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>
This commit is contained in:
Blizzard
2026-07-30 12:51:10 +08:00
parent afdbf94450
commit c4c0c2b7cd
18 changed files with 735 additions and 168 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-DyVnSuaV.js"></script>
<script type="module" crossorigin src="/admin/assets/index-BOP-ztlG.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-PPcOOdtF.css">
</head>
<body>
+233
View File
@@ -0,0 +1,233 @@
package database
import (
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/model"
)
// 养护方案预置:5 个阶段 × 2 物种,各一套「标准照护」。
//
// 内容按各阶段的真实生理重点配,不是把同一份东西换个名字:
// 幼年期 疫苗序列 + 驱虫 + 社会化,大型犬还要控制生长速度
// 青年期 绝育窗口 + 换成年粮
// 成年期 维持期,重点是把基线数据记下来(体重趋势、年度体检)
// 中年期 年度血检开始有意义,体重和牙结石重点看
// 老年期 体检频率加倍,猫看肾和甲状腺、狗看关节和心脏
//
// 和 record_types / breeds 一样只在表为空时铺一次,之后一切以数据库为准。
//
// 这些是「结构」不是「内容」:方案表空着的话建档最后一步没东西可挑,
// 用户建完档只能自己从零排计划。
func plan(title, desc, repeat, sheet string) model.CareTemplateItem {
return model.CareTemplateItem{Kind: model.CareKindPlan, Repeat: repeat,
Title: title, Description: desc, SheetType: sheet}
}
func weekly(title, desc, weekdays, sheet string) model.CareTemplateItem {
it := plan(title, desc, model.RepeatWeekly, sheet)
it.Weekdays = weekdays
return it
}
func monthly(title, desc string, day int, sheet string) model.CareTemplateItem {
it := plan(title, desc, model.RepeatMonthly, sheet)
it.MonthDay = day
return it
}
func once(title, desc string, day int, sheet string) model.CareTemplateItem {
it := plan(title, desc, model.RepeatOnce, sheet)
it.Day = day
return it
}
func remind(rtype, title string, offset int, freq string) model.CareTemplateItem {
return model.CareTemplateItem{Kind: model.CareKindReminder, ReminderType: rtype,
Title: title, OffsetDays: offset, Frequency: freq}
}
func important(it model.CareTemplateItem) model.CareTemplateItem {
it.Priority = "重要"
return it
}
// TplDef 一套方案的定义(迁移程序要读它)
type tplDef struct {
Species string
Stage string
Name string
Summary string
Items []model.CareTemplateItem
}
// CareTemplateDefs 导出给一次性迁移程序用
func CareTemplateDefs() []tplDef { return careTemplateDefs() }
// SeedCareTemplatesForce 不检查表空,直接铺。只给迁移程序用
func SeedCareTemplatesForce(db *gorm.DB) error { return insertCareTemplates(db) }
func careTemplateDefs() []tplDef {
const (
J = "幼年期"
Y = "青年期"
A = "成年期"
M = "中年期"
S = "老年期"
)
return []tplDef{
// ───────────────── 猫 ─────────────────
{model.SpeciesCat, J, "幼猫标准照护", "少量多餐、疫苗序列和社会化,六月龄前把基础打好", []model.CareTemplateItem{
important(plan("喂食 3-4 次", "幼猫胃容量小,一天分 3-4 顿,用幼猫粮", model.RepeatDaily, "food")),
plan("清理猫砂盆", "每天铲一次,顺手看便便形状和颜色", model.RepeatDaily, "litterbox"),
plan("陪玩 15 分钟", "逗猫棒模拟狩猎,消耗精力也是社会化", model.RepeatDaily, ""),
weekly("称一次体重", "幼猫每周都在长,体重曲线比单次数值有用", "1", "weight"),
weekly("检查耳朵和眼睛", "幼猫耳螨和眼部分泌物高发", "4", "ear"),
once("核对疫苗进度", "对照月龄看第几针,三针核心疫苗要在六月龄前打完", 2, "vaccine"),
once("首次体内驱虫", "幼猫驱虫比成猫频繁,按体重给药", 5, "deworm"),
remind("vaccine", "下一针疫苗", 21, "幼猫期每 3-4 周一针,共三针"),
remind("deworm", "体内外驱虫", 30, "幼猫每月一次"),
remind("weight", "记录体重", 7, "每周一次"),
}},
{model.SpeciesCat, Y, "青年猫标准照护", "绝育窗口、换成猫粮,开始把体重控住", []model.CareTemplateItem{
plan("喂食 2 次", "六月龄后可以改成早晚两顿", model.RepeatDaily, "food"),
plan("清理猫砂盆", "每天铲一次", model.RepeatDaily, "litterbox"),
plan("陪玩 15 分钟", "性成熟后精力旺盛,玩够了才不拆家", model.RepeatDaily, ""),
weekly("称一次体重", "绝育前后代谢会变,体重要盯紧", "1", "weight"),
weekly("梳毛", "换毛期减少毛球", "4", "brush"),
once("咨询绝育时间", "多数猫 6-8 月龄可做,术前要空腹和体检", 3, "vet"),
once("换成猫粮", "一岁前后用 7 天时间按比例过渡,别一次换完", 14, "food"),
remind("vaccine", "年度加强疫苗", 60, "一年一次"),
remind("deworm", "体内外驱虫", 60, "每 2-3 个月一次"),
}},
{model.SpeciesCat, A, "成年猫标准照护", "维持期,重点是把基线数据记下来", []model.CareTemplateItem{
plan("喂食和换水", "定时定量,水每天换", model.RepeatDaily, "food"),
plan("清理猫砂盆", "每天铲一次,便便异常是最早的信号", model.RepeatDaily, "litterbox"),
weekly("梳毛", "长毛猫每天,短毛猫每周两次", "2,5", "brush"),
weekly("刷牙", "牙结石是成年猫最常被忽略的问题", "6", "tooth"),
monthly("称一次体重", "成年后体重该是平的,涨跌超过 5% 要留意", 1, "weight"),
monthly("洗猫砂盆", "整盆倒空清洗一次", 15, "litterbox"),
once("安排年度体检", "成年期一年一次,做基础血检建立基线", 7, "checkup"),
remind("vaccine", "年度加强疫苗", 90, "一年一次"),
remind("deworm", "体内外驱虫", 90, "每 3 个月一次"),
remind("monthlyReport", "看看这个月的记录", 30, "每月一次"),
}},
{model.SpeciesCat, M, "中年猫标准照护", "七岁起代谢变慢,年度血检开始能查出早期问题", []model.CareTemplateItem{
plan("喂食和换水", "中年后容易发胖,按体重控制热量", model.RepeatDaily, "food"),
plan("清理猫砂盆", "尿量和尿块大小要留意,肾病最早从这里看出来", model.RepeatDaily, "litterbox"),
weekly("梳毛", "自我梳理会减少,需要帮忙", "2,5", "brush"),
weekly("刷牙", "牙结石到这个年龄已经开始影响进食", "6", "tooth"),
monthly("称一次体重", "不明原因掉体重是中年猫要警惕的信号", 1, "weight"),
monthly("检查口腔", "看牙龈颜色和有没有结石", 15, ""),
once("安排年度体检", "血检 + 尿检,重点看肾功能和甲状腺", 7, "checkup"),
remind("vaccine", "年度加强疫苗", 90, "一年一次"),
remind("deworm", "体内外驱虫", 90, "每 3 个月一次"),
remind("weight", "记录体重", 30, "每月一次"),
}},
{model.SpeciesCat, S, "老年猫标准照护", "体检半年一次,肾和甲状腺是重点", []model.CareTemplateItem{
important(plan("记录饮水量", "多饮多尿是慢性肾病最典型的早期表现", model.RepeatDaily, "water")),
plan("喂食", "老年猫可能需要处方粮,少量多次更好消化", model.RepeatDaily, "food"),
plan("清理猫砂盆", "尿块变大或变多要立刻记下来", model.RepeatDaily, "litterbox"),
weekly("梳毛", "老年猫自己舔不到的地方要帮它梳", "2,5", "brush"),
weekly("称一次体重", "老年期体重变化要按周盯,不是按月", "1", "weight"),
monthly("检查关节活动", "跳不上原来能跳的地方就是信号", 1, ""),
once("安排半年体检", "血检 + 尿检 + 甲状腺,老年期半年一次", 7, "checkup"),
remind("vaccine", "年度加强疫苗", 90, "一年一次(可与医生商量减免)"),
remind("deworm", "体内外驱虫", 90, "每 3 个月一次"),
remind("monthlyReport", "看看这个月的记录", 30, "每月一次"),
}},
// ───────────────── 狗 ─────────────────
{model.SpeciesDog, J, "幼犬标准照护", "疫苗序列、社会化,大型犬还要控制生长速度", []model.CareTemplateItem{
important(plan("喂食 3-4 次", "幼犬一天分 3-4 顿;大型犬别过量补钙,长太快伤关节", model.RepeatDaily, "food")),
plan("外出排便和散步", "疫苗打齐前避免去流浪动物多的地方", model.RepeatDaily, ""),
plan("基础训练 10 分钟", "坐、等待、唤回。幼犬期是社会化窗口,错过很难补", model.RepeatDaily, ""),
weekly("称一次体重", "幼犬长得快,体重曲线也是喂食量的依据", "1", "weight"),
weekly("检查爪垫和耳朵", "散步后看有没有扎伤,垂耳犬要重点看耳道", "4", "ear"),
once("核对疫苗进度", "对照月龄看第几针,狂犬疫苗按当地规定单独安排", 2, "vaccine"),
once("首次体内驱虫", "按体重给药,幼犬比成犬频繁", 5, "deworm"),
remind("vaccine", "下一针疫苗", 21, "幼犬期每 3-4 周一针"),
remind("deworm", "体内外驱虫", 30, "幼犬每月一次"),
remind("weight", "记录体重", 7, "每周一次"),
}},
{model.SpeciesDog, Y, "青年犬标准照护", "骨骼刚闭合,可以加运动量了;绝育窗口也在这里", []model.CareTemplateItem{
plan("喂食 2 次", "改成早晚两顿", model.RepeatDaily, "food"),
plan("散步 30 分钟以上", "骨骼闭合后可以正常运动,之前不宜长距离跑跳", model.RepeatDaily, ""),
plan("训练 10 分钟", "把幼犬期学的固化下来", model.RepeatDaily, ""),
weekly("称一次体重", "换成犬粮期间容易长胖", "1", "weight"),
weekly("梳毛", "换毛期每天,平时每周两次", "2,5", "brush"),
once("咨询绝育时间", "大型犬建议等骨骼闭合后再做", 3, "vet"),
once("换成犬粮", "用 7 天按比例过渡,别一次换完", 14, "food"),
remind("vaccine", "年度加强疫苗", 60, "一年一次"),
remind("deworm", "体内外驱虫", 60, "每 2-3 个月一次"),
}},
{model.SpeciesDog, A, "成年犬标准照护", "维持期,重点是把基线数据记下来", []model.CareTemplateItem{
plan("喂食和换水", "定时定量,水每天换", model.RepeatDaily, "food"),
plan("散步 30-60 分钟", "运动量不够是成年犬发胖和行为问题的主因", model.RepeatDaily, ""),
weekly("梳毛", "顺手摸一遍有没有肿块", "2,5", "brush"),
weekly("刷牙", "牙结石是成年犬最常被忽略的问题", "6", "tooth"),
monthly("称一次体重", "成年后体重该是平的,涨跌超过 5% 要留意", 1, "weight"),
monthly("剪指甲", "听到走路哒哒响就该剪了", 15, "nail"),
once("安排年度体检", "一年一次,做基础血检建立基线", 7, "checkup"),
remind("vaccine", "年度加强疫苗", 90, "一年一次"),
remind("deworm", "体内外驱虫", 90, "每 3 个月一次"),
remind("monthlyReport", "看看这个月的记录", 30, "每月一次"),
}},
{model.SpeciesDog, M, "中年犬标准照护", "体重和关节是重点,年度血检开始有意义", []model.CareTemplateItem{
plan("喂食和换水", "代谢变慢,按体重下调热量", model.RepeatDaily, "food"),
plan("散步 30 分钟", "保持规律但降低强度,避免急停急转", model.RepeatDaily, ""),
weekly("梳毛并摸一遍身体", "中年后肿块发生率上升,每周摸一遍是最便宜的筛查", "2,5", "brush"),
weekly("刷牙", "牙结石到这个年龄已经开始影响进食", "6", "tooth"),
monthly("称一次体重", "中年犬超重会直接加重关节负担", 1, "weight"),
monthly("检查关节活动", "起身变慢、不愿上楼就是信号", 15, ""),
once("安排年度体检", "血检 + 关节检查,大型犬加拍髋关节", 7, "checkup"),
remind("vaccine", "年度加强疫苗", 90, "一年一次"),
remind("deworm", "体内外驱虫", 90, "每 3 个月一次"),
remind("weight", "记录体重", 30, "每月一次"),
}},
{model.SpeciesDog, S, "老年犬标准照护", "体检半年一次,关节和心脏是重点", []model.CareTemplateItem{
important(plan("记录精神和食欲", "老年犬变化来得快,每天一句话记下来", model.RepeatDaily, "")),
plan("喂食", "老年犬可能需要处方粮,少量多次更好消化", model.RepeatDaily, "food"),
plan("散步 15-20 分钟", "短而多次,比一次走久对关节友好", model.RepeatDaily, ""),
weekly("梳毛并摸一遍身体", "重点摸有没有新长的肿块", "2,5", "brush"),
weekly("称一次体重", "老年期体重变化要按周盯", "1", "weight"),
monthly("检查关节和牙齿", "起身、上楼、咀嚼有没有变吃力", 1, ""),
once("安排半年体检", "血检 + 心脏听诊 + 关节,老年期半年一次", 7, "checkup"),
remind("vaccine", "年度加强疫苗", 90, "一年一次(可与医生商量减免)"),
remind("deworm", "体内外驱虫", 90, "每 3 个月一次"),
remind("monthlyReport", "看看这个月的记录", 30, "每月一次"),
}},
}
}
// seedCareTemplates 只在方案表为空时铺一次
func seedCareTemplates(db *gorm.DB) error {
var count int64
if err := db.Model(&model.CareTemplate{}).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
return insertCareTemplates(db)
}
func insertCareTemplates(db *gorm.DB) error {
for i, d := range careTemplateDefs() {
tpl := model.CareTemplate{
Species: d.Species, Stage: d.Stage, Name: d.Name,
Summary: d.Summary, Sort: (i + 1) * 10, Enabled: true,
}
if err := db.Create(&tpl).Error; err != nil {
return err
}
items := make([]model.CareTemplateItem, 0, len(d.Items))
for j, it := range d.Items {
it.TemplateID = tpl.ID
it.Sort = j
items = append(items, it)
}
if len(items) > 0 {
if err := db.Create(&items).Error; err != nil {
return err
}
}
}
return nil
}
+49 -46
View File
@@ -23,7 +23,10 @@ func Seed(db *gorm.DB, cfg *config.Config) error {
if err := seedRecordTypes(db); err != nil {
return err
}
return seedBreeds(db)
if err := seedBreeds(db); err != nil {
return err
}
return seedCareTemplates(db)
}
// seedBreeds 品种预置。同 record_types:只在表完全为空时铺一次。
@@ -76,51 +79,51 @@ func seedBreeds(db *gorm.DB) error {
{Species: model.SpeciesCat, Initial: "Y", Name: "英国短毛猫", Sort: 330},
{Species: model.SpeciesCat, Initial: "Z", Name: "中华田园猫", Sort: 340},
// 狗 46 种
{Species: model.SpeciesDog, Initial: "A", Name: "阿拉斯加雪橇犬", Sort: 10},
{Species: model.SpeciesDog, Initial: "A", Name: "阿富汗猎犬", Sort: 20},
{Species: model.SpeciesDog, Initial: "A", Name: "澳洲牧羊犬", Sort: 30},
{Species: model.SpeciesDog, Initial: "B", Name: "比熊犬", Sort: 40},
{Species: model.SpeciesDog, Initial: "B", Name: "比格犬", Sort: 50},
{Species: model.SpeciesDog, Initial: "B", Name: "边境牧羊犬", Sort: 60},
{Species: model.SpeciesDog, Initial: "B", Name: "巴哥犬", Sort: 70},
{Species: model.SpeciesDog, Initial: "B", Name: "博美犬", Sort: 80},
{Species: model.SpeciesDog, Initial: "B", Name: "巴吉度猎犬", Sort: 90},
{Species: model.SpeciesDog, Initial: "B", Name: "北京犬", Sort: 100},
{Species: model.SpeciesDog, Initial: "C", Name: "柴犬", Sort: 110},
{Species: model.SpeciesDog, Initial: "C", Name: "川东猎犬", Sort: 120},
{Species: model.SpeciesDog, Initial: "D", Name: "德国牧羊犬", Sort: 130},
{Species: model.SpeciesDog, Initial: "D", Name: "杜宾犬", Sort: 140},
{Species: model.SpeciesDog, Initial: "D", Name: "大丹犬", Sort: 150},
{Species: model.SpeciesDog, Initial: "D", Name: "斗牛犬", Sort: 160},
{Species: model.SpeciesDog, Initial: "F", Name: "法国斗牛犬", Sort: 170},
{Species: model.SpeciesDog, Initial: "G", Name: "贵宾犬(泰迪)", Sort: 180},
{Species: model.SpeciesDog, Initial: "G", Name: "古代牧羊犬", Sort: 190},
{Species: model.SpeciesDog, Initial: "H", Name: "哈士奇", Sort: 200},
{Species: model.SpeciesDog, Initial: "H", Name: "蝴蝶犬", Sort: 210},
{Species: model.SpeciesDog, Initial: "J", Name: "金毛寻回犬", Sort: 220},
{Species: model.SpeciesDog, Initial: "J", Name: "吉娃娃", Sort: 230},
{Species: model.SpeciesDog, Initial: "K", Name: "柯基犬", Sort: 240},
{Species: model.SpeciesDog, Initial: "K", Name: "可卡犬", Sort: 250},
{Species: model.SpeciesDog, Initial: "K", Name: "苏格兰柯利犬", Sort: 260},
{Species: model.SpeciesDog, Initial: "L", Name: "拉布拉多寻回犬", Sort: 270},
{Species: model.SpeciesDog, Initial: "L", Name: "罗威纳犬", Sort: 280},
{Species: model.SpeciesDog, Initial: "L", Name: "灵缇犬", Sort: 290},
{Species: model.SpeciesDog, Initial: "L", Name: "腊肠犬", Sort: 300},
{Species: model.SpeciesDog, Initial: "M", Name: "马尔济斯犬", Sort: 310},
{Species: model.SpeciesDog, Initial: "M", Name: "迷你雪纳瑞", Sort: 320},
{Species: model.SpeciesDog, Initial: "N", Name: "牛头梗", Sort: 330},
{Species: model.SpeciesDog, Initial: "Q", Name: "秋田犬", Sort: 340},
{Species: model.SpeciesDog, Initial: "S", Name: "萨摩耶", Sort: 350},
{Species: model.SpeciesDog, Initial: "S", Name: "松狮犬", Sort: 360},
{Species: model.SpeciesDog, Initial: "S", Name: "圣伯纳犬", Sort: 370},
{Species: model.SpeciesDog, Initial: "X", Name: "雪纳瑞", Sort: 390},
{Species: model.SpeciesDog, Initial: "X", Name: "西高地白梗", Sort: 400},
{Species: model.SpeciesDog, Initial: "X", Name: "喜乐蒂牧羊犬", Sort: 410},
{Species: model.SpeciesDog, Initial: "Y", Name: "约克夏梗", Sort: 420},
{Species: model.SpeciesDog, Initial: "Y", Name: "银狐犬", Sort: 430},
{Species: model.SpeciesDog, Initial: "Z", Name: "中华田园犬", Sort: 440},
{Species: model.SpeciesDog, Initial: "Z", Name: "中亚牧羊犬", Sort: 450},
{Species: model.SpeciesDog, Initial: "Z", Name: "藏獒", Sort: 460},
{Species: model.SpeciesDog, Initial: "A", Name: "阿拉斯加雪橇犬", SizeClass: "large", Sort: 10},
{Species: model.SpeciesDog, Initial: "A", Name: "阿富汗猎犬", SizeClass: "large", Sort: 20},
{Species: model.SpeciesDog, Initial: "A", Name: "澳洲牧羊犬", SizeClass: "medium", Sort: 30},
{Species: model.SpeciesDog, Initial: "B", Name: "比熊犬", SizeClass: "small", Sort: 40},
{Species: model.SpeciesDog, Initial: "B", Name: "比格犬", SizeClass: "medium", Sort: 50},
{Species: model.SpeciesDog, Initial: "B", Name: "边境牧羊犬", SizeClass: "medium", Sort: 60},
{Species: model.SpeciesDog, Initial: "B", Name: "巴哥犬", SizeClass: "small", Sort: 70},
{Species: model.SpeciesDog, Initial: "B", Name: "博美犬", SizeClass: "small", Sort: 80},
{Species: model.SpeciesDog, Initial: "B", Name: "巴吉度猎犬", SizeClass: "medium", Sort: 90},
{Species: model.SpeciesDog, Initial: "B", Name: "北京犬", SizeClass: "small", Sort: 100},
{Species: model.SpeciesDog, Initial: "C", Name: "柴犬", SizeClass: "medium", Sort: 110},
{Species: model.SpeciesDog, Initial: "C", Name: "川东猎犬", SizeClass: "medium", Sort: 120},
{Species: model.SpeciesDog, Initial: "D", Name: "德国牧羊犬", SizeClass: "large", Sort: 130},
{Species: model.SpeciesDog, Initial: "D", Name: "杜宾犬", SizeClass: "large", Sort: 140},
{Species: model.SpeciesDog, Initial: "D", Name: "大丹犬", SizeClass: "giant", Sort: 150},
{Species: model.SpeciesDog, Initial: "D", Name: "斗牛犬", SizeClass: "medium", Sort: 160},
{Species: model.SpeciesDog, Initial: "F", Name: "法国斗牛犬", SizeClass: "medium", Sort: 170},
{Species: model.SpeciesDog, Initial: "G", Name: "贵宾犬(泰迪)", SizeClass: "small", Sort: 180},
{Species: model.SpeciesDog, Initial: "G", Name: "古代牧羊犬", SizeClass: "medium", Sort: 190},
{Species: model.SpeciesDog, Initial: "H", Name: "哈士奇", SizeClass: "large", Sort: 200},
{Species: model.SpeciesDog, Initial: "H", Name: "蝴蝶犬", SizeClass: "small", Sort: 210},
{Species: model.SpeciesDog, Initial: "J", Name: "金毛寻回犬", SizeClass: "large", Sort: 220},
{Species: model.SpeciesDog, Initial: "J", Name: "吉娃娃", SizeClass: "small", Sort: 230},
{Species: model.SpeciesDog, Initial: "K", Name: "柯基犬", SizeClass: "small", Sort: 240},
{Species: model.SpeciesDog, Initial: "K", Name: "可卡犬", SizeClass: "medium", Sort: 250},
{Species: model.SpeciesDog, Initial: "K", Name: "苏格兰柯利犬", SizeClass: "medium", Sort: 260},
{Species: model.SpeciesDog, Initial: "L", Name: "拉布拉多寻回犬", SizeClass: "large", Sort: 270},
{Species: model.SpeciesDog, Initial: "L", Name: "罗威纳犬", SizeClass: "large", Sort: 280},
{Species: model.SpeciesDog, Initial: "L", Name: "灵缇犬", SizeClass: "large", Sort: 290},
{Species: model.SpeciesDog, Initial: "L", Name: "腊肠犬", SizeClass: "small", Sort: 300},
{Species: model.SpeciesDog, Initial: "M", Name: "马尔济斯犬", SizeClass: "small", Sort: 310},
{Species: model.SpeciesDog, Initial: "M", Name: "迷你雪纳瑞", SizeClass: "small", Sort: 320},
{Species: model.SpeciesDog, Initial: "N", Name: "牛头梗", SizeClass: "medium", Sort: 330},
{Species: model.SpeciesDog, Initial: "Q", Name: "秋田犬", SizeClass: "large", Sort: 340},
{Species: model.SpeciesDog, Initial: "S", Name: "萨摩耶", SizeClass: "large", Sort: 350},
{Species: model.SpeciesDog, Initial: "S", Name: "松狮犬", SizeClass: "medium", Sort: 360},
{Species: model.SpeciesDog, Initial: "S", Name: "圣伯纳犬", SizeClass: "giant", Sort: 370},
{Species: model.SpeciesDog, Initial: "X", Name: "雪纳瑞", SizeClass: "medium", Sort: 390},
{Species: model.SpeciesDog, Initial: "X", Name: "西高地白梗", SizeClass: "small", Sort: 400},
{Species: model.SpeciesDog, Initial: "X", Name: "喜乐蒂牧羊犬", SizeClass: "small", Sort: 410},
{Species: model.SpeciesDog, Initial: "Y", Name: "约克夏梗", SizeClass: "small", Sort: 420},
{Species: model.SpeciesDog, Initial: "Y", Name: "银狐犬", SizeClass: "medium", Sort: 430},
{Species: model.SpeciesDog, Initial: "Z", Name: "中华田园犬", SizeClass: "medium", Sort: 440},
{Species: model.SpeciesDog, Initial: "Z", Name: "中亚牧羊犬", SizeClass: "large", Sort: 450},
{Species: model.SpeciesDog, Initial: "Z", Name: "藏獒", SizeClass: "giant", Sort: 460},
}
for i := range breeds {
breeds[i].Enabled = true
+36 -5
View File
@@ -7,6 +7,7 @@ import (
"gorm.io/datatypes"
"github.com/sundynix/pets-be/internal/middleware"
"github.com/sundynix/pets-be/internal/model"
"github.com/sundynix/pets-be/internal/service"
"github.com/sundynix/pets-be/pkg/response"
)
@@ -46,9 +47,8 @@ func (r petReq) toInput() service.PetInput {
in.ArrivedAt = &t
}
}
// 阶段以生日为准。用户在引导页可以手改,但服务端只认年龄阶段三选一——
// 「刚到家」是叠加包不是年龄段,混进来会让整套模板取错
in.Stage = service.NormalizeStage(service.SpeciesOf(r.Type), in.Stage, in.Birthday)
// 阶段归一化不在这里做:狗要按品种查体型才能定阈值,需要访问 DB。
// 交给 handler 里 h.svc.NormalizeStage 处理
if r.Goals != nil {
if b, err := jsonMarshal(r.Goals); err == nil {
in.Goals = datatypes.JSON(b)
@@ -74,7 +74,9 @@ func (h *Handler) CreatePet(c *gin.Context) {
response.FailParams(c, err.Error())
return
}
pet, err := h.svc.CreatePet(middleware.UserID(c), req.toInput())
in := req.toInput()
in.Stage = h.svc.NormalizeStage(service.SpeciesOf(req.Type), req.Breed, in.Stage, in.Birthday)
pet, err := h.svc.CreatePet(middleware.UserID(c), in)
if err != nil {
response.FailErr(c, err)
return
@@ -151,7 +153,9 @@ func (h *Handler) Onboarding(c *gin.Context) {
response.FailParams(c, err.Error())
return
}
pet, err := h.svc.Onboarding(middleware.UserID(c), req.toInput())
in := req.toInput()
in.Stage = h.svc.NormalizeStage(service.SpeciesOf(req.Type), req.Breed, in.Stage, in.Birthday)
pet, err := h.svc.Onboarding(middleware.UserID(c), in)
if err != nil {
response.FailErr(c, err)
return
@@ -164,3 +168,30 @@ func (h *Handler) Onboarding(c *gin.Context) {
func (h *Handler) Breeds(c *gin.Context) {
response.OK(c, h.svc.ListBreeds(c.Query("species")))
}
// LifeStages GET /api/life-stages?species=dog&size=large
// 生命阶段的划分标准。建档时选阶段那里、和「该换方案了」的提示里都要展示依据,
// 所以直接把驱动判定的那张表吐出来 —— 不另写一份说明文案,免得两边漂
func (h *Handler) LifeStages(c *gin.Context) {
species := c.Query("species")
if species == "" {
species = model.SpeciesCat
}
response.OK(c, gin.H{
"species": species,
"size": c.Query("size"),
"stages": service.StageRules(species, c.Query("size")),
"sizes": service.SizeLabels,
})
}
// PetStageDrift GET /api/pets/:id/stage-drift
// 存的阶段和按生日算的对不上时,告诉前端可以提示用户换一套方案
func (h *Handler) PetStageDrift(c *gin.Context) {
pet, err := h.svc.GetPet(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, h.svc.CheckStageDrift(pet))
}
+7
View File
@@ -14,6 +14,13 @@ type Breed struct {
// 还是 zang 分组,人来定比库来猜靠谱。后台新增品种时手填这一位。
Initial string `gorm:"size:1;index" json:"initial"`
// SizeClass 成年体型,只有犬类用:small(<10kg) / medium(10-25) / large(25-40) / giant(>40)。
//
// 生命阶段的判定要靠它。狗的发育完全取决于体型——大丹犬 8 个月还在长骨头,
// 吉娃娃 8 个月已经性成熟;大丹犬 7 岁进老年,吉娃娃 7 岁还是中年。
// 不能靠当前体重判断(幼犬体重说明不了成年后多大),只能靠品种。
SizeClass string `gorm:"size:16;index" json:"size_class"`
Sort int `json:"sort"`
Enabled bool `gorm:"default:true" json:"enabled"`
}
+3 -1
View File
@@ -20,7 +20,9 @@ type Pet struct {
ArrivedAt *time.Time `json:"arrived_at"` // 到家日期。30 天内会额外叠加一套安置期任务
Weight string `gorm:"size:16" json:"weight"` // 如 "2.8kg"
Stage string `gorm:"size:32" json:"stage"` // 年龄阶段:幼年期 / 成年期 / 老年期(由生日推断,可手改)
Age string `gorm:"size:32" json:"age"`
// Age 计算字段,不入库。原来是建档时算好存下来的字符串,
// 养半年后首页还显示「2个月」—— 存一个会过期的展示值本身就是 bug
Age string `gorm:"-" json:"age"`
Color string `gorm:"size:32" json:"color"` // 毛色,如 橘白 / 奶牛
Breed string `gorm:"size:64" json:"breed"` // 品种
HealthStatus string `gorm:"size:16;default:正常" json:"health_status"`
+2
View File
@@ -59,6 +59,7 @@ func registerUserAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manage
g.GET("/record-types", h.RecordTypes)
g.GET("/breeds", h.Breeds)
g.GET("/life-stages", h.LifeStages)
g.GET("/pets/:id/records", h.ListRecords)
g.POST("/pets/:id/records", h.CreateRecord)
g.GET("/pets/:id/records/weight-trend", h.WeightTrend)
@@ -73,6 +74,7 @@ func registerUserAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manage
g.GET("/pets/:id/plan", h.GetPlan)
g.GET("/pets/:id/plan/calendar", h.PlanCalendar)
g.GET("/pets/:id/stage-drift", h.PetStageDrift)
g.GET("/pets/:id/plan/month-status", h.PlanMonthStatus)
// 养护方案:列出可选的、套用一套到某个月
g.GET("/care-templates", h.CareTemplateOptions)
+4 -43
View File
@@ -13,41 +13,13 @@ import (
"github.com/sundynix/pets-be/internal/model"
)
// 模板分组。注意 StageNewHome 不是年龄阶段,而是「刚到家」叠加包
// 建档时先按年龄阶段取一套,若到家不足 30 天,再把这一套叠上去
const (
StageNewHome = "刚到家 0-30 天"
StageJuvenile = "幼年期"
StageAdult = "成年期"
StageSenior = "老年期"
)
// 阶段常量、CareStages 和判定逻辑都搬到 life_stage.go 了
// 那边一张表既驱动判定又直接吐给前端当「划分依据」,两个用途一份数据
// 阶段从 3 段(幼年/成年/老年)细分成 5 段,狗还按体型走不同阈值。
// CareSpecies / CareStages 支持的物种与分组(字符串与 onboarding 一致)
// CareSpecies 支持的物种(字符串与 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 {
@@ -62,17 +34,6 @@ 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 {
+250
View File
@@ -0,0 +1,250 @@
package service
import (
"fmt"
"time"
"github.com/sundynix/pets-be/internal/model"
)
// 生命阶段的划分标准。
//
// 这张表既用来判定阶段,也直接吐给前端当「划分依据」展示 —— 一份数据两个用途,
// 而不是「代码里判定 + 文档里写标准」。后者一定会漂:改了阈值忘了改文档,
// 用户看到的说明和实际行为对不上,比没有说明更糟。
//
// ── 为什么猫狗不能共用阈值 ──
//
// 猫的发育在品种间差别不大。狗完全取决于体型:
// 吉娃娃 8 个月已经性成熟、可以换成犬粮;大丹犬 8 个月还在长骨头,
// 这时候喂成犬粮(钙磷比和热量密度都不同)会加重关节负担。
// 吉娃娃 7 岁还在成年期(8 岁才进中年,能活 13-16 年);
// 大丹犬 7 岁已经是老年(平均寿命 8-10 年)。
//
// 原来代码里是一刀切「<12 月幼年,>=7 岁老年」,对猫勉强,对狗两头都错。
//
// 体型不能靠当前体重判断 —— 幼犬的体重说明不了它成年后多大。只能靠品种,
// 所以 breeds 表带 size_class。
// 五个阶段。标签故意做成物种中立(不分「幼猫期/幼犬期」):
// 物种本来就是另一个字段,分开会让 (species, stage) 的组合和模板配置都翻倍。
const (
StageJuvenile = "幼年期"
StageYoung = "青年期"
StageAdult = "成年期"
StageMature = "中年期"
StageSenior = "老年期"
// StageNewHome 和年龄正交:刚接回家的成年猫两套照护都需要。
// 它不参与年龄判定,是叠加上去的一层。
StageNewHome = "刚到家 0-30 天"
)
// CareStages 后台配方案时可选的阶段。刚到家放最前面,它是所有宠物都会经历的
var CareStages = []string{StageNewHome, StageJuvenile, StageYoung, StageAdult, StageMature, StageSenior}
// 犬类体型。分界取自成年体重,是行业通行的分法
const (
SizeSmall = "small" // < 10kg
SizeMedium = "medium" // 10-25kg
SizeLarge = "large" // 25-40kg
SizeGiant = "giant" // > 40kg
)
// SizeLabels 体型的中文名和依据,后台和小程序都用它
var SizeLabels = map[string]string{
SizeSmall: "小型犬(成年 <10kg",
SizeMedium: "中型犬(成年 10-25kg",
SizeLarge: "大型犬(成年 25-40kg",
SizeGiant: "巨型犬(成年 >40kg",
}
// StageRule 一个阶段的月龄区间和依据
type StageRule struct {
Stage string `json:"stage"`
FromMon int `json:"from_month"` // 含
ToMon int `json:"to_month"` // 不含;0 = 无上限
Range string `json:"range"` // 展示用,如「6-12 月」
Basis string `json:"basis"` // 划分依据,直接展示给用户
Focus string `json:"focus"` // 这个阶段的养护重点
}
// 猫:AAFP/AAHA 猫生命阶段指南那套,在「成年」里细分出中年
var catStages = []StageRule{
{StageJuvenile, 0, 6, "0-6 月", "断奶到六月龄是生长最快的阶段,三针核心疫苗和首次驱虫都在这里完成",
"疫苗序列、驱虫、社会化、少量多餐"},
{StageYoung, 6, 12, "6-12 月", "六月龄前后性成熟,绝育窗口;一岁前后换成猫粮",
"绝育安排、换成猫粮、开始控体重"},
{StageAdult, 12, 84, "1-7 岁", "身体状态最稳定的时期,重点是把基线数据记下来",
"每月称重、年度体检、口腔护理"},
{StageMature, 84, 132, "7-11 岁", "七岁起代谢变慢,年度血检开始能查出早期问题",
"年度血检、体重和牙结石重点看"},
{StageSenior, 132, 0, "11 岁以上", "十一岁后肾脏和甲状腺问题高发,体检频率要加倍",
"半年一次体检、肾和甲状腺、饮水量重点记"},
}
// 狗:同样五段,阈值按体型走。越大的犬种生长期越长、老得越早
var dogStages = map[string][]StageRule{
SizeSmall: {
{StageJuvenile, 0, 8, "0-8 月", "小型犬生长期短,八月龄基本定型", "疫苗序列、驱虫、社会化"},
{StageYoung, 8, 12, "8-12 月", "性成熟早,绝育窗口也早", "绝育安排、换成犬粮"},
{StageAdult, 12, 96, "1-8 岁", "小型犬寿命长,成年期也长", "每月称重、年度体检、牙结石"},
{StageMature, 96, 132, "8-11 岁", "八岁起进入中年,心脏和牙齿是重点", "年度血检、心脏听诊、洁牙"},
{StageSenior, 132, 0, "11 岁以上", "小型犬普遍能活到 13-16 岁,十一岁后算老年", "半年一次体检、关节和心脏"},
},
SizeMedium: {
{StageJuvenile, 0, 12, "0-12 月", "一岁前后完成生长", "疫苗序列、驱虫、社会化"},
{StageYoung, 12, 18, "12-18 月", "骨骼刚闭合,可以开始正常运动量", "换成犬粮、运动量上调"},
{StageAdult, 18, 84, "1.5-7 岁", "维持期", "每月称重、年度体检、牙结石"},
{StageMature, 84, 120, "7-10 岁", "七岁起体重和关节要重点看", "年度血检、体重控制、关节"},
{StageSenior, 120, 0, "10 岁以上", "中型犬平均寿命 10-13 岁", "半年一次体检、关节和肿瘤筛查"},
},
SizeLarge: {
{StageJuvenile, 0, 15, "0-15 月", "大型犬生长期长到 15 个月,这期间不能过量补钙或过早剧烈运动",
"控制生长速度、疫苗序列、避免跳跃"},
{StageYoung, 15, 24, "15-24 月", "两岁前骨骼才完全闭合", "换成犬粮、逐步加运动量"},
{StageAdult, 24, 72, "2-6 岁", "维持期较短", "每月称重、年度体检、髋关节"},
{StageMature, 72, 96, "6-8 岁", "六岁起就要按中年管,比中小型犬早", "年度血检、髋关节、体重控制"},
{StageSenior, 96, 0, "8 岁以上", "大型犬平均寿命 8-12 岁,八岁进老年", "半年一次体检、关节和心脏"},
},
SizeGiant: {
{StageJuvenile, 0, 18, "0-18 月", "巨型犬生长期最长,18 个月还在长;这阶段最忌过度补钙",
"严格控制生长速度、疫苗序列、限制跳跃"},
{StageYoung, 18, 24, "18-24 月", "两岁才算成年", "换成犬粮、关节保养开始"},
{StageAdult, 24, 60, "2-5 岁", "成年期很短", "每月称重、年度体检、心脏和关节"},
{StageMature, 60, 84, "5-7 岁", "五岁就要按中年管 —— 巨型犬老得最快", "半年一次血检、心脏、体重"},
{StageSenior, 84, 0, "7 岁以上", "巨型犬平均寿命 7-10 岁", "每半年体检、心脏和骨科肿瘤"},
},
}
// StageRules 某个物种(狗还要看体型)的完整划分标准,给前端展示用
func StageRules(species, sizeClass string) []StageRule {
if species == model.SpeciesDog {
if rs, ok := dogStages[sizeClass]; ok {
return rs
}
return dogStages[SizeMedium] // 品种没标体型时按中型算,最不容易错得离谱
}
return catStages
}
// stageOfMonths 按月龄在规则表里落位
func stageOfMonths(rules []StageRule, months int) string {
for _, r := range rules {
if months >= r.FromMon && (r.ToMon == 0 || months < r.ToMon) {
return r.Stage
}
}
return StageAdult
}
// StageFromAge 由物种、体型、月龄推断阶段
func StageFromAge(species, sizeClass string, months int) string {
if months < 0 {
return StageAdult // 没有生日只能按成年算,它是最不容易出错的默认
}
return stageOfMonths(StageRules(species, sizeClass), months)
}
// sizeClassOfBreed 查品种的体型。查不到返回空,上层按中型兜底
func (s *Service) sizeClassOfBreed(species, breed string) string {
if species != model.SpeciesDog || breed == "" {
return ""
}
var b model.Breed
if err := s.db.Where("species = ? AND name = ?", species, breed).First(&b).Error; err != nil {
return ""
}
return b.SizeClass
}
// StageOf 由生日推断阶段(狗会查品种体型)
func (s *Service) StageOf(species, breed string, birthday *time.Time) string {
if birthday == nil || birthday.IsZero() {
return StageAdult
}
return StageFromAge(species, s.sizeClassOfBreed(species, breed), monthsSince(*birthday))
}
// NormalizeStage 定下最终使用的阶段。
// 用户手选的优先(领养的猫生日是估的,主人按体型判断更准;而模型里的月龄阈值
// 对混种和边界体型都是一刀切);没选或选了不认识的值才按生日推。
func (s *Service) NormalizeStage(species, breed, picked string, birthday *time.Time) string {
for _, v := range CareStages {
if picked == v {
return picked
}
}
return s.StageOf(species, breed, birthday)
}
// AgeLabel 月龄的中文展示。不入库 —— 存下来第二天就过期了
func AgeLabel(birthday *time.Time) string {
if birthday == nil || birthday.IsZero() {
return ""
}
m := monthsSince(*birthday)
if m < 0 {
return ""
}
if m < 24 {
return fmt.Sprintf("%d个月", m)
}
if m%12 == 0 {
return fmt.Sprintf("%d岁", m/12)
}
return fmt.Sprintf("%d岁%d个月", m/12, m%12)
}
// StageDrift 「档案里存的阶段」和「按生日算出来的阶段」不一致。
//
// 为什么不自动改:
// 1. 用户可能手改过 —— 领养的猫生日是估的,主人按体型判断比月龄阈值准
// 2. 已经排好的这个月计划不该被推翻。套用方案是「往里加」,不是「替换」
// 3. 偷偷换会让用户莫名发现计划变了,找不到原因
//
// 所以系统只负责发现,用户负责决定:小程序端提示「它已经进入成年期了,
// 要不要换一套方案」,点了才套用。
type StageDrift struct {
Drifted bool `json:"drifted"`
Stored string `json:"stored"` // 档案里现在存的
Computed string `json:"computed"` // 按生日算出来的
AgeLabel string `json:"age_label"` // 「1岁3个月」
Basis string `json:"basis"` // 新阶段的划分依据,一并给出来
Focus string `json:"focus"` // 新阶段的养护重点
Size string `json:"size"` // 犬类的体型说明,猫为空
}
// CheckStageDrift 检查某只宠物的阶段有没有漂
func (s *Service) CheckStageDrift(pet *model.Pet) *StageDrift {
out := &StageDrift{Stored: pet.Stage, AgeLabel: AgeLabel(pet.Birthday)}
if pet.Birthday == nil || pet.Birthday.IsZero() {
return out // 没生日算不了,不提示
}
// 「刚到家」是叠加层不是年龄段,处在这个状态时不谈漂移
if pet.Stage == StageNewHome {
return out
}
species := SpeciesOf(pet.Type)
size := s.sizeClassOfBreed(species, pet.Breed)
out.Computed = StageFromAge(species, size, monthsSince(*pet.Birthday))
if species == model.SpeciesDog {
if l, ok := SizeLabels[size]; ok {
out.Size = l
} else {
out.Size = SizeLabels[SizeMedium] + "(品种未标体型,按中型算)"
}
}
if out.Computed == out.Stored || out.Computed == "" {
return out
}
out.Drifted = true
for _, r := range StageRules(species, size) {
if r.Stage == out.Computed {
out.Basis = r.Basis
out.Focus = r.Focus
break
}
}
return out
}
+9
View File
@@ -51,6 +51,11 @@ func (s *Service) ListPets(userID string) ([]model.Pet, error) {
urls := s.fileURLs(ids)
for i := range pets {
pets[i].AvatarURL = urls[pets[i].AvatarFileID]
// Age 和 Stage 都是「会过期」的值。Age 直接现算(不入库);
// Stage 存着但要校准 —— 半年前建档时是幼年期,现在可能已经进青年期了。
// 校准只改返回值不写库:用户手改过的阶段不该被静默冲掉,
// 「该换一套方案了」这件事要提示他、由他决定(见 StageDrift)
pets[i].Age = AgeLabel(pets[i].Birthday)
}
return pets, err
}
@@ -60,6 +65,7 @@ func (s *Service) GetPet(userID, petID string) (*model.Pet, error) {
p, err := s.ownedPet(userID, petID)
if err == nil && p != nil {
p.AvatarURL = s.fileURL(p.AvatarFileID)
p.Age = AgeLabel(p.Birthday)
}
return p, err
}
@@ -91,6 +97,9 @@ func (s *Service) CreatePet(userID string, in PetInput) (*model.Pet, error) {
}); err != nil {
return nil, err
}
// Age 是计算字段,创建后的返回也得填 —— 不填前端拿到的是空字符串
pet.Age = AgeLabel(pet.Birthday)
pet.AvatarURL = s.fileURL(pet.AvatarFileID)
return &pet, nil
}
+3
View File
@@ -46,5 +46,8 @@ func (s *Service) ownedPet(userID, petID string) (*model.Pet, error) {
if err != nil {
return nil, err
}
// Age 是计算字段(gorm:"-")。在这里填而不是在每个调用点填 ——
// ownedPet 是所有「取一只宠物」的路径的收口,漏一个就会返回空年龄
pet.Age = AgeLabel(pet.Birthday)
return &pet, nil
}
+11 -3
View File
@@ -13,7 +13,8 @@ const SPECIES = [
{ key: 'cat', label: '猫猫', Icon: Cat },
{ key: 'dog', label: '狗狗', Icon: Dog },
]
const STAGES = ['刚到家 0-30 天', '幼年期', '成年期', '老年期']
// 阶段列表从后端 meta 来(service.CareStages)。写死在这里的话,
// 后端从 3 段细分成 5 段之后这一页就少两个选项,而且不会报错
const REMINDER_LABEL: Record<string, string> = {
vaccine: '疫苗', deworm: '驱虫', weight: '体重', monthlyReport: '月报',
}
@@ -78,6 +79,7 @@ function blank(kind: Item['kind']): Item {
export default function CareTemplates() {
const [list, setList] = useState<Tpl[]>([])
const [types, setTypes] = useState<any[]>([])
const [stages, setStages] = useState<string[]>([])
const [open, setOpen] = useState(false)
const [tpl, setTpl] = useState<Tpl>({
id: '', species: 'cat', stage: '成年期', name: '', summary: '', sort: 0, enabled: true,
@@ -94,7 +96,13 @@ export default function CareTemplates() {
reload()
// 「关联记录事项」的可选值来自 record_types,不再是写死的几个。
// 原来那两个硬编码数组里还有 report / taskDetail 这种早就不存在的值
api.careTemplateMeta().then((m: any) => setTypes(m?.record_types || [])).catch(() => {})
api
.careTemplateMeta()
.then((m: any) => {
setTypes(m?.record_types || [])
setStages(m?.stages || [])
})
.catch(() => {})
}, [])
function create() {
@@ -267,7 +275,7 @@ export default function CareTemplates() {
value={tpl.stage}
onChange={(e) => setTpl({ ...tpl, stage: e.target.value })}
>
{STAGES.map((s) => (
{stages.map((s) => (
<option key={s} value={s}>
{s}
</option>