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
+18
View File
@@ -0,0 +1,18 @@
import api from './client';
import type {
AutomationJobItem,
AutomationJobUpdate,
AutomationJobsResponse,
AutomationRunOutcome,
AutomationRunsResponse,
} from './types';
export const automationApi = {
jobs: () => api.get<AutomationJobsResponse, AutomationJobsResponse>('/automation/jobs'),
updateJob: (key: string, payload: AutomationJobUpdate) =>
api.patch<AutomationJobItem, AutomationJobItem>(`/automation/jobs/${key}`, payload),
runJob: (key: string) =>
api.post<AutomationRunOutcome, AutomationRunOutcome>(`/automation/jobs/${key}/run`),
runs: (params: { job_key?: string; limit?: number }) =>
api.get<AutomationRunsResponse, AutomationRunsResponse>('/automation/runs', { params }),
};
+1
View File
@@ -4,6 +4,7 @@ export { accountApi } from './accounts';
export { appApi } from './app';
export { auditApi } from './audit';
export { authApi } from './auth';
export { automationApi } from './automation';
export { cookieApi } from './cookies';
export { dashboardApi } from './dashboard';
export { douyuApi } from './douyu';
+64
View File
@@ -827,3 +827,67 @@ export interface PermissionsListResult {
permissions: Record<string, string>;
role_permissions: Record<string, string[]>;
}
// ==================== 自动化作业 ====================
export interface AutomationRunItem {
id: number;
job_key: string;
started_at: string | null;
finished_at: string | null;
status: string;
scanned: number;
valid: number;
invalid: number;
skipped: number;
message: string;
detail: Record<string, unknown> | null;
}
export interface AutomationJobItem {
key: string;
title: string;
enabled: boolean;
interval_seconds: number;
batch_size: number;
concurrency: number;
last_run_at: string | null;
next_run_at: string | null;
last_status: string;
last_message: string;
registered: boolean;
latest_run: AutomationRunItem | null;
}
export interface AutomationSchedulerStatus {
enabled: boolean;
running: boolean;
tick_seconds: number;
registered: string[];
}
export interface AutomationJobsResponse {
scheduler: AutomationSchedulerStatus;
jobs: AutomationJobItem[];
}
export interface AutomationRunsResponse {
items: AutomationRunItem[];
}
export interface AutomationJobUpdate {
enabled?: boolean;
interval_seconds?: number;
batch_size?: number;
concurrency?: number;
}
export interface AutomationRunOutcome {
job_key: string;
status: string;
message: string;
scanned: number;
valid: number;
invalid: number;
skipped: number;
}