Files
sundynix-pets/pets-be/internal/service/care_template.go
T
Blizzard afdbf94450 feat(admin): 养护方案页重写 —— 多套、重复规则、"弹层"改成真实的记录事项
## 你问的那个「弹层」是什么
sheet_type = 用户在小程序点这条计划时,直接跳到哪个记录表单。
「体重记录」这条关联 weight,点了就是体重表单,不用自己去记录页找。

叫「弹层」是因为那时候记录表单是底部弹出的浮层。三件事让这个名字烂掉了:
  1. 记录表单上一批已经搬成 pages/addrecord 独立页面了
  2. 可选值写死 6 个,而记录事项现在有 24 种且后台可配 —— 新加的一个都选不到
  3. 里头的 report / taskDetail 早就不存在,选了等于点了没反应

现在改成「关联记录事项」,下拉读 record_types 表。后台加一种事项,
这里立刻能选到。

## 这一页原来和后端已经对不上了
它的结构(今日任务 / 30 天计划 / 提醒 三段、第 N 天偏移)就是上一个提交在后端
推翻掉的那套。你在上面配的东西一部分已经不会生效——所以先改它,不等前端。

  三段 → 两段(计划事项 / 到期提醒)
  「第 N 天 + 标签」 → 重复规则:每天 / 每周(勾星期几)/ 每月(几号)/ 一次性
  一个 (物种,阶段) 一套 → 多套,列表页展示,每套有名字和摘要

## 后端跟着改成按 id 存取
原来是「按 species+stage 覆盖」:GET/PUT /admin/care-templates?species=&stage=。
那个模型下「让用户选方案」无从选起。改成
  GET    /admin/care-templates       列全部
  GET    /admin/care-templates/:id
  POST   /admin/care-templates       新增或更新
  DELETE /admin/care-templates/:id

## 唯一索引是真拦路石
从模型 tag 里去掉 uniqueIndex:idx_species_stage 之后,新增第二套仍然报
Duplicate entry —— **AutoMigrate 会加索引但不会删已有的**。写了个一次性程序
DropIndex 才通。这个坑值得记:改索引不能只改 tag。

## 校验放服务端
一条配错(「每周」没勾星期几)会让套用时静默少排几条,用户看不出是配错了。
所以保存时就拦,并且报出是哪一条:「梳毛」选了每周,但没勾星期几。

## 列表里报「展开后大概多少条」
只报明细条数会骗人:4 条「每天」套一整月就是 120 多条。
列表和小程序端的方案卡都按重复方式分开报数。

## 验证(预生产库实跑 service 层)
  8 套现有方案迁移后都有名字
  没名字 / 每周没勾星期几 / 每月几号填 0 → 三条校验都拦住并指名道姓
  (猫,成年期) 成功配出第二套「成年猫省心版」,小程序端能看到 2 套可选
  tsc --noEmit 通过;npm run build 后重启,/admin/ 资源哈希与新构建一致

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:59:52 +08:00

298 lines
10 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 优先,无则内置默认)
// AdminListTemplates 后台:全部方案(含停用),按物种阶段排
func (s *Service) AdminListTemplates() []model.CareTemplate {
var all []model.CareTemplate
s.db.Preload("Items", func(db *gorm.DB) *gorm.DB { return db.Order("kind, sort, id") }).
Order("species asc, stage asc, sort asc, id asc").Find(&all)
if all == nil {
return []model.CareTemplate{}
}
return all
}
// AdminGetTemplate 后台:取一套
func (s *Service) AdminGetTemplate(id string) (*model.CareTemplate, error) {
var tpl model.CareTemplate
if err := s.db.Preload("Items", func(db *gorm.DB) *gorm.DB { return db.Order("kind, sort, id") }).
First(&tpl, "id = ?", id).Error; err != nil {
return nil, ErrNotFound
}
return &tpl, nil
}
// DeleteTemplate 删一套方案。已经套用出去的计划节点不受影响——
// 套用时就已经展开成实体行、和模板断开了
func (s *Service) DeleteTemplate(id string) error {
return s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("template_id = ?", id).Delete(&model.CareTemplateItem{}).Error; err != nil {
return err
}
return tx.Delete(&model.CareTemplate{}, "id = ?", id).Error
})
}
// SaveCareTemplate 后台保存:整表替换该组合的明细
// SaveTemplate 存一套方案。id 为空则新建 —— 同一个 (物种, 阶段) 可以有多套,
// 用户建档时挑一套。原来是「按 species+stage 覆盖」,那个模型下
// 「让用户选方案」根本无从选起
func (s *Service) SaveTemplate(in *model.CareTemplate, items []model.CareTemplateItem) error {
if !validSpecies(in.Species) || !validStage(in.Stage) {
return errors.New("物种或阶段不合法")
}
if strings.TrimSpace(in.Name) == "" {
return errors.New("方案要有名字 —— 用户建档时看到的就是它")
}
// 明细先校验一遍再落库:一条配错(比如「每周」没勾星期几)会让套用时
// 静默少排几条,用户看不出来是配错了
clean := make([]model.CareTemplateItem, 0, len(items))
for i := range items {
it := items[i]
it.Title = strings.TrimSpace(it.Title)
if it.Title == "" {
continue
}
if it.Kind != model.CareKindReminder {
it.Kind = model.CareKindPlan
switch it.Repeat {
case model.RepeatDaily, model.RepeatMonthly:
case model.RepeatWeekly:
if len(weekdaysOf(it.Weekdays)) == 0 {
return errors.New("「" + it.Title + "」选了每周,但没勾星期几")
}
default:
it.Repeat = model.RepeatOnce
}
if it.Repeat == model.RepeatMonthly && (it.MonthDay < 1 || it.MonthDay > 31) {
return errors.New("「" + it.Title + "」选了每月,几号要填 1-31")
}
}
it.ID = ""
it.Sort = i
clean = append(clean, it)
}
return s.db.Transaction(func(tx *gorm.DB) error {
if in.ID == "" {
if err := tx.Create(in).Error; err != nil {
return err
}
} else {
if err := tx.Model(&model.CareTemplate{}).Where("id = ?", in.ID).Updates(map[string]any{
"species": in.Species, "stage": in.Stage, "name": in.Name,
"summary": in.Summary, "sort": in.Sort, "enabled": in.Enabled,
}).Error; err != nil {
return err
}
if err := tx.Where("template_id = ?", in.ID).Delete(&model.CareTemplateItem{}).Error; err != nil {
return err
}
}
for i := range clean {
clean[i].TemplateID = in.ID
}
if len(clean) > 0 {
return tx.Create(&clean).Error
}
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}
}