diff --git a/tests/test_huya_elite_query.py b/tests/test_huya_elite_query.py index d3f35ac..cf1be08 100644 --- a/tests/test_huya_elite_query.py +++ b/tests/test_huya_elite_query.py @@ -10,6 +10,7 @@ """ import json +from types import SimpleNamespace from core.huya.frame_decoder import decode_struct_bytes from core.huya.http_client import HuyaHttpClient @@ -117,9 +118,11 @@ class _StubClient: self.order_pages = order_pages or [] self.season_payload = ALLOCATION_SEASON_PAYLOAD if season else None self.calls: list[tuple[str, dict]] = [] + self.redeem_calls = 0 def get_redeem_need_goods(self, **kwargs): self.calls.append(("get_redeem_need_goods", kwargs)) + self.redeem_calls += 1 return self.redeem def play_mall_query_allocation(self, **kwargs): @@ -252,6 +255,156 @@ def test_query_all_fills_expire_time_from_season(monkeypatch): assert "有效期至 2026-12-31" in message +def test_refresh_elite_open_after_payment_retries_until_opened(): + """刚支付完 tag5 可能还是 0(交付延迟)→ 重试到已开通,并写回账号。""" + + class _FlakyClient(_StubClient): + def get_redeem_need_goods(self, **kwargs): + result = super().get_redeem_need_goods(**kwargs) + if self.redeem_calls > 1: + return _redeem(REDEEM_OPENED_HEX) + return result + + client = _FlakyClient(season=True) + runner = HuyaBatchRunner(db=None, batch_id="t", task_type="create_recharge_order") + account = HuyaAccount(uid=str(UID), cookie=COOKIE) + + state = runner._refresh_elite_open_after_payment( + client, + account, + UID, + COOKIE, + goods_num=8315982, + pay_time_ms=1792053457000, + attempts=3, + interval=0, + ) + + assert client.redeem_calls == 2 # 第一次未开通 → 重试一次即成功 + assert state["elite_opened"] is True + assert state["elite_open_time"] == 1792053457 + assert state["elite_expire_time"] == 1798732799 + assert state["redeem_points"] == 280 + assert account.elite_opened is True + assert account.elite_open_time == 1792053457 + assert account.elite_expire_time == 1798732799 + + +def test_refresh_elite_open_after_payment_gives_up_after_attempts(): + client = _StubClient(redeem=_redeem(REDEEM_NOT_OPENED_HEX)) + runner = HuyaBatchRunner(db=None, batch_id="t", task_type="create_recharge_order") + account = HuyaAccount(uid=str(UID), cookie=COOKIE) + + state = runner._refresh_elite_open_after_payment( + client, + account, + UID, + COOKIE, + goods_num=8315982, + pay_time_ms=1792053457000, + attempts=2, + interval=0, + ) + + assert client.redeem_calls == 2 # 重试到上限 + assert state["elite_opened"] is False + assert account.elite_opened is False + assert account.elite_open_time is None + + +def test_create_recharge_order_confirms_elite_open_after_payment(db, monkeypatch): + """支付成功后自动确认开通状态:任务结果与账号字段都带上宝典状态。""" + + outcome = SimpleNamespace( + ok=True, + message="", + detail={"spu_id": "hy-8328015", "sku_id": 7756811, "name": "精英宝典"}, + sku_id=7756811, + product_name="精英宝典", + unit_price=3000, + order_raw={"order_id": 11399999}, + pay_raw={"pay_order_id": "pay-1"}, + amount=3000, + order_id=11399999, + app_order_id="app-1", + pay_order_id="pay-1", + pay_url="https://example.com/pay", + supplier_name="和平精英官方小店", + promotion={}, + promotion_info="本单无返利", + ) + + shop_client = _StubClient(redeem=_redeem(REDEEM_OPENED_HEX), season=True) + runner = HuyaBatchRunner( + db=None, + batch_id="t", + task_type="create_recharge_order", + payload={"spu_id": "hy-8328015", "sku_id": 7756811, "count": 1}, + ) + 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 + ) + + def fake_wait_payment(**kwargs): + # 真实现会边轮询边写 result,这里照抄「已支付」那几个字段 + target = kwargs.get("result") + if isinstance(target, dict): + target.update( + { + "payment_status": "paid", + "payment_status_label": "已支付", + "payment_paid": True, + } + ) + return "paid", {"pay_time": 1792053457000} + + monkeypatch.setattr( + runner, "_wait_recharge_payment", fake_wait_payment, raising=False + ) + monkeypatch.setattr( + "web.backend.services.huya_runner_recharge.run_open_flow", + lambda *args, **kwargs: outcome, + ) + account = HuyaAccount(uid=str(UID), cookie=COOKIE) + db.add(account) + db.commit() + + runner._execute_create_recharge_order( + db, + None, + account, + {"uid": UID, "cookie": COOKIE}, + {"room_pid": "1199650619883", "pay_channel": "Zfb", "sid": "2203"}, + ) + + status, _message, result = marks[-1] + assert status == "success" + assert result["payment_paid"] is True + assert result["elite_opened"] is True + assert result["elite_open_time"] == 1792053457 + assert result["elite_expire_time"] == 1798732799 + assert result["post_payment_points"] == 280 + assert account.elite_opened is True + assert account.elite_expire_time == 1798732799 + assert account.points == 280 + assert account.status == "recharge_paid" + + 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/backend/services/huya_runner_elite.py b/web/backend/services/huya_runner_elite.py index 48e9b49..cc66c52 100644 --- a/web/backend/services/huya_runner_elite.py +++ b/web/backend/services/huya_runner_elite.py @@ -14,6 +14,7 @@ from __future__ import annotations +import time from datetime import UTC, datetime from typing import TYPE_CHECKING, Any @@ -168,6 +169,62 @@ class EliteMixin: return None return self._extract_elite_season(getattr(resp, "payload", None)) + def _refresh_elite_open_after_payment( + self, + client: Any, + account: HuyaAccount, + uid: int, + cookie: str, + *, + goods_num: int, + pay_time_ms: int | None = None, + attempts: int = 4, + interval: float = 3.0, + ) -> dict: + """支付完成后确认宝典是否已开通,并写回账号。 + + 交付有几秒延迟,所以 ``tag5`` 还没翻成 1 时会重试(默认 4 次 × 3 秒)。 + 返回可直接并入任务结果的 dict(含 ``redeem_points``,省掉一次重复查询)。 + """ + redeem = None + opened: bool | None = None + total = max(1, attempts) + for index in range(total): + redeem = client.get_redeem_need_goods( + uid=uid, guid="", cookie=cookie, goods_num=int(goods_num or 0) + ) + opened = self._elite_opened_from_redeem(redeem) + if opened or index == total - 1: + break + time.sleep(interval) + + state: dict[str, Any] = {"elite_checked_at": datetime.now(UTC).isoformat()} + if redeem is not None and getattr(redeem, "code", 0) == 200: + state["redeem"] = redeem.to_dict() + state["redeem_points"] = self._to_int(redeem.value("tag2")) + if opened is None: + return state + + open_time: int | None = pay_time_ms or None + if opened and not open_time: + open_time = self._resolve_elite_open_time(client, uid, cookie) + season = self._resolve_elite_season(client, uid, cookie) if opened else None + expire_time = self._to_int(season["end_ms"]) if season else None + self._apply_elite_state(account, opened, open_time, expire_time) + + state["elite_opened"] = opened + state["elite_open_time"] = (open_time // 1000) if open_time else None + state["elite_open_time_text"] = ( + self._format_local_time(open_time // 1000) if open_time else "" + ) + if season: + state["elite_season"] = season + state["elite_expire_time"] = expire_time // 1000 + state["elite_expire_time_text"] = self._format_local_time( + expire_time // 1000 + ) + return state + def _apply_elite_state( self, account: HuyaAccount, diff --git a/web/backend/services/huya_runner_recharge.py b/web/backend/services/huya_runner_recharge.py index 4c1fc9f..285fe4d 100644 --- a/web/backend/services/huya_runner_recharge.py +++ b/web/backend/services/huya_runner_recharge.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any from sqlalchemy.orm import Session -from core.huya.wss_client import SHOP_APP_SCENE +from core.huya.wss_client import POINTS_REDEEM_GOODS_NUM, SHOP_APP_SCENE from ..models import HuyaAccount, HuyaRechargeGoodsSnapshot, HuyaTask from .huya_shop_flow import run_open_flow @@ -91,6 +91,18 @@ class RechargeMixin: def _update_task_progress(self, *args: Any, **kwargs: Any) -> None: ... def _open_wss_session(self, uid: int, cookie: str, kind: str): ... def _shop_client(self, uid: int, cookie: str): ... + def _refresh_elite_open_after_payment( + self, + client: Any, + account: HuyaAccount, + uid: int, + cookie: str, + *, + goods_num: int, + pay_time_ms: int | None = None, + attempts: int = 4, + interval: float = 3.0, + ) -> dict: ... @staticmethod def _format_local_time(timestamp: int) -> str: ... @@ -561,12 +573,25 @@ class RechargeMixin: account.updated_at = datetime.now(UTC) if payment_status == "paid": account.status = "recharge_paid" - # 支付后刷新积分:旧活动接口 getUserScore 已下线(603 活动已结束), - # 改走积分中心 getRedeemNeedGoods(tag2 = 当前积分)。 - post_redeem = client.get_redeem_need_goods(uid=uid, guid="", cookie=cookie) - if post_redeem is not None and post_redeem.code == 200: - result["post_payment_points"] = self._to_int(post_redeem.value("tag2")) - account.points = result["post_payment_points"] + # 支付后自动确认宝典开通状态(顺带取最新积分):交付有几秒延迟, + # _refresh_elite_open_after_payment 会重试到 tag5 变成已开通。 + elite_state = self._refresh_elite_open_after_payment( + client, + account, + uid, + cookie, + goods_num=self._to_int(self.payload.get("goods_num")) + or POINTS_REDEEM_GOODS_NUM, + pay_time_ms=( + self._to_int(payment_order.get("pay_time")) + if payment_order + else None + ), + ) + result.update(elite_state) + if "redeem_points" in elite_state: + result["post_payment_points"] = elite_state["redeem_points"] + account.points = elite_state["redeem_points"] paid_message = f"支付成功: {product_name} x{count} {result['amount_text']}" if payment_order and payment_order.get("pay_time"): paid_message += f",支付时间 {self._format_local_time(int(payment_order['pay_time']) // 1000)}" diff --git a/web/frontend/src/pages/HuyaElitePage.tsx b/web/frontend/src/pages/HuyaElitePage.tsx index 1f0a8c2..e957927 100644 --- a/web/frontend/src/pages/HuyaElitePage.tsx +++ b/web/frontend/src/pages/HuyaElitePage.tsx @@ -59,6 +59,12 @@ function resultText(task: HuyaTaskItem | undefined, key: string): string { return value == null ? '' : String(value); } +/** 开通支付码是否还需要扫码:已支付或已确认开通就不用再展示了 */ +function needsScanPay(task: HuyaTaskItem): boolean { + const result = task.result || {}; + return !(result.payment_paid === true || result.elite_opened === true); +} + function latestTask(tasks: HuyaTaskItem[], accountId: number): HuyaTaskItem | undefined { return tasks.filter((task) => task.account_id === accountId).sort((a, b) => b.id - a.id)[0]; } @@ -93,6 +99,9 @@ export default function HuyaElitePage() { const [importTags, setImportTags] = useState([]); const [importSelected, setImportSelected] = useState([]); const [qrTask, setQrTask] = useState(null); + // 开通支付码:多选账号时可以一次生成多个码,弹窗里逐个展示(按批次收集) + const [payBatchIds, setPayBatchIds] = useState([]); + const [payOpen, setPayOpen] = useState(false); const [ckImportOpen, setCkImportOpen] = useState(false); const [config, setConfig] = useState(null); const [configDraft, setConfigDraft] = useState(null); @@ -176,13 +185,18 @@ export default function HuyaElitePage() { })() : {}; const created = await huyaApi.createTasks({ account_ids: accountIds, task_type: taskType, handbook_scope: SCOPE, concurrency, payload }); setActiveBatches((prev) => [...new Set([...prev, created.batch_id])]); + if (taskType === 'create_recharge_order') { + // 多选账号时每个账号一个支付码:收进同一列表,弹窗里一起展示 + setPayBatchIds((prev) => [...new Set([...prev, created.batch_id])]); + setPayOpen(true); + } logs.connectBatch(created.batch_id, `/api/huya/ws/${created.batch_id}`, { clear: false, onTask: (raw) => { 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' && typeof result.pay_url === 'string' && result.pay_url) setQrTask(next); + 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(); }, @@ -250,6 +264,21 @@ export default function HuyaElitePage() { } }, [filteredRechargeGoods, selectedRechargeSpu]); const exchangeCount = tasks.filter((task) => task.task_type === 'exchange_goods').length; + // 开通支付码:同一批次里每个账号一个码,未支付的排前面;已支付/已开通的只留一行状态 + const payTasks = useMemo(() => { + const list = tasks.filter((task) => task.task_type === 'create_recharge_order' + && payBatchIds.includes(task.batch_id) + && Boolean(resultText(task, 'pay_url') || resultText(task, 'mini_qrcode_image'))); + return [...list].sort((a, b) => Number(needsScanPay(b)) - Number(needsScanPay(a)) || a.id - b.id); + }, [tasks, payBatchIds]); + const payPendingTasks = payTasks.filter(needsScanPay); + const payDoneTasks = payTasks.filter((task) => !needsScanPay(task)); + + const openPayTasks = (batchId?: string) => { + if (batchId) setPayBatchIds((prev) => (prev.includes(batchId) ? prev : [...prev, batchId])); + setPayOpen(true); + }; + const payTaskLabel = (task: HuyaTaskItem) => task.account_nickname || task.account_uid || `#${task.account_id}`; // 列宽按比例分配(percent),表头与单元格统一居中,避免左右跳着看 const accountColumns: TableProps['columns'] = [ @@ -276,7 +305,7 @@ export default function HuyaElitePage() { ); - const accountsBody = <>} placeholder="搜索账号/昵称/游戏名/手机号" value={search} onChange={(event) => setSearch(event.target.value)} style={{ width: 240 }} />{canImport && }{selectedIds.length > 0 && }
setSelectedIds(keys.map(Number)) }} columns={accountColumns} dataSource={filteredAccounts} locale={{ emptyText: '工作台暂无账号,点击右上角「导入账号」添加' }} pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (total) => `共 ${total} 条` }} scroll={{ y: 'calc(100vh - 300px)' }} onRow={(row) => ({ onClick: () => { const task = latestTask(tasks, row.id); if (task && ['get_bind_qr', 'create_recharge_order'].includes(task.task_type) && task.result) void openQrTask(task); }, onContextMenu: (event) => handleRowContextMenu(row, event), style: { cursor: 'context-menu' }, title: '右键打开账号动作' })} />; + const accountsBody = <>} placeholder="搜索账号/昵称/游戏名/手机号" value={search} onChange={(event) => setSearch(event.target.value)} style={{ width: 240 }} />{canImport && }{selectedIds.length > 0 && }
setSelectedIds(keys.map(Number)) }} columns={accountColumns} dataSource={filteredAccounts} locale={{ emptyText: '工作台暂无账号,点击右上角「导入账号」添加' }} pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (total) => `共 ${total} 条` }} scroll={{ y: 'calc(100vh - 300px)' }} onRow={(row) => ({ onClick: () => { const task = latestTask(tasks, row.id); if (!task?.result) return; if (task.task_type === 'get_bind_qr') void openQrTask(task); else if (task.task_type === 'create_recharge_order') openPayTasks(task.batch_id); }, onContextMenu: (event) => handleRowContextMenu(row, event), style: { cursor: 'context-menu' }, title: '右键打开账号动作' })} />; const configField = (key: Exclude, label: string) => configDraft && {label} setConfigDraft({ ...configDraft, [key]: event.target.value })} style={{ width: 300 }} />; const contextMenuStyle = contextMenu ? { @@ -293,7 +322,40 @@ export default function HuyaElitePage() { {layoutMode === 'split' ?
已选 {selectedIds.length}/{accounts.length}} style={{ flex: 1, minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }} styles={{ body: { flex: 1, minHeight: 0, padding: '4px 6px', overflow: 'hidden', display: 'flex', flexDirection: 'column' } }}>{accountsBody}并发 setConcurrency(value || 1)} style={{ width: 58 }} />} style={{ width: 360, flexShrink: 0, minHeight: 0, overflow: 'hidden' }} styles={{ body: { padding: 8, overflowY: 'auto', height: 'calc(100% - 38px)' } }}>{operationBody}
: <>并发 setConcurrency(value || 1)} style={{ width: 58 }} />} style={{ flexShrink: 0, marginBottom: 12 }} styles={{ body: { padding: 8 } }}>{operationBody}已选 {selectedIds.length}/{accounts.length}} style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }} styles={{ body: { flex: 1, minHeight: 0, padding: '4px 6px', overflow: 'hidden', display: 'flex', flexDirection: 'column' } }}>{accountsBody}} 兑换任务 {exchangeCount} 条;勾选账号后可批量执行,账号行支持右键操作。 {contextMenu &&
event.stopPropagation()}> 1 ? '批量动作' : '账号动作'} styles={{ body: { padding: 4 } }} style={{ width: 220, boxShadow: '0 4px 12px rgba(0,0,0,0.14)' }}>{HUYA_QUICK_ACTIONS.map((item) => )}
} - setQrTask(null)} centered>{qrTask &&
{resultText(qrTask, 'mini_qrcode_image') ? 绑定二维码 : }

{qrTask.message || qrTask.status}

{qrTask.task_type === 'create_recharge_order' && 扫码后订单状态会自动轮询更新}
}
+ setQrTask(null)} centered>{qrTask &&
{resultText(qrTask, 'mini_qrcode_image') ? 绑定二维码 : }

{qrTask.message || qrTask.status}

}
+ setPayOpen(false)} + footer={支付后会自动确认开通状态;已开通的码自动收起} + > + {payTasks.length === 0 ?
正在为选中的账号生成支付码…
:
+ {payPendingTasks.length > 0 && + {payPendingTasks.map((task) => ( +
+
{resultText(task, 'mini_qrcode_image') ? 支付码 : }
+ + {payTaskLabel(task)} + UID {task.account_uid || '-'} + {[resultText(task, 'product_name') || '精英宝典', resultText(task, 'amount_text')].filter(Boolean).join(' ')} + {resultText(task, 'payment_status_label') || TASK_STATUS_LABELS[task.status] || task.status} + {task.message || '-'} + +
+ ))} +
} + {payDoneTasks.length > 0 &&
+ 已完成(二维码已收起) +
+ {payDoneTasks.map((task) => ( + {payTaskLabel(task)} · {task.result?.elite_opened === true ? '已开通' : resultText(task, 'payment_status_label') || '已支付'} + ))} +
+
} +
} +
setImportOpen(false)} onOk={() => { void saveAccounts([...new Set([...accounts.map((item) => item.id), ...importSelected])]).then(() => { setImportSelected([]); setImportOpen(false); }).catch((error) => message.error(getErrorMessage(error))); }} okText="导入" cancelText="取消"> setImportSearch(event.target.value)} style={{ marginBottom: 8 }} />
!accounts.some((current) => current.id === item.id) && (!importSearch || [item.uid, item.nickname, item.username].some((value) => String(value || '').includes(importSearch))) && (!importTag || item.tag === importTag))} columns={[{ title: '账号', render: (_value, row) => row.nickname || row.username || row.uid }, { title: '标签', dataIndex: 'tag' }]} pagination={{ pageSize: 8 }} rowSelection={{ selectedRowKeys: importSelected, onChange: (keys) => setImportSelected(keys.map(Number)) }} /> setConfigOpen(false)} onOk={() => void saveConfig()} confirmLoading={configSaving} okText="保存" cancelText="取消" width={500}>{configLoading ?
加载中...
: configDraft && {configField('sid', '活动 SID')}{configField('bind_act_id', '绑定活动 ID')}{configField('outer_act_id', '外部活动 ID')}{configField('room_pid', '直播间 PID')}{configField('pay_channel', '支付渠道')}空闲轮询间隔(秒) { const next = Math.min(60, Math.max(5, value || 15)); setRefreshInterval(next); localStorage.setItem('huya_elite_refresh_interval', String(next)); }} style={{ width: 300 }} />}
setCkImportOpen(false)} tags={importTags} scope={SCOPE} onImported={(result) => { localStorage.setItem('huya_elite_workbench_account_ids', JSON.stringify(result.account_ids)); void load(); }} />