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) =>