优化客服CK重登流程
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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}`),
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user