mirror of
https://github.com/cnlimiter/codex-register.git
synced 2026-05-11 18:10:53 +08:00
2
This commit is contained in:
56
.gitignore
vendored
Normal file
56
.gitignore
vendored
Normal 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/
|
||||||
BIN
requirements.txt
BIN
requirements.txt
Binary file not shown.
24
src/__init__.py
Normal file
24
src/__init__.py
Normal 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
34
src/config/__init__.py
Normal 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
265
src/config/constants.py
Normal 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
168
src/config/settings.py
Normal 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
32
src/core/__init__.py
Normal 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
420
src/core/http_client.py
Normal 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
336
src/core/oauth.py
Normal 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
723
src/core/register.py
Normal 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
566
src/core/utils.py
Normal 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
20
src/database/__init__.py
Normal 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
372
src/database/crud.py
Normal 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
133
src/database/init_db.py
Normal 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
113
src/database/models.py
Normal 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
115
src/database/session.py
Normal 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
32
src/services/__init__.py
Normal 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
384
src/services/base.py
Normal 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)
|
||||||
528
src/services/custom_domain.py
Normal file
528
src/services/custom_domain.py
Normal 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
610
src/services/outlook.py
Normal 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, # 对于 Outlook,service_id 就是邮箱地址
|
||||||
|
"account": {
|
||||||
|
"email": account.email,
|
||||||
|
"has_oauth": account.has_oauth()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"选择 Outlook 账户: {account.email}")
|
||||||
|
self.update_status(True)
|
||||||
|
return email_info
|
||||||
|
|
||||||
|
def get_verification_code(
|
||||||
|
self,
|
||||||
|
email: str,
|
||||||
|
email_id: str = None,
|
||||||
|
timeout: int = 120,
|
||||||
|
pattern: str = OTP_CODE_PATTERN
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
从 Outlook 邮箱获取验证码
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: 邮箱地址
|
||||||
|
email_id: 未使用(对于 Outlook,email 就是标识)
|
||||||
|
timeout: 超时时间(秒)
|
||||||
|
pattern: 验证码正则表达式
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
验证码字符串,如果超时或未找到返回 None
|
||||||
|
"""
|
||||||
|
# 查找对应的账户
|
||||||
|
account = None
|
||||||
|
for acc in self.accounts:
|
||||||
|
if acc.email.lower() == email.lower():
|
||||||
|
account = acc
|
||||||
|
break
|
||||||
|
|
||||||
|
if not account:
|
||||||
|
self.update_status(False, EmailServiceError(f"未找到邮箱对应的账户: {email}"))
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.info(f"正在从 Outlook 邮箱 {email} 获取验证码...")
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
last_check_time = 0
|
||||||
|
check_count = 0
|
||||||
|
|
||||||
|
while time.time() - start_time < timeout:
|
||||||
|
check_count += 1
|
||||||
|
|
||||||
|
# 控制检查频率
|
||||||
|
if time.time() - last_check_time < 3:
|
||||||
|
time.sleep(1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
with self._imap_semaphore:
|
||||||
|
with OutlookIMAPClient(
|
||||||
|
account,
|
||||||
|
host=self.config["imap_host"],
|
||||||
|
port=self.config["imap_port"],
|
||||||
|
timeout=10
|
||||||
|
) as client:
|
||||||
|
emails = client.get_recent_emails(count=10, only_unseen=True)
|
||||||
|
|
||||||
|
for mail in emails:
|
||||||
|
# 检查是否是 OpenAI 相关邮件
|
||||||
|
if not self._is_oai_mail(mail):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 提取验证码
|
||||||
|
content = f"{mail.get('from', '')} {mail.get('subject', '')} {mail.get('body', '')}"
|
||||||
|
match = re.search(pattern, content)
|
||||||
|
if match:
|
||||||
|
code = match.group(1)
|
||||||
|
logger.info(f"从 Outlook 邮箱 {email} 找到验证码: {code}")
|
||||||
|
|
||||||
|
# 可选:标记邮件为已读(避免重复获取)
|
||||||
|
# 注意:这需要修改 IMAP 客户端的实现
|
||||||
|
|
||||||
|
self.update_status(True)
|
||||||
|
return code
|
||||||
|
|
||||||
|
last_check_time = time.time()
|
||||||
|
|
||||||
|
if check_count % 5 == 0:
|
||||||
|
logger.debug(f"检查 {email} 的验证码,已检查 {check_count} 次")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"检查 Outlook 邮箱 {email} 时出错: {e}")
|
||||||
|
last_check_time = time.time()
|
||||||
|
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
logger.warning(f"等待验证码超时: {email}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def list_emails(self, **kwargs) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
列出所有可用的 Outlook 账户
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
账户列表
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"email": account.email,
|
||||||
|
"id": account.email,
|
||||||
|
"has_oauth": account.has_oauth(),
|
||||||
|
"type": "outlook"
|
||||||
|
}
|
||||||
|
for account in self.accounts
|
||||||
|
]
|
||||||
|
|
||||||
|
def delete_email(self, email_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
删除邮箱(对于 Outlook,不支持删除账户)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email_id: 邮箱地址
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
False(Outlook 不支持删除账户)
|
||||||
|
"""
|
||||||
|
logger.warning(f"Outlook 服务不支持删除账户: {email_id}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def check_health(self) -> bool:
|
||||||
|
"""检查 Outlook 服务是否可用"""
|
||||||
|
if not self.accounts:
|
||||||
|
self.update_status(False, EmailServiceError("没有配置的账户"))
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 测试第一个账户的连接
|
||||||
|
test_account = self.accounts[0]
|
||||||
|
try:
|
||||||
|
with self._imap_semaphore:
|
||||||
|
with OutlookIMAPClient(
|
||||||
|
test_account,
|
||||||
|
host=self.config["imap_host"],
|
||||||
|
port=self.config["imap_port"],
|
||||||
|
timeout=10
|
||||||
|
) as client:
|
||||||
|
# 尝试列出邮箱(快速测试)
|
||||||
|
client._conn.select("INBOX", readonly=True)
|
||||||
|
self.update_status(True)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Outlook 健康检查失败 ({test_account.email}): {e}")
|
||||||
|
self.update_status(False, e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _is_oai_mail(self, mail: Dict[str, Any]) -> bool:
|
||||||
|
"""判断是否为 OpenAI 相关邮件"""
|
||||||
|
combined = f"{mail.get('from', '')} {mail.get('subject', '')} {mail.get('body', '')}".lower()
|
||||||
|
keywords = ["openai", "chatgpt", "verification", "验证码", "code"]
|
||||||
|
return any(keyword in combined for keyword in keywords)
|
||||||
|
|
||||||
|
def get_account_stats(self) -> Dict[str, Any]:
|
||||||
|
"""获取账户统计信息"""
|
||||||
|
total = len(self.accounts)
|
||||||
|
oauth_count = sum(1 for acc in self.accounts if acc.has_oauth())
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_accounts": total,
|
||||||
|
"oauth_accounts": oauth_count,
|
||||||
|
"password_accounts": total - oauth_count,
|
||||||
|
"accounts": [
|
||||||
|
{
|
||||||
|
"email": acc.email,
|
||||||
|
"has_oauth": acc.has_oauth()
|
||||||
|
}
|
||||||
|
for acc in self.accounts
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
def add_account(self, account_config: Dict[str, Any]) -> bool:
|
||||||
|
"""添加新的 Outlook 账户"""
|
||||||
|
try:
|
||||||
|
account = OutlookAccount.from_config(account_config)
|
||||||
|
if not account.validate():
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.accounts.append(account)
|
||||||
|
self._account_locks[account.email] = threading.Lock()
|
||||||
|
logger.info(f"添加 Outlook 账户: {account.email}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"添加 Outlook 账户失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def remove_account(self, email: str) -> bool:
|
||||||
|
"""移除 Outlook 账户"""
|
||||||
|
for i, acc in enumerate(self.accounts):
|
||||||
|
if acc.email.lower() == email.lower():
|
||||||
|
self.accounts.pop(i)
|
||||||
|
self._account_locks.pop(email, None)
|
||||||
|
logger.info(f"移除 Outlook 账户: {email}")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
398
src/services/tempmail.py
Normal file
398
src/services/tempmail.py
Normal 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
7
src/web/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
Web UI 应用模块
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .app import app, create_app
|
||||||
|
|
||||||
|
__all__ = ['app', 'create_app']
|
||||||
104
src/web/app.py
Normal file
104
src/web/app.py
Normal 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()
|
||||||
18
src/web/routes/__init__.py
Normal file
18
src/web/routes/__init__.py
Normal 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
369
src/web/routes/accounts.py
Normal 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}
|
||||||
|
}
|
||||||
437
src/web/routes/email_services.py
Normal file
437
src/web/routes/email_services.py
Normal 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} 个服务"}
|
||||||
500
src/web/routes/registration.py
Normal file
500
src/web/routes/registration.py
Normal 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
294
src/web/routes/settings.py
Normal 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
605
static/css/style.css
Normal 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
381
static/js/accounts.js
Normal 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
360
static/js/app.js
Normal 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
513
static/js/settings.js
Normal 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
134
templates/accounts.html
Normal 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">×</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
153
templates/index.html
Normal 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
286
templates/settings.html
Normal 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 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">×</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>
|
||||||
Reference in New Issue
Block a user