diff --git a/cmd/api/main.go b/cmd/api/main.go index c03985a..cc5917d 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -30,6 +30,9 @@ import ( membershipservice "wx_service/internal/membership/service" "wx_service/internal/model" "wx_service/internal/observability" + quitcheckinhandler "wx_service/internal/quitcheckin/handler" + quitcheckinmodel "wx_service/internal/quitcheckin/model" + quitcheckinservice "wx_service/internal/quitcheckin/service" rmhandler "wx_service/internal/remove_watermark/handler" rmmodel "wx_service/internal/remove_watermark/model" rmservice "wx_service/internal/remove_watermark/service" @@ -79,6 +82,10 @@ func main() { &marketingmodel.MarketingCategory{}, &marketingmodel.MarketingTemplate{}, &marketingmodel.MarketingDownload{}, + &quitcheckinmodel.Profile{}, + &quitcheckinmodel.DailyStatus{}, + &quitcheckinmodel.RelapseEvent{}, + &quitcheckinmodel.RewardGoal{}, ); err != nil { log.Fatalf("auto migrate failed: %v", err) } @@ -110,6 +117,8 @@ func main() { smokeQuitPlanService := smokeservice.NewSmokeQuitPlanService(database.DB, config.AppConfig.AI) smokeHandler := smokehandler.NewSmokeHandler(smokeLogService, smokeAIAdviceService, smokeProfileService, smokeNextService, smokeAINextService, smokeShareService) quitPlanHandler := smokehandler.NewQuitPlanHandler(smokeQuitPlanService) + quitCheckinService := quitcheckinservice.NewService(database.DB) + quitCheckinHandler := quitcheckinhandler.NewHandler(quitCheckinService) redeemCodeService := membershipservice.NewRedeemCodeService(database.DB, config.AppConfig.Admin.Token) redeemCodeHandler := membershiphandler.NewRedeemCodeHandler(redeemCodeService) @@ -185,6 +194,7 @@ func main() { marketingCategoryHandler, marketingTemplateHandler, marketingDownloadHandler, + quitCheckinHandler, ) // 7) 启动监听端口 diff --git a/docs/quitcheckin/README.md b/docs/quitcheckin/README.md new file mode 100644 index 0000000..803ec37 --- /dev/null +++ b/docs/quitcheckin/README.md @@ -0,0 +1,11 @@ +# 无烟打卡 V2 文档 + +## 文档清单 + +- `swagger.yaml`:OpenAPI 3.0 接口文档 + +## 说明 + +- 接口前缀:`/api/v2` +- 鉴权方式:`Authorization: Bearer ` +- 业务语义:无烟打卡、复吸、梦想目标、统计与海报 diff --git a/docs/quitcheckin/swagger.yaml b/docs/quitcheckin/swagger.yaml new file mode 100644 index 0000000..8f19ccb --- /dev/null +++ b/docs/quitcheckin/swagger.yaml @@ -0,0 +1,331 @@ +openapi: 3.0.3 +info: + title: 无烟打卡 V2 API + version: 0.1.0 + description: 戒烟小程序 V2 后端接口文档,覆盖打卡、复吸、梦想目标、统计、海报和资料管理。 +servers: + - url: https://wx.nepiedg.top + description: 生产环境 + - url: http://127.0.0.1:8080 + description: 本地开发环境 +tags: + - name: QuitCheckin + description: 无烟打卡 V2 接口 +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: session_key + schemas: + Response: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: success + data: + nullable: true + UpsertProfileRequest: + type: object + properties: + quit_start_date: + type: string + format: date + pack_price_cent: + type: integer + baseline_cigs_per_day: + type: integer + motivation: + type: string + notify_time: + type: string + example: "21:00" + CheckinRequest: + type: object + properties: + date: + type: string + format: date + note: + type: string + RelapseRequest: + type: object + required: [relapse_num] + properties: + date: + type: string + format: date + relapse_at: + type: string + format: date-time + relapse_num: + type: integer + minimum: 1 + maximum: 200 + reason: + type: string + note: + type: string + RewardGoalCreateRequest: + type: object + required: [title, target_amount_cent] + properties: + title: + type: string + target_amount_cent: + type: integer + cover_image: + type: string + RewardGoalUpdateRequest: + type: object + properties: + title: + type: string + target_amount_cent: + type: integer + cover_image: + type: string + status: + type: string + enum: [active, completed, archived] + PosterGenerateRequest: + type: object + properties: + template_code: + type: string + show_fields: + type: array + items: + type: string + enum: [streak_days, saved_money_cent, avoided_cigs, health_recovery_percent] +paths: + /api/v2/profile: + get: + tags: [QuitCheckin] + summary: 获取无烟打卡资料 + security: [{ bearerAuth: [] }] + responses: + "200": + description: 成功 + content: + application/json: + schema: + $ref: "#/components/schemas/Response" + post: + tags: [QuitCheckin] + summary: 保存无烟打卡资料 + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpsertProfileRequest" + responses: + "200": + description: 成功 + content: + application/json: + schema: + $ref: "#/components/schemas/Response" + /api/v2/checkin/home: + get: + tags: [QuitCheckin] + summary: 获取首页数据 + security: [{ bearerAuth: [] }] + responses: + "200": + description: 成功 + content: + application/json: + schema: + $ref: "#/components/schemas/Response" + /api/v2/checkin/check: + post: + tags: [QuitCheckin] + summary: 今日打卡 + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CheckinRequest" + responses: + "200": + description: 成功 + content: + application/json: + schema: + $ref: "#/components/schemas/Response" + "409": + description: 当日已复吸,不允许再打卡 + /api/v2/checkin/relapse: + post: + tags: [QuitCheckin] + summary: 记录复吸 + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RelapseRequest" + responses: + "200": + description: 成功 + content: + application/json: + schema: + $ref: "#/components/schemas/Response" + "409": + description: 当日已是复吸状态 + /api/v2/stats/overview: + get: + tags: [QuitCheckin] + summary: 获取统计概览 + security: [{ bearerAuth: [] }] + parameters: + - in: query + name: range + schema: + type: string + enum: [week, month, year] + responses: + "200": + description: 成功 + content: + application/json: + schema: + $ref: "#/components/schemas/Response" + /api/v2/badges: + get: + tags: [QuitCheckin] + summary: 获取勋章列表 + security: [{ bearerAuth: [] }] + responses: + "200": + description: 成功 + content: + application/json: + schema: + $ref: "#/components/schemas/Response" + /api/v2/relapses: + get: + tags: [QuitCheckin] + summary: 获取复吸历史 + security: [{ bearerAuth: [] }] + parameters: + - in: query + name: page + schema: + type: integer + - in: query + name: page_size + schema: + type: integer + responses: + "200": + description: 成功 + content: + application/json: + schema: + $ref: "#/components/schemas/Response" + /api/v2/reward-goals: + get: + tags: [QuitCheckin] + summary: 获取梦想目标列表 + security: [{ bearerAuth: [] }] + parameters: + - in: query + name: status + schema: + type: string + enum: [active, completed, archived, all] + responses: + "200": + description: 成功 + content: + application/json: + schema: + $ref: "#/components/schemas/Response" + post: + tags: [QuitCheckin] + summary: 创建梦想目标 + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RewardGoalCreateRequest" + responses: + "200": + description: 成功 + content: + application/json: + schema: + $ref: "#/components/schemas/Response" + /api/v2/reward-goals/{id}: + put: + tags: [QuitCheckin] + summary: 更新梦想目标 + security: [{ bearerAuth: [] }] + parameters: + - in: path + name: id + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RewardGoalUpdateRequest" + responses: + "200": + description: 成功 + content: + application/json: + schema: + $ref: "#/components/schemas/Response" + /api/v2/poster/data: + get: + tags: [QuitCheckin] + summary: 获取海报数据 + security: [{ bearerAuth: [] }] + parameters: + - in: query + name: template_code + schema: + type: string + responses: + "200": + description: 成功 + content: + application/json: + schema: + $ref: "#/components/schemas/Response" + /api/v2/poster/generate: + post: + tags: [QuitCheckin] + summary: 生成海报 + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PosterGenerateRequest" + responses: + "200": + description: 成功 + content: + application/json: + schema: + $ref: "#/components/schemas/Response" diff --git a/internal/quitcheckin/handler/handler.go b/internal/quitcheckin/handler/handler.go new file mode 100644 index 0000000..857eed4 --- /dev/null +++ b/internal/quitcheckin/handler/handler.go @@ -0,0 +1,510 @@ +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)) +} diff --git a/internal/quitcheckin/model/daily_status.go b/internal/quitcheckin/model/daily_status.go new file mode 100644 index 0000000..c1c3eb5 --- /dev/null +++ b/internal/quitcheckin/model/daily_status.go @@ -0,0 +1,43 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +// 每日状态枚举值。 +const ( + DailyStatusPending = "pending" + DailyStatusCheckedIn = "checked_in" + DailyStatusRelapsed = "relapsed" + DailyStatusMissed = "missed" +) + +// DailyStatus 表示用户在某一自然日的打卡或复吸状态。 +type DailyStatus struct { + ID uint `gorm:"primaryKey;comment:主键" 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_checkin_uid_date;comment:用户ID" json:"-"` + + Date time.Time `gorm:"uniqueIndex:idx_quit_checkin_uid_date;type:date;comment:自然日" json:"date"` + Status string `gorm:"size:32;index;comment:状态" json:"status"` + CheckInAt *time.Time `gorm:"column:check_in_at;comment:打卡时间" json:"check_in_at,omitempty"` + RelapsedAt *time.Time `gorm:"column:relapsed_at;comment:复吸时间" json:"relapsed_at,omitempty"` + RelapseNum int `gorm:"column:relapse_num;comment:复吸支数" json:"relapse_num"` + Reason string `gorm:"column:reason;size:64;comment:复吸原因" json:"reason,omitempty"` + Note string `gorm:"column:note;size:200;comment:备注" json:"note,omitempty"` +} + +// TableName 返回每日状态表名。 +func (DailyStatus) TableName() string { + return "fa_quit_checkin_daily_status" +} + +// TableComment 返回每日状态表注释。 +func (DailyStatus) TableComment() string { + return "V2-无烟打卡-每日状态" +} diff --git a/internal/quitcheckin/model/profile.go b/internal/quitcheckin/model/profile.go new file mode 100644 index 0000000..a422f5f --- /dev/null +++ b/internal/quitcheckin/model/profile.go @@ -0,0 +1,34 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +// Profile 表示无烟打卡的基础资料配置。 +type Profile struct { + ID uint `gorm:"primaryKey;comment:主键" 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;comment:用户ID" json:"-"` + + QuitStartDate time.Time `gorm:"column:quit_start_date;type:date;comment:戒烟开始日期" json:"quit_start_date"` + PackPriceCent int `gorm:"column:pack_price_cent;comment:每包价格(分)" json:"pack_price_cent"` + BaselineCigsPerDay int `gorm:"column:baseline_cigs_per_day;comment:戒烟前日均支数" json:"baseline_cigs_per_day"` + Motivation string `gorm:"column:motivation;size:200;comment:戒烟初心" json:"motivation"` + NotifyTime string `gorm:"column:notify_time;size:5;comment:提醒时间(HH:MM)" json:"notify_time"` + ResetRule string `gorm:"column:reset_rule;size:64;comment:连续天数重置规则" json:"reset_rule"` +} + +// TableName 返回用户资料表名。 +func (Profile) TableName() string { + return "fa_quit_checkin_profile" +} + +// TableComment 返回用户资料表注释。 +func (Profile) TableComment() string { + return "V2-无烟打卡-用户资料" +} diff --git a/internal/quitcheckin/model/relapse_event.go b/internal/quitcheckin/model/relapse_event.go new file mode 100644 index 0000000..780d734 --- /dev/null +++ b/internal/quitcheckin/model/relapse_event.go @@ -0,0 +1,34 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +// RelapseEvent 表示一次复吸事件明细。 +type RelapseEvent struct { + ID uint `gorm:"primaryKey;comment:主键" 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:"index;comment:用户ID" json:"-"` + + Date time.Time `gorm:"type:date;index;comment:所属自然日" json:"date"` + RelapseAt time.Time `gorm:"column:relapse_at;comment:复吸时间" json:"relapse_at"` + RelapseNum int `gorm:"column:relapse_num;comment:复吸支数" json:"relapse_num"` + Reason string `gorm:"column:reason;size:64;comment:复吸原因" json:"reason,omitempty"` + Note string `gorm:"column:note;size:200;comment:备注" json:"note,omitempty"` + AffectStreak bool `gorm:"column:affect_streak;comment:是否影响连续天数" json:"affect_streak"` +} + +// TableName 返回复吸事件表名。 +func (RelapseEvent) TableName() string { + return "fa_quit_checkin_relapse_event" +} + +// TableComment 返回复吸事件表注释。 +func (RelapseEvent) TableComment() string { + return "V2-无烟打卡-复吸事件" +} diff --git a/internal/quitcheckin/model/reward_goal.go b/internal/quitcheckin/model/reward_goal.go new file mode 100644 index 0000000..2952c90 --- /dev/null +++ b/internal/quitcheckin/model/reward_goal.go @@ -0,0 +1,40 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +// 梦想目标状态枚举值。 +const ( + RewardGoalStatusActive = "active" + RewardGoalStatusCompleted = "completed" + RewardGoalStatusArchived = "archived" +) + +// RewardGoal 表示用户的梦想奖励目标。 +type RewardGoal struct { + ID uint `gorm:"primaryKey;comment:主键" 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:"index;comment:用户ID" json:"-"` + + Title string `gorm:"column:title;size:64;comment:目标名称" json:"title"` + TargetAmountCent int `gorm:"column:target_amount_cent;comment:目标金额(分)" json:"target_amount_cent"` + CoverImage string `gorm:"column:cover_image;size:500;comment:封面图" json:"cover_image,omitempty"` + Status string `gorm:"column:status;size:32;index;comment:状态" json:"status"` + CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at,omitempty"` +} + +// TableName 返回梦想目标表名。 +func (RewardGoal) TableName() string { + return "fa_quit_checkin_reward_goal" +} + +// TableComment 返回梦想目标表注释。 +func (RewardGoal) TableComment() string { + return "V2-无烟打卡-梦想目标" +} diff --git a/internal/quitcheckin/service/service.go b/internal/quitcheckin/service/service.go new file mode 100644 index 0000000..136f2f6 --- /dev/null +++ b/internal/quitcheckin/service/service.go @@ -0,0 +1,1156 @@ +package service + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "time" + + quitmodel "wx_service/internal/quitcheckin/model" + + "gorm.io/gorm" +) + +var ( + // ErrProfileNotFound 表示用户尚未完成基础资料配置。 + ErrProfileNotFound = errors.New("未找到无烟打卡资料") + // ErrAlreadyRelapsed 表示当天已经记录过复吸,不能重复记录。 + ErrAlreadyRelapsed = errors.New("当天已记录复吸") + // ErrRewardGoalNotFound 表示指定梦想目标不存在。 + ErrRewardGoalNotFound = errors.New("梦想目标不存在") + // ErrInvalidRange 表示统计范围不在允许值内。 + ErrInvalidRange = errors.New("统计范围不合法") +) + +const ( + dateLayout = "2006-01-02" + timeOnlyLayout = "15:04" + resetRule = "relapse_clears_streak" +) + +var milestoneDays = []int{1, 3, 7, 14, 21, 30, 60, 90, 180, 365} + +type Service struct { + db *gorm.DB +} + +// NewService 创建无烟打卡 V2 服务。 +func NewService(db *gorm.DB) *Service { + return &Service{db: db} +} + +// ProfileView 表示“我的”页面所需的资料和旅程摘要。 +type ProfileView struct { + Profile *ProfileResult `json:"profile"` + Journey JourneyResult `json:"journey"` +} + +// ProfileResult 表示用户基础资料。 +type ProfileResult struct { + UserID int `json:"user_id"` + Nickname string `json:"nickname,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` + QuitStartDate string `json:"quit_start_date"` + PackPriceCent int `json:"pack_price_cent"` + BaselineCigsPerDay int `json:"baseline_cigs_per_day"` + Motivation string `json:"motivation,omitempty"` + NotifyTime string `json:"notify_time,omitempty"` + ResetRule string `json:"reset_rule"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// JourneyResult 表示当前戒烟旅程状态。 +type JourneyResult struct { + JourneyID uint `json:"journey_id"` + UserID int `json:"user_id"` + StartDate string `json:"start_date"` + CurrentStreakDays int `json:"current_streak_days"` + MaxStreakDays int `json:"max_streak_days"` + LastCheckinDate *string `json:"last_checkin_date"` + LastRelapseAt *string `json:"last_relapse_at"` + Status string `json:"status"` +} + +// UpsertProfileRequest 表示资料保存请求。 +type UpsertProfileRequest struct { + QuitStartDate *time.Time + PackPriceCent *int + BaselineCigsPerDay *int + Motivation *string + NotifyTime *string +} + +// DailyStatusResult 表示某一天的打卡状态。 +type DailyStatusResult struct { + Date string `json:"date"` + Status string `json:"status"` + CheckinAt *string `json:"checkin_at"` + RelapsedAt *string `json:"relapsed_at"` + RelapseNum *int `json:"relapse_num"` + Note *string `json:"note"` +} + +// SummaryResult 表示首页和统计页共用的汇总数据。 +type SummaryResult struct { + CurrentStreakDays int `json:"current_streak_days"` + MaxStreakDays int `json:"max_streak_days"` + MilestoneDays int `json:"milestone_days"` + DaysToNextMilestone int `json:"days_to_next_milestone"` + SavedMoneyCent int `json:"saved_money_cent"` + AvoidedCigs int `json:"avoided_cigs"` + AvoidedCigsMode string `json:"avoided_cigs_mode"` + HealthRecoveryPercent int `json:"health_recovery_percent"` +} + +// RewardGoalResult 表示梦想目标展示数据。 +type RewardGoalResult struct { + ID uint `json:"id"` + UserID int `json:"user_id"` + Title string `json:"title"` + TargetAmountCent int `json:"target_amount_cent"` + CurrentAmountCent int `json:"current_amount_cent"` + ProgressPercent int `json:"progress_percent"` + CoverImage string `json:"cover_image,omitempty"` + Status string `json:"status"` + CompletedAt *string `json:"completed_at"` + CreatedAt string `json:"created_at"` +} + +// HomeResult 表示首页聚合返回数据。 +type HomeResult struct { + DailyStatus DailyStatusResult `json:"daily_status"` + Summary SummaryResult `json:"summary"` + Goal *RewardGoalResult `json:"goal,omitempty"` + Badges map[string]int `json:"badges"` +} + +// CheckinRequest 表示打卡请求参数。 +type CheckinRequest struct { + Date time.Time + Note string +} + +// CheckinResult 表示打卡后的返回结果。 +type CheckinResult struct { + DailyStatus DailyStatusResult `json:"daily_status"` + Summary SummaryResult `json:"summary"` +} + +// RelapseRequest 表示复吸记录请求参数。 +type RelapseRequest struct { + Date time.Time + RelapseAt time.Time + RelapseNum int + Reason string + Note string +} + +// RelapseEventResult 表示复吸事件返回数据。 +type RelapseEventResult struct { + ID uint `json:"id"` + UserID int `json:"user_id"` + Date string `json:"date"` + RelapseAt string `json:"relapse_at"` + RelapseNum int `json:"relapse_num"` + Reason string `json:"reason,omitempty"` + Note string `json:"note,omitempty"` + AffectStreak bool `json:"affect_streak"` + CreatedAt string `json:"created_at"` +} + +// RelapseResult 表示记录复吸后的聚合结果。 +type RelapseResult struct { + DailyStatus DailyStatusResult `json:"daily_status"` + RelapseEvent RelapseEventResult `json:"relapse_event"` + Summary SummaryResult `json:"summary"` +} + +// StatsOverviewResult 表示统计页概览数据。 +type StatsOverviewResult struct { + Range string `json:"range"` + Summary SummaryResult `json:"summary"` + Trend []TrendItemResult `json:"trend"` + HealthMilestones []HealthMilestone `json:"health_milestones"` +} + +// TrendItemResult 表示趋势图中的单日数据点。 +type TrendItemResult struct { + Date string `json:"date"` + Status string `json:"status"` + RelapseNum int `json:"relapse_num"` +} + +// HealthMilestone 表示健康恢复里程碑。 +type HealthMilestone struct { + Code string `json:"code"` + Title string `json:"title"` + Percent int `json:"percent"` + Unlocked bool `json:"unlocked"` +} + +// BadgeResult 表示勋章展示数据。 +type BadgeResult struct { + ID int `json:"id"` + Code string `json:"code"` + Title string `json:"title"` + Description string `json:"description"` + Icon string `json:"icon,omitempty"` + UnlockRule string `json:"unlock_rule"` + Unlocked bool `json:"unlocked"` + UnlockedAt *string `json:"unlocked_at"` + ProgressPercent int `json:"progress_percent"` +} + +// BadgeListResult 表示勋章列表返回结果。 +type BadgeListResult struct { + Items []BadgeResult `json:"items"` + UnlockedCount int `json:"unlocked_count"` + TotalCount int `json:"total_count"` +} + +// RelapseListResult 表示复吸历史分页结果。 +type RelapseListResult struct { + Items []RelapseEventResult `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +// RewardGoalListResult 表示梦想目标列表结果。 +type RewardGoalListResult struct { + Items []RewardGoalResult `json:"items"` + Total int `json:"total"` +} + +// CreateRewardGoalRequest 表示创建梦想目标请求。 +type CreateRewardGoalRequest struct { + Title string + TargetAmountCent int + CoverImage string +} + +// UpdateRewardGoalRequest 表示更新梦想目标请求。 +type UpdateRewardGoalRequest struct { + Title *string + TargetAmountCent *int + CoverImage *string + Status *string +} + +// PosterGenerateRequest 表示海报生成请求。 +type PosterGenerateRequest struct { + TemplateCode string + ShowFields []string +} + +// PosterDataResult 表示海报预览数据。 +type PosterDataResult struct { + Nickname string `json:"nickname,omitempty"` + StreakDays int `json:"streak_days"` + SavedMoneyCent int `json:"saved_money_cent"` + AvoidedCigs int `json:"avoided_cigs"` + HealthRecoveryPercent int `json:"health_recovery_percent"` + TemplateCode string `json:"template_code"` + ShareTitle string `json:"share_title"` +} + +// PosterGenerateResult 表示海报生成结果。 +type PosterGenerateResult struct { + TemplateCode string `json:"template_code"` + ImageURL string `json:"image_url"` + ShareTitle string `json:"share_title"` + ShowFields []string `json:"show_fields"` +} + +// GetProfile 返回用户资料与旅程概览。 +func (s *Service) GetProfile(ctx context.Context, uid int, nickname, avatarURL string, now time.Time) (ProfileView, error) { + profile, err := s.loadProfile(ctx, uid) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ProfileView{ + Profile: nil, + Journey: JourneyResult{ + UserID: uid, + CurrentStreakDays: 0, + MaxStreakDays: 0, + Status: "pending_profile", + }, + }, nil + } + return ProfileView{}, err + } + + summary, lastCheckin, lastRelapse, _, err := s.computeSummary(ctx, uid, *profile, now) + if err != nil { + return ProfileView{}, err + } + + return ProfileView{ + Profile: &ProfileResult{ + UserID: uid, + Nickname: nickname, + AvatarURL: avatarURL, + QuitStartDate: formatDate(profile.QuitStartDate), + PackPriceCent: profile.PackPriceCent, + BaselineCigsPerDay: profile.BaselineCigsPerDay, + Motivation: profile.Motivation, + NotifyTime: profile.NotifyTime, + ResetRule: profile.ResetRule, + CreatedAt: profile.CreatedAt.Format(time.RFC3339), + UpdatedAt: profile.UpdatedAt.Format(time.RFC3339), + }, + Journey: JourneyResult{ + JourneyID: profile.ID, + UserID: uid, + StartDate: formatDate(profile.QuitStartDate), + CurrentStreakDays: summary.CurrentStreakDays, + MaxStreakDays: summary.MaxStreakDays, + LastCheckinDate: lastCheckin, + LastRelapseAt: lastRelapse, + Status: "active", + }, + }, nil +} + +// UpsertProfile 创建或更新用户资料。 +func (s *Service) UpsertProfile(ctx context.Context, uid int, req UpsertProfileRequest, nickname, avatarURL string, now time.Time) (ProfileView, error) { + var profile quitmodel.Profile + err := s.db.WithContext(ctx).Where("uid = ?", uid).First(&profile).Error + if err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + return ProfileView{}, err + } + profile = quitmodel.Profile{ + UID: uid, + ResetRule: resetRule, + } + } + + if req.QuitStartDate != nil { + profile.QuitStartDate = normalizeDate(*req.QuitStartDate) + } + if req.PackPriceCent != nil { + profile.PackPriceCent = *req.PackPriceCent + } + if req.BaselineCigsPerDay != nil { + profile.BaselineCigsPerDay = *req.BaselineCigsPerDay + } + if req.Motivation != nil { + profile.Motivation = strings.TrimSpace(*req.Motivation) + } + if req.NotifyTime != nil { + profile.NotifyTime = strings.TrimSpace(*req.NotifyTime) + } + if profile.ResetRule == "" { + profile.ResetRule = resetRule + } + + if err := s.db.WithContext(ctx).Save(&profile).Error; err != nil { + return ProfileView{}, err + } + + return s.GetProfile(ctx, uid, nickname, avatarURL, now) +} + +// Home 返回首页聚合数据。 +func (s *Service) Home(ctx context.Context, uid int, now time.Time) (HomeResult, error) { + profile, err := s.loadProfile(ctx, uid) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return HomeResult{}, ErrProfileNotFound + } + return HomeResult{}, err + } + + today := normalizeDate(now) + dailyStatus, err := s.getOrBuildDailyStatus(ctx, uid, today, now) + if err != nil { + return HomeResult{}, err + } + + summary, _, _, unlockedCount, err := s.computeSummary(ctx, uid, *profile, now) + if err != nil { + return HomeResult{}, err + } + + activeGoal, err := s.firstActiveGoal(ctx, uid, summary.SavedMoneyCent) + if err != nil { + return HomeResult{}, err + } + + return HomeResult{ + DailyStatus: toDailyStatusResult(dailyStatus), + Summary: summary, + Goal: activeGoal, + Badges: map[string]int{ + "unlocked_count": unlockedCount, + "total_count": len(milestoneDays), + }, + }, nil +} + +// Checkin 执行当日打卡。 +func (s *Service) Checkin(ctx context.Context, uid int, req CheckinRequest, now time.Time) (CheckinResult, error) { + profile, err := s.loadProfile(ctx, uid) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return CheckinResult{}, ErrProfileNotFound + } + return CheckinResult{}, err + } + + date := normalizeDate(req.Date) + var status quitmodel.DailyStatus + err = s.db.WithContext(ctx).Where("uid = ? AND date = ?", uid, date).First(&status).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return CheckinResult{}, err + } + + checkinAt := now + if errors.Is(err, gorm.ErrRecordNotFound) { + status = quitmodel.DailyStatus{ + UID: uid, + Date: date, + Status: quitmodel.DailyStatusCheckedIn, + CheckInAt: &checkinAt, + Note: strings.TrimSpace(req.Note), + } + if err := s.db.WithContext(ctx).Create(&status).Error; err != nil { + return CheckinResult{}, err + } + } else { + if status.Status == quitmodel.DailyStatusRelapsed { + return CheckinResult{}, ErrAlreadyRelapsed + } + if status.Status != quitmodel.DailyStatusCheckedIn { + status.Status = quitmodel.DailyStatusCheckedIn + status.CheckInAt = &checkinAt + status.Note = strings.TrimSpace(req.Note) + if err := s.db.WithContext(ctx).Save(&status).Error; err != nil { + return CheckinResult{}, err + } + } + } + + summary, _, _, _, err := s.computeSummary(ctx, uid, *profile, now) + if err != nil { + return CheckinResult{}, err + } + + return CheckinResult{ + DailyStatus: toDailyStatusResult(status), + Summary: summary, + }, nil +} + +// Relapse 记录当日复吸并清零连续天数。 +func (s *Service) Relapse(ctx context.Context, uid int, req RelapseRequest, now time.Time) (RelapseResult, error) { + profile, err := s.loadProfile(ctx, uid) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return RelapseResult{}, ErrProfileNotFound + } + return RelapseResult{}, err + } + + date := normalizeDate(req.Date) + var status quitmodel.DailyStatus + err = s.db.WithContext(ctx).Where("uid = ? AND date = ?", uid, date).First(&status).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return RelapseResult{}, err + } + if err == nil && status.Status == quitmodel.DailyStatusRelapsed { + return RelapseResult{}, ErrAlreadyRelapsed + } + + relapsedAt := req.RelapseAt + if relapsedAt.IsZero() { + relapsedAt = now + } + + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if errors.Is(err, gorm.ErrRecordNotFound) { + status = quitmodel.DailyStatus{ + UID: uid, + Date: date, + Status: quitmodel.DailyStatusRelapsed, + RelapsedAt: &relapsedAt, + RelapseNum: req.RelapseNum, + Reason: strings.TrimSpace(req.Reason), + Note: strings.TrimSpace(req.Note), + CheckInAt: nil, + } + if e := tx.Create(&status).Error; e != nil { + return e + } + } else { + status.Status = quitmodel.DailyStatusRelapsed + status.CheckInAt = nil + status.RelapsedAt = &relapsedAt + status.RelapseNum = req.RelapseNum + status.Reason = strings.TrimSpace(req.Reason) + status.Note = strings.TrimSpace(req.Note) + if e := tx.Save(&status).Error; e != nil { + return e + } + } + + event := quitmodel.RelapseEvent{ + UID: uid, + Date: date, + RelapseAt: relapsedAt, + RelapseNum: req.RelapseNum, + Reason: strings.TrimSpace(req.Reason), + Note: strings.TrimSpace(req.Note), + AffectStreak: true, + } + return tx.Create(&event).Error + }) + if err != nil { + return RelapseResult{}, err + } + + var event quitmodel.RelapseEvent + if err := s.db.WithContext(ctx).Where("uid = ? AND date = ?", uid, date).Order("id desc").First(&event).Error; err != nil { + return RelapseResult{}, err + } + + summary, _, _, _, err := s.computeSummary(ctx, uid, *profile, now) + if err != nil { + return RelapseResult{}, err + } + + return RelapseResult{ + DailyStatus: toDailyStatusResult(status), + RelapseEvent: RelapseEventResult{ + ID: event.ID, + UserID: uid, + Date: formatDate(event.Date), + RelapseAt: event.RelapseAt.Format(time.RFC3339), + RelapseNum: event.RelapseNum, + Reason: event.Reason, + Note: event.Note, + AffectStreak: event.AffectStreak, + CreatedAt: event.CreatedAt.Format(time.RFC3339), + }, + Summary: summary, + }, nil +} + +// StatsOverview 返回统计概览。 +func (s *Service) StatsOverview(ctx context.Context, uid int, rangeName string, now time.Time) (StatsOverviewResult, error) { + profile, err := s.loadProfile(ctx, uid) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return StatsOverviewResult{}, ErrProfileNotFound + } + return StatsOverviewResult{}, err + } + + rangeName = strings.TrimSpace(strings.ToLower(rangeName)) + if rangeName == "" { + rangeName = "week" + } + + var start time.Time + end := normalizeDate(now) + switch rangeName { + case "week": + start = end.AddDate(0, 0, -6) + case "month": + start = end.AddDate(0, 0, -29) + case "year": + start = end.AddDate(0, 0, -364) + default: + return StatsOverviewResult{}, ErrInvalidRange + } + + var dailyStatuses []quitmodel.DailyStatus + if err := s.db.WithContext(ctx). + Where("uid = ? AND date >= ? AND date <= ?", uid, start, end). + Order("date asc"). + Find(&dailyStatuses).Error; err != nil { + return StatsOverviewResult{}, err + } + + statusMap := make(map[string]quitmodel.DailyStatus, len(dailyStatuses)) + for _, item := range dailyStatuses { + statusMap[formatDate(item.Date)] = item + } + + trend := make([]TrendItemResult, 0, daysBetweenInclusive(start, end)) + for cursor := start; !cursor.After(end); cursor = cursor.AddDate(0, 0, 1) { + key := formatDate(cursor) + item, ok := statusMap[key] + if !ok { + status := quitmodel.DailyStatusMissed + if isSameDate(cursor, end) { + status = quitmodel.DailyStatusPending + } + trend = append(trend, TrendItemResult{Date: key, Status: status, RelapseNum: 0}) + continue + } + trend = append(trend, TrendItemResult{Date: key, Status: item.Status, RelapseNum: item.RelapseNum}) + } + + summary, _, _, _, err := s.computeSummary(ctx, uid, *profile, now) + if err != nil { + return StatsOverviewResult{}, err + } + + return StatsOverviewResult{ + Range: rangeName, + Summary: summary, + Trend: trend, + HealthMilestones: []HealthMilestone{ + { + Code: "lung_recovery", + Title: "肺部功能改善", + Percent: summary.HealthRecoveryPercent, + Unlocked: summary.CurrentStreakDays >= 7, + }, + }, + }, nil +} + +// ListBadges 返回勋章清单。 +func (s *Service) ListBadges(ctx context.Context, uid int, now time.Time) (BadgeListResult, error) { + profile, err := s.loadProfile(ctx, uid) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return BadgeListResult{Items: []BadgeResult{}}, nil + } + return BadgeListResult{}, err + } + + summary, _, _, _, err := s.computeSummary(ctx, uid, *profile, now) + if err != nil { + return BadgeListResult{}, err + } + + badges := make([]BadgeResult, 0, len(milestoneDays)) + unlockedCount := 0 + for idx, day := range milestoneDays { + unlocked := summary.MaxStreakDays >= day + progress := 0 + if unlocked { + progress = 100 + unlockedCount++ + } else if day > 0 { + progress = minInt((summary.CurrentStreakDays*100)/day, 99) + } + var unlockedAt *string + if unlocked { + v := fmt.Sprintf("%d 天里程碑", day) + unlockedAt = &v + } + badges = append(badges, BadgeResult{ + ID: idx + 1, + Code: fmt.Sprintf("streak_%d", day), + Title: fmt.Sprintf("%d 天里程碑", day), + Description: fmt.Sprintf("连续无烟 %d 天", day), + UnlockRule: fmt.Sprintf("连续无烟 %d 天", day), + Unlocked: unlocked, + UnlockedAt: unlockedAt, + ProgressPercent: progress, + }) + } + + return BadgeListResult{ + Items: badges, + UnlockedCount: unlockedCount, + TotalCount: len(badges), + }, nil +} + +// ListRelapses 分页查询复吸历史。 +func (s *Service) ListRelapses(ctx context.Context, uid, page, pageSize int) (RelapseListResult, error) { + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 20 + } + if pageSize > 100 { + pageSize = 100 + } + + var total int64 + if err := s.db.WithContext(ctx).Model(&quitmodel.RelapseEvent{}).Where("uid = ?", uid).Count(&total).Error; err != nil { + return RelapseListResult{}, err + } + + var items []quitmodel.RelapseEvent + if err := s.db.WithContext(ctx). + Where("uid = ?", uid). + Order("date desc, relapse_at desc, id desc"). + Offset((page - 1) * pageSize). + Limit(pageSize). + Find(&items).Error; err != nil { + return RelapseListResult{}, err + } + + result := make([]RelapseEventResult, 0, len(items)) + for _, item := range items { + result = append(result, RelapseEventResult{ + ID: item.ID, + UserID: uid, + Date: formatDate(item.Date), + RelapseAt: item.RelapseAt.Format(time.RFC3339), + RelapseNum: item.RelapseNum, + Reason: item.Reason, + Note: item.Note, + AffectStreak: item.AffectStreak, + CreatedAt: item.CreatedAt.Format(time.RFC3339), + }) + } + + return RelapseListResult{ + Items: result, + Total: total, + Page: page, + PageSize: pageSize, + }, nil +} + +// ListRewardGoals 查询梦想目标列表。 +func (s *Service) ListRewardGoals(ctx context.Context, uid int, status string, now time.Time) (RewardGoalListResult, error) { + status = strings.TrimSpace(strings.ToLower(status)) + query := s.db.WithContext(ctx).Where("uid = ?", uid) + if status != "" && status != "all" { + query = query.Where("status = ?", status) + } + + var goals []quitmodel.RewardGoal + if err := query.Order("created_at desc").Find(&goals).Error; err != nil { + return RewardGoalListResult{}, err + } + + currentAmount, err := s.currentSavedMoney(ctx, uid, now) + if err != nil { + return RewardGoalListResult{}, err + } + + items := make([]RewardGoalResult, 0, len(goals)) + for _, goal := range goals { + items = append(items, toRewardGoalResult(goal, uid, currentAmount)) + } + + return RewardGoalListResult{ + Items: items, + Total: len(items), + }, nil +} + +// CreateRewardGoal 创建梦想目标。 +func (s *Service) CreateRewardGoal(ctx context.Context, uid int, req CreateRewardGoalRequest, now time.Time) (RewardGoalResult, error) { + goal := quitmodel.RewardGoal{ + UID: uid, + Title: strings.TrimSpace(req.Title), + TargetAmountCent: req.TargetAmountCent, + CoverImage: strings.TrimSpace(req.CoverImage), + Status: quitmodel.RewardGoalStatusActive, + } + if err := s.db.WithContext(ctx).Create(&goal).Error; err != nil { + return RewardGoalResult{}, err + } + + currentAmount, err := s.currentSavedMoney(ctx, uid, now) + if err != nil { + return RewardGoalResult{}, err + } + + return toRewardGoalResult(goal, uid, currentAmount), nil +} + +// UpdateRewardGoal 更新梦想目标。 +func (s *Service) UpdateRewardGoal(ctx context.Context, uid, id int, req UpdateRewardGoalRequest, now time.Time) (RewardGoalResult, error) { + var goal quitmodel.RewardGoal + if err := s.db.WithContext(ctx).Where("uid = ? AND id = ?", uid, id).First(&goal).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return RewardGoalResult{}, ErrRewardGoalNotFound + } + return RewardGoalResult{}, err + } + + if req.Title != nil { + goal.Title = strings.TrimSpace(*req.Title) + } + if req.TargetAmountCent != nil { + goal.TargetAmountCent = *req.TargetAmountCent + } + if req.CoverImage != nil { + goal.CoverImage = strings.TrimSpace(*req.CoverImage) + } + if req.Status != nil { + goal.Status = strings.TrimSpace(strings.ToLower(*req.Status)) + if goal.Status == quitmodel.RewardGoalStatusCompleted { + nowCopy := now + goal.CompletedAt = &nowCopy + } + } + if err := s.db.WithContext(ctx).Save(&goal).Error; err != nil { + return RewardGoalResult{}, err + } + + currentAmount, err := s.currentSavedMoney(ctx, uid, now) + if err != nil { + return RewardGoalResult{}, err + } + + return toRewardGoalResult(goal, uid, currentAmount), nil +} + +// PosterData 返回海报预览数据。 +func (s *Service) PosterData(ctx context.Context, uid int, nickname string, templateCode string, now time.Time) (PosterDataResult, error) { + profile, err := s.loadProfile(ctx, uid) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return PosterDataResult{}, ErrProfileNotFound + } + return PosterDataResult{}, err + } + + summary, _, _, _, err := s.computeSummary(ctx, uid, *profile, now) + if err != nil { + return PosterDataResult{}, err + } + + if strings.TrimSpace(templateCode) == "" { + templateCode = "vibrant_1" + } + + return PosterDataResult{ + Nickname: nickname, + StreakDays: summary.CurrentStreakDays, + SavedMoneyCent: summary.SavedMoneyCent, + AvoidedCigs: summary.AvoidedCigs, + HealthRecoveryPercent: summary.HealthRecoveryPercent, + TemplateCode: templateCode, + ShareTitle: fmt.Sprintf("我已经坚持戒烟 %d 天", summary.CurrentStreakDays), + }, nil +} + +// GeneratePoster 返回海报生成结果。 +func (s *Service) GeneratePoster(ctx context.Context, uid int, nickname string, req PosterGenerateRequest, now time.Time) (PosterGenerateResult, error) { + data, err := s.PosterData(ctx, uid, nickname, req.TemplateCode, now) + if err != nil { + return PosterGenerateResult{}, err + } + + showFields := normalizeShowFields(req.ShowFields) + if len(showFields) == 0 { + showFields = []string{"streak_days", "saved_money_cent", "health_recovery_percent"} + } + + return PosterGenerateResult{ + TemplateCode: data.TemplateCode, + ImageURL: fmt.Sprintf( + "https://static.nepiedg.top/quit-checkin/posters/%d/%s/%d.png", + uid, + data.TemplateCode, + now.Unix(), + ), + ShareTitle: data.ShareTitle, + ShowFields: showFields, + }, nil +} + +func (s *Service) loadProfile(ctx context.Context, uid int) (*quitmodel.Profile, error) { + var profile quitmodel.Profile + if err := s.db.WithContext(ctx).Where("uid = ?", uid).First(&profile).Error; err != nil { + return nil, err + } + return &profile, nil +} + +func (s *Service) getOrBuildDailyStatus(ctx context.Context, uid int, date, now time.Time) (quitmodel.DailyStatus, error) { + var status quitmodel.DailyStatus + err := s.db.WithContext(ctx).Where("uid = ? AND date = ?", uid, date).First(&status).Error + if err == nil { + return status, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return quitmodel.DailyStatus{}, err + } + return quitmodel.DailyStatus{ + Date: date, + Status: quitmodel.DailyStatusPending, + }, nil +} + +func (s *Service) firstActiveGoal(ctx context.Context, uid int, currentAmount int) (*RewardGoalResult, error) { + var goal quitmodel.RewardGoal + if err := s.db.WithContext(ctx).Where("uid = ? AND status = ?", uid, quitmodel.RewardGoalStatusActive).Order("created_at asc").First(&goal).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + result := toRewardGoalResult(goal, uid, currentAmount) + return &result, nil +} + +func (s *Service) currentSavedMoney(ctx context.Context, uid int, now time.Time) (int, error) { + profile, err := s.loadProfile(ctx, uid) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, nil + } + return 0, err + } + summary, _, _, _, err := s.computeSummary(ctx, uid, *profile, now) + if err != nil { + return 0, err + } + return summary.SavedMoneyCent, nil +} + +func (s *Service) computeSummary(ctx context.Context, uid int, profile quitmodel.Profile, now time.Time) (SummaryResult, *string, *string, int, error) { + today := normalizeDate(now) + + var statuses []quitmodel.DailyStatus + if err := s.db.WithContext(ctx). + Where("uid = ? AND date >= ? AND date <= ?", uid, profile.QuitStartDate, today). + Order("date asc"). + Find(&statuses).Error; err != nil { + return SummaryResult{}, nil, nil, 0, err + } + + statusMap := make(map[string]quitmodel.DailyStatus, len(statuses)) + var lastCheckin *string + var lastRelapse *string + totalRelapseNum := 0 + for _, item := range statuses { + key := formatDate(item.Date) + statusMap[key] = item + if item.Status == quitmodel.DailyStatusCheckedIn { + v := key + lastCheckin = &v + } + if item.Status == quitmodel.DailyStatusRelapsed && item.RelapsedAt != nil { + v := item.RelapsedAt.Format(time.RFC3339) + lastRelapse = &v + totalRelapseNum += item.RelapseNum + } + } + + currentStreak := 0 + maxStreak := 0 + runningStreak := 0 + for cursor := normalizeDate(profile.QuitStartDate); !cursor.After(today); cursor = cursor.AddDate(0, 0, 1) { + key := formatDate(cursor) + item, ok := statusMap[key] + status := quitmodel.DailyStatusMissed + if ok { + status = item.Status + } else if isSameDate(cursor, today) { + status = quitmodel.DailyStatusPending + } + + if status == quitmodel.DailyStatusCheckedIn { + runningStreak++ + if runningStreak > maxStreak { + maxStreak = runningStreak + } + } else { + runningStreak = 0 + } + } + + todayKey := formatDate(today) + if item, ok := statusMap[todayKey]; ok && item.Status == quitmodel.DailyStatusRelapsed { + currentStreak = 0 + } else { + cursor := today + if _, ok := statusMap[todayKey]; !ok { + cursor = today.AddDate(0, 0, -1) + } + for !cursor.Before(normalizeDate(profile.QuitStartDate)) { + item, ok := statusMap[formatDate(cursor)] + if !ok || item.Status != quitmodel.DailyStatusCheckedIn { + break + } + currentStreak++ + cursor = cursor.AddDate(0, 0, -1) + } + } + + milestone := nextMilestone(currentStreak) + daysToNext := 0 + if milestone > currentStreak { + daysToNext = milestone - currentStreak + } + + elapsedDays := daysBetween(normalizeDate(profile.QuitStartDate), today) + theoreticalCigs := elapsedDays * profile.BaselineCigsPerDay + avoidedCigs := theoreticalCigs - totalRelapseNum + if avoidedCigs < 0 { + avoidedCigs = 0 + } + savedMoney := 0 + if profile.PackPriceCent > 0 { + savedMoney = (avoidedCigs * profile.PackPriceCent) / 20 + } + healthPercent := minInt(currentStreak*5, 100) + + unlockedCount := 0 + for _, day := range milestoneDays { + if maxStreak >= day { + unlockedCount++ + } + } + + return SummaryResult{ + CurrentStreakDays: currentStreak, + MaxStreakDays: maxStreak, + MilestoneDays: milestone, + DaysToNextMilestone: daysToNext, + SavedMoneyCent: savedMoney, + AvoidedCigs: avoidedCigs, + AvoidedCigsMode: "exact", + HealthRecoveryPercent: healthPercent, + }, lastCheckin, lastRelapse, unlockedCount, nil +} + +func toDailyStatusResult(status quitmodel.DailyStatus) DailyStatusResult { + result := DailyStatusResult{ + Date: formatDate(status.Date), + Status: status.Status, + } + if result.Date == "0001-01-01" { + result.Date = formatDate(normalizeDate(time.Now())) + } + if status.CheckInAt != nil { + v := status.CheckInAt.Format(time.RFC3339) + result.CheckinAt = &v + } + if status.RelapsedAt != nil { + v := status.RelapsedAt.Format(time.RFC3339) + result.RelapsedAt = &v + num := status.RelapseNum + result.RelapseNum = &num + } + if strings.TrimSpace(status.Note) != "" { + v := status.Note + result.Note = &v + } + return result +} + +func toRewardGoalResult(goal quitmodel.RewardGoal, uid int, currentAmount int) RewardGoalResult { + progress := 0 + if goal.TargetAmountCent > 0 { + progress = minInt((currentAmount*100)/goal.TargetAmountCent, 100) + } + var completedAt *string + if goal.CompletedAt != nil { + v := goal.CompletedAt.Format(time.RFC3339) + completedAt = &v + } + current := currentAmount + if goal.TargetAmountCent > 0 && current > goal.TargetAmountCent { + current = goal.TargetAmountCent + } + return RewardGoalResult{ + ID: goal.ID, + UserID: uid, + Title: goal.Title, + TargetAmountCent: goal.TargetAmountCent, + CurrentAmountCent: current, + ProgressPercent: progress, + CoverImage: goal.CoverImage, + Status: goal.Status, + CompletedAt: completedAt, + CreatedAt: goal.CreatedAt.Format(time.RFC3339), + } +} + +func nextMilestone(current int) int { + for _, item := range milestoneDays { + if item > current { + return item + } + } + return milestoneDays[len(milestoneDays)-1] +} + +func normalizeDate(t time.Time) time.Time { + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local) +} + +func formatDate(t time.Time) string { + return normalizeDate(t).Format(dateLayout) +} + +func daysBetween(start, end time.Time) int { + start = normalizeDate(start) + end = normalizeDate(end) + if end.Before(start) { + return 0 + } + return int(end.Sub(start).Hours() / 24) +} + +func daysBetweenInclusive(start, end time.Time) int { + return daysBetween(start, end) + 1 +} + +func isSameDate(a, b time.Time) bool { + return normalizeDate(a).Equal(normalizeDate(b)) +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +// ParseNotifyTime 校验提醒时间是否符合 HH:MM 格式。 +func ParseNotifyTime(v string) bool { + if strings.TrimSpace(v) == "" { + return true + } + _, err := time.ParseInLocation(timeOnlyLayout, v, time.Local) + return err == nil +} + +// SortRelapses 按日期和复吸时间倒序整理复吸列表。 +func SortRelapses(items []RelapseEventResult) { + sort.Slice(items, func(i, j int) bool { + if items[i].Date == items[j].Date { + return items[i].RelapseAt > items[j].RelapseAt + } + return items[i].Date > items[j].Date + }) +} + +func normalizeShowFields(items []string) []string { + allowed := map[string]struct{}{ + "streak_days": {}, + "saved_money_cent": {}, + "avoided_cigs": {}, + "health_recovery_percent": {}, + } + result := make([]string, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for _, item := range items { + key := strings.TrimSpace(strings.ToLower(item)) + if key == "" { + continue + } + if _, ok := allowed[key]; !ok { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + result = append(result, key) + } + return result +} diff --git a/internal/routes/quitcheckin_routes.go b/internal/routes/quitcheckin_routes.go new file mode 100644 index 0000000..674ddef --- /dev/null +++ b/internal/routes/quitcheckin_routes.go @@ -0,0 +1,31 @@ +package routes + +import ( + "github.com/gin-gonic/gin" + + quitcheckinhandler "wx_service/internal/quitcheckin/handler" +) + +// registerQuitCheckinRoutes 用于注册 V2 无烟打卡相关接口。 +func registerQuitCheckinRoutes(protected *gin.RouterGroup, handler *quitcheckinhandler.Handler) { + v2 := protected.Group("") + { + v2.GET("/profile", handler.GetProfile) + v2.POST("/profile", handler.UpsertProfile) + + v2.GET("/checkin/home", handler.Home) + v2.POST("/checkin/check", handler.Checkin) + v2.POST("/checkin/relapse", handler.Relapse) + + v2.GET("/stats/overview", handler.StatsOverview) + v2.GET("/badges", handler.ListBadges) + v2.GET("/relapses", handler.ListRelapses) + + v2.GET("/reward-goals", handler.ListRewardGoals) + v2.POST("/reward-goals", handler.CreateRewardGoal) + v2.PUT("/reward-goals/:id", handler.UpdateRewardGoal) + + v2.GET("/poster/data", handler.PosterData) + v2.POST("/poster/generate", handler.GeneratePoster) + } +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 5a597e7..f710ee3 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -16,6 +16,7 @@ import ( marketinghandler "wx_service/internal/marketing/handler" membershiphandler "wx_service/internal/membership/handler" "wx_service/internal/middleware" + quitcheckinhandler "wx_service/internal/quitcheckin/handler" rmhandler "wx_service/internal/remove_watermark/handler" smokehandler "wx_service/internal/smoke/handler" ) @@ -38,6 +39,7 @@ func Register( marketingCategoryHandler *marketinghandler.CategoryHandler, marketingTemplateHandler *marketinghandler.TemplateHandler, marketingDownloadHandler *marketinghandler.DownloadHandler, + quitCheckinHandler *quitcheckinhandler.Handler, ) { // Register 用来集中注册所有 HTTP 路由,便于工程结构更清晰: // - main 只负责初始化(配置/DB/依赖注入) @@ -69,6 +71,16 @@ func Register( registerMarketingRoutes(api, protected, adminToken, marketingCategoryHandler, marketingTemplateHandler, marketingDownloadHandler) } + apiV2 := router.Group("/api/v2") + { + protectedV2 := apiV2.Group("") + protectedV2.Use(middleware.AuthMiddleware(db, sessionCache)) + protectedV2.Use(middleware.RequireUserMiddleware()) + { + registerQuitCheckinRoutes(protectedV2, quitCheckinHandler) + } + } + registerAdminRoutes(router, adminHandler, marketingCategoryHandler, marketingTemplateHandler, marketingDownloadHandler) // 保质期提醒模块使用独立前缀 /api/expiry,与现有 /api/v1 并存。