docs: 登录任务与 CK 解耦设计(待评审)

根因是 login_tasks 一张表同时扛了四件事:CK 本体、CK 健康状态、重登调度、
登录历史。前三个是「账号当前状态」,第四个是「流水」,生命周期不同,
于是清理流水必然伤到状态。临时护栏只是兜底。

方案:照 huya_accounts 的既有先例,把 CK 与状态挂到 accounts,
login_tasks 退化为纯流水。分两步:先双写+读切换(可随时回退),
稳定后再停止双写并删列。含迁移与回填 SQL、读写切换清单、验收基线、
测试计划、回退方案,以及 5 个待确认决策点。
This commit is contained in:
yml2213
2026-10-03 11:15:40 +08:00
parent cd60a48039
commit a9e72f16da
+260
View File
@@ -0,0 +1,260 @@
# 设计:登录任务与 CK 解耦
> 状态:**待评审**(未动代码)
> 起因: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` 的对应列在 Phase 2 才删;Phase 1 期间保留(双写),随时可回退。
### 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` 为空时更新(**幂等**,可重跑;也不会覆盖 Phase 1 之后新写入的 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. 读写切换清单(Phase 1 全量切到 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(**前端要改,且要提示不可逆**)。
## 6. 分步实施
### Phase 1 —— 建立账号级 CK(可随时回退)
1. 迁移 `20261002_0040`:加列 + 回填 + 校验(§4);
2. `models.py`:`Account` 加列;`LoginTask` 的列**先不动**;
3. 写路径**双写**:`account.* = 新值` 的同时照旧写 `task.*`(保证回退时数据不丢);
4. 读路径全量切到 `accounts`(§5.1);
5. 自动化作业改为按账号调度(§5.3);
6. **解除临时护栏**:删除登录任务不再限制持有 CK 的记录(此时删历史已不影响 CK);
前端提示文案同步改回;
7. 上线后观察:巡检覆盖数、重登成功率、Cookie 页数据量与迁移前一致。
> Phase 1 完成后,用户目标已达成(历史可自由删除)。双写是回退保险。
### Phase 2 —— 去掉冗余(观察 1~2 周后)
1. 停止写 `login_tasks.cookie`(历史行只留 `cookie_snapshot` 或干脆不留,见决策 D);
2. 停止写 `login_tasks.ck_check_*` / `relogin_*`;
3. 迁移 `20261002_00XX`:drop 这些列;
4. 清理读取代码里对 `LoginTask.cookie` 的残留引用。
## 7. 回退方案
- **Phase 1 回退**:代码回退到旧版本即可 —— `login_tasks` 的列与数据**全程双写、未被删**,
旧代码读它照样工作。新增的 `accounts` 列留着不影响旧代码;
- **回填出错**:回填只在 `accounts.cookie` 为空时执行且幂等,可重跑;
- **Phase 2 回退**:需要恢复列结构(有迁移 downgrade)但**数据无法自动还原**(列已 drop),
因此 Phase 2 前必须先做一次逻辑备份。**这也是建议尽快加每日备份的原因**。
## 8. 测试计划
| 层次 | 用例 |
| --- | --- |
| 迁移 | 加列/回填幂等;回填条数与预期一致;校验 SQL 为 0/相等 |
| 单元 | `cookie_holding_filter()` 依据账号;`latest_cookie()` 读账号;清空 CK 写账号 |
| 回归 | **删除全部历史行后,账号 CK 与其健康/重登状态仍在**(这条是本次改造的验收核心) |
| 回归 | 登录成功写账号 CK;重登失败/停止不破坏已有 CK(沿用 `test_login_attempt_isolation.py`) |
| 自动化 | `ck_health` / `ck_relogin` 按账号选行;无历史行的账号也能被巡检/重登 |
| 接口 | Cookie 页各接口在账号级语义下返回正确;导出内容与迁移前一致 |
| 生产 | 迁移后对比:持 CK 账号数(应 13264)、巡检每轮 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** | 重登/巡检的调度粒度 | ①账号(推荐)②仍按最近一次登录行 | ①:账号才是状态的载体 |
## 10. 工作量与风险
- 涉及:1 个迁移、`models.py`、4 个 service、7 个 router、2 个自动化作业、前端 3 个页面 + 类型、约 10 个测试文件;
- 粗估 **2~3 人日**(Phase 1 约 1.5 天,Phase 2 半天);
- **最大风险是 Cookie 页**(决策 A):它的接口语义从「记录」变成「账号」,前后端要一起改,
且 `DELETE /cookies/{id}` 的入参含义变了 —— 改错会变成「点删除清错账号的 CK」,比这次事故更糟。
建议 Phase 1 里**先不动 Cookie 页的删除接口**,只切读取(列表/导出/检测),删除接口单独一轮处理。
- 生产上自动化在跑,迁移要选在巡检空闲窗口(当前 5 分钟一轮,无真正的空窗,但迁移只锁
`accounts` 的 1.4 万行、分块执行,影响可控)。