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 落地时要把自动化选行的状态过滤一起补上。
This commit is contained in:
yml2213
2026-10-04 14:10:13 +08:00
parent e916a7015d
commit 00125fbfc4
26 changed files with 1380 additions and 1068 deletions
+195 -102
View File
@@ -10,6 +10,11 @@
结算逻辑(``settle_results``)与选行查询(``build_select_query``)都刻意抽成了
纯函数/可编译对象,这里不碰真实登录链路。
CK 解耦(2026-10-04)后,**选行与退避进度的单位是账号**:``ck_check_status`` 与
``relogin_*`` 都落到了 ``accounts`` 上,``login_tasks`` 退化为纯登录尝试历史
(本轮只读它的 ``attempt_status`` / ``attempt_message`` / ``attempt_batch_id``)。
所以这里建的是 Account;只有「在途」这类**某次尝试**的属性才补一条 LoginTask。
"""
from datetime import UTC, datetime, timedelta
@@ -37,43 +42,59 @@ class _Base:
def teardown_method(self):
self.session.close()
def make_task(
def make_account(
self,
name="acc",
*,
ck_check_status="invalid",
cookie="acf_uid=1",
attempt_status="",
attempt_message="",
relogin_attempts=0,
relogin_next_at=None,
relogin_failure_kind="",
relogin_last_error="",
auto_managed=True,
):
"""建一个账号 —— CK 与重登进度都是账号自己的属性。
``login_tasks`` 的 ``cookie`` / ``ck_check_*`` / ``relogin_*`` 旧列已不再被
新代码读写,这里也不往里写值:留作回退的列不该继续被测试固化。
"""
account = Account(
username=name,
password="p",
email=f"{name}@e.com",
email_password="ep",
auto_managed=auto_managed,
)
self.session.add(account)
self.session.commit()
task = LoginTask(
batch_id="b",
account_id=account.id,
created_by=self.admin.id,
status="success",
cookie=cookie,
ck_check_status=ck_check_status,
attempt_status=attempt_status,
attempt_message=attempt_message,
relogin_attempts=relogin_attempts,
relogin_next_at=relogin_next_at,
relogin_failure_kind=relogin_failure_kind,
relogin_last_error=relogin_last_error,
)
self.session.add(account)
self.session.commit()
return account
def make_attempt(
self,
account,
*,
attempt_status="",
attempt_message="",
attempt_batch_id="",
status="success",
):
"""建一条登录尝试历史(本轮重登的那条记录)。"""
task = LoginTask(
batch_id="b",
account_id=account.id,
created_by=self.admin.id,
status=status,
attempt_status=attempt_status,
attempt_message=attempt_message,
attempt_batch_id=attempt_batch_id,
)
self.session.add(task)
self.session.commit()
return task
@@ -155,61 +176,68 @@ class TestBackoff(_Base):
class TestSelection(_Base):
"""选行:``build_select_query`` 现在按**账号**返回,断言取 ``account.id``。"""
def _ids(self, job):
now = datetime.now(UTC)
return [
t.id
for t in ck_relogin.build_select_query(
account.id
for account in ck_relogin.build_select_query(
self.session, job, now, max_attempts=2
).all()
]
def test_picks_invalid_with_expired_backoff(self):
job = self.job()
ready = self.make_task("ready")
ready = self.make_account("ready")
assert self._ids(job) == [ready.id]
def test_skips_valid_and_empty_cookie_and_opted_out(self):
job = self.job()
keep = self.make_task("keep")
self.make_task("valid", ck_check_status="valid")
self.make_task("nocookie", cookie="")
self.make_task("optedout", auto_managed=False)
keep = self.make_account("keep")
self.make_account("valid", ck_check_status="valid")
self.make_account("nocookie", cookie="")
self.make_account("optedout", auto_managed=False)
assert self._ids(job) == [keep.id]
def test_skips_inflight_attempt(self):
"""「在途」是某次尝试的属性,仍然要靠 login_tasks 表达。"""
job = self.job()
keep = self.make_task("keep")
self.make_task("running", attempt_status="running")
self.make_task("pending", attempt_status="pending")
keep = self.make_account("keep")
running = self.make_account("running")
self.make_attempt(running, attempt_status="running")
pending = self.make_account("pending")
self.make_attempt(pending, attempt_status="pending")
assert self._ids(job) == [keep.id]
def test_respects_backoff_window(self):
job = self.job()
due = self.make_task(
due = self.make_account(
"due", relogin_next_at=datetime.now(UTC) - timedelta(minutes=1)
)
self.make_task("later", relogin_next_at=datetime.now(UTC) + timedelta(hours=3))
self.make_account(
"later", relogin_next_at=datetime.now(UTC) + timedelta(hours=3)
)
assert self._ids(job) == [due.id]
def test_skips_when_attempts_exhausted(self):
job = self.job()
keep = self.make_task("keep")
self.make_task("exhausted", relogin_attempts=2)
keep = self.make_account("keep")
self.make_account("exhausted", relogin_attempts=2)
assert self._ids(job) == [keep.id]
def test_never_retries_manual_kinds(self):
"""已判定极验/凭证问题的账号,绝不能再次自动重登。"""
job = self.job()
keep = self.make_task("keep")
self.make_task("captcha", relogin_failure_kind="captcha")
self.make_task("credential", relogin_failure_kind="credential")
keep = self.make_account("keep")
self.make_account("captcha", relogin_failure_kind="captcha")
self.make_account("credential", relogin_failure_kind="credential")
assert self._ids(job) == [keep.id]
def test_retryable_kinds_stay_eligible(self):
job = self.job()
code = self.make_task("code", relogin_failure_kind="email_code")
net = self.make_task("net", relogin_failure_kind="network")
code = self.make_account("code", relogin_failure_kind="email_code")
net = self.make_account("net", relogin_failure_kind="network")
assert sorted(self._ids(job)) == sorted([code.id, net.id])
def test_generated_sql_is_mysql_compatible(self):
@@ -224,20 +252,24 @@ class TestSelection(_Base):
def test_batch_size_caps_the_round(self):
job = self.job(batch_size=3)
for index in range(6):
self.make_task(f"a{index}")
self.make_account(f"a{index}")
assert len(self._ids(job)) == 3
class TestSettlement(_Base):
"""结算:成功清零;失败按分型决定重试还是转人工。"""
"""结算:成功清零;失败按分型决定重试还是转人工。
def _settle(self, tasks, *, max_attempts=2):
解耦后决策看「账号 + 本轮的尝试记录」:成败读 ``LoginTask.attempt_status``,
退避进度写**账号**的 ``relogin_*``。
"""
def _settle(self, pairs, *, max_attempts=2):
now = datetime.now(UTC)
fresh = {t.id: t for t in tasks}
fresh = {account.id: (account, task) for account, task in pairs}
counters = ck_relogin.settle_results(
self.session,
fresh,
[t.id for t in tasks],
[account.id for account, _ in pairs],
now=now,
max_attempts=max_attempts,
)
@@ -245,77 +277,85 @@ class TestSettlement(_Base):
return counters, now
def test_success_resets_state(self):
task = self.make_task(
account = self.make_account(
"ok",
attempt_status="success",
relogin_attempts=1,
relogin_failure_kind="email_code",
relogin_last_error="旧的报错",
relogin_next_at=datetime.now(UTC) + timedelta(hours=1),
)
counters, _ = self._settle([task])
self.session.refresh(task)
task = self.make_attempt(account, attempt_status="success")
counters, _ = self._settle([(account, task)])
self.session.refresh(account)
assert counters["valid"] == 1 and counters["invalid"] == 0
assert task.relogin_attempts == 0
assert task.relogin_next_at is None
assert task.relogin_failure_kind == ""
assert task.relogin_last_error == ""
assert account.relogin_attempts == 0
assert account.relogin_next_at is None
assert account.relogin_failure_kind == ""
assert account.relogin_last_error == ""
def test_retryable_failure_schedules_next_attempt(self):
task = self.make_task(
"retry",
account = self.make_account("retry")
task = self.make_attempt(
account,
attempt_status="failed",
attempt_message="等待验证码超时(旧 Cookie 已保留)",
)
counters, now = self._settle([task])
self.session.refresh(task)
counters, now = self._settle([(account, task)])
self.session.refresh(account)
assert counters["invalid"] == 1 and counters["retry"] == 1
assert task.relogin_failure_kind == "email_code"
assert task.relogin_attempts == 1
assert task.relogin_next_at is not None and task.relogin_next_at > now
assert "旧 Cookie" in task.relogin_last_error
assert account.relogin_failure_kind == "email_code"
assert account.relogin_attempts == 1
assert account.relogin_next_at is not None and account.relogin_next_at > now
assert "旧 Cookie" in account.relogin_last_error
def test_captcha_goes_to_manual_without_schedule(self):
task = self.make_task(
"cap", attempt_status="failed", attempt_message="极验验证失败: slide"
account = self.make_account("cap")
task = self.make_attempt(
account, attempt_status="failed", attempt_message="极验验证失败: slide"
)
counters, _ = self._settle([task])
self.session.refresh(task)
counters, _ = self._settle([(account, task)])
self.session.refresh(account)
assert counters["manual"] == 1 and counters["retry"] == 0
assert task.relogin_failure_kind == "captcha"
assert task.relogin_next_at is None # 不再自动排期
assert account.relogin_failure_kind == "captcha"
assert account.relogin_next_at is None # 不再自动排期
def test_quota_backs_off_to_next_day(self):
task = self.make_task(
"quota",
account = self.make_account("quota")
task = self.make_attempt(
account,
attempt_status="failed",
attempt_message="您的邮箱验证码发送次数已达上限,请改天再试!",
)
counters, now = self._settle([task])
self.session.refresh(task)
counters, now = self._settle([(account, task)])
self.session.refresh(account)
assert counters["email_quota"] == 1 and counters["retry"] == 1
assert task.relogin_next_at.date() == (now + timedelta(days=1)).date()
assert account.relogin_next_at.date() == (now + timedelta(days=1)).date()
def test_attempts_exhausted_stops_retrying(self):
task = self.make_task(
"used",
account = self.make_account("used", relogin_attempts=1)
task = self.make_attempt(
account,
attempt_status="failed",
attempt_message="等待验证码超时",
relogin_attempts=1,
)
counters, _ = self._settle([task], max_attempts=2)
self.session.refresh(task)
counters, _ = self._settle([(account, task)], max_attempts=2)
self.session.refresh(account)
assert counters["manual"] == 1 and counters["retry"] == 0
assert task.relogin_next_at is None
assert account.relogin_next_at is None
def test_settlement_never_touches_cookie(self):
task = self.make_task(
"keepck", attempt_status="failed", attempt_message="极验验证失败: slide"
"""结算只改账号的 ``relogin_*``,绝不碰账号持有的 CK。"""
account = self.make_account("keepck", cookie="acf_uid=1")
task = self.make_attempt(
account, attempt_status="failed", attempt_message="极验验证失败: slide"
)
before = (task.cookie, task.status)
self._settle([task])
self.session.refresh(task)
assert (task.cookie, task.status) == before
before = (account.cookie, account.status)
self._settle([(account, task)])
self.session.refresh(account)
assert (account.cookie, account.status) == before
assert account.cookie == "acf_uid=1"
# 顺带确认结算确实跑了:分型写上了,而 CK 仍原封不动。
assert account.relogin_failure_kind == "captcha"
class TestManualQueueApi(_Base):
@@ -327,56 +367,79 @@ class TestManualQueueApi(_Base):
return api.manual_queue(limit=20, db=self.session, current=self.admin)
def test_groups_manual_kinds_only(self):
captcha = self.make_task(
captcha = self.make_account(
"cap",
attempt_status="failed",
attempt_message="极验验证失败: slide",
relogin_failure_kind="captcha",
relogin_attempts=1,
relogin_last_error="极验验证失败: slide",
)
self.make_task(
self.make_attempt(
captcha, attempt_status="failed", attempt_message="极验验证失败: slide"
)
cred = self.make_account(
"cred",
attempt_status="failed",
attempt_message="账号缺少密码或邮箱",
relogin_failure_kind="credential",
relogin_attempts=1,
relogin_last_error="账号缺少密码或邮箱",
)
self.make_attempt(
cred, attempt_status="failed", attempt_message="账号缺少密码或邮箱"
)
# 可自动重试的不该出现在人工队列里
self.make_task(
retryable = self.make_account(
"retryable",
attempt_status="failed",
attempt_message="等待验证码超时",
relogin_failure_kind="email_code",
relogin_attempts=1,
relogin_next_at=datetime.now(UTC) + timedelta(hours=1),
relogin_last_error="等待验证码超时",
)
self.make_attempt(
retryable, attempt_status="failed", attempt_message="等待验证码超时"
)
payload = self._queue()
kinds = [group["kind"] for group in payload["groups"]]
assert sorted(kinds) == ["captcha", "credential"]
assert payload["total"] == 2
captcha_group = next(g for g in payload["groups"] if g["kind"] == "captcha")
assert captcha_group["count"] == 1
assert captcha_group["samples"][0]["task_id"] == captcha.id
assert captcha_group["samples"][0]["account_id"] == captcha.id
assert "极验" in captcha_group["label"]
def test_exhausted_retries_show_up_as_manual(self):
"""次数用尽的(next_run_at 已清空)也要冒出来,否则会静默卡住。"""
self.make_task(
used = self.make_account(
"used",
attempt_status="failed",
attempt_message="等待验证码超时",
relogin_failure_kind="email_code",
relogin_attempts=2,
relogin_next_at=None,
relogin_last_error="等待验证码超时",
)
self.make_attempt(
used, attempt_status="failed", attempt_message="等待验证码超时"
)
payload = self._queue()
assert payload["total"] == 1
assert "重试次数用尽" in payload["groups"][0]["label"]
def test_sample_last_error_comes_from_account(self):
"""样本里的 last_error 取账号的 ``relogin_last_error``,不回退到尝试文案。"""
account = self.make_account(
"cap",
relogin_failure_kind="captcha",
relogin_attempts=1,
relogin_last_error="账号侧记录的失败原因",
)
self.make_attempt(
account,
attempt_status="failed",
attempt_message="这是尝试记录里的文案,不该被当成账号的最后错误",
)
payload = self._queue()
assert payload["total"] == 1
assert (
payload["groups"][0]["samples"][0]["last_error"] == "账号侧记录的失败原因"
)
def test_empty_queue(self):
payload = self._queue()
assert payload["total"] == 0 and payload["groups"] == []
@@ -386,15 +449,16 @@ class TestLinkageWithHealthJob(_Base):
"""两个作业是上下游:jobs 接口要暴露「当前有多少条等着自动重登」。
页面靠这个数字把巡检 → 重登的关系显性化;没有它,两个开关看起来像互不相干。
解耦后 ``count_pending_relogin`` 按**账号**计数。
"""
def test_pending_relogin_counts_only_actionable_invalid(self):
from web.backend.routers import automation as api
ready = self.make_task("ready")
self.make_task("valid", ck_check_status="valid")
self.make_task("nocookie", cookie="")
self.make_task("optedout", auto_managed=False)
ready = self.make_account("ready")
self.make_account("valid", ck_check_status="valid")
self.make_account("nocookie", cookie="")
self.make_account("optedout", auto_managed=False)
payload = api.list_jobs(db=self.session, current=self.admin)
# 只有 ready 同时满足:invalid + 有 CK + 允许自动管理
@@ -412,7 +476,7 @@ class TestLinkageWithHealthJob(_Base):
from web.backend.routers import automation as api
for index in range(25):
self.make_task(f"acc{index}")
self.make_account(f"acc{index}")
payload = api.list_jobs(db=self.session, current=self.admin)
relogin = next(j for j in payload["jobs"] if j["key"] == ck_relogin.JOB_KEY)
assert relogin["batch_size"] == 10
@@ -428,10 +492,21 @@ class TestSettlementVisibility(_Base):
SQLite 的读一致性不同,这条在 SQLite 上复现不出来 —— 属于「测试跑在 SQLite
就漏掉」的典型,现在测试跑在 MySQL 上才守得住。
解耦后本轮记录靠 ``LoginTask.attempt_batch_id`` 找回,退避进度落在账号上。
"""
def test_settlement_sees_other_session_commit(self):
task = self.make_task("acc", attempt_status="pending")
account = self.make_account(
"acc",
relogin_attempts=1,
relogin_failure_kind="email_code",
relogin_last_error="上一次的失败",
relogin_next_at=datetime.now(UTC) + timedelta(hours=1),
)
task = self.make_attempt(
account, attempt_status="pending", attempt_batch_id="batch-visibility"
)
# 调用方会话先开事务(模拟调度器挑完候选后的状态)。
stale = make_test_session(self.engine)
@@ -455,20 +530,38 @@ class TestSettlementVisibility(_Base):
), "REPEATABLE READ 下同一事务读不到别人的提交,这正是当初踩的坑"
counters = ck_relogin.settle_fresh_results(
[task.id], now=datetime.now(UTC), max_attempts=2
"batch-visibility", now=datetime.now(UTC), max_attempts=2
)
self.session.expire_all()
assert counters["valid"] == 1 and counters["invalid"] == 0
assert self.session.get(LoginTask, task.id).relogin_failure_kind == ""
# 结算写在自己的事务里。这里必须再用**一个全新会话**读回:当前测试会话的
# 事务快照在上面读 ``task.id``(commit 后属性已过期,取用即触发刷新)时
# 就已经开了,复用它会看到结算前的旧值 —— 正是这条用例要守的坑。
checker = make_test_session(self.engine)
settled = checker.get(Account, account.id)
assert settled.relogin_failure_kind == ""
assert settled.relogin_attempts == 0
assert settled.relogin_next_at is None
checker.close()
stale.close()
def test_settlement_marks_real_success_as_valid(self):
"""结算看到 success 就必须清零退避,不能写成失败。"""
task = self.make_task("acc", attempt_status="success", relogin_attempts=1)
account = self.make_account(
"acc",
relogin_attempts=1,
relogin_failure_kind="email_code",
relogin_last_error="上一次的失败",
relogin_next_at=datetime.now(UTC) + timedelta(hours=1),
)
task = self.make_attempt(account, attempt_status="success")
now = datetime.now(UTC)
counters = ck_relogin.settle_results(
self.session, {task.id: task}, [task.id], now=now, max_attempts=2
self.session,
{account.id: (account, task)},
[account.id],
now=now,
max_attempts=2,
)
assert counters["valid"] == 1 and counters["invalid"] == 0
assert task.relogin_failure_kind == ""
assert task.relogin_next_at is None
assert account.relogin_failure_kind == ""
assert account.relogin_next_at is None