c4c0c2b7cd
## 你问的「养了半年会不会自动切换」
原来的答案是「都不会,而且比这更糟」:
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>
225 lines
13 KiB
Go
225 lines
13 KiB
Go
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_value,CreateRecord 靠它回写宠物档案
|
||
// 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
|
||
}
|