feat: 引导页阶段按生日推断 + 8 套养护模板按临床共识重配
## 引导页三个逻辑漏洞 1. **阶段和生日脱节**:用户填了 3 个月的生日,却能手选「成年期」, 而阶段决定疫苗针次、驱虫周期和每天要做的事,选错整套计划都是偏的。 现在按生日自动推断(猫狗同:12 月龄成年、7 岁进老年),仍可手改, 但改成矛盾值时会弹框说明代价;服务端 NormalizeStage 兜底。 2. **`goals` 是假选择**:6 个勾选框收上来只存进 pets.goals,一处没用过。 现在真正生效:体重提醒、月度报告按勾选生成,勾了「想控制养宠开销」 会多一条记账任务。疫苗和驱虫不给关——狂犬是法定要求,漏驱虫真会出事。 3. **「刚到家 0-30 天」和年龄阶段互斥**:挤在一个四选一里,刚接回家的 成年猫只能二选一。现在拆开:stage 只存年龄阶段,新增 pets.arrived_at, 到家不满 30 天时把安置期那套任务和计划叠加上去(同标题去重)。 顺带:生日/到家日期不能选未来(前后端各拦一道)、体重范围校验。 ## 疫苗提醒改为按月龄算 原来不管多大都排「14 天后首针」,一只 8 月龄才接回家的猫会被安排去打首免。 现在 dueDaysFor 按真实月龄推:<2 月龄等到 8 周龄、2-5 月龄间隔 21 天、 5-12 月龄两周内补齐、成年走年度加强。标题也跟着改写,否则用户照着 「年度疫苗加强」去打错针。 年度疫苗不再编一个假的到期日(原来写 30 天后),改用频率描述。 ## 8 套模板按临床共识重配 依据 WSAVA 疫苗指南、ESCCAP/CAPC 驱虫建议、AAHA 生命阶段指南整理: 猫三联 3 针间隔 3-4 周末针不早于 16 周龄、犬联苗 2-3 针 + 3 月龄狂犬、 体内驱虫 6 月龄前每月一次之后每 3 个月、幼崽 3-6 月龄每天 3-4 餐、 成年犬每天 2 次共 30-60 分钟遛狗、老年建议半年一次体检。 每套从 11 条扩到 10-14 条,描述写成能直接照做的具体动作。 后台新增「恢复默认」:启动 seed 改为只补缺失、不覆盖已有(管理员可能 改过),内置模板更新后必须从后台显式推一次。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+156
-12
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -12,14 +13,15 @@ import (
|
||||
|
||||
// PetInput 建/改宠物入参
|
||||
type PetInput struct {
|
||||
Name string
|
||||
Emoji string
|
||||
Type string
|
||||
Gender string
|
||||
Birthday *time.Time
|
||||
Weight string
|
||||
Stage string
|
||||
Age string
|
||||
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
|
||||
@@ -70,6 +72,7 @@ func (s *Service) CreatePet(userID string, in PetInput) (*model.Pet, error) {
|
||||
Type: in.Type,
|
||||
Gender: in.Gender,
|
||||
Birthday: in.Birthday,
|
||||
ArrivedAt: in.ArrivedAt,
|
||||
Weight: normalizeWeight(in.Weight),
|
||||
Stage: in.Stage,
|
||||
Age: in.Age,
|
||||
@@ -145,7 +148,32 @@ func (s *Service) Onboarding(userID string, in PetInput) (*model.Pet, error) {
|
||||
// seedPetDefaults 为新宠按「物种 × 阶段」养护模板生成今日任务、提醒、30 天计划
|
||||
func (s *Service) seedPetDefaults(tx *gorm.DB, pet *model.Pet) error {
|
||||
today := time.Now()
|
||||
items := s.careTemplateItems(SpeciesOf(pet.Type), pet.Stage)
|
||||
species := SpeciesOf(pet.Type)
|
||||
items := s.careTemplateItems(species, pet.Stage)
|
||||
|
||||
// 刚到家不到 30 天,把安置期那套的任务和计划叠加上来。
|
||||
// 「刚到家」和「幼年/成年/老年」是两个正交的维度,原来挤在一个四选一里,
|
||||
// 结果刚接回家的成年猫只能二选一,要么丢应激期照护、要么丢年龄段照护。
|
||||
if isNewHome(pet.ArrivedAt, today) && pet.Stage != StageNewHome {
|
||||
seen := map[string]bool{}
|
||||
for _, it := range items {
|
||||
seen[it.Kind+"|"+it.Title] = true
|
||||
}
|
||||
for _, it := range s.careTemplateItems(species, StageNewHome) {
|
||||
if it.Kind != model.CareKindTask && it.Kind != model.CareKindPlan {
|
||||
continue
|
||||
}
|
||||
// 两套模板会撞题(比如都有「清理猫砂盆并观察」),撞了就跳过,
|
||||
// 否则用户第一天会看到两条一模一样的任务
|
||||
if seen[it.Kind+"|"+it.Title] {
|
||||
continue
|
||||
}
|
||||
seen[it.Kind+"|"+it.Title] = true
|
||||
items = append(items, it)
|
||||
}
|
||||
}
|
||||
|
||||
goals := petGoals(pet)
|
||||
|
||||
var tasks []model.DailyTask
|
||||
var reminders []model.Reminder
|
||||
@@ -162,17 +190,28 @@ func (s *Service) seedPetDefaults(tx *gorm.DB, pet *model.Pet) error {
|
||||
Day: it.Day, DayLabel: it.DayLabel, Title: it.Title, Description: it.Description, SheetType: it.SheetType,
|
||||
})
|
||||
case model.CareKindReminder:
|
||||
// 建档时勾的目标决定要不要建这条提醒。原来这几个勾选框收上来只是
|
||||
// 存进 pets.goals,一处都没用过,等于让用户点了个寂寞。
|
||||
if !goalWantsReminder(goals, it.ReminderType) {
|
||||
continue
|
||||
}
|
||||
r := model.Reminder{
|
||||
PetID: pet.ID, UserID: pet.UserID, Type: it.ReminderType,
|
||||
Title: it.Title, Frequency: it.Frequency,
|
||||
Title: reminderTitle(it, pet), Frequency: it.Frequency,
|
||||
}
|
||||
if it.OffsetDays > 0 {
|
||||
due := today.AddDate(0, 0, it.OffsetDays)
|
||||
if days := dueDaysFor(it, pet, today); days > 0 {
|
||||
due := today.AddDate(0, 0, days)
|
||||
r.NextDueDate = &due
|
||||
}
|
||||
reminders = append(reminders, r)
|
||||
}
|
||||
}
|
||||
if wantsGoal(goals, "开销") {
|
||||
tasks = append(tasks, model.DailyTask{
|
||||
PetID: pet.ID, UserID: pet.UserID, TaskDate: today,
|
||||
Title: "记一笔养宠开销", Description: "粮、猫砂、医疗都记上,月底能看到花在哪", SheetType: "cost",
|
||||
})
|
||||
}
|
||||
|
||||
if len(tasks) > 0 {
|
||||
if err := tx.Create(&tasks).Error; err != nil {
|
||||
@@ -194,3 +233,108 @@ func (s *Service) seedPetDefaults(tx *gorm.DB, pet *model.Pet) error {
|
||||
}
|
||||
return tx.Create(&plan).Error
|
||||
}
|
||||
|
||||
// ---- 建档默认值的几个判断 ----
|
||||
|
||||
// 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 "下一针疫苗(按月龄安排)"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user