feat(cookies): Cookie 失效后可账号重新登录替换, 并修复 Cookie 列空白过多
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<CookieItem[], CookieItem[]>('/cookies'),
|
||||
@@ -10,6 +10,10 @@ export const cookieApi = {
|
||||
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(',') } }),
|
||||
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<LoginTaskItem[], LoginTaskItem[]>('/login/tasks', { params: { batch_id: batchId } }),
|
||||
delete: (id: number) => api.delete<MessageResponse, MessageResponse>(`/cookies/${id}`),
|
||||
deleteBatch: (ids: number[]) => api.delete<MessageDeletedResponse, MessageDeletedResponse>('/cookies/batch', { params: { task_ids: ids.join(',') } }),
|
||||
};
|
||||
|
||||
@@ -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<Set<number>>(new Set());
|
||||
const [checkResults, setCheckResults] = useState<Map<number, CookieCheckResult>>(new Map());
|
||||
const [reloginIds, setReloginIds] = useState<Set<number>>(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 <Tag>***</Tag>;
|
||||
@@ -306,6 +361,21 @@ export default function CookiePage() {
|
||||
检测
|
||||
</Button>
|
||||
)}
|
||||
{canRelogin && (
|
||||
<Popconfirm
|
||||
title="用账号信息重新登录并替换失效 Cookie?"
|
||||
onConfirm={() => handleRelogin([record.id])}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={reloginIds.has(record.id)}
|
||||
disabled={reloginIds.has(record.id)}
|
||||
>
|
||||
重登
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
{canExport && (
|
||||
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button danger size="small" icon={<DeleteOutlined />} />
|
||||
@@ -337,6 +407,20 @@ export default function CookiePage() {
|
||||
检测选中 {selectedRowKeys.length > 0 && `(${selectedRowKeys.length})`}
|
||||
</Button>
|
||||
)}
|
||||
{canRelogin && (
|
||||
<Popconfirm
|
||||
title={`确认重新登录选中的 ${selectedRowKeys.length} 条 Cookie?将用账号信息重新登录并替换`}
|
||||
onConfirm={handleReloginSelected}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
>
|
||||
重登选中 {selectedRowKeys.length > 0 && `(${selectedRowKeys.length})`}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
{canExport && (
|
||||
<Popconfirm
|
||||
title={`确认删除选中的 ${selectedRowKeys.length} 条 Cookie?`}
|
||||
@@ -431,7 +515,7 @@ export default function CookiePage() {
|
||||
}
|
||||
},
|
||||
}}
|
||||
scroll={{ x: 900 }}
|
||||
scroll={{ x: 1210 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user