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_by = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime, default=_utcnow) created_at = Column(DateTime, default=_utcnow)
finished_at = Column(DateTime, nullable=True) 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") account = relationship("Account", back_populates="login_tasks")
+147 -1
View File
@@ -1,6 +1,8 @@
"""Cookie 管理路由""" """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 import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlalchemy import or_ from sqlalchemy import or_
@@ -24,6 +26,90 @@ def _fmt_dt(dt) -> str | None:
router = APIRouter(prefix="/api/cookies", tags=["Cookie管理"]) 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): def _visible_cookie_tasks_query(db: Session, current: User):
"""返回当前用户可见的成功 Cookie 任务查询。""" """返回当前用户可见的成功 Cookie 任务查询。"""
@@ -98,6 +184,9 @@ def list_cookies(
"assigned_to": acc.assigned_to, "assigned_to": acc.assigned_to,
"assigned_username": acc.assigned_user.username if acc and acc.assigned_user else None, "assigned_username": acc.assigned_user.username if acc and acc.assigned_user else None,
"created_at": _fmt_dt(t.finished_at), "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"): 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}") @router.get("/{task_id}")
def get_cookie( def get_cookie(
task_id: int, task_id: int,
@@ -203,6 +346,9 @@ def get_cookie(
"assigned_to": acc.assigned_to if acc else None, "assigned_to": acc.assigned_to if acc else None,
"assigned_username": acc.assigned_user.username if acc and acc.assigned_user else None, "assigned_username": acc.assigned_user.username if acc and acc.assigned_user else None,
"created_at": _fmt_dt(task.finished_at), "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": "",
"cookie_preview": "***", "cookie_preview": "***",
"account_password": "", "account_password": "",
+3 -1
View File
@@ -1,5 +1,5 @@
import api from './client'; import api from './client';
import type { BasicSummary, CookieItem, PageParams, PaginatedResponse, MessageDeletedResponse, MessageResponse } from './types'; import type { BasicSummary, CookieCheckResult, CookieItem, PageParams, PaginatedResponse, MessageDeletedResponse, MessageResponse } from './types';
export const cookieApi = { export const cookieApi = {
list: () => api.get<CookieItem[], CookieItem[]>('/cookies'), list: () => api.get<CookieItem[], CookieItem[]>('/cookies'),
@@ -8,6 +8,8 @@ export const cookieApi = {
summary: () => api.get<BasicSummary, BasicSummary>('/cookies/summary'), summary: () => api.get<BasicSummary, BasicSummary>('/cookies/summary'),
get: (id: number) => api.get<CookieItem, CookieItem>(`/cookies/${id}`), get: (id: number) => api.get<CookieItem, CookieItem>(`/cookies/${id}`),
exportCsv: (format?: string) => api.get<Blob, Blob>('/cookies/export', { responseType: 'blob', params: format ? { format } : {} }), exportCsv: (format?: string) => api.get<Blob, Blob>('/cookies/export', { responseType: 'blob', params: format ? { format } : {} }),
check: (ids: number[]) =>
api.post<{ results: CookieCheckResult[] }, { results: CookieCheckResult[] }>('/cookies/check', null, { params: { ids: ids.join(',') } }),
delete: (id: number) => api.delete<MessageResponse, MessageResponse>(`/cookies/${id}`), delete: (id: number) => api.delete<MessageResponse, MessageResponse>(`/cookies/${id}`),
deleteBatch: (ids: number[]) => api.delete<MessageDeletedResponse, MessageDeletedResponse>('/cookies/batch', { params: { task_ids: ids.join(',') } }), deleteBatch: (ids: number[]) => api.delete<MessageDeletedResponse, MessageDeletedResponse>('/cookies/batch', { params: { task_ids: ids.join(',') } }),
}; };
+18
View File
@@ -204,6 +204,24 @@ export interface CookieItem {
cookie: string; cookie: string;
cookie_preview: string; cookie_preview: string;
account_password: string; account_password: string;
ck_check_status: string;
ck_check_result: {
fish_ball?: number | null;
nickname?: string | null;
level?: number | null;
message?: string;
} | null;
ck_checked_at: string | null;
}
export interface CookieCheckResult {
id: number;
valid: boolean;
message: string;
fish_ball: number | null;
nickname: string | null;
level: number | null;
checked_at: string;
} }
// ==================== Douyu Activity ==================== // ==================== Douyu Activity ====================
+107 -4
View File
@@ -1,8 +1,8 @@
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { Table, Button, Card, Row, Col, Statistic, Tag, Popconfirm, Space, Typography, Input, theme, Dropdown } from 'antd'; import { Table, Button, Card, Row, Col, Statistic, Tag, Popconfirm, Space, Typography, Input, theme, Dropdown, Tooltip } from 'antd';
import { message } from '../utils/antdMessage'; import { message } from '../utils/antdMessage';
import { DownloadOutlined, DeleteOutlined, CopyOutlined, SearchOutlined } from '@ant-design/icons'; import { DownloadOutlined, DeleteOutlined, CopyOutlined, SearchOutlined, LoadingOutlined } from '@ant-design/icons';
import { cookieApi, type BasicSummary, type CookieItem } from '../api/modules'; import { cookieApi, type BasicSummary, type CookieCheckResult, type CookieItem } from '../api/modules';
import { usePermissions } from '../hooks/usePermissions'; import { usePermissions } from '../hooks/usePermissions';
import { formatTime } from '../utils/time'; import { formatTime } from '../utils/time';
import { getErrorMessage } from '../utils/error'; import { getErrorMessage } from '../utils/error';
@@ -50,11 +50,43 @@ export default function CookiePage() {
return v ? Number(v) || 20 : 20; return v ? Number(v) || 20 : 20;
}); });
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [checkingIds, setCheckingIds] = useState<Set<number>>(new Set());
const [checkResults, setCheckResults] = useState<Map<number, CookieCheckResult>>(new Map());
const { can } = usePermissions(); const { can } = usePermissions();
const canView = can('cookie:view'); const canView = can('cookie:view');
const canExport = can('cookie:export'); const canExport = can('cookie:export');
const handleCheck = async (ids: number[]) => {
if (ids.length === 0) return;
setCheckingIds((prev) => new Set([...prev, ...ids]));
try {
const res = await cookieApi.check(ids);
const map = new Map(checkResults);
for (const item of res.results) map.set(item.id, item);
setCheckResults(map);
const validCount = res.results.filter((item) => item.valid).length;
message.success(`检测完成: ${validCount}/${res.results.length} 条有效`);
} catch (e: unknown) {
message.error(getErrorMessage(e));
} finally {
setCheckingIds((prev) => {
const next = new Set(prev);
for (const id of ids) next.delete(id);
return next;
});
}
};
const handleCheckSelected = () => {
const ids = selectedRowKeys.map((k) => Number(k));
if (ids.length === 0) {
message.warning('请先选择要检测的 Cookie');
return;
}
void handleCheck(ids);
};
const loadCookies = useCallback(async () => { const loadCookies = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
@@ -66,6 +98,23 @@ export default function CookiePage() {
}); });
setCookies(data.items); setCookies(data.items);
setTotal(data.total); setTotal(data.total);
// 合并已持久化的检测结果(刷新后仍保留)
setCheckResults((prev) => {
const next = new Map(prev);
for (const item of data.items) {
if (!item.ck_check_status) continue;
next.set(item.id, {
id: item.id,
valid: item.ck_check_status === 'valid',
message: item.ck_check_result?.message ?? '',
fish_ball: item.ck_check_result?.fish_ball ?? null,
nickname: item.ck_check_result?.nickname ?? null,
level: item.ck_check_result?.level ?? null,
checked_at: item.ck_checked_at ?? '',
});
}
return next;
});
} catch (e: unknown) { } catch (e: unknown) {
message.error(getErrorMessage(e)); message.error(getErrorMessage(e));
} finally { } finally {
@@ -191,6 +240,40 @@ export default function CookiePage() {
return <span style={{ fontFamily: 'monospace', fontSize: 12 }}>{val}</span>; return <span style={{ fontFamily: 'monospace', fontSize: 12 }}>{val}</span>;
}, },
}, },
{
title: '有效性',
width: 90,
align: 'center' as const,
render: (_: unknown, record: CookieItem) => {
if (checkingIds.has(record.id)) {
return <Tag color="processing" icon={<LoadingOutlined />}>...</Tag>;
}
const result = checkResults.get(record.id);
if (!result) return <Text type="secondary" style={{ fontSize: 12 }}></Text>;
const detail = [
result.fish_ball != null ? `鱼丸: ${result.fish_ball}` : '',
result.nickname ? `昵称: ${result.nickname}${result.level != null ? ` (Lv.${result.level})` : ''}` : '',
result.message && !result.valid ? result.message : '',
].filter(Boolean).join(' | ');
const tag = result.valid
? <Tag color="success"></Tag>
: <Tag color="error"></Tag>;
return detail
? <Tooltip title={detail}>{tag}</Tooltip>
: tag;
},
},
{
title: '检测时间',
width: 170,
align: 'center' as const,
render: (_: unknown, record: CookieItem) => {
const checkedAt = checkResults.get(record.id)?.checked_at || record.ck_checked_at;
return checkedAt
? <Text type="secondary" style={{ fontSize: 12 }}>{formatTime(checkedAt)}</Text>
: <Text type="secondary" style={{ fontSize: 12 }}>-</Text>;
},
},
{ {
title: '时间', title: '时间',
dataIndex: 'created_at', dataIndex: 'created_at',
@@ -200,7 +283,7 @@ export default function CookiePage() {
}, },
{ {
title: '操作', title: '操作',
width: 130, width: 190,
align: 'center' as const, align: 'center' as const,
fixed: 'right' as const, fixed: 'right' as const,
render: (_: unknown, record: CookieItem) => ( render: (_: unknown, record: CookieItem) => (
@@ -212,6 +295,17 @@ export default function CookiePage() {
> >
</Button> </Button>
{canView && (
<Button
size="small"
icon={checkingIds.has(record.id) ? <LoadingOutlined /> : undefined}
loading={checkingIds.has(record.id)}
disabled={checkingIds.has(record.id)}
onClick={() => handleCheck([record.id])}
>
</Button>
)}
{canExport && ( {canExport && (
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}> <Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
<Button danger size="small" icon={<DeleteOutlined />} /> <Button danger size="small" icon={<DeleteOutlined />} />
@@ -234,6 +328,15 @@ export default function CookiePage() {
> >
{selectedRowKeys.length > 0 && `(${selectedRowKeys.length})`} {selectedRowKeys.length > 0 && `(${selectedRowKeys.length})`}
</Button> </Button>
{canView && (
<Button
icon={<LoadingOutlined />}
onClick={handleCheckSelected}
disabled={selectedRowKeys.length === 0}
>
{selectedRowKeys.length > 0 && `(${selectedRowKeys.length})`}
</Button>
)}
{canExport && ( {canExport && (
<Popconfirm <Popconfirm
title={`确认删除选中的 ${selectedRowKeys.length} 条 Cookie`} title={`确认删除选中的 ${selectedRowKeys.length} 条 Cookie`}