Files
wx_service/internal/smoke/service/smoke_quit_plan_service.go
hello-dd-code 93bcc6c787 feat(smoke): 添加个性化戒烟计划生成功能 (Issue #46)
- 新增 Model 层: SmokeQuitPlan, SmokeQuitPlanDay 结构体
- 新增 Service 层: GenerateQuitPlan, GetActivePlan, GetPlanDays, ResetPlan
- 新增 Handler 层: POST /generate, GET /, GET /days, POST /reset
- 集成 AI 生成 30 天个性化戒烟减量方案
- 支持重置计划功能
2026-03-13 14:58:42 +08:00

554 lines
17 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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]
}