feat(smoke): 添加个性化戒烟计划生成功能 (Issue #46)

- 新增 Model 层: SmokeQuitPlan, SmokeQuitPlanDay 结构体
- 新增 Service 层: GenerateQuitPlan, GetActivePlan, GetPlanDays, ResetPlan
- 新增 Handler 层: POST /generate, GET /, GET /days, POST /reset
- 集成 AI 生成 30 天个性化戒烟减量方案
- 支持重置计划功能
This commit is contained in:
hello-dd-code
2026-03-13 14:58:42 +08:00
parent a46b51cd58
commit 93bcc6c787
6 changed files with 948 additions and 3 deletions
+2 -1
View File
@@ -26,6 +26,7 @@ func Register(
authHandler *authhandler.AuthHandler,
videoHandler *rmhandler.VideoHandler,
smokeHandler *smokehandler.SmokeHandler,
quitPlanHandler *smokehandler.QuitPlanHandler,
redeemCodeHandler *membershiphandler.RedeemCodeHandler,
uploadHandler *qiniuhandler.UploadHandler,
oaOAuthHandler *oahandler.OAuthHandler,
@@ -62,7 +63,7 @@ func Register(
registerCommonRoutes(protected, uploadHandler)
registerRemoveWatermarkRoutes(api, protected, videoHandler)
registerMembershipRoutes(protected, redeemCodeHandler)
registerSmokeRoutes(protected, smokeHandler)
registerSmokeRoutes(protected, smokeHandler, quitPlanHandler)
}
registerMarketingRoutes(api, protected, adminToken, marketingCategoryHandler, marketingTemplateHandler, marketingDownloadHandler)
+8 -2
View File
@@ -6,7 +6,7 @@ import (
smokehandler "wx_service/internal/smoke/handler"
)
func registerSmokeRoutes(protected *gin.RouterGroup, smokeHandler *smokehandler.SmokeHandler) {
func registerSmokeRoutes(protected *gin.RouterGroup, smokeHandler *smokehandler.SmokeHandler, quitPlanHandler *smokehandler.QuitPlanHandler) {
// 戒烟/抽烟记录(与 video 去水印功能在路由前缀上区分开)
smoke := protected.Group("/smoke")
{
@@ -16,7 +16,7 @@ func registerSmokeRoutes(protected *gin.RouterGroup, smokeHandler *smokehandler.
smoke.GET("/profile", smokeHandler.GetProfile)
smoke.POST("/profile", smokeHandler.UpsertProfile)
// 不使用 AI 时的默认下次抽烟时间建议(阶梯式延时)
// 不使用 AI 时的默认"下次抽烟时间"建议(阶梯式延时)
smoke.GET("/next_smoke_time", smokeHandler.GetNextSmokeTime)
smoke.GET("/dashboard", smokeHandler.Dashboard)
@@ -41,5 +41,11 @@ func registerSmokeRoutes(protected *gin.RouterGroup, smokeHandler *smokehandler.
smoke.POST("/share", smokeHandler.CreateShare)
smoke.GET("/share/:token", smokeHandler.GetShareView)
smoke.POST("/share/:token/revoke", smokeHandler.RevokeShare)
// 个性化戒烟计划(30天减量方案)
smoke.POST("/quit-plan/generate", quitPlanHandler.GenerateQuitPlan)
smoke.GET("/quit-plan", quitPlanHandler.GetQuitPlan)
smoke.GET("/quit-plan/days", quitPlanHandler.GetQuitPlanDays)
smoke.POST("/quit-plan/reset", quitPlanHandler.ResetQuitPlan)
}
}
@@ -0,0 +1,259 @@
package handler
import (
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"wx_service/internal/middleware"
"wx_service/internal/model"
smokeservice "wx_service/internal/smoke/service"
)
// QuitPlanHandler 戒烟计划 Handler
type QuitPlanHandler struct {
smokeQuitPlanService *smokeservice.SmokeQuitPlanService
}
// NewQuitPlanHandler 创建戒烟计划 Handler
func NewQuitPlanHandler(smokeQuitPlanService *smokeservice.SmokeQuitPlanService) *QuitPlanHandler {
return &QuitPlanHandler{
smokeQuitPlanService: smokeQuitPlanService,
}
}
// generateQuitPlanRequest 生成戒烟计划请求
type generateQuitPlanRequest struct {
StartDate string `json:"start_date"`
}
// GenerateQuitPlan POST /api/smoke/quit-plan/generate - 生成戒烟计划
func (h *QuitPlanHandler) GenerateQuitPlan(c *gin.Context) {
user := middleware.MustCurrentUser(c)
var req generateQuitPlanRequest
if err := c.ShouldBindJSON(&req); err != nil {
// 允许空请求,使用默认开始日期
req = generateQuitPlanRequest{}
}
var startDate *time.Time
if req.StartDate != "" {
parsed, err := time.ParseInLocation(dateLayout, req.StartDate, time.Local)
if err != nil {
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "start_date 格式错误,应为 YYYY-MM-DD"))
return
}
startDate = &parsed
}
plan, err := h.smokeQuitPlanService.GenerateQuitPlan(c.Request.Context(), user, smokeservice.GenerateQuitPlanRequest{
StartDate: startDate,
})
if err != nil {
switch {
case err == smokeservice.ErrNoUserProfile:
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请先完成个人资料填写"))
return
case err == smokeservice.ErrPlanAlreadyActive:
c.JSON(http.StatusConflict, model.Error(http.StatusConflict, "已有进行中的戒烟计划,请先重置"))
return
case err == smokeservice.ErrAIServiceDisabled:
c.JSON(http.StatusServiceUnavailable, model.Error(http.StatusServiceUnavailable, "AI 服务暂不可用,请联系管理员"))
return
default:
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "生成计划失败,请稍后重试"))
return
}
}
c.JSON(http.StatusOK, model.Success(gin.H{
"id": plan.ID,
"status": plan.Status,
"start_date": plan.StartDate.Format(dateLayout),
"end_date": plan.EndDate.Format(dateLayout),
"current_stage": plan.CurrentStage,
"current_day": plan.CurrentDay,
"baseline_cigs": plan.BaselineCigsPerDay,
"summary": plan.Summary,
}))
}
// GetQuitPlan GET /api/smoke/quit-plan - 查询当前戒烟计划
func (h *QuitPlanHandler) GetQuitPlan(c *gin.Context) {
user := middleware.MustCurrentUser(c)
plan, err := h.smokeQuitPlanService.GetActivePlan(c.Request.Context(), int(user.ID))
if err != nil {
if err == smokeservice.ErrQuitPlanNotFound {
c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "暂无进行中的戒烟计划"))
return
}
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "查询计划失败,请稍后重试"))
return
}
// 计算当前进度
today := time.Now()
daysSinceStart := int(today.Sub(plan.StartDate).Hours() / 24)
if daysSinceStart < 0 {
daysSinceStart = 0
}
currentDay := daysSinceStart + 1
if currentDay > 30 {
currentDay = 30
}
// 更新阶段
currentStage := plan.CurrentStage
if currentDay > 21 {
currentStage = "consolidating"
} else if currentDay > 7 {
currentStage = "reducing"
} else {
currentStage = "recording"
}
// 获取今日目标
var todayTarget *int
var todayAdvice *string
todayDate := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, time.Local)
dayPlan, err := h.smokeQuitPlanService.GetPlanDayByDate(c.Request.Context(), int(user.ID), todayDate)
if err == nil {
todayTarget = &dayPlan.TargetCigs
todayAdvice = &dayPlan.Advice
}
c.JSON(http.StatusOK, model.Success(gin.H{
"id": plan.ID,
"status": plan.Status,
"start_date": plan.StartDate.Format(dateLayout),
"end_date": plan.EndDate.Format(dateLayout),
"current_stage": currentStage,
"current_day": currentDay,
"completed_days": plan.CompletedDays,
"baseline_cigs": plan.BaselineCigsPerDay,
"summary": plan.Summary,
"today_target": todayTarget,
"today_advice": todayAdvice,
}))
}
// GetQuitPlanDays GET /api/smoke/quit-plan/days - 查询每日明细
func (h *QuitPlanHandler) GetQuitPlanDays(c *gin.Context) {
user := middleware.MustCurrentUser(c)
// 获取计划 ID
planIDStr := c.Query("plan_id")
var planID int
if planIDStr != "" {
var err error
planID, err = strconv.Atoi(planIDStr)
if err != nil || planID <= 0 {
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "plan_id 参数错误"))
return
}
} else {
// 默认获取活跃计划
plan, err := h.smokeQuitPlanService.GetActivePlan(c.Request.Context(), int(user.ID))
if err != nil {
if err == smokeservice.ErrQuitPlanNotFound {
c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "暂无进行中的戒烟计划"))
return
}
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "查询计划失败"))
return
}
planID = int(plan.ID)
}
// 查询每日明细
days, err := h.smokeQuitPlanService.GetPlanDays(c.Request.Context(), planID)
if err != nil {
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "查询明细失败"))
return
}
// 转换为响应格式
result := make([]gin.H, len(days))
for i, d := range days {
result[i] = gin.H{
"day": d.Day,
"plan_date": d.PlanDate.Format(dateLayout),
"stage": d.Stage,
"target_cigs": d.TargetCigs,
"target_reduced": d.TargetReduced,
"advice": d.Advice,
}
// 如果有实际数据,也返回
if d.ActualCigs != nil {
result[i]["actual_cigs"] = *d.ActualCigs
}
if d.ResistedCnt != nil {
result[i]["resisted_cnt"] = *d.ResistedCnt
}
if d.Achieved != nil {
result[i]["achieved"] = *d.Achieved
}
}
c.JSON(http.StatusOK, model.Success(gin.H{
"plan_id": planID,
"days": result,
}))
}
// resetQuitPlanRequest 重置戒烟计划请求
type resetQuitPlanRequest struct {
StartDate string `json:"start_date"`
}
// ResetQuitPlan POST /api/smoke/quit-plan/reset - 重置戒烟计划
func (h *QuitPlanHandler) ResetQuitPlan(c *gin.Context) {
user := middleware.MustCurrentUser(c)
var req resetQuitPlanRequest
if err := c.ShouldBindJSON(&req); err != nil {
req = resetQuitPlanRequest{}
}
var startDate *time.Time
if req.StartDate != "" {
parsed, err := time.ParseInLocation(dateLayout, req.StartDate, time.Local)
if err != nil {
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "start_date 格式错误,应为 YYYY-MM-DD"))
return
}
startDate = &parsed
}
plan, err := h.smokeQuitPlanService.ResetPlan(c.Request.Context(), user, smokeservice.GenerateQuitPlanRequest{
StartDate: startDate,
})
if err != nil {
switch {
case err == smokeservice.ErrNoUserProfile:
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请先完成个人资料填写"))
return
case err == smokeservice.ErrAIServiceDisabled:
c.JSON(http.StatusServiceUnavailable, model.Error(http.StatusServiceUnavailable, "AI 服务暂不可用,请联系管理员"))
return
default:
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "重置计划失败,请稍后重试"))
return
}
}
c.JSON(http.StatusOK, model.Success(gin.H{
"id": plan.ID,
"status": plan.Status,
"start_date": plan.StartDate.Format(dateLayout),
"end_date": plan.EndDate.Format(dateLayout),
"current_stage": plan.CurrentStage,
"current_day": plan.CurrentDay,
"baseline_cigs": plan.BaselineCigsPerDay,
"summary": plan.Summary,
}))
}
+121
View File
@@ -0,0 +1,121 @@
package model
import (
"time"
"gorm.io/gorm"
)
// QuitPlanStatus 戒烟计划状态
type QuitPlanStatus string
const (
QuitPlanStatusActive QuitPlanStatus = "active" // 进行中
QuitPlanStatusPaused QuitPlanStatus = "paused" // 已暂停
QuitPlanStatusDone QuitPlanStatus = "done" // 已完成
QuitPlanStatusFailed QuitPlanStatus = "failed" // 已失败
)
// QuitPlanStage 戒烟计划阶段
type QuitPlanStage string
const (
QuitPlanStageRecording QuitPlanStage = "recording" // 记录期 (Day 1-7)
QuitPlanStageReducing QuitPlanStage = "reducing" // 减量期 (Day 8-21)
QuitPlanStage巩固ing QuitPlanStage = "consolidating" // 巩固期 (Day 22-30)
)
// SmokeQuitPlan 对应表 fa_smoke_quit_plan(戒烟计划主表)。
type SmokeQuitPlan struct {
ID uint `gorm:"primaryKey;autoIncrement;comment:计划ID" json:"id"`
CreatedAt time.Time `gorm:"comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"comment:更新时间" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"-"`
UID int `gorm:"uniqueIndex:idx_quit_plan_uid;comment:用户ID" json:"-"`
// 计划基本信息
Status QuitPlanStatus `gorm:"column:status;size:20;default:active;index:idx_quit_plan_uid;comment:计划状态" json:"status"`
StartDate time.Time `gorm:"column:start_date;type:date;comment:计划开始日期" json:"start_date"`
EndDate time.Time `gorm:"column:end_date;type:date;comment:计划结束日期" json:"end_date"`
// 用户画像(生成计划时的快照)
BaselineCigsPerDay int `gorm:"column:baseline_cigs_per_day;comment:日均吸烟量" json:"baseline_cigs_per_day"`
SmokingYears float64 `gorm:"column:smoking_years;type:decimal(6,2);comment:烟龄" json:"smoking_years"`
PackPriceCent int `gorm:"column:pack_price_cent;comment:单包价格(分)" json:"pack_price_cent"`
// 阶段进度
CurrentStage QuitPlanStage `gorm:"column:current_stage;size:20;default:recording;comment:当前阶段" json:"current_stage"`
CurrentDay int `gorm:"column:current_day;default:1;comment:当前天数(1-30)" json:"current_day"`
CompletedDays int `gorm:"column:completed_days;default:0;comment:已完成天数" json:"completed_days"`
// AI 生成信息
PromptVersion string `gorm:"column:prompt_version;size:30;default:v1;comment:提示词版本" json:"prompt_version"`
Provider string `gorm:"column:provider;size:30;comment:AI提供方" json:"provider,omitempty"`
Model string `gorm:"column:model;size:60;comment:模型名" json:"model,omitempty"`
// Token 消耗
TokensIn *int `gorm:"column:tokens_in;comment:输入tokens" json:"tokens_in,omitempty"`
TokensOut *int `gorm:"column:tokens_out;comment:输出tokens" json:"tokens_out,omitempty"`
CostCent *int `gorm:"column:cost_cent;comment:成本(分)" json:"cost_cent,omitempty"`
// 摘要(AI 生成的 30 天计划概述)
Summary string `gorm:"column:summary;type:mediumtext;comment:计划摘要" json:"summary,omitempty"`
// 时间戳(秒级,与旧系统保持一致)
CreateTime *int64 `gorm:"column:createtime;comment:创建时间(秒)" json:"createtime,omitempty"`
UpdateTime *int64 `gorm:"column:updatetime;comment:更新时间(秒)" json:"updatetime,omitempty"`
DeleteTime *int64 `gorm:"column:deletetime;comment:删除时间(秒)" json:"deletetime,omitempty"`
}
func (SmokeQuitPlan) TableName() string {
return "fa_smoke_quit_plan"
}
func (SmokeQuitPlan) TableComment() string {
return "戒烟计划主表"
}
// SmokeQuitPlanDay 对应表 fa_smoke_quit_plan_day(每日计划明细)。
type SmokeQuitPlanDay struct {
ID uint `gorm:"primaryKey;autoIncrement;comment:记录ID" json:"id"`
CreatedAt time.Time `gorm:"comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"comment:更新时间" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"-"`
PlanID uint `gorm:"uniqueIndex:idx_quit_plan_day_plan_day;index:idx_quit_plan_day_uid;comment:计划ID" json:"plan_id"`
UID int `gorm:"uniqueIndex:idx_quit_plan_day_plan_day;index:idx_quit_plan_day_uid;comment:用户ID" json:"-"`
// 计划日期
PlanDate time.Time `gorm:"column:plan_date;type:date;uniqueIndex:idx_quit_plan_day_plan_day;comment:计划日期" json:"plan_date"`
// 阶段
Stage QuitPlanStage `gorm:"column:stage;size:20;comment:所属阶段" json:"stage"`
Day int `gorm:"column:day;comment:计划第几天(1-30)" json:"day"`
// 目标
TargetCigs int `gorm:"column:target_cigs;comment:目标吸烟量" json:"target_cigs"`
TargetReduced bool `gorm:"column:target_reduced;default:false;comment:是否比昨天减少" json:"target_reduced"`
// 建议内容(AI 生成)
Advice string `gorm:"column:advice;type:mediumtext;comment:每日建议" json:"advice,omitempty"`
// 实际执行结果
ActualCigs *int `gorm:"column:actual_cigs;comment:实际吸烟量" json:"actual_cigs,omitempty"`
ResistedCnt *int `gorm:"column:resisted_cnt;comment:忍住的次数" json:"resisted_cnt,omitempty"`
Achieved *bool `gorm:"column:achieved;comment:是否达成目标" json:"achieved,omitempty"`
CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at,omitempty"`
// 时间戳(秒级)
CreateTime *int64 `gorm:"column:createtime;comment:创建时间(秒)" json:"createtime,omitempty"`
UpdateTime *int64 `gorm:"column:updatetime;comment:更新时间(秒)" json:"updatetime,omitempty"`
DeleteTime *int64 `gorm:"column:deletetime;comment:删除时间(秒)" json:"deletetime,omitempty"`
}
func (SmokeQuitPlanDay) TableName() string {
return "fa_smoke_quit_plan_day"
}
func (SmokeQuitPlanDay) TableComment() string {
return "戒烟计划每日明细"
}
@@ -0,0 +1,553 @@
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]
}