From 72125ec4fc87136af2373b8c6f2341a73d684df9 Mon Sep 17 00:00:00 2001 From: okxlin <61420215+okxlin@users.noreply.github.com> Date: Sat, 28 Mar 2026 13:34:30 +0000 Subject: [PATCH] feat: sync merge-xbin-2 updates as a single squashed commit --- src/config/settings.py | 88 +++ src/core/auto_registration.py | 297 ++++++++ src/core/db_logs.py | 3 +- src/core/openai/token_refresh.py | 5 +- src/core/register.py | 50 +- src/core/timezone_utils.py | 4 + src/core/upload/cpa_upload.py | 67 +- src/database/crud.py | 7 +- src/database/models.py | 45 +- src/web/app.py | 128 ++-- src/web/routes/accounts.py | 22 +- src/web/routes/email.py | 5 +- src/web/routes/logs.py | 4 +- src/web/routes/payment.py | 61 +- src/web/routes/registration.py | 260 ++++++- src/web/routes/settings.py | 124 ++- src/web/routes/upload/cpa_services.py | 5 +- src/web/routes/upload/sub2api_services.py | 5 +- src/web/routes/upload/tm_services.py | 5 +- src/web/routes/websocket.py | 2 + src/web/task_manager.py | 11 +- static/js/app.js | 346 ++++++++- templates/index.html | 105 ++- tests/test_auto_registration_merge.py | 711 ++++++++++++++++++ .../test_settings_registration_auto_fields.py | 149 ++++ tmp_app_core.js | 11 - tmp_redirectToPage.js | 2 - 27 files changed, 2321 insertions(+), 201 deletions(-) create mode 100644 src/core/auto_registration.py create mode 100644 tests/test_auto_registration_merge.py create mode 100644 tests/test_settings_registration_auto_fields.py delete mode 100644 tmp_app_core.js delete mode 100644 tmp_redirectToPage.js diff --git a/src/config/settings.py b/src/config/settings.py index f137153b..4d559201 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -254,6 +254,72 @@ class SettingDefinition: category=SettingCategory.REGISTRATION, description="注册入口链路(native=原本链路, abcard=ABCard入口链路;Outlook 邮箱会自动走 Outlook 链路)" ), + "registration_auto_enabled": SettingDefinition( + db_key="registration.auto.enabled", + default_value=False, + category=SettingCategory.REGISTRATION, + description="是否启用自动注册补货" + ), + "registration_auto_check_interval": SettingDefinition( + db_key="registration.auto.check_interval", + default_value=60, + category=SettingCategory.REGISTRATION, + description="自动注册库存检查间隔(秒)" + ), + "registration_auto_min_ready_auth_files": SettingDefinition( + db_key="registration.auto.min_ready_auth_files", + default_value=1, + category=SettingCategory.REGISTRATION, + description="自动注册保底可用认证文件数量" + ), + "registration_auto_email_service_type": SettingDefinition( + db_key="registration.auto.email_service_type", + default_value="tempmail", + category=SettingCategory.REGISTRATION, + description="自动注册使用的邮箱服务类型" + ), + "registration_auto_email_service_id": SettingDefinition( + db_key="registration.auto.email_service_id", + default_value=0, + category=SettingCategory.REGISTRATION, + description="自动注册绑定的邮箱服务 ID(0 表示自动选择)" + ), + "registration_auto_proxy": SettingDefinition( + db_key="registration.auto.proxy", + default_value="", + category=SettingCategory.REGISTRATION, + description="自动注册固定代理地址(留空则沿用系统策略)" + ), + "registration_auto_interval_min": SettingDefinition( + db_key="registration.auto.interval_min", + default_value=5, + category=SettingCategory.REGISTRATION, + description="自动注册批量任务最小启动间隔(秒)" + ), + "registration_auto_interval_max": SettingDefinition( + db_key="registration.auto.interval_max", + default_value=30, + category=SettingCategory.REGISTRATION, + description="自动注册批量任务最大启动间隔(秒)" + ), + "registration_auto_concurrency": SettingDefinition( + db_key="registration.auto.concurrency", + default_value=1, + category=SettingCategory.REGISTRATION, + description="自动注册批量任务并发数" + ), + "registration_auto_mode": SettingDefinition( + db_key="registration.auto.mode", + default_value="pipeline", + category=SettingCategory.REGISTRATION, + description="自动注册批量任务模式" + ), + "registration_auto_cpa_service_id": SettingDefinition( + db_key="registration.auto.cpa_service_id", + default_value=0, + category=SettingCategory.REGISTRATION, + description="自动注册监控并回传的 CPA 服务 ID" + ), # 邮箱服务配置 "email_service_priority": SettingDefinition( @@ -450,6 +516,17 @@ class SettingDefinition: "registration_sleep_min": int, "registration_sleep_max": int, "registration_entry_flow": str, + "registration_auto_enabled": bool, + "registration_auto_check_interval": int, + "registration_auto_min_ready_auth_files": int, + "registration_auto_email_service_type": str, + "registration_auto_email_service_id": int, + "registration_auto_proxy": str, + "registration_auto_interval_min": int, + "registration_auto_interval_max": int, + "registration_auto_concurrency": int, + "registration_auto_mode": str, + "registration_auto_cpa_service_id": int, "email_service_priority": dict, "tempmail_enabled": bool, "tempmail_timeout": int, @@ -718,6 +795,17 @@ def proxy_url(self) -> Optional[str]: registration_sleep_min: int = 5 registration_sleep_max: int = 30 registration_entry_flow: str = "native" + registration_auto_enabled: bool = False + registration_auto_check_interval: int = 60 + registration_auto_min_ready_auth_files: int = 1 + registration_auto_email_service_type: str = "tempmail" + registration_auto_email_service_id: int = 0 + registration_auto_proxy: str = "" + registration_auto_interval_min: int = 5 + registration_auto_interval_max: int = 30 + registration_auto_concurrency: int = 1 + registration_auto_mode: str = "pipeline" + registration_auto_cpa_service_id: int = 0 # 邮箱服务配置 email_service_priority: Dict[str, int] = {"tempmail": 0, "yyds_mail": 1, "outlook": 2, "moe_mail": 3} diff --git a/src/core/auto_registration.py b/src/core/auto_registration.py new file mode 100644 index 00000000..8535e9cd --- /dev/null +++ b/src/core/auto_registration.py @@ -0,0 +1,297 @@ +import asyncio +import logging +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Awaitable, Callable, Optional + +from ..config.settings import Settings, get_settings +from .upload.cpa_upload import count_ready_cpa_auth_files, list_cpa_auth_files +from ..database import crud +from ..database.session import get_db + +logger = logging.getLogger(__name__) +AUTO_REGISTRATION_CHANNEL = "auto-registration" +_auto_registration_state = { + "enabled": False, + "status": "idle", + "message": "自动注册未启动", + "current_batch_id": None, + "current_ready_count": None, + "target_ready_count": None, + "last_checked_at": None, + "last_triggered_at": None, +} +_coordinator_instance = None + + +def _timestamp() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _remaining_delay(target_time: float, now: float) -> float: + return max(0.0, target_time - now) + + +def update_auto_registration_state(**kwargs) -> dict: + _auto_registration_state.update(kwargs) + return get_auto_registration_state() + + +def get_auto_registration_state() -> dict: + return dict(_auto_registration_state) + + +def register_auto_registration_coordinator( + coordinator: Optional["AutoRegistrationCoordinator"], +) -> None: + global _coordinator_instance + _coordinator_instance = coordinator + + +def trigger_auto_registration_check() -> None: + coordinator = _coordinator_instance + if coordinator is not None: + coordinator.request_immediate_check() + + +def add_auto_registration_log(message: str) -> None: + from ..web.task_manager import task_manager + + task_manager.add_batch_log(AUTO_REGISTRATION_CHANNEL, message) + + +def get_auto_registration_logs() -> list[str]: + from ..web.task_manager import task_manager + + return task_manager.get_batch_logs(AUTO_REGISTRATION_CHANNEL) + + +@dataclass +class AutoRegistrationPlan: + deficit: int + ready_count: int + min_ready_auth_files: int + cpa_service_id: int + + +def get_auto_registration_inventory( + settings: Settings, +) -> Optional[tuple[int, int, int]]: + cpa_service_id = int(settings.registration_auto_cpa_service_id or 0) + if cpa_service_id <= 0: + logger.warning("自动注册已启用,但未配置 CPA 服务 ID,跳过库存检查") + return None + + with get_db() as db: + cpa_service = crud.get_cpa_service_by_id(db, cpa_service_id) + + if not cpa_service: + logger.warning("自动注册目标 CPA 服务不存在: %s", cpa_service_id) + return None + + if not cpa_service.enabled: + logger.warning("自动注册目标 CPA 服务已禁用: %s", cpa_service.name) + return None + + success, payload, message = list_cpa_auth_files( + str(cpa_service.api_url), + str(cpa_service.api_token), + ) + if not success: + logger.warning("自动注册读取 auth-files 库存失败: %s", message) + return None + + ready_count = count_ready_cpa_auth_files(payload) + min_ready_auth_files = max(1, int(settings.registration_auto_min_ready_auth_files)) + deficit = max(0, min_ready_auth_files - ready_count) + return ready_count, min_ready_auth_files, deficit + + +def build_auto_registration_plan(settings: Settings) -> Optional[AutoRegistrationPlan]: + if not settings.registration_auto_enabled: + return None + + cpa_service_id = int(settings.registration_auto_cpa_service_id or 0) + inventory = get_auto_registration_inventory(settings) + if inventory is None: + return None + + ready_count, min_ready_auth_files, deficit = inventory + if deficit <= 0: + logger.info( + "自动注册库存充足,当前可用 %s / 目标 %s", + ready_count, + min_ready_auth_files, + ) + + return AutoRegistrationPlan( + deficit=deficit, + ready_count=ready_count, + min_ready_auth_files=min_ready_auth_files, + cpa_service_id=cpa_service_id, + ) + + +class AutoRegistrationCoordinator: + def __init__( + self, + trigger_callback: Callable[[AutoRegistrationPlan, Settings], Awaitable[Any]], + settings_getter: Callable[[], Settings] = get_settings, + plan_builder: Callable[ + [Settings], Optional[AutoRegistrationPlan] + ] = build_auto_registration_plan, + ): + self._trigger_callback = trigger_callback + self._settings_getter = settings_getter + self._plan_builder = plan_builder + self._task: Optional[asyncio.Task] = None + self._cycle_lock = asyncio.Lock() + self._wake_event = asyncio.Event() + + def start(self) -> None: + if self._task and not self._task.done(): + return + update_auto_registration_state( + enabled=bool(self._settings_getter().registration_auto_enabled), + status="idle", + message="自动注册协调器已启动", + ) + self._task = asyncio.create_task( + self._run_forever(), name="auto-registration-loop" + ) + + async def stop(self) -> None: + if not self._task: + return + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + finally: + self._task = None + + def request_immediate_check(self) -> None: + self._wake_event.set() + + async def run_once(self) -> Optional[AutoRegistrationPlan]: + if self._cycle_lock.locked(): + logger.info("自动注册上一轮仍在执行,跳过重入检查") + add_auto_registration_log("[自动注册] 上一轮补货仍在执行,跳过本次重入检查") + return None + + async with self._cycle_lock: + settings = self._settings_getter() + update_auto_registration_state( + enabled=bool(settings.registration_auto_enabled), + status="disabled" + if not settings.registration_auto_enabled + else "checking", + message="自动注册已禁用" + if not settings.registration_auto_enabled + else "正在检查 auth-files 库存", + last_checked_at=_timestamp() + if not settings.registration_auto_enabled + else None, + current_batch_id=None + if not settings.registration_auto_enabled + else _auto_registration_state.get("current_batch_id"), + current_ready_count=None + if not settings.registration_auto_enabled + else _auto_registration_state.get("current_ready_count"), + ) + if not settings.registration_auto_enabled: + return None + + add_auto_registration_log("[自动注册] 开始检查 CPA auth-files 库存") + plan = await asyncio.to_thread(self._plan_builder, settings) + if not plan: + update_auto_registration_state( + status="idle", + message="检查完成,当前无需补货或配置不可用", + last_checked_at=_timestamp(), + current_batch_id=None, + current_ready_count=None, + ) + add_auto_registration_log("[自动注册] 检查完成,当前无需补货") + return None + + if plan.deficit <= 0: + update_auto_registration_state( + status="idle", + message=f"检查完成,当前 codex 库存充足 ({plan.ready_count}/{plan.min_ready_auth_files})", + current_ready_count=plan.ready_count, + target_ready_count=plan.min_ready_auth_files, + last_checked_at=_timestamp(), + current_batch_id=None, + ) + add_auto_registration_log( + f"[自动注册] 检查完成,当前 codex 库存充足 ({plan.ready_count}/{plan.min_ready_auth_files})" + ) + return None + + logger.info( + "自动注册准备补货,当前可用 %s / 目标 %s,计划新增 %s", + plan.ready_count, + plan.min_ready_auth_files, + plan.deficit, + ) + add_auto_registration_log( + f"[自动注册] 库存不足,当前可用 {plan.ready_count} / 目标 {plan.min_ready_auth_files},开始补货 {plan.deficit} 个" + ) + update_auto_registration_state( + status="running", + message="自动补货任务运行中", + current_ready_count=plan.ready_count, + target_ready_count=plan.min_ready_auth_files, + last_checked_at=_timestamp(), + last_triggered_at=_timestamp(), + ) + await self._trigger_callback(plan, settings) + return plan + + async def _run_forever(self) -> None: + loop = asyncio.get_running_loop() + next_check_at: Optional[float] = None + + while True: + settings = self._settings_getter() + interval = max(5, int(settings.registration_auto_check_interval or 60)) + update_auto_registration_state( + enabled=bool(settings.registration_auto_enabled), + target_ready_count=max( + 1, int(settings.registration_auto_min_ready_auth_files or 1) + ), + ) + + scheduled_start = ( + next_check_at if next_check_at is not None else loop.time() + ) + wait_seconds = _remaining_delay(scheduled_start, loop.time()) + if wait_seconds > 0: + try: + await asyncio.wait_for( + self._wake_event.wait(), timeout=wait_seconds + ) + self._wake_event.clear() + except asyncio.TimeoutError: + pass + elif self._wake_event.is_set(): + self._wake_event.clear() + + try: + await self.run_once() + except asyncio.CancelledError: + raise + except Exception: + logger.exception("自动注册循环执行失败") + update_auto_registration_state( + status="error", + message="自动注册循环执行失败,请查看服务端日志", + last_checked_at=_timestamp(), + ) + add_auto_registration_log( + "[自动注册] 自动注册循环执行失败,请检查服务端日志" + ) + + next_check_at = loop.time() + interval diff --git a/src/core/db_logs.py b/src/core/db_logs.py index 00257d98..1bf94d37 100644 --- a/src/core/db_logs.py +++ b/src/core/db_logs.py @@ -15,6 +15,7 @@ from ..config.settings import get_settings from ..database.models import AppLog from ..database.session import get_db +from .timezone_utils import utcnow_naive _INSTALL_LOCK = threading.Lock() @@ -120,7 +121,7 @@ def cleanup_database_logs( keep_days = int(retention_days if retention_days is not None else settings.log_retention_days or 30) keep_days = max(1, keep_days) max_rows = max(1000, int(max_rows)) - cutoff = datetime.utcnow() - timedelta(days=keep_days) + cutoff = utcnow_naive() - timedelta(days=keep_days) deleted_by_age = 0 deleted_by_limit = 0 diff --git a/src/core/openai/token_refresh.py b/src/core/openai/token_refresh.py index 13e8ec8c..0626b707 100644 --- a/src/core/openai/token_refresh.py +++ b/src/core/openai/token_refresh.py @@ -16,6 +16,7 @@ from ...config.settings import get_settings from ...config.constants import AccountStatus from ...database.session import get_db +from ..timezone_utils import utcnow_naive from ...database import crud from ...database.models import Account @@ -250,7 +251,7 @@ def _request_once(session: cffi_requests.Session): return result # 计算过期时间 - expires_at = datetime.utcnow() + timedelta(seconds=expires_in) + expires_at = utcnow_naive() + timedelta(seconds=expires_in) result.success = True result.access_token = access_token @@ -370,7 +371,7 @@ def refresh_account_token(account_id: int, proxy_url: Optional[str] = None) -> T # 更新数据库 update_data = { "access_token": result.access_token, - "last_refresh": datetime.utcnow() + "last_refresh": utcnow_naive() } if result.refresh_token: diff --git a/src/core/register.py b/src/core/register.py index f4a098c9..de0f98df 100644 --- a/src/core/register.py +++ b/src/core/register.py @@ -96,7 +96,8 @@ def __init__( email_service: BaseEmailService, proxy_url: Optional[str] = None, callback_logger: Optional[Callable[[str], None]] = None, - task_uuid: Optional[str] = None + task_uuid: Optional[str] = None, + cancel_requested: Optional[Callable[[], bool]] = None ): """ 初始化注册引擎 @@ -111,6 +112,7 @@ def __init__( self.proxy_url = proxy_url self.callback_logger = callback_logger or (lambda msg: logger.info(msg)) self.task_uuid = task_uuid + self.cancel_requested = cancel_requested or (lambda: False) # 创建 HTTP 客户端 self.http_client = OpenAIHTTPClient(proxy_url=proxy_url) @@ -153,6 +155,24 @@ def __init__( self._last_otp_validation_status_code: Optional[int] = None self._last_otp_validation_outcome: str = "" # success/http_non_200/network_timeout/network_error + def _is_cancelled(self) -> bool: + try: + return bool(self.cancel_requested()) + except Exception: + return False + + def _raise_if_cancelled(self) -> None: + if self._is_cancelled(): + raise RuntimeError("任务已取消") + + def _sleep_with_cancel(self, seconds: float) -> None: + remaining = max(0.0, float(seconds or 0)) + while remaining > 0: + self._raise_if_cancelled() + chunk = min(0.5, remaining) + time.sleep(chunk) + remaining -= chunk + def _log(self, message: str, level: str = "info"): """记录日志""" timestamp = datetime.now().strftime("%H:%M:%S") @@ -449,7 +469,7 @@ def _get_device_id(self) -> Optional[str]: ) if attempt < max_attempts: - time.sleep(attempt) + self._sleep_with_cancel(attempt) self.http_client.close() self.session = self.http_client.session @@ -536,7 +556,7 @@ def _submit_auth_start( f"{log_label}命中限流 429(第 {attempt}/{max_attempts} 次),{wait_seconds}s 后自动重试...", "warning", ) - time.sleep(wait_seconds) + self._sleep_with_cancel(wait_seconds) continue # 部分网络/会话边界情况下会返回 409,做自愈重试而非直接失败。 @@ -560,7 +580,7 @@ def _submit_auth_start( self.session.get(str(self.oauth_start.auth_url), timeout=12) except Exception: pass - time.sleep(wait_seconds) + self._sleep_with_cancel(wait_seconds) continue if response.status_code != 200: @@ -603,7 +623,7 @@ def _submit_auth_start( f"{log_label}异常(第 {attempt}/{max_attempts} 次): {e},准备重试...", "warning", ) - time.sleep(2 * attempt) + self._sleep_with_cancel(2 * attempt) continue self._log(f"{log_label}失败: {e}", "error") return SignupFormResult(success=False, error_message=str(e)) @@ -680,7 +700,7 @@ def _submit_login_password(self) -> SignupFormResult: f"提交登录密码命中限流 429(第 {attempt}/{max_attempts} 次),{wait_seconds}s 后自动重试...", "warning", ) - time.sleep(wait_seconds) + self._sleep_with_cancel(wait_seconds) continue if response.status_code == 401 and attempt < max_attempts: @@ -692,7 +712,7 @@ def _submit_login_password(self) -> SignupFormResult: f"疑似密码尚未生效或历史账号密码不一致,{wait_seconds}s 后自动重试...", "warning", ) - time.sleep(wait_seconds) + self._sleep_with_cancel(wait_seconds) continue if response.status_code != 200: @@ -723,7 +743,7 @@ def _submit_login_password(self) -> SignupFormResult: f"提交登录密码异常(第 {attempt}/{max_attempts} 次): {e},准备重试...", "warning", ) - time.sleep(2 * attempt) + self._sleep_with_cancel(2 * attempt) continue self._log(f"提交登录密码失败: {e}", "error") return SignupFormResult(success=False, error_message=str(e)) @@ -2103,6 +2123,7 @@ def _send_verification_code(self, referer: Optional[str] = None) -> bool: def _get_verification_code(self, timeout: Optional[int] = None) -> Optional[str]: """获取验证码""" try: + self._raise_if_cancelled() mailbox_email = str(self.inbox_email or self.email or "").strip() self._log(f"正在等待邮箱 {mailbox_email} 的验证码...") @@ -2239,7 +2260,7 @@ def _verify_email_otp_with_retry( f"{stage_label}第 {attempt}/{max_attempts} 次未取到验证码,稍后重试...", "warning", ) - time.sleep(2) + self._sleep_with_cancel(2) continue return False @@ -2257,7 +2278,7 @@ def _verify_email_otp_with_retry( if self._validate_verification_code(code): return True if attempt < max_attempts: - time.sleep(2) + self._sleep_with_cancel(2) continue return False @@ -2266,7 +2287,7 @@ def _verify_email_otp_with_retry( f"{stage_label}第 {attempt}/{max_attempts} 次命中重复验证码 {code},等待新邮件...", "warning", ) - time.sleep(2) + self._sleep_with_cancel(2) continue return False @@ -2280,7 +2301,7 @@ def _verify_email_otp_with_retry( f"{stage_label}第 {attempt}/{max_attempts} 次校验未通过,疑似旧验证码,自动重试下一封...", "warning", ) - time.sleep(2) + self._sleep_with_cancel(2) return False @@ -2638,6 +2659,7 @@ def run(self) -> RegistrationResult: self._create_account_refresh_token = None self._last_validate_otp_continue_url = None self._last_validate_otp_workspace_id = None + self._raise_if_cancelled() self._log("=" * 60) self._log("注册流程启动,开始替你敲门") @@ -2660,6 +2682,7 @@ def run(self) -> RegistrationResult: return result self._log(f"IP 位置: {location}") + self._raise_if_cancelled() # 2. 创建邮箱 self._log("2. 开个新邮箱,准备收信...") @@ -2668,6 +2691,7 @@ def run(self) -> RegistrationResult: return result result.email = self.email + self._raise_if_cancelled() # 3. 准备首轮授权流程 did, sen_token = self._prepare_authorize_flow("首次授权") @@ -2678,6 +2702,7 @@ def run(self) -> RegistrationResult: if not sen_token: result.error_message = "Sentinel POW 验证失败" return result + self._raise_if_cancelled() # 4. 提交注册入口邮箱 self._log("4. 递上邮箱,看看 OpenAI 这球怎么接...") @@ -2710,6 +2735,7 @@ def run(self) -> RegistrationResult: if not self._create_user_account(): result.error_message = "创建用户账户失败" return result + self._raise_if_cancelled() if effective_entry_flow in {"native", "outlook"}: login_ready, login_error = self._restart_login_flow() diff --git a/src/core/timezone_utils.py b/src/core/timezone_utils.py index 73051c92..ad87c671 100644 --- a/src/core/timezone_utils.py +++ b/src/core/timezone_utils.py @@ -38,6 +38,10 @@ def now_shanghai() -> datetime: return datetime.now(UTC).astimezone(SHANGHAI_TZ) +def utcnow_naive() -> datetime: + return datetime.now(UTC).replace(tzinfo=None) + + def to_utc(dt: datetime | None) -> datetime | None: if dt is None: return None diff --git a/src/core/upload/cpa_upload.py b/src/core/upload/cpa_upload.py index 900cff6a..4fe584df 100644 --- a/src/core/upload/cpa_upload.py +++ b/src/core/upload/cpa_upload.py @@ -14,6 +14,7 @@ from ...database.session import get_db from ...database.models import Account from ...config.settings import get_settings +from ..timezone_utils import utcnow_naive logger = logging.getLogger(__name__) @@ -239,7 +240,7 @@ def batch_upload_to_cpa( if success: # 更新数据库状态 account.cpa_uploaded = True - account.cpa_uploaded_at = datetime.utcnow() + account.cpa_uploaded_at = utcnow_naive() db.commit() results["success_count"] += 1 @@ -261,6 +262,70 @@ def batch_upload_to_cpa( return results +def list_cpa_auth_files(api_url: str, api_token: str) -> Tuple[bool, Any, str]: + """列出远端 CPA auth-files 清单。""" + if not api_url: + return False, None, "API URL 不能为空" + + if not api_token: + return False, None, "API Token 不能为空" + + list_url = _normalize_cpa_auth_files_url(api_url) + headers = _build_cpa_headers(api_token) + + try: + response = cffi_requests.get( + list_url, + headers=headers, + proxies=None, + timeout=10, + impersonate="chrome110", + ) + if response.status_code != 200: + return False, None, _extract_cpa_error(response) + return True, response.json(), "ok" + except cffi_requests.exceptions.ConnectionError as e: + return False, None, f"无法连接到服务器: {str(e)}" + except cffi_requests.exceptions.Timeout: + return False, None, "连接超时,请检查网络配置" + except Exception as e: + logger.error("获取 CPA auth-files 清单异常: %s", e) + return False, None, f"获取 auth-files 失败: {str(e)}" + + +def count_ready_cpa_auth_files(payload: Any) -> int: + """统计可用于补货判断的认证文件数量。""" + if isinstance(payload, dict): + files = payload.get("files", []) + elif isinstance(payload, list): + files = payload + else: + return 0 + + ready_count = 0 + for item in files: + if not isinstance(item, dict): + continue + + status = str(item.get("status", "")).strip().lower() + provider = str(item.get("provider") or item.get("type") or "").strip().lower() + disabled = bool(item.get("disabled", False)) + unavailable = bool(item.get("unavailable", False)) + + if disabled or unavailable: + continue + + if provider != "codex": + continue + + if status and status not in {"ready", "active"}: + continue + + ready_count += 1 + + return ready_count + + def test_cpa_connection(api_url: str, api_token: str, proxy: str = None) -> Tuple[bool, str]: """ 测试 CPA 连接(不走代理) diff --git a/src/database/crud.py b/src/database/crud.py index 3c6c8492..e4ecbb71 100644 --- a/src/database/crud.py +++ b/src/database/crud.py @@ -8,6 +8,7 @@ from sqlalchemy import and_, or_, desc, asc, func from .models import Account, EmailService, RegistrationTask, Setting, Proxy, CpaService, Sub2ApiService +from ..core.timezone_utils import utcnow_naive # ============================================================================ @@ -53,7 +54,7 @@ def create_account( extra_data=extra_data or {}, status=status or 'active', source=source or 'register', - registered_at=datetime.utcnow() + registered_at=utcnow_naive() ) db.add(db_account) db.commit() @@ -360,7 +361,7 @@ def set_setting( db_setting.value = value db_setting.description = description or db_setting.description db_setting.category = category - db_setting.updated_at = datetime.utcnow() + db_setting.updated_at = utcnow_naive() else: db_setting = Setting( key=key, @@ -480,7 +481,7 @@ def update_proxy_last_used(db: Session, proxy_id: int) -> bool: if not db_proxy: return False - db_proxy.last_used = datetime.utcnow() + db_proxy.last_used = utcnow_naive() db.commit() return True diff --git a/src/database/models.py b/src/database/models.py index ff0e4443..26b9be3a 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -2,17 +2,20 @@ SQLAlchemy ORM 模型定义 """ -from datetime import datetime +from datetime import datetime, UTC from typing import Optional, Dict, Any import json from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey -from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.types import TypeDecorator -from sqlalchemy.orm import relationship +from sqlalchemy.orm import declarative_base, relationship Base = declarative_base() +def utcnow() -> datetime: + return datetime.now(UTC).replace(tzinfo=None) + + class JSONEncodedDict(TypeDecorator): """JSON 编码字典类型""" impl = Text @@ -45,7 +48,7 @@ class Account(Base): email_service = Column(String(50), nullable=False) # 'tempmail', 'outlook', 'moe_mail' email_service_id = Column(String(255)) # 邮箱服务中的ID proxy_used = Column(String(255)) - registered_at = Column(DateTime, default=datetime.utcnow) + registered_at = Column(DateTime, default=utcnow) last_refresh = Column(DateTime) # 最后刷新时间 expires_at = Column(DateTime) # Token 过期时间 status = Column(String(20), default='active') # 'active', 'expired', 'banned', 'failed' @@ -56,8 +59,8 @@ class Account(Base): subscription_type = Column(String(20)) # None / 'plus' / 'team' subscription_at = Column(DateTime) # 订阅开通时间 cookies = Column(Text) # 完整 cookie 字符串,用于支付请求 - created_at = Column(DateTime, default=datetime.utcnow) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_at = Column(DateTime, default=utcnow) + updated_at = Column(DateTime, default=utcnow, onupdate=utcnow) bind_card_tasks = relationship("BindCardTask", back_populates="account") def to_dict(self) -> Dict[str, Any]: @@ -96,8 +99,8 @@ class EmailService(Base): enabled = Column(Boolean, default=True) priority = Column(Integer, default=0) # 使用优先级 last_used = Column(DateTime) - created_at = Column(DateTime, default=datetime.utcnow) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_at = Column(DateTime, default=utcnow) + updated_at = Column(DateTime, default=utcnow, onupdate=utcnow) class RegistrationTask(Base): @@ -112,7 +115,7 @@ class RegistrationTask(Base): logs = Column(Text) # 注册过程日志 result = Column(JSONEncodedDict) # 注册结果 error_message = Column(Text) - created_at = Column(DateTime, default=datetime.utcnow) + created_at = Column(DateTime, default=utcnow) started_at = Column(DateTime) completed_at = Column(DateTime) @@ -143,8 +146,8 @@ class BindCardTask(Base): opened_at = Column(DateTime) last_checked_at = Column(DateTime) completed_at = Column(DateTime) - created_at = Column(DateTime, default=datetime.utcnow) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_at = Column(DateTime, default=utcnow) + updated_at = Column(DateTime, default=utcnow, onupdate=utcnow) # 关系 account = relationship("Account", back_populates="bind_card_tasks") @@ -162,7 +165,7 @@ class AppLog(Base): lineno = Column(Integer) message = Column(Text, nullable=False) exception = Column(Text) - created_at = Column(DateTime, default=datetime.utcnow, index=True) + created_at = Column(DateTime, default=utcnow, index=True) def to_dict(self) -> Dict[str, Any]: return { @@ -186,7 +189,7 @@ class Setting(Base): value = Column(Text) description = Column(Text) category = Column(String(50), default='general') # 'general', 'email', 'proxy', 'openai' - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + updated_at = Column(DateTime, default=utcnow, onupdate=utcnow) class CpaService(Base): @@ -200,8 +203,8 @@ class CpaService(Base): proxy_url = Column(String(1000)) # ?? URL enabled = Column(Boolean, default=True) priority = Column(Integer, default=0) # 优先级 - created_at = Column(DateTime, default=datetime.utcnow) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_at = Column(DateTime, default=utcnow) + updated_at = Column(DateTime, default=utcnow, onupdate=utcnow) class Sub2ApiService(Base): @@ -215,8 +218,8 @@ class Sub2ApiService(Base): target_type = Column(String(50), nullable=False, default='sub2api') # sub2api/newapi enabled = Column(Boolean, default=True) priority = Column(Integer, default=0) # 优先级 - created_at = Column(DateTime, default=datetime.utcnow) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_at = Column(DateTime, default=utcnow) + updated_at = Column(DateTime, default=utcnow, onupdate=utcnow) class TeamManagerService(Base): @@ -229,8 +232,8 @@ class TeamManagerService(Base): api_key = Column(Text, nullable=False) # X-API-Key enabled = Column(Boolean, default=True) priority = Column(Integer, default=0) # 优先级 - created_at = Column(DateTime, default=datetime.utcnow) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_at = Column(DateTime, default=utcnow) + updated_at = Column(DateTime, default=utcnow, onupdate=utcnow) class Proxy(Base): @@ -248,8 +251,8 @@ class Proxy(Base): is_default = Column(Boolean, default=False) # 是否为默认代理 priority = Column(Integer, default=0) # 优先级(保留字段) last_used = Column(DateTime) # 最后使用时间 - created_at = Column(DateTime, default=datetime.utcnow) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + created_at = Column(DateTime, default=utcnow) + updated_at = Column(DateTime, default=utcnow, onupdate=utcnow) def to_dict(self, include_password: bool = False) -> Dict[str, Any]: """转换为字典""" diff --git a/src/web/app.py b/src/web/app.py index b679341d..e551858b 100644 --- a/src/web/app.py +++ b/src/web/app.py @@ -3,11 +3,13 @@ 轻量级 Web UI,支持注册、账号管理、设置 """ +import asyncio import logging import sys import secrets import hmac import hashlib +from contextlib import asynccontextmanager from typing import Optional, Dict, Any from pathlib import Path @@ -24,6 +26,7 @@ from .task_manager import task_manager logger = logging.getLogger(__name__) +auto_registration_coordinator = None # 获取项目根目录 # PyInstaller 打包后静态资源在 sys._MEIPASS,开发时在源码根目录 @@ -51,12 +54,81 @@ def create_app() -> FastAPI: """创建 FastAPI 应用实例""" settings = get_settings() + @asynccontextmanager + async def lifespan(app: FastAPI): + from ..database.init_db import initialize_database + from ..core.db_logs import cleanup_database_logs + from ..core.auto_registration import ( + AutoRegistrationCoordinator, + register_auto_registration_coordinator, + ) + from .routes.registration import run_auto_registration_batch + + try: + initialize_database() + except Exception as e: + logger.warning(f"数据库初始化: {e}") + + loop = asyncio.get_running_loop() + task_manager.set_loop(loop) + + global auto_registration_coordinator + auto_registration_coordinator = AutoRegistrationCoordinator( + trigger_callback=run_auto_registration_batch, + ) + register_auto_registration_coordinator(auto_registration_coordinator) + auto_registration_coordinator.start() + + async def run_log_cleanup_once(): + try: + result = await asyncio.to_thread(cleanup_database_logs) + logger.info( + "后台日志清理完成: 删除 %s 条,剩余 %s 条", + result.get("deleted_total", 0), + result.get("remaining", 0), + ) + except Exception as exc: + logger.warning(f"后台日志清理失败: {exc}") + + async def periodic_log_cleanup(): + while True: + try: + await asyncio.sleep(3600) + await run_log_cleanup_once() + except asyncio.CancelledError: + break + except Exception as exc: + logger.warning(f"后台日志定时清理异常: {exc}") + + await run_log_cleanup_once() + app.state.log_cleanup_task = asyncio.create_task(periodic_log_cleanup()) + + logger.info("=" * 50) + logger.info(f"{settings.app_name} v{settings.app_version} 启动中,程序正在伸懒腰...") + logger.info(f"调试模式: {settings.debug}") + logger.info(f"数据库连接已接好线: {settings.database_url}") + logger.info("=" * 50) + + try: + yield + finally: + if auto_registration_coordinator is not None: + await auto_registration_coordinator.stop() + register_auto_registration_coordinator(None) + auto_registration_coordinator = None + + cleanup_task = getattr(app.state, "log_cleanup_task", None) + if cleanup_task: + cleanup_task.cancel() + logger.info("应用关闭,今天先收摊啦") + app = FastAPI( title=settings.app_name, version=settings.app_version, description="OpenAI/Codex CLI 自动注册系统 Web UI", docs_url="/api/docs" if settings.debug else None, redoc_url="/api/redoc" if settings.debug else None, + lifespan=lifespan, ) # CORS 中间件 @@ -228,62 +300,6 @@ async def logs_page(request: Request): return _redirect_to_login(request) return _render_template(request, "logs.html") - @app.on_event("startup") - async def startup_event(): - """应用启动事件""" - import asyncio - from ..database.init_db import initialize_database - from ..core.db_logs import cleanup_database_logs - - # 确保数据库已初始化(reload 模式下子进程也需要初始化) - try: - initialize_database() - except Exception as e: - logger.warning(f"数据库初始化: {e}") - - # 设置 TaskManager 的事件循环 - loop = asyncio.get_event_loop() - task_manager.set_loop(loop) - - async def run_log_cleanup_once(): - try: - result = await asyncio.to_thread(cleanup_database_logs) - logger.info( - "后台日志清理完成: 删除 %s 条,剩余 %s 条", - result.get("deleted_total", 0), - result.get("remaining", 0), - ) - except Exception as exc: - logger.warning(f"后台日志清理失败: {exc}") - - async def periodic_log_cleanup(): - while True: - try: - await asyncio.sleep(3600) # 每小时清理一次 - await run_log_cleanup_once() - except asyncio.CancelledError: - break - except Exception as exc: - logger.warning(f"后台日志定时清理异常: {exc}") - - # 启动时先执行一次,再开启定时任务 - await run_log_cleanup_once() - app.state.log_cleanup_task = asyncio.create_task(periodic_log_cleanup()) - - logger.info("=" * 50) - logger.info(f"{settings.app_name} v{settings.app_version} 启动中,程序正在伸懒腰...") - logger.info(f"调试模式: {settings.debug}") - logger.info(f"数据库连接已接好线: {settings.database_url}") - logger.info("=" * 50) - - @app.on_event("shutdown") - async def shutdown_event(): - """应用关闭事件""" - cleanup_task = getattr(app.state, "log_cleanup_task", None) - if cleanup_task: - cleanup_task.cancel() - logger.info("应用关闭,今天先收摊啦") - return app diff --git a/src/web/routes/accounts.py b/src/web/routes/accounts.py index 4024d90e..699ce731 100644 --- a/src/web/routes/accounts.py +++ b/src/web/routes/accounts.py @@ -14,7 +14,7 @@ from fastapi import APIRouter, HTTPException, Query, BackgroundTasks, Body from fastapi.responses import StreamingResponse -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from sqlalchemy import func from ...config.constants import AccountStatus @@ -27,6 +27,7 @@ from ...core.upload.sub2api_upload import batch_upload_to_sub2api, upload_to_sub2api from ...core.dynamic_proxy import get_proxy_url_for_task +from ...core.timezone_utils import utcnow_naive from ...database import crud from ...database.models import Account from ...database.session import get_db @@ -99,8 +100,7 @@ class AccountResponse(BaseModel): created_at: Optional[str] = None updated_at: Optional[str] = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class AccountListResponse(BaseModel): @@ -587,7 +587,7 @@ def _get_account_overview_data( # 避免把本地已确认的付费订阅(plus/team)被远端偶发 free/basic 覆盖降级。 if detected_sub and current_sub != detected_sub: account.subscription_type = detected_sub - account.subscription_at = datetime.utcnow() if detected_sub else None + account.subscription_at = utcnow_naive() if detected_sub else None updated = True elif not detected_sub and current_sub in PAID_SUBSCRIPTION_TYPES: logger.info( @@ -660,7 +660,7 @@ async def create_manual_account(request: ManualAccountCreateRequest): ) if subscription_type: account.subscription_type = subscription_type - account.subscription_at = datetime.utcnow() + account.subscription_at = utcnow_naive() db.commit() db.refresh(account) except Exception as exc: @@ -821,14 +821,14 @@ def _safe_text(value: Optional[str]) -> Optional[str]: "proxy_used": _safe_text(item.proxy_used), "source": source, "extra_data": metadata, - "last_refresh": datetime.utcnow(), + "last_refresh": utcnow_naive(), } clean_update_payload = {k: v for k, v in update_payload.items() if v is not None} account = crud.update_account(db, exists.id, **clean_update_payload) if account is None: raise RuntimeError("更新账号失败") account.subscription_type = subscription_type - account.subscription_at = datetime.utcnow() if subscription_type else None + account.subscription_at = utcnow_naive() if subscription_type else None db.commit() result["updated"] += 1 continue @@ -853,7 +853,7 @@ def _safe_text(value: Optional[str]) -> Optional[str]: ) if subscription_type: account.subscription_type = subscription_type - account.subscription_at = datetime.utcnow() + account.subscription_at = utcnow_naive() db.commit() result["created"] += 1 except Exception as exc: @@ -1341,7 +1341,7 @@ async def get_account_tokens(account_id: int): # 若 DB 为空但 cookies 可解析到 session_token,自动回写,避免后续重复解析。 if resolved_session_token and not str(account.session_token or "").strip(): account.session_token = resolved_session_token - account.last_refresh = datetime.utcnow() + account.last_refresh = utcnow_naive() db.commit() db.refresh(account) @@ -1384,7 +1384,7 @@ async def update_account(account_id: int, request: AccountUpdateRequest): if request.session_token is not None: # 留空则清空,非空则更新 update_data["session_token"] = request.session_token or None - update_data["last_refresh"] = datetime.utcnow() + update_data["last_refresh"] = utcnow_naive() account = crud.update_account(db, account_id, **update_data) return account_to_response(account) @@ -2045,7 +2045,7 @@ async def upload_account_to_cpa(account_id: int, request: Optional[CPAUploadRequ if success: account.cpa_uploaded = True - account.cpa_uploaded_at = datetime.utcnow() + account.cpa_uploaded_at = utcnow_naive() db.commit() return {"success": True, "message": message} else: diff --git a/src/web/routes/email.py b/src/web/routes/email.py index 8dd4ef1f..b9d7f13f 100644 --- a/src/web/routes/email.py +++ b/src/web/routes/email.py @@ -6,7 +6,7 @@ from typing import List, Optional, Dict, Any from fastapi import APIRouter, HTTPException, Query -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from sqlalchemy import func from ...database import crud @@ -53,8 +53,7 @@ class EmailServiceResponse(BaseModel): created_at: Optional[str] = None updated_at: Optional[str] = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class EmailServiceListResponse(BaseModel): diff --git a/src/web/routes/logs.py b/src/web/routes/logs.py index 5a4131ac..a86c1ba3 100644 --- a/src/web/routes/logs.py +++ b/src/web/routes/logs.py @@ -12,7 +12,7 @@ from sqlalchemy import func, or_ from ...core.db_logs import cleanup_database_logs -from ...core.timezone_utils import to_shanghai_iso +from ...core.timezone_utils import to_shanghai_iso, utcnow_naive from ...database.models import AppLog from ...database.session import get_db @@ -60,7 +60,7 @@ def list_logs( ) if since_minutes: - since_at = datetime.utcnow() - timedelta(minutes=since_minutes) + since_at = utcnow_naive() - timedelta(minutes=since_minutes) query = query.filter(AppLog.created_at >= since_at) total = query.count() diff --git a/src/web/routes/payment.py b/src/web/routes/payment.py index 17c30729..3c8302d9 100644 --- a/src/web/routes/payment.py +++ b/src/web/routes/payment.py @@ -31,6 +31,7 @@ open_url_incognito, check_subscription_status_detail, ) +from ...core.timezone_utils import utcnow_naive from ...core.openai.browser_bind import auto_bind_checkout_with_playwright from ...core.openai.random_billing import generate_random_billing_profile from ...core.openai.token_refresh import TokenRefreshManager @@ -945,7 +946,7 @@ def _bootstrap_session_token_by_relogin(db, account: Account, proxy: Optional[st account.cookies = fresh_cookies if forced_access: account.access_token = forced_access - account.last_refresh = datetime.utcnow() + account.last_refresh = utcnow_naive() db.commit() return "" @@ -954,7 +955,7 @@ def _bootstrap_session_token_by_relogin(db, account: Account, proxy: Optional[st if fresh_cookies: account.cookies = fresh_cookies account.session_token = session_token - account.last_refresh = datetime.utcnow() + account.last_refresh = utcnow_naive() db.commit() db.refresh(account) logger.info("会话补全登录成功: account_id=%s email=%s", account.id, account.email) @@ -1210,7 +1211,7 @@ def _request_session_token( account.refresh_token = refresh_result.refresh_token if refresh_result.expires_at: account.expires_at = refresh_result.expires_at - account.last_refresh = datetime.utcnow() + account.last_refresh = utcnow_naive() db.commit() db.refresh(account) for proxy_item in proxy_candidates: @@ -1284,7 +1285,7 @@ def _mask_card_number(number: Optional[str]) -> str: def _mark_task_paid_pending_sync(task: BindCardTask, reason: str) -> None: - now = datetime.utcnow() + now = utcnow_naive() task.status = "paid_pending_sync" task.completed_at = None task.last_checked_at = now @@ -1791,7 +1792,7 @@ def _refresh_account_token_for_subscription_check(account: Account, proxy: Optio account.refresh_token = refresh_result.refresh_token if refresh_result.expires_at: account.expires_at = refresh_result.expires_at - account.last_refresh = datetime.utcnow() + account.last_refresh = utcnow_naive() return True, None @@ -2167,7 +2168,7 @@ def get_account_session_diagnostic( "probe": probe_result, "notes": notes, "recommendation": recommendation, - "checked_at": datetime.utcnow().isoformat(), + "checked_at": utcnow_naive().isoformat(), }, } @@ -2227,7 +2228,7 @@ def save_account_session_token( account.session_token = token if request.merge_cookie: account.cookies = _upsert_cookie(account.cookies, "__Secure-next-auth.session-token", token) - account.last_refresh = datetime.utcnow() + account.last_refresh = utcnow_naive() db.commit() db.refresh(account) @@ -2376,7 +2377,7 @@ def create_bind_card_task(request: CreateBindCardTaskRequest): opened = open_url_incognito(link, account.cookies if account else None) if opened: task.status = "opened" - task.opened_at = datetime.utcnow() + task.opened_at = utcnow_naive() db.commit() db.refresh(task) logger.info("绑卡任务自动打开成功: task_id=%s mode=%s", task.id, bind_mode) @@ -2420,7 +2421,7 @@ def list_bind_card_tasks( tasks = query.order_by(BindCardTask.created_at.desc()).offset(offset).limit(page_size).all() # 自动收敛任务状态:如果账号已是 plus/team,任务自动标记完成。 - now = datetime.utcnow() + now = utcnow_naive() changed = False changed_count = 0 for task in tasks: @@ -2460,7 +2461,7 @@ def open_bind_card_task(task_id: int): if opened: if str(task.status or "") not in ("paid_pending_sync", "completed"): task.status = "opened" - task.opened_at = datetime.utcnow() + task.opened_at = utcnow_naive() task.last_error = None db.commit() db.refresh(task) @@ -2547,7 +2548,7 @@ def auto_bind_bind_card_task_third_party(task_id: int, request: ThirdPartyAutoBi task.bind_mode = "third_party" task.status = "verifying" task.last_error = None - task.last_checked_at = datetime.utcnow() + task.last_checked_at = utcnow_naive() db.commit() try: @@ -2580,7 +2581,7 @@ def auto_bind_bind_card_task_third_party(task_id: int, request: ThirdPartyAutoBi if assess_state == "failed": task.status = "failed" task.last_error = f"第三方返回失败: {assess_reason or 'unknown'}" - task.last_checked_at = datetime.utcnow() + task.last_checked_at = utcnow_naive() db.commit() logger.warning( "第三方自动绑卡返回业务失败: task_id=%s account_id=%s endpoint=%s reason=%s response=%s", @@ -2600,7 +2601,7 @@ def auto_bind_bind_card_task_third_party(task_id: int, request: ThirdPartyAutoBi # 若第三方明确返回 challenge/requires_action,直接切换待用户完成,避免无意义轮询超时。 if _is_third_party_challenge_pending(third_party_assessment): task.status = "waiting_user_action" - task.last_checked_at = datetime.utcnow() + task.last_checked_at = utcnow_naive() hint_reason = assess_reason or "requires_action" hint_payment_status = payment_status or "unknown" task.last_error = ( @@ -2660,13 +2661,13 @@ def auto_bind_bind_card_task_third_party(task_id: int, request: ThirdPartyAutoBi if poll_state == "failed": task.status = "failed" task.last_error = f"第三方状态失败: {poll_reason or 'unknown'}" - task.last_checked_at = datetime.utcnow() + task.last_checked_at = utcnow_naive() db.commit() raise HTTPException(status_code=400, detail=f"第三方状态失败: {poll_reason or 'unknown'}") if poll_state != "success": task.status = "waiting_user_action" - task.last_checked_at = datetime.utcnow() + task.last_checked_at = utcnow_naive() hint_reason = poll_reason or assess_reason or "pending_confirmation" hint_payment_status = poll_payment_status or payment_status or "unknown" task.last_error = ( @@ -2735,7 +2736,7 @@ def auto_bind_bind_card_task_third_party(task_id: int, request: ThirdPartyAutoBi except Exception as exc: task.status = "failed" task.last_error = f"第三方绑卡提交失败: {exc}" - task.last_checked_at = datetime.utcnow() + task.last_checked_at = utcnow_naive() db.commit() logger.warning("第三方自动绑卡提交失败: task_id=%s error=%s", task.id, exc) raise HTTPException(status_code=500, detail=str(exc)) @@ -2773,7 +2774,7 @@ def auto_bind_bind_card_task_local(task_id: int, request: LocalAutoBindRequest): task.bind_mode = "local_auto" task.status = "verifying" task.last_error = None - task.last_checked_at = datetime.utcnow() + task.last_checked_at = utcnow_naive() db.commit() logger.info( @@ -2794,7 +2795,7 @@ def auto_bind_bind_card_task_local(task_id: int, request: LocalAutoBindRequest): ) if not resolved_session_token and not runtime_proxy: task.status = "failed" - task.last_checked_at = datetime.utcnow() + task.last_checked_at = utcnow_naive() task.last_error = ( "当前账号缺少 session_token,且未检测到可用代理。" "请在设置中配置代理(或为本次任务传入 proxy)后重试全自动。" @@ -2815,7 +2816,7 @@ def auto_bind_bind_card_task_local(task_id: int, request: LocalAutoBindRequest): ) if not resolved_session_token: task.status = "failed" - task.last_checked_at = datetime.utcnow() + task.last_checked_at = utcnow_naive() task.last_error = ( "会话补全未拿到 session_token。" "请先在支付页执行“会话诊断/自动补会话”," @@ -2855,7 +2856,7 @@ def auto_bind_bind_card_task_local(task_id: int, request: LocalAutoBindRequest): except Exception as exc: task.status = "failed" task.last_error = f"本地自动绑卡执行异常: {exc}" - task.last_checked_at = datetime.utcnow() + task.last_checked_at = utcnow_naive() db.commit() logger.warning("本地自动绑卡执行异常: task_id=%s account_id=%s error=%s", task.id, account.id, exc) raise HTTPException(status_code=500, detail=f"本地自动绑卡执行失败: {exc}") @@ -2870,7 +2871,7 @@ def auto_bind_bind_card_task_local(task_id: int, request: LocalAutoBindRequest): if not success: if "playwright not installed" in message.lower(): task.status = "failed" - task.last_checked_at = datetime.utcnow() + task.last_checked_at = utcnow_naive() task.last_error = ( "本地自动绑卡环境缺少 Playwright/Chromium。" "请先执行: pip install playwright && playwright install chromium" @@ -2905,7 +2906,7 @@ def auto_bind_bind_card_task_local(task_id: int, request: LocalAutoBindRequest): if manual_opened: hint += " 已自动为你打开手动验证窗口。" task.status = "waiting_user_action" - task.last_checked_at = datetime.utcnow() + task.last_checked_at = utcnow_naive() task.last_error = hint db.commit() db.refresh(task) @@ -2931,7 +2932,7 @@ def auto_bind_bind_card_task_local(task_id: int, request: LocalAutoBindRequest): } task.status = "failed" - task.last_checked_at = datetime.utcnow() + task.last_checked_at = utcnow_naive() task.last_error = f"本地自动绑卡失败: {message or 'unknown_error'}" db.commit() logger.warning( @@ -2983,7 +2984,7 @@ def sync_bind_card_task_subscription(task_id: int, request: SyncBindCardTaskRequ raise HTTPException(status_code=404, detail="任务关联账号不存在") proxy = _resolve_runtime_proxy(request.proxy, account) - now = datetime.utcnow() + now = utcnow_naive() try: detail, refreshed = _check_subscription_detail_with_retry( db=db, @@ -3093,7 +3094,7 @@ def mark_bind_card_task_user_action(task_id: int, request: MarkUserActionRequest ) previous_status = str(task.status or "") - now = datetime.utcnow() + now = utcnow_naive() task.status = "verifying" task.last_error = None task.last_checked_at = now @@ -3122,7 +3123,7 @@ def mark_bind_card_task_user_action(task_id: int, request: MarkUserActionRequest task.id, checks, status, detail.get("source"), detail.get("confidence"), bool(detail.get("token_refreshed")) ) except Exception as exc: - failed_at = datetime.utcnow() + failed_at = utcnow_naive() task.status = "failed" task.last_error = f"订阅检测失败: {exc}" task.last_checked_at = failed_at @@ -3130,7 +3131,7 @@ def mark_bind_card_task_user_action(task_id: int, request: MarkUserActionRequest logger.warning("绑卡任务验证失败: task_id=%s attempt=%s error=%s", task.id, checks, exc) raise HTTPException(status_code=500, detail=f"订阅检测失败: {exc}") - checked_at = datetime.utcnow() + checked_at = utcnow_naive() if status in ("plus", "team"): account.subscription_type = status account.subscription_at = checked_at @@ -3197,7 +3198,7 @@ def mark_bind_card_task_user_action(task_id: int, request: MarkUserActionRequest else: task.status = "waiting_user_action" task.last_error = timeout_msg + "请稍后点击“同步订阅”重试。" - task.last_checked_at = datetime.utcnow() + task.last_checked_at = utcnow_naive() task.completed_at = None db.commit() db.refresh(task) @@ -3268,7 +3269,7 @@ def batch_check_subscription(request: BatchCheckSubscriptionRequest): if status in ("plus", "team"): account.subscription_type = status - account.subscription_at = datetime.utcnow() + account.subscription_at = utcnow_naive() elif status == "free" and confidence == "high": account.subscription_type = None account.subscription_at = None @@ -3308,7 +3309,7 @@ def mark_subscription(account_id: int, request: MarkSubscriptionRequest): raise HTTPException(status_code=404, detail="账号不存在") account.subscription_type = None if request.subscription_type == "free" else request.subscription_type - account.subscription_at = datetime.utcnow() if request.subscription_type != "free" else None + account.subscription_at = utcnow_naive() if request.subscription_type != "free" else None db.commit() return {"success": True, "subscription_type": request.subscription_type} diff --git a/src/web/routes/registration.py b/src/web/routes/registration.py index cb89f056..e4e120a2 100644 --- a/src/web/routes/registration.py +++ b/src/web/routes/registration.py @@ -6,18 +6,26 @@ import logging import uuid import random -from datetime import datetime +from datetime import datetime, timezone from typing import List, Optional, Dict, Tuple from fastapi import APIRouter, HTTPException, Query, BackgroundTasks -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from ...database import crud from ...database.session import get_db from ...database.models import RegistrationTask, Proxy from ...core.register import RegistrationEngine, RegistrationResult from ...services import EmailServiceFactory, EmailServiceType -from ...config.settings import get_settings +from ...config.settings import get_settings, Settings +from ...core.auto_registration import ( + add_auto_registration_log, + get_auto_registration_inventory, + get_auto_registration_logs, + get_auto_registration_state, + update_auto_registration_state, +) +from ...core.timezone_utils import utcnow_naive from ..task_manager import task_manager logger = logging.getLogger(__name__) @@ -29,6 +37,23 @@ batch_tasks: Dict[str, dict] = {} +def _cancel_batch_tasks(batch_id: str) -> None: + batch = batch_tasks.get(batch_id) + if not batch: + return + + for task_uuid in batch.get("task_uuids", []): + task_manager.cancel_task(task_uuid) + + auto_state = get_auto_registration_state() + if auto_state.get("current_batch_id") == batch_id: + update_auto_registration_state( + status="cancelling", + message=f"自动补货取消中: {batch_id}", + ) + add_auto_registration_log(f"[自动注册] 已提交补货批量任务取消请求: {batch_id}") + + # ============== Proxy Helper Functions ============== def get_proxy_for_registration(db) -> Tuple[Optional[str], Optional[int]]: @@ -112,8 +137,7 @@ class RegistrationTaskResponse(BaseModel): started_at: Optional[str] = None completed_at: Optional[str] = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class BatchRegistrationResponse(BaseModel): @@ -241,7 +265,7 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: task = crud.update_registration_task( db, task_uuid, status="running", - started_at=datetime.utcnow() + started_at=utcnow_naive() ) if not task: @@ -415,12 +439,26 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: email_service=email_service, proxy_url=actual_proxy_url, callback_logger=log_callback, - task_uuid=task_uuid + task_uuid=task_uuid, + cancel_requested=lambda: task_manager.is_cancelled(task_uuid) ) # 执行注册 result = engine.run() + if task_manager.is_cancelled(task_uuid): + cancellation_message = result.error_message or "任务已取消" + crud.update_registration_task( + db, + task_uuid, + status="cancelled", + completed_at=utcnow_naive(), + error_message=cancellation_message, + ) + task_manager.update_status(task_uuid, "cancelled", error=cancellation_message) + logger.info(f"注册任务已取消: {task_uuid}") + return + if result.success: # 更新代理使用时间 update_proxy_usage(db, proxy_id) @@ -451,7 +489,7 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: _ok, _msg = upload_to_cpa(token_data, api_url=_svc.api_url, api_token=_svc.api_token) if _ok: saved_account.cpa_uploaded = True - saved_account.cpa_uploaded_at = datetime.utcnow() + saved_account.cpa_uploaded_at = utcnow_naive() db.commit() log_callback(f"[CPA] 投递成功,服务站已签收: {_svc.name}") else: @@ -515,7 +553,7 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: crud.update_registration_task( db, task_uuid, status="completed", - completed_at=datetime.utcnow(), + completed_at=utcnow_naive(), result=result.to_dict() ) @@ -528,7 +566,7 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: crud.update_registration_task( db, task_uuid, status="failed", - completed_at=datetime.utcnow(), + completed_at=utcnow_naive(), error_message=result.error_message ) @@ -542,10 +580,21 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: try: with get_db() as db: + if task_manager.is_cancelled(task_uuid): + crud.update_registration_task( + db, + task_uuid, + status="cancelled", + completed_at=utcnow_naive(), + error_message=str(e) or "任务已取消", + ) + task_manager.update_status(task_uuid, "cancelled", error=str(e) or "任务已取消") + return + crud.update_registration_task( db, task_uuid, status="failed", - completed_at=datetime.utcnow(), + completed_at=utcnow_naive(), error_message=str(e) ) @@ -626,6 +675,42 @@ def update_batch_status(**kwargs): return add_batch_log, update_batch_status +async def _wait_for_batch_delay(batch_id: str, seconds: int) -> bool: + remaining = max(0, int(seconds)) + while remaining > 0: + if task_manager.is_batch_cancelled(batch_id) or batch_tasks[batch_id]["cancelled"]: + return False + await asyncio.sleep(min(0.5, remaining)) + remaining -= 0.5 + return True + + +def _mark_batch_tasks_cancelled(batch_id: str, task_uuids: List[str]) -> None: + if not task_uuids: + return + + terminal_statuses = {"completed", "failed", "cancelled"} + task_statuses = {} + with get_db() as db: + for task_uuid in task_uuids: + task = crud.get_registration_task(db, task_uuid) + current_status = getattr(task, "status", None) + task_statuses[task_uuid] = current_status + if current_status not in terminal_statuses: + crud.update_registration_task(db, task_uuid, status="cancelled") + + current_completed = batch_tasks[batch_id]["completed"] + update_count = 0 + for task_uuid in task_uuids: + if not task_manager.is_cancelled(task_uuid): + task_manager.cancel_task(task_uuid) + if task_statuses.get(task_uuid) not in terminal_statuses: + update_count += 1 + + batch_tasks[batch_id]["completed"] = current_completed + update_count + task_manager.update_batch_status(batch_id, completed=batch_tasks[batch_id]["completed"]) + + async def run_batch_parallel( batch_id: str, task_uuids: List[str], @@ -653,6 +738,9 @@ async def run_batch_parallel( async def _run_one(idx: int, uuid: str): prefix = f"[任务{idx + 1}]" async with semaphore: + if task_manager.is_batch_cancelled(batch_id) or batch_tasks[batch_id]["cancelled"]: + _mark_batch_tasks_cancelled(batch_id, [uuid]) + return await run_registration_task( uuid, email_service_type, proxy, email_service_config, email_service_id, log_prefix=prefix, batch_id=batch_id, @@ -670,6 +758,8 @@ async def _run_one(idx: int, uuid: str): if t.status == "completed": new_success += 1 add_batch_log(f"{prefix} [成功] 注册成功") + elif t.status == "cancelled": + add_batch_log(f"{prefix} [取消] 注册已取消") elif t.status == "failed": new_failed += 1 add_batch_log(f"{prefix} [失败] 注册失败: {t.error_message}") @@ -736,6 +826,8 @@ async def _run_and_release(idx: int, uuid: str, pfx: str): if t.status == "completed": new_success += 1 add_batch_log(f"{pfx} [成功] 注册成功") + elif t.status == "cancelled": + add_batch_log(f"{pfx} [取消] 注册已取消") elif t.status == "failed": new_failed += 1 add_batch_log(f"{pfx} [失败] 注册失败: {t.error_message}") @@ -746,9 +838,7 @@ async def _run_and_release(idx: int, uuid: str, pfx: str): try: for i, task_uuid in enumerate(task_uuids): if task_manager.is_batch_cancelled(batch_id) or batch_tasks[batch_id]["cancelled"]: - with get_db() as db: - for remaining_uuid in task_uuids[i:]: - crud.update_registration_task(db, remaining_uuid, status="cancelled") + _mark_batch_tasks_cancelled(batch_id, task_uuids[i:]) add_batch_log("[取消] 批量任务已取消") update_batch_status(finished=True, status="cancelled") break @@ -763,7 +853,11 @@ async def _run_and_release(idx: int, uuid: str, pfx: str): if i < len(task_uuids) - 1 and not task_manager.is_batch_cancelled(batch_id): wait_time = random.randint(interval_min, interval_max) logger.info(f"批量任务 {batch_id}: 等待 {wait_time} 秒后启动下一个任务") - await asyncio.sleep(wait_time) + if not await _wait_for_batch_delay(batch_id, wait_time): + _mark_batch_tasks_cancelled(batch_id, task_uuids[i + 1:]) + add_batch_log("[取消] 批量任务在等待下一个任务期间已取消") + update_batch_status(finished=True, status="cancelled") + break if running_tasks_list: await asyncio.gather(*running_tasks_list, return_exceptions=True) @@ -817,6 +911,112 @@ async def run_batch_registration( ) +async def run_auto_registration_batch(plan, settings: Settings) -> str: + email_service_type = settings.registration_auto_email_service_type + try: + EmailServiceType(email_service_type) + except ValueError as exc: + raise ValueError(f"自动注册邮箱服务类型无效: {email_service_type}") from exc + + mode = settings.registration_auto_mode or "pipeline" + if mode not in ("parallel", "pipeline"): + raise ValueError(f"自动注册模式无效: {mode}") + + interval_min = max(0, int(settings.registration_auto_interval_min)) + interval_max = max(interval_min, int(settings.registration_auto_interval_max)) + concurrency = max(1, int(settings.registration_auto_concurrency)) + email_service_id = int(settings.registration_auto_email_service_id or 0) or None + proxy = settings.registration_auto_proxy.strip() or None + + batch_id = str(uuid.uuid4()) + task_uuids = [] + + with get_db() as db: + for _ in range(plan.deficit): + task_uuid = str(uuid.uuid4()) + crud.create_registration_task( + db, + task_uuid=task_uuid, + proxy=proxy, + email_service_id=email_service_id, + ) + task_uuids.append(task_uuid) + + update_auto_registration_state( + status="running", + message=f"自动补货任务运行中: {batch_id}", + current_batch_id=batch_id, + ) + add_auto_registration_log( + f"[自动注册] 已创建补货批量任务 {batch_id},计划注册 {len(task_uuids)} 个账号" + ) + logger.info( + "自动注册批量任务已创建: batch=%s, count=%s, cpa_service_id=%s", + batch_id, + len(task_uuids), + plan.cpa_service_id, + ) + + await run_batch_registration( + batch_id=batch_id, + task_uuids=task_uuids, + email_service_type=email_service_type, + proxy=proxy, + email_service_config=None, + email_service_id=email_service_id, + interval_min=interval_min, + interval_max=interval_max, + concurrency=concurrency, + mode=mode, + auto_upload_cpa=True, + cpa_service_ids=[plan.cpa_service_id], + auto_upload_sub2api=False, + sub2api_service_ids=[], + auto_upload_tm=False, + tm_service_ids=[], + ) + + batch = batch_tasks.get(batch_id) + if batch: + batch_cancelled = bool(batch.get("cancelled")) + current_auto_state = get_auto_registration_state() + refreshed_inventory = await asyncio.to_thread( + get_auto_registration_inventory, settings + ) + refreshed_ready_count = ( + refreshed_inventory[0] + if refreshed_inventory + else current_auto_state.get("current_ready_count") + ) + refreshed_target_count = ( + refreshed_inventory[1] + if refreshed_inventory + else max(1, int(settings.registration_auto_min_ready_auth_files or 1)) + ) + final_status = "cancelled" if batch_cancelled else "idle" + final_message = ( + f"自动补货批量任务已取消: {batch_id}" + if batch_cancelled + else f"自动补货批量任务已完成: {batch_id}" + ) + final_log_message = ( + f"[自动注册] 补货批量任务已取消:成功 {batch.get('success', 0)},失败 {batch.get('failed', 0)}" + if batch_cancelled + else f"[自动注册] 补货批量任务已完成:成功 {batch.get('success', 0)},失败 {batch.get('failed', 0)}" + ) + update_auto_registration_state( + status=final_status, + message=final_message, + current_batch_id=None, + current_ready_count=refreshed_ready_count, + target_ready_count=refreshed_target_count, + last_checked_at=datetime.now(timezone.utc).isoformat(), + ) + add_auto_registration_log(final_log_message) + + return batch_id + + # ============== API Endpoints ============== @router.post("/start", response_model=RegistrationTaskResponse) @@ -972,6 +1172,32 @@ async def get_batch_status(batch_id: str): } +@router.get("/auto-monitor") +async def get_auto_registration_monitor(): + auto_state = get_auto_registration_state() + current_batch_id = auto_state.get("current_batch_id") + batch = batch_tasks.get(current_batch_id) if current_batch_id else None + logs = get_auto_registration_logs().copy() + if batch and current_batch_id: + logs.extend(task_manager.get_batch_logs(current_batch_id)) + + return { + **auto_state, + "logs": logs, + "batch": { + "batch_id": current_batch_id, + "total": batch["total"], + "completed": batch["completed"], + "success": batch["success"], + "failed": batch["failed"], + "current_index": batch["current_index"], + "cancelled": batch["cancelled"], + "finished": batch.get("finished", False), + "progress": f"{batch['completed']}/{batch['total']}", + } if batch else None, + } + + @router.post("/batch/{batch_id}/cancel") async def cancel_batch(batch_id: str): """取消批量任务""" @@ -984,6 +1210,7 @@ async def cancel_batch(batch_id: str): batch["cancelled"] = True task_manager.cancel_batch(batch_id) + _cancel_batch_tasks(batch_id) return {"success": True, "message": "批量任务取消请求已提交,正在让它们有序收工"} @@ -1053,6 +1280,7 @@ async def cancel_task(task_uuid: str): raise HTTPException(status_code=400, detail="任务已完成或已取消") task = crud.update_registration_task(db, task_uuid, status="cancelled") + task_manager.cancel_task(task_uuid) return {"success": True, "message": "任务已取消"} @@ -1086,7 +1314,7 @@ async def get_registration_stats(): ).group_by(RegistrationTask.status).all() # 今日统计 - today = datetime.utcnow().date() + today = utcnow_naive().date() today_status_stats = db.query( RegistrationTask.status, func.count(RegistrationTask.id) diff --git a/src/web/routes/settings.py b/src/web/routes/settings.py index a41799c5..33754f7a 100644 --- a/src/web/routes/settings.py +++ b/src/web/routes/settings.py @@ -10,8 +10,13 @@ from pydantic import BaseModel from ...config.settings import get_settings, update_settings +from ...core.auto_registration import ( + trigger_auto_registration_check, + update_auto_registration_state, +) from ...database import crud from ...database.session import get_db +from ...services import EmailServiceType logger = logging.getLogger(__name__) router = APIRouter() @@ -50,6 +55,17 @@ class RegistrationSettings(BaseModel): sleep_min: int = 5 sleep_max: int = 30 entry_flow: str = "native" + auto_enabled: bool = False + auto_check_interval: int = 60 + auto_min_ready_auth_files: int = 1 + auto_email_service_type: str = "tempmail" + auto_email_service_id: int = 0 + auto_proxy: Optional[str] = None + auto_interval_min: int = 5 + auto_interval_max: int = 30 + auto_concurrency: int = 1 + auto_mode: str = "pipeline" + auto_cpa_service_id: int = 0 class WebUISettings(BaseModel): @@ -98,6 +114,17 @@ async def get_all_settings(): "sleep_min": settings.registration_sleep_min, "sleep_max": settings.registration_sleep_max, "entry_flow": entry_flow, + "auto_enabled": settings.registration_auto_enabled, + "auto_check_interval": settings.registration_auto_check_interval, + "auto_min_ready_auth_files": settings.registration_auto_min_ready_auth_files, + "auto_email_service_type": settings.registration_auto_email_service_type, + "auto_email_service_id": settings.registration_auto_email_service_id, + "auto_proxy": settings.registration_auto_proxy, + "auto_interval_min": settings.registration_auto_interval_min, + "auto_interval_max": settings.registration_auto_interval_max, + "auto_concurrency": settings.registration_auto_concurrency, + "auto_mode": settings.registration_auto_mode, + "auto_cpa_service_id": settings.registration_auto_cpa_service_id, }, "webui": { "host": settings.webui_host, @@ -228,18 +255,81 @@ async def get_registration_settings(): "sleep_min": settings.registration_sleep_min, "sleep_max": settings.registration_sleep_max, "entry_flow": entry_flow, + "auto_enabled": settings.registration_auto_enabled, + "auto_check_interval": settings.registration_auto_check_interval, + "auto_min_ready_auth_files": settings.registration_auto_min_ready_auth_files, + "auto_email_service_type": settings.registration_auto_email_service_type, + "auto_email_service_id": settings.registration_auto_email_service_id, + "auto_proxy": settings.registration_auto_proxy, + "auto_interval_min": settings.registration_auto_interval_min, + "auto_interval_max": settings.registration_auto_interval_max, + "auto_concurrency": settings.registration_auto_concurrency, + "auto_mode": settings.registration_auto_mode, + "auto_cpa_service_id": settings.registration_auto_cpa_service_id, } @router.post("/registration") async def update_registration_settings(request: RegistrationSettings): """更新注册设置""" + if request.timeout < 30 or request.timeout > 600: + raise HTTPException(status_code=400, detail="注册超时时间必须在 30-600 秒之间") + + if request.default_password_length < 8 or request.default_password_length > 64: + raise HTTPException(status_code=400, detail="密码长度必须在 8-64 之间") + + if request.sleep_min < 1 or request.sleep_max < request.sleep_min: + raise HTTPException(status_code=400, detail="注册等待时间参数无效") + flow_raw = (request.entry_flow or "native").strip().lower() # 兼容旧前端历史值:outlook -> native(Outlook 邮箱会在运行时自动走 outlook 链路)。 flow = "native" if flow_raw == "outlook" else flow_raw if flow not in {"native", "abcard"}: raise HTTPException(status_code=400, detail="entry_flow 仅支持 native / abcard") + if request.auto_check_interval < 5 or request.auto_check_interval > 3600: + raise HTTPException(status_code=400, detail="自动注册检查间隔必须在 5-3600 秒之间") + + if request.auto_min_ready_auth_files < 1 or request.auto_min_ready_auth_files > 10000: + raise HTTPException(status_code=400, detail="自动注册保底数量必须在 1-10000 之间") + + try: + EmailServiceType(request.auto_email_service_type) + except ValueError as exc: + raise HTTPException(status_code=400, detail="自动注册邮箱服务类型无效") from exc + + normalized_auto_email_service_type = ( + "imap_mail" if request.auto_email_service_type == "catchall_imap" else request.auto_email_service_type + ) + + if request.auto_interval_min < 0 or request.auto_interval_max < request.auto_interval_min: + raise HTTPException(status_code=400, detail="自动注册间隔时间参数无效") + + if request.auto_concurrency < 1 or request.auto_concurrency > 100: + raise HTTPException(status_code=400, detail="自动注册并发数必须在 1-100 之间") + + if request.auto_mode not in ("parallel", "pipeline"): + raise HTTPException(status_code=400, detail="自动注册模式必须为 parallel 或 pipeline") + + if request.auto_enabled and request.auto_cpa_service_id <= 0: + raise HTTPException(status_code=400, detail="启用自动注册时必须选择一个 CPA 服务") + + with get_db() as db: + if request.auto_enabled: + cpa_service = crud.get_cpa_service_by_id(db, request.auto_cpa_service_id) + if not cpa_service or not cpa_service.enabled: + raise HTTPException(status_code=400, detail="自动注册选择的 CPA 服务不存在或已禁用") + + if request.auto_email_service_id > 0: + email_service = crud.get_email_service_by_id(db, request.auto_email_service_id) + if not email_service or not email_service.enabled: + raise HTTPException(status_code=400, detail="自动注册选择的邮箱服务不存在或已禁用") + normalized_service_type = ( + "imap_mail" if email_service.service_type == "catchall_imap" else email_service.service_type + ) + if normalized_service_type != normalized_auto_email_service_type: + raise HTTPException(status_code=400, detail="自动注册邮箱服务类型与指定服务不匹配") + update_settings( registration_max_retries=request.max_retries, registration_timeout=request.timeout, @@ -247,8 +337,37 @@ async def update_registration_settings(request: RegistrationSettings): registration_sleep_min=request.sleep_min, registration_sleep_max=request.sleep_max, registration_entry_flow=flow, + registration_auto_enabled=request.auto_enabled, + registration_auto_check_interval=request.auto_check_interval, + registration_auto_min_ready_auth_files=request.auto_min_ready_auth_files, + registration_auto_email_service_type=normalized_auto_email_service_type, + registration_auto_email_service_id=max(0, request.auto_email_service_id), + registration_auto_proxy=(request.auto_proxy or "").strip(), + registration_auto_interval_min=request.auto_interval_min, + registration_auto_interval_max=request.auto_interval_max, + registration_auto_concurrency=request.auto_concurrency, + registration_auto_mode=request.auto_mode, + registration_auto_cpa_service_id=max(0, request.auto_cpa_service_id), ) + if request.auto_enabled: + update_auto_registration_state( + enabled=True, + status="checking", + message="自动注册设置已更新,正在立即检查库存", + target_ready_count=request.auto_min_ready_auth_files, + ) + trigger_auto_registration_check() + else: + update_auto_registration_state( + enabled=False, + status="disabled", + message="自动注册已禁用", + current_batch_id=None, + current_ready_count=None, + target_ready_count=request.auto_min_ready_auth_files, + ) + return {"success": True, "message": "注册设置已更新"} @@ -432,9 +551,10 @@ async def cleanup_database( keep_failed: bool = True ): """清理过期数据""" - from datetime import datetime, timedelta + from datetime import timedelta + from ...core.timezone_utils import utcnow_naive - cutoff_date = datetime.utcnow() - timedelta(days=days) + cutoff_date = utcnow_naive() - timedelta(days=days) with get_db() as db: from ...database.models import RegistrationTask diff --git a/src/web/routes/upload/cpa_services.py b/src/web/routes/upload/cpa_services.py index 9c636cb7..29076046 100644 --- a/src/web/routes/upload/cpa_services.py +++ b/src/web/routes/upload/cpa_services.py @@ -4,7 +4,7 @@ from typing import List, Optional from fastapi import APIRouter, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from ....database import crud from ....database.session import get_db @@ -44,8 +44,7 @@ class CpaServiceResponse(BaseModel): created_at: Optional[str] = None updated_at: Optional[str] = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class CpaServiceTestRequest(BaseModel): diff --git a/src/web/routes/upload/sub2api_services.py b/src/web/routes/upload/sub2api_services.py index 653f4b19..91a23236 100644 --- a/src/web/routes/upload/sub2api_services.py +++ b/src/web/routes/upload/sub2api_services.py @@ -4,7 +4,7 @@ from typing import List, Optional from fastapi import APIRouter, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from ....database import crud from ....database.session import get_db @@ -43,8 +43,7 @@ class Sub2ApiServiceResponse(BaseModel): created_at: Optional[str] = None updated_at: Optional[str] = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class Sub2ApiTestRequest(BaseModel): diff --git a/src/web/routes/upload/tm_services.py b/src/web/routes/upload/tm_services.py index b363139e..9fe61e20 100644 --- a/src/web/routes/upload/tm_services.py +++ b/src/web/routes/upload/tm_services.py @@ -4,7 +4,7 @@ from typing import List, Optional from fastapi import APIRouter, HTTPException -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from ....database import crud from ....database.session import get_db @@ -41,8 +41,7 @@ class TmServiceResponse(BaseModel): created_at: Optional[str] = None updated_at: Optional[str] = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class TmTestRequest(BaseModel): diff --git a/src/web/routes/websocket.py b/src/web/routes/websocket.py index d864f837..63bf9ab3 100644 --- a/src/web/routes/websocket.py +++ b/src/web/routes/websocket.py @@ -8,6 +8,7 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect from ..task_manager import task_manager +from .registration import _cancel_batch_tasks logger = logging.getLogger(__name__) router = APIRouter() @@ -145,6 +146,7 @@ async def batch_websocket(websocket: WebSocket, batch_id: str): # 处理取消请求 elif data.get("type") == "cancel": task_manager.cancel_batch(batch_id) + _cancel_batch_tasks(batch_id) await websocket.send_json({ "type": "status", "batch_id": batch_id, diff --git a/src/web/task_manager.py b/src/web/task_manager.py index fde9adf7..538adf00 100644 --- a/src/web/task_manager.py +++ b/src/web/task_manager.py @@ -9,7 +9,8 @@ from concurrent.futures import ThreadPoolExecutor from typing import Dict, Optional, List, Callable, Any from collections import defaultdict -from datetime import datetime + +from ..core.timezone_utils import utcnow_naive logger = logging.getLogger(__name__) @@ -115,7 +116,7 @@ async def _broadcast_log(self, task_uuid: str, log_message: str): "type": "log", "task_uuid": task_uuid, "message": log_message, - "timestamp": datetime.utcnow().isoformat() + "timestamp": utcnow_naive().isoformat() }) # 发送成功后更新 sent_index with _ws_lock: @@ -134,7 +135,7 @@ async def broadcast_status(self, task_uuid: str, status: str, **kwargs): "type": "status", "task_uuid": task_uuid, "status": status, - "timestamp": datetime.utcnow().isoformat(), + "timestamp": utcnow_naive().isoformat(), **kwargs } @@ -264,7 +265,7 @@ async def _broadcast_batch_log(self, batch_id: str, log_message: str): "type": "log", "batch_id": batch_id, "message": log_message, - "timestamp": datetime.utcnow().isoformat() + "timestamp": utcnow_naive().isoformat() }) # 发送成功后更新 sent_index with _ws_lock: @@ -304,7 +305,7 @@ async def _broadcast_batch_status(self, batch_id: str): await ws.send_json({ "type": "status", "batch_id": batch_id, - "timestamp": datetime.utcnow().isoformat(), + "timestamp": utcnow_naive().isoformat(), **status }) except Exception as e: diff --git a/static/js/app.js b/static/js/app.js index 8fd65b6d..32e4afa6 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -8,11 +8,13 @@ let currentTask = null; let currentBatch = null; let logPollingInterval = null; let batchPollingInterval = null; +let autoMonitorPollingInterval = null; let accountsPollingInterval = null; let todayStatsPollingInterval = null; let todayStatsResetInterval = null; let isBatchMode = false; let isOutlookBatchMode = false; +let isAutoMode = false; let outlookAccounts = []; let taskCompleted = false; // 标记任务是否已完成 let batchCompleted = false; // 标记批量任务是否已完成 @@ -44,6 +46,7 @@ let wsReconnectAttempts = 0; let batchWsReconnectAttempts = 0; let wsManualClose = false; let batchWsManualClose = false; +let autoMonitorLastLogIndex = 0; const WS_RECONNECT_BASE_DELAY = 1000; const WS_RECONNECT_MAX_DELAY = 10000; @@ -52,6 +55,7 @@ const WS_RECONNECT_MAX_DELAY = 10000; const elements = { form: document.getElementById('registration-form'), emailService: document.getElementById('email-service'), + emailServiceGroup: document.getElementById('email-service')?.closest('.form-group'), regMode: document.getElementById('reg-mode'), regModeGroup: document.getElementById('reg-mode-group'), batchCountGroup: document.getElementById('batch-count-group'), @@ -71,6 +75,10 @@ const elements = { taskStatus: document.getElementById('task-status'), taskService: document.getElementById('task-service'), taskStatusBadge: document.getElementById('task-status-badge'), + autoMonitorStatusBadge: document.getElementById('auto-monitor-status-badge'), + autoMonitorLastChecked: document.getElementById('auto-monitor-last-checked'), + taskLastChecked: document.getElementById('task-last-checked'), + taskInventory: document.getElementById('task-inventory'), // 批量状态 batchProgressText: document.getElementById('batch-progress-text'), batchProgressPercent: document.getElementById('batch-progress-percent'), @@ -112,13 +120,29 @@ const elements = { autoUploadTm: document.getElementById('auto-upload-tm'), tmServiceSelectGroup: document.getElementById('tm-service-select-group'), tmServiceSelect: document.getElementById('tm-service-select'), + autoRegistrationSection: document.getElementById('auto-registration-section'), + autoRegistrationEnabled: document.getElementById('auto-registration-enabled'), + autoRegistrationCheckInterval: document.getElementById('auto-registration-check-interval'), + autoRegistrationMinReady: document.getElementById('auto-registration-min-ready'), + autoRegistrationCpaServiceId: document.getElementById('auto-registration-cpa-service-id'), + autoRegistrationEmailServiceType: document.getElementById('auto-registration-email-service-type'), + autoRegistrationEmailServiceId: document.getElementById('auto-registration-email-service-id'), + autoRegistrationProxy: document.getElementById('auto-registration-proxy'), + autoRegistrationMode: document.getElementById('auto-registration-mode'), + autoRegistrationConcurrency: document.getElementById('auto-registration-concurrency'), + autoRegistrationIntervalGroup: document.getElementById('auto-registration-interval-group'), + autoRegistrationIntervalMin: document.getElementById('auto-registration-interval-min'), + autoRegistrationIntervalMax: document.getElementById('auto-registration-interval-max'), }; // 初始化 document.addEventListener('DOMContentLoaded', () => { initEventListeners(); + handleModeChange({ target: elements.regMode }); loadAvailableServices(); loadRecentAccounts(); + loadAutoRegistrationSettings(); + loadAutoRegistrationCpaOptions(); startAccountsPolling(); loadTodayStats(true); startTodayStatsPolling(); @@ -216,6 +240,18 @@ function initEventListeners() { // 邮箱服务切换 elements.emailService.addEventListener('change', handleServiceChange); + if (elements.autoRegistrationEmailServiceType) { + elements.autoRegistrationEmailServiceType.addEventListener('change', () => populateAutoRegistrationEmailServiceOptions(0)); + } + if (elements.autoRegistrationMode) { + elements.autoRegistrationMode.addEventListener('change', () => { + handleConcurrencyModeChange( + elements.autoRegistrationMode, + elements.concurrencyHint, + elements.autoRegistrationIntervalGroup + ); + }); + } // 取消按钮 elements.cancelBtn.addEventListener('click', handleCancelTask); @@ -249,6 +285,7 @@ async function loadAvailableServices() { // 更新邮箱服务选择框 updateEmailServiceOptions(); + populateAutoRegistrationEmailServiceOptions(parseInt(elements.autoRegistrationEmailServiceId?.value || '0', 10) || 0); addLog('info', '[系统] 邮箱服务列表已加载'); } catch (error) { @@ -467,10 +504,30 @@ function handleServiceChange(e) { // 模式切换 function handleModeChange(e) { const mode = e.target.value; + isAutoMode = mode === 'auto'; isBatchMode = mode === 'batch'; elements.batchCountGroup.style.display = isBatchMode ? 'block' : 'none'; elements.batchOptions.style.display = isBatchMode ? 'block' : 'none'; + if (elements.autoRegistrationSection) { + elements.autoRegistrationSection.style.display = isAutoMode ? 'block' : 'none'; + } + if (elements.emailServiceGroup) { + elements.emailServiceGroup.style.display = isAutoMode ? 'none' : 'block'; + } + const autoUploadGroup = elements.autoUploadCpa?.closest('#auto-upload-group'); + if (autoUploadGroup) { + autoUploadGroup.style.display = isAutoMode ? 'none' : 'block'; + } + elements.startBtn.textContent = isAutoMode ? '💾 保存自动注册设置' : '🚀 开始注册'; + + if (isAutoMode) { + elements.cancelBtn.disabled = false; + } else { + stopAutoRegistrationMonitor(); + updateAutoMonitorHeader('idle', null); + elements.cancelBtn.disabled = true; + } } // 并发模式切换(批量) @@ -489,6 +546,11 @@ function handleConcurrencyModeChange(selectEl, hintEl, intervalGroupEl) { async function handleStartRegistration(e) { e.preventDefault(); + if (isAutoMode) { + await handleSaveAutoRegistration(); + return; + } + const selectedValue = elements.emailService.value; if (!selectedValue) { toast.error('请选择一个邮箱服务'); @@ -533,6 +595,142 @@ async function handleStartRegistration(e) { } } + +async function loadAutoRegistrationSettings() { + if (!elements.autoRegistrationEnabled) return; + try { + const data = await api.get('/settings'); + const reg = data.registration || {}; + elements.autoRegistrationEnabled.checked = reg.auto_enabled || false; + elements.autoRegistrationCheckInterval.value = reg.auto_check_interval || 60; + elements.autoRegistrationMinReady.value = reg.auto_min_ready_auth_files || 1; + elements.autoRegistrationEmailServiceType.value = reg.auto_email_service_type || 'tempmail'; + elements.autoRegistrationProxy.value = reg.auto_proxy || ''; + elements.autoRegistrationMode.value = reg.auto_mode || 'pipeline'; + elements.autoRegistrationConcurrency.value = reg.auto_concurrency || 1; + elements.autoRegistrationIntervalMin.value = reg.auto_interval_min || 5; + elements.autoRegistrationIntervalMax.value = reg.auto_interval_max || 30; + handleConcurrencyModeChange( + elements.autoRegistrationMode, + elements.concurrencyHint, + elements.autoRegistrationIntervalGroup + ); + elements.autoRegistrationEmailServiceId.dataset.selectedId = String(reg.auto_email_service_id || 0); + elements.autoRegistrationCpaServiceId.dataset.selectedId = String(reg.auto_cpa_service_id || 0); + populateAutoRegistrationEmailServiceOptions(reg.auto_email_service_id || 0); + } catch (error) { + console.error('加载自动注册设置失败:', error); + } +} + +async function loadAutoRegistrationCpaOptions() { + if (!elements.autoRegistrationCpaServiceId) return; + try { + const services = await api.get('/cpa-services?enabled=true'); + const options = ['']; + services.forEach(service => { + options.push(``); + }); + elements.autoRegistrationCpaServiceId.innerHTML = options.join(''); + elements.autoRegistrationCpaServiceId.value = elements.autoRegistrationCpaServiceId.dataset.selectedId || '0'; + } catch (error) { + console.error('加载 CPA 服务失败:', error); + } +} + +function populateAutoRegistrationEmailServiceOptions(selectedId = 0) { + if (!elements.autoRegistrationEmailServiceId || !elements.autoRegistrationEmailServiceType) return; + const selectedType = elements.autoRegistrationEmailServiceType.value || 'tempmail'; + const options = ['']; + const bucket = availableServices[selectedType]; + if (bucket && Array.isArray(bucket.services)) { + bucket.services.forEach(service => { + options.push(``); + }); + } + elements.autoRegistrationEmailServiceId.innerHTML = options.join(''); + elements.autoRegistrationEmailServiceId.value = String(selectedId || elements.autoRegistrationEmailServiceId.dataset.selectedId || 0); +} + +async function handleSaveAutoRegistration() { + const autoCheckInterval = parseInt(elements.autoRegistrationCheckInterval.value, 10) || 60; + const autoMinReady = parseInt(elements.autoRegistrationMinReady.value, 10) || 1; + const autoEmailServiceId = parseInt(elements.autoRegistrationEmailServiceId.value, 10) || 0; + const autoConcurrency = parseInt(elements.autoRegistrationConcurrency.value, 10) || 1; + const autoIntervalMin = parseInt(elements.autoRegistrationIntervalMin.value, 10) || 0; + const autoIntervalMax = parseInt(elements.autoRegistrationIntervalMax.value, 10) || 0; + const autoCpaServiceId = parseInt(elements.autoRegistrationCpaServiceId.value, 10) || 0; + + if (autoCheckInterval < 5 || autoCheckInterval > 3600) { + toast.error('自动注册检查间隔必须在 5-3600 秒之间'); + return; + } + if (autoMinReady < 1 || autoMinReady > 10000) { + toast.error('自动注册保底数量必须在 1-10000 之间'); + return; + } + if (autoIntervalMin < 0 || autoIntervalMax < autoIntervalMin) { + toast.error('自动注册启动间隔参数无效'); + return; + } + if (autoConcurrency < 1 || autoConcurrency > 100) { + toast.error('自动注册并发数必须在 1-100 之间'); + return; + } + if (elements.autoRegistrationEnabled.checked && autoCpaServiceId <= 0) { + toast.error('启用自动注册前请先选择一个 CPA 服务'); + return; + } + + const data = await api.get('/settings'); + const reg = data.registration || {}; + const payload = { + max_retries: reg.max_retries || 3, + timeout: reg.timeout || 120, + default_password_length: reg.default_password_length || 12, + entry_flow: reg.entry_flow || 'native', + sleep_min: reg.sleep_min || 5, + sleep_max: reg.sleep_max || 30, + auto_enabled: elements.autoRegistrationEnabled.checked, + auto_check_interval: autoCheckInterval, + auto_min_ready_auth_files: autoMinReady, + auto_email_service_type: elements.autoRegistrationEmailServiceType.value, + auto_email_service_id: autoEmailServiceId, + auto_proxy: elements.autoRegistrationProxy.value.trim(), + auto_interval_min: autoIntervalMin, + auto_interval_max: autoIntervalMax, + auto_concurrency: autoConcurrency, + auto_mode: elements.autoRegistrationMode.value, + auto_cpa_service_id: autoCpaServiceId, + }; + + await api.post('/settings/registration', payload); + toast.success('自动注册设置已保存'); + + if (elements.autoRegistrationEnabled.checked) { + sessionStorage.setItem('activeTask', JSON.stringify({ mode: 'auto' })); + autoMonitorLastLogIndex = 0; + displayedLogs.clear(); + elements.consoleLog.innerHTML = ''; + addLog('info', '[系统] 自动注册监控已启动'); + startAutoRegistrationMonitor(); + } else { + stopAutoRegistrationMonitor(); + const saved = sessionStorage.getItem('activeTask'); + if (saved) { + try { + const parsed = JSON.parse(saved); + if (parsed.mode === 'auto') { + sessionStorage.removeItem('activeTask'); + } + } catch { + sessionStorage.removeItem('activeTask'); + } + } + addLog('info', '[系统] 自动注册已禁用'); + } +} + // 单次注册 async function handleSingleRegistration(requestData) { // 重置任务状态 @@ -838,7 +1036,7 @@ async function handleCancelTask() { try { // 批量任务取消(包括普通批量模式和 Outlook 批量模式) - if (currentBatch && (isBatchMode || isOutlookBatchMode)) { + if (currentBatch && (isBatchMode || isOutlookBatchMode || isAutoMode)) { // 优先通过 WebSocket 取消 if (batchWebSocket && batchWebSocket.readyState === WebSocket.OPEN) { batchWebSocket.send(JSON.stringify({ type: 'cancel' })); @@ -853,8 +1051,10 @@ async function handleCancelTask() { await api.post(endpoint); addLog('warning', '[警告] 批量任务取消请求已提交'); toast.info('任务取消请求已提交'); - stopBatchPolling(); - resetButtons(); + if (!isAutoMode) { + stopBatchPolling(); + resetButtons(); + } } } // 单次任务取消 @@ -1005,6 +1205,12 @@ function showTaskStatus(task) { elements.taskId.textContent = task.task_uuid.substring(0, 8) + '...'; elements.taskEmail.textContent = '-'; elements.taskService.textContent = '-'; + if (elements.taskLastChecked) { + elements.taskLastChecked.textContent = '-'; + } + if (elements.taskInventory) { + elements.taskInventory.textContent = '-'; + } } // 更新任务状态 @@ -1014,7 +1220,12 @@ function updateTaskStatus(status) { running: { text: '运行中', class: 'running' }, completed: { text: '已完成', class: 'completed' }, failed: { text: '失败', class: 'failed' }, - cancelled: { text: '已取消', class: 'disabled' } + cancelled: { text: '已取消', class: 'disabled' }, + checking: { text: '检查中', class: 'running' }, + idle: { text: '空闲', class: 'completed' }, + disabled: { text: '已禁用', class: 'disabled' }, + error: { text: '异常', class: 'failed' }, + cancelling: { text: '取消中', class: 'running' }, }; const info = statusInfo[status] || { text: status, class: '' }; @@ -1026,8 +1237,8 @@ function updateTaskStatus(status) { // 显示批量状态 function showBatchStatus(batch) { elements.batchProgressSection.style.display = 'block'; - elements.taskStatusRow.style.display = 'none'; - elements.taskStatusBadge.style.display = 'none'; + elements.taskStatusRow.style.display = isAutoMode ? 'grid' : 'none'; + elements.taskStatusBadge.style.display = isAutoMode ? 'inline-flex' : 'none'; elements.batchProgressText.textContent = `0/${batch.count}`; elements.batchProgressPercent.textContent = '0%'; elements.progressBar.style.width = '0%'; @@ -1040,6 +1251,113 @@ function showBatchStatus(batch) { elements.batchFailed.dataset.last = '0'; } +function formatAutoMonitorTimestamp(value) { + if (!value) return '-'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString('zh-CN', { + hour12: false, + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); +} + +function updateAutoMonitorHeader(status, lastCheckedAt) { + if (!elements.autoMonitorStatusBadge || !elements.autoMonitorLastChecked) return; + + if (!isAutoMode) { + elements.autoMonitorStatusBadge.style.display = 'none'; + elements.autoMonitorLastChecked.style.display = 'none'; + return; + } + + const statusInfo = { + pending: { text: '自动等待', class: 'pending' }, + checking: { text: '自动检查中', class: 'running' }, + running: { text: '自动补货中', class: 'running' }, + idle: { text: '自动空闲', class: 'completed' }, + disabled: { text: '自动已禁用', class: 'disabled' }, + error: { text: '自动异常', class: 'failed' }, + cancelling: { text: '自动取消中', class: 'running' }, + }; + + const info = statusInfo[status] || { text: `自动${status || '未知'}`, class: 'pending' }; + elements.autoMonitorStatusBadge.style.display = 'inline-flex'; + elements.autoMonitorStatusBadge.textContent = info.text; + elements.autoMonitorStatusBadge.className = `status-badge ${info.class}`; + elements.autoMonitorLastChecked.style.display = 'inline'; + elements.autoMonitorLastChecked.textContent = `最近检查: ${formatAutoMonitorTimestamp(lastCheckedAt)}`; +} + +async function pollAutoRegistrationStatus() { + try { + const data = await api.get('/registration/auto-monitor'); + + elements.taskStatusRow.style.display = 'grid'; + elements.taskId.textContent = data.current_batch_id || 'auto-registration'; + elements.taskStatus.textContent = data.message || data.status || '-'; + if (elements.taskLastChecked) { + elements.taskLastChecked.textContent = formatAutoMonitorTimestamp(data.last_checked_at); + } + if (elements.taskInventory) { + const readyCount = data.current_ready_count ?? '-'; + const targetCount = data.target_ready_count ?? '-'; + elements.taskInventory.textContent = `${readyCount} / ${targetCount}`; + } + const effectiveStatus = data.batch && data.batch.cancelled && !data.batch.finished + ? 'cancelling' + : (data.status || 'pending'); + updateAutoMonitorHeader(effectiveStatus, data.last_checked_at); + updateTaskStatus(effectiveStatus); + + const logs = data.logs || []; + for (let i = autoMonitorLastLogIndex; i < logs.length; i++) { + addLog(getLogType(logs[i]), logs[i]); + } + autoMonitorLastLogIndex = logs.length; + + if (data.batch) { + currentBatch = data.batch; + activeBatchId = data.batch.batch_id; + batchCompleted = !!data.batch.finished; + elements.cancelBtn.disabled = !!data.batch.finished; + showBatchStatus({ count: data.batch.total }); + updateBatchProgress(data.batch); + if ((!batchWebSocket || batchWebSocket.readyState === WebSocket.CLOSED) && !data.batch.finished) { + connectBatchWebSocket(data.batch.batch_id); + } + } else { + currentBatch = null; + activeBatchId = null; + elements.cancelBtn.disabled = true; + elements.batchProgressSection.style.display = 'none'; + } + } catch (error) { + console.error('加载自动注册监控失败:', error); + updateAutoMonitorHeader('error', null); + elements.taskStatus.textContent = '自动注册监控获取失败'; + addLog('warning', '[警告] 自动注册监控获取失败'); + } +} + +function startAutoRegistrationMonitor() { + stopAutoRegistrationMonitor(); + pollAutoRegistrationStatus(); + autoMonitorPollingInterval = setInterval(() => { + pollAutoRegistrationStatus(); + }, 2000); +} + +function stopAutoRegistrationMonitor() { + if (autoMonitorPollingInterval) { + clearInterval(autoMonitorPollingInterval); + autoMonitorPollingInterval = null; + } +} + // 更新批量进度 function updateBatchProgress(data) { const progress = ((data.completed / data.total) * 100).toFixed(0); @@ -1266,9 +1584,12 @@ function getLogType(log) { // 重置按钮状态 function resetButtons() { elements.startBtn.disabled = false; - elements.cancelBtn.disabled = true; + elements.cancelBtn.disabled = isAutoMode; stopLogPolling(); stopBatchPolling(); + if (!isAutoMode) { + stopAutoRegistrationMonitor(); + } clearWebSocketReconnect(); clearBatchWebSocketReconnect(); currentTask = null; @@ -1284,7 +1605,9 @@ function resetButtons() { activeTaskUuid = null; activeBatchId = null; // 清除 sessionStorage 持久化状态 - sessionStorage.removeItem('activeTask'); + if (!isAutoMode) { + sessionStorage.removeItem('activeTask'); + } // 断开 WebSocket disconnectWebSocket(); disconnectBatchWebSocket(); @@ -1683,6 +2006,11 @@ function initVisibilityReconnect() { addLog('info', '[系统] 页面重新激活,正在重连批量任务监控...'); connectBatchWebSocket(activeBatchId); } + + if (isAutoMode && !autoMonitorPollingInterval) { + addLog('info', '[系统] 页面重新激活,正在恢复自动注册监控...'); + startAutoRegistrationMonitor(); + } }); } @@ -1753,6 +2081,8 @@ async function restoreActiveTask() { } catch { sessionStorage.removeItem('activeTask'); } + } else if (mode === 'auto') { + sessionStorage.removeItem('activeTask'); } } diff --git a/templates/index.html b/templates/index.html index 0cf09508..bf7a83c9 100644 --- a/templates/index.html +++ b/templates/index.html @@ -366,6 +366,7 @@