From 0b9a01ab3f02dceb8af807f7b53bca1825f40d10 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 15 Oct 2026 19:28:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(huya):=20=E5=BC=80=E9=80=9A=E6=94=AF?= =?UTF-8?q?=E4=BB=98=E7=A0=81=E5=A4=8D=E7=94=A8=E6=9C=AA=E6=94=AF=E4=BB=98?= =?UTF-8?q?=E6=97=A7=E5=8D=95=20+=20=E4=BA=8C=E7=BB=B4=E7=A0=81=E6=94=B9?= =?UTF-8?q?=E7=94=A8=E6=94=AF=E4=BB=98=E5=AE=9D=E6=94=B6=E9=93=B6=E5=8F=B0?= =?UTF-8?q?=E7=9B=B4=E8=BE=BE=E5=9C=B0=E5=9D=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 限购商品(精英宝典 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/?k=,后端跑在 localhost 时客户也扫得开。 - 前端 QRCode 显式 color="#000" bgColor="#fff":antd v6 暗色主题默认 color=token.colorText、bgColor=transparent,白码落在白底容器里根本看不见。 - 支付成功后列表「二维码」列不再展示二维码(qrDoneLabel → 已支付/已开通)。 测试:pytest 767 passed / 6 skipped;typecheck、eslint、ruff 通过。 --- tests/test_huya_elite_query.py | 15 +- tests/test_huya_pay_link.py | 181 +++++++++++ tests/test_huya_shop_flow_reuse.py | 310 +++++++++++++++++++ web/backend/routers/huya/__init__.py | 4 + web/backend/routers/huya/pay.py | 35 +++ web/backend/services/huya_pay_link.py | 96 ++++++ web/backend/services/huya_runner_recharge.py | 20 ++ web/backend/services/huya_shop_flow.py | 160 +++++++--- web/frontend/src/pages/HuyaElitePage.tsx | 73 ++++- 9 files changed, 850 insertions(+), 44 deletions(-) create mode 100644 tests/test_huya_pay_link.py create mode 100644 tests/test_huya_shop_flow_reuse.py create mode 100644 web/backend/routers/huya/pay.py create mode 100644 web/backend/services/huya_pay_link.py diff --git a/tests/test_huya_elite_query.py b/tests/test_huya_elite_query.py index cf1be08..49bf6e7 100644 --- a/tests/test_huya_elite_query.py +++ b/tests/test_huya_elite_query.py @@ -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 diff --git a/tests/test_huya_pay_link.py b/tests/test_huya_pay_link.py new file mode 100644 index 0000000..17692c0 --- /dev/null +++ b/tests/test_huya_pay_link.py @@ -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/?k=`(约 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 diff --git a/tests/test_huya_shop_flow_reuse.py b/tests/test_huya_shop_flow_reuse.py new file mode 100644 index 0000000..b20da24 --- /dev/null +++ b/tests/test_huya_shop_flow_reuse.py @@ -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 diff --git a/web/backend/routers/huya/__init__.py b/web/backend/routers/huya/__init__.py index 2102716..c3d8acf 100644 --- a/web/backend/routers/huya/__init__.py +++ b/web/backend/routers/huya/__init__.py @@ -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", diff --git a/web/backend/routers/huya/pay.py b/web/backend/routers/huya/pay.py new file mode 100644 index 0000000..9f8239b --- /dev/null +++ b/web/backend/routers/huya/pay.py @@ -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) diff --git a/web/backend/services/huya_pay_link.py b/web/backend/services/huya_pay_link.py new file mode 100644 index 0000000..7e53a6e --- /dev/null +++ b/web/backend/services/huya_pay_link.py @@ -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/?k=`(约 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=`。 + 这个地址编二维码又小又好扫,且不依赖本服务的域名能不能被手机访问。 + + 解析失败、超时、或最终落到非支付宝域名都返回空串 —— 调用方回落到本站短链, + 绝不能让解析失败影响支付码本身。 + """ + 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 "" diff --git a/web/backend/services/huya_runner_recharge.py b/web/backend/services/huya_runner_recharge.py index 285fe4d..7f753c0 100644 --- a/web/backend/services/huya_runner_recharge.py +++ b/web/backend/services/huya_runner_recharge.py @@ -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/?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 ) diff --git a/web/backend/services/huya_shop_flow.py b/web/backend/services/huya_shop_flow.py index 701f1df..208c207 100644 --- a/web/backend/services/huya_shop_flow.py +++ b/web/backend/services/huya_shop_flow.py @@ -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, diff --git a/web/frontend/src/pages/HuyaElitePage.tsx b/web/frontend/src/pages/HuyaElitePage.tsx index e957927..b8671d3 100644 --- a/web/frontend/src/pages/HuyaElitePage.tsx +++ b/web/frontend/src/pages/HuyaElitePage.tsx @@ -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/?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([]); const [payOpen, setPayOpen] = useState(false); + // 放大扫码用(二维码在弹窗里开到底,手机才好扫) + const [zoomPayTask, setZoomPayTask] = useState(null); const [ckImportOpen, setCkImportOpen] = useState(false); const [config, setConfig] = useState(null); const [configDraft, setConfigDraft] = useState(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['columns'] = [ @@ -289,7 +330,7 @@ export default function HuyaElitePage() { { title: '精英宝典', width: '13%', align: 'center', ellipsis: true, render: (_value, row) => {row.elite_opened === true ? '已开通' : row.elite_opened === false ? '未开通' : '未查询'}{row.elite_opened === true && row.elite_open_time ? {`开通 ${formatUnixSeconds(row.elite_open_time)}`} : null}{row.elite_expire_time ? {`有效期至 ${formatUnixSeconds(row.elite_expire_time)}`} : null} }, { title: '积分', dataIndex: 'points', width: '7%', align: 'center', render: (value: number | null) => value ?? - }, { title: '最近操作', width: '25%', align: 'center', ellipsis: true, render: (_value, row) => { const task = latestTask(tasks, row.id); return task ? {TASK_LABELS[task.task_type] || task.task_type}{task.message || '-'}{formatTime(task.finished_at || task.created_at)} : 暂无操作; } }, - { 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 -; return
{ event.stopPropagation(); if (task) void openQrTask(task); }} style={{ cursor: 'pointer', display: 'inline-block', lineHeight: 0 }} title="点击查看二维码">{image ? 二维码 : }
; } }, + { title: '二维码', width: '6%', align: 'center', render: (_value, row) => { const task = latestTask(tasks, row.id); if (!task) return -; const url = taskQrUrl(task); const image = resultText(task, 'mini_qrcode_image'); if (!url && !image) return -; const doneLabel = qrDoneLabel(task); if (doneLabel) return {doneLabel}; return
{ 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 ? 二维码 : }
; } }, { title: '状态', width: '7%', align: 'center', render: (_value, row) => { const task = latestTask(tasks, row.id); const status = task?.status || row.status || ''; return {TASK_STATUS_LABELS[status] || status || '待操作'}; } }, ]; @@ -322,7 +363,7 @@ export default function HuyaElitePage() { {layoutMode === 'split' ?
已选 {selectedIds.length}/{accounts.length}} 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}并发 setConcurrency(value || 1)} style={{ width: 58 }} />} style={{ width: 360, flexShrink: 0, minHeight: 0, overflow: 'hidden' }} styles={{ body: { padding: 8, overflowY: 'auto', height: 'calc(100% - 38px)' } }}>{operationBody}
: <>并发 setConcurrency(value || 1)} style={{ width: 58 }} />} style={{ flexShrink: 0, marginBottom: 12 }} styles={{ body: { padding: 8 } }}>{operationBody}已选 {selectedIds.length}/{accounts.length}} 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}} 兑换任务 {exchangeCount} 条;勾选账号后可批量执行,账号行支持右键操作。 {contextMenu &&
event.stopPropagation()}> 1 ? '批量动作' : '账号动作'} styles={{ body: { padding: 4 } }} style={{ width: 220, boxShadow: '0 4px 12px rgba(0,0,0,0.14)' }}>{HUYA_QUICK_ACTIONS.map((item) => )}
} - setQrTask(null)} centered>{qrTask &&
{resultText(qrTask, 'mini_qrcode_image') ? 绑定二维码 : }

{qrTask.message || qrTask.status}

}
+ setQrTask(null)} centered>{qrTask &&
{resultText(qrTask, 'mini_qrcode_image') ? 绑定二维码 : }

{qrTask.message || qrTask.status}

}
{payTasks.length === 0 ?
正在为选中的账号生成支付码…
:
{payPendingTasks.length > 0 && - {payPendingTasks.map((task) => ( + {payPendingTasks.map((task) => { + const qrValue = payQrValue(task); + return (
-
{resultText(task, 'mini_qrcode_image') ? 支付码 : }
+
{resultText(task, 'mini_qrcode_image') ? 支付码 : }
{payTaskLabel(task)} UID {task.account_uid || '-'} {[resultText(task, 'product_name') || '精英宝典', resultText(task, 'amount_text')].filter(Boolean).join(' ')} {resultText(task, 'payment_status_label') || TASK_STATUS_LABELS[task.status] || task.status} {task.message || '-'} + {isLoopbackUrl(qrValue) && 二维码是本机地址(localhost),手机扫不开;请用公网域名打开本页后重新生成} + + + +
- ))} + ); + })}
} {payDoneTasks.length > 0 &&
已完成(二维码已收起) @@ -356,6 +405,14 @@ export default function HuyaElitePage() {
}
}
+ setZoomPayTask(null)} width={680} centered> + {zoomPayTask &&
+
+

{resultText(zoomPayTask, 'payment_status_label') || zoomPayTask.status}{resultText(zoomPayTask, 'amount_text')}

+ {payQrValue(zoomPayTask)} +
+
} +
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="取消"> setImportSearch(event.target.value)} style={{ marginBottom: 8 }} />