账号管理
+查看和管理已注册的 OpenAI 账号
+| + | ID | +邮箱 | +邮箱服务 | +状态 | +注册时间 | +操作 | +
|---|
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e58d61a --- /dev/null +++ b/.gitignore @@ -0,0 +1,56 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual Environment +.venv/ +venv/ +ENV/ +env/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Data and Logs +data/ +logs/ +*.db +*.sqlite +*.sqlite3 + +# Token files +token_*.json + +# Environment +.env +.env.local +*.local + +# OS +.DS_Store +Thumbs.db + +# Project specific +backups/ diff --git a/requirements.txt b/requirements.txt index 5d1a46c..c6a9327 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e5ac097 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,24 @@ +""" +OpenAI/Codex CLI 自动注册系统 +""" + +from .config import get_settings, EmailServiceType +from .database import get_db, Account, EmailService, RegistrationTask +from .core import RegistrationEngine, RegistrationResult +from .services import EmailServiceFactory, BaseEmailService + +__version__ = "2.0.0" +__author__ = "Yasal" + +__all__ = [ + 'get_settings', + 'EmailServiceType', + 'get_db', + 'Account', + 'EmailService', + 'RegistrationTask', + 'RegistrationEngine', + 'RegistrationResult', + 'EmailServiceFactory', + 'BaseEmailService', +] diff --git a/src/config/__init__.py b/src/config/__init__.py new file mode 100644 index 0000000..5fa20c2 --- /dev/null +++ b/src/config/__init__.py @@ -0,0 +1,34 @@ +""" +配置模块 +""" + +from .settings import Settings, get_settings, update_settings, get_database_url +from .constants import ( + AccountStatus, + TaskStatus, + EmailServiceType, + APP_NAME, + APP_VERSION, + OTP_CODE_PATTERN, + DEFAULT_PASSWORD_LENGTH, + PASSWORD_CHARSET, + DEFAULT_USER_INFO, + OPENAI_API_ENDPOINTS, +) + +__all__ = [ + 'Settings', + 'get_settings', + 'update_settings', + 'get_database_url', + 'AccountStatus', + 'TaskStatus', + 'EmailServiceType', + 'APP_NAME', + 'APP_VERSION', + 'OTP_CODE_PATTERN', + 'DEFAULT_PASSWORD_LENGTH', + 'PASSWORD_CHARSET', + 'DEFAULT_USER_INFO', + 'OPENAI_API_ENDPOINTS', +] diff --git a/src/config/constants.py b/src/config/constants.py new file mode 100644 index 0000000..7001a06 --- /dev/null +++ b/src/config/constants.py @@ -0,0 +1,265 @@ +""" +常量定义 +""" + +from enum import Enum +from typing import Dict, List, Tuple + + +# ============================================================================ +# 枚举类型 +# ============================================================================ + +class AccountStatus(str, Enum): + """账户状态""" + ACTIVE = "active" + EXPIRED = "expired" + BANNED = "banned" + FAILED = "failed" + + +class TaskStatus(str, Enum): + """任务状态""" + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + + +class EmailServiceType(str, Enum): + """邮箱服务类型""" + TEMPMAIL = "tempmail" + OUTLOOK = "outlook" + CUSTOM_DOMAIN = "custom_domain" + + +# ============================================================================ +# 应用常量 +# ============================================================================ + +APP_NAME = "OpenAI/Codex CLI 自动注册系统" +APP_VERSION = "2.0.0" +APP_DESCRIPTION = "自动注册 OpenAI/Codex CLI 账号的系统" + +# ============================================================================ +# OpenAI OAuth 相关常量 +# ============================================================================ + +# OAuth 参数 +OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" +OAUTH_AUTH_URL = "https://auth.openai.com/oauth/authorize" +OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token" +OAUTH_REDIRECT_URI = "http://localhost:1455/auth/callback" +OAUTH_SCOPE = "openid email profile offline_access" + +# OpenAI API 端点 +OPENAI_API_ENDPOINTS = { + "sentinel": "https://sentinel.openai.com/backend-api/sentinel/req", + "signup": "https://auth.openai.com/api/accounts/authorize/continue", + "register": "https://auth.openai.com/api/accounts/user/register", + "send_otp": "https://auth.openai.com/api/accounts/email-otp/send", + "validate_otp": "https://auth.openai.com/api/accounts/email-otp/validate", + "create_account": "https://auth.openai.com/api/accounts/create_account", + "select_workspace": "https://auth.openai.com/api/accounts/workspace/select", +} + +# ============================================================================ +# 邮箱服务相关常量 +# ============================================================================ + +# Tempmail.lol API 端点 +TEMPMAIL_API_ENDPOINTS = { + "create_inbox": "/inbox/create", + "get_inbox": "/inbox", +} + +# 自定义域名邮箱 API 端点 +CUSTOM_DOMAIN_API_ENDPOINTS = { + "get_config": "/api/config", + "create_email": "/api/emails/generate", + "list_emails": "/api/emails", + "get_email_messages": "/api/emails/{emailId}", + "delete_email": "/api/emails/{emailId}", + "get_message": "/api/emails/{emailId}/{messageId}", +} + +# 邮箱服务默认配置 +EMAIL_SERVICE_DEFAULTS = { + "tempmail": { + "base_url": "https://api.tempmail.lol/v2", + "timeout": 30, + "max_retries": 3, + }, + "outlook": { + "imap_server": "outlook.office365.com", + "imap_port": 993, + "smtp_server": "smtp.office365.com", + "smtp_port": 587, + "timeout": 30, + }, + "custom_domain": { + "base_url": "", # 需要用户配置 + "api_key_header": "X-API-Key", + "timeout": 30, + "max_retries": 3, + } +} + +# ============================================================================ +# 注册流程相关常量 +# ============================================================================ + +# 验证码相关 +OTP_CODE_PATTERN = r"(? .env 文件 > 默认值 + """ + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore", + ) + + # 应用信息 + app_name: str = Field(default="OpenAI/Codex CLI 自动注册系统") + app_version: str = Field(default="2.0.0") + debug: bool = Field(default=False) + + # 数据库配置 + database_url: str = Field( + default=os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(__file__))), + 'data', + 'database.db' + ) + ) + + @field_validator('database_url', mode='before') + @classmethod + def validate_database_url(cls, v): + if isinstance(v, str) and v.startswith("sqlite:///"): + return v + if isinstance(v, str) and not v.startswith(("sqlite:///", "postgresql://", "mysql://")): + # 如果是文件路径,转换为 SQLite URL + if os.path.isabs(v) or ":/" not in v: + return f"sqlite:///{v}" + return v + + # Web UI 配置 + webui_host: str = Field(default="0.0.0.0") + webui_port: int = Field(default=8000) + webui_secret_key: SecretStr = Field( + default=SecretStr("your-secret-key-change-in-production") + ) + + # 日志配置 + log_level: str = Field(default="INFO") + log_file: str = Field(default="logs/app.log") + log_retention_days: int = Field(default=30) + + # OpenAI 配置 + openai_client_id: str = Field(default="app_EMoamEEZ73f0CkXaXp7hrann") + openai_auth_url: str = Field(default="https://auth.openai.com/oauth/authorize") + openai_token_url: str = Field(default="https://auth.openai.com/oauth/token") + openai_redirect_uri: str = Field(default="http://localhost:1455/auth/callback") + openai_scope: str = Field(default="openid email profile offline_access") + + # 代理配置 + proxy_enabled: bool = Field(default=False) + proxy_type: str = Field(default="http") # http, socks5 + proxy_host: str = Field(default="127.0.0.1") + proxy_port: int = Field(default=7890) + proxy_username: Optional[str] = Field(default=None) + proxy_password: Optional[SecretStr] = Field(default=None) + + @property + def proxy_url(self) -> Optional[str]: + """获取完整的代理 URL""" + if not self.proxy_enabled: + return None + + if self.proxy_type == "http": + scheme = "http" + elif self.proxy_type == "socks5": + scheme = "socks5" + else: + return None + + auth = "" + if self.proxy_username and self.proxy_password: + auth = f"{self.proxy_username}:{self.proxy_password.get_secret_value()}@" + + return f"{scheme}://{auth}{self.proxy_host}:{self.proxy_port}" + + # 注册配置 + registration_max_retries: int = Field(default=3) + registration_timeout: int = Field(default=120) # 秒 + registration_default_password_length: int = Field(default=12) + registration_sleep_min: int = Field(default=5) + registration_sleep_max: int = Field(default=30) + + # 邮箱服务配置 + email_service_priority: Dict[str, int] = Field( + default={"tempmail": 0, "outlook": 1, "custom_domain": 2} + ) + + # Tempmail.lol 配置 + tempmail_base_url: str = Field(default="https://api.tempmail.lol/v2") + tempmail_timeout: int = Field(default=30) + tempmail_max_retries: int = Field(default=3) + + # 自定义域名邮箱配置 + custom_domain_base_url: str = Field(default="") + custom_domain_api_key: Optional[SecretStr] = Field(default=None) + + # 安全配置 + encryption_key: SecretStr = Field( + default=SecretStr("your-encryption-key-change-in-production") + ) + + +# 全局配置实例 +_settings: Optional[Settings] = None + + +def get_settings() -> Settings: + """ + 获取全局配置实例(单例模式) + """ + global _settings + if _settings is None: + _settings = Settings() + return _settings + + +def update_settings(**kwargs) -> Settings: + """ + 更新配置(用于测试或运行时配置更改) + """ + global _settings + if _settings is None: + _settings = Settings() + + # 创建新的配置实例 + updated_data = _settings.model_dump() + updated_data.update(kwargs) + _settings = Settings(**updated_data) + return _settings + + +def get_database_url() -> str: + """ + 获取数据库 URL(处理相对路径) + """ + settings = get_settings() + url = settings.database_url + + # 如果 URL 是相对路径,转换为绝对路径 + if url.startswith("sqlite:///"): + path = url[10:] # 移除 "sqlite:///" + if not os.path.isabs(path): + # 转换为相对于项目根目录的路径 + project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) + abs_path = os.path.join(project_root, path) + return f"sqlite:///{abs_path}" + + return url diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..a849736 --- /dev/null +++ b/src/core/__init__.py @@ -0,0 +1,32 @@ +""" +核心功能模块 +""" + +from .oauth import OAuthManager, OAuthStart, generate_oauth_url, submit_callback_url +from .http_client import ( + OpenAIHTTPClient, + HTTPClient, + HTTPClientError, + RequestConfig, + create_http_client, + create_openai_client, +) +from .register import RegistrationEngine, RegistrationResult +from .utils import setup_logging, get_data_dir + +__all__ = [ + 'OAuthManager', + 'OAuthStart', + 'generate_oauth_url', + 'submit_callback_url', + 'OpenAIHTTPClient', + 'HTTPClient', + 'HTTPClientError', + 'RequestConfig', + 'create_http_client', + 'create_openai_client', + 'RegistrationEngine', + 'RegistrationResult', + 'setup_logging', + 'get_data_dir', +] diff --git a/src/core/http_client.py b/src/core/http_client.py new file mode 100644 index 0000000..517dfc4 --- /dev/null +++ b/src/core/http_client.py @@ -0,0 +1,420 @@ +""" +HTTP 客户端封装 +基于 curl_cffi 的 HTTP 请求封装,支持代理和错误处理 +""" + +import time +import json +from typing import Optional, Dict, Any, Union, Tuple +from dataclasses import dataclass +import logging + +from curl_cffi import requests as cffi_requests +from curl_cffi.requests import Session, Response + +from ..config.constants import ERROR_MESSAGES +from ..config.settings import get_settings + + +logger = logging.getLogger(__name__) + + +@dataclass +class RequestConfig: + """HTTP 请求配置""" + timeout: int = 30 + max_retries: int = 3 + retry_delay: float = 1.0 + impersonate: str = "chrome" + verify_ssl: bool = True + follow_redirects: bool = True + + +class HTTPClientError(Exception): + """HTTP 客户端异常""" + pass + + +class HTTPClient: + """ + HTTP 客户端封装 + 支持代理、重试、错误处理和会话管理 + """ + + def __init__( + self, + proxy_url: Optional[str] = None, + config: Optional[RequestConfig] = None, + session: Optional[Session] = None + ): + """ + 初始化 HTTP 客户端 + + Args: + proxy_url: 代理 URL,如 "http://127.0.0.1:7890" + config: 请求配置 + session: 可重用的会话对象 + """ + self.proxy_url = proxy_url + self.config = config or RequestConfig() + self._session = session + + @property + def proxies(self) -> Optional[Dict[str, str]]: + """获取代理配置""" + if not self.proxy_url: + return None + return { + "http": self.proxy_url, + "https": self.proxy_url, + } + + @property + def session(self) -> Session: + """获取会话对象(单例)""" + if self._session is None: + self._session = Session( + proxies=self.proxies, + impersonate=self.config.impersonate, + verify=self.config.verify_ssl, + timeout=self.config.timeout + ) + return self._session + + def request( + self, + method: str, + url: str, + **kwargs + ) -> Response: + """ + 发送 HTTP 请求 + + Args: + method: HTTP 方法 (GET, POST, PUT, DELETE, etc.) + url: 请求 URL + **kwargs: 其他请求参数 + + Returns: + Response 对象 + + Raises: + HTTPClientError: 请求失败 + """ + # 设置默认参数 + kwargs.setdefault("timeout", self.config.timeout) + kwargs.setdefault("allow_redirects", self.config.follow_redirects) + + # 添加代理配置 + if self.proxies and "proxies" not in kwargs: + kwargs["proxies"] = self.proxies + + last_exception = None + for attempt in range(self.config.max_retries): + try: + response = self.session.request(method, url, **kwargs) + + # 检查响应状态码 + if response.status_code >= 400: + logger.warning( + f"HTTP {response.status_code} for {method} {url}" + f" (attempt {attempt + 1}/{self.config.max_retries})" + ) + + # 如果是服务器错误,重试 + if response.status_code >= 500 and attempt < self.config.max_retries - 1: + time.sleep(self.config.retry_delay * (attempt + 1)) + continue + + return response + + except (cffi_requests.RequestsError, ConnectionError, TimeoutError) as e: + last_exception = e + logger.warning( + f"请求失败: {method} {url} (attempt {attempt + 1}/{self.config.max_retries}): {e}" + ) + + if attempt < self.config.max_retries - 1: + time.sleep(self.config.retry_delay * (attempt + 1)) + else: + break + + raise HTTPClientError( + f"请求失败,最大重试次数已达: {method} {url} - {last_exception}" + ) + + def get(self, url: str, **kwargs) -> Response: + """发送 GET 请求""" + return self.request("GET", url, **kwargs) + + def post(self, url: str, data: Any = None, json: Any = None, **kwargs) -> Response: + """发送 POST 请求""" + return self.request("POST", url, data=data, json=json, **kwargs) + + def put(self, url: str, data: Any = None, json: Any = None, **kwargs) -> Response: + """发送 PUT 请求""" + return self.request("PUT", url, data=data, json=json, **kwargs) + + def delete(self, url: str, **kwargs) -> Response: + """发送 DELETE 请求""" + return self.request("DELETE", url, **kwargs) + + def head(self, url: str, **kwargs) -> Response: + """发送 HEAD 请求""" + return self.request("HEAD", url, **kwargs) + + def options(self, url: str, **kwargs) -> Response: + """发送 OPTIONS 请求""" + return self.request("OPTIONS", url, **kwargs) + + def patch(self, url: str, data: Any = None, json: Any = None, **kwargs) -> Response: + """发送 PATCH 请求""" + return self.request("PATCH", url, data=data, json=json, **kwargs) + + def download_file(self, url: str, filepath: str, chunk_size: int = 8192) -> None: + """ + 下载文件 + + Args: + url: 文件 URL + filepath: 保存路径 + chunk_size: 块大小 + + Raises: + HTTPClientError: 下载失败 + """ + try: + response = self.get(url, stream=True) + response.raise_for_status() + + with open(filepath, 'wb') as f: + for chunk in response.iter_content(chunk_size=chunk_size): + if chunk: + f.write(chunk) + + except Exception as e: + raise HTTPClientError(f"下载文件失败: {url} - {e}") + + def check_proxy(self, test_url: str = "https://httpbin.org/ip") -> bool: + """ + 检查代理是否可用 + + Args: + test_url: 测试 URL + + Returns: + bool: 代理是否可用 + """ + if not self.proxy_url: + return False + + try: + response = self.get(test_url, timeout=10) + return response.status_code == 200 + except Exception: + return False + + def close(self): + """关闭会话""" + if self._session: + self._session.close() + self._session = None + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + + +class OpenAIHTTPClient(HTTPClient): + """ + OpenAI 专用 HTTP 客户端 + 包含 OpenAI API 特定的请求方法 + """ + + def __init__( + self, + proxy_url: Optional[str] = None, + config: Optional[RequestConfig] = None + ): + """ + 初始化 OpenAI HTTP 客户端 + + Args: + proxy_url: 代理 URL + config: 请求配置 + """ + super().__init__(proxy_url, config) + + # OpenAI 特定的默认配置 + if config is None: + self.config.timeout = 30 + self.config.max_retries = 3 + + # 默认请求头 + self.default_headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Accept": "application/json", + "Accept-Language": "en-US,en;q=0.9", + "Accept-Encoding": "gzip, deflate, br", + "Connection": "keep-alive", + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-site", + } + + def check_ip_location(self) -> Tuple[bool, Optional[str]]: + """ + 检查 IP 地理位置 + + Returns: + Tuple[是否支持, 位置信息] + """ + try: + response = self.get("https://cloudflare.com/cdn-cgi/trace", timeout=10) + trace_text = response.text + + # 解析位置信息 + import re + loc_match = re.search(r"loc=([A-Z]+)", trace_text) + loc = loc_match.group(1) if loc_match else None + + # 检查是否支持 + if loc in ["CN", "HK", "MO", "TW"]: + return False, loc + return True, loc + + except Exception as e: + logger.error(f"检查 IP 地理位置失败: {e}") + return False, None + + def send_openai_request( + self, + endpoint: str, + method: str = "POST", + data: Optional[Dict[str, Any]] = None, + json_data: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, + **kwargs + ) -> Dict[str, Any]: + """ + 发送 OpenAI API 请求 + + Args: + endpoint: API 端点 + method: HTTP 方法 + data: 表单数据 + json_data: JSON 数据 + headers: 请求头 + **kwargs: 其他参数 + + Returns: + 响应 JSON 数据 + + Raises: + HTTPClientError: 请求失败 + """ + # 合并请求头 + request_headers = self.default_headers.copy() + if headers: + request_headers.update(headers) + + # 设置 Content-Type + if json_data is not None and "Content-Type" not in request_headers: + request_headers["Content-Type"] = "application/json" + elif data is not None and "Content-Type" not in request_headers: + request_headers["Content-Type"] = "application/x-www-form-urlencoded" + + try: + response = self.request( + method, + endpoint, + data=data, + json=json_data, + headers=request_headers, + **kwargs + ) + + # 检查响应状态码 + response.raise_for_status() + + # 尝试解析 JSON + try: + return response.json() + except json.JSONDecodeError: + return {"raw_response": response.text} + + except cffi_requests.RequestsError as e: + raise HTTPClientError(f"OpenAI 请求失败: {endpoint} - {e}") + + def check_sentinel(self, did: str, proxies: Optional[Dict] = None) -> Optional[str]: + """ + 检查 Sentinel 拦截 + + Args: + did: Device ID + proxies: 代理配置 + + Returns: + Sentinel token 或 None + """ + from ..config.constants import OPENAI_API_ENDPOINTS + + try: + sen_req_body = f'{{"p":"","id":"{did}","flow":"authorize_continue"}}' + + response = self.post( + OPENAI_API_ENDPOINTS["sentinel"], + headers={ + "origin": "https://sentinel.openai.com", + "referer": "https://sentinel.openai.com/backend-api/sentinel/frame.html?sv=20260219f9f6", + "content-type": "text/plain;charset=UTF-8", + }, + data=sen_req_body, + ) + + if response.status_code == 200: + return response.json().get("token") + else: + logger.warning(f"Sentinel 检查失败: {response.status_code}") + return None + + except Exception as e: + logger.error(f"Sentinel 检查异常: {e}") + return None + + +def create_http_client( + proxy_url: Optional[str] = None, + config: Optional[RequestConfig] = None +) -> HTTPClient: + """ + 创建 HTTP 客户端工厂函数 + + Args: + proxy_url: 代理 URL + config: 请求配置 + + Returns: + HTTPClient 实例 + """ + return HTTPClient(proxy_url, config) + + +def create_openai_client( + proxy_url: Optional[str] = None, + config: Optional[RequestConfig] = None +) -> OpenAIHTTPClient: + """ + 创建 OpenAI HTTP 客户端工厂函数 + + Args: + proxy_url: 代理 URL + config: 请求配置 + + Returns: + OpenAIHTTPClient 实例 + """ + return OpenAIHTTPClient(proxy_url, config) \ No newline at end of file diff --git a/src/core/oauth.py b/src/core/oauth.py new file mode 100644 index 0000000..bbb7c83 --- /dev/null +++ b/src/core/oauth.py @@ -0,0 +1,336 @@ +""" +OpenAI OAuth 授权模块 +从 main.py 中提取的 OAuth 相关函数 +""" + +import base64 +import hashlib +import json +import secrets +import time +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from typing import Any, Dict, Optional + +from ..config.constants import ( + OAUTH_CLIENT_ID, + OAUTH_AUTH_URL, + OAUTH_TOKEN_URL, + OAUTH_REDIRECT_URI, + OAUTH_SCOPE, +) + + +def _b64url_no_pad(raw: bytes) -> str: + """Base64 URL 编码(无填充)""" + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + + +def _sha256_b64url_no_pad(s: str) -> str: + """SHA256 哈希后 Base64 URL 编码""" + return _b64url_no_pad(hashlib.sha256(s.encode("ascii")).digest()) + + +def _random_state(nbytes: int = 16) -> str: + """生成随机 state""" + return secrets.token_urlsafe(nbytes) + + +def _pkce_verifier() -> str: + """生成 PKCE code_verifier""" + return secrets.token_urlsafe(64) + + +def _parse_callback_url(callback_url: str) -> Dict[str, str]: + """解析回调 URL""" + candidate = callback_url.strip() + if not candidate: + return {"code": "", "state": "", "error": "", "error_description": ""} + + if "://" not in candidate: + if candidate.startswith("?"): + candidate = f"http://localhost{candidate}" + elif any(ch in candidate for ch in "/?#") or ":" in candidate: + candidate = f"http://{candidate}" + elif "=" in candidate: + candidate = f"http://localhost/?{candidate}" + + parsed = urllib.parse.urlparse(candidate) + query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True) + fragment = urllib.parse.parse_qs(parsed.fragment, keep_blank_values=True) + + for key, values in fragment.items(): + if key not in query or not query[key] or not (query[key][0] or "").strip(): + query[key] = values + + def get1(k: str) -> str: + v = query.get(k, [""]) + return (v[0] or "").strip() + + code = get1("code") + state = get1("state") + error = get1("error") + error_description = get1("error_description") + + if code and not state and "#" in code: + code, state = code.split("#", 1) + + if not error and error_description: + error, error_description = error_description, "" + + return { + "code": code, + "state": state, + "error": error, + "error_description": error_description, + } + + +def _jwt_claims_no_verify(id_token: str) -> Dict[str, Any]: + """解析 JWT ID Token(不验证签名)""" + if not id_token or id_token.count(".") < 2: + return {} + payload_b64 = id_token.split(".")[1] + pad = "=" * ((4 - (len(payload_b64) % 4)) % 4) + try: + payload = base64.urlsafe_b64decode((payload_b64 + pad).encode("ascii")) + return json.loads(payload.decode("utf-8")) + except Exception: + return {} + + +def _decode_jwt_segment(seg: str) -> Dict[str, Any]: + """解码 JWT 片段""" + raw = (seg or "").strip() + if not raw: + return {} + pad = "=" * ((4 - (len(raw) % 4)) % 4) + try: + decoded = base64.urlsafe_b64decode((raw + pad).encode("ascii")) + return json.loads(decoded.decode("utf-8")) + except Exception: + return {} + + +def _to_int(v: Any) -> int: + """转换为整数""" + try: + return int(v) + except (TypeError, ValueError): + return 0 + + +def _post_form(url: str, data: Dict[str, str], timeout: int = 30) -> Dict[str, Any]: + """发送 POST 表单请求""" + body = urllib.parse.urlencode(data).encode("utf-8") + req = urllib.request.Request( + url, + data=body, + method="POST", + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + }, + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + raw = resp.read() + if resp.status != 200: + raise RuntimeError( + f"token exchange failed: {resp.status}: {raw.decode('utf-8', 'replace')}" + ) + return json.loads(raw.decode("utf-8")) + except urllib.error.HTTPError as exc: + raw = exc.read() + raise RuntimeError( + f"token exchange failed: {exc.code}: {raw.decode('utf-8', 'replace')}" + ) from exc + + +@dataclass(frozen=True) +class OAuthStart: + """OAuth 开始信息""" + auth_url: str + state: str + code_verifier: str + redirect_uri: str + + +def generate_oauth_url( + *, + redirect_uri: str = OAUTH_REDIRECT_URI, + scope: str = OAUTH_SCOPE, + client_id: str = OAUTH_CLIENT_ID +) -> OAuthStart: + """ + 生成 OAuth 授权 URL + + Args: + redirect_uri: 回调地址 + scope: 权限范围 + client_id: OpenAI Client ID + + Returns: + OAuthStart 对象,包含授权 URL 和必要参数 + """ + state = _random_state() + code_verifier = _pkce_verifier() + code_challenge = _sha256_b64url_no_pad(code_verifier) + + params = { + "client_id": client_id, + "response_type": "code", + "redirect_uri": redirect_uri, + "scope": scope, + "state": state, + "code_challenge": code_challenge, + "code_challenge_method": "S256", + "prompt": "login", + "id_token_add_organizations": "true", + "codex_cli_simplified_flow": "true", + } + auth_url = f"{OAUTH_AUTH_URL}?{urllib.parse.urlencode(params)}" + return OAuthStart( + auth_url=auth_url, + state=state, + code_verifier=code_verifier, + redirect_uri=redirect_uri, + ) + + +def submit_callback_url( + *, + callback_url: str, + expected_state: str, + code_verifier: str, + redirect_uri: str = OAUTH_REDIRECT_URI, + client_id: str = OAUTH_CLIENT_ID, + token_url: str = OAUTH_TOKEN_URL +) -> str: + """ + 处理 OAuth 回调 URL,获取访问令牌 + + Args: + callback_url: 回调 URL + expected_state: 预期的 state 值 + code_verifier: PKCE code_verifier + redirect_uri: 回调地址 + client_id: OpenAI Client ID + token_url: Token 交换地址 + + Returns: + 包含访问令牌等信息的 JSON 字符串 + + Raises: + RuntimeError: OAuth 错误 + ValueError: 缺少必要参数或 state 不匹配 + """ + cb = _parse_callback_url(callback_url) + if cb["error"]: + desc = cb["error_description"] + raise RuntimeError(f"oauth error: {cb['error']}: {desc}".strip()) + + if not cb["code"]: + raise ValueError("callback url missing ?code=") + if not cb["state"]: + raise ValueError("callback url missing ?state=") + if cb["state"] != expected_state: + raise ValueError("state mismatch") + + token_resp = _post_form( + token_url, + { + "grant_type": "authorization_code", + "client_id": client_id, + "code": cb["code"], + "redirect_uri": redirect_uri, + "code_verifier": code_verifier, + }, + ) + + access_token = (token_resp.get("access_token") or "").strip() + refresh_token = (token_resp.get("refresh_token") or "").strip() + id_token = (token_resp.get("id_token") or "").strip() + expires_in = _to_int(token_resp.get("expires_in")) + + claims = _jwt_claims_no_verify(id_token) + email = str(claims.get("email") or "").strip() + auth_claims = claims.get("https://api.openai.com/auth") or {} + account_id = str(auth_claims.get("chatgpt_account_id") or "").strip() + + now = int(time.time()) + expired_rfc3339 = time.strftime( + "%Y-%m-%dT%H:%M:%SZ", time.gmtime(now + max(expires_in, 0)) + ) + now_rfc3339 = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now)) + + config = { + "id_token": id_token, + "access_token": access_token, + "refresh_token": refresh_token, + "account_id": account_id, + "last_refresh": now_rfc3339, + "email": email, + "type": "codex", + "expired": expired_rfc3339, + } + + return json.dumps(config, ensure_ascii=False, separators=(",", ":")) + + +class OAuthManager: + """OAuth 管理器""" + + def __init__( + self, + client_id: str = OAUTH_CLIENT_ID, + auth_url: str = OAUTH_AUTH_URL, + token_url: str = OAUTH_TOKEN_URL, + redirect_uri: str = OAUTH_REDIRECT_URI, + scope: str = OAUTH_SCOPE + ): + self.client_id = client_id + self.auth_url = auth_url + self.token_url = token_url + self.redirect_uri = redirect_uri + self.scope = scope + + def start_oauth(self) -> OAuthStart: + """开始 OAuth 流程""" + return generate_oauth_url( + redirect_uri=self.redirect_uri, + scope=self.scope, + client_id=self.client_id + ) + + def handle_callback( + self, + callback_url: str, + expected_state: str, + code_verifier: str + ) -> Dict[str, Any]: + """处理 OAuth 回调""" + result_json = submit_callback_url( + callback_url=callback_url, + expected_state=expected_state, + code_verifier=code_verifier, + redirect_uri=self.redirect_uri, + client_id=self.client_id, + token_url=self.token_url + ) + return json.loads(result_json) + + def extract_account_info(self, id_token: str) -> Dict[str, Any]: + """从 ID Token 中提取账户信息""" + claims = _jwt_claims_no_verify(id_token) + email = str(claims.get("email") or "").strip() + auth_claims = claims.get("https://api.openai.com/auth") or {} + account_id = str(auth_claims.get("chatgpt_account_id") or "").strip() + + return { + "email": email, + "account_id": account_id, + "claims": claims + } \ No newline at end of file diff --git a/src/core/register.py b/src/core/register.py new file mode 100644 index 0000000..086483d --- /dev/null +++ b/src/core/register.py @@ -0,0 +1,723 @@ +""" +注册流程引擎 +从 main.py 中提取并重构的注册流程 +""" + +import re +import json +import time +import logging +import secrets +import string +from typing import Optional, Dict, Any, Tuple, Callable +from dataclasses import dataclass +from datetime import datetime + +from curl_cffi import requests as cffi_requests + +from .oauth import OAuthManager, OAuthStart +from .http_client import OpenAIHTTPClient, HTTPClientError +from ..services import EmailServiceFactory, BaseEmailService, EmailServiceType +from ..database import crud +from ..database.session import get_db +from ..config.constants import ( + OPENAI_API_ENDPOINTS, + DEFAULT_USER_INFO, + OTP_CODE_PATTERN, + DEFAULT_PASSWORD_LENGTH, + PASSWORD_CHARSET, + AccountStatus, + TaskStatus, +) +from ..config.settings import get_settings + + +logger = logging.getLogger(__name__) + + +@dataclass +class RegistrationResult: + """注册结果""" + success: bool + email: str = "" + account_id: str = "" + workspace_id: str = "" + access_token: str = "" + refresh_token: str = "" + id_token: str = "" + error_message: str = "" + logs: list = None + metadata: dict = None + + def to_dict(self) -> Dict[str, Any]: + """转换为字典""" + return { + "success": self.success, + "email": self.email, + "account_id": self.account_id, + "workspace_id": self.workspace_id, + "access_token": self.access_token[:20] + "..." if self.access_token else "", + "refresh_token": self.refresh_token[:20] + "..." if self.refresh_token else "", + "id_token": self.id_token[:20] + "..." if self.id_token else "", + "error_message": self.error_message, + "logs": self.logs or [], + "metadata": self.metadata or {}, + } + + +class RegistrationEngine: + """ + 注册引擎 + 负责协调邮箱服务、OAuth 流程和 OpenAI API 调用 + """ + + def __init__( + self, + email_service: BaseEmailService, + proxy_url: Optional[str] = None, + callback_logger: Optional[Callable[[str], None]] = None, + task_uuid: Optional[str] = None + ): + """ + 初始化注册引擎 + + Args: + email_service: 邮箱服务实例 + proxy_url: 代理 URL + callback_logger: 日志回调函数 + task_uuid: 任务 UUID(用于数据库记录) + """ + self.email_service = email_service + self.proxy_url = proxy_url + self.callback_logger = callback_logger or (lambda msg: logger.info(msg)) + self.task_uuid = task_uuid + + # 创建 HTTP 客户端 + self.http_client = OpenAIHTTPClient(proxy_url=proxy_url) + + # 创建 OAuth 管理器 + settings = get_settings() + self.oauth_manager = OAuthManager( + client_id=settings.openai_client_id, + auth_url=settings.openai_auth_url, + token_url=settings.openai_token_url, + redirect_uri=settings.openai_redirect_uri, + scope=settings.openai_scope + ) + + # 状态变量 + self.email: Optional[str] = None + self.email_info: Optional[Dict[str, Any]] = None + self.oauth_start: Optional[OAuthStart] = None + self.session: Optional[cffi_requests.Session] = None + self.logs: list = [] + + def _log(self, message: str, level: str = "info"): + """记录日志""" + timestamp = datetime.now().strftime("%H:%M:%S") + log_message = f"[{timestamp}] {message}" + + # 添加到日志列表 + self.logs.append(log_message) + + # 调用回调函数 + if self.callback_logger: + self.callback_logger(log_message) + + # 记录到数据库(如果有关联任务) + if self.task_uuid: + try: + with get_db() as db: + crud.append_task_log(db, self.task_uuid, log_message) + except Exception as e: + logger.warning(f"记录任务日志失败: {e}") + + # 根据级别记录到日志系统 + if level == "error": + logger.error(message) + elif level == "warning": + logger.warning(message) + else: + logger.info(message) + + def _generate_password(self, length: int = DEFAULT_PASSWORD_LENGTH) -> str: + """生成随机密码""" + return ''.join(secrets.choice(PASSWORD_CHARSET) for _ in range(length)) + + def _check_ip_location(self) -> Tuple[bool, Optional[str]]: + """检查 IP 地理位置""" + try: + return self.http_client.check_ip_location() + except Exception as e: + self._log(f"检查 IP 地理位置失败: {e}", "error") + return False, None + + def _create_email(self) -> bool: + """创建邮箱""" + try: + self._log(f"正在创建 {self.email_service.service_type.value} 邮箱...") + self.email_info = self.email_service.create_email() + + if not self.email_info or "email" not in self.email_info: + self._log("创建邮箱失败: 返回信息不完整", "error") + return False + + self.email = self.email_info["email"] + self._log(f"成功创建邮箱: {self.email}") + return True + + except Exception as e: + self._log(f"创建邮箱失败: {e}", "error") + return False + + def _start_oauth(self) -> bool: + """开始 OAuth 流程""" + try: + self._log("开始 OAuth 授权流程...") + self.oauth_start = self.oauth_manager.start_oauth() + self._log(f"OAuth URL 已生成: {self.oauth_start.auth_url[:80]}...") + return True + except Exception as e: + self._log(f"生成 OAuth URL 失败: {e}", "error") + return False + + def _init_session(self) -> bool: + """初始化会话""" + try: + self.session = self.http_client.session + return True + except Exception as e: + self._log(f"初始化会话失败: {e}", "error") + return False + + def _get_device_id(self) -> Optional[str]: + """获取 Device ID""" + try: + if not self.oauth_start: + return None + + response = self.session.get( + self.oauth_start.auth_url, + timeout=15 + ) + did = self.session.cookies.get("oai-did") + self._log(f"Device ID: {did}") + return did + + except Exception as e: + self._log(f"获取 Device ID 失败: {e}", "error") + return None + + def _check_sentinel(self, did: str) -> Optional[str]: + """检查 Sentinel 拦截""" + try: + sen_req_body = f'{{"p":"","id":"{did}","flow":"authorize_continue"}}' + + response = self.http_client.post( + OPENAI_API_ENDPOINTS["sentinel"], + headers={ + "origin": "https://sentinel.openai.com", + "referer": "https://sentinel.openai.com/backend-api/sentinel/frame.html?sv=20260219f9f6", + "content-type": "text/plain;charset=UTF-8", + }, + data=sen_req_body, + ) + + if response.status_code == 200: + sen_token = response.json().get("token") + self._log(f"Sentinel token 获取成功") + return sen_token + else: + self._log(f"Sentinel 检查失败: {response.status_code}", "warning") + return None + + except Exception as e: + self._log(f"Sentinel 检查异常: {e}", "warning") + return None + + def _submit_signup_form(self, did: str, sen_token: Optional[str]) -> bool: + """提交注册表单""" + try: + signup_body = f'{{"username":{{"value":"{self.email}","kind":"email"}},"screen_hint":"signup"}}' + + headers = { + "referer": "https://auth.openai.com/create-account", + "accept": "application/json", + "content-type": "application/json", + } + + if sen_token: + sentinel = f'{{"p": "", "t": "", "c": "{sen_token}", "id": "{did}", "flow": "authorize_continue"}}' + headers["openai-sentinel-token"] = sentinel + + response = self.session.post( + OPENAI_API_ENDPOINTS["signup"], + headers=headers, + data=signup_body, + ) + + self._log(f"提交注册表单状态: {response.status_code}") + return response.status_code == 200 + + except Exception as e: + self._log(f"提交注册表单失败: {e}", "error") + return False + + def _register_password(self) -> Tuple[bool, Optional[str]]: + """注册密码""" + try: + # 生成密码 + password = self._generate_password() + self._log(f"生成密码: {password}") + + # 提交密码注册 + register_body = json.dumps({ + "password": password, + "username": self.email + }) + + response = self.session.post( + OPENAI_API_ENDPOINTS["register"], + headers={ + "referer": "https://auth.openai.com/create-account/password", + "accept": "application/json", + "content-type": "application/json", + }, + data=register_body, + ) + + self._log(f"提交密码状态: {response.status_code}") + + if response.status_code != 200: + self._log(f"密码注册失败: {response.text[:200]}", "warning") + return False, None + + return True, password + + except Exception as e: + self._log(f"密码注册失败: {e}", "error") + return False, None + + def _send_verification_code(self) -> bool: + """发送验证码""" + try: + response = self.session.get( + OPENAI_API_ENDPOINTS["send_otp"], + headers={ + "referer": "https://auth.openai.com/create-account/password", + "accept": "application/json", + }, + ) + + self._log(f"验证码发送状态: {response.status_code}") + return response.status_code == 200 + + except Exception as e: + self._log(f"发送验证码失败: {e}", "error") + return False + + def _get_verification_code(self) -> Optional[str]: + """获取验证码""" + try: + self._log(f"正在等待邮箱 {self.email} 的验证码...") + + email_id = self.email_info.get("service_id") if self.email_info else None + code = self.email_service.get_verification_code( + email=self.email, + email_id=email_id, + timeout=120, + pattern=OTP_CODE_PATTERN + ) + + if code: + self._log(f"成功获取验证码: {code}") + return code + else: + self._log("等待验证码超时", "error") + return None + + except Exception as e: + self._log(f"获取验证码失败: {e}", "error") + return None + + def _validate_verification_code(self, code: str) -> bool: + """验证验证码""" + try: + code_body = f'{{"code":"{code}"}}' + + response = self.session.post( + OPENAI_API_ENDPOINTS["validate_otp"], + headers={ + "referer": "https://auth.openai.com/email-verification", + "accept": "application/json", + "content-type": "application/json", + }, + data=code_body, + ) + + self._log(f"验证码校验状态: {response.status_code}") + return response.status_code == 200 + + except Exception as e: + self._log(f"验证验证码失败: {e}", "error") + return False + + def _create_user_account(self) -> bool: + """创建用户账户""" + try: + create_account_body = json.dumps(DEFAULT_USER_INFO) + + response = self.session.post( + OPENAI_API_ENDPOINTS["create_account"], + headers={ + "referer": "https://auth.openai.com/about-you", + "accept": "application/json", + "content-type": "application/json", + }, + data=create_account_body, + ) + + self._log(f"账户创建状态: {response.status_code}") + + if response.status_code != 200: + self._log(f"账户创建失败: {response.text[:200]}", "warning") + return False + + return True + + except Exception as e: + self._log(f"创建账户失败: {e}", "error") + return False + + def _get_workspace_id(self) -> Optional[str]: + """获取 Workspace ID""" + try: + auth_cookie = self.session.cookies.get("oai-client-auth-session") + if not auth_cookie: + self._log("未能获取到授权 Cookie", "error") + return None + + # 解码 JWT + import base64 + import json as json_module + + try: + segments = auth_cookie.split(".") + if len(segments) < 1: + self._log("授权 Cookie 格式错误", "error") + return None + + # 解码第一个 segment + payload = segments[0] + pad = "=" * ((4 - (len(payload) % 4)) % 4) + decoded = base64.urlsafe_b64decode((payload + pad).encode("ascii")) + auth_json = json_module.loads(decoded.decode("utf-8")) + + workspaces = auth_json.get("workspaces") or [] + if not workspaces: + self._log("授权 Cookie 里没有 workspace 信息", "error") + return None + + workspace_id = str((workspaces[0] or {}).get("id") or "").strip() + if not workspace_id: + self._log("无法解析 workspace_id", "error") + return None + + self._log(f"Workspace ID: {workspace_id}") + return workspace_id + + except Exception as e: + self._log(f"解析授权 Cookie 失败: {e}", "error") + return None + + except Exception as e: + self._log(f"获取 Workspace ID 失败: {e}", "error") + return None + + def _select_workspace(self, workspace_id: str) -> Optional[str]: + """选择 Workspace""" + try: + select_body = f'{{"workspace_id":"{workspace_id}"}}' + + response = self.session.post( + OPENAI_API_ENDPOINTS["select_workspace"], + headers={ + "referer": "https://auth.openai.com/sign-in-with-chatgpt/codex/consent", + "content-type": "application/json", + }, + data=select_body, + ) + + if response.status_code != 200: + self._log(f"选择 workspace 失败: {response.status_code}", "error") + self._log(f"响应: {response.text[:200]}", "warning") + return None + + continue_url = str((response.json() or {}).get("continue_url") or "").strip() + if not continue_url: + self._log("workspace/select 响应里缺少 continue_url", "error") + return None + + self._log(f"Continue URL: {continue_url[:100]}...") + return continue_url + + except Exception as e: + self._log(f"选择 Workspace 失败: {e}", "error") + return None + + def _follow_redirects(self, start_url: str) -> Optional[str]: + """跟随重定向链,寻找回调 URL""" + try: + current_url = start_url + max_redirects = 6 + + for i in range(max_redirects): + self._log(f"重定向 {i+1}/{max_redirects}: {current_url[:100]}...") + + response = self.session.get( + current_url, + allow_redirects=False, + timeout=15 + ) + + location = response.headers.get("Location") or "" + + # 如果不是重定向状态码,停止 + if response.status_code not in [301, 302, 303, 307, 308]: + self._log(f"非重定向状态码: {response.status_code}") + break + + if not location: + self._log("重定向响应缺少 Location 头") + break + + # 构建下一个 URL + import urllib.parse + next_url = urllib.parse.urljoin(current_url, location) + + # 检查是否包含回调参数 + if "code=" in next_url and "state=" in next_url: + self._log(f"找到回调 URL: {next_url[:100]}...") + return next_url + + current_url = next_url + + self._log("未能在重定向链中找到回调 URL", "error") + return None + + except Exception as e: + self._log(f"跟随重定向失败: {e}", "error") + return None + + def _handle_oauth_callback(self, callback_url: str) -> Optional[Dict[str, Any]]: + """处理 OAuth 回调""" + try: + if not self.oauth_start: + self._log("OAuth 流程未初始化", "error") + return None + + self._log("处理 OAuth 回调...") + token_info = self.oauth_manager.handle_callback( + callback_url=callback_url, + expected_state=self.oauth_start.state, + code_verifier=self.oauth_start.code_verifier + ) + + self._log("OAuth 授权成功") + return token_info + + except Exception as e: + self._log(f"处理 OAuth 回调失败: {e}", "error") + return None + + def run(self) -> RegistrationResult: + """ + 执行完整的注册流程 + + Returns: + RegistrationResult: 注册结果 + """ + result = RegistrationResult(success=False, logs=self.logs) + + try: + self._log("=" * 60) + self._log("开始注册流程") + self._log("=" * 60) + + # 1. 检查 IP 地理位置 + self._log("1. 检查 IP 地理位置...") + ip_ok, location = self._check_ip_location() + if not ip_ok: + result.error_message = f"IP 地理位置不支持: {location}" + self._log(f"IP 检查失败: {location}", "error") + return result + + self._log(f"IP 位置: {location}") + + # 2. 创建邮箱 + self._log("2. 创建邮箱...") + if not self._create_email(): + result.error_message = "创建邮箱失败" + return result + + result.email = self.email + + # 3. 初始化会话 + self._log("3. 初始化会话...") + if not self._init_session(): + result.error_message = "初始化会话失败" + return result + + # 4. 开始 OAuth 流程 + self._log("4. 开始 OAuth 授权流程...") + if not self._start_oauth(): + result.error_message = "开始 OAuth 流程失败" + return result + + # 5. 获取 Device ID + self._log("5. 获取 Device ID...") + did = self._get_device_id() + if not did: + result.error_message = "获取 Device ID 失败" + return result + + # 6. 检查 Sentinel 拦截 + self._log("6. 检查 Sentinel 拦截...") + sen_token = self._check_sentinel(did) + if sen_token: + self._log("Sentinel 检查通过") + else: + self._log("Sentinel 检查失败或未启用", "warning") + + # 7. 提交注册表单 + self._log("7. 提交注册表单...") + if not self._submit_signup_form(did, sen_token): + result.error_message = "提交注册表单失败" + return result + + # 8. 注册密码 + self._log("8. 注册密码...") + password_ok, password = self._register_password() + if not password_ok: + result.error_message = "注册密码失败" + return result + + # 9. 发送验证码 + self._log("9. 发送验证码...") + if not self._send_verification_code(): + result.error_message = "发送验证码失败" + return result + + # 10. 获取验证码 + self._log("10. 等待验证码...") + code = self._get_verification_code() + if not code: + result.error_message = "获取验证码失败" + return result + + # 11. 验证验证码 + self._log("11. 验证验证码...") + if not self._validate_verification_code(code): + result.error_message = "验证验证码失败" + return result + + # 12. 创建用户账户 + self._log("12. 创建用户账户...") + if not self._create_user_account(): + result.error_message = "创建用户账户失败" + return result + + # 13. 获取 Workspace ID + self._log("13. 获取 Workspace ID...") + workspace_id = self._get_workspace_id() + if not workspace_id: + result.error_message = "获取 Workspace ID 失败" + return result + + result.workspace_id = workspace_id + + # 14. 选择 Workspace + self._log("14. 选择 Workspace...") + continue_url = self._select_workspace(workspace_id) + if not continue_url: + result.error_message = "选择 Workspace 失败" + return result + + # 15. 跟随重定向链 + self._log("15. 跟随重定向链...") + callback_url = self._follow_redirects(continue_url) + if not callback_url: + result.error_message = "跟随重定向链失败" + return result + + # 16. 处理 OAuth 回调 + self._log("16. 处理 OAuth 回调...") + token_info = self._handle_oauth_callback(callback_url) + if not token_info: + result.error_message = "处理 OAuth 回调失败" + return result + + # 提取账户信息 + result.account_id = token_info.get("account_id", "") + result.access_token = token_info.get("access_token", "") + result.refresh_token = token_info.get("refresh_token", "") + result.id_token = token_info.get("id_token", "") + + # 17. 完成 + self._log("=" * 60) + self._log(f"注册成功!") + self._log(f"邮箱: {result.email}") + self._log(f"Account ID: {result.account_id}") + self._log(f"Workspace ID: {result.workspace_id}") + self._log("=" * 60) + + result.success = True + result.metadata = { + "email_service": self.email_service.service_type.value, + "proxy_used": self.proxy_url, + "registered_at": datetime.now().isoformat(), + } + + return result + + except Exception as e: + self._log(f"注册过程中发生未预期错误: {e}", "error") + result.error_message = str(e) + return result + + def save_to_database(self, result: RegistrationResult) -> bool: + """ + 保存注册结果到数据库 + + Args: + result: 注册结果 + + Returns: + 是否保存成功 + """ + if not result.success: + return False + + try: + with get_db() as db: + # 保存账户信息 + account = crud.create_account( + db, + email=result.email, + email_service=self.email_service.service_type.value, + email_service_id=self.email_info.get("service_id") if self.email_info else None, + account_id=result.account_id, + workspace_id=result.workspace_id, + access_token=result.access_token, + refresh_token=result.refresh_token, + id_token=result.id_token, + proxy_used=self.proxy_url, + metadata=result.metadata + ) + + self._log(f"账户已保存到数据库,ID: {account.id}") + return True + + except Exception as e: + self._log(f"保存到数据库失败: {e}", "error") + return False \ No newline at end of file diff --git a/src/core/utils.py b/src/core/utils.py new file mode 100644 index 0000000..b176571 --- /dev/null +++ b/src/core/utils.py @@ -0,0 +1,566 @@ +""" +通用工具函数 +""" + +import os +import sys +import json +import time +import random +import string +import secrets +import hashlib +import logging +import base64 +import re +import uuid +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional, Union, Callable +from pathlib import Path + +from ..config.constants import PASSWORD_CHARSET, DEFAULT_PASSWORD_LENGTH +from ..config.settings import get_settings + + +def setup_logging( + log_level: str = "INFO", + log_file: Optional[str] = None, + log_format: str = "%(asctime)s [%(levelname)s] %(name)s: %(message)s" +) -> logging.Logger: + """ + 配置日志系统 + + Args: + log_level: 日志级别 (DEBUG, INFO, WARNING, ERROR, CRITICAL) + log_file: 日志文件路径,如果不指定则只输出到控制台 + log_format: 日志格式 + + Returns: + 根日志记录器 + """ + # 设置日志级别 + numeric_level = getattr(logging, log_level.upper(), None) + if not isinstance(numeric_level, int): + numeric_level = logging.INFO + + # 配置根日志记录器 + root_logger = logging.getLogger() + root_logger.setLevel(numeric_level) + + # 清除现有的处理器 + root_logger.handlers.clear() + + # 创建格式化器 + formatter = logging.Formatter(log_format) + + # 控制台处理器 + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setFormatter(formatter) + console_handler.setLevel(numeric_level) + root_logger.addHandler(console_handler) + + # 文件处理器(如果指定了日志文件) + if log_file: + # 确保日志目录存在 + log_dir = os.path.dirname(log_file) + if log_dir: + os.makedirs(log_dir, exist_ok=True) + + file_handler = logging.FileHandler(log_file, encoding="utf-8") + file_handler.setFormatter(formatter) + file_handler.setLevel(numeric_level) + root_logger.addHandler(file_handler) + + return root_logger + + +def generate_password(length: int = DEFAULT_PASSWORD_LENGTH) -> str: + """ + 生成随机密码 + + Args: + length: 密码长度 + + Returns: + 随机密码字符串 + """ + if length < 4: + length = 4 + + # 确保密码包含至少一个大写字母、一个小写字母和一个数字 + password = [ + secrets.choice(string.ascii_lowercase), + secrets.choice(string.ascii_uppercase), + secrets.choice(string.digits), + ] + + # 添加剩余字符 + password.extend(secrets.choice(PASSWORD_CHARSET) for _ in range(length - 3)) + + # 随机打乱 + secrets.SystemRandom().shuffle(password) + + return ''.join(password) + + +def generate_random_string(length: int = 8) -> str: + """ + 生成随机字符串(仅字母) + + Args: + length: 字符串长度 + + Returns: + 随机字符串 + """ + chars = string.ascii_letters + return ''.join(secrets.choice(chars) for _ in range(length)) + + +def generate_uuid() -> str: + """生成 UUID 字符串""" + return str(uuid.uuid4()) + + +def get_timestamp() -> int: + """获取当前时间戳(秒)""" + return int(time.time()) + + +def format_datetime(dt: Optional[datetime] = None, fmt: str = "%Y-%m-%d %H:%M:%S") -> str: + """ + 格式化日期时间 + + Args: + dt: 日期时间对象,如果为 None 则使用当前时间 + fmt: 格式字符串 + + Returns: + 格式化后的字符串 + """ + if dt is None: + dt = datetime.now() + return dt.strftime(fmt) + + +def parse_datetime(dt_str: str, fmt: str = "%Y-%m-%d %H:%M:%S") -> Optional[datetime]: + """ + 解析日期时间字符串 + + Args: + dt_str: 日期时间字符串 + fmt: 格式字符串 + + Returns: + 日期时间对象,如果解析失败返回 None + """ + try: + return datetime.strptime(dt_str, fmt) + except (ValueError, TypeError): + return None + + +def human_readable_size(size_bytes: int) -> str: + """ + 将字节大小转换为人类可读的格式 + + Args: + size_bytes: 字节大小 + + Returns: + 人类可读的字符串 + """ + if size_bytes < 0: + return "0 B" + + units = ["B", "KB", "MB", "GB", "TB", "PB"] + unit_index = 0 + + while size_bytes >= 1024 and unit_index < len(units) - 1: + size_bytes /= 1024 + unit_index += 1 + + return f"{size_bytes:.2f} {units[unit_index]}" + + +def retry_with_backoff( + func: Callable, + max_retries: int = 3, + base_delay: float = 1.0, + max_delay: float = 30.0, + backoff_factor: float = 2.0, + exceptions: tuple = (Exception,) +) -> Any: + """ + 带有指数退避的重试装饰器/函数 + + Args: + func: 要重试的函数 + max_retries: 最大重试次数 + base_delay: 基础延迟(秒) + max_delay: 最大延迟(秒) + backoff_factor: 退避因子 + exceptions: 要捕获的异常类型 + + Returns: + 函数的返回值 + + Raises: + 最后一次尝试的异常 + """ + last_exception = None + + for attempt in range(max_retries + 1): + try: + return func() + except exceptions as e: + last_exception = e + + # 如果是最后一次尝试,直接抛出异常 + if attempt == max_retries: + break + + # 计算延迟时间 + delay = min(base_delay * (backoff_factor ** attempt), max_delay) + + # 添加随机抖动 + delay *= (0.5 + random.random()) + + # 记录日志 + logger = logging.getLogger(__name__) + logger.warning( + f"尝试 {func.__name__} 失败 (attempt {attempt + 1}/{max_retries + 1}): {e}. " + f"等待 {delay:.2f} 秒后重试..." + ) + + time.sleep(delay) + + # 所有重试都失败,抛出最后一个异常 + raise last_exception + + +class RetryDecorator: + """重试装饰器类""" + + def __init__( + self, + max_retries: int = 3, + base_delay: float = 1.0, + max_delay: float = 30.0, + backoff_factor: float = 2.0, + exceptions: tuple = (Exception,) + ): + self.max_retries = max_retries + self.base_delay = base_delay + self.max_delay = max_delay + self.backoff_factor = backoff_factor + self.exceptions = exceptions + + def __call__(self, func: Callable) -> Callable: + """装饰器调用""" + def wrapper(*args, **kwargs): + def func_to_retry(): + return func(*args, **kwargs) + + return retry_with_backoff( + func_to_retry, + max_retries=self.max_retries, + base_delay=self.base_delay, + max_delay=self.max_delay, + backoff_factor=self.backoff_factor, + exceptions=self.exceptions + ) + + return wrapper + + +def validate_email(email: str) -> bool: + """ + 验证邮箱地址格式 + + Args: + email: 邮箱地址 + + Returns: + 是否有效 + """ + pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" + return bool(re.match(pattern, email)) + + +def validate_url(url: str) -> bool: + """ + 验证 URL 格式 + + Args: + url: URL + + Returns: + 是否有效 + """ + pattern = r"^https?://[^\s/$.?#].[^\s]*$" + return bool(re.match(pattern, url)) + + +def sanitize_filename(filename: str) -> str: + """ + 清理文件名,移除不安全的字符 + + Args: + filename: 原始文件名 + + Returns: + 清理后的文件名 + """ + # 移除危险字符 + filename = re.sub(r'[<>:"/\\|?*]', '_', filename) + # 移除控制字符 + filename = ''.join(char for char in filename if ord(char) >= 32) + # 限制长度 + if len(filename) > 255: + name, ext = os.path.splitext(filename) + filename = name[:255 - len(ext)] + ext + return filename + + +def read_json_file(filepath: str) -> Optional[Dict[str, Any]]: + """ + 读取 JSON 文件 + + Args: + filepath: 文件路径 + + Returns: + JSON 数据,如果读取失败返回 None + """ + try: + with open(filepath, 'r', encoding='utf-8') as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError, IOError) as e: + logging.getLogger(__name__).warning(f"读取 JSON 文件失败: {filepath} - {e}") + return None + + +def write_json_file(filepath: str, data: Dict[str, Any], indent: int = 2) -> bool: + """ + 写入 JSON 文件 + + Args: + filepath: 文件路径 + data: 要写入的数据 + indent: 缩进空格数 + + Returns: + 是否成功 + """ + try: + # 确保目录存在 + os.makedirs(os.path.dirname(filepath), exist_ok=True) + + with open(filepath, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=indent) + + return True + except (IOError, TypeError) as e: + logging.getLogger(__name__).error(f"写入 JSON 文件失败: {filepath} - {e}") + return False + + +def get_project_root() -> Path: + """ + 获取项目根目录 + + Returns: + 项目根目录 Path 对象 + """ + # 当前文件所在目录 + current_dir = Path(__file__).parent + + # 向上查找直到找到项目根目录(包含 pyproject.toml 或 setup.py) + for parent in [current_dir] + list(current_dir.parents): + if (parent / "pyproject.toml").exists() or (parent / "setup.py").exists(): + return parent + + # 如果找不到,返回当前目录的父目录 + return current_dir.parent + + +def get_data_dir() -> Path: + """ + 获取数据目录 + + Returns: + 数据目录 Path 对象 + """ + settings = get_settings() + data_dir = Path(settings.database_url).parent + + # 如果 database_url 是 SQLite URL,提取路径 + if settings.database_url.startswith("sqlite:///"): + db_path = settings.database_url[10:] # 移除 "sqlite:///" + data_dir = Path(db_path).parent + + # 确保目录存在 + data_dir.mkdir(parents=True, exist_ok=True) + + return data_dir + + +def get_logs_dir() -> Path: + """ + 获取日志目录 + + Returns: + 日志目录 Path 对象 + """ + settings = get_settings() + log_file = Path(settings.log_file) + log_dir = log_file.parent + + # 确保目录存在 + log_dir.mkdir(parents=True, exist_ok=True) + + return log_dir + + +def format_duration(seconds: int) -> str: + """ + 格式化持续时间 + + Args: + seconds: 秒数 + + Returns: + 格式化的持续时间字符串 + """ + if seconds < 60: + return f"{seconds}秒" + + minutes, seconds = divmod(seconds, 60) + if minutes < 60: + return f"{minutes}分{seconds}秒" + + hours, minutes = divmod(minutes, 60) + if hours < 24: + return f"{hours}小时{minutes}分" + + days, hours = divmod(hours, 24) + return f"{days}天{hours}小时" + + +def mask_sensitive_data(data: Union[str, Dict, List], mask_char: str = "*") -> Union[str, Dict, List]: + """ + 掩码敏感数据 + + Args: + data: 要掩码的数据 + mask_char: 掩码字符 + + Returns: + 掩码后的数据 + """ + if isinstance(data, str): + # 如果是邮箱,掩码中间部分 + if "@" in data: + local, domain = data.split("@", 1) + if len(local) > 2: + masked_local = local[0] + mask_char * (len(local) - 2) + local[-1] + else: + masked_local = mask_char * len(local) + return f"{masked_local}@{domain}" + + # 如果是 token 或密钥,掩码大部分内容 + if len(data) > 10: + return data[:4] + mask_char * (len(data) - 8) + data[-4:] + return mask_char * len(data) + + elif isinstance(data, dict): + masked_dict = {} + for key, value in data.items(): + # 敏感字段名 + sensitive_keys = ["password", "token", "secret", "key", "auth", "credential"] + if any(sensitive in key.lower() for sensitive in sensitive_keys): + masked_dict[key] = mask_sensitive_data(value, mask_char) + else: + masked_dict[key] = value + return masked_dict + + elif isinstance(data, list): + return [mask_sensitive_data(item, mask_char) for item in data] + + return data + + +def calculate_md5(data: Union[str, bytes]) -> str: + """ + 计算 MD5 哈希 + + Args: + data: 要哈希的数据 + + Returns: + MD5 哈希字符串 + """ + if isinstance(data, str): + data = data.encode('utf-8') + + return hashlib.md5(data).hexdigest() + + +def calculate_sha256(data: Union[str, bytes]) -> str: + """ + 计算 SHA256 哈希 + + Args: + data: 要哈希的数据 + + Returns: + SHA256 哈希字符串 + """ + if isinstance(data, str): + data = data.encode('utf-8') + + return hashlib.sha256(data).hexdigest() + + +def base64_encode(data: Union[str, bytes]) -> str: + """Base64 编码""" + if isinstance(data, str): + data = data.encode('utf-8') + + return base64.b64encode(data).decode('utf-8') + + +def base64_decode(data: str) -> str: + """Base64 解码""" + try: + decoded = base64.b64decode(data) + return decoded.decode('utf-8') + except (base64.binascii.Error, UnicodeDecodeError): + return "" + + +class Timer: + """计时器上下文管理器""" + + def __init__(self, name: str = "操作"): + self.name = name + self.start_time = None + self.elapsed = None + + def __enter__(self): + self.start_time = time.time() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.elapsed = time.time() - self.start_time + logger = logging.getLogger(__name__) + logger.debug(f"{self.name} 耗时: {self.elapsed:.2f} 秒") + + def get_elapsed(self) -> float: + """获取经过的时间(秒)""" + if self.elapsed is not None: + return self.elapsed + if self.start_time is not None: + return time.time() - self.start_time + return 0.0 \ No newline at end of file diff --git a/src/database/__init__.py b/src/database/__init__.py new file mode 100644 index 0000000..1ee05b9 --- /dev/null +++ b/src/database/__init__.py @@ -0,0 +1,20 @@ +""" +数据库模块 +""" + +from .models import Base, Account, EmailService, RegistrationTask, Setting +from .session import get_db, init_database, get_session_manager, DatabaseSessionManager +from . import crud + +__all__ = [ + 'Base', + 'Account', + 'EmailService', + 'RegistrationTask', + 'Setting', + 'get_db', + 'init_database', + 'get_session_manager', + 'DatabaseSessionManager', + 'crud', +] diff --git a/src/database/crud.py b/src/database/crud.py new file mode 100644 index 0000000..e147e0f --- /dev/null +++ b/src/database/crud.py @@ -0,0 +1,372 @@ +""" +数据库 CRUD 操作 +""" + +from typing import List, Optional, Dict, Any, Union +from datetime import datetime, timedelta +from sqlalchemy.orm import Session +from sqlalchemy import and_, or_, desc, asc, func + +from .models import Account, EmailService, RegistrationTask, Setting + + +# ============================================================================ +# 账户 CRUD +# ============================================================================ + +def create_account( + db: Session, + email: str, + email_service: str, + email_service_id: Optional[str] = None, + account_id: Optional[str] = None, + workspace_id: Optional[str] = None, + access_token: Optional[str] = None, + refresh_token: Optional[str] = None, + id_token: Optional[str] = None, + proxy_used: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None +) -> Account: + """创建新账户""" + db_account = Account( + email=email, + email_service=email_service, + email_service_id=email_service_id, + account_id=account_id, + workspace_id=workspace_id, + access_token=access_token, + refresh_token=refresh_token, + id_token=id_token, + proxy_used=proxy_used, + metadata=metadata or {}, + registered_at=datetime.utcnow() + ) + db.add(db_account) + db.commit() + db.refresh(db_account) + return db_account + + +def get_account_by_id(db: Session, account_id: int) -> Optional[Account]: + """根据 ID 获取账户""" + return db.query(Account).filter(Account.id == account_id).first() + + +def get_account_by_email(db: Session, email: str) -> Optional[Account]: + """根据邮箱获取账户""" + return db.query(Account).filter(Account.email == email).first() + + +def get_accounts( + db: Session, + skip: int = 0, + limit: int = 100, + email_service: Optional[str] = None, + status: Optional[str] = None, + search: Optional[str] = None +) -> List[Account]: + """获取账户列表(支持分页、筛选)""" + query = db.query(Account) + + if email_service: + query = query.filter(Account.email_service == email_service) + + if status: + query = query.filter(Account.status == status) + + if search: + search_filter = or_( + Account.email.ilike(f"%{search}%"), + Account.account_id.ilike(f"%{search}%"), + Account.workspace_id.ilike(f"%{search}%") + ) + query = query.filter(search_filter) + + query = query.order_by(desc(Account.created_at)).offset(skip).limit(limit) + return query.all() + + +def update_account( + db: Session, + account_id: int, + **kwargs +) -> Optional[Account]: + """更新账户信息""" + db_account = get_account_by_id(db, account_id) + if not db_account: + return None + + for key, value in kwargs.items(): + if hasattr(db_account, key) and value is not None: + setattr(db_account, key, value) + + db.commit() + db.refresh(db_account) + return db_account + + +def delete_account(db: Session, account_id: int) -> bool: + """删除账户""" + db_account = get_account_by_id(db, account_id) + if not db_account: + return False + + db.delete(db_account) + db.commit() + return True + + +def delete_accounts_batch(db: Session, account_ids: List[int]) -> int: + """批量删除账户""" + result = db.query(Account).filter(Account.id.in_(account_ids)).delete(synchronize_session=False) + db.commit() + return result + + +def get_accounts_count( + db: Session, + email_service: Optional[str] = None, + status: Optional[str] = None +) -> int: + """获取账户数量""" + query = db.query(func.count(Account.id)) + + if email_service: + query = query.filter(Account.email_service == email_service) + + if status: + query = query.filter(Account.status == status) + + return query.scalar() + + +# ============================================================================ +# 邮箱服务 CRUD +# ============================================================================ + +def create_email_service( + db: Session, + service_type: str, + name: str, + config: Dict[str, Any], + enabled: bool = True, + priority: int = 0 +) -> EmailService: + """创建邮箱服务配置""" + db_service = EmailService( + service_type=service_type, + name=name, + config=config, + enabled=enabled, + priority=priority + ) + db.add(db_service) + db.commit() + db.refresh(db_service) + return db_service + + +def get_email_service_by_id(db: Session, service_id: int) -> Optional[EmailService]: + """根据 ID 获取邮箱服务""" + return db.query(EmailService).filter(EmailService.id == service_id).first() + + +def get_email_services( + db: Session, + service_type: Optional[str] = None, + enabled: Optional[bool] = None, + skip: int = 0, + limit: int = 100 +) -> List[EmailService]: + """获取邮箱服务列表""" + query = db.query(EmailService) + + if service_type: + query = query.filter(EmailService.service_type == service_type) + + if enabled is not None: + query = query.filter(EmailService.enabled == enabled) + + query = query.order_by( + asc(EmailService.priority), + desc(EmailService.last_used) + ).offset(skip).limit(limit) + + return query.all() + + +def update_email_service( + db: Session, + service_id: int, + **kwargs +) -> Optional[EmailService]: + """更新邮箱服务配置""" + db_service = get_email_service_by_id(db, service_id) + if not db_service: + return None + + for key, value in kwargs.items(): + if hasattr(db_service, key) and value is not None: + setattr(db_service, key, value) + + db.commit() + db.refresh(db_service) + return db_service + + +def delete_email_service(db: Session, service_id: int) -> bool: + """删除邮箱服务配置""" + db_service = get_email_service_by_id(db, service_id) + if not db_service: + return False + + db.delete(db_service) + db.commit() + return True + + +# ============================================================================ +# 注册任务 CRUD +# ============================================================================ + +def create_registration_task( + db: Session, + task_uuid: str, + email_service_id: Optional[int] = None, + proxy: Optional[str] = None +) -> RegistrationTask: + """创建注册任务""" + db_task = RegistrationTask( + task_uuid=task_uuid, + email_service_id=email_service_id, + proxy=proxy, + status='pending' + ) + db.add(db_task) + db.commit() + db.refresh(db_task) + return db_task + + +def get_registration_task_by_uuid(db: Session, task_uuid: str) -> Optional[RegistrationTask]: + """根据 UUID 获取注册任务""" + return db.query(RegistrationTask).filter(RegistrationTask.task_uuid == task_uuid).first() + + +def get_registration_tasks( + db: Session, + status: Optional[str] = None, + skip: int = 0, + limit: int = 100 +) -> List[RegistrationTask]: + """获取注册任务列表""" + query = db.query(RegistrationTask) + + if status: + query = query.filter(RegistrationTask.status == status) + + query = query.order_by(desc(RegistrationTask.created_at)).offset(skip).limit(limit) + return query.all() + + +def update_registration_task( + db: Session, + task_uuid: str, + **kwargs +) -> Optional[RegistrationTask]: + """更新注册任务状态""" + db_task = get_registration_task_by_uuid(db, task_uuid) + if not db_task: + return None + + for key, value in kwargs.items(): + if hasattr(db_task, key): + setattr(db_task, key, value) + + db.commit() + db.refresh(db_task) + return db_task + + +def append_task_log(db: Session, task_uuid: str, log_message: str) -> bool: + """追加任务日志""" + db_task = get_registration_task_by_uuid(db, task_uuid) + if not db_task: + return False + + if db_task.logs: + db_task.logs += f"\n{log_message}" + else: + db_task.logs = log_message + + db.commit() + return True + + +def delete_registration_task(db: Session, task_uuid: str) -> bool: + """删除注册任务""" + db_task = get_registration_task_by_uuid(db, task_uuid) + if not db_task: + return False + + db.delete(db_task) + db.commit() + return True + + +# 为 API 路由添加别名 +get_account = get_account_by_id +get_registration_task = get_registration_task_by_uuid + + +# ============================================================================ +# 设置 CRUD +# ============================================================================ + +def get_setting(db: Session, key: str) -> Optional[Setting]: + """获取设置""" + return db.query(Setting).filter(Setting.key == key).first() + + +def get_settings_by_category(db: Session, category: str) -> List[Setting]: + """根据分类获取设置""" + return db.query(Setting).filter(Setting.category == category).all() + + +def set_setting( + db: Session, + key: str, + value: str, + description: Optional[str] = None, + category: str = 'general' +) -> Setting: + """设置或更新配置项""" + db_setting = get_setting(db, key) + if db_setting: + db_setting.value = value + db_setting.description = description or db_setting.description + db_setting.category = category + db_setting.updated_at = datetime.utcnow() + else: + db_setting = Setting( + key=key, + value=value, + description=description, + category=category + ) + db.add(db_setting) + + db.commit() + db.refresh(db_setting) + return db_setting + + +def delete_setting(db: Session, key: str) -> bool: + """删除设置""" + db_setting = get_setting(db, key) + if not db_setting: + return False + + db.delete(db_setting) + db.commit() + return True \ No newline at end of file diff --git a/src/database/init_db.py b/src/database/init_db.py new file mode 100644 index 0000000..38f9601 --- /dev/null +++ b/src/database/init_db.py @@ -0,0 +1,133 @@ +""" +数据库初始化和初始化数据 +""" + +import json +from datetime import datetime +from .session import init_database +from .crud import set_setting +from .models import Base + + +def init_default_settings(db): + """初始化默认设置""" + # 通用设置 + default_settings = [ + ("system.name", "OpenAI/Codex CLI 自动注册系统", "系统名称", "general"), + ("system.version", "2.0.0", "系统版本", "general"), + ("logs.retention_days", "30", "日志保留天数", "general"), + + # OpenAI 配置 + ("openai.client_id", "app_EMoamEEZ73f0CkXaXp7hrann", "OpenAI OAuth Client ID", "openai"), + ("openai.auth_url", "https://auth.openai.com/oauth/authorize", "OpenAI 认证地址", "openai"), + ("openai.token_url", "https://auth.openai.com/oauth/token", "OpenAI Token 地址", "openai"), + ("openai.redirect_uri", "http://localhost:1455/auth/callback", "OpenAI 回调地址", "openai"), + ("openai.scope", "openid email profile offline_access", "OpenAI 权限范围", "openai"), + + # 代理设置 + ("proxy.enabled", "false", "是否启用代理", "proxy"), + ("proxy.type", "http", "代理类型 (http/socks5)", "proxy"), + ("proxy.host", "127.0.0.1", "代理主机", "proxy"), + ("proxy.port", "7890", "代理端口", "proxy"), + + # 注册设置 + ("registration.max_retries", "3", "最大重试次数", "registration"), + ("registration.timeout", "120", "超时时间(秒)", "registration"), + ("registration.default_password_length", "12", "默认密码长度", "registration"), + + # Web UI 设置 + ("webui.host", "0.0.0.0", "Web UI 监听主机", "webui"), + ("webui.port", "8000", "Web UI 监听端口", "webui"), + ("webui.debug", "true", "调试模式", "webui"), + ] + + for key, value, description, category in default_settings: + set_setting(db, key, value, description, category) + + +def init_default_email_services(db): + """初始化默认邮箱服务(仅模板,需要用户配置)""" + # 这里只创建模板配置,实际配置需要用户通过 Web UI 设置 + pass + + +def initialize_database(database_url: str = None): + """ + 初始化数据库 + 创建所有表并设置默认配置 + """ + # 初始化数据库连接和表 + db_manager = init_database(database_url) + + # 在事务中设置默认配置 + with db_manager.session_scope() as session: + # 初始化默认设置 + init_default_settings(session) + + # 初始化默认邮箱服务 + init_default_email_services(session) + + print("数据库初始化完成") + return db_manager + + +def reset_database(database_url: str = None): + """ + 重置数据库(删除所有表并重新创建) + 警告:会丢失所有数据! + """ + db_manager = init_database(database_url) + + # 删除所有表 + db_manager.drop_tables() + print("已删除所有表") + + # 重新创建所有表 + db_manager.create_tables() + print("已重新创建所有表") + + # 初始化数据 + with db_manager.session_scope() as session: + init_default_settings(session) + + print("数据库重置完成") + return db_manager + + +def check_database_connection(database_url: str = None) -> bool: + """ + 检查数据库连接是否正常 + """ + try: + db_manager = init_database(database_url) + with db_manager.get_db() as db: + # 尝试执行一个简单的查询 + db.execute("SELECT 1") + print("数据库连接正常") + return True + except Exception as e: + print(f"数据库连接失败: {e}") + return False + + +if __name__ == "__main__": + # 当直接运行此脚本时,初始化数据库 + import argparse + + parser = argparse.ArgumentParser(description="数据库初始化脚本") + parser.add_argument("--reset", action="store_true", help="重置数据库(删除所有数据)") + parser.add_argument("--check", action="store_true", help="检查数据库连接") + parser.add_argument("--url", help="数据库连接字符串") + + args = parser.parse_args() + + if args.check: + check_database_connection(args.url) + elif args.reset: + confirm = input("警告:这将删除所有数据!确认重置?(y/N): ") + if confirm.lower() == 'y': + reset_database(args.url) + else: + print("操作已取消") + else: + initialize_database(args.url) \ No newline at end of file diff --git a/src/database/models.py b/src/database/models.py new file mode 100644 index 0000000..af6c041 --- /dev/null +++ b/src/database/models.py @@ -0,0 +1,113 @@ +""" +SQLAlchemy ORM 模型定义 +""" + +from datetime import datetime +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 + +Base = declarative_base() + + +class JSONEncodedDict(TypeDecorator): + """JSON 编码字典类型""" + impl = Text + + def process_bind_param(self, value: Optional[Dict[str, Any]], dialect): + if value is None: + return None + return json.dumps(value, ensure_ascii=False) + + def process_result_value(self, value: Optional[str], dialect): + if value is None: + return None + return json.loads(value) + + +class Account(Base): + """已注册账号表""" + __tablename__ = 'accounts' + + id = Column(Integer, primary_key=True, autoincrement=True) + email = Column(String(255), nullable=False, unique=True, index=True) + password_hash = Column(String(255)) + access_token = Column(Text) + refresh_token = Column(Text) + id_token = Column(Text) + account_id = Column(String(255)) + workspace_id = Column(String(255)) + email_service = Column(String(50), nullable=False) # 'tempmail', 'outlook', 'custom_domain' + email_service_id = Column(String(255)) # 邮箱服务中的ID + proxy_used = Column(String(255)) + registered_at = Column(DateTime, default=datetime.utcnow) + last_refresh = Column(DateTime) + expires_at = Column(DateTime) + status = Column(String(20), default='active') # 'active', 'expired', 'banned', 'failed' + extra_data = Column(JSONEncodedDict) # 额外信息存储 + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + def to_dict(self) -> Dict[str, Any]: + """转换为字典""" + return { + 'id': self.id, + 'email': self.email, + 'email_service': self.email_service, + 'account_id': self.account_id, + 'workspace_id': self.workspace_id, + 'registered_at': self.registered_at.isoformat() if self.registered_at else None, + 'status': self.status, + 'proxy_used': self.proxy_used, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None + } + + +class EmailService(Base): + """邮箱服务配置表""" + __tablename__ = 'email_services' + + id = Column(Integer, primary_key=True, autoincrement=True) + service_type = Column(String(50), nullable=False) # 'outlook', 'custom_domain' + name = Column(String(100), nullable=False) + config = Column(JSONEncodedDict, nullable=False) # 服务配置(加密存储) + 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) + + +class RegistrationTask(Base): + """注册任务表""" + __tablename__ = 'registration_tasks' + + id = Column(Integer, primary_key=True, autoincrement=True) + task_uuid = Column(String(36), unique=True, nullable=False, index=True) # 任务唯一标识 + status = Column(String(20), default='pending') # 'pending', 'running', 'completed', 'failed', 'cancelled' + email_service_id = Column(Integer, ForeignKey('email_services.id'), index=True) # 使用的邮箱服务 + proxy = Column(String(255)) # 使用的代理 + logs = Column(Text) # 注册过程日志 + result = Column(JSONEncodedDict) # 注册结果 + error_message = Column(Text) + created_at = Column(DateTime, default=datetime.utcnow) + started_at = Column(DateTime) + completed_at = Column(DateTime) + + # 关系 + email_service = relationship('EmailService') + + +class Setting(Base): + """系统设置表""" + __tablename__ = 'settings' + + key = Column(String(100), primary_key=True) + 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) \ No newline at end of file diff --git a/src/database/session.py b/src/database/session.py new file mode 100644 index 0000000..98cd498 --- /dev/null +++ b/src/database/session.py @@ -0,0 +1,115 @@ +""" +数据库会话管理 +""" + +from contextlib import contextmanager +from typing import Generator +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, Session +from sqlalchemy.exc import SQLAlchemyError +import os + +from .models import Base + + +class DatabaseSessionManager: + """数据库会话管理器""" + + def __init__(self, database_url: str = None): + if database_url is None: + # 默认使用项目根目录下的 SQLite 数据库 + db_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(__file__))), + 'data', + 'database.db' + ) + # 确保目录存在 + os.makedirs(os.path.dirname(db_path), exist_ok=True) + database_url = f"sqlite:///{db_path}" + + self.database_url = database_url + self.engine = create_engine( + database_url, + connect_args={"check_same_thread": False} if database_url.startswith("sqlite") else {}, + echo=False, # 设置为 True 可以查看所有 SQL 语句 + pool_pre_ping=True # 连接池预检查 + ) + self.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=self.engine) + + def get_db(self) -> Generator[Session, None, None]: + """ + 获取数据库会话的上下文管理器 + 使用示例: + with get_db() as db: + # 使用 db 进行数据库操作 + pass + """ + db = self.SessionLocal() + try: + yield db + finally: + db.close() + + @contextmanager + def session_scope(self) -> Generator[Session, None, None]: + """ + 事务作用域上下文管理器 + 使用示例: + with session_scope() as session: + # 数据库操作 + pass + """ + session = self.SessionLocal() + try: + yield session + session.commit() + except Exception as e: + session.rollback() + raise e + finally: + session.close() + + def create_tables(self): + """创建所有表""" + Base.metadata.create_all(bind=self.engine) + + def drop_tables(self): + """删除所有表(谨慎使用)""" + Base.metadata.drop_all(bind=self.engine) + + +# 全局数据库会话管理器实例 +_db_manager: DatabaseSessionManager = None + + +def init_database(database_url: str = None) -> DatabaseSessionManager: + """ + 初始化数据库会话管理器 + """ + global _db_manager + if _db_manager is None: + _db_manager = DatabaseSessionManager(database_url) + _db_manager.create_tables() + return _db_manager + + +def get_session_manager() -> DatabaseSessionManager: + """ + 获取数据库会话管理器 + """ + if _db_manager is None: + raise RuntimeError("数据库未初始化,请先调用 init_database()") + return _db_manager + + +@contextmanager +def get_db() -> Generator[Session, None, None]: + """ + 获取数据库会话的快捷函数 + """ + manager = get_session_manager() + db = manager.SessionLocal() + try: + yield db + finally: + db.close() \ No newline at end of file diff --git a/src/services/__init__.py b/src/services/__init__.py new file mode 100644 index 0000000..4d1cb9c --- /dev/null +++ b/src/services/__init__.py @@ -0,0 +1,32 @@ +""" +邮箱服务模块 +""" + +from .base import ( + BaseEmailService, + EmailServiceError, + EmailServiceStatus, + EmailServiceFactory, + create_email_service, + EmailServiceType +) +from .tempmail import TempmailService +from .outlook import OutlookService +from .custom_domain import CustomDomainEmailService + +# 注册服务 +EmailServiceFactory.register(EmailServiceType.TEMPMAIL, TempmailService) +EmailServiceFactory.register(EmailServiceType.OUTLOOK, OutlookService) +EmailServiceFactory.register(EmailServiceType.CUSTOM_DOMAIN, CustomDomainEmailService) + +__all__ = [ + 'BaseEmailService', + 'EmailServiceError', + 'EmailServiceStatus', + 'EmailServiceFactory', + 'create_email_service', + 'EmailServiceType', + 'TempmailService', + 'OutlookService', + 'CustomDomainEmailService', +] \ No newline at end of file diff --git a/src/services/base.py b/src/services/base.py new file mode 100644 index 0000000..92d98af --- /dev/null +++ b/src/services/base.py @@ -0,0 +1,384 @@ +""" +邮箱服务抽象基类 +所有邮箱服务实现的基类 +""" + +import abc +import logging +from typing import Optional, Dict, Any, List +from enum import Enum + +from ..config.constants import EmailServiceType + + +logger = logging.getLogger(__name__) + + +class EmailServiceError(Exception): + """邮箱服务异常""" + pass + + +class EmailServiceStatus(Enum): + """邮箱服务状态""" + HEALTHY = "healthy" + DEGRADED = "degraded" + UNAVAILABLE = "unavailable" + + +class BaseEmailService(abc.ABC): + """ + 邮箱服务抽象基类 + + 所有邮箱服务必须实现此接口 + """ + + def __init__(self, service_type: EmailServiceType, name: str = None): + """ + 初始化邮箱服务 + + Args: + service_type: 服务类型 + name: 服务名称 + """ + self.service_type = service_type + self.name = name or f"{service_type.value}_service" + self._status = EmailServiceStatus.HEALTHY + self._last_error = None + + @property + def status(self) -> EmailServiceStatus: + """获取服务状态""" + return self._status + + @property + def last_error(self) -> Optional[str]: + """获取最后一次错误信息""" + return self._last_error + + @abc.abstractmethod + def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]: + """ + 创建新邮箱地址 + + Args: + config: 配置参数,如邮箱前缀、域名等 + + Returns: + 包含邮箱信息的字典,至少包含: + - email: 邮箱地址 + - service_id: 邮箱服务中的 ID + - token/credentials: 访问凭证(如果需要) + + Raises: + EmailServiceError: 创建失败 + """ + pass + + @abc.abstractmethod + def get_verification_code( + self, + email: str, + email_id: str = None, + timeout: int = 120, + pattern: str = r"(? Optional[str]: + """ + 获取验证码 + + Args: + email: 邮箱地址 + email_id: 邮箱服务中的 ID(如果需要) + timeout: 超时时间(秒) + pattern: 验证码正则表达式 + + Returns: + 验证码字符串,如果超时或未找到返回 None + + Raises: + EmailServiceError: 服务错误 + """ + pass + + @abc.abstractmethod + def list_emails(self, **kwargs) -> List[Dict[str, Any]]: + """ + 列出所有邮箱(如果服务支持) + + Args: + **kwargs: 其他参数 + + Returns: + 邮箱列表 + + Raises: + EmailServiceError: 服务错误 + """ + pass + + @abc.abstractmethod + def delete_email(self, email_id: str) -> bool: + """ + 删除邮箱 + + Args: + email_id: 邮箱服务中的 ID + + Returns: + 是否删除成功 + + Raises: + EmailServiceError: 服务错误 + """ + pass + + @abc.abstractmethod + def check_health(self) -> bool: + """ + 检查服务健康状态 + + Returns: + 服务是否健康 + + Note: + 此方法不应抛出异常,应捕获异常并返回 False + """ + pass + + def get_email_info(self, email_id: str) -> Optional[Dict[str, Any]]: + """ + 获取邮箱信息(可选实现) + + Args: + email_id: 邮箱服务中的 ID + + Returns: + 邮箱信息字典,如果不存在返回 None + """ + # 默认实现:遍历列表查找 + for email_info in self.list_emails(): + if email_info.get("id") == email_id: + return email_info + return None + + def wait_for_email( + self, + email: str, + email_id: str = None, + timeout: int = 120, + check_interval: int = 3, + expected_sender: str = None, + expected_subject: str = None + ) -> Optional[Dict[str, Any]]: + """ + 等待并获取邮件(可选实现) + + Args: + email: 邮箱地址 + email_id: 邮箱服务中的 ID + timeout: 超时时间(秒) + check_interval: 检查间隔(秒) + expected_sender: 期望的发件人(包含检查) + expected_subject: 期望的主题(包含检查) + + Returns: + 邮件信息字典,如果超时返回 None + """ + import time + from datetime import datetime + + start_time = time.time() + last_email_id = None + + while time.time() - start_time < timeout: + try: + emails = self.list_emails() + for email_info in emails: + email_data = email_info.get("email", {}) + current_email_id = email_info.get("id") + + # 检查是否是新的邮件 + if last_email_id and current_email_id == last_email_id: + continue + + # 检查邮箱地址 + if email_data.get("address") != email: + continue + + # 获取邮件列表 + messages = self.get_email_messages(email_id or current_email_id) + for message in messages: + # 检查发件人 + if expected_sender and expected_sender not in message.get("from", ""): + continue + + # 检查主题 + if expected_subject and expected_subject not in message.get("subject", ""): + continue + + # 返回邮件信息 + return { + "id": message.get("id"), + "from": message.get("from"), + "subject": message.get("subject"), + "content": message.get("content"), + "received_at": message.get("received_at"), + "email_info": email_info + } + + # 更新最后检查的邮件 ID + if messages: + last_email_id = current_email_id + + except Exception as e: + logger.warning(f"等待邮件时出错: {e}") + + time.sleep(check_interval) + + return None + + def get_email_messages(self, email_id: str, **kwargs) -> List[Dict[str, Any]]: + """ + 获取邮箱中的邮件列表(可选实现) + + Args: + email_id: 邮箱服务中的 ID + **kwargs: 其他参数 + + Returns: + 邮件列表 + + Note: + 这是可选方法,某些服务可能不支持 + """ + raise NotImplementedError("此邮箱服务不支持获取邮件列表") + + def get_message_content(self, email_id: str, message_id: str) -> Optional[Dict[str, Any]]: + """ + 获取邮件内容(可选实现) + + Args: + email_id: 邮箱服务中的 ID + message_id: 邮件 ID + + Returns: + 邮件内容字典 + + Note: + 这是可选方法,某些服务可能不支持 + """ + raise NotImplementedError("此邮箱服务不支持获取邮件内容") + + def update_status(self, success: bool, error: Exception = None): + """ + 更新服务状态 + + Args: + success: 操作是否成功 + error: 错误信息 + """ + if success: + self._status = EmailServiceStatus.HEALTHY + self._last_error = None + else: + self._status = EmailServiceStatus.DEGRADED + if error: + self._last_error = str(error) + + def __str__(self) -> str: + """字符串表示""" + return f"{self.name} ({self.service_type.value})" + + +class EmailServiceFactory: + """邮箱服务工厂""" + + _registry: Dict[EmailServiceType, type] = {} + + @classmethod + def register(cls, service_type: EmailServiceType, service_class: type): + """ + 注册邮箱服务类 + + Args: + service_type: 服务类型 + service_class: 服务类 + """ + if not issubclass(service_class, BaseEmailService): + raise TypeError(f"{service_class} 必须是 BaseEmailService 的子类") + cls._registry[service_type] = service_class + logger.info(f"注册邮箱服务: {service_type.value} -> {service_class.__name__}") + + @classmethod + def create( + cls, + service_type: EmailServiceType, + config: Dict[str, Any], + name: str = None + ) -> BaseEmailService: + """ + 创建邮箱服务实例 + + Args: + service_type: 服务类型 + config: 服务配置 + name: 服务名称 + + Returns: + 邮箱服务实例 + + Raises: + ValueError: 服务类型未注册或配置无效 + """ + if service_type not in cls._registry: + raise ValueError(f"未注册的服务类型: {service_type.value}") + + service_class = cls._registry[service_type] + try: + instance = service_class(config, name) + return instance + except Exception as e: + raise ValueError(f"创建邮箱服务失败: {e}") + + @classmethod + def get_available_services(cls) -> List[EmailServiceType]: + """ + 获取所有已注册的服务类型 + + Returns: + 已注册的服务类型列表 + """ + return list(cls._registry.keys()) + + @classmethod + def get_service_class(cls, service_type: EmailServiceType) -> Optional[type]: + """ + 获取服务类 + + Args: + service_type: 服务类型 + + Returns: + 服务类,如果未注册返回 None + """ + return cls._registry.get(service_type) + + +# 简化的工厂函数 +def create_email_service( + service_type: EmailServiceType, + config: Dict[str, Any], + name: str = None +) -> BaseEmailService: + """ + 创建邮箱服务(简化工厂函数) + + Args: + service_type: 服务类型 + config: 服务配置 + name: 服务名称 + + Returns: + 邮箱服务实例 + """ + return EmailServiceFactory.create(service_type, config, name) \ No newline at end of file diff --git a/src/services/custom_domain.py b/src/services/custom_domain.py new file mode 100644 index 0000000..5c70f11 --- /dev/null +++ b/src/services/custom_domain.py @@ -0,0 +1,528 @@ +""" +自定义域名邮箱服务实现 +基于 email.md 中的 REST API 接口 +""" + +import re +import time +import json +import logging +from typing import Optional, Dict, Any, List +from urllib.parse import urljoin + +from .base import BaseEmailService, EmailServiceError, EmailServiceType +from ..core.http_client import HTTPClient, RequestConfig +from ..config.constants import OTP_CODE_PATTERN + + +logger = logging.getLogger(__name__) + + +class CustomDomainEmailService(BaseEmailService): + """ + 自定义域名邮箱服务 + 基于 REST API 接口 + """ + + def __init__(self, config: Dict[str, Any] = None, name: str = None): + """ + 初始化自定义域名邮箱服务 + + Args: + config: 配置字典,支持以下键: + - base_url: API 基础地址 (必需) + - api_key: API 密钥 (必需) + - api_key_header: API 密钥请求头名称 (默认: X-API-Key) + - timeout: 请求超时时间 (默认: 30) + - max_retries: 最大重试次数 (默认: 3) + - proxy_url: 代理 URL + - default_domain: 默认域名 + - default_expiry: 默认过期时间(毫秒) + name: 服务名称 + """ + super().__init__(EmailServiceType.CUSTOM_DOMAIN, name) + + # 必需配置检查 + required_keys = ["base_url", "api_key"] + missing_keys = [key for key in required_keys if key not in (config or {})] + + if missing_keys: + raise ValueError(f"缺少必需配置: {missing_keys}") + + # 默认配置 + default_config = { + "base_url": "", + "api_key": "", + "api_key_header": "X-API-Key", + "timeout": 30, + "max_retries": 3, + "proxy_url": None, + "default_domain": None, + "default_expiry": 3600000, # 1小时 + } + + self.config = {**default_config, **(config or {})} + + # 创建 HTTP 客户端 + http_config = RequestConfig( + timeout=self.config["timeout"], + max_retries=self.config["max_retries"], + ) + self.http_client = HTTPClient( + proxy_url=self.config.get("proxy_url"), + config=http_config + ) + + # 状态变量 + self._emails_cache: Dict[str, Dict[str, Any]] = {} + self._last_config_check: float = 0 + self._cached_config: Optional[Dict[str, Any]] = None + + def _get_headers(self) -> Dict[str, str]: + """获取 API 请求头""" + headers = { + "Accept": "application/json", + "Content-Type": "application/json", + } + + # 添加 API 密钥 + api_key_header = self.config.get("api_key_header", "X-API-Key") + headers[api_key_header] = self.config["api_key"] + + return headers + + def _make_request(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]: + """ + 发送 API 请求 + + Args: + method: HTTP 方法 + endpoint: API 端点 + **kwargs: 请求参数 + + Returns: + 响应 JSON 数据 + + Raises: + EmailServiceError: 请求失败 + """ + url = urljoin(self.config["base_url"], endpoint) + + # 添加默认请求头 + kwargs.setdefault("headers", {}) + kwargs["headers"].update(self._get_headers()) + + try: + response = self.http_client.request(method, url, **kwargs) + + if response.status_code >= 400: + error_msg = f"API 请求失败: {response.status_code}" + try: + error_data = response.json() + error_msg = f"{error_msg} - {error_data}" + except: + error_msg = f"{error_msg} - {response.text[:200]}" + + self.update_status(False, EmailServiceError(error_msg)) + raise EmailServiceError(error_msg) + + # 解析响应 + try: + return response.json() + except json.JSONDecodeError: + return {"raw_response": response.text} + + except Exception as e: + self.update_status(False, e) + if isinstance(e, EmailServiceError): + raise + raise EmailServiceError(f"API 请求失败: {method} {endpoint} - {e}") + + def get_config(self, force_refresh: bool = False) -> Dict[str, Any]: + """ + 获取系统配置 + + Args: + force_refresh: 是否强制刷新缓存 + + Returns: + 配置信息 + """ + # 检查缓存 + if not force_refresh and self._cached_config and time.time() - self._last_config_check < 300: + return self._cached_config + + try: + response = self._make_request("GET", "/api/config") + self._cached_config = response + self._last_config_check = time.time() + self.update_status(True) + return response + except Exception as e: + logger.warning(f"获取配置失败: {e}") + return {} + + def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]: + """ + 创建临时邮箱 + + Args: + config: 配置参数: + - name: 邮箱前缀(可选) + - expiryTime: 有效期(毫秒)(可选) + - domain: 邮箱域名(可选) + + Returns: + 包含邮箱信息的字典: + - email: 邮箱地址 + - service_id: 邮箱 ID + - id: 邮箱 ID(同 service_id) + - expiry: 过期时间信息 + """ + # 获取默认配置 + sys_config = self.get_config() + default_domain = self.config.get("default_domain") + if not default_domain and sys_config.get("emailDomains"): + # 使用系统配置的第一个域名 + domains = sys_config["emailDomains"].split(",") + default_domain = domains[0].strip() if domains else None + + # 构建请求参数 + request_config = config or {} + create_data = { + "name": request_config.get("name", ""), + "expiryTime": request_config.get("expiryTime", self.config.get("default_expiry", 3600000)), + "domain": request_config.get("domain", default_domain), + } + + # 移除空值 + create_data = {k: v for k, v in create_data.items() if v is not None and v != ""} + + try: + response = self._make_request("POST", "/api/emails/generate", json=create_data) + + email = response.get("email", "").strip() + email_id = response.get("id", "").strip() + + if not email or not email_id: + raise EmailServiceError("API 返回数据不完整") + + email_info = { + "email": email, + "service_id": email_id, + "id": email_id, + "created_at": time.time(), + "expiry": create_data.get("expiryTime"), + "domain": create_data.get("domain"), + "raw_response": response, + } + + # 缓存邮箱信息 + self._emails_cache[email_id] = email_info + + logger.info(f"成功创建自定义域名邮箱: {email} (ID: {email_id})") + self.update_status(True) + return email_info + + except Exception as e: + self.update_status(False, e) + if isinstance(e, EmailServiceError): + raise + raise EmailServiceError(f"创建邮箱失败: {e}") + + def get_verification_code( + self, + email: str, + email_id: str = None, + timeout: int = 120, + pattern: str = OTP_CODE_PATTERN + ) -> Optional[str]: + """ + 从自定义域名邮箱获取验证码 + + Args: + email: 邮箱地址 + email_id: 邮箱 ID(如果不提供,从缓存中查找) + timeout: 超时时间(秒) + pattern: 验证码正则表达式 + + Returns: + 验证码字符串,如果超时或未找到返回 None + """ + # 查找邮箱 ID + target_email_id = email_id + if not target_email_id: + # 从缓存中查找 + for eid, info in self._emails_cache.items(): + if info.get("email") == email: + target_email_id = eid + break + + if not target_email_id: + logger.warning(f"未找到邮箱 {email} 的 ID,无法获取验证码") + return None + + logger.info(f"正在从自定义域名邮箱 {email} 获取验证码...") + + start_time = time.time() + seen_message_ids = set() + + while time.time() - start_time < timeout: + try: + # 获取邮件列表 + response = self._make_request("GET", f"/api/emails/{target_email_id}") + + messages = response.get("messages", []) + if not isinstance(messages, list): + time.sleep(3) + continue + + for message in messages: + message_id = message.get("id") + if not message_id or message_id in seen_message_ids: + continue + + seen_message_ids.add(message_id) + + # 检查是否是目标邮件 + sender = str(message.get("from_address", "")).lower() + subject = str(message.get("subject", "")) + + # 获取邮件内容 + message_content = self._get_message_content(target_email_id, message_id) + if not message_content: + continue + + content = f"{sender} {subject} {message_content}" + + # 检查是否是 OpenAI 邮件 + if "openai" not in sender and "openai" not in content.lower(): + continue + + # 提取验证码 + match = re.search(pattern, content) + if match: + code = match.group(1) + logger.info(f"从自定义域名邮箱 {email} 找到验证码: {code}") + self.update_status(True) + return code + + except Exception as e: + logger.debug(f"检查邮件时出错: {e}") + + # 等待一段时间再检查 + time.sleep(3) + + logger.warning(f"等待验证码超时: {email}") + return None + + def _get_message_content(self, email_id: str, message_id: str) -> Optional[str]: + """获取邮件内容""" + try: + response = self._make_request("GET", f"/api/emails/{email_id}/{message_id}") + message = response.get("message", {}) + + # 优先使用纯文本内容,其次使用 HTML 内容 + content = message.get("content", "") + if not content: + html = message.get("html", "") + if html: + # 简单去除 HTML 标签 + content = re.sub(r"<[^>]+>", " ", html) + + return content + except Exception as e: + logger.debug(f"获取邮件内容失败: {e}") + return None + + def list_emails(self, cursor: str = None, **kwargs) -> List[Dict[str, Any]]: + """ + 列出所有邮箱 + + Args: + cursor: 分页游标 + **kwargs: 其他参数 + + Returns: + 邮箱列表 + """ + params = {} + if cursor: + params["cursor"] = cursor + + try: + response = self._make_request("GET", "/api/emails", params=params) + emails = response.get("emails", []) + + # 更新缓存 + for email_info in emails: + email_id = email_info.get("id") + if email_id: + self._emails_cache[email_id] = email_info + + self.update_status(True) + return emails + except Exception as e: + logger.warning(f"列出邮箱失败: {e}") + self.update_status(False, e) + return [] + + def delete_email(self, email_id: str) -> bool: + """ + 删除邮箱 + + Args: + email_id: 邮箱 ID + + Returns: + 是否删除成功 + """ + try: + response = self._make_request("DELETE", f"/api/emails/{email_id}") + success = response.get("success", False) + + if success: + # 从缓存中移除 + self._emails_cache.pop(email_id, None) + logger.info(f"成功删除邮箱: {email_id}") + else: + logger.warning(f"删除邮箱失败: {email_id}") + + self.update_status(success) + return success + + except Exception as e: + logger.error(f"删除邮箱失败: {email_id} - {e}") + self.update_status(False, e) + return False + + def check_health(self) -> bool: + """检查自定义域名邮箱服务是否可用""" + try: + # 尝试获取配置 + config = self.get_config(force_refresh=True) + if config: + logger.debug(f"自定义域名邮箱服务健康检查通过,配置: {config.get('defaultRole', 'N/A')}") + self.update_status(True) + return True + else: + logger.warning("自定义域名邮箱服务健康检查失败:获取配置为空") + self.update_status(False, EmailServiceError("获取配置为空")) + return False + except Exception as e: + logger.warning(f"自定义域名邮箱服务健康检查失败: {e}") + self.update_status(False, e) + return False + + def get_email_messages(self, email_id: str, cursor: str = None) -> List[Dict[str, Any]]: + """ + 获取邮箱中的邮件列表 + + Args: + email_id: 邮箱 ID + cursor: 分页游标 + + Returns: + 邮件列表 + """ + params = {} + if cursor: + params["cursor"] = cursor + + try: + response = self._make_request("GET", f"/api/emails/{email_id}", params=params) + messages = response.get("messages", []) + self.update_status(True) + return messages + except Exception as e: + logger.error(f"获取邮件列表失败: {email_id} - {e}") + self.update_status(False, e) + return [] + + def get_message_detail(self, email_id: str, message_id: str) -> Optional[Dict[str, Any]]: + """ + 获取邮件详情 + + Args: + email_id: 邮箱 ID + message_id: 邮件 ID + + Returns: + 邮件详情 + """ + try: + response = self._make_request("GET", f"/api/emails/{email_id}/{message_id}") + message = response.get("message") + self.update_status(True) + return message + except Exception as e: + logger.error(f"获取邮件详情失败: {email_id}/{message_id} - {e}") + self.update_status(False, e) + return None + + def create_email_share(self, email_id: str, expires_in: int = 86400000) -> Optional[Dict[str, Any]]: + """ + 创建邮箱分享链接 + + Args: + email_id: 邮箱 ID + expires_in: 有效期(毫秒) + + Returns: + 分享信息 + """ + try: + response = self._make_request( + "POST", + f"/api/emails/{email_id}/share", + json={"expiresIn": expires_in} + ) + self.update_status(True) + return response + except Exception as e: + logger.error(f"创建邮箱分享链接失败: {email_id} - {e}") + self.update_status(False, e) + return None + + def create_message_share( + self, + email_id: str, + message_id: str, + expires_in: int = 86400000 + ) -> Optional[Dict[str, Any]]: + """ + 创建邮件分享链接 + + Args: + email_id: 邮箱 ID + message_id: 邮件 ID + expires_in: 有效期(毫秒) + + Returns: + 分享信息 + """ + try: + response = self._make_request( + "POST", + f"/api/emails/{email_id}/messages/{message_id}/share", + json={"expiresIn": expires_in} + ) + self.update_status(True) + return response + except Exception as e: + logger.error(f"创建邮件分享链接失败: {email_id}/{message_id} - {e}") + self.update_status(False, e) + return None + + def get_service_info(self) -> Dict[str, Any]: + """获取服务信息""" + config = self.get_config() + return { + "service_type": self.service_type.value, + "name": self.name, + "base_url": self.config["base_url"], + "default_domain": self.config.get("default_domain"), + "system_config": config, + "cached_emails_count": len(self._emails_cache), + "status": self.status.value, + } \ No newline at end of file diff --git a/src/services/outlook.py b/src/services/outlook.py new file mode 100644 index 0000000..e8a9906 --- /dev/null +++ b/src/services/outlook.py @@ -0,0 +1,610 @@ +""" +Outlook 邮箱服务实现 +支持 IMAP 协议,XOAUTH2 和密码认证 +""" + +import imaplib +import email +import re +import time +import threading +import json +import urllib.parse +import urllib.request +import base64 +import hashlib +import secrets +import logging +from typing import Optional, Dict, Any, List +from email.header import decode_header +from email.utils import parsedate_to_datetime +from urllib.error import HTTPError + +from .base import BaseEmailService, EmailServiceError, EmailServiceType +from ..config.constants import OTP_CODE_PATTERN + + +logger = logging.getLogger(__name__) + + +class OutlookAccount: + """Outlook 账户信息""" + + def __init__( + self, + email: str, + password: str, + client_id: str = "", + refresh_token: str = "" + ): + self.email = email + self.password = password + self.client_id = client_id + self.refresh_token = refresh_token + + @classmethod + def from_config(cls, config: Dict[str, Any]) -> "OutlookAccount": + """从配置创建账户""" + return cls( + email=config.get("email", ""), + password=config.get("password", ""), + client_id=config.get("client_id", ""), + refresh_token=config.get("refresh_token", "") + ) + + def has_oauth(self) -> bool: + """是否支持 OAuth2""" + return bool(self.client_id and self.refresh_token) + + def validate(self) -> bool: + """验证账户信息是否有效""" + return bool(self.email and self.password) or self.has_oauth() + + +class OutlookIMAPClient: + """ + Outlook IMAP 客户端 + 支持 XOAUTH2 和密码认证 + """ + + # Microsoft OAuth2 Token 缓存 + _token_cache: Dict[str, tuple] = {} + _cache_lock = threading.Lock() + + def __init__( + self, + account: OutlookAccount, + host: str = "outlook.office365.com", + port: int = 993, + timeout: int = 20 + ): + self.account = account + self.host = host + self.port = port + self.timeout = timeout + self._conn: Optional[imaplib.IMAP4_SSL] = None + + @staticmethod + def refresh_ms_token(account: OutlookAccount, timeout: int = 15) -> str: + """刷新 Microsoft access token""" + if not account.client_id or not account.refresh_token: + raise RuntimeError("缺少 client_id 或 refresh_token") + + key = account.email.lower() + with OutlookIMAPClient._cache_lock: + cached = OutlookIMAPClient._token_cache.get(key) + if cached and time.time() < cached[1]: + return cached[0] + + body = urllib.parse.urlencode({ + "client_id": account.client_id, + "refresh_token": account.refresh_token, + "grant_type": "refresh_token", + "redirect_uri": "https://login.live.com/oauth20_desktop.srf", + }).encode() + + req = urllib.request.Request( + "https://login.live.com/oauth20_token.srf", + data=body, + headers={"Content-Type": "application/x-www-form-urlencoded"} + ) + + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = json.loads(resp.read()) + except HTTPError as e: + raise RuntimeError(f"MS OAuth 刷新失败: {e.code}") from e + + token = data.get("access_token") + if not token: + raise RuntimeError("MS OAuth 响应无 access_token") + + ttl = int(data.get("expires_in", 3600)) + with OutlookIMAPClient._cache_lock: + OutlookIMAPClient._token_cache[key] = (token, time.time() + ttl - 120) + + return token + + @staticmethod + def _build_xoauth2(email_addr: str, token: str) -> bytes: + """构建 XOAUTH2 认证字符串""" + return f"user={email_addr}\x01auth=Bearer {token}\x01\x01".encode() + + def connect(self): + """连接到 IMAP 服务器""" + self._conn = imaplib.IMAP4_SSL(self.host, self.port, timeout=self.timeout) + + # 优先使用 XOAUTH2 认证 + if self.account.has_oauth(): + try: + token = self.refresh_ms_token(self.account) + self._conn.authenticate( + "XOAUTH2", + lambda _: self._build_xoauth2(self.account.email, token) + ) + logger.debug(f"使用 XOAUTH2 认证连接: {self.account.email}") + return + except Exception as e: + logger.warning(f"XOAUTH2 认证失败,回退密码认证: {e}") + + # 回退到密码认证 + self._conn.login(self.account.email, self.account.password) + logger.debug(f"使用密码认证连接: {self.account.email}") + + def _ensure_connection(self): + """确保连接有效""" + if self._conn: + try: + self._conn.noop() + return + except Exception: + self.close() + + self.connect() + + def get_recent_emails( + self, + count: int = 20, + only_unseen: bool = True, + timeout: int = 30 + ) -> List[Dict[str, Any]]: + """ + 获取最近的邮件 + + Args: + count: 获取的邮件数量 + only_unseen: 是否只获取未读邮件 + timeout: 超时时间 + + Returns: + 邮件列表 + """ + self._ensure_connection() + + flag = "UNSEEN" if only_unseen else "ALL" + self._conn.select("INBOX", readonly=True) + + _, data = self._conn.search(None, flag) + if not data or not data[0]: + return [] + + # 获取最新的邮件 + ids = data[0].split()[-count:] + result = [] + + for mid in reversed(ids): + try: + _, payload = self._conn.fetch(mid, "(RFC822)") + if not payload: + continue + + raw = b"" + for part in payload: + if isinstance(part, tuple) and len(part) > 1: + raw = part[1] + break + + if raw: + result.append(self._parse_email(raw)) + except Exception as e: + logger.warning(f"解析邮件失败 (ID: {mid}): {e}") + + return result + + @staticmethod + def _parse_email(raw: bytes) -> Dict[str, Any]: + """解析邮件内容""" + # 移除可能的 BOM + if raw.startswith(b"\xef\xbb\xbf"): + raw = raw[3:] + + msg = email.message_from_bytes(raw) + + # 解析邮件头 + subject = OutlookIMAPClient._decode_header(msg.get("Subject", "")) + sender = OutlookIMAPClient._decode_header(msg.get("From", "")) + date_str = OutlookIMAPClient._decode_header(msg.get("Date", "")) + to = OutlookIMAPClient._decode_header(msg.get("To", "")) + delivered_to = OutlookIMAPClient._decode_header(msg.get("Delivered-To", "")) + x_original_to = OutlookIMAPClient._decode_header(msg.get("X-Original-To", "")) + + # 提取邮件正文 + body = OutlookIMAPClient._extract_body(msg) + + # 解析日期 + date_timestamp = 0 + try: + if date_str: + dt = parsedate_to_datetime(date_str) + date_timestamp = int(dt.timestamp()) + except Exception: + pass + + return { + "subject": subject, + "from": sender, + "date": date_str, + "date_timestamp": date_timestamp, + "to": to, + "delivered_to": delivered_to, + "x_original_to": x_original_to, + "body": body, + "raw": raw.hex()[:100] # 存储原始数据的部分哈希用于调试 + } + + @staticmethod + def _decode_header(header: str) -> str: + """解码邮件头""" + if not header: + return "" + + parts = [] + for chunk, encoding in decode_header(header): + if isinstance(chunk, bytes): + try: + decoded = chunk.decode(encoding or "utf-8", errors="replace") + parts.append(decoded) + except Exception: + parts.append(chunk.decode("utf-8", errors="replace")) + else: + parts.append(chunk) + + return "".join(parts).strip() + + @staticmethod + def _extract_body(msg) -> str: + """提取邮件正文""" + import html as html_module + + texts = [] + parts = msg.walk() if msg.is_multipart() else [msg] + + for part in parts: + content_type = part.get_content_type() + if content_type not in ("text/plain", "text/html"): + continue + + payload = part.get_payload(decode=True) + if not payload: + continue + + charset = part.get_content_charset() or "utf-8" + try: + text = payload.decode(charset, errors="replace") + except LookupError: + text = payload.decode("utf-8", errors="replace") + + # 如果是 HTML,移除标签 + if "]+>", " ", text) + + texts.append(text) + + # 合并并清理文本 + combined = " ".join(texts) + combined = html_module.unescape(combined) + combined = re.sub(r"\s+", " ", combined).strip() + + return combined + + def close(self): + """关闭连接""" + if self._conn: + try: + self._conn.close() + except Exception: + pass + try: + self._conn.logout() + except Exception: + pass + self._conn = None + + def __enter__(self): + self.connect() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + + +class OutlookService(BaseEmailService): + """ + Outlook 邮箱服务 + 支持多个 Outlook 账户的轮询和验证码获取 + """ + + def __init__(self, config: Dict[str, Any] = None, name: str = None): + """ + 初始化 Outlook 服务 + + Args: + config: 配置字典,支持以下键: + - accounts: Outlook 账户列表,每个账户包含: + - email: 邮箱地址 + - password: 密码 + - client_id: OAuth2 client_id (可选) + - refresh_token: OAuth2 refresh_token (可选) + - imap_host: IMAP 服务器 (默认: outlook.office365.com) + - imap_port: IMAP 端口 (默认: 993) + - timeout: 超时时间 (默认: 30) + - max_retries: 最大重试次数 (默认: 3) + name: 服务名称 + """ + super().__init__(EmailServiceType.OUTLOOK, name) + + # 默认配置 + default_config = { + "accounts": [], + "imap_host": "outlook.office365.com", + "imap_port": 993, + "timeout": 30, + "max_retries": 3, + "proxy_url": None, + } + + self.config = {**default_config, **(config or {})} + + # 解析账户 + self.accounts: List[OutlookAccount] = [] + self._current_account_index = 0 + self._account_locks: Dict[str, threading.Lock] = {} + + for account_config in self.config.get("accounts", []): + account = OutlookAccount.from_config(account_config) + if account.validate(): + self.accounts.append(account) + self._account_locks[account.email] = threading.Lock() + else: + logger.warning(f"无效的 Outlook 账户配置: {account_config}") + + if not self.accounts: + logger.warning("未配置有效的 Outlook 账户") + + # IMAP 连接限制(防止限流) + self._imap_semaphore = threading.Semaphore(5) + + def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]: + """ + 选择可用的 Outlook 账户 + + Args: + config: 配置参数(目前未使用) + + Returns: + 包含邮箱信息的字典: + - email: 邮箱地址 + - service_id: 账户邮箱(同 email) + - account: 账户信息 + """ + if not self.accounts: + self.update_status(False, EmailServiceError("没有可用的 Outlook 账户")) + raise EmailServiceError("没有可用的 Outlook 账户") + + # 轮询选择账户 + with threading.Lock(): + account = self.accounts[self._current_account_index] + self._current_account_index = (self._current_account_index + 1) % len(self.accounts) + + email_info = { + "email": account.email, + "service_id": account.email, # 对于 Outlook,service_id 就是邮箱地址 + "account": { + "email": account.email, + "has_oauth": account.has_oauth() + } + } + + logger.info(f"选择 Outlook 账户: {account.email}") + self.update_status(True) + return email_info + + def get_verification_code( + self, + email: str, + email_id: str = None, + timeout: int = 120, + pattern: str = OTP_CODE_PATTERN + ) -> Optional[str]: + """ + 从 Outlook 邮箱获取验证码 + + Args: + email: 邮箱地址 + email_id: 未使用(对于 Outlook,email 就是标识) + timeout: 超时时间(秒) + pattern: 验证码正则表达式 + + Returns: + 验证码字符串,如果超时或未找到返回 None + """ + # 查找对应的账户 + account = None + for acc in self.accounts: + if acc.email.lower() == email.lower(): + account = acc + break + + if not account: + self.update_status(False, EmailServiceError(f"未找到邮箱对应的账户: {email}")) + return None + + logger.info(f"正在从 Outlook 邮箱 {email} 获取验证码...") + + start_time = time.time() + last_check_time = 0 + check_count = 0 + + while time.time() - start_time < timeout: + check_count += 1 + + # 控制检查频率 + if time.time() - last_check_time < 3: + time.sleep(1) + continue + + try: + with self._imap_semaphore: + with OutlookIMAPClient( + account, + host=self.config["imap_host"], + port=self.config["imap_port"], + timeout=10 + ) as client: + emails = client.get_recent_emails(count=10, only_unseen=True) + + for mail in emails: + # 检查是否是 OpenAI 相关邮件 + if not self._is_oai_mail(mail): + continue + + # 提取验证码 + content = f"{mail.get('from', '')} {mail.get('subject', '')} {mail.get('body', '')}" + match = re.search(pattern, content) + if match: + code = match.group(1) + logger.info(f"从 Outlook 邮箱 {email} 找到验证码: {code}") + + # 可选:标记邮件为已读(避免重复获取) + # 注意:这需要修改 IMAP 客户端的实现 + + self.update_status(True) + return code + + last_check_time = time.time() + + if check_count % 5 == 0: + logger.debug(f"检查 {email} 的验证码,已检查 {check_count} 次") + + except Exception as e: + logger.warning(f"检查 Outlook 邮箱 {email} 时出错: {e}") + last_check_time = time.time() + + time.sleep(3) + + logger.warning(f"等待验证码超时: {email}") + return None + + def list_emails(self, **kwargs) -> List[Dict[str, Any]]: + """ + 列出所有可用的 Outlook 账户 + + Returns: + 账户列表 + """ + return [ + { + "email": account.email, + "id": account.email, + "has_oauth": account.has_oauth(), + "type": "outlook" + } + for account in self.accounts + ] + + def delete_email(self, email_id: str) -> bool: + """ + 删除邮箱(对于 Outlook,不支持删除账户) + + Args: + email_id: 邮箱地址 + + Returns: + False(Outlook 不支持删除账户) + """ + logger.warning(f"Outlook 服务不支持删除账户: {email_id}") + return False + + def check_health(self) -> bool: + """检查 Outlook 服务是否可用""" + if not self.accounts: + self.update_status(False, EmailServiceError("没有配置的账户")) + return False + + # 测试第一个账户的连接 + test_account = self.accounts[0] + try: + with self._imap_semaphore: + with OutlookIMAPClient( + test_account, + host=self.config["imap_host"], + port=self.config["imap_port"], + timeout=10 + ) as client: + # 尝试列出邮箱(快速测试) + client._conn.select("INBOX", readonly=True) + self.update_status(True) + return True + except Exception as e: + logger.warning(f"Outlook 健康检查失败 ({test_account.email}): {e}") + self.update_status(False, e) + return False + + def _is_oai_mail(self, mail: Dict[str, Any]) -> bool: + """判断是否为 OpenAI 相关邮件""" + combined = f"{mail.get('from', '')} {mail.get('subject', '')} {mail.get('body', '')}".lower() + keywords = ["openai", "chatgpt", "verification", "验证码", "code"] + return any(keyword in combined for keyword in keywords) + + def get_account_stats(self) -> Dict[str, Any]: + """获取账户统计信息""" + total = len(self.accounts) + oauth_count = sum(1 for acc in self.accounts if acc.has_oauth()) + + return { + "total_accounts": total, + "oauth_accounts": oauth_count, + "password_accounts": total - oauth_count, + "accounts": [ + { + "email": acc.email, + "has_oauth": acc.has_oauth() + } + for acc in self.accounts + ] + } + + def add_account(self, account_config: Dict[str, Any]) -> bool: + """添加新的 Outlook 账户""" + try: + account = OutlookAccount.from_config(account_config) + if not account.validate(): + return False + + self.accounts.append(account) + self._account_locks[account.email] = threading.Lock() + logger.info(f"添加 Outlook 账户: {account.email}") + return True + except Exception as e: + logger.error(f"添加 Outlook 账户失败: {e}") + return False + + def remove_account(self, email: str) -> bool: + """移除 Outlook 账户""" + for i, acc in enumerate(self.accounts): + if acc.email.lower() == email.lower(): + self.accounts.pop(i) + self._account_locks.pop(email, None) + logger.info(f"移除 Outlook 账户: {email}") + return True + return False \ No newline at end of file diff --git a/src/services/tempmail.py b/src/services/tempmail.py new file mode 100644 index 0000000..b117e95 --- /dev/null +++ b/src/services/tempmail.py @@ -0,0 +1,398 @@ +""" +Tempmail.lol 邮箱服务实现 +""" + +import re +import time +import logging +from typing import Optional, Dict, Any, List +import json + +from curl_cffi import requests as cffi_requests + +from .base import BaseEmailService, EmailServiceError, EmailServiceType +from ..core.http_client import HTTPClient, RequestConfig +from ..config.constants import OTP_CODE_PATTERN + + +logger = logging.getLogger(__name__) + + +class TempmailService(BaseEmailService): + """ + Tempmail.lol 邮箱服务 + 基于 Tempmail.lol API v2 + """ + + def __init__(self, config: Dict[str, Any] = None, name: str = None): + """ + 初始化 Tempmail 服务 + + Args: + config: 配置字典,支持以下键: + - base_url: API 基础地址 (默认: https://api.tempmail.lol/v2) + - timeout: 请求超时时间 (默认: 30) + - max_retries: 最大重试次数 (默认: 3) + - proxy_url: 代理 URL + name: 服务名称 + """ + super().__init__(EmailServiceType.TEMPMAIL, name) + + # 默认配置 + default_config = { + "base_url": "https://api.tempmail.lol/v2", + "timeout": 30, + "max_retries": 3, + "proxy_url": None, + } + + self.config = {**default_config, **(config or {})} + + # 创建 HTTP 客户端 + http_config = RequestConfig( + timeout=self.config["timeout"], + max_retries=self.config["max_retries"], + ) + self.http_client = HTTPClient( + proxy_url=self.config.get("proxy_url"), + config=http_config + ) + + # 状态变量 + self._email_cache: Dict[str, Dict[str, Any]] = {} + self._last_check_time: float = 0 + + def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]: + """ + 创建新的临时邮箱 + + Args: + config: 配置参数(Tempmail.lol 目前不支持自定义配置) + + Returns: + 包含邮箱信息的字典: + - email: 邮箱地址 + - service_id: 邮箱 token + - token: 邮箱 token(同 service_id) + - created_at: 创建时间戳 + """ + try: + # 发送创建请求 + response = self.http_client.post( + f"{self.config['base_url']}/inbox/create", + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + }, + json={} + ) + + if response.status_code not in (200, 201): + self.update_status(False, EmailServiceError(f"请求失败,状态码: {response.status_code}")) + raise EmailServiceError(f"Tempmail.lol 请求失败,状态码: {response.status_code}") + + data = response.json() + email = str(data.get("address", "")).strip() + token = str(data.get("token", "")).strip() + + if not email or not token: + self.update_status(False, EmailServiceError("返回数据不完整")) + raise EmailServiceError("Tempmail.lol 返回数据不完整") + + # 缓存邮箱信息 + email_info = { + "email": email, + "service_id": token, + "token": token, + "created_at": time.time(), + } + self._email_cache[email] = email_info + + logger.info(f"成功创建 Tempmail.lol 邮箱: {email}") + self.update_status(True) + return email_info + + except Exception as e: + self.update_status(False, e) + if isinstance(e, EmailServiceError): + raise + raise EmailServiceError(f"创建 Tempmail.lol 邮箱失败: {e}") + + def get_verification_code( + self, + email: str, + email_id: str = None, + timeout: int = 120, + pattern: str = OTP_CODE_PATTERN + ) -> Optional[str]: + """ + 从 Tempmail.lol 获取验证码 + + Args: + email: 邮箱地址 + email_id: 邮箱 token(如果不提供,从缓存中查找) + timeout: 超时时间(秒) + pattern: 验证码正则表达式 + + Returns: + 验证码字符串,如果超时或未找到返回 None + """ + token = email_id + if not token: + # 从缓存中查找 token + if email in self._email_cache: + token = self._email_cache[email].get("token") + else: + logger.warning(f"未找到邮箱 {email} 的 token,无法获取验证码") + return None + + if not token: + logger.warning(f"邮箱 {email} 没有 token,无法获取验证码") + return None + + logger.info(f"正在等待邮箱 {email} 的验证码...") + + start_time = time.time() + seen_ids = set() + + while time.time() - start_time < timeout: + try: + # 获取邮件列表 + response = self.http_client.get( + f"{self.config['base_url']}/inbox", + params={"token": token}, + headers={"Accept": "application/json"} + ) + + if response.status_code != 200: + time.sleep(3) + continue + + data = response.json() + + # 检查 inbox 是否过期 + if data is None or (isinstance(data, dict) and not data): + logger.warning(f"邮箱 {email} 已过期") + return None + + email_list = data.get("emails", []) if isinstance(data, dict) else [] + + if not isinstance(email_list, list): + time.sleep(3) + continue + + for msg in email_list: + if not isinstance(msg, dict): + continue + + # 使用 date 作为唯一标识 + msg_date = msg.get("date", 0) + if not msg_date or msg_date in seen_ids: + continue + seen_ids.add(msg_date) + + sender = str(msg.get("from", "")).lower() + subject = str(msg.get("subject", "")) + body = str(msg.get("body", "")) + html = str(msg.get("html") or "") + + content = "\n".join([sender, subject, body, html]) + + # 检查是否是 OpenAI 邮件 + if "openai" not in sender and "openai" not in content.lower(): + continue + + # 提取验证码 + match = re.search(pattern, content) + if match: + code = match.group(1) + logger.info(f"找到验证码: {code}") + self.update_status(True) + return code + + except Exception as e: + logger.debug(f"检查邮件时出错: {e}") + + # 等待一段时间再检查 + time.sleep(3) + + logger.warning(f"等待验证码超时: {email}") + return None + + def list_emails(self, **kwargs) -> List[Dict[str, Any]]: + """ + 列出所有缓存的邮箱 + + Note: + Tempmail.lol API 不支持列出所有邮箱,这里返回缓存的邮箱 + """ + return list(self._email_cache.values()) + + def delete_email(self, email_id: str) -> bool: + """ + 删除邮箱 + + Note: + Tempmail.lol API 不支持删除邮箱,这里从缓存中移除 + """ + # 从缓存中查找并移除 + emails_to_delete = [] + for email, info in self._email_cache.items(): + if info.get("token") == email_id: + emails_to_delete.append(email) + + for email in emails_to_delete: + del self._email_cache[email] + logger.info(f"从缓存中移除邮箱: {email}") + + return len(emails_to_delete) > 0 + + def check_health(self) -> bool: + """检查 Tempmail.lol 服务是否可用""" + try: + response = self.http_client.get( + f"{self.config['base_url']}/inbox/create", + timeout=10 + ) + # 即使返回错误状态码也认为服务可用(只要可以连接) + self.update_status(True) + return True + except Exception as e: + logger.warning(f"Tempmail.lol 健康检查失败: {e}") + self.update_status(False, e) + return False + + def get_inbox(self, token: str) -> Optional[Dict[str, Any]]: + """ + 获取邮箱收件箱内容 + + Args: + token: 邮箱 token + + Returns: + 收件箱数据 + """ + try: + response = self.http_client.get( + f"{self.config['base_url']}/inbox", + params={"token": token}, + headers={"Accept": "application/json"} + ) + + if response.status_code != 200: + return None + + return response.json() + except Exception as e: + logger.error(f"获取收件箱失败: {e}") + return None + + def wait_for_verification_code_with_callback( + self, + email: str, + token: str, + callback: callable = None, + timeout: int = 120 + ) -> Optional[str]: + """ + 等待验证码并支持回调函数 + + Args: + email: 邮箱地址 + token: 邮箱 token + callback: 回调函数,接收当前状态信息 + timeout: 超时时间 + + Returns: + 验证码或 None + """ + start_time = time.time() + seen_ids = set() + check_count = 0 + + while time.time() - start_time < timeout: + check_count += 1 + + if callback: + callback({ + "status": "checking", + "email": email, + "check_count": check_count, + "elapsed_time": time.time() - start_time, + }) + + try: + data = self.get_inbox(token) + if not data: + time.sleep(3) + continue + + # 检查 inbox 是否过期 + if data is None or (isinstance(data, dict) and not data): + if callback: + callback({ + "status": "expired", + "email": email, + "message": "邮箱已过期" + }) + return None + + email_list = data.get("emails", []) if isinstance(data, dict) else [] + + for msg in email_list: + msg_date = msg.get("date", 0) + if not msg_date or msg_date in seen_ids: + continue + seen_ids.add(msg_date) + + sender = str(msg.get("from", "")).lower() + subject = str(msg.get("subject", "")) + body = str(msg.get("body", "")) + html = str(msg.get("html") or "") + + content = "\n".join([sender, subject, body, html]) + + # 检查是否是 OpenAI 邮件 + if "openai" not in sender and "openai" not in content.lower(): + continue + + # 提取验证码 + match = re.search(OTP_CODE_PATTERN, content) + if match: + code = match.group(1) + if callback: + callback({ + "status": "found", + "email": email, + "code": code, + "message": "找到验证码" + }) + return code + + if callback and check_count % 5 == 0: + callback({ + "status": "waiting", + "email": email, + "check_count": check_count, + "message": f"已检查 {len(seen_ids)} 封邮件,等待验证码..." + }) + + except Exception as e: + logger.debug(f"检查邮件时出错: {e}") + if callback: + callback({ + "status": "error", + "email": email, + "error": str(e), + "message": "检查邮件时出错" + }) + + time.sleep(3) + + if callback: + callback({ + "status": "timeout", + "email": email, + "message": "等待验证码超时" + }) + return None \ No newline at end of file diff --git a/src/web/__init__.py b/src/web/__init__.py new file mode 100644 index 0000000..f722b0b --- /dev/null +++ b/src/web/__init__.py @@ -0,0 +1,7 @@ +""" +Web UI 应用模块 +""" + +from .app import app, create_app + +__all__ = ['app', 'create_app'] diff --git a/src/web/app.py b/src/web/app.py new file mode 100644 index 0000000..fb5056e --- /dev/null +++ b/src/web/app.py @@ -0,0 +1,104 @@ +""" +FastAPI 应用主文件 +轻量级 Web UI,支持注册、账号管理、设置 +""" + +import logging +from pathlib import Path +from typing import Optional + +from fastapi import FastAPI, Request +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import HTMLResponse + +from ..config.settings import get_settings +from .routes import api_router + +logger = logging.getLogger(__name__) + +# 获取项目根目录 +PROJECT_ROOT = Path(__file__).parent.parent.parent + +# 静态文件和模板目录 +STATIC_DIR = PROJECT_ROOT / "static" +TEMPLATES_DIR = PROJECT_ROOT / "templates" + + +def create_app() -> FastAPI: + """创建 FastAPI 应用实例""" + settings = get_settings() + + 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, + ) + + # CORS 中间件 + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # 挂载静态文件 + if STATIC_DIR.exists(): + app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") + logger.info(f"静态文件目录: {STATIC_DIR}") + else: + # 创建静态目录 + STATIC_DIR.mkdir(parents=True, exist_ok=True) + app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") + logger.info(f"创建静态文件目录: {STATIC_DIR}") + + # 创建模板目录 + if not TEMPLATES_DIR.exists(): + TEMPLATES_DIR.mkdir(parents=True, exist_ok=True) + logger.info(f"创建模板目录: {TEMPLATES_DIR}") + + # 注册 API 路由 + app.include_router(api_router, prefix="/api") + + # 模板引擎 + templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) + + @app.get("/", response_class=HTMLResponse) + async def index(request: Request): + """首页 - 注册页面""" + return templates.TemplateResponse("index.html", {"request": request}) + + @app.get("/accounts", response_class=HTMLResponse) + async def accounts_page(request: Request): + """账号管理页面""" + return templates.TemplateResponse("accounts.html", {"request": request}) + + @app.get("/settings", response_class=HTMLResponse) + async def settings_page(request: Request): + """设置页面""" + return templates.TemplateResponse("settings.html", {"request": request}) + + @app.on_event("startup") + async def startup_event(): + """应用启动事件""" + 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(): + """应用关闭事件""" + logger.info("应用关闭") + + return app + + +# 创建全局应用实例 +app = create_app() diff --git a/src/web/routes/__init__.py b/src/web/routes/__init__.py new file mode 100644 index 0000000..0d80c60 --- /dev/null +++ b/src/web/routes/__init__.py @@ -0,0 +1,18 @@ +""" +API 路由模块 +""" + +from fastapi import APIRouter + +from .accounts import router as accounts_router +from .registration import router as registration_router +from .settings import router as settings_router +from .email_services import router as email_services_router + +api_router = APIRouter() + +# 注册各模块路由 +api_router.include_router(accounts_router, prefix="/accounts", tags=["accounts"]) +api_router.include_router(registration_router, prefix="/registration", tags=["registration"]) +api_router.include_router(settings_router, prefix="/settings", tags=["settings"]) +api_router.include_router(email_services_router, prefix="/email-services", tags=["email-services"]) diff --git a/src/web/routes/accounts.py b/src/web/routes/accounts.py new file mode 100644 index 0000000..d192827 --- /dev/null +++ b/src/web/routes/accounts.py @@ -0,0 +1,369 @@ +""" +账号管理 API 路由 +""" + +import json +import logging +from typing import List, Optional +from datetime import datetime + +from fastapi import APIRouter, HTTPException, Query, BackgroundTasks +from fastapi.responses import StreamingResponse +from pydantic import BaseModel + +from ...database import crud +from ...database.session import get_db +from ...database.models import Account +from ...config.constants import AccountStatus + +logger = logging.getLogger(__name__) +router = APIRouter() + + +# ============== Pydantic Models ============== + +class AccountResponse(BaseModel): + """账号响应模型""" + id: int + email: str + email_service: str + account_id: Optional[str] = None + workspace_id: Optional[str] = None + registered_at: Optional[str] = None + status: str + proxy_used: Optional[str] = None + created_at: Optional[str] = None + updated_at: Optional[str] = None + + class Config: + from_attributes = True + + +class AccountListResponse(BaseModel): + """账号列表响应""" + total: int + accounts: List[AccountResponse] + + +class AccountUpdateRequest(BaseModel): + """账号更新请求""" + status: Optional[str] = None + metadata: Optional[dict] = None + + +class BatchDeleteRequest(BaseModel): + """批量删除请求""" + ids: List[int] + + +class BatchUpdateRequest(BaseModel): + """批量更新请求""" + ids: List[int] + status: str + + +# ============== Helper Functions ============== + +def account_to_response(account: Account) -> AccountResponse: + """转换 Account 模型为响应模型""" + return AccountResponse( + id=account.id, + email=account.email, + email_service=account.email_service, + account_id=account.account_id, + workspace_id=account.workspace_id, + registered_at=account.registered_at.isoformat() if account.registered_at else None, + status=account.status, + proxy_used=account.proxy_used, + created_at=account.created_at.isoformat() if account.created_at else None, + updated_at=account.updated_at.isoformat() if account.updated_at else None, + ) + + +# ============== API Endpoints ============== + +@router.get("", response_model=AccountListResponse) +async def list_accounts( + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(20, ge=1, le=100, description="每页数量"), + status: Optional[str] = Query(None, description="状态筛选"), + email_service: Optional[str] = Query(None, description="邮箱服务筛选"), + search: Optional[str] = Query(None, description="搜索关键词"), +): + """ + 获取账号列表 + + 支持分页、状态筛选、邮箱服务筛选和搜索 + """ + with get_db() as db: + # 构建查询 + query = db.query(Account) + + # 状态筛选 + if status: + query = query.filter(Account.status == status) + + # 邮箱服务筛选 + if email_service: + query = query.filter(Account.email_service == email_service) + + # 搜索 + if search: + search_pattern = f"%{search}%" + query = query.filter( + (Account.email.ilike(search_pattern)) | + (Account.account_id.ilike(search_pattern)) + ) + + # 统计总数 + total = query.count() + + # 分页 + offset = (page - 1) * page_size + accounts = query.order_by(Account.created_at.desc()).offset(offset).limit(page_size).all() + + return AccountListResponse( + total=total, + accounts=[account_to_response(acc) for acc in accounts] + ) + + +@router.get("/{account_id}", response_model=AccountResponse) +async def get_account(account_id: int): + """获取单个账号详情""" + with get_db() as db: + account = crud.get_account_by_id(db, account_id) + if not account: + raise HTTPException(status_code=404, detail="账号不存在") + return account_to_response(account) + + +@router.get("/{account_id}/tokens") +async def get_account_tokens(account_id: int): + """获取账号的 Token 信息""" + with get_db() as db: + account = crud.get_account_by_id(db, account_id) + if not account: + raise HTTPException(status_code=404, detail="账号不存在") + + return { + "id": account.id, + "email": account.email, + "access_token": account.access_token[:50] + "..." if account.access_token else None, + "refresh_token": account.refresh_token[:50] + "..." if account.refresh_token else None, + "id_token": account.id_token[:50] + "..." if account.id_token else None, + "has_tokens": bool(account.access_token and account.refresh_token), + } + + +@router.patch("/{account_id}", response_model=AccountResponse) +async def update_account(account_id: int, request: AccountUpdateRequest): + """更新账号状态""" + with get_db() as db: + account = crud.get_account_by_id(db, account_id) + if not account: + raise HTTPException(status_code=404, detail="账号不存在") + + update_data = {} + if request.status: + if request.status not in [e.value for e in AccountStatus]: + raise HTTPException(status_code=400, detail="无效的状态值") + update_data["status"] = request.status + + if request.metadata: + current_metadata = account.metadata or {} + current_metadata.update(request.metadata) + update_data["metadata"] = current_metadata + + account = crud.update_account(db, account_id, **update_data) + return account_to_response(account) + + +@router.delete("/{account_id}") +async def delete_account(account_id: int): + """删除单个账号""" + with get_db() as db: + account = crud.get_account_by_id(db, account_id) + if not account: + raise HTTPException(status_code=404, detail="账号不存在") + + crud.delete_account(db, account_id) + return {"success": True, "message": f"账号 {account.email} 已删除"} + + +@router.post("/batch-delete") +async def batch_delete_accounts(request: BatchDeleteRequest): + """批量删除账号""" + with get_db() as db: + deleted_count = 0 + errors = [] + + for account_id in request.ids: + try: + account = crud.get_account_by_id(db, account_id) + if account: + crud.delete_account(db, account_id) + deleted_count += 1 + except Exception as e: + errors.append(f"ID {account_id}: {str(e)}") + + return { + "success": True, + "deleted_count": deleted_count, + "errors": errors if errors else None + } + + +@router.post("/batch-update") +async def batch_update_accounts(request: BatchUpdateRequest): + """批量更新账号状态""" + if request.status not in [e.value for e in AccountStatus]: + raise HTTPException(status_code=400, detail="无效的状态值") + + with get_db() as db: + updated_count = 0 + errors = [] + + for account_id in request.ids: + try: + account = crud.get_account_by_id(db, account_id) + if account: + crud.update_account(db, account_id, status=request.status) + updated_count += 1 + except Exception as e: + errors.append(f"ID {account_id}: {str(e)}") + + return { + "success": True, + "updated_count": updated_count, + "errors": errors if errors else None + } + + +@router.get("/export/json") +async def export_accounts_json( + status: Optional[str] = Query(None, description="状态筛选"), + email_service: Optional[str] = Query(None, description="邮箱服务筛选"), +): + """导出账号为 JSON 格式""" + with get_db() as db: + query = db.query(Account) + + if status: + query = query.filter(Account.status == status) + if email_service: + query = query.filter(Account.email_service == email_service) + + accounts = query.all() + + export_data = [] + for acc in accounts: + export_data.append({ + "email": acc.email, + "account_id": acc.account_id, + "workspace_id": acc.workspace_id, + "access_token": acc.access_token, + "refresh_token": acc.refresh_token, + "id_token": acc.id_token, + "email_service": acc.email_service, + "registered_at": acc.registered_at.isoformat() if acc.registered_at else None, + "status": acc.status, + }) + + # 生成文件名 + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"accounts_{timestamp}.json" + + # 返回 JSON 响应 + import io + content = json.dumps(export_data, ensure_ascii=False, indent=2) + + return StreamingResponse( + iter([content]), + media_type="application/json", + headers={"Content-Disposition": f"attachment; filename={filename}"} + ) + + +@router.get("/export/csv") +async def export_accounts_csv( + status: Optional[str] = Query(None, description="状态筛选"), + email_service: Optional[str] = Query(None, description="邮箱服务筛选"), +): + """导出账号为 CSV 格式""" + import csv + import io + + with get_db() as db: + query = db.query(Account) + + if status: + query = query.filter(Account.status == status) + if email_service: + query = query.filter(Account.email_service == email_service) + + accounts = query.all() + + # 创建 CSV 内容 + output = io.StringIO() + writer = csv.writer(output) + + # 写入表头 + writer.writerow([ + "ID", "Email", "Account ID", "Workspace ID", + "Access Token", "Refresh Token", "ID Token", + "Email Service", "Status", "Registered At" + ]) + + # 写入数据 + for acc in accounts: + writer.writerow([ + acc.id, + acc.email, + acc.account_id or "", + acc.workspace_id or "", + acc.access_token or "", + acc.refresh_token or "", + acc.id_token or "", + acc.email_service, + acc.status, + acc.registered_at.isoformat() if acc.registered_at else "" + ]) + + # 生成文件名 + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"accounts_{timestamp}.csv" + + return StreamingResponse( + iter([output.getvalue()]), + media_type="text/csv", + headers={"Content-Disposition": f"attachment; filename={filename}"} + ) + + +@router.get("/stats/summary") +async def get_accounts_stats(): + """获取账号统计信息""" + with get_db() as db: + from sqlalchemy import func + + # 总数 + total = db.query(func.count(Account.id)).scalar() + + # 按状态统计 + status_stats = db.query( + Account.status, + func.count(Account.id) + ).group_by(Account.status).all() + + # 按邮箱服务统计 + service_stats = db.query( + Account.email_service, + func.count(Account.id) + ).group_by(Account.email_service).all() + + return { + "total": total, + "by_status": {status: count for status, count in status_stats}, + "by_email_service": {service: count for service, count in service_stats} + } diff --git a/src/web/routes/email_services.py b/src/web/routes/email_services.py new file mode 100644 index 0000000..d8454ed --- /dev/null +++ b/src/web/routes/email_services.py @@ -0,0 +1,437 @@ +""" +邮箱服务配置 API 路由 +""" + +import logging +from typing import List, Optional, Dict, Any + +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel + +from ...database import crud +from ...database.session import get_db +from ...database.models import EmailService as EmailServiceModel +from ...services import EmailServiceFactory, EmailServiceType + +logger = logging.getLogger(__name__) +router = APIRouter() + + +# ============== Pydantic Models ============== + +class EmailServiceCreate(BaseModel): + """创建邮箱服务请求""" + service_type: str + name: str + config: Dict[str, Any] + enabled: bool = True + priority: int = 0 + + +class EmailServiceUpdate(BaseModel): + """更新邮箱服务请求""" + name: Optional[str] = None + config: Optional[Dict[str, Any]] = None + enabled: Optional[bool] = None + priority: Optional[int] = None + + +class EmailServiceResponse(BaseModel): + """邮箱服务响应""" + id: int + service_type: str + name: str + enabled: bool + priority: int + last_used: Optional[str] = None + created_at: Optional[str] = None + updated_at: Optional[str] = None + + class Config: + from_attributes = True + + +class EmailServiceListResponse(BaseModel): + """邮箱服务列表响应""" + total: int + services: List[EmailServiceResponse] + + +class ServiceTestResult(BaseModel): + """服务测试结果""" + success: bool + message: str + details: Optional[Dict[str, Any]] = None + + +class OutlookBatchImportRequest(BaseModel): + """Outlook 批量导入请求""" + data: str # 多行数据,每行格式: 邮箱----密码 或 邮箱----密码----client_id----refresh_token + enabled: bool = True + priority: int = 0 + + +class OutlookBatchImportResponse(BaseModel): + """Outlook 批量导入响应""" + total: int + success: int + failed: int + accounts: List[Dict[str, Any]] + errors: List[str] + + +# ============== Helper Functions ============== + +def service_to_response(service: EmailServiceModel) -> EmailServiceResponse: + """转换服务模型为响应""" + return EmailServiceResponse( + id=service.id, + service_type=service.service_type, + name=service.name, + enabled=service.enabled, + priority=service.priority, + last_used=service.last_used.isoformat() if service.last_used else None, + created_at=service.created_at.isoformat() if service.created_at else None, + updated_at=service.updated_at.isoformat() if service.updated_at else None, + ) + + +# ============== API Endpoints ============== + +@router.get("/types") +async def get_service_types(): + """获取支持的邮箱服务类型""" + return { + "types": [ + { + "value": "tempmail", + "label": "Tempmail.lol", + "description": "临时邮箱服务,无需配置", + "config_fields": [ + {"name": "base_url", "label": "API 地址", "default": "https://api.tempmail.lol/v2", "required": False}, + {"name": "timeout", "label": "超时时间", "default": 30, "required": False}, + ] + }, + { + "value": "outlook", + "label": "Outlook", + "description": "Outlook 邮箱,需要配置账户信息", + "config_fields": [ + {"name": "email", "label": "邮箱地址", "required": True}, + {"name": "password", "label": "密码", "required": True}, + {"name": "client_id", "label": "OAuth Client ID", "required": False}, + {"name": "refresh_token", "label": "OAuth Refresh Token", "required": False}, + ] + }, + { + "value": "custom_domain", + "label": "自定义域名", + "description": "自定义域名邮箱服务", + "config_fields": [ + {"name": "base_url", "label": "API 地址", "required": True}, + {"name": "api_key", "label": "API Key", "required": True}, + {"name": "default_domain", "label": "默认域名", "required": False}, + ] + } + ] + } + + +@router.get("", response_model=EmailServiceListResponse) +async def list_email_services( + service_type: Optional[str] = Query(None, description="服务类型筛选"), + enabled_only: bool = Query(False, description="只显示启用的服务"), +): + """获取邮箱服务列表""" + with get_db() as db: + query = db.query(EmailServiceModel) + + if service_type: + query = query.filter(EmailServiceModel.service_type == service_type) + + if enabled_only: + query = query.filter(EmailServiceModel.enabled == True) + + services = query.order_by(EmailServiceModel.priority.asc(), EmailServiceModel.id.asc()).all() + + return EmailServiceListResponse( + total=len(services), + services=[service_to_response(s) for s in services] + ) + + +@router.get("/{service_id}", response_model=EmailServiceResponse) +async def get_email_service(service_id: int): + """获取单个邮箱服务详情""" + with get_db() as db: + service = db.query(EmailServiceModel).filter(EmailServiceModel.id == service_id).first() + if not service: + raise HTTPException(status_code=404, detail="服务不存在") + return service_to_response(service) + + +@router.post("", response_model=EmailServiceResponse) +async def create_email_service(request: EmailServiceCreate): + """创建邮箱服务配置""" + # 验证服务类型 + try: + EmailServiceType(request.service_type) + except ValueError: + raise HTTPException(status_code=400, detail=f"无效的服务类型: {request.service_type}") + + with get_db() as db: + # 检查名称是否重复 + existing = db.query(EmailServiceModel).filter(EmailServiceModel.name == request.name).first() + if existing: + raise HTTPException(status_code=400, detail="服务名称已存在") + + service = EmailServiceModel( + service_type=request.service_type, + name=request.name, + config=request.config, + enabled=request.enabled, + priority=request.priority + ) + db.add(service) + db.commit() + db.refresh(service) + + return service_to_response(service) + + +@router.patch("/{service_id}", response_model=EmailServiceResponse) +async def update_email_service(service_id: int, request: EmailServiceUpdate): + """更新邮箱服务配置""" + with get_db() as db: + service = db.query(EmailServiceModel).filter(EmailServiceModel.id == service_id).first() + if not service: + raise HTTPException(status_code=404, detail="服务不存在") + + update_data = {} + if request.name is not None: + update_data["name"] = request.name + if request.config is not None: + update_data["config"] = request.config + if request.enabled is not None: + update_data["enabled"] = request.enabled + if request.priority is not None: + update_data["priority"] = request.priority + + for key, value in update_data.items(): + setattr(service, key, value) + + db.commit() + db.refresh(service) + + return service_to_response(service) + + +@router.delete("/{service_id}") +async def delete_email_service(service_id: int): + """删除邮箱服务配置""" + with get_db() as db: + service = db.query(EmailServiceModel).filter(EmailServiceModel.id == service_id).first() + if not service: + raise HTTPException(status_code=404, detail="服务不存在") + + db.delete(service) + db.commit() + + return {"success": True, "message": f"服务 {service.name} 已删除"} + + +@router.post("/{service_id}/test", response_model=ServiceTestResult) +async def test_email_service(service_id: int): + """测试邮箱服务是否可用""" + with get_db() as db: + service = db.query(EmailServiceModel).filter(EmailServiceModel.id == service_id).first() + if not service: + raise HTTPException(status_code=404, detail="服务不存在") + + try: + service_type = EmailServiceType(service.service_type) + email_service = EmailServiceFactory.create(service_type, service.config, name=service.name) + + health = email_service.check_health() + + if health: + return ServiceTestResult( + success=True, + message="服务连接正常", + details=email_service.get_service_info() if hasattr(email_service, 'get_service_info') else None + ) + else: + return ServiceTestResult( + success=False, + message="服务连接失败" + ) + + except Exception as e: + logger.error(f"测试邮箱服务失败: {e}") + return ServiceTestResult( + success=False, + message=f"测试失败: {str(e)}" + ) + + +@router.post("/{service_id}/enable") +async def enable_email_service(service_id: int): + """启用邮箱服务""" + with get_db() as db: + service = db.query(EmailServiceModel).filter(EmailServiceModel.id == service_id).first() + if not service: + raise HTTPException(status_code=404, detail="服务不存在") + + service.enabled = True + db.commit() + + return {"success": True, "message": f"服务 {service.name} 已启用"} + + +@router.post("/{service_id}/disable") +async def disable_email_service(service_id: int): + """禁用邮箱服务""" + with get_db() as db: + service = db.query(EmailServiceModel).filter(EmailServiceModel.id == service_id).first() + if not service: + raise HTTPException(status_code=404, detail="服务不存在") + + service.enabled = False + db.commit() + + return {"success": True, "message": f"服务 {service.name} 已禁用"} + + +@router.post("/reorder") +async def reorder_services(service_ids: List[int]): + """重新排序邮箱服务优先级""" + with get_db() as db: + for index, service_id in enumerate(service_ids): + service = db.query(EmailServiceModel).filter(EmailServiceModel.id == service_id).first() + if service: + service.priority = index + + db.commit() + + return {"success": True, "message": "优先级已更新"} + + +@router.post("/outlook/batch-import", response_model=OutlookBatchImportResponse) +async def batch_import_outlook(request: OutlookBatchImportRequest): + """ + 批量导入 Outlook 邮箱账户 + + 支持两种格式: + - 格式一(密码认证):邮箱----密码 + - 格式二(XOAUTH2 认证):邮箱----密码----client_id----refresh_token + + 每行一个账户,使用四个连字符(----)分隔字段 + """ + lines = request.data.strip().split("\n") + total = len(lines) + success = 0 + failed = 0 + accounts = [] + errors = [] + + with get_db() as db: + for i, line in enumerate(lines): + line = line.strip() + + # 跳过空行和注释 + if not line or line.startswith("#"): + continue + + parts = line.split("----") + + # 验证格式 + if len(parts) < 2: + failed += 1 + errors.append(f"行 {i+1}: 格式错误,至少需要邮箱和密码") + continue + + email = parts[0].strip() + password = parts[1].strip() + + # 验证邮箱格式 + if "@" not in email: + failed += 1 + errors.append(f"行 {i+1}: 无效的邮箱地址: {email}") + continue + + # 检查是否已存在 + existing = db.query(EmailServiceModel).filter( + EmailServiceModel.service_type == "outlook", + EmailServiceModel.name == email + ).first() + + if existing: + failed += 1 + errors.append(f"行 {i+1}: 邮箱已存在: {email}") + continue + + # 构建配置 + config = { + "email": email, + "password": password + } + + # 检查是否有 OAuth 信息(格式二) + if len(parts) >= 4: + client_id = parts[2].strip() + refresh_token = parts[3].strip() + if client_id and refresh_token: + config["client_id"] = client_id + config["refresh_token"] = refresh_token + + # 创建服务记录 + try: + service = EmailServiceModel( + service_type="outlook", + name=email, + config=config, + enabled=request.enabled, + priority=request.priority + ) + db.add(service) + db.commit() + db.refresh(service) + + accounts.append({ + "id": service.id, + "email": email, + "has_oauth": bool(config.get("client_id")), + "name": email + }) + success += 1 + + except Exception as e: + failed += 1 + errors.append(f"行 {i+1}: 创建失败: {str(e)}") + db.rollback() + + return OutlookBatchImportResponse( + total=total, + success=success, + failed=failed, + accounts=accounts, + errors=errors + ) + + +@router.delete("/outlook/batch") +async def batch_delete_outlook(service_ids: List[int]): + """批量删除 Outlook 邮箱服务""" + deleted = 0 + with get_db() as db: + for service_id in service_ids: + service = db.query(EmailServiceModel).filter( + EmailServiceModel.id == service_id, + EmailServiceModel.service_type == "outlook" + ).first() + if service: + db.delete(service) + deleted += 1 + db.commit() + + return {"success": True, "deleted": deleted, "message": f"已删除 {deleted} 个服务"} diff --git a/src/web/routes/registration.py b/src/web/routes/registration.py new file mode 100644 index 0000000..d559a01 --- /dev/null +++ b/src/web/routes/registration.py @@ -0,0 +1,500 @@ +""" +注册任务 API 路由 +""" + +import asyncio +import logging +import uuid +import random +from datetime import datetime +from typing import List, Optional, Dict + +from fastapi import APIRouter, HTTPException, Query, BackgroundTasks +from pydantic import BaseModel, Field + +from ...database import crud +from ...database.session import get_db +from ...database.models import RegistrationTask +from ...core.register import RegistrationEngine, RegistrationResult +from ...services import EmailServiceFactory, EmailServiceType +from ...config.settings import get_settings + +logger = logging.getLogger(__name__) +router = APIRouter() + +# 任务存储(简单的内存存储,生产环境应使用 Redis) +running_tasks: dict = {} +# 批量任务存储 +batch_tasks: Dict[str, dict] = {} + + +# ============== Pydantic Models ============== + +class RegistrationTaskCreate(BaseModel): + """创建注册任务请求""" + email_service_type: str = "tempmail" + proxy: Optional[str] = None + email_service_config: Optional[dict] = None + + +class BatchRegistrationRequest(BaseModel): + """批量注册请求""" + count: int = 1 # 注册数量 + email_service_type: str = "tempmail" + proxy: Optional[str] = None + email_service_config: Optional[dict] = None + interval_min: int = 5 # 最小间隔秒数 + interval_max: int = 30 # 最大间隔秒数 + + +class BatchRegistrationResponse(BaseModel): + """批量注册响应""" + batch_id: str + count: int + tasks: List[RegistrationTaskResponse] + + +class RegistrationTaskResponse(BaseModel): + """注册任务响应""" + id: int + task_uuid: str + status: str + email_service_id: Optional[int] = None + proxy: Optional[str] = None + logs: Optional[str] = None + result: Optional[dict] = None + error_message: Optional[str] = None + created_at: Optional[str] = None + started_at: Optional[str] = None + completed_at: Optional[str] = None + + class Config: + from_attributes = True + + +class TaskListResponse(BaseModel): + """任务列表响应""" + total: int + tasks: List[RegistrationTaskResponse] + + +# ============== Helper Functions ============== + +def task_to_response(task: RegistrationTask) -> RegistrationTaskResponse: + """转换任务模型为响应""" + return RegistrationTaskResponse( + id=task.id, + task_uuid=task.task_uuid, + status=task.status, + email_service_id=task.email_service_id, + proxy=task.proxy, + logs=task.logs, + result=task.result, + error_message=task.error_message, + created_at=task.created_at.isoformat() if task.created_at else None, + started_at=task.started_at.isoformat() if task.started_at else None, + completed_at=task.completed_at.isoformat() if task.completed_at else None, + ) + + +async def run_registration_task(task_uuid: str, email_service_type: str, proxy: Optional[str], email_service_config: Optional[dict]): + """异步执行注册任务""" + with get_db() as db: + try: + # 更新任务状态为运行中 + task = crud.update_registration_task( + db, task_uuid, + status="running", + started_at=datetime.utcnow() + ) + + if not task: + logger.error(f"任务不存在: {task_uuid}") + return + + # 创建邮箱服务 + service_type = EmailServiceType(email_service_type) + settings = get_settings() + + if service_type == EmailServiceType.TEMPMAIL: + config = { + "base_url": settings.tempmail_base_url, + "timeout": settings.tempmail_timeout, + "max_retries": settings.tempmail_max_retries, + "proxy_url": proxy, + } + elif service_type == EmailServiceType.CUSTOM_DOMAIN: + config = { + "base_url": settings.custom_domain_base_url, + "api_key": settings.custom_domain_api_key.get_secret_value() if settings.custom_domain_api_key else "", + "proxy_url": proxy, + } + else: + config = email_service_config or {} + + email_service = EmailServiceFactory.create(service_type, config) + + # 创建注册引擎 + def log_callback(msg): + with get_db() as db_inner: + crud.append_task_log(db_inner, task_uuid, msg) + + engine = RegistrationEngine( + email_service=email_service, + proxy_url=proxy, + callback_logger=log_callback, + task_uuid=task_uuid + ) + + # 执行注册 + result = engine.run() + + if result.success: + # 保存到数据库 + engine.save_to_database(result) + + # 更新任务状态 + crud.update_registration_task( + db, task_uuid, + status="completed", + completed_at=datetime.utcnow(), + result=result.to_dict() + ) + + logger.info(f"注册任务完成: {task_uuid}, 邮箱: {result.email}") + else: + # 更新任务状态为失败 + crud.update_registration_task( + db, task_uuid, + status="failed", + completed_at=datetime.utcnow(), + error_message=result.error_message + ) + + logger.warning(f"注册任务失败: {task_uuid}, 原因: {result.error_message}") + + except Exception as e: + logger.error(f"注册任务异常: {task_uuid}, 错误: {e}") + + try: + with get_db() as db: + crud.update_registration_task( + db, task_uuid, + status="failed", + completed_at=datetime.utcnow(), + error_message=str(e) + ) + except: + pass + + +async def run_batch_registration( + batch_id: str, + task_uuids: List[str], + email_service_type: str, + proxy: Optional[str], + email_service_config: Optional[dict], + interval_min: int, + interval_max: int +): + """异步执行批量注册任务""" + batch_tasks[batch_id] = { + "total": len(task_uuids), + "completed": 0, + "success": 0, + "failed": 0, + "cancelled": False, + "task_uuids": task_uuids, + "current_index": 0 + } + + try: + for i, task_uuid in enumerate(task_uuids): + # 检查是否已取消 + if 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") + logger.info(f"批量任务 {batch_id} 已取消") + break + + batch_tasks[batch_id]["current_index"] = i + + # 运行单个注册任务 + await run_registration_task( + task_uuid, email_service_type, proxy, email_service_config + ) + + # 更新统计 + with get_db() as db: + task = crud.get_registration_task(db, task_uuid) + if task: + batch_tasks[batch_id]["completed"] += 1 + if task.status == "completed": + batch_tasks[batch_id]["success"] += 1 + elif task.status == "failed": + batch_tasks[batch_id]["failed"] += 1 + + # 如果不是最后一个任务,等待随机间隔 + if i < len(task_uuids) - 1 and not batch_tasks[batch_id]["cancelled"]: + wait_time = random.randint(interval_min, interval_max) + logger.info(f"批量任务 {batch_id}: 等待 {wait_time} 秒后继续下一个任务") + await asyncio.sleep(wait_time) + + logger.info(f"批量任务 {batch_id} 完成: 成功 {batch_tasks[batch_id]['success']}, 失败 {batch_tasks[batch_id]['failed']}") + + except Exception as e: + logger.error(f"批量任务 {batch_id} 异常: {e}") + finally: + batch_tasks[batch_id]["finished"] = True + + +# ============== API Endpoints ============== + +@router.post("/start", response_model=RegistrationTaskResponse) +async def start_registration( + request: RegistrationTaskCreate, + background_tasks: BackgroundTasks +): + """ + 启动注册任务 + + - email_service_type: 邮箱服务类型 (tempmail, outlook, custom_domain) + - proxy: 代理地址 + - email_service_config: 邮箱服务配置(outlook 需要提供账户信息) + """ + # 验证邮箱服务类型 + try: + EmailServiceType(request.email_service_type) + except ValueError: + raise HTTPException( + status_code=400, + detail=f"无效的邮箱服务类型: {request.email_service_type}" + ) + + # 创建任务 + task_uuid = str(uuid.uuid4()) + + with get_db() as db: + task = crud.create_registration_task( + db, + task_uuid=task_uuid, + proxy=request.proxy + ) + + # 在后台运行注册任务 + background_tasks.add_task( + run_registration_task, + task_uuid, + request.email_service_type, + request.proxy, + request.email_service_config + ) + + return task_to_response(task) + + +@router.post("/batch", response_model=BatchRegistrationResponse) +async def start_batch_registration( + request: BatchRegistrationRequest, + background_tasks: BackgroundTasks +): + """ + 启动批量注册任务 + + - count: 注册数量 (1-100) + - email_service_type: 邮箱服务类型 + - proxy: 代理地址 + - interval_min: 最小间隔秒数 + - interval_max: 最大间隔秒数 + """ + # 验证参数 + if request.count < 1 or request.count > 100: + raise HTTPException(status_code=400, detail="注册数量必须在 1-100 之间") + + try: + EmailServiceType(request.email_service_type) + except ValueError: + raise HTTPException( + status_code=400, + detail=f"无效的邮箱服务类型: {request.email_service_type}" + ) + + if request.interval_min < 0 or request.interval_max < request.interval_min: + raise HTTPException(status_code=400, detail="间隔时间参数无效") + + # 创建批量任务 + batch_id = str(uuid.uuid4()) + task_uuids = [] + + with get_db() as db: + for _ in range(request.count): + task_uuid = str(uuid.uuid4()) + task = crud.create_registration_task( + db, + task_uuid=task_uuid, + proxy=request.proxy + ) + task_uuids.append(task_uuid) + + # 获取所有任务 + with get_db() as db: + tasks = [crud.get_registration_task(db, uuid) for uuid in task_uuids] + + # 在后台运行批量注册 + background_tasks.add_task( + run_batch_registration, + batch_id, + task_uuids, + request.email_service_type, + request.proxy, + request.email_service_config, + request.interval_min, + request.interval_max + ) + + return BatchRegistrationResponse( + batch_id=batch_id, + count=request.count, + tasks=[task_to_response(t) for t in tasks if t] + ) + + +@router.get("/batch/{batch_id}") +async def get_batch_status(batch_id: str): + """获取批量任务状态""" + if batch_id not in batch_tasks: + raise HTTPException(status_code=404, detail="批量任务不存在") + + batch = batch_tasks[batch_id] + return { + "batch_id": 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']}" + } + + +@router.post("/batch/{batch_id}/cancel") +async def cancel_batch(batch_id: str): + """取消批量任务""" + if batch_id not in batch_tasks: + raise HTTPException(status_code=404, detail="批量任务不存在") + + batch = batch_tasks[batch_id] + if batch.get("finished"): + raise HTTPException(status_code=400, detail="批量任务已完成") + + batch["cancelled"] = True + return {"success": True, "message": "批量任务取消请求已提交"} + + +@router.get("/tasks", response_model=TaskListResponse) +async def list_tasks( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + status: Optional[str] = Query(None), +): + """获取任务列表""" + with get_db() as db: + query = db.query(RegistrationTask) + + if status: + query = query.filter(RegistrationTask.status == status) + + total = query.count() + offset = (page - 1) * page_size + tasks = query.order_by(RegistrationTask.created_at.desc()).offset(offset).limit(page_size).all() + + return TaskListResponse( + total=total, + tasks=[task_to_response(t) for t in tasks] + ) + + +@router.get("/tasks/{task_uuid}", response_model=RegistrationTaskResponse) +async def get_task(task_uuid: str): + """获取任务详情""" + with get_db() as db: + task = crud.get_registration_task(db, task_uuid) + if not task: + raise HTTPException(status_code=404, detail="任务不存在") + return task_to_response(task) + + +@router.get("/tasks/{task_uuid}/logs") +async def get_task_logs(task_uuid: str): + """获取任务日志""" + with get_db() as db: + task = crud.get_registration_task(db, task_uuid) + if not task: + raise HTTPException(status_code=404, detail="任务不存在") + + logs = task.logs or "" + return { + "task_uuid": task_uuid, + "status": task.status, + "logs": logs.split("\n") if logs else [] + } + + +@router.post("/tasks/{task_uuid}/cancel") +async def cancel_task(task_uuid: str): + """取消任务""" + with get_db() as db: + task = crud.get_registration_task(db, task_uuid) + if not task: + raise HTTPException(status_code=404, detail="任务不存在") + + if task.status not in ["pending", "running"]: + raise HTTPException(status_code=400, detail="任务已完成或已取消") + + task = crud.update_registration_task(db, task_uuid, status="cancelled") + + return {"success": True, "message": "任务已取消"} + + +@router.delete("/tasks/{task_uuid}") +async def delete_task(task_uuid: str): + """删除任务""" + with get_db() as db: + task = crud.get_registration_task(db, task_uuid) + if not task: + raise HTTPException(status_code=404, detail="任务不存在") + + if task.status == "running": + raise HTTPException(status_code=400, detail="无法删除运行中的任务") + + crud.delete_registration_task(db, task_uuid) + + return {"success": True, "message": "任务已删除"} + + +@router.get("/stats") +async def get_registration_stats(): + """获取注册统计信息""" + with get_db() as db: + from sqlalchemy import func + + # 按状态统计 + status_stats = db.query( + RegistrationTask.status, + func.count(RegistrationTask.id) + ).group_by(RegistrationTask.status).all() + + # 今日注册数 + today = datetime.utcnow().date() + today_count = db.query(func.count(RegistrationTask.id)).filter( + func.date(RegistrationTask.created_at) == today + ).scalar() + + return { + "by_status": {status: count for status, count in status_stats}, + "today_count": today_count + } diff --git a/src/web/routes/settings.py b/src/web/routes/settings.py new file mode 100644 index 0000000..0b2777d --- /dev/null +++ b/src/web/routes/settings.py @@ -0,0 +1,294 @@ +""" +设置 API 路由 +""" + +import logging +from typing import Optional, Dict, Any, List + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from ...database import crud +from ...database.session import get_db +from ...config.settings import get_settings, update_settings + +logger = logging.getLogger(__name__) +router = APIRouter() + + +# ============== Pydantic Models ============== + +class SettingItem(BaseModel): + """设置项""" + key: str + value: str + description: Optional[str] = None + category: str = "general" + + +class SettingUpdateRequest(BaseModel): + """设置更新请求""" + value: str + + +class ProxySettings(BaseModel): + """代理设置""" + enabled: bool = False + type: str = "http" # http, socks5 + host: str = "127.0.0.1" + port: int = 7890 + username: Optional[str] = None + password: Optional[str] = None + + +class RegistrationSettings(BaseModel): + """注册设置""" + max_retries: int = 3 + timeout: int = 120 + default_password_length: int = 12 + sleep_min: int = 5 + sleep_max: int = 30 + + +class WebUISettings(BaseModel): + """Web UI 设置""" + host: str = "0.0.0.0" + port: int = 8000 + debug: bool = False + + +class AllSettings(BaseModel): + """所有设置""" + proxy: ProxySettings + registration: RegistrationSettings + webui: WebUISettings + + +# ============== API Endpoints ============== + +@router.get("") +async def get_all_settings(): + """获取所有设置""" + settings = get_settings() + + return { + "proxy": { + "enabled": settings.proxy_enabled, + "type": settings.proxy_type, + "host": settings.proxy_host, + "port": settings.proxy_port, + "username": settings.proxy_username, + "has_password": bool(settings.proxy_password), + }, + "registration": { + "max_retries": settings.registration_max_retries, + "timeout": settings.registration_timeout, + "default_password_length": settings.registration_default_password_length, + "sleep_min": settings.registration_sleep_min, + "sleep_max": settings.registration_sleep_max, + }, + "webui": { + "host": settings.webui_host, + "port": settings.webui_port, + "debug": settings.debug, + }, + "tempmail": { + "base_url": settings.tempmail_base_url, + "timeout": settings.tempmail_timeout, + "max_retries": settings.tempmail_max_retries, + }, + } + + +@router.get("/proxy") +async def get_proxy_settings(): + """获取代理设置""" + settings = get_settings() + + return { + "enabled": settings.proxy_enabled, + "type": settings.proxy_type, + "host": settings.proxy_host, + "port": settings.proxy_port, + "username": settings.proxy_username, + "has_password": bool(settings.proxy_password), + "proxy_url": settings.proxy_url, + } + + +@router.post("/proxy") +async def update_proxy_settings(request: ProxySettings): + """更新代理设置""" + update_dict = { + "proxy_enabled": request.enabled, + "proxy_type": request.type, + "proxy_host": request.host, + "proxy_port": request.port, + "proxy_username": request.username, + } + + if request.password: + update_dict["proxy_password"] = request.password + + update_settings(**update_dict) + + return {"success": True, "message": "代理设置已更新"} + + +@router.get("/registration") +async def get_registration_settings(): + """获取注册设置""" + settings = get_settings() + + return { + "max_retries": settings.registration_max_retries, + "timeout": settings.registration_timeout, + "default_password_length": settings.registration_default_password_length, + "sleep_min": settings.registration_sleep_min, + "sleep_max": settings.registration_sleep_max, + } + + +@router.post("/registration") +async def update_registration_settings(request: RegistrationSettings): + """更新注册设置""" + update_settings( + registration_max_retries=request.max_retries, + registration_timeout=request.timeout, + registration_default_password_length=request.default_password_length, + registration_sleep_min=request.sleep_min, + registration_sleep_max=request.sleep_max, + ) + + return {"success": True, "message": "注册设置已更新"} + + +@router.get("/database") +async def get_database_info(): + """获取数据库信息""" + settings = get_settings() + + import os + from pathlib import Path + + db_path = settings.database_url + if db_path.startswith("sqlite:///"): + db_path = db_path[10:] + + db_file = Path(db_path) if os.path.isabs(db_path) else Path(db_path) + db_size = db_file.stat().st_size if db_file.exists() else 0 + + with get_db() as db: + from ...database.models import Account, EmailService, RegistrationTask + + account_count = db.query(Account).count() + service_count = db.query(EmailService).count() + task_count = db.query(RegistrationTask).count() + + return { + "database_url": settings.database_url, + "database_size_bytes": db_size, + "database_size_mb": round(db_size / (1024 * 1024), 2), + "accounts_count": account_count, + "email_services_count": service_count, + "tasks_count": task_count, + } + + +@router.post("/database/backup") +async def backup_database(): + """备份数据库""" + import shutil + from datetime import datetime + + settings = get_settings() + + db_path = settings.database_url + if db_path.startswith("sqlite:///"): + db_path = db_path[10:] + + if not os.path.exists(db_path): + raise HTTPException(status_code=404, detail="数据库文件不存在") + + # 创建备份目录 + backup_dir = Path(db_path).parent / "backups" + backup_dir.mkdir(exist_ok=True) + + # 生成备份文件名 + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + backup_path = backup_dir / f"database_backup_{timestamp}.db" + + # 复制数据库文件 + shutil.copy2(db_path, backup_path) + + return { + "success": True, + "message": "数据库备份成功", + "backup_path": str(backup_path) + } + + +@router.post("/database/cleanup") +async def cleanup_database( + days: int = 30, + keep_failed: bool = True +): + """清理过期数据""" + from datetime import datetime, timedelta + + cutoff_date = datetime.utcnow() - timedelta(days=days) + + with get_db() as db: + from ...database.models import RegistrationTask + from sqlalchemy import delete + + # 删除旧任务 + conditions = [RegistrationTask.created_at < cutoff_date] + if not keep_failed: + conditions.append(RegistrationTask.status != "failed") + else: + conditions.append(RegistrationTask.status.in_(["completed", "cancelled"])) + + result = db.execute( + delete(RegistrationTask).where(*conditions) + ) + db.commit() + + deleted_count = result.rowcount + + return { + "success": True, + "message": f"已清理 {deleted_count} 条过期任务记录", + "deleted_count": deleted_count + } + + +@router.get("/logs") +async def get_recent_logs( + lines: int = 100, + level: str = "INFO" +): + """获取最近日志""" + settings = get_settings() + + log_file = settings.log_file + if not log_file: + return {"logs": [], "message": "日志文件未配置"} + + from pathlib import Path + log_path = Path(log_file) + + if not log_path.exists(): + return {"logs": [], "message": "日志文件不存在"} + + try: + with open(log_path, "r", encoding="utf-8") as f: + all_lines = f.readlines() + recent_lines = all_lines[-lines:] + + return { + "logs": [line.strip() for line in recent_lines], + "total_lines": len(all_lines) + } + except Exception as e: + return {"logs": [], "error": str(e)} diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..1afc287 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,605 @@ +/* + * OpenAI 注册系统 - 主样式表 + * 轻量级、现代、响应式设计 + */ + +/* CSS 变量 */ +:root { + --primary-color: #10a37f; + --primary-hover: #0d8a6a; + --secondary-color: #6b7280; + --danger-color: #ef4444; + --warning-color: #f59e0b; + --success-color: #22c55e; + --background: #f9fafb; + --surface: #ffffff; + --border: #e5e7eb; + --text-primary: #111827; + --text-secondary: #6b7280; + --font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; +} + +/* 重置样式 */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: var(--font-family); + background-color: var(--background); + color: var(--text-primary); + line-height: 1.6; +} + +/* 容器 */ +.container { + max-width: 1200px; + margin: 0 auto; + padding: 0 20px; +} + +/* 导航栏 */ +.navbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 0; + border-bottom: 1px solid var(--border); + background: var(--surface); + margin-bottom: 30px; +} + +.nav-brand h1 { + font-size: 1.5rem; + color: var(--primary-color); + font-weight: 600; +} + +.nav-links { + display: flex; + gap: 20px; +} + +.nav-link { + text-decoration: none; + color: var(--text-secondary); + padding: 8px 16px; + border-radius: 6px; + transition: all 0.2s; +} + +.nav-link:hover { + color: var(--primary-color); + background-color: rgba(16, 163, 127, 0.1); +} + +.nav-link.active { + color: var(--primary-color); + background-color: rgba(16, 163, 127, 0.1); + font-weight: 500; +} + +/* 主内容 */ +.main-content { + padding-bottom: 50px; +} + +.page-header { + margin-bottom: 30px; +} + +.page-header h2 { + font-size: 1.75rem; + margin-bottom: 8px; +} + +.subtitle { + color: var(--text-secondary); +} + +/* 卡片 */ +.card { + background: var(--surface); + border-radius: 8px; + border: 1px solid var(--border); + margin-bottom: 20px; +} + +.card-header { + padding: 16px 20px; + border-bottom: 1px solid var(--border); + display: flex; + justify-content: space-between; + align-items: center; +} + +.card-header h3 { + font-size: 1rem; + font-weight: 600; +} + +.card-body { + padding: 20px; +} + +/* 表单 */ +.form-group { + margin-bottom: 16px; +} + +.form-group label { + display: block; + margin-bottom: 6px; + font-weight: 500; + font-size: 0.875rem; +} + +.form-group input, +.form-group select, +.form-group textarea { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 0.875rem; + transition: border-color 0.2s; +} + +.form-group input:focus, +.form-group select:focus, +.form-group textarea:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(16, 163, 127, 0.1); +} + +.form-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; +} + +.form-actions { + display: flex; + gap: 12px; + margin-top: 20px; +} + +/* 按钮 */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 10px 20px; + font-size: 0.875rem; + font-weight: 500; + border-radius: 6px; + border: none; + cursor: pointer; + transition: all 0.2s; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-primary { + background-color: var(--primary-color); + color: white; +} + +.btn-primary:hover:not(:disabled) { + background-color: var(--primary-hover); +} + +.btn-secondary { + background-color: var(--border); + color: var(--text-primary); +} + +.btn-secondary:hover:not(:disabled) { + background-color: #d1d5db; +} + +.btn-danger { + background-color: var(--danger-color); + color: white; +} + +.btn-danger:hover:not(:disabled) { + background-color: #dc2626; +} + +.btn-warning { + background-color: var(--warning-color); + color: white; +} + +.btn-warning:hover:not(:disabled) { + background-color: #d97706; +} + +.btn-sm { + padding: 6px 12px; + font-size: 0.75rem; +} + +/* 控制台日志 */ +.console-log { + background-color: #1e1e1e; + color: #d4d4d4; + padding: 16px; + border-radius: 6px; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 0.8rem; + height: 300px; + overflow-y: auto; + line-height: 1.5; +} + +.log-line { + margin-bottom: 4px; + white-space: pre-wrap; + word-break: break-all; +} + +.log-line.info { color: #4fc3f7; } +.log-line.success { color: #81c784; } +.log-line.error { color: #e57373; } +.log-line.warning { color: #ffb74d; } + +/* 状态徽章 */ +.status-badge { + display: inline-block; + padding: 4px 10px; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; + background-color: var(--secondary-color); + color: white; +} + +.status-badge.running { + background-color: var(--primary-color); +} + +.status-badge.completed { + background-color: var(--success-color); +} + +.status-badge.failed { + background-color: var(--danger-color); +} + +/* 统计卡片 */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 20px; + margin-bottom: 20px; +} + +.stat-card { + background: var(--surface); + border-radius: 8px; + border: 1px solid var(--border); + padding: 20px; + text-align: center; +} + +.stat-value { + font-size: 2rem; + font-weight: 700; + color: var(--text-primary); +} + +.stat-label { + color: var(--text-secondary); + font-size: 0.875rem; + margin-top: 4px; +} + +.stat-card.success .stat-value { color: var(--success-color); } +.stat-card.warning .stat-value { color: var(--warning-color); } +.stat-card.danger .stat-value { color: var(--danger-color); } + +/* 数据表格 */ +.data-table { + width: 100%; + border-collapse: collapse; +} + +.data-table th, +.data-table td { + padding: 12px; + text-align: left; + border-bottom: 1px solid var(--border); +} + +.data-table th { + font-weight: 600; + color: var(--text-secondary); + font-size: 0.75rem; + text-transform: uppercase; +} + +.data-table tbody tr:hover { + background-color: #f9fafb; +} + +/* 工具栏 */ +.toolbar { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 12px; +} + +.toolbar-left, +.toolbar-right { + display: flex; + align-items: center; + gap: 12px; +} + +.form-select, +.form-input { + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 0.875rem; +} + +/* 分页 */ +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 16px; + margin-top: 20px; +} + +#page-info { + color: var(--text-secondary); + font-size: 0.875rem; +} + +/* 标签页 */ +.tabs { + display: flex; + gap: 4px; + margin-bottom: 20px; + border-bottom: 1px solid var(--border); + padding-bottom: 0; +} + +.tab-btn { + padding: 12px 20px; + background: none; + border: none; + cursor: pointer; + font-size: 0.875rem; + font-weight: 500; + color: var(--text-secondary); + border-bottom: 2px solid transparent; + margin-bottom: -1px; + transition: all 0.2s; +} + +.tab-btn:hover { + color: var(--primary-color); +} + +.tab-btn.active { + color: var(--primary-color); + border-bottom-color: var(--primary-color); +} + +.tab-content { + display: none; +} + +.tab-content.active { + display: block; +} + +/* 模态框 */ +.modal { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + z-index: 1000; + align-items: center; + justify-content: center; +} + +.modal.active { + display: flex; +} + +.modal-content { + background: var(--surface); + border-radius: 8px; + max-width: 600px; + width: 90%; + max-height: 80vh; + overflow-y: auto; +} + +.modal-header { + padding: 16px 20px; + border-bottom: 1px solid var(--border); + display: flex; + justify-content: space-between; + align-items: center; +} + +.modal-header h3 { + font-size: 1.125rem; +} + +.modal-close { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: var(--text-secondary); +} + +.modal-close:hover { + color: var(--text-primary); +} + +.modal-body { + padding: 20px; +} + +/* 下拉菜单 */ +.dropdown { + position: relative; + display: inline-block; +} + +.dropdown-menu { + display: none; + position: absolute; + right: 0; + top: 100%; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + min-width: 150px; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + z-index: 100; +} + +.dropdown-menu.active { + display: block; +} + +.dropdown-item { + display: block; + padding: 10px 16px; + color: var(--text-primary); + text-decoration: none; + font-size: 0.875rem; +} + +.dropdown-item:hover { + background-color: #f9fafb; +} + +/* 任务信息 */ +.task-info { + display: grid; + gap: 12px; +} + +.info-row { + display: flex; + gap: 12px; +} + +.info-row .label { + color: var(--text-secondary); + min-width: 80px; +} + +.info-row .value { + font-weight: 500; +} + +/* 信息网格 */ +.info-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; + margin-bottom: 20px; +} + +.info-item { + display: flex; + flex-direction: column; + gap: 4px; +} + +.info-item .label { + color: var(--text-secondary); + font-size: 0.875rem; +} + +.info-item .value { + font-size: 1.25rem; + font-weight: 600; +} + +/* 进度条 */ +.progress-bar-container { + background-color: var(--border); + border-radius: 9999px; + height: 12px; + overflow: hidden; + margin-bottom: 16px; +} + +.progress-bar { + background-color: var(--primary-color); + height: 100%; + border-radius: 9999px; + transition: width 0.3s ease; +} + +/* 批量统计 */ +.batch-stats { + display: flex; + justify-content: space-around; + gap: 16px; + text-align: center; +} + +.batch-stats span { + color: var(--text-secondary); + font-size: 0.875rem; +} + +.batch-stats strong { + display: block; + font-size: 1.5rem; + color: var(--text-primary); + margin-top: 4px; +} + +/* 响应式 */ +@media (max-width: 768px) { + .navbar { + flex-direction: column; + gap: 16px; + } + + .toolbar { + flex-direction: column; + align-items: stretch; + } + + .toolbar-left, + .toolbar-right { + flex-direction: column; + align-items: stretch; + } + + .form-row { + grid-template-columns: 1fr; + } + + .stats-grid { + grid-template-columns: repeat(2, 1fr); + } +} diff --git a/static/js/accounts.js b/static/js/accounts.js new file mode 100644 index 0000000..dffba41 --- /dev/null +++ b/static/js/accounts.js @@ -0,0 +1,381 @@ +/** + * 账号管理页面 JavaScript + */ + +// API 基础路径 +const API_BASE = '/api'; + +// 状态 +let currentPage = 1; +let pageSize = 20; +let totalAccounts = 0; +let selectedAccounts = new Set(); + +// DOM 元素 +const accountsTable = document.getElementById('accounts-table'); +const totalAccountsEl = document.getElementById('total-accounts'); +const activeAccountsEl = document.getElementById('active-accounts'); +const expiredAccountsEl = document.getElementById('expired-accounts'); +const failedAccountsEl = document.getElementById('failed-accounts'); +const filterStatus = document.getElementById('filter-status'); +const filterService = document.getElementById('filter-service'); +const searchInput = document.getElementById('search-input'); +const refreshBtn = document.getElementById('refresh-btn'); +const batchDeleteBtn = document.getElementById('batch-delete-btn'); +const exportBtn = document.getElementById('export-btn'); +const exportMenu = document.getElementById('export-menu'); +const selectAllCheckbox = document.getElementById('select-all'); +const prevPageBtn = document.getElementById('prev-page'); +const nextPageBtn = document.getElementById('next-page'); +const pageInfo = document.getElementById('page-info'); +const detailModal = document.getElementById('detail-modal'); +const modalBody = document.getElementById('modal-body'); +const closeModalBtn = document.getElementById('close-modal'); + +// 初始化 +document.addEventListener('DOMContentLoaded', () => { + loadStats(); + loadAccounts(); + initEventListeners(); +}); + +// 事件监听 +function initEventListeners() { + // 筛选 + filterStatus.addEventListener('change', () => { + currentPage = 1; + loadAccounts(); + }); + + filterService.addEventListener('change', () => { + currentPage = 1; + loadAccounts(); + }); + + // 搜索 + let searchTimeout; + searchInput.addEventListener('input', () => { + clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => { + currentPage = 1; + loadAccounts(); + }, 300); + }); + + // 刷新 + refreshBtn.addEventListener('click', () => { + loadStats(); + loadAccounts(); + }); + + // 批量删除 + batchDeleteBtn.addEventListener('click', handleBatchDelete); + + // 全选 + selectAllCheckbox.addEventListener('change', (e) => { + const checkboxes = accountsTable.querySelectorAll('input[type="checkbox"]'); + checkboxes.forEach(cb => { + cb.checked = e.target.checked; + const id = parseInt(cb.dataset.id); + if (e.target.checked) { + selectedAccounts.add(id); + } else { + selectedAccounts.delete(id); + } + }); + updateBatchButtons(); + }); + + // 分页 + prevPageBtn.addEventListener('click', () => { + if (currentPage > 1) { + currentPage--; + loadAccounts(); + } + }); + + nextPageBtn.addEventListener('click', () => { + const totalPages = Math.ceil(totalAccounts / pageSize); + if (currentPage < totalPages) { + currentPage++; + loadAccounts(); + } + }); + + // 导出 + exportBtn.addEventListener('click', (e) => { + e.stopPropagation(); + exportMenu.classList.toggle('active'); + }); + + document.querySelectorAll('#export-menu .dropdown-item').forEach(item => { + item.addEventListener('click', (e) => { + e.preventDefault(); + const format = e.target.dataset.format; + exportAccounts(format); + exportMenu.classList.remove('active'); + }); + }); + + // 关闭模态框 + closeModalBtn.addEventListener('click', () => { + detailModal.classList.remove('active'); + }); + + detailModal.addEventListener('click', (e) => { + if (e.target === detailModal) { + detailModal.classList.remove('active'); + } + }); + + // 点击其他地方关闭下拉菜单 + document.addEventListener('click', () => { + exportMenu.classList.remove('active'); + }); +} + +// 加载统计信息 +async function loadStats() { + try { + const response = await fetch(`${API_BASE}/accounts/stats/summary`); + const data = await response.json(); + + totalAccountsEl.textContent = data.total || 0; + activeAccountsEl.textContent = data.by_status?.active || 0; + expiredAccountsEl.textContent = data.by_status?.expired || 0; + failedAccountsEl.textContent = data.by_status?.failed || 0; + } catch (error) { + console.error('加载统计信息失败:', error); + } +} + +// 加载账号列表 +async function loadAccounts() { + const params = new URLSearchParams({ + page: currentPage, + page_size: pageSize, + }); + + if (filterStatus.value) { + params.append('status', filterStatus.value); + } + + if (filterService.value) { + params.append('email_service', filterService.value); + } + + if (searchInput.value.trim()) { + params.append('search', searchInput.value.trim()); + } + + try { + const response = await fetch(`${API_BASE}/accounts?${params}`); + const data = await response.json(); + + totalAccounts = data.total; + renderAccounts(data.accounts); + updatePagination(); + } catch (error) { + console.error('加载账号列表失败:', error); + accountsTable.innerHTML = '
查看和管理已注册的 OpenAI 账号
+| + | ID | +邮箱 | +邮箱服务 | +状态 | +注册时间 | +操作 | +
|---|
启动新的 OpenAI/Codex CLI 账号注册任务
+配置代理、邮箱服务和系统参数
+| + | 名称 | +类型 | +状态 | +优先级 | +最后使用 | +操作 | +
|---|