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');