Files
live-hub-py/web/backend/services/account_check_service.py
T
yml2213 5da12df16b feat(account-check): 登录接口可选 wgapi / iframe
账号检测原来把 WgapiLoginAPI 写死在执行器里,收不到邮箱验证码时只能干等;
登录任务早就可选 iframe(邮箱验证走 iframe 接口)。默认仍 wgapi,旧行为不变。
2026-10-06 20:19:26 +08:00

531 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, create_login_api_strategy
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]
#: 检测使用的登录接口策略(wgapi / iframe)—— 与登录任务同一套取值。
#: 默认 wgapi,保持账号检测原来的行为不变。
api_strategy: str = "wgapi"
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=create_login_api_strategy(self.batch.api_strategy),
).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,
"api_strategy": self.batch.api_strategy,
"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,
api_strategy: str = "wgapi",
) -> 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),
api_strategy=str(api_strategy or "wgapi"),
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()