Merge pull request #49 from hello-dd-code/feat/quitcheckin-v2-backend
feat(auth): return smoke mode in login response
This commit is contained in:
@@ -35,3 +35,4 @@ go.work
|
|||||||
|
|
||||||
# Local build binary
|
# Local build binary
|
||||||
wx_service
|
wx_service
|
||||||
|
wx_service_api
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ import (
|
|||||||
membershipservice "wx_service/internal/membership/service"
|
membershipservice "wx_service/internal/membership/service"
|
||||||
"wx_service/internal/model"
|
"wx_service/internal/model"
|
||||||
"wx_service/internal/observability"
|
"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"
|
rmhandler "wx_service/internal/remove_watermark/handler"
|
||||||
rmmodel "wx_service/internal/remove_watermark/model"
|
rmmodel "wx_service/internal/remove_watermark/model"
|
||||||
rmservice "wx_service/internal/remove_watermark/service"
|
rmservice "wx_service/internal/remove_watermark/service"
|
||||||
@@ -79,6 +82,10 @@ func main() {
|
|||||||
&marketingmodel.MarketingCategory{},
|
&marketingmodel.MarketingCategory{},
|
||||||
&marketingmodel.MarketingTemplate{},
|
&marketingmodel.MarketingTemplate{},
|
||||||
&marketingmodel.MarketingDownload{},
|
&marketingmodel.MarketingDownload{},
|
||||||
|
&quitcheckinmodel.Profile{},
|
||||||
|
&quitcheckinmodel.DailyStatus{},
|
||||||
|
&quitcheckinmodel.RelapseEvent{},
|
||||||
|
&quitcheckinmodel.RewardGoal{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
log.Fatalf("auto migrate failed: %v", err)
|
log.Fatalf("auto migrate failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -110,6 +117,8 @@ func main() {
|
|||||||
smokeQuitPlanService := smokeservice.NewSmokeQuitPlanService(database.DB, config.AppConfig.AI)
|
smokeQuitPlanService := smokeservice.NewSmokeQuitPlanService(database.DB, config.AppConfig.AI)
|
||||||
smokeHandler := smokehandler.NewSmokeHandler(smokeLogService, smokeAIAdviceService, smokeProfileService, smokeNextService, smokeAINextService, smokeShareService)
|
smokeHandler := smokehandler.NewSmokeHandler(smokeLogService, smokeAIAdviceService, smokeProfileService, smokeNextService, smokeAINextService, smokeShareService)
|
||||||
quitPlanHandler := smokehandler.NewQuitPlanHandler(smokeQuitPlanService)
|
quitPlanHandler := smokehandler.NewQuitPlanHandler(smokeQuitPlanService)
|
||||||
|
quitCheckinService := quitcheckinservice.NewService(database.DB)
|
||||||
|
quitCheckinHandler := quitcheckinhandler.NewHandler(quitCheckinService)
|
||||||
|
|
||||||
redeemCodeService := membershipservice.NewRedeemCodeService(database.DB, config.AppConfig.Admin.Token)
|
redeemCodeService := membershipservice.NewRedeemCodeService(database.DB, config.AppConfig.Admin.Token)
|
||||||
redeemCodeHandler := membershiphandler.NewRedeemCodeHandler(redeemCodeService)
|
redeemCodeHandler := membershiphandler.NewRedeemCodeHandler(redeemCodeService)
|
||||||
@@ -185,6 +194,7 @@ func main() {
|
|||||||
marketingCategoryHandler,
|
marketingCategoryHandler,
|
||||||
marketingTemplateHandler,
|
marketingTemplateHandler,
|
||||||
marketingDownloadHandler,
|
marketingDownloadHandler,
|
||||||
|
quitCheckinHandler,
|
||||||
)
|
)
|
||||||
|
|
||||||
// 7) 启动监听端口
|
// 7) 启动监听端口
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# 无烟打卡 V2 文档
|
||||||
|
|
||||||
|
## 文档清单
|
||||||
|
|
||||||
|
- `swagger.yaml`:OpenAPI 3.0 接口文档
|
||||||
|
|
||||||
|
## 说明
|
||||||
|
|
||||||
|
- 接口前缀:`/api/v2`
|
||||||
|
- 鉴权方式:`Authorization: Bearer <session_key>`
|
||||||
|
- 业务语义:无烟打卡、复吸、梦想目标、统计与海报
|
||||||
@@ -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"
|
||||||
+1089
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
|||||||
|
# 阅读打卡模块文档
|
||||||
|
|
||||||
|
## 文档清单
|
||||||
|
|
||||||
|
- `PRD.md`:产品需求文档(数据模型、API 设计、业务流程、前后端结构)
|
||||||
|
|
||||||
|
## 说明
|
||||||
|
|
||||||
|
- 小程序端接口前缀:`/api/reading`
|
||||||
|
- 管理后台接口前缀:`/api/admin/reading`
|
||||||
|
- 鉴权方式:`Authorization: Bearer <session_key>`
|
||||||
|
- 业务语义:书单浏览、阅读计划、每日打卡、读书感悟、分享海报
|
||||||
|
|
||||||
|
## 核心设计要点
|
||||||
|
|
||||||
|
1. **阅读时长由 AI 预估**:每日导读中的 `estimated_minutes` 由 AI 在生成阅读计划时自动预估,前端仅展示,用户无需手动输入
|
||||||
|
2. **模块命名**:后端 `internal/reading/`,前端 `apps/reading-checkin/`
|
||||||
|
3. **表前缀**:`fa_reading_`,共 7 张表
|
||||||
|
4. **存储前缀**:前端 localStorage 使用 `reading_checkin_` 前缀
|
||||||
@@ -391,6 +391,7 @@ curl -X GET 'http://127.0.0.1:8080/api/v1/smoke/logs/5202' \
|
|||||||
"pack_price_cent": 2500,
|
"pack_price_cent": 2500,
|
||||||
"smoke_motivations": ["压力大", "社交"],
|
"smoke_motivations": ["压力大", "社交"],
|
||||||
"quit_motivations": ["身体健康", "省钱"],
|
"quit_motivations": ["身体健康", "省钱"],
|
||||||
|
"mode": "record",
|
||||||
"wake_up_time": "07:30",
|
"wake_up_time": "07:30",
|
||||||
"sleep_time": "23:30",
|
"sleep_time": "23:30",
|
||||||
"quit_date": "2026-02-28T00:00:00+08:00",
|
"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`(单包价格/分):用于看板计算“已省金额”和“恢复时长”等指标(公式可在看板端实现)。
|
- `smoking_years`(烟龄/年)+ `pack_price_cent`(单包价格/分):用于看板计算“已省金额”和“恢复时长”等指标(公式可在看板端实现)。
|
||||||
- `smoke_motivations`(抽烟动机):如 `压力大/无聊/社交/提神`,用于 AI 在分析 remark 时更有针对性。
|
- `smoke_motivations`(抽烟动机):如 `压力大/无聊/社交/提神`,用于 AI 在分析 remark 时更有针对性。
|
||||||
- `quit_motivations`(戒烟动力):如 `身体健康/家人孩子/省钱`,当用户产生动摇时 AI 可用这些信息做“情感阻断/自我提醒”。
|
- `quit_motivations`(戒烟动力):如 `身体健康/家人孩子/省钱`,当用户产生动摇时 AI 可用这些信息做“情感阻断/自我提醒”。
|
||||||
|
- `mode`(使用模式):`quit` 表示戒烟打卡模式,`record` 表示记录抽烟模式。
|
||||||
- `wake_up_time` + `sleep_time`(作息时间):用于自动规避睡眠时间,防止在用户睡觉时提醒其“坚持”。
|
- `wake_up_time` + `sleep_time`(作息时间):用于自动规避睡眠时间,防止在用户睡觉时提醒其“坚持”。
|
||||||
- `quit_date`(目标戒烟日期):用于阶段规划或到期提醒。
|
- `quit_date`(目标戒烟日期):用于阶段规划或到期提醒。
|
||||||
|
|
||||||
@@ -444,6 +446,7 @@ curl -X GET 'http://127.0.0.1:8080/api/v1/smoke/logs/5202' \
|
|||||||
"pack_price_cent": 2500,
|
"pack_price_cent": 2500,
|
||||||
"smoke_motivations": ["压力大", "社交"],
|
"smoke_motivations": ["压力大", "社交"],
|
||||||
"quit_motivations": ["身体健康", "省钱"],
|
"quit_motivations": ["身体健康", "省钱"],
|
||||||
|
"mode": "record",
|
||||||
"wake_up_time": "07:30",
|
"wake_up_time": "07:30",
|
||||||
"sleep_time": "23:30",
|
"sleep_time": "23:30",
|
||||||
"quit_date": "2026-02-28"
|
"quit_date": "2026-02-28"
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ CREATE TABLE IF NOT EXISTS `fa_smoke_user_profile` (
|
|||||||
`pack_price_cent` int NOT NULL DEFAULT 0 COMMENT '单包价格(分)',
|
`pack_price_cent` int NOT NULL DEFAULT 0 COMMENT '单包价格(分)',
|
||||||
`smoke_motivations` json DEFAULT NULL COMMENT '抽烟动机(JSON数组)',
|
`smoke_motivations` json DEFAULT NULL COMMENT '抽烟动机(JSON数组)',
|
||||||
`quit_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)',
|
`wake_up_time` varchar(5) NOT NULL DEFAULT '' COMMENT '起床时间(HH:MM)',
|
||||||
`sleep_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 '首次补全完成时间',
|
`onboarding_completed_at` datetime(3) DEFAULT NULL COMMENT '首次补全完成时间',
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
module wx_service
|
module wx_service
|
||||||
|
|
||||||
go 1.23.6
|
go 1.23.0
|
||||||
|
|
||||||
|
toolchain go1.24.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2
|
||||||
github.com/gin-gonic/gin v1.11.0
|
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/joho/godotenv v1.5.1
|
||||||
github.com/redis/go-redis/v9 v9.17.2
|
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/mysql v1.6.0
|
||||||
|
gorm.io/driver/sqlite v1.6.0
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
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 v1.14.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.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/go-sql-driver/mysql v1.8.1 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
github.com/goccy/go-yaml v1.18.0 // 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/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
@@ -43,7 +47,6 @@ require (
|
|||||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||||
go.uber.org/mock v0.5.0 // indirect
|
go.uber.org/mock v0.5.0 // indirect
|
||||||
golang.org/x/arch v0.20.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/mod v0.25.0 // indirect
|
||||||
golang.org/x/net v0.42.0 // indirect
|
golang.org/x/net v0.42.0 // indirect
|
||||||
golang.org/x/sync v0.16.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/text v0.27.0 // indirect
|
||||||
golang.org/x/tools v0.34.0 // indirect
|
golang.org/x/tools v0.34.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.9 // indirect
|
google.golang.org/protobuf v1.36.9 // indirect
|
||||||
gorm.io/driver/sqlite v1.6.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ func (h *AuthHandler) LoginWithWeChat(c *gin.Context) {
|
|||||||
if result.User.UnionID != "" {
|
if result.User.UnionID != "" {
|
||||||
userPayload["union_id"] = result.User.UnionID
|
userPayload["union_id"] = result.User.UnionID
|
||||||
}
|
}
|
||||||
|
if result.Mode != "" {
|
||||||
|
userPayload["mode"] = result.Mode
|
||||||
|
}
|
||||||
|
|
||||||
miniProgramPayload := gin.H{
|
miniProgramPayload := gin.H{
|
||||||
"id": result.MiniProgram.ID,
|
"id": result.MiniProgram.ID,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"wx_service/internal/model"
|
"wx_service/internal/model"
|
||||||
|
smokemodel "wx_service/internal/smoke/model"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -40,6 +41,7 @@ type LoginResult struct {
|
|||||||
User *model.User
|
User *model.User
|
||||||
SessionKey string
|
SessionKey string
|
||||||
MiniProgram *model.MiniProgram
|
MiniProgram *model.MiniProgram
|
||||||
|
Mode string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAuthService(db *gorm.DB, miniProgramSvc *MiniProgramService) *AuthService {
|
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,
|
SessionKey: session.SessionKey,
|
||||||
MiniProgram: miniProgram,
|
MiniProgram: miniProgram,
|
||||||
}
|
}
|
||||||
|
if mode, err := s.getSmokeMode(ctx, int(user.ID)); err == nil {
|
||||||
|
result.Mode = mode
|
||||||
|
}
|
||||||
return result, nil
|
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 {
|
func (s *AuthService) getWeChatClient(mp *model.MiniProgram) *WeChatClient {
|
||||||
s.cacheMu.RLock()
|
s.cacheMu.RLock()
|
||||||
client, ok := s.wechatClientCache[mp.ID]
|
client, ok := s.wechatClientCache[mp.ID]
|
||||||
|
|||||||
@@ -0,0 +1,510 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"wx_service/internal/middleware"
|
||||||
|
"wx_service/internal/model"
|
||||||
|
quitservice "wx_service/internal/quitcheckin/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
const dateLayout = "2006-01-02"
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
service *quitservice.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler 创建无烟打卡 V2 接口处理器。
|
||||||
|
func NewHandler(service *quitservice.Service) *Handler {
|
||||||
|
return &Handler{service: service}
|
||||||
|
}
|
||||||
|
|
||||||
|
type upsertProfileRequest struct {
|
||||||
|
QuitStartDate *string `json:"quit_start_date"`
|
||||||
|
PackPriceCent *int `json:"pack_price_cent"`
|
||||||
|
BaselineCigsPerDay *int `json:"baseline_cigs_per_day"`
|
||||||
|
Motivation *string `json:"motivation"`
|
||||||
|
NotifyTime *string `json:"notify_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProfile 获取当前用户的 V2 基础资料与旅程摘要。
|
||||||
|
// @Summary 获取无烟打卡资料
|
||||||
|
// @Description 返回用户基础资料与当前旅程概览,用于“我的”页面首屏展示。
|
||||||
|
// @Tags QuitCheckin
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} model.Response
|
||||||
|
// @Failure 500 {object} model.Response
|
||||||
|
// @Router /api/v2/profile [get]
|
||||||
|
func (h *Handler) GetProfile(c *gin.Context) {
|
||||||
|
user := middleware.MustCurrentUser(c)
|
||||||
|
view, err := h.service.GetProfile(c.Request.Context(), int(user.ID), user.NickName, user.AvatarURL, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取资料失败,请稍后重试"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, model.Success(view))
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertProfile 新增或更新无烟打卡资料。
|
||||||
|
// @Summary 保存无烟打卡资料
|
||||||
|
// @Description 保存戒烟开始日期、戒烟前日均支数、每包单价、初心和提醒时间。
|
||||||
|
// @Tags QuitCheckin
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param body body upsertProfileRequest true "无烟打卡资料"
|
||||||
|
// @Success 200 {object} model.Response
|
||||||
|
// @Failure 400 {object} model.Response
|
||||||
|
// @Failure 500 {object} model.Response
|
||||||
|
// @Router /api/v2/profile [post]
|
||||||
|
func (h *Handler) UpsertProfile(c *gin.Context) {
|
||||||
|
user := middleware.MustCurrentUser(c)
|
||||||
|
|
||||||
|
var req upsertProfileRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请求参数错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var quitStartDate *time.Time
|
||||||
|
if req.QuitStartDate != nil {
|
||||||
|
parsed, err := time.ParseInLocation(dateLayout, strings.TrimSpace(*req.QuitStartDate), time.Local)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "quit_start_date 格式错误,应为 YYYY-MM-DD"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
quitStartDate = &parsed
|
||||||
|
}
|
||||||
|
if req.BaselineCigsPerDay != nil && (*req.BaselineCigsPerDay <= 0 || *req.BaselineCigsPerDay > 200) {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "baseline_cigs_per_day 应在 1~200"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.PackPriceCent != nil && (*req.PackPriceCent < 0 || *req.PackPriceCent > 999999) {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "pack_price_cent 应在 0~999999"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.NotifyTime != nil && !quitservice.ParseNotifyTime(*req.NotifyTime) {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "notify_time 格式错误,应为 HH:MM"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
view, err := h.service.UpsertProfile(c.Request.Context(), int(user.ID), quitservice.UpsertProfileRequest{
|
||||||
|
QuitStartDate: quitStartDate,
|
||||||
|
PackPriceCent: req.PackPriceCent,
|
||||||
|
BaselineCigsPerDay: req.BaselineCigsPerDay,
|
||||||
|
Motivation: req.Motivation,
|
||||||
|
NotifyTime: req.NotifyTime,
|
||||||
|
}, user.NickName, user.AvatarURL, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "保存资料失败,请稍后重试"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, model.Success(view))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Home 获取首页聚合数据。
|
||||||
|
// @Summary 获取首页数据
|
||||||
|
// @Description 返回今日状态、连续天数、成果摘要、梦想目标和勋章计数。
|
||||||
|
// @Tags QuitCheckin
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} model.Response
|
||||||
|
// @Failure 400 {object} model.Response
|
||||||
|
// @Failure 500 {object} model.Response
|
||||||
|
// @Router /api/v2/checkin/home [get]
|
||||||
|
func (h *Handler) Home(c *gin.Context) {
|
||||||
|
user := middleware.MustCurrentUser(c)
|
||||||
|
view, err := h.service.Home(c.Request.Context(), int(user.ID), time.Now())
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, quitservice.ErrProfileNotFound) {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请先完善基础资料"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取首页数据失败,请稍后重试"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, model.Success(view))
|
||||||
|
}
|
||||||
|
|
||||||
|
type checkinRequest struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checkin 执行当日打卡。
|
||||||
|
// @Summary 今日打卡
|
||||||
|
// @Description 同一自然日只允许一次成功打卡;若当日已复吸则不允许再次打卡。
|
||||||
|
// @Tags QuitCheckin
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param body body checkinRequest true "打卡请求"
|
||||||
|
// @Success 200 {object} model.Response
|
||||||
|
// @Failure 400 {object} model.Response
|
||||||
|
// @Failure 409 {object} model.Response
|
||||||
|
// @Failure 500 {object} model.Response
|
||||||
|
// @Router /api/v2/checkin/check [post]
|
||||||
|
func (h *Handler) Checkin(c *gin.Context) {
|
||||||
|
user := middleware.MustCurrentUser(c)
|
||||||
|
|
||||||
|
var req checkinRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请求参数错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targetDate := time.Now()
|
||||||
|
if strings.TrimSpace(req.Date) != "" {
|
||||||
|
parsed, err := time.ParseInLocation(dateLayout, strings.TrimSpace(req.Date), time.Local)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "date 格式错误,应为 YYYY-MM-DD"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
targetDate = parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.service.Checkin(c.Request.Context(), int(user.ID), quitservice.CheckinRequest{
|
||||||
|
Date: targetDate,
|
||||||
|
Note: req.Note,
|
||||||
|
}, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, quitservice.ErrProfileNotFound) {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请先完善基础资料"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, quitservice.ErrAlreadyRelapsed) {
|
||||||
|
c.JSON(http.StatusConflict, model.Error(http.StatusConflict, "今天已记录复吸,无法再次打卡"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "打卡失败,请稍后重试"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, model.Success(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
type relapseRequest struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
RelapseAt string `json:"relapse_at"`
|
||||||
|
RelapseNum int `json:"relapse_num"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relapse 记录当日复吸。
|
||||||
|
// @Summary 记录复吸
|
||||||
|
// @Description 当日复吸会将连续天数清零,并更新当日状态为 relapsed。
|
||||||
|
// @Tags QuitCheckin
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param body body relapseRequest true "复吸请求"
|
||||||
|
// @Success 200 {object} model.Response
|
||||||
|
// @Failure 400 {object} model.Response
|
||||||
|
// @Failure 409 {object} model.Response
|
||||||
|
// @Failure 500 {object} model.Response
|
||||||
|
// @Router /api/v2/checkin/relapse [post]
|
||||||
|
func (h *Handler) Relapse(c *gin.Context) {
|
||||||
|
user := middleware.MustCurrentUser(c)
|
||||||
|
|
||||||
|
var req relapseRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请求参数错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.RelapseNum <= 0 || req.RelapseNum > 200 {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "relapse_num 应在 1~200"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targetDate := time.Now()
|
||||||
|
if strings.TrimSpace(req.Date) != "" {
|
||||||
|
parsed, err := time.ParseInLocation(dateLayout, strings.TrimSpace(req.Date), time.Local)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "date 格式错误,应为 YYYY-MM-DD"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
targetDate = parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
relapseAt := time.Now()
|
||||||
|
if strings.TrimSpace(req.RelapseAt) != "" {
|
||||||
|
parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(req.RelapseAt))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "relapse_at 格式错误,应为 RFC3339"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
relapseAt = parsed.In(time.Local)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.service.Relapse(c.Request.Context(), int(user.ID), quitservice.RelapseRequest{
|
||||||
|
Date: targetDate,
|
||||||
|
RelapseAt: relapseAt,
|
||||||
|
RelapseNum: req.RelapseNum,
|
||||||
|
Reason: req.Reason,
|
||||||
|
Note: req.Note,
|
||||||
|
}, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, quitservice.ErrProfileNotFound) {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请先完善基础资料"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, quitservice.ErrAlreadyRelapsed) {
|
||||||
|
c.JSON(http.StatusConflict, model.Error(http.StatusConflict, "今天已记录复吸,请勿重复提交"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "记录复吸失败,请稍后重试"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, model.Success(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatsOverview 获取统计概览。
|
||||||
|
// @Summary 获取统计概览
|
||||||
|
// @Description 支持周、月、年三个维度,返回趋势、勋章和健康恢复摘要。
|
||||||
|
// @Tags QuitCheckin
|
||||||
|
// @Produce json
|
||||||
|
// @Param range query string false "统计范围:week|month|year"
|
||||||
|
// @Success 200 {object} model.Response
|
||||||
|
// @Failure 400 {object} model.Response
|
||||||
|
// @Failure 500 {object} model.Response
|
||||||
|
// @Router /api/v2/stats/overview [get]
|
||||||
|
func (h *Handler) StatsOverview(c *gin.Context) {
|
||||||
|
user := middleware.MustCurrentUser(c)
|
||||||
|
result, err := h.service.StatsOverview(c.Request.Context(), int(user.ID), c.DefaultQuery("range", "week"), time.Now())
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, quitservice.ErrProfileNotFound) {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请先完善基础资料"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, quitservice.ErrInvalidRange) {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "range 应为 week|month|year"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取统计失败,请稍后重试"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, model.Success(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListBadges 获取勋章列表。
|
||||||
|
// @Summary 获取勋章列表
|
||||||
|
// @Description 返回当前用户的已解锁/未解锁勋章清单。
|
||||||
|
// @Tags QuitCheckin
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} model.Response
|
||||||
|
// @Failure 500 {object} model.Response
|
||||||
|
// @Router /api/v2/badges [get]
|
||||||
|
func (h *Handler) ListBadges(c *gin.Context) {
|
||||||
|
user := middleware.MustCurrentUser(c)
|
||||||
|
result, err := h.service.ListBadges(c.Request.Context(), int(user.ID), time.Now())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取勋章失败,请稍后重试"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, model.Success(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRelapses 获取复吸历史列表。
|
||||||
|
// @Summary 获取复吸历史
|
||||||
|
// @Description 分页返回复吸记录,用于“统计与荣誉”页历史列表展示。
|
||||||
|
// @Tags QuitCheckin
|
||||||
|
// @Produce json
|
||||||
|
// @Param page query int false "页码"
|
||||||
|
// @Param page_size query int false "每页数量"
|
||||||
|
// @Success 200 {object} model.Response
|
||||||
|
// @Failure 500 {object} model.Response
|
||||||
|
// @Router /api/v2/relapses [get]
|
||||||
|
func (h *Handler) ListRelapses(c *gin.Context) {
|
||||||
|
user := middleware.MustCurrentUser(c)
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
result, err := h.service.ListRelapses(c.Request.Context(), int(user.ID), page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取复吸记录失败,请稍后重试"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, model.Success(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRewardGoals 获取梦想目标列表。
|
||||||
|
// @Summary 获取梦想目标列表
|
||||||
|
// @Description 按状态筛选梦想目标,用于梦想实验室页面展示。
|
||||||
|
// @Tags QuitCheckin
|
||||||
|
// @Produce json
|
||||||
|
// @Param status query string false "状态:active|completed|archived|all"
|
||||||
|
// @Success 200 {object} model.Response
|
||||||
|
// @Failure 500 {object} model.Response
|
||||||
|
// @Router /api/v2/reward-goals [get]
|
||||||
|
func (h *Handler) ListRewardGoals(c *gin.Context) {
|
||||||
|
user := middleware.MustCurrentUser(c)
|
||||||
|
result, err := h.service.ListRewardGoals(c.Request.Context(), int(user.ID), c.DefaultQuery("status", "active"), time.Now())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取梦想目标失败,请稍后重试"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, model.Success(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
type createRewardGoalRequest struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
TargetAmountCent int `json:"target_amount_cent"`
|
||||||
|
CoverImage string `json:"cover_image"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRewardGoal 创建梦想目标。
|
||||||
|
// @Summary 创建梦想目标
|
||||||
|
// @Description 创建一条新的财富奖励目标。
|
||||||
|
// @Tags QuitCheckin
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param body body createRewardGoalRequest true "梦想目标"
|
||||||
|
// @Success 200 {object} model.Response
|
||||||
|
// @Failure 400 {object} model.Response
|
||||||
|
// @Failure 500 {object} model.Response
|
||||||
|
// @Router /api/v2/reward-goals [post]
|
||||||
|
func (h *Handler) CreateRewardGoal(c *gin.Context) {
|
||||||
|
user := middleware.MustCurrentUser(c)
|
||||||
|
var req createRewardGoalRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请求参数错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Title) == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "title 不能为空"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.TargetAmountCent <= 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "target_amount_cent 必须大于 0"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.service.CreateRewardGoal(c.Request.Context(), int(user.ID), quitservice.CreateRewardGoalRequest{
|
||||||
|
Title: req.Title,
|
||||||
|
TargetAmountCent: req.TargetAmountCent,
|
||||||
|
CoverImage: req.CoverImage,
|
||||||
|
}, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "创建梦想目标失败,请稍后重试"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, model.Success(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateRewardGoalRequest struct {
|
||||||
|
Title *string `json:"title"`
|
||||||
|
TargetAmountCent *int `json:"target_amount_cent"`
|
||||||
|
CoverImage *string `json:"cover_image"`
|
||||||
|
Status *string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRewardGoal 更新梦想目标。
|
||||||
|
// @Summary 更新梦想目标
|
||||||
|
// @Description 支持更新标题、目标金额、封面和状态。
|
||||||
|
// @Tags QuitCheckin
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path int true "目标 ID"
|
||||||
|
// @Param body body updateRewardGoalRequest true "更新内容"
|
||||||
|
// @Success 200 {object} model.Response
|
||||||
|
// @Failure 400 {object} model.Response
|
||||||
|
// @Failure 404 {object} model.Response
|
||||||
|
// @Failure 500 {object} model.Response
|
||||||
|
// @Router /api/v2/reward-goals/{id} [put]
|
||||||
|
func (h *Handler) UpdateRewardGoal(c *gin.Context) {
|
||||||
|
user := middleware.MustCurrentUser(c)
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "id 参数错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req updateRewardGoalRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请求参数错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.service.UpdateRewardGoal(c.Request.Context(), int(user.ID), id, quitservice.UpdateRewardGoalRequest{
|
||||||
|
Title: req.Title,
|
||||||
|
TargetAmountCent: req.TargetAmountCent,
|
||||||
|
CoverImage: req.CoverImage,
|
||||||
|
Status: req.Status,
|
||||||
|
}, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, quitservice.ErrRewardGoalNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "梦想目标不存在"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "更新梦想目标失败,请稍后重试"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, model.Success(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
// PosterData 获取海报预览数据。
|
||||||
|
// @Summary 获取海报数据
|
||||||
|
// @Description 返回海报预览所需的连续天数、已省金额和健康恢复信息。
|
||||||
|
// @Tags QuitCheckin
|
||||||
|
// @Produce json
|
||||||
|
// @Param template_code query string false "模板编码"
|
||||||
|
// @Success 200 {object} model.Response
|
||||||
|
// @Failure 400 {object} model.Response
|
||||||
|
// @Failure 500 {object} model.Response
|
||||||
|
// @Router /api/v2/poster/data [get]
|
||||||
|
func (h *Handler) PosterData(c *gin.Context) {
|
||||||
|
user := middleware.MustCurrentUser(c)
|
||||||
|
result, err := h.service.PosterData(c.Request.Context(), int(user.ID), user.NickName, c.DefaultQuery("template_code", "vibrant_1"), time.Now())
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, quitservice.ErrProfileNotFound) {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请先完善基础资料"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取海报数据失败,请稍后重试"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, model.Success(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
type generatePosterRequest struct {
|
||||||
|
TemplateCode string `json:"template_code"`
|
||||||
|
ShowFields []string `json:"show_fields"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GeneratePoster 生成海报结果。
|
||||||
|
// @Summary 生成海报
|
||||||
|
// @Description 当前先返回可用的海报元数据,后续可替换为真实图片生成链路。
|
||||||
|
// @Tags QuitCheckin
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param body body generatePosterRequest true "海报生成请求"
|
||||||
|
// @Success 200 {object} model.Response
|
||||||
|
// @Failure 400 {object} model.Response
|
||||||
|
// @Failure 500 {object} model.Response
|
||||||
|
// @Router /api/v2/poster/generate [post]
|
||||||
|
func (h *Handler) GeneratePoster(c *gin.Context) {
|
||||||
|
user := middleware.MustCurrentUser(c)
|
||||||
|
|
||||||
|
var req generatePosterRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请求参数错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.service.GeneratePoster(c.Request.Context(), int(user.ID), user.NickName, quitservice.PosterGenerateRequest{
|
||||||
|
TemplateCode: req.TemplateCode,
|
||||||
|
ShowFields: req.ShowFields,
|
||||||
|
}, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, quitservice.ErrProfileNotFound) {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请先完善基础资料"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "海报生成失败,请稍后重试"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, model.Success(result))
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 每日状态枚举值。
|
||||||
|
const (
|
||||||
|
DailyStatusPending = "pending"
|
||||||
|
DailyStatusCheckedIn = "checked_in"
|
||||||
|
DailyStatusRelapsed = "relapsed"
|
||||||
|
DailyStatusMissed = "missed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DailyStatus 表示用户在某一自然日的打卡或复吸状态。
|
||||||
|
type DailyStatus struct {
|
||||||
|
ID uint `gorm:"primaryKey;comment:主键" json:"id"`
|
||||||
|
CreatedAt time.Time `gorm:"comment:创建时间" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `gorm:"comment:更新时间" json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"-"`
|
||||||
|
|
||||||
|
UID int `gorm:"uniqueIndex:idx_quit_checkin_uid_date;comment:用户ID" json:"-"`
|
||||||
|
|
||||||
|
Date time.Time `gorm:"uniqueIndex:idx_quit_checkin_uid_date;type:date;comment:自然日" json:"date"`
|
||||||
|
Status string `gorm:"size:32;index;comment:状态" json:"status"`
|
||||||
|
CheckInAt *time.Time `gorm:"column:check_in_at;comment:打卡时间" json:"check_in_at,omitempty"`
|
||||||
|
RelapsedAt *time.Time `gorm:"column:relapsed_at;comment:复吸时间" json:"relapsed_at,omitempty"`
|
||||||
|
RelapseNum int `gorm:"column:relapse_num;comment:复吸支数" json:"relapse_num"`
|
||||||
|
Reason string `gorm:"column:reason;size:64;comment:复吸原因" json:"reason,omitempty"`
|
||||||
|
Note string `gorm:"column:note;size:200;comment:备注" json:"note,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 返回每日状态表名。
|
||||||
|
func (DailyStatus) TableName() string {
|
||||||
|
return "fa_quit_checkin_daily_status"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableComment 返回每日状态表注释。
|
||||||
|
func (DailyStatus) TableComment() string {
|
||||||
|
return "V2-无烟打卡-每日状态"
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Profile 表示无烟打卡的基础资料配置。
|
||||||
|
type Profile struct {
|
||||||
|
ID uint `gorm:"primaryKey;comment:主键" json:"id"`
|
||||||
|
CreatedAt time.Time `gorm:"comment:创建时间" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `gorm:"comment:更新时间" json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"-"`
|
||||||
|
|
||||||
|
UID int `gorm:"uniqueIndex;comment:用户ID" json:"-"`
|
||||||
|
|
||||||
|
QuitStartDate time.Time `gorm:"column:quit_start_date;type:date;comment:戒烟开始日期" json:"quit_start_date"`
|
||||||
|
PackPriceCent int `gorm:"column:pack_price_cent;comment:每包价格(分)" json:"pack_price_cent"`
|
||||||
|
BaselineCigsPerDay int `gorm:"column:baseline_cigs_per_day;comment:戒烟前日均支数" json:"baseline_cigs_per_day"`
|
||||||
|
Motivation string `gorm:"column:motivation;size:200;comment:戒烟初心" json:"motivation"`
|
||||||
|
NotifyTime string `gorm:"column:notify_time;size:5;comment:提醒时间(HH:MM)" json:"notify_time"`
|
||||||
|
ResetRule string `gorm:"column:reset_rule;size:64;comment:连续天数重置规则" json:"reset_rule"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 返回用户资料表名。
|
||||||
|
func (Profile) TableName() string {
|
||||||
|
return "fa_quit_checkin_profile"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableComment 返回用户资料表注释。
|
||||||
|
func (Profile) TableComment() string {
|
||||||
|
return "V2-无烟打卡-用户资料"
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RelapseEvent 表示一次复吸事件明细。
|
||||||
|
type RelapseEvent struct {
|
||||||
|
ID uint `gorm:"primaryKey;comment:主键" json:"id"`
|
||||||
|
CreatedAt time.Time `gorm:"comment:创建时间" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `gorm:"comment:更新时间" json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"-"`
|
||||||
|
|
||||||
|
UID int `gorm:"index;comment:用户ID" json:"-"`
|
||||||
|
|
||||||
|
Date time.Time `gorm:"type:date;index;comment:所属自然日" json:"date"`
|
||||||
|
RelapseAt time.Time `gorm:"column:relapse_at;comment:复吸时间" json:"relapse_at"`
|
||||||
|
RelapseNum int `gorm:"column:relapse_num;comment:复吸支数" json:"relapse_num"`
|
||||||
|
Reason string `gorm:"column:reason;size:64;comment:复吸原因" json:"reason,omitempty"`
|
||||||
|
Note string `gorm:"column:note;size:200;comment:备注" json:"note,omitempty"`
|
||||||
|
AffectStreak bool `gorm:"column:affect_streak;comment:是否影响连续天数" json:"affect_streak"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 返回复吸事件表名。
|
||||||
|
func (RelapseEvent) TableName() string {
|
||||||
|
return "fa_quit_checkin_relapse_event"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableComment 返回复吸事件表注释。
|
||||||
|
func (RelapseEvent) TableComment() string {
|
||||||
|
return "V2-无烟打卡-复吸事件"
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 梦想目标状态枚举值。
|
||||||
|
const (
|
||||||
|
RewardGoalStatusActive = "active"
|
||||||
|
RewardGoalStatusCompleted = "completed"
|
||||||
|
RewardGoalStatusArchived = "archived"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RewardGoal 表示用户的梦想奖励目标。
|
||||||
|
type RewardGoal struct {
|
||||||
|
ID uint `gorm:"primaryKey;comment:主键" json:"id"`
|
||||||
|
CreatedAt time.Time `gorm:"comment:创建时间" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `gorm:"comment:更新时间" json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"-"`
|
||||||
|
|
||||||
|
UID int `gorm:"index;comment:用户ID" json:"-"`
|
||||||
|
|
||||||
|
Title string `gorm:"column:title;size:64;comment:目标名称" json:"title"`
|
||||||
|
TargetAmountCent int `gorm:"column:target_amount_cent;comment:目标金额(分)" json:"target_amount_cent"`
|
||||||
|
CoverImage string `gorm:"column:cover_image;size:500;comment:封面图" json:"cover_image,omitempty"`
|
||||||
|
Status string `gorm:"column:status;size:32;index;comment:状态" json:"status"`
|
||||||
|
CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 返回梦想目标表名。
|
||||||
|
func (RewardGoal) TableName() string {
|
||||||
|
return "fa_quit_checkin_reward_goal"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableComment 返回梦想目标表注释。
|
||||||
|
func (RewardGoal) TableComment() string {
|
||||||
|
return "V2-无烟打卡-梦想目标"
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
marketinghandler "wx_service/internal/marketing/handler"
|
marketinghandler "wx_service/internal/marketing/handler"
|
||||||
membershiphandler "wx_service/internal/membership/handler"
|
membershiphandler "wx_service/internal/membership/handler"
|
||||||
"wx_service/internal/middleware"
|
"wx_service/internal/middleware"
|
||||||
|
quitcheckinhandler "wx_service/internal/quitcheckin/handler"
|
||||||
rmhandler "wx_service/internal/remove_watermark/handler"
|
rmhandler "wx_service/internal/remove_watermark/handler"
|
||||||
smokehandler "wx_service/internal/smoke/handler"
|
smokehandler "wx_service/internal/smoke/handler"
|
||||||
)
|
)
|
||||||
@@ -38,6 +39,7 @@ func Register(
|
|||||||
marketingCategoryHandler *marketinghandler.CategoryHandler,
|
marketingCategoryHandler *marketinghandler.CategoryHandler,
|
||||||
marketingTemplateHandler *marketinghandler.TemplateHandler,
|
marketingTemplateHandler *marketinghandler.TemplateHandler,
|
||||||
marketingDownloadHandler *marketinghandler.DownloadHandler,
|
marketingDownloadHandler *marketinghandler.DownloadHandler,
|
||||||
|
quitCheckinHandler *quitcheckinhandler.Handler,
|
||||||
) {
|
) {
|
||||||
// Register 用来集中注册所有 HTTP 路由,便于工程结构更清晰:
|
// Register 用来集中注册所有 HTTP 路由,便于工程结构更清晰:
|
||||||
// - main 只负责初始化(配置/DB/依赖注入)
|
// - main 只负责初始化(配置/DB/依赖注入)
|
||||||
@@ -69,6 +71,16 @@ func Register(
|
|||||||
registerMarketingRoutes(api, protected, adminToken, marketingCategoryHandler, marketingTemplateHandler, marketingDownloadHandler)
|
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)
|
registerAdminRoutes(router, adminHandler, marketingCategoryHandler, marketingTemplateHandler, marketingDownloadHandler)
|
||||||
|
|
||||||
// 保质期提醒模块使用独立前缀 /api/expiry,与现有 /api/v1 并存。
|
// 保质期提醒模块使用独立前缀 /api/expiry,与现有 /api/v1 并存。
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type upsertSmokeProfileRequest struct {
|
|||||||
BaselineCigsPerDay *int `json:"baseline_cigs_per_day"`
|
BaselineCigsPerDay *int `json:"baseline_cigs_per_day"`
|
||||||
SmokingYears *float64 `json:"smoking_years"`
|
SmokingYears *float64 `json:"smoking_years"`
|
||||||
PackPriceCent *int `json:"pack_price_cent"`
|
PackPriceCent *int `json:"pack_price_cent"`
|
||||||
|
Mode *string `json:"mode"`
|
||||||
|
|
||||||
SmokeMotivations *[]string `json:"smoke_motivations"`
|
SmokeMotivations *[]string `json:"smoke_motivations"`
|
||||||
QuitMotivations *[]string `json:"quit_motivations"`
|
QuitMotivations *[]string `json:"quit_motivations"`
|
||||||
@@ -71,6 +72,13 @@ func (h *SmokeHandler) UpsertProfile(c *gin.Context) {
|
|||||||
return
|
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
|
quitDateProvided := false
|
||||||
var quitDate *time.Time
|
var quitDate *time.Time
|
||||||
@@ -91,6 +99,7 @@ func (h *SmokeHandler) UpsertProfile(c *gin.Context) {
|
|||||||
BaselineCigsPerDay: req.BaselineCigsPerDay,
|
BaselineCigsPerDay: req.BaselineCigsPerDay,
|
||||||
SmokingYears: req.SmokingYears,
|
SmokingYears: req.SmokingYears,
|
||||||
PackPriceCent: req.PackPriceCent,
|
PackPriceCent: req.PackPriceCent,
|
||||||
|
Mode: req.Mode,
|
||||||
SmokeMotivations: req.SmokeMotivations,
|
SmokeMotivations: req.SmokeMotivations,
|
||||||
QuitMotivations: req.QuitMotivations,
|
QuitMotivations: req.QuitMotivations,
|
||||||
WakeUpTime: req.WakeUpTime,
|
WakeUpTime: req.WakeUpTime,
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ type SmokeUserProfile struct {
|
|||||||
|
|
||||||
SmokeMotivations StringSlice `gorm:"column:smoke_motivations;type:json;comment:抽烟动机(JSON数组)" json:"smoke_motivations"`
|
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"`
|
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"`
|
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"`
|
SleepTime string `gorm:"column:sleep_time;size:5;comment:入睡时间(HH:MM)" json:"sleep_time"`
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ var (
|
|||||||
ErrSmokeProfileInvalidTime = errors.New("invalid time format, expected HH:MM")
|
ErrSmokeProfileInvalidTime = errors.New("invalid time format, expected HH:MM")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
SmokeModeQuit = "quit"
|
||||||
|
SmokeModeRecord = "record"
|
||||||
|
)
|
||||||
|
|
||||||
type SmokeProfileService struct {
|
type SmokeProfileService struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
}
|
}
|
||||||
@@ -47,6 +52,7 @@ func (s *SmokeProfileService) GetView(ctx context.Context, uid int) (SmokeProfil
|
|||||||
BaselineIntervalMinute: 0,
|
BaselineIntervalMinute: 0,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
profile.Mode = normalizedSmokeMode(profile.Mode)
|
||||||
|
|
||||||
awakeMinutes, err := awakeMinutesWithFallback(profile.WakeUpTime, profile.SleepTime)
|
awakeMinutes, err := awakeMinutesWithFallback(profile.WakeUpTime, profile.SleepTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -83,6 +89,7 @@ type UpsertSmokeProfileRequest struct {
|
|||||||
BaselineCigsPerDay *int
|
BaselineCigsPerDay *int
|
||||||
SmokingYears *float64
|
SmokingYears *float64
|
||||||
PackPriceCent *int
|
PackPriceCent *int
|
||||||
|
Mode *string
|
||||||
|
|
||||||
SmokeMotivations *[]string
|
SmokeMotivations *[]string
|
||||||
QuitMotivations *[]string
|
QuitMotivations *[]string
|
||||||
@@ -116,6 +123,12 @@ func (s *SmokeProfileService) Upsert(ctx context.Context, uid int, req UpsertSmo
|
|||||||
*dst = *v
|
*dst = *v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
applyMode := func(dst *string, v *string) {
|
||||||
|
if v == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
*dst = normalizedSmokeMode(*v)
|
||||||
|
}
|
||||||
applyTimeStr := func(dst *string, v *string) error {
|
applyTimeStr := func(dst *string, v *string) error {
|
||||||
if v == nil {
|
if v == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -135,6 +148,7 @@ func (s *SmokeProfileService) Upsert(ctx context.Context, uid int, req UpsertSmo
|
|||||||
applyInt(&profile.BaselineCigsPerDay, req.BaselineCigsPerDay)
|
applyInt(&profile.BaselineCigsPerDay, req.BaselineCigsPerDay)
|
||||||
applyFloat(&profile.SmokingYears, req.SmokingYears)
|
applyFloat(&profile.SmokingYears, req.SmokingYears)
|
||||||
applyInt(&profile.PackPriceCent, req.PackPriceCent)
|
applyInt(&profile.PackPriceCent, req.PackPriceCent)
|
||||||
|
applyMode(&profile.Mode, req.Mode)
|
||||||
|
|
||||||
if req.SmokeMotivations != nil {
|
if req.SmokeMotivations != nil {
|
||||||
profile.SmokeMotivations = smokemodel.StringSlice(*req.SmokeMotivations)
|
profile.SmokeMotivations = smokemodel.StringSlice(*req.SmokeMotivations)
|
||||||
@@ -154,6 +168,7 @@ func (s *SmokeProfileService) Upsert(ctx context.Context, uid int, req UpsertSmo
|
|||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
profile.Mode = normalizedSmokeMode(profile.Mode)
|
||||||
if profile.OnboardingCompletedAt == nil && isSmokeProfileCompleted(profile) {
|
if profile.OnboardingCompletedAt == nil && isSmokeProfileCompleted(profile) {
|
||||||
profile.OnboardingCompletedAt = &now
|
profile.OnboardingCompletedAt = &now
|
||||||
}
|
}
|
||||||
@@ -215,6 +230,17 @@ func baselineIntervalMinutes(awakeMinutes int, baselineCigsPerDay int) int {
|
|||||||
return interval
|
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) {
|
func parseHHMMToMinutes(s string) (int, error) {
|
||||||
s = strings.TrimSpace(s)
|
s = strings.TrimSpace(s)
|
||||||
if len(s) != 5 || s[2] != ':' {
|
if len(s) != 5 || s[2] != ':' {
|
||||||
|
|||||||
@@ -106,3 +106,23 @@ func TestIsSmokeProfileCompleted(t *testing.T) {
|
|||||||
t.Fatalf("isSmokeProfileCompleted: expected false when quit_motivations missing")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user