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:
Blizzard
2026-07-30 11:50:54 +08:00
parent 69bd56671c
commit 8b1b4cde02
9 changed files with 449 additions and 306 deletions
+260
View File
@@ -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)
}