package httpapi import ( "net/http" "regexp" "strings" "time" "github.com/gin-gonic/gin" "gorm.io/gorm" "gorm.io/gorm/clause" "github.com/sundynix/pets-be/internal/model" ) var clockPattern = regexp.MustCompile(`^([01][0-9]|2[0-3]):[0-5][0-9]$`) type taskInput struct { 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"` RepeatRule string `json:"repeat_rule"` ReminderEnabled bool `json:"reminder_enabled"` } func due(task model.CareTask, date time.Time) bool { switch task.RepeatRule { case "daily": return true case "weekdays": return date.Weekday() != time.Saturday && date.Weekday() != time.Sunday case "weekends": return date.Weekday() == time.Saturday || date.Weekday() == time.Sunday default: return false } } func (a *API) listTasks(c *gin.Context) { dateString := c.DefaultQuery("date", time.Now().Format("2006-01-02")) date, err := parseDay(dateString) if err != nil { fail(c, http.StatusBadRequest, "日期格式应为 YYYY-MM-DD") return } query := a.db.Where("user_id = ?", actor(c)) if petID := c.Query("pet_id"); petID != "" { if _, err := a.ownedPet(actor(c), petID); err != nil { fail(c, http.StatusNotFound, "宠物不存在") return } 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 { fail(c, http.StatusInternalServerError, "读取任务失败") return } var completions []model.TaskCompletion if err := a.db.Where("user_id = ? AND date = ?", actor(c), date).Find(&completions).Error; err != nil { fail(c, http.StatusInternalServerError, "读取完成状态失败") return } done := make(map[string]bool, len(completions)) for _, item := range completions { done[item.TaskID] = true } items := make([]gin.H, 0, len(tasks)) for _, task := range tasks { if due(task, date) { items = append(items, taskView(task, done[task.ID])) } } ok(c, gin.H{"date": dateString, "items": items}) } func taskView(task model.CareTask, completed bool) gin.H { 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} } func (a *API) buildTask(c *gin.Context, in taskInput) (model.CareTask, bool) { task := model.CareTask{} 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 { fail(c, http.StatusBadRequest, "任务名称无效") return task, false } if in.Category == "" { in.Category = "other" } if in.TimeOfDay == "" { in.TimeOfDay = "09:00" } if !clockPattern.MatchString(in.TimeOfDay) { fail(c, http.StatusBadRequest, "时间格式应为 HH:MM") return task, false } if in.RepeatRule == "" { in.RepeatRule = "daily" } if in.RepeatRule != "daily" && in.RepeatRule != "weekdays" && in.RepeatRule != "weekends" { fail(c, http.StatusBadRequest, "重复规则无效") return task, false } 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 } func (a *API) createTask(c *gin.Context) { var in taskInput if c.ShouldBindJSON(&in) != nil { fail(c, http.StatusBadRequest, "任务资料无效") return } task, valid := a.buildTask(c, in) if !valid { return } if err := a.db.Create(&task).Error; err != nil { fail(c, http.StatusInternalServerError, "添加任务失败") return } created(c, taskView(task, false)) } func (a *API) updateTask(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 taskInput if c.ShouldBindJSON(&in) != nil { fail(c, http.StatusBadRequest, "任务资料无效") return } updated, valid := a.buildTask(c, in) if !valid { return } task.PetID, task.Title, task.Category = updated.PetID, updated.Title, updated.Category task.TimeOfDay, task.RepeatRule, task.ReminderEnabled = updated.TimeOfDay, updated.RepeatRule, updated.ReminderEnabled if a.db.Save(&task).Error != nil { fail(c, http.StatusInternalServerError, "更新任务失败") return } ok(c, taskView(task, false)) } func (a *API) deleteTask(c *gin.Context) { if !validID(c.Param("id")) { fail(c, http.StatusBadRequest, "任务 ID 无效") return } err := a.db.Transaction(func(tx *gorm.DB) error { var task model.CareTask if err := tx.Where("id = ? AND user_id = ?", c.Param("id"), actor(c)).First(&task).Error; err != nil { return err } if err := tx.Where("task_id = ?", task.ID).Delete(&model.TaskCompletion{}).Error; err != nil { return err } return tx.Delete(&task).Error }) if err != nil { fail(c, http.StatusNotFound, "任务不存在或删除失败") return } ok(c, gin.H{"deleted": true}) } func (a *API) setTaskCompletion(c *gin.Context) { var in struct { Date string `json:"date" binding:"required"` Completed bool `json:"completed"` } if c.ShouldBindJSON(&in) != nil { 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 } 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": 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}) }