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>
This commit is contained in:
maxfandClaude committed 2026-09-28 10:26:59 +08:00
1 parent 2f13706e54
commit d6d034727b
34 files changed
+938 -70

No files matched your search

+1 -1
View File
@@ -261,7 +261,7 @@
| 表 | 内容 | 权限 |
|---|---|---|
| `server_settings`(全局) | `amap_key`、`smtp_host`、`smtp_port`、全局`threshold_per_liter`、全局`default_province`、采集状态 `last_crawl_*` | 仅 admin 可写 |
| `server_settings`(全局) | `amap_key`、`smtp_host`/`smtp_port`/`smtp_user`/`smtp_pass`、全局`threshold_per_liter`、全局`default_province`、采集状态 `last_crawl_*` | 仅 admin 可写 |
| `user_settings`(个人) | 飞书/企微/钉钉 `webhook`、`receiver_email`、个人`threshold_per_liter`、个人`default_province` | 按 `user_id` 归属 |
---
+7 -1
View File
@@ -199,7 +199,7 @@ function pushRecent(province):
005.3 ▲前端 若当前用户是 admin(/api/auth/me 返回 is_admin=true)→ 额外加载"全局设置区"
├─ 调用 GET /api/settings/server ⟶ [A10] → 渲染:
│ ├─ 高德 Key(amap_key)
│ └─ SMTP 主机/端口(smtp_host / smtp_port)
│ └─ 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 定位模块
@@ -408,6 +408,12 @@ function pushRecent(province):
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)或注销;回跳至触发点。
+108 -3
View File
@@ -22,6 +22,8 @@
| `GET /api/location/regeo` | ✅ | ✅ | ✅ |
| `GET /api/health` | ✅ | ✅ | ✅ |
| `POST /api/auth/register` `login` | ✅ | — | — |
| `POST /api/auth/forgot-password` | ✅ | — | — |
| `POST /api/auth/reset` | ✅ | — | — |
| `GET /api/auth/me` | ✅(返回未登录态) | ✅ | ✅ |
| `POST /api/auth/logout` | — | ✅ | ✅ |
| `GET/POST /api/vehicles*` | ❌ 401 | ✅ 按 user_id | ✅ |
@@ -801,19 +803,23 @@ ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value;
"settings": {
"amap_key": "****8765",
"smtp_host": "smtp.qq.com",
"smtp_port": 465
"smtp_port": 465,
"smtp_user": "****@qq.com",
"smtp_pass": "****abcd"
}
}
}
```
**POST 请求体**(部分更新,空串清空)
**POST 请求体**(部分更新,空串清空;脱敏值不写回)
```json
{
"amap_key": "abcdef1234567890",
"smtp_host": "smtp.qq.com",
"smtp_port": 465
"smtp_port": 465,
"smtp_user": "oil-monitor@qq.com",
"smtp_pass": "授权码"
}
```
@@ -825,6 +831,8 @@ ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value;
| amap_key | body | string | 否 | 高德 Web 服务 Key(16/32 位);空串清空 |
| smtp_host | body | string | 否 | SMTP 服务器地址;空串清空 |
| smtp_port | body | int | 否 | SMTP 端口,(0, 65535] |
| smtp_user | body | string | 否 | SMTP 发件邮箱账号(邮件 From 与登录账号);空串清空 |
| smtp_pass | body | string | 否 | SMTP 授权码(非邮箱登录密码);空串清空 |
**响应字段说明**
@@ -837,6 +845,8 @@ ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value;
| data.settings.amap_key | string | 高德 Key(脱敏,仅留后 4 位) |
| data.settings.smtp_host | string | SMTP 服务器地址 |
| data.settings.smtp_port | int | SMTP 端口 |
| data.settings.smtp_user | string | SMTP 发件邮箱(脱敏,仅留后 4 位) |
| data.settings.smtp_pass | string | SMTP 授权码(脱敏,仅留后 4 位) |
**校验**:`smtp_port` ∈ (0, 65535];`amap_key` 非空时建议为高德 16/32 位 Web 服务 Key 格式。
@@ -1244,3 +1254,98 @@ SELECT u.id, u.username, u.is_admin
FROM sessions s JOIN users u ON s.user_id = u.id
WHERE s.token = ? AND s.expires_at > datetime('now','localtime');
```
---
## A18 · POST /api/auth/forgot-password(🔓匿名)
**功能**:邮箱找回第一步——按「接收邮箱」反查用户,生成一次性重置令牌并通过全局 SMTP 配置发送重置邮件。无论邮箱是否存在均返回统一成功提示(防枚举)。
**请求**:`POST /api/auth/forgot-password`(🔓匿名)
**请求体**
```json
{ "email": "user@example.com" }
```
**请求参数**
| 参数 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| email | body | string | 是 | 用户在设置页填写的「接收邮箱」 |
**响应**(HTTP 200,统一提示防枚举)
```json
{ "code": 0, "message": "ok", "data": { "message": "若该邮箱已注册,重置邮件已发送" } }
```
**错误响应**
| 场景 | HTTP | code | message |
|---|---|---|---|
| 邮箱格式不正确 | 400 | 1002 | 邮箱格式不正确 |
| 同 IP+邮箱 15 分钟超过 5 次 | 429 | 1014 | 尝试过于频繁,请稍后再试 |
| SMTP 未配置 / 发送失败 | 500 | 1005 | 重置邮件发送失败:请确认已配置 SMTP 服务 |
**校验**:邮箱需含 `@`;限流复用登录限流器,键为 `fp:`+邮箱(与登录键互不冲突)。
**处理逻辑**:解析 email(转小写去空格)→ 限流校验 → `user_settings` 按 `receiver_email` 反查 `user_id`(一个邮箱可能对应多个账号)→ 对每个匹配用户:生成 32 字节随机 token 写 `password_resets`(30 分钟过期)→ 复用 `push.Send(smtp)` 发送含重置链接的邮件(链接域名取 `X-Forwarded-Proto` / `Host`)→ 无匹配用户也返回统一成功提示。
**DB 操作(SQL)**
```sql
-- 按邮箱反查用户
SELECT user_id FROM user_settings WHERE key = 'receiver_email' AND lower(value) = ?;
-- 写入重置令牌
INSERT INTO password_resets(token, user_id, expires_at, used, created_at)
VALUES(?, ?, datetime('now','localtime','+30 minutes'), 0, datetime('now','localtime'));
```
---
## A19 · POST /api/auth/reset(🔓匿名)
**功能**:邮箱找回第二步——校验重置令牌(未过期、未使用)后更新密码,作废令牌并强制该用户所有会话下线。
**请求**:`POST /api/auth/reset`(🔓匿名)
**请求体**
```json
{ "token": "32字节hex", "password": "newpass123" }
```
**请求参数**
| 参数 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| token | body | string | 是 | 邮件重置链接中的令牌 |
| password | body | string | 是 | 新密码(≥8 位) |
**响应**(HTTP 200)
```json
{ "code": 0, "message": "ok", "data": { "message": "密码已重置,请使用新密码登录" } }
```
**错误响应**
| 场景 | HTTP | code | message |
|---|---|---|---|
| 令牌缺失 / 无效 | 400 | 1002 | 重置令牌缺失 / 重置链接无效 |
| 令牌已使用 | 400 | 1002 | 该重置链接已使用,请重新申请 |
| 令牌已过期 | 400 | 1002 | 重置链接已过期,请重新申请 |
| 密码不足 8 位 | 400 | 1002 | 密码至少 8 位 |
**处理逻辑**:校验 token 存在且 `used=0` 且 `expires_at` 未过 → `auth.HashPassword` 生成 bcrypt 哈希更新 `users.password_hash` → 标记令牌 `used=1` → 删除该用户全部 `sessions`(强制下线)→ 返回成功。
**DB 操作(SQL)**
```sql
SELECT user_id, used, expires_at FROM password_resets WHERE token = ?;
UPDATE users SET password_hash = ? WHERE id = ?;
UPDATE password_resets SET used = 1 WHERE token = ?;
DELETE FROM sessions WHERE user_id = ?;
```
+7 -1
View File
@@ -42,6 +42,12 @@ ON CONFLICT(quote_date) DO UPDATE SET
- 解析失败(页面结构变化):日志 error + 保留原始响应到 `logs/raw/` 便于排查。
- 周末/节假日:新浪接口仍返回最近交易日数据,以响应日期为准,不产生脏数据。
**任务执行日志**:所有定时任务经统一包装器 `runTask` 注册(`internal/cron/scheduler.go`),记录执行生命周期——
- 开始:`level=DEBUG msg=定时任务开始 task=<任务名>`(上线 `info` 静默)
- 正常结束:`level=INFO msg=定时任务完成 task=<任务名> dur_ms=<耗时>`
- 业务失败:`level=ERROR msg=定时任务失败 task=<任务名> dur_ms=<耗时> err=<原因>`
- 异常中断:`level=ERROR msg=定时任务异常 task=<任务名> dur_ms=<耗时> panic=<内容>`(panic 被 recover,不连累调度器)
**衔接**:成功后 → `T4`;写 `server_settings.last_crawl_crude`。
---
@@ -275,7 +281,7 @@ VALUES (?, ?, 'announce', ?, ?, ?, ?, datetime('now','localtime'));
```
1. 读渠道配置:
- 飞书/企微/钉钉 webhook:来自 user_settings(user_id 指定)或 server_settings(匿名/系统告警用兜底)
- SMTP 服务器:smtp_host/port 来自 server_settings(全局);收件人 receiver_email 来自 user_settings
- SMTP 服务器:smtp_host/port/user/pass 来自 server_settings(全局);收件人 receiver_email 来自 user_settings
2. 渠道未配置 → 返回 error("channel_not_configured")
3. 发送:
- Webhook 类:POST {url},Body:
+15 -2
View File
@@ -29,6 +29,19 @@ CREATE TABLE IF NOT EXISTS sessions (
CREATE INDEX IF NOT EXISTS idx_sess_user ON sessions(user_id);
```
**password_resets**(密码重置令牌,邮箱找回)
```sql
CREATE TABLE IF NOT EXISTS password_resets (
token TEXT PRIMARY KEY, -- 一次性重置令牌(crypto/rand 32 字节 hex)
user_id INTEGER NOT NULL REFERENCES users(id), -- 归属用户 id
expires_at TEXT NOT NULL, -- 过期时间(默认 +30 分钟)
used INTEGER NOT NULL DEFAULT 0, -- 是否已使用:1=是
created_at TEXT DEFAULT (datetime('now','localtime')) -- 创建时间
);
CREATE INDEX IF NOT EXISTS idx_pr_user ON password_resets(user_id);
```
**price_adjustments**(调价事件)
```sql
@@ -113,7 +126,7 @@ CREATE INDEX IF NOT EXISTS idx_pred_target ON predictions(target_date);
```sql
CREATE TABLE IF NOT EXISTS server_settings (
key TEXT PRIMARY KEY, -- 配置键(如 amap_key / smtp_host / smtp_port / threshold_per_liter / default_province / last_crawl_*)
key TEXT PRIMARY KEY, -- 配置键(如 amap_key / smtp_host / smtp_port / smtp_user / smtp_pass / threshold_per_liter / default_province / last_crawl_*)
value TEXT NOT NULL -- 配置值
);
-- 预置默认值:
@@ -140,7 +153,7 @@ CREATE TABLE IF NOT EXISTS notify_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 日志 id
user_id INTEGER, -- 接收用户 id(系统告警可为 NULL)
channel TEXT NOT NULL, -- 推送渠道:feishu/wecom/dingtalk/smtp
notify_type TEXT NOT NULL, -- 通知类型:test/pre_alert/announce/warn
notify_type TEXT NOT NULL, -- 通知类型:test/pre_alert/announce/warn/forgot
title TEXT, -- 通知标题
content TEXT, -- 通知内容
status TEXT NOT NULL, -- 发送状态:sent/failed/skipped