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

579 lines
21 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.
"""Cookie 管理路由"""
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone
import requests
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy import case, or_
from sqlalchemy.orm import Session, defer, joinedload
import io
import csv
from ..database import get_db, SessionLocal
from ..models import User, LoginTask, Account, ProxyConfig as ProxyConfigModel
from ..deps import get_current_user, require_permission
from ..permissions import user_has_permission, get_user_permissions
from ..schemas import CookieReloginRequest
from ..services.login_service import LoginBatchRunner
def _fmt_dt(dt) -> str | None:
"""将 datetime 格式化为带时区的 ISO 字符串。"""
if dt is None:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.isoformat()
router = APIRouter(prefix="/api/cookies", tags=["Cookie管理"])
def _parse_account_names(raw_names: str) -> list[str]:
"""解析前端粘贴的 Excel 账号名,每行一个并去重。"""
names = []
seen = set()
for value in (raw_names or "").replace("\r\n", "\n").replace("\r", "\n").split("\n"):
name = value.strip()
if name and name not in seen:
names.append(name)
seen.add(name)
return names
def _order_cookie_tasks(query, selected_names: list[str]):
"""按自定义账号名的输入顺序排序,未指定时保持默认的最新优先。"""
if selected_names:
input_order = case(
{name: index for index, name in enumerate(selected_names)},
value=Account.username,
else_=len(selected_names),
)
return query.order_by(input_order, LoginTask.finished_at.desc(), LoginTask.id.desc())
return query.order_by(LoginTask.finished_at.desc(), LoginTask.id.desc())
# 斗鱼 Cookie 有效性检测接口(抓包参考:钱包中心鱼丸余额 + 用户等级详情)
CHECK_UA = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"
)
FISH_BALL_API = "https://www.douyu.com/wjapi/nc/exchange/fishBall"
USER_LEVEL_API = "https://www.douyu.com/japi/interactnc/web/userLevel/userLevelDetail"
def _check_one_cookie(task: LoginTask) -> dict:
"""检测单条斗鱼 Cookie 有效性:鱼丸 + 用户等级双接口均通过才算有效。
附带鱼丸数、昵称/等级与检测时间;任一接口失败会说明原因。
"""
cookie = task.cookie or ""
checked_at = datetime.now(timezone.utc).isoformat()
base = {"id": task.id, "checked_at": checked_at, "fish_ball": None, "nickname": None, "level": None}
if not cookie:
return {**base, "valid": False, "message": "Cookie 为空"}
fish_ok = False
fish_msg = ""
fish_ball = None
try:
fish_data = requests.get(
FISH_BALL_API,
params={"appCode": "YJTX"},
headers={
"User-Agent": CHECK_UA,
"Accept": "application/json, text/plain, */*",
"Referer": "https://www.douyu.com/member/walletcenter",
"Cookie": cookie,
},
timeout=(5, 10),
).json()
if isinstance(fish_data, dict) and fish_data.get("error") in (0, "0"):
fish_ok = True
fish_ball = (fish_data.get("data") or {}).get("count") if isinstance(fish_data.get("data"), dict) else None
else:
fish_msg = str(fish_data.get("msg") or fish_data.get("error") or "响应异常") if isinstance(fish_data, dict) else "响应异常"
except Exception as exc:
fish_msg = f"请求失败: {exc}"
level_ok = False
level_msg = ""
nickname = None
level = None
try:
level_data = requests.get(
USER_LEVEL_API,
params={"rid": "0"},
headers={
"User-Agent": CHECK_UA,
"Accept": "application/json, text/plain, */*",
"Referer": "https://www.douyu.com/pages/ord-user-level?clientType=web",
"Cookie": cookie,
},
timeout=(5, 10),
).json()
if isinstance(level_data, dict) and level_data.get("error") in (0, "0"):
level_ok = True
info = level_data.get("data") if isinstance(level_data.get("data"), dict) else {}
nickname = str(info.get("nn") or "") or None
level = info.get("lv")
else:
level_msg = str(level_data.get("msg") or level_data.get("error") or "响应异常") if isinstance(level_data, dict) else "响应异常"
except Exception as exc:
level_msg = f"请求失败: {exc}"
valid = fish_ok and level_ok
if valid:
message = "有效"
else:
parts = [f"鱼丸接口: {'ok' if fish_ok else (fish_msg or '失败')}", f"等级接口: {'ok' if level_ok else (level_msg or '失败')}"]
message = "".join(parts)
return {
**base,
"valid": valid,
"message": message[:200],
"fish_ball": fish_ball,
"nickname": nickname,
"level": level,
}
def _visible_cookie_tasks_query(db: Session, current: User):
"""返回当前用户可见的成功 Cookie 任务查询。"""
query = db.query(LoginTask).filter(LoginTask.status == "success")
# 无全量登录任务权限时,只能操作分配给自己的账号 Cookie。
if not user_has_permission(current, "login:view_all"):
query = query.join(Account, LoginTask.account_id == Account.id).filter(
Account.assigned_to == current.id
)
return query
@router.get("")
def list_cookies(
search: str = Query(""),
account_names: 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 列表。"""
if not include_cookie:
query = _visible_cookie_tasks_query(db, current).options(defer(LoginTask.cookie))
else:
query = _visible_cookie_tasks_query(db, current).options(
joinedload(LoginTask.account).joinedload(Account.assigned_user),
)
search_text = (search or "").strip()
selected_names = _parse_account_names(account_names)
can_view_all = user_has_permission(current, "login:view_all")
account_joined = not can_view_all
if selected_names:
if not account_joined:
query = query.join(Account, LoginTask.account_id == Account.id)
account_joined = True
query = query.filter(Account.username.in_(selected_names))
if search_text:
pattern = f"%{search_text}%"
if not account_joined:
query = query.join(Account, LoginTask.account_id == Account.id)
account_joined = True
query = query.outerjoin(User, Account.assigned_to == User.id).filter(or_(
Account.username.ilike(pattern),
User.username.ilike(pattern),
))
total = None
if page is not None:
total = query.order_by(None).count()
query = _order_cookie_tasks(query, selected_names)
if page is not None:
query = query.offset((page - 1) * page_size).limit(page_size)
tasks = query.all()
# 批量查账号,避免 N+1 查询
account_ids = [t.account_id for t in tasks]
accounts_map = {}
if account_ids:
account_query = db.query(Account).filter(Account.id.in_(account_ids))
if not include_cookie:
account_query = account_query.options(
joinedload(Account.assigned_user),
defer(Account.password),
defer(Account.email),
defer(Account.email_password),
)
accs = account_query.all()
accounts_map = {a.id: a for a in accs}
result = []
for t in tasks:
acc = accounts_map.get(t.account_id)
item = {
"id": t.id,
"batch_id": t.batch_id,
"account_id": t.account_id,
"account_username": acc.username if acc else "",
"assigned_to": acc.assigned_to,
"assigned_username": acc.assigned_user.username if acc and acc.assigned_user else None,
"created_at": _fmt_dt(t.finished_at),
"ck_check_status": t.ck_check_status or "",
"ck_check_result": t.ck_check_result,
"ck_checked_at": _fmt_dt(t.ck_checked_at),
}
# 分页列表默认只返回预览,复制/导出时再获取完整敏感字段。
if include_cookie and user_has_permission(current, "cookie:view"):
cookie = t.cookie or ""
item["cookie"] = cookie
item["cookie_preview"] = cookie[:50] + "..." if len(cookie) > 50 else cookie
item["account_password"] = acc.password if acc else ""
else:
item["cookie"] = ""
item["cookie_preview"] = "***"
item["account_password"] = ""
result.append(item)
if page is not None:
return {"items": result, "total": total or 0, "page": page, "page_size": page_size}
return result
@router.get("/summary")
def cookies_summary(
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""Cookie 管理统计,避免前端为了卡片统计拉全量 Cookie。"""
query = _visible_cookie_tasks_query(db, current)
if user_has_permission(current, "login:view_all"):
query = query.join(Account, LoginTask.account_id == Account.id)
total = query.count()
assigned_count = query.filter(Account.assigned_to.isnot(None)).count()
return {
"total": total,
"assigned_count": assigned_count,
"unassigned_count": max(0, total - assigned_count),
}
@router.get("/duplicates")
def find_duplicate_cookies(
db: Session = Depends(get_db),
current: User = Depends(require_permission("cookie:view")),
):
"""检测 CK 管理中同一账号出现多次的记录,不返回 Cookie 敏感字段。"""
query = (
db.query(
LoginTask.id.label("cookie_id"),
LoginTask.account_id.label("account_id"),
LoginTask.batch_id.label("batch_id"),
LoginTask.finished_at.label("finished_at"),
Account.username.label("username"),
)
.join(Account, LoginTask.account_id == Account.id)
.filter(LoginTask.status == "success")
)
# 与 CK 列表保持相同的数据可见范围,客服只能检测自己被分配的账号。
if not user_has_permission(current, "login:view_all"):
query = query.filter(Account.assigned_to == current.id)
rows = query.order_by(Account.username.asc(), LoginTask.finished_at.desc(), LoginTask.id.desc()).all()
grouped: dict[str, dict] = {}
for row in rows:
username = (row.username or "").strip()
if not username:
continue
account_key = username.casefold()
group = grouped.setdefault(
account_key,
{
"account_key": account_key,
"account_names": set(),
"account_ids": set(),
"cookie_ids": [],
"records": [],
},
)
group["account_names"].add(username)
group["account_ids"].add(row.account_id)
group["cookie_ids"].append(row.cookie_id)
group["records"].append({
"id": row.cookie_id,
"account_id": row.account_id,
"batch_id": row.batch_id,
"finished_at": _fmt_dt(row.finished_at),
})
duplicate_groups = []
for group in grouped.values():
if len(group["cookie_ids"]) < 2:
continue
duplicate_groups.append({
"account_key": group["account_key"],
"account_names": sorted(group["account_names"]),
"cookie_count": len(group["cookie_ids"]),
"account_count": len(group["account_ids"]),
"cookie_ids": group["cookie_ids"],
"account_ids": sorted(group["account_ids"]),
"records": group["records"],
})
duplicate_groups.sort(key=lambda item: (-item["cookie_count"], item["account_key"]))
return {
"success": True,
"duplicate_groups": len(duplicate_groups),
"duplicate_rows": sum(item["cookie_count"] for item in duplicate_groups),
"groups": duplicate_groups,
}
@router.get("/export")
def export_cookies(
format: str = "csv",
account_names: str = Query(""),
db: Session = Depends(get_db),
current: User = Depends(require_permission("cookie:export")),
):
"""导出 Cookie,支持 csv 和 custom 格式。
csv: 账号, Cookie, 时间
custom: 账号----密码----ck
"""
query = _visible_cookie_tasks_query(db, current)
selected_names = _parse_account_names(account_names)
if selected_names:
if user_has_permission(current, "login:view_all"):
query = query.join(Account, LoginTask.account_id == Account.id)
query = query.filter(Account.username.in_(selected_names))
tasks = _order_cookie_tasks(query, selected_names).all()
# 批量查账号,避免 N+1
account_ids = [t.account_id for t in tasks]
accounts_map = {}
if account_ids:
accs = db.query(Account).filter(Account.id.in_(account_ids)).all()
accounts_map = {a.id: a for a in accs}
if format == "custom":
# 账号----密码----ck
lines = []
for t in tasks:
acc = accounts_map.get(t.account_id)
username = acc.username if acc else ""
password = acc.password if acc else ""
ck = t.cookie or ""
lines.append(f"{username}----{password}----{ck}")
content = "\r\n".join(lines)
filename = "cookies_custom.txt"
media = "text/plain"
else:
# CSV: 账号, Cookie, 时间
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["账号", "Cookie", "时间"])
for t in tasks:
acc = accounts_map.get(t.account_id)
username = acc.username if acc else ""
writer.writerow([username, t.cookie or "", _fmt_dt(t.finished_at) or ""])
content = output.getvalue()
filename = "cookies.csv"
media = "text/csv"
return StreamingResponse(
iter([content]),
media_type=media,
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
@router.post("/check")
def check_cookies(
ids: str = "",
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""批量检测斗鱼 Cookie 有效性(鱼丸接口),返回每条有效性与鱼丸数/昵称。"""
if not ids:
raise HTTPException(status_code=400, detail="请指定记录ID")
id_list = [int(x) for x in ids.split(",") if x.strip().isdigit()]
if not id_list:
raise HTTPException(status_code=400, detail="无效的ID")
tasks = _visible_cookie_tasks_query(db, current).filter(LoginTask.id.in_(id_list)).all()
if not tasks:
raise HTTPException(status_code=404, detail="记录不存在")
results: list[dict] = []
with ThreadPoolExecutor(max_workers=8) as pool:
futures = {pool.submit(_check_one_cookie, task): task for task in tasks}
for future in as_completed(futures):
try:
results.append(future.result())
except Exception as exc:
task = futures[future]
results.append({
"id": task.id,
"valid": False,
"message": f"检测异常: {exc}",
"fish_ball": None,
"nickname": None,
"level": None,
"checked_at": datetime.now(timezone.utc).isoformat(),
})
results.sort(key=lambda item: item["id"])
# 持久化检测结果,刷新/翻页不丢失
tasks_by_id = {task.id: task for task in tasks}
for item in results:
task = tasks_by_id.get(item["id"])
if not task:
continue
task.ck_check_status = "valid" if item["valid"] else "invalid"
task.ck_check_result = {
"fish_ball": item.get("fish_ball"),
"nickname": item.get("nickname"),
"level": item.get("level"),
"message": item.get("message", ""),
}
task.ck_checked_at = datetime.now(timezone.utc)
db.commit()
return {"results": results, "success": True}
@router.post("/relogin")
def relogin_cookies(
req: CookieReloginRequest,
db: Session = Depends(get_db),
current: User = Depends(require_permission("login:batch")),
):
"""重新登录失效的 Cookie:用账号信息重新登录,成功后替换旧 Cookie。
复用原 LoginTask 记录,行不消失;重新登录失败保留旧 Cookie 并记录原因。
"""
if not req.ids:
raise HTTPException(status_code=400, detail="请选择要重新登录的 Cookie")
tasks = _visible_cookie_tasks_query(db, current).filter(LoginTask.id.in_(req.ids)).all()
if not tasks:
raise HTTPException(status_code=404, detail="记录不存在")
# 过滤缺少登录凭据的账号(密码/邮箱),避免启动后全部失败
task_ids: list[int] = []
skipped: list[str] = []
accounts_map = {t.account_id: t for t in tasks}
for acc_id, task in accounts_map.items():
acc = db.query(Account).filter(Account.id == acc_id).first()
if not acc:
continue
if not acc.password or not acc.email:
skipped.append(task.id)
continue
task_ids.append(task.id)
if not task_ids:
raise HTTPException(
status_code=400,
detail="所选 Cookie 对应账号缺少密码或邮箱信息,无法重新登录,请先在账号管理中补充",
)
proxy = db.query(ProxyConfigModel).first()
thread_db = SessionLocal()
runner = LoginBatchRunner(
db=thread_db,
account_ids=[],
created_by=current.id,
creator_permissions=get_user_permissions(current),
proxy_config=proxy,
log_queue=None,
loop=None,
concurrency=3,
mode="relogin",
relogin_task_ids=task_ids,
)
batch_id = runner.batch_id
thread = threading.Thread(target=runner.run, daemon=True)
thread.start()
return {
"batch_id": batch_id,
"count": len(task_ids),
"skipped": len(skipped),
"success": True,
}
@router.get("/{task_id}")
def get_cookie(
task_id: int,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""获取单条 Cookie 详情,供复制操作按需读取完整敏感字段。"""
task = _visible_cookie_tasks_query(db, current).filter(LoginTask.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="记录不存在")
acc = db.query(Account).filter(Account.id == task.account_id).first()
item = {
"id": task.id,
"batch_id": task.batch_id,
"account_id": task.account_id,
"account_username": acc.username if acc else "",
"assigned_to": acc.assigned_to if acc else None,
"assigned_username": acc.assigned_user.username if acc and acc.assigned_user else None,
"created_at": _fmt_dt(task.finished_at),
"ck_check_status": task.ck_check_status or "",
"ck_check_result": task.ck_check_result,
"ck_checked_at": _fmt_dt(task.ck_checked_at),
"cookie": "",
"cookie_preview": "***",
"account_password": "",
}
if user_has_permission(current, "cookie:view"):
cookie = task.cookie or ""
item["cookie"] = cookie
item["cookie_preview"] = cookie[:50] + "..." if len(cookie) > 50 else cookie
item["account_password"] = acc.password if acc else ""
return item
@router.delete("/batch")
def delete_cookies_batch(
task_ids: str = "",
db: Session = Depends(get_db),
current: User = Depends(require_permission("cookie:export")),
):
"""批量删除 Cookie 记录。"""
if not task_ids:
raise HTTPException(status_code=400, detail="请指定记录ID")
ids = [int(x) for x in task_ids.split(",") if x.strip().isdigit()]
if not ids:
raise HTTPException(status_code=400, detail="无效的ID")
tasks = _visible_cookie_tasks_query(db, current).filter(LoginTask.id.in_(ids)).all()
for t in tasks:
t.cookie = ""
t.status = "failed"
t.message = "Cookie已清除"
db.commit()
return {"message": f"已删除 {len(tasks)} 条", "deleted": len(tasks), "success": True}
@router.delete("/{task_id}")
def delete_cookie(
task_id: int,
db: Session = Depends(get_db),
current: User = Depends(require_permission("cookie:export")),
):
"""删除一条 Cookie 记录。"""
task = _visible_cookie_tasks_query(db, current).filter(LoginTask.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="记录不存在")
task.cookie = ""
task.status = "failed"
task.message = "Cookie已清除"
db.commit()
return {"message": "已删除", "success": True}