feat(huya): 统一积分兑换并完善兑换记录

This commit is contained in:
yml
2026-11-13 19:03:55 +08:00
parent b89ee4fd18
commit 711be0e2b6
17 changed files with 2064 additions and 193 deletions
+69
View File
@@ -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()
+154
View File
@@ -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
View File
@@ -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,
+7 -5
View File
@@ -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("})();")
+32
View File
@@ -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()
+479
View File
@@ -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"
+65 -1
View File
@@ -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 -1
View File
@@ -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"
+13 -2
View File
@@ -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)))
+16 -3
View File
@@ -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())
+47 -7
View File
@@ -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,
)
+238 -143
View File
@@ -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)
+108 -13
View File
@@ -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)}
+17 -3
View File
@@ -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>;
}
+297 -1
View File
@@ -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}
+24 -13
View File
@@ -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>
}
/>
)}