Files
live-hub-py/web/backend/services/huya_pay_link.py
T

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 = 15.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 = 15.0) -> str:
"""只要收银台直达地址(约 93 字符);解析不到返回空串。"""
return resolve_pay_targets(pay_url, timeout=timeout).cashier_url