- routers/huya.py: POST /api/huya/device-bindings/batch-delete (去重/跳过无记录/删画像+指纹目录) - schemas.py: HuyaDeviceBindingsBatchDeleteRequest - DeviceBindingsPage: 多选(rowSelection preserve) / 全选全部 / 解绑选中(N) + loading, 刷新后清理失效选中 - api/huya.ts + types.ts: deleteDeviceBindings 与响应类型 验证: 批量解绑逻辑单测 OK; 77 后端单测 OK; tsc + vite build OK
1870 lines
69 KiB
Python
1870 lines
69 KiB
Python
"""虎牙基础管理路由"""
|
||
|
||
import asyncio
|
||
import csv
|
||
import io
|
||
import json
|
||
import shutil
|
||
import threading
|
||
from datetime import datetime, timezone
|
||
|
||
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(),
|
||
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, 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, 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}
|