`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 账号页重构。
30 KiB
设计:账号为中心的全生命周期模型(斗鱼)
状态:待评审(未动代码)|2026-10-04 定位:斗鱼账号域的全新设计。本文取代以下文档中关于「账号应该长什么样」的部分, 其余文档保留其专业内容:
- 登录任务与 CK 解耦 —— 迁移机制、回填校验、回退方案仍然有效
- 账号详情页与事件历史 ——
account_events表已实现,本文在其上扩展- 自动化与资料收集规划 §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. 设计原则
- 账号是聚合根 —— 一切围绕账号发生的事实,都以
account_id归属;不存在「必须借道登录记录才能找到账号状态」的路径。 - 当前状态在账号上,发生过什么在流水里 —— 两者严格分离。状态可覆盖,流水只追加,且流水不因状态变化而丢失。
- 一列一义 —— 禁止「最后一件事」式的复用列(
bind_status的教训)。一列要么是某个确定的当前值,要么是某个确定的历史量。 - 结构可以推翻,数据必须无损 —— 可以丢掉旧的设计包袱,但 CK 一条都不能丢(实测重登当前不可用,丢了补不回来)。
- 历史从权威源来,不从噪声里挖 —— 斗鱼侧有权威记录接口(兑换记录 / 小店购买记录 / 个人资料),补历史优先走它,不要在 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 表,否决,理由:
- 本仓库有多个「新开会话 / 多线程」的调用模式(
ck_relogin刻意用新 session 绕开 REPEATABLE READ 快照)。1:1 关系的延迟加载跨会话是经典 bug 源,而这里出 bug 的代价是CK; - 1:1 拆表的收益(列表查询不拖大字段)用
defer()就能拿到 ——accounts列表接口现在 已经在用这个模式(defer(Account.password)等); - 切换成本:内联是纯字段搬家(
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 落库意味着两份真相(底层判据 + 摘要),谁忘了同步页面就撒谎。防线:
- 收口:推导逻辑只有
derive_status()一份纯函数;所有写底层判据的地方 (cookie/ck_check_status/relogin_*,共 9 处)在其后调用sync_status(); - 幂等:状态没变时
sync_status()返回False,不写库、不发事件 —— 否则巡检每轮都会给时间线刷一条; - 迁移与运行时同规则:迁移
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 步骤
accounts加列:生命周期 6 列 + 登录态 10 列 + 运营 2 列 + 明确的状态列;account_events加 4 列 + 1 个复合索引;- 回填登录态(§5.2);
- 回填生命周期状态(§5.3);
login_tasks加(account_id, attempt_status)索引(在途判定要用);- 旧列本次不动,下一次发版再 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」变为「保护在途尝试」 |