# 设计:账号详情页与账号事件历史 > 状态:**后端已完成、前端已完成,本地验证通过**(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:" 保证幂等) 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 一致:**迁移编号一旦被任何库应用过就不能再改名**。 > 这次能改,纯粹是因为它还没进生产。