按 `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: []`), 已获得/未获得判定全部正确
255 lines
8.8 KiB
TypeScript
255 lines
8.8 KiB
TypeScript
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>
|
||
);
|
||
}
|