From 39ea0d2184b7a17579137e792e55f34ec76d2f3a Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 8 Aug 2026 01:39:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(cookies):=20Cookie=20=E5=A4=B1=E6=95=88?= =?UTF-8?q?=E5=90=8E=E5=8F=AF=E8=B4=A6=E5=8F=B7=E9=87=8D=E6=96=B0=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E6=9B=BF=E6=8D=A2,=20=E5=B9=B6=E4=BF=AE=E5=A4=8D=20Co?= =?UTF-8?q?okie=20=E5=88=97=E7=A9=BA=E7=99=BD=E8=BF=87=E5=A4=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/backend/routers/cookies.py | 71 +++++++++++- web/backend/schemas.py | 4 + web/backend/services/login_service.py | 160 +++++++++++++++++--------- web/frontend/src/api/cookies.ts | 6 +- web/frontend/src/pages/CookiePage.tsx | 88 +++++++++++++- 5 files changed, 269 insertions(+), 60 deletions(-) diff --git a/web/backend/routers/cookies.py b/web/backend/routers/cookies.py index 408bb50..5f27db5 100644 --- a/web/backend/routers/cookies.py +++ b/web/backend/routers/cookies.py @@ -1,5 +1,6 @@ """Cookie 管理路由""" +import threading from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timezone import requests @@ -10,10 +11,12 @@ from sqlalchemy.orm import Session, defer, joinedload import io import csv -from ..database import get_db -from ..models import User, LoginTask, Account +from ..database import get_db, SessionLocal +from ..models import User, LoginTask, Account, ProxyConfig as ProxyConfigModel from ..deps import get_current_user, require_permission -from ..permissions import user_has_permission +from ..permissions import user_has_permission, get_user_permissions +from ..schemas import CookieReloginRequest +from ..services.login_service import LoginBatchRunner def _fmt_dt(dt) -> str | None: @@ -327,6 +330,68 @@ def check_cookies( return {"results": results, "success": True} +@router.post("/relogin") +def relogin_cookies( + req: CookieReloginRequest, + db: Session = Depends(get_db), + current: User = Depends(require_permission("login:batch")), +): + """重新登录失效的 Cookie:用账号信息重新登录,成功后替换旧 Cookie。 + + 复用原 LoginTask 记录,行不消失;重新登录失败保留旧 Cookie 并记录原因。 + """ + if not req.ids: + raise HTTPException(status_code=400, detail="请选择要重新登录的 Cookie") + + tasks = _visible_cookie_tasks_query(db, current).filter(LoginTask.id.in_(req.ids)).all() + if not tasks: + raise HTTPException(status_code=404, detail="记录不存在") + + # 过滤缺少登录凭据的账号(密码/邮箱),避免启动后全部失败 + task_ids: list[int] = [] + skipped: list[str] = [] + accounts_map = {t.account_id: t for t in tasks} + for acc_id, task in accounts_map.items(): + acc = db.query(Account).filter(Account.id == acc_id).first() + if not acc: + continue + if not acc.password or not acc.email: + skipped.append(task.id) + continue + task_ids.append(task.id) + + if not task_ids: + raise HTTPException( + status_code=400, + detail="所选 Cookie 对应账号缺少密码或邮箱信息,无法重新登录,请先在账号管理中补充", + ) + + proxy = db.query(ProxyConfigModel).first() + thread_db = SessionLocal() + runner = LoginBatchRunner( + db=thread_db, + account_ids=[], + created_by=current.id, + creator_permissions=get_user_permissions(current), + proxy_config=proxy, + log_queue=None, + loop=None, + concurrency=3, + mode="relogin", + relogin_task_ids=task_ids, + ) + batch_id = runner.batch_id + thread = threading.Thread(target=runner.run, daemon=True) + thread.start() + + return { + "batch_id": batch_id, + "count": len(task_ids), + "skipped": len(skipped), + "success": True, + } + + @router.get("/{task_id}") def get_cookie( task_id: int, diff --git a/web/backend/schemas.py b/web/backend/schemas.py index ff479a3..1a1b861 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -158,6 +158,10 @@ class LoginBatchRequest(BaseModel): mode: str = Field("login", pattern="^(login|check)$") # login=登录取CK,check=账号状态检测 +class CookieReloginRequest(BaseModel): + ids: list[int] = [] # 需要重新登录的 Cookie 记录 ID + + class LoginTaskOut(BaseModel): id: int batch_id: str diff --git a/web/backend/services/login_service.py b/web/backend/services/login_service.py index 16ee011..6d9d8c5 100644 --- a/web/backend/services/login_service.py +++ b/web/backend/services/login_service.py @@ -57,6 +57,7 @@ class LoginBatchRunner: concurrency: int = 3, api_strategy: str = "wgapi", mode: str = "login", + relogin_task_ids: Optional[list[int]] = None, ): self.db = db self.account_ids = account_ids @@ -72,7 +73,8 @@ class LoginBatchRunner: self.batch_id = uuid.uuid4().hex[:12] self.concurrency = max(1, min(concurrency, 10)) # 限制 1-10 self.api_strategy = _create_api_strategy(api_strategy) - self.mode = "check" if mode == "check" else "login" + self.mode = mode if mode in ("login", "check", "relogin") else "login" + self.relogin_task_ids = list(relogin_task_ids) if relogin_task_ids else [] self._stop = threading.Event() self._counter_lock = threading.Lock() self._completed = 0 @@ -133,6 +135,17 @@ class LoginBatchRunner: """在独立线程中执行单个账号登录,使用独立的 DB 会话。""" if self._stop.is_set(): self._push_log("warning", f"任务已停止,跳过: {acc_info['username']}") + if self.mode == "relogin": + # 重新登录被停止时恢复原 Cookie 记录,避免列表行消失 + worker_db = SessionLocal() + try: + task = worker_db.query(LoginTask).filter(LoginTask.id == task_id).first() + if task and task.status in ("pending", "running"): + task.status = "success" + task.finished_at = datetime.now(timezone.utc) + worker_db.commit() + finally: + worker_db.close() return worker_db = SessionLocal() @@ -148,7 +161,7 @@ class LoginBatchRunner: self._completed += 1 current = self._completed - action_name = "检测" if self.mode == "check" else "登录" + action_name = "检测" if self.mode == "check" else "重新登录" if self.mode == "relogin" else "登录" self._push_log("info", f"[{current}/{total}] 开始{action_name}: {acc_info['username']}") # 解析代理配置 @@ -198,16 +211,34 @@ class LoginBatchRunner: task.status = "success" task.cookie = result.cookie task.message = result.message or "登录成功" - self._push_log("success", f"[{current}] {acc_info['username']} {task.message}") + if self.mode == "relogin": + task.ck_check_status = "" + task.ck_check_result = None + task.ck_checked_at = None + task.message = "重新登录成功" + self._push_log("success", f"[{current}] {acc_info['username']} 重新登录成功,Cookie 已替换") + else: + self._push_log("success", f"[{current}] {acc_info['username']} {task.message}") else: - task.status = "failed" - task.message = result.message - self._push_log("error", f"[{current}] {acc_info['username']} {action_name}失败: {result.message}") + if self.mode == "relogin": + # 重新登录失败时保留旧 Cookie 与成功状态,仅记录失败原因,行不消失 + task.status = "success" + task.message = f"重新登录失败: {result.message}" + self._push_log("error", f"[{current}] {acc_info['username']} 重新登录失败: {result.message}") + else: + task.status = "failed" + task.message = result.message + self._push_log("error", f"[{current}] {acc_info['username']} {action_name}失败: {result.message}") except Exception as e: - task.status = "error" - task.message = str(e) - self._push_log("error", f"[{current}] {acc_info['username']} {action_name}异常: {e}") + if self.mode == "relogin": + task.status = "success" + task.message = f"重新登录异常: {e}" + self._push_log("error", f"[{current}] {acc_info['username']} 重新登录异常: {e}") + else: + task.status = "error" + task.message = str(e) + self._push_log("error", f"[{current}] {acc_info['username']} {action_name}异常: {e}") task.finished_at = datetime.now(timezone.utc) worker_db.commit() @@ -219,8 +250,8 @@ class LoginBatchRunner: """在线程中执行批量登录。""" batch_id = self.batch_id concurrency = self.concurrency - action_name = "账号检测" if self.mode == "check" else "登录" - self._push_log("info", f"批量{action_name}任务 {batch_id} 开始,共 {len(self.account_ids)} 个账号,并发数: {concurrency}") + action_name = "账号检测" if self.mode == "check" else "重新登录" if self.mode == "relogin" else "登录" + self._push_log("info", f"批量{action_name}任务 {batch_id} 开始,共 {len(self.account_ids or self.relogin_task_ids)} 个账号,并发数: {concurrency}") # 批次开始前同步一次出口 IP 到白名单,后续 fetch_new_proxy 不再主动同步 if self._shared_proxy_fetcher: @@ -228,56 +259,77 @@ class LoginBatchRunner: if msg != "无白名单凭据,跳过": self._push_log("info" if ok else "warning", f"白名单预热: {msg}") + def _append_task_info(task: LoginTask, acc: AccountModel): + task.batch_id = batch_id + task.status = "pending" + task.message = "" + task.finished_at = None + self.db.flush() + task_infos.append({ + "task_id": task.id, + "acc_info": { + "username": acc.username, + "password": acc.password, + "email": acc.email, + "email_password": acc.email_password, + "email_imap_server": acc.email_imap_server or "", + "email_imap_port": acc.email_imap_port or 993, + "email_imap_ssl": acc.email_imap_ssl if acc.email_imap_ssl is not None else True, + }, + }) + try: # 创建或复用任务记录(顺序执行,线程安全) task_infos: list[dict] = [] # {task_id, acc_info} - for aid in self.account_ids: - acc = self.db.query(AccountModel).filter(AccountModel.id == aid).first() - if not acc: - continue - # 权限检查:客服只能跑分配给自己的 - if "login:view_all" not in self.creator_permissions: - if acc.assigned_to != self.created_by: - self._push_log("warning", f"跳过无权账号: {acc.username}") + if self.relogin_task_ids: + # 重新登录模式:复用指定 Cookie 记录,登录成功后原地替换 Cookie + for task_id in self.relogin_task_ids: + task = self.db.query(LoginTask).filter(LoginTask.id == task_id).first() + if not task: continue + acc = self.db.query(AccountModel).filter(AccountModel.id == task.account_id).first() + if not acc: + self._push_log("warning", f"跳过无账号的任务 #{task_id}") + continue + if "login:view_all" not in self.creator_permissions: + if acc.assigned_to != self.created_by: + self._push_log("warning", f"跳过无权账号: {acc.username}") + continue + task.ck_check_status = "" + task.ck_check_result = None + task.ck_checked_at = None + _append_task_info(task, acc) + else: + for aid in self.account_ids: + acc = self.db.query(AccountModel).filter(AccountModel.id == aid).first() + if not acc: + continue + # 权限检查:客服只能跑分配给自己的 + if "login:view_all" not in self.creator_permissions: + if acc.assigned_to != self.created_by: + self._push_log("warning", f"跳过无权账号: {acc.username}") + continue - # 复用该账号最近一条失败任务记录,避免重复产生多条 - existing_task = ( - self.db.query(LoginTask) - .filter(LoginTask.account_id == aid, LoginTask.status.in_(["failed", "error"])) - .order_by(LoginTask.id.desc()) - .first() - ) - if existing_task: - existing_task.batch_id = batch_id - existing_task.status = "pending" - existing_task.cookie = "" - existing_task.message = "" - existing_task.finished_at = None - task = existing_task - else: - task = LoginTask( - batch_id=batch_id, - account_id=aid, - status="pending", - created_by=self.created_by, + # 复用该账号最近一条失败任务记录,避免重复产生多条 + existing_task = ( + self.db.query(LoginTask) + .filter(LoginTask.account_id == aid, LoginTask.status.in_(["failed", "error"])) + .order_by(LoginTask.id.desc()) + .first() ) - self.db.add(task) + if existing_task: + existing_task.cookie = "" + task = existing_task + else: + task = LoginTask( + batch_id=batch_id, + account_id=aid, + status="pending", + created_by=self.created_by, + ) + self.db.add(task) - self.db.flush() # 获取 task.id - - task_infos.append({ - "task_id": task.id, - "acc_info": { - "username": acc.username, - "password": acc.password, - "email": acc.email, - "email_password": acc.email_password, - "email_imap_server": acc.email_imap_server or "", - "email_imap_port": acc.email_imap_port or 993, - "email_imap_ssl": acc.email_imap_ssl if acc.email_imap_ssl is not None else True, - }, - }) + _append_task_info(task, acc) self.db.commit() total = len(task_infos) diff --git a/web/frontend/src/api/cookies.ts b/web/frontend/src/api/cookies.ts index 732542a..f08cc87 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, CookieCheckResult, CookieItem, PageParams, PaginatedResponse, MessageDeletedResponse, MessageResponse } from './types'; +import type { BasicSummary, CookieCheckResult, CookieItem, LoginTaskItem, PageParams, PaginatedResponse, MessageDeletedResponse, MessageResponse } from './types'; export const cookieApi = { list: () => api.get('/cookies'), @@ -10,6 +10,10 @@ export const cookieApi = { 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(',') } }), + relogin: (ids: number[]) => + api.post<{ batch_id: string; count: number; skipped: number; success: boolean }, { batch_id: string; count: number; skipped: number; success: boolean }>('/cookies/relogin', { ids }), + loginTasks: (batchId: string) => + api.get('/login/tasks', { params: { batch_id: batchId } }), 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/pages/CookiePage.tsx b/web/frontend/src/pages/CookiePage.tsx index 716781d..1c0e231 100644 --- a/web/frontend/src/pages/CookiePage.tsx +++ b/web/frontend/src/pages/CookiePage.tsx @@ -1,7 +1,7 @@ import { useEffect, useState, useCallback } from 'react'; 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, LoadingOutlined } from '@ant-design/icons'; +import { DownloadOutlined, DeleteOutlined, CopyOutlined, SearchOutlined, LoadingOutlined, ReloadOutlined } 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'; @@ -52,10 +52,64 @@ export default function CookiePage() { const [currentPage, setCurrentPage] = useState(1); const [checkingIds, setCheckingIds] = useState>(new Set()); const [checkResults, setCheckResults] = useState>(new Map()); + const [reloginIds, setReloginIds] = useState>(new Set()); const { can } = usePermissions(); const canView = can('cookie:view'); const canExport = can('cookie:export'); + const canRelogin = can('login:batch'); + + const pollRelogin = (batchId: string, count: number) => { + let attempts = 0; + const timer = window.setInterval(async () => { + attempts += 1; + try { + const tasks = await cookieApi.loginTasks(batchId); + const done = tasks.every((t) => !['pending', 'running'].includes(t.status)); + if (done) { + window.clearInterval(timer); + message.success(`重新登录完成: ${tasks.filter((t) => t.status === 'success').length}/${tasks.length} 个成功`); + loadCookies(); + loadSummary(); + } else if (attempts > 120) { + window.clearInterval(timer); + message.warning('重新登录超时,请到登录任务页查看进度'); + } + } catch { + window.clearInterval(timer); + message.warning('查询重登进度失败,请稍后手动刷新'); + } + }, 3000); + void count; + }; + + const handleRelogin = async (ids: number[]) => { + if (ids.length === 0) return; + setReloginIds((prev) => new Set([...prev, ...ids])); + try { + const res = await cookieApi.relogin(ids); + const tip = res.skipped > 0 ? `,${res.skipped} 条因账号缺少登录凭据被跳过` : ''; + message.success(`已开始重新登录 ${res.count} 个账号${tip}`); + pollRelogin(res.batch_id, res.count); + } catch (e: unknown) { + message.error(getErrorMessage(e)); + } finally { + setReloginIds((prev) => { + const next = new Set(prev); + for (const id of ids) next.delete(id); + return next; + }); + } + }; + + const handleReloginSelected = () => { + const ids = selectedRowKeys.map((k) => Number(k)); + if (ids.length === 0) { + message.warning('请先选择要重新登录的 Cookie'); + return; + } + void handleRelogin(ids); + }; const handleCheck = async (ids: number[]) => { if (ids.length === 0) return; @@ -234,6 +288,7 @@ export default function CookiePage() { { title: 'Cookie', dataIndex: 'cookie_preview', + width: 300, ellipsis: true, render: (val: string) => { if (!canView) return ***; @@ -306,6 +361,21 @@ export default function CookiePage() { 检测 )} + {canRelogin && ( + handleRelogin([record.id])} + > + + + )} {canExport && ( handleDelete(record.id)}> )} + {canRelogin && ( + + + + )} {canExport && ( );