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 "下一针疫苗(按月龄安排)" }