This commit is contained in:
cnlimiter
2026-03-14 16:51:57 +08:00
parent dc1334fbab
commit 9d3099fcd8
35 changed files with 9490 additions and 0 deletions

56
.gitignore vendored Normal file
View File

@@ -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/

Binary file not shown.

24
src/__init__.py Normal file
View File

@@ -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',
]

34
src/config/__init__.py Normal file
View File

@@ -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',
]

265
src/config/constants.py Normal file
View File

@@ -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"(?<!\d)(\d{6})(?!\d)"
OTP_WAIT_TIMEOUT = 120 # 秒
OTP_POLL_INTERVAL = 3 # 秒
OTP_MAX_ATTEMPTS = 40 # 最大轮询次数
# 密码生成
PASSWORD_CHARSET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
DEFAULT_PASSWORD_LENGTH = 12
# 用户信息(用于注册)
DEFAULT_USER_INFO = {
"name": "Neo",
"birthdate": "2000-02-20",
}
# ============================================================================
# 代理相关常量
# ============================================================================
PROXY_TYPES = ["http", "socks5", "socks5h"]
DEFAULT_PROXY_CONFIG = {
"enabled": False,
"type": "http",
"host": "127.0.0.1",
"port": 7890,
}
# ============================================================================
# 数据库相关常量
# ============================================================================
# 数据库表名
DB_TABLE_NAMES = {
"accounts": "accounts",
"email_services": "email_services",
"registration_tasks": "registration_tasks",
"settings": "settings",
}
# 默认设置
DEFAULT_SETTINGS = [
# (key, value, description, category)
("system.name", APP_NAME, "系统名称", "general"),
("system.version", APP_VERSION, "系统版本", "general"),
("logs.retention_days", "30", "日志保留天数", "general"),
("openai.client_id", OAUTH_CLIENT_ID, "OpenAI OAuth Client ID", "openai"),
("openai.auth_url", OAUTH_AUTH_URL, "OpenAI 认证地址", "openai"),
("openai.token_url", OAUTH_TOKEN_URL, "OpenAI Token 地址", "openai"),
("openai.redirect_uri", OAUTH_REDIRECT_URI, "OpenAI 回调地址", "openai"),
("openai.scope", OAUTH_SCOPE, "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"),
("webui.host", "0.0.0.0", "Web UI 监听主机", "webui"),
("webui.port", "8000", "Web UI 监听端口", "webui"),
("webui.debug", "true", "调试模式", "webui"),
]
# ============================================================================
# Web UI 相关常量
# ============================================================================
# WebSocket 事件
WEBSOCKET_EVENTS = {
"CONNECT": "connect",
"DISCONNECT": "disconnect",
"LOG": "log",
"STATUS": "status",
"ERROR": "error",
"COMPLETE": "complete",
}
# API 响应状态码
API_STATUS_CODES = {
"SUCCESS": 200,
"CREATED": 201,
"BAD_REQUEST": 400,
"UNAUTHORIZED": 401,
"FORBIDDEN": 403,
"NOT_FOUND": 404,
"CONFLICT": 409,
"INTERNAL_ERROR": 500,
}
# 分页
DEFAULT_PAGE_SIZE = 20
MAX_PAGE_SIZE = 100
# ============================================================================
# 错误消息
# ============================================================================
ERROR_MESSAGES = {
# 通用错误
"DATABASE_ERROR": "数据库操作失败",
"CONFIG_ERROR": "配置错误",
"NETWORK_ERROR": "网络连接失败",
"TIMEOUT": "操作超时",
"VALIDATION_ERROR": "参数验证失败",
# 邮箱服务错误
"EMAIL_SERVICE_UNAVAILABLE": "邮箱服务不可用",
"EMAIL_CREATION_FAILED": "创建邮箱失败",
"OTP_NOT_RECEIVED": "未收到验证码",
"OTP_INVALID": "验证码无效",
# OpenAI 相关错误
"OPENAI_AUTH_FAILED": "OpenAI 认证失败",
"OPENAI_RATE_LIMIT": "OpenAI 接口限流",
"OPENAI_CAPTCHA": "遇到验证码",
# 代理错误
"PROXY_FAILED": "代理连接失败",
"PROXY_AUTH_FAILED": "代理认证失败",
# 账户错误
"ACCOUNT_NOT_FOUND": "账户不存在",
"ACCOUNT_ALREADY_EXISTS": "账户已存在",
"ACCOUNT_INVALID": "账户无效",
# 任务错误
"TASK_NOT_FOUND": "任务不存在",
"TASK_ALREADY_RUNNING": "任务已在运行中",
"TASK_CANCELLED": "任务已取消",
}
# ============================================================================
# 正则表达式
# ============================================================================
REGEX_PATTERNS = {
"EMAIL": r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$",
"URL": r"https?://(?:[-\w.]|(?:%[\da-fA-F]{2}))+",
"IP_ADDRESS": r"\b(?:\d{1,3}\.){3}\d{1,3}\b",
"OTP_CODE": OTP_CODE_PATTERN,
}
# ============================================================================
# 时间常量
# ============================================================================
TIME_CONSTANTS = {
"SECOND": 1,
"MINUTE": 60,
"HOUR": 3600,
"DAY": 86400,
"WEEK": 604800,
}

168
src/config/settings.py Normal file
View File

@@ -0,0 +1,168 @@
"""
配置管理 - Pydantic 设置模型
"""
import os
from typing import Optional, Dict, Any
from pydantic import Field, field_validator
from pydantic.types import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""
应用配置
优先级:环境变量 > .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

32
src/core/__init__.py Normal file
View File

@@ -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',
]

420
src/core/http_client.py Normal file
View File

@@ -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)

336
src/core/oauth.py Normal file
View File

@@ -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
}

723
src/core/register.py Normal file
View File

@@ -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

566
src/core/utils.py Normal file
View File

@@ -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

20
src/database/__init__.py Normal file
View File

@@ -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',
]

372
src/database/crud.py Normal file
View File

@@ -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

133
src/database/init_db.py Normal file
View File

@@ -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)

113
src/database/models.py Normal file
View File

@@ -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)

115
src/database/session.py Normal file
View File

@@ -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()

32
src/services/__init__.py Normal file
View File

@@ -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',
]

384
src/services/base.py Normal file
View File

@@ -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"(?<!\d)(\d{6})(?!\d)"
) -> 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)

View File

@@ -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,
}

610
src/services/outlook.py Normal file
View File

@@ -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 "<html" in text.lower():
text = re.sub(r"<[^>]+>", " ", 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, # 对于 Outlookservice_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: 未使用(对于 Outlookemail 就是标识)
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:
FalseOutlook 不支持删除账户)
"""
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

398
src/services/tempmail.py Normal file
View File

@@ -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

7
src/web/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
"""
Web UI 应用模块
"""
from .app import app, create_app
__all__ = ['app', 'create_app']

104
src/web/app.py Normal file
View File

@@ -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()

View File

@@ -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"])

369
src/web/routes/accounts.py Normal file
View File

@@ -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}
}

View File

@@ -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} 个服务"}

View File

@@ -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
}

294
src/web/routes/settings.py Normal file
View File

@@ -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)}

605
static/css/style.css Normal file
View File

@@ -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);
}
}

381
static/js/accounts.js Normal file
View File

@@ -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 = '<tr><td colspan="7" style="text-align: center;">加载失败</td></tr>';
}
}
// 渲染账号列表
function renderAccounts(accounts) {
if (accounts.length === 0) {
accountsTable.innerHTML = '<tr><td colspan="7" style="text-align: center;">暂无数据</td></tr>';
return;
}
accountsTable.innerHTML = accounts.map(account => `
<tr>
<td><input type="checkbox" data-id="${account.id}" ${selectedAccounts.has(account.id) ? 'checked' : ''}></td>
<td>${account.id}</td>
<td>${escapeHtml(account.email)}</td>
<td>${escapeHtml(account.email_service)}</td>
<td><span class="status-badge ${account.status}">${getStatusText(account.status)}</span></td>
<td>${formatDate(account.registered_at)}</td>
<td>
<button class="btn btn-sm btn-secondary" onclick="viewAccount(${account.id})">查看</button>
<button class="btn btn-sm btn-danger" onclick="deleteAccount(${account.id}, '${escapeHtml(account.email)}')">删除</button>
</td>
</tr>
`).join('');
// 绑定复选框事件
accountsTable.querySelectorAll('input[type="checkbox"]').forEach(cb => {
cb.addEventListener('change', (e) => {
const id = parseInt(e.target.dataset.id);
if (e.target.checked) {
selectedAccounts.add(id);
} else {
selectedAccounts.delete(id);
}
updateBatchButtons();
});
});
}
// 更新分页
function updatePagination() {
const totalPages = Math.ceil(totalAccounts / pageSize);
prevPageBtn.disabled = currentPage <= 1;
nextPageBtn.disabled = currentPage >= totalPages;
pageInfo.textContent = `${currentPage} 页 / 共 ${totalPages}`;
}
// 更新批量操作按钮
function updateBatchButtons() {
batchDeleteBtn.disabled = selectedAccounts.size === 0;
}
// 查看账号详情
async function viewAccount(id) {
try {
const response = await fetch(`${API_BASE}/accounts/${id}`);
const account = await response.json();
const tokensResponse = await fetch(`${API_BASE}/accounts/${id}/tokens`);
const tokens = await tokensResponse.json();
modalBody.innerHTML = `
<div class="info-grid">
<div class="info-item">
<span class="label">邮箱</span>
<span class="value">${escapeHtml(account.email)}</span>
</div>
<div class="info-item">
<span class="label">邮箱服务</span>
<span class="value">${escapeHtml(account.email_service)}</span>
</div>
<div class="info-item">
<span class="label">状态</span>
<span class="value">${getStatusText(account.status)}</span>
</div>
<div class="info-item">
<span class="label">注册时间</span>
<span class="value">${formatDate(account.registered_at)}</span>
</div>
<div class="info-item">
<span class="label">Account ID</span>
<span class="value">${escapeHtml(account.account_id || '-')}</span>
</div>
<div class="info-item">
<span class="label">Workspace ID</span>
<span class="value">${escapeHtml(account.workspace_id || '-')}</span>
</div>
<div class="info-item">
<span class="label">Access Token</span>
<span class="value" style="font-size: 0.75rem; word-break: break-all;">${escapeHtml(tokens.access_token || '-')}</span>
</div>
<div class="info-item">
<span class="label">Refresh Token</span>
<span class="value" style="font-size: 0.75rem; word-break: break-all;">${escapeHtml(tokens.refresh_token || '-')}</span>
</div>
</div>
`;
detailModal.classList.add('active');
} catch (error) {
alert('加载账号详情失败: ' + error.message);
}
}
// 删除账号
async function deleteAccount(id, email) {
if (!confirm(`确定要删除账号 ${email} 吗?`)) {
return;
}
try {
const response = await fetch(`${API_BASE}/accounts/${id}`, {
method: 'DELETE',
});
if (response.ok) {
loadStats();
loadAccounts();
} else {
const data = await response.json();
alert('删除失败: ' + (data.detail || '未知错误'));
}
} catch (error) {
alert('删除失败: ' + error.message);
}
}
// 批量删除
async function handleBatchDelete() {
if (selectedAccounts.size === 0) return;
if (!confirm(`确定要删除选中的 ${selectedAccounts.size} 个账号吗?`)) {
return;
}
try {
const response = await fetch(`${API_BASE}/accounts/batch-delete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
ids: Array.from(selectedAccounts),
}),
});
const data = await response.json();
if (response.ok) {
alert(`成功删除 ${data.deleted_count} 个账号`);
selectedAccounts.clear();
loadStats();
loadAccounts();
} else {
alert('删除失败: ' + (data.detail || '未知错误'));
}
} catch (error) {
alert('删除失败: ' + error.message);
}
}
// 导出账号
function exportAccounts(format) {
const params = new URLSearchParams();
if (filterStatus.value) {
params.append('status', filterStatus.value);
}
if (filterService.value) {
params.append('email_service', filterService.value);
}
window.location.href = `${API_BASE}/accounts/export/${format}?${params}`;
}
// 工具函数
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function getStatusText(status) {
const statusMap = {
'active': '活跃',
'expired': '过期',
'banned': '封禁',
'failed': '失败',
};
return statusMap[status] || status;
}
function formatDate(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleString('zh-CN');
}

360
static/js/app.js Normal file
View File

@@ -0,0 +1,360 @@
/**
* 注册页面 JavaScript
*/
// API 基础路径
const API_BASE = '/api';
// 状态
let currentTask = null;
let currentBatch = null;
let logPollingInterval = null;
let batchPollingInterval = null;
let isBatchMode = false;
// DOM 元素
const registrationForm = document.getElementById('registration-form');
const emailServiceSelect = document.getElementById('email-service');
const proxyInput = document.getElementById('proxy');
const regModeSelect = document.getElementById('reg-mode');
const batchCountGroup = document.getElementById('batch-count-group');
const batchCountInput = document.getElementById('batch-count');
const batchOptions = document.getElementById('batch-options');
const intervalMinInput = document.getElementById('interval-min');
const intervalMaxInput = document.getElementById('interval-max');
const startBtn = document.getElementById('start-btn');
const cancelBtn = document.getElementById('cancel-btn');
const taskStatusCard = document.getElementById('task-status-card');
const batchStatusCard = document.getElementById('batch-status-card');
const consoleLog = document.getElementById('console-log');
const clearLogBtn = document.getElementById('clear-log-btn');
// 初始化
document.addEventListener('DOMContentLoaded', () => {
initEventListeners();
});
// 事件监听
function initEventListeners() {
// 注册表单提交
registrationForm.addEventListener('submit', handleStartRegistration);
// 注册模式切换
regModeSelect.addEventListener('change', handleModeChange);
// 取消按钮
cancelBtn.addEventListener('click', handleCancelTask);
// 清空日志
clearLogBtn.addEventListener('click', () => {
consoleLog.innerHTML = '<div class="log-line info">[*] 日志已清空</div>';
});
}
// 模式切换
function handleModeChange(e) {
const mode = e.target.value;
isBatchMode = mode === 'batch';
batchCountGroup.style.display = isBatchMode ? 'block' : 'none';
batchOptions.style.display = isBatchMode ? 'block' : 'none';
}
// 开始注册
async function handleStartRegistration(e) {
e.preventDefault();
const emailService = emailServiceSelect.value;
const proxy = proxyInput.value.trim() || null;
// 禁用开始按钮
startBtn.disabled = true;
cancelBtn.disabled = false;
// 清空日志
consoleLog.innerHTML = '';
if (isBatchMode) {
await handleBatchRegistration(emailService, proxy);
} else {
await handleSingleRegistration(emailService, proxy);
}
}
// 单次注册
async function handleSingleRegistration(emailService, proxy) {
addLog('info', '[*] 正在启动注册任务...');
try {
const response = await fetch(`${API_BASE}/registration/start`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email_service_type: emailService,
proxy: proxy,
}),
});
const data = await response.json();
if (response.ok) {
currentTask = data;
addLog('info', `[*] 任务已创建: ${data.task_uuid}`);
showTaskStatus(data);
// 开始轮询日志
startLogPolling(data.task_uuid);
} else {
addLog('error', `[Error] 启动失败: ${data.detail || '未知错误'}`);
resetButtons();
}
} catch (error) {
addLog('error', `[Error] 网络错误: ${error.message}`);
resetButtons();
}
}
// 批量注册
async function handleBatchRegistration(emailService, proxy) {
const count = parseInt(batchCountInput.value) || 5;
const intervalMin = parseInt(intervalMinInput.value) || 5;
const intervalMax = parseInt(intervalMaxInput.value) || 30;
addLog('info', `[*] 正在启动批量注册任务 (数量: ${count})...`);
try {
const response = await fetch(`${API_BASE}/registration/batch`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
count: count,
email_service_type: emailService,
proxy: proxy,
interval_min: intervalMin,
interval_max: intervalMax,
}),
});
const data = await response.json();
if (response.ok) {
currentBatch = data;
addLog('info', `[*] 批量任务已创建: ${data.batch_id}`);
addLog('info', `[*] 共 ${data.count} 个任务已加入队列`);
showBatchStatus(data);
// 开始轮询批量状态
startBatchPolling(data.batch_id);
} else {
addLog('error', `[Error] 启动失败: ${data.detail || '未知错误'}`);
resetButtons();
}
} catch (error) {
addLog('error', `[Error] 网络错误: ${error.message}`);
resetButtons();
}
}
// 取消任务
async function handleCancelTask() {
if (isBatchMode && currentBatch) {
try {
const response = await fetch(`${API_BASE}/registration/batch/${currentBatch.batch_id}/cancel`, {
method: 'POST',
});
if (response.ok) {
addLog('warning', '[!] 批量任务取消请求已提交');
stopBatchPolling();
resetButtons();
}
} catch (error) {
addLog('error', `[Error] 取消失败: ${error.message}`);
}
} else if (currentTask) {
try {
const response = await fetch(`${API_BASE}/registration/tasks/${currentTask.task_uuid}/cancel`, {
method: 'POST',
});
if (response.ok) {
addLog('warning', '[!] 任务已取消');
stopLogPolling();
resetButtons();
}
} catch (error) {
addLog('error', `[Error] 取消失败: ${error.message}`);
}
}
}
// 开始轮询日志
function startLogPolling(taskUuid) {
let lastLogLine = '';
logPollingInterval = setInterval(async () => {
try {
const response = await fetch(`${API_BASE}/registration/tasks/${taskUuid}/logs`);
const data = await response.json();
if (response.ok) {
// 更新任务状态
updateTaskStatus(data.status);
// 添加新日志
const logs = data.logs || [];
logs.forEach(log => {
if (log !== lastLogLine) {
const logType = getLogType(log);
addLog(logType, log);
lastLogLine = log;
}
});
// 检查任务是否完成
if (['completed', 'failed', 'cancelled'].includes(data.status)) {
stopLogPolling();
resetButtons();
if (data.status === 'completed') {
addLog('success', '[*] 注册成功!');
} else if (data.status === 'failed') {
addLog('error', '[Error] 注册失败');
}
}
}
} catch (error) {
console.error('轮询日志失败:', error);
}
}, 1000);
}
// 停止轮询日志
function stopLogPolling() {
if (logPollingInterval) {
clearInterval(logPollingInterval);
logPollingInterval = null;
}
}
// 开始轮询批量状态
function startBatchPolling(batchId) {
batchPollingInterval = setInterval(async () => {
try {
const response = await fetch(`${API_BASE}/registration/batch/${batchId}`);
const data = await response.json();
if (response.ok) {
updateBatchProgress(data);
// 检查是否完成
if (data.finished) {
stopBatchPolling();
resetButtons();
addLog('info', `[*] 批量任务完成!成功: ${data.success}, 失败: ${data.failed}`);
}
}
} catch (error) {
console.error('轮询批量状态失败:', error);
}
}, 2000);
}
// 停止轮询批量状态
function stopBatchPolling() {
if (batchPollingInterval) {
clearInterval(batchPollingInterval);
batchPollingInterval = null;
}
}
// 显示任务状态
function showTaskStatus(task) {
taskStatusCard.style.display = 'block';
batchStatusCard.style.display = 'none';
document.getElementById('task-id').textContent = task.task_uuid;
updateTaskStatus(task.status);
}
// 更新任务状态
function updateTaskStatus(status) {
const statusBadge = document.getElementById('task-status-badge');
const statusText = document.getElementById('task-status');
const statusMap = {
'pending': { text: '等待中', class: '' },
'running': { text: '运行中', class: 'running' },
'completed': { text: '已完成', class: 'completed' },
'failed': { text: '失败', class: 'failed' },
'cancelled': { text: '已取消', class: '' },
};
const info = statusMap[status] || { text: status, class: '' };
statusBadge.textContent = info.text;
statusBadge.className = 'status-badge ' + info.class;
statusText.textContent = info.text;
}
// 显示批量状态
function showBatchStatus(batch) {
batchStatusCard.style.display = 'block';
taskStatusCard.style.display = 'none';
document.getElementById('batch-progress').textContent = `0/${batch.count}`;
document.getElementById('progress-bar').style.width = '0%';
document.getElementById('batch-success').textContent = '0';
document.getElementById('batch-failed').textContent = '0';
document.getElementById('batch-remaining').textContent = batch.count;
}
// 更新批量进度
function updateBatchProgress(data) {
const progress = data.completed / data.total * 100;
document.getElementById('batch-progress').textContent = data.progress;
document.getElementById('progress-bar').style.width = `${progress}%`;
document.getElementById('batch-success').textContent = data.success;
document.getElementById('batch-failed').textContent = data.failed;
document.getElementById('batch-remaining').textContent = data.total - data.completed;
// 记录日志
if (data.completed > 0) {
addLog('info', `[*] 进度: ${data.progress}, 成功: ${data.success}, 失败: ${data.failed}`);
}
}
// 添加日志
function addLog(type, message) {
const line = document.createElement('div');
line.className = `log-line ${type}`;
line.textContent = message;
consoleLog.appendChild(line);
// 自动滚动到底部
consoleLog.scrollTop = consoleLog.scrollHeight;
}
// 获取日志类型
function getLogType(log) {
if (log.includes('[Error]') || log.includes('失败') || log.includes('错误')) {
return 'error';
}
if (log.includes('[!]') || log.includes('警告')) {
return 'warning';
}
if (log.includes('成功') || log.includes('完成')) {
return 'success';
}
return 'info';
}
// 重置按钮状态
function resetButtons() {
startBtn.disabled = false;
cancelBtn.disabled = true;
currentTask = null;
currentBatch = null;
}

513
static/js/settings.js Normal file
View File

@@ -0,0 +1,513 @@
/**
* 设置页面 JavaScript
*/
// API 基础路径
const API_BASE = '/api';
// DOM 元素
const tabBtns = document.querySelectorAll('.tab-btn');
const tabContents = document.querySelectorAll('.tab-content');
const proxyForm = document.getElementById('proxy-form');
const registrationForm = document.getElementById('registration-form');
const testProxyBtn = document.getElementById('test-proxy-btn');
const backupBtn = document.getElementById('backup-btn');
const cleanupBtn = document.getElementById('cleanup-btn');
const addEmailServiceBtn = document.getElementById('add-email-service-btn');
const addServiceModal = document.getElementById('add-service-modal');
const addServiceForm = document.getElementById('add-service-form');
const closeServiceModalBtn = document.getElementById('close-service-modal');
const cancelAddServiceBtn = document.getElementById('cancel-add-service');
const serviceTypeSelect = document.getElementById('service-type');
const serviceConfigFields = document.getElementById('service-config-fields');
const emailServicesTable = document.getElementById('email-services-table');
// Outlook 批量导入相关
const toggleImportBtn = document.getElementById('toggle-import-btn');
const outlookImportBody = document.getElementById('outlook-import-body');
const outlookImportBtn = document.getElementById('outlook-import-btn');
const clearImportBtn = document.getElementById('clear-import-btn');
const outlookImportData = document.getElementById('outlook-import-data');
const importResult = document.getElementById('import-result');
// 批量操作
const batchDeleteBtn = document.getElementById('batch-delete-btn');
const selectAllCheckbox = document.getElementById('select-all-services');
// 选中的服务 ID
let selectedServiceIds = [];
// 初始化
document.addEventListener('DOMContentLoaded', () => {
initTabs();
loadSettings();
loadEmailServices();
loadDatabaseInfo();
initEventListeners();
});
// 初始化标签页
function initTabs() {
tabBtns.forEach(btn => {
btn.addEventListener('click', () => {
const tab = btn.dataset.tab;
tabBtns.forEach(b => b.classList.remove('active'));
tabContents.forEach(c => c.classList.remove('active'));
btn.classList.add('active');
document.getElementById(`${tab}-tab`).classList.add('active');
});
});
}
// 事件监听
function initEventListeners() {
// 代理表单
proxyForm.addEventListener('submit', handleSaveProxy);
// 测试代理
testProxyBtn.addEventListener('click', handleTestProxy);
// 注册配置表单
registrationForm.addEventListener('submit', handleSaveRegistration);
// 备份数据库
backupBtn.addEventListener('click', handleBackup);
// 清理数据
cleanupBtn.addEventListener('click', handleCleanup);
// 添加邮箱服务
addEmailServiceBtn.addEventListener('click', () => {
addServiceModal.classList.add('active');
loadServiceConfigFields(serviceTypeSelect.value);
});
closeServiceModalBtn.addEventListener('click', () => {
addServiceModal.classList.remove('active');
});
cancelAddServiceBtn.addEventListener('click', () => {
addServiceModal.classList.remove('active');
});
addServiceModal.addEventListener('click', (e) => {
if (e.target === addServiceModal) {
addServiceModal.classList.remove('active');
}
});
// 服务类型切换
serviceTypeSelect.addEventListener('change', (e) => {
loadServiceConfigFields(e.target.value);
});
// 添加服务表单
addServiceForm.addEventListener('submit', handleAddService);
// Outlook 批量导入展开/折叠
if (toggleImportBtn) {
toggleImportBtn.addEventListener('click', () => {
const isHidden = outlookImportBody.style.display === 'none';
outlookImportBody.style.display = isHidden ? 'block' : 'none';
toggleImportBtn.textContent = isHidden ? '收起' : '展开';
});
}
// Outlook 批量导入
if (outlookImportBtn) {
outlookImportBtn.addEventListener('click', handleOutlookBatchImport);
}
// 清空导入数据
if (clearImportBtn) {
clearImportBtn.addEventListener('click', () => {
outlookImportData.value = '';
importResult.style.display = 'none';
});
}
// 全选/取消全选
if (selectAllCheckbox) {
selectAllCheckbox.addEventListener('change', (e) => {
const checkboxes = document.querySelectorAll('.service-checkbox');
checkboxes.forEach(cb => cb.checked = e.target.checked);
updateSelectedServices();
});
}
// 批量删除
if (batchDeleteBtn) {
batchDeleteBtn.addEventListener('click', handleBatchDelete);
}
}
// 加载设置
async function loadSettings() {
try {
const response = await fetch(`${API_BASE}/settings`);
const data = await response.json();
// 代理设置
document.getElementById('proxy-enabled').checked = data.proxy?.enabled || false;
document.getElementById('proxy-type').value = data.proxy?.type || 'http';
document.getElementById('proxy-host').value = data.proxy?.host || '127.0.0.1';
document.getElementById('proxy-port').value = data.proxy?.port || 7890;
document.getElementById('proxy-username').value = data.proxy?.username || '';
// 注册配置
document.getElementById('max-retries').value = data.registration?.max_retries || 3;
document.getElementById('timeout').value = data.registration?.timeout || 120;
document.getElementById('password-length').value = data.registration?.default_password_length || 12;
document.getElementById('sleep-min').value = data.registration?.sleep_min || 5;
document.getElementById('sleep-max').value = data.registration?.sleep_max || 30;
} catch (error) {
console.error('加载设置失败:', error);
}
}
// 加载邮箱服务
async function loadEmailServices() {
try {
const response = await fetch(`${API_BASE}/email-services`);
const data = await response.json();
renderEmailServices(data.services);
} catch (error) {
console.error('加载邮箱服务失败:', error);
}
}
// 渲染邮箱服务
function renderEmailServices(services) {
if (services.length === 0) {
emailServicesTable.innerHTML = '<tr><td colspan="7" style="text-align: center;">暂无配置</td></tr>';
batchDeleteBtn.style.display = 'none';
return;
}
emailServicesTable.innerHTML = services.map(service => `
<tr data-service-id="${service.id}">
<td><input type="checkbox" class="service-checkbox" data-id="${service.id}" onchange="updateSelectedServices()"></td>
<td>${escapeHtml(service.name)}</td>
<td>${getServiceTypeText(service.service_type)}</td>
<td><span class="status-badge ${service.enabled ? 'completed' : ''}">${service.enabled ? '已启用' : '已禁用'}</span></td>
<td>${service.priority}</td>
<td>${formatDate(service.last_used)}</td>
<td>
<button class="btn btn-sm btn-secondary" onclick="testService(${service.id})">测试</button>
<button class="btn btn-sm ${service.enabled ? 'btn-warning' : 'btn-primary'}" onclick="toggleService(${service.id}, ${!service.enabled})">
${service.enabled ? '禁用' : '启用'}
</button>
<button class="btn btn-sm btn-danger" onclick="deleteService(${service.id})">删除</button>
</td>
</tr>
`).join('');
// 更新批量删除按钮状态
updateSelectedServices();
}
// 加载数据库信息
async function loadDatabaseInfo() {
try {
const response = await fetch(`${API_BASE}/settings/database`);
const data = await response.json();
document.getElementById('db-size').textContent = `${data.database_size_mb} MB`;
document.getElementById('db-accounts').textContent = data.accounts_count;
document.getElementById('db-services').textContent = data.email_services_count;
document.getElementById('db-tasks').textContent = data.tasks_count;
} catch (error) {
console.error('加载数据库信息失败:', error);
}
}
// 保存代理设置
async function handleSaveProxy(e) {
e.preventDefault();
const data = {
enabled: document.getElementById('proxy-enabled').checked,
type: document.getElementById('proxy-type').value,
host: document.getElementById('proxy-host').value,
port: parseInt(document.getElementById('proxy-port').value),
username: document.getElementById('proxy-username').value || null,
password: document.getElementById('proxy-password').value || null,
};
try {
const response = await fetch(`${API_BASE}/settings/proxy`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (response.ok) {
alert('代理设置已保存');
} else {
const result = await response.json();
alert('保存失败: ' + (result.detail || '未知错误'));
}
} catch (error) {
alert('保存失败: ' + error.message);
}
}
// 测试代理
async function handleTestProxy() {
testProxyBtn.disabled = true;
testProxyBtn.textContent = '测试中...';
try {
// 这里应该调用一个测试代理的 API
// 暂时模拟
await new Promise(resolve => setTimeout(resolve, 1000));
alert('代理测试功能待实现');
} finally {
testProxyBtn.disabled = false;
testProxyBtn.textContent = '测试连接';
}
}
// 保存注册配置
async function handleSaveRegistration(e) {
e.preventDefault();
const data = {
max_retries: parseInt(document.getElementById('max-retries').value),
timeout: parseInt(document.getElementById('timeout').value),
default_password_length: parseInt(document.getElementById('password-length').value),
sleep_min: parseInt(document.getElementById('sleep-min').value),
sleep_max: parseInt(document.getElementById('sleep-max').value),
};
try {
const response = await fetch(`${API_BASE}/settings/registration`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (response.ok) {
alert('注册配置已保存');
} else {
const result = await response.json();
alert('保存失败: ' + (result.detail || '未知错误'));
}
} catch (error) {
alert('保存失败: ' + error.message);
}
}
// 备份数据库
async function handleBackup() {
backupBtn.disabled = true;
backupBtn.textContent = '备份中...';
try {
const response = await fetch(`${API_BASE}/settings/database/backup`, {
method: 'POST',
});
const data = await response.json();
if (response.ok) {
alert(`备份成功: ${data.backup_path}`);
} else {
alert('备份失败: ' + (data.detail || '未知错误'));
}
} catch (error) {
alert('备份失败: ' + error.message);
} finally {
backupBtn.disabled = false;
backupBtn.textContent = '备份数据库';
}
}
// 清理数据
async function handleCleanup() {
if (!confirm('确定要清理过期数据吗?此操作不可恢复。')) {
return;
}
cleanupBtn.disabled = true;
cleanupBtn.textContent = '清理中...';
try {
const response = await fetch(`${API_BASE}/settings/database/cleanup?days=30`, {
method: 'POST',
});
const data = await response.json();
if (response.ok) {
alert(data.message);
loadDatabaseInfo();
} else {
alert('清理失败: ' + (data.detail || '未知错误'));
}
} catch (error) {
alert('清理失败: ' + error.message);
} finally {
cleanupBtn.disabled = false;
cleanupBtn.textContent = '清理过期数据';
}
}
// 加载服务配置字段
async function loadServiceConfigFields(serviceType) {
try {
const response = await fetch(`${API_BASE}/email-services/types`);
const data = await response.json();
const typeInfo = data.types.find(t => t.value === serviceType);
if (!typeInfo) return;
serviceConfigFields.innerHTML = typeInfo.config_fields.map(field => `
<div class="form-group">
<label for="config-${field.name}">${field.label}</label>
<input type="${field.name.includes('password') || field.name.includes('token') ? 'password' : 'text'}"
id="config-${field.name}"
name="${field.name}"
value="${field.default || ''}"
${field.required ? 'required' : ''}>
</div>
`).join('');
} catch (error) {
console.error('加载配置字段失败:', error);
}
}
// 添加邮箱服务
async function handleAddService(e) {
e.preventDefault();
const formData = new FormData(addServiceForm);
const config = {};
serviceConfigFields.querySelectorAll('input').forEach(input => {
config[input.name] = input.value;
});
const data = {
service_type: formData.get('service_type'),
name: formData.get('name'),
config: config,
enabled: true,
priority: 0,
};
try {
const response = await fetch(`${API_BASE}/email-services`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (response.ok) {
addServiceModal.classList.remove('active');
addServiceForm.reset();
loadEmailServices();
alert('邮箱服务已添加');
} else {
const result = await response.json();
alert('添加失败: ' + (result.detail || '未知错误'));
}
} catch (error) {
alert('添加失败: ' + error.message);
}
}
// 测试服务
async function testService(id) {
try {
const response = await fetch(`${API_BASE}/email-services/${id}/test`, {
method: 'POST',
});
const data = await response.json();
if (data.success) {
alert('服务连接正常');
} else {
alert('服务连接失败: ' + data.message);
}
} catch (error) {
alert('测试失败: ' + error.message);
}
}
// 切换服务状态
async function toggleService(id, enabled) {
try {
const endpoint = enabled ? 'enable' : 'disable';
const response = await fetch(`${API_BASE}/email-services/${id}/${endpoint}`, {
method: 'POST',
});
if (response.ok) {
loadEmailServices();
} else {
const data = await response.json();
alert('操作失败: ' + (data.detail || '未知错误'));
}
} catch (error) {
alert('操作失败: ' + error.message);
}
}
// 删除服务
async function deleteService(id) {
if (!confirm('确定要删除此邮箱服务配置吗?')) {
return;
}
try {
const response = await fetch(`${API_BASE}/email-services/${id}`, {
method: 'DELETE',
});
if (response.ok) {
loadEmailServices();
} else {
const data = await response.json();
alert('删除失败: ' + (data.detail || '未知错误'));
}
} catch (error) {
alert('删除失败: ' + error.message);
}
}
// 工具函数
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function getServiceTypeText(type) {
const typeMap = {
'tempmail': 'Tempmail.lol',
'outlook': 'Outlook',
'custom_domain': '自定义域名',
};
return typeMap[type] || type;
}
function formatDate(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleString('zh-CN');
}

134
templates/accounts.html Normal file
View File

@@ -0,0 +1,134 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>账号管理 - OpenAI 注册系统</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<div class="container">
<!-- 导航栏 -->
<nav class="navbar">
<div class="nav-brand">
<h1>OpenAI 注册系统</h1>
</div>
<div class="nav-links">
<a href="/" class="nav-link">注册</a>
<a href="/accounts" class="nav-link active">账号管理</a>
<a href="/settings" class="nav-link">设置</a>
</div>
</nav>
<!-- 主内容区 -->
<main class="main-content">
<div class="page-header">
<h2>账号管理</h2>
<p class="subtitle">查看和管理已注册的 OpenAI 账号</p>
</div>
<!-- 统计卡片 -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value" id="total-accounts">0</div>
<div class="stat-label">总账号数</div>
</div>
<div class="stat-card success">
<div class="stat-value" id="active-accounts">0</div>
<div class="stat-label">活跃账号</div>
</div>
<div class="stat-card warning">
<div class="stat-value" id="expired-accounts">0</div>
<div class="stat-label">过期账号</div>
</div>
<div class="stat-card danger">
<div class="stat-value" id="failed-accounts">0</div>
<div class="stat-label">失败账号</div>
</div>
</div>
<!-- 工具栏 -->
<div class="card">
<div class="card-body toolbar">
<div class="toolbar-left">
<select id="filter-status" class="form-select">
<option value="">全部状态</option>
<option value="active">活跃</option>
<option value="expired">过期</option>
<option value="banned">封禁</option>
<option value="failed">失败</option>
</select>
<select id="filter-service" class="form-select">
<option value="">全部邮箱服务</option>
<option value="tempmail">Tempmail</option>
<option value="outlook">Outlook</option>
<option value="custom_domain">自定义域名</option>
</select>
<input type="text" id="search-input" class="form-input" placeholder="搜索邮箱...">
</div>
<div class="toolbar-right">
<button class="btn btn-secondary" id="refresh-btn">刷新</button>
<button class="btn btn-danger" id="batch-delete-btn" disabled>批量删除</button>
<div class="dropdown">
<button class="btn btn-primary dropdown-toggle" id="export-btn">
导出
</button>
<div class="dropdown-menu" id="export-menu">
<a href="#" class="dropdown-item" data-format="json">导出 JSON</a>
<a href="#" class="dropdown-item" data-format="csv">导出 CSV</a>
</div>
</div>
</div>
</div>
</div>
<!-- 账号列表 -->
<div class="card">
<div class="card-body">
<table class="data-table">
<thead>
<tr>
<th><input type="checkbox" id="select-all"></th>
<th>ID</th>
<th>邮箱</th>
<th>邮箱服务</th>
<th>状态</th>
<th>注册时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="accounts-table">
<!-- 动态加载 -->
</tbody>
</table>
<!-- 分页 -->
<div class="pagination" id="pagination">
<button class="btn btn-sm" id="prev-page" disabled>上一页</button>
<span id="page-info">第 1 页</span>
<button class="btn btn-sm" id="next-page">下一页</button>
</div>
</div>
</div>
</main>
</div>
<!-- 详情模态框 -->
<div class="modal" id="detail-modal">
<div class="modal-content">
<div class="modal-header">
<h3>账号详情</h3>
<button class="modal-close" id="close-modal">&times;</button>
</div>
<div class="modal-body" id="modal-body">
<!-- 动态加载 -->
</div>
</div>
</div>
<script src="/static/js/accounts.js"></script>
</body>
</html>

153
templates/index.html Normal file
View File

@@ -0,0 +1,153 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OpenAI/Codex CLI 自动注册系统</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<div class="container">
<!-- 导航栏 -->
<nav class="navbar">
<div class="nav-brand">
<h1>OpenAI 注册系统</h1>
</div>
<div class="nav-links">
<a href="/" class="nav-link active">注册</a>
<a href="/accounts" class="nav-link">账号管理</a>
<a href="/settings" class="nav-link">设置</a>
</div>
</nav>
<!-- 主内容区 -->
<main class="main-content">
<div class="page-header">
<h2>注册控制台</h2>
<p class="subtitle">启动新的 OpenAI/Codex CLI 账号注册任务</p>
</div>
<!-- 注册表单 -->
<div class="card">
<div class="card-header">
<h3>新建注册任务</h3>
</div>
<div class="card-body">
<form id="registration-form">
<div class="form-row">
<div class="form-group">
<label for="email-service">邮箱服务</label>
<select id="email-service" name="email_service" required>
<option value="tempmail">Tempmail.lol (临时邮箱)</option>
<option value="outlook">Outlook</option>
<option value="custom_domain">自定义域名</option>
</select>
</div>
<div class="form-group">
<label for="proxy">代理地址 (可选)</label>
<input type="text" id="proxy" name="proxy" placeholder="http://127.0.0.1:7890">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="reg-mode">注册模式</label>
<select id="reg-mode" name="reg_mode">
<option value="single">单次注册</option>
<option value="batch">批量注册</option>
</select>
</div>
<div class="form-group" id="batch-count-group" style="display: none;">
<label for="batch-count">注册数量 (1-100)</label>
<input type="number" id="batch-count" name="batch_count" min="1" max="100" value="5">
</div>
</div>
<div id="batch-options" style="display: none;">
<div class="form-row">
<div class="form-group">
<label for="interval-min">最小间隔 (秒)</label>
<input type="number" id="interval-min" name="interval_min" min="0" max="300" value="5">
</div>
<div class="form-group">
<label for="interval-max">最大间隔 (秒)</label>
<input type="number" id="interval-max" name="interval_max" min="1" max="600" value="30">
</div>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" id="start-btn">
开始注册
</button>
<button type="button" class="btn btn-secondary" id="cancel-btn" disabled>
取消任务
</button>
</div>
</form>
</div>
</div>
<!-- 批量任务状态 -->
<div class="card" id="batch-status-card" style="display: none;">
<div class="card-header">
<h3>批量任务进度</h3>
<span id="batch-progress" class="status-badge">0/0</span>
</div>
<div class="card-body">
<div class="progress-bar-container">
<div id="progress-bar" class="progress-bar" style="width: 0%"></div>
</div>
<div class="batch-stats">
<span>成功: <strong id="batch-success">0</strong></span>
<span>失败: <strong id="batch-failed">0</strong></span>
<span>剩余: <strong id="batch-remaining">0</strong></span>
</div>
</div>
</div>
<!-- 任务状态 -->
<div class="card" id="task-status-card" style="display: none;">
<div class="card-header">
<h3>任务状态</h3>
<span id="task-status-badge" class="status-badge">等待中</span>
</div>
<div class="card-body">
<div class="task-info">
<div class="info-row">
<span class="label">任务 ID:</span>
<span id="task-id" class="value">-</span>
</div>
<div class="info-row">
<span class="label">邮箱:</span>
<span id="task-email" class="value">-</span>
</div>
<div class="info-row">
<span class="label">状态:</span>
<span id="task-status" class="value">-</span>
</div>
</div>
</div>
</div>
<!-- 控制台日志 -->
<div class="card">
<div class="card-header">
<h3>控制台日志</h3>
<button class="btn btn-sm" id="clear-log-btn">清空</button>
</div>
<div class="card-body">
<div id="console-log" class="console-log">
<div class="log-line info">[*] 系统就绪,等待开始注册...</div>
</div>
</div>
</div>
</main>
</div>
<script src="/static/js/app.js"></script>
</body>
</html>

286
templates/settings.html Normal file
View File

@@ -0,0 +1,286 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>设置 - OpenAI 注册系统</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<div class="container">
<!-- 导航栏 -->
<nav class="navbar">
<div class="nav-brand">
<h1>OpenAI 注册系统</h1>
</div>
<div class="nav-links">
<a href="/" class="nav-link">注册</a>
<a href="/accounts" class="nav-link">账号管理</a>
<a href="/settings" class="nav-link active">设置</a>
</div>
</nav>
<!-- 主内容区 -->
<main class="main-content">
<div class="page-header">
<h2>系统设置</h2>
<p class="subtitle">配置代理、邮箱服务和系统参数</p>
</div>
<!-- 设置标签页 -->
<div class="tabs">
<button class="tab-btn active" data-tab="proxy">代理设置</button>
<button class="tab-btn" data-tab="email">邮箱服务</button>
<button class="tab-btn" data-tab="registration">注册配置</button>
<button class="tab-btn" data-tab="database">数据库</button>
</div>
<!-- 代理设置 -->
<div class="tab-content active" id="proxy-tab">
<div class="card">
<div class="card-header">
<h3>代理配置</h3>
</div>
<div class="card-body">
<form id="proxy-form">
<div class="form-group">
<label>
<input type="checkbox" id="proxy-enabled" name="enabled">
启用代理
</label>
</div>
<div class="form-row">
<div class="form-group">
<label for="proxy-type">代理类型</label>
<select id="proxy-type" name="type">
<option value="http">HTTP</option>
<option value="socks5">SOCKS5</option>
</select>
</div>
<div class="form-group">
<label for="proxy-host">主机地址</label>
<input type="text" id="proxy-host" name="host" value="127.0.0.1">
</div>
<div class="form-group">
<label for="proxy-port">端口</label>
<input type="number" id="proxy-port" name="port" value="7890">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="proxy-username">用户名 (可选)</label>
<input type="text" id="proxy-username" name="username">
</div>
<div class="form-group">
<label for="proxy-password">密码 (可选)</label>
<input type="password" id="proxy-password" name="password">
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">保存设置</button>
<button type="button" class="btn btn-secondary" id="test-proxy-btn">测试连接</button>
</div>
</form>
</div>
</div>
</div>
<!-- 邮箱服务 -->
<div class="tab-content" id="email-tab">
<!-- Outlook 批量导入 -->
<div class="card" id="outlook-import-card">
<div class="card-header">
<h3>Outlook 批量导入</h3>
<button class="btn btn-sm btn-secondary" id="toggle-import-btn">展开</button>
</div>
<div class="card-body" id="outlook-import-body" style="display: none;">
<div class="import-info">
<p><strong>支持格式:</strong></p>
<ul>
<li><code>邮箱----密码</code> (密码认证)</li>
<li><code>邮箱----密码----client_id----refresh_token</code> XOAUTH2 认证,推荐)</li>
</ul>
<p>每行一个账户,使用四个连字符(----)分隔字段。以 # 开头的行将被忽略。</p>
</div>
<div class="form-group">
<label for="outlook-import-data">批量导入数据</label>
<textarea id="outlook-import-data" rows="8" placeholder="example@outlook.com----password123&#10;test@outlook.com----password456----client_id----refresh_token"></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label>
<input type="checkbox" id="outlook-import-enabled" checked>
导入后启用
</label>
</div>
<div class="form-group">
<label for="outlook-import-priority">优先级</label>
<input type="number" id="outlook-import-priority" value="0" min="0">
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-primary" id="outlook-import-btn">开始导入</button>
<button type="button" class="btn btn-secondary" id="clear-import-btn">清空</button>
</div>
<div id="import-result" style="display: none; margin-top: 16px;">
<div class="import-stats"></div>
<div class="import-errors" style="margin-top: 8px;"></div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>邮箱服务配置</h3>
<div class="toolbar-left">
<button class="btn btn-sm btn-primary" id="add-email-service-btn">添加服务</button>
<button class="btn btn-sm btn-danger" id="batch-delete-btn" style="display: none;">批量删除</button>
</div>
</div>
<div class="card-body">
<table class="data-table">
<thead>
<tr>
<th><input type="checkbox" id="select-all-services"></th>
<th>名称</th>
<th>类型</th>
<th>状态</th>
<th>优先级</th>
<th>最后使用</th>
<th>操作</th>
</tr>
</thead>
<tbody id="email-services-table">
<!-- 动态加载 -->
</tbody>
</table>
</div>
</div>
</div>
<!-- 注册配置 -->
<div class="tab-content" id="registration-tab">
<div class="card">
<div class="card-header">
<h3>注册配置</h3>
</div>
<div class="card-body">
<form id="registration-form">
<div class="form-row">
<div class="form-group">
<label for="max-retries">最大重试次数</label>
<input type="number" id="max-retries" name="max_retries" value="3" min="1" max="10">
</div>
<div class="form-group">
<label for="timeout">超时时间 (秒)</label>
<input type="number" id="timeout" name="timeout" value="120" min="30" max="600">
</div>
<div class="form-group">
<label for="password-length">密码长度</label>
<input type="number" id="password-length" name="default_password_length" value="12" min="8" max="32">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="sleep-min">最小等待时间 (秒)</label>
<input type="number" id="sleep-min" name="sleep_min" value="5" min="1" max="60">
</div>
<div class="form-group">
<label for="sleep-max">最大等待时间 (秒)</label>
<input type="number" id="sleep-max" name="sleep_max" value="30" min="5" max="120">
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">保存设置</button>
</div>
</form>
</div>
</div>
</div>
<!-- 数据库 -->
<div class="tab-content" id="database-tab">
<div class="card">
<div class="card-header">
<h3>数据库信息</h3>
</div>
<div class="card-body">
<div class="info-grid">
<div class="info-item">
<span class="label">数据库大小:</span>
<span id="db-size" class="value">-</span>
</div>
<div class="info-item">
<span class="label">账号数量:</span>
<span id="db-accounts" class="value">-</span>
</div>
<div class="info-item">
<span class="label">邮箱服务数量:</span>
<span id="db-services" class="value">-</span>
</div>
<div class="info-item">
<span class="label">任务记录数量:</span>
<span id="db-tasks" class="value">-</span>
</div>
</div>
<div class="form-actions">
<button class="btn btn-secondary" id="backup-btn">备份数据库</button>
<button class="btn btn-warning" id="cleanup-btn">清理过期数据</button>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- 添加邮箱服务模态框 -->
<div class="modal" id="add-service-modal">
<div class="modal-content">
<div class="modal-header">
<h3>添加邮箱服务</h3>
<button class="modal-close" id="close-service-modal">&times;</button>
</div>
<div class="modal-body">
<form id="add-service-form">
<div class="form-group">
<label for="service-type">服务类型</label>
<select id="service-type" name="service_type" required>
<option value="tempmail">Tempmail.lol</option>
<option value="outlook">Outlook</option>
<option value="custom_domain">自定义域名</option>
</select>
</div>
<div class="form-group">
<label for="service-name">服务名称</label>
<input type="text" id="service-name" name="name" required>
</div>
<div id="service-config-fields">
<!-- 根据类型动态加载 -->
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">添加</button>
<button type="button" class="btn btn-secondary" id="cancel-add-service">取消</button>
</div>
</form>
</div>
</div>
</div>
<script src="/static/js/settings.js"></script>
</body>
</html>