package service import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "strings" "time" "gorm.io/gorm" "wx_service/config" usermodel "wx_service/internal/model" smokemodel "wx_service/internal/smoke/model" ) var ( ErrQuitPlanNotFound = errors.New("quit plan not found") ErrQuitPlanDayNotFound = errors.New("quit plan day not found") ErrNoUserProfile = errors.New("user profile not found, please complete onboarding first") ErrPlanAlreadyActive = errors.New("already has an active quit plan") ) const ( DefaultQuitPlanPromptVersion = "v1" QuitPlanDays = 30 ) // QuitPlanUserProfile 用户画像(用于生成戒烟计划) type QuitPlanUserProfile struct { BaselineCigsPerDay int `json:"baseline_cigs_per_day"` SmokingYears float64 `json:"smoking_years"` PackPriceCent int `json:"pack_price_cent"` SmokeMotivations []string `json:"smoke_motivations"` QuitMotivations []string `json:"quit_motivations"` WakeUpTime string `json:"wake_up_time"` SleepTime string `json:"sleep_time"` UserSegment string `json:"user_segment"` } // QuitPlanDayData 单日计划数据(AI 返回格式) type QuitPlanDayData struct { Day int `json:"day"` Stage string `json:"stage"` TargetCigs int `json:"target_cigs"` TargetReduce bool `json:"target_reduce"` Advice string `json:"advice"` } // QuitPlanAIResponse AI 返回的戒烟计划 type QuitPlanAIResponse struct { Summary string `json:"summary"` // 30 天计划概述 Days []QuitPlanDayData `json:"days"` // 30 天每日计划 } // SmokeQuitPlanService 戒烟计划服务 type SmokeQuitPlanService struct { db *gorm.DB cfg config.AIConfig client *http.Client } // NewSmokeQuitPlanService 创建戒烟计划服务 func NewSmokeQuitPlanService(db *gorm.DB, cfg config.AIConfig) *SmokeQuitPlanService { timeout := cfg.RequestTimeout if timeout <= 0 { timeout = 30 * time.Second } return &SmokeQuitPlanService{ db: db, cfg: cfg, client: &http.Client{ Timeout: timeout, }, } } // GenerateQuitPlanRequest 生成戒烟计划请求 type GenerateQuitPlanRequest struct { // 可选:指定开始日期,默认今天 StartDate *time.Time } // GenerateQuitPlan 生成戒烟计划 func (s *SmokeQuitPlanService) GenerateQuitPlan(ctx context.Context, user *usermodel.User, req GenerateQuitPlanRequest) (*smokemodel.SmokeQuitPlan, error) { // 检查是否已有活跃计划 existing, err := s.GetActivePlan(ctx, int(user.ID)) if err != nil && !errors.Is(err, ErrQuitPlanNotFound) { return nil, err } if existing != nil { return nil, ErrPlanAlreadyActive } // 获取用户画像 profile, err := s.getUserProfile(ctx, int(user.ID)) if err != nil { return nil, err } if profile == nil { return nil, ErrNoUserProfile } // 确定开始日期 startDate := time.Now().In(time.Local) if req.StartDate != nil { startDate = *req.StartDate } startDate = time.Date(startDate.Year(), startDate.Month(), startDate.Day(), 0, 0, 0, 0, time.Local) endDate := startDate.AddDate(0, 0, QuitPlanDays-1) // 调用 AI 生成计划 aiResp, modelName, tokensIn, tokensOut, err := s.callAIForQuitPlan(ctx, profile) if err != nil { return nil, fmt.Errorf("generate quit plan from AI: %w", err) } // 保存主计划 now := time.Now().Unix() createTime := now updateTime := now plan := smokemodel.SmokeQuitPlan{ UID: int(user.ID), Status: smokemodel.QuitPlanStatusActive, StartDate: startDate, EndDate: endDate, BaselineCigsPerDay: profile.BaselineCigsPerDay, SmokingYears: profile.SmokingYears, PackPriceCent: profile.PackPriceCent, CurrentStage: smokemodel.QuitPlanStageRecording, CurrentDay: 1, CompletedDays: 0, PromptVersion: DefaultQuitPlanPromptVersion, Provider: "openai-compatible", Model: modelName, TokensIn: tokensIn, TokensOut: tokensOut, Summary: aiResp.Summary, CreateTime: &createTime, UpdateTime: &updateTime, } // 事务保存计划及每日明细 err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Create(&plan).Error; err != nil { return fmt.Errorf("create quit plan: %w", err) } // 保存每日明细 for _, dayData := range aiResp.Days { planDate := startDate.AddDate(0, 0, dayData.Day-1) advice := dayData.Advice dayRecord := smokemodel.SmokeQuitPlanDay{ PlanID: plan.ID, UID: int(user.ID), PlanDate: planDate, Stage: smokemodel.QuitPlanStage(dayData.Stage), Day: dayData.Day, TargetCigs: dayData.TargetCigs, TargetReduced: dayData.TargetReduce, Advice: advice, CreateTime: &createTime, UpdateTime: &updateTime, } if err := tx.Create(&dayRecord).Error; err != nil { return fmt.Errorf("create quit plan day: %w", err) } } return nil }) if err != nil { return nil, err } return &plan, nil } // GetActivePlan 获取当前活跃的戒烟计划 func (s *SmokeQuitPlanService) GetActivePlan(ctx context.Context, uid int) (*smokemodel.SmokeQuitPlan, error) { var plan smokemodel.SmokeQuitPlan err := s.db.WithContext(ctx). Where("uid = ? AND status = ? AND (deletetime IS NULL OR deletetime = 0)", uid, smokemodel.QuitPlanStatusActive). First(&plan).Error if err == gorm.ErrRecordNotFound { return nil, ErrQuitPlanNotFound } if err != nil { return nil, fmt.Errorf("get active quit plan: %w", err) } return &plan, nil } // GetPlanByID 根据 ID 获取戒烟计划 func (s *SmokeQuitPlanService) GetPlanByID(ctx context.Context, uid, planID int) (*smokemodel.SmokeQuitPlan, error) { var plan smokemodel.SmokeQuitPlan err := s.db.WithContext(ctx). Where("id = ? AND uid = ? AND (deletetime IS NULL OR deletetime = 0)", planID, uid). First(&plan).Error if err == gorm.ErrRecordNotFound { return nil, ErrQuitPlanNotFound } if err != nil { return nil, fmt.Errorf("get quit plan by id: %w", err) } return &plan, nil } // GetPlanDays 获取计划的每日明细 func (s *SmokeQuitPlanService) GetPlanDays(ctx context.Context, planID int) ([]smokemodel.SmokeQuitPlanDay, error) { var days []smokemodel.SmokeQuitPlanDay err := s.db.WithContext(ctx). Where("plan_id = ? AND (deletetime IS NULL OR deletetime = 0)", planID). Order("day ASC"). Find(&days).Error if err != nil { return nil, fmt.Errorf("get quit plan days: %w", err) } return days, nil } // GetPlanDayByDate 根据日期获取每日计划 func (s *SmokeQuitPlanService) GetPlanDayByDate(ctx context.Context, uid int, date time.Time) (*smokemodel.SmokeQuitPlanDay, error) { dateOnly := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, time.Local) var dayPlan smokemodel.SmokeQuitPlanDay err := s.db.WithContext(ctx). Where("uid = ? AND plan_date = ? AND (deletetime IS NULL OR deletetime = 0)", uid, dateOnly.Format("2006-01-02")). First(&dayPlan).Error if err == gorm.ErrRecordNotFound { return nil, ErrQuitPlanDayNotFound } if err != nil { return nil, fmt.Errorf("get quit plan day by date: %w", err) } return &dayPlan, nil } // ResetPlan 重置戒烟计划(生成新计划) func (s *SmokeQuitPlanService) ResetPlan(ctx context.Context, user *usermodel.User, req GenerateQuitPlanRequest) (*smokemodel.SmokeQuitPlan, error) { // 软删除现有活跃计划(如果有) existing, err := s.GetActivePlan(ctx, int(user.ID)) if err != nil && !errors.Is(err, ErrQuitPlanNotFound) { return nil, err } if existing != nil { now := time.Now().Unix() err := s.db.WithContext(ctx).Model(existing).Updates(map[string]interface{}{ "status": smokemodel.QuitPlanStatusFailed, "updatetime": now, }).Error if err != nil { return nil, fmt.Errorf("mark existing plan as failed: %w", err) } } // 生成新计划 return s.GenerateQuitPlan(ctx, user, req) } // UpdatePlanDayProgress 更新每日计划进度(根据当天抽烟记录) func (s *SmokeQuitPlanService) UpdatePlanDayProgress(ctx context.Context, uid int, date time.Time) error { dayPlan, err := s.GetPlanDayByDate(ctx, uid, date) if err != nil { if errors.Is(err, ErrQuitPlanDayNotFound) { return nil // 没有计划日期,跳过 } return err } // 统计当天的抽烟记录 var totalCigs int err = s.db.WithContext(ctx). Table("fa_smoke_log"). Where("uid = ? AND smoke_time = ? AND (deletetime IS NULL OR deletetime = 0)", uid, time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, time.Local).Format("2006-01-02")). Select("COALESCE(SUM(num), 0) as total_cigs"). Row().Scan(&totalCigs) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return fmt.Errorf("count smoke logs: %w", err) } // 统计忍住的次数 var resistedCnt int64 err = s.db.WithContext(ctx). Table("fa_smoke_log"). Where("uid = ? AND smoke_time = ? AND level = 0 AND num = 0 AND (deletetime IS NULL OR deletetime = 0)", uid, time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, time.Local).Format("2006-01-02")). Count(&resistedCnt).Error if err != nil { return fmt.Errorf("count resisted: %w", err) } // 更新完成状态 achieved := totalCigs <= dayPlan.TargetCigs now := time.Now() nowUnix := now.Unix() resistedCntVal := int(resistedCnt) return s.db.WithContext(ctx).Model(dayPlan).Updates(map[string]interface{}{ "actual_cigs": totalCigs, "resisted_cnt": resistedCntVal, "achieved": achieved, "completed_at": now, "updatetime": nowUnix, }).Error } // getUserProfile 获取用户画像 func (s *SmokeQuitPlanService) getUserProfile(ctx context.Context, uid int) (*QuitPlanUserProfile, error) { var profile smokemodel.SmokeUserProfile err := s.db.WithContext(ctx). Where("uid = ? AND deleted_at IS NULL", uid). First(&profile).Error if err == gorm.ErrRecordNotFound { return nil, nil } if err != nil { return nil, fmt.Errorf("load user profile: %w", err) } wake := strings.TrimSpace(profile.WakeUpTime) sleep := strings.TrimSpace(profile.SleepTime) segment := quitPlanDeriveUserSegment(profile.BaselineCigsPerDay, profile.SmokingYears) return &QuitPlanUserProfile{ BaselineCigsPerDay: profile.BaselineCigsPerDay, SmokingYears: profile.SmokingYears, PackPriceCent: profile.PackPriceCent, SmokeMotivations: []string(profile.SmokeMotivations), QuitMotivations: []string(profile.QuitMotivations), WakeUpTime: wake, SleepTime: sleep, UserSegment: segment, }, nil } // callAIForQuitPlan 调用 AI 生成戒烟计划 func (s *SmokeQuitPlanService) callAIForQuitPlan(ctx context.Context, profile *QuitPlanUserProfile) (*QuitPlanAIResponse, string, *int, *int, error) { if s.cfg.APIKey == "" || s.cfg.Model == "" || s.cfg.BaseURL == "" { return nil, "", nil, nil, ErrAIServiceDisabled } systemPrompt := strings.TrimSpace(` 你是一位专业的戒烟教练与行为改变专家。你的任务是根据用户的画像数据,生成一份为期30天的个性化戒烟减量方案。 ## 输出要求 请严格按照以下 JSON 格式输出,不要输出任何其他内容: { "summary": "计划概述(200字以内,包含整体目标和策略)", "days": [ {"day": 1, "stage": "recording", "target_cigs": 10, "target_reduce": false, "advice": "建议内容"}, {"day": 2, "stage": "recording", "target_cigs": 10, "target_reduce": false, "advice": "建议内容"}, ...共30天... ] } ## 阶段划分 - recording(记录期): Day 1-7,目标建立基线,正常记录但尝试控制 - reducing(减量期): Day 8-21,目标逐步减少吸烟量,每周递减 - consolidating(巩固期): Day 22-30,目标维持成果,准备最终戒烟 ## 每日字段说明 - day: 第几天(1-30) - stage: 阶段(recording/reducing/consolidating) - target_cigs: 当天目标吸烟量(整数) - target_reduce: 是否比前一天减少 - advice: 当天的具体建议(50-100字,包含心理建设、替代行为、触发应对等) ## 生成策略 1. 根据用户的 baseline_cigs_per_day 生成递减目标 2. 记录期(Day1-7):目标 = baseline,允许小幅波动 3. 减量期(Day8-21):逐步递减,最终降到 baseline 的 30-50% 4. 巩固期(Day22-30):维持或接近归零 5. 建议要个性化,结合用户的戒烟动力、抽烟动机、作息时间 6. 用中文输出 `) smokeMotivations := "无" if len(profile.SmokeMotivations) > 0 { smokeMotivations = strings.Join(profile.SmokeMotivations, "、") } quitMotivations := "无" if len(profile.QuitMotivations) > 0 { quitMotivations = strings.Join(profile.QuitMotivations, "、") } schedule := "未设置" if profile.WakeUpTime != "" && profile.SleepTime != "" { schedule = fmt.Sprintf("%s - %s", profile.WakeUpTime, profile.SleepTime) } userPrompt := fmt.Sprintf(`用户画像: - 日均吸烟量:%d 支 - 烟龄:%.1f 年 - 单包价格:%d 分 - 抽烟动机:%s - 戒烟动力:%s - 作息时间:%s - 用户分段:%s 请生成30天戒烟计划。`, profile.BaselineCigsPerDay, profile.SmokingYears, profile.PackPriceCent, smokeMotivations, quitMotivations, schedule, profile.UserSegment) reqBody := quitPlanChatCompletionRequest{ Model: s.cfg.Model, Messages: []quitPlanChatMessage{ {Role: "system", Content: systemPrompt}, {Role: "user", Content: userPrompt}, }, Temperature: 0.7, } payload, err := json.Marshal(reqBody) if err != nil { return nil, "", nil, nil, fmt.Errorf("marshal ai request: %w", err) } endpoint := strings.TrimRight(s.cfg.BaseURL, "/") + "/chat/completions" httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload)) if err != nil { return nil, "", nil, nil, fmt.Errorf("build ai request: %w", err) } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Authorization", "Bearer "+s.cfg.APIKey) resp, err := s.client.Do(httpReq) if err != nil { return nil, "", nil, nil, fmt.Errorf("call ai: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, "", nil, nil, fmt.Errorf("read ai response: %w", err) } if resp.StatusCode != http.StatusOK { return nil, "", nil, nil, fmt.Errorf("ai http %d: %s", resp.StatusCode, quitPlanTruncateString(string(body), 512)) } var parsed quitPlanChatCompletionResponse if err := json.Unmarshal(body, &parsed); err != nil { return nil, "", nil, nil, fmt.Errorf("parse ai response: %w", err) } if len(parsed.Choices) == 0 { return nil, "", nil, nil, errors.New("ai response has no choices") } content := strings.TrimSpace(parsed.Choices[0].Message.Content) if content == "" { return nil, "", nil, nil, errors.New("ai response content is empty") } // 解析 JSON // 尝试提取 JSON 部分(可能包含markdown代码块) jsonStr := quitPlanExtractJSON(content) var aiResp QuitPlanAIResponse if err := json.Unmarshal([]byte(jsonStr), &aiResp); err != nil { return nil, "", nil, nil, fmt.Errorf("parse ai json: %w, content: %s", err, quitPlanTruncateString(content, 500)) } // 验证数据 if len(aiResp.Days) != 30 { return nil, "", nil, nil, fmt.Errorf("expected 30 days, got %d", len(aiResp.Days)) } modelName := parsed.Model if modelName == "" { modelName = s.cfg.Model } var tokensIn, tokensOut *int if parsed.Usage != nil { tokensIn = &parsed.Usage.PromptTokens tokensOut = &parsed.Usage.CompletionTokens } return &aiResp, modelName, tokensIn, tokensOut, nil } // quitPlanChatMessage AI 聊天消息 type quitPlanChatMessage struct { Role string `json:"role"` Content string `json:"content"` } // quitPlanChatCompletionRequest AI 请求 type quitPlanChatCompletionRequest struct { Model string `json:"model"` Messages []quitPlanChatMessage `json:"messages"` Temperature float64 `json:"temperature,omitempty"` } // quitPlanChatCompletionResponse AI 响应 type quitPlanChatCompletionResponse struct { Model string `json:"model"` Choices []struct { Message quitPlanChatMessage `json:"message"` } `json:"choices"` Usage *struct { PromptTokens int `json:"prompt_tokens"` CompletionTokens int `json:"completion_tokens"` TotalTokens int `json:"total_tokens"` } `json:"usage"` } // quitPlanExtractJSON 从可能包含 markdown 代码块的文本中提取 JSON func quitPlanExtractJSON(content string) string { // 尝试查找 JSON 代码块 start := strings.Index(content, "```json") if start >= 0 { content = content[start+7:] end := strings.Index(content, "```") if end >= 0 { content = content[:end] } } else { start = strings.Index(content, "```") if start >= 0 { content = content[start+3:] end := strings.Index(content, "```") if end >= 0 { content = content[:end] } } } return strings.TrimSpace(content) } // quitPlanDeriveUserSegment 推导用户分段 func quitPlanDeriveUserSegment(baselineCigsPerDay int, smokingYears float64) string { if baselineCigsPerDay >= 20 || smokingYears >= 10 { return "heavy" } if baselineCigsPerDay >= 10 || smokingYears >= 3 { return "moderate" } return "newbie" } // quitPlanTruncateString 截断字符串 func quitPlanTruncateString(s string, max int) string { if max <= 0 || len(s) <= max { return s } return s[:max] }