package service import ( "math" "strings" "time" "gorm.io/datatypes" "gorm.io/gorm" "github.com/sundynix/pets-be/internal/model" ) // TogglePlanTask 切换计划明细完成状态(校验归属) func (s *Service) TogglePlanTask(userID, taskID string) error { var pt model.PlanTask if err := s.db.First(&pt, taskID).Error; err != nil { return ErrNotFound } var plan model.Plan if err := s.db.Where("id = ? AND user_id = ?", pt.PlanID, userID).First(&plan).Error; err != nil { return ErrNotFound } return s.db.Model(&model.PlanTask{}).Where("id = ?", taskID).Update("done", !pt.Done).Error } // newStagePlan 按宠物当前阶段的养护模板生成一份 30 天计划(未保存) func (s *Service) newStagePlan(pet *model.Pet, start time.Time) model.Plan { var planTasks []model.PlanTask for _, it := range s.careTemplateItems(SpeciesOf(pet.Type), pet.Stage) { if it.Kind == model.CareKindPlan { planTasks = append(planTasks, model.PlanTask{ Day: it.Day, DayLabel: it.DayLabel, Title: it.Title, Description: it.Description, SheetType: it.SheetType, }) } } end := start.AddDate(0, 0, 30) return model.Plan{ PetID: pet.ID, UserID: pet.UserID, Kind: model.PlanThirtyDay, Stage: pet.Stage, StartDate: &start, EndDate: &end, CompletionPct: 0, Status: "active", Tasks: planTasks, } } // ensurePlan 计划到期自动续期:active 计划过期则归档并按当前阶段生成新一轮;无 active 则补建 func (s *Service) ensurePlan(pet *model.Pet) { now := time.Now() var plan model.Plan err := s.db.Where("pet_id = ? AND kind = ? AND status = ?", pet.ID, model.PlanThirtyDay, "active"). Order("id desc").First(&plan).Error if err == gorm.ErrRecordNotFound { p := s.newStagePlan(pet, now) s.db.Create(&p) return } if err == nil && plan.EndDate != nil && plan.EndDate.Before(now) { s.db.Model(&model.Plan{}).Where("id = ?", plan.ID).Update("status", "archived") p := s.newStagePlan(pet, now) s.db.Create(&p) } } // GetPlan 取宠物的 30 天计划(含明细);过期自动续期 func (s *Service) GetPlan(userID, petID string) (*model.Plan, error) { pet, err := s.ownedPet(userID, petID) if err != nil { return nil, err } s.ensurePlan(pet) var plan model.Plan err = s.db.Preload("Tasks"). Where("pet_id = ? AND kind = ?", petID, model.PlanThirtyDay). Where("status = ?", "active"). Order("id desc").First(&plan).Error if err == gorm.ErrRecordNotFound { return nil, ErrNotFound } if err != nil { return nil, err } // 真实完成度:已完成明细 / 总明细 if n := len(plan.Tasks); n > 0 { done := 0 for _, t := range plan.Tasks { if t.Done { done++ } } plan.CompletionPct = int(math.Round(float64(done) / float64(n) * 100)) } else { plan.CompletionPct = 0 } // 计算每个路线图节点的真实日期(开始日 + Day),供前端展示并与日历对齐 if plan.StartDate != nil { for i := range plan.Tasks { plan.Tasks[i].Date = plan.StartDate.AddDate(0, 0, plan.Tasks[i].Day).Format("2006-01-02") } } return &plan, nil } // planNodeDates 30 天计划路线图节点在 [start,end) 内的真实日期(开始日 + Day) func (s *Service) planNodeDates(petID string, start, end time.Time) []time.Time { var plan model.Plan if err := s.db.Preload("Tasks"). Where("pet_id = ? AND kind = ?", petID, model.PlanThirtyDay). Order("id desc").First(&plan).Error; err != nil || plan.StartDate == nil { return nil } var out []time.Time for _, t := range plan.Tasks { d := plan.StartDate.AddDate(0, 0, t.Day) dd := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, d.Location()) if !dd.Before(start) && dd.Before(end) { out = append(out, dd) } } return out } // CalendarResult 日历视图 type CalendarResult struct { Year int `json:"year"` Month int `json:"month"` TaskedDays []int `json:"tasked_days"` Today int `json:"today"` } // Calendar 某月有任务/提醒的日期 func (s *Service) Calendar(userID, petID string, year, month int) (*CalendarResult, error) { if _, err := s.ownedPet(userID, petID); err != nil { return nil, err } loc := time.Now().Location() start := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, loc) end := start.AddDate(0, 1, 0) // 与首页周历同源:任务/记录/提醒/计划路线图节点 set := s.datesWithActivity(petID, start, end) days := make([]int, 0, len(set)) for k := range set { if t, err := time.ParseInLocation("2006-01-02", k, time.Local); err == nil { days = append(days, t.Day()) } } today := 0 now := time.Now() if now.Year() == year && int(now.Month()) == month { today = now.Day() } return &CalendarResult{Year: year, Month: month, TaskedDays: days, Today: today}, nil } // DayPlan 某一天的安排:计划路线图节点 + 今日任务 + 到期提醒 func (s *Service) DayPlan(userID, petID, dateStr string) (map[string]any, error) { if _, err := s.ownedPet(userID, petID); err != nil { return nil, err } day, err := time.ParseInLocation("2006-01-02", dateStr, time.Local) if err != nil { return nil, ErrNotFound } next := day.AddDate(0, 0, 1) nodes := []model.PlanTask{} var plan model.Plan if e := s.db.Preload("Tasks"). Where("pet_id = ? AND kind = ?", petID, model.PlanThirtyDay). Order("id desc").First(&plan).Error; e == nil && plan.StartDate != nil { for _, t := range plan.Tasks { d := plan.StartDate.AddDate(0, 0, t.Day) if d.Year() == day.Year() && d.YearDay() == day.YearDay() { t.Date = d.Format("2006-01-02") nodes = append(nodes, t) } } } var tasks []model.DailyTask s.db.Where("pet_id = ? AND task_date >= ? AND task_date < ?", petID, day, next).Find(&tasks) var reminders []model.Reminder s.db.Where("pet_id = ? AND next_due_date >= ? AND next_due_date < ?", petID, day, next).Find(&reminders) return map[string]any{ "date": dateStr, "plan_nodes": nodes, "tasks": tasks, "reminders": reminders, }, nil } // CreateAIPlan 基于用户描述做规则化提取,生成待确认的 AI 计划 func (s *Service) CreateAIPlan(userID, petID string, input string) (*model.Plan, error) { if err := s.consumeAIQuota(userID, aiKindPlan); err != nil { return nil, err } pet, err := s.ownedPet(userID, petID) if err != nil { return nil, err } // 默认(规则化)提取与任务 var extracted any = extractAIInfo(input, pet.Stage) tasks := []model.PlanTask{ {DayLabel: "Day 1-2", Title: "观察食欲和便便", Description: "每日记录饮食、便便、精神状态。"}, {DayLabel: "Day 3-5", Title: "记录换粮比例", Description: "逐步提高新粮比例,留意软便。"}, {DayLabel: "Day 6-7", Title: "生成复盘建议", Description: "汇总一周状态,输出复盘。"}, } // 启用模型则用真实提取覆盖 if ex, ok := s.aiPlanExtract(pet, input); ok { extracted = map[string]string{ "stage": ex.Stage, "risk": ex.Risk, "priority": ex.Priority, "reminder": ex.Reminder, } tasks = tasks[:0] for _, t := range ex.Tasks { tasks = append(tasks, model.PlanTask{DayLabel: t.DayLabel, Title: t.Title, Description: t.Description}) } } b, _ := jsonMarshalAny(extracted) plan := model.Plan{ PetID: petID, UserID: userID, Kind: model.PlanAI, Stage: pet.Stage, UserInput: input, Extracted: datatypes.JSON(b), Status: "draft", Tasks: tasks, } if err := s.db.Create(&plan).Error; err != nil { return nil, err } return &plan, nil } // ApplyAIPlan 确认并应用 AI 计划 func (s *Service) ApplyAIPlan(userID, planID string) (*model.Plan, error) { var plan model.Plan if err := s.db.Where("id = ? AND user_id = ?", planID, userID).First(&plan).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, ErrNotFound } return nil, err } now := time.Now() end := now.AddDate(0, 0, 7) if err := s.db.Model(&plan).Updates(map[string]any{ "status": "active", "start_date": now, "end_date": end, }).Error; err != nil { return nil, err } if err := s.db.Preload("Tasks").First(&plan, plan.ID).Error; err != nil { return nil, err } return &plan, nil } // extractAIInfo 规则化提取(后续可替换为大模型) func extractAIInfo(input, stage string) map[string]string { risk := "暂无明显风险" priority := "日常观察" reminder := "常规提醒" if strings.Contains(input, "换粮") || strings.Contains(input, "软便") { risk = "换粮软便" priority = "排便观察" } if strings.Contains(input, "疫苗") { reminder = "第二针疫苗" } if stage == "" { stage = "幼年期" } return map[string]string{ "stage": stage + " / 疫苗期", "risk": risk, "priority": priority, "reminder": reminder, } }