"""斗鱼账号检测批次执行器。""" 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()