8b1b4cde02
## 三个问题,一起解决 你问「模版要不要做成可选」,摸下来发现底下有三个更硬的: **① 根本没有多套模版可选。** 模板表唯一索引是 (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>
250 lines
8.5 KiB
Go
250 lines
8.5 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/sundynix/pets-be/internal/ai"
|
|
"github.com/sundynix/pets-be/internal/model"
|
|
)
|
|
|
|
// 模板分组。注意 StageNewHome 不是年龄阶段,而是「刚到家」叠加包:
|
|
// 建档时先按年龄阶段取一套,若到家不足 30 天,再把这一套叠上去。
|
|
const (
|
|
StageNewHome = "刚到家 0-30 天"
|
|
StageJuvenile = "幼年期"
|
|
StageAdult = "成年期"
|
|
StageSenior = "老年期"
|
|
)
|
|
|
|
// CareSpecies / CareStages 支持的物种与分组(字符串与 onboarding 一致)
|
|
var CareSpecies = []string{model.SpeciesCat, model.SpeciesDog}
|
|
var CareStages = []string{StageNewHome, StageJuvenile, StageAdult, StageSenior}
|
|
|
|
// AgeStages 只含年龄阶段,用于推断和校验(不含叠加包)
|
|
var AgeStages = []string{StageJuvenile, StageAdult, StageSenior}
|
|
|
|
// StageFromBirthday 由生日推断年龄阶段。
|
|
// 猫:12 月龄成年、7 岁进入老年。
|
|
// 犬:12 月龄成年(大型犬实际 18-24 月,这里取通用值)、7 岁进入老年。
|
|
// 拿不到生日就回落到成年期——它的模板最保守,不会给幼崽排年度疫苗、
|
|
// 也不会给成年犬排"每周两次称重"。
|
|
func StageFromBirthday(species string, birthday *time.Time) string {
|
|
if birthday == nil || birthday.IsZero() {
|
|
return StageAdult
|
|
}
|
|
months := monthsSince(*birthday)
|
|
switch {
|
|
case months < 12:
|
|
return StageJuvenile
|
|
case months >= 7*12:
|
|
return StageSenior
|
|
default:
|
|
return StageAdult
|
|
}
|
|
}
|
|
|
|
// monthsSince 从某天到今天的整月数(不足一月算 0)
|
|
func monthsSince(t time.Time) int {
|
|
now := time.Now()
|
|
m := (now.Year()-t.Year())*12 + int(now.Month()) - int(t.Month())
|
|
if now.Day() < t.Day() {
|
|
m--
|
|
}
|
|
if m < 0 {
|
|
m = 0
|
|
}
|
|
return m
|
|
}
|
|
|
|
// NormalizeStage 定下最终使用的年龄阶段。
|
|
// 用户手选优先(他可能比生日更清楚,比如领养来的估算年龄),
|
|
// 但只认三个年龄阶段;传了「刚到家」或空值时,一律按生日推断。
|
|
func NormalizeStage(species, picked string, birthday *time.Time) string {
|
|
for _, s := range AgeStages {
|
|
if picked == s {
|
|
return picked
|
|
}
|
|
}
|
|
return StageFromBirthday(species, birthday)
|
|
}
|
|
|
|
// SpeciesOf 由宠物类型(猫猫/狗狗)映射到模板物种键
|
|
func SpeciesOf(petType string) string {
|
|
if strings.Contains(petType, "狗") {
|
|
return model.SpeciesDog
|
|
}
|
|
return model.SpeciesCat
|
|
}
|
|
|
|
func validSpecies(s string) bool { return s == model.SpeciesCat || s == model.SpeciesDog }
|
|
func validStage(s string) bool {
|
|
for _, x := range CareStages {
|
|
if x == s {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// careTemplateItems 取某组合的模板明细。模板是后台配的内容数据,代码里不再留
|
|
// 一套内置默认——没配就是没配,这里返回空,由调用方决定怎么处理(见 seedPetDefaults)。
|
|
func (s *Service) careTemplateItems(species, stage string) []model.CareTemplateItem {
|
|
var tpl model.CareTemplate
|
|
err := s.db.
|
|
Preload("Items", func(db *gorm.DB) *gorm.DB { return db.Order("kind, sort, id") }).
|
|
Where("species = ? AND stage = ?", species, stage).First(&tpl).Error
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return tpl.Items
|
|
}
|
|
|
|
// GetCareTemplate 后台读取:返回某组合的明细(DB 优先,无则内置默认)
|
|
func (s *Service) GetCareTemplate(species, stage string) (map[string]any, error) {
|
|
if !validSpecies(species) || !validStage(stage) {
|
|
return nil, errors.New("invalid species or stage")
|
|
}
|
|
return map[string]any{
|
|
"species": species,
|
|
"stage": stage,
|
|
"items": s.careTemplateItems(species, stage),
|
|
}, nil
|
|
}
|
|
|
|
// SaveCareTemplate 后台保存:整表替换该组合的明细
|
|
func (s *Service) SaveCareTemplate(species, stage string, items []model.CareTemplateItem) error {
|
|
if !validSpecies(species) || !validStage(stage) {
|
|
return errors.New("invalid species or stage")
|
|
}
|
|
return s.db.Transaction(func(tx *gorm.DB) error {
|
|
var tpl model.CareTemplate
|
|
err := tx.Where("species = ? AND stage = ?", species, stage).First(&tpl).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
tpl = model.CareTemplate{Species: species, Stage: stage}
|
|
if err := tx.Create(&tpl).Error; err != nil {
|
|
return err
|
|
}
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Where("template_id = ?", tpl.ID).Delete(&model.CareTemplateItem{}).Error; err != nil {
|
|
return err
|
|
}
|
|
clean := make([]model.CareTemplateItem, 0, len(items))
|
|
for i := range items {
|
|
it := items[i]
|
|
if strings.TrimSpace(it.Title) == "" {
|
|
continue
|
|
}
|
|
it.ID = ""
|
|
it.TemplateID = tpl.ID
|
|
it.Sort = i
|
|
clean = append(clean, it)
|
|
}
|
|
if len(clean) > 0 {
|
|
if err := tx.Create(&clean).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// GenerateCareTemplate 后台「AI 生成草稿」:不落库,仅返回建议明细。
|
|
// AI 未开启或失败时回退内置默认,保证总有可用草稿。
|
|
func (s *Service) GenerateCareTemplate(species, stage string) ([]model.CareTemplateItem, error) {
|
|
if !validSpecies(species) || !validStage(stage) {
|
|
return nil, errors.New("invalid species or stage")
|
|
}
|
|
if !s.ai.Enabled() {
|
|
return nil, errors.New("AI 未开启,无法生成草稿;请手动添加明细")
|
|
}
|
|
spName := "猫"
|
|
if species == model.SpeciesDog {
|
|
spName = "狗"
|
|
}
|
|
system := aiSafetyPrompt + `
|
|
|
|
你现在为后台生成一套「养护模板」,用于新宠建档时自动生成任务/计划/提醒。
|
|
只输出 JSON,结构如下(不要多余文字):
|
|
{
|
|
"tasks": [{"title":"","description":"","priority":"重要或空","sheet_type":"weight|poop|food|symptom|vaccine|medicine 或空"}],
|
|
"plan": [{"day":0,"day_label":"今天","title":"","description":"","sheet_type":"weight|vaccine|medicine|report|taskDetail 或空"}],
|
|
"reminders":[{"reminder_type":"vaccine|deworm|weight|monthlyReport","title":"","offset_days":18,"frequency":"到期用天数则填0并写频率,如每周二五"}]
|
|
}
|
|
要求:tasks 3 条、plan 4 条、reminders 3-4 条;贴合该物种与阶段的真实照护节奏(如成年比幼年疫苗/驱虫间隔更长,狗驱虫比猫更频繁)。`
|
|
user := fmt.Sprintf("为【%s · %s】生成一套养护模板。", spName, stage)
|
|
|
|
out, err := s.ai.Complete(system, []ai.Message{{Role: "user", Content: user}}, ai.Options{JSON: true, Temperature: -1})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("AI 生成失败:%w", err)
|
|
}
|
|
items, ok := parseCareItems(out)
|
|
if !ok || len(items) == 0 {
|
|
return nil, errors.New("AI 返回的内容解析不出可用明细,请重试或手动添加")
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
// parseCareItems 解析 AI 返回的分组 JSON → 扁平明细
|
|
func parseCareItems(out string) ([]model.CareTemplateItem, bool) {
|
|
var raw struct {
|
|
Tasks []struct {
|
|
Title, Description, Priority, SheetType string
|
|
} `json:"tasks"`
|
|
Plan []struct {
|
|
Day int
|
|
DayLabel, Title, Description, SheetType string
|
|
} `json:"plan"`
|
|
Reminders []struct {
|
|
ReminderType, Title, Frequency string
|
|
OffsetDays int
|
|
} `json:"reminders"`
|
|
}
|
|
if err := json.Unmarshal([]byte(extractJSON(out)), &raw); err != nil {
|
|
return nil, false
|
|
}
|
|
var items []model.CareTemplateItem
|
|
for _, t := range raw.Tasks {
|
|
if t.Title == "" {
|
|
continue
|
|
}
|
|
items = append(items, careTask(t.Title, t.Description, t.Priority, t.SheetType))
|
|
}
|
|
for _, p := range raw.Plan {
|
|
if p.Title == "" {
|
|
continue
|
|
}
|
|
items = append(items, carePlan(p.Day, p.DayLabel, p.Title, p.Description, p.SheetType))
|
|
}
|
|
for _, r := range raw.Reminders {
|
|
if r.Title == "" {
|
|
continue
|
|
}
|
|
items = append(items, careReminder(r.ReminderType, r.Title, r.OffsetDays, r.Frequency))
|
|
}
|
|
return items, len(items) > 0
|
|
}
|
|
|
|
// ---- 明细构造小工具(供 AI 草稿解析用)----
|
|
|
|
// careTask 原来产出 kind=task(今日任务)。task 和 plan 已经合并——
|
|
// 「每天要做的事」就是「repeat=daily 的计划节点」,AI 草稿里那部分照这个走
|
|
func careTask(title, desc, priority, sheet string) model.CareTemplateItem {
|
|
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, 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}
|
|
}
|