feat(huya): 开通支付码多选账号时每个账号各出一个码并排展示
多选账号点「生成开通支付码」只出一个码:create_planned_tasks 把 create_recharge_order 也当成单笔任务(accounts[:1]),前端本来就支持多码并排,是被后端截断的。 - 后端:create_planned_tasks 不再截断 create_recharge_order(refresh_* 仍只建一个,商品快照是全局的),N 个账号 = N 个支付码任务,各自独立下单/监听支付 - 前端:支付码任务的并发取 min(10, max(并发, 账号数)),否则超出并发的码要等前面的码付完/超时才生成 - 前端:支付弹窗改成网格并排展示多个 150px 码,标题显示待扫码数;新增「生成失败(没有二维码)」点名区和「还有 N 个账号在排队生成」提示 - 前端:去掉每个码回包就自动弹支付弹窗的行为(多选时会反复把弹窗顶到最前);不做付完自动跳转 - 测试:多账号生成支付码 → 每账号一个任务,refresh_recharge_goods 仍单任务
This commit is contained in:
@@ -1,7 +1,13 @@
|
||||
import pytest
|
||||
|
||||
from tests.support.db import get_test_engine, make_test_session
|
||||
from web.backend.models import HuyaAccount, HuyaWorkbench, HuyaWorkbenchAccount, User
|
||||
from web.backend.models import (
|
||||
HuyaAccount,
|
||||
HuyaTask,
|
||||
HuyaWorkbench,
|
||||
HuyaWorkbenchAccount,
|
||||
User,
|
||||
)
|
||||
from web.backend.routers.huya import (
|
||||
delete_account,
|
||||
list_huya_account_ids,
|
||||
@@ -108,6 +114,47 @@ class TestHuyaWorkbenchScope:
|
||||
)
|
||||
assert searched["account_ids"] == [no_cookie.id]
|
||||
|
||||
def test_recharge_order_tasks_created_for_every_account(self):
|
||||
"""多选账号生成支付码:每个账号一个任务(一个账号一张码,各自监听支付)。
|
||||
|
||||
商品快照类任务(refresh_*)仍然是全局的,多选也只建一个。
|
||||
"""
|
||||
other = HuyaAccount(uid="300", yyuid="300", cookie="yyuid=300; udb_uid=300")
|
||||
self.session.add(other)
|
||||
self.session.commit()
|
||||
account_ids = [self.account.id, other.id]
|
||||
|
||||
batch_id, count = create_planned_tasks(
|
||||
self.session,
|
||||
account_ids,
|
||||
"create_recharge_order",
|
||||
self.user.id,
|
||||
payload={"spu_id": "hy-8328015"},
|
||||
handbook_scope="elite",
|
||||
)
|
||||
|
||||
assert count == 2
|
||||
tasks = self.session.query(HuyaTask).filter(HuyaTask.batch_id == batch_id).all()
|
||||
assert len(tasks) == 2
|
||||
# 每个选中的账号都拿到一个码,而不是只有第一个
|
||||
assert sorted(task.account_id for task in tasks) == sorted(account_ids)
|
||||
assert {task.task_type for task in tasks} == {"create_recharge_order"}
|
||||
|
||||
snapshot_batch, snapshot_count = create_planned_tasks(
|
||||
self.session,
|
||||
account_ids,
|
||||
"refresh_recharge_goods",
|
||||
self.user.id,
|
||||
handbook_scope="elite",
|
||||
)
|
||||
assert snapshot_count == 1
|
||||
assert (
|
||||
self.session.query(HuyaTask)
|
||||
.filter(HuyaTask.batch_id == snapshot_batch)
|
||||
.count()
|
||||
== 1
|
||||
)
|
||||
|
||||
def test_account_delete_cleans_workbench_membership(self):
|
||||
self.session.add(
|
||||
HuyaWorkbenchAccount(
|
||||
|
||||
@@ -523,12 +523,10 @@ def create_planned_tasks(
|
||||
batch_id = uuid.uuid4().hex[:12]
|
||||
payload = payload or {}
|
||||
accounts = db.query(HuyaAccount).filter(HuyaAccount.id.in_(account_ids)).all()
|
||||
if (
|
||||
task_type
|
||||
in {"refresh_goods", "refresh_recharge_goods", "create_recharge_order"}
|
||||
and accounts
|
||||
):
|
||||
# 全局快照和单笔支付二维码使用一个选中的 CK 即可;兑换商品需要保留多账号批量任务。
|
||||
if task_type in {"refresh_goods", "refresh_recharge_goods"} and accounts:
|
||||
# 商品快照是全局的(不分账号),一个选中的 CK 就够了。
|
||||
# 注意:create_recharge_order 不能截断 —— 一个账号一张支付码,
|
||||
# 多选时要同时生成多个码各自监听支付(前端弹窗并排展示)。
|
||||
accounts = accounts[:1]
|
||||
for account in accounts:
|
||||
db.add(
|
||||
|
||||
@@ -232,7 +232,11 @@ export default function HuyaElitePage() {
|
||||
const selected = rechargeGoods.find((item) => item.spu_id === selectedRechargeSpu);
|
||||
return { spu_id: selectedRechargeSpu, sku_id: Number(selected?.sku_id || 0), product_name: selected?.name || '精英宝典' };
|
||||
})() : {};
|
||||
const created = await huyaApi.createTasks({ account_ids: accountIds, task_type: taskType, handbook_scope: SCOPE, concurrency, payload });
|
||||
// 支付码要「同时弹出多个二维码」:并发必须 >= 账号数,否则排在后面的码要等前面的码付完/超时才开始生成
|
||||
const batchConcurrency = taskType === 'create_recharge_order'
|
||||
? Math.min(10, Math.max(concurrency, accountIds.length))
|
||||
: concurrency;
|
||||
const created = await huyaApi.createTasks({ account_ids: accountIds, task_type: taskType, handbook_scope: SCOPE, concurrency: batchConcurrency, payload });
|
||||
setActiveBatches((prev) => [...new Set([...prev, created.batch_id])]);
|
||||
if (taskType === 'create_recharge_order') {
|
||||
// 多选账号时每个账号一个支付码:收进同一列表,弹窗里一起展示
|
||||
@@ -245,7 +249,8 @@ export default function HuyaElitePage() {
|
||||
const next = raw as unknown as HuyaTaskItem;
|
||||
setTasks((prev) => [next, ...prev.filter((item) => item.id !== next.id)].slice(0, 300));
|
||||
const result = next.result || {};
|
||||
if (next.task_type === 'create_recharge_order' && result.pay_url) setPayOpen(true);
|
||||
// 支付弹窗只在「生成开通支付码」和点账号行时打开:多选时每个码都会回包,
|
||||
// 这里再自动弹会把用户刚关掉的弹窗反复顶出来(码会在弹窗里自动补齐)。
|
||||
if (next.task_type === 'get_bind_qr' && (result.mini_qrcode_image || result.bind_redirect_url)) setQrTask(next);
|
||||
},
|
||||
onResult: () => { setActiveBatches((prev) => prev.filter((id) => id !== created.batch_id)); void load(); },
|
||||
@@ -322,6 +327,15 @@ export default function HuyaElitePage() {
|
||||
}, [tasks, payBatchIds]);
|
||||
const payPendingTasks = payTasks.filter(needsScanPay);
|
||||
const payDoneTasks = payTasks.filter((task) => !needsScanPay(task));
|
||||
// 多选账号时一次生成多个码:个别账号失败(限购、CK 失效…)要单独点名,否则少一个码看不出原因
|
||||
const payFailedTasks = useMemo(() => tasks.filter((task) => task.task_type === 'create_recharge_order'
|
||||
&& payBatchIds.includes(task.batch_id)
|
||||
&& ['failed', 'error', 'stopped'].includes(task.status)), [tasks, payBatchIds]);
|
||||
// 还在排队/生成中的码(并发不够时,后面的码要等前面的码付完或超时才轮到)
|
||||
const payQueuedCount = useMemo(() => tasks.filter((task) => task.task_type === 'create_recharge_order'
|
||||
&& payBatchIds.includes(task.batch_id)
|
||||
&& ['planned', 'pending', 'running'].includes(task.status)
|
||||
&& !resultText(task, 'pay_url')).length, [tasks, payBatchIds]);
|
||||
|
||||
const openPayTasks = (batchId?: string) => {
|
||||
if (batchId) setPayBatchIds((prev) => (prev.includes(batchId) ? prev : [...prev, batchId]));
|
||||
@@ -379,35 +393,37 @@ export default function HuyaElitePage() {
|
||||
<Modal open={qrTask !== null} title="绑定二维码" footer={null} onCancel={() => setQrTask(null)} centered>{qrTask && <div style={{ textAlign: 'center' }}>{resultText(qrTask, 'mini_qrcode_image') ? <img src={`data:image/png;base64,${resultText(qrTask, 'mini_qrcode_image')}`} alt="绑定二维码" style={{ width: 240, height: 240 }} /> : <QRCode value={resultText(qrTask, 'bind_redirect_url') || 'https://zt.huya.com/b02faae1/pc/index.html'} size={240} color="#000" bgColor="#fff" />}<p><Tag color={TASK_COLORS[qrTask.status] || 'default'}>{qrTask.message || qrTask.status}</Tag></p></div>}</Modal>
|
||||
<Modal
|
||||
open={payOpen}
|
||||
title={payTasks.length ? `开通支付码(${payDoneTasks.length}/${payTasks.length} 已完成)` : '开通支付码'}
|
||||
width={880}
|
||||
title={payTasks.length ? `开通支付码(${payPendingTasks.length} 个待扫码 · 已完成 ${payDoneTasks.length}/${payTasks.length})` : '开通支付码'}
|
||||
width={920}
|
||||
centered
|
||||
onCancel={() => setPayOpen(false)}
|
||||
footer={<Space><Text type="secondary">支付后会自动确认开通状态;已开通的码自动收起</Text><Button onClick={() => setPayOpen(false)}>关闭</Button><Button onClick={() => { setPayBatchIds([]); setPayOpen(false); }}>清空列表</Button></Space>}
|
||||
footer={<Space><Text type="secondary">多选账号会同时生成多个码并排展示,各自独立等待支付;支付后自动确认开通状态</Text><Button onClick={() => setPayOpen(false)}>关闭</Button><Button onClick={() => { setPayBatchIds([]); setPayOpen(false); }}>清空列表</Button></Space>}
|
||||
>
|
||||
{payTasks.length === 0 ? <div style={{ padding: 24, textAlign: 'center' }}><Text type="secondary">正在为选中的账号生成支付码…</Text></div> : <div style={{ maxHeight: '62vh', overflowY: 'auto' }}>
|
||||
{payPendingTasks.length > 0 && <Space direction="vertical" size={10} style={{ width: '100%' }}>
|
||||
{payQueuedCount > 0 && <div style={{ marginBottom: 10, textAlign: 'left' }}><Text type="secondary" style={{ fontSize: 12 }}>还有 {payQueuedCount} 个账号在排队生成支付码(并发不够时会等已出的码付完/超时,可调大右侧「并发」)</Text></div>}
|
||||
{payPendingTasks.length > 0 && <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(230px, 1fr))', gap: 12 }}>
|
||||
{payPendingTasks.map((task) => {
|
||||
const qrValue = payQrValue(task);
|
||||
return (
|
||||
<div key={task.id} style={{ display: 'flex', gap: 16, alignItems: 'center', border: '1px solid rgba(128,128,128,.22)', borderRadius: 6, padding: 10, textAlign: 'left' }}>
|
||||
<div style={{ background: '#fff', padding: 8, borderRadius: 6, lineHeight: 0 }}>{resultText(task, 'mini_qrcode_image') ? <img src={`data:image/png;base64,${resultText(task, 'mini_qrcode_image')}`} alt="支付码" style={{ width: 180, height: 180 }} /> : <QRCode value={qrValue} size={180} type="svg" errorLevel="L" bordered={false} color="#000" bgColor="#fff" />}</div>
|
||||
<Space direction="vertical" size={2} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text strong ellipsis>{payTaskLabel(task)}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>UID {task.account_uid || '-'}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{[resultText(task, 'product_name') || '精英宝典', resultText(task, 'amount_text')].filter(Boolean).join(' ')}</Text>
|
||||
<Tag color={TASK_COLORS[task.status] || 'default'}>{resultText(task, 'payment_status_label') || TASK_STATUS_LABELS[task.status] || task.status}</Tag>
|
||||
<Text type="secondary" ellipsis title={task.message}>{task.message || '-'}</Text>
|
||||
{isLoopbackUrl(qrValue) && <Text type="warning" style={{ fontSize: 12 }}>二维码是本机地址(localhost),手机扫不开;请用公网域名打开本页后重新生成</Text>}
|
||||
<Space size={6}>
|
||||
<Button size="small" icon={<ZoomInOutlined />} onClick={() => setZoomPayTask(task)}>放大扫码</Button>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => void copyPayLink(qrValue, '支付链接')}>复制链接</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
<div key={task.id} style={{ border: '1px solid rgba(128,128,128,.22)', borderRadius: 6, padding: 10, textAlign: 'center' }}>
|
||||
<div style={{ background: '#fff', padding: 6, borderRadius: 6, lineHeight: 0, display: 'inline-block' }}>{resultText(task, 'mini_qrcode_image') ? <img src={`data:image/png;base64,${resultText(task, 'mini_qrcode_image')}`} alt="支付码" style={{ width: 150, height: 150, objectFit: 'contain' }} /> : <QRCode value={qrValue} size={150} type="svg" errorLevel="L" bordered={false} color="#000" bgColor="#fff" />}</div>
|
||||
<div><Text strong ellipsis>{payTaskLabel(task)}</Text></div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>UID {task.account_uid || '-'}</Text>
|
||||
<div><Text type="secondary" style={{ fontSize: 12 }}>{[resultText(task, 'product_name') || '精英宝典', resultText(task, 'amount_text')].filter(Boolean).join(' ')}</Text></div>
|
||||
<div style={{ marginTop: 4 }}><Tag color={TASK_COLORS[task.status] || 'default'}>{resultText(task, 'payment_status_label') || TASK_STATUS_LABELS[task.status] || task.status}</Tag></div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }} ellipsis title={task.message}>{task.message || '-'}</Text>
|
||||
{isLoopbackUrl(qrValue) && <div><Text type="warning" style={{ fontSize: 12 }}>二维码是本机地址(localhost),手机扫不开</Text></div>}
|
||||
<div style={{ marginTop: 6 }}><Space size={6}><Button size="small" icon={<ZoomInOutlined />} onClick={() => setZoomPayTask(task)}>放大扫码</Button><Button size="small" icon={<CopyOutlined />} onClick={() => void copyPayLink(qrValue, '支付链接')}>复制链接</Button></Space></div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Space>}
|
||||
</div>}
|
||||
{payFailedTasks.length > 0 && <div style={{ marginTop: payPendingTasks.length ? 12 : 0, textAlign: 'left' }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>生成失败(没有二维码)</Text>
|
||||
<div style={{ marginTop: 6, display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{payFailedTasks.map((task) => <Tag key={task.id} color="error" title={task.message}>{payTaskLabel(task)} · {task.message || '生成失败'}</Tag>)}
|
||||
</div>
|
||||
</div>}
|
||||
{payDoneTasks.length > 0 && <div style={{ marginTop: payPendingTasks.length ? 12 : 0, textAlign: 'left' }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>已完成(二维码已收起)</Text>
|
||||
<div style={{ marginTop: 6, display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
|
||||
Reference in New Issue
Block a user