From e1d47a85be08594ec350138fadf151335a81a0e8 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 4 Jul 2026 16:31:12 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=99=8E=E7=89=99=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E5=92=8C=E4=BB=BB=E5=8A=A1=E5=9F=BA=E7=A1=80=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/huya/__init__.py | 9 + core/huya/frame_decoder.py | 309 ++++++++ core/huya/http_client.py | 357 +++++++++ core/huya/shop_structs.py | 583 ++++++++++++++ core/huya/taf_protocol.py | 477 ++++++++++++ core/huya/wss_client.py | 709 ++++++++++++++++++ core/huya/wup_protocol.py | 281 +++++++ deploy.sh | 7 + dev.sh | 88 +++ pyproject.toml | 1 + uv.lock | 2 + web/backend/main.py | 3 +- .../versions/20260704_0004_huya_foundation.py | 127 ++++ web/backend/models.py | 70 ++ web/backend/permissions.py | 11 + web/backend/routers/huya.py | 250 ++++++ web/backend/schemas.py | 146 ++++ web/backend/services/huya_service.py | 161 ++++ web/frontend/src/App.tsx | 4 + web/frontend/src/api/huya.ts | 30 + web/frontend/src/api/modules.ts | 1 + web/frontend/src/api/types.ts | 74 ++ web/frontend/src/layouts/MainLayout.tsx | 12 +- web/frontend/src/pages/HuyaAccountsPage.tsx | 315 ++++++++ web/frontend/src/pages/HuyaTasksPage.tsx | 485 ++++++++++++ web/frontend/vite.config.ts | 4 +- 26 files changed, 4513 insertions(+), 3 deletions(-) create mode 100644 core/huya/__init__.py create mode 100644 core/huya/frame_decoder.py create mode 100644 core/huya/http_client.py create mode 100644 core/huya/shop_structs.py create mode 100644 core/huya/taf_protocol.py create mode 100644 core/huya/wss_client.py create mode 100644 core/huya/wup_protocol.py create mode 100755 dev.sh create mode 100644 web/backend/migrations/versions/20260704_0004_huya_foundation.py create mode 100644 web/backend/routers/huya.py create mode 100644 web/backend/services/huya_service.py create mode 100644 web/frontend/src/api/huya.ts create mode 100644 web/frontend/src/pages/HuyaAccountsPage.tsx create mode 100644 web/frontend/src/pages/HuyaTasksPage.tsx diff --git a/core/huya/__init__.py b/core/huya/__init__.py new file mode 100644 index 0000000..10be1dc --- /dev/null +++ b/core/huya/__init__.py @@ -0,0 +1,9 @@ +"""虎牙协议与业务客户端。""" + +from .http_client import HuyaHttpClient +from .wss_client import HuyaWssClient + +__all__ = [ + "HuyaHttpClient", + "HuyaWssClient", +] diff --git a/core/huya/frame_decoder.py b/core/huya/frame_decoder.py new file mode 100644 index 0000000..52f8b09 --- /dev/null +++ b/core/huya/frame_decoder.py @@ -0,0 +1,309 @@ +""" +TAF/WUP 帧解码器 — 将二进制帧转为可读摘要,用于日志输出 +""" +from .taf_protocol import TafInputStream, TafType +from .wup_protocol import normalize_wup_payload + +# cmd 编号 → 名称 +CMD_NAMES = { + 0x03: "RPC_REQ", 0x04: "RPC_RSP", 0x0a: "AUTH", + 0x0b: "PUSH1", 0x10: "HB_SEND", 0x11: "HB_RECV", + 0x17: "CONFIRM", 0x18: "PUSH2", 0x21: "REGISTER", 0x22: "CONFIRM_RSP", +} + +# TAIL_BYTES = 2c36004c5c6600 +_TAIL = bytes.fromhex("2c36004c5c6600") + + +def _strip_tail(body: bytes) -> bytes: + """裁掉 body 末尾的 TAIL_BYTES""" + if body.endswith(_TAIL): + return body[:-len(_TAIL)] + # 有时 TAIL 前还有 0c (ZERO tag) + if len(body) > 1 and body[-len(_TAIL)-1:-len(_TAIL)] == b'\x0c' and body.endswith(_TAIL): + return body[:-len(_TAIL)-1] + return body + + +def _decode_taf_value(ins: TafInputStream, dtype: int, depth: int = 0) -> object: + """递归解码单个 TAF 值""" + if dtype == TafType.ZERO: + return 0 + if dtype in (TafType.INT8, TafType.INT16, TafType.INT32, TafType.INT64): + return ins._read_int_value(dtype) + if dtype in (TafType.FLOAT, TafType.DOUBLE): + import struct as _s + if dtype == TafType.FLOAT: + return round(_s.unpack('>f', ins.buf.read(4))[0], 4) + return round(_s.unpack('>d', ins.buf.read(8))[0], 6) + if dtype == TafType.STRING1: + ln = ins.buf.read(1)[0] + return ins.buf.read(ln).decode('utf-8', errors='replace') + if dtype == TafType.STRING4: + import struct as _s + ln = _s.unpack('>I', ins.buf.read(4))[0] + return ins.buf.read(ln).decode('utf-8', errors='replace') + if dtype == TafType.MAP: + cnt = ins._read_int_len() + m = {} + for _ in range(cnt): + _, kt = ins.read_head() + k = _decode_taf_value(ins, kt, depth + 1) + _, vt = ins.read_head() + v = _decode_taf_value(ins, vt, depth + 1) + m[k] = v + return m + if dtype == TafType.LIST: + cnt = ins._read_int_len() + items = [] + for _ in range(cnt): + _, it = ins.read_head() + items.append(_decode_taf_value(ins, it, depth + 1)) + return items + if dtype == TafType.SIMPLE_LIST: + ins.read_head() # element type INT8 + ln = ins._read_int_len() + return f"" + if dtype == TafType.STRUCT_BEGIN: + return _decode_taf_struct(ins, depth + 1) + return f"<0x{dtype:02x}>" + + +def _decode_taf_struct(ins: TafInputStream, depth: int = 0) -> dict: + """解码一个 TAF 结构体 (已消费 STRUCT_BEGIN) → 有序字典""" + fields = {} + while True: + try: + tag, dtype = ins.peek_head() + except EOFError: + break + if dtype == TafType.STRUCT_END: + ins.read_head() + break + ins.read_head() + # 限制递归深度 + if depth < 5: + try: + val = _decode_taf_value(ins, dtype, depth) + except Exception: + val = f"" + else: + val = f"<...>" + try: + ins.skip_field(dtype) + except Exception: + break + key = f"tag{tag}" + if key in fields: + key = f"{key}_2" + fields[key] = val + return fields + + +def _extract_wup(body: bytes) -> bytes: + """从 WSS 响应 body 中提取干净的 WUP 包""" + if len(body) < 5: + return body + # 大包格式: [1B prefix][4B wup_len][wup_body][tail] + # prefix 可能是 0x00,必须优先于 4B total_len 判断。 + wup_len = int.from_bytes(body[1:5], 'big') + if 5 + wup_len <= len(body) and body[5:7] == b'\x10\x03': + return body[5:5 + wup_len] + total_len = int.from_bytes(body[0:4], 'big') + if 8 <= total_len <= len(body) and body[4:6] == b'\x10\x03': + return body[:total_len] + if 5 + wup_len <= len(body): + return body[5:5 + wup_len] + return body + + +def _decode_wup_body(body: bytes) -> dict: + """解码 WUP 封装 (请求/响应通用) → {servant, func, data}""" + result = {} + wup = normalize_wup_payload(_extract_wup(body)) + + # 手动解析 WUP,WSS 大帧常在 tag10 后追加校验/尾部字节 + try: + ins = TafInputStream(wup) + + # 读 WUP 字段 tag1~tag10,读到 tag10 后停止(忽略尾部垃圾) + sBuffer = b'' + while True: + try: + tag, dtype = ins.peek_head() + except EOFError: + break + # tag10 是最后一个合法 WUP 字段 + if tag > 10: + break + ins.read_head() + if tag == 1: + if dtype != TafType.ZERO: + ins._read_int_value(dtype) + elif tag in (2, 3): + if dtype != TafType.ZERO: + ins._read_int_value(dtype) + elif tag == 4: + if dtype != TafType.ZERO: + ins._read_int_value(dtype) + elif tag == 5: + result["servant"] = _decode_taf_value(ins, dtype) + elif tag == 6: + result["func"] = _decode_taf_value(ins, dtype) + elif tag == 7: + if dtype == TafType.SIMPLE_LIST: + ins.read_head() + ln = ins._read_int_len() + sBuffer = ins.buf.read(ln) + else: + ins.skip_field(dtype) + elif tag == 8: + if dtype != TafType.ZERO: + ins._read_int_value(dtype) + elif tag in (9, 10): + ins.skip_field(dtype) + if tag == 10: + break + else: + ins.skip_field(dtype) + + # 解析 sBuffer → newdata MAP + if sBuffer: + try: + sins = TafInputStream(sBuffer) + stag, sdt = sins.read_head() + if stag == 0 and sdt == TafType.MAP: + cnt = sins._read_int_len() + for _ in range(cnt): + _, kt = sins.read_head() + k = _decode_taf_value(sins, kt) + _, vt = sins.read_head() + if vt == TafType.SIMPLE_LIST: + sins.read_head() + vln = sins._read_int_len() + v = sins.buf.read(vln) + if v: + try: + tins = TafInputStream(v) + ttag, tdt = tins.peek_head() + if tdt == TafType.STRUCT_BEGIN: + tins.read_head() + result[k] = _decode_taf_struct(tins) + else: + result[k] = f"<{len(v)}B>" + except Exception: + result[k] = f"<{len(v)}B>" + else: + result[k] = _decode_taf_value(sins, vt) + except Exception: + pass + except Exception as e: + result["err"] = str(e) + return result + + +def _truncate(obj, max_str=80): + """截断过长的值,使日志紧凑""" + if isinstance(obj, str): + if len(obj) > max_str: + return obj[:max_str] + f"...({len(obj)})" + return obj + if isinstance(obj, dict): + return {k: _truncate(v, max_str) for k, v in obj.items()} + if isinstance(obj, list): + return [_truncate(v, max_str) for v in obj[:5]] + return obj + + +def _fmt_fields(fields: dict) -> str: + """把字段字典格式化为一行摘要""" + parts = [] + for k, v in fields.items(): + if isinstance(v, dict): + parts.append(f"{k}={{...}}") + elif isinstance(v, list): + parts.append(f"{k}=[{len(v)}项]") + else: + sv = str(v) + if len(sv) > 60: + sv = sv[:60] + "..." + parts.append(f"{k}={sv}") + return ", ".join(parts) + + +def format_wss_log(body: bytes, cmd: int, seq: int, direction: str) -> str: + """格式化单条 WSS 帧的日志行 + + Args: + body: 帧体 (不含 6B 头) + cmd: 命令字 + seq: 序列号 + direction: "发" 或 "收" + Returns: + 可读日志字符串 + """ + prefix = f"[{direction}]" + + # 心跳精简 + if cmd == 0x10: + return f"{prefix} HB →" + if cmd == 0x11: + return f"{prefix} HB ←" + + # RPC 请求/响应 + if cmd in (0x03, 0x04): + decoded = _decode_wup_body(body) + svc = decoded.get("servant", "") + func = decoded.get("func", "") + err = decoded.get("err") + if err: + return f"{prefix} RPC ERR: {err}" + label = f"{svc}.{func}" if svc else "RPC" + # 找 tRsp/tResp/tReq + for key in ("tRsp", "tResp", "tReq"): + data = decoded.get(key) + if isinstance(data, dict): + return f"{prefix} {label} {_fmt_fields(_truncate(data))}" + return f"{prefix} {label}" + + # AUTH + if cmd == 0x0a: + text = _strip_tail(body).decode('utf-8', errors='replace') + return f"{prefix} AUTH {text[:100]}{'...' if len(text) > 100 else ''}" + + # REGISTER / CONFIRM / PUSH 等 + clean = _strip_tail(body) + try: + ins = TafInputStream(clean) + # 看第一个 head + tag, dtype = ins.peek_head() + if dtype == TafType.STRUCT_BEGIN: + ins.read_head() + fields = _decode_taf_struct(ins) + elif dtype == TafType.MAP: + ins.read_head() + cnt = ins._read_int_len() + fields = {} + for _ in range(cnt): + _, kt = ins.read_head() + k = _decode_taf_value(ins, kt) + _, vt = ins.read_head() + v = _decode_taf_value(ins, vt) + fields[k] = v + elif dtype == TafType.LIST: + ins.read_head() + cnt = ins._read_int_len() + items = [] + for _ in range(cnt): + _, it = ins.read_head() + items.append(_decode_taf_value(ins, it)) + fields = {"items": items} + else: + fields = {} + cmd_name = CMD_NAMES.get(cmd, f"0x{cmd:02x}") + if fields: + return f"{prefix} {cmd_name} {_fmt_fields(_truncate(fields))}" + return f"{prefix} {cmd_name}" + except Exception: + cmd_name = CMD_NAMES.get(cmd, f"0x{cmd:02x}") + return f"{prefix} {cmd_name} ({len(body)}B)" diff --git a/core/huya/http_client.py b/core/huya/http_client.py new file mode 100644 index 0000000..f4c3653 --- /dev/null +++ b/core/huya/http_client.py @@ -0,0 +1,357 @@ +""" +虎牙 HTTP POST RPC 通道(cdnws.api.huya.com) + +用于 WSS 业务通道被设备态 Cookie 静默拒收时的兜底: + URL: https://cdnws.api.huya.com/?baseinfo= + Body: Wup 包 (含4字节长度前缀) + Response: Wup 包 (tRsp) +""" +import base64 +import json +import random +import struct +import urllib.parse +import urllib.request +from typing import Optional, Callable + +from .taf_protocol import TafOutputStream, TafInputStream, TafType, TafStruct +from .wup_protocol import WupRequest, WupResponse + +CDNWS_HOST = "cdnws.api.huya.com" +PC_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/149.0.0.0 Safari/537.36") +HTTP_HUYA_UA = "webh5&0.0.1&websocket&&diypc_52775" + + +class WSConnectParaInfo(TafStruct): + """WSS/HTTP 连接参数(baseinfo 的内容),从前端 SDK 逆向 + + tag0 lUid int64 + tag1 sGuid string + tag2 sUA string "webh5&0.0.1&websocket&&diypc_52775" + tag3 sAppSrc string "HUYA&ZH&2052" + tag4 sMid string + tag5 sExp string + tag6 iTokenType int32 + tag7 sToken string + tag8 sCookie string (lUid>0 时设 document.cookie) + tag9 sTraceId string "hex8:hex8:0:0" + tag10 mCustomHeaders Map + """ + def __init__(self): + self.lUid: int = 0 + self.sGuid: str = "" + self.sUA: str = "webh5&0.0.1&websocket&&diypc_52775" + self.sAppSrc: str = "HUYA&ZH&2052" + self.sMid: str = "" + self.sExp: str = "" + self.iTokenType: int = 0 + self.sToken: str = "" + self.sCookie: str = "" + self.sTraceId: str = "" + self.mCustomHeaders: dict = {} + + def write_to(self, os: TafOutputStream): + # 与浏览器一致: 空字符串也写 (HAR 实证 tag4/5/7/8 都写了空串) + if self.lUid: + os.write_int64(0, self.lUid) + else: + os.write_int64(0, 0) # ZERO + os.write_string(1, self.sGuid) + os.write_string(2, self.sUA) + os.write_string(3, self.sAppSrc) + os.write_string(4, self.sMid) + os.write_string(5, self.sExp) + os.write_int32(6, self.iTokenType) + os.write_string(7, self.sToken) + os.write_string(8, self.sCookie) + os.write_string(9, self.sTraceId) + os.write_map(10, self.mCustomHeaders) + + def read_from(self, ins: TafInputStream): + pass + + +def _gen_trace_id() -> str: + """生成 sTraceId (格式 hex8:hex8:0:0,HAR 实证)""" + h = '%016x' % random.getrandbits(64) + return f"{h}:{h}:0:0" + + +def generate_http_baseinfo(uid: int, guid: str, cookie: str, + trace_id: str = None) -> str: + """ + 构造 HTTP POST 的 baseinfo URL 参数 + + HAR 实证: getGoodsInfoV5 的 baseinfo lUid=0, sCookie="" (身份靠 Wup body 的 userId.sCookie) + """ + info = WSConnectParaInfo() + info.lUid = 0 # HAR 实证: baseinfo 里 lUid=0 + info.sGuid = guid + info.sUA = "webh5&0.0.1&websocket&&diypc_52775" + info.sAppSrc = "HUYA&ZH&2052" + info.sCookie = "" # HAR 实证: baseinfo 不带 cookie, cookie 在 Wup body + info.sTraceId = trace_id or _gen_trace_id() + + os = TafOutputStream() + os.write_struct_begin(0) # WSConnectParaInfo 是顶层 struct? 实际 baseinfo 是裸 struct 字段 + # 实际 HAR baseinfo 解码: 顶层直接是字段(tag0 lUid...), 不包 STRUCT_BEGIN + # 重置, 不写 STRUCT_BEGIN + os = TafOutputStream() + info.write_to(os) + raw = os.get_bytes() + # base64 (与 JS window.btoa 一致) + b64 = base64.b64encode(raw).decode('ascii') + return urllib.parse.quote(b64, safe='') + + +class HuyaHttpClient: + """虎牙 HTTP POST RPC 客户端""" + + def __init__(self, logger: Callable[[str], None] = None): + self.logger = logger or print + + @staticmethod + def _normalize_cookie(cookie: str) -> str: + """HTTP 业务 UserId.sCookie 需要带 huya_ua 前缀,避免重复写入。""" + import re + cookie = (cookie or "").strip() + normalized = f"huya_ua={HTTP_HUYA_UA}" + if re.search(r"(?:^|;\s*)huya_ua=", cookie): + return re.sub(r"(^|;\s*)huya_ua=[^;]*", + lambda m: f"{m.group(1)}{normalized}", + cookie, count=1) + if not cookie: + return normalized + return f"{normalized}; {cookie}" + + @staticmethod + def _build_user(uid: int, guid: str, cookie: str): + """构造 HTTP 业务 UserId。""" + from .shop_structs import UserId + user = UserId() + user.lUid = uid + user.sGuid = guid + user.sToken = "" + user.sHuYaUA = HTTP_HUYA_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。""" + from .shop_structs import ShopAppInfo + info = ShopAppInfo() + info.sAppId = "huya" + info.sBizType = "" + info.scene = scene + info.sourceId = source_id + return info + + @staticmethod + def _build_order_env(item_count: int = 1): + """构造下单/支付环境参数。""" + import time + click_time = int(time.time() * 1000) + return { + "user_click_seq": f'[{{"x":258,"y":487,"time":{click_time},"id":""}}]', + "input_1": str(item_count), + } + + def call_rpc(self, service: str, method: str, + req_struct: TafStruct, rsp_class=None, + uid: int = 0, guid: str = "", cookie: str = "", + timeout: float = 15.0): + """ + HTTP POST RPC 调用 + + Args: + service: servant 名 (shopMiddleUI) + method: 方法名 (getGoodsInfoV5) + req_struct: 请求结构体 (TafStruct) + rsp_class: 响应类 + uid/guid/cookie: 用于构造 baseinfo (cookie 主要放 Wup body) + """ + # 1. 构造 Wup 请求体 + wup = WupRequest() + wup.setServant(service) + wup.setFunc(method) + wup.setRequestId(1) + wup.writeStruct("tReq", req_struct) + wup_data = wup.encode() # [4字节长度][wup body] + + # 2. 构造 baseinfo + baseinfo = generate_http_baseinfo(uid, guid, cookie) + url = f"https://{CDNWS_HOST}/?baseinfo={baseinfo}" + + self.logger(f"[HTTP] POST {service}.{method}") + self.logger(f"[HTTP] URL: {url[:100]}...") + self.logger(f"[HTTP] 发送 body: {len(wup_data)} 字节, hex前60: {wup_data[:60].hex()}") + + # 3. POST + req = urllib.request.Request(url, data=wup_data, method='POST', headers={ + 'User-Agent': PC_UA, + 'Origin': 'https://zt.huya.com', + 'Referer': 'https://zt.huya.com/', + 'Content-Type': 'application/octet-stream', + 'Accept-Language': 'zh-CN,zh;q=0.9', + }) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + resp_data = resp.read() + # 检测 gzip 压缩 (响应 hex 1f8b 开头) + if resp_data[:2] == b'\x1f\x8b': + import gzip + resp_data = gzip.decompress(resp_data) + except Exception as e: + self.logger(f"[HTTP] ❌ 请求失败: {type(e).__name__}: {e}") + return None + + self.logger(f"[HTTP] 收到响应: {len(resp_data)} 字节, hex前60: {resp_data[:60].hex()}") + + # 4. 解析响应 Wup + wup_resp = WupResponse() + wup_resp.decode(resp_data) + if wup_resp.newdata: + self.logger(f"[HTTP] 响应 newdata keys: {list(wup_resp.newdata.keys())}") + for k, v in wup_resp.newdata.items(): + self.logger(f"[HTTP] {k} hex({len(v)}): {v.hex()[:200]}") + else: + self.logger(f"[HTTP] 响应无 newdata (servant={wup_resp.sServantName})") + + if rsp_class is None: + return wup_resp + + result = wup_resp.readStruct("tRsp", rsp_class) + if result is None: + result = wup_resp.readStruct("tResp", rsp_class) + return result + + # ---------- 业务便捷方法 ---------- + + def get_goods_info(self, uid, guid, cookie, pid, spu_id, sku_id=0, game_id="", + source_id="yellowcarlist", scene=7): + from .shop_structs import GetGoodsInfoReqV5, GoodsInfoRsp + req = GetGoodsInfoReqV5() + req.userId = self._build_user(uid, guid, cookie) + req.shopAppInfo = self._build_shop_app(source_id, scene) + req.pid = pid + req.spuId = spu_id + req.skuId = sku_id + req.gameId = game_id + return self.call_rpc("shopMiddleUI", "getGoodsInfoV5", req, + GoodsInfoRsp, uid, guid, cookie) + + def create_order(self, uid, guid, cookie, pid, spu_id, sku_id, + item_count=1, source_id="yellowcarlist", game_id="", scene=7, + order_type=None): + """order_type=None 时自动尝试从 1 到 10 找到有效值""" + from .shop_structs import (CreateOrderReqV5, CreateOrderRsp, + CreateOrderExtraParam, CreateOrderPromotionParam, + CreateOrderAccountParam) + + # 如果指定了具体值,直接试 + types_to_try = [order_type] if order_type is not None else list(range(1, 11)) + + last_result = None + for ot in types_to_try: + req = CreateOrderReqV5() + req.userId = self._build_user(uid, guid, cookie) + req.shopAppInfo = self._build_shop_app(source_id, scene) + req.receiveId = 0 + req.pid = pid + req.spuId = spu_id + req.skuId = sku_id + req.itemCount = item_count + req.gameId = game_id or "0" + req.src = scene + req.scene = 0 + req.sourceId = source_id + req.orderType = ot + req.extraParam = CreateOrderExtraParam() + req.env = self._build_order_env(item_count) + req.orderScene = scene + req.promotionParam = CreateOrderPromotionParam() + req.accountParam = CreateOrderAccountParam() + req.bizType = 5 + req.gameCategoryId = 507 + req.ext = "" + + result = self.call_rpc("shopMiddleUI", "createOrderV5", req, + CreateOrderRsp, uid, guid, cookie) + if result is not None: + self.logger(f" orderType={ot}: code={result.code} orderId={result.orderId} msg={result.message}") + if result.code == 200 and result.orderId: + self.logger(f"[✓] 找到有效 orderType={ot}") + return result + last_result = result + else: + self.logger(f" orderType={ot}: 无响应") + return last_result + + def pay_order_submit(self, uid, guid, cookie, order_id, pay_type=1, + pid=0, source_id="yellowcarlist", scene=7, + item_count=1): + """发起支付,结构与 WSS HAR 中 payOrderSubmitV5 对齐。""" + from .shop_structs import PayOrderRes + os = TafOutputStream() + os.write_struct_begin(0) + + os.write_struct(0, self._build_user(uid, guid, cookie)) + os.write_struct(1, self._build_shop_app(source_id, scene)) + os.write_int64(2, order_id) + os.write_string(3, "Zfb" if pay_type == 1 else str(pay_type)) + os.write_string(4, "QrCode") + callback_url = ( + f"https://m-shop.yaoguo.com/index.html#/consumer/paycallback" + f"?orderId={order_id}&pid={pid}&sourceId={source_id}" + ) + os.write_string(5, callback_url) + os.write_map(6, self._build_order_env(item_count)) + os.write_string(7, "null") + + os.write_struct_end() + + # 构造 Wup + wup = WupRequest() + wup.setServant("shopMiddleUI") + wup.setFunc("payOrderSubmitV5") + wup.setRequestId(1) + wup.newdata["tReq"] = os.get_bytes() + wup_data = wup.encode() + + baseinfo = generate_http_baseinfo(uid, guid, cookie) + url = f"https://{CDNWS_HOST}/?baseinfo={baseinfo}" + self.logger(f"[HTTP] POST shopMiddleUI.payOrderSubmitV5") + self.logger(f"[HTTP] 发送 hex前60: {wup_data[:60].hex()}") + + req = urllib.request.Request(url, data=wup_data, method='POST', headers={ + 'User-Agent': PC_UA, 'Origin': 'https://zt.huya.com', + 'Referer': 'https://zt.huya.com/', + 'Content-Type': 'application/octet-stream', + }) + try: + with urllib.request.urlopen(req, timeout=15) as resp: + resp_data = resp.read() + if resp_data[:2] == b'\x1f\x8b': + import gzip + resp_data = gzip.decompress(resp_data) + except Exception as e: + self.logger(f"[HTTP] ❌ payOrderSubmitV5 失败: {e}") + return None + + self.logger(f"[HTTP] payOrderSubmitV5 响应 {len(resp_data)} 字节, hex前60: {resp_data[:60].hex()}") + wup_resp = WupResponse() + wup_resp.decode(resp_data) + if wup_resp.newdata: + self.logger(f"[HTTP] 响应 keys: {list(wup_resp.newdata.keys())}") + for k, v in wup_resp.newdata.items(): + self.logger(f"[HTTP] {k} hex({len(v)}): {v.hex()[:200]}") + result = wup_resp.readStruct("tRsp", PayOrderRes) + if result is None: + result = wup_resp.readStruct("tResp", PayOrderRes) + return result diff --git a/core/huya/shop_structs.py b/core/huya/shop_structs.py new file mode 100644 index 0000000..d5c3e56 --- /dev/null +++ b/core/huya/shop_structs.py @@ -0,0 +1,583 @@ +""" +虎牙充值相关 JCE 结构定义 +字段顺序 = TAF tag 顺序(从 JS 逆向所得) + +来源:m-shop.yaoguo.com 的 jce/ShopFacade.js、api/orderui.ts、api/mall/PlayMallNewHome.ts +""" +from typing import List, Dict, Optional +from .taf_protocol import TafOutputStream, TafInputStream, TafStruct, TafType + + +class OrderType: + HUYA_PHYSICAL = 1 + TENCENT_VIRTUAL = 2 + MAGIC_BOX = 3 + MAGIC_BOX_DELIVERY = 4 + PAID_COURSE = 5 + HUYA_VIRTUAL = 6 + LOTTERY_PICKUP = 7 + DELIVERY_WORKBENCH = 11 + GAME_ACCOUNT = 12 + SHELF_GOODS = 17 + + +class OrderStatus: + DEPOSIT_WAIT_PAY = 10 + DEPOSIT_PAID = 20 + WAIT_DELIVER = 30 + WAIT_RECEIVE = 40 + FINISHED = 50 + FINISHED_CLOSED = 60 + CANCELLED = 90 + BALANCE_WAIT_PAY = 1000 + CANCELLED_BALANCE_EXPIRED = 1001 + + +def _opt_str(os: TafOutputStream, tag: int, val: str): + """非空才写字符串(与浏览器一致,省默认值)""" + if val: + os.write_string(tag, val) + + +def _opt_int(os: TafOutputStream, tag: int, val): + """非 0 才写整数""" + if val: + os.write_int64(tag, val) + + +def _opt_struct(os: TafOutputStream, tag: int, val): + """非 None 才写结构体""" + if val is not None: + os.write_struct(tag, val) + + +def _opt_list(os: TafOutputStream, tag: int, val): + """非空才写 list""" + if val: + os.write_list(tag, val) + + +def _opt_map(os: TafOutputStream, tag: int, val): + """非空才写 map""" + if val: + os.write_map(tag, val) + + +def _skip_to_struct_end(ins: TafInputStream): + """跳过当前结构里未解析的尾部字段,停在 STRUCT_END 前。""" + 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) + + +# ============================================================ +# 基础结构 +# ============================================================ + +class UserId(TafStruct): + """用户标识(cookie 在这里)""" + def __init__(self): + self.lUid: int = 0 # tag 0 + self.sGuid: str = "" # tag 1 + self.sToken: str = "" # tag 2 + self.sHuYaUA: str = "" # tag 3 "webh5&0.0.1&websocket&&diypc_52775" + self.sCookie: str = "" # tag 4 完整 cookie + self.iTokenType: int = 0 # tag 5 + self.sDeviceInfo: str = "" # tag 6 + self.sQIMEI: str = "" # tag 7 + + def write_to(self, os: TafOutputStream): + # HAR2 实证: 浏览器不优化空串/0值, 全写字段 + os.write_int64(0, self.lUid) + os.write_string(1, self.sGuid) + os.write_string(2, self.sToken) + os.write_string(3, self.sHuYaUA) + os.write_string(4, self.sCookie) + os.write_int32(5, self.iTokenType) + os.write_string(6, self.sDeviceInfo) + os.write_string(7, self.sQIMEI) + + def read_from(self, ins: TafInputStream): + self.lUid = ins.read_int64(0, default=self.lUid) + self.sGuid = ins.read_string(1, default=self.sGuid) + self.sToken = ins.read_string(2, default=self.sToken) + self.sHuYaUA = ins.read_string(3, default=self.sHuYaUA) + self.sCookie = ins.read_string(4, default=self.sCookie) + self.iTokenType = ins.read_int32(5, default=self.iTokenType) + self.sDeviceInfo = ins.read_string(6, default=self.sDeviceInfo) + self.sQIMEI = ins.read_string(7, default=self.sQIMEI) + + +class ShopAppInfo(TafStruct): + """应用信息(HAR2 实证字段顺序: tag0 sAppId, tag1 sBizType, tag4 scene, tag5 sourceId)""" + def __init__(self): + self.sAppId: str = "huya" # tag 0 + self.sBizType: str = "" # tag 1 + self.scene: int = 0 # tag 4 + self.sourceId: str = "" # tag 5 + + def write_to(self, os: TafOutputStream): + # HAR2 实证: 全写字段(含空串/0) + os.write_string(0, self.sAppId) + os.write_string(1, self.sBizType) + os.write_int32(4, self.scene) + os.write_string(5, self.sourceId) + + def read_from(self, ins: TafInputStream): + self.sAppId = ins.read_string(0, default=self.sAppId) + self.sBizType = ins.read_string(1, default=self.sBizType) + self.scene = ins.read_int32(4, default=self.scene) + self.sourceId = ins.read_string(5, default=self.sourceId) + + +# ============================================================ +# wsLaunch 初始化 +# ============================================================ + +class WsLaunchSubStruct(TafStruct): + """wsLaunch tag4 子结构(5个空字符串字段,浏览器强制写)""" + def __init__(self): + self.s0: str = "" # tag 0 + self.s1: str = "" # tag 1 + self.s2: str = "" # tag 2 + self.s3: str = "" # tag 3 + self.s4: str = "" # tag 4 + + def write_to(self, os: TafOutputStream): + # 浏览器写空字符串(STRING1 length0),这里强制写以精确匹配 + os.write_string(0, self.s0) + os.write_string(1, self.s1) + os.write_string(2, self.s2) + os.write_string(3, self.s3) + os.write_string(4, self.s4) + + def read_from(self, ins: TafInputStream): + pass + + +class WsLaunchReq(TafStruct): + """launch.wsLaunch 请求(tReq 是 STRUCT,不是 list) + + 从 HAR 逆向: + tag0: lUid (int64) + tag1: string "" + tag2: sHuYaUA "webh5&1.0.0&huya" + tag3: appSrc "HUYA&ZH&2052" + tag4: 子struct (5个空字符串) + """ + def __init__(self): + self.lUid: int = 0 # tag 0 + self.s1: str = "" # tag 1 + self.sHuYaUA: str = "webh5&1.0.0&huya" # tag 2 + self.appSrc: str = "HUYA&ZH&2052" # tag 3 + self.sub: WsLaunchSubStruct = WsLaunchSubStruct() # tag 4 + + def write_to(self, os: TafOutputStream): + # 注意: 浏览器不优化空字符串,tag1 强制写(HAR 实证) + _opt_int(os, 0, self.lUid) + os.write_string(1, self.s1) # 强制写空字符串 + _opt_str(os, 2, self.sHuYaUA) + _opt_str(os, 3, self.appSrc) + _opt_struct(os, 4, self.sub) + + def read_from(self, ins: TafInputStream): + pass + + +# ============================================================ +# 商品查询 +# ============================================================ + +class GetGoodsInfoReqV5(TafStruct): + """商品查询请求 (shopMiddleUI.getGoodsInfoV5)""" + def __init__(self): + self.userId = UserId() # tag 0 + self.shopAppInfo = ShopAppInfo() # tag 1 + self.pid: int = 0 # tag 2 + self.gameId: str = "" # tag 3 + self.spuId: str = "" # tag 4 + self.channelStockCode: str = "" # tag 5 + self.skuId: int = 0 # tag 6 + self.inviterUid: int = 0 # tag 7 + self.userModifyPriceId: int = 0 # tag 8 + + def write_to(self, os: TafOutputStream): + # HAR2 实证: 全写字段 + os.write_struct(0, self.userId) + os.write_struct(1, self.shopAppInfo) + os.write_int64(2, self.pid) + os.write_string(3, self.gameId) + os.write_string(4, self.spuId) + os.write_string(5, self.channelStockCode) + os.write_int64(6, self.skuId) + os.write_int64(7, self.inviterUid) + os.write_int64(8, self.userModifyPriceId) + + def read_from(self, ins: TafInputStream): + # 请求结构体一般不需要读,但保留 + pass + + +class GoodsInfoRsp(TafStruct): + """商品查询响应(简化:只取关键字段)""" + def __init__(self): + self.code: int = 0 # tag 0 + self.message: str = "" # tag 1 + # tag 2 是 goodsInfo 结构,简化跳过 + self.selfGoods: int = 0 # tag 3 + self.marketStatus: int = 0 # tag 5 + self.timestamp: int = 0 # tag 6 + + def read_from(self, ins: TafInputStream): + self.code = ins.read_int32(0, default=self.code) + self.message = ins.read_string(1, default=self.message) + # goodsInfo (tag 2) 跳过 + self.selfGoods = ins.read_int32(3, default=self.selfGoods) + self.marketStatus = ins.read_int32(5, default=self.marketStatus) + self.timestamp = ins.read_int64(6, default=self.timestamp) + + def write_to(self, os: TafOutputStream): + pass + + +# ============================================================ +# 订单历史 +# ============================================================ + +class OrderListShopInfo(TafStruct): + """订单明细里的店铺信息(只取展示需要的字段)""" + def __init__(self): + self.shopName: str = "" # tag 0 + + def read_from(self, ins: TafInputStream): + self.shopName = ins.read_string(0, default=self.shopName) + _skip_to_struct_end(ins) + + def write_to(self, os: TafOutputStream): + pass + + +class OrderListGoodsDetail(TafStruct): + """订单明细(queryUserOrderList 响应 tag16)""" + def __init__(self): + self.spuId: str = "" # tag 4 + self.skuId: int = 0 # tag 16 + self.buyerUid: int = 0 # tag 18 + self.virtualType: int = 0 # tag 19 + self.quantity: int = 0 # tag 20 + self.shopInfo: Optional[OrderListShopInfo] = None # tag 21 + self.points: int = 0 # tag 23 + + def read_from(self, ins: TafInputStream): + self.spuId = ins.read_string(4, default=self.spuId) + self.skuId = ins.read_int64(16, default=self.skuId) + self.buyerUid = ins.read_int64(18, default=self.buyerUid) + self.virtualType = ins.read_int32(19, default=self.virtualType) + self.quantity = ins.read_int64(20, default=self.quantity) + self.shopInfo = ins.read_struct(21, OrderListShopInfo) + self.points = ins.read_int64(23, default=self.points) + _skip_to_struct_end(ins) + + def write_to(self, os: TafOutputStream): + pass + + +class OrderListItem(TafStruct): + """订单列表项(queryUserOrderList 响应 tag3 的 list 元素)""" + def __init__(self): + self.bizOrderId: str = "" # tag 0 shop10148750 + self.appId: str = "" # tag 1 shop + self.orderId: str = "" # tag 2 + self.pid: int = 0 # tag 3 + self.shopName: str = "" # tag 4 + self.orderStatus: int = 0 # tag 5 + self.itemName: str = "" # tag 8 + self.unitPrice: int = 0 # tag 9 分 + self.quantity: int = 0 # tag 10 + self.totalPrice: int = 0 # tag 12 分 + self.createTime: int = 0 # tag 14 毫秒时间戳 + self.payTime: int = 0 # tag 15 毫秒时间戳 + self.goodsDetail: Optional[OrderListGoodsDetail] = None # tag 16 + + def read_from(self, ins: TafInputStream): + self.bizOrderId = ins.read_string(0, default=self.bizOrderId) + self.appId = ins.read_string(1, default=self.appId) + self.orderId = ins.read_string(2, default=self.orderId) + self.pid = ins.read_int64(3, default=self.pid) + self.shopName = ins.read_string(4, default=self.shopName) + self.orderStatus = ins.read_int32(5, default=self.orderStatus) + self.itemName = ins.read_string(8, default=self.itemName) + self.unitPrice = ins.read_int64(9, default=self.unitPrice) + self.quantity = ins.read_int64(10, default=self.quantity) + self.totalPrice = ins.read_int64(12, default=self.totalPrice) + self.createTime = ins.read_int64(14, default=self.createTime) + self.payTime = ins.read_int64(15, default=self.payTime) + self.goodsDetail = ins.read_struct(16, OrderListGoodsDetail) + _skip_to_struct_end(ins) + + def write_to(self, os: TafOutputStream): + pass + + +class QueryUserOrderListReq(TafStruct): + """查询购买历史订单 (revenueWebUI.queryUserOrderList)""" + def __init__(self): + self.userId = UserId() # tag 0 + self.offset: int = 0 # tag 1 + self.orderType: int = 1 # tag 2 + self.pageSize: int = 10 # tag 3 + self.status: int = 0 # tag 4 + + def write_to(self, os: TafOutputStream): + os.write_struct(0, self.userId) + os.write_int32(1, self.offset) + os.write_int32(2, self.orderType) + os.write_int32(3, self.pageSize) + os.write_int32(4, self.status) + + def read_from(self, ins: TafInputStream): + pass + + +class QueryUserOrderListRsp(TafStruct): + """购买历史订单响应""" + def __init__(self): + self.code: int = 0 + self.message: str = "" + self.totalCount: int = 0 + self.orders: List[OrderListItem] = [] + + @staticmethod + def _read_order_item(ins: TafInputStream, _tag: int): + item = OrderListItem() + item.read_from(ins) + _end_tag, dtype = ins.read_head() + if dtype != TafType.STRUCT_END: + raise ValueError(f"期望订单 STRUCT_END,实际 0x{dtype:02x}") + return item + + 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.totalCount = ins.read_int64(2, default=self.totalCount) + self.orders = ins.read_list(3, item_reader=self._read_order_item) + + def write_to(self, os: TafOutputStream): + pass + + +# ============================================================ +# 下单 +# ============================================================ + +class CreateOrderExtraParam(TafStruct): + def __init__(self): + self.freight: int = 0 # tag 0 + self.channelStockType: str = "" # tag 1 + self.channelStockCode: str = "" # tag 2 + self.relatedBizId: str = "" # tag 3 + self.bizParams: str = "" # tag 4 + self.popupTraceId: str = "" # tag 5 + self.supplierUid: int = 0 # tag 6 + self.categoryId: str = "" # tag 7 + self.ext: str = "" # tag 8 + + def write_to(self, os: TafOutputStream): + # HAR 实证:下单 extraParam 会强制写默认 0/空串字段 + os.write_int64(0, self.freight) + os.write_string(1, self.channelStockType) + os.write_string(2, self.channelStockCode) + os.write_string(3, self.relatedBizId) + os.write_string(4, self.bizParams) + os.write_string(5, self.popupTraceId) + os.write_int64(6, self.supplierUid) + if self.categoryId: + os.write_string(7, self.categoryId) + os.write_string(8, self.ext) + + def read_from(self, ins: TafInputStream): + pass + + +class CreateOrderPromotionParam(TafStruct): + def __init__(self): + self.yxjDeductPrice: int = 0 # tag 0 + self.userModifyPriceId: int = 0 # tag 1 + self.enablePromotion: int = 1 # tag 2 + + def write_to(self, os: TafOutputStream): + # HAR 实证:tag0/tag1 为 0,tag2 为 1 + os.write_int64(0, self.yxjDeductPrice) + os.write_int64(1, self.userModifyPriceId) + os.write_int32(2, self.enablePromotion) + + def read_from(self, ins: TafInputStream): + pass + + +class CreateOrderAccountParam(TafStruct): + def __init__(self): + self.payoutTypeList: List[int] = [] # tag 0 Vector + self.payoutChargeAmount: int = 0 # tag 1 + self.cancelPayoutTypeList: List[int] = [] # tag 2 + self.recycleSupplierId: int = 0 # tag 3 + self.claimPrice: int = 0 # tag 4 + + def write_to(self, os: TafOutputStream): + # HAR 实证:空 list/0 值也会写出 + os.write_list(0, self.payoutTypeList) + os.write_int64(1, self.payoutChargeAmount) + os.write_list(2, self.cancelPayoutTypeList) + os.write_int64(3, self.recycleSupplierId) + os.write_int64(4, self.claimPrice) + + def read_from(self, ins: TafInputStream): + pass + + +class PromotionItem(TafStruct): + def __init__(self): + self.promotionId: int = 0 # tag 0 + self.promotionType: int = 0 # tag 1 + + def write_to(self, os: TafOutputStream): + _opt_int(os, 0, self.promotionId) + _opt_int(os, 1, self.promotionType) + + def read_from(self, ins: TafInputStream): + self.promotionId = ins.read_int64(0, default=self.promotionId) + self.promotionType = ins.read_int32(1, default=self.promotionType) + + +class CreateOrderReqV5(TafStruct): + """下单请求 (shopMiddleUI.createOrderV5)""" + def __init__(self): + self.userId = UserId() # tag 0 + self.shopAppInfo = ShopAppInfo() # tag 1 + self.receiveId: int = 0 # tag 2 + self.pid: int = 0 # tag 3 + self.skuId: int = 0 # tag 4 + self.itemCount: int = 1 # tag 5 + self.remark: str = "" # tag 6 + self.spuId: str = "" # tag 7 + self.gameId: str = "" # tag 8 + self.orderId: int = 0 # tag 9 + self.src: int = 0 # tag 10 + self.couponUserIds: List[int] = [] # tag 11 Vector + self.orderType: int = 0 # tag 12 + self.extraParam: Optional[CreateOrderExtraParam] = None # tag 13 + self.scene: int = 0 # tag 14 + self.promotionItems: List = [] # tag 15 Vector + self.sourceId: str = "" # tag 16 + self.env: Dict[str, str] = {} # tag 17 Map + self.orderScene: int = 0 # tag 18 + self.watchWord: str = "" # tag 19 + self.marketingChannel: str = "" # tag 20 + self.promotionParam: Optional[CreateOrderPromotionParam] = None # tag 21 + self.externalTraceKey: str = "" # tag 22 + self.kefuUid: int = 0 # tag 23 + self.accountParam: Optional[CreateOrderAccountParam] = None # tag 24 + self.parentOrderId: int = 0 # tag 25 + self.vendorAccountType: str = "" # tag 26 + self.vendorAccountVal: str = "" # tag 27 + self.vendorSubAccountVal: str = "" # tag 28 + self.vendorSubAccountType: str = "" # tag 29 + self.bizType: int = 0 # tag 30 + self.gameCategoryId: int = 0 # tag 31 + self.ext: str = "" # tag 32 + + def write_to(self, os: TafOutputStream): + # HAR 实证:createOrderV5 会写出完整字段,即使值为 0/空串/空 list + os.write_struct(0, self.userId) + os.write_struct(1, self.shopAppInfo) + os.write_int64(2, self.receiveId) + os.write_int64(3, self.pid) + os.write_int64(4, self.skuId) + os.write_int64(5, self.itemCount) + os.write_string(6, self.remark) + os.write_string(7, self.spuId) + os.write_string(8, self.gameId) + os.write_int64(9, self.orderId) + os.write_int32(10, self.src) + os.write_list(11, self.couponUserIds) + os.write_int32(12, self.orderType) + os.write_struct(13, self.extraParam or CreateOrderExtraParam()) + os.write_int32(14, self.scene) + os.write_list(15, self.promotionItems) + os.write_string(16, self.sourceId) + os.write_map(17, self.env) + os.write_int32(18, self.orderScene) + os.write_string(19, self.watchWord) + os.write_string(20, self.marketingChannel) + os.write_struct(21, self.promotionParam or CreateOrderPromotionParam()) + os.write_string(22, self.externalTraceKey) + os.write_int64(23, self.kefuUid) + os.write_struct(24, self.accountParam or CreateOrderAccountParam()) + os.write_int64(25, self.parentOrderId) + os.write_string(26, self.vendorAccountType) + os.write_string(27, self.vendorAccountVal) + os.write_string(28, self.vendorSubAccountVal) + os.write_string(29, self.vendorSubAccountType) + os.write_int32(30, self.bizType) + os.write_int32(31, self.gameCategoryId) + os.write_string(32, self.ext) + + def read_from(self, ins: TafInputStream): + pass + + +class CreateOrderRsp(TafStruct): + """下单响应 (shopMiddleUI.createOrderV5)""" + def __init__(self): + self.code: int = 0 # tag 0 + self.message: str = "" # tag 1 + self.orderId: int = 0 # tag 2 虎牙订单号 + self.subOrderId: int = 0 # tag 4 + self.orderStatus: int = 0 # tag 5 + self.riskUrl: str = "" # tag 8 风控跳转URL(code!=200时可能有) + + def read_from(self, ins: TafInputStream): + self.code = ins.read_int32(0, default=self.code) + self.message = ins.read_string(1, default=self.message) + self.orderId = ins.read_int64(2, default=self.orderId) + self.subOrderId = ins.read_int64(4, default=self.subOrderId) + self.orderStatus = ins.read_int32(5, default=self.orderStatus) + self.riskUrl = ins.read_string(8, default=self.riskUrl) + + def write_to(self, os: TafOutputStream): + pass + + +# ============================================================ +# 支付(payOrderSubmitV5) +# ============================================================ + +class PayOrderRes(TafStruct): + """ + 发起支付响应 (shopMiddleUI.payOrderSubmitV5) + 结构从 state_shop_ts.js 的 payOrderRes 推断,tag 顺序按出现顺序 + """ + def __init__(self): + self.code: int = 0 # tag 0 + self.message: str = "" # tag 1 + self.orderId: int = 0 # tag 2 虎牙订单号 + self.appOrderId: str = "" # tag 3 + self.payOrderId: str = "" # tag 4 支付宝 payOrderId + self.payUrl: str = "" # tag 5 支付宝下单URL(含sign) + self.amount: int = 0 # tag 6 + + def read_from(self, ins: TafInputStream): + self.code = ins.read_int32(0, default=self.code) + self.message = ins.read_string(1, default=self.message) + self.orderId = ins.read_int64(2, default=self.orderId) + self.appOrderId = ins.read_string(3, default=self.appOrderId) + self.payOrderId = ins.read_string(4, default=self.payOrderId) + self.payUrl = ins.read_string(5, default=self.payUrl) + self.amount = ins.read_int64(6, default=self.amount) + + def write_to(self, os: TafOutputStream): + pass diff --git a/core/huya/taf_protocol.py b/core/huya/taf_protocol.py new file mode 100644 index 0000000..316b5b7 --- /dev/null +++ b/core/huya/taf_protocol.py @@ -0,0 +1,477 @@ +""" +虎牙 TAF 协议 Python 实现 +基于前端 TAF/WUP 实现逆向 + +类型标签: + 0x00 INT8 0x01 INT16 0x02 INT32 0x03 INT64 + 0x04 FLOAT 0x05 DOUBLE 0x06 STRING1 0x07 STRING4 + 0x08 MAP 0x09 LIST 0x0a STRUCT_BEGIN 0x0b STRUCT_END + 0x0c ZERO 0x0d SIMPLE_LIST +""" +import struct +import io +from typing import Any, Dict, List, Optional, Tuple + + +class TafType: + INT8 = 0x00 + INT16 = 0x01 + INT32 = 0x02 + INT64 = 0x03 + FLOAT = 0x04 + DOUBLE = 0x05 + STRING1 = 0x06 + STRING4 = 0x07 + MAP = 0x08 + LIST = 0x09 + STRUCT_BEGIN = 0x0a + STRUCT_END = 0x0b + ZERO = 0x0c + SIMPLE_LIST = 0x0d + + +# ============================================================ +# 输出流(编码) +# ============================================================ + +class TafOutputStream: + """TAF 编码输出流""" + + def __init__(self): + self.buf = io.BytesIO() + + def get_bytes(self) -> bytes: + return self.buf.getvalue() + + # ---- head ---- + def write_head(self, tag: int, data_type: int): + if tag < 15: + self.buf.write(struct.pack('B', (tag << 4) | data_type)) + else: + self.buf.write(struct.pack('BB', 0xF0 | data_type, tag)) + + # ---- 整数(带自动优化) ---- + def write_int8(self, tag: int, value: int): + if value == 0: + self.write_head(tag, TafType.ZERO) + else: + self.write_head(tag, TafType.INT8) + self.buf.write(struct.pack('b', value)) + + def write_int16(self, tag: int, value: int): + if -128 <= value <= 127: + self.write_int8(tag, value) + else: + self.write_head(tag, TafType.INT16) + self.buf.write(struct.pack('>h', value)) + + def write_int32(self, tag: int, value: int): + if -32768 <= value <= 32767: + self.write_int16(tag, value) + else: + self.write_head(tag, TafType.INT32) + self.buf.write(struct.pack('>i', value)) + + def write_int64(self, tag: int, value: int): + if -2147483648 <= value <= 2147483647: + self.write_int32(tag, value) + else: + self.write_head(tag, TafType.INT64) + self.buf.write(struct.pack('>q', value)) + + def write_uint64(self, tag: int, value: int): + """uint64:超过 int32 范围用 INT64""" + if value <= 2147483647: + self.write_int32(tag, value) + else: + self.write_head(tag, TafType.INT64) + self.buf.write(struct.pack('>Q', value)) + + # ---- 浮点 ---- + def write_float(self, tag: int, value: float): + self.write_head(tag, TafType.FLOAT) + self.buf.write(struct.pack('>f', value)) + + def write_double(self, tag: int, value: float): + self.write_head(tag, TafType.DOUBLE) + self.buf.write(struct.pack('>d', value)) + + # ---- 字符串 ---- + def write_string(self, tag: int, value: str): + encoded = value.encode('utf-8') + length = len(encoded) + if length > 255: + self.write_head(tag, TafType.STRING4) + self.buf.write(struct.pack('>I', length)) + else: + self.write_head(tag, TafType.STRING1) + self.buf.write(struct.pack('B', length)) + self.buf.write(encoded) + + # ---- 字节数组 ---- + def write_bytes(self, tag: int, value: bytes): + self.write_head(tag, TafType.SIMPLE_LIST) + self.write_head(0, TafType.INT8) # 元素类型固定 INT8 + self.write_int32(0, len(value)) + self.buf.write(value) + + # ---- 布尔 ---- + def write_boolean(self, tag: int, value: bool): + self.write_int8(tag, 1 if value else 0) + + # ---- 结构体 ---- + def write_struct_begin(self, tag: int): + self.write_head(tag, TafType.STRUCT_BEGIN) + + def write_struct_end(self): + self.write_head(0, TafType.STRUCT_END) + + def write_struct(self, tag: int, struct_obj): + """写入结构体对象(需实现 write_to)""" + self.write_struct_begin(tag) + struct_obj.write_to(self) + self.write_struct_end() + + # ---- Map ---- + def write_map(self, tag: int, value: Dict[Any, Any], + key_writer=None, val_writer=None): + self.write_head(tag, TafType.MAP) + self.write_int32(0, len(value)) + for k, v in value.items(): + if key_writer: + key_writer(self, 0, k) + else: + self._write_any(0, k) + if val_writer: + val_writer(self, 1, v) + else: + self._write_any(1, v) + + # ---- List ---- + def write_list(self, tag: int, value: List[Any], item_writer=None): + self.write_head(tag, TafType.LIST) + self.write_int32(0, len(value)) + for item in value: + if item_writer: + item_writer(self, 0, item) + else: + self._write_any(0, item) + + def _write_any(self, tag: int, value: Any): + if isinstance(value, bool): + self.write_boolean(tag, value) + elif isinstance(value, int): + self.write_int64(tag, value) + elif isinstance(value, float): + self.write_double(tag, value) + elif isinstance(value, str): + self.write_string(tag, value) + elif isinstance(value, bytes): + self.write_bytes(tag, value) + elif isinstance(value, dict): + self.write_map(tag, value) + elif isinstance(value, (list, tuple)): + self.write_list(tag, list(value)) + elif hasattr(value, 'write_to'): + self.write_struct(tag, value) + else: + raise TypeError(f"不支持的类型: {type(value)}") + + +# ============================================================ +# 输入流(解码)—— 完整实现,支持所有类型 +# ============================================================ + +class TafInputStream: + """TAF 解码输入流""" + + def __init__(self, data: bytes): + self.buf = io.BytesIO(data) + + def peek_head(self) -> Tuple[int, int]: + """读取 head 但不消费(用于探测)""" + pos = self.buf.tell() + try: + return self.read_head() + finally: + self.buf.seek(pos) + + def read_head(self) -> Tuple[int, int]: + """返回 (tag, type)""" + data = self.buf.read(1) + if not data: + raise EOFError("读取到文件末尾") + b = struct.unpack('B', data)[0] + tag = (b >> 4) & 0x0F + data_type = b & 0x0F + if tag == 15: + data = self.buf.read(1) + if not data: + raise EOFError("读取 tag 扩展字节失败") + tag = struct.unpack('B', data)[0] + return tag, data_type + + # ---- 跳过 ---- + def skip_field(self, data_type: int): + if data_type == TafType.ZERO: + return + if data_type == TafType.INT8: + self.buf.read(1) + elif data_type == TafType.INT16: + self.buf.read(2) + elif data_type == TafType.INT32: + self.buf.read(4) + elif data_type == TafType.INT64: + self.buf.read(8) + elif data_type == TafType.FLOAT: + self.buf.read(4) + elif data_type == TafType.DOUBLE: + self.buf.read(8) + elif data_type == TafType.STRING1: + length = struct.unpack('B', self.buf.read(1))[0] + self.buf.read(length) + elif data_type == TafType.STRING4: + length = struct.unpack('>I', self.buf.read(4))[0] + self.buf.read(length) + elif data_type == TafType.MAP: + self._skip_map() + elif data_type == TafType.LIST: + self._skip_list() + elif data_type == TafType.SIMPLE_LIST: + # [head(0,INT8)] [int32 length] [bytes] + self.read_head() # 元素类型 + length = self._read_int_len() + self.buf.read(length) + elif data_type == TafType.STRUCT_BEGIN: + self._skip_struct() + elif data_type == TafType.STRUCT_END: + pass + else: + raise ValueError(f"未知类型 0x{data_type:02x}") + + def _read_int_len(self) -> int: + """读 map/list 长度(int32 带优化)""" + tag, dtype = self.read_head() + return self._read_int_value(dtype) + + def _read_int_value(self, dtype: int) -> int: + if dtype == TafType.ZERO: + return 0 + if dtype == TafType.INT8: + return struct.unpack('b', self.buf.read(1))[0] + if dtype == TafType.INT16: + return struct.unpack('>h', self.buf.read(2))[0] + if dtype == TafType.INT32: + return struct.unpack('>i', self.buf.read(4))[0] + if dtype == TafType.INT64: + return struct.unpack('>q', self.buf.read(8))[0] + raise ValueError(f"期望整数, 实际 0x{dtype:02x}") + + def _skip_struct(self): + while True: + tag, dtype = self.read_head() + if dtype == TafType.STRUCT_END: + break + self.skip_field(dtype) + + def _skip_map(self): + count = self._read_int_len() + for _ in range(count): + _, kt = self.read_head() + self.skip_field(kt) + _, vt = self.read_head() + self.skip_field(vt) + + def _skip_list(self): + count = self._read_int_len() + for _ in range(count): + _, it = self.read_head() + self.skip_field(it) + + # ---- 带跳过策略的字段读取:找到 tag,否则返回默认 ---- + def _find_tag(self, target_tag: int, required: bool) -> Optional[Tuple[int, int]]: + """逐个读 head,tag 相等则返回,tag 超过则回退并返回 None""" + while True: + pos = self.buf.tell() + tag, dtype = self.read_head() + if tag == target_tag: + return (tag, dtype) + if dtype == TafType.STRUCT_END: + # 回退,让上层处理 STRUCT_END + self.buf.seek(pos) + if required: + raise ValueError(f"未找到 tag={target_tag} (遇到 STRUCT_END)") + return None + if tag > target_tag: + # 超过目标 tag,回退(字段不存在) + self.buf.seek(pos) + if required: + raise ValueError(f"未找到 tag={target_tag} (遇到 tag={tag})") + return None + # tag < target_tag,跳过此字段 + self.skip_field(dtype) + + # ---- 基本类型读取 ---- + def read_int8(self, tag: int, required: bool = False, default: int = 0) -> int: + found = self._find_tag(tag, required) + if not found: + return default + return self._read_int_value(found[1]) + + def read_int16(self, tag: int, required: bool = False, default: int = 0) -> int: + return self.read_int8(tag, required, default) + + def read_int32(self, tag: int, required: bool = False, default: int = 0) -> int: + return self.read_int8(tag, required, default) + + def read_int64(self, tag: int, required: bool = False, default: int = 0) -> int: + return self.read_int8(tag, required, default) + + def read_uint64(self, tag: int, required: bool = False, default: int = 0) -> int: + found = self._find_tag(tag, required) + if not found: + return default + dtype = found[1] + if dtype == TafType.ZERO: + return 0 + if dtype == TafType.INT8: + return struct.unpack('B', self.buf.read(1))[0] + if dtype == TafType.INT16: + return struct.unpack('>H', self.buf.read(2))[0] + if dtype == TafType.INT32: + return struct.unpack('>I', self.buf.read(4))[0] + if dtype == TafType.INT64: + return struct.unpack('>Q', self.buf.read(8))[0] + raise ValueError(f"期望 uint, 实际 0x{dtype:02x}") + + def read_boolean(self, tag: int, required: bool = False, default: bool = False) -> bool: + return bool(self.read_int8(tag, required, 1 if default else 0)) + + def read_float(self, tag: int, required: bool = False, default: float = 0.0) -> float: + found = self._find_tag(tag, required) + if not found: + return default + dtype = found[1] + if dtype == TafType.ZERO: + return 0.0 + if dtype == TafType.FLOAT: + return struct.unpack('>f', self.buf.read(4))[0] + if dtype == TafType.DOUBLE: + return struct.unpack('>d', self.buf.read(8))[0] + return float(self._read_int_value(dtype)) + + def read_double(self, tag: int, required: bool = False, default: float = 0.0) -> float: + return self.read_float(tag, required, default) + + def read_string(self, tag: int, required: bool = False, default: str = "") -> str: + found = self._find_tag(tag, required) + if not found: + return default + dtype = found[1] + if dtype == TafType.STRING1: + length = struct.unpack('B', self.buf.read(1))[0] + elif dtype == TafType.STRING4: + length = struct.unpack('>I', self.buf.read(4))[0] + else: + raise ValueError(f"期望 string, 实际 0x{dtype:02x}") + return self.buf.read(length).decode('utf-8', errors='replace') + + def read_bytes(self, tag: int, required: bool = False, default: bytes = b'') -> bytes: + found = self._find_tag(tag, required) + if not found: + return default + dtype = found[1] + if dtype != TafType.SIMPLE_LIST: + raise ValueError(f"期望 bytes, 实际 0x{dtype:02x}") + self.read_head() # 元素类型 INT8 + length = self._read_int_len() + return self.buf.read(length) + + # ---- 复合类型 ---- + def read_map(self, tag: int, required: bool = False, + key_reader=None, val_reader=None) -> Dict: + found = self._find_tag(tag, required) + if not found: + return {} + if found[1] != TafType.MAP: + raise ValueError(f"期望 map, 实际 0x{found[1]:02x}") + count = self._read_int_len() + result = {} + for _ in range(count): + _, kt = self.read_head() + k = self._read_value(kt, key_reader) + _, vt = self.read_head() + v = self._read_value(vt, val_reader) + result[k] = v + return result + + def read_list(self, tag: int, required: bool = False, + item_reader=None) -> List: + found = self._find_tag(tag, required) + if not found: + return [] + if found[1] != TafType.LIST: + raise ValueError(f"期望 list, 实际 0x{found[1]:02x}") + count = self._read_int_len() + result = [] + for _ in range(count): + _, it = self.read_head() + result.append(self._read_value(it, item_reader)) + return result + + def read_struct(self, tag: int, struct_class, required: bool = False): + """读取结构体,struct_class 需有无参构造 + read_from""" + found = self._find_tag(tag, required) + if not found: + return None + if found[1] != TafType.STRUCT_BEGIN: + raise ValueError(f"期望 struct, 实际 0x{found[1]:02x}") + obj = struct_class() + obj.read_from(self) + # 消费 STRUCT_END + t, dt = self.read_head() + if dt != TafType.STRUCT_END: + raise ValueError(f"期望 STRUCT_END, 实际 0x{dt:02x}") + return obj + + def _read_value(self, dtype: int, reader=None): + if reader: + return reader(self, 0) + # 自动推断 + if dtype == TafType.STRING1: + length = struct.unpack('B', self.buf.read(1))[0] + return self.buf.read(length).decode('utf-8', errors='replace') + if dtype == TafType.STRING4: + length = struct.unpack('>I', self.buf.read(4))[0] + return self.buf.read(length).decode('utf-8', errors='replace') + if dtype in (TafType.ZERO, TafType.INT8, TafType.INT16, + TafType.INT32, TafType.INT64): + return self._read_int_value(dtype) + if dtype == TafType.STRUCT_BEGIN: + # 未知 struct,跳过 + self._skip_struct() + return None + self.skip_field(dtype) + return None + + +# ============================================================ +# 结构体基类 +# ============================================================ + +class TafStruct: + """TAF 结构体基类:子类实现 write_to / read_from""" + + def write_to(self, os: TafOutputStream): + raise NotImplementedError + + def read_from(self, ins: TafInputStream): + raise NotImplementedError + + def to_dict(self) -> Dict[str, Any]: + """调试用:转字典""" + return {k: v for k, v in self.__dict__.items() + if not k.startswith('_')} + + def __repr__(self): + return f"{self.__class__.__name__}({self.to_dict()})" diff --git a/core/huya/wss_client.py b/core/huya/wss_client.py new file mode 100644 index 0000000..7fa04ec --- /dev/null +++ b/core/huya/wss_client.py @@ -0,0 +1,709 @@ +""" +虎牙 WSS 客户端 — 商城通道 (77bc035c-ws.va.huya.com) + +初始化流程 (Chrome WS 帧实证): + 1. HEARTBEAT_SEND (cmd 0x10) + 2. RPC: launch.wsLaunch (cmd 0x03) — 商城版无 lUid + 3. RPC: mobileui.getConfig (cmd 0x03) + 4. 等待 wsLaunch/getConfig 响应 + 5. REGISTER (cmd 0x21) — 回传 wsLaunch 返回的 sGuid + 6. CONFIRM_SEND (cmd 0x17) + 7. 业务 RPC ... + +关键发现 (Chrome WS 帧分析): + - 服务端响应 seq ≠ 请求 seq → 必须用 FIFO 匹配 + - 初始化 wsLaunch iRequestId=-1,业务 requestId 从 8 开始递增 +""" +import asyncio +import random +import re +import struct +import time +from collections import deque +from typing import Optional, Callable + +import websockets + +from .wup_protocol import WupRequest, WupResponse +from .taf_protocol import TafOutputStream, TafInputStream, TafType, TafStruct +from .frame_decoder import format_wss_log, _decode_taf_struct, _truncate + + +SHOP_WS_HOST = "77bc035c-ws.va.huya.com" +# 商城端点 baseinfo (conn4, h5_/index.html) — 商城业务 shopMiddleUI 走此通道 +# wsLaunch tReq: lUid=0, sGuid="", sUA="webh5&0.0.1&websocket&&h5_/index.html" +SHOP_BASEINFO = "DBYAJiV3ZWJoNSYwLjAuMSZ3ZWJzb2NrZXQmJmg1Xy9pbmRleC5odG1sNgxIVVlBJlpIJjIwNTJGAFYAbHYAhgCWAKgM" + +TAIL_BYTES = bytes.fromhex("2c36004c5c6600") +BIG_TAIL_SUFFIX = bytes.fromhex("4c5c6600") +WSS_BIZ_UA = "web&1.0.0&huya" +WSS_COOKIE_UA = "webh5&0.0.1&websocket&&diypc_52775" +WSS_SHOP_SCENE = 4 + +# seq 按消息类型固定 (HAR 实证,跨两次抓包一致) +SEQ_HEARTBEAT = 0x1d00000d +SEQ_WSLAUNCH = 0x1d000078 +SEQ_GETCONFIG = 0x1d000076 +SEQ_REGISTER = 0x1d00003c +SEQ_CONFIRM = 0x1d000002 +SEQ_BUSINESS = 0x1d000106 # 业务 RPC (HAR 7.4 实证:业务帧固定使用该 seq) + + +class WssCommand: + RPC_REQUEST = 0x03 + RPC_RESPONSE = 0x04 + AUTH = 0x0a + SERVER_PUSH1 = 0x0b + HEARTBEAT_SEND = 0x10 + HEARTBEAT_RECV = 0x11 + CONFIRM_SEND = 0x17 + SERVER_PUSH2 = 0x18 + REGISTER = 0x21 + + +class WssMessage: + def __init__(self, command: int, sequence: int, body: bytes): + self.command = command + self.sequence = sequence + self.body = body + + def encode(self) -> bytes: + header = struct.pack('>BBI', 0x00, self.command, self.sequence) + return header + self.body + + @classmethod + def decode(cls, data: bytes) -> 'WssMessage': + if len(data) < 6: + raise ValueError(f"消息太短: {len(data)} bytes") + version, command = struct.unpack('>BB', data[0:2]) + sequence = struct.unpack('>I', data[2:6])[0] + body = data[6:] + return cls(command=command, sequence=sequence, body=body) + + +class HuyaWssClient: + """虎牙 WSS 客户端 — 商城通道""" + + def __init__(self, baseinfo: str = None, logger: Callable[[str], None] = None): + self.baseinfo = baseinfo or SHOP_BASEINFO + self.ws = None + self._biz_seq = SEQ_BUSINESS # 业务 RPC 递增用 + self._wup_req_id = 8 + self.rpc_queue = deque() + self.logger = logger or print + self._recv_task = None + self._heartbeat_task = None + self._launch_guid = "" + self._launch_ip = "" + + def _next_biz_seq(self) -> int: + """业务 RPC 的 frame seq。 + + 浏览器在商城 WSS 上连续发送多个业务 RPC 时,frame seq 都保持 + 0x1d000106;真正区分请求依赖 WUP requestId。 + """ + return SEQ_BUSINESS + + def _next_wup_req_id(self) -> int: + """业务 WUP requestId,HAR 中 getGoodsInfoV5 从 8 开始递增""" + req_id = self._wup_req_id + self._wup_req_id += 1 + return req_id + + @staticmethod + def _make_big_tail() -> bytes: + """生成 WSS 大包尾部 trace(格式与浏览器 HAR 一致)""" + trace = f"{random.getrandbits(64):016x}" + return f",6%{trace}:{trace}:0:3".encode("ascii") + BIG_TAIL_SUFFIX + + @staticmethod + def _encode_rpc_body(wup_data: bytes) -> bytes: + """按浏览器规则封装 WSS RPC body + + 小包: [4B WUP长度][WUP body] + TAIL_BYTES + 大包: [1B len低字节][4B WUP body长度][裸WUP body] + trace尾部 + """ + if len(wup_data) < 4: + return wup_data + TAIL_BYTES + declared_len = int.from_bytes(wup_data[:4], "big") + if declared_len == len(wup_data): + wup_body = wup_data[4:] + else: + wup_body = wup_data + if len(wup_body) <= 0xFF: + return wup_data + TAIL_BYTES + # 大包头里的长度使用原始 WUP 包长度(含 4B 长度前缀),但正文只放裸 WUP body; + # 浏览器随后用 trace 尾部的前 4 字节补足该长度窗口。 + packet_len = len(wup_data) + return bytes([packet_len & 0xFF]) + packet_len.to_bytes(4, "big") + wup_body + HuyaWssClient._make_big_tail() + + @staticmethod + def _normalize_biz_cookie(cookie: str) -> str: + """业务 RPC 的 huya_ua Cookie 值按浏览器 WSS 抓包修正""" + cookie = (cookie or "").strip() + normalized = f"huya_ua={WSS_COOKIE_UA}" + if re.search(r"(?:^|;\s*)huya_ua=", cookie): + return re.sub(r"(^|;\s*)huya_ua=[^;]*", lambda m: f"{m.group(1)}{normalized}", cookie, count=1) + if not cookie: + return normalized + return f"{normalized}; {cookie}" + + @staticmethod + def _build_biz_user(uid: int, cookie: str): + """构造 WSS 业务 UserId(HAR 实证:guid 为空,cookie 不额外加前缀)""" + from .shop_structs import UserId + user = UserId() + user.lUid = uid + user.sGuid = "" + user.sToken = "" + user.sHuYaUA = WSS_BIZ_UA + user.sCookie = HuyaWssClient._normalize_biz_cookie(cookie) + user.iTokenType = 0 + user.sDeviceInfo = "" + user.sQIMEI = "" + return user + + @staticmethod + def _build_shop_app(source_id: str = "yellowcarlist", scene: int = WSS_SHOP_SCENE): + """构造 WSS 业务 ShopAppInfo""" + from .shop_structs import ShopAppInfo + info = ShopAppInfo() + info.sAppId = "huya" + info.sBizType = "" + info.scene = scene + info.sourceId = source_id + return info + + @staticmethod + def _build_order_env(item_count: int = 1): + """构造下单/支付环境参数""" + click_time = int(time.time() * 1000) + return { + "user_click_seq": f'[{{"x":258,"y":487,"time":{click_time},"id":""}}]', + "input_1": str(item_count), + } + + async def connect(self, host: str = SHOP_WS_HOST, timeout: float = 15.0, + cookie: str = ""): + from urllib.parse import quote + url = f"wss://{host}/?baseinfo={quote(self.baseinfo)}" + self.logger(f"[WSS] 正在连接 {host} ...") + self.logger(f"[WSS] URL: {url[:100]}...") + + BROWSER_UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/149.0.0.0 Safari/537.36") + headers = {"Accept-Language": "zh-CN,zh;q=0.9"} + + try: + self.ws = await asyncio.wait_for( + websockets.connect( + url, + origin="https://m-shop.yaoguo.com", + user_agent_header=BROWSER_UA, + additional_headers=headers, + open_timeout=timeout, + ), + timeout=timeout) + except asyncio.TimeoutError: + self.logger(f"[WSS] 连接超时({timeout}s)") + raise + except Exception as e: + self.logger(f"[WSS] 连接失败: {type(e).__name__}: {e}") + raise + + self.logger(f"[WSS] 握手成功, 实际连接: {self.ws.remote_address}") + self._recv_task = asyncio.create_task(self._recv_loop()) + self._heartbeat_task = asyncio.create_task(self._heartbeat_loop()) + + async def disconnect(self): + if self._heartbeat_task: + self._heartbeat_task.cancel() + if self._recv_task: + self._recv_task.cancel() + if self.ws: + await self.ws.close() + self.ws = None + self.logger("[WSS] 已断开") + + async def _recv_loop(self): + try: + async for raw in self.ws: + try: + msg = WssMessage.decode(raw) + self.logger(format_wss_log(msg.body, msg.command, msg.sequence, "收")) + await self._handle_message(msg) + except Exception as e: + self.logger(f"[WSS] 解析消息失败: {e} raw_hex={raw[:50].hex()}") + except asyncio.CancelledError: + pass + except websockets.exceptions.ConnectionClosed as e: + self.logger(f"[WSS] 连接关闭: code={e.code} reason={e.reason}") + except Exception as e: + self.logger(f"[WSS] 接收循环异常: {type(e).__name__}: {e}") + + async def _handle_message(self, msg: WssMessage): + if msg.command == WssCommand.RPC_RESPONSE: + if self.rpc_queue: + future = self.rpc_queue.popleft() + if not future.done(): + future.set_result(msg.body) + else: + self.logger(f"[WSS] 未匹配的 RPC 响应 seq={msg.sequence}") + elif msg.command == WssCommand.HEARTBEAT_RECV: + pass # 心跳已在上层 _recv_loop 打过日志 + elif msg.command in (WssCommand.SERVER_PUSH1, WssCommand.SERVER_PUSH2): + pass # 推送已在上层打过日志 + else: + pass # 未知消息已在上层打过日志 + + async def _heartbeat_loop(self): + try: + while True: + await asyncio.sleep(8) + await self.send_heartbeat() + except asyncio.CancelledError: + pass + except Exception as e: + self.logger(f"[WSS] 心跳循环异常: {e}") + + async def send_heartbeat(self): + # body = LIST(tag0,count=1) + 元素{tag0="live:0", tag1=""} + TAIL + os = TafOutputStream() + os.write_head(0, TafType.LIST) # 09 + os.write_int32(0, 1) # 00 01 (count=1) + os.write_string(0, "live:0") # 06 06 live:0 + os.buf.write(b'\x16\x00') # 16 00 (tag1 "") + body = os.get_bytes() + TAIL_BYTES + msg = WssMessage(command=WssCommand.HEARTBEAT_SEND, + sequence=SEQ_HEARTBEAT, body=body) + self.logger(format_wss_log(body, WssCommand.HEARTBEAT_SEND, SEQ_HEARTBEAT, "发")) + await self.ws.send(msg.encode()) + + async def initialize(self, uid: int, guid: str, cookie: str): + """商城端点初始化: heartbeat → wsLaunch → getConfig → confirm + + 商城 wsLaunch 无 lUid/sGuid (匿名), tReq: + tag0 lUid=0, tag1 sGuid="", tag2 sUA="webh5&0.0.1&websocket&&h5_/index.html", + tag3 sAppSrc="HUYA&ZH&2052", tag4 tDeviceInfo={5空串} + """ + self.logger("[初始化] 开始 WSS 初始化流程 (商城端点)...") + + await self.send_heartbeat() + + launch_rsp = await self.call_ws_launch_shop() + if launch_rsp is None: + self.logger("[✗] wsLaunch 无响应,初始化失败") + return False + self.logger(f"[初始化] wsLaunch OK guid={self._launch_guid} ip={self._launch_ip}") + + config_rsp = await self.call_get_config_shop() + self.logger(f"[初始化] getConfig {'OK' if config_rsp is not None else '无响应(可继续)'}") + + if self._launch_guid: + await self.send_register(self._launch_guid) + + await self.send_confirm() + + await asyncio.sleep(0.3) + self.logger("[初始化] 完成 ✓") + return True + + async def call_ws_launch_shop(self, timeout: float = 10.0): + """商城版 wsLaunch (匿名, 无 lUid/sGuid, 与 conn4 格式一致)""" + ua = "webh5&0.0.1&websocket&&h5_/index.html" + app_src = "HUYA&ZH&2052" + + os = TafOutputStream() + os.write_struct_begin(0) + os.write_int64(0, 0) # lUid = 0 + os.write_string(1, "") # sGuid = "" + os.write_string(2, ua) # sUA + os.write_string(3, app_src) # sAppSrc + os.write_struct_begin(4) # tDeviceInfo + for i in range(5): + os.write_string(i, "") + os.write_struct_end() + os.write_struct_end() + + wup = WupRequest() + wup.setServant("launch") + wup.setFunc("wsLaunch") + wup.setRequestId(-1) + wup.iTimeout = 0 + wup.newdata["tReq"] = os.get_bytes() + wup_data = wup.encode() + body = self._encode_rpc_body(wup_data) + + seq = SEQ_WSLAUNCH + msg = WssMessage(command=WssCommand.RPC_REQUEST, + sequence=seq, body=body) + future = asyncio.Future() + self.rpc_queue.append(future) + self.logger(format_wss_log(body, WssCommand.RPC_REQUEST, seq, "发")) + await self.ws.send(msg.encode()) + + try: + body = await asyncio.wait_for(future, timeout) + except asyncio.TimeoutError: + if future in self.rpc_queue: + self.rpc_queue.remove(future) + self.logger("[✗] wsLaunch 超时") + return None + + self._parse_launch_response(body) + return body + + def _parse_launch_response(self, body: bytes): + """解析 wsLaunch 响应 (WSLaunchRsp: tag0=sGuid, tag1=sClientIp)""" + try: + # 从 body 中提取干净的 WUP 包(去掉尾部垃圾字节) + wup = self._extract_wup(body) + wup_resp = WupResponse() + wup_resp.decode(wup) + treq = wup_resp.newdata.get("tRsp") or wup_resp.newdata.get("tResp") + if not treq: + self.logger(f"[RPC] wsLaunch 响应无 tRsp, keys={list(wup_resp.newdata.keys())}") + return + ins = TafInputStream(treq) + tag, dtype = ins.peek_head() + if dtype != 0x0a: # STRUCT_BEGIN + self.logger(f"[RPC] wsLaunch tRsp 非结构体 dtype=0x{dtype:02x}") + return + ins.read_head() + while True: + try: + ftag, ftype = ins.peek_head() + except EOFError: + break + if ftype == 0x0b: # STRUCT_END + ins.read_head() + break + ins.read_head() + if ftag == 0 and ftype in (0x06, 0x07): + ln = ins.buf.read(1)[0] if ftype == 0x06 else int.from_bytes(ins.buf.read(4), 'big') + self._launch_guid = ins.buf.read(ln).decode('utf-8', 'replace') + elif ftag == 1 and ftype in (0x06, 0x07): + ln = ins.buf.read(1)[0] if ftype == 0x06 else int.from_bytes(ins.buf.read(4), 'big') + self._launch_ip = ins.buf.read(ln).decode('utf-8', 'replace') + else: + ins.skip_field(ftype) + self.logger(f"[RPC] wsLaunch 解析: guid={self._launch_guid} ip={self._launch_ip}") + except Exception as e: + self.logger(f"[RPC] wsLaunch 响应解析失败: {e}") + + @staticmethod + def _extract_wup(body: bytes) -> bytes: + """从 WSS 响应 body 中提取干净的 WUP 包 + + 响应 body 格式: + 小包: [4B BE total_len][wup_body][tail] + 大包: [1B prefix][4B BE wup_body_len][wup_body][tail] + """ + if len(body) < 5: + return body + # 大包格式需优先判断;prefix 可能是 0x00,容易被误判成小包长度 8。 + wup_len = int.from_bytes(body[1:5], 'big') + if 5 + wup_len <= len(body) and body[5:7] == b'\x10\x03': + return body[5:5 + wup_len] + # 尝试 4B total_len 小包格式 + total_len = int.from_bytes(body[0:4], 'big') + if 8 <= total_len <= len(body) and body[4:6] == b'\x10\x03': + # total_len 包含自身 4B + return body[:total_len] + if 5 + wup_len <= len(body): + return body[5:5 + wup_len] + return body + + async def send_auth(self, cookie: str): + """cmd 0x0a AUTH — 发送 cookie""" + ua = "webh5&0.0.1&websocket&&diypc_52775" + auth_text = f"huya_ua={ua}; {cookie}" + msg = WssMessage(command=WssCommand.AUTH, + sequence=SEQ_WSLAUNCH, + body=auth_text.encode('utf-8') + TAIL_BYTES) + await self.ws.send(msg.encode()) + self.logger(format_wss_log(auth_text.encode('utf-8') + TAIL_BYTES, WssCommand.AUTH, SEQ_WSLAUNCH, "发")) + + async def call_get_config_shop(self, timeout: float = 10.0): + """商城版 mobileui.getConfig + + tReq 结构 (HAR 实证): + tag0: UserId {lUid=0, sGuid="", sUA="", sHuYaUA="webh5&0.0.1&websocket&&h5_/index.html", + sToken="", iTokenType=0, sDeviceInfo="", sCookie=""} + tag1: 空Map + tag2: "huya" (sAppId) + tag3: 空List + """ + ua = "webh5&0.0.1&websocket&&h5_/index.html" + + os = TafOutputStream() + os.write_struct_begin(0) + # tag0: UserId (匿名) + os.write_struct_begin(0) + os.write_int64(0, 0) # lUid = 0 + os.write_string(1, "") # sGuid + os.write_string(2, "") # sUA + os.write_string(3, ua) # sHuYaUA + os.write_string(4, "") # sToken + os.write_int32(5, 0) # iTokenType + os.write_string(6, "") # sDeviceInfo + os.write_string(7, "") # sCookie + os.write_struct_end() + os.write_map(1, {}) # tag1: 空Map + os.write_string(2, "huya") # tag2: sAppId + os.write_head(3, TafType.LIST) # tag3: 空List + os.write_int32(0, 0) + os.write_struct_end() + + wup = WupRequest() + wup.setServant("mobileui") + wup.setFunc("getConfig") + wup.setRequestId(7) + wup.iTimeout = 0 + wup.newdata["tReq"] = os.get_bytes() + wup_data = wup.encode() + body = self._encode_rpc_body(wup_data) + + seq = SEQ_GETCONFIG + msg = WssMessage(command=WssCommand.RPC_REQUEST, + sequence=seq, body=body) + future = asyncio.Future() + self.rpc_queue.append(future) + self.logger(format_wss_log(body, WssCommand.RPC_REQUEST, seq, "发")) + await self.ws.send(msg.encode()) + + try: + body = await asyncio.wait_for(future, timeout) + except asyncio.TimeoutError: + if future in self.rpc_queue: + self.rpc_queue.remove(future) + self.logger("[✗] getConfig 超时") + return None + + return body + + async def send_register(self, guid_from_launch: str): + """cmd 0x21 REGISTER — 回传 wsLaunch 的 sGuid + + body = tag0("HUYA&ZH&2052") + tag1(sGuid) + tag2=0 + tag3=0 + + tag6 struct{tag0空Map, tag1空Map, tag3=0} + + tag7空Map + tag8=0 + TAIL + """ + os = TafOutputStream() + os.write_string(0, "HUYA&ZH&2052") + os.write_string(1, guid_from_launch) + os.write_int32(2, 0) + os.write_int32(3, 0) + os.write_struct_begin(6) + os.write_map(0, {}) + os.write_map(1, {}) + os.write_int32(3, 0) + os.write_struct_end() + os.write_map(7, {}) + os.write_int32(8, 0) + body = os.get_bytes() + TAIL_BYTES + msg = WssMessage(command=WssCommand.REGISTER, + sequence=SEQ_REGISTER, body=body) + self.logger(format_wss_log(body, WssCommand.REGISTER, SEQ_REGISTER, "发")) + await self.ws.send(msg.encode()) + + async def send_confirm(self): + """cmd 0x17 CONFIRM_SEND — body = 空Map(tag0) + TAIL""" + os = TafOutputStream() + os.write_map(0, {}) + body = os.get_bytes() + TAIL_BYTES + msg = WssMessage(command=WssCommand.CONFIRM_SEND, + sequence=SEQ_CONFIRM, body=body) + self.logger(format_wss_log(body, WssCommand.CONFIRM_SEND, SEQ_CONFIRM, "发")) + await self.ws.send(msg.encode()) + + async def call_rpc(self, service: str, method: str, + req_struct: TafStruct, rsp_class=None, + timeout: float = 10.0): + seq = self._next_biz_seq() + + wup = WupRequest() + wup.setServant(service) + wup.setFunc(method) + wup.setRequestId(self._next_wup_req_id()) + wup.iTimeout = 0 + wup.writeStruct("tReq", req_struct) + wup_data = wup.encode() + body = self._encode_rpc_body(wup_data) + + msg = WssMessage(command=WssCommand.RPC_REQUEST, + sequence=seq, body=body) + future = asyncio.Future() + self.rpc_queue.append(future) + self.logger(format_wss_log(body, WssCommand.RPC_REQUEST, seq, "发")) + await self.ws.send(msg.encode()) + + try: + body = await asyncio.wait_for(future, timeout) + except asyncio.TimeoutError: + if future in self.rpc_queue: + self.rpc_queue.remove(future) + self.logger(f"[✗] {service}.{method} 超时") + return None + + wup_resp = WupResponse() + wup_resp.decode(self._extract_wup(body)) + # 响应日志已由 _recv_loop 打出,此处只打解码后的业务字段 + for key in ("tRsp", "tResp"): + data = wup_resp.newdata.get(key) + if data and isinstance(data, bytes) and len(data) > 0: + try: + ins = TafInputStream(data) + tag, dtype = ins.peek_head() + if dtype == TafType.STRUCT_BEGIN: + ins.read_head() + decoded = _decode_taf_struct(ins) + self.logger(f"[←] {service}.{method} {key}: {_truncate(decoded)}") + except Exception: + pass + + if rsp_class is None: + return body + + result = wup_resp.readStruct("tRsp", rsp_class) + if result is None: + result = wup_resp.readStruct("tResp", rsp_class) + return result + + async def get_goods_info(self, uid: int, guid: str, cookie: str, + pid: int, spu_id: str, sku_id: int = 0, + game_id: str = "", source_id: str = "yellowcarlist", + scene: int = WSS_SHOP_SCENE): + from .shop_structs import GetGoodsInfoReqV5, GoodsInfoRsp + + req = GetGoodsInfoReqV5() + req.userId = self._build_biz_user(uid, cookie) + req.shopAppInfo = self._build_shop_app(source_id, scene) + req.pid = pid + req.spuId = spu_id + req.skuId = sku_id + req.gameId = game_id + + return await self.call_rpc("shopMiddleUI", "getGoodsInfoV5", + req, GoodsInfoRsp, timeout=15.0) + + async def query_user_order_list(self, uid: int, guid: str, cookie: str, + offset: int = 0, page_size: int = 10, + order_type: int = 1, status: int = 0): + from .shop_structs import QueryUserOrderListReq, QueryUserOrderListRsp + + req = QueryUserOrderListReq() + req.userId = self._build_biz_user(uid, cookie) + req.offset = offset + req.orderType = order_type + req.pageSize = page_size + req.status = status + + return await self.call_rpc("revenueWebUI", "queryUserOrderList", + req, QueryUserOrderListRsp, timeout=15.0) + + async def create_order(self, uid: int, guid: str, cookie: str, + pid: int, spu_id: str, sku_id: int, + item_count: int = 1, source_id: str = "yellowcarlist", + game_id: str = "", scene: int = WSS_SHOP_SCENE, + order_type: int = 6): + from .shop_structs import (CreateOrderReqV5, CreateOrderRsp, + CreateOrderExtraParam, CreateOrderPromotionParam, + CreateOrderAccountParam) + + req = CreateOrderReqV5() + req.userId = self._build_biz_user(uid, cookie) + req.shopAppInfo = self._build_shop_app(source_id, scene) + req.receiveId = 0 + req.pid = pid + req.spuId = spu_id + req.skuId = sku_id + req.itemCount = item_count + req.gameId = game_id or "0" + req.src = scene + req.scene = 0 + req.sourceId = source_id + req.orderType = order_type + req.extraParam = CreateOrderExtraParam() + req.env = self._build_order_env(item_count) + req.orderScene = scene + req.promotionParam = CreateOrderPromotionParam() + req.accountParam = CreateOrderAccountParam() + req.bizType = 5 + req.gameCategoryId = 507 + req.ext = "" + + self.logger(f"[下单] orderType={order_type} (HUYA_VIRTUAL)") + return await self.call_rpc("shopMiddleUI", "createOrderV5", + req, CreateOrderRsp, timeout=15.0) + + async def pay_order_submit(self, uid: int, guid: str, cookie: str, + order_id: int, pay_type: int = 1, + pid: int = 0, source_id: str = "yellowcarlist", + scene: int = WSS_SHOP_SCENE, + item_count: int = 1): + from .shop_structs import PayOrderRes + + os = TafOutputStream() + os.write_struct_begin(0) + + os.write_struct(0, self._build_biz_user(uid, cookie)) + os.write_struct(1, self._build_shop_app(source_id, scene)) + os.write_int64(2, order_id) + os.write_string(3, "Zfb" if pay_type == 1 else str(pay_type)) + os.write_string(4, "QrCode") + callback_url = ( + f"https://m-shop.yaoguo.com/index.html#/consumer/paycallback" + f"?orderId={order_id}&pid={pid}&sourceId={source_id}" + ) + os.write_string(5, callback_url) + os.write_map(6, self._build_order_env(item_count)) + os.write_string(7, "null") + + os.write_struct_end() + + seq = self._next_biz_seq() + + wup = WupRequest() + wup.setServant("shopMiddleUI") + wup.setFunc("payOrderSubmitV5") + wup.setRequestId(self._next_wup_req_id()) + wup.iTimeout = 0 + wup.newdata["tReq"] = os.get_bytes() + wup_data = wup.encode() + body = self._encode_rpc_body(wup_data) + + msg = WssMessage(command=WssCommand.RPC_REQUEST, + sequence=seq, body=body) + future = asyncio.Future() + self.rpc_queue.append(future) + self.logger(format_wss_log(body, WssCommand.RPC_REQUEST, seq, "发")) + await self.ws.send(msg.encode()) + + try: + body = await asyncio.wait_for(future, 15.0) + except asyncio.TimeoutError: + if future in self.rpc_queue: + self.rpc_queue.remove(future) + self.logger("[✗] payOrderSubmitV5 超时") + return None + + wup_resp = WupResponse() + wup_resp.decode(self._extract_wup(body)) + for key in ("tRsp", "tResp"): + data = wup_resp.newdata.get(key) + if data and isinstance(data, bytes) and len(data) > 0: + try: + ins = TafInputStream(data) + tag, dtype = ins.peek_head() + if dtype == TafType.STRUCT_BEGIN: + ins.read_head() + decoded = _decode_taf_struct(ins) + self.logger(f"[←] payOrderSubmitV5 {key}: {_truncate(decoded)}") + except Exception: + pass + result = wup_resp.readStruct("tRsp", PayOrderRes) + if result is None: + result = wup_resp.readStruct("tResp", PayOrderRes) + return result diff --git a/core/huya/wup_protocol.py b/core/huya/wup_protocol.py new file mode 100644 index 0000000..a0651d8 --- /dev/null +++ b/core/huya/wup_protocol.py @@ -0,0 +1,281 @@ +""" +虎牙 Wup 协议 Python 实现 +基于前端 WUP 实现逆向 + +Wup 包结构: + [4字节长度][Wup body] + Wup body = tag1:iVersion, tag2:cPacketType, tag3:iMessageType, + tag4:iRequestId, tag5:sServantName, tag6:sFuncName, + tag7:sBuffer(bytes), tag8:iTimeout, tag9:context(map), tag10:status(map) + sBuffer = Map<"tReq", 编码后的请求结构体> +""" +import struct +from typing import Any, Dict, Optional +from .taf_protocol import TafOutputStream, TafInputStream, TafType, TafStruct + + +class WupRequest: + """Wup 请求对象""" + + def __init__(self): + self.iVersion: int = 3 # tag 1 + self.cPacketType: int = 0 # tag 2 + self.iMessageType: int = 0 # tag 3 + self.iRequestId: int = 0 # tag 4 + self.sServantName: str = "" # tag 5 + self.sFuncName: str = "" # tag 6 + self.sBuffer: bytes = b'' # tag 7 + self.iTimeout: int = 3000 # tag 8 + self.context: Dict[str, str] = {} # tag 9 + self.status: Dict[str, str] = {} # tag 10 + self.newdata: Dict[str, bytes] = {} + + def setServant(self, name: str): + self.sServantName = name + + def setFunc(self, name: str): + self.sFuncName = name + + def setRequestId(self, req_id: int): + self.iRequestId = req_id + + def writeStruct(self, key: str, struct_data): + """ + 写入请求参数到 newdata[key] + + Args: + key: 键名(通常是 "tReq") + struct_data: TafStruct 对象 / dict / list + """ + os = TafOutputStream() + + if isinstance(struct_data, TafStruct) or hasattr(struct_data, 'write_to'): + # 结构体对象:STRUCT_BEGIN + 内容 + STRUCT_END + os.write_struct(0, struct_data) + elif isinstance(struct_data, dict): + os.write_struct_begin(0) + for tag_id, (field_name, field_value) in enumerate(struct_data.items()): + self._write_field(os, tag_id, field_value) + os.write_struct_end() + elif isinstance(struct_data, (list, tuple)): + # wsLaunch 等用 list 参数 + os.write_head(0, TafType.LIST) + os.write_int32(0, len(struct_data)) + for i, item in enumerate(struct_data, start=1): + self._write_field(os, i, item) + else: + raise TypeError(f"不支持的 struct_data 类型: {type(struct_data)}") + + self.newdata[key] = os.get_bytes() + + def _write_field(self, os: TafOutputStream, tag: int, value: Any): + if isinstance(value, bool): + os.write_boolean(tag, value) + elif isinstance(value, int): + os.write_int64(tag, value) + elif isinstance(value, float): + os.write_double(tag, value) + elif isinstance(value, str): + os.write_string(tag, value) + elif isinstance(value, bytes): + os.write_bytes(tag, value) + elif isinstance(value, dict): + os.write_map(tag, value) + elif isinstance(value, (list, tuple)): + os.write_list(tag, list(value)) + elif hasattr(value, 'write_to'): + os.write_struct(tag, value) + else: + raise TypeError(f"不支持的字段类型: {type(value)}") + + def encode(self) -> bytes: + """编码为完整 Wup 包(含长度前缀)""" + # 1. newdata -> Map (tag 0) + data_os = TafOutputStream() + data_os.write_head(0, TafType.MAP) + data_os.write_int32(0, len(self.newdata)) + for k, v in self.newdata.items(): + data_os.write_string(0, k) + data_os.write_bytes(1, v) + self.sBuffer = data_os.get_bytes() + + # 2. Wup 头 + wup_os = TafOutputStream() + wup_os.write_int16(1, self.iVersion) + wup_os.write_int8(2, self.cPacketType) + wup_os.write_int32(3, self.iMessageType) + wup_os.write_int32(4, self.iRequestId) + wup_os.write_string(5, self.sServantName) + wup_os.write_string(6, self.sFuncName) + wup_os.write_bytes(7, self.sBuffer) + wup_os.write_int32(8, self.iTimeout) + wup_os.write_map(9, self.context) + wup_os.write_map(10, self.status) + wup_body = wup_os.get_bytes() + + # 3. 长度前缀 + length = 4 + len(wup_body) + return struct.pack('>I', length) + wup_body + + +def normalize_wup_payload(data: bytes) -> bytes: + """去掉可选的 4 字节 WUP 长度前缀,返回裸 WUP body""" + if len(data) < 4: + return data + declared_len = struct.unpack('>I', data[0:4])[0] + if declared_len == len(data) or declared_len + 4 == len(data): + return data[4:] + return data + + +class WupResponse: + """Wup 响应对象""" + + def __init__(self): + self.iVersion: int = 0 + self.cPacketType: int = 0 + self.iMessageType: int = 0 + self.iRequestId: int = 0 + self.sServantName: str = "" + self.sFuncName: str = "" + self.sBuffer: bytes = b'' + self.iTimeout: int = 0 + self.context: Dict[str, str] = {} + self.status: Dict[str, str] = {} + self.newdata: Dict[str, bytes] = {} + + def decode(self, data: bytes): + """解码响应(不包含长度前缀;若含前缀会自动跳过)""" + data = normalize_wup_payload(data) + + ins = TafInputStream(data) + # 按字段读,直到结束 + try: + while True: + tag, dtype = ins.peek_head() + if tag > 10: + break + if tag == 1: + ins.read_head() + self.iVersion = ins._read_int_value(dtype) + elif tag == 2: + ins.read_head() + self.cPacketType = ins._read_int_value(dtype) + elif tag == 3: + ins.read_head() + self.iMessageType = ins._read_int_value(dtype) + elif tag == 4: + ins.read_head() + self.iRequestId = ins._read_int_value(dtype) + elif tag == 5: + ins.read_head() + self.sServantName = _read_string_value(ins, dtype) + elif tag == 6: + ins.read_head() + self.sFuncName = _read_string_value(ins, dtype) + elif tag == 7: + ins.read_head() + self.sBuffer = _read_bytes_value(ins, dtype) + self._decode_buffer() + elif tag == 8: + ins.read_head() + self.iTimeout = ins._read_int_value(dtype) + elif tag == 9: + # context map + ins.read_head() + self.context = _read_map_value(ins, dtype) + elif tag == 10: + ins.read_head() + self.status = _read_map_value(ins, dtype) + break + else: + ins.read_head() + ins.skip_field(dtype) + except EOFError: + pass + + def _decode_buffer(self): + """解析 sBuffer 里的 newdata Map""" + if not self.sBuffer: + return + ins = TafInputStream(self.sBuffer) + try: + tag, dtype = ins.read_head() + if tag == 0 and dtype == TafType.MAP: + count = ins._read_int_len() + for _ in range(count): + _, kt = ins.read_head() + key = _read_string_value(ins, kt) + _, vt = ins.read_head() + val = _read_bytes_value(ins, vt) + self.newdata[key] = val + except Exception as e: + print(f"[WupResponse] 解析 newdata 失败: {e}") + + def readStruct(self, key: str, struct_class=None): + """ + 读取响应结构体 + + Args: + key: "tRsp" / "tResp" / "tReq"(响应里通常是 tRsp) + struct_class: 结构体类(需实现 read_from),None 则返回原始 bytes + """ + data = self.newdata.get(key) + if not data and key == "tRsp": + data = self.newdata.get("tResp") + if not data and key == "tResp": + data = self.newdata.get("tRsp") + if not data: + return None + + if struct_class is None: + return data + + ins = TafInputStream(data) + # newdata 里的结构体以 STRUCT_BEGIN 开头 + try: + tag, dtype = ins.peek_head() + if dtype == TafType.STRUCT_BEGIN: + ins.read_head() # 消费 STRUCT_BEGIN + obj = struct_class() + obj.read_from(ins) + return obj + except Exception as e: + print(f"[WupResponse] 解析 {struct_class.__name__} 失败: {e}") + return None + + +# ============================================================ +# 辅助:按已知 dtype 读取值 +# ============================================================ + +def _read_string_value(ins: TafInputStream, dtype: int) -> str: + if dtype == TafType.STRING1: + length = struct.unpack('B', ins.buf.read(1))[0] + elif dtype == TafType.STRING4: + length = struct.unpack('>I', ins.buf.read(4))[0] + else: + return "" + return ins.buf.read(length).decode('utf-8', errors='replace') + + +def _read_bytes_value(ins: TafInputStream, dtype: int) -> bytes: + if dtype != TafType.SIMPLE_LIST: + return b'' + ins.read_head() # 元素类型 INT8 + length = ins._read_int_len() + return ins.buf.read(length) + + +def _read_map_value(ins: TafInputStream, dtype: int) -> Dict: + if dtype != TafType.MAP: + return {} + count = ins._read_int_len() + result = {} + for _ in range(count): + _, kt = ins.read_head() + k = _read_string_value(ins, kt) + _, vt = ins.read_head() + v = _read_string_value(ins, vt) if vt in (TafType.STRING1, TafType.STRING4) else "" + result[k] = v + return result diff --git a/deploy.sh b/deploy.sh index ade4b9e..a5b4c38 100755 --- a/deploy.sh +++ b/deploy.sh @@ -163,6 +163,10 @@ cmd_restart() { echo "✅ 服务已重启" } +cmd_dev() { + exec "$ROOT_DIR/dev.sh" +} + # ── 帮助 ────────────────────────────────────────────────── cmd_help() { @@ -179,9 +183,11 @@ cmd_help() { echo " logs 查看实时日志" echo " stop 停止服务" echo " restart 重启服务" + echo " dev 本地调试启动(后端 reload + 前端热更新)" echo " help 显示帮助" echo "" echo " 访问地址: http://localhost:8000" + echo " 调试地址: http://localhost:5173" echo " 默认账号见环境变量 ADMIN_USERNAME/ADMIN_PASSWORD" echo "" } @@ -193,6 +199,7 @@ case "${1:-}" in logs) cmd_logs ;; stop) cmd_stop ;; restart) cmd_restart ;; + dev) cmd_dev ;; help|-h) cmd_help ;; *) cmd_deploy ;; esac diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..64363c3 --- /dev/null +++ b/dev.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ===== 本地调试启动脚本:后端 reload + 前端热更新 ===== + +ROOT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$ROOT_DIR" + +if [ -f "$ROOT_DIR/.env" ]; then + set -a + # 调试模式复用 Docker 部署的环境变量,尤其是 JWT 和敏感字段加密密钥。 + # shellcheck disable=SC1091 + source "$ROOT_DIR/.env" + set +a +fi + +BACKEND_HOST="${BACKEND_HOST:-0.0.0.0}" +BACKEND_PORT="${BACKEND_PORT:-8000}" +FRONTEND_HOST="${FRONTEND_HOST:-0.0.0.0}" +FRONTEND_PORT="${FRONTEND_PORT:-5173}" +LOG_LEVEL="${LOG_LEVEL:-DEBUG}" + +BACKEND_PID="" +FRONTEND_PID="" + +cleanup() { + trap - EXIT INT TERM + if [ -n "$FRONTEND_PID" ] && kill -0 "$FRONTEND_PID" 2>/dev/null; then + kill "$FRONTEND_PID" 2>/dev/null || true + fi + if [ -n "$BACKEND_PID" ] && kill -0 "$BACKEND_PID" 2>/dev/null; then + kill "$BACKEND_PID" 2>/dev/null || true + fi +} + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "缺少命令: $1" + exit 1 + fi +} + +require_cmd uv +require_cmd npm + +mkdir -p data logs + +echo "" +echo "==============================" +echo " 本地调试模式" +echo "==============================" +echo " 后端: http://${BACKEND_HOST}:${BACKEND_PORT}" +echo " 前端: http://localhost:${FRONTEND_PORT}" +echo " API 代理: http://127.0.0.1:${BACKEND_PORT}" +echo " 退出: Ctrl+C" +echo "==============================" +echo "" + +trap cleanup EXIT INT TERM + +LOG_LEVEL="$LOG_LEVEL" \ +uv run uvicorn web.backend.main:app \ + --host "$BACKEND_HOST" \ + --port "$BACKEND_PORT" \ + --reload \ + --reload-dir core \ + --reload-dir utils \ + --reload-dir web/backend & +BACKEND_PID=$! + +( + cd web/frontend + VITE_BACKEND_TARGET="http://127.0.0.1:${BACKEND_PORT}" \ + npm run dev -- --host "$FRONTEND_HOST" --port "$FRONTEND_PORT" +) & +FRONTEND_PID=$! + +while true; do + if ! kill -0 "$BACKEND_PID" 2>/dev/null; then + wait "$BACKEND_PID" + exit $? + fi + if ! kill -0 "$FRONTEND_PID" 2>/dev/null; then + wait "$FRONTEND_PID" + exit $? + fi + sleep 1 +done diff --git a/pyproject.toml b/pyproject.toml index 2c67d15..3d19291 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "pydantic>=2.0.0", "python-multipart>=0.0.9", "alembic>=1.18.4", + "websockets>=16.0", ] [build-system] diff --git a/uv.lock b/uv.lock index cacbb5c..6c9542b 100644 --- a/uv.lock +++ b/uv.lock @@ -219,6 +219,7 @@ dependencies = [ { name = "requests", extra = ["socks"] }, { name = "sqlalchemy" }, { name = "uvicorn", extra = ["standard"] }, + { name = "websockets" }, ] [package.metadata] @@ -237,6 +238,7 @@ requires-dist = [ { name = "requests", extras = ["socks"], specifier = ">=2.31.0" }, { name = "sqlalchemy", specifier = ">=2.0.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.27.0" }, + { name = "websockets", specifier = ">=16.0" }, ] [[package]] diff --git a/web/backend/main.py b/web/backend/main.py index ef66d89..d809224 100644 --- a/web/backend/main.py +++ b/web/backend/main.py @@ -11,7 +11,7 @@ from fastapi.responses import FileResponse from starlette.middleware.base import BaseHTTPMiddleware from .database import init_db -from .routers import auth, users, accounts, login, proxy, cookies +from .routers import auth, users, accounts, login, proxy, cookies, huya from .schemas import AppInfo from .version import get_app_version from utils import setup_logger @@ -69,6 +69,7 @@ app.include_router(accounts.router) app.include_router(login.router) app.include_router(proxy.router) app.include_router(cookies.router) +app.include_router(huya.router) @app.get("/api/health") diff --git a/web/backend/migrations/versions/20260704_0004_huya_foundation.py b/web/backend/migrations/versions/20260704_0004_huya_foundation.py new file mode 100644 index 0000000..98eb37e --- /dev/null +++ b/web/backend/migrations/versions/20260704_0004_huya_foundation.py @@ -0,0 +1,127 @@ +"""新增虎牙基础数据表 + +Revision ID: 20260704_0004 +Revises: 20260624_0003 +Create Date: 2026-07-04 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260704_0004" +down_revision: Union[str, None] = "20260624_0003" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _has_table(bind, table_name: str) -> bool: + return sa.inspect(bind).has_table(table_name) + + +def _indexes(bind, table_name: str) -> set[str]: + if not _has_table(bind, table_name): + return set() + return {index["name"] for index in sa.inspect(bind).get_indexes(table_name)} + + +def _create_index_if_missing(bind, name: str, table_name: str, columns: list[str], unique: bool = False) -> None: + if name not in _indexes(bind, table_name): + op.create_index(name, table_name, columns, unique=unique) + + +def upgrade() -> None: + bind = op.get_bind() + + if not _has_table(bind, "huya_accounts"): + op.create_table( + "huya_accounts", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("uid", sa.String(length=32), nullable=True), + sa.Column("yyuid", sa.String(length=32), nullable=True), + sa.Column("username", sa.String(length=128), nullable=True), + sa.Column("nickname", sa.String(length=128), nullable=True), + sa.Column("cookie", sa.Text(), nullable=False), + sa.Column("tag", sa.String(length=64), nullable=True), + sa.Column("remark", sa.String(length=256), nullable=True), + sa.Column("status", sa.String(length=32), nullable=True), + sa.Column("points", sa.Integer(), nullable=True), + sa.Column("game_name", sa.String(length=128), nullable=True), + sa.Column("game_channel", sa.String(length=64), nullable=True), + sa.Column("game_phone", sa.String(length=64), nullable=True), + sa.Column("assigned_to", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["assigned_to"], ["users.id"]), + sa.PrimaryKeyConstraint("id"), + ) + _create_index_if_missing(bind, "ix_huya_accounts_uid", "huya_accounts", ["uid"]) + _create_index_if_missing(bind, "ix_huya_accounts_yyuid", "huya_accounts", ["yyuid"]) + _create_index_if_missing(bind, "ix_huya_accounts_assigned_to", "huya_accounts", ["assigned_to"]) + + if not _has_table(bind, "huya_tasks"): + op.create_table( + "huya_tasks", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("batch_id", sa.String(length=64), nullable=False), + sa.Column("account_id", sa.Integer(), nullable=False), + sa.Column("task_type", sa.String(length=64), nullable=False), + sa.Column("status", sa.String(length=32), nullable=True), + sa.Column("message", sa.String(length=512), nullable=True), + sa.Column("result", sa.JSON(), nullable=True), + sa.Column("created_by", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("finished_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["account_id"], ["huya_accounts.id"]), + sa.ForeignKeyConstraint(["created_by"], ["users.id"]), + sa.PrimaryKeyConstraint("id"), + ) + _create_index_if_missing(bind, "ix_huya_tasks_batch_id", "huya_tasks", ["batch_id"]) + _create_index_if_missing(bind, "ix_huya_tasks_task_type", "huya_tasks", ["task_type"]) + + if not _has_table(bind, "huya_config"): + op.create_table( + "huya_config", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("room_pid", sa.String(length=64), nullable=True), + sa.Column("sid", sa.String(length=32), nullable=True), + sa.Column("outer_act_id", sa.String(length=32), nullable=True), + sa.Column("bind_act_id", sa.String(length=32), nullable=True), + sa.Column("pay_channel", sa.String(length=16), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + + if not _has_table(bind, "huya_goods_snapshot"): + op.create_table( + "huya_goods_snapshot", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("product_id", sa.String(length=64), nullable=False), + sa.Column("name", sa.String(length=256), nullable=True), + sa.Column("price", sa.Integer(), nullable=True), + sa.Column("remain_text", sa.String(length=64), nullable=True), + sa.Column("raw", sa.JSON(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + _create_index_if_missing(bind, "ix_huya_goods_snapshot_product_id", "huya_goods_snapshot", ["product_id"]) + + +def downgrade() -> None: + bind = op.get_bind() + if _has_table(bind, "huya_goods_snapshot"): + op.drop_index("ix_huya_goods_snapshot_product_id", table_name="huya_goods_snapshot") + op.drop_table("huya_goods_snapshot") + if _has_table(bind, "huya_config"): + op.drop_table("huya_config") + if _has_table(bind, "huya_tasks"): + op.drop_index("ix_huya_tasks_task_type", table_name="huya_tasks") + op.drop_index("ix_huya_tasks_batch_id", table_name="huya_tasks") + op.drop_table("huya_tasks") + if _has_table(bind, "huya_accounts"): + op.drop_index("ix_huya_accounts_assigned_to", table_name="huya_accounts") + op.drop_index("ix_huya_accounts_yyuid", table_name="huya_accounts") + op.drop_index("ix_huya_accounts_uid", table_name="huya_accounts") + op.drop_table("huya_accounts") diff --git a/web/backend/models.py b/web/backend/models.py index 2c41ea3..5898801 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -30,6 +30,7 @@ class User(Base): # 客服被分配的账号 assigned_accounts = relationship("Account", back_populates="assigned_user", foreign_keys="Account.assigned_to") + huya_accounts = relationship("HuyaAccount", back_populates="assigned_user", foreign_keys="HuyaAccount.assigned_to") class Account(Base): @@ -70,6 +71,75 @@ class LoginTask(Base): account = relationship("Account", back_populates="login_tasks") +class HuyaAccount(Base): + """虎牙 Cookie 账号""" + __tablename__ = "huya_accounts" + + id = Column(Integer, primary_key=True, autoincrement=True) + uid = Column(String(32), default="", index=True) + yyuid = Column(String(32), default="", index=True) + username = Column(String(128), default="") + nickname = Column(String(128), default="") + cookie = Column(EncryptedText(), nullable=False) + tag = Column(String(64), default="") + remark = Column(String(256), default="") + status = Column(String(32), default="imported") + points = Column(Integer, nullable=True) + game_name = Column(String(128), default="") + game_channel = Column(String(64), default="") + game_phone = Column(String(64), default="") + assigned_to = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) + created_at = Column(DateTime, default=_utcnow) + updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow) + + assigned_user = relationship("User", back_populates="huya_accounts", foreign_keys=[assigned_to]) + tasks = relationship("HuyaTask", back_populates="account") + + +class HuyaTask(Base): + """虎牙业务任务""" + __tablename__ = "huya_tasks" + + id = Column(Integer, primary_key=True, autoincrement=True) + batch_id = Column(String(64), nullable=False, index=True) + account_id = Column(Integer, ForeignKey("huya_accounts.id"), nullable=False) + task_type = Column(String(64), nullable=False, index=True) + status = Column(String(32), default="pending") + message = Column(String(512), default="") + result = Column(JSON, nullable=True) + created_by = Column(Integer, ForeignKey("users.id"), nullable=False) + created_at = Column(DateTime, default=_utcnow) + finished_at = Column(DateTime, nullable=True) + + account = relationship("HuyaAccount", back_populates="tasks") + + +class HuyaConfig(Base): + """虎牙业务配置""" + __tablename__ = "huya_config" + + id = Column(Integer, primary_key=True, autoincrement=True) + room_pid = Column(String(64), default="") + sid = Column(String(32), default="") + outer_act_id = Column(String(32), default="9504") + bind_act_id = Column(String(32), default="17096") + pay_channel = Column(String(16), default="Zfb") + updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow) + + +class HuyaGoodsSnapshot(Base): + """虎牙兑换商品快照""" + __tablename__ = "huya_goods_snapshot" + + id = Column(Integer, primary_key=True, autoincrement=True) + product_id = Column(String(64), nullable=False, index=True) + name = Column(String(256), default="") + price = Column(Integer, nullable=True) + remain_text = Column(String(64), default="") + raw = Column(JSON, nullable=True) + updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow) + + class ProxyConfig(Base): """代理配置(全局单条记录)""" __tablename__ = "proxy_config" diff --git a/web/backend/permissions.py b/web/backend/permissions.py index 10048fd..451a93f 100644 --- a/web/backend/permissions.py +++ b/web/backend/permissions.py @@ -22,6 +22,12 @@ PERMISSIONS = { # Cookie "cookie:view": "查看 Cookie", "cookie:export": "导出 Cookie", + # 虎牙 + "huya:account": "虎牙 CK 管理", + "huya:task": "虎牙任务管理", + "huya:bind": "虎牙绑定操作", + "huya:recharge": "虎牙充值操作", + "huya:config": "虎牙配置管理", # 代理 & 白名单 "proxy:manage": "代理配置管理", "whitelist:manage": "白名单配置管理", @@ -42,6 +48,11 @@ ROLE_PERMISSIONS = { "login:view_all", "cookie:view", "cookie:export", + "huya:account", + "huya:task", + "huya:bind", + "huya:recharge", + "huya:config", "proxy:manage", "whitelist:manage", "whitelist:test", diff --git a/web/backend/routers/huya.py b/web/backend/routers/huya.py new file mode 100644 index 0000000..3459425 --- /dev/null +++ b/web/backend/routers/huya.py @@ -0,0 +1,250 @@ +"""虎牙基础管理路由""" + +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket +from sqlalchemy.orm import Session, joinedload + +from ..database import get_db +from ..deps import authenticate_websocket, require_permission +from ..models import HuyaAccount, HuyaConfig, HuyaGoodsSnapshot, HuyaTask, User +from ..schemas import ( + HuyaAccountOut, + HuyaConfigOut, + HuyaConfigUpdate, + HuyaCookieImport, + HuyaGoodsOut, + HuyaTaskBatchRequest, + HuyaTaskOut, +) +from ..services.huya_service import ( + SUPPORTED_TASK_TYPES, + create_planned_tasks, + ensure_huya_config, + import_huya_cookies, +) + + +router = APIRouter(prefix="/api/huya", tags=["虎牙"]) + + +def _fmt_cookie_preview(cookie: str) -> str: + if not cookie: + return "" + return cookie[:50] + "..." if len(cookie) > 50 else cookie + + +def _account_out(account: HuyaAccount) -> HuyaAccountOut: + cookie = account.cookie or "" + return HuyaAccountOut( + id=account.id, + uid=account.uid or "", + yyuid=account.yyuid or "", + username=account.username or "", + nickname=account.nickname or "", + cookie=cookie, + cookie_preview=_fmt_cookie_preview(cookie), + tag=account.tag or "", + remark=account.remark or "", + status=account.status or "", + points=account.points, + game_name=account.game_name or "", + game_channel=account.game_channel or "", + game_phone=account.game_phone or "", + assigned_to=account.assigned_to, + assigned_username=account.assigned_user.username if account.assigned_user else None, + created_at=account.created_at, + updated_at=account.updated_at, + ) + + +def _task_out(task: HuyaTask) -> HuyaTaskOut: + account = task.account + return HuyaTaskOut( + id=task.id, + batch_id=task.batch_id, + account_id=task.account_id, + account_uid=account.uid if account else "", + account_nickname=account.nickname if account else "", + task_type=task.task_type, + status=task.status or "", + message=task.message or "", + result=task.result, + created_by=task.created_by, + created_at=task.created_at, + finished_at=task.finished_at, + ) + + +@router.get("/task-types") +def task_types(current: User = Depends(require_permission("huya:task"))): + """返回当前规划的虎牙任务类型。""" + return SUPPORTED_TASK_TYPES + + +@router.get("/accounts", response_model=list[HuyaAccountOut]) +def list_accounts( + tag: str | None = Query(None), + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:account")), +): + """查看虎牙 CK 账号。""" + query = db.query(HuyaAccount).options(joinedload(HuyaAccount.assigned_user)) + if tag: + query = query.filter(HuyaAccount.tag == tag) + accounts = query.order_by(HuyaAccount.id.desc()).all() + return [_account_out(account) for account in accounts] + + +@router.post("/accounts/import-cookies") +def import_cookies( + req: HuyaCookieImport, + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:account")), +): + """粘贴并导入虎牙 Cookie。""" + count, skipped = import_huya_cookies(db, req.text, req.tag) + return { + "message": f"导入/更新 {count} 条,跳过 {skipped} 条", + "success": True, + "count": count, + "skipped": skipped, + } + + +@router.delete("/accounts/batch") +def delete_accounts_batch( + account_ids: str = Query(..., description="逗号分隔的虎牙账号ID"), + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:account")), +): + """批量删除虎牙 CK 账号及任务记录。""" + ids = [int(x) for x in account_ids.split(",") if x.strip().isdigit()] + if not ids: + raise HTTPException(status_code=400, detail="无效的账号ID") + db.query(HuyaTask).filter(HuyaTask.account_id.in_(ids)).delete(synchronize_session=False) + deleted = db.query(HuyaAccount).filter(HuyaAccount.id.in_(ids)).delete(synchronize_session=False) + db.commit() + return {"message": f"已删除 {deleted} 个虎牙账号", "deleted": deleted, "success": True} + + +@router.delete("/accounts/{account_id}") +def delete_account( + account_id: int, + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:account")), +): + """删除单个虎牙 CK 账号。""" + account = db.query(HuyaAccount).filter(HuyaAccount.id == account_id).first() + if not account: + raise HTTPException(status_code=404, detail="账号不存在") + db.query(HuyaTask).filter(HuyaTask.account_id == account_id).delete(synchronize_session=False) + db.delete(account) + db.commit() + return {"message": "已删除", "success": True} + + +@router.get("/config", response_model=HuyaConfigOut) +def get_config( + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:config")), +): + """获取虎牙配置。""" + config = ensure_huya_config(db) + return HuyaConfigOut( + room_pid=config.room_pid or "", + sid=config.sid or "", + outer_act_id=config.outer_act_id or "9504", + bind_act_id=config.bind_act_id or "17096", + pay_channel=config.pay_channel or "Zfb", + updated_at=config.updated_at, + ) + + +@router.put("/config", response_model=HuyaConfigOut) +def update_config( + req: HuyaConfigUpdate, + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:config")), +): + """更新虎牙配置。""" + config = ensure_huya_config(db) + for field in ("room_pid", "sid", "outer_act_id", "bind_act_id", "pay_channel"): + value = getattr(req, field) + if value is not None: + setattr(config, field, value.strip()) + config.updated_at = datetime.now(timezone.utc) + db.commit() + db.refresh(config) + return HuyaConfigOut( + room_pid=config.room_pid or "", + sid=config.sid or "", + outer_act_id=config.outer_act_id or "9504", + bind_act_id=config.bind_act_id or "17096", + pay_channel=config.pay_channel or "Zfb", + updated_at=config.updated_at, + ) + + +@router.get("/goods", response_model=list[HuyaGoodsOut]) +def list_goods( + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:task")), +): + """查看已缓存的虎牙商品快照。""" + rows = db.query(HuyaGoodsSnapshot).order_by(HuyaGoodsSnapshot.updated_at.desc()).all() + return rows + + +@router.post("/tasks/batch") +def create_task_batch( + req: HuyaTaskBatchRequest, + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:task")), +): + """创建虎牙任务记录,真实执行器后续接入。""" + if not req.account_ids: + raise HTTPException(status_code=400, detail="请选择虎牙账号") + try: + batch_id, count = create_planned_tasks( + db, + req.account_ids, + req.task_type, + current.id, + req.payload, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if count == 0: + raise HTTPException(status_code=400, detail="没有有效的虎牙账号") + return {"batch_id": batch_id, "count": count, "success": True} + + +@router.get("/tasks", response_model=list[HuyaTaskOut]) +def list_tasks( + batch_id: str | None = None, + db: Session = Depends(get_db), + current: User = Depends(require_permission("huya:task")), +): + """查看虎牙任务记录。""" + query = db.query(HuyaTask).options(joinedload(HuyaTask.account)) + if batch_id: + query = query.filter(HuyaTask.batch_id == batch_id) + tasks = query.order_by(HuyaTask.id.desc()).limit(300).all() + return [_task_out(task) for task in tasks] + + +@router.websocket("/ws/{batch_id}") +async def ws_huya_logs(websocket: WebSocket, batch_id: str): + """虎牙实时日志占位通道。""" + user = authenticate_websocket(websocket) + if not user: + await websocket.close(code=1008, reason="未授权") + return + await websocket.accept() + await websocket.send_json({ + "level": "warning", + "message": f"虎牙批次 {batch_id} 已创建,真实执行器尚未接入", + }) + await websocket.send_json({"level": "result", "message": ""}) + await websocket.close() diff --git a/web/backend/schemas.py b/web/backend/schemas.py index c38beb8..2abab0e 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -160,6 +160,152 @@ class LoginTaskOut(BaseModel): } +# ---- 虎牙 ---- +class HuyaCookieImport(BaseModel): + """批量导入虎牙 Cookie。支持纯 CK 或 账号----密码----CK。""" + text: str + tag: str = "" + + +class HuyaAccountOut(BaseModel): + id: int + uid: str = "" + yyuid: str = "" + username: str = "" + nickname: str = "" + cookie: str = "" + cookie_preview: str = "" + tag: str = "" + remark: str = "" + status: str = "" + points: Optional[int] = None + game_name: str = "" + game_channel: str = "" + game_phone: str = "" + assigned_to: Optional[int] = None + assigned_username: Optional[str] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + model_config = ConfigDict(from_attributes=True) + + @model_serializer + def _serialize(self) -> dict[str, Any]: + return { + "id": self.id, + "uid": self.uid, + "yyuid": self.yyuid, + "username": self.username, + "nickname": self.nickname, + "cookie": self.cookie, + "cookie_preview": self.cookie_preview, + "tag": self.tag, + "remark": self.remark, + "status": self.status, + "points": self.points, + "game_name": self.game_name, + "game_channel": self.game_channel, + "game_phone": self.game_phone, + "assigned_to": self.assigned_to, + "assigned_username": self.assigned_username, + "created_at": _ensure_tz(self.created_at).isoformat() if self.created_at else None, + "updated_at": _ensure_tz(self.updated_at).isoformat() if self.updated_at else None, + } + + +class HuyaConfigOut(BaseModel): + room_pid: str = "" + sid: str = "" + outer_act_id: str = "9504" + bind_act_id: str = "17096" + pay_channel: str = "Zfb" + updated_at: Optional[datetime] = None + + @model_serializer + def _serialize(self) -> dict[str, Any]: + return { + "room_pid": self.room_pid, + "sid": self.sid, + "outer_act_id": self.outer_act_id, + "bind_act_id": self.bind_act_id, + "pay_channel": self.pay_channel, + "updated_at": _ensure_tz(self.updated_at).isoformat() if self.updated_at else None, + } + + +class HuyaConfigUpdate(BaseModel): + room_pid: Optional[str] = None + sid: Optional[str] = None + outer_act_id: Optional[str] = None + bind_act_id: Optional[str] = None + pay_channel: Optional[str] = None + + +class HuyaTaskBatchRequest(BaseModel): + account_ids: list[int] + task_type: str + concurrency: int = 3 + payload: dict[str, Any] = Field(default_factory=dict) + + +class HuyaTaskOut(BaseModel): + id: int + batch_id: str + account_id: int + account_uid: str = "" + account_nickname: str = "" + task_type: str + status: str + message: str = "" + result: Optional[dict[str, Any]] = None + created_by: int + created_at: Optional[datetime] = None + finished_at: Optional[datetime] = None + + model_config = ConfigDict(from_attributes=True) + + @model_serializer + def _serialize(self) -> dict[str, Any]: + return { + "id": self.id, + "batch_id": self.batch_id, + "account_id": self.account_id, + "account_uid": self.account_uid, + "account_nickname": self.account_nickname, + "task_type": self.task_type, + "status": self.status, + "message": self.message, + "result": self.result, + "created_by": self.created_by, + "created_at": _ensure_tz(self.created_at).isoformat() if self.created_at else None, + "finished_at": _ensure_tz(self.finished_at).isoformat() if self.finished_at else None, + } + + +class HuyaGoodsOut(BaseModel): + id: int + product_id: str + name: str = "" + price: Optional[int] = None + remain_text: str = "" + raw: Optional[dict[str, Any]] = None + updated_at: Optional[datetime] = None + + model_config = ConfigDict(from_attributes=True) + + @model_serializer + def _serialize(self) -> dict[str, Any]: + return { + "id": self.id, + "product_id": self.product_id, + "name": self.name, + "price": self.price, + "remain_text": self.remain_text, + "raw": self.raw, + "updated_at": _ensure_tz(self.updated_at).isoformat() if self.updated_at else None, + } + + # ---- 代理配置 ---- class ProxyConfigOut(BaseModel): enabled: bool = False diff --git a/web/backend/services/huya_service.py b/web/backend/services/huya_service.py new file mode 100644 index 0000000..d941544 --- /dev/null +++ b/web/backend/services/huya_service.py @@ -0,0 +1,161 @@ +"""虎牙基础业务服务。""" + +import re +import uuid +from datetime import datetime, timezone + +from sqlalchemy.orm import Session + +from ..models import HuyaAccount, HuyaConfig, HuyaTask + + +SUPPORTED_TASK_TYPES = { + "get_bind_qr": "获取绑定二维码", + "query_points": "一键查询积分", + "open_elite_book": "开通精英宝典", + "recharge_points": "充值积分", + "query_game_name": "一键查询游戏名", + "query_exchange_records": "一键查询兑换记录", + "confirm_bind": "确认绑定", + "refresh_goods": "刷新商品列表", +} + + +def cookie_value(cookie: str, key: str) -> str: + """从 Cookie 文本中提取指定 key。""" + match = re.search(rf"(?:^|;\s*){re.escape(key)}=([^;]+)", cookie or "") + return match.group(1).strip() if match else "" + + +def _looks_like_huya_cookie(value: str) -> bool: + """判断文本是否像虎牙 Cookie。""" + return "udb_" in value or "yyuid=" in value + + +def parse_huya_cookie_line(line: str) -> dict | None: + """解析单行虎牙 CK,兼容纯 CK、账号----密码----CK、CK----手机号。""" + raw = (line or "").strip() + if not raw: + return None + + parts = [part.strip() for part in raw.split("----")] + username_hint = "" + game_phone = "" + + if len(parts) == 1: + cookie = raw + elif _looks_like_huya_cookie(parts[0]): + cookie = parts[0] + game_phone = parts[1] if len(parts) >= 2 else "" + elif _looks_like_huya_cookie(parts[-1]): + cookie = parts[-1] + username_hint = parts[0] + else: + return None + + if not _looks_like_huya_cookie(cookie): + return None + + yyuid = cookie_value(cookie, "yyuid") + uid = cookie_value(cookie, "udb_uid") or yyuid + username = cookie_value(cookie, "udb_passport") or cookie_value(cookie, "username") + if not username and username_hint: + username = username_hint + + if not uid and not yyuid: + return None + + return { + "uid": uid, + "yyuid": yyuid or uid, + "username": username or uid or yyuid, + "cookie": cookie, + "game_phone": game_phone, + } + + +def import_huya_cookies(db: Session, text: str, tag: str = "") -> tuple[int, int]: + """导入虎牙 Cookie,返回 (成功数, 跳过数)。""" + created_or_updated = 0 + skipped = 0 + tag = (tag or "").strip() + + for line in (text or "").splitlines(): + parsed = parse_huya_cookie_line(line) + if not parsed: + skipped += 1 + continue + + account = None + if parsed["uid"]: + account = db.query(HuyaAccount).filter(HuyaAccount.uid == parsed["uid"]).first() + if account is None and parsed["yyuid"]: + account = db.query(HuyaAccount).filter(HuyaAccount.yyuid == parsed["yyuid"]).first() + + if account is None: + account = HuyaAccount( + uid=parsed["uid"], + yyuid=parsed["yyuid"], + username=parsed["username"], + cookie=parsed["cookie"], + game_phone=parsed["game_phone"], + tag=tag, + status="imported", + ) + db.add(account) + else: + account.uid = parsed["uid"] or account.uid + account.yyuid = parsed["yyuid"] or account.yyuid + account.username = parsed["username"] or account.username + account.cookie = parsed["cookie"] + account.game_phone = parsed["game_phone"] or account.game_phone + if tag: + account.tag = tag + account.status = "updated" + account.updated_at = datetime.now(timezone.utc) + + created_or_updated += 1 + + if created_or_updated: + db.commit() + return created_or_updated, skipped + + +def ensure_huya_config(db: Session) -> HuyaConfig: + """获取单条虎牙配置,不存在则创建。""" + config = db.query(HuyaConfig).first() + if config: + return config + config = HuyaConfig() + db.add(config) + db.commit() + db.refresh(config) + return config + + +def create_planned_tasks( + db: Session, + account_ids: list[int], + task_type: str, + created_by: int, + payload: dict | None = None, +) -> tuple[str, int]: + """创建虎牙任务记录,真实执行器后续接入。""" + if task_type not in SUPPORTED_TASK_TYPES: + raise ValueError("不支持的任务类型") + + batch_id = uuid.uuid4().hex[:12] + payload = payload or {} + accounts = db.query(HuyaAccount).filter(HuyaAccount.id.in_(account_ids)).all() + for account in accounts: + db.add(HuyaTask( + batch_id=batch_id, + account_id=account.id, + task_type=task_type, + status="planned", + message="任务已创建,等待虎牙执行器接入", + result={"payload": payload} if payload else None, + created_by=created_by, + )) + db.commit() + return batch_id, len(accounts) diff --git a/web/frontend/src/App.tsx b/web/frontend/src/App.tsx index 640dccc..c4361fc 100644 --- a/web/frontend/src/App.tsx +++ b/web/frontend/src/App.tsx @@ -11,6 +11,8 @@ import LoginTasksPage from './pages/LoginTasksPage'; import ProxyPage from './pages/ProxyPage'; import UsersPage from './pages/UsersPage'; import CookiePage from './pages/CookiePage'; +import HuyaAccountsPage from './pages/HuyaAccountsPage'; +import HuyaTasksPage from './pages/HuyaTasksPage'; import { getUser } from './store/auth'; import { ThemeProvider } from './store/theme'; import { useTheme } from './store/useTheme'; @@ -44,6 +46,8 @@ function AppContent() { } /> } /> } /> + } /> + } /> } /> } /> diff --git a/web/frontend/src/api/huya.ts b/web/frontend/src/api/huya.ts new file mode 100644 index 0000000..7dbe9c2 --- /dev/null +++ b/web/frontend/src/api/huya.ts @@ -0,0 +1,30 @@ +import api from './client'; +import type { + HuyaAccountItem, + HuyaConfig, + HuyaCookieImportResult, + HuyaGoodsItem, + HuyaTaskBatchRequest, + HuyaTaskBatchResult, + HuyaTaskItem, + MessageDeletedResponse, + MessageResponse, +} from './types'; + +export const huyaApi = { + taskTypes: () => api.get, Record>('/huya/task-types'), + listAccounts: (params?: { tag?: string }) => + api.get('/huya/accounts', { params }), + importCookies: (text: string, tag: string = '') => + api.post('/huya/accounts/import-cookies', { text, tag }), + deleteAccount: (id: number) => api.delete(`/huya/accounts/${id}`), + deleteAccounts: (accountIds: number[]) => + api.delete('/huya/accounts/batch', { params: { account_ids: accountIds.join(',') } }), + getConfig: () => api.get('/huya/config'), + updateConfig: (data: Partial) => api.put('/huya/config', data), + listGoods: () => api.get('/huya/goods'), + createTasks: (data: HuyaTaskBatchRequest) => + api.post('/huya/tasks/batch', data), + listTasks: (batchId?: string) => + api.get('/huya/tasks', { params: batchId ? { batch_id: batchId } : {} }), +}; diff --git a/web/frontend/src/api/modules.ts b/web/frontend/src/api/modules.ts index b8b1b07..db7076c 100644 --- a/web/frontend/src/api/modules.ts +++ b/web/frontend/src/api/modules.ts @@ -3,6 +3,7 @@ export { accountApi } from './accounts'; export { appApi } from './app'; export { authApi } from './auth'; export { cookieApi } from './cookies'; +export { huyaApi } from './huya'; export { loginApi } from './login'; export { proxyApi } from './proxy'; export { userApi } from './users'; diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index de18851..646f6f8 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -109,6 +109,80 @@ export interface CookieItem { account_password: string; } +// ==================== Huya ==================== + +export interface HuyaAccountItem { + id: number; + uid: string; + yyuid: string; + username: string; + nickname: string; + cookie: string; + cookie_preview: string; + tag: string; + remark: string; + status: string; + points: number | null; + game_name: string; + game_channel: string; + game_phone: string; + assigned_to: number | null; + assigned_username: string | null; + created_at: string | null; + updated_at: string | null; +} + +export interface HuyaCookieImportResult extends MessageCountResponse { + skipped: number; +} + +export interface HuyaConfig { + room_pid: string; + sid: string; + outer_act_id: string; + bind_act_id: string; + pay_channel: string; + updated_at: string | null; +} + +export interface HuyaTaskBatchRequest { + account_ids: number[]; + task_type: string; + concurrency?: number; + payload?: Record; +} + +export interface HuyaTaskBatchResult { + batch_id: string; + count: number; + success: boolean; +} + +export interface HuyaTaskItem { + id: number; + batch_id: string; + account_id: number; + account_uid: string; + account_nickname: string; + task_type: string; + status: string; + message: string; + result: Record | null; + created_by: number; + created_at: string | null; + finished_at: string | null; +} + +export interface HuyaGoodsItem { + id: number; + product_id: string; + name: string; + price: number | null; + remain_text: string; + raw: Record | null; + updated_at: string | null; +} + // ==================== Proxy ==================== export interface ProxyConfig { diff --git a/web/frontend/src/layouts/MainLayout.tsx b/web/frontend/src/layouts/MainLayout.tsx index 9c41f91..0d400f2 100644 --- a/web/frontend/src/layouts/MainLayout.tsx +++ b/web/frontend/src/layouts/MainLayout.tsx @@ -4,7 +4,7 @@ import { DashboardOutlined, UserOutlined, LogoutOutlined, CloudServerOutlined, TeamOutlined, ApiOutlined, KeyOutlined, MenuFoldOutlined, MenuUnfoldOutlined, SwapOutlined, - SunOutlined, MoonOutlined, DesktopOutlined, + SunOutlined, MoonOutlined, DesktopOutlined, GiftOutlined, ShoppingCartOutlined, } from '@ant-design/icons'; import { useNavigate, useLocation, Outlet } from 'react-router-dom'; import { getUser, clearAuth, type AuthUser } from '../store/auth'; @@ -69,6 +69,16 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) { menuItems.push({ key: '/cookies', label: 'Cookie 管理', icon: }); } + // 虎牙 CK 管理 + if (can('huya:account')) { + menuItems.push({ key: '/huya/accounts', label: '虎牙 CK', icon: }); + } + + // 虎牙兑换与充值 + if (can('huya:task')) { + menuItems.push({ key: '/huya/tasks', label: '虎牙任务', icon: }); + } + // 代理配置 if (can('proxy:manage')) { menuItems.push({ key: '/proxy', label: '代理配置', icon: }); diff --git a/web/frontend/src/pages/HuyaAccountsPage.tsx b/web/frontend/src/pages/HuyaAccountsPage.tsx new file mode 100644 index 0000000..840d0d9 --- /dev/null +++ b/web/frontend/src/pages/HuyaAccountsPage.tsx @@ -0,0 +1,315 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Button, Card, Col, Input, message, Modal, Popconfirm, Row, Space, Statistic, Table, Tag, Typography, +} from 'antd'; +import type { TableProps } from 'antd'; +import { DeleteOutlined, ImportOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'; +import { huyaApi, type HuyaAccountItem } from '../api/modules'; +import { usePermissions } from '../hooks/usePermissions'; +import { formatTime } from '../utils/time'; +import { getErrorMessage } from '../utils/error'; + +const { Text, Paragraph } = Typography; +const { TextArea } = Input; + +const STATUS_LABELS: Record = { + imported: '已导入', + updated: '已更新', + active: '正常', + invalid: '失效', +}; + +const STATUS_COLORS: Record = { + imported: 'blue', + updated: 'cyan', + active: 'success', + invalid: 'error', +}; + +export default function HuyaAccountsPage() { + const [accounts, setAccounts] = useState([]); + const [loading, setLoading] = useState(false); + const [importOpen, setImportOpen] = useState(false); + const [importText, setImportText] = useState(''); + const [importTag, setImportTag] = useState(''); + const [importing, setImporting] = useState(false); + const [searchText, setSearchText] = useState(''); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + const [pageSize, setPageSize] = useState(() => { + const v = localStorage.getItem('huya_account_page_size'); + return v ? Number(v) || 20 : 20; + }); + const [currentPage, setCurrentPage] = useState(1); + const { can } = usePermissions(); + + const canManage = can('huya:account'); + + const loadAccounts = useCallback(async () => { + setLoading(true); + try { + const data = await huyaApi.listAccounts(); + setAccounts(data); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadAccounts(); + }, [loadAccounts]); + + const tags = useMemo(() => { + return [...new Set(accounts.map((item) => item.tag.trim()).filter(Boolean))].sort(); + }, [accounts]); + + const filteredAccounts = useMemo(() => { + const s = searchText.trim().toLowerCase(); + if (!s) return accounts; + return accounts.filter((item) => ( + item.uid.toLowerCase().includes(s) || + item.yyuid.toLowerCase().includes(s) || + item.username.toLowerCase().includes(s) || + item.nickname.toLowerCase().includes(s) || + item.tag.toLowerCase().includes(s) || + item.game_name.toLowerCase().includes(s) || + item.game_phone.toLowerCase().includes(s) + )); + }, [accounts, searchText]); + + const handleImport = async () => { + if (!importText.trim()) { + message.warning('请先粘贴虎牙 CK'); + return; + } + setImporting(true); + try { + const result = await huyaApi.importCookies(importText, importTag); + message.success(result.message); + setImportOpen(false); + setImportText(''); + setImportTag(''); + loadAccounts(); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } finally { + setImporting(false); + } + }; + + const handleDelete = async (id: number) => { + try { + await huyaApi.deleteAccount(id); + message.success('已删除'); + setSelectedRowKeys((prev) => prev.filter((key) => key !== id)); + loadAccounts(); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } + }; + + const handleDeleteSelected = async () => { + if (selectedRowKeys.length === 0) { + message.warning('请先选择虎牙 CK'); + return; + } + try { + const result = await huyaApi.deleteAccounts(selectedRowKeys.map((key) => Number(key))); + message.success(result.message); + setSelectedRowKeys([]); + loadAccounts(); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } + }; + + const boundCount = accounts.filter((item) => item.game_name || item.game_channel || item.game_phone).length; + const pointCount = accounts.filter((item) => item.points !== null && item.points !== undefined).length; + + const columns: TableProps['columns'] = [ + { title: 'ID', dataIndex: 'id', width: 70, align: 'center' }, + { + title: '虎牙账号', + width: 180, + render: (_: unknown, record) => ( + + {record.nickname || record.username || record.uid || '-'} + + UID {record.uid || record.yyuid || '-'} + + + ), + }, + { + title: '标签', + dataIndex: 'tag', + width: 110, + render: (tag: string) => tag ? {tag} : -, + }, + { + title: '积分', + dataIndex: 'points', + width: 90, + align: 'center', + render: (points: number | null) => points ?? 未查, + }, + { + title: '游戏名', + dataIndex: 'game_name', + width: 160, + ellipsis: true, + render: (value: string) => value || 未查, + }, + { + title: '手机号', + dataIndex: 'game_phone', + width: 140, + ellipsis: true, + render: (value: string) => value || -, + }, + { + title: 'Cookie', + dataIndex: 'cookie_preview', + ellipsis: true, + render: (value: string) => ( + + {value || '-'} + + ), + }, + { + title: '状态', + dataIndex: 'status', + width: 100, + align: 'center', + render: (status: string) => ( + {STATUS_LABELS[status] || status || '-'} + ), + }, + { + title: '更新时间', + dataIndex: 'updated_at', + width: 170, + render: (value: string | null) => value ? formatTime(value) : -, + }, + { + title: '操作', + width: 90, + fixed: 'right', + align: 'center', + render: (_: unknown, record) => ( + handleDelete(record.id)}> + + {selectedRowKeys.length > 0 && ( + + + + )} + {canManage && ( + + )} + + + + + + + + + + + + + + + +
+ setSearchText(e.target.value)} + style={{ width: 300 }} + prefix={} + /> + {tags.map((tag) => ( + setSearchText(tag)} style={{ cursor: 'pointer' }}> + {tag} + + ))} +
+ + setSelectedRowKeys(keys), + }} + columns={columns} + dataSource={filteredAccounts} + rowKey="id" + loading={loading} + size="small" + pagination={{ + current: currentPage, + pageSize, + showSizeChanger: true, + showTotal: (total) => `共 ${total} 条`, + onChange: (page, size) => { + setCurrentPage(page); + if (size !== pageSize) { + setPageSize(size); + localStorage.setItem('huya_account_page_size', String(size)); + setCurrentPage(1); + } + }, + }} + scroll={{ x: 1120 }} + /> + + setImportOpen(false)} + onOk={handleImport} + okText="导入" + confirmLoading={importing} + width={720} + > + + setImportTag(e.target.value)} + /> +