7acdf916d5
和文章同一个理由:模板是运营内容,不是代码。留一套内置默认还额外带来 一个坏处——库里配的和代码里写的会悄悄分叉,出问题时分不清用户拿到的 到底是哪一套。 删掉: - builtinCat / builtinDog / builtinCareItems(8 套 × 96 条) - SeedCareTemplates(启动时写内置默认) - ResetCareTemplate + 后台「恢复默认」按钮 —— 已经没有默认可恢复 careTemplateItems 现在查不到就返回空。这意味着后台把某个组合配空了, 新用户会拿到一个什么都没有的首页而且不报错,所以 seedPetDefaults 里 加了一条 log:模板为空时打出 species/stage/petID,否则没人能发现。 「AI 生成草稿」原来在 AI 未开启或调用失败时静默返回内置默认,看起来 像是 AI 生成的。现在直接报错说清原因,让管理员知道要么开 AI 要么手填。 数据在库里,删代码不影响:预生产 8 套 96 条原样在,建档端到端复测通过 (3 月龄幼猫仍拿到「下一针疫苗·21 天后」,成年犬仍有疫苗驱虫提醒)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
348 lines
10 KiB
Go
348 lines
10 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]
|
|
}
|
|
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)
|
|
}
|
|
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
|
|
}
|
|
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 天计划
|
|
func (s *Service) seedPetDefaults(tx *gorm.DB, pet *model.Pet) error {
|
|
today := time.Now()
|
|
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)
|
|
}
|
|
|
|
// 刚到家不到 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
|
|
var planTasks []model.PlanTask
|
|
for _, it := range items {
|
|
switch it.Kind {
|
|
case model.CareKindTask:
|
|
tasks = append(tasks, model.DailyTask{
|
|
PetID: pet.ID, UserID: pet.UserID, TaskDate: today,
|
|
Title: it.Title, Description: it.Description, Priority: it.Priority, SheetType: it.SheetType,
|
|
})
|
|
case model.CareKindPlan:
|
|
planTasks = append(planTasks, model.PlanTask{
|
|
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: reminderTitle(it, pet), Frequency: it.Frequency,
|
|
}
|
|
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 {
|
|
return err
|
|
}
|
|
}
|
|
if len(reminders) > 0 {
|
|
if err := tx.Create(&reminders).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
start := today
|
|
end := today.AddDate(0, 0, 30)
|
|
plan := model.Plan{
|
|
PetID: pet.ID, UserID: pet.UserID, Kind: model.PlanThirtyDay, Stage: pet.Stage,
|
|
StartDate: &start, EndDate: &end, CompletionPct: 0, Status: "active",
|
|
Tasks: planTasks,
|
|
}
|
|
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 "下一针疫苗(按月龄安排)"
|
|
}
|