Files
live-hub-py/web/backend/services/huya_pay_link.py
T
yml2213 bf1e2e9361 fix(huya): 支付二维码改用收银台页面里的扫码内容 qr.alipay.com
真机复现(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 通过。
2026-10-15 19:51:51 +08:00

145 lines
5.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""开通支付码的**二维码内容**:让手机一定扫得动、打得开、且能直接付款。
问题(生产实测 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