From 86ca2ed021686499d12282cf928d13f6d5e73fd3 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 15 Oct 2026 21:00:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(huya):=20=E5=BC=80=E9=80=9A=E5=85=85?= =?UTF-8?q?=E5=80=BC=E6=94=AF=E6=8C=81=E6=95=B0=E9=87=8F=E4=B8=8E=E8=AE=B0?= =?UTF-8?q?=E4=BD=8F=E4=B8=8A=E6=AC=A1=E9=80=89=E6=8B=A9=EF=BC=8C=E7=B2=BE?= =?UTF-8?q?=E8=8B=B1=E5=AE=9D=E5=85=B8=E5=8D=95=E7=8B=AC=E4=B8=80=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 支持「其他开通商品」按份数下单(payload.count → createOrder 的 item_count),数量上限取商品 buy_limit - 商品选择/数量写入 localStorage,下次进来还是上次那套 - 精英宝典(限购主商品)从其他开通商品里拆出来单独一块,避免混在一个下拉里选错 - 测试:payload.count=3 进入 run_open_flow 且结果/文案带 x3 --- tests/test_huya_elite_query.py | 94 ++++++++++++++++++ web/frontend/src/pages/HuyaElitePage.tsx | 118 ++++++++++++++++++++--- 2 files changed, 198 insertions(+), 14 deletions(-) diff --git a/tests/test_huya_elite_query.py b/tests/test_huya_elite_query.py index 6680e0f..8002669 100644 --- a/tests/test_huya_elite_query.py +++ b/tests/test_huya_elite_query.py @@ -421,6 +421,100 @@ def test_create_recharge_order_confirms_elite_open_after_payment(db, monkeypatch assert account.status == "recharge_paid" +def test_create_recharge_order_uses_requested_count(db, monkeypatch): + """开通数量:payload.count 按份数下单(前端「数量」输入框),结果与文案都带 xN。""" + + captured: dict = {} + outcome = SimpleNamespace( + ok=True, + message="", + detail={"spu_id": "hy-5872824", "sku_id": 7756812, "name": "精英徽章"}, + sku_id=7756812, + product_name="精英徽章", + unit_price=100, + order_raw={"order_id": 11399998}, + pay_raw={"pay_order_id": "pay-2"}, + amount=300, + order_id=11399998, + app_order_id="app-2", + pay_order_id="pay-2", + pay_url="https://example.com/pay", + supplier_name="和平精英官方小店", + promotion={}, + promotion_info="", + reused_order={}, + ) + + def fake_open_flow(_client, **kwargs): + captured.update(kwargs) + return outcome + + shop_client = _StubClient(redeem=_redeem(REDEEM_OPENED_HEX)) + runner = HuyaBatchRunner( + db=None, + batch_id="t", + task_type="create_recharge_order", + payload={ + "spu_id": "hy-5872824", + "sku_id": 7756812, + "product_name": "精英徽章", + "count": 3, + }, + ) + marks: list[tuple[str, str, dict | None]] = [] + monkeypatch.setattr( + runner, "_shop_client", lambda uid, cookie: shop_client, raising=False + ) + monkeypatch.setattr( + runner, + "_mark_task", + lambda _db, _task, status, message, result=None: marks.append( + (status, message, result) + ), + raising=False, + ) + monkeypatch.setattr( + runner, "_update_task_progress", lambda *args, **kwargs: None, raising=False + ) + monkeypatch.setattr( + runner, "_push_log", lambda *args, **kwargs: None, raising=False + ) + # 不真等支付:这一步只验证数量有没有进下单请求 + monkeypatch.setattr( + runner, + "_wait_recharge_payment", + lambda **kwargs: ("timeout", None), + raising=False, + ) + monkeypatch.setattr( + "web.backend.services.huya_runner_recharge.run_open_flow", fake_open_flow + ) + monkeypatch.setattr( + "web.backend.services.huya_runner_recharge.resolve_pay_targets", + lambda _pay_url, **_kwargs: PayTargets( + cashier_url="", qr_code="https://qr.alipay.com/upx03110ghym9vm37sx7255c" + ), + ) + account = HuyaAccount(uid=str(UID), cookie=COOKIE) + db.add(account) + db.commit() + + runner._execute_create_recharge_order( + db, + SimpleNamespace(id=588), + account, + {"uid": UID, "cookie": COOKIE}, + {"room_pid": "1199650619883", "pay_channel": "Zfb", "sid": "2203"}, + ) + + assert captured["count"] == 3 + _status, message, result = marks[-1] + assert result["count"] == 3 + assert result["amount"] == 300 + assert "x3" in message + assert account.status == "recharge_order_created" + + def test_query_all_combines_points_elite_and_role(monkeypatch): shop_client = _StubClient( redeem=_redeem(REDEEM_OPENED_HEX), order_pages=[[PAID_ELITE_ORDER]] diff --git a/web/frontend/src/pages/HuyaElitePage.tsx b/web/frontend/src/pages/HuyaElitePage.tsx index 72272ba..e49b217 100644 --- a/web/frontend/src/pages/HuyaElitePage.tsx +++ b/web/frontend/src/pages/HuyaElitePage.tsx @@ -22,6 +22,13 @@ const SCOPE = 'elite' as const; const ACT_ID = 25135; const DEFAULT_SID = '2203'; const LAYOUT_KEY = 'huya_elite_layout_mode'; +// 开通/充值:记住上次选的商品与数量(浏览器本地,换台机器不影响) +const RECHARGE_SPU_KEY = 'huya_elite_recharge_spu'; +const RECHARGE_COUNT_KEY = 'huya_elite_recharge_count'; +const ELITE_SPU_KEY = 'huya_elite_elite_spu'; +// 与后端 ELITE_HANDBOOK_SPU_IDS 对齐:精英宝典单独一块,不和其他开通商品混在一个下拉里 +const ELITE_HANDBOOK_SPU_IDS = ['hy-8328015', 'hy-5879340', 'hy-3845257', 'hy-2947756']; +const ELITE_HANDBOOK_NAME_KEYWORD = '精英宝典'; const TASK_LABELS: Record = { query_act_tasks: '读取宝典任务', get_bind_qr: '获取绑定二维码', query_game_name: '查询游戏角色', @@ -54,6 +61,37 @@ function savedInterval(): number { return Number.isInteger(value) && value >= 5 && value <= 60 ? value : 15; } +/** 上次选的开通商品(列表里已不存在时会自动回落到第一个)。 */ +function savedRechargeSpu(): string { + return localStorage.getItem(RECHARGE_SPU_KEY) || ''; +} + +/** 上次填的开通数量(后端 clamp 1~999)。 */ +function savedRechargeCount(): number { + const value = Number(localStorage.getItem(RECHARGE_COUNT_KEY)); + return Number.isInteger(value) && value >= 1 && value <= 999 ? value : 1; +} + +/** 上次选的精英宝典商品(就一个时基本用不上)。 */ +function savedEliteSpu(): string { + return localStorage.getItem(ELITE_SPU_KEY) || ''; +} + +/** 精英宝典(含换过 SKU 的历史 spu)走单独入口,其余商品走「其他开通商品」。 */ +function isEliteHandbookGood(item: HuyaRechargeGoodsItem): boolean { + return ELITE_HANDBOOK_SPU_IDS.includes(item.spu_id) || (item.name || '').includes(ELITE_HANDBOOK_NAME_KEYWORD); +} + +/** 开通商品顺序:跟虎牙页面一致的 raw_order,其次按 id。 */ +function byRechargeOrder(a: HuyaRechargeGoodsItem, b: HuyaRechargeGoodsItem): number { + return Number(a.raw?.raw_order ?? 0) - Number(b.raw?.raw_order ?? 0) || a.id - b.id; +} + +/** 开通商品的下拉/展示文案:名称 / 单价 / spu。 */ +function rechargeGoodsLabel(item: HuyaRechargeGoodsItem): string { + return `${item.name || item.spu_id} / ${item.price ? `${(item.price / 100).toFixed(2)}元` : '价格动态'} / ${item.spu_id}`; +} + function resultText(task: HuyaTaskItem | undefined, key: string): string { const value = task?.result?.[key]; return value == null ? '' : String(value); @@ -132,7 +170,11 @@ export default function HuyaElitePage() { const [goods, setGoods] = useState([]); const [selectedGoodsId, setSelectedGoodsId] = useState(''); const [rechargeGoods, setRechargeGoods] = useState([]); - const [selectedRechargeSpu, setSelectedRechargeSpu] = useState(''); + const [selectedRechargeSpu, setSelectedRechargeSpu] = useState(savedRechargeSpu); + // 精英宝典单独一块:和自己的商品选择分开记 + const [selectedEliteSpu, setSelectedEliteSpu] = useState(savedEliteSpu); + // 开通/充值数量:一个账号按这个份数下单(后端 payload.count,1~999),记住上次填的值 + const [rechargeCount, setRechargeCount] = useState(savedRechargeCount); const [search, setSearch] = useState(''); const [loading, setLoading] = useState(false); const [starting, setStarting] = useState(false); @@ -219,18 +261,56 @@ export default function HuyaElitePage() { setSelectedIds((prev) => prev.filter((id) => saved.account_ids.includes(id))); }; - const startTask = async (taskType: string, accountIds: number[] = selectedIds) => { + // 开通商品分组:精英宝典(限购主商品,单独一块)与其他商品(币/徽章/令…,带数量那一块) + const rechargeGoodsSorted = [...rechargeGoods].sort(byRechargeOrder); + const eliteRechargeGoods = rechargeGoodsSorted.filter(isEliteHandbookGood); + const otherRechargeGoods = rechargeGoodsSorted.filter((item) => !isEliteHandbookGood(item)); + const selectedEliteGoods = eliteRechargeGoods.find((item) => item.spu_id === selectedEliteSpu) || eliteRechargeGoods[0]; + const rechargeEliteLimit = Math.max(1, Math.min(999, selectedEliteGoods?.buy_limit || 999)); + + // 其他开通商品的数量:上限取商品的 buy_limit(限购商品只能 1 份), + // 下单时作为后端 payload.count(1~999,走 createOrder 的 item_count)。 + const selectedRechargeGoods = otherRechargeGoods.find((item) => item.spu_id === selectedRechargeSpu); + const rechargeBuyLimit = Math.max(1, Math.min(999, selectedRechargeGoods?.buy_limit || 999)); + const rechargeCountValue = Math.max(1, Math.min(rechargeCount, rechargeBuyLimit)); + const rechargeAmountText = selectedRechargeGoods?.price + ? `单价 ¥${(selectedRechargeGoods.price / 100).toFixed(2)} × ${rechargeCountValue}` + : ''; + + // 换商品:按新商品的限购把数量压回合法范围,并把这次选择写进浏览器,下次进来还是它 + const selectRechargeGoods = (spu: string) => { + const limit = Math.max(1, Math.min(999, otherRechargeGoods.find((item) => item.spu_id === spu)?.buy_limit || 999)); + const nextCount = Math.max(1, Math.min(rechargeCount, limit)); + setSelectedRechargeSpu(spu); + setRechargeCount(nextCount); + localStorage.setItem(RECHARGE_SPU_KEY, spu); + localStorage.setItem(RECHARGE_COUNT_KEY, String(nextCount)); + }; + const selectEliteGoods = (spu: string) => { + setSelectedEliteSpu(spu); + localStorage.setItem(ELITE_SPU_KEY, spu); + }; + const changeRechargeCount = (value: number | null) => { + const next = Math.max(1, Math.min(rechargeBuyLimit, Number(value) || 1)); + setRechargeCount(next); + localStorage.setItem(RECHARGE_COUNT_KEY, String(next)); + }; + + const startTask = async (taskType: string, accountIds: number[] = selectedIds, rechargeOverride?: { spu: string; count: number }) => { if (!accountIds.length) { message.warning('请先勾选账号'); return; } if (taskType === 'exchange_goods' && !selectedGoodsId) { message.warning('请先选择兑换商品'); return; } - if (taskType === 'create_recharge_order' && !selectedRechargeSpu) { message.warning('请先选择宝典商品'); return; } + if (taskType === 'create_recharge_order' && !rechargeOverride && !selectedRechargeSpu) { message.warning('请先选择开通商品'); return; } setStarting(true); try { const payload = taskType === 'exchange_goods' ? { sid: Number(config?.sid || DEFAULT_SID), product_id: Number(selectedGoodsId), act_id: ACT_ID } : taskType === 'query_act_tasks' ? { act_id: ACT_ID } : taskType === 'create_recharge_order' ? (() => { - const selected = rechargeGoods.find((item) => item.spu_id === selectedRechargeSpu); - return { spu_id: selectedRechargeSpu, sku_id: Number(selected?.sku_id || 0), product_name: selected?.name || '精英宝典' }; + // 精英宝典那块用 override 传自己的 spu(限购 1 份);其他商品用下拉选的 spu + 数量 + const spu = rechargeOverride?.spu || selectedRechargeSpu; + const count = rechargeOverride?.count ?? rechargeCountValue; + const selected = rechargeGoods.find((item) => item.spu_id === spu); + return { spu_id: spu, sku_id: Number(selected?.sku_id || 0), product_name: selected?.name || '精英宝典', count }; })() : {}; // 支付码要「同时弹出多个二维码」:并发必须 >= 账号数,否则排在后面的码要等前面的码付完/超时才开始生成 const batchConcurrency = taskType === 'create_recharge_order' @@ -309,14 +389,24 @@ export default function HuyaElitePage() { return accounts.filter((item) => !value || [item.uid, item.nickname, item.username, item.tag, item.game_name, item.game_phone].some((v) => String(v || '').toLowerCase().includes(value))); }, [accounts, search]); const filteredGoods = useMemo(() => goods.map((item) => ({ ...item, label: `${item.name} / ${item.price ?? '-'}分 / ${item.remain_text || '动态库存'}` })), [goods]); - const filteredRechargeGoods = useMemo(() => rechargeGoods.map((item) => ({ ...item, label: `${item.name || item.spu_id} / ${item.price ? `${(item.price / 100).toFixed(2)}元` : '价格动态'} / ${item.spu_id}` })).sort((a, b) => Number(a.raw?.raw_order ?? 0) - Number(b.raw?.raw_order ?? 0) || a.id - b.id), [rechargeGoods]); - // 默认选中列表里的第一个商品;已选商品从列表消失时同步回落到第一个 + // 精英宝典:通常只有一个商品;换过 SKU 或已下线时回落到当前的第一个(并记住回落结果) useEffect(() => { - if (!filteredRechargeGoods.length) return; - if (!filteredRechargeGoods.some((item) => item.spu_id === selectedRechargeSpu)) { - setSelectedRechargeSpu(filteredRechargeGoods[0].spu_id); + const list = [...rechargeGoods].sort(byRechargeOrder).filter(isEliteHandbookGood); + if (!list.length) return; + if (!list.some((item) => item.spu_id === selectedEliteSpu)) { + setSelectedEliteSpu(list[0].spu_id); + localStorage.setItem(ELITE_SPU_KEY, list[0].spu_id); } - }, [filteredRechargeGoods, selectedRechargeSpu]); + }, [rechargeGoods, selectedEliteSpu]); + // 其他开通商品:已选商品从列表消失时回落到第一个(并记住回落结果) + useEffect(() => { + const list = [...rechargeGoods].sort(byRechargeOrder).filter((item) => !isEliteHandbookGood(item)); + if (!list.length) return; + if (!list.some((item) => item.spu_id === selectedRechargeSpu)) { + setSelectedRechargeSpu(list[0].spu_id); + localStorage.setItem(RECHARGE_SPU_KEY, list[0].spu_id); + } + }, [rechargeGoods, selectedRechargeSpu]); const exchangeCount = tasks.filter((task) => task.task_type === 'exchange_goods').length; // 开通支付码:同一批次里每个账号一个码,未支付的排前面;已支付/已开通的只留一行状态 const payTasks = useMemo(() => { @@ -367,8 +457,8 @@ export default function HuyaElitePage() {
兑换
({ value: item.spu_id, label: item.label }))} placeholder="先刷新开通商品" style={{ width: '100%' }} />
+
开通宝典
{eliteRechargeGoods.length > 1 ? ({ value: item.spu_id, label: rechargeGoodsLabel(item) }))} placeholder="先刷新开通商品" style={{ width: '100%' }} />
数量{rechargeAmountText}{rechargeBuyLimit < 999 ? ` · 限购 ${rechargeBuyLimit} 份` : ''}
); @@ -409,7 +499,7 @@ export default function HuyaElitePage() {
{resultText(task, 'mini_qrcode_image') ? 支付码 : }
{payTaskLabel(task)}
UID {task.account_uid || '-'} -
{[resultText(task, 'product_name') || '精英宝典', resultText(task, 'amount_text')].filter(Boolean).join(' ')}
+
{[resultText(task, 'product_name') || '精英宝典', Number(resultText(task, 'count')) > 1 ? `×${resultText(task, 'count')}` : '', resultText(task, 'amount_text')].filter(Boolean).join(' ')}
{resultText(task, 'payment_status_label') || TASK_STATUS_LABELS[task.status] || task.status}
{task.message || '-'} {isLoopbackUrl(qrValue) &&
二维码是本机地址(localhost),手机扫不开
}