package service import ( "errors" "math" "strconv" "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, } } // PlanTaskInput 用户自己加/改的计划节点 type PlanTaskInput struct { Date string // "2026-08-05",落到 Day = 这天 - 计划开始日 Title string Description string SheetType string // 关联的记录类型 code,可空 } // planOf 取这只宠物当前的 30 天计划,顺带做归属校验 func (s *Service) planOf(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 if e := s.db.Where("pet_id = ? AND kind = ? AND status = ?", petID, model.PlanThirtyDay, "active").Order("id desc").First(&plan).Error; e != nil { return nil, ErrNotFound } return &plan, nil } // planSpanDays 计划覆盖到第几天。内置模板会生成 day=30 的节点(第 31 天), // 所以窗口是 [0, 30] 而不是 [0, 29]——按 29 卡的话模板自己生成的最后一个节点 // 就落在窗口外:日历上没有那一格,用户看得到却改不动也删不掉。 const planSpanDays = 30 // dayOffset 把日期换算成计划里的 Day。只接受计划开始日起 planSpanDays 天内—— // 「只做 30 天内的计划」是产品定的边界,超出的话日历上根本没有那一格 func dayOffset(plan *model.Plan, dateStr string) (int, error) { if plan.StartDate == nil { return 0, errors.New("计划还没有开始日期") } d, err := time.ParseInLocation("2006-01-02", dateStr, time.Local) if err != nil { return 0, ErrInvalidParam } start := time.Date(plan.StartDate.Year(), plan.StartDate.Month(), plan.StartDate.Day(), 0, 0, 0, 0, time.Local) day := int(d.Sub(start).Hours() / 24) if day < 0 || day > planSpanDays { return 0, errors.New("只能安排计划开始后 30 天内的事,这天超出范围了") } return day, nil } // AddPlanTask 用户自己往计划里加一条 func (s *Service) AddPlanTask(userID, petID string, in PlanTaskInput) (*model.PlanTask, error) { if strings.TrimSpace(in.Title) == "" { return nil, ErrInvalidParam } plan, err := s.planOf(userID, petID) if err != nil { return nil, err } day, err := dayOffset(plan, in.Date) if err != nil { return nil, err } t := model.PlanTask{ PlanID: plan.ID, Day: day, DayLabel: dayLabelFor(day), Title: strings.TrimSpace(in.Title), Description: strings.TrimSpace(in.Description), SheetType: in.SheetType, } if err := s.db.Create(&t).Error; err != nil { return nil, err } t.Date = plan.StartDate.AddDate(0, 0, day).Format("2006-01-02") return &t, nil } // UpdatePlanTask 改标题/描述/日期 func (s *Service) UpdatePlanTask(userID, taskID string, in PlanTaskInput) error { plan, t, err := s.ownedPlanTask(userID, taskID) if err != nil { return err } fields := map[string]any{} if strings.TrimSpace(in.Title) != "" { fields["title"] = strings.TrimSpace(in.Title) } fields["description"] = strings.TrimSpace(in.Description) if in.Date != "" { day, err := dayOffset(plan, in.Date) if err != nil { return err } fields["day"] = day fields["day_label"] = dayLabelFor(day) } return s.db.Model(&model.PlanTask{}).Where("id = ?", t.ID).Updates(fields).Error } // DeletePlanTask 删掉一条 func (s *Service) DeletePlanTask(userID, taskID string) error { _, t, err := s.ownedPlanTask(userID, taskID) if err != nil { return err } return s.db.Delete(&model.PlanTask{}, "id = ?", t.ID).Error } // ownedPlanTask 校验这条节点属于当前用户的宠物。 // 不校验的话,拿到别人的 task id 就能改删别人的计划 func (s *Service) ownedPlanTask(userID, taskID string) (*model.Plan, *model.PlanTask, error) { var t model.PlanTask if err := s.db.First(&t, "id = ?", taskID).Error; err != nil { return nil, nil, ErrNotFound } var plan model.Plan if err := s.db.First(&plan, "id = ?", t.PlanID).Error; err != nil { return nil, nil, ErrNotFound } if _, err := s.ownedPet(userID, plan.PetID); err != nil { return nil, nil, ErrForbidden } return &plan, &t, nil } // dayLabelFor 第 N 天的展示标签,和内置模板生成的那批保持一致 func dayLabelFor(day int) string { switch { case day == 0: return "今天" case day == 1: return "明天" default: return "第 " + strconv.Itoa(day+1) + " 天" } }