chore: initialize project repository

This commit is contained in:
GitHub Actions
2026-03-18 21:23:50 +08:00
commit 14120394ce
23 changed files with 15737 additions and 0 deletions

View File

@@ -0,0 +1,193 @@
[根目录](../CLAUDE.md) > **openai_pool_orchestrator (主包)**
# openai_pool_orchestrator -- 核心业务包
## 模块职责
项目唯一的 Python 包包含全部后端业务逻辑OpenAI 账号自动注册引擎、FastAPI REST API 服务、CPA / Sub2Api 双平台账号池维护、多邮箱提供商适配层,以及嵌入式 Web 前端静态资源。
## 入口与启动
| 入口 | 路径 | 说明 |
|------|------|------|
| Web 服务 | `__main__.py` -> `main()` | 启动 Uvicorn 服务器,监听 `0.0.0.0:18421`,加载 `server.app` |
| CLI 注册 | `register.py` -> `main()` | 命令行单次/循环注册模式,通过 argparse 接收参数 |
| 快捷脚本 | `../run.py` | 根据 `--cli` 标志分派到上述两种入口 |
| pip 命令 | `openai-pool` | pyproject.toml 注册的控制台入口,指向 `__main__:main` |
## 对外接口
### REST APIserver.py
FastAPI 应用提供 40+ 端点,核心分组:
**任务控制**
- `POST /api/start` -- 启动注册任务(支持多线程、目标数量、代理配置)
- `POST /api/stop` -- 停止运行中的任务
**代理管理**
- `POST /api/proxy/save` / `GET /api/proxy` -- 保存/获取代理地址
- `POST /api/check-proxy` -- 检测代理可用性
- `GET /api/proxy-pool/config` / `POST /api/proxy-pool/config` -- 代理池配置
- `POST /api/proxy-pool/test` -- 测试代理池取号
**配置管理**
- `GET /api/sync-config` / `POST /api/sync-config` -- Sub2Api 同步配置
- `GET /api/pool/config` / `POST /api/pool/config` -- CPA 池配置
- `GET /api/mail/config` / `POST /api/mail/config` -- 邮箱提供商配置
- `POST /api/upload-mode` -- 上传策略切换snapshot / decoupled
**Token 管理**
- `GET /api/tokens` -- 列出本地 Token 文件
- `DELETE /api/tokens/{filename}` -- 删除单个 Token
- `POST /api/sync-now` -- 单个 Token 导入 Sub2Api
- `POST /api/sync-batch` -- 批量导入
**CPA 池**
- `GET /api/pool/status` -- CPA 池状态
- `POST /api/pool/check` -- 探测 CPA 池
- `POST /api/pool/maintain` -- 执行 CPA 维护
- `POST /api/pool/auto` -- 开关自动维护
**Sub2Api 池**
- `GET /api/sub2api/accounts` -- 分页账号列表(支持状态/关键字筛选)
- `POST /api/sub2api/accounts/probe` -- 批量测活
- `POST /api/sub2api/accounts/delete` -- 批量删除
- `POST /api/sub2api/accounts/handle-exception` -- 异常处理
- `GET /api/sub2api/pool/status` -- Sub2Api 池状态
- `POST /api/sub2api/pool/maintain` -- Sub2Api 维护
- `POST /api/sub2api/pool/dedupe` -- 重复账号去重
**实时通信**
- `GET /api/logs` -- SSE 事件流结构化事件task_snapshot、runtime_snapshot、stats、log、pool_status 等)
### SSE 事件类型
| 事件类型 | 说明 |
|---------|------|
| `task_snapshot` | 任务全局状态快照 |
| `runtime_snapshot` | 多 Worker 运行时明细 |
| `stats` | 成功/失败计数统计 |
| `log` | 实时日志消息 |
| `pool_status` | CPA/Sub2Api 池状态变化 |
## 关键依赖与配置
### Python 依赖
| 包 | 用途 |
|----|------|
| `fastapi` >= 0.110 | Web 框架 |
| `uvicorn[standard]` >= 0.27 | ASGI 服务器 |
| `curl-cffi` >= 0.6 | TLS 指纹伪装 HTTP 客户端(模拟 Chrome |
| `aiohttp` >= 3.9 | 异步 HTTP池维护探测 |
| `requests` >= 2.31 | 同步 HTTP 客户端 |
| `pydantic` | 请求体校验FastAPI 内置) |
### 配置文件
- `data/sync_config.json` -- 运行时配置(从 `config/sync_config.example.json` 生成)
- `data/state.json` -- 累计成功/失败计数持久化
- `data/tokens/` -- 注册获取的 Token JSON 文件
### 核心配置项
| 配置键 | 类型 | 说明 |
|--------|------|------|
| `proxy` | str | 固定代理地址 |
| `auto_register` | bool | 池不足时自动注册 |
| `mail_providers` | list[str] | 启用的邮箱提供商列表 |
| `mail_strategy` | str | 邮箱路由策略round_robin / random / failover |
| `base_url` / `email` / `password` | str | Sub2Api 平台连接信息 |
| `cpa_base_url` / `cpa_token` | str | CPA 平台连接信息 |
| `upload_mode` | str | 上传策略snapshot串行/ decoupled双平台同传|
| `proxy_pool_*` | mixed | 代理池 API 配置 |
| `sub2api_maintain_actions` | dict | Sub2Api 维护动作开关 |
## 数据模型
### Token 文件格式data/tokens/*.json
注册成功后保存的 Token 文件包含 OAuth 凭证信息,用于后续导入平台。
### TaskStateserver.py
全局单例,管理注册任务的完整生命周期:
- 多 Worker 线程管理与运行时快照
- SSE 事件订阅/分发
- 成功/失败计数与平台上传统计
- 注册步骤追踪check_proxy -> create_email -> oauth_init -> sentinel -> signup -> send_otp -> wait_otp -> verify_otp -> create_account -> workspace -> get_token
### PoolMaintainerpool_maintainer.py
CPA 平台维护器:
- `fetch_auth_files()` -- 获取全部 auth 文件
- `get_pool_status()` -- 池状态统计
- `probe_accounts_async()` -- 异步批量探测账号有效性
### Sub2ApiMaintainerpool_maintainer.py
Sub2Api 平台维护器:
- `list_accounts()` / `_list_all_accounts()` -- 分页/全量列出账号
- `get_dashboard_stats()` -- 仪表盘统计
- 自动 token 刷新401 -> re-login
### MailProvider 体系mail_providers.py
抽象基类 + 4 种实现:
| 类名 | 提供商 | 认证方式 |
|------|--------|---------|
| `MailTmProvider` | Mail.tm | Bearer Token |
| `MoeMailProvider` | MoeMail | API Key |
| `DuckMailProvider` | DuckMail | Bearer Token |
| `CloudflareTempEmailProvider` | Cloudflare Workers | JWT + Admin Password |
`MultiMailRouter` -- 线程安全的多提供商路由器,支持轮询/随机/容错策略。
## 测试与质量
- **测试**:当前无测试套件
- **类型检查**:无 mypy/pyright 配置
- **Lint**:无 ruff/flake8 配置
- **CI/CD**:无
**建议优先覆盖的测试场景**
1. `mail_providers.py` -- 各提供商创建邮箱与 OTP 轮询的异常路径
2. `register.py` -- OAuth 流程各步骤的错误处理与重试
3. `server.py` -- 核心 API 端点的请求/响应校验
4. `pool_maintainer.py` -- 池状态计算与维护动作
## 常见问题 (FAQ)
**Q: server.py 文件为何如此庞大?**
A: 当前 server.py 约 3500+ 行,包含了全部 REST API 路由、TaskState 状态管理、平台交互逻辑、自动维护定时器等。建议后续拆分为路由模块、任务管理模块、平台交互模块等。
**Q: register.py 中 curl-cffi 的作用?**
A: 使用 `curl_cffi.requests` 而非标准 `requests`,可伪装 Chrome TLS 指纹,避免被 OpenAI / Cloudflare 反爬检测拦截。
**Q: 如何新增邮箱提供商?**
A: 继承 `MailProvider` 基类,实现 `create_mailbox()``wait_for_otp()` 方法,然后在 `create_provider_by_name()` 工厂函数中注册。
**Q: 双平台上传策略的区别?**
A: `snapshot` 模式按顺序先补 CPA 再补 Sub2Api`decoupled` 模式让单个账号同时上传到两个平台。
## 相关文件清单
| 文件 | 行数(估) | 说明 |
|------|----------|------|
| `__init__.py` | 29 | 包初始化,路径常量定义 |
| `__main__.py` | 119 | Uvicorn 启动与优雅关闭 |
| `server.py` | 3550+ | FastAPI 服务,全部 API 与任务状态 |
| `register.py` | 1600+ | OpenAI OAuth 注册引擎 |
| `pool_maintainer.py` | 800+ | CPA / Sub2Api 池维护 |
| `mail_providers.py` | 809 | 邮箱提供商抽象与 4 种实现 |
| `static/index.html` | 695 | Web UI 页面结构 |
| `static/app.js` | 2200+ | 前端交互逻辑 |
| `static/style.css` | 2800+ | iOS Flat Design 样式 |
## 变更记录 (Changelog)
| 时间 | 操作 | 说明 |
|------|------|------|
| 2026-03-18 09:19:57 | 初始扫描 | 首次生成模块级 CLAUDE.md |

View File

@@ -0,0 +1,28 @@
"""
OpenAI Pool Orchestrator
========================
自动化 OpenAI 账号注册、Token 管理与多平台账号池维护工具。
"""
__version__ = "2.0.0"
__author__ = "OpenAI Pool Orchestrator Contributors"
import os
from pathlib import Path
# 项目根目录(包目录的上一级)
PACKAGE_DIR = Path(__file__).parent
PROJECT_ROOT = PACKAGE_DIR.parent
# 运行时数据目录
DATA_DIR = PROJECT_ROOT / "data"
DATA_DIR.mkdir(exist_ok=True)
TOKENS_DIR = DATA_DIR / "tokens"
TOKENS_DIR.mkdir(exist_ok=True)
CONFIG_FILE = DATA_DIR / "sync_config.json"
STATE_FILE = DATA_DIR / "state.json"
# 前端静态文件目录
STATIC_DIR = PACKAGE_DIR / "static"

View File

@@ -0,0 +1,119 @@
"""
允许通过 python -m openai_pool_orchestrator 启动服务。
"""
import os
import sys
import threading
from typing import Callable
import uvicorn
from . import __version__
GRACEFUL_SHUTDOWN_TIMEOUT = 5
FORCE_EXIT_TIMEOUT = 3
def _request_server_shutdown(
server: uvicorn.Server,
notify_shutdown: Callable[[], None],
*,
force: bool = False,
message: str | None = None,
) -> None:
if message:
print(f"\n{message}")
server.should_exit = True
if force:
server.force_exit = True
notify_shutdown()
def _install_windows_ctrl_handler(
server: uvicorn.Server,
notify_shutdown: Callable[[], None],
):
import ctypes
kernel32 = ctypes.windll.kernel32
shutting_down = threading.Event()
shutdown_finished = threading.Event()
def _force_exit_after_timeout() -> None:
if shutdown_finished.wait(GRACEFUL_SHUTDOWN_TIMEOUT):
return
_request_server_shutdown(
server,
notify_shutdown,
force=True,
message="正在强制退出...",
)
if shutdown_finished.wait(FORCE_EXIT_TIMEOUT):
return
os._exit(130)
def _ctrl_handler(ctrl_type):
# CTRL_C_EVENT = 0, CTRL_BREAK_EVENT = 1
if ctrl_type not in (0, 1):
return False
if shutting_down.is_set():
_request_server_shutdown(
server,
notify_shutdown,
force=True,
message=None if server.force_exit else "正在强制退出...",
)
return True
shutting_down.set()
_request_server_shutdown(server, notify_shutdown, message="正在退出...")
threading.Thread(target=_force_exit_after_timeout, daemon=True).start()
return True
handler_routine = ctypes.WINFUNCTYPE(ctypes.c_int, ctypes.c_uint)
handler = handler_routine(_ctrl_handler)
kernel32.SetConsoleCtrlHandler(handler, True)
def _cleanup() -> None:
shutdown_finished.set()
try:
kernel32.SetConsoleCtrlHandler(handler, False)
except Exception:
pass
return _cleanup
def main() -> None:
print("=" * 50)
print(f" OpenAI Pool Orchestrator v{__version__}")
print(" 访问: http://localhost:18421")
print(" 按 Ctrl+C 可退出")
print("=" * 50)
from .server import app, request_service_shutdown
config = uvicorn.Config(
app,
host="0.0.0.0",
port=18421,
log_level="warning",
timeout_graceful_shutdown=GRACEFUL_SHUTDOWN_TIMEOUT,
)
server = uvicorn.Server(config)
cleanup_ctrl_handler = None
if sys.platform == "win32":
cleanup_ctrl_handler = _install_windows_ctrl_handler(server, request_service_shutdown)
try:
server.run()
finally:
if cleanup_ctrl_handler is not None:
cleanup_ctrl_handler()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,812 @@
"""
MailProvider 抽象层
支持 Mail.tm / MoeMail / DuckMail / 自定义兼容 API
"""
from __future__ import annotations
import itertools
import logging
import random
import re
import secrets
import string
import time
import threading
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional, Tuple, Callable
import requests as _requests
from requests.adapters import HTTPAdapter
import urllib3
from urllib3.exceptions import InsecureRequestWarning
from urllib3.util.retry import Retry
logger = logging.getLogger(__name__)
urllib3.disable_warnings(InsecureRequestWarning)
def _normalize_proxy_url(proxy: str) -> str:
value = str(proxy or "").strip()
if not value:
return ""
if "://" in value:
return value
if ":" in value:
return f"http://{value}"
return ""
class _ProxyAwareSession(_requests.Session):
def __init__(
self,
proxy: str = "",
proxy_selector: Optional[Callable[[], str]] = None,
):
super().__init__()
self._default_proxy = _normalize_proxy_url(proxy)
self._proxy_selector = proxy_selector
def request(self, method, url, **kwargs):
selected_proxy = ""
if self._proxy_selector:
try:
selected_proxy = _normalize_proxy_url(self._proxy_selector() or "")
except Exception:
selected_proxy = ""
if not selected_proxy:
selected_proxy = self._default_proxy
base_kwargs = dict(kwargs)
if selected_proxy and "proxies" not in base_kwargs:
base_kwargs["proxies"] = {"http": selected_proxy, "https": selected_proxy}
try:
return super().request(method, url, **base_kwargs)
except Exception:
# 动态代理失败时,自动回退固定代理(若有)
if (
selected_proxy
and self._default_proxy
and selected_proxy != self._default_proxy
and "proxies" not in kwargs
):
fallback_kwargs = dict(kwargs)
fallback_kwargs["proxies"] = {"http": self._default_proxy, "https": self._default_proxy}
return super().request(method, url, **fallback_kwargs)
raise
def _build_session(proxy: str = "", proxy_selector: Optional[Callable[[], str]] = None) -> _requests.Session:
s = _ProxyAwareSession(proxy, proxy_selector)
retry_total = 0 if proxy_selector else 2
retry = Retry(
total=retry_total,
connect=retry_total,
read=retry_total,
status=retry_total,
backoff_factor=0.2,
status_forcelist=[429, 500, 502, 503, 504],
)
adapter = HTTPAdapter(max_retries=retry)
s.mount("https://", adapter)
s.mount("http://", adapter)
fixed_proxy = _normalize_proxy_url(proxy)
if fixed_proxy and not proxy_selector:
s.proxies = {"http": fixed_proxy, "https": fixed_proxy}
return s
def _extract_code(content: str) -> Optional[str]:
if not content:
return None
m = re.search(r"background-color:\s*#F3F3F3[^>]*>[\s\S]*?(\d{6})[\s\S]*?</p>", content)
if m:
return m.group(1)
for pat in [
r"Verification code:?\s*(\d{6})",
r"code is\s*(\d{6})",
r"Subject:.*?(\d{6})",
r">\s*(\d{6})\s*<",
r"(?<![#&])\b(\d{6})\b",
]:
for code in re.findall(pat, content, re.IGNORECASE):
return code
return None
# ==================== 抽象基类 ====================
class MailProvider(ABC):
@abstractmethod
def create_mailbox(
self,
proxy: str = "",
proxy_selector: Optional[Callable[[], str]] = None,
) -> Tuple[str, str]:
"""返回 (email, auth_credential)auth_credential 是 bearer token 或 email_id"""
@abstractmethod
def wait_for_otp(
self,
auth_credential: str,
email: str,
proxy: str = "",
proxy_selector: Optional[Callable[[], str]] = None,
timeout: int = 120,
stop_event: Optional[threading.Event] = None,
) -> str:
"""轮询获取6位验证码超时返回空字符串"""
def test_connection(self, proxy: str = "") -> Tuple[bool, str]:
"""测试 API 连通性,返回 (success, message)"""
try:
email, cred = self.create_mailbox(proxy)
if email and cred:
return True, f"成功创建测试邮箱: {email}"
return False, "创建邮箱失败,请检查配置"
except Exception as e:
return False, f"连接失败: {e}"
def close(self):
pass
# ==================== Mail.tm ====================
class MailTmProvider(MailProvider):
def __init__(self, api_base: str = "https://api.mail.tm"):
self.api_base = api_base.rstrip("/")
def _headers(self, token: str = "", use_json: bool = False) -> Dict[str, str]:
h: Dict[str, str] = {"Accept": "application/json"}
if use_json:
h["Content-Type"] = "application/json"
if token:
h["Authorization"] = f"Bearer {token}"
return h
def _get_domains(self, session: _requests.Session) -> List[str]:
resp = session.get(f"{self.api_base}/domains", headers=self._headers(), timeout=15, verify=False)
if resp.status_code != 200:
return []
data = resp.json()
items = data if isinstance(data, list) else (data.get("hydra:member") or data.get("items") or [])
domains = []
for item in items:
if not isinstance(item, dict):
continue
domain = str(item.get("domain") or "").strip()
if domain and item.get("isActive", True) and not item.get("isPrivate", False):
domains.append(domain)
return domains
def create_mailbox(
self,
proxy: str = "",
proxy_selector: Optional[Callable[[], str]] = None,
) -> Tuple[str, str]:
with _build_session(proxy, proxy_selector) as session:
domains = self._get_domains(session)
if not domains:
return "", ""
# 优先使用 duckmail.sbs 主域名,避免临时域名被 OpenAI 封禁
_preferred = [d for d in domains if "duckmail" in d.lower()]
domain = random.choice(_preferred) if _preferred else random.choice(domains)
for _ in range(5):
local = f"oc{secrets.token_hex(5)}"
email = f"{local}@{domain}"
password = secrets.token_urlsafe(18)
resp = session.post(
f"{self.api_base}/accounts",
headers=self._headers(use_json=True),
json={"address": email, "password": password},
timeout=15, verify=False,
)
if resp.status_code not in (200, 201):
continue
token_resp = session.post(
f"{self.api_base}/token",
headers=self._headers(use_json=True),
json={"address": email, "password": password},
timeout=15, verify=False,
)
if token_resp.status_code == 200:
token = str(token_resp.json().get("token") or "").strip()
if token:
return email, token
return "", ""
def wait_for_otp(
self,
auth_credential: str,
email: str,
proxy: str = "",
proxy_selector: Optional[Callable[[], str]] = None,
timeout: int = 120,
stop_event: Optional[threading.Event] = None,
) -> str:
with _build_session(proxy, proxy_selector) as session:
seen_ids: set = set()
start = time.time()
while time.time() - start < timeout:
if stop_event and stop_event.is_set():
return ""
try:
resp = session.get(
f"{self.api_base}/messages",
headers=self._headers(token=auth_credential),
timeout=15, verify=False,
)
if resp.status_code != 200:
time.sleep(3)
continue
data = resp.json()
messages = data if isinstance(data, list) else (
data.get("hydra:member") or data.get("messages") or []
)
for msg in messages:
if not isinstance(msg, dict):
continue
msg_id = str(msg.get("id") or msg.get("@id") or "").strip()
if not msg_id or msg_id in seen_ids:
continue
if msg_id.startswith("/messages/"):
msg_id = msg_id.split("/")[-1]
detail_resp = session.get(
f"{self.api_base}/messages/{msg_id}",
headers=self._headers(token=auth_credential),
timeout=15, verify=False,
)
if detail_resp.status_code != 200:
continue
seen_ids.add(msg_id)
mail_data = detail_resp.json()
sender = str(((mail_data.get("from") or {}).get("address") or "")).lower()
subject = str(mail_data.get("subject") or "")
intro = str(mail_data.get("intro") or "")
text = str(mail_data.get("text") or "")
html = mail_data.get("html") or ""
if isinstance(html, list):
html = "\n".join(str(x) for x in html)
content = "\n".join([subject, intro, text, str(html)])
if "openai" not in sender and "openai" not in content.lower():
continue
code = _extract_code(content)
if code:
return code
except Exception as exc:
logger.warning("Mail.tm 轮询验证码失败: %s", exc)
time.sleep(3)
return ""
# ==================== MoeMail ====================
class MoeMailProvider(MailProvider):
def __init__(self, api_base: str, api_key: str):
self.api_base = api_base.rstrip("/")
self.api_key = api_key
def _headers(self) -> Dict[str, str]:
return {"X-API-Key": self.api_key}
def _get_domain(self, session: _requests.Session) -> Optional[str]:
try:
resp = session.get(
f"{self.api_base}/api/config",
headers=self._headers(), timeout=10, verify=False,
)
if resp.status_code == 200:
data = resp.json()
domains_str = data.get("emailDomains", "")
if domains_str:
domains = [d.strip() for d in domains_str.split(",") if d.strip()]
if domains:
return random.choice(domains)
except Exception as exc:
logger.warning("MoeMail 读取域名配置失败: %s", exc)
return None
def create_mailbox(
self,
proxy: str = "",
proxy_selector: Optional[Callable[[], str]] = None,
) -> Tuple[str, str]:
with _build_session(proxy, proxy_selector) as session:
domain = self._get_domain(session)
if not domain:
return "", ""
chars = string.ascii_lowercase + string.digits
prefix = "".join(random.choice(chars) for _ in range(random.randint(8, 13)))
try:
resp = session.post(
f"{self.api_base}/api/emails/generate",
json={"name": prefix, "domain": domain, "expiryTime": 0},
headers=self._headers(), timeout=15, verify=False,
)
if resp.status_code not in (200, 201):
return "", ""
data = resp.json()
email_id = data.get("id")
email = data.get("email")
if email_id and email:
return email, str(email_id)
except Exception as exc:
logger.warning("MoeMail 创建邮箱失败: %s", exc)
return "", ""
def wait_for_otp(
self,
auth_credential: str,
email: str,
proxy: str = "",
proxy_selector: Optional[Callable[[], str]] = None,
timeout: int = 120,
stop_event: Optional[threading.Event] = None,
) -> str:
with _build_session(proxy, proxy_selector) as session:
email_id = auth_credential
start = time.time()
while time.time() - start < timeout:
if stop_event and stop_event.is_set():
return ""
try:
resp = session.get(
f"{self.api_base}/api/emails/{email_id}",
headers=self._headers(), timeout=15, verify=False,
)
if resp.status_code == 200:
messages = resp.json().get("messages") or []
for msg in messages:
if not isinstance(msg, dict):
continue
msg_id = msg.get("id")
if not msg_id:
continue
detail_resp = session.get(
f"{self.api_base}/api/emails/{email_id}/{msg_id}",
headers=self._headers(), timeout=15, verify=False,
)
if detail_resp.status_code == 200:
detail = detail_resp.json()
msg_obj = detail.get("message") or {}
content = msg_obj.get("content") or msg_obj.get("html") or ""
if not content:
content = detail.get("text") or detail.get("html") or ""
code = _extract_code(content)
if code:
return code
except Exception as exc:
logger.warning("MoeMail 轮询验证码失败: %s", exc)
time.sleep(3)
return ""
# ==================== DuckMail ====================
class DuckMailProvider(MailProvider):
def __init__(self, api_base: str = "https://api.duckmail.sbs", bearer_token: str = ""):
self.api_base = api_base.rstrip("/")
self.bearer_token = bearer_token
def _auth_headers(self, token: str = "") -> Dict[str, str]:
h: Dict[str, str] = {"Accept": "application/json"}
if token:
h["Authorization"] = f"Bearer {token}"
return h
def create_mailbox(
self,
proxy: str = "",
proxy_selector: Optional[Callable[[], str]] = None,
) -> Tuple[str, str]:
with _build_session(proxy, proxy_selector) as session:
headers: Dict[str, str] = {"Content-Type": "application/json", "Accept": "application/json"}
if self.bearer_token:
headers["Authorization"] = f"Bearer {self.bearer_token}"
try:
domains_resp = session.get(f"{self.api_base}/domains", headers={"Accept": "application/json"}, timeout=15, verify=False)
if domains_resp.status_code != 200:
return "", ""
data = domains_resp.json()
items = data if isinstance(data, list) else (data.get("hydra:member") or [])
domains = [str(i.get("domain") or "") for i in items if isinstance(i, dict) and i.get("domain") and i.get("isActive", True)]
if not domains:
return "", ""
# 优先使用 duckmail.sbs 主域名,避免临时域名被 OpenAI 封禁
_preferred = [d for d in domains if "duckmail" in d.lower()]
domain = random.choice(_preferred) if _preferred else random.choice(domains)
local = f"oc{secrets.token_hex(5)}"
email = f"{local}@{domain}"
password = secrets.token_urlsafe(18)
resp = session.post(
f"{self.api_base}/accounts",
json={"address": email, "password": password},
headers=headers, timeout=30, verify=False,
)
if resp.status_code not in (200, 201):
return "", ""
time.sleep(0.5)
token_resp = session.post(
f"{self.api_base}/token",
json={"address": email, "password": password},
headers=headers, timeout=30, verify=False,
)
if token_resp.status_code == 200:
mail_token = token_resp.json().get("token")
if mail_token:
return email, str(mail_token)
except Exception as exc:
logger.warning("DuckMail 创建邮箱失败: %s", exc)
return "", ""
def wait_for_otp(
self,
auth_credential: str,
email: str,
proxy: str = "",
proxy_selector: Optional[Callable[[], str]] = None,
timeout: int = 120,
stop_event: Optional[threading.Event] = None,
) -> str:
with _build_session(proxy, proxy_selector) as session:
seen_ids: set = set()
start = time.time()
while time.time() - start < timeout:
if stop_event and stop_event.is_set():
return ""
try:
resp = session.get(
f"{self.api_base}/messages",
headers=self._auth_headers(auth_credential),
timeout=30, verify=False,
)
if resp.status_code == 200:
data = resp.json()
messages = data.get("hydra:member") or data.get("member") or data.get("data") or []
for msg in (messages if isinstance(messages, list) else []):
if not isinstance(msg, dict):
continue
msg_id = msg.get("id") or msg.get("@id")
if not msg_id or msg_id in seen_ids:
continue
raw_id = str(msg_id).split("/")[-1] if str(msg_id).startswith("/") else str(msg_id)
detail_resp = session.get(
f"{self.api_base}/messages/{raw_id}",
headers=self._auth_headers(auth_credential),
timeout=30, verify=False,
)
if detail_resp.status_code == 200:
seen_ids.add(msg_id)
detail = detail_resp.json()
content = detail.get("text") or detail.get("html") or ""
code = _extract_code(content)
if code:
return code
except Exception as exc:
logger.warning("DuckMail 轮询验证码失败: %s", exc)
time.sleep(3)
return ""
# ==================== Cloudflare Temp Email ====================
class CloudflareTempEmailProvider(MailProvider):
def __init__(self, api_base: str = "", admin_password: str = "", domain: str = ""):
self.api_base = api_base.rstrip("/")
self.admin_password = admin_password
self.domain = str(domain).strip()
# 使用线程本地 token避免多线程下邮箱 token 串用。
self._tls = threading.local()
def _get_random_domain(self) -> str:
if not self.domain:
return ""
# 尝试按照 JSON 数组解析
if self.domain.startswith("[") and self.domain.endswith("]"):
try:
import json
domain_list = json.loads(self.domain)
if isinstance(domain_list, list) and domain_list:
return random.choice([str(d).strip() for d in domain_list if str(d).strip()])
except Exception:
pass
# 按照逗号分隔解析
if "," in self.domain:
parts = [d.strip() for d in self.domain.split(",") if d.strip()]
if parts:
return random.choice(parts)
return self.domain
@staticmethod
def _message_matches_email(msg: Dict[str, Any], target_email: str) -> bool:
target = str(target_email or "").strip().lower()
if not target:
return True
def _extract_text_candidates(value: Any) -> List[str]:
out: List[str] = []
if isinstance(value, str):
out.append(value)
elif isinstance(value, dict):
for k in ("address", "email", "name", "value"):
if value.get(k):
out.extend(_extract_text_candidates(value.get(k)))
elif isinstance(value, list):
for item in value:
out.extend(_extract_text_candidates(item))
return out
candidates: List[str] = []
for key in ("to", "mailTo", "receiver", "receivers", "address", "email", "envelope_to"):
if key in msg:
candidates.extend(_extract_text_candidates(msg.get(key)))
if not candidates:
return True
target_lower = target.lower()
for raw in candidates:
text = str(raw or "").strip().lower()
if not text:
continue
if target_lower in text:
return True
return False
def create_mailbox(
self,
proxy: str = "",
proxy_selector: Optional[Callable[[], str]] = None,
) -> Tuple[str, str]:
if not self.api_base or not self.admin_password or not self.domain:
return "", ""
with _build_session(proxy, proxy_selector) as session:
try:
# 生成5位字母 + 1-3位数字 + 1-3位字母的随机名
letters1 = ''.join(random.choices(string.ascii_lowercase, k=5))
numbers = ''.join(random.choices(string.digits, k=random.randint(1, 3)))
letters2 = ''.join(random.choices(string.ascii_lowercase, k=random.randint(1, 3)))
name = letters1 + numbers + letters2
target_domain = self._get_random_domain()
if not target_domain:
return "", ""
resp = session.post(
f"{self.api_base}/admin/new_address",
json={
"enablePrefix": True,
"name": name,
"domain": target_domain,
},
headers={
"x-admin-auth": self.admin_password,
"Content-Type": "application/json"
},
timeout=30, verify=False,
)
if resp.status_code == 200:
data = resp.json()
email = data.get("address")
jwt_token = data.get("jwt")
if email and jwt_token:
self._tls.jwt_token = jwt_token
return email, jwt_token
except Exception as exc:
logger.warning("Cloudflare 临时邮箱创建失败: %s", exc)
return "", ""
def wait_for_otp(
self,
auth_credential: str,
email: str,
proxy: str = "",
proxy_selector: Optional[Callable[[], str]] = None,
timeout: int = 120,
stop_event: Optional[threading.Event] = None,
) -> str:
token = str(auth_credential or "").strip() or str(getattr(self._tls, "jwt_token", "") or "").strip()
if not token:
return ""
print(f"[CFMail] wait_for_otp 进入! email={email}, api_base={self.api_base}, jwt前16={token[:16] if token else 'EMPTY'}", flush=True)
with _build_session(proxy, proxy_selector) as session:
seen_ids: set = set()
start = time.time()
poll_count = 0
while time.time() - start < timeout:
if stop_event and stop_event.is_set():
print("[CFMail] stop_event 已触发,退出", flush=True)
return ""
try:
poll_count += 1
url = f"{self.api_base}/api/mails?limit=10&offset=0"
resp = session.get(
url,
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
},
timeout=30, verify=False,
)
print(f"[CFMail] 轮询#{poll_count} status={resp.status_code}, body前200={str(resp.text or '')[:200]}", flush=True)
if resp.status_code == 200:
try:
data = resp.json()
except Exception as je:
print(f"[CFMail] JSON解析失败: {je}", flush=True)
time.sleep(3)
continue
# API 返回字典 {"results": [...], "count": 0},需正确提取
if isinstance(data, dict):
messages = data.get("results") or []
elif isinstance(data, list):
messages = data
else:
messages = []
print(f"[CFMail] 解析到 {len(messages)} 条邮件", flush=True)
for msg in messages:
if not isinstance(msg, dict):
continue
if not self._message_matches_email(msg, email):
continue
msg_id = msg.get("id")
if not msg_id or msg_id in seen_ids:
continue
seen_ids.add(msg_id)
content = msg.get("text") or msg.get("html") or ""
# Cloudflare Temp Email 将邮件原文放在 raw 字段MIME 格式)
if not content and msg.get("raw"):
try:
import email as _email_mod
from email import policy
parsed = _email_mod.message_from_string(msg["raw"], policy=policy.default)
# 优先取纯文本
body = parsed.get_body(preferencelist=('plain', 'html'))
if body:
content = body.get_content() or ""
if not content:
# 回退:遍历所有 part
for part in parsed.walk():
ctype = part.get_content_type()
if ctype in ("text/plain", "text/html"):
payload = part.get_content()
if payload:
content = str(payload)
break
except Exception as parse_err:
print(f"[CFMail] MIME解析失败回退raw: {parse_err}", flush=True)
content = msg.get("raw", "")
print(f"[CFMail] 邮件id={msg_id}, 内容前200={content[:200]}", flush=True)
code = _extract_code(content)
if code:
print(f"[CFMail] 成功提取验证码: {code}", flush=True)
return code
except Exception as e:
print(f"[CFMail] 轮询异常: {e}", flush=True)
time.sleep(3)
print("[CFMail] wait_for_otp 超时, 未获取到验证码", flush=True)
return ""
# ==================== 多提供商路由 ====================
class MultiMailRouter:
"""线程安全的多邮箱提供商路由器,支持轮询/随机/容错策略"""
def __init__(self, config: Dict[str, Any]):
providers_list: List[str] = config.get("mail_providers") or []
provider_configs: Dict[str, Dict] = config.get("mail_provider_configs") or {}
self.strategy: str = config.get("mail_strategy", "round_robin")
if not providers_list:
legacy = config.get("mail_provider", "mailtm")
providers_list = [legacy]
provider_configs = {legacy: config.get("mail_config") or {}}
self._provider_names: List[str] = []
self._providers: Dict[str, MailProvider] = {}
self._failures: Dict[str, int] = {}
self._lock = threading.RLock()
self._counter = itertools.count()
for name in providers_list:
try:
p = create_provider_by_name(name, provider_configs.get(name, {}))
self._provider_names.append(name)
self._providers[name] = p
self._failures[name] = 0
except Exception as e:
logger.warning("创建邮箱提供商 %s 失败: %s", name, e)
if not self._providers:
if providers_list:
raise RuntimeError(f"邮箱提供商配置无效: {', '.join(str(n) for n in providers_list)}")
fallback = create_provider_by_name("mailtm", {})
self._provider_names = ["mailtm"]
self._providers = {"mailtm": fallback}
self._failures = {"mailtm": 0}
def next_provider(self) -> Tuple[str, MailProvider]:
with self._lock:
names = self._provider_names
if not names:
raise RuntimeError("无可用邮箱提供商")
if self.strategy == "random":
name = random.choice(names)
elif self.strategy == "failover":
name = min(names, key=lambda n: self._failures.get(n, 0))
else:
idx = next(self._counter) % len(names)
name = names[idx]
return name, self._providers[name]
def providers(self) -> List[Tuple[str, MailProvider]]:
with self._lock:
return [(n, self._providers[n]) for n in self._provider_names]
def report_success(self, provider_name: str) -> None:
with self._lock:
self._failures[provider_name] = max(0, self._failures.get(provider_name, 0) - 1)
def report_failure(self, provider_name: str) -> None:
with self._lock:
self._failures[provider_name] = self._failures.get(provider_name, 0) + 1
# ==================== 工厂函数 ====================
def create_provider_by_name(provider_type: str, mail_cfg: Dict[str, Any]) -> MailProvider:
"""根据提供商名称和单独配置创建实例"""
provider_type = provider_type.lower().strip()
api_base = str(mail_cfg.get("api_base", "")).strip()
if provider_type == "moemail":
return MoeMailProvider(
api_base=api_base or "https://your-moemail-api.example.com",
api_key=str(mail_cfg.get("api_key", "")).strip(),
)
elif provider_type == "duckmail":
return DuckMailProvider(
api_base=api_base or "https://api.duckmail.sbs",
bearer_token=str(mail_cfg.get("bearer_token", "")).strip(),
)
elif provider_type == "cloudflare_temp_email":
return CloudflareTempEmailProvider(
api_base=api_base,
admin_password=str(mail_cfg.get("admin_password", "")).strip(),
domain=str(mail_cfg.get("domain", "")).strip(),
)
elif provider_type == "mailtm":
return MailTmProvider(api_base=api_base or "https://api.mail.tm")
raise ValueError(f"未知邮箱提供商: {provider_type}")
def create_provider(config: Dict[str, Any]) -> MailProvider:
"""兼容旧配置格式的工厂函数"""
provider_type = str(config.get("mail_provider", "mailtm")).lower()
mail_cfg = config.get("mail_config") or {}
return create_provider_by_name(provider_type, mail_cfg)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

3556
openai_pool_orchestrator/server.py Executable file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,694 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenAI Pool Orchestrator</title>
<meta name="description" content="OpenAI 账号池编排器 — Web 可视化界面" />
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<!-- ========== 头部 ========== -->
<header>
<div class="header-brand">
<div class="logo">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a4 4 0 0 0-4 4v2H6a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V10a2 2 0 0 0-2-2h-2V6a4 4 0 0 0-4-4z"/><circle cx="12" cy="15" r="2"/></svg>
</div>
<h1>Pool Orchestrator</h1>
<span class="version">v5.2.1</span>
</div>
<nav class="tab-nav header-tab-nav" role="tablist" aria-label="主导航">
<div class="segmented-control">
<div class="segment-indicator" id="segmentIndicator"></div>
<button class="tab-btn active" data-tab="tabDashboard" role="tab" aria-selected="true" aria-controls="tabDashboard">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
<span>仪表盘</span>
</button>
<button class="tab-btn" data-tab="tabConfig" role="tab" aria-selected="false" aria-controls="tabConfig">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
<span>配置中心</span>
</button>
</div>
</nav>
<div class="header-right">
<div id="headerSub2apiChip" class="pool-chip status-idle interactive-chip" title="切换到 Sub2Api 视图" role="button" tabindex="0" aria-controls="dataPanelSub2Api" aria-pressed="true">
<span class="pool-chip-name">Sub2Api</span>
<span id="headerSub2apiLabel" class="pool-chip-value">-- / --</span>
<span id="headerSub2apiDelta" class="pool-chip-delta">--</span>
<div class="pool-chip-track">
<div id="headerSub2apiBar" class="pool-chip-fill" style="width:0%"></div>
</div>
</div>
<div id="headerCpaChip" class="pool-chip status-idle interactive-chip" title="切换到 CPA 视图" role="button" tabindex="0" aria-controls="dataPanelCpa" aria-pressed="false">
<span class="pool-chip-name">CPA</span>
<span id="headerCpaLabel" class="pool-chip-value">-- / --</span>
<span id="headerCpaDelta" class="pool-chip-delta">--</span>
<div class="pool-chip-track">
<div id="headerCpaBar" class="pool-chip-fill" style="width:0%"></div>
</div>
</div>
<div id="headerLocalTokenChip" class="pool-chip status-idle interactive-chip" title="切换到本地 Token 视图" role="button" tabindex="0" aria-controls="dataPanelLocalTokens" aria-pressed="false">
<span class="pool-chip-name">Local Token</span>
<span id="headerLocalTokenLabel" class="pool-chip-value">-- / --</span>
<span id="headerLocalTokenDelta" class="pool-chip-delta">--</span>
<div class="pool-chip-track">
<div id="headerLocalTokenBar" class="pool-chip-fill" style="width:0%"></div>
</div>
</div>
<div id="statusBadge" class="status-badge idle">
<span id="statusDot" class="status-dot"></span>
<span id="statusText">空闲</span>
</div>
<button id="themeToggleBtn" class="theme-toggle-btn" type="button" aria-label="切换到明亮主题" title="切换到明亮主题">
<span class="theme-toggle-icon" aria-hidden="true"></span>
<span class="theme-toggle-label">黑暗</span>
</button>
</div>
</header>
<!-- ========== 进度条 ========== -->
<div class="progress-bar">
<div id="progressFill" class="progress-fill"></div>
</div>
<!-- ========== 仪表盘 Tab ========== -->
<div id="tabDashboard" class="tab-panel active" role="tabpanel">
<div class="app-shell">
<!-- ===== 左栏:控制面板 ===== -->
<aside class="sidebar">
<!-- 代理配置 -->
<div class="panel-section">
<div class="section-title">代理配置</div>
<div class="proxy-row">
<div class="input-wrapper">
<input type="text" id="proxyInput" placeholder="http://127.0.0.1:7897" value="http://127.0.0.1:7897"
autocomplete="off" spellcheck="false" />
</div>
<button id="checkProxyBtn" class="btn btn-ghost btn-sm">检测</button>
<button id="saveProxyBtn" class="btn btn-ghost btn-sm">保存</button>
</div>
<div id="proxyStatus" class="proxy-status">
<span>点击「检测」验证代理可用性</span>
</div>
</div>
<!-- 任务控制 -->
<div class="panel-section">
<div class="section-title">任务控制</div>
<div class="toggle-row">
<label class="ios-toggle-label" for="multithreadCheck">
<span class="ios-toggle">
<input type="checkbox" id="multithreadCheck" />
<span class="toggle-track"></span>
</span>
<span>多线程</span>
</label>
<div class="thread-count-wrap">
<label>线程</label>
<input type="number" id="threadCountInput" value="3" min="1" max="10" />
</div>
</div>
<div class="toggle-row">
<label class="ios-toggle-label" for="autoRegisterCheck">
<span class="ios-toggle">
<input type="checkbox" id="autoRegisterCheck" />
<span class="toggle-track"></span>
</span>
<span>池不足自动注册</span>
</label>
</div>
<div class="control-buttons">
<button id="btnStart" class="btn btn-success">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
启动
</button>
<button id="btnStop" class="btn btn-danger" disabled>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="4" y="4" width="16" height="16" rx="2"/></svg>
停止
</button>
</div>
</div>
<!-- 注册步骤追踪 -->
<div class="panel-section">
<div class="section-title">注册进度</div>
<div class="progress-section-block">
<div class="progress-subtitle">任务概览</div>
<div id="taskOverview" class="task-overview-grid">
<div class="task-overview-card empty">等待任务启动</div>
</div>
</div>
<div class="progress-section-block">
<div class="progress-subtitle progress-subtitle-row">
<span>Worker 列表</span>
<button id="unlockFocusBtn" class="btn btn-ghost btn-sm" type="button" disabled>跟随后端焦点</button>
</div>
<div id="workerList" class="worker-list">
<div class="worker-card empty">暂无 Worker 运行</div>
</div>
</div>
<div class="progress-section-block">
<div class="progress-subtitle">焦点 Worker 明细</div>
<div id="workerDetail" class="worker-detail-card empty">等待任务启动</div>
</div>
</div>
</aside>
<!-- 拖拽分隔条(左) -->
<div class="resize-handle" id="resizeLeft"></div>
<!-- ===== 中栏:日志面板 ===== -->
<main class="main-area">
<div class="log-panel">
<div class="log-header">
<div class="log-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
<span>实时日志</span>
<span id="logCount" class="log-count-badge">0</span>
</div>
<div class="log-actions">
<label class="ios-toggle-label log-autoscroll-label" for="autoScrollCheck">
<span class="ios-toggle ios-toggle-sm">
<input type="checkbox" id="autoScrollCheck" checked />
<span class="toggle-track"></span>
</span>
<span>自动滚动</span>
</label>
<button id="clearLogBtn" class="btn btn-ghost btn-sm">清空</button>
</div>
</div>
<div id="logBody" class="log-body">
<div class="log-entry log-placeholder">
等待任务启动...
</div>
</div>
</div>
</main>
<!-- 拖拽分隔条(右) -->
<div class="resize-handle" id="resizeRight"></div>
<!-- ===== 右栏:池状态 + Token ===== -->
<aside class="data-panel">
<div class="remote-panel-wrap">
<div class="data-panel-body">
<div id="dataPanelSub2Api" class="data-panel-section active" role="tabpanel" aria-labelledby="headerSub2apiChip">
<div class="pool-section-header">
<div class="pool-section-title">
<span>Sub2Api 账号池</span>
</div>
<div class="pool-section-actions">
<button id="sub2apiPoolRefreshBtn" class="btn btn-ghost btn-sm">刷新</button>
<button id="sub2apiPoolMaintainBtn" class="btn btn-primary btn-sm">维护</button>
<span id="sub2apiPoolMaintainStatus" class="inline-status"></span>
</div>
</div>
<div class="pool-overview">
<div class="pool-stat-card">
<div id="sub2apiPoolTotal" class="stat-value blue">--</div>
<div class="stat-label">总账号</div>
</div>
<div class="pool-stat-card">
<div id="sub2apiPoolNormal" class="stat-value green">--</div>
<div class="stat-label">正常</div>
</div>
<div class="pool-stat-card">
<div id="sub2apiPoolError" class="stat-value red">--</div>
<div class="stat-label">异常</div>
</div>
<div class="pool-stat-card">
<div id="sub2apiPoolThreshold" class="stat-value muted">--</div>
<div class="stat-label">目标</div>
</div>
<div class="pool-stat-card">
<div id="sub2apiPoolPercent" class="stat-value">--</div>
<div class="stat-label">充足率</div>
</div>
</div>
<div class="sub2api-account-wrap">
<div class="token-filter-row">
<select id="sub2apiAccountStatusFilter" class="token-filter-select">
<option value="all">全部状态</option>
<option value="normal">仅正常</option>
<option value="abnormal">仅异常</option>
<option value="error">仅 Error</option>
<option value="disabled">仅 Disabled</option>
<option value="duplicate">仅重复</option>
</select>
<input id="sub2apiAccountKeyword" type="text" placeholder="筛选邮箱 / ID / 名称" autocomplete="off" autocapitalize="off" spellcheck="false" data-lpignore="true" />
<button id="sub2apiAccountApplyBtn" class="btn btn-ghost btn-sm">筛选</button>
<button id="sub2apiAccountResetBtn" class="btn btn-ghost btn-sm">重置</button>
</div>
<div class="token-filter-row account-toolbar">
<label class="account-select-all" for="sub2apiAccountSelectAll">
<input type="checkbox" id="sub2apiAccountSelectAll" />
<span>全选当前页</span>
</label>
<button id="sub2apiAccountProbeBtn" class="btn btn-ghost btn-sm">测活选中</button>
<button id="sub2apiAccountExceptionBtn" class="btn btn-ghost btn-sm">异常处理</button>
<button id="sub2apiDuplicateScanBtn" class="btn btn-ghost btn-sm">重复检测</button>
<button id="sub2apiDuplicateCleanBtn" class="btn btn-primary btn-sm">清理重复</button>
<button id="sub2apiAccountDeleteBtn" class="btn btn-danger btn-sm">批量删除</button>
<span id="sub2apiAccountSelection" class="inline-status"></span>
</div>
<div id="sub2apiAccountList" class="token-list sub2api-account-list">
<div class="empty-state">
<div class="empty-icon"></div>
<span>正在加载 Sub2Api 账号列表...</span>
</div>
</div>
<div class="pool-table-footer">
<div class="account-pager">
<button id="sub2apiAccountPrevBtn" class="btn btn-ghost btn-sm">上一页</button>
<span id="sub2apiAccountPageInfo" class="pager-info">第 1/1 页 · 每页 20 条</span>
<button id="sub2apiAccountNextBtn" class="btn btn-ghost btn-sm">下一页</button>
<select id="sub2apiAccountPageSize" class="token-filter-select pager-size-select">
<option value="20">20/页</option>
<option value="50">50/页</option>
<option value="100">100/页</option>
</select>
</div>
<span id="sub2apiAccountActionStatus" class="inline-status"></span>
</div>
</div>
</div>
<div id="dataPanelCpa" class="data-panel-section" role="tabpanel" aria-labelledby="headerCpaChip">
<div class="pool-section-header">
<div class="pool-section-title">
<span>CPA 账号池</span>
</div>
<div class="pool-section-actions">
<button id="poolRefreshBtn" class="btn btn-ghost btn-sm">刷新</button>
<button id="poolMaintainBtn" class="btn btn-primary btn-sm">维护</button>
<span id="poolMaintainStatus" class="inline-status"></span>
</div>
</div>
<div class="pool-overview">
<div class="pool-stat-card">
<div id="poolTotal" class="stat-value blue">--</div>
<div class="stat-label">总账号</div>
</div>
<div class="pool-stat-card">
<div id="poolCandidates" class="stat-value green">--</div>
<div class="stat-label">正常</div>
</div>
<div class="pool-stat-card">
<div id="poolError" class="stat-value red">--</div>
<div class="stat-label">异常</div>
</div>
<div class="pool-stat-card">
<div id="poolThreshold" class="stat-value muted">--</div>
<div class="stat-label">目标</div>
</div>
<div class="pool-stat-card">
<div id="poolPercent" class="stat-value">--</div>
<div class="stat-label">充足率</div>
</div>
</div>
<div class="platform-note-wrap">
<div class="platform-note-card">
<div class="platform-note-title">CPA 当前交互</div>
<div class="platform-note-text">右侧面板展示池状态与维护动作,详细配置仍在配置中心;当前版本暂不提供 CPA 账号明细列表。</div>
</div>
</div>
</div>
<div id="dataPanelLocalTokens" class="data-panel-section local-token-panel-section" role="tabpanel" aria-labelledby="headerLocalTokenChip">
<div class="pool-section-header">
<div class="pool-section-title">
<span>本地 Token 池</span>
</div>
<div class="pool-section-actions">
<button id="poolCopyRtBtn" class="btn btn-ghost btn-sm">复制 RT</button>
<button id="poolExportBtn" class="btn btn-ghost btn-sm">导出</button>
<button id="poolPwSyncBtn" class="btn btn-primary btn-sm">批量导入</button>
</div>
</div>
<div class="pool-table-wrap local-token-wrap">
<div class="token-filter-row">
<select id="tokenFilterStatus" class="token-filter-select">
<option value="all">全部</option>
<option value="synced">仅已导入</option>
<option value="unsynced">仅未导入</option>
<option value="cpa">仅 CPA</option>
<option value="sub2api">仅 Sub2Api</option>
<option value="both">CPA + Sub2Api</option>
</select>
<input id="tokenFilterKeyword" type="text" placeholder="筛选邮箱/文件名" />
<button id="tokenFilterApplyBtn" class="btn btn-ghost btn-sm">筛选</button>
<button id="tokenFilterResetBtn" class="btn btn-ghost btn-sm">重置</button>
</div>
<div id="poolTokenList" class="token-list">
<div class="empty-state">
<div class="empty-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
</div>
<span>暂无 Token</span>
</div>
</div>
</div>
</div>
</div>
</div>
</aside>
</div>
</div>
<!-- ========== 配置中心 Tab ========== -->
<div id="tabConfig" class="tab-panel" role="tabpanel">
<div class="config-page">
<!-- 全局上传策略 -->
<div class="config-card collapsible open" style="grid-column: span 2;">
<div class="collapsible-trigger">
<span>全局上传策略</span>
<span class="collapse-icon"></span>
</div>
<div class="collapsible-body">
<div class="config-field" style="max-width:720px;">
<label>上传策略</label>
<select id="uploadMode">
<option value="snapshot">串行补平台先CPA后Sub2Api</option>
<option value="decoupled">双平台同传(单账号双上传)</option>
</select>
<div class="config-hint">
串行模式更保守;并行模式会让单账号并发上传到两个平台。当前任务运行中切换策略,将在下轮任务生效。
</div>
</div>
<div class="config-actions" style="margin-top:8px;">
<button id="uploadModeSaveBtn" class="btn btn-primary btn-sm">保存策略</button>
<span id="uploadModeStatus" class="inline-status"></span>
</div>
</div>
</div>
<!-- 请求代理池配置 -->
<div class="config-card collapsible open" style="grid-column: span 2;">
<div class="collapsible-trigger">
<span>请求代理池配置</span>
<span class="collapse-icon"></span>
</div>
<div class="collapsible-body">
<div class="config-hint" style="margin-top:0;">
在每次请求前动态取号。默认接口将附带 <code>api_key</code><code>count</code><code>country</code> 参数。
</div>
<div class="toggle-row" style="margin-top:10px;">
<label class="ios-toggle-label" for="proxyPoolEnabled">
<span class="ios-toggle ios-toggle-sm">
<input type="checkbox" id="proxyPoolEnabled" />
<span class="toggle-track"></span>
</span>
<span>启用请求前代理池</span>
</label>
</div>
<div class="config-field">
<label>代理池 API</label>
<input type="text" id="proxyPoolApiUrl" value="https://zenproxy.top/api/fetch" autocomplete="off" spellcheck="false" />
</div>
<div class="config-row">
<div class="config-field" style="flex:1;">
<label>认证方式</label>
<select id="proxyPoolAuthMode">
<option value="query">Query 参数</option>
<option value="header">Header Bearer</option>
</select>
</div>
<div class="config-field" style="flex:1;">
<label>API Key留空则保持原值</label>
<input type="password" id="proxyPoolApiKey" placeholder="19c0ec43-8f76-4c97-81bc-bcda059eeba4" autocomplete="off" />
</div>
</div>
<div class="config-row">
<div class="config-field" style="max-width:180px;">
<label>Count</label>
<input type="number" id="proxyPoolCount" value="1" min="1" max="20" />
</div>
<div class="config-field" style="max-width:180px;">
<label>Country</label>
<input type="text" id="proxyPoolCountry" value="US" maxlength="8" autocomplete="off" spellcheck="false" />
</div>
</div>
<div class="config-actions">
<button id="proxyPoolTestBtn" class="btn btn-ghost btn-sm">测试代理池取号</button>
<button id="proxyPoolSaveBtn" class="btn btn-primary btn-sm">保存代理池配置</button>
<span id="proxyPoolStatus" class="inline-status"></span>
</div>
</div>
</div>
<!-- CPA 平台配置 -->
<div class="config-card collapsible open">
<div class="collapsible-trigger">
<span>CPA 平台配置</span>
<span class="collapse-icon"></span>
</div>
<div class="collapsible-body">
<div class="config-field">
<label>Base URL</label>
<input type="text" id="cpaBaseUrl" placeholder="https://your-cpa-server.com" autocomplete="off"
spellcheck="false" />
</div>
<div class="config-field">
<label>Token</label>
<input type="password" id="cpaToken" placeholder="CPA 登录密码" autocomplete="off" />
</div>
<div class="config-row">
<div class="config-field" style="flex:1;">
<label>目标阈值</label>
<input type="number" id="cpaMinCandidates" value="800" min="1" />
</div>
<div class="config-field" style="flex:1;">
<label>使用率阈值(%)</label>
<input type="number" id="cpaUsedPercent" value="95" min="1" max="100" />
</div>
</div>
<div class="config-row" style="margin-top:10px;">
<div class="config-field" style="flex:1;">
<label class="ios-toggle-label" for="cpaAutoMaintain">
<span class="ios-toggle ios-toggle-sm">
<input type="checkbox" id="cpaAutoMaintain" />
<span class="toggle-track"></span>
</span>
<span>自动维护</span>
</label>
</div>
<div class="config-field" style="flex:1;">
<label>间隔(分钟)</label>
<input type="number" id="cpaInterval" value="30" min="5" />
</div>
</div>
<div class="config-actions">
<button id="cpaTestBtn" class="btn btn-ghost btn-sm">测试连接</button>
<button id="cpaSaveBtn" class="btn btn-primary btn-sm">保存</button>
<span id="cpaStatus" class="inline-status"></span>
</div>
</div>
</div>
<!-- Sub2Api 平台配置 -->
<div class="config-card collapsible open">
<div class="collapsible-trigger">
<span>Sub2Api 平台配置</span>
<span class="collapse-icon"></span>
</div>
<div class="collapsible-body">
<div class="config-field">
<label>平台地址</label>
<input type="text" id="sub2apiBaseUrl" value="" autocomplete="off" spellcheck="false" />
</div>
<div class="config-row">
<div class="config-field" style="flex:1;">
<label>管理员邮箱</label>
<input type="text" id="sub2apiEmail" value="" autocomplete="off" placeholder="admin@example.com" />
</div>
<div class="config-field" style="flex:1;">
<label>密码</label>
<input type="password" id="sub2apiPassword" value="" autocomplete="off" />
</div>
</div>
<div class="toggle-row" style="margin-top:6px;">
<label class="ios-toggle-label" for="autoSyncCheck">
<span class="ios-toggle ios-toggle-sm">
<input type="checkbox" id="autoSyncCheck" checked />
<span class="toggle-track"></span>
</span>
<span>注册后自动导入</span>
</label>
</div>
<div class="config-row" style="margin-top:10px;">
<div class="config-field" style="flex:1;">
<label>目标阈值</label>
<input type="number" id="sub2apiMinCandidates" value="200" min="1" />
</div>
<div class="config-field" style="flex:1;">
<label>维护间隔(分钟)</label>
<input type="number" id="sub2apiInterval" value="30" min="5" />
</div>
</div>
<div class="toggle-row" style="margin-top:6px;">
<label class="ios-toggle-label" for="sub2apiAutoMaintain">
<span class="ios-toggle ios-toggle-sm">
<input type="checkbox" id="sub2apiAutoMaintain" />
<span class="toggle-track"></span>
</span>
<span>自动维护</span>
</label>
</div>
<div class="config-field" style="margin-top:10px;">
<label>维护动作</label>
<div class="config-hint" style="margin-top:0;">
手动维护和自动维护都会按勾选项执行,可分别控制异常账号测活、仍异常删除与重复账号清理。
</div>
<div class="maintain-option-grid">
<label class="maintain-option">
<input type="checkbox" id="sub2apiMaintainRefreshAbnormal" checked />
<span>异常账号测活</span>
</label>
<label class="maintain-option">
<input type="checkbox" id="sub2apiMaintainDeleteAbnormal" checked />
<span>删除仍异常账号</span>
</label>
<label class="maintain-option">
<input type="checkbox" id="sub2apiMaintainDedupe" checked />
<span>重复账号清理</span>
</label>
</div>
</div>
<div class="config-actions">
<button id="sub2apiTestPoolBtn" class="btn btn-ghost btn-sm">测试连接</button>
<button id="saveSyncConfigBtn" class="btn btn-primary btn-sm">保存</button>
<span id="syncStatus" class="inline-status"></span>
</div>
</div>
</div>
<!-- 邮箱提供商 -->
<div class="config-card collapsible open" style="grid-column: span 2;">
<div class="collapsible-trigger">
<span>邮箱提供商</span>
<span class="collapse-icon"></span>
</div>
<div class="collapsible-body">
<div class="mail-providers-group">
<div class="provider-item" data-provider="mailtm">
<label class="provider-toggle">
<input type="checkbox" class="mail-provider-check" value="mailtm" checked />
<span class="provider-check-mark"></span>
Mail.tm (免费)
</label>
<div class="provider-config">
<div class="config-field">
<label>API 地址</label>
<input type="text" data-key="api_base" placeholder="https://api.mail.tm" autocomplete="off"
spellcheck="false" />
</div>
</div>
</div>
<div class="provider-item" data-provider="moemail">
<label class="provider-toggle">
<input type="checkbox" class="mail-provider-check" value="moemail" />
<span class="provider-check-mark"></span>
MoeMail (API Key)
</label>
<div class="provider-config" style="display:none;">
<div class="config-field">
<label>API 地址</label>
<input type="text" data-key="api_base" placeholder="https://your-moemail-api.example.com" autocomplete="off"
spellcheck="false" />
</div>
<div class="config-field">
<label>API Key</label>
<input type="password" data-key="api_key" placeholder="MoeMail API Key" autocomplete="off" />
</div>
</div>
</div>
<div class="provider-item" data-provider="duckmail">
<label class="provider-toggle">
<input type="checkbox" class="mail-provider-check" value="duckmail" />
<span class="provider-check-mark"></span>
DuckMail (Bearer Token)
</label>
<div class="provider-config" style="display:none;">
<div class="config-field">
<label>API 地址</label>
<input type="text" data-key="api_base" placeholder="https://api.duckmail.sbs" autocomplete="off"
spellcheck="false" />
</div>
<div class="config-field">
<label>Bearer Token</label>
<input type="password" data-key="bearer_token" placeholder="DuckMail Bearer Token"
autocomplete="off" />
</div>
</div>
</div>
<div class="provider-item" data-provider="cloudflare_temp_email">
<label class="provider-toggle">
<input type="checkbox" class="mail-provider-check" value="cloudflare_temp_email" />
<span class="provider-check-mark"></span>
Cloudflare Temp Email
</label>
<div class="provider-config" style="display:none;">
<div class="config-field">
<label>API 地址 (Worker URL)</label>
<input type="text" data-key="api_base" placeholder="https://xxx.xxx.workers.dev" autocomplete="off"
spellcheck="false" />
</div>
<div class="config-row">
<div class="config-field" style="flex:1;">
<label>Admin 密码</label>
<input type="password" data-key="admin_password" placeholder="x-admin-auth"
autocomplete="off" />
</div>
<div class="config-field" style="flex:1;">
<label>分配域名</label>
<input type="text" data-key="domain" placeholder="example.com" autocomplete="off"
spellcheck="false" />
</div>
</div>
</div>
</div>
</div>
<div class="config-field" style="margin-top:10px;max-width:240px;">
<label>路由策略</label>
<select id="mailStrategySelect">
<option value="round_robin">轮询</option>
<option value="random">随机</option>
<option value="failover">容错优先</option>
</select>
</div>
<div class="config-actions">
<button id="mailTestBtn" class="btn btn-ghost btn-sm">测试连接</button>
<button id="mailSaveBtn" class="btn btn-primary btn-sm">保存</button>
<span id="mailStatus" class="inline-status"></span>
</div>
</div>
</div>
</div>
</div>
<!-- Toast 容器 -->
<div id="toastContainer" class="toast-container"></div>
<script src="/static/app.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff