8 Commits
Author SHA1 Message Date
yml2213 19f1cbceba fix(automation): 修复作业完成状态回写崩溃、统计字段对齐及表单布局挤压
1. 修复 DouyuBatchRunner 在 run 结束时私自关闭外部 Session 导致作业状态无法回写并死锁在 running 的问题(增加 close_db 参数);
2. 修复资料补全作业返回值映射,将 succeeded/failed 对齐到 valid/invalid,并在全库暂无待查账号时给出清晰说明;
3. 增强调度器 _run_one 边界防御:支持字段名兜底与 Session 崩溃时独立连接兜底回写,防止作业无限重复发起;
4. 修复巡检作业在全库无到期账号时的说明信息,如实展示最久检测时间与重检间隔;
5. 修复 AutomationPage 布局:移除外层 Form 的 layout=inline,Card 恢复 100% 满宽纵向排列,内部字段横向弹性自适应;
6. 修复未启用作业因残留历史 next_run_at 而错误显示「排队中」的问题。
2026-10-04 22:11:44 +08:00
yml2213 55f899c615 feat(automation): 拆开「多久跑一次」与「同一账号多久重检」—— 调度频率终于可配
用户反馈:「单账号重检间隔(小时) 很别扭,直接设置多久跑一次、一次跑多少个更符合直觉」。

**对,而且比"别扭"更严重:调度频率以前根本改不了。**

## 问题:两个概念挤在一个字段里

`_run_one` 里原本写着:

    job.next_run_at = finished + timedelta(seconds=JOB_INTERVAL_SECONDS)   # 硬编码 300 秒

也就是不管界面上填什么,下一轮永远是「跑完 + 5 分钟」。
而界面那个「单账号重检间隔(小时)」对应的是 `interval_seconds` ——
它只被作业**自己的选行逻辑**使用("这行多久该重检"),**完全不参与调度**。

于是出现最坏的情况:**看起来能配调度,其实配的是一个不相干的参数**。
上一轮我还拿它算过吞吐("10 分钟一轮"),算错了 —— 已在调度器注释里纠正。

## 拆法

| 概念 | 字段 | 界面 |
| --- | --- | --- |
| **多久跑一次** | `run_interval_seconds`(迁移 0051,默认 300)| 「多久跑一次(分钟)」|
| 同一账号多久重检 | `interval_seconds`(不变)| 「同一账号多久重检(小时)」|

- `next_run_at = finished + run_interval_seconds`(有下限 30 秒,避免忙轮询)
- 接口:`GET` 返回新字段、`PATCH` 可改
- 前端:两个输入框分开,并把原来那段绕口的 tooltip
  ("它不决定调度频率 —— 调度周期见页面顶部说明")改掉 ——
  那句话本身就是这个设计缺陷的自白

## 默认 300 秒

与改动前**实际行为一致** —— 不会因为这次迁移让已有作业突然变快或变慢。
想调快就自己填(比如「账号资料定时补全」填 5 分钟 + 每轮 400 条 ≈ 3 小时补完)。

## ⚠️ 顺带说明一个你截图里可能看到的现象

截图里「账号资料定时补全」显示的是 **0.5 小时 / 30 条** —— 那是我**最初的默认值**。
因为 `ensure_jobs` **只创建不更新**,我后面改成 10 分钟/400 **不会作用到已存在的行**。
这是刻意的(否则会覆盖运维的手工调整),但意味着**默认值改了要手动在界面上改**,
或者删掉那行让 `ensure_jobs` 重建。现在有了「多久跑一次」这个框,直接填就行。

## 测试(+3)

- 下一轮按**运行间隔**排,不是按「同一行重检间隔」(两个值刻意设成不同来钉住)
- 不填时默认 300 秒(保持旧行为)
- 过小的间隔被夹到下限

## 验证

TZ=Asia/Shanghai **639 passed / 6 skipped**;ruff 全绿;前端 build 通过、lint 0 errors。
2026-10-04 21:30:13 +08:00
yml2213 aea8e4f73d perf(automation): 调度器改为**作业之间并发**,慢作业不再挡住快作业
用户要求做这条(原本串行)。串行的代价现在很明显:新加的「账号资料定时补全」
一轮要 4 分钟以上,而源码注释里写的是「目前只有 CK 巡检,单轮约 20~40 秒」——
那 4 分钟里 CK 巡检/重登只能干等。

## 两处保护(缺一不可)

**① 在途守卫**(并发化之后最容易出的事故)

`next_run_at` 是作业**跑完之后**才更新的,所以一个跑得比 tick 还久的作业,
下一个 tick 看它仍然是「到期」状态 —— 没有守卫就会被**再派发一次**,
等于把速率翻倍(对斗鱼接口就是"并发放开",正是风控最敏感的地方)。
所以用 `_IN_FLIGHT` 记录在跑的作业 key,且**成功/失败都必须清标记**
(否则该作业以后再也不跑了)。两条都有用例钉住。

**② 独立 session**

SQLAlchemy 的 Session **不是线程安全的**。作业现在真的并行了,
所以每个作业在自己的 `SessionLocal()` 里跑,按 key 重新取行
(调度线程里那行可能已经过期)。共用 session 会互相踩事务。

## tick 不再被拖住

调度循环改用 `run_due_jobs(db, wait=False)`:**派发完就返回**,
tick 保持 5 分钟一轮,不再等作业跑完。

`wait=True`(默认)保留原语义:顺序执行、用调用方的 session 并返回 outcomes ——
测试与手动触发走这条,所以既有的 33 条调度器用例**一条没改**。

## ⚠️ 顺带纠正我自己上一轮的一个错误说法

我上一条说「interval_seconds=10 分钟」会生效 —— **不对**。`_run_one` 里写着:

    下一轮固定在一个 tick 之后;interval_seconds 是「同一行多久重检」,
    由作业自己在选行时用,**不参与调度**。

也就是下一轮永远是「跑完 + 5 分钟」。所以「账号资料定时补全」的真实节奏是
`4.4 分钟跑 + 5 分钟等 ≈ 9.4 分钟一轮` ≈ 2,553 个/小时 ≈ **3.05 小时**,
和之前估的 3.2 小时吻合。

## 测试(+4)

- `wait=False` 立刻返回、作业在后台真的开始了
- **在途作业不会被重复派发**(速率翻倍那个事故的守卫)
- 失败后仍会清在途标记、作业能再跑(别被永久卡死)
- `_release` 即使对不存在的 key 也不炸

## 验证

TZ=Asia/Shanghai **636 passed / 6 skipped**;ruff 全绿。
2026-10-04 21:24:35 +08:00
yml2213 d6c6113112 fix(automation): 被重启打断的轮次记为 interrupted,不再混进 failed
部署重建容器会打断正在跑的作业,那轮永远写不回结果,由启动时的
cleanup_orphan_runs 收尾。之前收尾成 failed,于是页面上一片红 —— 但这并不是
作业本身的失败:账号侧的 CK 一个没丢(旧 Cookie 已保留),被中断的账号也会按
退避自动重试。混在 failed 里会掩盖真正的失败(如 SQL 语法错误)。

改为独立状态 interrupted,前端用中性灰显示「被中断」,与红色的「失败」区分开。
2026-10-02 22:08:18 +08:00
yml2213 df62898d22 fix(automation): 调度心跳与作业周期解耦,修掉系统性跳轮
线上现象:同一页面上「CK 自动巡检」的最近运行是 21:45:34,而「下次运行」显示
21:50:34 且已经过去了 —— 巡检明明只要 19 秒,却总是等在那里。

根因是把心跳周期直接当成了作业周期:TICK_SECONDS=300 既是「循环多久醒一次」,
又被写进 next_run_at = 作业结束 + 300s。两者同频会系统性跳轮:

  心跳 t=0     ck_health 19 秒跑完 → next = 5:19
  心跳 t=5:00  差 19 秒没到点 → 整轮空转,什么都不做
  心跳 t=10:00 才真正跑到

于是作业实际是每 10 分钟一轮,而不是设计的 5 分钟;只要作业耗时不是刚好 0,
就会稳定地"差一点点"被跳过。两个作业共享同一个心跳时,还会互相把对方的到期
时刻推后。

改动:
- 拆成两个常量:TICK_SECONDS=30(心跳,只做「有没有到点的作业」检查,几乎不
  耗资源)、JOB_INTERVAL_SECONDS=300(作业周期,跑完一轮后隔多久再跑)。
- next_run_at 改用 JOB_INTERVAL_SECONDS。
- /api/automation/jobs 的 scheduler 同时暴露两个值;启动日志一并打印。
- 页面:「单账号重检间隔」加 tooltip 说明它不决定调度频率(它只控制同一行多久
  重检/重试),避免和调度周期混淆。

回归测试(3 条):心跳必须比作业周期密至少 4 倍;周期未到时连跳多次心跳都不跑;
把 next_run_at 拨到过去后下一次心跳必须跑到。

全量 304 通过。
2026-10-02 21:57:41 +08:00
yml2213 34d667cba0 fix(automation): 启动时收尾卡在 running 的运行记录
作业是在调度线程里同步跑的:进程一重启(部署、崩溃、手工重启),那一轮就再没
机会写回结果,automation_runs 会永远停在 running,页面上一直显示「执行中」。
登录任务早有孤儿清理,运行记录原先漏了。

线上就是这么暴露的:部署时重建容器打断了正在跑的 ck_relogin 第 14 轮,重启清理
把 20 个在途账号标成了「重新登录中断(旧 Cookie 已保留)」(这部分是对的),
但 run 14 本身永远卡在 running,detail 为空。

新增 cleanup_orphan_runs(),在启动清理链里调用:把所有 running 的运行记录收尾为
failed + finished_at + 「服务重启中断」。如实记为 failed 而不是 success —— 那轮
确实没跑完;被中断的账号由各自的孤儿清理负责标记并按退避重试。

新增回归测试:只有 running 的被收尾,已完成的不受影响。

(另一件事一并说明:上一次部署我报告「零中断」是验证过早 —— 检查时部署的 up -d
还没执行到。实际情况是 mysql 未被重建(达到了本次改动目的),但应用容器因镜像
变化被正常重建,因此打断了那轮重登。)
2026-10-02 21:35:42 +08:00
yml2213 7aaaeebe2b fix(automation): 修正调度口径——作业每 5 分钟跑一轮,interval_seconds 只是单行重检间隔
P1a 把两个「间隔」混成了一个:next_run_at 用的是 job.interval_seconds,于是
作业 6 小时才跑一轮、每轮 200 条 = 800 条/天,全库 12,881 条要 16 天才能过
一遍 —— 等于没巡检,与规划里「账号 6 小时内被查一次」的目标差了两个数量级。

按规划原意修正:
- TICK_SECONDS 60 → 300:作业每个 tick(5 分钟)跑一轮;
- next_run_at 改为 finished + TICK_SECONDS,不再用 interval_seconds;
- interval_seconds 只作为「同一行多久重新检测」的选行 cutoff(选行逻辑本来就
  是这么用的,无需改);
- 容量口径随之更新:5 分钟一轮 × 200 条 = 40 条/分钟 ≥ 需要的 36 条/分钟,
  冷启动约 5.4 小时覆盖全库,稳态每轮一百多条;
- 前端标签由「巡检周期(小时)」改为「单账号重检间隔(小时)」,说明文案写清
  「每 5 分钟跑一轮」,避免运维误以为是作业周期。

同步更新规划文档 §7.2/§7.3(含两个间隔不能混的说明)与附录 C 的三段上线
步骤(立即跑一次 → 每轮 50 观察 → 每轮 200 启用)。
2026-10-02 19:54:31 +08:00
yml2213 f745a61df1 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() 补时区。
2026-10-02 19:47:41 +08:00