fix(douyu): 账号只要还持有 CK 就不再从工作台消失
线上事故:分配给客服的账号(如「远行者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,避免页面永久显示排队中。 新增回归测试覆盖上述状态下的可见性、可执行性与启动清理。
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
+7
-1
@@ -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="任务已中断(服务重启)")
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user