- 高价值套装精确匹配「套装-兔兔白日梦」与「套装-狂战萌兔」,排除头盔/背包/手榴弹散件干扰 - 增加 is_suit_queried_expr,未查询或 NULL 账号严禁误判为双套均可兑换 - 修复前后端序列化空值保持,完善列表与详情页准入状态和判定依据展示 - 新增头盔排除与 NULL 统计防错回归单测,全量通过 689 个用例
417 lines
26 KiB
Markdown
417 lines
26 KiB
Markdown
# 设计:账号详情页与账号事件历史
|
||
|
||
> 状态:**后端已完成、前端已完成,本地验证通过**(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 一致:**迁移编号一旦被任何库应用过就不能再改名**。
|
||
> 这次能改,纯粹是因为它还没进生产。
|