Files
sundynix-pets/pets-be/internal/service/pet.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

310 lines
9.4 KiB
Go

package service
import (
"encoding/json"
"log"
"strings"
"time"
"gorm.io/datatypes"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/model"
)
// PetInput 建/改宠物入参
type PetInput struct {
Name string
Emoji string
Type string
Gender string
Birthday *time.Time
ArrivedAt *time.Time
Weight string
Stage string
Age string
Color string
Breed string
AvatarFileID string
Goals datatypes.JSON
}
func normalizeWeight(w string) string {
w = strings.TrimSpace(w)
if w == "" {
return ""
}
if strings.Contains(w, "kg") {
return w
}
return w + "kg"
}
// ListPets 用户的全部宠物
func (s *Service) ListPets(userID string) ([]model.Pet, error) {
var pets []model.Pet
err := s.db.Where("user_id = ?", userID).Order("id asc").Find(&pets).Error
ids := make([]string, 0, len(pets))
for i := range pets {
ids = append(ids, pets[i].AvatarFileID)
}
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
}
// GetPet 取单只宠物(校验归属)
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
}
// CreatePet 新增宠物并生成默认数据
func (s *Service) CreatePet(userID string, in PetInput) (*model.Pet, error) {
pet := model.Pet{
UserID: userID,
Name: in.Name,
Emoji: in.Emoji,
Type: in.Type,
Gender: in.Gender,
Birthday: in.Birthday,
ArrivedAt: in.ArrivedAt,
Weight: normalizeWeight(in.Weight),
Stage: in.Stage,
Age: in.Age,
Color: in.Color,
Breed: in.Breed,
AvatarFileID: in.AvatarFileID,
HealthStatus: "正常",
Goals: in.Goals,
}
if err := s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&pet).Error; err != nil {
return err
}
return s.seedPetDefaults(tx, &pet)
}); err != nil {
return nil, err
}
// Age 是计算字段,创建后的返回也得填 —— 不填前端拿到的是空字符串
pet.Age = AgeLabel(pet.Birthday)
pet.AvatarURL = s.fileURL(pet.AvatarFileID)
return &pet, nil
}
// UpdatePet 更新宠物字段
func (s *Service) UpdatePet(userID, petID string, fields map[string]any) (*model.Pet, error) {
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, err
}
if w, ok := fields["weight"].(string); ok {
fields["weight"] = normalizeWeight(w)
}
if err := s.db.Model(&model.Pet{}).Where("id = ?", petID).Updates(fields).Error; err != nil {
return nil, err
}
return s.ownedPet(userID, petID)
}
// DeletePet 删除宠物并级联清除其记录/任务/计划/提醒/每日建议(不留孤儿数据)
func (s *Service) DeletePet(userID, petID string) error {
if _, err := s.ownedPet(userID, petID); err != nil {
return err
}
return s.db.Transaction(func(tx *gorm.DB) error {
// 先删计划明细(按 plan_id),再删计划
var planIDs []string
tx.Model(&model.Plan{}).Where("pet_id = ?", petID).Pluck("id", &planIDs)
if len(planIDs) > 0 {
if err := tx.Where("plan_id IN ?", planIDs).Delete(&model.PlanTask{}).Error; err != nil {
return err
}
}
for _, m := range []any{
&model.HealthRecord{}, &model.DailyTask{}, &model.Reminder{},
&model.Plan{}, &model.DailyAdvice{},
} {
if err := tx.Where("pet_id = ?", petID).Delete(m).Error; err != nil {
return err
}
}
return tx.Where("id = ? AND user_id = ?", petID, userID).Delete(&model.Pet{}).Error
})
}
// Onboarding 建首宠 + 标记用户已引导
func (s *Service) Onboarding(userID string, in PetInput) (*model.Pet, error) {
pet, err := s.CreatePet(userID, in)
if err != nil {
return nil, err
}
if err := s.db.Model(&model.User{}).Where("id = ?", userID).Update("onboarded", true).Error; err != nil {
return nil, err
}
return pet, nil
}
// seedPetDefaults 为新宠按「物种 × 阶段」养护模板生成今日任务、提醒、30 天计划
// seedPetDefaults 建档时只做一件事:建「必须有」的到期提醒(疫苗、驱虫)。
//
// 原来它会自动把整套模板铺开成今日任务 + 30 天计划。改成不自动铺了——
// 用户在建档最后一步自己挑方案,或者选「先空着我自己排」。
// 自动铺的问题是:用户还没看懂这个 App 是干什么的,首页已经躺着十几条
// 他没答应过的任务,第一反应是「这些是什么」而不是「我该做什么」。
//
// 疫苗驱虫这两条例外,因为漏针是真会出事的,而且它们是「到期提醒」不是
// 「今天要做的事」——不占计划页的格子,只在到期时冒出来。
func (s *Service) seedPetDefaults(tx *gorm.DB, pet *model.Pet) error {
species := SpeciesOf(pet.Type)
items := s.careTemplateItems(species, pet.Stage)
if len(items) == 0 {
log.Printf("care: 养护方案为空 species=%s stage=%s,新宠 %s 建档后没有可选方案,请到后台配置",
species, pet.Stage, pet.ID)
}
goals := petGoals(pet)
now := time.Now()
var reminders []model.Reminder
seen := map[string]bool{}
for _, it := range items {
if it.Kind != model.CareKindReminder || seen[it.Title] {
continue
}
if !goalWantsReminder(goals, it.ReminderType) {
continue
}
seen[it.Title] = true
r := model.Reminder{
PetID: pet.ID, UserID: pet.UserID, Type: it.ReminderType,
Title: it.Title, Frequency: it.Frequency,
}
if it.OffsetDays > 0 {
due := now.AddDate(0, 0, dueDaysFor(it, pet, now))
r.NextDueDate = &due
}
reminders = append(reminders, r)
}
if len(reminders) > 0 {
if err := tx.Create(&reminders).Error; err != nil {
return err
}
}
return nil
}
// ---- 建档默认值的几个判断 ----
// isNewHome 到家不足 30 天。没填到家日期的按「不是新到家」处理,
// 宁可少排几条安置期任务,也不要给养了三年的狗排「先只开放一个房间」。
func isNewHome(arrivedAt *time.Time, now time.Time) bool {
if arrivedAt == nil || arrivedAt.IsZero() {
return false
}
return now.Sub(*arrivedAt) < 30*24*time.Hour && !arrivedAt.After(now)
}
// petGoals 取建档时勾选的目标
func petGoals(pet *model.Pet) []string {
if len(pet.Goals) == 0 {
return nil
}
var goals []string
if err := json.Unmarshal(pet.Goals, &goals); err != nil {
return nil
}
return goals
}
func wantsGoal(goals []string, keyword string) bool {
for _, g := range goals {
if strings.Contains(g, keyword) {
return true
}
}
return false
}
// goalWantsReminder 目标 → 该不该建这类提醒。
// 一条都没勾(比如从「添加宠物」弹层建的档)时全部建,不做减法。
func goalWantsReminder(goals []string, reminderType string) bool {
if len(goals) == 0 {
return true
}
switch reminderType {
case model.ReminderVaccine, model.ReminderDeworm:
// 疫苗和驱虫不给关。狂犬是法定要求,漏驱虫是真会出事的,
// 不能因为用户建档时没勾「疫苗驱虫怕忘记」就干脆不提醒。
return true
case model.ReminderWeight:
return wantsGoal(goals, "体重") || wantsGoal(goals, "健康")
case model.ReminderMonthlyReport:
return wantsGoal(goals, "报告")
}
return true
}
// dueDaysFor 算这条提醒几天后到期。
// 模板里的 OffsetDays 是「典型情况」的天数,疫苗和驱虫必须按真实月龄修正——
// 原来不管多大都排「14 天后首针」,一只 8 月龄才接回家的猫会被安排去打首免。
func dueDaysFor(it model.CareTemplateItem, pet *model.Pet, now time.Time) int {
months := -1
if pet.Birthday != nil && !pet.Birthday.IsZero() {
months = monthsSince(*pet.Birthday)
}
switch it.ReminderType {
case model.ReminderVaccine:
if months < 0 {
return it.OffsetDays // 不知道月龄就按模板给的典型值
}
switch {
case months < 2:
// 首免 8 周龄起。还没到就等到那天,已经到了就尽快约。
if d := 56 - int(now.Sub(*pet.Birthday).Hours()/24); d > 0 {
return d
}
return 7
case months < 5:
return 21 // 幼崽针次之间隔 3-4 周
case months < 12:
return 14 // 月龄偏大但多半没打全,两周内补齐
default:
return it.OffsetDays // 成年后走模板:年度加强用频率而非到期日
}
case model.ReminderDeworm:
if months >= 0 && months < 6 {
return 30 // 6 月龄前每月一次
}
return it.OffsetDays
}
return it.OffsetDays
}
// reminderTitle 修正提醒标题。
// 阶段是用户可以手改的,改错了模板就会给一只 3 月龄的幼崽发「年度疫苗加强」。
// 到期日已经按月龄算过(见 dueDaysFor),标题也得跟上,否则用户照着标题去打错针。
func reminderTitle(it model.CareTemplateItem, pet *model.Pet) string {
if it.ReminderType != model.ReminderVaccine || pet.Birthday == nil || pet.Birthday.IsZero() {
return it.Title
}
months := monthsSince(*pet.Birthday)
if months >= 12 {
return it.Title
}
if months < 2 {
return "首针疫苗接种"
}
return "下一针疫苗(按月龄安排)"
}