- 限购商品(精英宝典 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 通过。
97 lines
3.8 KiB
Python
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 ""
|