feat(cookies): Cookie 失效后可账号重新登录替换, 并修复 Cookie 列空白过多

This commit is contained in:
yml2213
2026-08-08 01:39:02 +08:00
parent 64b017fd6a
commit 39ea0d2184
5 changed files with 269 additions and 60 deletions
+68 -3
View File
@@ -1,5 +1,6 @@
"""Cookie 管理路由""" """Cookie 管理路由"""
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone from datetime import datetime, timezone
import requests import requests
@@ -10,10 +11,12 @@ from sqlalchemy.orm import Session, defer, joinedload
import io import io
import csv import csv
from ..database import get_db from ..database import get_db, SessionLocal
from ..models import User, LoginTask, Account from ..models import User, LoginTask, Account, ProxyConfig as ProxyConfigModel
from ..deps import get_current_user, require_permission 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: def _fmt_dt(dt) -> str | None:
@@ -327,6 +330,68 @@ def check_cookies(
return {"results": results, "success": True} 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}") @router.get("/{task_id}")
def get_cookie( def get_cookie(
task_id: int, task_id: int,
+4
View File
@@ -158,6 +158,10 @@ class LoginBatchRequest(BaseModel):
mode: str = Field("login", pattern="^(login|check)$") # login=登录取CKcheck=账号状态检测 mode: str = Field("login", pattern="^(login|check)$") # login=登录取CKcheck=账号状态检测
class CookieReloginRequest(BaseModel):
ids: list[int] = [] # 需要重新登录的 Cookie 记录 ID
class LoginTaskOut(BaseModel): class LoginTaskOut(BaseModel):
id: int id: int
batch_id: str batch_id: str
+106 -54
View File
@@ -57,6 +57,7 @@ class LoginBatchRunner:
concurrency: int = 3, concurrency: int = 3,
api_strategy: str = "wgapi", api_strategy: str = "wgapi",
mode: str = "login", mode: str = "login",
relogin_task_ids: Optional[list[int]] = None,
): ):
self.db = db self.db = db
self.account_ids = account_ids self.account_ids = account_ids
@@ -72,7 +73,8 @@ class LoginBatchRunner:
self.batch_id = uuid.uuid4().hex[:12] self.batch_id = uuid.uuid4().hex[:12]
self.concurrency = max(1, min(concurrency, 10)) # 限制 1-10 self.concurrency = max(1, min(concurrency, 10)) # 限制 1-10
self.api_strategy = _create_api_strategy(api_strategy) 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._stop = threading.Event()
self._counter_lock = threading.Lock() self._counter_lock = threading.Lock()
self._completed = 0 self._completed = 0
@@ -133,6 +135,17 @@ class LoginBatchRunner:
"""在独立线程中执行单个账号登录,使用独立的 DB 会话。""" """在独立线程中执行单个账号登录,使用独立的 DB 会话。"""
if self._stop.is_set(): if self._stop.is_set():
self._push_log("warning", f"任务已停止,跳过: {acc_info['username']}") 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 return
worker_db = SessionLocal() worker_db = SessionLocal()
@@ -148,7 +161,7 @@ class LoginBatchRunner:
self._completed += 1 self._completed += 1
current = self._completed 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']}") self._push_log("info", f"[{current}/{total}] 开始{action_name}: {acc_info['username']}")
# 解析代理配置 # 解析代理配置
@@ -198,16 +211,34 @@ class LoginBatchRunner:
task.status = "success" task.status = "success"
task.cookie = result.cookie task.cookie = result.cookie
task.message = result.message or "登录成功" 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: else:
task.status = "failed" if self.mode == "relogin":
task.message = result.message # 重新登录失败时保留旧 Cookie 与成功状态,仅记录失败原因,行不消失
self._push_log("error", f"[{current}] {acc_info['username']} {action_name}失败: {result.message}") 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: except Exception as e:
task.status = "error" if self.mode == "relogin":
task.message = str(e) task.status = "success"
self._push_log("error", f"[{current}] {acc_info['username']} {action_name}异常: {e}") 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) task.finished_at = datetime.now(timezone.utc)
worker_db.commit() worker_db.commit()
@@ -219,8 +250,8 @@ class LoginBatchRunner:
"""在线程中执行批量登录。""" """在线程中执行批量登录。"""
batch_id = self.batch_id batch_id = self.batch_id
concurrency = self.concurrency concurrency = self.concurrency
action_name = "账号检测" if self.mode == "check" else "登录" 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)} 个账号,并发数: {concurrency}") self._push_log("info", f"批量{action_name}任务 {batch_id} 开始,共 {len(self.account_ids or self.relogin_task_ids)} 个账号,并发数: {concurrency}")
# 批次开始前同步一次出口 IP 到白名单,后续 fetch_new_proxy 不再主动同步 # 批次开始前同步一次出口 IP 到白名单,后续 fetch_new_proxy 不再主动同步
if self._shared_proxy_fetcher: if self._shared_proxy_fetcher:
@@ -228,56 +259,77 @@ class LoginBatchRunner:
if msg != "无白名单凭据,跳过": if msg != "无白名单凭据,跳过":
self._push_log("info" if ok else "warning", f"白名单预热: {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: try:
# 创建或复用任务记录(顺序执行,线程安全) # 创建或复用任务记录(顺序执行,线程安全)
task_infos: list[dict] = [] # {task_id, acc_info} task_infos: list[dict] = [] # {task_id, acc_info}
for aid in self.account_ids: if self.relogin_task_ids:
acc = self.db.query(AccountModel).filter(AccountModel.id == aid).first() # 重新登录模式:复用指定 Cookie 记录,登录成功后原地替换 Cookie
if not acc: for task_id in self.relogin_task_ids:
continue task = self.db.query(LoginTask).filter(LoginTask.id == task_id).first()
# 权限检查:客服只能跑分配给自己的 if not task:
if "login:view_all" not in self.creator_permissions:
if acc.assigned_to != self.created_by:
self._push_log("warning", f"跳过无权账号: {acc.username}")
continue 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 = ( existing_task = (
self.db.query(LoginTask) self.db.query(LoginTask)
.filter(LoginTask.account_id == aid, LoginTask.status.in_(["failed", "error"])) .filter(LoginTask.account_id == aid, LoginTask.status.in_(["failed", "error"]))
.order_by(LoginTask.id.desc()) .order_by(LoginTask.id.desc())
.first() .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,
) )
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 _append_task_info(task, acc)
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,
},
})
self.db.commit() self.db.commit()
total = len(task_infos) total = len(task_infos)
+5 -1
View File
@@ -1,5 +1,5 @@
import api from './client'; 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 = { export const cookieApi = {
list: () => api.get<CookieItem[], CookieItem[]>('/cookies'), 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 } : {} }), exportCsv: (format?: string) => api.get<Blob, Blob>('/cookies/export', { responseType: 'blob', params: format ? { format } : {} }),
check: (ids: number[]) => check: (ids: number[]) =>
api.post<{ results: CookieCheckResult[] }, { results: CookieCheckResult[] }>('/cookies/check', null, { params: { ids: ids.join(',') } }), 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}`), delete: (id: number) => api.delete<MessageResponse, MessageResponse>(`/cookies/${id}`),
deleteBatch: (ids: number[]) => api.delete<MessageDeletedResponse, MessageDeletedResponse>('/cookies/batch', { params: { task_ids: ids.join(',') } }), deleteBatch: (ids: number[]) => api.delete<MessageDeletedResponse, MessageDeletedResponse>('/cookies/batch', { params: { task_ids: ids.join(',') } }),
}; };
+86 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useState, useCallback } from 'react'; 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 } from 'antd';
import { message } from '../utils/antdMessage'; 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 { cookieApi, type BasicSummary, type CookieCheckResult, type CookieItem } from '../api/modules';
import { usePermissions } from '../hooks/usePermissions'; import { usePermissions } from '../hooks/usePermissions';
import { formatTime } from '../utils/time'; import { formatTime } from '../utils/time';
@@ -52,10 +52,64 @@ export default function CookiePage() {
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [checkingIds, setCheckingIds] = useState<Set<number>>(new Set()); const [checkingIds, setCheckingIds] = useState<Set<number>>(new Set());
const [checkResults, setCheckResults] = useState<Map<number, CookieCheckResult>>(new Map()); const [checkResults, setCheckResults] = useState<Map<number, CookieCheckResult>>(new Map());
const [reloginIds, setReloginIds] = useState<Set<number>>(new Set());
const { can } = usePermissions(); const { can } = usePermissions();
const canView = can('cookie:view'); const canView = can('cookie:view');
const canExport = can('cookie:export'); 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[]) => { const handleCheck = async (ids: number[]) => {
if (ids.length === 0) return; if (ids.length === 0) return;
@@ -234,6 +288,7 @@ export default function CookiePage() {
{ {
title: 'Cookie', title: 'Cookie',
dataIndex: 'cookie_preview', dataIndex: 'cookie_preview',
width: 300,
ellipsis: true, ellipsis: true,
render: (val: string) => { render: (val: string) => {
if (!canView) return <Tag>***</Tag>; if (!canView) return <Tag>***</Tag>;
@@ -306,6 +361,21 @@ export default function CookiePage() {
</Button> </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 && ( {canExport && (
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}> <Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
<Button danger size="small" icon={<DeleteOutlined />} /> <Button danger size="small" icon={<DeleteOutlined />} />
@@ -337,6 +407,20 @@ export default function CookiePage() {
{selectedRowKeys.length > 0 && `(${selectedRowKeys.length})`} {selectedRowKeys.length > 0 && `(${selectedRowKeys.length})`}
</Button> </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 && ( {canExport && (
<Popconfirm <Popconfirm
title={`确认删除选中的 ${selectedRowKeys.length} 条 Cookie`} title={`确认删除选中的 ${selectedRowKeys.length} 条 Cookie`}
@@ -431,7 +515,7 @@ export default function CookiePage() {
} }
}, },
}} }}
scroll={{ x: 900 }} scroll={{ x: 1210 }}
/> />
</div> </div>
); );