docs(account): 斗鱼账号全生命周期全新设计,取代旧的目标模型

围绕账号做一套全新设计,不再参照虎牙,并丢掉旧结构的包袱。

推翻的直接证据:
1. 三个「状态」都找不到家 —— 凭证在 login_tasks.cookie、健康与重登在 login_tasks.ck_check_*/relogin_*、
   生命周期状态根本不存在(只能靠「有没有持 CK 的记录」反推);
2. accounts.bind_status 一列塞了 36 种取值,混了三种性质(真绑定状态/一次性动作痕迹/业务结果)
   且互相覆盖 —— 查一次精英宝典状态就把 bind_confirmed 冲掉,账号详情页还拿它显示「绑定状态」;
3. 开通/积分/兑换/充值这些账号事实全埋在 douyu_tasks.result(22 万行 / 687MB,绝大多数是轮询噪声);
4. 账号检测页粘贴文本检测、结果不落库、连 account_id 字段都没有,与库内账号完全脱节。

设计要点:
- 账号为聚合根;当前状态在账号上、发生过什么在 account_events,两者严格分离;
- 登录态(CK/健康/重登)内联 accounts(否决独立表:多会话与懒加载风险,且内联切换面最小),
  并说明 1:1 场景下 defer() 已能解决列表查询拖大字段的问题;
- 新增显式生命周期状态机 imported/active/invalid/relogin/manual/disabled/archived,
  写入收口到唯一服务,禁止各处直接赋值(bind_status 崩掉的直接教训);
- account_events 扩展 category/subject_type/subject_key/actor_* 与台账复合索引,
  一张表同时承载「时间线 + 开通/积分/兑换/充值台账」,支撑 365 天同皮肤规则;
  有流程的兑换工单仍独立建表 —— 流程归工单、事实归事件流;
- login_tasks 瘦身为纯登录尝试历史,douyu_tasks 降级为执行日志并加保留策略;
- 迁移沿用 CK 解耦文档的回填机制(分块/幂等/自校验 fail closed/值级校验),
  历史业务记录不回填 douyu_tasks,需要时走斗鱼侧权威接口按需同步;
- 落地分 S1~S8,S1→S2 为关键路径,S8(drop 旧列)不可逆且前置每日备份。

同时更新 CK 解耦文档头部,标注其目标模型已被取代、迁移机制仍然有效。
This commit is contained in:
yml2213
2026-10-04 13:37:07 +08:00
parent 44a1a2bc95
commit 3a227ffec1
2 changed files with 461 additions and 0 deletions
+4
View File
@@ -6,6 +6,10 @@
>
> **2026-10-04 已逐行核对代码**,结论见 §0:方向成立,但耦合面比原文列举的更大,
> 且有 4 处漏项与 1 处死代码 —— 动手前先看 §0。
>
> **2026-10-04 目标模型已升级**:本文的「目标模型」(§3)与「账号该长什么样」被
> [设计-账号全生命周期](设计-账号全生命周期.md) **取代**(改为内联 `accounts` + 新增生命周期状态机 +
> 统一事件台账)。**本文的迁移机制部分(§4 回填 SQL、§6 一次切换、§7 回退、§0 耦合点清单)仍然有效,照用。**
## 0. 现状核对(2026-10-04 逐行核对代码)
+457
View File
@@ -0,0 +1,457 @@
# 设计:账号为中心的全生命周期模型(斗鱼)
> 状态:**待评审**(未动代码)|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 生命周期状态机
```
导入
│
▼
┌─────────┐ 登录成功 ┌────────┐ 巡检判定失效 ┌─────────┐
│imported │────────────▶│ active │───────────────▶│ invalid │
└─────────┘ └────────┘ └────┬────┘
│ ▲ ▲ │ 自动重登(可退避)
│ 人工补 CK / 手动登录 │ │ 重登成功 ▼
└────────────────────┘ │ ┌──────────┐
│ │relogin │
│ └────┬─────┘
│ │ 失败转人工 / 连续失败达上限
│ ▼
│ ┌─────────┐
└──────────────────│ manual │
人工处理成功 └────┬────┘
│ 人工停用
▼
┌──────────┐ 归档 ┌──────────┐
│ disabled │───────▶│ archived │
└──────────┘ └──────────┘
```
| 状态 | 含义 | 能否跑业务 | 自动化是否覆盖 |
| --- | --- | --- | --- |
| `imported` | 已导入,从未拿到 CK | ✗ | 不巡检(没 CK 可检) |
| `active` | 有 CK,且未被判失效 | ✅ | 参与巡检 |
| `invalid` | CK 已判失效,等待自动重登 | ✗ | 参与重登 |
| `relogin` | 正在退避重试中 | ✗ | 参与重登 |
| `manual` | 重登失败已转人工(极验/凭证) | ✗ | **不参与**(避免刷屏) |
| `disabled` | 人工停用 | ✗ | 不参与 |
| `archived` | 归档,默认列表不显示 | ✗ | 不参与 |
**状态与判据的分工(重要)**:
- `status` 是**人可读的当前阶段**,落库、可筛选、可统计;
- `status` **不替代**底层判据:能不能跑业务仍然要看 `cookie != '' AND ck_status != 'invalid'`;
- **写入收口**:所有状态变迁统一走一个服务(`account_lifecycle.set_status()`),
禁止各处直接赋值 `account.status = ...` —— 这是 `bind_status` 崩掉的直接教训。
> `auto_managed` 保留,但它与 `disabled` 的区别要写进 UI:`disabled` = 账号不可用;
> `auto_managed=false` = 账号可用,只是不自动跑(人工维护的特殊账号)。
### 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 | `imported` | 导入 | — |
| lifecycle | `status_changed` | 生命周期状态变迁(detail 里带 from/to) | — |
| lifecycle | `disabled` / `archived` | 停用 / 归档 | — |
| auth | `login_ok` | 登录成功(拿到新 CK) | — |
| auth | `ck_invalid` | 巡检判定 CK 失效 | — |
| auth | `relogin_ok` / `relogin_failed` | 自动重登结果 | — |
| auth | `ck_cleared` | CK 被清除(人工或检测判定) | — |
| business | `recharge` | 充值到账 | + ¥ |
| business | `exchange` | 兑换(含消耗积分) | − 积分 |
| business | `points_credit` | 积分到账 | + 积分 |
| business | `points_expire` | 积分到期(由 `willExpireTime` 派生) | — |
| business | `elite_opened` / `esports_opened` | 开通精英宝典 / 电竞手册 | — |
| business | `bind_ok` / `rebind` | 角色绑定 / 换绑 | — |
| ops | `assigned` / `tag_changed` / `remark_changed` | 人工操作 | — |
**「开通 / 积分 / 兑换记录」怎么呈现**:就是这张表按 `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.6 `bind_status` 的拆解(旧列 → 新字段)
| 旧取值族 | 新归属 |
| --- | --- |
| `bind_confirmed` / `bind_qr_generated` / `game_queried` / `change_time_queried` / `game_not_bound` | `accounts.role_bind_status`(**明确的当前值**:`unbound`/`bound`/`pending`)<br>动作痕迹进 `account_events`,不占状态列 |
| `gold_qr_created` / `gold_recharged` / `gold_balance_queried` | 充值流水进 `account_events`;余额进 `gold_balance` |
| `elite_qr_created` / `elite_opened` / `elite_status_queried` | `elite_opened` + `elite_expire_time` 已有;开通时间进 `account_events.elite_opened` |
| `esports_*` | `accounts.esports_bind_status` 收敛为明确值;其余进事件流 |
| `xpd_*` | 同上 |
| `points_queried` / `limited_goods_queried` / `exchange_records_queried` / `csrf_token_ready` / `goods_refreshed` | **直接删除** —— 这些是「跑过一个查询任务」,不是账号状态 |
**规则**:状态列只放「现在是什么」,动作痕迹一律进 `account_events`(要查随时能查,且不会被覆盖)。
### 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` 降级:执行日志,不再是账号历史来源
- 职责收窄为**执行层**:二维码、轮询、锁单、支付、结果回传、WebSocket 推送;
- 业务事实在任务成功时**同时**写入 `account_events`(而不是事后从 JSON 里挖);
- 补保留策略:`query_*` / `refresh_*` 等**查询类任务保留 7 天**,业务类保留 90 天 —— 直接解决 687MB;
- `account_events` 独立长期保留(体量小:实测每天数百条,一年 10 万条以内)。
---
## 4. 数据源矩阵:记录从哪来
| 记录 | 主源(权威) | 补充源 |
| --- | --- | --- |
| 导入 | 导入动作本身 | — |
| 登录 / CK / 失效 / 重登 | 登录批次、`ck_health`、`ck_relogin` 直接发事件 | — |
| 开通(精英宝典 / 电竞手册) | 开通任务成功时发事件(`run` 内直发,不经 JSON 回捞) | 斗鱼侧状态查询可校正当前值 |
| 充值鱼翅 | 充值任务成功时发事件 | 供应商回调 |
| 积分到账 / 兑换 | 任务成功时发事件 | **斗鱼侧权威接口**:`exchange_records`(兑换)、`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** | 状态收口:`account_lifecycle.set_status()` 唯一写入口;`ck_health` / `ck_relogin` / 登录批次全部改走它;生命周期状态机上线 | `status` 可信 | S2 |
| **S4** | `account_events` 扩展(4 列 + 索引)+ 事件发射点补齐(含 `audit_logs.account_id` 打通) | 时间线完整 | S2 |
| **S5** | 业务事实直发事件:开通 / 充值 / 积分 / 兑换 / 绑定在任务成功时写事件;`douyu_tasks` 加保留策略 | 台账可用,687MB 可控 | S4 |
| **S6** | 账号页重构:列表加状态与关键列;详情页做成「生命周期主页」(状态卡 + 台账 + 时间线 + 操作);`bind_status` 三列拆解落地 | 账号管理真正独立 | 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」变为「保护在途尝试」 |