"""虎牙任务执行器:角色绑定(由 huya_runner.py 按功能域拆分)。""" from __future__ import annotations import time from datetime import datetime, timezone 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 class BindMixin: """游戏角色绑定域:绑定状态机、扫码/确认绑定。""" @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 HuyaBatchRunner._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": 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 = [game_role.platName, game_role.areaName] return " / ".join(part for part in parts if part) @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), } @classmethod def _bind_ready_result(cls, bind_status) -> dict: role_info = cls._bind_role_result(bind_status) return { **role_info, **cls._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: HuyaHttpClient, 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 = 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 = 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 = 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)