Files
sundynix-pets/pets-be/internal/service/plan.go
T
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

507 lines
16 KiB
Go
Raw 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"
"math"
"strconv"
"strings"
"time"
"gorm.io/datatypes"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/model"
)
// TogglePlanTask 切换计划明细完成状态(校验归属)
func (s *Service) TogglePlanTask(userID, taskID string) error {
var pt model.PlanTask
if err := s.db.First(&pt, taskID).Error; err != nil {
return ErrNotFound
}
var plan model.Plan
if err := s.db.Where("id = ? AND user_id = ?", pt.PlanID, userID).First(&plan).Error; err != nil {
return ErrNotFound
}
return s.db.Model(&model.PlanTask{}).Where("id = ?", taskID).Update("done", !pt.Done).Error
}
// newStagePlan 按宠物当前阶段的养护模板生成一份 30 天计划(未保存)
// newStagePlan 只建一个空计划壳子(记住开始日,PlanTask.Day 是相对它算的)。
//
// 原来它顺手把整套模板铺成节点。那是第二条「自动铺」的路径——我把
// seedPetDefaults 改成不自动铺之后,建档后首页仍然躺着 5 条任务,就是这里干的。
// 惰性生成尤其难查:不是建档时发生的,是第一次读计划才发生的。
//
// 现在铺不铺由用户决定:建档最后一步挑方案,或者之后在计划页套用。
func (s *Service) newStagePlan(pet *model.Pet, start time.Time) model.Plan {
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",
}
}
// ensurePlan 计划到期自动续期:active 计划过期则归档并按当前阶段生成新一轮;无 active 则补建
func (s *Service) ensurePlan(pet *model.Pet) {
now := time.Now()
var plan model.Plan
err := s.db.Where("pet_id = ? AND kind = ? AND status = ?", pet.ID, model.PlanThirtyDay, "active").
Order("id desc").First(&plan).Error
if err == gorm.ErrRecordNotFound {
p := s.newStagePlan(pet, now)
s.db.Create(&p)
return
}
if err == nil && plan.EndDate != nil && plan.EndDate.Before(now) {
s.db.Model(&model.Plan{}).Where("id = ?", plan.ID).Update("status", "archived")
p := s.newStagePlan(pet, now)
s.db.Create(&p)
}
}
// GetPlan 取宠物的 30 天计划(含明细);过期自动续期
func (s *Service) GetPlan(userID, petID string) (*model.Plan, error) {
pet, err := s.ownedPet(userID, petID)
if err != nil {
return nil, err
}
s.ensurePlan(pet)
var plan model.Plan
err = s.db.Preload("Tasks").
Where("pet_id = ? AND kind = ?", petID, model.PlanThirtyDay).
Where("status = ?", "active").
Order("id desc").First(&plan).Error
if err == gorm.ErrRecordNotFound {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
// 真实完成度:已完成明细 / 总明细
if n := len(plan.Tasks); n > 0 {
done := 0
for _, t := range plan.Tasks {
if t.Done {
done++
}
}
plan.CompletionPct = int(math.Round(float64(done) / float64(n) * 100))
} else {
plan.CompletionPct = 0
}
// 计算每个路线图节点的真实日期(开始日 + Day),供前端展示并与日历对齐
if plan.StartDate != nil {
for i := range plan.Tasks {
plan.Tasks[i].Date = plan.StartDate.AddDate(0, 0, plan.Tasks[i].Day).Format("2006-01-02")
}
}
return &plan, nil
}
// planNodeDates 30 天计划路线图节点在 [start,end) 内的真实日期(开始日 + Day)
func (s *Service) planNodeDates(petID string, start, end time.Time) []time.Time {
var plan model.Plan
if err := s.db.Preload("Tasks").
Where("pet_id = ? AND kind = ?", petID, model.PlanThirtyDay).
Order("id desc").First(&plan).Error; err != nil || plan.StartDate == nil {
return nil
}
var out []time.Time
for _, t := range plan.Tasks {
d := plan.StartDate.AddDate(0, 0, t.Day)
dd := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, d.Location())
if !dd.Before(start) && dd.Before(end) {
out = append(out, dd)
}
}
return out
}
// CalendarResult 日历视图
type CalendarResult struct {
Year int `json:"year"`
Month int `json:"month"`
TaskedDays []int `json:"tasked_days"`
Today int `json:"today"`
}
// Calendar 某月有任务/提醒的日期
func (s *Service) Calendar(userID, petID string, year, month int) (*CalendarResult, error) {
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, err
}
loc := time.Now().Location()
start := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, loc)
end := start.AddDate(0, 1, 0)
// 与首页周历同源:任务/记录/提醒/计划路线图节点
set := s.datesWithActivity(petID, start, end)
days := make([]int, 0, len(set))
for k := range set {
if t, err := time.ParseInLocation("2006-01-02", k, time.Local); err == nil {
days = append(days, t.Day())
}
}
today := 0
now := time.Now()
if now.Year() == year && int(now.Month()) == month {
today = now.Day()
}
return &CalendarResult{Year: year, Month: month, TaskedDays: days, Today: today}, nil
}
// DayPlan 某一天的安排:计划路线图节点 + 今日任务 + 到期提醒
func (s *Service) DayPlan(userID, petID, dateStr string) (map[string]any, error) {
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, err
}
day, err := time.ParseInLocation("2006-01-02", dateStr, time.Local)
if err != nil {
return nil, ErrNotFound
}
next := day.AddDate(0, 0, 1)
nodes := []model.PlanTask{}
var plan model.Plan
if e := s.db.Preload("Tasks").
Where("pet_id = ? AND kind = ?", petID, model.PlanThirtyDay).
Order("id desc").First(&plan).Error; e == nil && plan.StartDate != nil {
for _, t := range plan.Tasks {
d := plan.StartDate.AddDate(0, 0, t.Day)
if d.Year() == day.Year() && d.YearDay() == day.YearDay() {
t.Date = d.Format("2006-01-02")
nodes = append(nodes, t)
}
}
}
var tasks []model.DailyTask
s.db.Where("pet_id = ? AND task_date >= ? AND task_date < ?", petID, day, next).Find(&tasks)
var reminders []model.Reminder
s.db.Where("pet_id = ? AND next_due_date >= ? AND next_due_date < ?", petID, day, next).Find(&reminders)
return map[string]any{
"date": dateStr,
"plan_nodes": nodes,
"tasks": tasks,
"reminders": reminders,
}, nil
}
// CreateAIPlan 基于用户描述做规则化提取,生成待确认的 AI 计划
func (s *Service) CreateAIPlan(userID, petID string, input string) (*model.Plan, error) {
if err := s.consumeAIQuota(userID, aiKindPlan); err != nil {
return nil, err
}
pet, err := s.ownedPet(userID, petID)
if err != nil {
return nil, err
}
// 默认(规则化)提取与任务
var extracted any = extractAIInfo(input, pet.Stage)
tasks := []model.PlanTask{
{DayLabel: "Day 1-2", Title: "观察食欲和便便", Description: "每日记录饮食、便便、精神状态。"},
{DayLabel: "Day 3-5", Title: "记录换粮比例", Description: "逐步提高新粮比例,留意软便。"},
{DayLabel: "Day 6-7", Title: "生成复盘建议", Description: "汇总一周状态,输出复盘。"},
}
// 启用模型则用真实提取覆盖
if ex, ok := s.aiPlanExtract(pet, input); ok {
extracted = map[string]string{
"stage": ex.Stage, "risk": ex.Risk, "priority": ex.Priority, "reminder": ex.Reminder,
}
tasks = tasks[:0]
for _, t := range ex.Tasks {
tasks = append(tasks, model.PlanTask{DayLabel: t.DayLabel, Title: t.Title, Description: t.Description})
}
}
b, _ := jsonMarshalAny(extracted)
plan := model.Plan{
PetID: petID, UserID: userID, Kind: model.PlanAI, Stage: pet.Stage,
UserInput: input, Extracted: datatypes.JSON(b), Status: "draft", Tasks: tasks,
}
if err := s.db.Create(&plan).Error; err != nil {
return nil, err
}
return &plan, nil
}
// ApplyAIPlan 确认并应用 AI 计划
func (s *Service) ApplyAIPlan(userID, planID string) (*model.Plan, error) {
var plan model.Plan
if err := s.db.Where("id = ? AND user_id = ?", planID, userID).First(&plan).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, ErrNotFound
}
return nil, err
}
now := time.Now()
end := now.AddDate(0, 0, 7)
if err := s.db.Model(&plan).Updates(map[string]any{
"status": "active", "start_date": now, "end_date": end,
}).Error; err != nil {
return nil, err
}
if err := s.db.Preload("Tasks").First(&plan, plan.ID).Error; err != nil {
return nil, err
}
return &plan, nil
}
// extractAIInfo 规则化提取(后续可替换为大模型)
func extractAIInfo(input, stage string) map[string]string {
risk := "暂无明显风险"
priority := "日常观察"
reminder := "常规提醒"
if strings.Contains(input, "换粮") || strings.Contains(input, "软便") {
risk = "换粮软便"
priority = "排便观察"
}
if strings.Contains(input, "疫苗") {
reminder = "第二针疫苗"
}
if stage == "" {
stage = "幼年期"
}
return map[string]string{
"stage": stage + " / 疫苗期",
"risk": risk,
"priority": priority,
"reminder": reminder,
}
}
// PlanTaskInput 用户自己加/改的计划节点
type PlanTaskInput struct {
Date string // "2026-08-05",落到 Day = 这天 - 计划开始日
Title string
Description string
SheetType string // 关联的记录类型 code,可空
// Daily 应用到每天:从 Date 起到窗口末尾,一天建一条。
// 不做「一条 + repeat 字段读时展开」是因为勾选状态是按天的——
// 那样得再开一张表记「哪天打过勾」,而直接建 N 条天然就有这个能力。
Daily bool
}
// planOf 取这只宠物当前的 30 天计划,顺带做归属校验
func (s *Service) planOf(userID, petID string) (*model.Plan, error) {
pet, err := s.ownedPet(userID, petID)
if err != nil {
return nil, err
}
s.ensurePlan(pet)
var plan model.Plan
if e := s.db.Where("pet_id = ? AND kind = ? AND status = ?",
petID, model.PlanThirtyDay, "active").Order("id desc").First(&plan).Error; e != nil {
return nil, ErrNotFound
}
return &plan, nil
}
// 计划以「自然月」为单位管理。
//
// 不用「从计划开始日起 30 天」那种滚动窗口:计划可能是月中建的,那样窗口会
// 跨两个月,用户看着一个 7月26日—8月25日 的日历,既不像本月也不像下月,
// 「这个月还有什么没做」根本对不上。按自然月切,日历就是一眼能认的月历。
//
// 允许安排到下月末,否则「做下月计划」这件事本身做不了。
// monthBounds 某个月的首尾。off=0 本月,off=1 下月
func monthBounds(off int) (time.Time, time.Time) {
n := time.Now()
first := time.Date(n.Year(), n.Month(), 1, 0, 0, 0, 0, time.Local).AddDate(0, off, 0)
return first, first.AddDate(0, 1, -1)
}
// dayOffset 把日期换算成 PlanTask.Day(相对计划开始日)。
//
// 校验按**日期**而不是 Day 上限:Day 是相对计划开始日的,月份边界是相对今天的,
// 两者会错开。按 Day<=30 卡的话,计划建了几天之后下月末就换算成 Day=40 多,
// 用户在日历上明明看得到那格、点进去却存不了。
func dayOffset(plan *model.Plan, dateStr string) (int, error) {
if plan.StartDate == nil {
return 0, errors.New("计划还没有开始日期")
}
d, err := time.ParseInLocation("2006-01-02", dateStr, time.Local)
if err != nil {
return 0, ErrInvalidParam
}
st := time.Date(plan.StartDate.Year(), plan.StartDate.Month(), plan.StartDate.Day(),
0, 0, 0, 0, time.Local)
// 下界放到计划开始日而不是本月 1 号:模板生成的历史节点可能在上个月,
// 那些要允许原地改(改标题时日期会原样传回来)
if d.Before(st) {
return 0, errors.New("这天在计划开始之前,排不了")
}
_, nextEnd := monthBounds(1)
if d.After(nextEnd) {
return 0, errors.New("最远只能排到下个月月底(" + nextEnd.Format("1月2日") + "")
}
return int(d.Sub(st).Hours() / 24), nil
}
// AddPlanTask 用户自己往计划里加。Daily=true 时从这天起到窗口末尾每天一条
func (s *Service) AddPlanTask(userID, petID string, in PlanTaskInput) ([]model.PlanTask, error) {
title := strings.TrimSpace(in.Title)
if title == "" {
return nil, ErrInvalidParam
}
plan, err := s.planOf(userID, petID)
if err != nil {
return nil, err
}
day, err := dayOffset(plan, in.Date)
if err != nil {
return nil, err
}
// 要建几天。非 daily 就一天;daily 从这天排到窗口末尾
// 要建几天。daily 从这天排到**这一天所在那个月**的月底:
// 「应用到每天」说的是这个月每天,跨到下月去就变成用户没要求的事了
count := 1
if in.Daily {
from, e := time.ParseInLocation("2006-01-02", in.Date, time.Local)
if e != nil {
return nil, ErrInvalidParam
}
monthEnd := time.Date(from.Year(), from.Month(), 1, 0, 0, 0, 0, time.Local).
AddDate(0, 1, -1)
count = int(monthEnd.Sub(from).Hours()/24) + 1
if count < 1 {
count = 1
}
if count > 31 {
count = 31 // 兜一层,别让脏日期算出个巨大的数
}
}
out := make([]model.PlanTask, 0, count)
for i := 0; i < count; i++ {
d := day + i
out = append(out, model.PlanTask{
PlanID: plan.ID, Day: d, DayLabel: dayLabelFor(d),
Title: title, Description: strings.TrimSpace(in.Description),
SheetType: in.SheetType,
})
}
if err := s.db.Create(&out).Error; err != nil {
return nil, err
}
for i := range out {
out[i].Date = plan.StartDate.AddDate(0, 0, out[i].Day).Format("2006-01-02")
}
return out, nil
}
// UpdatePlanTask 改标题/描述/日期
func (s *Service) UpdatePlanTask(userID, taskID string, in PlanTaskInput) error {
plan, t, err := s.ownedPlanTask(userID, taskID)
if err != nil {
return err
}
fields := map[string]any{}
if strings.TrimSpace(in.Title) != "" {
fields["title"] = strings.TrimSpace(in.Title)
}
fields["description"] = strings.TrimSpace(in.Description)
if in.Date != "" {
day, err := dayOffset(plan, in.Date)
if err != nil {
return err
}
fields["day"] = day
fields["day_label"] = dayLabelFor(day)
}
return s.db.Model(&model.PlanTask{}).Where("id = ?", t.ID).Updates(fields).Error
}
// DeletePlanTask 删掉一条
func (s *Service) DeletePlanTask(userID, taskID string) error {
_, t, err := s.ownedPlanTask(userID, taskID)
if err != nil {
return err
}
return s.db.Delete(&model.PlanTask{}, "id = ?", t.ID).Error
}
// ownedPlanTask 校验这条节点属于当前用户的宠物。
// 不校验的话,拿到别人的 task id 就能改删别人的计划
func (s *Service) ownedPlanTask(userID, taskID string) (*model.Plan, *model.PlanTask, error) {
var t model.PlanTask
if err := s.db.First(&t, "id = ?", taskID).Error; err != nil {
return nil, nil, ErrNotFound
}
var plan model.Plan
if err := s.db.First(&plan, "id = ?", t.PlanID).Error; err != nil {
return nil, nil, ErrNotFound
}
if _, err := s.ownedPet(userID, plan.PetID); err != nil {
return nil, nil, ErrForbidden
}
return &plan, &t, nil
}
// dayLabelFor 第 N 天的展示标签,和内置模板生成的那批保持一致
func dayLabelFor(day int) string {
switch {
case day == 0:
return "今天"
case day == 1:
return "明天"
default:
return "第 " + strconv.Itoa(day+1) + " 天"
}
}
// PlanMonthStatus 首页那个提醒角标要的东西
type PlanMonthStatus struct {
Month string `json:"month"` // "2026-07"
ThisMonth int `json:"this_month"` // 本月有几件
ThisUndone int `json:"this_undone"` // 本月还剩几件
NextMonth int `json:"next_month"` // 下月有几件
// NeedNext 该提醒用户排下月计划了。
// 只在月末最后 7 天提醒:月初就催「排下个月」太早,用户会当成噪音关掉。
NeedNext bool `json:"need_next"`
NextLabel string `json:"next_label"` // "8 月"
}
// PlanMonthStatus 统计本月/下月的计划件数
func (s *Service) PlanMonthStatus(userID, petID string) (*PlanMonthStatus, error) {
plan, err := s.planOf(userID, petID)
if err != nil {
return nil, err
}
thisFirst, thisEnd := monthBounds(0)
nextFirst, nextEnd := monthBounds(1)
var tasks []model.PlanTask
s.db.Where("plan_id = ?", plan.ID).Find(&tasks)
out := &PlanMonthStatus{
Month: thisFirst.Format("2006-01"),
NextLabel: nextFirst.Format("1 月"),
}
for _, t := range tasks {
d := plan.StartDate.AddDate(0, 0, t.Day)
switch {
case !d.Before(thisFirst) && !d.After(thisEnd):
out.ThisMonth++
if !t.Done {
out.ThisUndone++
}
case !d.Before(nextFirst) && !d.After(nextEnd):
out.NextMonth++
}
}
// 距月底还剩几天。用 24h 取整会在夏令时地区差一天,这里按日期差算
left := int(thisEnd.Sub(time.Date(time.Now().Year(), time.Now().Month(), time.Now().Day(),
0, 0, 0, 0, time.Local)).Hours()/24)
out.NeedNext = out.NextMonth == 0 && left <= 7
return out, nil
}