diff --git a/core/huya/http_client.py b/core/huya/http_client.py index 27b59cf..3c74e0c 100644 --- a/core/huya/http_client.py +++ b/core/huya/http_client.py @@ -1142,6 +1142,75 @@ class HuyaHttpClient: self.logger(" createOrderV5: 无响应") return result + def create_order_v6( + self, + uid, + guid, + cookie, + pid, + items, + source_id="yellowcarlist", + game_id="0", + biz_params=JIFENCENTER_BIZ_PARAMS, + ): + """积分兑换多商品下单(``shopMiddleUI.createOrderV6``)。 + + 2026-11-13 HAR:积分足够时官方只发这一单,不再调用 + ``scoreExchangePrize`` 或 ``payOrderSubmitV5``。 + """ + from .shop_structs import ( + CreateOrderAccountParam, + CreateOrderExtraParam, + CreateOrderItemReqV6, + CreateOrderPromotionParam, + CreateOrderReqV6, + CreateOrderRspV6, + ) + + req = CreateOrderReqV6() + req.userId = self._build_shop_user(uid, cookie) + req.shopAppInfo = self._build_shop_app(source_id, SHOP_APP_SCENE) + req.items = [] + for item in items: + row = CreateOrderItemReqV6() + row.spuId = str(item.get("spu_id") or "") + row.skuId = int(item.get("sku_id") or 0) + row.itemCount = int(item.get("item_count") or 1) + row.goodsType = int(item.get("goods_type") or 0) + row.virtualType = int(item.get("virtual_type") or 0) + req.items.append(row) + req.pid = int(pid or 0) + req.gameId = str(game_id or "0") + req.src = 2 + req.orderType = 6 + extra = CreateOrderExtraParam() + extra.bizParams = biz_params + req.extraParam = extra + req.sourceId = source_id + req.env = self._build_order_env() + req.orderScene = 3 + req.promotionParam = CreateOrderPromotionParam() + req.accountParam = CreateOrderAccountParam() + + result: Any = self.call_rpc( + "shopMiddleUI", + "createOrderV6", + req, + CreateOrderRspV6, + uid, + guid, + cookie, + timeout=15.0, + ) + if result is not None: + self.logger( + f" createOrderV6: code={result.code} orderId={result.orderId} " + f"msg={result.message}" + ) + else: + self.logger(" createOrderV6: 无响应") + return result + @staticmethod def _normalize_pay_channel(pay_type) -> str: text = str(pay_type or "").strip() diff --git a/core/huya/shop_structs.py b/core/huya/shop_structs.py index f39e013..689a9f4 100644 --- a/core/huya/shop_structs.py +++ b/core/huya/shop_structs.py @@ -1055,6 +1055,138 @@ class CreateOrderRsp(TafStruct): pass +class CreateOrderItemReqV6(TafStruct): + """``createOrderV6`` 的单个商品项(2026-11-13 积分兑换 HAR)。""" + + def __init__(self): + self.spuId: str = "" # tag 0 + self.skuId: int = 0 # tag 1 + self.itemCount: int = 0 # tag 2 + self.groupItemList: list = [] # tag 3 Vector + self.goodsType: int = 0 # tag 4 + self.virtualType: int = 0 # tag 5 + + def write_to(self, os: TafOutputStream): + os.write_string(0, self.spuId) + os.write_int64(1, self.skuId) + os.write_int32(2, self.itemCount) + os.write_list(3, self.groupItemList) + os.write_int32(4, self.goodsType) + os.write_int32(5, self.virtualType) + + def read_from(self, ins: TafInputStream): + self.spuId = ins.read_string(0, default=self.spuId) + self.skuId = ins.read_int64(1, default=self.skuId) + self.itemCount = ins.read_int32(2, default=self.itemCount) + self.groupItemList = ins.read_list(3, item_reader=CreateOrderItemReqV6.read) + self.goodsType = ins.read_int32(4, default=self.goodsType) + self.virtualType = ins.read_int32(5, default=self.virtualType) + + @staticmethod + def read(ins: TafInputStream) -> "CreateOrderItemReqV6": + item = CreateOrderItemReqV6() + item.read_from(ins) + return item + + +class CreateOrderReqV6(TafStruct): + """积分兑换多商品下单请求 (shopMiddleUI.createOrderV6)。""" + + def __init__(self): + self.userId = UserId() # tag 0 + self.shopAppInfo = ShopAppInfo() # tag 1 + self.receiveId: int = 0 # tag 2 + self.items: list[CreateOrderItemReqV6] = [] # tag 3 + self.pid: int = 0 # tag 4 + self.remark: str = "" # tag 5 + self.gameId: str = "" # tag 6 + self.orderId: int = 0 # tag 7 + self.src: int = 0 # tag 8 + self.couponUserIds: list[int] = [] # tag 9 + self.orderType: int = 0 # tag 10 + self.extraParam: CreateOrderExtraParam | None = None # tag 11 + self.scene: int = 0 # tag 12 + self.promotionItems: list = [] # tag 13 + self.sourceId: str = "" # tag 14 + self.env: dict[str, str] = {} # tag 15 + self.orderScene: int = 0 # tag 16 + self.watchWord: str = "" # tag 17 + self.marketingChannel: str = "" # tag 18 + self.promotionParam: CreateOrderPromotionParam | None = None # tag 19 + self.externalTraceKey: str = "" # tag 20 + self.kefuUid: int = 0 # tag 21 + self.accountParam: CreateOrderAccountParam | None = None # tag 22 + self.parentOrderId: int = 0 # tag 23 + self.vendorAccountType: str = "" # tag 24 + self.vendorAccountVal: str = "" # tag 25 + self.vendorSubAccountVal: str = "" # tag 26 + self.vendorSubAccountType: str = "" # tag 27 + + def write_to(self, os: TafOutputStream): + os.write_struct(0, self.userId) + os.write_struct(1, self.shopAppInfo) + os.write_int64(2, self.receiveId) + os.write_list(3, self.items) + os.write_int64(4, self.pid) + os.write_string(5, self.remark) + os.write_string(6, self.gameId) + os.write_int64(7, self.orderId) + os.write_int32(8, self.src) + os.write_list(9, self.couponUserIds) + os.write_int32(10, self.orderType) + os.write_struct(11, self.extraParam or CreateOrderExtraParam()) + os.write_int32(12, self.scene) + os.write_list(13, self.promotionItems) + os.write_string(14, self.sourceId) + os.write_map(15, self.env) + os.write_int32(16, self.orderScene) + os.write_string(17, self.watchWord) + os.write_string(18, self.marketingChannel) + os.write_struct(19, self.promotionParam or CreateOrderPromotionParam()) + os.write_string(20, self.externalTraceKey) + os.write_int64(21, self.kefuUid) + os.write_struct(22, self.accountParam or CreateOrderAccountParam()) + os.write_int64(23, self.parentOrderId) + os.write_string(24, self.vendorAccountType) + os.write_string(25, self.vendorAccountVal) + os.write_string(26, self.vendorSubAccountVal) + os.write_string(27, self.vendorSubAccountType) + + def read_from(self, ins: TafInputStream): + pass + + +class CreateOrderRspV6(TafStruct): + """积分兑换多商品下单响应 (shopMiddleUI.createOrderV6)。""" + + def __init__(self): + self.code: int = 0 # tag 0 + self.message: str = "" # tag 1 + self.orderId: int = 0 # tag 2 + self.subOrderId: int = 0 # tag 3 + self.orderStatus: int = 0 # tag 4 + + def read_from(self, ins: TafInputStream): + self.code = ins.read_int32(0, default=self.code) + self.message = ins.read_string(1, default=self.message) + self.orderId = ins.read_int64(2, default=self.orderId) + self.subOrderId = ins.read_int64(3, default=self.subOrderId) + self.orderStatus = ins.read_int32(4, default=self.orderStatus) + _skip_to_struct_end(ins) + + def write_to(self, os: TafOutputStream): + pass + + def to_dict(self) -> dict: + return { + "code": self.code, + "message": self.message, + "order_id": self.orderId, + "sub_order_id": self.subOrderId, + "order_status": self.orderStatus, + } + + # ============================================================ # 支付(payOrderSubmitV5) # ============================================================ @@ -1314,6 +1446,28 @@ def extract_points_center_tabs(payload: dict | None) -> list[dict]: return result +def extract_points_center_sid(payload: dict | None) -> int | None: + """从 ``queryAllocation`` 的 ScoreCenterConfig 提取当前兑换活动 SID。 + + 2026-11-13 HAR:配置位于 + ``payload.tag2[*].tag2_2.tag0.tag7``,当前值为 ``2277``;这个值正是 + ``webActUI.getUserPrizeRecords`` 的 ``tag1``。 + """ + if not isinstance(payload, dict): + return None + for section in payload.get("tag2") or []: + if not isinstance(section, dict): + continue + config_box = section.get("tag2_2") + config = config_box.get("tag0") if isinstance(config_box, dict) else None + if not isinstance(config, dict): + continue + sid = _as_int(config.get("tag7")) + if sid and sid > 0: + return sid + return None + + class PlayMallAppInfo(TafStruct): """PlayMallUI 系接口的应用信息。 diff --git a/core/huya/wss_client.py b/core/huya/wss_client.py index 5d3810f..fc1d07f 100644 --- a/core/huya/wss_client.py +++ b/core/huya/wss_client.py @@ -105,7 +105,7 @@ SEQ_ACTIVITY_BIZ = 0x1D000109 SEQ_POINTS_BIZ = 0x1D000107 SEQ_POINTS_ORDER = 0x1D000108 # 积分中心通道里用 0x1d000108 的两帧 -POINTS_ORDER_METHODS = frozenset({"createOrderV5", "payOrderSubmitV5"}) +POINTS_ORDER_METHODS = frozenset({"createOrderV5", "createOrderV6", "payOrderSubmitV5"}) class WssCommand: @@ -1309,6 +1309,59 @@ class HuyaWssClient: "shopMiddleUI", "createOrderV5", req, CreateOrderRsp, timeout=15.0 ) + async def create_order_v6( + self, + uid: int, + guid: str, + cookie: str, + pid: int, + items, + source_id: str = "yellowcarlist", + game_id: str = "0", + biz_params: str = JIFENCENTER_BIZ_PARAMS, + ): + """积分兑换多商品下单(``shopMiddleUI.createOrderV6``)。""" + from .shop_structs import ( + CreateOrderAccountParam, + CreateOrderExtraParam, + CreateOrderItemReqV6, + CreateOrderPromotionParam, + CreateOrderReqV6, + CreateOrderRspV6, + ) + + req = CreateOrderReqV6() + req.userId = self._build_biz_user(uid, cookie) + req.shopAppInfo = self._build_shop_app(source_id, SHOP_APP_SCENE) + req.items = [] + for item in items: + row = CreateOrderItemReqV6() + row.spuId = str(item.get("spu_id") or "") + row.skuId = int(item.get("sku_id") or 0) + row.itemCount = int(item.get("item_count") or 1) + row.goodsType = int(item.get("goods_type") or 0) + row.virtualType = int(item.get("virtual_type") or 0) + req.items.append(row) + req.pid = int(pid or 0) + req.gameId = str(game_id or "0") + req.src = 2 + req.orderType = 6 + extra = CreateOrderExtraParam() + extra.bizParams = biz_params + req.extraParam = extra + req.sourceId = source_id + req.env = self._build_order_env() + req.orderScene = 3 + req.promotionParam = CreateOrderPromotionParam() + req.accountParam = CreateOrderAccountParam() + return await self.call_rpc( + "shopMiddleUI", + "createOrderV6", + req, + CreateOrderRspV6, + timeout=15.0, + ) + async def pay_order_submit( self, uid: int, diff --git a/tests/test_huya_browser_exchange.py b/tests/test_huya_browser_exchange.py index 754c569..e25a050 100644 --- a/tests/test_huya_browser_exchange.py +++ b/tests/test_huya_browser_exchange.py @@ -9,7 +9,10 @@ from fastapi import HTTPException from pydantic import ValidationError from tests.support.db import get_test_engine, make_test_session -from web.backend.huya_defaults import HUYA_DEFAULT_ROOM_PID +from web.backend.huya_defaults import ( + HUYA_DEFAULT_POINTS_PAGE_CODE, + HUYA_DEFAULT_ROOM_PID, +) from web.backend.models import AuditLog, HuyaAccount, HuyaConfig, User from web.backend.routers.huya import create_browser_exchange_kit from web.backend.schemas import HuyaBrowserExchangeRequest @@ -55,10 +58,9 @@ class TestHuyaBrowserExchangeKit: "精英宝典", "电竞宝典", ] - # 默认目标地址=精英宝典(沿用历史行为:绑定/充值活动的 zt 活动页) - assert kit["target_url"].startswith( - "https://zt.huya.com/b02faae1/pc/index.html?" - ) + # 2026-11-13 最新官方入口已切到积分中心页,不再跳旧 zt 宝典页。 + assert kit["target_url"].startswith("https://m-shop.huya.com/points.html?") + assert f"pagecode={HUYA_DEFAULT_POINTS_PAGE_CODE}" in kit["target_url"] assert f"pid={HUYA_DEFAULT_ROOM_PID}" in kit["target_url"] assert kit["cookie_string"] == "yyuid=100; udb_uid=100" assert kit["cookie_script"].endswith("})();") diff --git a/tests/test_huya_http_shop_flow.py b/tests/test_huya_http_shop_flow.py index 4bd8582..4f1f485 100644 --- a/tests/test_huya_http_shop_flow.py +++ b/tests/test_huya_http_shop_flow.py @@ -45,6 +45,38 @@ def test_http_create_order_matches_points_capture(): assert req.gameCategoryId == 507 +def test_http_create_order_v6_matches_points_exchange_capture(): + client, recorded = _recording_client() + + client.create_order_v6( + uid=1199669523011, + guid="", + cookie="yyuid=1199669523011", + pid=1199650619883, + items=[ + { + "spu_id": "hy-8315982", + "sku_id": 7747527, + "item_count": 1, + } + ], + ) + + service, method, req = recorded[-1] + assert (service, method) == ("shopMiddleUI", "createOrderV6") + assert req.pid == 1199650619883 + assert len(req.items) == 1 + assert (req.items[0].spuId, req.items[0].skuId, req.items[0].itemCount) == ( + "hy-8315982", + 7747527, + 1, + ) + assert req.src == 2 + assert req.orderType == 6 + assert req.orderScene == 3 + assert req.extraParam.bizParams == '{ "jifencenter": 1 }' + + def test_http_get_goods_info_uses_app_scene(): client, recorded = _recording_client() diff --git a/tests/test_huya_points_exchange.py b/tests/test_huya_points_exchange.py new file mode 100644 index 0000000..ac84d12 --- /dev/null +++ b/tests/test_huya_points_exchange.py @@ -0,0 +1,479 @@ +"""虎牙积分兑换新链路:直兑、购买积分包后再兑、前置条件。""" + +from __future__ import annotations + +from types import SimpleNamespace + +from web.backend.models import HuyaAccount +from web.backend.services import huya_points_exchange as exchange +from web.backend.services.huya_runner import HuyaBatchRunner +from web.backend.services.huya_shop_flow import ShopOrderOutcome + + +class _Response: + def __init__(self, code=200, message="", payload=None, detail=None): + self.code = code + self.message = message + self.payload = payload or {} + self._detail = detail or {} + + def value(self, *path): + node = self.payload + for key in path: + if not isinstance(node, dict): + return None + node = node.get(key) + return node + + def to_dict(self): + return dict(self._detail or self.payload) + + +class _Client: + def __init__(self, states, *, goods_code=200): + self.states = list(states) + self.goods_code = goods_code + self.create_v6_calls = [] + + def get_redeem_need_goods(self, **_kwargs): + return self.states.pop(0) + + def get_goods_info(self, **kwargs): + return _Response( + code=self.goods_code, + message="" if self.goods_code == 200 else "商品详情失败", + detail={ + "sku_id": 7747527, + "name": "幸运币礼包(小)", + "price": 20, + "goods_type": 5, + "virtual_type": 507, + "spu_id": kwargs["spu_id"], + }, + ) + + def create_order_v6(self, **kwargs): + self.create_v6_calls.append(kwargs) + return SimpleNamespace( + code=200, + message="", + orderId=12104642, + to_dict=lambda: { + "code": 200, + "order_id": 12104642, + "points": 20, + }, + ) + + +def _state(*, full: bool, precondition: bool = True, plans=None): + return _Response( + payload={ + "tag2": 20, + "tag3": 20, + "tag4": 0 if full else 20, + "tag5": 1 if precondition else 0, + "tag6": 1 if full else 0, + "tag7": plans or [], + } + ) + + +def test_points_enough_uses_create_order_v6_without_payment(monkeypatch): + client = _Client([_state(full=True)]) + purchase_called = False + + def fail_purchase(*_args, **_kwargs): + nonlocal purchase_called + purchase_called = True + raise AssertionError("积分足够时不应购买积分包") + + monkeypatch.setattr(exchange, "run_open_flow", fail_purchase) + + result = exchange.prepare_exchange( + client, + uid=1199669523011, + cookie="yyuid=1199669523011", + pid=1199650619883, + spu_id="hy-8315982", + ) + + assert result.status == exchange.EXCHANGE_STATUS_EXCHANGED + assert result.message == "兑换成功: 幸运币礼包(小)" + assert result.order["order_id"] == 12104642 + assert purchase_called is False + assert client.create_v6_calls[0]["items"] == [ + { + "spu_id": "hy-8315982", + "sku_id": 7747527, + "item_count": 1, + "goods_type": 5, + "virtual_type": 507, + } + ] + + +def test_points_shortage_returns_points_package_payment(monkeypatch): + client = _Client( + [ + _state( + full=False, + plans=[ + { + "tag0": 8327356, + "tag1": 7759407, + "tag2": "精英币", + "tag5": 1, + "tag6": 10, + "tag7": 20, + "tag9": 2, + "tag21": 20, + } + ], + ) + ] + ) + calls = [] + + def fake_purchase(client_arg, **kwargs): + calls.append((client_arg, kwargs)) + return ShopOrderOutcome( + ok=True, + message="已生成支付二维码", + order_id=12095620, + pay_url="https://openapi.alipay.com/gateway.do?probe=1", + amount=200, + product_name="精英币", + count=20, + ) + + monkeypatch.setattr(exchange, "run_open_flow", fake_purchase) + + result = exchange.prepare_exchange( + client, + uid=1199669523011, + cookie="yyuid=1199669523011", + pid=1199650619883, + spu_id="hy-8315982", + pay_channel="Zfb", + ) + + assert result.status == exchange.EXCHANGE_STATUS_PAYMENT_REQUIRED + assert result.plan is not None + assert result.plan["spu_id"] == "hy-8327356" + assert result.plan["sku_id"] == 7759407 + assert result.plan["count"] == 20 + assert result.purchase is not None + assert result.purchase.order_id == 12095620 + assert calls[0][1]["spu_id"] == "hy-8327356" + assert calls[0][1]["count"] == 20 + assert client.create_v6_calls == [] + + +def test_complete_after_payment_waits_for_points_then_uses_v6(): + client = _Client([_state(full=False), _state(full=True)]) + + result = exchange.complete_exchange_after_payment( + client, + uid=1199669523011, + cookie="yyuid=1199669523011", + pid=1199650619883, + spu_id="hy-8315982", + attempts=2, + interval=0, + sleep=lambda _seconds: None, + ) + + assert result.status == exchange.EXCHANGE_STATUS_EXCHANGED + assert result.state is not None + assert result.state.user_score == 20 + assert result.order["order_id"] == 12104642 + assert len(client.create_v6_calls) == 1 + + +def test_precondition_failure_does_not_create_order(): + client = _Client([_state(full=True, precondition=False)]) + + result = exchange.prepare_exchange( + client, + uid=1199669523011, + cookie="yyuid=1199669523011", + pid=1199650619883, + spu_id="hy-8315982", + ) + + assert result.status == exchange.EXCHANGE_STATUS_FAILED + assert "前置条件" in result.message + assert client.create_v6_calls == [] + + +def test_runner_uses_points_exchange_service_for_direct_redeem(monkeypatch): + client = _Client([_state(full=True)]) + runner = HuyaBatchRunner( + db=None, + batch_id="exchange-direct", + task_type="exchange_goods", + payload={"product_id": "hy-8315982"}, + ) + marks = [] + monkeypatch.setattr(runner, "_http_client", lambda: client) + monkeypatch.setattr( + runner, + "_mark_task", + lambda _db, _task, status, message, result=None: marks.append( + (status, message, result) + ), + ) + account = HuyaAccount(uid="1199669523011", cookie="yyuid=1199669523011") + + runner._execute_exchange_goods( + None, + SimpleNamespace(id=1), + account, + {"uid": 1199669523011, "cookie": "yyuid=1199669523011"}, + {"room_pid": "1199650619883", "pay_channel": "Zfb"}, + ) + + assert marks[-1][0] == "success" + assert marks[-1][2]["exchange_mode"] == "points_only" + assert marks[-1][2]["exchange_order"]["order_id"] == 12104642 + assert account.status == "goods_exchanged" + assert len(client.create_v6_calls) == 1 + + +def test_runner_waits_points_payment_then_redeems(monkeypatch): + client = _Client( + [ + _state( + full=False, + plans=[ + { + "tag0": 8327356, + "tag1": 7759407, + "tag2": "精英币", + "tag5": 1, + "tag6": 10, + "tag7": 20, + "tag9": 2, + } + ], + ), + _state(full=True), + ] + ) + runner = HuyaBatchRunner( + db=None, + batch_id="exchange-pay", + task_type="exchange_goods", + payload={"product_id": "hy-8315982"}, + ) + marks = [] + progress = [] + monkeypatch.setattr(runner, "_http_client", lambda: client) + monkeypatch.setattr( + runner, + "_mark_task", + lambda _db, _task, status, message, result=None: marks.append( + (status, message, result) + ), + ) + monkeypatch.setattr( + runner, + "_update_task_progress", + lambda _db, _task, status, message, result=None: progress.append( + (status, message, result) + ), + ) + monkeypatch.setattr( + runner, + "_wait_recharge_payment", + lambda **_kwargs: ("paid", {"order_id": 12095620, "pay_time": 1}), + ) + monkeypatch.setattr( + exchange, + "run_open_flow", + lambda *_args, **_kwargs: ShopOrderOutcome( + ok=True, + message="已生成支付二维码", + order_id=12095620, + pay_url="https://openapi.alipay.com/gateway.do?probe=1", + amount=200, + product_name="精英币", + count=20, + ), + ) + account = HuyaAccount(uid="1199669523011", cookie="yyuid=1199669523011") + + runner._execute_exchange_goods( + None, + SimpleNamespace(id=2), + account, + {"uid": 1199669523011, "cookie": "yyuid=1199669523011"}, + { + "room_pid": "1199650619883", + "pay_channel": "Zfb", + "sid": "2203", + }, + ) + + assert progress[0][0] == "running" + assert progress[0][2]["exchange_mode"] == "buy_points_then_exchange" + assert progress[0][2]["points_purchase"]["order_id"] == 12095620 + assert marks[-1][0] == "success" + assert marks[-1][2]["exchange_order"]["order_id"] == 12104642 + assert account.points == 20 + + +def test_runner_refresh_goods_uses_points_center_query(monkeypatch): + from web.backend.services import huya_collab_service + + runner = HuyaBatchRunner( + db=None, + batch_id="refresh-points-goods", + task_type="refresh_goods", + ) + marks = [] + added = [] + calls = [] + + class _Query: + def filter(self, *_args, **_kwargs): + return self + + def delete(self, **_kwargs): + return 0 + + class _DB: + def query(self, *_args, **_kwargs): + return _Query() + + def add(self, item): + added.append(item) + + monkeypatch.setattr( + huya_collab_service, + "fetch_points_center_gifts", + lambda uid, cookie, **kwargs: ( + calls.append((uid, cookie, kwargs)) + or ( + [ + { + "product_id": "hy-8315982", + "name": "幸运币礼包(小)", + "price": 20, + "remain_text": "11197", + "stock": 11197, + } + ], + "已拉取 1 个礼包", + ) + ), + ) + monkeypatch.setattr( + runner, + "_mark_task", + lambda _db, _task, status, message, result=None: marks.append( + (status, message, result) + ), + ) + account = SimpleNamespace( + id=8, + uid="1199669523011", + cookie="yyuid=1199669523011", + points=0, + status="", + updated_at=None, + ) + + runner._execute_refresh_goods( + _DB(), + SimpleNamespace(id=9, batch_id="refresh-points-goods", result=None), + account, + {"uid": 1199669523011, "cookie": "yyuid=1199669523011"}, + { + "sid": "2203", + "points_page_code": "heping_baodian_credit_center_page", + "points_source_id": "yellowcarlist", + "points_scene": "7", + }, + ) + + assert calls[0][2]["page_code"] == "heping_baodian_credit_center_page" + assert calls[0][2]["source_id"] == "yellowcarlist" + assert len(added) == 1 + assert added[0].product_id == "hy-8315982" + assert marks[-1][2]["source"] == "PlayMallUI.queryAllocation" + + +def test_exchange_record_query_discovers_current_sid(monkeypatch): + record_calls = [] + + class _PointsClient: + def play_mall_query_allocation(self, **_kwargs): + return SimpleNamespace( + code=200, + payload={ + "tag2": [ + { + "tag2_2": { + "tag0": { + "tag7": 2277, + } + } + } + ] + }, + ) + + def get_user_prize_records(self, **kwargs): + record_calls.append(kwargs) + return SimpleNamespace( + status=200, + msg="请求成功", + to_dict=lambda: { + "status": 200, + "msg": "请求成功", + "records": [ + { + "record_id": 9922423, + "prize_name": "幸运币礼包(小)", + "score": 20, + "exchange_time": 1794564026, + } + ], + }, + ) + + runner = HuyaBatchRunner( + db=None, + batch_id="exchange-records", + task_type="query_exchange_records", + ) + marks = [] + monkeypatch.setattr(runner, "_http_client", _PointsClient) + monkeypatch.setattr( + runner, + "_mark_task", + lambda _db, _task, status, message, result=None: marks.append( + (status, message, result) + ), + ) + account = SimpleNamespace(status="", updated_at=None) + + runner._execute_query_exchange_records( + None, + SimpleNamespace(id=3), + account, + {"uid": 1199669523011, "cookie": "yyuid=1199669523011"}, + { + "sid": "2203", + "points_page_code": "heping_baodian_credit_center_page", + "points_source_id": "yellowcarlist", + "points_scene": "7", + }, + ) + + assert record_calls[0]["sid"] == 2277 + assert marks[-1][0] == "success" + assert marks[-1][2]["sid"] == 2277 + assert marks[-1][2]["sid_source"] == "queryAllocation" diff --git a/tests/test_huya_points_shop_protocol.py b/tests/test_huya_points_shop_protocol.py index d2fe58f..7ded89b 100644 --- a/tests/test_huya_points_shop_protocol.py +++ b/tests/test_huya_points_shop_protocol.py @@ -14,7 +14,7 @@ from core.huya.http_client import ( _extract_ws_hosts, generate_ws_baseinfo, ) -from core.huya.shop_structs import shop_spu_number +from core.huya.shop_structs import extract_points_center_sid, shop_spu_number from core.huya.taf_protocol import TafInputStream, TafOutputStream, TafType from core.huya.wss_client import ( HuyaWssClient, @@ -218,6 +218,70 @@ def test_create_order_v5_matches_points_capture(): asyncio.run(run()) +def test_create_order_v6_matches_points_exchange_capture(): + async def run(): + client = _points_client() + await client.create_order_v6( + uid=UID, + guid=GUID, + cookie=COOKIE, + pid=PID, + items=[ + { + "spu_id": "hy-8315982", + "sku_id": 7747527, + "item_count": 1, + } + ], + ) + + message = WssMessage.decode(client.ws.sent[-1]) + assert message.command == WssCommand.RPC_REQUEST + + decoded = _decode_wup_body(message.body) + assert (decoded["servant"], decoded["func"]) == ( + "shopMiddleUI", + "createOrderV6", + ) + t_req = decoded["tReq"] + assert t_req["tag3"] == [ + { + "tag0": "hy-8315982", + "tag1": 7747527, + "tag2": 1, + "tag3": [], + "tag4": 0, + "tag5": 0, + } + ] + assert t_req["tag4"] == PID + assert t_req["tag6"] == "0" + assert t_req["tag8"] == 2 + assert t_req["tag10"] == 6 + assert t_req["tag11"]["tag4"] == BIZ_PARAMS_LITERAL + assert t_req["tag14"] == "yellowcarlist" + assert t_req["tag16"] == 3 + + asyncio.run(run()) + + +def test_current_points_center_payload_exposes_exchange_sid(): + payload = { + "tag2": [ + { + "tag2_2": { + "tag0": { + "tag1": "精英宝典福利季", + "tag7": 2277, + } + } + } + ] + } + + assert extract_points_center_sid(payload) == 2277 + + def test_pay_order_submit_v5_callback_points_page(): async def run(): client = _points_client() diff --git a/web/backend/huya_defaults.py b/web/backend/huya_defaults.py index 2a76ac0..5655796 100644 --- a/web/backend/huya_defaults.py +++ b/web/backend/huya_defaults.py @@ -1,7 +1,7 @@ """虎牙活动默认配置。""" HUYA_DEFAULT_ROOM_PID = "1199650619883" -HUYA_DEFAULT_SID = "2203" +HUYA_DEFAULT_SID = "2277" HUYA_DEFAULT_OUTER_ACT_ID = "17096" # 2026-11-09 最新活动页抓包:绑定走 gid=3203、bActId=0。 HUYA_DEFAULT_BIND_GID = "3203" diff --git a/web/backend/routers/huya/accounts.py b/web/backend/routers/huya/accounts.py index 5754cac..a803229 100644 --- a/web/backend/routers/huya/accounts.py +++ b/web/backend/routers/huya/accounts.py @@ -31,10 +31,21 @@ def create_browser_exchange_kit( raise HTTPException(status_code=400, detail="该账号没有可用 Cookie,请先登录") config = ensure_huya_config(db) room_pid = huya_config_value("room_pid", config.room_pid) + points_page_code = huya_config_value("points_page_code", config.points_page_code) + points_source_id = huya_config_value("points_source_id", config.points_source_id) target = req.target if req and req.target else DEFAULT_EXCHANGE_TARGET - targets = exchange_target_options(room_pid) + targets = exchange_target_options( + room_pid, + points_page_code=points_page_code, + points_source_id=points_source_id, + ) try: - target_url = exchange_target_url(target, room_pid) + target_url = exchange_target_url( + target, + room_pid, + points_page_code=points_page_code, + points_source_id=points_source_id, + ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc pairs = dict(cookie_pairs(normalize_huya_cookie(account.cookie))) diff --git a/web/backend/routers/huya/workbench.py b/web/backend/routers/huya/workbench.py index 4be0b7a..4afb670 100644 --- a/web/backend/routers/huya/workbench.py +++ b/web/backend/routers/huya/workbench.py @@ -165,11 +165,24 @@ def list_goods( db: Session = Depends(get_db), current: User = Depends(require_permission("huya:task")), ): - """查看已缓存的虎牙商品快照。""" - rows = db.query(HuyaGoodsSnapshot).order_by(HuyaGoodsSnapshot.id.desc()).all() + """查看最近一次从积分中心 ``queryAllocation`` 刷新的商品快照。""" + rows = ( + db.query(HuyaGoodsSnapshot) + .filter(HuyaGoodsSnapshot.account_id.is_not(None)) + .order_by( + HuyaGoodsSnapshot.updated_at.desc(), + HuyaGoodsSnapshot.id.desc(), + ) + .all() + ) + if not rows: + return [] + newest = rows[0].updated_at latest = {} for row in rows: - latest.setdefault((row.sid, row.product_id), row) + if row.updated_at != newest: + continue + latest.setdefault(row.product_id, row) return list(latest.values()) diff --git a/web/backend/services/huya_exchange_targets.py b/web/backend/services/huya_exchange_targets.py index 0e4fd16..959c1fb 100644 --- a/web/backend/services/huya_exchange_targets.py +++ b/web/backend/services/huya_exchange_targets.py @@ -10,6 +10,10 @@ from __future__ import annotations +from ..huya_defaults import ( + HUYA_DEFAULT_POINTS_PAGE_CODE, + HUYA_DEFAULT_POINTS_SOURCE_ID, +) from .huya_runner_bind import HUYA_BIND_ROOM_ID, HUYA_BIND_ZT_UUID from .huya_runner_core import HUYA_RECHARGE_SOURCE_ID @@ -20,7 +24,7 @@ EXCHANGE_TARGET_ESPORTS = "esports" #: 未指定目标时使用的默认页(保持历史行为:精英宝典) DEFAULT_EXCHANGE_TARGET = EXCHANGE_TARGET_ELITE -#: 精英宝典复用绑定/充值活动 uuid;电竞宝典为独立的 zt 活动 uuid +#: 旧 zt 活动 uuid 仅保留给旧调用兼容;精英宝典现统一走积分中心页。 HUYA_ELITE_ZT_UUID = HUYA_BIND_ZT_UUID HUYA_ESPORTS_ZT_UUID = "50ea007f" @@ -52,25 +56,61 @@ def _zt_exchange_url(zt_uuid: str, room_pid: str) -> str: ) -def exchange_target_url(key: str, room_pid: str) -> str: - """返回指定目标页地址;pid 由调用方(路由)归一化,空值在这里会拼出无 pid 的地址。""" +def _points_exchange_url( + room_pid: str, + *, + page_code: str = HUYA_DEFAULT_POINTS_PAGE_CODE, + source_id: str = HUYA_DEFAULT_POINTS_SOURCE_ID, +) -> str: pid = str(room_pid or "").strip() + return ( + "https://m-shop.huya.com/points.html" + f"?pagecode={page_code}" + f"&sourceId={source_id}" + f"&pid={pid}" + f"&anchorUid={pid}" + f"&roomid={HUYA_BIND_ROOM_ID}" + ) + + +def exchange_target_url( + key: str, + room_pid: str, + *, + points_page_code: str = HUYA_DEFAULT_POINTS_PAGE_CODE, + points_source_id: str = HUYA_DEFAULT_POINTS_SOURCE_ID, +) -> str: + """返回指定目标页地址;pid 由调用方(路由)归一化,空值在这里会拼出无 pid 的地址。""" if key == EXCHANGE_TARGET_LIVE_ROOM: return f"https://www.huya.com/{HUYA_BIND_ROOM_ID}" if key == EXCHANGE_TARGET_ELITE: - return _zt_exchange_url(HUYA_ELITE_ZT_UUID, pid) + return _points_exchange_url( + room_pid, + page_code=points_page_code, + source_id=points_source_id, + ) if key == EXCHANGE_TARGET_ESPORTS: - return _zt_exchange_url(HUYA_ESPORTS_ZT_UUID, pid) + return _zt_exchange_url(HUYA_ESPORTS_ZT_UUID, str(room_pid or "").strip()) raise ValueError(f"不支持的兑换目标:{key}") -def exchange_target_options(room_pid: str) -> list[dict[str, str]]: +def exchange_target_options( + room_pid: str, + *, + points_page_code: str = HUYA_DEFAULT_POINTS_PAGE_CODE, + points_source_id: str = HUYA_DEFAULT_POINTS_SOURCE_ID, +) -> list[dict[str, str]]: """返回三个可选目标页,顺序固定,供前端选择器渲染。""" return [ { "key": key, "label": EXCHANGE_TARGET_LABELS[key], - "url": exchange_target_url(key, room_pid), + "url": exchange_target_url( + key, + room_pid, + points_page_code=points_page_code, + points_source_id=points_source_id, + ), } for key in EXCHANGE_TARGET_KEYS ] diff --git a/web/backend/services/huya_points_exchange.py b/web/backend/services/huya_points_exchange.py new file mode 100644 index 0000000..c108f90 --- /dev/null +++ b/web/backend/services/huya_points_exchange.py @@ -0,0 +1,443 @@ +"""虎牙积分中心兑换的唯一编排入口。 + +2026-11-13 HAR 实证: + +* 积分不足时,先通过商城 ``createOrderV5 / payOrderSubmitV5`` 购买服务端推荐的 + 积分包; +* 支付到账后,再调用 ``getRedeemNeedGoods`` 确认积分; +* 积分足够后使用 ``createOrderV6`` 兑换目标道具,纯积分兑换不再发起支付。 + +精英宝典工作台、通用任务页以及后续浏览器入口都只调用本模块,不再直接实现 +``scoreExchangePrize`` 或商城下单分支。 +""" + +from __future__ import annotations + +import time +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any + +from .huya_shop_flow import ShopOrderOutcome, run_open_flow + +EXCHANGE_STATUS_EXCHANGED = "exchanged" +EXCHANGE_STATUS_PAYMENT_REQUIRED = "payment_required" +EXCHANGE_STATUS_FAILED = "failed" + + +def _int(value: Any) -> int: + try: + return int(value or 0) + except (TypeError, ValueError): + return 0 + + +def _text(value: Any) -> str: + return str(value or "").strip() + + +def normalize_spu_id(value: Any) -> str: + """把数值或 ``hy-`` 前缀 SPU 统一成接口使用的字符串形式。""" + text = _text(value) + if not text: + return "" + if text.startswith("hy-"): + return text + if text.isdigit(): + return f"hy-{text}" + return text + + +def spu_number(value: Any) -> int: + text = normalize_spu_id(value) + if "-" in text: + text = text.rsplit("-", 1)[-1] + return _int(text) + + +def _response_code(response: Any) -> int: + if response is None: + return 0 + return _int( + getattr(response, "code", None) + or getattr(response, "status", None) + or getattr(response, "value", lambda: 0)("tag0") + ) + + +def _response_message(response: Any) -> str: + if response is None: + return "" + return _text( + getattr(response, "message", "") + or getattr(response, "msg", "") + or getattr(response, "value", lambda: "")(*("tag1",)) + ) + + +def _value(response: Any, *path: str) -> Any: + if response is None: + return None + getter = getattr(response, "value", None) + if not callable(getter): + return None + return getter(*path) + + +def _stock_remaining(value: Any) -> int | None: + stock = _int(value) + return stock if stock > 0 else None + + +def _parse_plan(item: Any) -> dict[str, Any] | None: + if not isinstance(item, dict): + return None + spu_id = normalize_spu_id(item.get("tag0")) + sku_id = _int(item.get("tag1")) + name = _text(item.get("tag2")) + if not spu_id or not sku_id: + return None + + gain_score = _int(item.get("tag5")) + price = _int(item.get("tag6")) + custom_count = max(1, _int(item.get("tag7")) or 1) + credit_type = _int(item.get("tag9")) + once_max = _int(item.get("tag21")) + count = custom_count if credit_type == 2 else 1 + if once_max > 0: + count = min(count, once_max) + groups = item.get("tag8") + group_items = groups if isinstance(groups, list) else [] + return { + "spu_id": spu_id, + "sku_id": sku_id, + "name": name or spu_id, + "gain_score": gain_score, + "price": price, + "count": count, + "credit_type": credit_type, + "once_max": once_max, + "group_items": group_items, + "remaining": _stock_remaining(item.get("tag20")), + } + + +@dataclass +class RedeemState: + code: int + message: str + user_score: int + redeem_need_score: int + need_buy_score: int + meet_precondition: bool + full_score: bool + plans: list[dict[str, Any]] = field(default_factory=list) + raw: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return { + "code": self.code, + "message": self.message, + "user_score": self.user_score, + "redeem_need_score": self.redeem_need_score, + "need_buy_score": self.need_buy_score, + "meet_precondition": self.meet_precondition, + "full_score": self.full_score, + "plans": self.plans, + } + + +@dataclass +class PointsExchangeOutcome: + status: str + message: str + state: RedeemState | None = None + target: dict[str, Any] = field(default_factory=dict) + order: dict[str, Any] = field(default_factory=dict) + plan: dict[str, Any] | None = None + purchase: ShopOrderOutcome | None = None + detail: dict[str, Any] = field(default_factory=dict) + + +def _parse_redeem_state(response: Any) -> RedeemState: + code = _response_code(response) + plans_raw = _value(response, "tag7") + plans = [ + plan + for plan in ( + _parse_plan(item) + for item in (plans_raw if isinstance(plans_raw, list) else []) + ) + if plan is not None + ] + return RedeemState( + code=code, + message=_response_message(response), + user_score=_int(_value(response, "tag2")), + redeem_need_score=_int(_value(response, "tag3")), + need_buy_score=_int(_value(response, "tag4")), + meet_precondition=_int(_value(response, "tag5")) == 1, + full_score=_int(_value(response, "tag6")) == 1, + plans=plans, + raw=response.to_dict() if hasattr(response, "to_dict") else {}, + ) + + +def get_redeem_state( + client: Any, + *, + uid: int, + cookie: str, + spu_id: str, +) -> RedeemState: + response = client.get_redeem_need_goods( + uid=uid, + guid="", + cookie=cookie, + goods_num=spu_number(spu_id), + ) + return _parse_redeem_state(response) + + +def _get_target_goods( + client: Any, + *, + uid: int, + cookie: str, + pid: int, + spu_id: str, +) -> dict[str, Any]: + response = client.get_goods_info( + uid=uid, + guid="", + cookie=cookie, + pid=pid, + spu_id=normalize_spu_id(spu_id), + sku_id=0, + game_id="", + ) + if response is None: + raise RuntimeError("兑换商品详情接口无响应") + if _response_code(response) != 200: + raise RuntimeError(_response_message(response) or "兑换商品详情获取失败") + detail = response.to_dict() if hasattr(response, "to_dict") else {} + sku_id = _int(detail.get("sku_id")) + if not sku_id: + raise RuntimeError("兑换商品缺少 SKU,请先刷新商品列表") + return { + "spu_id": normalize_spu_id(spu_id), + "sku_id": sku_id, + "name": _text(detail.get("name")) or normalize_spu_id(spu_id), + "price": _int(detail.get("price")), + "goods_type": _int(detail.get("goods_type")), + "virtual_type": _int(detail.get("virtual_type")), + "detail": detail, + } + + +def _create_redeem_order( + client: Any, + *, + uid: int, + cookie: str, + pid: int, + target: dict[str, Any], +) -> PointsExchangeOutcome: + response = client.create_order_v6( + uid=uid, + guid="", + cookie=cookie, + pid=pid, + items=[ + { + "spu_id": target["spu_id"], + "sku_id": target["sku_id"], + "item_count": 1, + "goods_type": target.get("goods_type", 0), + "virtual_type": target.get("virtual_type", 0), + } + ], + ) + if response is None: + return PointsExchangeOutcome( + EXCHANGE_STATUS_FAILED, + "兑换下单接口无响应", + target=target, + ) + order = response.to_dict() if hasattr(response, "to_dict") else {} + if _response_code(response) != 200: + return PointsExchangeOutcome( + EXCHANGE_STATUS_FAILED, + _response_message(response) or "积分兑换下单失败", + target=target, + order=order, + ) + return PointsExchangeOutcome( + EXCHANGE_STATUS_EXCHANGED, + f"兑换成功: {target['name']}", + target=target, + order=order, + ) + + +def select_points_plan( + state: RedeemState, +) -> dict[str, Any] | None: + if not state.plans: + return None + simple = [plan for plan in state.plans if not plan.get("group_items")] + candidates = simple or list(state.plans) + enough = [ + plan + for plan in candidates + if plan["gain_score"] * max(1, plan["count"]) >= state.need_buy_score + ] + pool = enough or candidates + return min( + pool, + key=lambda plan: ( + plan["price"] * max(1, plan["count"]), + plan["gain_score"] * max(1, plan["count"]), + ), + ) + + +def prepare_exchange( + client: Any, + *, + uid: int, + cookie: str, + pid: int, + spu_id: str, + pay_channel: str = "Zfb", + stopped: Callable[[], bool] | None = None, + log: Callable[[str], None] | None = None, +) -> PointsExchangeOutcome: + """检查兑换状态;直接兑换或返回一张待支付的积分包订单。""" + state = get_redeem_state(client, uid=uid, cookie=cookie, spu_id=spu_id) + if state.code != 200: + return PointsExchangeOutcome( + EXCHANGE_STATUS_FAILED, + state.message or f"兑换状态查询失败: {state.code}", + state=state, + ) + if not state.meet_precondition: + return PointsExchangeOutcome( + EXCHANGE_STATUS_FAILED, + "当前账号未满足兑换前置条件,请先开通对应宝典", + state=state, + ) + + try: + target = _get_target_goods( + client, uid=uid, cookie=cookie, pid=pid, spu_id=spu_id + ) + except RuntimeError as exc: + return PointsExchangeOutcome(EXCHANGE_STATUS_FAILED, str(exc), state=state) + + if state.full_score: + outcome = _create_redeem_order( + client, uid=uid, cookie=cookie, pid=pid, target=target + ) + outcome.state = state + return outcome + + plan = select_points_plan(state) + if plan is None: + return PointsExchangeOutcome( + EXCHANGE_STATUS_FAILED, + "积分不足,服务端没有返回可购买的积分包", + state=state, + target=target, + ) + if plan.get("group_items"): + return PointsExchangeOutcome( + EXCHANGE_STATUS_FAILED, + "服务端返回组合积分包,当前版本无法安全自动下单", + state=state, + target=target, + plan=plan, + ) + + purchase = run_open_flow( + client, + uid=uid, + cookie=cookie, + pid=pid, + spu_id=plan["spu_id"], + sku_id=plan["sku_id"], + count=int(plan["count"]), + pay_channel=pay_channel, + snapshot_price=int(plan["price"]), + log=log, + stopped=stopped, + ) + if not purchase.ok: + return PointsExchangeOutcome( + EXCHANGE_STATUS_FAILED, + purchase.message, + state=state, + target=target, + plan=plan, + purchase=purchase, + detail=purchase.detail, + ) + return PointsExchangeOutcome( + EXCHANGE_STATUS_PAYMENT_REQUIRED, + f"积分不足,已生成积分包支付码: {plan['name']}", + state=state, + target=target, + plan=plan, + purchase=purchase, + ) + + +def complete_exchange_after_payment( + client: Any, + *, + uid: int, + cookie: str, + pid: int, + spu_id: str, + attempts: int = 5, + interval: float = 3.0, + sleep: Callable[[float], None] = time.sleep, +) -> PointsExchangeOutcome: + """支付成功后等待积分到账并提交 V6 兑换。""" + state: RedeemState | None = None + last_message = "积分尚未到账" + for index in range(max(1, attempts)): + state = get_redeem_state(client, uid=uid, cookie=cookie, spu_id=spu_id) + if state.code != 200: + last_message = state.message or f"兑换状态查询失败: {state.code}" + elif not state.meet_precondition: + return PointsExchangeOutcome( + EXCHANGE_STATUS_FAILED, + "支付成功,但账号仍未满足兑换前置条件", + state=state, + ) + elif state.full_score: + try: + target = _get_target_goods( + client, uid=uid, cookie=cookie, pid=pid, spu_id=spu_id + ) + except RuntimeError as exc: + return PointsExchangeOutcome( + EXCHANGE_STATUS_FAILED, str(exc), state=state + ) + outcome = _create_redeem_order( + client, uid=uid, cookie=cookie, pid=pid, target=target + ) + outcome.state = state + return outcome + else: + last_message = ( + f"积分到账中:当前 {state.user_score},还需 {state.need_buy_score}" + ) + if index + 1 < max(1, attempts): + sleep(interval) + return PointsExchangeOutcome( + EXCHANGE_STATUS_FAILED, + last_message, + state=state, + ) diff --git a/web/backend/services/huya_runner_goods.py b/web/backend/services/huya_runner_goods.py index 883ae2f..feb36c2 100644 --- a/web/backend/services/huya_runner_goods.py +++ b/web/backend/services/huya_runner_goods.py @@ -11,6 +11,14 @@ from sqlalchemy.orm import Session from core.huya.wss_client import POINTS_REDEEM_GOODS_NUM from ..models import HuyaAccount, HuyaGoodsSnapshot, HuyaTask +from .huya_pay_link import pay_qr_path, resolve_pay_targets +from .huya_points_exchange import ( + EXCHANGE_STATUS_EXCHANGED, + EXCHANGE_STATUS_PAYMENT_REQUIRED, + complete_exchange_after_payment, + normalize_spu_id, + prepare_exchange, +) # 积分查询超时:HTTP 通道正常几百毫秒,10 秒足够;失败时别让整批任务长时间挂住。 HUYA_POINTS_QUERY_TIMEOUT_SECONDS = 10.0 @@ -37,6 +45,8 @@ class GoodsMixin: self, uid: int, cookie: str, b_act_id_int: int, outer_act_id: str = "" ): ... def _mark_task(self, *args: Any, **kwargs: Any) -> None: ... + def _update_task_progress(self, *args: Any, **kwargs: Any) -> None: ... + def _wait_recharge_payment(self, *args: Any, **kwargs: Any): ... def _wait_until(self, when: datetime, uid: int) -> bool: ... def _parse_scheduled_time(self, value: Any) -> datetime | None: ... @@ -216,16 +226,6 @@ class GoodsMixin: account_info: dict, config_info: dict, ): - sid = str(self.payload.get("sid") or config_info.get("sid") or "").strip() - if not sid: - self._mark_task(worker_db, task, "failed", "请先配置虎牙活动 SID") - return - - sid_int = self._to_int(sid) - if not sid_int: - self._mark_task(worker_db, task, "failed", f"虎牙活动 SID 无效: {sid}") - return - uid = self._resolve_uid(account_info) if not uid: self._mark_task(worker_db, task, "failed", "无法从账号或 Cookie 解析 yyuid") @@ -236,7 +236,50 @@ class GoodsMixin: self._mark_task(worker_db, task, "failed", "账号 Cookie 为空") return - client: Any = self._activity_client(uid, cookie) + from core.huya.shop_structs import extract_points_center_sid + + client: Any = self._http_client() + configured_sid = self._to_int(self.payload.get("sid") or config_info.get("sid")) + sid_int = configured_sid + sid_source = "config" + try: + page_code = ( + str(config_info.get("points_page_code") or "").strip() + or "heping_baodian_credit_center_page" + ) + source_id = ( + str(config_info.get("points_source_id") or "").strip() + or "yellowcarlist" + ) + scene = self._to_int(config_info.get("points_scene")) or 7 + allocation = client.play_mall_query_allocation( + uid=uid, + guid="", + cookie=cookie, + page_code=page_code, + source_id=source_id, + scene=scene, + deep=True, + ) + payload = getattr(allocation, "payload", None) + discovered_sid = extract_points_center_sid(payload) + if discovered_sid: + sid_int = discovered_sid + sid_source = "queryAllocation" + except Exception as exc: # noqa: BLE001 自动发现失败时回退配置值 + self._push_log( + "warning", + f"[{uid}] 自动发现兑换活动 SID 失败,回退配置值: {exc}", + ) + if not sid_int: + self._mark_task( + worker_db, + task, + "failed", + "无法确定当前兑换活动 SID,请先刷新积分中心商品", + ) + return + response = client.get_user_prize_records(uid=uid, cookie=cookie, sid=sid_int) if response is None: self._mark_task(worker_db, task, "error", "虎牙兑换记录接口无响应") @@ -244,6 +287,8 @@ class GoodsMixin: result = response.to_dict() result["sid"] = sid_int + result["sid_source"] = sid_source + result["configured_sid"] = configured_sid if response.status != 200: self._mark_task( worker_db, @@ -277,16 +322,6 @@ class GoodsMixin: account_info: dict, config_info: dict, ): - sid = str(self.payload.get("sid") or config_info.get("sid") or "").strip() - if not sid: - self._mark_task(worker_db, task, "failed", "请先配置虎牙活动 SID") - return - - sid_int = self._to_int(sid) - if not sid_int: - self._mark_task(worker_db, task, "failed", f"虎牙活动 SID 无效: {sid}") - return - uid = self._resolve_uid(account_info) if not uid: self._mark_task(worker_db, task, "failed", "无法从账号或 Cookie 解析 yyuid") @@ -297,29 +332,36 @@ class GoodsMixin: self._mark_task(worker_db, task, "failed", "账号 Cookie 为空") return - client: Any = self._activity_client(uid, cookie) - response = client.get_act_prize_list(uid=uid, cookie=cookie, sid=sid_int) - if response is None: - self._mark_task(worker_db, task, "error", "虎牙商品列表接口无响应") - return + from .huya_collab_service import fetch_points_center_gifts - result = response.to_dict() - result["sid"] = sid_int - if response.status != 200: - self._mark_task( - worker_db, - task, - "failed", - response.msg or f"虎牙商品列表刷新失败: {response.status}", - result, - ) - return - - goods = [ - item - for item in result.get("goods", []) - if item.get("product_id") and item.get("name") + sid_int = self._to_int(config_info.get("sid")) or 2203 + page_code = ( + str(config_info.get("points_page_code") or "").strip() + or "heping_baodian_credit_center_page" + ) + source_id = ( + str(config_info.get("points_source_id") or "").strip() or "yellowcarlist" + ) + scene = self._to_int(config_info.get("points_scene")) or 7 + page_id = self._to_int(config_info.get("points_page_id")) or None + tab_ids = [ + int(part) + for part in str(config_info.get("points_tab_ids") or "") + .replace(",", ",") + .replace(" ", ",") + .split(",") + if part.isdigit() ] + goods, message = fetch_points_center_gifts( + uid, + cookie, + client=self._http_client(), + page_code=page_code, + source_id=source_id, + scene=scene, + page_id=page_id, + tab_ids=tab_ids or None, + ) now = datetime.now(UTC) worker_db.query(HuyaGoodsSnapshot).filter( HuyaGoodsSnapshot.account_id == account.id, @@ -339,10 +381,26 @@ class GoodsMixin: ) ) + result = { + "source": "PlayMallUI.queryAllocation", + "sid": sid_int, + "page_code": page_code, + "source_id": source_id, + "scene": scene, + "goods": goods, + } + if not goods: + self._mark_task(worker_db, task, "failed", message, result) + return account.status = "goods_refreshed" account.updated_at = now - message = f"已刷新商品 {len(goods)} 个" - self._mark_task(worker_db, task, "success", message, {**result, "goods": goods}) + self._mark_task( + worker_db, + task, + "success", + f"已刷新积分中心商品 {len(goods)} 个", + result, + ) def _execute_exchange_goods( self, @@ -352,18 +410,8 @@ class GoodsMixin: account_info: dict, config_info: dict, ): - sid = str(self.payload.get("sid") or config_info.get("sid") or "").strip() - if not sid: - self._mark_task(worker_db, task, "failed", "请先配置虎牙活动 SID") - return - - sid_int = self._to_int(sid) - if not sid_int: - self._mark_task(worker_db, task, "failed", f"虎牙活动 SID 无效: {sid}") - return - - product_id = self._to_int(self.payload.get("product_id")) - if not product_id: + spu_id = normalize_spu_id(self.payload.get("product_id")) + if not spu_id: self._mark_task(worker_db, task, "failed", "请选择兑换商品") return @@ -377,20 +425,6 @@ class GoodsMixin: self._mark_task(worker_db, task, "failed", "账号 Cookie 为空") return - snapshot = ( - worker_db.query(HuyaGoodsSnapshot) - .filter( - HuyaGoodsSnapshot.account_id == account.id, - HuyaGoodsSnapshot.sid == sid_int, - HuyaGoodsSnapshot.product_id == str(product_id), - ) - .first() - ) - product_name = str( - self.payload.get("product_name") - or (snapshot.name if snapshot else "") - or product_id - ) scheduled_at = self._parse_scheduled_time(self.payload.get("scheduled_at")) if self.payload.get("scheduled_at") and scheduled_at is None: self._mark_task(worker_db, task, "failed", "定时兑换时间格式无效") @@ -403,92 +437,153 @@ class GoodsMixin: self._mark_task(worker_db, task, "stopped", "兑换任务已停止") return - client: Any = self._activity_client(uid, cookie) - detail = client.get_act_prize_detail( - uid=uid, cookie=cookie, sid=sid_int, pid=product_id - ) - if detail is None: - self._mark_task(worker_db, task, "error", "虎牙兑换详情接口无响应") + pid = self._to_int(config_info.get("room_pid")) + if not pid: + self._mark_task(worker_db, task, "failed", "请先配置虎牙直播间 ID") return - detail_result = detail.to_dict() - prize = detail.prize - if detail.status != 200 or prize is None: + pay_channel = self._normalize_pay_channel( + self.payload.get("pay_channel") or config_info.get("pay_channel") + ) + client: Any = self._http_client() + prepared = prepare_exchange( + client, + uid=uid, + cookie=cookie, + pid=pid, + spu_id=spu_id, + pay_channel=pay_channel, + stopped=self._stop.is_set, + log=lambda message: self._push_log("info", message), + ) + common = { + "spu_id": spu_id, + "product_name": (prepared.target.get("name") if prepared.target else "") + or str(self.payload.get("product_name") or spu_id), + "scheduled_at": scheduled_at.isoformat() if scheduled_at else "", + "executed_at": datetime.now(UTC).isoformat(), + "redeem_state": prepared.state.to_dict() if prepared.state else None, + "target": prepared.target, + "exchange_plan": prepared.plan, + } + if prepared.status == EXCHANGE_STATUS_EXCHANGED: + result = { + **common, + "exchange_mode": "points_only", + "exchange_order": prepared.order, + "payment_status": "not_required", + "payment_status_label": "积分直兑", + } + account.status = "goods_exchanged" + account.updated_at = datetime.now(UTC) + if prepared.state: + account.points = prepared.state.user_score + self._mark_task(worker_db, task, "success", prepared.message, result) + return + if prepared.status != EXCHANGE_STATUS_PAYMENT_REQUIRED or not prepared.purchase: self._mark_task( worker_db, task, "failed", - detail.msg or f"虎牙兑换详情获取失败: {detail.status}", - detail_result, + prepared.message, + {**common, **prepared.detail}, ) return - now_ts = int(time.time()) - if prize.isCanExchange == 0: - self._mark_task( - worker_db, task, "failed", "该商品当前不可兑换", detail_result - ) - return - if prize.isShowNum and prize.leftNum <= 0: - self._mark_task(worker_db, task, "failed", "该商品库存不足", detail_result) - return - if prize.exchangeStartTime and now_ts < prize.exchangeStartTime: - self._mark_task( - worker_db, task, "failed", "该商品尚未开始兑换", detail_result - ) - return - if prize.exchangeEndTime and now_ts > prize.exchangeEndTime: - self._mark_task( - worker_db, task, "failed", "该商品兑换已结束", detail_result - ) - return - score = client.query_user_score(uid=uid, cookie=cookie, sid=sid_int) - if score is None: - self._mark_task( - worker_db, task, "error", "兑换前积分查询无响应", detail_result - ) - return - if score.status != 200 or score.available_score < prize.newScore: - detail_result["score"] = score.to_dict() - self._mark_task(worker_db, task, "failed", "可用积分不足", detail_result) - return - response = client.score_exchange_prize( - uid=uid, cookie=cookie, sid=sid_int, pid=product_id - ) - if response is None: - self._mark_task(worker_db, task, "error", "虎牙兑换接口无响应") - return - result = response.to_dict() - result.update( - { - "sid": sid_int, - "product_id": str(product_id), - "product_name": product_name, - "scheduled_at": scheduled_at.isoformat() if scheduled_at else "", - "executed_at": datetime.now(UTC).isoformat(), - "goods": snapshot.raw if snapshot else None, - "prize_detail": detail_result, - } + purchase = prepared.purchase + pay_targets = resolve_pay_targets(purchase.pay_url) + result = { + **common, + "exchange_mode": "buy_points_then_exchange", + "points_package": prepared.plan, + "points_purchase": { + "order_id": purchase.order_id, + "app_order_id": purchase.app_order_id, + "pay_order_id": purchase.pay_order_id, + "product_name": purchase.product_name, + "count": purchase.count, + "unit_price": purchase.unit_price, + "amount": purchase.amount, + "supplier_name": purchase.supplier_name, + "order": purchase.order_raw, + "pay": purchase.pay_raw, + }, + "pay_url": purchase.pay_url, + "pay_qr_code": pay_targets.qr_code, + "pay_cashier_url": pay_targets.cashier_url, + "pay_qr_path": pay_qr_path(task.id) if task is not None else "", + "payment_status": "pending", + "payment_status_label": "等待支付积分包", + "payment_paid": False, + } + account.status = "exchange_payment_pending" + account.updated_at = datetime.now(UTC) + self._update_task_progress( + worker_db, + task, + "running", + f"积分不足,需先购买 {prepared.plan['name'] if prepared.plan else '积分包'},等待扫码支付", + result, ) - if response.status != 200: + payment_status, _payment_order = self._wait_recharge_payment( + client=client, + uid=uid, + guid="", + cookie=cookie, + order_id=purchase.order_id, + sid=self._to_int(config_info.get("sid")) or 2203, + result=result, + ) + if payment_status == "paid": + completed = complete_exchange_after_payment( + client, + uid=uid, + cookie=cookie, + pid=pid, + spu_id=spu_id, + ) + result.update( + { + "post_payment_redeem_state": ( + completed.state.to_dict() if completed.state else None + ), + "exchange_order": completed.order, + "target": completed.target or prepared.target, + } + ) + if completed.status == EXCHANGE_STATUS_EXCHANGED: + account.status = "goods_exchanged" + account.updated_at = datetime.now(UTC) + if completed.state: + account.points = completed.state.user_score + self._mark_task( + worker_db, + task, + "success", + f"积分包支付成功,{completed.message}", + result, + ) + else: + self._mark_task( + worker_db, + task, + "failed", + f"积分包支付成功,但兑换失败:{completed.message}", + result, + ) + return + if payment_status == "stopped": self._mark_task( worker_db, task, - "failed", - response.msg or f"虎牙兑换失败: {response.status}", + "stopped", + "积分包支付监听已停止", result, ) return - - post_score = client.query_user_score(uid=uid, cookie=cookie, sid=sid_int) - post_records = client.get_user_prize_records( - uid=uid, cookie=cookie, sid=sid_int + self._mark_task( + worker_db, + task, + "timeout", + f"积分包支付超时,订单 #{purchase.order_id} 仍未完成", + result, ) - if post_score is not None: - result["post_exchange_score"] = post_score.to_dict() - account.points = post_score.available_score - if post_records is not None: - result["post_exchange_records"] = post_records.to_dict() - account.status = "goods_exchanged" - account.updated_at = datetime.now(UTC) - message = response.msg or f"兑换成功: {product_name}" - self._mark_task(worker_db, task, "success", message, result) diff --git a/web/frontend/src/pages/HuyaElitePage.tsx b/web/frontend/src/pages/HuyaElitePage.tsx index 24c226d..80c95b5 100644 --- a/web/frontend/src/pages/HuyaElitePage.tsx +++ b/web/frontend/src/pages/HuyaElitePage.tsx @@ -21,7 +21,6 @@ import { formatTime, formatUnixSeconds } from '../utils/time'; const { Text } = Typography; const SCOPE = 'elite' as const; const ACT_ID = 25135; -const DEFAULT_SID = '2203'; const LAYOUT_KEY = 'huya_elite_layout_mode'; const PAGE_SIZE_KEY = 'huya_elite_page_size'; // 开通/充值:记住上次选的商品与数量(浏览器本地,换台机器不影响) @@ -130,8 +129,9 @@ function needsScanPay(task: HuyaTaskItem): boolean { * 客户还没扫,必须继续展示。 */ function qrDoneLabel(task: HuyaTaskItem): string | null { - if (task.task_type !== 'create_recharge_order') return null; + if (!['create_recharge_order', 'exchange_goods'].includes(task.task_type)) return null; if (needsScanPay(task)) return null; + if (task.task_type === 'exchange_goods') return '已兑换'; if (task.result?.elite_opening === true) return '开通中'; return task.result?.elite_opened === true ? '已开通' : '已支付'; } @@ -173,9 +173,18 @@ function latestTask(tasks: HuyaTaskItem[], accountId: number): HuyaTaskItem | un return tasks.filter((task) => task.account_id === accountId).sort((a, b) => b.id - a.id)[0]; } +function taskResultRecords(task: HuyaTaskItem | null | undefined): Record[] { + const records = task?.result?.records; + return Array.isArray(records) + ? records.filter((item): item is Record => Boolean(item && typeof item === 'object')) + : []; +} + function taskQrUrl(task: HuyaTaskItem | undefined): string { if (!task) return ''; - if (task.task_type === 'create_recharge_order') return payQrValue(task); + if (['create_recharge_order', 'exchange_goods'].includes(task.task_type)) { + return payQrValue(task); + } return resultText(task, 'bind_redirect_url') || resultText(task, 'url'); } @@ -213,6 +222,7 @@ export default function HuyaElitePage() { const [importTags, setImportTags] = useState([]); const [importSelected, setImportSelected] = useState([]); const [qrTask, setQrTask] = useState(null); + const [recordsTask, setRecordsTask] = useState(null); const [qrTaskLoading, setQrTaskLoading] = useState(false); // 小程序码图片独立保存:任务轮询只会更新轻量 tasks,不允许覆盖这张大图。 const [qrImage, setQrImage] = useState(''); @@ -358,7 +368,11 @@ export default function HuyaElitePage() { setStarting(true); try { const payload = taskType === 'exchange_goods' - ? { sid: Number(config?.sid || DEFAULT_SID), product_id: Number(selectedGoodsId), act_id: ACT_ID } + ? { + product_id: selectedGoodsId, + product_name: goods.find((item) => item.product_id === selectedGoodsId)?.name || '', + pay_channel: config?.pay_channel || '', + } : taskType === 'query_act_tasks' ? { act_id: ACT_ID } : taskType === 'create_recharge_order' ? (() => { // 精英宝典那块用 override 传自己的 spu(限购 1 份);其他商品用下拉选的 spu + 数量 @@ -384,8 +398,15 @@ export default function HuyaElitePage() { const next = raw as unknown as HuyaTaskItem; setTasks((prev) => [next, ...prev.filter((item) => item.id !== next.id)].slice(0, 300)); const result = (next.result || {}) as Record; - // 支付弹窗只在「生成开通支付码」和点账号行时打开:多选时每个码都会回包, - // 这里再自动弹会把用户刚关掉的弹窗反复顶出来(码会在弹窗里自动补齐)。 + // 积分兑换只有积分不足时才会生成支付码;直兑任务不弹空支付窗。 + if ( + next.task_type === 'exchange_goods' + && (result.pay_url || result.pay_qr_code || result.mini_qrcode_image) + ) { + setPayBatchIds((prev) => [...new Set([...prev, created.batch_id])]); + setPayOpen(true); + } + // 开通支付码只在批次创建时打开弹窗;后续回包不能反复把用户关掉的弹窗顶出来。 if (next.task_type === 'get_bind_qr') { if (result.mini_qrcode_image) { const directImage = String(result.mini_qrcode_image); @@ -509,7 +530,7 @@ export default function HuyaElitePage() { const exchangeCount = tasks.filter((task) => task.task_type === 'exchange_goods').length; // 开通支付码:同一批次里每个账号一个码,未支付的排前面;已支付/已开通的只留一行状态 const payTasks = useMemo(() => { - const list = tasks.filter((task) => task.task_type === 'create_recharge_order' + const list = tasks.filter((task) => ['create_recharge_order', 'exchange_goods'].includes(task.task_type) && payBatchIds.includes(task.batch_id) && Boolean(resultText(task, 'pay_url') || resultText(task, 'mini_qrcode_image'))); return [...list].sort((a, b) => Number(needsScanPay(b)) - Number(needsScanPay(a)) || a.id - b.id); @@ -517,11 +538,11 @@ export default function HuyaElitePage() { const payPendingTasks = payTasks.filter(needsScanPay); const payDoneTasks = payTasks.filter((task) => !needsScanPay(task)); // 多选账号时一次生成多个码:个别账号失败(限购、CK 失效…)要单独点名,否则少一个码看不出原因 - const payFailedTasks = useMemo(() => tasks.filter((task) => task.task_type === 'create_recharge_order' + const payFailedTasks = useMemo(() => tasks.filter((task) => ['create_recharge_order', 'exchange_goods'].includes(task.task_type) && payBatchIds.includes(task.batch_id) && ['failed', 'error', 'stopped'].includes(task.status)), [tasks, payBatchIds]); // 还在排队/生成中的码(并发不够时,后面的码要等前面的码付完或超时才轮到) - const payQueuedCount = useMemo(() => tasks.filter((task) => task.task_type === 'create_recharge_order' + const payQueuedCount = useMemo(() => tasks.filter((task) => ['create_recharge_order', 'exchange_goods'].includes(task.task_type) && payBatchIds.includes(task.batch_id) && ['planned', 'pending', 'running'].includes(task.status) && !resultText(task, 'pay_url')).length, [tasks, payBatchIds]); @@ -537,6 +558,52 @@ export default function HuyaElitePage() { catch { message.error('复制失败,请手动选中复制'); } }; + const viewedRecords = taskResultRecords(recordsTask); + const exchangeRecordColumns: TableProps>['columns'] = [ + { + title: '商品', + dataIndex: 'prize_name', + width: 210, + render: (value: unknown) => String(value || '-'), + }, + { + title: '积分', + dataIndex: 'score', + width: 90, + align: 'right', + render: (value: unknown) => Number(value || 0).toLocaleString(), + }, + { + title: '兑换时间', + dataIndex: 'exchange_time', + width: 180, + render: (value: unknown, record) => { + const text = record.exchange_time_text; + if (typeof text === 'string' && text) return text; + const timestamp = Number(value || 0); + return timestamp > 0 ? formatUnixSeconds(timestamp) : '-'; + }, + }, + { + title: '发放状态', + dataIndex: 'status_label', + width: 100, + align: 'center', + render: (value: unknown, record) => { + const label = String(value || record.prize_status || '-'); + return {label}; + }, + }, + { + title: '订单号', + dataIndex: 'order_id', + width: 240, + ellipsis: true, + render: (value: unknown) => + value ? {String(value)} : '-', + }, + ]; + // 列宽按比例分配(percent),表头与单元格统一居中,避免左右跳着看 const accountColumns: TableProps['columns'] = [ { title: '#', width: '4%', align: 'center', render: (_value, _row, index) => {index + 1} }, @@ -545,8 +612,8 @@ export default function HuyaElitePage() { { title: '游戏名', width: '13%', align: 'center', ellipsis: true, render: (_value, row) => { const expired = ckExpired(latestTask(tasks, row.id), row); return {row.game_name || {expired ? '角色未查到' : '未查询'}}{row.game_channel && {row.game_channel}}; } }, { title: '精英宝典', width: '13%', align: 'center', ellipsis: true, render: (_value, row) => { const task = latestTask(tasks, row.id); const opening = task?.result?.elite_opening === true; return {opening ? '开通中' : 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); const expired = ckExpired(task, row); 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); if (!task) return -; const url = taskQrUrl(task); const image = resultText(task, 'mini_qrcode_image'); if (task.task_type === 'get_bind_qr' && !image) { const hasMini = task.result?.has_mini_qrcode === true; if (!hasMini) return -; return
{ event.stopPropagation(); void openQrTask(task); }} style={{ cursor: 'pointer', display: 'inline-block', lineHeight: 0, background: '#fff', padding: 6, borderRadius: 4 }} title="点击查看小程序码">
; } 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: '25%', align: 'center', ellipsis: true, render: (_value, row) => { const task = latestTask(tasks, row.id); const expired = ckExpired(task, row); const records = taskResultRecords(task); return task ? {TASK_LABELS[task.task_type] || task.task_type}{task.message || '-'}{formatTime(task.finished_at || task.created_at)}{task.task_type === 'query_exchange_records' && records.length > 0 && } : 暂无操作; } }, + { 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 (task.task_type === 'get_bind_qr' && !image) { const hasMini = task.result?.has_mini_qrcode === true; if (!hasMini) return -; return
{ event.stopPropagation(); void openQrTask(task); }} style={{ cursor: 'pointer', display: 'inline-block', lineHeight: 0, background: '#fff', padding: 6, borderRadius: 4 }} title="点击查看小程序码">
; } if (!url && !image) return -; const doneLabel = qrDoneLabel(task); if (doneLabel) return {doneLabel}; return
{ event.stopPropagation(); if (!task) return; if (['create_recharge_order', 'exchange_goods'].includes(task.task_type)) 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); if (ckExpired(task, row)) return CK 失效; const status = task?.status || row.status || ''; return {TASK_STATUS_LABELS[status] || status || '待操作'}; } }, ]; @@ -562,7 +629,7 @@ export default function HuyaElitePage() { ); - const accountsBody = <>} placeholder="搜索账号/昵称/游戏名/手机号" value={search} onChange={(event) => { setSearch(event.target.value); setCurrentPage(1); }} style={{ width: 240 }} />{canImport && }{selectedIds.length > 0 && }{canTransferCollab && selectedIds.length === 1 && }
setSelectedIds(keys.map(Number)), preserveSelectedRowKeys: true }} columns={accountColumns} dataSource={filteredAccounts} locale={{ emptyText: '工作台暂无账号,点击右上角「导入账号」添加' }} pagination={{ current: currentPage, pageSize, total: filteredAccounts.length, showSizeChanger: true, showTotal: (total) => `共 ${total} 条`, onChange: (page, size) => { setCurrentPage(page); if (size !== pageSize) { setPageSize(size); localStorage.setItem(PAGE_SIZE_KEY, String(size)); setCurrentPage(1); } } }} scroll={{ y: 'calc(100vh - 300px)' }} onRow={(row) => ({ onClick: () => { const task = latestTask(tasks, row.id); if (!task?.result) return; if (task.task_type === 'get_bind_qr') void openQrTask(task); else if (task.task_type === 'create_recharge_order') openPayTasks(task.batch_id); }, onContextMenu: (event) => handleRowContextMenu(row, event), style: { cursor: 'context-menu' }, title: '右键打开账号动作' })} />; + const accountsBody = <>} placeholder="搜索账号/昵称/游戏名/手机号" value={search} onChange={(event) => { setSearch(event.target.value); setCurrentPage(1); }} style={{ width: 240 }} />{canImport && }{selectedIds.length > 0 && }{canTransferCollab && selectedIds.length === 1 && }
setSelectedIds(keys.map(Number)), preserveSelectedRowKeys: true }} columns={accountColumns} dataSource={filteredAccounts} locale={{ emptyText: '工作台暂无账号,点击右上角「导入账号」添加' }} pagination={{ current: currentPage, pageSize, total: filteredAccounts.length, showSizeChanger: true, showTotal: (total) => `共 ${total} 条`, onChange: (page, size) => { setCurrentPage(page); if (size !== pageSize) { setPageSize(size); localStorage.setItem(PAGE_SIZE_KEY, String(size)); setCurrentPage(1); } } }} scroll={{ y: 'calc(100vh - 300px)' }} onRow={(row) => ({ onClick: () => { const task = latestTask(tasks, row.id); if (!task?.result) return; if (task.task_type === 'get_bind_qr') void openQrTask(task); else if (task.task_type === 'query_exchange_records' && taskResultRecords(task).length > 0) setRecordsTask(task); else if (['create_recharge_order', 'exchange_goods'].includes(task.task_type)) openPayTasks(task.batch_id); }, onContextMenu: (event) => handleRowContextMenu(row, event), style: { cursor: 'context-menu' }, title: '右键打开账号动作' })} />; const configField = (key: Exclude, label: string) => configDraft && {label} setConfigDraft({ ...configDraft, [key]: event.target.value })} style={{ width: 300 }} />; const contextMenuStyle = contextMenu ? { @@ -580,9 +647,37 @@ export default function HuyaElitePage() { 兑换任务 {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); setQrImage(''); }} centered>{qrTask &&
{qrImage ? 绑定二维码 : qrTask.result?.has_mini_qrcode === true ?
{qrTaskLoading ? : 正在获取小程序码…}
: }

{qrTask.message || qrTask.status}

}
+ setRecordsTask(null)} + width={980} + centered + > + + + 共 {viewedRecords.length} 条 + 活动 SID {String(recordsTask?.result?.sid || '-')} + {recordsTask?.message ? {recordsTask.message} : null} + +
String(record.record_id || record.order_id || index)} + size="small" + columns={exchangeRecordColumns} + dataSource={viewedRecords} + pagination={{ + pageSize: 10, + showSizeChanger: false, + showTotal: (total) => `共 ${total} 条`, + }} + scroll={{ x: 820 }} + /> + + setPayOpen(false)} diff --git a/web/frontend/src/pages/HuyaTasksPage.tsx b/web/frontend/src/pages/HuyaTasksPage.tsx index 05e733d..169ca82 100644 --- a/web/frontend/src/pages/HuyaTasksPage.tsx +++ b/web/frontend/src/pages/HuyaTasksPage.tsx @@ -257,7 +257,8 @@ function bindReadyForConfirm(task: HuyaTaskItem | null | undefined): boolean { } function hasPaymentQrcode(task: HuyaTaskItem): boolean { - return task.task_type === 'create_recharge_order' && Boolean(resultText(task.result, 'pay_url')); + return ['create_recharge_order', 'exchange_goods'].includes(task.task_type) + && Boolean(resultText(task.result, 'pay_url')); } function paymentStatus(task: HuyaTaskItem | null | undefined): string { @@ -983,7 +984,7 @@ export default function HuyaTasksPage() { ? resultProfileNick(qrResult) || qrTask.account_nickname || qrTask.account_uid || `#${qrTask.account_id}` : ''; const payResult = payTask?.result || null; - const payUrl = resultText(payResult, 'pay_url'); + const payUrl = resultText(payResult, 'pay_qr_code') || resultText(payResult, 'pay_cashier_url') || resultText(payResult, 'pay_url'); const payProductName = resultText(payResult, 'product_name'); const payAmountText = resultText(payResult, 'amount_text'); const payChannelLabel = resultText(payResult, 'pay_channel_label'); @@ -1099,7 +1100,20 @@ export default function HuyaTasksPage() { } if (record.task_type === 'exchange_goods') { const productName = resultText(value, 'product_name'); - const orderId = resultText(value, 'order_id'); + const exchangeOrder = value?.exchange_order; + const orderId = exchangeOrder && typeof exchangeOrder === 'object' + ? String((exchangeOrder as Record).order_id || '') + : resultText(value, 'order_id'); + if (resultText(value, 'pay_url') && record.status !== 'success') { + return ( + + 等待支付积分包 + + + ); + } if (record.status !== 'success') { return productName ? {productName} : -; } diff --git a/web/frontend/src/pages/WeiboAccountsPage.tsx b/web/frontend/src/pages/WeiboAccountsPage.tsx index dfa6bcf..cc80a3b 100644 --- a/web/frontend/src/pages/WeiboAccountsPage.tsx +++ b/web/frontend/src/pages/WeiboAccountsPage.tsx @@ -97,6 +97,8 @@ const WEIBO_MALL_CACHE_KEY = 'weibo_workbench_mall_cache_v1'; const WEIBO_BATCH_RECHARGE_MAX = 20; const WEIBO_BATCH_RECHARGE_CONCURRENCY = 3; const WEIBO_BATCH_RECHARGE_POLL_INTERVAL_MS = 8_000; +const WEIBO_BATCH_EXCHANGE_MAX = 50; +const WEIBO_BATCH_EXCHANGE_CONCURRENCY = 3; function normalizeImageURL(value: string): string { const imageURL = value.trim(); @@ -122,6 +124,13 @@ interface BatchRechargeEntry { creditError: string; } +interface BatchExchangeEntry { + account: WeiboAccountItem; + phase: 'loading' | 'done' | 'error'; + result: WeiboExchangeRecordsResult | null; + error: string; +} + interface ImportRefreshTarget { id: number; label: string; @@ -259,6 +268,14 @@ export default function WeiboAccountsPage({ const [exchangeLoading, setExchangeLoading] = useState(false); const [exchangeResult, setExchangeResult] = useState(null); + const [batchExchangeOpen, setBatchExchangeOpen] = useState(false); + const [batchExchangeLoading, setBatchExchangeLoading] = useState(false); + const [batchExchangeSelectedId, setBatchExchangeSelectedId] = useState< + number | null + >(null); + const [batchExchangeEntries, setBatchExchangeEntries] = useState< + BatchExchangeEntry[] + >([]); const [pointLoading, setPointLoading] = useState(false); const [pointResult, setPointResult] = useState(null); const [exchangingGoodsId, setExchangingGoodsId] = useState(null); @@ -957,6 +974,126 @@ export default function WeiboAccountsPage({ void loadExchangeRecords(account); }; + const handleBatchExchangeRecords = async () => { + const selected = items.filter((item) => selectedIds.includes(item.id)); + if (selected.length > WEIBO_BATCH_EXCHANGE_MAX) { + message.warning( + `批量查询兑换记录一次最多 ${WEIBO_BATCH_EXCHANGE_MAX} 个账号,请缩小选择范围`, + ); + return; + } + const enabled = selected.filter((item) => item.is_enabled); + const disabledCount = selected.length - enabled.length; + if (enabled.length === 0) { + message.warning('所选账号均已停用,无法查询实时兑换记录'); + return; + } + if (disabledCount > 0) { + message.info(`已自动跳过 ${disabledCount} 个停用账号`); + } + + const entries: BatchExchangeEntry[] = enabled.map((account) => ({ + account, + phase: 'loading', + result: null, + error: '', + })); + setBatchExchangeEntries(entries); + setBatchExchangeSelectedId(null); + setBatchExchangeLoading(true); + let success = 0; + let failed = 0; + let totalRecords = 0; + + const patchEntry = ( + accountId: number, + update: Partial, + ) => { + setBatchExchangeEntries((current) => + current.map((entry) => + entry.account.id === accountId ? { ...entry, ...update } : entry, + ), + ); + }; + + await runWithConcurrency( + entries, + WEIBO_BATCH_EXCHANGE_CONCURRENCY, + async (entry) => { + try { + const result = await weiboApi.exchangeRecords( + entry.account.id, + 1, + 50, + ); + success += 1; + totalRecords += result.total; + patchEntry(entry.account.id, { + phase: 'done', + result, + error: '', + }); + } catch (error) { + failed += 1; + patchEntry(entry.account.id, { + phase: 'error', + error: getErrorMessage(error), + }); + } + }, + ); + + setBatchExchangeLoading(false); + const text = `批量查询完成:成功 ${success} 个,失败 ${failed} 个,共 ${totalRecords} 条记录`; + if (failed > 0) message.warning(text); + else message.success(text); + }; + + const querySingleAccountExchangeRecords = async ( + account: WeiboAccountItem, + ) => { + if (!account.is_enabled) { + message.warning('账号已停用,请先启用'); + return; + } + setBatchExchangeEntries((current) => { + const nextEntry: BatchExchangeEntry = { + account, + phase: 'loading', + result: null, + error: '', + }; + return current.some((entry) => entry.account.id === account.id) + ? current.map((entry) => + entry.account.id === account.id ? nextEntry : entry, + ) + : [...current, nextEntry]; + }); + try { + const result = await weiboApi.exchangeRecords(account.id, 1, 50); + setBatchExchangeEntries((current) => + current.map((entry) => + entry.account.id === account.id + ? { ...entry, phase: 'done', result, error: '' } + : entry, + ), + ); + } catch (error) { + setBatchExchangeEntries((current) => + current.map((entry) => + entry.account.id === account.id + ? { + ...entry, + phase: 'error', + result: null, + error: getErrorMessage(error), + } + : entry, + ), + ); + } + }; + const loadPointHistory = async (account: WeiboAccountItem) => { setPointLoading(true); try { @@ -1520,6 +1657,88 @@ export default function WeiboAccountsPage({ render: (value: number | null) => value == null ? - : value.toLocaleString(), }, + ...(isWorkbench + ? [ + { + title: '兑换记录', + key: 'exchange_records', + width: 150, + align: 'center' as const, + onHeaderCell: centerHeader, + render: (_: unknown, account: WeiboAccountItem) => { + const entry = batchExchangeByAccount.get(account.id); + if (!entry) { + return ( + + ); + } + if (entry.phase === 'loading') { + return 查询中; + } + if (entry.phase === 'error') { + return ( + + + + ); + } + const latest = entry.result?.items[0]; + return ( + + + {entry.result?.total ?? 0} 条 + + {latest && ( + + {latest.goods_name} + + )} + + + ); + }, + }, + ] + : []), { title: '最近检查', dataIndex: 'ck_checked_at', @@ -1862,6 +2081,19 @@ export default function WeiboAccountsPage({ (total, item) => total + Math.max(0, item.cost_weidou ?? 0), 0, ) ?? 0; + const batchExchangeByAccount = new Map( + batchExchangeEntries.map((entry) => [entry.account.id, entry]), + ); + const batchExchangeSelectedEntry = + batchExchangeEntries.find( + (entry) => entry.account.id === batchExchangeSelectedId, + ) ?? null; + const batchExchangeDetailRows = ( + batchExchangeSelectedEntry?.result?.items || [] + ).map((record, index) => ({ + ...record, + key: `${batchExchangeSelectedEntry?.account.id || 0}-${record.exchange_time}-${index}`, + })); return ( @@ -2002,6 +2234,16 @@ export default function WeiboAccountsPage({ 批量充值 )} + {isWorkbench && ( + + )} {isWorkbench && canTransferCollab && selectedIds.length === 1 && ( + } + > + {batchExchangeSelectedEntry && ( + + + + {batchExchangeSelectedEntry.account.nickname || + batchExchangeSelectedEntry.account.uid} + + + UID {batchExchangeSelectedEntry.account.uid} + + + 共 {batchExchangeSelectedEntry.result?.total ?? 0} 条 + + +
`共 ${total} 条`, + }} + scroll={{ x: 760 }} + /> + + )} + + -