diff --git a/docs/设计-登录任务与CK解耦.md b/docs/设计-登录任务与CK解耦.md new file mode 100644 index 0000000..4f1f212 --- /dev/null +++ b/docs/设计-登录任务与CK解耦.md @@ -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 万行、分块执行,影响可控)。