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
+11 -11
View File
@@ -287,11 +287,11 @@ def assign_account(
if target.role != "support":
raise HTTPException(status_code=400, detail="只能分配给客服角色")
# 只能分配当前仍持有 CK 的账号(旧 CK 在重登失败/排队时也会保留)
# 只能分配当前仍持有 CK 的账号(CK 现在挂在账号上)
has_success = (
db.query(LoginTask)
db.query(Account)
.filter(
LoginTask.account_id == account_id,
Account.id == account_id,
cookie_holding_filter(),
)
.first()
@@ -334,8 +334,8 @@ def batch_assign_accounts(
if target.role != "support":
raise HTTPException(status_code=400, detail="只能分配给客服角色")
# 只能分配当前仍持有 CK 的账号(旧 CK 在重登失败/排队时也会保留)
cookie_ids_query = select(LoginTask.account_id).where(cookie_holding_filter())
# 只能分配当前仍持有 CK 的账号(CK 现在挂在账号上)
cookie_ids_query = select(Account.id).where(cookie_holding_filter())
invalid_ids = (
db.query(Account.id)
.filter(
@@ -388,7 +388,7 @@ def assignments_summary(
current: User = Depends(require_permission("account:assign")),
):
"""分配概览:每个客服分配了多少账号(仅统计当前仍持有 CK 的账号)。"""
cookie_subq = select(LoginTask.account_id).where(cookie_holding_filter())
cookie_subq = select(Account.id).where(cookie_holding_filter())
cookie_accounts = db.query(Account).filter(Account.id.in_(cookie_subq)).subquery()
results = (
@@ -626,16 +626,16 @@ def account_overview(
or {}
)
# 登录次数取历史记录(事件表不回填,所以 CK 相关计数仍以 login_tasks 为准)。
# 登录次数取历史记录(事件表不回填,所以登录相关计数仍以 login_tasks 为准);
# 「最近 CK 检测」改从**账号**上读(2026-10-04 CK 解耦:ck_checked_at 已搬到 accounts)。
login_stats = (
db.execute(
text(
"""
SELECT COUNT(*) AS login_count,
MAX(finished_at) AS last_login_at,
MAX(ck_checked_at) AS last_ck_check_at
MAX(finished_at) AS last_login_at
FROM login_tasks
WHERE account_id = :aid AND cookie IS NOT NULL AND cookie <> ''
WHERE account_id = :aid AND status = 'success'
"""
),
{"aid": account_id},
@@ -677,7 +677,7 @@ def account_overview(
"event_count": int(stats.get("event_count") or 0),
"login_count": int(login_stats.get("login_count") or 0),
"last_login_at": login_stats.get("last_login_at"),
"last_ck_check_at": login_stats.get("last_ck_check_at"),
"last_ck_check_at": acc.ck_checked_at,
},
# 「开通时间」由事件表提供;事件表从上线起才有,早于上线的不显示。
"opened_at": {
+28 -19
View File
@@ -12,7 +12,7 @@ from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy import and_, or_
from sqlalchemy import and_, or_, select
from sqlalchemy.orm import Session
from ..database import get_db
@@ -203,27 +203,36 @@ def manual_queue(
"""
from ..services.automation.jobs.ck_relogin import MANUAL_KINDS
# 转人工 = 分型属于需人工,或者次数用尽(next_run_at 已清空且失败过)。
# 解耦后这一队列的单位是**账号**(2026-10-04):重登进度与 CK 都在 accounts 上。
# 「最近尝试时间」仍来自登录记录(那是尝试的属性),用相关子查询取最后一条。
latest_attempt_at = (
select(LoginTask.attempt_at)
.where(LoginTask.account_id == Account.id)
.order_by(LoginTask.id.desc())
.limit(1)
.scalar_subquery()
)
# 转人工 = 分型属于需人工,或者次数用尽(next_at 已清空且失败过)。
manual_filter = or_(
LoginTask.relogin_failure_kind.in_(tuple(MANUAL_KINDS)),
Account.relogin_failure_kind.in_(tuple(sorted(MANUAL_KINDS))),
and_(
LoginTask.relogin_last_error != "",
LoginTask.relogin_next_at.is_(None),
LoginTask.relogin_attempts > 0,
Account.relogin_last_error != "",
Account.relogin_next_at.is_(None),
Account.relogin_attempts > 0,
),
)
rows = (
db.query(LoginTask, Account.username)
.join(Account, LoginTask.account_id == Account.id)
.filter(LoginTask.ck_check_status == "invalid")
.filter(LoginTask.relogin_failure_kind != "")
db.query(Account, latest_attempt_at.label("last_attempt_at"))
.filter(Account.ck_check_status == "invalid")
.filter(Account.relogin_failure_kind != "")
.filter(manual_filter)
.order_by(LoginTask.attempt_at.desc(), LoginTask.id.desc())
.order_by(latest_attempt_at.desc(), Account.id.desc())
.all()
)
grouped: dict[str, dict] = {}
for task, username in rows:
kind = task.relogin_failure_kind or ""
for account, last_attempt_at in rows:
kind = account.relogin_failure_kind or ""
# 可重试的类型却已经没有下次排期 = 次数用尽,单独成组。
# 否则它会混进「验证码失败」组里,客服看不出「这个已经不会再自动试了」。
if kind not in MANUAL_KINDS:
@@ -241,12 +250,12 @@ def manual_queue(
if len(group["samples"]) < limit:
group["samples"].append(
{
"task_id": task.id,
"account_username": username,
"relogin_attempts": int(task.relogin_attempts or 0),
"last_error": task.relogin_last_error or task.attempt_message or "",
"attempt_at": task.attempt_at.isoformat()
if task.attempt_at
"account_id": account.id,
"account_username": account.username,
"relogin_attempts": int(account.relogin_attempts or 0),
"last_error": account.relogin_last_error or "",
"attempt_at": last_attempt_at.isoformat()
if last_attempt_at
else None,
}
)
+318 -326
View File
@@ -1,6 +1,22 @@
"""Cookie 管理路由"""
"""Cookie 管理路由。
## 2026-10-04 语义变更(CK 解耦)
CK 从 `login_tasks.cookie` 迁到 `accounts.cookie`,因此本页面的**记录单位从
「登录记录」变成「账号」**:
- 列表/汇总/导出 = **持有 CK 的账号**(一行一账号,不再出现同一账号多行);
- 所有接口的 `id` 入参/出参 = **account_id**(以前是 task_id);
- `DELETE /cookies/{id}` = **清掉该账号的 CK**(不可逆),必写审计;
- 重复检测 = **同一个 CK 值出现在多个账号上**(真正的风险:两个账号共用一个
登录态)。以前查的是「同一账号多行持 CK」,那在解耦后已不可能出现。
⚠️ 这是本项目**破坏性最强**的接口面:清错账号的 CK 比 10-03 那次事故更糟。
所以删除/批量删除都单独写审计,且入参语义有专门的用例覆盖。
"""
import csv
import hashlib
import io
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -62,7 +78,7 @@ def _parse_account_names(raw_names: str) -> list[str]:
return names
def _order_cookie_tasks(query, selected_names: list[str]):
def _order_cookie_accounts(query, selected_names: list[str]):
"""按自定义账号名的输入顺序排序,未指定时保持默认的最新优先。"""
if selected_names:
input_order = case(
@@ -71,71 +87,109 @@ def _order_cookie_tasks(query, selected_names: list[str]):
else_=len(selected_names),
)
return query.order_by(
input_order, LoginTask.finished_at.desc(), LoginTask.id.desc()
input_order, Account.cookie_updated_at.desc(), Account.id.desc()
)
return query.order_by(LoginTask.finished_at.desc(), LoginTask.id.desc())
return query.order_by(Account.cookie_updated_at.desc(), Account.id.desc())
# CK 操作页沿用的重登状态词表;attempt_* 是唯一来源,历史值一并兼容。
_RELOGIN_STATUS_BY_ATTEMPT = {
"pending": "relogin_pending",
"running": "relogin_running",
"failed": "relogin_failed",
"error": "relogin_failed",
"relogin_pending": "relogin_pending",
"relogin_running": "relogin_running",
"relogin_failed": "relogin_failed",
}
def _relogin_display(account: Account, in_flight_batch_id: str = "") -> dict:
"""把账号上的重登进度映射成 CK 操作页展示用的状态。
def _relogin_display(task: LoginTask) -> dict:
"""把最近一次登录尝试映射成 CK 操作页展示用的重登状态。
账号持有的 CK 与「这次尝试的结果」分开存储,页面只关心后者;尝试结束
后旧 CK 仍在记录里,因此停止或失败都不会让这一行消失。
解耦后重登进度就写在账号上(`relogin_*`),不再需要顺着「最近一条登录记录」
去猜。「正在跑」仍然要看有没有在途尝试 —— 那是尝试的属性,由调用方批量查出后
以 ``in_flight_batch_id`` 传进来(避免 N+1)。
"""
attempt_status = task.attempt_status or ""
if in_flight_batch_id:
status = "relogin_running"
elif account.ck_check_status == "invalid" and account.relogin_next_at is not None:
status = "relogin_pending"
elif account.ck_check_status == "invalid" and account.relogin_last_error:
status = "relogin_failed"
else:
status = ""
return {
"relogin_status": _RELOGIN_STATUS_BY_ATTEMPT.get(attempt_status, ""),
"relogin_message": task.attempt_message or "",
"relogin_batch_id": task.attempt_batch_id
if attempt_status in LOGIN_ATTEMPT_IN_FLIGHT_STATUSES
else "",
"relogin_status": status,
"relogin_message": account.relogin_last_error or "",
"relogin_batch_id": in_flight_batch_id,
}
def _check_one_cookie(task: LoginTask) -> dict:
"""将共享检测结果补充为 Cookie 记录的接口返回格式。"""
result = check_douyu_cookie(task.cookie or "")
result["id"] = task.id
def _check_one_cookie(account: Account) -> dict:
"""将共享检测结果补充为 CK 记录的接口返回格式。"""
result = check_douyu_cookie(account.cookie or "")
result["id"] = account.id
result["checked_at"] = result["checked_at"].isoformat()
return result
def _visible_cookie_tasks_query(db: Session, current: User):
"""返回当前用户可见的、仍持有 Cookie 的登录记录查询。
def _visible_cookie_accounts_query(db: Session, current: User):
"""返回当前用户可见的、仍持有 CK 的**账号**查询。
以「记录里还留着 CK」为准:重登失败(relogin_failed)与排队中的登录
都保留了旧 CK,管理页必须继续能看到这些账号,否则客服会以为账号消失。
以「账号上还有 CK」为准:重登失败与排队中的登录都不会让账号失去 CK,
管理页必须继续能看到这些账号,否则客服会以为账号消失。
"""
query = db.query(LoginTask).filter(cookie_holding_filter())
# 无全量登录任务权限时,只能操作分配给自己的账号 Cookie。
query = db.query(Account).filter(cookie_holding_filter())
# 无全量登录任务权限时,只能操作分配给自己的账号。
if not user_has_permission(current, "login:view_all"):
query = query.join(Account, LoginTask.account_id == Account.id).filter(
Account.assigned_to == current.id
)
query = query.filter(Account.assigned_to == current.id)
return query
def _visible_cookie_operation_tasks_query(db: Session, current: User):
"""返回可检测/重登的 Cookie 记录,重登中或失败时仍保留在操作列表。"""
query = db.query(LoginTask).filter(cookie_holding_filter())
if not user_has_permission(current, "login:view_all"):
query = query.join(Account, LoginTask.account_id == Account.id).filter(
Account.assigned_to == current.id
def _visible_cookie_accounts_query_with_users(db: Session, current: User):
"""同上,但预加载归属客服(列表展示需要)。"""
return _visible_cookie_accounts_query(db, current).options(
joinedload(Account.assigned_user)
)
def _in_flight_batch_ids(db: Session, account_ids: list[int]) -> dict[int, str]:
"""批量取「在途尝试」的批次号,供重登状态展示(一次查询,避免 N+1)。"""
if not account_ids:
return {}
rows = (
db.query(LoginTask.account_id, func.max(LoginTask.attempt_batch_id))
.filter(LoginTask.account_id.in_(account_ids))
.filter(
func.coalesce(LoginTask.attempt_status, "").in_(
LOGIN_ATTEMPT_IN_FLIGHT_STATUSES
)
)
return query
.group_by(LoginTask.account_id)
.all()
)
return {int(account_id): (batch_id or "") for account_id, batch_id in rows}
def _cookie_item(
acc: Account,
*,
include_cookie: bool,
in_flight_batch_id: str = "",
) -> dict:
"""构造账号的 CK 列表项(REST 与详情接口共用)。"""
cookie = (acc.cookie or "") if include_cookie else ""
item = {
"id": acc.id,
"account_id": acc.id,
"account_username": acc.username,
"assigned_to": acc.assigned_to,
"assigned_username": acc.assigned_user.username if acc.assigned_user else None,
# 这份 CK 是什么时候拿到的(原来是登录记录的 finished_at)
"created_at": _fmt_dt(acc.cookie_updated_at),
"ck_check_status": acc.ck_check_status or "",
"ck_check_result": acc.ck_check_result,
"ck_checked_at": _fmt_dt(acc.ck_checked_at),
**_relogin_display(acc, in_flight_batch_id),
}
if include_cookie:
item["cookie"] = cookie
item["cookie_preview"] = cookie[:50] + "..." if len(cookie) > 50 else cookie
item["account_password"] = acc.password
else:
item["cookie"] = ""
item["cookie_preview"] = "***"
item["account_password"] = ""
return item
@router.get("")
@@ -149,37 +203,30 @@ def list_cookies(
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""查看登录成功的 Cookie 列表。"""
"""查看持有 CK 的账号列表(一行一账号)。"""
if not user_has_permission(current, "cookie:view"):
raise HTTPException(status_code=403, detail="无权限: cookie:view")
query = _visible_cookie_accounts_query_with_users(db, current)
if not include_cookie:
query = _visible_cookie_tasks_query(db, current).options(
defer(LoginTask.cookie)
# 列表默认不拖 1.7KB 的密文;复制/导出时才按 id 取完整值。
query = query.options(
defer(Account.cookie),
defer(Account.password),
defer(Account.email),
defer(Account.email_password),
)
else:
query = _visible_cookie_tasks_query(db, current).options(
joinedload(LoginTask.account).joinedload(Account.assigned_user),
)
query = query.options(defer(Account.email), defer(Account.email_password))
search_text = (search or "").strip()
tag_value = (tag or "").strip()
selected_names = _parse_account_names(account_names)
can_view_all = user_has_permission(current, "login:view_all")
account_joined = not can_view_all
if selected_names:
if not account_joined:
query = query.join(Account, LoginTask.account_id == Account.id)
account_joined = True
query = query.filter(Account.username.in_(selected_names))
if tag_value:
if not account_joined:
query = query.join(Account, LoginTask.account_id == Account.id)
account_joined = True
query = query.filter(Account.tag == tag_value)
if search_text:
pattern = f"%{search_text}%"
if not account_joined:
query = query.join(Account, LoginTask.account_id == Account.id)
account_joined = True
query = query.outerjoin(User, Account.assigned_to == User.id).filter(
or_(
Account.username.ilike(pattern),
@@ -190,58 +237,22 @@ def list_cookies(
total = None
if page is not None:
total = (
query.order_by(None).with_entities(func.count(LoginTask.id)).scalar() or 0
)
query = _order_cookie_tasks(query, selected_names)
total = query.order_by(None).with_entities(func.count(Account.id)).scalar() or 0
query = _order_cookie_accounts(query, selected_names)
if page is not None:
query = query.offset((page - 1) * page_size).limit(page_size)
tasks = query.all()
# 批量查账号,避免 N+1 查询
account_ids = [t.account_id for t in tasks]
accounts_map = {}
if account_ids:
account_query = db.query(Account).filter(Account.id.in_(account_ids))
if not include_cookie:
account_query = account_query.options(
joinedload(Account.assigned_user),
defer(Account.password),
defer(Account.email),
defer(Account.email_password),
)
accs = account_query.all()
accounts_map = {a.id: a for a in accs}
result = []
for t in tasks:
acc = accounts_map.get(t.account_id)
item = {
"id": t.id,
"batch_id": t.batch_id,
"account_id": t.account_id,
"account_username": acc.username if acc else "",
"assigned_to": acc.assigned_to if acc else None,
"assigned_username": acc.assigned_user.username
if acc and acc.assigned_user
else None,
"created_at": _fmt_dt(t.finished_at),
"ck_check_status": t.ck_check_status or "",
"ck_check_result": t.ck_check_result,
"ck_checked_at": _fmt_dt(t.ck_checked_at),
}
# 分页列表默认只返回预览,复制/导出时再获取完整敏感字段。
if include_cookie and user_has_permission(current, "cookie:view"):
cookie = t.cookie or ""
item["cookie"] = cookie
item["cookie_preview"] = cookie[:50] + "..." if len(cookie) > 50 else cookie
item["account_password"] = acc.password if acc else ""
else:
item["cookie"] = ""
item["cookie_preview"] = "***"
item["account_password"] = ""
result.append(item)
accounts = query.all()
in_flight = _in_flight_batch_ids(db, [acc.id for acc in accounts])
# include_cookie 已经在上面的权限里保证只对有 cookie:view 的人开放。
result = [
_cookie_item(
acc,
include_cookie=include_cookie,
in_flight_batch_id=in_flight.get(acc.id, ""),
)
for acc in accounts
]
if page is not None:
return {
"items": result,
@@ -260,13 +271,11 @@ def cookies_summary(
"""Cookie 管理统计,避免前端为了卡片统计拉全量 Cookie。"""
if not user_has_permission(current, "cookie:view"):
raise HTTPException(status_code=403, detail="无权限: cookie:view")
query = _visible_cookie_tasks_query(db, current)
if user_has_permission(current, "login:view_all"):
query = query.join(Account, LoginTask.account_id == Account.id)
total = query.with_entities(func.count(LoginTask.id)).scalar() or 0
query = _visible_cookie_accounts_query(db, current)
total = query.with_entities(func.count(Account.id)).scalar() or 0
assigned_count = (
query.filter(Account.assigned_to.isnot(None))
.with_entities(func.count(LoginTask.id))
.with_entities(func.count(Account.id))
.scalar()
or 0
)
@@ -288,9 +297,7 @@ def list_cookie_operations(
):
"""脱敏的 CK 操作列表,只用于检测与重登。"""
_require_cookie_operation_perm(current)
query = _visible_cookie_operation_tasks_query(db, current)
if user_has_permission(current, "login:view_all"):
query = query.join(Account, LoginTask.account_id == Account.id)
query = _visible_cookie_accounts_query(db, current)
search_text = (search or "").strip()
tag_value = (tag or "").strip()
if tag_value:
@@ -303,25 +310,26 @@ def list_cookie_operations(
)
)
total = query.order_by(None).with_entities(func.count(LoginTask.id)).scalar() or 0
tasks = (
query.order_by(LoginTask.finished_at.desc(), LoginTask.id.desc())
total = query.order_by(None).with_entities(func.count(Account.id)).scalar() or 0
accounts = (
query.order_by(Account.cookie_updated_at.desc(), Account.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
in_flight = _in_flight_batch_ids(db, [acc.id for acc in accounts])
return {
"items": [
{
"id": task.id,
"account_username": task.account.username if task.account else "",
"tag": task.account.tag if task.account else "",
"ck_check_status": task.ck_check_status or "",
"ck_checked_at": _fmt_dt(task.ck_checked_at),
"created_at": _fmt_dt(task.finished_at),
**_relogin_display(task),
"id": acc.id,
"account_username": acc.username,
"tag": acc.tag or "",
"ck_check_status": acc.ck_check_status or "",
"ck_checked_at": _fmt_dt(acc.ck_checked_at),
"created_at": _fmt_dt(acc.cookie_updated_at),
**_relogin_display(acc, in_flight.get(acc.id, "")),
}
for task in tasks
for acc in accounts
],
"total": total,
"page": page,
@@ -334,11 +342,9 @@ def list_cookie_operation_tags(
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""返回当前用户可操作 CK 记录对应的标签,不跨越账号分配范围。"""
"""返回当前用户可操作 CK 的账号标签,不跨越账号分配范围。"""
_require_cookie_operation_perm(current)
query = _visible_cookie_operation_tasks_query(db, current)
if user_has_permission(current, "login:view_all"):
query = query.join(Account, LoginTask.account_id == Account.id)
query = _visible_cookie_accounts_query(db, current)
rows = (
query.filter(Account.tag != "", Account.tag.isnot(None))
.with_entities(Account.tag)
@@ -354,67 +360,53 @@ def find_duplicate_cookies(
db: Session = Depends(get_db),
current: User = Depends(require_permission("cookie:view")),
):
"""检测 CK 管理中同一账号出现多次的记录,不返回 Cookie 敏感字段。"""
query = (
db.query(
LoginTask.id.label("cookie_id"),
LoginTask.account_id.label("account_id"),
LoginTask.batch_id.label("batch_id"),
LoginTask.finished_at.label("finished_at"),
Account.username.label("username"),
)
.join(Account, LoginTask.account_id == Account.id)
.filter(cookie_holding_filter())
"""检测**同一个 CK 值出现在多个账号上**,不返回 Cookie 敏感字段。
解耦前这里查的是「同一账号有多行持 CK」—— 那是「一行一账号」不变量漏水的产物。
现在每个账号只有一份 CK,真正值得报警的是**多个账号共用一个登录态**:
要么是导入时复制粘贴错了,要么是同一个斗鱼号被建了两次。这两种都会让
「按账号算的积分/兑换」失真,所以按 CK 指纹分组。
指纹用 sha256 而不是密文本身:CK 用 AES-GCM + 随机 nonce 加密,
**同一个明文每次加密结果都不同**,直接比密文永远比不出重复。
"""
accounts = (
_visible_cookie_accounts_query(db, current)
.order_by(Account.username.asc(), Account.id.asc())
.all()
)
# 与 CK 列表保持相同的数据可见范围,客服只能检测自己被分配的账号。
if not user_has_permission(current, "login:view_all"):
query = query.filter(Account.assigned_to == current.id)
rows = query.order_by(
Account.username.asc(), LoginTask.finished_at.desc(), LoginTask.id.desc()
).all()
grouped: dict[str, dict] = {}
for row in rows:
username = (row.username or "").strip()
if not username:
grouped: dict[str, list[Account]] = {}
for acc in accounts:
cookie = acc.cookie or ""
if not cookie:
continue
account_key = username.casefold()
group = grouped.setdefault(
account_key,
{
"account_key": account_key,
"account_names": set(),
"account_ids": set(),
"cookie_ids": [],
"records": [],
},
)
group["account_names"].add(username)
group["account_ids"].add(row.account_id)
group["cookie_ids"].append(row.cookie_id)
group["records"].append(
{
"id": row.cookie_id,
"account_id": row.account_id,
"batch_id": row.batch_id,
"finished_at": _fmt_dt(row.finished_at),
}
)
fingerprint = hashlib.sha256(cookie.encode("utf-8")).hexdigest()
grouped.setdefault(fingerprint, []).append(acc)
duplicate_groups = []
for group in grouped.values():
if len(group["cookie_ids"]) < 2:
for fingerprint, group in grouped.items():
if len(group) < 2:
continue
account_ids = [acc.id for acc in group]
duplicate_groups.append(
{
"account_key": group["account_key"],
"account_names": sorted(group["account_names"]),
"cookie_count": len(group["cookie_ids"]),
"account_count": len(group["account_ids"]),
"cookie_ids": group["cookie_ids"],
"account_ids": sorted(group["account_ids"]),
"records": group["records"],
# account_key 保持字段名兼容,现在放的是 CK 指纹前 12 位。
"account_key": fingerprint[:12],
"account_names": [acc.username for acc in group],
"cookie_count": len(group),
"account_count": len(group),
"cookie_ids": account_ids,
"account_ids": account_ids,
"records": [
{
"id": acc.id,
"account_id": acc.id,
"batch_id": "",
"finished_at": _fmt_dt(acc.cookie_updated_at),
}
for acc in group
],
}
)
duplicate_groups.sort(key=lambda item: (-item["cookie_count"], item["account_key"]))
@@ -438,30 +430,18 @@ def export_cookies(
csv: 账号, Cookie, 时间
custom: 账号----密码----ck
"""
query = _visible_cookie_tasks_query(db, current)
query = _visible_cookie_accounts_query(db, current)
selected_names = _parse_account_names(account_names)
if selected_names:
if user_has_permission(current, "login:view_all"):
query = query.join(Account, LoginTask.account_id == Account.id)
query = query.filter(Account.username.in_(selected_names))
tasks = _order_cookie_tasks(query, selected_names).all()
# 批量查账号,避免 N+1
account_ids = [t.account_id for t in tasks]
accounts_map = {}
if account_ids:
accs = db.query(Account).filter(Account.id.in_(account_ids)).all()
accounts_map = {a.id: a for a in accs}
accounts = _order_cookie_accounts(query, selected_names).all()
if format == "custom":
# 账号----密码----ck
lines = []
for t in tasks:
acc = accounts_map.get(t.account_id)
username = acc.username if acc else ""
password = acc.password if acc else ""
ck = t.cookie or ""
lines.append(f"{username}----{password}----{ck}")
lines = [
f"{acc.username}----{acc.password}----{acc.cookie or ''}"
for acc in accounts
]
content = "\r\n".join(lines)
filename = "cookies_custom.txt"
media = "text/plain"
@@ -470,10 +450,10 @@ def export_cookies(
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["账号", "Cookie", "时间"])
for t in tasks:
acc = accounts_map.get(t.account_id)
username = acc.username if acc else ""
writer.writerow([username, t.cookie or "", _fmt_dt(t.finished_at) or ""])
for acc in accounts:
writer.writerow(
[acc.username, acc.cookie or "", _fmt_dt(acc.cookie_updated_at) or ""]
)
content = output.getvalue()
filename = "cookies.csv"
media = "text/csv"
@@ -486,36 +466,35 @@ def export_cookies(
def _check_cookies(ids: str, db: Session, current: User, *, detailed: bool) -> dict:
"""执行 CK 检测;客服操作页仅返回状态,不返回账号衍生信息。"""
"""执行 CK 检测;客服操作页仅返回状态,不返回账号衍生信息。
`ids` 是**账号 ID**(2026-10-04 起)。
"""
if not ids:
raise HTTPException(status_code=400, detail="请指定记录ID")
raise HTTPException(status_code=400, detail="请指定账号ID")
id_list = [int(x) for x in ids.split(",") if x.strip().isdigit()]
if not id_list:
raise HTTPException(status_code=400, detail="无效的ID")
base_query = (
_visible_cookie_tasks_query(db, current)
if detailed
else _visible_cookie_operation_tasks_query(db, current)
)
tasks = (
base_query.filter(LoginTask.id.in_(id_list))
accounts = (
_visible_cookie_accounts_query(db, current)
.filter(Account.id.in_(id_list))
.filter(cookie_operable_filter())
.all()
)
if not tasks:
raise HTTPException(status_code=404, detail="记录不存在")
if not accounts:
raise HTTPException(status_code=404, detail="账号不存在或不可检测")
results: list[dict] = []
with ThreadPoolExecutor(max_workers=8) as pool:
futures = {pool.submit(_check_one_cookie, task): task for task in tasks}
futures = {pool.submit(_check_one_cookie, acc): acc for acc in accounts}
for future in as_completed(futures):
try:
results.append(future.result())
except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
task = futures[future]
acc = futures[future]
results.append(
{
"id": task.id,
"id": acc.id,
"valid": False,
"message": f"检测异常: {exc}",
"fish_ball": None,
@@ -526,26 +505,26 @@ def _check_cookies(ids: str, db: Session, current: User, *, detailed: bool) -> d
)
results.sort(key=lambda item: item["id"])
# 持久化检测结果,刷新/翻页不丢失
tasks_by_id = {task.id: task for task in tasks}
# 持久化检测结果到**账号**,刷新/翻页不丢失。
accounts_by_id = {acc.id: acc for acc in accounts}
for item in results:
task = tasks_by_id.get(item["id"])
if not task:
acc = accounts_by_id.get(item["id"])
if not acc:
continue
task.ck_check_status = "valid" if item["valid"] else "invalid"
task.ck_check_result = {
acc.ck_check_status = "valid" if item["valid"] else "invalid"
acc.ck_check_result = {
"fish_ball": item.get("fish_ball"),
"nickname": item.get("nickname"),
"level": item.get("level"),
"message": item.get("message", ""),
}
task.ck_checked_at = datetime.now(UTC)
acc.ck_checked_at = datetime.now(UTC)
db.add(
AuditLog(
user_id=current.id,
username=current.username,
action="cookie:check",
target=f"检测 {len(results)} 条已分配账号 CK",
target=f"检测 {len(results)} 个账号的 CK",
)
)
db.commit()
@@ -580,38 +559,41 @@ def check_cookie_operations(
return _check_cookies(ids, db, current, detailed=False)
def _start_relogin_tasks(
tasks: list[LoginTask],
def _start_relogin_accounts(
accounts: list[Account],
db: Session,
current: User,
*,
action: str = "cookie:relogin",
):
"""启动重登批次:旧 Cookie 保留到新登录成功后才替换。"""
if not tasks:
raise HTTPException(status_code=404, detail="记录不存在")
"""启动重登批次:旧 CK 保留到新登录成功后才替换。
task_ids: list[int] = []
入参是**账号**(2026-10-04 起):调度单位就是账号,登录记录只是每次尝试的历史。
"""
if not accounts:
raise HTTPException(status_code=404, detail="账号不存在")
account_ids: list[int] = []
skipped: list[int] = []
for task in tasks:
acc = db.query(Account).filter(Account.id == task.account_id).first()
if not acc:
skipped.append(task.id)
continue
for acc in accounts:
if not acc.password or not acc.email:
# 只记录这次尝试失败,账号已有的 CK 原封不动。
task.attempt_status = "failed"
task.attempt_message = "重新登录失败: 账号缺少密码或邮箱,旧 Cookie 已保留"
task.attempt_at = datetime.now(UTC)
skipped.append(task.id)
# 记录这次尝试失败即可;账号已有的 CK 原封不动。
db.add(
LoginTask(
batch_id="relogin-precheck",
account_id=acc.id,
status="failed",
attempt_status="failed",
attempt_message="重新登录失败: 账号缺少密码或邮箱,旧 Cookie 已保留",
attempt_at=datetime.now(UTC),
created_by=current.id,
)
)
skipped.append(acc.id)
continue
if task.attempt_status in LOGIN_ATTEMPT_IN_FLIGHT_STATUSES:
skipped.append(task.id)
continue
# 排队状态由 LoginBatchRunner 在批次启动时写入 attempt_*(含批次号)。
task_ids.append(task.id)
account_ids.append(acc.id)
if not task_ids:
if not account_ids:
db.commit()
raise HTTPException(
status_code=400, detail="没有可重登的账号,请检查账号凭据或当前重登状态"
@@ -622,7 +604,7 @@ def _start_relogin_tasks(
max_login_retries, max_total_time = get_relogin_limits()
runner = LoginBatchRunner(
db=thread_db,
account_ids=[],
account_ids=account_ids,
created_by=current.id,
creator_permissions=get_user_permissions(current),
max_login_retries=max_login_retries,
@@ -632,7 +614,6 @@ def _start_relogin_tasks(
loop=None,
concurrency=3,
mode="relogin",
relogin_task_ids=task_ids,
)
batch_id = runner.batch_id
db.add(
@@ -640,7 +621,7 @@ def _start_relogin_tasks(
user_id=current.id,
username=current.username,
action=action,
target=f"重登 {len(task_ids)} 条账号 CK",
target=f"重登 {len(account_ids)} 个账号的 CK",
detail="旧 Cookie 将在新登录成功后替换",
)
)
@@ -653,8 +634,8 @@ def _start_relogin_tasks(
except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
runner._push_log("error", f"批量重新登录异常: {exc}")
message = f"重新登录异常: {exc}(旧 Cookie 已保留)"
for task_id in task_ids:
runner._mark_relogin_stopped(task_id, message)
for account_id in account_ids:
runner._mark_account_relogin_stopped(account_id, message)
runner.db.close()
finally:
cookie_relogin_registry.pop(batch_id)
@@ -662,7 +643,7 @@ def _start_relogin_tasks(
threading.Thread(target=run_relogin_batch, daemon=True).start()
return {
"batch_id": batch_id,
"count": len(task_ids),
"count": len(account_ids),
"skipped": len(skipped),
"success": True,
}
@@ -703,16 +684,16 @@ def stop_cookie_relogin(
def _start_relogin(req: CookieReloginRequest, db: Session, current: User):
"""按选中的 Cookie 记录启动重新登录。"""
"""按选中的账号启动重新登录(`req.ids` 是账号 ID)。"""
if not req.ids:
raise HTTPException(status_code=400, detail="请选择要重新登录的 Cookie")
tasks = (
_visible_cookie_operation_tasks_query(db, current)
.filter(LoginTask.id.in_(req.ids))
raise HTTPException(status_code=400, detail="请选择要重新登录的账号")
accounts = (
_visible_cookie_accounts_query(db, current)
.filter(Account.id.in_(req.ids))
.filter(cookie_operable_filter())
.all()
)
return _start_relogin_tasks(tasks, db, current)
return _start_relogin_accounts(accounts, db, current)
@router.post("/relogin")
@@ -745,12 +726,10 @@ def relogin_invalid_cookie_operations(
):
"""批量重新登录当前用户可见且最近检测为无效的账号。"""
_require_cookie_operation_perm(current)
query = _visible_cookie_operation_tasks_query(db, current).filter(
LoginTask.ck_check_status == "invalid",
query = _visible_cookie_accounts_query(db, current).filter(
Account.ck_check_status == "invalid",
cookie_operable_filter(),
)
if user_has_permission(current, "login:view_all"):
query = query.join(Account, LoginTask.account_id == Account.id)
if tag.strip():
query = query.filter(Account.tag == tag.strip())
if search.strip():
@@ -758,89 +737,102 @@ def relogin_invalid_cookie_operations(
query = query.filter(
or_(Account.username.ilike(pattern), Account.tag.ilike(pattern))
)
tasks = query.order_by(LoginTask.id.asc()).all()
return _start_relogin_tasks(tasks, db, current, action="cookie:relogin_invalid")
accounts = query.order_by(Account.id.asc()).all()
return _start_relogin_accounts(
accounts, db, current, action="cookie:relogin_invalid"
)
@router.get("/{task_id}")
@router.get("/{account_id}")
def get_cookie(
task_id: int,
account_id: int,
db: Session = Depends(get_db),
current: User = Depends(get_current_user),
):
"""获取单条 Cookie 详情,供复制操作按需读取完整敏感字段。"""
"""获取单个账号的 CK 详情,供复制操作按需读取完整敏感字段。"""
if not user_has_permission(current, "cookie:view"):
raise HTTPException(status_code=403, detail="无权限: cookie:view")
task = (
_visible_cookie_tasks_query(db, current).filter(LoginTask.id == task_id).first()
acc = (
_visible_cookie_accounts_query_with_users(db, current)
.filter(Account.id == account_id)
.first()
)
if not task:
raise HTTPException(status_code=404, detail="记录不存在")
acc = db.query(Account).filter(Account.id == task.account_id).first()
item = {
"id": task.id,
"batch_id": task.batch_id,
"account_id": task.account_id,
"account_username": acc.username if acc else "",
"assigned_to": acc.assigned_to if acc else None,
"assigned_username": acc.assigned_user.username
if acc and acc.assigned_user
else None,
"created_at": _fmt_dt(task.finished_at),
"ck_check_status": task.ck_check_status or "",
"ck_check_result": task.ck_check_result,
"ck_checked_at": _fmt_dt(task.ck_checked_at),
"cookie": "",
"cookie_preview": "***",
"account_password": "",
}
if user_has_permission(current, "cookie:view"):
cookie = task.cookie or ""
item["cookie"] = cookie
item["cookie_preview"] = cookie[:50] + "..." if len(cookie) > 50 else cookie
item["account_password"] = acc.password if acc else ""
return item
if not acc:
raise HTTPException(status_code=404, detail="账号不存在或没有 CK")
return _cookie_item(acc, include_cookie=True)
@router.delete("/batch")
def delete_cookies_batch(
task_ids: str = "",
account_ids: str = "",
db: Session = Depends(get_db),
current: User = Depends(require_permission("cookie:export")),
):
"""批量删除 Cookie 记录。"""
if not task_ids:
raise HTTPException(status_code=400, detail="请指定记录ID")
ids = [int(x) for x in task_ids.split(",") if x.strip().isdigit()]
"""批量清除账号的 CK(**不可逆**,写审计)。"""
if not account_ids:
raise HTTPException(status_code=400, detail="请指定账号ID")
ids = [int(x) for x in account_ids.split(",") if x.strip().isdigit()]
if not ids:
raise HTTPException(status_code=400, detail="无效的ID")
tasks = _visible_cookie_tasks_query(db, current).filter(LoginTask.id.in_(ids)).all()
for t in tasks:
t.cookie = ""
t.status = "failed"
t.message = "Cookie已清除"
accounts = (
_visible_cookie_accounts_query(db, current).filter(Account.id.in_(ids)).all()
)
now = datetime.now(UTC)
for acc in accounts:
acc.cookie = ""
acc.cookie_updated_at = now
acc.ck_check_status = ""
acc.ck_check_result = None
acc.ck_checked_at = None
db.add(
AuditLog(
user_id=current.id,
username=current.username,
action="cookie:delete",
target=f"清除 {len(accounts)} 个账号的 CK",
detail="不可逆:CK 已从账号上清空",
)
)
db.commit()
return {
"message": f"已删除 {len(tasks)} 条",
"deleted": len(tasks),
"message": f"已清除 {len(accounts)} 个账号的 CK",
"deleted": len(accounts),
"success": True,
}
@router.delete("/{task_id}")
@router.delete("/{account_id}")
def delete_cookie(
task_id: int,
account_id: int,
db: Session = Depends(get_db),
current: User = Depends(require_permission("cookie:export")),
):
"""删除一条 Cookie 记录。"""
task = (
_visible_cookie_tasks_query(db, current).filter(LoginTask.id == task_id).first()
"""清除一个账号的 CK(**不可逆**,写审计)。
⚠️ 入参是 **account_id**(2026-10-04 起)。以前是 task_id —— 语义变了,
改错会变成「点删除清掉另一个账号的登录态」,所以有专门用例盯这一条。
"""
acc = (
_visible_cookie_accounts_query(db, current)
.filter(Account.id == account_id)
.first()
)
if not acc:
raise HTTPException(status_code=404, detail="账号不存在或没有 CK")
username = acc.username
acc.cookie = ""
acc.cookie_updated_at = datetime.now(UTC)
acc.ck_check_status = ""
acc.ck_check_result = None
acc.ck_checked_at = None
db.add(
AuditLog(
user_id=current.id,
username=current.username,
action="cookie:delete",
target=username,
detail="不可逆:CK 已从该账号清空",
)
)
if not task:
raise HTTPException(status_code=404, detail="记录不存在")
task.cookie = ""
task.status = "failed"
task.message = "Cookie已清除"
db.commit()
return {"message": "已删除", "success": True}
+4 -6
View File
@@ -84,14 +84,12 @@ def dashboard_summary(
cookies = 0
if user_has_permission(current, "cookie:view"):
cookie_query = (
db.query(LoginTask)
.join(Account, LoginTask.account_id == Account.id)
.filter(cookie_holding_filter())
)
# CK 已挂在账号上(2026-10-04 解耦),所以这里统计的是「账号数」而不是
# 「带 CK 的登录记录行数」—— 后者在有历史行时会把同一个账号算多次。
cookie_query = db.query(Account).filter(cookie_holding_filter())
if not user_has_permission(current, "login:view_all"):
cookie_query = cookie_query.filter(Account.assigned_to == current.id)
cookies = cookie_query.with_entities(func.count(LoginTask.id)).scalar() or 0
cookies = cookie_query.with_entities(func.count(Account.id)).scalar() or 0
huya_accounts = 0
can_view_huya_accounts = any(
+1 -2
View File
@@ -37,7 +37,6 @@ from ..models import (
DouyuWorkbench,
DouyuWorkbenchAccount,
DouyuXpdGoodsSnapshot,
LoginTask,
User,
)
from ..permissions import user_has_permission
@@ -185,7 +184,7 @@ def _visible_task_accounts_query(db: Session, current: User):
只要账号还持有 Cookie 就可见:排队中的普通登录(pending/running)和
重登失败(relogin_failed)都会保留旧 CK,不能让账号从工作台消失。
"""
cookie_ids = select(LoginTask.account_id).where(cookie_holding_filter()).distinct()
cookie_ids = select(Account.id).where(cookie_holding_filter())
query = (
db.query(Account)
.options(joinedload(Account.assigned_user))
+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}
+1 -1
View File
@@ -192,7 +192,7 @@ class LoginBatchRequest(BaseModel):
class CookieReloginRequest(BaseModel):
ids: list[int] = [] # 需要重新登录的 Cookie 记录 ID
ids: list[int] = [] # 需要重新登录的**账号** ID(2026-10-04 CK 解耦前是记录 ID)
class LoginTaskOut(BaseModel):
@@ -16,7 +16,7 @@ from typing import Any
from sqlalchemy import or_
from sqlalchemy.orm import Session
from ....models import Account, AutomationJob, LoginTask
from ....models import Account, AutomationJob
from ...account_events_service import EVENT_CK_INVALID, emit
from ...cookie_check_service import check_douyu_cookie
from ...douyu_service import cookie_operable_filter
@@ -60,11 +60,11 @@ def classify_result(result: dict[str, Any]) -> str:
return "error"
def build_select_query(db: Session, job: AutomationJob, now: datetime):
"""构造本轮要检测记录的查询(与取行分开,便于按方言编译做回归)。
def _candidates_query(db: Session, job: AutomationJob, now: datetime):
"""本轮「待检测账号」的完整候选集(**不带 LIMIT**,供计数复用)。
口径与人工点检测一致(``cookie_operable_filter``:持有 CK 且没有在途尝试),
再加三条:距上次检测超过作业间隔、账号允许自动管理、最久没查的优先。
口径与人工点检测一致(``cookie_operable_filter``:账号有 CK 且没有在途尝试),
再加两条:距上次检测超过作业间隔、账号允许自动管理。
排序刻意不用 ``NULLS FIRST``:那是 PostgreSQL/SQLite 语法,**MySQL 会报
1064 语法错误**(生产踩过一次)。改用 ``ck_checked_at IS NULL DESC``,
@@ -72,32 +72,52 @@ def build_select_query(db: Session, job: AutomationJob, now: datetime):
"""
cutoff = now - timedelta(seconds=max(int(job.interval_seconds or 0), 60))
return (
db.query(LoginTask)
.join(Account, LoginTask.account_id == Account.id)
db.query(Account)
.filter(cookie_operable_filter())
.filter(Account.auto_managed.is_(True))
.filter(
or_(
LoginTask.ck_checked_at.is_(None),
LoginTask.ck_checked_at < cutoff,
Account.ck_checked_at.is_(None),
Account.ck_checked_at < cutoff,
)
)
.order_by(
LoginTask.ck_checked_at.is_(None).desc(),
LoginTask.ck_checked_at.asc(),
LoginTask.id.asc(),
Account.ck_checked_at.is_(None).desc(),
Account.ck_checked_at.asc(),
Account.id.asc(),
)
.limit(max(int(job.batch_size or 0), 1))
)
def _select_tasks(db: Session, job: AutomationJob, now: datetime) -> list[LoginTask]:
def build_select_query(db: Session, job: AutomationJob, now: datetime):
"""构造本轮要检测**账号**的查询(与取行分开,便于按方言编译做回归)。
解耦后**按账号选行**(2026-10-04):CK 与 ``ck_check_*`` 都在 ``accounts`` 上,
以前是 ``db.query(LoginTask).join(Account)`` —— 那样「没有登录历史的账号」
永远进不了巡检队列,而且一个账号有多行时会被重复检测。
"""
return _candidates_query(db, job, now).limit(max(int(job.batch_size or 0), 1))
def _select_accounts(db: Session, job: AutomationJob, now: datetime) -> list[Account]:
return build_select_query(db, job, now).all()
def _detect(task: LoginTask) -> dict[str, Any]:
def count_remaining(db: Session, job: AutomationJob, now: datetime) -> int:
"""剩余待检账号数(给页面提示用)。
刻意**不用** ``build_select_query`` —— 那个带 ``LIMIT batch_size``,
取 count 只会得到批次上限,完全看不出积压。
"""
return int(
_candidates_query(db, job, now).order_by(None).with_entities(Account.id).count()
or 0
)
def _detect(account: Account) -> dict[str, Any]:
try:
return check_douyu_cookie(task.cookie or "")
return check_douyu_cookie(account.cookie or "")
except Exception as exc: # noqa: BLE001 单个账号失败不影响整轮
return {
"valid": False,
@@ -111,22 +131,22 @@ def _detect(task: LoginTask) -> dict[str, Any]:
def run_ck_health(db: Session, job: AutomationJob) -> dict[str, Any]:
now = datetime.now(UTC)
tasks = _select_tasks(db, job, now)
if not tasks:
accounts = _select_accounts(db, job, now)
if not accounts:
return {"scanned": 0, "valid": 0, "invalid": 0, "skipped": 0}
results: dict[int, dict[str, Any]] = {}
concurrency = max(min(int(job.concurrency or 1), 16), 1)
with ThreadPoolExecutor(max_workers=concurrency) as pool:
futures = {pool.submit(_detect, task): task for task in tasks}
futures = {pool.submit(_detect, account): account for account in accounts}
for future in as_completed(futures):
task = futures[future]
results[task.id] = future.result()
account = futures[future]
results[account.id] = future.result()
# 只写检测结果三件套;cookie / status / attempt_* 一律不动。
valid = invalid = errors = 0
for index, task in enumerate(tasks, start=1):
result = results.get(task.id)
for index, account in enumerate(accounts, start=1):
result = results.get(account.id)
if result is None:
continue
verdict = classify_result(result)
@@ -136,17 +156,17 @@ def run_ck_health(db: Session, job: AutomationJob) -> dict[str, Any]:
invalid += 1
else:
errors += 1
# error 时保留该行原来的结论:查不到不等于失效,宁可维持上一次判断。
# error 时保留该账号原来的结论:查不到不等于失效,宁可维持上一次判断。
if verdict != "error":
task.ck_check_status = verdict
task.ck_check_result = {
account.ck_check_status = verdict
account.ck_check_result = {
"fish_ball": result.get("fish_ball"),
"nickname": result.get("nickname"),
"level": result.get("level"),
"message": result.get("message", ""),
"verdict": verdict,
}
task.ck_checked_at = now
account.ck_checked_at = now
# 判定失效时记一条账号事件:`ck_check_status` / `ck_checked_at` 都是**覆盖式**
# 字段,覆盖之后就再也看不出「什么时候开始失效的」。只在真的判成 invalid 时记,
@@ -154,7 +174,7 @@ def run_ck_health(db: Session, job: AutomationJob) -> dict[str, Any]:
if verdict == "invalid":
emit(
db,
account_id=task.account_id,
account_id=account.id,
event_type=EVENT_CK_INVALID,
title="CK 检测失效",
occurred_at=now,
@@ -165,7 +185,7 @@ def run_ck_health(db: Session, job: AutomationJob) -> dict[str, Any]:
source="login",
# 幂等键带检测时间:同一账号**再次**失效算新事件,但同一次检测
# 重跑(例如失败重试)不会重复入库。
source_ref=f"ck_invalid:{task.id}:{int(now.timestamp())}",
source_ref=f"ck_invalid:{account.id}:{int(now.timestamp())}",
)
if index % FLUSH_EVERY == 0:
@@ -173,21 +193,8 @@ def run_ck_health(db: Session, job: AutomationJob) -> dict[str, Any]:
db.commit()
# 剩余待检数量只用于提示,不参与调度。
remaining = (
db.query(LoginTask)
.join(Account, LoginTask.account_id == Account.id)
.filter(cookie_operable_filter())
.filter(Account.auto_managed.is_(True))
.filter(
or_(
LoginTask.ck_checked_at.is_(None),
LoginTask.ck_checked_at
< now - timedelta(seconds=max(int(job.interval_seconds or 0), 60)),
)
)
.count()
)
error_ratio = errors / len(tasks) if tasks else 0.0
remaining = count_remaining(db, job, now)
error_ratio = errors / len(accounts) if accounts else 0.0
paused = False
if errors >= ERROR_PAUSE_MIN_SCANNED and error_ratio > ERROR_PAUSE_RATIO:
# 大面积「查不到」通常是斗鱼接口抖动或被风控,继续跑只会污染更多账号。
@@ -195,13 +202,13 @@ def run_ck_health(db: Session, job: AutomationJob) -> dict[str, Any]:
job.updated_at = datetime.now(UTC)
paused = True
message = f"本轮检测 {len(tasks)} 条,剩余待检 {remaining} 条"
message = f"本轮检测 {len(accounts)} 条,剩余待检 {remaining} 条"
if errors:
message += f";非鉴权类失败 {errors} 条({error_ratio:.0%})"
if paused:
message += ";失败占比过高,已自动暂停作业"
return {
"scanned": len(tasks),
"scanned": len(accounts),
"valid": valid,
"invalid": invalid,
"skipped": errors,
@@ -24,7 +24,7 @@ from ....database import SessionLocal
from ....models import Account, AutomationJob, LoginTask, User
from ....models import ProxyConfig as ProxyConfigModel
from ...account_events_service import EVENT_RELOGIN_FAILED, emit
from ...douyu_service import LOGIN_ATTEMPT_IN_FLIGHT_STATUSES, cookie_holding_filter
from ...douyu_service import cookie_holding_filter, cookie_operable_filter
from ...login_service import LoginBatchRunner, get_relogin_limits
JOB_KEY = "ck_relogin"
@@ -82,41 +82,71 @@ def build_select_query(
max_attempts: int = 2,
system_user_id: int | None = None,
):
"""构造本轮重登候选的查询(与取行分开,便于按方言编译做回归)。"""
"""构造本轮重登候选的查询(与取行分开,便于按方言编译做回归)。
解耦后**按账号选行**(2026-10-04):CK 与 ``ck_check_status`` / ``relogin_*``
都在 ``accounts`` 上。以前是 ``db.query(LoginTask).join(Account)`` —— 那样
「没有登录历史的账号」永远进不了重登队列,而重登本来只关心账号。
「在途尝试」仍然要查 ``login_tasks``(那是某次尝试的属性),由
``cookie_operable_filter`` 的 NOT EXISTS 子查询完成,走迁移 0042 新增的
``(account_id, attempt_status)`` 索引。
"""
query = (
db.query(LoginTask)
.join(Account, LoginTask.account_id == Account.id)
.filter(LoginTask.ck_check_status == "invalid")
.filter(cookie_holding_filter())
.filter(
func.coalesce(LoginTask.attempt_status, "").notin_(
LOGIN_ATTEMPT_IN_FLIGHT_STATUSES
)
)
db.query(Account)
.filter(Account.ck_check_status == "invalid")
.filter(cookie_operable_filter())
.filter(Account.auto_managed.is_(True))
.filter(LoginTask.relogin_attempts < max_attempts)
.filter(Account.relogin_attempts < max_attempts)
.filter(
or_(
LoginTask.relogin_next_at.is_(None),
LoginTask.relogin_next_at <= _cutoff(now),
Account.relogin_next_at.is_(None),
Account.relogin_next_at <= _cutoff(now),
)
)
)
# 已判定需要人工的类型不再自动重试(极验/凭证)。空值用 coalesce 兜住历史行。
# 已判定需要人工的类型不再自动重试(极验/凭证)。空值用 coalesce 兜住历史值。
query = query.filter(
func.coalesce(LoginTask.relogin_failure_kind, "").notin_(tuple(MANUAL_KINDS))
func.coalesce(Account.relogin_failure_kind, "").notin_(
tuple(sorted(MANUAL_KINDS))
)
)
if system_user_id is not None:
pass # 预留:将来若需要限制到某个归属人,在这里加过滤
return query.order_by(
LoginTask.relogin_next_at.is_(None).desc(),
LoginTask.relogin_next_at.asc(),
LoginTask.id.asc(),
Account.relogin_next_at.is_(None).desc(),
Account.relogin_next_at.asc(),
Account.id.asc(),
).limit(max(int(job.batch_size or 0), 1))
def _load_batch_state(
session: Session, batch_id: str
) -> tuple[dict[int, tuple[Account, LoginTask]], list[int]]:
"""读回某一批次产生的尝试记录,并配对其所属账号。
按 ``attempt_batch_id`` 找 —— ``_begin_attempt`` 会给每一条被本批次接管的记录
打上批次号。所以即使记录是**复用**的旧行,也能被准确对应回本轮。
"""
tasks = (
session.query(LoginTask)
.filter(LoginTask.attempt_batch_id == batch_id)
.order_by(LoginTask.id.asc())
.all()
)
state: dict[int, tuple[Account, LoginTask]] = {}
order: list[int] = []
for task in tasks:
account = session.get(Account, task.account_id)
if account is None:
continue
state[account.id] = (account, task)
order.append(account.id)
return state, order
def settle_fresh_results(
task_ids: list[int], *, now: datetime, max_attempts: int = 2
batch_id: str, *, now: datetime, max_attempts: int = 2
) -> dict[str, int]:
"""在**新会话(新事务)**里读回批次结果并结算。
@@ -130,11 +160,11 @@ def settle_fresh_results(
"""
session = SessionLocal()
try:
rows = session.query(LoginTask).filter(LoginTask.id.in_(task_ids)).all()
fresh, order = _load_batch_state(session, batch_id)
counters = settle_results(
session,
{row.id: row for row in rows},
task_ids,
fresh,
order,
now=now,
max_attempts=max_attempts,
)
@@ -152,15 +182,14 @@ def count_pending_relogin(db: Session, *, max_attempts: int = 2) -> int:
按退避窗口过滤** —— 退避是「什么时候试」的细节,不影响「还有多少要处理」。
"""
return int(
db.query(func.count(LoginTask.id))
.join(Account, LoginTask.account_id == Account.id)
.filter(LoginTask.ck_check_status == "invalid")
db.query(func.count(Account.id))
.filter(Account.ck_check_status == "invalid")
.filter(cookie_holding_filter())
.filter(Account.auto_managed.is_(True))
.filter(LoginTask.relogin_attempts < max_attempts)
.filter(Account.relogin_attempts < max_attempts)
.filter(
func.coalesce(LoginTask.relogin_failure_kind, "").notin_(
tuple(MANUAL_KINDS)
func.coalesce(Account.relogin_failure_kind, "").notin_(
tuple(sorted(MANUAL_KINDS))
)
)
.scalar()
@@ -210,11 +239,12 @@ def run_ck_relogin(db: Session, job: AutomationJob) -> dict[str, Any]:
"message": "库里没有可用用户,无法以系统身份发起重登",
}
tasks = build_select_query(db, job, now, max_attempts=max_attempts).all()
if not tasks:
accounts = build_select_query(db, job, now, max_attempts=max_attempts).all()
if not accounts:
return {"scanned": 0, "valid": 0, "invalid": 0, "skipped": 0}
task_ids = [task.id for task in tasks]
# 调度单位是**账号**(CK 在账号上);登录记录只是这次尝试的历史。
account_ids = [account.id for account in accounts]
proxy = db.query(ProxyConfigModel).first()
_, max_total_time = get_relogin_limits()
@@ -222,7 +252,7 @@ def run_ck_relogin(db: Session, job: AutomationJob) -> dict[str, Any]:
runner_db = SessionLocal()
runner = LoginBatchRunner(
db=runner_db,
account_ids=[],
account_ids=account_ids,
created_by=system_user_id,
# 自动化要对全库生效,不受「只能操作分配给自己的账号」限制。
creator_permissions=["login:view_all", "cookie:operate"],
@@ -233,7 +263,6 @@ def run_ck_relogin(db: Session, job: AutomationJob) -> dict[str, Any]:
loop=None,
concurrency=max(min(int(job.concurrency or 3), 8), 1),
mode="relogin",
relogin_task_ids=task_ids,
)
batch_id = runner.batch_id
try:
@@ -242,7 +271,7 @@ def run_ck_relogin(db: Session, job: AutomationJob) -> dict[str, Any]:
runner_db.close()
# runner 在另一个会话里提交了结果:必须用新事务读回(见 settle_fresh_results)。
counters = settle_fresh_results(task_ids, now=now, max_attempts=max_attempts)
counters = settle_fresh_results(batch_id, now=now, max_attempts=max_attempts)
parts = [
f"本轮重登 {counters['scanned']} 条",
@@ -274,58 +303,64 @@ def run_ck_relogin(db: Session, job: AutomationJob) -> dict[str, Any]:
def settle_results(
db: Session,
fresh: dict[int, LoginTask],
task_ids: list[int],
fresh: dict[int, tuple[Account, LoginTask]],
account_ids: list[int],
*,
now: datetime,
max_attempts: int = 2,
) -> dict[str, int]:
"""批次跑完后结算:成功的清零,失败的按分型决定重试还是转人工。
解耦后 **决策看「账号 + 本轮的尝试记录」**(2026-10-04):
- 成败来自这次尝试(``LoginTask.attempt_status``);
- 退避进度写在**账号**上(``Account.relogin_*``)—— 它是账号的状态,
不该随某条历史记录一起被删掉。
单独抽出来是因为这段是 P1b 的核心决策逻辑,必须能脱离真实登录做单测。
只改 ``relogin_*`` 四个字段,**不碰 CK 本体**(CK 由 runner 在成功时替换)。
``db`` 用于写账号事件(转人工时记一条);事件是旁路,写失败不影响结算。
"""
counters = {
"scanned": len(task_ids),
"scanned": len(account_ids),
"valid": 0,
"invalid": 0,
"manual": 0,
"retry": 0,
"email_quota": 0,
}
for task_id in task_ids:
task = fresh.get(task_id)
if task is None:
for account_id in account_ids:
entry = fresh.get(account_id)
if entry is None:
continue
account, task = entry
if task.attempt_status == "success":
counters["valid"] += 1
task.relogin_attempts = 0
task.relogin_next_at = None
task.relogin_failure_kind = ""
task.relogin_last_error = ""
account.relogin_attempts = 0
account.relogin_next_at = None
account.relogin_failure_kind = ""
account.relogin_last_error = ""
continue
counters["invalid"] += 1
kind = classify_failure(task.attempt_message)
task.relogin_failure_kind = kind
task.relogin_last_error = (task.attempt_message or "")[:500]
task.relogin_attempts = int(task.relogin_attempts or 0) + 1
if kind in MANUAL_KINDS or task.relogin_attempts >= max_attempts:
account.relogin_failure_kind = kind
account.relogin_last_error = (task.attempt_message or "")[:500]
account.relogin_attempts = int(account.relogin_attempts or 0) + 1
if kind in MANUAL_KINDS or account.relogin_attempts >= max_attempts:
# 转人工:极验/凭证问题重试无意义,次数用尽也不该无限撞。
counters["manual"] += 1
task.relogin_next_at = None
account.relogin_next_at = None
# 只在「转人工」时记事件:还要退避重试的不记,否则同一账号每轮都刷一条。
emit(
db,
account_id=task.account_id,
account_id=account.id,
event_type=EVENT_RELOGIN_FAILED,
title=f"自动重登转人工({kind})",
occurred_at=now,
detail={
"kind": kind,
"attempts": task.relogin_attempts,
"attempts": account.relogin_attempts,
"error": (task.attempt_message or "")[:200],
},
source="login",
@@ -335,5 +370,5 @@ def settle_results(
if kind == "email_quota":
counters["email_quota"] += 1
counters["retry"] += 1
task.relogin_next_at = _next_retry_at(kind, task.relogin_attempts, now)
account.relogin_next_at = _next_retry_at(kind, account.relogin_attempts, now)
return counters
+6 -7
View File
@@ -21,7 +21,7 @@ from .douyu_runner_gold import GoldMixin
from .douyu_runner_goods import GoodsMixin
from .douyu_runner_manual import ManualMixin
from .douyu_runner_xpd import XpdMixin
from .douyu_service import latest_cookie_login_task, update_account_profile_from_cookie
from .douyu_service import update_account_profile_from_cookie
class DouyuBatchRunner(
@@ -56,23 +56,22 @@ class DouyuBatchRunner(
self._push_log(
"info", f"[{current}/{total}] 开始: {self._account_name(account)}"
)
login_task = latest_cookie_login_task(worker_db, account.id)
cookie = login_task.cookie if login_task else ""
# CK 与健康状态都挂在账号上(2026-10-04 CK 解耦)。
cookie = account.cookie or ""
if not cookie:
self._mark_task(worker_db, task, "failed", "账号没有可用登录 Cookie")
self._push_log(
"warning", f"[{current}] {self._account_name(account)} 无 Cookie"
)
return
assert login_task is not None
cookie_check = check_douyu_cookie(cookie)
login_task.ck_check_status = "valid" if cookie_check["valid"] else "invalid"
login_task.ck_check_result = {
account.ck_check_status = "valid" if cookie_check["valid"] else "invalid"
account.ck_check_result = {
key: cookie_check.get(key)
for key in ("fish_ball", "nickname", "level", "message")
}
login_task.ck_checked_at = cookie_check["checked_at"]
account.ck_checked_at = cookie_check["checked_at"]
worker_db.commit()
if not cookie_check["valid"]:
message = f"Cookie 已失效,请重新登录:{cookie_check['message']}"
+30 -36
View File
@@ -183,69 +183,63 @@ def ensure_douyu_config(db: Session) -> DouyuConfig:
return config
# 登录/重登尝试的在途状态:判断只看 attempt_*,与记录持有的 CK 无关。
# 登录/重登尝试的在途状态:判断只看 attempt_*,与账号持有的 CK 无关。
LOGIN_ATTEMPT_IN_FLIGHT_STATUSES = ("pending", "running")
def cookie_holding_filter():
"""判定登录记录当前是否仍持有 Cookie。
"""判定**账号**当前是否持有 CK。
账号是否有 CK 只取决于记录里是否还留着 Cookie。登录尝试(attempt_*)
在成功前不会碰 status/cookie/message,所以停止、失败、进程重启都不会
让账号失去 CK;只有 Cookie 真正被清空(账号检测成功)时才不再持有。
CK 是账号级凭证(2026-10-04 起从 `login_tasks.cookie` 迁到 `accounts.cookie`)。
判定只看账号自己,不再看任何登录记录的状态 —— 这正是解耦的目的:
删历史、批次排队、重登失败都不该让账号凭空「没有 CK」。
"""
return and_(
LoginTask.cookie.isnot(None),
LoginTask.cookie != "",
)
def latest_cookie_login_task(db: Session, account_id: int) -> LoginTask | None:
"""读取账号最近一条仍持有 Cookie 的登录记录。"""
return (
db.query(LoginTask)
.filter(
LoginTask.account_id == account_id,
cookie_holding_filter(),
)
.order_by(LoginTask.id.desc())
.first()
Account.cookie.isnot(None),
Account.cookie != "",
)
def cookie_operable_filter():
"""判定登录记录能否基于现有 Cookie 再做检测或重登。
"""判定**账号**能否基于现有 CK 再做检测或重登。
正在被批次处理的记录(attempt_* 处于在途状态)已由批次接管,并发再点
检测或重登只会互相覆盖,因此排除在外;CK 与 status 不参与判断。
在途排除仍然要查 `login_tasks`:「正被批次处理」是**某次尝试**的属性,
CK 才是账号的属性。所以这里是「账号有 CK」+「该账号没有在途尝试」。
走 `login_tasks(account_id, attempt_status)` 索引(迁移 0042 新增),
否则每轮选行都要回表。
"""
return and_(
cookie_holding_filter(),
func.coalesce(LoginTask.attempt_status, "").notin_(
LOGIN_ATTEMPT_IN_FLIGHT_STATUSES
),
in_flight = (
select(LoginTask.id)
.where(LoginTask.account_id == Account.id)
.where(
func.coalesce(LoginTask.attempt_status, "").in_(
LOGIN_ATTEMPT_IN_FLIGHT_STATUSES
)
)
.exists()
)
return and_(cookie_holding_filter(), ~in_flight)
def latest_cookie(db: Session, account_id: int) -> str:
"""读取账号当前 Cookie。"""
task = latest_cookie_login_task(db, account_id)
return task.cookie if task else ""
"""读取账号当前 CK。"""
account = db.query(Account.cookie).filter(Account.id == account_id).first()
return (account[0] if account else "") or ""
def cookie_holding_account_ids_query(db: Session):
"""返回当前仍持有 Cookie 的斗鱼账号 ID 查询。"""
return db.query(LoginTask.account_id).filter(cookie_holding_filter()).distinct()
"""返回当前仍持有 CK 的斗鱼账号 ID 查询。"""
return db.query(Account.id).filter(cookie_holding_filter())
def visible_douyu_task_accounts(db: Session, account_ids: list[int]) -> list[Account]:
"""只保留当前仍持有 Cookie 的斗鱼账号。"""
"""只保留当前仍持有 CK 的斗鱼账号。"""
if not account_ids:
return []
cookie_ids = select(LoginTask.account_id).where(cookie_holding_filter())
return (
db.query(Account)
.filter(Account.id.in_(account_ids), Account.id.in_(cookie_ids))
.filter(Account.id.in_(account_ids), cookie_holding_filter())
.all()
)
+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)