Files
live-hub-py/web/backend/services/huya_runner_bind.py
T

707 lines
26 KiB
Python

"""虎牙任务执行器:角色绑定(由 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)