Files
live-hub-py/docs/设计-登录任务与CK解耦.md
T
yml2213 294c6cd058 docs: 登录任务与 CK 解耦设计(待评审)
根因:login_tasks 一张表同时扛四件事——CK 本体、CK 健康状态、重登调度、
登录历史。前三个是「账号当前状态」,第四个是流水,生命周期不同,于是
清理流水必然伤到状态(2026-10-03 因此丢了 5019 个 CK)。临时护栏只是兜底。

方案:照 huya_accounts 的既有先例,把 CK 与状态挂到 accounts,
login_tasks 退化为纯流水。

关键结论:
- **不做双写**:两处真相会悄悄发散(比报错更难发现),且旧路径会掩盖新
  路径的 bug;而它买到的回退保险本来就有——accounts.cookie 是权威副本,
  随时能反向回填出旧列。改为代码一次切换、旧列延后 drop。
- 两条不可省项:迁移必须自校验(回填不全就中止启动,fail closed);
  在途判定仍要查 login_tasks(实测 255 行在途),需补 (account_id,
  attempt_status) 索引。
- 登录服务里「一行一账号」的复用逻辑是耦合产物(且已漏水),切换时其
  第一段 cookie_holding_filter 查询必须一起重写,否则静默退化。

含迁移与回填 SQL、读写切换清单、7 个提交的提交计划与依赖关系、
验收基线(13264 个账号持 CK)、测试计划、回退方案,以及待确认决策点。
2026-10-03 11:40:05 +08:00

422 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 设计:登录任务与 CK 解耦
> 状态:**待评审**(未动代码)|**方案已修订:不做双写,代码一次切换,旧列留到下次发版再 drop(§6)**
> 起因:2026-10-03 误删事故([复盘](事故复盘-CK误删与恢复.md))。当前靠「删除时跳过持有 CK 的记录」
> 兜住,但那只是**临时护栏** —— 根因是两类数据挤在同一张表里。
## 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 列)
```python
# ── 斗鱼 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. 迁移设计
编号:下一个是 **`20261002_0040`**(按落地顺序取,见规划 §C.1)。
### 4.1 DDL(幂等,照 `20261002_0039` 写法:先 `sa.inspect` 查存在)
`accounts` 加 §3.1 的 8 列;`ck_checked_at` / `relogin_next_at` 建索引(巡检选行要排序)。
### 4.2 回填(同一迁移内,**必须幂等**)
对每个账号,从其 `login_tasks` 里挑「最近一条持有 CK 的行」,把四类值搬过来:
```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,
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 <> ''` **必须等于 13264**
(之后随登录增长,只增不减)。
### 4.3 回填校验(迁移后立即执行,写进验收)
```sql
-- 必须为 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)` | 查 login_tasks | `account.cookie` |
| `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:285/324/374` | 同上 | 同上 |
| `routers/dashboard.py:90` | 同上 | 同上 |
| `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` 超期),改为按账号选:
```python
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 为什么双写是错的
起初写的是「先双写 + 读切换,稳定后再停写」。重新推敲后否掉了,双写在这里**弊大于利**:
1. **两处真相 → 发散风险**。双写要求每个写入点都记得写两边;漏掉任何一处,两个存储就
悄悄不一致。而这类不一致**比直接报错更难发现** —— 两边单独看都「有值」,测试也未必覆盖。
2. **旧路径会掩盖新路径的 bug**。只要 `login_tasks.cookie` 还在被写,新代码即使读错了地方,
页面看上去也可能是对的(因为旧数据仍在),真实缺陷被推迟到「停止双写」那一刻才爆发。
3. **它买到的回退保险,其实不需要它也能拿到**。关键事实:**`accounts.cookie` 是 CK 的权威副本,
任何时候都能反向回填出 `login_tasks.cookie`**。也就是说「回退」不缺数据来源,
缺的只是一次反向回填脚本。
4. 双写还要多写一遍、多删一遍代码,净增工作量。
### 6.2 一次切换的具体做法
1. **迁移 `20261002_0040`**:加列 + 回填 + **自校验**(§4)+ 保留 `login_tasks` 旧列(不动);
2. **代码一次改到位**:读(§5.1)与写(§5.2)**全部**切到 `accounts`,
自动化改为按账号调度(§5.3),调整删除护栏(§5.5);
3. **旧列留在库里但不映射进模型**(或映射但不再读写)—— 零额外工作,纯属留后路;
4. 稳定运行 1~2 周后,**下一次发版**再 drop 旧列。
### 6.3 不可省的两条(不是「分步」,是「一次切换」的必要条件)
**(1) 迁移必须自校验,失败就中止启动。**
回填是「读旧、写新」的一次性动作,一旦漏掉一部分账号,切到新读路径后这些账号的 CK
**直接消失**(表现为「账号突然没有 CK」,且无从察觉是漏回填)。所以迁移里必须:
```python
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 行**在途):
```python
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` 不再写入、恒为空。所以这段查询
必须一起重写,否则会静默退化(永远命中不了第一段)。
一次切换时的写法(保持行为简单、可预期):
```python
# ① 复用该账号在途/最近一条未完成的行,避免同一账号并发两行
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** | 重登/巡检的调度粒度 | ①账号(推荐)②仍按最近一次登录行 | ①:账号才是状态的载体 |
| ~~F~~ | ~~是否双写~~ | —— | ✅ **已定:不双写**(§6.1),代码一次切换、旧列延后 drop |
| **G** | 每日逻辑备份是否随本次一起做 | ①一起做(drop 列前必须有)②先不做 | ①(§12、C7 前置) |
## 10. 提交计划(7 个提交)
拆分的核心约束:**同一份数据的读与写必须在同一个提交里切**。否则出现中间态 ——
比如「写已经写账号、读还在读记录行」,新登录拿到的 CK 在页面上看不见,巡检还会按旧值判定;
这类中间态跑起来不报错,只是**默默用错数据**,比崩溃更难发现。
另外三条边界:
- **迁移单独一个提交**(纯加列 + 回填,无行为变化)—— 单独部署也安全;
- **用户可感知的语义变化各占一个提交**(Cookie 页单位、删除护栏),便于单独验证与回退;
- **drop 旧列隔离到下一次发版**(唯一不可逆动作)。
| # | 提交信息 | 改动文件 | 干什么 | 怎么验证 |
| --- | --- | --- | --- | --- |
| **C1** | `feat(db): accounts 增加 CK 与状态列,并从 login_tasks 回填` | `migrations/versions/20261002_0040_account_ck_state.py`(新)<br>`models.py`<br>`tests/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`)<br>`services/login_service.py`(`_write_login_success`、清空、批次行复用)<br>`routers/cookies.py`(清除/删除 CK 的**写点**)<br>`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.py`<br>`services/automation/jobs/ck_relogin.py`<br>`routers/automation.py`<br>`routers/cookies.py`(检测写点)<br>`services/login_service.py`(重登后复检写点)<br>`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.py`<br>`routers/accounts.py`<br>`routers/douyu.py`<br>`routers/dashboard.py`<br>`web/frontend/src/pages/CookiePage.tsx`<br>`api/cookies.ts` / `api/types.ts` | 列表/汇总/重复/导出/检测/删除全部改为以**账号**为单位;`DELETE /cookies/{id}` 入参由 task_id 变 account_id | **风险最高**:前后端同一提交;专门用例覆盖「删除清的是入参那个账号」;上线后手工验证一次 |
| **C5** | `feat(login): 登录历史可自由删除(解除 CK 护栏)` | `routers/login.py`<br>`web/frontend/src/pages/LoginTasksPage.tsx`<br>`tests/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 兜底。