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
+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
}