diff --git a/web/backend/migrations/versions/20260807_0018_cookie_check_fields.py b/web/backend/migrations/versions/20260807_0018_cookie_check_fields.py new file mode 100644 index 0000000..b52064f --- /dev/null +++ b/web/backend/migrations/versions/20260807_0018_cookie_check_fields.py @@ -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) diff --git a/web/backend/models.py b/web/backend/models.py index 72aca2e..6589078 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -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") diff --git a/web/backend/routers/cookies.py b/web/backend/routers/cookies.py index aee2a9b..408bb50 100644 --- a/web/backend/routers/cookies.py +++ b/web/backend/routers/cookies.py @@ -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": "", diff --git a/web/frontend/src/api/cookies.ts b/web/frontend/src/api/cookies.ts index cf310be..732542a 100644 --- a/web/frontend/src/api/cookies.ts +++ b/web/frontend/src/api/cookies.ts @@ -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('/cookies'), @@ -8,6 +8,8 @@ export const cookieApi = { summary: () => api.get('/cookies/summary'), get: (id: number) => api.get(`/cookies/${id}`), exportCsv: (format?: string) => api.get('/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(`/cookies/${id}`), deleteBatch: (ids: number[]) => api.delete('/cookies/batch', { params: { task_ids: ids.join(',') } }), }; diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 2480cdb..8b66e7d 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -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 ==================== diff --git a/web/frontend/src/pages/CookiePage.tsx b/web/frontend/src/pages/CookiePage.tsx index 25ef933..716781d 100644 --- a/web/frontend/src/pages/CookiePage.tsx +++ b/web/frontend/src/pages/CookiePage.tsx @@ -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>(new Set()); + const [checkResults, setCheckResults] = useState>(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 {val}; }, }, + { + title: '有效性', + width: 90, + align: 'center' as const, + render: (_: unknown, record: CookieItem) => { + if (checkingIds.has(record.id)) { + return }>检测中...; + } + const result = checkResults.get(record.id); + if (!result) return 未检测; + 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 + ? 有效 + : 无效; + return detail + ? {tag} + : 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 + ? {formatTime(checkedAt)} + : -; + }, + }, { 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() { > 复制 + {canView && ( + + )} {canExport && ( handleDelete(record.id)}> + {canView && ( + + )} {canExport && (