新增虎牙账号和任务基础功能
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
"""虎牙协议与业务客户端。"""
|
||||||
|
|
||||||
|
from .http_client import HuyaHttpClient
|
||||||
|
from .wss_client import HuyaWssClient
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"HuyaHttpClient",
|
||||||
|
"HuyaWssClient",
|
||||||
|
]
|
||||||
@@ -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)"
|
||||||
@@ -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:0,HAR 实证)"""
|
||||||
|
h = '%016x' % random.getrandbits(64)
|
||||||
|
return f"{h}:{h}:0:0"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_http_baseinfo(uid: int, guid: str, cookie: str,
|
||||||
|
trace_id: str = None) -> str:
|
||||||
|
"""
|
||||||
|
构造 HTTP POST 的 baseinfo URL 参数
|
||||||
|
|
||||||
|
HAR 实证: getGoodsInfoV5 的 baseinfo lUid=0, sCookie="" (身份靠 Wup body 的 userId.sCookie)
|
||||||
|
"""
|
||||||
|
info = WSConnectParaInfo()
|
||||||
|
info.lUid = 0 # HAR 实证: baseinfo 里 lUid=0
|
||||||
|
info.sGuid = guid
|
||||||
|
info.sUA = "webh5&0.0.1&websocket&&diypc_52775"
|
||||||
|
info.sAppSrc = "HUYA&ZH&2052"
|
||||||
|
info.sCookie = "" # HAR 实证: baseinfo 不带 cookie, cookie 在 Wup body
|
||||||
|
info.sTraceId = trace_id or _gen_trace_id()
|
||||||
|
|
||||||
|
os = TafOutputStream()
|
||||||
|
os.write_struct_begin(0) # WSConnectParaInfo 是顶层 struct? 实际 baseinfo 是裸 struct 字段
|
||||||
|
# 实际 HAR baseinfo 解码: 顶层直接是字段(tag0 lUid...), 不包 STRUCT_BEGIN
|
||||||
|
# 重置, 不写 STRUCT_BEGIN
|
||||||
|
os = TafOutputStream()
|
||||||
|
info.write_to(os)
|
||||||
|
raw = os.get_bytes()
|
||||||
|
# base64 (与 JS window.btoa 一致)
|
||||||
|
b64 = base64.b64encode(raw).decode('ascii')
|
||||||
|
return urllib.parse.quote(b64, safe='')
|
||||||
|
|
||||||
|
|
||||||
|
class HuyaHttpClient:
|
||||||
|
"""虎牙 HTTP POST RPC 客户端"""
|
||||||
|
|
||||||
|
def __init__(self, logger: Callable[[str], None] = None):
|
||||||
|
self.logger = logger or print
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_cookie(cookie: str) -> str:
|
||||||
|
"""HTTP 业务 UserId.sCookie 需要带 huya_ua 前缀,避免重复写入。"""
|
||||||
|
import re
|
||||||
|
cookie = (cookie or "").strip()
|
||||||
|
normalized = f"huya_ua={HTTP_HUYA_UA}"
|
||||||
|
if re.search(r"(?:^|;\s*)huya_ua=", cookie):
|
||||||
|
return re.sub(r"(^|;\s*)huya_ua=[^;]*",
|
||||||
|
lambda m: f"{m.group(1)}{normalized}",
|
||||||
|
cookie, count=1)
|
||||||
|
if not cookie:
|
||||||
|
return normalized
|
||||||
|
return f"{normalized}; {cookie}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_user(uid: int, guid: str, cookie: str):
|
||||||
|
"""构造 HTTP 业务 UserId。"""
|
||||||
|
from .shop_structs import UserId
|
||||||
|
user = UserId()
|
||||||
|
user.lUid = uid
|
||||||
|
user.sGuid = guid
|
||||||
|
user.sToken = ""
|
||||||
|
user.sHuYaUA = HTTP_HUYA_UA
|
||||||
|
user.sCookie = HuyaHttpClient._normalize_cookie(cookie)
|
||||||
|
user.iTokenType = 0
|
||||||
|
user.sDeviceInfo = ""
|
||||||
|
user.sQIMEI = ""
|
||||||
|
return user
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_shop_app(source_id: str = "yellowcarlist", scene: int = 7):
|
||||||
|
"""构造 HTTP 业务 ShopAppInfo。"""
|
||||||
|
from .shop_structs import ShopAppInfo
|
||||||
|
info = ShopAppInfo()
|
||||||
|
info.sAppId = "huya"
|
||||||
|
info.sBizType = ""
|
||||||
|
info.scene = scene
|
||||||
|
info.sourceId = source_id
|
||||||
|
return info
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_order_env(item_count: int = 1):
|
||||||
|
"""构造下单/支付环境参数。"""
|
||||||
|
import time
|
||||||
|
click_time = int(time.time() * 1000)
|
||||||
|
return {
|
||||||
|
"user_click_seq": f'[{{"x":258,"y":487,"time":{click_time},"id":""}}]',
|
||||||
|
"input_1": str(item_count),
|
||||||
|
}
|
||||||
|
|
||||||
|
def call_rpc(self, service: str, method: str,
|
||||||
|
req_struct: TafStruct, rsp_class=None,
|
||||||
|
uid: int = 0, guid: str = "", cookie: str = "",
|
||||||
|
timeout: float = 15.0):
|
||||||
|
"""
|
||||||
|
HTTP POST RPC 调用
|
||||||
|
|
||||||
|
Args:
|
||||||
|
service: servant 名 (shopMiddleUI)
|
||||||
|
method: 方法名 (getGoodsInfoV5)
|
||||||
|
req_struct: 请求结构体 (TafStruct)
|
||||||
|
rsp_class: 响应类
|
||||||
|
uid/guid/cookie: 用于构造 baseinfo (cookie 主要放 Wup body)
|
||||||
|
"""
|
||||||
|
# 1. 构造 Wup 请求体
|
||||||
|
wup = WupRequest()
|
||||||
|
wup.setServant(service)
|
||||||
|
wup.setFunc(method)
|
||||||
|
wup.setRequestId(1)
|
||||||
|
wup.writeStruct("tReq", req_struct)
|
||||||
|
wup_data = wup.encode() # [4字节长度][wup body]
|
||||||
|
|
||||||
|
# 2. 构造 baseinfo
|
||||||
|
baseinfo = generate_http_baseinfo(uid, guid, cookie)
|
||||||
|
url = f"https://{CDNWS_HOST}/?baseinfo={baseinfo}"
|
||||||
|
|
||||||
|
self.logger(f"[HTTP] POST {service}.{method}")
|
||||||
|
self.logger(f"[HTTP] URL: {url[:100]}...")
|
||||||
|
self.logger(f"[HTTP] 发送 body: {len(wup_data)} 字节, hex前60: {wup_data[:60].hex()}")
|
||||||
|
|
||||||
|
# 3. POST
|
||||||
|
req = urllib.request.Request(url, data=wup_data, method='POST', headers={
|
||||||
|
'User-Agent': PC_UA,
|
||||||
|
'Origin': 'https://zt.huya.com',
|
||||||
|
'Referer': 'https://zt.huya.com/',
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
'Accept-Language': 'zh-CN,zh;q=0.9',
|
||||||
|
})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
resp_data = resp.read()
|
||||||
|
# 检测 gzip 压缩 (响应 hex 1f8b 开头)
|
||||||
|
if resp_data[:2] == b'\x1f\x8b':
|
||||||
|
import gzip
|
||||||
|
resp_data = gzip.decompress(resp_data)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger(f"[HTTP] ❌ 请求失败: {type(e).__name__}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
self.logger(f"[HTTP] 收到响应: {len(resp_data)} 字节, hex前60: {resp_data[:60].hex()}")
|
||||||
|
|
||||||
|
# 4. 解析响应 Wup
|
||||||
|
wup_resp = WupResponse()
|
||||||
|
wup_resp.decode(resp_data)
|
||||||
|
if wup_resp.newdata:
|
||||||
|
self.logger(f"[HTTP] 响应 newdata keys: {list(wup_resp.newdata.keys())}")
|
||||||
|
for k, v in wup_resp.newdata.items():
|
||||||
|
self.logger(f"[HTTP] {k} hex({len(v)}): {v.hex()[:200]}")
|
||||||
|
else:
|
||||||
|
self.logger(f"[HTTP] 响应无 newdata (servant={wup_resp.sServantName})")
|
||||||
|
|
||||||
|
if rsp_class is None:
|
||||||
|
return wup_resp
|
||||||
|
|
||||||
|
result = wup_resp.readStruct("tRsp", rsp_class)
|
||||||
|
if result is None:
|
||||||
|
result = wup_resp.readStruct("tResp", rsp_class)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ---------- 业务便捷方法 ----------
|
||||||
|
|
||||||
|
def get_goods_info(self, uid, guid, cookie, pid, spu_id, sku_id=0, game_id="",
|
||||||
|
source_id="yellowcarlist", scene=7):
|
||||||
|
from .shop_structs import GetGoodsInfoReqV5, GoodsInfoRsp
|
||||||
|
req = GetGoodsInfoReqV5()
|
||||||
|
req.userId = self._build_user(uid, guid, cookie)
|
||||||
|
req.shopAppInfo = self._build_shop_app(source_id, scene)
|
||||||
|
req.pid = pid
|
||||||
|
req.spuId = spu_id
|
||||||
|
req.skuId = sku_id
|
||||||
|
req.gameId = game_id
|
||||||
|
return self.call_rpc("shopMiddleUI", "getGoodsInfoV5", req,
|
||||||
|
GoodsInfoRsp, uid, guid, cookie)
|
||||||
|
|
||||||
|
def create_order(self, uid, guid, cookie, pid, spu_id, sku_id,
|
||||||
|
item_count=1, source_id="yellowcarlist", game_id="", scene=7,
|
||||||
|
order_type=None):
|
||||||
|
"""order_type=None 时自动尝试从 1 到 10 找到有效值"""
|
||||||
|
from .shop_structs import (CreateOrderReqV5, CreateOrderRsp,
|
||||||
|
CreateOrderExtraParam, CreateOrderPromotionParam,
|
||||||
|
CreateOrderAccountParam)
|
||||||
|
|
||||||
|
# 如果指定了具体值,直接试
|
||||||
|
types_to_try = [order_type] if order_type is not None else list(range(1, 11))
|
||||||
|
|
||||||
|
last_result = None
|
||||||
|
for ot in types_to_try:
|
||||||
|
req = CreateOrderReqV5()
|
||||||
|
req.userId = self._build_user(uid, guid, cookie)
|
||||||
|
req.shopAppInfo = self._build_shop_app(source_id, scene)
|
||||||
|
req.receiveId = 0
|
||||||
|
req.pid = pid
|
||||||
|
req.spuId = spu_id
|
||||||
|
req.skuId = sku_id
|
||||||
|
req.itemCount = item_count
|
||||||
|
req.gameId = game_id or "0"
|
||||||
|
req.src = scene
|
||||||
|
req.scene = 0
|
||||||
|
req.sourceId = source_id
|
||||||
|
req.orderType = ot
|
||||||
|
req.extraParam = CreateOrderExtraParam()
|
||||||
|
req.env = self._build_order_env(item_count)
|
||||||
|
req.orderScene = scene
|
||||||
|
req.promotionParam = CreateOrderPromotionParam()
|
||||||
|
req.accountParam = CreateOrderAccountParam()
|
||||||
|
req.bizType = 5
|
||||||
|
req.gameCategoryId = 507
|
||||||
|
req.ext = ""
|
||||||
|
|
||||||
|
result = self.call_rpc("shopMiddleUI", "createOrderV5", req,
|
||||||
|
CreateOrderRsp, uid, guid, cookie)
|
||||||
|
if result is not None:
|
||||||
|
self.logger(f" orderType={ot}: code={result.code} orderId={result.orderId} msg={result.message}")
|
||||||
|
if result.code == 200 and result.orderId:
|
||||||
|
self.logger(f"[✓] 找到有效 orderType={ot}")
|
||||||
|
return result
|
||||||
|
last_result = result
|
||||||
|
else:
|
||||||
|
self.logger(f" orderType={ot}: 无响应")
|
||||||
|
return last_result
|
||||||
|
|
||||||
|
def pay_order_submit(self, uid, guid, cookie, order_id, pay_type=1,
|
||||||
|
pid=0, source_id="yellowcarlist", scene=7,
|
||||||
|
item_count=1):
|
||||||
|
"""发起支付,结构与 WSS HAR 中 payOrderSubmitV5 对齐。"""
|
||||||
|
from .shop_structs import PayOrderRes
|
||||||
|
os = TafOutputStream()
|
||||||
|
os.write_struct_begin(0)
|
||||||
|
|
||||||
|
os.write_struct(0, self._build_user(uid, guid, cookie))
|
||||||
|
os.write_struct(1, self._build_shop_app(source_id, scene))
|
||||||
|
os.write_int64(2, order_id)
|
||||||
|
os.write_string(3, "Zfb" if pay_type == 1 else str(pay_type))
|
||||||
|
os.write_string(4, "QrCode")
|
||||||
|
callback_url = (
|
||||||
|
f"https://m-shop.yaoguo.com/index.html#/consumer/paycallback"
|
||||||
|
f"?orderId={order_id}&pid={pid}&sourceId={source_id}"
|
||||||
|
)
|
||||||
|
os.write_string(5, callback_url)
|
||||||
|
os.write_map(6, self._build_order_env(item_count))
|
||||||
|
os.write_string(7, "null")
|
||||||
|
|
||||||
|
os.write_struct_end()
|
||||||
|
|
||||||
|
# 构造 Wup
|
||||||
|
wup = WupRequest()
|
||||||
|
wup.setServant("shopMiddleUI")
|
||||||
|
wup.setFunc("payOrderSubmitV5")
|
||||||
|
wup.setRequestId(1)
|
||||||
|
wup.newdata["tReq"] = os.get_bytes()
|
||||||
|
wup_data = wup.encode()
|
||||||
|
|
||||||
|
baseinfo = generate_http_baseinfo(uid, guid, cookie)
|
||||||
|
url = f"https://{CDNWS_HOST}/?baseinfo={baseinfo}"
|
||||||
|
self.logger(f"[HTTP] POST shopMiddleUI.payOrderSubmitV5")
|
||||||
|
self.logger(f"[HTTP] 发送 hex前60: {wup_data[:60].hex()}")
|
||||||
|
|
||||||
|
req = urllib.request.Request(url, data=wup_data, method='POST', headers={
|
||||||
|
'User-Agent': PC_UA, 'Origin': 'https://zt.huya.com',
|
||||||
|
'Referer': 'https://zt.huya.com/',
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
resp_data = resp.read()
|
||||||
|
if resp_data[:2] == b'\x1f\x8b':
|
||||||
|
import gzip
|
||||||
|
resp_data = gzip.decompress(resp_data)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger(f"[HTTP] ❌ payOrderSubmitV5 失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
self.logger(f"[HTTP] payOrderSubmitV5 响应 {len(resp_data)} 字节, hex前60: {resp_data[:60].hex()}")
|
||||||
|
wup_resp = WupResponse()
|
||||||
|
wup_resp.decode(resp_data)
|
||||||
|
if wup_resp.newdata:
|
||||||
|
self.logger(f"[HTTP] 响应 keys: {list(wup_resp.newdata.keys())}")
|
||||||
|
for k, v in wup_resp.newdata.items():
|
||||||
|
self.logger(f"[HTTP] {k} hex({len(v)}): {v.hex()[:200]}")
|
||||||
|
result = wup_resp.readStruct("tRsp", PayOrderRes)
|
||||||
|
if result is None:
|
||||||
|
result = wup_resp.readStruct("tResp", PayOrderRes)
|
||||||
|
return result
|
||||||
@@ -0,0 +1,583 @@
|
|||||||
|
"""
|
||||||
|
虎牙充值相关 JCE 结构定义
|
||||||
|
字段顺序 = TAF tag 顺序(从 JS 逆向所得)
|
||||||
|
|
||||||
|
来源:m-shop.yaoguo.com 的 jce/ShopFacade.js、api/orderui.ts、api/mall/PlayMallNewHome.ts
|
||||||
|
"""
|
||||||
|
from typing import List, Dict, Optional
|
||||||
|
from .taf_protocol import TafOutputStream, TafInputStream, TafStruct, TafType
|
||||||
|
|
||||||
|
|
||||||
|
class OrderType:
|
||||||
|
HUYA_PHYSICAL = 1
|
||||||
|
TENCENT_VIRTUAL = 2
|
||||||
|
MAGIC_BOX = 3
|
||||||
|
MAGIC_BOX_DELIVERY = 4
|
||||||
|
PAID_COURSE = 5
|
||||||
|
HUYA_VIRTUAL = 6
|
||||||
|
LOTTERY_PICKUP = 7
|
||||||
|
DELIVERY_WORKBENCH = 11
|
||||||
|
GAME_ACCOUNT = 12
|
||||||
|
SHELF_GOODS = 17
|
||||||
|
|
||||||
|
|
||||||
|
class OrderStatus:
|
||||||
|
DEPOSIT_WAIT_PAY = 10
|
||||||
|
DEPOSIT_PAID = 20
|
||||||
|
WAIT_DELIVER = 30
|
||||||
|
WAIT_RECEIVE = 40
|
||||||
|
FINISHED = 50
|
||||||
|
FINISHED_CLOSED = 60
|
||||||
|
CANCELLED = 90
|
||||||
|
BALANCE_WAIT_PAY = 1000
|
||||||
|
CANCELLED_BALANCE_EXPIRED = 1001
|
||||||
|
|
||||||
|
|
||||||
|
def _opt_str(os: TafOutputStream, tag: int, val: str):
|
||||||
|
"""非空才写字符串(与浏览器一致,省默认值)"""
|
||||||
|
if val:
|
||||||
|
os.write_string(tag, val)
|
||||||
|
|
||||||
|
|
||||||
|
def _opt_int(os: TafOutputStream, tag: int, val):
|
||||||
|
"""非 0 才写整数"""
|
||||||
|
if val:
|
||||||
|
os.write_int64(tag, val)
|
||||||
|
|
||||||
|
|
||||||
|
def _opt_struct(os: TafOutputStream, tag: int, val):
|
||||||
|
"""非 None 才写结构体"""
|
||||||
|
if val is not None:
|
||||||
|
os.write_struct(tag, val)
|
||||||
|
|
||||||
|
|
||||||
|
def _opt_list(os: TafOutputStream, tag: int, val):
|
||||||
|
"""非空才写 list"""
|
||||||
|
if val:
|
||||||
|
os.write_list(tag, val)
|
||||||
|
|
||||||
|
|
||||||
|
def _opt_map(os: TafOutputStream, tag: int, val):
|
||||||
|
"""非空才写 map"""
|
||||||
|
if val:
|
||||||
|
os.write_map(tag, val)
|
||||||
|
|
||||||
|
|
||||||
|
def _skip_to_struct_end(ins: TafInputStream):
|
||||||
|
"""跳过当前结构里未解析的尾部字段,停在 STRUCT_END 前。"""
|
||||||
|
while True:
|
||||||
|
pos = ins.buf.tell()
|
||||||
|
tag, dtype = ins.read_head()
|
||||||
|
if dtype == TafType.STRUCT_END:
|
||||||
|
ins.buf.seek(pos)
|
||||||
|
return
|
||||||
|
ins.skip_field(dtype)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 基础结构
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class UserId(TafStruct):
|
||||||
|
"""用户标识(cookie 在这里)"""
|
||||||
|
def __init__(self):
|
||||||
|
self.lUid: int = 0 # tag 0
|
||||||
|
self.sGuid: str = "" # tag 1
|
||||||
|
self.sToken: str = "" # tag 2
|
||||||
|
self.sHuYaUA: str = "" # tag 3 "webh5&0.0.1&websocket&&diypc_52775"
|
||||||
|
self.sCookie: str = "" # tag 4 完整 cookie
|
||||||
|
self.iTokenType: int = 0 # tag 5
|
||||||
|
self.sDeviceInfo: str = "" # tag 6
|
||||||
|
self.sQIMEI: str = "" # tag 7
|
||||||
|
|
||||||
|
def write_to(self, os: TafOutputStream):
|
||||||
|
# HAR2 实证: 浏览器不优化空串/0值, 全写字段
|
||||||
|
os.write_int64(0, self.lUid)
|
||||||
|
os.write_string(1, self.sGuid)
|
||||||
|
os.write_string(2, self.sToken)
|
||||||
|
os.write_string(3, self.sHuYaUA)
|
||||||
|
os.write_string(4, self.sCookie)
|
||||||
|
os.write_int32(5, self.iTokenType)
|
||||||
|
os.write_string(6, self.sDeviceInfo)
|
||||||
|
os.write_string(7, self.sQIMEI)
|
||||||
|
|
||||||
|
def read_from(self, ins: TafInputStream):
|
||||||
|
self.lUid = ins.read_int64(0, default=self.lUid)
|
||||||
|
self.sGuid = ins.read_string(1, default=self.sGuid)
|
||||||
|
self.sToken = ins.read_string(2, default=self.sToken)
|
||||||
|
self.sHuYaUA = ins.read_string(3, default=self.sHuYaUA)
|
||||||
|
self.sCookie = ins.read_string(4, default=self.sCookie)
|
||||||
|
self.iTokenType = ins.read_int32(5, default=self.iTokenType)
|
||||||
|
self.sDeviceInfo = ins.read_string(6, default=self.sDeviceInfo)
|
||||||
|
self.sQIMEI = ins.read_string(7, default=self.sQIMEI)
|
||||||
|
|
||||||
|
|
||||||
|
class ShopAppInfo(TafStruct):
|
||||||
|
"""应用信息(HAR2 实证字段顺序: tag0 sAppId, tag1 sBizType, tag4 scene, tag5 sourceId)"""
|
||||||
|
def __init__(self):
|
||||||
|
self.sAppId: str = "huya" # tag 0
|
||||||
|
self.sBizType: str = "" # tag 1
|
||||||
|
self.scene: int = 0 # tag 4
|
||||||
|
self.sourceId: str = "" # tag 5
|
||||||
|
|
||||||
|
def write_to(self, os: TafOutputStream):
|
||||||
|
# HAR2 实证: 全写字段(含空串/0)
|
||||||
|
os.write_string(0, self.sAppId)
|
||||||
|
os.write_string(1, self.sBizType)
|
||||||
|
os.write_int32(4, self.scene)
|
||||||
|
os.write_string(5, self.sourceId)
|
||||||
|
|
||||||
|
def read_from(self, ins: TafInputStream):
|
||||||
|
self.sAppId = ins.read_string(0, default=self.sAppId)
|
||||||
|
self.sBizType = ins.read_string(1, default=self.sBizType)
|
||||||
|
self.scene = ins.read_int32(4, default=self.scene)
|
||||||
|
self.sourceId = ins.read_string(5, default=self.sourceId)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# wsLaunch 初始化
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class WsLaunchSubStruct(TafStruct):
|
||||||
|
"""wsLaunch tag4 子结构(5个空字符串字段,浏览器强制写)"""
|
||||||
|
def __init__(self):
|
||||||
|
self.s0: str = "" # tag 0
|
||||||
|
self.s1: str = "" # tag 1
|
||||||
|
self.s2: str = "" # tag 2
|
||||||
|
self.s3: str = "" # tag 3
|
||||||
|
self.s4: str = "" # tag 4
|
||||||
|
|
||||||
|
def write_to(self, os: TafOutputStream):
|
||||||
|
# 浏览器写空字符串(STRING1 length0),这里强制写以精确匹配
|
||||||
|
os.write_string(0, self.s0)
|
||||||
|
os.write_string(1, self.s1)
|
||||||
|
os.write_string(2, self.s2)
|
||||||
|
os.write_string(3, self.s3)
|
||||||
|
os.write_string(4, self.s4)
|
||||||
|
|
||||||
|
def read_from(self, ins: TafInputStream):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class WsLaunchReq(TafStruct):
|
||||||
|
"""launch.wsLaunch 请求(tReq 是 STRUCT,不是 list)
|
||||||
|
|
||||||
|
从 HAR 逆向:
|
||||||
|
tag0: lUid (int64)
|
||||||
|
tag1: string ""
|
||||||
|
tag2: sHuYaUA "webh5&1.0.0&huya"
|
||||||
|
tag3: appSrc "HUYA&ZH&2052"
|
||||||
|
tag4: 子struct (5个空字符串)
|
||||||
|
"""
|
||||||
|
def __init__(self):
|
||||||
|
self.lUid: int = 0 # tag 0
|
||||||
|
self.s1: str = "" # tag 1
|
||||||
|
self.sHuYaUA: str = "webh5&1.0.0&huya" # tag 2
|
||||||
|
self.appSrc: str = "HUYA&ZH&2052" # tag 3
|
||||||
|
self.sub: WsLaunchSubStruct = WsLaunchSubStruct() # tag 4
|
||||||
|
|
||||||
|
def write_to(self, os: TafOutputStream):
|
||||||
|
# 注意: 浏览器不优化空字符串,tag1 强制写(HAR 实证)
|
||||||
|
_opt_int(os, 0, self.lUid)
|
||||||
|
os.write_string(1, self.s1) # 强制写空字符串
|
||||||
|
_opt_str(os, 2, self.sHuYaUA)
|
||||||
|
_opt_str(os, 3, self.appSrc)
|
||||||
|
_opt_struct(os, 4, self.sub)
|
||||||
|
|
||||||
|
def read_from(self, ins: TafInputStream):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 商品查询
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class GetGoodsInfoReqV5(TafStruct):
|
||||||
|
"""商品查询请求 (shopMiddleUI.getGoodsInfoV5)"""
|
||||||
|
def __init__(self):
|
||||||
|
self.userId = UserId() # tag 0
|
||||||
|
self.shopAppInfo = ShopAppInfo() # tag 1
|
||||||
|
self.pid: int = 0 # tag 2
|
||||||
|
self.gameId: str = "" # tag 3
|
||||||
|
self.spuId: str = "" # tag 4
|
||||||
|
self.channelStockCode: str = "" # tag 5
|
||||||
|
self.skuId: int = 0 # tag 6
|
||||||
|
self.inviterUid: int = 0 # tag 7
|
||||||
|
self.userModifyPriceId: int = 0 # tag 8
|
||||||
|
|
||||||
|
def write_to(self, os: TafOutputStream):
|
||||||
|
# HAR2 实证: 全写字段
|
||||||
|
os.write_struct(0, self.userId)
|
||||||
|
os.write_struct(1, self.shopAppInfo)
|
||||||
|
os.write_int64(2, self.pid)
|
||||||
|
os.write_string(3, self.gameId)
|
||||||
|
os.write_string(4, self.spuId)
|
||||||
|
os.write_string(5, self.channelStockCode)
|
||||||
|
os.write_int64(6, self.skuId)
|
||||||
|
os.write_int64(7, self.inviterUid)
|
||||||
|
os.write_int64(8, self.userModifyPriceId)
|
||||||
|
|
||||||
|
def read_from(self, ins: TafInputStream):
|
||||||
|
# 请求结构体一般不需要读,但保留
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class GoodsInfoRsp(TafStruct):
|
||||||
|
"""商品查询响应(简化:只取关键字段)"""
|
||||||
|
def __init__(self):
|
||||||
|
self.code: int = 0 # tag 0
|
||||||
|
self.message: str = "" # tag 1
|
||||||
|
# tag 2 是 goodsInfo 结构,简化跳过
|
||||||
|
self.selfGoods: int = 0 # tag 3
|
||||||
|
self.marketStatus: int = 0 # tag 5
|
||||||
|
self.timestamp: int = 0 # tag 6
|
||||||
|
|
||||||
|
def read_from(self, ins: TafInputStream):
|
||||||
|
self.code = ins.read_int32(0, default=self.code)
|
||||||
|
self.message = ins.read_string(1, default=self.message)
|
||||||
|
# goodsInfo (tag 2) 跳过
|
||||||
|
self.selfGoods = ins.read_int32(3, default=self.selfGoods)
|
||||||
|
self.marketStatus = ins.read_int32(5, default=self.marketStatus)
|
||||||
|
self.timestamp = ins.read_int64(6, default=self.timestamp)
|
||||||
|
|
||||||
|
def write_to(self, os: TafOutputStream):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 订单历史
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class OrderListShopInfo(TafStruct):
|
||||||
|
"""订单明细里的店铺信息(只取展示需要的字段)"""
|
||||||
|
def __init__(self):
|
||||||
|
self.shopName: str = "" # tag 0
|
||||||
|
|
||||||
|
def read_from(self, ins: TafInputStream):
|
||||||
|
self.shopName = ins.read_string(0, default=self.shopName)
|
||||||
|
_skip_to_struct_end(ins)
|
||||||
|
|
||||||
|
def write_to(self, os: TafOutputStream):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class OrderListGoodsDetail(TafStruct):
|
||||||
|
"""订单明细(queryUserOrderList 响应 tag16)"""
|
||||||
|
def __init__(self):
|
||||||
|
self.spuId: str = "" # tag 4
|
||||||
|
self.skuId: int = 0 # tag 16
|
||||||
|
self.buyerUid: int = 0 # tag 18
|
||||||
|
self.virtualType: int = 0 # tag 19
|
||||||
|
self.quantity: int = 0 # tag 20
|
||||||
|
self.shopInfo: Optional[OrderListShopInfo] = None # tag 21
|
||||||
|
self.points: int = 0 # tag 23
|
||||||
|
|
||||||
|
def read_from(self, ins: TafInputStream):
|
||||||
|
self.spuId = ins.read_string(4, default=self.spuId)
|
||||||
|
self.skuId = ins.read_int64(16, default=self.skuId)
|
||||||
|
self.buyerUid = ins.read_int64(18, default=self.buyerUid)
|
||||||
|
self.virtualType = ins.read_int32(19, default=self.virtualType)
|
||||||
|
self.quantity = ins.read_int64(20, default=self.quantity)
|
||||||
|
self.shopInfo = ins.read_struct(21, OrderListShopInfo)
|
||||||
|
self.points = ins.read_int64(23, default=self.points)
|
||||||
|
_skip_to_struct_end(ins)
|
||||||
|
|
||||||
|
def write_to(self, os: TafOutputStream):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class OrderListItem(TafStruct):
|
||||||
|
"""订单列表项(queryUserOrderList 响应 tag3 的 list 元素)"""
|
||||||
|
def __init__(self):
|
||||||
|
self.bizOrderId: str = "" # tag 0 shop10148750
|
||||||
|
self.appId: str = "" # tag 1 shop
|
||||||
|
self.orderId: str = "" # tag 2
|
||||||
|
self.pid: int = 0 # tag 3
|
||||||
|
self.shopName: str = "" # tag 4
|
||||||
|
self.orderStatus: int = 0 # tag 5
|
||||||
|
self.itemName: str = "" # tag 8
|
||||||
|
self.unitPrice: int = 0 # tag 9 分
|
||||||
|
self.quantity: int = 0 # tag 10
|
||||||
|
self.totalPrice: int = 0 # tag 12 分
|
||||||
|
self.createTime: int = 0 # tag 14 毫秒时间戳
|
||||||
|
self.payTime: int = 0 # tag 15 毫秒时间戳
|
||||||
|
self.goodsDetail: Optional[OrderListGoodsDetail] = None # tag 16
|
||||||
|
|
||||||
|
def read_from(self, ins: TafInputStream):
|
||||||
|
self.bizOrderId = ins.read_string(0, default=self.bizOrderId)
|
||||||
|
self.appId = ins.read_string(1, default=self.appId)
|
||||||
|
self.orderId = ins.read_string(2, default=self.orderId)
|
||||||
|
self.pid = ins.read_int64(3, default=self.pid)
|
||||||
|
self.shopName = ins.read_string(4, default=self.shopName)
|
||||||
|
self.orderStatus = ins.read_int32(5, default=self.orderStatus)
|
||||||
|
self.itemName = ins.read_string(8, default=self.itemName)
|
||||||
|
self.unitPrice = ins.read_int64(9, default=self.unitPrice)
|
||||||
|
self.quantity = ins.read_int64(10, default=self.quantity)
|
||||||
|
self.totalPrice = ins.read_int64(12, default=self.totalPrice)
|
||||||
|
self.createTime = ins.read_int64(14, default=self.createTime)
|
||||||
|
self.payTime = ins.read_int64(15, default=self.payTime)
|
||||||
|
self.goodsDetail = ins.read_struct(16, OrderListGoodsDetail)
|
||||||
|
_skip_to_struct_end(ins)
|
||||||
|
|
||||||
|
def write_to(self, os: TafOutputStream):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class QueryUserOrderListReq(TafStruct):
|
||||||
|
"""查询购买历史订单 (revenueWebUI.queryUserOrderList)"""
|
||||||
|
def __init__(self):
|
||||||
|
self.userId = UserId() # tag 0
|
||||||
|
self.offset: int = 0 # tag 1
|
||||||
|
self.orderType: int = 1 # tag 2
|
||||||
|
self.pageSize: int = 10 # tag 3
|
||||||
|
self.status: int = 0 # tag 4
|
||||||
|
|
||||||
|
def write_to(self, os: TafOutputStream):
|
||||||
|
os.write_struct(0, self.userId)
|
||||||
|
os.write_int32(1, self.offset)
|
||||||
|
os.write_int32(2, self.orderType)
|
||||||
|
os.write_int32(3, self.pageSize)
|
||||||
|
os.write_int32(4, self.status)
|
||||||
|
|
||||||
|
def read_from(self, ins: TafInputStream):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class QueryUserOrderListRsp(TafStruct):
|
||||||
|
"""购买历史订单响应"""
|
||||||
|
def __init__(self):
|
||||||
|
self.code: int = 0
|
||||||
|
self.message: str = ""
|
||||||
|
self.totalCount: int = 0
|
||||||
|
self.orders: List[OrderListItem] = []
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _read_order_item(ins: TafInputStream, _tag: int):
|
||||||
|
item = OrderListItem()
|
||||||
|
item.read_from(ins)
|
||||||
|
_end_tag, dtype = ins.read_head()
|
||||||
|
if dtype != TafType.STRUCT_END:
|
||||||
|
raise ValueError(f"期望订单 STRUCT_END,实际 0x{dtype:02x}")
|
||||||
|
return item
|
||||||
|
|
||||||
|
def read_from(self, ins: TafInputStream):
|
||||||
|
self.code = ins.read_int32(0, default=self.code)
|
||||||
|
self.message = ins.read_string(1, default=self.message)
|
||||||
|
self.totalCount = ins.read_int64(2, default=self.totalCount)
|
||||||
|
self.orders = ins.read_list(3, item_reader=self._read_order_item)
|
||||||
|
|
||||||
|
def write_to(self, os: TafOutputStream):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 下单
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class CreateOrderExtraParam(TafStruct):
|
||||||
|
def __init__(self):
|
||||||
|
self.freight: int = 0 # tag 0
|
||||||
|
self.channelStockType: str = "" # tag 1
|
||||||
|
self.channelStockCode: str = "" # tag 2
|
||||||
|
self.relatedBizId: str = "" # tag 3
|
||||||
|
self.bizParams: str = "" # tag 4
|
||||||
|
self.popupTraceId: str = "" # tag 5
|
||||||
|
self.supplierUid: int = 0 # tag 6
|
||||||
|
self.categoryId: str = "" # tag 7
|
||||||
|
self.ext: str = "" # tag 8
|
||||||
|
|
||||||
|
def write_to(self, os: TafOutputStream):
|
||||||
|
# HAR 实证:下单 extraParam 会强制写默认 0/空串字段
|
||||||
|
os.write_int64(0, self.freight)
|
||||||
|
os.write_string(1, self.channelStockType)
|
||||||
|
os.write_string(2, self.channelStockCode)
|
||||||
|
os.write_string(3, self.relatedBizId)
|
||||||
|
os.write_string(4, self.bizParams)
|
||||||
|
os.write_string(5, self.popupTraceId)
|
||||||
|
os.write_int64(6, self.supplierUid)
|
||||||
|
if self.categoryId:
|
||||||
|
os.write_string(7, self.categoryId)
|
||||||
|
os.write_string(8, self.ext)
|
||||||
|
|
||||||
|
def read_from(self, ins: TafInputStream):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CreateOrderPromotionParam(TafStruct):
|
||||||
|
def __init__(self):
|
||||||
|
self.yxjDeductPrice: int = 0 # tag 0
|
||||||
|
self.userModifyPriceId: int = 0 # tag 1
|
||||||
|
self.enablePromotion: int = 1 # tag 2
|
||||||
|
|
||||||
|
def write_to(self, os: TafOutputStream):
|
||||||
|
# HAR 实证:tag0/tag1 为 0,tag2 为 1
|
||||||
|
os.write_int64(0, self.yxjDeductPrice)
|
||||||
|
os.write_int64(1, self.userModifyPriceId)
|
||||||
|
os.write_int32(2, self.enablePromotion)
|
||||||
|
|
||||||
|
def read_from(self, ins: TafInputStream):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CreateOrderAccountParam(TafStruct):
|
||||||
|
def __init__(self):
|
||||||
|
self.payoutTypeList: List[int] = [] # tag 0 Vector<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 风控跳转URL(code!=200时可能有)
|
||||||
|
|
||||||
|
def read_from(self, ins: TafInputStream):
|
||||||
|
self.code = ins.read_int32(0, default=self.code)
|
||||||
|
self.message = ins.read_string(1, default=self.message)
|
||||||
|
self.orderId = ins.read_int64(2, default=self.orderId)
|
||||||
|
self.subOrderId = ins.read_int64(4, default=self.subOrderId)
|
||||||
|
self.orderStatus = ins.read_int32(5, default=self.orderStatus)
|
||||||
|
self.riskUrl = ins.read_string(8, default=self.riskUrl)
|
||||||
|
|
||||||
|
def write_to(self, os: TafOutputStream):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 支付(payOrderSubmitV5)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class PayOrderRes(TafStruct):
|
||||||
|
"""
|
||||||
|
发起支付响应 (shopMiddleUI.payOrderSubmitV5)
|
||||||
|
结构从 state_shop_ts.js 的 payOrderRes 推断,tag 顺序按出现顺序
|
||||||
|
"""
|
||||||
|
def __init__(self):
|
||||||
|
self.code: int = 0 # tag 0
|
||||||
|
self.message: str = "" # tag 1
|
||||||
|
self.orderId: int = 0 # tag 2 虎牙订单号
|
||||||
|
self.appOrderId: str = "" # tag 3
|
||||||
|
self.payOrderId: str = "" # tag 4 支付宝 payOrderId
|
||||||
|
self.payUrl: str = "" # tag 5 支付宝下单URL(含sign)
|
||||||
|
self.amount: int = 0 # tag 6
|
||||||
|
|
||||||
|
def read_from(self, ins: TafInputStream):
|
||||||
|
self.code = ins.read_int32(0, default=self.code)
|
||||||
|
self.message = ins.read_string(1, default=self.message)
|
||||||
|
self.orderId = ins.read_int64(2, default=self.orderId)
|
||||||
|
self.appOrderId = ins.read_string(3, default=self.appOrderId)
|
||||||
|
self.payOrderId = ins.read_string(4, default=self.payOrderId)
|
||||||
|
self.payUrl = ins.read_string(5, default=self.payUrl)
|
||||||
|
self.amount = ins.read_int64(6, default=self.amount)
|
||||||
|
|
||||||
|
def write_to(self, os: TafOutputStream):
|
||||||
|
pass
|
||||||
@@ -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()})"
|
||||||
@@ -0,0 +1,709 @@
|
|||||||
|
"""
|
||||||
|
虎牙 WSS 客户端 — 商城通道 (77bc035c-ws.va.huya.com)
|
||||||
|
|
||||||
|
初始化流程 (Chrome WS 帧实证):
|
||||||
|
1. HEARTBEAT_SEND (cmd 0x10)
|
||||||
|
2. RPC: launch.wsLaunch (cmd 0x03) — 商城版无 lUid
|
||||||
|
3. RPC: mobileui.getConfig (cmd 0x03)
|
||||||
|
4. 等待 wsLaunch/getConfig 响应
|
||||||
|
5. REGISTER (cmd 0x21) — 回传 wsLaunch 返回的 sGuid
|
||||||
|
6. CONFIRM_SEND (cmd 0x17)
|
||||||
|
7. 业务 RPC ...
|
||||||
|
|
||||||
|
关键发现 (Chrome WS 帧分析):
|
||||||
|
- 服务端响应 seq ≠ 请求 seq → 必须用 FIFO 匹配
|
||||||
|
- 初始化 wsLaunch iRequestId=-1,业务 requestId 从 8 开始递增
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
from typing import Optional, Callable
|
||||||
|
|
||||||
|
import websockets
|
||||||
|
|
||||||
|
from .wup_protocol import WupRequest, WupResponse
|
||||||
|
from .taf_protocol import TafOutputStream, TafInputStream, TafType, TafStruct
|
||||||
|
from .frame_decoder import format_wss_log, _decode_taf_struct, _truncate
|
||||||
|
|
||||||
|
|
||||||
|
SHOP_WS_HOST = "77bc035c-ws.va.huya.com"
|
||||||
|
# 商城端点 baseinfo (conn4, h5_/index.html) — 商城业务 shopMiddleUI 走此通道
|
||||||
|
# wsLaunch tReq: lUid=0, sGuid="", sUA="webh5&0.0.1&websocket&&h5_/index.html"
|
||||||
|
SHOP_BASEINFO = "DBYAJiV3ZWJoNSYwLjAuMSZ3ZWJzb2NrZXQmJmg1Xy9pbmRleC5odG1sNgxIVVlBJlpIJjIwNTJGAFYAbHYAhgCWAKgM"
|
||||||
|
|
||||||
|
TAIL_BYTES = bytes.fromhex("2c36004c5c6600")
|
||||||
|
BIG_TAIL_SUFFIX = bytes.fromhex("4c5c6600")
|
||||||
|
WSS_BIZ_UA = "web&1.0.0&huya"
|
||||||
|
WSS_COOKIE_UA = "webh5&0.0.1&websocket&&diypc_52775"
|
||||||
|
WSS_SHOP_SCENE = 4
|
||||||
|
|
||||||
|
# seq 按消息类型固定 (HAR 实证,跨两次抓包一致)
|
||||||
|
SEQ_HEARTBEAT = 0x1d00000d
|
||||||
|
SEQ_WSLAUNCH = 0x1d000078
|
||||||
|
SEQ_GETCONFIG = 0x1d000076
|
||||||
|
SEQ_REGISTER = 0x1d00003c
|
||||||
|
SEQ_CONFIRM = 0x1d000002
|
||||||
|
SEQ_BUSINESS = 0x1d000106 # 业务 RPC (HAR 7.4 实证:业务帧固定使用该 seq)
|
||||||
|
|
||||||
|
|
||||||
|
class WssCommand:
|
||||||
|
RPC_REQUEST = 0x03
|
||||||
|
RPC_RESPONSE = 0x04
|
||||||
|
AUTH = 0x0a
|
||||||
|
SERVER_PUSH1 = 0x0b
|
||||||
|
HEARTBEAT_SEND = 0x10
|
||||||
|
HEARTBEAT_RECV = 0x11
|
||||||
|
CONFIRM_SEND = 0x17
|
||||||
|
SERVER_PUSH2 = 0x18
|
||||||
|
REGISTER = 0x21
|
||||||
|
|
||||||
|
|
||||||
|
class WssMessage:
|
||||||
|
def __init__(self, command: int, sequence: int, body: bytes):
|
||||||
|
self.command = command
|
||||||
|
self.sequence = sequence
|
||||||
|
self.body = body
|
||||||
|
|
||||||
|
def encode(self) -> bytes:
|
||||||
|
header = struct.pack('>BBI', 0x00, self.command, self.sequence)
|
||||||
|
return header + self.body
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def decode(cls, data: bytes) -> 'WssMessage':
|
||||||
|
if len(data) < 6:
|
||||||
|
raise ValueError(f"消息太短: {len(data)} bytes")
|
||||||
|
version, command = struct.unpack('>BB', data[0:2])
|
||||||
|
sequence = struct.unpack('>I', data[2:6])[0]
|
||||||
|
body = data[6:]
|
||||||
|
return cls(command=command, sequence=sequence, body=body)
|
||||||
|
|
||||||
|
|
||||||
|
class HuyaWssClient:
|
||||||
|
"""虎牙 WSS 客户端 — 商城通道"""
|
||||||
|
|
||||||
|
def __init__(self, baseinfo: str = None, logger: Callable[[str], None] = None):
|
||||||
|
self.baseinfo = baseinfo or SHOP_BASEINFO
|
||||||
|
self.ws = None
|
||||||
|
self._biz_seq = SEQ_BUSINESS # 业务 RPC 递增用
|
||||||
|
self._wup_req_id = 8
|
||||||
|
self.rpc_queue = deque()
|
||||||
|
self.logger = logger or print
|
||||||
|
self._recv_task = None
|
||||||
|
self._heartbeat_task = None
|
||||||
|
self._launch_guid = ""
|
||||||
|
self._launch_ip = ""
|
||||||
|
|
||||||
|
def _next_biz_seq(self) -> int:
|
||||||
|
"""业务 RPC 的 frame seq。
|
||||||
|
|
||||||
|
浏览器在商城 WSS 上连续发送多个业务 RPC 时,frame seq 都保持
|
||||||
|
0x1d000106;真正区分请求依赖 WUP requestId。
|
||||||
|
"""
|
||||||
|
return SEQ_BUSINESS
|
||||||
|
|
||||||
|
def _next_wup_req_id(self) -> int:
|
||||||
|
"""业务 WUP requestId,HAR 中 getGoodsInfoV5 从 8 开始递增"""
|
||||||
|
req_id = self._wup_req_id
|
||||||
|
self._wup_req_id += 1
|
||||||
|
return req_id
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _make_big_tail() -> bytes:
|
||||||
|
"""生成 WSS 大包尾部 trace(格式与浏览器 HAR 一致)"""
|
||||||
|
trace = f"{random.getrandbits(64):016x}"
|
||||||
|
return f",6%{trace}:{trace}:0:3".encode("ascii") + BIG_TAIL_SUFFIX
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _encode_rpc_body(wup_data: bytes) -> bytes:
|
||||||
|
"""按浏览器规则封装 WSS RPC body
|
||||||
|
|
||||||
|
小包: [4B WUP长度][WUP body] + TAIL_BYTES
|
||||||
|
大包: [1B len低字节][4B WUP body长度][裸WUP body] + trace尾部
|
||||||
|
"""
|
||||||
|
if len(wup_data) < 4:
|
||||||
|
return wup_data + TAIL_BYTES
|
||||||
|
declared_len = int.from_bytes(wup_data[:4], "big")
|
||||||
|
if declared_len == len(wup_data):
|
||||||
|
wup_body = wup_data[4:]
|
||||||
|
else:
|
||||||
|
wup_body = wup_data
|
||||||
|
if len(wup_body) <= 0xFF:
|
||||||
|
return wup_data + TAIL_BYTES
|
||||||
|
# 大包头里的长度使用原始 WUP 包长度(含 4B 长度前缀),但正文只放裸 WUP body;
|
||||||
|
# 浏览器随后用 trace 尾部的前 4 字节补足该长度窗口。
|
||||||
|
packet_len = len(wup_data)
|
||||||
|
return bytes([packet_len & 0xFF]) + packet_len.to_bytes(4, "big") + wup_body + HuyaWssClient._make_big_tail()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_biz_cookie(cookie: str) -> str:
|
||||||
|
"""业务 RPC 的 huya_ua Cookie 值按浏览器 WSS 抓包修正"""
|
||||||
|
cookie = (cookie or "").strip()
|
||||||
|
normalized = f"huya_ua={WSS_COOKIE_UA}"
|
||||||
|
if re.search(r"(?:^|;\s*)huya_ua=", cookie):
|
||||||
|
return re.sub(r"(^|;\s*)huya_ua=[^;]*", lambda m: f"{m.group(1)}{normalized}", cookie, count=1)
|
||||||
|
if not cookie:
|
||||||
|
return normalized
|
||||||
|
return f"{normalized}; {cookie}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_biz_user(uid: int, cookie: str):
|
||||||
|
"""构造 WSS 业务 UserId(HAR 实证:guid 为空,cookie 不额外加前缀)"""
|
||||||
|
from .shop_structs import UserId
|
||||||
|
user = UserId()
|
||||||
|
user.lUid = uid
|
||||||
|
user.sGuid = ""
|
||||||
|
user.sToken = ""
|
||||||
|
user.sHuYaUA = WSS_BIZ_UA
|
||||||
|
user.sCookie = HuyaWssClient._normalize_biz_cookie(cookie)
|
||||||
|
user.iTokenType = 0
|
||||||
|
user.sDeviceInfo = ""
|
||||||
|
user.sQIMEI = ""
|
||||||
|
return user
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_shop_app(source_id: str = "yellowcarlist", scene: int = WSS_SHOP_SCENE):
|
||||||
|
"""构造 WSS 业务 ShopAppInfo"""
|
||||||
|
from .shop_structs import ShopAppInfo
|
||||||
|
info = ShopAppInfo()
|
||||||
|
info.sAppId = "huya"
|
||||||
|
info.sBizType = ""
|
||||||
|
info.scene = scene
|
||||||
|
info.sourceId = source_id
|
||||||
|
return info
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_order_env(item_count: int = 1):
|
||||||
|
"""构造下单/支付环境参数"""
|
||||||
|
click_time = int(time.time() * 1000)
|
||||||
|
return {
|
||||||
|
"user_click_seq": f'[{{"x":258,"y":487,"time":{click_time},"id":""}}]',
|
||||||
|
"input_1": str(item_count),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def connect(self, host: str = SHOP_WS_HOST, timeout: float = 15.0,
|
||||||
|
cookie: str = ""):
|
||||||
|
from urllib.parse import quote
|
||||||
|
url = f"wss://{host}/?baseinfo={quote(self.baseinfo)}"
|
||||||
|
self.logger(f"[WSS] 正在连接 {host} ...")
|
||||||
|
self.logger(f"[WSS] URL: {url[:100]}...")
|
||||||
|
|
||||||
|
BROWSER_UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
|
"Chrome/149.0.0.0 Safari/537.36")
|
||||||
|
headers = {"Accept-Language": "zh-CN,zh;q=0.9"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.ws = await asyncio.wait_for(
|
||||||
|
websockets.connect(
|
||||||
|
url,
|
||||||
|
origin="https://m-shop.yaoguo.com",
|
||||||
|
user_agent_header=BROWSER_UA,
|
||||||
|
additional_headers=headers,
|
||||||
|
open_timeout=timeout,
|
||||||
|
),
|
||||||
|
timeout=timeout)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
self.logger(f"[WSS] 连接超时({timeout}s)")
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
self.logger(f"[WSS] 连接失败: {type(e).__name__}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
self.logger(f"[WSS] 握手成功, 实际连接: {self.ws.remote_address}")
|
||||||
|
self._recv_task = asyncio.create_task(self._recv_loop())
|
||||||
|
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
||||||
|
|
||||||
|
async def disconnect(self):
|
||||||
|
if self._heartbeat_task:
|
||||||
|
self._heartbeat_task.cancel()
|
||||||
|
if self._recv_task:
|
||||||
|
self._recv_task.cancel()
|
||||||
|
if self.ws:
|
||||||
|
await self.ws.close()
|
||||||
|
self.ws = None
|
||||||
|
self.logger("[WSS] 已断开")
|
||||||
|
|
||||||
|
async def _recv_loop(self):
|
||||||
|
try:
|
||||||
|
async for raw in self.ws:
|
||||||
|
try:
|
||||||
|
msg = WssMessage.decode(raw)
|
||||||
|
self.logger(format_wss_log(msg.body, msg.command, msg.sequence, "收"))
|
||||||
|
await self._handle_message(msg)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger(f"[WSS] 解析消息失败: {e} raw_hex={raw[:50].hex()}")
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
except websockets.exceptions.ConnectionClosed as e:
|
||||||
|
self.logger(f"[WSS] 连接关闭: code={e.code} reason={e.reason}")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger(f"[WSS] 接收循环异常: {type(e).__name__}: {e}")
|
||||||
|
|
||||||
|
async def _handle_message(self, msg: WssMessage):
|
||||||
|
if msg.command == WssCommand.RPC_RESPONSE:
|
||||||
|
if self.rpc_queue:
|
||||||
|
future = self.rpc_queue.popleft()
|
||||||
|
if not future.done():
|
||||||
|
future.set_result(msg.body)
|
||||||
|
else:
|
||||||
|
self.logger(f"[WSS] 未匹配的 RPC 响应 seq={msg.sequence}")
|
||||||
|
elif msg.command == WssCommand.HEARTBEAT_RECV:
|
||||||
|
pass # 心跳已在上层 _recv_loop 打过日志
|
||||||
|
elif msg.command in (WssCommand.SERVER_PUSH1, WssCommand.SERVER_PUSH2):
|
||||||
|
pass # 推送已在上层打过日志
|
||||||
|
else:
|
||||||
|
pass # 未知消息已在上层打过日志
|
||||||
|
|
||||||
|
async def _heartbeat_loop(self):
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(8)
|
||||||
|
await self.send_heartbeat()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
self.logger(f"[WSS] 心跳循环异常: {e}")
|
||||||
|
|
||||||
|
async def send_heartbeat(self):
|
||||||
|
# body = LIST(tag0,count=1) + 元素{tag0="live:0", tag1=""} + TAIL
|
||||||
|
os = TafOutputStream()
|
||||||
|
os.write_head(0, TafType.LIST) # 09
|
||||||
|
os.write_int32(0, 1) # 00 01 (count=1)
|
||||||
|
os.write_string(0, "live:0") # 06 06 live:0
|
||||||
|
os.buf.write(b'\x16\x00') # 16 00 (tag1 "")
|
||||||
|
body = os.get_bytes() + TAIL_BYTES
|
||||||
|
msg = WssMessage(command=WssCommand.HEARTBEAT_SEND,
|
||||||
|
sequence=SEQ_HEARTBEAT, body=body)
|
||||||
|
self.logger(format_wss_log(body, WssCommand.HEARTBEAT_SEND, SEQ_HEARTBEAT, "发"))
|
||||||
|
await self.ws.send(msg.encode())
|
||||||
|
|
||||||
|
async def initialize(self, uid: int, guid: str, cookie: str):
|
||||||
|
"""商城端点初始化: heartbeat → wsLaunch → getConfig → confirm
|
||||||
|
|
||||||
|
商城 wsLaunch 无 lUid/sGuid (匿名), tReq:
|
||||||
|
tag0 lUid=0, tag1 sGuid="", tag2 sUA="webh5&0.0.1&websocket&&h5_/index.html",
|
||||||
|
tag3 sAppSrc="HUYA&ZH&2052", tag4 tDeviceInfo={5空串}
|
||||||
|
"""
|
||||||
|
self.logger("[初始化] 开始 WSS 初始化流程 (商城端点)...")
|
||||||
|
|
||||||
|
await self.send_heartbeat()
|
||||||
|
|
||||||
|
launch_rsp = await self.call_ws_launch_shop()
|
||||||
|
if launch_rsp is None:
|
||||||
|
self.logger("[✗] wsLaunch 无响应,初始化失败")
|
||||||
|
return False
|
||||||
|
self.logger(f"[初始化] wsLaunch OK guid={self._launch_guid} ip={self._launch_ip}")
|
||||||
|
|
||||||
|
config_rsp = await self.call_get_config_shop()
|
||||||
|
self.logger(f"[初始化] getConfig {'OK' if config_rsp is not None else '无响应(可继续)'}")
|
||||||
|
|
||||||
|
if self._launch_guid:
|
||||||
|
await self.send_register(self._launch_guid)
|
||||||
|
|
||||||
|
await self.send_confirm()
|
||||||
|
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
self.logger("[初始化] 完成 ✓")
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def call_ws_launch_shop(self, timeout: float = 10.0):
|
||||||
|
"""商城版 wsLaunch (匿名, 无 lUid/sGuid, 与 conn4 格式一致)"""
|
||||||
|
ua = "webh5&0.0.1&websocket&&h5_/index.html"
|
||||||
|
app_src = "HUYA&ZH&2052"
|
||||||
|
|
||||||
|
os = TafOutputStream()
|
||||||
|
os.write_struct_begin(0)
|
||||||
|
os.write_int64(0, 0) # lUid = 0
|
||||||
|
os.write_string(1, "") # sGuid = ""
|
||||||
|
os.write_string(2, ua) # sUA
|
||||||
|
os.write_string(3, app_src) # sAppSrc
|
||||||
|
os.write_struct_begin(4) # tDeviceInfo
|
||||||
|
for i in range(5):
|
||||||
|
os.write_string(i, "")
|
||||||
|
os.write_struct_end()
|
||||||
|
os.write_struct_end()
|
||||||
|
|
||||||
|
wup = WupRequest()
|
||||||
|
wup.setServant("launch")
|
||||||
|
wup.setFunc("wsLaunch")
|
||||||
|
wup.setRequestId(-1)
|
||||||
|
wup.iTimeout = 0
|
||||||
|
wup.newdata["tReq"] = os.get_bytes()
|
||||||
|
wup_data = wup.encode()
|
||||||
|
body = self._encode_rpc_body(wup_data)
|
||||||
|
|
||||||
|
seq = SEQ_WSLAUNCH
|
||||||
|
msg = WssMessage(command=WssCommand.RPC_REQUEST,
|
||||||
|
sequence=seq, body=body)
|
||||||
|
future = asyncio.Future()
|
||||||
|
self.rpc_queue.append(future)
|
||||||
|
self.logger(format_wss_log(body, WssCommand.RPC_REQUEST, seq, "发"))
|
||||||
|
await self.ws.send(msg.encode())
|
||||||
|
|
||||||
|
try:
|
||||||
|
body = await asyncio.wait_for(future, timeout)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
if future in self.rpc_queue:
|
||||||
|
self.rpc_queue.remove(future)
|
||||||
|
self.logger("[✗] wsLaunch 超时")
|
||||||
|
return None
|
||||||
|
|
||||||
|
self._parse_launch_response(body)
|
||||||
|
return body
|
||||||
|
|
||||||
|
def _parse_launch_response(self, body: bytes):
|
||||||
|
"""解析 wsLaunch 响应 (WSLaunchRsp: tag0=sGuid, tag1=sClientIp)"""
|
||||||
|
try:
|
||||||
|
# 从 body 中提取干净的 WUP 包(去掉尾部垃圾字节)
|
||||||
|
wup = self._extract_wup(body)
|
||||||
|
wup_resp = WupResponse()
|
||||||
|
wup_resp.decode(wup)
|
||||||
|
treq = wup_resp.newdata.get("tRsp") or wup_resp.newdata.get("tResp")
|
||||||
|
if not treq:
|
||||||
|
self.logger(f"[RPC] wsLaunch 响应无 tRsp, keys={list(wup_resp.newdata.keys())}")
|
||||||
|
return
|
||||||
|
ins = TafInputStream(treq)
|
||||||
|
tag, dtype = ins.peek_head()
|
||||||
|
if dtype != 0x0a: # STRUCT_BEGIN
|
||||||
|
self.logger(f"[RPC] wsLaunch tRsp 非结构体 dtype=0x{dtype:02x}")
|
||||||
|
return
|
||||||
|
ins.read_head()
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
ftag, ftype = ins.peek_head()
|
||||||
|
except EOFError:
|
||||||
|
break
|
||||||
|
if ftype == 0x0b: # STRUCT_END
|
||||||
|
ins.read_head()
|
||||||
|
break
|
||||||
|
ins.read_head()
|
||||||
|
if ftag == 0 and ftype in (0x06, 0x07):
|
||||||
|
ln = ins.buf.read(1)[0] if ftype == 0x06 else int.from_bytes(ins.buf.read(4), 'big')
|
||||||
|
self._launch_guid = ins.buf.read(ln).decode('utf-8', 'replace')
|
||||||
|
elif ftag == 1 and ftype in (0x06, 0x07):
|
||||||
|
ln = ins.buf.read(1)[0] if ftype == 0x06 else int.from_bytes(ins.buf.read(4), 'big')
|
||||||
|
self._launch_ip = ins.buf.read(ln).decode('utf-8', 'replace')
|
||||||
|
else:
|
||||||
|
ins.skip_field(ftype)
|
||||||
|
self.logger(f"[RPC] wsLaunch 解析: guid={self._launch_guid} ip={self._launch_ip}")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger(f"[RPC] wsLaunch 响应解析失败: {e}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_wup(body: bytes) -> bytes:
|
||||||
|
"""从 WSS 响应 body 中提取干净的 WUP 包
|
||||||
|
|
||||||
|
响应 body 格式:
|
||||||
|
小包: [4B BE total_len][wup_body][tail]
|
||||||
|
大包: [1B prefix][4B BE wup_body_len][wup_body][tail]
|
||||||
|
"""
|
||||||
|
if len(body) < 5:
|
||||||
|
return body
|
||||||
|
# 大包格式需优先判断;prefix 可能是 0x00,容易被误判成小包长度 8。
|
||||||
|
wup_len = int.from_bytes(body[1:5], 'big')
|
||||||
|
if 5 + wup_len <= len(body) and body[5:7] == b'\x10\x03':
|
||||||
|
return body[5:5 + wup_len]
|
||||||
|
# 尝试 4B total_len 小包格式
|
||||||
|
total_len = int.from_bytes(body[0:4], 'big')
|
||||||
|
if 8 <= total_len <= len(body) and body[4:6] == b'\x10\x03':
|
||||||
|
# total_len 包含自身 4B
|
||||||
|
return body[:total_len]
|
||||||
|
if 5 + wup_len <= len(body):
|
||||||
|
return body[5:5 + wup_len]
|
||||||
|
return body
|
||||||
|
|
||||||
|
async def send_auth(self, cookie: str):
|
||||||
|
"""cmd 0x0a AUTH — 发送 cookie"""
|
||||||
|
ua = "webh5&0.0.1&websocket&&diypc_52775"
|
||||||
|
auth_text = f"huya_ua={ua}; {cookie}"
|
||||||
|
msg = WssMessage(command=WssCommand.AUTH,
|
||||||
|
sequence=SEQ_WSLAUNCH,
|
||||||
|
body=auth_text.encode('utf-8') + TAIL_BYTES)
|
||||||
|
await self.ws.send(msg.encode())
|
||||||
|
self.logger(format_wss_log(auth_text.encode('utf-8') + TAIL_BYTES, WssCommand.AUTH, SEQ_WSLAUNCH, "发"))
|
||||||
|
|
||||||
|
async def call_get_config_shop(self, timeout: float = 10.0):
|
||||||
|
"""商城版 mobileui.getConfig
|
||||||
|
|
||||||
|
tReq 结构 (HAR 实证):
|
||||||
|
tag0: UserId {lUid=0, sGuid="", sUA="", sHuYaUA="webh5&0.0.1&websocket&&h5_/index.html",
|
||||||
|
sToken="", iTokenType=0, sDeviceInfo="", sCookie=""}
|
||||||
|
tag1: 空Map
|
||||||
|
tag2: "huya" (sAppId)
|
||||||
|
tag3: 空List
|
||||||
|
"""
|
||||||
|
ua = "webh5&0.0.1&websocket&&h5_/index.html"
|
||||||
|
|
||||||
|
os = TafOutputStream()
|
||||||
|
os.write_struct_begin(0)
|
||||||
|
# tag0: UserId (匿名)
|
||||||
|
os.write_struct_begin(0)
|
||||||
|
os.write_int64(0, 0) # lUid = 0
|
||||||
|
os.write_string(1, "") # sGuid
|
||||||
|
os.write_string(2, "") # sUA
|
||||||
|
os.write_string(3, ua) # sHuYaUA
|
||||||
|
os.write_string(4, "") # sToken
|
||||||
|
os.write_int32(5, 0) # iTokenType
|
||||||
|
os.write_string(6, "") # sDeviceInfo
|
||||||
|
os.write_string(7, "") # sCookie
|
||||||
|
os.write_struct_end()
|
||||||
|
os.write_map(1, {}) # tag1: 空Map
|
||||||
|
os.write_string(2, "huya") # tag2: sAppId
|
||||||
|
os.write_head(3, TafType.LIST) # tag3: 空List
|
||||||
|
os.write_int32(0, 0)
|
||||||
|
os.write_struct_end()
|
||||||
|
|
||||||
|
wup = WupRequest()
|
||||||
|
wup.setServant("mobileui")
|
||||||
|
wup.setFunc("getConfig")
|
||||||
|
wup.setRequestId(7)
|
||||||
|
wup.iTimeout = 0
|
||||||
|
wup.newdata["tReq"] = os.get_bytes()
|
||||||
|
wup_data = wup.encode()
|
||||||
|
body = self._encode_rpc_body(wup_data)
|
||||||
|
|
||||||
|
seq = SEQ_GETCONFIG
|
||||||
|
msg = WssMessage(command=WssCommand.RPC_REQUEST,
|
||||||
|
sequence=seq, body=body)
|
||||||
|
future = asyncio.Future()
|
||||||
|
self.rpc_queue.append(future)
|
||||||
|
self.logger(format_wss_log(body, WssCommand.RPC_REQUEST, seq, "发"))
|
||||||
|
await self.ws.send(msg.encode())
|
||||||
|
|
||||||
|
try:
|
||||||
|
body = await asyncio.wait_for(future, timeout)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
if future in self.rpc_queue:
|
||||||
|
self.rpc_queue.remove(future)
|
||||||
|
self.logger("[✗] getConfig 超时")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return body
|
||||||
|
|
||||||
|
async def send_register(self, guid_from_launch: str):
|
||||||
|
"""cmd 0x21 REGISTER — 回传 wsLaunch 的 sGuid
|
||||||
|
|
||||||
|
body = tag0("HUYA&ZH&2052") + tag1(sGuid) + tag2=0 + tag3=0
|
||||||
|
+ tag6 struct{tag0空Map, tag1空Map, tag3=0}
|
||||||
|
+ tag7空Map + tag8=0 + TAIL
|
||||||
|
"""
|
||||||
|
os = TafOutputStream()
|
||||||
|
os.write_string(0, "HUYA&ZH&2052")
|
||||||
|
os.write_string(1, guid_from_launch)
|
||||||
|
os.write_int32(2, 0)
|
||||||
|
os.write_int32(3, 0)
|
||||||
|
os.write_struct_begin(6)
|
||||||
|
os.write_map(0, {})
|
||||||
|
os.write_map(1, {})
|
||||||
|
os.write_int32(3, 0)
|
||||||
|
os.write_struct_end()
|
||||||
|
os.write_map(7, {})
|
||||||
|
os.write_int32(8, 0)
|
||||||
|
body = os.get_bytes() + TAIL_BYTES
|
||||||
|
msg = WssMessage(command=WssCommand.REGISTER,
|
||||||
|
sequence=SEQ_REGISTER, body=body)
|
||||||
|
self.logger(format_wss_log(body, WssCommand.REGISTER, SEQ_REGISTER, "发"))
|
||||||
|
await self.ws.send(msg.encode())
|
||||||
|
|
||||||
|
async def send_confirm(self):
|
||||||
|
"""cmd 0x17 CONFIRM_SEND — body = 空Map(tag0) + TAIL"""
|
||||||
|
os = TafOutputStream()
|
||||||
|
os.write_map(0, {})
|
||||||
|
body = os.get_bytes() + TAIL_BYTES
|
||||||
|
msg = WssMessage(command=WssCommand.CONFIRM_SEND,
|
||||||
|
sequence=SEQ_CONFIRM, body=body)
|
||||||
|
self.logger(format_wss_log(body, WssCommand.CONFIRM_SEND, SEQ_CONFIRM, "发"))
|
||||||
|
await self.ws.send(msg.encode())
|
||||||
|
|
||||||
|
async def call_rpc(self, service: str, method: str,
|
||||||
|
req_struct: TafStruct, rsp_class=None,
|
||||||
|
timeout: float = 10.0):
|
||||||
|
seq = self._next_biz_seq()
|
||||||
|
|
||||||
|
wup = WupRequest()
|
||||||
|
wup.setServant(service)
|
||||||
|
wup.setFunc(method)
|
||||||
|
wup.setRequestId(self._next_wup_req_id())
|
||||||
|
wup.iTimeout = 0
|
||||||
|
wup.writeStruct("tReq", req_struct)
|
||||||
|
wup_data = wup.encode()
|
||||||
|
body = self._encode_rpc_body(wup_data)
|
||||||
|
|
||||||
|
msg = WssMessage(command=WssCommand.RPC_REQUEST,
|
||||||
|
sequence=seq, body=body)
|
||||||
|
future = asyncio.Future()
|
||||||
|
self.rpc_queue.append(future)
|
||||||
|
self.logger(format_wss_log(body, WssCommand.RPC_REQUEST, seq, "发"))
|
||||||
|
await self.ws.send(msg.encode())
|
||||||
|
|
||||||
|
try:
|
||||||
|
body = await asyncio.wait_for(future, timeout)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
if future in self.rpc_queue:
|
||||||
|
self.rpc_queue.remove(future)
|
||||||
|
self.logger(f"[✗] {service}.{method} 超时")
|
||||||
|
return None
|
||||||
|
|
||||||
|
wup_resp = WupResponse()
|
||||||
|
wup_resp.decode(self._extract_wup(body))
|
||||||
|
# 响应日志已由 _recv_loop 打出,此处只打解码后的业务字段
|
||||||
|
for key in ("tRsp", "tResp"):
|
||||||
|
data = wup_resp.newdata.get(key)
|
||||||
|
if data and isinstance(data, bytes) and len(data) > 0:
|
||||||
|
try:
|
||||||
|
ins = TafInputStream(data)
|
||||||
|
tag, dtype = ins.peek_head()
|
||||||
|
if dtype == TafType.STRUCT_BEGIN:
|
||||||
|
ins.read_head()
|
||||||
|
decoded = _decode_taf_struct(ins)
|
||||||
|
self.logger(f"[←] {service}.{method} {key}: {_truncate(decoded)}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if rsp_class is None:
|
||||||
|
return body
|
||||||
|
|
||||||
|
result = wup_resp.readStruct("tRsp", rsp_class)
|
||||||
|
if result is None:
|
||||||
|
result = wup_resp.readStruct("tResp", rsp_class)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def get_goods_info(self, uid: int, guid: str, cookie: str,
|
||||||
|
pid: int, spu_id: str, sku_id: int = 0,
|
||||||
|
game_id: str = "", source_id: str = "yellowcarlist",
|
||||||
|
scene: int = WSS_SHOP_SCENE):
|
||||||
|
from .shop_structs import GetGoodsInfoReqV5, GoodsInfoRsp
|
||||||
|
|
||||||
|
req = GetGoodsInfoReqV5()
|
||||||
|
req.userId = self._build_biz_user(uid, cookie)
|
||||||
|
req.shopAppInfo = self._build_shop_app(source_id, scene)
|
||||||
|
req.pid = pid
|
||||||
|
req.spuId = spu_id
|
||||||
|
req.skuId = sku_id
|
||||||
|
req.gameId = game_id
|
||||||
|
|
||||||
|
return await self.call_rpc("shopMiddleUI", "getGoodsInfoV5",
|
||||||
|
req, GoodsInfoRsp, timeout=15.0)
|
||||||
|
|
||||||
|
async def query_user_order_list(self, uid: int, guid: str, cookie: str,
|
||||||
|
offset: int = 0, page_size: int = 10,
|
||||||
|
order_type: int = 1, status: int = 0):
|
||||||
|
from .shop_structs import QueryUserOrderListReq, QueryUserOrderListRsp
|
||||||
|
|
||||||
|
req = QueryUserOrderListReq()
|
||||||
|
req.userId = self._build_biz_user(uid, cookie)
|
||||||
|
req.offset = offset
|
||||||
|
req.orderType = order_type
|
||||||
|
req.pageSize = page_size
|
||||||
|
req.status = status
|
||||||
|
|
||||||
|
return await self.call_rpc("revenueWebUI", "queryUserOrderList",
|
||||||
|
req, QueryUserOrderListRsp, timeout=15.0)
|
||||||
|
|
||||||
|
async def create_order(self, uid: int, guid: str, cookie: str,
|
||||||
|
pid: int, spu_id: str, sku_id: int,
|
||||||
|
item_count: int = 1, source_id: str = "yellowcarlist",
|
||||||
|
game_id: str = "", scene: int = WSS_SHOP_SCENE,
|
||||||
|
order_type: int = 6):
|
||||||
|
from .shop_structs import (CreateOrderReqV5, CreateOrderRsp,
|
||||||
|
CreateOrderExtraParam, CreateOrderPromotionParam,
|
||||||
|
CreateOrderAccountParam)
|
||||||
|
|
||||||
|
req = CreateOrderReqV5()
|
||||||
|
req.userId = self._build_biz_user(uid, cookie)
|
||||||
|
req.shopAppInfo = self._build_shop_app(source_id, scene)
|
||||||
|
req.receiveId = 0
|
||||||
|
req.pid = pid
|
||||||
|
req.spuId = spu_id
|
||||||
|
req.skuId = sku_id
|
||||||
|
req.itemCount = item_count
|
||||||
|
req.gameId = game_id or "0"
|
||||||
|
req.src = scene
|
||||||
|
req.scene = 0
|
||||||
|
req.sourceId = source_id
|
||||||
|
req.orderType = order_type
|
||||||
|
req.extraParam = CreateOrderExtraParam()
|
||||||
|
req.env = self._build_order_env(item_count)
|
||||||
|
req.orderScene = scene
|
||||||
|
req.promotionParam = CreateOrderPromotionParam()
|
||||||
|
req.accountParam = CreateOrderAccountParam()
|
||||||
|
req.bizType = 5
|
||||||
|
req.gameCategoryId = 507
|
||||||
|
req.ext = ""
|
||||||
|
|
||||||
|
self.logger(f"[下单] orderType={order_type} (HUYA_VIRTUAL)")
|
||||||
|
return await self.call_rpc("shopMiddleUI", "createOrderV5",
|
||||||
|
req, CreateOrderRsp, timeout=15.0)
|
||||||
|
|
||||||
|
async def pay_order_submit(self, uid: int, guid: str, cookie: str,
|
||||||
|
order_id: int, pay_type: int = 1,
|
||||||
|
pid: int = 0, source_id: str = "yellowcarlist",
|
||||||
|
scene: int = WSS_SHOP_SCENE,
|
||||||
|
item_count: int = 1):
|
||||||
|
from .shop_structs import PayOrderRes
|
||||||
|
|
||||||
|
os = TafOutputStream()
|
||||||
|
os.write_struct_begin(0)
|
||||||
|
|
||||||
|
os.write_struct(0, self._build_biz_user(uid, cookie))
|
||||||
|
os.write_struct(1, self._build_shop_app(source_id, scene))
|
||||||
|
os.write_int64(2, order_id)
|
||||||
|
os.write_string(3, "Zfb" if pay_type == 1 else str(pay_type))
|
||||||
|
os.write_string(4, "QrCode")
|
||||||
|
callback_url = (
|
||||||
|
f"https://m-shop.yaoguo.com/index.html#/consumer/paycallback"
|
||||||
|
f"?orderId={order_id}&pid={pid}&sourceId={source_id}"
|
||||||
|
)
|
||||||
|
os.write_string(5, callback_url)
|
||||||
|
os.write_map(6, self._build_order_env(item_count))
|
||||||
|
os.write_string(7, "null")
|
||||||
|
|
||||||
|
os.write_struct_end()
|
||||||
|
|
||||||
|
seq = self._next_biz_seq()
|
||||||
|
|
||||||
|
wup = WupRequest()
|
||||||
|
wup.setServant("shopMiddleUI")
|
||||||
|
wup.setFunc("payOrderSubmitV5")
|
||||||
|
wup.setRequestId(self._next_wup_req_id())
|
||||||
|
wup.iTimeout = 0
|
||||||
|
wup.newdata["tReq"] = os.get_bytes()
|
||||||
|
wup_data = wup.encode()
|
||||||
|
body = self._encode_rpc_body(wup_data)
|
||||||
|
|
||||||
|
msg = WssMessage(command=WssCommand.RPC_REQUEST,
|
||||||
|
sequence=seq, body=body)
|
||||||
|
future = asyncio.Future()
|
||||||
|
self.rpc_queue.append(future)
|
||||||
|
self.logger(format_wss_log(body, WssCommand.RPC_REQUEST, seq, "发"))
|
||||||
|
await self.ws.send(msg.encode())
|
||||||
|
|
||||||
|
try:
|
||||||
|
body = await asyncio.wait_for(future, 15.0)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
if future in self.rpc_queue:
|
||||||
|
self.rpc_queue.remove(future)
|
||||||
|
self.logger("[✗] payOrderSubmitV5 超时")
|
||||||
|
return None
|
||||||
|
|
||||||
|
wup_resp = WupResponse()
|
||||||
|
wup_resp.decode(self._extract_wup(body))
|
||||||
|
for key in ("tRsp", "tResp"):
|
||||||
|
data = wup_resp.newdata.get(key)
|
||||||
|
if data and isinstance(data, bytes) and len(data) > 0:
|
||||||
|
try:
|
||||||
|
ins = TafInputStream(data)
|
||||||
|
tag, dtype = ins.peek_head()
|
||||||
|
if dtype == TafType.STRUCT_BEGIN:
|
||||||
|
ins.read_head()
|
||||||
|
decoded = _decode_taf_struct(ins)
|
||||||
|
self.logger(f"[←] payOrderSubmitV5 {key}: {_truncate(decoded)}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
result = wup_resp.readStruct("tRsp", PayOrderRes)
|
||||||
|
if result is None:
|
||||||
|
result = wup_resp.readStruct("tResp", PayOrderRes)
|
||||||
|
return result
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 } : {} }),
|
||||||
|
};
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 /> });
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
当前阶段会解析并保存 UID、YYUID、账号名、手机号和 CK,绑定二维码、积分、游戏名等动作在虎牙任务页创建计划任务。
|
||||||
|
</Paragraph>
|
||||||
|
</Space>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user