diff --git a/core/huya/activity_structs.py b/core/huya/activity_structs.py index 324fb5b..15a63d4 100644 --- a/core/huya/activity_structs.py +++ b/core/huya/activity_structs.py @@ -291,6 +291,50 @@ class CheckUserBindGameAccountResp(TafStruct): } +class ConfirmBindActAccountReq(TafStruct): + """webActUI.confirmBindActAccount 请求。""" + + def __init__(self): + self.userId = ActivityUserId() + self.gid: int = 0 + self.outerActId: 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_string(2, self.outerActId) + os.write_int32(3, 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_string(2, default=self.outerActId) + self.bActId = ins.read_int32(3, default=self.bActId) + + +class ConfirmBindActAccountResp(TafStruct): + """webActUI.confirmBindActAccount 响应。""" + + def __init__(self): + self.status: int = 0 + self.msg: 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) + + def write_to(self, os: TafOutputStream): + os.write_int32(0, self.status) + os.write_string(1, self.msg) + + def to_dict(self) -> dict: + return { + "status": self.status, + "msg": self.msg, + } + + class GetLiveLinkParamReq(TafStruct): """webActUI.getLiveLinkParam 请求。""" @@ -508,6 +552,19 @@ def _self_check(): assert parsed_link.status == 200 assert parsed_link.livelinkParam["actId"] == "17096" + confirm_rsp = ConfirmBindActAccountResp() + confirm_rsp.status = 200 + confirm_rsp.msg = "请求成功" + os = TafOutputStream() + os.write_struct(0, confirm_rsp) + ins = TafInputStream(os.get_bytes()) + _, dtype = ins.read_head() + assert dtype == TafType.STRUCT_BEGIN + parsed_confirm = ConfirmBindActAccountResp() + parsed_confirm.read_from(ins) + assert parsed_confirm.status == 200 + assert parsed_confirm.msg == "请求成功" + if __name__ == "__main__": _self_check() diff --git a/core/huya/http_client.py b/core/huya/http_client.py index 4134f5b..0b35b1a 100644 --- a/core/huya/http_client.py +++ b/core/huya/http_client.py @@ -301,6 +301,32 @@ class HuyaHttpClient: timeout=timeout, ) + def confirm_bind_act_account( + self, + uid: int, + cookie: str, + b_act_id: int, + gid: int = 0, + outer_act_id: str = "", + timeout: float = 15.0, + ): + """确认虎牙号与当前活动的游戏账号绑定关系。""" + from .activity_structs import ConfirmBindActAccountReq, ConfirmBindActAccountResp + req = ConfirmBindActAccountReq() + req.userId = self._build_activity_user(uid, cookie) + req.gid = int(gid or 0) + req.outerActId = str(outer_act_id or "") + req.bActId = int(b_act_id or 0) + return self.call_rpc( + "webActUI", + "confirmBindActAccount", + req, + ConfirmBindActAccountResp, + uid=uid, + cookie=cookie, + timeout=timeout, + ) + def get_live_link_param( self, uid: int, diff --git a/web/backend/services/huya_runner.py b/web/backend/services/huya_runner.py index 080169c..abd0871 100644 --- a/web/backend/services/huya_runner.py +++ b/web/backend/services/huya_runner.py @@ -75,6 +75,62 @@ class HuyaBatchRunner: or self._to_int(cookie_value(cookie, "udb_uid")) ) + @staticmethod + def _role_name(bind_status) -> str: + account_data = bind_status.accountData + return account_data.gameRole.roleName or account_data.gameAccount.nick or "" + + @staticmethod + def _bind_role_result(bind_status) -> dict: + account_data = bind_status.accountData + game_account = account_data.gameAccount + game_role = account_data.gameRole + return { + "game_title": bind_status.gameName, + "role_name": HuyaBatchRunner._role_name(bind_status), + "change_bind_day": bind_status.changeBindDay, + "is_bind_account": account_data.isBindAcount, + "is_bind_role": account_data.isBindRole, + "is_need_act_check": account_data.isNeedActCheck, + "change_bind_time": account_data.changBindTime, + "game_account": game_account.to_dict(), + "game_role": game_role.to_dict(), + } + + @staticmethod + def _role_channel(bind_status) -> str: + game_role = bind_status.accountData.gameRole + parts = [bind_status.gameName, game_role.areaName, game_role.platName] + return " / ".join(part for part in parts if part) + + @staticmethod + def _format_local_time(timestamp: int) -> str: + if not timestamp: + return "" + return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S") + + @classmethod + def _bind_change_state(cls, bind_status) -> dict: + account_data = bind_status.accountData + is_bound = bool(account_data.isBindAcount and account_data.isBindRole) + change_time = int(account_data.changBindTime or 0) + now = int(datetime.now(timezone.utc).timestamp()) + can_change = not is_bound or not change_time or change_time <= now + return { + "is_bound": is_bound, + "can_change_bind": can_change, + "change_bind_time": change_time, + "change_available_at": cls._format_local_time(change_time), + "change_bind_day": int(bind_status.changeBindDay or 0), + } + + def _apply_role_to_account(self, account: HuyaAccount, bind_status, status: str): + role_name = self._role_name(bind_status) + account.status = status + account.game_name = role_name or bind_status.gameName or account.game_name + account.game_channel = self._role_channel(bind_status) or account.game_channel + account.updated_at = datetime.now(timezone.utc) + def _mark_task( self, worker_db: Session, @@ -189,6 +245,28 @@ class HuyaBatchRunner: ) return + role_info = self._bind_role_result(bind_status) + change_state = self._bind_change_state(bind_status) + if role_info["role_name"]: + self._apply_role_to_account(account, bind_status, account.status or "imported") + if not change_state["can_change_bind"]: + role_name = role_info["role_name"] or "当前角色" + available_at = change_state["change_available_at"] + result = { + "bind_act_id": b_act_id_int, + **role_info, + **change_state, + "bind_status": bind_status.to_dict(), + } + self._mark_task( + worker_db, + task, + "failed", + f"{role_name} 暂不能更换,{available_at} 后可更换", + result, + ) + return + live_link = client.get_live_link_param( uid=uid, cookie=cookie, @@ -240,6 +318,8 @@ class HuyaBatchRunner: "mini_qrcode_image": mini_qrcode["mini_qrcode_image"], "qrcode_token": mini_qrcode["qrcode_token"], "bind_status": bind_status.to_dict(), + **role_info, + **change_state, "profile": { "nick": profile_nick, "avatar": profile_avatar, @@ -247,11 +327,211 @@ class HuyaBatchRunner: } account.status = "bind_qr_generated" - account.game_name = bind_status.gameName or account.game_name + account.game_name = role_info["role_name"] or 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_query_game_name( + 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_act_id": b_act_id_int, "bind_status": bind_status.to_dict()}, + ) + return + + role_info = self._bind_role_result(bind_status) + change_state = self._bind_change_state(bind_status) + result = { + "bind_act_id": b_act_id_int, + **role_info, + **change_state, + "bind_status": bind_status.to_dict(), + } + role_name = role_info["role_name"] + if role_name: + self._apply_role_to_account(account, bind_status, "game_queried") + self._mark_task(worker_db, task, "success", f"角色: {role_name}", result) + return + + account.status = "game_not_bound" + account.updated_at = datetime.now(timezone.utc) + self._mark_task(worker_db, task, "success", "未绑定游戏角色", result) + + def _execute_confirm_bind( + 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}")) + role_status = client.check_user_bind_game_account( + uid=uid, + cookie=cookie, + b_act_id=b_act_id_int, + is_use_outer_act_id=0, + ) + if role_status is None: + self._mark_task(worker_db, task, "error", "虎牙绑定角色查询接口无响应") + return + if role_status.status != 200: + self._mark_task( + worker_db, + task, + "failed", + role_status.msg or f"虎牙绑定角色查询失败: {role_status.status}", + {"bind_act_id": b_act_id_int, "bind_status": role_status.to_dict()}, + ) + return + + if not role_status.accountData.isBindAcount or not role_status.accountData.isBindRole: + result = { + "bind_act_id": b_act_id_int, + "bind_confirmed": False, + "bind_status": role_status.to_dict(), + } + self._mark_task(worker_db, task, "failed", "尚未绑定游戏角色,请先扫码完成绑定", result) + return + + confirm_resp = client.confirm_bind_act_account( + uid=uid, + cookie=cookie, + b_act_id=b_act_id_int, + ) + if confirm_resp is None: + self._mark_task(worker_db, task, "error", "虎牙确认绑定接口无响应") + return + if confirm_resp.status != 200: + result = { + "bind_act_id": b_act_id_int, + "bind_confirmed": False, + "confirm_result": confirm_resp.to_dict(), + "before_bind_status": role_status.to_dict(), + } + self._mark_task( + worker_db, + task, + "failed", + confirm_resp.msg or f"虎牙确认绑定失败: {confirm_resp.status}", + result, + ) + return + + refreshed_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 refreshed_status is None: + result = { + "bind_act_id": b_act_id_int, + "bind_confirmed": False, + "confirm_result": confirm_resp.to_dict(), + "before_bind_status": role_status.to_dict(), + } + self._mark_task(worker_db, task, "error", "虎牙活动绑定状态刷新无响应", result) + return + if refreshed_status.status != 200: + result = { + "bind_act_id": b_act_id_int, + "bind_confirmed": False, + "confirm_result": confirm_resp.to_dict(), + "before_bind_status": role_status.to_dict(), + "bind_status": refreshed_status.to_dict(), + } + self._mark_task( + worker_db, + task, + "failed", + refreshed_status.msg or f"虎牙活动绑定状态刷新失败: {refreshed_status.status}", + result, + ) + return + + bind_confirmed = bool( + refreshed_status.accountData.isBindAcount + and refreshed_status.accountData.isBindRole + ) + role_info = self._bind_role_result(refreshed_status) + result = { + "bind_act_id": b_act_id_int, + "bind_confirmed": bind_confirmed, + "confirm_result": confirm_resp.to_dict(), + "before_bind_status": role_status.to_dict(), + "bind_status": refreshed_status.to_dict(), + **role_info, + } + if not bind_confirmed: + self._mark_task(worker_db, task, "failed", "确认后仍未检测到活动绑定角色", result) + return + + self._apply_role_to_account(account, refreshed_status, "bind_confirmed") + role_name = self._role_name(refreshed_status) or "已绑定" + self._mark_task(worker_db, task, "success", f"确认绑定: {role_name}", result) + def _execute_one(self, task_id: int, account_info: dict, config_info: dict, total: int): worker_db = SessionLocal() try: @@ -276,7 +556,7 @@ class HuyaBatchRunner: name = self._account_name(account_info) self._push_log("info", f"[{current}/{total}] 开始虎牙任务: {name}") - if self.task_type not in {"query_points", "get_bind_qr"}: + if self.task_type not in {"query_points", "get_bind_qr", "confirm_bind", "query_game_name"}: self._mark_task(worker_db, task, "failed", "该虎牙任务执行器暂未实现") self._push_log("warning", f"[{current}] {name} 暂未实现: {self.task_type}") return @@ -286,6 +566,10 @@ class HuyaBatchRunner: 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) + elif self.task_type == "confirm_bind": + self._execute_confirm_bind(worker_db, task, account, account_info, config_info) + elif self.task_type == "query_game_name": + self._execute_query_game_name(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/frontend/src/pages/HuyaAccountsPage.tsx b/web/frontend/src/pages/HuyaAccountsPage.tsx index 840d0d9..26e3fc9 100644 --- a/web/frontend/src/pages/HuyaAccountsPage.tsx +++ b/web/frontend/src/pages/HuyaAccountsPage.tsx @@ -159,7 +159,14 @@ export default function HuyaAccountsPage() { dataIndex: 'game_name', width: 160, ellipsis: true, - render: (value: string) => value || 未查, + render: (value: string, record) => value ? ( + + {value} + {record.game_channel ? ( + {record.game_channel} + ) : null} + + ) : 未查, }, { title: '手机号', diff --git a/web/frontend/src/pages/HuyaTasksPage.tsx b/web/frontend/src/pages/HuyaTasksPage.tsx index db4a109..8a42ad9 100644 --- a/web/frontend/src/pages/HuyaTasksPage.tsx +++ b/web/frontend/src/pages/HuyaTasksPage.tsx @@ -74,6 +74,12 @@ function resultText(result: Record | null | undefined, key: str return typeof value === 'string' ? value : ''; } +function resultObject(result: Record | null | undefined, key: string): Record | null { + const value = result?.[key]; + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as Record; +} + function resultProfileNick(result: Record | null | undefined): string { const profile = result?.profile; if (!profile || typeof profile !== 'object' || Array.isArray(profile)) return ''; @@ -279,6 +285,34 @@ export default function HuyaTasksPage() { ); } if (record.task_type === 'get_bind_qr') { + if (value?.can_change_bind === false) { + const changeAt = resultText(value, 'change_available_at'); + return ( + + 不可更换 + {changeAt ? {changeAt} : null} + + ); + } + return -; + } + if (record.task_type === 'confirm_bind') { + if (value?.bind_confirmed === true) { + const gameRole = resultObject(value, 'game_role'); + const roleName = typeof gameRole?.role_name === 'string' ? gameRole.role_name : ''; + return ( + + 已绑定 + {roleName ? {roleName} : null} + + ); + } + return -; + } + if (record.task_type === 'query_game_name') { + const roleName = resultText(value, 'role_name'); + if (roleName) return {roleName}; + if (value?.is_bound === false) return 未绑定; return -; } @@ -558,18 +592,27 @@ export default function HuyaTasksPage() {
{qrAccountName} {qrImage ? ( - 绑定小程序码 + > + 绑定小程序码 +
) : ( 暂无二维码 )}