"""开通支付码的**二维码内容**:让手机一定扫得动、打得开、且能直接付款。 问题(生产实测 2026-10-15):`payOrderSubmitV5` 返回的 `payUrl` 是 `https://openapi.alipay.com/gateway.do?...&sign=...`,长度 **1626** 字符。 直接把它编成二维码是 version 29+(约 140 个模块),在 150px 的画面上每个模块 只有约 1 像素 —— 手机怎么都扫不上。 二维码里放什么,按顺序取第一个可用的: 1. **支付宝扫码支付码** `https://qr.alipay.com/`(首选,约 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/?k=`(约 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" ) #: 收银台页面里真正的扫码内容(``)。 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): 取不到就返回空串(调用方回落到收银台地址 / 本站短链)。 """ 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=`(约 93 字符), 页面里再带一个 `https://qr.alipay.com/`(约 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