Files
live-hub-py/web/backend/services/huya_pay_link.py
T
yml2213 0b9a01ab3f feat(huya): 开通支付码复用未支付旧单 + 二维码改用支付宝收银台直达地址
- 限购商品(精英宝典 buy_limit=1)挂着未支付旧单时,真正先失败的是购买权限
  校验 checkUserBuyAuth(真机 703「已超过该商品的购买上限,下单失败~」),
  createOrderV5 根本不会执行;现在 checkUserBuyAuth 与 createOrderV5 两处失败
  都会回退 find_reusable_order,拿同一商品的未支付旧单继续 payOrderSubmitV5。
- 新增支付宝收银台直达地址解析(resolve_pay_entry_url):跟随网关 302 后的
  excashier.alipay.com/standard/auth.htm?payOrderId=…(93 字符,原始网关地址
  1616 字符)写入结果 pay_cashier_url;解析失败回落本站短链
  /api/huya/pay/<task_id>?k=<hmac>,后端跑在 localhost 时客户也扫得开。
- 前端 QRCode 显式 color="#000" bgColor="#fff":antd v6 暗色主题默认
  color=token.colorText、bgColor=transparent,白码落在白底容器里根本看不见。
- 支付成功后列表「二维码」列不再展示二维码(qrDoneLabel → 已支付/已开通)。

测试:pytest 767 passed / 6 skipped;typecheck、eslint、ruff 通过。
2026-10-15 19:28:26 +08:00

97 lines
3.8 KiB
Python

"""开通支付码的**二维码内容**:让手机一定扫得动、打得开。
问题(生产实测 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/<task_id>?k=<hmac>`(约 38 字符):手机打开后由
后端 302 跳到上面的网关地址。要求「本页域名」手机能访问到(localhost 不行)。
扫码的是没登录的手机,所以路由只校验 HMAC 签名(用 JWT 密钥派生,不额外存库)。
"""
from __future__ import annotations
import hashlib
import hmac
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"
)
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 resolve_pay_entry_url(pay_url: str, *, timeout: float = 6.0) -> str:
"""跟随支付宝网关的 302,取**收银台直达地址**(约 74 字符,公网可达)。
真机实测(2026-10-15):`openapi.alipay.com/gateway.do?...` 会 302 到
`unitradeprod.alipay.com/appAssign.htm?...` 再 302 到
`https://excashier.alipay.com/standard/auth.htm?payOrderId=<id>`。
这个地址编二维码又小又好扫,且不依赖本服务的域名能不能被手机访问。
解析失败、超时、或最终落到非支付宝域名都返回空串 —— 调用方回落到本站短链,
绝不能让解析失败影响支付码本身。
"""
url = str(pay_url or "").strip()
if not url.startswith(("http://", "https://")):
return ""
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()
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 ""