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 } // 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 取某组合的模板明细:DB 优先,无则回退内置默认 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 && len(tpl.Items) > 0 { return tpl.Items } return builtinCareItems(species, stage) } // 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 builtinCareItems(species, stage), nil } 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 builtinCareItems(species, stage), nil } items, ok := parseCareItems(out) if !ok || len(items) == 0 { return builtinCareItems(species, stage), nil } 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 } // SeedCareTemplates 启动幂等 seed:库中无任何模板时写入 8 套内置默认 // 按组合补齐:只创建缺失的那几套,已有的(可能被管理员改过)一律不动。 // 想把内置默认推到已有组合上,走后台的「恢复默认」——那是显式操作,不该由启动时偷偷覆盖。 func (s *Service) SeedCareTemplates() error { for _, sp := range CareSpecies { for _, stage := range CareStages { var count int64 if err := s.db.Model(&model.CareTemplate{}). Where("species = ? AND stage = ?", sp, stage).Count(&count).Error; err != nil { return err } if count > 0 { continue } tpl := model.CareTemplate{Species: sp, Stage: stage, Items: builtinCareItems(sp, stage)} if err := s.db.Create(&tpl).Error; err != nil { return err } } } return nil } // ResetCareTemplate 把某个组合恢复成内置默认(后台按钮触发) func (s *Service) ResetCareTemplate(species, stage string) ([]model.CareTemplateItem, error) { if !validSpecies(species) || !validStage(stage) { return nil, errors.New("物种或阶段不合法") } items := builtinCareItems(species, stage) if err := s.SaveCareTemplate(species, stage, items); err != nil { return nil, err } return s.careTemplateItems(species, stage), nil } // ---- 明细构造小工具 ---- func careTask(title, desc, priority, sheet string) model.CareTemplateItem { return model.CareTemplateItem{Kind: model.CareKindTask, 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, 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} } // builtinCareItems 内置默认模板(猫/狗 × 4 阶段,共 8 套)。 // 兽医常识向:狗每个阶段都含遛狗/牵引,猫含猫砂盆/梳毛/饮水;疫苗驱虫节奏按物种阶段区分。 // builtinCareItems 内置默认养护模板(猫/狗 × 4 组,共 8 套)。 // // 内容依据主流小动物临床共识(WSAVA 疫苗指南、ESCCAP/CAPC 驱虫建议、AAHA // 生命阶段指南)与国内实际情况整理: // - 猫三联(FVRCP) 幼猫共 3 针,8-9 周龄起,每针间隔 3-4 周,末针不早于 16 周龄 // - 犬联苗幼犬 2-3 针,6-8 周龄起,间隔 3-4 周,末针不早于 16 周龄; // 狂犬 3 月龄首针、其后每年一次(国内为法定要求) // - 体内驱虫:6 月龄前每月一次,之后每 3 个月一次;体外每月一次(随环境调整) // - 猫 12 月龄成年、7 岁进入老年;犬 12 月龄成年(大型犬 18-24 月)、7 岁进入老年 // // 「刚到家 0-30 天」不是年龄阶段,而是叠加包:建档时按年龄阶段取一套, // 若到家不足 30 天,再把这一套的任务和计划叠加上去(见 seedPetDefaults)。 func builtinCareItems(species, stage string) []model.CareTemplateItem { if species == model.SpeciesDog { return builtinDog(stage) } return builtinCat(stage) } func builtinCat(stage string) []model.CareTemplateItem { switch stage { case StageNewHome: return []model.CareTemplateItem{ careTask("记录一次体重", "到家当天称一次做基线,之后每周一次看趋势", "重要", "weight"), careTask("保持原来的猫粮", "换环境已经够折腾了,粮先别换;要换也要 7 天过渡", "重要", "food"), careTask("清理猫砂盆并观察", "应激性软便很常见,超过 3 天不好转要就医", "", "poop"), careTask("陪玩 15 分钟", "用逗猫棒隔着距离玩,头两天别强行抱它", "", ""), carePlan(0, "今天", "布置一个能躲的角落", "猫砂盆、食盆、水碗分开放;纸箱或猫窝给它安全感", ""), carePlan(1, "第2天", "先只开放一个房间", "空间由小到大,全屋乱窜会加重应激", ""), carePlan(3, "第3天", "观察食欲与排便", "肯吃、肯拉、肯出来走动,就是适应良好", "poop"), carePlan(7, "第7天", "第一次正式称重", "作为之后所有体重趋势的基线", "weight"), carePlan(14, "第14天", "预约首次体检与驱虫", "带一份新鲜粪便去,可以顺便查体内寄生虫", "vaccine"), carePlan(30, "第30天", "确认免疫计划已排好", "把三针联苗的时间点定下来,别拖到成年", "vaccine"), } case StageJuvenile: return []model.CareTemplateItem{ careTask("记录一次体重", "幼猫每周称 1-2 次,稳定增重比单次数字重要", "重要", "weight"), careTask("定时定量喂食", "3-6 月龄每天 3-4 餐,6 月龄后 2-3 餐,自由采食容易过胖", "", "food"), careTask("检查免疫进度", "猫三联共 3 针,每针间隔 3-4 周,末针不早于 16 周龄", "重要", "vaccine"), careTask("陪玩与社会化", "每天 2 次各 10-15 分钟;多摸爪子和耳朵,长大好剪指甲", "", ""), carePlan(0, "今天", "对照月龄核免疫和驱虫", "算清楚还差几针、下次驱虫什么时候", "vaccine"), carePlan(3, "第3天", "换粮观察", "换粮按 7 天过渡,每天替换约 1/7,软便就放慢", "food"), carePlan(7, "第7天", "体重趋势检查", "掉秤或长期不增重要查球虫、寄生虫", "weight"), carePlan(14, "第14天", "体内外驱虫", "6 月龄前体内每月一次,体外按环境每月一次", "medicine"), carePlan(21, "第21天", "养成梳毛和剪指甲的习惯", "从小做,成年后不抗拒;顺便摸一遍有没有硬块", ""), carePlan(30, "第30天", "月度复盘 + 绝育咨询", "6-8 月龄可以和医生聊绝育时间", "report"), careReminder("vaccine", "下一针猫三联", 21, "幼猫共 3 针,间隔 3-4 周"), careReminder("deworm", "体内外驱虫", 30, "6 月龄前每月一次"), careReminder("weight", "体重记录", 0, "每周 1-2 次"), careReminder("monthlyReport", "月度报告", 0, "每月 1 日"), } case StageSenior: return []model.CareTemplateItem{ careTask("记录一次体重", "老年猫掉秤常是甲亢或慢性肾病的第一个信号", "重要", "weight"), careTask("关注饮水量与尿量", "又喝又尿是慢性肾病的典型表现,比食欲变化更早出现", "重要", "symptom"), careTask("观察进食与口腔", "牙口疼会让它挑软食甚至不吃,别只当成挑嘴", "", "food"), carePlan(0, "今天", "老年状态整体自查", "精神、食欲、饮水、体重、被毛过一遍", ""), carePlan(7, "第7天", "体重与饮水一起记", "两条曲线放在一起看,慢病能早发现几个月", "weight"), carePlan(14, "第14天", "整理用药与体检报告", "长期药别断,检查单留好方便对比", "medicine"), carePlan(21, "第21天", "关节与活动观察", "跳不上高处、理毛变少、猫砂盆边缘迈不过去都要留意", "symptom"), carePlan(30, "第30天", "安排半年一次体检", "老年猫建议每 6-12 个月一次,含血检和尿检", "report"), careReminder("vaccine", "年度疫苗加强", 0, "每年一次"), careReminder("deworm", "体内驱虫", 90, "每 3 个月一次"), careReminder("weight", "体重记录", 0, "每两周一次"), careReminder("monthlyReport", "月度报告 / 半年体检", 0, "每月 1 日"), } default: // 成年期 return []model.CareTemplateItem{ careTask("清理猫砂盆并观察", "公猫尿闭是急症,24 小时排不出尿必须马上就医", "重要", "poop"), careTask("检查饮水情况", "吃干粮更要多喝水;流动水或多放几个水碗都管用", "", "symptom"), careTask("梳毛", "每周 2-3 次,减少毛球,也是摸有没有硬块的机会", "", ""), carePlan(0, "今天", "全面健康自查", "体重、精神、食欲、饮水、被毛、口腔过一遍", ""), carePlan(7, "第7天", "体重记录", "成年猫每月一次即可;超重是绝大多数慢病的起点", "weight"), carePlan(14, "第14天", "口腔检查", "牙龈红肿、口臭明显,该洗牙了", "symptom"), carePlan(21, "第21天", "体外驱虫", "只在室内也可能被带进来的跳蚤找上", "medicine"), carePlan(30, "第30天", "生成月度报告", "回顾本月记录,导出成长卡片", "report"), careReminder("vaccine", "年度疫苗加强", 0, "每年一次"), careReminder("deworm", "体内驱虫", 90, "每 3 个月一次"), careReminder("weight", "体重记录", 0, "每月一次"), careReminder("monthlyReport", "月度报告", 0, "每月 1 日"), } } } func builtinDog(stage string) []model.CareTemplateItem { switch stage { case StageNewHome: return []model.CareTemplateItem{ careTask("记录一次体重", "到家当天称一次做基线,之后每周一次", "重要", "weight"), careTask("保持原来的狗粮", "换粮和换环境别撞在一起;要换按 7 天过渡", "重要", "food"), careTask("定点排便训练", "固定位置、成功就立刻表扬;事后惩罚只会让它躲着拉", "", "poop"), careTask("先在室内熟悉环境", "疫苗没打全之前不要去公共遛狗区和宠物店", "", ""), carePlan(0, "今天", "安顿:食盆、水碗、窝、厕所", "位置固定下来,别频繁挪动", ""), carePlan(1, "第2天", "适应牵引绳", "先在家戴着走两圈,出门前就该习惯", ""), carePlan(3, "第3天", "观察进食与排便", "应激期软便常见,精神差、带血要就医", "poop"), carePlan(7, "第7天", "第一次正式称重", "作为之后所有体重趋势的基线", "weight"), carePlan(14, "第14天", "预约疫苗与体检", "确认联苗针次,3 月龄以上要安排狂犬", "vaccine"), carePlan(30, "第30天", "免疫打全后开始户外散步", "从安静路段开始,逐步接触人、车、其它狗", ""), } case StageJuvenile: return []model.CareTemplateItem{ careTask("牵引散步与社会化", "3-16 周是社会化关键期;疫苗未全时可抱着或在自家院子接触外界", "重要", ""), careTask("记录一次体重", "幼犬长得快,每周称 1-2 次", "重要", "weight"), careTask("定时定量喂食", "3-6 月龄每天 3 餐,6 月龄后 2 餐", "", "food"), careTask("基础训练 10 分钟", "坐下、召回、不咬手;短时高频比一次练很久有效", "", ""), carePlan(0, "今天", "核对免疫进度", "联苗 2-3 针间隔 3-4 周,3 月龄安排狂犬", "vaccine"), carePlan(3, "第3天", "换粮观察", "7 天过渡,软便就放慢速度", "food"), carePlan(7, "第7天", "体重趋势检查", "增长停滞要查寄生虫和喂食量", "weight"), carePlan(14, "第14天", "体内外驱虫", "6 月龄前体内每月一次,体外每月一次", "medicine"), carePlan(21, "第21天", "巩固定点排便与独处训练", "从 5 分钟开始拉长独处时间,预防分离焦虑", ""), carePlan(30, "第30天", "月度复盘 + 绝育咨询", "小型犬 6-9 月龄,大型犬建议 12-18 月龄再做,等骨骼长好", "report"), careReminder("vaccine", "下一针联苗 / 狂犬", 21, "联苗间隔 3-4 周,3 月龄起狂犬"), careReminder("deworm", "体内外驱虫", 30, "6 月龄前每月一次"), careReminder("weight", "体重记录", 0, "每周 1-2 次"), careReminder("monthlyReport", "月度报告", 0, "每月 1 日"), } case StageSenior: return []model.CareTemplateItem{ careTask("温和遛狗", "缩短单次时长、增加次数;避免跳跃和频繁爬楼梯", "重要", ""), careTask("记录一次体重", "骤增骤减都要重视,老年犬掉秤要查甲状腺和肿瘤", "重要", "weight"), careTask("观察关节与精神", "起身困难、后腿打滑、不愿上车,都是关节问题的早期表现", "", "symptom"), carePlan(0, "今天", "老年状态整体自查", "精神、食欲、饮水、关节活动一起看", ""), carePlan(7, "第7天", "体重与饮水一起记", "多饮多尿要查肾和血糖", "weight"), carePlan(14, "第14天", "整理用药与体检报告", "长期药别断,检查单留好方便对比", "medicine"), carePlan(21, "第21天", "口腔与体表触诊", "牙结石、乳腺和体表肿物,摸一遍心里有数", "symptom"), carePlan(30, "第30天", "安排半年一次体检", "老年犬建议每 6 个月一次,含血检和胸腹部检查", "report"), careReminder("vaccine", "年度疫苗 + 狂犬加强", 0, "每年一次,狂犬为法定要求"), careReminder("deworm", "体内驱虫", 90, "每 3 个月一次"), careReminder("weight", "体重记录", 0, "每两周一次"), careReminder("monthlyReport", "月度报告 / 半年体检", 0, "每月 1 日"), } default: // 成年期 return []model.CareTemplateItem{ careTask("遛狗", "每天 2 次共 30-60 分钟;运动量不够是拆家和吠叫的头号原因", "重要", ""), careTask("刷牙", "每周 2-3 次。3 岁以上的犬多数已有牙周问题", "", ""), careTask("记录一次体重", "每月一次。摸得到肋骨但看不见,是标准体型", "", "weight"), carePlan(0, "今天", "全面健康自查", "体重、精神、食欲、被毛、指甲、耳道过一遍", ""), carePlan(7, "第7天", "体重记录", "对比上月,稳定为宜", "weight"), carePlan(14, "第14天", "体外驱虫", "遛狗接触草地,蜱虫和跳蚤风险比猫高得多", "medicine"), carePlan(21, "第21天", "指甲与耳道检查", "指甲过长会影响步态;垂耳犬更容易耳道发炎", "symptom"), carePlan(30, "第30天", "生成月度报告", "回顾本月记录,导出成长卡片", "report"), careReminder("vaccine", "年度疫苗 + 狂犬加强", 0, "每年一次,狂犬为法定要求"), careReminder("deworm", "体内驱虫", 90, "每 3 个月一次"), careReminder("weight", "体重记录", 0, "每月一次"), careReminder("monthlyReport", "月度报告", 0, "每月 1 日"), } } } // NormalizeStage 定下最终使用的年龄阶段。 // 用户手选优先(他可能比生日更清楚,比如领养来的估算年龄), // 但只认三个年龄阶段;传了「刚到家」或空值时,一律按生日推断。 func NormalizeStage(species, picked string, birthday *time.Time) string { for _, s := range AgeStages { if picked == s { return picked } } return StageFromBirthday(species, birthday) }