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 落地时要把自动化选行的状态过滤一起补上。
826 lines
34 KiB
Python
826 lines
34 KiB
Python
"""登录服务:复用 core/ 核心模块,在线程池中并发执行登录并推送日志。"""
|
||
|
||
import asyncio
|
||
import os
|
||
import threading
|
||
import time
|
||
import uuid
|
||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||
from datetime import UTC, datetime
|
||
from types import SimpleNamespace
|
||
from typing import cast
|
||
|
||
from loguru import logger
|
||
from sqlalchemy import and_, func, or_
|
||
from sqlalchemy.orm import Session
|
||
|
||
from core.douyu import DouyuLogin, IframeLoginAPI, WgapiLoginAPI
|
||
from core.douyu.login import AccountLike
|
||
from core.douyu.proxy_fetcher import ProxyFetcher
|
||
|
||
from ..models import Account as AccountModel
|
||
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 LOGIN_ATTEMPT_IN_FLIGHT_STATUSES
|
||
|
||
|
||
def _create_api_strategy(strategy_name: str):
|
||
"""根据名称创建登录接口策略实例。"""
|
||
if strategy_name == "iframe":
|
||
return IframeLoginAPI()
|
||
return WgapiLoginAPI()
|
||
|
||
|
||
CHECK_STATUS_MESSAGES = {
|
||
"account_cancelled": "账号已注销",
|
||
"password_wrong": "账号密码错误",
|
||
"account_unverified": "账号未认证",
|
||
"account_verified": "账号已认证",
|
||
"account_auth_unknown": "账号认证状态未知",
|
||
}
|
||
|
||
CHECK_STATUS_LOG_LEVELS = {
|
||
"account_cancelled": "warning",
|
||
"password_wrong": "error",
|
||
"account_unverified": "warning",
|
||
"account_verified": "success",
|
||
"account_auth_unknown": "warning",
|
||
}
|
||
|
||
|
||
def _positive_env_int(name: str, default: int) -> int:
|
||
"""读取正整数环境变量,非法值回退到默认值。"""
|
||
try:
|
||
value = int(os.getenv(name, str(default)))
|
||
except ValueError:
|
||
return default
|
||
return value if value > 0 else default
|
||
|
||
|
||
def get_relogin_limits() -> tuple[int, int]:
|
||
"""读取客服 CK 重登的重试与总时长限制。"""
|
||
return (
|
||
_positive_env_int("COOKIE_RELOGIN_MAX_RETRIES", 5),
|
||
_positive_env_int("COOKIE_RELOGIN_MAX_TOTAL_TIME", 600),
|
||
)
|
||
|
||
|
||
def _snapshot_proxy_config(
|
||
proxy_config: ProxyConfigModel | None,
|
||
) -> ProxyConfigModel | None:
|
||
"""复制代理配置,避免后台线程访问已关闭会话中的 ORM 对象。"""
|
||
if proxy_config is None:
|
||
return None
|
||
credentials = getattr(proxy_config, "whitelist_credentials", None)
|
||
return cast(
|
||
ProxyConfigModel,
|
||
SimpleNamespace(
|
||
enabled=bool(getattr(proxy_config, "enabled", False)),
|
||
http=getattr(proxy_config, "http", "") or "",
|
||
https=getattr(proxy_config, "https", "") or "",
|
||
api_url=getattr(proxy_config, "api_url", "") or "",
|
||
whitelist_enabled=bool(getattr(proxy_config, "whitelist_enabled", False)),
|
||
whitelist_platform=getattr(proxy_config, "whitelist_platform", None),
|
||
whitelist_credentials=dict(credentials)
|
||
if isinstance(credentials, dict)
|
||
else credentials,
|
||
whitelist_uid=getattr(proxy_config, "whitelist_uid", "") or "",
|
||
whitelist_ukey=getattr(proxy_config, "whitelist_ukey", "") or "",
|
||
),
|
||
)
|
||
|
||
|
||
ATTEMPT_IN_FLIGHT_STATUSES = ("pending", "running")
|
||
# 升级前把尝试状态写进 status 的历史取值;判断与清理时一并兼容。
|
||
_LEGACY_IN_FLIGHT_STATUSES = (
|
||
"pending",
|
||
"running",
|
||
"relogin_pending",
|
||
"relogin_running",
|
||
)
|
||
|
||
|
||
def _attempt_in_flight(task: LoginTask) -> bool:
|
||
"""判断这条记录是否正被某个批次处理(只看尝试字段,不看 CK 字段)。"""
|
||
if task.attempt_status in ATTEMPT_IN_FLIGHT_STATUSES:
|
||
return True
|
||
return not task.attempt_status and task.status in _LEGACY_IN_FLIGHT_STATUSES
|
||
|
||
|
||
def _begin_attempt(task: LoginTask, batch_id: str, message: str) -> None:
|
||
"""登记一次登录尝试。
|
||
|
||
只写 attempt_*:账号当前持有的 CK(status/cookie/message)在登录成功前
|
||
一律不动,所以批次被停止、失败或进程重启都不会让账号失去 CK。
|
||
"""
|
||
task.attempt_status = "pending"
|
||
task.attempt_message = message
|
||
task.attempt_batch_id = batch_id
|
||
task.attempt_at = None
|
||
|
||
|
||
def _mark_attempt_failed(
|
||
task: LoginTask, message: str, *, status: str = "failed"
|
||
) -> None:
|
||
"""记录一次失败/被中断的尝试;**账号已有的 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
|
||
account = task.account
|
||
if not (account and account.cookie):
|
||
# 账号还没有任何 CK 需要保护:保持旧的失败语义,便于复用记录与页面统计。
|
||
task.status = task.attempt_status
|
||
task.message = message
|
||
task.finished_at = completed_at
|
||
|
||
|
||
def _write_login_success(
|
||
db: Session,
|
||
task: LoginTask,
|
||
*,
|
||
cookie: str,
|
||
message: str,
|
||
batch_id: str,
|
||
event_type: str = EVENT_LOGIN_OK,
|
||
) -> None:
|
||
"""登录成功后把新 CK 写到**账号**上:这是唯一替换旧 CK 的入口。
|
||
|
||
顺带记一条账号事件 —— CK 是**覆盖式**字段,换掉之后就看不出「这账号什么时候
|
||
登录成功过、换过几次 CK」。事件是旁路(emit 内部吞异常),记不上也不影响登录。
|
||
"""
|
||
completed_at = datetime.now(UTC)
|
||
account = task.account
|
||
task.status = "success"
|
||
task.message = message
|
||
task.batch_id = batch_id
|
||
task.finished_at = completed_at
|
||
task.attempt_status = "success"
|
||
task.attempt_message = message
|
||
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,
|
||
event_type=event_type,
|
||
title="重新登录成功,Cookie 已更新"
|
||
if event_type == EVENT_RELOGIN_OK
|
||
else "登录成功",
|
||
occurred_at=completed_at,
|
||
detail={"message": message, "batch_id": batch_id},
|
||
source="login",
|
||
# 幂等键用登录记录 + 完成时间:同一账号多次登录是多个事件,
|
||
# 但同一次登录重复写回不会重复入库。
|
||
source_ref=f"login_task:{task.id}:{int(completed_at.timestamp())}",
|
||
)
|
||
|
||
|
||
class LoginBatchRunner:
|
||
"""批量登录执行器,在线程中运行,通过 ThreadPoolExecutor 并发登录多个账号。"""
|
||
|
||
def __init__(
|
||
self,
|
||
db: Session,
|
||
account_ids: list[int],
|
||
created_by: int,
|
||
creator_permissions: list[str],
|
||
max_login_retries: int = 0,
|
||
max_total_time: float = 0,
|
||
proxy_config: ProxyConfigModel | None = None,
|
||
log_queue: asyncio.Queue | None = None,
|
||
loop: asyncio.AbstractEventLoop | None = None,
|
||
concurrency: int = 3,
|
||
api_strategy: str = "wgapi",
|
||
mode: str = "login",
|
||
):
|
||
self.db = db
|
||
self.account_ids = account_ids
|
||
self.created_by = created_by
|
||
self.creator_permissions = creator_permissions
|
||
# 限制最大重试次数,避免无限重试导致资源耗尽
|
||
# 0 表示使用默认值 20,其他值保持原样
|
||
self.max_login_retries = max_login_retries if max_login_retries > 0 else 20
|
||
self.max_total_time = max_total_time
|
||
self.proxy_config = _snapshot_proxy_config(proxy_config)
|
||
self.log_queue = log_queue
|
||
self.loop = loop
|
||
self.batch_id = uuid.uuid4().hex[:12]
|
||
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._stop = threading.Event()
|
||
self._counter_lock = threading.Lock()
|
||
self._completed = 0
|
||
|
||
# 共享代理获取器(无池,每次取新代理)
|
||
self._shared_proxy_fetcher = None
|
||
proxy_config = self.proxy_config
|
||
if proxy_config and proxy_config.enabled and proxy_config.api_url:
|
||
wl_platform = "xiequ"
|
||
wl_credentials = None
|
||
if proxy_config.whitelist_enabled:
|
||
wl_platform = (
|
||
getattr(proxy_config, "whitelist_platform", None) or "xiequ"
|
||
)
|
||
wl_credentials = getattr(proxy_config, "whitelist_credentials", None)
|
||
# 向后兼容
|
||
if (
|
||
not wl_credentials
|
||
and proxy_config.whitelist_uid
|
||
and proxy_config.whitelist_ukey
|
||
):
|
||
wl_platform = "xiequ"
|
||
wl_credentials = {
|
||
"uid": proxy_config.whitelist_uid,
|
||
"ukey": proxy_config.whitelist_ukey,
|
||
}
|
||
|
||
self._shared_proxy_fetcher = ProxyFetcher(
|
||
api_url=proxy_config.api_url,
|
||
whitelist_platform=wl_platform,
|
||
whitelist_credentials=wl_credentials,
|
||
stop_event=self._stop,
|
||
)
|
||
|
||
def stop(self):
|
||
self._stop.set()
|
||
|
||
def _sleep_or_stop(self, seconds: float) -> bool:
|
||
"""可中断等待;返回 True 表示收到停止信号。"""
|
||
deadline = time.monotonic() + seconds
|
||
while time.monotonic() < deadline:
|
||
if self._stop.is_set():
|
||
return True
|
||
time.sleep(min(0.2, deadline - time.monotonic()))
|
||
return self._stop.is_set()
|
||
|
||
def _mark_relogin_stopped(
|
||
self,
|
||
task_id: int,
|
||
message: str = "重新登录已停止,旧 Cookie 已保留",
|
||
) -> None:
|
||
"""把未开始或已中断的重登尝试收敛为终态,旧 CK 原封不动。"""
|
||
worker_db = SessionLocal()
|
||
try:
|
||
task = worker_db.query(LoginTask).filter(LoginTask.id == task_id).first()
|
||
if task and _attempt_in_flight(task):
|
||
_mark_attempt_failed(task, message)
|
||
worker_db.commit()
|
||
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:
|
||
log_level = (
|
||
level
|
||
if level in {"debug", "info", "warning", "error", "success"}
|
||
else "debug"
|
||
)
|
||
getattr(logger, log_level)(f"[登录批次 {self.batch_id}] {message}")
|
||
if self.log_queue and self.loop:
|
||
asyncio.run_coroutine_threadsafe(
|
||
self.log_queue.put({"level": level, "message": message}),
|
||
self.loop,
|
||
)
|
||
|
||
def _resolve_static_proxy(self) -> tuple[dict | None, str]:
|
||
"""解析静态代理配置。"""
|
||
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}, f"使用静态代理: {proxy_url}"
|
||
|
||
# API代理:由 DouyuLogin 通过 proxy_fetcher 内部管理
|
||
return None, ""
|
||
|
||
def _execute_one(self, task_id: int, acc_info: dict, total: int):
|
||
"""在独立线程中执行单个账号登录,使用独立的 DB 会话。"""
|
||
if self._stop.is_set():
|
||
self._push_log("warning", f"任务已停止,跳过: {acc_info['username']}")
|
||
self._mark_relogin_stopped(task_id, "任务已停止,旧 Cookie 已保留")
|
||
return
|
||
|
||
worker_db = SessionLocal()
|
||
try:
|
||
task = worker_db.query(LoginTask).filter(LoginTask.id == task_id).first()
|
||
if not task:
|
||
return
|
||
# CK、健康与重登状态都挂在账号上;登录记录只是一次尝试的历史。
|
||
account = task.account
|
||
|
||
# 只推进尝试状态;账号持有的旧 CK 在成功前保持不变。
|
||
task.attempt_status = "running"
|
||
task.attempt_message = (
|
||
"正在重新登录,旧 Cookie 保留中"
|
||
if self.mode == "relogin"
|
||
else "正在检测"
|
||
if self.mode == "check"
|
||
else "正在登录"
|
||
)
|
||
worker_db.commit()
|
||
|
||
with self._counter_lock:
|
||
self._completed += 1
|
||
current = self._completed
|
||
|
||
action_name = (
|
||
"检测"
|
||
if self.mode == "check"
|
||
else "重新登录"
|
||
if self.mode == "relogin"
|
||
else "登录"
|
||
)
|
||
self._push_log(
|
||
"info", f"[{current}/{total}] 开始{action_name}: {acc_info['username']}"
|
||
)
|
||
|
||
try:
|
||
# 代理配置也可能异常,必须由当前任务的失败处理收敛状态。
|
||
proxy_dict, proxy_msg = self._resolve_static_proxy()
|
||
if proxy_msg:
|
||
self._push_log("info", f"[{current}] {proxy_msg}")
|
||
|
||
# 静态代理启用但配置为空 → 不可用
|
||
if (
|
||
self.proxy_config
|
||
and self.proxy_config.enabled
|
||
and not (self.proxy_config.http or self.proxy_config.https)
|
||
and not self._shared_proxy_fetcher
|
||
and not proxy_dict
|
||
):
|
||
if self.mode == "relogin":
|
||
_mark_attempt_failed(
|
||
task,
|
||
"重新登录失败: 代理不可用: 未配置代理(旧 Cookie 已保留)",
|
||
)
|
||
else:
|
||
_mark_attempt_failed(
|
||
task, "代理不可用: 未配置代理", status="error"
|
||
)
|
||
worker_db.commit()
|
||
self._push_log(
|
||
"error", f"[{current}] {acc_info['username']} 代理不可用"
|
||
)
|
||
return
|
||
|
||
account = cast(
|
||
AccountLike,
|
||
SimpleNamespace(
|
||
username=acc_info["username"],
|
||
password=acc_info["password"],
|
||
email=acc_info["email"],
|
||
email_password=acc_info["email_password"],
|
||
email_imap_server=acc_info["email_imap_server"] or "",
|
||
email_imap_port=acc_info["email_imap_port"] or 993,
|
||
email_imap_ssl=acc_info["email_imap_ssl"],
|
||
),
|
||
)
|
||
|
||
loginer = DouyuLogin(
|
||
account,
|
||
proxy=proxy_dict,
|
||
max_login_retries=self.max_login_retries,
|
||
max_total_time=self.max_total_time,
|
||
proxy_fetcher=self._shared_proxy_fetcher,
|
||
stop_event=self._stop,
|
||
api_strategy=self.api_strategy,
|
||
)
|
||
result = (
|
||
loginer.check_account() if self.mode == "check" else loginer.login()
|
||
)
|
||
|
||
if self.mode == "check" and result.success:
|
||
status = (
|
||
result.code
|
||
if result.code in CHECK_STATUS_MESSAGES
|
||
else "account_auth_unknown"
|
||
)
|
||
task.status = status
|
||
# ⚠️ 行为变更(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
|
||
task.attempt_message = task.message
|
||
task.attempt_at = datetime.now(UTC)
|
||
task.finished_at = datetime.now(UTC)
|
||
level = CHECK_STATUS_LOG_LEVELS.get(status, "info")
|
||
self._push_log(
|
||
level,
|
||
f"[{current}] {acc_info['username']} 检测结果: {task.message}",
|
||
)
|
||
elif result.success:
|
||
message = result.message or "登录成功"
|
||
if self.mode == "relogin" and account is not None:
|
||
check_result = check_douyu_cookie(result.cookie)
|
||
# 复检结论写账号(CK 在哪,健康状态就在哪)。
|
||
account.ck_check_status = (
|
||
"valid" if check_result["valid"] else "invalid"
|
||
)
|
||
account.ck_check_result = {
|
||
"fish_ball": check_result["fish_ball"],
|
||
"nickname": check_result["nickname"],
|
||
"level": check_result["level"],
|
||
"message": check_result["message"],
|
||
}
|
||
account.ck_checked_at = check_result["checked_at"]
|
||
if check_result["valid"]:
|
||
message = "重新登录成功,Cookie 有效"
|
||
else:
|
||
message = f"重新登录成功,但 Cookie 有效性检测失败: {check_result['message']}"
|
||
# 唯一替换旧 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(
|
||
worker_db,
|
||
task,
|
||
cookie=result.cookie,
|
||
message=message,
|
||
batch_id=self.batch_id,
|
||
event_type=(
|
||
EVENT_RELOGIN_OK
|
||
if self.mode == "relogin"
|
||
else EVENT_LOGIN_OK
|
||
),
|
||
)
|
||
if self.mode == "relogin":
|
||
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 有效性检测失败: {detail}",
|
||
)
|
||
else:
|
||
self._push_log(
|
||
"success",
|
||
f"[{current}] {acc_info['username']} {task.message}",
|
||
)
|
||
else:
|
||
if self.mode == "relogin":
|
||
# 重登失败不动旧 CK 与成功状态,只记录这次尝试失败
|
||
_mark_attempt_failed(
|
||
task, f"重新登录失败: {result.message}(旧 Cookie 已保留)"
|
||
)
|
||
self._push_log(
|
||
"error",
|
||
f"[{current}] {acc_info['username']} 重新登录失败: {result.message}",
|
||
)
|
||
else:
|
||
_mark_attempt_failed(task, result.message or "登录失败")
|
||
self._push_log(
|
||
"error",
|
||
f"[{current}] {acc_info['username']} {action_name}失败: {result.message}",
|
||
)
|
||
|
||
except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
|
||
if self.mode == "relogin":
|
||
_mark_attempt_failed(task, f"重新登录异常: {e}(旧 Cookie 已保留)")
|
||
self._push_log(
|
||
"error", f"[{current}] {acc_info['username']} 重新登录异常: {e}"
|
||
)
|
||
else:
|
||
_mark_attempt_failed(task, str(e), status="error")
|
||
self._push_log(
|
||
"error",
|
||
f"[{current}] {acc_info['username']} {action_name}异常: {e}",
|
||
)
|
||
|
||
worker_db.commit()
|
||
|
||
finally:
|
||
worker_db.close()
|
||
|
||
def run(self):
|
||
"""在线程中执行批量登录。"""
|
||
batch_id = self.batch_id
|
||
concurrency = self.concurrency
|
||
action_name = (
|
||
"账号检测"
|
||
if self.mode == "check"
|
||
else "重新登录"
|
||
if self.mode == "relogin"
|
||
else "登录"
|
||
)
|
||
self._push_log(
|
||
"info",
|
||
f"批量{action_name}任务 {batch_id} 开始,共 {len(self.account_ids)} 个账号,并发数: {concurrency}",
|
||
)
|
||
|
||
# 批次开始前同步一次出口 IP 到白名单,后续 fetch_new_proxy 不再主动同步
|
||
if self._shared_proxy_fetcher:
|
||
ok, msg = self._shared_proxy_fetcher.warmup_whitelist()
|
||
if msg != "无白名单凭据,跳过":
|
||
self._push_log("info" if ok else "warning", f"白名单预热: {msg}")
|
||
|
||
def _append_task_info(task: LoginTask, acc: AccountModel):
|
||
# 只登记这次尝试:账号当前持有的 CK(status/cookie/message)在
|
||
# 登录成功前一律不动,批次被停止或失败都不会让账号失去 CK。
|
||
_begin_attempt(
|
||
task,
|
||
batch_id,
|
||
"等待重新登录,旧 Cookie 已保留"
|
||
if self.mode == "relogin"
|
||
else "等待检测"
|
||
if self.mode == "check"
|
||
else "等待登录",
|
||
)
|
||
self.db.flush()
|
||
task_infos.append(
|
||
{
|
||
"task_id": task.id,
|
||
"acc_info": {
|
||
"username": acc.username,
|
||
"password": acc.password,
|
||
"email": acc.email,
|
||
"email_password": acc.email_password,
|
||
"email_imap_server": acc.email_imap_server or "",
|
||
"email_imap_port": acc.email_imap_port or 993,
|
||
"email_imap_ssl": acc.email_imap_ssl
|
||
if acc.email_imap_ssl is not None
|
||
else True,
|
||
},
|
||
}
|
||
)
|
||
|
||
try:
|
||
# 创建或复用任务记录(顺序执行,线程安全)
|
||
#
|
||
# **重登模式也从 account_ids 走**(2026-10-04 CK 解耦):以前重登传的是
|
||
# 「持有 CK 的记录 id」,因为 CK 挂在记录上;现在 CK 在账号上,调度单位就是
|
||
# 账号,登录记录只是「这次尝试」的历史 —— 顺带让**没有登录历史的账号**
|
||
# 也能被重登(以前它根本进不了候选)。
|
||
task_infos: list[dict] = [] # {task_id, acc_info}
|
||
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 在账号上)。
|
||
# 这里只需要保证**同一账号不并发两行**:
|
||
# ① 复用该账号在途的尝试(避免并发两个批次各建一行);
|
||
# ② 否则复用最近一条失败/中断的历史,避免刷屏;
|
||
# ③ 都没有就新建一行(历史而已,允许一个账号多行)。
|
||
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,
|
||
or_(
|
||
LoginTask.status.in_(["failed", "error"]),
|
||
LoginTask.attempt_status.in_(["failed", "error"]),
|
||
),
|
||
)
|
||
.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)
|
||
|
||
_append_task_info(task, acc)
|
||
|
||
self.db.commit()
|
||
total = len(task_infos)
|
||
if total == 0:
|
||
self._push_log("warning", "没有可执行的账号")
|
||
self._push_log("result", "")
|
||
return
|
||
|
||
# 并发执行登录,每个账号独立获取代理
|
||
with ThreadPoolExecutor(max_workers=concurrency) as executor:
|
||
futures = []
|
||
for item_index, item in enumerate(task_infos):
|
||
if self._stop.is_set():
|
||
self._push_log("warning", "任务已停止,跳过剩余账号")
|
||
# 所有模式都要把没轮到的尝试收敛掉,否则会一直停在
|
||
# 「等待中」;已有 CK 的记录本身不受影响。
|
||
for pending_item in task_infos[item_index:]:
|
||
self._mark_relogin_stopped(
|
||
pending_item["task_id"], "任务已停止,旧 Cookie 已保留"
|
||
)
|
||
break
|
||
future = executor.submit(
|
||
self._execute_one,
|
||
item["task_id"],
|
||
item["acc_info"],
|
||
total,
|
||
)
|
||
futures.append(future)
|
||
|
||
# 等待所有任务完成
|
||
for future in as_completed(futures):
|
||
try:
|
||
future.result()
|
||
except Exception as e: # noqa: BLE001 外部接口与任务边界需要保留宽泛异常兜底
|
||
self._push_log("error", f"Worker 异常: {e}")
|
||
|
||
self._push_log("info", f"批量{action_name}任务 {batch_id} 完成")
|
||
self._push_log("result", "")
|
||
finally:
|
||
# 标记批次结束:WS 端据此决定何时清理注册表(断线重连可继续订阅日志)。
|
||
batch_registry.mark_finished(batch_id)
|
||
# 确保 DB Session 被关闭,避免连接泄漏
|
||
self.db.close()
|
||
|
||
|
||
class BatchRegistry:
|
||
"""管理运行中的登录批次状态。"""
|
||
|
||
def __init__(self):
|
||
self._batches: dict[str, dict] = {}
|
||
|
||
def register(
|
||
self,
|
||
batch_id: str,
|
||
log_queue: asyncio.Queue | None,
|
||
loop: asyncio.AbstractEventLoop | None,
|
||
runner: LoginBatchRunner,
|
||
owner_id: int | None = None,
|
||
):
|
||
self._batches[batch_id] = {
|
||
"log_queue": log_queue,
|
||
"loop": loop,
|
||
"runner": runner,
|
||
"owner_id": owner_id,
|
||
"finished": False,
|
||
"finished_at": None,
|
||
}
|
||
|
||
def get(self, batch_id: str):
|
||
return self._batches.get(batch_id)
|
||
|
||
def mark_finished(self, batch_id: str):
|
||
"""标记批次已结束(幂等;不在本注册表的批次为无操作)。
|
||
|
||
与虎牙批次一致:WS 端只在 finished 后 pop,客户端断线重连仍可订阅
|
||
到运行中批次的实时日志。
|
||
"""
|
||
batch = self._batches.get(batch_id)
|
||
if batch:
|
||
batch["finished"] = True
|
||
batch["finished_at"] = time.time()
|
||
|
||
def pop(self, batch_id: str):
|
||
return self._batches.pop(batch_id, None)
|
||
|
||
|
||
# 模块级单例
|
||
batch_registry = BatchRegistry()
|
||
|
||
|
||
def _converge_interrupted_attempts(db: Session, message: str) -> int:
|
||
"""把遗留的在途尝试收敛为失败。
|
||
|
||
只写 attempt_*;对已经没有 CK 的记录才顺带落一个失败终态。返回收敛条数。
|
||
"""
|
||
tasks = (
|
||
db.query(LoginTask)
|
||
.filter(
|
||
or_(
|
||
LoginTask.attempt_status.in_(ATTEMPT_IN_FLIGHT_STATUSES),
|
||
LoginTask.status.in_(_LEGACY_IN_FLIGHT_STATUSES),
|
||
)
|
||
)
|
||
.all()
|
||
)
|
||
cleaned = 0
|
||
for task in tasks:
|
||
if not _attempt_in_flight(task):
|
||
continue
|
||
_mark_attempt_failed(task, message)
|
||
cleaned += 1
|
||
if cleaned:
|
||
db.commit()
|
||
return cleaned
|
||
|
||
|
||
def cleanup_orphan_relogin_tasks(
|
||
db: Session,
|
||
message: str = "重新登录已中断(服务重启),旧 Cookie 已保留",
|
||
) -> int:
|
||
"""服务重启后收敛遗留的重登尝试,旧 CK 原封不动。"""
|
||
return _converge_interrupted_attempts(db, message)
|
||
|
||
|
||
def cleanup_orphan_login_tasks(
|
||
db: Session,
|
||
message: str = "登录已中断(服务重启),旧 Cookie 已保留",
|
||
) -> int:
|
||
"""服务重启后收敛遗留的登录尝试。
|
||
|
||
在途尝试只体现在 attempt_* 上,收敛它不会碰到账号已有的 CK;只有本来
|
||
就没有 CK 的记录才会顺带标记失败,避免页面永久显示「排队中」。
|
||
"""
|
||
return _converge_interrupted_attempts(db, message)
|
||
|
||
|
||
# 在模块末尾导入 SessionLocal(避免循环导入)
|
||
from ..database import SessionLocal
|