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
+144 -91
View File
@@ -11,7 +11,7 @@ from types import SimpleNamespace
from typing import cast
from loguru import logger
from sqlalchemy import or_
from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from core.douyu import DouyuLogin, IframeLoginAPI, WgapiLoginAPI
@@ -23,7 +23,7 @@ from ..models import LoginTask
from ..models import ProxyConfig as ProxyConfigModel
from .account_events_service import EVENT_LOGIN_OK, EVENT_RELOGIN_OK, emit
from .cookie_check_service import check_douyu_cookie
from .douyu_service import cookie_holding_filter
from .douyu_service import LOGIN_ATTEMPT_IN_FLIGHT_STATUSES
def _create_api_strategy(strategy_name: str):
@@ -124,13 +124,18 @@ def _begin_attempt(task: LoginTask, batch_id: str, message: str) -> None:
def _mark_attempt_failed(
task: LoginTask, message: str, *, status: str = "failed"
) -> None:
"""记录一次失败/被中断的尝试;已经持有 CK 的记录保持原样。"""
"""记录一次失败/被中断的尝试;**账号已有的 CK 原样不动**。
这里读的是 `task.account.cookie`(CK 自 2026-10-04 起挂在账号上),
而不是这条登录记录 —— 记录不再持有凭证。
"""
completed_at = datetime.now(UTC)
task.attempt_status = status if status in {"failed", "error"} else "failed"
task.attempt_message = message
task.attempt_at = completed_at
if not task.cookie:
# 还没有任何 CK 需要保护:保持旧的失败语义,便于复用记录与页面统计。
account = task.account
if not (account and account.cookie):
# 账号还没有任何 CK 需要保护:保持旧的失败语义,便于复用记录与页面统计。
task.status = task.attempt_status
task.message = message
task.finished_at = completed_at
@@ -145,14 +150,14 @@ def _write_login_success(
batch_id: str,
event_type: str = EVENT_LOGIN_OK,
) -> None:
"""登录成功后把新 CK 写回记录:这是唯一替换旧 CK 的入口。
"""登录成功后把新 CK 写到**账号**上:这是唯一替换旧 CK 的入口。
顺带记一条账号事件 —— CK 是**覆盖式**字段,换掉之后就看不出「这账号什么时候
登录成功过、换过几次 CK」。事件是旁路(emit 内部吞异常),记不上也不影响登录。
"""
completed_at = datetime.now(UTC)
account = task.account
task.status = "success"
task.cookie = cookie
task.message = message
task.batch_id = batch_id
task.finished_at = completed_at
@@ -161,6 +166,12 @@ def _write_login_success(
task.attempt_batch_id = batch_id
task.attempt_at = completed_at
if account is not None:
# CK 是**账号级凭证**,不是「某次登录的产物」—— 写账号,不写记录。
account.cookie = cookie
account.cookie_updated_at = completed_at
account.cookie_source = "web"
emit(
db,
account_id=task.account_id,
@@ -194,7 +205,6 @@ class LoginBatchRunner:
concurrency: int = 3,
api_strategy: str = "wgapi",
mode: str = "login",
relogin_task_ids: list[int] | None = None,
):
self.db = db
self.account_ids = account_ids
@@ -211,7 +221,6 @@ class LoginBatchRunner:
self.concurrency = max(1, min(concurrency, 10)) # 限制 1-10
self.api_strategy = _create_api_strategy(api_strategy)
self.mode = mode if mode in ("login", "check", "relogin") else "login"
self.relogin_task_ids = list(relogin_task_ids) if relogin_task_ids else []
self._stop = threading.Event()
self._counter_lock = threading.Lock()
self._completed = 0
@@ -273,6 +282,34 @@ class LoginBatchRunner:
finally:
worker_db.close()
def _mark_account_relogin_stopped(
self,
account_id: int,
message: str = "重新登录已停止,旧 Cookie 已保留",
) -> None:
"""按**账号**收敛在途的重登尝试(新模型下调度单位是账号)。
与 `_mark_relogin_stopped` 的区别只是入口:批次挂了但记录还没建出来时,
这里是唯一能把「该账号的在途尝试」收干净的路径。
"""
worker_db = SessionLocal()
try:
tasks = (
worker_db.query(LoginTask)
.filter(LoginTask.account_id == account_id)
.order_by(LoginTask.id.desc())
.all()
)
changed = False
for task in tasks:
if _attempt_in_flight(task):
_mark_attempt_failed(task, message)
changed = True
if changed:
worker_db.commit()
finally:
worker_db.close()
def _push_log(self, level: str, message: str):
# 即使没有页面实时日志,也要保留批次进度到 app.log,便于排查卡点。
if message:
@@ -313,8 +350,10 @@ class LoginBatchRunner:
task = worker_db.query(LoginTask).filter(LoginTask.id == task_id).first()
if not task:
return
# CK、健康与重登状态都挂在账号上;登录记录只是一次尝试的历史。
account = task.account
# 只推进尝试状态;记录持有的旧 CK 在成功前保持不变。
# 只推进尝试状态;账号持有的旧 CK 在成功前保持不变。
task.attempt_status = "running"
task.attempt_message = (
"正在重新登录,旧 Cookie 保留中"
@@ -402,7 +441,12 @@ class LoginBatchRunner:
else "account_auth_unknown"
)
task.status = status
task.cookie = ""
# ⚠️ 行为变更(2026-10-04 CK 解耦):**检测不再清空账号的 CK**。
# 旧代码在这里写 `task.cookie = ""`,而那时记录的 cookie 就是账号的
# 唯一凭证 —— 于是一次「批量检测」就会把账号的登录态删掉。CK 换到账号
# 上之后,「检测账号凭据」与「CK 是否有效」本就是两件事(后者归
# ck_health 判),继续清空等于凭空制造不可逆的数据丢失
# (重登当前成功率极低,丢了补不回来)。
task.message = result.message or CHECK_STATUS_MESSAGES[status]
# 检测结果本身就是这次尝试的结论,页面按 attempt_* 展示。
task.attempt_status = status
@@ -416,25 +460,35 @@ class LoginBatchRunner:
)
elif result.success:
message = result.message or "登录成功"
if self.mode == "relogin":
if self.mode == "relogin" and account is not None:
check_result = check_douyu_cookie(result.cookie)
task.ck_check_status = (
# 复检结论写账号(CK 在哪,健康状态就在哪)。
account.ck_check_status = (
"valid" if check_result["valid"] else "invalid"
)
task.ck_check_result = {
account.ck_check_result = {
"fish_ball": check_result["fish_ball"],
"nickname": check_result["nickname"],
"level": check_result["level"],
"message": check_result["message"],
}
task.ck_checked_at = check_result["checked_at"]
account.ck_checked_at = check_result["checked_at"]
if check_result["valid"]:
message = "重新登录成功,Cookie 有效"
else:
message = f"重新登录成功,但 Cookie 有效性检测失败: {check_result['message']}"
# 唯一替换旧 CK 的入口:登录成功后写回新 CK。
# 唯一替换旧 CK 的入口:登录成功后写回账号。
#
# ⚠️ 必须传 **worker_db**(本线程的会话),不能传 self.db:
# ① `self.db` 由主线程持有,SQLAlchemy Session 不是线程安全的;
# ② 更致命的是 `emit()` 会往 account_events 插一行,其
# `account_id` 外键会对 accounts 那一行加**共享锁**。如果这
# 把锁加在**另一个**会话上,紧接着本会话对同一个账号的
# `UPDATE accounts SET cookie=...` 就会被自己人挡住 ——
# 实测直接 50 秒后 `Lock wait timeout exceeded`,登录明明成功
# 却写不回 CK。事件本就该和状态变更在同一个事务里提交。
_write_login_success(
self.db,
worker_db,
task,
cookie=result.cookie,
message=message,
@@ -446,15 +500,20 @@ class LoginBatchRunner:
),
)
if self.mode == "relogin":
if task.ck_check_status == "valid":
if account is not None and account.ck_check_status == "valid":
self._push_log(
"success",
f"[{current}] {acc_info['username']} 重新登录成功,Cookie 已替换并验证有效",
)
else:
detail = (
account.ck_check_result.get("message")
if account is not None and account.ck_check_result
else ""
)
self._push_log(
"warning",
f"[{current}] {acc_info['username']} 重新登录成功,但 Cookie 有效性检测失败: {task.ck_check_result.get('message') if task.ck_check_result else ''}",
f"[{current}] {acc_info['username']} 重新登录成功,但 Cookie 有效性检测失败: {detail}",
)
else:
self._push_log(
@@ -509,7 +568,7 @@ class LoginBatchRunner:
)
self._push_log(
"info",
f"批量{action_name}任务 {batch_id} 开始,共 {len(self.account_ids or self.relogin_task_ids)} 个账号,并发数: {concurrency}",
f"批量{action_name}任务 {batch_id} 开始,共 {len(self.account_ids)} 个账号,并发数: {concurrency}",
)
# 批次开始前同步一次出口 IP 到白名单,后续 fetch_new_proxy 不再主动同步
@@ -550,88 +609,82 @@ class LoginBatchRunner:
try:
# 创建或复用任务记录(顺序执行,线程安全)
#
# **重登模式也从 account_ids 走**(2026-10-04 CK 解耦):以前重登传的是
# 「持有 CK 的记录 id」,因为 CK 挂在记录上;现在 CK 在账号上,调度单位就是
# 账号,登录记录只是「这次尝试」的历史 —— 顺带让**没有登录历史的账号**
# 也能被重登(以前它根本进不了候选)。
task_infos: list[dict] = [] # {task_id, acc_info}
if self.relogin_task_ids:
# 重新登录模式:复用指定 Cookie 记录,登录成功后原地替换 Cookie
for task_id in self.relogin_task_ids:
task = (
self.db.query(LoginTask).filter(LoginTask.id == task_id).first()
)
if not task:
continue
acc = (
self.db.query(AccountModel)
.filter(AccountModel.id == task.account_id)
.first()
)
if not acc:
self._push_log("warning", f"跳过无账号的任务 #{task_id}")
continue
if (
"login:view_all" not in self.creator_permissions
and acc.assigned_to != self.created_by
):
self._push_log("warning", f"跳过无权账号: {acc.username}")
continue
_append_task_info(task, acc)
else:
seen_account_ids = set()
for aid in self.account_ids:
if aid in seen_account_ids:
continue
seen_account_ids.add(aid)
acc = (
self.db.query(AccountModel)
.filter(AccountModel.id == aid)
.with_for_update()
.first()
)
if not acc:
continue
# 权限检查:客服只能跑分配给自己的
if (
"login:view_all" not in self.creator_permissions
and acc.assigned_to != self.created_by
):
self._push_log("warning", f"跳过无权账号: {acc.username}")
continue
seen_account_ids = set()
for aid in self.account_ids:
if aid in seen_account_ids:
continue
seen_account_ids.add(aid)
acc = (
self.db.query(AccountModel)
.filter(AccountModel.id == aid)
.with_for_update()
.first()
)
if not acc:
continue
# 权限检查:客服只能跑分配给自己的
if (
"login:view_all" not in self.creator_permissions
and acc.assigned_to != self.created_by
):
self._push_log("warning", f"跳过无权账号: {acc.username}")
continue
# 一个斗鱼账号只保留一条持有 CK 的记录:优先复用它,
# 登录成功后才把新 CK 写回同一条记录。
# 解耦后不再需要「哪一行持有 CK」这个不变量(CK 在账号上)。
# 这里只需要保证**同一账号不并发两行**:
# ① 复用该账号在途的尝试(避免并发两个批次各建一行);
# ② 否则复用最近一条失败/中断的历史,避免刷屏;
# ③ 都没有就新建一行(历史而已,允许一个账号多行)。
task = (
self.db.query(LoginTask)
.filter(
LoginTask.account_id == aid,
or_(
func.coalesce(LoginTask.attempt_status, "").in_(
LOGIN_ATTEMPT_IN_FLIGHT_STATUSES
),
and_(
LoginTask.attempt_status == "",
LoginTask.status.in_(LOGIN_ATTEMPT_IN_FLIGHT_STATUSES),
),
),
)
.order_by(LoginTask.id.desc())
.first()
)
if task is None:
# 复用最近一条**失败/中断**的历史,避免每次失败都刷一行。
# 注意要同时看 attempt_status:账号有 CK 时 `_mark_attempt_failed`
# 刻意不动 status(保护旧语义),所以失败记录的 status 可能还停在
# pending —— 只看 status 就永远命不中,行数会无限长。
task = (
self.db.query(LoginTask)
.filter(
LoginTask.account_id == aid,
cookie_holding_filter(),
or_(
LoginTask.status.in_(["failed", "error"]),
LoginTask.attempt_status.in_(["failed", "error"]),
),
)
.order_by(LoginTask.id.desc())
.first()
)
if task is None:
# 复用最近一条失败记录,避免重复产生多条失败历史。
task = (
self.db.query(LoginTask)
.filter(
LoginTask.account_id == aid,
LoginTask.status.in_(["failed", "error"]),
or_(
LoginTask.cookie.is_(None),
LoginTask.cookie == "",
),
)
.order_by(LoginTask.id.desc())
.first()
)
if task is None:
task = LoginTask(
batch_id=batch_id,
account_id=aid,
status="pending",
created_by=self.created_by,
)
self.db.add(task)
if task is None:
task = LoginTask(
batch_id=batch_id,
account_id=aid,
status="pending",
created_by=self.created_by,
)
self.db.add(task)
_append_task_info(task, acc)
_append_task_info(task, acc)
self.db.commit()
total = len(task_infos)