feat(cookies): CK 有效性检测(双接口) + 结果持久化

- 检测: 鱼丸余额(fishBall) + 用户等级(userLevelDetail)双接口均通过才判有效,
  附带鱼丸数/昵称/等级, 失败时分别说明原因, 8 并发批量检测
- 持久化: login_tasks 新增 ck_check_status/ck_check_result/ck_checked_at,
  刷新翻页不丢失; 列表与详情接口返回检测字段
- 前端: 新增"有效性"列(有效/无效+鱼丸昵称tooltip)与独立"检测时间"列,
  行内/选中批量检测按钮(cookie:view 权限), 加载时初始化历史检测结果
This commit is contained in:
yml2213
2026-08-06 20:58:23 +08:00
parent a515ea6e0a
commit b962f63bd2
6 changed files with 320 additions and 6 deletions
@@ -0,0 +1,41 @@
"""斗鱼 Cookie 有效性检测结果持久化字段
Revision ID: 20260807_0018
Revises: 20260807_0017
Create Date: 2026-08-07
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260807_0018"
down_revision: Union[str, None] = "20260807_0017"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
if not sa.inspect(bind).has_table("login_tasks"):
return
columns = {column["name"] for column in sa.inspect(bind).get_columns("login_tasks")}
for column in [
sa.Column("ck_check_status", sa.String(length=16), nullable=True),
sa.Column("ck_check_result", sa.JSON(), nullable=True),
sa.Column("ck_checked_at", sa.DateTime(), nullable=True),
]:
if column.name not in columns:
op.add_column("login_tasks", column)
def downgrade() -> None:
bind = op.get_bind()
if not sa.inspect(bind).has_table("login_tasks"):
return
columns = {column["name"] for column in sa.inspect(bind).get_columns("login_tasks")}
for column_name in ["ck_checked_at", "ck_check_result", "ck_check_status"]:
if column_name in columns:
op.drop_column("login_tasks", column_name)
+4
View File
@@ -99,6 +99,10 @@ class LoginTask(Base):
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime, default=_utcnow)
finished_at = Column(DateTime, nullable=True)
# Cookie 有效性检测结果(持久化,刷新不丢失)
ck_check_status = Column(String(16), default="") # valid / invalid / 空=未检测
ck_check_result = Column(JSON, nullable=True) # {fish_ball, nickname, level, message}
ck_checked_at = Column(DateTime, nullable=True)
account = relationship("Account", back_populates="login_tasks")
+147 -1
View File
@@ -1,6 +1,8 @@
"""Cookie 管理路由"""
from datetime import timezone
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 or_
@@ -24,6 +26,90 @@ def _fmt_dt(dt) -> str | None:
router = APIRouter(prefix="/api/cookies", tags=["Cookie管理"])
# 斗鱼 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 任务查询。"""
@@ -98,6 +184,9 @@ def list_cookies(
"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"):
@@ -184,6 +273,60 @@ def export_cookies(
)
@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.get("/{task_id}")
def get_cookie(
task_id: int,
@@ -203,6 +346,9 @@ def get_cookie(
"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": "",