Files
live-hub-py/tests/test_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

244 lines
8.4 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.
"""开通支付码二维码内容:1600+ 字符的支付地址 → 手机扫得动、打得开的地址。
生产实测(2026-10-15):`payOrderSubmitV5` 返回的支付宝网关地址有 **1626** 字符
(`https://openapi.alipay.com/gateway.do?...&sign=...`)。直接把它编成二维码是
version 29+(140+ 个模块),在 150px 的画面上每个模块只有约 1 像素 —— 手机扫不上。
现在的优先级:
1. **支付宝收银台直达地址**(`resolve_pay_entry_url`):网关地址 302 两次后落到
`https://excashier.alipay.com/standard/auth.htm?payOrderId=...`(约 74 字符),
公网域名,后端跑在 localhost 时客户也扫得开;
2. **本站短链** `/api/huya/pay/<task_id>?k=<hmac>`(约 38 字符):后端 302 跳到网关地址,
但要求本页域名手机能访问到。
"""
import pytest
from fastapi import HTTPException
from web.backend.models import HuyaAccount, HuyaTask, User
from web.backend.routers.huya.pay import redirect_huya_pay
from web.backend.services import huya_pay_link
from web.backend.services.huya_pay_link import (
PAY_PATH_PREFIX,
PayTargets,
extract_pay_qr_code,
is_alipay_url,
pay_qr_path,
pay_token,
resolve_pay_entry_url,
resolve_pay_targets,
verify_pay_token,
)
# 真机取到的支付宝网关地址形态(长度同量级)
ALIPAY_LONG_URL = (
"https://openapi.alipay.com/gateway.do"
"?alipay_sdk=alipay-sdk-java-4.40.272.ALL&app_id=2017123456789012"
"&method=alipay.trade.page.pay&sign=" + "a" * 1500
)
# 真机实测:上面的网关地址 302 两次后的落点(任务 191 原样取回)
CASHIER_URL = (
"https://excashier.alipay.com/standard/auth.htm"
"?payOrderId=6cb86f1d259642ac843522b85dab8a52.25"
)
# 收银台页面里真正的扫码内容(真机页面原样片段)
QR_CODE_LITERAL = "https://qr.alipay.com/upx03110ghym9vm37sx7255c"
CASHIER_HTML = (
'<input name="timeoutUrl" type="hidden" value="" id="J_timeoutUrl"/>\n'
f'<input name="qrCode" type="hidden" value="{QR_CODE_LITERAL}" id="J_qrCode"/>\n'
'<input name="qrImgUrl" type="hidden" '
'value="https://mobilecodec.alipay.com/show.htm?code=upx03110ghym9vm37sx7255c" '
'id="J_qrImgUrl"/>\n'
f'<input name="qrRenewalURL" type="hidden" value="{CASHIER_URL}" id="J_qrRenewalURL"/>\n'
'<input type="hidden" name="qrcodeWidth" id="J_qrcodeWidth" value="150"/>'
)
def _create_pay_task(db, pay_url: str) -> HuyaTask:
user = User(username="pay-link-test", password_hash="x", role="support")
db.add(user)
db.commit()
account = HuyaAccount(uid="1199665170519", cookie="yyuid=1199665170519", tag="")
db.add(account)
db.commit()
task = HuyaTask(
batch_id="test-batch",
account_id=account.id,
task_type="create_recharge_order",
handbook_scope="elite",
status="running",
created_by=user.id,
result={"pay_url": pay_url} if pay_url else {},
)
db.add(task)
db.commit()
return task
def test_pay_token_round_trip_and_rejects_others():
token = pay_token(587)
assert len(token) == 16
assert verify_pay_token(587, token) is True
# 大小写不同就是另一个签名(hex 严格比对)
assert verify_pay_token(587, token.upper()) is False
assert verify_pay_token(587, "") is False
assert verify_pay_token(588, token) is False
def test_pay_qr_path_is_short_enough_to_scan():
path = pay_qr_path(587)
assert path.startswith(f"{PAY_PATH_PREFIX}/587?k=")
# 短链必须足够短:36 字符 → 二维码约 30 个模块(长链是 140+ 个模块)
assert len(path) <= 48
# 原始支付地址长度对比(就是它导致扫不上)
assert len(ALIPAY_LONG_URL) > 1000
def test_pay_redirect_route_returns_pay_url(db):
task = _create_pay_task(db, ALIPAY_LONG_URL)
response = redirect_huya_pay(task.id, pay_token(task.id), db)
assert response.status_code == 302
assert response.headers["location"] == ALIPAY_LONG_URL
def test_pay_redirect_route_rejects_bad_token(db):
task = _create_pay_task(db, ALIPAY_LONG_URL)
with pytest.raises(HTTPException) as excinfo:
redirect_huya_pay(task.id, "deadbeefdeadbeef", db)
assert excinfo.value.status_code == 404
def test_pay_redirect_route_rejects_task_without_pay_url(db):
task = _create_pay_task(db, "")
with pytest.raises(HTTPException) as excinfo:
redirect_huya_pay(task.id, pay_token(task.id), db)
assert excinfo.value.status_code == 404
class _FakeResponse:
"""假的 requests 响应:只需要 ``url`` / ``text`` / ``close()``。"""
def __init__(self, url: str, text: str = ""):
self.url = url
self.text = text
self.closed = False
def close(self) -> None:
self.closed = True
def _patch_get(
monkeypatch, *, final_url: str | None = None, body: str = "", error=None
):
calls: list[dict] = []
def fake_get(url, **kwargs):
calls.append({"url": url, **kwargs})
if error is not None:
raise error
return _FakeResponse(final_url or "", body)
monkeypatch.setattr(huya_pay_link.requests, "get", fake_get)
return calls
def test_resolve_pay_entry_url_follows_redirect_to_cashier(monkeypatch):
calls = _patch_get(monkeypatch, final_url=CASHIER_URL)
assert resolve_pay_entry_url(ALIPAY_LONG_URL) == CASHIER_URL
assert calls[0]["url"] == ALIPAY_LONG_URL
# 必须跟随 302,否则拿到的是网关页自己
assert calls[0]["allow_redirects"] is True
assert "Mozilla" in calls[0]["headers"]["User-Agent"]
def test_resolve_pay_entry_url_rejects_non_alipay_target(monkeypatch):
"""落到非支付宝域名就当没解析出来(调用方回落到本站短链)。"""
_patch_get(monkeypatch, final_url="https://evil.example.com/cashier")
assert resolve_pay_entry_url(ALIPAY_LONG_URL) == ""
def test_resolve_pay_entry_url_swallows_errors(monkeypatch):
_patch_get(monkeypatch, error=RuntimeError("network down"))
assert resolve_pay_entry_url(ALIPAY_LONG_URL) == ""
def test_resolve_pay_entry_url_ignores_blank_or_relative():
assert resolve_pay_entry_url("") == ""
assert resolve_pay_entry_url("/api/huya/pay/191?k=x") == ""
def test_resolve_pay_entry_url_is_shorter_than_raw_url(monkeypatch):
_patch_get(monkeypatch, final_url=CASHIER_URL)
resolved = resolve_pay_entry_url(ALIPAY_LONG_URL)
# 收银台直达地址约 93 字符 → 二维码模块数远低于原始地址
assert len(resolved) < 120
assert len(resolved) < len(ALIPAY_LONG_URL)
def test_extract_pay_qr_code_from_cashier_html():
"""收银台页面里的 J_qrCode 才是手机该扫的内容(真机抓到的原样片段)。"""
assert extract_pay_qr_code(CASHIER_HTML) == QR_CODE_LITERAL
def test_extract_pay_qr_code_ignores_other_alipay_urls():
"""只有 qr.alipay.com 才算扫码内容:qrImgUrl(mobilecodec)不算。"""
html = (
'<input name="qrImgUrl" type="hidden" '
'value="https://mobilecodec.alipay.com/show.htm?code=upx03110ghym9vm37sx7255c" '
'id="J_qrImgUrl"/>'
)
assert extract_pay_qr_code(html) == ""
assert extract_pay_qr_code("") == ""
def test_resolve_pay_targets_returns_cashier_and_qr_code(monkeypatch):
_patch_get(monkeypatch, final_url=CASHIER_URL, body=CASHIER_HTML)
targets = resolve_pay_targets(ALIPAY_LONG_URL)
assert targets.cashier_url == CASHIER_URL
assert targets.qr_code == QR_CODE_LITERAL
def test_resolve_pay_targets_keeps_cashier_when_qr_code_missing(monkeypatch):
"""拿不到 J_qrCode(比如停在中间页)时只回落到收银台地址。"""
_patch_get(monkeypatch, final_url=CASHIER_URL, body="<html>no qr here</html>")
targets = resolve_pay_targets(ALIPAY_LONG_URL)
assert targets.cashier_url == CASHIER_URL
assert targets.qr_code == ""
def test_resolve_pay_targets_empty_on_error(monkeypatch):
_patch_get(monkeypatch, error=RuntimeError("boom"))
targets = resolve_pay_targets(ALIPAY_LONG_URL)
assert targets == PayTargets()
def test_is_alipay_url_matches_only_alipay_domains():
assert is_alipay_url(CASHIER_URL) is True
assert is_alipay_url(QR_CODE_LITERAL) is True
assert is_alipay_url("https://openapi.alipay.com/gateway.do?x=1") is True
assert is_alipay_url("https://alipay.com/") is True
assert is_alipay_url("https://alipay.com.evil.com/") is False
assert is_alipay_url("https://excashier.alipay.com.evil.com/") is False
assert is_alipay_url("") is False