"""虎牙基础管理路由""" 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 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 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, ProxyConfig, User, ) from ..permissions import user_has_permission from ..schemas import ( AccountAssign, AccountBulkSelection, AccountBulkTag, AccountTag, BatchAssign, HuyaAccountOut, HuyaAppPasswordLoginRequest, HuyaAutoRegisterBatchOut, HuyaAutoRegisterRequest, HuyaAutoRegisterRetryRequest, HuyaConfigOut, HuyaConfigUpdate, HuyaCookieImport, HuyaDeviceBindingsBatchDeleteRequest, HuyaGoodsOut, HuyaPasswordAccountImport, HuyaPasswordLoginRequest, HuyaPasswordLoginSelectedRequest, HuyaRechargeGoodsOut, HuyaRegisterSuccessLogOut, HuyaSmsCodeRequest, HuyaSmsLoginRequest, HuyaTaskBatchRequest, HuyaTaskOut, ) from ..services.audit_service import record_audit 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_password_accounts, save_huya_login_cookie_to_account, upsert_huya_cookie, ) router = APIRouter(prefix="/api/huya", tags=["虎牙"]) 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 _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), 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 "", 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 ) -> HuyaAccountOut: """虎牙账号列表轻量输出,不读取加密字段。""" return HuyaAccountOut( id=account.id, uid=account.uid or "", yyuid=account.yyuid or "", username=account.username or "", has_password=has_password, 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 "", 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, 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 "***", } 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": "***", } 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} @router.get("/task-types") def task_types(current: User = Depends(require_permission("huya:task"))): """返回当前规划的虎牙任务类型。""" return SUPPORTED_TASK_TYPES @router.get("/accounts") def list_accounts( assigned_only: bool = Query(False), tag: str | None = Query(None), has_cookie: bool = Query(False), search: str = Query(""), page: int | None = Query(None, ge=1), page_size: int = Query(20, ge=1, le=200), include_cookie: bool | None = Query(None), db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """查看虎牙 CK 账号。""" query = _filter_huya_accounts_query( _visible_huya_accounts_query(db, current), current, assigned_only=assigned_only, tag=tag, has_cookie=has_cookie, search=search, ) total = None if page is not None: total = query.order_by(None).count() should_include_cookie = ( _can_view_huya_cookie(current) if include_cookie is None else (include_cookie and _can_view_huya_cookie(current)) ) query = query.order_by(HuyaAccount.id.desc()) if page is not None: query = query.offset((page - 1) * page_size).limit(page_size) if not should_include_cookie: query = query.options( defer(HuyaAccount.cookie), defer(HuyaAccount.account_password) ) accounts = query.all() if should_include_cookie: result = [_account_out(account, include_cookie=True) for account in accounts] else: password_map = _huya_password_state_map( db, [account.id for account in accounts] ) result = [ _account_out_light( account, has_password=password_map.get(account.id, False) ) for account in accounts ] if page is not None: return { "items": result, "total": total or 0, "page": page, "page_size": page_size, } return result @router.get("/accounts/summary") def accounts_summary( db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """虎牙账号统计,避免前端为了卡片统计拉全量账号。""" query = _visible_huya_accounts_query(db, current) total = query.count() assigned_count = query.filter(HuyaAccount.assigned_to.isnot(None)).count() tag_count = ( query.filter(HuyaAccount.tag != "", HuyaAccount.tag.isnot(None)) .with_entities(HuyaAccount.tag) .distinct() .count() ) password_ready_count = query.filter(HuyaAccount.account_password != "").count() point_count = query.filter(HuyaAccount.points.isnot(None)).count() bound_count = query.filter( or_( HuyaAccount.game_name != "", HuyaAccount.game_channel != "", HuyaAccount.game_phone != "", ) ).count() return { "total": total, "assigned_count": assigned_count, "unassigned_count": max(0, total - assigned_count), "tag_count": tag_count, "password_ready_count": password_ready_count, "point_count": point_count, "bound_count": bound_count, } @router.post("/accounts/import-cookies") def import_cookies( req: HuyaCookieImport, db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """粘贴并导入虎牙 Cookie。""" _require_huya_perm(current, "huya:import") count, skipped = import_huya_cookies(db, req.text, req.tag) return { "message": f"导入/更新 {count} 条,跳过 {skipped} 条", "success": True, "count": count, "skipped": skipped, } @router.post("/accounts/import-passwords") def import_password_accounts( req: HuyaPasswordAccountImport, db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """导入虎牙账号密码,只入库不立即登录。""" _require_huya_perm(current, "huya:import") count, skipped = import_huya_password_accounts(db, req.text, req.tag) return { "message": f"导入/更新 {count} 个虎牙账号,跳过 {skipped} 条", "success": True, "count": count, "skipped": skipped, } @router.post("/accounts/password-login") def password_login_account( req: HuyaPasswordLoginRequest, db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """使用 Web 方式账号密码登录虎牙(旧版),成功后保存 Cookie。""" _require_huya_perm(current, "huya:import") try: result = login_huya_password( username=req.username.strip(), password=req.password, cookie=req.cookie.strip() or None, ) except HuyaCredentialError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except HuyaLoginError as exc: raise HTTPException(status_code=502, detail=str(exc)) from exc except Exception as exc: raise HTTPException( status_code=502, detail=f"虎牙 Web 密码登录失败: {exc}" ) from exc if not result.success or not result.cookie: raise HTTPException( status_code=502, detail=result.message or "虎牙 Web 密码登录失败" ) try: account = upsert_huya_cookie( db, result.cookie, tag=req.tag, username_hint=req.username ) if hasattr(account, "account_password") and req.password: account.account_password = req.password account.status = "active" account.login_channel = "web" db.commit() except ValueError as exc: raise HTTPException(status_code=502, detail=str(exc)) from exc return { "message": "Web 登录成功,Cookie 已保存", "success": True, "account": _account_out(account), "sdid": result.sdid, } @router.post("/accounts/app-password-login") def app_password_login_account( req: HuyaAppPasswordLoginRequest, db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """使用 App 协议密码登录虎牙(推荐),自动过滑块并保存 Cookie。""" _require_huya_perm(current, "huya:import") try: result = login_huya_app_password( username=req.username.strip(), password=req.password, force_new_device=req.force_new_device, ) except HuyaAppQrAuthRequiredError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except HuyaCredentialError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except (HuyaAppLoginError, HuyaLoginError) as exc: raise HTTPException(status_code=502, detail=str(exc)) from exc except Exception as exc: raise HTTPException( status_code=502, detail=f"虎牙 App 密码登录失败: {exc}" ) from exc if not result.success or not result.cookie: raise HTTPException( status_code=502, detail=result.message or "虎牙 App 密码登录失败" ) try: account = upsert_huya_cookie( db, result.cookie, tag=req.tag, username_hint=req.username ) if hasattr(account, "account_password") and req.password: account.account_password = req.password account.status = "active" account.login_channel = "app" db.commit() except ValueError as exc: raise HTTPException(status_code=502, detail=str(exc)) from exc return { "message": "App 协议登录成功,Cookie 已保存", "success": True, "account": _account_out(account), "sdid": result.sdid, } @router.post("/accounts/sms-code") def send_sms_code( req: HuyaSmsCodeRequest, current: User = Depends(get_current_user), ): """发送虎牙短信验证码,返回提交登录所需 state。""" _require_huya_perm(current, "huya:import") try: result = send_huya_sms_code( phone=req.phone.strip(), cookie=req.cookie.strip() or None, ) except HuyaLoginError as exc: raise HTTPException(status_code=502, detail=str(exc)) from exc except Exception as exc: raise HTTPException(status_code=502, detail=f"虎牙短信发码失败: {exc}") from exc if not result.success or not result.state: raise HTTPException( status_code=502, detail=result.message or "虎牙短信发码失败" ) return { "message": result.message or "短信已发送", "success": True, "state": result.state, "sdid": result.sdid, "context": result.context, "request_id": result.request_id, } @router.post("/accounts/sms-login") def sms_login_account( req: HuyaSmsLoginRequest, db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """提交虎牙短信验证码登录,成功后保存 Cookie。""" _require_huya_perm(current, "huya:import") try: result = login_huya_sms( authcode=req.authcode.strip(), state=req.state.strip(), phone=req.phone.strip(), ) except HuyaLoginError as exc: raise HTTPException(status_code=502, detail=str(exc)) from exc except Exception as exc: raise HTTPException(status_code=502, detail=f"虎牙短信登录失败: {exc}") from exc if not result.success or not result.cookie: raise HTTPException( status_code=502, detail=result.message or "虎牙短信登录失败" ) try: account = upsert_huya_cookie(db, result.cookie, tag=req.tag, username_hint="") account.login_channel = "sms" phone = (req.phone or "").strip() if phone: account.game_phone = phone account.updated_at = datetime.now(UTC) db.commit() db.refresh(account) except ValueError as exc: raise HTTPException(status_code=502, detail=str(exc)) from exc return { "message": "登录成功,Cookie 已保存", "success": True, "account": _account_out(account), "sdid": result.sdid, } @router.post("/register/batches", response_model=HuyaAutoRegisterBatchOut) def create_auto_register_batch( req: HuyaAutoRegisterRequest, db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """启动虎牙手机号自动注册批次。""" _require_huya_perm(current, "huya:import") sms_lines = parse_sms_lines(req.text) if not sms_lines: raise HTTPException( status_code=400, detail="没有识别到有效手机号,格式为:手机号----短信查询URL 或 虎牙号----密码----手机号----短信查询URL", ) proxy_config = db.query(ProxyConfig).first() if req.use_proxy else None runner = huya_register_registry.create( sms_lines=sms_lines, tag=req.tag.strip(), created_by=current.id, concurrency=req.concurrency, wait_seconds=req.wait_seconds, poll_interval=req.poll_interval, password_prefix=req.password_prefix, fixed_password=req.fixed_password, use_proxy=req.use_proxy, proxy_config=proxy_config, ) runner.mark_running("批次运行中") thread = threading.Thread(target=runner.run, daemon=True) thread.start() return runner.snapshot() @router.get("/register/batches", response_model=list[HuyaAutoRegisterBatchOut]) def list_auto_register_batches( limit: int = Query(50, ge=1, le=200), current: User = Depends(get_current_user), ): """列出历史自动注册批次(摘要,不含明细)。""" _require_huya_perm(current, "huya:import") return huya_register_registry.list_summaries(limit=limit) @router.get("/register/batches/{batch_id}", response_model=HuyaAutoRegisterBatchOut) def get_auto_register_batch( batch_id: str, current: User = Depends(get_current_user), ): """查询虎牙手机号自动注册批次状态(优先内存,否则读库)。""" _require_huya_perm(current, "huya:import") snapshot = huya_register_registry.get_snapshot(batch_id) if not snapshot: raise HTTPException(status_code=404, detail="批次不存在") return snapshot @router.post("/register/batches/{batch_id}/stop") def stop_auto_register_batch( batch_id: str, current: User = Depends(get_current_user), ): """停止虎牙手机号自动注册批次。""" _require_huya_perm(current, "huya:import") runner = huya_register_registry.get(batch_id) if not runner: # 无内存 runner:若 DB 中存在且处于 running/interrupted,标记停止 snapshot = huya_register_registry.get_snapshot(batch_id) if not snapshot: raise HTTPException(status_code=404, detail="批次不存在") return {"message": "批次未在本进程运行,无需停止", "success": True} runner.stop() return {"message": "已发送停止信号", "success": True} @router.post( "/register/batches/{batch_id}/retry", response_model=HuyaAutoRegisterBatchOut ) def retry_auto_register_batch( batch_id: str, req: HuyaAutoRegisterRetryRequest | None = None, db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """继续批次:默认从停止处往下跑(跳过成功与已失败);可传 mode 改行为。""" _require_huya_perm(current, "huya:import") if req is None: req = HuyaAutoRegisterRetryRequest.model_construct() use_proxy = req.use_proxy # 先看历史批次是否用过代理 snapshot = huya_register_registry.get_snapshot(batch_id) if not snapshot: raise HTTPException(status_code=404, detail="批次不存在") effective_proxy = ( snapshot.get("use_proxy", False) if use_proxy is None else bool(use_proxy) ) proxy_config = db.query(ProxyConfig).first() if effective_proxy else None try: runner = huya_register_registry.retry( batch_id, proxy_config=proxy_config, mode=req.mode, concurrency=req.concurrency, wait_seconds=req.wait_seconds, poll_interval=req.poll_interval, password_prefix=req.password_prefix, fixed_password=req.fixed_password, use_proxy=use_proxy, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except RuntimeError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc thread = threading.Thread(target=runner.run, daemon=True) thread.start() return runner.snapshot() @router.get("/register/batches/{batch_id}/export") def export_auto_register_batch_success( batch_id: str, current: User = Depends(get_current_user), ): """导出指定批次的成功账号 txt。""" _require_huya_perm(current, "huya:import") snapshot = huya_register_registry.get_snapshot(batch_id) if not snapshot: raise HTTPException(status_code=404, detail="批次不存在") content = export_success_logs_text(batch_id=batch_id) if not content.strip(): raise HTTPException(status_code=404, detail="该批次没有可导出的成功记录") return StreamingResponse( iter([content]), media_type="text/plain; charset=utf-8", headers={ "Content-Disposition": f"attachment; filename=huya-register-{batch_id}.txt" }, ) @router.get("/register/success-logs", response_model=list[HuyaRegisterSuccessLogOut]) def list_register_success_logs( batch_id: str | None = Query(None), tag: str | None = Query(None), limit: int = Query(200, ge=1, le=2000), current: User = Depends(get_current_user), ): """列出注册成功流水(换电脑也可查看)。""" _require_huya_perm(current, "huya:import") return list_success_logs(batch_id=batch_id, tag=tag, limit=limit) @router.get("/register/success-logs/export") def export_register_success_logs( batch_id: str | None = Query(None), tag: str | None = Query(None), limit: int = Query(5000, ge=1, le=20000), current: User = Depends(get_current_user), ): """导出注册成功流水 txt:账号----密码----手机号----接码链接。""" _require_huya_perm(current, "huya:import") content = export_success_logs_text(batch_id=batch_id, tag=tag, limit=limit) if not content.strip(): raise HTTPException(status_code=404, detail="没有可导出的成功记录") filename = "huya-register-success" if batch_id: filename += f"-{batch_id}" if tag: filename += f"-{tag}" filename += ".txt" return StreamingResponse( iter([content]), media_type="text/plain; charset=utf-8", headers={"Content-Disposition": f"attachment; filename={filename}"}, ) @router.post("/accounts/password-login/selected") def password_login_selected_accounts( req: HuyaPasswordLoginSelectedRequest, db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """对已导入的虎牙账号执行密码登录。""" _require_huya_perm(current, "huya:import") selected_ids = _selected_huya_account_ids( db, current, cast(AccountBulkSelection, req) ) if not selected_ids: raise HTTPException(status_code=400, detail="请选择虎牙账号") accounts = ( _visible_huya_accounts_query(db, current) .filter(HuyaAccount.id.in_(selected_ids)) .all() ) account_map = {account.id: account for account in accounts} results = [] success_count = 0 failed_count = 0 include_cookie = _can_view_huya_cookie(current) for account_id in selected_ids: account = account_map.get(account_id) if account is None: failed_count += 1 results.append( { "line": account_id, "username": "", "success": False, "message": "账号不存在或无权登录", } ) continue username = (account.username or "").strip() password = (account.account_password or "").strip() if not username or not password: failed_count += 1 results.append( { "line": account.id, "username": username, "success": False, "message": "该账号未导入密码", "account": _account_out(account, include_cookie=include_cookie), } ) continue try: result = login_huya_password( username=username, password=password, cookie=account.cookie or None, ) if not result.success or not result.cookie: account.status = "login_failed" account.updated_at = datetime.now(UTC) db.commit() failed_count += 1 results.append( { "line": account.id, "username": username, "success": False, "message": result.message or "虎牙密码登录失败", "account": _account_out(account, include_cookie=include_cookie), "sdid": result.sdid, } ) continue saved = save_huya_login_cookie_to_account( db, account, result.cookie, tag=account.tag or "", username_hint=username, ) # save_huya_login_cookie_to_account 内部已 commit,渠道/状态回填需再次提交, # 否则 get_db 关闭会话时丢弃(GUI 登录渠道列会显示"仅导入")。 saved.status = "active" saved.login_channel = "web" db.commit() success_count += 1 results.append( { "line": account.id, "username": username, "success": True, "message": "登录成功,Cookie 已保存", "account": _account_out(saved, include_cookie=include_cookie), "sdid": result.sdid, } ) except HuyaCredentialError as exc: account.status = "login_failed" account.updated_at = datetime.now(UTC) db.commit() failed_count += 1 results.append( { "line": account.id, "username": username, "success": False, "message": str(exc), "account": _account_out(account, include_cookie=include_cookie), } ) except (HuyaLoginError, ValueError) as exc: account.status = "login_failed" account.updated_at = datetime.now(UTC) db.commit() failed_count += 1 results.append( { "line": account.id, "username": username, "success": False, "message": str(exc), "account": _account_out(account, include_cookie=include_cookie), } ) except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底 account.status = "login_failed" account.updated_at = datetime.now(UTC) db.commit() failed_count += 1 results.append( { "line": account.id, "username": username, "success": False, "message": f"虎牙密码登录失败: {exc}", "account": _account_out(account, include_cookie=include_cookie), } ) return { "message": f"Web 登录完成:成功 {success_count} 条,失败 {failed_count} 条", "success": True, "count": success_count, "failed": failed_count, "results": results, } @router.post("/accounts/app-password-login/selected") def app_password_login_selected_accounts( req: HuyaPasswordLoginSelectedRequest, db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """对已导入的虎牙账号使用 App 协议批量执行密码登录(推荐)。""" _require_huya_perm(current, "huya:import") selected_ids = _selected_huya_account_ids( db, current, cast(AccountBulkSelection, req) ) if not selected_ids: raise HTTPException(status_code=400, detail="请选择虎牙账号") accounts = ( _visible_huya_accounts_query(db, current) .filter(HuyaAccount.id.in_(selected_ids)) .all() ) account_map = {account.id: account for account in accounts} results = [] success_count = 0 failed_count = 0 include_cookie = _can_view_huya_cookie(current) for account_id in selected_ids: account = account_map.get(account_id) if account is None: failed_count += 1 results.append( { "line": account_id, "username": "", "success": False, "message": "账号不存在或无权登录", } ) continue username = (account.username or "").strip() password = (account.account_password or "").strip() if not username or not password: failed_count += 1 results.append( { "line": account.id, "username": username, "success": False, "message": "该账号未导入密码", "account": _account_out(account, include_cookie=include_cookie), } ) continue try: result = login_huya_app_password( username=username, password=password, force_new_device=req.force_new_device, ) if not result.success or not result.cookie: account.status = "login_failed" account.updated_at = datetime.now(UTC) db.commit() failed_count += 1 results.append( { "line": account.id, "username": username, "success": False, "message": result.message or "虎牙 App 密码登录失败", "account": _account_out(account, include_cookie=include_cookie), "sdid": result.sdid, } ) continue saved = save_huya_login_cookie_to_account( db, account, result.cookie, tag=account.tag or "", username_hint=username, ) saved.status = "active" saved.login_channel = "app" db.commit() success_count += 1 results.append( { "line": account.id, "username": username, "success": True, "message": "App 登录成功,Cookie 已保存", "account": _account_out(saved, include_cookie=include_cookie), "sdid": result.sdid, } ) except HuyaAppQrAuthRequiredError as exc: account.status = "login_failed" account.updated_at = datetime.now(UTC) db.commit() failed_count += 1 results.append( { "line": account.id, "username": username, "success": False, "message": str(exc), "account": _account_out(account, include_cookie=include_cookie), } ) except HuyaCredentialError as exc: account.status = "login_failed" account.updated_at = datetime.now(UTC) db.commit() failed_count += 1 results.append( { "line": account.id, "username": username, "success": False, "message": str(exc), "account": _account_out(account, include_cookie=include_cookie), } ) except (HuyaAppLoginError, HuyaLoginError, ValueError) as exc: account.status = "login_failed" account.updated_at = datetime.now(UTC) db.commit() failed_count += 1 results.append( { "line": account.id, "username": username, "success": False, "message": str(exc), "account": _account_out(account, include_cookie=include_cookie), } ) except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底 account.status = "login_failed" account.updated_at = datetime.now(UTC) db.commit() failed_count += 1 results.append( { "line": account.id, "username": username, "success": False, "message": f"虎牙 App 密码登录失败: {exc}", "account": _account_out(account, include_cookie=include_cookie), } ) return { "message": f"App 登录完成:成功 {success_count} 条,失败 {failed_count} 条", "success": True, "count": success_count, "failed": failed_count, "results": results, } @router.delete("/accounts/batch") def delete_accounts_batch( account_ids: str = Query(..., description="逗号分隔的虎牙账号ID"), db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """批量删除虎牙 CK 账号及任务记录。""" _require_huya_perm(current, "huya:delete") ids = [int(x) for x in account_ids.split(",") if x.strip().isdigit()] if not ids: raise HTTPException(status_code=400, detail="无效的账号ID") ids = _selected_huya_account_ids(db, current, AccountBulkSelection(account_ids=ids)) if not ids: raise HTTPException(status_code=400, detail="请选择虎牙账号") db.query(HuyaTask).filter(HuyaTask.account_id.in_(ids)).delete( synchronize_session=False ) _clear_huya_account_references(db, ids) deleted = ( db.query(HuyaAccount) .filter(HuyaAccount.id.in_(ids)) .delete(synchronize_session=False) ) db.commit() return { "message": f"已删除 {deleted} 个虎牙账号", "deleted": deleted, "success": True, } @router.post("/accounts/batch-delete") def delete_accounts_batch_selection( req: AccountBulkSelection, db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """按显式选择或当前筛选结果批量删除虎牙账号及任务记录。""" _require_huya_perm(current, "huya:delete") ids = _selected_huya_account_ids(db, current, req) if not ids: raise HTTPException(status_code=400, detail="请选择虎牙账号") db.query(HuyaTask).filter(HuyaTask.account_id.in_(ids)).delete( synchronize_session=False ) _clear_huya_account_references(db, ids) deleted = ( db.query(HuyaAccount) .filter(HuyaAccount.id.in_(ids)) .delete(synchronize_session=False) ) db.commit() scope = "当前筛选下" if req.all_matching else "选中的" return { "message": f"已删除{scope} {deleted} 个虎牙账号", "deleted": deleted, "success": True, } @router.delete("/accounts/{account_id}") def delete_account( account_id: int, db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """删除单个虎牙 CK 账号。""" _require_huya_perm(current, "huya:delete") account = db.query(HuyaAccount).filter(HuyaAccount.id == account_id).first() if not account: raise HTTPException(status_code=404, detail="账号不存在") db.query(HuyaTask).filter(HuyaTask.account_id == account_id).delete( synchronize_session=False ) _clear_huya_account_references(db, [account_id]) db.delete(account) db.commit() return {"message": "已删除", "success": True} @router.put("/accounts/{account_id}/assign") def assign_account( account_id: int, req: AccountAssign, db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """分配单个虎牙账号给客服。""" _require_huya_perm(current, "huya:assign") account = db.query(HuyaAccount).filter(HuyaAccount.id == account_id).first() if not account: raise HTTPException(status_code=404, detail="账号不存在") if req.assigned_to: target = db.query(User).filter(User.id == req.assigned_to).first() if not target: raise HTTPException(status_code=404, detail="目标用户不存在") if target.role != "support": raise HTTPException(status_code=400, detail="只能分配给客服角色") account.assigned_to = req.assigned_to db.commit() return {"message": "已分配", "success": True} @router.post("/accounts/batch-assign") def batch_assign_accounts( req: BatchAssign, db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """批量分配/取消分配虎牙账号给客服。""" _require_huya_perm(current, "huya:assign") if not req.account_ids: raise HTTPException(status_code=400, detail="请选择账号") if req.assigned_to is not None: target = db.query(User).filter(User.id == req.assigned_to).first() if not target: raise HTTPException(status_code=404, detail="目标用户不存在") if target.role != "support": raise HTTPException(status_code=400, detail="只能分配给客服角色") count = ( db.query(HuyaAccount) .filter(HuyaAccount.id.in_(req.account_ids)) .update( {HuyaAccount.assigned_to: req.assigned_to}, synchronize_session=False, ) ) db.commit() action = "分配" if req.assigned_to else "取消分配" return { "message": f"已批量{action} {count} 个虎牙账号", "success": True, "count": count, } @router.get("/accounts/assignments/summary") def assignments_summary( db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """虎牙分配概览:每个客服分配了多少虎牙账号。""" _require_huya_perm(current, "huya:assign") results = ( db.query(User.id, User.username, func.count(HuyaAccount.id).label("count")) .outerjoin(HuyaAccount, HuyaAccount.assigned_to == User.id) .filter(User.role == "support") .group_by(User.id, User.username) .order_by(func.count(HuyaAccount.id).desc()) .all() ) total_unassigned = ( db.query(func.count(HuyaAccount.id)) .filter(HuyaAccount.assigned_to.is_(None)) .scalar() ) or 0 return { "support_users": [ {"id": uid, "username": uname, "assigned_count": cnt} for uid, uname, cnt in results ], "unassigned_count": total_unassigned, } @router.put("/accounts/{account_id}/tag") def set_account_tag( account_id: int, req: AccountTag, db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """设置单个虎牙账号标签。""" _require_huya_tag_permission(current) account = ( _visible_huya_accounts_query(db, current) .filter(HuyaAccount.id == account_id) .first() ) if not account: raise HTTPException(status_code=404, detail="账号不存在") account.tag = (req.tag or "").strip() db.commit() return {"message": "标签已更新", "success": True} @router.put("/accounts/batch-tag") def batch_tag( req: AccountTag, db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """批量设置虎牙账号标签。""" _require_huya_tag_permission(current) if not req.account_ids: raise HTTPException(status_code=400, detail="请选择账号") tag = (req.tag or "").strip() count = ( _visible_huya_accounts_query(db, current) .filter(HuyaAccount.id.in_(req.account_ids)) .update( {HuyaAccount.tag: tag}, synchronize_session=False, ) ) db.commit() return { "message": f"已为 {count} 个虎牙账号设置标签", "success": True, "count": count, } @router.put("/accounts/batch-tag-selection") def batch_tag_selection( req: AccountBulkTag, db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """按显式选择或当前筛选结果批量设置虎牙账号标签。""" _require_huya_tag_permission(current) ids = _selected_huya_account_ids(db, current, req) if not ids: raise HTTPException(status_code=400, detail="请选择虎牙账号") tag = (req.tag_value or "").strip() count = ( _visible_huya_accounts_query(db, current) .filter(HuyaAccount.id.in_(ids)) .update( {HuyaAccount.tag: tag}, synchronize_session=False, ) ) db.commit() scope = "当前筛选下" if req.all_matching else "选中的" return { "message": f"已为{scope} {count} 个虎牙账号设置标签", "success": True, "count": count, } @router.get("/accounts/tags/list") def list_tags( db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """获取虎牙账号标签列表。""" tag_query = db.query(HuyaAccount.tag) if not _can_view_huya_all(current): if _can_view_huya_assigned(current): tag_query = tag_query.filter(HuyaAccount.assigned_to == current.id) else: raise HTTPException(status_code=403, detail="无权查看虎牙账号") tags = ( tag_query.filter(HuyaAccount.tag != "", HuyaAccount.tag.isnot(None)) .distinct() .all() ) return [item[0] for item in tags if item[0]] @router.get("/cookies") def list_cookies( search: str = Query(""), page: int | None = Query(None, ge=1), page_size: int = Query(20, ge=1, le=200), include_cookie: bool = Query(True), db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """查看当前用户可见的虎牙 Cookie。""" should_include_cookie = include_cookie and _can_view_huya_cookie(current) query = _visible_huya_accounts_query(db, current).filter(HuyaAccount.cookie != "") search_text = (search or "").strip() if search_text: pattern = f"%{search_text}%" query = query.outerjoin(User, HuyaAccount.assigned_to == User.id).filter( or_( HuyaAccount.uid.ilike(pattern), HuyaAccount.yyuid.ilike(pattern), HuyaAccount.username.ilike(pattern), HuyaAccount.nickname.ilike(pattern), User.username.ilike(pattern), ) ) total = None if page is not None: total = query.order_by(None).count() query = query.order_by(HuyaAccount.updated_at.desc()) if page is not None: query = query.offset((page - 1) * page_size).limit(page_size) if not should_include_cookie: query = query.options( defer(HuyaAccount.cookie), defer(HuyaAccount.account_password) ) accounts = query.all() result = ( [_cookie_out(account, include_cookie=True) for account in accounts] if should_include_cookie else [_cookie_out_light(account) for account in accounts] ) if page is not None: return { "items": result, "total": total or 0, "page": page, "page_size": page_size, } return result @router.get("/cookies/summary") def cookies_summary( db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """虎牙 Cookie 统计,避免前端为了卡片统计拉全量 Cookie。""" query = _visible_huya_accounts_query(db, current).filter(HuyaAccount.cookie != "") total = query.count() assigned_count = query.filter(HuyaAccount.assigned_to.isnot(None)).count() return { "total": total, "assigned_count": assigned_count, "unassigned_count": max(0, total - assigned_count), } @router.get("/cookies/export") def export_cookies( format: str = "csv", db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """导出虎牙 Cookie,支持 csv 和 custom 格式。""" _require_huya_perm(current, "huya:cookie:export") accounts = ( _visible_huya_accounts_query(db, current) .filter(HuyaAccount.cookie != "") .order_by(HuyaAccount.updated_at.desc()) .all() ) if format == "custom": lines = [] for account in accounts: username = account.username or account.nickname or account.uid or "" lines.append(f"{username}----{normalize_huya_cookie(account.cookie or '')}") content = "\r\n".join(lines) filename = "huya_cookies_custom.txt" media = "text/plain" else: output = io.StringIO() writer = csv.writer(output) writer.writerow(["账号", "UID", "YYUID", "Cookie", "时间"]) for account in accounts: username = account.nickname or account.username or account.uid or "" writer.writerow( [ username, account.uid or "", account.yyuid or "", normalize_huya_cookie(account.cookie or ""), account.updated_at.isoformat() if account.updated_at else "", ] ) content = output.getvalue() filename = "huya_cookies.csv" media = "text/csv" return StreamingResponse( iter([content]), media_type=media, headers={"Content-Disposition": f"attachment; filename={filename}"}, ) @router.get("/cookies/{account_id}") def get_cookie( account_id: int, db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """获取单条虎牙 Cookie 详情,供复制操作按需读取完整敏感字段。""" account = ( _visible_huya_accounts_query(db, current) .filter(HuyaAccount.id == account_id, HuyaAccount.cookie != "") .first() ) if not account: raise HTTPException(status_code=404, detail="记录不存在") return _cookie_out(account, include_cookie=_can_view_huya_cookie(current)) @router.delete("/cookies/batch") def delete_cookies_batch( account_ids: str = "", db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """批量清除虎牙 Cookie,保留账号记录。""" _require_huya_perm(current, "huya:cookie:export") ids = [int(x) for x in account_ids.split(",") if x.strip().isdigit()] if not ids: raise HTTPException(status_code=400, detail="无效的账号ID") accounts = ( _visible_huya_accounts_query(db, current).filter(HuyaAccount.id.in_(ids)).all() ) for account in accounts: account.cookie = "" account.status = "invalid" db.commit() return { "message": f"已清除 {len(accounts)} 条虎牙 Cookie", "deleted": len(accounts), "success": True, } @router.delete("/cookies/{account_id}") def delete_cookie( account_id: int, db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """清除单个虎牙 Cookie,保留账号记录。""" _require_huya_perm(current, "huya:cookie:export") account = ( _visible_huya_accounts_query(db, current) .filter(HuyaAccount.id == account_id) .first() ) if not account: raise HTTPException(status_code=404, detail="记录不存在") account.cookie = "" account.status = "invalid" db.commit() return {"message": "已清除", "success": True} @router.get("/config", response_model=HuyaConfigOut) def get_config( db: Session = Depends(get_db), current: User = Depends(require_permission("huya:config")), ): """获取虎牙配置。""" config = ensure_huya_config(db) return _config_out(config) @router.put("/config", response_model=HuyaConfigOut) def update_config( req: HuyaConfigUpdate, db: Session = Depends(get_db), current: User = Depends(require_permission("huya:config")), ): """更新虎牙配置。""" config = ensure_huya_config(db) changed_fields = [] for field in HUYA_CONFIG_FIELDS: value = getattr(req, field) if value is not None: setattr(config, field, value.strip()) changed_fields.append(field) apply_huya_config_defaults(config) config.updated_at = datetime.now(UTC) if changed_fields: record_audit( db, current, action="recharge:huya:config", target="huya_config", detail={"changed_fields": changed_fields}, ) db.commit() db.refresh(config) return _config_out(config) @router.get("/goods", response_model=list[HuyaGoodsOut]) def list_goods( db: Session = Depends(get_db), current: User = Depends(require_permission("huya:task")), ): """查看已缓存的虎牙商品快照。""" rows = db.query(HuyaGoodsSnapshot).order_by(HuyaGoodsSnapshot.id.asc()).all() return rows @router.get("/recharge-goods", response_model=list[HuyaRechargeGoodsOut]) def list_recharge_goods( db: Session = Depends(get_db), current: User = Depends(require_permission("huya:task")), ): """查看已缓存的虎牙充值商品快照。""" rows = ( db.query(HuyaRechargeGoodsSnapshot) .order_by(HuyaRechargeGoodsSnapshot.id.asc()) .all() ) return rows @router.post("/tasks/batch") async def create_task_batch( req: HuyaTaskBatchRequest, db: Session = Depends(get_db), current: User = Depends(require_permission("huya:task")), ): """创建虎牙任务记录并启动后台执行器。""" if not req.account_ids: raise HTTPException(status_code=400, detail="请选择虎牙账号") _require_huya_task_account_access(db, current, req.account_ids) # 先清掉没有执行器的历史 running/pending,避免前端被假活跃批次锁死。 # 不清理 planned:避免与刚创建的新任务产生竞态。 cleanup_orphan_huya_tasks( db, active_batch_ids=huya_batch_registry.active_ids(), statuses=("pending", "running"), message="任务已中断(无执行器接管)", ) try: batch_id, count = create_planned_tasks( db, req.account_ids, req.task_type, current.id, req.payload, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc if count == 0: raise HTTPException(status_code=400, detail="没有有效的虎牙账号") if req.task_type == "create_recharge_order": record_audit( db, current, action="recharge:huya:create", target=f"huya_batch:{batch_id}", detail={ "batch_id": batch_id, "task_type": req.task_type, "count": count, "account_count": len(req.account_ids), }, ) db.commit() log_queue = asyncio.Queue() loop = asyncio.get_running_loop() thread_db = SessionLocal() runner = HuyaBatchRunner( db=thread_db, batch_id=batch_id, task_type=req.task_type, payload=req.payload, log_queue=log_queue, loop=loop, concurrency=req.concurrency, ) huya_batch_registry.register(batch_id, log_queue, loop, runner) thread = threading.Thread(target=runner.run, daemon=True) thread.start() return {"batch_id": batch_id, "count": count, "success": True} @router.get("/tasks", response_model=list[HuyaTaskOut]) def list_tasks( batch_id: str | None = None, include_images: bool = Query( False, description="是否返回 base64 小程序码(默认否,轮询请保持 false)" ), db: Session = Depends(get_db), current: User = Depends(require_permission("huya:task")), ): """查看虎牙任务记录。""" query = _visible_huya_tasks_query(db, current) if batch_id: query = query.filter(HuyaTask.batch_id == batch_id) tasks = query.order_by(HuyaTask.id.desc()).limit(300).all() return [_task_out(task, include_images=include_images) for task in tasks] @router.post("/tasks/cleanup-orphans") def cleanup_orphan_tasks( db: Session = Depends(get_db), current: User = Depends(require_permission("huya:task")), ): """手动清理没有内存执行器接管的虎牙任务。""" cleaned = cleanup_orphan_huya_tasks( db, active_batch_ids=huya_batch_registry.active_ids(), statuses=("pending", "running"), message="任务已中断(无执行器接管)", ) return { "message": f"已清理 {cleaned} 个残留任务", "cleaned": cleaned, "success": True, } @router.get("/tasks/summary") def tasks_summary( db: Session = Depends(get_db), current: User = Depends(require_permission("huya:task")), ): """虎牙任务统计。""" return _huya_task_summary(_visible_huya_tasks_query(db, current)) @router.get("/tasks/{task_id}", response_model=HuyaTaskOut) def get_task( task_id: int, db: Session = Depends(get_db), current: User = Depends(require_permission("huya:task")), ): """获取单条虎牙任务详情(含二维码图片)。""" task = _visible_huya_tasks_query(db, current).filter(HuyaTask.id == task_id).first() if not task: raise HTTPException(status_code=404, detail="任务不存在") return _task_out(task, include_images=True) @router.post("/stop/{batch_id}") def stop_batch( batch_id: str, db: Session = Depends(get_db), current: User = Depends(require_permission("huya:task")), ): """停止正在运行的虎牙批次。""" _require_huya_batch_owner(db, current, batch_id) is_recharge_batch = ( db.query(HuyaTask.id) .filter( HuyaTask.batch_id == batch_id, HuyaTask.task_type == "create_recharge_order" ) .first() is not None ) batch = huya_batch_registry.get(batch_id) if batch: if batch.get("finished"): huya_batch_registry.pop(batch_id) cleaned = cleanup_orphan_huya_tasks( db, batch_id=batch_id, message="批次已结束", ) if cleaned: return { "message": f"批次已结束,已清理 {cleaned} 个残留任务", "success": True, } raise HTTPException(status_code=404, detail="批次已结束") batch["runner"].stop() if is_recharge_batch: record_audit( db, current, action="recharge:huya:stop", target=f"huya_batch:{batch_id}", detail={"batch_id": batch_id, "mode": "running"}, ) db.commit() return {"message": "已发送停止信号", "success": True} cleaned = cleanup_orphan_huya_tasks( db, batch_id=batch_id, message="任务已停止(批次不存在)", ) if cleaned: if is_recharge_batch: record_audit( db, current, action="recharge:huya:stop", target=f"huya_batch:{batch_id}", detail={ "batch_id": batch_id, "mode": "orphan_cleanup", "cleaned": cleaned, }, ) db.commit() return {"message": f"已清理 {cleaned} 个残留任务", "success": True} raise HTTPException(status_code=404, detail="批次不存在或已结束") @router.websocket("/ws/{batch_id}") async def ws_huya_logs(websocket: WebSocket, batch_id: str): """虎牙实时日志推送通道。""" user = authenticate_websocket(websocket) if not user: await websocket.close(code=1008, reason="未授权") return if not _has_huya_perm(user, "huya:task"): await websocket.close(code=1008, reason="无权限") return db = SessionLocal() try: _require_huya_batch_owner(db, user, batch_id) except HTTPException: await websocket.close(code=1008, reason="无权访问该任务批次") return finally: db.close() await websocket.accept() batch = huya_batch_registry.get(batch_id) if not batch: await websocket.send_json({"level": "error", "message": "批次不存在或已结束"}) await websocket.close() return log_queue: asyncio.Queue = batch["log_queue"] try: while True: try: msg = await asyncio.wait_for(log_queue.get(), timeout=30) await websocket.send_json(msg) if msg.get("level") == "result": await asyncio.sleep(0.1) break except TimeoutError: await websocket.send_json({"level": "heartbeat", "message": ""}) except WebSocketDisconnect: pass finally: latest = huya_batch_registry.get(batch_id) if latest and latest.get("finished"): huya_batch_registry.pop(batch_id) # ==================== 设备绑定管理 (一号一环境, R40) ==================== # 环境数据源: # 1) data/huya_device_profiles.json — 账号 ↔ 设备画像 (core.huya.device_profile) # 2) data/huya_fp_states/<账号>/ — hydevice 高信任指纹 localStorage (account_state_dir) # 3) huya_accounts 表 — 登录渠道/账号状态 (登录成功后回填) # 解绑 = 删除 1+2 → 下次 App 登录自动生成全新环境并重新注册签发 t2/t5。 _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 @router.get("/device-bindings") def list_device_bindings( db: Session = Depends(get_db), current: User = Depends(get_current_user) ): """列出所有账号 ↔ 设备环境绑定 (画像 + 指纹状态 + 账号表三方合并)。""" if not any(user_has_permission(current, p) for p in _DEVICE_BINDINGS_PERMS_VIEW): raise HTTPException(status_code=403, detail="无虎牙账号查看权限") profiles = _load_profile_db() accounts = {a.username: a for a in db.query(HuyaAccount).all() if a.username} rows = [] for account, prof in sorted(profiles.items()): acct = accounts.get(account) state_dir = _fp_state_dir(account) last_login = prof.get("last_login") or {} rows.append( { "account": account, "vendor": prof.get("vendor", ""), "model": prof.get("model", ""), "screen": prof.get("screen", ""), "fingerprint_masked": _mask_hex(prof.get("fingerprint")), "guid32_masked": _mask_hex(prof.get("guid32")), "hebe_count": len(prof.get("hebe") or {}), "hdid32": prof.get("hdid", ""), # t1.t0 app 级常量, 非敏感 "has_hydevice_state": state_dir.exists(), "login_channel": (acct.login_channel if acct else "") or "", "account_status": (acct.status if acct else "") or "未入库", "bound_at": prof.get("bound_at"), "last_login_at": last_login.get("at"), "last_login_ok": last_login.get("ok"), } ) return {"items": rows, "total": len(rows)} @router.delete("/device-bindings/{account}") def delete_device_binding( account: str, db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """解绑: 删除该账号的设备画像与 hydevice 指纹状态, 下次登录自动生成全新环境。""" _require_huya_perm(current, "huya:import") profiles = _load_profile_db() if account not in profiles: raise HTTPException(status_code=404, detail="该账号没有设备绑定记录") profiles.pop(account) _save_profile_db(profiles) state_dir = _fp_state_dir(account) removed_state = False if state_dir.exists(): shutil.rmtree(state_dir, ignore_errors=True) removed_state = True return { "ok": True, "account": account, "profile_removed": True, "fp_state_removed": removed_state, "message": "已解绑, 下次 App 协议登录将自动生成全新设备环境并重新注册", } 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 @router.post("/device-bindings/batch-delete") def delete_device_bindings( req: HuyaDeviceBindingsBatchDeleteRequest, db: Session = Depends(get_db), current: User = Depends(get_current_user), ): """批量解绑: 一次删除多个账号的设备画像与指纹状态。""" _require_huya_perm(current, "huya:import") removed, missing = _unbind_device_accounts(req.accounts) if not removed: raise HTTPException(status_code=404, detail="所选账号均没有设备绑定记录") message = f"已批量解绑 {len(removed)} 个账号" if missing: message += f",{len(missing)} 个账号无绑定记录已跳过" message += ",下次 App 协议登录将自动生成全新设备环境并重新注册" return {"ok": True, "removed": removed, "missing": missing, "message": message}