This commit is contained in:
cnlimiter
2026-03-14 20:36:03 +08:00
parent 0688f4ca7e
commit 6891b9f11d
22 changed files with 3882 additions and 299 deletions

View File

@@ -78,6 +78,11 @@ def create_app() -> FastAPI:
"""账号管理页面"""
return templates.TemplateResponse("accounts.html", {"request": request})
@app.get("/email-services", response_class=HTMLResponse)
async def email_services_page(request: Request):
"""邮箱服务管理页面"""
return templates.TemplateResponse("email_services.html", {"request": request})
@app.get("/settings", response_class=HTMLResponse)
async def settings_page(request: Request):
"""设置页面"""

View File

@@ -26,10 +26,14 @@ class AccountResponse(BaseModel):
"""账号响应模型"""
id: int
email: str
password: Optional[str] = None
client_id: Optional[str] = None
email_service: str
account_id: Optional[str] = None
workspace_id: Optional[str] = None
registered_at: Optional[str] = None
last_refresh: Optional[str] = None
expires_at: Optional[str] = None
status: str
proxy_used: Optional[str] = None
created_at: Optional[str] = None
@@ -69,10 +73,14 @@ def account_to_response(account: Account) -> AccountResponse:
return AccountResponse(
id=account.id,
email=account.email,
password=account.password,
client_id=account.client_id,
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,
last_refresh=account.last_refresh.isoformat() if account.last_refresh else None,
expires_at=account.expires_at.isoformat() if account.expires_at else None,
status=account.status,
proxy_used=account.proxy_used,
created_at=account.created_at.isoformat() if account.created_at else None,
@@ -260,13 +268,18 @@ async def export_accounts_json(
for acc in accounts:
export_data.append({
"email": acc.email,
"password": acc.password,
"client_id": acc.client_id,
"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,
"session_token": acc.session_token,
"email_service": acc.email_service,
"registered_at": acc.registered_at.isoformat() if acc.registered_at else None,
"last_refresh": acc.last_refresh.isoformat() if acc.last_refresh else None,
"expires_at": acc.expires_at.isoformat() if acc.expires_at else None,
"status": acc.status,
})
@@ -310,9 +323,10 @@ async def export_accounts_csv(
# 写入表头
writer.writerow([
"ID", "Email", "Account ID", "Workspace ID",
"Access Token", "Refresh Token", "ID Token",
"Email Service", "Status", "Registered At"
"ID", "Email", "Password", "Client ID",
"Account ID", "Workspace ID",
"Access Token", "Refresh Token", "ID Token", "Session Token",
"Email Service", "Status", "Registered At", "Last Refresh", "Expires At"
])
# 写入数据
@@ -320,14 +334,19 @@ async def export_accounts_csv(
writer.writerow([
acc.id,
acc.email,
acc.password or "",
acc.client_id or "",
acc.account_id or "",
acc.workspace_id or "",
acc.access_token or "",
acc.refresh_token or "",
acc.id_token or "",
acc.session_token or "",
acc.email_service,
acc.status,
acc.registered_at.isoformat() if acc.registered_at else ""
acc.registered_at.isoformat() if acc.registered_at else "",
acc.last_refresh.isoformat() if acc.last_refresh else "",
acc.expires_at.isoformat() if acc.expires_at else ""
])
# 生成文件名
@@ -367,3 +386,123 @@ async def get_accounts_stats():
"by_status": {status: count for status, count in status_stats},
"by_email_service": {service: count for service, count in service_stats}
}
# ============== Token 刷新相关 ==============
class TokenRefreshRequest(BaseModel):
"""Token 刷新请求"""
proxy: Optional[str] = None
class BatchRefreshRequest(BaseModel):
"""批量刷新请求"""
ids: List[int]
proxy: Optional[str] = None
class TokenValidateRequest(BaseModel):
"""Token 验证请求"""
proxy: Optional[str] = None
class BatchValidateRequest(BaseModel):
"""批量验证请求"""
ids: List[int]
proxy: Optional[str] = None
@router.post("/{account_id}/refresh")
async def refresh_account_token(account_id: int, request: TokenRefreshRequest = None):
"""刷新单个账号的 Token"""
from ...core.token_refresh import refresh_account_token as do_refresh
proxy = request.proxy if request else None
result = do_refresh(account_id, proxy)
if result.success:
return {
"success": True,
"message": "Token 刷新成功",
"expires_at": result.expires_at.isoformat() if result.expires_at else None
}
else:
return {
"success": False,
"error": result.error_message
}
@router.post("/batch-refresh")
async def batch_refresh_tokens(request: BatchRefreshRequest, background_tasks: BackgroundTasks):
"""批量刷新账号 Token"""
from ...core.token_refresh import refresh_account_token as do_refresh
results = {
"success_count": 0,
"failed_count": 0,
"errors": []
}
for account_id in request.ids:
try:
result = do_refresh(account_id, request.proxy)
if result.success:
results["success_count"] += 1
else:
results["failed_count"] += 1
results["errors"].append({"id": account_id, "error": result.error_message})
except Exception as e:
results["failed_count"] += 1
results["errors"].append({"id": account_id, "error": str(e)})
return results
@router.post("/{account_id}/validate")
async def validate_account_token(account_id: int, request: TokenValidateRequest = None):
"""验证单个账号的 Token 有效性"""
from ...core.token_refresh import validate_account_token as do_validate
proxy = request.proxy if request else None
is_valid, error = do_validate(account_id, proxy)
return {
"id": account_id,
"valid": is_valid,
"error": error
}
@router.post("/batch-validate")
async def batch_validate_tokens(request: BatchValidateRequest):
"""批量验证账号 Token 有效性"""
from ...core.token_refresh import validate_account_token as do_validate
results = {
"valid_count": 0,
"invalid_count": 0,
"details": []
}
for account_id in request.ids:
try:
is_valid, error = do_validate(account_id, request.proxy)
results["details"].append({
"id": account_id,
"valid": is_valid,
"error": error
})
if is_valid:
results["valid_count"] += 1
else:
results["invalid_count"] += 1
except Exception as e:
results["invalid_count"] += 1
results["details"].append({
"id": account_id,
"valid": False,
"error": str(e)
})
return results

View File

@@ -43,6 +43,7 @@ class EmailServiceResponse(BaseModel):
name: str
enabled: bool
priority: int
config: Optional[Dict[str, Any]] = None # 过滤敏感信息后的配置
last_used: Optional[str] = None
created_at: Optional[str] = None
updated_at: Optional[str] = None
@@ -82,6 +83,29 @@ class OutlookBatchImportResponse(BaseModel):
# ============== Helper Functions ==============
# 敏感字段列表,返回响应时需要过滤
SENSITIVE_FIELDS = {'password', 'api_key', 'refresh_token', 'access_token'}
def filter_sensitive_config(config: Dict[str, Any]) -> Dict[str, Any]:
"""过滤敏感配置信息"""
if not config:
return {}
filtered = {}
for key, value in config.items():
if key in SENSITIVE_FIELDS:
# 敏感字段不返回,但标记是否存在
filtered[f"has_{key}"] = bool(value)
else:
filtered[key] = value
# 为 Outlook 计算是否有 OAuth
if config.get('client_id') and config.get('refresh_token'):
filtered['has_oauth'] = True
return filtered
def service_to_response(service: EmailServiceModel) -> EmailServiceResponse:
"""转换服务模型为响应"""
return EmailServiceResponse(
@@ -90,6 +114,7 @@ def service_to_response(service: EmailServiceModel) -> EmailServiceResponse:
name=service.name,
enabled=service.enabled,
priority=service.priority,
config=filter_sensitive_config(service.config),
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,
@@ -98,6 +123,39 @@ def service_to_response(service: EmailServiceModel) -> EmailServiceResponse:
# ============== API Endpoints ==============
@router.get("/stats")
async def get_email_services_stats():
"""获取邮箱服务统计信息"""
with get_db() as db:
from sqlalchemy import func
# 按类型统计
type_stats = db.query(
EmailServiceModel.service_type,
func.count(EmailServiceModel.id)
).group_by(EmailServiceModel.service_type).all()
# 启用数量
enabled_count = db.query(func.count(EmailServiceModel.id)).filter(
EmailServiceModel.enabled == True
).scalar()
stats = {
'outlook_count': 0,
'custom_count': 0,
'tempmail_available': True, # 临时邮箱始终可用
'enabled_count': enabled_count
}
for service_type, count in type_stats:
if service_type == 'outlook':
stats['outlook_count'] = count
elif service_type == 'custom_domain':
stats['custom_count'] = count
return stats
@router.get("/types")
async def get_service_types():
"""获取支持的邮箱服务类型"""
@@ -435,3 +493,36 @@ async def batch_delete_outlook(service_ids: List[int]):
db.commit()
return {"success": True, "deleted": deleted, "message": f"已删除 {deleted} 个服务"}
# ============== 临时邮箱测试 ==============
class TempmailTestRequest(BaseModel):
"""临时邮箱测试请求"""
api_url: Optional[str] = None
@router.post("/test-tempmail")
async def test_tempmail_service(request: TempmailTestRequest):
"""测试临时邮箱服务是否可用"""
try:
from ...services import EmailServiceFactory, EmailServiceType
from ...config.settings import get_settings
settings = get_settings()
base_url = request.api_url or settings.tempmail_base_url
config = {"base_url": base_url}
tempmail = EmailServiceFactory.create(EmailServiceType.TEMPMAIL, config)
# 检查服务健康状态
health = tempmail.check_health()
if health:
return {"success": True, "message": "临时邮箱连接正常"}
else:
return {"success": False, "message": "临时邮箱连接失败"}
except Exception as e:
logger.error(f"测试临时邮箱失败: {e}")
return {"success": False, "message": f"测试失败: {str(e)}"}

View File

@@ -7,14 +7,14 @@ import logging
import uuid
import random
from datetime import datetime
from typing import List, Optional, Dict
from typing import List, Optional, Dict, Tuple
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 ...database.models import RegistrationTask, Proxy
from ...core.register import RegistrationEngine, RegistrationResult
from ...services import EmailServiceFactory, EmailServiceType
from ...config.settings import get_settings
@@ -28,6 +28,38 @@ running_tasks: dict = {}
batch_tasks: Dict[str, dict] = {}
# ============== Proxy Helper Functions ==============
def get_proxy_for_registration(db) -> Tuple[Optional[str], Optional[int]]:
"""
获取用于注册的代理
策略:
1. 优先从代理列表中随机选择一个启用的代理
2. 如果代理列表为空,使用系统设置中的默认代理
Returns:
Tuple[proxy_url, proxy_id]: 代理 URL 和代理 ID如果来自代理列表
"""
# 先尝试从代理列表中获取
proxy = crud.get_random_proxy(db)
if proxy:
return proxy.proxy_url, proxy.id
# 代理列表为空,使用系统设置中的默认代理
settings = get_settings()
if settings.proxy_enabled and settings.proxy_url:
return settings.proxy_url, None
return None, None
def update_proxy_usage(db, proxy_id: Optional[int]):
"""更新代理的使用时间"""
if proxy_id:
crud.update_proxy_last_used(db, proxy_id)
# ============== Pydantic Models ==============
class RegistrationTaskCreate(BaseModel):
@@ -114,6 +146,20 @@ async def run_registration_task(task_uuid: str, email_service_type: str, proxy:
logger.error(f"任务不存在: {task_uuid}")
return
# 确定使用的代理
# 如果前端传入了代理参数,使用传入的
# 否则从代理列表或系统设置中获取
actual_proxy_url = proxy
proxy_id = None
if not actual_proxy_url:
actual_proxy_url, proxy_id = get_proxy_for_registration(db)
if actual_proxy_url:
logger.info(f"任务 {task_uuid} 使用代理: {actual_proxy_url[:50]}...")
# 更新任务的代理记录
crud.update_registration_task(db, task_uuid, proxy=actual_proxy_url)
# 创建邮箱服务
service_type = EmailServiceType(email_service_type)
settings = get_settings()
@@ -140,7 +186,7 @@ async def run_registration_task(task_uuid: str, email_service_type: str, proxy:
"base_url": settings.tempmail_base_url,
"timeout": settings.tempmail_timeout,
"max_retries": settings.tempmail_max_retries,
"proxy_url": proxy,
"proxy_url": actual_proxy_url,
}
elif service_type == EmailServiceType.CUSTOM_DOMAIN:
# 检查数据库中是否有可用的自定义域名服务
@@ -158,7 +204,7 @@ async def run_registration_task(task_uuid: str, email_service_type: str, proxy:
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,
"proxy_url": actual_proxy_url,
}
else:
raise ValueError("没有可用的自定义域名邮箱服务,请先在设置中配置")
@@ -188,7 +234,7 @@ async def run_registration_task(task_uuid: str, email_service_type: str, proxy:
engine = RegistrationEngine(
email_service=email_service,
proxy_url=proxy,
proxy_url=actual_proxy_url,
callback_logger=log_callback,
task_uuid=task_uuid
)
@@ -197,6 +243,9 @@ async def run_registration_task(task_uuid: str, email_service_type: str, proxy:
result = engine.run()
if result.success:
# 更新代理使用时间
update_proxy_usage(db, proxy_id)
# 保存到数据库
engine.save_to_database(result)

View File

@@ -135,6 +135,66 @@ async def update_proxy_settings(request: ProxySettings):
return {"success": True, "message": "代理设置已更新"}
@router.post("/proxy/test")
async def test_proxy_settings(request: ProxySettings):
"""测试代理连接"""
import time
from curl_cffi import requests as cffi_requests
# 构建代理 URL
if request.type == "http":
scheme = "http"
elif request.type == "socks5":
scheme = "socks5"
else:
raise HTTPException(status_code=400, detail="不支持的代理类型")
auth = ""
if request.username and request.password:
auth = f"{request.username}:{request.password}@"
proxy_url = f"{scheme}://{auth}{request.host}:{request.port}"
# 测试连接
test_url = "https://api.ipify.org?format=json"
start_time = time.time()
try:
proxies = {
"http": proxy_url,
"https": proxy_url
}
response = cffi_requests.get(
test_url,
proxies=proxies,
timeout=3,
impersonate="chrome110"
)
elapsed_time = time.time() - start_time
if response.status_code == 200:
ip_info = response.json()
return {
"success": True,
"ip": ip_info.get("ip", ""),
"response_time": round(elapsed_time * 1000), # 毫秒
"message": f"代理连接成功,出口 IP: {ip_info.get('ip', 'unknown')}"
}
else:
return {
"success": False,
"message": f"代理返回错误状态码: {response.status_code}"
}
except Exception as e:
return {
"success": False,
"message": f"代理连接失败: {str(e)}"
}
@router.get("/registration")
async def get_registration_settings():
"""获取注册设置"""
@@ -292,3 +352,275 @@ async def get_recent_logs(
}
except Exception as e:
return {"logs": [], "error": str(e)}
# ============== 临时邮箱设置 ==============
class TempmailSettings(BaseModel):
"""临时邮箱设置"""
api_url: Optional[str] = None
enabled: bool = True
@router.get("/tempmail")
async def get_tempmail_settings():
"""获取临时邮箱设置"""
settings = get_settings()
return {
"api_url": settings.tempmail_base_url,
"timeout": settings.tempmail_timeout,
"max_retries": settings.tempmail_max_retries,
"enabled": True # 临时邮箱默认可用
}
@router.post("/tempmail")
async def update_tempmail_settings(request: TempmailSettings):
"""更新临时邮箱设置"""
update_dict = {}
if request.api_url:
update_dict["tempmail_base_url"] = request.api_url
update_settings(**update_dict)
return {"success": True, "message": "临时邮箱设置已更新"}
# ============== 代理列表 CRUD ==============
class ProxyCreateRequest(BaseModel):
"""创建代理请求"""
name: str
type: str = "http" # http, socks5
host: str
port: int
username: Optional[str] = None
password: Optional[str] = None
enabled: bool = True
priority: int = 0
class ProxyUpdateRequest(BaseModel):
"""更新代理请求"""
name: Optional[str] = None
type: Optional[str] = None
host: Optional[str] = None
port: Optional[int] = None
username: Optional[str] = None
password: Optional[str] = None
enabled: Optional[bool] = None
priority: Optional[int] = None
@router.get("/proxies")
async def get_proxies_list(enabled: Optional[bool] = None):
"""获取代理列表"""
with get_db() as db:
proxies = crud.get_proxies(db, enabled=enabled)
return {
"proxies": [p.to_dict() for p in proxies],
"total": len(proxies)
}
@router.post("/proxies")
async def create_proxy_item(request: ProxyCreateRequest):
"""创建代理"""
with get_db() as db:
proxy = crud.create_proxy(
db,
name=request.name,
type=request.type,
host=request.host,
port=request.port,
username=request.username,
password=request.password,
enabled=request.enabled,
priority=request.priority
)
return {"success": True, "proxy": proxy.to_dict()}
@router.get("/proxies/{proxy_id}")
async def get_proxy_item(proxy_id: int):
"""获取单个代理"""
with get_db() as db:
proxy = crud.get_proxy_by_id(db, proxy_id)
if not proxy:
raise HTTPException(status_code=404, detail="代理不存在")
return proxy.to_dict(include_password=True)
@router.patch("/proxies/{proxy_id}")
async def update_proxy_item(proxy_id: int, request: ProxyUpdateRequest):
"""更新代理"""
with get_db() as db:
update_data = {}
if request.name is not None:
update_data["name"] = request.name
if request.type is not None:
update_data["type"] = request.type
if request.host is not None:
update_data["host"] = request.host
if request.port is not None:
update_data["port"] = request.port
if request.username is not None:
update_data["username"] = request.username
if request.password is not None:
update_data["password"] = request.password
if request.enabled is not None:
update_data["enabled"] = request.enabled
if request.priority is not None:
update_data["priority"] = request.priority
proxy = crud.update_proxy(db, proxy_id, **update_data)
if not proxy:
raise HTTPException(status_code=404, detail="代理不存在")
return {"success": True, "proxy": proxy.to_dict()}
@router.delete("/proxies/{proxy_id}")
async def delete_proxy_item(proxy_id: int):
"""删除代理"""
with get_db() as db:
success = crud.delete_proxy(db, proxy_id)
if not success:
raise HTTPException(status_code=404, detail="代理不存在")
return {"success": True, "message": "代理已删除"}
@router.post("/proxies/{proxy_id}/test")
async def test_proxy_item(proxy_id: int):
"""测试单个代理"""
import time
from curl_cffi import requests as cffi_requests
with get_db() as db:
proxy = crud.get_proxy_by_id(db, proxy_id)
if not proxy:
raise HTTPException(status_code=404, detail="代理不存在")
proxy_url = proxy.proxy_url
test_url = "https://api.ipify.org?format=json"
start_time = time.time()
try:
proxies = {
"http": proxy_url,
"https": proxy_url
}
response = cffi_requests.get(
test_url,
proxies=proxies,
timeout=3,
impersonate="chrome110"
)
elapsed_time = time.time() - start_time
if response.status_code == 200:
ip_info = response.json()
return {
"success": True,
"ip": ip_info.get("ip", ""),
"response_time": round(elapsed_time * 1000),
"message": f"代理连接成功,出口 IP: {ip_info.get('ip', 'unknown')}"
}
else:
return {
"success": False,
"message": f"代理返回错误状态码: {response.status_code}"
}
except Exception as e:
return {
"success": False,
"message": f"代理连接失败: {str(e)}"
}
@router.post("/proxies/test-all")
async def test_all_proxies():
"""测试所有启用的代理"""
import time
from curl_cffi import requests as cffi_requests
with get_db() as db:
proxies = crud.get_enabled_proxies(db)
results = []
for proxy in proxies:
proxy_url = proxy.proxy_url
test_url = "https://api.ipify.org?format=json"
start_time = time.time()
try:
proxies_dict = {
"http": proxy_url,
"https": proxy_url
}
response = cffi_requests.get(
test_url,
proxies=proxies_dict,
timeout=3,
impersonate="chrome110"
)
elapsed_time = time.time() - start_time
if response.status_code == 200:
ip_info = response.json()
results.append({
"id": proxy.id,
"name": proxy.name,
"success": True,
"ip": ip_info.get("ip", ""),
"response_time": round(elapsed_time * 1000)
})
else:
results.append({
"id": proxy.id,
"name": proxy.name,
"success": False,
"message": f"状态码: {response.status_code}"
})
except Exception as e:
results.append({
"id": proxy.id,
"name": proxy.name,
"success": False,
"message": str(e)
})
success_count = sum(1 for r in results if r["success"])
return {
"total": len(proxies),
"success": success_count,
"failed": len(proxies) - success_count,
"results": results
}
@router.post("/proxies/{proxy_id}/enable")
async def enable_proxy(proxy_id: int):
"""启用代理"""
with get_db() as db:
proxy = crud.update_proxy(db, proxy_id, enabled=True)
if not proxy:
raise HTTPException(status_code=404, detail="代理不存在")
return {"success": True, "message": "代理已启用"}
@router.post("/proxies/{proxy_id}/disable")
async def disable_proxy(proxy_id: int):
"""禁用代理"""
with get_db() as db:
proxy = crud.update_proxy(db, proxy_id, enabled=False)
if not proxy:
raise HTTPException(status_code=404, detail="代理不存在")
return {"success": True, "message": "代理已禁用"}