Files
live-hub-py/tests/test_login_attempt_isolation.py
T
yml2213 6a25097412 fix(douyu): 替换 CK 是最后一步,只有登录成功才写回记录
上一版把可见性改成「记录里还有 CK」,账号不会再消失,但根因还在:批次启动
时就把复用的成功记录改成 pending(旧 CK 仍在),停止或失败后没人恢复状态,
记录一直显示成一次没完成的登录。

按「替换 CK 是最后一步」把两者拆开:

- login_tasks 新增 attempt_status / attempt_message / attempt_batch_id /
  attempt_at,批次只写 attempt_*,status/cookie/message 只在登录成功时更新;
- _begin_attempt 只登记尝试,_mark_attempt_failed 只记录失败(已有 CK 的记录
  原样不动),_write_login_success 是唯一替换旧 CK 的入口;
- 停止批次、代理不可用、登录异常、进程重启清理全部改为收敛 attempt_*,
  因此停止一个批次不可能再让账号失去 CK;
- 复用的记录改用「持有 CK 的记录」,不再创建第二条记录,失败记录在无 CK 时
  才复用;
- 登录任务列表/统计按 attempt_* 展示「这次登录怎么样了」,批次筛选同时匹配
  attempt_batch_id;CK 操作页的重登状态也从 attempt_* 映射;
- 迁移 20261001_0035 归一存量数据:持有 CK 的记录 status 统一为 success、
  历史状态搬进 attempt_*,无 CK 且卡在 pending/running 的收敛为 failed。

新增测试:批次失败/停止不碰旧 CK、成功后替换 CK、列表展示尝试状态、
迁移存量数据归一。
2026-10-01 18:29:49 +08:00

225 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""回归:替换 CK 必须是最后一步,只有登录成功才会写回记录。
事故背景:批次启动时会把复用的成功记录直接改成 pending(旧 CK 仍保留),
批次被停止或失败后没有任何地方把它改回成功,账号于是从工作台消失。
现在尝试状态独立存放在 attempt_*,status/cookie/message 只在登录成功时
更新,所以停止、失败、进程重启都不可能破坏账号已有的 CK。
"""
import tempfile
from pathlib import Path
from types import SimpleNamespace
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
import web.backend.database as database_module
from web.backend.database import Base
from web.backend.models import Account, LoginTask, User
from web.backend.routers.login import list_tasks
from web.backend.services import login_service
class _FakeLogin:
"""替身登录器:不发网络请求,结果由类属性控制。"""
success = True
cookie = "new-cookie"
message = "登录成功"
code = None
calls = 0
def __init__(self, *_args, **_kwargs):
pass
def login(self):
type(self).calls += 1
return SimpleNamespace(
success=type(self).success,
cookie=type(self).cookie if type(self).success else "",
message=type(self).message,
code=type(self).code,
)
def check_account(self):
return self.login()
class TestLoginAttemptIsolation:
def setup_method(self):
self.tmpdir = tempfile.TemporaryDirectory()
url = f"sqlite:///{Path(self.tmpdir.name) / 'attempt.db'}"
self.engine = create_engine(url, connect_args={"check_same_thread": False})
Base.metadata.create_all(self.engine)
# 批次执行器在后台线程里用 SessionLocal,测试期间指向同一个文件库。
database_module.SessionLocal.configure(bind=self.engine)
self.session = sessionmaker(bind=self.engine)()
self.user = User(username="admin", password_hash="hash", role="super_admin")
self.session.add(self.user)
self.session.flush()
self.account = Account(
username="远行者103",
password="password",
email="yuanxingzhe@example.com",
email_password="mail-password",
)
self.session.add(self.account)
self.session.flush()
self.task = LoginTask(
batch_id="old-batch",
account_id=self.account.id,
created_by=self.user.id,
status="success",
cookie="old-cookie",
message="登录成功",
)
self.session.add(self.task)
self.session.commit()
self.task_id = self.task.id
_FakeLogin.success = True
_FakeLogin.cookie = "new-cookie"
_FakeLogin.message = "登录成功"
_FakeLogin.calls = 0
def teardown_method(self):
database_module.SessionLocal.configure(bind=database_module.engine)
self.session.close()
Base.metadata.drop_all(self.engine)
self.engine.dispose()
self.tmpdir.cleanup()
def _run_batch(self, monkeypatch, *, mode="login", account_ids=None, stop=False):
monkeypatch.setattr(login_service, "DouyuLogin", _FakeLogin)
# 批次结束时 runner 会关掉自己的会话,因此单独给它一个。
runner_db = sessionmaker(bind=self.engine)()
runner = login_service.LoginBatchRunner(
db=runner_db,
account_ids=account_ids if account_ids is not None else [self.account.id],
created_by=self.user.id,
creator_permissions=["login:view_all"],
proxy_config=None,
concurrency=1,
mode=mode,
)
if stop:
runner.stop()
runner.run()
return runner
def _fresh(self, task_id: int) -> LoginTask:
session = sessionmaker(bind=self.engine)()
try:
return session.query(LoginTask).filter(LoginTask.id == task_id).one()
finally:
session.close()
def _fresh_user(self, session) -> User:
return session.query(User).filter(User.id == self.user.id).one()
def test_failed_attempt_keeps_existing_cookie(self, monkeypatch):
_FakeLogin.success = False
_FakeLogin.message = "账号密码错误"
self._run_batch(monkeypatch)
task = self._fresh(self.task_id)
# 记录持有 CK 的字段完全没被碰过
assert task.status == "success"
assert task.cookie == "old-cookie"
assert task.message == "登录成功"
assert task.batch_id == "old-batch"
# 失败只体现在尝试字段上
assert task.attempt_status == "failed"
assert task.attempt_message == "账号密码错误"
assert task.attempt_at is not None
def test_failed_attempt_reuses_the_cookie_row(self, monkeypatch):
_FakeLogin.success = False
_FakeLogin.message = "账号密码错误"
self._run_batch(monkeypatch)
session = sessionmaker(bind=self.engine)()
try:
rows = (
session.query(LoginTask)
.filter(LoginTask.account_id == self.account.id)
.all()
)
finally:
session.close()
assert [row.id for row in rows] == [self.task_id]
def test_successful_attempt_replaces_cookie(self, monkeypatch):
self._run_batch(monkeypatch)
task = self._fresh(self.task_id)
assert task.status == "success"
assert task.cookie == "new-cookie"
assert task.message == "登录成功"
assert task.attempt_status == "success"
assert task.attempt_at is not None
assert task.batch_id != "old-batch"
def test_stopped_batch_keeps_existing_cookie(self, monkeypatch):
self._run_batch(monkeypatch, stop=True)
task = self._fresh(self.task_id)
assert _FakeLogin.calls == 0
assert task.cookie == "old-cookie"
assert task.status == "success"
assert task.attempt_status == "failed"
assert "旧 Cookie 已保留" in task.attempt_message
def test_list_tasks_reports_attempt_state_without_losing_ck_row(self, monkeypatch):
_FakeLogin.success = False
_FakeLogin.message = "账号密码错误"
runner = self._run_batch(monkeypatch)
read_db = sessionmaker(bind=self.engine)()
try:
rows = list_tasks(
batch_id=runner.batch_id, db=read_db, current=self._fresh_user(read_db)
)
finally:
read_db.close()
assert len(rows) == 1
row = rows[0]
# 页面看到的是「这次尝试怎么样了」,而记录仍然持有 CK
assert row.status == "failed"
assert row.message == "账号密码错误"
assert row.batch_id == runner.batch_id
assert self._fresh(self.task_id).cookie == "old-cookie"
def test_account_without_cookie_still_reports_failure(self, monkeypatch):
"""首次登录失败的账号没有 CK 需要保护,保持原来的失败语义。"""
fresh_account = Account(
username="no-cookie",
password="password",
email="nocookie@example.com",
email_password="mail-password",
)
self.session.add(fresh_account)
self.session.commit()
_FakeLogin.success = False
_FakeLogin.message = "账号密码错误"
self._run_batch(monkeypatch, account_ids=[fresh_account.id])
session = sessionmaker(bind=self.engine)()
try:
task = (
session.query(LoginTask)
.filter(LoginTask.account_id == fresh_account.id)
.one()
)
assert task.status == "failed"
assert task.message == "账号密码错误"
assert task.attempt_status == "failed"
assert task.cookie == ""
finally:
session.close()