优化客服CK重登流程

This commit is contained in:
yml2213
2026-08-14 14:43:21 +08:00
parent 1ed1eac205
commit bf2f78aa70
10 changed files with 550 additions and 162 deletions
+4
View File
@@ -21,6 +21,10 @@ APP_PORT=8000
# 应用日志等级:DEBUG、INFO、WARNING、ERROR、CRITICAL;生产环境建议保持 INFO。 # 应用日志等级:DEBUG、INFO、WARNING、ERROR、CRITICAL;生产环境建议保持 INFO。
LOG_LEVEL=INFO LOG_LEVEL=INFO
# CK 重登保护:单账号最多尝试次数与总耗时秒数,超时/失败均保留旧 Cookie。
COOKIE_RELOGIN_MAX_RETRIES=5
COOKIE_RELOGIN_MAX_TOTAL_TIME=600
# Docker 构建镜像源(网络可访问官方源时保持 false;国内服务器可改为 true) # Docker 构建镜像源(网络可访问官方源时保持 false;国内服务器可改为 true)
USE_CHINA_MIRRORS=false USE_CHINA_MIRRORS=false
+89
View File
@@ -11,12 +11,15 @@ from sqlalchemy.orm import sessionmaker
from web.backend.database import Base from web.backend.database import Base
from web.backend.models import Account, LoginTask, User from web.backend.models import Account, LoginTask, User
from web.backend.services.login_service import LoginBatchRunner, cleanup_orphan_relogin_tasks
from web.backend.services.cookie_check_service import check_douyu_cookie
from web.backend.routers.cookies import ( from web.backend.routers.cookies import (
check_cookie_operations, check_cookie_operations,
get_cookie, get_cookie,
list_cookie_operation_tags, list_cookie_operation_tags,
list_cookie_operations, list_cookie_operations,
list_cookies, list_cookies,
relogin_invalid_cookie_operations,
) )
@@ -78,6 +81,9 @@ class CookieOperationTests(unittest.TestCase):
"ck_check_status": "", "ck_check_status": "",
"ck_checked_at": None, "ck_checked_at": None,
"created_at": None, "created_at": None,
"relogin_status": "",
"relogin_message": "",
"relogin_batch_id": "",
}]) }])
self.assertNotIn("owned-secret", str(result)) self.assertNotIn("owned-secret", str(result))
self.assertNotIn("owned-password", str(result)) self.assertNotIn("owned-password", str(result))
@@ -168,6 +174,89 @@ class CookieOperationTests(unittest.TestCase):
current=no_permission_user, current=no_permission_user,
) )
def test_relogin_failed_record_stays_visible_and_can_be_checked(self):
self.owned_task.status = "relogin_failed"
self.owned_task.message = "重新登录失败: 密码错误(旧 Cookie 已保留)"
self.session.commit()
result = list_cookie_operations(
search="", tag="", page=1, page_size=20, db=self.session, current=self.support,
)
self.assertEqual(result["total"], 1)
self.assertEqual(result["items"][0]["relogin_status"], "relogin_failed")
self.assertIn("旧 Cookie 已保留", result["items"][0]["relogin_message"])
def test_service_restart_cleans_orphan_relogin_tasks(self):
self.owned_task.status = "relogin_running"
self.session.commit()
cleaned = cleanup_orphan_relogin_tasks(self.session)
self.assertEqual(cleaned, 1)
self.session.refresh(self.owned_task)
self.session.refresh(self.other_task)
self.assertEqual(self.owned_task.status, "relogin_failed")
self.assertIn("服务重启", self.owned_task.message)
self.assertEqual(self.other_task.status, "success")
def test_runner_copies_proxy_config_before_background_execution(self):
proxy = SimpleNamespace(
enabled=True,
http="http://127.0.0.1:8080",
https="",
api_url="",
whitelist_enabled=False,
whitelist_platform=None,
whitelist_credentials=None,
whitelist_uid="",
whitelist_ukey="",
)
runner = LoginBatchRunner(
db=self.session,
account_ids=[],
created_by=self.support.id,
creator_permissions=[],
proxy_config=proxy,
)
proxy.http = "http://changed.example:8080"
proxy_dict, _ = runner._resolve_static_proxy()
self.assertEqual(proxy_dict, {
"http": "http://127.0.0.1:8080",
"https": "http://127.0.0.1:8080",
})
@patch("web.backend.services.cookie_check_service.requests.get")
def test_cookie_check_uses_new_cookie_result(self, mock_get):
mock_get.side_effect = [
SimpleNamespace(json=lambda: {"error": 0, "data": {"count": 9}}),
SimpleNamespace(json=lambda: {"error": 0, "data": {"nn": "new-name", "lv": 12}}),
]
result = check_douyu_cookie("new-cookie")
self.assertTrue(result["valid"])
self.assertEqual(result["fish_ball"], 9)
self.assertEqual(result["nickname"], "new-name")
self.assertEqual(result["level"], 12)
@patch("web.backend.routers.cookies._start_relogin_tasks")
def test_relogin_invalid_only_targets_support_visible_accounts(self, start_relogin):
self.owned_task.ck_check_status = "invalid"
self.other_task.ck_check_status = "invalid"
self.session.commit()
start_relogin.return_value = {"batch_id": "batch", "count": 1, "skipped": 0, "success": True}
result = relogin_invalid_cookie_operations(
search="", tag="", db=self.session, current=self.support,
)
self.assertTrue(result["success"])
selected_tasks = start_relogin.call_args.args[0]
self.assertEqual([task.id for task in selected_tasks], [self.owned_task.id])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+4
View File
@@ -30,6 +30,7 @@ async def lifespan(app: FastAPI):
from loguru import logger from loguru import logger
from .database import SessionLocal from .database import SessionLocal
from .services.login_service import cleanup_orphan_relogin_tasks
from .services.huya_service import cleanup_orphan_huya_tasks from .services.huya_service import cleanup_orphan_huya_tasks
from .services.douyu_service import cleanup_orphan_douyu_tasks from .services.douyu_service import cleanup_orphan_douyu_tasks
@@ -41,6 +42,9 @@ async def lifespan(app: FastAPI):
cleaned_douyu = cleanup_orphan_douyu_tasks(db, message="任务已中断(服务重启)") cleaned_douyu = cleanup_orphan_douyu_tasks(db, message="任务已中断(服务重启)")
if cleaned_douyu: if cleaned_douyu:
logger.info(f"启动清理斗鱼残留任务: {cleaned_douyu}") logger.info(f"启动清理斗鱼残留任务: {cleaned_douyu}")
cleaned_relogin = cleanup_orphan_relogin_tasks(db)
if cleaned_relogin:
logger.info(f"启动清理 CK 重登残留任务: {cleaned_relogin}")
from .services.yyb_service import cleanup_orphan_yyb_tasks from .services.yyb_service import cleanup_orphan_yyb_tasks
cleaned_yyb = cleanup_orphan_yyb_tasks(db, message="任务已中断(服务重启)") cleaned_yyb = cleanup_orphan_yyb_tasks(db, message="任务已中断(服务重启)")
if cleaned_yyb: if cleaned_yyb:
+132 -116
View File
@@ -3,7 +3,6 @@
import threading 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
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlalchemy import case, or_ from sqlalchemy import case, or_
@@ -16,7 +15,8 @@ from ..models import User, LoginTask, Account, AuditLog, ProxyConfig as ProxyCon
from ..deps import get_current_user, require_permission from ..deps import get_current_user, require_permission
from ..permissions import user_has_permission, get_user_permissions from ..permissions import user_has_permission, get_user_permissions
from ..schemas import CookieReloginRequest from ..schemas import CookieReloginRequest
from ..services.login_service import LoginBatchRunner from ..services.login_service import BatchRegistry, LoginBatchRunner, get_relogin_limits
from ..services.cookie_check_service import check_douyu_cookie
def _fmt_dt(dt) -> str | None: def _fmt_dt(dt) -> str | None:
@@ -28,6 +28,7 @@ def _fmt_dt(dt) -> str | None:
return dt.isoformat() return dt.isoformat()
router = APIRouter(prefix="/api/cookies", tags=["Cookie管理"]) router = APIRouter(prefix="/api/cookies", tags=["Cookie管理"])
cookie_relogin_registry = BatchRegistry()
def _require_cookie_operation_perm(current: User) -> None: def _require_cookie_operation_perm(current: User) -> None:
@@ -63,89 +64,12 @@ def _order_cookie_tasks(query, selected_names: list[str]):
return query.order_by(LoginTask.finished_at.desc(), LoginTask.id.desc()) return query.order_by(LoginTask.finished_at.desc(), LoginTask.id.desc())
# 斗鱼 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: def _check_one_cookie(task: LoginTask) -> dict:
"""检测单条斗鱼 Cookie 有效性:鱼丸 + 用户等级双接口均通过才算有效。 """将共享检测结果补充为 Cookie 记录的接口返回格式。"""
result = check_douyu_cookie(task.cookie or "")
附带鱼丸数、昵称/等级与检测时间;任一接口失败会说明原因。 result["id"] = task.id
""" result["checked_at"] = result["checked_at"].isoformat()
cookie = task.cookie or "" return result
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): def _visible_cookie_tasks_query(db: Session, current: User):
@@ -160,6 +84,19 @@ def _visible_cookie_tasks_query(db: Session, current: User):
return query return query
def _visible_cookie_operation_tasks_query(db: Session, current: User):
"""返回可检测/重登的 Cookie 记录,重登中或失败时仍保留在操作列表。"""
query = db.query(LoginTask).filter(
LoginTask.cookie != "",
LoginTask.status.in_(("success", "relogin_pending", "relogin_running", "relogin_failed")),
)
if not user_has_permission(current, "login:view_all"):
query = query.join(Account, LoginTask.account_id == Account.id).filter(
Account.assigned_to == current.id
)
return query
@router.get("") @router.get("")
def list_cookies( def list_cookies(
search: str = Query(""), search: str = Query(""),
@@ -292,7 +229,7 @@ def list_cookie_operations(
): ):
"""脱敏的 CK 操作列表,只用于检测与重登。""" """脱敏的 CK 操作列表,只用于检测与重登。"""
_require_cookie_operation_perm(current) _require_cookie_operation_perm(current)
query = _visible_cookie_tasks_query(db, current) query = _visible_cookie_operation_tasks_query(db, current)
if user_has_permission(current, "login:view_all"): if user_has_permission(current, "login:view_all"):
query = query.join(Account, LoginTask.account_id == Account.id) query = query.join(Account, LoginTask.account_id == Account.id)
search_text = (search or "").strip() search_text = (search or "").strip()
@@ -321,6 +258,9 @@ def list_cookie_operations(
"ck_check_status": task.ck_check_status or "", "ck_check_status": task.ck_check_status or "",
"ck_checked_at": _fmt_dt(task.ck_checked_at), "ck_checked_at": _fmt_dt(task.ck_checked_at),
"created_at": _fmt_dt(task.finished_at), "created_at": _fmt_dt(task.finished_at),
"relogin_status": task.status if task.status != "success" else "",
"relogin_message": task.message or "",
"relogin_batch_id": task.batch_id if task.status in {"relogin_pending", "relogin_running"} else "",
} }
for task in tasks for task in tasks
], ],
@@ -337,7 +277,7 @@ def list_cookie_operation_tags(
): ):
"""返回当前用户可操作 CK 记录对应的标签,不跨越账号分配范围。""" """返回当前用户可操作 CK 记录对应的标签,不跨越账号分配范围。"""
_require_cookie_operation_perm(current) _require_cookie_operation_perm(current)
query = _visible_cookie_tasks_query(db, current) query = _visible_cookie_operation_tasks_query(db, current)
if user_has_permission(current, "login:view_all"): if user_has_permission(current, "login:view_all"):
query = query.join(Account, LoginTask.account_id == Account.id) query = query.join(Account, LoginTask.account_id == Account.id)
rows = ( rows = (
@@ -487,7 +427,10 @@ def _check_cookies(ids: str, db: Session, current: User, *, detailed: bool) -> d
id_list = [int(x) for x in ids.split(",") if x.strip().isdigit()] id_list = [int(x) for x in ids.split(",") if x.strip().isdigit()]
if not id_list: if not id_list:
raise HTTPException(status_code=400, detail="无效的ID") raise HTTPException(status_code=400, detail="无效的ID")
tasks = _visible_cookie_tasks_query(db, current).filter(LoginTask.id.in_(id_list)).all() base_query = _visible_cookie_tasks_query(db, current) if detailed else _visible_cookie_operation_tasks_query(db, current)
tasks = base_query.filter(LoginTask.id.in_(id_list)).filter(
LoginTask.status.in_(("success", "relogin_failed"))
).all()
if not tasks: if not tasks:
raise HTTPException(status_code=404, detail="记录不存在") raise HTTPException(status_code=404, detail="记录不存在")
@@ -562,48 +505,46 @@ def check_cookie_operations(
return _check_cookies(ids, db, current, detailed=False) return _check_cookies(ids, db, current, detailed=False)
def _start_relogin( def _start_relogin_tasks(tasks: list[LoginTask], db: Session, current: User, *, action: str = "cookie:relogin"):
req: CookieReloginRequest, """启动重登批次:旧 Cookie 保留到新登录成功后才替换。"""
db: Session,
current: User,
):
"""重新登录失效的 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: if not tasks:
raise HTTPException(status_code=404, detail="记录不存在") raise HTTPException(status_code=404, detail="记录不存在")
# 过滤缺少登录凭据的账号(密码/邮箱),避免启动后全部失败
task_ids: list[int] = [] task_ids: list[int] = []
skipped: list[str] = [] skipped: list[int] = []
accounts_map = {t.account_id: t for t in tasks} for task in tasks:
for acc_id, task in accounts_map.items(): acc = db.query(Account).filter(Account.id == task.account_id).first()
acc = db.query(Account).filter(Account.id == acc_id).first()
if not acc: if not acc:
continue
if not acc.password or not acc.email:
skipped.append(task.id) skipped.append(task.id)
continue continue
if not acc.password or not acc.email:
task.status = "relogin_failed"
task.message = "重新登录失败: 账号缺少密码或邮箱,旧 Cookie 已保留"
task.finished_at = datetime.now(timezone.utc)
skipped.append(task.id)
continue
if task.status in {"relogin_pending", "relogin_running"}:
skipped.append(task.id)
continue
task.status = "relogin_pending"
task.message = "等待重新登录,旧 Cookie 已保留"
task.finished_at = None
task_ids.append(task.id) task_ids.append(task.id)
if not task_ids: if not task_ids:
raise HTTPException( db.commit()
status_code=400, raise HTTPException(status_code=400, detail="没有可重登的账号,请检查账号凭据或当前重登状态")
detail="所选 Cookie 对应账号缺少密码或邮箱信息,无法重新登录,请先在账号管理中补充",
)
proxy = db.query(ProxyConfigModel).first() proxy = db.query(ProxyConfigModel).first()
thread_db = SessionLocal() thread_db = SessionLocal()
max_login_retries, max_total_time = get_relogin_limits()
runner = LoginBatchRunner( runner = LoginBatchRunner(
db=thread_db, db=thread_db,
account_ids=[], account_ids=[],
created_by=current.id, created_by=current.id,
creator_permissions=get_user_permissions(current), creator_permissions=get_user_permissions(current),
max_login_retries=max_login_retries,
max_total_time=max_total_time,
proxy_config=proxy, proxy_config=proxy,
log_queue=None, log_queue=None,
loop=None, loop=None,
@@ -612,16 +553,29 @@ def _start_relogin(
relogin_task_ids=task_ids, relogin_task_ids=task_ids,
) )
batch_id = runner.batch_id batch_id = runner.batch_id
thread = threading.Thread(target=runner.run, daemon=True)
thread.start()
db.add(AuditLog( db.add(AuditLog(
user_id=current.id, user_id=current.id,
username=current.username, username=current.username,
action="cookie:relogin", action=action,
target=f"重登 {len(task_ids)}已分配账号 CK", target=f"重登 {len(task_ids)} 条账号 CK",
detail="旧 Cookie 将在新登录成功后替换",
)) ))
db.commit() db.commit()
cookie_relogin_registry.register(batch_id, None, None, runner, owner_id=current.id)
def run_relogin_batch():
try:
runner.run()
except Exception as exc:
runner._push_log("error", f"批量重新登录异常: {exc}")
message = f"重新登录异常: {exc}(旧 Cookie 已保留)"
for task_id in task_ids:
runner._mark_relogin_stopped(task_id, message)
runner.db.close()
finally:
cookie_relogin_registry.pop(batch_id)
threading.Thread(target=run_relogin_batch, daemon=True).start()
return { return {
"batch_id": batch_id, "batch_id": batch_id,
"count": len(task_ids), "count": len(task_ids),
@@ -630,6 +584,44 @@ def _start_relogin(
} }
@router.post("/operations/relogin/{batch_id}/stop")
def stop_cookie_relogin(
batch_id: str,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""停止当前用户发起的 CK 重登批次。"""
_require_cookie_operation_perm(current)
batch = cookie_relogin_registry.get(batch_id)
if not batch:
raise HTTPException(status_code=404, detail="重登批次不存在、已结束或服务已重启")
if batch.get("owner_id") != current.id and not user_has_permission(current, "login:view_all"):
raise HTTPException(status_code=403, detail="无权限停止该重登批次")
batch["runner"].stop()
db.add(AuditLog(
user_id=current.id,
username=current.username,
action="cookie:relogin_stop",
target=f"停止 CK 重登批次 {batch_id}",
))
db.commit()
return {"message": "已发送停止信号,正在登录的账号会在当前请求结束后停止", "success": True}
def _start_relogin(req: CookieReloginRequest, db: Session, current: User):
"""按选中的 Cookie 记录启动重新登录。"""
if not req.ids:
raise HTTPException(status_code=400, detail="请选择要重新登录的 Cookie")
tasks = (
_visible_cookie_operation_tasks_query(db, current)
.filter(LoginTask.id.in_(req.ids))
.filter(LoginTask.status.in_(("success", "relogin_failed")))
.all()
)
return _start_relogin_tasks(tasks, db, current)
@router.post("/relogin") @router.post("/relogin")
def relogin_cookies( def relogin_cookies(
req: CookieReloginRequest, req: CookieReloginRequest,
@@ -651,6 +643,30 @@ def relogin_cookie_operations(
return _start_relogin(req, db, current) return _start_relogin(req, db, current)
@router.post("/operations/relogin-invalid")
def relogin_invalid_cookie_operations(
search: str = Query(""),
tag: str = Query(""),
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""批量重新登录当前用户可见且最近检测为无效的账号。"""
_require_cookie_operation_perm(current)
query = _visible_cookie_operation_tasks_query(db, current).filter(
LoginTask.ck_check_status == "invalid",
LoginTask.status.in_(("success", "relogin_failed")),
)
if user_has_permission(current, "login:view_all"):
query = query.join(Account, LoginTask.account_id == Account.id)
if tag.strip():
query = query.filter(Account.tag == tag.strip())
if search.strip():
pattern = f"%{search.strip()}%"
query = query.filter(or_(Account.username.ilike(pattern), Account.tag.ilike(pattern)))
tasks = query.order_by(LoginTask.id.asc()).all()
return _start_relogin_tasks(tasks, db, current, action="cookie:relogin_invalid")
@router.get("/{task_id}") @router.get("/{task_id}")
def get_cookie( def get_cookie(
task_id: int, task_id: int,
@@ -0,0 +1,92 @@
"""斗鱼 Cookie 有效性检测服务。"""
from datetime import datetime, timezone
import requests
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_douyu_cookie(cookie: str) -> dict:
"""检测斗鱼 Cookie 有效性,鱼丸与等级接口均通过才算有效。"""
checked_at = datetime.now(timezone.utc)
base = {
"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:
message = "".join([
f"鱼丸接口: {'ok' if fish_ok else (fish_msg or '失败')}",
f"等级接口: {'ok' if level_ok else (level_msg or '失败')}",
])
return {
**base,
"valid": valid,
"message": message[:200],
"fish_ball": fish_ball,
"nickname": nickname,
"level": level,
}
+141 -43
View File
@@ -1,6 +1,7 @@
"""登录服务:复用 core/ 核心模块,在线程池中并发执行登录并推送日志。""" """登录服务:复用 core/ 核心模块,在线程池中并发执行登录并推送日志。"""
import asyncio import asyncio
import os
import threading import threading
import time import time
import uuid import uuid
@@ -10,10 +11,12 @@ from datetime import datetime, timezone
from typing import Optional from typing import Optional
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from loguru import logger
from core.douyu import DouyuLogin, WgapiLoginAPI, IframeLoginAPI from core.douyu import DouyuLogin, WgapiLoginAPI, IframeLoginAPI
from core.douyu.proxy_fetcher import ProxyFetcher from core.douyu.proxy_fetcher import ProxyFetcher
from ..models import Account as AccountModel, LoginTask, ProxyConfig as ProxyConfigModel from ..models import Account as AccountModel, LoginTask, ProxyConfig as ProxyConfigModel
from .cookie_check_service import check_douyu_cookie
def _create_api_strategy(strategy_name: str): def _create_api_strategy(strategy_name: str):
@@ -40,6 +43,41 @@ CHECK_STATUS_LOG_LEVELS = {
} }
def _positive_env_int(name: str, default: int) -> int:
"""读取正整数环境变量,非法值回退到默认值。"""
try:
value = int(os.getenv(name, str(default)))
except ValueError:
return default
return value if value > 0 else default
def get_relogin_limits() -> tuple[int, int]:
"""读取客服 CK 重登的重试与总时长限制。"""
return (
_positive_env_int("COOKIE_RELOGIN_MAX_RETRIES", 5),
_positive_env_int("COOKIE_RELOGIN_MAX_TOTAL_TIME", 600),
)
def _snapshot_proxy_config(proxy_config: Optional[ProxyConfigModel]) -> Optional[SimpleNamespace]:
"""复制代理配置,避免后台线程访问已关闭会话中的 ORM 对象。"""
if proxy_config is None:
return None
credentials = getattr(proxy_config, "whitelist_credentials", None)
return SimpleNamespace(
enabled=bool(getattr(proxy_config, "enabled", False)),
http=getattr(proxy_config, "http", "") or "",
https=getattr(proxy_config, "https", "") or "",
api_url=getattr(proxy_config, "api_url", "") or "",
whitelist_enabled=bool(getattr(proxy_config, "whitelist_enabled", False)),
whitelist_platform=getattr(proxy_config, "whitelist_platform", None),
whitelist_credentials=dict(credentials) if isinstance(credentials, dict) else credentials,
whitelist_uid=getattr(proxy_config, "whitelist_uid", "") or "",
whitelist_ukey=getattr(proxy_config, "whitelist_ukey", "") or "",
)
class LoginBatchRunner: class LoginBatchRunner:
"""批量登录执行器,在线程中运行,通过 ThreadPoolExecutor 并发登录多个账号。""" """批量登录执行器,在线程中运行,通过 ThreadPoolExecutor 并发登录多个账号。"""
@@ -67,7 +105,7 @@ class LoginBatchRunner:
# 0 表示使用默认值 20,其他值保持原样 # 0 表示使用默认值 20,其他值保持原样
self.max_login_retries = max_login_retries if max_login_retries > 0 else 20 self.max_login_retries = max_login_retries if max_login_retries > 0 else 20
self.max_total_time = max_total_time self.max_total_time = max_total_time
self.proxy_config = proxy_config self.proxy_config = _snapshot_proxy_config(proxy_config)
self.log_queue = log_queue self.log_queue = log_queue
self.loop = loop self.loop = loop
self.batch_id = uuid.uuid4().hex[:12] self.batch_id = uuid.uuid4().hex[:12]
@@ -81,6 +119,7 @@ class LoginBatchRunner:
# 共享代理获取器(无池,每次取新代理) # 共享代理获取器(无池,每次取新代理)
self._shared_proxy_fetcher = None self._shared_proxy_fetcher = None
proxy_config = self.proxy_config
if proxy_config and proxy_config.enabled and proxy_config.api_url: if proxy_config and proxy_config.enabled and proxy_config.api_url:
wl_platform = "xiequ" wl_platform = "xiequ"
wl_credentials = None wl_credentials = None
@@ -111,7 +150,28 @@ class LoginBatchRunner:
time.sleep(min(0.2, deadline - time.monotonic())) time.sleep(min(0.2, deadline - time.monotonic()))
return self._stop.is_set() return self._stop.is_set()
def _mark_relogin_stopped(
self,
task_id: int,
message: str = "重新登录已停止,旧 Cookie 已保留",
) -> None:
"""将未开始或已中断的重登任务收敛为保留旧 CK 的终态。"""
worker_db = SessionLocal()
try:
task = worker_db.query(LoginTask).filter(LoginTask.id == task_id).first()
if task and task.status in ("relogin_pending", "relogin_running"):
task.status = "relogin_failed"
task.message = message
task.finished_at = datetime.now(timezone.utc)
worker_db.commit()
finally:
worker_db.close()
def _push_log(self, level: str, message: str): def _push_log(self, level: str, message: str):
# 即使没有页面实时日志,也要保留批次进度到 app.log,便于排查卡点。
if message:
log_level = level if level in {"debug", "info", "warning", "error", "success"} else "debug"
getattr(logger, log_level)(f"[登录批次 {self.batch_id}] {message}")
if self.log_queue and self.loop: if self.log_queue and self.loop:
asyncio.run_coroutine_threadsafe( asyncio.run_coroutine_threadsafe(
self.log_queue.put({"level": level, "message": message}), self.log_queue.put({"level": level, "message": message}),
@@ -136,16 +196,7 @@ class LoginBatchRunner:
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": if self.mode == "relogin":
# 重新登录被停止时恢复原 Cookie 记录,避免列表行消失 self._mark_relogin_stopped(task_id)
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()
@@ -154,7 +205,11 @@ class LoginBatchRunner:
if not task: if not task:
return return
task.status = "running" if self.mode == "relogin":
task.status = "relogin_running"
task.message = "正在重新登录,旧 Cookie 保留中"
else:
task.status = "running"
worker_db.commit() worker_db.commit()
with self._counter_lock: with self._counter_lock:
@@ -164,21 +219,25 @@ class LoginBatchRunner:
action_name = "检测" if self.mode == "check" else "重新登录" if self.mode == "relogin" 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']}")
# 解析代理配置
proxy_dict, proxy_msg = self._resolve_static_proxy()
if proxy_msg:
self._push_log("info", f"[{current}] {proxy_msg}")
# 静态代理启用但配置为空 → 不可用
if self.proxy_config and self.proxy_config.enabled and not (self.proxy_config.http or self.proxy_config.https) and not self._shared_proxy_fetcher and not proxy_dict:
task.status = "error"
task.message = "代理不可用: 未配置代理"
task.finished_at = datetime.now(timezone.utc)
worker_db.commit()
self._push_log("error", f"[{current}] {acc_info['username']} 代理不可用")
return
try: try:
# 代理配置也可能异常,必须由当前任务的失败处理收敛状态。
proxy_dict, proxy_msg = self._resolve_static_proxy()
if proxy_msg:
self._push_log("info", f"[{current}] {proxy_msg}")
# 静态代理启用但配置为空 → 不可用
if self.proxy_config and self.proxy_config.enabled and not (self.proxy_config.http or self.proxy_config.https) and not self._shared_proxy_fetcher and not proxy_dict:
if self.mode == "relogin":
task.status = "relogin_failed"
task.message = "重新登录失败: 代理不可用: 未配置代理(旧 Cookie 已保留)"
else:
task.status = "error"
task.message = "代理不可用: 未配置代理"
task.finished_at = datetime.now(timezone.utc)
worker_db.commit()
self._push_log("error", f"[{current}] {acc_info['username']} 代理不可用")
return
account = SimpleNamespace( account = SimpleNamespace(
username=acc_info["username"], username=acc_info["username"],
password=acc_info["password"], password=acc_info["password"],
@@ -212,18 +271,28 @@ class LoginBatchRunner:
task.cookie = result.cookie task.cookie = result.cookie
task.message = result.message or "登录成功" task.message = result.message or "登录成功"
if self.mode == "relogin": if self.mode == "relogin":
task.ck_check_status = "" check_result = check_douyu_cookie(result.cookie)
task.ck_check_result = None task.ck_check_status = "valid" if check_result["valid"] else "invalid"
task.ck_checked_at = None task.ck_check_result = {
task.message = "重新登录成功" "fish_ball": check_result["fish_ball"],
self._push_log("success", f"[{current}] {acc_info['username']} 重新登录成功,Cookie 已替换") "nickname": check_result["nickname"],
"level": check_result["level"],
"message": check_result["message"],
}
task.ck_checked_at = check_result["checked_at"]
if check_result["valid"]:
task.message = "重新登录成功,Cookie 有效"
self._push_log("success", f"[{current}] {acc_info['username']} 重新登录成功,Cookie 已替换并验证有效")
else:
task.message = f"重新登录成功,但 Cookie 有效性检测失败: {check_result['message']}"
self._push_log("warning", f"[{current}] {acc_info['username']} 重新登录成功,但 Cookie 有效性检测失败: {check_result['message']}")
else: else:
self._push_log("success", f"[{current}] {acc_info['username']} {task.message}") self._push_log("success", f"[{current}] {acc_info['username']} {task.message}")
else: else:
if self.mode == "relogin": if self.mode == "relogin":
# 重新登录失败时保留旧 Cookie 与成功状态,仅记录失败原因,行不消失 # 重新登录失败时保留旧 Cookie 与成功状态,仅记录失败原因,行不消失
task.status = "success" task.status = "relogin_failed"
task.message = f"重新登录失败: {result.message}" task.message = f"重新登录失败: {result.message}(旧 Cookie 已保留)"
self._push_log("error", f"[{current}] {acc_info['username']} 重新登录失败: {result.message}") self._push_log("error", f"[{current}] {acc_info['username']} 重新登录失败: {result.message}")
else: else:
task.status = "failed" task.status = "failed"
@@ -232,8 +301,8 @@ class LoginBatchRunner:
except Exception as e: except Exception as e:
if self.mode == "relogin": if self.mode == "relogin":
task.status = "success" task.status = "relogin_failed"
task.message = f"重新登录异常: {e}" task.message = f"重新登录异常: {e}(旧 Cookie 已保留)"
self._push_log("error", f"[{current}] {acc_info['username']} 重新登录异常: {e}") self._push_log("error", f"[{current}] {acc_info['username']} 重新登录异常: {e}")
else: else:
task.status = "error" task.status = "error"
@@ -261,8 +330,13 @@ class LoginBatchRunner:
def _append_task_info(task: LoginTask, acc: AccountModel): def _append_task_info(task: LoginTask, acc: AccountModel):
task.batch_id = batch_id task.batch_id = batch_id
task.status = "pending" if self.mode == "relogin":
task.message = "" # 旧 Cookie 仅在 result.success 后由 _execute_one 原子替换。
task.status = "relogin_pending"
task.message = "等待重新登录,旧 Cookie 已保留"
else:
task.status = "pending"
task.message = ""
task.finished_at = None task.finished_at = None
self.db.flush() self.db.flush()
task_infos.append({ task_infos.append({
@@ -295,9 +369,6 @@ class LoginBatchRunner:
if acc.assigned_to != self.created_by: if acc.assigned_to != self.created_by:
self._push_log("warning", f"跳过无权账号: {acc.username}") self._push_log("warning", f"跳过无权账号: {acc.username}")
continue continue
task.ck_check_status = ""
task.ck_check_result = None
task.ck_checked_at = None
_append_task_info(task, acc) _append_task_info(task, acc)
else: else:
seen_account_ids = set() seen_account_ids = set()
@@ -371,9 +442,12 @@ class LoginBatchRunner:
# 并发执行登录,每个账号独立获取代理 # 并发执行登录,每个账号独立获取代理
with ThreadPoolExecutor(max_workers=concurrency) as executor: with ThreadPoolExecutor(max_workers=concurrency) as executor:
futures = [] futures = []
for item in task_infos: for item_index, item in enumerate(task_infos):
if self._stop.is_set(): if self._stop.is_set():
self._push_log("warning", "任务已停止,跳过剩余账号") self._push_log("warning", "任务已停止,跳过剩余账号")
if self.mode == "relogin":
for pending_item in task_infos[item_index:]:
self._mark_relogin_stopped(pending_item["task_id"])
break break
future = executor.submit( future = executor.submit(
self._execute_one, self._execute_one,
@@ -403,12 +477,14 @@ class BatchRegistry:
def __init__(self): def __init__(self):
self._batches: dict[str, dict] = {} self._batches: dict[str, dict] = {}
def register(self, batch_id: str, log_queue: asyncio.Queue, def register(self, batch_id: str, log_queue: Optional[asyncio.Queue],
loop: asyncio.AbstractEventLoop, runner: LoginBatchRunner): loop: Optional[asyncio.AbstractEventLoop], runner: LoginBatchRunner,
owner_id: Optional[int] = None):
self._batches[batch_id] = { self._batches[batch_id] = {
"log_queue": log_queue, "log_queue": log_queue,
"loop": loop, "loop": loop,
"runner": runner, "runner": runner,
"owner_id": owner_id,
} }
def get(self, batch_id: str): def get(self, batch_id: str):
@@ -422,5 +498,27 @@ class BatchRegistry:
batch_registry = BatchRegistry() batch_registry = BatchRegistry()
def cleanup_orphan_relogin_tasks(
db: Session,
message: str = "重新登录已中断(服务重启),旧 Cookie 已保留",
) -> int:
"""服务重启后收敛遗留重登状态,避免页面永久显示重登中。"""
tasks = (
db.query(LoginTask)
.filter(LoginTask.status.in_(("relogin_pending", "relogin_running")))
.all()
)
if not tasks:
return 0
finished_at = datetime.now(timezone.utc)
for task in tasks:
task.status = "relogin_failed"
task.message = message
task.finished_at = finished_at
db.commit()
return len(tasks)
# 在模块末尾导入 SessionLocal(避免循环导入) # 在模块末尾导入 SessionLocal(避免循环导入)
from ..database import SessionLocal from ..database import SessionLocal
+4
View File
@@ -26,6 +26,10 @@ export const cookieApi = {
api.post<{ batch_id: string; count: number; skipped: number; success: boolean }, { batch_id: string; count: number; skipped: number; success: boolean }>('/cookies/relogin', { ids }), api.post<{ batch_id: string; count: number; skipped: number; success: boolean }, { batch_id: string; count: number; skipped: number; success: boolean }>('/cookies/relogin', { ids }),
reloginOperations: (ids: number[]) => reloginOperations: (ids: number[]) =>
api.post<{ batch_id: string; count: number; skipped: number; success: boolean }, { batch_id: string; count: number; skipped: number; success: boolean }>('/cookies/operations/relogin', { ids }), api.post<{ batch_id: string; count: number; skipped: number; success: boolean }, { batch_id: string; count: number; skipped: number; success: boolean }>('/cookies/operations/relogin', { ids }),
reloginInvalidOperations: (params?: { search?: string; tag?: string }) =>
api.post<{ batch_id: string; count: number; skipped: number; success: boolean }, { batch_id: string; count: number; skipped: number; success: boolean }>('/cookies/operations/relogin-invalid', null, { params }),
stopOperationRelogin: (batchId: string) =>
api.post<MessageResponse, MessageResponse>(`/cookies/operations/relogin/${batchId}/stop`),
loginTasks: (batchId: string) => loginTasks: (batchId: string) =>
api.get<LoginTaskItem[], LoginTaskItem[]>('/login/tasks', { params: { batch_id: batchId } }), 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}`),
+3
View File
@@ -280,6 +280,9 @@ export interface CookieOperationItem {
ck_check_status: string; ck_check_status: string;
ck_checked_at: string | null; ck_checked_at: string | null;
created_at: string | null; created_at: string | null;
relogin_status: '' | 'relogin_pending' | 'relogin_running' | 'relogin_failed';
relogin_message: string;
relogin_batch_id: string;
} }
export interface CookieOperationCheckResult { export interface CookieOperationCheckResult {
+4
View File
@@ -22,6 +22,10 @@ const ACTION_OPTIONS = [
{ value: 'recharge:huya:create', label: '虎牙创建充值批次' }, { value: 'recharge:huya:create', label: '虎牙创建充值批次' },
{ value: 'recharge:huya:stop', label: '虎牙停止充值批次' }, { value: 'recharge:huya:stop', label: '虎牙停止充值批次' },
{ value: 'recharge:huya:config', label: '虎牙充值配置' }, { value: 'recharge:huya:config', label: '虎牙充值配置' },
{ value: 'cookie:check', label: '检测账号 CK' },
{ value: 'cookie:relogin', label: '创建 CK 重登批次' },
{ value: 'cookie:relogin_invalid', label: '批量重登失效 CK' },
{ value: 'cookie:relogin_stop', label: '停止 CK 重登批次' },
]; ];
const ACTION_LABELS = new Map(ACTION_OPTIONS.map((item) => [item.value, item.label])); const ACTION_LABELS = new Map(ACTION_OPTIONS.map((item) => [item.value, item.label]));
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Button, Input, Popconfirm, Select, Space, Table, Tag, Typography } from 'antd'; import { Button, Input, Popconfirm, Select, Space, Table, Tag, Typography } from 'antd';
import { message } from '../utils/antdMessage'; import { message } from '../utils/antdMessage';
import { LoadingOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'; import { LoadingOutlined, ReloadOutlined, SearchOutlined, StopOutlined } from '@ant-design/icons';
import { cookieApi, type CookieOperationCheckResult, type CookieOperationItem } from '../api/modules'; import { cookieApi, type CookieOperationCheckResult, type CookieOperationItem } from '../api/modules';
import { formatTime } from '../utils/time'; import { formatTime } from '../utils/time';
import { getErrorMessage } from '../utils/error'; import { getErrorMessage } from '../utils/error';
@@ -59,6 +59,20 @@ export default function CookieOperationsPage() {
void loadItems(); void loadItems();
}, [loadItems]); }, [loadItems]);
const hasActiveRelogin = items.some((item) => (
item.relogin_status === 'relogin_pending' || item.relogin_status === 'relogin_running'
));
const activeReloginBatchIds = Array.from(new Set(items
.filter((item) => item.relogin_status === 'relogin_pending' || item.relogin_status === 'relogin_running')
.map((item) => item.relogin_batch_id)
.filter(Boolean)));
useEffect(() => {
if (!hasActiveRelogin) return;
const timer = window.setInterval(() => void loadItems(), 2000);
return () => window.clearInterval(timer);
}, [hasActiveRelogin, loadItems]);
useEffect(() => { useEffect(() => {
cookieApi.listOperationTags().then(setTags).catch(() => { cookieApi.listOperationTags().then(setTags).catch(() => {
// 标签加载失败不影响 CK 检测与重登。 // 标签加载失败不影响 CK 检测与重登。
@@ -108,8 +122,41 @@ export default function CookieOperationsPage() {
} }
}; };
const handleReloginInvalid = async () => {
try {
const response = await cookieApi.reloginInvalidOperations({
search: search.trim() || undefined,
tag: tagFilter || undefined,
});
const skipped = response.skipped ? `${response.skipped} 条因缺少登录凭据跳过` : '';
message.success(`已开始重登 ${response.count} 个失效账号${skipped}`);
setSelectedRowKeys([]);
void loadItems();
} catch (error: unknown) {
message.error(getErrorMessage(error));
}
};
const handleStopRelogin = async () => {
if (activeReloginBatchIds.length === 0) return;
try {
await Promise.all(activeReloginBatchIds.map((batchId) => cookieApi.stopOperationRelogin(batchId)));
message.success('已发送停止信号');
void loadItems();
} catch (error: unknown) {
message.error(getErrorMessage(error));
}
};
const resultFor = (item: CookieOperationItem) => checkResults.get(item.id); const resultFor = (item: CookieOperationItem) => checkResults.get(item.id);
const selectedIds = selectedRowKeys.map((key) => Number(key)); const selectedIds = selectedRowKeys.map((key) => Number(key));
const reloginStatus = (item: CookieOperationItem) => {
if (item.relogin_status === 'relogin_pending') return <Tag color="processing"></Tag>;
if (item.relogin_status === 'relogin_running') return <Tag color="processing" icon={<LoadingOutlined />}></Tag>;
if (item.relogin_status === 'relogin_failed') return <Tag color="error"></Tag>;
if (item.relogin_message.startsWith('重新登录成功')) return <Tag color="success"></Tag>;
return <Text type="secondary">-</Text>;
};
const columns = [ const columns = [
{ title: '账号', dataIndex: 'account_username', ellipsis: true }, { title: '账号', dataIndex: 'account_username', ellipsis: true },
{ {
@@ -137,6 +184,20 @@ export default function CookieOperationsPage() {
return checkedAt ? formatTime(checkedAt) : <Text type="secondary">-</Text>; return checkedAt ? formatTime(checkedAt) : <Text type="secondary">-</Text>;
}, },
}, },
{
title: '重登状态',
width: 130,
render: (_: unknown, item: CookieOperationItem) => (
<Space direction="vertical" size={0}>
{reloginStatus(item)}
{item.relogin_status === 'relogin_failed' && item.relogin_message ? (
<Text type="secondary" ellipsis style={{ maxWidth: 180, fontSize: 12 }} title={item.relogin_message}>
{item.relogin_message}
</Text>
) : null}
</Space>
),
},
{ {
title: '操作', title: '操作',
width: 190, width: 190,
@@ -145,7 +206,7 @@ export default function CookieOperationsPage() {
<Button <Button
size="small" size="small"
loading={checkingIds.has(item.id)} loading={checkingIds.has(item.id)}
disabled={checkingIds.has(item.id)} disabled={checkingIds.has(item.id) || item.relogin_status === 'relogin_pending' || item.relogin_status === 'relogin_running'}
onClick={() => void handleCheck([item.id])} onClick={() => void handleCheck([item.id])}
> >
@@ -155,7 +216,7 @@ export default function CookieOperationsPage() {
size="small" size="small"
icon={<ReloadOutlined />} icon={<ReloadOutlined />}
loading={reloginIds.has(item.id)} loading={reloginIds.has(item.id)}
disabled={reloginIds.has(item.id)} disabled={reloginIds.has(item.id) || item.relogin_status === 'relogin_pending' || item.relogin_status === 'relogin_running'}
> >
</Button> </Button>
@@ -182,6 +243,19 @@ export default function CookieOperationsPage() {
{selectedIds.length > 0 ? `(${selectedIds.length})` : ''} {selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
</Button> </Button>
</Popconfirm> </Popconfirm>
<Popconfirm
title="将重新登录当前筛选范围内所有已检测为无效的账号,并在成功后替换旧 CK,确认继续?"
onConfirm={() => void handleReloginInvalid()}
>
<Button icon={<ReloadOutlined />}>
</Button>
</Popconfirm>
{activeReloginBatchIds.length > 0 ? (
<Popconfirm title="确认停止当前重登任务?旧 CK 会保留。" onConfirm={() => void handleStopRelogin()}>
<Button danger icon={<StopOutlined />}></Button>
</Popconfirm>
) : null}
</Space> </Space>
</div> </div>
<Space wrap style={{ marginBottom: 12 }}> <Space wrap style={{ marginBottom: 12 }}>