c4c0c2b7cd
## 你问的「养了半年会不会自动切换」
原来的答案是「都不会,而且比这更糟」:
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>
259 lines
9.1 KiB
Go
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}
|
|
}
|