Files
maxfandClaude d6d034727b feat: 邮箱找回密码 + 日志体系完善 + SMTP 配置补全
## 功能
- 邮箱找回密码:新增 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>
2026-09-28 10:26:59 +08:00

18 KiB
Raw Permalink Blame History

第一部分:功能大纲(编号生命周期)

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)或注销;回跳至触发点。