Files
live-hub-py/docs/设计-账号全生命周期.md
T
yml2213 5ae5d92978 feat(account): 兑换记录列 + 修掉 source_ref NOT NULL 导致的事件静默丢失
三件事合成一次提交(按反馈不再一改一提交)。

## ① 账号列表加「兑换记录」列

次数 + 最近一次(商品名 + 日期),填上列表右侧的空档。

数据源是**账号自己的事件流**(`account_events` 的 `exchange`),不是 `douyu_tasks`
的 JSON —— 后者是执行日志,S5 起业务事实已经在事件里了。

性能上刻意**每页只跑两条聚合查询**(不是每行一条 N+1):
一条分组算 `count` + `max(occurred_at)`(走 `(account_id, event_type, occurred_at)`
复合索引),一条用窗口函数 `ROW_NUMBER()` 取每个账号最新那条的商品名。
实测 6 个账号 3 条兑换事件,聚合与「最近」都正确。

「兑换 」前缀在前端剥掉(列头已经叫「兑换记录」,再带前缀是重复)。

## ② 修掉一个静默丢事件的 bug(排查中发现的)

**现象**:开发库上「状态变迁 / 分配 / 标签 / 备注 / CK 清除」这些事件**一条都没有**,
而带幂等键的事件(业务事实、`ck_invalid`、`relogin_*`)照常写入 ——
时间线看起来有数据,只是恰好缺了某一类,**极难发现**。

**根因**:`source_ref` 是**可选**幂等键(模型与迁移文件都是 `nullable=True`,
`emit()` 对没有天然幂等键的事件就传 `None`)。但早期那版迁移
(重编号成 `0041` 之前的 `0040`)建的是 **NOT NULL**,而我后来只改了
**迁移文件**、没管**已建好的库** —— Alembic 只记版本号,不会重放已应用的脚本。
`__pycache__` 里那个 `20261002_0040_*.pyc` 就是旧版留下的痕迹。

**为什么静默**:`emit()` 按设计吞掉任何异常(事件是旁路,不能影响主流程),
只记一条 warning,调用方也从不检查返回值。已实测复现:
`emit(...)` 返回 `False` + warning,实际落库 `0` 条。

**修法**:迁移 `20261004_0045` 改回可空(`MODIFY`,**唯一索引保留**,幂等,
生产若跑过旧版也一并修好)。修复后实测:`emit` 返回 `True`、落库 1 条。

## ③ 补一条能防「未来」同类问题的守卫

`tests/test_migrations.py` 原本只比对「表/列/索引在不在」,**不比对可空性**。
已加:迁移建出的列**不允许比模型更严**(模型说可空、库里 NOT NULL)。

只查这个**危险方向**:反方向(模型 NOT NULL、库里允许 NULL)是无害的宽松,
历史遗留里有 28 处,一并报出来只会制造噪声、逼人修没收益的东西。

> 这条守卫**有边界**,已在代码注释与设计文档 §3.5.1 写明:
> 它在**全新库**上跑整链,只能防「未来的迁移建错」,**防不了已有库的历史漂移**。
> 已验证:临时禁用 `0045`,该用例仍通过(全新链本来就是对的)。

## 验证

- `TZ=Asia/Shanghai`:**503 passed / 6 skipped**(+3 条兑换列用例)
- `TZ=UTC`(迁移 + 列表相关)45 passed;ruff 全绿;前端 build 通过、lint 0 errors
- 真实开发库:迁移已应用(head `20261004_0045`),
  `source_ref` 现在 `IS_NULLABLE=YES` 且唯一索引仍在;
  不带幂等键的事件确实能落库;兑换聚合返回值正确

注:验证时在开发库那个有 CK 的账号上留了 3 条 `exchange` 演示事件
(兔兔白日梦 / 冠军鸡腿 / 烟花),是为了让列有数据可看,随时可删。
2026-10-04 17:25:40 +08:00

38 KiB
Raw Blame History

设计:账号为中心的全生命周期模型(斗鱼)

状态:待评审(未动代码)|2026-10-04 定位:斗鱼账号域的全新设计。本文取代以下文档中关于「账号应该长什么样」的部分, 其余文档保留其专业内容:

不参照虎牙(虎牙是另一套业务与历史,只有「账号自己承载凭证」这一点值得借鉴)。


1. 为什么推倒重来

1.1 三个「状态」都找不到家

状态 今天在哪 后果
凭证状态(CK 本体) login_tasks.cookie(某一条登录记录的字段) 删一条历史记录 = 账号失去登录态(10-03 事故根因)
健康与重登状态 login_tasks.ck_check_* / relogin_* 状态挂在「碰巧最后一次登录那一行」上;一个账号有两行持 CK 时(实测有)谁的权威不明确
生命周期状态 不存在 没有任何字段能回答「这个账号现在是什么状态」,只能靠「有没有持 CK 的记录」反推

1.2 bind_status 是个垃圾场(本次推翻的直接证据)

accounts.bind_status 一列里塞了 36 种取值,把三种完全不同性质的东西混在一起:

性质 取值
真·绑定状态 bind_confirmed / xpd_bound / xpd_not_bound / game_not_bound / game_queried
一次性动作的痕迹 gold_qr_created / points_queried / goods_refreshed / csrf_token_ready / limited_goods_queried / elite_status_queried
业务结果 gold_recharged / gift_donated / goods_exchanged / elite_opened / esports_opened

致命点:它们互相覆盖。 代码里 account.bind_status = "elite_status_queried"(查一次精英宝典状态) 会把之前的 bind_confirmed 冲掉 —— 于是「这个账号到底绑没绑」查不出来。 而账号详情页正拿这个字段显示「绑定状态」。

同样的问题在 esports_bind_status(8 种)与 xpd_bind_status(8 种)上重复,且三列取值还互相串 (esports_* 同时出现在 bind_status 与 esports_bind_status 里)。

结论:账号的「当前状态」被当成「任务执行日志」在用。这不是加几个字段能修的,是列的语义没有定义。

1.3 业务记录埋在 687MB 的 JSON 里

开通 / 积分 / 兑换 / 充值 这些账号的业务事实,今天只存在于 douyu_tasks.result(JSON)中:

问题 实测
体量 douyu_tasks 222,679 行 / 687MB,且每天在写
噪声占比 绝大多数是例行轮询(query_limited_goods 6.7 万行、query_change_bind_time 3 万行)
结构 每条结果 JSON 结构不一(单条兑换 result ≈ 8KB),字段路径靠抽样猜
可用性 无法按账号查历史、无法建索引、无法支撑「365 天同皮肤」规则
寿命 一旦给 douyu_tasks 做保留策略(规划 §11.4),账号历史跟着消失

1.4 三套东西各说各话

场景 今天怎么做 问题
账号检测 /account-check 页:粘贴一段文本 → 检测 → 下载 zip 结果不落库、没有 account_id,与库里账号完全脱节;AccountCheckInput 连账号 ID 字段都没有
CK 检测 /cookie-operations、ck_health 作业:按 login_tasks 行检测 写 login_tasks.ck_check_*,与上面那套是两回事
业务执行 /douyu/elite、/esports、/peace 工作台 → douyu_tasks 结果散在 JSON 里,账号页看不到

同一个账号,三处各有各的「状态」,互不相认。


2. 设计原则

  1. 账号是聚合根 —— 一切围绕账号发生的事实,都以 account_id 归属;不存在「必须借道登录记录才能找到账号状态」的路径。
  2. 当前状态在账号上,发生过什么在流水里 —— 两者严格分离。状态可覆盖,流水只追加,且流水不因状态变化而丢失。
  3. 一列一义 —— 禁止「最后一件事」式的复用列(bind_status 的教训)。一列要么是某个确定的当前值,要么是某个确定的历史量。
  4. 结构可以推翻,数据必须无损 —— 可以丢掉旧的设计包袱,但 CK 一条都不能丢(实测重登当前不可用,丢了补不回来)。
  5. 历史从权威源来,不从噪声里挖 —— 斗鱼侧有权威记录接口(兑换记录 / 小店购买记录 / 个人资料),补历史优先走它,不要在 687MB 的 JSON 里考古。

3. 领域模型

3.1 总览

                    ┌───────────────────────────────────────────┐
                    │              accounts                     │  ← 聚合根
                    │  身份凭据 · 平台身份 · 生命周期 · 归属运营   │
                    │  登录态(CK/健康/重登)· 当前画像快照        │
                    └───────────────┬───────────────────────────┘
                                    │ account_id
        ┌───────────────┬───────────┼────────────┬──────────────────┐
        ↓               ↓           ↓            ↓                  ↓
  account_events   login_tasks   douyu_tasks  yyb_recharge_*   workbench_accounts
  时间线 + 业务台账  登录尝试历史   执行日志      应用宝充值       工作台归属
  (append-only)   (瘦身后)    (降级)      (已有)          (已有)

一句话:账号独自站立;登录记录退化为历史;douyu_tasks 退化为执行日志;业务事实进事件流。

3.2 accounts(账号主体)

按语义分四组,避免再出现 bind_status 那种无定义的列。

① 身份与凭据(导入时确定,人工维护)

列 说明
id / username / password(enc) 主键与登录凭据
email(enc) / email_password(enc) / email_imap_server / email_imap_port / email_imap_ssl 取码邮箱配置
uid / nickname 斗鱼平台身份(登录/查询后回写)

② 生命周期状态(新增,本文核心)

列 说明
status 见 §3.4 状态机;有明确取值域,不再是自由文本
status_reason 进入该状态的原因(失败分型 / 人工备注)
status_changed_at 状态最后一次变化时间
disabled_at / archived_at 停用 / 归档时间(可空)

③ 登录态(从 login_tasks 搬来,见 §3.3)

列 原位置
cookie(enc) / cookie_updated_at / cookie_source login_tasks.cookie
ck_status / ck_checked_at / ck_detail(JSON) login_tasks.ck_check_status/ck_checked_at/ck_check_result
relogin_attempts / relogin_next_at / relogin_last_error / relogin_failure_kind login_tasks.relogin_*

④ 归属与运营

列 说明
assigned_to / tag / remark 已有
auto_managed 已有;语义收窄为「是否参与自动化」,与 status=disabled 分工明确(disabled 是账号不可用,auto_managed=false 是可用但不自动跑)
import_batch_id / imported_by 新增:回答「这批 103 个现在活了几个」

⑤ 当前画像快照(覆盖式,来自斗鱼侧查询)

points / gold_balance / exchange_balance / game_name / game_channel / elite_opened / elite_expire_time / esports_points / esports_game_name / esports_game_channel / esports_can_change_time / xpd_*

这些是「现在是多少」,保留但清掉 bind_status / esports_bind_status / xpd_bind_status 三列, 用职责明确的字段替代(对照表见 §3.6)。

⑥ 时间戳:created_at(导入时间)/ updated_at

3.3 登录态:为什么不单开一张表

cookie + 健康 + 重登共 10 列,仍然是 1 账号 : 1 登录态。评估过单开 account_auth 表,否决,理由:

  1. 本仓库有多个「新开会话 / 多线程」的调用模式(ck_relogin 刻意用新 session 绕开 REPEATABLE READ 快照)。1:1 关系的延迟加载跨会话是经典 bug 源,而这里出 bug 的代价是CK;
  2. 1:1 拆表的收益(列表查询不拖大字段)用 defer() 就能拿到 —— accounts 列表接口现在 已经在用这个模式(defer(Account.password) 等);
  3. 切换成本:内联是纯字段搬家(LoginTask.cookie → Account.cookie),12 个文件全部只是改 类名与属性名,可逐处静态核对;拆表要额外引入 join 或 relationship,出错面更大。

若将来真出现「一个账号多端登录态」(web / app 各一份),再抽 account_auth(account_id, channel) 也不迟 —— 那时是新增,不是迁移,成本更低。

3.4 生命周期状态机

S3 已落地(2026-10-04),实现见 web/backend/services/account_lifecycle.py。 落地时把原来的 7 个状态收敛成 6 个:砍掉了 relogin(理由见下)。

        导入
         │
         ▼
    ┌─────────┐  登录成功    ┌────────┐  巡检判定失效   ┌─────────┐
    │imported │────────────▶│ active │───────────────▶│ invalid │
    └─────────┘             └────────┘                └────┬────┘
         ▲                    ▲   ▲                       │ 自动重登(可退避)
         │  清空 CK            │   │ 重登成功/人工登录救回   │
         └────────────────────┘   │                       ▼
                                  │              ┌─────────────────┐
                                  │              │ 极验/凭证问题     │
                                  │              │ 或重试次数用尽     │
                                  │              └────────┬────────┘
                                  │                       ▼
                                  │                  ┌─────────┐
                                  └──────────────────│ manual  │
                                    人工处理成功       └────┬────┘
                                                             │ 人工停用 / 归档
                                                             ▼
                                         ┌──────────┐        ┌──────────┐
                                         │ disabled │◀──────▶│ archived │
                                         └──────────┘        └──────────┘
                                          两者可互换,也可 restore 回自动态
状态 判据(derive_status) 能否跑业务 自动化
imported 没有 CK ✗ 不巡检(没 CK 可检)
active 有 CK,且 ck_check_status != 'invalid' ✅ 参与巡检
invalid 有 CK 但判失效,还会自动救 ✗ 参与重登
manual 判失效且已放弃自动救(极验/凭证,或试过却没排下次) ✗ 不参与(避免刷屏)
disabled 人工停用(最高优先级,不被推导覆盖) ✗ 不参与
archived 人工归档,默认列表不显示(同上) ✗ 不参与

为什么砍掉 relogin:它完全等价于 invalid + relogin_next_at is not None。 独立成状态就多出一个必须和 relogin_next_at 同步的字段 —— 正是下面要消灭的 「双份真相」。想筛「正在退避重试的」直接查那个字段即可。

状态与判据的分工(重要):

  • status 是人可读的当前阶段,落库、可索引筛选、可统计;
  • status 不替代底层判据:能不能跑业务仍然要看 cookie != '' AND ck_check_status != 'invalid';
  • 写入收口:状态只由 account_lifecycle 写 —— sync_status()(按判据推导) 与 set_manual_state()(人工态)。禁止各处直接赋值 account.status = ..., 这是 bind_status 崩掉的直接教训。

auto_managed 保留,与 disabled 的分工写进了 UI 与接口注释: disabled = 账号不可用;auto_managed=false = 账号可用,只是不自动跑。 S3 之前 auto_managed 是个死开关(没有任何接口/前端能改它),本次一并接上。

3.4.1 防漂移:三层防线

status 落库意味着两份真相(底层判据 + 摘要),谁忘了同步页面就撒谎。防线:

  1. 收口:推导逻辑只有 derive_status() 一份纯函数;所有写底层判据的地方 (cookie / ck_check_status / relogin_*,共 9 处)在其后调用 sync_status();
  2. 幂等:状态没变时 sync_status() 返回 False,不写库、不发事件 —— 否则巡检每轮都会给时间线刷一条;
  3. 迁移与运行时同规则:迁移 0042 的回填 SQL 与 derive_status() 必须一致; 有一条用例真跑那段 SQL 与纯函数逐条比对。分叉了不会报错,只会表现为 「迁移刚跑完是 A、下轮巡检又改成 B」。

另有 warn_if_drift() 供巡检顺手观测 —— 但真正的修复是补上 sync_status,不是硬改。

3.5 account_events(时间线 + 业务台账)

已实现(迁移 20261003_0041,在 dev)。本文在其上扩展,让它同时承担 「开通记录 / 积分记录 / 兑换记录 / 充值记录」—— 因为用户要的本来就是账号视角的一条时间线。

现有字段:id / account_id / event_type / occurred_at / title / amount / balance_after / detail(JSON) / source / source_ref(唯一,幂等) / created_at

新增 4 列:

列 用途
category 粗分类:lifecycle(状态变迁)/ business(开通兑换充值)/ ops(人工操作)/ auth(登录与CK)
subject_type 客体类型:commodity / skin / order / role / 空
subject_key 客体键:斗鱼 commodity_id / 归一后的 skin_key / 订单号
actor_type / actor_id 谁触发的:user / system / public(收集链接)+ 用户 ID

新增索引(支撑「365 天同皮肤」与各类台账查询):

-- 已有:ix_account_events_account_occurred (account_id, occurred_at)
-- 已有:ix_account_events_event_type (event_type)
-- 已有:ix_account_events_source_ref (source_ref, unique)
CREATE INDEX ix_account_events_ledger
    ON account_events (account_id, event_type, subject_key, occurred_at);

事件类型表(收敛后的完整取值域):

category event_type 含义 发射点 amount
lifecycle status_changed 所有状态变迁(detail 带 from/to/reason),停用与归档不再单列事件类型 account_lifecycle._apply(唯一) —
auth login_ok 登录成功(拿到新 CK) login_service._write_login_success —
auth ck_invalid CK 由好变坏(转变才记,见下) account_lifecycle.on_ck_verdict —
auth ck_cleared CK 被清除(不可逆,必须留痕) account_lifecycle.on_ck_cleared —
auth relogin_ok / relogin_failed 自动重登结果 login_service / ck_relogin.settle_results —
ops assigned 分配 / 取消分配 audit_service.record_account_action、批量走 emit_bulk —
ops tag_changed 标签变更(detail 带 from/to) 同上 —
ops remark_changed 备注变更(detail 带 from/to) accounts.set_account_remark(单个) —
business recharge 充值到账 business_events.record_recharge(二维码/供应商轮询/供应商回调 三条路径,同一 task_ref 去重) + ¥
business exchange 兑换(含消耗积分) record_exchange:exchange_goods 的真兑换分支、exchange_esports_goods、exchange_xpd_goods、pay_locked_order − 积分/点券
business points_credit 积分到账(detail 带 confirmed) record_points_credit:donate_elite_gift、donate_esports_* + 积分
business elite_opened / esports_opened 开通精英宝典 / 电竞手册 douyu_runner_manual —
business bind_ok(detail 带 rebind) 角色绑定 / 换绑 record_bind:confirm_bind 的真回查分支、confirm_esports_bind —

只有一个不落表:imported —— 导入时间直接读 accounts.created_at。

remark_changed 曾长期处于「定义了但没有发射点」(因为压根没有改备注的接口), 2026-10-04 补上 PUT /api/accounts/{id}/remark 后接顺。 教训:留着一个永远不发的类型,比删掉它更糟 —— 看代码的人会以为它有数据。

ck_invalid 只认「转变」(2026-10-04 S4 修正):旧实现每轮巡检只要判定 invalid 就发一条,注释写着「避免每轮刷屏」而代码并没有做到 —— 6 小时一轮、失效一周 = 28 条一模一样的事件,把时间线淹掉。判定失效是一次状态转变,不是每轮读数。 同理 error(查不到)不改变结论、也不发事件。

「开通 / 积分 / 兑换记录」怎么呈现:就是这张表按 category 或 event_type 过滤的视图, 不另建三张表。理由:

要支持的查询 统一事件表能否满足
账号详情页时间线(倒序分页) ✅ 已有索引
365 天同皮肤判定 ✅ (account_id, event_type, subject_key, occurred_at)
某皮肤被哪些账号兑换过 ✅ 同索引反查
累计充值 / 累计消耗积分 ✅ SUM(amount) 按 event_type
某账号开通了哪些手册、何时开通 ✅ 按 event_type 过滤
积分明细 ✅ points_credit + exchange 组合

反过来说:什么时候才该建独立表? 当它有「流程」而不只是「事实」时。 规划 §10 的 exchange_orders(工单:资料 → 匹配 → 执行 → 归档)有状态机,保留为独立表; 它执行成功后再往 account_events 写一条 exchange 事实。流程归工单,事实归事件流。

3.5.1 事故复盘:source_ref 的 NOT NULL 让一批事件静默消失

现象:开发库上「状态变迁 / 分配 / 标签 / 备注 / CK 清除」这些事件一条都没有, 而带幂等键的事件(业务事实、ck_invalid、relogin_*)照常写入 —— 时间线看起来「有数据」,只是恰好缺了某一类,极难发现。

根因:account_events.source_ref 是可选幂等键(模型与迁移文件都写着 nullable=True,emit() 对没有天然幂等键的事件就传 None)。 但早期那版迁移(重编号成 0041 之前的 0040)建的是 NOT NULL, 而我后来只改了迁移文件、没处理已建好的库 —— Alembic 只记版本号, 不会重放已应用过的脚本。开发库就是这么漂移的。

为什么静默:emit() 按设计吞掉任何异常(事件是旁路,不能影响主流程), 只记一条 warning;调用方也从不检查它的返回值。所以「写不进去」表现为完全正常。

修法:迁移 20261004_0045 把它改回可空(MODIFY,唯一索引保留,幂等)。 另在 tests/test_migrations.py 加了一条可空性比对:迁移建出的列不允许 比模型更严(模型说可空、库里 NOT NULL)。

⚠️ 这条守卫有边界:它在全新库上跑整条链,所以只能防「未来的迁移 把模型可空的列建成 NOT NULL」,防不了已有库的历史漂移(那种只能靠迁移本身修)。 已验证:把 0045 临时禁用,该用例仍然通过 —— 因为全新链本来就是对的。

教训:改动「迁移文件」时,已经建过表的库不会自动跟上。 要么写一条幂等修正迁移,要么确认所有环境都还没应用过。

3.6 bind_status 的拆解(旧列 → 新字段)

S6 已落地(2026-10-04),实现见 web/backend/services/account_binding.py, 迁移 20261004_0044。

它烂掉的方式:一个自由文本列,6 个文件、29 处写入、20 多种取值, 而且混了三种语义 —— 真状态(bind_confirmed)、动作痕迹(goods_locked)、 纯查询痕迹(points_queried)。因为它是覆盖式的,bind_confirmed 会被之后 任意一次查询盖掉,于是「这个账号到底绑没绑」查不出来。 esports_bind_status / xpd_bind_status 是同一个病。

收敛成四个值:unknown / unbound / pending / bound, 三列各管一个作用域(一列一义):

列 作用域
accounts.role_bind_status(新增) 精英宝典角色绑定
accounts.esports_bind_status(收敛) 电竞手册角色绑定
accounts.xpd_bind_status(收敛) 和平小店角色绑定

写入收口:只有 account_binding.set_bind_status() 能写; 传旧自由文本直接 ValueError(否则垃圾又会被写回去)。 有一条源码扫描用例禁止任何地方再出现自由文本写入。

回填不猜(§5.5):只映射明确陈述绑定关系的取值 (bind_confirmed/esports_bound/xpd_bound → bound,*_qr_*/*_ready → pending, game_not_bound/xpd_not_bound → unbound),其余一律 unknown。 迁移的 CASE 与 Python 映射有一致性用例(解析迁移 SQL 源码比对)。

动作与查询痕迹一律不再写状态列:动作已由 S5 的直发事件记录, 查询痕迹本来就不该是状态。

S6 顺带救回一个差点丢掉的状态

「电竞手册是否已开通」以前只寄生在 esports_bind_status 的 esports_opened 取值里(elite_opened 有独立布尔列,电竞没有)。归一化会把那个取值抹成 unknown —— 于是迁移里必须先 save_esports_opened() 把它救进新增的 accounts.esports_opened 列,再归一化。顺序用例钉住了这一点(upgrade() 里 save_esports_opened 必须早于 normalise_esports_bind_status)。

accounts.bind_status 列保留原值不动(便于人工比对),但已无任何写入方, 计划 S8 一并 drop。role_bind_status/esports_bind_status/xpd_bind_status 三列收窄成 String(16) + server_default='unknown',与模型一致; ⚠️ 收窄必须放在归一化之后(旧取值最长 19 字符,先收窄会截断成废值)。

前端不再从任务日志反推账号状态

工作台原来从当前加载的任务列表里挑每个账号最新的一条来推绑定状态 (latestConfirmBindTaskByAccount 等)—— 只看得见这一页的任务,支撑任务一旦翻出 窗口显示的状态就是错的;而且这让任务日志变成了「账号状态库」,于是它永远不敢清理。

现在状态读账号字段,任务日志只用来补「扫码流程中的瞬时信息」 (如扫码后、确认前的 pending_role_name)。

3.7 login_tasks 瘦身:退化为登录尝试历史

保留(这是它的本职): id / batch_id / account_id / status / message / created_by / created_at / finished_at / attempt_status / attempt_message / attempt_batch_id / attempt_at

迁走后 drop(下一次发版): cookie / ck_check_status / ck_check_result / ck_checked_at / relogin_attempts / relogin_next_at / relogin_last_error / relogin_failure_kind

不变量:

  • 删除任意登录历史行,不影响账号的 CK、健康、重登进度与生命周期状态;
  • 「在途尝试不可删」的护栏保留(批次线程正拿着那一行);
  • 批次行复用逻辑(login_service.py:599-632)重写为「同一账号不并发两行」,不再关心「哪一行持有 CK」。

3.8 douyu_tasks 降级:执行日志,不再是账号历史来源

S5 已落地(2026-10-04)。职责收窄为执行层:二维码、轮询、锁单、支付、 结果回传、WebSocket 推送。业务事实由业务代码在发生那一刻直接发事件 (services/business_events.py 的命名构造函数),不再事后从 JSON 里挖。

收集器已删除(jobs/account_events_collect.py 连同 _task_to_event、 COLLECTED_TASK_TYPES、水位线一起删掉,迁移 20261004_0043 清掉作业行)。 它必然漏数据:靠 allowlist 认业务类型,新增类型没人想起维护就静默丢 (电竞兑换、和平小店兑换、锁单支付、电竞礼物积分因此从来没进过时间线)。

保留策略(jobs/douyu_tasks_retention.py,默认关闭,由运维打开):

档 判定 保留
噪声查询 只读看一眼就走,且前端不拿它派生账号状态 7 天
其余一切(业务事实 + 状态派生依赖的查询 + 以后新增的类型) — 90 天

长保留档用 task_type NOT IN (噪声名单) 表达,于是新增任务类型默认长保留, 不会因为「忘了加进某个列表」被误删 —— 正是收集器当年翻车的反面。

⚠️ 「状态派生依赖类的查询」不能按 7 天删:工作台是从任务列表反推账号 当前状态的(DouyuTasksPage 的 latest*ByAccount,且要求 task.result 非空)。 这是历史包袱(与 bind_status 同源:账号的「现在是什么」存在执行日志里), §3.6 的状态列拆解才是根治;在拆解完成前,这类查询保守留长。

S6 之后:前端已不再用任务日志推账号状态,因此「状态派生依赖」那几类查询 理论上可以下探到 7 天档(只剩扫码流程的瞬时提示会看不到)。这一步保守起见 留待确认后再做 —— 它影响的是在用页面的显示,不是历史。

account_events 独立长期保留(体量小:每天数百条,一年 10 万条以内)。 注:DELETE 只让 InnoDB 的空间可复用,文件不会自动缩 —— 要缩需在维护窗口 OPTIMIZE TABLE。

4. 数据源矩阵:记录从哪来

记录 主源(权威) 补充源
导入 导入动作本身 —
登录 / CK / 失效 / 重登 登录批次、ck_health、ck_relogin 直接发事件 —
开通(精英宝典 / 电竞手册) 开通任务成功时发事件(run 内直发) 斗鱼侧状态查询可校正当前值
充值鱼翅 任务成功或供应商回调时发事件(三条路径同一 task_ref 去重) —
积分到账 / 兑换 任务成功时发事件(电竞与小店兑换、锁单支付也在内) 斗鱼侧权威接口:query_exchange_records、query_xpd_purchase_records、query_points
角色绑定 / 换绑 确认接口成功 + 回查生效时发事件 —
人工操作(分配 / 标签 / 备注 / 停用) 审计日志 + 直接发事件(audit_logs.account_id 已打通) —

历史怎么办:不从 douyu_tasks 回填(噪声大、结构靠猜)。 需要历史时走斗鱼侧权威接口按需同步(如 query_exchange_records), 这是「抛弃旧包袱」的落地方式 —— 宁可少,不可假。


5. 迁移方案(无损 · 幂等 · 自校验)

编号 20261003_0042(按规划 §C.1 取号规则)。

5.1 步骤

  1. accounts 加列:生命周期 6 列 + 登录态 10 列 + 运营 2 列 + 明确的状态列;
  2. account_events 加 4 列 + 1 个复合索引;
  3. 回填登录态(§5.2);
  4. 回填生命周期状态(§5.3);
  5. login_tasks 加 (account_id, attempt_status) 索引(在途判定要用);
  6. 旧列本次不动,下一次发版再 drop。

5.2 回填登录态(CK 绝不允许丢)

对每个账号,取其 login_tasks 里最近一条持有 CK 的行,搬 cookie 与状态:

UPDATE accounts a
JOIN (
  SELECT account_id, cookie, finished_at, ck_check_status, ck_check_result, ck_checked_at,
         relogin_attempts, relogin_next_at, relogin_last_error, relogin_failure_kind,
         ROW_NUMBER() OVER (PARTITION BY account_id ORDER BY id DESC) AS rn
  FROM login_tasks WHERE cookie IS NOT NULL AND cookie <> ''
) src ON src.account_id = a.id AND src.rn = 1
SET a.cookie = src.cookie, a.cookie_updated_at = src.finished_at, …
WHERE a.cookie IS NULL OR a.cookie = '';     -- 幂等,可重跑

要点(沿用 CK 解耦设计 §4.2,那部分经复核仍然有效):

  • 窗口函数取最近一行,不要 GROUP_CONCAT(... ORDER BY id DESC)(受 group_concat_max_len 限制会静默截断);
  • 分块执行(先 SELECT 出 (account_id, last_id),按 200 一批更新),1.4 万账号 × 单账号约 1.7KB,一次性 UPDATE 会长时间持锁;
  • 只读 login_tasks、不删不改源数据 —— 这是回退的安全网。

5.3 回填生命周期状态

-- 有 CK 且未判失效 → active;有 CK 但判失效 → 按重登进度分流;无 CK → imported
UPDATE accounts SET status = CASE
    WHEN cookie IS NULL OR cookie = '' THEN 'imported'
    WHEN ck_status = 'invalid' AND relogin_failure_kind IN ('captcha','credential') THEN 'manual'
    WHEN ck_status = 'invalid' AND relogin_next_at IS NOT NULL THEN 'relogin'
    WHEN ck_status = 'invalid' THEN 'invalid'
    ELSE 'active'
END;

5.4 自校验(不通过就中止启动,fail closed)

expected = SELECT COUNT(DISTINCT account_id) FROM login_tasks WHERE cookie <> ''
actual   = SELECT COUNT(*) FROM accounts WHERE cookie <> ''
if actual < expected:
    raise RuntimeError(f"回填不完整:期望 {expected},实际 {actual},中止迁移")

外加值级校验(防张冠李戴):

-- 必须为 0:账号的 CK 与其「最近一条持 CK 记录」不一致
SELECT COUNT(*) FROM accounts a
JOIN (SELECT account_id, cookie, ROW_NUMBER() OVER (PARTITION BY account_id ORDER BY id DESC) rn
      FROM login_tasks WHERE cookie IS NOT NULL AND cookie <> '') src
  ON src.account_id = a.id AND src.rn = 1
WHERE a.cookie <> src.cookie;

expected 必须运行时算(这个数在长:实测 13264 → 13339,因为重登在跑)。

5.5 bind_status 的处理(不回填进状态列)

旧的 bind_status 是覆盖式垃圾场,无法可靠还原「当时到底绑没绑」。 处理:把三列(bind_status / esports_bind_status / xpd_bind_status)的当前值作为 「未知」导入,随后由第一次查询/任务校正。不做任何猜测性回填。 旧的原始值整列保留在旧列里(下一次发版一起 drop),便于人工比对。


6. 落地阶段

阶段 内容 产出 依赖
S1 迁移 0042:加列 + 回填 + 自校验;accounts 成为状态载体 账号能独立描述登录态 —
S2 读写一次切换:12 个文件、24 读点 / 10 写点全部改 LoginTask.* → Account.*;批次行复用逻辑重写;在途子查询 + 索引 登录记录与 CK 彻底解耦 S1
S3 ✅ 状态收口(已完成 2026-10-04):account_lifecycle 提供 derive_status() / sync_status() / set_manual_state() / on_login_success();9 处判据写入点全部接线;6 状态状态机上线;自动化选行排除 disabled/archived;新增 account:manage 权限与停用/归档/恢复接口;前端列表加状态列与筛选、详情页加状态卡与操作入口 status 可信 S2
S4 ✅ 事件发射点补齐(已完成 2026-10-04):assigned / tag_changed 接上(此前定义了却从没发过)、新增 ck_cleared、ck_invalid 改为只在转变时发;audit_logs.account_id 在分配/标签/检测/清 CK 处打通;新增 emit_bulk(批量操作逐账号写事件)与 record_account_action(审计+事件一次写完);时间线支持 category 筛选;前端补 status_changed / ck_cleared 样式与分类筛选;补上改备注接口 PUT /api/accounts/{id}/remark(remark_changed 从此有发射点) 时间线完整 S2
S5 ✅ 业务事实直发事件(已完成 2026-10-04):新增 services/business_events.py 命名构造函数(record_recharge / record_exchange / record_points_credit / record_bind);9 处成功点直发(含此前从未收录的电竞兑换、和平小店兑换、锁单支付、电竞礼物积分);修掉「到账判据 !=」与「回调晚于超时」两个洞;幂等键 douyu_task:{id} 让直发与旧收集器/回调互相去重;删除收集器(含映射、水位线、测试,迁移 0043 清作业行);新增 douyu_tasks 保留策略作业(噪声 7 天 / 其余 90 天,默认关闭) 台账可用,687MB 可控 S4
S6 ✅ 绑定三列拆解 + 账号页重构(已完成 2026-10-04):①services/account_binding.py + 迁移 0044(新增 role_bind_status 与 esports_opened、归一化三列、收窄列宽、可回退),删除全部 29 处自由文本写入;②前端不再从任务日志反推账号状态,改为读账号字段;③账号列表加关键列(CK 健康 + 检测时间、精/电/店绑定、积分/鱼翅)与三个筛选(状态 / CK / 绑定);④详情页做成生命周期主页:顶部状态卡(生命周期状态 + 原因 + 变更时间 + CK + 三角色绑定 + 积分/鱼翅/兑换余额 + 归属与标注操作)、业务台账(补登录次数/最近登录)、分类时间线、生命周期操作入口。bind_status 旧列待 S8 drop 账号管理真正独立 S3、S5
S7 账号检测并入账号域(account-check 从「粘贴文本」改为「按账号选」,结果落事件) 检测不再是孤岛 S6
S8 清理:drop login_tasks 旧列与 accounts 三个 *_bind_status 列(不可逆,前置:每日备份 + 反向回填脚本) 包袱丢干净 S6

关键路径:S1 → S2 是一切的前置。S4/S5 可以并行推进(事件流不依赖状态切换完成), 但 S6(账号页)必须等 S3 + S5。


7. 风险与回退

风险 等级 应对
回填丢 CK 🔴 最高 迁移只读不删、分块、自校验 fail closed、值级校验;重登当前不可用,丢了补不回来
Cookie 页语义从「记录」变「账号」,DELETE /cookies/{id} 入参含义改变 🔴 高 前后端同提交;专门用例覆盖「删的是入参那个账号」;上线后手工验证一次
状态列被绕过直接赋值(重蹈 bind_status) 🟠 中 唯一写入口 + 静态检查(grep \.status = )+ 用例
12 个文件的切换漏一处 → 静默用错数据 🟠 中 按 §0.2(CK 解耦设计)的清单逐项核对;每处都有对应用例
事件表被当成「什么都能塞」 🟡 低 event_type 收敛为受控取值域;subject_key 只放可检索的键

回退:S1 之后每一步都可回退 —— 旧列保留、login_tasks.cookie 始终有一份, 最坏情况是回退代码 + 跑一次反向回填(把 accounts 的 CK 写回 login_tasks)。 唯一不可逆的是 S8 的 drop,所以它单独放到最后,且前置每日逻辑备份。


8. 待确认

# 问题 选项 倾向
A 登录态内联 accounts 还是独立 account_auth 表 ①内联 ②独立表 ①(§3.3:多会话/懒加载风险 + 切换面最小)
B 生命周期 status 落库还是派生 ①落库 + 唯一写入口 ②纯派生 ①(可筛选统计;写入口收口后一致性可控)
C 开通/积分/兑换记录用统一事件表还是各建表 ①统一 account_events ②三张专表 ①(§3.5;有流程的工单才独立建表)
D 历史业务记录是否回填 ①不回填,需要时走斗鱼权威接口同步 ②从 douyu_tasks 挖 ①(噪声大、结构靠猜,宁可少不可假)
E 导入批次是否本轮做 ①做(回答「这批活了几个」)②往后放 ②(不阻塞主链路)
F account-check 页改为按账号选 ①S7 一起做 ②保留粘贴模式 ①(否则检测永远是孤岛)
G drop 旧列前是否已有每日备份 ①已就绪 ②需要先做 ②必须先做(S8 前置)

9. 本文与既有文档的关系

文档 关系
设计-登录任务与CK解耦 迁移机制仍然有效:回填 SQL、分块、自校验、回退方案、§0 耦合点清单照用。本文覆盖其「目标模型」部分(把登录态放 accounts 而非新表,并补生命周期状态)
设计-账号详情页与事件历史 account_events 已实现(迁移 20261003_0041,在 dev),本文在其上加 category / subject_* / actor_* 与台账索引
自动化与资料收集规划 §9 其 account_exchange_records 由本文的 account_events 取代(subject_key 承载 skin_key,索引支撑 365 天规则)。§10 的 exchange_orders 工单表保留
事故复盘-CK误删与恢复 本次设计的动因;其「护栏」在 S2 后从「保护 CK」变为「保护在途尝试」