修复虎牙宝典开通中状态与支付监听异常

This commit is contained in:
yml2213
2026-11-11 12:51:06 +08:00
parent b18e46d72d
commit 26809dd116
6 changed files with 282 additions and 19 deletions
+4 -2
View File
@@ -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(
+13 -1
View File
@@ -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 分钟取消订单”
只是官方倒计时,不是开通状态。
## 如何判定传输通道
执行器只检查字段是否存在,不记录字段值:
+65 -5
View File
@@ -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):
+41
View File
@@ -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()
+151 -8
View File
@@ -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
+8 -3
View File
@@ -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) => <Space direction="vertical" align="center" size={0} style={{ width: '100%' }}><Text strong ellipsis title={row.nickname || row.username || row.uid}>{row.nickname || row.username || row.uid}</Text><Text type="secondary" style={{ fontSize: 12 }} ellipsis title={row.username}>UID {row.uid || '-'}</Text></Space> },
{ title: '手机号', width: '10%', align: 'center', ellipsis: true, render: (_value, row) => row.game_phone || <Text type="secondary">-</Text> },
{ title: '游戏名', width: '13%', align: 'center', ellipsis: true, render: (_value, row) => { const expired = ckExpired(latestTask(tasks, row.id), row); return <Space direction="vertical" align="center" size={0} style={{ width: '100%' }}><Text ellipsis title={row.game_name || row.game_channel}>{row.game_name || <Text type={expired ? 'danger' : 'secondary'}>{expired ? '角色未查到' : '未查询'}</Text>}</Text>{row.game_channel && <Text type="secondary" style={{ fontSize: 12 }} ellipsis>{row.game_channel}</Text>}</Space>; } },
{ title: '精英宝典', width: '13%', align: 'center', ellipsis: true, render: (_value, row) => <Space direction="vertical" align="center" size={0} style={{ width: '100%' }}><Tag color={row.elite_opened === true ? 'success' : row.elite_opened === false ? 'default' : undefined}>{row.elite_opened === true ? '已开通' : row.elite_opened === false ? '未开通' : '未查询'}</Tag>{row.elite_opened === true && row.elite_open_time ? <Text type="secondary" style={{ fontSize: 12 }} ellipsis>{`开通 ${formatUnixSeconds(row.elite_open_time)}`}</Text> : null}{row.elite_expire_time ? <Text type="secondary" style={{ fontSize: 12 }} ellipsis>{`有效期至 ${formatUnixSeconds(row.elite_expire_time)}`}</Text> : null}</Space> },
{ title: '精英宝典', width: '13%', align: 'center', ellipsis: true, render: (_value, row) => { const task = latestTask(tasks, row.id); const opening = task?.result?.elite_opening === true; return <Space direction="vertical" align="center" size={0} style={{ width: '100%' }}><Tag color={opening ? 'processing' : row.elite_opened === true ? 'success' : row.elite_opened === false ? 'default' : undefined}>{opening ? '开通中' : row.elite_opened === true ? '已开通' : row.elite_opened === false ? '未开通' : '未查询'}</Tag>{row.elite_opened === true && row.elite_open_time ? <Text type="secondary" style={{ fontSize: 12 }} ellipsis>{`开通 ${formatUnixSeconds(row.elite_open_time)}`}</Text> : null}{row.elite_expire_time ? <Text type="secondary" style={{ fontSize: 12 }} ellipsis>{`有效期至 ${formatUnixSeconds(row.elite_expire_time)}`}</Text> : null}</Space>; } },
{ title: '积分', dataIndex: 'points', width: '7%', align: 'center', render: (value: number | null) => value ?? <Text type="secondary">-</Text> },
{ title: '最近操作', width: '25%', align: 'center', ellipsis: true, render: (_value, row) => { const task = latestTask(tasks, row.id); const expired = ckExpired(task, row); return task ? <Space direction="vertical" align="center" size={1} style={{ width: '100%' }}><Text strong ellipsis>{TASK_LABELS[task.task_type] || task.task_type}</Text><Text type={expired ? 'danger' : 'secondary'} ellipsis title={task.message}>{task.message || '-'}</Text><Text type="secondary" style={{ fontSize: 12 }}>{formatTime(task.finished_at || task.created_at)}</Text></Space> : <Text type="secondary">暂无操作</Text>; } },
{ title: '二维码', width: '6%', align: 'center', render: (_value, row) => { const task = latestTask(tasks, row.id); if (!task) return <Text type="secondary">-</Text>; 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 <Text type="secondary">-</Text>; return <div onClick={(event) => { event.stopPropagation(); void openQrTask(task); }} style={{ cursor: 'pointer', display: 'inline-block', lineHeight: 0, background: '#fff', padding: 6, borderRadius: 4 }} title="点击查看小程序码"><QrcodeOutlined style={{ fontSize: 26, color: '#000' }} /></div>; } if (!url && !image) return <Text type="secondary">-</Text>; const doneLabel = qrDoneLabel(task); if (doneLabel) return <Text type="secondary">{doneLabel}</Text>; return <div onClick={(event) => { 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 ? <img src={miniQrcodeSrc(image)} alt="二维码" style={{ width: 52, height: 52, objectFit: 'contain' }} /> : <QRCode value={url} size={52} type="svg" errorLevel="L" bordered={false} color="#000" bgColor="#fff" />}</div>; } },
@@ -612,7 +617,7 @@ export default function HuyaElitePage() {
<Text type="secondary" style={{ fontSize: 12 }}>已完成(二维码已收起)</Text>
<div style={{ marginTop: 6, display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{payDoneTasks.map((task) => (
<Tag key={task.id} color={task.result?.elite_opened === true ? 'success' : 'blue'}>{payTaskLabel(task)} · {task.result?.elite_opened === true ? '已开通' : resultText(task, 'payment_status_label') || '已支付'}</Tag>
<Tag key={task.id} color={task.result?.elite_opened === true ? 'success' : task.result?.elite_opening === true ? 'processing' : 'blue'}>{payTaskLabel(task)} · {task.result?.elite_opened === true ? '已开通' : task.result?.elite_opening === true ? '开通中' : resultText(task, 'payment_status_label') || '已支付'}</Tag>
))}
</div>
</div>}