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 通过。
This commit is contained in:
@@ -16,6 +16,7 @@ from core.huya.frame_decoder import decode_struct_bytes
|
||||
from core.huya.http_client import HuyaHttpClient
|
||||
from core.huya.shop_structs import ShopRawRsp
|
||||
from web.backend.models import HuyaAccount
|
||||
from web.backend.services.huya_pay_link import pay_qr_path
|
||||
from web.backend.services.huya_runner import HuyaBatchRunner
|
||||
|
||||
UID = 1199668595203
|
||||
@@ -332,6 +333,7 @@ def test_create_recharge_order_confirms_elite_open_after_payment(db, monkeypatch
|
||||
supplier_name="和平精英官方小店",
|
||||
promotion={},
|
||||
promotion_info="本单无返利",
|
||||
reused_order={},
|
||||
)
|
||||
|
||||
shop_client = _StubClient(redeem=_redeem(REDEEM_OPENED_HEX), season=True)
|
||||
@@ -380,13 +382,20 @@ def test_create_recharge_order_confirms_elite_open_after_payment(db, monkeypatch
|
||||
"web.backend.services.huya_runner_recharge.run_open_flow",
|
||||
lambda *args, **kwargs: outcome,
|
||||
)
|
||||
# 二维码内容优先用支付宝收银台直达地址(真机:网关 302 后的落点)
|
||||
monkeypatch.setattr(
|
||||
"web.backend.services.huya_runner_recharge.resolve_pay_entry_url",
|
||||
lambda _pay_url, **_kwargs: (
|
||||
"https://excashier.alipay.com/standard/auth.htm?payOrderId=x.25"
|
||||
),
|
||||
)
|
||||
account = HuyaAccount(uid=str(UID), cookie=COOKIE)
|
||||
db.add(account)
|
||||
db.commit()
|
||||
|
||||
runner._execute_create_recharge_order(
|
||||
db,
|
||||
None,
|
||||
SimpleNamespace(id=587),
|
||||
account,
|
||||
{"uid": UID, "cookie": COOKIE},
|
||||
{"room_pid": "1199650619883", "pay_channel": "Zfb", "sid": "2203"},
|
||||
@@ -399,6 +408,10 @@ def test_create_recharge_order_confirms_elite_open_after_payment(db, monkeypatch
|
||||
assert result["elite_open_time"] == 1792053457
|
||||
assert result["elite_expire_time"] == 1798732799
|
||||
assert result["post_payment_points"] == 280
|
||||
# 二维码内容给前端两条短地址(原始支付地址 1626 字符,扫码扫不动)
|
||||
assert result["pay_qr_path"] == pay_qr_path(587)
|
||||
# 优先用支付宝收银台直达地址(公网域名,localhost 下客户也扫得开)
|
||||
assert result["pay_cashier_url"].startswith("https://excashier.alipay.com/")
|
||||
assert account.elite_opened is True
|
||||
assert account.elite_expire_time == 1798732799
|
||||
assert account.points == 280
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"""开通支付码二维码内容: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,
|
||||
is_alipay_url,
|
||||
pay_qr_path,
|
||||
pay_token,
|
||||
resolve_pay_entry_url,
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
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`` 和 ``close()``。"""
|
||||
|
||||
def __init__(self, url: str):
|
||||
self.url = url
|
||||
self.closed = False
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
def _patch_get(monkeypatch, *, final_url: str | None = None, 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 "")
|
||||
|
||||
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)
|
||||
|
||||
# 收银台直达地址约 74 字符 → 二维码模块数远低于原始地址
|
||||
assert len(resolved) < 120
|
||||
assert len(resolved) < len(ALIPAY_LONG_URL)
|
||||
|
||||
|
||||
def test_is_alipay_url_matches_only_alipay_domains():
|
||||
assert is_alipay_url(CASHIER_URL) 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
|
||||
@@ -0,0 +1,310 @@
|
||||
"""限购商品的旧订单复用:已有未支付订单时不该「已超过该商品的购买上限」直接失败。
|
||||
|
||||
生产反馈(2026-10-15):账号上挂着上一张没付掉的宝典订单时,先是**购买权限校验**
|
||||
`checkUserBuyAuth` 返回 703
|
||||
|
||||
已超过该商品的购买上限,下单失败~(如有未支付订单,请及时完成支付)
|
||||
|
||||
(后端日志 18:34 / 18:48 / 19:00 三批都停在 checkUserBuyAuth,createOrderV5 根本没发),
|
||||
下单接口 `createOrderV5` 也可能返回同一句。限购 1 件的商品,旧单没付掉就再也下不了
|
||||
新单 —— 所以这两种失败都要复用那张旧单去发起支付(官方页面同样是复用旧单继续付)。
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from web.backend.services.huya_shop_flow import find_reusable_order, run_open_flow
|
||||
|
||||
BUY_LIMIT_MESSAGE = (
|
||||
"已超过该商品的购买上限,下单失败~(如有未支付订单,请及时完成支付)"
|
||||
)
|
||||
SPU_ID = "hy-8328015"
|
||||
|
||||
UNPAID_ELITE_ORDER = {
|
||||
"order_id": "11346649",
|
||||
"item_name": "精英宝典",
|
||||
"order_status": 10,
|
||||
"pay_time": 0,
|
||||
"goods_detail": {"spu_id": SPU_ID, "sku_id": 7756811},
|
||||
}
|
||||
PAID_ELITE_ORDER = {
|
||||
"order_id": "11346648",
|
||||
"item_name": "精英宝典",
|
||||
"order_status": 60,
|
||||
"pay_time": 1791895396000,
|
||||
"goods_detail": {"spu_id": SPU_ID, "sku_id": 7756811},
|
||||
}
|
||||
OTHER_GOODS_ORDER = {
|
||||
"order_id": "11346647",
|
||||
"item_name": "精英币",
|
||||
"order_status": 10,
|
||||
"pay_time": 0,
|
||||
"goods_detail": {"spu_id": "hy-8327356", "sku_id": 7759407},
|
||||
}
|
||||
#: 真机 2026-10-15:账号 66 的旧单被用户取消 → order_status=90、pay_time=0
|
||||
CANCELLED_ELITE_ORDER = {
|
||||
"order_id": "11357769",
|
||||
"item_name": "精英宝典",
|
||||
"order_status": 90,
|
||||
"pay_time": 0,
|
||||
"goods_detail": {"spu_id": SPU_ID, "sku_id": 7756811},
|
||||
}
|
||||
|
||||
|
||||
class _Order(SimpleNamespace):
|
||||
def to_dict(self) -> dict:
|
||||
return dict(self.__dict__)
|
||||
|
||||
|
||||
class _OrderList(SimpleNamespace):
|
||||
pass
|
||||
|
||||
|
||||
class _Client:
|
||||
"""商城链路假客户端:只造流程需要的那几个返回。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
create_code: int,
|
||||
create_message: str = "",
|
||||
orders=None,
|
||||
auth_code: int = 200,
|
||||
auth_message: str = "",
|
||||
):
|
||||
self.create_code = create_code
|
||||
self.create_message = create_message
|
||||
self.orders = orders or []
|
||||
self.auth_code = auth_code
|
||||
self.auth_message = auth_message
|
||||
self.pay_order_ids: list[int] = []
|
||||
self.order_list_calls = 0
|
||||
self.create_calls = 0
|
||||
|
||||
def get_goods_info(self, **kwargs):
|
||||
return SimpleNamespace(
|
||||
code=200,
|
||||
message="",
|
||||
to_dict=lambda: {
|
||||
"sku_id": 7756811,
|
||||
"name": "精英宝典",
|
||||
"price": 3000,
|
||||
},
|
||||
)
|
||||
|
||||
def get_supplier_info(self, **kwargs):
|
||||
resp = SimpleNamespace(code=200, message="", payload={})
|
||||
resp.value = lambda *path: "和平精英官方小店"
|
||||
return resp
|
||||
|
||||
def list_pay_channels(self, **kwargs):
|
||||
return SimpleNamespace(code=200, message="", channels=["Zfb"])
|
||||
|
||||
def check_hy_protocol(self, **kwargs):
|
||||
return SimpleNamespace(code=200, message="")
|
||||
|
||||
def play_mall_get_my_promotion(self, **kwargs):
|
||||
return SimpleNamespace(code=200, message="", to_dict=lambda: {"code": 200})
|
||||
|
||||
def calc_order_promotion(self, **kwargs):
|
||||
return SimpleNamespace(code=200, message="", to_dict=lambda: {"code": 200})
|
||||
|
||||
def accept_hy_protocol(self, **kwargs):
|
||||
return SimpleNamespace(code=200, message="")
|
||||
|
||||
def check_user_buy_auth(self, **kwargs):
|
||||
return SimpleNamespace(code=self.auth_code, message=self.auth_message)
|
||||
|
||||
def create_order(self, **kwargs):
|
||||
self.create_calls += 1
|
||||
if self.create_code == 200:
|
||||
return SimpleNamespace(
|
||||
code=200,
|
||||
message="",
|
||||
orderId=11346649,
|
||||
to_dict=lambda: {"order_id": 11346649, "code": 200},
|
||||
)
|
||||
return SimpleNamespace(
|
||||
code=self.create_code,
|
||||
message=self.create_message,
|
||||
orderId=0,
|
||||
to_dict=lambda: {"code": self.create_code, "message": self.create_message},
|
||||
)
|
||||
|
||||
def query_user_order_list(self, **kwargs):
|
||||
self.order_list_calls += 1
|
||||
return _OrderList(
|
||||
totalCount=len(self.orders),
|
||||
orders=[_Order(**item) for item in self.orders],
|
||||
)
|
||||
|
||||
def pay_order_submit(self, **kwargs):
|
||||
self.pay_order_ids.append(int(kwargs.get("order_id") or 0))
|
||||
return SimpleNamespace(
|
||||
code=200,
|
||||
message="",
|
||||
payUrl="https://openapi.alipay.com/gateway.do?probe=1",
|
||||
appOrderId="app-1",
|
||||
payOrderId="pay-1",
|
||||
amount=3000,
|
||||
to_dict=lambda: {
|
||||
"code": 200,
|
||||
"pay_url": "https://openapi.alipay.com/gateway.do?probe=1",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _run(client: _Client):
|
||||
return run_open_flow(
|
||||
client,
|
||||
uid=1199665170519,
|
||||
cookie="yyuid=1199665170519",
|
||||
pid=1199650619883,
|
||||
spu_id=SPU_ID,
|
||||
sku_id=7756811,
|
||||
count=1,
|
||||
pay_channel="Zfb",
|
||||
)
|
||||
|
||||
|
||||
def test_reuse_unpaid_order_when_create_hits_buy_limit():
|
||||
client = _Client(
|
||||
create_code=703, create_message=BUY_LIMIT_MESSAGE, orders=[UNPAID_ELITE_ORDER]
|
||||
)
|
||||
|
||||
outcome = _run(client)
|
||||
|
||||
assert outcome.ok is True
|
||||
assert outcome.order_id == 11346649 # 复用旧单
|
||||
assert outcome.message == "已复用未支付订单 #11346649"
|
||||
assert outcome.pay_url.startswith("https://openapi.alipay.com/gateway.do")
|
||||
assert client.pay_order_ids == [11346649] # 支付发给的是旧单
|
||||
assert client.create_calls == 1 # 先试过下单,失败后才复用
|
||||
assert outcome.reused_order["order_id"] == "11346649"
|
||||
|
||||
|
||||
def test_auth_buy_limit_reuses_unpaid_order_without_creating():
|
||||
"""真机 2026-10-15:限购校验先报 703,这时复用旧单且不该再发下单请求。"""
|
||||
client = _Client(
|
||||
create_code=200,
|
||||
orders=[UNPAID_ELITE_ORDER],
|
||||
auth_code=703,
|
||||
auth_message=BUY_LIMIT_MESSAGE,
|
||||
)
|
||||
|
||||
outcome = _run(client)
|
||||
|
||||
assert outcome.ok is True
|
||||
assert outcome.order_id == 11346649
|
||||
assert outcome.message == "已复用未支付订单 #11346649"
|
||||
assert client.pay_order_ids == [11346649]
|
||||
assert client.create_calls == 0
|
||||
assert outcome.reused_order["order_status"] == 10
|
||||
|
||||
|
||||
def test_auth_buy_limit_without_unpaid_order_keeps_original_error():
|
||||
"""没有可复用的旧单时保持原样:报限购错误、不下单、不起支付。"""
|
||||
client = _Client(
|
||||
create_code=200,
|
||||
orders=[],
|
||||
auth_code=703,
|
||||
auth_message=BUY_LIMIT_MESSAGE,
|
||||
)
|
||||
|
||||
outcome = _run(client)
|
||||
|
||||
assert outcome.ok is False
|
||||
assert outcome.message == BUY_LIMIT_MESSAGE
|
||||
assert client.pay_order_ids == []
|
||||
assert client.create_calls == 0
|
||||
|
||||
|
||||
def test_auth_buy_limit_ignores_paid_and_cancelled_orders():
|
||||
client = _Client(
|
||||
create_code=200,
|
||||
orders=[CANCELLED_ELITE_ORDER, PAID_ELITE_ORDER],
|
||||
auth_code=703,
|
||||
auth_message=BUY_LIMIT_MESSAGE,
|
||||
)
|
||||
|
||||
outcome = _run(client)
|
||||
|
||||
assert outcome.ok is False
|
||||
assert outcome.message == BUY_LIMIT_MESSAGE
|
||||
assert client.pay_order_ids == []
|
||||
|
||||
|
||||
def test_auth_no_response_fails_without_touching_orders():
|
||||
client = _Client(create_code=200, orders=[UNPAID_ELITE_ORDER], auth_code=200)
|
||||
client.check_user_buy_auth = lambda **kwargs: None
|
||||
|
||||
outcome = _run(client)
|
||||
|
||||
assert outcome.ok is False
|
||||
assert outcome.message == "购买权限校验接口无响应"
|
||||
assert client.create_calls == 0
|
||||
assert client.order_list_calls == 0
|
||||
|
||||
|
||||
def test_paid_order_is_not_reused():
|
||||
client = _Client(
|
||||
create_code=703, create_message=BUY_LIMIT_MESSAGE, orders=[PAID_ELITE_ORDER]
|
||||
)
|
||||
|
||||
outcome = _run(client)
|
||||
|
||||
assert outcome.ok is False
|
||||
assert outcome.message == BUY_LIMIT_MESSAGE
|
||||
assert client.pay_order_ids == []
|
||||
|
||||
|
||||
def test_cancelled_order_is_not_reused():
|
||||
"""已取消(90)的旧单不能复用(真机:用户取消后状态变 90)。"""
|
||||
client = _Client(
|
||||
create_code=703,
|
||||
create_message=BUY_LIMIT_MESSAGE,
|
||||
orders=[CANCELLED_ELITE_ORDER],
|
||||
)
|
||||
|
||||
outcome = _run(client)
|
||||
|
||||
assert outcome.ok is False
|
||||
assert client.pay_order_ids == []
|
||||
|
||||
|
||||
def test_other_goods_order_is_not_reused():
|
||||
client = _Client(
|
||||
create_code=703, create_message=BUY_LIMIT_MESSAGE, orders=[OTHER_GOODS_ORDER]
|
||||
)
|
||||
|
||||
outcome = _run(client)
|
||||
|
||||
assert outcome.ok is False
|
||||
assert client.pay_order_ids == []
|
||||
|
||||
|
||||
def test_create_success_does_not_look_up_old_orders():
|
||||
client = _Client(create_code=200)
|
||||
|
||||
outcome = _run(client)
|
||||
|
||||
assert outcome.ok is True
|
||||
assert outcome.message == "已生成支付二维码"
|
||||
assert outcome.reused_order == {}
|
||||
# 下单成功就不该多打一次订单列表
|
||||
assert client.order_list_calls == 0
|
||||
|
||||
|
||||
def test_find_reusable_order_skips_to_next_page():
|
||||
class _PagedClient(_Client):
|
||||
def query_user_order_list(self, **kwargs):
|
||||
self.order_list_calls += 1
|
||||
offset = int(kwargs.get("offset") or 0)
|
||||
page = [OTHER_GOODS_ORDER] * 20 if offset == 0 else [UNPAID_ELITE_ORDER]
|
||||
return _OrderList(totalCount=21, orders=[_Order(**item) for item in page])
|
||||
|
||||
client = _PagedClient(create_code=200)
|
||||
|
||||
reused = find_reusable_order(client, uid=1, cookie="c", spu_id=SPU_ID)
|
||||
|
||||
assert reused["order_id"] == "11346649"
|
||||
assert client.order_list_calls == 2
|
||||
@@ -51,6 +51,8 @@ from .workbench import (
|
||||
update_huya_workbench_accounts,
|
||||
ws_huya_logs,
|
||||
)
|
||||
from .pay import redirect_huya_pay
|
||||
from .pay import router as pay_router
|
||||
from .workbench import router as workbench_router
|
||||
|
||||
router = APIRouter(prefix="/api/huya", tags=["虎牙"])
|
||||
@@ -60,6 +62,7 @@ router.include_router(register_router)
|
||||
router.include_router(cookies_router)
|
||||
router.include_router(workbench_router)
|
||||
router.include_router(devices_router)
|
||||
router.include_router(pay_router)
|
||||
|
||||
__all__ = [
|
||||
"app_password_login_account",
|
||||
@@ -90,6 +93,7 @@ __all__ = [
|
||||
"list_tasks",
|
||||
"password_login_account",
|
||||
"password_login_selected_accounts",
|
||||
"redirect_huya_pay",
|
||||
"router",
|
||||
"send_sms_code",
|
||||
"set_account_tag",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""虎牙支付短链:把「开通支付码」的二维码内容换成可扫的短链。
|
||||
|
||||
手机扫码时没有登录态(不是当前浏览器),所以这条路由**不带鉴权依赖**,
|
||||
只校验 `huya_pay_link.pay_qr_path` 里那个 HMAC 签名;签名不对或任务里没有
|
||||
支付地址就 404。除了跳到支付宝/微信的支付地址,这里不返回任何数据。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...database import get_db
|
||||
from ...models import HuyaTask
|
||||
from ...services.huya_pay_link import verify_pay_token
|
||||
|
||||
|
||||
router = APIRouter(tags=["虎牙"])
|
||||
|
||||
|
||||
@router.get("/pay/{task_id}")
|
||||
def redirect_huya_pay(task_id: int, k: str = "", db: Session = Depends(get_db)):
|
||||
"""302 到该任务真实的下单支付地址(供手机扫码后打开)。"""
|
||||
if not verify_pay_token(task_id, k):
|
||||
raise HTTPException(status_code=404, detail="支付链接无效")
|
||||
|
||||
task = db.query(HuyaTask).filter(HuyaTask.id == int(task_id)).first()
|
||||
pay_url = str(
|
||||
((task.result or {}) if task is not None else {}).get("pay_url") or ""
|
||||
)
|
||||
if not pay_url.startswith(("http://", "https://")):
|
||||
raise HTTPException(status_code=404, detail="支付链接不存在")
|
||||
|
||||
return RedirectResponse(pay_url, status_code=302)
|
||||
@@ -0,0 +1,96 @@
|
||||
"""开通支付码的**二维码内容**:让手机一定扫得动、打得开。
|
||||
|
||||
问题(生产实测 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 ""
|
||||
@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
|
||||
from core.huya.wss_client import POINTS_REDEEM_GOODS_NUM, SHOP_APP_SCENE
|
||||
|
||||
from ..models import HuyaAccount, HuyaRechargeGoodsSnapshot, HuyaTask
|
||||
from .huya_pay_link import pay_qr_path, resolve_pay_entry_url
|
||||
from .huya_shop_flow import run_open_flow
|
||||
|
||||
HUYA_RECHARGE_ACT_ID = 25135
|
||||
@@ -527,6 +528,15 @@ class RechargeMixin:
|
||||
order_result = outcome.order_raw
|
||||
pay_result = outcome.pay_raw
|
||||
amount = outcome.amount
|
||||
# 二维码内容:优先支付宝收银台直达地址(约 74 字符、公网可达,手机一定打得开),
|
||||
# 解析不到再回落到本站短链(要求本页域名手机能访问)。
|
||||
pay_cashier_url = resolve_pay_entry_url(outcome.pay_url)
|
||||
if pay_cashier_url:
|
||||
self._push_log("info", f"[{uid}] 支付二维码改用支付宝收银台直达地址")
|
||||
else:
|
||||
self._push_log(
|
||||
"warning", f"[{uid}] 未解析到支付宝收银台直达地址,二维码回落到本站短链"
|
||||
)
|
||||
result = {
|
||||
"spu_id": spu_id,
|
||||
"sku_id": sku_id,
|
||||
@@ -541,6 +551,14 @@ class RechargeMixin:
|
||||
"app_order_id": outcome.app_order_id,
|
||||
"pay_order_id": outcome.pay_order_id,
|
||||
"pay_url": outcome.pay_url,
|
||||
# 支付地址 1600+ 字符,直接编成二维码扫不动(真机反馈);给前端两条更短的路径:
|
||||
# pay_cashier_url = 支付宝收银台直达地址(公网域名,任意手机可打开);
|
||||
# pay_qr_path = 本站短链 /api/huya/pay/<task_id>?k=...(需本页域名手机可达)。
|
||||
"pay_cashier_url": pay_cashier_url,
|
||||
"pay_qr_path": pay_qr_path(task.id) if task is not None else "",
|
||||
# 复用未支付旧单时为那张单的原始信息(限购商品:旧单没付完就下不了新单)
|
||||
"reused_unpaid_order": bool(outcome.reused_order),
|
||||
"reused_order": outcome.reused_order or None,
|
||||
"payment_status": "pending",
|
||||
"payment_status_label": "等待支付",
|
||||
"payment_paid": False,
|
||||
@@ -554,6 +572,8 @@ class RechargeMixin:
|
||||
account.status = "recharge_order_created"
|
||||
account.updated_at = datetime.now(UTC)
|
||||
message = f"{product_name} x{count} {self._pay_channel_label(pay_channel)} {result['amount_text']}"
|
||||
if result.get("reused_unpaid_order"):
|
||||
message += f"(复用未支付订单 #{result['order_id']})"
|
||||
self._update_task_progress(
|
||||
worker_db, task, "running", f"{message},等待扫码支付", result
|
||||
)
|
||||
|
||||
@@ -20,6 +20,16 @@ from typing import Any
|
||||
#: 小黄车供应商(和平精英官方小店),抓包实测。
|
||||
SUPPLIER_ID_YELLOW_CAR = 87401
|
||||
|
||||
#: 订单列表里「已支付/已交付」的状态值(与任务侧 _is_huya_order_paid 对齐)。
|
||||
PAID_ORDER_STATUSES = frozenset({50, 55, 60, 70, 80})
|
||||
#: 只有这两种状态能被继续支付:10=待支付、1000=尾款待支付
|
||||
#: (core.huya.shop_structs.OrderStatus;90=已取消、60=已关闭都不能复用,
|
||||
#: 真机 2026-10-15:账号 66 的旧单被用户取消后 order_status 变成 90)
|
||||
REUSABLE_ORDER_STATUSES = frozenset({10, 1000})
|
||||
#: 复用旧单时最多翻几页订单。
|
||||
REUSE_ORDER_PAGE_SIZE = 20
|
||||
REUSE_ORDER_MAX_PAGES = 3
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShopOrderOutcome:
|
||||
@@ -43,6 +53,61 @@ class ShopOrderOutcome:
|
||||
unit_price: int = 0
|
||||
count: int = 1
|
||||
product_name: str = ""
|
||||
#: 复用了未支付的旧订单时为那张单的原始信息(限购商品没付掉就下不了新单)
|
||||
reused_order: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
def find_reusable_order(
|
||||
client: Any,
|
||||
*,
|
||||
uid: int,
|
||||
cookie: str,
|
||||
spu_id: str,
|
||||
page_size: int = REUSE_ORDER_PAGE_SIZE,
|
||||
max_pages: int = REUSE_ORDER_MAX_PAGES,
|
||||
) -> dict:
|
||||
"""找这个账号**同一商品**的未支付订单(用于复用旧单继续支付)。
|
||||
|
||||
真机(2026-10-15):已经有一张未支付的精英宝典订单时,**限购校验**
|
||||
`checkUserBuyAuth` 会先返回 703
|
||||
「已超过该商品的购买上限,下单失败~(如有未支付订单,请及时完成支付)」,
|
||||
`createOrderV5` 根本不会执行(下单接口有时也会返回同一句)。限购 1 件的
|
||||
商品旧单没付掉就再也下不了新单,这时应该拿旧单去发起支付,而不是把任务
|
||||
判失败(官方页面同样是复用旧单继续付)。
|
||||
真机核对(账号 hy_300546644):未支付单 #11353073 order_status=10、
|
||||
pay_time=0、goods_detail.spu_id=hy-8328015;已取消单是 order_status=90。
|
||||
查不到 / 接口不可用都返回空 dict。
|
||||
"""
|
||||
offset = 0
|
||||
for _page in range(max(1, max_pages)):
|
||||
resp = client.query_user_order_list(
|
||||
uid=uid,
|
||||
guid="",
|
||||
cookie=cookie,
|
||||
offset=offset,
|
||||
page_size=page_size,
|
||||
order_type=1,
|
||||
status=0,
|
||||
)
|
||||
if resp is None:
|
||||
return {}
|
||||
orders = list(getattr(resp, "orders", None) or [])
|
||||
for order in orders:
|
||||
item = order.to_dict() if hasattr(order, "to_dict") else dict(order)
|
||||
goods = item.get("goods_detail") or {}
|
||||
if str(goods.get("spu_id") or "") != str(spu_id or ""):
|
||||
continue
|
||||
if int(item.get("pay_time") or 0) > 0:
|
||||
continue
|
||||
# 只认「待支付」:已取消(90)/已关闭(60) 的旧单付不了
|
||||
if int(item.get("order_status") or 0) not in REUSABLE_ORDER_STATUSES:
|
||||
continue
|
||||
return item
|
||||
total = int(getattr(resp, "totalCount", 0) or 0)
|
||||
if len(orders) < page_size or offset + len(orders) >= total:
|
||||
break
|
||||
offset += len(orders)
|
||||
return {}
|
||||
|
||||
|
||||
def run_open_flow(
|
||||
@@ -165,30 +230,6 @@ def run_open_flow(
|
||||
if stopped is not None and stopped():
|
||||
return fail("已停止", detail=detail, supplier_name=supplier_name)
|
||||
|
||||
# 7) 购买权限
|
||||
auth = client.check_user_buy_auth(
|
||||
uid=uid, guid="", cookie=cookie, spu_id=spu_id, sku_id=sku_id
|
||||
)
|
||||
if auth is None or auth.code != 200:
|
||||
return fail(
|
||||
getattr(auth, "message", "") or "购买权限校验失败",
|
||||
detail=detail,
|
||||
supplier_name=supplier_name,
|
||||
promotion=promotion_dict,
|
||||
)
|
||||
|
||||
# 8) 下单
|
||||
order_resp = client.create_order(
|
||||
uid=uid,
|
||||
guid="",
|
||||
cookie=cookie,
|
||||
pid=pid,
|
||||
spu_id=spu_id,
|
||||
sku_id=sku_id,
|
||||
item_count=count,
|
||||
game_id="0",
|
||||
order_type=6,
|
||||
)
|
||||
common = {
|
||||
"detail": detail,
|
||||
"supplier_name": supplier_name,
|
||||
@@ -202,22 +243,69 @@ def run_open_flow(
|
||||
"product_name": product_name,
|
||||
"pay_channel": pay_channel,
|
||||
}
|
||||
if order_resp is None:
|
||||
return fail("虎牙下单接口无响应", **common)
|
||||
order_raw = order_resp.to_dict()
|
||||
common["order_raw"] = order_raw
|
||||
if order_resp.code != 200 or not order_resp.orderId:
|
||||
return fail(
|
||||
order_resp.message or f"虎牙下单失败: {order_resp.code}",
|
||||
**common,
|
||||
order_id = 0
|
||||
order_raw: dict = {}
|
||||
reused_order: dict = {}
|
||||
|
||||
# 7) 购买权限
|
||||
#
|
||||
# 限购商品(精英宝典 buy_limit=1)已经挂着未支付旧单时,**先失败的是这一步**:
|
||||
# 真机 2026-10-15 实测 checkUserBuyAuth 直接返回 703「已超过该商品的购买上限,
|
||||
# 下单失败~(如有未支付订单,请及时完成支付)」,createOrderV5 根本不会执行。
|
||||
# 所以这里失败也要先找同商品的未支付旧单接着付,找不到才判失败。
|
||||
auth = client.check_user_buy_auth(
|
||||
uid=uid, guid="", cookie=cookie, spu_id=spu_id, sku_id=sku_id
|
||||
)
|
||||
if auth is None:
|
||||
return fail("购买权限校验接口无响应", **common)
|
||||
if auth.code != 200:
|
||||
auth_message = auth.message or f"购买权限校验失败: {auth.code}"
|
||||
reused_order = find_reusable_order(
|
||||
client, uid=uid, cookie=cookie, spu_id=spu_id
|
||||
)
|
||||
if not reused_order or not reused_order.get("order_id"):
|
||||
return fail(auth_message, **common)
|
||||
order_id = int(reused_order["order_id"])
|
||||
order_raw = dict(reused_order)
|
||||
emit(f"[{uid}] {auth_message};改为复用未支付订单 #{order_id} 继续支付")
|
||||
|
||||
# 8) 下单(限购商品若已有未支付的旧单,就复用旧单发起支付,而不是判失败)
|
||||
if not order_id:
|
||||
order_resp = client.create_order(
|
||||
uid=uid,
|
||||
guid="",
|
||||
cookie=cookie,
|
||||
pid=pid,
|
||||
spu_id=spu_id,
|
||||
sku_id=sku_id,
|
||||
item_count=count,
|
||||
game_id="0",
|
||||
order_type=6,
|
||||
)
|
||||
if order_resp is not None and order_resp.code == 200 and order_resp.orderId:
|
||||
order_id = int(order_resp.orderId)
|
||||
order_raw = order_resp.to_dict()
|
||||
else:
|
||||
create_message = (
|
||||
order_resp.message if order_resp is not None else "虎牙下单接口无响应"
|
||||
) or f"虎牙下单失败: {getattr(order_resp, 'code', 0)}"
|
||||
reused_order = find_reusable_order(
|
||||
client, uid=uid, cookie=cookie, spu_id=spu_id
|
||||
)
|
||||
if not reused_order or not reused_order.get("order_id"):
|
||||
return fail(create_message, **common)
|
||||
order_id = int(reused_order["order_id"])
|
||||
order_raw = dict(reused_order)
|
||||
emit(f"[{uid}] {create_message};改为复用未支付订单 #{order_id} 继续支付")
|
||||
common["order_raw"] = order_raw
|
||||
common["reused_order"] = reused_order
|
||||
|
||||
# 9) 起支付(出支付宝/微信二维码)
|
||||
pay_resp = client.pay_order_submit(
|
||||
uid=uid,
|
||||
guid="",
|
||||
cookie=cookie,
|
||||
order_id=order_resp.orderId,
|
||||
order_id=order_id,
|
||||
pay_type=pay_channel,
|
||||
pid=pid,
|
||||
item_count=count,
|
||||
@@ -235,8 +323,10 @@ def run_open_flow(
|
||||
amount = int(pay_resp.amount or unit_price * count or 0)
|
||||
return ShopOrderOutcome(
|
||||
ok=True,
|
||||
message="已生成支付二维码",
|
||||
order_id=int(order_resp.orderId),
|
||||
message=(
|
||||
f"已复用未支付订单 #{order_id}" if reused_order else "已生成支付二维码"
|
||||
),
|
||||
order_id=order_id,
|
||||
app_order_id=pay_resp.appOrderId,
|
||||
pay_order_id=pay_resp.payOrderId,
|
||||
pay_url=pay_resp.payUrl,
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
} from 'antd';
|
||||
import type { TableProps } from 'antd';
|
||||
import {
|
||||
CheckCircleOutlined, ColumnWidthOutlined, GiftOutlined, ImportOutlined, QrcodeOutlined,
|
||||
ReloadOutlined, SearchOutlined, SettingOutlined, ShoppingOutlined, StopOutlined,
|
||||
CheckCircleOutlined, ColumnWidthOutlined, CopyOutlined, GiftOutlined, ImportOutlined, QrcodeOutlined,
|
||||
ReloadOutlined, SearchOutlined, SettingOutlined, ShoppingOutlined, StopOutlined, ZoomInOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { huyaApi, type HuyaAccountItem, type HuyaConfig, type HuyaGoodsItem, type HuyaRechargeGoodsItem, type HuyaTaskItem } from '../api/modules';
|
||||
import HuyaExternalCkImportModal from '../components/HuyaExternalCkImportModal';
|
||||
@@ -65,13 +65,47 @@ function needsScanPay(task: HuyaTaskItem): boolean {
|
||||
return !(result.payment_paid === true || result.elite_opened === true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表「二维码」列的占位文案:扫码这件事做完后就别再挂二维码了(真机反馈:
|
||||
* 支付成功后那一格还挂着码,容易被当成待支付)。返回 null 表示仍要展示二维码。
|
||||
*
|
||||
* 只针对「开通支付码」:绑定二维码那类任务 status=success 只代表码生成成功,
|
||||
* 客户还没扫,必须继续展示。
|
||||
*/
|
||||
function qrDoneLabel(task: HuyaTaskItem): string | null {
|
||||
if (task.task_type !== 'create_recharge_order') return null;
|
||||
if (needsScanPay(task)) return null;
|
||||
return task.result?.elite_opened === true ? '已开通' : '已支付';
|
||||
}
|
||||
|
||||
/**
|
||||
* 二维码里放**短地址**:支付宝网关地址 1600+ 字符,直接编成二维码有 140+ 个模块,
|
||||
* 手机根本扫不上(生产实测)。优先级:
|
||||
* 1. `pay_cashier_url` —— 支付宝收银台直达地址(约 74 字符、公网域名,任意手机可扫码支付);
|
||||
* 2. `pay_qr_path` —— 本站短链 `/api/huya/pay/<id>?k=<签名>`,手机打开后 302 跳到真实支付地址,
|
||||
* 但要求当前页面域名手机能访问到(localhost 不行);
|
||||
* 3. `pay_url` —— 原始网关地址,兜底。
|
||||
*/
|
||||
function payQrValue(task: HuyaTaskItem): string {
|
||||
const cashier = resultText(task, 'pay_cashier_url');
|
||||
if (cashier) return cashier;
|
||||
const path = resultText(task, 'pay_qr_path');
|
||||
return path ? `${window.location.origin}${path}` : resultText(task, 'pay_url');
|
||||
}
|
||||
|
||||
/** 二维码内容指向本机时手机一定扫不开(localhost / 127.0.0.1 / 内网回环)。 */
|
||||
function isLoopbackUrl(url: string): boolean {
|
||||
return /^https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(:|\/|$)/i.test(url);
|
||||
}
|
||||
|
||||
function latestTask(tasks: HuyaTaskItem[], accountId: number): HuyaTaskItem | undefined {
|
||||
return tasks.filter((task) => task.account_id === accountId).sort((a, b) => b.id - a.id)[0];
|
||||
}
|
||||
|
||||
function taskQrUrl(task: HuyaTaskItem | undefined): string {
|
||||
if (!task) return '';
|
||||
return resultText(task, 'bind_redirect_url') || resultText(task, 'pay_url') || resultText(task, 'url');
|
||||
if (task.task_type === 'create_recharge_order') return payQrValue(task);
|
||||
return resultText(task, 'bind_redirect_url') || resultText(task, 'url');
|
||||
}
|
||||
|
||||
export default function HuyaElitePage() {
|
||||
@@ -102,6 +136,8 @@ export default function HuyaElitePage() {
|
||||
// 开通支付码:多选账号时可以一次生成多个码,弹窗里逐个展示(按批次收集)
|
||||
const [payBatchIds, setPayBatchIds] = useState<string[]>([]);
|
||||
const [payOpen, setPayOpen] = useState(false);
|
||||
// 放大扫码用(二维码在弹窗里开到底,手机才好扫)
|
||||
const [zoomPayTask, setZoomPayTask] = useState<HuyaTaskItem | null>(null);
|
||||
const [ckImportOpen, setCkImportOpen] = useState(false);
|
||||
const [config, setConfig] = useState<HuyaConfig | null>(null);
|
||||
const [configDraft, setConfigDraft] = useState<HuyaConfig | null>(null);
|
||||
@@ -279,6 +315,11 @@ export default function HuyaElitePage() {
|
||||
setPayOpen(true);
|
||||
};
|
||||
const payTaskLabel = (task: HuyaTaskItem) => task.account_nickname || task.account_uid || `#${task.account_id}`;
|
||||
const copyPayLink = async (text: string, label: string) => {
|
||||
if (!text) { message.warning('没有可复制的链接'); return; }
|
||||
try { await navigator.clipboard.writeText(text); message.success(`${label}已复制`); }
|
||||
catch { message.error('复制失败,请手动选中复制'); }
|
||||
};
|
||||
|
||||
// 列宽按比例分配(percent),表头与单元格统一居中,避免左右跳着看
|
||||
const accountColumns: TableProps<HuyaAccountItem>['columns'] = [
|
||||
@@ -289,7 +330,7 @@ export default function HuyaElitePage() {
|
||||
{ title: '精英宝典', width: '13%', align: 'center', ellipsis: true, render: (_value, row) => <Space direction="vertical" align="center" size={0} style={{ width: '100%' }}><Tag color={row.elite_opened === true ? 'success' : row.elite_opened === false ? 'default' : undefined}>{row.elite_opened === true ? '已开通' : row.elite_opened === false ? '未开通' : '未查询'}</Tag>{row.elite_opened === true && row.elite_open_time ? <Text type="secondary" style={{ fontSize: 12 }} ellipsis>{`开通 ${formatUnixSeconds(row.elite_open_time)}`}</Text> : null}{row.elite_expire_time ? <Text type="secondary" style={{ fontSize: 12 }} ellipsis>{`有效期至 ${formatUnixSeconds(row.elite_expire_time)}`}</Text> : null}</Space> },
|
||||
{ title: '积分', dataIndex: 'points', width: '7%', align: 'center', render: (value: number | null) => value ?? <Text type="secondary">-</Text> },
|
||||
{ title: '最近操作', width: '25%', align: 'center', ellipsis: true, render: (_value, row) => { const task = latestTask(tasks, row.id); return task ? <Space direction="vertical" align="center" size={1} style={{ width: '100%' }}><Text strong ellipsis>{TASK_LABELS[task.task_type] || task.task_type}</Text><Text type="secondary" ellipsis title={task.message}>{task.message || '-'}</Text><Text type="secondary" style={{ fontSize: 12 }}>{formatTime(task.finished_at || task.created_at)}</Text></Space> : <Text type="secondary">暂无操作</Text>; } },
|
||||
{ title: '二维码', width: '6%', align: 'center', render: (_value, row) => { const task = latestTask(tasks, row.id); const url = taskQrUrl(task); const image = resultText(task, 'mini_qrcode_image'); if (!url && !image) return <Text type="secondary">-</Text>; return <div onClick={(event) => { event.stopPropagation(); if (task) void openQrTask(task); }} style={{ cursor: 'pointer', display: 'inline-block', lineHeight: 0 }} title="点击查看二维码">{image ? <img src={`data:image/png;base64,${image}`} alt="二维码" style={{ width: 44, height: 44, objectFit: 'contain' }} /> : <QRCode value={url} size={44} bordered={false} />}</div>; } },
|
||||
{ title: '二维码', width: '6%', align: 'center', render: (_value, row) => { const task = latestTask(tasks, row.id); if (!task) return <Text type="secondary">-</Text>; const url = taskQrUrl(task); const image = resultText(task, 'mini_qrcode_image'); if (!url && !image) return <Text type="secondary">-</Text>; const doneLabel = qrDoneLabel(task); if (doneLabel) return <Text type="secondary">{doneLabel}</Text>; return <div onClick={(event) => { event.stopPropagation(); if (!task) return; if (task.task_type === 'create_recharge_order') setZoomPayTask(task); else void openQrTask(task); }} style={{ cursor: 'pointer', display: 'inline-block', lineHeight: 0, background: '#fff', padding: 3, borderRadius: 4 }} title="点击放大扫码">{image ? <img src={`data:image/png;base64,${image}`} alt="二维码" style={{ width: 52, height: 52, objectFit: 'contain' }} /> : <QRCode value={url} size={52} type="svg" errorLevel="L" bordered={false} color="#000" bgColor="#fff" />}</div>; } },
|
||||
{ title: '状态', width: '7%', align: 'center', render: (_value, row) => { const task = latestTask(tasks, row.id); const status = task?.status || row.status || ''; return <Tag color={TASK_COLORS[status] || 'default'}>{TASK_STATUS_LABELS[status] || status || '待操作'}</Tag>; } },
|
||||
];
|
||||
|
||||
@@ -322,7 +363,7 @@ export default function HuyaElitePage() {
|
||||
{layoutMode === 'split' ? <div style={{ flex: 1, minHeight: 0, display: 'flex', gap: 12, overflow: 'hidden' }}><Card size="small" title="账号" extra={<Tag color="blue">已选 {selectedIds.length}/{accounts.length}</Tag>} style={{ flex: 1, minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }} styles={{ body: { flex: 1, minHeight: 0, padding: '4px 6px', overflow: 'hidden', display: 'flex', flexDirection: 'column' } }}>{accountsBody}</Card><Card size="small" title="精英宝典操作" extra={<Space size={4}><Text type="secondary">并发</Text><InputNumber size="small" min={1} max={10} value={concurrency} onChange={(value) => setConcurrency(value || 1)} style={{ width: 58 }} /></Space>} style={{ width: 360, flexShrink: 0, minHeight: 0, overflow: 'hidden' }} styles={{ body: { padding: 8, overflowY: 'auto', height: 'calc(100% - 38px)' } }}>{operationBody}</Card></div> : <><Card size="small" title="精英宝典操作" extra={<Space size={4}><Text type="secondary">并发</Text><InputNumber size="small" min={1} max={10} value={concurrency} onChange={(value) => setConcurrency(value || 1)} style={{ width: 58 }} /></Space>} style={{ flexShrink: 0, marginBottom: 12 }} styles={{ body: { padding: 8 } }}>{operationBody}</Card><Card size="small" title="账号" extra={<Tag color="blue">已选 {selectedIds.length}/{accounts.length}</Tag>} style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }} styles={{ body: { flex: 1, minHeight: 0, padding: '4px 6px', overflow: 'hidden', display: 'flex', flexDirection: 'column' } }}>{accountsBody}</Card></>}
|
||||
<Text type="secondary" style={{ fontSize: 12, marginTop: 6 }}>兑换任务 {exchangeCount} 条;勾选账号后可批量执行,账号行支持右键操作。</Text>
|
||||
{contextMenu && <div style={contextMenuStyle} onClick={(event) => event.stopPropagation()}><Card size="small" title={contextMenu.accountIds.length > 1 ? '批量动作' : '账号动作'} styles={{ body: { padding: 4 } }} style={{ width: 220, boxShadow: '0 4px 12px rgba(0,0,0,0.14)' }}><Space direction="vertical" style={{ width: '100%' }}>{HUYA_QUICK_ACTIONS.map((item) => <Button key={item.key} block size="small" type="text" icon={item.icon} onClick={() => runContextAction(item.key)} disabled={starting} style={{ justifyContent: 'flex-start' }}>{TASK_LABELS[item.key] || item.key}</Button>)}</Space></Card></div>}
|
||||
<Modal open={qrTask !== null} title="绑定二维码" footer={null} onCancel={() => setQrTask(null)} centered>{qrTask && <div style={{ textAlign: 'center' }}>{resultText(qrTask, 'mini_qrcode_image') ? <img src={`data:image/png;base64,${resultText(qrTask, 'mini_qrcode_image')}`} alt="绑定二维码" style={{ width: 240, height: 240 }} /> : <QRCode value={resultText(qrTask, 'bind_redirect_url') || 'https://zt.huya.com/b02faae1/pc/index.html'} size={240} />}<p><Tag color={TASK_COLORS[qrTask.status] || 'default'}>{qrTask.message || qrTask.status}</Tag></p></div>}</Modal>
|
||||
<Modal open={qrTask !== null} title="绑定二维码" footer={null} onCancel={() => setQrTask(null)} centered>{qrTask && <div style={{ textAlign: 'center' }}>{resultText(qrTask, 'mini_qrcode_image') ? <img src={`data:image/png;base64,${resultText(qrTask, 'mini_qrcode_image')}`} alt="绑定二维码" style={{ width: 240, height: 240 }} /> : <QRCode value={resultText(qrTask, 'bind_redirect_url') || 'https://zt.huya.com/b02faae1/pc/index.html'} size={240} color="#000" bgColor="#fff" />}<p><Tag color={TASK_COLORS[qrTask.status] || 'default'}>{qrTask.message || qrTask.status}</Tag></p></div>}</Modal>
|
||||
<Modal
|
||||
open={payOpen}
|
||||
title={payTasks.length ? `开通支付码(${payDoneTasks.length}/${payTasks.length} 已完成)` : '开通支付码'}
|
||||
@@ -333,18 +374,26 @@ export default function HuyaElitePage() {
|
||||
>
|
||||
{payTasks.length === 0 ? <div style={{ padding: 24, textAlign: 'center' }}><Text type="secondary">正在为选中的账号生成支付码…</Text></div> : <div style={{ maxHeight: '62vh', overflowY: 'auto' }}>
|
||||
{payPendingTasks.length > 0 && <Space direction="vertical" size={10} style={{ width: '100%' }}>
|
||||
{payPendingTasks.map((task) => (
|
||||
{payPendingTasks.map((task) => {
|
||||
const qrValue = payQrValue(task);
|
||||
return (
|
||||
<div key={task.id} style={{ display: 'flex', gap: 16, alignItems: 'center', border: '1px solid rgba(128,128,128,.22)', borderRadius: 6, padding: 10, textAlign: 'left' }}>
|
||||
<div style={{ lineHeight: 0 }}>{resultText(task, 'mini_qrcode_image') ? <img src={`data:image/png;base64,${resultText(task, 'mini_qrcode_image')}`} alt="支付码" style={{ width: 150, height: 150 }} /> : <QRCode value={resultText(task, 'pay_url')} size={150} bordered={false} />}</div>
|
||||
<div style={{ background: '#fff', padding: 8, borderRadius: 6, lineHeight: 0 }}>{resultText(task, 'mini_qrcode_image') ? <img src={`data:image/png;base64,${resultText(task, 'mini_qrcode_image')}`} alt="支付码" style={{ width: 180, height: 180 }} /> : <QRCode value={qrValue} size={180} type="svg" errorLevel="L" bordered={false} color="#000" bgColor="#fff" />}</div>
|
||||
<Space direction="vertical" size={2} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text strong ellipsis>{payTaskLabel(task)}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>UID {task.account_uid || '-'}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{[resultText(task, 'product_name') || '精英宝典', resultText(task, 'amount_text')].filter(Boolean).join(' ')}</Text>
|
||||
<Tag color={TASK_COLORS[task.status] || 'default'}>{resultText(task, 'payment_status_label') || TASK_STATUS_LABELS[task.status] || task.status}</Tag>
|
||||
<Text type="secondary" ellipsis title={task.message}>{task.message || '-'}</Text>
|
||||
{isLoopbackUrl(qrValue) && <Text type="warning" style={{ fontSize: 12 }}>二维码是本机地址(localhost),手机扫不开;请用公网域名打开本页后重新生成</Text>}
|
||||
<Space size={6}>
|
||||
<Button size="small" icon={<ZoomInOutlined />} onClick={() => setZoomPayTask(task)}>放大扫码</Button>
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => void copyPayLink(qrValue, '支付链接')}>复制链接</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</Space>}
|
||||
{payDoneTasks.length > 0 && <div style={{ marginTop: payPendingTasks.length ? 12 : 0, textAlign: 'left' }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>已完成(二维码已收起)</Text>
|
||||
@@ -356,6 +405,14 @@ export default function HuyaElitePage() {
|
||||
</div>}
|
||||
</div>}
|
||||
</Modal>
|
||||
<Modal open={zoomPayTask !== null} title={`扫码支付 · ${zoomPayTask ? payTaskLabel(zoomPayTask) : ''}`} footer={null} onCancel={() => setZoomPayTask(null)} width={680} centered>
|
||||
{zoomPayTask && <div style={{ textAlign: 'center' }}>
|
||||
<div style={{ display: 'inline-block', background: '#fff', padding: 16, borderRadius: 8, lineHeight: 0 }}><QRCode value={payQrValue(zoomPayTask)} size={520} type="svg" errorLevel="L" bordered={false} color="#000" bgColor="#fff" /></div>
|
||||
<p><Space size={8}><Tag color={TASK_COLORS[zoomPayTask.status] || 'default'}>{resultText(zoomPayTask, 'payment_status_label') || zoomPayTask.status}</Tag><Text type="secondary">{resultText(zoomPayTask, 'amount_text')}</Text></Space></p>
|
||||
<Text type="secondary" style={{ fontSize: 12, wordBreak: 'break-all' }}>{payQrValue(zoomPayTask)}</Text>
|
||||
<div style={{ marginTop: 10 }}><Space><Button size="small" icon={<CopyOutlined />} onClick={() => void copyPayLink(payQrValue(zoomPayTask), '支付链接')}>复制支付链接</Button><Button size="small" onClick={() => void copyPayLink(resultText(zoomPayTask, 'pay_url'), '原始支付链接')}>复制原始链接</Button></Space></div>
|
||||
</div>}
|
||||
</Modal>
|
||||
<Modal open={importOpen} title="导入精英宝典账号" onCancel={() => setImportOpen(false)} onOk={() => { void saveAccounts([...new Set([...accounts.map((item) => item.id), ...importSelected])]).then(() => { setImportSelected([]); setImportOpen(false); }).catch((error) => message.error(getErrorMessage(error))); }} okText="导入" cancelText="取消"><Input.Search allowClear placeholder="搜索账号" value={importSearch} onChange={(event) => setImportSearch(event.target.value)} style={{ marginBottom: 8 }} /><Select allowClear placeholder="标签" value={importTag || undefined} onChange={(value) => setImportTag(value || '')} options={importTags.map((tag) => ({ value: tag, label: tag }))} style={{ width: '100%', marginBottom: 8 }} /><Table rowKey="id" size="small" dataSource={pool.filter((item) => !accounts.some((current) => current.id === item.id) && (!importSearch || [item.uid, item.nickname, item.username].some((value) => String(value || '').includes(importSearch))) && (!importTag || item.tag === importTag))} columns={[{ title: '账号', render: (_value, row) => row.nickname || row.username || row.uid }, { title: '标签', dataIndex: 'tag' }]} pagination={{ pageSize: 8 }} rowSelection={{ selectedRowKeys: importSelected, onChange: (keys) => setImportSelected(keys.map(Number)) }} /></Modal>
|
||||
<Modal open={configOpen} title="精英宝典设置" onCancel={() => setConfigOpen(false)} onOk={() => void saveConfig()} confirmLoading={configSaving} okText="保存" cancelText="取消" width={500}>{configLoading ? <div style={{ padding: 24, textAlign: 'center' }}>加载中...</div> : configDraft && <Space direction="vertical" size={10} style={{ width: '100%' }}>{configField('sid', '活动 SID')}{configField('bind_act_id', '绑定活动 ID')}{configField('outer_act_id', '外部活动 ID')}{configField('room_pid', '直播间 PID')}{configField('pay_channel', '支付渠道')}<Space style={{ width: '100%', justifyContent: 'space-between' }}><Text>空闲轮询间隔(秒)</Text><InputNumber min={5} max={60} value={refreshInterval} onChange={(value) => { const next = Math.min(60, Math.max(5, value || 15)); setRefreshInterval(next); localStorage.setItem('huya_elite_refresh_interval', String(next)); }} style={{ width: 300 }} /></Space></Space>}</Modal>
|
||||
<HuyaExternalCkImportModal open={ckImportOpen} onClose={() => setCkImportOpen(false)} tags={importTags} scope={SCOPE} onImported={(result) => { localStorage.setItem('huya_elite_workbench_account_ids', JSON.stringify(result.account_ids)); void load(); }} />
|
||||
|
||||
Reference in New Issue
Block a user