From 3a227ffec1a2044f61d497597f3181460cbd3edf Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 4 Oct 2026 13:37:07 +0800 Subject: [PATCH] =?UTF-8?q?docs(account):=20=E6=96=97=E9=B1=BC=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E5=85=A8=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E5=85=A8?= =?UTF-8?q?=E6=96=B0=E8=AE=BE=E8=AE=A1=EF=BC=8C=E5=8F=96=E4=BB=A3=E6=97=A7?= =?UTF-8?q?=E7=9A=84=E7=9B=AE=E6=A0=87=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 围绕账号做一套全新设计,不再参照虎牙,并丢掉旧结构的包袱。 推翻的直接证据: 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 解耦文档头部,标注其目标模型已被取代、迁移机制仍然有效。 --- docs/设计-登录任务与CK解耦.md | 4 + docs/设计-账号全生命周期.md | 457 ++++++++++++++++++++++++++++++++++ 2 files changed, 461 insertions(+) create mode 100644 docs/设计-账号全生命周期.md diff --git a/docs/设计-登录任务与CK解耦.md b/docs/设计-登录任务与CK解耦.md index 4d27813..397d356 100644 --- a/docs/设计-登录任务与CK解耦.md +++ b/docs/设计-登录任务与CK解耦.md @@ -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 逐行核对代码) diff --git a/docs/设计-账号全生命周期.md b/docs/设计-账号全生命周期.md new file mode 100644 index 0000000..4aff137 --- /dev/null +++ b/docs/设计-账号全生命周期.md @@ -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`)
动作痕迹进 `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」变为「保护在途尝试」 |