Files
live-hub-py/docs/设计-账号全生命周期.md
T
yml2213 1a8710834a feat(account): S3 生命周期状态收口 —— 6 状态状态机、唯一推导入口、停用/归档闭环
`status` 列从「空壳」变成可信状态:S1 建了列、S2 让账号独立,本提交让状态真正
反映账号处境,并把「哪些账号要人处理」变成可筛选的事实。

## 状态机(7 → 6 个状态)

`imported` / `active` / `invalid` / `manual` / `disabled` / `archived`

**砍掉 `relogin`**:它完全等价于 `invalid + relogin_next_at is not None`,
独立成状态就多出一个必须与 `relogin_next_at` 同步的字段 —— 正是要消灭的「双份真相」。

## 唯一推导入口(防「双份真相」)

新增 `services/account_lifecycle.py`:
- `derive_status(account)` —— **纯函数**,按 cookie / ck_check_status / relogin_*
  推导状态;人工态(disabled/archived)优先级最高,不被覆盖;
- `sync_status(db, account)` —— 推导 + 变了才写,**幂等**(没变则不写库、不发事件,
  否则巡检每轮都往时间线刷一条);
- `set_manual_state(db, account, state)` —— 停用/归档/`auto`(恢复自动推导);
- `on_login_success(db, account)` —— **「登录成功对账号意味着什么」只定义一次**:
  作废本轮重登退避痕迹 + 重算状态。于是**「待人工」的账号被人工登录救回来时状态
  会自动恢复**,而不是卡在 manual。
- `MANUAL_KINDS` 从 `jobs/ck_relogin.py` 挪到这里(service 不该反向依赖 job)。

**9 处底层判据写入点全部接线**:登录成功、CK 清除(单个/批量)、ck_health 巡检、
人工 CK 检测、工作台执行前检测、重登复检、重登结算(成功/失败退避)。

## 消费端:补上一个真缺口 + 删掉两处临时派生

- ⚠️ **缺口**:`ck_health` / `ck_relogin` / `count_pending_relogin` 此前只按底层判据
  选行,**没排除 disabled/archived** —— S2 提交里已标注,状态一上线就会变成
  「停用的账号照样被自动重登」。现已全部排除。
- `automation.manual_queue` 原本用 4 个条件自己拼「需要人工」,现在一句
  `status == 'manual'`。同一判据从「3 处各写一遍」收敛成「1 处推导」。

## 接口与前端

- `PUT /api/accounts/{id}/lifecycle`(disabled/archived/auto + 原因,写审计)
- `PUT /api/accounts/{id}/automation`(**接上死开关**:`auto_managed` 此前没有任何
  接口/前端能改,永远是默认 True)
- 新增权限点 `account:manage`(运营默认拥有)
- 列表支持 `?status=` 筛选;**归档默认不出现**(要看需显式筛选)
- `AccountOut` / 概览接口补 status / status_label / status_reason / auto_managed
- 前端:账号列表加「状态」列与状态筛选;详情页状态卡 + 停用/归档/恢复/自动化开关

## 两个自查出来的问题(都已修)

1. **幂等键反而丢事件**:状态事件一开始用 `status:{id}:{target}:{秒级时间戳}` 作
   `source_ref`,结果同一账号在同一秒内两次切到同一状态就撞唯一键,`emit` 静默吞掉
   —— **状态改了、时间线却少一条**,正是本模块要消灭的「悄悄不一致」。
   改为不传 `source_ref`:`sync_status` 本身已幂等,不存在重复写入路径。已加用例。
2. **迁移与运行时规则分叉**:迁移 0042 的状态回填 CASE 原本会写出已被砍掉的
   `relogin`,且判据与 `derive_status` 不一致(次数用尽的会回填成 invalid 而非 manual)。
   已改成逐字对应,并加一条用例**真跑那段 SQL 与纯函数逐条比对**(已验证:
   临时改回旧 CASE 该用例立刻红)。

## 验证

- `TZ=Asia/Shanghai`:**404 passed / 6 skipped**(S2 后 373 → 新增 31 条)
- 迁移链 3 passed;ruff check/format 全绿;前端 `tsc -b && vite build` 通过
- 真实开发库核对:落库状态分布 active 4 / imported 8,**与 derive_status 逐账号比对无漂移**
- 新增用例覆盖:derive 真值表(每状态入口 + 人工态优先)、sync 幂等、
  人工态切换、**全生命周期走查**(导入→登录→失效→重登→转人工→人工救回→清 CK→停用)、
  自动化排除 disabled/archived、列表状态筛选、接口

未做(后续阶段):S4 事件发射点补齐、S5 业务事实直发事件、S6 账号页重构。
2026-10-04 14:48:22 +08:00

30 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 imported 导入 —
lifecycle status_changed 所有状态变迁(detail 带 from/to/reason),停用与归档不再单列事件类型 —
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 与状态:

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 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解耦 迁移机制仍然有效:回填 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」变为「保护在途尝试」