真机复现(2026-10-15):上一版把二维码内容放成了**收银台页面地址**
(excashier.alipay.com/standard/auth.htm?payOrderId=…),那是 PC 版页面
(页面里写死 qrcodeWidth=150)。有些安卓机是用系统相机/浏览器打开的,就停在
这张 PC 页上——只显示左上角一个很小的二维码,不触发付款。
收银台页面里真正的扫码内容是隐藏字段 `J_qrCode`:
<input name="qrCode" value="https://qr.alipay.com/upx03110ghym9vm37sx7255c" id="J_qrCode"/>
- huya_pay_link 新增 PayTargets / resolve_pay_targets() / extract_pay_qr_code():
跟随网关 302 取收银台页面并解析出 qr.alipay.com 扫码码(约 40 字符)。
- 出码时写入 pay_qr_code,保留 pay_cashier_url 兜底;拿不到扫码码会打 warning。
- 前端二维码优先级:pay_qr_code → pay_cashier_url → 本站短链 → 原始地址。
验证:全量 pytest 772 passed / 6 skipped;用真机那张收银台页面跑解析得到
https://qr.alipay.com/upx03110ghym9vm37sx7255c;typecheck / eslint / ruff 通过。
145 lines
5.9 KiB
Python
145 lines
5.9 KiB
Python
"""开通支付码的**二维码内容**:让手机一定扫得动、打得开、且能直接付款。
|
||
|
||
问题(生产实测 2026-10-15):`payOrderSubmitV5` 返回的 `payUrl` 是
|
||
`https://openapi.alipay.com/gateway.do?...&sign=...`,长度 **1626** 字符。
|
||
直接把它编成二维码是 version 29+(约 140 个模块),在 150px 的画面上每个模块
|
||
只有约 1 像素 —— 手机怎么都扫不上。
|
||
|
||
二维码里放什么,按顺序取第一个可用的:
|
||
|
||
1. **支付宝扫码支付码** `https://qr.alipay.com/<code>`(首选,约 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/<task_id>?k=<hmac>`(约 38 字符):手机打开后由
|
||
后端 302 跳到网关地址。要求「本页域名」手机能访问到(localhost 不行)。
|
||
扫码的是没登录的手机,所以路由只校验 HMAC 签名(用 JWT 密钥派生,不额外存库)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import hmac
|
||
import re
|
||
from dataclasses import dataclass
|
||
from urllib.parse import urlsplit
|
||
|
||
import requests
|
||
|
||
from ..security import SECRET_KEY_TYPED
|
||
|
||
TOKEN_LENGTH = 16
|
||
PAY_PATH_PREFIX = "/api/huya/pay"
|
||
|
||
#: 解析收银台页面时冒充浏览器(支付宝对无 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"
|
||
)
|
||
|
||
#: 收银台页面里真正的扫码内容(`<input name="qrCode" ... id="J_qrCode">`)。
|
||
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 抗枚举)。"""
|
||
return hmac.new(
|
||
SECRET_KEY_TYPED.encode("utf-8"),
|
||
f"huya-pay:{int(task_id)}".encode(),
|
||
hashlib.sha256,
|
||
).hexdigest()[:TOKEN_LENGTH]
|
||
|
||
|
||
def verify_pay_token(task_id: int, token: str) -> bool:
|
||
"""校验短链签名(常数时间比较)。"""
|
||
text = str(token or "").strip()
|
||
if not text:
|
||
return False
|
||
return hmac.compare_digest(pay_token(task_id), text)
|
||
|
||
|
||
def pay_qr_path(task_id: int) -> str:
|
||
"""短链路径;前端拼上 ``window.location.origin`` 就是手机能打开的地址。"""
|
||
return f"{PAY_PATH_PREFIX}/{int(task_id)}?k={pay_token(task_id)}"
|
||
|
||
|
||
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 extract_pay_qr_code(html: str) -> str:
|
||
"""从收银台页面里取真正的扫码内容 `https://qr.alipay.com/xxx`。
|
||
|
||
真机取到的页面里有(`id="J_qrCode"` 那个隐藏 input):
|
||
|
||
<input name="qrCode" type="hidden" value="https://qr.alipay.com/upx03110ghym9vm37sx7255c" id="J_qrCode"/>
|
||
|
||
取不到就返回空串(调用方回落到收银台地址 / 本站短链)。
|
||
"""
|
||
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=<id>`(约 93 字符),
|
||
页面里再带一个 `https://qr.alipay.com/<code>`(约 40 字符)——后者才是
|
||
「扫码支付」该放进二维码的内容(见模块头说明)。
|
||
|
||
解析失败、超时、最终落到非支付宝域名,都返回空 `PayTargets`:调用方回落到
|
||
本站短链,绝不能让解析失败影响支付码本身。
|
||
"""
|
||
url = str(pay_url or "").strip()
|
||
if not url.startswith(("http://", "https://")):
|
||
return PayTargets()
|
||
try:
|
||
response = requests.get(
|
||
url,
|
||
headers={"User-Agent": BROWSER_UA, "Accept": "text/html,*/*"},
|
||
timeout=timeout,
|
||
allow_redirects=True,
|
||
)
|
||
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 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
|