新增虎牙账号和任务基础功能

This commit is contained in:
yml2213
2026-07-04 16:31:12 +08:00
parent 3df247e4e5
commit e1d47a85be
26 changed files with 4513 additions and 3 deletions
+9
View File
@@ -0,0 +1,9 @@
"""虎牙协议与业务客户端。"""
from .http_client import HuyaHttpClient
from .wss_client import HuyaWssClient
__all__ = [
"HuyaHttpClient",
"HuyaWssClient",
]
+309
View File
@@ -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"<bytes {ln}B>"
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"<decode_err:0x{dtype:02x}>"
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)"
+357
View File
@@ -0,0 +1,357 @@
"""
虎牙 HTTP POST RPC 通道(cdnws.api.huya.com
用于 WSS 业务通道被设备态 Cookie 静默拒收时的兜底:
URL: https://cdnws.api.huya.com/?baseinfo=<base64(WSConnectParaInfo)>
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<string,string>
"""
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:0HAR 实证)"""
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
+583
View File
@@ -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 为 0tag2 为 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<INT32>
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<INT64>
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<PromotionItem>
self.sourceId: str = "" # tag 16
self.env: Dict[str, str] = {} # tag 17 Map<STRING,STRING>
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 风控跳转URLcode!=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
+477
View File
@@ -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()})"
+709
View File
@@ -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 requestIdHAR 中 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 业务 UserIdHAR 实证: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
+281
View File
@@ -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
+7
View File
@@ -163,6 +163,10 @@ cmd_restart() {
echo "✅ 服务已重启" echo "✅ 服务已重启"
} }
cmd_dev() {
exec "$ROOT_DIR/dev.sh"
}
# ── 帮助 ────────────────────────────────────────────────── # ── 帮助 ──────────────────────────────────────────────────
cmd_help() { cmd_help() {
@@ -179,9 +183,11 @@ cmd_help() {
echo " logs 查看实时日志" echo " logs 查看实时日志"
echo " stop 停止服务" echo " stop 停止服务"
echo " restart 重启服务" echo " restart 重启服务"
echo " dev 本地调试启动(后端 reload + 前端热更新)"
echo " help 显示帮助" echo " help 显示帮助"
echo "" echo ""
echo " 访问地址: http://localhost:8000" echo " 访问地址: http://localhost:8000"
echo " 调试地址: http://localhost:5173"
echo " 默认账号见环境变量 ADMIN_USERNAME/ADMIN_PASSWORD" echo " 默认账号见环境变量 ADMIN_USERNAME/ADMIN_PASSWORD"
echo "" echo ""
} }
@@ -193,6 +199,7 @@ case "${1:-}" in
logs) cmd_logs ;; logs) cmd_logs ;;
stop) cmd_stop ;; stop) cmd_stop ;;
restart) cmd_restart ;; restart) cmd_restart ;;
dev) cmd_dev ;;
help|-h) cmd_help ;; help|-h) cmd_help ;;
*) cmd_deploy ;; *) cmd_deploy ;;
esac esac
Executable
+88
View File
@@ -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
+1
View File
@@ -19,6 +19,7 @@ dependencies = [
"pydantic>=2.0.0", "pydantic>=2.0.0",
"python-multipart>=0.0.9", "python-multipart>=0.0.9",
"alembic>=1.18.4", "alembic>=1.18.4",
"websockets>=16.0",
] ]
[build-system] [build-system]
Generated
+2
View File
@@ -219,6 +219,7 @@ dependencies = [
{ name = "requests", extra = ["socks"] }, { name = "requests", extra = ["socks"] },
{ name = "sqlalchemy" }, { name = "sqlalchemy" },
{ name = "uvicorn", extra = ["standard"] }, { name = "uvicorn", extra = ["standard"] },
{ name = "websockets" },
] ]
[package.metadata] [package.metadata]
@@ -237,6 +238,7 @@ requires-dist = [
{ name = "requests", extras = ["socks"], specifier = ">=2.31.0" }, { name = "requests", extras = ["socks"], specifier = ">=2.31.0" },
{ name = "sqlalchemy", specifier = ">=2.0.0" }, { name = "sqlalchemy", specifier = ">=2.0.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.27.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.27.0" },
{ name = "websockets", specifier = ">=16.0" },
] ]
[[package]] [[package]]
+2 -1
View File
@@ -11,7 +11,7 @@ from fastapi.responses import FileResponse
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from .database import init_db 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 .schemas import AppInfo
from .version import get_app_version from .version import get_app_version
from utils import setup_logger from utils import setup_logger
@@ -69,6 +69,7 @@ app.include_router(accounts.router)
app.include_router(login.router) app.include_router(login.router)
app.include_router(proxy.router) app.include_router(proxy.router)
app.include_router(cookies.router) app.include_router(cookies.router)
app.include_router(huya.router)
@app.get("/api/health") @app.get("/api/health")
@@ -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")
+70
View File
@@ -30,6 +30,7 @@ class User(Base):
# 客服被分配的账号 # 客服被分配的账号
assigned_accounts = relationship("Account", back_populates="assigned_user", foreign_keys="Account.assigned_to") 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): class Account(Base):
@@ -70,6 +71,75 @@ class LoginTask(Base):
account = relationship("Account", back_populates="login_tasks") 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): class ProxyConfig(Base):
"""代理配置(全局单条记录)""" """代理配置(全局单条记录)"""
__tablename__ = "proxy_config" __tablename__ = "proxy_config"
+11
View File
@@ -22,6 +22,12 @@ PERMISSIONS = {
# Cookie # Cookie
"cookie:view": "查看 Cookie", "cookie:view": "查看 Cookie",
"cookie:export": "导出 Cookie", "cookie:export": "导出 Cookie",
# 虎牙
"huya:account": "虎牙 CK 管理",
"huya:task": "虎牙任务管理",
"huya:bind": "虎牙绑定操作",
"huya:recharge": "虎牙充值操作",
"huya:config": "虎牙配置管理",
# 代理 & 白名单 # 代理 & 白名单
"proxy:manage": "代理配置管理", "proxy:manage": "代理配置管理",
"whitelist:manage": "白名单配置管理", "whitelist:manage": "白名单配置管理",
@@ -42,6 +48,11 @@ ROLE_PERMISSIONS = {
"login:view_all", "login:view_all",
"cookie:view", "cookie:view",
"cookie:export", "cookie:export",
"huya:account",
"huya:task",
"huya:bind",
"huya:recharge",
"huya:config",
"proxy:manage", "proxy:manage",
"whitelist:manage", "whitelist:manage",
"whitelist:test", "whitelist:test",
+250
View File
@@ -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()
+146
View File
@@ -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): class ProxyConfigOut(BaseModel):
enabled: bool = False enabled: bool = False
+161
View File
@@ -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)
+4
View File
@@ -11,6 +11,8 @@ import LoginTasksPage from './pages/LoginTasksPage';
import ProxyPage from './pages/ProxyPage'; import ProxyPage from './pages/ProxyPage';
import UsersPage from './pages/UsersPage'; import UsersPage from './pages/UsersPage';
import CookiePage from './pages/CookiePage'; import CookiePage from './pages/CookiePage';
import HuyaAccountsPage from './pages/HuyaAccountsPage';
import HuyaTasksPage from './pages/HuyaTasksPage';
import { getUser } from './store/auth'; import { getUser } from './store/auth';
import { ThemeProvider } from './store/theme'; import { ThemeProvider } from './store/theme';
import { useTheme } from './store/useTheme'; import { useTheme } from './store/useTheme';
@@ -44,6 +46,8 @@ function AppContent() {
<Route path="assignments" element={<AssignmentsPage />} /> <Route path="assignments" element={<AssignmentsPage />} />
<Route path="login-tasks" element={<LoginTasksPage />} /> <Route path="login-tasks" element={<LoginTasksPage />} />
<Route path="cookies" element={<CookiePage />} /> <Route path="cookies" element={<CookiePage />} />
<Route path="huya/accounts" element={<HuyaAccountsPage />} />
<Route path="huya/tasks" element={<HuyaTasksPage />} />
<Route path="proxy" element={<ProxyPage />} /> <Route path="proxy" element={<ProxyPage />} />
<Route path="users" element={<UsersPage />} /> <Route path="users" element={<UsersPage />} />
</Route> </Route>
+30
View File
@@ -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<string, string>, Record<string, string>>('/huya/task-types'),
listAccounts: (params?: { tag?: string }) =>
api.get<HuyaAccountItem[], HuyaAccountItem[]>('/huya/accounts', { params }),
importCookies: (text: string, tag: string = '') =>
api.post<HuyaCookieImportResult, HuyaCookieImportResult>('/huya/accounts/import-cookies', { text, tag }),
deleteAccount: (id: number) => api.delete<MessageResponse, MessageResponse>(`/huya/accounts/${id}`),
deleteAccounts: (accountIds: number[]) =>
api.delete<MessageDeletedResponse, MessageDeletedResponse>('/huya/accounts/batch', { params: { account_ids: accountIds.join(',') } }),
getConfig: () => api.get<HuyaConfig, HuyaConfig>('/huya/config'),
updateConfig: (data: Partial<HuyaConfig>) => api.put<HuyaConfig, HuyaConfig>('/huya/config', data),
listGoods: () => api.get<HuyaGoodsItem[], HuyaGoodsItem[]>('/huya/goods'),
createTasks: (data: HuyaTaskBatchRequest) =>
api.post<HuyaTaskBatchResult, HuyaTaskBatchResult>('/huya/tasks/batch', data),
listTasks: (batchId?: string) =>
api.get<HuyaTaskItem[], HuyaTaskItem[]>('/huya/tasks', { params: batchId ? { batch_id: batchId } : {} }),
};
+1
View File
@@ -3,6 +3,7 @@ export { accountApi } from './accounts';
export { appApi } from './app'; export { appApi } from './app';
export { authApi } from './auth'; export { authApi } from './auth';
export { cookieApi } from './cookies'; export { cookieApi } from './cookies';
export { huyaApi } from './huya';
export { loginApi } from './login'; export { loginApi } from './login';
export { proxyApi } from './proxy'; export { proxyApi } from './proxy';
export { userApi } from './users'; export { userApi } from './users';
+74
View File
@@ -109,6 +109,80 @@ export interface CookieItem {
account_password: string; 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<string, unknown>;
}
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<string, unknown> | 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<string, unknown> | null;
updated_at: string | null;
}
// ==================== Proxy ==================== // ==================== Proxy ====================
export interface ProxyConfig { export interface ProxyConfig {
+11 -1
View File
@@ -4,7 +4,7 @@ import {
DashboardOutlined, UserOutlined, LogoutOutlined, DashboardOutlined, UserOutlined, LogoutOutlined,
CloudServerOutlined, TeamOutlined, ApiOutlined, KeyOutlined, CloudServerOutlined, TeamOutlined, ApiOutlined, KeyOutlined,
MenuFoldOutlined, MenuUnfoldOutlined, SwapOutlined, MenuFoldOutlined, MenuUnfoldOutlined, SwapOutlined,
SunOutlined, MoonOutlined, DesktopOutlined, SunOutlined, MoonOutlined, DesktopOutlined, GiftOutlined, ShoppingCartOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useNavigate, useLocation, Outlet } from 'react-router-dom'; import { useNavigate, useLocation, Outlet } from 'react-router-dom';
import { getUser, clearAuth, type AuthUser } from '../store/auth'; 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: <KeyOutlined /> }); menuItems.push({ key: '/cookies', label: 'Cookie 管理', icon: <KeyOutlined /> });
} }
// 虎牙 CK 管理
if (can('huya:account')) {
menuItems.push({ key: '/huya/accounts', label: '虎牙 CK', icon: <GiftOutlined /> });
}
// 虎牙兑换与充值
if (can('huya:task')) {
menuItems.push({ key: '/huya/tasks', label: '虎牙任务', icon: <ShoppingCartOutlined /> });
}
// 代理配置 // 代理配置
if (can('proxy:manage')) { if (can('proxy:manage')) {
menuItems.push({ key: '/proxy', label: '代理配置', icon: <CloudServerOutlined /> }); menuItems.push({ key: '/proxy', label: '代理配置', icon: <CloudServerOutlined /> });
+315
View File
@@ -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<string, string> = {
imported: '已导入',
updated: '已更新',
active: '正常',
invalid: '失效',
};
const STATUS_COLORS: Record<string, string> = {
imported: 'blue',
updated: 'cyan',
active: 'success',
invalid: 'error',
};
export default function HuyaAccountsPage() {
const [accounts, setAccounts] = useState<HuyaAccountItem[]>([]);
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<React.Key[]>([]);
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<HuyaAccountItem>['columns'] = [
{ title: 'ID', dataIndex: 'id', width: 70, align: 'center' },
{
title: '虎牙账号',
width: 180,
render: (_: unknown, record) => (
<Space direction="vertical" size={0}>
<Text strong>{record.nickname || record.username || record.uid || '-'}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
UID {record.uid || record.yyuid || '-'}
</Text>
</Space>
),
},
{
title: '标签',
dataIndex: 'tag',
width: 110,
render: (tag: string) => tag ? <Tag color="blue">{tag}</Tag> : <Text type="secondary">-</Text>,
},
{
title: '积分',
dataIndex: 'points',
width: 90,
align: 'center',
render: (points: number | null) => points ?? <Text type="secondary"></Text>,
},
{
title: '游戏名',
dataIndex: 'game_name',
width: 160,
ellipsis: true,
render: (value: string) => value || <Text type="secondary"></Text>,
},
{
title: '手机号',
dataIndex: 'game_phone',
width: 140,
ellipsis: true,
render: (value: string) => value || <Text type="secondary">-</Text>,
},
{
title: 'Cookie',
dataIndex: 'cookie_preview',
ellipsis: true,
render: (value: string) => (
<Text code style={{ fontSize: 12 }}>
{value || '-'}
</Text>
),
},
{
title: '状态',
dataIndex: 'status',
width: 100,
align: 'center',
render: (status: string) => (
<Tag color={STATUS_COLORS[status] || 'default'}>{STATUS_LABELS[status] || status || '-'}</Tag>
),
},
{
title: '更新时间',
dataIndex: 'updated_at',
width: 170,
render: (value: string | null) => value ? formatTime(value) : <Text type="secondary">-</Text>,
},
{
title: '操作',
width: 90,
fixed: 'right',
align: 'center',
render: (_: unknown, record) => (
<Popconfirm title="确认删除这条虎牙 CK" onConfirm={() => handleDelete(record.id)}>
<Button danger size="small" icon={<DeleteOutlined />} />
</Popconfirm>
),
},
];
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<h2 style={{ margin: 0 }}> CK </h2>
<Space wrap>
<Button icon={<ReloadOutlined />} onClick={loadAccounts} loading={loading}>
</Button>
{selectedRowKeys.length > 0 && (
<Popconfirm title={`确认删除选中的 ${selectedRowKeys.length} 条虎牙 CK`} onConfirm={handleDeleteSelected}>
<Button danger icon={<DeleteOutlined />}>
({selectedRowKeys.length})
</Button>
</Popconfirm>
)}
{canManage && (
<Button type="primary" icon={<ImportOutlined />} onClick={() => setImportOpen(true)}>
CK
</Button>
)}
</Space>
</div>
<Row gutter={12} style={{ marginBottom: 16 }}>
<Col xs={24} sm={8}>
<Card size="small"><Statistic title="CK 总数" value={accounts.length} /></Card>
</Col>
<Col xs={24} sm={8}>
<Card size="small"><Statistic title="已查积分" value={pointCount} /></Card>
</Col>
<Col xs={24} sm={8}>
<Card size="small"><Statistic title="已绑定信息" value={boundCount} /></Card>
</Col>
</Row>
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<Input.Search
placeholder="搜索 UID、昵称、标签、游戏名、手机号"
allowClear
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
style={{ width: 300 }}
prefix={<SearchOutlined />}
/>
{tags.map((tag) => (
<Tag key={tag} color="blue" onClick={() => setSearchText(tag)} style={{ cursor: 'pointer' }}>
{tag}
</Tag>
))}
</div>
<Table
rowSelection={{
selectedRowKeys,
onChange: (keys) => 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 }}
/>
<Modal
title="粘贴虎牙 CK"
open={importOpen}
onCancel={() => setImportOpen(false)}
onOk={handleImport}
okText="导入"
confirmLoading={importing}
width={720}
>
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<Input
placeholder="标签,可选"
value={importTag}
onChange={(e) => setImportTag(e.target.value)}
/>
<TextArea
rows={12}
value={importText}
onChange={(e) => setImportText(e.target.value)}
placeholder="每行一条,支持纯 CK、账号----密码----CK、CK----手机号"
/>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
UIDYYUID CK
</Paragraph>
</Space>
</Modal>
</div>
);
}
+485
View File
@@ -0,0 +1,485 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Button, Card, Col, Form, Input, InputNumber, message, Row, Select, Space, Table, Tag, Tooltip, Typography, theme,
} from 'antd';
import type { TableProps } from 'antd';
import {
AppstoreOutlined, CheckCircleOutlined, CreditCardOutlined, FieldTimeOutlined, GiftOutlined,
LinkOutlined, PlayCircleOutlined, ReloadOutlined, SearchOutlined, SettingOutlined, ShoppingOutlined,
} from '@ant-design/icons';
import {
huyaApi,
type HuyaAccountItem,
type HuyaConfig,
type HuyaGoodsItem,
type HuyaTaskItem,
} from '../api/modules';
import RealtimeLogPanel from '../components/RealtimeLogPanel';
import { usePermissions } from '../hooks/usePermissions';
import { useWebSocketLogs } from '../hooks/useWebSocketLogs';
import { formatTime } from '../utils/time';
import { getErrorMessage } from '../utils/error';
const { Text } = Typography;
const FALLBACK_TASK_TYPES: Record<string, string> = {
get_bind_qr: '获取绑定二维码',
confirm_bind: '确认绑定',
query_points: '一键查询积分',
open_elite_book: '开通精英宝典',
recharge_points: '充值积分',
query_game_name: '一键查询游戏名',
query_exchange_records: '一键查询兑换记录',
refresh_goods: '刷新商品列表',
};
const QUICK_ACTIONS = [
{ key: 'get_bind_qr', icon: <LinkOutlined /> },
{ key: 'confirm_bind', icon: <CheckCircleOutlined /> },
{ key: 'query_points', icon: <SearchOutlined /> },
{ key: 'open_elite_book', icon: <GiftOutlined /> },
{ key: 'recharge_points', icon: <CreditCardOutlined /> },
{ key: 'query_game_name', icon: <AppstoreOutlined /> },
{ key: 'query_exchange_records', icon: <FieldTimeOutlined /> },
{ key: 'refresh_goods', icon: <ReloadOutlined /> },
];
const STATUS_COLORS: Record<string, string> = {
planned: 'default',
pending: 'default',
running: 'processing',
success: 'success',
failed: 'error',
error: 'error',
};
const STATUS_LABELS: Record<string, string> = {
planned: '已计划',
pending: '等待中',
running: '执行中',
success: '成功',
failed: '失败',
error: '异常',
};
function accountLabel(account: HuyaAccountItem): string {
const name = account.nickname || account.username || account.uid || `#${account.id}`;
const tag = account.tag ? ` [${account.tag}]` : '';
const phone = account.game_phone ? ` / ${account.game_phone}` : '';
return `${name}${tag}${phone}`;
}
export default function HuyaTasksPage() {
const { token } = theme.useToken();
const [form] = Form.useForm<HuyaConfig>();
const [accounts, setAccounts] = useState<HuyaAccountItem[]>([]);
const [tasks, setTasks] = useState<HuyaTaskItem[]>([]);
const [goods, setGoods] = useState<HuyaGoodsItem[]>([]);
const [taskTypes, setTaskTypes] = useState<Record<string, string>>(FALLBACK_TASK_TYPES);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [selectedTaskType, setSelectedTaskType] = useState('query_points');
const [selectedGoodsId, setSelectedGoodsId] = useState<string>('');
const [rechargeCount, setRechargeCount] = useState(1);
const [concurrency, setConcurrency] = useState(() => {
const v = localStorage.getItem('huya_task_concurrency');
return v ? Math.max(1, Math.min(10, Number(v) || 3)) : 3;
});
const [loading, setLoading] = useState(false);
const [starting, setStarting] = useState(false);
const [savingConfig, setSavingConfig] = useState(false);
const [batchId, setBatchId] = useState<string | null>(null);
const { logs, connected: wsConnected, connect: connectLogs } = useWebSocketLogs();
const { can } = usePermissions();
const canTask = can('huya:task');
const canConfig = can('huya:config');
useEffect(() => {
localStorage.setItem('huya_task_concurrency', String(concurrency));
}, [concurrency]);
const loadAll = useCallback(async () => {
setLoading(true);
try {
const [accountResult, taskResult, goodsResult, configResult, taskTypeResult] = await Promise.allSettled([
huyaApi.listAccounts(),
huyaApi.listTasks(),
huyaApi.listGoods(),
canConfig ? huyaApi.getConfig() : Promise.resolve(null),
huyaApi.taskTypes(),
]);
if (accountResult.status === 'fulfilled') setAccounts(accountResult.value);
if (taskResult.status === 'fulfilled') setTasks(taskResult.value);
if (goodsResult.status === 'fulfilled') setGoods(goodsResult.value);
if (configResult.status === 'fulfilled' && configResult.value) form.setFieldsValue(configResult.value);
if (taskTypeResult.status === 'fulfilled') setTaskTypes({ ...FALLBACK_TASK_TYPES, ...taskTypeResult.value });
const failedLabels = [
accountResult.status === 'rejected' ? `CK 列表: ${getErrorMessage(accountResult.reason)}` : '',
taskResult.status === 'rejected' ? `任务记录: ${getErrorMessage(taskResult.reason)}` : '',
goodsResult.status === 'rejected' ? `商品快照: ${getErrorMessage(goodsResult.reason)}` : '',
configResult.status === 'rejected' ? `虎牙配置: ${getErrorMessage(configResult.reason)}` : '',
taskTypeResult.status === 'rejected' ? `任务类型: ${getErrorMessage(taskTypeResult.reason)}` : '',
].filter(Boolean);
if (failedLabels.length > 0) {
message.warning(`部分数据加载失败:${failedLabels.join('')}`);
}
} finally {
setLoading(false);
}
}, [canConfig, form]);
const loadTasks = useCallback(async () => {
try {
const data = await huyaApi.listTasks();
setTasks(data);
} catch {
// 轮询失败不打扰操作,下一轮继续刷新。
}
}, []);
useEffect(() => {
loadAll();
}, [loadAll]);
useEffect(() => {
const timer = setInterval(loadTasks, 3000);
return () => clearInterval(timer);
}, [loadTasks]);
const accountOptions = useMemo(() => {
return accounts.map((account) => ({ value: account.id, label: accountLabel(account) }));
}, [accounts]);
const goodsOptions = useMemo(() => {
return goods.map((item) => ({
value: item.product_id,
label: `${item.name || item.product_id}${item.price ? ` / ${item.price}积分` : ''}`,
}));
}, [goods]);
const selectedGoods = useMemo(() => {
return goods.find((item) => item.product_id === selectedGoodsId) || null;
}, [goods, selectedGoodsId]);
const createPayload = (taskType: string) => {
if (taskType !== 'recharge_points') return {};
return {
product_id: selectedGoods?.product_id || selectedGoodsId,
product_name: selectedGoods?.name || '',
count: rechargeCount,
};
};
const startTask = async (taskType = selectedTaskType) => {
if (selectedIds.length === 0) {
message.warning('请先选择虎牙 CK');
return;
}
if (taskType === 'recharge_points' && !selectedGoodsId) {
message.warning('请先选择充值商品');
return;
}
setStarting(true);
try {
const result = await huyaApi.createTasks({
account_ids: selectedIds,
task_type: taskType,
concurrency,
payload: createPayload(taskType),
});
setBatchId(result.batch_id);
message.success(`已创建 ${taskTypes[taskType] || taskType},共 ${result.count} 个账号`);
await loadTasks();
connectLogs(`/api/huya/ws/${result.batch_id}`, {
onClose: () => { setBatchId(null); setStarting(false); loadTasks(); },
onResult: () => { setBatchId(null); setStarting(false); loadTasks(); },
onError: () => { setBatchId(null); setStarting(false); loadTasks(); },
});
} catch (e: unknown) {
message.error(getErrorMessage(e));
setStarting(false);
}
};
const saveConfig = async () => {
setSavingConfig(true);
try {
const values = await form.validateFields();
const result = await huyaApi.updateConfig(values);
form.setFieldsValue(result);
message.success('虎牙配置已保存');
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setSavingConfig(false);
}
};
const successCount = tasks.filter((task) => task.status === 'success').length;
const plannedCount = tasks.filter((task) => task.status === 'planned').length;
const failedCount = tasks.filter((task) => ['failed', 'error'].includes(task.status)).length;
const taskColumns: TableProps<HuyaTaskItem>['columns'] = [
{ title: 'ID', dataIndex: 'id', width: 70, align: 'center' },
{
title: '任务',
dataIndex: 'task_type',
width: 150,
render: (value: string) => taskTypes[value] || value,
},
{
title: '账号',
width: 160,
render: (_: unknown, record) => record.account_nickname || record.account_uid || record.account_id,
},
{
title: '状态',
dataIndex: 'status',
width: 100,
align: 'center',
render: (status: string) => (
<Tag color={STATUS_COLORS[status] || 'default'}>{STATUS_LABELS[status] || status}</Tag>
),
},
{ title: '消息', dataIndex: 'message', ellipsis: true },
{
title: '结果',
dataIndex: 'result',
width: 180,
ellipsis: true,
render: (value: Record<string, unknown> | null) => (
value ? <Text code style={{ fontSize: 12 }}>{JSON.stringify(value)}</Text> : <Text type="secondary">-</Text>
),
},
{
title: '时间',
dataIndex: 'created_at',
width: 170,
render: (value: string | null) => value ? formatTime(value) : <Text type="secondary">-</Text>,
},
];
const goodsColumns: TableProps<HuyaGoodsItem>['columns'] = [
{ title: '商品ID', dataIndex: 'product_id', width: 120, ellipsis: true },
{ title: '名称', dataIndex: 'name', ellipsis: true },
{
title: '价格',
dataIndex: 'price',
width: 90,
align: 'center',
render: (value: number | null) => value ?? <Text type="secondary">-</Text>,
},
{
title: '库存',
dataIndex: 'remain_text',
width: 100,
render: (value: string) => value || <Text type="secondary">-</Text>,
},
{
title: '更新时间',
dataIndex: 'updated_at',
width: 160,
render: (value: string | null) => value ? formatTime(value) : <Text type="secondary">-</Text>,
},
];
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ flexShrink: 0, marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<h2 style={{ margin: 0 }}></h2>
<Space wrap>
<Button icon={<ReloadOutlined />} onClick={loadAll} loading={loading}>
</Button>
{batchId && <Tag color="processing"> {batchId}</Tag>}
</Space>
</div>
<div style={{ flex: 1, minHeight: 0, overflow: 'auto', paddingRight: 2 }}>
<Row gutter={12}>
<Col xs={24} xl={10}>
<Card
size="small"
title={<Space><SettingOutlined /></Space>}
extra={canConfig && (
<Button size="small" type="primary" onClick={saveConfig} loading={savingConfig}>
</Button>
)}
style={{ marginBottom: 12 }}
>
<Form form={form} layout="vertical" disabled={!canConfig}>
<Row gutter={8}>
<Col span={12}>
<Form.Item label="直播间 ID" name="room_pid">
<Input placeholder="roomPid / pid" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="SID" name="sid">
<Input placeholder="活动 sid" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="兑换活动 ID" name="outer_act_id">
<Input placeholder="默认 9504" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="绑定活动 ID" name="bind_act_id">
<Input placeholder="默认 17096" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="支付渠道" name="pay_channel">
<Select
options={[
{ value: 'Zfb', label: '支付宝' },
{ value: 'Wx', label: '微信' },
]}
/>
</Form.Item>
</Col>
</Row>
</Form>
</Card>
<Card size="small" title={<Space><ShoppingOutlined /></Space>} style={{ marginBottom: 12 }}>
<Space style={{ marginBottom: 8 }} wrap>
<Select
showSearch
allowClear
placeholder="选择充值商品"
value={selectedGoodsId || undefined}
onChange={(value) => setSelectedGoodsId(value || '')}
options={goodsOptions}
style={{ minWidth: 240 }}
filterOption={(input, option) => String(option?.label || '').toLowerCase().includes(input.toLowerCase())}
/>
<InputNumber
min={1}
max={99}
value={rechargeCount}
onChange={(value) => setRechargeCount(value || 1)}
addonAfter="份"
style={{ width: 120 }}
/>
</Space>
<Table
columns={goodsColumns}
dataSource={goods}
rowKey="id"
size="small"
pagination={false}
scroll={{ x: 620, y: 220 }}
locale={{ emptyText: '暂无商品快照,后续接入刷新商品列表后写入' }}
/>
</Card>
</Col>
<Col xs={24} xl={14}>
<Card size="small" title="批量动作" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<Select
mode="multiple"
showSearch
placeholder="选择虎牙 CK"
value={selectedIds}
onChange={setSelectedIds}
options={accountOptions}
maxTagCount="responsive"
style={{ width: '100%' }}
filterOption={(input, option) => String(option?.label || '').toLowerCase().includes(input.toLowerCase())}
dropdownRender={(menu) => (
<>
<div style={{ padding: '4px 8px', borderBottom: `1px solid ${token.colorBorderSecondary}`, display: 'flex', gap: 8 }}>
<Button size="small" type="link" onClick={() => setSelectedIds(accounts.map((item) => item.id))}>
({accounts.length})
</Button>
<Button size="small" type="link" onClick={() => setSelectedIds([])}>
</Button>
</div>
{menu}
</>
)}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<Select
value={selectedTaskType}
onChange={setSelectedTaskType}
options={Object.entries(taskTypes).map(([value, label]) => ({ value, label }))}
style={{ flex: '1 1 220px', minWidth: 180 }}
/>
<InputNumber
min={1}
max={10}
value={concurrency}
onChange={(value) => setConcurrency(value || 1)}
addonBefore="并发"
style={{ width: 130, flexShrink: 0 }}
/>
<Button
type="primary"
icon={<PlayCircleOutlined />}
loading={starting}
disabled={!canTask || selectedIds.length === 0 || wsConnected}
onClick={() => startTask()}
>
</Button>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{QUICK_ACTIONS.map((item) => (
<Tooltip key={item.key} title={item.key === 'refresh_goods' ? '当前阶段创建计划任务,真实拉取逻辑后续接入' : undefined}>
<Button
icon={item.icon}
size="small"
onClick={() => startTask(item.key)}
disabled={!canTask || selectedIds.length === 0 || wsConnected}
>
{taskTypes[item.key] || item.key}
</Button>
</Tooltip>
))}
</div>
<Text type="secondary" style={{ fontSize: 12 }}>
planned WSS/HTTP
</Text>
</div>
</Card>
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 8, color: token.colorTextSecondary }}>
<span> <b>{tasks.length}</b> </span>
<span> <b>{plannedCount}</b></span>
<span> <b style={{ color: token.colorSuccess }}>{successCount}</b></span>
<span> <b style={{ color: token.colorError }}>{failedCount}</b></span>
</div>
<Table
columns={taskColumns}
dataSource={tasks}
rowKey="id"
loading={loading}
size="small"
pagination={{ pageSize: 12, showTotal: (total) => `${total}` }}
scroll={{ x: 920 }}
/>
</Col>
</Row>
</div>
<RealtimeLogPanel
logs={logs}
connected={wsConnected}
title="虎牙实时日志"
emptyText="暂无虎牙任务日志"
collapsible
spinWhenEmpty
style={{ marginTop: 4 }}
/>
</div>
);
}
+3 -1
View File
@@ -1,6 +1,8 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
const backendTarget = process.env.VITE_BACKEND_TARGET || 'http://127.0.0.1:8000'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
@@ -9,7 +11,7 @@ export default defineConfig({
allowedHosts: ["www.u499731.nyat.app"], allowedHosts: ["www.u499731.nyat.app"],
proxy: { proxy: {
'/api': { '/api': {
target: 'http://127.0.0.1:8000', target: backendTarget,
changeOrigin: true, changeOrigin: true,
ws: true, ws: true,
}, },