Files
live-hub-py/docs/设计-账号详情页与事件历史.md
yml2213 44b762ea8c fix(overview): 锁定核心高价值套装并修复未查询空值误判
- 高价值套装精确匹配「套装-兔兔白日梦」与「套装-狂战萌兔」,排除头盔/背包/手榴弹散件干扰
- 增加 is_suit_queried_expr,未查询或 NULL 账号严禁误判为双套均可兑换
- 修复前后端序列化空值保持,完善列表与详情页准入状态和判定依据展示
- 新增头盔排除与 NULL 统计防错回归单测,全量通过 689 个用例
2026-10-07 15:27:30 +08:00

417 lines
26 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 设计:账号详情页与账号事件历史
> 状态:**后端已完成、前端已完成,本地验证通过**(2026-10-03)
> 已提交在 `dev`(`834bfdd`);迁移编号经合并后为 **`20261003_0041`**(原 `20261002_0040`,
> 因生产索引优化先落地占了 `20261003_0040`)。**尚未合入 `main`、未上生产。**
> 关联:[登录任务与 CK 解耦](设计-登录任务与CK解耦.md) —— 两者的写入点高度重合,建议同一工作流推进(见 §8)。
>
> **范围(2026-10-03 已定)**:**不做历史回填**。既有数据(`douyu_tasks` 里的兑换/充值/积分记录)
> **不导入**事件表,事件**从上线那一刻起往后累积**。账号的历史明细仍可去既有的任务页查。
> 这样本需求从「一次数据搬运工程」缩小为「建表 + 几处发事件 + 一个页面」。
## 1. 需求
要一个「账号相关」的界面,围绕**单个账号**把下面这些都记下来、看得见:
| # | 需求项 | 用户原话 |
| --- | --- | --- |
| 1 | 导入时间 | 「这个账号 导入时间」 |
| 2 | CK 登录记录 | 「ck登录记录」 |
| 3 | **CK 失效时间** | 「失效时间」 |
| 4 | 兑换记录 | 「兑换记录」 |
| 5 | 积分多少 | 「积分多少」 |
| 6 | 消耗记录 | 「消耗记录」 |
| 7 | 充值记录 | 「充值记录」 |
| 8 | **精英宝典开通记录** | 「精英宝典开通记录 时间等等」 |
| 9 | **电竞手册开通时间** | 「还有电竞手册开通时间 啥的」 |
| 10 | 兜底原则 | 「只要跟这个账号有关系的 都记录下来」 |
## 2. 现状盘点:每条需求今天有没有数据
上机实测(2026-10-03)—— 结论是**大部分业务数据已经存在**,但两点不足:
**散在 687MB 的噪声表里**、以及**状态变更没有历史**(覆盖式)。本设计**不回填**这些历史,
只保证「从现在起不再丢」。
| # | 需求 | 今天的数据在哪 | 本设计怎么处理 |
| --- | --- | --- | --- |
| 1 | 导入时间 | `accounts.created_at` | ✅ 直接读,无需事件 |
| 2 | CK 登录记录 | `login_tasks`;解耦后状态在 `accounts` | ✅ 读现有 + 新事件补「失效/重登」 |
| 3 | **CK 失效时间** | 只有 `ck_check_status`+`ck_checked_at`,**每次检测覆盖** | 🆕 **新增事件**(覆盖前记一笔) |
| 4 | 兑换记录 | `douyu_tasks` `exchange_goods`(10,009 行) | 🔸 **不回填**;上线后用收集器增量提取 |
| 5 | 积分多少 | `accounts.points`(当前值) | ✅ 状态卡直接读 |
| 6 | 消耗记录 | 兑换任务的 `result.score` | 🔸 同 #4(增量) |
| 7 | 充值记录 | `douyu_tasks` `create_gold_qr`(13,867 行) | 🔸 同 #4(增量) |
| 8 | **精英宝典开通** | 只有 `elite_opened`(bool),**覆盖式** | 🆕 **新增事件** |
| 9 | **电竞手册开通** | 只有 `esports_bind_status` 字符串,**覆盖式** | 🆕 **新增事件** |
| 10 | 兜底(人工操作) | `audit_logs` | ⚠️ **关联不到账号**,需补 `account_id`(§7) |
> 🔸 = 既有数据不回填,从上线起增量收录。账号的历史明细仍可在既有的
> `/douyu/elite`、`/douyu/esports`、`/login-tasks` 等页面查到,**本需求不复制它们的历史**。
### 2.1 为什么用事件表、而不是详情页实时聚合
`douyu_tasks` 现状 **222,679 行 / 687MB**,每天在写。若详情页实时聚合:
- 每次开页要扫大表 + 解析巨型 JSON(单条兑换的 `result` ≈ 8KB);
- 噪声占绝大多数(`query_limited_goods` 6.7 万行、`query_change_bind_time` 3 万行是例行轮询);
- `douyu_tasks` 将来若做保留策略(规划 §11.4),账号历史会跟着消失。
所以事件表**精简、独立、可长期保留**。反过来说:**不回填也就不必去核对历史 JSON 的字段路径**,
风险与工作量都大幅下降。
## 3. 目标模型:`account_events`(账号事件流)
一张 append-only 的事件表,**账号的时间线 = 这张表按时间倒序读**。
```python
class AccountEvent(Base):
__tablename__ = "account_events"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
account_id: Mapped[int] = mapped_column(ForeignKey("accounts.id"), index=True)
event_type: Mapped[str] = mapped_column(String(32), index=True)
occurred_at: Mapped[datetime] = mapped_column(UTCDateTime, index=True)
title: Mapped[str] = mapped_column(String(256), default="") # 直接可展示
amount: Mapped[int | None] = mapped_column(
Integer, nullable=True
) # 充值额/消耗积分(有符号)
balance_after: Mapped[int | None] = mapped_column(
Integer, nullable=True
) # 变更后余额
detail: Mapped[dict | None] = mapped_column(
JSON, nullable=True
) # 精简后的细节(非原始大 JSON)
source: Mapped[str] = mapped_column(
String(24), default=""
) # login/task/audit/manual
source_ref: Mapped[str] = mapped_column(String(96), unique=True) # 幂等键
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow)
```
设计要点:
- **`source_ref` 唯一键**:如 `douyu_task:123456`、`login_task:14166`、`audit:789`。
作用是**幂等**:收集器重跑、事件重复发射都不会造出重复时间线(§4 的水位线失守时靠它兜底);
- **不存原始大 JSON**:只存提取出的业务字段(商品名、单号、金额…),把 687MB 的包袱甩掉;
- **`amount` 有符号**:充值为正、兑换消耗为负,便于页面上做「累计充值 / 累计消耗」;
- **`occurred_at` 用业务发生时间**(不是入库时间):收集器取原任务行的 `finished_at || created_at`;
直接发射的取当下时刻。
### 3.1 事件类型(初版)
| event_type | 含义 | 来源 |
| --- | --- | --- |
| `imported` | 账号导入 | `accounts.created_at` |
| `login_ok` | 登录成功(含新 CK) | `login_tasks` / 解耦后的账号写入点 |
| `ck_invalid` | **CK 失效**(新增记录) | 巡检判定 |
| `relogin_ok` / `relogin_failed` | 自动重登结果 | `ck_relogin` 作业 |
| `bind_ok` / `rebind` | 绑定 / 换绑 | `douyu_tasks` |
| `recharge` | 充值到账 | `create_gold_qr` |
| `exchange` | 兑换(**含消耗积分**) | `exchange_goods` |
| `points_credit` | 积分到账(含**到期时间**) | `donate_elite_gift` |
| `points_expire` | 积分即将到期 | 由 `willExpireTime` 派生 |
| `elite_opened` | **精英宝典开通**(新增记录) | `douyu_runner_manual` |
| `esports_opened` | **电竞手册开通**(新增记录) | `douyu_runner_manual` |
| `assigned` / `tag_changed` / `remark_changed` | 人工操作 | `audit_logs`(需补 account_id,见 §7) |
## 4. 业务事件怎么收录:~~增量收集器~~ → 已改为**直发**(不回填)
> ## ⚠️ 本节已作废(2026-10-04,S5)
>
> 收集器方案**已放弃并删除代码**。原因(详见
> [账号全生命周期设计](设计-账号全生命周期.md) §3.8):
>
> 1. **靠 allowlist 认业务类型必然漏**:新增类型没人想起维护名单就静默丢数据 ——
> 电竞兑换、和平小店兑换、锁单支付、电竞礼物积分因此**从来没进过时间线**;
> 2. **「任务 success」不等于「业务真的发生了」**:预约到货、已预兑、「检测到本来就
> 已绑定」、积分未确认到账,都被标成 success,收集器照单全收写成事实;
> 3. **字段路径靠抽样猜**:二维码充值的 `result` 里根本没有金额,收集器因此恒写
> `amount=None`,`recharge_total` 系统性少计;
> 4. 作业**默认关闭**,生产上它一直没跑。
>
> 现在业务代码在动作成功那一刻用 `services/business_events.py` 的命名构造函数直发
> (幂等键 `douyu_task:{id}`,与旧收集器一致,迁移窗口内可互相去重)。
>
> 下文 §4.1 / §4.2 / §5.2 保留作为**历史记录**:其中的 `result` 字段实测结论仍然
> 有价值(接入斗鱼侧接口时用得上),但**不要再按它实现收集器**。
既有历史**不导入**。业务事件(充值/兑换/积分到账/绑定)~~用**一个增量收集器**从 `douyu_tasks`
往后提取 —— 好处是**完全不侵入 runner 代码**(不必改 8 个业务文件),且新老任务类型统一处理~~。
### 4.1 收集器设计
```
水位线(watermark):记录「已收集到 douyu_tasks.id = N」
每轮(挂到现有 automation 调度器,5~15 分钟一次):
1. 读 douyu_tasks WHERE id > N AND task_type IN (业务类型) ORDER BY id LIMIT 500
2. 按 §4.2 的映射提取字段 → 写 account_events(source_ref = "douyu_task:<id>" 保证幂等)
3. 更新水位线到本批最大 id
```
- 水位线存在 `automation_jobs.config`(JSON)或单独一小张表,**必须持久化**(进程重启不重来);
- `source_ref` 唯一键兜住「同一行被处理两次」(例如水位线写入失败后重跑);
- **起点 = 上线那一刻的 `MAX(douyu_tasks.id)`**,不回溯。
### 4.2 字段映射(已抽样核实,仅用于**新**数据)
已实测的 `result` 结构(用于收集器解析):
**`exchange_goods` → `exchange`**
| 事件字段 | JSON 路径 | 实测样例 |
| --- | --- | --- |
| title | `commodity_name` | `套装-兔兔白日梦` |
| detail.commodity_id | `commodity_id` | `20260120QYOOB_bag10` |
| **amount(消耗)** | `score`,取负 | `-1280` |
| **balance_after** | `points_query.points` | `0` |
| detail.order_id / exchange_id | `order_id` / `exchange_id` | `147879` / `11241526` |
| detail.game_name | `game_name` | `唉痛恨透` |
| detail.game_channel | `game_channel` | `手Q / 安卓(android)` |
**`create_gold_qr` → `recharge`**
| 事件字段 | JSON 路径 | 实测样例 |
| --- | --- | --- |
| **amount(充值)** | `buy_num` / `pay_amount` | `98` |
| detail.order_id | `order_id` | `DYGF5C24C5E4EA84T238035` |
| detail.supplier_order_id | `supplier_order.order_id` | `200000229425420261003114601` |
| detail.supplier_finished_at | `supplier_order.finish_time` | `2026-10-03 11:46:07` |
| detail.channel | `recharge_channel` | `supplier_api` |
| detail.douyu_uid | `douyu_uid` | `678503799` |
**`donate_elite_gift` → `points_credit`**
| 事件字段 | JSON 路径 | 实测样例 |
| --- | --- | --- |
| **amount(到账)** | `gift_points_expected_gain` | `980` |
| **balance_after** | `points` / `points_query.points` | `1280` |
| detail.baseline / target | `gift_points_baseline` / `gift_points_target` | `300` / `1280` |
| **detail.points_expire_at** | `points_query.raw.data.willExpireTime` | `1798819199`(时间戳) |
> 绑定类(`confirm_bind` / `*_esports_bind`)的 `result` 结构**尚未抽样**,实现前先各 dump 一条
> (教训见 [CK 误删复盘](事故复盘-CK误删与恢复.md):字段假设必须实测)。收集器对
> **解析不出的行要记 warning 并跳过**,不能让一行坏数据卡住整条水位线。
### 4.3 覆盖的事件量(预估)
按实测的业务任务量,稳定后每天新增事件量级在**数百条**(充值/兑换/到账为主),
一年约 10 万条以内 —— 事件表长期保留没有压力。
## 5. 事件从哪来:两条路
### 5.1 直接发(CK 相关 + 开通类)—— 4 处
这几类**不是 `douyu_tasks` 里的行**,只能在代码里「状态被覆盖之前先记一笔」:
| 场景 | 位置 | 事件 |
| --- | --- | --- |
| CK 判定失效 | `automation/jobs/ck_health.py`(判定 invalid 时) | `ck_invalid` |
| CK 重登成功/失败 | `login_service._write_login_success` / `ck_relogin`(**CK 解耦的同一处**) | `login_ok` / `relogin_ok` / `relogin_failed` |
| 精英宝典开通 | `douyu_runner_manual.py:377`(`elite_opened = True` 那一行) | `elite_opened` |
| 电竞手册开通 | `douyu_runner_manual.py:426`(`esports_bind_status = "esports_opened"`) | `esports_opened` |
这 4 处正好是「当前状态被覆盖」的现场。实现上收敛成一个 helper:
`account_events_service.emit(db, account_id, type, title, amount=..., balance_after=..., detail=...)`,
并要求**发射失败不影响主流程**(事件是旁路,不能因为它让登录/兑换失败)。
### 5.2 收集器(业务类)—— 0 处侵入
充值 / 兑换 / 积分到账 / 绑定 这些**已经是 `douyu_tasks` 的行**,走 §4 的增量收集器,
**不动 runner 代码**。
> 这个分工的取舍:CK 与开通事件「只在内存里出现过」,不记就永远没有;
> 业务事件已经在表里有结构化落库,再改一遍 runner 属于重复工作。
## 6. 页面设计:`/accounts/:id`(账号详情)
现在是**列表页**(`AccountsPage.tsx`),没有详情页。新增:
```
┌─ 顶部:账号身份 ────────────────────────────────────────┐
│ 用户名 / 斗鱼昵称+UID / 标签 / 归属客服 / 导入时间(第 N 天) │
│ 操作:分配 · 标签 · 备注 · 登录 · 检测 CK · 重登 · 导出 │
└──────────────────────────────────────────────────────┘
┌─ 状态卡(当前值:accounts 表)──────────────────────────┐
│ 积分(含到期时间) · 鱼翅余额 · 兑换余额 │
│ 精英宝典:已开通 / 到期时间 · 电竞手册:开通+绑定+可换绑 │
│ CK:状态 · 最近检测 · 最近重登 ← CK 解耦后从 accounts 读 │
└──────────────────────────────────────────────────────┘
┌─ 统计条(由 account_events 聚合)──────────────────────┐
│ 累计充值 ¥X · 累计兑换 N 次 / 消耗积分 M · CK 失效 K 次 │
└──────────────────────────────────────────────────────┘
┌─ 时间线(account_events 倒序 + 类型筛选 + 分页)────────┐
│ 10-03 11:46 充值 ¥98 供应商直充成功 │
│ 10-03 11:46 积分 +980 → 余额 1280(到期 12-31) │
│ 10-03 11:46 兑换 -1280 套装-兔兔白日梦 │
│ 10-02 17:17 登录 重新登录成功,Cookie 有效 │
│ 10-01 09:12 CK失效 检测判定 CK 已失效 │
│ 09-30 14:00 开通 精英宝典开通 │
└──────────────────────────────────────────────────────┘
```
- 接口:`GET /api/accounts/{id}/events?types=&page=&page_size=`、`GET /api/accounts/{id}/overview`;
- 权限:沿用账号可见性(客服只能看自己名下账号)+ 敏感字段按现有 `account:view_full` 控制;
- 入口:账号列表点用户名进详情;从 CK 页、登录任务页也可跳转。
## 7. 必须先修的缺口:`audit_logs` 关联不到账号
实测 `audit_logs.target` 是**自由文本**:
| action | 样例 target |
| --- | --- |
| `account:assign` | `冰柳192`(用户名) |
| `account:import` | `导入103个`(一句话) |
| `recharge:douyu:create` | `douyu_batch:000619c494fd`(批次号) |
| `account:delete` | `hui60945`(用户名) |
**没有 account_id**,只能拿用户名去模糊匹配 —— 用户名可改、可重复,**不可靠**。
建议:`audit_logs` 增加 `account_id`(可空)列,账号相关操作写入时带上。
这样「谁在什么时候分配/改标签/删除了这个账号」才能进账号时间线。
这是本次需求的前置项,改动很小(加一列 + 若干调用点传参)。
## 8. 与 CK 解耦怎么「一块弄」
两者的**写入点是同一批**,所以设计上共用一套约定,实施上排好顺序即可:
| 事项 | CK 解耦 | 账号事件 |
| --- | --- | --- |
| 状态落点 | CK/健康/重登状态 → `accounts` | 新增 `account_events`(流水) |
| 写入点 | `_write_login_success`、`ck_health`、`ck_relogin` | 同上 + `elite/esports 开通` 两处 |
| 读取点 | Cookie 页、工作台、仪表盘 | 新增详情页 |
**共享的一条原则**:`accounts` 存**当前状态**,`account_events` 存**发生过什么** ——
这与 CK 解耦「状态归账号、流水归历史表」是同一个模型。做完解耦,账号上就有了完整的
「当前状态」,事件表补上「历史」,详情页就是把两者拼起来。
**建议顺序**(在 [CK 解耦的 7 个提交](设计-登录任务与CK解耦.md#10-提交计划7-个提交) 之后接):
| 提交 | 内容 | 依赖 |
| --- | --- | --- |
| E1 | `feat(db): account_events 表 + audit_logs.account_id` | 无 |
| E2 | `feat(events): 事件发射 helper + CK 事件(失效/登录/重登)` | **CK 解耦已完成**(否则写入点还会变) |
| E3 | `feat(events): 开通事件(精英宝典 / 电竞手册)` | 无 |
| ~~E4~~ | ~~`feat(events): douyu_tasks 增量收集器 + 水位线`~~ **已废弃**(S5 改为直发并删除) | 无 |
| E5 | `feat(api): 账号事件与概览接口` | E1 |
| E6 | `feat(ui): 账号详情页(状态卡 + 统计 + 时间线)` | E5 |
| E7 | `docs: 落点与验收` | —— |
> 原计划的「历史回填脚本」已取消(§2 已定不回填),E4 从「搬运 8.5 万行」变成
> 「装一个水位线收集器」。
## 9. 风险与注意
1. **不改 runner 代码**:业务事件走收集器(§4),只有 CK/开通 4 处直接发(§5.1)——
把改动面压到最小;
2. **`source_ref` 唯一键是幂等的生命线**:收集器重跑、事件重复发射都靠它兜住,
漏了就会污染时间线;
3. **收集器的水位线必须持久化**,且**解析失败要跳过并告警**,不能让一行坏数据卡住整条链路;
4. **发射事件是旁路**:必须保证 `emit()` 抛错不影响登录/兑换主流程(用 try 包住 + 记日志);
5. **积分口径要先定**(决策 C):`accounts.points` 与 `donate_elite_gift` 的 `points_query.points`
有不一致的情况(如兑换后 `points_query.points=0`),时间线显示哪个要明确;
6. ~~绑定类任务的 `result` 结构尚未抽样(§4.2),收集器实现前先 dump 一条确认。~~ **已随收集器一并作废**:直发不再需要解析 `result`。
## 10. 待确认的决策点
| # | 决策 | 选项 | 建议 |
| --- | --- | --- | --- |
| **A** | 时间线是否收录「例行查询」类事件 | ①只收业务事件(推荐)②全都收 | ①:6.7 万行轮询进去后时间线没法看 |
| **B** | 详情页入口 | ①账号列表点用户名(推荐)②独立菜单 | ①更顺手;②可加,但主入口应是列表 |
| **C** | 积分显示口径 | ①以 `accounts.points` 为准,事件里记变更量(推荐)②以事件累加为准 | ①简单可靠;②容易和历史噪声打架 |
| **D** | 收集器的跑法 | ①挂到现有 automation 调度器(推荐)②独立定时任务 | ①复用现成的开关/运行记录/页面;②多一套机制 |
| **E** | `audit_logs` 加 `account_id` 是否随本次做 | ①做(否则人工操作进不了时间线)②先不做 | ①(§7) |
| ~~F~~ | ~~回填范围~~ | —— | ✅ **已定:不回填**,从上线起增量累积 |
---
## 11. 落地记录(2026-10-03)
已按本文实现,**已提交在 `dev`、未碰生产**(用户要求先本地验证)。迁移编号见 §11.4。
| 提交建议 | 内容 | 落点 |
| --- | --- | --- |
| E1 | 迁移 `20261003_0041`:`account_events` + `audit_logs.account_id` | `migrations/versions/20261003_0041_account_events.py`、`models.py`(`AccountEvent`)、`tests/test_migrations.py` |
| E2 | 事件服务 `emit()` + 任务映射 | `services/account_events_service.py` |
| E3 | 发射点:CK 失效 / 登录·重登成功 / 重登转人工 / 精英宝典·电竞手册开通 | `jobs/ck_health.py`、`services/login_service.py`、`jobs/ck_relogin.py`、`services/douyu_runner_manual.py` |
| E4 | 收集器作业(水位线在 `automation_jobs.config`) | `jobs/account_events_collect.py`,注册为「账号事件收集」 |
| E5 | 接口 | `routers/accounts.py`:`GET /{id}/overview`、`GET /{id}/events` |
| E6 | 页面 | `pages/AccountDetailPage.tsx`;路由 `/accounts/:id`;列表用户名可点入 |
### 11.1 与设计的差异(实施时改的)
1. **收集器的水位线放作业自己的 `config`**,不再寄生在 `ck_health` 的 config 里 ——
顺带白拿「自动巡检」页的开关、运行记录与手动触发;
2. **`source_ref` 可空唯一,不用空串默认值**。原设计写的是 `server_default=""`:MySQL 唯一索引下
**空串会互相冲突**,第二条「无幂等键」的事件就插不进去。改为可空(MySQL 允许 NULL 重复),
并补了用例专门盯这点;
3. **电竞手册只在「真正开通」那一支发事件**。代码里有两处都会写 `esports_bind_status="esports_opened"`:
支付流程成功后(真开通)与「基线查到已开通」(检测)。两处都发会每次检查刷一条,只取前者;
4. **重登失败只在「转人工」时发事件**:还要退避重试的每轮都会再来,发事件会刷屏;
5. **`emit` 用 SAVEPOINT 隔离**:失败只回滚这一条,不污染调用方事务(有用例覆盖)。
### 11.2 实施时踩到的坑
1. **`create_all` 不改已存在的表** —— 给 `audit_logs` 加列后,测试库里的旧表没有该列,
**22 个不相关测试全挂**(`Unknown column 'account_id'`)。改已有表的模型后,测试库要么手动
`ALTER`,要么 `DROP` 让 `create_all` 重建。已在本地测试库补列;**这个坑值得写进通用约定**;
2. **REPEATABLE READ 快照**(又踩一次):用例里用另一个 session 写入事件后,原 session 查不到 ——
与 P1b「成功被记成失败」同根因。用例里补 `commit()` 结束事务;
3. **`delete_account` 必须连带清事件**:`account_events` 有指向 `accounts` 的外键,不清就删不掉账号。
这是本次改动**引入的**问题,已修并有用例;
4. **测试数据的字符集**:用 `docker exec mysql < file` 灌中文时没带 `--default-character-set=utf8mb4`,
数据被双重编码(`套装` → `套装`)。排查时先看 `HEX()` 就能立刻分清「存坏了」还是「读坏了」;
5. **⚠️ naive datetime 被当成「本地时间」(2026-10-04 审计发现并修复)**:
`emit()` 原来写的是 `(occurred_at or now).astimezone(UTC)`。对 **naive** 值,Python 会把它
当作**服务器本地时间**再换算 —— 开发机 UTC+8,事件时间整整**早 8 小时**。
naive 的来源很常见:**裸 SQL** 读 DATETIME 列(收集器读 `douyu_tasks.finished_at`)
会绕过 `UTCDateTime` 装饰器,拿到的就是 naive。
仓库的既定约定是「**naive = 已经是 UTC,原样用**」,`scheduler._as_utc` /
`yyb_service._as_utc` / `audit.py` 都遵守了,只有 `emit()` 漏了。
已抽成 `account_events_service.to_naive_utc()` 统一收口。
**这个 bug 在 TZ=UTC 的机器上完全不可见**(`astimezone(UTC)` 恰好是恒等变换),
所以只在开发机上现形、CI/容器里全绿 —— 回归用例必须**显式把进程时区切到非 UTC**
才有意义(见 `tests/test_account_events.py` 的 `_force_non_utc_timezone`)。
**若某套环境曾开启过收集器**,用这段按源任务时间对齐(幂等):
```sql
UPDATE account_events e
JOIN douyu_tasks t ON t.id = CAST(SUBSTRING(e.source_ref, 12) AS UNSIGNED)
SET e.occurred_at = COALESCE(t.finished_at, t.created_at)
WHERE e.source = 'task' AND e.source_ref LIKE 'douyu_task:%';
```
### 11.3 本地验证结果
| 项 | 结果 |
| --- | --- |
| 后端测试 | **363 通过**(新增 20 条:幂等、旁路隔离、映射、水位线、API、账号隔离) |
| 收集器 | 3 条业务任务 → 3 条事件;金额/余额/中文均正确;水位线 0 → 686 |
| 概览接口 | 累计充值 98、兑换 1 次、消耗积分 1280、导入时间、登录次数、最近 CK 检测均正确 |
| 时间线接口 | 倒序分页正确、按类型筛选正确、中文无乱码 |
| 前端 | `tsc -b` 通过、`vite build` 通过、模块转译正常(dev 5174 → 8800) |
> 本地验证时把收集器**临时启用**并手动灌了 3 条测试任务(`batch_id='local-evt'`,仅本地开发库)。
> 生产上该作业仍是 `enabled=false`,需在「自动巡检」页手动打开;首次运行只定水位线、不回溯历史。
### 11.4 迁移编号改号:`20261002_0040` → `20261003_0041`
本功能最初取的号是 `20261002_0040`,`down_revision` 指向 `20261002_0039`。开发期间生产上并发
落地了索引优化,它同样从 `20261002_0039` 起链、占了 `20261003_0040`。两边一旦合并,
**两个迁移平级 = 两个 alembic head**,`alembic upgrade head` 会直接报
`Multiple head revisions are present`,`tests/test_migrations.py` 的单头断言也会失败。
处理(2026-10-03):
1. 文件名改为 `20261003_0041_account_events.py`,`revision` 改 `20261003_0041`,
`down_revision` 改指 `20261003_0040`,接在索引优化之后;
2. `tests/test_migrations.py` 的 `HEAD_REVISION` 同步改 `20261003_0041`;
3. **改号是安全的**,因为 `20261002_0040` 从未被生产库应用过(`dev` 未部署)。
本机开发库 `douyu_login` 的 `alembic_version` 已是 `20261003_0040`,且 `account_events`
表已存在 —— 迁移写成幂等的(先 `sa.inspect` 查存在再建),所以升级到 `0041` 只会空跑一次
并把版本号推进,不会重复建表。
> ⚠️ 教训与规划 §C.1 一致:**迁移编号一旦被任何库应用过就不能再改名**。
> 这次能改,纯粹是因为它还没进生产。