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:
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 = {
|
||||
list: () => api.get<CookieItem[], CookieItem[]>('/cookies'),
|
||||
@@ -8,6 +8,8 @@ export const cookieApi = {
|
||||
summary: () => api.get<BasicSummary, BasicSummary>('/cookies/summary'),
|
||||
get: (id: number) => api.get<CookieItem, CookieItem>(`/cookies/${id}`),
|
||||
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}`),
|
||||
deleteBatch: (ids: number[]) => api.delete<MessageDeletedResponse, MessageDeletedResponse>('/cookies/batch', { params: { task_ids: ids.join(',') } }),
|
||||
};
|
||||
|
||||
@@ -204,6 +204,24 @@ export interface CookieItem {
|
||||
cookie: string;
|
||||
cookie_preview: 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 ====================
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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 { DownloadOutlined, DeleteOutlined, CopyOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import { cookieApi, type BasicSummary, type CookieItem } from '../api/modules';
|
||||
import { DownloadOutlined, DeleteOutlined, CopyOutlined, SearchOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||
import { cookieApi, type BasicSummary, type CookieCheckResult, type CookieItem } from '../api/modules';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { formatTime } from '../utils/time';
|
||||
import { getErrorMessage } from '../utils/error';
|
||||
@@ -50,11 +50,43 @@ export default function CookiePage() {
|
||||
return v ? Number(v) || 20 : 20;
|
||||
});
|
||||
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 canView = can('cookie:view');
|
||||
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 () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -66,6 +98,23 @@ export default function CookiePage() {
|
||||
});
|
||||
setCookies(data.items);
|
||||
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) {
|
||||
message.error(getErrorMessage(e));
|
||||
} finally {
|
||||
@@ -191,6 +240,40 @@ export default function CookiePage() {
|
||||
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: '时间',
|
||||
dataIndex: 'created_at',
|
||||
@@ -200,7 +283,7 @@ export default function CookiePage() {
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 130,
|
||||
width: 190,
|
||||
align: 'center' as const,
|
||||
fixed: 'right' as const,
|
||||
render: (_: unknown, record: CookieItem) => (
|
||||
@@ -212,6 +295,17 @@ export default function CookiePage() {
|
||||
>
|
||||
复制
|
||||
</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 && (
|
||||
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button danger size="small" icon={<DeleteOutlined />} />
|
||||
@@ -234,6 +328,15 @@ export default function CookiePage() {
|
||||
>
|
||||
复制选中 {selectedRowKeys.length > 0 && `(${selectedRowKeys.length})`}
|
||||
</Button>
|
||||
{canView && (
|
||||
<Button
|
||||
icon={<LoadingOutlined />}
|
||||
onClick={handleCheckSelected}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
>
|
||||
检测选中 {selectedRowKeys.length > 0 && `(${selectedRowKeys.length})`}
|
||||
</Button>
|
||||
)}
|
||||
{canExport && (
|
||||
<Popconfirm
|
||||
title={`确认删除选中的 ${selectedRowKeys.length} 条 Cookie?`}
|
||||
|
||||
Reference in New Issue
Block a user