完善虎牙账号管理并规范Cookie

This commit is contained in:
yml2213
2026-07-05 13:13:38 +08:00
parent 8f48b96fdc
commit 984096f758
15 changed files with 1320 additions and 59 deletions
+15 -1
View File
@@ -23,7 +23,14 @@ PERMISSIONS = {
"cookie:view": "查看 Cookie",
"cookie:export": "导出 Cookie",
# 虎牙
"huya:account": "虎牙 CK 管理",
"huya:account": "虎牙账号管理(兼容旧权限)",
"huya:view_all": "查看所有虎牙账号",
"huya:view_assigned": "查看分配给自己的虎牙账号",
"huya:import": "导入/登录虎牙账号",
"huya:assign": "分配虎牙账号",
"huya:delete": "删除虎牙账号",
"huya:cookie:view": "查看虎牙 Cookie",
"huya:cookie:export": "导出/清除虎牙 Cookie",
"huya:task": "虎牙任务管理",
"huya:bind": "虎牙绑定操作",
"huya:recharge": "虎牙充值操作",
@@ -49,6 +56,12 @@ ROLE_PERMISSIONS = {
"cookie:view",
"cookie:export",
"huya:account",
"huya:view_all",
"huya:import",
"huya:assign",
"huya:delete",
"huya:cookie:view",
"huya:cookie:export",
"huya:task",
"huya:bind",
"huya:recharge",
@@ -59,6 +72,7 @@ ROLE_PERMISSIONS = {
],
"support": [
"account:view_assigned",
"huya:view_assigned",
],
}
+298 -12
View File
@@ -1,18 +1,27 @@
"""虎牙基础管理路由"""
import asyncio
import csv
import io
import threading
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
from fastapi.responses import StreamingResponse
from sqlalchemy import func
from sqlalchemy.orm import Session, joinedload
from core.huya import HuyaCredentialError, HuyaLoginError, login_huya_password
from core.huya.cookie_utils import normalize_huya_cookie
from ..database import SessionLocal, get_db
from ..deps import authenticate_websocket, require_permission
from ..deps import authenticate_websocket, get_current_user, require_permission
from ..models import HuyaAccount, HuyaConfig, HuyaGoodsSnapshot, HuyaRechargeGoodsSnapshot, HuyaTask, User
from ..permissions import user_has_permission
from ..schemas import (
AccountAssign,
AccountTag,
BatchAssign,
HuyaAccountOut,
HuyaConfigOut,
HuyaConfigUpdate,
@@ -39,22 +48,54 @@ from ..services.huya_runner import HuyaBatchRunner, huya_batch_registry
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 _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 _fmt_cookie_preview(cookie: str) -> str:
if not cookie:
return ""
return cookie[:50] + "..." if len(cookie) > 50 else cookie
def _account_out(account: HuyaAccount) -> HuyaAccountOut:
cookie = account.cookie or ""
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 "",
nickname=account.nickname or "",
cookie=cookie,
cookie_preview=_fmt_cookie_preview(cookie),
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 "",
@@ -98,6 +139,23 @@ def _config_out(config: HuyaConfig) -> HuyaConfigOut:
)
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 "***",
}
@router.get("/task-types")
def task_types(current: User = Depends(require_permission("huya:task"))):
"""返回当前规划的虎牙任务类型。"""
@@ -106,25 +164,33 @@ def task_types(current: User = Depends(require_permission("huya:task"))):
@router.get("/accounts", response_model=list[HuyaAccountOut])
def list_accounts(
assigned_only: bool = Query(False),
tag: str | None = Query(None),
has_cookie: bool = Query(False),
db: Session = Depends(get_db),
current: User = Depends(require_permission("huya:account")),
current: User = Depends(get_current_user),
):
"""查看虎牙 CK 账号。"""
query = db.query(HuyaAccount).options(joinedload(HuyaAccount.assigned_user))
query = _visible_huya_accounts_query(db, current)
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 != "")
accounts = query.order_by(HuyaAccount.id.desc()).all()
return [_account_out(account) for account in accounts]
include_cookie = _can_view_huya_cookie(current)
return [_account_out(account, include_cookie=include_cookie) for account in accounts]
@router.post("/accounts/import-cookies")
def import_cookies(
req: HuyaCookieImport,
db: Session = Depends(get_db),
current: User = Depends(require_permission("huya:account")),
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}",
@@ -138,9 +204,10 @@ def import_cookies(
def password_login_account(
req: HuyaPasswordLoginRequest,
db: Session = Depends(get_db),
current: User = Depends(require_permission("huya:account")),
current: User = Depends(get_current_user),
):
"""使用账号密码登录虎牙,成功后保存 Cookie。"""
_require_huya_perm(current, "huya:import")
try:
result = login_huya_password(
username=req.username.strip(),
@@ -173,9 +240,10 @@ def password_login_account(
def delete_accounts_batch(
account_ids: str = Query(..., description="逗号分隔的虎牙账号ID"),
db: Session = Depends(get_db),
current: User = Depends(require_permission("huya:account")),
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")
@@ -189,9 +257,10 @@ def delete_accounts_batch(
def delete_account(
account_id: int,
db: Session = Depends(get_db),
current: User = Depends(require_permission("huya:account")),
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="账号不存在")
@@ -201,6 +270,223 @@ def delete_account(
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_perm(current, "huya:import")
account = db.query(HuyaAccount).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_perm(current, "huya:import")
if not req.account_ids:
raise HTTPException(status_code=400, detail="请选择账号")
tag = (req.tag or "").strip()
count = db.query(HuyaAccount).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.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(
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""查看当前用户可见的虎牙 Cookie。"""
include_cookie = _can_view_huya_cookie(current)
accounts = _visible_huya_accounts_query(db, current).filter(HuyaAccount.cookie != "").order_by(HuyaAccount.updated_at.desc()).all()
return [_cookie_out(account, include_cookie=include_cookie) for account in accounts]
@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.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),
+2 -1
View File
@@ -11,6 +11,7 @@ from loguru import logger
from sqlalchemy.orm import Session, joinedload
from core.huya import HuyaHttpClient
from core.huya.cookie_utils import normalize_huya_cookie
from ..database import SessionLocal
from ..models import HuyaAccount, HuyaGoodsSnapshot, HuyaRechargeGoodsSnapshot, HuyaTask
from .huya_service import HUYA_CONFIG_FIELDS, cookie_value, ensure_huya_config, huya_config_value
@@ -1196,7 +1197,7 @@ class HuyaBatchRunner:
"yyuid": account.yyuid or "",
"username": account.username or "",
"nickname": account.nickname or "",
"cookie": account.cookie or "",
"cookie": normalize_huya_cookie(account.cookie or ""),
},
})
self.db.commit()
+3 -7
View File
@@ -1,11 +1,12 @@
"""虎牙基础业务服务。"""
import re
import uuid
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from core.huya.cookie_utils import cookie_value, normalize_huya_cookie
from ..huya_defaults import HUYA_CONFIG_DEFAULTS, HUYA_CONFIG_FIELDS
from ..models import HuyaAccount, HuyaConfig, HuyaTask
@@ -44,12 +45,6 @@ def apply_huya_config_defaults(config: HuyaConfig) -> bool:
return changed
def cookie_value(cookie: str, key: str) -> str:
"""从 Cookie 文本中提取指定 key。"""
match = re.search(rf"(?:^|;\s*){re.escape(key)}=([^;]+)", cookie or "")
return match.group(1).strip() if match else ""
def _looks_like_huya_cookie(value: str) -> bool:
"""判断文本是否像虎牙 Cookie。"""
return "udb_" in value or "yyuid=" in value
@@ -78,6 +73,7 @@ def parse_huya_cookie_line(line: str) -> dict | None:
if not _looks_like_huya_cookie(cookie):
return None
cookie = normalize_huya_cookie(cookie)
yyuid = cookie_value(cookie, "yyuid")
uid = cookie_value(cookie, "udb_uid") or yyuid