From b39780f26df3c74a00d323c162e39a743efc7121 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 8 Aug 2026 20:51:50 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=80=E9=94=AE=E6=A3=80=E6=B5=8B=E9=87=8D?= =?UTF-8?q?=E5=A4=8D-=E4=BB=93=E5=BA=93=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/backend/routers/cookies.py | 72 ++++++++++++++++ web/frontend/src/api/cookies.ts | 3 +- web/frontend/src/api/types.ts | 24 ++++++ web/frontend/src/pages/CookiePage.tsx | 116 +++++++++++++++++++++++++- 4 files changed, 211 insertions(+), 4 deletions(-) diff --git a/web/backend/routers/cookies.py b/web/backend/routers/cookies.py index 7b1ebd5..547b990 100644 --- a/web/backend/routers/cookies.py +++ b/web/backend/routers/cookies.py @@ -245,6 +245,78 @@ def cookies_summary( } +@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", diff --git a/web/frontend/src/api/cookies.ts b/web/frontend/src/api/cookies.ts index 4758c95..c5249f6 100644 --- a/web/frontend/src/api/cookies.ts +++ b/web/frontend/src/api/cookies.ts @@ -1,11 +1,12 @@ import api from './client'; -import type { BasicSummary, CookieCheckResult, CookieItem, LoginTaskItem, PageParams, PaginatedResponse, MessageDeletedResponse, MessageResponse } from './types'; +import type { BasicSummary, CookieCheckResult, CookieDuplicateResponse, CookieItem, LoginTaskItem, PageParams, PaginatedResponse, MessageDeletedResponse, MessageResponse } from './types'; export const cookieApi = { list: () => api.get('/cookies'), listPaged: (params: PageParams & { include_cookie?: boolean; account_names?: string }) => api.get, PaginatedResponse>('/cookies', { params }), summary: () => api.get('/cookies/summary'), + duplicates: () => api.get('/cookies/duplicates'), get: (id: number) => api.get(`/cookies/${id}`), exportCsv: (format?: string, accountNames?: string) => api.get('/cookies/export', { responseType: 'blob', diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 67bb2a2..22ce79f 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -225,6 +225,30 @@ export interface CookieCheckResult { checked_at: string; } +export interface CookieDuplicateRecord { + id: number; + account_id: number; + batch_id: string; + finished_at: string | null; +} + +export interface CookieDuplicateGroup { + account_key: string; + account_names: string[]; + cookie_count: number; + account_count: number; + cookie_ids: number[]; + account_ids: number[]; + records: CookieDuplicateRecord[]; +} + +export interface CookieDuplicateResponse { + success: boolean; + duplicate_groups: number; + duplicate_rows: number; + groups: CookieDuplicateGroup[]; +} + // ==================== Douyu Activity ==================== export interface DouyuTaskAccountItem { diff --git a/web/frontend/src/pages/CookiePage.tsx b/web/frontend/src/pages/CookiePage.tsx index e4ced1b..86eb12f 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, Tooltip } from 'antd'; +import { Table, Button, Card, Row, Col, Statistic, Tag, Popconfirm, Space, Typography, Input, theme, Dropdown, Tooltip, Modal, Alert, Empty } from 'antd'; import { message } from '../utils/antdMessage'; -import { DownloadOutlined, DeleteOutlined, CopyOutlined, SearchOutlined, LoadingOutlined, ReloadOutlined, FilterOutlined } from '@ant-design/icons'; -import { cookieApi, type BasicSummary, type CookieCheckResult, type CookieItem } from '../api/modules'; +import { DownloadOutlined, DeleteOutlined, CopyOutlined, SearchOutlined, LoadingOutlined, ReloadOutlined, FilterOutlined, ScanOutlined } from '@ant-design/icons'; +import { cookieApi, type BasicSummary, type CookieCheckResult, type CookieDuplicateGroup, type CookieDuplicateResponse, type CookieItem } from '../api/modules'; import { usePermissions } from '../hooks/usePermissions'; import { formatTime } from '../utils/time'; import { getErrorMessage } from '../utils/error'; @@ -65,6 +65,9 @@ export default function CookiePage() { const [checkingIds, setCheckingIds] = useState>(new Set()); const [checkResults, setCheckResults] = useState>(new Map()); const [reloginIds, setReloginIds] = useState>(new Set()); + const [duplicateOpen, setDuplicateOpen] = useState(false); + const [duplicateLoading, setDuplicateLoading] = useState(false); + const [duplicateResult, setDuplicateResult] = useState(null); const { can } = usePermissions(); const canView = can('cookie:view'); @@ -153,6 +156,24 @@ export default function CookiePage() { void handleCheck(ids); }; + const handleDuplicateCheck = async () => { + setDuplicateOpen(true); + setDuplicateLoading(true); + try { + const result = await cookieApi.duplicates(); + setDuplicateResult(result); + if (result.duplicate_groups > 0) { + message.warning(`发现 ${result.duplicate_groups} 个重复账号,共 ${result.duplicate_rows} 条 CK 记录`); + } else { + message.success('未发现重复账号'); + } + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } finally { + setDuplicateLoading(false); + } + }; + const loadCookies = useCallback(async () => { setLoading(true); try { @@ -440,11 +461,63 @@ export default function CookiePage() { }, ]; + const duplicateColumns = [ + { + title: '账号', + dataIndex: 'account_names', + width: 180, + ellipsis: true, + render: (names: string[]) => names.join(' / '), + }, + { + title: 'CK 条数', + dataIndex: 'cookie_count', + width: 90, + align: 'center' as const, + render: (count: number) => {count}, + }, + { + title: '账号记录数', + dataIndex: 'account_count', + width: 110, + align: 'center' as const, + }, + { + title: 'CK 记录 ID', + dataIndex: 'cookie_ids', + width: 220, + ellipsis: true, + render: (ids: number[]) => ids.join(', '), + }, + { + title: '账号 ID', + dataIndex: 'account_ids', + width: 130, + ellipsis: true, + render: (ids: number[]) => ids.join(', '), + }, + { + title: '最近记录', + width: 170, + align: 'center' as const, + render: (_: unknown, group: CookieDuplicateGroup) => formatTime(group.records[0]?.finished_at), + }, + ]; + return (

Cookie 管理

+ {canView && ( + + )}
); }