diff --git a/cmd/api/main.go b/cmd/api/main.go index 44d118a..74d79f4 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -2,6 +2,7 @@ package main import ( "log" + "time" "github.com/gin-gonic/gin" @@ -31,6 +32,12 @@ import ( ) func main() { + if loc, err := time.LoadLocation("Asia/Shanghai"); err == nil { + time.Local = loc + } else { + time.Local = time.FixedZone("CST", 8*3600) + } + // 1) 加载配置(通常来自环境变量 / .env) config.LoadConfig() diff --git a/docs/smoke/ALGORITHM.md b/docs/smoke/ALGORITHM.md new file mode 100644 index 0000000..4b54bb5 --- /dev/null +++ b/docs/smoke/ALGORITHM.md @@ -0,0 +1,319 @@ +# 戒烟算法与 AI 策略说明 + +## 1. 核心理念 + +采用**渐进式递减**策略,而非突然戒断: +- 科学研究表明,逐步减少比冷火鸡戒断成功率更高 +- 通过延长抽烟间隔,让身体逐渐适应尼古丁减少 +- AI 分析个人模式,提供个性化的递减计划 + +--- + +## 2. 默认递减算法 (staircase_delay_v1) + +### 2.1 算法概述 + +``` +下次建议时间 = 上次抽烟时间 + 基础间隔 + 奖励间隔 +``` + +### 2.2 参数说明 + +| 参数 | 来源 | 默认值 | 说明 | +|------|------|--------|------| +| base_interval | profile.baseline_interval_minutes | 60 分钟 | 用户初始平均抽烟间隔 | +| resisted_7d | 近7天忍住次数 | 0 | level=0,num=0 的记录数 | +| bonus_interval | 计算得出 | 0 | 奖励延长时间 | + +### 2.3 奖励机制 + +每累计 **5 次忍住**,基础间隔 **+5 分钟**,最多 **+60 分钟**: + +``` +bonus_interval = min(floor(resisted_7d / 5) * 5, 60) +final_interval = base_interval + bonus_interval +``` + +**示例**: +- 用户基础间隔 48 分钟,近 7 天忍住 12 次 +- bonus = floor(12/5) * 5 = 10 分钟 +- 最终间隔 = 48 + 10 = 58 分钟 + +### 2.4 睡眠规避 + +若计算出的时间落在睡眠区间,顺延到次日起床时间: + +``` +if suggested_time in [sleep_time, wake_up_time]: + suggested_time = next_day_wake_up_time +``` + +### 2.5 算法流程图 + +``` +获取上次抽烟时间 (last_smoke_at) + ↓ +获取用户基础间隔 (base_interval_minutes) + ↓ +统计近7天忍住次数 (resisted_7d) + ↓ +计算奖励间隔: bonus = min(floor(resisted_7d / 5) * 5, 60) + ↓ +计算建议时间: suggested = last_smoke_at + base + bonus + ↓ +检查是否在睡眠时间? + ├── 是 → 顺延到起床时间 + └── 否 → 返回建议时间 +``` + +--- + +## 3. AI 增强算法 + +### 3.1 AI 时间节点规划 + +当用户解锁 AI 功能后,系统会: + +1. **收集近 3 天数据**: + - 每次抽烟的时间点 + - 每次忍住的时间点 + - 抽烟原因/场景标签 + +2. **分析抽烟模式**: + - 高峰时段识别(如下午 2-4 点) + - 触发场景识别(如压力、无聊、社交) + - 间隔规律分析 + +3. **生成个性化时间节点**: + - 避开高峰时段的前半小时 + - 在用户通常能忍住的时段设置节点 + - 逐日递增间隔 + +### 3.2 AI 建议内容 + +AI 会生成以下内容: + +```json +{ + "advice": "昨天你的吸烟量比限额少了2支,这是一个巨大的胜利!数据显示你的烟瘾在下午2点左右达到顶峰——今天试着那个时候去散散步。", + "time_nodes": [ + { "time": "09:30", "type": "suggested", "note": "第一支,早餐后" }, + { "time": "12:30", "type": "suggested", "note": "午餐后" }, + { "time": "15:30", "type": "suggested", "note": "下午茶时间" }, + { "time": "19:00", "type": "suggested", "note": "晚餐后" }, + { "time": "22:00", "type": "suggested", "note": "睡前最后一支" } + ], + "daily_target": 5, + "tips": ["2点是你的高峰期,准备一颗薄荷糖", "试着用深呼吸替代"] +} +``` + +### 3.3 AI Prompt 设计 + +``` +你是一位专业的戒烟辅导教练。基于用户的抽烟数据,提供个性化的戒烟建议。 + +用户档案: +- 日均吸烟量:{baseline_cigs_per_day} 支 +- 烟龄:{smoking_years} 年 +- 抽烟动机:{smoke_motivations} +- 戒烟动力:{quit_motivations} +- 作息:{wake_up_time} - {sleep_time} + +近3天数据: +{recent_logs} + +请分析: +1. 用户的抽烟规律和高峰时段 +2. 主要的触发场景 +3. 成功忍住的模式 + +然后生成: +1. 一段鼓励性的分析总结(2-3句话) +2. 明天的建议抽烟时间节点(比今天少1支) +3. 2-3条实用的应对建议 +``` + +--- + +## 4. 阶段划分 + +### 4.1 三阶段戒烟计划 + +| 阶段 | 时间 | 目标 | 策略 | +|------|------|------|------| +| 记录期 | Day 1-7 | 建立基线 | 正常抽烟,但每次都记录 | +| 减量期 | Day 8-21 | 减少 50% | 每周目标递减,AI 指导 | +| 巩固期 | Day 22-30 | 维持/归零 | 强化抵抗,心理建设 | + +### 4.2 阶段进度计算 + +```javascript +// utils/stage.js +function calculateStage(startDate) { + const daysSinceStart = daysBetween(startDate, new Date()) + + if (daysSinceStart <= 7) { + return { + stage: 1, + name: '记录期', + progress: daysSinceStart / 7, + daysLeft: 7 - daysSinceStart + } + } else if (daysSinceStart <= 21) { + return { + stage: 2, + name: '减量期', + progress: (daysSinceStart - 7) / 14, + daysLeft: 21 - daysSinceStart + } + } else { + return { + stage: 3, + name: '巩固期', + progress: Math.min((daysSinceStart - 21) / 9, 1), + daysLeft: Math.max(30 - daysSinceStart, 0) + } + } +} +``` + +### 4.3 每日目标计算 + +```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 +} +``` + +--- + +## 5. 健康恢复计算 + +基于医学研究的恢复时间线: + +| 时间点 | 恢复指标 | 计算方式 | +|--------|----------|----------| +| 20分钟 | 心率血压恢复正常 | 固定 | +| 8小时 | 血氧水平恢复 | 固定 | +| 24小时 | 心脏病风险开始下降 | 固定 | +| 48小时 | 嗅觉味觉开始恢复 | 固定 | +| 2周 | 肺功能提升 15% | 线性计算 | +| 1月 | 肺功能提升 30% | 线性计算 | +| 3月 | 肺功能提升 50% | 线性计算 | +| 1年 | 心脏病风险降低 50% | 线性计算 | + +```javascript +// utils/health.js +function calculateLungRecovery(smokeFreeMinutes) { + const days = smokeFreeMinutes / (24 * 60) + + if (days < 14) { + return (days / 14) * 15 + } else if (days < 30) { + return 15 + ((days - 14) / 16) * 15 + } else if (days < 90) { + return 30 + ((days - 30) / 60) * 20 + } else { + return Math.min(50 + ((days - 90) / 275) * 50, 100) + } +} +``` + +--- + +## 6. 省钱计算 + +```javascript +// utils/money.js +function calculateMoneySaved(packPriceCent, cigsPerPack, baselineCigsPerDay, actualCigsTotal, days) { + const expectedTotal = baselineCigsPerDay * days + const savedCigs = expectedTotal - actualCigsTotal + const savedPacks = savedCigs / cigsPerPack + return Math.round(savedPacks * packPriceCent) +} +``` + +--- + +## 7. 激励语生成 + +根据用户状态生成不同的激励语: + +```javascript +// utils/motivation.js +function getMotivationMessage(context) { + const { + minutesSinceLast, + todayCount, + dailyTarget, + resistedToday, + quitMotivations + } = context + + if (resistedToday > 0 && minutesSinceLast < 30) { + return '太棒了!你刚刚成功抵抗了一次烟瘾' + } + + if (todayCount < dailyTarget * 0.5) { + return '今天的表现非常出色,继续保持!' + } + + if (todayCount === dailyTarget - 1) { + return '还剩最后一支配额,考虑把它留到睡前?' + } + + if (todayCount > dailyTarget) { + return `没关系,明天是新的一天。记住你为什么要戒烟:${quitMotivations[0]}` + } + + return '保持连胜纪录!' +} +``` + +--- + +## 8. 数据分析指标 + +### 8.1 关键指标 + +| 指标 | 计算方式 | 用途 | +|------|----------|------| +| 日均吸烟量 | 周期内总量 / 天数 | 趋势对比 | +| 周同比变化 | (本周 - 上周) / 上周 | 进度评估 | +| 忍住成功率 | 忍住次数 / (忍住+抽烟次数) | 意志力评估 | +| 平均间隔 | 总时长 / 抽烟次数 | 递减效果 | +| 最长无烟时长 | 最大间隔记录 | 成就激励 | + +### 8.2 周报数据结构 + +```javascript +// 周报数据结构 +const weeklyReport = { + period: { start: '2026-01-01', end: '2026-01-07' }, + totalCigs: 35, + dailyAverage: 5, + comparedToLastWeek: -20, // 百分比变化 + resistedCount: 12, + longestGap: 180, // 分钟 + peakHours: ['14:00', '21:00'], + topTriggers: ['压力大', '无聊'], + achievements: ['连续7天记录', '单日忍住5次'], + nextWeekTarget: 4 +} +``` diff --git a/docs/smoke/API.md b/docs/smoke/API.md index f3b25a9..ac3f689 100644 --- a/docs/smoke/API.md +++ b/docs/smoke/API.md @@ -155,7 +155,7 @@ curl -X GET 'http://127.0.0.1:8080/api/v1/smoke/logs/5202' \ ## 6) 更新记录 -`PUT /api/v1/smoke/logs/:id` +`POST /api/v1/smoke/logs/:id` 请求体(字段可选,按需传): @@ -302,7 +302,7 @@ curl -X GET 'http://127.0.0.1:8080/api/v1/smoke/logs/5202' \ ## 11) 补全/更新用户基础信息(Upsert) -`PUT /api/v1/smoke/profile` +`POST /api/v1/smoke/profile` 说明: - 字段按需传;首次进入建议一次性补全。 @@ -348,6 +348,7 @@ curl -X GET 'http://127.0.0.1:8080/api/v1/smoke/logs/5202' \ 说明: - 用于首页展示“建议的下次记录时间”。 +- 已整合首页所需汇总字段(上次抽烟时间/今日抽烟支数/今日克制次数/较昨日减少支数)。 - 如果指定日期存在 AI 给出的时间节点(`time_nodes` 不为空),则优先使用 AI 的建议;否则使用默认策略。 - 可选参数: - `date`:计划日期(默认今天),支持 `YYYY-MM-DD` 或 `today/tomorrow`。 @@ -372,18 +373,22 @@ AI 生成说明: "message": "success", "data": { "source": "default", - "not_before_at": "2026-01-05T10:18:00+08:00", - "suggested_at": "2026-01-05T10:18:00+08:00", + "not_before_at": "2026-01-05 10:18:00", + "suggested_at": "2026-01-05 10:18:00", + "last_smoke_at": "2026-01-05 09:30:00", + "today_count": 3, + "resisted_count": 1, + "reduced_from_yesterday": 2, "default": { - "last_smoke_at": "2026-01-05T09:30:00+08:00", - "next_smoke_at": "2026-01-05T10:18:00+08:00", + "last_smoke_at": "2026-01-05 09:30:00", + "next_smoke_at": "2026-01-05 10:18:00", "base_interval_minutes": 48, "interval_minutes": 48, "stage": 0, "resisted_7d": 3, "sleep_adjusted": false, "algorithm": "staircase_delay_v1", - "as_of": "2026-01-05T10:00:00+08:00" + "as_of": "2026-01-05 10:00:00" } } } @@ -396,15 +401,19 @@ AI 生成说明: "message": "success", "data": { "source": "ai", - "not_before_at": "2026-01-05T10:18:00+08:00", - "suggested_at": "2026-01-05T10:28:00+08:00", + "not_before_at": "2026-01-05 10:18:00", + "suggested_at": "2026-01-05 10:28:00", + "last_smoke_at": "2026-01-05 09:30:00", + "today_count": 3, + "resisted_count": 1, + "reduced_from_yesterday": 2, "time_nodes": ["10:30", "11:10", "14:00", "16:30"], "advice": "先把这次冲动延后到10:28,期间做一次5分钟快走+喝水,压力场景用深呼吸替代。", "default": { "algorithm": "staircase_delay_v1" }, "ai": { "plan_date": "2026-01-05", - "not_before_at": "2026-01-05T10:18:00+08:00", - "suggested_at": "2026-01-05T10:28:00+08:00", + "not_before_at": "2026-01-05 10:18:00", + "suggested_at": "2026-01-05 10:28:00", "time_nodes": ["10:30", "11:10", "14:00", "16:30"], "advice": "先把这次冲动延后到10:28,期间做一次5分钟快走+喝水,压力场景用深呼吸替代。", "prompt_version": "v1", @@ -414,3 +423,9 @@ AI 生成说明: } } ``` + +字段说明(新增首页字段): +- `last_smoke_at`:上次“实际抽烟”时间(忽略忍住记录),格式 `YYYY-MM-DD HH:MM:SS`。 +- `today_count`:今日抽烟支数(累加 `num`)。 +- `resisted_count`:今日克制次数(`level=0 && num=0`)。 +- `reduced_from_yesterday`:较昨日减少的支数(`max(昨日支数 - 今日支数, 0)`)。 diff --git a/docs/smoke/PRD.md b/docs/smoke/PRD.md new file mode 100644 index 0000000..f43169b --- /dev/null +++ b/docs/smoke/PRD.md @@ -0,0 +1,231 @@ +# 戒烟小程序 - 产品需求文档 (PRD) + +## 1. 产品概述 + +### 1.1 产品定位 +一款基于 AI 辅助的科学戒烟小程序,通过记录抽烟行为、数据分析、智能递减计划帮助用户逐步减少吸烟量,最终实现戒烟目标。 + +### 1.2 核心价值 +- **记录追踪**:精确记录每次抽烟/忍住行为 +- **数据洞察**:可视化展示吸烟趋势与规律 +- **AI 指导**:个性化递减建议与时间节点规划 +- **正向激励**:省钱计算、健康恢复、成就系统 + +### 1.3 目标用户 +- 有戒烟意愿的吸烟者 +- 想要减少吸烟量的用户 +- 需要科学方法辅助戒烟的人群 + +--- + +## 2. 功能模块 + +### 2.1 首页 (home_dashboard) + +**核心目标**:快速记录 + 实时激励 + +| 元素 | 说明 | 数据来源 | +|------|------|----------| +| 问候语 | 根据时段显示(早上好/下午好等) + 用户昵称 | 本地计算 + profile | +| AI 提示卡片 | 发现的抽烟规律/建议(可关闭) | `GET /ai/advice` 缓存 | +| 计时环 | 距上次抽烟时间(时:分:秒) | `dashboard.minutes_since_last` | +| 下次建议时间 | 显示建议的下次抽烟时间点 | `GET /next_smoke_time` | +| 今日已抽 | X / 目标数,较昨日 ±N | `dashboard.today_count` | +| 烟瘾发作已抵抗 | 忍住次数统计 | 筛选 `level=0,num=0` 记录 | +| 记录抽烟按钮 | 快速记录一次抽烟 | `POST /logs` | +| 想抽忍住了按钮 | 记录成功抵抗 | `POST /logs/resisted` | + +**性能要求**: +- 首屏渲染 < 500ms +- 关键数据(计时环)优先加载 +- 非关键数据(AI提示)异步延迟加载 + +### 2.2 统计页 (smoking_statistics) + +**核心目标**:数据可视化 + 趋势分析 + +| 功能 | 说明 | 数据来源 | +|------|------|----------| +| 周/月/年切换 | 切换统计时间范围 | `GET /dashboard?start=&end=` | +| 每周洞察 | AI 分析本周表现 | `GET /ai/advice` | +| 吸烟趋势图 | 柱状图展示每日吸烟量 | `dashboard.weekly` | +| 趋势对比 | 较上周减少 X% | 本地计算 | +| 日均吸烟量 | 统计周期内日均值 | 本地计算 | +| 节省金额 | 基于减少量 × 单价计算 | profile + logs | +| 肺部功能恢复 | 根据戒烟天数估算 | 固定公式 | +| 连续记录天数 | 用户活跃天数 | logs 统计 | +| 已拒绝次数 | 累计忍住次数 | `level=0,num=0` 统计 | + +### 2.3 AI 助手页 (ai_quit_assistant) + +**核心目标**:个性化指导 + 阶段管理 + +| 功能 | 说明 | 数据来源 | +|------|------|----------| +| 减量计划卡片 | 当前阶段(第X/30天)、阶段名称、进度 | profile + 本地计算 | +| 每日 AI 分析 | 对话式展示昨日分析与今日建议 | `GET /ai/advice` | +| 今日目标 | 任务清单(喝水、散步、阅读激励等) | AI 生成 + 本地存储 | +| 记录按钮 | 快速记录吸烟或烟瘾 | 跳转记录流程 | + +**阶段划分**: +1. 阶段1 - 记录期 (Day 1-7):建立基线数据 +2. 阶段2 - 减量期 (Day 8-21):逐步减少吸烟量 +3. 阶段3 - 巩固期 (Day 22-30):维持低量/零吸烟 + +### 2.4 历史记录页 (activity_history) + +**核心目标**:记录管理 + 行为回顾 + +| 功能 | 说明 | 数据来源 | +|------|------|----------| +| 筛选 Tabs | 全部 / 已抽烟 / 已忍住 | 前端筛选 | +| 时间线 | 按日期分组展示 | `GET /logs` | +| 记录卡片 | 类型、时间、原因标签、间隔时间 | logs 数据 | +| 左滑操作 | 编辑 / 删除 | `PUT/DELETE /logs/:id` | +| 新增按钮 | 浮动按钮快速新增 | 跳转记录流程 | + +### 2.5 个人中心 (profile_&_settings) + +**核心目标**:用户信息 + 设置管理 + +| 功能 | 说明 | 数据来源 | +|------|------|----------| +| 用户信息 | 头像、昵称 | 微信授权 | +| 目标展示 | 目标戒烟日期、连续天数 | profile | +| 目标设定 | 调整每日限额与戒烟日期 | `PUT /profile` | +| AI 计划调整 | 个性化辅导风格设置 | profile 扩展 | +| 通知设置 | 提醒时间、频率 | 本地存储 | +| 会员解锁 | PRO 功能 / 广告解锁 | 会员系统 | +| 基础设置 | 作息时间等 | `PUT /profile` | +| 隐私与数据 | 数据导出、账号注销 | 待扩展 | + +--- + +## 3. 用户流程 + +### 3.1 新用户引导流程 + +``` +启动小程序 + ↓ +微信登录 (wx.login) + ↓ +检查 profile (GET /profile) + ↓ +exists=false ? → 进入引导页 + ↓ +Step 1: 每日吸烟量 (baseline_cigs_per_day) + ↓ +Step 2: 烟龄 (smoking_years) + ↓ +Step 3: 吸烟动机 (smoke_motivations) + ↓ +Step 4: 戒烟动力 (quit_motivations) + ↓ +Step 5: 作息时间 (wake_up_time, sleep_time) + ↓ +Step 6: 设置目标 (目标日期、每日限额) + ↓ +提交 profile (PUT /profile) + ↓ +进入首页 +``` + +### 3.2 日常使用流程 + +``` +打开首页 + ↓ +查看计时器 + 下次建议时间 + ↓ +[想抽烟时] + ├── 还没到建议时间 → 点击"想抽忍住了" + └── 到了/忍不住 → 点击"记录抽烟" + ↓ +记录表单 (可选填原因) + ↓ +提交成功 → 更新首页数据 +``` + +--- + +## 4. 数据模型 + +### 4.1 用户档案 (profile) + +| 字段 | 类型 | 说明 | +|------|------|------| +| baseline_cigs_per_day | int | 基准日吸烟量 | +| smoking_years | int | 烟龄(年) | +| pack_price_cent | int | 单包价格(分) | +| smoke_motivations | []string | 抽烟动机 | +| quit_motivations | []string | 戒烟动力 | +| wake_up_time | string | 起床时间 HH:MM | +| sleep_time | string | 入睡时间 HH:MM | +| daily_target | int | 每日目标限额(扩展) | +| quit_date | date | 目标戒烟日期(扩展) | + +### 4.2 抽烟记录 (log) + +| 字段 | 类型 | 说明 | +|------|------|------| +| smoke_time | date | 记录日期 | +| smoke_at | datetime | 实际抽烟时间 | +| level | int | 烟瘾等级 0-5 (0=忍住) | +| num | int | 支数 (0=忍住) | +| remark | string | 原因备注 | + +--- + +## 5. 性能优化策略 + +### 5.1 首页加载优化 + +**目标**:首屏 < 500ms + +``` +[并行请求] +├── GET /profile (用户信息,判断是否需引导) +├── GET /dashboard (今日统计,计时器数据) +└── GET /next_smoke_time (下次建议时间) + +[延迟加载] +└── GET /ai/advice (AI提示卡片,非关键) +``` + +**缓存策略**: +- profile: 登录后缓存,变更时更新 +- dashboard: 每次进入刷新,后台定时更新 +- next_smoke_time: 缓存至下次记录 +- ai/advice: 按天缓存 + +### 5.2 数据预加载 + +- 首页加载时预取统计页首屏数据 +- TabBar 切换时使用缓存 + 后台刷新 + +--- + +## 6. 权限与会员 + +### 6.1 免费功能 +- 记录抽烟/忍住 +- 基础统计(周视图) +- 首页计时器 +- 基础递减算法 + +### 6.2 会员/广告解锁功能 +- AI 每日建议 +- AI 时间节点规划 +- 月/年统计视图 +- 数据导出 + +--- + +## 7. 待扩展功能 + +- [ ] 成就系统 (连续X天、累计忍住X次等) +- [ ] 社交分享 (戒烟打卡海报) +- [ ] 提醒推送 (到达建议时间提醒) +- [ ] 健康知识卡片 +- [ ] 紧急求助 (烟瘾强烈时的快速干预) diff --git a/docs/smoke/PRODUCT.md b/docs/smoke/PRODUCT.md index 66a4201..dbbbb92 100644 --- a/docs/smoke/PRODUCT.md +++ b/docs/smoke/PRODUCT.md @@ -57,14 +57,14 @@ ### 4.1 首次进入(Onboarding) 1) 登录:`POST /api/v1/auth/login` 获取 `session_key`(Bearer)。 2) 拉取基础信息:`GET /api/v1/smoke/profile` -3) 若未补全:进入补全页,提交:`PUT /api/v1/smoke/profile` +3) 若未补全:进入补全页,提交:`POST /api/v1/smoke/profile` 4) 进入首页:展示下次时间/今日累计/最近记录。 ### 4.2 日常记录 - 实际抽烟:`POST /api/v1/smoke/logs` - 忍住:`POST /api/v1/smoke/logs/resisted` - 列表:`GET /api/v1/smoke/logs`、`GET /api/v1/smoke/logs/latest` -- 编辑/删除:`PUT /api/v1/smoke/logs/:id`、`DELETE /api/v1/smoke/logs/:id` +- 编辑/删除:`POST /api/v1/smoke/logs/:id`、`DELETE /api/v1/smoke/logs/:id` ### 4.3 首页“下次时间” - 默认展示:`GET /api/v1/smoke/next_smoke_time?date=today&mode=auto` @@ -106,4 +106,3 @@ - D1/D7 留存:进入首页、完成补全、记录次数(抽烟/忍住)、打开看板、生成 AI 建议。 - “忍住记录”转化:忍住按钮点击 -> 成功落库。 - AI 付费/广告:解锁按钮曝光 -> 广告完成 -> 生成成功率。 - diff --git a/docs/smoke/SEQUENCE.md b/docs/smoke/SEQUENCE.md index 89d89c4..05e9332 100644 --- a/docs/smoke/SEQUENCE.md +++ b/docs/smoke/SEQUENCE.md @@ -37,7 +37,7 @@ sequenceDiagram API->>DB: 查询 fa_smoke_user_profile(uid) API-->>MP: exists/is_completed/baseline_interval_minutes/作息等 alt 需要补全 - MP->>API: PUT /api/v1/smoke/profile {...基础烟量/动机/动力/作息...} + MP->>API: POST /api/v1/smoke/profile {...基础烟量/动机/动力/作息...} API->>DB: upsert fa_smoke_user_profile(uid) API-->>MP: 返回最新 profile + baseline_interval_minutes end @@ -141,4 +141,3 @@ sequenceDiagram end end ``` - diff --git a/docs/smoke/UI.md b/docs/smoke/UI.md index 6f6853a..9f9930e 100644 --- a/docs/smoke/UI.md +++ b/docs/smoke/UI.md @@ -124,7 +124,7 @@ API: 展示: - smoke_time、smoke_at、remark、level、num 编辑: -- `PUT /api/v1/smoke/logs/:id`(支持清空 smoke_at/smoke_time 传空字符串) +- `POST /api/v1/smoke/logs/:id`(支持清空 smoke_at/smoke_time 传空字符串) 删除: - `DELETE /api/v1/smoke/logs/:id` @@ -166,7 +166,7 @@ API: API: - `GET /api/v1/smoke/profile` -- `PUT /api/v1/smoke/profile` +- `POST /api/v1/smoke/profile` ## 4. 关键交互与状态 @@ -190,7 +190,7 @@ API: ## 5. API 映射速查表 - 登录:`POST /api/v1/auth/login`(返回 session_key) -- Profile:`GET/PUT /api/v1/smoke/profile` +- Profile:`GET/POST /api/v1/smoke/profile` - 新增抽烟:`POST /api/v1/smoke/logs` - 忍住:`POST /api/v1/smoke/logs/resisted` - 列表:`GET /api/v1/smoke/logs`、`GET /api/v1/smoke/logs/latest` @@ -198,4 +198,3 @@ API: - 下次时间:`GET /api/v1/smoke/next_smoke_time?date=&mode=` - 每日 AI 建议:`GET /api/v1/smoke/ai/advice?date=` - 广告解锁:`POST /api/v1/smoke/ai/advice_unlocks {date}` - diff --git a/internal/routes/smoke_routes.go b/internal/routes/smoke_routes.go index a5444f3..12e469a 100644 --- a/internal/routes/smoke_routes.go +++ b/internal/routes/smoke_routes.go @@ -12,7 +12,7 @@ func registerSmokeRoutes(protected *gin.RouterGroup, smokeHandler *smokehandler. { // 首次进入/基础信息(用于基准、AI 个性化、作息规避等) smoke.GET("/profile", smokeHandler.GetProfile) - smoke.PUT("/profile", smokeHandler.UpsertProfile) + smoke.POST("/profile", smokeHandler.UpsertProfile) // 不使用 AI 时的默认“下次抽烟时间”建议(阶梯式延时) smoke.GET("/next_smoke_time", smokeHandler.GetNextSmokeTime) @@ -23,7 +23,7 @@ func registerSmokeRoutes(protected *gin.RouterGroup, smokeHandler *smokehandler. smoke.GET("/logs", smokeHandler.List) smoke.GET("/logs/latest", smokeHandler.LatestLogs) smoke.GET("/logs/:id", smokeHandler.Get) - smoke.PUT("/logs/:id", smokeHandler.Update) + smoke.POST("/logs/:id", smokeHandler.Update) smoke.DELETE("/logs/:id", smokeHandler.Delete) // AI 戒烟建议(会员优先;非会员需看广告解锁) diff --git a/internal/smoke/handler/smoke_next_handler.go b/internal/smoke/handler/smoke_next_handler.go index 3846481..f89b26a 100644 --- a/internal/smoke/handler/smoke_next_handler.go +++ b/internal/smoke/handler/smoke_next_handler.go @@ -14,13 +14,17 @@ import ( ) type nextSmokeTimeUnifiedResponse struct { - Source string `json:"source"` - NotBeforeAt string `json:"not_before_at"` - SuggestedAt string `json:"suggested_at"` - TimeNodes []string `json:"time_nodes,omitempty"` - Advice string `json:"advice,omitempty"` - Default smokeservice.NextSmokeSuggestion `json:"default"` - AI *smokeservice.AINextSmokeSuggestion `json:"ai,omitempty"` + Source string `json:"source"` + NotBeforeAt string `json:"not_before_at"` + SuggestedAt string `json:"suggested_at"` + LastSmokeAt string `json:"last_smoke_at,omitempty"` + TodayCount int `json:"today_count"` + Resisted int `json:"resisted_count"` + Reduced int `json:"reduced_from_yesterday"` + TimeNodes []string `json:"time_nodes,omitempty"` + Advice string `json:"advice,omitempty"` + Default nextSmokeDefaultResponse `json:"default"` + AI *nextSmokeAIResponse `json:"ai,omitempty"` } func (h *SmokeHandler) GetNextSmokeTime(c *gin.Context) { @@ -68,20 +72,72 @@ func (h *SmokeHandler) GetNextSmokeTime(c *gin.Context) { return } + homeSummary, err := h.smokeLogService.HomeSummary(c.Request.Context(), int(user.ID), asOf) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取首页汇总失败,请稍后重试")) + return + } + mode := strings.ToLower(strings.TrimSpace(c.DefaultQuery("mode", "auto"))) formatPtr := func(t *time.Time) string { if t == nil { return "" } - return t.In(time.Local).Format(time.RFC3339) + return t.In(time.Local).Format(dateTimeLayout) + } + + formatTimeString := func(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + if t, err := time.Parse(time.RFC3339, value); err == nil { + return t.In(time.Local).Format(dateTimeLayout) + } + if t, err := time.ParseInLocation(dateTimeLayout, value, time.Local); err == nil { + return t.In(time.Local).Format(dateTimeLayout) + } + return value + } + + formatDefault := func(s smokeservice.NextSmokeSuggestion) nextSmokeDefaultResponse { + out := nextSmokeDefaultResponse{ + LastSmokeAt: formatPtr(s.LastSmokeAt), + NextSmokeAt: formatPtr(s.NextSmokeAt), + BaseIntervalMinutes: s.BaseIntervalMinutes, + IntervalMinutes: s.IntervalMinutes, + Stage: s.Stage, + Resisted7d: s.Resisted7d, + SleepAdjusted: s.SleepAdjusted, + Algorithm: s.Algorithm, + } + out.AsOf = formatTimeString(s.AsOf) + return out + } + + formatAI := func(s smokeservice.AINextSmokeSuggestion) nextSmokeAIResponse { + return nextSmokeAIResponse{ + PlanDate: s.PlanDate, + NotBeforeAt: formatTimeString(s.NotBeforeAt), + SuggestedAt: formatTimeString(s.SuggestedAt), + TimeNodes: s.TimeNodes, + Advice: s.Advice, + PromptVersion: s.PromptVersion, + Model: s.Model, + Provider: s.Provider, + } } resp := nextSmokeTimeUnifiedResponse{ Source: "default", NotBeforeAt: formatPtr(defaultSuggestion.NextSmokeAt), SuggestedAt: formatPtr(defaultSuggestion.NextSmokeAt), - Default: defaultSuggestion, + LastSmokeAt: formatPtr(homeSummary.LastSmokeAt), + TodayCount: homeSummary.TodayCount, + Resisted: homeSummary.ResistedCount, + Reduced: homeSummary.ReducedFromYesterday, + Default: formatDefault(defaultSuggestion), } // mode=default: 永远返回默认建议 @@ -124,11 +180,12 @@ func (h *SmokeHandler) GetNextSmokeTime(c *gin.Context) { if hasAI { resp.Source = "ai" - resp.NotBeforeAt = ai.NotBeforeAt - resp.SuggestedAt = ai.SuggestedAt + resp.NotBeforeAt = formatTimeString(ai.NotBeforeAt) + resp.SuggestedAt = formatTimeString(ai.SuggestedAt) resp.TimeNodes = ai.TimeNodes resp.Advice = ai.Advice - resp.AI = &ai + formattedAI := formatAI(ai) + resp.AI = &formattedAI } c.JSON(http.StatusOK, model.Success(resp)) @@ -138,3 +195,26 @@ func dateOnlyLocal(t time.Time) time.Time { local := t.In(time.Local) return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, time.Local) } + +type nextSmokeDefaultResponse struct { + LastSmokeAt string `json:"last_smoke_at,omitempty"` + NextSmokeAt string `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"` +} + +type nextSmokeAIResponse 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"` +} diff --git a/internal/smoke/service/smoke_log_service.go b/internal/smoke/service/smoke_log_service.go index 5000065..1a8c3a9 100644 --- a/internal/smoke/service/smoke_log_service.go +++ b/internal/smoke/service/smoke_log_service.go @@ -117,6 +117,14 @@ type SmokeDashboardResult struct { Weekly []DashboardWeeklyStat `json:"weekly"` } +// SmokeHomeSummary 汇总首页所需的关键指标。 +type SmokeHomeSummary struct { + LastSmokeAt *time.Time + TodayCount int + ResistedCount int + ReducedFromYesterday int +} + // DashboardWeeklyStat 表示某一天的抽烟支数以及是否为今天。 type DashboardWeeklyStat struct { Date string `json:"date"` @@ -248,6 +256,68 @@ func (s *SmokeLogService) Dashboard(ctx context.Context, uid int, req SmokeDashb }, nil } +// HomeSummary 返回首页所需的汇总数据(不包含时间范围的周统计)。 +func (s *SmokeLogService) HomeSummary(ctx context.Context, uid int, asOf time.Time) (SmokeHomeSummary, error) { + today := dateOnly(asOf) + todayKey := today.Format("2006-01-02") + yesterdayKey := today.AddDate(0, 0, -1).Format("2006-01-02") + + var todayCount int64 + if err := s.db.WithContext(ctx). + Model(&smokemodel.SmokeLog{}). + Where("uid = ? AND (deletetime IS NULL OR deletetime = 0) AND smoke_time = ?", uid, todayKey). + Select("COALESCE(SUM(num), 0)"). + Scan(&todayCount).Error; err != nil { + return SmokeHomeSummary{}, fmt.Errorf("count today smoke logs: %w", err) + } + + var resistedCount int64 + 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). + Count(&resistedCount).Error; err != nil { + return SmokeHomeSummary{}, fmt.Errorf("count resisted logs: %w", err) + } + + var yesterdayCount int64 + if err := s.db.WithContext(ctx). + Model(&smokemodel.SmokeLog{}). + Where("uid = ? AND (deletetime IS NULL OR deletetime = 0) AND smoke_time = ?", uid, yesterdayKey). + Select("COALESCE(SUM(num), 0)"). + Scan(&yesterdayCount).Error; err != nil { + return SmokeHomeSummary{}, fmt.Errorf("count yesterday smoke logs: %w", err) + } + + reduced := int(yesterdayCount - todayCount) + if reduced < 0 { + reduced = 0 + } + + var lastSmokeAt *time.Time + 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)"). + Order("COALESCE(smoke_at, FROM_UNIXTIME(createtime), smoke_time) DESC"). + Order("id DESC"). + Limit(1). + Take(&last).Error; err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + return SmokeHomeSummary{}, fmt.Errorf("load last smoke log: %w", err) + } + } else if t, ok := lastEventTime(last); ok { + lastSmokeAt = &t + } + + return SmokeHomeSummary{ + LastSmokeAt: lastSmokeAt, + TodayCount: int(todayCount), + ResistedCount: int(resistedCount), + ReducedFromYesterday: reduced, + }, nil +} + func (s *SmokeLogService) ListLatest(ctx context.Context, uid int, limit int) ([]smokemodel.SmokeLog, error) { if limit <= 0 { limit = 20 diff --git a/internal/smoke/service/smoke_profile_service.go b/internal/smoke/service/smoke_profile_service.go index e5627a8..29c93d9 100644 --- a/internal/smoke/service/smoke_profile_service.go +++ b/internal/smoke/service/smoke_profile_service.go @@ -168,7 +168,6 @@ func (s *SmokeProfileService) Upsert(ctx context.Context, uid int, req UpsertSmo func isSmokeProfileCompleted(p smokemodel.SmokeUserProfile) bool { return p.BaselineCigsPerDay > 0 && p.PackPriceCent > 0 && - len(p.SmokeMotivations) > 0 && len(p.QuitMotivations) > 0 && strings.TrimSpace(p.WakeUpTime) != "" && strings.TrimSpace(p.SleepTime) != "" diff --git a/internal/smoke/service/smoke_profile_service_test.go b/internal/smoke/service/smoke_profile_service_test.go index b7a4ae5..9c22ebf 100644 --- a/internal/smoke/service/smoke_profile_service_test.go +++ b/internal/smoke/service/smoke_profile_service_test.go @@ -98,8 +98,11 @@ func TestIsSmokeProfileCompleted(t *testing.T) { t.Fatalf("isSmokeProfileCompleted: expected true") } p.SmokeMotivations = nil + if !isSmokeProfileCompleted(p) { + t.Fatalf("isSmokeProfileCompleted: expected true when smoke_motivations empty") + } + p.QuitMotivations = nil if isSmokeProfileCompleted(p) { - t.Fatalf("isSmokeProfileCompleted: expected false when motivations missing") + t.Fatalf("isSmokeProfileCompleted: expected false when quit_motivations missing") } } -