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 } newDone := !pt.Done if err := s.db.Model(&model.PlanTask{}).Where("id = ?", taskID).Update("done", newDone).Error; err != nil { return err } // 勾选完成、且没关联记录类型的任务(自定义/无 sheet_type),补一条 note 记录, // 让最近记录里留痕。有 sheet_type 的任务是走记录表单单独落记录的,这里不重复。 if newDone && pt.SheetType == "" { _, _ = s.CreateRecord(userID, plan.PetID, RecordInput{Type: "note", Title: "完成:" + pt.Title}) } return nil } // newStagePlan 按宠物当前阶段的养护模板生成一份 30 天计划(未保存) // newStagePlan 只建一个空计划壳子(记住开始日,PlanTask.Day 是相对它算的)。 // // 原来它顺手把整套模板铺成节点。那是第二条「自动铺」的路径——我把 // seedPetDefaults 改成不自动铺之后,建档后首页仍然躺着 5 条任务,就是这里干的。 // 惰性生成尤其难查:不是建档时发生的,是第一次读计划才发生的。 // // 现在铺不铺由用户决定:建档最后一步挑方案,或者之后在计划页套用。 func (s *Service) newStagePlan(pet *model.Pet, start time.Time) model.Plan { 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", } } // 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,可空 // Daily 应用到每天:从 Date 起到窗口末尾,一天建一条。 // 不做「一条 + repeat 字段读时展开」是因为勾选状态是按天的—— // 那样得再开一张表记「哪天打过勾」,而直接建 N 条天然就有这个能力。 Daily bool } // 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 } // 计划以「自然月」为单位管理。 // // 不用「从计划开始日起 30 天」那种滚动窗口:计划可能是月中建的,那样窗口会 // 跨两个月,用户看着一个 7月26日—8月25日 的日历,既不像本月也不像下月, // 「这个月还有什么没做」根本对不上。按自然月切,日历就是一眼能认的月历。 // // 允许安排到下月末,否则「做下月计划」这件事本身做不了。 // monthBounds 某个月的首尾。off=0 本月,off=1 下月 func monthBounds(off int) (time.Time, time.Time) { n := time.Now() first := time.Date(n.Year(), n.Month(), 1, 0, 0, 0, 0, time.Local).AddDate(0, off, 0) return first, first.AddDate(0, 1, -1) } // dayOffset 把日期换算成 PlanTask.Day(相对计划开始日)。 // // 校验按**日期**而不是 Day 上限:Day 是相对计划开始日的,月份边界是相对今天的, // 两者会错开。按 Day<=30 卡的话,计划建了几天之后下月末就换算成 Day=40 多, // 用户在日历上明明看得到那格、点进去却存不了。 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 } st := time.Date(plan.StartDate.Year(), plan.StartDate.Month(), plan.StartDate.Day(), 0, 0, 0, 0, time.Local) // 下界放到计划开始日而不是本月 1 号:模板生成的历史节点可能在上个月, // 那些要允许原地改(改标题时日期会原样传回来) if d.Before(st) { return 0, errors.New("这天在计划开始之前,排不了") } _, nextEnd := monthBounds(1) if d.After(nextEnd) { return 0, errors.New("最远只能排到下个月月底(" + nextEnd.Format("1月2日") + ")") } return int(d.Sub(st).Hours() / 24), nil } // AddPlanTask 用户自己往计划里加。Daily=true 时从这天起到窗口末尾每天一条 func (s *Service) AddPlanTask(userID, petID string, in PlanTaskInput) ([]model.PlanTask, error) { title := strings.TrimSpace(in.Title) if 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 } // 要建几天。非 daily 就一天;daily 从这天排到窗口末尾 // 要建几天。daily 从这天排到**这一天所在那个月**的月底: // 「应用到每天」说的是这个月每天,跨到下月去就变成用户没要求的事了 count := 1 if in.Daily { from, e := time.ParseInLocation("2006-01-02", in.Date, time.Local) if e != nil { return nil, ErrInvalidParam } monthEnd := time.Date(from.Year(), from.Month(), 1, 0, 0, 0, 0, time.Local). AddDate(0, 1, -1) count = int(monthEnd.Sub(from).Hours()/24) + 1 if count < 1 { count = 1 } if count > 31 { count = 31 // 兜一层,别让脏日期算出个巨大的数 } } out := make([]model.PlanTask, 0, count) for i := 0; i < count; i++ { d := day + i out = append(out, model.PlanTask{ PlanID: plan.ID, Day: d, DayLabel: dayLabelFor(d), Title: title, Description: strings.TrimSpace(in.Description), SheetType: in.SheetType, }) } if err := s.db.Create(&out).Error; err != nil { return nil, err } for i := range out { out[i].Date = plan.StartDate.AddDate(0, 0, out[i].Day).Format("2006-01-02") } return out, 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) + " 天" } } // PlanMonthStatus 首页那个提醒角标要的东西 type PlanMonthStatus struct { Month string `json:"month"` // "2026-07" ThisMonth int `json:"this_month"` // 本月有几件 ThisUndone int `json:"this_undone"` // 本月还剩几件 NextMonth int `json:"next_month"` // 下月有几件 // NeedNext 该提醒用户排下月计划了。 // 只在月末最后 7 天提醒:月初就催「排下个月」太早,用户会当成噪音关掉。 NeedNext bool `json:"need_next"` NextLabel string `json:"next_label"` // "8 月" } // PlanMonthStatus 统计本月/下月的计划件数 func (s *Service) PlanMonthStatus(userID, petID string) (*PlanMonthStatus, error) { plan, err := s.planOf(userID, petID) if err != nil { return nil, err } thisFirst, thisEnd := monthBounds(0) nextFirst, nextEnd := monthBounds(1) var tasks []model.PlanTask s.db.Where("plan_id = ?", plan.ID).Find(&tasks) out := &PlanMonthStatus{ Month: thisFirst.Format("2006-01"), NextLabel: nextFirst.Format("1 月"), } for _, t := range tasks { d := plan.StartDate.AddDate(0, 0, t.Day) switch { case !d.Before(thisFirst) && !d.After(thisEnd): out.ThisMonth++ if !t.Done { out.ThisUndone++ } case !d.Before(nextFirst) && !d.After(nextEnd): out.NextMonth++ } } // 距月底还剩几天。用 24h 取整会在夏令时地区差一天,这里按日期差算 left := int(thisEnd.Sub(time.Date(time.Now().Year(), time.Now().Month(), time.Now().Day(), 0, 0, 0, 0, time.Local)).Hours()/24) out.NeedNext = out.NextMonth == 0 && left <= 7 return out, nil }