From 93bcc6c78786b2e846baec4c5a001c2873df2892 Mon Sep 17 00:00:00 2001 From: hello-dd-code Date: Fri, 13 Mar 2026 14:58:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(smoke):=20=E6=B7=BB=E5=8A=A0=E4=B8=AA?= =?UTF-8?q?=E6=80=A7=E5=8C=96=E6=88=92=E7=83=9F=E8=AE=A1=E5=88=92=E7=94=9F?= =?UTF-8?q?=E6=88=90=E5=8A=9F=E8=83=BD=20(Issue=20#46)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Model 层: SmokeQuitPlan, SmokeQuitPlanDay 结构体 - 新增 Service 层: GenerateQuitPlan, GetActivePlan, GetPlanDays, ResetPlan - 新增 Handler 层: POST /generate, GET /, GET /days, POST /reset - 集成 AI 生成 30 天个性化戒烟减量方案 - 支持重置计划功能 --- cmd/api/main.go | 5 + internal/routes/routes.go | 3 +- internal/routes/smoke_routes.go | 10 +- .../smoke/handler/smoke_quit_plan_handler.go | 259 ++++++++ internal/smoke/model/smoke_quit_plan.go | 121 ++++ .../smoke/service/smoke_quit_plan_service.go | 553 ++++++++++++++++++ 6 files changed, 948 insertions(+), 3 deletions(-) create mode 100644 internal/smoke/handler/smoke_quit_plan_handler.go create mode 100644 internal/smoke/model/smoke_quit_plan.go create mode 100644 internal/smoke/service/smoke_quit_plan_service.go diff --git a/cmd/api/main.go b/cmd/api/main.go index 3bf0895..c03985a 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -74,6 +74,8 @@ func main() { &smokemodel.SmokeAINextSmoke{}, &smokemodel.SmokeMotivationQuote{}, &smokemodel.SmokeShare{}, + &smokemodel.SmokeQuitPlan{}, + &smokemodel.SmokeQuitPlanDay{}, &marketingmodel.MarketingCategory{}, &marketingmodel.MarketingTemplate{}, &marketingmodel.MarketingDownload{}, @@ -105,7 +107,9 @@ func main() { smokeNextService := smokeservice.NewSmokeNextService(database.DB) smokeAINextService := smokeservice.NewSmokeAINextSmokeService(database.DB, config.AppConfig.AI) smokeShareService := smokeservice.NewSmokeShareService(database.DB) + smokeQuitPlanService := smokeservice.NewSmokeQuitPlanService(database.DB, config.AppConfig.AI) smokeHandler := smokehandler.NewSmokeHandler(smokeLogService, smokeAIAdviceService, smokeProfileService, smokeNextService, smokeAINextService, smokeShareService) + quitPlanHandler := smokehandler.NewQuitPlanHandler(smokeQuitPlanService) redeemCodeService := membershipservice.NewRedeemCodeService(database.DB, config.AppConfig.Admin.Token) redeemCodeHandler := membershiphandler.NewRedeemCodeHandler(redeemCodeService) @@ -169,6 +173,7 @@ func main() { authHandler, videoHandler, smokeHandler, + quitPlanHandler, redeemCodeHandler, uploadHandler, oaOAuthHandler, diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 4ef9b97..5a597e7 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -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) diff --git a/internal/routes/smoke_routes.go b/internal/routes/smoke_routes.go index 75c641a..e3c31aa 100644 --- a/internal/routes/smoke_routes.go +++ b/internal/routes/smoke_routes.go @@ -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) } } diff --git a/internal/smoke/handler/smoke_quit_plan_handler.go b/internal/smoke/handler/smoke_quit_plan_handler.go new file mode 100644 index 0000000..84f47b1 --- /dev/null +++ b/internal/smoke/handler/smoke_quit_plan_handler.go @@ -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, + })) +} diff --git a/internal/smoke/model/smoke_quit_plan.go b/internal/smoke/model/smoke_quit_plan.go new file mode 100644 index 0000000..1f13437 --- /dev/null +++ b/internal/smoke/model/smoke_quit_plan.go @@ -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 "戒烟计划每日明细" +} diff --git a/internal/smoke/service/smoke_quit_plan_service.go b/internal/smoke/service/smoke_quit_plan_service.go new file mode 100644 index 0000000..e0d1e7e --- /dev/null +++ b/internal/smoke/service/smoke_quit_plan_service.go @@ -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] +}