Files
live-hub-py/web/frontend/src/pages/douyu-tasks/AccountOverviewPanel.tsx
T
yml2213 d285196287 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: []`),
  已获得/未获得判定全部正确
2026-10-04 19:07:35 +08:00

255 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}