feat(huya): 统一积分兑换并完善兑换记录
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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<CreateOrderItemReqV6>
|
||||
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 系接口的应用信息。
|
||||
|
||||
|
||||
+54
-1
@@ -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,
|
||||
|
||||
@@ -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("})();")
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string, unknown>[] {
|
||||
const records = task?.result?.records;
|
||||
return Array.isArray(records)
|
||||
? records.filter((item): item is Record<string, unknown> => 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<string[]>([]);
|
||||
const [importSelected, setImportSelected] = useState<number[]>([]);
|
||||
const [qrTask, setQrTask] = useState<HuyaTaskItem | null>(null);
|
||||
const [recordsTask, setRecordsTask] = useState<HuyaTaskItem | null>(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<string, unknown>;
|
||||
// 支付弹窗只在「生成开通支付码」和点账号行时打开:多选时每个码都会回包,
|
||||
// 这里再自动弹会把用户刚关掉的弹窗反复顶出来(码会在弹窗里自动补齐)。
|
||||
// 积分兑换只有积分不足时才会生成支付码;直兑任务不弹空支付窗。
|
||||
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<Record<string, unknown>>['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 <Tag color={label === '已发放' ? 'success' : 'default'}>{label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '订单号',
|
||||
dataIndex: 'order_id',
|
||||
width: 240,
|
||||
ellipsis: true,
|
||||
render: (value: unknown) =>
|
||||
value ? <Text copyable={{ text: String(value) }}>{String(value)}</Text> : '-',
|
||||
},
|
||||
];
|
||||
|
||||
// 列宽按比例分配(percent),表头与单元格统一居中,避免左右跳着看
|
||||
const accountColumns: TableProps<HuyaAccountItem>['columns'] = [
|
||||
{ title: '#', width: '4%', align: 'center', render: (_value, _row, index) => <Text type="secondary">{index + 1}</Text> },
|
||||
@@ -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 <Space direction="vertical" align="center" size={0} style={{ width: '100%' }}><Text ellipsis title={row.game_name || row.game_channel}>{row.game_name || <Text type={expired ? 'danger' : 'secondary'}>{expired ? '角色未查到' : '未查询'}</Text>}</Text>{row.game_channel && <Text type="secondary" style={{ fontSize: 12 }} ellipsis>{row.game_channel}</Text>}</Space>; } },
|
||||
{ title: '精英宝典', width: '13%', align: 'center', ellipsis: true, render: (_value, row) => { const task = latestTask(tasks, row.id); const opening = task?.result?.elite_opening === true; return <Space direction="vertical" align="center" size={0} style={{ width: '100%' }}><Tag color={opening ? 'processing' : row.elite_opened === true ? 'success' : row.elite_opened === false ? 'default' : undefined}>{opening ? '开通中' : row.elite_opened === true ? '已开通' : row.elite_opened === false ? '未开通' : '未查询'}</Tag>{row.elite_opened === true && row.elite_open_time ? <Text type="secondary" style={{ fontSize: 12 }} ellipsis>{`开通 ${formatUnixSeconds(row.elite_open_time)}`}</Text> : null}{row.elite_expire_time ? <Text type="secondary" style={{ fontSize: 12 }} ellipsis>{`有效期至 ${formatUnixSeconds(row.elite_expire_time)}`}</Text> : null}</Space>; } },
|
||||
{ title: '积分', dataIndex: 'points', width: '7%', align: 'center', render: (value: number | null) => value ?? <Text type="secondary">-</Text> },
|
||||
{ title: '最近操作', width: '25%', align: 'center', ellipsis: true, render: (_value, row) => { const task = latestTask(tasks, row.id); const expired = ckExpired(task, row); return task ? <Space direction="vertical" align="center" size={1} style={{ width: '100%' }}><Text strong ellipsis>{TASK_LABELS[task.task_type] || task.task_type}</Text><Text type={expired ? 'danger' : 'secondary'} ellipsis title={task.message}>{task.message || '-'}</Text><Text type="secondary" style={{ fontSize: 12 }}>{formatTime(task.finished_at || task.created_at)}</Text></Space> : <Text type="secondary">暂无操作</Text>; } },
|
||||
{ title: '二维码', width: '6%', align: 'center', render: (_value, row) => { const task = latestTask(tasks, row.id); if (!task) return <Text type="secondary">-</Text>; 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 <Text type="secondary">-</Text>; return <div onClick={(event) => { event.stopPropagation(); void openQrTask(task); }} style={{ cursor: 'pointer', display: 'inline-block', lineHeight: 0, background: '#fff', padding: 6, borderRadius: 4 }} title="点击查看小程序码"><QrcodeOutlined style={{ fontSize: 26, color: '#000' }} /></div>; } if (!url && !image) return <Text type="secondary">-</Text>; const doneLabel = qrDoneLabel(task); if (doneLabel) return <Text type="secondary">{doneLabel}</Text>; return <div onClick={(event) => { event.stopPropagation(); if (!task) return; if (task.task_type === 'create_recharge_order') setZoomPayTask(task); else void openQrTask(task); }} style={{ cursor: 'pointer', display: 'inline-block', lineHeight: 0, background: '#fff', padding: 3, borderRadius: 4 }} title="点击放大扫码">{image ? <img src={miniQrcodeSrc(image)} alt="二维码" style={{ width: 52, height: 52, objectFit: 'contain' }} /> : <QRCode value={url} size={52} type="svg" errorLevel="L" bordered={false} color="#000" bgColor="#fff" />}</div>; } },
|
||||
{ title: '最近操作', width: '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 ? <Space direction="vertical" align="center" size={1} style={{ width: '100%' }}><Text strong ellipsis>{TASK_LABELS[task.task_type] || task.task_type}</Text><Text type={expired ? 'danger' : 'secondary'} ellipsis title={task.message}>{task.message || '-'}</Text><Text type="secondary" style={{ fontSize: 12 }}>{formatTime(task.finished_at || task.created_at)}</Text>{task.task_type === 'query_exchange_records' && records.length > 0 && <Button size="small" type="link" onClick={(event) => { event.stopPropagation(); setRecordsTask(task); }}>查看记录</Button>}</Space> : <Text type="secondary">暂无操作</Text>; } },
|
||||
{ title: '二维码', width: '6%', align: 'center', render: (_value, row) => { const task = latestTask(tasks, row.id); if (!task) return <Text type="secondary">-</Text>; const url = taskQrUrl(task); const image = resultText(task, 'mini_qrcode_image'); if (task.task_type === 'get_bind_qr' && !image) { const hasMini = task.result?.has_mini_qrcode === true; if (!hasMini) return <Text type="secondary">-</Text>; return <div onClick={(event) => { event.stopPropagation(); void openQrTask(task); }} style={{ cursor: 'pointer', display: 'inline-block', lineHeight: 0, background: '#fff', padding: 6, borderRadius: 4 }} title="点击查看小程序码"><QrcodeOutlined style={{ fontSize: 26, color: '#000' }} /></div>; } if (!url && !image) return <Text type="secondary">-</Text>; const doneLabel = qrDoneLabel(task); if (doneLabel) return <Text type="secondary">{doneLabel}</Text>; return <div onClick={(event) => { event.stopPropagation(); if (!task) return; if (['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 ? <img src={miniQrcodeSrc(image)} alt="二维码" style={{ width: 52, height: 52, objectFit: 'contain' }} /> : <QRCode value={url} size={52} type="svg" errorLevel="L" bordered={false} color="#000" bgColor="#fff" />}</div>; } },
|
||||
{ title: '状态', width: '7%', align: 'center', render: (_value, row) => { const task = latestTask(tasks, row.id); if (ckExpired(task, row)) return <Tooltip title={`${resultText(task, 'ck_expired_reason') || '角色信息查不到'} —— 请重新登录或重新导入 Cookie`}><Tag color="error">CK 失效</Tag></Tooltip>; const status = task?.status || row.status || ''; return <Tag color={TASK_COLORS[status] || 'default'}>{TASK_STATUS_LABELS[status] || status || '待操作'}</Tag>; } },
|
||||
];
|
||||
|
||||
@@ -562,7 +629,7 @@ export default function HuyaElitePage() {
|
||||
</Space>
|
||||
);
|
||||
|
||||
const accountsBody = <><Space style={{ marginBottom: 8, width: '100%', justifyContent: 'space-between' }} wrap><Input size="small" allowClear prefix={<SearchOutlined />} placeholder="搜索账号/昵称/游戏名/手机号" value={search} onChange={(event) => { setSearch(event.target.value); setCurrentPage(1); }} style={{ width: 240 }} /><Space><Button size="small" icon={<ImportOutlined />} onClick={() => setImportOpen(true)}>导入账号</Button>{canImport && <Button size="small" icon={<ImportOutlined />} onClick={() => setCkImportOpen(true)}>导入外部 CK</Button>}{selectedIds.length > 0 && <Button size="small" danger onClick={() => void saveAccounts(accounts.map((item) => item.id).filter((id) => !selectedIds.includes(id)))}>移出选中</Button>}{canTransferCollab && selectedIds.length === 1 && <Button size="small" icon={<SwapOutlined />} loading={transferring} onClick={() => void transferToCollab()}>转入外部单协作</Button>}</Space></Space><div className="huya-account-table"><Table rowKey="id" size="small" loading={loading} tableLayout="fixed" rowSelection={{ selectedRowKeys: selectedIds, onChange: (keys) => 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: '右键打开账号动作' })} /></div></>;
|
||||
const accountsBody = <><Space style={{ marginBottom: 8, width: '100%', justifyContent: 'space-between' }} wrap><Input size="small" allowClear prefix={<SearchOutlined />} placeholder="搜索账号/昵称/游戏名/手机号" value={search} onChange={(event) => { setSearch(event.target.value); setCurrentPage(1); }} style={{ width: 240 }} /><Space><Button size="small" icon={<ImportOutlined />} onClick={() => setImportOpen(true)}>导入账号</Button>{canImport && <Button size="small" icon={<ImportOutlined />} onClick={() => setCkImportOpen(true)}>导入外部 CK</Button>}{selectedIds.length > 0 && <Button size="small" danger onClick={() => void saveAccounts(accounts.map((item) => item.id).filter((id) => !selectedIds.includes(id)))}>移出选中</Button>}{canTransferCollab && selectedIds.length === 1 && <Button size="small" icon={<SwapOutlined />} loading={transferring} onClick={() => void transferToCollab()}>转入外部单协作</Button>}</Space></Space><div className="huya-account-table"><Table rowKey="id" size="small" loading={loading} tableLayout="fixed" rowSelection={{ selectedRowKeys: selectedIds, onChange: (keys) => 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: '右键打开账号动作' })} /></div></>;
|
||||
|
||||
const configField = (key: Exclude<keyof HuyaConfig, 'updated_at'>, label: string) => configDraft && <Space key={key} style={{ width: '100%', justifyContent: 'space-between' }}><Text>{label}</Text><Input value={configDraft[key] || ''} onChange={(event) => setConfigDraft({ ...configDraft, [key]: event.target.value })} style={{ width: 300 }} /></Space>;
|
||||
const contextMenuStyle = contextMenu ? {
|
||||
@@ -580,9 +647,37 @@ export default function HuyaElitePage() {
|
||||
<Text type="secondary" style={{ fontSize: 12, marginTop: 6 }}>兑换任务 {exchangeCount} 条;勾选账号后可批量执行,账号行支持右键操作。</Text>
|
||||
{contextMenu && <div style={contextMenuStyle} onClick={(event) => event.stopPropagation()}><Card size="small" title={contextMenu.accountIds.length > 1 ? '批量动作' : '账号动作'} styles={{ body: { padding: 4 } }} style={{ width: 220, boxShadow: '0 4px 12px rgba(0,0,0,0.14)' }}><Space direction="vertical" style={{ width: '100%' }}>{HUYA_QUICK_ACTIONS.map((item) => <Button key={item.key} block size="small" type="text" icon={item.icon} onClick={() => runContextAction(item.key)} disabled={starting} style={{ justifyContent: 'flex-start' }}>{TASK_LABELS[item.key] || item.key}</Button>)}</Space></Card></div>}
|
||||
<Modal open={qrTask !== null} title="绑定二维码" footer={null} onCancel={() => { setQrTask(null); setQrImage(''); }} centered>{qrTask && <div style={{ textAlign: 'center' }}>{qrImage ? <img src={miniQrcodeSrc(qrImage)} alt="绑定二维码" style={{ width: 240, height: 240, background: '#fff', padding: 12, boxSizing: 'border-box', borderRadius: 8 }} /> : qrTask.result?.has_mini_qrcode === true ? <div style={{ width: 240, height: 240, display: 'grid', placeItems: 'center', background: '#fff', borderRadius: 8 }}>{qrTaskLoading ? <Spin size="large" /> : <Text type="secondary">正在获取小程序码…</Text>}</div> : <QRCode value={resultText(qrTask, 'bind_redirect_url') || 'https://zt.huya.com/b02faae1/pc/index.html'} size={240} color="#000" bgColor="#fff" />}<p><Tag color={TASK_COLORS[qrTask.status] || 'default'}>{qrTask.message || qrTask.status}</Tag></p></div>}</Modal>
|
||||
<Modal
|
||||
open={recordsTask !== null}
|
||||
title={`兑换记录 · ${recordsTask?.account_nickname || recordsTask?.account_uid || ''}`}
|
||||
footer={null}
|
||||
onCancel={() => setRecordsTask(null)}
|
||||
width={980}
|
||||
centered
|
||||
>
|
||||
<Space orientation="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Space wrap>
|
||||
<Tag color="blue">共 {viewedRecords.length} 条</Tag>
|
||||
<Tag>活动 SID {String(recordsTask?.result?.sid || '-')}</Tag>
|
||||
{recordsTask?.message ? <Text type="secondary">{recordsTask.message}</Text> : null}
|
||||
</Space>
|
||||
<Table
|
||||
rowKey={(record, index) => 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 }}
|
||||
/>
|
||||
</Space>
|
||||
</Modal>
|
||||
<Modal
|
||||
open={payOpen}
|
||||
title={payTasks.length ? `开通支付码(${payPendingTasks.length} 个待扫码 · 已完成 ${payDoneTasks.length}/${payTasks.length})` : '开通支付码'}
|
||||
title={payTasks.length ? `支付码(${payPendingTasks.length} 个待扫码 · 已完成 ${payDoneTasks.length}/${payTasks.length})` : '支付码'}
|
||||
width={920}
|
||||
centered
|
||||
onCancel={() => setPayOpen(false)}
|
||||
|
||||
@@ -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<string, unknown>).order_id || '')
|
||||
: resultText(value, 'order_id');
|
||||
if (resultText(value, 'pay_url') && record.status !== 'success') {
|
||||
return (
|
||||
<Space size={6}>
|
||||
<Tag color="processing">等待支付积分包</Tag>
|
||||
<Button size="small" icon={<QrcodeOutlined />} onClick={() => setPayTask(record)}>
|
||||
支付码
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
if (record.status !== 'success') {
|
||||
return productName ? <Text>{productName}</Text> : <Text type="secondary">-</Text>;
|
||||
}
|
||||
|
||||
@@ -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<WeiboExchangeRecordsResult | null>(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<WeiboPointHistoryResult | null>(null);
|
||||
const [exchangingGoodsId, setExchangingGoodsId] = useState<number | null>(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<BatchExchangeEntry>,
|
||||
) => {
|
||||
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 ? <Text type="secondary">-</Text> : 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 (
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
disabled={!account.is_enabled}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void querySingleAccountExchangeRecords(account);
|
||||
}}
|
||||
>
|
||||
查询
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
if (entry.phase === 'loading') {
|
||||
return <Tag color="processing">查询中</Tag>;
|
||||
}
|
||||
if (entry.phase === 'error') {
|
||||
return (
|
||||
<Tooltip title={entry.error}>
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
danger
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void querySingleAccountExchangeRecords(account);
|
||||
}}
|
||||
>
|
||||
查询失败
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
const latest = entry.result?.items[0];
|
||||
return (
|
||||
<Space
|
||||
orientation="vertical"
|
||||
size={0}
|
||||
style={{ alignItems: 'center' }}
|
||||
>
|
||||
<Tag color={entry.result?.total ? 'blue' : 'default'}>
|
||||
{entry.result?.total ?? 0} 条
|
||||
</Tag>
|
||||
{latest && (
|
||||
<Text
|
||||
type="secondary"
|
||||
ellipsis
|
||||
style={{ fontSize: 12, maxWidth: 140 }}
|
||||
title={latest.goods_name}
|
||||
>
|
||||
{latest.goods_name}
|
||||
</Text>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setBatchExchangeSelectedId(account.id);
|
||||
setBatchExchangeOpen(true);
|
||||
}}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
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 (
|
||||
<Space orientation="vertical" size={16} style={{ width: '100%' }}>
|
||||
@@ -2002,6 +2234,16 @@ export default function WeiboAccountsPage({
|
||||
批量充值
|
||||
</Button>
|
||||
)}
|
||||
{isWorkbench && (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<SearchOutlined />}
|
||||
loading={batchExchangeLoading}
|
||||
onClick={() => void handleBatchExchangeRecords()}
|
||||
>
|
||||
批量查兑换记录
|
||||
</Button>
|
||||
)}
|
||||
{isWorkbench && canTransferCollab && selectedIds.length === 1 && (
|
||||
<Button
|
||||
size="small"
|
||||
@@ -2122,7 +2364,7 @@ export default function WeiboAccountsPage({
|
||||
selectedRowKeys: selectedIds,
|
||||
onChange: (keys) => setSelectedIds(keys.map(Number)),
|
||||
}}
|
||||
scroll={{ x: isWorkbench ? 980 : 1450 }}
|
||||
scroll={{ x: isWorkbench ? 1130 : 1450 }}
|
||||
onRow={(account) => ({
|
||||
onClick: () => {
|
||||
if (isWorkbench) selectWorkbenchAccount(account);
|
||||
@@ -2443,6 +2685,60 @@ export default function WeiboAccountsPage({
|
||||
</AutoComplete>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`兑换记录 · ${
|
||||
batchExchangeSelectedEntry?.account.nickname ||
|
||||
batchExchangeSelectedEntry?.account.uid ||
|
||||
''
|
||||
}`}
|
||||
open={batchExchangeOpen}
|
||||
width={1080}
|
||||
onCancel={() => {
|
||||
setBatchExchangeOpen(false);
|
||||
setBatchExchangeSelectedId(null);
|
||||
}}
|
||||
footer={
|
||||
<Button
|
||||
onClick={() => {
|
||||
setBatchExchangeOpen(false);
|
||||
setBatchExchangeSelectedId(null);
|
||||
}}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{batchExchangeSelectedEntry && (
|
||||
<Space orientation="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Space wrap>
|
||||
<Text strong>
|
||||
{batchExchangeSelectedEntry.account.nickname ||
|
||||
batchExchangeSelectedEntry.account.uid}
|
||||
</Text>
|
||||
<Text type="secondary">
|
||||
UID {batchExchangeSelectedEntry.account.uid}
|
||||
</Text>
|
||||
<Tag color="blue">
|
||||
共 {batchExchangeSelectedEntry.result?.total ?? 0} 条
|
||||
</Tag>
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="key"
|
||||
size="small"
|
||||
columns={exchangeColumns}
|
||||
dataSource={batchExchangeDetailRows}
|
||||
locale={{ emptyText: '该账号暂无兑换记录' }}
|
||||
pagination={{
|
||||
pageSize: 20,
|
||||
showSizeChanger: false,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}}
|
||||
scroll={{ x: 760 }}
|
||||
/>
|
||||
</Space>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`批量充值 · 已选 ${selectedIds.length} 个账号`}
|
||||
open={batchRechargeConfigOpen}
|
||||
|
||||
@@ -1268,19 +1268,6 @@ export default function WeiboCollabPage() {
|
||||
{ value: 'error', label: ACCOUNT_CK_LABELS.error },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={archiveScope}
|
||||
onChange={(value) => {
|
||||
setArchiveScope(value);
|
||||
setPage(1);
|
||||
}}
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: 'active', label: '活跃台账' },
|
||||
{ value: 'archived', label: '已归档' },
|
||||
{ value: 'all', label: '全部' },
|
||||
]}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={includeVoid}
|
||||
onChange={(event) => {
|
||||
@@ -1290,6 +1277,19 @@ export default function WeiboCollabPage() {
|
||||
>
|
||||
显示作废
|
||||
</Checkbox>
|
||||
<Space size={4}>
|
||||
<Text type="secondary">查看归档</Text>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={archiveScope === 'archived'}
|
||||
onChange={(checked) => {
|
||||
setArchiveScope(checked ? 'archived' : 'active');
|
||||
setPage(1);
|
||||
}}
|
||||
aria-label="查看归档"
|
||||
/>
|
||||
<Tag>{summary?.archived_total ?? 0}</Tag>
|
||||
</Space>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={1_000_000}
|
||||
@@ -1366,6 +1366,17 @@ export default function WeiboCollabPage() {
|
||||
showIcon
|
||||
message="正在查看已归档订单"
|
||||
description="归档订单只读,取消归档后可继续操作。"
|
||||
action={
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setArchiveScope('active');
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
返回活跃台账
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user