diff --git a/tests/test_huya_elite_query.py b/tests/test_huya_elite_query.py
index 49bf6e7..70a1372 100644
--- a/tests/test_huya_elite_query.py
+++ b/tests/test_huya_elite_query.py
@@ -16,7 +16,7 @@ from core.huya.frame_decoder import decode_struct_bytes
from core.huya.http_client import HuyaHttpClient
from core.huya.shop_structs import ShopRawRsp
from web.backend.models import HuyaAccount
-from web.backend.services.huya_pay_link import pay_qr_path
+from web.backend.services.huya_pay_link import PayTargets, pay_qr_path
from web.backend.services.huya_runner import HuyaBatchRunner
UID = 1199668595203
@@ -382,11 +382,12 @@ def test_create_recharge_order_confirms_elite_open_after_payment(db, monkeypatch
"web.backend.services.huya_runner_recharge.run_open_flow",
lambda *args, **kwargs: outcome,
)
- # 二维码内容优先用支付宝收银台直达地址(真机:网关 302 后的落点)
+ # 二维码内容优先用收银台页面里的扫码内容(真机:支付宝扫码支付码)
monkeypatch.setattr(
- "web.backend.services.huya_runner_recharge.resolve_pay_entry_url",
- lambda _pay_url, **_kwargs: (
- "https://excashier.alipay.com/standard/auth.htm?payOrderId=x.25"
+ "web.backend.services.huya_runner_recharge.resolve_pay_targets",
+ lambda _pay_url, **_kwargs: PayTargets(
+ cashier_url="https://excashier.alipay.com/standard/auth.htm?payOrderId=x.25",
+ qr_code="https://qr.alipay.com/upx03110ghym9vm37sx7255c",
),
)
account = HuyaAccount(uid=str(UID), cookie=COOKIE)
@@ -410,7 +411,9 @@ def test_create_recharge_order_confirms_elite_open_after_payment(db, monkeypatch
assert result["post_payment_points"] == 280
# 二维码内容给前端两条短地址(原始支付地址 1626 字符,扫码扫不动)
assert result["pay_qr_path"] == pay_qr_path(587)
- # 优先用支付宝收银台直达地址(公网域名,localhost 下客户也扫得开)
+ # 优先用收银台页面里的扫码内容(支付宝 App 扫码即付款)
+ assert result["pay_qr_code"] == "https://qr.alipay.com/upx03110ghym9vm37sx7255c"
+ # 收银台直达地址作为兜底保留(PC 打开就是这个页面)
assert result["pay_cashier_url"].startswith("https://excashier.alipay.com/")
assert account.elite_opened is True
assert account.elite_expire_time == 1798732799
diff --git a/tests/test_huya_pay_link.py b/tests/test_huya_pay_link.py
index 17692c0..e0805b5 100644
--- a/tests/test_huya_pay_link.py
+++ b/tests/test_huya_pay_link.py
@@ -21,10 +21,13 @@ from web.backend.routers.huya.pay import redirect_huya_pay
from web.backend.services import huya_pay_link
from web.backend.services.huya_pay_link import (
PAY_PATH_PREFIX,
+ PayTargets,
+ extract_pay_qr_code,
is_alipay_url,
pay_qr_path,
pay_token,
resolve_pay_entry_url,
+ resolve_pay_targets,
verify_pay_token,
)
@@ -39,6 +42,17 @@ CASHIER_URL = (
"https://excashier.alipay.com/standard/auth.htm"
"?payOrderId=6cb86f1d259642ac843522b85dab8a52.25"
)
+# 收银台页面里真正的扫码内容(真机页面原样片段)
+QR_CODE_LITERAL = "https://qr.alipay.com/upx03110ghym9vm37sx7255c"
+CASHIER_HTML = (
+ '\n'
+ f'\n'
+ '\n'
+ f'\n'
+ ''
+)
def _create_pay_task(db, pay_url: str) -> HuyaTask:
@@ -111,24 +125,27 @@ def test_pay_redirect_route_rejects_task_without_pay_url(db):
class _FakeResponse:
- """假的 requests 响应:只需要 ``url`` 和 ``close()``。"""
+ """假的 requests 响应:只需要 ``url`` / ``text`` / ``close()``。"""
- def __init__(self, url: str):
+ def __init__(self, url: str, text: str = ""):
self.url = url
+ self.text = text
self.closed = False
def close(self) -> None:
self.closed = True
-def _patch_get(monkeypatch, *, final_url: str | None = None, error=None):
+def _patch_get(
+ monkeypatch, *, final_url: str | None = None, body: str = "", error=None
+):
calls: list[dict] = []
def fake_get(url, **kwargs):
calls.append({"url": url, **kwargs})
if error is not None:
raise error
- return _FakeResponse(final_url or "")
+ return _FakeResponse(final_url or "", body)
monkeypatch.setattr(huya_pay_link.requests, "get", fake_get)
return calls
@@ -167,13 +184,58 @@ def test_resolve_pay_entry_url_is_shorter_than_raw_url(monkeypatch):
resolved = resolve_pay_entry_url(ALIPAY_LONG_URL)
- # 收银台直达地址约 74 字符 → 二维码模块数远低于原始地址
+ # 收银台直达地址约 93 字符 → 二维码模块数远低于原始地址
assert len(resolved) < 120
assert len(resolved) < len(ALIPAY_LONG_URL)
+def test_extract_pay_qr_code_from_cashier_html():
+ """收银台页面里的 J_qrCode 才是手机该扫的内容(真机抓到的原样片段)。"""
+ assert extract_pay_qr_code(CASHIER_HTML) == QR_CODE_LITERAL
+
+
+def test_extract_pay_qr_code_ignores_other_alipay_urls():
+ """只有 qr.alipay.com 才算扫码内容:qrImgUrl(mobilecodec)不算。"""
+ html = (
+ ''
+ )
+
+ assert extract_pay_qr_code(html) == ""
+ assert extract_pay_qr_code("") == ""
+
+
+def test_resolve_pay_targets_returns_cashier_and_qr_code(monkeypatch):
+ _patch_get(monkeypatch, final_url=CASHIER_URL, body=CASHIER_HTML)
+
+ targets = resolve_pay_targets(ALIPAY_LONG_URL)
+
+ assert targets.cashier_url == CASHIER_URL
+ assert targets.qr_code == QR_CODE_LITERAL
+
+
+def test_resolve_pay_targets_keeps_cashier_when_qr_code_missing(monkeypatch):
+ """拿不到 J_qrCode(比如停在中间页)时只回落到收银台地址。"""
+ _patch_get(monkeypatch, final_url=CASHIER_URL, body="no qr here")
+
+ targets = resolve_pay_targets(ALIPAY_LONG_URL)
+
+ assert targets.cashier_url == CASHIER_URL
+ assert targets.qr_code == ""
+
+
+def test_resolve_pay_targets_empty_on_error(monkeypatch):
+ _patch_get(monkeypatch, error=RuntimeError("boom"))
+
+ targets = resolve_pay_targets(ALIPAY_LONG_URL)
+
+ assert targets == PayTargets()
+
+
def test_is_alipay_url_matches_only_alipay_domains():
assert is_alipay_url(CASHIER_URL) is True
+ assert is_alipay_url(QR_CODE_LITERAL) is True
assert is_alipay_url("https://openapi.alipay.com/gateway.do?x=1") is True
assert is_alipay_url("https://alipay.com/") is True
assert is_alipay_url("https://alipay.com.evil.com/") is False
diff --git a/web/backend/services/huya_pay_link.py b/web/backend/services/huya_pay_link.py
index 7e53a6e..1908bfc 100644
--- a/web/backend/services/huya_pay_link.py
+++ b/web/backend/services/huya_pay_link.py
@@ -1,18 +1,21 @@
-"""开通支付码的**二维码内容**:让手机一定扫得动、打得开。
+"""开通支付码的**二维码内容**:让手机一定扫得动、打得开、且能直接付款。
问题(生产实测 2026-10-15):`payOrderSubmitV5` 返回的 `payUrl` 是
`https://openapi.alipay.com/gateway.do?...&sign=...`,长度 **1626** 字符。
直接把它编成二维码是 version 29+(约 140 个模块),在 150px 的画面上每个模块
只有约 1 像素 —— 手机怎么都扫不上。
-两条出路,二维码里按顺序取第一个可用的:
+二维码里放什么,按顺序取第一个可用的:
-1. **支付宝收银台直达地址**(首选):网关地址 302 两次后落到
- `https://excashier.alipay.com/standard/auth.htm?payOrderId=...`(约 74 字符),
- 公网域名、支付宝扫一扫直接识别,后端跑在 localhost 时也能发给客户扫。
- 见 `resolve_pay_entry_url`。
-2. **本站短链** `/api/huya/pay/?k=`(约 38 字符):手机打开后由
- 后端 302 跳到上面的网关地址。要求「本页域名」手机能访问到(localhost 不行)。
+1. **支付宝扫码支付码** `https://qr.alipay.com/`(首选,约 40 字符):
+ 就是收银台页面里 `J_qrCode` 那个隐藏字段。支付宝 App 扫它直接进付款;普通
+ 相机/浏览器扫它是支付宝的手机页面(会拉起支付宝 App)。
+2. **收银台直达地址** `https://excashier.alipay.com/standard/auth.htm?payOrderId=...`
+ (约 93 字符,见 `resolve_pay_targets`)。⚠️ 这是**PC 版**收银台页面
+ (`qrcodeWidth=150`,左上角一个小二维码):真机反馈有些安卓机扫完是用浏览器
+ 打开的,就停在这张 PC 页上不触发付款,所以只作为拿不到 qrCode 时的兜底。
+3. **本站短链** `/api/huya/pay/?k=`(约 38 字符):手机打开后由
+ 后端 302 跳到网关地址。要求「本页域名」手机能访问到(localhost 不行)。
扫码的是没登录的手机,所以路由只校验 HMAC 签名(用 JWT 密钥派生,不额外存库)。
"""
@@ -20,6 +23,8 @@ from __future__ import annotations
import hashlib
import hmac
+import re
+from dataclasses import dataclass
from urllib.parse import urlsplit
import requests
@@ -29,12 +34,26 @@ from ..security import SECRET_KEY_TYPED
TOKEN_LENGTH = 16
PAY_PATH_PREFIX = "/api/huya/pay"
-#: 解析收银台直达地址时冒充浏览器(支付宝对无 UA 的请求会走风控页)。
+#: 解析收银台页面时冒充浏览器(支付宝对无 UA 的请求会走风控页)。
BROWSER_UA = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36"
)
+#: 收银台页面里真正的扫码内容(``)。
+QR_CODE_HOST = "qr.alipay.com"
+_QR_CODE_RE = re.compile(r"https://qr\.alipay\.com/[A-Za-z0-9]+")
+
+
+@dataclass(frozen=True)
+class PayTargets:
+ """支付码可用的两个地址(取不到就是空串)。"""
+
+ #: 收银台直达地址(PC 打开就是这个页面,也是「复制链接」给电脑用的地址)
+ cashier_url: str = ""
+ #: 收银台页面里真正的扫码内容 https://qr.alipay.com/xxx(手机扫码用这个)
+ qr_code: str = ""
+
def pay_token(task_id: int) -> str:
"""按任务号派生短链签名(16 位十六进制,48 bit 抗枚举)。"""
@@ -59,25 +78,42 @@ def pay_qr_path(task_id: int) -> str:
def is_alipay_url(url: str) -> bool:
- """是否支付宝域名(网关/收银台)。"""
+ """是否支付宝域名(网关/收银台/扫码短链)。"""
host = (urlsplit(str(url or "")).hostname or "").lower()
return bool(host) and (host == "alipay.com" or host.endswith(".alipay.com"))
-def resolve_pay_entry_url(pay_url: str, *, timeout: float = 6.0) -> str:
- """跟随支付宝网关的 302,取**收银台直达地址**(约 74 字符,公网可达)。
+def extract_pay_qr_code(html: str) -> str:
+ """从收银台页面里取真正的扫码内容 `https://qr.alipay.com/xxx`。
+
+ 真机取到的页面里有(`id="J_qrCode"` 那个隐藏 input):
+
+
+
+ 取不到就返回空串(调用方回落到收银台地址 / 本站短链)。
+ """
+ match = _QR_CODE_RE.search(html or "")
+ if match is None:
+ return ""
+ code = match.group(0)
+ return code if is_alipay_url(code) else ""
+
+
+def resolve_pay_targets(pay_url: str, *, timeout: float = 6.0) -> PayTargets:
+ """跟随支付宝网关的 302,取**收银台页面**和页面里**真正的扫码内容**。
真机实测(2026-10-15):`openapi.alipay.com/gateway.do?...` 会 302 到
`unitradeprod.alipay.com/appAssign.htm?...` 再 302 到
- `https://excashier.alipay.com/standard/auth.htm?payOrderId=`。
- 这个地址编二维码又小又好扫,且不依赖本服务的域名能不能被手机访问。
+ `https://excashier.alipay.com/standard/auth.htm?payOrderId=`(约 93 字符),
+ 页面里再带一个 `https://qr.alipay.com/`(约 40 字符)——后者才是
+ 「扫码支付」该放进二维码的内容(见模块头说明)。
- 解析失败、超时、或最终落到非支付宝域名都返回空串 —— 调用方回落到本站短链,
- 绝不能让解析失败影响支付码本身。
+ 解析失败、超时、最终落到非支付宝域名,都返回空 `PayTargets`:调用方回落到
+ 本站短链,绝不能让解析失败影响支付码本身。
"""
url = str(pay_url or "").strip()
if not url.startswith(("http://", "https://")):
- return ""
+ return PayTargets()
try:
response = requests.get(
url,
@@ -87,10 +123,22 @@ def resolve_pay_entry_url(pay_url: str, *, timeout: float = 6.0) -> str:
)
try:
final_url = str(getattr(response, "url", "") or "").strip()
+ html = (
+ str(getattr(response, "text", "") or "")
+ if is_alipay_url(final_url)
+ else ""
+ )
finally:
close = getattr(response, "close", None)
if callable(close):
close()
except Exception: # noqa: BLE001 - 解析失败不能打断支付码生成
- return ""
- return final_url if is_alipay_url(final_url) else ""
+ return PayTargets()
+ if not is_alipay_url(final_url):
+ return PayTargets()
+ return PayTargets(cashier_url=final_url, qr_code=extract_pay_qr_code(html))
+
+
+def resolve_pay_entry_url(pay_url: str, *, timeout: float = 6.0) -> str:
+ """只要收银台直达地址(约 93 字符);解析不到返回空串。"""
+ return resolve_pay_targets(pay_url, timeout=timeout).cashier_url
diff --git a/web/backend/services/huya_runner_recharge.py b/web/backend/services/huya_runner_recharge.py
index 7f753c0..6332a5c 100644
--- a/web/backend/services/huya_runner_recharge.py
+++ b/web/backend/services/huya_runner_recharge.py
@@ -11,7 +11,7 @@ from sqlalchemy.orm import Session
from core.huya.wss_client import POINTS_REDEEM_GOODS_NUM, SHOP_APP_SCENE
from ..models import HuyaAccount, HuyaRechargeGoodsSnapshot, HuyaTask
-from .huya_pay_link import pay_qr_path, resolve_pay_entry_url
+from .huya_pay_link import pay_qr_path, resolve_pay_targets
from .huya_shop_flow import run_open_flow
HUYA_RECHARGE_ACT_ID = 25135
@@ -528,14 +528,21 @@ class RechargeMixin:
order_result = outcome.order_raw
pay_result = outcome.pay_raw
amount = outcome.amount
- # 二维码内容:优先支付宝收银台直达地址(约 74 字符、公网可达,手机一定打得开),
- # 解析不到再回落到本站短链(要求本页域名手机能访问)。
- pay_cashier_url = resolve_pay_entry_url(outcome.pay_url)
- if pay_cashier_url:
- self._push_log("info", f"[{uid}] 支付二维码改用支付宝收银台直达地址")
+ # 二维码内容优先级(见 huya_pay_link 模块头):
+ # ① 支付宝扫码支付码 https://qr.alipay.com/xxx —— 手机扫码直接进付款;
+ # ② 收银台直达地址(PC 版页面,拿不到 ① 时才用,有些安卓机扫开只看到小二维码);
+ # ③ 本站短链(要求本页域名手机能访问)。
+ pay_targets = resolve_pay_targets(outcome.pay_url)
+ if pay_targets.qr_code:
+ self._push_log("info", f"[{uid}] 支付二维码使用支付宝扫码支付码")
+ elif pay_targets.cashier_url:
+ self._push_log(
+ "warning",
+ f"[{uid}] 未取到支付宝扫码支付码,二维码回落到收银台地址(部分手机扫开不触发付款)",
+ )
else:
self._push_log(
- "warning", f"[{uid}] 未解析到支付宝收银台直达地址,二维码回落到本站短链"
+ "warning", f"[{uid}] 未解析到支付宝收银台地址,二维码回落到本站短链"
)
result = {
"spu_id": spu_id,
@@ -552,9 +559,11 @@ class RechargeMixin:
"pay_order_id": outcome.pay_order_id,
"pay_url": outcome.pay_url,
# 支付地址 1600+ 字符,直接编成二维码扫不动(真机反馈);给前端两条更短的路径:
- # pay_cashier_url = 支付宝收银台直达地址(公网域名,任意手机可打开);
+ # pay_qr_code = 收银台页面里的 https://qr.alipay.com/xxx(约 40 字符,扫码即付款);
+ # pay_cashier_url = 收银台直达地址(PC 版页面);
# pay_qr_path = 本站短链 /api/huya/pay/?k=...(需本页域名手机可达)。
- "pay_cashier_url": pay_cashier_url,
+ "pay_qr_code": pay_targets.qr_code,
+ "pay_cashier_url": pay_targets.cashier_url,
"pay_qr_path": pay_qr_path(task.id) if task is not None else "",
# 复用未支付旧单时为那张单的原始信息(限购商品:旧单没付完就下不了新单)
"reused_unpaid_order": bool(outcome.reused_order),
diff --git a/web/frontend/src/pages/HuyaElitePage.tsx b/web/frontend/src/pages/HuyaElitePage.tsx
index b8671d3..25c8179 100644
--- a/web/frontend/src/pages/HuyaElitePage.tsx
+++ b/web/frontend/src/pages/HuyaElitePage.tsx
@@ -79,14 +79,17 @@ function qrDoneLabel(task: HuyaTaskItem): string | null {
}
/**
- * 二维码里放**短地址**:支付宝网关地址 1600+ 字符,直接编成二维码有 140+ 个模块,
- * 手机根本扫不上(生产实测)。优先级:
- * 1. `pay_cashier_url` —— 支付宝收银台直达地址(约 74 字符、公网域名,任意手机可扫码支付);
- * 2. `pay_qr_path` —— 本站短链 `/api/huya/pay/?k=<签名>`,手机打开后 302 跳到真实支付地址,
- * 但要求当前页面域名手机能访问到(localhost 不行);
- * 3. `pay_url` —— 原始网关地址,兜底。
+ * 二维码里放什么(优先级,见后端 huya_pay_link 模块头):
+ * 1. `pay_qr_code` —— 收银台页面里真正的扫码内容 `https://qr.alipay.com/xxx`(约 40 字符),
+ * 支付宝 App 扫它直接进付款,手机相机扫它也会拉起支付宝;
+ * 2. `pay_cashier_url` —— 收银台直达地址,但那是 **PC 版**页面(真机反馈:有的安卓机
+ * 扫开只看到左上角一个小二维码,不触发付款),只在拿不到 ① 时兜底;
+ * 3. `pay_qr_path` —— 本站短链 `/api/huya/pay/?k=<签名>`,要求本页域名手机能访问(localhost 不行);
+ * 4. `pay_url` —— 原始网关地址 1600+ 字符,二维码太密扫不动,最后兜底。
*/
function payQrValue(task: HuyaTaskItem): string {
+ const qrCode = resultText(task, 'pay_qr_code');
+ if (qrCode) return qrCode;
const cashier = resultText(task, 'pay_cashier_url');
if (cashier) return cashier;
const path = resultText(task, 'pay_qr_path');