Files
sundynix-pets/pets-be/internal/service/care_template.go
T
Blizzard c4c0c2b7cd feat: 生命阶段按猫狗真实生理规律细分,划分标准和判定用同一份数据
## 你问的「养了半年会不会自动切换」
原来的答案是「都不会,而且比这更糟」:
  age「2个月」  建档时算好存进库,养半年后首页还显示 2个月
  stage「幼年期」同上,一年后还是幼年期
  养护方案      按 stage 挑的,所以永远给幼猫那套
NormalizeStage 只在建档那一次调用(handler/pet.go:51),之后就是两个死字符串。

现在:
  age  改成计算字段(gorm:"-"),读取时现算。它没有手改需求,存下来只会过期。
       填在 ownedPet 这个收口里,不是逐个函数补——那样漏一个就返回空年龄。
  stage 保留存储(有正当的手改需求:领养的猫生日是估的,主人按体型判断更准),
       但加了漂移检测 GET /pets/:id/stage-drift。

**系统负责发现,用户负责决定。** 不自动改的三个理由:
  1. 用户可能手改过
  2. 已经排好的这个月计划不该被推翻(套用是「往里加」不是「替换」)
  3. 偷偷换会让用户莫名发现计划变了,找不到原因

## 猫狗不能共用阈值 —— 这是这次最实质的发现
原来一刀切「<12月幼年,>=7岁老年」,对猫勉强,对狗两头都错:
  吉娃娃 8 个月已性成熟、可换成犬粮;大丹犬 8 个月还在长骨头,
  这时喂成犬粮(钙磷比和热量密度都不同)会加重关节负担。
  吉娃娃 7 岁还在成年期(8 岁才进中年,能活 13-16 年);
  大丹犬 7 岁已是老年(平均寿命 7-10 年)。

所以狗的阶段判定要看体型。体型不能靠当前体重(幼犬体重说明不了成年后多大),
只能靠品种 —— breeds 表加 size_class,45 个犬种逐个标了。

实测:同样 10 个月大 → 吉娃娃青年期、柴犬幼年期、金毛幼年期、大丹犬幼年期;
      同样 7 岁 → 吉娃娃成年期、大丹犬老年期。

## 阶段 3 段 → 5 段
幼年期 / 青年期 / 成年期 / 中年期 / 老年期。
标签做成物种中立(不分幼猫期/幼犬期)——物种本来是另一个字段,
分开会让 (species,stage) 的组合和方案配置都翻倍。

「刚到家 0-30 天」不进这五段:它和年龄正交(刚接回家的成年猫两套都需要),
代码里本来就是叠加处理的。

猫按 AAFP/AAHA 猫生命阶段指南那套,在成年里细分出中年。
狗五段的阈值按体型走四组,越大的犬种生长期越长、老得越早。

## 划分标准和判定用同一张表
StageRules 那张表既驱动判定,也直接吐给前端当依据展示
(GET /api/life-stages?species=dog&size=giant)。

不是「代码里判定 + 文档里写标准」——后者一定会漂:改了阈值忘了改文档,
用户看到的说明和实际行为对不上,比没有说明更糟。

建档第三步的阶段选择器现在会显示「0-18 月 · 巨型犬生长期最长,18 个月还在长;
这阶段最忌过度补钙」,还会标出「巨型犬(成年 >40kg)——阶段阈值按体型算」。
前端和后台的 STAGES 硬编码列表都删了,改成读后端。

## 10 套方案,内容按各阶段真实重点配
不是把同一份换个名字:
  幼年期 疫苗序列 + 驱虫 + 社会化,大型犬还要控制生长速度
  青年期 绝育窗口 + 换成年粮
  成年期 维持期,把基线数据记下来
  中年期 年度血检开始有意义,体重和牙结石重点看
  老年期 体检半年一次;猫记饮水量(多饮多尿是慢性肾病最典型的早期表现)、
         狗记精神食欲,都设成「重要」

老的 8 套(按 3 段配的)改名停用不删:万一新内容有问题还能翻回去看;
而且已套用出去的计划早就和模板断开了,停用不影响任何用户。

## 验证(预生产库实跑)
  体型判定    10 个月 / 7 岁两组对照,四个体型都落在预期阶段
  age         '13个月' 现算,不入库
  漂移检测    手改成幼年期后 drifted=true,连带给出新阶段的依据和重点
  划分标准    猫 5 段、狗 4 个体型各 5 段,老年起点 7/8/10/11 岁
  全链路      3 个月大丹犬建档 → 幼年期 → 0 任务 + 4 条必要提醒 →
              挑「幼犬标准照护」→ 套用下月 104 条节点,
              周四那天正确出现每周项(检查爪垫和耳朵)
  迁移        45 个犬种体型回填;现有 5 只宠物阶段重算后无变化(原本都对)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 12:51:10 +08:00

259 lines
9.1 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"
)
// 阶段常量、CareStages 和判定逻辑都搬到 life_stage.go 了:
// 那边一张表既驱动判定又直接吐给前端当「划分依据」,两个用途一份数据。
// 阶段从 3 段(幼年/成年/老年)细分成 5 段,狗还按体型走不同阈值。
// CareSpecies 支持的物种(字符串与 onboarding 一致)
var CareSpecies = []string{model.SpeciesCat, model.SpeciesDog}
// 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
}
// 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}
}