Files
sundynix-pets/pets-be/internal/database/seed.go
T
Blizzard aefb1bc103 feat: 建档改成三步向导页,品种改成后台可配的选择器
## pages/petform 三步向导
  ① 名字 + 传头像
  ② 猫还是狗 → 选品种
  ③ 性别 / 生日 / 到家日期 / 体重 / 毛色 / 阶段

**编辑时不分步**,一屏全放开:用户是来改某一项的,不该被按顺序走一遍。
同一个页面两种形态,靠 ?id= 区分。

每一步只校验这一步的东西。三步全填完才校验的话,用户在第三步才被告知
第一步没填名字,还得翻回去。

进度条没用 seg-tabs:那是「可以随意切」的语义,这里有顺序,
点第三步跳过前两步会拿到一份没名字的档案。

## 生日和到家日期分开
原来是一个「生日 / 到家时间」字段。领养的成年猫狗生日常常是不知道的,
而「一起生活了多少天」要按到家日算——合成一个字段两件事都说不准。
验过:到家 4 个月前 → 第 121 天;按生日算会是 801 天。

模型里 Birthday 和 ArrivedAt 本来就是两个字段,只是表单没露出来。

## 品种:自由文本 → 后台可配的选择器
原来品种是个输入框,用户手打「柯基」「柯基犬」「威尔士柯基」算三个品种,
以后想按品种做体重基准、常见病提示这类事就没法做。

新增 breeds 表,预置 34 种猫 + 45 种狗,按首字母分组、右侧 A-Z 索引跳转、
首字母吸顶。留了「不确定」——领养的串串确实说不出品种,逼着选一个只会
得到假数据。换物种会清掉已选品种(原来那个一定不对了)。

**首字母存字段不运行时算**:Go 没有标准拼音库,而多音字自动转常出错——
「藏獒」按 cang 还是 zang 分组、「柴犬」的柴,人来定比库来猜靠谱。
后台新增品种时手填这一位,服务端校验必须是单个 A-Z。

已有档案在用的品种不许删(删了那些档案的品种就变成查不到的字符串),
想隐藏用 enabled=false。同物种下不许重名——品种是统计口径,重名会把
同一种猫劈成两半。

预置数据自己填错了两条,实跑时看出来的:「苏格兰柯利犬」我按「柯利」
填了 K,应该按「苏格兰」算 S;「双血统边牧」根本不是品种,是血统描述。
seed 源和库里都改了。

## 弹层继续瘦
addPet/editPet 两支删掉,连带 10 个方法、ageFromBirthday/GENDERS/STAGES
和 5 个 add* 字段。累计(从记录表单搬走那次算起):
  js   1110 → 734 行
  wxml  485 → 307 行

多宠管理里「点某只去编辑」原来是 setType('editPet'),那支没了会开一个
空白弹层——改成关弹层再跳页。8 个页面的入口全换完,grep 无残留。

## 验证(预生产库实跑)
  品种接口   猫 34 种 16 个首字母、狗 45 种 17 个首字母;非法 species 返回 []
  建档      生日 / 到家日期 分别落库,品种毛色阶段都对
  编辑      只改名字,到家日期没被冲掉
  修正      柯利犬 K→S、双血统边牧已删

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:26:51 +08:00

222 lines
12 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
}
return seedBreeds(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: "阿拉斯加雪橇犬", 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},
}
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
}