diff --git a/core/huya/activity_structs.py b/core/huya/activity_structs.py index 6d3033a..3a3e15b 100644 --- a/core/huya/activity_structs.py +++ b/core/huya/activity_structs.py @@ -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): """活动任务奖励项,刷新充值商品时只取展示需要字段。""" diff --git a/core/huya/http_client.py b/core/huya/http_client.py index da54621..4168c18 100644 --- a/core/huya/http_client.py +++ b/core/huya/http_client.py @@ -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)) diff --git a/core/huya/shop_structs.py b/core/huya/shop_structs.py index 7ddf8e3..ef3d051 100644 --- a/core/huya/shop_structs.py +++ b/core/huya/shop_structs.py @@ -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, + } + + # ============================================================ # 下单 # ============================================================ diff --git a/core/huya/wss_client.py b/core/huya/wss_client.py index 43f5964..ea67522 100644 --- a/core/huya/wss_client.py +++ b/core/huya/wss_client.py @@ -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, diff --git a/docs/HUYA_精英宝典-后端与9.1抓包对照.md b/docs/HUYA_精英宝典-后端与9.1抓包对照.md new file mode 100644 index 0000000..26fad35 --- /dev/null +++ b/docs/HUYA_精英宝典-后端与9.1抓包对照.md @@ -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 session;HTTP 保留为显式 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` 的显式前置调用;按账号隔离商品快照。 diff --git a/tests/test_huya_elite_protocol.py b/tests/test_huya_elite_protocol.py new file mode 100644 index 0000000..ea1dd93 --- /dev/null +++ b/tests/test_huya_elite_protocol.py @@ -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 diff --git a/web/backend/huya_defaults.py b/web/backend/huya_defaults.py index 3d38d46..25643eb 100644 --- a/web/backend/huya_defaults.py +++ b/web/backend/huya_defaults.py @@ -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" diff --git a/web/backend/services/huya_runner_core.py b/web/backend/services/huya_runner_core.py index 5ed541c..fb5e83d 100644 --- a/web/backend/services/huya_runner_core.py +++ b/web/backend/services/huya_runner_core.py @@ -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: diff --git a/web/backend/services/huya_runner_goods.py b/web/backend/services/huya_runner_goods.py index 334d569..24b336e 100644 --- a/web/backend/services/huya_runner_goods.py +++ b/web/backend/services/huya_runner_goods.py @@ -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}" diff --git a/web/backend/services/huya_runner_recharge.py b/web/backend/services/huya_runner_recharge.py index ebbde25..a55be60 100644 --- a/web/backend/services/huya_runner_recharge.py +++ b/web/backend/services/huya_runner_recharge.py @@ -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) diff --git a/web/backend/services/huya_service.py b/web/backend/services/huya_service.py index 060c1e4..dd939ac 100644 --- a/web/backend/services/huya_service.py +++ b/web/backend/services/huya_service.py @@ -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) diff --git a/web/frontend/src/pages/HuyaElitePage.tsx b/web/frontend/src/pages/HuyaElitePage.tsx index b333f5e..2e2e1ab 100644 --- a/web/frontend/src/pages/HuyaElitePage.tsx +++ b/web/frontend/src/pages/HuyaElitePage.tsx @@ -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 = { 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 = { 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 = { 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([]); const [goods, setGoods] = useState([]); const [selectedGoodsId, setSelectedGoodsId] = useState(''); + const [rechargeGoods, setRechargeGoods] = useState([]); + 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['columns'] = [ @@ -190,11 +206,12 @@ export default function HuyaElitePage() {
兑换
({ value: item.spu_id, label: item.label }))} placeholder="先刷新开通商品" style={{ width: '100%' }} />
); - const accountsBody = <>} placeholder="搜索账号/昵称/游戏名" value={search} onChange={(event) => setSearch(event.target.value)} style={{ width: 240 }} />{selectedIds.length > 0 && }
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); } })} />; + const accountsBody = <>} placeholder="搜索账号/昵称/游戏名" value={search} onChange={(event) => setSearch(event.target.value)} style={{ width: 240 }} />{selectedIds.length > 0 && }
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); } })} />; const configField = (key: Exclude, label: string) => configDraft && {label} setConfigDraft({ ...configDraft, [key]: event.target.value })} style={{ width: 300 }} />; @@ -202,7 +219,7 @@ export default function HuyaElitePage() {

虎牙精英宝典工作台

绑定、开通、积分与兑换任务
{canConfig && }{!!activeBatches.length && }
{layoutMode === 'split' ?
已选 {selectedIds.length}/{accounts.length}} style={{ flex: 1, minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }} styles={{ body: { flex: 1, minHeight: 0, padding: '4px 6px', overflow: 'hidden', display: 'flex', flexDirection: 'column' } }}>{accountsBody}并发 setConcurrency(value || 1)} style={{ width: 58 }} />} style={{ width: 360, flexShrink: 0, minHeight: 0, overflow: 'hidden' }} styles={{ body: { padding: 8, overflowY: 'auto', height: 'calc(100% - 38px)' } }}>{operationBody}
: <>并发 setConcurrency(value || 1)} style={{ width: 58 }} />} style={{ flexShrink: 0, marginBottom: 12 }} styles={{ body: { padding: 8 } }}>{operationBody}已选 {selectedIds.length}/{accounts.length}} style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }} styles={{ body: { flex: 1, minHeight: 0, padding: '4px 6px', overflow: 'hidden', display: 'flex', flexDirection: 'column' } }}>{accountsBody}} 兑换任务 {exchangeCount} 条;勾选账号后可批量执行。 - setQrTask(null)} centered>{qrTask &&
{resultText(qrTask, 'mini_qrcode_image') ? 绑定二维码 : }

{qrTask.message || qrTask.status}

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

{qrTask.message || qrTask.status}

{qrTask.task_type === 'create_recharge_order' && 扫码后订单状态会自动轮询更新}
}
setImportOpen(false)} onOk={() => { void saveAccounts([...new Set([...accounts.map((item) => item.id), ...importSelected])]).then(() => { setImportSelected([]); setImportOpen(false); }).catch((error) => message.error(getErrorMessage(error))); }} okText="导入" cancelText="取消"> setImportSearch(event.target.value)} style={{ marginBottom: 8 }} />
!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)) }} /> setConfigOpen(false)} onOk={() => void saveConfig()} confirmLoading={configSaving} okText="保存" cancelText="取消" width={500}>{configLoading ?
加载中...
: configDraft && {configField('sid', '活动 SID')}{configField('bind_act_id', '绑定活动 ID')}{configField('outer_act_id', '外部活动 ID')}{configField('room_pid', '直播间 PID')}{configField('pay_channel', '支付渠道')}空闲轮询间隔(秒) { const next = Math.min(60, Math.max(5, value || 15)); setRefreshInterval(next); localStorage.setItem('huya_elite_refresh_interval', String(next)); }} style={{ width: 300 }} />}
;