# 设计:账号为中心的全生命周期模型(斗鱼) > 状态:**待评审**(未动代码)|2026-10-04 > 定位:**斗鱼账号域的全新设计**。本文取代以下文档中关于「账号应该长什么样」的部分, > 其余文档保留其专业内容: > - [登录任务与 CK 解耦](设计-登录任务与CK解耦.md) —— 迁移机制、回填校验、回退方案仍然有效 > - [账号详情页与事件历史](设计-账号详情页与事件历史.md) —— `account_events` 表已实现,本文在其上扩展 > - [自动化与资料收集规划](自动化与资料收集规划.md) §9 皮肤台账 —— 本文的 `account_events` 是其落地方式 > > **不参照虎牙**(虎牙是另一套业务与历史,只有「账号自己承载凭证」这一点值得借鉴)。 --- ## 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 天同皮肤」与各类台账查询): ```sql -- 已有: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` 与状态: ```sql 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 回填生命周期状态 ```sql -- 有 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) ```python 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},中止迁移") ``` 外加**值级校验**(防张冠李戴): ```sql -- 必须为 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解耦](设计-登录任务与CK解耦.md) | **迁移机制仍然有效**:回填 SQL、分块、自校验、回退方案、§0 耦合点清单照用。本文覆盖其「目标模型」部分(把登录态放 `accounts` 而非新表,并补生命周期状态) | | [设计-账号详情页与事件历史](设计-账号详情页与事件历史.md) | `account_events` 已实现(迁移 `20261003_0041`,在 `dev`),本文在其上加 `category` / `subject_*` / `actor_*` 与台账索引 | | [自动化与资料收集规划](自动化与资料收集规划.md) §9 | 其 `account_exchange_records` **由本文的 `account_events` 取代**(`subject_key` 承载 `skin_key`,索引支撑 365 天规则)。§10 的 `exchange_orders` 工单表保留 | | [事故复盘-CK误删与恢复](事故复盘-CK误删与恢复.md) | 本次设计的动因;其「护栏」在 S2 后从「保护 CK」变为「保护在途尝试」 |