package handler import ( "errors" "net/http" "strconv" "strings" "time" "github.com/gin-gonic/gin" "wx_service/internal/middleware" "wx_service/internal/model" quitservice "wx_service/internal/quitcheckin/service" ) const dateLayout = "2006-01-02" type Handler struct { service *quitservice.Service } // NewHandler 创建无烟打卡 V2 接口处理器。 func NewHandler(service *quitservice.Service) *Handler { return &Handler{service: service} } type upsertProfileRequest struct { QuitStartDate *string `json:"quit_start_date"` PackPriceCent *int `json:"pack_price_cent"` BaselineCigsPerDay *int `json:"baseline_cigs_per_day"` Motivation *string `json:"motivation"` NotifyTime *string `json:"notify_time"` } // GetProfile 获取当前用户的 V2 基础资料与旅程摘要。 // @Summary 获取无烟打卡资料 // @Description 返回用户基础资料与当前旅程概览,用于“我的”页面首屏展示。 // @Tags QuitCheckin // @Produce json // @Success 200 {object} model.Response // @Failure 500 {object} model.Response // @Router /api/v2/profile [get] func (h *Handler) GetProfile(c *gin.Context) { user := middleware.MustCurrentUser(c) view, err := h.service.GetProfile(c.Request.Context(), int(user.ID), user.NickName, user.AvatarURL, time.Now()) if err != nil { c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取资料失败,请稍后重试")) return } c.JSON(http.StatusOK, model.Success(view)) } // UpsertProfile 新增或更新无烟打卡资料。 // @Summary 保存无烟打卡资料 // @Description 保存戒烟开始日期、戒烟前日均支数、每包单价、初心和提醒时间。 // @Tags QuitCheckin // @Accept json // @Produce json // @Param body body upsertProfileRequest true "无烟打卡资料" // @Success 200 {object} model.Response // @Failure 400 {object} model.Response // @Failure 500 {object} model.Response // @Router /api/v2/profile [post] func (h *Handler) UpsertProfile(c *gin.Context) { user := middleware.MustCurrentUser(c) var req upsertProfileRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请求参数错误")) return } var quitStartDate *time.Time if req.QuitStartDate != nil { parsed, err := time.ParseInLocation(dateLayout, strings.TrimSpace(*req.QuitStartDate), time.Local) if err != nil { c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "quit_start_date 格式错误,应为 YYYY-MM-DD")) return } quitStartDate = &parsed } if req.BaselineCigsPerDay != nil && (*req.BaselineCigsPerDay <= 0 || *req.BaselineCigsPerDay > 200) { c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "baseline_cigs_per_day 应在 1~200")) return } if req.PackPriceCent != nil && (*req.PackPriceCent < 0 || *req.PackPriceCent > 999999) { c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "pack_price_cent 应在 0~999999")) return } if req.NotifyTime != nil && !quitservice.ParseNotifyTime(*req.NotifyTime) { c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "notify_time 格式错误,应为 HH:MM")) return } view, err := h.service.UpsertProfile(c.Request.Context(), int(user.ID), quitservice.UpsertProfileRequest{ QuitStartDate: quitStartDate, PackPriceCent: req.PackPriceCent, BaselineCigsPerDay: req.BaselineCigsPerDay, Motivation: req.Motivation, NotifyTime: req.NotifyTime, }, user.NickName, user.AvatarURL, time.Now()) if err != nil { c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "保存资料失败,请稍后重试")) return } c.JSON(http.StatusOK, model.Success(view)) } // Home 获取首页聚合数据。 // @Summary 获取首页数据 // @Description 返回今日状态、连续天数、成果摘要、梦想目标和勋章计数。 // @Tags QuitCheckin // @Produce json // @Success 200 {object} model.Response // @Failure 400 {object} model.Response // @Failure 500 {object} model.Response // @Router /api/v2/checkin/home [get] func (h *Handler) Home(c *gin.Context) { user := middleware.MustCurrentUser(c) view, err := h.service.Home(c.Request.Context(), int(user.ID), time.Now()) if err != nil { if errors.Is(err, quitservice.ErrProfileNotFound) { c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请先完善基础资料")) return } c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取首页数据失败,请稍后重试")) return } c.JSON(http.StatusOK, model.Success(view)) } type checkinRequest struct { Date string `json:"date"` Note string `json:"note"` } // Checkin 执行当日打卡。 // @Summary 今日打卡 // @Description 同一自然日只允许一次成功打卡;若当日已复吸则不允许再次打卡。 // @Tags QuitCheckin // @Accept json // @Produce json // @Param body body checkinRequest true "打卡请求" // @Success 200 {object} model.Response // @Failure 400 {object} model.Response // @Failure 409 {object} model.Response // @Failure 500 {object} model.Response // @Router /api/v2/checkin/check [post] func (h *Handler) Checkin(c *gin.Context) { user := middleware.MustCurrentUser(c) var req checkinRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请求参数错误")) return } targetDate := time.Now() if strings.TrimSpace(req.Date) != "" { parsed, err := time.ParseInLocation(dateLayout, strings.TrimSpace(req.Date), time.Local) if err != nil { c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "date 格式错误,应为 YYYY-MM-DD")) return } targetDate = parsed } result, err := h.service.Checkin(c.Request.Context(), int(user.ID), quitservice.CheckinRequest{ Date: targetDate, Note: req.Note, }, time.Now()) if err != nil { if errors.Is(err, quitservice.ErrProfileNotFound) { c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请先完善基础资料")) return } if errors.Is(err, quitservice.ErrAlreadyRelapsed) { c.JSON(http.StatusConflict, model.Error(http.StatusConflict, "今天已记录复吸,无法再次打卡")) return } c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "打卡失败,请稍后重试")) return } c.JSON(http.StatusOK, model.Success(result)) } type relapseRequest struct { Date string `json:"date"` RelapseAt string `json:"relapse_at"` RelapseNum int `json:"relapse_num"` Reason string `json:"reason"` Note string `json:"note"` } // Relapse 记录当日复吸。 // @Summary 记录复吸 // @Description 当日复吸会将连续天数清零,并更新当日状态为 relapsed。 // @Tags QuitCheckin // @Accept json // @Produce json // @Param body body relapseRequest true "复吸请求" // @Success 200 {object} model.Response // @Failure 400 {object} model.Response // @Failure 409 {object} model.Response // @Failure 500 {object} model.Response // @Router /api/v2/checkin/relapse [post] func (h *Handler) Relapse(c *gin.Context) { user := middleware.MustCurrentUser(c) var req relapseRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请求参数错误")) return } if req.RelapseNum <= 0 || req.RelapseNum > 200 { c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "relapse_num 应在 1~200")) return } targetDate := time.Now() if strings.TrimSpace(req.Date) != "" { parsed, err := time.ParseInLocation(dateLayout, strings.TrimSpace(req.Date), time.Local) if err != nil { c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "date 格式错误,应为 YYYY-MM-DD")) return } targetDate = parsed } relapseAt := time.Now() if strings.TrimSpace(req.RelapseAt) != "" { parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(req.RelapseAt)) if err != nil { c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "relapse_at 格式错误,应为 RFC3339")) return } relapseAt = parsed.In(time.Local) } result, err := h.service.Relapse(c.Request.Context(), int(user.ID), quitservice.RelapseRequest{ Date: targetDate, RelapseAt: relapseAt, RelapseNum: req.RelapseNum, Reason: req.Reason, Note: req.Note, }, time.Now()) if err != nil { if errors.Is(err, quitservice.ErrProfileNotFound) { c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请先完善基础资料")) return } if errors.Is(err, quitservice.ErrAlreadyRelapsed) { c.JSON(http.StatusConflict, model.Error(http.StatusConflict, "今天已记录复吸,请勿重复提交")) return } c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "记录复吸失败,请稍后重试")) return } c.JSON(http.StatusOK, model.Success(result)) } // StatsOverview 获取统计概览。 // @Summary 获取统计概览 // @Description 支持周、月、年三个维度,返回趋势、勋章和健康恢复摘要。 // @Tags QuitCheckin // @Produce json // @Param range query string false "统计范围:week|month|year" // @Success 200 {object} model.Response // @Failure 400 {object} model.Response // @Failure 500 {object} model.Response // @Router /api/v2/stats/overview [get] func (h *Handler) StatsOverview(c *gin.Context) { user := middleware.MustCurrentUser(c) result, err := h.service.StatsOverview(c.Request.Context(), int(user.ID), c.DefaultQuery("range", "week"), time.Now()) if err != nil { if errors.Is(err, quitservice.ErrProfileNotFound) { c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请先完善基础资料")) return } if errors.Is(err, quitservice.ErrInvalidRange) { c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "range 应为 week|month|year")) return } c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取统计失败,请稍后重试")) return } c.JSON(http.StatusOK, model.Success(result)) } // ListBadges 获取勋章列表。 // @Summary 获取勋章列表 // @Description 返回当前用户的已解锁/未解锁勋章清单。 // @Tags QuitCheckin // @Produce json // @Success 200 {object} model.Response // @Failure 500 {object} model.Response // @Router /api/v2/badges [get] func (h *Handler) ListBadges(c *gin.Context) { user := middleware.MustCurrentUser(c) result, err := h.service.ListBadges(c.Request.Context(), int(user.ID), time.Now()) if err != nil { c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取勋章失败,请稍后重试")) return } c.JSON(http.StatusOK, model.Success(result)) } // ListRelapses 获取复吸历史列表。 // @Summary 获取复吸历史 // @Description 分页返回复吸记录,用于“统计与荣誉”页历史列表展示。 // @Tags QuitCheckin // @Produce json // @Param page query int false "页码" // @Param page_size query int false "每页数量" // @Success 200 {object} model.Response // @Failure 500 {object} model.Response // @Router /api/v2/relapses [get] func (h *Handler) ListRelapses(c *gin.Context) { user := middleware.MustCurrentUser(c) page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) result, err := h.service.ListRelapses(c.Request.Context(), int(user.ID), page, pageSize) if err != nil { c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取复吸记录失败,请稍后重试")) return } c.JSON(http.StatusOK, model.Success(result)) } // ListRewardGoals 获取梦想目标列表。 // @Summary 获取梦想目标列表 // @Description 按状态筛选梦想目标,用于梦想实验室页面展示。 // @Tags QuitCheckin // @Produce json // @Param status query string false "状态:active|completed|archived|all" // @Success 200 {object} model.Response // @Failure 500 {object} model.Response // @Router /api/v2/reward-goals [get] func (h *Handler) ListRewardGoals(c *gin.Context) { user := middleware.MustCurrentUser(c) result, err := h.service.ListRewardGoals(c.Request.Context(), int(user.ID), c.DefaultQuery("status", "active"), time.Now()) if err != nil { c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取梦想目标失败,请稍后重试")) return } c.JSON(http.StatusOK, model.Success(result)) } type createRewardGoalRequest struct { Title string `json:"title"` TargetAmountCent int `json:"target_amount_cent"` CoverImage string `json:"cover_image"` } // CreateRewardGoal 创建梦想目标。 // @Summary 创建梦想目标 // @Description 创建一条新的财富奖励目标。 // @Tags QuitCheckin // @Accept json // @Produce json // @Param body body createRewardGoalRequest true "梦想目标" // @Success 200 {object} model.Response // @Failure 400 {object} model.Response // @Failure 500 {object} model.Response // @Router /api/v2/reward-goals [post] func (h *Handler) CreateRewardGoal(c *gin.Context) { user := middleware.MustCurrentUser(c) var req createRewardGoalRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请求参数错误")) return } if strings.TrimSpace(req.Title) == "" { c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "title 不能为空")) return } if req.TargetAmountCent <= 0 { c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "target_amount_cent 必须大于 0")) return } result, err := h.service.CreateRewardGoal(c.Request.Context(), int(user.ID), quitservice.CreateRewardGoalRequest{ Title: req.Title, TargetAmountCent: req.TargetAmountCent, CoverImage: req.CoverImage, }, time.Now()) if err != nil { c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "创建梦想目标失败,请稍后重试")) return } c.JSON(http.StatusOK, model.Success(result)) } type updateRewardGoalRequest struct { Title *string `json:"title"` TargetAmountCent *int `json:"target_amount_cent"` CoverImage *string `json:"cover_image"` Status *string `json:"status"` } // UpdateRewardGoal 更新梦想目标。 // @Summary 更新梦想目标 // @Description 支持更新标题、目标金额、封面和状态。 // @Tags QuitCheckin // @Accept json // @Produce json // @Param id path int true "目标 ID" // @Param body body updateRewardGoalRequest true "更新内容" // @Success 200 {object} model.Response // @Failure 400 {object} model.Response // @Failure 404 {object} model.Response // @Failure 500 {object} model.Response // @Router /api/v2/reward-goals/{id} [put] func (h *Handler) UpdateRewardGoal(c *gin.Context) { user := middleware.MustCurrentUser(c) id, err := strconv.Atoi(c.Param("id")) if err != nil || id <= 0 { c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "id 参数错误")) return } var req updateRewardGoalRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请求参数错误")) return } result, err := h.service.UpdateRewardGoal(c.Request.Context(), int(user.ID), id, quitservice.UpdateRewardGoalRequest{ Title: req.Title, TargetAmountCent: req.TargetAmountCent, CoverImage: req.CoverImage, Status: req.Status, }, time.Now()) if err != nil { if errors.Is(err, quitservice.ErrRewardGoalNotFound) { c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "梦想目标不存在")) return } c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "更新梦想目标失败,请稍后重试")) return } c.JSON(http.StatusOK, model.Success(result)) } // PosterData 获取海报预览数据。 // @Summary 获取海报数据 // @Description 返回海报预览所需的连续天数、已省金额和健康恢复信息。 // @Tags QuitCheckin // @Produce json // @Param template_code query string false "模板编码" // @Success 200 {object} model.Response // @Failure 400 {object} model.Response // @Failure 500 {object} model.Response // @Router /api/v2/poster/data [get] func (h *Handler) PosterData(c *gin.Context) { user := middleware.MustCurrentUser(c) result, err := h.service.PosterData(c.Request.Context(), int(user.ID), user.NickName, c.DefaultQuery("template_code", "vibrant_1"), time.Now()) if err != nil { if errors.Is(err, quitservice.ErrProfileNotFound) { c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请先完善基础资料")) return } c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取海报数据失败,请稍后重试")) return } c.JSON(http.StatusOK, model.Success(result)) } type generatePosterRequest struct { TemplateCode string `json:"template_code"` ShowFields []string `json:"show_fields"` } // GeneratePoster 生成海报结果。 // @Summary 生成海报 // @Description 当前先返回可用的海报元数据,后续可替换为真实图片生成链路。 // @Tags QuitCheckin // @Accept json // @Produce json // @Param body body generatePosterRequest true "海报生成请求" // @Success 200 {object} model.Response // @Failure 400 {object} model.Response // @Failure 500 {object} model.Response // @Router /api/v2/poster/generate [post] func (h *Handler) GeneratePoster(c *gin.Context) { user := middleware.MustCurrentUser(c) var req generatePosterRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请求参数错误")) return } result, err := h.service.GeneratePoster(c.Request.Context(), int(user.ID), user.NickName, quitservice.PosterGenerateRequest{ TemplateCode: req.TemplateCode, ShowFields: req.ShowFields, }, time.Now()) if err != nil { if errors.Is(err, quitservice.ErrProfileNotFound) { c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请先完善基础资料")) return } c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "海报生成失败,请稍后重试")) return } c.JSON(http.StatusOK, model.Success(result)) }