优化客服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
@@ -30,6 +30,7 @@ async def lifespan(app: FastAPI):
from loguru import logger
from .database import SessionLocal
from .services.login_service import cleanup_orphan_relogin_tasks
from .services.huya_service import cleanup_orphan_huya_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="任务已中断(服务重启)")
if 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
cleaned_yyb = cleanup_orphan_yyb_tasks(db, message="任务已中断(服务重启)")
if cleaned_yyb:
+132 -116
View File
@@ -3,7 +3,6 @@
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone
import requests
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
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 ..permissions import user_has_permission, get_user_permissions
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:
@@ -28,6 +28,7 @@ def _fmt_dt(dt) -> str | None:
return dt.isoformat()
router = APIRouter(prefix="/api/cookies", tags=["Cookie管理"])
cookie_relogin_registry = BatchRegistry()
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())
# 斗鱼 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:
"""检测单条斗鱼 Cookie 有效性:鱼丸 + 用户等级双接口均通过才算有效。
附带鱼丸数、昵称/等级与检测时间;任一接口失败会说明原因。
"""
cookie = task.cookie or ""
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,
}
"""将共享检测结果补充为 Cookie 记录的接口返回格式。"""
result = check_douyu_cookie(task.cookie or "")
result["id"] = task.id
result["checked_at"] = result["checked_at"].isoformat()
return result
def _visible_cookie_tasks_query(db: Session, current: User):
@@ -160,6 +84,19 @@ def _visible_cookie_tasks_query(db: Session, current: User):
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("")
def list_cookies(
search: str = Query(""),
@@ -292,7 +229,7 @@ def list_cookie_operations(
):
"""脱敏的 CK 操作列表,只用于检测与重登。"""
_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"):
query = query.join(Account, LoginTask.account_id == Account.id)
search_text = (search or "").strip()
@@ -321,6 +258,9 @@ def list_cookie_operations(
"ck_check_status": task.ck_check_status or "",
"ck_checked_at": _fmt_dt(task.ck_checked_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
],
@@ -337,7 +277,7 @@ def list_cookie_operation_tags(
):
"""返回当前用户可操作 CK 记录对应的标签,不跨越账号分配范围。"""
_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"):
query = query.join(Account, LoginTask.account_id == Account.id)
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()]
if not id_list:
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:
raise HTTPException(status_code=404, detail="记录不存在")
@@ -562,48 +505,46 @@ def check_cookie_operations(
return _check_cookies(ids, db, current, detailed=False)
def _start_relogin(
req: CookieReloginRequest,
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()
def _start_relogin_tasks(tasks: list[LoginTask], db: Session, current: User, *, action: str = "cookie:relogin"):
"""启动重登批次:旧 Cookie 保留到新登录成功后才替换。"""
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()
skipped: list[int] = []
for task in tasks:
acc = db.query(Account).filter(Account.id == task.account_id).first()
if not acc:
continue
if not acc.password or not acc.email:
skipped.append(task.id)
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)
if not task_ids:
raise HTTPException(
status_code=400,
detail="所选 Cookie 对应账号缺少密码或邮箱信息,无法重新登录,请先在账号管理中补充",
)
db.commit()
raise HTTPException(status_code=400, detail="没有可重登的账号,请检查账号凭据或当前重登状态")
proxy = db.query(ProxyConfigModel).first()
thread_db = SessionLocal()
max_login_retries, max_total_time = get_relogin_limits()
runner = LoginBatchRunner(
db=thread_db,
account_ids=[],
created_by=current.id,
creator_permissions=get_user_permissions(current),
max_login_retries=max_login_retries,
max_total_time=max_total_time,
proxy_config=proxy,
log_queue=None,
loop=None,
@@ -612,16 +553,29 @@ def _start_relogin(
relogin_task_ids=task_ids,
)
batch_id = runner.batch_id
thread = threading.Thread(target=runner.run, daemon=True)
thread.start()
db.add(AuditLog(
user_id=current.id,
username=current.username,
action="cookie:relogin",
target=f"重登 {len(task_ids)}已分配账号 CK",
action=action,
target=f"重登 {len(task_ids)} 条账号 CK",
detail="旧 Cookie 将在新登录成功后替换",
))
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 {
"batch_id": batch_id,
"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")
def relogin_cookies(
req: CookieReloginRequest,
@@ -651,6 +643,30 @@ def relogin_cookie_operations(
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}")
def get_cookie(
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/ 核心模块,在线程池中并发执行登录并推送日志。"""
import asyncio
import os
import threading
import time
import uuid
@@ -10,10 +11,12 @@ from datetime import datetime, timezone
from typing import Optional
from sqlalchemy.orm import Session
from loguru import logger
from core.douyu import DouyuLogin, WgapiLoginAPI, IframeLoginAPI
from core.douyu.proxy_fetcher import ProxyFetcher
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):
@@ -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:
"""批量登录执行器,在线程中运行,通过 ThreadPoolExecutor 并发登录多个账号。"""
@@ -67,7 +105,7 @@ class LoginBatchRunner:
# 0 表示使用默认值 20,其他值保持原样
self.max_login_retries = max_login_retries if max_login_retries > 0 else 20
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.loop = loop
self.batch_id = uuid.uuid4().hex[:12]
@@ -81,6 +119,7 @@ class LoginBatchRunner:
# 共享代理获取器(无池,每次取新代理)
self._shared_proxy_fetcher = None
proxy_config = self.proxy_config
if proxy_config and proxy_config.enabled and proxy_config.api_url:
wl_platform = "xiequ"
wl_credentials = None
@@ -111,7 +150,28 @@ class LoginBatchRunner:
time.sleep(min(0.2, deadline - time.monotonic()))
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):
# 即使没有页面实时日志,也要保留批次进度到 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:
asyncio.run_coroutine_threadsafe(
self.log_queue.put({"level": level, "message": message}),
@@ -136,16 +196,7 @@ class LoginBatchRunner:
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()
self._mark_relogin_stopped(task_id)
return
worker_db = SessionLocal()
@@ -154,7 +205,11 @@ class LoginBatchRunner:
if not task:
return
task.status = "running"
if self.mode == "relogin":
task.status = "relogin_running"
task.message = "正在重新登录,旧 Cookie 保留中"
else:
task.status = "running"
worker_db.commit()
with self._counter_lock:
@@ -164,21 +219,25 @@ class LoginBatchRunner:
action_name = "检测" if self.mode == "check" else "重新登录" if self.mode == "relogin" else "登录"
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:
# 代理配置也可能异常,必须由当前任务的失败处理收敛状态。
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(
username=acc_info["username"],
password=acc_info["password"],
@@ -212,18 +271,28 @@ class LoginBatchRunner:
task.cookie = result.cookie
task.message = result.message or "登录成功"
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 已替换")
check_result = check_douyu_cookie(result.cookie)
task.ck_check_status = "valid" if check_result["valid"] else "invalid"
task.ck_check_result = {
"fish_ball": check_result["fish_ball"],
"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:
self._push_log("success", f"[{current}] {acc_info['username']} {task.message}")
else:
if self.mode == "relogin":
# 重新登录失败时保留旧 Cookie 与成功状态,仅记录失败原因,行不消失
task.status = "success"
task.message = f"重新登录失败: {result.message}"
task.status = "relogin_failed"
task.message = f"重新登录失败: {result.message}(旧 Cookie 已保留)"
self._push_log("error", f"[{current}] {acc_info['username']} 重新登录失败: {result.message}")
else:
task.status = "failed"
@@ -232,8 +301,8 @@ class LoginBatchRunner:
except Exception as e:
if self.mode == "relogin":
task.status = "success"
task.message = f"重新登录异常: {e}"
task.status = "relogin_failed"
task.message = f"重新登录异常: {e}(旧 Cookie 已保留)"
self._push_log("error", f"[{current}] {acc_info['username']} 重新登录异常: {e}")
else:
task.status = "error"
@@ -261,8 +330,13 @@ class LoginBatchRunner:
def _append_task_info(task: LoginTask, acc: AccountModel):
task.batch_id = batch_id
task.status = "pending"
task.message = ""
if self.mode == "relogin":
# 旧 Cookie 仅在 result.success 后由 _execute_one 原子替换。
task.status = "relogin_pending"
task.message = "等待重新登录,旧 Cookie 已保留"
else:
task.status = "pending"
task.message = ""
task.finished_at = None
self.db.flush()
task_infos.append({
@@ -295,9 +369,6 @@ class LoginBatchRunner:
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:
seen_account_ids = set()
@@ -371,9 +442,12 @@ class LoginBatchRunner:
# 并发执行登录,每个账号独立获取代理
with ThreadPoolExecutor(max_workers=concurrency) as executor:
futures = []
for item in task_infos:
for item_index, item in enumerate(task_infos):
if self._stop.is_set():
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
future = executor.submit(
self._execute_one,
@@ -403,12 +477,14 @@ class BatchRegistry:
def __init__(self):
self._batches: dict[str, dict] = {}
def register(self, batch_id: str, log_queue: asyncio.Queue,
loop: asyncio.AbstractEventLoop, runner: LoginBatchRunner):
def register(self, batch_id: str, log_queue: Optional[asyncio.Queue],
loop: Optional[asyncio.AbstractEventLoop], runner: LoginBatchRunner,
owner_id: Optional[int] = None):
self._batches[batch_id] = {
"log_queue": log_queue,
"loop": loop,
"runner": runner,
"owner_id": owner_id,
}
def get(self, batch_id: str):
@@ -422,5 +498,27 @@ class 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(避免循环导入)
from ..database import SessionLocal