Files
live-hub-py/web/backend/services/account_check_service.py
T

431 lines
14 KiB
Python

"""斗鱼账号检测批次执行器。"""
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 datetime, timezone
from types import SimpleNamespace
from typing import Optional, cast
from core.douyu import DouyuLogin, WgapiLoginAPI
from core.douyu.login import AccountLike
from core.douyu.proxy_fetcher import ProxyFetcher
from ..models import ProxyConfig as ProxyConfigModel
def _now() -> datetime:
"""返回时区感知 UTC 时间。"""
return datetime.now(timezone.utc)
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
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
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,
"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 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: Optional[ProxyConfigModel] = 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)
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:
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().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:
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: Optional[ProxyConfigModel] = 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(),
)
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()