Files
sundynix-pets/pets-be/internal/database/seed.go
T
Blizzard c4c0c2b7cd 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>
2026-07-30 12:51:10 +08:00

225 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package database
import (
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/config"
"github.com/sundynix/pets-be/internal/model"
)
// Seed 初始化种子数据:管理员账号 + 记录类型。
//
// 文章、养护模板这类**内容**数据不放代码里——正文几百字塞进 Go 源文件,既看不清
// 也没法在后台改完再同步回来。上线时直接同步数据库。
//
// 记录类型是另一码事,它是**结构**:表空着的话记录页一个可记事项都没有,
// App 直接不能用。所以和管理员账号一样,只在表完全为空时铺一次底,
// 之后一切以数据库为准,代码里这份不再参与。
func Seed(db *gorm.DB, cfg *config.Config) error {
if err := seedAdmin(db, cfg); err != nil {
return err
}
if err := seedRecordTypes(db); err != nil {
return err
}
if err := seedBreeds(db); err != nil {
return err
}
return seedCareTemplates(db)
}
// seedBreeds 品种预置。同 record_types:只在表完全为空时铺一次。
//
// 这也是「结构」不是「内容」——品种列表空着,建档第二步就选不了品种。
// 首字母存字段不运行时算:Go 没有标准拼音库,而多音字(藏獒、柴犬)
// 自动转常出错,人来定比库来猜靠谱。
func seedBreeds(db *gorm.DB) error {
var count int64
if err := db.Model(&model.Breed{}).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
breeds := []model.Breed{
// 猫 34 种
{Species: model.SpeciesCat, Initial: "A", Name: "阿比西尼亚猫", Sort: 10},
{Species: model.SpeciesCat, Initial: "A", Name: "奥西猫", Sort: 20},
{Species: model.SpeciesCat, Initial: "B", Name: "巴厘猫", Sort: 30},
{Species: model.SpeciesCat, Initial: "B", Name: "波斯猫", Sort: 40},
{Species: model.SpeciesCat, Initial: "B", Name: "布偶猫", Sort: 50},
{Species: model.SpeciesCat, Initial: "B", Name: "伯曼猫", Sort: 60},
{Species: model.SpeciesCat, Initial: "C", Name: "长毛折耳猫", Sort: 70},
{Species: model.SpeciesCat, Initial: "D", Name: "德文卷毛猫", Sort: 80},
{Species: model.SpeciesCat, Initial: "D", Name: "东方短毛猫", Sort: 90},
{Species: model.SpeciesCat, Initial: "D", Name: "东奇尼猫", Sort: 100},
{Species: model.SpeciesCat, Initial: "E", Name: "俄罗斯蓝猫", Sort: 110},
{Species: model.SpeciesCat, Initial: "H", Name: "哈瓦那棕猫", Sort: 120},
{Species: model.SpeciesCat, Initial: "K", Name: "科拉特猫", Sort: 130},
{Species: model.SpeciesCat, Initial: "L", Name: "拉波猫", Sort: 140},
{Species: model.SpeciesCat, Initial: "M", Name: "缅因猫", Sort: 150},
{Species: model.SpeciesCat, Initial: "M", Name: "美国短毛猫", Sort: 160},
{Species: model.SpeciesCat, Initial: "M", Name: "美国卷耳猫", Sort: 170},
{Species: model.SpeciesCat, Initial: "M", Name: "曼赤肯猫", Sort: 180},
{Species: model.SpeciesCat, Initial: "M", Name: "孟买猫", Sort: 190},
{Species: model.SpeciesCat, Initial: "M", Name: "孟加拉豹猫", Sort: 200},
{Species: model.SpeciesCat, Initial: "N", Name: "挪威森林猫", Sort: 210},
{Species: model.SpeciesCat, Initial: "R", Name: "日本短尾猫", Sort: 220},
{Species: model.SpeciesCat, Initial: "S", Name: "苏格兰折耳猫", Sort: 230},
{Species: model.SpeciesCat, Initial: "S", Name: "索马里猫", Sort: 240},
{Species: model.SpeciesCat, Initial: "S", Name: "塞尔凯克卷毛猫", Sort: 250},
{Species: model.SpeciesCat, Initial: "S", Name: "斯芬克斯无毛猫", Sort: 260},
{Species: model.SpeciesCat, Initial: "S", Name: "沙特尔猫", Sort: 270},
{Species: model.SpeciesCat, Initial: "T", Name: "土耳其安哥拉猫", Sort: 280},
{Species: model.SpeciesCat, Initial: "T", Name: "土耳其梵猫", Sort: 290},
{Species: model.SpeciesCat, Initial: "X", Name: "暹罗猫", Sort: 300},
{Species: model.SpeciesCat, Initial: "X", Name: "新加坡猫", Sort: 310},
{Species: model.SpeciesCat, Initial: "Y", Name: "异国短毛猫", Sort: 320},
{Species: model.SpeciesCat, Initial: "Y", Name: "英国短毛猫", Sort: 330},
{Species: model.SpeciesCat, Initial: "Z", Name: "中华田园猫", Sort: 340},
// 狗 46 种
{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
}
return db.Create(&breeds).Error
}
// seedRecordTypes 只在表为空时跑。已经有数据(哪怕只有一条)就完全不碰,
// 否则后台删掉的类型会在每次重启后自己长回来
func seedRecordTypes(db *gorm.DB) error {
var count int64
if err := db.Model(&model.RecordType{}).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
// locked=true 的这 9 种在 record.go / insight.go / report.go / home.go 里有真实
// 代码分支(体重回写档案、食便相关性、消费统计、疫苗完成度…),并且被
// care_template_items.sheet_type 引用,后台不许删,只能停用。
// form=full 表示 bottom-sheet 里有它专用的 wx:elif 分支。
// fields 是表单额外字段的配置,一行一个(写法见 model.RecordType.Fields 的注释)。
// 空 = 只要「时间 + 描述 + 照片」。
//
// 原有 9 种的配置必须和现在的统计口径严丝合缝:
// weight number → num_valueCreateRecord 靠它回写宠物档案
// cost 金额→num_value + 类别→category,账单是按 category 分组聚合的
// poop 状态→category,食便相关性分析排除 category='正常'
// symptom 两个单选:症状不占 category,严重程度占——周报按 category='高' 数高风险
mk := func(code, label, icon, group string, sort int, locked bool, fields string) model.RecordType {
form := model.RecordFormSimple
if locked {
form = model.RecordFormFull
}
return model.RecordType{Code: code, Label: label, Icon: icon, GroupKey: group,
Sort: sort, Enabled: true, Form: form, Locked: locked, Fields: fields}
}
full := func(code, label, icon, group string, sort int, fields string) model.RecordType {
return mk(code, label, icon, group, sort, true, fields)
}
simple := func(code, label, icon, group string, sort int, fields string) model.RecordType {
return mk(code, label, icon, group, sort, false, fields)
}
d, h, c, cl := model.RecordGroupDaily, model.RecordGroupHealth, model.RecordGroupCare, model.RecordGroupClean
types := []model.RecordType{
// 日常
full("weight", "体重", "weight", d, 10, "number:体重:kg"),
full("poop", "排便", "poop", d, 20, "options:状态:正常|软便|拉稀"),
full("food", "饮食", "food", d, 30, "text:吃了什么\noptions:食欲:正常|偏少|不吃"),
simple("water", "喝水", "water", d, 40, "number:饮水量:ml"),
full("cost", "记账", "cost", d, 50, "number:金额:元\noptions:类别:医疗|食品|用品|美容|其他"),
full("photo", "照片", "photo", d, 60, ""),
// 健康
full("symptom", "异常", "symptom", h, 10,
"options:发生了什么:呕吐|拉稀|精神差|皮肤红|其他:-\noptions:严重程度:低|中|高"),
full("vaccine", "疫苗", "vaccine", h, 20, "text:疫苗名称"),
full("deworm", "驱虫", "deworm", h, 30, "options:类型:体内|体外|体内外同驱"),
simple("checkup", "体检", "checkup", h, 40, "text:在哪做的"),
simple("vet", "看病", "vet", h, 50, "text:诊断\nnumber:花费:元"),
full("medicine", "给药", "medicine", h, 60, "text:药品名称\ntext:剂量"),
simple("supplement", "保健品", "supplement", h, 70, "text:名称\ntext:剂量"),
// 洗护
simple("bath", "洗澡", "bath", c, 10, ""),
simple("nail", "剪指甲", "nail", c, 20, ""),
simple("ear", "洗耳朵", "ear", c, 30, ""),
simple("tooth", "刷牙", "tooth", c, 40, ""),
simple("brush", "梳毛", "brush", c, 50, ""),
simple("groom", "美容", "groom", c, 60, "number:花费:元"),
// 清洁
simple("litter", "换猫砂", "litter", cl, 10, ""),
simple("litterbox", "洗猫砂盆", "litterbox", cl, 20, ""),
simple("bowl", "洗食盆", "bowl", cl, 30, ""),
simple("waterbowl", "洗水盆", "waterbowl", cl, 40, ""),
simple("clean", "消毒", "clean", cl, 50, ""),
}
return db.Create(&types).Error
}
func seedAdmin(db *gorm.DB, cfg *config.Config) error {
var count int64
if err := db.Model(&model.Admin{}).Where("username = ?", cfg.Admin.Username).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
hash, err := bcrypt.GenerateFromPassword([]byte(cfg.Admin.Password), bcrypt.DefaultCost)
if err != nil {
return err
}
return db.Create(&model.Admin{
Username: cfg.Admin.Username,
PasswordHash: string(hash),
Role: "admin",
}).Error
}