feat(account): 账号「一键查询」(异步 + 批量)—— 含一次重要的判定修正

按 `docs/设计-账号一键查询.md` 实现。异步任务、支持批量(后端每账号一条任务)。

## 功能

- 新任务类型 `query_account_overview`:**只读**聚合查询,不改账号状态、不发事件
  (纯查询不是「事实」,按既有设计例行查询不进时间线,否则会刷屏)
- 一次拉全:鱼翅 / 钱包兑换余额 / 积分(含到期)/ 精英宝典 / 电竞手册 /
  **官方兑换记录** / 限兑商品,并**重点标出「兔兔白日梦」「狂战萌兔」是否已获得**
- **单项失败不整体失败**:账号没绑电竞手册是常态,失败只写进 `errors` 并在 UI
  **如实展示** —— 避免「没查到」被读成「没有」
- 批量:`create_douyu_planned_tasks` 本来就是每账号一条任务,前端加「批量一键查询」
- 前端:详情页一键查询卡片 + 结果面板(两个套装单独高亮);任务页结果自动弹面板;
  三个工作台的快捷操作/类型集合都加了

## ⚠️ 修正:判定「是否已获得」用错了依据

设计文档第一版说「`storeStatus == 0` → 已拥有」,依据是
「账号 558(兑换过)count=0,账号 15407(没兑换)count=96982」。

**这是错的。** 那两条记录分别取自 **2026-08-01** 和 **2026-10-04**,相隔两个月 ——
我把**时间差异**当成了**账号差异**。

**同一时刻**同时查 558 / 607(都兑换过)和 15507(没有),
商品项的**所有字段完全一致**,`preExchange.isExists` 也都是 0;
`count` 的取值分布是 0/98746/103350/109832…,**随时间变化 = 全局库存**。
用它判「这个账号有没有」必然给错结论 —— 而对高价值资产给错结论是本功能最不能接受的。

**正确依据:`exchange_records(manual_id)`**(斗鱼官方的**账号级**兑换记录)。
真实斗鱼实测:

| 账号 | 官方兑换记录 | 判定 |
| --- | --- | --- |
| 558 | 破片手榴弹-兔兔白日梦 | **已获得 ✅** |
| 607 | 破片手榴弹-兔兔白日梦 | **已获得 ✅** |
| 15507 | (0 条) | 未获得 ✅ |

同时刻三者看到的兔兔白日梦库存**都是 96956**(全局),兑换记录却区分得干干净净。

顺带发现:同一件东西有多个商品名(`套装-兔兔白日梦`、`破片手榴弹-兔兔白日梦`),
所以按**核心词子串**匹配整个名字家族。

商品列表的 `score`/`count`/活动时间仍展示,但明确是**全局**信息
(「现在还能不能换、要多少分」),与「该账号是否已获得」分开。

## 测试

`tests/test_account_overview.py` 22 条,含**回归用例**:
`test_global_stock_never_implies_owned`(全局售罄不能推出账号拥有)——
正是第一版踩的坑。另有失败隔离、只读性(不发事件/不改状态)、
批量每账号一条任务、`missing` 不被静默丢弃。

## 验证

- `TZ=Asia/Shanghai` **574 passed / 6 skipped**;`TZ=UTC` 27 passed
- ruff 全绿;前端 build 通过、lint 0 errors
- **真实斗鱼端到端**:三个账号各跑一次真查询,8 项全部成功(`errors: []`),
  已获得/未获得判定全部正确
This commit is contained in:
yml2213
2026-10-04 19:07:35 +08:00
parent 64dc9a9116
commit d285196287
10 changed files with 1191 additions and 23 deletions
+81 -2
View File
@@ -5,9 +5,12 @@ import {
} from 'antd';
import type { TableProps } from 'antd';
import {
ArrowLeftOutlined, EditOutlined, ReloadOutlined,
ArrowLeftOutlined, EditOutlined, ReloadOutlined, ThunderboltOutlined,
} from '@ant-design/icons';
import { accountApi, userApi, type AccountEventItem, type AccountOverview, type UserInfo } from '../api/modules';
import { accountApi, douyuApi, userApi, type AccountEventItem, type AccountOverview, type UserInfo } from '../api/modules';
import AccountOverviewPanel, {
type AccountOverviewResult,
} from './douyu-tasks/AccountOverviewPanel';
import { getErrorMessage } from '../utils/error';
import { message } from '../utils/antdMessage';
import { formatTime } from '../utils/time';
@@ -113,6 +116,10 @@ export default function AccountDetailPage() {
const [category, setCategory] = useState('');
const [eventsLoading, setEventsLoading] = useState(false);
const [tagDraft, setTagDraft] = useState('');
// 一键查询:向斗鱼实时查一次该账号的全部关键信息(异步任务 + 轮询结果)
const [ovLoading, setOvLoading] = useState(false);
const [ovResult, setOvResult] = useState<AccountOverviewResult | null>(null);
const [ovError, setOvError] = useState('');
const [tagEditing, setTagEditing] = useState(false);
const [supportUsers, setSupportUsers] = useState<UserInfo[]>([]);
const [remarkEditing, setRemarkEditing] = useState(false);
@@ -133,6 +140,44 @@ export default function AccountDetailPage() {
}, []);
// 一键查询:建一个只读聚合任务 → 轮询到出结果 → 渲染面板。
// 之所以用异步任务而不是同步接口:它要串好几个斗鱼请求(可能十几秒),
// 异步能复用现成的执行器与进度推送,也不受 HTTP 请求超时限制。
const runOverview = useCallback(async () => {
if (!id) return;
setOvLoading(true);
setOvError('');
try {
const created = await douyuApi.createTasks({
account_ids: [Number(id)],
task_type: 'query_account_overview',
handbook_scope: 'elite',
});
const batchId = created.batch_id;
const deadline = Date.now() + 90_000; // 最多等 90 秒
while (Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 2000));
const rows = await douyuApi.listTasks('elite', batchId);
const done = rows.find(
(x) => x.status === 'success' || x.status === 'failed',
);
if (done) {
if (done.status === 'failed') {
setOvError(done.message || '查询失败');
} else {
setOvResult((done.result ?? null) as AccountOverviewResult | null);
}
return;
}
}
setOvError('查询超时(90 秒未出结果),可到斗鱼工作台看这个任务的进度');
} catch (error) {
setOvError(getErrorMessage(error));
} finally {
setOvLoading(false);
}
}, [id]);
const loadOverview = useCallback(async () => {
if (!accountId) return;
setLoading(true);
@@ -324,6 +369,40 @@ export default function AccountDetailPage() {
1. 这账号现在能不能用?(状态 + CK)
2. 要不要我处理?(原因 + 变更时间 + 自动化是否在管)
3. 账上还有多少?(积分 / 鱼翅 / 兑换余额) */}
<Card
size="small"
style={{ marginBottom: 12 }}
title={
<Space>
<ThunderboltOutlined />
<span>一键查询(斗鱼实时)</span>
</Space>
}
extra={
<Button
type="primary"
icon={<ThunderboltOutlined />}
loading={ovLoading}
onClick={runOverview}
>
查询该账号全部信息
</Button>
}
>
{ovError && (
<Text type="danger" style={{ display: 'block', marginBottom: 8 }}>
{ovError}
</Text>
)}
{ovResult ? (
<AccountOverviewPanel result={ovResult} />
) : (
<Text type="secondary">
实时向斗鱼查询:鱼翅 / 积分(含到期时间)/ 精英宝典 / 电竞手册 / 官方兑换记录,
并重点标出「兔兔白日梦」「狂战萌兔」是否已拥有。
</Text>
)}
</Card>
<Card size="small" style={{ marginBottom: 12 }}>
<Space size={24} wrap align="start">
<Space direction="vertical" size={4}>
+38 -2
View File
@@ -6,8 +6,8 @@ import {
} from 'antd';
import { message } from '../utils/antdMessage';
import type { TableProps } from 'antd';
import { ImportOutlined, DeleteOutlined, TagOutlined, FilterOutlined, EyeOutlined, EyeInvisibleOutlined } from '@ant-design/icons';
import { accountApi, type AccountItem, type BasicSummary } from '../api/modules';
import { ImportOutlined, DeleteOutlined, TagOutlined, FilterOutlined, EyeOutlined, EyeInvisibleOutlined, ThunderboltOutlined } from '@ant-design/icons';
import { accountApi, douyuApi, type AccountItem, type BasicSummary } from '../api/modules';
import { usePermissions } from '../hooks/usePermissions';
import { formatTimeShort } from '../utils/time';
import { getErrorMessage } from '../utils/error';
@@ -250,6 +250,32 @@ export default function AccountsPage() {
}
};
// 批量一键查询:给每个勾选的账号建一条只读聚合任务(后端就是一个批次 N 条任务)。
// 结果在「斗鱼工作台」按批次查看,那里有专门的结果面板。
const [batchQuerying, setBatchQuerying] = useState(false);
const handleBatchOverview = async () => {
const ids = selectedRowKeys.map((key) => Number(key));
if (ids.length === 0) {
message.warning('请先勾选要查询的账号');
return;
}
setBatchQuerying(true);
try {
const created = await douyuApi.createTasks({
account_ids: ids,
task_type: 'query_account_overview',
handbook_scope: 'elite',
});
message.success(
`已创建 ${created.count} 条一键查询任务(批次 ${created.batch_id}),可到「斗鱼工作台 → 精英宝典」查看结果`,
);
} catch (error) {
message.error(getErrorMessage(error));
} finally {
setBatchQuerying(false);
}
};
const handleBatchDelete = async () => {
if (selectedCount === 0) {
message.warning('请先选择账号');
@@ -539,6 +565,16 @@ export default function AccountsPage() {
</Button>
</Popconfirm>
)}
<Tooltip title="实时向斗鱼查这些账号的鱼翅/积分/开通状态/兑换记录,并标出高价值套装是否已拥有">
<Button
disabled={selectedRowKeys.length === 0}
loading={batchQuerying}
icon={<ThunderboltOutlined />}
onClick={handleBatchOverview}
>
批量一键查询
</Button>
</Tooltip>
{canImport && (
<Button type="primary" icon={<ImportOutlined />} onClick={() => { setImportTag(''); setImportOpen(true); }}>
批量导入
@@ -0,0 +1,254 @@
import { Alert, Card, Descriptions, Empty, Space, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
const { Text } = Typography;
/** 一键查询的结果结构(后端 `query_account_overview` 写入 task.result)。 */
export interface HighValueGoods {
name: string;
commodity_id?: string;
score?: number | null;
count?: number | null;
store_status?: number | null;
count_limited?: number | null;
start_time?: string | number | null;
end_time?: string | number | null;
pic?: string;
/** **是否已获得** —— 依据是斗鱼官方的账号级兑换记录,不是全局库存 */
owned?: boolean | null;
/** 官方兑换记录里命中的商品名(可能是「破片手榴弹-兔兔白日梦」这类变体) */
obtained_names?: string[];
/** 该商品当前是否在限兑列表里(全局信息,与"是否已获得"无关) */
in_goods_list?: boolean;
/** 限兑列表和兑换记录里都没有这个商品 */
missing?: boolean;
reason?: string;
}
export interface AccountOverviewResult {
queried_at?: string;
assets?: {
gold_balance?: number | null;
exchange_balance?: number | null;
points?: number | null;
points_expire_at?: string | number | null;
};
elite?: { opened?: boolean | null; expire_time?: number | null; points?: number | null };
esports?: {
opened?: boolean | null;
manual_type?: number | null;
points?: number | null;
expire_time?: string | number | null;
};
high_value_goods?: HighValueGoods[];
limited_goods_total?: number;
exchange_records?: Array<Record<string, unknown>>;
exchange_record_count?: number;
/** 单项失败:**必须展示**,否则「没查到」会被读成「没有」 */
errors?: Record<string, string>;
}
const ERROR_LABELS: Record<string, string> = {
gold: '鱼翅',
points: '积分',
elite: '精英宝典',
esports: '电竞手册',
high_value_goods: '高价值套装',
exchange_records: '兑换记录',
};
/** 时间戳(秒/毫秒/字符串)→ 可读文本;认不出来就原样显示。 */
function formatTime(value: string | number | null | undefined): string {
if (value === null || value === undefined || value === '') return '-';
const text = String(value).trim();
let seconds: number | null = null;
if (/^\d+$/.test(text)) {
seconds = Number(text);
if (seconds > 10_000_000_000) seconds /= 1000;
}
if (seconds !== null) {
const d = new Date(seconds * 1000);
if (!Number.isNaN(d.getTime())) return d.toLocaleString('zh-CN');
return text;
}
const d = new Date(text);
return Number.isNaN(d.getTime()) ? text : d.toLocaleString('zh-CN');
}
function yesNo(value: boolean | null | undefined): string {
if (value === true) return '已开通';
if (value === false) return '未开通';
return '未知';
}
/**
* 高价值套装卡片。
*
* ⚠️ 措辞刻意不用「已拥有」:`storeStatus=0` 也可能是**全局售罄/活动下架**,
* 我们没法只凭它断言账号真的拿到了。所以写「不可再兑」并把 count 一起显示,
* 让运营自己判断 —— 这比给一个可能错的结论好。
*/
function HighValueCard({ goods }: { goods: HighValueGoods }) {
if (goods.missing) {
return (
<Card size="small" style={{ width: 280, borderStyle: 'dashed' }}>
<Space direction="vertical" size={2}>
<Text strong>{goods.name}</Text>
<Tag color="default">未查到该商品</Tag>
<Text type="secondary" style={{ fontSize: 12 }}>
{goods.reason || '限兑列表和官方兑换记录里都没有它'}
</Text>
</Space>
</Card>
);
}
// ⚠️ 「是否已获得」只看官方兑换记录。`count`/`store_status` 是**全局库存**,
// 同一时刻不同账号看到的都一样,**不能**用来判断这个账号有没有。
const owned = goods.owned === true;
return (
<Card
size="small"
style={{ width: 280, borderColor: owned ? '#52c41a' : undefined }}
title={<Text strong>{goods.name}</Text>}
extra={
<Tag color={owned ? 'green' : 'default'}>{owned ? '✅ 已获得' : '未获得'}</Tag>
}
>
<Descriptions size="small" column={1} colon={false}>
{owned && (
<Descriptions.Item label="记录">
{(goods.obtained_names || []).join('、') || '-'}
</Descriptions.Item>
)}
<Descriptions.Item label="积分价">{goods.score ?? '-'}</Descriptions.Item>
<Descriptions.Item label="当前库存">
{goods.count ?? '-'}
{goods.count === 0 ? '(已售罄)' : ''}
</Descriptions.Item>
<Descriptions.Item label="活动时间">
{formatTime(goods.start_time)} ~ {formatTime(goods.end_time)}
</Descriptions.Item>
</Descriptions>
{!goods.in_goods_list && (
<Text type="secondary" style={{ fontSize: 12 }}>
当前限兑列表里没有它(活动窗口可能已结束),但兑换记录仍在。
</Text>
)}
</Card>
);
}
export default function AccountOverviewPanel({ result }: { result: AccountOverviewResult }) {
if (!result) return <Empty description="暂无结果" />;
const assets = result.assets || {};
const errors = result.errors || {};
const goods = result.high_value_goods || [];
const records = result.exchange_records || [];
const recordColumns: ColumnsType<Record<string, unknown>> = [
{
title: '商品',
dataIndex: 'commodityName',
key: 'commodityName',
render: (v: unknown) => String(v ?? '-'),
},
{
title: '时间',
key: 'time',
render: (_, row) =>
formatTime(
(row.createTime ?? row.create_time ?? row.time) as string | number | null,
),
},
];
return (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{Object.keys(errors).length > 0 && (
<Alert
type="warning"
showIcon
message={`有 ${Object.keys(errors).length} 项没取到(不代表没有,只是这次没查到)`}
description={
<ul style={{ margin: 0, paddingLeft: 18 }}>
{Object.entries(errors).map(([key, msg]) => (
<li key={key}>
<Text strong>{ERROR_LABELS[key] || key}</Text>:{msg}
</li>
))}
</ul>
}
/>
)}
<Card size="small" title="资产">
<Descriptions size="small" column={2}>
<Descriptions.Item label="鱼翅">
{assets.gold_balance ?? '-'}
</Descriptions.Item>
<Descriptions.Item label="钱包兑换余额">
{assets.exchange_balance ?? '-'}
</Descriptions.Item>
<Descriptions.Item label="积分">{assets.points ?? '-'}</Descriptions.Item>
<Descriptions.Item label="积分到期">
{formatTime(assets.points_expire_at)}
</Descriptions.Item>
</Descriptions>
</Card>
<Card size="small" title="开通状态">
<Descriptions size="small" column={2}>
<Descriptions.Item label="精英宝典">
<Tag color={result.elite?.opened ? 'green' : 'default'}>
{yesNo(result.elite?.opened)}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="宝典到期">
{formatTime(result.elite?.expire_time)}
</Descriptions.Item>
<Descriptions.Item label="电竞手册">
<Tag color={result.esports?.opened ? 'green' : 'default'}>
{yesNo(result.esports?.opened)}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="电竞积分">
{result.esports?.points ?? '-'}
</Descriptions.Item>
</Descriptions>
</Card>
<Card size="small" title="🎯 高价值套装">
{goods.length === 0 ? (
<Text type="secondary">没有配置关注的高价值套装,或本次没取到</Text>
) : (
<Space wrap size={12}>
{goods.map((g) => (
<HighValueCard key={g.commodity_id || g.name} goods={g} />
))}
</Space>
)}
</Card>
<Card
size="small"
title={`兑换记录${result.exchange_record_count ? `(${result.exchange_record_count} 条)` : ''}`}
>
{records.length === 0 ? (
<Text type="secondary">官方暂无兑换记录</Text>
) : (
<Table
size="small"
rowKey={(_, i) => String(i)}
columns={recordColumns}
dataSource={records}
pagination={{ pageSize: 10, size: 'small' }}
/>
)}
</Card>
<Text type="secondary" style={{ fontSize: 12 }}>
查询时间:{formatTime(result.queried_at)}
</Text>
</Space>
);
}
@@ -7,6 +7,7 @@ import {
CheckCircleOutlined, ColumnWidthOutlined, CopyOutlined, CreditCardOutlined, ExclamationCircleOutlined, FieldTimeOutlined, FilterOutlined, GiftOutlined,
ImportOutlined, QrcodeOutlined, ReloadOutlined,
SearchOutlined, SettingOutlined, ShoppingOutlined, StopOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import {
accountApi,
@@ -18,6 +19,9 @@ import {
} from '../../api/modules';
import ExchangeResultImage from '../../components/ExchangeResultImage';
import QrActions from '../../components/QrActions';
import AccountOverviewPanel, {
type AccountOverviewResult,
} from './AccountOverviewPanel';
import { usePermissions } from '../../hooks/usePermissions';
import { useWebSocketLogs } from '../../hooks/useWebSocketLogs';
import {
@@ -149,6 +153,9 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
const autoOpenedPayTaskIds = useRef<Set<number>>(new Set());
const autoCopiedExchangeTaskIds = useRef<Set<number>>(new Set());
const autoNotifiedConfirmFailedTaskIds = useRef<Set<number>>(new Set());
// 一键查询:任务跑完后自动把结果面板弹出来(去重,同一个任务只弹一次)
const [ovPanelTask, setOvPanelTask] = useState<DouyuTaskItem | null>(null);
const shownOvTaskIds = useRef<Set<number>>(new Set());
const [confirmFailTip, setConfirmFailTip] = useState<{ taskId: number; message: string } | null>(null);
const [confirmFailCountdown, setConfirmFailCountdown] = useState(0);
const [exchangePreview, setExchangePreview] = useState<{ task: DouyuTaskItem; url: string } | null>(null);
@@ -642,6 +649,22 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
}
};
useEffect(() => {
// 一键查询的结果是一大块结构化信息,不适合塞进列表单元格 ——
// 跑完就弹面板,弹过就不再弹(任务会被反复轮询到,不去重会一直弹)。
const finished = tasks.filter(
(task) =>
task.task_type === 'query_account_overview'
&& task.status === 'success'
&& task.result,
);
const fresh = finished.find((task) => !shownOvTaskIds.current.has(task.id));
if (fresh) {
shownOvTaskIds.current.add(fresh.id);
setOvPanelTask(fresh);
}
}, [tasks]);
// qrTask 已由 useMemo 从 tasks 派生,切换 Tab / 后端 progress 写入后自动更新,无需手动跟随
useEffect(() => {
@@ -2537,6 +2560,19 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
</Text>
</div>
</Modal>
<Modal
title={<Space><ThunderboltOutlined /><span>一键查询结果</span></Space>}
open={Boolean(ovPanelTask)}
onCancel={() => setOvPanelTask(null)}
footer={<Button onClick={() => setOvPanelTask(null)}>关闭</Button>}
width={780}
styles={{ body: { maxHeight: '70vh', overflowY: 'auto' } }}
>
{ovPanelTask?.result ? (
<AccountOverviewPanel result={ovPanelTask.result as AccountOverviewResult} />
) : null}
</Modal>
</div>
);
}
@@ -1,6 +1,7 @@
import type { ReactNode } from 'react';
import {
CheckCircleOutlined, CreditCardOutlined, FieldTimeOutlined, GiftOutlined,
ProfileOutlined,
QrcodeOutlined, ReloadOutlined, SearchOutlined, ShoppingOutlined,
} from '@ant-design/icons';
import type { DouyuGoodsItem, DouyuTaskItem } from '../../api/modules';
@@ -38,6 +39,7 @@ export const ELITE_QUICK_ACTIONS = [
{ key: 'query_limited_goods', icon: <SearchOutlined /> },
{ key: 'query_gold_balance', icon: <SearchOutlined /> },
{ key: 'lock_goods', icon: <ShoppingOutlined /> },
{ key: 'query_account_overview', icon: <ProfileOutlined /> },
{ key: 'exchange_goods', icon: <ShoppingOutlined /> },
{ key: 'create_gold_qr', icon: <CreditCardOutlined /> },
{ key: 'donate_elite_gift', icon: <GiftOutlined /> },
@@ -52,6 +54,7 @@ export const ESPORTS_QUICK_ACTIONS = [
{ key: 'query_limited_goods', icon: <SearchOutlined /> },
{ key: 'query_gold_balance', icon: <SearchOutlined /> },
{ key: 'refresh_esports_goods', icon: <ReloadOutlined /> },
{ key: 'query_account_overview', icon: <ProfileOutlined /> },
{ key: 'exchange_esports_goods', icon: <ShoppingOutlined /> },
{ key: 'create_gold_qr', icon: <CreditCardOutlined /> },
{ key: 'donate_esports_chicken_gift', icon: <GiftOutlined /> },
@@ -66,6 +69,7 @@ export const PEACE_QUICK_ACTIONS = [
{ key: 'query_xpd_balance', icon: <SearchOutlined /> },
{ key: 'query_xpd_fragments', icon: <SearchOutlined /> },
{ key: 'query_xpd_purchase_records', icon: <FieldTimeOutlined /> },
{ key: 'query_account_overview', icon: <ProfileOutlined /> },
{ key: 'refresh_xpd_goods', icon: <ReloadOutlined /> },
{ key: 'exchange_xpd_goods', icon: <ShoppingOutlined /> },
];
@@ -88,6 +92,7 @@ export const ELITE_TASK_TYPES = new Set([
'refresh_goods',
'query_exchange_records',
'prefetch_csrf_token',
'query_account_overview',
]);
export const ESPORTS_TASK_TYPES = new Set([
'prepare_esports_bind',
@@ -104,6 +109,7 @@ export const ESPORTS_TASK_TYPES = new Set([
'create_gold_qr',
'donate_esports_chicken_gift',
'donate_esports_firework_gift',
'query_account_overview',
]);
export const PEACE_TASK_TYPES = new Set([
'get_xpd_bind_qr',
@@ -115,6 +121,7 @@ export const PEACE_TASK_TYPES = new Set([
'query_xpd_fragments',
'query_xpd_purchase_records',
'exchange_xpd_goods',
'query_account_overview',
]);
export const DOUYU_GOLD_AMOUNT_STORAGE_KEY = 'douyu_task_gold_amount';
export const DOUYU_GIFT_COUNT_STORAGE_KEY = 'douyu_task_gift_count';