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:
@@ -15,6 +15,7 @@ const AccountCheckPage = lazy(() => import('./pages/AccountCheckPage'));
|
||||
const AssignmentsPage = lazy(() => import('./pages/AssignmentsPage'));
|
||||
const LoginTasksPage = lazy(() => import('./pages/LoginTasksPage'));
|
||||
const ProxyPage = lazy(() => import('./pages/ProxyPage'));
|
||||
const AutomationPage = lazy(() => import('./pages/AutomationPage'));
|
||||
const UsersPage = lazy(() => import('./pages/UsersPage'));
|
||||
const CookiePage = lazy(() => import('./pages/CookiePage'));
|
||||
const CookieOperationsPage = lazy(() => import('./pages/CookieOperationsPage'));
|
||||
@@ -100,6 +101,7 @@ function AppContent() {
|
||||
<Route path="huya/browser-exchange" element={lazyRoute(<HuyaBrowserExchangePage />)} />
|
||||
<Route path="yyb/recharge" element={lazyRoute(<YybRechargePage />)} />
|
||||
<Route path="proxy" element={lazyRoute(<ProxyPage />)} />
|
||||
<Route path="automation" element={lazyRoute(<AutomationPage />)} />
|
||||
<Route path="users" element={lazyRoute(<UsersPage />)} />
|
||||
<Route path="audit-logs" element={isSuperAdmin ? lazyRoute(<AuditLogsPage />) : <Navigate to="/" replace />} />
|
||||
</Route>
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
MenuFoldOutlined, MenuUnfoldOutlined, SwapOutlined,
|
||||
SunOutlined, MoonOutlined, DesktopOutlined, GiftOutlined, ApartmentOutlined, MobileOutlined, ShoppingCartOutlined,
|
||||
BookOutlined, TrophyOutlined, ShopOutlined, GlobalOutlined,
|
||||
SafetyCertificateOutlined, FileSearchOutlined,
|
||||
SafetyCertificateOutlined, FileSearchOutlined, RobotOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
|
||||
import { getUser, clearAuth, type AuthUser } from '../store/auth';
|
||||
@@ -75,6 +75,7 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
|
||||
}
|
||||
if (can('cookie:operate')) {
|
||||
douyuItems.push({ key: '/cookie-operations', label: 'CK 检测与重登', icon: <SafetyCertificateOutlined /> });
|
||||
douyuItems.push({ key: '/automation', label: '自动巡检', icon: <RobotOutlined /> });
|
||||
}
|
||||
if (can('douyu:task')) {
|
||||
douyuItems.push({ key: '/douyu/elite', label: '精英宝典', icon: <BookOutlined /> });
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Descriptions,
|
||||
Form,
|
||||
InputNumber,
|
||||
Popconfirm,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { ReloadOutlined, ThunderboltOutlined } from '@ant-design/icons';
|
||||
import { message } from '../utils/antdMessage';
|
||||
import { getErrorMessage } from '../utils/error';
|
||||
import { automationApi, type AutomationJobItem, type AutomationRunItem } from '../api/modules';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { formatTime } from '../utils/time';
|
||||
|
||||
const STATUS_TAG: Record<string, { color: string; text: string }> = {
|
||||
success: { color: 'green', text: '成功' },
|
||||
failed: { color: 'red', text: '失败' },
|
||||
running: { color: 'blue', text: '执行中' },
|
||||
skipped: { color: 'default', text: '已跳过' },
|
||||
};
|
||||
|
||||
function secondsToHours(seconds: number): number {
|
||||
return Math.round((seconds / 3600) * 10) / 10;
|
||||
}
|
||||
|
||||
export default function AutomationPage() {
|
||||
const [jobs, setJobs] = useState<AutomationJobItem[]>([]);
|
||||
const [schedulerEnabled, setSchedulerEnabled] = useState(true);
|
||||
const [schedulerRunning, setSchedulerRunning] = useState(false);
|
||||
const [runs, setRuns] = useState<AutomationRunItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [savingKey, setSavingKey] = useState<string | null>(null);
|
||||
const [runningKey, setRunningKey] = useState<string | null>(null);
|
||||
const [forms] = Form.useForm();
|
||||
const { can } = usePermissions();
|
||||
const canManage = can('automation:manage');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await automationApi.jobs();
|
||||
setJobs(data.jobs);
|
||||
setSchedulerEnabled(data.scheduler.enabled);
|
||||
setSchedulerRunning(data.scheduler.running);
|
||||
const history = await automationApi.runs({ limit: 50 });
|
||||
setRuns(history.items);
|
||||
// 统一回填表单:initialValue 只在首次渲染生效,刷新后不会跟着变。
|
||||
forms.setFieldsValue(
|
||||
Object.fromEntries(
|
||||
data.jobs.map((job) => [
|
||||
job.key,
|
||||
{
|
||||
enabled: job.enabled,
|
||||
interval_hours: secondsToHours(job.interval_seconds),
|
||||
batch_size: job.batch_size,
|
||||
concurrency: job.concurrency,
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [forms]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const saveJob = async (job: AutomationJobItem) => {
|
||||
const values = forms.getFieldValue(job.key) || {};
|
||||
setSavingKey(job.key);
|
||||
try {
|
||||
await automationApi.updateJob(job.key, {
|
||||
enabled: values.enabled,
|
||||
interval_seconds: Math.max(Math.round((values.interval_hours ?? 6) * 3600), 300),
|
||||
batch_size: values.batch_size,
|
||||
concurrency: values.concurrency,
|
||||
});
|
||||
message.success(`${job.title} 配置已保存`);
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
} finally {
|
||||
setSavingKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const runOnce = async (job: AutomationJobItem) => {
|
||||
setRunningKey(job.key);
|
||||
try {
|
||||
const outcome = await automationApi.runJob(job.key);
|
||||
message.success(
|
||||
`跑完一轮:扫描 ${outcome.scanned} 条,有效 ${outcome.valid},失效 ${outcome.invalid}`,
|
||||
);
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
} finally {
|
||||
setRunningKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const runColumns: ColumnsType<AutomationRunItem> = [
|
||||
{
|
||||
title: '开始时间',
|
||||
dataIndex: 'started_at',
|
||||
width: 180,
|
||||
render: (value: string | null) => formatTime(value),
|
||||
},
|
||||
{
|
||||
title: '作业',
|
||||
dataIndex: 'job_key',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '结果',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (value: string) => {
|
||||
const tag = STATUS_TAG[value] ?? { color: 'default', text: value || '-' };
|
||||
return <Tag color={tag.color}>{tag.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{ title: '扫描', dataIndex: 'scanned', width: 80 },
|
||||
{ title: '有效', dataIndex: 'valid', width: 80 },
|
||||
{
|
||||
title: '失效',
|
||||
dataIndex: 'invalid',
|
||||
width: 80,
|
||||
render: (value: number) => (value > 0 ? <Typography.Text type="warning">{value}</Typography.Text> : 0),
|
||||
},
|
||||
{ title: '耗时', width: 90, render: (_, row) => formatDuration(row.started_at, row.finished_at) },
|
||||
{ title: '说明', dataIndex: 'message', ellipsis: true },
|
||||
];
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Card
|
||||
title="自动巡检"
|
||||
extra={
|
||||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{!schedulerEnabled && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="调度器总开关未打开"
|
||||
description="服务端环境变量 AUTOMATION_ENABLED 未开启,打开下面的作业开关不会真正执行。需要先打开总开关并重启服务,再回到本页打开作业。"
|
||||
/>
|
||||
)}
|
||||
{schedulerEnabled && !schedulerRunning && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="调度线程未运行"
|
||||
description="总开关已开但调度线程不在运行状态,请检查服务日志或重启服务。"
|
||||
/>
|
||||
)}
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="巡检只检测、不改 CK"
|
||||
description="本作业只把失效账号找出来并标记,不会替换任何 Cookie、也不会改账号状态;重登仍由人工在「CK 检测与重登」页面操作。"
|
||||
/>
|
||||
|
||||
{jobs.map((job) => (
|
||||
<Card key={job.key} type="inner" title={job.title} style={{ marginBottom: 16 }}>
|
||||
<Descriptions size="small" column={2} style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="最近运行">
|
||||
{formatTime(job.last_run_at)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="下次运行">
|
||||
{formatTime(job.next_run_at)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="最近结果">
|
||||
{job.last_status ? (
|
||||
<Tag color={STATUS_TAG[job.last_status]?.color ?? 'default'}>
|
||||
{STATUS_TAG[job.last_status]?.text ?? job.last_status}
|
||||
</Tag>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="最近说明">{job.last_message || '-'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Form form={forms} layout="inline" onFinish={() => saveJob(job)}>
|
||||
<Form.Item
|
||||
name={[job.key, 'enabled']}
|
||||
label="启用"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch disabled={!canManage} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={[job.key, 'interval_hours']}
|
||||
label="巡检周期(小时)"
|
||||
>
|
||||
<InputNumber min={0.1} step={1} disabled={!canManage} />
|
||||
</Form.Item>
|
||||
<Form.Item name={[job.key, 'batch_size']} label="单轮条数">
|
||||
<InputNumber min={1} max={2000} disabled={!canManage} />
|
||||
</Form.Item>
|
||||
<Form.Item name={[job.key, 'concurrency']} label="并发">
|
||||
<InputNumber min={1} max={16} disabled={!canManage} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
disabled={!canManage}
|
||||
loading={savingKey === job.key}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="立刻跑一轮?"
|
||||
description="不看启用开关,立即执行一次巡检。"
|
||||
onConfirm={() => runOnce(job)}
|
||||
disabled={!canManage}
|
||||
>
|
||||
<Button
|
||||
icon={<ThunderboltOutlined />}
|
||||
disabled={!canManage}
|
||||
loading={runningKey === job.key}
|
||||
>
|
||||
立即跑一次
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
{!canManage && (
|
||||
<Typography.Text type="secondary">
|
||||
当前账号只能查看,修改配置需要 automation:manage 权限。
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<Card title="运行历史">
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={loading}
|
||||
columns={runColumns}
|
||||
dataSource={runs}
|
||||
pagination={{ pageSize: 20, showSizeChanger: false }}
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDuration(start: string | null, end: string | null): string {
|
||||
if (!start || !end) return '-';
|
||||
const ms = new Date(end).getTime() - new Date(start).getTime();
|
||||
if (!Number.isFinite(ms) || ms < 0) return '-';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
Reference in New Issue
Block a user