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:
yml2213
2026-10-01 18:18:02 +08:00
parent 4806d4bbf1
commit 64c399617a
11 changed files with 302 additions and 71 deletions
+17
View File
@@ -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,
+166
View File
@@ -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
View File
@@ -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="任务已中断(服务重启)")
+7 -15
View File
@@ -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 = (
+17 -13
View File
@@ -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)
+2 -1
View File
@@ -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)
+7 -6
View File
@@ -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))
+8 -11
View File
@@ -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(
+3 -3
View File
@@ -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"
)
+43 -21
View File
@@ -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))
+25
View File
@@ -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