"""虎牙路由共享:权限、查询、序列化。""" import asyncio import csv import io import shutil import threading from datetime import UTC, datetime from typing import cast from fastapi import ( APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect, ) from fastapi.responses import StreamingResponse from loguru import logger from sqlalchemy import func, or_ from sqlalchemy.orm import Session, defer, joinedload from core.huya import ( HuyaAppLoginError, HuyaAppQrAuthRequiredError, HuyaCredentialError, HuyaLoginError, login_huya_app_password, login_huya_password, login_huya_sms, send_huya_sms_code, ) from core.huya.cookie_utils import ( browser_login_script, cookie_pairs, normalize_huya_cookie, ) from core.huya.device_fingerprint import account_state_dir as _fp_state_dir from core.huya.device_profile import ( _load_db as _load_profile_db, ) from core.huya.device_profile import ( _save_db as _save_profile_db, ) from core.sms_provider import parse_sms_lines from ...database import SessionLocal, get_db from ...deps import authenticate_websocket, get_current_user, require_permission from ...models import ( HuyaAccount, HuyaConfig, HuyaGoodsSnapshot, HuyaRechargeGoodsSnapshot, HuyaRegisterItem, HuyaRegisterSuccessLog, HuyaTask, HuyaWorkbench, HuyaWorkbenchAccount, ProxyConfig, User, ) from ...permissions import user_has_permission from ...schemas import ( AccountAssign, AccountBulkSelection, AccountBulkTag, AccountTag, BatchAssign, HuyaAccountOut, HuyaAppPasswordLoginRequest, HuyaAutoRegisterBatchOut, HuyaAutoRegisterRequest, HuyaAutoRegisterRetryRequest, HuyaBrowserExchangeRequest, HuyaConfigOut, HuyaConfigUpdate, HuyaCookieImport, HuyaDeviceBindingsBatchDeleteRequest, HuyaGoodsOut, HuyaPasswordAccountImport, HuyaPasswordLoginRequest, HuyaPasswordLoginSelectedRequest, HuyaRechargeGoodsOut, HuyaRegisterSuccessLogOut, HuyaSmsCodeRequest, HuyaSmsLoginRequest, HuyaTaskBatchRequest, HuyaTaskOut, HuyaTestSmsUrlRequest, HuyaWorkbenchAccountsUpdate, HuyaWorkbenchCookieImport, ) from ...services.audit_service import record_audit from ...services.huya_exchange_targets import ( DEFAULT_EXCHANGE_TARGET, exchange_target_options, exchange_target_url, ) from ...services.huya_register_runner import ( export_success_logs_text, huya_register_registry, list_success_logs, ) from ...services.huya_runner import HuyaBatchRunner, huya_batch_registry from ...services.huya_service import ( HUYA_CONFIG_FIELDS, SUPPORTED_TASK_TYPES, apply_huya_config_defaults, cleanup_orphan_huya_tasks, create_planned_tasks, ensure_huya_config, huya_config_value, import_huya_cookies, import_huya_cookies_detailed, import_huya_password_accounts, rebuild_web_cookie_from_raw, save_huya_login_cookie_to_account, upsert_huya_cookie, ) def _has_huya_perm(user: User, permission: str) -> bool: """虎牙新权限兼容旧的 huya:account 大权限。""" return user_has_permission(user, permission) or user_has_permission( user, "huya:account" ) def _require_huya_perm(user: User, permission: str) -> None: if not _has_huya_perm(user, permission): raise HTTPException(status_code=403, detail="权限不足") def _require_huya_tag_permission(user: User) -> None: """标签权限兼容历史上使用导入权限的运营人员。""" if not (_has_huya_perm(user, "huya:tag") or _has_huya_perm(user, "huya:import")): raise HTTPException(status_code=403, detail="无权修改虎牙账号标签") def _can_view_huya_all(user: User) -> bool: return _has_huya_perm(user, "huya:view_all") def _can_view_huya_assigned(user: User) -> bool: return _has_huya_perm(user, "huya:view_assigned") def _can_view_huya_cookie(user: User) -> bool: return _has_huya_perm(user, "huya:cookie:view") or _has_huya_perm( user, "huya:cookie:export" ) def _visible_huya_accounts_query(db: Session, current: User): """返回当前用户可见的虎牙账号查询。""" query = db.query(HuyaAccount).options(joinedload(HuyaAccount.assigned_user)) if _can_view_huya_all(current): return query if _can_view_huya_assigned(current): return query.filter(HuyaAccount.assigned_to == current.id) raise HTTPException(status_code=403, detail="无权查看虎牙账号") def _filter_huya_accounts_query( query, current: User, *, assigned_only: bool = False, tag: str | None = None, has_cookie: bool = False, search: str = "", ): """复用虎牙账号列表筛选条件,供分页和批量操作保持一致。""" if assigned_only and _can_view_huya_all(current): query = query.filter(HuyaAccount.assigned_to.isnot(None)) if tag: query = query.filter(HuyaAccount.tag == tag) if has_cookie: query = query.filter(HuyaAccount.cookie != "") search_text = (search or "").strip() if search_text: pattern = f"%{search_text}%" query = query.filter( or_( HuyaAccount.uid.ilike(pattern), HuyaAccount.yyuid.ilike(pattern), HuyaAccount.username.ilike(pattern), HuyaAccount.nickname.ilike(pattern), HuyaAccount.tag.ilike(pattern), HuyaAccount.game_name.ilike(pattern), HuyaAccount.game_phone.ilike(pattern), ) ) return query def _selected_huya_account_ids( db: Session, current: User, req: AccountBulkSelection ) -> list[int]: """解析虎牙批量操作目标:当前筛选全部或显式选择的 ID。""" base_query = _visible_huya_accounts_query(db, current) if req.all_matching: rows = ( _filter_huya_accounts_query( base_query, current, assigned_only=req.assigned_only, tag=req.tag, has_cookie=req.has_cookie, search=req.search, ) .with_entities(HuyaAccount.id) .order_by(None) .all() ) return [account_id for (account_id,) in rows] seen = set() requested_ids = [] for account_id in req.account_ids: if account_id not in seen: seen.add(account_id) requested_ids.append(account_id) if not requested_ids: return [] rows = ( base_query.filter(HuyaAccount.id.in_(requested_ids)) .with_entities(HuyaAccount.id) .order_by(None) .all() ) allowed = {account_id for (account_id,) in rows} return [account_id for account_id in requested_ids if account_id in allowed] def _clear_huya_account_references(db: Session, account_ids: list[int]) -> None: """删除账号前保留注册历史,把历史流水中的账号引用置空。""" if not account_ids: return db.query(HuyaRegisterItem).filter( HuyaRegisterItem.account_id.in_(account_ids) ).update( {HuyaRegisterItem.account_id: None}, synchronize_session=False, ) db.query(HuyaRegisterSuccessLog).filter( HuyaRegisterSuccessLog.account_id.in_(account_ids) ).update( {HuyaRegisterSuccessLog.account_id: None}, synchronize_session=False, ) def _visible_huya_tasks_query(db: Session, current: User): """返回当前用户可查看的虎牙任务查询。""" query = db.query(HuyaTask).options(joinedload(HuyaTask.account)) if _can_view_huya_all(current): return query if _can_view_huya_assigned(current): return query.join(HuyaTask.account).filter( HuyaAccount.assigned_to == current.id ) raise HTTPException(status_code=403, detail="无权查看虎牙任务") def _huya_task_summary(query): """按状态汇总虎牙任务,避免概览页拉完整任务列表。""" rows = ( query.enable_eagerloads(False) .order_by(None) .with_entities(HuyaTask.status, func.count(HuyaTask.id)) .group_by(HuyaTask.status) .all() ) status_counts = {status or "": count for status, count in rows} return { "total": sum(status_counts.values()), "success": status_counts.get("success", 0), "failed": sum(status_counts.get(status, 0) for status in ("failed", "error")), "status_counts": status_counts, } def _require_huya_task_account_access( db: Session, current: User, account_ids: list[int] ) -> None: """确保任务只会提交到当前用户可操作的虎牙账号。""" requested_ids = set(account_ids) query = db.query(HuyaAccount.id).filter(HuyaAccount.id.in_(requested_ids)) if not _can_view_huya_all(current): if not _can_view_huya_assigned(current): raise HTTPException(status_code=403, detail="无权操作虎牙账号") query = query.filter(HuyaAccount.assigned_to == current.id) allowed_ids = {account_id for (account_id,) in query.all()} if allowed_ids != requested_ids: raise HTTPException(status_code=403, detail="包含无权操作的虎牙账号") def _workbench_account_ids(db: Session, current: User, scope: str) -> list[int]: query = db.query(HuyaWorkbenchAccount.account_id).filter( HuyaWorkbenchAccount.user_id == current.id, HuyaWorkbenchAccount.handbook_scope == scope, ) if not _can_view_huya_all(current): query = query.join( HuyaAccount, HuyaAccount.id == HuyaWorkbenchAccount.account_id ).filter(HuyaAccount.assigned_to == current.id) return [ account_id for (account_id,) in query.order_by(HuyaWorkbenchAccount.id.asc()).all() ] def _require_huya_batch_owner(db: Session, current: User, batch_id: str) -> None: """客服只能停止或订阅自己创建的任务批次。""" if _can_view_huya_all(current): return exists = ( db.query(HuyaTask.id) .filter(HuyaTask.batch_id == batch_id, HuyaTask.created_by == current.id) .first() ) if not exists: raise HTTPException(status_code=403, detail="无权操作该虎牙任务批次") def _fmt_cookie_preview(cookie: str) -> str: if not cookie: return "" return cookie[:50] + "..." if len(cookie) > 50 else cookie def _account_out(account: HuyaAccount, include_cookie: bool = True) -> HuyaAccountOut: cookie = normalize_huya_cookie(account.cookie or "") return HuyaAccountOut( id=account.id, uid=account.uid or "", yyuid=account.yyuid or "", username=account.username or "", has_password=bool(account.account_password), has_cookie=bool(account.cookie), nickname=account.nickname or "", cookie=cookie if include_cookie else "", cookie_preview=_fmt_cookie_preview(cookie) if include_cookie else "***", tag=account.tag or "", remark=account.remark or "", status=account.status or "", login_channel=getattr(account, "login_channel", "") or "", points=account.points, game_name=account.game_name or "", game_channel=account.game_channel or "", game_phone=account.game_phone or "", elite_opened=account.elite_opened, elite_open_time=account.elite_open_time, elite_expire_time=account.elite_expire_time, sms_url=account.sms_url or "", assigned_to=account.assigned_to, assigned_username=account.assigned_user.username if account.assigned_user else None, created_at=account.created_at, updated_at=account.updated_at, ) def _account_out_light( account: HuyaAccount, *, has_password: bool = False, has_cookie: bool = False ) -> HuyaAccountOut: """虎牙账号列表轻量输出,不读取加密字段。""" return HuyaAccountOut( id=account.id, uid=account.uid or "", yyuid=account.yyuid or "", username=account.username or "", has_password=has_password, has_cookie=has_cookie, nickname=account.nickname or "", cookie="", cookie_preview="***", tag=account.tag or "", remark=account.remark or "", status=account.status or "", login_channel=getattr(account, "login_channel", "") or "", points=account.points, game_name=account.game_name or "", game_channel=account.game_channel or "", game_phone=account.game_phone or "", elite_opened=account.elite_opened, elite_open_time=account.elite_open_time, elite_expire_time=account.elite_expire_time, sms_url=account.sms_url or "", assigned_to=account.assigned_to, assigned_username=account.assigned_user.username if account.assigned_user else None, created_at=account.created_at, updated_at=account.updated_at, ) def _sanitize_task_result( result: dict | None, *, include_images: bool = False ) -> dict | None: """列表接口默认剥离 base64 图片,避免轮询每次传 1MB+ 数据。""" if not isinstance(result, dict): return result data = dict(result) image = data.get("mini_qrcode_image") if isinstance(image, str) and image: data["has_mini_qrcode"] = True if not include_images: data.pop("mini_qrcode_image", None) elif data.get("has_mini_qrcode"): data["has_mini_qrcode"] = True return data def _task_out(task: HuyaTask, *, include_images: bool = False) -> HuyaTaskOut: account = task.account return HuyaTaskOut( id=task.id, batch_id=task.batch_id, account_id=task.account_id, account_uid=account.uid if account else "", account_nickname=account.nickname if account else "", task_type=task.task_type, handbook_scope=getattr(task, "handbook_scope", "legacy") or "legacy", status=task.status or "", message=task.message or "", result=_sanitize_task_result( task.result if isinstance(task.result, dict) else None, include_images=include_images, ), created_by=task.created_by, created_at=task.created_at, finished_at=task.finished_at, ) def _config_out(config: HuyaConfig) -> HuyaConfigOut: return HuyaConfigOut( room_pid=huya_config_value("room_pid", config.room_pid), sid=huya_config_value("sid", config.sid), outer_act_id=huya_config_value("outer_act_id", config.outer_act_id), bind_act_id=huya_config_value("bind_act_id", config.bind_act_id), pay_channel=huya_config_value("pay_channel", config.pay_channel), updated_at=config.updated_at, ) def _cookie_out(account: HuyaAccount, include_cookie: bool) -> dict: cookie = normalize_huya_cookie(account.cookie or "") account_name = ( account.nickname or account.username or account.uid or str(account.id) ) return { "id": account.id, "account_id": account.id, "account_username": account_name, "uid": account.uid or "", "yyuid": account.yyuid or "", "assigned_to": account.assigned_to, "assigned_username": account.assigned_user.username if account.assigned_user else None, "created_at": account.updated_at.isoformat() if account.updated_at else None, "cookie": cookie if include_cookie else "", "cookie_preview": _fmt_cookie_preview(cookie) if include_cookie else "***", "has_raw_cookie": bool(account.raw_cookie), } def _cookie_out_light(account: HuyaAccount) -> dict: """虎牙 Cookie 列表轻量输出,不读取完整加密 Cookie。""" account_name = ( account.nickname or account.username or account.uid or str(account.id) ) return { "id": account.id, "account_id": account.id, "account_username": account_name, "uid": account.uid or "", "yyuid": account.yyuid or "", "assigned_to": account.assigned_to, "assigned_username": account.assigned_user.username if account.assigned_user else None, "created_at": account.updated_at.isoformat() if account.updated_at else None, "cookie": "", "cookie_preview": "***", "has_raw_cookie": bool(account.raw_cookie), } def _huya_password_state_map(db: Session, account_ids: list[int]) -> dict[int, bool]: """不解密密码字段,仅判断密文字段是否非空。""" if not account_ids: return {} rows = ( db.query( HuyaAccount.id, (HuyaAccount.account_password != "").label("has_password") ) .filter(HuyaAccount.id.in_(account_ids)) .all() ) return {account_id: bool(has_password) for account_id, has_password in rows} def _huya_cookie_state_map(db: Session, account_ids: list[int]) -> dict[int, bool]: if not account_ids: return {} rows = ( db.query(HuyaAccount.id, (HuyaAccount.cookie != "").label("has_cookie")) .filter(HuyaAccount.id.in_(account_ids)) .all() ) return {account_id: bool(has_cookie) for account_id, has_cookie in rows} _DEVICE_BINDINGS_PERMS_VIEW = ("huya:account", "huya:view_all", "huya:view_assigned") def _mask_hex(value: str | None, keep: int = 12) -> str: """设备标识脱敏展示: 只保留前 keep 位。""" v = value or "" return f"{v[:keep]}…({len(v)})" if len(v) > keep else v def _unbind_device_accounts(accounts: list[str]) -> tuple[list[str], list[str]]: """批量解绑实现: 返回 (成功解绑账号, 无绑定记录的账号)。""" profiles = _load_profile_db() removed: list[str] = [] missing: list[str] = [] seen = set() for account in accounts: account = (account or "").strip() if not account or account in seen: continue seen.add(account) if account not in profiles: missing.append(account) continue profiles.pop(account) state_dir = _fp_state_dir(account) if state_dir.exists(): shutil.rmtree(state_dir, ignore_errors=True) removed.append(account) if removed: _save_profile_db(profiles) return removed, missing __all__ = [name for name in globals() if not name.startswith("__")]