diff --git a/docs/设计-登录任务与CK解耦.md b/docs/设计-登录任务与CK解耦.md
new file mode 100644
index 0000000..93124ff
--- /dev/null
+++ b/docs/设计-登录任务与CK解耦.md
@@ -0,0 +1,421 @@
+# 设计:登录任务与 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`(新)
`models.py`
`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`)
`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.py`
`services/automation/jobs/ck_relogin.py`
`routers/automation.py`
`routers/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.py`
`routers/accounts.py`
`routers/douyu.py`
`routers/dashboard.py`
`web/frontend/src/pages/CookiePage.tsx`
`api/cookies.ts` / `api/types.ts` | 列表/汇总/重复/导出/检测/删除全部改为以**账号**为单位;`DELETE /cookies/{id}` 入参由 task_id 变 account_id | **风险最高**:前后端同一提交;专门用例覆盖「删除清的是入参那个账号」;上线后手工验证一次 |
+| **C5** | `feat(login): 登录历史可自由删除(解除 CK 护栏)` | `routers/login.py`
`web/frontend/src/pages/LoginTasksPage.tsx`
`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 兜底。