Files
Blizzard 8b1b4cde02 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>
2026-07-30 11:50:54 +08:00

261 lines
8.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}