feat: rebuild pet app and backend around core care flows

This commit is contained in:
2026-10-01 13:11:30 +08:00
parent 51ead8f411
commit bfcf8788a4
321 changed files with 4918 additions and 25199 deletions
+211
View File
@@ -0,0 +1,211 @@
package httpapi
import (
"net/http"
"regexp"
"strings"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"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" binding:"required"`
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 = ?", 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, "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{}
if _, err := a.ownedPet(actor(c), in.PetID); err != nil {
fail(c, http.StatusNotFound, "宠物不存在")
return task, false
}
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: in.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 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"`
}
if c.ShouldBindJSON(&in) != nil {
fail(c, http.StatusBadRequest, "完成状态参数无效")
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, "取消完成失败")
return
}
ok(c, gin.H{"id": task.ID, "date": in.Date, "completed": in.Completed})
}