refactor(be): 养护模板也从代码里移出,只从数据库读

和文章同一个理由:模板是运营内容,不是代码。留一套内置默认还额外带来
一个坏处——库里配的和代码里写的会悄悄分叉,出问题时分不清用户拿到的
到底是哪一套。

删掉:
- builtinCat / builtinDog / builtinCareItems(8 套 × 96 条)
- SeedCareTemplates(启动时写内置默认)
- ResetCareTemplate + 后台「恢复默认」按钮 —— 已经没有默认可恢复

careTemplateItems 现在查不到就返回空。这意味着后台把某个组合配空了,
新用户会拿到一个什么都没有的首页而且不报错,所以 seedPetDefaults 里
加了一条 log:模板为空时打出 species/stage/petID,否则没人能发现。

「AI 生成草稿」原来在 AI 未开启或调用失败时静默返回内置默认,看起来
像是 AI 生成的。现在直接报错说清原因,让管理员知道要么开 AI 要么手填。

数据在库里,删代码不影响:预生产 8 套 96 条原样在,建档端到端复测通过
(3 月龄幼猫仍拿到「下一针疫苗·21 天后」,成年犬仍有疫苗驱虫提醒)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-29 13:59:11 +08:00
parent 4f6f0f632e
commit 7acdf916d5
10 changed files with 89 additions and 314 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/admin/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>admin</title>
<script type="module" crossorigin src="/admin/assets/index-nel7hMHL.js"></script>
<script type="module" crossorigin src="/admin/assets/index-GGI_ie1V.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-BjLxu16U.css">
</head>
<body>
-17
View File
@@ -325,23 +325,6 @@ func (h *Handler) AdminSaveCareTemplate(c *gin.Context) {
response.OK(c, data)
}
// AdminResetCareTemplate POST /api/admin/care-templates/reset
// 把某组合恢复成内置默认。启动时的 seed 只补缺失、不覆盖已有,
// 想把新版内置模板推到线上必须走这个显式动作。
func (h *Handler) AdminResetCareTemplate(c *gin.Context) {
var req careGenReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
items, err := h.svc.ResetCareTemplate(req.Species, req.Stage)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, gin.H{"species": req.Species, "stage": req.Stage, "items": items})
}
type careGenReq struct {
Species string `json:"species"`
Stage string `json:"stage"`
-1
View File
@@ -144,7 +144,6 @@ func registerAdminAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manag
g.GET("/care-templates", h.AdminGetCareTemplate)
g.PUT("/care-templates", h.AdminSaveCareTemplate)
g.POST("/care-templates/generate", h.AdminGenerateCareTemplate)
g.POST("/care-templates/reset", h.AdminResetCareTemplate)
g.GET("/community-bot", h.AdminGetCommunityBot)
g.PUT("/community-bot", h.AdminSaveCommunityBot)
+21 -206
View File
@@ -62,6 +62,18 @@ func monthsSince(t time.Time) int {
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, "狗") {
@@ -80,16 +92,17 @@ func validStage(s string) bool {
return false
}
// careTemplateItems 取某组合的模板明细DB 优先,无则回退内置默认
// 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 && len(tpl.Items) > 0 {
return tpl.Items
if err != nil {
return nil
}
return builtinCareItems(species, stage)
return tpl.Items
}
// GetCareTemplate 后台读取:返回某组合的明细(DB 优先,无则内置默认)
@@ -150,7 +163,7 @@ func (s *Service) GenerateCareTemplate(species, stage string) ([]model.CareTempl
return nil, errors.New("invalid species or stage")
}
if !s.ai.Enabled() {
return builtinCareItems(species, stage), nil
return nil, errors.New("AI 未开启,无法生成草稿;请手动添加明细")
}
spName := "猫"
if species == model.SpeciesDog {
@@ -170,11 +183,11 @@ func (s *Service) GenerateCareTemplate(species, stage string) ([]model.CareTempl
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
return nil, fmt.Errorf("AI 生成失败:%w", err)
}
items, ok := parseCareItems(out)
if !ok || len(items) == 0 {
return builtinCareItems(species, stage), nil
return nil, errors.New("AI 返回的内容解析不出可用明细,请重试或手动添加")
}
return items, nil
}
@@ -219,42 +232,7 @@ func parseCareItems(out string) ([]model.CareTemplateItem, bool) {
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
}
// ---- 明细构造小工具 ----
// ---- 明细构造小工具(供 AI 草稿解析用)----
func careTask(title, desc, priority, sheet string) model.CareTemplateItem {
return model.CareTemplateItem{Kind: model.CareKindTask, Title: title, Description: desc, Priority: priority, SheetType: sheet}
@@ -265,166 +243,3 @@ func carePlan(day int, label, title, desc, sheet string) model.CareTemplateItem
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)
}
+7
View File
@@ -2,6 +2,7 @@ package service
import (
"encoding/json"
"log"
"strings"
"time"
@@ -150,6 +151,12 @@ func (s *Service) seedPetDefaults(tx *gorm.DB, pet *model.Pet) error {
today := time.Now()
species := SpeciesOf(pet.Type)
items := s.careTemplateItems(species, pet.Stage)
// 模板是后台配的,代码里没有兜底。配空了新用户会拿到一个什么都没有的
// 首页,而且不报错——所以这里必须留下痕迹,否则没人能发现。
if len(items) == 0 {
log.Printf("care: 养护模板为空 species=%s stage=%s,新宠 %s 不会生成任务/计划/提醒,请到后台配置",
species, pet.Stage, pet.ID)
}
// 刚到家不到 30 天,把安置期那套的任务和计划叠加上来。
// 「刚到家」和「幼年/成年/老年」是两个正交的维度,原来挤在一个四选一里,