Enhance smoking tracking API with new features and improvements
- Added a new API endpoint `GET /api/v1/smoke/home` to consolidate core modules for the home dashboard, reducing the need for multiple requests. - Updated the `smoke` routes to include the new home endpoint and improved user profile management with the addition of a `quit_date` field. - Enhanced the algorithm for calculating daily targets and next smoke suggestions, ensuring accurate future time handling and user-specific recommendations. - Improved API documentation to reflect new endpoints, response formats, and detailed field descriptions for better clarity and usability. - Refactored user authentication handling in various handlers to streamline the process and ensure consistent error responses.
This commit is contained in:
+16
-15
@@ -71,6 +71,11 @@ if suggested_time in [sleep_time, wake_up_time]:
|
||||
└── 否 → 返回建议时间
|
||||
```
|
||||
|
||||
### 2.7 过期/未来记录兜底
|
||||
|
||||
- 如果用户补录了“未来时间”的抽烟记录,为了避免前端出现负倒计时,服务端会把 `last_smoke_at` 限制在当前 `as_of` 时刻以内。
|
||||
- 当 `plan_date` 是“今天”且 `last_smoke_at + interval` 早于当前时间时,会根据已过去的分钟数计算需要补齐的间隔,向前跳跃若干次直到结果落在未来。这样首页和 `GET /next_smoke_time` 都能返回一个“未来的下一次建议时间”,不会出现提示已经过期的时间点。
|
||||
- 如果 `plan_date` 是将来日期(例如明天的日程),仍然按照指定日期的起床时间作为 `not_before_at`,不会使用上述补齐逻辑。
|
||||
---
|
||||
|
||||
## 3. AI 增强算法
|
||||
@@ -177,26 +182,22 @@ function calculateStage(startDate) {
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 每日目标计算
|
||||
### 4.3 每日目标计算(线性递减)
|
||||
|
||||
以 onboarding 完成日期为起点,到 `quit_date` 线性递减到 0:
|
||||
|
||||
```javascript
|
||||
// utils/target.js
|
||||
function calculateDailyTarget(baseline, stage, dayInStage) {
|
||||
if (stage === 1) {
|
||||
return baseline
|
||||
}
|
||||
function calculateDailyTarget(baseline, startDate, quitDate, today) {
|
||||
if (!baseline || !startDate || !quitDate) return baseline
|
||||
if (today >= quitDate) return 0
|
||||
|
||||
if (stage === 2) {
|
||||
const reduction = (dayInStage / 14) * 0.5
|
||||
return Math.max(Math.round(baseline * (1 - reduction)), 1)
|
||||
}
|
||||
const totalDays = daysBetween(startDate, quitDate)
|
||||
if (totalDays <= 0) return baseline
|
||||
|
||||
if (stage === 3) {
|
||||
const targetRate = 0.25 - (dayInStage / 9) * 0.25
|
||||
return Math.max(Math.round(baseline * targetRate), 0)
|
||||
}
|
||||
|
||||
return baseline
|
||||
const remainingDays = daysBetween(today, quitDate)
|
||||
const target = Math.round(baseline * (remainingDays / totalDays))
|
||||
return remainingDays > 0 ? Math.max(target, 1) : 0
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
+143
-12
@@ -23,7 +23,7 @@
|
||||
- `smoke_time` 可选;不传则默认“当天”。
|
||||
- `smoke_at` 可选;真实抽烟时间(格式 `YYYY-MM-DD HH:MM:SS`)。用于“按时间节点分析/AI 建议”;不传则可用 `createtime` 近似。
|
||||
- `level/num` 可选;不传时后端会按 `1` 处理。
|
||||
- 如果要记录“想抽但忍住了”,请传 `level=0` 且 `num=0`(会在 `fa_smoke_log` 中展示为一条记录,但不会影响看板的支数累加)。
|
||||
- “想抽但忍住了”请传 `level=0` 且 `num=0`,系统以 `num=0` 作为“忍住”的判断条件(计入忍住次数,但不计入抽烟支数)。
|
||||
|
||||
curl 示例:
|
||||
|
||||
@@ -67,12 +67,16 @@ curl -X GET 'http://127.0.0.1:8080/api/v1/smoke/logs/5202' \
|
||||
|
||||
## 3) 列表查询(分页)
|
||||
|
||||
`GET /api/v1/smoke/logs?page=1&page_size=20&start=2025-12-01&end=2025-12-31`
|
||||
`GET /api/v1/smoke/logs?page=1&page_size=20&start=2025-12-01&end=2025-12-31&type=all`
|
||||
|
||||
参数:
|
||||
- `page`:页码,默认 `1`
|
||||
- `page_size`:每页数量,默认 `20`,最大 `200`
|
||||
- `start/end`:可选,按 `smoke_time` 过滤(格式 `YYYY-MM-DD`)
|
||||
- `type`:可选,默认 `all`;`smoke` 表示抽烟记录(`num>0`),`resisted` 表示忍住记录(`num=0`)
|
||||
|
||||
说明:
|
||||
- 列表按时间倒序返回(优先 `smoke_at`,其次 `createtime`,最后 `smoke_time`)。
|
||||
|
||||
成功响应示例:
|
||||
|
||||
@@ -199,6 +203,125 @@ curl -X GET 'http://127.0.0.1:8080/api/v1/smoke/logs/5202' \
|
||||
- 权限:会员用户直接可用;非会员需要先对该 `date` 完成“看广告解锁”(见下一个接口)。
|
||||
- 建议结果会按 `uid + date + prompt_version` 缓存(表:`fa_smoke_ai_advice`)。
|
||||
|
||||
## 9) 首页整合接口(Home Dashboard)
|
||||
|
||||
`GET /api/v1/smoke/home`
|
||||
|
||||
此接口把首页 UI 所需核心模块一次返回,避免前端串行请求多个接口。返回示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"greeting": {
|
||||
"title": "早安,Alex",
|
||||
"subtitle": "今天也是清爽的一天",
|
||||
"nickname": "Alex",
|
||||
"time_of_day": "morning",
|
||||
"avatar_url": "https://example.com/avatar.jpg"
|
||||
},
|
||||
"profile": {
|
||||
"exists": true,
|
||||
"is_completed": true,
|
||||
"awake_minutes": 900,
|
||||
"baseline_interval_minutes": 60,
|
||||
"profile": {
|
||||
"baseline_cigs_per_day": 10,
|
||||
"pack_price_cent": 3200,
|
||||
"wake_up_time": "07:20",
|
||||
"sleep_time": "23:30"
|
||||
}
|
||||
},
|
||||
"advice_card": {
|
||||
"title": "智能控烟建议",
|
||||
"date": "2026-01-04",
|
||||
"message": "根据你的习惯,下午2点是烟瘾高峰,可以试试短暂散步。",
|
||||
"status": "available"
|
||||
},
|
||||
"campaign_card": {
|
||||
"title": "绿色生活,从戒烟开始",
|
||||
"subtitle": "BRAND CAMPAIGN",
|
||||
"badge": "广告"
|
||||
},
|
||||
"timer": {
|
||||
"label": "距上次抽烟",
|
||||
"last_smoke_at": "2026-01-05T07:42:00+08:00",
|
||||
"seconds_since_last": 9900,
|
||||
"next_suggested_at": "2026-01-05T10:30:00+08:00",
|
||||
"next_suggested_clock": "10:30",
|
||||
"not_before_at": "2026-01-05T10:30:00+08:00",
|
||||
"suggestion_source": "default",
|
||||
"suggestion_algorithm": "staircase_delay_v1"
|
||||
},
|
||||
"summary": {
|
||||
"today_count": 3,
|
||||
"daily_target": 10,
|
||||
"resisted_count": 1,
|
||||
"reduced_from_yesterday": 2,
|
||||
"exceeded_yesterday": false,
|
||||
"profile_completed": true
|
||||
},
|
||||
"motivation": {
|
||||
"message": "太棒了!你刚刚成功抵抗了一次烟瘾",
|
||||
"type": "praise"
|
||||
},
|
||||
"quick_actions": [
|
||||
{ "type": "log_smoke", "title": "记录抽烟", "primary": false },
|
||||
{ "type": "resist", "title": "想抽忍住了", "primary": true }
|
||||
],
|
||||
"data_sources": {
|
||||
"ai_advice_date": "2026-01-04",
|
||||
"plan_date": "2026-01-05"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
- `greeting.title`:问候语 + 昵称(如“下午好,Alex”)。
|
||||
- `greeting.subtitle`:副标题/心情提示文案。
|
||||
- `greeting.nickname`:昵称(无昵称时使用“朋友”)。
|
||||
- `greeting.time_of_day`:时间段标识(`morning`/`noon`/`afternoon`/`evening`)。
|
||||
- `greeting.avatar_url`:头像 URL。
|
||||
- `profile.exists`:是否存在用户档案。
|
||||
- `profile.profile`:档案详情对象(可能为空)。
|
||||
- `profile.is_completed`:是否已完成 onboarding。
|
||||
- `profile.awake_minutes`:清醒时长(分钟)。
|
||||
- `profile.baseline_interval_minutes`:基准间隔(分钟)。
|
||||
- `advice_card.title`:AI 提示卡片标题。
|
||||
- `advice_card.date`:建议对应日期。
|
||||
- `advice_card.message`:AI 建议内容。
|
||||
- `advice_card.status`:`available`、`locked`(需解锁)、`unavailable`(AI 服务未配置)、`no_data`(所需日期没有记录)、`empty`(初始化)。
|
||||
- `advice_card.model`:模型名称(有则返回)。
|
||||
- `campaign_card.title`:活动标题。
|
||||
- `campaign_card.subtitle`:活动副标题。
|
||||
- `campaign_card.badge`:活动角标(如“广告”)。
|
||||
- `timer.label`:展示标题(如“距上次抽烟”)。
|
||||
- `timer.last_smoke_at`:最近一次实际抽烟时间(RFC3339)。
|
||||
- `timer.seconds_since_last`:距上次抽烟的秒数(无记录返回 `-1`)。
|
||||
- `timer.next_suggested_at`:建议下次抽烟时间(RFC3339)。
|
||||
- `timer.next_suggested_clock`:仅时分显示(如“16:30”)。
|
||||
- `timer.not_before_at`:不早于的时间点(当前与 `next_suggested_at` 一致)。
|
||||
- `timer.suggestion_source`:建议来源(`default`/`ai`)。
|
||||
- `timer.suggestion_algorithm`:算法版本(`staircase_delay_v1`)。
|
||||
- `timer` 说明:`seconds_since_last` 基于服务器当前时间计算,`last_smoke_at` 若补录未来时间会截断到 `as_of`;当 `plan_date=今天` 时会补齐过期间隔确保 `next_suggested_at` 在未来。
|
||||
- `summary.today_count`:今日吸烟支数累加。
|
||||
- `summary.daily_target`:每日目标(线性递减:以 `onboarding_completed_at` 为起点,按 `quit_date` 线性下降到 0)。
|
||||
- `summary.resisted_count`:今日忍住次数。
|
||||
- `summary.reduced_from_yesterday`:与昨日的绝对差值(非负)。
|
||||
- `summary.exceeded_yesterday`:是否比昨天多。
|
||||
- `summary.profile_completed`:是否已完成基础信息。
|
||||
- `motivation.message`:激励语文案。
|
||||
- `motivation.type`:激励语类型。
|
||||
- `quick_actions[].type`:动作类型(`log_smoke`/`resist`)。
|
||||
- `quick_actions[].title`:按钮文案。
|
||||
- `quick_actions[].primary`:是否主按钮。
|
||||
- `data_sources.ai_advice_date`:AI 建议日期。
|
||||
- `data_sources.plan_date`:当前计划日期。
|
||||
|
||||
如需 AI 时间节点完整版,可继续调用 `GET /ai/next_smoke_time`;首页接口只返回默认建议,避免额外的 AI 生成成本。
|
||||
|
||||
未满足权限时的建议响应(示例):
|
||||
```json
|
||||
{
|
||||
@@ -223,7 +346,7 @@ curl -X GET 'http://127.0.0.1:8080/api/v1/smoke/logs/5202' \
|
||||
}
|
||||
```
|
||||
|
||||
## 9) 看广告解锁(用于非会员)
|
||||
## 10) 看广告解锁(用于非会员)
|
||||
|
||||
`POST /api/v1/smoke/ai/advice_unlocks`
|
||||
|
||||
@@ -241,7 +364,7 @@ curl -X GET 'http://127.0.0.1:8080/api/v1/smoke/logs/5202' \
|
||||
- 解锁是“按天”的:观看一次广告解锁一天内的 AI 生成功能(可用于「每日 AI 建议」以及「AI 下次抽烟时间节点」)。
|
||||
- 如果你要生成“明天”的 AI 时间节点,请把 `date` 传为明天日期(例如 `2026-01-06`)。
|
||||
|
||||
## 10) 获取用户基础信息(首次进入:判断是否需要补全)
|
||||
## 11) 获取用户基础信息(首次进入:判断是否需要补全)
|
||||
|
||||
`GET /api/v1/smoke/profile`
|
||||
|
||||
@@ -269,6 +392,7 @@ curl -X GET 'http://127.0.0.1:8080/api/v1/smoke/logs/5202' \
|
||||
"quit_motivations": ["身体健康", "省钱"],
|
||||
"wake_up_time": "07:30",
|
||||
"sleep_time": "23:30",
|
||||
"quit_date": "2026-02-28T00:00:00+08:00",
|
||||
"onboarding_completed_at": "2026-01-05T10:00:00+08:00"
|
||||
},
|
||||
"is_completed": true,
|
||||
@@ -299,8 +423,9 @@ curl -X GET 'http://127.0.0.1:8080/api/v1/smoke/logs/5202' \
|
||||
- `smoke_motivations`(抽烟动机):如 `压力大/无聊/社交/提神`,用于 AI 在分析 remark 时更有针对性。
|
||||
- `quit_motivations`(戒烟动力):如 `身体健康/家人孩子/省钱`,当用户产生动摇时 AI 可用这些信息做“情感阻断/自我提醒”。
|
||||
- `wake_up_time` + `sleep_time`(作息时间):用于自动规避睡眠时间,防止在用户睡觉时提醒其“坚持”。
|
||||
- `quit_date`(目标戒烟日期):用于阶段规划或到期提醒。
|
||||
|
||||
## 11) 补全/更新用户基础信息(Upsert)
|
||||
## 12) 补全/更新用户基础信息(Upsert)
|
||||
|
||||
`POST /api/v1/smoke/profile`
|
||||
|
||||
@@ -319,13 +444,14 @@ curl -X GET 'http://127.0.0.1:8080/api/v1/smoke/logs/5202' \
|
||||
"smoke_motivations": ["压力大", "社交"],
|
||||
"quit_motivations": ["身体健康", "省钱"],
|
||||
"wake_up_time": "07:30",
|
||||
"sleep_time": "23:30"
|
||||
"sleep_time": "23:30",
|
||||
"quit_date": "2026-02-28"
|
||||
}
|
||||
```
|
||||
|
||||
成功响应:同 `GET /api/v1/smoke/profile`(返回最新 `profile` + `is_completed` + `baseline_interval_minutes`)。
|
||||
|
||||
## 12) 想抽但忍住了(写入一条 level=0,num=0 的记录)
|
||||
## 13) 想抽但忍住了(写入一条 level=0,num=0 的记录)
|
||||
|
||||
`POST /api/v1/smoke/logs/resisted`
|
||||
|
||||
@@ -334,7 +460,9 @@ curl -X GET 'http://127.0.0.1:8080/api/v1/smoke/logs/5202' \
|
||||
{
|
||||
"smoke_time": "2026-01-05",
|
||||
"smoke_at": "2026-01-05 10:20:00",
|
||||
"remark": "压力大,想抽但忍住了"
|
||||
"remark": "压力大,想抽但忍住了",
|
||||
"level": 0,
|
||||
"num": 0
|
||||
}
|
||||
```
|
||||
|
||||
@@ -342,7 +470,7 @@ curl -X GET 'http://127.0.0.1:8080/api/v1/smoke/logs/5202' \
|
||||
- 该接口会在 `fa_smoke_log` 中新增一条记录:`level=0` 且 `num=0`,用于更直观记录“想抽/忍住”的过程。
|
||||
- 这类记录不会影响 `today_count/weekly.count` 的支数统计(因为 `num=0`)。
|
||||
|
||||
## 13) 获取“下次抽烟记录时间”(默认 + AI 自动切换)
|
||||
## 14) 获取“下次抽烟记录时间”(默认 + AI 自动切换)
|
||||
|
||||
`GET /api/v1/smoke/next_smoke_time`
|
||||
|
||||
@@ -430,11 +558,11 @@ AI 生成说明:
|
||||
字段说明(新增首页字段):
|
||||
- `last_smoke_at`:上次“实际抽烟”时间(忽略忍住记录),格式 `RFC3339`(含时区)。
|
||||
- `today_count`:今日抽烟支数(累加 `num`)。
|
||||
- `resisted_count`:今日克制次数(`level=0 && num=0`)。
|
||||
- `resisted_count`:今日克制次数(`num=0`)。
|
||||
- `reduced_from_yesterday`:较昨日减少的支数(允许为负数;为负时表示“今天超出昨日”)。
|
||||
- `exceeded_yesterday`:是否超出昨日(`true` 表示今天超出昨日,前端可用作单独标识)。
|
||||
|
||||
## 14) 数据统计分析(趋势 + 健康 + 省钱)
|
||||
## 15) 数据统计分析(趋势 + 健康 + 省钱)
|
||||
|
||||
`GET /api/v1/smoke/stats?range=week|month|year&date=2026-01-07`
|
||||
|
||||
@@ -445,6 +573,7 @@ AI 生成说明:
|
||||
说明:
|
||||
- 用于“统计页”一屏数据整合(趋势、均值、环比、健康恢复、省钱、连续记录、已拒绝次数)。
|
||||
- `trend_unit`:`day` 或 `month`,用于前端图表横轴显示。
|
||||
- `trend`:最多返回 7 个 label,超出会等距抽样,方便绘图。
|
||||
|
||||
成功响应(示例):
|
||||
```json
|
||||
@@ -493,9 +622,11 @@ AI 生成说明:
|
||||
字段说明:
|
||||
- `change_percent`:与上一个同周期对比的变化比例(可为负)。
|
||||
- `money.available=false`:表示缺少 `baseline_cigs_per_day` 或 `pack_price_cent`。
|
||||
- `money.expected_total`:按“统计周期内有记录的天数”×`baseline_cigs_per_day` 计算;不统计无日志的天数。
|
||||
- `money.saved_cent`:按 `max(expected_total - actual_total, 0)` 计算,避免出现负值。
|
||||
- `health.available=false`:表示无历史记录。
|
||||
|
||||
## 15) 激励语(后端统一生成)
|
||||
## 16) 激励语(后端统一生成)
|
||||
|
||||
`GET /api/v1/smoke/motivation`
|
||||
|
||||
|
||||
@@ -27,11 +27,7 @@ type qiniuTokenRequest struct {
|
||||
// QiniuToken 返回七牛直传所需的 token/key/upload_url 等信息。
|
||||
// 建议放在鉴权后:用当前登录用户生成 key,避免前端写入任意路径。
|
||||
func (h *UploadHandler) QiniuToken(c *gin.Context) {
|
||||
user, ok := middleware.CurrentUser(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
|
||||
return
|
||||
}
|
||||
user := middleware.MustCurrentUser(c)
|
||||
|
||||
var req qiniuTokenRequest
|
||||
_ = c.ShouldBindJSON(&req) // filename 可选,解析失败也不影响生成 token
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
membershipservice "wx_service/internal/membership/service"
|
||||
"wx_service/internal/middleware"
|
||||
"wx_service/internal/model"
|
||||
membershipservice "wx_service/internal/membership/service"
|
||||
)
|
||||
|
||||
type RedeemCodeHandler struct {
|
||||
@@ -80,11 +80,7 @@ type redeemRequest struct {
|
||||
}
|
||||
|
||||
func (h *RedeemCodeHandler) Redeem(c *gin.Context) {
|
||||
user, ok := middleware.CurrentUser(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
|
||||
return
|
||||
}
|
||||
user := middleware.MustCurrentUser(c)
|
||||
|
||||
var req redeemRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"wx_service/internal/model"
|
||||
@@ -16,3 +18,34 @@ func CurrentUser(c *gin.Context) (*model.User, bool) {
|
||||
user, ok := userVal.(*model.User)
|
||||
return user, ok
|
||||
}
|
||||
|
||||
// MustCurrentUser 仅用于已通过鉴权中间件的路由,避免每个 handler 重复判断。
|
||||
func MustCurrentUser(c *gin.Context) *model.User {
|
||||
userVal := c.MustGet(ContextCurrentUserKey)
|
||||
user, ok := userVal.(*model.User)
|
||||
if !ok || user == nil {
|
||||
panic("current user missing in context")
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
// RequireUser 是对 CurrentUser 的封装,复用统一的未登录响应。
|
||||
func RequireUser(c *gin.Context) (*model.User, bool) {
|
||||
user, ok := CurrentUser(c)
|
||||
if ok {
|
||||
return user, true
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// RequireUserMiddleware 将登录校验统一下沉到路由层。
|
||||
func RequireUserMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if _, ok := CurrentUser(c); !ok {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,11 +43,7 @@ func (h *VideoHandler) RemoveWatermark(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := middleware.CurrentUser(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
|
||||
return
|
||||
}
|
||||
user := middleware.MustCurrentUser(c)
|
||||
|
||||
result, err := h.videoService.RemoveWatermark(c.Request.Context(), user, req.Content)
|
||||
if err != nil {
|
||||
@@ -80,11 +76,7 @@ func (h *VideoHandler) RemoveWatermark(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *VideoHandler) UnlockQuota(c *gin.Context) {
|
||||
user, ok := middleware.CurrentUser(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
|
||||
return
|
||||
}
|
||||
user := middleware.MustCurrentUser(c)
|
||||
|
||||
if err := h.videoService.UnlockForToday(c.Request.Context(), user); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "解锁失败,请稍后重试"))
|
||||
|
||||
@@ -47,6 +47,7 @@ func Register(
|
||||
// 需要登录的接口组:统一挂载鉴权中间件
|
||||
protected := api.Group("")
|
||||
protected.Use(middleware.AuthMiddleware(db, sessionCache))
|
||||
protected.Use(middleware.RequireUserMiddleware())
|
||||
{
|
||||
registerCommonRoutes(protected, uploadHandler)
|
||||
registerRemoveWatermarkRoutes(api, protected, videoHandler)
|
||||
|
||||
@@ -10,6 +10,8 @@ func registerSmokeRoutes(protected *gin.RouterGroup, smokeHandler *smokehandler.
|
||||
// 戒烟/抽烟记录(与 video 去水印功能在路由前缀上区分开)
|
||||
smoke := protected.Group("/smoke")
|
||||
{
|
||||
smoke.GET("/home", smokeHandler.Home)
|
||||
|
||||
// 首次进入/基础信息(用于基准、AI 个性化、作息规避等)
|
||||
smoke.GET("/profile", smokeHandler.GetProfile)
|
||||
smoke.POST("/profile", smokeHandler.UpsertProfile)
|
||||
|
||||
@@ -19,11 +19,7 @@ type unlockAIAdviceRequest struct {
|
||||
}
|
||||
|
||||
func (h *SmokeHandler) GetAIAdvice(c *gin.Context) {
|
||||
user, ok := middleware.CurrentUser(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
|
||||
return
|
||||
}
|
||||
user := middleware.MustCurrentUser(c)
|
||||
|
||||
dateStr := c.Query("date")
|
||||
adviceDate := yesterdayDate()
|
||||
@@ -61,11 +57,7 @@ func (h *SmokeHandler) GetAIAdvice(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *SmokeHandler) UnlockAIAdvice(c *gin.Context) {
|
||||
user, ok := middleware.CurrentUser(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
|
||||
return
|
||||
}
|
||||
user := middleware.MustCurrentUser(c)
|
||||
|
||||
var req unlockAIAdviceRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -53,11 +54,7 @@ type createSmokeLogRequest struct {
|
||||
}
|
||||
|
||||
func (h *SmokeHandler) Create(c *gin.Context) {
|
||||
user, ok := middleware.CurrentUser(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
|
||||
return
|
||||
}
|
||||
user := middleware.MustCurrentUser(c)
|
||||
|
||||
var req createSmokeLogRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -130,11 +127,7 @@ type resistedSmokeLogRequest struct {
|
||||
|
||||
// Resist 表示“想抽但忍住了”:在 fa_smoke_log 中写入 level=0,num=0。
|
||||
func (h *SmokeHandler) Resist(c *gin.Context) {
|
||||
user, ok := middleware.CurrentUser(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
|
||||
return
|
||||
}
|
||||
user := middleware.MustCurrentUser(c)
|
||||
|
||||
var req resistedSmokeLogRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
|
||||
@@ -178,11 +171,7 @@ func (h *SmokeHandler) Resist(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *SmokeHandler) Get(c *gin.Context) {
|
||||
user, ok := middleware.CurrentUser(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
|
||||
return
|
||||
}
|
||||
user := middleware.MustCurrentUser(c)
|
||||
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
@@ -204,14 +193,15 @@ func (h *SmokeHandler) Get(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *SmokeHandler) List(c *gin.Context) {
|
||||
user, ok := middleware.CurrentUser(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
|
||||
return
|
||||
}
|
||||
user := middleware.MustCurrentUser(c)
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
listType := strings.ToLower(strings.TrimSpace(c.DefaultQuery("type", "all")))
|
||||
if listType != "all" && listType != "smoke" && listType != "resisted" {
|
||||
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "type 应为 all|smoke|resisted"))
|
||||
return
|
||||
}
|
||||
|
||||
var start *time.Time
|
||||
if v := c.Query("start"); v != "" {
|
||||
@@ -237,6 +227,7 @@ func (h *SmokeHandler) List(c *gin.Context) {
|
||||
PageSize: pageSize,
|
||||
Start: start,
|
||||
End: end,
|
||||
Type: listType,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "查询列表失败,请稍后重试"))
|
||||
@@ -252,11 +243,7 @@ func (h *SmokeHandler) List(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *SmokeHandler) Dashboard(c *gin.Context) {
|
||||
user, ok := middleware.CurrentUser(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
|
||||
return
|
||||
}
|
||||
user := middleware.MustCurrentUser(c)
|
||||
|
||||
now := time.Now()
|
||||
defaultStart, defaultEnd := defaultDashboardRange(now)
|
||||
@@ -303,11 +290,7 @@ func (h *SmokeHandler) Dashboard(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *SmokeHandler) LatestLogs(c *gin.Context) {
|
||||
user, ok := middleware.CurrentUser(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
|
||||
return
|
||||
}
|
||||
user := middleware.MustCurrentUser(c)
|
||||
|
||||
limit, err := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
if err != nil {
|
||||
@@ -341,11 +324,7 @@ type updateSmokeLogRequest struct {
|
||||
}
|
||||
|
||||
func (h *SmokeHandler) Update(c *gin.Context) {
|
||||
user, ok := middleware.CurrentUser(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
|
||||
return
|
||||
}
|
||||
user := middleware.MustCurrentUser(c)
|
||||
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
@@ -391,9 +370,13 @@ func (h *SmokeHandler) Update(c *gin.Context) {
|
||||
} else {
|
||||
parsed, err := time.ParseInLocation(dateTimeLayout, *req.SmokeAt, time.Local)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "smoke_at 格式错误,应为 YYYY-MM-DD HH:MM:SS"))
|
||||
parsedRFC, errRFC := time.Parse(time.RFC3339, *req.SmokeAt)
|
||||
if errRFC != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "smoke_at 格式错误,应为 YYYY-MM-DD HH:MM:SS 或 RFC3339"))
|
||||
return
|
||||
}
|
||||
parsed = parsedRFC.In(time.Local)
|
||||
}
|
||||
smokeAt = &parsed
|
||||
}
|
||||
}
|
||||
@@ -420,11 +403,7 @@ func (h *SmokeHandler) Update(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *SmokeHandler) Delete(c *gin.Context) {
|
||||
user, ok := middleware.CurrentUser(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
|
||||
return
|
||||
}
|
||||
user := middleware.MustCurrentUser(c)
|
||||
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || id <= 0 {
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"wx_service/internal/middleware"
|
||||
"wx_service/internal/model"
|
||||
smokeservice "wx_service/internal/smoke/service"
|
||||
)
|
||||
|
||||
type homeDashboardResponse struct {
|
||||
Greeting homeGreeting `json:"greeting"`
|
||||
Profile smokeservice.SmokeProfileView `json:"profile"`
|
||||
AdviceCard homeAdviceCard `json:"advice_card"`
|
||||
CampaignCard homeCampaignCard `json:"campaign_card"`
|
||||
Timer homeTimerBlock `json:"timer"`
|
||||
Summary homeSummaryBlock `json:"summary"`
|
||||
Motivation smokeservice.SmokeMotivation `json:"motivation"`
|
||||
QuickActions []homeQuickAction `json:"quick_actions"`
|
||||
DataSources homeDataSources `json:"data_sources"`
|
||||
}
|
||||
|
||||
type homeGreeting struct {
|
||||
Title string `json:"title"`
|
||||
Subtitle string `json:"subtitle"`
|
||||
Nickname string `json:"nickname"`
|
||||
TimeOfDay string `json:"time_of_day"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
}
|
||||
|
||||
type homeAdviceCard struct {
|
||||
Title string `json:"title"`
|
||||
Date string `json:"date"`
|
||||
Message string `json:"message"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Status string `json:"status"` // available | locked | unavailable | no_data | empty
|
||||
}
|
||||
|
||||
type homeCampaignCard struct {
|
||||
Title string `json:"title"`
|
||||
Subtitle string `json:"subtitle"`
|
||||
Badge string `json:"badge"`
|
||||
}
|
||||
|
||||
type homeTimerBlock struct {
|
||||
Label string `json:"label"`
|
||||
LastSmokeAt string `json:"last_smoke_at"`
|
||||
SecondsSinceLast int `json:"seconds_since_last"`
|
||||
NextSuggestedAt string `json:"next_suggested_at"`
|
||||
NextSuggestedClock string `json:"next_suggested_clock"`
|
||||
NotBeforeAt string `json:"not_before_at"`
|
||||
SuggestionSource string `json:"suggestion_source"`
|
||||
SuggestionAlgorithm string `json:"suggestion_algorithm"`
|
||||
}
|
||||
|
||||
type homeSummaryBlock struct {
|
||||
TodayCount int `json:"today_count"`
|
||||
DailyTarget int `json:"daily_target"`
|
||||
ResistedCount int `json:"resisted_count"`
|
||||
ReducedFromYesterday int `json:"reduced_from_yesterday"`
|
||||
ExceededYesterday bool `json:"exceeded_yesterday"`
|
||||
ProfileCompleted bool `json:"profile_completed"`
|
||||
}
|
||||
|
||||
type homeQuickAction struct {
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Primary bool `json:"primary"`
|
||||
}
|
||||
|
||||
type homeDataSources struct {
|
||||
AdviceDate string `json:"ai_advice_date"`
|
||||
PlanDate string `json:"plan_date"`
|
||||
}
|
||||
|
||||
func (h *SmokeHandler) Home(c *gin.Context) {
|
||||
user := middleware.MustCurrentUser(c)
|
||||
|
||||
ctx := c.Request.Context()
|
||||
now := time.Now().In(time.Local)
|
||||
planDate := dateOnlyLocal(now)
|
||||
|
||||
profileView, err := h.smokeProfileService.GetView(ctx, int(user.ID))
|
||||
if err != nil {
|
||||
if errors.Is(err, smokeservice.ErrSmokeProfileInvalidTime) {
|
||||
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "作息时间格式错误,应为 HH:MM"))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取基础信息失败,请稍后重试"))
|
||||
return
|
||||
}
|
||||
|
||||
defaultSuggestion, err := h.smokeNextService.GetDefaultSuggestion(ctx, int(user.ID), now, planDate, profileView)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "计算失败,请稍后重试"))
|
||||
return
|
||||
}
|
||||
|
||||
homeSummary, err := h.smokeLogService.HomeSummary(ctx, int(user.ID), now)
|
||||
// HomeSummary 已经包含 last smoke、今日数据等,直接复用
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取首页汇总失败,请稍后重试"))
|
||||
return
|
||||
}
|
||||
|
||||
motivation, err := h.smokeLogService.Motivation(ctx, int(user.ID), now, profileView.Profile)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "生成激励语失败,请稍后重试"))
|
||||
return
|
||||
}
|
||||
|
||||
adviceCard := buildAdviceCard()
|
||||
adviceDate := yesterdayDate()
|
||||
adviceCard.Date = adviceDate.Format(dateLayout)
|
||||
|
||||
if record, err := h.smokeAIAdviceService.GetOrGenerate(ctx, user, adviceDate, smokeservice.DefaultAdvicePromptVersion); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, smokeservice.ErrAIAdviceLocked):
|
||||
adviceCard.Status = "locked"
|
||||
case errors.Is(err, smokeservice.ErrAIServiceDisabled):
|
||||
adviceCard.Status = "unavailable"
|
||||
case errors.Is(err, smokeservice.ErrNoSmokeLogs):
|
||||
adviceCard.Status = "no_data"
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取 AI 建议失败,请稍后重试"))
|
||||
return
|
||||
}
|
||||
} else if record != nil {
|
||||
adviceCard.Message = record.Advice
|
||||
adviceCard.Model = record.Model
|
||||
adviceCard.Status = "available"
|
||||
}
|
||||
|
||||
timerBlock := homeTimerBlock{
|
||||
Label: "距上次抽烟",
|
||||
LastSmokeAt: formatRFC3339(homeSummary.LastSmokeAt),
|
||||
SecondsSinceLast: homeSummary.SecondsSinceLast,
|
||||
NextSuggestedAt: formatRFC3339(defaultSuggestion.NextSmokeAt),
|
||||
NextSuggestedClock: formatClock(defaultSuggestion.NextSmokeAt),
|
||||
NotBeforeAt: formatRFC3339(defaultSuggestion.NextSmokeAt),
|
||||
SuggestionSource: "default",
|
||||
SuggestionAlgorithm: defaultSuggestion.Algorithm,
|
||||
}
|
||||
|
||||
response := homeDashboardResponse{
|
||||
Greeting: greetingBlock(user.NickName, user.AvatarURL, now),
|
||||
Profile: profileView,
|
||||
AdviceCard: homeAdviceCard{
|
||||
Title: adviceCard.Title,
|
||||
Date: adviceCard.Date,
|
||||
Message: adviceCard.Message,
|
||||
Model: adviceCard.Model,
|
||||
Status: adviceCard.Status,
|
||||
},
|
||||
CampaignCard: homeCampaignCard{
|
||||
Title: "绿色生活,从戒烟开始",
|
||||
Subtitle: "BRAND CAMPAIGN",
|
||||
Badge: "广告",
|
||||
},
|
||||
Timer: timerBlock,
|
||||
Summary: homeSummaryBlock{
|
||||
TodayCount: homeSummary.TodayCount,
|
||||
DailyTarget: profileDailyTarget(profileView, now),
|
||||
ResistedCount: homeSummary.ResistedCount,
|
||||
ReducedFromYesterday: homeSummary.ReducedFromYesterday,
|
||||
ExceededYesterday: homeSummary.ExceededYesterday,
|
||||
ProfileCompleted: profileView.IsCompleted,
|
||||
},
|
||||
Motivation: motivation,
|
||||
QuickActions: []homeQuickAction{
|
||||
{Type: "log_smoke", Title: "记录抽烟", Primary: false},
|
||||
{Type: "resist", Title: "想抽忍住了", Primary: true},
|
||||
},
|
||||
DataSources: homeDataSources{
|
||||
AdviceDate: adviceCard.Date,
|
||||
PlanDate: planDate.Format(dateLayout),
|
||||
},
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model.Success(response))
|
||||
}
|
||||
|
||||
func buildAdviceCard() homeAdviceCard {
|
||||
return homeAdviceCard{
|
||||
Title: "智能控烟建议",
|
||||
Status: "empty",
|
||||
}
|
||||
}
|
||||
|
||||
func greetingBlock(nickname string, avatar string, now time.Time) homeGreeting {
|
||||
greetKey, greetText, sub := greetingText(now)
|
||||
name := strings.TrimSpace(nickname)
|
||||
if name == "" {
|
||||
name = "朋友"
|
||||
}
|
||||
return homeGreeting{
|
||||
Title: fmt.Sprintf("%s,%s", greetText, name),
|
||||
Subtitle: sub,
|
||||
Nickname: name,
|
||||
TimeOfDay: greetKey,
|
||||
AvatarURL: avatar,
|
||||
}
|
||||
}
|
||||
|
||||
func greetingText(now time.Time) (string, string, string) {
|
||||
hour := now.Hour()
|
||||
switch {
|
||||
case hour >= 5 && hour < 11:
|
||||
return "morning", "早安", "今天也是清爽的一天"
|
||||
case hour >= 11 && hour < 14:
|
||||
return "noon", "午安", "补充水分和能量"
|
||||
case hour >= 14 && hour < 19:
|
||||
return "afternoon", "下午好", "保持呼吸节奏"
|
||||
case hour >= 19 || hour < 5:
|
||||
return "evening", "晚上好", "放松心情早点休息"
|
||||
default:
|
||||
return "hello", "你好", "和自己好好相处"
|
||||
}
|
||||
}
|
||||
|
||||
func formatRFC3339(t *time.Time) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
return t.In(time.Local).Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func formatClock(t *time.Time) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
return t.In(time.Local).Format("15:04")
|
||||
}
|
||||
|
||||
func profileDailyTarget(view smokeservice.SmokeProfileView, now time.Time) int {
|
||||
if view.Profile == nil || view.Profile.BaselineCigsPerDay <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
base := view.Profile.BaselineCigsPerDay
|
||||
if view.Profile.QuitDate == nil || view.Profile.OnboardingCompletedAt == nil {
|
||||
return base
|
||||
}
|
||||
|
||||
start := dateOnlyLocal(*view.Profile.OnboardingCompletedAt)
|
||||
quit := dateOnlyLocal(*view.Profile.QuitDate)
|
||||
today := dateOnlyLocal(now)
|
||||
|
||||
if !today.Before(quit) {
|
||||
return 0
|
||||
}
|
||||
|
||||
if !quit.After(start) {
|
||||
return base
|
||||
}
|
||||
|
||||
totalDays := daysBetweenDate(start, quit)
|
||||
if totalDays <= 0 {
|
||||
return base
|
||||
}
|
||||
|
||||
remainingDays := daysBetweenDate(today, quit)
|
||||
if remainingDays < 0 {
|
||||
return 0
|
||||
}
|
||||
if remainingDays > totalDays {
|
||||
remainingDays = totalDays
|
||||
}
|
||||
|
||||
target := int(math.Round(float64(base) * float64(remainingDays) / float64(totalDays)))
|
||||
if remainingDays > 0 && target < 1 {
|
||||
target = 1
|
||||
}
|
||||
if target > base {
|
||||
target = base
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
func daysBetweenDate(start, end time.Time) int {
|
||||
start = dateOnlyLocal(start)
|
||||
end = dateOnlyLocal(end)
|
||||
return int(end.Sub(start).Hours() / 24)
|
||||
}
|
||||
@@ -11,11 +11,7 @@ import (
|
||||
)
|
||||
|
||||
func (h *SmokeHandler) Motivation(c *gin.Context) {
|
||||
user, ok := middleware.CurrentUser(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
|
||||
return
|
||||
}
|
||||
user := middleware.MustCurrentUser(c)
|
||||
|
||||
profile, err := h.smokeProfileService.Get(c.Request.Context(), int(user.ID))
|
||||
if err != nil {
|
||||
|
||||
@@ -29,11 +29,7 @@ type nextSmokeTimeUnifiedResponse struct {
|
||||
}
|
||||
|
||||
func (h *SmokeHandler) GetNextSmokeTime(c *gin.Context) {
|
||||
user, ok := middleware.CurrentUser(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
|
||||
return
|
||||
}
|
||||
user := middleware.MustCurrentUser(c)
|
||||
|
||||
asOf := time.Now().In(time.Local)
|
||||
planDate := dateOnlyLocal(asOf)
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -22,14 +24,12 @@ type upsertSmokeProfileRequest struct {
|
||||
|
||||
WakeUpTime *string `json:"wake_up_time"`
|
||||
SleepTime *string `json:"sleep_time"`
|
||||
|
||||
QuitDate *string `json:"quit_date"`
|
||||
}
|
||||
|
||||
func (h *SmokeHandler) GetProfile(c *gin.Context) {
|
||||
user, ok := middleware.CurrentUser(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
|
||||
return
|
||||
}
|
||||
user := middleware.MustCurrentUser(c)
|
||||
|
||||
view, err := h.smokeProfileService.GetView(c.Request.Context(), int(user.ID))
|
||||
if err != nil {
|
||||
@@ -45,11 +45,7 @@ func (h *SmokeHandler) GetProfile(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *SmokeHandler) UpsertProfile(c *gin.Context) {
|
||||
user, ok := middleware.CurrentUser(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
|
||||
return
|
||||
}
|
||||
user := middleware.MustCurrentUser(c)
|
||||
|
||||
var req upsertSmokeProfileRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
|
||||
@@ -76,6 +72,21 @@ func (h *SmokeHandler) UpsertProfile(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
quitDateProvided := false
|
||||
var quitDate *time.Time
|
||||
if req.QuitDate != nil {
|
||||
quitDateProvided = true
|
||||
value := strings.TrimSpace(*req.QuitDate)
|
||||
if value != "" {
|
||||
parsed, err := time.ParseInLocation(dateLayout, value, time.Local)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "quit_date 格式错误,应为 YYYY-MM-DD"))
|
||||
return
|
||||
}
|
||||
quitDate = &parsed
|
||||
}
|
||||
}
|
||||
|
||||
view, err := h.smokeProfileService.Upsert(c.Request.Context(), int(user.ID), smokeservice.UpsertSmokeProfileRequest{
|
||||
BaselineCigsPerDay: req.BaselineCigsPerDay,
|
||||
SmokingYears: req.SmokingYears,
|
||||
@@ -84,6 +95,8 @@ func (h *SmokeHandler) UpsertProfile(c *gin.Context) {
|
||||
QuitMotivations: req.QuitMotivations,
|
||||
WakeUpTime: req.WakeUpTime,
|
||||
SleepTime: req.SleepTime,
|
||||
QuitDateProvided: quitDateProvided,
|
||||
QuitDate: quitDate,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, smokeservice.ErrSmokeProfileInvalidTime) {
|
||||
@@ -96,4 +109,3 @@ func (h *SmokeHandler) UpsertProfile(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, model.Success(view))
|
||||
}
|
||||
|
||||
|
||||
@@ -14,11 +14,7 @@ import (
|
||||
)
|
||||
|
||||
func (h *SmokeHandler) Stats(c *gin.Context) {
|
||||
user, ok := middleware.CurrentUser(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
|
||||
return
|
||||
}
|
||||
user := middleware.MustCurrentUser(c)
|
||||
|
||||
rangeType := strings.ToLower(strings.TrimSpace(c.DefaultQuery("range", "week")))
|
||||
asOf := time.Now().In(time.Local)
|
||||
|
||||
@@ -71,6 +71,8 @@ type SmokeUserProfile struct {
|
||||
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"`
|
||||
|
||||
QuitDate *time.Time `gorm:"column:quit_date;type:date;comment:目标戒烟日期" json:"quit_date,omitempty"`
|
||||
|
||||
OnboardingCompletedAt *time.Time `gorm:"column:onboarding_completed_at;comment:首次补全完成时间" json:"onboarding_completed_at,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -402,7 +402,7 @@ func (s *SmokeAINextSmokeService) loadRecent3Days(ctx context.Context, uid int,
|
||||
}
|
||||
snap := ensure(day)
|
||||
|
||||
isResisted := l.Level == 0 && l.Num == 0
|
||||
isResisted := l.Num == 0
|
||||
if isResisted {
|
||||
snap.ResistedCount++
|
||||
} else if l.Num > 0 {
|
||||
|
||||
@@ -95,6 +95,7 @@ type ListSmokeLogsRequest struct {
|
||||
PageSize int
|
||||
Start *time.Time
|
||||
End *time.Time
|
||||
Type string
|
||||
}
|
||||
|
||||
type ListSmokeLogsResult struct {
|
||||
@@ -124,6 +125,7 @@ type SmokeHomeSummary struct {
|
||||
ResistedCount int
|
||||
ReducedFromYesterday int
|
||||
ExceededYesterday bool
|
||||
SecondsSinceLast int
|
||||
}
|
||||
|
||||
// DashboardWeeklyStat 表示某一天的抽烟支数以及是否为今天。
|
||||
@@ -155,6 +157,14 @@ func (s *SmokeLogService) List(ctx context.Context, uid int, req ListSmokeLogsRe
|
||||
if req.End != nil {
|
||||
tx = tx.Where("smoke_time <= ?", req.End.Format("2006-01-02"))
|
||||
}
|
||||
switch req.Type {
|
||||
case "", "all":
|
||||
// no-op
|
||||
case "smoke":
|
||||
tx = tx.Where("num > 0")
|
||||
case "resisted":
|
||||
tx = tx.Where("num = 0")
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := tx.Count(&total).Error; err != nil {
|
||||
@@ -164,7 +174,7 @@ func (s *SmokeLogService) List(ctx context.Context, uid int, req ListSmokeLogsRe
|
||||
var items []smokemodel.SmokeLog
|
||||
offset := (page - 1) * pageSize
|
||||
if err := tx.
|
||||
Order("smoke_time DESC").
|
||||
Order("COALESCE(smoke_at, FROM_UNIXTIME(createtime), smoke_time) DESC").
|
||||
Order("id DESC").
|
||||
Limit(pageSize).
|
||||
Offset(offset).
|
||||
@@ -221,7 +231,7 @@ func (s *SmokeLogService) Dashboard(ctx context.Context, uid int, req SmokeDashb
|
||||
var last smokemodel.SmokeLog
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("uid = ? AND (deletetime IS NULL OR deletetime = 0)", uid).
|
||||
Where("NOT (level = 0 AND num = 0)").
|
||||
Where("num > 0").
|
||||
Order("COALESCE(smoke_at, FROM_UNIXTIME(createtime), smoke_time) DESC").
|
||||
Order("id DESC").
|
||||
Limit(1).
|
||||
@@ -259,6 +269,7 @@ func (s *SmokeLogService) Dashboard(ctx context.Context, uid int, req SmokeDashb
|
||||
|
||||
// HomeSummary 返回首页所需的汇总数据(不包含时间范围的周统计)。
|
||||
func (s *SmokeLogService) HomeSummary(ctx context.Context, uid int, asOf time.Time) (SmokeHomeSummary, error) {
|
||||
localAsOf := asOf.In(time.Local)
|
||||
today := dateOnly(asOf)
|
||||
todayKey := today.Format("2006-01-02")
|
||||
yesterdayKey := today.AddDate(0, 0, -1).Format("2006-01-02")
|
||||
@@ -276,7 +287,7 @@ func (s *SmokeLogService) HomeSummary(ctx context.Context, uid int, asOf time.Ti
|
||||
if err := s.db.WithContext(ctx).
|
||||
Model(&smokemodel.SmokeLog{}).
|
||||
Where("uid = ? AND (deletetime IS NULL OR deletetime = 0)", uid).
|
||||
Where("level = 0 AND num = 0 AND smoke_time = ?", todayKey).
|
||||
Where("num = 0 AND smoke_time = ?", todayKey).
|
||||
Count(&resistedCount).Error; err != nil {
|
||||
return SmokeHomeSummary{}, fmt.Errorf("count resisted logs: %w", err)
|
||||
}
|
||||
@@ -290,14 +301,22 @@ func (s *SmokeLogService) HomeSummary(ctx context.Context, uid int, asOf time.Ti
|
||||
return SmokeHomeSummary{}, fmt.Errorf("count yesterday smoke logs: %w", err)
|
||||
}
|
||||
|
||||
reduced := int(yesterdayCount - todayCount)
|
||||
exceeded := reduced < 0
|
||||
diffFromYesterday := int(yesterdayCount - todayCount)
|
||||
reduced := 0
|
||||
exceeded := false
|
||||
if diffFromYesterday > 0 {
|
||||
reduced = diffFromYesterday
|
||||
} else if diffFromYesterday < 0 {
|
||||
reduced = -diffFromYesterday
|
||||
exceeded = true
|
||||
}
|
||||
|
||||
var lastSmokeAt *time.Time
|
||||
secondsSinceLast := -1
|
||||
var last smokemodel.SmokeLog
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("uid = ? AND (deletetime IS NULL OR deletetime = 0)", uid).
|
||||
Where("NOT (level = 0 AND num = 0)").
|
||||
Where("num > 0").
|
||||
Order("COALESCE(smoke_at, FROM_UNIXTIME(createtime), smoke_time) DESC").
|
||||
Order("id DESC").
|
||||
Limit(1).
|
||||
@@ -306,7 +325,15 @@ func (s *SmokeLogService) HomeSummary(ctx context.Context, uid int, asOf time.Ti
|
||||
return SmokeHomeSummary{}, fmt.Errorf("load last smoke log: %w", err)
|
||||
}
|
||||
} else if t, ok := lastEventTime(last); ok {
|
||||
if t.After(localAsOf) {
|
||||
t = localAsOf
|
||||
}
|
||||
lastSmokeAt = &t
|
||||
diff := int(localAsOf.Sub(t).Seconds())
|
||||
if diff < 0 {
|
||||
diff = 0
|
||||
}
|
||||
secondsSinceLast = diff
|
||||
}
|
||||
|
||||
return SmokeHomeSummary{
|
||||
@@ -315,6 +342,7 @@ func (s *SmokeLogService) HomeSummary(ctx context.Context, uid int, asOf time.Ti
|
||||
ResistedCount: int(resistedCount),
|
||||
ReducedFromYesterday: reduced,
|
||||
ExceededYesterday: exceeded,
|
||||
SecondsSinceLast: secondsSinceLast,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ type NextSmokeSuggestion struct {
|
||||
func (s *SmokeNextService) GetDefaultSuggestion(ctx context.Context, uid int, asOf time.Time, planDate time.Time, profileView SmokeProfileView) (NextSmokeSuggestion, error) {
|
||||
now := asOf.In(time.Local)
|
||||
planDay := dateOnly(planDate)
|
||||
today := dateOnly(now)
|
||||
|
||||
base := profileView.BaselineIntervalMinute
|
||||
if base <= 0 {
|
||||
@@ -46,8 +47,11 @@ func (s *SmokeNextService) GetDefaultSuggestion(ctx context.Context, uid int, as
|
||||
return NextSmokeSuggestion{}, err
|
||||
}
|
||||
if !ok {
|
||||
nowCopy := now
|
||||
lastSmokeAt = &nowCopy
|
||||
lastCopy := now
|
||||
lastSmokeAt = &lastCopy
|
||||
} else if lastSmokeAt.After(now) {
|
||||
clamped := now
|
||||
lastSmokeAt = &clamped
|
||||
}
|
||||
|
||||
resisted, err := s.countResistedLastDays(ctx, uid, 7)
|
||||
@@ -68,7 +72,22 @@ func (s *SmokeNextService) GetDefaultSuggestion(ctx context.Context, uid int, as
|
||||
interval = 240
|
||||
}
|
||||
|
||||
next := lastSmokeAt.Add(time.Duration(interval) * time.Minute)
|
||||
intervalDuration := time.Duration(interval) * time.Minute
|
||||
next := lastSmokeAt.Add(intervalDuration)
|
||||
|
||||
if !planDay.After(today) && next.Before(now) {
|
||||
if intervalDuration <= 0 {
|
||||
next = now
|
||||
} else {
|
||||
elapsed := now.Sub(*lastSmokeAt)
|
||||
missed := int(elapsed / intervalDuration)
|
||||
if missed < 0 {
|
||||
missed = 0
|
||||
}
|
||||
next = lastSmokeAt.Add(time.Duration(missed+1) * intervalDuration)
|
||||
}
|
||||
}
|
||||
|
||||
sleepAdjusted := false
|
||||
|
||||
var wakeUp, sleep string
|
||||
@@ -78,7 +97,6 @@ func (s *SmokeNextService) GetDefaultSuggestion(ctx context.Context, uid int, as
|
||||
}
|
||||
|
||||
// 如果是“生成某一天的计划”(例如明天),默认不早于该日的起床时间(若未配置则使用 07:00)。
|
||||
today := dateOnly(now)
|
||||
if planDay.After(today) {
|
||||
minNotBefore := time.Date(planDay.Year(), planDay.Month(), planDay.Day(), 7, 0, 0, 0, time.Local)
|
||||
if wakeUp != "" {
|
||||
@@ -123,7 +141,7 @@ func (s *SmokeNextService) loadLastActualSmokeAt(ctx context.Context, uid int) (
|
||||
var last smokemodel.SmokeLog
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("uid = ? AND (deletetime IS NULL OR deletetime = 0)", uid).
|
||||
Where("NOT (level = 0 AND num = 0)").
|
||||
Where("num > 0").
|
||||
Order("COALESCE(smoke_at, FROM_UNIXTIME(createtime), smoke_time) DESC").
|
||||
Order("id DESC").
|
||||
Limit(1).
|
||||
@@ -152,7 +170,7 @@ func (s *SmokeNextService) countResistedLastDays(ctx context.Context, uid int, d
|
||||
if err := s.db.WithContext(ctx).
|
||||
Model(&smokemodel.SmokeLog{}).
|
||||
Where("uid = ? AND (deletetime IS NULL OR deletetime = 0)", uid).
|
||||
Where("level = 0 AND num = 0").
|
||||
Where("num = 0").
|
||||
Where("smoke_time BETWEEN ? AND ?", start.Format("2006-01-02"), end.Format("2006-01-02")).
|
||||
Count(&count).Error; err != nil {
|
||||
return 0, fmt.Errorf("count resisted logs: %w", err)
|
||||
|
||||
@@ -89,6 +89,9 @@ type UpsertSmokeProfileRequest struct {
|
||||
|
||||
WakeUpTime *string
|
||||
SleepTime *string
|
||||
|
||||
QuitDateProvided bool
|
||||
QuitDate *time.Time
|
||||
}
|
||||
|
||||
func (s *SmokeProfileService) Upsert(ctx context.Context, uid int, req UpsertSmokeProfileRequest) (SmokeProfileView, error) {
|
||||
@@ -146,6 +149,9 @@ func (s *SmokeProfileService) Upsert(ctx context.Context, uid int, req UpsertSmo
|
||||
if err := applyTimeStr(&profile.SleepTime, req.SleepTime); err != nil {
|
||||
return SmokeProfileView{}, err
|
||||
}
|
||||
if req.QuitDateProvided {
|
||||
profile.QuitDate = req.QuitDate
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if profile.OnboardingCompletedAt == nil && isSmokeProfileCompleted(profile) {
|
||||
@@ -195,7 +201,7 @@ func awakeMinutesWithFallback(wakeUp, sleep string) (int, error) {
|
||||
if sleepMin > wakeMin {
|
||||
return sleepMin - wakeMin, nil
|
||||
}
|
||||
return (24 * 60 - wakeMin) + sleepMin, nil
|
||||
return (24*60 - wakeMin) + sleepMin, nil
|
||||
}
|
||||
|
||||
func baselineIntervalMinutes(awakeMinutes int, baselineCigsPerDay int) int {
|
||||
|
||||
@@ -83,6 +83,7 @@ func (s *SmokeLogService) Stats(ctx context.Context, uid int, req SmokeStatsRequ
|
||||
if err != nil {
|
||||
return SmokeStatsResult{}, err
|
||||
}
|
||||
trend = limitTrend(trend, 7)
|
||||
|
||||
dayCount := daysBetweenInclusive(start, end)
|
||||
dailyAvg := 0
|
||||
@@ -109,7 +110,7 @@ func (s *SmokeLogService) Stats(ctx context.Context, uid int, req SmokeStatsRequ
|
||||
return SmokeStatsResult{}, err
|
||||
}
|
||||
|
||||
money := s.computeMoney(profile, int(total), dayCount)
|
||||
money := s.computeMoney(ctx, uid, profile, int(total), start, end)
|
||||
health, err := s.computeHealth(ctx, uid, req.AsOf)
|
||||
if err != nil {
|
||||
return SmokeStatsResult{}, err
|
||||
@@ -130,6 +131,24 @@ func (s *SmokeLogService) Stats(ctx context.Context, uid int, req SmokeStatsRequ
|
||||
}, nil
|
||||
}
|
||||
|
||||
func limitTrend(items []SmokeStatsTrend, max int) []SmokeStatsTrend {
|
||||
if max <= 0 || len(items) <= max {
|
||||
return items
|
||||
}
|
||||
lastIndex := len(items) - 1
|
||||
out := make([]SmokeStatsTrend, 0, max)
|
||||
seen := make(map[int]struct{}, max)
|
||||
for i := 0; i < max; i++ {
|
||||
pos := int(math.Round(float64(i) * float64(lastIndex) / float64(max-1)))
|
||||
if _, ok := seen[pos]; ok {
|
||||
continue
|
||||
}
|
||||
seen[pos] = struct{}{}
|
||||
out = append(out, items[pos])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *SmokeLogService) loadDailyTrend(ctx context.Context, uid int, start, end time.Time) ([]SmokeStatsTrend, int64, error) {
|
||||
type dailyCount struct {
|
||||
SmokeTime time.Time `gorm:"column:smoke_time"`
|
||||
@@ -225,7 +244,7 @@ func (s *SmokeLogService) countResisted(ctx context.Context, uid int, start, end
|
||||
if err := s.db.WithContext(ctx).
|
||||
Model(&smokemodel.SmokeLog{}).
|
||||
Where("uid = ? AND (deletetime IS NULL OR deletetime = 0)", uid).
|
||||
Where("level = 0 AND num = 0").
|
||||
Where("num = 0").
|
||||
Where("smoke_time BETWEEN ? AND ?", start.Format("2006-01-02"), end.Format("2006-01-02")).
|
||||
Count(&count).Error; err != nil {
|
||||
return 0, fmt.Errorf("count resisted: %w", err)
|
||||
@@ -267,12 +286,31 @@ func (s *SmokeLogService) computeStreakDays(ctx context.Context, uid int, asOf t
|
||||
return streak, nil
|
||||
}
|
||||
|
||||
func (s *SmokeLogService) computeMoney(profile *smokemodel.SmokeUserProfile, actualTotal int, dayCount int) SmokeStatsMoney {
|
||||
if profile == nil || profile.BaselineCigsPerDay <= 0 || profile.PackPriceCent <= 0 || dayCount <= 0 {
|
||||
func (s *SmokeLogService) computeMoney(ctx context.Context, uid int, profile *smokemodel.SmokeUserProfile, actualTotal int, start, end time.Time) SmokeStatsMoney {
|
||||
if profile == nil || profile.BaselineCigsPerDay <= 0 || profile.PackPriceCent <= 0 {
|
||||
return SmokeStatsMoney{Available: false}
|
||||
}
|
||||
expectedTotal := profile.BaselineCigsPerDay * dayCount
|
||||
|
||||
activeDays, err := s.countLogDays(ctx, uid, start, end)
|
||||
if err != nil {
|
||||
return SmokeStatsMoney{Available: false}
|
||||
}
|
||||
if activeDays <= 0 {
|
||||
return SmokeStatsMoney{
|
||||
Available: true,
|
||||
PackPriceCent: profile.PackPriceCent,
|
||||
CigsPerPack: defaultCigsPerPack,
|
||||
ExpectedTotal: 0,
|
||||
ActualTotal: actualTotal,
|
||||
SavedCent: 0,
|
||||
}
|
||||
}
|
||||
|
||||
expectedTotal := profile.BaselineCigsPerDay * activeDays
|
||||
savedCigs := expectedTotal - actualTotal
|
||||
if savedCigs < 0 {
|
||||
savedCigs = 0
|
||||
}
|
||||
savedPacks := float64(savedCigs) / float64(defaultCigsPerPack)
|
||||
savedCent := int(math.Round(savedPacks * float64(profile.PackPriceCent)))
|
||||
return SmokeStatsMoney{
|
||||
@@ -285,6 +323,25 @@ func (s *SmokeLogService) computeMoney(profile *smokemodel.SmokeUserProfile, act
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SmokeLogService) countLogDays(ctx context.Context, uid int, start, end time.Time) (int, error) {
|
||||
start = dateOnly(start)
|
||||
end = dateOnly(end)
|
||||
|
||||
type row struct {
|
||||
SmokeTime time.Time `gorm:"column:smoke_time"`
|
||||
}
|
||||
var rows []row
|
||||
if err := s.db.WithContext(ctx).
|
||||
Model(&smokemodel.SmokeLog{}).
|
||||
Distinct("smoke_time").
|
||||
Where("uid = ? AND (deletetime IS NULL OR deletetime = 0)", uid).
|
||||
Where("smoke_time BETWEEN ? AND ?", start.Format("2006-01-02"), end.Format("2006-01-02")).
|
||||
Find(&rows).Error; err != nil {
|
||||
return 0, fmt.Errorf("count log days: %w", err)
|
||||
}
|
||||
return len(rows), nil
|
||||
}
|
||||
|
||||
func (s *SmokeLogService) computeHealth(ctx context.Context, uid int, asOf time.Time) (SmokeStatsHealth, error) {
|
||||
lastSmokeAt, err := s.loadLastActualSmokeAt(ctx, uid)
|
||||
if err != nil {
|
||||
@@ -311,7 +368,7 @@ func (s *SmokeLogService) loadLastActualSmokeAt(ctx context.Context, uid int) (*
|
||||
var last smokemodel.SmokeLog
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("uid = ? AND (deletetime IS NULL OR deletetime = 0)", uid).
|
||||
Where("NOT (level = 0 AND num = 0)").
|
||||
Where("num > 0").
|
||||
Order("COALESCE(smoke_at, FROM_UNIXTIME(createtime), smoke_time) DESC").
|
||||
Order("id DESC").
|
||||
Limit(1).
|
||||
|
||||
Reference in New Issue
Block a user