diff --git a/README.md b/README.md index 79786ba..4c189a9 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,9 @@ - **多邮箱服务支持** - Tempmail.lol(临时邮箱,无需配置) - Outlook(IMAP + XOAUTH2,支持批量导入) - - 自定义域名(REST API) + - 自定义域名(两种子类型) + - **MoeMail**:标准 REST API,配置 API 地址 + API 密钥 + - **TempMail**:自部署 Cloudflare Worker 临时邮箱,配置 Worker 地址 + Admin 密码 - **注册模式** - 单次注册 @@ -40,12 +42,21 @@ - 单个账号导出为独立 `.json` 文件 - 多个账号打包为 `.zip`,每个账号一个独立文件 - CPA 上传(Codex Protocol API,直连不走代理) + - 订阅状态管理(手动标记 / 自动检测 plus/team) + - Team Manager 上传(直连不走代理) + +- **支付升级** + - 为账号生成 ChatGPT Plus 或 Team 订阅支付链接 + - 后端命令行以无痕模式自动打开 Chrome/Edge + - Team 套餐支持自定义工作区名称、座位数、计费周期 - **系统设置** - 代理配置(静态 + 动态) - Outlook OAuth 参数 - 注册参数(超时、重试、密码长度等) - 验证码等待配置 + - CPA 上传配置 + - Team Manager 配置(API URL + API Key) - 数据库管理(备份、清理) - 支持远程 PostgreSQL @@ -113,7 +124,7 @@ codex-register-v2/ ├── build.sh # Linux/macOS 打包脚本 ├── src/ │ ├── config/ # 配置管理(Pydantic Settings) -│ ├── core/ # 核心功能(注册引擎、HTTP 客户端、CPA 上传) +│ ├── core/ # 核心功能(注册引擎、HTTP 客户端、CPA 上传、支付、TM 上传) │ ├── database/ # 数据库(SQLAlchemy + SQLite) │ ├── services/ # 邮箱服务实现 │ └── web/ # FastAPI Web 应用 @@ -177,6 +188,17 @@ codex-register-v2/ | POST | `/api/accounts/{id}/upload-cpa` | 上传到 CPA | | POST | `/api/accounts/batch-upload-cpa` | 批量上传到 CPA | +### 支付升级 + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/api/payment/generate-link` | 生成 Plus/Team 支付链接 | +| POST | `/api/payment/open-incognito` | 后端无痕模式打开浏览器 | +| POST | `/api/payment/accounts/{id}/mark-subscription` | 手动标记订阅类型 | +| POST | `/api/payment/accounts/batch-check-subscription` | 批量检测订阅状态 | +| POST | `/api/payment/accounts/{id}/upload-tm` | 上传单账号到 Team Manager | +| POST | `/api/payment/accounts/batch-upload-tm` | 批量上传到 Team Manager | + ### 邮箱服务 | 方法 | 路径 | 说明 | @@ -198,6 +220,8 @@ codex-register-v2/ | POST | `/api/settings/dynamic-proxy` | 更新动态代理设置 | | POST | `/api/settings/cpa` | 更新 CPA 设置 | | POST | `/api/settings/cpa/test` | 测试 CPA 连接 | +| GET/POST | `/api/settings/team-manager` | Team Manager 设置 | +| POST | `/api/settings/team-manager/test` | 测试 Team Manager 连接 | | GET | `/api/settings/database` | 数据库信息 | ### WebSocket @@ -266,6 +290,11 @@ docker-compose build --no-cache - 代理设置优先级:动态代理 > 代理列表(随机) > 静态默认代理 - 注册时自动随机生成用户名和生日(年龄范围 18-45 岁) - CPA 上传始终直连,不经过代理 +- Team Manager 上传始终直连,不经过代理 +- 支付链接生成使用账号 access_token 鉴权,走全局代理配置 +- 无痕浏览器优先使用 playwright(注入 cookie 直达支付页);未安装时降级为系统 Chrome/Edge 无痕模式 +- 安装完整支付功能:`pip install playwright && playwright install chromium`(可选) +- 订阅状态自动检测调用 `chatgpt.com/backend-api/me`,走全局代理 - 批量注册并发数上限为 50,线程池大小已相应调整 ## License diff --git a/pyproject.toml b/pyproject.toml index 9cc8a29..bf01157 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "codex-register-v2" -version = "0.1.0" -description = "OpenAI/Codex CLI 自动注册系统" +version = "1.0.4" +description = "OpenAI 自动注册系统 v2" requires-python = ">=3.10" dependencies = [ "curl-cffi>=0.14.0", @@ -22,6 +22,9 @@ dev = [ "pytest>=7.0.0", "httpx>=0.24.0", ] +payment = [ + "playwright>=1.40.0", +] [project.scripts] codex-webui = "webui:main" diff --git a/requirements.txt b/requirements.txt index 2f55eb6..462b7fc 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/src/config/constants.py b/src/config/constants.py index c6be6ae..dcf24ee 100644 --- a/src/config/constants.py +++ b/src/config/constants.py @@ -34,6 +34,7 @@ class EmailServiceType(str, Enum): TEMPMAIL = "tempmail" OUTLOOK = "outlook" CUSTOM_DOMAIN = "custom_domain" + TEMP_MAIL = "temp_mail" # ============================================================================ diff --git a/src/config/settings.py b/src/config/settings.py index 3988a9c..fd119eb 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -301,6 +301,27 @@ SETTING_DEFINITIONS: Dict[str, SettingDefinition] = { is_secret=True ), + # Team Manager 配置 + "tm_enabled": SettingDefinition( + db_key="tm.enabled", + default_value=False, + category=SettingCategory.GENERAL, + description="是否启用 Team Manager 上传" + ), + "tm_api_url": SettingDefinition( + db_key="tm.api_url", + default_value="", + category=SettingCategory.GENERAL, + description="Team Manager API 地址" + ), + "tm_api_key": SettingDefinition( + db_key="tm.api_key", + default_value="", + category=SettingCategory.GENERAL, + description="Team Manager API Key", + is_secret=True + ), + # CPA 上传配置 "cpa_enabled": SettingDefinition( db_key="cpa.enabled", @@ -382,6 +403,7 @@ SETTING_TYPES: Dict[str, Type] = { "email_service_priority": dict, "tempmail_timeout": int, "tempmail_max_retries": int, + "tm_enabled": bool, "cpa_enabled": bool, "email_code_timeout": int, "email_code_poll_interval": int, @@ -642,6 +664,11 @@ class Settings(BaseModel): # 安全配置 encryption_key: SecretStr = SecretStr("your-encryption-key-change-in-production") + # Team Manager 配置 + tm_enabled: bool = False + tm_api_url: str = "" + tm_api_key: Optional[SecretStr] = None + # CPA 上传配置 cpa_enabled: bool = False cpa_api_url: str = "" diff --git a/src/core/payment.py b/src/core/payment.py new file mode 100644 index 0000000..24e2182 --- /dev/null +++ b/src/core/payment.py @@ -0,0 +1,261 @@ +""" +支付核心逻辑 — 生成 Plus/Team 支付链接、无痕打开浏览器、检测订阅状态 +""" + +import logging +import subprocess +import sys +from typing import Optional + +from curl_cffi import requests as cffi_requests + +from ..database.models import Account + +logger = logging.getLogger(__name__) + +PAYMENT_CHECKOUT_URL = "https://chatgpt.com/backend-api/payments/checkout" +TEAM_CHECKOUT_BASE_URL = "https://chatgpt.com/checkout/openai_llc/" + + +def _build_proxies(proxy: Optional[str]) -> Optional[dict]: + if proxy: + return {"http": proxy, "https": proxy} + return None + + +_COUNTRY_CURRENCY_MAP = { + "SG": "SGD", + "US": "USD", + "TR": "TRY", + "JP": "JPY", + "HK": "HKD", + "GB": "GBP", + "EU": "EUR", + "AU": "AUD", + "CA": "CAD", + "IN": "INR", + "BR": "BRL", + "MX": "MXN", +} + + +def _extract_oai_did(cookies_str: str) -> Optional[str]: + """从 cookie 字符串中提取 oai-device-id""" + for part in cookies_str.split(";"): + part = part.strip() + if part.startswith("oai-did="): + return part[len("oai-did="):].strip() + return None + + +def _parse_cookie_str(cookies_str: str, domain: str) -> list: + """将 'key=val; key2=val2' 格式解析为 Playwright cookie 列表""" + cookies = [] + for part in cookies_str.split(";"): + part = part.strip() + if "=" not in part: + continue + name, _, value = part.partition("=") + cookies.append({ + "name": name.strip(), + "value": value.strip(), + "domain": domain, + "path": "/", + }) + return cookies + + +def _open_url_system_browser(url: str) -> bool: + """回退方案:调用系统浏览器以无痕模式打开""" + platform = sys.platform + try: + if platform == "win32": + for browser, flag in [("chrome", "--incognito"), ("msedge", "--inprivate")]: + try: + subprocess.Popen(f'start {browser} {flag} "{url}"', shell=True) + return True + except Exception: + continue + elif platform == "darwin": + subprocess.Popen(["open", "-a", "Google Chrome", "--args", "--incognito", url]) + return True + else: + for binary in ["google-chrome", "chromium-browser", "chromium"]: + try: + subprocess.Popen([binary, "--incognito", url]) + return True + except FileNotFoundError: + continue + except Exception as e: + logger.warning(f"系统浏览器无痕打开失败: {e}") + return False + + +def generate_plus_link( + account: Account, + proxy: Optional[str] = None, + country: str = "SG", +) -> str: + """生成 Plus 支付链接(后端携带账号 cookie 发请求)""" + if not account.access_token: + raise ValueError("账号缺少 access_token") + + currency = _COUNTRY_CURRENCY_MAP.get(country, "USD") + headers = { + "Authorization": f"Bearer {account.access_token}", + "Content-Type": "application/json", + "oai-language": "zh-CN", + } + if account.cookies: + headers["cookie"] = account.cookies + oai_did = _extract_oai_did(account.cookies) + if oai_did: + headers["oai-device-id"] = oai_did + + payload = { + "plan_name": "chatgptplusplan", + "billing_details": {"country": country, "currency": currency}, + "promo_campaign": { + "promo_campaign_id": "plus-1-month-free", + "is_coupon_from_query_param": False, + }, + "checkout_ui_mode": "custom", + } + + resp = cffi_requests.post( + PAYMENT_CHECKOUT_URL, + headers=headers, + json=payload, + proxies=_build_proxies(proxy), + timeout=30, + impersonate="chrome110", + ) + resp.raise_for_status() + data = resp.json() + if "checkout_session_id" in data: + return TEAM_CHECKOUT_BASE_URL + data["checkout_session_id"] + raise ValueError(data.get("detail", "API 未返回 checkout_session_id")) + + +def generate_team_link( + account: Account, + workspace_name: str = "MyTeam", + price_interval: str = "month", + seat_quantity: int = 5, + proxy: Optional[str] = None, + country: str = "SG", +) -> str: + """生成 Team 支付链接(后端携带账号 cookie 发请求)""" + if not account.access_token: + raise ValueError("账号缺少 access_token") + + currency = _COUNTRY_CURRENCY_MAP.get(country, "USD") + headers = { + "Authorization": f"Bearer {account.access_token}", + "Content-Type": "application/json", + "oai-language": "zh-CN", + } + if account.cookies: + headers["cookie"] = account.cookies + oai_did = _extract_oai_did(account.cookies) + if oai_did: + headers["oai-device-id"] = oai_did + + payload = { + "plan_name": "chatgptteamplan", + "team_plan_data": { + "workspace_name": workspace_name, + "price_interval": price_interval, + "seat_quantity": seat_quantity, + }, + "billing_details": {"country": country, "currency": currency}, + "promo_campaign": { + "promo_campaign_id": "team-1-month-free", + "is_coupon_from_query_param": True, + }, + "cancel_url": "https://chatgpt.com/#pricing", + "checkout_ui_mode": "custom", + } + + resp = cffi_requests.post( + PAYMENT_CHECKOUT_URL, + headers=headers, + json=payload, + proxies=_build_proxies(proxy), + timeout=30, + impersonate="chrome110", + ) + resp.raise_for_status() + data = resp.json() + if "checkout_session_id" in data: + return TEAM_CHECKOUT_BASE_URL + data["checkout_session_id"] + raise ValueError(data.get("detail", "API 未返回 checkout_session_id")) + + +def open_url_incognito(url: str, cookies_str: Optional[str] = None) -> bool: + """用 Playwright 以无痕模式打开 URL,可注入 cookie""" + import threading + try: + from playwright.sync_api import sync_playwright + except ImportError: + logger.warning("playwright 未安装,回退到系统浏览器") + return _open_url_system_browser(url) + + def _launch(): + try: + with sync_playwright() as p: + browser = p.chromium.launch(headless=False, args=["--incognito"]) + ctx = browser.new_context() + if cookies_str: + ctx.add_cookies(_parse_cookie_str(cookies_str, "chatgpt.com")) + page = ctx.new_page() + page.goto(url) + # 保持窗口打开直到用户关闭 + page.wait_for_timeout(300_000) # 最多等待 5 分钟 + except Exception as e: + logger.warning(f"Playwright 无痕打开失败: {e}") + + threading.Thread(target=_launch, daemon=True).start() + return True + + +def check_subscription_status(account: Account, proxy: Optional[str] = None) -> str: + """ + 检测账号当前订阅状态。 + + Returns: + 'free' / 'plus' / 'team' + """ + if not account.access_token: + raise ValueError("账号缺少 access_token") + + headers = { + "Authorization": f"Bearer {account.access_token}", + "Content-Type": "application/json", + } + + resp = cffi_requests.get( + "https://chatgpt.com/backend-api/me", + headers=headers, + proxies=_build_proxies(proxy), + timeout=20, + impersonate="chrome110", + ) + resp.raise_for_status() + data = resp.json() + + # 解析订阅类型 + plan = data.get("plan_type") or "" + if "team" in plan.lower(): + return "team" + if "plus" in plan.lower(): + return "plus" + + # 尝试从 orgs 或 workspace 信息判断 + orgs = data.get("orgs", {}).get("data", []) + for org in orgs: + settings_ = org.get("settings", {}) + if settings_.get("workspace_plan_type") in ("team", "enterprise"): + return "team" + + return "free" diff --git a/src/core/team_manager.py b/src/core/team_manager.py new file mode 100644 index 0000000..b51b5fe --- /dev/null +++ b/src/core/team_manager.py @@ -0,0 +1,158 @@ +""" +Team Manager 上传功能 +参照 CPA 上传模式,直连不走代理 +""" + +import logging +from typing import List, Tuple +from datetime import datetime + +from curl_cffi import requests as cffi_requests + +from ..database.session import get_db +from ..database.models import Account +from ..config.settings import get_settings + +logger = logging.getLogger(__name__) + + +def upload_to_team_manager( + account: Account, + api_url: str, + api_key: str, +) -> Tuple[bool, str]: + """ + 上传单账号到 Team Manager(直连,不走代理) + + Returns: + (成功标志, 消息) + """ + if not api_url: + return False, "Team Manager API URL 未配置" + if not api_key: + return False, "Team Manager API Key 未配置" + if not account.access_token: + return False, "账号缺少 access_token" + + url = api_url.rstrip("/") + "/api/accounts/import" + headers = { + "X-API-Key": api_key, + "Content-Type": "application/json", + } + payload = { + "import_type": "single", + "email": account.email, + "access_token": account.access_token or "", + "session_token": account.session_token or "", + "refresh_token": account.refresh_token or "", + "client_id": account.client_id or "", + } + + try: + resp = cffi_requests.post( + url, + headers=headers, + json=payload, + proxies=None, + timeout=30, + impersonate="chrome110", + ) + if resp.status_code in (200, 201): + return True, "上传成功" + error_msg = f"上传失败: HTTP {resp.status_code}" + try: + detail = resp.json() + if isinstance(detail, dict): + error_msg = detail.get("message", error_msg) + except Exception: + error_msg = f"{error_msg} - {resp.text[:200]}" + return False, error_msg + except Exception as e: + logger.error(f"Team Manager 上传异常: {e}") + return False, f"上传异常: {str(e)}" + + +def batch_upload_to_team_manager( + account_ids: List[int], + api_url: str, + api_key: str, +) -> dict: + """ + 批量上传账号到 Team Manager + + Returns: + 包含成功/失败统计和详情的字典 + """ + results = { + "success_count": 0, + "failed_count": 0, + "skipped_count": 0, + "details": [], + } + + with get_db() as db: + for account_id in account_ids: + account = db.query(Account).filter(Account.id == account_id).first() + if not account: + results["failed_count"] += 1 + results["details"].append( + {"id": account_id, "email": None, "success": False, "error": "账号不存在"} + ) + continue + + if not account.access_token: + results["skipped_count"] += 1 + results["details"].append( + {"id": account_id, "email": account.email, "success": False, "error": "缺少 Token"} + ) + continue + + success, message = upload_to_team_manager(account, api_url, api_key) + if success: + results["success_count"] += 1 + results["details"].append( + {"id": account_id, "email": account.email, "success": True, "message": message} + ) + else: + results["failed_count"] += 1 + results["details"].append( + {"id": account_id, "email": account.email, "success": False, "error": message} + ) + + return results + + +def test_team_manager_connection(api_url: str, api_key: str) -> Tuple[bool, str]: + """ + 测试 Team Manager 连接(直连) + + Returns: + (成功标志, 消息) + """ + if not api_url: + return False, "API URL 不能为空" + if not api_key: + return False, "API Key 不能为空" + + url = api_url.rstrip("/") + "/api/accounts/import" + headers = {"X-API-Key": api_key} + + try: + resp = cffi_requests.options( + url, + headers=headers, + proxies=None, + timeout=10, + impersonate="chrome110", + ) + if resp.status_code in (200, 204, 401, 403, 405): + if resp.status_code == 401: + return False, "连接成功,但 API Key 无效" + return True, "Team Manager 连接测试成功" + return False, f"服务器返回异常状态码: {resp.status_code}" + except cffi_requests.exceptions.ConnectionError as e: + return False, f"无法连接到服务器: {str(e)}" + except cffi_requests.exceptions.Timeout: + return False, "连接超时,请检查网络配置" + except Exception as e: + return False, f"连接测试失败: {str(e)}" diff --git a/src/database/models.py b/src/database/models.py index edfef8e..e17f94b 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -53,6 +53,9 @@ class Account(Base): cpa_uploaded = Column(Boolean, default=False) # 是否已上传到 CPA cpa_uploaded_at = Column(DateTime) # 上传时间 source = Column(String(20), default='register') # 'register' 或 'login',区分账号来源 + subscription_type = Column(String(20)) # None / 'plus' / 'team' + subscription_at = Column(DateTime) # 订阅开通时间 + cookies = Column(Text) # 完整 cookie 字符串,用于支付请求 created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) @@ -74,6 +77,8 @@ class Account(Base): 'cpa_uploaded': self.cpa_uploaded, 'cpa_uploaded_at': self.cpa_uploaded_at.isoformat() if self.cpa_uploaded_at else None, 'source': self.source, + 'subscription_type': self.subscription_type, + 'subscription_at': self.subscription_at.isoformat() if self.subscription_at else None, 'created_at': self.created_at.isoformat() if self.created_at else None, 'updated_at': self.updated_at.isoformat() if self.updated_at else None } diff --git a/src/database/session.py b/src/database/session.py index 0b8eb53..6a26da2 100644 --- a/src/database/session.py +++ b/src/database/session.py @@ -107,6 +107,9 @@ class DatabaseSessionManager: ("accounts", "cpa_uploaded", "BOOLEAN DEFAULT 0"), ("accounts", "cpa_uploaded_at", "DATETIME"), ("accounts", "source", "VARCHAR(20) DEFAULT 'register'"), + ("accounts", "subscription_type", "VARCHAR(20)"), + ("accounts", "subscription_at", "DATETIME"), + ("accounts", "cookies", "TEXT"), ] with self.engine.connect() as conn: diff --git a/src/services/__init__.py b/src/services/__init__.py index 144805c..7718a09 100644 --- a/src/services/__init__.py +++ b/src/services/__init__.py @@ -13,11 +13,13 @@ from .base import ( from .tempmail import TempmailService from .outlook import OutlookService from .custom_domain import CustomDomainEmailService +from .temp_mail import TempMailService # 注册服务 EmailServiceFactory.register(EmailServiceType.TEMPMAIL, TempmailService) EmailServiceFactory.register(EmailServiceType.OUTLOOK, OutlookService) EmailServiceFactory.register(EmailServiceType.CUSTOM_DOMAIN, CustomDomainEmailService) +EmailServiceFactory.register(EmailServiceType.TEMP_MAIL, TempMailService) # 导出 Outlook 模块的额外内容 from .outlook.base import ( @@ -47,6 +49,7 @@ __all__ = [ 'TempmailService', 'OutlookService', 'CustomDomainEmailService', + 'TempMailService', # Outlook 模块 'ProviderType', 'EmailMessage', diff --git a/src/services/custom_domain.py b/src/services/custom_domain.py index ab2476a..816447f 100644 --- a/src/services/custom_domain.py +++ b/src/services/custom_domain.py @@ -326,8 +326,9 @@ class CustomDomainEmailService(BaseEmailService): if "openai" not in sender and "openai" not in content.lower(): continue - # 提取验证码 - match = re.search(pattern, content) + # 提取验证码 过滤掉邮箱 + email_pattern = r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}" + match = re.search(pattern, re.sub(email_pattern, "", content)) if match: code = match.group(1) logger.info(f"从自定义域名邮箱 {email} 找到验证码: {code}") diff --git a/src/services/temp_mail.py b/src/services/temp_mail.py new file mode 100644 index 0000000..dcd6e33 --- /dev/null +++ b/src/services/temp_mail.py @@ -0,0 +1,268 @@ +""" +Temp-Mail 邮箱服务实现 +基于自部署 Cloudflare Worker 临时邮箱服务 +接口文档参见 plan/temp-mail.md +""" + +import re +import time +import json +import logging +from typing import Optional, Dict, Any + +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): + """ + Temp-Mail 邮箱服务 + 基于自部署 Cloudflare Worker 的临时邮箱,admin 模式管理邮箱 + 不走代理,不使用 requests 库 + """ + + def __init__(self, config: Dict[str, Any] = None, name: str = None): + """ + 初始化 TempMail 服务 + + Args: + config: 配置字典,支持以下键: + - base_url: Worker 域名地址,如 https://mail.example.com (必需) + - admin_password: Admin 密码,对应 x-admin-auth header (必需) + - domain: 邮箱域名,如 example.com (必需) + - enable_prefix: 是否启用前缀,默认 True + - timeout: 请求超时时间,默认 30 + - max_retries: 最大重试次数,默认 3 + name: 服务名称 + """ + super().__init__(EmailServiceType.TEMP_MAIL, name) + + required_keys = ["base_url", "admin_password", "domain"] + missing_keys = [key for key in required_keys if not (config or {}).get(key)] + if missing_keys: + raise ValueError(f"缺少必需配置: {missing_keys}") + + default_config = { + "enable_prefix": True, + "timeout": 30, + "max_retries": 3, + } + self.config = {**default_config, **(config or {})} + + # 不走代理,proxy_url=None + http_config = RequestConfig( + timeout=self.config["timeout"], + max_retries=self.config["max_retries"], + ) + self.http_client = HTTPClient(proxy_url=None, config=http_config) + + # 邮箱缓存:email -> {jwt, address} + self._email_cache: Dict[str, Dict[str, Any]] = {} + + def _admin_headers(self) -> Dict[str, str]: + """构造 admin 请求头""" + return { + "x-admin-auth": self.config["admin_password"], + "Content-Type": "application/json", + "Accept": "application/json", + } + + def _make_request(self, method: str, path: str, **kwargs) -> Any: + """ + 发送请求并返回 JSON 数据 + + Args: + method: HTTP 方法 + path: 请求路径(以 / 开头) + **kwargs: 传递给 http_client.request 的额外参数 + + Returns: + 响应 JSON 数据 + + Raises: + EmailServiceError: 请求失败 + """ + base_url = self.config["base_url"].rstrip("/") + url = f"{base_url}{path}" + + # 合并默认 admin headers + kwargs.setdefault("headers", {}) + for k, v in self._admin_headers().items(): + kwargs["headers"].setdefault(k, v) + + try: + response = self.http_client.request(method, url, **kwargs) + + if response.status_code >= 400: + error_msg = f"请求失败: {response.status_code}" + try: + error_data = response.json() + error_msg = f"{error_msg} - {error_data}" + except Exception: + 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"请求失败: {method} {path} - {e}") + + def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]: + """ + 通过 admin API 创建临时邮箱 + + Returns: + 包含邮箱信息的字典: + - email: 邮箱地址 + - jwt: 用户级 JWT token + - service_id: 同 email(用作标识) + """ + import random + import string + + # 生成随机邮箱名 + letters = ''.join(random.choices(string.ascii_lowercase, k=5)) + digits = ''.join(random.choices(string.digits, k=random.randint(1, 3))) + suffix = ''.join(random.choices(string.ascii_lowercase, k=random.randint(1, 3))) + name = letters + digits + suffix + + domain = self.config["domain"] + enable_prefix = self.config.get("enable_prefix", True) + + body = { + "enablePrefix": enable_prefix, + "name": name, + "domain": domain, + } + + try: + response = self._make_request("POST", "/admin/new_address", json=body) + + address = response.get("address", "").strip() + jwt = response.get("jwt", "").strip() + + if not address: + raise EmailServiceError(f"API 返回数据不完整: {response}") + + email_info = { + "email": address, + "jwt": jwt, + "service_id": address, + "id": address, + "created_at": time.time(), + } + + # 缓存 jwt,供获取验证码时使用 + self._email_cache[address] = email_info + + logger.info(f"成功创建 TempMail 邮箱: {address}") + 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, + otp_sent_at: Optional[float] = None, + ) -> Optional[str]: + """ + 从 TempMail 邮箱获取验证码 + + Args: + email: 邮箱地址 + email_id: 未使用,保留接口兼容 + timeout: 超时时间(秒) + pattern: 验证码正则 + otp_sent_at: OTP 发送时间戳(暂未使用) + + Returns: + 验证码字符串,超时返回 None + """ + logger.info(f"正在从 TempMail 邮箱 {email} 获取验证码...") + + start_time = time.time() + seen_mail_ids: set = set() + + while time.time() - start_time < timeout: + try: + # 使用 admin API 查询邮件,通过 address 参数过滤 + response = self._make_request( + "GET", + "/admin/mails", + params={"limit": 20, "offset": 0, "address": email}, + ) + + # admin/mails 返回格式: {"results": [...], "total": N} + mails = response.get("results", []) + if not isinstance(mails, list): + time.sleep(3) + continue + + for mail in mails: + mail_id = mail.get("id") + if not mail_id or mail_id in seen_mail_ids: + continue + + seen_mail_ids.add(mail_id) + + sender = str(mail.get("source", "")).lower() + subject = str(mail.get("subject", "")) + body_text = str(mail.get("text", "") or mail.get("html", "") or "") + + # 去除简单 HTML 标签 + body_clean = re.sub(r"<[^>]+>", " ", body_text) + + content = f"{sender} {subject} {body_clean}" + + # 只处理 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"从 TempMail 邮箱 {email} 找到验证码: {code}") + self.update_status(True) + return code + + except Exception as e: + logger.debug(f"检查 TempMail 邮件时出错: {e}") + + time.sleep(3) + + logger.warning(f"等待 TempMail 验证码超时: {email}") + return None + + def check_health(self) -> bool: + """检查服务健康状态""" + try: + self._make_request( + "GET", + "/admin/mails", + params={"limit": 1, "offset": 0}, + ) + self.update_status(True) + return True + except Exception as e: + logger.warning(f"TempMail 健康检查失败: {e}") + self.update_status(False, e) + return False diff --git a/src/web/app.py b/src/web/app.py index 72bfc1a..8bb9710 100644 --- a/src/web/app.py +++ b/src/web/app.py @@ -151,6 +151,11 @@ def create_app() -> FastAPI: return _redirect_to_login(request) return templates.TemplateResponse("settings.html", {"request": request}) + @app.get("/payment", response_class=HTMLResponse) + async def payment_page(request: Request): + """支付页面""" + return templates.TemplateResponse("payment.html", {"request": request}) + @app.on_event("startup") async def startup_event(): """应用启动事件""" diff --git a/src/web/routes/__init__.py b/src/web/routes/__init__.py index 0d80c60..9b9d280 100644 --- a/src/web/routes/__init__.py +++ b/src/web/routes/__init__.py @@ -8,6 +8,7 @@ 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 +from .payment import router as payment_router api_router = APIRouter() @@ -16,3 +17,4 @@ 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"]) +api_router.include_router(payment_router, prefix="/payment", tags=["payment"]) diff --git a/src/web/routes/accounts.py b/src/web/routes/accounts.py index 0055575..b33d526 100644 --- a/src/web/routes/accounts.py +++ b/src/web/routes/accounts.py @@ -39,6 +39,7 @@ class AccountResponse(BaseModel): proxy_used: Optional[str] = None cpa_uploaded: bool = False cpa_uploaded_at: Optional[str] = None + cookies: Optional[str] = None created_at: Optional[str] = None updated_at: Optional[str] = None @@ -56,11 +57,16 @@ class AccountUpdateRequest(BaseModel): """账号更新请求""" status: Optional[str] = None metadata: Optional[dict] = None + cookies: Optional[str] = None # 完整 cookie 字符串,用于支付请求 class BatchDeleteRequest(BaseModel): """批量删除请求""" - ids: List[int] + ids: List[int] = [] + select_all: bool = False + status_filter: Optional[str] = None + email_service_filter: Optional[str] = None + search_filter: Optional[str] = None class BatchUpdateRequest(BaseModel): @@ -71,6 +77,30 @@ class BatchUpdateRequest(BaseModel): # ============== Helper Functions ============== +def resolve_account_ids( + db, + ids: List[int], + select_all: bool = False, + status_filter: Optional[str] = None, + email_service_filter: Optional[str] = None, + search_filter: Optional[str] = None, +) -> List[int]: + """当 select_all=True 时查询全部符合条件的 ID,否则直接返回传入的 ids""" + if not select_all: + return ids + query = db.query(Account.id) + if status_filter: + query = query.filter(Account.status == status_filter) + if email_service_filter: + query = query.filter(Account.email_service == email_service_filter) + if search_filter: + pattern = f"%{search_filter}%" + query = query.filter( + (Account.email.ilike(pattern)) | (Account.account_id.ilike(pattern)) + ) + return [row[0] for row in query.all()] + + def account_to_response(account: Account) -> AccountResponse: """转换 Account 模型为响应模型""" return AccountResponse( @@ -88,6 +118,7 @@ def account_to_response(account: Account) -> AccountResponse: proxy_used=account.proxy_used, cpa_uploaded=account.cpa_uploaded or False, cpa_uploaded_at=account.cpa_uploaded_at.isoformat() if account.cpa_uploaded_at else None, + cookies=account.cookies, created_at=account.created_at.isoformat() if account.created_at else None, updated_at=account.updated_at.isoformat() if account.updated_at else None, ) @@ -162,9 +193,9 @@ async def get_account_tokens(account_id: int): 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, + "access_token": account.access_token, + "refresh_token": account.refresh_token, + "id_token": account.id_token, "has_tokens": bool(account.access_token and account.refresh_token), } @@ -188,10 +219,24 @@ async def update_account(account_id: int, request: AccountUpdateRequest): current_metadata.update(request.metadata) update_data["metadata"] = current_metadata + if request.cookies is not None: + # 留空则清空,非空则更新 + update_data["cookies"] = request.cookies or None + account = crud.update_account(db, account_id, **update_data) return account_to_response(account) +@router.get("/{account_id}/cookies") +async def get_account_cookies(account_id: int): + """获取账号的 cookie 字符串(仅供支付使用)""" + 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_id": account_id, "cookies": account.cookies or ""} + + @router.delete("/{account_id}") async def delete_account(account_id: int): """删除单个账号""" @@ -208,10 +253,14 @@ async def delete_account(account_id: int): async def batch_delete_accounts(request: BatchDeleteRequest): """批量删除账号""" with get_db() as db: + ids = resolve_account_ids( + db, request.ids, request.select_all, + request.status_filter, request.email_service_filter, request.search_filter + ) deleted_count = 0 errors = [] - for account_id in request.ids: + for account_id in ids: try: account = crud.get_account_by_id(db, account_id) if account: @@ -255,14 +304,22 @@ async def batch_update_accounts(request: BatchUpdateRequest): class BatchExportRequest(BaseModel): """批量导出请求""" - ids: List[int] + ids: List[int] = [] + select_all: bool = False + status_filter: Optional[str] = None + email_service_filter: Optional[str] = None + search_filter: Optional[str] = None @router.post("/export/json") async def export_accounts_json(request: BatchExportRequest): """导出账号为 JSON 格式""" with get_db() as db: - accounts = db.query(Account).filter(Account.id.in_(request.ids)).all() + ids = resolve_account_ids( + db, request.ids, request.select_all, + request.status_filter, request.email_service_filter, request.search_filter + ) + accounts = db.query(Account).filter(Account.id.in_(ids)).all() export_data = [] for acc in accounts: @@ -304,7 +361,11 @@ async def export_accounts_csv(request: BatchExportRequest): import io with get_db() as db: - accounts = db.query(Account).filter(Account.id.in_(request.ids)).all() + ids = resolve_account_ids( + db, request.ids, request.select_all, + request.status_filter, request.email_service_filter, request.search_filter + ) + accounts = db.query(Account).filter(Account.id.in_(ids)).all() # 创建 CSV 内容 output = io.StringIO() @@ -349,6 +410,82 @@ async def export_accounts_csv(request: BatchExportRequest): ) +@router.post("/export/sub2api") +async def export_accounts_sub2api(request: BatchExportRequest): + """导出账号为 Sub2Api 格式(每个账号单独一个 JSON 文件,多个打包为 ZIP)""" + import io + import zipfile + + def make_sub2api_json(acc) -> dict: + expires_at = int(acc.expires_at.timestamp()) if acc.expires_at else 0 + return { + "proxies": [], + "accounts": [ + { + "name": acc.email, + "platform": "openai", + "type": "oauth", + "credentials": { + "access_token": acc.access_token or "", + "chatgpt_account_id": acc.account_id or "", + "chatgpt_user_id": "", + "client_id": acc.client_id or "", + "expires_at": expires_at, + "expires_in": 863999, + "model_mapping": { + "gpt-5.1": "gpt-5.1", + "gpt-5.1-codex": "gpt-5.1-codex", + "gpt-5.1-codex-max": "gpt-5.1-codex-max", + "gpt-5.1-codex-mini": "gpt-5.1-codex-mini", + "gpt-5.2": "gpt-5.2", + "gpt-5.2-codex": "gpt-5.2-codex" + }, + "organization_id": acc.workspace_id or "", + "refresh_token": acc.refresh_token or "" + }, + "extra": {}, + "concurrency": 10, + "priority": 1, + "rate_multiplier": 1, + "auto_pause_on_expired": True + } + ] + } + + with get_db() as db: + ids = resolve_account_ids( + db, request.ids, request.select_all, + request.status_filter, request.email_service_filter, request.search_filter + ) + accounts = db.query(Account).filter(Account.id.in_(ids)).all() + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + + if len(accounts) == 1: + acc = accounts[0] + content = json.dumps(make_sub2api_json(acc), ensure_ascii=False, indent=2) + filename = f"{acc.email}_sub2api.json" + return StreamingResponse( + iter([content]), + media_type="application/json", + headers={"Content-Disposition": f"attachment; filename={filename}"} + ) + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf: + for acc in accounts: + content = json.dumps(make_sub2api_json(acc), ensure_ascii=False, indent=2) + zf.writestr(f"{acc.email}_sub2api.json", content) + + zip_buffer.seek(0) + zip_filename = f"sub2api_tokens_{timestamp}.zip" + return StreamingResponse( + zip_buffer, + media_type="application/zip", + headers={"Content-Disposition": f"attachment; filename={zip_filename}"} + ) + + @router.post("/export/cpa") async def export_accounts_cpa(request: BatchExportRequest): """导出账号为 CPA Token JSON 格式(每个账号单独一个 JSON 文件,打包为 ZIP)""" @@ -357,7 +494,11 @@ async def export_accounts_cpa(request: BatchExportRequest): from ...core.cpa_upload import generate_token_json with get_db() as db: - accounts = db.query(Account).filter(Account.id.in_(request.ids)).all() + ids = resolve_account_ids( + db, request.ids, request.select_all, + request.status_filter, request.email_service_filter, request.search_filter + ) + accounts = db.query(Account).filter(Account.id.in_(ids)).all() timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") @@ -427,8 +568,12 @@ class TokenRefreshRequest(BaseModel): class BatchRefreshRequest(BaseModel): """批量刷新请求""" - ids: List[int] + ids: List[int] = [] proxy: Optional[str] = None + select_all: bool = False + status_filter: Optional[str] = None + email_service_filter: Optional[str] = None + search_filter: Optional[str] = None class TokenValidateRequest(BaseModel): @@ -438,8 +583,12 @@ class TokenValidateRequest(BaseModel): class BatchValidateRequest(BaseModel): """批量验证请求""" - ids: List[int] + ids: List[int] = [] proxy: Optional[str] = None + select_all: bool = False + status_filter: Optional[str] = None + email_service_filter: Optional[str] = None + search_filter: Optional[str] = None @router.post("/{account_id}/refresh") @@ -478,7 +627,13 @@ async def batch_refresh_tokens(request: BatchRefreshRequest, background_tasks: B "errors": [] } - for account_id in request.ids: + with get_db() as db: + ids = resolve_account_ids( + db, request.ids, request.select_all, + request.status_filter, request.email_service_filter, request.search_filter + ) + + for account_id in ids: try: result = do_refresh(account_id, proxy) if result.success: @@ -523,7 +678,13 @@ async def batch_validate_tokens(request: BatchValidateRequest): "details": [] } - for account_id in request.ids: + with get_db() as db: + ids = resolve_account_ids( + db, request.ids, request.select_all, + request.status_filter, request.email_service_filter, request.search_filter + ) + + for account_id in ids: try: is_valid, error = do_validate(account_id, proxy) results["details"].append({ @@ -555,8 +716,12 @@ class CPAUploadRequest(BaseModel): class BatchCPAUploadRequest(BaseModel): """批量 CPA 上传请求""" - ids: List[int] + ids: List[int] = [] proxy: Optional[str] = None + select_all: bool = False + status_filter: Optional[str] = None + email_service_filter: Optional[str] = None + search_filter: Optional[str] = None @router.post("/{account_id}/upload-cpa") @@ -609,6 +774,12 @@ async def batch_upload_accounts_to_cpa(request: BatchCPAUploadRequest): # 使用传入的代理或全局代理配置 proxy = request.proxy if request.proxy else get_settings().proxy_url - results = batch_upload_to_cpa(request.ids, proxy) + with get_db() as db: + ids = resolve_account_ids( + db, request.ids, request.select_all, + request.status_filter, request.email_service_filter, request.search_filter + ) + + results = batch_upload_to_cpa(ids, proxy) return results diff --git a/src/web/routes/email_services.py b/src/web/routes/email_services.py index a2282d4..b2b4499 100644 --- a/src/web/routes/email_services.py +++ b/src/web/routes/email_services.py @@ -143,6 +143,7 @@ async def get_email_services_stats(): stats = { 'outlook_count': 0, 'custom_count': 0, + 'temp_mail_count': 0, 'tempmail_available': True, # 临时邮箱始终可用 'enabled_count': enabled_count } @@ -152,6 +153,8 @@ async def get_email_services_stats(): stats['outlook_count'] = count elif service_type == 'custom_domain': stats['custom_count'] = count + elif service_type == 'temp_mail': + stats['temp_mail_count'] = count return stats @@ -190,6 +193,17 @@ async def get_service_types(): {"name": "api_key", "label": "API Key", "required": True}, {"name": "default_domain", "label": "默认域名", "required": False}, ] + }, + { + "value": "temp_mail", + "label": "Temp-Mail(自部署)", + "description": "自部署 Cloudflare Worker 临时邮箱,admin 模式管理", + "config_fields": [ + {"name": "base_url", "label": "Worker 地址", "required": True, "placeholder": "https://mail.example.com"}, + {"name": "admin_password", "label": "Admin 密码", "required": True, "secret": True}, + {"name": "domain", "label": "邮箱域名", "required": True, "placeholder": "example.com"}, + {"name": "enable_prefix", "label": "启用前缀", "required": False, "default": True}, + ] } ] } diff --git a/src/web/routes/payment.py b/src/web/routes/payment.py new file mode 100644 index 0000000..94f5015 --- /dev/null +++ b/src/web/routes/payment.py @@ -0,0 +1,236 @@ +""" +支付相关 API 路由 +""" + +import logging +from typing import Optional, List +from datetime import datetime + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from ...database.session import get_db +from ...database.models import Account +from ...config.settings import get_settings +from .accounts import resolve_account_ids +from ...core.payment import ( + generate_plus_link, + generate_team_link, + open_url_incognito, + check_subscription_status, +) +from ...core.team_manager import ( + upload_to_team_manager, + batch_upload_to_team_manager, +) + +logger = logging.getLogger(__name__) +router = APIRouter() + + +# ============== Pydantic Models ============== + +class GenerateLinkRequest(BaseModel): + account_id: int + plan_type: str # 'plus' or 'team' + workspace_name: str = "MyTeam" + price_interval: str = "month" + seat_quantity: int = 5 + proxy: Optional[str] = None + auto_open: bool = False # 生成后是否自动无痕打开 + country: str = "SG" # 计费国家,决定货币 # 生成后是否自动无痕打开 + + +class OpenIncognitoRequest(BaseModel): + url: str + account_id: Optional[int] = None # 可选,用于注入账号 cookie + + +class MarkSubscriptionRequest(BaseModel): + subscription_type: str # 'free' / 'plus' / 'team' + + +class BatchCheckSubscriptionRequest(BaseModel): + ids: List[int] = [] + proxy: Optional[str] = None + select_all: bool = False + status_filter: Optional[str] = None + email_service_filter: Optional[str] = None + search_filter: Optional[str] = None + + +class UploadTMRequest(BaseModel): + proxy: Optional[str] = None # 保留,TM 上传不走代理 + + +class BatchUploadTMRequest(BaseModel): + ids: List[int] = [] + select_all: bool = False + status_filter: Optional[str] = None + email_service_filter: Optional[str] = None + search_filter: Optional[str] = None + + +# ============== 支付链接生成 ============== + +@router.post("/generate-link") +def generate_payment_link(request: GenerateLinkRequest): + """生成 Plus 或 Team 支付链接,可选自动无痕打开""" + with get_db() as db: + account = db.query(Account).filter(Account.id == request.account_id).first() + if not account: + raise HTTPException(status_code=404, detail="账号不存在") + + proxy = request.proxy or get_settings().proxy_url + + try: + if request.plan_type == "plus": + link = generate_plus_link(account, proxy, country=request.country) + elif request.plan_type == "team": + link = generate_team_link( + account, + workspace_name=request.workspace_name, + price_interval=request.price_interval, + seat_quantity=request.seat_quantity, + proxy=proxy, + country=request.country, + ) + else: + raise HTTPException(status_code=400, detail="plan_type 必须为 plus 或 team") + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"生成支付链接失败: {e}") + raise HTTPException(status_code=500, detail=f"生成链接失败: {str(e)}") + + opened = False + if request.auto_open and link: + cookies_str = account.cookies if account else None + opened = open_url_incognito(link, cookies_str) + + return { + "success": True, + "link": link, + "plan_type": request.plan_type, + "auto_opened": opened, + } + + +@router.post("/open-incognito") +def open_browser_incognito(request: OpenIncognitoRequest): + """后端以无痕模式打开指定 URL,可注入账号 cookie""" + if not request.url: + raise HTTPException(status_code=400, detail="URL 不能为空") + + cookies_str = None + if request.account_id: + with get_db() as db: + account = db.query(Account).filter(Account.id == request.account_id).first() + if account: + cookies_str = account.cookies + + success = open_url_incognito(request.url, cookies_str) + if success: + return {"success": True, "message": "已在无痕模式打开浏览器"} + return {"success": False, "message": "未找到可用的浏览器,请手动复制链接"} + + +# ============== 订阅状态 ============== + +@router.post("/accounts/{account_id}/mark-subscription") +def mark_subscription(account_id: int, request: MarkSubscriptionRequest): + """手动标记账号订阅类型""" + allowed = ("free", "plus", "team") + if request.subscription_type not in allowed: + raise HTTPException(status_code=400, detail=f"subscription_type 必须为 {allowed}") + + with get_db() as db: + account = db.query(Account).filter(Account.id == account_id).first() + if not account: + raise HTTPException(status_code=404, detail="账号不存在") + + account.subscription_type = None if request.subscription_type == "free" else request.subscription_type + account.subscription_at = datetime.utcnow() if request.subscription_type != "free" else None + db.commit() + + return {"success": True, "subscription_type": request.subscription_type} + + +@router.post("/accounts/batch-check-subscription") +def batch_check_subscription(request: BatchCheckSubscriptionRequest): + """批量检测账号订阅状态""" + proxy = request.proxy or get_settings().proxy_url + + results = {"success_count": 0, "failed_count": 0, "details": []} + + with get_db() as db: + ids = resolve_account_ids( + db, request.ids, request.select_all, + request.status_filter, request.email_service_filter, request.search_filter + ) + for account_id in ids: + account = db.query(Account).filter(Account.id == account_id).first() + if not account: + results["failed_count"] += 1 + results["details"].append( + {"id": account_id, "email": None, "success": False, "error": "账号不存在"} + ) + continue + + try: + status = check_subscription_status(account, proxy) + account.subscription_type = None if status == "free" else status + account.subscription_at = datetime.utcnow() if status != "free" else account.subscription_at + db.commit() + results["success_count"] += 1 + results["details"].append( + {"id": account_id, "email": account.email, "success": True, "subscription_type": status} + ) + except Exception as e: + results["failed_count"] += 1 + results["details"].append( + {"id": account_id, "email": account.email, "success": False, "error": str(e)} + ) + + return results + + +# ============== Team Manager 上传 ============== + +@router.post("/accounts/{account_id}/upload-tm") +def upload_account_tm(account_id: int, request: UploadTMRequest = None): + """上传单账号到 Team Manager""" + settings = get_settings() + if not settings.tm_enabled: + raise HTTPException(status_code=400, detail="Team Manager 上传未启用") + + api_url = settings.tm_api_url + api_key = settings.tm_api_key.get_secret_value() if settings.tm_api_key else "" + + with get_db() as db: + account = db.query(Account).filter(Account.id == account_id).first() + if not account: + raise HTTPException(status_code=404, detail="账号不存在") + success, message = upload_to_team_manager(account, api_url, api_key) + + return {"success": success, "message": message} + + +@router.post("/accounts/batch-upload-tm") +def batch_upload_tm(request: BatchUploadTMRequest): + """批量上传账号到 Team Manager""" + settings = get_settings() + if not settings.tm_enabled: + raise HTTPException(status_code=400, detail="Team Manager 上传未启用") + + api_url = settings.tm_api_url + api_key = settings.tm_api_key.get_secret_value() if settings.tm_api_key else "" + + with get_db() as db: + ids = resolve_account_ids( + db, request.ids, request.select_all, + request.status_filter, request.email_service_filter, request.search_filter + ) + + results = batch_upload_to_team_manager(ids, api_url, api_key) + return results diff --git a/src/web/routes/registration.py b/src/web/routes/registration.py index 65e16a0..4427086 100644 --- a/src/web/routes/registration.py +++ b/src/web/routes/registration.py @@ -71,6 +71,7 @@ class RegistrationTaskCreate(BaseModel): proxy: Optional[str] = None email_service_config: Optional[dict] = None email_service_id: Optional[int] = None # 使用数据库中已配置的邮箱服务 ID + auto_upload_cpa: bool = False # 注册成功后自动上传到 CPA class BatchRegistrationRequest(BaseModel): @@ -84,6 +85,7 @@ class BatchRegistrationRequest(BaseModel): interval_max: int = 30 # 最大间隔秒数 concurrency: int = 1 # 并发线程数 (1-50) mode: str = "pipeline" # 执行模式: "parallel" 或 "pipeline" + auto_upload_cpa: bool = False # 注册成功后自动上传到 CPA class RegistrationTaskResponse(BaseModel): @@ -146,6 +148,7 @@ class OutlookBatchRegistrationRequest(BaseModel): interval_max: int = 30 concurrency: int = 1 # 并发线程数 (1-50) mode: str = "pipeline" # 执行模式: "parallel" 或 "pipeline" + auto_upload_cpa: bool = False # 注册成功后自动上传到 CPA class OutlookBatchRegistrationResponse(BaseModel): @@ -176,7 +179,7 @@ def task_to_response(task: RegistrationTask) -> RegistrationTaskResponse: ) -def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: Optional[str], email_service_config: Optional[dict], email_service_id: Optional[int] = None, log_prefix: str = "", batch_id: str = ""): +def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: Optional[str], email_service_config: Optional[dict], email_service_id: Optional[int] = None, log_prefix: str = "", batch_id: str = "", auto_upload_cpa: bool = False): """ 在线程池中执行的同步注册任务 @@ -333,6 +336,25 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: # 保存到数据库 engine.save_to_database(result) + # 自动上传到 CPA + if auto_upload_cpa: + try: + from ...core.cpa_upload import upload_to_cpa, generate_token_json + from ...database.models import Account as AccountModel + saved_account = db.query(AccountModel).filter_by(email=result.email).first() + if saved_account and saved_account.access_token: + token_data = generate_token_json(saved_account) + cpa_success, cpa_msg = upload_to_cpa(token_data) + if cpa_success: + saved_account.cpa_uploaded = True + saved_account.cpa_uploaded_at = datetime.utcnow() + db.commit() + log_callback(f"[CPA] 已自动上传到 CPA: {result.email}") + else: + log_callback(f"[CPA] 上传失败: {cpa_msg}") + except Exception as cpa_err: + log_callback(f"[CPA] 上传异常: {cpa_err}") + # 更新任务状态 crud.update_registration_task( db, task_uuid, @@ -377,7 +399,7 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: pass -async def run_registration_task(task_uuid: str, email_service_type: str, proxy: Optional[str], email_service_config: Optional[dict], email_service_id: Optional[int] = None, log_prefix: str = "", batch_id: str = ""): +async def run_registration_task(task_uuid: str, email_service_type: str, proxy: Optional[str], email_service_config: Optional[dict], email_service_id: Optional[int] = None, log_prefix: str = "", batch_id: str = "", auto_upload_cpa: bool = False): """ 异步执行注册任务 @@ -403,7 +425,8 @@ async def run_registration_task(task_uuid: str, email_service_type: str, proxy: email_service_config, email_service_id, log_prefix, - batch_id + batch_id, + auto_upload_cpa ) except Exception as e: logger.error(f"线程池执行异常: {task_uuid}, 错误: {e}") @@ -449,7 +472,8 @@ async def run_batch_parallel( proxy: Optional[str], email_service_config: Optional[dict], email_service_id: Optional[int], - concurrency: int + concurrency: int, + auto_upload_cpa: bool = False ): """ 并行模式:所有任务同时提交,Semaphore 控制最大并发数 @@ -465,7 +489,7 @@ async def run_batch_parallel( async with semaphore: await run_registration_task( uuid, email_service_type, proxy, email_service_config, email_service_id, - log_prefix=prefix, batch_id=batch_id + log_prefix=prefix, batch_id=batch_id, auto_upload_cpa=auto_upload_cpa ) with get_db() as db: t = crud.get_registration_task(db, uuid) @@ -506,7 +530,8 @@ async def run_batch_pipeline( email_service_id: Optional[int], interval_min: int, interval_max: int, - concurrency: int + concurrency: int, + auto_upload_cpa: bool = False ): """ 流水线模式:每隔 interval 秒启动一个新任务,Semaphore 限制最大并发数 @@ -522,7 +547,7 @@ async def run_batch_pipeline( try: await run_registration_task( uuid, email_service_type, proxy, email_service_config, email_service_id, - log_prefix=pfx, batch_id=batch_id + log_prefix=pfx, batch_id=batch_id, auto_upload_cpa=auto_upload_cpa ) with get_db() as db: t = crud.get_registration_task(db, uuid) @@ -587,19 +612,22 @@ async def run_batch_registration( interval_min: int, interval_max: int, concurrency: int = 1, - mode: str = "pipeline" + mode: str = "pipeline", + auto_upload_cpa: bool = False ): """根据 mode 分发到并行或流水线执行""" if mode == "parallel": await run_batch_parallel( batch_id, task_uuids, email_service_type, proxy, - email_service_config, email_service_id, concurrency + email_service_config, email_service_id, concurrency, + auto_upload_cpa=auto_upload_cpa ) else: await run_batch_pipeline( batch_id, task_uuids, email_service_type, proxy, email_service_config, email_service_id, - interval_min, interval_max, concurrency + interval_min, interval_max, concurrency, + auto_upload_cpa=auto_upload_cpa ) @@ -643,7 +671,10 @@ async def start_registration( request.email_service_type, request.proxy, request.email_service_config, - request.email_service_id + request.email_service_id, + "", + "", + request.auto_upload_cpa ) return task_to_response(task) @@ -714,7 +745,8 @@ async def start_batch_registration( request.interval_min, request.interval_max, request.concurrency, - request.mode + request.mode, + request.auto_upload_cpa ) return BatchRegistrationResponse( @@ -898,6 +930,11 @@ async def get_available_email_services(): "available": False, "count": 0, "services": [] + }, + "temp_mail": { + "available": False, + "count": 0, + "services": [] } } @@ -952,6 +989,25 @@ async def get_available_email_services(): "from_settings": True }) + # 获取 TempMail 服务(自部署 Cloudflare Worker 临时邮箱) + temp_mail_services = db.query(EmailServiceModel).filter( + EmailServiceModel.service_type == "temp_mail", + EmailServiceModel.enabled == True + ).order_by(EmailServiceModel.priority.asc()).all() + + for service in temp_mail_services: + config = service.config or {} + result["temp_mail"]["services"].append({ + "id": service.id, + "name": service.name, + "type": "temp_mail", + "domain": config.get("domain"), + "priority": service.priority + }) + + result["temp_mail"]["count"] = len(temp_mail_services) + result["temp_mail"]["available"] = len(temp_mail_services) > 0 + return result @@ -1018,7 +1074,8 @@ async def run_outlook_batch_registration( interval_min: int, interval_max: int, concurrency: int = 1, - mode: str = "pipeline" + mode: str = "pipeline", + auto_upload_cpa: bool = False ): """ 异步执行 Outlook 批量注册任务,复用通用并发逻辑 @@ -1055,7 +1112,8 @@ async def run_outlook_batch_registration( interval_min=interval_min, interval_max=interval_max, concurrency=concurrency, - mode=mode + mode=mode, + auto_upload_cpa=auto_upload_cpa ) @@ -1153,7 +1211,8 @@ async def start_outlook_batch_registration( request.interval_min, request.interval_max, request.concurrency, - request.mode + request.mode, + request.auto_upload_cpa ) return OutlookBatchRegistrationResponse( diff --git a/src/web/routes/settings.py b/src/web/routes/settings.py index 24faf03..0762ab3 100644 --- a/src/web/routes/settings.py +++ b/src/web/routes/settings.py @@ -877,3 +877,59 @@ async def update_outlook_settings(request: OutlookSettings): update_settings(**update_dict) return {"success": True, "message": "Outlook 设置已更新"} + + +# ============== Team Manager 设置 ============== + +class TeamManagerSettings(BaseModel): + """Team Manager 设置""" + enabled: bool = False + api_url: str = "" + api_key: str = "" + + +class TeamManagerTestRequest(BaseModel): + """Team Manager 测试请求""" + api_url: str + api_key: str + + +@router.get("/team-manager") +async def get_team_manager_settings(): + """获取 Team Manager 设置""" + settings = get_settings() + return { + "enabled": settings.tm_enabled, + "api_url": settings.tm_api_url, + "has_api_key": bool(settings.tm_api_key and settings.tm_api_key.get_secret_value()), + } + + +@router.post("/team-manager") +async def update_team_manager_settings(request: TeamManagerSettings): + """更新 Team Manager 设置""" + update_dict = { + "tm_enabled": request.enabled, + "tm_api_url": request.api_url, + } + if request.api_key: + update_dict["tm_api_key"] = request.api_key + update_settings(**update_dict) + return {"success": True, "message": "Team Manager 设置已更新"} + + +@router.post("/team-manager/test") +async def test_team_manager_connection(request: TeamManagerTestRequest): + """测试 Team Manager 连接""" + from ...core.team_manager import test_team_manager_connection as do_test + + settings = get_settings() + api_key = request.api_key + if api_key == 'use_saved_key' or not api_key: + if settings.tm_api_key: + api_key = settings.tm_api_key.get_secret_value() + else: + return {"success": False, "message": "未配置 API Key"} + + success, message = do_test(request.api_url, api_key) + return {"success": success, "message": message} diff --git a/static/js/accounts.js b/static/js/accounts.js index edfd81c..8fe35b5 100644 --- a/static/js/accounts.js +++ b/static/js/accounts.js @@ -9,6 +9,8 @@ let pageSize = 20; let totalAccounts = 0; let selectedAccounts = new Set(); let isLoading = false; +let selectAllPages = false; // 是否选中了全部页 +let currentFilters = { status: '', email_service: '', search: '' }; // 当前筛选条件 // DOM 元素 const elements = { @@ -24,6 +26,8 @@ const elements = { batchRefreshBtn: document.getElementById('batch-refresh-btn'), batchValidateBtn: document.getElementById('batch-validate-btn'), batchUploadCpaBtn: document.getElementById('batch-upload-cpa-btn'), + batchCheckSubBtn: document.getElementById('batch-check-sub-btn'), + batchUploadTmBtn: document.getElementById('batch-upload-tm-btn'), batchDeleteBtn: document.getElementById('batch-delete-btn'), exportBtn: document.getElementById('export-btn'), exportMenu: document.getElementById('export-menu'), @@ -42,6 +46,7 @@ document.addEventListener('DOMContentLoaded', () => { loadAccounts(); initEventListeners(); updateBatchButtons(); // 初始化按钮状态 + renderSelectAllBanner(); }); // 事件监听 @@ -49,17 +54,20 @@ function initEventListeners() { // 筛选 elements.filterStatus.addEventListener('change', () => { currentPage = 1; + resetSelectAllPages(); loadAccounts(); }); elements.filterService.addEventListener('change', () => { currentPage = 1; + resetSelectAllPages(); loadAccounts(); }); // 搜索(防抖) elements.searchInput.addEventListener('input', debounce(() => { currentPage = 1; + resetSelectAllPages(); loadAccounts(); }, 300)); @@ -68,6 +76,7 @@ function initEventListeners() { if (e.key === 'Escape') { elements.searchInput.blur(); elements.searchInput.value = ''; + resetSelectAllPages(); loadAccounts(); } }); @@ -88,10 +97,16 @@ function initEventListeners() { // 批量上传CPA elements.batchUploadCpaBtn.addEventListener('click', handleBatchUploadCpa); + // 批量检测订阅 + elements.batchCheckSubBtn.addEventListener('click', handleBatchCheckSubscription); + + // 批量上传TM + elements.batchUploadTmBtn.addEventListener('click', handleBatchUploadTm); + // 批量删除 elements.batchDeleteBtn.addEventListener('click', handleBatchDelete); - // 全选 + // 全选(当前页) elements.selectAll.addEventListener('change', (e) => { const checkboxes = elements.table.querySelectorAll('input[type="checkbox"][data-id]'); checkboxes.forEach(cb => { @@ -103,7 +118,11 @@ function initEventListeners() { selectedAccounts.delete(id); } }); + if (!e.target.checked) { + selectAllPages = false; + } updateBatchButtons(); + renderSelectAllBanner(); }); // 分页 @@ -196,21 +215,26 @@ async function loadAccounts() { `; + // 记录当前筛选条件 + currentFilters.status = elements.filterStatus.value; + currentFilters.email_service = elements.filterService.value; + currentFilters.search = elements.searchInput.value.trim(); + const params = new URLSearchParams({ page: currentPage, page_size: pageSize, }); - if (elements.filterStatus.value) { - params.append('status', elements.filterStatus.value); + if (currentFilters.status) { + params.append('status', currentFilters.status); } - if (elements.filterService.value) { - params.append('email_service', elements.filterService.value); + if (currentFilters.email_service) { + params.append('email_service', currentFilters.email_service); } - if (elements.searchInput.value.trim()) { - params.append('search', elements.searchInput.value.trim()); + if (currentFilters.search) { + params.append('search', currentFilters.search); } try { @@ -283,6 +307,13 @@ function renderAccounts(accounts) { : `-`} +