feat(douyu): 精英宝典支持查询开通状态,开通前先校验且不再误判到账

线上两个问题:工作台看不到账号有没有开通精英宝典;点开通时二维码会「闪一下
就变小」——大图弹窗 2~4 秒后自动关闭,只剩列表里 56px 的小二维码。

根因:
- getUserInfo 的 manualType/expireTime 才是开通状态的权威口径,但只有兑换
  流程用过一次,没有任务类型对外暴露;前端只看积分列,而「开通后积分花完」
  和「从未开通」都是 0 分,区分不了(实测 13456 manualType=1/score=0);
- 开通前不查状态,且到账判定写成「积分 >= 300」:账上本来就有 680 分的账号
  在出码后第一轮轮询就被判成功,前端按 paymentArrived 自动关掉了弹窗。

改动:
- 新增任务类型 query_elite_status:查 manualType/manualScore/expireTime 并
  落库到 accounts.elite_opened / elite_expire_time(迁移 20261001_0036),
  精英工作台加查询按钮与「宝典状态」列(已开通+到期 / 未开通);
- create_elite_qr 开通前先查状态:已开通直接判成功、不再生成支付码(方案 A),
  既消除二维码闪现,也避免给已开通账号开出真实支付单;
- 到账判定改为「积分比开通前增长」,基线用与轮询同源的 credit/balance 取,
  取不到基线才退回旧口径;
- 支付弹窗不再自动关闭:到账/失败/停止都在弹窗里给结论 + 手动关闭;列表小
  二维码在任务进终态后隐藏。

新增 9 条测试覆盖上述行为,回退 runner 时其中 8 条失败。
This commit is contained in:
yml2213
2026-10-01 19:08:25 +08:00
parent 4d87cdc16d
commit 99db772ddd
12 changed files with 546 additions and 25 deletions
+2
View File
@@ -340,6 +340,8 @@ export interface DouyuTaskAccountItem {
exchange_balance: number | null;
bind_status: string;
change_role_wait_time: number | null;
elite_opened: boolean | null;
elite_expire_time: number | null;
esports_points: number | null;
esports_game_name: string;
esports_game_channel: string;
@@ -43,7 +43,8 @@ import {
resultText,
taskPayloadText,
resultFlag,
taskQrUrl,
activeTaskQrUrl,
eliteStatusText,
bindReadyForConfirm,
hasBindQrcode,
hasPaymentQrcode,
@@ -74,6 +75,7 @@ function parseAccountNames(text: string): string[] {
export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind }) {
const isPeaceHandbook = handbook === 'peace';
const isEsportsHandbook = handbook === 'esports';
const isEliteHandbook = handbook === 'elite';
const { title: handbookTitle, description: handbookDescription, taskTypes: activeTaskTypes, quickActions } = HANDBOOK_META[handbook];
const { can } = usePermissions();
const { token } = theme.useToken();
@@ -665,10 +667,8 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
if (!payTask) return;
const latest = tasks.find((task) => task.id === payTask.id);
if (!latest) return;
if (paymentArrived(latest)) {
setPayTask(null);
return;
}
// 任务进终态后不再自动关闭弹窗:二维码刚弹出来就消失会让人以为"闪一下就没了",
// 改成在弹窗里显示结果,由用户手动关闭。
if (latest !== payTask) setPayTask(latest);
}, [payTask, tasks]);
@@ -1239,6 +1239,18 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
width: 80,
render: (v) => v ?? <Text type="secondary">-</Text>,
},
// 精英宝典开通状态:积分区分不了「开通后积分花完」和「从未开通」,单独展示
...(isEliteHandbook
? [{
title: '宝典状态', dataIndex: 'elite_opened', width: 150,
render: (_: unknown, record: DouyuTaskAccountItem) => {
const text = eliteStatusText(record);
if (!text) return <Text type="secondary">未查</Text>;
if (record.elite_opened) return <Tag color="green">{text}</Tag>;
return <Tag color="default">未开通</Tag>;
},
}] as NonNullable<TableProps<DouyuTaskAccountItem>['columns']>
: []),
// 和平小店无鱼翅/限制兑换/换绑时间概念(可无限制换绑),展示扭蛋碎片
...(isPeaceHandbook
? [{
@@ -1348,7 +1360,8 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
title: '二维码', width: 76, align: 'center',
render: (_, record) => {
const task = latestTaskByAccount.get(record.id);
const url = taskQrUrl(task);
// 终态任务(成功/失败/停止)的二维码不再有用,不展示小图
const url = activeTaskQrUrl(task);
if (!url) return null;
const openTask = () => {
if (!task) return;
@@ -1673,7 +1686,10 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
<span>开通、充值与送礼</span>
</div>
<Space direction="vertical" style={{ width: '100%' }} size={6}>
{renderActionButton('create_elite_qr', 'primary')}
<div style={actionGridStyle}>
{renderActionButton('query_elite_status')}
{renderActionButton('create_elite_qr', 'primary')}
</div>
<Tag color={isSupplierGoldRecharge ? 'green' : 'blue'}>{`当前渠道:${goldRechargeChannelLabel}`}</Tag>
<Space.Compact style={{ width: '100%' }}>
<InputNumber
@@ -2348,17 +2364,44 @@ export default function DouyuTasksPage({ handbook }: { handbook: HandbookKind })
{payAccountName && (
<Text type="secondary" style={{ marginBottom: 12, display: 'block' }}>{payAccountName}</Text>
)}
<Space direction="vertical" align="center" size={12}>
{payUrl ? (
<div ref={payQrCanvasWrapRef}>
<QRCode value={payUrl} size={220} bordered={false} />
</div>
) : (
<Text type="secondary">支付码生成中...</Text>
)}
<QrActions wrapRef={payQrCanvasWrapRef} url={payUrl} disabled={!payUrl} />
<Text>{payTask.message || '请扫码支付'}</Text>
</Space>
{paymentArrived(payTask) ? (
// 已到账/已开通:二维码不再有意义,留在弹窗里给结论 + 手动关闭
<Space direction="vertical" align="center" size={12}>
<Alert
type="success"
showIcon
message={payTask.task_type === 'create_elite_qr'
? '精英宝典已处理完成'
: payTask.task_type === 'create_esports_qr'
? '电竞手册已处理完成'
: '充值已到账'}
description={payTask.message || ''}
/>
<Button type="primary" onClick={() => setPayTask(null)}>关闭</Button>
</Space>
) : ['failed', 'error', 'stopped'].includes(payTask.status) ? (
<Space direction="vertical" align="center" size={12}>
<Alert
type={payTask.status === 'stopped' ? 'warning' : 'error'}
showIcon
message={payTask.status === 'stopped' ? '任务已停止' : '支付码任务未完成'}
description={payTask.message || ''}
/>
<Button onClick={() => setPayTask(null)}>关闭</Button>
</Space>
) : (
<Space direction="vertical" align="center" size={12}>
{payUrl ? (
<div ref={payQrCanvasWrapRef}>
<QRCode value={payUrl} size={220} bordered={false} />
</div>
) : (
<Text type="secondary">支付码生成中...</Text>
)}
<QrActions wrapRef={payQrCanvasWrapRef} url={payUrl} disabled={!payUrl} />
<Text>{payTask.message || '请扫码支付'}</Text>
</Space>
)}
</div>
)}
</Modal>
@@ -30,6 +30,7 @@ export const TASK_STATUS_LABELS: Record<string, string> = {
export const ELITE_QUICK_ACTIONS = [
{ key: 'get_bind_qr', icon: <QrcodeOutlined /> },
{ key: 'confirm_bind', icon: <CheckCircleOutlined /> },
{ key: 'query_elite_status', icon: <SearchOutlined /> },
{ key: 'create_elite_qr', icon: <CreditCardOutlined /> },
{ key: 'query_points', icon: <SearchOutlined /> },
{ key: 'query_game_name', icon: <SearchOutlined /> },
@@ -73,6 +74,7 @@ export const ELITE_TASK_TYPES = new Set([
'get_bind_qr',
'confirm_bind',
'create_elite_qr',
'query_elite_status',
'create_gold_qr',
'donate_elite_gift',
'query_points',
@@ -154,6 +156,30 @@ export function taskQrUrl(task: DouyuTaskItem | null | undefined): string {
return '';
}
// 任务已进终态后二维码不再有意义(支付已完成/已失败/已停止),列表里不再展示,
// 避免大图弹窗关闭后只剩一个小图,看起来像"二维码变小了还能继续付"。
const QR_TERMINAL_STATUSES = new Set(['success', 'failed', 'error', 'stopped']);
export function activeTaskQrUrl(task: DouyuTaskItem | null | undefined): string {
if (!task || QR_TERMINAL_STATUSES.has(task.status)) return '';
return taskQrUrl(task);
}
// 精英宝典开通状态展示:积分区分不了"开通后积分花完"和"从未开通",
// 以后端落库的 manualType(elite_opened)为准。
export function eliteStatusText(
account: { elite_opened?: boolean | null; elite_expire_time?: number | null },
): string {
if (account.elite_opened == null) return '';
if (!account.elite_opened) return '未开通';
const expire = account.elite_expire_time;
if (!expire || expire <= 0) return '已开通';
const at = new Date(expire * 1000);
if (Number.isNaN(at.getTime())) return '已开通';
const pad = (n: number) => String(n).padStart(2, '0');
return `已开通 ${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())} 到期`;
}
export function bindReadyForConfirm(task: DouyuTaskItem | null | undefined): boolean {
if (!task) return false;
if (task.task_type === 'get_xpd_bind_qr') {