Files
live-hub-py/web/backend/routers/huya.py
T

2126 lines
72 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""虎牙基础管理路由"""
import asyncio
import csv
import io
import json
import shutil
import threading
from datetime import datetime, timezone
from typing import cast
from pathlib import Path
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,
_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,
HuyaAutoRegisterBatchOut,
HuyaAutoRegisterRequest,
HuyaAutoRegisterRetryRequest,
HuyaConfigOut,
HuyaConfigUpdate,
HuyaCookieImport,
HuyaDeviceBindingsBatchDeleteRequest,
HuyaGoodsOut,
HuyaPasswordAccountImport,
HuyaPasswordLoginRequest,
HuyaAppPasswordLoginRequest,
HuyaPasswordLoginSelectedRequest,
HuyaRechargeGoodsOut,
HuyaRegisterSuccessLogOut,
HuyaSmsCodeRequest,
HuyaSmsLoginRequest,
HuyaTaskBatchRequest,
HuyaTaskOut,
)
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,
)
from ..services.huya_runner import HuyaBatchRunner, huya_batch_registry
from ..services.audit_service import record_audit
from ..services.huya_register_runner import (
export_success_logs_text,
huya_register_registry,
list_success_logs,
)
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(timezone.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 = HuyaAutoRegisterRetryRequest.model_construct(),
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""继续批次:默认从停止处往下跑(跳过成功与已失败);可传 mode 改行为。"""
_require_huya_perm(current, "huya:import")
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(timezone.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(timezone.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(timezone.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:
account.status = "login_failed"
account.updated_at = datetime.now(timezone.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(timezone.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(timezone.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(timezone.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(timezone.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:
account.status = "login_failed"
account.updated_at = datetime.now(timezone.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(timezone.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 asyncio.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}