feat: add quit-checkin v2 backend APIs

This commit is contained in:
你çšnepiedg
2026-03-17 00:47:33 +08:00
parent 12619aa4ab
commit 9278260681
11 changed files with 2212 additions and 0 deletions
+10
View File
@@ -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) 启动监听端口
+11
View File
@@ -0,0 +1,11 @@
# 无烟打卡 V2 文档
## 文档清单
- `swagger.yaml`OpenAPI 3.0 接口文档
## 说明
- 接口前缀:`/api/v2`
- 鉴权方式:`Authorization: Bearer <session_key>`
- 业务语义:无烟打卡、复吸、梦想目标、统计与海报
+331
View File
@@ -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"
+510
View File
@@ -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))
}
@@ -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-无烟打卡-每日状态"
}
+34
View File
@@ -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-无烟打卡-用户资料"
}
@@ -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-无烟打卡-复吸事件"
}
+40
View File
@@ -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-无烟打卡-梦想目标"
}
File diff suppressed because it is too large Load Diff
+31
View File
@@ -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)
}
}
+12
View File
@@ -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 并存。