- 高价值套装精确匹配「套装-兔兔白日梦」与「套装-狂战萌兔」,排除头盔/背包/手榴弹散件干扰 - 增加 is_suit_queried_expr,未查询或 NULL 账号严禁误判为双套均可兑换 - 修复前后端序列化空值保持,完善列表与详情页准入状态和判定依据展示 - 新增头盔排除与 NULL 统计防错回归单测,全量通过 689 个用例
42 KiB
设计:登录任务与 CK 解耦
状态:待评审(未动代码)|方案已修订:不做双写,代码一次切换,旧列留到下次发版再 drop(§6) 起因:2026-10-03 误删事故(复盘)。当前靠「删除时跳过持有 CK 的记录」 兜住,但那只是临时护栏 —— 根因是两类数据挤在同一张表里。
2026-10-04 已逐行核对代码,结论见 §0:方向成立,但耦合面比原文列举的更大, 且有 4 处漏项与 1 处死代码 —— 动手前先看 §0。
2026-10-04 目标模型已升级:本文的「目标模型」(§3)与「账号该长什么样」被 设计-账号全生命周期 取代(改为内联
accounts+ 新增生命周期状态机 + 统一事件台账)。本文的迁移机制部分(§4 回填 SQL、§6 一次切换、§7 回退、§0 耦合点清单)仍然有效,照用。
0. 现状核对(2026-10-04 逐行核对代码)
0.1 一句话结论
设计的判断与方案都成立(CK 确实只存在于 login_tasks.cookie,accounts 表没有 cookie 列),
原文 §5 的读写清单方向正确、覆盖约 80%。但有 4 处实际耦合点原文没列,
另有 1 处是死代码、若干行号已漂移 —— 而这些恰恰是「一次切换」最容易漏掉的地方(漏一处就是静默用错数据)。
0.2 精确耦合面(核对后)
读(判定「谁有 CK / 取 CK」),共 15 个真实调用点:
| 入口 | 调用点 |
|---|---|
cookie_holding_filter() |
services/douyu_service.py:209,223,238,245(内部)routers/douyu.py:188、routers/cookies.py:121,133,367、routers/accounts.py:295,338,391、routers/dashboard.py:90services/login_service.py:605、jobs/ck_relogin.py:90,158 |
cookie_operable_filter() |
routers/cookies.py:502,712,750、jobs/ck_health.py:77,179 |
latest_cookie_login_task() |
services/douyu_runner.py:59 |
cookie_holding_account_ids_query() |
services/account_service.py:31 |
visible_douyu_task_accounts() |
services/douyu_service.py:283 |
login.py::_holds_ck_clause()(重复实现) |
routers/login.py:259 |
写,共 4 个文件 10 个写入点(约 25 行赋值):
| 类别 | 位置 |
|---|---|
cookie(CK 本体) |
login_service.py:155(登录成功)、login_service.py:405(重登后复检失败清空)、cookies.py:819(批量清除)、cookies.py:842(单个清除) |
ck_check_*(健康状态) |
cookies.py:535-542(手动检测)、login_service.py:421-430(重登后复检)、douyu_runner.py:70-75(工作台登录检测)、ck_health.py:141-149(自动巡检) |
relogin_*(重登调度) |
ck_relogin.py:304-307(成功清零)、ck_relogin.py:312-338(失败退避 + 下次重试时间) |
合计:6 个入口 / 24 个读点 / 10 个写点,横跨 12 个文件。
涉及 12 个文件(6 service + 6 router):douyu_service、account_service、login_service、
douyu_runner、jobs/ck_health、jobs/ck_relogin;douyu、cookies、accounts、dashboard、
login、automation。
0.3 原文漏掉的 4 处 + 1 处死代码
services/account_service.py:31cookie_account_ids_query()—— 账号管理列表「只看持有 CK」 的筛选走这里。切了cookie_holding_filter却漏了它,账号列表的 CK 筛选会静默失效;services/douyu_service.py:283visible_douyu_task_accounts()—— 业务任务页的账号可见性 也依赖「记录持有 CK」;routers/accounts.py:629-643(账号详情概览) —— 这是 2026-10-03 账号事件功能新增的接口, 用裸 SQL 读login_tasks.ck_checked_at作为「最近 CK 检测」。本文写作时还不存在, 但它是「账号详情页」的数据源,解耦后必须改成读accounts,否则详情页会永远显示旧值;routers/login.py:259_holds_ck_clause()—— 与cookie_holding_filter()同口径的重复实现 (只差一处注释口径)。解耦时要一起处理,并建议顺手合并成一个来源,避免第三次分叉;douyu_service.py:230 latest_cookie()是死代码(全仓库无调用)—— §5.1 把它列为改造目标, 实际可以直接删除,不必迁移。
另:
jobs/ck_health.py/jobs/ck_relogin.py的选行查询都是db.query(LoginTask).join(Account, ...)—— 解耦后要变成db.query(Account)。 这不是简单的字段替换:join 反过来写,auto_managed/ck_checked_at的过滤与排序 全要重新落到Account上(原文 §5.3 已给出写法,照做即可)。
0.4 行号漂移(原文 → 现在)
| 原文 | 现在 | 原因 |
|---|---|---|
routers/accounts.py:285/324/374 |
295/338/391 |
账号事件功能在文件头部加了导入与辅助函数 |
routers/automation.py:218 |
✅ 未变 | —— |
routers/douyu.py:188 |
✅ 未变 | —— |
routers/dashboard.py:90 |
✅ 未变 | —— |
0.5 与「账号事件」功能的关系(2026-10-03 新增,在 dev)
账号事件表(设计)已经把「账号发生过什么」独立出来了,
并且明确遵循同一个模型:accounts 存当前状态、account_events 存流水。
本次解耦补的是**「当前状态」里最核心的那一块(凭证与健康)**,两者是同一件事的两半:
- 解耦后
accounts上才有完整的「当前状态」,事件表补「历史」,账号详情页把两者拼起来; - 事件发射点(
ck_health判失效、login_service登录成功、ck_relogin重登结果) 与本次要改的写入点是同一批 —— 建议 C2/C3 改动时顺带一起改,否则改两遍。
⚠️ 账号详情页的概览接口(
routers/accounts.py:586起)现在同时读accounts(状态卡) 和login_tasks(登录次数 / 最近 CK 检测,§0.3 第 3 条)。解耦后这段裸 SQL 必须一起改。
1. 问题:一张表扛了四件事
login_tasks 现在同时是:
| # | 关注点 | 字段 | 性质 |
|---|---|---|---|
| ① | CK 本体 | cookie |
账号的当前凭证(唯一副本,丢了就得重新登录) |
| ② | CK 健康状态 | ck_check_status / ck_check_result / ck_checked_at |
账号的当前状态 |
| ③ | 重登调度 | relogin_attempts / relogin_next_at / relogin_last_error / relogin_failure_kind |
账号的当前状态 |
| ④ | 登录历史 | batch_id / status / message / created_at / finished_at / attempt_* |
只增不改的流水 |
①②③ 是「账号现在怎么样」,④ 是「过去发生过什么」。①~③ 是状态、④ 是流水,生命周期完全不同, 却共用一行记录 —— 于是「清理流水」必然伤到「状态」。
直接后果:
- 删除历史 = 删 CK,且 ①②③ 一起没;
- 账号的 CK 健康/重登进度存在某一条历史行上,语义是「碰巧最后一次登录那行」;
- 一个账号可能有多行持有 CK(实测有 1 个账号 2 行),谁是权威不明确 ——
latest_cookie_login_task()靠「按 id 倒序取第一条」来猜; - 自动化作业(
ck_health/ck_relogin)以行为单位调度,删除行会让账号直接从 巡检/重登队列里消失。
2. 目标
login_tasks 退化为纯流水,可以随意删除、清理、归档,不影响任何账号状态。
判据:删掉任意历史行后 —— CK 还在、CK 健康状态还在、重登进度还在、自动化照常覆盖该账号。
3. 目标模型
CK 与状态挂到账号上。这在本仓库已有先例:huya_accounts 就是
cookie + raw_cookie 直接挂在账号行上,斗鱼照抄即可,不引入新表。
3.1 accounts 新增列(8 列)
# ── 斗鱼 CK(唯一权威副本;原 login_tasks.cookie)──
cookie: Mapped[str] = mapped_column(EncryptedText(), default="", nullable=False)
cookie_updated_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
# ── CK 健康状态(原 login_tasks.ck_check_*)──
ck_check_status: Mapped[str] = mapped_column(String(16), default="", nullable=False)
ck_check_result: Mapped[dict | None] = mapped_column(JSON, nullable=True)
ck_checked_at: Mapped[datetime | None] = mapped_column(
UTCDateTime, nullable=True, index=True
)
# ── 重登调度(原 login_tasks.relogin_*)──
relogin_attempts: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
relogin_next_at: Mapped[datetime | None] = mapped_column(
UTCDateTime, nullable=True, index=True
)
relogin_last_error: Mapped[str] = mapped_column(String(512), default="", nullable=False)
relogin_failure_kind: Mapped[str] = mapped_column(
String(16), default="", nullable=False
)
login_tasks 的对应列本次不动(既不读写、也不 drop),留到下一次发版再清理 —— 这是回退后路,见 §6、§7。
3.2 语义澄清
- 一个账号一份 CK(cookie 是账号级凭证,不是某次登录的产物)。历史上多行持 CK 的情况, 回填时取「最近一条」为准;
login_tasks继续记录每次尝试的结果,但不再持有凭证。若仍需「这次登录产出的 CK 是什么」 的审计能力,可在login_tasks留一个cookie_snapshot(可选,见 §8 决策 D)。
4. 迁移设计
编号:下一个是 20261003_0042(按落地顺序取,见规划 §C.1)。
(20261003_0040 已被生产索引优化占用、20261003_0041 已被账号事件表占用 —— 这正是「不预留编号」的实例。)
4.1 DDL(幂等,照 20261002_0039 写法:先 sa.inspect 查存在)
accounts 加 §3.1 的 8 列;ck_checked_at / relogin_next_at 建索引(巡检选行要排序)。
4.2 回填(同一迁移内,必须幂等)
对每个账号,从其 login_tasks 里挑「最近一条持有 CK 的行」,把四类值搬过来:
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,
a.ck_check_status = src.ck_check_status,
a.ck_check_result = src.ck_check_result,
a.ck_checked_at = src.ck_checked_at,
a.relogin_attempts = src.relogin_attempts,
a.relogin_next_at = src.relogin_next_at,
a.relogin_last_error = src.relogin_last_error,
a.relogin_failure_kind = src.relogin_failure_kind
WHERE a.cookie IS NULL OR a.cookie = '';
要点:
- 用窗口函数取每组最近一行。不要用
GROUP_CONCAT(... ORDER BY id DESC)取 id —— 它受group_concat_max_len(默认 1024)限制,账号一多就静默截断; - 只在
accounts.cookie为空时更新(幂等,可重跑;也不会覆盖切换之后新写入的 CK); - 不要一条 UPDATE 打全表:1.4 万账号、单账号 CK 约 1.7KB,一次性更新会长时间持锁。
改为先
SELECT出(account_id, last_id)列表,再按 200 一批 更新; - 生产 MySQL 8.4 支持窗口函数 ✅。
回填前的安全前置:迁移开始时校验一次「有多少账号会拿到 CK」,并把数字写进日志, 便于事后核对。
4.2.1 基线(2026-10-03 实测,作为验收对照)
| 项 | 值 |
|---|---|
accounts 总数 |
14446 |
| 持有 CK 的账号数 | 13264 |
| 持有 CK 的行数 | 13265 |
| 其中多行持 CK 的账号 | 1(回填取 id 最大的那行) |
迁移后 SELECT COUNT(*) FROM accounts WHERE cookie <> '' 必须等于记录级去重数
(之后随登录增长,只增不减;基线是动态的,迁移里用运行时算出的值比对)。
4.2.2 ⚠️ 为什么这条迁移绝不能丢 CK(实测代价)
用户明确要求:不能靠重新登录来补。实测依据(2026-10-03):
| 项 | 实测值 |
|---|---|
| 需要保住的账号数 | 13,534(持 CK 账号,且在增长) |
| 重登速率 | 约 7 轮/小时 × 30 条 = 210 账号/小时(98% 成功) |
| 全量重登耗时 | 13,534 ÷ 210 ≈ 64 小时 ≈ 2.7 天连续跑 |
| 当前重登可用性 | ⚠️ 最近 3 小时成功率 0(ck_relogin:00h 发起 90 / 成功 30;01h、02h 均为 0)——斗鱼对新账号静默不发验证码,即前面查到的邮件验证码问题 |
最后一行才是关键:重登现在本身就是坏的。此刻若丢了 CK,不是「花 2.7 天补回来」, 而是很可能根本补不回来,这些账号的登录态就永久没了。
由此三条不可让步的要求:
- 迁移必须非破坏性 —— 回填只读
login_tasks、不删不改源数据。 CK 在源表里始终留一份,任何环节出问题都能重来; - 回填不完整必须中止启动(§6.3)——不能带着半份 CK 切到新读路径;
- 校验要到值级别,不能只比条数 —— 条数相等仍可能张冠李戴(A 账号拿到 B 账号的 CK):
-- 必须为 0:账号的 CK 与其「最近一条持 CK 记录」的值不一致
SELECT COUNT(*) FROM accounts a
JOIN (
SELECT account_id, cookie,
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
WHERE a.cookie <> src.cookie;
安全网:因为迁移不删源数据,「最坏情况」永远是回退代码(旧列数据完好), 而不是「账号没救了」。这是整个方案敢一次切换的前提。
4.3 回填校验(迁移后立即执行,写进验收)
-- 必须为 0:有账号能回填但没填上
SELECT COUNT(*) FROM accounts a WHERE (a.cookie IS NULL OR a.cookie='')
AND EXISTS (SELECT 1 FROM login_tasks lt WHERE lt.account_id=a.id AND lt.cookie<>'');
-- 必须相等:账号级持 CK 数 == 记录级去重后的账号数
SELECT (SELECT COUNT(*) FROM accounts WHERE cookie<>''),
(SELECT COUNT(DISTINCT account_id) FROM login_tasks WHERE cookie<>'');
5. 读写切换清单(一次切到 accounts)
5.1 读(判定「谁有 CK / 取 CK」)
| 位置 | 现状 | 改为 |
|---|---|---|
services/douyu_service.py::cookie_holding_filter() |
LoginTask.cookie != "" |
Account.cookie != "" |
services/douyu_service.py::cookie_operable_filter() |
持有 CK + 非在途 | 持有 CK(账号级)+ 该账号无在途批次 |
services/douyu_service.py::latest_cookie(db, account_id) |
死代码(全仓库无调用) | 直接删除,不必迁移 |
services/douyu_service.py::latest_cookie_login_task() |
返回 LoginTask | 改为 latest_cookie_account(),或保留但内部改查 accounts |
routers/douyu.py:188 |
select(LoginTask.account_id).where(...) |
select(Account.id).where(Account.cookie != "") |
routers/accounts.py:295/338/391 |
同上 | 同上 |
routers/dashboard.py:90 |
同上 | 同上 |
services/account_service.py:31(§0.3 漏项) |
cookie_account_ids_query() → cookie_holding_account_ids_query() |
同上(账号列表的 CK 筛选) |
services/douyu_service.py:283(§0.3 漏项) |
visible_douyu_task_accounts() |
同上(业务任务页的账号可见性) |
routers/login.py:259(§0.3 漏项) |
_holds_ck_clause() 重复实现 |
与 cookie_holding_filter 合并成单一来源 |
routers/accounts.py:629-643(§0.3 漏项) |
裸 SQL 读 login_tasks.ck_checked_at |
改读 Account.ck_checked_at |
routers/automation.py:218 |
LoginTask.ck_check_status == "invalid" |
Account.ck_check_status == "invalid" |
services/automation/jobs/ck_health.py:75-90 |
以 LoginTask 选行 | 以 Account 选行(见 §5.3) |
services/automation/jobs/ck_relogin.py:85-115/155 |
以 LoginTask 选行 | 同上 |
routers/cookies.py(列表/汇总/重复/导出/check/relogin,见 §5.4) |
以 LoginTask 为「Cookie 记录」 | 改为以 Account 为记录 |
5.2 写
| 位置 | 现状 | 改为 |
|---|---|---|
login_service.py::_write_login_success() |
task.cookie = cookie |
account.cookie = cookie; account.cookie_updated_at = now(并可继续写 task 作快照,见决策 D) |
login_service.py:379 清空 |
task.cookie = "" |
account.cookie = "" |
routers/cookies.py:819/842(清除/删除 Cookie) |
task.cookie = "" |
account.cookie = "";删除记录不再等于清 CK |
ck_health.py:140/148 |
写 task | 写 account |
routers/cookies.py:535/542(手动检测) |
写 task | 写 account |
services/douyu_runner.py:70/75(工作台登录检测) |
写 task | 写 account |
login_service.py:395/404(重登后复检) |
写 task | 写 account |
ck_relogin.py:299-318(退避结算) |
写 task | 写 account |
5.3 自动化作业的选行改造(影响最大)
ck_health 现在按行选(LoginTask.ck_checked_at 超期),改为按账号选:
db.query(Account)
.filter(Account.cookie != "")
.filter(Account.auto_managed.is_(True))
.filter(or_(Account.ck_checked_at.is_(None), Account.ck_checked_at < cutoff))
.order_by(Account.ck_checked_at.is_(None).desc(), Account.ck_checked_at.asc(), Account.id.asc())
ck_relogin 同理(Account.ck_check_status == "invalid" + relogin_*)。
依赖变化:automation_runs.detail 里记的 task_id 要改成 account_id(前端运行历史若展示 id,
需同步)。login_tasks 仍会在重登时新建/更新行(记录这次尝试),但调度不再依赖它。
5.4 Cookie 页的语义变化(需要产品确认,见决策 A)
routers/cookies.py 目前把 login_tasks 行当作「Cookie 记录」:
GET /cookies、/cookies/summary、/cookies/duplicates、/cookies/export、POST /cookies/check、
POST /cookies/relogin、DELETE /cookies/{task_id} 等。
解耦后「Cookie 记录」应改为账号:
- 列表 = 持有 CK 的账号(一行一账号);
- 重复检测 = 同一账号/同一 CK 出现在多个账号(逻辑要重写,现状是查多行持 CK 的账号);
- 导出 = 账号级导出(username + cookie);
DELETE /cookies/{id}的语义要从「清掉这条记录的 CK」改成「清掉该账号的 CK」,接口入参 从 task_id 变成 account_id(前端要改,且要提示不可逆)。
5.5 删除护栏的调整
现状(事故后临时护栏,routers/login.py):
| 护栏 | 现状 | 改造后 |
|---|---|---|
| 持有 CK 的行不可删 | 按 cookie != "" 拦 |
去掉 —— 删历史不再影响 CK |
| 在途尝试的行不可删 | 按 attempt_status in (pending, running) 拦 |
保留 —— 批次线程正拿着这条记录 |
前端「清空失败」的确认文案要同步改回(不再提「持有 CK 的记录会跳过」), 改为提示「进行中的会跳过」。
6. 实施方式:不做双写,一次切换
结论(2026-10-03 修订):不采用双写,代码只切一次。但列不在同一次发布里 drop —— 见 §6.3 的两条不可省项。
6.1 为什么双写是错的
起初写的是「先双写 + 读切换,稳定后再停写」。重新推敲后否掉了,双写在这里弊大于利:
- 两处真相 → 发散风险。双写要求每个写入点都记得写两边;漏掉任何一处,两个存储就 悄悄不一致。而这类不一致比直接报错更难发现 —— 两边单独看都「有值」,测试也未必覆盖。
- 旧路径会掩盖新路径的 bug。只要
login_tasks.cookie还在被写,新代码即使读错了地方, 页面看上去也可能是对的(因为旧数据仍在),真实缺陷被推迟到「停止双写」那一刻才爆发。 - 它买到的回退保险,其实不需要它也能拿到。关键事实:
accounts.cookie是 CK 的权威副本, 任何时候都能反向回填出login_tasks.cookie。也就是说「回退」不缺数据来源, 缺的只是一次反向回填脚本。 - 双写还要多写一遍、多删一遍代码,净增工作量。
6.2 一次切换的具体做法
- 迁移
20261003_0042:加列 + 回填 + 自校验(§4)+ 保留login_tasks旧列(不动); - 代码一次改到位:读(§5.1)与写(§5.2)全部切到
accounts, 自动化改为按账号调度(§5.3),调整删除护栏(§5.5); - 旧列留在库里但不映射进模型(或映射但不再读写)—— 零额外工作,纯属留后路;
- 稳定运行 1~2 周后,下一次发版再 drop 旧列。
6.3 不可省的两条(不是「分步」,是「一次切换」的必要条件)
(1) 迁移必须自校验,失败就中止启动。
回填是「读旧、写新」的一次性动作,一旦漏掉一部分账号,切到新读路径后这些账号的 CK 直接消失(表现为「账号突然没有 CK」,且无从察觉是漏回填)。所以迁移里必须:
expected = db.execute(
text(
"SELECT COUNT(DISTINCT account_id) FROM login_tasks WHERE cookie IS NOT NULL AND cookie <> ''"
)
).scalar()
actual = db.execute(
text("SELECT COUNT(*) FROM accounts WHERE cookie IS NOT NULL AND cookie <> ''")
).scalar()
if actual < expected:
raise RuntimeError(f"回填不完整:期望 {expected},实际 {actual},中止迁移")
expected 必须运行时算,不能写死 —— 这个数在长(实测:13264 → 13339,因为重登在跑)。
校验不过就让迁移抛错,宁可应用起不来(fail closed),也不要带着半份 CK 起来。
(2) 在途判定仍要查 login_tasks,且要给它加索引。
cookie_operable_filter() 现在排除的是「该记录正在被批次处理」。CK 搬到账号后,
「在途」是某次尝试的属性,只能留在 login_tasks(实测当前 255 行在途):
in_flight = (
select(LoginTask.id)
.where(LoginTask.account_id == Account.id)
.where(LoginTask.attempt_status.in_(("pending", "running")))
.exists()
)
cookie_operable = and_(Account.cookie != "", ~in_flight)
现有索引 ix_login_tasks_account_status_id (account_id, status, id) 只能吃到 account_id 前缀,
过滤的是 attempt_status,建议再补一个 (account_id, attempt_status) 索引,
否则每轮 200 条选行会走回表(巡检 5 分钟一轮,积压时更明显)。
6.4 与「删除历史」的关系(本次改造的验收点)
改造完成后:
- 删除任意登录历史行 → CK 仍在账号上、健康状态仍在、重登进度仍在;
- 但在途尝试的行仍不可删(批次线程正拿着它)—— 这条护栏语义从「保护 CK」变成 「保护正在进行的尝试」,是合理的、要保留的;
- 前端「清空失败」的提示文案要相应改回:不再提示「持有 CK 的记录会跳过」。
6.5 顺带简化:可以删掉的「一行一账号」补丁
login_service.py:565-597 为了让 CK 待在行里,写了三段复用逻辑(复用持 CK 行 → 复用失败行
→ 新建),并注明「一个斗鱼账号只保留一条持有 CK 的记录」。这个不变量正是耦合的产物。
⚠️ 这里有个必须一起改的连带点:该逻辑第一段用的是 cookie_holding_filter(),而它将被
重定义为作用于 Account;且切换后 login_tasks.cookie 不再写入、恒为空。所以这段查询
必须一起重写,否则会静默退化(永远命中不了第一段)。
一次切换时的写法(保持行为简单、可预期):
# ① 复用该账号在途/最近一条未完成的行,避免同一账号并发两行
task = (
db.query(LoginTask)
.filter(LoginTask.account_id == aid)
.filter(LoginTask.attempt_status.in_(("pending", "running")))
.order_by(LoginTask.id.desc())
.first()
)
# ② 否则新建一行(历史而已,允许一个账号多行)
if task is None:
task = LoginTask(batch_id=batch_id, account_id=aid, created_by=self.created_by)
self.db.add(task)
即:不再关心「哪一行持有 CK」(CK 在账号上),只需要保证「同一账号不并发两行」。 真正的「一行一账号」约束消失了。
实测该「不变量」本身已经漏水:近一小时新增 324 行、166 个账号有 2 行历史 —— 说明复用逻辑在并发/长跑下并不严格。这也是「靠应用层约定维持数据模型」不可靠的又一例证。
7. 回退方案
按「不做双写、保留旧列」的方式,回退比原方案更简单:
| 场景 | 处理 | 是否需要动数据 |
|---|---|---|
| 新代码有 bug | 回退代码(部署上一版镜像)即可。旧列还在,旧代码读 login_tasks.cookie 能跑 |
不需要 |
| 旧列数据已过期(切换后新登录的 CK 只在 accounts) | 跑一次反向回填:把 accounts 的 CK/状态写回 login_tasks 的持 CK 行 |
需要,但有权威副本,脚本 20 行 |
| 回填本身不完整 | 迁移自校验会中止启动;修好回填脚本重跑(幂等) | 不需要 |
| 已经 drop 旧列后才发现问题 | 迁移 downgrade 恢复列 + 反向回填 | 需要,且这是唯一不可逆的一步 → drop 前先做逻辑备份 |
结论:只要不在同一次发布里 drop 旧列,这次改造的每一步都可回退。 真正不可逆的只有最后的 清理动作,所以把它单独放到下一次发版,并且那之前先有备份。
8. 测试计划
| 层次 | 用例 |
|---|---|
| 迁移 | 加列/回填幂等;回填条数与预期一致;自校验不过时必须中止(构造漏回填场景) |
| 单元 | cookie_holding_filter() 依据账号;latest_cookie() 读账号;清空 CK 写账号 |
| 回归 | 删除全部历史行后,账号 CK 与其健康/重登状态仍在(这条是本次改造的验收核心) |
| 回归 | 登录成功写账号 CK;重登失败/停止不破坏已有 CK(沿用 test_login_attempt_isolation.py) |
| 回归 | 在途尝试的行仍不可删(护栏语义变了,但护栏要留着) |
| 自动化 | ck_health / ck_relogin 按账号选行;没有历史行的账号也能被巡检/重登 |
| 自动化 | 在途排除用的是账号级 NOT EXISTS,验证并发下不会与批次抢同一个账号 |
| 接口 | Cookie 页各接口在账号级语义下返回正确;导出内容与迁移前一致 |
| 接口 | DELETE /cookies/{id} 清的是入参那个账号的 CK(改错会清错账号,重点覆盖) |
| 生产 | 迁移后对比:持 CK 账号数(动态基线)、巡检每轮 200 条、重登成功率 |
9. 待确认的决策点
| # | 决策 | 选项 | 建议 |
|---|---|---|---|
| A | Cookie 页的记录单位 | ①改为账号(一行一账号)②保留历史行、只是 CK 从账号取 | ①更符合解耦目标;②改动小但仍会混淆 |
| B | login_tasks 是否保留 cookie 作快照 |
①保留(审计「这次登录产出什么」)②不留(最干净) | ①:CK 是加密串,约 1.4 万行 ≈ 28MB,可接受,且便于排障 |
| C | 清理历史的行为 | ①仍不许删持有 CK 的行(保守)②任意删,CK 不受影响 | ②——这才叫解耦;①就没必要做这个改造了 |
| D | 是否顺带把历史做归档/保留策略 | ①加 automation_runs 那样的 90 天清理(规划 §11.4)②不做 |
①可顺带解决「任务列表 1.4 万条」的体量问题 |
| E | 重登/巡检的调度粒度 | ①账号(推荐)②仍按最近一次登录行 | ①:账号才是状态的载体 |
| —— | ✅ 已定:不双写(§6.1),代码一次切换、旧列延后 drop | ||
| G | 每日逻辑备份是否随本次一起做 | ①一起做(drop 列前必须有)②先不做 | ①(§12、C7 前置) |
10. 提交计划(7 个提交)
拆分的核心约束:同一份数据的读与写必须在同一个提交里切。否则出现中间态 —— 比如「写已经写账号、读还在读记录行」,新登录拿到的 CK 在页面上看不见,巡检还会按旧值判定; 这类中间态跑起来不报错,只是默默用错数据,比崩溃更难发现。
另外三条边界:
- 迁移单独一个提交(纯加列 + 回填,无行为变化)—— 单独部署也安全;
- 用户可感知的语义变化各占一个提交(Cookie 页单位、删除护栏),便于单独验证与回退;
- drop 旧列隔离到下一次发版(唯一不可逆动作)。
| # | 提交信息 | 改动文件 | 干什么 | 怎么验证 |
|---|---|---|---|---|
| C1 | feat(db): accounts 增加 CK 与状态列,并从 login_tasks 回填 |
migrations/versions/20261003_0042_account_ck_state.py(新)models.pytests/test_migrations.py |
加 8 列 + (account_id, attempt_status) 索引;分块回填;自校验不过就抛错 |
pytest tests/test_migrations.py;本地 dev 库实跑;回填后 accounts.cookie<>'' == 记录级去重数 |
| C2 | refactor(cookie): 斗鱼 CK 本体改由账号承载 |
services/douyu_service.py(cookie_holding_filter / cookie_operable_filter / latest_cookie / latest_cookie_login_task)services/login_service.py(_write_login_success、清空、批次行复用)routers/cookies.py(清除/删除 CK 的写点)services/douyu_runner.py |
读+写一起切到 accounts.cookie。批次行复用逻辑同步重写(§6.5 连带点,否则静默退化) |
新增 tests/test_account_ck_storage.py;改造 test_login_attempt_isolation.py |
| C3 | refactor(automation): CK 健康状态与重登状态迁到账号,作业按账号调度 |
services/automation/jobs/ck_health.pyservices/automation/jobs/ck_relogin.pyrouters/automation.pyrouters/cookies.py(检测写点)services/login_service.py(重登后复检写点)services/douyu_runner.py |
ck_check_* / relogin_* 的读+写+选行一起切到 Account;automation_runs.detail 里的 task_id 改 account_id |
改造 test_automation_ck_health.py / test_automation_ck_relogin.py;新增「没有历史行的账号也能被巡检/重登」 |
| C4 | refactor(cookies): Cookie 页改为账号级 |
routers/cookies.pyrouters/accounts.pyrouters/douyu.pyrouters/dashboard.pyweb/frontend/src/pages/CookiePage.tsxapi/cookies.ts / api/types.ts |
列表/汇总/重复/导出/检测/删除全部改为以账号为单位;DELETE /cookies/{id} 入参由 task_id 变 account_id |
风险最高:前后端同一提交;专门用例覆盖「删除清的是入参那个账号」;上线后手工验证一次 |
| C5 | feat(login): 登录历史可自由删除(解除 CK 护栏) |
routers/login.pyweb/frontend/src/pages/LoginTasksPage.tsxtests/test_login_tasks_pagination.py |
去掉「持有 CK 不可删」,保留「在途不可删」;前端文案改回 | 验收核心:删除全部历史后,账号 CK 与健康/重登状态仍在 |
| C6 | docs: 解耦落点与验收结果 |
本设计文档、docs/自动化与资料收集规划.md、README.md |
记录落点、基线对照、遗留项 | —— |
| C7 | chore(db): 删除 login_tasks 上已弃用的 CK/状态列(下一次发版) |
迁移 20261002_0041、models.py |
drop cookie / ck_check_* / relogin_*(若决策 B 选「留快照」则保留 cookie) |
downgrade 可恢复结构;执行前必须有每日备份(决策 G) |
10.1 提交顺序的依赖关系
C1(加列+回填,无行为变化)
├─> C2(CK 读写切账号)──┬─> C5(解除删除护栏)
│ └─> C4(Cookie 页账号级)──> C6(文档)──> C7(drop 列)
└─> C3(健康/重登状态 + 作业按账号)──┘
- C2 与 C3 可以并行(CK 本体 vs 健康状态,互不依赖),但 C3 必须先于 C4(Cookie 页要展示
ck_check_status); - 每个提交都能单独部署:C1 部署后行为不变;C2 之后旧列虽不写但仍可回退;
- 每一步 pre-push 跑全量 pytest,迁移保持幂等,重复部署无害。
10.2 每个提交的「不能省」
| 提交 | 不能省的东西 |
|---|---|
| C1 | 自校验 + 分块回填 + 幂等;HEAD_REVISION 同步 |
| C2 | 读与写同提交;批次行复用逻辑一起改;cookie_operable_filter 的在途子查询 + 新索引 |
| C3 | 选行改账号后,「无历史行」的账号也要能被覆盖(否则等于漏掉一批账号) |
| C4 | 前后端同提交;删除接口的入参语义单独用例 |
| C5 | 在途护栏保留(语义从「保护 CK」变成「保护正在进行的尝试」) |
| C7 | 每日备份已就绪(不可逆) |
11. 工作量与风险
- 涉及:1 个迁移、
models.py、4 个 service、7 个 router、2 个自动化作业、前端 3 个页面 + 类型、约 10 个测试文件; - 粗估 2~2.5 人日(去掉双写后比原估的 2~3 天更少:少了双写与后续停写两轮改动);
- 最大风险是 Cookie 页:它的接口语义从「记录」变成「账号」,前后端要一起改,
且
DELETE /cookies/{id}的入参含义变了 —— 改错会变成「点删除清错账号的 CK」,比这次事故更糟。 这一处要单独写用例(见 §8 最后一条),并在上线后手工验证一次删除清的是同一个账号; - 生产上自动化在跑(5 分钟一轮,无真正空窗),迁移只动
accounts的 1.4 万行且分块执行,影响可控; 但回填 SELECT 与切换之间存在的竞态要注意:迁移在应用启动时执行,此时旧容器已停止 (compose 会先停后起),所以窗口极小;回填按finished_at取最新并写成幂等,必要时可重跑自愈。
12. 与「备份」的关系
这次改造本身是安全的(旧列保留、可反向回填),但最后的 drop 列是不可逆的。 叠加 2026-10-03 的事故(当时唯一退路是 binlog),建议把每日逻辑备份作为这个改造的 前置项一起做掉 —— 否则「不可逆的一步」仍然只靠 30 天 binlog 兜底。
13. 账号「独立」的目标形态:全生命周期信息模型
本节回答的是比「解耦」更大的问题:一个斗鱼账号,应该自己承载哪些信息? 解耦只是让第 ② 类信息回到账号上;要「账号管理单独出来」,还得把 ③④ 补上。
13.1 现状:账号的事实散在 5 张表、7 个页面
| # | 信息类别 | 今天存在哪 | 今天在哪看 |
|---|---|---|---|
| ① | 身份与凭据(用户名/密码/邮箱/邮箱密码) | accounts ✅ |
账号管理 |
| ② | 凭证与健康(CK / 巡检 / 重登) | login_tasks ❌ |
Cookie 管理、CK 检测与重登、自动巡检 |
| ③ | 生命周期状态(现在是「没有」) | —— ❌ | —— |
| ④ | 归属与运营(分配/标签/备注/自动化开关) | accounts ✅ |
账号管理、分配管理 |
| ⑤ | 平台画像(uid/昵称/积分/鱼翅/游戏角色…) | accounts ✅ |
散在精英宝典/电竞手册/和平小店 |
| ⑥ | 业务流水(充值/兑换/积分/开通) | douyu_tasks+account_events ✅ |
各业务页、账号详情页 |
| ⑦ | 人工操作审计 | audit_logs(关联不到账号)⚠️ |
审计日志页 |
「账号管理没有单独出来」的三种具体表现:
- 数据层:
accounts表没有cookie列,账号的登录态寄存在某条登录记录的cookie字段上。 删除登录记录 = 删除账号的登录态(事故根因); - 接口层:
AccountOut只暴露id/username/tag/remark/assigned_to/created_at(+敏感字段), 零运营状态 —— 账号列表接口拿不到 CK、健康、积分、开通状态; - UI 层:账号管理页只有「ID/用户名/标签/上传时间/密码/邮箱/分配/删除」8 列。 要知道这个账号能不能用,得跳去 Cookie 管理页;要看积分/开通,得跳去业务页。
13.2 参照物:虎牙账号已经是「独立」的
huya_accounts 把凭据与状态全部挂在账号上,因此虎牙账号页一页就能看清:
has_cookie / cookie / cookie_preview / status / login_channel /
points / game_* / sms_url / assigned_to / updated_at。
斗鱼与之相比:平台画像比虎牙更全(多了精英宝典、电竞手册、和平小店、鱼翅/兑换余额), 但凭证与状态几乎为零。对齐虎牙即可,不需要发明新模型。
13.3 目标:账号承载的四层信息
┌ ① 身份凭据 ────────────────────────────────────────────┐
│ username / password / email / email_password / tag / remark │
└────────────────────────────────────────────────────────┘
┌ ② 登录态(本次解耦)────────────────────────────────────┐
│ cookie / cookie_updated_at │
│ ck_check_status / ck_check_result / ck_checked_at │
│ relogin_attempts / relogin_next_at / relogin_last_error / │
│ relogin_failure_kind │
└────────────────────────────────────────────────────────┘
┌ ③ 生命周期状态(新增)──────────────────────────────────┐
│ status: imported → active → ck_invalid → relogin → manual │
│ → disabled / archived │
│ 派生视图(不落库):能不能跑业务 = 有 CK 且健康且未被禁用 │
└────────────────────────────────────────────────────────┘
┌ ④ 平台画像 + 运营 ──────────────────────────────────────┐
│ uid / nickname / points / gold / exchange / bind / │
│ elite_* / esports_* / xpd_* / assigned_to / auto_managed │
└────────────────────────────────────────────────────────┘
↓ 流水(append-only,已经做好)
account_events(业务+状态变更+人工操作)+ login_tasks(登录尝试历史)
13.4 生命周期阶段与缺口
| 阶段 | 现状 | 缺口 |
|---|---|---|
| 导入 | accounts.created_at |
无导入批次、无导入人(只有一句审计文案「导入103个」) |
| 待登录 | 无状态,靠「有没有持 CK 的记录」反推 | ③ status 缺失 |
| 登录成功 | CK 写进 login_tasks.cookie |
② 不在账号上 |
| 已分配 | accounts.assigned_to ✅ |
—— |
| 业务执行 | 画像字段 ✅ + douyu_tasks |
画像字段在账号页看不到(接口未暴露) |
| CK 失效 | login_tasks.ck_check_status |
② 不在账号上 |
| 自动重登 | login_tasks.relogin_* |
② 不在账号上 |
| 转人工 | login_tasks.relogin_failure_kind |
② 不在账号上;人工队列页面以「记录」为单位 |
| 停用 / 归档 | 只有 auto_managed(自动化后门) |
③ 没有停用/归档语义,删除是硬删(连带删任务与事件) |
| 删除 | 硬删 + 连带清 login_tasks/douyu_tasks/account_events |
无软删除,历史不可追溯 |
13.5 建议的落地顺序(与 §10 的 C1~C7 对齐)
解耦(C1~C5)是前置:凭证状态不回到账号上,后面的「状态机」「账号页」都是空中楼阁。
⚠️ 阶段编号以 设计-账号全生命周期 §6 为准(S1~S8)。 本文早期版本在这里另写过一套 S1~S6,两套编号含义不同,已废弃 —— 保留本节只为说明依赖关系。
| 依赖关系 | 说明 |
|---|---|
| 解耦(本文 C1 |
一切的前置。不做,账号页展示的 CK 状态仍然来自登录记录,删一条记录页面就变了 |
| 状态收口(S3) | 依赖解耦;产出「可信的 status」 |
| 账号页重构(S6) | 依赖 S3(要显示状态)+ S5(要显示台账) |
| 清理旧列(S8) | 最后一步,不可逆,前置每日备份 |
13.6 待确认
| # | 问题 | 选项 |
|---|---|---|
| L1 | ③ 生命周期 status 落库还是派生? |
①显式列(好筛选/统计,需维护一致性)②纯派生(无冗余,但查询复杂) |
| L2 | 停用/归档要不要做(软删除)? | ①做(账号是资产,硬删不可逆)②不做 |
| L3 | 导入批次要不要做? | ①做(能回答「这批 103 个现在活了几个」)②不做 |
| L4 | 账号列表页要不要直接显示 CK/健康/积分? | ①显示(信息密度换查询成本,需注意 N+1)②只显示状态徽标 |