Files
Blizzard 3175936266
ci-cd / verify (push) Successful in 2m18s
ci-cd / deploy (push) Skipped
feat: support shared pet care tasks and smooth daily page return
2026-10-05 20:20:05 +08:00

288 lines
8.7 KiB
Go

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})
}