feat(login-tasks): 按状态导出 CSV + 登录失败写进账号生命周期

一、失败入账号一生(此前完全缺失)
生产实测:`login_ok` 1,456 条、`relogin_failed` 73 条,但**没有 login_failed**,
而 login_tasks 里 attempt_status='failed' 有 247 条 —— 这 247 次失败在账号时间线上
一条痕迹都没有,「这账号为什么一直登录不上」在账号页上无从回答。

- 新增事件类型 login_failed,在 `_mark_attempt_failed` 里写入
- **被停止 / 被中断不写**:分型把「已停止/中断/服务重启」归到 interrupted,
  那不是账号的问题 —— 否则运营按一次停止就能给几十个账号刷满假失败
- 事件带分型(captcha/credential/email_code/email_quota/network),
  同时落进 detail.kind,以后可直接按「验证码失败」筛账号
- 幂等键 login_task:{id}:failed:{ts},与成功路径同构

二、失败分型下沉成共用 service
`classify_failure` 原在 automation/jobs/ck_relogin.py,手动登录也要用。
挪到 services/login_failure.py(job 改为 import,`ck_relogin.classify_failure`
这个名字保留,旧调用方与用例不受影响),并提供型别中文标签与
`is_account_fault` 判据 —— 与仓库既有的「service 不该依赖 job」分层一致。

三、按状态导出 CSV
- 新增 GET /api/login/tasks/export:不分页,导出当前筛选的**全部**匹配记录;
  列:账号/状态/失败原因/失败分型/批次/最近尝试时间(UTC)/标签/归属客服
- 权限与筛选与列表**完全共用**,客服只导出自己名下账号
- 带 UTF-8 BOM(不加 Excel 打开中文表头是乱码),导出动作写审计

四、修一个口径不一致的真 bug
页面「失败 N」= failed + error 之和,但筛选只认 failed ——
所以截图里「失败 389」点进去只有 386 条(差的就是那几条 error);
「清空失败」同样漏删 error。现在把状态组收敛成一份 `_apply_status_filter`,
列表 / 导出 / 批量删除三处共用。

测试:684 passed, 6 skipped(本次新增 13 个);三条关键断言做了反向验证
(去掉 stopped 判断 / 失败组去掉 error / 去掉 BOM,对应用例均变红)。
This commit is contained in:
yml2213
2026-10-07 12:54:57 +08:00
parent 281769b440
commit d11fa1032d
8 changed files with 598 additions and 49 deletions
+14
View File
@@ -26,6 +26,20 @@ export const loginApi = {
listTasksPaged: (params: { page: number; page_size: number; batch_id?: string; status?: string }) =>
api.get<TaskPage, TaskPage>('/login/tasks', { params: { ...params, batch_id: params.batch_id ?? '' } }),
tasksSummary: () => api.get<TaskSummary, TaskSummary>('/login/tasks/summary'),
/**
* 导出登录任务 CSV(按当前筛选,不分页)。
*
* `status` 传 'failed' 时后端会展开成 failed + error —— 页面上「失败」
* 本来就是一个组,导出与列表口径必须一致。
*/
exportTasks: (params: { batch_id?: string; status?: string }) =>
api.get<Blob, Blob>('/login/tasks/export', {
responseType: 'blob',
params: {
batch_id: params.batch_id ?? '',
status: params.status ?? '',
},
}),
stop: (batch_id: string) => api.post<MessageResponse, MessageResponse>(`/login/stop/${batch_id}`),
deleteTask: (id: number) => api.delete<MessageResponse, MessageResponse>(`/login/tasks/${id}`),
deleteTasks: (ids: number[]) => api.delete<MessageDeletedResponse, MessageDeletedResponse>(`/login/tasks`, { params: { task_ids: ids.join(',') } }),
+45 -2
View File
@@ -1,11 +1,11 @@
import { useEffect, useState, useMemo, useCallback, useRef } from 'react';
import {
Table, Button, Select, Tag, Space, InputNumber, Tooltip, Popconfirm, theme, Modal, Form,
Table, Button, Select, Tag, Space, InputNumber, Tooltip, Popconfirm, theme, Modal, Form, Dropdown,
} from 'antd';
import { message } from '../utils/antdMessage';
import {
PlayCircleOutlined, StopOutlined, FilterOutlined, ReloadOutlined, DeleteOutlined,
SettingOutlined, SafetyCertificateOutlined,
SettingOutlined, SafetyCertificateOutlined, DownloadOutlined,
} from '@ant-design/icons';
import { accountApi, loginApi, type AccountItem, type LoginTaskItem, type TaskSummary } from '../api/modules';
import RealtimeLogPanel from '../components/RealtimeLogPanel';
@@ -391,6 +391,34 @@ export default function LoginTasksPage() {
}
};
/**
* 导出登录任务 CSV。
*
* `status` 传 undefined 表示「当前筛选」—— 与列表看到的完全是同一批;
* 传 'all' 表示不受筛选限制导出全部(后端把空串当不加条件)。
* 导出的是**全部匹配记录**,不是当前页。
*/
const handleExport = async (scope: 'current' | 'all' | 'success' | 'failed') => {
const status =
scope === 'current' ? taskStatus : scope === 'all' ? '' : scope;
try {
const blob = await loginApi.exportTasks({
batch_id: batchId || undefined,
status,
});
const url = URL.createObjectURL(blob instanceof Blob ? blob : new Blob([blob]));
const a = document.createElement('a');
a.href = url;
const label = scope === 'current' ? (taskStatus || 'all') : scope;
a.download = `login_tasks_${label}.csv`;
a.click();
URL.revokeObjectURL(url);
message.success('已导出,可用 Excel 打开(已带 BOM,中文不乱码)');
} catch (e: unknown) {
message.error(getErrorMessage(e));
}
};
// 一键清空当前筛选下的失败任务(跨页,不用先翻完所有页去勾)
const handleClearFailed = async () => {
try {
@@ -649,6 +677,21 @@ export default function LoginTasksPage() {
{ value: 'running', label: '进行中' },
]}
/>
<Dropdown
menu={{
items: [
{ key: 'current', label: taskStatus ? '导出当前筛选' : '导出全部' },
{ key: 'all', label: '导出全部(忽略筛选)' },
{ key: 'success', label: '只导出成功' },
{ key: 'failed', label: '只导出失败(含异常)' },
],
onClick: ({ key }) => void handleExport(key as 'current' | 'all' | 'success' | 'failed'),
}}
>
<Button size="small" icon={<DownloadOutlined />}>
导出 CSV
</Button>
</Dropdown>
<div style={{ flex: 1 }} />
{canBatch && (
<>