Files
live-hub-py/tests/test_login_attempt_isolation.py
T
yml2213 00125fbfc4 refactor(account): S2 读写一次切换 —— CK/健康/重登彻底搬到账号,登录记录退化为历史
S1 加好了列,本提交把**读与写一起切**过去。这是设计里「同一份数据的读与写必须
在同一个提交里切」的要求:先切读或先切写都会出现「写账号、读记录」的中间态,
跑起来不报错,只是默默用错数据。

## 核心改动

**判定「谁有 CK」不再借道登录记录**
- `cookie_holding_filter()` / `cookie_operable_filter()` / `cookie_holding_account_ids_query()`
  / `visible_douyu_task_accounts()` 全部改为作用于 `Account`;
- `cookie_operable_filter()` 的「在途排除」仍查 `login_tasks`(那是**某次尝试**的属性),
  用 `NOT EXISTS` 子查询 + 迁移 0042 新增的 `(account_id, attempt_status)` 索引;
- 删除死代码 `latest_cookie_login_task()`;`latest_cookie()` 改为直接读账号。

**CK 写入**
- `_write_login_success()` 写 `Account.cookie / cookie_updated_at / cookie_source`;
- `_mark_attempt_failed()` 改看**账号**有没有 CK(决定是否沿用旧的失败语义);
- 检测模式的 `task.cookie = ""` **不再清空账号的 CK**(见下「行为变更」)。

**自动化作业按账号调度**
- `ck_health`:`_select_tasks` → `_select_accounts`,`db.query(Account)`;新增
  `count_remaining()`(刻意不带 LIMIT,否则积压数恒等于批次上限);
- `ck_relogin`:同样按账号选行;结算改为「账号 + 本轮尝试记录」,
  退避进度写账号;`settle_fresh_results(batch_id)` 按 `attempt_batch_id` 配对,
  `run_ck_relogin` 把 **account_ids** 交给 runner;
- 顺带让**没有任何登录历史的账号**也能被巡检/重登(以前它们永远进不了队列)。

**登录批次**
- `LoginBatchRunner` 去掉 `relogin_task_ids`,重登与登录统一走 `account_ids`;
- 批次行复用逻辑重写:只保证「同一账号不并发两行」,不再关心「哪一行持有 CK」。
  失败行复用同时看 `attempt_status`(账号有 CK 时 `_mark_attempt_failed` 刻意不动
  `status`,只看 status 会永远命不中 → 每次失败刷一行)。

**删除护栏语义变更(用户可感知)**
- 去掉「持有 CK 的记录不可删」——CK 在账号上,删历史不再影响登录态,历史终于可清理;
- **保留**「在途尝试不可删」(批次线程正拿着那条记录)。

## 行为变更(需评审)

1. **账号检测不再清空 CK**。旧代码在 check 模式写 `task.cookie = ""`,而那时记录的
   cookie 就是账号唯一凭证 —— 一次「批量检测」会把账号登录态删掉。解耦后
   「检测账号凭据」与「CK 是否有效」本就是两件事(后者归 `ck_health` 判),
   继续清空等于凭空制造不可逆的数据丢失(重登当前成功率极低)。
2. **Cookie 页的记录单位从「登录记录」变成「账号」**,所有接口的 id 入参/出参
   变成 `account_id`;`DELETE /cookies/{id}` 清的是该账号的 CK,**必写审计**。
3. 重复检测从「同一账号多行持 CK」改为「**同一个 CK 出现在多个账号上**」——
   后者才是真风险(两个账号共用一个登录态)。用 sha256 指纹分组:
   CK 是 AES-GCM + 随机 nonce 加密,**同一明文每次密文都不同**,比密文永远比不出重复。

## 修掉一个真实并发缺陷

`_write_login_success` 原先被传入 `self.db`(runner 主会话),而它是在**工作线程**里
用 `worker_db` 执行的。`emit()` 会往 `account_events` 插一行,其 `account_id` 外键对
`accounts` 那一行加**共享锁**;这把锁加在另一个会话上,紧接着本会话的
`UPDATE accounts SET cookie=...` 就被自己人挡住 —— 实测 **50 秒后
`Lock wait timeout exceeded`,登录明明成功却写不回 CK**(改造前 UPDATE 打在
`login_tasks` 上所以没撞上)。改为传 `worker_db`:事件本就该和状态变更同事务提交,
顺带修掉跨线程共用一个 Session 的隐患。

## 验证

- 全量 pytest **368 passed / 6 skipped**;迁移链 3 passed;
- 前端 `tsc -b && vite build` 通过;
- `ruff check` / `ruff format --check` 全绿(顺带修掉 main 上遗留的 F821);
- 新增/改写的关键回归:**删掉全部登录历史后账号的 CK 与健康状态仍在**、
  **无登录历史的账号也能进巡检队列**、结算不碰 CK、删除护栏改为在途语义。

仍未做(设计里排期的后续阶段):`account_lifecycle` 状态收口(S3)、事件发射点补齐(S4)、
业务事实直发事件(S5)、账号页重构(S6)。注意 `status` 目前只有模型默认值与迁移回填,
所以 `disabled/archived` 还没有任何写入方——S3 落地时要把自动化选行的状态过滤一起补上。
2026-10-04 14:10:13 +08:00

239 lines
8.5 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_*,而 CK 自 2026-10-04 起挂在**账号**上
(`accounts.cookie`),登录记录只是一次尝试的历史 ——
停止、失败、进程重启都不可能破坏账号已有的 CK。
"""
from types import SimpleNamespace
from sqlalchemy.orm import sessionmaker
import web.backend.database as database_module
from tests.support.db import get_test_engine, make_test_session
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.engine = get_test_engine()
database_module.SessionLocal.configure(bind=self.engine)
self.session = make_test_session(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",
# CK 是账号的属性(解耦前它挂在 login_tasks.cookie 上)
cookie="old-cookie",
)
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",
message="登录成功",
# 上一次尝试失败过 —— 解耦后「复用行为」按 attempt_status 判定,
# 批次会复用这一行继续重试(而不是每次失败都新建一行)。
attempt_status="failed",
attempt_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):
self.session.close()
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_account(self) -> Account:
"""重新读账号 —— CK 现在挂在它身上,断言要跨会话读才作数。"""
session = sessionmaker(bind=self.engine)()
try:
return session.query(Account).filter(Account.id == self.account.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)
# 登录记录只是历史,它的状态字段没被碰过
assert task.status == "success"
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
# 账号的 CK 完全没被碰过
assert self._fresh_account().cookie == "old-cookie"
def test_failed_attempt_reuses_the_failed_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)
account = self._fresh_account()
assert task.status == "success"
assert task.message == "登录成功"
assert task.attempt_status == "success"
assert task.attempt_at is not None
assert task.batch_id != "old-batch"
# 唯一替换 CK 的入口:写的是**账号**
assert account.cookie == "new-cookie"
assert account.cookie_updated_at is not None
assert account.cookie_source == "web"
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 self._fresh_account().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_account().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 (
session.query(Account)
.filter(Account.id == fresh_account.id)
.one()
.cookie
== ""
)
finally:
session.close()