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:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user