Files
live-hub-py/web/backend/services/account_check_service.py
T
yml2213 eec42aa8a7 feat(check): 账号检测新增「按账号选择」—— 旧的手动输入与逻辑完整保留
按反馈是**增加**而不是**替换**:旧路径一行未动,新路径另起一条。

## 两套来源并存

| | 手动输入(旧,默认) | 选择账号(新) |
| --- | --- | --- |
| 来源 | 粘贴文本 | 库里账号的凭据(不用人工粘贴) |
| 条目 `account_id` | **无** | 有 |
| 结果落账号时间线 | **否**(保持旧行为) | 是,新事件类型 `account_check` |
| 批次 / 并发 / 重试 / 代理 / 导出 zip | 完全不变 | 完全不变 |

**旧路径的「不发事件」是刻意的**:粘贴进来的账号没有归属,凭空造一条没有账号的
事件只是污染时间线。`parse_account_check_lines`、`export_line`、zip 分组全部未动,
有用例专门钉住这一点。

## 「选择账号」支持筛选(按反馈补充)

不是给一个扁平下拉,而是先按条件缩小范围再决定选法:

- 筛选:**标签 / 生命周期状态 / CK 健康**(复用账号列表同一套 `_filter_accounts_query`,
  两处口径不会不一致)
- 选法:手动多选(选项随筛选刷新)**或**「按筛选全选」(含列表里没显示出来的)

服务端支持 `select_all_matching + 各筛选条件`,与账号列表批量操作的字段形状一致。
可见性沿用账号域口径:客服只能检测自己名下的账号(越权 id 直接 403)。

## 结果表加账号回链

「选择账号」模式下条目带 `account_id`,账号列变成可点链接 → 直接跳到账号详情,
结果也在那里的时间线上。

## 排查中修掉两个 bug(都不是测试发现的)

**① `registry.create` 漏传 `account_id`**
它构造 `AccountCheckItemState` 时没把 `account_id` 从输入复制过去。
后果:批次照跑、结果照显示、zip 照导出,**事件永远不发且没有任何报错**。
是「跑一次真实检测再查事件表」才发现的(查出来 0 条)。已补,并加了用例。

**② 终态判断写成「键存在」而不是「非空」**
`_run_one` 置 `running` 时会显式传 `finished_at=None`,而判断写的是
`"finished_at" in updates` → **中间态也发事件**,于是时间线上记的是
`账号检查:running`;真正的终态再发时撞同一个幂等键被**静默丢弃** ——
结果是「只有一条,但记错了」,比没有更糟。

改成判非空。此处暴露了一个**假绿的用例**:我原来的「中间态不发事件」用例
没传 `finished_at=None`(与真实调用不一致),所以放过了这个 bug;
而且它还缺 `session.commit()`,在 MySQL REPEATABLE READ 下根本看不到
另一个会话刚提交的行 —— 双重假绿。两处都修了,并做了反向验证
(改回旧判断 → 用例立刻红)。

## 验证

- `TZ=Asia/Shanghai`:**520 passed / 6 skipped**(新增 17 条检测模式用例)
- `TZ=UTC`(检测 + 迁移)20 passed;ruff 全绿;前端 build 通过、lint 0 errors
- **真实端到端**:用真实库账号起批次 → 检测跑完(斗鱼返回「账号已认证」)→
  事件确实落库;清掉脏数据后用**与 `_run_one` 完全相同的调用序列**复验:
  两次 `_set_item` 只产生一条事件,且 title 是
  `账号检测:账号已认证`、`status=account_verified`(不再记成 running)

注:验证时真实触发了几次斗鱼登录检测(开发库那两个账号),结果都是「已认证」。
2026-10-04 17:34:58 +08:00

525 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""斗鱼账号检测批次执行器。"""
from __future__ import annotations
import io
import threading
import uuid
import zipfile
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from datetime import UTC, datetime
from types import SimpleNamespace
from typing import cast
from loguru import logger
from core.douyu import DouyuLogin, WgapiLoginAPI
from core.douyu.login import AccountLike
from core.douyu.proxy_fetcher import ProxyFetcher
from ..database import SessionLocal
from ..models import ProxyConfig as ProxyConfigModel
from .account_events_service import CATEGORY_AUTH, emit
def _now() -> datetime:
"""返回时区感知 UTC 时间。"""
return datetime.now(UTC)
#: 账号检测结果写进账号时间线的事件类型(仅「按账号选择」模式会有,
#: 模式下手动粘贴的文本**没有 account_id**,无从归属,保持原样不落库)。
EVENT_ACCOUNT_CHECK = "account_check"
STATUS_LABELS = {
"account_cancelled": "账号已注销",
"password_wrong": "账号密码错误",
"account_unverified": "账号未认证",
"account_verified": "账号已认证",
"account_auth_unknown": "认证状态未知",
"error": "检测失败",
"stopped": "已停止",
}
EXPORT_STATUS_ORDER = [
"account_cancelled",
"password_wrong",
"account_unverified",
"account_verified",
"account_auth_unknown",
"error",
"stopped",
]
@dataclass
class AccountCheckInput:
"""导入的一行斗鱼账号。"""
line: int
username: str
password: str
email: str
email_password: str
#: 「按账号选择」模式下才有:把检测结果归回库里的账号。
#: 手动粘贴的文本没有它 —— 那正是旧模式与账号域脱节的地方,保留原样。
account_id: int | None = None
def export_line(self) -> str:
"""导出为统一四段格式。"""
return f"{self.username}----{self.password}----{self.email}----{self.email_password}"
@dataclass
class AccountCheckItemState:
"""单个账号检测状态。"""
line: int
username: str
email: str
export_text: str
account_id: int | None = None
status: str = "pending"
message: str = "等待开始"
started_at: datetime | None = None
finished_at: datetime | None = None
def to_dict(self) -> dict:
return {
"line": self.line,
"username": self.username,
"email": self.email,
"account_id": self.account_id,
"status": self.status,
"message": self.message,
"started_at": self.started_at,
"finished_at": self.finished_at,
}
@dataclass
class AccountCheckBatch:
"""账号检测批次内存快照。"""
batch_id: str
created_by: int
concurrency: int
max_login_retries: int
max_total_time: float
use_proxy: bool
items: list[AccountCheckItemState]
status: str = "pending"
message: str = "等待开始"
created_at: datetime = field(default_factory=_now)
started_at: datetime | None = None
finished_at: datetime | None = None
def build_inputs_from_accounts(
accounts: list, account_ids: list[int]
) -> list[AccountCheckInput]:
"""把库里的账号转成检测输入(**按账号选择**模式)。
凭据直接取账号自己的字段 —— 这正是与旧模式的区别:旧模式要人工把凭据
粘贴进来,检测完结果无处可归;这里每个条目都带着 `account_id`,
结果能写回该账号的时间线。
只接受调用方已经按可见性筛过的账号列表(路由层负责过滤)。
"""
by_id = {int(a.id): a for a in accounts}
inputs: list[AccountCheckInput] = []
for line_no, account_id in enumerate(account_ids, 1):
account = by_id.get(int(account_id))
if account is None:
continue
inputs.append(
AccountCheckInput(
line=line_no,
username=str(account.username or ""),
password=str(account.password or ""),
email=str(account.email or ""),
email_password=str(account.email_password or ""),
account_id=int(account.id),
)
)
return inputs
def parse_account_check_lines(text: str) -> list[AccountCheckInput]:
"""解析账号检测导入文本,支持 ---- 和 | 两种分隔符。"""
accounts: list[AccountCheckInput] = []
for line_no, raw_line in enumerate(text.splitlines(), 1):
line = raw_line.strip()
if not line:
continue
separator = "----" if "----" in line else "|"
parts = [part.strip() for part in line.split(separator)]
if len(parts) != 4 or any(not part for part in parts):
raise ValueError(
f"第 {line_no} 行格式错误,请使用:账号----密码----邮箱----邮箱密码 "
f"或 账号|密码|邮箱|邮箱密码"
)
accounts.append(
AccountCheckInput(
line=line_no,
username=parts[0],
password=parts[1],
email=parts[2],
email_password=parts[3],
)
)
return accounts
class AccountCheckRunner:
"""在后台线程中批量检测斗鱼账号。"""
def __init__(
self,
batch: AccountCheckBatch,
accounts: list[AccountCheckInput],
proxy_config: ProxyConfigModel | None = None,
):
self.batch = batch
self.accounts = accounts
self.proxy_config = proxy_config
self._lock = threading.Lock()
self._stop = threading.Event()
self._shared_proxy_fetcher = self._create_proxy_fetcher()
def _create_proxy_fetcher(self) -> ProxyFetcher | None:
"""按全局代理配置创建 API 代理获取器。"""
if (
not self.batch.use_proxy
or not self.proxy_config
or not self.proxy_config.enabled
or not self.proxy_config.api_url
):
return None
wl_platform = "xiequ"
wl_credentials = None
if self.proxy_config.whitelist_enabled:
wl_platform = (
getattr(self.proxy_config, "whitelist_platform", None) or "xiequ"
)
wl_credentials = getattr(self.proxy_config, "whitelist_credentials", None)
if (
not wl_credentials
and self.proxy_config.whitelist_uid
and self.proxy_config.whitelist_ukey
):
wl_credentials = {
"uid": self.proxy_config.whitelist_uid,
"ukey": self.proxy_config.whitelist_ukey,
}
return ProxyFetcher(
api_url=self.proxy_config.api_url,
whitelist_platform=wl_platform,
whitelist_credentials=wl_credentials,
stop_event=self._stop,
)
def _resolve_static_proxy(self) -> tuple[dict[str, str] | None, str]:
"""解析静态代理;API 代理由 DouyuLogin 内部通过 proxy_fetcher 获取。"""
if not self.batch.use_proxy:
return None, ""
if not self.proxy_config or not self.proxy_config.enabled:
return None, "代理不可用: 未启用代理配置"
if self.proxy_config.http or self.proxy_config.https:
proxy_url = self.proxy_config.http or self.proxy_config.https
return {"http": proxy_url, "https": proxy_url}, ""
if self._shared_proxy_fetcher:
return None, ""
return None, "代理不可用: 未配置代理"
def stop(self):
self._stop.set()
with self._lock:
if self.batch.status == "running":
self.batch.message = "正在停止"
def _set_item(self, index: int, **updates):
with self._lock:
item = self.batch.items[index]
for key, value in updates.items():
setattr(item, key, value)
# ⚠️ 必须判**非空**而不是「键存在」:`_run_one` 置 running 时会显式传
# `finished_at=None`,用 `in` 判断会把中间态也当终态 —— 于是事件记为
# 「running」,而真正的终态撞同一个幂等键被静默丢弃(只剩一条错的)。
snapshot = dict(item.to_dict()) if updates.get("finished_at") else None
# 终态(带 finished_at)且这个条目归属于某个库内账号时,把结果写进它的时间线。
# 手动粘贴的条目没有 account_id → 保持旧行为(只在批次里 / 导出 zip),
# 这正是「旧逻辑保留」的部分。
if snapshot and snapshot.get("account_id"):
self._emit_result(snapshot)
def _emit_result(self, item: dict) -> None:
"""把一个条目的检测结果写成账号事件。
**绝不影响检测**:事件是旁路,任何失败都只记日志。
线程里自己开一个会话(批次跑在后台线程,不能借用请求的 session)。
"""
status = str(item.get("status") or "")
message = str(item.get("message") or "")
try:
db = SessionLocal()
try:
emit(
db,
account_id=int(item["account_id"]),
event_type=EVENT_ACCOUNT_CHECK,
title=f"账号检测:{STATUS_LABELS.get(status, status)}",
category=CATEGORY_AUTH,
occurred_at=item.get("finished_at") or _now(),
detail={
"batch_id": self.batch.batch_id,
"status": status,
"message": message,
},
source="account_check",
# 幂等键带上批次:同一次检测重试/重放不会写两条,
# 但再次检测同一账号是**新的事实**,应该各留一条。
source_ref=(
f"account_check:{self.batch.batch_id}:{item['account_id']}"
),
)
db.commit()
finally:
db.close()
except Exception as exc: # noqa: BLE001 旁路,绝不能影响检测
logger.warning(
f"账号检测结果写事件失败(account={item.get('account_id')}):{exc}"
)
def _set_batch(self, **updates):
with self._lock:
for key, value in updates.items():
setattr(self.batch, key, value)
def _run_one(self, index: int, account: AccountCheckInput):
if self._stop.is_set():
self._set_item(
index, status="stopped", message="已停止", finished_at=_now()
)
return
proxy_dict, proxy_error = self._resolve_static_proxy()
if proxy_error:
self._set_item(
index, status="error", message=proxy_error, finished_at=_now()
)
return
self._set_item(
index,
status="running",
message="检测中",
started_at=_now(),
finished_at=None,
)
try:
result = DouyuLogin(
cast(
AccountLike,
SimpleNamespace(
username=account.username,
password=account.password,
email=account.email,
email_password=account.email_password,
email_imap_server="",
email_imap_port=993,
email_imap_ssl=True,
),
),
proxy=proxy_dict,
max_login_retries=self.batch.max_login_retries,
max_total_time=self.batch.max_total_time,
proxy_fetcher=self._shared_proxy_fetcher,
stop_event=self._stop,
api_strategy=WgapiLoginAPI(),
).check_account()
except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
self._set_item(
index, status="error", message=f"检测异常: {exc}", finished_at=_now()
)
return
if self._stop.is_set() and not result.success:
self._set_item(
index,
status="stopped",
message=result.message or "已停止",
finished_at=_now(),
)
return
if result.success:
status = (
result.code if result.code in STATUS_LABELS else "account_auth_unknown"
)
message = result.message or STATUS_LABELS.get(status, "认证状态未知")
else:
status = "error"
message = result.message or "检测失败"
self._set_item(index, status=status, message=message, finished_at=_now())
def snapshot(self) -> dict:
with self._lock:
items = [item.to_dict() for item in self.batch.items]
status_counts = {
status: sum(1 for item in self.batch.items if item.status == status)
for status in STATUS_LABELS
}
running_count = sum(
1 for item in self.batch.items if item.status in {"pending", "running"}
)
finished_count = len(self.batch.items) - running_count
return {
"batch_id": self.batch.batch_id,
"status": self.batch.status,
"message": self.batch.message,
"created_by": self.batch.created_by,
"concurrency": self.batch.concurrency,
"max_login_retries": self.batch.max_login_retries,
"max_total_time": self.batch.max_total_time,
"use_proxy": self.batch.use_proxy,
"total": len(self.batch.items),
"finished_count": finished_count,
"running_count": running_count,
"status_counts": status_counts,
"created_at": self.batch.created_at,
"started_at": self.batch.started_at,
"finished_at": self.batch.finished_at,
"items": items,
}
def build_zip(self) -> tuple[bytes, str]:
"""按检测状态生成 zip 包。"""
with self._lock:
items = list(self.batch.items)
timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for status in EXPORT_STATUS_ORDER:
label = STATUS_LABELS[status]
lines: list[str] = []
for item in items:
if item.status != status:
continue
line = item.export_text
if (
status in {"error", "stopped", "account_auth_unknown"}
and item.message
):
line = f"{line}----{item.message}"
lines.append(line)
content = "\n".join(lines)
if content:
content += "\n"
zf.writestr(f"{timestamp}_{label}.txt", content.encode("utf-8"))
filename = f"account_check_{timestamp}.zip"
return buffer.getvalue(), filename
def run(self):
"""线程入口。"""
self._set_batch(
status="running", message="批次运行中", started_at=_now(), finished_at=None
)
try:
if self._shared_proxy_fetcher:
self._shared_proxy_fetcher.warmup_whitelist()
with ThreadPoolExecutor(max_workers=self.batch.concurrency) as executor:
futures = []
for index, account in enumerate(self.accounts):
if self._stop.is_set():
self._set_item(
index,
status="stopped",
message="已停止",
finished_at=_now(),
)
continue
futures.append(executor.submit(self._run_one, index, account))
for future in as_completed(futures):
future.result()
except Exception as exc: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
self._set_batch(
status="error", message=f"批次执行异常: {exc}", finished_at=_now()
)
return
if self._stop.is_set():
self._set_batch(status="stopped", message="批次已停止", finished_at=_now())
else:
self._set_batch(status="finished", message="批次已完成", finished_at=_now())
class AccountCheckRegistry:
"""管理账号检测批次。"""
def __init__(self):
self._lock = threading.Lock()
self._runners: dict[str, AccountCheckRunner] = {}
def create(
self,
accounts: list[AccountCheckInput],
created_by: int,
concurrency: int,
max_login_retries: int,
max_total_time: float,
use_proxy: bool = False,
proxy_config: ProxyConfigModel | None = None,
) -> AccountCheckRunner:
batch_id = uuid.uuid4().hex[:12]
normalized_retries = max_login_retries if max_login_retries > 0 else 20
batch = AccountCheckBatch(
batch_id=batch_id,
created_by=created_by,
concurrency=max(1, min(int(concurrency or 1), 10)),
max_login_retries=normalized_retries,
max_total_time=max(0.0, float(max_total_time or 0)),
use_proxy=bool(use_proxy),
items=[
AccountCheckItemState(
line=account.line,
username=account.username,
email=account.email,
export_text=account.export_line(),
# ⚠️ 必须带上:`_set_item` 靠它决定要不要把结果写进账号时间线。
# 漏了这一行 → 条目看着有账号,事件却永远不发(静默)。
account_id=account.account_id,
)
for account in accounts
],
)
runner = AccountCheckRunner(
batch=batch, accounts=accounts, proxy_config=proxy_config
)
with self._lock:
self._runners[batch_id] = runner
return runner
def get(self, batch_id: str) -> AccountCheckRunner | None:
with self._lock:
return self._runners.get(batch_id)
account_check_registry = AccountCheckRegistry()