refactor(be): 今日任务并进计划节点,养护方案改成可选、按重复规则配
## 三个问题,一起解决 你问「模版要不要做成可选」,摸下来发现底下有三个更硬的: **① 根本没有多套模版可选。** 模板表唯一索引是 (species, stage) —— 同一个「猫 × 幼年期」只能有一套。「让用户选模版」在数据模型上做不到。 去掉唯一索引,加 Name / Summary / Sort / Enabled。 **② 今日任务和计划节点是两套,而且是我上一轮改出来的。** DailyTask 模板 kind=task 生成,ensureDayTasks 每天把昨天那份复制到今天 PlanTask 模板 kind=plan 生成,Day 偏移 计划改成月度管理 + 加了「应用到每天」之后,两者做的是同一件事, 但首页读 DailyTask、计划页管 PlanTask —— 用户在计划页加一条「每天喂药」, 首页今日任务里看不到。 DailyTask 退役。task.go 重写成 PlanTask 的一层「按日期取」的门面, /pets/:id/tasks 路径不变,小程序端不用为这次合并改调用。 PlanTask 加 Priority(首页那份要能标出哪条重要)。 顺手删掉重复的 CreateTask/UpdateTask/DeleteTask —— /plan-tasks 那套已经 覆盖了,留着两套会出现「首页管理弹层加的任务」和「计划页加的节点」进不同的表。 **③ 模板的 Day 偏移和月度计划对不上。** 月底建档的话 day=14/21/30 全落到 下个月,用户建完档打开计划页发现本月是空的。 改成重复规则:once(偏移 N 天,疫苗序列要用)/ daily / weekly(星期几) / monthly(几号)。套用 = 展开成目标月份的具体日期。 「每月 31 号」在 2 月按月末算,不然那个月直接没有。 列名用 repeat_rule 不用 repeat —— repeat 是 MySQL 保留字(和 group_key 同理)。 ## 展开成实体行,不是「存规则读时展开」 勾选状态是按天的。存规则得再开一张表记「哪天打过勾」,而且用户把 「每周二梳毛」这周挪到周三就要引入例外记录。一次展开成行,增删改和打勾全免费。 代价是套用后模板和计划断开:后台改模板不影响已套用的宠物。这是有意的—— 用户已经在自己的计划上勾了几十个勾,后台一改就被覆盖才是灾难。 ## 建档不再自动铺 seedPetDefaults 只建必要提醒(疫苗、驱虫)。漏针是真会出事的,而且它们是 「到期提醒」不占计划页的格子。其余交给用户挑方案。 自动铺的问题是:用户还没看懂这个 App 是干什么的,首页已经躺着十几条他没 答应过的任务,第一反应是「这些是什么」而不是「我该做什么」。 **改完发现还有第二条自动铺的路径**:newStagePlan 在建计划壳子时顺手把模板 铺成节点。所以我改完 seedPetDefaults 之后,建档后首页仍然躺着 5 条任务。 惰性生成尤其难查——不是建档时发生的,是第一次读计划才发生的。 ## 方案摘要按重复方式分开报数 一开始只报一个总数,「10 条」套用一整月展开成 130 条,用户会以为出 bug。 现在分成 每天 4 · 每周 0 · 每月 0 · 一次性 6 · 提醒 4。 ## 数据迁移(预生产库,已执行) 模板明细 kind=task → plan + repeat=daily 28 条 模板明细 kind=plan 补 repeat=once 44 条 8 套方案补名字和摘要 DailyTask → PlanTask 34 条,跳过 0 DailyTask 表的行**没删**,先留着当备份。 迁移第一版又踩了「列还没 AutoMigrate 出来就跑」那个坑:Count 静默返回 0, 我以为「没有需要补的」。第二版加了 HasColumn 前置检查才发现 44 条没转。 和上次 record_types.fields 回填是同一个错。 ## 验证(预生产库实跑) 今日任务/计划页今天 4 条 vs 4 条,一致 建档后 0 任务 / 0 计划节点 / 4 条必要提醒 套用本月 从今天到月末,4 条(7/30-7/31 两天) 重复套用 0 条新增(按「日期+标题」去重) 套用下月 130 条覆盖 31 天,每天 4.2 条 全部完成 5 → 0 未做 ## 还没做(下一步) 前端:建档第 4 步选方案、计划页空月份的套用入口、首页「管理」改跳计划页、 弹层删 manageTasks;后台模板页要支持多套 + 重复规则编辑。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -26,66 +26,9 @@ func (h *Handler) ListTasks(c *gin.Context) {
|
||||
response.OK(c, tasks)
|
||||
}
|
||||
|
||||
type taskReq struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Priority string `json:"priority"`
|
||||
SheetType string `json:"sheet_type"`
|
||||
Date string `json:"date"` // 仅新增用,YYYY-MM-DD,默认今天
|
||||
}
|
||||
|
||||
func (r taskReq) toInput() service.TaskInput {
|
||||
in := service.TaskInput{Title: r.Title, Description: r.Description, Priority: r.Priority, SheetType: r.SheetType}
|
||||
if r.Date != "" {
|
||||
if t, err := time.ParseInLocation("2006-01-02", r.Date, time.Local); err == nil {
|
||||
in.Date = &t
|
||||
}
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
// CreateTask POST /api/pets/:id/tasks
|
||||
func (h *Handler) CreateTask(c *gin.Context) {
|
||||
var req taskReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailParams(c, err.Error())
|
||||
return
|
||||
}
|
||||
if req.Title == "" {
|
||||
response.FailParams(c, "任务标题不能为空")
|
||||
return
|
||||
}
|
||||
t, err := h.svc.CreateTask(middleware.UserID(c), idParam(c, "id"), req.toInput())
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, t)
|
||||
}
|
||||
|
||||
// UpdateTask PUT /api/tasks/:id
|
||||
func (h *Handler) UpdateTask(c *gin.Context) {
|
||||
var req taskReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailParams(c, err.Error())
|
||||
return
|
||||
}
|
||||
t, err := h.svc.UpdateTask(middleware.UserID(c), idParam(c, "id"), req.toInput())
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, t)
|
||||
}
|
||||
|
||||
// DeleteTask DELETE /api/tasks/:id
|
||||
func (h *Handler) DeleteTask(c *gin.Context) {
|
||||
if err := h.svc.DeleteTask(middleware.UserID(c), idParam(c, "id")); err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"deleted": true})
|
||||
}
|
||||
// 任务的增删改统一走 /plan-tasks 那套 —— 「今日任务」和「计划节点」合并之后,
|
||||
// /tasks 这边只保留「读某天的」和「勾选」,重复的 CRUD 删掉了。
|
||||
// 留着两套会出现「首页管理弹层加的任务」和「计划页加的节点」进不同的表。
|
||||
|
||||
// ToggleTask POST /api/tasks/:id/toggle
|
||||
func (h *Handler) ToggleTask(c *gin.Context) {
|
||||
@@ -245,3 +188,33 @@ func (h *Handler) PlanMonthStatus(c *gin.Context) {
|
||||
}
|
||||
response.OK(c, st)
|
||||
}
|
||||
|
||||
// CareTemplateOptions GET /api/care-templates?species=cat&stage=幼年期
|
||||
// 建档最后一步和计划页的「套用方案」都读这个
|
||||
func (h *Handler) CareTemplateOptions(c *gin.Context) {
|
||||
response.OK(c, h.svc.ListTemplateOptions(c.Query("species"), c.Query("stage")))
|
||||
}
|
||||
|
||||
type applyTemplateReq struct {
|
||||
TemplateID string `json:"template_id"`
|
||||
MonthOffset int `json:"month_offset"` // 0 本月,1 下月
|
||||
}
|
||||
|
||||
// ApplyTemplate POST /api/pets/:id/apply-template
|
||||
func (h *Handler) ApplyTemplate(c *gin.Context) {
|
||||
var req applyTemplateReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailParams(c, err.Error())
|
||||
return
|
||||
}
|
||||
if req.TemplateID == "" {
|
||||
response.FailParams(c, "缺少方案 id")
|
||||
return
|
||||
}
|
||||
res, err := h.svc.ApplyTemplate(middleware.UserID(c), idParam(c, "id"), req.TemplateID, req.MonthOffset)
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, res)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package model
|
||||
|
||||
// 养护模板:按「物种 × 阶段」配置建档时生成的今日任务 / 30天计划 / 提醒。
|
||||
// 管理员可 AI 生成草稿后修改保存;建档时按宠物物种阶段套用,查不到回退内置默认。
|
||||
// 养护方案:一套「这个物种、这个阶段该做哪些事」的配置。
|
||||
// 后台可以给同一个 (species, stage) 配多套,建档时让用户挑一套套用,
|
||||
// 也可以不套用、自己排。原来是一个组合只能有一套(唯一索引卡着),
|
||||
// 「让用户选模版」这件事在数据模型上做不到。
|
||||
|
||||
// 物种取值
|
||||
const (
|
||||
@@ -9,37 +11,64 @@ const (
|
||||
SpeciesDog = "dog"
|
||||
)
|
||||
|
||||
// 模板明细的类别
|
||||
// 明细的类别。原来还有个 task(今日任务),已经和 plan 合并——
|
||||
// 「今天要做的事」就是「今天那一天的计划节点」,两套并存的结果是
|
||||
// 用户在计划页加的「每天喂药」在首页看不到。
|
||||
const (
|
||||
CareKindTask = "task" // 今日任务
|
||||
CareKindPlan = "plan" // 30天计划节点
|
||||
CareKindReminder = "reminder" // 提醒
|
||||
CareKindPlan = "plan" // 计划节点(含每天/每周/每月重复)
|
||||
CareKindReminder = "reminder" // 到期提醒(疫苗、驱虫这类有固定周期的)
|
||||
)
|
||||
|
||||
// CareTemplate 一套模板 = 一个 (species, stage) 组合
|
||||
// 重复规则。月度计划要的是「多久做一次」,不是「第几天做」——
|
||||
// 原来只有 Day 偏移,月底建档的话 day=14/21/30 全落到下个月,
|
||||
// 用户建完档打开计划页发现本月是空的。
|
||||
const (
|
||||
RepeatOnce = "once" // 一次性,按 Day 偏移(到家第 N 天,疫苗序列要用)
|
||||
RepeatDaily = "daily" // 每天
|
||||
RepeatWeekly = "weekly" // 每周某几天,看 Weekdays
|
||||
RepeatMonthly = "monthly" // 每月某天,看 MonthDay
|
||||
)
|
||||
|
||||
// CareTemplate 一套方案
|
||||
type CareTemplate struct {
|
||||
Base
|
||||
Species string `gorm:"size:16;uniqueIndex:idx_species_stage" json:"species"` // cat | dog
|
||||
Stage string `gorm:"size:48;uniqueIndex:idx_species_stage" json:"stage"` // 与 onboarding 阶段一致
|
||||
Items []CareTemplateItem `gorm:"foreignKey:TemplateID" json:"items"`
|
||||
Species string `gorm:"size:16;index" json:"species"` // cat | dog
|
||||
Stage string `gorm:"size:48;index" json:"stage"` // 与 onboarding 阶段一致
|
||||
// Name 方案名。建档时用户看到的就是这个,要能一眼分辨
|
||||
// (「幼猫标准照护」/「幼猫省心版」)
|
||||
Name string `gorm:"size:64" json:"name"`
|
||||
// Summary 一句话摘要,选择列表里展示
|
||||
Summary string `gorm:"size:255" json:"summary"`
|
||||
Sort int `json:"sort"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
|
||||
Items []CareTemplateItem `gorm:"foreignKey:TemplateID" json:"items"`
|
||||
}
|
||||
|
||||
// CareTemplateItem 模板明细,kind 区分三类;不同类只用到部分字段
|
||||
// CareTemplateItem 明细,kind 区分两类;不同类只用到部分字段
|
||||
type CareTemplateItem struct {
|
||||
Base
|
||||
TemplateID string `gorm:"size:24;index" json:"template_id"`
|
||||
Kind string `gorm:"size:16;index" json:"kind"` // task | plan | reminder
|
||||
TemplateID string `gorm:"size:24;index" json:"template_id"`
|
||||
Kind string `gorm:"size:16;index" json:"kind"` // plan | reminder
|
||||
Sort int `json:"sort"`
|
||||
Title string `gorm:"size:128" json:"title"`
|
||||
Description string `gorm:"size:512" json:"description"`
|
||||
|
||||
// task 专用
|
||||
Priority string `gorm:"size:16" json:"priority"` // 重要 / 普通(空)
|
||||
SheetType string `gorm:"size:32" json:"sheet_type"` // 关联记录弹层:weight/poop/food/vaccine...
|
||||
|
||||
// plan 专用
|
||||
Day int `json:"day"` // 第 N 天(0=今天)
|
||||
DayLabel string `gorm:"size:32" json:"day_label"` // 展示标签
|
||||
Priority string `gorm:"size:16" json:"priority"` // 重要 / 普通(空)
|
||||
SheetType string `gorm:"size:32" json:"sheet_type"` // 关联记录类型 code:weight/poop/bath...
|
||||
// Repeat 列名用 repeat_rule:repeat 是 MySQL 保留字,GORM 虽然会加反引号,
|
||||
// 但手写 SQL 排查时很容易踩(和 group_key 同样的理由)
|
||||
Repeat string `gorm:"column:repeat_rule;size:16" json:"repeat"` // once | daily | weekly | monthly
|
||||
Day int `json:"day"` // once:从套用日起第 N 天(0=当天)
|
||||
// DayLabel 留着:后台的 AI 生成模板那条链路在产出它。月度计划里日期是绝对的
|
||||
// 不需要「第 N 天」标签,但删它要连着改 AI 解析,不在这次范围内
|
||||
DayLabel string `gorm:"size:32" json:"day_label"`
|
||||
// Weekdays weekly 用:逗号分隔的 1-7(1=周一)。
|
||||
// 存字符串不另开表——一条模板明细最多 7 个数字,开表换来的只是查询灵活性,
|
||||
// 而这份数据从来只整条读出来展开
|
||||
Weekdays string `gorm:"size:16" json:"weekdays"`
|
||||
MonthDay int `json:"month_day"` // monthly 用:几号(1-31),超出当月天数按月末算
|
||||
|
||||
// reminder 专用
|
||||
ReminderType string `gorm:"size:32" json:"reminder_type"` // vaccine/deworm/weight/monthlyReport
|
||||
|
||||
@@ -37,6 +37,8 @@ type PlanTask struct {
|
||||
Title string `gorm:"size:128" json:"title"`
|
||||
Description string `gorm:"size:512" json:"description"`
|
||||
SheetType string `gorm:"size:32" json:"sheet_type"`
|
||||
Done bool `json:"done"`
|
||||
// Priority 从 DailyTask 合并过来的:首页那份「今天要做的事」要能标出哪条重要
|
||||
Priority string `gorm:"size:16" json:"priority"`
|
||||
Done bool `json:"done"`
|
||||
Date string `gorm:"-" json:"date"` // 计算字段:计划开始日 + Day
|
||||
}
|
||||
|
||||
@@ -66,17 +66,17 @@ func registerUserAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manage
|
||||
g.DELETE("/records/:id", h.DeleteRecord)
|
||||
|
||||
g.GET("/pets/:id/tasks", h.ListTasks)
|
||||
g.POST("/pets/:id/tasks", h.CreateTask)
|
||||
g.POST("/pets/:id/tasks/complete-all", h.CompleteAllTasks)
|
||||
g.POST("/tasks/:id/toggle", h.ToggleTask)
|
||||
g.PUT("/tasks/:id", h.UpdateTask)
|
||||
g.DELETE("/tasks/:id", h.DeleteTask)
|
||||
|
||||
g.GET("/pets/:id/home-summary", h.HomeSummary)
|
||||
|
||||
g.GET("/pets/:id/plan", h.GetPlan)
|
||||
g.GET("/pets/:id/plan/calendar", h.PlanCalendar)
|
||||
g.GET("/pets/:id/plan/month-status", h.PlanMonthStatus)
|
||||
// 养护方案:列出可选的、套用一套到某个月
|
||||
g.GET("/care-templates", h.CareTemplateOptions)
|
||||
g.POST("/pets/:id/apply-template", h.ApplyTemplate)
|
||||
g.GET("/pets/:id/day-plan", h.DayPlan)
|
||||
g.POST("/pets/:id/ai-plan", h.CreateAIPlan)
|
||||
g.POST("/ai-plan/:id/apply", h.ApplyAIPlan)
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 套用养护方案:把模板里的重复规则展开成某个月的计划节点。
|
||||
//
|
||||
// 为什么要展开成一条条实体行、而不是「存规则、读时展开」:
|
||||
// 勾选状态是按天的。存规则的话得再开一张表记「哪天打过勾」,
|
||||
// 而且用户改一条(比如把「每周二梳毛」这周挪到周三)就要引入例外记录。
|
||||
// 一次展开成行,增删改和打勾全都免费。
|
||||
//
|
||||
// 代价是套用之后模板和计划就断开了:后台改模板不会影响已套用的宠物。
|
||||
// 这是有意的——用户已经在自己的计划上勾了几十个勾,后台一改就被覆盖才是灾难。
|
||||
|
||||
// TemplateOption 建档/计划页上给用户挑的一套方案
|
||||
type TemplateOption struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Summary string `json:"summary"`
|
||||
// 按重复方式分开报数。只给一个总数会骗人:10 条明细里有 4 条是「每天」,
|
||||
// 套用一整月会展开成 130 条节点,用户看到「10 条」再看到 130 条会以为出 bug 了
|
||||
Daily int `json:"daily"` // 每天做的
|
||||
Weekly int `json:"weekly"` // 每周做的
|
||||
Once int `json:"once"` // 一次性的
|
||||
Monthly int `json:"monthly"` // 每月做的
|
||||
Reminds int `json:"reminders"` // 会建的提醒
|
||||
}
|
||||
|
||||
// ListTemplateOptions 某物种阶段下可选的方案。空列表意味着后台没配,
|
||||
// 前端要显示「暂无方案,可以自己排」而不是转圈
|
||||
func (s *Service) ListTemplateOptions(species, stage string) []TemplateOption {
|
||||
var tpls []model.CareTemplate
|
||||
s.db.Preload("Items").
|
||||
Where("species = ? AND stage = ? AND enabled = ?", species, stage, true).
|
||||
Order("sort asc, id asc").Find(&tpls)
|
||||
out := make([]TemplateOption, 0, len(tpls))
|
||||
for _, t := range tpls {
|
||||
o := TemplateOption{ID: t.ID, Name: t.Name, Summary: t.Summary}
|
||||
if o.Name == "" {
|
||||
o.Name = t.Stage + "养护方案" // 老数据没名字,兜一个别显示空白
|
||||
}
|
||||
for _, it := range t.Items {
|
||||
switch {
|
||||
case it.Kind == model.CareKindReminder:
|
||||
o.Reminds++
|
||||
case it.Repeat == model.RepeatDaily:
|
||||
o.Daily++
|
||||
case it.Repeat == model.RepeatWeekly:
|
||||
o.Weekly++
|
||||
case it.Repeat == model.RepeatMonthly:
|
||||
o.Monthly++
|
||||
default:
|
||||
o.Once++
|
||||
}
|
||||
}
|
||||
out = append(out, o)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// weekdaysOf 解析 "1,3,5" → {1:true,3:true,5:true},1=周一
|
||||
func weekdaysOf(s string) map[int]bool {
|
||||
out := map[int]bool{}
|
||||
for _, p := range strings.Split(s, ",") {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(p))
|
||||
if err == nil && n >= 1 && n <= 7 {
|
||||
out[n] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isoWeekday 把 Go 的 Sunday=0 换成 1=周一 … 7=周日
|
||||
func isoWeekday(t time.Time) int {
|
||||
d := int(t.Weekday())
|
||||
if d == 0 {
|
||||
return 7
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// expandItem 把一条明细展开成若干个日期。
|
||||
// from/to 是套用窗口(通常是「今天」到「本月最后一天」)
|
||||
func expandItem(it model.CareTemplateItem, from, to time.Time) []time.Time {
|
||||
var out []time.Time
|
||||
switch it.Repeat {
|
||||
case model.RepeatDaily:
|
||||
for d := from; !d.After(to); d = d.AddDate(0, 0, 1) {
|
||||
out = append(out, d)
|
||||
}
|
||||
case model.RepeatWeekly:
|
||||
wd := weekdaysOf(it.Weekdays)
|
||||
if len(wd) == 0 {
|
||||
return nil // 配了每周却没选星期几,展开不出东西,跳过比排错天好
|
||||
}
|
||||
for d := from; !d.After(to); d = d.AddDate(0, 0, 1) {
|
||||
if wd[isoWeekday(d)] {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
case model.RepeatMonthly:
|
||||
md := it.MonthDay
|
||||
if md < 1 {
|
||||
md = 1
|
||||
}
|
||||
// 逐月走,因为窗口可能跨月
|
||||
for cur := time.Date(from.Year(), from.Month(), 1, 0, 0, 0, 0, from.Location()); !cur.After(to); cur = cur.AddDate(0, 1, 0) {
|
||||
last := cur.AddDate(0, 1, -1).Day()
|
||||
day := md
|
||||
if day > last {
|
||||
day = last // 「每月 31 号」在 2 月按月末算,不然这个月直接没有
|
||||
}
|
||||
d := time.Date(cur.Year(), cur.Month(), day, 0, 0, 0, 0, from.Location())
|
||||
if !d.Before(from) && !d.After(to) {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
default: // once:从窗口起点偏移 N 天
|
||||
d := from.AddDate(0, 0, it.Day)
|
||||
// 偏移出窗口的直接丢掉。硬塞到窗口末尾会让「到家第 14 天驱虫」
|
||||
// 变成「月底驱虫」,日期错了比没有更糟
|
||||
if !d.Before(from) && !d.After(to) {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ApplyTemplateResult 套用结果,给前端一句反馈用
|
||||
type ApplyTemplateResult struct {
|
||||
Plans int `json:"plans"` // 排进了几条计划
|
||||
Reminders int `json:"reminders"` // 建了几条提醒
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
}
|
||||
|
||||
// ApplyTemplate 把一套方案套用到某只宠物的某个月。
|
||||
// monthOffset: 0 本月,1 下月。本月只从今天算起——给用户排一堆昨天的事没意义
|
||||
func (s *Service) ApplyTemplate(userID, petID, templateID string, monthOffset int) (*ApplyTemplateResult, error) {
|
||||
pet, err := s.ownedPet(userID, petID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var tpl model.CareTemplate
|
||||
if e := s.db.Preload("Items", func(db *gorm.DB) *gorm.DB { return db.Order("sort, id") }).
|
||||
First(&tpl, "id = ?", templateID).Error; e != nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
first, last := monthBounds(monthOffset)
|
||||
from := first
|
||||
if monthOffset == 0 {
|
||||
today := time.Now()
|
||||
t := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, time.Local)
|
||||
if t.After(from) {
|
||||
from = t
|
||||
}
|
||||
}
|
||||
if from.After(last) {
|
||||
return nil, errors.New("这个月已经过完了")
|
||||
}
|
||||
|
||||
plan, err := s.planOf(userID, petID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
start := time.Date(plan.StartDate.Year(), plan.StartDate.Month(), plan.StartDate.Day(),
|
||||
0, 0, 0, 0, time.Local)
|
||||
|
||||
// 已经有的节点按「日期+标题」记下来,套用不产生重复。
|
||||
// 用户可能先自己排了「每天喂药」,再套一套含同样一条的方案
|
||||
var exist []model.PlanTask
|
||||
s.db.Where("plan_id = ?", plan.ID).Find(&exist)
|
||||
seen := map[string]bool{}
|
||||
for _, t := range exist {
|
||||
d := start.AddDate(0, 0, t.Day)
|
||||
seen[d.Format("2006-01-02")+"|"+t.Title] = true
|
||||
}
|
||||
|
||||
var rows []model.PlanTask
|
||||
for _, it := range tpl.Items {
|
||||
if it.Kind == model.CareKindReminder {
|
||||
continue
|
||||
}
|
||||
for _, d := range expandItem(it, from, last) {
|
||||
key := d.Format("2006-01-02") + "|" + it.Title
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
day := int(d.Sub(start).Hours() / 24)
|
||||
if day < 0 {
|
||||
continue // 早于计划开始日的排不进去
|
||||
}
|
||||
rows = append(rows, model.PlanTask{
|
||||
PlanID: plan.ID, Day: day, DayLabel: dayLabelFor(day),
|
||||
Title: it.Title, Description: it.Description, SheetType: it.SheetType,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
res := &ApplyTemplateResult{From: from.Format("2006-01-02"), To: last.Format("2006-01-02")}
|
||||
if len(rows) > 0 {
|
||||
if e := s.db.Create(&rows).Error; e != nil {
|
||||
return nil, e
|
||||
}
|
||||
res.Plans = len(rows)
|
||||
}
|
||||
|
||||
// 提醒只在第一次套用时建:它是「到期提醒」,按物种阶段来就一份,
|
||||
// 套两次方案不该出现两条疫苗提醒
|
||||
res.Reminders = s.applyTemplateReminders(pet, tpl.Items)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// applyTemplateReminders 建模板里的提醒,已存在同标题的跳过
|
||||
func (s *Service) applyTemplateReminders(pet *model.Pet, items []model.CareTemplateItem) int {
|
||||
var exist []model.Reminder
|
||||
s.db.Where("pet_id = ?", pet.ID).Find(&exist)
|
||||
seen := map[string]bool{}
|
||||
for _, r := range exist {
|
||||
seen[r.Title] = true
|
||||
}
|
||||
goals := petGoals(pet)
|
||||
var rows []model.Reminder
|
||||
for _, it := range items {
|
||||
if it.Kind != model.CareKindReminder || seen[it.Title] {
|
||||
continue
|
||||
}
|
||||
if !goalWantsReminder(goals, it.ReminderType) {
|
||||
continue
|
||||
}
|
||||
seen[it.Title] = true
|
||||
r := model.Reminder{
|
||||
PetID: pet.ID, UserID: pet.UserID, Type: it.ReminderType,
|
||||
Title: it.Title, Frequency: it.Frequency,
|
||||
}
|
||||
if it.OffsetDays > 0 {
|
||||
// dueDaysFor 会按宠物月龄调整(幼猫的「下一针」不该按成年猫的周期算)
|
||||
due := time.Now().AddDate(0, 0, dueDaysFor(it, pet, time.Now()))
|
||||
r.NextDueDate = &due
|
||||
}
|
||||
rows = append(rows, r)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return 0
|
||||
}
|
||||
if err := s.db.Create(&rows).Error; err != nil {
|
||||
return 0
|
||||
}
|
||||
return len(rows)
|
||||
}
|
||||
@@ -234,11 +234,15 @@ func parseCareItems(out string) ([]model.CareTemplateItem, bool) {
|
||||
|
||||
// ---- 明细构造小工具(供 AI 草稿解析用)----
|
||||
|
||||
// careTask 原来产出 kind=task(今日任务)。task 和 plan 已经合并——
|
||||
// 「每天要做的事」就是「repeat=daily 的计划节点」,AI 草稿里那部分照这个走
|
||||
func careTask(title, desc, priority, sheet string) model.CareTemplateItem {
|
||||
return model.CareTemplateItem{Kind: model.CareKindTask, Title: title, Description: desc, Priority: priority, SheetType: sheet}
|
||||
return model.CareTemplateItem{Kind: model.CareKindPlan, Repeat: model.RepeatDaily,
|
||||
Title: title, Description: desc, Priority: priority, SheetType: sheet}
|
||||
}
|
||||
func carePlan(day int, label, title, desc, sheet string) model.CareTemplateItem {
|
||||
return model.CareTemplateItem{Kind: model.CareKindPlan, Day: day, DayLabel: label, Title: title, Description: desc, SheetType: sheet}
|
||||
return model.CareTemplateItem{Kind: model.CareKindPlan, Repeat: model.RepeatOnce,
|
||||
Day: day, DayLabel: label, Title: title, Description: desc, SheetType: sheet}
|
||||
}
|
||||
func careReminder(rtype, title string, offset int, freq string) model.CareTemplateItem {
|
||||
return model.CareTemplateItem{Kind: model.CareKindReminder, ReminderType: rtype, Title: title, OffsetDays: offset, Frequency: freq}
|
||||
|
||||
@@ -147,98 +147,51 @@ func (s *Service) Onboarding(userID string, in PetInput) (*model.Pet, error) {
|
||||
}
|
||||
|
||||
// seedPetDefaults 为新宠按「物种 × 阶段」养护模板生成今日任务、提醒、30 天计划
|
||||
// seedPetDefaults 建档时只做一件事:建「必须有」的到期提醒(疫苗、驱虫)。
|
||||
//
|
||||
// 原来它会自动把整套模板铺开成今日任务 + 30 天计划。改成不自动铺了——
|
||||
// 用户在建档最后一步自己挑方案,或者选「先空着我自己排」。
|
||||
// 自动铺的问题是:用户还没看懂这个 App 是干什么的,首页已经躺着十几条
|
||||
// 他没答应过的任务,第一反应是「这些是什么」而不是「我该做什么」。
|
||||
//
|
||||
// 疫苗驱虫这两条例外,因为漏针是真会出事的,而且它们是「到期提醒」不是
|
||||
// 「今天要做的事」——不占计划页的格子,只在到期时冒出来。
|
||||
func (s *Service) seedPetDefaults(tx *gorm.DB, pet *model.Pet) error {
|
||||
today := time.Now()
|
||||
species := SpeciesOf(pet.Type)
|
||||
items := s.careTemplateItems(species, pet.Stage)
|
||||
// 模板是后台配的,代码里没有兜底。配空了新用户会拿到一个什么都没有的
|
||||
// 首页,而且不报错——所以这里必须留下痕迹,否则没人能发现。
|
||||
if len(items) == 0 {
|
||||
log.Printf("care: 养护模板为空 species=%s stage=%s,新宠 %s 不会生成任务/计划/提醒,请到后台配置",
|
||||
log.Printf("care: 养护方案为空 species=%s stage=%s,新宠 %s 建档后没有可选方案,请到后台配置",
|
||||
species, pet.Stage, pet.ID)
|
||||
}
|
||||
|
||||
// 刚到家不到 30 天,把安置期那套的任务和计划叠加上来。
|
||||
// 「刚到家」和「幼年/成年/老年」是两个正交的维度,原来挤在一个四选一里,
|
||||
// 结果刚接回家的成年猫只能二选一,要么丢应激期照护、要么丢年龄段照护。
|
||||
if isNewHome(pet.ArrivedAt, today) && pet.Stage != StageNewHome {
|
||||
seen := map[string]bool{}
|
||||
for _, it := range items {
|
||||
seen[it.Kind+"|"+it.Title] = true
|
||||
}
|
||||
for _, it := range s.careTemplateItems(species, StageNewHome) {
|
||||
if it.Kind != model.CareKindTask && it.Kind != model.CareKindPlan {
|
||||
continue
|
||||
}
|
||||
// 两套模板会撞题(比如都有「清理猫砂盆并观察」),撞了就跳过,
|
||||
// 否则用户第一天会看到两条一模一样的任务
|
||||
if seen[it.Kind+"|"+it.Title] {
|
||||
continue
|
||||
}
|
||||
seen[it.Kind+"|"+it.Title] = true
|
||||
items = append(items, it)
|
||||
}
|
||||
}
|
||||
|
||||
goals := petGoals(pet)
|
||||
|
||||
var tasks []model.DailyTask
|
||||
now := time.Now()
|
||||
var reminders []model.Reminder
|
||||
var planTasks []model.PlanTask
|
||||
seen := map[string]bool{}
|
||||
for _, it := range items {
|
||||
switch it.Kind {
|
||||
case model.CareKindTask:
|
||||
tasks = append(tasks, model.DailyTask{
|
||||
PetID: pet.ID, UserID: pet.UserID, TaskDate: today,
|
||||
Title: it.Title, Description: it.Description, Priority: it.Priority, SheetType: it.SheetType,
|
||||
})
|
||||
case model.CareKindPlan:
|
||||
planTasks = append(planTasks, model.PlanTask{
|
||||
Day: it.Day, DayLabel: it.DayLabel, Title: it.Title, Description: it.Description, SheetType: it.SheetType,
|
||||
})
|
||||
case model.CareKindReminder:
|
||||
// 建档时勾的目标决定要不要建这条提醒。原来这几个勾选框收上来只是
|
||||
// 存进 pets.goals,一处都没用过,等于让用户点了个寂寞。
|
||||
if !goalWantsReminder(goals, it.ReminderType) {
|
||||
continue
|
||||
}
|
||||
r := model.Reminder{
|
||||
PetID: pet.ID, UserID: pet.UserID, Type: it.ReminderType,
|
||||
Title: reminderTitle(it, pet), Frequency: it.Frequency,
|
||||
}
|
||||
if days := dueDaysFor(it, pet, today); days > 0 {
|
||||
due := today.AddDate(0, 0, days)
|
||||
r.NextDueDate = &due
|
||||
}
|
||||
reminders = append(reminders, r)
|
||||
if it.Kind != model.CareKindReminder || seen[it.Title] {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if wantsGoal(goals, "开销") {
|
||||
tasks = append(tasks, model.DailyTask{
|
||||
PetID: pet.ID, UserID: pet.UserID, TaskDate: today,
|
||||
Title: "记一笔养宠开销", Description: "粮、猫砂、医疗都记上,月底能看到花在哪", SheetType: "cost",
|
||||
})
|
||||
}
|
||||
|
||||
if len(tasks) > 0 {
|
||||
if err := tx.Create(&tasks).Error; err != nil {
|
||||
return err
|
||||
if !goalWantsReminder(goals, it.ReminderType) {
|
||||
continue
|
||||
}
|
||||
seen[it.Title] = true
|
||||
r := model.Reminder{
|
||||
PetID: pet.ID, UserID: pet.UserID, Type: it.ReminderType,
|
||||
Title: it.Title, Frequency: it.Frequency,
|
||||
}
|
||||
if it.OffsetDays > 0 {
|
||||
due := now.AddDate(0, 0, dueDaysFor(it, pet, now))
|
||||
r.NextDueDate = &due
|
||||
}
|
||||
reminders = append(reminders, r)
|
||||
}
|
||||
if len(reminders) > 0 {
|
||||
if err := tx.Create(&reminders).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
start := today
|
||||
end := today.AddDate(0, 0, 30)
|
||||
plan := model.Plan{
|
||||
PetID: pet.ID, UserID: pet.UserID, Kind: model.PlanThirtyDay, Stage: pet.Stage,
|
||||
StartDate: &start, EndDate: &end, CompletionPct: 0, Status: "active",
|
||||
Tasks: planTasks,
|
||||
}
|
||||
return tx.Create(&plan).Error
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- 建档默认值的几个判断 ----
|
||||
|
||||
@@ -27,19 +27,18 @@ func (s *Service) TogglePlanTask(userID, taskID string) error {
|
||||
}
|
||||
|
||||
// newStagePlan 按宠物当前阶段的养护模板生成一份 30 天计划(未保存)
|
||||
// newStagePlan 只建一个空计划壳子(记住开始日,PlanTask.Day 是相对它算的)。
|
||||
//
|
||||
// 原来它顺手把整套模板铺成节点。那是第二条「自动铺」的路径——我把
|
||||
// seedPetDefaults 改成不自动铺之后,建档后首页仍然躺着 5 条任务,就是这里干的。
|
||||
// 惰性生成尤其难查:不是建档时发生的,是第一次读计划才发生的。
|
||||
//
|
||||
// 现在铺不铺由用户决定:建档最后一步挑方案,或者之后在计划页套用。
|
||||
func (s *Service) newStagePlan(pet *model.Pet, start time.Time) model.Plan {
|
||||
var planTasks []model.PlanTask
|
||||
for _, it := range s.careTemplateItems(SpeciesOf(pet.Type), pet.Stage) {
|
||||
if it.Kind == model.CareKindPlan {
|
||||
planTasks = append(planTasks, model.PlanTask{
|
||||
Day: it.Day, DayLabel: it.DayLabel, Title: it.Title, Description: it.Description, SheetType: it.SheetType,
|
||||
})
|
||||
}
|
||||
}
|
||||
end := start.AddDate(0, 0, 30)
|
||||
return model.Plan{
|
||||
PetID: pet.ID, UserID: pet.UserID, Kind: model.PlanThirtyDay, Stage: pet.Stage,
|
||||
StartDate: &start, EndDate: &end, CompletionPct: 0, Status: "active", Tasks: planTasks,
|
||||
StartDate: &start, EndDate: &end, CompletionPct: 0, Status: "active",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,173 +3,96 @@ package service
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
|
||||
func dayStart(t time.Time) time.Time {
|
||||
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
||||
}
|
||||
// 「今日任务」就是「今天那一天的计划节点」。
|
||||
//
|
||||
// 原来这是两套东西:DailyTask(模板 kind=task 生成,每天把昨天那份复制到今天)
|
||||
// 和 PlanTask(模板 kind=plan 生成,Day 偏移)。计划改成月度管理、并且能
|
||||
// 「应用到每天」之后,两者做的是同一件事,但首页读 DailyTask、计划页管 PlanTask
|
||||
// —— 用户在计划页加一条「每天喂药」,首页看不到。
|
||||
//
|
||||
// 所以 DailyTask 退役,这个文件只是 PlanTask 的一层「按日期取」的门面。
|
||||
// 接口路径 /pets/:id/tasks 保留不变,小程序端不用为这次合并改调用。
|
||||
|
||||
// ensureDayTasks 惰性生成某天的任务:今天/未来若为空,则从「最近一次有任务的那天」顺延复制,
|
||||
// 无历史则用养护模板。过去的日子不补(保留历史真实)。
|
||||
func (s *Service) ensureDayTasks(pet *model.Pet, day time.Time) {
|
||||
start := dayStart(day)
|
||||
if start.Before(dayStart(time.Now())) {
|
||||
return
|
||||
}
|
||||
end := start.AddDate(0, 0, 1)
|
||||
var cnt int64
|
||||
s.db.Model(&model.DailyTask{}).Where("pet_id = ? AND task_date >= ? AND task_date < ?", pet.ID, start, end).Count(&cnt)
|
||||
if cnt > 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var defs []model.DailyTask
|
||||
var latest model.DailyTask
|
||||
if err := s.db.Where("pet_id = ? AND task_date < ?", pet.ID, start).Order("task_date desc, id desc").First(&latest).Error; err == nil {
|
||||
ls := dayStart(latest.TaskDate)
|
||||
var prev []model.DailyTask
|
||||
s.db.Where("pet_id = ? AND task_date >= ? AND task_date < ?", pet.ID, ls, ls.AddDate(0, 0, 1)).Order("id asc").Find(&prev)
|
||||
for _, t := range prev {
|
||||
defs = append(defs, model.DailyTask{
|
||||
PetID: pet.ID, UserID: pet.UserID, TaskDate: start,
|
||||
Title: t.Title, Description: t.Description, Priority: t.Priority, SheetType: t.SheetType,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
for _, it := range s.careTemplateItems(SpeciesOf(pet.Type), pet.Stage) {
|
||||
if it.Kind == model.CareKindTask {
|
||||
defs = append(defs, model.DailyTask{
|
||||
PetID: pet.ID, UserID: pet.UserID, TaskDate: start,
|
||||
Title: it.Title, Description: it.Description, Priority: it.Priority, SheetType: it.SheetType,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(defs) > 0 {
|
||||
s.db.Create(&defs)
|
||||
}
|
||||
}
|
||||
|
||||
// ListTasks 列出宠物某天任务(date 为空则取今天);今天/未来为空会惰性生成
|
||||
func (s *Service) ListTasks(userID, petID string, date *time.Time) ([]model.DailyTask, error) {
|
||||
pet, err := s.ownedPet(userID, petID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target := time.Now()
|
||||
if date != nil {
|
||||
target = *date
|
||||
}
|
||||
s.ensureDayTasks(pet, target)
|
||||
|
||||
start := dayStart(target)
|
||||
end := start.AddDate(0, 0, 1)
|
||||
var tasks []model.DailyTask
|
||||
err = s.db.Where("pet_id = ? AND task_date >= ? AND task_date < ?", petID, start, end).
|
||||
Order("id asc").Find(&tasks).Error
|
||||
return tasks, err
|
||||
}
|
||||
|
||||
// TaskInput 增/改任务入参
|
||||
// TaskInput 保留给 handler 绑定用;增删改现在统一走 /plan-tasks 那套
|
||||
type TaskInput struct {
|
||||
Title string
|
||||
Description string
|
||||
Priority string
|
||||
SheetType string
|
||||
Date *time.Time // 仅新增用,默认今天
|
||||
}
|
||||
|
||||
// CreateTask 为某天新增一条任务(默认今天)
|
||||
func (s *Service) CreateTask(userID, petID string, in TaskInput) (*model.DailyTask, error) {
|
||||
// ListTasks 某一天要做的事。不传日期就是今天
|
||||
func (s *Service) ListTasks(userID, petID string, date *time.Time) ([]model.PlanTask, error) {
|
||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
day := time.Now()
|
||||
if in.Date != nil {
|
||||
day = *in.Date
|
||||
plan, err := s.planOf(userID, petID)
|
||||
if err != nil {
|
||||
// 没有计划就没有任务,返回空数组而不是错误——新建档的宠物必然走这一支
|
||||
return []model.PlanTask{}, nil
|
||||
}
|
||||
t := model.DailyTask{
|
||||
PetID: petID, UserID: userID, TaskDate: dayStart(day),
|
||||
Title: in.Title, Description: in.Description, Priority: in.Priority, SheetType: in.SheetType,
|
||||
target := time.Now()
|
||||
if date != nil {
|
||||
target = *date
|
||||
}
|
||||
if err := s.db.Create(&t).Error; err != nil {
|
||||
return nil, err
|
||||
day := dayIndexOf(plan, target)
|
||||
if day < 0 {
|
||||
return []model.PlanTask{}, nil
|
||||
}
|
||||
return &t, nil
|
||||
var tasks []model.PlanTask
|
||||
s.db.Where("plan_id = ? AND day = ?", plan.ID, day).Order("id asc").Find(&tasks)
|
||||
if tasks == nil {
|
||||
tasks = []model.PlanTask{}
|
||||
}
|
||||
stamp := plan.StartDate.AddDate(0, 0, day).Format("2006-01-02")
|
||||
for i := range tasks {
|
||||
tasks[i].Date = stamp
|
||||
}
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
// UpdateTask 修改任务文案/优先级/关联弹层
|
||||
func (s *Service) UpdateTask(userID, taskID string, in TaskInput) (*model.DailyTask, error) {
|
||||
var t model.DailyTask
|
||||
if err := s.db.Where("id = ? AND user_id = ?", taskID, userID).First(&t).Error; err != nil {
|
||||
return nil, ErrNotFound
|
||||
// dayIndexOf 某个日期在这份计划里是第几天;早于计划开始日返回 -1
|
||||
func dayIndexOf(plan *model.Plan, t time.Time) int {
|
||||
if plan.StartDate == nil {
|
||||
return -1
|
||||
}
|
||||
if err := s.db.Model(&model.DailyTask{}).Where("id = ?", taskID).Updates(map[string]any{
|
||||
"title": in.Title, "description": in.Description, "priority": in.Priority, "sheet_type": in.SheetType,
|
||||
}).Error; err != nil {
|
||||
return nil, err
|
||||
st := time.Date(plan.StartDate.Year(), plan.StartDate.Month(), plan.StartDate.Day(),
|
||||
0, 0, 0, 0, time.Local)
|
||||
d := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local)
|
||||
if d.Before(st) {
|
||||
return -1
|
||||
}
|
||||
return s.UpdateTaskReload(taskID)
|
||||
return int(d.Sub(st).Hours() / 24)
|
||||
}
|
||||
|
||||
// UpdateTaskReload 重新读取任务
|
||||
func (s *Service) UpdateTaskReload(taskID string) (*model.DailyTask, error) {
|
||||
var t model.DailyTask
|
||||
// ToggleTask 勾选/取消。和 TogglePlanTask 是同一件事,保留这个名字是为了
|
||||
// 让 /tasks/:id/toggle 这个老路径继续可用
|
||||
func (s *Service) ToggleTask(userID, taskID string) (*model.PlanTask, error) {
|
||||
if err := s.TogglePlanTask(userID, taskID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var t model.PlanTask
|
||||
if err := s.db.First(&t, "id = ?", taskID).Error; err != nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// DeleteTask 删除任务
|
||||
func (s *Service) DeleteTask(userID, taskID string) error {
|
||||
res := s.db.Where("id = ? AND user_id = ?", taskID, userID).Delete(&model.DailyTask{})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
// CompleteAllTasks 把今天的都勾上
|
||||
func (s *Service) CompleteAllTasks(userID, petID string) ([]model.PlanTask, error) {
|
||||
plan, err := s.planOf(userID, petID)
|
||||
if err != nil {
|
||||
return []model.PlanTask{}, nil
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrNotFound
|
||||
day := dayIndexOf(plan, time.Now())
|
||||
if day < 0 {
|
||||
return []model.PlanTask{}, nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToggleTask 切换任务完成状态
|
||||
func (s *Service) ToggleTask(userID, taskID string) (*model.DailyTask, error) {
|
||||
var task model.DailyTask
|
||||
if err := s.db.Where("id = ? AND user_id = ?", taskID, userID).First(&task).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
task.Done = !task.Done
|
||||
if task.Done {
|
||||
now := time.Now()
|
||||
task.CompletedAt = &now
|
||||
} else {
|
||||
task.CompletedAt = nil
|
||||
}
|
||||
if err := s.db.Save(&task).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
// CompleteAllTasks 完成宠物今日全部任务
|
||||
func (s *Service) CompleteAllTasks(userID, petID string) ([]model.DailyTask, error) {
|
||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
end := start.AddDate(0, 0, 1)
|
||||
if err := s.db.Model(&model.DailyTask{}).
|
||||
Where("pet_id = ? AND task_date >= ? AND task_date < ?", petID, start, end).
|
||||
Updates(map[string]any{"done": true, "completed_at": now}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.ListTasks(userID, petID, &now)
|
||||
s.db.Model(&model.PlanTask{}).
|
||||
Where("plan_id = ? AND day = ? AND done = ?", plan.ID, day, false).
|
||||
Update("done", true)
|
||||
return s.ListTasks(userID, petID, nil)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user