feat(automation): CK 自动巡检(P1a),只检测不改 CK

后台原本靠人工逐批点「CK 检测」发现失效账号,1.2 万个账号根本盯不过来。
P1a 把「哪些账号失效了」自动化:自动挑久未检测的账号、调现成检测接口、
把结果写回 ck_check_*。**本作业不替换任何 CK**,重登仍由人工点,符合规划里
「先做只读/只提示,确认无误再做自动动作」的渐进上线原则。

实现:
- 迁移 20261002_0037:automation_jobs / automation_runs 两张表,
  login_tasks 加 relogin_attempts / relogin_next_at / relogin_last_error
  (为 P1b 备好),accounts 加 auto_managed(特殊账号不参与自动化);
- services/automation/scheduler.py:进程内调度线程,60s tick,状态全部落库
  (重启不丢调度进度、不重复跑轮);AUTOMATION_ENABLED 默认关闭,作业开关
  也默认关闭;ensure_jobs 只补缺失行、不覆盖运维改过的配置;
  作业异常只记 failed,不拖垮调度线程;单容器 --workers 1,无需分布式锁;
- services/automation/jobs/ck_health.py:选行口径与人工检测一致
  (cookie_holding_filter:持有 CK 且无在途尝试),叠加久未检测、
  auto_managed、batch_size,按 ck_checked_at 升序(最久没查的优先);
  只写 ck_check_status / ck_check_result / ck_checked_at;
- routers/automation.py:GET /jobs、PATCH /jobs/{key}、POST /jobs/{key}/run、
  GET /runs;新增权限点 automation:view(运营可看)与 automation:manage
  (仅超管),改配置与手动触发都写审计;
- 顺带补 POST /api/login/batch 的审计(此前批次是谁发起、多少账号无据可查);
- 前端 /automation 页:作业卡片(开关/周期/单轮条数/并发)、运行历史、
  「立即跑一次」;总开关未打开、调度线程未运行时给明确提示。

验证:
- 22 条新测试覆盖调度器行为(只跑到点的、异常不抛出、下轮时间、配置不被重置)、
  选行条件(在途尝试/auto_managed/间隔/batch_size)、接口与权限,其中一条
  专门断言巡检后 cookie/status/message/attempt_status 一字未动;
- 全量 257 条通过;前端 tsc 与构建通过;
- 端到端用 3 个真实账号跑真实斗鱼接口:巡检结果与直接调 check_douyu_cookie
  逐条一致(3/3 valid),CK 与尝试状态全程未变。

顺带修掉一处会炸生产的坑:DB 时间列读出来是 naive UTC,直接与
datetime.now(UTC) 比较会 TypeError,现统一走 _as_utc() 补时区。
This commit is contained in:
yml2213
2026-10-02 19:47:41 +08:00
parent 6537d77323
commit f745a61df1
19 changed files with 1721 additions and 19 deletions
+34 -16
View File
@@ -1,7 +1,7 @@
# 自动化与资料收集规划
更新时间:2026-10-02
状态:**规划中(未开工)**。产品决策已与负责人确认(见 §4),代码实现待排期。
状态:**P1a(CK 自动巡检)已落地**,P0/P2/P3/P4/P1b 待排期。产品决策已确认(见 §4),施工单见附录 C。
---
@@ -356,6 +356,15 @@ lookup_order(order_no) -> {
- **P1a(先做)**:把「哪些账号 CK 失效了」自动找出来并展示 —— **只检测、只标记、只提示,不改动任何 CK**;重登仍然人工点。
- **P1b(观察后再开)**:把「失效账号自动重登」也自动化 —— 有退避、有熔断、有开关,**默认关闭**。
> **P1a 已落地(2026-10-02)**,实际落点:
> `services/automation/scheduler.py`(调度线程 + 到点判定 + 运行记录)、
> `services/automation/jobs/ck_health.py`(巡检作业)、
> `routers/automation.py`(4 个接口)、`permissions.py`(`automation:view` / `automation:manage`)、
> `migrations/versions/20261002_0037_automation_jobs.py`、
> 前端 `pages/AutomationPage.tsx`(菜单「自动巡检」)、
> `tests/test_automation_ck_health.py`(22 条)。
> 端到端已用 3 个真实账号验证:巡检结果与直接检测逐条一致,且 `cookie` / `status` / `message` / `attempt_status` 全程未变。
### 7.2 数据模型(迁移 1 个,P1a/P1b 共用)
**新表 `automation_jobs`**
@@ -1065,7 +1074,7 @@ OSS_PREFIX=collect
---
## 附录 A · 迁移清单(共 5 个,均为加表/加字段,不动存量数据)
## 附录 A · 迁移清单(均为加表/加字段,不动存量数据;编号按落地顺序取)
| 阶段 | 迁移内容 |
| --- | --- |
@@ -1126,15 +1135,20 @@ OSS_PREFIX=collect
战略与数据模型在正文已定;这里只写**按什么顺序动手、每步怎么验证**。
### C.1 迁移编号分配(现状 head = `20261001_0036`)
### C.1 迁移编号(按落地顺序取,**不预留**)
| 阶段 | 文件名 | revision |
现状 head = `20261001_0036`。**谁先落地谁取下一个号**,不要按阶段预分配。
| 阶段 | revision | 说明 |
| --- | --- | --- |
| P0 | `20261002_0037_external_order_cache.py` | `20261002_0037` |
| P1 | `20261002_0038_automation_jobs.py` | `20261002_0038` |
| P2 | `20261002_0039_collect_links_submissions.py` | `20261002_0039` |
| P3 | `20261002_0040_skin_catalog_exchange_records.py` | `20261002_0040` |
| P4 | `20261002_0041_exchange_order_tables.py` | `20261002_0041` |
| P1a(已落地) | `20261002_0037` | `automation_jobs` / `automation_runs` / `login_tasks` +3 / `accounts.auto_managed` |
| P0 | 落地时取下一个 | `external_order_cache` |
| P2 / P3 / P4 | 各自落地时依次取号 | 见附录 A 的表清单 |
> **为什么不能预留编号**:Alembic 的 `down_revision` 必须形成单链。若先给 P1 占 0038、
> 把 0037 留给 P0,等 P0 真落地时它的 `down_revision` 只能指向 0038(否则与 0038 平级,
> 出现**两个 head**,`tests/test_migrations.py` 的单头断言会直接失败),编号比父提交还小。
> 所以按落地顺序递增,才是唯一不别扭的做法。
约定:每个迁移的 `down_revision` 指向前一个;**写成幂等的**(照 `20261001_0036` 的写法,先 `sa.inspect` 查存在再改);每加一个迁移,`tests/test_migrations.py` 的 `HEAD_REVISION` 要同步改。
@@ -1143,7 +1157,11 @@ OSS_PREFIX=collect
- 测试文件按功能命名:`tests/test_<域>_<主题>.py`(现有 38 个文件、235 条);
- **纯函数优先**:匹配打分、别名归一、365 天判定、订单号归一、模板必填校验都做成纯函数重点覆盖;
- **写操作不进自动回归**:兑换/重登/支付用 mock 客户端做契约测试(沿用 `tests/test_douyu_write_path_contracts.py`);
- 提交门禁:pre-commit 跑 `ruff format` + `tsc -b`;pre-push 跑全量 pytest;
- 提交门禁:pre-commit 跑 `ruff format --check`(仅暂存文件)+ `tsc -b`;pre-push 跑全量 pytest;
- **DB 读出来的时间是 naive UTC**:`datetime` 列取回后不带时区,直接和 `datetime.now(UTC)` 比较会
`TypeError: can't compare offset-naive and offset-aware`(P1a 踩过一次)。比较前一律用
`_as_utc()`(`services/yyb_service.py` 与 `services/automation/scheduler.py` 各有一份)补时区。
SQL 里用绑定参数比较不受影响,只有 Python 侧比较需要注意 —— P0 判缓存过期时会再遇到;
- **每个阶段独立可发布**,新作业/新功能一律**开关默认关闭**。
### C.3 已核实的环境事实
@@ -1158,11 +1176,11 @@ OSS_PREFIX=collect
| OSS | 桶 `live-hub-assets`(私有+版本控制)、RAM `live-hub-oss`、`.env` 已配 | `oss2`;**不要用 HeadBucket** |
| 迁移 head / 测试基线 | `20261001_0036` / 235 条 | 见 C.1 |
### C.4 P1a 施工单(下一个要做的,8 步)
### C.4 P1a 施工单(✅ 已按此完成,8 步)
| # | 动作 | 文件 | 验证 |
| --- | --- | --- | --- |
| 1 | 迁移 0038:`automation_jobs`、`automation_runs`;`login_tasks` +3 字段;`accounts.auto_managed` | `migrations/versions/20261002_0038_*.py`、`models.py` | `pytest tests/test_migrations.py`;本地建表通过 |
| 1 | 迁移 0037:`automation_jobs`、`automation_runs`;`login_tasks` +3 字段;`accounts.auto_managed` | `migrations/versions/20261002_0037_automation_jobs.py`、`models.py` | `pytest tests/test_migrations.py`;本地建表通过 |
| 2 | 调度器骨架:作业注册表、60s tick、进程内锁、开关、`automation_runs` 记录、优雅停止 | `services/automation/scheduler.py` | 单测:假时钟驱动 tick,断言「作业 enabled=false 时不执行」「不重叠执行」 |
| 3 | `ck_health` 作业:选行(`cookie_holding_filter` + 超期 + 排除在途/`auto_managed=false`)、并发 5、调 `check_douyu_cookie()`、写回 `ck_check_*` | `services/automation/jobs/ck_health.py` | 单测:选行条件正确;**断言全程不写 `cookie` / `status`**(P1a 的核心约束) |
| 4 | 接口 + 权限 + 注册:4 个 `/api/automation/*`;`automation:view` / `automation:manage`;lifespan 启停 | `routers/automation.py`、`permissions.py`、`main.py` | `tests/test_automation_api.py`:权限校验、开关生效、手动触发 |
@@ -1177,7 +1195,7 @@ OSS_PREFIX=collect
| 阶段 | 顺序 |
| --- | --- |
| **P0** | ① `pyproject.toml` 加 `psycopg[binary]` → ② 迁移 0037 + `models.py` → ③ `external_order_source.py`(唯一跨库 SQL;URL 归一成 `postgresql+psycopg://`;`statement_timeout`;2 条连接池) → ④ `tests/test_external_order_source.py`(用真实只读账号跑只读查询,覆盖 A/B/C 三桶与不存在订单) → ⑤ 降级开关验证 |
| **P2** | ① 迁移 0039 → ② `object_storage.py`(`oss2`,Put/Get/Delete/List,不用 HeadBucket) → ③ `collect_service.py`(token/限流/图片校验+Pillow 重编码+缩略图) → ④ 公开路由 `/api/public/collect/*` + 内部 `/api/collect/*` → ⑤ 前端公开页(放 `App.tsx` 的 `/login` 同级)+ 内部两页 → ⑥ 测试(含"宿主机找不到图片副本"的验收) |
| **P3** | ① 迁移 0040 → ② `skin_catalog` 从 `douyu_goods_snapshot` 同步 + `sku_catalog_sync` 作业(挂 P1 调度器) → ③ `skin_service.py`(别名归一 + `can_exchange` 365 天 + 落账) → ④ `/api/skins/*` → ⑤ `/skins` 页 → ⑥ 把 62 条唯一映射 + 14 条多选映射导入 |
| **P4** | ① 迁移 0041 → ② `exchange_order_service.py`(`resolve_skin_rule` 按 SKU 四级匹配 + 状态机 + 事件) → ③ 前置校验(CK/365 天/绑定/积分) → ④ 工单接口 + 与 `collect_submissions` 衔接 → ⑤ `/exchange-rules` + `/exchange-orders` 两页 → ⑥ 端到端联调(发链接 → 提交 → 校验 → 执行 → 台账) |
| **P0** | ① `pyproject.toml` 加 `psycopg[binary]` → ② 迁移(取下一个号)+ `models.py` → ③ `external_order_source.py`(唯一跨库 SQL;URL 归一成 `postgresql+psycopg://`;`statement_timeout`;2 条连接池) → ④ `tests/test_external_order_source.py`(用真实只读账号跑只读查询,覆盖 A/B/C 三桶与不存在订单) → ⑤ 降级开关验证 |
| **P2** | ① 迁移(取下一个号)→ ② `object_storage.py`(`oss2`,Put/Get/Delete/List,不用 HeadBucket) → ③ `collect_service.py`(token/限流/图片校验+Pillow 重编码+缩略图) → ④ 公开路由 `/api/public/collect/*` + 内部 `/api/collect/*` → ⑤ 前端公开页(放 `App.tsx` 的 `/login` 同级)+ 内部两页 → ⑥ 测试(含"宿主机找不到图片副本"的验收) |
| **P3** | ① 迁移(取下一个号)→ ② `skin_catalog` 从 `douyu_goods_snapshot` 同步 + `sku_catalog_sync` 作业(挂 P1 调度器) → ③ `skin_service.py`(别名归一 + `can_exchange` 365 天 + 落账) → ④ `/api/skins/*` → ⑤ `/skins` 页 → ⑥ 把 62 条唯一映射 + 14 条多选映射导入 |
| **P4** | ① 迁移(取下一个号)→ ② `exchange_order_service.py`(`resolve_skin` 两步匹配 + 状态机 + 事件) → ③ 前置校验(CK/365 天/绑定/积分) → ④ 工单接口 + 与 `collect_submissions` 衔接 → ⑤ `/exchange-rules` + `/exchange-orders` 两页 → ⑥ 端到端联调(发链接 → 提交 → 校验 → 执行 → 台账) |