package service import ( "encoding/json" "errors" "fmt" "strings" "time" "gorm.io/gorm" "github.com/sundynix/pets-be/internal/ai" "github.com/sundynix/pets-be/internal/model" ) // 模板分组。注意 StageNewHome 不是年龄阶段,而是「刚到家」叠加包: // 建档时先按年龄阶段取一套,若到家不足 30 天,再把这一套叠上去。 const ( StageNewHome = "刚到家 0-30 天" StageJuvenile = "幼年期" StageAdult = "成年期" StageSenior = "老年期" ) // CareSpecies / CareStages 支持的物种与分组(字符串与 onboarding 一致) var CareSpecies = []string{model.SpeciesCat, model.SpeciesDog} var CareStages = []string{StageNewHome, StageJuvenile, StageAdult, StageSenior} // AgeStages 只含年龄阶段,用于推断和校验(不含叠加包) var AgeStages = []string{StageJuvenile, StageAdult, StageSenior} // StageFromBirthday 由生日推断年龄阶段。 // 猫:12 月龄成年、7 岁进入老年。 // 犬:12 月龄成年(大型犬实际 18-24 月,这里取通用值)、7 岁进入老年。 // 拿不到生日就回落到成年期——它的模板最保守,不会给幼崽排年度疫苗、 // 也不会给成年犬排"每周两次称重"。 func StageFromBirthday(species string, birthday *time.Time) string { if birthday == nil || birthday.IsZero() { return StageAdult } months := monthsSince(*birthday) switch { case months < 12: return StageJuvenile case months >= 7*12: return StageSenior default: return StageAdult } } // monthsSince 从某天到今天的整月数(不足一月算 0) func monthsSince(t time.Time) int { now := time.Now() m := (now.Year()-t.Year())*12 + int(now.Month()) - int(t.Month()) if now.Day() < t.Day() { m-- } if m < 0 { m = 0 } return m } // NormalizeStage 定下最终使用的年龄阶段。 // 用户手选优先(他可能比生日更清楚,比如领养来的估算年龄), // 但只认三个年龄阶段;传了「刚到家」或空值时,一律按生日推断。 func NormalizeStage(species, picked string, birthday *time.Time) string { for _, s := range AgeStages { if picked == s { return picked } } return StageFromBirthday(species, birthday) } // SpeciesOf 由宠物类型(猫猫/狗狗)映射到模板物种键 func SpeciesOf(petType string) string { if strings.Contains(petType, "狗") { return model.SpeciesDog } return model.SpeciesCat } func validSpecies(s string) bool { return s == model.SpeciesCat || s == model.SpeciesDog } func validStage(s string) bool { for _, x := range CareStages { if x == s { return true } } return false } // careTemplateItems 取某组合的模板明细。模板是后台配的内容数据,代码里不再留 // 一套内置默认——没配就是没配,这里返回空,由调用方决定怎么处理(见 seedPetDefaults)。 func (s *Service) careTemplateItems(species, stage string) []model.CareTemplateItem { var tpl model.CareTemplate err := s.db. Preload("Items", func(db *gorm.DB) *gorm.DB { return db.Order("kind, sort, id") }). Where("species = ? AND stage = ?", species, stage).First(&tpl).Error if err != nil { return nil } return tpl.Items } // GetCareTemplate 后台读取:返回某组合的明细(DB 优先,无则内置默认) // AdminListTemplates 后台:全部方案(含停用),按物种阶段排 func (s *Service) AdminListTemplates() []model.CareTemplate { var all []model.CareTemplate s.db.Preload("Items", func(db *gorm.DB) *gorm.DB { return db.Order("kind, sort, id") }). Order("species asc, stage asc, sort asc, id asc").Find(&all) if all == nil { return []model.CareTemplate{} } return all } // AdminGetTemplate 后台:取一套 func (s *Service) AdminGetTemplate(id string) (*model.CareTemplate, error) { var tpl model.CareTemplate if err := s.db.Preload("Items", func(db *gorm.DB) *gorm.DB { return db.Order("kind, sort, id") }). First(&tpl, "id = ?", id).Error; err != nil { return nil, ErrNotFound } return &tpl, nil } // DeleteTemplate 删一套方案。已经套用出去的计划节点不受影响—— // 套用时就已经展开成实体行、和模板断开了 func (s *Service) DeleteTemplate(id string) error { return s.db.Transaction(func(tx *gorm.DB) error { if err := tx.Where("template_id = ?", id).Delete(&model.CareTemplateItem{}).Error; err != nil { return err } return tx.Delete(&model.CareTemplate{}, "id = ?", id).Error }) } // SaveCareTemplate 后台保存:整表替换该组合的明细 // SaveTemplate 存一套方案。id 为空则新建 —— 同一个 (物种, 阶段) 可以有多套, // 用户建档时挑一套。原来是「按 species+stage 覆盖」,那个模型下 // 「让用户选方案」根本无从选起 func (s *Service) SaveTemplate(in *model.CareTemplate, items []model.CareTemplateItem) error { if !validSpecies(in.Species) || !validStage(in.Stage) { return errors.New("物种或阶段不合法") } if strings.TrimSpace(in.Name) == "" { return errors.New("方案要有名字 —— 用户建档时看到的就是它") } // 明细先校验一遍再落库:一条配错(比如「每周」没勾星期几)会让套用时 // 静默少排几条,用户看不出来是配错了 clean := make([]model.CareTemplateItem, 0, len(items)) for i := range items { it := items[i] it.Title = strings.TrimSpace(it.Title) if it.Title == "" { continue } if it.Kind != model.CareKindReminder { it.Kind = model.CareKindPlan switch it.Repeat { case model.RepeatDaily, model.RepeatMonthly: case model.RepeatWeekly: if len(weekdaysOf(it.Weekdays)) == 0 { return errors.New("「" + it.Title + "」选了每周,但没勾星期几") } default: it.Repeat = model.RepeatOnce } if it.Repeat == model.RepeatMonthly && (it.MonthDay < 1 || it.MonthDay > 31) { return errors.New("「" + it.Title + "」选了每月,几号要填 1-31") } } it.ID = "" it.Sort = i clean = append(clean, it) } return s.db.Transaction(func(tx *gorm.DB) error { if in.ID == "" { if err := tx.Create(in).Error; err != nil { return err } } else { if err := tx.Model(&model.CareTemplate{}).Where("id = ?", in.ID).Updates(map[string]any{ "species": in.Species, "stage": in.Stage, "name": in.Name, "summary": in.Summary, "sort": in.Sort, "enabled": in.Enabled, }).Error; err != nil { return err } if err := tx.Where("template_id = ?", in.ID).Delete(&model.CareTemplateItem{}).Error; err != nil { return err } } for i := range clean { clean[i].TemplateID = in.ID } if len(clean) > 0 { return tx.Create(&clean).Error } return nil }) } // GenerateCareTemplate 后台「AI 生成草稿」:不落库,仅返回建议明细。 // AI 未开启或失败时回退内置默认,保证总有可用草稿。 func (s *Service) GenerateCareTemplate(species, stage string) ([]model.CareTemplateItem, error) { if !validSpecies(species) || !validStage(stage) { return nil, errors.New("invalid species or stage") } if !s.ai.Enabled() { return nil, errors.New("AI 未开启,无法生成草稿;请手动添加明细") } spName := "猫" if species == model.SpeciesDog { spName = "狗" } system := aiSafetyPrompt + ` 你现在为后台生成一套「养护模板」,用于新宠建档时自动生成任务/计划/提醒。 只输出 JSON,结构如下(不要多余文字): { "tasks": [{"title":"","description":"","priority":"重要或空","sheet_type":"weight|poop|food|symptom|vaccine|medicine 或空"}], "plan": [{"day":0,"day_label":"今天","title":"","description":"","sheet_type":"weight|vaccine|medicine|report|taskDetail 或空"}], "reminders":[{"reminder_type":"vaccine|deworm|weight|monthlyReport","title":"","offset_days":18,"frequency":"到期用天数则填0并写频率,如每周二五"}] } 要求:tasks 3 条、plan 4 条、reminders 3-4 条;贴合该物种与阶段的真实照护节奏(如成年比幼年疫苗/驱虫间隔更长,狗驱虫比猫更频繁)。` user := fmt.Sprintf("为【%s · %s】生成一套养护模板。", spName, stage) out, err := s.ai.Complete(system, []ai.Message{{Role: "user", Content: user}}, ai.Options{JSON: true, Temperature: -1}) if err != nil { return nil, fmt.Errorf("AI 生成失败:%w", err) } items, ok := parseCareItems(out) if !ok || len(items) == 0 { return nil, errors.New("AI 返回的内容解析不出可用明细,请重试或手动添加") } return items, nil } // parseCareItems 解析 AI 返回的分组 JSON → 扁平明细 func parseCareItems(out string) ([]model.CareTemplateItem, bool) { var raw struct { Tasks []struct { Title, Description, Priority, SheetType string } `json:"tasks"` Plan []struct { Day int DayLabel, Title, Description, SheetType string } `json:"plan"` Reminders []struct { ReminderType, Title, Frequency string OffsetDays int } `json:"reminders"` } if err := json.Unmarshal([]byte(extractJSON(out)), &raw); err != nil { return nil, false } var items []model.CareTemplateItem for _, t := range raw.Tasks { if t.Title == "" { continue } items = append(items, careTask(t.Title, t.Description, t.Priority, t.SheetType)) } for _, p := range raw.Plan { if p.Title == "" { continue } items = append(items, carePlan(p.Day, p.DayLabel, p.Title, p.Description, p.SheetType)) } for _, r := range raw.Reminders { if r.Title == "" { continue } items = append(items, careReminder(r.ReminderType, r.Title, r.OffsetDays, r.Frequency)) } return items, len(items) > 0 } // ---- 明细构造小工具(供 AI 草稿解析用)---- // careTask 原来产出 kind=task(今日任务)。task 和 plan 已经合并—— // 「每天要做的事」就是「repeat=daily 的计划节点」,AI 草稿里那部分照这个走 func careTask(title, desc, priority, sheet string) model.CareTemplateItem { return model.CareTemplateItem{Kind: model.CareKindPlan, Repeat: model.RepeatDaily, Title: title, Description: desc, Priority: priority, SheetType: sheet} } func carePlan(day int, label, title, desc, sheet string) model.CareTemplateItem { return model.CareTemplateItem{Kind: model.CareKindPlan, Repeat: model.RepeatOnce, Day: day, DayLabel: label, Title: title, Description: desc, SheetType: sheet} } func careReminder(rtype, title string, offset int, freq string) model.CareTemplateItem { return model.CareTemplateItem{Kind: model.CareKindReminder, ReminderType: rtype, Title: title, OffsetDays: offset, Frequency: freq} }