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
@@ -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,
}))
}