diff --git a/core/huya/activity_structs.py b/core/huya/activity_structs.py index eb2a278..324fb5b 100644 --- a/core/huya/activity_structs.py +++ b/core/huya/activity_structs.py @@ -104,6 +104,369 @@ class GetUserScoreResp(TafStruct): } +class GameAccount(TafStruct): + """游戏账号信息。""" + + def __init__(self): + self.openId: str = "" + self.appId: str = "" + self.type: str = "" + self.faceUrl: str = "" + self.nick: str = "" + self.accessToken: str = "" + self.extendInfo: str = "" + + def read_from(self, ins: TafInputStream): + self.openId = ins.read_string(0, default=self.openId) + self.appId = ins.read_string(1, default=self.appId) + self.type = ins.read_string(2, default=self.type) + self.faceUrl = ins.read_string(3, default=self.faceUrl) + self.nick = ins.read_string(4, default=self.nick) + self.accessToken = ins.read_string(5, default=self.accessToken) + self.extendInfo = ins.read_string(6, default=self.extendInfo) + + def write_to(self, os: TafOutputStream): + os.write_string(0, self.openId) + os.write_string(1, self.appId) + os.write_string(2, self.type) + os.write_string(3, self.faceUrl) + os.write_string(4, self.nick) + os.write_string(5, self.accessToken) + os.write_string(6, self.extendInfo) + + def to_dict(self) -> dict: + return { + "app_id": self.appId, + "type": self.type, + "face_url": self.faceUrl, + "nick": self.nick, + "extend_info": self.extendInfo, + "has_open_id": bool(self.openId), + "has_access_token": bool(self.accessToken), + } + + +class GameRole(TafStruct): + """游戏角色信息。""" + + def __init__(self): + self.roleId: str = "" + self.roleName: str = "" + self.areaName: str = "" + self.platName: str = "" + self.partitionName: str = "" + self.gameSerial: str = "" + + def read_from(self, ins: TafInputStream): + self.roleId = ins.read_string(0, default=self.roleId) + self.roleName = ins.read_string(1, default=self.roleName) + self.areaName = ins.read_string(2, default=self.areaName) + self.platName = ins.read_string(3, default=self.platName) + self.partitionName = ins.read_string(4, default=self.partitionName) + self.gameSerial = ins.read_string(5, default=self.gameSerial) + + def write_to(self, os: TafOutputStream): + os.write_string(0, self.roleId) + os.write_string(1, self.roleName) + os.write_string(2, self.areaName) + os.write_string(3, self.platName) + os.write_string(4, self.partitionName) + os.write_string(5, self.gameSerial) + + def to_dict(self) -> dict: + return { + "role_id": self.roleId, + "role_name": self.roleName, + "area_name": self.areaName, + "plat_name": self.platName, + "partition_name": self.partitionName, + "game_serial": self.gameSerial, + } + + +class GameAccountData(TafStruct): + """绑定账号状态。""" + + def __init__(self): + self.isBindAcount: int = 0 + self.isBindRole: int = 0 + self.isNeedActCheck: int = 0 + self.gameAccount = GameAccount() + self.gameRole = GameRole() + self.changBindTime: int = 0 + + def read_from(self, ins: TafInputStream): + self.isBindAcount = ins.read_int32(0, default=self.isBindAcount) + self.isBindRole = ins.read_int32(1, default=self.isBindRole) + self.isNeedActCheck = ins.read_int32(2, default=self.isNeedActCheck) + self.gameAccount = ins.read_struct(3, GameAccount) or self.gameAccount + self.gameRole = ins.read_struct(4, GameRole) or self.gameRole + self.changBindTime = ins.read_int32(5, default=self.changBindTime) + + def write_to(self, os: TafOutputStream): + os.write_int32(0, self.isBindAcount) + os.write_int32(1, self.isBindRole) + os.write_int32(2, self.isNeedActCheck) + os.write_struct(3, self.gameAccount) + os.write_struct(4, self.gameRole) + os.write_int32(5, self.changBindTime) + + def to_dict(self) -> dict: + return { + "is_bind_account": self.isBindAcount, + "is_bind_role": self.isBindRole, + "is_need_act_check": self.isNeedActCheck, + "game_account": self.gameAccount.to_dict(), + "game_role": self.gameRole.to_dict(), + "change_bind_time": self.changBindTime, + } + + +class CheckUserBindGameAccountReq(TafStruct): + """webActUI.checkUserBindGameAccount 请求。""" + + def __init__(self): + self.userId = ActivityUserId() + self.gid: int = 0 + self.outerActId: str = "" + self.isInnerBind: int = 0 + self.scene: str = "" + self.bActId: int = 0 + self.isUseOuterActId: int = 0 + + def write_to(self, os: TafOutputStream): + os.write_struct(0, self.userId) + os.write_int32(1, self.gid) + os.write_string(2, self.outerActId) + os.write_int32(3, self.isInnerBind) + os.write_string(4, self.scene) + os.write_int32(5, self.bActId) + os.write_int32(6, self.isUseOuterActId) + + def read_from(self, ins: TafInputStream): + self.userId = ins.read_struct(0, ActivityUserId) or self.userId + self.gid = ins.read_int32(1, default=self.gid) + self.outerActId = ins.read_string(2, default=self.outerActId) + self.isInnerBind = ins.read_int32(3, default=self.isInnerBind) + self.scene = ins.read_string(4, default=self.scene) + self.bActId = ins.read_int32(5, default=self.bActId) + self.isUseOuterActId = ins.read_int32(6, default=self.isUseOuterActId) + + +class CheckUserBindGameAccountResp(TafStruct): + """webActUI.checkUserBindGameAccount 响应。""" + + def __init__(self): + self.status: int = 0 + self.msg: str = "" + self.accountData = GameAccountData() + self.gameName: str = "" + self.gameAuthScene: str = "" + self.changeBindDay: int = 0 + + def read_from(self, ins: TafInputStream): + self.status = ins.read_int32(0, default=self.status) + self.msg = ins.read_string(1, default=self.msg) + self.accountData = ins.read_struct(2, GameAccountData) or self.accountData + self.gameName = ins.read_string(3, default=self.gameName) + self.gameAuthScene = ins.read_string(4, default=self.gameAuthScene) + self.changeBindDay = ins.read_int32(5, default=self.changeBindDay) + + def write_to(self, os: TafOutputStream): + os.write_int32(0, self.status) + os.write_string(1, self.msg) + os.write_struct(2, self.accountData) + os.write_string(3, self.gameName) + os.write_string(4, self.gameAuthScene) + os.write_int32(5, self.changeBindDay) + + def to_dict(self) -> dict: + return { + "status": self.status, + "msg": self.msg, + "account_data": self.accountData.to_dict(), + "game_name": self.gameName, + "game_auth_scene": self.gameAuthScene, + "change_bind_day": self.changeBindDay, + } + + +class GetLiveLinkParamReq(TafStruct): + """webActUI.getLiveLinkParam 请求。""" + + def __init__(self): + self.userId = ActivityUserId() + self.gid: int = 0 + self.outerActId: int = 0 + self.gameAuthScene: str = "" + self.v: str = "" + self.bActId: int = 0 + + def write_to(self, os: TafOutputStream): + os.write_struct(0, self.userId) + os.write_int32(1, self.gid) + os.write_int32(2, self.outerActId) + os.write_string(3, self.gameAuthScene) + os.write_string(4, self.v) + os.write_int32(5, self.bActId) + + def read_from(self, ins: TafInputStream): + self.userId = ins.read_struct(0, ActivityUserId) or self.userId + self.gid = ins.read_int32(1, default=self.gid) + self.outerActId = ins.read_int32(2, default=self.outerActId) + self.gameAuthScene = ins.read_string(3, default=self.gameAuthScene) + self.v = ins.read_string(4, default=self.v) + self.bActId = ins.read_int32(5, default=self.bActId) + + +class GetLiveLinkParamResp(TafStruct): + """webActUI.getLiveLinkParam 响应。""" + + def __init__(self): + self.status: int = 0 + self.msg: str = "" + self.livelinkParam: dict[str, str] = {} + + def read_from(self, ins: TafInputStream): + self.status = ins.read_int32(0, default=self.status) + self.msg = ins.read_string(1, default=self.msg) + self.livelinkParam = ins.read_map(2) + + def write_to(self, os: TafOutputStream): + os.write_int32(0, self.status) + os.write_string(1, self.msg) + os.write_map(2, self.livelinkParam) + + def to_dict(self) -> dict: + return { + "status": self.status, + "msg": self.msg, + "livelink_param": dict(self.livelinkParam), + } + + def to_log_dict(self) -> dict: + data = dict(self.livelinkParam) + if data.get("code"): + data["code"] = f"" + if data.get("sig"): + data["sig"] = f"" + return { + "status": self.status, + "msg": self.msg, + "livelink_param": data, + } + + +class GetUserProfileBatchReq(TafStruct): + """huyauserui.getUserProfileBatch 请求。""" + + def __init__(self): + self.userId = ActivityUserId() + self.uidList: list[int] = [] + + def write_to(self, os: TafOutputStream): + os.write_struct(0, self.userId) + os.write_list(1, self.uidList) + + def read_from(self, ins: TafInputStream): + self.userId = ins.read_struct(0, ActivityUserId) or self.userId + self.uidList = ins.read_list(1) + + +class HuyaUserProfile(TafStruct): + """虎牙用户资料,仅读取绑定链接需要的字段。""" + + def __init__(self): + self.uid: int = 0 + self.nick: str = "" + self.avatar: str = "" + self.passport: str = "" + + def read_from(self, ins: TafInputStream): + while True: + tag, dtype = ins.peek_head() + if dtype == TafType.STRUCT_END: + break + ins.read_head() + if tag == 0: + self.uid = ins._read_int_value(dtype) + elif tag == 1: + self.nick = self._read_string_value(ins, dtype) + elif tag == 2: + self.avatar = self._read_string_value(ins, dtype) + elif tag == 17: + self.passport = self._read_string_value(ins, dtype) + else: + ins.skip_field(dtype) + + @staticmethod + def _read_string_value(ins: TafInputStream, dtype: int) -> str: + if dtype == TafType.STRING1: + length = int.from_bytes(ins.buf.read(1), "big") + elif dtype == TafType.STRING4: + length = int.from_bytes(ins.buf.read(4), "big") + else: + ins.skip_field(dtype) + return "" + return ins.buf.read(length).decode("utf-8", errors="replace") + + def write_to(self, os: TafOutputStream): + os.write_int64(0, self.uid) + os.write_string(1, self.nick) + os.write_string(2, self.avatar) + os.write_string(17, self.passport) + + def to_dict(self) -> dict: + return { + "uid": self.uid, + "nick": self.nick, + "avatar": self.avatar, + "passport": self.passport, + } + + +class GetUserProfileBatchResp(TafStruct): + """huyauserui.getUserProfileBatch 响应。""" + + def __init__(self): + self.profiles: list[HuyaUserProfile] = [] + + def read_from(self, ins: TafInputStream): + found = ins._find_tag(0, False) + if not found: + return + if found[1] != TafType.LIST: + ins.skip_field(found[1]) + return + count = ins._read_int_len() + profiles = [] + for _ in range(count): + _, dtype = ins.read_head() + if dtype != TafType.STRUCT_BEGIN: + ins.skip_field(dtype) + continue + item_profile = None + while True: + tag, field_type = ins.peek_head() + if field_type == TafType.STRUCT_END: + ins.read_head() + break + if tag == 0 and field_type == TafType.STRUCT_BEGIN: + item_profile = ins.read_struct(0, HuyaUserProfile) + else: + ins.read_head() + ins.skip_field(field_type) + if item_profile is not None: + profiles.append(item_profile) + self.profiles = profiles + + def write_to(self, os: TafOutputStream): + os.write_list(0, self.profiles) + + def to_dict(self) -> dict: + return {"profiles": [profile.to_dict() for profile in self.profiles]} + + def _self_check(): rsp = GetUserScoreResp() rsp.status = 200 @@ -125,6 +488,26 @@ def _self_check(): assert parsed.status == 200 assert parsed.available_score == 180 + link_rsp = GetLiveLinkParamResp() + link_rsp.status = 200 + link_rsp.livelinkParam = { + "actId": "17096", + "gameIdList": "cjm", + "livePlatId": "huya", + "code": "abc", + "sig": "def", + "t": "1783159287", + } + os = TafOutputStream() + os.write_struct(0, link_rsp) + ins = TafInputStream(os.get_bytes()) + _, dtype = ins.read_head() + assert dtype == TafType.STRUCT_BEGIN + parsed_link = GetLiveLinkParamResp() + parsed_link.read_from(ins) + assert parsed_link.status == 200 + assert parsed_link.livelinkParam["actId"] == "17096" + if __name__ == "__main__": _self_check() diff --git a/core/huya/http_client.py b/core/huya/http_client.py index 3443384..4134f5b 100644 --- a/core/huya/http_client.py +++ b/core/huya/http_client.py @@ -18,6 +18,7 @@ from .taf_protocol import TafOutputStream, TafInputStream, TafType, TafStruct from .wup_protocol import WupRequest, WupResponse CDNWS_HOST = "cdnws.api.huya.com" +LIVELINK_BIND_API_BASE = "https://livelinkbind.game.qq.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") @@ -246,7 +247,8 @@ class HuyaHttpClient: if result is None: result = wup_resp.readStruct("tResp", rsp_class) if result is not None and hasattr(result, "to_dict"): - decoded = json.dumps(result.to_dict(), ensure_ascii=False, separators=(",", ":")) + log_data = result.to_log_dict() if hasattr(result, "to_log_dict") else result.to_dict() + decoded = json.dumps(log_data, ensure_ascii=False, separators=(",", ":")) self.logger(f"[HTTP] 解码 tRsp: {decoded}") return result @@ -268,6 +270,227 @@ class HuyaHttpClient: timeout=timeout, ) + def check_user_bind_game_account( + self, + uid: int, + cookie: str, + b_act_id: int, + is_use_outer_act_id: int = 1, + gid: int = 0, + outer_act_id: str = "", + scene: str = "", + timeout: float = 15.0, + ): + """查询用户游戏账号绑定状态。""" + from .activity_structs import CheckUserBindGameAccountReq, CheckUserBindGameAccountResp + req = CheckUserBindGameAccountReq() + req.userId = self._build_activity_user(uid, cookie) + req.gid = int(gid or 0) + req.outerActId = str(outer_act_id or "") + req.isInnerBind = 0 + req.scene = scene or "" + req.bActId = int(b_act_id or 0) + req.isUseOuterActId = int(is_use_outer_act_id or 0) + return self.call_rpc( + "webActUI", + "checkUserBindGameAccount", + req, + CheckUserBindGameAccountResp, + uid=uid, + cookie=cookie, + timeout=timeout, + ) + + def get_live_link_param( + self, + uid: int, + cookie: str, + b_act_id: int, + gid: int = 0, + outer_act_id: int = 0, + game_auth_scene: str = "", + timeout: float = 15.0, + ): + """获取绑定二维码拉起参数。""" + from .activity_structs import GetLiveLinkParamReq, GetLiveLinkParamResp + req = GetLiveLinkParamReq() + req.userId = self._build_activity_user(uid, cookie) + req.gid = int(gid or 0) + req.outerActId = int(outer_act_id or 0) + req.gameAuthScene = game_auth_scene or "" + req.v = "" + req.bActId = int(b_act_id or 0) + return self.call_rpc( + "webActUI", + "getLiveLinkParam", + req, + GetLiveLinkParamResp, + uid=uid, + cookie=cookie, + timeout=timeout, + ) + + def get_user_profile_batch(self, uid: int, cookie: str, target_uids: list[int], timeout: float = 15.0): + """批量查询虎牙用户资料,用于补齐绑定页头像和昵称。""" + from .activity_structs import GetUserProfileBatchReq, GetUserProfileBatchResp + req = GetUserProfileBatchReq() + req.userId = self._build_activity_user(0, "") + req.uidList = [int(item) for item in target_uids if int(item or 0)] + if not req.uidList: + return None + return self.call_rpc( + "huyauserui", + "getUserProfileBatch", + req, + GetUserProfileBatchResp, + uid=uid, + cookie=cookie, + timeout=timeout, + ) + + @staticmethod + def _livelink_qrcode_image_src(qrcode: str) -> str: + """把 livelink 返回的二维码内容转成浏览器可直接展示的图片地址。""" + text = (qrcode or "").strip() + if not text: + return "" + lower = text.lower() + if lower.startswith(("https://", "http://", "data:image/")): + return text + mime = "image/png" if text.startswith("iVBOR") else "image/jpeg" + return f"data:{mime};base64,{text}" + + def get_livelink_mini_qrcode(self, bind_page_url: str, timeout: float = 15.0) -> dict | None: + """请求绑定页内部接口,获取真正可扫的小程序码图片。""" + import gzip + import time + + query = urllib.parse.urlsplit(bind_page_url or "").query + if not query: + self.logger("[LIVELINK] 获取小程序码失败: 绑定页 URL 缺少 query") + return None + + params = urllib.parse.parse_qs(query, keep_blank_values=True) + live_plat_id = (params.get("livePlatId") or [""])[0] + act_id = (params.get("actId") or [""])[0] + from_id = (params.get("fromId") or [""])[0] + if len(from_id) > 32: + from_id = "" + + suffix = "".join(random.choices("abcdefghijklmnopqrstuvwxyz0123456789", k=8)) + session_id = f"{int(time.time())}_{suffix}" + payload = json.dumps( + {"queryString": f"{query}&sessionId={session_id}"}, + ensure_ascii=False, + separators=(",", ":"), + ).encode("utf-8") + + endpoint = f"{LIVELINK_BIND_API_BASE}/api/qrcode/getMiniAppQrcode" + self.logger( + f"[LIVELINK] POST getMiniAppQrcode plat={live_plat_id or '-'} actId={act_id or '-'}" + ) + req = urllib.request.Request(endpoint, data=payload, method="POST", headers={ + "User-Agent": PC_UA, + "Origin": "https://livelink.qq.com", + "Referer": bind_page_url, + "Content-Type": "application/json;charset=UTF-8", + "Accept": "application/json, text/plain, */*", + "Accept-Language": "zh-CN,zh;q=0.9", + "Gap-Plat-Id": live_plat_id, + "Gap-Act-Id": act_id, + "Gap-User-From": "pc", + "Gap-From-Id": from_id, + "Vulcan-AccType": "auth_in_h5", + "Vulcan-PlatUser-Session": "", + }) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + resp_data = resp.read() + if resp_data[:2] == b"\x1f\x8b" or resp.headers.get("Content-Encoding") == "gzip": + resp_data = gzip.decompress(resp_data) + except Exception as e: + self.logger(f"[LIVELINK] ❌ 小程序码请求失败: {type(e).__name__}: {e}") + return None + + try: + data = json.loads(resp_data.decode("utf-8", "replace")) + except Exception as e: + self.logger(f"[LIVELINK] ❌ 小程序码响应解析失败: {type(e).__name__}: {e}") + return None + + if data.get("iRet") != 0: + self.logger(f"[LIVELINK] ❌ 小程序码接口失败: iRet={data.get('iRet')} msg={data.get('sMsg') or '-'}") + return None + + jdata = data.get("jData") if isinstance(data.get("jData"), dict) else {} + qrcode = jdata.get("qrcode") or "" + image_src = self._livelink_qrcode_image_src(qrcode) + if not image_src: + self.logger("[LIVELINK] ❌ 小程序码接口未返回 qrcode") + return None + + self.logger(f"[LIVELINK] 小程序码获取成功: image={len(image_src)} 字符 token={'有' if jdata.get('qrcodeToken') else '无'}") + return { + "mini_qrcode_image": image_src, + "qrcode_token": jdata.get("qrcodeToken") or "", + } + + @staticmethod + def build_bind_urls( + livelink_param: dict, + b_act_id: int, + game_auth_scene: str = "", + nick_name: str = "", + face_url: str = "", + ) -> dict: + """按活动页 JS 逻辑拼绑定二维码 URL。""" + from urllib.parse import quote, urlencode + + def join_query(params: dict) -> str: + # livelink 返回的 code 已经带 %2F/%2B,不能再用 urlencode 二次转义。 + return "&".join(f"{key}={value}" for key, value in params.items()) + + bind_query = { + "t": livelink_param.get("t", ""), + "gameIdList": livelink_param.get("gameIdList", ""), + "actId": livelink_param.get("actId", ""), + "livePlatId": livelink_param.get("livePlatId", ""), + "sig": livelink_param.get("sig", ""), + "code": livelink_param.get("code", ""), + "faceUrl": quote(face_url or "", safe=""), + "nickName": quote(nick_name or "", safe=""), + } + bind_query_text = join_query(bind_query) + mini_path = quote(f"/pages/gameAccountBind/index?{bind_query_text}", safe="") + action_url = ( + "https://m.huya.com?hyaction=launchweixinminiprogram" + f"&path={mini_path}&username=gh_49af166706ae" + ) + pc_query = dict(bind_query) + pc_query["redirectUrl"] = "" + pc_url = f"https://livelink.qq.com/act/a20200527midgroupage/pc/?{join_query(pc_query)}" + + huya_params = { + "bActId": int(b_act_id or 0), + "isUseOuterActId": 1, + "delayclose": 1000, + "from": "diy", + } + if game_auth_scene: + huya_params["gameAuthScene"] = game_auth_scene + + huya_base = "https://hd.huya.com/h5/livelinkV3/index.html" + huya_container_url = f"{huya_base}?{urlencode(huya_params)}" + exchange_params = dict(huya_params) + exchange_params["extendParm"] = "type=qqCoinDeliver" + return { + "action_url": action_url, + "qr_url": pc_url, + "pc_url": pc_url, + "huya_container_url": huya_container_url, + "exchange_qq_coin_url": f"{huya_base}?{urlencode(exchange_params)}", + } + 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 diff --git a/web/backend/huya_defaults.py b/web/backend/huya_defaults.py index 8b1d3ce..3d38d46 100644 --- a/web/backend/huya_defaults.py +++ b/web/backend/huya_defaults.py @@ -3,7 +3,7 @@ HUYA_DEFAULT_ROOM_PID = "1199650619883" HUYA_DEFAULT_SID = "2203" HUYA_DEFAULT_OUTER_ACT_ID = "9504" -HUYA_DEFAULT_BIND_ACT_ID = "17096" +HUYA_DEFAULT_BIND_ACT_ID = "9271" HUYA_DEFAULT_PAY_CHANNEL = "Zfb" HUYA_CONFIG_DEFAULTS = { diff --git a/web/backend/services/huya_runner.py b/web/backend/services/huya_runner.py index 3191063..080169c 100644 --- a/web/backend/services/huya_runner.py +++ b/web/backend/services/huya_runner.py @@ -141,6 +141,117 @@ class HuyaBatchRunner: account.updated_at = datetime.now(timezone.utc) self._mark_task(worker_db, task, "success", f"积分: {points}", result) + def _execute_get_bind_qr( + self, + worker_db: Session, + task: HuyaTask, + account: HuyaAccount, + account_info: dict, + config_info: dict, + ): + b_act_id = str(self.payload.get("bind_act_id") or config_info.get("bind_act_id") or "").strip() + if not b_act_id: + self._mark_task(worker_db, task, "failed", "请先配置虎牙绑定 bActId") + return + + b_act_id_int = self._to_int(b_act_id) + if not b_act_id_int: + self._mark_task(worker_db, task, "failed", f"虎牙绑定 bActId 无效: {b_act_id}") + return + + uid = self._resolve_uid(account_info) + if not uid: + self._mark_task(worker_db, task, "failed", "无法从账号或 Cookie 解析 yyuid") + return + + cookie = account_info.get("cookie") or "" + if not cookie: + self._mark_task(worker_db, task, "failed", "账号 Cookie 为空") + return + + client = HuyaHttpClient(logger=lambda msg: self._push_log("info", f"[{uid}] {msg}")) + bind_status = client.check_user_bind_game_account( + uid=uid, + cookie=cookie, + b_act_id=b_act_id_int, + is_use_outer_act_id=1, + ) + if bind_status is None: + self._mark_task(worker_db, task, "error", "虎牙绑定状态接口无响应") + return + if bind_status.status != 200: + self._mark_task( + worker_db, + task, + "failed", + bind_status.msg or f"虎牙绑定状态查询失败: {bind_status.status}", + bind_status.to_dict(), + ) + return + + live_link = client.get_live_link_param( + uid=uid, + cookie=cookie, + b_act_id=b_act_id_int, + game_auth_scene=bind_status.gameAuthScene, + ) + if live_link is None: + self._mark_task(worker_db, task, "error", "虎牙绑定二维码参数接口无响应") + return + if live_link.status != 200: + self._mark_task( + worker_db, + task, + "failed", + live_link.msg or f"虎牙绑定二维码参数获取失败: {live_link.status}", + live_link.to_log_dict(), + ) + return + + profile_nick = account_info.get("nickname") or account_info.get("username") or "" + profile_avatar = "" + profile_resp = client.get_user_profile_batch(uid=uid, cookie=cookie, target_uids=[uid]) + if profile_resp is not None and profile_resp.profiles: + profile = profile_resp.profiles[0] + profile_nick = profile.nick or profile.passport or profile_nick + profile_avatar = profile.avatar or "" + + urls = client.build_bind_urls( + live_link.livelinkParam, + b_act_id_int, + game_auth_scene=bind_status.gameAuthScene, + nick_name=profile_nick, + face_url=profile_avatar, + ) + mini_qrcode = client.get_livelink_mini_qrcode(urls["qr_url"]) + if not mini_qrcode: + result = { + "bind_act_id": b_act_id_int, + "profile": { + "nick": profile_nick, + "avatar": profile_avatar, + }, + } + self._mark_task(worker_db, task, "failed", "绑定小程序码获取失败", result) + return + + result = { + "bind_act_id": b_act_id_int, + "mini_qrcode_image": mini_qrcode["mini_qrcode_image"], + "qrcode_token": mini_qrcode["qrcode_token"], + "bind_status": bind_status.to_dict(), + "profile": { + "nick": profile_nick, + "avatar": profile_avatar, + }, + } + + account.status = "bind_qr_generated" + account.game_name = bind_status.gameName or account.game_name + account.nickname = profile_nick or account.nickname + account.updated_at = datetime.now(timezone.utc) + self._mark_task(worker_db, task, "success", "已生成绑定小程序码", result) + def _execute_one(self, task_id: int, account_info: dict, config_info: dict, total: int): worker_db = SessionLocal() try: @@ -165,13 +276,16 @@ class HuyaBatchRunner: name = self._account_name(account_info) self._push_log("info", f"[{current}/{total}] 开始虎牙任务: {name}") - if self.task_type != "query_points": + if self.task_type not in {"query_points", "get_bind_qr"}: self._mark_task(worker_db, task, "failed", "该虎牙任务执行器暂未实现") self._push_log("warning", f"[{current}] {name} 暂未实现: {self.task_type}") return try: - self._execute_query_points(worker_db, task, account, account_info, config_info) + if self.task_type == "query_points": + self._execute_query_points(worker_db, task, account, account_info, config_info) + elif self.task_type == "get_bind_qr": + self._execute_get_bind_qr(worker_db, task, account, account_info, config_info) worker_db.refresh(task) if task.status == "success": self._push_log("success", f"[{current}] {name} {task.message}") diff --git a/web/backend/services/huya_service.py b/web/backend/services/huya_service.py index cf02f4d..adfd316 100644 --- a/web/backend/services/huya_service.py +++ b/web/backend/services/huya_service.py @@ -32,6 +32,10 @@ def apply_huya_config_defaults(config: HuyaConfig) -> bool: """补齐虎牙配置默认值,返回是否发生变更。""" changed = False for field in HUYA_CONFIG_FIELDS: + if field == "bind_act_id" and str(getattr(config, field, "") or "").strip() == "17096": + setattr(config, field, HUYA_CONFIG_DEFAULTS[field]) + changed = True + continue normalized = huya_config_value(field, getattr(config, field, None)) if getattr(config, field, None) != normalized: setattr(config, field, normalized) diff --git a/web/frontend/src/pages/HuyaTasksPage.tsx b/web/frontend/src/pages/HuyaTasksPage.tsx index 92f4fae..db4a109 100644 --- a/web/frontend/src/pages/HuyaTasksPage.tsx +++ b/web/frontend/src/pages/HuyaTasksPage.tsx @@ -1,11 +1,11 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - Button, Card, Col, Form, Input, InputNumber, message, Row, Select, Space, Table, Tag, Tooltip, Typography, theme, + Button, Card, Col, Form, Input, InputNumber, message, Modal, 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, + LinkOutlined, PlayCircleOutlined, QrcodeOutlined, ReloadOutlined, SearchOutlined, SettingOutlined, ShoppingOutlined, } from '@ant-design/icons'; import { huyaApi, @@ -69,6 +69,22 @@ function accountLabel(account: HuyaAccountItem): string { return `${name}${tag}${phone}`; } +function resultText(result: Record | null | undefined, key: string): string { + const value = result?.[key]; + return typeof value === 'string' ? value : ''; +} + +function resultProfileNick(result: Record | null | undefined): string { + const profile = result?.profile; + if (!profile || typeof profile !== 'object' || Array.isArray(profile)) return ''; + const nick = (profile as Record).nick; + return typeof nick === 'string' ? nick : ''; +} + +function hasMiniQrcode(task: HuyaTaskItem): boolean { + return task.task_type === 'get_bind_qr' && Boolean(resultText(task.result, 'mini_qrcode_image')); +} + export default function HuyaTasksPage() { const { token } = theme.useToken(); const [form] = Form.useForm(); @@ -88,6 +104,9 @@ export default function HuyaTasksPage() { const [starting, setStarting] = useState(false); const [savingConfig, setSavingConfig] = useState(false); const [batchId, setBatchId] = useState(null); + const [qrTask, setQrTask] = useState(null); + const autoOpenedQrTaskIds = useRef>(new Set()); + const autoOpenQrReady = useRef(false); const { logs, connected: wsConnected, connect: connectLogs } = useWebSocketLogs(); const { can } = usePermissions(); @@ -98,6 +117,17 @@ export default function HuyaTasksPage() { localStorage.setItem('huya_task_concurrency', String(concurrency)); }, [concurrency]); + const rememberExistingQrcodes = useCallback((items: HuyaTaskItem[]) => { + if (autoOpenQrReady.current) return; + items.filter(hasMiniQrcode).forEach((task) => autoOpenedQrTaskIds.current.add(task.id)); + autoOpenQrReady.current = true; + }, []); + + const openQrTask = useCallback((task: HuyaTaskItem) => { + autoOpenedQrTaskIds.current.add(task.id); + setQrTask(task); + }, []); + const loadAll = useCallback(async () => { setLoading(true); try { @@ -110,7 +140,10 @@ export default function HuyaTasksPage() { ]); if (accountResult.status === 'fulfilled') setAccounts(accountResult.value); - if (taskResult.status === 'fulfilled') setTasks(taskResult.value); + if (taskResult.status === 'fulfilled') { + rememberExistingQrcodes(taskResult.value); + 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 }); @@ -128,16 +161,17 @@ export default function HuyaTasksPage() { } finally { setLoading(false); } - }, [canConfig, form]); + }, [canConfig, form, rememberExistingQrcodes]); const loadTasks = useCallback(async () => { try { const data = await huyaApi.listTasks(); + rememberExistingQrcodes(data); setTasks(data); } catch { // 轮询失败不打扰操作,下一轮继续刷新。 } - }, []); + }, [rememberExistingQrcodes]); useEffect(() => { loadAll(); @@ -148,6 +182,14 @@ export default function HuyaTasksPage() { return () => clearInterval(timer); }, [loadTasks]); + useEffect(() => { + if (!autoOpenQrReady.current || qrTask) return; + const nextQrTask = tasks + .filter((task) => hasMiniQrcode(task) && !autoOpenedQrTaskIds.current.has(task.id)) + .sort((a, b) => b.id - a.id)[0]; + if (nextQrTask) openQrTask(nextQrTask); + }, [openQrTask, qrTask, tasks]); + const accountOptions = useMemo(() => { return accounts.map((account) => ({ value: account.id, label: accountLabel(account) })); }, [accounts]); @@ -221,6 +263,32 @@ export default function HuyaTasksPage() { 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 qrResult = qrTask?.result || null; + const qrImage = resultText(qrResult, 'mini_qrcode_image'); + const qrAccountName = qrTask + ? resultProfileNick(qrResult) || qrTask.account_nickname || qrTask.account_uid || `#${qrTask.account_id}` + : ''; + + const renderTaskResult = (value: Record | null, record: HuyaTaskItem) => { + const bindQrImage = resultText(value, 'mini_qrcode_image'); + if (bindQrImage) { + return ( + + ); + } + if (record.task_type === 'get_bind_qr') { + return -; + } + + const availableScore = value?.available_score; + if (typeof availableScore === 'number') { + return 可用积分 {availableScore}; + } + + return value ? {JSON.stringify(value)} : -; + }; const taskColumns: TableProps['columns'] = [ { title: 'ID', dataIndex: 'id', width: 70, align: 'center' }, @@ -248,11 +316,9 @@ export default function HuyaTasksPage() { { title: '结果', dataIndex: 'result', - width: 180, + width: 210, ellipsis: true, - render: (value: Record | null) => ( - value ? {JSON.stringify(value)} : - - ), + render: renderTaskResult, }, { title: '时间', @@ -329,8 +395,8 @@ export default function HuyaTasksPage() { - - + + @@ -447,7 +513,7 @@ export default function HuyaTasksPage() { ))} - 当前阶段只创建 planned 任务并打通日志通道,真实 WSS/HTTP 执行器后续接入。 + 当前已接入查询积分和获取绑定二维码,其余任务会先保留计划记录。 @@ -480,6 +546,36 @@ export default function HuyaTasksPage() { spinWhenEmpty style={{ marginTop: 4 }} /> + + setQrTask(null)} + footer={null} + width={360} + > + {qrTask && ( +
+ {qrAccountName} + {qrImage ? ( + 绑定小程序码 + ) : ( + 暂无二维码 + )} +
+ )} +
); }