重构虎牙精英宝典协议与支付状态链路

This commit is contained in:
yml2213
2026-09-01 11:31:15 +08:00
parent 955ba45488
commit 684e16a07a
12 changed files with 937 additions and 51 deletions
+340
View File
@@ -107,6 +107,84 @@ class GetUserScoreResp(TafStruct):
}
class GetActInfoReq(TafStruct):
"""webActUI.getActInfo 请求。"""
def __init__(self):
self.actId = 0
self.actUuid = ""
self.appSource = ""
def write_to(self, os: TafOutputStream):
os.write_int64(0, self.actId)
os.write_string(1, self.actUuid)
os.write_string(2, self.appSource)
def read_from(self, ins: TafInputStream):
self.actId = ins.read_int64(0, default=self.actId)
self.actUuid = ins.read_string(1, default=self.actUuid)
self.appSource = ins.read_string(2, default=self.appSource)
class ActInfoItem(TafStruct):
def __init__(self):
self.actId = 0
self.name = ""
self.startTime = 0
self.endTime = 0
self.moduleId = 0
self.outerActId = ""
self.gameIdList = ""
self.gid = 0
def read_from(self, ins: TafInputStream):
for tag, attr, reader in (
(0, "actId", ins.read_int64), (1, "name", ins.read_string),
(2, "startTime", ins.read_int64), (3, "endTime", ins.read_int64),
(4, "moduleId", ins.read_int64), (5, "outerActId", ins.read_string),
(6, "gameIdList", ins.read_string), (8, "gid", ins.read_int64),
):
setattr(self, attr, reader(tag, default=getattr(self, attr)))
def write_to(self, os: TafOutputStream):
os.write_int64(0, self.actId)
os.write_string(1, self.name)
os.write_int64(2, self.startTime)
os.write_int64(3, self.endTime)
os.write_int64(4, self.moduleId)
os.write_string(5, self.outerActId)
os.write_string(6, self.gameIdList)
os.write_int64(8, self.gid)
def to_dict(self) -> dict:
return {"act_id": self.actId, "name": self.name, "start_time": self.startTime,
"end_time": self.endTime, "module_id": self.moduleId,
"outer_act_id": self.outerActId, "game_id_list": self.gameIdList,
"gid": self.gid}
class GetActInfoResp(TafStruct):
def __init__(self):
self.status = 0
self.msg = ""
self.info: ActInfoItem | None = None
def read_from(self, ins: TafInputStream):
self.status = ins.read_int32(0, default=self.status)
self.msg = ins.read_string(1, default=self.msg)
self.info = ins.read_struct(2, ActInfoItem)
def write_to(self, os: TafOutputStream):
os.write_int32(0, self.status)
os.write_string(1, self.msg)
if self.info:
os.write_struct(2, self.info)
def to_dict(self) -> dict:
return {"status": self.status, "msg": self.msg,
"info": self.info.to_dict() if self.info else None}
class GetActPrizeListReq(TafStruct):
"""webActUI.getActPrizeList 请求。"""
@@ -170,6 +248,180 @@ class ScoreExchangePrizeReq(TafStruct):
self.isRole = ins.read_int32(6, default=self.isRole)
class GetActPrizeDetailReq(TafStruct):
"""webActUI.getActPrizeDetail 请求。"""
def __init__(self):
self.userId = ActivityUserId()
self.sid: int = 0
self.pid: int = 0
def write_to(self, os: TafOutputStream):
os.write_struct(0, self.userId)
os.write_int32(1, self.sid)
os.write_int32(2, self.pid)
def read_from(self, ins: TafInputStream):
self.userId = ins.read_struct(0, ActivityUserId) or self.userId
self.sid = ins.read_int32(1, default=self.sid)
self.pid = ins.read_int32(2, default=self.pid)
class ActPrizeDetailItem(TafStruct):
"""兑换详情中的奖品和服务端限制字段。"""
def __init__(self):
self.prizeId = 0
self.name = ""
self.score = 0
self.icon = ""
self.frequency = 0
self.frequencyLimit = 0
self.num = 0
self.status = 0
self.leftNum = 0
self.isShowNum = 0
self.percent = ""
self.newScore = 0
self.categoryId = ""
self.usedNum = 0
self.goodType = 0
self.commonPrizeId = 0
self.updateTime = 0
self.isCanExchange = 0
self.exchangeStartTime = 0
self.exchangeEndTime = 0
self.startTimeSlot = ""
self.endTimeSlot = ""
self.isTodayLimit = 0
self.isUserLimit = 0
def read_from(self, ins: TafInputStream):
readers = {
0: ("prizeId", ins.read_int64), 2: ("name", ins.read_string),
3: ("score", ins.read_int64), 5: ("icon", ins.read_string),
6: ("frequency", ins.read_int32), 7: ("frequencyLimit", ins.read_int32),
8: ("num", ins.read_int64), 9: ("status", ins.read_int32),
10: ("leftNum", ins.read_int64), 11: ("isShowNum", ins.read_int32),
12: ("percent", ins.read_string), 13: ("newScore", ins.read_int64),
14: ("categoryId", ins.read_string), 17: ("usedNum", ins.read_int64),
18: ("goodType", ins.read_int32), 22: ("commonPrizeId", ins.read_int64),
23: ("updateTime", ins.read_int64), 24: ("isCanExchange", ins.read_int32),
25: ("exchangeStartTime", ins.read_int64), 26: ("exchangeEndTime", ins.read_int64),
27: ("startTimeSlot", ins.read_string), 28: ("endTimeSlot", ins.read_string),
29: ("isTodayLimit", ins.read_int32), 30: ("isUserLimit", ins.read_int32),
}
while True:
pos = ins.buf.tell()
tag, dtype = ins.read_head()
if dtype == TafType.STRUCT_END:
ins.buf.seek(pos)
return
item = readers.get(tag)
if item is None:
ins.skip_field(dtype)
continue
name, reader = item
ins.buf.seek(pos)
setattr(self, name, reader(tag, default=getattr(self, name)))
def write_to(self, os: TafOutputStream):
os.write_int64(0, self.prizeId)
os.write_string(2, self.name)
os.write_int64(3, self.score)
os.write_string(5, self.icon)
os.write_int32(6, self.frequency)
os.write_int32(7, self.frequencyLimit)
os.write_int64(8, self.num)
os.write_int32(9, self.status)
os.write_int64(10, self.leftNum)
os.write_int32(11, self.isShowNum)
os.write_string(12, self.percent)
os.write_int64(13, self.newScore)
os.write_string(14, self.categoryId)
os.write_int64(17, self.usedNum)
os.write_int32(18, self.goodType)
os.write_int64(22, self.commonPrizeId)
os.write_int64(23, self.updateTime)
os.write_int32(24, self.isCanExchange)
os.write_int64(25, self.exchangeStartTime)
os.write_int64(26, self.exchangeEndTime)
os.write_string(27, self.startTimeSlot)
os.write_string(28, self.endTimeSlot)
os.write_int32(29, self.isTodayLimit)
os.write_int32(30, self.isUserLimit)
def to_dict(self) -> dict:
return {
"product_id": str(self.prizeId), "name": self.name, "score": self.score,
"icon": self.icon, "frequency": self.frequency,
"frequency_limit": self.frequencyLimit, "num": self.num,
"status": self.status, "left_num": self.leftNum,
"is_show_num": self.isShowNum, "percent": self.percent,
"new_score": self.newScore, "category_id": self.categoryId,
"used_num": self.usedNum, "good_type": self.goodType,
"common_prize_id": self.commonPrizeId, "update_time": self.updateTime,
"is_can_exchange": self.isCanExchange,
"exchange_start_time": self.exchangeStartTime,
"exchange_end_time": self.exchangeEndTime,
"start_time_slot": self.startTimeSlot, "end_time_slot": self.endTimeSlot,
"is_today_limit": self.isTodayLimit, "is_user_limit": self.isUserLimit,
}
class GetActPrizeDetailResp(TafStruct):
"""webActUI.getActPrizeDetail 响应。"""
def __init__(self):
self.status = 0
self.msg = ""
self.prize: ActPrizeDetailItem | None = None
self.detailStatus = 0
def read_from(self, ins: TafInputStream):
self.status = ins.read_int32(0, default=self.status)
self.msg = ins.read_string(1, default=self.msg)
outer = ins.read_struct(2, _ActPrizeDetailEnvelope)
if outer:
self.prize = outer.prize
self.detailStatus = outer.detailStatus
def write_to(self, os: TafOutputStream):
os.write_int32(0, self.status)
os.write_string(1, self.msg)
envelope = _ActPrizeDetailEnvelope()
envelope.prize = self.prize
envelope.detailStatus = self.detailStatus
os.write_struct(2, envelope)
def to_dict(self) -> dict:
return {"status": self.status, "msg": self.msg,
"prize": self.prize.to_dict() if self.prize else None,
"detail_status": self.detailStatus}
class _ActPrizeDetailEnvelope(TafStruct):
def __init__(self):
self.prize: ActPrizeDetailItem | None = None
self.detailStatus = 0
def read_from(self, ins: TafInputStream):
self.prize = ins.read_struct(0, ActPrizeDetailItem)
self.detailStatus = ins.read_int32(1, default=self.detailStatus)
while True:
pos = ins.buf.tell()
_tag, dtype = ins.read_head()
if dtype == TafType.STRUCT_END:
ins.buf.seek(pos)
return
ins.skip_field(dtype)
def write_to(self, os: TafOutputStream):
if self.prize:
os.write_struct(0, self.prize)
os.write_int32(1, self.detailStatus)
class ExchangeInfo(TafStruct):
"""积分兑换结果信息。"""
@@ -384,6 +636,94 @@ class GetActTaskDetailReq(TafStruct):
self.actId = ins.read_int32(1, default=self.actId)
class GetActUserTaskDetailReq(TafStruct):
"""webActUI.getActUserTaskDetail 请求。"""
def __init__(self):
self.userId = ActivityUserId()
self.actId = 0
def write_to(self, os: TafOutputStream):
os.write_struct(0, self.userId)
os.write_int64(1, self.actId)
def read_from(self, ins: TafInputStream):
self.userId = ins.read_struct(0, ActivityUserId) or self.userId
self.actId = ins.read_int64(1, default=self.actId)
class UserActTaskItem(TafStruct):
def __init__(self):
self.actId = 0
self.taskId = 0
self.taskStatus = 0
self.prizeStatus = 0
self.taskValue = ""
self.taskCount = 0
self.prizeCount = 0
self.subTaskDetail = 0
self.extraStatus = 0
def read_from(self, ins: TafInputStream):
for tag, attr, reader in (
(0, "actId", ins.read_int64), (1, "taskId", ins.read_int64),
(2, "taskStatus", ins.read_int32), (3, "prizeStatus", ins.read_int32),
(4, "taskValue", ins.read_string), (5, "taskCount", ins.read_int64),
(6, "prizeCount", ins.read_int64), (8, "subTaskDetail", ins.read_int64),
(9, "extraStatus", ins.read_int64),
):
setattr(self, attr, reader(tag, default=getattr(self, attr)))
def write_to(self, os: TafOutputStream):
os.write_int64(0, self.actId)
os.write_int64(1, self.taskId)
os.write_int32(2, self.taskStatus)
os.write_int32(3, self.prizeStatus)
os.write_string(4, self.taskValue)
os.write_int64(5, self.taskCount)
os.write_int64(6, self.prizeCount)
os.write_int64(8, self.subTaskDetail)
os.write_int64(9, self.extraStatus)
@staticmethod
def read_list_item(ins: TafInputStream, _tag: int):
item = UserActTaskItem()
item.read_from(ins)
_end, dtype = ins.read_head()
if dtype != TafType.STRUCT_END:
raise ValueError(f"期望用户任务 STRUCT_END,实际 0x{dtype:02x}")
return item
def to_dict(self) -> dict:
return {"act_id": self.actId, "task_id": self.taskId,
"task_status": self.taskStatus, "prize_status": self.prizeStatus,
"task_value": self.taskValue, "task_count": self.taskCount,
"prize_count": self.prizeCount, "sub_task_detail": self.subTaskDetail,
"extra_status": self.extraStatus}
class GetActUserTaskDetailResp(TafStruct):
def __init__(self):
self.status = 0
self.msg = ""
self.tasks: list[UserActTaskItem] = []
def read_from(self, ins: TafInputStream):
self.status = ins.read_int32(0, default=self.status)
self.msg = ins.read_string(1, default=self.msg)
self.tasks = ins.read_list(2, item_reader=UserActTaskItem.read_list_item)
def write_to(self, os: TafOutputStream):
os.write_int32(0, self.status)
os.write_string(1, self.msg)
os.write_list(2, self.tasks)
def to_dict(self) -> dict:
return {"status": self.status, "msg": self.msg,
"task_count": len(self.tasks),
"tasks": [item.to_dict() for item in self.tasks]}
class ActTaskPrizeInfo(TafStruct):
"""活动任务奖励项,刷新充值商品时只取展示需要字段。"""
+88 -4
View File
@@ -28,6 +28,7 @@ PC_UA = (
"Chrome/149.0.0.0 Safari/537.36"
)
HTTP_HUYA_UA = "webh5&0.0.1&websocket&&diypc_52775"
SHOP_BIZ_UA = "web&1.0.0&huya"
class WSConnectParaInfo(TafStruct):
@@ -200,6 +201,22 @@ class HuyaHttpClient:
user.sQIMEI = ""
return user
@staticmethod
def _build_shop_user(uid: int, cookie: str):
"""构造商城业务 UserId;与 9.1 商城 WSS 帧保持一致。"""
from .shop_structs import UserId
user = UserId()
user.lUid = int(uid or 0)
user.sGuid = ""
user.sToken = ""
user.sHuYaUA = SHOP_BIZ_UA
user.sCookie = HuyaHttpClient._normalize_cookie(cookie)
user.iTokenType = 0
user.sDeviceInfo = ""
user.sQIMEI = ""
return user
@staticmethod
def _build_shop_app(source_id: str = "yellowcarlist", scene: int = 7):
"""构造 HTTP 业务 ShopAppInfo。"""
@@ -331,6 +348,32 @@ class HuyaHttpClient:
# ---------- 业务便捷方法 ----------
def get_act_info(
self, act_id: int, act_uuid: str = "", app_source: str = "", timeout: float = 15.0
):
"""读取活动主信息,用于驱动当前活动配置。"""
from .activity_structs import GetActInfoReq, GetActInfoResp
req = GetActInfoReq()
req.actId = int(act_id or 0)
req.actUuid = act_uuid
req.appSource = app_source
return self.call_rpc("webActUI", "getActInfo", req, GetActInfoResp, timeout=timeout)
def get_act_user_task_detail(
self, uid: int, cookie: str, act_id: int, timeout: float = 15.0
):
"""读取当前用户任务状态,而不是只读取任务定义。"""
from .activity_structs import GetActUserTaskDetailReq, GetActUserTaskDetailResp
req = GetActUserTaskDetailReq()
req.userId = self._build_activity_user(uid, cookie)
req.actId = int(act_id or 0)
return self.call_rpc(
"webActUI", "getActUserTaskDetail", req, GetActUserTaskDetailResp,
uid=uid, cookie=cookie, timeout=timeout,
)
def query_user_score(self, uid: int, cookie: str, sid: int, timeout: float = 15.0):
"""查询活动可用积分。"""
from .activity_structs import GetUserScoreReq, GetUserScoreResp
@@ -406,6 +449,26 @@ class HuyaHttpClient:
timeout=timeout,
)
def get_act_prize_detail(
self, uid: int, cookie: str, sid: int, pid: int, timeout: float = 15.0
):
"""查询单个兑换奖品详情和服务端限制。"""
from .activity_structs import GetActPrizeDetailReq, GetActPrizeDetailResp
req = GetActPrizeDetailReq()
req.userId = self._build_activity_user(uid, cookie)
req.sid = int(sid or 0)
req.pid = int(pid or 0)
return self.call_rpc(
"webActUI",
"getActPrizeDetail",
req,
GetActPrizeDetailResp,
uid=uid,
cookie=cookie,
timeout=timeout,
)
def get_act_task_detail(
self, uid: int, cookie: str, act_id: int, timeout: float = 15.0
):
@@ -778,7 +841,7 @@ class HuyaHttpClient:
from .shop_structs import GetGoodsInfoReqV5, GoodsInfoRsp
req = GetGoodsInfoReqV5()
req.userId = self._build_user(uid, guid, cookie)
req.userId = self._build_shop_user(uid, cookie)
req.shopAppInfo = self._build_shop_app(source_id, scene)
req.pid = pid
req.spuId = spu_id
@@ -803,7 +866,7 @@ class HuyaHttpClient:
from .shop_structs import QueryUserOrderListReq, QueryUserOrderListRsp
req = QueryUserOrderListReq()
req.userId = self._build_user(uid, guid, cookie)
req.userId = self._build_shop_user(uid, cookie)
req.offset = int(offset or 0)
req.orderType = int(order_type or 1)
req.pageSize = int(page_size or 10)
@@ -833,6 +896,27 @@ class HuyaHttpClient:
)
return fallback if fallback is not None else result
def order_detail(
self, uid: int, guid: str, cookie: str, order_id: int, timeout: float = 15.0
):
"""按订单号查询商城订单详情(最新商城支付链路)。"""
from .shop_structs import OrderDetailReq, OrderDetailRsp
req = OrderDetailReq()
req.userId = self._build_shop_user(uid, cookie)
req.shopAppInfo = self._build_shop_app("yellowcarlist", 4)
req.orderId = int(order_id or 0)
return self.call_rpc(
"shopMiddleUI",
"orderDetailV5",
req,
OrderDetailRsp,
uid,
guid,
cookie,
timeout=timeout,
)
def create_order(
self,
uid,
@@ -862,7 +946,7 @@ class HuyaHttpClient:
last_result = None
for ot in types_to_try:
req = CreateOrderReqV5()
req.userId = self._build_user(uid, guid, cookie)
req.userId = self._build_shop_user(uid, cookie)
req.shopAppInfo = self._build_shop_app(source_id, scene)
req.receiveId = 0
req.pid = pid
@@ -926,7 +1010,7 @@ class HuyaHttpClient:
os = TafOutputStream()
os.write_struct_begin(0)
os.write_struct(0, self._build_user(uid, guid, cookie))
os.write_struct(0, self._build_shop_user(uid, cookie))
os.write_struct(1, self._build_shop_app(source_id, scene))
os.write_int64(2, order_id)
os.write_string(3, self._normalize_pay_channel(pay_type))
+124
View File
@@ -639,6 +639,130 @@ class QueryUserOrderListRsp(TafStruct):
}
class OrderDetailOrder(TafStruct):
"""orderDetailV5 中的订单摘要;保留支付轮询所需字段。"""
def __init__(self):
self.orderId: int = 0
self.orderStatus: int = 0
self.createTime: int = 0
self.payTime: int = 0
self.totalPrice: int = 0
def read_from(self, ins: TafInputStream):
while True:
pos = ins.buf.tell()
tag, dtype = ins.read_head()
if dtype == TafType.STRUCT_END:
ins.buf.seek(pos)
return
if tag == 0:
ins.buf.seek(pos)
self.orderId = ins.read_int64(tag, default=self.orderId)
elif tag == 3:
ins.buf.seek(pos)
self.orderStatus = ins.read_int32(tag, default=self.orderStatus)
elif tag == 7:
ins.buf.seek(pos)
self.totalPrice = ins.read_int64(tag, default=self.totalPrice)
elif tag == 13:
ins.buf.seek(pos)
self.createTime = ins.read_int64(tag, default=self.createTime)
elif tag == 14:
ins.buf.seek(pos)
self.payTime = ins.read_int64(tag, default=self.payTime)
else:
ins.skip_field(dtype)
def write_to(self, os: TafOutputStream):
os.write_int64(0, self.orderId)
os.write_int32(3, self.orderStatus)
os.write_int64(7, self.totalPrice)
os.write_int64(13, self.createTime)
os.write_int64(14, self.payTime)
def to_dict(self) -> dict:
return {
"order_id": self.orderId,
"order_status": self.orderStatus,
"create_time": self.createTime,
"pay_time": self.payTime,
"total_price": self.totalPrice,
}
class OrderDetailReq(TafStruct):
"""shopMiddleUI.orderDetailV5 请求。"""
def __init__(self):
self.userId = UserId()
self.shopAppInfo = ShopAppInfo()
self.orderId: int = 0
def write_to(self, os: TafOutputStream):
os.write_struct(0, self.userId)
os.write_struct(1, self.shopAppInfo)
os.write_int64(2, self.orderId)
def read_from(self, ins: TafInputStream):
self.userId = ins.read_struct(0, UserId) or self.userId
self.shopAppInfo = ins.read_struct(1, ShopAppInfo) or self.shopAppInfo
self.orderId = ins.read_int64(2, default=self.orderId)
class OrderDetailData(TafStruct):
def __init__(self):
self.order: OrderDetailOrder | None = None
def read_from(self, ins: TafInputStream):
while True:
pos = ins.buf.tell()
tag, dtype = ins.read_head()
if dtype == TafType.STRUCT_END:
ins.buf.seek(pos)
return
if tag == 1:
ins.buf.seek(pos)
self.order = ins.read_struct(1, OrderDetailOrder)
else:
ins.skip_field(dtype)
def write_to(self, os: TafOutputStream):
if self.order:
os.write_struct(1, self.order)
class OrderDetailRsp(TafStruct):
"""shopMiddleUI.orderDetailV5 响应。"""
def __init__(self):
self.code = 0
self.message = ""
self.data: OrderDetailData | None = None
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.data = ins.read_struct(2, OrderDetailData)
@property
def order(self) -> OrderDetailOrder | None:
return self.data.order if self.data else None
def write_to(self, os: TafOutputStream):
os.write_int32(0, self.code)
os.write_string(1, self.message)
if self.data:
os.write_struct(2, self.data)
def to_dict(self) -> dict:
return {
"code": self.code,
"message": self.message,
"order": self.order.to_dict() if self.order else None,
}
# ============================================================
# 下单
# ============================================================
+20
View File
@@ -683,6 +683,26 @@ class HuyaWssClient:
timeout=15.0,
)
async def order_detail(
self,
uid: int,
guid: str,
cookie: str,
order_id: int,
source_id: str = "yellowcarlist",
scene: int = WSS_SHOP_SCENE,
):
"""按订单号查询商城订单详情,匹配 9.1 支付后轮询。"""
from .shop_structs import OrderDetailReq, OrderDetailRsp
req = OrderDetailReq()
req.userId = self._build_biz_user(uid, cookie)
req.shopAppInfo = self._build_shop_app(source_id, scene)
req.orderId = int(order_id or 0)
return await self.call_rpc(
"shopMiddleUI", "orderDetailV5", req, OrderDetailRsp, timeout=15.0
)
async def create_order(
self,
uid: int,
@@ -0,0 +1,123 @@
# 虎牙精英宝典后端与 9.1 全流程抓包对照
## 结论
当前后端不是“完全不能用”,而是保留了旧版 HTTP/WUP 兜底链路;最新浏览器链路已经切换为两个长连接 WSS:活动/绑定通道和商城/支付通道。查询和下单的多数字段与抓包兼容,真正需要优先更新的是兑换闭环、支付后订单状态和权益回查。
## 对照矩阵
| 优先级 | 领域 | 9.1 实际链路 | 当前后端 | 判断 | 更新动作 |
|---|---|---|---|---|---|
| P0 | 支付监听 | `orderDetailV5(orderId)` 重复查询,状态最终为 50 | `queryUserOrderList`,同时尝试 `shopMiddleUI/revenueWebUI` | 不一致 | 增加 `orderDetailV5` 结构和方法,按订单号轮询;以订单状态 50/已支付时间作为完成依据 |
| P0 | 支付超时 | 未支付仍是待支付/取消状态 | `huya_runner_recharge.py:535-537` 将 timeout 标为 `success` | 业务错误 | 超时改为 `pending``timeout`,不能写成功;前端显示“等待支付超时” |
| P0 | WSS 会话 | 活动通道 `d35bf373-ws.va.huya.com`,商城通道 `wsapi.huya.com`;每个会话有 launch、register、confirm、递增 requestId | `HuyaBatchRunner` 全部调用 `HuyaHttpClient``cdnws.api.huya.com` 单次 HTTP | 架构不一致 | 优先实现可复用的 WSS sessionHTTP 保留为显式 fallback,并记录 fallback 原因和重试次数 |
| P1 | 商城身份字段 | 最新商城业务 `UserId.sHuYaUA=web&1.0.0&huya``sGuid` 为空 | HTTP `_build_user` 使用 `webh5&0.0.1&websocket...`,并从 Cookie 派生 `sGuid` | 字段不一致 | 商城 WSS 优先;HTTP fallback 也应拆分商城 UserId,按最新 UA/空 guid 构造 |
| P1 | 兑换前置 | `getActPrizeDetail(sid=2203,pid)`,随后才 `scoreExchangePrize` | `huya_runner_goods.py:327-329` 直接提交兑换 | 缺步骤 | 增加 `GetActPrizeDetailReq/Resp`;提交前校验 `isCanExchange`、活动时间、库存、今日/用户限制和积分余额 |
| P1 | 兑换回查 | 成功后依次回查 `getUserScore``getUserPrizeRecords``getEntityPrizeFieldMap``getModuleAddress` | 成功后立即结束任务 | 缺步骤 | 将扣分、记录和地址状态写入任务结果,并更新 `HuyaAccount.points` |
| P1 | 活动初始化 | `getActInfo``getActUserTaskDetail``getUserScore``getUserPrizeRecords` 等并行查询 | `query_act_tasks` 只调用 `getActTaskDetail` | 信息不完整 | 增加 snapshot 任务或让页面初始化批量创建这些查询任务 |
| P1 | 权益到账 | 支付完成后仍需回查宝典积分/用户任务 | 只回查商城订单列表 | 信息不完整 | 订单完成后调用积分和用户任务接口,明确“已支付”和“宝典权益到账”两个状态 |
| P2 | 商品详情 | `getGoodsInfoV5` 直接返回 `spu=hy-5879340``sku=5370360`、场景 4 | 当前会先从任务详情发现 SPU,再以 sku=0 查询详情 | 兼容但脆弱 | 固定业务默认值仅作候选;优先使用实时商品详情返回的 SKU,不依赖任务类型 67 |
| P2 | 商品详情 gameId | 最新 `getGoodsInfoV5` 请求的 `gameId` 为空字符串 | `huya_runner_recharge.py:385-395` 传入 `game_id="0"` | 细节不一致 | 查询商品详情改为空字符串;`createOrderV5``gameId="0"` 仍保持不变 |
| P2 | 下单字段 | `orderType=6``src=4``orderScene=4``bizType=5``gameCategoryId=507``gameId="0"` | `create_order` 已按相同字段写入 | 基本一致 | 保留,增加请求快照脱敏日志以便字段回归 |
| P2 | 支付提交 | `payOrderSubmitV5(order,Zfb,QrCode,callback,env)` 返回支付宝网关 URL | 当前 HTTP 手工构造 WUP,字段基本一致 | 兼容但非同链路 | 迁移到商城 WSS;未迁移前保留 HTTP fallback |
| P2 | 支付渠道 | 先 `listPayChannelV5`,返回 `Zfb/Weixin` | 直接使用配置渠道 | 缺运行时校验 | 下单前读取可用渠道,不支持配置值时回退或报错 |
| P2 | 配置 | 本次活动 `sid=2203`、绑定活动 `9271`、外部活动响应为 `17096` | `outer_act_id` 默认 `9504` 且执行器未实际使用 | 配置过期/死字段 | 默认值改为当前活动值或删除该字段;将外部活动 ID 从 `getLiveLinkParam` 响应传递,不手填签名 |
| P2 | 数据隔离 | 业务响应随账号、绑定状态和库存变化 | `HuyaGoodsSnapshot``HuyaRechargeGoodsSnapshot` 为全局快照 | 并发风险 | 至少按 `sid/spu` 隔离;兑换可用性必须以执行账号的实时查询为准 |
## 已对上的部分
- `ActivityUserId``lUid``sHuYaUA`、Cookie 前缀和空 Token 形态与 9.1 活动帧兼容。
- `scoreExchangePrize` 的主体字段 `userId/sid/pid/ip/clientEnv/source/isRole` 已有结构,样本中后三项为空、`isRole=1` 与默认值一致。
- 商城 `CreateOrderReqV5` 已覆盖抓包中的完整 tag 0-32;`orderType=6` 和场景 4 正确。
- `payOrderSubmitV5` 的回调 URL、`Zfb``QrCode` 和环境 map 已覆盖抓包字段。
- `CreateOrderReqV5` 的下单 `gameId="0"` 与抓包一致;只有商品详情查询的 `gameId` 应为空字符串。
- Cookie、baseinfo、WUP requestId、支付宝订单和签名没有被当作固定常量,方向正确。
## 推荐更新顺序(按依赖关系)
之前的顺序有两个问题:把支付状态修复放在协议补齐之前,且把 WSS 迁移放在写操作之后。这样会先扩大旧链路的行为面,无法判断是业务字段还是传输方式导致失败。建议按下面的依赖顺序推进,每一步都能独立验证和回滚。
### 0. 固化 9.1 契约和回归样本
整理脱敏的请求/响应 fixture,明确活动通道、商城通道、WUP tag、状态码和动态字段边界。先不改线上任务行为,只增加解码测试。验收条件:9.1 中的关键 RPC 能稳定解码,动态 Cookie、签名、订单号不会进入 fixture。
### 1. 先修配置和身份模型
`sid=2203``bindActId=9271``outerActId=17096``moduleId=20051``scene=4``sourceId=yellowcarlist` 统一为活动配置;商城 UserId 与活动 UserId 分开,商城使用最新 `sHuYaUA`/空 `sGuid` 规则。此步只影响参数构造,不启用新写操作。
### 2. 补齐只读协议方法和响应结构
优先实现并测试:
```text
getActInfo
getActUserTaskDetail
getActPrizeDetail
getEntityPrizeFieldMap
getModuleAddress
listPayChannelV5
orderDetailV5
```
只读方法齐全后,才能用服务端实时数据做兑换和支付前置判断;不要先改 `scoreExchangePrize` 或支付提交。
### 3. 建立统一传输层,再做只读灰度
抽象活动 WSS session 和商城 WSS session,复用 launch/register/confirm、requestId FIFO、断线和超时处理。先让查询接口走 WSS,失败时明确记录原因并 fallback 到 HTTP;连续验证成功后再切换写接口。不要在每个 runner 中分别临时建 WebSocket。
### 4. 先做快照和数据隔离
页面初始化改为一次读取活动信息、用户任务、积分、奖品和记录;商品/充值商品快照至少按 `sid + spu/pid` 隔离,执行账号始终以实时详情覆盖快照。此步完成后 UI 可以展示真实状态,但仍不触发兑换或支付。
### 5. 重写兑换状态机
严格按以下顺序:
```text
实时 getActPrizeDetail
-> 校验 isCanExchange/活动时间/库存/今日限制/用户限制/可用积分
-> scoreExchangePrize
-> getUserScore + getUserPrizeRecords
-> getEntityPrizeFieldMap + getModuleAddress
```
成功、已兑换、余额不足、库存不足、需要地址和未知错误分别落库;收到成功响应后使用幂等键阻止重复提交。只有四项回查完成,任务才标记为兑换完成。
### 6. 重写开通和支付状态机
严格按以下顺序:
```text
getGoodsInfoV5
-> listPayChannelV5
-> checkHyProtocolV5 / checkUserBuyAuth
-> createOrderV5(orderType=6)
-> payOrderSubmitV5(Zfb|Weixin, QrCode)
-> 立即 orderDetailV5 轮询
-> 支付完成后回查宝典积分和用户任务
```
`orderDetailV5` 必须先于 runner 改造完成;`queryUserOrderList` 只能作为兼容 fallback。支付状态拆成 `created/pending/paid/timeout/cancelled/error`,超时绝不能标记 `success`。二维码生成后任务保持 `running/pending`,不能因为 runner 线程尚未结束而隐藏二维码。
### 7. 最后接入 UI 和批量执行
UI 只调用统一任务 API:查询使用快照任务,兑换和开通使用状态机任务;通过任务 WebSocket 推送二维码、订单状态和最终权益状态。先单账号灰度,再开启并发批量,避免旧的全局商品快照污染多个账号。
### 8. 清理旧链路
连续灰度验证通过后,删除 `create_order` 的 orderType 猜测和支付列表轮询主路径;保留 HTTP fallback、失败原因、超时和回滚开关。最后再迁移旧版虎牙任务入口,避免影响非精英宝典功能。
## 不应照搬抓包的字段
UID、Cookie、guid、baseinfo、WUP requestId、支付宝 payOrderId、ctoken、二维码 URL、`t/code/sig`、用户点击时间和 RSA2 签名均为运行时动态值。它们只能来自当前登录态、前序响应或运行时生成,不能写入默认配置或测试 fixture。
## 本轮已实施
- 增加 `getActInfo``getActUserTaskDetail``getActPrizeDetail` 和详情响应结构。
- 兑换提交前读取实时详情和积分,校验可兑换状态、库存、时间窗口和余额;成功后回查积分和兑换记录。
- 增加 `orderDetailV5` 请求/响应结构,支付监听优先按订单号查询,旧订单列表作为 fallback。
- 支付超时改为终态 `timeout`,不再误报成功;支付完成后回查活动积分。
- 商城 HTTP fallback 的 `UserId` 改为 9.1 商城 UA/空 guid 形态;商品详情 `gameId` 改为空字符串,下单仍使用 `gameId="0"`
- 外部活动默认值由旧值更新为 `17096`,已有旧配置会在读取时迁移。
仍待下一轮实施:将批处理执行器从 HTTP fallback 迁移到统一活动/商城 WSS session;补齐 `listPayChannelV5``checkHyProtocolV5``getMyPromotion``calcOrderPromotion``checkUserBuyAuth` 的显式前置调用;按账号隔离商品快照。
+68
View File
@@ -0,0 +1,68 @@
from core.huya.activity_structs import ActPrizeDetailItem, GetActPrizeDetailResp
from core.huya.http_client import SHOP_BIZ_UA, HuyaHttpClient
from core.huya.shop_structs import OrderDetailData, OrderDetailOrder, OrderDetailRsp
from core.huya.taf_protocol import TafInputStream, TafOutputStream, TafType
def _round_trip(value):
output = TafOutputStream()
output.write_struct_begin(0)
value.write_to(output)
output.write_struct_end()
stream = TafInputStream(output.get_bytes())
_tag, _dtype = stream.read_head()
value_type = type(value)
decoded = value_type()
decoded.read_from(stream)
_end_tag, end_type = stream.read_head()
assert end_type == TafType.STRUCT_END
return decoded
def test_shop_user_matches_latest_wss_identity():
user = HuyaHttpClient._build_shop_user(123, "yyuid=123; udb_passport=test")
assert user.sHuYaUA == SHOP_BIZ_UA
assert user.sGuid == ""
assert user.lUid == 123
def test_act_prize_detail_round_trip_preserves_exchange_guards():
prize = ActPrizeDetailItem()
prize.prizeId = 12861
prize.name = "幸运币礼包(小)"
prize.score = 20
prize.newScore = 20
prize.leftNum = 15000
prize.isShowNum = 1
prize.isCanExchange = 1
prize.exchangeStartTime = 1782874800
prize.exchangeEndTime = 1790783400
response = GetActPrizeDetailResp()
response.status = 200
response.msg = "请求成功"
response.prize = prize
decoded = _round_trip(response)
assert decoded.status == 200
assert decoded.prize is not None
assert decoded.prize.prizeId == 12861
assert decoded.prize.newScore == 20
assert decoded.prize.isCanExchange == 1
def test_order_detail_round_trip_exposes_payment_status():
order = OrderDetailOrder()
order.orderId = 10938643
order.orderStatus = 50
order.createTime = 1788226648183
order.payTime = 1788226656000
order.totalPrice = 3000
response = OrderDetailRsp()
response.code = 200
response.data = OrderDetailData()
response.data.order = order
decoded = _round_trip(response)
assert decoded.code == 200
assert decoded.order is not None
assert decoded.order.orderId == 10938643
assert decoded.order.orderStatus == 50
assert decoded.order.payTime > 0
+1 -1
View File
@@ -2,7 +2,7 @@
HUYA_DEFAULT_ROOM_PID = "1199650619883"
HUYA_DEFAULT_SID = "2203"
HUYA_DEFAULT_OUTER_ACT_ID = "9504"
HUYA_DEFAULT_OUTER_ACT_ID = "17096"
HUYA_DEFAULT_BIND_ACT_ID = "9271"
HUYA_DEFAULT_PAY_CHANNEL = "Zfb"
+30
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
import copy
import threading
import time
from datetime import UTC, datetime
@@ -130,6 +131,7 @@ class HuyaBatchRunnerCore:
task.result = result
task.finished_at = datetime.now(UTC)
worker_db.commit()
self._push_task_event(task)
def _update_task_progress(
self,
@@ -144,6 +146,34 @@ class HuyaBatchRunnerCore:
if result is not None:
task.result = result
worker_db.commit()
self._push_task_event(task)
def _push_task_event(self, task: HuyaTask) -> None:
"""向批次 WS 推送任务状态;二维码图片留给详情接口按需读取。"""
if not self.log_queue or not self.loop:
return
result = copy.deepcopy(task.result) if isinstance(task.result, dict) else None
if result and isinstance(result.get("mini_qrcode_image"), str):
result.pop("mini_qrcode_image", None)
result["has_mini_qrcode"] = True
event = {
"level": "task",
"message": "",
"task": {
"id": task.id,
"batch_id": task.batch_id,
"account_id": task.account_id,
"task_type": task.task_type,
"handbook_scope": getattr(task, "handbook_scope", "legacy") or "legacy",
"status": task.status or "",
"message": task.message or "",
"result": result,
"created_by": task.created_by,
"created_at": task.created_at.isoformat() if task.created_at else None,
"finished_at": task.finished_at.isoformat() if task.finished_at else None,
},
}
asyncio.run_coroutine_threadsafe(self.log_queue.put(event), self.loop)
class HuyaBatchRegistry:
+52
View File
@@ -58,6 +58,12 @@ class GoodsMixin:
return
result = response.to_dict()
result["act_id"] = act_id
act_info = client.get_act_info(act_id=act_id)
user_tasks = client.get_act_user_task_detail(uid=uid, cookie=cookie, act_id=act_id)
if act_info is not None:
result["act_info"] = act_info.to_dict()
if user_tasks is not None:
result["user_task_detail"] = user_tasks.to_dict()
if response.status != 200:
self._mark_task(
worker_db,
@@ -324,6 +330,44 @@ class GoodsMixin:
client: Any = HuyaHttpClient(
logger=lambda msg: self._push_log("info", f"[{uid}] {msg}")
)
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", "虎牙兑换详情接口无响应")
return
detail_result = detail.to_dict()
prize = detail.prize
if detail.status != 200 or prize is None:
self._mark_task(
worker_db,
task,
"failed",
detail.msg or f"虎牙兑换详情获取失败: {detail.status}",
detail_result,
)
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
)
@@ -340,6 +384,7 @@ class GoodsMixin:
"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,
}
)
if response.status != 200:
@@ -352,6 +397,13 @@ class GoodsMixin:
)
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)
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}"
+57 -36
View File
@@ -86,52 +86,66 @@ class RechargeMixin:
guid: str,
cookie: str,
order_id: int,
sid: int,
result: dict,
) -> tuple[str, dict | None]:
deadline = time.time() + HUYA_PAYMENT_POLL_SECONDS
order_id_text = str(order_id)
last_order = None
while not self._stop.is_set() and time.time() < deadline:
resp = client.query_user_order_list(
uid=uid,
guid=guid,
cookie=cookie,
offset=0,
page_size=10,
order_type=1,
status=0,
timeout=10.0,
detail = client.order_detail(
uid=uid, guid=guid, cookie=cookie, order_id=order_id, timeout=10.0
)
checked_at = datetime.now(UTC).isoformat()
if resp is not None and getattr(resp, "orders", None):
for order in resp.orders:
if str(getattr(order, "orderId", "")) != order_id_text:
continue
last_order = order.to_dict()
status = int(getattr(order, "orderStatus", 0) or 0)
order = getattr(detail, "order", None) if detail is not None else None
if order is not None and (
not getattr(order, "orderId", 0)
or str(getattr(order, "orderId", "")) == order_id_text
):
last_order = order.to_dict()
status = int(getattr(order, "orderStatus", 0) or 0)
result.update(
{
"payment_checked_at": checked_at,
"payment_order": last_order,
"payment_order_status": status,
"payment_order_status_label": self._huya_order_status_label(status),
}
)
if self._is_huya_order_paid(order):
result.update(
{
"payment_checked_at": checked_at,
"payment_order": last_order,
"payment_order_status": status,
"payment_order_status_label": self._huya_order_status_label(
status
),
"payment_status": "paid",
"payment_status_label": "已支付",
"payment_paid": True,
"payment_paid_at": checked_at,
}
)
if self._is_huya_order_paid(order):
result.update(
{
"payment_status": "paid",
"payment_status_label": "已支付",
"payment_paid": True,
"payment_paid_at": checked_at,
}
)
return "paid", last_order
break
return "paid", last_order
else:
result["payment_checked_at"] = checked_at
# 兼容旧 HTTP 网关不提供 orderDetailV5 的情况。
resp = client.query_user_order_list(
uid=uid, guid=guid, cookie=cookie, offset=0, page_size=10,
order_type=1, status=0, timeout=10.0,
)
for legacy_order in getattr(resp, "orders", []) if resp else []:
if str(getattr(legacy_order, "orderId", "")) != order_id_text:
continue
last_order = legacy_order.to_dict()
status = int(getattr(legacy_order, "orderStatus", 0) or 0)
result.update({
"payment_checked_at": checked_at,
"payment_order": last_order,
"payment_order_status": status,
"payment_order_status_label": self._huya_order_status_label(status),
})
if self._is_huya_order_paid(legacy_order):
result.update({
"payment_status": "paid", "payment_status_label": "已支付",
"payment_paid": True, "payment_paid_at": checked_at,
})
return "paid", last_order
result.setdefault("payment_checked_at", checked_at)
if self._stop.wait(HUYA_PAYMENT_POLL_INTERVAL):
break
@@ -260,7 +274,7 @@ class RechargeMixin:
pid=pid,
spu_id=spu_id,
sku_id=0,
game_id="0",
game_id="",
source_id=HUYA_RECHARGE_SOURCE_ID,
scene=HUYA_RECHARGE_SCENE,
)
@@ -389,7 +403,7 @@ class RechargeMixin:
pid=pid,
spu_id=spu_id,
sku_id=sku_id or 0,
game_id="0",
game_id="",
source_id=HUYA_RECHARGE_SOURCE_ID,
scene=HUYA_RECHARGE_SCENE,
)
@@ -515,11 +529,18 @@ class RechargeMixin:
guid="",
cookie=cookie,
order_id=order_resp.orderId,
sid=self._to_int(config_info.get("sid")) or 2203,
result=result,
)
account.updated_at = datetime.now(UTC)
if payment_status == "paid":
account.status = "recharge_paid"
post_score = client.query_user_score(
uid=uid, cookie=cookie, sid=self._to_int(config_info.get("sid")) or 2203
)
if post_score is not None:
result["post_payment_score"] = post_score.to_dict()
account.points = post_score.available_score
paid_message = f"支付成功: {product_name} x{count} {result['amount_text']}"
if payment_order and payment_order.get("pay_time"):
paid_message += f",支付时间 {self._format_local_time(int(payment_order['pay_time']) // 1000)}"
@@ -534,4 +555,4 @@ class RechargeMixin:
account.status = "recharge_order_created"
timeout_message = f"{message}{result['payment_status_label']}"
self._mark_task(worker_db, task, "success", timeout_message, result)
self._mark_task(worker_db, task, "timeout", timeout_message, result)
+7
View File
@@ -57,6 +57,13 @@ def apply_huya_config_defaults(config: HuyaConfig) -> bool:
setattr(config, field, HUYA_CONFIG_DEFAULTS[field])
changed = True
continue
if (
field == "outer_act_id"
and str(getattr(config, field, "") or "").strip() == "9504"
):
setattr(config, field, HUYA_CONFIG_DEFAULTS[field])
changed = True
continue
normalized = huya_config_value(field, getattr(config, field, None))
if getattr(config, field, None) != normalized:
setattr(config, field, normalized)
+27 -10
View File
@@ -7,7 +7,7 @@ import {
CheckCircleOutlined, ColumnWidthOutlined, GiftOutlined, ImportOutlined, QrcodeOutlined,
ReloadOutlined, SearchOutlined, SettingOutlined, ShoppingOutlined, StopOutlined,
} from '@ant-design/icons';
import { huyaApi, type HuyaAccountItem, type HuyaConfig, type HuyaGoodsItem, type HuyaTaskItem } from '../api/modules';
import { huyaApi, type HuyaAccountItem, type HuyaConfig, type HuyaGoodsItem, type HuyaRechargeGoodsItem, type HuyaTaskItem } from '../api/modules';
import RealtimeLogPanel from '../components/RealtimeLogPanel';
import { usePermissions } from '../hooks/usePermissions';
import { useWebSocketLogs } from '../hooks/useWebSocketLogs';
@@ -24,14 +24,15 @@ const TASK_LABELS: Record<string, string> = {
query_act_tasks: '读取宝典任务', get_bind_qr: '获取绑定二维码', query_game_name: '查询游戏角色',
confirm_bind: '确认绑定角色', query_points: '查询积分', refresh_goods: '刷新商品',
exchange_goods: '兑换商品', query_exchange_records: '查询兑换记录', create_recharge_order: '生成开通支付码',
refresh_recharge_goods: '刷新开通商品',
};
const TASK_COLORS: Record<string, string> = {
planned: 'default', pending: 'default', running: 'processing', success: 'success',
failed: 'error', error: 'error', stopped: 'warning',
failed: 'error', error: 'error', stopped: 'warning', timeout: 'warning',
};
const TASK_STATUS_LABELS: Record<string, string> = {
planned: '已计划', pending: '等待中', running: '执行中', success: '成功',
failed: '失败', error: '异常', stopped: '已停止',
failed: '失败', error: '异常', stopped: '已停止', timeout: '支付超时',
};
function savedInterval(): number {
@@ -57,6 +58,8 @@ export default function HuyaElitePage() {
const [tasks, setTasks] = useState<HuyaTaskItem[]>([]);
const [goods, setGoods] = useState<HuyaGoodsItem[]>([]);
const [selectedGoodsId, setSelectedGoodsId] = useState('');
const [rechargeGoods, setRechargeGoods] = useState<HuyaRechargeGoodsItem[]>([]);
const [selectedRechargeSpu, setSelectedRechargeSpu] = useState('hy-5879340');
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(false);
const [starting, setStarting] = useState(false);
@@ -81,9 +84,9 @@ export default function HuyaElitePage() {
const load = useCallback(async () => {
setLoading(true);
try {
const [poolResult, workbenchResult, goodsResult, taskResult] = await Promise.all([
const [poolResult, workbenchResult, goodsResult, rechargeGoodsResult, taskResult] = await Promise.all([
huyaApi.listAccounts({ include_cookie: false }), huyaApi.listWorkbenchAccounts(SCOPE),
huyaApi.listGoods(), huyaApi.listTasks(undefined, SCOPE),
huyaApi.listGoods(), huyaApi.listRechargeGoods(), huyaApi.listTasks(undefined, SCOPE),
]);
setPool(poolResult);
const byId = new Map(poolResult.map((item) => [item.id, item]));
@@ -94,6 +97,7 @@ export default function HuyaElitePage() {
setAccounts(storedIds.map((id) => byId.get(Number(id))).filter((item): item is HuyaAccountItem => Boolean(item)));
setSelectedIds((prev) => prev.filter((id) => storedIds.includes(id)));
setGoods(goodsResult);
setRechargeGoods(rechargeGoodsResult);
setTasks(taskResult);
} catch (error) { message.error(getErrorMessage(error)); } finally { setLoading(false); }
}, []);
@@ -123,17 +127,28 @@ export default function HuyaElitePage() {
const startTask = async (taskType: string) => {
if (!selectedIds.length) { message.warning('请先勾选账号'); return; }
if (taskType === 'exchange_goods' && !selectedGoodsId) { message.warning('请先选择兑换商品'); return; }
if (taskType === 'create_recharge_order' && !selectedRechargeSpu) { message.warning('请先选择宝典商品'); return; }
setStarting(true);
try {
const payload = taskType === 'exchange_goods'
? { sid: Number(config?.sid || DEFAULT_SID), product_id: Number(selectedGoodsId), act_id: ACT_ID }
: taskType === 'query_act_tasks' ? { act_id: ACT_ID } : {};
: taskType === 'query_act_tasks' ? { act_id: ACT_ID }
: taskType === 'create_recharge_order' ? (() => {
const selected = rechargeGoods.find((item) => item.spu_id === selectedRechargeSpu);
return { spu_id: selectedRechargeSpu, sku_id: Number(selected?.sku_id || 0), product_name: selected?.name || '精英宝典' };
})() : {};
const created = await huyaApi.createTasks({ account_ids: selectedIds, task_type: taskType, handbook_scope: SCOPE, concurrency, payload });
setActiveBatches((prev) => [...new Set([...prev, created.batch_id])]);
logs.connectBatch(created.batch_id, `/api/huya/ws/${created.batch_id}`, {
clear: false,
onTask: (raw) => setTasks((prev) => [raw as unknown as HuyaTaskItem, ...prev.filter((item) => item.id !== Number((raw as { id?: number }).id))].slice(0, 300)),
onResult: () => { setActiveBatches((prev) => prev.filter((id) => id !== created.batch_id)); void loadTasks(); },
onTask: (raw) => {
const next = raw as unknown as HuyaTaskItem;
setTasks((prev) => [next, ...prev.filter((item) => item.id !== next.id)].slice(0, 300));
const result = next.result || {};
if (next.task_type === 'create_recharge_order' && typeof result.pay_url === 'string' && result.pay_url) setQrTask(next);
if (next.task_type === 'get_bind_qr' && (result.mini_qrcode_image || result.bind_redirect_url)) setQrTask(next);
},
onResult: () => { setActiveBatches((prev) => prev.filter((id) => id !== created.batch_id)); void load(); },
});
message.success(`已创建 ${created.count} 个任务`);
window.setTimeout(() => { void loadTasks(); }, 400);
@@ -172,6 +187,7 @@ export default function HuyaElitePage() {
return accounts.filter((item) => !value || [item.uid, item.nickname, item.username, item.tag, item.game_name].some((v) => String(v || '').toLowerCase().includes(value)));
}, [accounts, search]);
const filteredGoods = useMemo(() => goods.map((item) => ({ ...item, label: `${item.name} / ${item.price ?? '-'}分 / ${item.remain_text || '动态库存'}` })), [goods]);
const filteredRechargeGoods = useMemo(() => rechargeGoods.map((item) => ({ ...item, label: `${item.name || item.spu_id} / ${item.price ? `${(item.price / 100).toFixed(2)}` : '价格动态'}` })), [rechargeGoods]);
const exchangeCount = tasks.filter((task) => task.task_type === 'exchange_goods').length;
const accountColumns: TableProps<HuyaAccountItem>['columns'] = [
@@ -190,11 +206,12 @@ export default function HuyaElitePage() {
</div></div>
<div className="huya-operation-section"><div className="huya-operation-title"><ShoppingOutlined /><span></span></div><Select size="small" showSearch optionFilterProp="label" value={selectedGoodsId || undefined} onChange={setSelectedGoodsId} options={filteredGoods.map((item) => ({ value: item.product_id, label: item.label }))} placeholder="选择兑换商品" style={{ width: '100%' }} /><div className="huya-action-grid"><Button size="small" icon={<ReloadOutlined />} onClick={() => void startTask('refresh_goods')} disabled={starting || !selectedIds.length}></Button><Button size="small" type="primary" icon={<ShoppingOutlined />} onClick={() => void startTask('exchange_goods')} disabled={starting || !selectedIds.length || !selectedGoodsId}></Button></div></div>
<div className="huya-operation-section"><div className="huya-operation-title"><GiftOutlined /><span></span></div><Button block size="small" icon={<QrcodeOutlined />} onClick={() => void startTask('create_recharge_order')} disabled={starting || !selectedIds.length}></Button></div>
<div className="huya-operation-section"><div className="huya-operation-title"><ShoppingOutlined /><span></span></div><Select size="small" showSearch optionFilterProp="label" value={selectedRechargeSpu || undefined} onChange={setSelectedRechargeSpu} options={filteredRechargeGoods.map((item) => ({ value: item.spu_id, label: item.label }))} placeholder="先刷新开通商品" style={{ width: '100%' }} /><Button block size="small" icon={<ReloadOutlined />} onClick={() => void startTask('refresh_recharge_goods')} disabled={starting || !selectedIds.length} style={{ marginTop: 6 }}></Button></div>
<RealtimeLogPanel logs={logs.logs} connected={logs.connected} title="实时日志" emptyText="暂无任务日志" height={150} collapsible defaultVisible={false} />
</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)} style={{ width: 240 }} /><Space><Button size="small" icon={<ImportOutlined />} onClick={() => setImportOpen(true)}></Button>{selectedIds.length > 0 && <Button size="small" danger onClick={() => void saveAccounts(accounts.map((item) => item.id).filter((id) => !selectedIds.includes(id)))}></Button>}</Space></Space><div className="huya-account-table"><Table rowKey="id" size="small" loading={loading} rowSelection={{ selectedRowKeys: selectedIds, onChange: (keys) => setSelectedIds(keys.map(Number)) }} columns={accountColumns} dataSource={filteredAccounts} pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (total) => `${total}` }} scroll={{ x: 760, y: 'calc(100vh - 300px)' }} onRow={(row) => ({ onClick: () => { const task = latestTask(tasks, row.id); if (task?.task_type === 'get_bind_qr') void openQrTask(task); } })} /></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)} style={{ width: 240 }} /><Space><Button size="small" icon={<ImportOutlined />} onClick={() => setImportOpen(true)}></Button>{selectedIds.length > 0 && <Button size="small" danger onClick={() => void saveAccounts(accounts.map((item) => item.id).filter((id) => !selectedIds.includes(id)))}></Button>}</Space></Space><div className="huya-account-table"><Table rowKey="id" size="small" loading={loading} rowSelection={{ selectedRowKeys: selectedIds, onChange: (keys) => setSelectedIds(keys.map(Number)) }} columns={accountColumns} dataSource={filteredAccounts} pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (total) => `${total}` }} scroll={{ x: 760, y: 'calc(100vh - 300px)' }} onRow={(row) => ({ onClick: () => { const task = latestTask(tasks, row.id); if (task && ['get_bind_qr', 'create_recharge_order'].includes(task.task_type) && task.result) void openQrTask(task); } })} /></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>;
@@ -202,7 +219,7 @@ export default function HuyaElitePage() {
<Space style={{ justifyContent: 'space-between', width: '100%', marginBottom: 8, flexShrink: 0 }}><div><h2 style={{ margin: 0 }}></h2><Text type="secondary"></Text></div><Space><Tooltip title={layoutMode === 'split' ? '切换为上下布局' : '切换为左右布局'}><Button icon={<ColumnWidthOutlined />} onClick={() => { const next = layoutMode === 'split' ? 'stack' : 'split'; setLayoutMode(next); localStorage.setItem(LAYOUT_KEY, next); }} /></Tooltip><Button icon={<ReloadOutlined />} onClick={() => void load()} loading={loading}></Button>{canConfig && <Button icon={<SettingOutlined />} onClick={() => void openSettings()}></Button>}{!!activeBatches.length && <Button danger icon={<StopOutlined />} onClick={() => void stop()}></Button>}</Space></Space>
{layoutMode === 'split' ? <div style={{ flex: 1, minHeight: 0, display: 'flex', gap: 12, overflow: 'hidden' }}><Card size="small" title="账号" extra={<Tag color="blue"> {selectedIds.length}/{accounts.length}</Tag>} style={{ flex: 1, minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }} styles={{ body: { flex: 1, minHeight: 0, padding: '4px 6px', overflow: 'hidden', display: 'flex', flexDirection: 'column' } }}>{accountsBody}</Card><Card size="small" title="精英宝典操作" extra={<Space size={4}><Text type="secondary"></Text><InputNumber size="small" min={1} max={10} value={concurrency} onChange={(value) => setConcurrency(value || 1)} style={{ width: 58 }} /></Space>} style={{ width: 360, flexShrink: 0, minHeight: 0, overflow: 'hidden' }} styles={{ body: { padding: 8, overflowY: 'auto', height: 'calc(100% - 38px)' } }}>{operationBody}</Card></div> : <><Card size="small" title="精英宝典操作" extra={<Space size={4}><Text type="secondary"></Text><InputNumber size="small" min={1} max={10} value={concurrency} onChange={(value) => setConcurrency(value || 1)} style={{ width: 58 }} /></Space>} style={{ flexShrink: 0, marginBottom: 12 }} styles={{ body: { padding: 8 } }}>{operationBody}</Card><Card size="small" title="账号" extra={<Tag color="blue"> {selectedIds.length}/{accounts.length}</Tag>} style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }} styles={{ body: { flex: 1, minHeight: 0, padding: '4px 6px', overflow: 'hidden', display: 'flex', flexDirection: 'column' } }}>{accountsBody}</Card></>}
<Text type="secondary" style={{ fontSize: 12, marginTop: 6 }}> {exchangeCount} </Text>
<Modal open={qrTask !== null} title="绑定二维码" footer={null} onCancel={() => setQrTask(null)} centered>{qrTask && <div style={{ textAlign: 'center' }}>{resultText(qrTask, 'mini_qrcode_image') ? <img src={`data:image/png;base64,${resultText(qrTask, 'mini_qrcode_image')}`} alt="绑定二维码" style={{ width: 240, height: 240 }} /> : <QRCode value={resultText(qrTask, 'bind_redirect_url') || 'https://zt.huya.com/b02faae1/pc/index.html'} size={240} />}<p><Tag color={TASK_COLORS[qrTask.status] || 'default'}>{qrTask.message || qrTask.status}</Tag></p></div>}</Modal>
<Modal open={qrTask !== null} title={qrTask?.task_type === 'create_recharge_order' ? '开通支付二维码' : '绑定二维码'} footer={null} onCancel={() => setQrTask(null)} centered>{qrTask && <div style={{ textAlign: 'center' }}>{resultText(qrTask, 'mini_qrcode_image') ? <img src={`data:image/png;base64,${resultText(qrTask, 'mini_qrcode_image')}`} alt="绑定二维码" style={{ width: 240, height: 240 }} /> : <QRCode value={resultText(qrTask, 'pay_url') || resultText(qrTask, 'bind_redirect_url') || 'https://zt.huya.com/b02faae1/pc/index.html'} size={240} />}<p><Tag color={TASK_COLORS[qrTask.status] || 'default'}>{qrTask.message || qrTask.status}</Tag></p>{qrTask.task_type === 'create_recharge_order' && <Text type="secondary"></Text>}</div>}</Modal>
<Modal open={importOpen} title="导入精英宝典账号" onCancel={() => setImportOpen(false)} onOk={() => { void saveAccounts([...new Set([...accounts.map((item) => item.id), ...importSelected])]).then(() => { setImportSelected([]); setImportOpen(false); }).catch((error) => message.error(getErrorMessage(error))); }} okText="导入" cancelText="取消"><Input.Search allowClear placeholder="搜索账号" value={importSearch} onChange={(event) => setImportSearch(event.target.value)} style={{ marginBottom: 8 }} /><Select allowClear placeholder="标签" value={importTag || undefined} onChange={(value) => setImportTag(value || '')} options={importTags.map((tag) => ({ value: tag, label: tag }))} style={{ width: '100%', marginBottom: 8 }} /><Table rowKey="id" size="small" dataSource={pool.filter((item) => !accounts.some((current) => current.id === item.id) && (!importSearch || [item.uid, item.nickname, item.username].some((value) => String(value || '').includes(importSearch))) && (!importTag || item.tag === importTag))} columns={[{ title: '账号', render: (_value, row) => row.nickname || row.username || row.uid }, { title: '标签', dataIndex: 'tag' }]} pagination={{ pageSize: 8 }} rowSelection={{ selectedRowKeys: importSelected, onChange: (keys) => setImportSelected(keys.map(Number)) }} /></Modal>
<Modal open={configOpen} title="精英宝典设置" onCancel={() => setConfigOpen(false)} onOk={() => void saveConfig()} confirmLoading={configSaving} okText="保存" cancelText="取消" width={500}>{configLoading ? <div style={{ padding: 24, textAlign: 'center' }}>...</div> : configDraft && <Space direction="vertical" size={10} style={{ width: '100%' }}>{configField('sid', '活动 SID')}{configField('bind_act_id', '绑定活动 ID')}{configField('outer_act_id', '外部活动 ID')}{configField('room_pid', '直播间 PID')}{configField('pay_channel', '支付渠道')}<Space style={{ width: '100%', justifyContent: 'space-between' }}><Text></Text><InputNumber min={5} max={60} value={refreshInterval} onChange={(value) => { const next = Math.min(60, Math.max(5, value || 15)); setRefreshInterval(next); localStorage.setItem('huya_elite_refresh_interval', String(next)); }} style={{ width: 300 }} /></Space></Space>}</Modal>
</div>;