From 9278260681aaf536dd5bdbfc3bc20b9f3e7a7b5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=A0=C3=A7=C2=9Anepiedg?= <你的yunchuansong@163.com> Date: Tue, 17 Mar 2026 00:47:33 +0800 Subject: [PATCH 1/4] feat: add quit-checkin v2 backend APIs --- cmd/api/main.go | 10 + docs/quitcheckin/README.md | 11 + docs/quitcheckin/swagger.yaml | 331 ++++++ internal/quitcheckin/handler/handler.go | 510 ++++++++ internal/quitcheckin/model/daily_status.go | 43 + internal/quitcheckin/model/profile.go | 34 + internal/quitcheckin/model/relapse_event.go | 34 + internal/quitcheckin/model/reward_goal.go | 40 + internal/quitcheckin/service/service.go | 1156 +++++++++++++++++++ internal/routes/quitcheckin_routes.go | 31 + internal/routes/routes.go | 12 + 11 files changed, 2212 insertions(+) create mode 100644 docs/quitcheckin/README.md create mode 100644 docs/quitcheckin/swagger.yaml create mode 100644 internal/quitcheckin/handler/handler.go create mode 100644 internal/quitcheckin/model/daily_status.go create mode 100644 internal/quitcheckin/model/profile.go create mode 100644 internal/quitcheckin/model/relapse_event.go create mode 100644 internal/quitcheckin/model/reward_goal.go create mode 100644 internal/quitcheckin/service/service.go create mode 100644 internal/routes/quitcheckin_routes.go 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 并存。 From 515fba0b552f7cc343a5ab45f77b2017be623ea2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=A0=C3=A7=C2=9Anepiedg?= <你的yunchuansong@163.com> Date: Thu, 19 Mar 2026 15:41:26 +0800 Subject: [PATCH 2/4] feat: update smoke profile area handling --- docs/smoke/API.md | 3 +++ docs/sql/smoke.sql | 1 + .../smoke/handler/smoke_profile_handler.go | 9 +++++++ internal/smoke/model/smoke_profile.go | 1 + .../smoke/service/smoke_profile_service.go | 26 +++++++++++++++++++ .../service/smoke_profile_service_test.go | 20 ++++++++++++++ 6 files changed, 60 insertions(+) diff --git a/docs/smoke/API.md b/docs/smoke/API.md index a9fafd3..54c833a 100644 --- a/docs/smoke/API.md +++ b/docs/smoke/API.md @@ -391,6 +391,7 @@ curl -X GET 'http://127.0.0.1:8080/api/v1/smoke/logs/5202' \ "pack_price_cent": 2500, "smoke_motivations": ["压力大", "社交"], "quit_motivations": ["身体健康", "省钱"], + "mode": "record", "wake_up_time": "07:30", "sleep_time": "23:30", "quit_date": "2026-02-28T00:00:00+08:00", @@ -423,6 +424,7 @@ curl -X GET 'http://127.0.0.1:8080/api/v1/smoke/logs/5202' \ - `smoking_years`(烟龄/年)+ `pack_price_cent`(单包价格/分):用于看板计算“已省金额”和“恢复时长”等指标(公式可在看板端实现)。 - `smoke_motivations`(抽烟动机):如 `压力大/无聊/社交/提神`,用于 AI 在分析 remark 时更有针对性。 - `quit_motivations`(戒烟动力):如 `身体健康/家人孩子/省钱`,当用户产生动摇时 AI 可用这些信息做“情感阻断/自我提醒”。 +- `mode`(使用模式):`quit` 表示戒烟打卡模式,`record` 表示记录抽烟模式。 - `wake_up_time` + `sleep_time`(作息时间):用于自动规避睡眠时间,防止在用户睡觉时提醒其“坚持”。 - `quit_date`(目标戒烟日期):用于阶段规划或到期提醒。 @@ -444,6 +446,7 @@ curl -X GET 'http://127.0.0.1:8080/api/v1/smoke/logs/5202' \ "pack_price_cent": 2500, "smoke_motivations": ["压力大", "社交"], "quit_motivations": ["身体健康", "省钱"], + "mode": "record", "wake_up_time": "07:30", "sleep_time": "23:30", "quit_date": "2026-02-28" diff --git a/docs/sql/smoke.sql b/docs/sql/smoke.sql index 50e7f9d..0275dd5 100644 --- a/docs/sql/smoke.sql +++ b/docs/sql/smoke.sql @@ -71,6 +71,7 @@ CREATE TABLE IF NOT EXISTS `fa_smoke_user_profile` ( `pack_price_cent` int NOT NULL DEFAULT 0 COMMENT '单包价格(分)', `smoke_motivations` json DEFAULT NULL COMMENT '抽烟动机(JSON数组)', `quit_motivations` json DEFAULT NULL COMMENT '戒烟动力(JSON数组)', + `mode` varchar(16) NOT NULL DEFAULT 'record' COMMENT '使用模式(quit=戒烟打卡,record=记录抽烟)', `wake_up_time` varchar(5) NOT NULL DEFAULT '' COMMENT '起床时间(HH:MM)', `sleep_time` varchar(5) NOT NULL DEFAULT '' COMMENT '入睡时间(HH:MM)', `onboarding_completed_at` datetime(3) DEFAULT NULL COMMENT '首次补全完成时间', diff --git a/internal/smoke/handler/smoke_profile_handler.go b/internal/smoke/handler/smoke_profile_handler.go index 29f592f..07acb96 100644 --- a/internal/smoke/handler/smoke_profile_handler.go +++ b/internal/smoke/handler/smoke_profile_handler.go @@ -18,6 +18,7 @@ type upsertSmokeProfileRequest struct { BaselineCigsPerDay *int `json:"baseline_cigs_per_day"` SmokingYears *float64 `json:"smoking_years"` PackPriceCent *int `json:"pack_price_cent"` + Mode *string `json:"mode"` SmokeMotivations *[]string `json:"smoke_motivations"` QuitMotivations *[]string `json:"quit_motivations"` @@ -71,6 +72,13 @@ func (h *SmokeHandler) UpsertProfile(c *gin.Context) { return } } + if req.Mode != nil { + mode := strings.TrimSpace(*req.Mode) + if mode != "" && mode != smokeservice.SmokeModeQuit && mode != smokeservice.SmokeModeRecord { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "mode 仅支持 quit 或 record")) + return + } + } quitDateProvided := false var quitDate *time.Time @@ -91,6 +99,7 @@ func (h *SmokeHandler) UpsertProfile(c *gin.Context) { BaselineCigsPerDay: req.BaselineCigsPerDay, SmokingYears: req.SmokingYears, PackPriceCent: req.PackPriceCent, + Mode: req.Mode, SmokeMotivations: req.SmokeMotivations, QuitMotivations: req.QuitMotivations, WakeUpTime: req.WakeUpTime, diff --git a/internal/smoke/model/smoke_profile.go b/internal/smoke/model/smoke_profile.go index 72c1fe1..8c7a40a 100644 --- a/internal/smoke/model/smoke_profile.go +++ b/internal/smoke/model/smoke_profile.go @@ -67,6 +67,7 @@ type SmokeUserProfile struct { SmokeMotivations StringSlice `gorm:"column:smoke_motivations;type:json;comment:抽烟动机(JSON数组)" json:"smoke_motivations"` QuitMotivations StringSlice `gorm:"column:quit_motivations;type:json;comment:戒烟动力(JSON数组)" json:"quit_motivations"` + Mode string `gorm:"column:mode;size:16;default:record;comment:使用模式(quit=戒烟打卡,record=记录抽烟)" json:"mode,omitempty"` WakeUpTime string `gorm:"column:wake_up_time;size:5;comment:起床时间(HH:MM)" json:"wake_up_time"` SleepTime string `gorm:"column:sleep_time;size:5;comment:入睡时间(HH:MM)" json:"sleep_time"` diff --git a/internal/smoke/service/smoke_profile_service.go b/internal/smoke/service/smoke_profile_service.go index abd8af4..f0e3ff4 100644 --- a/internal/smoke/service/smoke_profile_service.go +++ b/internal/smoke/service/smoke_profile_service.go @@ -16,6 +16,11 @@ var ( ErrSmokeProfileInvalidTime = errors.New("invalid time format, expected HH:MM") ) +const ( + SmokeModeQuit = "quit" + SmokeModeRecord = "record" +) + type SmokeProfileService struct { db *gorm.DB } @@ -47,6 +52,7 @@ func (s *SmokeProfileService) GetView(ctx context.Context, uid int) (SmokeProfil BaselineIntervalMinute: 0, }, nil } + profile.Mode = normalizedSmokeMode(profile.Mode) awakeMinutes, err := awakeMinutesWithFallback(profile.WakeUpTime, profile.SleepTime) if err != nil { @@ -83,6 +89,7 @@ type UpsertSmokeProfileRequest struct { BaselineCigsPerDay *int SmokingYears *float64 PackPriceCent *int + Mode *string SmokeMotivations *[]string QuitMotivations *[]string @@ -116,6 +123,12 @@ func (s *SmokeProfileService) Upsert(ctx context.Context, uid int, req UpsertSmo *dst = *v } } + applyMode := func(dst *string, v *string) { + if v == nil { + return + } + *dst = normalizedSmokeMode(*v) + } applyTimeStr := func(dst *string, v *string) error { if v == nil { return nil @@ -135,6 +148,7 @@ func (s *SmokeProfileService) Upsert(ctx context.Context, uid int, req UpsertSmo applyInt(&profile.BaselineCigsPerDay, req.BaselineCigsPerDay) applyFloat(&profile.SmokingYears, req.SmokingYears) applyInt(&profile.PackPriceCent, req.PackPriceCent) + applyMode(&profile.Mode, req.Mode) if req.SmokeMotivations != nil { profile.SmokeMotivations = smokemodel.StringSlice(*req.SmokeMotivations) @@ -154,6 +168,7 @@ func (s *SmokeProfileService) Upsert(ctx context.Context, uid int, req UpsertSmo } now := time.Now() + profile.Mode = normalizedSmokeMode(profile.Mode) if profile.OnboardingCompletedAt == nil && isSmokeProfileCompleted(profile) { profile.OnboardingCompletedAt = &now } @@ -215,6 +230,17 @@ func baselineIntervalMinutes(awakeMinutes int, baselineCigsPerDay int) int { return interval } +func normalizedSmokeMode(mode string) string { + switch strings.TrimSpace(mode) { + case SmokeModeQuit: + return SmokeModeQuit + case SmokeModeRecord: + return SmokeModeRecord + default: + return SmokeModeRecord + } +} + func parseHHMMToMinutes(s string) (int, error) { s = strings.TrimSpace(s) if len(s) != 5 || s[2] != ':' { diff --git a/internal/smoke/service/smoke_profile_service_test.go b/internal/smoke/service/smoke_profile_service_test.go index 9c22ebf..b4024f3 100644 --- a/internal/smoke/service/smoke_profile_service_test.go +++ b/internal/smoke/service/smoke_profile_service_test.go @@ -106,3 +106,23 @@ func TestIsSmokeProfileCompleted(t *testing.T) { t.Fatalf("isSmokeProfileCompleted: expected false when quit_motivations missing") } } + +func TestNormalizedSmokeMode(t *testing.T) { + t.Parallel() + + cases := []struct { + in string + want string + }{ + {in: SmokeModeQuit, want: SmokeModeQuit}, + {in: SmokeModeRecord, want: SmokeModeRecord}, + {in: "", want: SmokeModeRecord}, + {in: "unknown", want: SmokeModeRecord}, + } + + for _, c := range cases { + if got := normalizedSmokeMode(c.in); got != c.want { + t.Fatalf("normalizedSmokeMode(%q): got %q, want %q", c.in, got, c.want) + } + } +} From 47874a8769313db0bbe5593a76ef6f95e066ceee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=A0=C3=A7=C2=9Anepiedg?= <你的yunchuansong@163.com> Date: Tue, 24 Mar 2026 18:26:20 +0800 Subject: [PATCH 3/4] feat(smoke): enhance smoke profile area management with new features --- docs/reading/PRD.md | 1089 ++++++++++++++++++++++++++++++++++++++++ docs/reading/README.md | 19 + 2 files changed, 1108 insertions(+) create mode 100644 docs/reading/PRD.md create mode 100644 docs/reading/README.md diff --git a/docs/reading/PRD.md b/docs/reading/PRD.md new file mode 100644 index 0000000..9b95eef --- /dev/null +++ b/docs/reading/PRD.md @@ -0,0 +1,1089 @@ +# 阅读打卡小程序 - 产品需求文档 (PRD) + +## 1. 产品概述 + +### 1.1 产品定位 +一款阅读打卡小程序,通过 AI 预生成每本书的分日阅读计划(含导读、预估阅读时长、理解问题),引导用户按计划阅读并每日打卡,培养持续阅读习惯。 + +### 1.2 核心价值 +- **AI 阅读计划**:AI 预先为每本书拆分每日阅读导读,预估阅读时长,后台可人工调整 +- **引导式阅读**:每天展示导读内容 + 预估阅读时长 + 理解问题,降低阅读门槛 +- **打卡激励**:读完当日内容后回答问题、写读书感悟,完成打卡 +- **分享传播**:生成精美分享宣传图,展示阅读成果 + +### 1.3 目标用户 +- 想养成阅读习惯但缺乏计划性的用户 +- 读书会/学习社群成员 +- 家长引导孩子阅读 + +--- + +## 2. 功能范围 + +### 2.1 核心功能(MVP) + +#### ✅ 书单与书籍 +- 浏览书单列表(按分类/主题组织) +- 查看书单下的书籍列表 +- 查看书籍详情和阅读计划概览 + +#### ✅ AI 阅读计划 +- AI 预生成每本书的分日阅读导读(后台触发) +- 每日导读包含:导读内容、阅读范围(页码/章节)、**AI 预估阅读时长**、理解问题 +- 后台管理员可编辑调整 AI 生成的计划内容 + +#### ✅ 每日打卡 +- 展示今日导读内容和 AI 预估阅读时长(用户无需手动填写时长) +- 用户阅读完成后回答理解问题 +- 提交打卡 + 选填读书感悟 +- 打卡日历展示连续打卡记录 + +#### ✅ 分享宣传图 +- 生成分享海报(连续打卡天数、已读书籍数、累计阅读时长等) +- 支持多种海报模板 + +#### ✅ 阅读统计 +- 连续打卡天数 / 最长连续天数 +- 已读完书籍数 +- 累计阅读时长(由 AI 预估时长按打卡天数累加) +- 阅读趋势图 + +### 2.2 暂不实现(后续迭代) +- ❌ 用户自建书单 +- ❌ 社区互动(评论/点赞感悟) +- ❌ 阅读排行榜 +- ❌ 订阅消息推送提醒 +- ❌ 书籍推荐算法 + +--- + +## 3. 页面结构 + +### 3.1 页面清单与 TabBar + +``` +TabBar: + ├── 今日阅读 (pages/home/index) - 首页,展示今日任务与打卡入口 + ├── 书单 (pages/book-lists/index) - 浏览书单和书籍 + ├── 统计 (pages/stats/index) - 阅读数据统计 + └── 我的 (pages/profile/index) - 个人中心 + +非 Tab 页: + ├── 书籍详情 (pages/book-detail/index) - 书籍信息 + 阅读计划预览 + ├── 阅读打卡 (pages/reading/index) - 今日导读 + 问题 + 打卡 + ├── 感悟列表 (pages/reflections/index) - 某本书的感悟记录 + └── 分享海报 (pages/poster/index) - 生成/预览分享图 +``` + +### 3.2 首页 — 今日阅读 (home) + +**核心目标**:一目了然展示今日阅读任务,快速进入打卡 + +| 元素 | 说明 | 数据来源 | +|------|------|----------| +| 打卡日历条 | 近 7 天打卡状态(圆点标记) | `GET /reading/my/stats` | +| 在读书籍卡片 | 书名、封面、进度(第 X/Y 天) | `GET /reading/my/books` | +| 今日任务预览 | 导读标题 + AI 预估阅读时长 | `GET /reading/my/books/:id/today` | +| 打卡按钮 | "开始今日阅读" / "今日已打卡 ✅" | 根据 daily_status 判断 | +| 空状态引导 | 未选书时展示 "去选一本书开始吧" | 跳转书单页 | + +**首页布局示意**: + +``` +┌─────────────────────────────┐ +│ 📖 今日阅读 │ +│ ───────────────────────── │ +│ [日 一 二 三 四 五 六] │ +│ ● ● ● ○ ○ ○ ○ │ +│ 连续打卡 3 天 │ +│ ───────────────────────── │ +│ ┌───────────────────────┐ │ +│ │ 📕 《人类简史》 │ │ +│ │ 第 5/30 天 │ │ +│ │ ████████░░░ 17% │ │ +│ └───────────────────────┘ │ +│ ───────────────────────── │ +│ 今日导读:第三章 农业革命 │ +│ ⏱ 预估阅读 25 分钟 │ +│ 📝 3 道理解问题 │ +│ ───────────────────────── │ +│ [ 🟢 开始今日阅读 ] │ +└─────────────────────────────┘ +``` + +### 3.3 书单页 (book-lists) + +**核心目标**:浏览和选择想读的书 + +| 元素 | 说明 | +|------|------| +| 书单卡片列表 | 书单封面、标题、描述、书籍数量 | +| 点击书单 | 展开书单下的书籍列表 | +| 书籍卡片 | 书名、作者、封面、计划天数、"开始阅读"按钮 | + +### 3.4 书籍详情页 (book-detail) + +**核心目标**:了解书籍信息和阅读计划,决定是否开始阅读 + +| 元素 | 说明 | +|------|------| +| 书籍封面大图 | 封面、书名、作者 | +| 书籍简介 | 内容简介 | +| 阅读计划概览 | 共 X 天、AI 预估总阅读时长 | +| 每日计划预览 | 可展开查看每天的导读标题和预估时长 | +| 操作按钮 | "开始阅读"(未开始)/ "继续阅读"(进行中)/ "已读完"(已完成) | + +### 3.5 阅读打卡页 (reading) + +**核心目标**:完成今日阅读任务并打卡 + +``` +┌─────────────────────────────┐ +│ ← 返回 第 5/30 天 │ +│ ───────────────────────── │ +│ 📖 今日导读 │ +│ 第三章 农业革命 (P45-P62) │ +│ ───────────────────────── │ +│ ⏱ 预估阅读时长: 25 分钟 │ +│ ───────────────────────── │ +│ [导读内容区域] │ +│ "农业革命是人类历史上最具 │ +│ 争议的转折点之一。作者认为 │ +│ 农业并非进步,而是..." │ +│ ───────────────────────── │ +│ 📝 阅读理解 (读完后作答) │ +│ ───────────────────────── │ +│ Q1: 作者为什么认为农业革命 │ +│ 是"史上最大的骗局"? │ +│ [文本输入框] │ +│ │ +│ Q2: 采集社会与农业社会相比 │ +│ 各有什么优劣? │ +│ [文本输入框] │ +│ │ +│ Q3: 你是否同意作者的观点? │ +│ 为什么? │ +│ [文本输入框] │ +│ ───────────────────────── │ +│ 💭 读书感悟 (选填) │ +│ [多行文本输入框] │ +│ ───────────────────────── │ +│ [ ✅ 完成今日打卡 ] │ +└─────────────────────────────┘ +``` + +**交互规则**: +- 理解问题至少回答 1 道才能提交打卡 +- 读书感悟为选填项 +- 打卡成功后展示鼓励动画 + 连续打卡天数 +- 当天最后一天打卡 → 弹出"恭喜读完全书" + 引导生成分享图 + +### 3.6 统计页 (stats) + +| 元素 | 说明 | +|------|------| +| 核心数据卡片 | 连续打卡天数、已读完书籍数、累计阅读时长 | +| 打卡日历 | 月度日历视图,标记已打卡日期 | +| 阅读趋势 | 周/月维度的每日阅读时长趋势图(来自 AI 预估时长) | +| 已读书籍列表 | 已完成的书籍及完成日期 | + +### 3.7 个人中心 (profile) + +| 元素 | 说明 | +|------|------| +| 用户信息 | 头像、昵称 | +| 在读书籍 | 当前正在阅读的书籍列表 | +| 已读书籍 | 已完成的书籍列表 | +| 我的感悟 | 所有读书感悟汇总 | +| 分享成就 | 进入海报生成页 | + +### 3.8 分享海报页 (poster) + +| 元素 | 说明 | +|------|------| +| 海报预览 | 实时预览生成的宣传图 | +| 模板选择 | 多种海报风格可选 | +| 展示字段 | 连续打卡天数、已读书籍数、累计阅读时长、当前在读书名 | +| 操作按钮 | "保存到相册" / "分享给好友" | + +--- + +## 4. 数据模型 + +### 4.1 书单表 (fa_reading_book_list) + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| id | bigint | PK | 主键 | +| title | varchar(100) | ✅ | 书单标题 | +| description | varchar(500) | ❌ | 书单描述 | +| cover_image | varchar(500) | ❌ | 书单封面图 URL | +| sort_order | int | ❌ | 排序权重(越小越靠前,默认 0) | +| status | varchar(20) | ✅ | 状态:active / inactive | +| created_at | timestamp | 自动 | 创建时间 | +| updated_at | timestamp | 自动 | 更新时间 | +| deleted_at | timestamp | 自动 | 软删除时间 | + +### 4.2 书籍表 (fa_reading_book) + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| id | bigint | PK | 主键 | +| book_list_id | bigint | ✅ | 所属书单 ID | +| title | varchar(200) | ✅ | 书名 | +| author | varchar(100) | ❌ | 作者 | +| cover_image | varchar(500) | ❌ | 书籍封面图 URL | +| description | text | ❌ | 内容简介 | +| isbn | varchar(20) | ❌ | ISBN 编号 | +| total_pages | int | ❌ | 总页数(用于 AI 生成计划) | +| total_plan_days | int | ❌ | 计划总天数 | +| sort_order | int | ❌ | 排序权重(默认 0) | +| status | varchar(20) | ✅ | 状态:active / inactive | +| created_at | timestamp | 自动 | 创建时间 | +| updated_at | timestamp | 自动 | 更新时间 | +| deleted_at | timestamp | 自动 | 软删除时间 | + +**索引**: +- `idx_book_list_id` (book_list_id) +- `idx_status` (status) + +### 4.3 阅读计划表 (fa_reading_plan) + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| id | bigint | PK | 主键 | +| book_id | bigint | ✅ | 关联书籍 ID(唯一) | +| total_days | int | ✅ | 计划总天数 | +| total_estimated_minutes | int | ❌ | AI 预估总阅读时长(分钟,所有天数累加) | +| ai_generated | tinyint(1) | ✅ | 是否 AI 生成(1=是,0=否) | +| ai_model | varchar(50) | ❌ | 生成时使用的 AI 模型 | +| status | varchar(20) | ✅ | 状态:draft / published | +| created_at | timestamp | 自动 | 创建时间 | +| updated_at | timestamp | 自动 | 更新时间 | +| deleted_at | timestamp | 自动 | 软删除时间 | + +**索引**: +- `uidx_book_id` (book_id) UNIQUE + +### 4.4 每日导读表 (fa_reading_plan_day) + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| id | bigint | PK | 主键 | +| plan_id | bigint | ✅ | 关联阅读计划 ID | +| day_number | int | ✅ | 第几天(从 1 开始) | +| title | varchar(200) | ✅ | 导读标题(如"第三章 农业革命") | +| guide_content | text | ✅ | 导读正文(AI 生成的阅读引导) | +| reading_range | varchar(100) | ❌ | 阅读范围(如"P45-P62"或"第三章") | +| estimated_minutes | int | ✅ | AI 预估阅读时长(分钟) | +| questions_json | json | ❌ | 理解问题数组 `[{"q":"问题内容","hint":"提示"}]` | +| sort_order | int | ❌ | 排序(默认等于 day_number) | +| created_at | timestamp | 自动 | 创建时间 | +| updated_at | timestamp | 自动 | 更新时间 | +| deleted_at | timestamp | 自动 | 软删除时间 | + +**索引**: +- `uidx_plan_day` (plan_id, day_number) UNIQUE +- `idx_plan_id` (plan_id) + +**questions_json 结构**: +```json +[ + { + "q": "作者为什么认为农业革命是'史上最大的骗局'?", + "hint": "可以从人类生活质量、劳动强度等角度思考" + }, + { + "q": "采集社会与农业社会相比各有什么优劣?", + "hint": "" + } +] +``` + +### 4.5 用户阅读进度表 (fa_reading_user_book) + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| id | bigint | PK | 主键 | +| uid | bigint | ✅ | 用户 ID | +| book_id | bigint | ✅ | 书籍 ID | +| plan_id | bigint | ✅ | 阅读计划 ID | +| current_day | int | ✅ | 当前进度(已完成到第几天,默认 0) | +| total_days | int | ✅ | 计划总天数(冗余,方便查询) | +| status | varchar(20) | ✅ | 状态:reading / completed / dropped | +| started_at | timestamp | ✅ | 开始阅读时间 | +| completed_at | timestamp | ❌ | 读完时间 | +| created_at | timestamp | 自动 | 创建时间 | +| updated_at | timestamp | 自动 | 更新时间 | +| deleted_at | timestamp | 自动 | 软删除时间 | + +**索引**: +- `uidx_uid_book` (uid, book_id) UNIQUE +- `idx_uid_status` (uid, status) + +### 4.6 每日打卡表 (fa_reading_daily_checkin) + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| id | bigint | PK | 主键 | +| uid | bigint | ✅ | 用户 ID | +| user_book_id | bigint | ✅ | 关联用户阅读进度 ID | +| plan_day_id | bigint | ✅ | 关联每日导读 ID | +| day_number | int | ✅ | 第几天 | +| date | date | ✅ | 打卡自然日 | +| estimated_minutes | int | ✅ | AI 预估阅读时长(冗余自 plan_day) | +| answers_json | json | ❌ | 问题回答 `[{"q":"问题","a":"回答"}]` | +| reflection | text | ❌ | 读书感悟 | +| status | varchar(20) | ✅ | 状态:checked_in / missed | +| checked_in_at | timestamp | ❌ | 打卡时间 | +| created_at | timestamp | 自动 | 创建时间 | +| updated_at | timestamp | 自动 | 更新时间 | +| deleted_at | timestamp | 自动 | 软删除时间 | + +**索引**: +- `uidx_uid_date` (uid, date) UNIQUE +- `idx_uid_user_book` (uid, user_book_id) +- `idx_user_book_day` (user_book_id, day_number) + +**answers_json 结构**: +```json +[ + { + "q": "作者为什么认为农业革命是'史上最大的骗局'?", + "a": "因为农业让人类的劳动量大幅增加,饮食更加单一,但总人口的增长又让人们无法回到采集社会..." + } +] +``` + +### 4.7 分享记录表 (fa_reading_share) + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| id | bigint | PK | 主键 | +| uid | bigint | ✅ | 用户 ID | +| share_type | varchar(20) | ✅ | 分享类型:daily / book_complete / streak_milestone | +| template_code | varchar(50) | ✅ | 海报模板编码 | +| data_json | json | ❌ | 海报数据快照 | +| image_url | varchar(500) | ❌ | 生成的海报图片 URL | +| created_at | timestamp | 自动 | 创建时间 | +| updated_at | timestamp | 自动 | 更新时间 | +| deleted_at | timestamp | 自动 | 软删除时间 | + +**索引**: +- `idx_uid` (uid) + +--- + +## 5. API 设计 + +### 5.1 路由前缀与鉴权 + +| 前缀 | 鉴权 | 用途 | +|------|------|------| +| `/api/reading` | 部分公开 | 小程序主接口 | +| `/api/admin/reading` | Admin JWT | 管理后台 | + +### 5.2 书单与书籍(公开接口) + +#### 获取书单列表 +``` +GET /api/reading/book-lists + +Response: +{ + "code": 0, + "data": { + "items": [ + { + "id": 1, + "title": "经典必读书单", + "description": "精选 10 本改变思维方式的经典作品", + "cover_image": "https://...", + "book_count": 10 + } + ] + } +} +``` + +#### 获取书单下的书籍列表 +``` +GET /api/reading/book-lists/:id/books + +Response: +{ + "code": 0, + "data": { + "book_list": { "id": 1, "title": "经典必读书单" }, + "items": [ + { + "id": 1, + "title": "人类简史", + "author": "尤瓦尔·赫拉利", + "cover_image": "https://...", + "total_plan_days": 30, + "total_estimated_minutes": 750, + "description": "..." + } + ] + } +} +``` + +#### 获取书籍详情(含计划概览) +``` +GET /api/reading/books/:id + +Response: +{ + "code": 0, + "data": { + "book": { + "id": 1, + "title": "人类简史", + "author": "尤瓦尔·赫拉利", + "cover_image": "https://...", + "description": "...", + "total_pages": 440 + }, + "plan": { + "id": 1, + "total_days": 30, + "total_estimated_minutes": 750, + "status": "published", + "days_preview": [ + { + "day_number": 1, + "title": "序章 - 人类的三大革命", + "reading_range": "P1-P15", + "estimated_minutes": 20 + }, + { + "day_number": 2, + "title": "第一章 - 人类:一种也没什么特别的动物", + "reading_range": "P16-P32", + "estimated_minutes": 25 + } + ] + } + } +} +``` + +### 5.3 用户阅读(需登录) + +#### 开始阅读一本书 +``` +POST /api/reading/books/:id/start + +Response: +{ + "code": 0, + "data": { + "user_book_id": 1, + "book_id": 1, + "book_title": "人类简史", + "current_day": 0, + "total_days": 30, + "status": "reading", + "started_at": "2026-03-21T10:00:00+08:00" + } +} +``` + +**业务规则**: +- 同一本书不能重复开始(返回 409) +- 同时只能在读一本书(可配置,MVP 先限制 1 本) + +#### 获取我的阅读列表 +``` +GET /api/reading/my/books +Query: + - status: string (reading/completed/dropped/all,默认 all) + +Response: +{ + "code": 0, + "data": { + "items": [ + { + "user_book_id": 1, + "book_id": 1, + "book_title": "人类简史", + "book_cover": "https://...", + "author": "尤瓦尔·赫拉利", + "current_day": 5, + "total_days": 30, + "progress_percent": 17, + "status": "reading", + "started_at": "2026-03-15T10:00:00+08:00" + } + ] + } +} +``` + +#### 获取某本书的阅读进度详情 +``` +GET /api/reading/my/books/:user_book_id + +Response: +{ + "code": 0, + "data": { + "user_book": { + "user_book_id": 1, + "book_id": 1, + "book_title": "人类简史", + "author": "尤瓦尔·赫拉利", + "current_day": 5, + "total_days": 30, + "status": "reading" + }, + "checkin_history": [ + { + "day_number": 5, + "date": "2026-03-20", + "title": "第三章 农业革命", + "estimated_minutes": 25, + "has_reflection": true, + "checked_in_at": "2026-03-20T21:30:00+08:00" + } + ] + } +} +``` + +### 5.4 每日打卡(需登录) + +#### 获取今日阅读任务 +``` +GET /api/reading/my/books/:user_book_id/today + +Response: +{ + "code": 0, + "data": { + "daily_status": "pending", // pending / checked_in + "day_number": 6, + "total_days": 30, + "plan_day": { + "id": 6, + "title": "第三章 农业革命(续)", + "guide_content": "上一节我们了解了农业革命的起源...", + "reading_range": "P63-P80", + "estimated_minutes": 25, + "questions": [ + { + "q": "农业社会带来了哪些社会结构的变化?", + "hint": "可以从私有制、阶级分化等角度思考" + }, + { + "q": "作者认为小麦'驯化'了人类,你怎么理解?", + "hint": "" + } + ] + }, + "checkin": null // 已打卡时返回打卡详情 + } +} +``` + +#### 提交今日打卡 +``` +POST /api/reading/my/books/:user_book_id/checkin +Body: +{ + "answers": [ + { + "q": "农业社会带来了哪些社会结构的变化?", + "a": "农业带来了定居生活,随之产生了私有制和社会阶级分化..." + }, + { + "q": "作者认为小麦'驯化'了人类,你怎么理解?", + "a": "从小麦的角度看,它成功地让人类为它服务..." + } + ], + "reflection": "今天读到农业革命的部分很有启发,原来我们以为的进步..." +} + +Response: +{ + "code": 0, + "data": { + "checkin_id": 10, + "day_number": 6, + "date": "2026-03-21", + "estimated_minutes": 25, + "status": "checked_in", + "checked_in_at": "2026-03-21T22:00:00+08:00", + "summary": { + "current_streak_days": 6, + "max_streak_days": 6, + "total_reading_minutes": 145, + "total_books_completed": 0, + "book_progress_percent": 20, + "is_book_completed": false + } + } +} +``` + +**业务规则**: +- 同一天同一本书只能打卡一次(返回 409) +- `answers` 数组至少包含 1 条非空回答 +- `reflection` 选填 +- 打卡成功后 `user_book.current_day` 自增 1 +- 若 `current_day == total_days`,自动将 `user_book.status` 更新为 `completed` +- 当天未打卡自然过期后标记为 `missed`(可通过定时任务或下次请求时回填) + +#### 获取打卡记录列表 +``` +GET /api/reading/my/books/:user_book_id/checkins +Query: + - page: int (默认 1) + - page_size: int (默认 20) + +Response: +{ + "code": 0, + "data": { + "items": [ + { + "id": 10, + "day_number": 6, + "date": "2026-03-21", + "title": "第三章 农业革命(续)", + "estimated_minutes": 25, + "answers_count": 2, + "has_reflection": true, + "checked_in_at": "2026-03-21T22:00:00+08:00" + } + ], + "total": 6, + "page": 1, + "page_size": 20 + } +} +``` + +### 5.5 读书感悟(需登录) + +#### 获取某本书的感悟列表 +``` +GET /api/reading/my/books/:user_book_id/reflections +Query: + - page: int (默认 1) + - page_size: int (默认 20) + +Response: +{ + "code": 0, + "data": { + "items": [ + { + "day_number": 6, + "date": "2026-03-21", + "title": "第三章 农业革命(续)", + "reflection": "今天读到农业革命的部分很有启发..." + } + ], + "total": 3, + "page": 1, + "page_size": 20 + } +} +``` + +### 5.6 阅读统计(需登录) + +#### 获取统计概览 +``` +GET /api/reading/my/stats + +Response: +{ + "code": 0, + "data": { + "current_streak_days": 6, + "max_streak_days": 6, + "total_reading_minutes": 145, + "total_books_completed": 0, + "total_checkin_days": 6, + "current_book": { + "book_title": "人类简史", + "progress_percent": 20, + "current_day": 6, + "total_days": 30 + }, + "recent_checkins": [ + { "date": "2026-03-21", "status": "checked_in", "estimated_minutes": 25 }, + { "date": "2026-03-20", "status": "checked_in", "estimated_minutes": 20 }, + { "date": "2026-03-19", "status": "checked_in", "estimated_minutes": 30 } + ] + } +} +``` + +### 5.7 分享海报(需登录) + +#### 获取海报预览数据 +``` +GET /api/reading/poster/data +Query: + - template_code: string (默认 "reading_1") + +Response: +{ + "code": 0, + "data": { + "nickname": "读书人", + "streak_days": 6, + "total_books_completed": 2, + "total_reading_minutes": 1450, + "current_book_title": "人类简史", + "current_book_progress": "6/30", + "template_code": "reading_1", + "share_title": "我已连续阅读打卡 6 天" + } +} +``` + +#### 生成分享海报 +``` +POST /api/reading/poster/generate +Body: +{ + "template_code": "reading_1", + "show_fields": ["streak_days", "total_books_completed", "total_reading_minutes"] +} + +Response: +{ + "code": 0, + "data": { + "template_code": "reading_1", + "image_url": "https://static.nepiedg.top/reading/posters/...", + "share_title": "我已连续阅读打卡 6 天" + } +} +``` + +### 5.8 管理后台 API + +| 方法 | 路径 | 说明 | +|------|------|------| +| **书单管理** | | | +| GET | `/api/admin/reading/book-lists` | 书单列表(支持分页) | +| POST | `/api/admin/reading/book-lists` | 创建书单 | +| PUT | `/api/admin/reading/book-lists/:id` | 更新书单 | +| DELETE | `/api/admin/reading/book-lists/:id` | 删除书单 | +| **书籍管理** | | | +| GET | `/api/admin/reading/books` | 书籍列表(支持按书单筛选) | +| POST | `/api/admin/reading/books` | 创建书籍 | +| PUT | `/api/admin/reading/books/:id` | 更新书籍 | +| DELETE | `/api/admin/reading/books/:id` | 删除书籍 | +| **阅读计划管理** | | | +| GET | `/api/admin/reading/plans/:book_id` | 获取书籍的阅读计划 | +| POST | `/api/admin/reading/plans/:book_id/generate` | AI 生成阅读计划 | +| PUT | `/api/admin/reading/plans/:plan_id` | 更新阅读计划状态 | +| **导读内容管理** | | | +| GET | `/api/admin/reading/plan-days?plan_id=X` | 获取某计划的所有导读 | +| PUT | `/api/admin/reading/plan-days/:id` | 编辑单日导读内容 | + +--- + +## 6. 核心业务流程 + +### 6.1 用户打卡流程 + +``` +用户进入"今日阅读"首页 + │ + ├── 未选书 → 展示空状态 → 引导去"书单"页选书 + │ │ + │ ↓ + │ 点击书籍 → 书籍详情页 + │ │ + │ ↓ + │ 点击"开始阅读" + │ │ + │ POST /books/:id/start + │ │ + │ ↓ + │ 返回首页(展示在读书籍) + │ + └── 有在读书 → GET /my/books/:id/today + │ + ├── 今日已打卡 (daily_status = checked_in) + │ → 展示"今日已完成 ✅" + │ → 展示打卡记录(时长、回答、感悟) + │ + └── 今日未打卡 (daily_status = pending) + → 展示今日导读内容 + → 展示 AI 预估阅读时长(如"预估 25 分钟") + → 用户阅读完成后: + │ + ├── 1) 回答理解问题(至少 1 题) + ├── 2) 写读书感悟(选填) + └── 3) 点击"完成打卡" + │ + POST /my/books/:id/checkin + │ + ├── 普通天 → 打卡成功 + │ → current_day + 1 + │ → 展示鼓励动画 + 连续天数 + │ + └── 最后一天 (current_day == total_days) + → user_book.status = completed + → 弹出"🎉 恭喜读完全书!" + → 引导生成分享宣传图 +``` + +### 6.2 AI 阅读计划生成流程(后台) + +``` +管理员在后台创建书籍 + │ + ↓ +点击"AI 生成阅读计划" + │ + ↓ +POST /admin/reading/plans/:book_id/generate + │ + ├── 输入给 AI: + │ - 书名、作者、简介、总页数 + │ - 期望计划天数(如 30 天) + │ + ├── AI 返回(结构化 JSON): + │ - 每日导读标题 (title) + │ - 导读正文 (guide_content) + │ - 阅读范围 (reading_range) + │ - 预估阅读时长 (estimated_minutes) + │ - 理解问题 (questions) + │ + ├── 写入 fa_reading_plan 表 + │ - ai_generated = true + │ - status = draft + │ + └── 写入 fa_reading_plan_day 表(逐日) + │ + ↓ +管理员逐日审核/编辑导读内容、时长、问题 + │ + ↓ +将计划状态改为 published → 前端可见 +``` + +**AI Prompt 参考**: +``` +你是一位资深阅读导师,请为以下书籍制定一个 {total_days} 天的阅读计划: + +书名:{title} +作者:{author} +简介:{description} +总页数:{total_pages} + +要求: +1. 将全书合理拆分为 {total_days} 天的阅读任务 +2. 每天包含:导读标题、导读正文(200-300字,引导读者关注重点)、阅读范围(页码)、预估阅读时长(分钟)、2-3 道理解问题 +3. 预估阅读时长应考虑该部分的难度和信息密度 +4. 理解问题应引导深度思考,而非简单的信息检索 + +请以 JSON 数组格式返回,每个元素包含: +{ + "day_number": 1, + "title": "导读标题", + "guide_content": "导读正文", + "reading_range": "P1-P15", + "estimated_minutes": 20, + "questions": [ + {"q": "问题内容", "hint": "思考提示"} + ] +} +``` + +### 6.3 分享宣传图流程 + +``` +用户点击"分享成就" + │ + ↓ +GET /reading/poster/data → 获取海报数据 + │ + ↓ +选择海报模板(多种风格) + │ + ↓ +POST /reading/poster/generate → 生成海报图片 + │ + ↓ +预览海报 + │ + ├── "保存到相册" → wx.saveImageToPhotosAlbum + └── "分享给好友" → open-type="share" +``` + +--- + +## 7. 后端模块结构 + +遵循现有 `wx_service` 的 Handler → Service → Model 分层架构: + +``` +wx_service/internal/reading/ +├── handler/ +│ └── handler.go # HTTP 接口处理(入参校验、调用 Service、返回 JSON) +├── model/ +│ ├── book_list.go # 书单模型 (fa_reading_book_list) +│ ├── book.go # 书籍模型 (fa_reading_book) +│ ├── reading_plan.go # 阅读计划模型 (fa_reading_plan) +│ ├── plan_day.go # 每日导读模型 (fa_reading_plan_day) +│ ├── user_book.go # 用户阅读进度模型 (fa_reading_user_book) +│ ├── daily_checkin.go # 每日打卡模型 (fa_reading_daily_checkin) +│ └── share.go # 分享记录模型 (fa_reading_share) +└── service/ + └── service.go # 业务逻辑(打卡、统计、计划生成、海报) +``` + +### 7.1 接入 main.go 的步骤 + +```go +// 1. 在 AutoMigrate 中注册所有 Model +database.AutoMigrate( + // ... 现有模型 ... + &readingmodel.BookList{}, + &readingmodel.Book{}, + &readingmodel.ReadingPlan{}, + &readingmodel.PlanDay{}, + &readingmodel.UserBook{}, + &readingmodel.DailyCheckin{}, + &readingmodel.Share{}, +) + +// 2. 创建 Service +readingService := readingservice.NewService(database.DB, config.AppConfig.AI) + +// 3. 创建 Handler +readingHandler := readinghandler.NewHandler(readingService) + +// 4. 传入 routes.Register 并注册路由 +``` + +### 7.2 路由注册 + +```go +// routes.go 中新增 +readingAPI := router.Group("/api/reading") +{ + // 公开接口 + readingAPI.GET("/book-lists", readingHandler.ListBookLists) + readingAPI.GET("/book-lists/:id/books", readingHandler.ListBooksByList) + readingAPI.GET("/books/:id", readingHandler.GetBookDetail) + + // 需登录接口 + readingProtected := readingAPI.Group("") + readingProtected.Use(middleware.AuthMiddleware(db, sessionCache)) + readingProtected.Use(middleware.RequireUserMiddleware()) + { + readingProtected.POST("/books/:id/start", readingHandler.StartReading) + readingProtected.GET("/my/books", readingHandler.ListMyBooks) + readingProtected.GET("/my/books/:id", readingHandler.GetMyBookDetail) + readingProtected.GET("/my/books/:id/today", readingHandler.GetTodayTask) + readingProtected.POST("/my/books/:id/checkin", readingHandler.Checkin) + readingProtected.GET("/my/books/:id/checkins", readingHandler.ListCheckins) + readingProtected.GET("/my/books/:id/reflections", readingHandler.ListReflections) + readingProtected.GET("/my/stats", readingHandler.Stats) + readingProtected.GET("/poster/data", readingHandler.PosterData) + readingProtected.POST("/poster/generate", readingHandler.GeneratePoster) + } +} +``` + +--- + +## 8. 前端项目结构 + +``` +apps/reading-checkin/ +├── src/ +│ ├── api/ +│ │ ├── auth.js # 微信登录(复用现有模式) +│ │ ├── request.js # 请求封装(Bearer Token + 401 重试) +│ │ ├── book.js # 书单/书籍相关 API +│ │ ├── checkin.js # 打卡相关 API +│ │ └── poster.js # 海报相关 API +│ ├── components/ +│ │ ├── book-card/ # 书籍卡片组件 +│ │ ├── checkin-calendar/ # 打卡日历组件 +│ │ ├── progress-bar/ # 阅读进度条 +│ │ └── question-card/ # 问题回答卡片 +│ ├── pages/ +│ │ ├── home/index.vue # 今日阅读(首页) +│ │ ├── book-lists/index.vue # 书单列表 +│ │ ├── book-detail/index.vue# 书籍详情 +│ │ ├── reading/index.vue # 阅读打卡页 +│ │ ├── stats/index.vue # 统计 +│ │ ├── profile/index.vue # 我的 +│ │ └── poster/index.vue # 分享海报 +│ ├── stores/ +│ │ ├── index.js # Pinia store 入口 +│ │ └── user.js # 用户状态 +│ ├── utils/ +│ │ ├── format.js # 时长、日期格式化 +│ │ └── storage.js # reading_checkin_ 前缀存储 +│ ├── config/ +│ │ └── index.js # mini_program_id, baseUrl 配置 +│ ├── App.vue # 应用入口(onLaunch 登录) +│ ├── main.js +│ ├── manifest.json +│ ├── pages.json # 页面路由 + TabBar 配置 +│ └── uni.scss # 全局样式 +├── package.json +└── vite.config.js +``` + +### 8.1 关键配置 + +**config/index.js**: +```javascript +const config = { + mini_program_id: '', + baseUrl: { + dev: 'http://localhost:8080/api', + prod: 'https://wx.nepiedg.top/api' + } +} +``` + +**utils/storage.js** 存储前缀: `reading_checkin_` + +--- + +## 9. 实施计划 + +| 阶段 | 内容 | 优先级 | +|------|------|--------| +| **P0: 后端基础** | Model 定义 + AutoMigrate + Service 骨架 + Handler + Routes 接入 | 高 | +| **P0: 前端脚手架** | 基于 quit-checkin 创建项目骨架、登录、请求封装 | 高 | +| **P1: 书单与书籍** | 后台 CRUD + 前端展示 | 高 | +| **P1: AI 阅读计划** | 后台 AI 生成 + 导读编辑 | 高 | +| **P1: 打卡核心流程** | 今日任务获取 + 回答问题 + 提交打卡 + 进度更新 | 高 | +| **P2: 统计页面** | 连续天数、日历、趋势图 | 中 | +| **P2: 分享海报** | 海报数据 + 模板 + 生成 | 中 | +| **P3: 优化体验** | 打卡动画、空状态引导、错误处理 | 低 | +| **P3: 联调测试** | 前后端联调 + 微信真机测试 | 低 | + +--- + +## 10. 与现有系统的关系 + +| 复用项 | 说明 | +|--------|------| +| 用户表 (`users`) | 共用,通过 `mini_program_id` 区分小程序 | +| 微信登录 (`AuthService`) | 完全复用 | +| 鉴权中间件 (`AuthMiddleware`) | 完全复用 | +| 七牛上传 (`QiniuService`) | 复用(书籍封面、海报图片上传) | +| Redis 缓存 (`SessionUserCache`) | 复用 | +| 管理后台框架 (`admin`) | 复用 JWT 鉴权和管理员体系 | +| AI 配置 (`config.AI`) | 复用 AI 调用配置(参照 smoke 模块的 AI 集成) | diff --git a/docs/reading/README.md b/docs/reading/README.md new file mode 100644 index 0000000..75e1ea4 --- /dev/null +++ b/docs/reading/README.md @@ -0,0 +1,19 @@ +# 阅读打卡模块文档 + +## 文档清单 + +- `PRD.md`:产品需求文档(数据模型、API 设计、业务流程、前后端结构) + +## 说明 + +- 小程序端接口前缀:`/api/reading` +- 管理后台接口前缀:`/api/admin/reading` +- 鉴权方式:`Authorization: Bearer ` +- 业务语义:书单浏览、阅读计划、每日打卡、读书感悟、分享海报 + +## 核心设计要点 + +1. **阅读时长由 AI 预估**:每日导读中的 `estimated_minutes` 由 AI 在生成阅读计划时自动预估,前端仅展示,用户无需手动输入 +2. **模块命名**:后端 `internal/reading/`,前端 `apps/reading-checkin/` +3. **表前缀**:`fa_reading_`,共 7 张表 +4. **存储前缀**:前端 localStorage 使用 `reading_checkin_` 前缀 From 0f624a5c0746dec1266402405d9dc663f721cd2f Mon Sep 17 00:00:00 2001 From: nepiedg Date: Thu, 2 Apr 2026 00:37:54 +0800 Subject: [PATCH 4/4] feat(auth): return smoke mode in login response --- .gitignore | 1 + go.mod | 12 ++++---- internal/common/auth/handler/auth_handler.go | 3 ++ internal/common/auth/service/auth_service.go | 31 ++++++++++++++++++++ 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 3ee7d37..047882e 100755 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ go.work # Local build binary wx_service +wx_service_api diff --git a/go.mod b/go.mod index e8addcb..af560a8 100755 --- a/go.mod +++ b/go.mod @@ -1,18 +1,23 @@ module wx_service -go 1.23.6 +go 1.23.0 + +toolchain go1.24.4 require ( + github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/gin-gonic/gin v1.11.0 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/joho/godotenv v1.5.1 github.com/redis/go-redis/v9 v9.17.2 + golang.org/x/crypto v0.40.0 gorm.io/driver/mysql v1.6.0 + gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.1 ) require ( filippo.io/edwards25519 v1.1.0 // indirect - github.com/DATA-DOG/go-sqlmock v1.5.2 // indirect github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -26,7 +31,6 @@ require ( github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-yaml v1.18.0 // indirect - github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -43,7 +47,6 @@ require ( github.com/ugorji/go/codec v1.3.0 // indirect go.uber.org/mock v0.5.0 // indirect golang.org/x/arch v0.20.0 // indirect - golang.org/x/crypto v0.40.0 // indirect golang.org/x/mod v0.25.0 // indirect golang.org/x/net v0.42.0 // indirect golang.org/x/sync v0.16.0 // indirect @@ -51,5 +54,4 @@ require ( golang.org/x/text v0.27.0 // indirect golang.org/x/tools v0.34.0 // indirect google.golang.org/protobuf v1.36.9 // indirect - gorm.io/driver/sqlite v1.6.0 // indirect ) diff --git a/internal/common/auth/handler/auth_handler.go b/internal/common/auth/handler/auth_handler.go index b4d9d1a..2beab96 100644 --- a/internal/common/auth/handler/auth_handler.go +++ b/internal/common/auth/handler/auth_handler.go @@ -88,6 +88,9 @@ func (h *AuthHandler) LoginWithWeChat(c *gin.Context) { if result.User.UnionID != "" { userPayload["union_id"] = result.User.UnionID } + if result.Mode != "" { + userPayload["mode"] = result.Mode + } miniProgramPayload := gin.H{ "id": result.MiniProgram.ID, diff --git a/internal/common/auth/service/auth_service.go b/internal/common/auth/service/auth_service.go index e135d2c..92353df 100644 --- a/internal/common/auth/service/auth_service.go +++ b/internal/common/auth/service/auth_service.go @@ -8,6 +8,7 @@ import ( "sync" "wx_service/internal/model" + smokemodel "wx_service/internal/smoke/model" "gorm.io/gorm" ) @@ -40,6 +41,7 @@ type LoginResult struct { User *model.User SessionKey string MiniProgram *model.MiniProgram + Mode string } func NewAuthService(db *gorm.DB, miniProgramSvc *MiniProgramService) *AuthService { @@ -139,9 +141,38 @@ func (s *AuthService) LoginWithCode(ctx context.Context, req LoginRequest) (*Log SessionKey: session.SessionKey, MiniProgram: miniProgram, } + if mode, err := s.getSmokeMode(ctx, int(user.ID)); err == nil { + result.Mode = mode + } return result, nil } +func (s *AuthService) getSmokeMode(ctx context.Context, uid int) (string, error) { + var profile smokemodel.SmokeUserProfile + err := s.db.WithContext(ctx). + Select("mode"). + Where("uid = ? AND deleted_at IS NULL", uid). + First(&profile).Error + if err == nil { + return normalizeSmokeMode(profile.Mode), nil + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", nil + } + return "", fmt.Errorf("load smoke profile mode: %w", err) +} + +func normalizeSmokeMode(mode string) string { + switch strings.TrimSpace(mode) { + case "quit": + return "quit" + case "record": + return "record" + default: + return "" + } +} + func (s *AuthService) getWeChatClient(mp *model.MiniProgram) *WeChatClient { s.cacheMu.RLock() client, ok := s.wechatClientCache[mp.ID]