From 64c399617a5bb2be62675e03fe56b3dc808b5046 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 1 Oct 2026 18:18:02 +0800 Subject: [PATCH] =?UTF-8?q?fix(douyu):=20=E8=B4=A6=E5=8F=B7=E5=8F=AA?= =?UTF-8?q?=E8=A6=81=E8=BF=98=E6=8C=81=E6=9C=89=20CK=20=E5=B0=B1=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E4=BB=8E=E5=B7=A5=E4=BD=9C=E5=8F=B0=E6=B6=88=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 线上事故:分配给客服的账号(如「远行者103」)在斗鱼工作台与 CK 列表里 查不到,只有账号管理里还能搜到。原因是可见性判定写成了 LoginTask.status == "success",而按设计保留旧 CK 的三种状态都不再是 success: - 普通登录批次启动时会把复用的成功记录置为 pending/running(旧 CK 保留), 批次被停止或失败后一直停在 pending,账号永久消失; - CK 重登失败置为 relogin_failed(旧 CK 保留),账号从工作台消失; - 普通登录失败置为 failed(复用记录的旧 CK 未清空),同样消失。 线上现状:12545 个账号持有 CK,但只有 11703 个能出现在工作台,842 个 (644 重登失败 + 176 排队中 + …)对客服和管理员都不可见。 改动: - douyu_service 新增 cookie_holding_filter()/latest_cookie_login_task(), 以「记录里还留着 Cookie」作为唯一判据,并抽出 LOGIN_IN_FLIGHT_STATUSES 与 cookie_operable_filter() 供检测/重登入口复用; - 斗鱼工作台账号列表、任务账号校验、账号管理 has_cookie 筛选、分配校验与 概览、CK 管理/操作列表、首页 CK 统计统一改用该判据; - 工作台取 CK 从 latest_success_login_task 换成 latest_cookie_login_task, 保证重登失败/排队中的账号仍能被任务正常执行; - 新增 cleanup_orphan_login_tasks():进程重启后把遗留的 pending/running 收敛为 failed 并保留旧 CK,避免页面永久显示排队中。 新增回归测试覆盖上述状态下的可见性、可执行性与启动清理。 --- tests/test_cookie_operations.py | 17 +++ tests/test_douyu_cookie_visibility.py | 166 ++++++++++++++++++++++++ web/backend/main.py | 8 +- web/backend/routers/accounts.py | 22 +--- web/backend/routers/cookies.py | 30 +++-- web/backend/routers/dashboard.py | 3 +- web/backend/routers/douyu.py | 13 +- web/backend/services/account_service.py | 19 ++- web/backend/services/douyu_runner.py | 6 +- web/backend/services/douyu_service.py | 64 ++++++--- web/backend/services/login_service.py | 25 ++++ 11 files changed, 302 insertions(+), 71 deletions(-) create mode 100644 tests/test_douyu_cookie_visibility.py diff --git a/tests/test_cookie_operations.py b/tests/test_cookie_operations.py index 5eb38d8..b611bca 100644 --- a/tests/test_cookie_operations.py +++ b/tests/test_cookie_operations.py @@ -19,6 +19,7 @@ from web.backend.routers.cookies import ( from web.backend.services.cookie_check_service import check_douyu_cookie from web.backend.services.login_service import ( LoginBatchRunner, + cleanup_orphan_login_tasks, cleanup_orphan_relogin_tasks, ) @@ -236,6 +237,22 @@ class TestCookieOperation: assert "服务重启" in self.owned_task.message assert self.other_task.status == "success" + def test_service_restart_cleans_orphan_login_tasks(self): + """普通登录批次遗留的 pending/running 必须收敛,且保留旧 CK。""" + self.owned_task.status = "pending" + self.owned_task.cookie = "acf_uid=owned" + self.session.commit() + + cleaned = cleanup_orphan_login_tasks(self.session) + + assert cleaned == 1 + self.session.refresh(self.owned_task) + self.session.refresh(self.other_task) + assert self.owned_task.status == "failed" + assert self.owned_task.cookie == "acf_uid=owned" + assert "服务重启" in self.owned_task.message + assert self.other_task.status == "success" + def test_runner_copies_proxy_config_before_background_execution(self): proxy = ProxyConfig( enabled=True, diff --git a/tests/test_douyu_cookie_visibility.py b/tests/test_douyu_cookie_visibility.py new file mode 100644 index 0000000..2022f1e --- /dev/null +++ b/tests/test_douyu_cookie_visibility.py @@ -0,0 +1,166 @@ +"""回归:只要账号还留着 CK,就不得因为登录批次状态而从工作台消失。 + +线上事故(2026-10-01):账号「远行者103」被分配给客服后,客服与管理员在 +斗鱼工作台都看不到它,只有账号管理里还能搜到。原因是可见性判定写成了 +``LoginTask.status == "success"``,而普通登录批次启动时会把复用的成功记录 +置为 ``pending``,CK 重登失败会置为 ``relogin_failed`` —— 两种状态都保留旧 +Cookie,却让账号从工作台与 CK 列表里凭空消失。 +""" + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from web.backend.database import Base +from web.backend.models import Account, LoginTask, User +from web.backend.routers.cookies import list_cookies +from web.backend.routers.douyu import list_task_account_ids, list_task_accounts +from web.backend.services.account_service import cookie_account_ids_query +from web.backend.services.douyu_service import ( + create_douyu_planned_tasks, + latest_cookie_login_task, + visible_douyu_task_accounts, +) + + +class TestDouyuCookieVisibility: + def setup_method(self): + self.engine = create_engine("sqlite://") + Base.metadata.create_all(self.engine) + self.session = sessionmaker(bind=self.engine)() + + self.admin = User(username="admin", password_hash="hash", role="super_admin") + self.support = User(username="kf_7", password_hash="hash", role="support") + self.session.add_all([self.admin, self.support]) + self.session.commit() + + # relogin_failed:重登失败但旧 CK 已按设计保留 + self.relogin_account = self._make_account("远行者103", self.support.id) + self.session.add( + LoginTask( + batch_id="relogin-failed", + account_id=self.relogin_account.id, + created_by=self.support.id, + status="relogin_failed", + cookie="acf_uid=481802039", + ck_check_status="valid", + ) + ) + # pending:普通登录批次排队中,复用了成功记录,旧 CK 仍在 + self.pending_account = self._make_account("排队中账号", self.support.id) + self.session.add( + LoginTask( + batch_id="queued", + account_id=self.pending_account.id, + created_by=self.admin.id, + status="pending", + cookie="acf_uid=2", + ) + ) + # 从未登录成功:没有 CK,必须继续隐藏 + self.empty_account = self._make_account("无CK账号", self.support.id) + self.session.add( + LoginTask( + batch_id="failed", + account_id=self.empty_account.id, + created_by=self.admin.id, + status="failed", + cookie="", + ) + ) + self.session.commit() + + def _make_account(self, username: str, assigned_to: int) -> Account: + account = Account( + username=username, + password="password", + email=f"{username}@example.com", + email_password="mail-password", + assigned_to=assigned_to, + ) + self.session.add(account) + self.session.flush() + return account + + def teardown_method(self): + self.session.close() + Base.metadata.drop_all(self.engine) + self.engine.dispose() + + def _task_account_names(self, current: User) -> set[str]: + result = list_task_accounts( + search="", + tag="", + ids="", + account_names="", + page=None, + page_size=50, + db=self.session, + current=current, + ) + return {item.username for item in result} + + @pytest.mark.parametrize("current_attr", ["admin", "support"]) + def test_workbench_lists_accounts_that_still_hold_cookie(self, current_attr): + current = getattr(self, current_attr) + names = self._task_account_names(current) + assert {"远行者103", "排队中账号"} <= names + assert "无CK账号" not in names + + def test_workbench_account_ids_keep_cookie_holders(self): + result = list_task_account_ids( + search="", + tag="", + account_names="", + db=self.session, + current=self.admin, + ) + assert set(result["account_ids"]) == { + self.relogin_account.id, + self.pending_account.id, + } + + def test_latest_cookie_login_task_ignores_batch_status(self): + task = latest_cookie_login_task(self.session, self.relogin_account.id) + assert task is not None + assert task.cookie == "acf_uid=481802039" + + def test_visible_task_accounts_include_retained_cookie(self): + accounts = visible_douyu_task_accounts( + self.session, + [self.relogin_account.id, self.empty_account.id], + ) + assert [account.id for account in accounts] == [self.relogin_account.id] + + def test_account_has_cookie_filter_matches_workbench(self): + ids = { + account_id for (account_id,) in cookie_account_ids_query(self.session).all() + } + assert ids == {self.relogin_account.id, self.pending_account.id} + + def test_cookie_management_page_lists_retained_cookie(self): + items = list_cookies( + search="", + tag="", + account_names="", + page=None, + page_size=50, + include_cookie=True, + db=self.session, + current=self.admin, + ) + assert {item["account_username"] for item in items} == { + "远行者103", + "排队中账号", + } + + def test_retained_cookie_account_can_still_create_task(self): + batch_id, created = create_douyu_planned_tasks( + self.session, + [self.relogin_account.id], + "create_gold_qr", + "elite", + self.admin.id, + ) + assert batch_id + assert created == 1 diff --git a/web/backend/main.py b/web/backend/main.py index 53ef383..1add633 100644 --- a/web/backend/main.py +++ b/web/backend/main.py @@ -49,7 +49,10 @@ async def lifespan(app: FastAPI): from .database import SessionLocal from .services.douyu_service import cleanup_orphan_douyu_tasks from .services.huya_service import cleanup_orphan_huya_tasks - from .services.login_service import cleanup_orphan_relogin_tasks + from .services.login_service import ( + cleanup_orphan_login_tasks, + cleanup_orphan_relogin_tasks, + ) db = SessionLocal() try: @@ -62,6 +65,9 @@ async def lifespan(app: FastAPI): cleaned_relogin = cleanup_orphan_relogin_tasks(db) if cleaned_relogin: logger.info(f"启动清理 CK 重登残留任务: {cleaned_relogin} 条") + cleaned_login = cleanup_orphan_login_tasks(db) + if cleaned_login: + logger.info(f"启动清理登录残留任务: {cleaned_login} 条") from .services.yyb_service import cleanup_orphan_yyb_tasks cleaned_yyb = cleanup_orphan_yyb_tasks(db, message="任务已中断(服务重启)") diff --git a/web/backend/routers/accounts.py b/web/backend/routers/accounts.py index efa5691..76ca6ab 100644 --- a/web/backend/routers/accounts.py +++ b/web/backend/routers/accounts.py @@ -28,6 +28,7 @@ from ..services.account_service import ( cookie_account_ids_query, parse_and_build_accounts, ) +from ..services.douyu_service import cookie_holding_filter router = APIRouter(prefix="/api/accounts", tags=["账号管理"]) @@ -276,13 +277,12 @@ def assign_account( if target.role != "support": raise HTTPException(status_code=400, detail="只能分配给客服角色") - # 只能分配已成功登录过的账号(有cookie) + # 只能分配当前仍持有 CK 的账号(旧 CK 在重登失败/排队时也会保留) has_success = ( db.query(LoginTask) .filter( LoginTask.account_id == account_id, - LoginTask.status == "success", - LoginTask.cookie != "", + cookie_holding_filter(), ) .first() ) @@ -320,12 +320,8 @@ def batch_assign_accounts( if target.role != "support": raise HTTPException(status_code=400, detail="只能分配给客服角色") - # 只能分配已成功登录过的账号(有cookie) - cookie_ids_query = select(LoginTask.account_id).where( - LoginTask.status == "success", - LoginTask.cookie != "", - LoginTask.cookie.isnot(None), - ) + # 只能分配当前仍持有 CK 的账号(旧 CK 在重登失败/排队时也会保留) + cookie_ids_query = select(LoginTask.account_id).where(cookie_holding_filter()) invalid_ids = ( db.query(Account.id) .filter( @@ -374,12 +370,8 @@ def assignments_summary( db: Session = Depends(get_db), current: User = Depends(require_permission("account:assign")), ): - """分配概览:每个客服分配了多少账号(仅统计已成功登录的账号)。""" - cookie_subq = select(LoginTask.account_id).where( - LoginTask.status == "success", - LoginTask.cookie != "", - LoginTask.cookie.isnot(None), - ) + """分配概览:每个客服分配了多少账号(仅统计当前仍持有 CK 的账号)。""" + cookie_subq = select(LoginTask.account_id).where(cookie_holding_filter()) cookie_accounts = db.query(Account).filter(Account.id.in_(cookie_subq)).subquery() results = ( diff --git a/web/backend/routers/cookies.py b/web/backend/routers/cookies.py index 70d3a86..d1335c5 100644 --- a/web/backend/routers/cookies.py +++ b/web/backend/routers/cookies.py @@ -18,6 +18,11 @@ from ..models import ProxyConfig as ProxyConfigModel from ..permissions import get_user_permissions, user_has_permission from ..schemas import CookieReloginRequest from ..services.cookie_check_service import check_douyu_cookie +from ..services.douyu_service import ( + LOGIN_IN_FLIGHT_STATUSES, + cookie_holding_filter, + cookie_operable_filter, +) from ..services.login_service import BatchRegistry, LoginBatchRunner, get_relogin_limits @@ -80,8 +85,12 @@ def _check_one_cookie(task: LoginTask) -> dict: def _visible_cookie_tasks_query(db: Session, current: User): - """返回当前用户可见的成功 Cookie 任务查询。""" - query = db.query(LoginTask).filter(LoginTask.status == "success") + """返回当前用户可见的、仍持有 Cookie 的登录记录查询。 + + 以「记录里还留着 CK」为准:重登失败(relogin_failed)与排队中的登录 + 都保留了旧 CK,管理页必须继续能看到这些账号,否则客服会以为账号消失。 + """ + query = db.query(LoginTask).filter(cookie_holding_filter()) # 无全量登录任务权限时,只能操作分配给自己的账号 Cookie。 if not user_has_permission(current, "login:view_all"): @@ -93,12 +102,7 @@ def _visible_cookie_tasks_query(db: Session, current: User): 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") - ), - ) + query = db.query(LoginTask).filter(cookie_holding_filter()) if not user_has_permission(current, "login:view_all"): query = query.join(Account, LoginTask.account_id == Account.id).filter( Account.assigned_to == current.id @@ -336,7 +340,7 @@ def find_duplicate_cookies( Account.username.label("username"), ) .join(Account, LoginTask.account_id == Account.id) - .filter(LoginTask.status == "success") + .filter(cookie_holding_filter()) ) # 与 CK 列表保持相同的数据可见范围,客服只能检测自己被分配的账号。 @@ -471,7 +475,7 @@ def _check_cookies(ids: str, db: Session, current: User, *, detailed: bool) -> d ) tasks = ( base_query.filter(LoginTask.id.in_(id_list)) - .filter(LoginTask.status.in_(("success", "relogin_failed"))) + .filter(cookie_operable_filter()) .all() ) if not tasks: @@ -576,7 +580,7 @@ def _start_relogin_tasks( task.finished_at = datetime.now(UTC) skipped.append(task.id) continue - if task.status in {"relogin_pending", "relogin_running"}: + if task.status in LOGIN_IN_FLIGHT_STATUSES: skipped.append(task.id) continue task.status = "relogin_pending" @@ -682,7 +686,7 @@ def _start_relogin(req: CookieReloginRequest, db: Session, current: User): tasks = ( _visible_cookie_operation_tasks_query(db, current) .filter(LoginTask.id.in_(req.ids)) - .filter(LoginTask.status.in_(("success", "relogin_failed"))) + .filter(cookie_operable_filter()) .all() ) return _start_relogin_tasks(tasks, db, current) @@ -720,7 +724,7 @@ def relogin_invalid_cookie_operations( _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")), + cookie_operable_filter(), ) if user_has_permission(current, "login:view_all"): query = query.join(Account, LoginTask.account_id == Account.id) diff --git a/web/backend/routers/dashboard.py b/web/backend/routers/dashboard.py index 96ce8c5..51ada46 100644 --- a/web/backend/routers/dashboard.py +++ b/web/backend/routers/dashboard.py @@ -16,6 +16,7 @@ from ..models import ( User, ) from ..permissions import user_has_permission +from ..services.douyu_service import cookie_holding_filter router = APIRouter(prefix="/api/dashboard", tags=["首页概览"]) @@ -86,7 +87,7 @@ def dashboard_summary( cookie_query = ( db.query(LoginTask) .join(Account, LoginTask.account_id == Account.id) - .filter(LoginTask.status == "success") + .filter(cookie_holding_filter()) ) if not user_has_permission(current, "login:view_all"): cookie_query = cookie_query.filter(Account.assigned_to == current.id) diff --git a/web/backend/routers/douyu.py b/web/backend/routers/douyu.py index 42a1273..12ceb91 100644 --- a/web/backend/routers/douyu.py +++ b/web/backend/routers/douyu.py @@ -59,6 +59,7 @@ from ..services.douyu_service import ( SUPPORTED_DOUYU_TASK_TYPES, apply_douyu_config_defaults, cleanup_orphan_douyu_tasks, + cookie_holding_filter, create_douyu_planned_tasks, douyu_config_value, douyu_task_payload, @@ -179,12 +180,12 @@ def _can_view_all(user: User) -> bool: def _visible_task_accounts_query(db: Session, current: User): - """返回当前用户可用于斗鱼任务的账号查询。""" - cookie_ids = ( - select(LoginTask.account_id) - .where(LoginTask.status == "success", LoginTask.cookie != "") - .distinct() - ) + """返回当前用户可用于斗鱼任务的账号查询。 + + 只要账号还持有 Cookie 就可见:排队中的普通登录(pending/running)和 + 重登失败(relogin_failed)都会保留旧 CK,不能让账号从工作台消失。 + """ + cookie_ids = select(LoginTask.account_id).where(cookie_holding_filter()).distinct() query = ( db.query(Account) .options(joinedload(Account.assigned_user)) diff --git a/web/backend/services/account_service.py b/web/backend/services/account_service.py index 7a4781e..8086322 100644 --- a/web/backend/services/account_service.py +++ b/web/backend/services/account_service.py @@ -5,7 +5,8 @@ import re from sqlalchemy.orm import Session -from ..models import Account, LoginTask +from ..models import Account +from .douyu_service import cookie_holding_account_ids_query EMAIL_PATTERN = re.compile(r"^[^\s@|]+@[^\s@|]+\.[^\s@|]+$") @@ -22,16 +23,12 @@ def split_account_line(line: str) -> list[str]: def cookie_account_ids_query(db: Session): - """返回有成功登录记录(cookie非空)的账号ID子查询。""" - return ( - db.query(LoginTask.account_id) - .filter( - LoginTask.status == "success", - LoginTask.cookie != "", - LoginTask.cookie.isnot(None), - ) - .distinct() - ) + """返回当前仍持有 Cookie 的账号ID子查询。 + + 以「记录里还留着 CK」为准,而不是以登录批次状态为准:排队中的登录与 + 重登失败都会保留旧 CK,账号不应因此从筛选结果里消失。 + """ + return cookie_holding_account_ids_query(db) def parse_and_build_accounts( diff --git a/web/backend/services/douyu_runner.py b/web/backend/services/douyu_runner.py index 0b73a63..2b1aad4 100644 --- a/web/backend/services/douyu_runner.py +++ b/web/backend/services/douyu_runner.py @@ -21,7 +21,7 @@ from .douyu_runner_gold import GoldMixin from .douyu_runner_goods import GoodsMixin from .douyu_runner_manual import ManualMixin from .douyu_runner_xpd import XpdMixin -from .douyu_service import latest_success_login_task, update_account_profile_from_cookie +from .douyu_service import latest_cookie_login_task, update_account_profile_from_cookie class DouyuBatchRunner( @@ -56,10 +56,10 @@ class DouyuBatchRunner( self._push_log( "info", f"[{current}/{total}] 开始: {self._account_name(account)}" ) - login_task = latest_success_login_task(worker_db, account.id) + login_task = latest_cookie_login_task(worker_db, account.id) cookie = login_task.cookie if login_task else "" if not cookie: - self._mark_task(worker_db, task, "failed", "账号没有成功登录 Cookie") + self._mark_task(worker_db, task, "failed", "账号没有可用登录 Cookie") self._push_log( "warning", f"[{current}] {self._account_name(account)} 无 Cookie" ) diff --git a/web/backend/services/douyu_service.py b/web/backend/services/douyu_service.py index 4a5b209..ec40cfb 100644 --- a/web/backend/services/douyu_service.py +++ b/web/backend/services/douyu_service.py @@ -5,7 +5,7 @@ from __future__ import annotations import uuid from datetime import UTC, datetime -from sqlalchemy import select +from sqlalchemy import and_, select from sqlalchemy.orm import Session from core.douyu.activity_client import DouyuActivityClient @@ -181,44 +181,66 @@ def ensure_douyu_config(db: Session) -> DouyuConfig: return config -def latest_success_login_task(db: Session, account_id: int) -> LoginTask | None: - """读取账号最近一次成功登录记录。""" +# 登录/重登批次正在处理的状态:这些记录已被批次接管,不允许并发操作。 +LOGIN_IN_FLIGHT_STATUSES = ("pending", "running", "relogin_pending", "relogin_running") + + +def cookie_holding_filter(): + """判定登录记录当前是否仍持有 Cookie。 + + 账号是否有 CK 只取决于记录里是否还留着 Cookie,而不是取决于批次状态: + 普通登录在排队时会把复用的成功记录临时置为 ``pending``/``running``, + CK 重登失败会置为 ``relogin_failed``,这些状态都按设计保留旧 Cookie + (见 ``login_service`` 的「旧 Cookie 已保留」分支),账号必须继续可见。 + 只有 Cookie 真正被清空(账号检测成功)时才不再算持有 CK。 + """ + return and_( + LoginTask.cookie.isnot(None), + LoginTask.cookie != "", + ) + + +def latest_cookie_login_task(db: Session, account_id: int) -> LoginTask | None: + """读取账号最近一条仍持有 Cookie 的登录记录。""" return ( db.query(LoginTask) .filter( LoginTask.account_id == account_id, - LoginTask.status == "success", - LoginTask.cookie != "", + cookie_holding_filter(), ) .order_by(LoginTask.id.desc()) .first() ) -def latest_success_cookie(db: Session, account_id: int) -> str: - """读取账号最近一次成功登录 Cookie。""" - task = latest_success_login_task(db, account_id) +def cookie_operable_filter(): + """判定登录记录能否基于现有 Cookie 再做检测或重登。 + + 正在被登录/重登批次处理的行(pending/running/relogin_*)已由批次接管, + 并发再点检测或重登只会互相覆盖状态,因此排除在外。 + """ + return and_( + cookie_holding_filter(), + LoginTask.status.notin_(LOGIN_IN_FLIGHT_STATUSES), + ) + + +def latest_cookie(db: Session, account_id: int) -> str: + """读取账号当前 Cookie。""" + task = latest_cookie_login_task(db, account_id) return task.cookie if task else "" -def cookie_account_ids_query(db: Session): - """返回拥有成功 Cookie 的斗鱼账号 ID 查询。""" - return ( - db.query(LoginTask.account_id) - .filter(LoginTask.status == "success", LoginTask.cookie != "") - .distinct() - ) +def cookie_holding_account_ids_query(db: Session): + """返回当前仍持有 Cookie 的斗鱼账号 ID 查询。""" + return db.query(LoginTask.account_id).filter(cookie_holding_filter()).distinct() def visible_douyu_task_accounts(db: Session, account_ids: list[int]) -> list[Account]: - """只保留存在成功 Cookie 的斗鱼账号。""" + """只保留当前仍持有 Cookie 的斗鱼账号。""" if not account_ids: return [] - cookie_ids = select(LoginTask.account_id).where( - LoginTask.status == "success", - LoginTask.cookie != "", - LoginTask.cookie.isnot(None), - ) + cookie_ids = select(LoginTask.account_id).where(cookie_holding_filter()) return ( db.query(Account) .filter(Account.id.in_(account_ids), Account.id.in_(cookie_ids)) diff --git a/web/backend/services/login_service.py b/web/backend/services/login_service.py index e516aec..3df9732 100644 --- a/web/backend/services/login_service.py +++ b/web/backend/services/login_service.py @@ -646,5 +646,30 @@ def cleanup_orphan_relogin_tasks( return len(tasks) +def cleanup_orphan_login_tasks( + db: Session, + message: str = "登录已中断(服务重启),旧 Cookie 已保留", +) -> int: + """服务重启后收敛遗留的普通登录状态。 + + 普通登录批次停止或进程重启时,未轮到的记录会永远停在 pending/running, + 页面上一直显示「排队中」,旧 CK 也失去终态说明。统一收敛为 failed 并 + 保留 Cookie,账号仍然可见可重登。 + """ + tasks = ( + db.query(LoginTask).filter(LoginTask.status.in_(("pending", "running"))).all() + ) + if not tasks: + return 0 + + finished_at = datetime.now(UTC) + for task in tasks: + task.status = "failed" + task.message = message + task.finished_at = finished_at + db.commit() + return len(tasks) + + # 在模块末尾导入 SessionLocal(避免循环导入) from ..database import SessionLocal