## 功能 - 邮箱找回密码:新增 password_resets 表 + POST /api/auth/forgot-password (按接收邮箱反查用户 → 生成一次性 30 分钟令牌 → 复用 SMTP 发重置邮件, 防枚举统一提示、复用登录限流器防刷)+ POST /api/auth/reset (校验令牌有效性 → bcrypt 更新密码 → 作废令牌 → 强制该用户全部会话下线); H5 新增 /forgot、/reset 页面,登录页加"忘记密码"入口 - SMTP 全局配置补全:新增发件邮箱 smtp_user / 授权码 smtp_pass 字段 (H5 + 小程序设置页同步展示与保存),GET 脱敏回显, 修复"脱敏值含 * 被 POST 写回覆盖真实配置"的 bug - 新增 cmd/sendmail 真实发送工具(配置走环境变量,敏感信息不落库, 已用 163 SMTP 465 隐式 SSL 实测发送成功) ## 日志体系 - 死配置 OIL_MONITOR_LOG_DIR 替换为 OIL_MONITOR_LOG_LEVEL (debug/info/warn/error,LevelVar 动态切换,上线默认 info 收敛日志) - 自定义 requestLogger 请求日志中间件(Debug 级:method/path/status/耗时/IP) - 外部交互日志统一:爬虫抓取 / SMTP / Webhook / 高德 regeo / 微信 code2session, 成功 Debug、失败 Error、均带 dur_ms、URL 去 query 脱敏防 key/token 泄露 - 定时任务统一 runTask 包装:开始 Debug、完成+时长 Info、失败/panic Error (panic 被 recover,不连累调度器) ## 测试 - cron/scheduler_test.go:runTask 成功/失败/panic 三路径单测 - push/sender_test.go:本地假 SMTP 服务器完整发送会话链路 + 服务器拒绝失败路径 - db_test.go:表数量断言 11 → 12(新增 password_resets) ## 部署配置与文档 - Dockerfile / docker-compose.yml / .env.example / README:日志配置同步 (删 /app/logs 目录与挂载,OIL_MONITOR_LOG_LEVEL) - .gitignore:忽略 internal/pkg 依赖缓存(349M,防误提交) - docs/10/21/22/23/24:同步 A18/A19 接口、password_resets 表、任务执行日志、SMTP 字段 Co-Authored-By: Claude <noreply@anthropic.com>
18 KiB
第一部分:功能大纲(编号生命周期)
0. 编号体系
| 编号 | 功能 | 类型 |
|---|---|---|
| 001 | 首页展示(定位 + 看板 + 爱车入口) | 前端交互 |
| 002 | 省份选择页 | 前端交互 |
| 003 | 车辆档案管理(增删改查,🔒需登录) | 前端交互 |
| 004 | 调价成本测算(爱车影响,🔒需登录) | 前端交互 |
| 005 | 设置管理(个人偏好🔒登录 / 全局👑admin) | 前端交互 |
| 006 | 通知渠道测试(🔒需登录) | 前端交互 |
| 007 | 历史调价记录查询 | 前端交互 |
| 008 | 国际原油价格定时采集 | 后端任务 |
| 009 | 发改委调价公告轮询采集 | 后端任务 |
| 010 | 各省零售价同步 | 后端任务 |
| 011 | 调价预测计算与置信度更新 | 后端任务 |
| 012 | T-1 日预测预警推送(按用户个性化) | 后端任务 |
| 013 | 调价日官方锤定推送(按用户个性化) | 后端任务 |
| 014 | 登录 / 注册 / 登出 / 会话探活 | 前端交互 |
通用符号约定:
▲前端:浏览器 H5 / 小程序端▼后端:Go 服务- 每个功能的流程图中,
⟶ [A1]/⟶ [T2]表示跳转至对应 API / 任务的详细设计。- 权限标记:
🔓匿名匿名可访问;🔒登录需登录(未登录返回401 + code=1008);👑admin仅管理员(非管理员返回403 + code=1009)。
001 首页展示(定位 + 看板)
触发:用户打开首页(H5 路由 /)
生命周期:
001.1 ▲前端 打开首页
├─ 001.1.1 读取 localStorage.recent_provinces 首位 → 得到 initial_province(无则 null)
├─ 001.1.2 调用 GET /api/health ⟶ [A12]
│ ├─ 失败(服务不可达)→ 渲染"服务不可达"整屏提示 + 重试按钮
│ └─ 成功 → 继续
├─ 001.1.3 调用 GET /api/auth/me ⟶ [A17] 探登录态
│ ├─ 已登录 → is_login=true,得 user_id
│ └─ 未登录 → is_login=false
├─ 001.1.4 调用 GET /api/dashboard?province={initial_province} ⟶ [A1]
│ ├─ 成功 → 渲染首屏(倒计时/预测/当前价)
│ │ ├─ is_login=true → 爱车提醒卡片渲染真实数据(车辆成本/加油建议)
│ │ └─ is_login=false → 爱车提醒卡片渲染"登录后查看我的加油提醒"占位入口
│ └─ 失败 → 局部降级展示 + 重试
│
├─ 001.2 ▲前端 并行启动"定位模块"(异步,不阻塞首屏渲染)
│ ├─ 001.2.1 P1 浏览器 Geolocation(需 HTTPS + 用户授权)
│ │ ├─ 成功 → 得 (lat,lng)
│ │ │ ├─ 调用 GET /api/location/regeo?lat=&lng= ⟶ [A13]
│ │ │ │ ├─ 成功得 province
│ │ │ │ │ └─ province ≠ 当前展示省 → 重新调用 [A1] 无缝刷新看板 + 更新顶部省份名
│ │ │ │ └─ 失败(高德Key未配置等)→ 转入 001.2.2
│ │ └─ 失败(用户拒绝/非HTTPS/浏览器不支持/超时)→ 转入 001.2.2
│ └─ 001.2.2 P2 高德 IP 定位(弱兜底,前端 JSONP 直调)
│ ├─ 成功得 province → 同上,若≠当前省则刷新
│ └─ 失败 → 001.2.3
│ └─ 001.2.3 P3 兜底:保持 initial_province(无则后端默认省),不打扰用户
│
├─ 001.3 ▲前端 用户点击首页左上角省份入口 `📍 北京 ▾` → 跳转 002 省份选择页
│
└─ 001.4 ▲前端 用户点击首页"爱车提醒"卡片(点击逻辑 = 车辆信息入口,见 003)
├─ is_login=true → 进入 003 车辆档案管理页
└─ is_login=false → 进入 014 登录流程(记录回跳地址=车辆管理页)→ 登录成功回跳 003
出口:
- 看板正常渲染,省份正确
- 或:服务不可达整屏提示
- 或:跳转至 [002] / [003] / [014]
状态记录(前端内存):current_province(驱动看板);is_login / user_id;localStorage.recent_provinces(仅手动切换时更新,见 002)。
002 省份选择页
触发:点击首页左上角省份入口(路由 /province)
生命周期:
002.1 ▲前端 进入省份选择页
├─ 002.1.1 读取 localStorage.recent_provinces(上限5)→ 渲染"历史省份"横排芯片区
│ └─ 无数据 → 隐藏该区块
├─ 002.1.2 调用 GET /api/provinces ⟶ [A2]
│ ├─ 成功 → 渲染"全部省份"31省网格(含各省 92# 价格角标)
│ └─ 失败 → 使用前端内置静态省份表渲染(无价格角标),显示"价格加载失败"轻提示
│
├─ 002.2 ▲前端 用户点击"历史省份"芯片(如 [四川])
│ ├─ 002.2.1 写历史:recent_provinces LRU 更新(置顶)
│ ├─ 002.2.2 写 current_province = 四川
│ └─ 002.2.3 返回首页(history.back)→ 首页自动重新调用 [A1] 刷新
│
├─ 002.3 ▲前端 用户点击"全部省份"网格中的省份(如 广东)
│ ├─ 002.3.1 写历史:LRU 更新
│ ├─ 002.3.2 写 current_province = 广东
│ └─ 002.3.3 返回首页 → 刷新 [A1]
│
└─ 002.4 ▲前端 用户直接返回(未选择)
└─ current_province 保持不变 → 首页不刷新
LRU 更新规则(纯前端,localStorage.recent_provinces,JSON 数组):
function pushRecent(province):
arr = JSON.parse(localStorage.recent_provinces || '[]')
arr = arr.filter(p => p !== province) // 已存在则先移除
arr.unshift(province) // 置顶
if arr.length > 5: arr.pop() // 超限剔除最旧
localStorage.recent_provinces = JSON.stringify(arr)
出口:返回首页;或停留在选择页(未选)。
003 车辆档案管理
触发:首页"爱车提醒"卡片 / 设置页"车辆管理"
生命周期:
003.0 ▲前端 进入车辆管理(导航 / 首页爱车卡片 / 设置页入口)
├─ 携带会话请求 GET /api/vehicles ⟶ [A4]
├─ 后端返回 401(未登录)→ 跳转 014 登录流程(记录回跳地址=车辆管理页)
├─ 登录成功 → 回跳本页 → 重新请求 [A4]
└─ 已登录 → 进入 003.1
003.1 ▲前端 车辆列表
├─ 调用 GET /api/vehicles ⟶ [A4] → 渲染车辆卡片列表(名称/省/油号/油箱/油耗/月里程)
├─ 003.2 ▲前端 点击"新增车辆"
│ ├─ 打开表单(名称/省份下拉/油号单选/油箱容量/百公里油耗/月均里程)
│ ├─ 前端校验必填与范围 → 提交 POST /api/vehicles ⟶ [A5]
│ │ ├─ 成功 → 刷新列表 + 提示"已添加"
│ │ └─ 失败 → 回显后端校验错误(400)
├─ 003.3 ▲前端 点击某车辆"编辑"
│ ├─ 表单预填当前值 → 提交 PUT /api/vehicles/:id ⟶ [A6]
│ │ ├─ 成功 → 刷新
│ │ └─ 失败(404不存在/400) → 回显错误
├─ 003.4 ▲前端 点击某车辆"删除"
│ ├─ 二次确认弹窗 → DELETE /api/vehicles/:id ⟶ [A7]
│ │ ├─ 成功 → 刷新列表
│ │ └─ 失败(404) → 提示"车辆不存在或已删除"
└─ 003.5 ▲前端 车辆列表页提供"查看调价影响"入口 → 004
出口:车辆数据变更完成;或跳转 [004]。
004 调价成本测算(爱车影响)
触发:首页爱车提醒卡片 / 车辆管理页"查看影响"按钮
生命周期:
004.0 ▲前端 进入"调价影响"视图(未登录 → 引导 014 登录流程;登录后进入)
004.1 ▲前端 调用 GET /api/vehicles/impact ⟶ [A8](🔒)
├─ 成功 → 渲染每辆车的:
│ ├─ 当前所处窗口(无调价窗口/已公布未生效/预测中)
│ ├─ 每升涨跌额、一箱油变化、月度成本变化
│ └─ 加油建议文案
├─ 401 未登录 → 引导 014 登录流程
└─ 200 但 vehicles 为空 → 空态提示"请先添加车辆"(引导 003)
├─ 004.2 若存在"已公布未生效"调价窗口(17:00~24:00)
│ └─ 卡片高亮 + 醒目建议(如"今晚24点前加满省 6.60 元")
└─ 004.3 若无窗口或已生效 → 展示当前成本快照(加满一箱多少钱、月均油费)
出口:影响数据展示;或引导去 [003] 添加车辆。
005 设置管理
触发:底部导航"我的/设置"页
生命周期:
005.0 ▲前端 进入设置页(页面内容按登录态分层)
├─ 未登录 → 引导 014 登录流程;登录后加载个人区
└─ 已登录 → 进入 005.1
005.1 ▲前端 加载"个人偏好区"
├─ 调用 GET /api/settings/user ⟶ [A9] → 渲染(脱敏显示):
│ ├─ 飞书/企微/钉钉 Webhook(个人,可为空)
│ ├─ 收件邮箱 receiver_email(个人,可为空)
│ ├─ 预警阈值 threshold_per_liter(默认 0.05)
│ └─ 默认省 default_province(P3 兜底用)
├─ 005.2 ▲前端 保存个人偏好 → POST /api/settings/user ⟶ [A9]
│ ├─ 成功 → 提示已保存
│ └─ 失败 → 回显错误
005.3 ▲前端 若当前用户是 admin(/api/auth/me 返回 is_admin=true)→ 额外加载"全局设置区"
├─ 调用 GET /api/settings/server ⟶ [A10] → 渲染:
│ ├─ 高德 Key(amap_key)
│ └─ SMTP 主机/端口/发件邮箱/授权码(smtp_host / smtp_port / smtp_user / smtp_pass)
└─ 005.4 ▲前端 保存全局设置 → POST /api/settings/server ⟶ [A10]
005.5 ▲前端 点击"发送测试消息"(选择渠道)→ POST /api/settings/test ⟶ [A11] → 展示发送结果
005.6 ▲前端 切换"自动定位开关" → 写入 localStorage(auto_location),关闭时首页跳过 001.2 定位模块
出口:配置持久化;渠道可被验证。
006 通知渠道测试
触发:设置页"发送测试消息"
生命周期:
006.1 ▲前端 进入测试页(未登录 → 引导 014 登录流程)
006.2 ▲前端 选择渠道(飞书/企微/钉钉/邮件)+ 可选测试消息 → 提交 POST /api/settings/test ⟶ [A11]
006.3 ▼后端 读该用户渠道配置(user_settings)
├─ 未配置 → 返回 400 code=1005 "该渠道未配置"
└─ 已配置 → 调用通用发送器 ⟶ [T7]
├─ 成功 → 返回 {status:"sent"}
└─ 失败 → 返回 {status:"failed", error:"..."},前端展示失败原因
006.4 ▲前端 显示"已发送,请检查群聊/邮箱" 或 失败详情
出口:渠道可用性确认。
007 历史调价记录查询
触发:首页"历史调价"入口(路由 /history)
生命周期:
007.1 ▲前端 进入历史页(默认当年 + 当前省)
├─ 调用 GET /api/history?year=2026&province=北京 ⟶ [A3]
│ ├─ 成功 → 渲染:
│ │ ├─ 年度汇总卡(涨X次/跌Y次/搁浅Z次,年度累计±元/升)
│ │ ├─ 调价记录列表(日期/方向/每吨/折合每升)
│ │ └─ 该省价格阶梯图(ECharts)
│ └─ 失败 → 空态
└─ 007.2 ▲前端 切换年份(年份下拉)→ 重新调用 [A3]
出口:历史数据展示。
008 国际原油价格定时采集
触发:Cron 0 5 * * *(每日 05:00,北京时间)
生命周期:
008.1 ▼后端 Cron 触发 T1
├─ 008.1.1 构造请求 GET https://hq.sinajs.cn/list=hf_CL,hf_OIL
│ Header: Referer=https://finance.sina.com.cn
├─ 008.1.2 响应体 GBK → 转 UTF-8(Go 内统一处理)
├─ 008.1.3 解析两个报价块(字段索引见 T1 详设)
│ └─ 解析失败 → 指数退避重试(1/2/4 分钟,共3次)→ 仍失败写告警日志,本轮结束
├─ 008.1.4 用响应中的日期字段做幂等(quote_date 已存在 → UPDATE)
├─ 008.1.5 落库 crude_quotes
└─ 008.1.6 触发 [T4] 预测计算(同进程内调用)
出口:原油日线入库;预测更新。
009 发改委调价公告轮询采集
触发:内部调度器"高频监测模式"(候选调价日前 1 天起进入,16:30~18:30 每 2 分钟)
生命周期:
009.1 ▼后端 调度器判定进入高频监测
├─ 009.1.1 抓取发改委新闻发布列表页 https://www.ndrc.gov.cn/xwdt/xwfb/
├─ 009.1.2 解析列表,找标题含"成品油价格按机制调整" 且 发布日期=今日 的条目
│ ├─ 未找到 → 本轮结束(等待下个 2 分钟 tick)
│ └─ 找到 → 抓取详情页
├─ 009.1.3 正则提取:日期、每吨涨跌额(汽油/柴油)、方向
├─ 009.1.4 幂等检查:source_url 已入库 → 跳过
├─ 009.1.5 落库 price_adjustments
├─ 009.1.6 退出高频监测(当日不再轮询)
└─ 009.1.7 顺序触发 [T3] 省份价格同步 → [T6] 官方锤定推送
异常路径:
- 列表页解析失败(页面改版)→ 告警 + 降级:用第三方聚合源(探数/接口盒子)核对当日是否调价
- 提取正则不匹配 → 保留原文快照到日志,人工介入
出口:调价事件入库;省份价格更新;推送完成。
010 各省零售价同步
触发:①[T2] 成功后立即;②每日 05:30 兜底检查
生命周期:
010.1 ▼后端 判定是否需要同步
├─ 最新 price_adjustments.id > 已同步的最大 adjustment_id → 需要
└─ 不需要 → 结束
010.2 ▼后端 抓取 http://www.qiyoujiage.com(PC首页 或 省价格列表页)
010.3 ▼后端 goquery 解析 31 省 × {92,95,98,0} 价格表
└─ 解析失败 → 告警 + 指数退避重试(最多3次)→ 仍失败待下轮
010.4 ▼后端 事务写入 province_prices(adjustment_id = 最新调价 id)
010.5 ▼后端 更新 server_settings.last_province_sync_adjustment_id
出口:31 省最新零售价入库(每个调价日一份历史快照)。
011 调价预测计算与置信度更新
触发:Cron 5 5 * * *(每日 05:05,紧跟 T1 原油采集完成之后)
生命周期:
011.1 ▼后端 取上次调价 effective_at 为周期起点,推算 target_date(+10 工作日)
011.2 ▼后端 计算两周期三地原油均价 → 变化率 R
011.3 ▼后端 按近 3 次历史调价校准系数 K → delta_per_ton = R * K
011.4 ▼后端 判定 direction(≥50 up / ≤-50 down / else stalled)
011.5 ▼后端 折合每升 delta_per_liter(汽油/1350)
011.6 ▼后端 计算置信度(按距 target_date 剩余工作日)
011.7 ▼后端 落库 predictions(每日保留快照,供趋势查看)
出口:predictions 最新一条即为 /api/dashboard 的 prediction 数据源。
012 T-1 日预测预警推送
触发:Cron 0 20 * * *(每日 20:00)
生命周期:
012.1 ▼后端 判断"下一个调价窗口日"是否落在明天(或最近工作日)
├─ 不是 → 本轮结束
└─ 是 →
012.2 ▼后端 取最新 predictions
012.3 ▼后端 若 |delta_per_liter| >= 全局预警阈值(默认0.05)
├─ 是 → 遍历所有"已配置推送渠道"的登录用户(users 表中 user_settings 非空的):
│ ├─ 读该用户 user_settings(渠道/个人阈值/默认省)
│ ├─ 按用户个人阈值二次判断(个人可覆盖全局阈值)
│ ├─ 读该用户 vehicles → 计算每车影响
│ └─ 组装个性化预警消息 → 调用 [T7](传 user_id 写日志)
└─ 否(搁浅/幅度过小)→ 记录"未达阈值"不推送
012.4 ▼后端 逐用户写 notify_logs(含 status)
出口:预测预警送达各渠道;或按规则不打扰。
013 调价日官方锤定推送
触发:[T2] 落库成功后立即触发
生命周期:
013.1 ▼后端 读取本次调价(gas/diesel_delta、direction、effective_at)
013.2 ▼后端 遍历所有"已配置推送渠道"的登录用户:
├─ 读该用户 vehicles + user_settings.default_province
├─ 读该用户默认省最新价格(无则用全国行情)
├─ 计算该用户每车一箱油/月度成本变化
├─ 组装个性化"锤定通知"(方向/幅度/每升/爱车建议)
└─ 调用 [T7](传 user_id 写日志)
013.3 ▼后端 无任何已配置用户 → 仅写系统日志
出口:调价结果 + 加油建议送达(仅已配置渠道的登录用户);无用户则仅落库日志。
014 登录 / 注册 / 登出 / 会话探活
触发:未登录用户点击任何私有功能(车辆管理 / 首页爱车卡片 / 个人设置 / 渠道测试);或用户主动登录
生命周期:
014.1 ▲前端 触发登录门槛(点击车辆 / 爱车卡片 / 设置)
├─ 记录回跳地址(如 /vehicles)
└─ 跳转登录页(路由 /login)
014.2 ▲前端 登录页提供"登录"与"注册"两个 Tab
├─ 注册:POST /api/auth/register ⟶ [A14](用户名 + 密码)
│ ├─ 成功 → 自动登录 → 回跳触发点
│ └─ 失败(用户名已存在)→ 回显错误
├─ 登录:POST /api/auth/login ⟶ [A15]
│ ├─ 成功 → 服务端写会话 Cookie → 回跳触发点
│ └─ 失败(密码错误)→ 回显错误
└─ 首用户规则:users 表为空时,注册接口自动将首个注册者标记 is_admin=1
014.3 ▲前端 回跳 → 重新执行触发原流程(如进入 003)
014.4 ▲前端 用户点击"退出登录" → POST /api/auth/logout ⟶ [A16] → 清 Cookie → 回首页
014.5 ▲前端 应用启动时 GET /api/auth/me ⟶ [A17] 探登录态(对应 001.1.3)
014.6 ▲前端 忘记密码(登录页链接 → /forgot 输接收邮箱)→ POST /api/auth/forgot-password ⟶ [A18]
├─ 成功 → 提示"重置邮件已发送"(无论邮箱是否存在均同文案,防枚举)
└─ 邮件内含 /reset?token= 链接(30 分钟有效)
014.7 ▲前端 打开邮件链接 → 输新密码两次 → POST /api/auth/reset ⟶ [A19]
├─ 成功 → 密码已重置并强制下线全部会话 → 跳登录页
└─ 失败(令牌无效/已用/过期)→ 提示重新申请
出口:会话建立(Cookie)或注销;回跳至触发点。