Add one-way task completion and exportable pet identity cards
This commit is contained in:
@@ -54,6 +54,7 @@ func (a *API) Register(r *gin.Engine) {
|
||||
user.PUT("/tasks/:id", a.updateTask)
|
||||
user.DELETE("/tasks/:id", a.deleteTask)
|
||||
user.PUT("/tasks/:id/completion", a.setTaskCompletion)
|
||||
user.PUT("/tasks/completions", a.completeTasks)
|
||||
user.GET("/walks", a.listWalks)
|
||||
user.POST("/walks", a.startWalk)
|
||||
user.GET("/walks/:id", a.getWalk)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
@@ -179,11 +180,6 @@ func (a *API) deleteTask(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (a *API) setTaskCompletion(c *gin.Context) {
|
||||
var task model.CareTask
|
||||
if !validID(c.Param("id")) || a.db.Where("id = ? AND user_id = ?", c.Param("id"), actor(c)).First(&task).Error != nil {
|
||||
fail(c, http.StatusNotFound, "任务不存在")
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Date string `json:"date" binding:"required"`
|
||||
Completed bool `json:"completed"`
|
||||
@@ -192,20 +188,82 @@ func (a *API) setTaskCompletion(c *gin.Context) {
|
||||
fail(c, http.StatusBadRequest, "完成状态参数无效")
|
||||
return
|
||||
}
|
||||
if !in.Completed {
|
||||
fail(c, http.StatusBadRequest, "已完成的任务不能取消完成")
|
||||
return
|
||||
}
|
||||
var task model.CareTask
|
||||
if !validID(c.Param("id")) || a.db.Where("id = ? AND user_id = ?", c.Param("id"), actor(c)).First(&task).Error != nil {
|
||||
fail(c, http.StatusNotFound, "任务不存在")
|
||||
return
|
||||
}
|
||||
date, err := parseDay(in.Date)
|
||||
if err != nil || !due(task, date) {
|
||||
fail(c, http.StatusBadRequest, "该日期没有这项任务")
|
||||
return
|
||||
}
|
||||
if in.Completed {
|
||||
item := model.TaskCompletion{TaskID: task.ID, UserID: actor(c), Date: date, CompletedAt: time.Now()}
|
||||
if err := a.db.Where("task_id = ? AND date = ?", task.ID, date).FirstOrCreate(&item).Error; err != nil {
|
||||
fail(c, http.StatusInternalServerError, "保存完成状态失败")
|
||||
return
|
||||
}
|
||||
} else if err := a.db.Where("task_id = ? AND date = ?", task.ID, date).Delete(&model.TaskCompletion{}).Error; err != nil {
|
||||
fail(c, http.StatusInternalServerError, "取消完成失败")
|
||||
item := model.TaskCompletion{TaskID: task.ID, UserID: actor(c), Date: date, CompletedAt: time.Now()}
|
||||
if err := a.db.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "task_id"}, {Name: "date"}}, DoNothing: true}).Create(&item).Error; err != nil {
|
||||
fail(c, http.StatusInternalServerError, "保存完成状态失败")
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"id": task.ID, "date": in.Date, "completed": in.Completed})
|
||||
ok(c, gin.H{"id": task.ID, "date": in.Date, "completed": true})
|
||||
}
|
||||
|
||||
// Complete the displayed tasks atomically, allowing safe retries and concurrent taps.
|
||||
func (a *API) completeTasks(c *gin.Context) {
|
||||
var in struct {
|
||||
Date string `json:"date" binding:"required"`
|
||||
TaskIDs []string `json:"task_ids" binding:"required,min=1,max=200"`
|
||||
}
|
||||
if c.ShouldBindJSON(&in) != nil {
|
||||
fail(c, http.StatusBadRequest, "请选择 1 至 200 项任务")
|
||||
return
|
||||
}
|
||||
date, err := parseDay(in.Date)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, "日期格式应为 YYYY-MM-DD")
|
||||
return
|
||||
}
|
||||
ids := make([]string, 0, len(in.TaskIDs))
|
||||
seen := map[string]bool{}
|
||||
for _, id := range in.TaskIDs {
|
||||
if !validID(id) {
|
||||
fail(c, http.StatusBadRequest, "任务 ID 无效")
|
||||
return
|
||||
}
|
||||
if !seen[id] {
|
||||
ids = append(ids, id)
|
||||
seen[id] = true
|
||||
}
|
||||
}
|
||||
invalid := false
|
||||
err = a.db.Transaction(func(tx *gorm.DB) error {
|
||||
var tasks []model.CareTask
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id IN ? AND user_id = ?", ids, actor(c)).Order("id").Find(&tasks).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(tasks) != len(ids) {
|
||||
invalid = true
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
items := make([]model.TaskCompletion, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
if !due(task, date) {
|
||||
invalid = true
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
items = append(items, model.TaskCompletion{TaskID: task.ID, UserID: actor(c), Date: date, CompletedAt: time.Now()})
|
||||
}
|
||||
return tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "task_id"}, {Name: "date"}}, DoNothing: true}).Create(&items).Error
|
||||
})
|
||||
if err != nil {
|
||||
if invalid {
|
||||
fail(c, http.StatusBadRequest, "包含不存在或该日期无需完成的任务")
|
||||
} else {
|
||||
fail(c, http.StatusInternalServerError, "完成任务失败,请重试")
|
||||
}
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"task_ids": ids, "date": in.Date, "completed": true})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sundynix/pets-be/internal/config"
|
||||
"github.com/sundynix/pets-be/internal/database"
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
|
||||
func TestTaskCompletionCannotBeUndone(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPut, "/completion", bytes.NewBufferString(`{"date":"2026-10-05","completed":false}`))
|
||||
api := New(nil, nil, &config.Config{})
|
||||
api.setTaskCompletion(c)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("undo status %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// All fixtures and writes are rolled back, including inner batch transactions.
|
||||
func TestTaskCompletionPostgres(t *testing.T) {
|
||||
if os.Getenv("PETS_ADMIN_INTEGRATION") != "1" {
|
||||
t.Skip("PostgreSQL integration disabled")
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db, err := database.Open(cfg.Postgres, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, _ := db.DB()
|
||||
defer sqlDB.Close()
|
||||
tx := db.Begin()
|
||||
if tx.Error != nil {
|
||||
t.Fatal(tx.Error)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
user := model.User{OpenID: "completion-test:" + uuid.NewString(), Nickname: "测试家长"}
|
||||
if err := tx.Create(&user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pet := model.Pet{UserID: user.ID, Name: "测试毛孩", Species: "dog", Gender: "male"}
|
||||
if err := tx.Create(&pet).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tasks := []model.CareTask{{UserID: user.ID, PetID: pet.ID, Title: "喝水", Category: "water", TimeOfDay: "08:00", RepeatRule: "daily"}, {UserID: user.ID, PetID: pet.ID, Title: "喂食", Category: "food", TimeOfDay: "09:00", RepeatRule: "daily"}}
|
||||
if err := tx.Create(&tasks).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
api := New(tx, nil, cfg)
|
||||
call := func(ids []string) int {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(gin.H{"date": "2026-10-05", "task_ids": ids})
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Set("actor_id", user.ID)
|
||||
c.Request = httptest.NewRequest(http.MethodPut, "/tasks/completions", bytes.NewReader(body))
|
||||
api.completeTasks(c)
|
||||
return rec.Code
|
||||
}
|
||||
if status := call([]string{tasks[0].ID, uuid.NewString()}); status != 400 {
|
||||
t.Fatalf("invalid batch status %d", status)
|
||||
}
|
||||
var count int64
|
||||
tx.Model(&model.TaskCompletion{}).Where("user_id = ?", user.ID).Count(&count)
|
||||
if count != 0 {
|
||||
t.Fatal("invalid batch partially completed")
|
||||
}
|
||||
ids := []string{tasks[0].ID, tasks[1].ID}
|
||||
for range 2 {
|
||||
if status := call(ids); status != 200 {
|
||||
t.Fatalf("batch status %d", status)
|
||||
}
|
||||
}
|
||||
tx.Model(&model.TaskCompletion{}).Where("user_id = ?", user.ID).Count(&count)
|
||||
if count != 2 {
|
||||
t.Fatalf("retry duplicated completions: %d", count)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user