"""虎牙任务执行器:角色绑定(由 huya_runner.py 按功能域拆分)。""" from __future__ import annotations import time from datetime import datetime, timezone from typing import Any from sqlalchemy.orm import Session from core.huya import HuyaHttpClient from ..models import HuyaAccount, HuyaTask from typing import TYPE_CHECKING if TYPE_CHECKING: from .huya_runner import HuyaBatchRunner HUYA_BIND_ROLE_POLL_SECONDS = 180 HUYA_BIND_ROLE_POLL_INTERVAL = 3 HUYA_BIND_ZT_UUID = "b02faae1" HUYA_BIND_ROOM_ID = "30596253" from .huya_runner_core import HUYA_RECHARGE_SOURCE_ID, HuyaBatchRunnerCore class BindMixin: """游戏角色绑定域:绑定状态机、扫码/确认绑定。""" if TYPE_CHECKING: _stop: Any payload: dict @staticmethod def _format_local_time(timestamp: int) -> str: ... @staticmethod def _to_int(value: Any) -> int: ... def _resolve_uid(self, account_info: dict) -> int: ... def _push_log(self, level: str, message: str) -> None: ... def _mark_task(self, *args: Any, **kwargs: Any) -> None: ... def _update_task_progress(self, *args: Any, **kwargs: Any) -> None: ... @staticmethod def _role_name(bind_status) -> str: account_data = bind_status.accountData return account_data.gameRole.roleName or "" @staticmethod def _has_bind_role(bind_status) -> bool: return bool(bind_status and BindMixin._role_name(bind_status)) @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": BindMixin._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 = [game_role.platName, game_role.areaName] return " / ".join(part for part in parts if part) @staticmethod def _bind_change_state(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": HuyaBatchRunnerCore._format_local_time(change_time), "change_bind_day": int(bind_status.changeBindDay or 0), } @staticmethod def _bind_ready_result(bind_status) -> dict: role_info = BindMixin._bind_role_result(bind_status) return { **role_info, **BindMixin._bind_change_state(bind_status), "bind_status": bind_status.to_dict(), "bind_ready_for_confirm": bool(role_info["role_name"]), "bind_phase": "role_ready" if role_info["role_name"] else "waiting_role", } def _resolve_bind_status( self, client: Any, uid: int, cookie: str, b_act_id_int: int, ): """按活动页逻辑解析绑定状态,优先返回含角色的状态。""" outer_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 outer_status is None: return None, {} query_result = { "bind_status_source": "outer", "outer_bind_status": outer_status.to_dict(), } if outer_status.status != 200: return outer_status, query_result chosen_status = outer_status account_data = outer_status.accountData should_check_inner = not self._has_bind_role(outer_status) and bool( account_data.isNeedActCheck or not account_data.isBindAcount or not account_data.isBindRole ) if should_check_inner: inner_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 inner_status is not None: query_result["inner_bind_status"] = inner_status.to_dict() if inner_status.status == 200 and self._has_bind_role(inner_status): chosen_status = inner_status query_result["bind_status_source"] = "inner" query_result["bind_status"] = chosen_status.to_dict() return chosen_status, query_result 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 account.game_name account.game_channel = self._role_channel(bind_status) or account.game_channel account.updated_at = datetime.now(timezone.utc) @staticmethod def _bind_redirect_url(config_info: dict) -> str: room_pid = str(config_info.get("room_pid") or "").strip() if not room_pid: return "" return ( f"https://zt.huya.com/{HUYA_BIND_ZT_UUID}/pc/index.html" f"?sourceId={HUYA_RECHARGE_SOURCE_ID}" f"&pid={room_pid}" f"&anchorUid={room_pid}" f"&roomid={HUYA_BIND_ROOM_ID}" ) def _wait_bind_role_result( self, client: HuyaHttpClient, worker_db: Session, task: HuyaTask, account: HuyaAccount, uid: int, cookie: str, b_act_id_int: int, result: dict, ) -> tuple[str, dict]: """生成二维码后轮询扫码状态与角色同步,直到识别到角色、超时或停止。""" deadline = time.monotonic() + HUYA_BIND_ROLE_POLL_SECONDS qrcode_token = str(result.get("qrcode_token") or "") qrcode_finished = not qrcode_token result["bind_polling"] = True self._update_task_progress( worker_db, task, "running", "已生成绑定小程序码,等待扫码绑定", result ) while not self._stop.is_set() and time.monotonic() < deadline: if self._stop.wait(HUYA_BIND_ROLE_POLL_INTERVAL): break if qrcode_token and not qrcode_finished: qrcode_status = client.get_livelink_qrcode_status( qrcode_token, timeout=10.0 ) if qrcode_status is not None: result["qrcode_status"] = qrcode_status if qrcode_status["is_expired"] or qrcode_status["is_failure"]: result.update( { "bind_phase": "qrcode_expired", "bind_ready_for_confirm": False, "bind_polling": False, } ) self._update_task_progress( worker_db, task, "running", "绑定小程序码已失效,请重新获取", result, ) return "", result if qrcode_status["is_completed"]: qrcode_finished = True result["bind_phase"] = "qrcode_completed" elif qrcode_status["is_scan"]: result["bind_phase"] = "qrcode_scanned" else: result["bind_phase"] = "waiting_scan" bind_status, bind_query_result = self._resolve_bind_status( client=client, uid=uid, cookie=cookie, b_act_id_int=b_act_id_int, ) if bind_status is None: continue result.update(bind_query_result) if bind_status.status != 200: result.update( { "bind_phase": "role_check_failed", "bind_status": bind_status.to_dict(), } ) self._update_task_progress( worker_db, task, "running", bind_status.msg or "等待绑定角色同步", result, ) continue previous_phase = result.get("bind_phase") ready = self._bind_ready_result(bind_status) # 角色未就绪时保留扫码阶段文案,避免状态来回跳。 if not ready["role_name"] and previous_phase in { "waiting_scan", "qrcode_scanned", "qrcode_completed", }: ready["bind_phase"] = previous_phase result.update(ready) result["bind_polling"] = True role_name = ready["role_name"] if role_name: self._apply_role_to_account(account, bind_status, "game_queried") result["bind_polling"] = False self._update_task_progress( worker_db, task, "running", f"已识别角色: {role_name},待确认绑定", result, ) return role_name, result if result.get("bind_phase") == "qrcode_completed": message = "小程序绑定已完成,等待角色同步" elif result.get("bind_phase") == "qrcode_scanned": message = "已扫码,等待小程序绑定完成" else: message = "已生成绑定小程序码,等待扫码绑定" self._update_task_progress(worker_db, task, "running", message, result) result.update( { "bind_phase": "role_timeout" if not self._stop.is_set() else "stopped", "bind_ready_for_confirm": False, "bind_polling": False, } ) return "", 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: Any = HuyaHttpClient( logger=lambda msg: self._push_log("info", f"[{uid}] {msg}") ) bind_status, bind_query_result = self._resolve_bind_status( client=client, uid=uid, cookie=cookie, b_act_id_int=b_act_id_int, ) 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_query_result or bind_status.to_dict(), ) 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, 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 "" bind_redirect_url = self._bind_redirect_url(config_info) 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, redirect_url=bind_redirect_url, ) 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.get("qrcode_token") or "", **bind_query_result, "bind_phase": "waiting_scan" if mini_qrcode.get("qrcode_token") else "waiting_role", "bind_ready_for_confirm": False, "bind_polling": True, "bind_redirect_url": bind_redirect_url, **role_info, **change_state, "profile": { "nick": profile_nick, "avatar": profile_avatar, }, } account.status = "bind_qr_generated" account.game_name = role_info["role_name"] or account.game_name account.game_channel = self._role_channel(bind_status) or account.game_channel account.nickname = profile_nick or account.nickname account.updated_at = datetime.now(timezone.utc) # 生成二维码后自动轮询扫码/角色,避免用户必须手动点「查询角色」。 role_name, result = self._wait_bind_role_result( client=client, worker_db=worker_db, task=task, account=account, uid=uid, cookie=cookie, b_act_id_int=b_act_id_int, result=result, ) if role_name: self._mark_task( worker_db, task, "success", f"已识别角色: {role_name},待确认绑定", result, ) return if result.get("bind_phase") == "stopped": self._mark_task(worker_db, task, "stopped", "任务已停止", result) return if result.get("bind_phase") == "qrcode_expired": self._mark_task( worker_db, task, "failed", "绑定小程序码已失效,请重新获取", result ) return 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: Any = HuyaHttpClient( logger=lambda msg: self._push_log("info", f"[{uid}] {msg}") ) bind_status, bind_query_result = self._resolve_bind_status( client=client, uid=uid, cookie=cookie, b_act_id_int=b_act_id_int, ) 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_query_result}, ) 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_query_result, "bind_ready_for_confirm": bool(role_info["role_name"]), "bind_phase": "role_ready" if role_info["role_name"] else "waiting_role", } 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: Any = HuyaHttpClient( logger=lambda msg: self._push_log("info", f"[{uid}] {msg}") ) role_status, role_query_result = self._resolve_bind_status( client=client, uid=uid, cookie=cookie, b_act_id_int=b_act_id_int, ) 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, **role_query_result}, ) return role_info = self._bind_role_result(role_status) role_name = role_info["role_name"] if not role_name: result = { "bind_act_id": b_act_id_int, "bind_confirmed": False, **role_query_result, } 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(), **role_query_result, } self._mark_task( worker_db, task, "failed", confirm_resp.msg or f"虎牙确认绑定失败: {confirm_resp.status}", result, ) return refreshed_status, refreshed_query_result = self._resolve_bind_status( client=client, uid=uid, cookie=cookie, b_act_id_int=b_act_id_int, ) if refreshed_status is None: result = { "bind_act_id": b_act_id_int, "bind_confirmed": True, "confirm_result": confirm_resp.to_dict(), "before_bind_status": role_status.to_dict(), **role_info, **role_query_result, } self._apply_role_to_account(account, role_status, "bind_confirmed") self._mark_task( worker_db, task, "success", f"确认绑定: {role_name}", result ) return if refreshed_status.status != 200: result = { "bind_act_id": b_act_id_int, "bind_confirmed": True, "confirm_result": confirm_resp.to_dict(), "before_bind_status": role_status.to_dict(), **role_info, **role_query_result, "refresh_error": refreshed_query_result, } self._apply_role_to_account(account, role_status, "bind_confirmed") self._mark_task( worker_db, task, "success", f"确认绑定: {role_name}", result ) return refreshed_confirmed = bool( refreshed_status.accountData.isBindAcount and refreshed_status.accountData.isBindRole ) refreshed_role_info = self._bind_role_result(refreshed_status) final_status = ( refreshed_status if refreshed_role_info["role_name"] else role_status ) final_role_info = ( refreshed_role_info if refreshed_role_info["role_name"] else role_info ) result = { "bind_act_id": b_act_id_int, "bind_confirmed": True, "refreshed_is_bound": refreshed_confirmed, "confirm_result": confirm_resp.to_dict(), "before_bind_status": role_status.to_dict(), **role_query_result, "refresh_result": refreshed_query_result, **final_role_info, } self._apply_role_to_account(account, final_status, "bind_confirmed") role_name = final_role_info["role_name"] or role_name or "已绑定" self._mark_task(worker_db, task, "success", f"确认绑定: {role_name}", result)