diff --git a/docs/smoke/ALGORITHM.md b/docs/smoke/ALGORITHM.md index 901500b..b2522df 100644 --- a/docs/smoke/ALGORITHM.md +++ b/docs/smoke/ALGORITHM.md @@ -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 - } - - if (stage === 2) { - const reduction = (dayInStage / 14) * 0.5 - return Math.max(Math.round(baseline * (1 - reduction)), 1) - } - - if (stage === 3) { - const targetRate = 0.25 - (dayInStage / 9) * 0.25 - return Math.max(Math.round(baseline * targetRate), 0) - } - - return baseline +function calculateDailyTarget(baseline, startDate, quitDate, today) { + if (!baseline || !startDate || !quitDate) return baseline + if (today >= quitDate) return 0 + + const totalDays = daysBetween(startDate, quitDate) + if (totalDays <= 0) return baseline + + const remainingDays = daysBetween(today, quitDate) + const target = Math.round(baseline * (remainingDays / totalDays)) + return remainingDays > 0 ? Math.max(target, 1) : 0 } ``` diff --git a/docs/smoke/API.md b/docs/smoke/API.md index 2f7a561..ccc353a 100644 --- a/docs/smoke/API.md +++ b/docs/smoke/API.md @@ -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` diff --git a/internal/common/qiniu/handler/upload_handler.go b/internal/common/qiniu/handler/upload_handler.go index 3af6ecf..f354fb3 100644 --- a/internal/common/qiniu/handler/upload_handler.go +++ b/internal/common/qiniu/handler/upload_handler.go @@ -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 diff --git a/internal/membership/handler/redeem_code_handler.go b/internal/membership/handler/redeem_code_handler.go index e79223e..67f056b 100644 --- a/internal/membership/handler/redeem_code_handler.go +++ b/internal/membership/handler/redeem_code_handler.go @@ -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 { diff --git a/internal/middleware/current_user.go b/internal/middleware/current_user.go index 5cdcc7d..902cc61 100644 --- a/internal/middleware/current_user.go +++ b/internal/middleware/current_user.go @@ -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() + } +} diff --git a/internal/remove_watermark/handler/video_handler.go b/internal/remove_watermark/handler/video_handler.go index 7e33fdf..b840dae 100644 --- a/internal/remove_watermark/handler/video_handler.go +++ b/internal/remove_watermark/handler/video_handler.go @@ -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, "解锁失败,请稍后重试")) diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 1b39d06..966acdf 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -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) diff --git a/internal/routes/smoke_routes.go b/internal/routes/smoke_routes.go index c7097dd..92b3922 100644 --- a/internal/routes/smoke_routes.go +++ b/internal/routes/smoke_routes.go @@ -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) diff --git a/internal/smoke/handler/smoke_ai_handler.go b/internal/smoke/handler/smoke_ai_handler.go index b3460cb..67d51e7 100644 --- a/internal/smoke/handler/smoke_ai_handler.go +++ b/internal/smoke/handler/smoke_ai_handler.go @@ -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) { diff --git a/internal/smoke/handler/smoke_handler.go b/internal/smoke/handler/smoke_handler.go index 7bcffcd..c26d954 100644 --- a/internal/smoke/handler/smoke_handler.go +++ b/internal/smoke/handler/smoke_handler.go @@ -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,8 +370,12 @@ 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")) - return + 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 { diff --git a/internal/smoke/handler/smoke_home_handler.go b/internal/smoke/handler/smoke_home_handler.go new file mode 100644 index 0000000..92ed633 --- /dev/null +++ b/internal/smoke/handler/smoke_home_handler.go @@ -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) +} diff --git a/internal/smoke/handler/smoke_motivation_handler.go b/internal/smoke/handler/smoke_motivation_handler.go index 716eca2..8ba3c6e 100644 --- a/internal/smoke/handler/smoke_motivation_handler.go +++ b/internal/smoke/handler/smoke_motivation_handler.go @@ -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 { diff --git a/internal/smoke/handler/smoke_next_handler.go b/internal/smoke/handler/smoke_next_handler.go index 5a91c53..6d2932d 100644 --- a/internal/smoke/handler/smoke_next_handler.go +++ b/internal/smoke/handler/smoke_next_handler.go @@ -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) diff --git a/internal/smoke/handler/smoke_profile_handler.go b/internal/smoke/handler/smoke_profile_handler.go index 559a4e9..29f592f 100644 --- a/internal/smoke/handler/smoke_profile_handler.go +++ b/internal/smoke/handler/smoke_profile_handler.go @@ -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)) } - diff --git a/internal/smoke/handler/smoke_stats_handler.go b/internal/smoke/handler/smoke_stats_handler.go index 2f48e1a..158f1c7 100644 --- a/internal/smoke/handler/smoke_stats_handler.go +++ b/internal/smoke/handler/smoke_stats_handler.go @@ -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) diff --git a/internal/smoke/model/smoke_profile.go b/internal/smoke/model/smoke_profile.go index d37b83f..72c1fe1 100644 --- a/internal/smoke/model/smoke_profile.go +++ b/internal/smoke/model/smoke_profile.go @@ -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"` } diff --git a/internal/smoke/service/smoke_ai_next_smoke_service.go b/internal/smoke/service/smoke_ai_next_smoke_service.go index 706d4b0..903e560 100644 --- a/internal/smoke/service/smoke_ai_next_smoke_service.go +++ b/internal/smoke/service/smoke_ai_next_smoke_service.go @@ -44,12 +44,12 @@ func NewSmokeAINextSmokeService(db *gorm.DB, cfg config.AIConfig) *SmokeAINextSm } type aiNextSmokeInput struct { - AsOf string `json:"as_of"` - PlanDate string `json:"plan_date"` - MinNotBeforeAt string `json:"min_not_before_at"` + AsOf string `json:"as_of"` + PlanDate string `json:"plan_date"` + MinNotBeforeAt string `json:"min_not_before_at"` DefaultSuggestion NextSmokeSuggestion `json:"default_suggestion"` - Profile *adviceUserProfile `json:"profile,omitempty"` - Recent3Days []recentDaySnapshot `json:"recent_3_days"` + Profile *adviceUserProfile `json:"profile,omitempty"` + Recent3Days []recentDaySnapshot `json:"recent_3_days"` } type aiNextSmokeOutput struct { @@ -75,14 +75,14 @@ type recentDayNode struct { } type AINextSmokeSuggestion struct { - PlanDate string `json:"plan_date"` - NotBeforeAt string `json:"not_before_at"` - SuggestedAt string `json:"suggested_at"` - TimeNodes []string `json:"time_nodes"` - Advice string `json:"advice"` - PromptVersion string `json:"prompt_version"` - Model string `json:"model,omitempty"` - Provider string `json:"provider,omitempty"` + PlanDate string `json:"plan_date"` + NotBeforeAt string `json:"not_before_at"` + SuggestedAt string `json:"suggested_at"` + TimeNodes []string `json:"time_nodes"` + Advice string `json:"advice"` + PromptVersion string `json:"prompt_version"` + Model string `json:"model,omitempty"` + Provider string `json:"provider,omitempty"` } func (s *SmokeAINextSmokeService) GetOrGenerate(ctx context.Context, user *usermodel.User, asOf time.Time, planDate time.Time, promptVersion string, defaultSuggestion NextSmokeSuggestion) (AINextSmokeSuggestion, error) { @@ -122,12 +122,12 @@ func (s *SmokeAINextSmokeService) GetOrGenerate(ctx context.Context, user *userm minNotBefore := s.computeMinNotBefore(asOf, planDate, defaultSuggestion, profile) input := aiNextSmokeInput{ - AsOf: asOf.In(time.Local).Format(time.RFC3339), - PlanDate: planDate.Format("2006-01-02"), - MinNotBeforeAt: minNotBefore.In(time.Local).Format(time.RFC3339), - DefaultSuggestion: defaultSuggestion, - Profile: profile, - Recent3Days: recent, + AsOf: asOf.In(time.Local).Format(time.RFC3339), + PlanDate: planDate.Format("2006-01-02"), + MinNotBeforeAt: minNotBefore.In(time.Local).Format(time.RFC3339), + DefaultSuggestion: defaultSuggestion, + Profile: profile, + Recent3Days: recent, } inputJSON, _ := json.Marshal(input) @@ -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 { diff --git a/internal/smoke/service/smoke_log_service.go b/internal/smoke/service/smoke_log_service.go index 6dc6132..f3ab854 100644 --- a/internal/smoke/service/smoke_log_service.go +++ b/internal/smoke/service/smoke_log_service.go @@ -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 } diff --git a/internal/smoke/service/smoke_next_service.go b/internal/smoke/service/smoke_next_service.go index cd78377..23ae586 100644 --- a/internal/smoke/service/smoke_next_service.go +++ b/internal/smoke/service/smoke_next_service.go @@ -20,21 +20,22 @@ func NewSmokeNextService(db *gorm.DB) *SmokeNextService { } type NextSmokeSuggestion struct { - LastSmokeAt *time.Time `json:"last_smoke_at,omitempty"` - NextSmokeAt *time.Time `json:"next_smoke_at,omitempty"` - BaseIntervalMinutes int `json:"base_interval_minutes"` - IntervalMinutes int `json:"interval_minutes"` - Stage int `json:"stage"` - Resisted7d int `json:"resisted_7d"` - SleepAdjusted bool `json:"sleep_adjusted"` - Algorithm string `json:"algorithm"` - AsOf string `json:"as_of"` + LastSmokeAt *time.Time `json:"last_smoke_at,omitempty"` + NextSmokeAt *time.Time `json:"next_smoke_at,omitempty"` + BaseIntervalMinutes int `json:"base_interval_minutes"` + IntervalMinutes int `json:"interval_minutes"` + Stage int `json:"stage"` + Resisted7d int `json:"resisted_7d"` + SleepAdjusted bool `json:"sleep_adjusted"` + Algorithm string `json:"algorithm"` + AsOf string `json:"as_of"` } // GetDefaultSuggestion 返回“未使用 AI 时”的默认下次抽烟时间建议(阶梯式延时算法)。 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 != "" { @@ -105,7 +123,7 @@ func (s *SmokeNextService) GetDefaultSuggestion(ctx context.Context, uid int, as out := NextSmokeSuggestion{ BaseIntervalMinutes: base, IntervalMinutes: interval, - Stage: stage, + Stage: stage, Resisted7d: resisted, SleepAdjusted: sleepAdjusted, Algorithm: "staircase_delay_v1", @@ -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) diff --git a/internal/smoke/service/smoke_profile_service.go b/internal/smoke/service/smoke_profile_service.go index 29c93d9..abd8af4 100644 --- a/internal/smoke/service/smoke_profile_service.go +++ b/internal/smoke/service/smoke_profile_service.go @@ -25,7 +25,7 @@ func NewSmokeProfileService(db *gorm.DB) *SmokeProfileService { } type SmokeProfileView struct { - Exists bool `json:"exists"` + Exists bool `json:"exists"` Profile *smokemodel.SmokeUserProfile `json:"profile,omitempty"` IsCompleted bool `json:"is_completed"` @@ -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 { diff --git a/internal/smoke/service/smoke_stats_service.go b/internal/smoke/service/smoke_stats_service.go index de18ae0..b846ace 100644 --- a/internal/smoke/service/smoke_stats_service.go +++ b/internal/smoke/service/smoke_stats_service.go @@ -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).