feat: support shared pet care tasks and smooth daily page return
ci-cd / verify (push) Successful in 2m18s
ci-cd / deploy (push) Skipped

This commit is contained in:
2026-10-05 20:20:05 +08:00
parent fbc1eda561
commit 3175936266
16 changed files with 196 additions and 119 deletions
+8 -2
View File
@@ -360,7 +360,9 @@ func (a *API) adminTasks(c *gin.Context) {
ids, petIDs, taskIDs := []string{}, []string{}, []string{}
for _, t := range rows {
ids = append(ids, t.UserID)
petIDs = append(petIDs, t.PetID)
if t.PetID != nil {
petIDs = append(petIDs, *t.PetID)
}
taskIDs = append(taskIDs, t.ID)
}
names, err := a.userNames(ids)
@@ -388,7 +390,11 @@ func (a *API) adminTasks(c *gin.Context) {
}
items := make([]gin.H, 0, len(rows))
for _, t := range rows {
items = append(items, gin.H{"id": t.ID, "user_id": t.UserID, "user_name": names[t.UserID], "pet_id": t.PetID, "pet_name": petNames[t.PetID], "title": t.Title, "category": t.Category, "time_of_day": t.TimeOfDay, "repeat_rule": t.RepeatRule, "reminder_enabled": t.ReminderEnabled, "completed": done[t.ID], "created_at": t.CreatedAt})
petName := "全部宠物"
if t.PetID != nil {
petName = petNames[*t.PetID]
}
items = append(items, gin.H{"id": t.ID, "user_id": t.UserID, "user_name": names[t.UserID], "pet_id": t.PetID, "all_pets": t.PetID == nil, "pet_name": petName, "title": t.Title, "category": t.Category, "time_of_day": t.TimeOfDay, "repeat_rule": t.RepeatRule, "reminder_enabled": t.ReminderEnabled, "completed": done[t.ID], "created_at": t.CreatedAt})
}
page.Items = items
ok(c, page)
+23 -5
View File
@@ -16,7 +16,8 @@ import (
var clockPattern = regexp.MustCompile(`^([01][0-9]|2[0-3]):[0-5][0-9]$`)
type taskInput struct {
PetID string `json:"pet_id" binding:"required"`
PetID string `json:"pet_id"`
AllPets bool `json:"all_pets"`
Title string `json:"title" binding:"required"`
Category string `json:"category"`
TimeOfDay string `json:"time_of_day"`
@@ -50,7 +51,7 @@ func (a *API) listTasks(c *gin.Context) {
fail(c, http.StatusNotFound, "宠物不存在")
return
}
query = query.Where("pet_id = ?", petID)
query = query.Where("(pet_id = ? OR pet_id IS NULL)", petID)
}
var tasks []model.CareTask
if err := query.Order("time_of_day ASC").Find(&tasks).Error; err != nil {
@@ -76,7 +77,7 @@ func (a *API) listTasks(c *gin.Context) {
}
func taskView(task model.CareTask, completed bool) gin.H {
return gin.H{"id": task.ID, "pet_id": task.PetID, "title": task.Title,
return gin.H{"id": task.ID, "pet_id": task.PetID, "all_pets": task.PetID == nil, "title": task.Title,
"category": task.Category, "time_of_day": task.TimeOfDay,
"repeat_rule": task.RepeatRule, "reminder_enabled": task.ReminderEnabled,
"completed": completed}
@@ -84,9 +85,26 @@ func taskView(task model.CareTask, completed bool) gin.H {
func (a *API) buildTask(c *gin.Context, in taskInput) (model.CareTask, bool) {
task := model.CareTask{}
if _, err := a.ownedPet(actor(c), in.PetID); err != nil {
var petID *string
if in.AllPets {
if in.PetID != "" {
fail(c, http.StatusBadRequest, "全部宠物与单只宠物不能同时选择")
return task, false
}
var count int64
if err := a.db.Model(&model.Pet{}).Where("user_id = ? AND archived = false", actor(c)).Count(&count).Error; err != nil {
fail(c, http.StatusInternalServerError, "读取宠物失败")
return task, false
}
if count == 0 {
fail(c, http.StatusBadRequest, "请先添加宠物")
return task, false
}
} else if _, err := a.ownedPet(actor(c), in.PetID); err != nil {
fail(c, http.StatusNotFound, "宠物不存在")
return task, false
} else {
petID = &in.PetID
}
in.Title = strings.TrimSpace(in.Title)
if in.Title == "" || len([]rune(in.Title)) > 128 {
@@ -110,7 +128,7 @@ func (a *API) buildTask(c *gin.Context, in taskInput) (model.CareTask, bool) {
fail(c, http.StatusBadRequest, "重复规则无效")
return task, false
}
task = model.CareTask{UserID: actor(c), PetID: in.PetID, Title: in.Title,
task = model.CareTask{UserID: actor(c), PetID: petID, Title: in.Title,
Category: in.Category, TimeOfDay: in.TimeOfDay, RepeatRule: in.RepeatRule,
ReminderEnabled: in.ReminderEnabled}
return task, true
+55 -1
View File
@@ -47,6 +47,10 @@ func TestTaskCompletionPostgres(t *testing.T) {
t.Fatal(tx.Error)
}
defer tx.Rollback()
// Verify the nullable pet association migration without changing live schema.
if err := tx.AutoMigrate(&model.CareTask{}); err != nil {
t.Fatal(err)
}
user := model.User{OpenID: "completion-test:" + uuid.NewString(), Nickname: "测试家长"}
if err := tx.Create(&user).Error; err != nil {
t.Fatal(err)
@@ -55,11 +59,54 @@ func TestTaskCompletionPostgres(t *testing.T) {
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"}}
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)
request := func(method, route, id string, body gin.H, handler gin.HandlerFunc) *httptest.ResponseRecorder {
t.Helper()
encoded, _ := json.Marshal(body)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Set("actor_id", user.ID)
c.Params = gin.Params{{Key: "id", Value: id}}
c.Request = httptest.NewRequest(method, route, bytes.NewReader(encoded))
handler(c)
return rec
}
allBody := gin.H{"all_pets": true, "title": "全部换水", "category": "water", "time_of_day": "10:00", "repeat_rule": "daily"}
rec := request("POST", "/tasks", "", allBody, api.createTask)
if rec.Code != 201 {
t.Fatalf("all-pet creation: %d %s", rec.Code, rec.Body.String())
}
var allTask model.CareTask
if err := tx.Where("user_id = ? AND title = ?", user.ID, "全部换水").First(&allTask).Error; err != nil || allTask.PetID != nil {
t.Fatalf("all-pet association should be null: %v", err)
}
// Single-pet filters include shared tasks; other users' tasks remain isolated.
rec = request("GET", "/tasks?date=2026-10-05&pet_id="+pet.ID, "", nil, api.listTasks)
var listing struct {
Data struct {
Items []struct {
ID string `json:"id"`
AllPets bool `json:"all_pets"`
} `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &listing); err != nil || rec.Code != 200 || len(listing.Data.Items) != 3 {
t.Fatalf("pet filter omitted shared task: %s", rec.Body.String())
}
if rec = request("PUT", "/tasks/"+allTask.ID, allTask.ID, gin.H{"pet_id": pet.ID, "title": "单只换水"}, api.updateTask); rec.Code != 200 {
t.Fatalf("switch to single pet: %s", rec.Body.String())
}
if rec = request("PUT", "/tasks/"+allTask.ID, allTask.ID, allBody, api.updateTask); rec.Code != 200 {
t.Fatalf("switch to all pets: %s", rec.Body.String())
}
conflicting := gin.H{"all_pets": true, "pet_id": pet.ID, "title": "冲突选择"}
if rec = request("POST", "/tasks", "", conflicting, api.createTask); rec.Code != 400 {
t.Fatalf("ambiguous selection accepted: %s", rec.Body.String())
}
call := func(ids []string) int {
t.Helper()
body, _ := json.Marshal(gin.H{"date": "2026-10-05", "task_ids": ids})
@@ -88,4 +135,11 @@ func TestTaskCompletionPostgres(t *testing.T) {
if count != 2 {
t.Fatalf("retry duplicated completions: %d", count)
}
if status := call([]string{allTask.ID}); status != 200 {
t.Fatalf("all-pet completion status %d", status)
}
tx.Model(&model.TaskCompletion{}).Where("task_id = ?", allTask.ID).Count(&count)
if count != 1 {
t.Fatalf("shared task should have one completion: %d", count)
}
}
+7 -7
View File
@@ -117,13 +117,13 @@ type Pet struct {
type CareTask struct {
Base
UserID string `gorm:"type:uuid;index;not null" json:"user_id"`
PetID string `gorm:"type:uuid;index;not null" json:"pet_id"`
Title string `gorm:"size:128;not null" json:"title"`
Category string `gorm:"size:32;not null" json:"category"`
TimeOfDay string `gorm:"size:5;not null" json:"time_of_day"`
RepeatRule string `gorm:"size:16;not null;default:daily" json:"repeat_rule"`
ReminderEnabled bool `gorm:"not null;default:false" json:"reminder_enabled"`
UserID string `gorm:"type:uuid;index;not null" json:"user_id"`
PetID *string `gorm:"type:uuid;index" json:"pet_id"` // nil applies to all of the user's pets.
Title string `gorm:"size:128;not null" json:"title"`
Category string `gorm:"size:32;not null" json:"category"`
TimeOfDay string `gorm:"size:5;not null" json:"time_of_day"`
RepeatRule string `gorm:"size:16;not null;default:daily" json:"repeat_rule"`
ReminderEnabled bool `gorm:"not null;default:false" json:"reminder_enabled"`
}
type TaskCompletion struct {