From 26809dd116eceb8cf809870970d1ce687fbe3576 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 11 Nov 2026 12:51:06 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=99=8E=E7=89=99=E5=AE=9D?= =?UTF-8?q?=E5=85=B8=E5=BC=80=E9=80=9A=E4=B8=AD=E7=8A=B6=E6=80=81=E4=B8=8E?= =?UTF-8?q?=E6=94=AF=E4=BB=98=E7=9B=91=E5=90=AC=E5=BC=82=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/huya/wss_client.py | 6 +- docs/虎牙/HUYA精英宝典联调指南.md | 14 +- tests/test_huya_elite_query.py | 70 +++++++++- tests/test_huya_points_shop_protocol.py | 41 ++++++ web/backend/services/huya_runner_elite.py | 159 ++++++++++++++++++++-- web/frontend/src/pages/HuyaElitePage.tsx | 11 +- 6 files changed, 282 insertions(+), 19 deletions(-) diff --git a/core/huya/wss_client.py b/core/huya/wss_client.py index d24612b..5d3810f 100644 --- a/core/huya/wss_client.py +++ b/core/huya/wss_client.py @@ -1214,6 +1214,7 @@ class HuyaWssClient: page_size: int = 10, order_type: int = 1, status: int = 0, + timeout: float = 15.0, ): from .shop_structs import QueryUserOrderListReq, QueryUserOrderListRsp @@ -1229,7 +1230,7 @@ class HuyaWssClient: "queryUserOrderList", req, QueryUserOrderListRsp, - timeout=15.0, + timeout=timeout, ) async def order_detail( @@ -1240,6 +1241,7 @@ class HuyaWssClient: order_id: int, source_id: str = "yellowcarlist", app_scene: int = SHOP_APP_SCENE, + timeout: float = 15.0, ): """按订单号查询商城订单详情,匹配 9.1 支付后轮询。""" from .shop_structs import OrderDetailReq, OrderDetailRsp @@ -1249,7 +1251,7 @@ class HuyaWssClient: req.shopAppInfo = self._build_shop_app(source_id, app_scene) req.orderId = int(order_id or 0) return await self.call_rpc( - "shopMiddleUI", "orderDetailV5", req, OrderDetailRsp, timeout=15.0 + "shopMiddleUI", "orderDetailV5", req, OrderDetailRsp, timeout=timeout ) async def create_order( diff --git a/docs/虎牙/HUYA精英宝典联调指南.md b/docs/虎牙/HUYA精英宝典联调指南.md index 1d654e6..6bb6d40 100644 --- a/docs/虎牙/HUYA精英宝典联调指南.md +++ b/docs/虎牙/HUYA精英宝典联调指南.md @@ -1,6 +1,6 @@ # 虎牙精英宝典联调指南 -更新时间:2026-09-02 +更新时间:2026-11-11 > 当前 Cookie 字段状态见 [HUYA_COOKIE路线状态.md](HUYA_COOKIE路线状态.md)。 @@ -37,6 +37,18 @@ 4. 兑换成功后再次执行 `query_points` 和 `query_exchange_records`,核对余额扣减与新增记录。 5. 充值/开通链路单独联调:先 `refresh_recharge_goods`,再只生成支付码 `create_recharge_order`。支付确认不要和查询任务混在同一批次。 +### 2026-11-11 订单状态补充 + +- `revenueWebUI.queryUserOrderList` 是订单查询主入口;返回的宝典订单: + `order_status=10` = 待支付,`pay_time>0`/已支付状态 = 已付款,`order_status=90` = 已取消。 +- WSS 的 `queryUserOrderList` / `orderDetailV5` 必须保留 `timeout` 参数; + 支付监听统一传该参数,缺了会在支付码生成后直接 + `unexpected keyword argument 'timeout'`。 +- “已付款但积分中心 `getRedeemNeedGoods.tag5` 仍为 0”不能直接判成未开通: + 24 小时交付窗口内显示“开通中”,超过窗口仍为 0 才回落到未开通。 +- 待支付旧单继续走复用支付;已取消订单不能复用。订单详情里的“还剩 N 分钟取消订单” + 只是官方倒计时,不是开通状态。 + ## 如何判定传输通道 执行器只检查字段是否存在,不记录字段值: diff --git a/tests/test_huya_elite_query.py b/tests/test_huya_elite_query.py index 57362e6..544a4bd 100644 --- a/tests/test_huya_elite_query.py +++ b/tests/test_huya_elite_query.py @@ -10,6 +10,7 @@ """ import json +import time from types import SimpleNamespace from core.huya.frame_decoder import decode_struct_bytes @@ -396,6 +397,7 @@ def test_refresh_elite_open_after_payment_retries_until_opened(): 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) @@ -412,11 +414,66 @@ def test_refresh_elite_open_after_payment_gives_up_after_attempts(): ) assert client.redeem_calls == 2 # 重试到上限 - assert state["elite_opened"] is False - assert account.elite_opened is False + assert state["elite_opened"] is None + assert state["elite_opening"] is True + assert state["elite_status_label"] == "开通中" + assert account.elite_opened is None assert account.elite_open_time is None +def test_query_all_marks_recent_paid_elite_order_as_opening(monkeypatch): + """已支付但 redeem 还是 0:订单列表证明已付款,应显示「开通中」。""" + now_ms = int(time.time() * 1000) + opening_order = { + **PAID_ELITE_ORDER, + "order_id": "12001621", + "pay_time": now_ms - 60_000, + "create_time": now_ms - 120_000, + } + shop_client = _StubClient( + redeem=_redeem(REDEEM_NOT_OPENED_HEX), + order_pages=[[opening_order]], + season=True, + ) + runner, marks, _logs = _runner(monkeypatch, shop_client, activity_client=object()) + monkeypatch.setattr( + runner, + "_probe_bind_status", + lambda **kwargs: (_FakeBindStatus(status=601, msg="活动已下线"), {}), + raising=False, + ) + account = HuyaAccount(uid=str(UID), cookie=COOKIE) + + runner._execute_query_all( + None, None, account, {"uid": UID, "cookie": COOKIE}, {"bind_act_id": "9271"} + ) + + status, message, result = marks[-1] + assert status == "success" + assert result["elite_opening"] is True + assert result["elite_status"] == "opening" + assert result["elite_status_label"] == "开通中" + assert result["elite_order"]["order_id"] == "12001621" + assert account.elite_opened is None + assert "开通中宝典" in message + + +def test_elite_order_state_labels_pending_and_cancelled_orders(): + runner = HuyaBatchRunner(db=None, batch_id="t", task_type="query_all") + assert ( + runner._elite_order_state( + {**UNPAID_ELITE_ORDER, "order_id": "1"}, opened=False + )["status"] + == "pending_payment" + ) + assert ( + runner._elite_order_state( + {**PAID_ELITE_ORDER, "order_status": 90, "pay_time": 0}, opened=False + )["status"] + == "cancelled" + ) + + def test_create_recharge_order_confirms_elite_open_after_payment(db, monkeypatch): """支付成功后自动确认开通状态:任务结果与账号字段都带上宝典状态。""" @@ -676,7 +733,7 @@ def test_query_all_combines_points_elite_and_role(monkeypatch): ) -def test_query_all_reports_not_opened_without_order_lookup(monkeypatch): +def test_query_all_reports_not_opened_and_checks_order_state(monkeypatch): shop_client = _StubClient(redeem=_redeem(REDEEM_NOT_OPENED_HEX)) runner, marks, _logs = _runner(monkeypatch, shop_client, activity_client=object()) monkeypatch.setattr( @@ -699,8 +756,11 @@ def test_query_all_reports_not_opened_without_order_lookup(monkeypatch): assert account.elite_open_time is None assert "未开通宝典" in message assert "活动已下线" in message - # 未开通不查订单(省一次请求) - assert [name for name, _kwargs in shop_client.calls] == ["get_redeem_need_goods"] + # 未开通也要查最近订单:否则无法区分「待支付 / 开通中 / 已取消」 + assert [name for name, _kwargs in shop_client.calls] == [ + "get_redeem_need_goods", + "query_user_order_list", + ] def test_query_all_fails_when_points_center_unreachable(monkeypatch): diff --git a/tests/test_huya_points_shop_protocol.py b/tests/test_huya_points_shop_protocol.py index c3fc27a..d2fe58f 100644 --- a/tests/test_huya_points_shop_protocol.py +++ b/tests/test_huya_points_shop_protocol.py @@ -253,6 +253,47 @@ def test_pay_order_submit_v5_callback_points_page(): asyncio.run(run()) +def test_order_poll_rpcs_accept_timeout_keyword(): + """支付监听统一传 timeout;WSS 方法缺这个参数会在生成二维码后立刻崩。""" + + async def run(): + client = _points_client() + + await client.query_user_order_list( + uid=UID, + guid=GUID, + cookie=COOKIE, + offset=0, + page_size=20, + order_type=1, + status=0, + timeout=10.0, + ) + decoded = client.ws.last_request() + assert (decoded["servant"], decoded["func"]) == ( + "revenueWebUI", + "queryUserOrderList", + ) + assert decoded["tReq"]["tag2"] == 1 + assert decoded["tReq"]["tag3"] == 20 + + await client.order_detail( + uid=UID, + guid=GUID, + cookie=COOKIE, + order_id=12001621, + timeout=10.0, + ) + decoded = client.ws.last_request() + assert (decoded["servant"], decoded["func"]) == ( + "shopMiddleUI", + "orderDetailV5", + ) + assert decoded["tReq"]["tag2"] == 12001621 + + asyncio.run(run()) + + def test_new_points_rpcs_match_capture(): async def run(): client = _points_client() diff --git a/web/backend/services/huya_runner_elite.py b/web/backend/services/huya_runner_elite.py index be9e623..d79b4f2 100644 --- a/web/backend/services/huya_runner_elite.py +++ b/web/backend/services/huya_runner_elite.py @@ -39,6 +39,12 @@ ELITE_HANDBOOK_SPU_IDS = frozenset( ELITE_HANDBOOK_NAME_KEYWORD = "精英宝典" ELITE_ORDER_PAGE_SIZE = 20 ELITE_ORDER_MAX_PAGES = 3 +# 已支付但 redeem 还没翻成已开通时,在这个窗口内视为「开通中」而不是「未开通」。 +ELITE_OPENING_WINDOW_MS = 24 * 60 * 60 * 1000 +# 官方订单状态:10=待支付、1000=尾款待支付;90=已取消。 +ELITE_PENDING_ORDER_STATUSES = frozenset({10, 1000}) +ELITE_CANCELLED_ORDER_STATUSES = frozenset({90, 1001}) +ELITE_PAID_ORDER_STATUSES = frozenset({20, 30, 40, 50, 55, 60, 70, 80}) # 赛季(福利季)节点的毫秒时间戳下限:2001-09 之后的值才认 ELITE_SEASON_MS_FLOOR = 1_000_000_000_000 @@ -126,6 +132,79 @@ class EliteMixin: offset += len(orders) return latest or None + def _latest_elite_order(self, client: Any, uid: int, cookie: str) -> dict | None: + """最近一张宝典订单(不要求已支付),用于识别开通中/待支付/已取消。""" + latest: dict | None = None + latest_key = -1 + offset = 0 + for _page in range(ELITE_ORDER_MAX_PAGES): + resp = client.query_user_order_list( + uid=uid, + guid="", + cookie=cookie, + offset=offset, + page_size=ELITE_ORDER_PAGE_SIZE, + order_type=1, + status=0, + ) + if resp is None: + break + orders = list(getattr(resp, "orders", None) or []) + for order in orders: + item = order.to_dict() if hasattr(order, "to_dict") else dict(order) + if not self._is_elite_order(item): + continue + key = int(item.get("create_time") or 0) + if key >= latest_key: + latest = item + latest_key = key + total = self._to_int(getattr(resp, "totalCount", 0)) + if len(orders) < ELITE_ORDER_PAGE_SIZE or offset + len(orders) >= total: + break + offset += len(orders) + return latest + + @staticmethod + def _elite_order_state( + order: dict | None, *, opened: bool | None, now_ms: int | None = None + ) -> dict[str, Any]: + """把「redeem 开通位 + 最近订单」解释成可展示状态。 + + 订单状态 10 是待支付,不代表开通中;只有已支付/有 pay_time 且权益尚未到账, + 才归为「开通中」。超出交付窗口后仍没开通,不再继续误导成开通中。 + """ + if opened is True: + return {"status": "opened", "label": "已开通"} + if not order: + return {"status": "unopened", "label": "未开通"} + + status = int(order.get("order_status") or 0) + pay_time = int(order.get("pay_time") or 0) + current_ms = int(now_ms if now_ms is not None else time.time() * 1000) + paid = pay_time > 0 or status in ELITE_PAID_ORDER_STATUSES + observed_ms = pay_time or int(order.get("create_time") or 0) + if paid and ( + observed_ms <= 0 or current_ms - observed_ms <= ELITE_OPENING_WINDOW_MS + ): + return { + "status": "opening", + "label": "开通中", + "order": order, + } + if status in ELITE_PENDING_ORDER_STATUSES: + return { + "status": "pending_payment", + "label": "待支付", + "order": order, + } + if status in ELITE_CANCELLED_ORDER_STATUSES: + return { + "status": "cancelled", + "label": "订单已取消", + "order": order, + } + return {"status": "unopened", "label": "未开通", "order": order} + @classmethod def _walk_season_nodes(cls, node: Any, found: list[dict]) -> None: """递归找「赛季」节点:同时带 tag10/tag11 毫秒起止时间的结构。""" @@ -210,9 +289,38 @@ class EliteMixin: 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")) + latest_order = self._latest_elite_order(client, uid, cookie) + elite_state = self._elite_order_state( + latest_order, + opened=opened, + now_ms=pay_time_ms, + ) + if latest_order: + state["elite_order"] = latest_order + + def mark_opening() -> None: + state.update( + { + "elite_opened": None, + "elite_opening": True, + "elite_status": "opening", + "elite_status_label": "开通中", + } + ) + if pay_time_ms: + state["elite_order_paid_time"] = int(pay_time_ms) // 1000 + if opened is None: + if pay_time_ms or elite_state["status"] == "opening": + mark_opening() return state + if not opened: + opening = bool(pay_time_ms) or elite_state["status"] == "opening" + if opening: + mark_opening() + 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) @@ -220,10 +328,17 @@ class EliteMixin: 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 "" + state.update( + { + "elite_opened": opened, + "elite_opening": False, + "elite_status": elite_state["status"], + "elite_status_label": elite_state["label"], + "elite_open_time": (open_time // 1000) if open_time else None, + "elite_open_time_text": ( + self._format_local_time(open_time // 1000) if open_time else "" + ), + } ) if season: state["elite_season"] = season @@ -379,17 +494,32 @@ class EliteMixin: ) message_parts.append(f"积分 {points}") + latest_order = ( + self._latest_elite_order(shop_client, uid, cookie) + if opened is not None + else None + ) + elite_state = self._elite_order_state(latest_order, opened=opened) open_time: int | None = None if opened: - # 已开通才去翻订单:既省一次请求,也避免给未开通账号显示旧订单时间 open_time = self._resolve_elite_open_time(shop_client, uid, cookie) season = ( self._resolve_elite_season(shop_client, uid, cookie) if opened else None ) expire_time = self._to_int(season["end_ms"]) if season else None if opened is not None: - self._apply_elite_state(account, opened, open_time, expire_time) - result["elite_opened"] = opened + opening = elite_state["status"] == "opening" + if not opening: + self._apply_elite_state(account, opened, open_time, expire_time) + result["elite_opened"] = None if opening else opened + result["elite_opening"] = opening + result["elite_status"] = elite_state["status"] + result["elite_status_label"] = elite_state["label"] + if latest_order: + result["elite_order"] = latest_order + paid_at = int(latest_order.get("pay_time") or 0) + if paid_at: + result["elite_order_paid_time"] = paid_at // 1000 result["elite_open_time"] = (open_time // 1000) if open_time else None result["elite_open_time_text"] = ( self._format_local_time(open_time // 1000) if open_time else "" @@ -408,8 +538,21 @@ class EliteMixin: opened_text += ( f",有效期至 {self._format_local_time(expire_time // 1000)}" ) + elif opening: + order_id = (latest_order or {}).get("order_id") or "" + opened_text = "开通中宝典" + if order_id: + opened_text += f"(订单 #{order_id},等待权益到账)" else: - opened_text = "未开通宝典" + order_id = (latest_order or {}).get("order_id") or "" + if elite_state["status"] == "pending_payment": + opened_text = ( + f"宝典订单 #{order_id} 待支付" if order_id else "宝典待支付" + ) + elif elite_state["status"] == "cancelled": + opened_text = "未开通宝典(最近订单已取消)" + else: + opened_text = "未开通宝典" message_parts.append(opened_text) account.points = points if points is not None else account.points diff --git a/web/frontend/src/pages/HuyaElitePage.tsx b/web/frontend/src/pages/HuyaElitePage.tsx index 9b49b84..24c226d 100644 --- a/web/frontend/src/pages/HuyaElitePage.tsx +++ b/web/frontend/src/pages/HuyaElitePage.tsx @@ -115,7 +115,11 @@ function miniQrcodeSrc(image: string): string { /** 开通支付码是否还需要扫码:已支付或已确认开通就不用再展示了 */ function needsScanPay(task: HuyaTaskItem): boolean { const result = task.result || {}; - return !(result.payment_paid === true || result.elite_opened === true); + return !( + result.payment_paid === true + || result.elite_opened === true + || result.elite_opening === true + ); } /** @@ -128,6 +132,7 @@ function needsScanPay(task: HuyaTaskItem): boolean { function qrDoneLabel(task: HuyaTaskItem): string | null { if (task.task_type !== 'create_recharge_order') return null; if (needsScanPay(task)) return null; + if (task.result?.elite_opening === true) return '开通中'; return task.result?.elite_opened === true ? '已开通' : '已支付'; } @@ -538,7 +543,7 @@ export default function HuyaElitePage() { { title: '账号', width: '15%', align: 'center', ellipsis: true, render: (_value, row) => {row.nickname || row.username || row.uid}UID {row.uid || '-'} }, { title: '手机号', width: '10%', align: 'center', ellipsis: true, render: (_value, row) => row.game_phone || - }, { title: '游戏名', width: '13%', align: 'center', ellipsis: true, render: (_value, row) => { const expired = ckExpired(latestTask(tasks, row.id), row); return {row.game_name || {expired ? '角色未查到' : '未查询'}}{row.game_channel && {row.game_channel}}; } }, - { title: '精英宝典', width: '13%', align: 'center', ellipsis: true, render: (_value, row) => {row.elite_opened === true ? '已开通' : row.elite_opened === false ? '未开通' : '未查询'}{row.elite_opened === true && row.elite_open_time ? {`开通 ${formatUnixSeconds(row.elite_open_time)}`} : null}{row.elite_expire_time ? {`有效期至 ${formatUnixSeconds(row.elite_expire_time)}`} : null} }, + { title: '精英宝典', width: '13%', align: 'center', ellipsis: true, render: (_value, row) => { const task = latestTask(tasks, row.id); const opening = task?.result?.elite_opening === true; return {opening ? '开通中' : row.elite_opened === true ? '已开通' : row.elite_opened === false ? '未开通' : '未查询'}{row.elite_opened === true && row.elite_open_time ? {`开通 ${formatUnixSeconds(row.elite_open_time)}`} : null}{row.elite_expire_time ? {`有效期至 ${formatUnixSeconds(row.elite_expire_time)}`} : null}; } }, { title: '积分', dataIndex: 'points', width: '7%', align: 'center', render: (value: number | null) => value ?? - }, { title: '最近操作', width: '25%', align: 'center', ellipsis: true, render: (_value, row) => { const task = latestTask(tasks, row.id); const expired = ckExpired(task, row); return task ? {TASK_LABELS[task.task_type] || task.task_type}{task.message || '-'}{formatTime(task.finished_at || task.created_at)} : 暂无操作; } }, { title: '二维码', width: '6%', align: 'center', render: (_value, row) => { const task = latestTask(tasks, row.id); if (!task) return -; const url = taskQrUrl(task); const image = resultText(task, 'mini_qrcode_image'); if (task.task_type === 'get_bind_qr' && !image) { const hasMini = task.result?.has_mini_qrcode === true; if (!hasMini) return -; return
{ event.stopPropagation(); void openQrTask(task); }} style={{ cursor: 'pointer', display: 'inline-block', lineHeight: 0, background: '#fff', padding: 6, borderRadius: 4 }} title="点击查看小程序码">
; } if (!url && !image) return -; const doneLabel = qrDoneLabel(task); if (doneLabel) return {doneLabel}; return
{ event.stopPropagation(); if (!task) return; if (task.task_type === 'create_recharge_order') setZoomPayTask(task); else void openQrTask(task); }} style={{ cursor: 'pointer', display: 'inline-block', lineHeight: 0, background: '#fff', padding: 3, borderRadius: 4 }} title="点击放大扫码">{image ? 二维码 : }
; } }, @@ -612,7 +617,7 @@ export default function HuyaElitePage() { 已完成(二维码已收起)
{payDoneTasks.map((task) => ( - {payTaskLabel(task)} · {task.result?.elite_opened === true ? '已开通' : resultText(task, 'payment_status_label') || '已支付'} + {payTaskLabel(task)} · {task.result?.elite_opened === true ? '已开通' : task.result?.elite_opening === true ? '开通中' : resultText(task, 'payment_status_label') || '已支付'} ))}
}