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
+40 -35
View File
@@ -11,7 +11,7 @@ from fastapi import (
WebSocketDisconnect,
)
from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session, defer, load_only
from sqlalchemy.orm import Session, load_only
from ..database import SessionLocal, get_db
from ..deps import authenticate_websocket, get_current_user, require_permission
@@ -142,12 +142,11 @@ async def create_batch(
def _visible_tasks_query(db: Session, current: User, batch_id: str | None):
"""按权限与批次过滤登录任务。客服只能看自己名下账号的任务。"""
query = (
db.query(LoginTask)
.options(defer(LoginTask.cookie))
.join(Account, LoginTask.account_id == Account.id)
)
"""按权限与批次过滤登录任务。客服只能看自己名下账号的任务。
这里已经**不需要 defer cookie** —— 登录记录不再持有凭证(2026-10-04 CK 解耦)。
"""
query = db.query(LoginTask).join(Account, LoginTask.account_id == Account.id)
if not user_has_permission(current, "login:view_all"):
query = query.filter(Account.assigned_to == current.id)
@@ -255,19 +254,34 @@ def tasks_summary(
return _login_task_summary(query)
def _holds_ck_clause():
"""判定登录记录是否仍**持有 CK**(与 `cookie_holding_filter` 同口径)。
def _in_flight_clause():
"""不可删除的记录:正被批次处理的**在途尝试**。
⚠️ 删除登录记录会连带删掉它持有的 CK —— 斗鱼账号的 CK **只存在
`login_tasks.cookie` 里**(`accounts` 表没有 cookie 列)。删掉就再也找不回来,
所以删除接口一律只允许删「空 cookie」的历史记录。
⚠️ **CK 自 2026-10-04 起不再是护栏理由** —— CK 已迁到 `accounts.cookie`,
删登录历史不再影响任何账号的登录态(这正是解耦的目的)。但「在途」必须保留:
删掉会让批次线程找不到记录;停批次请走 ``/stop/{batch_id}``。
"""
return and_(LoginTask.cookie.isnot(None), LoginTask.cookie != "")
in_flight = ("pending", "running")
return or_(
and_(
LoginTask.attempt_status != "",
LoginTask.attempt_status.in_(in_flight),
),
and_(LoginTask.attempt_status == "", LoginTask.status.in_(in_flight)),
)
def _deletable_clause():
"""可安全删除的记录:没有持有 CK。"""
return or_(LoginTask.cookie.is_(None), LoginTask.cookie == "")
"""可安全删除的记录:不在途。"""
return ~_in_flight_clause()
def _is_in_flight(task: LoginTask) -> bool:
"""单条记录是否在途(与 `_in_flight_clause` 同口径)。"""
in_flight = ("pending", "running")
if task.attempt_status:
return task.attempt_status in in_flight
return (task.status or "") in in_flight
@router.delete("/tasks/{task_id}")
@@ -276,14 +290,14 @@ def delete_task(
db: Session = Depends(get_db),
current: User = Depends(require_permission("login:batch")),
):
"""删除单个登录任务(**持有 CK 的记录不允许删除**)。"""
"""删除单个登录任务(**在途的记录不允许删除**;删历史不影响 CK)。"""
task = db.query(LoginTask).filter(LoginTask.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
if task.cookie:
if _is_in_flight(task):
raise HTTPException(
status_code=409,
detail="该记录仍持有 CK,删除会丢失账号的登录态;如需清理请先处理该账号的 CK",
detail="该记录正在被批次处理,删除会让批次失去目标;请先停止批次",
)
db.delete(task)
db.commit()
@@ -307,22 +321,13 @@ def delete_tasks(
两条护栏(都会在响应里报告跳过数量):
- **持有 CK 的记录一律不删**:斗鱼账号的 CK 只存在 `login_tasks.cookie`,
删掉即永久丢失登录态。列表显示的「状态」是最近一次尝试
(``attempt_status``),一条显示「失败」的记录完全可能仍持有有效 CK ——
曾因此丢过一批 CK,所以这里按 cookie 而不是按显示状态判断。
- **进行中(pending/running)的记录不删**:删掉会让批次线程找不到记录;
- **在途(pending/running)的记录不删**:删掉会让批次线程找不到记录;
停批次请走 ``/stop/{batch_id}``。
- ~~持有 CK 的记录不删~~ —— **这条护栏已随 CK 解耦(2026-10-04)取消**:
CK 现在挂在账号上,删登录历史不影响登录态。历史终于可以自由清理。
"""
# 护栏:不删在途任务,避免批次线程与数据库状态打架。
in_flight = ("pending", "running")
not_in_flight = or_(
and_(
LoginTask.attempt_status != "",
LoginTask.attempt_status.notin_(in_flight),
),
and_(LoginTask.attempt_status == "", LoginTask.status.notin_(in_flight)),
)
not_in_flight = _deletable_clause()
if task_ids:
ids = [int(x) for x in task_ids.split(",") if x.strip().isdigit()]
@@ -355,16 +360,16 @@ def delete_tasks(
}
rows = (
db.query(LoginTask.id, LoginTask.cookie, not_in_flight.label("ok_to_delete"))
db.query(LoginTask.id, not_in_flight.label("ok_to_delete"))
.filter(LoginTask.id.in_(ids))
.all()
)
target_ids = [r[0] for r in rows if r[2] and not r[1]]
target_ids = [r[0] for r in rows if r[1]]
skipped = len(rows) - len(target_ids)
if not target_ids:
return {
"message": f"没有可删除的记录({skipped} 条持有 CK 或在途,已跳过)",
"message": f"没有可删除的记录({skipped} 条在途,已跳过)",
"deleted": 0,
"skipped": skipped,
"success": True,
@@ -379,7 +384,7 @@ def delete_tasks(
message = f"已删除 {deleted} 个任务"
if skipped:
message += f"({skipped} 条持有 CK 或在途,已跳过)"
message += f"({skipped} 条在途,已跳过)"
return {"message": message, "deleted": deleted, "skipped": skipped, "success": True}