commit 0f9948ffc3417a36b1b350965292ae272506d338
Author: 237899745 <237899745@qq.com>
Date: Sun Mar 22 00:24:16 2026 +0800
feat: codex-register with Sub2API增强 + Playwright引擎
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..e69de29
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..b21ea40
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,19 @@
+# OpenAI 自动注册系统 - 环境变量配置示例
+# 复制此文件为 .env 并填写对应值
+
+# ── Web UI 监听地址 ──────────────────────────────────────────
+# 监听主机(默认 0.0.0.0)
+# APP_HOST=0.0.0.0
+
+# 监听端口(默认 8000)
+# APP_PORT=8000
+
+# Web UI 访问密钥(默认 admin123,强烈建议修改)
+# APP_ACCESS_PASSWORD=your_secret_password
+
+# ── 数据库 ───────────────────────────────────────────────────
+# 本地 SQLite(默认,无需配置)
+# APP_DATABASE_URL=data/database.db
+
+# 远程 PostgreSQL(优先)
+# APP_DATABASE_URL=postgresql://user:password@host:5432/dbname
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..adc71d9
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,131 @@
+name: 多平台打包发布
+
+on:
+ push:
+ tags:
+ - 'v*'
+ workflow_dispatch:
+ inputs:
+ version:
+ description: '版本号 (如 v1.0.0)'
+ required: false
+ default: 'dev'
+
+jobs:
+ build:
+ name: 打包 ${{ matrix.os }}
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - os: windows-latest
+ artifact_name: codex-register.exe
+ asset_name: codex-register-windows-x64.exe
+ - os: ubuntu-latest
+ artifact_name: codex-register
+ asset_name: codex-register-linux-x64
+ - os: macos-latest
+ artifact_name: codex-register
+ asset_name: codex-register-macos-arm64
+
+ steps:
+ - name: 检出代码
+ uses: actions/checkout@v4
+
+ - name: 设置 Python 3.11
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+ cache: 'pip'
+
+ - name: 安装依赖
+ run: |
+ pip install -r requirements.txt pyinstaller
+
+ - name: 打包
+ run: |
+ pyinstaller codex_register.spec --clean --noconfirm
+
+ - name: 上传构建产物
+ uses: actions/upload-artifact@v4
+ with:
+ name: ${{ matrix.asset_name }}
+ path: dist/${{ matrix.artifact_name }}
+ if-no-files-found: error
+
+ release:
+ name: 创建发布
+ needs: build
+ runs-on: ubuntu-latest
+ if: startsWith(github.ref, 'refs/tags/')
+ permissions:
+ contents: write
+
+ steps:
+ - name: 检出代码(获取附加文件)
+ uses: actions/checkout@v4
+
+ - name: 下载所有构建产物
+ uses: actions/download-artifact@v4
+ with:
+ path: dist/
+
+ - name: 整理文件并打包 zip
+ run: |
+ mkdir -p release
+ # 为每个平台二进制文件打包成 zip
+ find dist/ -type f | while read f; do
+ name=$(basename "$f")
+ # 根据文件名确定平台标识
+ case "$name" in
+ *windows*) platform=$(echo "$name" | sed 's/\.[^.]*$//') ;;
+ *linux*) platform="$name" ;;
+ *macos*) platform="$name" ;;
+ *) platform="$name" ;;
+ esac
+ tmpdir="tmp_${platform}"
+ mkdir -p "$tmpdir"
+ cp "$f" "$tmpdir/"
+ cp README.md "$tmpdir/README.md"
+ cp .env.example "$tmpdir/.env.example"
+ [ -f LICENSE ] && cp LICENSE "$tmpdir/LICENSE" || true
+ cd "$tmpdir"
+ zip -r "../release/${platform}.zip" .
+ cd ..
+ rm -rf "$tmpdir"
+ done
+ ls -lh release/
+
+ - name: 创建 GitHub Release
+ uses: softprops/action-gh-release@v2
+ with:
+ files: release/*
+ generate_release_notes: true
+ body: |
+ ## OpenAI 自动注册系统 v2
+
+ ### 下载说明
+ | 平台 | 文件 |
+ |------|------|
+ | Windows x64 | `codex-register-windows-x64.exe` |
+ | Linux x64 | `codex-register-linux-x64` |
+ | macOS ARM64 | `codex-register-macos-arm64` |
+
+ ### 使用方法
+ ```bash
+ # Linux/macOS 需要先赋予执行权限
+ chmod +x codex-register-*
+
+ # 启动 Web UI
+ ./codex-register
+
+ # 指定端口
+ ./codex-register --port 8080
+
+ # 调试模式(热重载)
+ ./codex-register --debug
+
+ # 设置 Web UI 访问密钥
+ ./codex-register --access-password mypassword
+ ```
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
new file mode 100644
index 0000000..874d40b
--- /dev/null
+++ b/.github/workflows/docker-publish.yml
@@ -0,0 +1,68 @@
+name: Docker Image CI
+
+on:
+ push:
+ branches: [ "master", "main" ]
+ # 当发布新版本时触发
+ tags: [ 'v*.*.*' ]
+ pull_request:
+ branches: [ "master", "main" ]
+
+env:
+ # GitHub Container Registry 的地址
+ REGISTRY: ghcr.io
+ # 镜像名称,默认为 GitHub 用户名/仓库名
+ IMAGE_NAME: ${{ github.repository }}
+
+jobs:
+ build-and-push-image:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+ # 如果需要签名生成的镜像,可以使用 id-token: write
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ # 设置 Docker Buildx 用于构建多平台镜像 (可选)
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ # 登录到 Docker 镜像仓库
+ # 如果只是在 PR 中测试构建,则跳过登录
+ - name: Log in to the Container registry
+ if: github.event_name != 'pull_request'
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ # 提取 Docker 镜像的元数据(标签、注释等)
+ - name: Extract metadata (tags, labels) for Docker
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+ tags: |
+ type=schedule
+ type=ref,event=branch
+ type=ref,event=pr
+ type=semver,pattern={{version}}
+ type=semver,pattern={{major}}.{{minor}}
+ type=semver,pattern={{major}}
+ type=sha
+ type=raw,value=latest,enable={{is_default_branch}}
+
+ # 构建并推送 Docker 镜像
+ - name: Build and push Docker image
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ push: ${{ github.event_name != 'pull_request' }}
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..5aa3627
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,60 @@
+# Python
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# Virtual Environment
+.venv/
+venv/
+ENV/
+env/
+uv.lock
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+*~
+
+# Data and Logs
+data/
+logs/
+*.db
+*.sqlite
+*.sqlite3
+
+# Token files
+token_*.json
+
+# Environment
+.env
+.env.local
+*.local
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Project specific
+backups/
+/out/
+chrome-linux64.zip
+node_modules/
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..200656f
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,57 @@
+FROM python:3.11-slim
+
+WORKDIR /app
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1 \
+ WEBUI_HOST=0.0.0.0 \
+ WEBUI_PORT=1455 \
+ LOG_LEVEL=info \
+ DEBUG=0
+
+# 安装系统依赖
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ gcc \
+ python3-dev \
+ libnss3 \
+ libatk-bridge2.0-0 \
+ libdrm2 \
+ libxkbcommon0 \
+ libgbm1 \
+ libasound2t64 \
+ libxshmfence1 \
+ libxcomposite1 \
+ libxdamage1 \
+ libxfixes3 \
+ libxrandr2 \
+ libx11-xcb1 \
+ libpango-1.0-0 \
+ libcairo2 \
+ fonts-liberation \
+ libdbus-1-3 \
+ libexpat1 \
+ libcups2t64 \
+ libxtst6 \
+ libxi6 \
+ libxext6 \
+ libxss1 \
+ unzip \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir --upgrade pip \
+ && pip install --no-cache-dir -r requirements.txt
+
+# 安装 Chromium 浏览器(从预下载的压缩包)
+COPY chrome-linux64.zip /tmp/chrome-linux64.zip
+RUN mkdir -p /opt/chromium && \
+ unzip -q /tmp/chrome-linux64.zip -d /opt/chromium && \
+ rm /tmp/chrome-linux64.zip && \
+ chmod +x /opt/chromium/chrome-linux64/chrome
+ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/opt/chromium/chrome-linux64/chrome
+
+COPY . .
+
+EXPOSE 1455
+
+CMD ["python", "webui.py"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..14fac91
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e907025
--- /dev/null
+++ b/README.md
@@ -0,0 +1,383 @@
+# OpenAI 账号管理系统 v2
+
+管理 OpenAI 账号的 Web UI 系统,支持多种邮箱服务、并发批量注册、代理管理和账号管理。
+
+# 官方拉闸了,改变了授权流程,各位自行研究吧
+
+> ⚠️ **免责声明**:本工具仅供学习和研究使用,使用本工具产生的一切后果由使用者自行承担。请遵守相关服务的使用条款,不要用于任何违法或不当用途。 如有侵权,请及时联系,会及时删除。
+
+[](LICENSE)
+[](https://www.python.org/)
+
+## 功能特性
+
+- **多邮箱服务支持**
+ - Tempmail.lol(临时邮箱,无需配置)
+ - Outlook(IMAP + XOAUTH2,支持批量导入)
+ - 自定义域名(两种子类型)
+ - **MoeMail**:标准 REST API,配置 API 地址 + API 密钥
+ - **TempMail**:自部署 Cloudflare Worker 临时邮箱,配置 Worker 地址 + Admin 密码
+ - DuckMail
+ - **DuckMail API**:兼容 DuckMail 接口,手动填写 API 地址、默认域名,可选 API Key
+
+- **注册模式**
+ - 单次注册
+ - 批量注册(可配置数量和间隔时间)
+ - Outlook 批量注册(指定账户逐一注册)
+
+- **并发控制**
+ - 流水线模式(Pipeline):每隔 interval 秒启动新任务,限制最大并发数
+ - 并行模式(Parallel):所有任务同时提交,Semaphore 控制最大并发
+ - 并发数可在 UI 自定义(1-50)
+ - 日志混合显示,带 `[任务N]` 前缀区分
+
+- **实时监控**
+ - WebSocket 实时日志推送
+ - 跨页面导航后自动重连
+ - 降级轮询备用方案
+
+- **代理管理**
+ - 动态代理(通过 API 每次获取新 IP)
+ - 代理列表(随机选取,支持设置默认代理,记录使用时间)
+
+- **账号管理**
+ - 查看、删除、批量操作
+ - Token 刷新与验证
+ - 订阅状态管理(手动标记 / 自动检测 plus/team/free)
+ - 导出格式:JSON / CSV / CPA 格式 / Sub2API 格式
+ - 单个账号导出为独立 `.json` 文件
+ - 多个 CPA 账号打包为 `.zip`,每个账号一个独立文件
+ - Sub2API 格式所有账号合并为单个 JSON
+ - 上传目标(直连不走代理):
+ - **CPA**:支持多服务配置,上传时选择目标服务,可按服务开关将账号实际代理写入 auth file 的 `proxy_url`
+ - **Sub2API**:支持多服务配置,标准 sub2api-data 格式
+ - **Team Manager**:支持多服务配置
+
+- **支付升级**
+ - 为账号生成 ChatGPT Plus 或 Team 订阅支付链接
+ - 后端命令行以无痕模式自动打开 Chrome/Edge
+ - Team 套餐支持自定义工作区名称、座位数、计费周期
+
+- **系统设置**
+ - 代理配置(动态代理 + 代理列表,支持设默认)
+ - CPA 服务列表管理(多服务,连接测试)
+ - Sub2API 服务列表管理(多服务,连接测试)
+ - Team Manager 服务列表管理(多服务,连接测试)
+ - Outlook OAuth 参数
+ - 注册参数(超时、重试、密码长度等)
+ - 验证码等待配置
+ - 数据库管理(备份、清理)
+ - 支持远程 PostgreSQL
+
+## 快速开始
+
+### 环境要求
+
+- Python 3.10+
+- [uv](https://github.com/astral-sh/uv)(推荐)或 pip
+
+### 安装依赖
+
+```bash
+# 使用 uv(推荐)
+uv sync
+
+# 或使用 pip
+pip install -r requirements.txt
+```
+
+### 环境变量配置(可选)
+
+复制 `.env.example` 为 `.env`,按需填写:
+
+```bash
+cp .env.example .env
+```
+
+| 变量 | 说明 | 默认值 |
+|------|------|--------|
+| `APP_HOST` | 监听主机 | `0.0.0.0` |
+| `APP_PORT` | 监听端口 | `8000` |
+| `APP_ACCESS_PASSWORD` | Web UI 访问密钥 | `admin123` |
+| `APP_DATABASE_URL` | 数据库连接字符串 | `data/database.db` |
+
+> 优先级:命令行参数 > 环境变量(`.env`)> 数据库设置 > 默认值
+
+### 启动 Web UI
+
+```bash
+# 默认启动(127.0.0.1:8000)
+python webui.py
+
+# 指定地址和端口
+python webui.py --host 0.0.0.0 --port 8080
+
+# 调试模式(热重载)
+python webui.py --debug
+
+# 设置 Web UI 访问密钥
+python webui.py --access-password mypassword
+
+# 组合参数
+python webui.py --host 0.0.0.0 --port 8080 --access-password mypassword
+```
+
+> `--access-password` 优先级高于数据库中保存的密钥设置,每次启动时生效。打包后的 exe 同样支持此参数:
+> ```bash
+> codex-register.exe --access-password mypassword
+> ```
+
+### Docker 部署
+
+项目支持通过 Docker 进行容器化部署。Docker 镜像已托管至 GitHub Container Registry (GHCR)。
+
+#### 使用 docker-compose (推荐)
+
+在项目根目录下,直接使用 `docker-compose` 启动:
+
+```bash
+docker-compose up -d
+```
+你可以在 `docker-compose.yml` 中修改相关的环境变量,例如配置端口或者设置 `WEBUI_ACCESS_PASSWORD` 访问密码。
+
+#### 直接使用 docker run
+
+如果你不想使用 docker-compose,也可以直接拉取并运行镜像:
+
+```bash
+docker run -d \
+ -p 1455:1455 \
+ -e WEBUI_HOST=0.0.0.0 \
+ -e WEBUI_PORT=1455 \
+ -e WEBUI_ACCESS_PASSWORD=your_secure_password \
+ -v $(pwd)/data:/app/data \
+ --name codex-register \
+ ghcr.io/yunxilyf/codex-register:latest
+```
+
+环境变量说明:
+- `WEBUI_HOST`: 监听的主机地址 (默认 `0.0.0.0`)
+- `WEBUI_PORT`: 监听的端口 (默认 `1455`)
+- `WEBUI_ACCESS_PASSWORD`: 设置 Web UI 的访问密码
+- `DEBUG`: 设为 `1` 或 `true` 开启调试模式
+- `LOG_LEVEL`: 日志级别,如 `info`, `debug`
+
+> **注意**:`-v $(pwd)/data:/app/data` 挂载参数非常重要,它确保了你的数据库文件和账户信息在容器重启或更新后不会丢失。
+
+### 使用远程 PostgreSQL
+
+通过环境变量指定数据库连接字符串:
+
+```bash
+export APP_DATABASE_URL="postgresql://user:password@host:5432/dbname"
+python webui.py
+```
+
+也支持 `DATABASE_URL`,优先级低于 `APP_DATABASE_URL`。
+
+启动后访问 http://127.0.0.1:8000
+
+## 打包为可执行文件
+
+```bash
+# Windows
+build.bat
+
+# Linux/macOS
+bash build.sh
+```
+
+打包后生成 `codex-register.exe`(Windows)或 `codex-register`(Unix),双击或直接运行即可,无需安装 Python 环境。
+
+## 项目结构
+
+```
+codex-register-v2/
+├── webui.py # Web UI 入口
+├── build.bat # Windows 打包脚本
+├── build.sh # Linux/macOS 打包脚本
+├── src/
+│ ├── config/ # 配置管理(Pydantic Settings)
+│ ├── core/
+│ │ ├── openai/ # OAuth、Token 刷新、支付核心
+│ │ └── upload/ # CPA / Sub2API / Team Manager 上传模块
+│ ├── database/ # 数据库(SQLAlchemy + SQLite/PostgreSQL)
+│ ├── services/ # 邮箱服务实现
+│ └── web/
+│ ├── app.py # 应用入口、路由挂载
+│ ├── task_manager.py # 任务/日志/WebSocket 管理
+│ └── routes/ # API 路由
+│ └── upload/ # CPA / Sub2API / TM 服务管理路由
+├── templates/ # Jinja2 HTML 模板
+├── static/ # 静态资源(CSS / JS)
+└── data/ # 运行时数据目录(数据库、日志)
+```
+
+## 技术栈
+
+| 层级 | 技术 |
+|------|------|
+| Web 框架 | FastAPI + Uvicorn |
+| 数据库 | SQLAlchemy + SQLite / PostgreSQL |
+| 模板引擎 | Jinja2 |
+| HTTP 客户端 | curl_cffi(浏览器指纹模拟) |
+| 实时通信 | WebSocket |
+| 并发 | asyncio Semaphore + ThreadPoolExecutor |
+| 前端 | 原生 JavaScript(无框架) |
+| 打包 | PyInstaller |
+
+## API 端点
+
+### 注册任务
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| POST | `/api/registration/start` | 启动注册任务 |
+| GET | `/api/registration/tasks` | 任务列表 |
+| GET | `/api/registration/tasks/{uuid}/logs` | 任务日志 |
+| POST | `/api/registration/tasks/{uuid}/cancel` | 取消任务 |
+| GET | `/api/registration/available-services` | 可用邮箱服务 |
+
+### 账号管理
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET | `/api/accounts` | 账号列表(支持分页、筛选、搜索) |
+| GET | `/api/accounts/{id}` | 账号详情 |
+| PATCH | `/api/accounts/{id}` | 更新账号(状态/cookies) |
+| DELETE | `/api/accounts/{id}` | 删除账号 |
+| POST | `/api/accounts/batch-delete` | 批量删除 |
+| POST | `/api/accounts/export/json` | 导出 JSON |
+| POST | `/api/accounts/export/csv` | 导出 CSV |
+| POST | `/api/accounts/export/cpa` | 导出 CPA 格式(单文件或 ZIP) |
+| POST | `/api/accounts/export/sub2api` | 导出 Sub2API 格式 |
+| POST | `/api/accounts/{id}/refresh` | 刷新 Token |
+| POST | `/api/accounts/batch-refresh` | 批量刷新 Token |
+| POST | `/api/accounts/{id}/validate` | 验证 Token |
+| POST | `/api/accounts/batch-validate` | 批量验证 Token |
+| POST | `/api/accounts/{id}/upload-cpa` | 上传单账号到 CPA |
+| POST | `/api/accounts/batch-upload-cpa` | 批量上传到 CPA |
+| POST | `/api/accounts/{id}/upload-sub2api` | 上传单账号到 Sub2API |
+| POST | `/api/accounts/batch-upload-sub2api` | 批量上传到 Sub2API |
+
+### 支付升级
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| POST | `/api/payment/generate` | 生成 Plus/Team 支付链接 |
+| POST | `/api/payment/open` | 后端无痕模式打开浏览器 |
+| 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 |
+
+### 邮箱服务
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET | `/api/email-services` | 服务列表 |
+| POST | `/api/email-services` | 添加服务 |
+| PATCH | `/api/email-services/{id}` | 更新服务 |
+| DELETE | `/api/email-services/{id}` | 删除服务 |
+| POST | `/api/email-services/{id}/test` | 测试服务 |
+| POST | `/api/email-services/outlook/batch-import` | 批量导入 Outlook |
+
+### 上传服务管理
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET/POST | `/api/cpa-services` | CPA 服务列表/创建 |
+| PUT/DELETE | `/api/cpa-services/{id}` | 更新/删除 CPA 服务 |
+| POST | `/api/cpa-services/{id}/test` | 测试 CPA 连接 |
+| GET/POST | `/api/sub2api-services` | Sub2API 服务列表/创建 |
+| PUT/DELETE | `/api/sub2api-services/{id}` | 更新/删除 Sub2API 服务 |
+| POST | `/api/sub2api-services/{id}/test` | 测试 Sub2API 连接 |
+| GET/POST | `/api/tm-services` | Team Manager 服务列表/创建 |
+| PUT/DELETE | `/api/tm-services/{id}` | 更新/删除 TM 服务 |
+| POST | `/api/tm-services/{id}/test` | 测试 TM 连接 |
+
+### 设置
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET | `/api/settings` | 获取所有设置 |
+| POST | `/api/settings/proxy/dynamic` | 更新动态代理设置 |
+| GET/POST/DELETE | `/api/settings/proxies` | 代理列表管理 |
+| POST | `/api/settings/proxies/{id}/set-default` | 设为默认代理 |
+| GET | `/api/settings/database` | 数据库信息 |
+
+### WebSocket
+
+| 路径 | 说明 |
+|------|------|
+|| `ws://host/api/ws/logs/{uuid}` | 实时日志流 |
+
+## Docker 部署
+
+### 环境要求
+
+- Docker
+- Docker Compose
+
+### 快速部署
+
+```bash
+# 克隆项目
+git clone https://github.com/cnlimiter/codex-register.git
+cd codex-register
+
+# 启动服务
+docker-compose up -d
+```
+
+服务启动后访问 http://localhost:8000
+
+### 配置说明
+
+**端口映射**:默认 `8000` 端口,可在 `docker-compose.yml` 中修改。
+
+**数据持久化**:
+```yaml
+volumes:
+ - ./data:/app/data
+ - ./logs:/app/logs
+```
+
+**环境变量配置**:
+```yaml
+environment:
+ - APP_ACCESS_PASSWORD=mypassword
+ - APP_HOST=0.0.0.0
+ - APP_PORT=8000
+```
+
+### 常用命令
+
+```bash
+# 查看日志
+docker-compose logs -f
+
+# 停止服务
+docker-compose down
+
+# 重新构建
+docker-compose build --no-cache
+```
+
+## 注意事项
+
+- 首次运行会自动创建 `data/` 目录和 SQLite 数据库
+- 所有账号和设置数据存储在 `data/register.db`
+- 日志文件写入 `logs/` 目录
+- 代理优先级:动态代理 > 代理列表(随机/默认) > 直连
+- CPA / Sub2API / Team Manager 上传始终直连,不走代理;其中 CPA 可选把账号记录的代理写入 auth file 的 `proxy_url`
+- 注册时自动随机生成用户名和生日(年龄范围 18-45 岁)
+- 支付链接生成使用账号 access_token 鉴权,走全局代理配置
+- 无痕浏览器优先使用 playwright(注入 cookie 直达支付页);未安装时降级为系统 Chrome/Edge 无痕模式
+- 安装完整支付功能:`pip install ".[payment]" && playwright install chromium`(可选)
+- 订阅状态自动检测调用 `chatgpt.com/backend-api/me`,走全局代理
+- 批量注册并发数上限为 50,线程池大小已相应调整
+
+## License
+
+[MIT](LICENSE)
diff --git a/build.bat b/build.bat
new file mode 100644
index 0000000..9222f61
--- /dev/null
+++ b/build.bat
@@ -0,0 +1,20 @@
+@echo off
+REM Windows 打包脚本
+
+echo === 构建平台: Windows ===
+
+REM 安装打包依赖
+pip install pyinstaller --quiet
+
+REM 执行打包
+pyinstaller codex_register.spec --clean --noconfirm
+
+IF EXIST dist\codex-register.exe (
+ FOR /F "tokens=*" %%i IN ('powershell -Command "[System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture"') DO SET ARCH=%%i
+ SET OUTPUT=dist\codex-register-windows-%ARCH%.exe
+ MOVE dist\codex-register.exe "%OUTPUT%"
+ echo === 构建完成: %OUTPUT% ===
+) ELSE (
+ echo === 构建失败,未找到输出文件 ===
+ exit /b 1
+)
diff --git a/build.sh b/build.sh
new file mode 100644
index 0000000..61be33c
--- /dev/null
+++ b/build.sh
@@ -0,0 +1,49 @@
+#!/bin/bash
+# 跨平台打包脚本(在各平台上分别运行)
+
+set -e
+
+OS=$(uname -s)
+ARCH=$(uname -m)
+
+case "$OS" in
+ Darwin)
+ PLATFORM="macos"
+ EXT=""
+ ;;
+ Linux)
+ PLATFORM="linux"
+ EXT=""
+ ;;
+ MINGW*|CYGWIN*|MSYS*)
+ PLATFORM="windows"
+ EXT=".exe"
+ ;;
+ *)
+ PLATFORM="$OS"
+ EXT=""
+ ;;
+esac
+
+OUTPUT_NAME="codex-register-${PLATFORM}-${ARCH}${EXT}"
+
+echo "=== 构建平台: ${PLATFORM} (${ARCH}) ==="
+echo "=== 输出文件: dist/${OUTPUT_NAME} ==="
+
+# 安装打包依赖
+pip install pyinstaller --quiet 2>/dev/null || \
+ uv run --with pyinstaller pyinstaller --version > /dev/null 2>&1
+
+# 执行打包(优先用 uv,回退到直接调用)
+if command -v uv &>/dev/null; then
+ uv run --with pyinstaller pyinstaller codex_register.spec --clean --noconfirm
+else
+ pyinstaller codex_register.spec --clean --noconfirm
+fi
+
+# 重命名输出文件
+mv dist/codex-register${EXT} dist/${OUTPUT_NAME} 2>/dev/null || \
+ mv "dist/codex-register" "dist/${OUTPUT_NAME}" 2>/dev/null || true
+
+echo "=== 构建完成: dist/${OUTPUT_NAME} ==="
+ls -lh dist/${OUTPUT_NAME}
diff --git a/codex_register.spec b/codex_register.spec
new file mode 100644
index 0000000..2b90129
--- /dev/null
+++ b/codex_register.spec
@@ -0,0 +1,149 @@
+# -*- mode: python ; coding: utf-8 -*-
+
+import sys
+from pathlib import Path
+
+block_cipher = None
+
+a = Analysis(
+ ['webui.py'],
+ pathex=['.'],
+ binaries=[],
+ datas=[
+ ('templates', 'templates'),
+ ('static', 'static'),
+ ('src', 'src'),
+ ],
+ hiddenimports=[
+ 'uvicorn.logging',
+ 'uvicorn.loops',
+ 'uvicorn.loops.auto',
+ 'uvicorn.loops.asyncio',
+ 'uvicorn.loops.uvloop',
+ 'uvicorn.protocols',
+ 'uvicorn.protocols.http',
+ 'uvicorn.protocols.http.auto',
+ 'uvicorn.protocols.http.h11_impl',
+ 'uvicorn.protocols.http.httptools_impl',
+ 'uvicorn.protocols.websockets',
+ 'uvicorn.protocols.websockets.auto',
+ 'uvicorn.protocols.websockets.websockets_impl',
+ 'uvicorn.protocols.websockets.wsproto_impl',
+ 'uvicorn.lifespan',
+ 'uvicorn.lifespan.off',
+ 'uvicorn.lifespan.on',
+ 'fastapi',
+ 'fastapi.middleware',
+ 'fastapi.middleware.cors',
+ 'fastapi.staticfiles',
+ 'fastapi.templating',
+ 'starlette',
+ 'starlette.routing',
+ 'starlette.middleware',
+ 'starlette.staticfiles',
+ 'starlette.templating',
+ 'jinja2',
+ 'sqlalchemy',
+ 'sqlalchemy.orm',
+ 'sqlalchemy.orm.session',
+ 'sqlalchemy.orm.decl_api',
+ 'sqlalchemy.ext.declarative',
+ 'sqlalchemy.engine',
+ 'sqlalchemy.engine.create',
+ 'sqlalchemy.engine.url',
+ 'sqlalchemy.pool',
+ 'sqlalchemy.sql',
+ 'sqlalchemy.sql.schema',
+ 'sqlalchemy.sql.sqltypes',
+ 'sqlalchemy.dialects',
+ 'sqlalchemy.dialects.sqlite',
+ 'sqlalchemy.dialects.sqlite.pysqlite',
+ 'aiosqlite',
+ 'pydantic',
+ 'pydantic_settings',
+ 'curl_cffi',
+ 'curl_cffi.requests',
+ 'email.mime',
+ 'email.mime.text',
+ 'email.mime.multipart',
+ 'imaplib',
+ 'h11',
+ 'anyio',
+ 'anyio.lowlevel',
+ 'click',
+ 'src.web.app',
+ 'src.web.routes',
+ 'src.config.settings',
+ 'src.config.constants',
+ 'src.database.models',
+ 'src.database.session',
+ 'src.database.crud',
+ 'src.database.init_db',
+ 'src.core.register',
+ 'src.core.http_client',
+ 'src.core.utils',
+ 'src.services.base',
+ 'src.services.tempmail',
+ 'src.services.moe_mail',
+ 'src.services.outlook',
+ 'src.services.outlook.account',
+ 'src.services.outlook.base',
+ 'src.services.outlook.email_parser',
+ 'src.services.outlook.health_checker',
+ 'src.services.outlook.service',
+ 'src.services.outlook.token_manager',
+ 'src.services.outlook.providers',
+ 'src.services.outlook.providers.base',
+ 'src.services.outlook.providers.graph_api',
+ 'src.services.outlook.providers.imap_new',
+ 'src.services.outlook.providers.imap_old',
+ 'src.services.outlook_legacy',
+ 'src.core.cpa_upload',
+ 'src.core.oauth',
+ 'src.core.token_refresh',
+ 'src.web.routes.accounts',
+ 'src.web.routes.email_services',
+ 'src.web.routes.registration',
+ 'src.web.routes.settings',
+ 'src.web.routes.websocket',
+ 'src.web.task_manager',
+ ],
+ hookspath=[],
+ hooksconfig={},
+ runtime_hooks=[],
+ excludes=[
+ 'tkinter',
+ 'matplotlib',
+ 'numpy',
+ 'pandas',
+ 'PIL',
+ 'pytest',
+ ],
+ win_no_prefer_redirects=False,
+ win_private_assemblies=False,
+ cipher=block_cipher,
+ noarchive=False,
+)
+
+pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
+
+exe = EXE(
+ pyz,
+ a.scripts,
+ a.binaries,
+ a.datas,
+ [],
+ name='codex-register',
+ debug=False,
+ bootloader_ignore_signals=False,
+ strip=False,
+ upx=True,
+ upx_exclude=[],
+ runtime_tmpdir=None,
+ console=True,
+ disable_windowed_traceback=False,
+ argv_emulation=False,
+ target_arch=None,
+ codesign_identity=None,
+ entitlements_file=None,
+)
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..506e45c
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,18 @@
+services:
+ codex-register:
+ build: .
+ container_name: codex-register
+ ports:
+ - "18000:18000"
+ environment:
+ - PYTHONUNBUFFERED=1
+ - APP_HOST=0.0.0.0
+ - APP_PORT=18000
+ - WEBUI_HOST=0.0.0.0
+ - WEBUI_PORT=18000
+ - APP_ACCESS_PASSWORD=yuyx4954
+ - WEBUI_ACCESS_PASSWORD=yuyx4954
+ volumes:
+ - ./data:/app/data
+ - ./logs:/app/logs
+ restart: unless-stopped
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..6f1b63f
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,43 @@
+[project]
+name = "codex-register-v2"
+version = "1.0.4"
+description = "OpenAI 自动注册系统 v2"
+requires-python = ">=3.10"
+dependencies = [
+ "curl-cffi>=0.14.0",
+ "fastapi>=0.100.0",
+ "uvicorn>=0.23.0",
+ "jinja2>=3.1.0",
+ "python-multipart>=0.0.6",
+ "pydantic>=2.0.0",
+ "pydantic-settings>=2.0.0",
+ "sqlalchemy>=2.0.0",
+ "aiosqlite>=0.19.0",
+ "psycopg[binary]>=3.1.18",
+ "websockets>=16.0",
+ "path>=17.1.1",
+]
+
+[project.optional-dependencies]
+dev = [
+ "pytest>=7.0.0",
+ "httpx>=0.24.0",
+]
+payment = [
+ "playwright>=1.40.0",
+]
+
+[project.scripts]
+codex-webui = "webui:main"
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+packages = ["src"]
+
+[dependency-groups]
+dev = [
+ "pyinstaller>=6.19.0",
+]
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..82c3fb0
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,15 @@
+certifi>=2024.0.0
+cffi>=1.16.0
+curl_cffi>=0.14.0
+pycparser>=1.21
+pydantic>=2.0.0
+pydantic-settings>=2.0.0
+fastapi>=0.100.0
+uvicorn[standard]>=0.23.0
+jinja2>=3.1.0
+python-multipart>=0.0.6
+sqlalchemy>=2.0.0
+aiosqlite>=0.19.0
+psycopg[binary]>=3.1.18
+# 可选:无痕打开支付页需要 playwright(pip install playwright && playwright install chromium)
+playwright>=1.40.0
\ No newline at end of file
diff --git a/src/__init__.py b/src/__init__.py
new file mode 100644
index 0000000..e5ac097
--- /dev/null
+++ b/src/__init__.py
@@ -0,0 +1,24 @@
+"""
+OpenAI/Codex CLI 自动注册系统
+"""
+
+from .config import get_settings, EmailServiceType
+from .database import get_db, Account, EmailService, RegistrationTask
+from .core import RegistrationEngine, RegistrationResult
+from .services import EmailServiceFactory, BaseEmailService
+
+__version__ = "2.0.0"
+__author__ = "Yasal"
+
+__all__ = [
+ 'get_settings',
+ 'EmailServiceType',
+ 'get_db',
+ 'Account',
+ 'EmailService',
+ 'RegistrationTask',
+ 'RegistrationEngine',
+ 'RegistrationResult',
+ 'EmailServiceFactory',
+ 'BaseEmailService',
+]
diff --git a/src/config/__init__.py b/src/config/__init__.py
new file mode 100644
index 0000000..da2f93b
--- /dev/null
+++ b/src/config/__init__.py
@@ -0,0 +1,53 @@
+"""
+配置模块
+"""
+
+from .settings import (
+ Settings,
+ get_settings,
+ update_settings,
+ get_database_url,
+ init_default_settings,
+ get_setting_definition,
+ get_all_setting_definitions,
+ SETTING_DEFINITIONS,
+ SettingCategory,
+ SettingDefinition,
+)
+from .constants import (
+ AccountStatus,
+ TaskStatus,
+ EmailServiceType,
+ APP_NAME,
+ APP_VERSION,
+ OTP_CODE_PATTERN,
+ DEFAULT_PASSWORD_LENGTH,
+ PASSWORD_CHARSET,
+ DEFAULT_USER_INFO,
+ generate_random_user_info,
+ OPENAI_API_ENDPOINTS,
+)
+
+__all__ = [
+ 'Settings',
+ 'get_settings',
+ 'update_settings',
+ 'get_database_url',
+ 'init_default_settings',
+ 'get_setting_definition',
+ 'get_all_setting_definitions',
+ 'SETTING_DEFINITIONS',
+ 'SettingCategory',
+ 'SettingDefinition',
+ 'AccountStatus',
+ 'TaskStatus',
+ 'EmailServiceType',
+ 'APP_NAME',
+ 'APP_VERSION',
+ 'OTP_CODE_PATTERN',
+ 'DEFAULT_PASSWORD_LENGTH',
+ 'PASSWORD_CHARSET',
+ 'DEFAULT_USER_INFO',
+ 'generate_random_user_info',
+ 'OPENAI_API_ENDPOINTS',
+]
diff --git a/src/config/constants.py b/src/config/constants.py
new file mode 100644
index 0000000..b65bff6
--- /dev/null
+++ b/src/config/constants.py
@@ -0,0 +1,397 @@
+"""
+常量定义
+"""
+
+import random
+from datetime import datetime
+from enum import Enum
+from typing import Dict, List, Tuple
+
+
+# ============================================================================
+# 枚举类型
+# ============================================================================
+
+class AccountStatus(str, Enum):
+ """账户状态"""
+ ACTIVE = "active"
+ EXPIRED = "expired"
+ BANNED = "banned"
+ FAILED = "failed"
+
+
+class TaskStatus(str, Enum):
+ """任务状态"""
+ PENDING = "pending"
+ RUNNING = "running"
+ COMPLETED = "completed"
+ FAILED = "failed"
+ CANCELLED = "cancelled"
+
+
+class EmailServiceType(str, Enum):
+ """邮箱服务类型"""
+ TEMPMAIL = "tempmail"
+ OUTLOOK = "outlook"
+ MOE_MAIL = "moe_mail"
+ TEMP_MAIL = "temp_mail"
+ DUCK_MAIL = "duck_mail"
+ FREEMAIL = "freemail"
+ IMAP_MAIL = "imap_mail"
+
+
+# ============================================================================
+# 应用常量
+# ============================================================================
+
+APP_NAME = "OpenAI/Codex CLI 自动注册系统"
+APP_VERSION = "2.0.0"
+APP_DESCRIPTION = "自动注册 OpenAI/Codex CLI 账号的系统"
+
+# ============================================================================
+# OpenAI OAuth 相关常量
+# ============================================================================
+
+# OAuth 参数
+OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
+OAUTH_AUTH_URL = "https://auth.openai.com/oauth/authorize"
+OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token"
+OAUTH_REDIRECT_URI = "http://localhost:1455/auth/callback"
+OAUTH_SCOPE = "openid email profile offline_access"
+
+# OpenAI API 端点
+OPENAI_API_ENDPOINTS = {
+ "sentinel": "https://sentinel.openai.com/backend-api/sentinel/req",
+ "signup": "https://auth.openai.com/api/accounts/authorize/continue",
+ "register": "https://auth.openai.com/api/accounts/user/register",
+ "send_otp": "https://auth.openai.com/api/accounts/email-otp/send",
+ "validate_otp": "https://auth.openai.com/api/accounts/email-otp/validate",
+ "create_account": "https://auth.openai.com/api/accounts/create_account",
+ "select_workspace": "https://auth.openai.com/api/accounts/workspace/select",
+}
+
+# OpenAI 页面类型(用于判断账号状态)
+OPENAI_PAGE_TYPES = {
+ "EMAIL_OTP_VERIFICATION": "email_otp_verification", # 已注册账号,需要 OTP 验证
+ "PASSWORD_REGISTRATION": "password", # 新账号,需要设置密码
+}
+
+# ============================================================================
+# 邮箱服务相关常量
+# ============================================================================
+
+# Tempmail.lol API 端点
+TEMPMAIL_API_ENDPOINTS = {
+ "create_inbox": "/inbox/create",
+ "get_inbox": "/inbox",
+}
+
+# 自定义域名邮箱 API 端点
+CUSTOM_DOMAIN_API_ENDPOINTS = {
+ "get_config": "/api/config",
+ "create_email": "/api/emails/generate",
+ "list_emails": "/api/emails",
+ "get_email_messages": "/api/emails/{emailId}",
+ "delete_email": "/api/emails/{emailId}",
+ "get_message": "/api/emails/{emailId}/{messageId}",
+}
+
+# 邮箱服务默认配置
+EMAIL_SERVICE_DEFAULTS = {
+ "tempmail": {
+ "base_url": "https://api.tempmail.lol/v2",
+ "timeout": 30,
+ "max_retries": 3,
+ },
+ "outlook": {
+ "imap_server": "outlook.office365.com",
+ "imap_port": 993,
+ "smtp_server": "smtp.office365.com",
+ "smtp_port": 587,
+ "timeout": 30,
+ },
+ "moe_mail": {
+ "base_url": "", # 需要用户配置
+ "api_key_header": "X-API-Key",
+ "timeout": 30,
+ "max_retries": 3,
+ },
+ "duck_mail": {
+ "base_url": "",
+ "default_domain": "",
+ "password_length": 12,
+ "timeout": 30,
+ "max_retries": 3,
+ },
+ "freemail": {
+ "base_url": "",
+ "admin_token": "",
+ "domain": "",
+ "timeout": 30,
+ "max_retries": 3,
+ },
+ "imap_mail": {
+ "host": "",
+ "port": 993,
+ "use_ssl": True,
+ "email": "",
+ "password": "",
+ "timeout": 30,
+ "max_retries": 3,
+ }
+}
+
+# ============================================================================
+# 注册流程相关常量
+# ============================================================================
+
+# 验证码相关
+OTP_CODE_PATTERN = r"(? dict:
+ """
+ 生成随机用户信息
+
+ Returns:
+ 包含 name 和 birthdate 的字典
+ """
+ # 随机选择名字
+ name = random.choice(FIRST_NAMES)
+
+ # 生成随机生日(18-45岁)
+ current_year = datetime.now().year
+ birth_year = random.randint(current_year - 45, current_year - 18)
+ birth_month = random.randint(1, 12)
+ # 根据月份确定天数
+ if birth_month in [1, 3, 5, 7, 8, 10, 12]:
+ birth_day = random.randint(1, 31)
+ elif birth_month in [4, 6, 9, 11]:
+ birth_day = random.randint(1, 30)
+ else:
+ # 2月,简化处理
+ birth_day = random.randint(1, 28)
+
+ birthdate = f"{birth_year}-{birth_month:02d}-{birth_day:02d}"
+
+ return {
+ "name": name,
+ "birthdate": birthdate
+ }
+
+# 保留默认值供兼容
+DEFAULT_USER_INFO = {
+ "name": "Neo",
+ "birthdate": "2000-02-20",
+}
+
+# ============================================================================
+# 代理相关常量
+# ============================================================================
+
+PROXY_TYPES = ["http", "socks5", "socks5h"]
+DEFAULT_PROXY_CONFIG = {
+ "enabled": False,
+ "type": "http",
+ "host": "127.0.0.1",
+ "port": 7890,
+}
+
+# ============================================================================
+# 数据库相关常量
+# ============================================================================
+
+# 数据库表名
+DB_TABLE_NAMES = {
+ "accounts": "accounts",
+ "email_services": "email_services",
+ "registration_tasks": "registration_tasks",
+ "settings": "settings",
+}
+
+# 默认设置
+DEFAULT_SETTINGS = [
+ # (key, value, description, category)
+ ("system.name", APP_NAME, "系统名称", "general"),
+ ("system.version", APP_VERSION, "系统版本", "general"),
+ ("logs.retention_days", "30", "日志保留天数", "general"),
+ ("openai.client_id", OAUTH_CLIENT_ID, "OpenAI OAuth Client ID", "openai"),
+ ("openai.auth_url", OAUTH_AUTH_URL, "OpenAI 认证地址", "openai"),
+ ("openai.token_url", OAUTH_TOKEN_URL, "OpenAI Token 地址", "openai"),
+ ("openai.redirect_uri", OAUTH_REDIRECT_URI, "OpenAI 回调地址", "openai"),
+ ("openai.scope", OAUTH_SCOPE, "OpenAI 权限范围", "openai"),
+ ("proxy.enabled", "false", "是否启用代理", "proxy"),
+ ("proxy.type", "http", "代理类型 (http/socks5)", "proxy"),
+ ("proxy.host", "127.0.0.1", "代理主机", "proxy"),
+ ("proxy.port", "7890", "代理端口", "proxy"),
+ ("registration.max_retries", "3", "最大重试次数", "registration"),
+ ("registration.timeout", "120", "超时时间(秒)", "registration"),
+ ("registration.default_password_length", "12", "默认密码长度", "registration"),
+ ("webui.host", "0.0.0.0", "Web UI 监听主机", "webui"),
+ ("webui.port", "8000", "Web UI 监听端口", "webui"),
+ ("webui.debug", "true", "调试模式", "webui"),
+]
+
+# ============================================================================
+# Web UI 相关常量
+# ============================================================================
+
+# WebSocket 事件
+WEBSOCKET_EVENTS = {
+ "CONNECT": "connect",
+ "DISCONNECT": "disconnect",
+ "LOG": "log",
+ "STATUS": "status",
+ "ERROR": "error",
+ "COMPLETE": "complete",
+}
+
+# API 响应状态码
+API_STATUS_CODES = {
+ "SUCCESS": 200,
+ "CREATED": 201,
+ "BAD_REQUEST": 400,
+ "UNAUTHORIZED": 401,
+ "FORBIDDEN": 403,
+ "NOT_FOUND": 404,
+ "CONFLICT": 409,
+ "INTERNAL_ERROR": 500,
+}
+
+# 分页
+DEFAULT_PAGE_SIZE = 20
+MAX_PAGE_SIZE = 100
+
+# ============================================================================
+# 错误消息
+# ============================================================================
+
+ERROR_MESSAGES = {
+ # 通用错误
+ "DATABASE_ERROR": "数据库操作失败",
+ "CONFIG_ERROR": "配置错误",
+ "NETWORK_ERROR": "网络连接失败",
+ "TIMEOUT": "操作超时",
+ "VALIDATION_ERROR": "参数验证失败",
+
+ # 邮箱服务错误
+ "EMAIL_SERVICE_UNAVAILABLE": "邮箱服务不可用",
+ "EMAIL_CREATION_FAILED": "创建邮箱失败",
+ "OTP_NOT_RECEIVED": "未收到验证码",
+ "OTP_INVALID": "验证码无效",
+
+ # OpenAI 相关错误
+ "OPENAI_AUTH_FAILED": "OpenAI 认证失败",
+ "OPENAI_RATE_LIMIT": "OpenAI 接口限流",
+ "OPENAI_CAPTCHA": "遇到验证码",
+
+ # 代理错误
+ "PROXY_FAILED": "代理连接失败",
+ "PROXY_AUTH_FAILED": "代理认证失败",
+
+ # 账户错误
+ "ACCOUNT_NOT_FOUND": "账户不存在",
+ "ACCOUNT_ALREADY_EXISTS": "账户已存在",
+ "ACCOUNT_INVALID": "账户无效",
+
+ # 任务错误
+ "TASK_NOT_FOUND": "任务不存在",
+ "TASK_ALREADY_RUNNING": "任务已在运行中",
+ "TASK_CANCELLED": "任务已取消",
+}
+
+# ============================================================================
+# 正则表达式
+# ============================================================================
+
+REGEX_PATTERNS = {
+ "EMAIL": r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$",
+ "URL": r"https?://(?:[-\w.]|(?:%[\da-fA-F]{2}))+",
+ "IP_ADDRESS": r"\b(?:\d{1,3}\.){3}\d{1,3}\b",
+ "OTP_CODE": OTP_CODE_PATTERN,
+}
+
+# ============================================================================
+# 时间常量
+# ============================================================================
+
+TIME_CONSTANTS = {
+ "SECOND": 1,
+ "MINUTE": 60,
+ "HOUR": 3600,
+ "DAY": 86400,
+ "WEEK": 604800,
+}
+
+
+# ============================================================================
+# Microsoft/Outlook 相关常量
+# ============================================================================
+
+# Microsoft OAuth2 Token 端点
+MICROSOFT_TOKEN_ENDPOINTS = {
+ # 旧版 IMAP 使用的端点
+ "LIVE": "https://login.live.com/oauth20_token.srf",
+ # 新版 IMAP 使用的端点(需要特定 scope)
+ "CONSUMERS": "https://login.microsoftonline.com/consumers/oauth2/v2.0/token",
+ # Graph API 使用的端点
+ "COMMON": "https://login.microsoftonline.com/common/oauth2/v2.0/token",
+}
+
+# IMAP 服务器配置
+OUTLOOK_IMAP_SERVERS = {
+ "OLD": "outlook.office365.com", # 旧版 IMAP
+ "NEW": "outlook.live.com", # 新版 IMAP
+}
+
+# Microsoft OAuth2 Scopes
+MICROSOFT_SCOPES = {
+ # 旧版 IMAP 不需要特定 scope
+ "IMAP_OLD": "",
+ # 新版 IMAP 需要的 scope
+ "IMAP_NEW": "https://outlook.office.com/IMAP.AccessAsUser.All offline_access",
+ # Graph API 需要的 scope
+ "GRAPH_API": "https://graph.microsoft.com/.default",
+}
+
+# Outlook 提供者默认优先级
+OUTLOOK_PROVIDER_PRIORITY = ["imap_new", "imap_old", "graph_api"]
diff --git a/src/config/settings.py b/src/config/settings.py
new file mode 100644
index 0000000..f273fab
--- /dev/null
+++ b/src/config/settings.py
@@ -0,0 +1,771 @@
+"""
+配置管理 - 完全基于数据库存储
+所有配置都从数据库读取,不再使用环境变量或 .env 文件
+"""
+
+import os
+from typing import Optional, Dict, Any, Type, List
+from enum import Enum
+from pydantic import BaseModel, field_validator
+from pydantic.types import SecretStr
+from dataclasses import dataclass
+
+
+class SettingCategory(str, Enum):
+ """设置分类"""
+ GENERAL = "general"
+ DATABASE = "database"
+ WEBUI = "webui"
+ LOG = "log"
+ OPENAI = "openai"
+ PROXY = "proxy"
+ REGISTRATION = "registration"
+ EMAIL = "email"
+ TEMPMAIL = "tempmail"
+ CUSTOM_DOMAIN = "moe_mail"
+ SECURITY = "security"
+ CPA = "cpa"
+
+
+@dataclass
+class SettingDefinition:
+ """设置定义"""
+ db_key: str
+ default_value: Any
+ category: SettingCategory
+ description: str = ""
+ is_secret: bool = False
+
+
+# 所有配置项定义(包含数据库键名、默认值、分类、描述)
+SETTING_DEFINITIONS: Dict[str, SettingDefinition] = {
+ # 应用信息
+ "app_name": SettingDefinition(
+ db_key="app.name",
+ default_value="OpenAI/Codex CLI 自动注册系统",
+ category=SettingCategory.GENERAL,
+ description="应用名称"
+ ),
+ "app_version": SettingDefinition(
+ db_key="app.version",
+ default_value="2.0.0",
+ category=SettingCategory.GENERAL,
+ description="应用版本"
+ ),
+ "debug": SettingDefinition(
+ db_key="app.debug",
+ default_value=False,
+ category=SettingCategory.GENERAL,
+ description="调试模式"
+ ),
+
+ # 数据库配置
+ "database_url": SettingDefinition(
+ db_key="database.url",
+ default_value="data/database.db",
+ category=SettingCategory.DATABASE,
+ description="数据库路径或连接字符串"
+ ),
+
+ # Web UI 配置
+ "webui_host": SettingDefinition(
+ db_key="webui.host",
+ default_value="0.0.0.0",
+ category=SettingCategory.WEBUI,
+ description="Web UI 监听地址"
+ ),
+ "webui_port": SettingDefinition(
+ db_key="webui.port",
+ default_value=8000,
+ category=SettingCategory.WEBUI,
+ description="Web UI 监听端口"
+ ),
+ "webui_secret_key": SettingDefinition(
+ db_key="webui.secret_key",
+ default_value="your-secret-key-change-in-production",
+ category=SettingCategory.WEBUI,
+ description="Web UI 密钥",
+ is_secret=True
+ ),
+ "webui_access_password": SettingDefinition(
+ db_key="webui.access_password",
+ default_value="admin123",
+ category=SettingCategory.WEBUI,
+ description="Web UI 访问密码",
+ is_secret=True
+ ),
+
+ # 日志配置
+ "log_level": SettingDefinition(
+ db_key="log.level",
+ default_value="INFO",
+ category=SettingCategory.LOG,
+ description="日志级别"
+ ),
+ "log_file": SettingDefinition(
+ db_key="log.file",
+ default_value="logs/app.log",
+ category=SettingCategory.LOG,
+ description="日志文件路径"
+ ),
+ "log_retention_days": SettingDefinition(
+ db_key="log.retention_days",
+ default_value=30,
+ category=SettingCategory.LOG,
+ description="日志保留天数"
+ ),
+
+ # OpenAI 配置
+ "openai_client_id": SettingDefinition(
+ db_key="openai.client_id",
+ default_value="app_EMoamEEZ73f0CkXaXp7hrann",
+ category=SettingCategory.OPENAI,
+ description="OpenAI OAuth 客户端 ID"
+ ),
+ "openai_auth_url": SettingDefinition(
+ db_key="openai.auth_url",
+ default_value="https://auth.openai.com/oauth/authorize",
+ category=SettingCategory.OPENAI,
+ description="OpenAI OAuth 授权 URL"
+ ),
+ "openai_token_url": SettingDefinition(
+ db_key="openai.token_url",
+ default_value="https://auth.openai.com/oauth/token",
+ category=SettingCategory.OPENAI,
+ description="OpenAI OAuth Token URL"
+ ),
+ "openai_redirect_uri": SettingDefinition(
+ db_key="openai.redirect_uri",
+ default_value="http://localhost:1455/auth/callback",
+ category=SettingCategory.OPENAI,
+ description="OpenAI OAuth 回调 URI"
+ ),
+ "openai_scope": SettingDefinition(
+ db_key="openai.scope",
+ default_value="openid email profile offline_access",
+ category=SettingCategory.OPENAI,
+ description="OpenAI OAuth 权限范围"
+ ),
+
+ # 代理配置
+ "proxy_enabled": SettingDefinition(
+ db_key="proxy.enabled",
+ default_value=False,
+ category=SettingCategory.PROXY,
+ description="是否启用代理"
+ ),
+ "proxy_type": SettingDefinition(
+ db_key="proxy.type",
+ default_value="http",
+ category=SettingCategory.PROXY,
+ description="代理类型 (http/socks5)"
+ ),
+ "proxy_host": SettingDefinition(
+ db_key="proxy.host",
+ default_value="127.0.0.1",
+ category=SettingCategory.PROXY,
+ description="代理服务器地址"
+ ),
+ "proxy_port": SettingDefinition(
+ db_key="proxy.port",
+ default_value=7890,
+ category=SettingCategory.PROXY,
+ description="代理服务器端口"
+ ),
+ "proxy_username": SettingDefinition(
+ db_key="proxy.username",
+ default_value="",
+ category=SettingCategory.PROXY,
+ description="代理用户名"
+ ),
+ "proxy_password": SettingDefinition(
+ db_key="proxy.password",
+ default_value="",
+ category=SettingCategory.PROXY,
+ description="代理密码",
+ is_secret=True
+ ),
+ "proxy_dynamic_enabled": SettingDefinition(
+ db_key="proxy.dynamic_enabled",
+ default_value=False,
+ category=SettingCategory.PROXY,
+ description="是否启用动态代理"
+ ),
+ "proxy_dynamic_api_url": SettingDefinition(
+ db_key="proxy.dynamic_api_url",
+ default_value="",
+ category=SettingCategory.PROXY,
+ description="动态代理 API 地址,返回代理 URL 字符串"
+ ),
+ "proxy_dynamic_api_key": SettingDefinition(
+ db_key="proxy.dynamic_api_key",
+ default_value="",
+ category=SettingCategory.PROXY,
+ description="动态代理 API 密钥(可选)",
+ is_secret=True
+ ),
+ "proxy_dynamic_api_key_header": SettingDefinition(
+ db_key="proxy.dynamic_api_key_header",
+ default_value="X-API-Key",
+ category=SettingCategory.PROXY,
+ description="动态代理 API 密钥请求头名称"
+ ),
+ "proxy_dynamic_result_field": SettingDefinition(
+ db_key="proxy.dynamic_result_field",
+ default_value="",
+ category=SettingCategory.PROXY,
+ description="从 JSON 响应中提取代理 URL 的字段路径(留空则使用响应原文)"
+ ),
+
+ # 注册配置
+ "registration_max_retries": SettingDefinition(
+ db_key="registration.max_retries",
+ default_value=3,
+ category=SettingCategory.REGISTRATION,
+ description="注册最大重试次数"
+ ),
+ "registration_timeout": SettingDefinition(
+ db_key="registration.timeout",
+ default_value=120,
+ category=SettingCategory.REGISTRATION,
+ description="注册超时时间(秒)"
+ ),
+ "registration_default_password_length": SettingDefinition(
+ db_key="registration.default_password_length",
+ default_value=12,
+ category=SettingCategory.REGISTRATION,
+ description="默认密码长度"
+ ),
+ "registration_sleep_min": SettingDefinition(
+ db_key="registration.sleep_min",
+ default_value=5,
+ category=SettingCategory.REGISTRATION,
+ description="注册间隔最小值(秒)"
+ ),
+ "registration_sleep_max": SettingDefinition(
+ db_key="registration.sleep_max",
+ default_value=30,
+ category=SettingCategory.REGISTRATION,
+ description="注册间隔最大值(秒)"
+ ),
+
+ # 邮箱服务配置
+ "email_service_priority": SettingDefinition(
+ db_key="email.service_priority",
+ default_value={"tempmail": 0, "outlook": 1, "moe_mail": 2},
+ category=SettingCategory.EMAIL,
+ description="邮箱服务优先级"
+ ),
+
+ # Tempmail.lol 配置
+ "tempmail_base_url": SettingDefinition(
+ db_key="tempmail.base_url",
+ default_value="https://api.tempmail.lol/v2",
+ category=SettingCategory.TEMPMAIL,
+ description="Tempmail API 地址"
+ ),
+ "tempmail_timeout": SettingDefinition(
+ db_key="tempmail.timeout",
+ default_value=30,
+ category=SettingCategory.TEMPMAIL,
+ description="Tempmail 超时时间(秒)"
+ ),
+ "tempmail_max_retries": SettingDefinition(
+ db_key="tempmail.max_retries",
+ default_value=3,
+ category=SettingCategory.TEMPMAIL,
+ description="Tempmail 最大重试次数"
+ ),
+
+ # 自定义域名邮箱配置
+ "custom_domain_base_url": SettingDefinition(
+ db_key="custom_domain.base_url",
+ default_value="",
+ category=SettingCategory.CUSTOM_DOMAIN,
+ description="自定义域名 API 地址"
+ ),
+ "custom_domain_api_key": SettingDefinition(
+ db_key="custom_domain.api_key",
+ default_value="",
+ category=SettingCategory.CUSTOM_DOMAIN,
+ description="自定义域名 API 密钥",
+ is_secret=True
+ ),
+
+ # 安全配置
+ "encryption_key": SettingDefinition(
+ db_key="security.encryption_key",
+ default_value="your-encryption-key-change-in-production",
+ category=SettingCategory.SECURITY,
+ description="加密密钥",
+ 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",
+ default_value=False,
+ category=SettingCategory.CPA,
+ description="是否启用 CPA 上传"
+ ),
+ "cpa_api_url": SettingDefinition(
+ db_key="cpa.api_url",
+ default_value="",
+ category=SettingCategory.CPA,
+ description="CPA API 地址"
+ ),
+ "cpa_api_token": SettingDefinition(
+ db_key="cpa.api_token",
+ default_value="",
+ category=SettingCategory.CPA,
+ description="CPA API Token",
+ is_secret=True
+ ),
+
+ # 验证码配置
+ "email_code_timeout": SettingDefinition(
+ db_key="email_code.timeout",
+ default_value=120,
+ category=SettingCategory.EMAIL,
+ description="验证码等待超时时间(秒)"
+ ),
+ "email_code_poll_interval": SettingDefinition(
+ db_key="email_code.poll_interval",
+ default_value=3,
+ category=SettingCategory.EMAIL,
+ description="验证码轮询间隔(秒)"
+ ),
+
+ # Outlook 配置
+ "outlook_provider_priority": SettingDefinition(
+ db_key="outlook.provider_priority",
+ default_value=["imap_old", "imap_new", "graph_api"],
+ category=SettingCategory.EMAIL,
+ description="Outlook 提供者优先级"
+ ),
+ "outlook_health_failure_threshold": SettingDefinition(
+ db_key="outlook.health_failure_threshold",
+ default_value=5,
+ category=SettingCategory.EMAIL,
+ description="Outlook 提供者连续失败次数阈值"
+ ),
+ "outlook_health_disable_duration": SettingDefinition(
+ db_key="outlook.health_disable_duration",
+ default_value=60,
+ category=SettingCategory.EMAIL,
+ description="Outlook 提供者禁用时长(秒)"
+ ),
+ "outlook_default_client_id": SettingDefinition(
+ db_key="outlook.default_client_id",
+ default_value="24d9a0ed-8787-4584-883c-2fd79308940a",
+ category=SettingCategory.EMAIL,
+ description="Outlook OAuth 默认 Client ID"
+ ),
+}
+
+# 属性名到数据库键名的映射(用于向后兼容)
+DB_SETTING_KEYS = {name: defn.db_key for name, defn in SETTING_DEFINITIONS.items()}
+
+# 类型定义映射
+SETTING_TYPES: Dict[str, Type] = {
+ "debug": bool,
+ "webui_port": int,
+ "log_retention_days": int,
+ "proxy_enabled": bool,
+ "proxy_port": int,
+ "proxy_dynamic_enabled": bool,
+ "registration_max_retries": int,
+ "registration_timeout": int,
+ "registration_default_password_length": int,
+ "registration_sleep_min": int,
+ "registration_sleep_max": int,
+ "registration_engine": str,
+ "playwright_pool_size": int,
+ "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,
+ "outlook_provider_priority": list,
+ "outlook_health_failure_threshold": int,
+ "outlook_health_disable_duration": int,
+}
+
+# 需要作为 SecretStr 处理的字段
+SECRET_FIELDS = {name for name, defn in SETTING_DEFINITIONS.items() if defn.is_secret}
+
+
+def _convert_value(attr_name: str, value: str) -> Any:
+ """将数据库字符串值转换为正确的类型"""
+ if attr_name in SECRET_FIELDS:
+ return SecretStr(value) if value else SecretStr("")
+
+ target_type = SETTING_TYPES.get(attr_name, str)
+
+ if target_type == bool:
+ if isinstance(value, bool):
+ return value
+ return str(value).lower() in ("true", "1", "yes", "on")
+ elif target_type == int:
+ if isinstance(value, int):
+ return value
+ return int(value) if value else 0
+ elif target_type == dict:
+ if isinstance(value, dict):
+ return value
+ if not value:
+ return {}
+ import json
+ import ast
+ try:
+ return json.loads(value)
+ except (json.JSONDecodeError, ValueError):
+ try:
+ return ast.literal_eval(value)
+ except Exception:
+ return {}
+ elif target_type == list:
+ if isinstance(value, list):
+ return value
+ if not value:
+ return []
+ import json
+ import ast
+ try:
+ return json.loads(value)
+ except (json.JSONDecodeError, ValueError):
+ try:
+ return ast.literal_eval(value)
+ except Exception:
+ return []
+ else:
+ return value
+
+
+def _normalize_database_url(url: str) -> str:
+ if url.startswith("postgres://"):
+ return "postgresql+psycopg://" + url[len("postgres://"):]
+ if url.startswith("postgresql://"):
+ return "postgresql+psycopg://" + url[len("postgresql://"):]
+ return url
+
+
+def _value_to_string(value: Any) -> str:
+ """将值转换为数据库存储的字符串"""
+ if isinstance(value, SecretStr):
+ return value.get_secret_value()
+ elif isinstance(value, bool):
+ return "true" if value else "false"
+ elif isinstance(value, (dict, list)):
+ import json
+ return json.dumps(value)
+ elif value is None:
+ return ""
+ else:
+ return str(value)
+
+
+def init_default_settings() -> None:
+ """
+ 初始化数据库中的默认设置
+ 如果设置项不存在,则创建并设置默认值
+ """
+ try:
+ from ..database.session import get_db
+ from ..database.crud import get_setting, set_setting
+
+ with get_db() as db:
+ for attr_name, defn in SETTING_DEFINITIONS.items():
+ existing = get_setting(db, defn.db_key)
+ if not existing:
+ default_value = defn.default_value
+ if attr_name == "database_url":
+ env_url = os.environ.get("APP_DATABASE_URL") or os.environ.get("DATABASE_URL")
+ if env_url:
+ default_value = _normalize_database_url(env_url)
+ default_value = _value_to_string(default_value)
+ set_setting(
+ db,
+ defn.db_key,
+ default_value,
+ category=defn.category.value,
+ description=defn.description
+ )
+ print(f"[Settings] 初始化默认设置: {defn.db_key} = {default_value if not defn.is_secret else '***'}")
+ except Exception as e:
+ if "未初始化" not in str(e):
+ print(f"[Settings] 初始化默认设置失败: {e}")
+
+
+def _load_settings_from_db() -> Dict[str, Any]:
+ """从数据库加载所有设置"""
+ try:
+ from ..database.session import get_db
+ from ..database.crud import get_setting
+
+ settings_dict = {}
+ with get_db() as db:
+ for attr_name, defn in SETTING_DEFINITIONS.items():
+ db_setting = get_setting(db, defn.db_key)
+ if db_setting:
+ settings_dict[attr_name] = _convert_value(attr_name, db_setting.value)
+ else:
+ # 数据库中没有此设置,使用默认值
+ settings_dict[attr_name] = _convert_value(attr_name, _value_to_string(defn.default_value))
+ env_url = os.environ.get("APP_DATABASE_URL") or os.environ.get("DATABASE_URL")
+ if env_url:
+ settings_dict["database_url"] = _normalize_database_url(env_url)
+ env_host = os.environ.get("APP_HOST")
+ if env_host:
+ settings_dict["webui_host"] = env_host
+ env_port = os.environ.get("APP_PORT")
+ if env_port:
+ try:
+ settings_dict["webui_port"] = int(env_port)
+ except ValueError:
+ pass
+ env_password = os.environ.get("APP_ACCESS_PASSWORD")
+ if env_password:
+ settings_dict["webui_access_password"] = env_password
+ return settings_dict
+ except Exception as e:
+ if "未初始化" not in str(e):
+ print(f"[Settings] 从数据库加载设置失败: {e},使用默认值")
+ return {name: defn.default_value for name, defn in SETTING_DEFINITIONS.items()}
+
+
+def _save_settings_to_db(**kwargs) -> None:
+ """保存设置到数据库"""
+ try:
+ from ..database.session import get_db
+ from ..database.crud import set_setting
+
+ with get_db() as db:
+ for attr_name, value in kwargs.items():
+ if attr_name in SETTING_DEFINITIONS:
+ defn = SETTING_DEFINITIONS[attr_name]
+ str_value = _value_to_string(value)
+ set_setting(
+ db,
+ defn.db_key,
+ str_value,
+ category=defn.category.value,
+ description=defn.description
+ )
+ except Exception as e:
+ if "未初始化" not in str(e):
+ print(f"[Settings] 保存设置到数据库失败: {e}")
+
+
+class Settings(BaseModel):
+ """
+ 应用配置 - 完全基于数据库存储
+ """
+
+ # 应用信息
+ app_name: str = "OpenAI/Codex CLI 自动注册系统"
+ app_version: str = "2.0.0"
+ debug: bool = False
+
+ # 数据库配置
+ database_url: str = "data/database.db"
+
+ @field_validator('database_url', mode='before')
+ @classmethod
+ def validate_database_url(cls, v):
+ if isinstance(v, str):
+ if v.startswith(("postgres://", "postgresql://")):
+ return _normalize_database_url(v)
+ if v.startswith(("postgresql+psycopg://", "postgresql+psycopg2://")):
+ return v
+ if isinstance(v, str) and v.startswith("sqlite:///"):
+ return v
+ if isinstance(v, str) and not v.startswith(("sqlite:///", "postgresql://", "postgresql+psycopg://", "postgresql+psycopg2://", "mysql://")):
+ # 如果是文件路径,转换为 SQLite URL
+ if os.path.isabs(v) or ":/" not in v:
+ return f"sqlite:///{v}"
+ return v
+
+ # Web UI 配置
+ webui_host: str = "0.0.0.0"
+ webui_port: int = 8000
+ webui_secret_key: SecretStr = SecretStr("your-secret-key-change-in-production")
+ webui_access_password: SecretStr = SecretStr("admin123")
+
+ # 日志配置
+ log_level: str = "INFO"
+ log_file: str = "logs/app.log"
+ log_retention_days: int = 30
+
+ # OpenAI 配置
+ openai_client_id: str = "app_EMoamEEZ73f0CkXaXp7hrann"
+ openai_auth_url: str = "https://auth.openai.com/oauth/authorize"
+ openai_token_url: str = "https://auth.openai.com/oauth/token"
+ openai_redirect_uri: str = "http://localhost:1455/auth/callback"
+ openai_scope: str = "openid email profile offline_access"
+
+ # 代理配置
+ proxy_enabled: bool = False
+ proxy_type: str = "http"
+ proxy_host: str = "127.0.0.1"
+ proxy_port: int = 7890
+ proxy_username: Optional[str] = None
+ proxy_password: Optional[SecretStr] = None
+ proxy_dynamic_enabled: bool = False
+ proxy_dynamic_api_url: str = ""
+ proxy_dynamic_api_key: Optional[SecretStr] = None
+ proxy_dynamic_api_key_header: str = "X-API-Key"
+ proxy_dynamic_result_field: str = ""
+
+ @property
+ def proxy_url(self) -> Optional[str]:
+ """获取完整的代理 URL"""
+ if not self.proxy_enabled:
+ return None
+
+ if self.proxy_type == "http":
+ scheme = "http"
+ elif self.proxy_type == "socks5":
+ scheme = "socks5"
+ else:
+ return None
+
+ auth = ""
+ if self.proxy_username and self.proxy_password:
+ auth = f"{self.proxy_username}:{self.proxy_password.get_secret_value()}@"
+
+ return f"{scheme}://{auth}{self.proxy_host}:{self.proxy_port}"
+
+ # 注册配置
+ registration_max_retries: int = 3
+ registration_timeout: int = 120
+ registration_default_password_length: int = 12
+ registration_sleep_min: int = 5
+ registration_sleep_max: int = 30
+ registration_engine: str = "http"
+ playwright_pool_size: int = 5
+
+ # 邮箱服务配置
+ email_service_priority: Dict[str, int] = {"tempmail": 0, "outlook": 1, "moe_mail": 2}
+
+ # Tempmail.lol 配置
+ tempmail_base_url: str = "https://api.tempmail.lol/v2"
+ tempmail_timeout: int = 30
+ tempmail_max_retries: int = 3
+
+ # 自定义域名邮箱配置
+ custom_domain_base_url: str = ""
+ custom_domain_api_key: Optional[SecretStr] = None
+
+ # 安全配置
+ 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 = ""
+ cpa_api_token: SecretStr = SecretStr("")
+
+ # 验证码配置
+ email_code_timeout: int = 120
+ email_code_poll_interval: int = 3
+
+ # Outlook 配置
+ outlook_provider_priority: List[str] = ["imap_old", "imap_new", "graph_api"]
+ outlook_health_failure_threshold: int = 5
+ outlook_health_disable_duration: int = 60
+ outlook_default_client_id: str = "24d9a0ed-8787-4584-883c-2fd79308940a"
+
+
+# 全局配置实例
+_settings: Optional[Settings] = None
+
+
+def get_settings() -> Settings:
+ """
+ 获取全局配置实例(单例模式)
+ 完全从数据库加载配置
+ """
+ global _settings
+ if _settings is None:
+ # 先初始化默认设置(如果数据库中没有的话)
+ init_default_settings()
+ # 从数据库加载所有设置
+ settings_dict = _load_settings_from_db()
+ _settings = Settings(**settings_dict)
+ return _settings
+
+
+def update_settings(**kwargs) -> Settings:
+ """
+ 更新配置并保存到数据库
+ """
+ global _settings
+ if _settings is None:
+ _settings = get_settings()
+
+ # 创建新的配置实例
+ updated_data = _settings.model_dump()
+ updated_data.update(kwargs)
+ _settings = Settings(**updated_data)
+
+ # 保存到数据库
+ _save_settings_to_db(**kwargs)
+
+ return _settings
+
+
+def get_database_url() -> str:
+ """
+ 获取数据库 URL(处理相对路径)
+ """
+ settings = get_settings()
+ url = settings.database_url
+
+ # 如果 URL 是相对路径,转换为绝对路径
+ if url.startswith("sqlite:///"):
+ path = url[10:] # 移除 "sqlite:///"
+ if not os.path.isabs(path):
+ # 转换为相对于项目根目录的路径
+ project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
+ abs_path = os.path.join(project_root, path)
+ return f"sqlite:///{abs_path}"
+
+ return url
+
+
+def get_setting_definition(attr_name: str) -> Optional[SettingDefinition]:
+ """获取设置项的定义信息"""
+ return SETTING_DEFINITIONS.get(attr_name)
+
+
+def get_all_setting_definitions() -> Dict[str, SettingDefinition]:
+ """获取所有设置项的定义"""
+ return SETTING_DEFINITIONS.copy()
diff --git a/src/core/__init__.py b/src/core/__init__.py
new file mode 100644
index 0000000..7ec7c6f
--- /dev/null
+++ b/src/core/__init__.py
@@ -0,0 +1,32 @@
+"""
+核心功能模块
+"""
+
+from .openai.oauth import OAuthManager, OAuthStart, generate_oauth_url, submit_callback_url
+from .http_client import (
+ OpenAIHTTPClient,
+ HTTPClient,
+ HTTPClientError,
+ RequestConfig,
+ create_http_client,
+ create_openai_client,
+)
+from .register import RegistrationEngine, RegistrationResult
+from .utils import setup_logging, get_data_dir
+
+__all__ = [
+ 'OAuthManager',
+ 'OAuthStart',
+ 'generate_oauth_url',
+ 'submit_callback_url',
+ 'OpenAIHTTPClient',
+ 'HTTPClient',
+ 'HTTPClientError',
+ 'RequestConfig',
+ 'create_http_client',
+ 'create_openai_client',
+ 'RegistrationEngine',
+ 'RegistrationResult',
+ 'setup_logging',
+ 'get_data_dir',
+]
diff --git a/src/core/dynamic_proxy.py b/src/core/dynamic_proxy.py
new file mode 100644
index 0000000..daf8dfa
--- /dev/null
+++ b/src/core/dynamic_proxy.py
@@ -0,0 +1,118 @@
+"""
+动态代理获取模块
+支持通过外部 API 获取动态代理 URL
+"""
+
+import logging
+import re
+from typing import Optional
+
+logger = logging.getLogger(__name__)
+
+
+def fetch_dynamic_proxy(api_url: str, api_key: str = "", api_key_header: str = "X-API-Key", result_field: str = "") -> Optional[str]:
+ """
+ 从代理 API 获取代理 URL
+
+ Args:
+ api_url: 代理 API 地址,响应应为代理 URL 字符串或含代理 URL 的 JSON
+ api_key: API 密钥(可选)
+ api_key_header: API 密钥请求头名称
+ result_field: 从 JSON 响应中提取代理 URL 的字段路径,支持点号分隔(如 "data.proxy"),留空则使用响应原文
+
+ Returns:
+ 代理 URL 字符串(如 http://user:pass@host:port),失败返回 None
+ """
+ try:
+ from curl_cffi import requests as cffi_requests
+
+ headers = {}
+ if api_key:
+ headers[api_key_header] = api_key
+
+ response = cffi_requests.get(
+ api_url,
+ headers=headers,
+ timeout=10,
+ impersonate="chrome110"
+ )
+
+ if response.status_code != 200:
+ logger.warning(f"动态代理 API 返回错误状态码: {response.status_code}")
+ return None
+
+ text = response.text.strip()
+
+ # 尝试解析 JSON
+ if result_field or text.startswith("{") or text.startswith("["):
+ try:
+ import json
+ data = json.loads(text)
+ if result_field:
+ # 按点号路径逐层提取
+ for key in result_field.split("."):
+ if isinstance(data, dict):
+ data = data.get(key)
+ elif isinstance(data, list) and key.isdigit():
+ data = data[int(key)]
+ else:
+ data = None
+ if data is None:
+ break
+ proxy_url = str(data).strip() if data is not None else None
+ else:
+ # 无指定字段,尝试常见键名
+ for key in ("proxy", "url", "proxy_url", "data", "ip"):
+ val = data.get(key) if isinstance(data, dict) else None
+ if val:
+ proxy_url = str(val).strip()
+ break
+ else:
+ proxy_url = text
+ except (ValueError, AttributeError):
+ proxy_url = text
+ else:
+ proxy_url = text
+
+ if not proxy_url:
+ logger.warning("动态代理 API 返回空代理 URL")
+ return None
+
+ # 若未包含协议头,默认加 http://
+ if not re.match(r'^(http|socks5)://', proxy_url):
+ proxy_url = "http://" + proxy_url
+
+ logger.info(f"动态代理获取成功: {proxy_url[:40]}..." if len(proxy_url) > 40 else f"动态代理获取成功: {proxy_url}")
+ return proxy_url
+
+ except Exception as e:
+ logger.error(f"获取动态代理失败: {e}")
+ return None
+
+
+def get_proxy_url_for_task() -> Optional[str]:
+ """
+ 为注册任务获取代理 URL。
+ 优先使用动态代理(若启用),否则使用静态代理配置。
+
+ Returns:
+ 代理 URL 或 None
+ """
+ from ..config.settings import get_settings
+ settings = get_settings()
+
+ # 优先使用动态代理
+ if settings.proxy_dynamic_enabled and settings.proxy_dynamic_api_url:
+ api_key = settings.proxy_dynamic_api_key.get_secret_value() if settings.proxy_dynamic_api_key else ""
+ proxy_url = fetch_dynamic_proxy(
+ api_url=settings.proxy_dynamic_api_url,
+ api_key=api_key,
+ api_key_header=settings.proxy_dynamic_api_key_header,
+ result_field=settings.proxy_dynamic_result_field,
+ )
+ if proxy_url:
+ return proxy_url
+ logger.warning("动态代理获取失败,回退到静态代理")
+
+ # 使用静态代理
+ return settings.proxy_url
diff --git a/src/core/http_client.py b/src/core/http_client.py
new file mode 100644
index 0000000..517dfc4
--- /dev/null
+++ b/src/core/http_client.py
@@ -0,0 +1,420 @@
+"""
+HTTP 客户端封装
+基于 curl_cffi 的 HTTP 请求封装,支持代理和错误处理
+"""
+
+import time
+import json
+from typing import Optional, Dict, Any, Union, Tuple
+from dataclasses import dataclass
+import logging
+
+from curl_cffi import requests as cffi_requests
+from curl_cffi.requests import Session, Response
+
+from ..config.constants import ERROR_MESSAGES
+from ..config.settings import get_settings
+
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class RequestConfig:
+ """HTTP 请求配置"""
+ timeout: int = 30
+ max_retries: int = 3
+ retry_delay: float = 1.0
+ impersonate: str = "chrome"
+ verify_ssl: bool = True
+ follow_redirects: bool = True
+
+
+class HTTPClientError(Exception):
+ """HTTP 客户端异常"""
+ pass
+
+
+class HTTPClient:
+ """
+ HTTP 客户端封装
+ 支持代理、重试、错误处理和会话管理
+ """
+
+ def __init__(
+ self,
+ proxy_url: Optional[str] = None,
+ config: Optional[RequestConfig] = None,
+ session: Optional[Session] = None
+ ):
+ """
+ 初始化 HTTP 客户端
+
+ Args:
+ proxy_url: 代理 URL,如 "http://127.0.0.1:7890"
+ config: 请求配置
+ session: 可重用的会话对象
+ """
+ self.proxy_url = proxy_url
+ self.config = config or RequestConfig()
+ self._session = session
+
+ @property
+ def proxies(self) -> Optional[Dict[str, str]]:
+ """获取代理配置"""
+ if not self.proxy_url:
+ return None
+ return {
+ "http": self.proxy_url,
+ "https": self.proxy_url,
+ }
+
+ @property
+ def session(self) -> Session:
+ """获取会话对象(单例)"""
+ if self._session is None:
+ self._session = Session(
+ proxies=self.proxies,
+ impersonate=self.config.impersonate,
+ verify=self.config.verify_ssl,
+ timeout=self.config.timeout
+ )
+ return self._session
+
+ def request(
+ self,
+ method: str,
+ url: str,
+ **kwargs
+ ) -> Response:
+ """
+ 发送 HTTP 请求
+
+ Args:
+ method: HTTP 方法 (GET, POST, PUT, DELETE, etc.)
+ url: 请求 URL
+ **kwargs: 其他请求参数
+
+ Returns:
+ Response 对象
+
+ Raises:
+ HTTPClientError: 请求失败
+ """
+ # 设置默认参数
+ kwargs.setdefault("timeout", self.config.timeout)
+ kwargs.setdefault("allow_redirects", self.config.follow_redirects)
+
+ # 添加代理配置
+ if self.proxies and "proxies" not in kwargs:
+ kwargs["proxies"] = self.proxies
+
+ last_exception = None
+ for attempt in range(self.config.max_retries):
+ try:
+ response = self.session.request(method, url, **kwargs)
+
+ # 检查响应状态码
+ if response.status_code >= 400:
+ logger.warning(
+ f"HTTP {response.status_code} for {method} {url}"
+ f" (attempt {attempt + 1}/{self.config.max_retries})"
+ )
+
+ # 如果是服务器错误,重试
+ if response.status_code >= 500 and attempt < self.config.max_retries - 1:
+ time.sleep(self.config.retry_delay * (attempt + 1))
+ continue
+
+ return response
+
+ except (cffi_requests.RequestsError, ConnectionError, TimeoutError) as e:
+ last_exception = e
+ logger.warning(
+ f"请求失败: {method} {url} (attempt {attempt + 1}/{self.config.max_retries}): {e}"
+ )
+
+ if attempt < self.config.max_retries - 1:
+ time.sleep(self.config.retry_delay * (attempt + 1))
+ else:
+ break
+
+ raise HTTPClientError(
+ f"请求失败,最大重试次数已达: {method} {url} - {last_exception}"
+ )
+
+ def get(self, url: str, **kwargs) -> Response:
+ """发送 GET 请求"""
+ return self.request("GET", url, **kwargs)
+
+ def post(self, url: str, data: Any = None, json: Any = None, **kwargs) -> Response:
+ """发送 POST 请求"""
+ return self.request("POST", url, data=data, json=json, **kwargs)
+
+ def put(self, url: str, data: Any = None, json: Any = None, **kwargs) -> Response:
+ """发送 PUT 请求"""
+ return self.request("PUT", url, data=data, json=json, **kwargs)
+
+ def delete(self, url: str, **kwargs) -> Response:
+ """发送 DELETE 请求"""
+ return self.request("DELETE", url, **kwargs)
+
+ def head(self, url: str, **kwargs) -> Response:
+ """发送 HEAD 请求"""
+ return self.request("HEAD", url, **kwargs)
+
+ def options(self, url: str, **kwargs) -> Response:
+ """发送 OPTIONS 请求"""
+ return self.request("OPTIONS", url, **kwargs)
+
+ def patch(self, url: str, data: Any = None, json: Any = None, **kwargs) -> Response:
+ """发送 PATCH 请求"""
+ return self.request("PATCH", url, data=data, json=json, **kwargs)
+
+ def download_file(self, url: str, filepath: str, chunk_size: int = 8192) -> None:
+ """
+ 下载文件
+
+ Args:
+ url: 文件 URL
+ filepath: 保存路径
+ chunk_size: 块大小
+
+ Raises:
+ HTTPClientError: 下载失败
+ """
+ try:
+ response = self.get(url, stream=True)
+ response.raise_for_status()
+
+ with open(filepath, 'wb') as f:
+ for chunk in response.iter_content(chunk_size=chunk_size):
+ if chunk:
+ f.write(chunk)
+
+ except Exception as e:
+ raise HTTPClientError(f"下载文件失败: {url} - {e}")
+
+ def check_proxy(self, test_url: str = "https://httpbin.org/ip") -> bool:
+ """
+ 检查代理是否可用
+
+ Args:
+ test_url: 测试 URL
+
+ Returns:
+ bool: 代理是否可用
+ """
+ if not self.proxy_url:
+ return False
+
+ try:
+ response = self.get(test_url, timeout=10)
+ return response.status_code == 200
+ except Exception:
+ return False
+
+ def close(self):
+ """关闭会话"""
+ if self._session:
+ self._session.close()
+ self._session = None
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ self.close()
+
+
+class OpenAIHTTPClient(HTTPClient):
+ """
+ OpenAI 专用 HTTP 客户端
+ 包含 OpenAI API 特定的请求方法
+ """
+
+ def __init__(
+ self,
+ proxy_url: Optional[str] = None,
+ config: Optional[RequestConfig] = None
+ ):
+ """
+ 初始化 OpenAI HTTP 客户端
+
+ Args:
+ proxy_url: 代理 URL
+ config: 请求配置
+ """
+ super().__init__(proxy_url, config)
+
+ # OpenAI 特定的默认配置
+ if config is None:
+ self.config.timeout = 30
+ self.config.max_retries = 3
+
+ # 默认请求头
+ self.default_headers = {
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
+ "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
+ "Accept": "application/json",
+ "Accept-Language": "en-US,en;q=0.9",
+ "Accept-Encoding": "gzip, deflate, br",
+ "Connection": "keep-alive",
+ "Sec-Fetch-Dest": "empty",
+ "Sec-Fetch-Mode": "cors",
+ "Sec-Fetch-Site": "same-site",
+ }
+
+ def check_ip_location(self) -> Tuple[bool, Optional[str]]:
+ """
+ 检查 IP 地理位置
+
+ Returns:
+ Tuple[是否支持, 位置信息]
+ """
+ try:
+ response = self.get("https://cloudflare.com/cdn-cgi/trace", timeout=10)
+ trace_text = response.text
+
+ # 解析位置信息
+ import re
+ loc_match = re.search(r"loc=([A-Z]+)", trace_text)
+ loc = loc_match.group(1) if loc_match else None
+
+ # 检查是否支持
+ if loc in ["CN", "HK", "MO", "TW"]:
+ return False, loc
+ return True, loc
+
+ except Exception as e:
+ logger.error(f"检查 IP 地理位置失败: {e}")
+ return False, None
+
+ def send_openai_request(
+ self,
+ endpoint: str,
+ method: str = "POST",
+ data: Optional[Dict[str, Any]] = None,
+ json_data: Optional[Dict[str, Any]] = None,
+ headers: Optional[Dict[str, str]] = None,
+ **kwargs
+ ) -> Dict[str, Any]:
+ """
+ 发送 OpenAI API 请求
+
+ Args:
+ endpoint: API 端点
+ method: HTTP 方法
+ data: 表单数据
+ json_data: JSON 数据
+ headers: 请求头
+ **kwargs: 其他参数
+
+ Returns:
+ 响应 JSON 数据
+
+ Raises:
+ HTTPClientError: 请求失败
+ """
+ # 合并请求头
+ request_headers = self.default_headers.copy()
+ if headers:
+ request_headers.update(headers)
+
+ # 设置 Content-Type
+ if json_data is not None and "Content-Type" not in request_headers:
+ request_headers["Content-Type"] = "application/json"
+ elif data is not None and "Content-Type" not in request_headers:
+ request_headers["Content-Type"] = "application/x-www-form-urlencoded"
+
+ try:
+ response = self.request(
+ method,
+ endpoint,
+ data=data,
+ json=json_data,
+ headers=request_headers,
+ **kwargs
+ )
+
+ # 检查响应状态码
+ response.raise_for_status()
+
+ # 尝试解析 JSON
+ try:
+ return response.json()
+ except json.JSONDecodeError:
+ return {"raw_response": response.text}
+
+ except cffi_requests.RequestsError as e:
+ raise HTTPClientError(f"OpenAI 请求失败: {endpoint} - {e}")
+
+ def check_sentinel(self, did: str, proxies: Optional[Dict] = None) -> Optional[str]:
+ """
+ 检查 Sentinel 拦截
+
+ Args:
+ did: Device ID
+ proxies: 代理配置
+
+ Returns:
+ Sentinel token 或 None
+ """
+ from ..config.constants import OPENAI_API_ENDPOINTS
+
+ try:
+ sen_req_body = f'{{"p":"","id":"{did}","flow":"authorize_continue"}}'
+
+ response = self.post(
+ OPENAI_API_ENDPOINTS["sentinel"],
+ headers={
+ "origin": "https://sentinel.openai.com",
+ "referer": "https://sentinel.openai.com/backend-api/sentinel/frame.html?sv=20260219f9f6",
+ "content-type": "text/plain;charset=UTF-8",
+ },
+ data=sen_req_body,
+ )
+
+ if response.status_code == 200:
+ return response.json().get("token")
+ else:
+ logger.warning(f"Sentinel 检查失败: {response.status_code}")
+ return None
+
+ except Exception as e:
+ logger.error(f"Sentinel 检查异常: {e}")
+ return None
+
+
+def create_http_client(
+ proxy_url: Optional[str] = None,
+ config: Optional[RequestConfig] = None
+) -> HTTPClient:
+ """
+ 创建 HTTP 客户端工厂函数
+
+ Args:
+ proxy_url: 代理 URL
+ config: 请求配置
+
+ Returns:
+ HTTPClient 实例
+ """
+ return HTTPClient(proxy_url, config)
+
+
+def create_openai_client(
+ proxy_url: Optional[str] = None,
+ config: Optional[RequestConfig] = None
+) -> OpenAIHTTPClient:
+ """
+ 创建 OpenAI HTTP 客户端工厂函数
+
+ Args:
+ proxy_url: 代理 URL
+ config: 请求配置
+
+ Returns:
+ OpenAIHTTPClient 实例
+ """
+ return OpenAIHTTPClient(proxy_url, config)
\ No newline at end of file
diff --git a/src/core/openai/__init__.py b/src/core/openai/__init__.py
new file mode 100644
index 0000000..3a2da6d
--- /dev/null
+++ b/src/core/openai/__init__.py
@@ -0,0 +1,3 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+# @Time : 2026/3/18 19:55
\ No newline at end of file
diff --git a/src/core/openai/oauth.py b/src/core/openai/oauth.py
new file mode 100644
index 0000000..e8dc0fa
--- /dev/null
+++ b/src/core/openai/oauth.py
@@ -0,0 +1,370 @@
+"""
+OpenAI OAuth 授权模块
+从 main.py 中提取的 OAuth 相关函数
+"""
+
+import base64
+import hashlib
+import json
+import secrets
+import time
+import urllib.parse
+from dataclasses import dataclass
+from typing import Any, Dict, Optional
+
+from curl_cffi import requests as cffi_requests
+
+from ...config.constants import (
+ OAUTH_CLIENT_ID,
+ OAUTH_AUTH_URL,
+ OAUTH_TOKEN_URL,
+ OAUTH_REDIRECT_URI,
+ OAUTH_SCOPE,
+)
+
+
+def _b64url_no_pad(raw: bytes) -> str:
+ """Base64 URL 编码(无填充)"""
+ return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
+
+
+def _sha256_b64url_no_pad(s: str) -> str:
+ """SHA256 哈希后 Base64 URL 编码"""
+ return _b64url_no_pad(hashlib.sha256(s.encode("ascii")).digest())
+
+
+def _random_state(nbytes: int = 16) -> str:
+ """生成随机 state"""
+ return secrets.token_urlsafe(nbytes)
+
+
+def _pkce_verifier() -> str:
+ """生成 PKCE code_verifier"""
+ return secrets.token_urlsafe(64)
+
+
+def _parse_callback_url(callback_url: str) -> Dict[str, str]:
+ """解析回调 URL"""
+ candidate = callback_url.strip()
+ if not candidate:
+ return {"code": "", "state": "", "error": "", "error_description": ""}
+
+ if "://" not in candidate:
+ if candidate.startswith("?"):
+ candidate = f"http://localhost{candidate}"
+ elif any(ch in candidate for ch in "/?#") or ":" in candidate:
+ candidate = f"http://{candidate}"
+ elif "=" in candidate:
+ candidate = f"http://localhost/?{candidate}"
+
+ parsed = urllib.parse.urlparse(candidate)
+ query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
+ fragment = urllib.parse.parse_qs(parsed.fragment, keep_blank_values=True)
+
+ for key, values in fragment.items():
+ if key not in query or not query[key] or not (query[key][0] or "").strip():
+ query[key] = values
+
+ def get1(k: str) -> str:
+ v = query.get(k, [""])
+ return (v[0] or "").strip()
+
+ code = get1("code")
+ state = get1("state")
+ error = get1("error")
+ error_description = get1("error_description")
+
+ if code and not state and "#" in code:
+ code, state = code.split("#", 1)
+
+ if not error and error_description:
+ error, error_description = error_description, ""
+
+ return {
+ "code": code,
+ "state": state,
+ "error": error,
+ "error_description": error_description,
+ }
+
+
+def _jwt_claims_no_verify(id_token: str) -> Dict[str, Any]:
+ """解析 JWT ID Token(不验证签名)"""
+ if not id_token or id_token.count(".") < 2:
+ return {}
+ payload_b64 = id_token.split(".")[1]
+ pad = "=" * ((4 - (len(payload_b64) % 4)) % 4)
+ try:
+ payload = base64.urlsafe_b64decode((payload_b64 + pad).encode("ascii"))
+ return json.loads(payload.decode("utf-8"))
+ except Exception:
+ return {}
+
+
+def _decode_jwt_segment(seg: str) -> Dict[str, Any]:
+ """解码 JWT 片段"""
+ raw = (seg or "").strip()
+ if not raw:
+ return {}
+ pad = "=" * ((4 - (len(raw) % 4)) % 4)
+ try:
+ decoded = base64.urlsafe_b64decode((raw + pad).encode("ascii"))
+ return json.loads(decoded.decode("utf-8"))
+ except Exception:
+ return {}
+
+
+def _to_int(v: Any) -> int:
+ """转换为整数"""
+ try:
+ return int(v)
+ except (TypeError, ValueError):
+ return 0
+
+
+def _post_form(
+ url: str,
+ data: Dict[str, str],
+ timeout: int = 30,
+ proxy_url: Optional[str] = None
+) -> Dict[str, Any]:
+ """
+ 发送 POST 表单请求
+
+ Args:
+ url: 请求 URL
+ data: 表单数据
+ timeout: 超时时间
+ proxy_url: 代理 URL
+
+ Returns:
+ 响应 JSON 数据
+ """
+ # 构建代理配置
+ proxies = None
+ if proxy_url:
+ proxies = {
+ "http": proxy_url,
+ "https": proxy_url,
+ }
+
+ headers = {
+ "Content-Type": "application/x-www-form-urlencoded",
+ "Accept": "application/json",
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
+ "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
+ }
+
+ try:
+ # 使用 curl_cffi 发送请求,支持代理和浏览器指纹
+ response = cffi_requests.post(
+ url,
+ data=data,
+ headers=headers,
+ timeout=timeout,
+ proxies=proxies,
+ impersonate="chrome"
+ )
+
+ if response.status_code != 200:
+ raise RuntimeError(
+ f"token exchange failed: {response.status_code}: {response.text}"
+ )
+
+ return response.json()
+
+ except cffi_requests.RequestsError as e:
+ raise RuntimeError(f"token exchange failed: network error: {e}") from e
+
+
+@dataclass(frozen=True)
+class OAuthStart:
+ """OAuth 开始信息"""
+ auth_url: str
+ state: str
+ code_verifier: str
+ redirect_uri: str
+
+
+def generate_oauth_url(
+ *,
+ redirect_uri: str = OAUTH_REDIRECT_URI,
+ scope: str = OAUTH_SCOPE,
+ client_id: str = OAUTH_CLIENT_ID
+) -> OAuthStart:
+ """
+ 生成 OAuth 授权 URL
+
+ Args:
+ redirect_uri: 回调地址
+ scope: 权限范围
+ client_id: OpenAI Client ID
+
+ Returns:
+ OAuthStart 对象,包含授权 URL 和必要参数
+ """
+ state = _random_state()
+ code_verifier = _pkce_verifier()
+ code_challenge = _sha256_b64url_no_pad(code_verifier)
+
+ params = {
+ "client_id": client_id,
+ "response_type": "code",
+ "redirect_uri": redirect_uri,
+ "scope": scope,
+ "state": state,
+ "code_challenge": code_challenge,
+ "code_challenge_method": "S256",
+ "prompt": "login",
+ "id_token_add_organizations": "true",
+ "codex_cli_simplified_flow": "true",
+ }
+ auth_url = f"{OAUTH_AUTH_URL}?{urllib.parse.urlencode(params)}"
+ return OAuthStart(
+ auth_url=auth_url,
+ state=state,
+ code_verifier=code_verifier,
+ redirect_uri=redirect_uri,
+ )
+
+
+def submit_callback_url(
+ *,
+ callback_url: str,
+ expected_state: str,
+ code_verifier: str,
+ redirect_uri: str = OAUTH_REDIRECT_URI,
+ client_id: str = OAUTH_CLIENT_ID,
+ token_url: str = OAUTH_TOKEN_URL,
+ proxy_url: Optional[str] = None
+) -> str:
+ """
+ 处理 OAuth 回调 URL,获取访问令牌
+
+ Args:
+ callback_url: 回调 URL
+ expected_state: 预期的 state 值
+ code_verifier: PKCE code_verifier
+ redirect_uri: 回调地址
+ client_id: OpenAI Client ID
+ token_url: Token 交换地址
+ proxy_url: 代理 URL
+
+ Returns:
+ 包含访问令牌等信息的 JSON 字符串
+
+ Raises:
+ RuntimeError: OAuth 错误
+ ValueError: 缺少必要参数或 state 不匹配
+ """
+ cb = _parse_callback_url(callback_url)
+ if cb["error"]:
+ desc = cb["error_description"]
+ raise RuntimeError(f"oauth error: {cb['error']}: {desc}".strip())
+
+ if not cb["code"]:
+ raise ValueError("callback url missing ?code=")
+ if not cb["state"]:
+ raise ValueError("callback url missing ?state=")
+ if cb["state"] != expected_state:
+ raise ValueError("state mismatch")
+
+ token_resp = _post_form(
+ token_url,
+ {
+ "grant_type": "authorization_code",
+ "client_id": client_id,
+ "code": cb["code"],
+ "redirect_uri": redirect_uri,
+ "code_verifier": code_verifier,
+ },
+ proxy_url=proxy_url
+ )
+
+ access_token = (token_resp.get("access_token") or "").strip()
+ refresh_token = (token_resp.get("refresh_token") or "").strip()
+ id_token = (token_resp.get("id_token") or "").strip()
+ expires_in = _to_int(token_resp.get("expires_in"))
+
+ claims = _jwt_claims_no_verify(id_token)
+ email = str(claims.get("email") or "").strip()
+ auth_claims = claims.get("https://api.openai.com/auth") or {}
+ account_id = str(auth_claims.get("chatgpt_account_id") or "").strip()
+
+ now = int(time.time())
+ expired_rfc3339 = time.strftime(
+ "%Y-%m-%dT%H:%M:%SZ", time.gmtime(now + max(expires_in, 0))
+ )
+ now_rfc3339 = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now))
+
+ config = {
+ "id_token": id_token,
+ "access_token": access_token,
+ "refresh_token": refresh_token,
+ "account_id": account_id,
+ "last_refresh": now_rfc3339,
+ "email": email,
+ "type": "codex",
+ "expired": expired_rfc3339,
+ }
+
+ return json.dumps(config, ensure_ascii=False, separators=(",", ":"))
+
+
+class OAuthManager:
+ """OAuth 管理器"""
+
+ def __init__(
+ self,
+ client_id: str = OAUTH_CLIENT_ID,
+ auth_url: str = OAUTH_AUTH_URL,
+ token_url: str = OAUTH_TOKEN_URL,
+ redirect_uri: str = OAUTH_REDIRECT_URI,
+ scope: str = OAUTH_SCOPE,
+ proxy_url: Optional[str] = None
+ ):
+ self.client_id = client_id
+ self.auth_url = auth_url
+ self.token_url = token_url
+ self.redirect_uri = redirect_uri
+ self.scope = scope
+ self.proxy_url = proxy_url
+
+ def start_oauth(self) -> OAuthStart:
+ """开始 OAuth 流程"""
+ return generate_oauth_url(
+ redirect_uri=self.redirect_uri,
+ scope=self.scope,
+ client_id=self.client_id
+ )
+
+ def handle_callback(
+ self,
+ callback_url: str,
+ expected_state: str,
+ code_verifier: str
+ ) -> Dict[str, Any]:
+ """处理 OAuth 回调"""
+ result_json = submit_callback_url(
+ callback_url=callback_url,
+ expected_state=expected_state,
+ code_verifier=code_verifier,
+ redirect_uri=self.redirect_uri,
+ client_id=self.client_id,
+ token_url=self.token_url,
+ proxy_url=self.proxy_url
+ )
+ return json.loads(result_json)
+
+ def extract_account_info(self, id_token: str) -> Dict[str, Any]:
+ """从 ID Token 中提取账户信息"""
+ claims = _jwt_claims_no_verify(id_token)
+ email = str(claims.get("email") or "").strip()
+ auth_claims = claims.get("https://api.openai.com/auth") or {}
+ account_id = str(auth_claims.get("chatgpt_account_id") or "").strip()
+
+ return {
+ "email": email,
+ "account_id": account_id,
+ "claims": claims
+ }
\ No newline at end of file
diff --git a/src/core/openai/payment.py b/src/core/openai/payment.py
new file mode 100644
index 0000000..d584a58
--- /dev/null
+++ b/src/core/openai/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/openai/token_refresh.py b/src/core/openai/token_refresh.py
new file mode 100644
index 0000000..b387ddb
--- /dev/null
+++ b/src/core/openai/token_refresh.py
@@ -0,0 +1,361 @@
+"""
+Token 刷新模块
+支持 Session Token 和 OAuth Refresh Token 两种刷新方式
+"""
+
+import logging
+import json
+import time
+from typing import Optional, Dict, Any, Tuple
+from dataclasses import dataclass
+from datetime import datetime, timedelta
+
+from curl_cffi import requests as cffi_requests
+
+from ...config.settings import get_settings
+from ...database.session import get_db
+from ...database import crud
+from ...database.models import Account
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class TokenRefreshResult:
+ """Token 刷新结果"""
+ success: bool
+ access_token: str = ""
+ refresh_token: str = ""
+ expires_at: Optional[datetime] = None
+ error_message: str = ""
+
+
+class TokenRefreshManager:
+ """
+ Token 刷新管理器
+ 支持两种刷新方式:
+ 1. Session Token 刷新(优先)
+ 2. OAuth Refresh Token 刷新
+ """
+
+ # OpenAI OAuth 端点
+ SESSION_URL = "https://chatgpt.com/api/auth/session"
+ TOKEN_URL = "https://auth.openai.com/oauth/token"
+
+ def __init__(self, proxy_url: Optional[str] = None):
+ """
+ 初始化 Token 刷新管理器
+
+ Args:
+ proxy_url: 代理 URL
+ """
+ self.proxy_url = proxy_url
+ self.settings = get_settings()
+
+ def _create_session(self) -> cffi_requests.Session:
+ """创建 HTTP 会话"""
+ session = cffi_requests.Session(impersonate="chrome120", proxy=self.proxy_url)
+ return session
+
+ def _parse_oauth_error(self, response: cffi_requests.Response) -> str:
+ """解析 OAuth 错误信息"""
+ body_text = (response.text or "").strip()
+ error_message = ""
+
+ try:
+ body = response.json()
+ error_obj = body.get("error") if isinstance(body, dict) else None
+ if isinstance(error_obj, dict):
+ error_message = str(error_obj.get("message") or "").strip()
+ elif isinstance(body, dict):
+ error_message = str(body.get("error_description") or body.get("message") or "").strip()
+ except Exception:
+ pass
+
+ error_lower = error_message.lower()
+ if "refresh token has already been used" in error_lower:
+ return "OAuth refresh_token 已失效(一次性令牌已被使用),请重新登录该账号后再上传 CPA"
+ if response.status_code == 401:
+ if error_message:
+ return f"OAuth token 刷新失败: {error_message}"
+ else:
+ return "OAuth token 刷新失败: refresh_token 无效或已过期,请重新登录账号"
+ if error_message:
+ return f"OAuth token 刷新失败: {error_message}"
+ if body_text:
+ return f"OAuth token 刷新失败: HTTP {response.status_code}, 响应: {body_text[:200]}"
+ return f"OAuth token 刷新失败: HTTP {response.status_code}"
+
+ def refresh_by_session_token(self, session_token: str) -> TokenRefreshResult:
+ """
+ 使用 Session Token 刷新
+
+ Args:
+ session_token: 会话令牌
+
+ Returns:
+ TokenRefreshResult: 刷新结果
+ """
+ result = TokenRefreshResult(success=False)
+
+ try:
+ session = self._create_session()
+
+ # 设置会话 Cookie
+ session.cookies.set(
+ "__Secure-next-auth.session-token",
+ session_token,
+ domain=".chatgpt.com",
+ path="/"
+ )
+
+ # 请求会话端点
+ response = session.get(
+ self.SESSION_URL,
+ headers={
+ "accept": "application/json",
+ "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
+ },
+ timeout=30
+ )
+
+ if response.status_code != 200:
+ result.error_message = f"Session token 刷新失败: HTTP {response.status_code}"
+ logger.warning(result.error_message)
+ return result
+
+ data = response.json()
+
+ # 提取 access_token
+ access_token = data.get("accessToken")
+ if not access_token:
+ result.error_message = "Session token 刷新失败: 未找到 accessToken"
+ logger.warning(result.error_message)
+ return result
+
+ # 提取过期时间
+ expires_at = None
+ expires_str = data.get("expires")
+ if expires_str:
+ try:
+ expires_at = datetime.fromisoformat(expires_str.replace("Z", "+00:00"))
+ except:
+ pass
+
+ result.success = True
+ result.access_token = access_token
+ result.expires_at = expires_at
+
+ logger.info(f"Session token 刷新成功,过期时间: {expires_at}")
+ return result
+
+ except Exception as e:
+ result.error_message = f"Session token 刷新异常: {str(e)}"
+ logger.error(result.error_message)
+ return result
+
+ def refresh_by_oauth_token(
+ self,
+ refresh_token: str,
+ client_id: Optional[str] = None
+ ) -> TokenRefreshResult:
+ """
+ 使用 OAuth Refresh Token 刷新
+
+ Args:
+ refresh_token: OAuth 刷新令牌
+ client_id: OAuth Client ID
+
+ Returns:
+ TokenRefreshResult: 刷新结果
+ """
+ result = TokenRefreshResult(success=False)
+
+ try:
+ session = self._create_session()
+
+ # 使用配置的 client_id 或默认值
+ client_id = client_id or self.settings.openai_client_id
+
+ # 构建请求体
+ token_data = {
+ "client_id": client_id,
+ "grant_type": "refresh_token",
+ "refresh_token": refresh_token,
+ "redirect_uri": self.settings.openai_redirect_uri
+ }
+
+ response = session.post(
+ self.TOKEN_URL,
+ headers={
+ "content-type": "application/x-www-form-urlencoded",
+ "accept": "application/json"
+ },
+ data=token_data,
+ timeout=30
+ )
+
+ if response.status_code != 200:
+ result.error_message = self._parse_oauth_error(response)
+ logger.warning(f"{result.error_message}, 响应: {response.text[:200]}")
+ return result
+
+ data = response.json()
+
+ # 提取令牌
+ access_token = data.get("access_token")
+ new_refresh_token = data.get("refresh_token", refresh_token)
+ expires_in = data.get("expires_in", 3600)
+
+ if not access_token:
+ result.error_message = "OAuth token 刷新失败: 未找到 access_token"
+ logger.warning(result.error_message)
+ return result
+
+ # 计算过期时间
+ expires_at = datetime.utcnow() + timedelta(seconds=expires_in)
+
+ result.success = True
+ result.access_token = access_token
+ result.refresh_token = new_refresh_token
+ result.expires_at = expires_at
+
+ logger.info(f"OAuth token 刷新成功,过期时间: {expires_at}")
+ return result
+
+ except Exception as e:
+ result.error_message = f"OAuth token 刷新异常: {str(e)}"
+ logger.error(result.error_message)
+ return result
+
+ def refresh_account(self, account: Account) -> TokenRefreshResult:
+ """
+ 刷新账号的 Token
+
+ 优先级:
+ 1. Session Token 刷新
+ 2. OAuth Refresh Token 刷新
+
+ Args:
+ account: 账号对象
+
+ Returns:
+ TokenRefreshResult: 刷新结果
+ """
+ # 优先尝试 Session Token
+ if account.session_token:
+ logger.info(f"尝试使用 Session Token 刷新账号 {account.email}")
+ result = self.refresh_by_session_token(account.session_token)
+ if result.success:
+ return result
+ logger.warning(f"Session Token 刷新失败,尝试 OAuth 刷新")
+
+ # 尝试 OAuth Refresh Token
+ if account.refresh_token:
+ logger.info(f"尝试使用 OAuth Refresh Token 刷新账号 {account.email}")
+ result = self.refresh_by_oauth_token(
+ refresh_token=account.refresh_token,
+ client_id=account.client_id
+ )
+ return result
+
+ # 无可用刷新方式
+ return TokenRefreshResult(
+ success=False,
+ error_message="账号没有可用的刷新方式(缺少 session_token 和 refresh_token)"
+ )
+
+ def validate_token(self, access_token: str) -> Tuple[bool, Optional[str]]:
+ """
+ 验证 Access Token 是否有效
+
+ Args:
+ access_token: 访问令牌
+
+ Returns:
+ Tuple[bool, Optional[str]]: (是否有效, 错误信息)
+ """
+ try:
+ session = self._create_session()
+
+ # 调用 OpenAI API 验证 token
+ response = session.get(
+ "https://chatgpt.com/backend-api/me",
+ headers={
+ "authorization": f"Bearer {access_token}",
+ "accept": "application/json"
+ },
+ timeout=30
+ )
+
+ if response.status_code == 200:
+ return True, None
+ elif response.status_code == 401:
+ return False, "Token 无效或已过期"
+ elif response.status_code == 403:
+ return False, "账号可能被封禁"
+ else:
+ return False, f"验证失败: HTTP {response.status_code}"
+
+ except Exception as e:
+ return False, f"验证异常: {str(e)}"
+
+
+def refresh_account_token(account_id: int, proxy_url: Optional[str] = None) -> TokenRefreshResult:
+ """
+ 刷新指定账号的 Token 并更新数据库
+
+ Args:
+ account_id: 账号 ID
+ proxy_url: 代理 URL
+
+ Returns:
+ TokenRefreshResult: 刷新结果
+ """
+ with get_db() as db:
+ account = crud.get_account_by_id(db, account_id)
+ if not account:
+ return TokenRefreshResult(success=False, error_message="账号不存在")
+
+ manager = TokenRefreshManager(proxy_url=proxy_url)
+ result = manager.refresh_account(account)
+
+ if result.success:
+ # 更新数据库
+ update_data = {
+ "access_token": result.access_token,
+ "last_refresh": datetime.utcnow()
+ }
+
+ if result.refresh_token:
+ update_data["refresh_token"] = result.refresh_token
+
+ if result.expires_at:
+ update_data["expires_at"] = result.expires_at
+
+ crud.update_account(db, account_id, **update_data)
+
+ return result
+
+
+def validate_account_token(account_id: int, proxy_url: Optional[str] = None) -> Tuple[bool, Optional[str]]:
+ """
+ 验证指定账号的 Token 是否有效
+
+ Args:
+ account_id: 账号 ID
+ proxy_url: 代理 URL
+
+ Returns:
+ Tuple[bool, Optional[str]]: (是否有效, 错误信息)
+ """
+ with get_db() as db:
+ account = crud.get_account_by_id(db, account_id)
+ if not account:
+ return False, "账号不存在"
+
+ if not account.access_token:
+ return False, "账号没有 access_token"
+
+ manager = TokenRefreshManager(proxy_url=proxy_url)
+ return manager.validate_token(account.access_token)
diff --git a/src/core/playwright_pool.py b/src/core/playwright_pool.py
new file mode 100644
index 0000000..0bd2083
--- /dev/null
+++ b/src/core/playwright_pool.py
@@ -0,0 +1,213 @@
+"""
+Playwright 浏览器上下文池
+管理共享的 Browser 实例和可复用的 BrowserContext 队列
+"""
+
+import logging
+import threading
+import queue
+from typing import Optional
+
+logger = logging.getLogger(__name__)
+
+
+class PlaywrightWorkerPool:
+ """
+ Playwright Worker 池(单例)
+
+ 架构:
+ - 一个共享的 Chromium Browser 实例(通过 CDP 连接)
+ - 多个 BrowserContext(轻量,独立 cookie/session/代理)
+ - 线程安全的 acquire/release 接口
+ """
+
+ _instance = None
+ _lock = threading.Lock()
+
+ def __new__(cls):
+ with cls._lock:
+ if cls._instance is None:
+ cls._instance = super().__new__(cls)
+ cls._instance._initialized = False
+ return cls._instance
+
+ def __init__(self):
+ if self._initialized:
+ return
+ self._initialized = True
+ self._browser = None
+ self._playwright = None
+ self._cdp_endpoint = None
+ self._pool_size = 5
+ self._available = queue.Queue()
+ self._busy_count = 0
+ self._total_created = 0
+ self._busy_lock = threading.Lock()
+ self._started = False
+
+ def initialize(self, pool_size: int = 5) -> bool:
+ """
+ 启动 Browser 实例(在主线程调用一次)
+
+ Args:
+ pool_size: Context 池大小
+
+ Returns:
+ 是否成功
+ """
+ if self._started:
+ logger.info("[PlaywrightPool] 已初始化,跳过")
+ return True
+
+ self._pool_size = pool_size
+
+ try:
+ from playwright.sync_api import sync_playwright
+
+ self._playwright = sync_playwright().start()
+ import os
+ chrome_path = os.environ.get("PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH")
+ launch_kwargs = {
+ "headless": True,
+ "args": [
+ "--no-sandbox",
+ "--disable-setuid-sandbox",
+ "--disable-dev-shm-usage",
+ "--disable-gpu",
+ "--disable-blink-features=AutomationControlled",
+ ],
+ }
+ if chrome_path:
+ launch_kwargs["executable_path"] = chrome_path
+ self._browser = self._playwright.chromium.launch(**launch_kwargs)
+
+ self._started = True
+ logger.info(f"[PlaywrightPool] Browser 已启动,池大小: {pool_size}")
+ return True
+
+ except Exception as e:
+ logger.error(f"[PlaywrightPool] 启动失败: {e}")
+ return False
+
+ def acquire(self, proxy_url: Optional[str] = None) -> Optional[object]:
+ """
+ 获取一个 BrowserContext(线程安全)
+
+ 每次创建新的 Context(确保干净的 cookie/session),
+ 任务完成后由 release 关闭。
+
+ Args:
+ proxy_url: 可选代理,格式如 socks5://host:port 或 http://user:pass@host:port
+
+ Returns:
+ BrowserContext 或 None
+ """
+ if not self._started or not self._browser:
+ logger.error("[PlaywrightPool] 未初始化")
+ return None
+
+ try:
+ # 构建 Context 参数
+ context_args = {
+ "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
+ "viewport": {"width": 1280, "height": 720},
+ "locale": "en-US",
+ "timezone_id": "America/New_York",
+ "ignore_https_errors": True,
+ }
+
+ # 配置代理
+ if proxy_url:
+ proxy_config = self._parse_proxy(proxy_url)
+ if proxy_config:
+ context_args["proxy"] = proxy_config
+
+ context = self._browser.new_context(**context_args)
+
+ # 注入反检测脚本
+ context.add_init_script("""
+ Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
+ Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
+ Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
+ window.chrome = { runtime: {} };
+ """)
+
+ with self._busy_lock:
+ self._busy_count += 1
+ self._total_created += 1
+
+ logger.debug(f"[PlaywrightPool] Context 已创建 (活跃: {self._busy_count})")
+ return context
+
+ except Exception as e:
+ logger.error(f"[PlaywrightPool] 创建 Context 失败: {e}")
+ return None
+
+ def release(self, context) -> None:
+ """
+ 释放 BrowserContext(关闭并销毁)
+
+ Args:
+ context: 要释放的 BrowserContext
+ """
+ if context is None:
+ return
+
+ try:
+ context.close()
+ except Exception as e:
+ logger.warning(f"[PlaywrightPool] 关闭 Context 异常: {e}")
+ finally:
+ with self._busy_lock:
+ self._busy_count = max(0, self._busy_count - 1)
+ logger.debug(f"[PlaywrightPool] Context 已释放 (活跃: {self._busy_count})")
+
+ def shutdown(self) -> None:
+ """关闭 Browser 和 Playwright"""
+ if not self._started:
+ return
+
+ try:
+ if self._browser:
+ self._browser.close()
+ self._browser = None
+ if self._playwright:
+ self._playwright.stop()
+ self._playwright = None
+ self._started = False
+ logger.info("[PlaywrightPool] 已关闭")
+ except Exception as e:
+ logger.error(f"[PlaywrightPool] 关闭异常: {e}")
+
+ @property
+ def stats(self) -> dict:
+ """池状态"""
+ return {
+ "started": self._started,
+ "pool_size": self._pool_size,
+ "busy": self._busy_count,
+ "total_created": self._total_created,
+ }
+
+ @property
+ def is_started(self) -> bool:
+ return self._started
+
+ @staticmethod
+ def _parse_proxy(proxy_url: str) -> Optional[dict]:
+ """解析代理 URL 为 Playwright 格式"""
+ try:
+ from urllib.parse import urlparse
+ parsed = urlparse(proxy_url)
+ proxy = {"server": f"{parsed.scheme}://{parsed.hostname}:{parsed.port}"}
+ if parsed.username:
+ proxy["username"] = parsed.username
+ if parsed.password:
+ proxy["password"] = parsed.password
+ return proxy
+ except Exception:
+ return None
+
+
+# 全局单例
+playwright_pool = PlaywrightWorkerPool()
diff --git a/src/core/register.py b/src/core/register.py
new file mode 100644
index 0000000..41549a0
--- /dev/null
+++ b/src/core/register.py
@@ -0,0 +1,883 @@
+"""
+注册流程引擎
+从 main.py 中提取并重构的注册流程
+"""
+
+import re
+import json
+import time
+import logging
+import secrets
+import string
+from typing import Optional, Dict, Any, Tuple, Callable
+from dataclasses import dataclass
+from datetime import datetime
+
+from curl_cffi import requests as cffi_requests
+
+from .openai.oauth import OAuthManager, OAuthStart
+from .http_client import OpenAIHTTPClient, HTTPClientError
+from ..services import EmailServiceFactory, BaseEmailService, EmailServiceType
+from ..database import crud
+from ..database.session import get_db
+from ..config.constants import (
+ OPENAI_API_ENDPOINTS,
+ OPENAI_PAGE_TYPES,
+ generate_random_user_info,
+ OTP_CODE_PATTERN,
+ DEFAULT_PASSWORD_LENGTH,
+ PASSWORD_CHARSET,
+ AccountStatus,
+ TaskStatus,
+)
+from ..config.settings import get_settings
+
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class RegistrationResult:
+ """注册结果"""
+ success: bool
+ email: str = ""
+ password: str = "" # 注册密码
+ account_id: str = ""
+ workspace_id: str = ""
+ access_token: str = ""
+ refresh_token: str = ""
+ id_token: str = ""
+ session_token: str = "" # 会话令牌
+ error_message: str = ""
+ logs: list = None
+ metadata: dict = None
+ source: str = "register" # 'register' 或 'login',区分账号来源
+
+ def to_dict(self) -> Dict[str, Any]:
+ """转换为字典"""
+ return {
+ "success": self.success,
+ "email": self.email,
+ "password": self.password,
+ "account_id": self.account_id,
+ "workspace_id": self.workspace_id,
+ "access_token": self.access_token[:20] + "..." if self.access_token else "",
+ "refresh_token": self.refresh_token[:20] + "..." if self.refresh_token else "",
+ "id_token": self.id_token[:20] + "..." if self.id_token else "",
+ "session_token": self.session_token[:20] + "..." if self.session_token else "",
+ "error_message": self.error_message,
+ "logs": self.logs or [],
+ "metadata": self.metadata or {},
+ "source": self.source,
+ }
+
+
+@dataclass
+class SignupFormResult:
+ """提交注册表单的结果"""
+ success: bool
+ page_type: str = "" # 响应中的 page.type 字段
+ is_existing_account: bool = False # 是否为已注册账号
+ response_data: Dict[str, Any] = None # 完整的响应数据
+ error_message: str = ""
+
+
+class RegistrationEngine:
+ """
+ 注册引擎
+ 负责协调邮箱服务、OAuth 流程和 OpenAI API 调用
+ """
+
+ def __init__(
+ self,
+ email_service: BaseEmailService,
+ proxy_url: Optional[str] = None,
+ callback_logger: Optional[Callable[[str], None]] = None,
+ task_uuid: Optional[str] = None
+ ):
+ """
+ 初始化注册引擎
+
+ Args:
+ email_service: 邮箱服务实例
+ proxy_url: 代理 URL
+ callback_logger: 日志回调函数
+ task_uuid: 任务 UUID(用于数据库记录)
+ """
+ self.email_service = email_service
+ self.proxy_url = proxy_url
+ self.callback_logger = callback_logger or (lambda msg: logger.info(msg))
+ self.task_uuid = task_uuid
+
+ # 创建 HTTP 客户端
+ self.http_client = OpenAIHTTPClient(proxy_url=proxy_url)
+
+ # 创建 OAuth 管理器
+ settings = get_settings()
+ self.oauth_manager = OAuthManager(
+ client_id=settings.openai_client_id,
+ auth_url=settings.openai_auth_url,
+ token_url=settings.openai_token_url,
+ redirect_uri=settings.openai_redirect_uri,
+ scope=settings.openai_scope,
+ proxy_url=proxy_url # 传递代理配置
+ )
+
+ # 状态变量
+ self.email: Optional[str] = None
+ self.password: Optional[str] = None # 注册密码
+ self.email_info: Optional[Dict[str, Any]] = None
+ self.oauth_start: Optional[OAuthStart] = None
+ self.session: Optional[cffi_requests.Session] = None
+ self.session_token: Optional[str] = None # 会话令牌
+ self.logs: list = []
+ self._otp_sent_at: Optional[float] = None # OTP 发送时间戳
+ self._is_existing_account: bool = False # 是否为已注册账号(用于自动登录)
+
+ def _log(self, message: str, level: str = "info"):
+ """记录日志"""
+ timestamp = datetime.now().strftime("%H:%M:%S")
+ log_message = f"[{timestamp}] {message}"
+
+ # 添加到日志列表
+ self.logs.append(log_message)
+
+ # 调用回调函数
+ if self.callback_logger:
+ self.callback_logger(log_message)
+
+ # 记录到数据库(如果有关联任务)
+ if self.task_uuid:
+ try:
+ with get_db() as db:
+ crud.append_task_log(db, self.task_uuid, log_message)
+ except Exception as e:
+ logger.warning(f"记录任务日志失败: {e}")
+
+ # 根据级别记录到日志系统
+ if level == "error":
+ logger.error(message)
+ elif level == "warning":
+ logger.warning(message)
+ else:
+ logger.info(message)
+
+ def _generate_password(self, length: int = DEFAULT_PASSWORD_LENGTH) -> str:
+ """生成随机密码"""
+ return ''.join(secrets.choice(PASSWORD_CHARSET) for _ in range(length))
+
+ def _check_ip_location(self) -> Tuple[bool, Optional[str]]:
+ """检查 IP 地理位置"""
+ try:
+ return self.http_client.check_ip_location()
+ except Exception as e:
+ self._log(f"检查 IP 地理位置失败: {e}", "error")
+ return False, None
+
+ def _create_email(self) -> bool:
+ """创建邮箱"""
+ try:
+ self._log(f"正在创建 {self.email_service.service_type.value} 邮箱...")
+ self.email_info = self.email_service.create_email()
+
+ if not self.email_info or "email" not in self.email_info:
+ self._log("创建邮箱失败: 返回信息不完整", "error")
+ return False
+
+ self.email = self.email_info["email"]
+ self._log(f"成功创建邮箱: {self.email}")
+ return True
+
+ except Exception as e:
+ self._log(f"创建邮箱失败: {e}", "error")
+ return False
+
+ def _start_oauth(self) -> bool:
+ """开始 OAuth 流程"""
+ try:
+ self._log("开始 OAuth 授权流程...")
+ self.oauth_start = self.oauth_manager.start_oauth()
+ self._log(f"OAuth URL 已生成: {self.oauth_start.auth_url[:80]}...")
+ return True
+ except Exception as e:
+ self._log(f"生成 OAuth URL 失败: {e}", "error")
+ return False
+
+ def _init_session(self) -> bool:
+ """初始化会话"""
+ try:
+ self.session = self.http_client.session
+ return True
+ except Exception as e:
+ self._log(f"初始化会话失败: {e}", "error")
+ return False
+
+ def _get_device_id(self) -> Optional[str]:
+ """获取 Device ID"""
+ if not self.oauth_start:
+ return None
+
+ max_attempts = 3
+ for attempt in range(1, max_attempts + 1):
+ try:
+ if not self.session:
+ self.session = self.http_client.session
+
+ response = self.session.get(
+ self.oauth_start.auth_url,
+ timeout=20
+ )
+ did = self.session.cookies.get("oai-did")
+
+ if did:
+ self._log(f"Device ID: {did}")
+ return did
+
+ self._log(
+ f"获取 Device ID 失败: 未返回 oai-did Cookie (HTTP {response.status_code}, 第 {attempt}/{max_attempts} 次)",
+ "warning" if attempt < max_attempts else "error"
+ )
+ except Exception as e:
+ self._log(
+ f"获取 Device ID 失败: {e} (第 {attempt}/{max_attempts} 次)",
+ "warning" if attempt < max_attempts else "error"
+ )
+
+ if attempt < max_attempts:
+ time.sleep(attempt)
+ self.http_client.close()
+ self.session = self.http_client.session
+
+ return None
+
+ def _check_sentinel(self, did: str) -> Optional[str]:
+ """检查 Sentinel 拦截"""
+ try:
+ sen_req_body = f'{{"p":"","id":"{did}","flow":"authorize_continue"}}'
+
+ response = self.http_client.post(
+ OPENAI_API_ENDPOINTS["sentinel"],
+ headers={
+ "origin": "https://sentinel.openai.com",
+ "referer": "https://sentinel.openai.com/backend-api/sentinel/frame.html?sv=20260219f9f6",
+ "content-type": "text/plain;charset=UTF-8",
+ },
+ data=sen_req_body,
+ )
+
+ if response.status_code == 200:
+ sen_token = response.json().get("token")
+ self._log(f"Sentinel token 获取成功")
+ return sen_token
+ else:
+ self._log(f"Sentinel 检查失败: {response.status_code}", "warning")
+ return None
+
+ except Exception as e:
+ self._log(f"Sentinel 检查异常: {e}", "warning")
+ return None
+
+ def _submit_signup_form(self, did: str, sen_token: Optional[str]) -> SignupFormResult:
+ """
+ 提交注册表单
+
+ Returns:
+ SignupFormResult: 提交结果,包含账号状态判断
+ """
+ try:
+ signup_body = f'{{"username":{{"value":"{self.email}","kind":"email"}},"screen_hint":"signup"}}'
+
+ headers = {
+ "referer": "https://auth.openai.com/create-account",
+ "accept": "application/json",
+ "content-type": "application/json",
+ }
+
+ if sen_token:
+ sentinel = f'{{"p": "", "t": "", "c": "{sen_token}", "id": "{did}", "flow": "authorize_continue"}}'
+ headers["openai-sentinel-token"] = sentinel
+
+ response = self.session.post(
+ OPENAI_API_ENDPOINTS["signup"],
+ headers=headers,
+ data=signup_body,
+ )
+
+ self._log(f"提交注册表单状态: {response.status_code}")
+
+ if response.status_code != 200:
+ return SignupFormResult(
+ success=False,
+ error_message=f"HTTP {response.status_code}: {response.text[:200]}"
+ )
+
+ # 解析响应判断账号状态
+ try:
+ response_data = response.json()
+ page_type = response_data.get("page", {}).get("type", "")
+ self._log(f"响应页面类型: {page_type}")
+
+ # 判断是否为已注册账号
+ is_existing = page_type == OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]
+
+ if is_existing:
+ self._log(f"检测到已注册账号,将自动切换到登录流程")
+ self._is_existing_account = True
+
+ return SignupFormResult(
+ success=True,
+ page_type=page_type,
+ is_existing_account=is_existing,
+ response_data=response_data
+ )
+
+ except Exception as parse_error:
+ self._log(f"解析响应失败: {parse_error}", "warning")
+ # 无法解析,默认成功
+ return SignupFormResult(success=True)
+
+ except Exception as e:
+ self._log(f"提交注册表单失败: {e}", "error")
+ return SignupFormResult(success=False, error_message=str(e))
+
+ def _register_password(self) -> Tuple[bool, Optional[str]]:
+ """注册密码"""
+ try:
+ # 生成密码
+ password = self._generate_password()
+ self.password = password # 保存密码到实例变量
+ self._log(f"生成密码: {password}")
+
+ # 提交密码注册
+ register_body = json.dumps({
+ "password": password,
+ "username": self.email
+ })
+
+ response = self.session.post(
+ OPENAI_API_ENDPOINTS["register"],
+ headers={
+ "referer": "https://auth.openai.com/create-account/password",
+ "accept": "application/json",
+ "content-type": "application/json",
+ },
+ data=register_body,
+ )
+
+ self._log(f"提交密码状态: {response.status_code}")
+
+ if response.status_code != 200:
+ error_text = response.text[:500]
+ self._log(f"密码注册失败: {error_text}", "warning")
+
+ # 解析错误信息,判断是否是邮箱已注册
+ try:
+ error_json = response.json()
+ error_msg = error_json.get("error", {}).get("message", "")
+ error_code = error_json.get("error", {}).get("code", "")
+
+ # 检测邮箱已注册的情况
+ if "already" in error_msg.lower() or "exists" in error_msg.lower() or error_code == "user_exists":
+ self._log(f"邮箱 {self.email} 可能已在 OpenAI 注册过", "error")
+ # 标记此邮箱为已注册状态
+ self._mark_email_as_registered()
+ except Exception:
+ pass
+
+ return False, None
+
+ return True, password
+
+ except Exception as e:
+ self._log(f"密码注册失败: {e}", "error")
+ return False, None
+
+ def _mark_email_as_registered(self):
+ """标记邮箱为已注册状态(用于防止重复尝试)"""
+ try:
+ with get_db() as db:
+ # 检查是否已存在该邮箱的记录
+ existing = crud.get_account_by_email(db, self.email)
+ if not existing:
+ # 创建一个失败记录,标记该邮箱已注册过
+ crud.create_account(
+ db,
+ email=self.email,
+ password="", # 空密码表示未成功注册
+ email_service=self.email_service.service_type.value,
+ email_service_id=self.email_info.get("service_id") if self.email_info else None,
+ status="failed",
+ extra_data={"register_failed_reason": "email_already_registered_on_openai"}
+ )
+ self._log(f"已在数据库中标记邮箱 {self.email} 为已注册状态")
+ except Exception as e:
+ logger.warning(f"标记邮箱状态失败: {e}")
+
+ def _send_verification_code(self) -> bool:
+ """发送验证码"""
+ try:
+ # 记录发送时间戳
+ self._otp_sent_at = time.time()
+
+ response = self.session.get(
+ OPENAI_API_ENDPOINTS["send_otp"],
+ headers={
+ "referer": "https://auth.openai.com/create-account/password",
+ "accept": "application/json",
+ },
+ )
+
+ self._log(f"验证码发送状态: {response.status_code}")
+ return response.status_code == 200
+
+ except Exception as e:
+ self._log(f"发送验证码失败: {e}", "error")
+ return False
+
+ def _get_verification_code(self) -> Optional[str]:
+ """获取验证码"""
+ try:
+ self._log(f"正在等待邮箱 {self.email} 的验证码...")
+
+ email_id = self.email_info.get("service_id") if self.email_info else None
+ code = self.email_service.get_verification_code(
+ email=self.email,
+ email_id=email_id,
+ timeout=120,
+ pattern=OTP_CODE_PATTERN,
+ otp_sent_at=self._otp_sent_at,
+ )
+
+ if code:
+ self._log(f"成功获取验证码: {code}")
+ return code
+ else:
+ self._log("等待验证码超时", "error")
+ return None
+
+ except Exception as e:
+ self._log(f"获取验证码失败: {e}", "error")
+ return None
+
+ def _validate_verification_code(self, code: str) -> bool:
+ """验证验证码"""
+ try:
+ code_body = f'{{"code":"{code}"}}'
+
+ response = self.session.post(
+ OPENAI_API_ENDPOINTS["validate_otp"],
+ headers={
+ "referer": "https://auth.openai.com/email-verification",
+ "accept": "application/json",
+ "content-type": "application/json",
+ },
+ data=code_body,
+ )
+
+ self._log(f"验证码校验状态: {response.status_code}")
+ return response.status_code == 200
+
+ except Exception as e:
+ self._log(f"验证验证码失败: {e}", "error")
+ return False
+
+ def _create_user_account(self) -> bool:
+ """创建用户账户"""
+ try:
+ user_info = generate_random_user_info()
+ self._log(f"生成用户信息: {user_info['name']}, 生日: {user_info['birthdate']}")
+ create_account_body = json.dumps(user_info)
+
+ response = self.session.post(
+ OPENAI_API_ENDPOINTS["create_account"],
+ headers={
+ "referer": "https://auth.openai.com/about-you",
+ "accept": "application/json",
+ "content-type": "application/json",
+ },
+ data=create_account_body,
+ )
+
+ self._log(f"账户创建状态: {response.status_code}")
+
+ if response.status_code != 200:
+ self._log(f"账户创建失败: {response.text[:200]}", "warning")
+ return False
+
+ return True
+
+ except Exception as e:
+ self._log(f"创建账户失败: {e}", "error")
+ return False
+
+ def _get_workspace_id(self) -> Optional[str]:
+ """获取 Workspace ID"""
+ try:
+ auth_cookie = self.session.cookies.get("oai-client-auth-session")
+ if not auth_cookie:
+ self._log("未能获取到授权 Cookie", "error")
+ return None
+
+ # 解码 JWT
+ import base64
+ import json as json_module
+
+ try:
+ segments = auth_cookie.split(".")
+ if len(segments) < 1:
+ self._log("授权 Cookie 格式错误", "error")
+ return None
+
+ # 解码第一个 segment
+ payload = segments[0]
+ pad = "=" * ((4 - (len(payload) % 4)) % 4)
+ decoded = base64.urlsafe_b64decode((payload + pad).encode("ascii"))
+ auth_json = json_module.loads(decoded.decode("utf-8"))
+
+ workspaces = auth_json.get("workspaces") or []
+ if not workspaces:
+ self._log("授权 Cookie 里没有 workspace 信息", "error")
+ return None
+
+ workspace_id = str((workspaces[0] or {}).get("id") or "").strip()
+ if not workspace_id:
+ self._log("无法解析 workspace_id", "error")
+ return None
+
+ self._log(f"Workspace ID: {workspace_id}")
+ return workspace_id
+
+ except Exception as e:
+ self._log(f"解析授权 Cookie 失败: {e}", "error")
+ return None
+
+ except Exception as e:
+ self._log(f"获取 Workspace ID 失败: {e}", "error")
+ return None
+
+ def _select_workspace(self, workspace_id: str) -> Optional[str]:
+ """选择 Workspace"""
+ try:
+ select_body = f'{{"workspace_id":"{workspace_id}"}}'
+
+ response = self.session.post(
+ OPENAI_API_ENDPOINTS["select_workspace"],
+ headers={
+ "referer": "https://auth.openai.com/sign-in-with-chatgpt/codex/consent",
+ "content-type": "application/json",
+ },
+ data=select_body,
+ )
+
+ if response.status_code != 200:
+ self._log(f"选择 workspace 失败: {response.status_code}", "error")
+ self._log(f"响应: {response.text[:200]}", "warning")
+ return None
+
+ continue_url = str((response.json() or {}).get("continue_url") or "").strip()
+ if not continue_url:
+ self._log("workspace/select 响应里缺少 continue_url", "error")
+ return None
+
+ self._log(f"Continue URL: {continue_url[:100]}...")
+ return continue_url
+
+ except Exception as e:
+ self._log(f"选择 Workspace 失败: {e}", "error")
+ return None
+
+ def _follow_redirects(self, start_url: str) -> Optional[str]:
+ """跟随重定向链,寻找回调 URL"""
+ try:
+ current_url = start_url
+ max_redirects = 6
+
+ for i in range(max_redirects):
+ self._log(f"重定向 {i+1}/{max_redirects}: {current_url[:100]}...")
+
+ response = self.session.get(
+ current_url,
+ allow_redirects=False,
+ timeout=15
+ )
+
+ location = response.headers.get("Location") or ""
+
+ # 如果不是重定向状态码,停止
+ if response.status_code not in [301, 302, 303, 307, 308]:
+ self._log(f"非重定向状态码: {response.status_code}")
+ break
+
+ if not location:
+ self._log("重定向响应缺少 Location 头")
+ break
+
+ # 构建下一个 URL
+ import urllib.parse
+ next_url = urllib.parse.urljoin(current_url, location)
+
+ # 检查是否包含回调参数
+ if "code=" in next_url and "state=" in next_url:
+ self._log(f"找到回调 URL: {next_url[:100]}...")
+ return next_url
+
+ current_url = next_url
+
+ self._log("未能在重定向链中找到回调 URL", "error")
+ return None
+
+ except Exception as e:
+ self._log(f"跟随重定向失败: {e}", "error")
+ return None
+
+ def _handle_oauth_callback(self, callback_url: str) -> Optional[Dict[str, Any]]:
+ """处理 OAuth 回调"""
+ try:
+ if not self.oauth_start:
+ self._log("OAuth 流程未初始化", "error")
+ return None
+
+ self._log("处理 OAuth 回调...")
+ token_info = self.oauth_manager.handle_callback(
+ callback_url=callback_url,
+ expected_state=self.oauth_start.state,
+ code_verifier=self.oauth_start.code_verifier
+ )
+
+ self._log("OAuth 授权成功")
+ return token_info
+
+ except Exception as e:
+ self._log(f"处理 OAuth 回调失败: {e}", "error")
+ return None
+
+ def run(self) -> RegistrationResult:
+ """
+ 执行完整的注册流程
+
+ 支持已注册账号自动登录:
+ - 如果检测到邮箱已注册,自动切换到登录流程
+ - 已注册账号跳过:设置密码、发送验证码、创建用户账户
+ - 共用步骤:获取验证码、验证验证码、Workspace 和 OAuth 回调
+
+ Returns:
+ RegistrationResult: 注册结果
+ """
+ result = RegistrationResult(success=False, logs=self.logs)
+
+ try:
+ self._log("=" * 60)
+ self._log("开始注册流程")
+ self._log("=" * 60)
+
+ # 1. 检查 IP 地理位置
+ self._log("1. 检查 IP 地理位置...")
+ ip_ok, location = self._check_ip_location()
+ if not ip_ok:
+ result.error_message = f"IP 地理位置不支持: {location}"
+ self._log(f"IP 检查失败: {location}", "error")
+ return result
+
+ self._log(f"IP 位置: {location}")
+
+ # 2. 创建邮箱
+ self._log("2. 创建邮箱...")
+ if not self._create_email():
+ result.error_message = "创建邮箱失败"
+ return result
+
+ result.email = self.email
+
+ # 3. 初始化会话
+ self._log("3. 初始化会话...")
+ if not self._init_session():
+ result.error_message = "初始化会话失败"
+ return result
+
+ # 4. 开始 OAuth 流程
+ self._log("4. 开始 OAuth 授权流程...")
+ if not self._start_oauth():
+ result.error_message = "开始 OAuth 流程失败"
+ return result
+
+ # 5. 获取 Device ID
+ self._log("5. 获取 Device ID...")
+ did = self._get_device_id()
+ if not did:
+ result.error_message = "获取 Device ID 失败"
+ return result
+
+ # 6. 检查 Sentinel 拦截
+ self._log("6. 检查 Sentinel 拦截...")
+ sen_token = self._check_sentinel(did)
+ if sen_token:
+ self._log("Sentinel 检查通过")
+ else:
+ self._log("Sentinel 检查失败或未启用", "warning")
+
+ # 7. 提交注册表单 + 解析响应判断账号状态
+ self._log("7. 提交注册表单...")
+ signup_result = self._submit_signup_form(did, sen_token)
+ if not signup_result.success:
+ result.error_message = f"提交注册表单失败: {signup_result.error_message}"
+ return result
+
+ # 8. [已注册账号跳过] 注册密码
+ if self._is_existing_account:
+ self._log("8. [已注册账号] 跳过密码设置,OTP 已自动发送")
+ else:
+ self._log("8. 注册密码...")
+ password_ok, password = self._register_password()
+ if not password_ok:
+ result.error_message = "注册密码失败"
+ return result
+
+ # 9. [已注册账号跳过] 发送验证码
+ if self._is_existing_account:
+ self._log("9. [已注册账号] 跳过发送验证码,使用自动发送的 OTP")
+ # 已注册账号的 OTP 在提交表单时已自动发送,记录时间戳
+ self._otp_sent_at = time.time()
+ else:
+ self._log("9. 发送验证码...")
+ if not self._send_verification_code():
+ result.error_message = "发送验证码失败"
+ return result
+
+ # 10. 获取验证码
+ self._log("10. 等待验证码...")
+ code = self._get_verification_code()
+ if not code:
+ result.error_message = "获取验证码失败"
+ return result
+
+ # 11. 验证验证码
+ self._log("11. 验证验证码...")
+ if not self._validate_verification_code(code):
+ result.error_message = "验证验证码失败"
+ return result
+
+ # 12. [已注册账号跳过] 创建用户账户
+ if self._is_existing_account:
+ self._log("12. [已注册账号] 跳过创建用户账户")
+ else:
+ self._log("12. 创建用户账户...")
+ if not self._create_user_account():
+ result.error_message = "创建用户账户失败"
+ return result
+
+ # 13. 获取 Workspace ID
+ self._log("13. 获取 Workspace ID...")
+ workspace_id = self._get_workspace_id()
+ if not workspace_id:
+ result.error_message = "获取 Workspace ID 失败"
+ return result
+
+ result.workspace_id = workspace_id
+
+ # 14. 选择 Workspace
+ self._log("14. 选择 Workspace...")
+ continue_url = self._select_workspace(workspace_id)
+ if not continue_url:
+ result.error_message = "选择 Workspace 失败"
+ return result
+
+ # 15. 跟随重定向链
+ self._log("15. 跟随重定向链...")
+ callback_url = self._follow_redirects(continue_url)
+ if not callback_url:
+ result.error_message = "跟随重定向链失败"
+ return result
+
+ # 16. 处理 OAuth 回调
+ self._log("16. 处理 OAuth 回调...")
+ token_info = self._handle_oauth_callback(callback_url)
+ if not token_info:
+ result.error_message = "处理 OAuth 回调失败"
+ return result
+
+ # 提取账户信息
+ result.account_id = token_info.get("account_id", "")
+ result.access_token = token_info.get("access_token", "")
+ result.refresh_token = token_info.get("refresh_token", "")
+ result.id_token = token_info.get("id_token", "")
+ result.password = self.password or "" # 保存密码(已注册账号为空)
+
+ # 设置来源标记
+ result.source = "login" if self._is_existing_account else "register"
+
+ # 尝试获取 session_token 从 cookie
+ session_cookie = self.session.cookies.get("__Secure-next-auth.session-token")
+ if session_cookie:
+ self.session_token = session_cookie
+ result.session_token = session_cookie
+ self._log(f"获取到 Session Token")
+
+ # 17. 完成
+ self._log("=" * 60)
+ if self._is_existing_account:
+ self._log("登录成功! (已注册账号)")
+ else:
+ self._log("注册成功!")
+ self._log(f"邮箱: {result.email}")
+ self._log(f"Account ID: {result.account_id}")
+ self._log(f"Workspace ID: {result.workspace_id}")
+ self._log("=" * 60)
+
+ result.success = True
+ result.metadata = {
+ "email_service": self.email_service.service_type.value,
+ "proxy_used": self.proxy_url,
+ "registered_at": datetime.now().isoformat(),
+ "is_existing_account": self._is_existing_account,
+ }
+
+ return result
+
+ except Exception as e:
+ self._log(f"注册过程中发生未预期错误: {e}", "error")
+ result.error_message = str(e)
+ return result
+
+ def save_to_database(self, result: RegistrationResult) -> bool:
+ """
+ 保存注册结果到数据库
+
+ Args:
+ result: 注册结果
+
+ Returns:
+ 是否保存成功
+ """
+ if not result.success:
+ return False
+
+ try:
+ # 获取默认 client_id
+ settings = get_settings()
+
+ with get_db() as db:
+ # 保存账户信息
+ account = crud.create_account(
+ db,
+ email=result.email,
+ password=result.password,
+ client_id=settings.openai_client_id,
+ session_token=result.session_token,
+ email_service=self.email_service.service_type.value,
+ email_service_id=self.email_info.get("service_id") if self.email_info else None,
+ account_id=result.account_id,
+ workspace_id=result.workspace_id,
+ access_token=result.access_token,
+ refresh_token=result.refresh_token,
+ id_token=result.id_token,
+ proxy_used=self.proxy_url,
+ extra_data=result.metadata,
+ source=result.source
+ )
+
+ self._log(f"账户已保存到数据库,ID: {account.id}")
+ return True
+
+ except Exception as e:
+ self._log(f"保存到数据库失败: {e}", "error")
+ return False
diff --git a/src/core/register_playwright.py b/src/core/register_playwright.py
new file mode 100644
index 0000000..cace904
--- /dev/null
+++ b/src/core/register_playwright.py
@@ -0,0 +1,767 @@
+"""
+Playwright 注册引擎
+使用真实浏览器环境执行 OAuth 注册流程,解决 workspace Cookie 问题
+"""
+
+import re
+import json
+import time
+import base64
+import logging
+import secrets
+import string
+from typing import Optional, Dict, Any, Tuple, Callable
+from dataclasses import dataclass
+from datetime import datetime
+
+from .register import RegistrationResult, SignupFormResult
+from .openai.oauth import OAuthManager, OAuthStart
+from .playwright_pool import playwright_pool
+from ..services import BaseEmailService
+from ..database import crud
+from ..database.session import get_db
+from ..config.constants import (
+ OPENAI_API_ENDPOINTS,
+ OPENAI_PAGE_TYPES,
+ generate_random_user_info,
+ OTP_CODE_PATTERN,
+ DEFAULT_PASSWORD_LENGTH,
+ PASSWORD_CHARSET,
+)
+from ..config.settings import get_settings
+
+logger = logging.getLogger(__name__)
+
+
+class PlaywrightRegistrationEngine:
+ """
+ 基于 Playwright 的注册引擎
+ 使用真实浏览器页面导航执行 OAuth 流程
+ """
+
+ def __init__(
+ self,
+ email_service: BaseEmailService,
+ proxy_url: Optional[str] = None,
+ callback_logger: Optional[Callable[[str], None]] = None,
+ task_uuid: Optional[str] = None,
+ ):
+ self.email_service = email_service
+ self.proxy_url = proxy_url
+ self.callback_logger = callback_logger or (lambda msg: logger.info(msg))
+ self.task_uuid = task_uuid
+
+ # OAuth 管理器(PKCE/Token 交换逻辑复用)
+ settings = get_settings()
+ self.oauth_manager = OAuthManager(
+ client_id=settings.openai_client_id,
+ auth_url=settings.openai_auth_url,
+ token_url=settings.openai_token_url,
+ redirect_uri=settings.openai_redirect_uri,
+ scope=settings.openai_scope,
+ proxy_url=proxy_url,
+ )
+
+ # 状态变量
+ self.email: Optional[str] = None
+ self.password: Optional[str] = None
+ self.email_info: Optional[Dict[str, Any]] = None
+ self.oauth_start: Optional[OAuthStart] = None
+ self.context = None # Playwright BrowserContext
+ self.page = None # Playwright Page
+ self.logs: list = []
+ self._otp_sent_at: Optional[float] = None
+ self._is_existing_account: bool = False
+
+ def _log(self, message: str, level: str = "info"):
+ """记录日志"""
+ timestamp = datetime.now().strftime("%H:%M:%S")
+ log_message = f"[{timestamp}] [Playwright] {message}"
+ self.logs.append(log_message)
+ if self.callback_logger:
+ self.callback_logger(log_message)
+ if self.task_uuid:
+ try:
+ with get_db() as db:
+ crud.append_task_log(db, self.task_uuid, log_message)
+ except Exception:
+ pass
+ getattr(logger, level if level in ("error", "warning") else "info")(message)
+
+ def _generate_password(self, length: int = DEFAULT_PASSWORD_LENGTH) -> str:
+ return "".join(secrets.choice(PASSWORD_CHARSET) for _ in range(length))
+
+ # ──────────────────────── 步骤 1: IP 检查(HTTP) ────────────────────────
+
+ def _check_ip_location(self) -> Tuple[bool, Optional[str]]:
+ """通过浏览器检查 IP 地理位置"""
+ try:
+ if self.page:
+ resp = self.page.goto("https://cloudflare.com/cdn-cgi/trace", wait_until="domcontentloaded", timeout=15000)
+ text = self.page.inner_text("body")
+ loc = ""
+ for line in text.strip().split("\n"):
+ if line.startswith("loc="):
+ loc = line.split("=", 1)[1].strip()
+ break
+ if loc in ("CN", "HK", "MO", "TW"):
+ return False, loc
+ return True, loc
+ return True, "unknown"
+ except Exception as e:
+ self._log(f"IP 检查异常: {e}", "warning")
+ return True, "unknown"
+
+ # ──────────────────────── 步骤 2: 创建邮箱(HTTP,保留原逻辑) ─────────
+
+ def _create_email(self) -> bool:
+ try:
+ self._log(f"正在创建 {self.email_service.service_type.value} 邮箱...")
+ self.email_info = self.email_service.create_email()
+ if not self.email_info or "email" not in self.email_info:
+ self._log("创建邮箱失败: 返回信息不完整", "error")
+ return False
+ self.email = self.email_info["email"]
+ self._log(f"成功创建邮箱: {self.email}")
+ return True
+ except Exception as e:
+ self._log(f"创建邮箱失败: {e}", "error")
+ return False
+
+ # ──────────────────────── 步骤 3: 初始化浏览器上下文 ──────────────────────
+
+ def _init_browser_context(self) -> bool:
+ try:
+ self.context = playwright_pool.acquire(proxy_url=self.proxy_url)
+ if not self.context:
+ self._log("获取浏览器上下文失败", "error")
+ return False
+ self.page = self.context.new_page()
+ self.page.set_default_timeout(20000)
+ self._log("浏览器上下文已创建")
+ return True
+ except Exception as e:
+ self._log(f"初始化浏览器失败: {e}", "error")
+ return False
+
+ # ──────────────────────── 步骤 4: 开始 OAuth ─────────────────────────────
+
+ def _start_oauth(self) -> bool:
+ try:
+ self.oauth_start = self.oauth_manager.start_oauth()
+ self._log(f"OAuth URL 已生成: {self.oauth_start.auth_url[:80]}...")
+ return True
+ except Exception as e:
+ self._log(f"生成 OAuth URL 失败: {e}", "error")
+ return False
+
+ # ──────────────────────── 步骤 5: 获取 Device ID ─────────────────────────
+
+ def _get_device_id(self) -> Optional[str]:
+ if not self.oauth_start or not self.page:
+ return None
+
+ max_attempts = 3
+ for attempt in range(1, max_attempts + 1):
+ try:
+ self.page.goto(self.oauth_start.auth_url, wait_until="domcontentloaded", timeout=30000)
+ time.sleep(1)
+
+ # 从浏览器 Cookie 中提取 oai-did
+ cookies = self.context.cookies()
+ for c in cookies:
+ if c["name"] == "oai-did":
+ self._log(f"Device ID: {c['value']}")
+ return c["value"]
+
+ self._log(f"获取 Device ID 失败: 未返回 oai-did Cookie (第 {attempt}/{max_attempts} 次)", "warning")
+ except Exception as e:
+ self._log(f"获取 Device ID 异常: {e} (第 {attempt}/{max_attempts} 次)", "warning")
+
+ if attempt < max_attempts:
+ time.sleep(attempt)
+
+ return None
+
+ # ──────────────────────── 步骤 6: Sentinel 检查 ──────────────────────────
+
+ def _check_sentinel(self, did: str) -> Optional[str]:
+ try:
+ # 在浏览器内执行 fetch 请求
+ result = self.page.evaluate("""
+ async (args) => {
+ try {
+ const resp = await fetch(args.url, {
+ method: 'POST',
+ headers: {
+ 'origin': 'https://sentinel.openai.com',
+ 'content-type': 'text/plain;charset=UTF-8',
+ },
+ body: JSON.stringify({p: '', id: args.did, flow: 'authorize_continue'}),
+ });
+ if (resp.ok) {
+ const data = await resp.json();
+ return data.token || null;
+ }
+ return null;
+ } catch { return null; }
+ }
+ """, {"url": OPENAI_API_ENDPOINTS["sentinel"], "did": did})
+
+ if result:
+ self._log("Sentinel token 获取成功")
+ return result
+ else:
+ self._log("Sentinel 检查失败或未启用", "warning")
+ return None
+ except Exception as e:
+ self._log(f"Sentinel 检查异常: {e}", "warning")
+ return None
+
+ # ──────────────────────── 步骤 7: 提交注册表单 ───────────────────────────
+
+ def _submit_signup_form(self, did: str, sen_token: Optional[str]) -> SignupFormResult:
+ try:
+ # 构建请求头
+ headers = {
+ "accept": "application/json",
+ "content-type": "application/json",
+ }
+ if sen_token:
+ sentinel_val = json.dumps({"p": "", "t": "", "c": sen_token, "id": did, "flow": "authorize_continue"})
+ headers["openai-sentinel-token"] = sentinel_val
+
+ body = json.dumps({"username": {"value": self.email, "kind": "email"}, "screen_hint": "signup"})
+
+ # 在浏览器中执行 fetch
+ result = self.page.evaluate("""
+ async (args) => {
+ try {
+ const resp = await fetch(args.url, {
+ method: 'POST',
+ headers: args.headers,
+ body: args.body,
+ credentials: 'include',
+ });
+ const data = await resp.json();
+ return { status: resp.status, data: data };
+ } catch (e) {
+ return { status: 0, error: e.message };
+ }
+ }
+ """, {"url": OPENAI_API_ENDPOINTS["signup"], "headers": headers, "body": body})
+
+ status = result.get("status", 0)
+ self._log(f"提交注册表单状态: {status}")
+
+ if status != 200:
+ return SignupFormResult(
+ success=False,
+ error_message=f"HTTP {status}: {result.get('error', json.dumps(result.get('data', {}))[:200])}"
+ )
+
+ data = result.get("data", {})
+ page_type = data.get("page", {}).get("type", "")
+ self._log(f"响应页面类型: {page_type}")
+
+ is_existing = page_type == OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]
+ if is_existing:
+ self._log("检测到已注册账号,将自动切换到登录流程")
+ self._is_existing_account = True
+
+ return SignupFormResult(success=True, page_type=page_type, is_existing_account=is_existing, response_data=data)
+ except Exception as e:
+ self._log(f"提交注册表单失败: {e}", "error")
+ return SignupFormResult(success=False, error_message=str(e))
+
+ # ──────────────────────── 步骤 8: 注册密码 ──────────────────────────────
+
+ def _register_password(self) -> Tuple[bool, Optional[str]]:
+ try:
+ password = self._generate_password()
+ self.password = password
+ self._log(f"生成密码: {password}")
+
+ body = json.dumps({"password": password, "username": self.email})
+
+ result = self.page.evaluate("""
+ async (args) => {
+ try {
+ const resp = await fetch(args.url, {
+ method: 'POST',
+ headers: {
+ 'accept': 'application/json',
+ 'content-type': 'application/json',
+ 'referer': 'https://auth.openai.com/create-account/password',
+ },
+ body: args.body,
+ credentials: 'include',
+ });
+ return { status: resp.status, text: await resp.text() };
+ } catch (e) { return { status: 0, text: e.message }; }
+ }
+ """, {"url": OPENAI_API_ENDPOINTS["register"], "body": body})
+
+ status = result.get("status", 0)
+ self._log(f"提交密码状态: {status}")
+
+ if status != 200:
+ self._log(f"密码注册失败: {result.get('text', '')[:200]}", "warning")
+ return False, None
+
+ return True, password
+ except Exception as e:
+ self._log(f"密码注册失败: {e}", "error")
+ return False, None
+
+ # ──────────────────────── 步骤 9: 发送验证码 ────────────────────────────
+
+ def _send_verification_code(self) -> bool:
+ try:
+ self._otp_sent_at = time.time()
+
+ result = self.page.evaluate("""
+ async (url) => {
+ try {
+ const resp = await fetch(url, {
+ method: 'GET',
+ headers: { 'accept': 'application/json' },
+ credentials: 'include',
+ });
+ return resp.status;
+ } catch { return 0; }
+ }
+ """, OPENAI_API_ENDPOINTS["send_otp"])
+
+ self._log(f"验证码发送状态: {result}")
+ return result == 200
+ except Exception as e:
+ self._log(f"发送验证码失败: {e}", "error")
+ return False
+
+ # ──────────────────────── 步骤 10: 获取验证码(HTTP,保留原逻辑) ──────
+
+ def _get_verification_code(self) -> Optional[str]:
+ try:
+ self._log(f"正在等待邮箱 {self.email} 的验证码...")
+ email_id = self.email_info.get("service_id") if self.email_info else None
+ code = self.email_service.get_verification_code(
+ email=self.email,
+ email_id=email_id,
+ timeout=120,
+ pattern=OTP_CODE_PATTERN,
+ otp_sent_at=self._otp_sent_at,
+ )
+ if code:
+ self._log(f"成功获取验证码: {code}")
+ return code
+ self._log("等待验证码超时", "error")
+ return None
+ except Exception as e:
+ self._log(f"获取验证码失败: {e}", "error")
+ return None
+
+ # ──────────────────────── 步骤 11: 验证验证码 ───────────────────────────
+
+ def _validate_verification_code(self, code: str) -> bool:
+ try:
+ body = json.dumps({"code": code})
+
+ result = self.page.evaluate("""
+ async (args) => {
+ try {
+ const resp = await fetch(args.url, {
+ method: 'POST',
+ headers: {
+ 'accept': 'application/json',
+ 'content-type': 'application/json',
+ },
+ body: args.body,
+ credentials: 'include',
+ });
+ return resp.status;
+ } catch { return 0; }
+ }
+ """, {"url": OPENAI_API_ENDPOINTS["validate_otp"], "body": body})
+
+ self._log(f"验证码校验状态: {result}")
+ return result == 200
+ except Exception as e:
+ self._log(f"验证验证码失败: {e}", "error")
+ return False
+
+ # ──────────────────────── 步骤 12: 创建用户账户 ─────────────────────────
+
+ def _create_user_account(self) -> bool:
+ try:
+ user_info = generate_random_user_info()
+ self._log(f"生成用户信息: {user_info['name']}, 生日: {user_info['birthdate']}")
+ body = json.dumps(user_info)
+
+ result = self.page.evaluate("""
+ async (args) => {
+ try {
+ const resp = await fetch(args.url, {
+ method: 'POST',
+ headers: {
+ 'accept': 'application/json',
+ 'content-type': 'application/json',
+ },
+ body: args.body,
+ credentials: 'include',
+ });
+ return resp.status;
+ } catch { return 0; }
+ }
+ """, {"url": OPENAI_API_ENDPOINTS["create_account"], "body": body})
+
+ self._log(f"账户创建状态: {result}")
+ if result != 200:
+ self._log(f"账户创建失败: HTTP {result}", "warning")
+ return False
+ return True
+ except Exception as e:
+ self._log(f"创建账户失败: {e}", "error")
+ return False
+
+ # ──────────────────────── 步骤 13: 获取 Workspace ID(核心) ────────────
+
+ def _get_workspace_id(self) -> Optional[str]:
+ """从浏览器 Cookie 中获取 Workspace ID — 这是使用 Playwright 的核心原因"""
+ try:
+ cookies = self.context.cookies()
+ auth_cookie = None
+ for c in cookies:
+ if c["name"] == "oai-client-auth-session":
+ auth_cookie = c["value"]
+ break
+
+ if not auth_cookie:
+ self._log("未能获取到授权 Cookie (oai-client-auth-session)", "error")
+ return None
+
+ # 解码 JWT
+ segments = auth_cookie.split(".")
+ if len(segments) < 1:
+ self._log("授权 Cookie 格式错误", "error")
+ return None
+
+ payload = segments[0]
+ pad = "=" * ((4 - (len(payload) % 4)) % 4)
+ decoded = base64.urlsafe_b64decode((payload + pad).encode("ascii"))
+ auth_json = json.loads(decoded.decode("utf-8"))
+
+ workspaces = auth_json.get("workspaces") or []
+ if not workspaces:
+ self._log("授权 Cookie 里没有 workspace 信息", "error")
+ return None
+
+ workspace_id = str((workspaces[0] or {}).get("id") or "").strip()
+ if not workspace_id:
+ self._log("无法解析 workspace_id", "error")
+ return None
+
+ self._log(f"Workspace ID: {workspace_id}")
+ return workspace_id
+ except Exception as e:
+ self._log(f"获取 Workspace ID 失败: {e}", "error")
+ return None
+
+ # ──────────────────────── 步骤 14: 选择 Workspace ───────────────────────
+
+ def _select_workspace(self, workspace_id: str) -> Optional[str]:
+ try:
+ body = json.dumps({"workspace_id": workspace_id})
+
+ result = self.page.evaluate("""
+ async (args) => {
+ try {
+ const resp = await fetch(args.url, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: args.body,
+ credentials: 'include',
+ });
+ const data = await resp.json();
+ return { status: resp.status, data: data };
+ } catch (e) { return { status: 0, error: e.message }; }
+ }
+ """, {"url": OPENAI_API_ENDPOINTS["select_workspace"], "body": body})
+
+ status = result.get("status", 0)
+ if status != 200:
+ self._log(f"选择 workspace 失败: {status}", "error")
+ return None
+
+ continue_url = str((result.get("data") or {}).get("continue_url") or "").strip()
+ if not continue_url:
+ self._log("workspace/select 响应里缺少 continue_url", "error")
+ return None
+
+ self._log(f"Continue URL: {continue_url[:100]}...")
+ return continue_url
+ except Exception as e:
+ self._log(f"选择 Workspace 失败: {e}", "error")
+ return None
+
+ # ──────────────────────── 步骤 15: 跟随重定向链 ─────────────────────────
+
+ def _follow_redirects(self, start_url: str) -> Optional[str]:
+ """使用浏览器跟随重定向链,寻找包含 code= 和 state= 的回调 URL"""
+ try:
+ callback_url = None
+
+ # 监听请求,捕获回调 URL
+ def on_request(request):
+ nonlocal callback_url
+ url = request.url
+ if "code=" in url and "state=" in url:
+ callback_url = url
+
+ self.page.on("request", on_request)
+
+ try:
+ self.page.goto(start_url, wait_until="domcontentloaded", timeout=30000)
+ except Exception:
+ pass # 可能重定向到 localhost 导致超时,这是正常的
+
+ self.page.remove_listener("request", on_request)
+
+ if callback_url:
+ self._log(f"找到回调 URL: {callback_url[:100]}...")
+ return callback_url
+
+ # 检查当前页面 URL
+ current = self.page.url
+ if "code=" in current and "state=" in current:
+ self._log(f"当前 URL 包含回调参数: {current[:100]}...")
+ return current
+
+ self._log("未能在重定向链中找到回调 URL", "error")
+ return None
+ except Exception as e:
+ self._log(f"跟随重定向失败: {e}", "error")
+ return None
+
+ # ──────────────────────── 步骤 16: OAuth 回调 + Token ───────────────────
+
+ def _handle_oauth_callback(self, callback_url: str) -> Optional[Dict[str, Any]]:
+ try:
+ if not self.oauth_start:
+ self._log("OAuth 流程未初始化", "error")
+ return None
+ self._log("处理 OAuth 回调...")
+ token_info = self.oauth_manager.handle_callback(
+ callback_url=callback_url,
+ expected_state=self.oauth_start.state,
+ code_verifier=self.oauth_start.code_verifier,
+ )
+ self._log("OAuth 授权成功")
+ return token_info
+ except Exception as e:
+ self._log(f"处理 OAuth 回调失败: {e}", "error")
+ return None
+
+ # ══════════════════════════ 主流程 ══════════════════════════════════════
+
+ def run(self) -> RegistrationResult:
+ """执行完整的 Playwright 注册流程"""
+ result = RegistrationResult(success=False, logs=self.logs)
+
+ try:
+ self._log("=" * 60)
+ self._log("开始注册流程 (Playwright 引擎)")
+ self._log("=" * 60)
+
+ # 3. 初始化浏览器上下文
+ self._log("3. 初始化浏览器上下文...")
+ if not self._init_browser_context():
+ result.error_message = "初始化浏览器失败"
+ return result
+
+ # 1. 检查 IP 地理位置
+ self._log("1. 检查 IP 地理位置...")
+ ip_ok, location = self._check_ip_location()
+ if not ip_ok:
+ result.error_message = f"IP 地理位置不支持: {location}"
+ return result
+ self._log(f"IP 位置: {location}")
+
+ # 2. 创建邮箱
+ self._log("2. 创建邮箱...")
+ if not self._create_email():
+ result.error_message = "创建邮箱失败"
+ return result
+ result.email = self.email
+
+ # 4. 开始 OAuth 流程
+ self._log("4. 开始 OAuth 授权流程...")
+ if not self._start_oauth():
+ result.error_message = "开始 OAuth 流程失败"
+ return result
+
+ # 5. 获取 Device ID
+ self._log("5. 获取 Device ID...")
+ did = self._get_device_id()
+ if not did:
+ result.error_message = "获取 Device ID 失败"
+ return result
+
+ # 6. 检查 Sentinel
+ self._log("6. 检查 Sentinel 拦截...")
+ sen_token = self._check_sentinel(did)
+
+ # 7. 提交注册表单
+ self._log("7. 提交注册表单...")
+ signup_result = self._submit_signup_form(did, sen_token)
+ if not signup_result.success:
+ result.error_message = f"提交注册表单失败: {signup_result.error_message}"
+ return result
+
+ # 8. 注册密码
+ if self._is_existing_account:
+ self._log("8. [已注册账号] 跳过密码设置,OTP 已自动发送")
+ else:
+ self._log("8. 注册密码...")
+ password_ok, password = self._register_password()
+ if not password_ok:
+ result.error_message = "注册密码失败"
+ return result
+
+ # 9. 发送验证码
+ if self._is_existing_account:
+ self._log("9. [已注册账号] 跳过发送验证码")
+ self._otp_sent_at = time.time()
+ else:
+ self._log("9. 发送验证码...")
+ if not self._send_verification_code():
+ result.error_message = "发送验证码失败"
+ return result
+
+ # 10. 获取验证码(HTTP)
+ self._log("10. 等待验证码...")
+ code = self._get_verification_code()
+ if not code:
+ result.error_message = "获取验证码失败"
+ return result
+
+ # 11. 验证验证码
+ self._log("11. 验证验证码...")
+ if not self._validate_verification_code(code):
+ result.error_message = "验证验证码失败"
+ return result
+
+ # 12. 创建用户账户
+ if self._is_existing_account:
+ self._log("12. [已注册账号] 跳过创建用户账户")
+ else:
+ self._log("12. 创建用户账户...")
+ if not self._create_user_account():
+ result.error_message = "创建用户账户失败"
+ return result
+
+ # 13. 获取 Workspace ID
+ self._log("13. 获取 Workspace ID...")
+ workspace_id = self._get_workspace_id()
+ if not workspace_id:
+ result.error_message = "获取 Workspace ID 失败"
+ return result
+ result.workspace_id = workspace_id
+
+ # 14. 选择 Workspace
+ self._log("14. 选择 Workspace...")
+ continue_url = self._select_workspace(workspace_id)
+ if not continue_url:
+ result.error_message = "选择 Workspace 失败"
+ return result
+
+ # 15. 跟随重定向链
+ self._log("15. 跟随重定向链...")
+ callback_url = self._follow_redirects(continue_url)
+ if not callback_url:
+ result.error_message = "跟随重定向链失败"
+ return result
+
+ # 16. 处理 OAuth 回调
+ self._log("16. 处理 OAuth 回调...")
+ token_info = self._handle_oauth_callback(callback_url)
+ if not token_info:
+ result.error_message = "处理 OAuth 回调失败"
+ return result
+
+ # 提取结果
+ result.account_id = token_info.get("account_id", "")
+ result.access_token = token_info.get("access_token", "")
+ result.refresh_token = token_info.get("refresh_token", "")
+ result.id_token = token_info.get("id_token", "")
+ result.password = self.password or ""
+ result.source = "login" if self._is_existing_account else "register"
+
+ # session_token
+ cookies = self.context.cookies()
+ for c in cookies:
+ if c["name"] == "__Secure-next-auth.session-token":
+ result.session_token = c["value"]
+ self._log("获取到 Session Token")
+ break
+
+ self._log("=" * 60)
+ self._log("登录成功! (已注册账号)" if self._is_existing_account else "注册成功!")
+ self._log(f"邮箱: {result.email}")
+ self._log(f"Account ID: {result.account_id}")
+ self._log(f"Workspace ID: {result.workspace_id}")
+ self._log("=" * 60)
+
+ result.success = True
+ result.metadata = {
+ "email_service": self.email_service.service_type.value,
+ "proxy_used": self.proxy_url,
+ "registered_at": datetime.now().isoformat(),
+ "is_existing_account": self._is_existing_account,
+ "engine": "playwright",
+ }
+ return result
+
+ except Exception as e:
+ self._log(f"注册过程中发生未预期错误: {e}", "error")
+ result.error_message = str(e)
+ return result
+ finally:
+ # 释放浏览器上下文
+ if self.context:
+ try:
+ playwright_pool.release(self.context)
+ self.context = None
+ self.page = None
+ except Exception:
+ pass
+
+ def save_to_database(self, result: RegistrationResult) -> bool:
+ """保存注册结果到数据库(复用原引擎逻辑)"""
+ if not result.success:
+ return False
+ try:
+ settings = get_settings()
+ with get_db() as db:
+ account = crud.create_account(
+ db,
+ email=result.email,
+ password=result.password,
+ client_id=settings.openai_client_id,
+ session_token=result.session_token,
+ email_service=self.email_service.service_type.value,
+ email_service_id=self.email_info.get("service_id") if self.email_info else None,
+ account_id=result.account_id,
+ workspace_id=result.workspace_id,
+ access_token=result.access_token,
+ refresh_token=result.refresh_token,
+ id_token=result.id_token,
+ proxy_used=self.proxy_url,
+ extra_data=result.metadata,
+ source=result.source,
+ )
+ self._log(f"账户已保存到数据库,ID: {account.id}")
+ return True
+ except Exception as e:
+ self._log(f"保存到数据库失败: {e}", "error")
+ return False
diff --git a/src/core/upload/__init__.py b/src/core/upload/__init__.py
new file mode 100644
index 0000000..059e515
--- /dev/null
+++ b/src/core/upload/__init__.py
@@ -0,0 +1,3 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+# @Time : 2026/3/18 19:54
\ No newline at end of file
diff --git a/src/core/upload/cpa_upload.py b/src/core/upload/cpa_upload.py
new file mode 100644
index 0000000..7c5741f
--- /dev/null
+++ b/src/core/upload/cpa_upload.py
@@ -0,0 +1,330 @@
+"""
+CPA (Codex Protocol API) 上传功能
+"""
+
+import json
+import logging
+from typing import List, Dict, Any, Tuple, Optional
+from datetime import datetime
+from urllib.parse import quote
+
+from curl_cffi import requests as cffi_requests
+from curl_cffi import CurlMime
+
+from ...database.session import get_db
+from ...database.models import Account
+from ...config.settings import get_settings
+
+logger = logging.getLogger(__name__)
+
+
+def _normalize_cpa_auth_files_url(api_url: str) -> str:
+ """将用户填写的 CPA 地址规范化为 auth-files 接口地址。"""
+ normalized = (api_url or "").strip().rstrip("/")
+ lower_url = normalized.lower()
+
+ if not normalized:
+ return ""
+
+ if lower_url.endswith("/auth-files"):
+ return normalized
+
+ if lower_url.endswith("/v0/management") or lower_url.endswith("/management"):
+ return f"{normalized}/auth-files"
+
+ if lower_url.endswith("/v0"):
+ return f"{normalized}/management/auth-files"
+
+ return f"{normalized}/v0/management/auth-files"
+
+
+def _build_cpa_headers(api_token: str, content_type: Optional[str] = None) -> dict:
+ headers = {
+ "Authorization": f"Bearer {api_token}",
+ }
+ if content_type:
+ headers["Content-Type"] = content_type
+ return headers
+
+
+def _extract_cpa_error(response) -> str:
+ error_msg = f"上传失败: HTTP {response.status_code}"
+ try:
+ error_detail = response.json()
+ if isinstance(error_detail, dict):
+ error_msg = error_detail.get("message", error_msg)
+ except Exception:
+ error_msg = f"{error_msg} - {response.text[:200]}"
+ return error_msg
+
+
+def _post_cpa_auth_file_multipart(upload_url: str, filename: str, file_content: bytes, api_token: str):
+ mime = CurlMime()
+ mime.addpart(
+ name="file",
+ data=file_content,
+ filename=filename,
+ content_type="application/json",
+ )
+
+ return cffi_requests.post(
+ upload_url,
+ multipart=mime,
+ headers=_build_cpa_headers(api_token),
+ proxies=None,
+ timeout=30,
+ impersonate="chrome110",
+ )
+
+
+def _post_cpa_auth_file_raw_json(upload_url: str, filename: str, file_content: bytes, api_token: str):
+ raw_upload_url = f"{upload_url}?name={quote(filename)}"
+ return cffi_requests.post(
+ raw_upload_url,
+ data=file_content,
+ headers=_build_cpa_headers(api_token, content_type="application/json"),
+ proxies=None,
+ timeout=30,
+ impersonate="chrome110",
+ )
+
+
+def generate_token_json(
+ account: Account,
+ include_proxy_url: bool = False,
+ proxy_url: Optional[str] = None,
+) -> dict:
+ """
+ 生成 CPA 格式的 Token JSON
+
+ Args:
+ account: 账号模型实例
+ include_proxy_url: 是否将账号代理写入 auth file 的 proxy_url 字段
+ proxy_url: 当账号本身没有记录代理时使用的兜底代理 URL
+
+ Returns:
+ CPA 格式的 Token 字典
+ """
+ token_data = {
+ "type": "codex",
+ "email": account.email,
+ "expired": account.expires_at.strftime("%Y-%m-%dT%H:%M:%S+08:00") if account.expires_at else "",
+ "id_token": account.id_token or "",
+ "account_id": account.account_id or "",
+ "access_token": account.access_token or "",
+ "last_refresh": account.last_refresh.strftime("%Y-%m-%dT%H:%M:%S+08:00") if account.last_refresh else "",
+ "refresh_token": account.refresh_token or "",
+ }
+
+ resolved_proxy_url = (getattr(account, "proxy_used", None) or proxy_url or "").strip()
+ if include_proxy_url and resolved_proxy_url:
+ token_data["proxy_url"] = resolved_proxy_url
+
+ return token_data
+
+
+def upload_to_cpa(
+ token_data: dict,
+ proxy: str = None,
+ api_url: str = None,
+ api_token: str = None,
+) -> Tuple[bool, str]:
+ """
+ 上传单个账号到 CPA 管理平台(不走代理)
+
+ Args:
+ token_data: Token JSON 数据
+ proxy: 保留参数,不使用(CPA 上传始终直连)
+ api_url: 指定 CPA API URL(优先于全局配置)
+ api_token: 指定 CPA API Token(优先于全局配置)
+
+ Returns:
+ (成功标志, 消息或错误信息)
+ """
+ settings = get_settings()
+
+ # 优先使用传入的参数,否则退回全局配置
+ effective_url = api_url or settings.cpa_api_url
+ effective_token = api_token or (settings.cpa_api_token.get_secret_value() if settings.cpa_api_token else "")
+
+ # 仅当未指定服务时才检查全局启用开关
+ if not api_url and not settings.cpa_enabled:
+ return False, "CPA 上传未启用"
+
+ if not effective_url:
+ return False, "CPA API URL 未配置"
+
+ if not effective_token:
+ return False, "CPA API Token 未配置"
+
+ upload_url = _normalize_cpa_auth_files_url(effective_url)
+
+ filename = f"{token_data['email']}.json"
+ file_content = json.dumps(token_data, ensure_ascii=False, indent=2).encode("utf-8")
+
+ try:
+ response = _post_cpa_auth_file_multipart(
+ upload_url,
+ filename,
+ file_content,
+ effective_token,
+ )
+
+ if response.status_code in (200, 201):
+ return True, "上传成功"
+
+ if response.status_code in (404, 405, 415):
+ logger.warning("CPA multipart 上传失败,尝试原始 JSON 回退: %s", response.status_code)
+ fallback_response = _post_cpa_auth_file_raw_json(
+ upload_url,
+ filename,
+ file_content,
+ effective_token,
+ )
+ if fallback_response.status_code in (200, 201):
+ return True, "上传成功"
+ response = fallback_response
+
+ return False, _extract_cpa_error(response)
+
+ except Exception as e:
+ logger.error(f"CPA 上传异常: {e}")
+ return False, f"上传异常: {str(e)}"
+
+
+def batch_upload_to_cpa(
+ account_ids: List[int],
+ proxy: str = None,
+ api_url: str = None,
+ api_token: str = None,
+ include_proxy_url: bool = False,
+) -> dict:
+ """
+ 批量上传账号到 CPA 管理平台
+
+ Args:
+ account_ids: 账号 ID 列表
+ proxy: 可选的代理 URL(用于 auth file proxy_url 的兜底值)
+ api_url: 指定 CPA API URL(优先于全局配置)
+ api_token: 指定 CPA API Token(优先于全局配置)
+ include_proxy_url: 是否将账号代理写入 auth file 的 proxy_url 字段
+
+ 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
+
+ # 检查是否已有 Token
+ if not account.access_token:
+ results["skipped_count"] += 1
+ results["details"].append({
+ "id": account_id,
+ "email": account.email,
+ "success": False,
+ "error": "缺少 Token"
+ })
+ continue
+
+ # 生成 Token JSON
+ token_data = generate_token_json(
+ account,
+ include_proxy_url=include_proxy_url,
+ proxy_url=proxy,
+ )
+
+ # 上传
+ success, message = upload_to_cpa(token_data, proxy, api_url=api_url, api_token=api_token)
+
+ if success:
+ # 更新数据库状态
+ account.cpa_uploaded = True
+ account.cpa_uploaded_at = datetime.utcnow()
+ db.commit()
+
+ 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_cpa_connection(api_url: str, api_token: str, proxy: str = None) -> Tuple[bool, str]:
+ """
+ 测试 CPA 连接(不走代理)
+
+ Args:
+ api_url: CPA API URL
+ api_token: CPA API Token
+ proxy: 保留参数,不使用(CPA 始终直连)
+
+ Returns:
+ (成功标志, 消息)
+ """
+ if not api_url:
+ return False, "API URL 不能为空"
+
+ if not api_token:
+ return False, "API Token 不能为空"
+
+ test_url = _normalize_cpa_auth_files_url(api_url)
+ headers = _build_cpa_headers(api_token)
+
+ try:
+ response = cffi_requests.get(
+ test_url,
+ headers=headers,
+ proxies=None,
+ timeout=10,
+ impersonate="chrome110",
+ )
+
+ if response.status_code == 200:
+ return True, "CPA 连接测试成功"
+ if response.status_code == 401:
+ return False, "连接成功,但 API Token 无效"
+ if response.status_code == 403:
+ return False, "连接成功,但服务端未启用远程管理或当前 Token 无权限"
+ if response.status_code == 404:
+ return False, "未找到 CPA auth-files 接口,请检查 API URL 是否填写为根地址、/v0/management 或完整 auth-files 地址"
+ if response.status_code == 503:
+ return False, "连接成功,但服务端认证管理器不可用"
+
+ return False, f"服务器返回异常状态码: {response.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/core/upload/sub2api_upload.py b/src/core/upload/sub2api_upload.py
new file mode 100644
index 0000000..80d9be7
--- /dev/null
+++ b/src/core/upload/sub2api_upload.py
@@ -0,0 +1,233 @@
+"""
+Sub2API 账号上传功能
+将账号以 sub2api-data 格式批量导入到 Sub2API 平台
+"""
+
+import json
+import logging
+from datetime import datetime, timezone
+from typing import List, Tuple, Optional
+
+from curl_cffi import requests as cffi_requests
+
+from ...database.session import get_db
+from ...database.models import Account
+
+logger = logging.getLogger(__name__)
+
+
+def upload_to_sub2api(
+ accounts: List[Account],
+ api_url: str,
+ api_key: str,
+ concurrency: int = 3,
+ priority: int = 50,
+ group_ids: Optional[List[int]] = None,
+ proxy_id: Optional[int] = None,
+ model_mapping: Optional[dict] = None,
+) -> Tuple[bool, str]:
+ """
+ 上传账号列表到 Sub2API 平台(不走代理)
+
+ Args:
+ accounts: 账号模型实例列表
+ api_url: Sub2API 地址,如 http://host
+ api_key: Admin API Key(x-api-key header)
+ concurrency: 账号并发数,默认 3
+ priority: 账号优先级,默认 50
+ group_ids: 分组 ID 列表(上传后绑定到指定分组)
+ proxy_id: 代理节点 ID(上传后关联到指定代理)
+ model_mapping: 自定义模型映射(覆盖默认映射)
+
+ Returns:
+ (成功标志, 消息)
+ """
+ if not accounts:
+ return False, "无可上传的账号"
+
+ if not api_url:
+ return False, "Sub2API URL 未配置"
+
+ if not api_key:
+ return False, "Sub2API API Key 未配置"
+
+ exported_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+
+ account_items = []
+ for acc in accounts:
+ if not acc.access_token:
+ continue
+ expires_at = int(acc.expires_at.timestamp()) if acc.expires_at else 0
+ account_items.append({
+ "name": acc.email,
+ "platform": "openai",
+ "type": "oauth",
+ "credentials": {
+ "access_token": acc.access_token,
+ "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",
+ "gpt-5.3": "gpt-5.3",
+ "gpt-5.3-codex": "gpt-5.3-codex",
+ "gpt-5.4": "gpt-5.4"
+ },
+ "organization_id": acc.workspace_id or "",
+ "refresh_token": acc.refresh_token or "",
+ },
+ "extra": {},
+ "concurrency": concurrency,
+ "priority": priority,
+ "rate_multiplier": 1,
+ "auto_pause_on_expired": True,
+ })
+
+ if not account_items:
+ return False, "所有账号均缺少 access_token,无法上传"
+
+ payload = {
+ "data": {
+ "type": "sub2api-data",
+ "version": 1,
+ "exported_at": exported_at,
+ "proxies": [],
+ "accounts": account_items,
+ },
+ "skip_default_group_bind": True,
+ }
+
+ url = api_url.rstrip("/") + "/api/v1/admin/accounts/data"
+ headers = {
+ "Content-Type": "application/json",
+ "x-api-key": api_key,
+ "Idempotency-Key": f"import-{exported_at}",
+ }
+
+ try:
+ response = cffi_requests.post(
+ url,
+ json=payload,
+ headers=headers,
+ proxies=None,
+ timeout=30,
+ impersonate="chrome110",
+ )
+
+ if response.status_code in (200, 201):
+ return True, f"成功上传 {len(account_items)} 个账号"
+
+ error_msg = f"上传失败: HTTP {response.status_code}"
+ try:
+ detail = response.json()
+ if isinstance(detail, dict):
+ error_msg = detail.get("message", error_msg)
+ except Exception:
+ error_msg = f"{error_msg} - {response.text[:200]}"
+ return False, error_msg
+
+ except Exception as e:
+ logger.error(f"Sub2API 上传异常: {e}")
+ return False, f"上传异常: {str(e)}"
+
+
+def batch_upload_to_sub2api(
+ account_ids: List[int],
+ api_url: str,
+ api_key: str,
+ concurrency: int = 3,
+ priority: int = 50,
+ group_ids: Optional[List[int]] = None,
+ proxy_id: Optional[int] = None,
+ model_mapping: Optional[dict] = None,
+) -> dict:
+ """
+ 批量上传指定 ID 的账号到 Sub2API 平台
+
+ Returns:
+ 包含成功/失败/跳过统计和详情的字典
+ """
+ results = {
+ "success_count": 0,
+ "failed_count": 0,
+ "skipped_count": 0,
+ "details": []
+ }
+
+ with get_db() as db:
+ accounts = []
+ for account_id in account_ids:
+ acc = db.query(Account).filter(Account.id == account_id).first()
+ if not acc:
+ results["failed_count"] += 1
+ results["details"].append({"id": account_id, "email": None, "success": False, "error": "账号不存在"})
+ continue
+ if not acc.access_token:
+ results["skipped_count"] += 1
+ results["details"].append({"id": account_id, "email": acc.email, "success": False, "error": "缺少 access_token"})
+ continue
+ accounts.append(acc)
+
+ if not accounts:
+ return results
+
+ success, message = upload_to_sub2api(accounts, api_url, api_key, concurrency, priority, group_ids, proxy_id, model_mapping)
+
+ if success:
+ for acc in accounts:
+ results["success_count"] += 1
+ results["details"].append({"id": acc.id, "email": acc.email, "success": True, "message": message})
+ else:
+ for acc in accounts:
+ results["failed_count"] += 1
+ results["details"].append({"id": acc.id, "email": acc.email, "success": False, "error": message})
+
+ return results
+
+
+def test_sub2api_connection(api_url: str, api_key: str) -> Tuple[bool, str]:
+ """
+ 测试 Sub2API 连接(GET /api/v1/admin/accounts/data 探活)
+
+ Returns:
+ (成功标志, 消息)
+ """
+ if not api_url:
+ return False, "API URL 不能为空"
+ if not api_key:
+ return False, "API Key 不能为空"
+
+ url = api_url.rstrip("/") + "/api/v1/admin/accounts/data"
+ headers = {"x-api-key": api_key}
+
+ try:
+ response = cffi_requests.get(
+ url,
+ headers=headers,
+ proxies=None,
+ timeout=10,
+ impersonate="chrome110",
+ )
+
+ if response.status_code in (200, 201, 204, 405):
+ return True, "Sub2API 连接测试成功"
+ if response.status_code == 401:
+ return False, "连接成功,但 API Key 无效"
+ if response.status_code == 403:
+ return False, "连接成功,但权限不足"
+
+ return False, f"服务器返回异常状态码: {response.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/core/upload/team_manager_upload.py b/src/core/upload/team_manager_upload.py
new file mode 100644
index 0000000..1197348
--- /dev/null
+++ b/src/core/upload/team_manager_upload.py
@@ -0,0 +1,204 @@
+"""
+Team Manager 上传功能
+参照 CPA 上传模式,直连不走代理
+"""
+
+import logging
+from typing import List, Tuple
+
+from curl_cffi import requests as cffi_requests
+
+from ...database.models import Account
+from ...database.session import get_db
+
+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("/") + "/admin/teams/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 "",
+ "account_id": account.account_id or "",
+ }
+
+ try:
+ resp = cffi_requests.post(
+ url,
+ headers=headers,
+ json=payload,
+ proxies=None,
+ timeout=30
+ )
+ 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(使用 batch 模式,一次请求提交所有账号)
+
+ Returns:
+ 包含成功/失败统计和详情的字典
+ """
+ results = {
+ "success_count": 0,
+ "failed_count": 0,
+ "skipped_count": 0,
+ "details": [],
+ }
+
+ with get_db() as db:
+ lines = []
+ valid_accounts = []
+ 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
+ # 格式:邮箱,AT,RT,ST,ClientID
+ lines.append(",".join([
+ account.email or "",
+ account.access_token or "",
+ account.refresh_token or "",
+ account.session_token or "",
+ account.client_id or "",
+ ]))
+ valid_accounts.append(account)
+
+ if not valid_accounts:
+ return results
+
+ url = api_url.rstrip("/") + "/admin/teams/import"
+ headers = {
+ "X-API-Key": api_key,
+ "Content-Type": "application/json",
+ }
+ payload = {
+ "import_type": "batch",
+ "content": "\n".join(lines),
+ }
+
+ try:
+ resp = cffi_requests.post(
+ url,
+ headers=headers,
+ json=payload,
+ proxies=None,
+ timeout=60,
+ impersonate="chrome110",
+ )
+ if resp.status_code in (200, 201):
+ for account in valid_accounts:
+ results["success_count"] += 1
+ results["details"].append(
+ {"id": account.id, "email": account.email, "success": True, "message": "批量上传成功"}
+ )
+ else:
+ 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]}"
+ for account in valid_accounts:
+ results["failed_count"] += 1
+ results["details"].append(
+ {"id": account.id, "email": account.email, "success": False, "error": error_msg}
+ )
+ except Exception as e:
+ logger.error(f"Team Manager 批量上传异常: {e}")
+ error_msg = f"上传异常: {str(e)}"
+ for account in valid_accounts:
+ results["failed_count"] += 1
+ results["details"].append(
+ {"id": account.id, "email": account.email, "success": False, "error": error_msg}
+ )
+
+ 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("/") + "/admin/teams/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/core/utils.py b/src/core/utils.py
new file mode 100644
index 0000000..80ab61f
--- /dev/null
+++ b/src/core/utils.py
@@ -0,0 +1,570 @@
+"""
+通用工具函数
+"""
+
+import os
+import sys
+import json
+import time
+import random
+import string
+import secrets
+import hashlib
+import logging
+import base64
+import re
+import uuid
+from datetime import datetime, timedelta
+from typing import Any, Dict, List, Optional, Union, Callable
+from pathlib import Path
+
+from ..config.constants import PASSWORD_CHARSET, DEFAULT_PASSWORD_LENGTH
+from ..config.settings import get_settings
+
+
+def setup_logging(
+ log_level: str = "INFO",
+ log_file: Optional[str] = None,
+ log_format: str = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
+) -> logging.Logger:
+ """
+ 配置日志系统
+
+ Args:
+ log_level: 日志级别 (DEBUG, INFO, WARNING, ERROR, CRITICAL)
+ log_file: 日志文件路径,如果不指定则只输出到控制台
+ log_format: 日志格式
+
+ Returns:
+ 根日志记录器
+ """
+ # 设置日志级别
+ numeric_level = getattr(logging, log_level.upper(), None)
+ if not isinstance(numeric_level, int):
+ numeric_level = logging.INFO
+
+ # 配置根日志记录器
+ root_logger = logging.getLogger()
+ root_logger.setLevel(numeric_level)
+
+ # 清除现有的处理器
+ root_logger.handlers.clear()
+
+ # 创建格式化器
+ formatter = logging.Formatter(log_format)
+
+ # 控制台处理器
+ console_handler = logging.StreamHandler(sys.stdout)
+ console_handler.setFormatter(formatter)
+ console_handler.setLevel(numeric_level)
+ root_logger.addHandler(console_handler)
+
+ # 文件处理器(如果指定了日志文件)
+ if log_file:
+ # 确保日志目录存在
+ log_dir = os.path.dirname(log_file)
+ if log_dir:
+ os.makedirs(log_dir, exist_ok=True)
+
+ file_handler = logging.FileHandler(log_file, encoding="utf-8")
+ file_handler.setFormatter(formatter)
+ file_handler.setLevel(numeric_level)
+ root_logger.addHandler(file_handler)
+
+ return root_logger
+
+
+def generate_password(length: int = DEFAULT_PASSWORD_LENGTH) -> str:
+ """
+ 生成随机密码
+
+ Args:
+ length: 密码长度
+
+ Returns:
+ 随机密码字符串
+ """
+ if length < 4:
+ length = 4
+
+ # 确保密码包含至少一个大写字母、一个小写字母和一个数字
+ password = [
+ secrets.choice(string.ascii_lowercase),
+ secrets.choice(string.ascii_uppercase),
+ secrets.choice(string.digits),
+ ]
+
+ # 添加剩余字符
+ password.extend(secrets.choice(PASSWORD_CHARSET) for _ in range(length - 3))
+
+ # 随机打乱
+ secrets.SystemRandom().shuffle(password)
+
+ return ''.join(password)
+
+
+def generate_random_string(length: int = 8) -> str:
+ """
+ 生成随机字符串(仅字母)
+
+ Args:
+ length: 字符串长度
+
+ Returns:
+ 随机字符串
+ """
+ chars = string.ascii_letters
+ return ''.join(secrets.choice(chars) for _ in range(length))
+
+
+def generate_uuid() -> str:
+ """生成 UUID 字符串"""
+ return str(uuid.uuid4())
+
+
+def get_timestamp() -> int:
+ """获取当前时间戳(秒)"""
+ return int(time.time())
+
+
+def format_datetime(dt: Optional[datetime] = None, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
+ """
+ 格式化日期时间
+
+ Args:
+ dt: 日期时间对象,如果为 None 则使用当前时间
+ fmt: 格式字符串
+
+ Returns:
+ 格式化后的字符串
+ """
+ if dt is None:
+ dt = datetime.now()
+ return dt.strftime(fmt)
+
+
+def parse_datetime(dt_str: str, fmt: str = "%Y-%m-%d %H:%M:%S") -> Optional[datetime]:
+ """
+ 解析日期时间字符串
+
+ Args:
+ dt_str: 日期时间字符串
+ fmt: 格式字符串
+
+ Returns:
+ 日期时间对象,如果解析失败返回 None
+ """
+ try:
+ return datetime.strptime(dt_str, fmt)
+ except (ValueError, TypeError):
+ return None
+
+
+def human_readable_size(size_bytes: int) -> str:
+ """
+ 将字节大小转换为人类可读的格式
+
+ Args:
+ size_bytes: 字节大小
+
+ Returns:
+ 人类可读的字符串
+ """
+ if size_bytes < 0:
+ return "0 B"
+
+ units = ["B", "KB", "MB", "GB", "TB", "PB"]
+ unit_index = 0
+
+ while size_bytes >= 1024 and unit_index < len(units) - 1:
+ size_bytes /= 1024
+ unit_index += 1
+
+ return f"{size_bytes:.2f} {units[unit_index]}"
+
+
+def retry_with_backoff(
+ func: Callable,
+ max_retries: int = 3,
+ base_delay: float = 1.0,
+ max_delay: float = 30.0,
+ backoff_factor: float = 2.0,
+ exceptions: tuple = (Exception,)
+) -> Any:
+ """
+ 带有指数退避的重试装饰器/函数
+
+ Args:
+ func: 要重试的函数
+ max_retries: 最大重试次数
+ base_delay: 基础延迟(秒)
+ max_delay: 最大延迟(秒)
+ backoff_factor: 退避因子
+ exceptions: 要捕获的异常类型
+
+ Returns:
+ 函数的返回值
+
+ Raises:
+ 最后一次尝试的异常
+ """
+ last_exception = None
+
+ for attempt in range(max_retries + 1):
+ try:
+ return func()
+ except exceptions as e:
+ last_exception = e
+
+ # 如果是最后一次尝试,直接抛出异常
+ if attempt == max_retries:
+ break
+
+ # 计算延迟时间
+ delay = min(base_delay * (backoff_factor ** attempt), max_delay)
+
+ # 添加随机抖动
+ delay *= (0.5 + random.random())
+
+ # 记录日志
+ logger = logging.getLogger(__name__)
+ logger.warning(
+ f"尝试 {func.__name__} 失败 (attempt {attempt + 1}/{max_retries + 1}): {e}. "
+ f"等待 {delay:.2f} 秒后重试..."
+ )
+
+ time.sleep(delay)
+
+ # 所有重试都失败,抛出最后一个异常
+ raise last_exception
+
+
+class RetryDecorator:
+ """重试装饰器类"""
+
+ def __init__(
+ self,
+ max_retries: int = 3,
+ base_delay: float = 1.0,
+ max_delay: float = 30.0,
+ backoff_factor: float = 2.0,
+ exceptions: tuple = (Exception,)
+ ):
+ self.max_retries = max_retries
+ self.base_delay = base_delay
+ self.max_delay = max_delay
+ self.backoff_factor = backoff_factor
+ self.exceptions = exceptions
+
+ def __call__(self, func: Callable) -> Callable:
+ """装饰器调用"""
+ def wrapper(*args, **kwargs):
+ def func_to_retry():
+ return func(*args, **kwargs)
+
+ return retry_with_backoff(
+ func_to_retry,
+ max_retries=self.max_retries,
+ base_delay=self.base_delay,
+ max_delay=self.max_delay,
+ backoff_factor=self.backoff_factor,
+ exceptions=self.exceptions
+ )
+
+ return wrapper
+
+
+def validate_email(email: str) -> bool:
+ """
+ 验证邮箱地址格式
+
+ Args:
+ email: 邮箱地址
+
+ Returns:
+ 是否有效
+ """
+ pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
+ return bool(re.match(pattern, email))
+
+
+def validate_url(url: str) -> bool:
+ """
+ 验证 URL 格式
+
+ Args:
+ url: URL
+
+ Returns:
+ 是否有效
+ """
+ pattern = r"^https?://[^\s/$.?#].[^\s]*$"
+ return bool(re.match(pattern, url))
+
+
+def sanitize_filename(filename: str) -> str:
+ """
+ 清理文件名,移除不安全的字符
+
+ Args:
+ filename: 原始文件名
+
+ Returns:
+ 清理后的文件名
+ """
+ # 移除危险字符
+ filename = re.sub(r'[<>:"/\\|?*]', '_', filename)
+ # 移除控制字符
+ filename = ''.join(char for char in filename if ord(char) >= 32)
+ # 限制长度
+ if len(filename) > 255:
+ name, ext = os.path.splitext(filename)
+ filename = name[:255 - len(ext)] + ext
+ return filename
+
+
+def read_json_file(filepath: str) -> Optional[Dict[str, Any]]:
+ """
+ 读取 JSON 文件
+
+ Args:
+ filepath: 文件路径
+
+ Returns:
+ JSON 数据,如果读取失败返回 None
+ """
+ try:
+ with open(filepath, 'r', encoding='utf-8') as f:
+ return json.load(f)
+ except (FileNotFoundError, json.JSONDecodeError, IOError) as e:
+ logging.getLogger(__name__).warning(f"读取 JSON 文件失败: {filepath} - {e}")
+ return None
+
+
+def write_json_file(filepath: str, data: Dict[str, Any], indent: int = 2) -> bool:
+ """
+ 写入 JSON 文件
+
+ Args:
+ filepath: 文件路径
+ data: 要写入的数据
+ indent: 缩进空格数
+
+ Returns:
+ 是否成功
+ """
+ try:
+ # 确保目录存在
+ os.makedirs(os.path.dirname(filepath), exist_ok=True)
+
+ with open(filepath, 'w', encoding='utf-8') as f:
+ json.dump(data, f, ensure_ascii=False, indent=indent)
+
+ return True
+ except (IOError, TypeError) as e:
+ logging.getLogger(__name__).error(f"写入 JSON 文件失败: {filepath} - {e}")
+ return False
+
+
+def get_project_root() -> Path:
+ """
+ 获取项目根目录
+
+ Returns:
+ 项目根目录 Path 对象
+ """
+ # 当前文件所在目录
+ current_dir = Path(__file__).parent
+
+ # 向上查找直到找到项目根目录(包含 pyproject.toml 或 setup.py)
+ for parent in [current_dir] + list(current_dir.parents):
+ if (parent / "pyproject.toml").exists() or (parent / "setup.py").exists():
+ return parent
+
+ # 如果找不到,返回当前目录的父目录
+ return current_dir.parent
+
+
+def get_data_dir() -> Path:
+ """
+ 获取数据目录
+
+ Returns:
+ 数据目录 Path 对象
+ """
+ settings = get_settings()
+ if not settings.database_url.startswith("sqlite"):
+ data_dir = Path(os.environ.get("APP_DATA_DIR", "data"))
+ data_dir.mkdir(parents=True, exist_ok=True)
+ return data_dir
+ data_dir = Path(settings.database_url).parent
+
+ # 如果 database_url 是 SQLite URL,提取路径
+ if settings.database_url.startswith("sqlite:///"):
+ db_path = settings.database_url[10:] # 移除 "sqlite:///"
+ data_dir = Path(db_path).parent
+
+ # 确保目录存在
+ data_dir.mkdir(parents=True, exist_ok=True)
+
+ return data_dir
+
+
+def get_logs_dir() -> Path:
+ """
+ 获取日志目录
+
+ Returns:
+ 日志目录 Path 对象
+ """
+ settings = get_settings()
+ log_file = Path(settings.log_file)
+ log_dir = log_file.parent
+
+ # 确保目录存在
+ log_dir.mkdir(parents=True, exist_ok=True)
+
+ return log_dir
+
+
+def format_duration(seconds: int) -> str:
+ """
+ 格式化持续时间
+
+ Args:
+ seconds: 秒数
+
+ Returns:
+ 格式化的持续时间字符串
+ """
+ if seconds < 60:
+ return f"{seconds}秒"
+
+ minutes, seconds = divmod(seconds, 60)
+ if minutes < 60:
+ return f"{minutes}分{seconds}秒"
+
+ hours, minutes = divmod(minutes, 60)
+ if hours < 24:
+ return f"{hours}小时{minutes}分"
+
+ days, hours = divmod(hours, 24)
+ return f"{days}天{hours}小时"
+
+
+def mask_sensitive_data(data: Union[str, Dict, List], mask_char: str = "*") -> Union[str, Dict, List]:
+ """
+ 掩码敏感数据
+
+ Args:
+ data: 要掩码的数据
+ mask_char: 掩码字符
+
+ Returns:
+ 掩码后的数据
+ """
+ if isinstance(data, str):
+ # 如果是邮箱,掩码中间部分
+ if "@" in data:
+ local, domain = data.split("@", 1)
+ if len(local) > 2:
+ masked_local = local[0] + mask_char * (len(local) - 2) + local[-1]
+ else:
+ masked_local = mask_char * len(local)
+ return f"{masked_local}@{domain}"
+
+ # 如果是 token 或密钥,掩码大部分内容
+ if len(data) > 10:
+ return data[:4] + mask_char * (len(data) - 8) + data[-4:]
+ return mask_char * len(data)
+
+ elif isinstance(data, dict):
+ masked_dict = {}
+ for key, value in data.items():
+ # 敏感字段名
+ sensitive_keys = ["password", "token", "secret", "key", "auth", "credential"]
+ if any(sensitive in key.lower() for sensitive in sensitive_keys):
+ masked_dict[key] = mask_sensitive_data(value, mask_char)
+ else:
+ masked_dict[key] = value
+ return masked_dict
+
+ elif isinstance(data, list):
+ return [mask_sensitive_data(item, mask_char) for item in data]
+
+ return data
+
+
+def calculate_md5(data: Union[str, bytes]) -> str:
+ """
+ 计算 MD5 哈希
+
+ Args:
+ data: 要哈希的数据
+
+ Returns:
+ MD5 哈希字符串
+ """
+ if isinstance(data, str):
+ data = data.encode('utf-8')
+
+ return hashlib.md5(data).hexdigest()
+
+
+def calculate_sha256(data: Union[str, bytes]) -> str:
+ """
+ 计算 SHA256 哈希
+
+ Args:
+ data: 要哈希的数据
+
+ Returns:
+ SHA256 哈希字符串
+ """
+ if isinstance(data, str):
+ data = data.encode('utf-8')
+
+ return hashlib.sha256(data).hexdigest()
+
+
+def base64_encode(data: Union[str, bytes]) -> str:
+ """Base64 编码"""
+ if isinstance(data, str):
+ data = data.encode('utf-8')
+
+ return base64.b64encode(data).decode('utf-8')
+
+
+def base64_decode(data: str) -> str:
+ """Base64 解码"""
+ try:
+ decoded = base64.b64decode(data)
+ return decoded.decode('utf-8')
+ except (base64.binascii.Error, UnicodeDecodeError):
+ return ""
+
+
+class Timer:
+ """计时器上下文管理器"""
+
+ def __init__(self, name: str = "操作"):
+ self.name = name
+ self.start_time = None
+ self.elapsed = None
+
+ def __enter__(self):
+ self.start_time = time.time()
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ self.elapsed = time.time() - self.start_time
+ logger = logging.getLogger(__name__)
+ logger.debug(f"{self.name} 耗时: {self.elapsed:.2f} 秒")
+
+ def get_elapsed(self) -> float:
+ """获取经过的时间(秒)"""
+ if self.elapsed is not None:
+ return self.elapsed
+ if self.start_time is not None:
+ return time.time() - self.start_time
+ return 0.0
diff --git a/src/database/__init__.py b/src/database/__init__.py
new file mode 100644
index 0000000..1ee05b9
--- /dev/null
+++ b/src/database/__init__.py
@@ -0,0 +1,20 @@
+"""
+数据库模块
+"""
+
+from .models import Base, Account, EmailService, RegistrationTask, Setting
+from .session import get_db, init_database, get_session_manager, DatabaseSessionManager
+from . import crud
+
+__all__ = [
+ 'Base',
+ 'Account',
+ 'EmailService',
+ 'RegistrationTask',
+ 'Setting',
+ 'get_db',
+ 'init_database',
+ 'get_session_manager',
+ 'DatabaseSessionManager',
+ 'crud',
+]
diff --git a/src/database/crud.py b/src/database/crud.py
new file mode 100644
index 0000000..67d827e
--- /dev/null
+++ b/src/database/crud.py
@@ -0,0 +1,716 @@
+"""
+数据库 CRUD 操作
+"""
+
+from typing import List, Optional, Dict, Any, Union
+from datetime import datetime, timedelta
+from sqlalchemy.orm import Session
+from sqlalchemy import and_, or_, desc, asc, func
+
+from .models import Account, EmailService, RegistrationTask, Setting, Proxy, CpaService, Sub2ApiService
+
+
+# ============================================================================
+# 账户 CRUD
+# ============================================================================
+
+def create_account(
+ db: Session,
+ email: str,
+ email_service: str,
+ password: Optional[str] = None,
+ client_id: Optional[str] = None,
+ session_token: Optional[str] = None,
+ email_service_id: Optional[str] = None,
+ account_id: Optional[str] = None,
+ workspace_id: Optional[str] = None,
+ access_token: Optional[str] = None,
+ refresh_token: Optional[str] = None,
+ id_token: Optional[str] = None,
+ proxy_used: Optional[str] = None,
+ expires_at: Optional['datetime'] = None,
+ extra_data: Optional[Dict[str, Any]] = None,
+ status: Optional[str] = None,
+ source: Optional[str] = None
+) -> Account:
+ """创建新账户"""
+ db_account = Account(
+ email=email,
+ password=password,
+ client_id=client_id,
+ session_token=session_token,
+ email_service=email_service,
+ email_service_id=email_service_id,
+ account_id=account_id,
+ workspace_id=workspace_id,
+ access_token=access_token,
+ refresh_token=refresh_token,
+ id_token=id_token,
+ proxy_used=proxy_used,
+ expires_at=expires_at,
+ extra_data=extra_data or {},
+ status=status or 'active',
+ source=source or 'register',
+ registered_at=datetime.utcnow()
+ )
+ db.add(db_account)
+ db.commit()
+ db.refresh(db_account)
+ return db_account
+
+
+def get_account_by_id(db: Session, account_id: int) -> Optional[Account]:
+ """根据 ID 获取账户"""
+ return db.query(Account).filter(Account.id == account_id).first()
+
+
+def get_account_by_email(db: Session, email: str) -> Optional[Account]:
+ """根据邮箱获取账户"""
+ return db.query(Account).filter(Account.email == email).first()
+
+
+def get_accounts(
+ db: Session,
+ skip: int = 0,
+ limit: int = 100,
+ email_service: Optional[str] = None,
+ status: Optional[str] = None,
+ search: Optional[str] = None
+) -> List[Account]:
+ """获取账户列表(支持分页、筛选)"""
+ query = db.query(Account)
+
+ if email_service:
+ query = query.filter(Account.email_service == email_service)
+
+ if status:
+ query = query.filter(Account.status == status)
+
+ if search:
+ search_filter = or_(
+ Account.email.ilike(f"%{search}%"),
+ Account.account_id.ilike(f"%{search}%"),
+ Account.workspace_id.ilike(f"%{search}%")
+ )
+ query = query.filter(search_filter)
+
+ query = query.order_by(desc(Account.created_at)).offset(skip).limit(limit)
+ return query.all()
+
+
+def update_account(
+ db: Session,
+ account_id: int,
+ **kwargs
+) -> Optional[Account]:
+ """更新账户信息"""
+ db_account = get_account_by_id(db, account_id)
+ if not db_account:
+ return None
+
+ for key, value in kwargs.items():
+ if hasattr(db_account, key) and value is not None:
+ setattr(db_account, key, value)
+
+ db.commit()
+ db.refresh(db_account)
+ return db_account
+
+
+def delete_account(db: Session, account_id: int) -> bool:
+ """删除账户"""
+ db_account = get_account_by_id(db, account_id)
+ if not db_account:
+ return False
+
+ db.delete(db_account)
+ db.commit()
+ return True
+
+
+def delete_accounts_batch(db: Session, account_ids: List[int]) -> int:
+ """批量删除账户"""
+ result = db.query(Account).filter(Account.id.in_(account_ids)).delete(synchronize_session=False)
+ db.commit()
+ return result
+
+
+def get_accounts_count(
+ db: Session,
+ email_service: Optional[str] = None,
+ status: Optional[str] = None
+) -> int:
+ """获取账户数量"""
+ query = db.query(func.count(Account.id))
+
+ if email_service:
+ query = query.filter(Account.email_service == email_service)
+
+ if status:
+ query = query.filter(Account.status == status)
+
+ return query.scalar()
+
+
+# ============================================================================
+# 邮箱服务 CRUD
+# ============================================================================
+
+def create_email_service(
+ db: Session,
+ service_type: str,
+ name: str,
+ config: Dict[str, Any],
+ enabled: bool = True,
+ priority: int = 0
+) -> EmailService:
+ """创建邮箱服务配置"""
+ db_service = EmailService(
+ service_type=service_type,
+ name=name,
+ config=config,
+ enabled=enabled,
+ priority=priority
+ )
+ db.add(db_service)
+ db.commit()
+ db.refresh(db_service)
+ return db_service
+
+
+def get_email_service_by_id(db: Session, service_id: int) -> Optional[EmailService]:
+ """根据 ID 获取邮箱服务"""
+ return db.query(EmailService).filter(EmailService.id == service_id).first()
+
+
+def get_email_services(
+ db: Session,
+ service_type: Optional[str] = None,
+ enabled: Optional[bool] = None,
+ skip: int = 0,
+ limit: int = 100
+) -> List[EmailService]:
+ """获取邮箱服务列表"""
+ query = db.query(EmailService)
+
+ if service_type:
+ query = query.filter(EmailService.service_type == service_type)
+
+ if enabled is not None:
+ query = query.filter(EmailService.enabled == enabled)
+
+ query = query.order_by(
+ asc(EmailService.priority),
+ desc(EmailService.last_used)
+ ).offset(skip).limit(limit)
+
+ return query.all()
+
+
+def update_email_service(
+ db: Session,
+ service_id: int,
+ **kwargs
+) -> Optional[EmailService]:
+ """更新邮箱服务配置"""
+ db_service = get_email_service_by_id(db, service_id)
+ if not db_service:
+ return None
+
+ for key, value in kwargs.items():
+ if hasattr(db_service, key) and value is not None:
+ setattr(db_service, key, value)
+
+ db.commit()
+ db.refresh(db_service)
+ return db_service
+
+
+def delete_email_service(db: Session, service_id: int) -> bool:
+ """删除邮箱服务配置"""
+ db_service = get_email_service_by_id(db, service_id)
+ if not db_service:
+ return False
+
+ db.delete(db_service)
+ db.commit()
+ return True
+
+
+# ============================================================================
+# 注册任务 CRUD
+# ============================================================================
+
+def create_registration_task(
+ db: Session,
+ task_uuid: str,
+ email_service_id: Optional[int] = None,
+ proxy: Optional[str] = None
+) -> RegistrationTask:
+ """创建注册任务"""
+ db_task = RegistrationTask(
+ task_uuid=task_uuid,
+ email_service_id=email_service_id,
+ proxy=proxy,
+ status='pending'
+ )
+ db.add(db_task)
+ db.commit()
+ db.refresh(db_task)
+ return db_task
+
+
+def get_registration_task_by_uuid(db: Session, task_uuid: str) -> Optional[RegistrationTask]:
+ """根据 UUID 获取注册任务"""
+ return db.query(RegistrationTask).filter(RegistrationTask.task_uuid == task_uuid).first()
+
+
+def get_registration_tasks(
+ db: Session,
+ status: Optional[str] = None,
+ skip: int = 0,
+ limit: int = 100
+) -> List[RegistrationTask]:
+ """获取注册任务列表"""
+ query = db.query(RegistrationTask)
+
+ if status:
+ query = query.filter(RegistrationTask.status == status)
+
+ query = query.order_by(desc(RegistrationTask.created_at)).offset(skip).limit(limit)
+ return query.all()
+
+
+def update_registration_task(
+ db: Session,
+ task_uuid: str,
+ **kwargs
+) -> Optional[RegistrationTask]:
+ """更新注册任务状态"""
+ db_task = get_registration_task_by_uuid(db, task_uuid)
+ if not db_task:
+ return None
+
+ for key, value in kwargs.items():
+ if hasattr(db_task, key):
+ setattr(db_task, key, value)
+
+ db.commit()
+ db.refresh(db_task)
+ return db_task
+
+
+def append_task_log(db: Session, task_uuid: str, log_message: str) -> bool:
+ """追加任务日志"""
+ db_task = get_registration_task_by_uuid(db, task_uuid)
+ if not db_task:
+ return False
+
+ if db_task.logs:
+ db_task.logs += f"\n{log_message}"
+ else:
+ db_task.logs = log_message
+
+ db.commit()
+ return True
+
+
+def delete_registration_task(db: Session, task_uuid: str) -> bool:
+ """删除注册任务"""
+ db_task = get_registration_task_by_uuid(db, task_uuid)
+ if not db_task:
+ return False
+
+ db.delete(db_task)
+ db.commit()
+ return True
+
+
+# 为 API 路由添加别名
+get_account = get_account_by_id
+get_registration_task = get_registration_task_by_uuid
+
+
+# ============================================================================
+# 设置 CRUD
+# ============================================================================
+
+def get_setting(db: Session, key: str) -> Optional[Setting]:
+ """获取设置"""
+ return db.query(Setting).filter(Setting.key == key).first()
+
+
+def get_settings_by_category(db: Session, category: str) -> List[Setting]:
+ """根据分类获取设置"""
+ return db.query(Setting).filter(Setting.category == category).all()
+
+
+def set_setting(
+ db: Session,
+ key: str,
+ value: str,
+ description: Optional[str] = None,
+ category: str = 'general'
+) -> Setting:
+ """设置或更新配置项"""
+ db_setting = get_setting(db, key)
+ if db_setting:
+ db_setting.value = value
+ db_setting.description = description or db_setting.description
+ db_setting.category = category
+ db_setting.updated_at = datetime.utcnow()
+ else:
+ db_setting = Setting(
+ key=key,
+ value=value,
+ description=description,
+ category=category
+ )
+ db.add(db_setting)
+
+ db.commit()
+ db.refresh(db_setting)
+ return db_setting
+
+
+def delete_setting(db: Session, key: str) -> bool:
+ """删除设置"""
+ db_setting = get_setting(db, key)
+ if not db_setting:
+ return False
+
+ db.delete(db_setting)
+ db.commit()
+ return True
+
+
+# ============================================================================
+# 代理 CRUD
+# ============================================================================
+
+def create_proxy(
+ db: Session,
+ name: str,
+ type: str,
+ host: str,
+ port: int,
+ username: Optional[str] = None,
+ password: Optional[str] = None,
+ enabled: bool = True,
+ priority: int = 0
+) -> Proxy:
+ """创建代理配置"""
+ db_proxy = Proxy(
+ name=name,
+ type=type,
+ host=host,
+ port=port,
+ username=username,
+ password=password,
+ enabled=enabled,
+ priority=priority
+ )
+ db.add(db_proxy)
+ db.commit()
+ db.refresh(db_proxy)
+ return db_proxy
+
+
+def get_proxy_by_id(db: Session, proxy_id: int) -> Optional[Proxy]:
+ """根据 ID 获取代理"""
+ return db.query(Proxy).filter(Proxy.id == proxy_id).first()
+
+
+def get_proxies(
+ db: Session,
+ enabled: Optional[bool] = None,
+ skip: int = 0,
+ limit: int = 100
+) -> List[Proxy]:
+ """获取代理列表"""
+ query = db.query(Proxy)
+
+ if enabled is not None:
+ query = query.filter(Proxy.enabled == enabled)
+
+ query = query.order_by(desc(Proxy.created_at)).offset(skip).limit(limit)
+ return query.all()
+
+
+def get_enabled_proxies(db: Session) -> List[Proxy]:
+ """获取所有启用的代理"""
+ return db.query(Proxy).filter(Proxy.enabled == True).all()
+
+
+def update_proxy(
+ db: Session,
+ proxy_id: int,
+ **kwargs
+) -> Optional[Proxy]:
+ """更新代理配置"""
+ db_proxy = get_proxy_by_id(db, proxy_id)
+ if not db_proxy:
+ return None
+
+ for key, value in kwargs.items():
+ if hasattr(db_proxy, key):
+ setattr(db_proxy, key, value)
+
+ db.commit()
+ db.refresh(db_proxy)
+ return db_proxy
+
+
+def delete_proxy(db: Session, proxy_id: int) -> bool:
+ """删除代理配置"""
+ db_proxy = get_proxy_by_id(db, proxy_id)
+ if not db_proxy:
+ return False
+
+ db.delete(db_proxy)
+ db.commit()
+ return True
+
+
+def update_proxy_last_used(db: Session, proxy_id: int) -> bool:
+ """更新代理最后使用时间"""
+ db_proxy = get_proxy_by_id(db, proxy_id)
+ if not db_proxy:
+ return False
+
+ db_proxy.last_used = datetime.utcnow()
+ db.commit()
+ return True
+
+
+def get_random_proxy(db: Session) -> Optional[Proxy]:
+ """随机获取一个启用的代理,优先返回 is_default=True 的代理"""
+ import random
+ # 优先返回默认代理
+ default_proxy = db.query(Proxy).filter(Proxy.enabled == True, Proxy.is_default == True).first()
+ if default_proxy:
+ return default_proxy
+ proxies = get_enabled_proxies(db)
+ if not proxies:
+ return None
+ return random.choice(proxies)
+
+
+def set_proxy_default(db: Session, proxy_id: int) -> Optional[Proxy]:
+ """将指定代理设为默认,同时清除其他代理的默认标记"""
+ # 清除所有默认标记
+ db.query(Proxy).filter(Proxy.is_default == True).update({"is_default": False})
+ # 设置新的默认代理
+ proxy = db.query(Proxy).filter(Proxy.id == proxy_id).first()
+ if proxy:
+ proxy.is_default = True
+ db.commit()
+ db.refresh(proxy)
+ return proxy
+
+
+def get_proxies_count(db: Session, enabled: Optional[bool] = None) -> int:
+ """获取代理数量"""
+ query = db.query(func.count(Proxy.id))
+ if enabled is not None:
+ query = query.filter(Proxy.enabled == enabled)
+ return query.scalar()
+
+
+# ============================================================================
+# CPA 服务 CRUD
+# ============================================================================
+
+def create_cpa_service(
+ db: Session,
+ name: str,
+ api_url: str,
+ api_token: str,
+ enabled: bool = True,
+ include_proxy_url: bool = False,
+ priority: int = 0
+) -> CpaService:
+ """创建 CPA 服务配置"""
+ db_service = CpaService(
+ name=name,
+ api_url=api_url,
+ api_token=api_token,
+ enabled=enabled,
+ include_proxy_url=include_proxy_url,
+ priority=priority
+ )
+ db.add(db_service)
+ db.commit()
+ db.refresh(db_service)
+ return db_service
+
+
+def get_cpa_service_by_id(db: Session, service_id: int) -> Optional[CpaService]:
+ """根据 ID 获取 CPA 服务"""
+ return db.query(CpaService).filter(CpaService.id == service_id).first()
+
+
+def get_cpa_services(
+ db: Session,
+ enabled: Optional[bool] = None
+) -> List[CpaService]:
+ """获取 CPA 服务列表"""
+ query = db.query(CpaService)
+ if enabled is not None:
+ query = query.filter(CpaService.enabled == enabled)
+ return query.order_by(asc(CpaService.priority), asc(CpaService.id)).all()
+
+
+def update_cpa_service(
+ db: Session,
+ service_id: int,
+ **kwargs
+) -> Optional[CpaService]:
+ """更新 CPA 服务配置"""
+ db_service = get_cpa_service_by_id(db, service_id)
+ if not db_service:
+ return None
+ for key, value in kwargs.items():
+ if hasattr(db_service, key):
+ setattr(db_service, key, value)
+ db.commit()
+ db.refresh(db_service)
+ return db_service
+
+
+def delete_cpa_service(db: Session, service_id: int) -> bool:
+ """删除 CPA 服务配置"""
+ db_service = get_cpa_service_by_id(db, service_id)
+ if not db_service:
+ return False
+ db.delete(db_service)
+ db.commit()
+ return True
+
+
+# ============================================================================
+# Sub2API 服务 CRUD
+# ============================================================================
+
+def create_sub2api_service(
+ db: Session,
+ name: str,
+ api_url: str,
+ api_key: str,
+ enabled: bool = True,
+ priority: int = 0
+) -> Sub2ApiService:
+ """创建 Sub2API 服务配置"""
+ svc = Sub2ApiService(
+ name=name,
+ api_url=api_url,
+ api_key=api_key,
+ enabled=enabled,
+ priority=priority,
+ )
+ db.add(svc)
+ db.commit()
+ db.refresh(svc)
+ return svc
+
+
+def get_sub2api_service_by_id(db: Session, service_id: int) -> Optional[Sub2ApiService]:
+ """按 ID 获取 Sub2API 服务"""
+ return db.query(Sub2ApiService).filter(Sub2ApiService.id == service_id).first()
+
+
+def get_sub2api_services(
+ db: Session,
+ enabled: Optional[bool] = None
+) -> List[Sub2ApiService]:
+ """获取 Sub2API 服务列表"""
+ query = db.query(Sub2ApiService)
+ if enabled is not None:
+ query = query.filter(Sub2ApiService.enabled == enabled)
+ return query.order_by(asc(Sub2ApiService.priority), asc(Sub2ApiService.id)).all()
+
+
+def update_sub2api_service(db: Session, service_id: int, **kwargs) -> Optional[Sub2ApiService]:
+ """更新 Sub2API 服务配置"""
+ svc = get_sub2api_service_by_id(db, service_id)
+ if not svc:
+ return None
+ for key, value in kwargs.items():
+ setattr(svc, key, value)
+ db.commit()
+ db.refresh(svc)
+ return svc
+
+
+def delete_sub2api_service(db: Session, service_id: int) -> bool:
+ """删除 Sub2API 服务配置"""
+ svc = get_sub2api_service_by_id(db, service_id)
+ if not svc:
+ return False
+ db.delete(svc)
+ db.commit()
+ return True
+
+
+# ============================================================================
+# Team Manager 服务 CRUD
+# ============================================================================
+
+def create_tm_service(
+ db: Session,
+ name: str,
+ api_url: str,
+ api_key: str,
+ enabled: bool = True,
+ priority: int = 0,
+):
+ """创建 Team Manager 服务配置"""
+ from .models import TeamManagerService
+ svc = TeamManagerService(
+ name=name,
+ api_url=api_url,
+ api_key=api_key,
+ enabled=enabled,
+ priority=priority,
+ )
+ db.add(svc)
+ db.commit()
+ db.refresh(svc)
+ return svc
+
+
+def get_tm_service_by_id(db: Session, service_id: int):
+ """按 ID 获取 Team Manager 服务"""
+ from .models import TeamManagerService
+ return db.query(TeamManagerService).filter(TeamManagerService.id == service_id).first()
+
+
+def get_tm_services(db: Session, enabled=None):
+ """获取 Team Manager 服务列表"""
+ from .models import TeamManagerService
+ q = db.query(TeamManagerService)
+ if enabled is not None:
+ q = q.filter(TeamManagerService.enabled == enabled)
+ return q.order_by(TeamManagerService.priority.asc(), TeamManagerService.id.asc()).all()
+
+
+def update_tm_service(db: Session, service_id: int, **kwargs):
+ """更新 Team Manager 服务配置"""
+ svc = get_tm_service_by_id(db, service_id)
+ if not svc:
+ return None
+ for k, v in kwargs.items():
+ setattr(svc, k, v)
+ db.commit()
+ db.refresh(svc)
+ return svc
+
+
+def delete_tm_service(db: Session, service_id: int) -> bool:
+ """删除 Team Manager 服务配置"""
+ svc = get_tm_service_by_id(db, service_id)
+ if not svc:
+ return False
+ db.delete(svc)
+ db.commit()
+ return True
\ No newline at end of file
diff --git a/src/database/init_db.py b/src/database/init_db.py
new file mode 100644
index 0000000..58ea4b0
--- /dev/null
+++ b/src/database/init_db.py
@@ -0,0 +1,86 @@
+"""
+数据库初始化和初始化数据
+"""
+
+from .session import init_database
+from .models import Base
+
+
+def initialize_database(database_url: str = None):
+ """
+ 初始化数据库
+ 创建所有表并设置默认配置
+ """
+ # 初始化数据库连接和表
+ db_manager = init_database(database_url)
+
+ # 创建表
+ db_manager.create_tables()
+
+ # 初始化默认设置(从 settings 模块导入以避免循环导入)
+ from ..config.settings import init_default_settings
+ init_default_settings()
+
+ return db_manager
+
+
+def reset_database(database_url: str = None):
+ """
+ 重置数据库(删除所有表并重新创建)
+ 警告:会丢失所有数据!
+ """
+ db_manager = init_database(database_url)
+
+ # 删除所有表
+ db_manager.drop_tables()
+ print("已删除所有表")
+
+ # 重新创建所有表
+ db_manager.create_tables()
+ print("已重新创建所有表")
+
+ # 初始化默认设置
+ from ..config.settings import init_default_settings
+ init_default_settings()
+
+ print("数据库重置完成")
+ return db_manager
+
+
+def check_database_connection(database_url: str = None) -> bool:
+ """
+ 检查数据库连接是否正常
+ """
+ try:
+ db_manager = init_database(database_url)
+ with db_manager.get_db() as db:
+ # 尝试执行一个简单的查询
+ db.execute("SELECT 1")
+ print("数据库连接正常")
+ return True
+ except Exception as e:
+ print(f"数据库连接失败: {e}")
+ return False
+
+
+if __name__ == "__main__":
+ # 当直接运行此脚本时,初始化数据库
+ import argparse
+
+ parser = argparse.ArgumentParser(description="数据库初始化脚本")
+ parser.add_argument("--reset", action="store_true", help="重置数据库(删除所有数据)")
+ parser.add_argument("--check", action="store_true", help="检查数据库连接")
+ parser.add_argument("--url", help="数据库连接字符串")
+
+ args = parser.parse_args()
+
+ if args.check:
+ check_database_connection(args.url)
+ elif args.reset:
+ confirm = input("警告:这将删除所有数据!确认重置?(y/N): ")
+ if confirm.lower() == 'y':
+ reset_database(args.url)
+ else:
+ print("操作已取消")
+ else:
+ initialize_database(args.url)
diff --git a/src/database/models.py b/src/database/models.py
new file mode 100644
index 0000000..216f7d8
--- /dev/null
+++ b/src/database/models.py
@@ -0,0 +1,230 @@
+"""
+SQLAlchemy ORM 模型定义
+"""
+
+from datetime import datetime
+from typing import Optional, Dict, Any
+import json
+from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey
+from sqlalchemy.ext.declarative import declarative_base
+from sqlalchemy.types import TypeDecorator
+from sqlalchemy.orm import relationship
+
+Base = declarative_base()
+
+
+class JSONEncodedDict(TypeDecorator):
+ """JSON 编码字典类型"""
+ impl = Text
+
+ def process_bind_param(self, value: Optional[Dict[str, Any]], dialect):
+ if value is None:
+ return None
+ return json.dumps(value, ensure_ascii=False)
+
+ def process_result_value(self, value: Optional[str], dialect):
+ if value is None:
+ return None
+ return json.loads(value)
+
+
+class Account(Base):
+ """已注册账号表"""
+ __tablename__ = 'accounts'
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ email = Column(String(255), nullable=False, unique=True, index=True)
+ password = Column(String(255)) # 注册密码(明文存储)
+ access_token = Column(Text)
+ refresh_token = Column(Text)
+ id_token = Column(Text)
+ session_token = Column(Text) # 会话令牌(优先刷新方式)
+ client_id = Column(String(255)) # OAuth Client ID
+ account_id = Column(String(255))
+ workspace_id = Column(String(255))
+ email_service = Column(String(50), nullable=False) # 'tempmail', 'outlook', 'moe_mail'
+ email_service_id = Column(String(255)) # 邮箱服务中的ID
+ proxy_used = Column(String(255))
+ registered_at = Column(DateTime, default=datetime.utcnow)
+ last_refresh = Column(DateTime) # 最后刷新时间
+ expires_at = Column(DateTime) # Token 过期时间
+ status = Column(String(20), default='active') # 'active', 'expired', 'banned', 'failed'
+ extra_data = Column(JSONEncodedDict) # 额外信息存储
+ 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)
+
+ def to_dict(self) -> Dict[str, Any]:
+ """转换为字典"""
+ return {
+ 'id': self.id,
+ 'email': self.email,
+ 'password': self.password,
+ 'client_id': self.client_id,
+ 'email_service': self.email_service,
+ 'account_id': self.account_id,
+ 'workspace_id': self.workspace_id,
+ 'registered_at': self.registered_at.isoformat() if self.registered_at else None,
+ 'last_refresh': self.last_refresh.isoformat() if self.last_refresh else None,
+ 'expires_at': self.expires_at.isoformat() if self.expires_at else None,
+ 'status': self.status,
+ 'proxy_used': self.proxy_used,
+ '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
+ }
+
+
+class EmailService(Base):
+ """邮箱服务配置表"""
+ __tablename__ = 'email_services'
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ service_type = Column(String(50), nullable=False) # 'outlook', 'moe_mail'
+ name = Column(String(100), nullable=False)
+ config = Column(JSONEncodedDict, nullable=False) # 服务配置(加密存储)
+ enabled = Column(Boolean, default=True)
+ priority = Column(Integer, default=0) # 使用优先级
+ last_used = Column(DateTime)
+ created_at = Column(DateTime, default=datetime.utcnow)
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+
+
+class RegistrationTask(Base):
+ """注册任务表"""
+ __tablename__ = 'registration_tasks'
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ task_uuid = Column(String(36), unique=True, nullable=False, index=True) # 任务唯一标识
+ status = Column(String(20), default='pending') # 'pending', 'running', 'completed', 'failed', 'cancelled'
+ email_service_id = Column(Integer, ForeignKey('email_services.id'), index=True) # 使用的邮箱服务
+ proxy = Column(String(255)) # 使用的代理
+ logs = Column(Text) # 注册过程日志
+ result = Column(JSONEncodedDict) # 注册结果
+ error_message = Column(Text)
+ created_at = Column(DateTime, default=datetime.utcnow)
+ started_at = Column(DateTime)
+ completed_at = Column(DateTime)
+
+ # 关系
+ email_service = relationship('EmailService')
+
+
+class Setting(Base):
+ """系统设置表"""
+ __tablename__ = 'settings'
+
+ key = Column(String(100), primary_key=True)
+ value = Column(Text)
+ description = Column(Text)
+ category = Column(String(50), default='general') # 'general', 'email', 'proxy', 'openai'
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+
+
+class CpaService(Base):
+ """CPA 服务配置表"""
+ __tablename__ = 'cpa_services'
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ name = Column(String(100), nullable=False) # 服务名称
+ api_url = Column(String(500), nullable=False) # API URL
+ api_token = Column(Text, nullable=False) # API Token
+ enabled = Column(Boolean, default=True)
+ include_proxy_url = Column(Boolean, default=False) # 是否将账号代理写入 auth file
+ priority = Column(Integer, default=0) # 优先级
+ created_at = Column(DateTime, default=datetime.utcnow)
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+
+
+class Sub2ApiService(Base):
+ """Sub2API 服务配置表"""
+ __tablename__ = 'sub2api_services'
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ name = Column(String(100), nullable=False) # 服务名称
+ api_url = Column(String(500), nullable=False) # API URL (host)
+ api_key = Column(Text, nullable=False) # x-api-key
+ enabled = Column(Boolean, default=True)
+ priority = Column(Integer, default=0) # 优先级
+ created_at = Column(DateTime, default=datetime.utcnow)
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+
+
+class TeamManagerService(Base):
+ """Team Manager 服务配置表"""
+ __tablename__ = 'tm_services'
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ name = Column(String(100), nullable=False) # 服务名称
+ api_url = Column(String(500), nullable=False) # API URL
+ api_key = Column(Text, nullable=False) # X-API-Key
+ enabled = Column(Boolean, default=True)
+ priority = Column(Integer, default=0) # 优先级
+ created_at = Column(DateTime, default=datetime.utcnow)
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+
+
+class Proxy(Base):
+ """代理列表表"""
+ __tablename__ = 'proxies'
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ name = Column(String(100), nullable=False) # 代理名称
+ type = Column(String(20), nullable=False, default='http') # http, socks5
+ host = Column(String(255), nullable=False)
+ port = Column(Integer, nullable=False)
+ username = Column(String(100))
+ password = Column(String(255))
+ enabled = Column(Boolean, default=True)
+ is_default = Column(Boolean, default=False) # 是否为默认代理
+ priority = Column(Integer, default=0) # 优先级(保留字段)
+ last_used = Column(DateTime) # 最后使用时间
+ created_at = Column(DateTime, default=datetime.utcnow)
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+
+ def to_dict(self, include_password: bool = False) -> Dict[str, Any]:
+ """转换为字典"""
+ result = {
+ 'id': self.id,
+ 'name': self.name,
+ 'type': self.type,
+ 'host': self.host,
+ 'port': self.port,
+ 'username': self.username,
+ 'enabled': self.enabled,
+ 'is_default': self.is_default or False,
+ 'priority': self.priority,
+ 'last_used': self.last_used.isoformat() if self.last_used 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,
+ }
+ if include_password:
+ result['password'] = self.password
+ else:
+ result['has_password'] = bool(self.password)
+ return result
+
+ @property
+ def proxy_url(self) -> str:
+ """获取完整的代理 URL"""
+ if self.type == "http":
+ scheme = "http"
+ elif self.type == "socks5":
+ scheme = "socks5"
+ else:
+ scheme = self.type
+
+ auth = ""
+ if self.username and self.password:
+ auth = f"{self.username}:{self.password}@"
+
+ return f"{scheme}://{auth}{self.host}:{self.port}"
\ No newline at end of file
diff --git a/src/database/session.py b/src/database/session.py
new file mode 100644
index 0000000..bb45334
--- /dev/null
+++ b/src/database/session.py
@@ -0,0 +1,183 @@
+"""
+数据库会话管理
+"""
+
+from contextlib import contextmanager
+from typing import Generator
+from sqlalchemy import create_engine, text
+from sqlalchemy.orm import sessionmaker, Session
+from sqlalchemy.exc import SQLAlchemyError
+import os
+import logging
+
+from .models import Base
+
+logger = logging.getLogger(__name__)
+
+
+def _build_sqlalchemy_url(database_url: str) -> str:
+ if database_url.startswith("postgresql://"):
+ return "postgresql+psycopg://" + database_url[len("postgresql://"):]
+ if database_url.startswith("postgres://"):
+ return "postgresql+psycopg://" + database_url[len("postgres://"):]
+ return database_url
+
+
+class DatabaseSessionManager:
+ """数据库会话管理器"""
+
+ def __init__(self, database_url: str = None):
+ if database_url is None:
+ env_url = os.environ.get("APP_DATABASE_URL") or os.environ.get("DATABASE_URL")
+ if env_url:
+ database_url = env_url
+ else:
+ # 优先使用 APP_DATA_DIR 环境变量(PyInstaller 打包后由 webui.py 设置)
+ data_dir = os.environ.get('APP_DATA_DIR') or os.path.join(
+ os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
+ 'data'
+ )
+ db_path = os.path.join(data_dir, 'database.db')
+ # 确保目录存在
+ os.makedirs(data_dir, exist_ok=True)
+ database_url = f"sqlite:///{db_path}"
+
+ self.database_url = _build_sqlalchemy_url(database_url)
+ self.engine = create_engine(
+ self.database_url,
+ connect_args={"check_same_thread": False} if self.database_url.startswith("sqlite") else {},
+ echo=False, # 设置为 True 可以查看所有 SQL 语句
+ pool_pre_ping=True # 连接池预检查
+ )
+ self.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=self.engine)
+
+ def get_db(self) -> Generator[Session, None, None]:
+ """
+ 获取数据库会话的上下文管理器
+ 使用示例:
+ with get_db() as db:
+ # 使用 db 进行数据库操作
+ pass
+ """
+ db = self.SessionLocal()
+ try:
+ yield db
+ finally:
+ db.close()
+
+ @contextmanager
+ def session_scope(self) -> Generator[Session, None, None]:
+ """
+ 事务作用域上下文管理器
+ 使用示例:
+ with session_scope() as session:
+ # 数据库操作
+ pass
+ """
+ session = self.SessionLocal()
+ try:
+ yield session
+ session.commit()
+ except Exception as e:
+ session.rollback()
+ raise e
+ finally:
+ session.close()
+
+ def create_tables(self):
+ """创建所有表"""
+ Base.metadata.create_all(bind=self.engine)
+
+ def drop_tables(self):
+ """删除所有表(谨慎使用)"""
+ Base.metadata.drop_all(bind=self.engine)
+
+ def migrate_tables(self):
+ """
+ 数据库迁移 - 添加缺失的列
+ 用于在不删除数据的情况下更新表结构
+ """
+ if not self.database_url.startswith("sqlite"):
+ logger.info("非 SQLite 数据库,跳过自动迁移")
+ return
+
+ # 需要检查和添加的新列
+ migrations = [
+ # (表名, 列名, 列类型)
+ ("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"),
+ ("proxies", "is_default", "BOOLEAN DEFAULT 0"),
+ ("cpa_services", "include_proxy_url", "BOOLEAN DEFAULT 0"),
+ ]
+
+ # 确保新表存在(create_tables 已处理,此处兜底)
+ Base.metadata.create_all(bind=self.engine)
+
+ with self.engine.connect() as conn:
+ # 数据迁移:将旧的 custom_domain 记录统一为 moe_mail
+ try:
+ conn.execute(text("UPDATE email_services SET service_type='moe_mail' WHERE service_type='custom_domain'"))
+ conn.execute(text("UPDATE accounts SET email_service='moe_mail' WHERE email_service='custom_domain'"))
+ conn.commit()
+ except Exception as e:
+ logger.warning(f"迁移 custom_domain -> moe_mail 时出错: {e}")
+
+ for table_name, column_name, column_type in migrations:
+ try:
+ # 检查列是否存在
+ result = conn.execute(text(
+ f"SELECT * FROM pragma_table_info('{table_name}') WHERE name='{column_name}'"
+ ))
+ if result.fetchone() is None:
+ # 列不存在,添加它
+ logger.info(f"添加列 {table_name}.{column_name}")
+ conn.execute(text(
+ f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_type}"
+ ))
+ conn.commit()
+ logger.info(f"成功添加列 {table_name}.{column_name}")
+ except Exception as e:
+ logger.warning(f"迁移列 {table_name}.{column_name} 时出错: {e}")
+
+
+# 全局数据库会话管理器实例
+_db_manager: DatabaseSessionManager = None
+
+
+def init_database(database_url: str = None) -> DatabaseSessionManager:
+ """
+ 初始化数据库会话管理器
+ """
+ global _db_manager
+ if _db_manager is None:
+ _db_manager = DatabaseSessionManager(database_url)
+ _db_manager.create_tables()
+ # 执行数据库迁移
+ _db_manager.migrate_tables()
+ return _db_manager
+
+
+def get_session_manager() -> DatabaseSessionManager:
+ """
+ 获取数据库会话管理器
+ """
+ if _db_manager is None:
+ raise RuntimeError("数据库未初始化,请先调用 init_database()")
+ return _db_manager
+
+
+@contextmanager
+def get_db() -> Generator[Session, None, None]:
+ """
+ 获取数据库会话的快捷函数
+ """
+ manager = get_session_manager()
+ db = manager.SessionLocal()
+ try:
+ yield db
+ finally:
+ db.close()
diff --git a/src/services/__init__.py b/src/services/__init__.py
new file mode 100644
index 0000000..ad29d3e
--- /dev/null
+++ b/src/services/__init__.py
@@ -0,0 +1,73 @@
+"""
+邮箱服务模块
+"""
+
+from .base import (
+ BaseEmailService,
+ EmailServiceError,
+ EmailServiceStatus,
+ EmailServiceFactory,
+ create_email_service,
+ EmailServiceType
+)
+from .tempmail import TempmailService
+from .outlook import OutlookService
+from .moe_mail import MeoMailEmailService
+from .temp_mail import TempMailService
+from .duck_mail import DuckMailService
+from .freemail import FreemailService
+from .imap_mail import ImapMailService
+
+# 注册服务
+EmailServiceFactory.register(EmailServiceType.TEMPMAIL, TempmailService)
+EmailServiceFactory.register(EmailServiceType.OUTLOOK, OutlookService)
+EmailServiceFactory.register(EmailServiceType.MOE_MAIL, MeoMailEmailService)
+EmailServiceFactory.register(EmailServiceType.TEMP_MAIL, TempMailService)
+EmailServiceFactory.register(EmailServiceType.DUCK_MAIL, DuckMailService)
+EmailServiceFactory.register(EmailServiceType.FREEMAIL, FreemailService)
+EmailServiceFactory.register(EmailServiceType.IMAP_MAIL, ImapMailService)
+
+# 导出 Outlook 模块的额外内容
+from .outlook.base import (
+ ProviderType,
+ EmailMessage,
+ TokenInfo,
+ ProviderHealth,
+ ProviderStatus,
+)
+from .outlook.account import OutlookAccount
+from .outlook.providers import (
+ OutlookProvider,
+ IMAPOldProvider,
+ IMAPNewProvider,
+ GraphAPIProvider,
+)
+
+__all__ = [
+ # 基类
+ 'BaseEmailService',
+ 'EmailServiceError',
+ 'EmailServiceStatus',
+ 'EmailServiceFactory',
+ 'create_email_service',
+ 'EmailServiceType',
+ # 服务类
+ 'TempmailService',
+ 'OutlookService',
+ 'MeoMailEmailService',
+ 'TempMailService',
+ 'DuckMailService',
+ 'FreemailService',
+ 'ImapMailService',
+ # Outlook 模块
+ 'ProviderType',
+ 'EmailMessage',
+ 'TokenInfo',
+ 'ProviderHealth',
+ 'ProviderStatus',
+ 'OutlookAccount',
+ 'OutlookProvider',
+ 'IMAPOldProvider',
+ 'IMAPNewProvider',
+ 'GraphAPIProvider',
+]
diff --git a/src/services/base.py b/src/services/base.py
new file mode 100644
index 0000000..aba923f
--- /dev/null
+++ b/src/services/base.py
@@ -0,0 +1,386 @@
+"""
+邮箱服务抽象基类
+所有邮箱服务实现的基类
+"""
+
+import abc
+import logging
+from typing import Optional, Dict, Any, List
+from enum import Enum
+
+from ..config.constants import EmailServiceType
+
+
+logger = logging.getLogger(__name__)
+
+
+class EmailServiceError(Exception):
+ """邮箱服务异常"""
+ pass
+
+
+class EmailServiceStatus(Enum):
+ """邮箱服务状态"""
+ HEALTHY = "healthy"
+ DEGRADED = "degraded"
+ UNAVAILABLE = "unavailable"
+
+
+class BaseEmailService(abc.ABC):
+ """
+ 邮箱服务抽象基类
+
+ 所有邮箱服务必须实现此接口
+ """
+
+ def __init__(self, service_type: EmailServiceType, name: str = None):
+ """
+ 初始化邮箱服务
+
+ Args:
+ service_type: 服务类型
+ name: 服务名称
+ """
+ self.service_type = service_type
+ self.name = name or f"{service_type.value}_service"
+ self._status = EmailServiceStatus.HEALTHY
+ self._last_error = None
+
+ @property
+ def status(self) -> EmailServiceStatus:
+ """获取服务状态"""
+ return self._status
+
+ @property
+ def last_error(self) -> Optional[str]:
+ """获取最后一次错误信息"""
+ return self._last_error
+
+ @abc.abstractmethod
+ def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]:
+ """
+ 创建新邮箱地址
+
+ Args:
+ config: 配置参数,如邮箱前缀、域名等
+
+ Returns:
+ 包含邮箱信息的字典,至少包含:
+ - email: 邮箱地址
+ - service_id: 邮箱服务中的 ID
+ - token/credentials: 访问凭证(如果需要)
+
+ Raises:
+ EmailServiceError: 创建失败
+ """
+ pass
+
+ @abc.abstractmethod
+ def get_verification_code(
+ self,
+ email: str,
+ email_id: str = None,
+ timeout: int = 120,
+ pattern: str = r"(? Optional[str]:
+ """
+ 获取验证码
+
+ Args:
+ email: 邮箱地址
+ email_id: 邮箱服务中的 ID(如果需要)
+ timeout: 超时时间(秒)
+ pattern: 验证码正则表达式
+ otp_sent_at: OTP 发送时间戳,用于过滤旧邮件
+
+ Returns:
+ 验证码字符串,如果超时或未找到返回 None
+
+ Raises:
+ EmailServiceError: 服务错误
+ """
+ pass
+
+ @abc.abstractmethod
+ def list_emails(self, **kwargs) -> List[Dict[str, Any]]:
+ """
+ 列出所有邮箱(如果服务支持)
+
+ Args:
+ **kwargs: 其他参数
+
+ Returns:
+ 邮箱列表
+
+ Raises:
+ EmailServiceError: 服务错误
+ """
+ pass
+
+ @abc.abstractmethod
+ def delete_email(self, email_id: str) -> bool:
+ """
+ 删除邮箱
+
+ Args:
+ email_id: 邮箱服务中的 ID
+
+ Returns:
+ 是否删除成功
+
+ Raises:
+ EmailServiceError: 服务错误
+ """
+ pass
+
+ @abc.abstractmethod
+ def check_health(self) -> bool:
+ """
+ 检查服务健康状态
+
+ Returns:
+ 服务是否健康
+
+ Note:
+ 此方法不应抛出异常,应捕获异常并返回 False
+ """
+ pass
+
+ def get_email_info(self, email_id: str) -> Optional[Dict[str, Any]]:
+ """
+ 获取邮箱信息(可选实现)
+
+ Args:
+ email_id: 邮箱服务中的 ID
+
+ Returns:
+ 邮箱信息字典,如果不存在返回 None
+ """
+ # 默认实现:遍历列表查找
+ for email_info in self.list_emails():
+ if email_info.get("id") == email_id:
+ return email_info
+ return None
+
+ def wait_for_email(
+ self,
+ email: str,
+ email_id: str = None,
+ timeout: int = 120,
+ check_interval: int = 3,
+ expected_sender: str = None,
+ expected_subject: str = None
+ ) -> Optional[Dict[str, Any]]:
+ """
+ 等待并获取邮件(可选实现)
+
+ Args:
+ email: 邮箱地址
+ email_id: 邮箱服务中的 ID
+ timeout: 超时时间(秒)
+ check_interval: 检查间隔(秒)
+ expected_sender: 期望的发件人(包含检查)
+ expected_subject: 期望的主题(包含检查)
+
+ Returns:
+ 邮件信息字典,如果超时返回 None
+ """
+ import time
+ from datetime import datetime
+
+ start_time = time.time()
+ last_email_id = None
+
+ while time.time() - start_time < timeout:
+ try:
+ emails = self.list_emails()
+ for email_info in emails:
+ email_data = email_info.get("email", {})
+ current_email_id = email_info.get("id")
+
+ # 检查是否是新的邮件
+ if last_email_id and current_email_id == last_email_id:
+ continue
+
+ # 检查邮箱地址
+ if email_data.get("address") != email:
+ continue
+
+ # 获取邮件列表
+ messages = self.get_email_messages(email_id or current_email_id)
+ for message in messages:
+ # 检查发件人
+ if expected_sender and expected_sender not in message.get("from", ""):
+ continue
+
+ # 检查主题
+ if expected_subject and expected_subject not in message.get("subject", ""):
+ continue
+
+ # 返回邮件信息
+ return {
+ "id": message.get("id"),
+ "from": message.get("from"),
+ "subject": message.get("subject"),
+ "content": message.get("content"),
+ "received_at": message.get("received_at"),
+ "email_info": email_info
+ }
+
+ # 更新最后检查的邮件 ID
+ if messages:
+ last_email_id = current_email_id
+
+ except Exception as e:
+ logger.warning(f"等待邮件时出错: {e}")
+
+ time.sleep(check_interval)
+
+ return None
+
+ def get_email_messages(self, email_id: str, **kwargs) -> List[Dict[str, Any]]:
+ """
+ 获取邮箱中的邮件列表(可选实现)
+
+ Args:
+ email_id: 邮箱服务中的 ID
+ **kwargs: 其他参数
+
+ Returns:
+ 邮件列表
+
+ Note:
+ 这是可选方法,某些服务可能不支持
+ """
+ raise NotImplementedError("此邮箱服务不支持获取邮件列表")
+
+ def get_message_content(self, email_id: str, message_id: str) -> Optional[Dict[str, Any]]:
+ """
+ 获取邮件内容(可选实现)
+
+ Args:
+ email_id: 邮箱服务中的 ID
+ message_id: 邮件 ID
+
+ Returns:
+ 邮件内容字典
+
+ Note:
+ 这是可选方法,某些服务可能不支持
+ """
+ raise NotImplementedError("此邮箱服务不支持获取邮件内容")
+
+ def update_status(self, success: bool, error: Exception = None):
+ """
+ 更新服务状态
+
+ Args:
+ success: 操作是否成功
+ error: 错误信息
+ """
+ if success:
+ self._status = EmailServiceStatus.HEALTHY
+ self._last_error = None
+ else:
+ self._status = EmailServiceStatus.DEGRADED
+ if error:
+ self._last_error = str(error)
+
+ def __str__(self) -> str:
+ """字符串表示"""
+ return f"{self.name} ({self.service_type.value})"
+
+
+class EmailServiceFactory:
+ """邮箱服务工厂"""
+
+ _registry: Dict[EmailServiceType, type] = {}
+
+ @classmethod
+ def register(cls, service_type: EmailServiceType, service_class: type):
+ """
+ 注册邮箱服务类
+
+ Args:
+ service_type: 服务类型
+ service_class: 服务类
+ """
+ if not issubclass(service_class, BaseEmailService):
+ raise TypeError(f"{service_class} 必须是 BaseEmailService 的子类")
+ cls._registry[service_type] = service_class
+ logger.info(f"注册邮箱服务: {service_type.value} -> {service_class.__name__}")
+
+ @classmethod
+ def create(
+ cls,
+ service_type: EmailServiceType,
+ config: Dict[str, Any],
+ name: str = None
+ ) -> BaseEmailService:
+ """
+ 创建邮箱服务实例
+
+ Args:
+ service_type: 服务类型
+ config: 服务配置
+ name: 服务名称
+
+ Returns:
+ 邮箱服务实例
+
+ Raises:
+ ValueError: 服务类型未注册或配置无效
+ """
+ if service_type not in cls._registry:
+ raise ValueError(f"未注册的服务类型: {service_type.value}")
+
+ service_class = cls._registry[service_type]
+ try:
+ instance = service_class(config, name)
+ return instance
+ except Exception as e:
+ raise ValueError(f"创建邮箱服务失败: {e}")
+
+ @classmethod
+ def get_available_services(cls) -> List[EmailServiceType]:
+ """
+ 获取所有已注册的服务类型
+
+ Returns:
+ 已注册的服务类型列表
+ """
+ return list(cls._registry.keys())
+
+ @classmethod
+ def get_service_class(cls, service_type: EmailServiceType) -> Optional[type]:
+ """
+ 获取服务类
+
+ Args:
+ service_type: 服务类型
+
+ Returns:
+ 服务类,如果未注册返回 None
+ """
+ return cls._registry.get(service_type)
+
+
+# 简化的工厂函数
+def create_email_service(
+ service_type: EmailServiceType,
+ config: Dict[str, Any],
+ name: str = None
+) -> BaseEmailService:
+ """
+ 创建邮箱服务(简化工厂函数)
+
+ Args:
+ service_type: 服务类型
+ config: 服务配置
+ name: 服务名称
+
+ Returns:
+ 邮箱服务实例
+ """
+ return EmailServiceFactory.create(service_type, config, name)
\ No newline at end of file
diff --git a/src/services/duck_mail.py b/src/services/duck_mail.py
new file mode 100644
index 0000000..deb911a
--- /dev/null
+++ b/src/services/duck_mail.py
@@ -0,0 +1,366 @@
+"""
+DuckMail 邮箱服务实现
+兼容 DuckMail 的 accounts/token/messages 接口模型
+"""
+
+import logging
+import random
+import re
+import string
+import time
+from datetime import datetime, timezone
+from html import unescape
+from typing import Any, Dict, List, Optional
+
+from .base import BaseEmailService, EmailServiceError, EmailServiceType
+from ..config.constants import OTP_CODE_PATTERN
+from ..core.http_client import HTTPClient, RequestConfig
+
+
+logger = logging.getLogger(__name__)
+
+
+class DuckMailService(BaseEmailService):
+ """DuckMail 邮箱服务"""
+
+ def __init__(self, config: Dict[str, Any] = None, name: str = None):
+ super().__init__(EmailServiceType.DUCK_MAIL, name)
+
+ required_keys = ["base_url", "default_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 = {
+ "api_key": "",
+ "password_length": 12,
+ "expires_in": None,
+ "timeout": 30,
+ "max_retries": 3,
+ "proxy_url": None,
+ }
+ self.config = {**default_config, **(config or {})}
+ self.config["base_url"] = str(self.config["base_url"]).rstrip("/")
+ self.config["default_domain"] = str(self.config["default_domain"]).strip().lstrip("@")
+
+ http_config = RequestConfig(
+ timeout=self.config["timeout"],
+ max_retries=self.config["max_retries"],
+ )
+ self.http_client = HTTPClient(
+ proxy_url=self.config.get("proxy_url"),
+ config=http_config,
+ )
+
+ self._accounts_by_id: Dict[str, Dict[str, Any]] = {}
+ self._accounts_by_email: Dict[str, Dict[str, Any]] = {}
+
+ def _build_headers(
+ self,
+ token: Optional[str] = None,
+ use_api_key: bool = False,
+ extra_headers: Optional[Dict[str, str]] = None,
+ ) -> Dict[str, str]:
+ headers = {
+ "Accept": "application/json",
+ "Content-Type": "application/json",
+ }
+
+ auth_token = token
+ if not auth_token and use_api_key and self.config.get("api_key"):
+ auth_token = self.config["api_key"]
+
+ if auth_token:
+ headers["Authorization"] = f"Bearer {auth_token}"
+
+ if extra_headers:
+ headers.update(extra_headers)
+
+ return headers
+
+ def _make_request(
+ self,
+ method: str,
+ path: str,
+ token: Optional[str] = None,
+ use_api_key: bool = False,
+ **kwargs,
+ ) -> Dict[str, Any]:
+ url = f"{self.config['base_url']}{path}"
+ kwargs["headers"] = self._build_headers(
+ token=token,
+ use_api_key=use_api_key,
+ extra_headers=kwargs.get("headers"),
+ )
+
+ try:
+ response = self.http_client.request(method, url, **kwargs)
+ if response.status_code >= 400:
+ error_message = f"API 请求失败: {response.status_code}"
+ try:
+ error_payload = response.json()
+ error_message = f"{error_message} - {error_payload}"
+ except Exception:
+ error_message = f"{error_message} - {response.text[:200]}"
+ raise EmailServiceError(error_message)
+
+ try:
+ return response.json()
+ except Exception:
+ 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 _generate_local_part(self) -> str:
+ first = random.choice(string.ascii_lowercase)
+ rest = "".join(random.choices(string.ascii_lowercase + string.digits, k=7))
+ return f"{first}{rest}"
+
+ def _generate_password(self) -> str:
+ length = max(6, int(self.config.get("password_length") or 12))
+ alphabet = string.ascii_letters + string.digits
+ return "".join(random.choices(alphabet, k=length))
+
+ def _cache_account(self, account_info: Dict[str, Any]) -> None:
+ account_id = str(account_info.get("account_id") or account_info.get("service_id") or "").strip()
+ email = str(account_info.get("email") or "").strip().lower()
+
+ if account_id:
+ self._accounts_by_id[account_id] = account_info
+ if email:
+ self._accounts_by_email[email] = account_info
+
+ def _get_account_info(self, email: Optional[str] = None, email_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
+ if email_id:
+ cached = self._accounts_by_id.get(str(email_id))
+ if cached:
+ return cached
+
+ if email:
+ cached = self._accounts_by_email.get(str(email).strip().lower())
+ if cached:
+ return cached
+
+ return None
+
+ def _strip_html(self, html_content: Any) -> str:
+ if isinstance(html_content, list):
+ html_content = "\n".join(str(item) for item in html_content if item)
+ text = str(html_content or "")
+ return unescape(re.sub(r"<[^>]+>", " ", text))
+
+ def _parse_message_time(self, value: Optional[str]) -> Optional[float]:
+ if not value:
+ return None
+ try:
+ normalized = value.replace("Z", "+00:00")
+ return datetime.fromisoformat(normalized).astimezone(timezone.utc).timestamp()
+ except Exception:
+ return None
+
+ def _message_search_text(self, summary: Dict[str, Any], detail: Dict[str, Any]) -> str:
+ sender = summary.get("from") or detail.get("from") or {}
+ if isinstance(sender, dict):
+ sender_text = " ".join(
+ str(sender.get(key) or "") for key in ("name", "address")
+ ).strip()
+ else:
+ sender_text = str(sender)
+
+ subject = str(summary.get("subject") or detail.get("subject") or "")
+ text_body = str(detail.get("text") or "")
+ html_body = self._strip_html(detail.get("html"))
+ return "\n".join(part for part in [sender_text, subject, text_body, html_body] if part).strip()
+
+ def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]:
+ request_config = config or {}
+ local_part = str(request_config.get("name") or self._generate_local_part()).strip()
+ domain = str(request_config.get("default_domain") or request_config.get("domain") or self.config["default_domain"]).strip().lstrip("@")
+ address = f"{local_part}@{domain}"
+ password = self._generate_password()
+
+ payload: Dict[str, Any] = {
+ "address": address,
+ "password": password,
+ }
+
+ expires_in = request_config.get("expiresIn", request_config.get("expires_in", self.config.get("expires_in")))
+ if expires_in is not None:
+ payload["expiresIn"] = expires_in
+
+ account_response = self._make_request(
+ "POST",
+ "/accounts",
+ json=payload,
+ use_api_key=bool(self.config.get("api_key")),
+ )
+ token_response = self._make_request(
+ "POST",
+ "/token",
+ json={
+ "address": account_response.get("address", address),
+ "password": password,
+ },
+ )
+
+ account_id = str(account_response.get("id") or token_response.get("id") or "").strip()
+ resolved_address = str(account_response.get("address") or address).strip()
+ token = str(token_response.get("token") or "").strip()
+
+ if not account_id or not resolved_address or not token:
+ raise EmailServiceError("DuckMail 返回数据不完整")
+
+ email_info = {
+ "email": resolved_address,
+ "service_id": account_id,
+ "id": account_id,
+ "account_id": account_id,
+ "token": token,
+ "password": password,
+ "created_at": time.time(),
+ "raw_account": account_response,
+ }
+
+ self._cache_account(email_info)
+ self.update_status(True)
+ return email_info
+
+ def get_verification_code(
+ self,
+ email: str,
+ email_id: str = None,
+ timeout: int = 120,
+ pattern: str = OTP_CODE_PATTERN,
+ otp_sent_at: Optional[float] = None,
+ ) -> Optional[str]:
+ account_info = self._get_account_info(email=email, email_id=email_id)
+ if not account_info:
+ logger.warning(f"DuckMail 未找到邮箱缓存: {email}, {email_id}")
+ return None
+
+ token = account_info.get("token")
+ if not token:
+ logger.warning(f"DuckMail 邮箱缺少访问 token: {email}")
+ return None
+
+ start_time = time.time()
+ seen_message_ids = set()
+
+ while time.time() - start_time < timeout:
+ try:
+ response = self._make_request(
+ "GET",
+ "/messages",
+ token=token,
+ params={"page": 1},
+ )
+ messages = response.get("hydra:member", [])
+
+ for message in messages:
+ message_id = str(message.get("id") or "").strip()
+ if not message_id or message_id in seen_message_ids:
+ continue
+
+ created_at = self._parse_message_time(message.get("createdAt"))
+ if otp_sent_at and created_at and created_at + 1 < otp_sent_at:
+ continue
+
+ seen_message_ids.add(message_id)
+ detail = self._make_request(
+ "GET",
+ f"/messages/{message_id}",
+ token=token,
+ )
+
+ content = self._message_search_text(message, detail)
+ if "openai" not in content.lower():
+ continue
+
+ match = re.search(pattern, content)
+ if match:
+ self.update_status(True)
+ return match.group(1)
+ except Exception as e:
+ logger.debug(f"DuckMail 轮询验证码失败: {e}")
+
+ time.sleep(3)
+
+ return None
+
+ def list_emails(self, **kwargs) -> List[Dict[str, Any]]:
+ return list(self._accounts_by_email.values())
+
+ def delete_email(self, email_id: str) -> bool:
+ account_info = self._get_account_info(email_id=email_id) or self._get_account_info(email=email_id)
+ if not account_info:
+ return False
+
+ token = account_info.get("token")
+ account_id = account_info.get("account_id") or account_info.get("service_id")
+ if not token or not account_id:
+ return False
+
+ try:
+ self._make_request(
+ "DELETE",
+ f"/accounts/{account_id}",
+ token=token,
+ )
+ self._accounts_by_id.pop(str(account_id), None)
+ self._accounts_by_email.pop(str(account_info.get("email") or "").lower(), None)
+ self.update_status(True)
+ return True
+ except Exception as e:
+ logger.warning(f"DuckMail 删除邮箱失败: {e}")
+ self.update_status(False, e)
+ return False
+
+ def check_health(self) -> bool:
+ try:
+ self._make_request(
+ "GET",
+ "/domains",
+ params={"page": 1},
+ use_api_key=bool(self.config.get("api_key")),
+ )
+ self.update_status(True)
+ return True
+ except Exception as e:
+ logger.warning(f"DuckMail 健康检查失败: {e}")
+ self.update_status(False, e)
+ return False
+
+ def get_email_messages(self, email_id: str, **kwargs) -> List[Dict[str, Any]]:
+ account_info = self._get_account_info(email_id=email_id) or self._get_account_info(email=email_id)
+ if not account_info or not account_info.get("token"):
+ return []
+ response = self._make_request(
+ "GET",
+ "/messages",
+ token=account_info["token"],
+ params={"page": kwargs.get("page", 1)},
+ )
+ return response.get("hydra:member", [])
+
+ def get_message_detail(self, email_id: str, message_id: str) -> Optional[Dict[str, Any]]:
+ account_info = self._get_account_info(email_id=email_id) or self._get_account_info(email=email_id)
+ if not account_info or not account_info.get("token"):
+ return None
+ return self._make_request(
+ "GET",
+ f"/messages/{message_id}",
+ token=account_info["token"],
+ )
+
+ def get_service_info(self) -> Dict[str, Any]:
+ return {
+ "service_type": self.service_type.value,
+ "name": self.name,
+ "base_url": self.config["base_url"],
+ "default_domain": self.config["default_domain"],
+ "cached_accounts": len(self._accounts_by_email),
+ "status": self.status.value,
+ }
diff --git a/src/services/freemail.py b/src/services/freemail.py
new file mode 100644
index 0000000..e1e0587
--- /dev/null
+++ b/src/services/freemail.py
@@ -0,0 +1,324 @@
+"""
+Freemail 邮箱服务实现
+基于自部署 Cloudflare Worker 临时邮箱服务 (https://github.com/idinging/freemail)
+"""
+
+import re
+import time
+import logging
+import random
+import string
+from typing import Optional, Dict, Any, List
+
+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 FreemailService(BaseEmailService):
+ """
+ Freemail 邮箱服务
+ 基于自部署 Cloudflare Worker 的临时邮箱
+ """
+
+ def __init__(self, config: Dict[str, Any] = None, name: str = None):
+ """
+ 初始化 Freemail 服务
+
+ Args:
+ config: 配置字典,支持以下键:
+ - base_url: Worker 域名地址 (必需)
+ - admin_token: Admin Token,对应 JWT_TOKEN (必需)
+ - domain: 邮箱域名,如 example.com
+ - timeout: 请求超时时间,默认 30
+ - max_retries: 最大重试次数,默认 3
+ name: 服务名称
+ """
+ super().__init__(EmailServiceType.FREEMAIL, name)
+
+ required_keys = ["base_url", "admin_token"]
+ missing_keys = [key for key in required_keys if not (config or {}).get(key)]
+ if missing_keys:
+ raise ValueError(f"缺少必需配置: {missing_keys}")
+
+ default_config = {
+ "timeout": 30,
+ "max_retries": 3,
+ }
+ self.config = {**default_config, **(config or {})}
+ self.config["base_url"] = self.config["base_url"].rstrip("/")
+
+ http_config = RequestConfig(
+ timeout=self.config["timeout"],
+ max_retries=self.config["max_retries"],
+ )
+ self.http_client = HTTPClient(proxy_url=None, config=http_config)
+
+ # 缓存 domain 列表
+ self._domains = []
+
+ def _get_headers(self) -> Dict[str, str]:
+ """构造 admin 请求头"""
+ return {
+ "Authorization": f"Bearer {self.config['admin_token']}",
+ "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: 请求失败
+ """
+ url = f"{self.config['base_url']}{path}"
+ kwargs.setdefault("headers", {})
+ kwargs["headers"].update(self._get_headers())
+
+ try:
+ response = self.http_client.request(method, url, **kwargs)
+
+ if response.status_code >= 400:
+ error_msg = f"请求失败: {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 Exception:
+ 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 _ensure_domains(self):
+ """获取并缓存可用域名列表"""
+ if not self._domains:
+ try:
+ domains = self._make_request("GET", "/api/domains")
+ if isinstance(domains, list):
+ self._domains = domains
+ except Exception as e:
+ logger.warning(f"获取 Freemail 域名列表失败: {e}")
+
+ def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]:
+ """
+ 通过 API 创建临时邮箱
+
+ Returns:
+ 包含邮箱信息的字典:
+ - email: 邮箱地址
+ - service_id: 同 email(用作标识)
+ """
+ self._ensure_domains()
+
+ req_config = config or {}
+ domain_index = 0
+ target_domain = req_config.get("domain") or self.config.get("domain")
+
+ if target_domain and self._domains:
+ for i, d in enumerate(self._domains):
+ if d == target_domain:
+ domain_index = i
+ break
+
+ prefix = req_config.get("name")
+ try:
+ if prefix:
+ body = {
+ "local": prefix,
+ "domainIndex": domain_index
+ }
+ resp = self._make_request("POST", "/api/create", json=body)
+ else:
+ params = {"domainIndex": domain_index}
+ length = req_config.get("length")
+ if length:
+ params["length"] = length
+ resp = self._make_request("GET", "/api/generate", params=params)
+
+ email = resp.get("email")
+ if not email:
+ raise EmailServiceError(f"创建邮箱失败,未返回邮箱地址: {resp}")
+
+ email_info = {
+ "email": email,
+ "service_id": email,
+ "id": email,
+ "created_at": time.time(),
+ }
+
+ logger.info(f"成功创建 Freemail 邮箱: {email}")
+ self.update_status(True)
+ return email_info
+
+ except Exception as e:
+ self.update_status(False, e)
+ if isinstance(e, EmailServiceError):
+ raise
+ raise EmailServiceError(f"创建邮箱失败: {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]:
+ """
+ 从 Freemail 邮箱获取验证码
+
+ Args:
+ email: 邮箱地址
+ email_id: 未使用,保留接口兼容
+ timeout: 超时时间(秒)
+ pattern: 验证码正则
+ otp_sent_at: OTP 发送时间戳(暂未使用)
+
+ Returns:
+ 验证码字符串,超时返回 None
+ """
+ logger.info(f"正在从 Freemail 邮箱 {email} 获取验证码...")
+
+ start_time = time.time()
+ seen_mail_ids: set = set()
+
+ while time.time() - start_time < timeout:
+ try:
+ mails = self._make_request("GET", "/api/emails", params={"mailbox": email, "limit": 20})
+ 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("sender", "")).lower()
+ subject = str(mail.get("subject", ""))
+ preview = str(mail.get("preview", ""))
+
+ content = f"{sender}\n{subject}\n{preview}"
+
+ if "openai" not in content.lower():
+ continue
+
+ # 尝试直接使用 Freemail 提取的验证码
+ v_code = mail.get("verification_code")
+ if v_code:
+ logger.info(f"从 Freemail 邮箱 {email} 找到验证码: {v_code}")
+ self.update_status(True)
+ return v_code
+
+ # 如果没有直接提供,通过正则匹配 preview
+ match = re.search(pattern, content)
+ if match:
+ code = match.group(1)
+ logger.info(f"从 Freemail 邮箱 {email} 找到验证码: {code}")
+ self.update_status(True)
+ return code
+
+ # 如果依然未找到,获取邮件详情进行匹配
+ try:
+ detail = self._make_request("GET", f"/api/email/{mail_id}")
+ full_content = str(detail.get("content", "")) + "\n" + str(detail.get("html_content", ""))
+ match = re.search(pattern, full_content)
+ if match:
+ code = match.group(1)
+ logger.info(f"从 Freemail 邮箱 {email} 找到验证码: {code}")
+ self.update_status(True)
+ return code
+ except Exception as e:
+ logger.debug(f"获取 Freemail 邮件详情失败: {e}")
+
+ except Exception as e:
+ logger.debug(f"检查 Freemail 邮件时出错: {e}")
+
+ time.sleep(3)
+
+ logger.warning(f"等待 Freemail 验证码超时: {email}")
+ return None
+
+ def list_emails(self, **kwargs) -> List[Dict[str, Any]]:
+ """
+ 列出邮箱
+
+ Args:
+ **kwargs: 额外查询参数
+
+ Returns:
+ 邮箱列表
+ """
+ try:
+ params = {
+ "limit": kwargs.get("limit", 100),
+ "offset": kwargs.get("offset", 0)
+ }
+ resp = self._make_request("GET", "/api/mailboxes", params=params)
+
+ emails = []
+ if isinstance(resp, list):
+ for mail in resp:
+ address = mail.get("address")
+ if address:
+ emails.append({
+ "id": address,
+ "service_id": address,
+ "email": address,
+ "created_at": mail.get("created_at"),
+ "raw_data": mail
+ })
+ self.update_status(True)
+ return emails
+ except Exception as e:
+ logger.warning(f"列出 Freemail 邮箱失败: {e}")
+ self.update_status(False, e)
+ return []
+
+ def delete_email(self, email_id: str) -> bool:
+ """
+ 删除邮箱
+ """
+ try:
+ self._make_request("DELETE", "/api/mailboxes", params={"address": email_id})
+ logger.info(f"已删除 Freemail 邮箱: {email_id}")
+ self.update_status(True)
+ return True
+ except Exception as e:
+ logger.warning(f"删除 Freemail 邮箱失败: {e}")
+ self.update_status(False, e)
+ return False
+
+ def check_health(self) -> bool:
+ """检查服务健康状态"""
+ try:
+ self._make_request("GET", "/api/domains")
+ self.update_status(True)
+ return True
+ except Exception as e:
+ logger.warning(f"Freemail 健康检查失败: {e}")
+ self.update_status(False, e)
+ return False
diff --git a/src/services/imap_mail.py b/src/services/imap_mail.py
new file mode 100644
index 0000000..01573f6
--- /dev/null
+++ b/src/services/imap_mail.py
@@ -0,0 +1,217 @@
+"""
+IMAP 邮箱服务
+支持 Gmail / QQ / 163 / Yahoo / Outlook 等标准 IMAP 协议邮件服务商。
+仅用于接收验证码,强制直连(imaplib 不支持代理)。
+"""
+
+import imaplib
+import email
+import re
+import time
+import logging
+from email.header import decode_header
+from typing import Any, Dict, Optional
+
+from .base import BaseEmailService, EmailServiceError
+from ..config.constants import (
+ EmailServiceType,
+ OPENAI_EMAIL_SENDERS,
+ OTP_CODE_SEMANTIC_PATTERN,
+ OTP_CODE_PATTERN,
+)
+
+logger = logging.getLogger(__name__)
+
+
+class ImapMailService(BaseEmailService):
+ """标准 IMAP 邮箱服务(仅接收验证码,强制直连)"""
+
+ def __init__(self, config: Dict[str, Any] = None, name: str = None):
+ super().__init__(EmailServiceType.IMAP_MAIL, name)
+
+ cfg = config or {}
+ required_keys = ["host", "email", "password"]
+ missing_keys = [k for k in required_keys if not cfg.get(k)]
+ if missing_keys:
+ raise ValueError(f"缺少必需配置: {missing_keys}")
+
+ self.host: str = str(cfg["host"]).strip()
+ self.port: int = int(cfg.get("port", 993))
+ self.use_ssl: bool = bool(cfg.get("use_ssl", True))
+ self.email_addr: str = str(cfg["email"]).strip()
+ self.password: str = str(cfg["password"])
+ self.timeout: int = int(cfg.get("timeout", 30))
+ self.max_retries: int = int(cfg.get("max_retries", 3))
+
+ def _connect(self) -> imaplib.IMAP4:
+ """建立 IMAP 连接并登录,返回 mail 对象"""
+ if self.use_ssl:
+ mail = imaplib.IMAP4_SSL(self.host, self.port)
+ else:
+ mail = imaplib.IMAP4(self.host, self.port)
+ mail.starttls()
+ mail.login(self.email_addr, self.password)
+ return mail
+
+ def _decode_str(self, value) -> str:
+ """解码邮件头部字段"""
+ if value is None:
+ return ""
+ parts = decode_header(value)
+ decoded = []
+ for part, charset in parts:
+ if isinstance(part, bytes):
+ decoded.append(part.decode(charset or "utf-8", errors="replace"))
+ else:
+ decoded.append(str(part))
+ return " ".join(decoded)
+
+ def _get_text_body(self, msg) -> str:
+ """提取邮件纯文本内容"""
+ body = ""
+ if msg.is_multipart():
+ for part in msg.walk():
+ if part.get_content_type() == "text/plain":
+ charset = part.get_content_charset() or "utf-8"
+ payload = part.get_payload(decode=True)
+ if payload:
+ body += payload.decode(charset, errors="replace")
+ else:
+ charset = msg.get_content_charset() or "utf-8"
+ payload = msg.get_payload(decode=True)
+ if payload:
+ body = payload.decode(charset, errors="replace")
+ return body
+
+ def _is_openai_sender(self, from_addr: str) -> bool:
+ """判断发件人是否为 OpenAI"""
+ from_lower = from_addr.lower()
+ for sender in OPENAI_EMAIL_SENDERS:
+ if sender.startswith("@") or sender.startswith("."):
+ if sender in from_lower:
+ return True
+ else:
+ if sender in from_lower:
+ return True
+ return False
+
+ def _extract_otp(self, text: str) -> Optional[str]:
+ """从文本中提取 6 位验证码,优先语义匹配,回退简单匹配"""
+ match = re.search(OTP_CODE_SEMANTIC_PATTERN, text, re.IGNORECASE)
+ if match:
+ return match.group(1)
+ match = re.search(OTP_CODE_PATTERN, text)
+ if match:
+ return match.group(1)
+ return None
+
+ def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]:
+ """IMAP 模式不创建新邮箱,直接返回配置中的固定地址"""
+ self.update_status(True)
+ return {
+ "email": self.email_addr,
+ "service_id": self.email_addr,
+ "id": self.email_addr,
+ }
+
+ def get_verification_code(
+ self,
+ email: str,
+ email_id: str = None,
+ timeout: int = 60,
+ pattern: str = None,
+ otp_sent_at: Optional[float] = None,
+ ) -> Optional[str]:
+ """轮询 IMAP 收件箱,获取 OpenAI 验证码"""
+ start_time = time.time()
+ seen_ids: set = set()
+ mail = None
+
+ try:
+ mail = self._connect()
+ mail.select("INBOX")
+
+ while time.time() - start_time < timeout:
+ try:
+ # 搜索所有未读邮件
+ status, data = mail.search(None, "UNSEEN")
+ if status != "OK" or not data or not data[0]:
+ time.sleep(3)
+ continue
+
+ msg_ids = data[0].split()
+ for msg_id in reversed(msg_ids): # 最新的优先
+ id_str = msg_id.decode()
+ if id_str in seen_ids:
+ continue
+ seen_ids.add(id_str)
+
+ # 获取邮件
+ status, msg_data = mail.fetch(msg_id, "(RFC822)")
+ if status != "OK" or not msg_data:
+ continue
+
+ raw = msg_data[0][1]
+ msg = email.message_from_bytes(raw)
+
+ # 检查发件人
+ from_addr = self._decode_str(msg.get("From", ""))
+ if not self._is_openai_sender(from_addr):
+ continue
+
+ # 提取验证码
+ body = self._get_text_body(msg)
+ code = self._extract_otp(body)
+ if code:
+ # 标记已读
+ mail.store(msg_id, "+FLAGS", "\\Seen")
+ self.update_status(True)
+ logger.info(f"IMAP 获取验证码成功: {code}")
+ return code
+
+ except imaplib.IMAP4.error as e:
+ logger.debug(f"IMAP 搜索邮件失败: {e}")
+ # 尝试重新连接
+ try:
+ mail.select("INBOX")
+ except Exception:
+ pass
+
+ time.sleep(3)
+
+ except Exception as e:
+ logger.warning(f"IMAP 连接/轮询失败: {e}")
+ self.update_status(False, str(e))
+ finally:
+ if mail:
+ try:
+ mail.logout()
+ except Exception:
+ pass
+
+ return None
+
+ def check_health(self) -> bool:
+ """尝试 IMAP 登录并选择收件箱"""
+ mail = None
+ try:
+ mail = self._connect()
+ status, _ = mail.select("INBOX")
+ return status == "OK"
+ except Exception as e:
+ logger.warning(f"IMAP 健康检查失败: {e}")
+ return False
+ finally:
+ if mail:
+ try:
+ mail.logout()
+ except Exception:
+ pass
+
+ def list_emails(self, **kwargs) -> list:
+ """IMAP 单账号模式,返回固定地址"""
+ return [{"email": self.email_addr, "id": self.email_addr}]
+
+ def delete_email(self, email_id: str) -> bool:
+ """IMAP 模式无需删除逻辑"""
+ return True
diff --git a/src/services/moe_mail.py b/src/services/moe_mail.py
new file mode 100644
index 0000000..dad6594
--- /dev/null
+++ b/src/services/moe_mail.py
@@ -0,0 +1,556 @@
+"""
+自定义域名邮箱服务实现
+基于 email.md 中的 REST API 接口
+"""
+
+import re
+import time
+import json
+import logging
+from typing import Optional, Dict, Any, List
+from urllib.parse import urljoin
+
+from .base import BaseEmailService, EmailServiceError, EmailServiceType
+from ..core.http_client import HTTPClient, RequestConfig
+from ..config.constants import OTP_CODE_PATTERN
+
+
+logger = logging.getLogger(__name__)
+
+
+class MeoMailEmailService(BaseEmailService):
+ """
+ 自定义域名邮箱服务
+ 基于 REST API 接口
+ """
+
+ def __init__(self, config: Dict[str, Any] = None, name: str = None):
+ """
+ 初始化自定义域名邮箱服务
+
+ Args:
+ config: 配置字典,支持以下键:
+ - base_url: API 基础地址 (必需)
+ - api_key: API 密钥 (必需)
+ - api_key_header: API 密钥请求头名称 (默认: X-API-Key)
+ - timeout: 请求超时时间 (默认: 30)
+ - max_retries: 最大重试次数 (默认: 3)
+ - proxy_url: 代理 URL
+ - default_domain: 默认域名
+ - default_expiry: 默认过期时间(毫秒)
+ name: 服务名称
+ """
+ super().__init__(EmailServiceType.MOE_MAIL, name)
+
+ # 必需配置检查
+ required_keys = ["base_url", "api_key"]
+ missing_keys = [key for key in required_keys if key not in (config or {})]
+
+ if missing_keys:
+ raise ValueError(f"缺少必需配置: {missing_keys}")
+
+ # 默认配置
+ default_config = {
+ "base_url": "",
+ "api_key": "",
+ "api_key_header": "X-API-Key",
+ "timeout": 30,
+ "max_retries": 3,
+ "proxy_url": None,
+ "default_domain": None,
+ "default_expiry": 3600000, # 1小时
+ }
+
+ self.config = {**default_config, **(config or {})}
+
+ # 创建 HTTP 客户端
+ http_config = RequestConfig(
+ timeout=self.config["timeout"],
+ max_retries=self.config["max_retries"],
+ )
+ self.http_client = HTTPClient(
+ proxy_url=self.config.get("proxy_url"),
+ config=http_config
+ )
+
+ # 状态变量
+ self._emails_cache: Dict[str, Dict[str, Any]] = {}
+ self._last_config_check: float = 0
+ self._cached_config: Optional[Dict[str, Any]] = None
+
+ def _get_headers(self) -> Dict[str, str]:
+ """获取 API 请求头"""
+ headers = {
+ "Accept": "application/json",
+ "Content-Type": "application/json",
+ }
+
+ # 添加 API 密钥
+ api_key_header = self.config.get("api_key_header", "X-API-Key")
+ headers[api_key_header] = self.config["api_key"]
+
+ return headers
+
+ def _make_request(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]:
+ """
+ 发送 API 请求
+
+ Args:
+ method: HTTP 方法
+ endpoint: API 端点
+ **kwargs: 请求参数
+
+ Returns:
+ 响应 JSON 数据
+
+ Raises:
+ EmailServiceError: 请求失败
+ """
+ url = urljoin(self.config["base_url"], endpoint)
+
+ # 添加默认请求头
+ kwargs.setdefault("headers", {})
+ kwargs["headers"].update(self._get_headers())
+
+ try:
+ # POST 请求禁用自动重定向,手动处理以保持 POST 方法(避免 HTTP→HTTPS 重定向时被转为 GET)
+ if method.upper() == "POST":
+ kwargs["allow_redirects"] = False
+ response = self.http_client.request(method, url, **kwargs)
+ # 处理重定向
+ max_redirects = 5
+ redirect_count = 0
+ while response.status_code in (301, 302, 303, 307, 308) and redirect_count < max_redirects:
+ location = response.headers.get("Location", "")
+ if not location:
+ break
+ import urllib.parse as _urlparse
+ redirect_url = _urlparse.urljoin(url, location)
+ # 307/308 保持 POST,其余(301/302/303)转为 GET
+ if response.status_code in (307, 308):
+ redirect_method = method
+ redirect_kwargs = kwargs
+ else:
+ redirect_method = "GET"
+ # GET 不传 body
+ redirect_kwargs = {k: v for k, v in kwargs.items() if k not in ("json", "data")}
+ response = self.http_client.request(redirect_method, redirect_url, **redirect_kwargs)
+ url = redirect_url
+ redirect_count += 1
+ else:
+ response = self.http_client.request(method, url, **kwargs)
+
+ if response.status_code >= 400:
+ error_msg = f"API 请求失败: {response.status_code}"
+ try:
+ error_data = response.json()
+ error_msg = f"{error_msg} - {error_data}"
+ except:
+ error_msg = f"{error_msg} - {response.text[:200]}"
+
+ self.update_status(False, EmailServiceError(error_msg))
+ raise EmailServiceError(error_msg)
+
+ # 解析响应
+ try:
+ return response.json()
+ except json.JSONDecodeError:
+ return {"raw_response": response.text}
+
+ except Exception as e:
+ self.update_status(False, e)
+ if isinstance(e, EmailServiceError):
+ raise
+ raise EmailServiceError(f"API 请求失败: {method} {endpoint} - {e}")
+
+ def get_config(self, force_refresh: bool = False) -> Dict[str, Any]:
+ """
+ 获取系统配置
+
+ Args:
+ force_refresh: 是否强制刷新缓存
+
+ Returns:
+ 配置信息
+ """
+ # 检查缓存
+ if not force_refresh and self._cached_config and time.time() - self._last_config_check < 300:
+ return self._cached_config
+
+ try:
+ response = self._make_request("GET", "/api/config")
+ self._cached_config = response
+ self._last_config_check = time.time()
+ self.update_status(True)
+ return response
+ except Exception as e:
+ logger.warning(f"获取配置失败: {e}")
+ return {}
+
+ def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]:
+ """
+ 创建临时邮箱
+
+ Args:
+ config: 配置参数:
+ - name: 邮箱前缀(可选)
+ - expiryTime: 有效期(毫秒)(可选)
+ - domain: 邮箱域名(可选)
+
+ Returns:
+ 包含邮箱信息的字典:
+ - email: 邮箱地址
+ - service_id: 邮箱 ID
+ - id: 邮箱 ID(同 service_id)
+ - expiry: 过期时间信息
+ """
+ # 获取默认配置
+ sys_config = self.get_config()
+ default_domain = self.config.get("default_domain")
+ if not default_domain and sys_config.get("emailDomains"):
+ # 使用系统配置的第一个域名
+ domains = sys_config["emailDomains"].split(",")
+ default_domain = domains[0].strip() if domains else None
+
+ # 构建请求参数
+ request_config = config or {}
+ create_data = {
+ "name": request_config.get("name", ""),
+ "expiryTime": request_config.get("expiryTime", self.config.get("default_expiry", 3600000)),
+ "domain": request_config.get("domain", default_domain),
+ }
+
+ # 移除空值
+ create_data = {k: v for k, v in create_data.items() if v is not None and v != ""}
+
+ try:
+ response = self._make_request("POST", "/api/emails/generate", json=create_data)
+
+ email = response.get("email", "").strip()
+ email_id = response.get("id", "").strip()
+
+ if not email or not email_id:
+ raise EmailServiceError("API 返回数据不完整")
+
+ email_info = {
+ "email": email,
+ "service_id": email_id,
+ "id": email_id,
+ "created_at": time.time(),
+ "expiry": create_data.get("expiryTime"),
+ "domain": create_data.get("domain"),
+ "raw_response": response,
+ }
+
+ # 缓存邮箱信息
+ self._emails_cache[email_id] = email_info
+
+ logger.info(f"成功创建自定义域名邮箱: {email} (ID: {email_id})")
+ self.update_status(True)
+ return email_info
+
+ except Exception as e:
+ self.update_status(False, e)
+ if isinstance(e, EmailServiceError):
+ raise
+ raise EmailServiceError(f"创建邮箱失败: {e}")
+
+ def get_verification_code(
+ self,
+ email: str,
+ email_id: str = None,
+ timeout: int = 120,
+ pattern: str = OTP_CODE_PATTERN,
+ otp_sent_at: Optional[float] = None,
+ ) -> Optional[str]:
+ """
+ 从自定义域名邮箱获取验证码
+
+ Args:
+ email: 邮箱地址
+ email_id: 邮箱 ID(如果不提供,从缓存中查找)
+ timeout: 超时时间(秒)
+ pattern: 验证码正则表达式
+ otp_sent_at: OTP 发送时间戳(自定义域名服务暂不使用此参数)
+
+ Returns:
+ 验证码字符串,如果超时或未找到返回 None
+ """
+ # 查找邮箱 ID
+ target_email_id = email_id
+ if not target_email_id:
+ # 从缓存中查找
+ for eid, info in self._emails_cache.items():
+ if info.get("email") == email:
+ target_email_id = eid
+ break
+
+ if not target_email_id:
+ logger.warning(f"未找到邮箱 {email} 的 ID,无法获取验证码")
+ return None
+
+ logger.info(f"正在从自定义域名邮箱 {email} 获取验证码...")
+
+ start_time = time.time()
+ seen_message_ids = set()
+
+ while time.time() - start_time < timeout:
+ try:
+ # 获取邮件列表
+ response = self._make_request("GET", f"/api/emails/{target_email_id}")
+
+ messages = response.get("messages", [])
+ if not isinstance(messages, list):
+ time.sleep(3)
+ continue
+
+ for message in messages:
+ message_id = message.get("id")
+ if not message_id or message_id in seen_message_ids:
+ continue
+
+ seen_message_ids.add(message_id)
+
+ # 检查是否是目标邮件
+ sender = str(message.get("from_address", "")).lower()
+ subject = str(message.get("subject", ""))
+
+ # 获取邮件内容
+ message_content = self._get_message_content(target_email_id, message_id)
+ if not message_content:
+ continue
+
+ content = f"{sender} {subject} {message_content}"
+
+ # 检查是否是 OpenAI 邮件
+ if "openai" not in sender and "openai" not in content.lower():
+ continue
+
+ # 提取验证码 过滤掉邮箱
+ 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}")
+ self.update_status(True)
+ return code
+
+ except Exception as e:
+ logger.debug(f"检查邮件时出错: {e}")
+
+ # 等待一段时间再检查
+ time.sleep(3)
+
+ logger.warning(f"等待验证码超时: {email}")
+ return None
+
+ def _get_message_content(self, email_id: str, message_id: str) -> Optional[str]:
+ """获取邮件内容"""
+ try:
+ response = self._make_request("GET", f"/api/emails/{email_id}/{message_id}")
+ message = response.get("message", {})
+
+ # 优先使用纯文本内容,其次使用 HTML 内容
+ content = message.get("content", "")
+ if not content:
+ html = message.get("html", "")
+ if html:
+ # 简单去除 HTML 标签
+ content = re.sub(r"<[^>]+>", " ", html)
+
+ return content
+ except Exception as e:
+ logger.debug(f"获取邮件内容失败: {e}")
+ return None
+
+ def list_emails(self, cursor: str = None, **kwargs) -> List[Dict[str, Any]]:
+ """
+ 列出所有邮箱
+
+ Args:
+ cursor: 分页游标
+ **kwargs: 其他参数
+
+ Returns:
+ 邮箱列表
+ """
+ params = {}
+ if cursor:
+ params["cursor"] = cursor
+
+ try:
+ response = self._make_request("GET", "/api/emails", params=params)
+ emails = response.get("emails", [])
+
+ # 更新缓存
+ for email_info in emails:
+ email_id = email_info.get("id")
+ if email_id:
+ self._emails_cache[email_id] = email_info
+
+ self.update_status(True)
+ return emails
+ except Exception as e:
+ logger.warning(f"列出邮箱失败: {e}")
+ self.update_status(False, e)
+ return []
+
+ def delete_email(self, email_id: str) -> bool:
+ """
+ 删除邮箱
+
+ Args:
+ email_id: 邮箱 ID
+
+ Returns:
+ 是否删除成功
+ """
+ try:
+ response = self._make_request("DELETE", f"/api/emails/{email_id}")
+ success = response.get("success", False)
+
+ if success:
+ # 从缓存中移除
+ self._emails_cache.pop(email_id, None)
+ logger.info(f"成功删除邮箱: {email_id}")
+ else:
+ logger.warning(f"删除邮箱失败: {email_id}")
+
+ self.update_status(success)
+ return success
+
+ except Exception as e:
+ logger.error(f"删除邮箱失败: {email_id} - {e}")
+ self.update_status(False, e)
+ return False
+
+ def check_health(self) -> bool:
+ """检查自定义域名邮箱服务是否可用"""
+ try:
+ # 尝试获取配置
+ config = self.get_config(force_refresh=True)
+ if config:
+ logger.debug(f"自定义域名邮箱服务健康检查通过,配置: {config.get('defaultRole', 'N/A')}")
+ self.update_status(True)
+ return True
+ else:
+ logger.warning("自定义域名邮箱服务健康检查失败:获取配置为空")
+ self.update_status(False, EmailServiceError("获取配置为空"))
+ return False
+ except Exception as e:
+ logger.warning(f"自定义域名邮箱服务健康检查失败: {e}")
+ self.update_status(False, e)
+ return False
+
+ def get_email_messages(self, email_id: str, cursor: str = None) -> List[Dict[str, Any]]:
+ """
+ 获取邮箱中的邮件列表
+
+ Args:
+ email_id: 邮箱 ID
+ cursor: 分页游标
+
+ Returns:
+ 邮件列表
+ """
+ params = {}
+ if cursor:
+ params["cursor"] = cursor
+
+ try:
+ response = self._make_request("GET", f"/api/emails/{email_id}", params=params)
+ messages = response.get("messages", [])
+ self.update_status(True)
+ return messages
+ except Exception as e:
+ logger.error(f"获取邮件列表失败: {email_id} - {e}")
+ self.update_status(False, e)
+ return []
+
+ def get_message_detail(self, email_id: str, message_id: str) -> Optional[Dict[str, Any]]:
+ """
+ 获取邮件详情
+
+ Args:
+ email_id: 邮箱 ID
+ message_id: 邮件 ID
+
+ Returns:
+ 邮件详情
+ """
+ try:
+ response = self._make_request("GET", f"/api/emails/{email_id}/{message_id}")
+ message = response.get("message")
+ self.update_status(True)
+ return message
+ except Exception as e:
+ logger.error(f"获取邮件详情失败: {email_id}/{message_id} - {e}")
+ self.update_status(False, e)
+ return None
+
+ def create_email_share(self, email_id: str, expires_in: int = 86400000) -> Optional[Dict[str, Any]]:
+ """
+ 创建邮箱分享链接
+
+ Args:
+ email_id: 邮箱 ID
+ expires_in: 有效期(毫秒)
+
+ Returns:
+ 分享信息
+ """
+ try:
+ response = self._make_request(
+ "POST",
+ f"/api/emails/{email_id}/share",
+ json={"expiresIn": expires_in}
+ )
+ self.update_status(True)
+ return response
+ except Exception as e:
+ logger.error(f"创建邮箱分享链接失败: {email_id} - {e}")
+ self.update_status(False, e)
+ return None
+
+ def create_message_share(
+ self,
+ email_id: str,
+ message_id: str,
+ expires_in: int = 86400000
+ ) -> Optional[Dict[str, Any]]:
+ """
+ 创建邮件分享链接
+
+ Args:
+ email_id: 邮箱 ID
+ message_id: 邮件 ID
+ expires_in: 有效期(毫秒)
+
+ Returns:
+ 分享信息
+ """
+ try:
+ response = self._make_request(
+ "POST",
+ f"/api/emails/{email_id}/messages/{message_id}/share",
+ json={"expiresIn": expires_in}
+ )
+ self.update_status(True)
+ return response
+ except Exception as e:
+ logger.error(f"创建邮件分享链接失败: {email_id}/{message_id} - {e}")
+ self.update_status(False, e)
+ return None
+
+ def get_service_info(self) -> Dict[str, Any]:
+ """获取服务信息"""
+ config = self.get_config()
+ return {
+ "service_type": self.service_type.value,
+ "name": self.name,
+ "base_url": self.config["base_url"],
+ "default_domain": self.config.get("default_domain"),
+ "system_config": config,
+ "cached_emails_count": len(self._emails_cache),
+ "status": self.status.value,
+ }
\ No newline at end of file
diff --git a/src/services/outlook/__init__.py b/src/services/outlook/__init__.py
new file mode 100644
index 0000000..fbdd660
--- /dev/null
+++ b/src/services/outlook/__init__.py
@@ -0,0 +1,8 @@
+"""
+Outlook 邮箱服务模块
+支持多种 IMAP/API 连接方式,自动故障切换
+"""
+
+from .service import OutlookService
+
+__all__ = ['OutlookService']
diff --git a/src/services/outlook/account.py b/src/services/outlook/account.py
new file mode 100644
index 0000000..6f427d5
--- /dev/null
+++ b/src/services/outlook/account.py
@@ -0,0 +1,51 @@
+"""
+Outlook 账户数据类
+"""
+
+from dataclasses import dataclass
+from typing import Dict, Any, Optional
+
+
+@dataclass
+class OutlookAccount:
+ """Outlook 账户信息"""
+ email: str
+ password: str = ""
+ client_id: str = ""
+ refresh_token: str = ""
+
+ @classmethod
+ def from_config(cls, config: Dict[str, Any]) -> "OutlookAccount":
+ """从配置创建账户"""
+ return cls(
+ email=config.get("email", ""),
+ password=config.get("password", ""),
+ client_id=config.get("client_id", ""),
+ refresh_token=config.get("refresh_token", "")
+ )
+
+ def has_oauth(self) -> bool:
+ """是否支持 OAuth2"""
+ return bool(self.client_id and self.refresh_token)
+
+ def validate(self) -> bool:
+ """验证账户信息是否有效"""
+ return bool(self.email and self.password) or self.has_oauth()
+
+ def to_dict(self, include_sensitive: bool = False) -> Dict[str, Any]:
+ """转换为字典"""
+ result = {
+ "email": self.email,
+ "has_oauth": self.has_oauth(),
+ }
+ if include_sensitive:
+ result.update({
+ "password": self.password,
+ "client_id": self.client_id,
+ "refresh_token": self.refresh_token[:20] + "..." if self.refresh_token else "",
+ })
+ return result
+
+ def __str__(self) -> str:
+ """字符串表示"""
+ return f"OutlookAccount({self.email})"
diff --git a/src/services/outlook/base.py b/src/services/outlook/base.py
new file mode 100644
index 0000000..335b11e
--- /dev/null
+++ b/src/services/outlook/base.py
@@ -0,0 +1,153 @@
+"""
+Outlook 服务基础定义
+包含枚举类型和数据类
+"""
+
+from dataclasses import dataclass, field
+from datetime import datetime
+from enum import Enum
+from typing import Optional, Dict, Any, List
+
+
+class ProviderType(str, Enum):
+ """Outlook 提供者类型"""
+ IMAP_OLD = "imap_old" # 旧版 IMAP (outlook.office365.com)
+ IMAP_NEW = "imap_new" # 新版 IMAP (outlook.live.com)
+ GRAPH_API = "graph_api" # Microsoft Graph API
+
+
+class TokenEndpoint(str, Enum):
+ """Token 端点"""
+ LIVE = "https://login.live.com/oauth20_token.srf"
+ CONSUMERS = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"
+ COMMON = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
+
+
+class IMAPServer(str, Enum):
+ """IMAP 服务器"""
+ OLD = "outlook.office365.com"
+ NEW = "outlook.live.com"
+
+
+class ProviderStatus(str, Enum):
+ """提供者状态"""
+ HEALTHY = "healthy" # 健康
+ DEGRADED = "degraded" # 降级
+ DISABLED = "disabled" # 禁用
+
+
+@dataclass
+class EmailMessage:
+ """邮件消息数据类"""
+ id: str # 消息 ID
+ subject: str # 主题
+ sender: str # 发件人
+ recipients: List[str] = field(default_factory=list) # 收件人列表
+ body: str = "" # 正文内容
+ body_preview: str = "" # 正文预览
+ received_at: Optional[datetime] = None # 接收时间
+ received_timestamp: int = 0 # 接收时间戳
+ is_read: bool = False # 是否已读
+ has_attachments: bool = False # 是否有附件
+ raw_data: Optional[bytes] = None # 原始数据(用于调试)
+
+ def to_dict(self) -> Dict[str, Any]:
+ """转换为字典"""
+ return {
+ "id": self.id,
+ "subject": self.subject,
+ "sender": self.sender,
+ "recipients": self.recipients,
+ "body": self.body,
+ "body_preview": self.body_preview,
+ "received_at": self.received_at.isoformat() if self.received_at else None,
+ "received_timestamp": self.received_timestamp,
+ "is_read": self.is_read,
+ "has_attachments": self.has_attachments,
+ }
+
+
+@dataclass
+class TokenInfo:
+ """Token 信息数据类"""
+ access_token: str
+ expires_at: float # 过期时间戳
+ token_type: str = "Bearer"
+ scope: str = ""
+ refresh_token: Optional[str] = None
+
+ def is_expired(self, buffer_seconds: int = 120) -> bool:
+ """检查 Token 是否已过期"""
+ import time
+ return time.time() >= (self.expires_at - buffer_seconds)
+
+ @classmethod
+ def from_response(cls, data: Dict[str, Any], scope: str = "") -> "TokenInfo":
+ """从 API 响应创建"""
+ import time
+ return cls(
+ access_token=data.get("access_token", ""),
+ expires_at=time.time() + data.get("expires_in", 3600),
+ token_type=data.get("token_type", "Bearer"),
+ scope=scope or data.get("scope", ""),
+ refresh_token=data.get("refresh_token"),
+ )
+
+
+@dataclass
+class ProviderHealth:
+ """提供者健康状态"""
+ provider_type: ProviderType
+ status: ProviderStatus = ProviderStatus.HEALTHY
+ failure_count: int = 0 # 连续失败次数
+ last_success: Optional[datetime] = None # 最后成功时间
+ last_failure: Optional[datetime] = None # 最后失败时间
+ last_error: str = "" # 最后错误信息
+ disabled_until: Optional[datetime] = None # 禁用截止时间
+
+ def record_success(self):
+ """记录成功"""
+ self.status = ProviderStatus.HEALTHY
+ self.failure_count = 0
+ self.last_success = datetime.now()
+ self.disabled_until = None
+
+ def record_failure(self, error: str):
+ """记录失败"""
+ self.failure_count += 1
+ self.last_failure = datetime.now()
+ self.last_error = error
+
+ def should_disable(self, threshold: int = 3) -> bool:
+ """判断是否应该禁用"""
+ return self.failure_count >= threshold
+
+ def is_disabled(self) -> bool:
+ """检查是否被禁用"""
+ if self.disabled_until and datetime.now() < self.disabled_until:
+ return True
+ return False
+
+ def disable(self, duration_seconds: int = 300):
+ """禁用提供者"""
+ from datetime import timedelta
+ self.status = ProviderStatus.DISABLED
+ self.disabled_until = datetime.now() + timedelta(seconds=duration_seconds)
+
+ def enable(self):
+ """启用提供者"""
+ self.status = ProviderStatus.HEALTHY
+ self.disabled_until = None
+ self.failure_count = 0
+
+ def to_dict(self) -> Dict[str, Any]:
+ """转换为字典"""
+ return {
+ "provider_type": self.provider_type.value,
+ "status": self.status.value,
+ "failure_count": self.failure_count,
+ "last_success": self.last_success.isoformat() if self.last_success else None,
+ "last_failure": self.last_failure.isoformat() if self.last_failure else None,
+ "last_error": self.last_error,
+ "disabled_until": self.disabled_until.isoformat() if self.disabled_until else None,
+ }
diff --git a/src/services/outlook/email_parser.py b/src/services/outlook/email_parser.py
new file mode 100644
index 0000000..84d5228
--- /dev/null
+++ b/src/services/outlook/email_parser.py
@@ -0,0 +1,228 @@
+"""
+邮件解析和验证码提取
+"""
+
+import logging
+import re
+from typing import Optional, List, Dict, Any
+
+from ...config.constants import (
+ OTP_CODE_SIMPLE_PATTERN,
+ OTP_CODE_SEMANTIC_PATTERN,
+ OPENAI_EMAIL_SENDERS,
+ OPENAI_VERIFICATION_KEYWORDS,
+)
+from .base import EmailMessage
+
+
+logger = logging.getLogger(__name__)
+
+
+class EmailParser:
+ """
+ 邮件解析器
+ 用于识别 OpenAI 验证邮件并提取验证码
+ """
+
+ def __init__(self):
+ # 编译正则表达式
+ self._simple_pattern = re.compile(OTP_CODE_SIMPLE_PATTERN)
+ self._semantic_pattern = re.compile(OTP_CODE_SEMANTIC_PATTERN, re.IGNORECASE)
+
+ def is_openai_verification_email(
+ self,
+ email: EmailMessage,
+ target_email: Optional[str] = None,
+ ) -> bool:
+ """
+ 判断是否为 OpenAI 验证邮件
+
+ Args:
+ email: 邮件对象
+ target_email: 目标邮箱地址(用于验证收件人)
+
+ Returns:
+ 是否为 OpenAI 验证邮件
+ """
+ sender = email.sender.lower()
+
+ # 1. 发件人必须是 OpenAI
+ if not any(s in sender for s in OPENAI_EMAIL_SENDERS):
+ logger.debug(f"邮件发件人非 OpenAI: {sender}")
+ return False
+
+ # 2. 主题或正文包含验证关键词
+ subject = email.subject.lower()
+ body = email.body.lower()
+ combined = f"{subject} {body}"
+
+ if not any(kw in combined for kw in OPENAI_VERIFICATION_KEYWORDS):
+ logger.debug(f"邮件未包含验证关键词: {subject[:50]}")
+ return False
+
+ # 3. 收件人检查已移除:别名邮件的 IMAP 头中收件人可能不匹配,只靠发件人+关键词判断
+ logger.debug(f"识别为 OpenAI 验证邮件: {subject[:50]}")
+ return True
+
+ def extract_verification_code(
+ self,
+ email: EmailMessage,
+ ) -> Optional[str]:
+ """
+ 从邮件中提取验证码
+
+ 优先级:
+ 1. 从主题提取(6位数字)
+ 2. 从正文用语义正则提取(如 "code is 123456")
+ 3. 兜底:任意 6 位数字
+
+ Args:
+ email: 邮件对象
+
+ Returns:
+ 验证码字符串,如果未找到返回 None
+ """
+ # 1. 主题优先
+ code = self._extract_from_subject(email.subject)
+ if code:
+ logger.debug(f"从主题提取验证码: {code}")
+ return code
+
+ # 2. 正文语义匹配
+ code = self._extract_semantic(email.body)
+ if code:
+ logger.debug(f"从正文语义提取验证码: {code}")
+ return code
+
+ # 3. 兜底:正文任意 6 位数字
+ code = self._extract_simple(email.body)
+ if code:
+ logger.debug(f"从正文兜底提取验证码: {code}")
+ return code
+
+ return None
+
+ def _extract_from_subject(self, subject: str) -> Optional[str]:
+ """从主题提取验证码"""
+ match = self._simple_pattern.search(subject)
+ if match:
+ return match.group(1)
+ return None
+
+ def _extract_semantic(self, body: str) -> Optional[str]:
+ """语义匹配提取验证码"""
+ match = self._semantic_pattern.search(body)
+ if match:
+ return match.group(1)
+ return None
+
+ def _extract_simple(self, body: str) -> Optional[str]:
+ """简单匹配提取验证码"""
+ match = self._simple_pattern.search(body)
+ if match:
+ return match.group(1)
+ return None
+
+ def find_verification_code_in_emails(
+ self,
+ emails: List[EmailMessage],
+ target_email: Optional[str] = None,
+ min_timestamp: int = 0,
+ used_codes: Optional[set] = None,
+ ) -> Optional[str]:
+ """
+ 从邮件列表中查找验证码
+
+ Args:
+ emails: 邮件列表
+ target_email: 目标邮箱地址
+ min_timestamp: 最小时间戳(用于过滤旧邮件)
+ used_codes: 已使用的验证码集合(用于去重)
+
+ Returns:
+ 验证码字符串,如果未找到返回 None
+ """
+ used_codes = used_codes or set()
+
+ for email in emails:
+ # 时间戳过滤
+ if min_timestamp > 0 and email.received_timestamp > 0:
+ if email.received_timestamp < min_timestamp:
+ logger.debug(f"跳过旧邮件: {email.subject[:50]}")
+ continue
+
+ # 检查是否是 OpenAI 验证邮件
+ if not self.is_openai_verification_email(email, target_email):
+ continue
+
+ # 提取验证码
+ code = self.extract_verification_code(email)
+ if code:
+ # 去重检查
+ if code in used_codes:
+ logger.debug(f"跳过已使用的验证码: {code}")
+ continue
+
+ logger.info(
+ f"[{target_email or 'unknown'}] 找到验证码: {code}, "
+ f"邮件主题: {email.subject[:30]}"
+ )
+ return code
+
+ return None
+
+ def filter_emails_by_sender(
+ self,
+ emails: List[EmailMessage],
+ sender_patterns: List[str],
+ ) -> List[EmailMessage]:
+ """
+ 按发件人过滤邮件
+
+ Args:
+ emails: 邮件列表
+ sender_patterns: 发件人匹配模式列表
+
+ Returns:
+ 过滤后的邮件列表
+ """
+ filtered = []
+ for email in emails:
+ sender = email.sender.lower()
+ if any(pattern.lower() in sender for pattern in sender_patterns):
+ filtered.append(email)
+ return filtered
+
+ def filter_emails_by_subject(
+ self,
+ emails: List[EmailMessage],
+ keywords: List[str],
+ ) -> List[EmailMessage]:
+ """
+ 按主题关键词过滤邮件
+
+ Args:
+ emails: 邮件列表
+ keywords: 关键词列表
+
+ Returns:
+ 过滤后的邮件列表
+ """
+ filtered = []
+ for email in emails:
+ subject = email.subject.lower()
+ if any(kw.lower() in subject for kw in keywords):
+ filtered.append(email)
+ return filtered
+
+
+# 全局解析器实例
+_parser: Optional[EmailParser] = None
+
+
+def get_email_parser() -> EmailParser:
+ """获取全局邮件解析器实例"""
+ global _parser
+ if _parser is None:
+ _parser = EmailParser()
+ return _parser
diff --git a/src/services/outlook/health_checker.py b/src/services/outlook/health_checker.py
new file mode 100644
index 0000000..c68ed4e
--- /dev/null
+++ b/src/services/outlook/health_checker.py
@@ -0,0 +1,312 @@
+"""
+健康检查和故障切换管理
+"""
+
+import logging
+import threading
+import time
+from datetime import datetime, timedelta
+from typing import Dict, List, Optional, Any
+
+from .base import ProviderType, ProviderHealth, ProviderStatus
+from .providers.base import OutlookProvider
+
+
+logger = logging.getLogger(__name__)
+
+
+class HealthChecker:
+ """
+ 健康检查管理器
+ 跟踪各提供者的健康状态,管理故障切换
+ """
+
+ def __init__(
+ self,
+ failure_threshold: int = 3,
+ disable_duration: int = 300,
+ recovery_check_interval: int = 60,
+ ):
+ """
+ 初始化健康检查器
+
+ Args:
+ failure_threshold: 连续失败次数阈值,超过后禁用
+ disable_duration: 禁用时长(秒)
+ recovery_check_interval: 恢复检查间隔(秒)
+ """
+ self.failure_threshold = failure_threshold
+ self.disable_duration = disable_duration
+ self.recovery_check_interval = recovery_check_interval
+
+ # 提供者健康状态: ProviderType -> ProviderHealth
+ self._health_status: Dict[ProviderType, ProviderHealth] = {}
+ self._lock = threading.Lock()
+
+ # 初始化所有提供者的健康状态
+ for provider_type in ProviderType:
+ self._health_status[provider_type] = ProviderHealth(
+ provider_type=provider_type
+ )
+
+ def get_health(self, provider_type: ProviderType) -> ProviderHealth:
+ """获取提供者的健康状态"""
+ with self._lock:
+ return self._health_status.get(provider_type, ProviderHealth(provider_type=provider_type))
+
+ def record_success(self, provider_type: ProviderType):
+ """记录成功操作"""
+ with self._lock:
+ health = self._health_status.get(provider_type)
+ if health:
+ health.record_success()
+ logger.debug(f"{provider_type.value} 记录成功")
+
+ def record_failure(self, provider_type: ProviderType, error: str):
+ """记录失败操作"""
+ with self._lock:
+ health = self._health_status.get(provider_type)
+ if health:
+ health.record_failure(error)
+
+ # 检查是否需要禁用
+ if health.should_disable(self.failure_threshold):
+ health.disable(self.disable_duration)
+ logger.warning(
+ f"{provider_type.value} 已禁用 {self.disable_duration} 秒,"
+ f"原因: {error}"
+ )
+
+ def is_available(self, provider_type: ProviderType) -> bool:
+ """
+ 检查提供者是否可用
+
+ Args:
+ provider_type: 提供者类型
+
+ Returns:
+ 是否可用
+ """
+ health = self.get_health(provider_type)
+
+ # 检查是否被禁用
+ if health.is_disabled():
+ remaining = (health.disabled_until - datetime.now()).total_seconds()
+ logger.debug(
+ f"{provider_type.value} 已被禁用,剩余 {int(remaining)} 秒"
+ )
+ return False
+
+ return health.status != ProviderStatus.DISABLED
+
+ def get_available_providers(
+ self,
+ priority_order: Optional[List[ProviderType]] = None,
+ ) -> List[ProviderType]:
+ """
+ 获取可用的提供者列表
+
+ Args:
+ priority_order: 优先级顺序,默认为 [IMAP_NEW, IMAP_OLD, GRAPH_API]
+
+ Returns:
+ 可用的提供者列表
+ """
+ if priority_order is None:
+ priority_order = [
+ ProviderType.IMAP_NEW,
+ ProviderType.IMAP_OLD,
+ ProviderType.GRAPH_API,
+ ]
+
+ available = []
+ for provider_type in priority_order:
+ if self.is_available(provider_type):
+ available.append(provider_type)
+
+ return available
+
+ def get_next_available_provider(
+ self,
+ priority_order: Optional[List[ProviderType]] = None,
+ ) -> Optional[ProviderType]:
+ """
+ 获取下一个可用的提供者
+
+ Args:
+ priority_order: 优先级顺序
+
+ Returns:
+ 可用的提供者类型,如果没有返回 None
+ """
+ available = self.get_available_providers(priority_order)
+ return available[0] if available else None
+
+ def force_disable(self, provider_type: ProviderType, duration: Optional[int] = None):
+ """
+ 强制禁用提供者
+
+ Args:
+ provider_type: 提供者类型
+ duration: 禁用时长(秒),默认使用配置值
+ """
+ with self._lock:
+ health = self._health_status.get(provider_type)
+ if health:
+ health.disable(duration or self.disable_duration)
+ logger.warning(f"{provider_type.value} 已强制禁用")
+
+ def force_enable(self, provider_type: ProviderType):
+ """
+ 强制启用提供者
+
+ Args:
+ provider_type: 提供者类型
+ """
+ with self._lock:
+ health = self._health_status.get(provider_type)
+ if health:
+ health.enable()
+ logger.info(f"{provider_type.value} 已启用")
+
+ def get_all_health_status(self) -> Dict[str, Any]:
+ """
+ 获取所有提供者的健康状态
+
+ Returns:
+ 健康状态字典
+ """
+ with self._lock:
+ return {
+ provider_type.value: health.to_dict()
+ for provider_type, health in self._health_status.items()
+ }
+
+ def check_and_recover(self):
+ """
+ 检查并恢复被禁用的提供者
+
+ 如果禁用时间已过,自动恢复提供者
+ """
+ with self._lock:
+ for provider_type, health in self._health_status.items():
+ if health.is_disabled():
+ # 检查是否可以恢复
+ if health.disabled_until and datetime.now() >= health.disabled_until:
+ health.enable()
+ logger.info(f"{provider_type.value} 已自动恢复")
+
+ def reset_all(self):
+ """重置所有提供者的健康状态"""
+ with self._lock:
+ for provider_type in ProviderType:
+ self._health_status[provider_type] = ProviderHealth(
+ provider_type=provider_type
+ )
+ logger.info("已重置所有提供者的健康状态")
+
+
+class FailoverManager:
+ """
+ 故障切换管理器
+ 管理提供者之间的自动切换
+ """
+
+ def __init__(
+ self,
+ health_checker: HealthChecker,
+ priority_order: Optional[List[ProviderType]] = None,
+ ):
+ """
+ 初始化故障切换管理器
+
+ Args:
+ health_checker: 健康检查器
+ priority_order: 提供者优先级顺序
+ """
+ self.health_checker = health_checker
+ self.priority_order = priority_order or [
+ ProviderType.IMAP_NEW,
+ ProviderType.IMAP_OLD,
+ ProviderType.GRAPH_API,
+ ]
+
+ # 当前使用的提供者索引
+ self._current_index = 0
+ self._lock = threading.Lock()
+
+ def get_current_provider(self) -> Optional[ProviderType]:
+ """
+ 获取当前提供者
+
+ Returns:
+ 当前提供者类型,如果没有可用的返回 None
+ """
+ available = self.health_checker.get_available_providers(self.priority_order)
+ if not available:
+ return None
+
+ with self._lock:
+ # 尝试使用当前索引
+ if self._current_index < len(available):
+ return available[self._current_index]
+ return available[0]
+
+ def switch_to_next(self) -> Optional[ProviderType]:
+ """
+ 切换到下一个提供者
+
+ Returns:
+ 下一个提供者类型,如果没有可用的返回 None
+ """
+ available = self.health_checker.get_available_providers(self.priority_order)
+ if not available:
+ return None
+
+ with self._lock:
+ self._current_index = (self._current_index + 1) % len(available)
+ next_provider = available[self._current_index]
+ logger.info(f"切换到提供者: {next_provider.value}")
+ return next_provider
+
+ def on_provider_success(self, provider_type: ProviderType):
+ """
+ 提供者成功时调用
+
+ Args:
+ provider_type: 提供者类型
+ """
+ self.health_checker.record_success(provider_type)
+
+ # 重置索引到成功的提供者
+ with self._lock:
+ available = self.health_checker.get_available_providers(self.priority_order)
+ if provider_type in available:
+ self._current_index = available.index(provider_type)
+
+ def on_provider_failure(self, provider_type: ProviderType, error: str):
+ """
+ 提供者失败时调用
+
+ Args:
+ provider_type: 提供者类型
+ error: 错误信息
+ """
+ self.health_checker.record_failure(provider_type, error)
+
+ def get_status(self) -> Dict[str, Any]:
+ """
+ 获取故障切换状态
+
+ Returns:
+ 状态字典
+ """
+ current = self.get_current_provider()
+ return {
+ "current_provider": current.value if current else None,
+ "priority_order": [p.value for p in self.priority_order],
+ "available_providers": [
+ p.value for p in self.health_checker.get_available_providers(self.priority_order)
+ ],
+ "health_status": self.health_checker.get_all_health_status(),
+ }
diff --git a/src/services/outlook/providers/__init__.py b/src/services/outlook/providers/__init__.py
new file mode 100644
index 0000000..d6fe6a1
--- /dev/null
+++ b/src/services/outlook/providers/__init__.py
@@ -0,0 +1,29 @@
+"""
+Outlook 提供者模块
+"""
+
+from .base import OutlookProvider, ProviderConfig
+from .imap_old import IMAPOldProvider
+from .imap_new import IMAPNewProvider
+from .graph_api import GraphAPIProvider
+
+__all__ = [
+ 'OutlookProvider',
+ 'ProviderConfig',
+ 'IMAPOldProvider',
+ 'IMAPNewProvider',
+ 'GraphAPIProvider',
+]
+
+
+# 提供者注册表
+PROVIDER_REGISTRY = {
+ 'imap_old': IMAPOldProvider,
+ 'imap_new': IMAPNewProvider,
+ 'graph_api': GraphAPIProvider,
+}
+
+
+def get_provider_class(provider_type: str):
+ """获取提供者类"""
+ return PROVIDER_REGISTRY.get(provider_type)
diff --git a/src/services/outlook/providers/base.py b/src/services/outlook/providers/base.py
new file mode 100644
index 0000000..0d6c072
--- /dev/null
+++ b/src/services/outlook/providers/base.py
@@ -0,0 +1,180 @@
+"""
+Outlook 提供者抽象基类
+"""
+
+import abc
+import logging
+from dataclasses import dataclass
+from typing import Dict, Any, List, Optional
+
+from ..base import ProviderType, EmailMessage, ProviderHealth, ProviderStatus
+from ..account import OutlookAccount
+
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class ProviderConfig:
+ """提供者配置"""
+ timeout: int = 30
+ max_retries: int = 3
+ proxy_url: Optional[str] = None
+
+ # 健康检查配置
+ health_failure_threshold: int = 3
+ health_disable_duration: int = 300 # 秒
+
+
+class OutlookProvider(abc.ABC):
+ """
+ Outlook 提供者抽象基类
+ 定义所有提供者必须实现的接口
+ """
+
+ def __init__(
+ self,
+ account: OutlookAccount,
+ config: Optional[ProviderConfig] = None,
+ ):
+ """
+ 初始化提供者
+
+ Args:
+ account: Outlook 账户
+ config: 提供者配置
+ """
+ self.account = account
+ self.config = config or ProviderConfig()
+
+ # 健康状态
+ self._health = ProviderHealth(provider_type=self.provider_type)
+
+ # 连接状态
+ self._connected = False
+ self._last_error: Optional[str] = None
+
+ @property
+ @abc.abstractmethod
+ def provider_type(self) -> ProviderType:
+ """获取提供者类型"""
+ pass
+
+ @property
+ def health(self) -> ProviderHealth:
+ """获取健康状态"""
+ return self._health
+
+ @property
+ def is_healthy(self) -> bool:
+ """检查是否健康"""
+ return (
+ self._health.status == ProviderStatus.HEALTHY
+ and not self._health.is_disabled()
+ )
+
+ @property
+ def is_connected(self) -> bool:
+ """检查是否已连接"""
+ return self._connected
+
+ @abc.abstractmethod
+ def connect(self) -> bool:
+ """
+ 连接到服务
+
+ Returns:
+ 是否连接成功
+ """
+ pass
+
+ @abc.abstractmethod
+ def disconnect(self):
+ """断开连接"""
+ pass
+
+ @abc.abstractmethod
+ def get_recent_emails(
+ self,
+ count: int = 20,
+ only_unseen: bool = True,
+ ) -> List[EmailMessage]:
+ """
+ 获取最近的邮件
+
+ Args:
+ count: 获取数量
+ only_unseen: 是否只获取未读
+
+ Returns:
+ 邮件列表
+ """
+ pass
+
+ @abc.abstractmethod
+ def test_connection(self) -> bool:
+ """
+ 测试连接是否正常
+
+ Returns:
+ 连接是否正常
+ """
+ pass
+
+ def record_success(self):
+ """记录成功操作"""
+ self._health.record_success()
+ self._last_error = None
+ logger.debug(f"[{self.account.email}] {self.provider_type.value} 操作成功")
+
+ def record_failure(self, error: str):
+ """记录失败操作"""
+ self._health.record_failure(error)
+ self._last_error = error
+
+ # 检查是否需要禁用
+ if self._health.should_disable(self.config.health_failure_threshold):
+ self._health.disable(self.config.health_disable_duration)
+ logger.warning(
+ f"[{self.account.email}] {self.provider_type.value} 已禁用 "
+ f"{self.config.health_disable_duration} 秒,原因: {error}"
+ )
+ else:
+ logger.warning(
+ f"[{self.account.email}] {self.provider_type.value} 操作失败 "
+ f"({self._health.failure_count}/{self.config.health_failure_threshold}): {error}"
+ )
+
+ def check_health(self) -> bool:
+ """
+ 检查健康状态
+
+ Returns:
+ 是否健康可用
+ """
+ # 检查是否被禁用
+ if self._health.is_disabled():
+ logger.debug(
+ f"[{self.account.email}] {self.provider_type.value} 已被禁用,"
+ f"将在 {self._health.disabled_until} 后恢复"
+ )
+ return False
+
+ return self._health.status in (ProviderStatus.HEALTHY, ProviderStatus.DEGRADED)
+
+ def __enter__(self):
+ """上下文管理器入口"""
+ self.connect()
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ """上下文管理器出口"""
+ self.disconnect()
+ return False
+
+ def __str__(self) -> str:
+ """字符串表示"""
+ return f"{self.__class__.__name__}({self.account.email})"
+
+ def __repr__(self) -> str:
+ return self.__str__()
diff --git a/src/services/outlook/providers/graph_api.py b/src/services/outlook/providers/graph_api.py
new file mode 100644
index 0000000..00cec80
--- /dev/null
+++ b/src/services/outlook/providers/graph_api.py
@@ -0,0 +1,250 @@
+"""
+Graph API 提供者
+使用 Microsoft Graph REST API
+"""
+
+import json
+import logging
+from typing import List, Optional
+from datetime import datetime
+
+from curl_cffi import requests as _requests
+
+from ..base import ProviderType, EmailMessage
+from ..account import OutlookAccount
+from ..token_manager import TokenManager
+from .base import OutlookProvider, ProviderConfig
+
+
+logger = logging.getLogger(__name__)
+
+
+class GraphAPIProvider(OutlookProvider):
+ """
+ Graph API 提供者
+ 使用 Microsoft Graph REST API 获取邮件
+ 需要 graph.microsoft.com/.default scope
+ """
+
+ # Graph API 端点
+ GRAPH_API_BASE = "https://graph.microsoft.com/v1.0"
+ MESSAGES_ENDPOINT = "/me/mailFolders/inbox/messages"
+
+ @property
+ def provider_type(self) -> ProviderType:
+ return ProviderType.GRAPH_API
+
+ def __init__(
+ self,
+ account: OutlookAccount,
+ config: Optional[ProviderConfig] = None,
+ ):
+ super().__init__(account, config)
+
+ # Token 管理器
+ self._token_manager: Optional[TokenManager] = None
+
+ # 注意:Graph API 必须使用 OAuth2
+ if not account.has_oauth():
+ logger.warning(
+ f"[{self.account.email}] Graph API 提供者需要 OAuth2 配置 "
+ f"(client_id + refresh_token)"
+ )
+
+ def connect(self) -> bool:
+ """
+ 验证连接(获取 Token)
+
+ Returns:
+ 是否连接成功
+ """
+ if not self.account.has_oauth():
+ error = "Graph API 需要 OAuth2 配置"
+ self.record_failure(error)
+ logger.error(f"[{self.account.email}] {error}")
+ return False
+
+ if not self._token_manager:
+ self._token_manager = TokenManager(
+ self.account,
+ ProviderType.GRAPH_API,
+ self.config.proxy_url,
+ self.config.timeout,
+ )
+
+ # 尝试获取 Token
+ token = self._token_manager.get_access_token()
+ if token:
+ self._connected = True
+ self.record_success()
+ logger.info(f"[{self.account.email}] Graph API 连接成功")
+ return True
+
+ return False
+
+ def disconnect(self):
+ """断开连接(清除状态)"""
+ self._connected = False
+
+ def get_recent_emails(
+ self,
+ count: int = 20,
+ only_unseen: bool = True,
+ ) -> List[EmailMessage]:
+ """
+ 获取最近的邮件
+
+ Args:
+ count: 获取数量
+ only_unseen: 是否只获取未读
+
+ Returns:
+ 邮件列表
+ """
+ if not self._connected:
+ if not self.connect():
+ return []
+
+ try:
+ # 获取 Access Token
+ token = self._token_manager.get_access_token()
+ if not token:
+ self.record_failure("无法获取 Access Token")
+ return []
+
+ # 构建 API 请求
+ url = f"{self.GRAPH_API_BASE}{self.MESSAGES_ENDPOINT}"
+
+ params = {
+ "$top": count,
+ "$select": "id,subject,from,toRecipients,receivedDateTime,isRead,hasAttachments,bodyPreview,body",
+ "$orderby": "receivedDateTime desc",
+ }
+
+ # 只获取未读邮件
+ if only_unseen:
+ params["$filter"] = "isRead eq false"
+
+ # 构建代理配置
+ proxies = None
+ if self.config.proxy_url:
+ proxies = {"http": self.config.proxy_url, "https": self.config.proxy_url}
+
+ # 发送请求(curl_cffi 自动对 params 进行 URL 编码)
+ resp = _requests.get(
+ url,
+ params=params,
+ headers={
+ "Authorization": f"Bearer {token}",
+ "Accept": "application/json",
+ "Prefer": "outlook.body-content-type='text'",
+ },
+ proxies=proxies,
+ timeout=self.config.timeout,
+ impersonate="chrome110",
+ )
+
+ if resp.status_code == 401:
+ # Token 无 Graph 权限(client_id 未授权),清除缓存但不记录健康失败
+ # 避免因权限不足导致健康检查器禁用该提供者,影响其他账户
+ if self._token_manager:
+ self._token_manager.clear_cache()
+ self._connected = False
+ logger.warning(f"[{self.account.email}] Graph API 返回 401,client_id 可能无 Graph 权限,跳过")
+ return []
+
+ if resp.status_code != 200:
+ error_body = resp.text[:200]
+ self.record_failure(f"HTTP {resp.status_code}: {error_body}")
+ logger.error(f"[{self.account.email}] Graph API 请求失败: HTTP {resp.status_code}")
+ return []
+
+ data = resp.json()
+
+ # 解析邮件
+ messages = data.get("value", [])
+ emails = []
+
+ for msg in messages:
+ try:
+ email_msg = self._parse_graph_message(msg)
+ if email_msg:
+ emails.append(email_msg)
+ except Exception as e:
+ logger.warning(f"[{self.account.email}] 解析 Graph API 邮件失败: {e}")
+
+ self.record_success()
+ return emails
+
+ except Exception as e:
+ self.record_failure(str(e))
+ logger.error(f"[{self.account.email}] Graph API 获取邮件失败: {e}")
+ return []
+
+ def _parse_graph_message(self, msg: dict) -> Optional[EmailMessage]:
+ """
+ 解析 Graph API 消息
+
+ Args:
+ msg: Graph API 消息对象
+
+ Returns:
+ EmailMessage 对象
+ """
+ # 解析发件人
+ from_info = msg.get("from", {})
+ sender_info = from_info.get("emailAddress", {})
+ sender = sender_info.get("address", "")
+
+ # 解析收件人
+ recipients = []
+ for recipient in msg.get("toRecipients", []):
+ addr_info = recipient.get("emailAddress", {})
+ addr = addr_info.get("address", "")
+ if addr:
+ recipients.append(addr)
+
+ # 解析日期
+ received_at = None
+ received_timestamp = 0
+ try:
+ date_str = msg.get("receivedDateTime", "")
+ if date_str:
+ # ISO 8601 格式
+ received_at = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
+ received_timestamp = int(received_at.timestamp())
+ except Exception:
+ pass
+
+ # 获取正文
+ body_info = msg.get("body", {})
+ body = body_info.get("content", "")
+ body_preview = msg.get("bodyPreview", "")
+
+ return EmailMessage(
+ id=msg.get("id", ""),
+ subject=msg.get("subject", ""),
+ sender=sender,
+ recipients=recipients,
+ body=body,
+ body_preview=body_preview,
+ received_at=received_at,
+ received_timestamp=received_timestamp,
+ is_read=msg.get("isRead", False),
+ has_attachments=msg.get("hasAttachments", False),
+ )
+
+ def test_connection(self) -> bool:
+ """
+ 测试 Graph API 连接
+
+ Returns:
+ 连接是否正常
+ """
+ try:
+ # 尝试获取一封邮件来测试连接
+ emails = self.get_recent_emails(count=1, only_unseen=False)
+ return True
+ except Exception as e:
+ logger.warning(f"[{self.account.email}] Graph API 连接测试失败: {e}")
+ return False
diff --git a/src/services/outlook/providers/imap_new.py b/src/services/outlook/providers/imap_new.py
new file mode 100644
index 0000000..5daa2f3
--- /dev/null
+++ b/src/services/outlook/providers/imap_new.py
@@ -0,0 +1,231 @@
+"""
+新版 IMAP 提供者
+使用 outlook.live.com 服务器和 login.microsoftonline.com/consumers Token 端点
+"""
+
+import email
+import imaplib
+import logging
+from email.header import decode_header
+from email.utils import parsedate_to_datetime
+from typing import List, Optional
+
+from ..base import ProviderType, EmailMessage
+from ..account import OutlookAccount
+from ..token_manager import TokenManager
+from .base import OutlookProvider, ProviderConfig
+from .imap_old import IMAPOldProvider
+
+
+logger = logging.getLogger(__name__)
+
+
+class IMAPNewProvider(OutlookProvider):
+ """
+ 新版 IMAP 提供者
+ 使用 outlook.live.com:993 和 login.microsoftonline.com/consumers Token 端点
+ 需要 IMAP.AccessAsUser.All scope
+ """
+
+ # IMAP 服务器配置
+ IMAP_HOST = "outlook.live.com"
+ IMAP_PORT = 993
+
+ @property
+ def provider_type(self) -> ProviderType:
+ return ProviderType.IMAP_NEW
+
+ def __init__(
+ self,
+ account: OutlookAccount,
+ config: Optional[ProviderConfig] = None,
+ ):
+ super().__init__(account, config)
+
+ # IMAP 连接
+ self._conn: Optional[imaplib.IMAP4_SSL] = None
+
+ # Token 管理器
+ self._token_manager: Optional[TokenManager] = None
+
+ # 注意:新版 IMAP 必须使用 OAuth2
+ if not account.has_oauth():
+ logger.warning(
+ f"[{self.account.email}] 新版 IMAP 提供者需要 OAuth2 配置 "
+ f"(client_id + refresh_token)"
+ )
+
+ def connect(self) -> bool:
+ """
+ 连接到 IMAP 服务器
+
+ Returns:
+ 是否连接成功
+ """
+ if self._connected and self._conn:
+ try:
+ self._conn.noop()
+ return True
+ except Exception:
+ self.disconnect()
+
+ # 新版 IMAP 必须使用 OAuth2,无 OAuth 时静默跳过,不记录健康失败
+ if not self.account.has_oauth():
+ logger.debug(f"[{self.account.email}] 跳过 IMAP_NEW(无 OAuth)")
+ return False
+
+ try:
+ logger.debug(f"[{self.account.email}] 正在连接 IMAP ({self.IMAP_HOST})...")
+
+ # 创建连接
+ self._conn = imaplib.IMAP4_SSL(
+ self.IMAP_HOST,
+ self.IMAP_PORT,
+ timeout=self.config.timeout,
+ )
+
+ # XOAUTH2 认证
+ if self._authenticate_xoauth2():
+ self._connected = True
+ self.record_success()
+ logger.info(f"[{self.account.email}] 新版 IMAP 连接成功 (XOAUTH2)")
+ return True
+
+ return False
+
+ except Exception as e:
+ self.disconnect()
+ self.record_failure(str(e))
+ logger.error(f"[{self.account.email}] 新版 IMAP 连接失败: {e}")
+ return False
+
+ def _authenticate_xoauth2(self) -> bool:
+ """
+ 使用 XOAUTH2 认证
+
+ Returns:
+ 是否认证成功
+ """
+ if not self._token_manager:
+ self._token_manager = TokenManager(
+ self.account,
+ ProviderType.IMAP_NEW,
+ self.config.proxy_url,
+ self.config.timeout,
+ )
+
+ # 获取 Access Token
+ token = self._token_manager.get_access_token()
+ if not token:
+ logger.error(f"[{self.account.email}] 获取 IMAP Token 失败")
+ return False
+
+ try:
+ # 构建 XOAUTH2 认证字符串
+ auth_string = f"user={self.account.email}\x01auth=Bearer {token}\x01\x01"
+ self._conn.authenticate("XOAUTH2", lambda _: auth_string.encode("utf-8"))
+ return True
+ except Exception as e:
+ logger.error(f"[{self.account.email}] XOAUTH2 认证异常: {e}")
+ # 清除缓存的 Token
+ self._token_manager.clear_cache()
+ return False
+
+ def disconnect(self):
+ """断开 IMAP 连接"""
+ if self._conn:
+ try:
+ self._conn.close()
+ except Exception:
+ pass
+ try:
+ self._conn.logout()
+ except Exception:
+ pass
+ self._conn = None
+
+ self._connected = False
+
+ def get_recent_emails(
+ self,
+ count: int = 20,
+ only_unseen: bool = True,
+ ) -> List[EmailMessage]:
+ """
+ 获取最近的邮件
+
+ Args:
+ count: 获取数量
+ only_unseen: 是否只获取未读
+
+ Returns:
+ 邮件列表
+ """
+ if not self._connected:
+ if not self.connect():
+ return []
+
+ try:
+ # 选择收件箱
+ self._conn.select("INBOX", readonly=True)
+
+ # 搜索邮件
+ flag = "UNSEEN" if only_unseen else "ALL"
+ status, data = self._conn.search(None, flag)
+
+ if status != "OK" or not data or not data[0]:
+ return []
+
+ # 获取最新的邮件 ID
+ ids = data[0].split()
+ recent_ids = ids[-count:][::-1]
+
+ emails = []
+ for msg_id in recent_ids:
+ try:
+ email_msg = self._fetch_email(msg_id)
+ if email_msg:
+ emails.append(email_msg)
+ except Exception as e:
+ logger.warning(f"[{self.account.email}] 解析邮件失败 (ID: {msg_id}): {e}")
+
+ return emails
+
+ except Exception as e:
+ self.record_failure(str(e))
+ logger.error(f"[{self.account.email}] 获取邮件失败: {e}")
+ return []
+
+ def _fetch_email(self, msg_id: bytes) -> Optional[EmailMessage]:
+ """获取并解析单封邮件"""
+ status, data = self._conn.fetch(msg_id, "(RFC822)")
+ if status != "OK" or not data or not data[0]:
+ return None
+
+ raw = b""
+ for part in data:
+ if isinstance(part, tuple) and len(part) > 1:
+ raw = part[1]
+ break
+
+ if not raw:
+ return None
+
+ return self._parse_email(raw)
+
+ @staticmethod
+ def _parse_email(raw: bytes) -> EmailMessage:
+ """解析原始邮件"""
+ # 使用旧版提供者的解析方法
+ return IMAPOldProvider._parse_email(raw)
+
+ def test_connection(self) -> bool:
+ """测试 IMAP 连接"""
+ try:
+ with self:
+ self._conn.select("INBOX", readonly=True)
+ self._conn.search(None, "ALL")
+ return True
+ except Exception as e:
+ logger.warning(f"[{self.account.email}] 新版 IMAP 连接测试失败: {e}")
+ return False
diff --git a/src/services/outlook/providers/imap_old.py b/src/services/outlook/providers/imap_old.py
new file mode 100644
index 0000000..e46f3ed
--- /dev/null
+++ b/src/services/outlook/providers/imap_old.py
@@ -0,0 +1,345 @@
+"""
+旧版 IMAP 提供者
+使用 outlook.office365.com 服务器和 login.live.com Token 端点
+"""
+
+import email
+import imaplib
+import logging
+from email.header import decode_header
+from email.utils import parsedate_to_datetime
+from typing import List, Optional
+
+from ..base import ProviderType, EmailMessage
+from ..account import OutlookAccount
+from ..token_manager import TokenManager
+from .base import OutlookProvider, ProviderConfig
+
+
+logger = logging.getLogger(__name__)
+
+
+class IMAPOldProvider(OutlookProvider):
+ """
+ 旧版 IMAP 提供者
+ 使用 outlook.office365.com:993 和 login.live.com Token 端点
+ """
+
+ # IMAP 服务器配置
+ IMAP_HOST = "outlook.office365.com"
+ IMAP_PORT = 993
+
+ @property
+ def provider_type(self) -> ProviderType:
+ return ProviderType.IMAP_OLD
+
+ def __init__(
+ self,
+ account: OutlookAccount,
+ config: Optional[ProviderConfig] = None,
+ ):
+ super().__init__(account, config)
+
+ # IMAP 连接
+ self._conn: Optional[imaplib.IMAP4_SSL] = None
+
+ # Token 管理器
+ self._token_manager: Optional[TokenManager] = None
+
+ def connect(self) -> bool:
+ """
+ 连接到 IMAP 服务器
+
+ Returns:
+ 是否连接成功
+ """
+ if self._connected and self._conn:
+ # 检查现有连接
+ try:
+ self._conn.noop()
+ return True
+ except Exception:
+ self.disconnect()
+
+ try:
+ logger.debug(f"[{self.account.email}] 正在连接 IMAP ({self.IMAP_HOST})...")
+
+ # 创建连接
+ self._conn = imaplib.IMAP4_SSL(
+ self.IMAP_HOST,
+ self.IMAP_PORT,
+ timeout=self.config.timeout,
+ )
+
+ # 尝试 XOAUTH2 认证
+ if self.account.has_oauth():
+ if self._authenticate_xoauth2():
+ self._connected = True
+ self.record_success()
+ logger.info(f"[{self.account.email}] IMAP 连接成功 (XOAUTH2)")
+ return True
+ else:
+ logger.warning(f"[{self.account.email}] XOAUTH2 认证失败,尝试密码认证")
+
+ # 密码认证
+ if self.account.password:
+ self._conn.login(self.account.email, self.account.password)
+ self._connected = True
+ self.record_success()
+ logger.info(f"[{self.account.email}] IMAP 连接成功 (密码认证)")
+ return True
+
+ raise ValueError("没有可用的认证方式")
+
+ except Exception as e:
+ self.disconnect()
+ self.record_failure(str(e))
+ logger.error(f"[{self.account.email}] IMAP 连接失败: {e}")
+ return False
+
+ def _authenticate_xoauth2(self) -> bool:
+ """
+ 使用 XOAUTH2 认证
+
+ Returns:
+ 是否认证成功
+ """
+ if not self._token_manager:
+ self._token_manager = TokenManager(
+ self.account,
+ ProviderType.IMAP_OLD,
+ self.config.proxy_url,
+ self.config.timeout,
+ )
+
+ # 获取 Access Token
+ token = self._token_manager.get_access_token()
+ if not token:
+ return False
+
+ try:
+ # 构建 XOAUTH2 认证字符串
+ auth_string = f"user={self.account.email}\x01auth=Bearer {token}\x01\x01"
+ self._conn.authenticate("XOAUTH2", lambda _: auth_string.encode("utf-8"))
+ return True
+ except Exception as e:
+ logger.debug(f"[{self.account.email}] XOAUTH2 认证异常: {e}")
+ # 清除缓存的 Token
+ self._token_manager.clear_cache()
+ return False
+
+ def disconnect(self):
+ """断开 IMAP 连接"""
+ if self._conn:
+ try:
+ self._conn.close()
+ except Exception:
+ pass
+ try:
+ self._conn.logout()
+ except Exception:
+ pass
+ self._conn = None
+
+ self._connected = False
+
+ def get_recent_emails(
+ self,
+ count: int = 20,
+ only_unseen: bool = True,
+ ) -> List[EmailMessage]:
+ """
+ 获取最近的邮件
+
+ Args:
+ count: 获取数量
+ only_unseen: 是否只获取未读
+
+ Returns:
+ 邮件列表
+ """
+ if not self._connected:
+ if not self.connect():
+ return []
+
+ try:
+ # 选择收件箱
+ self._conn.select("INBOX", readonly=True)
+
+ # 搜索邮件
+ flag = "UNSEEN" if only_unseen else "ALL"
+ status, data = self._conn.search(None, flag)
+
+ if status != "OK" or not data or not data[0]:
+ return []
+
+ # 获取最新的邮件 ID
+ ids = data[0].split()
+ recent_ids = ids[-count:][::-1] # 倒序,最新的在前
+
+ emails = []
+ for msg_id in recent_ids:
+ try:
+ email_msg = self._fetch_email(msg_id)
+ if email_msg:
+ emails.append(email_msg)
+ except Exception as e:
+ logger.warning(f"[{self.account.email}] 解析邮件失败 (ID: {msg_id}): {e}")
+
+ return emails
+
+ except Exception as e:
+ self.record_failure(str(e))
+ logger.error(f"[{self.account.email}] 获取邮件失败: {e}")
+ return []
+
+ def _fetch_email(self, msg_id: bytes) -> Optional[EmailMessage]:
+ """
+ 获取并解析单封邮件
+
+ Args:
+ msg_id: 邮件 ID
+
+ Returns:
+ EmailMessage 对象,失败返回 None
+ """
+ status, data = self._conn.fetch(msg_id, "(RFC822)")
+ if status != "OK" or not data or not data[0]:
+ return None
+
+ # 获取原始邮件内容
+ raw = b""
+ for part in data:
+ if isinstance(part, tuple) and len(part) > 1:
+ raw = part[1]
+ break
+
+ if not raw:
+ return None
+
+ return self._parse_email(raw)
+
+ @staticmethod
+ def _parse_email(raw: bytes) -> EmailMessage:
+ """
+ 解析原始邮件
+
+ Args:
+ raw: 原始邮件数据
+
+ Returns:
+ EmailMessage 对象
+ """
+ # 移除 BOM
+ if raw.startswith(b"\xef\xbb\xbf"):
+ raw = raw[3:]
+
+ msg = email.message_from_bytes(raw)
+
+ # 解析邮件头
+ subject = IMAPOldProvider._decode_header(msg.get("Subject", ""))
+ sender = IMAPOldProvider._decode_header(msg.get("From", ""))
+ to = IMAPOldProvider._decode_header(msg.get("To", ""))
+ delivered_to = IMAPOldProvider._decode_header(msg.get("Delivered-To", ""))
+ x_original_to = IMAPOldProvider._decode_header(msg.get("X-Original-To", ""))
+ date_str = IMAPOldProvider._decode_header(msg.get("Date", ""))
+
+ # 提取正文
+ body = IMAPOldProvider._extract_body(msg)
+
+ # 解析日期
+ received_timestamp = 0
+ received_at = None
+ try:
+ if date_str:
+ received_at = parsedate_to_datetime(date_str)
+ received_timestamp = int(received_at.timestamp())
+ except Exception:
+ pass
+
+ # 构建收件人列表
+ recipients = [r for r in [to, delivered_to, x_original_to] if r]
+
+ return EmailMessage(
+ id=msg.get("Message-ID", ""),
+ subject=subject,
+ sender=sender,
+ recipients=recipients,
+ body=body,
+ received_at=received_at,
+ received_timestamp=received_timestamp,
+ is_read=False, # 搜索的是未读邮件
+ raw_data=raw[:500] if len(raw) > 500 else raw,
+ )
+
+ @staticmethod
+ def _decode_header(header: str) -> str:
+ """解码邮件头"""
+ if not header:
+ return ""
+
+ parts = []
+ for chunk, encoding in decode_header(header):
+ if isinstance(chunk, bytes):
+ try:
+ decoded = chunk.decode(encoding or "utf-8", errors="replace")
+ parts.append(decoded)
+ except Exception:
+ parts.append(chunk.decode("utf-8", errors="replace"))
+ else:
+ parts.append(str(chunk))
+
+ return "".join(parts).strip()
+
+ @staticmethod
+ def _extract_body(msg) -> str:
+ """提取邮件正文"""
+ import html as html_module
+ import re
+
+ texts = []
+ parts = msg.walk() if msg.is_multipart() else [msg]
+
+ for part in parts:
+ content_type = part.get_content_type()
+ if content_type not in ("text/plain", "text/html"):
+ continue
+
+ payload = part.get_payload(decode=True)
+ if not payload:
+ continue
+
+ charset = part.get_content_charset() or "utf-8"
+ try:
+ text = payload.decode(charset, errors="replace")
+ except LookupError:
+ text = payload.decode("utf-8", errors="replace")
+
+ # 如果是 HTML,移除标签
+ if "]+>", " ", text)
+
+ texts.append(text)
+
+ # 合并并清理文本
+ combined = " ".join(texts)
+ combined = html_module.unescape(combined)
+ combined = re.sub(r"\s+", " ", combined).strip()
+
+ return combined
+
+ def test_connection(self) -> bool:
+ """
+ 测试 IMAP 连接
+
+ Returns:
+ 连接是否正常
+ """
+ try:
+ with self:
+ self._conn.select("INBOX", readonly=True)
+ self._conn.search(None, "ALL")
+ return True
+ except Exception as e:
+ logger.warning(f"[{self.account.email}] IMAP 连接测试失败: {e}")
+ return False
diff --git a/src/services/outlook/service.py b/src/services/outlook/service.py
new file mode 100644
index 0000000..321d8b3
--- /dev/null
+++ b/src/services/outlook/service.py
@@ -0,0 +1,487 @@
+"""
+Outlook 邮箱服务主类
+支持多种 IMAP/API 连接方式,自动故障切换
+"""
+
+import logging
+import threading
+import time
+from typing import Optional, Dict, Any, List
+
+from ..base import BaseEmailService, EmailServiceError, EmailServiceStatus, EmailServiceType
+from ...config.constants import EmailServiceType as ServiceType
+from ...config.settings import get_settings
+from .account import OutlookAccount
+from .base import ProviderType, EmailMessage
+from .email_parser import EmailParser, get_email_parser
+from .health_checker import HealthChecker, FailoverManager
+from .providers.base import OutlookProvider, ProviderConfig
+from .providers.imap_old import IMAPOldProvider
+from .providers.imap_new import IMAPNewProvider
+from .providers.graph_api import GraphAPIProvider
+
+
+logger = logging.getLogger(__name__)
+
+
+# 默认提供者优先级
+# IMAP_OLD 最兼容(只需 login.live.com token),IMAP_NEW 次之,Graph API 最后
+# 原因:部分 client_id 没有 Graph API 权限,但有 IMAP 权限
+DEFAULT_PROVIDER_PRIORITY = [
+ ProviderType.IMAP_OLD,
+ ProviderType.IMAP_NEW,
+ ProviderType.GRAPH_API,
+]
+
+
+def get_email_code_settings() -> dict:
+ """获取验证码等待配置"""
+ settings = get_settings()
+ return {
+ "timeout": settings.email_code_timeout,
+ "poll_interval": settings.email_code_poll_interval,
+ }
+
+
+class OutlookService(BaseEmailService):
+ """
+ Outlook 邮箱服务
+ 支持多种 IMAP/API 连接方式,自动故障切换
+ """
+
+ def __init__(self, config: Dict[str, Any] = None, name: str = None):
+ """
+ 初始化 Outlook 服务
+
+ Args:
+ config: 配置字典,支持以下键:
+ - accounts: Outlook 账户列表
+ - provider_priority: 提供者优先级列表
+ - health_failure_threshold: 连续失败次数阈值
+ - health_disable_duration: 禁用时长(秒)
+ - timeout: 请求超时时间
+ - proxy_url: 代理 URL
+ name: 服务名称
+ """
+ super().__init__(ServiceType.OUTLOOK, name)
+
+ # 默认配置
+ default_config = {
+ "accounts": [],
+ "provider_priority": [p.value for p in DEFAULT_PROVIDER_PRIORITY],
+ "health_failure_threshold": 5,
+ "health_disable_duration": 60,
+ "timeout": 30,
+ "proxy_url": None,
+ }
+
+ self.config = {**default_config, **(config or {})}
+
+ # 解析提供者优先级
+ self.provider_priority = [
+ ProviderType(p) for p in self.config.get("provider_priority", [])
+ ]
+ if not self.provider_priority:
+ self.provider_priority = DEFAULT_PROVIDER_PRIORITY
+
+ # 提供者配置
+ self.provider_config = ProviderConfig(
+ timeout=self.config.get("timeout", 30),
+ proxy_url=self.config.get("proxy_url"),
+ health_failure_threshold=self.config.get("health_failure_threshold", 3),
+ health_disable_duration=self.config.get("health_disable_duration", 300),
+ )
+
+ # 获取默认 client_id(供无 client_id 的账户使用)
+ try:
+ _default_client_id = get_settings().outlook_default_client_id
+ except Exception:
+ _default_client_id = "24d9a0ed-8787-4584-883c-2fd79308940a"
+
+ # 解析账户
+ self.accounts: List[OutlookAccount] = []
+ self._current_account_index = 0
+ self._account_lock = threading.Lock()
+
+ # 支持两种配置格式
+ if "email" in self.config and "password" in self.config:
+ account = OutlookAccount.from_config(self.config)
+ if not account.client_id and _default_client_id:
+ account.client_id = _default_client_id
+ if account.validate():
+ self.accounts.append(account)
+ else:
+ for account_config in self.config.get("accounts", []):
+ account = OutlookAccount.from_config(account_config)
+ if not account.client_id and _default_client_id:
+ account.client_id = _default_client_id
+ if account.validate():
+ self.accounts.append(account)
+
+ if not self.accounts:
+ logger.warning("未配置有效的 Outlook 账户")
+
+ # 健康检查器和故障切换管理器
+ self.health_checker = HealthChecker(
+ failure_threshold=self.provider_config.health_failure_threshold,
+ disable_duration=self.provider_config.health_disable_duration,
+ )
+ self.failover_manager = FailoverManager(
+ health_checker=self.health_checker,
+ priority_order=self.provider_priority,
+ )
+
+ # 邮件解析器
+ self.email_parser = get_email_parser()
+
+ # 提供者实例缓存: (email, provider_type) -> OutlookProvider
+ self._providers: Dict[tuple, OutlookProvider] = {}
+ self._provider_lock = threading.Lock()
+
+ # IMAP 连接限制(防止限流)
+ self._imap_semaphore = threading.Semaphore(5)
+
+ # 验证码去重机制
+ self._used_codes: Dict[str, set] = {}
+
+ def _get_provider(
+ self,
+ account: OutlookAccount,
+ provider_type: ProviderType,
+ ) -> OutlookProvider:
+ """
+ 获取或创建提供者实例
+
+ Args:
+ account: Outlook 账户
+ provider_type: 提供者类型
+
+ Returns:
+ 提供者实例
+ """
+ cache_key = (account.email.lower(), provider_type)
+
+ with self._provider_lock:
+ if cache_key not in self._providers:
+ provider = self._create_provider(account, provider_type)
+ self._providers[cache_key] = provider
+
+ return self._providers[cache_key]
+
+ def _create_provider(
+ self,
+ account: OutlookAccount,
+ provider_type: ProviderType,
+ ) -> OutlookProvider:
+ """
+ 创建提供者实例
+
+ Args:
+ account: Outlook 账户
+ provider_type: 提供者类型
+
+ Returns:
+ 提供者实例
+ """
+ if provider_type == ProviderType.IMAP_OLD:
+ return IMAPOldProvider(account, self.provider_config)
+ elif provider_type == ProviderType.IMAP_NEW:
+ return IMAPNewProvider(account, self.provider_config)
+ elif provider_type == ProviderType.GRAPH_API:
+ return GraphAPIProvider(account, self.provider_config)
+ else:
+ raise ValueError(f"未知的提供者类型: {provider_type}")
+
+ def _get_provider_priority_for_account(self, account: OutlookAccount) -> List[ProviderType]:
+ """根据账户是否有 OAuth,返回适合的提供者优先级列表"""
+ if account.has_oauth():
+ return self.provider_priority
+ else:
+ # 无 OAuth,直接走旧版 IMAP(密码认证),跳过需要 OAuth 的提供者
+ return [ProviderType.IMAP_OLD]
+
+ def _try_providers_for_emails(
+ self,
+ account: OutlookAccount,
+ count: int = 20,
+ only_unseen: bool = True,
+ ) -> List[EmailMessage]:
+ """
+ 尝试多个提供者获取邮件
+
+ Args:
+ account: Outlook 账户
+ count: 获取数量
+ only_unseen: 是否只获取未读
+
+ Returns:
+ 邮件列表
+ """
+ errors = []
+
+ # 根据账户类型选择合适的提供者优先级
+ priority = self._get_provider_priority_for_account(account)
+
+ # 按优先级尝试各提供者
+ for provider_type in priority:
+ # 检查提供者是否可用
+ if not self.health_checker.is_available(provider_type):
+ logger.debug(
+ f"[{account.email}] {provider_type.value} 不可用,跳过"
+ )
+ continue
+
+ try:
+ provider = self._get_provider(account, provider_type)
+
+ with self._imap_semaphore:
+ with provider:
+ emails = provider.get_recent_emails(count, only_unseen)
+
+ if emails:
+ # 成功获取邮件
+ self.health_checker.record_success(provider_type)
+ logger.debug(
+ f"[{account.email}] {provider_type.value} 获取到 {len(emails)} 封邮件"
+ )
+ return emails
+
+ except Exception as e:
+ error_msg = str(e)
+ errors.append(f"{provider_type.value}: {error_msg}")
+ self.health_checker.record_failure(provider_type, error_msg)
+ logger.warning(
+ f"[{account.email}] {provider_type.value} 获取邮件失败: {e}"
+ )
+
+ logger.error(
+ f"[{account.email}] 所有提供者都失败: {'; '.join(errors)}"
+ )
+ return []
+
+ def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]:
+ """
+ 选择可用的 Outlook 账户
+
+ Args:
+ config: 配置参数(未使用)
+
+ Returns:
+ 包含邮箱信息的字典
+ """
+ if not self.accounts:
+ self.update_status(False, EmailServiceError("没有可用的 Outlook 账户"))
+ raise EmailServiceError("没有可用的 Outlook 账户")
+
+ # 轮询选择账户
+ with self._account_lock:
+ account = self.accounts[self._current_account_index]
+ self._current_account_index = (self._current_account_index + 1) % len(self.accounts)
+
+ email_info = {
+ "email": account.email,
+ "service_id": account.email,
+ "account": {
+ "email": account.email,
+ "has_oauth": account.has_oauth()
+ }
+ }
+
+ logger.info(f"选择 Outlook 账户: {account.email}")
+ self.update_status(True)
+ return email_info
+
+ def get_verification_code(
+ self,
+ email: str,
+ email_id: str = None,
+ timeout: int = None,
+ pattern: str = None,
+ otp_sent_at: Optional[float] = None,
+ ) -> Optional[str]:
+ """
+ 从 Outlook 邮箱获取验证码
+
+ Args:
+ email: 邮箱地址
+ email_id: 未使用
+ timeout: 超时时间(秒)
+ pattern: 验证码正则表达式(未使用)
+ otp_sent_at: OTP 发送时间戳
+
+ Returns:
+ 验证码字符串
+ """
+ # 查找对应的账户
+ account = None
+ for acc in self.accounts:
+ if acc.email.lower() == email.lower():
+ account = acc
+ break
+
+ if not account:
+ self.update_status(False, EmailServiceError(f"未找到邮箱对应的账户: {email}"))
+ return None
+
+ # 获取验证码等待配置
+ code_settings = get_email_code_settings()
+ actual_timeout = timeout or code_settings["timeout"]
+ poll_interval = code_settings["poll_interval"]
+
+ logger.info(
+ f"[{email}] 开始获取验证码,超时 {actual_timeout}s,"
+ f"提供者优先级: {[p.value for p in self.provider_priority]}"
+ )
+
+ # 初始化验证码去重集合
+ if email not in self._used_codes:
+ self._used_codes[email] = set()
+ used_codes = self._used_codes[email]
+
+ # 计算最小时间戳(留出 60 秒时钟偏差)
+ min_timestamp = (otp_sent_at - 60) if otp_sent_at else 0
+
+ start_time = time.time()
+ poll_count = 0
+
+ while time.time() - start_time < actual_timeout:
+ poll_count += 1
+
+ # 渐进式邮件检查:前 3 次只检查未读
+ only_unseen = poll_count <= 3
+
+ try:
+ # 尝试多个提供者获取邮件
+ emails = self._try_providers_for_emails(
+ account,
+ count=15,
+ only_unseen=only_unseen,
+ )
+
+ if emails:
+ logger.debug(
+ f"[{email}] 第 {poll_count} 次轮询获取到 {len(emails)} 封邮件"
+ )
+
+ # 从邮件中查找验证码
+ code = self.email_parser.find_verification_code_in_emails(
+ emails,
+ target_email=email,
+ min_timestamp=min_timestamp,
+ used_codes=used_codes,
+ )
+
+ if code:
+ used_codes.add(code)
+ elapsed = int(time.time() - start_time)
+ logger.info(
+ f"[{email}] 找到验证码: {code},"
+ f"总耗时 {elapsed}s,轮询 {poll_count} 次"
+ )
+ self.update_status(True)
+ return code
+
+ except Exception as e:
+ logger.warning(f"[{email}] 检查出错: {e}")
+
+ # 等待下次轮询
+ time.sleep(poll_interval)
+
+ elapsed = int(time.time() - start_time)
+ logger.warning(f"[{email}] 验证码超时 ({actual_timeout}s),共轮询 {poll_count} 次")
+ return None
+
+ def list_emails(self, **kwargs) -> List[Dict[str, Any]]:
+ """列出所有可用的 Outlook 账户"""
+ return [
+ {
+ "email": account.email,
+ "id": account.email,
+ "has_oauth": account.has_oauth(),
+ "type": "outlook"
+ }
+ for account in self.accounts
+ ]
+
+ def delete_email(self, email_id: str) -> bool:
+ """删除邮箱(Outlook 不支持删除账户)"""
+ logger.warning(f"Outlook 服务不支持删除账户: {email_id}")
+ return False
+
+ def check_health(self) -> bool:
+ """检查 Outlook 服务是否可用"""
+ if not self.accounts:
+ self.update_status(False, EmailServiceError("没有配置的账户"))
+ return False
+
+ # 测试第一个账户的连接
+ test_account = self.accounts[0]
+
+ # 尝试任一提供者连接
+ for provider_type in self.provider_priority:
+ try:
+ provider = self._get_provider(test_account, provider_type)
+ if provider.test_connection():
+ self.update_status(True)
+ return True
+ except Exception as e:
+ logger.warning(
+ f"Outlook 健康检查失败 ({test_account.email}, {provider_type.value}): {e}"
+ )
+
+ self.update_status(False, EmailServiceError("健康检查失败"))
+ return False
+
+ def get_provider_status(self) -> Dict[str, Any]:
+ """获取提供者状态"""
+ return self.failover_manager.get_status()
+
+ def get_account_stats(self) -> Dict[str, Any]:
+ """获取账户统计信息"""
+ total = len(self.accounts)
+ oauth_count = sum(1 for acc in self.accounts if acc.has_oauth())
+
+ return {
+ "total_accounts": total,
+ "oauth_accounts": oauth_count,
+ "password_accounts": total - oauth_count,
+ "accounts": [acc.to_dict() for acc in self.accounts],
+ "provider_status": self.get_provider_status(),
+ }
+
+ def add_account(self, account_config: Dict[str, Any]) -> bool:
+ """添加新的 Outlook 账户"""
+ try:
+ account = OutlookAccount.from_config(account_config)
+ if not account.validate():
+ return False
+
+ self.accounts.append(account)
+ logger.info(f"添加 Outlook 账户: {account.email}")
+ return True
+ except Exception as e:
+ logger.error(f"添加 Outlook 账户失败: {e}")
+ return False
+
+ def remove_account(self, email: str) -> bool:
+ """移除 Outlook 账户"""
+ for i, acc in enumerate(self.accounts):
+ if acc.email.lower() == email.lower():
+ self.accounts.pop(i)
+ logger.info(f"移除 Outlook 账户: {email}")
+ return True
+ return False
+
+ def reset_provider_health(self):
+ """重置所有提供者的健康状态"""
+ self.health_checker.reset_all()
+ logger.info("已重置所有提供者的健康状态")
+
+ def force_provider(self, provider_type: ProviderType):
+ """强制使用指定的提供者"""
+ self.health_checker.force_enable(provider_type)
+ # 禁用其他提供者
+ for pt in ProviderType:
+ if pt != provider_type:
+ self.health_checker.force_disable(pt, 60)
+ logger.info(f"已强制使用提供者: {provider_type.value}")
diff --git a/src/services/outlook/token_manager.py b/src/services/outlook/token_manager.py
new file mode 100644
index 0000000..77e54f2
--- /dev/null
+++ b/src/services/outlook/token_manager.py
@@ -0,0 +1,239 @@
+"""
+Token 管理器
+支持多个 Microsoft Token 端点,自动选择合适的端点
+"""
+
+import json
+import logging
+import threading
+import time
+from typing import Dict, Optional, Any
+
+from curl_cffi import requests as _requests
+
+from .base import ProviderType, TokenEndpoint, TokenInfo
+from .account import OutlookAccount
+
+
+logger = logging.getLogger(__name__)
+
+
+# 各提供者的 Scope 配置
+PROVIDER_SCOPES = {
+ ProviderType.IMAP_OLD: "", # 旧版 IMAP 不需要特定 scope
+ ProviderType.IMAP_NEW: "https://outlook.office.com/IMAP.AccessAsUser.All offline_access",
+ ProviderType.GRAPH_API: "https://graph.microsoft.com/.default",
+}
+
+# 各提供者的 Token 端点
+PROVIDER_TOKEN_URLS = {
+ ProviderType.IMAP_OLD: TokenEndpoint.LIVE.value,
+ ProviderType.IMAP_NEW: TokenEndpoint.CONSUMERS.value,
+ ProviderType.GRAPH_API: TokenEndpoint.COMMON.value,
+}
+
+
+class TokenManager:
+ """
+ Token 管理器
+ 支持多端点 Token 获取和缓存
+ """
+
+ # Token 缓存: key = (email, provider_type) -> TokenInfo
+ _token_cache: Dict[tuple, TokenInfo] = {}
+ _cache_lock = threading.Lock()
+
+ # 默认超时时间
+ DEFAULT_TIMEOUT = 30
+ # Token 刷新提前时间(秒)
+ REFRESH_BUFFER = 120
+
+ def __init__(
+ self,
+ account: OutlookAccount,
+ provider_type: ProviderType,
+ proxy_url: Optional[str] = None,
+ timeout: int = DEFAULT_TIMEOUT,
+ ):
+ """
+ 初始化 Token 管理器
+
+ Args:
+ account: Outlook 账户
+ provider_type: 提供者类型
+ proxy_url: 代理 URL(可选)
+ timeout: 请求超时时间
+ """
+ self.account = account
+ self.provider_type = provider_type
+ self.proxy_url = proxy_url
+ self.timeout = timeout
+
+ # 获取端点和 Scope
+ self.token_url = PROVIDER_TOKEN_URLS.get(provider_type, TokenEndpoint.LIVE.value)
+ self.scope = PROVIDER_SCOPES.get(provider_type, "")
+
+ def get_cached_token(self) -> Optional[TokenInfo]:
+ """获取缓存的 Token"""
+ cache_key = (self.account.email.lower(), self.provider_type)
+ with self._cache_lock:
+ token = self._token_cache.get(cache_key)
+ if token and not token.is_expired(self.REFRESH_BUFFER):
+ return token
+ return None
+
+ def set_cached_token(self, token: TokenInfo):
+ """缓存 Token"""
+ cache_key = (self.account.email.lower(), self.provider_type)
+ with self._cache_lock:
+ self._token_cache[cache_key] = token
+
+ def clear_cache(self):
+ """清除缓存"""
+ cache_key = (self.account.email.lower(), self.provider_type)
+ with self._cache_lock:
+ self._token_cache.pop(cache_key, None)
+
+ def get_access_token(self, force_refresh: bool = False) -> Optional[str]:
+ """
+ 获取 Access Token
+
+ Args:
+ force_refresh: 是否强制刷新
+
+ Returns:
+ Access Token 字符串,失败返回 None
+ """
+ # 检查缓存
+ if not force_refresh:
+ cached = self.get_cached_token()
+ if cached:
+ logger.debug(f"[{self.account.email}] 使用缓存的 Token ({self.provider_type.value})")
+ return cached.access_token
+
+ # 刷新 Token
+ try:
+ token = self._refresh_token()
+ if token:
+ self.set_cached_token(token)
+ return token.access_token
+ except Exception as e:
+ logger.error(f"[{self.account.email}] 获取 Token 失败 ({self.provider_type.value}): {e}")
+
+ return None
+
+ def _refresh_token(self) -> Optional[TokenInfo]:
+ """
+ 刷新 Token
+
+ Returns:
+ TokenInfo 对象,失败返回 None
+ """
+ if not self.account.client_id or not self.account.refresh_token:
+ raise ValueError("缺少 client_id 或 refresh_token")
+
+ logger.debug(f"[{self.account.email}] 正在刷新 Token ({self.provider_type.value})...")
+ logger.debug(f"[{self.account.email}] Token URL: {self.token_url}")
+
+ # 构建请求体
+ data = {
+ "client_id": self.account.client_id,
+ "refresh_token": self.account.refresh_token,
+ "grant_type": "refresh_token",
+ }
+
+ # 添加 Scope(如果需要)
+ if self.scope:
+ data["scope"] = self.scope
+
+ headers = {
+ "Content-Type": "application/x-www-form-urlencoded",
+ "Accept": "application/json",
+ }
+
+ proxies = None
+ if self.proxy_url:
+ proxies = {"http": self.proxy_url, "https": self.proxy_url}
+
+ try:
+ resp = _requests.post(
+ self.token_url,
+ data=data,
+ headers=headers,
+ proxies=proxies,
+ timeout=self.timeout,
+ impersonate="chrome110",
+ )
+
+ if resp.status_code != 200:
+ error_body = resp.text
+ logger.error(f"[{self.account.email}] Token 刷新失败: HTTP {resp.status_code}")
+ logger.debug(f"[{self.account.email}] 错误响应: {error_body[:500]}")
+
+ if "service abuse" in error_body.lower():
+ logger.warning(f"[{self.account.email}] 账号可能被封禁")
+ elif "invalid_grant" in error_body.lower():
+ logger.warning(f"[{self.account.email}] Refresh Token 已失效")
+
+ return None
+
+ response_data = resp.json()
+
+ # 解析响应
+ token = TokenInfo.from_response(response_data, self.scope)
+ logger.info(
+ f"[{self.account.email}] Token 刷新成功 ({self.provider_type.value}), "
+ f"有效期 {int(token.expires_at - time.time())} 秒"
+ )
+ return token
+
+ except json.JSONDecodeError as e:
+ logger.error(f"[{self.account.email}] JSON 解析错误: {e}")
+ return None
+
+ except Exception as e:
+ logger.error(f"[{self.account.email}] 未知错误: {e}")
+ return None
+
+ @classmethod
+ def clear_all_cache(cls):
+ """清除所有 Token 缓存"""
+ with cls._cache_lock:
+ cls._token_cache.clear()
+ logger.info("已清除所有 Token 缓存")
+
+ @classmethod
+ def get_cache_stats(cls) -> Dict[str, Any]:
+ """获取缓存统计"""
+ with cls._cache_lock:
+ return {
+ "cache_size": len(cls._token_cache),
+ "entries": [
+ {
+ "email": key[0],
+ "provider": key[1].value,
+ }
+ for key in cls._token_cache.keys()
+ ],
+ }
+
+
+def create_token_manager(
+ account: OutlookAccount,
+ provider_type: ProviderType,
+ proxy_url: Optional[str] = None,
+ timeout: int = TokenManager.DEFAULT_TIMEOUT,
+) -> TokenManager:
+ """
+ 创建 Token 管理器的工厂函数
+
+ Args:
+ account: Outlook 账户
+ provider_type: 提供者类型
+ proxy_url: 代理 URL
+ timeout: 超时时间
+
+ Returns:
+ TokenManager 实例
+ """
+ return TokenManager(account, provider_type, proxy_url, timeout)
diff --git a/src/services/outlook_legacy_mail.py b/src/services/outlook_legacy_mail.py
new file mode 100644
index 0000000..3fd6a7d
--- /dev/null
+++ b/src/services/outlook_legacy_mail.py
@@ -0,0 +1,763 @@
+"""
+Outlook 邮箱服务实现
+支持 IMAP 协议,XOAUTH2 和密码认证
+"""
+
+import imaplib
+import email
+import re
+import time
+import threading
+import json
+import urllib.parse
+import urllib.request
+import base64
+import hashlib
+import secrets
+import logging
+from typing import Optional, Dict, Any, List
+from email.header import decode_header
+from email.utils import parsedate_to_datetime
+from urllib.error import HTTPError
+
+from .base import BaseEmailService, EmailServiceError, EmailServiceType
+from ..config.constants import (
+ OTP_CODE_PATTERN,
+ OTP_CODE_SIMPLE_PATTERN,
+ OTP_CODE_SEMANTIC_PATTERN,
+ OPENAI_EMAIL_SENDERS,
+ OPENAI_VERIFICATION_KEYWORDS,
+)
+from ..config.settings import get_settings
+
+
+def get_email_code_settings() -> dict:
+ """
+ 获取验证码等待配置
+
+ Returns:
+ dict: 包含 timeout 和 poll_interval 的字典
+ """
+ settings = get_settings()
+ return {
+ "timeout": settings.email_code_timeout,
+ "poll_interval": settings.email_code_poll_interval,
+ }
+
+
+logger = logging.getLogger(__name__)
+
+
+class OutlookAccount:
+ """Outlook 账户信息"""
+
+ def __init__(
+ self,
+ email: str,
+ password: str,
+ client_id: str = "",
+ refresh_token: str = ""
+ ):
+ self.email = email
+ self.password = password
+ self.client_id = client_id
+ self.refresh_token = refresh_token
+
+ @classmethod
+ def from_config(cls, config: Dict[str, Any]) -> "OutlookAccount":
+ """从配置创建账户"""
+ return cls(
+ email=config.get("email", ""),
+ password=config.get("password", ""),
+ client_id=config.get("client_id", ""),
+ refresh_token=config.get("refresh_token", "")
+ )
+
+ def has_oauth(self) -> bool:
+ """是否支持 OAuth2"""
+ return bool(self.client_id and self.refresh_token)
+
+ def validate(self) -> bool:
+ """验证账户信息是否有效"""
+ return bool(self.email and self.password) or self.has_oauth()
+
+
+class OutlookIMAPClient:
+ """
+ Outlook IMAP 客户端
+ 支持 XOAUTH2 和密码认证
+ """
+
+ # Microsoft OAuth2 Token 缓存
+ _token_cache: Dict[str, tuple] = {}
+ _cache_lock = threading.Lock()
+
+ def __init__(
+ self,
+ account: OutlookAccount,
+ host: str = "outlook.office365.com",
+ port: int = 993,
+ timeout: int = 20
+ ):
+ self.account = account
+ self.host = host
+ self.port = port
+ self.timeout = timeout
+ self._conn: Optional[imaplib.IMAP4_SSL] = None
+
+ @staticmethod
+ def refresh_ms_token(account: OutlookAccount, timeout: int = 15) -> str:
+ """刷新 Microsoft access token"""
+ if not account.client_id or not account.refresh_token:
+ raise RuntimeError("缺少 client_id 或 refresh_token")
+
+ key = account.email.lower()
+ with OutlookIMAPClient._cache_lock:
+ cached = OutlookIMAPClient._token_cache.get(key)
+ if cached and time.time() < cached[1]:
+ return cached[0]
+
+ body = urllib.parse.urlencode({
+ "client_id": account.client_id,
+ "refresh_token": account.refresh_token,
+ "grant_type": "refresh_token",
+ "redirect_uri": "https://login.live.com/oauth20_desktop.srf",
+ }).encode()
+
+ req = urllib.request.Request(
+ "https://login.live.com/oauth20_token.srf",
+ data=body,
+ headers={"Content-Type": "application/x-www-form-urlencoded"}
+ )
+
+ try:
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
+ data = json.loads(resp.read())
+ except HTTPError as e:
+ raise RuntimeError(f"MS OAuth 刷新失败: {e.code}") from e
+
+ token = data.get("access_token")
+ if not token:
+ raise RuntimeError("MS OAuth 响应无 access_token")
+
+ ttl = int(data.get("expires_in", 3600))
+ with OutlookIMAPClient._cache_lock:
+ OutlookIMAPClient._token_cache[key] = (token, time.time() + ttl - 120)
+
+ return token
+
+ @staticmethod
+ def _build_xoauth2(email_addr: str, token: str) -> bytes:
+ """构建 XOAUTH2 认证字符串"""
+ return f"user={email_addr}\x01auth=Bearer {token}\x01\x01".encode()
+
+ def connect(self):
+ """连接到 IMAP 服务器"""
+ self._conn = imaplib.IMAP4_SSL(self.host, self.port, timeout=self.timeout)
+
+ # 优先使用 XOAUTH2 认证
+ if self.account.has_oauth():
+ try:
+ token = self.refresh_ms_token(self.account)
+ self._conn.authenticate(
+ "XOAUTH2",
+ lambda _: self._build_xoauth2(self.account.email, token)
+ )
+ logger.debug(f"使用 XOAUTH2 认证连接: {self.account.email}")
+ return
+ except Exception as e:
+ logger.warning(f"XOAUTH2 认证失败,回退密码认证: {e}")
+
+ # 回退到密码认证
+ self._conn.login(self.account.email, self.account.password)
+ logger.debug(f"使用密码认证连接: {self.account.email}")
+
+ def _ensure_connection(self):
+ """确保连接有效"""
+ if self._conn:
+ try:
+ self._conn.noop()
+ return
+ except Exception:
+ self.close()
+
+ self.connect()
+
+ def get_recent_emails(
+ self,
+ count: int = 20,
+ only_unseen: bool = True,
+ timeout: int = 30
+ ) -> List[Dict[str, Any]]:
+ """
+ 获取最近的邮件
+
+ Args:
+ count: 获取的邮件数量
+ only_unseen: 是否只获取未读邮件
+ timeout: 超时时间
+
+ Returns:
+ 邮件列表
+ """
+ self._ensure_connection()
+
+ flag = "UNSEEN" if only_unseen else "ALL"
+ self._conn.select("INBOX", readonly=True)
+
+ _, data = self._conn.search(None, flag)
+ if not data or not data[0]:
+ return []
+
+ # 获取最新的邮件
+ ids = data[0].split()[-count:]
+ result = []
+
+ for mid in reversed(ids):
+ try:
+ _, payload = self._conn.fetch(mid, "(RFC822)")
+ if not payload:
+ continue
+
+ raw = b""
+ for part in payload:
+ if isinstance(part, tuple) and len(part) > 1:
+ raw = part[1]
+ break
+
+ if raw:
+ result.append(self._parse_email(raw))
+ except Exception as e:
+ logger.warning(f"解析邮件失败 (ID: {mid}): {e}")
+
+ return result
+
+ @staticmethod
+ def _parse_email(raw: bytes) -> Dict[str, Any]:
+ """解析邮件内容"""
+ # 移除可能的 BOM
+ if raw.startswith(b"\xef\xbb\xbf"):
+ raw = raw[3:]
+
+ msg = email.message_from_bytes(raw)
+
+ # 解析邮件头
+ subject = OutlookIMAPClient._decode_header(msg.get("Subject", ""))
+ sender = OutlookIMAPClient._decode_header(msg.get("From", ""))
+ date_str = OutlookIMAPClient._decode_header(msg.get("Date", ""))
+ to = OutlookIMAPClient._decode_header(msg.get("To", ""))
+ delivered_to = OutlookIMAPClient._decode_header(msg.get("Delivered-To", ""))
+ x_original_to = OutlookIMAPClient._decode_header(msg.get("X-Original-To", ""))
+
+ # 提取邮件正文
+ body = OutlookIMAPClient._extract_body(msg)
+
+ # 解析日期
+ date_timestamp = 0
+ try:
+ if date_str:
+ dt = parsedate_to_datetime(date_str)
+ date_timestamp = int(dt.timestamp())
+ except Exception:
+ pass
+
+ return {
+ "subject": subject,
+ "from": sender,
+ "date": date_str,
+ "date_timestamp": date_timestamp,
+ "to": to,
+ "delivered_to": delivered_to,
+ "x_original_to": x_original_to,
+ "body": body,
+ "raw": raw.hex()[:100] # 存储原始数据的部分哈希用于调试
+ }
+
+ @staticmethod
+ def _decode_header(header: str) -> str:
+ """解码邮件头"""
+ if not header:
+ return ""
+
+ parts = []
+ for chunk, encoding in decode_header(header):
+ if isinstance(chunk, bytes):
+ try:
+ decoded = chunk.decode(encoding or "utf-8", errors="replace")
+ parts.append(decoded)
+ except Exception:
+ parts.append(chunk.decode("utf-8", errors="replace"))
+ else:
+ parts.append(chunk)
+
+ return "".join(parts).strip()
+
+ @staticmethod
+ def _extract_body(msg) -> str:
+ """提取邮件正文"""
+ import html as html_module
+
+ texts = []
+ parts = msg.walk() if msg.is_multipart() else [msg]
+
+ for part in parts:
+ content_type = part.get_content_type()
+ if content_type not in ("text/plain", "text/html"):
+ continue
+
+ payload = part.get_payload(decode=True)
+ if not payload:
+ continue
+
+ charset = part.get_content_charset() or "utf-8"
+ try:
+ text = payload.decode(charset, errors="replace")
+ except LookupError:
+ text = payload.decode("utf-8", errors="replace")
+
+ # 如果是 HTML,移除标签
+ if "]+>", " ", text)
+
+ texts.append(text)
+
+ # 合并并清理文本
+ combined = " ".join(texts)
+ combined = html_module.unescape(combined)
+ combined = re.sub(r"\s+", " ", combined).strip()
+
+ return combined
+
+ def close(self):
+ """关闭连接"""
+ if self._conn:
+ try:
+ self._conn.close()
+ except Exception:
+ pass
+ try:
+ self._conn.logout()
+ except Exception:
+ pass
+ self._conn = None
+
+ def __enter__(self):
+ self.connect()
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ self.close()
+
+
+class OutlookService(BaseEmailService):
+ """
+ Outlook 邮箱服务
+ 支持多个 Outlook 账户的轮询和验证码获取
+ """
+
+ def __init__(self, config: Dict[str, Any] = None, name: str = None):
+ """
+ 初始化 Outlook 服务
+
+ Args:
+ config: 配置字典,支持以下键:
+ - accounts: Outlook 账户列表,每个账户包含:
+ - email: 邮箱地址
+ - password: 密码
+ - client_id: OAuth2 client_id (可选)
+ - refresh_token: OAuth2 refresh_token (可选)
+ - imap_host: IMAP 服务器 (默认: outlook.office365.com)
+ - imap_port: IMAP 端口 (默认: 993)
+ - timeout: 超时时间 (默认: 30)
+ - max_retries: 最大重试次数 (默认: 3)
+ name: 服务名称
+ """
+ super().__init__(EmailServiceType.OUTLOOK, name)
+
+ # 默认配置
+ default_config = {
+ "accounts": [],
+ "imap_host": "outlook.office365.com",
+ "imap_port": 993,
+ "timeout": 30,
+ "max_retries": 3,
+ "proxy_url": None,
+ }
+
+ self.config = {**default_config, **(config or {})}
+
+ # 解析账户
+ self.accounts: List[OutlookAccount] = []
+ self._current_account_index = 0
+ self._account_locks: Dict[str, threading.Lock] = {}
+
+ # 支持两种配置格式:
+ # 1. 单个账户格式:{"email": "xxx", "password": "xxx"}
+ # 2. 多账户格式:{"accounts": [{"email": "xxx", "password": "xxx"}]}
+ if "email" in self.config and "password" in self.config:
+ # 单个账户格式
+ account = OutlookAccount.from_config(self.config)
+ if account.validate():
+ self.accounts.append(account)
+ self._account_locks[account.email] = threading.Lock()
+ else:
+ logger.warning(f"无效的 Outlook 账户配置: {self.config}")
+ else:
+ # 多账户格式
+ for account_config in self.config.get("accounts", []):
+ account = OutlookAccount.from_config(account_config)
+ if account.validate():
+ self.accounts.append(account)
+ self._account_locks[account.email] = threading.Lock()
+ else:
+ logger.warning(f"无效的 Outlook 账户配置: {account_config}")
+
+ if not self.accounts:
+ logger.warning("未配置有效的 Outlook 账户")
+
+ # IMAP 连接限制(防止限流)
+ self._imap_semaphore = threading.Semaphore(5)
+
+ # 验证码去重机制:email -> set of used codes
+ self._used_codes: Dict[str, set] = {}
+
+ def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]:
+ """
+ 选择可用的 Outlook 账户
+
+ Args:
+ config: 配置参数(目前未使用)
+
+ Returns:
+ 包含邮箱信息的字典:
+ - email: 邮箱地址
+ - service_id: 账户邮箱(同 email)
+ - account: 账户信息
+ """
+ if not self.accounts:
+ self.update_status(False, EmailServiceError("没有可用的 Outlook 账户"))
+ raise EmailServiceError("没有可用的 Outlook 账户")
+
+ # 轮询选择账户
+ with threading.Lock():
+ account = self.accounts[self._current_account_index]
+ self._current_account_index = (self._current_account_index + 1) % len(self.accounts)
+
+ email_info = {
+ "email": account.email,
+ "service_id": account.email, # 对于 Outlook,service_id 就是邮箱地址
+ "account": {
+ "email": account.email,
+ "has_oauth": account.has_oauth()
+ }
+ }
+
+ logger.info(f"选择 Outlook 账户: {account.email}")
+ self.update_status(True)
+ return email_info
+
+ def get_verification_code(
+ self,
+ email: str,
+ email_id: str = None,
+ timeout: int = None,
+ pattern: str = OTP_CODE_PATTERN,
+ otp_sent_at: Optional[float] = None,
+ ) -> Optional[str]:
+ """
+ 从 Outlook 邮箱获取验证码
+
+ Args:
+ email: 邮箱地址
+ email_id: 未使用(对于 Outlook,email 就是标识)
+ timeout: 超时时间(秒),默认使用配置值
+ pattern: 验证码正则表达式
+ otp_sent_at: OTP 发送时间戳,用于过滤旧邮件
+
+ Returns:
+ 验证码字符串,如果超时或未找到返回 None
+ """
+ # 查找对应的账户
+ account = None
+ for acc in self.accounts:
+ if acc.email.lower() == email.lower():
+ account = acc
+ break
+
+ if not account:
+ self.update_status(False, EmailServiceError(f"未找到邮箱对应的账户: {email}"))
+ return None
+
+ # 从数据库获取验证码等待配置
+ code_settings = get_email_code_settings()
+ actual_timeout = timeout or code_settings["timeout"]
+ poll_interval = code_settings["poll_interval"]
+
+ logger.info(f"[{email}] 开始获取验证码,超时 {actual_timeout}s,OTP发送时间: {otp_sent_at}")
+
+ # 初始化验证码去重集合
+ if email not in self._used_codes:
+ self._used_codes[email] = set()
+ used_codes = self._used_codes[email]
+
+ # 计算最小时间戳(留出 60 秒时钟偏差)
+ min_timestamp = (otp_sent_at - 60) if otp_sent_at else 0
+
+ start_time = time.time()
+ poll_count = 0
+
+ while time.time() - start_time < actual_timeout:
+ poll_count += 1
+ loop_start = time.time()
+
+ # 渐进式邮件检查:前 3 次只检查未读,之后检查全部
+ only_unseen = poll_count <= 3
+
+ try:
+ connect_start = time.time()
+ with self._imap_semaphore:
+ with OutlookIMAPClient(
+ account,
+ host=self.config["imap_host"],
+ port=self.config["imap_port"],
+ timeout=10
+ ) as client:
+ connect_elapsed = time.time() - connect_start
+ logger.debug(f"[{email}] IMAP 连接耗时 {connect_elapsed:.2f}s")
+
+ # 搜索邮件
+ search_start = time.time()
+ emails = client.get_recent_emails(count=15, only_unseen=only_unseen)
+ search_elapsed = time.time() - search_start
+ logger.debug(f"[{email}] 搜索到 {len(emails)} 封邮件(未读={only_unseen}),耗时 {search_elapsed:.2f}s")
+
+ for mail in emails:
+ # 时间戳过滤
+ mail_ts = mail.get("date_timestamp", 0)
+ if min_timestamp > 0 and mail_ts > 0 and mail_ts < min_timestamp:
+ logger.debug(f"[{email}] 跳过旧邮件: {mail.get('subject', '')[:50]}")
+ continue
+
+ # 检查是否是 OpenAI 验证邮件
+ if not self._is_openai_verification_mail(mail, email):
+ continue
+
+ # 提取验证码
+ code = self._extract_code_from_mail(mail, pattern)
+ if code:
+ # 去重检查
+ if code in used_codes:
+ logger.debug(f"[{email}] 跳过已使用的验证码: {code}")
+ continue
+
+ used_codes.add(code)
+ elapsed = int(time.time() - start_time)
+ logger.info(f"[{email}] 找到验证码: {code},总耗时 {elapsed}s,轮询 {poll_count} 次")
+ self.update_status(True)
+ return code
+
+ except Exception as e:
+ loop_elapsed = time.time() - loop_start
+ logger.warning(f"[{email}] 检查出错: {e},循环耗时 {loop_elapsed:.2f}s")
+
+ # 等待下次轮询
+ time.sleep(poll_interval)
+
+ elapsed = int(time.time() - start_time)
+ logger.warning(f"[{email}] 验证码超时 ({actual_timeout}s),共轮询 {poll_count} 次")
+ return None
+
+ def list_emails(self, **kwargs) -> List[Dict[str, Any]]:
+ """
+ 列出所有可用的 Outlook 账户
+
+ Returns:
+ 账户列表
+ """
+ return [
+ {
+ "email": account.email,
+ "id": account.email,
+ "has_oauth": account.has_oauth(),
+ "type": "outlook"
+ }
+ for account in self.accounts
+ ]
+
+ def delete_email(self, email_id: str) -> bool:
+ """
+ 删除邮箱(对于 Outlook,不支持删除账户)
+
+ Args:
+ email_id: 邮箱地址
+
+ Returns:
+ False(Outlook 不支持删除账户)
+ """
+ logger.warning(f"Outlook 服务不支持删除账户: {email_id}")
+ return False
+
+ def check_health(self) -> bool:
+ """检查 Outlook 服务是否可用"""
+ if not self.accounts:
+ self.update_status(False, EmailServiceError("没有配置的账户"))
+ return False
+
+ # 测试第一个账户的连接
+ test_account = self.accounts[0]
+ try:
+ with self._imap_semaphore:
+ with OutlookIMAPClient(
+ test_account,
+ host=self.config["imap_host"],
+ port=self.config["imap_port"],
+ timeout=10
+ ) as client:
+ # 尝试列出邮箱(快速测试)
+ client._conn.select("INBOX", readonly=True)
+ self.update_status(True)
+ return True
+ except Exception as e:
+ logger.warning(f"Outlook 健康检查失败 ({test_account.email}): {e}")
+ self.update_status(False, e)
+ return False
+
+ def _is_oai_mail(self, mail: Dict[str, Any]) -> bool:
+ """判断是否为 OpenAI 相关邮件(旧方法,保留兼容)"""
+ combined = f"{mail.get('from', '')} {mail.get('subject', '')} {mail.get('body', '')}".lower()
+ keywords = ["openai", "chatgpt", "verification", "验证码", "code"]
+ return any(keyword in combined for keyword in keywords)
+
+ def _is_openai_verification_mail(
+ self,
+ mail: Dict[str, Any],
+ target_email: str = None
+ ) -> bool:
+ """
+ 严格判断是否为 OpenAI 验证邮件
+
+ Args:
+ mail: 邮件信息字典
+ target_email: 目标邮箱地址(用于验证收件人)
+
+ Returns:
+ 是否为 OpenAI 验证邮件
+ """
+ sender = mail.get("from", "").lower()
+
+ # 1. 发件人必须是 OpenAI
+ valid_senders = OPENAI_EMAIL_SENDERS
+ if not any(s in sender for s in valid_senders):
+ logger.debug(f"邮件发件人非 OpenAI: {sender}")
+ return False
+
+ # 2. 主题或正文包含验证关键词
+ subject = mail.get("subject", "").lower()
+ body = mail.get("body", "").lower()
+ verification_keywords = OPENAI_VERIFICATION_KEYWORDS
+ combined = f"{subject} {body}"
+ if not any(kw in combined for kw in verification_keywords):
+ logger.debug(f"邮件未包含验证关键词: {subject[:50]}")
+ return False
+
+ # 3. 验证收件人(可选)
+ if target_email:
+ recipients = f"{mail.get('to', '')} {mail.get('delivered_to', '')} {mail.get('x_original_to', '')}".lower()
+ if target_email.lower() not in recipients:
+ logger.debug(f"邮件收件人不匹配: {recipients[:50]}")
+ return False
+
+ logger.debug(f"识别为 OpenAI 验证邮件: {subject[:50]}")
+ return True
+
+ def _extract_code_from_mail(
+ self,
+ mail: Dict[str, Any],
+ fallback_pattern: str = OTP_CODE_PATTERN
+ ) -> Optional[str]:
+ """
+ 从邮件中提取验证码
+
+ 优先级:
+ 1. 从主题提取(6位数字)
+ 2. 从正文用语义正则提取(如 "code is 123456")
+ 3. 兜底:任意 6 位数字
+
+ Args:
+ mail: 邮件信息字典
+ fallback_pattern: 兜底正则表达式
+
+ Returns:
+ 验证码字符串,如果未找到返回 None
+ """
+ # 编译正则
+ re_simple = re.compile(OTP_CODE_SIMPLE_PATTERN)
+ re_semantic = re.compile(OTP_CODE_SEMANTIC_PATTERN, re.IGNORECASE)
+
+ # 1. 主题优先
+ subject = mail.get("subject", "")
+ match = re_simple.search(subject)
+ if match:
+ code = match.group(1)
+ logger.debug(f"从主题提取验证码: {code}")
+ return code
+
+ # 2. 正文语义匹配
+ body = mail.get("body", "")
+ match = re_semantic.search(body)
+ if match:
+ code = match.group(1)
+ logger.debug(f"从正文语义提取验证码: {code}")
+ return code
+
+ # 3. 兜底:任意 6 位数字
+ match = re_simple.search(body)
+ if match:
+ code = match.group(1)
+ logger.debug(f"从正文兜底提取验证码: {code}")
+ return code
+
+ return None
+
+ def get_account_stats(self) -> Dict[str, Any]:
+ """获取账户统计信息"""
+ total = len(self.accounts)
+ oauth_count = sum(1 for acc in self.accounts if acc.has_oauth())
+
+ return {
+ "total_accounts": total,
+ "oauth_accounts": oauth_count,
+ "password_accounts": total - oauth_count,
+ "accounts": [
+ {
+ "email": acc.email,
+ "has_oauth": acc.has_oauth()
+ }
+ for acc in self.accounts
+ ]
+ }
+
+ def add_account(self, account_config: Dict[str, Any]) -> bool:
+ """添加新的 Outlook 账户"""
+ try:
+ account = OutlookAccount.from_config(account_config)
+ if not account.validate():
+ return False
+
+ self.accounts.append(account)
+ self._account_locks[account.email] = threading.Lock()
+ logger.info(f"添加 Outlook 账户: {account.email}")
+ return True
+ except Exception as e:
+ logger.error(f"添加 Outlook 账户失败: {e}")
+ return False
+
+ def remove_account(self, email: str) -> bool:
+ """移除 Outlook 账户"""
+ for i, acc in enumerate(self.accounts):
+ if acc.email.lower() == email.lower():
+ self.accounts.pop(i)
+ self._account_locks.pop(email, None)
+ logger.info(f"移除 Outlook 账户: {email}")
+ return True
+ return False
\ No newline at end of file
diff --git a/src/services/temp_mail.py b/src/services/temp_mail.py
new file mode 100644
index 0000000..6804409
--- /dev/null
+++ b/src/services/temp_mail.py
@@ -0,0 +1,455 @@
+"""
+Temp-Mail 邮箱服务实现
+基于自部署 Cloudflare Worker 临时邮箱服务
+接口文档参见 plan/temp-mail.md
+"""
+
+import re
+import time
+import json
+import logging
+from email import message_from_string
+from email.header import decode_header, make_header
+from email.message import Message
+from email.policy import default as email_policy
+from html import unescape
+from typing import Optional, Dict, Any, List
+
+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 _decode_mime_header(self, value: str) -> str:
+ """解码 MIME 头,兼容 RFC 2047 编码主题。"""
+ if not value:
+ return ""
+ try:
+ return str(make_header(decode_header(value)))
+ except Exception:
+ return value
+
+ def _extract_body_from_message(self, message: Message) -> str:
+ """从 MIME 邮件对象中提取可读正文。"""
+ parts: List[str] = []
+
+ if message.is_multipart():
+ for part in message.walk():
+ if part.get_content_maintype() == "multipart":
+ continue
+
+ content_type = (part.get_content_type() or "").lower()
+ if content_type not in ("text/plain", "text/html"):
+ continue
+
+ try:
+ payload = part.get_payload(decode=True)
+ charset = part.get_content_charset() or "utf-8"
+ text = payload.decode(charset, errors="replace") if payload else ""
+ except Exception:
+ try:
+ text = part.get_content()
+ except Exception:
+ text = ""
+
+ if content_type == "text/html":
+ text = re.sub(r"<[^>]+>", " ", text)
+ parts.append(text)
+ else:
+ try:
+ payload = message.get_payload(decode=True)
+ charset = message.get_content_charset() or "utf-8"
+ body = payload.decode(charset, errors="replace") if payload else ""
+ except Exception:
+ try:
+ body = message.get_content()
+ except Exception:
+ body = str(message.get_payload() or "")
+
+ if "html" in (message.get_content_type() or "").lower():
+ body = re.sub(r"<[^>]+>", " ", body)
+ parts.append(body)
+
+ return unescape("\n".join(part for part in parts if part).strip())
+
+ def _extract_mail_fields(self, mail: Dict[str, Any]) -> Dict[str, str]:
+ """统一提取邮件字段,兼容 raw MIME 和不同 Worker 返回格式。"""
+ sender = str(
+ mail.get("source")
+ or mail.get("from")
+ or mail.get("from_address")
+ or mail.get("fromAddress")
+ or ""
+ ).strip()
+ subject = str(mail.get("subject") or mail.get("title") or "").strip()
+ body_text = str(
+ mail.get("text")
+ or mail.get("body")
+ or mail.get("content")
+ or mail.get("html")
+ or ""
+ ).strip()
+ raw = str(mail.get("raw") or "").strip()
+
+ if raw:
+ try:
+ message = message_from_string(raw, policy=email_policy)
+ sender = sender or self._decode_mime_header(message.get("From", ""))
+ subject = subject or self._decode_mime_header(message.get("Subject", ""))
+ parsed_body = self._extract_body_from_message(message)
+ if parsed_body:
+ body_text = f"{body_text}\n{parsed_body}".strip() if body_text else parsed_body
+ except Exception as e:
+ logger.debug(f"解析 TempMail raw 邮件失败: {e}")
+ body_text = f"{body_text}\n{raw}".strip() if body_text else raw
+
+ body_text = unescape(re.sub(r"<[^>]+>", " ", body_text))
+ return {
+ "sender": sender,
+ "subject": subject,
+ "body": body_text,
+ "raw": raw,
+ }
+
+ 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()
+
+ # 优先使用用户级 JWT,回退到 admin API 先注释用户级API
+ # cached = self._email_cache.get(email, {})
+ # jwt = cached.get("jwt")
+
+ while time.time() - start_time < timeout:
+ try:
+ # if jwt:
+ # response = self._make_request(
+ # "GET",
+ # "/user_api/mails",
+ # params={"limit": 20, "offset": 0},
+ # headers={"x-user-token": jwt, "Content-Type": "application/json", "Accept": "application/json"},
+ # )
+ # else:
+ response = self._make_request(
+ "GET",
+ "/admin/mails",
+ params={"limit": 20, "offset": 0, "address": email},
+ )
+
+ # /user_api/mails 和 /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)
+
+ parsed = self._extract_mail_fields(mail)
+ sender = parsed["sender"].lower()
+ subject = parsed["subject"]
+ body_text = parsed["body"]
+ raw_text = parsed["raw"]
+ content = f"{sender}\n{subject}\n{body_text}\n{raw_text}".strip()
+
+ # 只处理 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 list_emails(self, limit: int = 100, offset: int = 0, **kwargs) -> List[Dict[str, Any]]:
+ """
+ 列出邮箱
+
+ Args:
+ limit: 返回数量上限
+ offset: 分页偏移
+ **kwargs: 额外查询参数,透传给 admin API
+
+ Returns:
+ 邮箱列表
+ """
+ params = {
+ "limit": limit,
+ "offset": offset,
+ }
+ params.update({k: v for k, v in kwargs.items() if v is not None})
+
+ try:
+ response = self._make_request("GET", "/admin/mails", params=params)
+ mails = response.get("results", [])
+ if not isinstance(mails, list):
+ raise EmailServiceError(f"API 返回数据格式错误: {response}")
+
+ emails: List[Dict[str, Any]] = []
+ for mail in mails:
+ address = (mail.get("address") or "").strip()
+ mail_id = mail.get("id") or address
+ email_info = {
+ "id": mail_id,
+ "service_id": mail_id,
+ "email": address,
+ "subject": mail.get("subject"),
+ "from": mail.get("source"),
+ "created_at": mail.get("createdAt") or mail.get("created_at"),
+ "raw_data": mail,
+ }
+ emails.append(email_info)
+
+ if address:
+ cached = self._email_cache.get(address, {})
+ self._email_cache[address] = {**cached, **email_info}
+
+ self.update_status(True)
+ return emails
+ except Exception as e:
+ logger.warning(f"列出 TempMail 邮箱失败: {e}")
+ self.update_status(False, e)
+ return list(self._email_cache.values())
+
+ def delete_email(self, email_id: str) -> bool:
+ """
+ 删除邮箱
+
+ Note:
+ 当前 TempMail admin API 文档未见删除地址接口,这里先从本地缓存移除,
+ 以满足统一接口并避免服务实例化失败。
+ """
+ removed = False
+ emails_to_delete = []
+
+ for address, info in self._email_cache.items():
+ candidate_ids = {
+ address,
+ info.get("id"),
+ info.get("service_id"),
+ }
+ if email_id in candidate_ids:
+ emails_to_delete.append(address)
+
+ for address in emails_to_delete:
+ self._email_cache.pop(address, None)
+ removed = True
+
+ if removed:
+ logger.info(f"已从 TempMail 缓存移除邮箱: {email_id}")
+ self.update_status(True)
+ else:
+ logger.info(f"TempMail 缓存中未找到邮箱: {email_id}")
+
+ return removed
+
+ 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/services/tempmail.py b/src/services/tempmail.py
new file mode 100644
index 0000000..48d30f8
--- /dev/null
+++ b/src/services/tempmail.py
@@ -0,0 +1,400 @@
+"""
+Tempmail.lol 邮箱服务实现
+"""
+
+import re
+import time
+import logging
+from typing import Optional, Dict, Any, List
+import json
+
+from curl_cffi import requests as cffi_requests
+
+from .base import BaseEmailService, EmailServiceError, EmailServiceType
+from ..core.http_client import HTTPClient, RequestConfig
+from ..config.constants import OTP_CODE_PATTERN
+
+
+logger = logging.getLogger(__name__)
+
+
+class TempmailService(BaseEmailService):
+ """
+ Tempmail.lol 邮箱服务
+ 基于 Tempmail.lol API v2
+ """
+
+ def __init__(self, config: Dict[str, Any] = None, name: str = None):
+ """
+ 初始化 Tempmail 服务
+
+ Args:
+ config: 配置字典,支持以下键:
+ - base_url: API 基础地址 (默认: https://api.tempmail.lol/v2)
+ - timeout: 请求超时时间 (默认: 30)
+ - max_retries: 最大重试次数 (默认: 3)
+ - proxy_url: 代理 URL
+ name: 服务名称
+ """
+ super().__init__(EmailServiceType.TEMPMAIL, name)
+
+ # 默认配置
+ default_config = {
+ "base_url": "https://api.tempmail.lol/v2",
+ "timeout": 30,
+ "max_retries": 3,
+ "proxy_url": None,
+ }
+
+ self.config = {**default_config, **(config or {})}
+
+ # 创建 HTTP 客户端
+ http_config = RequestConfig(
+ timeout=self.config["timeout"],
+ max_retries=self.config["max_retries"],
+ )
+ self.http_client = HTTPClient(
+ proxy_url=self.config.get("proxy_url"),
+ config=http_config
+ )
+
+ # 状态变量
+ self._email_cache: Dict[str, Dict[str, Any]] = {}
+ self._last_check_time: float = 0
+
+ def create_email(self, config: Dict[str, Any] = None) -> Dict[str, Any]:
+ """
+ 创建新的临时邮箱
+
+ Args:
+ config: 配置参数(Tempmail.lol 目前不支持自定义配置)
+
+ Returns:
+ 包含邮箱信息的字典:
+ - email: 邮箱地址
+ - service_id: 邮箱 token
+ - token: 邮箱 token(同 service_id)
+ - created_at: 创建时间戳
+ """
+ try:
+ # 发送创建请求
+ response = self.http_client.post(
+ f"{self.config['base_url']}/inbox/create",
+ headers={
+ "Accept": "application/json",
+ "Content-Type": "application/json",
+ },
+ json={}
+ )
+
+ if response.status_code not in (200, 201):
+ self.update_status(False, EmailServiceError(f"请求失败,状态码: {response.status_code}"))
+ raise EmailServiceError(f"Tempmail.lol 请求失败,状态码: {response.status_code}")
+
+ data = response.json()
+ email = str(data.get("address", "")).strip()
+ token = str(data.get("token", "")).strip()
+
+ if not email or not token:
+ self.update_status(False, EmailServiceError("返回数据不完整"))
+ raise EmailServiceError("Tempmail.lol 返回数据不完整")
+
+ # 缓存邮箱信息
+ email_info = {
+ "email": email,
+ "service_id": token,
+ "token": token,
+ "created_at": time.time(),
+ }
+ self._email_cache[email] = email_info
+
+ logger.info(f"成功创建 Tempmail.lol 邮箱: {email}")
+ self.update_status(True)
+ return email_info
+
+ except Exception as e:
+ self.update_status(False, e)
+ if isinstance(e, EmailServiceError):
+ raise
+ raise EmailServiceError(f"创建 Tempmail.lol 邮箱失败: {e}")
+
+ def get_verification_code(
+ self,
+ email: str,
+ email_id: str = None,
+ timeout: int = 120,
+ pattern: str = OTP_CODE_PATTERN,
+ otp_sent_at: Optional[float] = None,
+ ) -> Optional[str]:
+ """
+ 从 Tempmail.lol 获取验证码
+
+ Args:
+ email: 邮箱地址
+ email_id: 邮箱 token(如果不提供,从缓存中查找)
+ timeout: 超时时间(秒)
+ pattern: 验证码正则表达式
+ otp_sent_at: OTP 发送时间戳(Tempmail 服务暂不使用此参数)
+
+ Returns:
+ 验证码字符串,如果超时或未找到返回 None
+ """
+ token = email_id
+ if not token:
+ # 从缓存中查找 token
+ if email in self._email_cache:
+ token = self._email_cache[email].get("token")
+ else:
+ logger.warning(f"未找到邮箱 {email} 的 token,无法获取验证码")
+ return None
+
+ if not token:
+ logger.warning(f"邮箱 {email} 没有 token,无法获取验证码")
+ return None
+
+ logger.info(f"正在等待邮箱 {email} 的验证码...")
+
+ start_time = time.time()
+ seen_ids = set()
+
+ while time.time() - start_time < timeout:
+ try:
+ # 获取邮件列表
+ response = self.http_client.get(
+ f"{self.config['base_url']}/inbox",
+ params={"token": token},
+ headers={"Accept": "application/json"}
+ )
+
+ if response.status_code != 200:
+ time.sleep(3)
+ continue
+
+ data = response.json()
+
+ # 检查 inbox 是否过期
+ if data is None or (isinstance(data, dict) and not data):
+ logger.warning(f"邮箱 {email} 已过期")
+ return None
+
+ email_list = data.get("emails", []) if isinstance(data, dict) else []
+
+ if not isinstance(email_list, list):
+ time.sleep(3)
+ continue
+
+ for msg in email_list:
+ if not isinstance(msg, dict):
+ continue
+
+ # 使用 date 作为唯一标识
+ msg_date = msg.get("date", 0)
+ if not msg_date or msg_date in seen_ids:
+ continue
+ seen_ids.add(msg_date)
+
+ sender = str(msg.get("from", "")).lower()
+ subject = str(msg.get("subject", ""))
+ body = str(msg.get("body", ""))
+ html = str(msg.get("html") or "")
+
+ content = "\n".join([sender, subject, body, html])
+
+ # 检查是否是 OpenAI 邮件
+ if "openai" not in sender and "openai" not in content.lower():
+ continue
+
+ # 提取验证码
+ match = re.search(pattern, content)
+ if match:
+ code = match.group(1)
+ logger.info(f"找到验证码: {code}")
+ self.update_status(True)
+ return code
+
+ except Exception as e:
+ logger.debug(f"检查邮件时出错: {e}")
+
+ # 等待一段时间再检查
+ time.sleep(3)
+
+ logger.warning(f"等待验证码超时: {email}")
+ return None
+
+ def list_emails(self, **kwargs) -> List[Dict[str, Any]]:
+ """
+ 列出所有缓存的邮箱
+
+ Note:
+ Tempmail.lol API 不支持列出所有邮箱,这里返回缓存的邮箱
+ """
+ return list(self._email_cache.values())
+
+ def delete_email(self, email_id: str) -> bool:
+ """
+ 删除邮箱
+
+ Note:
+ Tempmail.lol API 不支持删除邮箱,这里从缓存中移除
+ """
+ # 从缓存中查找并移除
+ emails_to_delete = []
+ for email, info in self._email_cache.items():
+ if info.get("token") == email_id:
+ emails_to_delete.append(email)
+
+ for email in emails_to_delete:
+ del self._email_cache[email]
+ logger.info(f"从缓存中移除邮箱: {email}")
+
+ return len(emails_to_delete) > 0
+
+ def check_health(self) -> bool:
+ """检查 Tempmail.lol 服务是否可用"""
+ try:
+ response = self.http_client.get(
+ f"{self.config['base_url']}/inbox/create",
+ timeout=10
+ )
+ # 即使返回错误状态码也认为服务可用(只要可以连接)
+ self.update_status(True)
+ return True
+ except Exception as e:
+ logger.warning(f"Tempmail.lol 健康检查失败: {e}")
+ self.update_status(False, e)
+ return False
+
+ def get_inbox(self, token: str) -> Optional[Dict[str, Any]]:
+ """
+ 获取邮箱收件箱内容
+
+ Args:
+ token: 邮箱 token
+
+ Returns:
+ 收件箱数据
+ """
+ try:
+ response = self.http_client.get(
+ f"{self.config['base_url']}/inbox",
+ params={"token": token},
+ headers={"Accept": "application/json"}
+ )
+
+ if response.status_code != 200:
+ return None
+
+ return response.json()
+ except Exception as e:
+ logger.error(f"获取收件箱失败: {e}")
+ return None
+
+ def wait_for_verification_code_with_callback(
+ self,
+ email: str,
+ token: str,
+ callback: callable = None,
+ timeout: int = 120
+ ) -> Optional[str]:
+ """
+ 等待验证码并支持回调函数
+
+ Args:
+ email: 邮箱地址
+ token: 邮箱 token
+ callback: 回调函数,接收当前状态信息
+ timeout: 超时时间
+
+ Returns:
+ 验证码或 None
+ """
+ start_time = time.time()
+ seen_ids = set()
+ check_count = 0
+
+ while time.time() - start_time < timeout:
+ check_count += 1
+
+ if callback:
+ callback({
+ "status": "checking",
+ "email": email,
+ "check_count": check_count,
+ "elapsed_time": time.time() - start_time,
+ })
+
+ try:
+ data = self.get_inbox(token)
+ if not data:
+ time.sleep(3)
+ continue
+
+ # 检查 inbox 是否过期
+ if data is None or (isinstance(data, dict) and not data):
+ if callback:
+ callback({
+ "status": "expired",
+ "email": email,
+ "message": "邮箱已过期"
+ })
+ return None
+
+ email_list = data.get("emails", []) if isinstance(data, dict) else []
+
+ for msg in email_list:
+ msg_date = msg.get("date", 0)
+ if not msg_date or msg_date in seen_ids:
+ continue
+ seen_ids.add(msg_date)
+
+ sender = str(msg.get("from", "")).lower()
+ subject = str(msg.get("subject", ""))
+ body = str(msg.get("body", ""))
+ html = str(msg.get("html") or "")
+
+ content = "\n".join([sender, subject, body, html])
+
+ # 检查是否是 OpenAI 邮件
+ if "openai" not in sender and "openai" not in content.lower():
+ continue
+
+ # 提取验证码
+ match = re.search(OTP_CODE_PATTERN, content)
+ if match:
+ code = match.group(1)
+ if callback:
+ callback({
+ "status": "found",
+ "email": email,
+ "code": code,
+ "message": "找到验证码"
+ })
+ return code
+
+ if callback and check_count % 5 == 0:
+ callback({
+ "status": "waiting",
+ "email": email,
+ "check_count": check_count,
+ "message": f"已检查 {len(seen_ids)} 封邮件,等待验证码..."
+ })
+
+ except Exception as e:
+ logger.debug(f"检查邮件时出错: {e}")
+ if callback:
+ callback({
+ "status": "error",
+ "email": email,
+ "error": str(e),
+ "message": "检查邮件时出错"
+ })
+
+ time.sleep(3)
+
+ if callback:
+ callback({
+ "status": "timeout",
+ "email": email,
+ "message": "等待验证码超时"
+ })
+ return None
\ No newline at end of file
diff --git a/src/web/__init__.py b/src/web/__init__.py
new file mode 100644
index 0000000..f722b0b
--- /dev/null
+++ b/src/web/__init__.py
@@ -0,0 +1,7 @@
+"""
+Web UI 应用模块
+"""
+
+from .app import app, create_app
+
+__all__ = ['app', 'create_app']
diff --git a/src/web/app.py b/src/web/app.py
new file mode 100644
index 0000000..3395bfb
--- /dev/null
+++ b/src/web/app.py
@@ -0,0 +1,201 @@
+"""
+FastAPI 应用主文件
+轻量级 Web UI,支持注册、账号管理、设置
+"""
+
+import logging
+import sys
+import secrets
+import hmac
+import hashlib
+from typing import Optional
+from pathlib import Path
+
+from fastapi import FastAPI, Request, Form
+from fastapi.staticfiles import StaticFiles
+from fastapi.templating import Jinja2Templates
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import HTMLResponse, RedirectResponse
+
+from ..config.settings import get_settings
+from .routes import api_router
+from .routes.websocket import router as ws_router
+from .task_manager import task_manager
+
+logger = logging.getLogger(__name__)
+
+# 获取项目根目录
+# PyInstaller 打包后静态资源在 sys._MEIPASS,开发时在源码根目录
+if getattr(sys, 'frozen', False):
+ _RESOURCE_ROOT = Path(sys._MEIPASS)
+else:
+ _RESOURCE_ROOT = Path(__file__).parent.parent.parent
+
+# 静态文件和模板目录
+STATIC_DIR = _RESOURCE_ROOT / "static"
+TEMPLATES_DIR = _RESOURCE_ROOT / "templates"
+
+
+def _build_static_asset_version(static_dir: Path) -> str:
+ """基于静态文件最后修改时间生成版本号,避免部署后浏览器继续使用旧缓存。"""
+ latest_mtime = 0
+ if static_dir.exists():
+ for path in static_dir.rglob("*"):
+ if path.is_file():
+ latest_mtime = max(latest_mtime, int(path.stat().st_mtime))
+ return str(latest_mtime or 1)
+
+
+def create_app() -> FastAPI:
+ """创建 FastAPI 应用实例"""
+ settings = get_settings()
+
+ app = FastAPI(
+ title=settings.app_name,
+ version=settings.app_version,
+ description="OpenAI/Codex CLI 自动注册系统 Web UI",
+ docs_url="/api/docs" if settings.debug else None,
+ redoc_url="/api/redoc" if settings.debug else None,
+ )
+
+ # CORS 中间件
+ app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+ )
+
+ # 挂载静态文件
+ if STATIC_DIR.exists():
+ app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
+ logger.info(f"静态文件目录: {STATIC_DIR}")
+ else:
+ # 创建静态目录
+ STATIC_DIR.mkdir(parents=True, exist_ok=True)
+ app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
+ logger.info(f"创建静态文件目录: {STATIC_DIR}")
+
+ # 创建模板目录
+ if not TEMPLATES_DIR.exists():
+ TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
+ logger.info(f"创建模板目录: {TEMPLATES_DIR}")
+
+ # 注册 API 路由
+ app.include_router(api_router, prefix="/api")
+
+ # 注册 WebSocket 路由
+ app.include_router(ws_router, prefix="/api")
+
+ # 模板引擎
+ templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
+ templates.env.globals["static_version"] = _build_static_asset_version(STATIC_DIR)
+
+ def _auth_token(password: str) -> str:
+ secret = get_settings().webui_secret_key.get_secret_value().encode("utf-8")
+ return hmac.new(secret, password.encode("utf-8"), hashlib.sha256).hexdigest()
+
+ def _is_authenticated(request: Request) -> bool:
+ cookie = request.cookies.get("webui_auth")
+ expected = _auth_token(get_settings().webui_access_password.get_secret_value())
+ return bool(cookie) and secrets.compare_digest(cookie, expected)
+
+ def _redirect_to_login(request: Request) -> RedirectResponse:
+ return RedirectResponse(url=f"/login?next={request.url.path}", status_code=302)
+
+ @app.get("/login", response_class=HTMLResponse)
+ async def login_page(request: Request, next: Optional[str] = "/"):
+ """登录页面"""
+ return templates.TemplateResponse(
+ "login.html",
+ {"request": request, "error": "", "next": next or "/"}
+ )
+
+ @app.post("/login")
+ async def login_submit(request: Request, password: str = Form(...), next: Optional[str] = "/"):
+ """处理登录提交"""
+ expected = get_settings().webui_access_password.get_secret_value()
+ if not secrets.compare_digest(password, expected):
+ return templates.TemplateResponse(
+ "login.html",
+ {"request": request, "error": "密码错误", "next": next or "/"},
+ status_code=401
+ )
+
+ response = RedirectResponse(url=next or "/", status_code=302)
+ response.set_cookie("webui_auth", _auth_token(expected), httponly=True, samesite="lax")
+ return response
+
+ @app.get("/logout")
+ async def logout(request: Request, next: Optional[str] = "/login"):
+ """退出登录"""
+ response = RedirectResponse(url=next or "/login", status_code=302)
+ response.delete_cookie("webui_auth")
+ return response
+
+ @app.get("/", response_class=HTMLResponse)
+ async def index(request: Request):
+ """首页 - 注册页面"""
+ if not _is_authenticated(request):
+ return _redirect_to_login(request)
+ return templates.TemplateResponse("index.html", {"request": request})
+
+ @app.get("/accounts", response_class=HTMLResponse)
+ async def accounts_page(request: Request):
+ """账号管理页面"""
+ if not _is_authenticated(request):
+ return _redirect_to_login(request)
+ return templates.TemplateResponse("accounts.html", {"request": request})
+
+ @app.get("/email-services", response_class=HTMLResponse)
+ async def email_services_page(request: Request):
+ """邮箱服务管理页面"""
+ if not _is_authenticated(request):
+ return _redirect_to_login(request)
+ return templates.TemplateResponse("email_services.html", {"request": request})
+
+ @app.get("/settings", response_class=HTMLResponse)
+ async def settings_page(request: Request):
+ """设置页面"""
+ if not _is_authenticated(request):
+ 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():
+ """应用启动事件"""
+ import asyncio
+ from ..database.init_db import initialize_database
+
+ # 确保数据库已初始化(reload 模式下子进程也需要初始化)
+ try:
+ initialize_database()
+ except Exception as e:
+ logger.warning(f"数据库初始化: {e}")
+
+ # 设置 TaskManager 的事件循环
+ loop = asyncio.get_event_loop()
+ task_manager.set_loop(loop)
+
+ logger.info("=" * 50)
+ logger.info(f"{settings.app_name} v{settings.app_version} 启动中...")
+ logger.info(f"调试模式: {settings.debug}")
+ logger.info(f"数据库: {settings.database_url}")
+ logger.info("=" * 50)
+
+ @app.on_event("shutdown")
+ async def shutdown_event():
+ """应用关闭事件"""
+ logger.info("应用关闭")
+
+ return app
+
+
+# 创建全局应用实例
+app = create_app()
diff --git a/src/web/routes/__init__.py b/src/web/routes/__init__.py
new file mode 100644
index 0000000..7748775
--- /dev/null
+++ b/src/web/routes/__init__.py
@@ -0,0 +1,26 @@
+"""
+API 路由模块
+"""
+
+from fastapi import APIRouter
+
+from .accounts import router as accounts_router
+from .registration import router as registration_router
+from .settings import router as settings_router
+from .email import router as email_services_router
+from .payment import router as payment_router
+from .upload.cpa_services import router as cpa_services_router
+from .upload.sub2api_services import router as sub2api_services_router
+from .upload.tm_services import router as tm_services_router
+
+api_router = APIRouter()
+
+# 注册各模块路由
+api_router.include_router(accounts_router, prefix="/accounts", tags=["accounts"])
+api_router.include_router(registration_router, prefix="/registration", tags=["registration"])
+api_router.include_router(settings_router, prefix="/settings", tags=["settings"])
+api_router.include_router(email_services_router, prefix="/email-services", tags=["email-services"])
+api_router.include_router(payment_router, prefix="/payment", tags=["payment"])
+api_router.include_router(cpa_services_router, prefix="/cpa-services", tags=["cpa-services"])
+api_router.include_router(sub2api_services_router, prefix="/sub2api-services", tags=["sub2api-services"])
+api_router.include_router(tm_services_router, prefix="/tm-services", tags=["tm-services"])
diff --git a/src/web/routes/accounts.py b/src/web/routes/accounts.py
new file mode 100644
index 0000000..5d83e16
--- /dev/null
+++ b/src/web/routes/accounts.py
@@ -0,0 +1,1086 @@
+"""
+账号管理 API 路由
+"""
+import io
+import json
+import logging
+import zipfile
+from datetime import datetime
+from typing import Dict, List, Optional
+
+from fastapi import APIRouter, HTTPException, Query, BackgroundTasks, Body
+from fastapi.responses import StreamingResponse
+from pydantic import BaseModel
+
+from ...config.constants import AccountStatus
+from ...config.settings import get_settings
+from ...core.openai.token_refresh import refresh_account_token as do_refresh
+from ...core.openai.token_refresh import validate_account_token as do_validate
+from ...core.upload.cpa_upload import generate_token_json, batch_upload_to_cpa, upload_to_cpa
+from ...core.upload.team_manager_upload import upload_to_team_manager, batch_upload_to_team_manager
+from ...core.upload.sub2api_upload import batch_upload_to_sub2api, upload_to_sub2api
+
+from ...core.dynamic_proxy import get_proxy_url_for_task
+from ...database import crud
+from ...database.models import Account
+from ...database.session import get_db
+
+logger = logging.getLogger(__name__)
+router = APIRouter()
+
+
+def _get_proxy(request_proxy: Optional[str] = None) -> Optional[str]:
+ """获取代理 URL,策略与注册流程一致:代理列表 → 动态代理 → 静态配置"""
+ if request_proxy:
+ return request_proxy
+ with get_db() as db:
+ proxy = crud.get_random_proxy(db)
+ if proxy:
+ return proxy.proxy_url
+ proxy_url = get_proxy_url_for_task()
+ if proxy_url:
+ return proxy_url
+ return get_settings().proxy_url
+
+
+# ============== Pydantic Models ==============
+
+class AccountResponse(BaseModel):
+ """账号响应模型"""
+ id: int
+ email: str
+ password: Optional[str] = None
+ client_id: Optional[str] = None
+ email_service: str
+ account_id: Optional[str] = None
+ workspace_id: Optional[str] = None
+ registered_at: Optional[str] = None
+ last_refresh: Optional[str] = None
+ expires_at: Optional[str] = None
+ status: str
+ proxy_used: Optional[str] = None
+ cpa_uploaded: bool = False
+ cpa_uploaded_at: Optional[str] = None
+ cookies: Optional[str] = None
+ created_at: Optional[str] = None
+ updated_at: Optional[str] = None
+
+ class Config:
+ from_attributes = True
+
+
+class AccountListResponse(BaseModel):
+ """账号列表响应"""
+ total: int
+ accounts: List[AccountResponse]
+
+
+class AccountUpdateRequest(BaseModel):
+ """账号更新请求"""
+ status: Optional[str] = None
+ metadata: Optional[dict] = None
+ cookies: Optional[str] = None # 完整 cookie 字符串,用于支付请求
+
+
+class BatchDeleteRequest(BaseModel):
+ """批量删除请求"""
+ 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):
+ """批量更新请求"""
+ ids: List[int]
+ status: str
+
+
+# ============== 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(
+ id=account.id,
+ email=account.email,
+ password=account.password,
+ client_id=account.client_id,
+ email_service=account.email_service,
+ account_id=account.account_id,
+ workspace_id=account.workspace_id,
+ registered_at=account.registered_at.isoformat() if account.registered_at else None,
+ last_refresh=account.last_refresh.isoformat() if account.last_refresh else None,
+ expires_at=account.expires_at.isoformat() if account.expires_at else None,
+ status=account.status,
+ proxy_used=account.proxy_used,
+ 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,
+ )
+
+
+# ============== API Endpoints ==============
+
+@router.get("", response_model=AccountListResponse)
+async def list_accounts(
+ page: int = Query(1, ge=1, description="页码"),
+ page_size: int = Query(20, ge=1, le=100, description="每页数量"),
+ status: Optional[str] = Query(None, description="状态筛选"),
+ email_service: Optional[str] = Query(None, description="邮箱服务筛选"),
+ search: Optional[str] = Query(None, description="搜索关键词"),
+):
+ """
+ 获取账号列表
+
+ 支持分页、状态筛选、邮箱服务筛选和搜索
+ """
+ with get_db() as db:
+ # 构建查询
+ query = db.query(Account)
+
+ # 状态筛选
+ if status:
+ query = query.filter(Account.status == status)
+
+ # 邮箱服务筛选
+ if email_service:
+ query = query.filter(Account.email_service == email_service)
+
+ # 搜索
+ if search:
+ search_pattern = f"%{search}%"
+ query = query.filter(
+ (Account.email.ilike(search_pattern)) |
+ (Account.account_id.ilike(search_pattern))
+ )
+
+ # 统计总数
+ total = query.count()
+
+ # 分页
+ offset = (page - 1) * page_size
+ accounts = query.order_by(Account.created_at.desc()).offset(offset).limit(page_size).all()
+
+ return AccountListResponse(
+ total=total,
+ accounts=[account_to_response(acc) for acc in accounts]
+ )
+
+
+@router.get("/{account_id}", response_model=AccountResponse)
+async def get_account(account_id: int):
+ """获取单个账号详情"""
+ with get_db() as db:
+ account = crud.get_account_by_id(db, account_id)
+ if not account:
+ raise HTTPException(status_code=404, detail="账号不存在")
+ return account_to_response(account)
+
+
+@router.get("/{account_id}/tokens")
+async def get_account_tokens(account_id: int):
+ """获取账号的 Token 信息"""
+ with get_db() as db:
+ account = crud.get_account_by_id(db, account_id)
+ if not account:
+ raise HTTPException(status_code=404, detail="账号不存在")
+
+ return {
+ "id": account.id,
+ "email": account.email,
+ "access_token": account.access_token,
+ "refresh_token": account.refresh_token,
+ "id_token": account.id_token,
+ "has_tokens": bool(account.access_token and account.refresh_token),
+ }
+
+
+@router.patch("/{account_id}", response_model=AccountResponse)
+async def update_account(account_id: int, request: AccountUpdateRequest):
+ """更新账号状态"""
+ with get_db() as db:
+ account = crud.get_account_by_id(db, account_id)
+ if not account:
+ raise HTTPException(status_code=404, detail="账号不存在")
+
+ update_data = {}
+ if request.status:
+ if request.status not in [e.value for e in AccountStatus]:
+ raise HTTPException(status_code=400, detail="无效的状态值")
+ update_data["status"] = request.status
+
+ if request.metadata:
+ current_metadata = account.metadata or {}
+ current_metadata.update(request.metadata)
+ update_data["metadata"] = current_metadata
+
+ 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):
+ """删除单个账号"""
+ with get_db() as db:
+ account = crud.get_account_by_id(db, account_id)
+ if not account:
+ raise HTTPException(status_code=404, detail="账号不存在")
+
+ crud.delete_account(db, account_id)
+ return {"success": True, "message": f"账号 {account.email} 已删除"}
+
+
+@router.post("/batch-delete")
+async def batch_delete_accounts(request: BatchDeleteRequest):
+ """批量删除账号"""
+ with get_db() as db:
+ 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 ids:
+ try:
+ account = crud.get_account_by_id(db, account_id)
+ if account:
+ crud.delete_account(db, account_id)
+ deleted_count += 1
+ except Exception as e:
+ errors.append(f"ID {account_id}: {str(e)}")
+
+ return {
+ "success": True,
+ "deleted_count": deleted_count,
+ "errors": errors if errors else None
+ }
+
+
+@router.post("/batch-update")
+async def batch_update_accounts(request: BatchUpdateRequest):
+ """批量更新账号状态"""
+ if request.status not in [e.value for e in AccountStatus]:
+ raise HTTPException(status_code=400, detail="无效的状态值")
+
+ with get_db() as db:
+ updated_count = 0
+ errors = []
+
+ for account_id in request.ids:
+ try:
+ account = crud.get_account_by_id(db, account_id)
+ if account:
+ crud.update_account(db, account_id, status=request.status)
+ updated_count += 1
+ except Exception as e:
+ errors.append(f"ID {account_id}: {str(e)}")
+
+ return {
+ "success": True,
+ "updated_count": updated_count,
+ "errors": errors if errors else None
+ }
+
+
+class BatchExportRequest(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("/export/json")
+async def export_accounts_json(request: BatchExportRequest):
+ """导出账号为 JSON 格式"""
+ 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()
+
+ export_data = []
+ for acc in accounts:
+ export_data.append({
+ "email": acc.email,
+ "password": acc.password,
+ "client_id": acc.client_id,
+ "account_id": acc.account_id,
+ "workspace_id": acc.workspace_id,
+ "access_token": acc.access_token,
+ "refresh_token": acc.refresh_token,
+ "id_token": acc.id_token,
+ "session_token": acc.session_token,
+ "email_service": acc.email_service,
+ "registered_at": acc.registered_at.isoformat() if acc.registered_at else None,
+ "last_refresh": acc.last_refresh.isoformat() if acc.last_refresh else None,
+ "expires_at": acc.expires_at.isoformat() if acc.expires_at else None,
+ "status": acc.status,
+ })
+
+ # 生成文件名
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ filename = f"accounts_{timestamp}.json"
+
+ # 返回 JSON 响应
+ content = json.dumps(export_data, ensure_ascii=False, indent=2)
+
+ return StreamingResponse(
+ iter([content]),
+ media_type="application/json",
+ headers={"Content-Disposition": f"attachment; filename={filename}"}
+ )
+
+
+@router.post("/export/csv")
+async def export_accounts_csv(request: BatchExportRequest):
+ """导出账号为 CSV 格式"""
+ import csv
+ import io
+
+ 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()
+
+ # 创建 CSV 内容
+ output = io.StringIO()
+ writer = csv.writer(output)
+
+ # 写入表头
+ writer.writerow([
+ "ID", "Email", "Password", "Client ID",
+ "Account ID", "Workspace ID",
+ "Access Token", "Refresh Token", "ID Token", "Session Token",
+ "Email Service", "Status", "Registered At", "Last Refresh", "Expires At"
+ ])
+
+ # 写入数据
+ for acc in accounts:
+ writer.writerow([
+ acc.id,
+ acc.email,
+ acc.password or "",
+ acc.client_id or "",
+ acc.account_id or "",
+ acc.workspace_id or "",
+ acc.access_token or "",
+ acc.refresh_token or "",
+ acc.id_token or "",
+ acc.session_token or "",
+ acc.email_service,
+ acc.status,
+ acc.registered_at.isoformat() if acc.registered_at else "",
+ acc.last_refresh.isoformat() if acc.last_refresh else "",
+ acc.expires_at.isoformat() if acc.expires_at else ""
+ ])
+
+ # 生成文件名
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ filename = f"accounts_{timestamp}.csv"
+
+ return StreamingResponse(
+ iter([output.getvalue()]),
+ media_type="text/csv",
+ headers={"Content-Disposition": f"attachment; filename={filename}"}
+ )
+
+
+@router.post("/export/sub2api")
+async def export_accounts_sub2api(request: BatchExportRequest):
+ """导出账号为 Sub2Api 格式(所有选中账号合并到一个 JSON 的 accounts 数组中)"""
+
+ def make_account_entry(acc) -> dict:
+ expires_at = int(acc.expires_at.timestamp()) if acc.expires_at else 0
+ return {
+ "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",
+ "gpt-5.3": "gpt-5.3",
+ "gpt-5.3-codex": "gpt-5.3-codex",
+ "gpt-5.4": "gpt-5.4"
+ },
+ "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")
+ payload = {
+ "proxies": [],
+ "accounts": [make_account_entry(acc) for acc in accounts]
+ }
+ content = json.dumps(payload, ensure_ascii=False, indent=2)
+
+ if len(accounts) == 1:
+ filename = f"{accounts[0].email}_sub2api.json"
+ else:
+ filename = f"sub2api_tokens_{timestamp}.json"
+
+ return StreamingResponse(
+ iter([content]),
+ media_type="application/json",
+ headers={"Content-Disposition": f"attachment; filename={filename}"}
+ )
+
+
+@router.post("/export/cpa")
+async def export_accounts_cpa(request: BatchExportRequest):
+ """导出账号为 CPA Token JSON 格式(每个账号单独一个 JSON 文件,打包为 ZIP)"""
+ 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:
+ # 单个账号直接返回 JSON 文件
+ acc = accounts[0]
+ token_data = generate_token_json(acc)
+ content = json.dumps(token_data, ensure_ascii=False, indent=2)
+ filename = f"{acc.email}.json"
+ return StreamingResponse(
+ iter([content]),
+ media_type="application/json",
+ headers={"Content-Disposition": f"attachment; filename={filename}"}
+ )
+
+ # 多个账号打包为 ZIP
+ zip_buffer = io.BytesIO()
+ with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+ for acc in accounts:
+ token_data = generate_token_json(acc)
+ content = json.dumps(token_data, ensure_ascii=False, indent=2)
+ zf.writestr(f"{acc.email}.json", content)
+
+ zip_buffer.seek(0)
+ zip_filename = f"cpa_tokens_{timestamp}.zip"
+ return StreamingResponse(
+ zip_buffer,
+ media_type="application/zip",
+ headers={"Content-Disposition": f"attachment; filename={zip_filename}"}
+ )
+
+
+@router.get("/stats/summary")
+async def get_accounts_stats():
+ """获取账号统计信息"""
+ with get_db() as db:
+ from sqlalchemy import func
+
+ # 总数
+ total = db.query(func.count(Account.id)).scalar()
+
+ # 按状态统计
+ status_stats = db.query(
+ Account.status,
+ func.count(Account.id)
+ ).group_by(Account.status).all()
+
+ # 按邮箱服务统计
+ service_stats = db.query(
+ Account.email_service,
+ func.count(Account.id)
+ ).group_by(Account.email_service).all()
+
+ return {
+ "total": total,
+ "by_status": {status: count for status, count in status_stats},
+ "by_email_service": {service: count for service, count in service_stats}
+ }
+
+
+# ============== Token 刷新相关 ==============
+
+class TokenRefreshRequest(BaseModel):
+ """Token 刷新请求"""
+ proxy: Optional[str] = None
+
+
+class BatchRefreshRequest(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 TokenValidateRequest(BaseModel):
+ """Token 验证请求"""
+ proxy: Optional[str] = None
+
+
+class BatchValidateRequest(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
+
+
+@router.post("/batch-refresh")
+async def batch_refresh_tokens(request: BatchRefreshRequest, background_tasks: BackgroundTasks):
+ """批量刷新账号 Token"""
+ proxy = _get_proxy(request.proxy)
+
+ results = {
+ "success_count": 0,
+ "failed_count": 0,
+ "errors": []
+ }
+
+ 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:
+ results["success_count"] += 1
+ else:
+ results["failed_count"] += 1
+ results["errors"].append({"id": account_id, "error": result.error_message})
+ except Exception as e:
+ results["failed_count"] += 1
+ results["errors"].append({"id": account_id, "error": str(e)})
+
+ return results
+
+
+@router.post("/{account_id}/refresh")
+async def refresh_account_token(account_id: int, request: Optional[TokenRefreshRequest] = Body(default=None)):
+ """刷新单个账号的 Token"""
+ proxy = _get_proxy(request.proxy if request else None)
+ result = do_refresh(account_id, proxy)
+
+ if result.success:
+ return {
+ "success": True,
+ "message": "Token 刷新成功",
+ "expires_at": result.expires_at.isoformat() if result.expires_at else None
+ }
+ else:
+ return {
+ "success": False,
+ "error": result.error_message
+ }
+
+
+@router.post("/batch-validate")
+async def batch_validate_tokens(request: BatchValidateRequest):
+ """批量验证账号 Token 有效性"""
+ proxy = _get_proxy(request.proxy)
+
+ results = {
+ "valid_count": 0,
+ "invalid_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:
+ try:
+ is_valid, error = do_validate(account_id, proxy)
+ results["details"].append({
+ "id": account_id,
+ "valid": is_valid,
+ "error": error
+ })
+ if is_valid:
+ results["valid_count"] += 1
+ else:
+ results["invalid_count"] += 1
+ except Exception as e:
+ results["invalid_count"] += 1
+ results["details"].append({
+ "id": account_id,
+ "valid": False,
+ "error": str(e)
+ })
+
+ return results
+
+
+@router.post("/{account_id}/validate")
+async def validate_account_token(account_id: int, request: Optional[TokenValidateRequest] = Body(default=None)):
+ """验证单个账号的 Token 有效性"""
+ proxy = _get_proxy(request.proxy if request else None)
+ is_valid, error = do_validate(account_id, proxy)
+
+ return {
+ "id": account_id,
+ "valid": is_valid,
+ "error": error
+ }
+
+
+# ============== CPA 上传相关 ==============
+
+class CPAUploadRequest(BaseModel):
+ """CPA 上传请求"""
+ proxy: Optional[str] = None
+ cpa_service_id: Optional[int] = None # 指定 CPA 服务 ID,不传则使用全局配置
+
+
+class BatchCPAUploadRequest(BaseModel):
+ """批量 CPA 上传请求"""
+ 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
+ cpa_service_id: Optional[int] = None # 指定 CPA 服务 ID,不传则使用全局配置
+
+
+@router.post("/batch-upload-cpa")
+async def batch_upload_accounts_to_cpa(request: BatchCPAUploadRequest):
+ """批量上传账号到 CPA"""
+
+ proxy = request.proxy
+
+ # 解析指定的 CPA 服务
+ cpa_api_url = None
+ cpa_api_token = None
+ include_proxy_url = False
+ if request.cpa_service_id:
+ with get_db() as db:
+ svc = crud.get_cpa_service_by_id(db, request.cpa_service_id)
+ if not svc:
+ raise HTTPException(status_code=404, detail="指定的 CPA 服务不存在")
+ cpa_api_url = svc.api_url
+ cpa_api_token = svc.api_token
+ include_proxy_url = bool(svc.include_proxy_url)
+
+ 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,
+ api_url=cpa_api_url,
+ api_token=cpa_api_token,
+ include_proxy_url=include_proxy_url,
+ )
+ return results
+
+
+@router.post("/{account_id}/upload-cpa")
+async def upload_account_to_cpa(account_id: int, request: Optional[CPAUploadRequest] = Body(default=None)):
+ """上传单个账号到 CPA"""
+
+ proxy = request.proxy if request else None
+ cpa_service_id = request.cpa_service_id if request else None
+
+ # 解析指定的 CPA 服务
+ cpa_api_url = None
+ cpa_api_token = None
+ include_proxy_url = False
+ if cpa_service_id:
+ with get_db() as db:
+ svc = crud.get_cpa_service_by_id(db, cpa_service_id)
+ if not svc:
+ raise HTTPException(status_code=404, detail="指定的 CPA 服务不存在")
+ cpa_api_url = svc.api_url
+ cpa_api_token = svc.api_token
+ include_proxy_url = bool(svc.include_proxy_url)
+
+ with get_db() as db:
+ account = crud.get_account_by_id(db, account_id)
+ if not account:
+ raise HTTPException(status_code=404, detail="账号不存在")
+
+ if not account.access_token:
+ return {
+ "success": False,
+ "error": "账号缺少 Token,无法上传"
+ }
+
+ # 生成 Token JSON
+ token_data = generate_token_json(
+ account,
+ include_proxy_url=include_proxy_url,
+ proxy_url=proxy,
+ )
+
+ # 上传
+ success, message = upload_to_cpa(token_data, proxy, api_url=cpa_api_url, api_token=cpa_api_token)
+
+ if success:
+ account.cpa_uploaded = True
+ account.cpa_uploaded_at = datetime.utcnow()
+ db.commit()
+ return {"success": True, "message": message}
+ else:
+ return {"success": False, "error": message}
+
+
+class Sub2ApiUploadRequest(BaseModel):
+ """单账号 Sub2API 上传请求"""
+ service_id: Optional[int] = None
+ concurrency: int = 3
+ priority: int = 50
+ group_ids: List[int] = []
+ proxy_id: Optional[int] = None
+ model_mapping: Optional[Dict[str, str]] = None
+
+
+class BatchSub2ApiUploadRequest(BaseModel):
+ """批量 Sub2API 上传请求"""
+ ids: List[int] = []
+ select_all: bool = False
+ status_filter: Optional[str] = None
+ email_service_filter: Optional[str] = None
+ search_filter: Optional[str] = None
+ service_id: Optional[int] = None # 指定 Sub2API 服务 ID,不传则使用第一个启用的
+ concurrency: int = 3
+ priority: int = 50
+ group_ids: List[int] = []
+ proxy_id: Optional[int] = None
+ model_mapping: Optional[Dict[str, str]] = None
+
+
+@router.post("/batch-upload-sub2api")
+async def batch_upload_accounts_to_sub2api(request: BatchSub2ApiUploadRequest):
+ """批量上传账号到 Sub2API"""
+
+ # 解析指定的 Sub2API 服务
+ api_url = None
+ api_key = None
+ if request.service_id:
+ with get_db() as db:
+ svc = crud.get_sub2api_service_by_id(db, request.service_id)
+ if not svc:
+ raise HTTPException(status_code=404, detail="指定的 Sub2API 服务不存在")
+ api_url = svc.api_url
+ api_key = svc.api_key
+ else:
+ with get_db() as db:
+ svcs = crud.get_sub2api_services(db, enabled=True)
+ if svcs:
+ api_url = svcs[0].api_url
+ api_key = svcs[0].api_key
+
+ if not api_url or not api_key:
+ raise HTTPException(status_code=400, detail="未找到可用的 Sub2API 服务,请先在设置中配置")
+
+ 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_sub2api(
+ ids, api_url, api_key,
+ concurrency=request.concurrency,
+ priority=request.priority,
+ group_ids=request.group_ids if request.group_ids else None,
+ proxy_id=request.proxy_id,
+ model_mapping=request.model_mapping,
+ )
+ return results
+
+
+@router.post("/{account_id}/upload-sub2api")
+async def upload_account_to_sub2api(account_id: int, request: Optional[Sub2ApiUploadRequest] = Body(default=None)):
+ """上传单个账号到 Sub2API"""
+
+ service_id = request.service_id if request else None
+ concurrency = request.concurrency if request else 3
+ priority = request.priority if request else 50
+
+ api_url = None
+ api_key = None
+ if service_id:
+ with get_db() as db:
+ svc = crud.get_sub2api_service_by_id(db, service_id)
+ if not svc:
+ raise HTTPException(status_code=404, detail="指定的 Sub2API 服务不存在")
+ api_url = svc.api_url
+ api_key = svc.api_key
+ else:
+ with get_db() as db:
+ svcs = crud.get_sub2api_services(db, enabled=True)
+ if svcs:
+ api_url = svcs[0].api_url
+ api_key = svcs[0].api_key
+
+ if not api_url or not api_key:
+ raise HTTPException(status_code=400, detail="未找到可用的 Sub2API 服务,请先在设置中配置")
+
+ with get_db() as db:
+ account = crud.get_account_by_id(db, account_id)
+ if not account:
+ raise HTTPException(status_code=404, detail="账号不存在")
+ if not account.access_token:
+ return {"success": False, "error": "账号缺少 Token,无法上传"}
+
+ success, message = upload_to_sub2api(
+ [account], api_url, api_key,
+ concurrency=concurrency, priority=priority
+ )
+ if success:
+ return {"success": True, "message": message}
+ else:
+ return {"success": False, "error": message}
+
+
+# ============== Team Manager 上传 ==============
+
+class UploadTMRequest(BaseModel):
+ service_id: Optional[int] = None
+
+
+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
+ service_id: Optional[int] = None
+
+
+@router.post("/batch-upload-tm")
+async def batch_upload_accounts_to_tm(request: BatchUploadTMRequest):
+ """批量上传账号到 Team Manager"""
+
+ with get_db() as db:
+ if request.service_id:
+ svc = crud.get_tm_service_by_id(db, request.service_id)
+ else:
+ svcs = crud.get_tm_services(db, enabled=True)
+ svc = svcs[0] if svcs else None
+
+ if not svc:
+ raise HTTPException(status_code=400, detail="未找到可用的 Team Manager 服务,请先在设置中配置")
+
+ api_url = svc.api_url
+ api_key = svc.api_key
+
+ 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
+
+
+@router.post("/{account_id}/upload-tm")
+async def upload_account_to_tm(account_id: int, request: Optional[UploadTMRequest] = Body(default=None)):
+ """上传单账号到 Team Manager"""
+
+ service_id = request.service_id if request else None
+
+ with get_db() as db:
+ if service_id:
+ svc = crud.get_tm_service_by_id(db, service_id)
+ else:
+ svcs = crud.get_tm_services(db, enabled=True)
+ svc = svcs[0] if svcs else None
+
+ if not svc:
+ raise HTTPException(status_code=400, detail="未找到可用的 Team Manager 服务,请先在设置中配置")
+
+ api_url = svc.api_url
+ api_key = svc.api_key
+
+ account = crud.get_account_by_id(db, account_id)
+ 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}
+
+
+# ============== Inbox Code ==============
+
+def _build_inbox_config(db, service_type, email: str) -> dict:
+ """根据账号邮箱服务类型从数据库构建服务配置(不传 proxy_url)"""
+ from ...database.models import EmailService as EmailServiceModel
+ from ...services import EmailServiceType as EST
+
+ if service_type == EST.TEMPMAIL:
+ settings = get_settings()
+ return {
+ "base_url": settings.tempmail_base_url,
+ "timeout": settings.tempmail_timeout,
+ "max_retries": settings.tempmail_max_retries,
+ }
+
+ if service_type == EST.MOE_MAIL:
+ # 按域名后缀匹配,找不到则取 priority 最小的
+ domain = email.split("@")[1] if "@" in email else ""
+ services = db.query(EmailServiceModel).filter(
+ EmailServiceModel.service_type == "moe_mail",
+ EmailServiceModel.enabled == True
+ ).order_by(EmailServiceModel.priority.asc()).all()
+ svc = None
+ for s in services:
+ cfg = s.config or {}
+ if cfg.get("default_domain") == domain or cfg.get("domain") == domain:
+ svc = s
+ break
+ if not svc and services:
+ svc = services[0]
+ if not svc:
+ return None
+ cfg = svc.config.copy()
+ if "api_url" in cfg and "base_url" not in cfg:
+ cfg["base_url"] = cfg.pop("api_url")
+ return cfg
+
+ # 其余服务类型:直接按 service_type 查数据库
+ type_map = {
+ EST.TEMP_MAIL: "temp_mail",
+ EST.DUCK_MAIL: "duck_mail",
+ EST.FREEMAIL: "freemail",
+ EST.IMAP_MAIL: "imap_mail",
+ EST.OUTLOOK: "outlook",
+ }
+ db_type = type_map.get(service_type)
+ if not db_type:
+ return None
+
+ query = db.query(EmailServiceModel).filter(
+ EmailServiceModel.service_type == db_type,
+ EmailServiceModel.enabled == True
+ )
+ if service_type == EST.OUTLOOK:
+ # 按 config.email 匹配账号 email
+ services = query.all()
+ svc = next((s for s in services if (s.config or {}).get("email") == email), None)
+ else:
+ svc = query.order_by(EmailServiceModel.priority.asc()).first()
+
+ if not svc:
+ return None
+ cfg = svc.config.copy() if svc.config else {}
+ if "api_url" in cfg and "base_url" not in cfg:
+ cfg["base_url"] = cfg.pop("api_url")
+ return cfg
+
+
+@router.post("/{account_id}/inbox-code")
+async def get_account_inbox_code(account_id: int):
+ """查询账号邮箱收件箱最新验证码"""
+ from ...services import EmailServiceFactory, EmailServiceType
+
+ with get_db() as db:
+ account = crud.get_account_by_id(db, account_id)
+ if not account:
+ raise HTTPException(status_code=404, detail="账号不存在")
+
+ try:
+ service_type = EmailServiceType(account.email_service)
+ except ValueError:
+ return {"success": False, "error": "不支持的邮箱服务类型"}
+
+ config = _build_inbox_config(db, service_type, account.email)
+ if config is None:
+ return {"success": False, "error": "未找到可用的邮箱服务配置"}
+
+ try:
+ svc = EmailServiceFactory.create(service_type, config)
+ code = svc.get_verification_code(
+ account.email,
+ email_id=account.email_service_id,
+ timeout=12
+ )
+ except Exception as e:
+ return {"success": False, "error": str(e)}
+
+ if not code:
+ return {"success": False, "error": "未收到验证码邮件"}
+
+ return {"success": True, "code": code, "email": account.email}
diff --git a/src/web/routes/email.py b/src/web/routes/email.py
new file mode 100644
index 0000000..5f0123c
--- /dev/null
+++ b/src/web/routes/email.py
@@ -0,0 +1,610 @@
+"""
+邮箱服务配置 API 路由
+"""
+
+import logging
+from typing import List, Optional, Dict, Any
+
+from fastapi import APIRouter, HTTPException, Query
+from pydantic import BaseModel
+
+from ...database import crud
+from ...database.session import get_db
+from ...database.models import EmailService as EmailServiceModel
+from ...services import EmailServiceFactory, EmailServiceType
+
+logger = logging.getLogger(__name__)
+router = APIRouter()
+
+
+# ============== Pydantic Models ==============
+
+class EmailServiceCreate(BaseModel):
+ """创建邮箱服务请求"""
+ service_type: str
+ name: str
+ config: Dict[str, Any]
+ enabled: bool = True
+ priority: int = 0
+
+
+class EmailServiceUpdate(BaseModel):
+ """更新邮箱服务请求"""
+ name: Optional[str] = None
+ config: Optional[Dict[str, Any]] = None
+ enabled: Optional[bool] = None
+ priority: Optional[int] = None
+
+
+class EmailServiceResponse(BaseModel):
+ """邮箱服务响应"""
+ id: int
+ service_type: str
+ name: str
+ enabled: bool
+ priority: int
+ config: Optional[Dict[str, Any]] = None # 过滤敏感信息后的配置
+ last_used: Optional[str] = None
+ created_at: Optional[str] = None
+ updated_at: Optional[str] = None
+
+ class Config:
+ from_attributes = True
+
+
+class EmailServiceListResponse(BaseModel):
+ """邮箱服务列表响应"""
+ total: int
+ services: List[EmailServiceResponse]
+
+
+class ServiceTestResult(BaseModel):
+ """服务测试结果"""
+ success: bool
+ message: str
+ details: Optional[Dict[str, Any]] = None
+
+
+class OutlookBatchImportRequest(BaseModel):
+ """Outlook 批量导入请求"""
+ data: str # 多行数据,每行格式: 邮箱----密码 或 邮箱----密码----client_id----refresh_token
+ enabled: bool = True
+ priority: int = 0
+
+
+class OutlookBatchImportResponse(BaseModel):
+ """Outlook 批量导入响应"""
+ total: int
+ success: int
+ failed: int
+ accounts: List[Dict[str, Any]]
+ errors: List[str]
+
+
+# ============== Helper Functions ==============
+
+# 敏感字段列表,返回响应时需要过滤
+SENSITIVE_FIELDS = {'password', 'api_key', 'refresh_token', 'access_token', 'admin_token'}
+
+def filter_sensitive_config(config: Dict[str, Any]) -> Dict[str, Any]:
+ """过滤敏感配置信息"""
+ if not config:
+ return {}
+
+ filtered = {}
+ for key, value in config.items():
+ if key in SENSITIVE_FIELDS:
+ # 敏感字段不返回,但标记是否存在
+ filtered[f"has_{key}"] = bool(value)
+ else:
+ filtered[key] = value
+
+ # 为 Outlook 计算是否有 OAuth
+ if config.get('client_id') and config.get('refresh_token'):
+ filtered['has_oauth'] = True
+
+ return filtered
+
+
+def service_to_response(service: EmailServiceModel) -> EmailServiceResponse:
+ """转换服务模型为响应"""
+ return EmailServiceResponse(
+ id=service.id,
+ service_type=service.service_type,
+ name=service.name,
+ enabled=service.enabled,
+ priority=service.priority,
+ config=filter_sensitive_config(service.config),
+ last_used=service.last_used.isoformat() if service.last_used else None,
+ created_at=service.created_at.isoformat() if service.created_at else None,
+ updated_at=service.updated_at.isoformat() if service.updated_at else None,
+ )
+
+
+# ============== API Endpoints ==============
+
+@router.get("/stats")
+async def get_email_services_stats():
+ """获取邮箱服务统计信息"""
+ with get_db() as db:
+ from sqlalchemy import func
+
+ # 按类型统计
+ type_stats = db.query(
+ EmailServiceModel.service_type,
+ func.count(EmailServiceModel.id)
+ ).group_by(EmailServiceModel.service_type).all()
+
+ # 启用数量
+ enabled_count = db.query(func.count(EmailServiceModel.id)).filter(
+ EmailServiceModel.enabled == True
+ ).scalar()
+
+ stats = {
+ 'outlook_count': 0,
+ 'custom_count': 0,
+ 'temp_mail_count': 0,
+ 'duck_mail_count': 0,
+ 'freemail_count': 0,
+ 'imap_mail_count': 0,
+ 'tempmail_available': True, # 临时邮箱始终可用
+ 'enabled_count': enabled_count
+ }
+
+ for service_type, count in type_stats:
+ if service_type == 'outlook':
+ stats['outlook_count'] = count
+ elif service_type == 'moe_mail':
+ stats['custom_count'] = count
+ elif service_type == 'temp_mail':
+ stats['temp_mail_count'] = count
+ elif service_type == 'duck_mail':
+ stats['duck_mail_count'] = count
+ elif service_type == 'freemail':
+ stats['freemail_count'] = count
+ elif service_type == 'imap_mail':
+ stats['imap_mail_count'] = count
+
+ return stats
+
+
+@router.get("/types")
+async def get_service_types():
+ """获取支持的邮箱服务类型"""
+ return {
+ "types": [
+ {
+ "value": "tempmail",
+ "label": "Tempmail.lol",
+ "description": "临时邮箱服务,无需配置",
+ "config_fields": [
+ {"name": "base_url", "label": "API 地址", "default": "https://api.tempmail.lol/v2", "required": False},
+ {"name": "timeout", "label": "超时时间", "default": 30, "required": False},
+ ]
+ },
+ {
+ "value": "outlook",
+ "label": "Outlook",
+ "description": "Outlook 邮箱,需要配置账户信息",
+ "config_fields": [
+ {"name": "email", "label": "邮箱地址", "required": True},
+ {"name": "password", "label": "密码", "required": True},
+ {"name": "client_id", "label": "OAuth Client ID", "required": False},
+ {"name": "refresh_token", "label": "OAuth Refresh Token", "required": False},
+ ]
+ },
+ {
+ "value": "moe_mail",
+ "label": "MoeMail",
+ "description": "自定义域名邮箱服务",
+ "config_fields": [
+ {"name": "base_url", "label": "API 地址", "required": True},
+ {"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},
+ ]
+ },
+ {
+ "value": "duck_mail",
+ "label": "DuckMail",
+ "description": "DuckMail 接口邮箱服务,支持 API Key 私有域名访问",
+ "config_fields": [
+ {"name": "base_url", "label": "API 地址", "required": True, "placeholder": "https://api.duckmail.sbs"},
+ {"name": "default_domain", "label": "默认域名", "required": True, "placeholder": "duckmail.sbs"},
+ {"name": "api_key", "label": "API Key", "required": False, "secret": True},
+ {"name": "password_length", "label": "随机密码长度", "required": False, "default": 12},
+ ]
+ },
+ {
+ "value": "freemail",
+ "label": "Freemail",
+ "description": "Freemail 自部署 Cloudflare Worker 临时邮箱服务",
+ "config_fields": [
+ {"name": "base_url", "label": "API 地址", "required": True, "placeholder": "https://freemail.example.com"},
+ {"name": "admin_token", "label": "Admin Token", "required": True, "secret": True},
+ {"name": "domain", "label": "邮箱域名", "required": False, "placeholder": "example.com"},
+ ]
+ },
+ {
+ "value": "imap_mail",
+ "label": "IMAP 邮箱",
+ "description": "标准 IMAP 协议邮箱(Gmail/QQ/163等),仅用于接收验证码,强制直连",
+ "config_fields": [
+ {"name": "host", "label": "IMAP 服务器", "required": True, "placeholder": "imap.gmail.com"},
+ {"name": "port", "label": "端口", "required": False, "default": 993},
+ {"name": "use_ssl", "label": "使用 SSL", "required": False, "default": True},
+ {"name": "email", "label": "邮箱地址", "required": True},
+ {"name": "password", "label": "密码/授权码", "required": True, "secret": True},
+ ]
+ }
+ ]
+ }
+
+
+@router.get("", response_model=EmailServiceListResponse)
+async def list_email_services(
+ service_type: Optional[str] = Query(None, description="服务类型筛选"),
+ enabled_only: bool = Query(False, description="只显示启用的服务"),
+):
+ """获取邮箱服务列表"""
+ with get_db() as db:
+ query = db.query(EmailServiceModel)
+
+ if service_type:
+ query = query.filter(EmailServiceModel.service_type == service_type)
+
+ if enabled_only:
+ query = query.filter(EmailServiceModel.enabled == True)
+
+ services = query.order_by(EmailServiceModel.priority.asc(), EmailServiceModel.id.asc()).all()
+
+ return EmailServiceListResponse(
+ total=len(services),
+ services=[service_to_response(s) for s in services]
+ )
+
+
+@router.get("/{service_id}", response_model=EmailServiceResponse)
+async def get_email_service(service_id: int):
+ """获取单个邮箱服务详情"""
+ with get_db() as db:
+ service = db.query(EmailServiceModel).filter(EmailServiceModel.id == service_id).first()
+ if not service:
+ raise HTTPException(status_code=404, detail="服务不存在")
+ return service_to_response(service)
+
+
+@router.get("/{service_id}/full")
+async def get_email_service_full(service_id: int):
+ """获取单个邮箱服务完整详情(包含敏感字段,用于编辑)"""
+ with get_db() as db:
+ service = db.query(EmailServiceModel).filter(EmailServiceModel.id == service_id).first()
+ if not service:
+ raise HTTPException(status_code=404, detail="服务不存在")
+
+ return {
+ "id": service.id,
+ "service_type": service.service_type,
+ "name": service.name,
+ "enabled": service.enabled,
+ "priority": service.priority,
+ "config": service.config or {}, # 返回完整配置
+ "last_used": service.last_used.isoformat() if service.last_used else None,
+ "created_at": service.created_at.isoformat() if service.created_at else None,
+ "updated_at": service.updated_at.isoformat() if service.updated_at else None,
+ }
+
+
+@router.post("", response_model=EmailServiceResponse)
+async def create_email_service(request: EmailServiceCreate):
+ """创建邮箱服务配置"""
+ # 验证服务类型
+ try:
+ EmailServiceType(request.service_type)
+ except ValueError:
+ raise HTTPException(status_code=400, detail=f"无效的服务类型: {request.service_type}")
+
+ with get_db() as db:
+ # 检查名称是否重复
+ existing = db.query(EmailServiceModel).filter(EmailServiceModel.name == request.name).first()
+ if existing:
+ raise HTTPException(status_code=400, detail="服务名称已存在")
+
+ service = EmailServiceModel(
+ service_type=request.service_type,
+ name=request.name,
+ config=request.config,
+ enabled=request.enabled,
+ priority=request.priority
+ )
+ db.add(service)
+ db.commit()
+ db.refresh(service)
+
+ return service_to_response(service)
+
+
+@router.patch("/{service_id}", response_model=EmailServiceResponse)
+async def update_email_service(service_id: int, request: EmailServiceUpdate):
+ """更新邮箱服务配置"""
+ with get_db() as db:
+ service = db.query(EmailServiceModel).filter(EmailServiceModel.id == service_id).first()
+ if not service:
+ raise HTTPException(status_code=404, detail="服务不存在")
+
+ update_data = {}
+ if request.name is not None:
+ update_data["name"] = request.name
+ if request.config is not None:
+ # 合并配置而不是替换
+ current_config = service.config or {}
+ merged_config = {**current_config, **request.config}
+ # 移除空值
+ merged_config = {k: v for k, v in merged_config.items() if v}
+ update_data["config"] = merged_config
+ if request.enabled is not None:
+ update_data["enabled"] = request.enabled
+ if request.priority is not None:
+ update_data["priority"] = request.priority
+
+ for key, value in update_data.items():
+ setattr(service, key, value)
+
+ db.commit()
+ db.refresh(service)
+
+ return service_to_response(service)
+
+
+@router.delete("/{service_id}")
+async def delete_email_service(service_id: int):
+ """删除邮箱服务配置"""
+ with get_db() as db:
+ service = db.query(EmailServiceModel).filter(EmailServiceModel.id == service_id).first()
+ if not service:
+ raise HTTPException(status_code=404, detail="服务不存在")
+
+ db.delete(service)
+ db.commit()
+
+ return {"success": True, "message": f"服务 {service.name} 已删除"}
+
+
+@router.post("/{service_id}/test", response_model=ServiceTestResult)
+async def test_email_service(service_id: int):
+ """测试邮箱服务是否可用"""
+ with get_db() as db:
+ service = db.query(EmailServiceModel).filter(EmailServiceModel.id == service_id).first()
+ if not service:
+ raise HTTPException(status_code=404, detail="服务不存在")
+
+ try:
+ service_type = EmailServiceType(service.service_type)
+ email_service = EmailServiceFactory.create(service_type, service.config, name=service.name)
+
+ health = email_service.check_health()
+
+ if health:
+ return ServiceTestResult(
+ success=True,
+ message="服务连接正常",
+ details=email_service.get_service_info() if hasattr(email_service, 'get_service_info') else None
+ )
+ else:
+ return ServiceTestResult(
+ success=False,
+ message="服务连接失败"
+ )
+
+ except Exception as e:
+ logger.error(f"测试邮箱服务失败: {e}")
+ return ServiceTestResult(
+ success=False,
+ message=f"测试失败: {str(e)}"
+ )
+
+
+@router.post("/{service_id}/enable")
+async def enable_email_service(service_id: int):
+ """启用邮箱服务"""
+ with get_db() as db:
+ service = db.query(EmailServiceModel).filter(EmailServiceModel.id == service_id).first()
+ if not service:
+ raise HTTPException(status_code=404, detail="服务不存在")
+
+ service.enabled = True
+ db.commit()
+
+ return {"success": True, "message": f"服务 {service.name} 已启用"}
+
+
+@router.post("/{service_id}/disable")
+async def disable_email_service(service_id: int):
+ """禁用邮箱服务"""
+ with get_db() as db:
+ service = db.query(EmailServiceModel).filter(EmailServiceModel.id == service_id).first()
+ if not service:
+ raise HTTPException(status_code=404, detail="服务不存在")
+
+ service.enabled = False
+ db.commit()
+
+ return {"success": True, "message": f"服务 {service.name} 已禁用"}
+
+
+@router.post("/reorder")
+async def reorder_services(service_ids: List[int]):
+ """重新排序邮箱服务优先级"""
+ with get_db() as db:
+ for index, service_id in enumerate(service_ids):
+ service = db.query(EmailServiceModel).filter(EmailServiceModel.id == service_id).first()
+ if service:
+ service.priority = index
+
+ db.commit()
+
+ return {"success": True, "message": "优先级已更新"}
+
+
+@router.post("/outlook/batch-import", response_model=OutlookBatchImportResponse)
+async def batch_import_outlook(request: OutlookBatchImportRequest):
+ """
+ 批量导入 Outlook 邮箱账户
+
+ 支持两种格式:
+ - 格式一(密码认证):邮箱----密码
+ - 格式二(XOAUTH2 认证):邮箱----密码----client_id----refresh_token
+
+ 每行一个账户,使用四个连字符(----)分隔字段
+ """
+ lines = request.data.strip().split("\n")
+ total = len(lines)
+ success = 0
+ failed = 0
+ accounts = []
+ errors = []
+
+ with get_db() as db:
+ for i, line in enumerate(lines):
+ line = line.strip()
+
+ # 跳过空行和注释
+ if not line or line.startswith("#"):
+ continue
+
+ parts = line.split("----")
+
+ # 验证格式
+ if len(parts) < 2:
+ failed += 1
+ errors.append(f"行 {i+1}: 格式错误,至少需要邮箱和密码")
+ continue
+
+ email = parts[0].strip()
+ password = parts[1].strip()
+
+ # 验证邮箱格式
+ if "@" not in email:
+ failed += 1
+ errors.append(f"行 {i+1}: 无效的邮箱地址: {email}")
+ continue
+
+ # 检查是否已存在
+ existing = db.query(EmailServiceModel).filter(
+ EmailServiceModel.service_type == "outlook",
+ EmailServiceModel.name == email
+ ).first()
+
+ if existing:
+ failed += 1
+ errors.append(f"行 {i+1}: 邮箱已存在: {email}")
+ continue
+
+ # 构建配置
+ config = {
+ "email": email,
+ "password": password
+ }
+
+ # 检查是否有 OAuth 信息(格式二)
+ if len(parts) >= 4:
+ client_id = parts[2].strip()
+ refresh_token = parts[3].strip()
+ if client_id and refresh_token:
+ config["client_id"] = client_id
+ config["refresh_token"] = refresh_token
+
+ # 创建服务记录
+ try:
+ service = EmailServiceModel(
+ service_type="outlook",
+ name=email,
+ config=config,
+ enabled=request.enabled,
+ priority=request.priority
+ )
+ db.add(service)
+ db.commit()
+ db.refresh(service)
+
+ accounts.append({
+ "id": service.id,
+ "email": email,
+ "has_oauth": bool(config.get("client_id")),
+ "name": email
+ })
+ success += 1
+
+ except Exception as e:
+ failed += 1
+ errors.append(f"行 {i+1}: 创建失败: {str(e)}")
+ db.rollback()
+
+ return OutlookBatchImportResponse(
+ total=total,
+ success=success,
+ failed=failed,
+ accounts=accounts,
+ errors=errors
+ )
+
+
+@router.delete("/outlook/batch")
+async def batch_delete_outlook(service_ids: List[int]):
+ """批量删除 Outlook 邮箱服务"""
+ deleted = 0
+ with get_db() as db:
+ for service_id in service_ids:
+ service = db.query(EmailServiceModel).filter(
+ EmailServiceModel.id == service_id,
+ EmailServiceModel.service_type == "outlook"
+ ).first()
+ if service:
+ db.delete(service)
+ deleted += 1
+ db.commit()
+
+ return {"success": True, "deleted": deleted, "message": f"已删除 {deleted} 个服务"}
+
+
+# ============== 临时邮箱测试 ==============
+
+class TempmailTestRequest(BaseModel):
+ """临时邮箱测试请求"""
+ api_url: Optional[str] = None
+
+
+@router.post("/test-tempmail")
+async def test_tempmail_service(request: TempmailTestRequest):
+ """测试临时邮箱服务是否可用"""
+ try:
+ from ...services import EmailServiceFactory, EmailServiceType
+ from ...config.settings import get_settings
+
+ settings = get_settings()
+ base_url = request.api_url or settings.tempmail_base_url
+
+ config = {"base_url": base_url}
+ tempmail = EmailServiceFactory.create(EmailServiceType.TEMPMAIL, config)
+
+ # 检查服务健康状态
+ health = tempmail.check_health()
+
+ if health:
+ return {"success": True, "message": "临时邮箱连接正常"}
+ else:
+ return {"success": False, "message": "临时邮箱连接失败"}
+
+ except Exception as e:
+ logger.error(f"测试临时邮箱失败: {e}")
+ return {"success": False, "message": f"测试失败: {str(e)}"}
diff --git a/src/web/routes/payment.py b/src/web/routes/payment.py
new file mode 100644
index 0000000..ef9ff6b
--- /dev/null
+++ b/src/web/routes/payment.py
@@ -0,0 +1,182 @@
+"""
+支付相关 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 ...database import crud
+from ...config.settings import get_settings
+from .accounts import resolve_account_ids
+from ...core.openai.payment import (
+ generate_plus_link,
+ generate_team_link,
+ open_url_incognito,
+ check_subscription_status,
+)
+
+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
+
+
+# ============== 支付链接生成 ==============
+
+@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/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
+
+
+@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}
+
+
diff --git a/src/web/routes/registration.py b/src/web/routes/registration.py
new file mode 100644
index 0000000..7a08dd9
--- /dev/null
+++ b/src/web/routes/registration.py
@@ -0,0 +1,1549 @@
+"""
+注册任务 API 路由
+"""
+
+import asyncio
+import logging
+import uuid
+import random
+from datetime import datetime
+from typing import List, Optional, Dict, Tuple
+
+from fastapi import APIRouter, HTTPException, Query, BackgroundTasks
+from pydantic import BaseModel, Field
+
+from ...database import crud
+from ...database.session import get_db
+from ...database.models import RegistrationTask, Proxy
+from ...core.register import RegistrationEngine, RegistrationResult
+from ...config.settings import get_settings as _get_settings
+from ...services import EmailServiceFactory, EmailServiceType
+from ...config.settings import get_settings
+from ..task_manager import task_manager
+
+logger = logging.getLogger(__name__)
+router = APIRouter()
+
+# 任务存储(简单的内存存储,生产环境应使用 Redis)
+running_tasks: dict = {}
+# 批量任务存储
+batch_tasks: Dict[str, dict] = {}
+
+
+# ============== Proxy Helper Functions ==============
+
+def get_proxy_for_registration(db) -> Tuple[Optional[str], Optional[int]]:
+ """
+ 获取用于注册的代理
+
+ 策略:
+ 1. 优先从代理列表中随机选择一个启用的代理
+ 2. 如果代理列表为空且启用了动态代理,调用动态代理 API 获取
+ 3. 否则使用系统设置中的静态默认代理
+
+ Returns:
+ Tuple[proxy_url, proxy_id]: 代理 URL 和代理 ID(如果来自代理列表)
+ """
+ # 先尝试从代理列表中获取
+ proxy = crud.get_random_proxy(db)
+ if proxy:
+ return proxy.proxy_url, proxy.id
+
+ # 代理列表为空,尝试动态代理或静态代理
+ from ...core.dynamic_proxy import get_proxy_url_for_task
+ proxy_url = get_proxy_url_for_task()
+ if proxy_url:
+ return proxy_url, None
+
+ return None, None
+
+
+def update_proxy_usage(db, proxy_id: Optional[int]):
+ """更新代理的使用时间"""
+ if proxy_id:
+ crud.update_proxy_last_used(db, proxy_id)
+
+
+# ============== Pydantic Models ==============
+
+class RegistrationTaskCreate(BaseModel):
+ """创建注册任务请求"""
+ email_service_type: str = "tempmail"
+ proxy: Optional[str] = None
+ email_service_config: Optional[dict] = None
+ email_service_id: Optional[int] = None
+ auto_upload_cpa: bool = False
+ cpa_service_ids: List[int] = [] # 指定 CPA 服务 ID 列表,空则取第一个启用的
+ auto_upload_sub2api: bool = False
+ sub2api_service_ids: List[int] = [] # 指定 Sub2API 服务 ID 列表
+ sub2api_group_ids: List[int] = [] # Sub2API 分组 ID 列表
+ sub2api_proxy_id: Optional[int] = None # Sub2API 代理节点 ID
+ sub2api_model_mapping: Optional[Dict[str, str]] = None # Sub2API 自定义模型映射
+ auto_upload_tm: bool = False
+ tm_service_ids: List[int] = [] # 指定 TM 服务 ID 列表
+
+
+class BatchRegistrationRequest(BaseModel):
+ """批量注册请求"""
+ count: int = 1
+ email_service_type: str = "tempmail"
+ proxy: Optional[str] = None
+ email_service_config: Optional[dict] = None
+ email_service_id: Optional[int] = None
+ interval_min: int = 5
+ interval_max: int = 30
+ concurrency: int = 1
+ mode: str = "pipeline"
+ auto_upload_cpa: bool = False
+ cpa_service_ids: List[int] = []
+ auto_upload_sub2api: bool = False
+ sub2api_service_ids: List[int] = []
+ sub2api_group_ids: List[int] = []
+ sub2api_proxy_id: Optional[int] = None
+ sub2api_model_mapping: Optional[Dict[str, str]] = None
+ auto_upload_tm: bool = False
+ tm_service_ids: List[int] = []
+
+
+class RegistrationTaskResponse(BaseModel):
+ """注册任务响应"""
+ id: int
+ task_uuid: str
+ status: str
+ email_service_id: Optional[int] = None
+ proxy: Optional[str] = None
+ logs: Optional[str] = None
+ result: Optional[dict] = None
+ error_message: Optional[str] = None
+ created_at: Optional[str] = None
+ started_at: Optional[str] = None
+ completed_at: Optional[str] = None
+
+ class Config:
+ from_attributes = True
+
+
+class BatchRegistrationResponse(BaseModel):
+ """批量注册响应"""
+ batch_id: str
+ count: int
+ tasks: List[RegistrationTaskResponse]
+
+
+class TaskListResponse(BaseModel):
+ """任务列表响应"""
+ total: int
+ tasks: List[RegistrationTaskResponse]
+
+
+# ============== Outlook 批量注册模型 ==============
+
+class OutlookAccountForRegistration(BaseModel):
+ """可用于注册的 Outlook 账户"""
+ id: int # EmailService 表的 ID
+ email: str
+ name: str
+ has_oauth: bool # 是否有 OAuth 配置
+ is_registered: bool # 是否已注册
+ registered_account_id: Optional[int] = None
+
+
+class OutlookAccountsListResponse(BaseModel):
+ """Outlook 账户列表响应"""
+ total: int
+ registered_count: int # 已注册数量
+ unregistered_count: int # 未注册数量
+ accounts: List[OutlookAccountForRegistration]
+
+
+class OutlookBatchRegistrationRequest(BaseModel):
+ """Outlook 批量注册请求"""
+ service_ids: List[int]
+ skip_registered: bool = True
+ proxy: Optional[str] = None
+ interval_min: int = 5
+ interval_max: int = 30
+ concurrency: int = 1
+ mode: str = "pipeline"
+ auto_upload_cpa: bool = False
+ cpa_service_ids: List[int] = []
+ auto_upload_sub2api: bool = False
+ sub2api_service_ids: List[int] = []
+ sub2api_group_ids: List[int] = []
+ sub2api_proxy_id: Optional[int] = None
+ sub2api_model_mapping: Optional[Dict[str, str]] = None
+ auto_upload_tm: bool = False
+ tm_service_ids: List[int] = []
+
+
+class OutlookBatchRegistrationResponse(BaseModel):
+ """Outlook 批量注册响应"""
+ batch_id: str
+ total: int # 总数
+ skipped: int # 跳过数(已注册)
+ to_register: int # 待注册数
+ service_ids: List[int] # 实际要注册的服务 ID
+
+
+# ============== Helper Functions ==============
+
+def task_to_response(task: RegistrationTask) -> RegistrationTaskResponse:
+ """转换任务模型为响应"""
+ return RegistrationTaskResponse(
+ id=task.id,
+ task_uuid=task.task_uuid,
+ status=task.status,
+ email_service_id=task.email_service_id,
+ proxy=task.proxy,
+ logs=task.logs,
+ result=task.result,
+ error_message=task.error_message,
+ created_at=task.created_at.isoformat() if task.created_at else None,
+ started_at=task.started_at.isoformat() if task.started_at else None,
+ completed_at=task.completed_at.isoformat() if task.completed_at else None,
+ )
+
+
+def _normalize_email_service_config(
+ service_type: EmailServiceType,
+ config: Optional[dict],
+ proxy_url: Optional[str] = None
+) -> dict:
+ """按服务类型兼容旧字段名,避免不同服务的配置键互相污染。"""
+ normalized = config.copy() if config else {}
+
+ if 'api_url' in normalized and 'base_url' not in normalized:
+ normalized['base_url'] = normalized.pop('api_url')
+
+ if service_type == EmailServiceType.MOE_MAIL:
+ if 'domain' in normalized and 'default_domain' not in normalized:
+ normalized['default_domain'] = normalized.pop('domain')
+ elif service_type in (EmailServiceType.TEMP_MAIL, EmailServiceType.FREEMAIL):
+ if 'default_domain' in normalized and 'domain' not in normalized:
+ normalized['domain'] = normalized.pop('default_domain')
+ elif service_type == EmailServiceType.DUCK_MAIL:
+ if 'domain' in normalized and 'default_domain' not in normalized:
+ normalized['default_domain'] = normalized.pop('domain')
+
+ if proxy_url and 'proxy_url' not in normalized:
+ normalized['proxy_url'] = proxy_url
+
+ return normalized
+
+
+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, cpa_service_ids: List[int] = None, auto_upload_sub2api: bool = False, sub2api_service_ids: List[int] = None, sub2api_group_ids: List[int] = None, sub2api_proxy_id: int = None, sub2api_model_mapping: dict = None, auto_upload_tm: bool = False, tm_service_ids: List[int] = None):
+ """
+ 在线程池中执行的同步注册任务
+
+ 这个函数会被 run_in_executor 调用,运行在独立线程中
+ """
+ with get_db() as db:
+ try:
+ # 检查是否已取消
+ if task_manager.is_cancelled(task_uuid):
+ logger.info(f"任务 {task_uuid} 已取消,跳过执行")
+ return
+
+ # 更新任务状态为运行中
+ task = crud.update_registration_task(
+ db, task_uuid,
+ status="running",
+ started_at=datetime.utcnow()
+ )
+
+ if not task:
+ logger.error(f"任务不存在: {task_uuid}")
+ return
+
+ # 更新 TaskManager 状态
+ task_manager.update_status(task_uuid, "running")
+
+ # 确定使用的代理
+ # 如果前端传入了代理参数,使用传入的
+ # 否则从代理列表或系统设置中获取
+ actual_proxy_url = proxy
+ proxy_id = None
+
+ if not actual_proxy_url:
+ actual_proxy_url, proxy_id = get_proxy_for_registration(db)
+ if actual_proxy_url:
+ logger.info(f"任务 {task_uuid} 使用代理: {actual_proxy_url[:50]}...")
+
+ # 更新任务的代理记录
+ crud.update_registration_task(db, task_uuid, proxy=actual_proxy_url)
+
+ # 创建邮箱服务
+ service_type = EmailServiceType(email_service_type)
+ settings = get_settings()
+
+ # 优先使用数据库中配置的邮箱服务
+ if email_service_id:
+ from ...database.models import EmailService as EmailServiceModel
+ db_service = db.query(EmailServiceModel).filter(
+ EmailServiceModel.id == email_service_id,
+ EmailServiceModel.enabled == True
+ ).first()
+
+ if db_service:
+ service_type = EmailServiceType(db_service.service_type)
+ config = _normalize_email_service_config(service_type, db_service.config, actual_proxy_url)
+ # 更新任务关联的邮箱服务
+ crud.update_registration_task(db, task_uuid, email_service_id=db_service.id)
+ logger.info(f"使用数据库邮箱服务: {db_service.name} (ID: {db_service.id}, 类型: {service_type.value})")
+ else:
+ raise ValueError(f"邮箱服务不存在或已禁用: {email_service_id}")
+ else:
+ # 使用默认配置或传入的配置
+ if service_type == EmailServiceType.TEMPMAIL:
+ config = {
+ "base_url": settings.tempmail_base_url,
+ "timeout": settings.tempmail_timeout,
+ "max_retries": settings.tempmail_max_retries,
+ "proxy_url": actual_proxy_url,
+ }
+ elif service_type == EmailServiceType.MOE_MAIL:
+ # 检查数据库中是否有可用的自定义域名服务
+ from ...database.models import EmailService as EmailServiceModel
+ db_service = db.query(EmailServiceModel).filter(
+ EmailServiceModel.service_type == "moe_mail",
+ EmailServiceModel.enabled == True
+ ).order_by(EmailServiceModel.priority.asc()).first()
+
+ if db_service and db_service.config:
+ config = _normalize_email_service_config(service_type, db_service.config, actual_proxy_url)
+ crud.update_registration_task(db, task_uuid, email_service_id=db_service.id)
+ logger.info(f"使用数据库自定义域名服务: {db_service.name}")
+ elif settings.custom_domain_base_url and settings.custom_domain_api_key:
+ config = {
+ "base_url": settings.custom_domain_base_url,
+ "api_key": settings.custom_domain_api_key.get_secret_value() if settings.custom_domain_api_key else "",
+ "proxy_url": actual_proxy_url,
+ }
+ else:
+ raise ValueError("没有可用的自定义域名邮箱服务,请先在设置中配置")
+ elif service_type == EmailServiceType.OUTLOOK:
+ # 检查数据库中是否有可用的 Outlook 账户
+ from ...database.models import EmailService as EmailServiceModel, Account
+ # 获取所有启用的 Outlook 服务
+ outlook_services = db.query(EmailServiceModel).filter(
+ EmailServiceModel.service_type == "outlook",
+ EmailServiceModel.enabled == True
+ ).order_by(EmailServiceModel.priority.asc()).all()
+
+ if not outlook_services:
+ raise ValueError("没有可用的 Outlook 账户,请先在设置中导入账户")
+
+ # 找到一个未注册的 Outlook 账户
+ selected_service = None
+ for svc in outlook_services:
+ email = svc.config.get("email") if svc.config else None
+ if not email:
+ continue
+ # 检查是否已在 accounts 表中注册
+ existing = db.query(Account).filter(Account.email == email).first()
+ if not existing:
+ selected_service = svc
+ logger.info(f"选择未注册的 Outlook 账户: {email}")
+ break
+ else:
+ logger.info(f"跳过已注册的 Outlook 账户: {email}")
+
+ if selected_service and selected_service.config:
+ config = selected_service.config.copy()
+ crud.update_registration_task(db, task_uuid, email_service_id=selected_service.id)
+ logger.info(f"使用数据库 Outlook 账户: {selected_service.name}")
+ else:
+ raise ValueError("所有 Outlook 账户都已注册过 OpenAI 账号,请添加新的 Outlook 账户")
+ elif service_type == EmailServiceType.DUCK_MAIL:
+ from ...database.models import EmailService as EmailServiceModel
+
+ db_service = db.query(EmailServiceModel).filter(
+ EmailServiceModel.service_type == "duck_mail",
+ EmailServiceModel.enabled == True
+ ).order_by(EmailServiceModel.priority.asc()).first()
+
+ if db_service and db_service.config:
+ config = _normalize_email_service_config(service_type, db_service.config, actual_proxy_url)
+ crud.update_registration_task(db, task_uuid, email_service_id=db_service.id)
+ logger.info(f"使用数据库 DuckMail 服务: {db_service.name}")
+ else:
+ raise ValueError("没有可用的 DuckMail 邮箱服务,请先在邮箱服务页面添加服务")
+ elif service_type == EmailServiceType.FREEMAIL:
+ from ...database.models import EmailService as EmailServiceModel
+
+ db_service = db.query(EmailServiceModel).filter(
+ EmailServiceModel.service_type == "freemail",
+ EmailServiceModel.enabled == True
+ ).order_by(EmailServiceModel.priority.asc()).first()
+
+ if db_service and db_service.config:
+ config = _normalize_email_service_config(service_type, db_service.config, actual_proxy_url)
+ crud.update_registration_task(db, task_uuid, email_service_id=db_service.id)
+ logger.info(f"使用数据库 Freemail 服务: {db_service.name}")
+ else:
+ raise ValueError("没有可用的 Freemail 邮箱服务,请先在邮箱服务页面添加服务")
+ elif service_type == EmailServiceType.IMAP_MAIL:
+ from ...database.models import EmailService as EmailServiceModel
+
+ db_service = db.query(EmailServiceModel).filter(
+ EmailServiceModel.service_type == "imap_mail",
+ EmailServiceModel.enabled == True
+ ).order_by(EmailServiceModel.priority.asc()).first()
+
+ if db_service and db_service.config:
+ config = _normalize_email_service_config(service_type, db_service.config, actual_proxy_url)
+ crud.update_registration_task(db, task_uuid, email_service_id=db_service.id)
+ logger.info(f"使用数据库 IMAP 邮箱服务: {db_service.name}")
+ else:
+ raise ValueError("没有可用的 IMAP 邮箱服务,请先在邮箱服务中添加")
+ else:
+ config = email_service_config or {}
+
+ email_service = EmailServiceFactory.create(service_type, config)
+
+ # 创建注册引擎 - 使用 TaskManager 的日志回调
+ log_callback = task_manager.create_log_callback(task_uuid, prefix=log_prefix, batch_id=batch_id)
+
+ engine = RegistrationEngine(
+ email_service=email_service,
+ proxy_url=actual_proxy_url,
+ callback_logger=log_callback,
+ task_uuid=task_uuid
+ )
+
+ # 执行注册
+ result = engine.run()
+
+ if result.success:
+ # 更新代理使用时间
+ update_proxy_usage(db, proxy_id)
+
+ # 保存到数据库
+ engine.save_to_database(result)
+
+ # 自动上传到 CPA(可多服务)
+ if auto_upload_cpa:
+ try:
+ from ...core.upload.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:
+ _cpa_ids = cpa_service_ids or []
+ if not _cpa_ids:
+ # 未指定则取所有启用的服务
+ _cpa_ids = [s.id for s in crud.get_cpa_services(db, enabled=True)]
+ if not _cpa_ids:
+ log_callback("[CPA] 无可用 CPA 服务,跳过上传")
+ for _sid in _cpa_ids:
+ try:
+ _svc = crud.get_cpa_service_by_id(db, _sid)
+ if not _svc:
+ continue
+ token_data = generate_token_json(
+ saved_account,
+ include_proxy_url=bool(_svc.include_proxy_url),
+ )
+ log_callback(f"[CPA] 上传到服务: {_svc.name}")
+ _ok, _msg = upload_to_cpa(token_data, api_url=_svc.api_url, api_token=_svc.api_token)
+ if _ok:
+ saved_account.cpa_uploaded = True
+ saved_account.cpa_uploaded_at = datetime.utcnow()
+ db.commit()
+ log_callback(f"[CPA] 上传成功: {_svc.name}")
+ else:
+ log_callback(f"[CPA] 上传失败({_svc.name}): {_msg}")
+ except Exception as _e:
+ log_callback(f"[CPA] 异常({_sid}): {_e}")
+ except Exception as cpa_err:
+ log_callback(f"[CPA] 上传异常: {cpa_err}")
+
+ # 自动上传到 Sub2API(可多服务)
+ if auto_upload_sub2api:
+ try:
+ from ...core.upload.sub2api_upload import upload_to_sub2api
+ 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:
+ _s2a_ids = sub2api_service_ids or []
+ if not _s2a_ids:
+ _s2a_ids = [s.id for s in crud.get_sub2api_services(db, enabled=True)]
+ if not _s2a_ids:
+ log_callback("[Sub2API] 无可用 Sub2API 服务,跳过上传")
+ for _sid in _s2a_ids:
+ try:
+ _svc = crud.get_sub2api_service_by_id(db, _sid)
+ if not _svc:
+ continue
+ log_callback(f"[Sub2API] 上传到服务: {_svc.name}")
+ _ok, _msg = upload_to_sub2api([saved_account], _svc.api_url, _svc.api_key, group_ids=sub2api_group_ids, proxy_id=sub2api_proxy_id, model_mapping=sub2api_model_mapping)
+ log_callback(f"[Sub2API] {'成功' if _ok else '失败'}({_svc.name}): {_msg}")
+ except Exception as _e:
+ log_callback(f"[Sub2API] 异常({_sid}): {_e}")
+ except Exception as s2a_err:
+ log_callback(f"[Sub2API] 上传异常: {s2a_err}")
+
+ # 自动上传到 Team Manager(可多服务)
+ if auto_upload_tm:
+ try:
+ from ...core.upload.team_manager_upload import upload_to_team_manager
+ 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:
+ _tm_ids = tm_service_ids or []
+ if not _tm_ids:
+ _tm_ids = [s.id for s in crud.get_tm_services(db, enabled=True)]
+ if not _tm_ids:
+ log_callback("[TM] 无可用 Team Manager 服务,跳过上传")
+ for _sid in _tm_ids:
+ try:
+ _svc = crud.get_tm_service_by_id(db, _sid)
+ if not _svc:
+ continue
+ log_callback(f"[TM] 上传到服务: {_svc.name}")
+ _ok, _msg = upload_to_team_manager(saved_account, _svc.api_url, _svc.api_key)
+ log_callback(f"[TM] {'成功' if _ok else '失败'}({_svc.name}): {_msg}")
+ except Exception as _e:
+ log_callback(f"[TM] 异常({_sid}): {_e}")
+ except Exception as tm_err:
+ log_callback(f"[TM] 上传异常: {tm_err}")
+
+ # 更新任务状态
+ crud.update_registration_task(
+ db, task_uuid,
+ status="completed",
+ completed_at=datetime.utcnow(),
+ result=result.to_dict()
+ )
+
+ # 更新 TaskManager 状态
+ task_manager.update_status(task_uuid, "completed", email=result.email)
+
+ logger.info(f"注册任务完成: {task_uuid}, 邮箱: {result.email}")
+ else:
+ # 更新任务状态为失败
+ crud.update_registration_task(
+ db, task_uuid,
+ status="failed",
+ completed_at=datetime.utcnow(),
+ error_message=result.error_message
+ )
+
+ # 更新 TaskManager 状态
+ task_manager.update_status(task_uuid, "failed", error=result.error_message)
+
+ logger.warning(f"注册任务失败: {task_uuid}, 原因: {result.error_message}")
+
+ except Exception as e:
+ logger.error(f"注册任务异常: {task_uuid}, 错误: {e}")
+
+ try:
+ with get_db() as db:
+ crud.update_registration_task(
+ db, task_uuid,
+ status="failed",
+ completed_at=datetime.utcnow(),
+ error_message=str(e)
+ )
+
+ # 更新 TaskManager 状态
+ task_manager.update_status(task_uuid, "failed", error=str(e))
+ except:
+ 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 = "", auto_upload_cpa: bool = False, cpa_service_ids: List[int] = None, auto_upload_sub2api: bool = False, sub2api_service_ids: List[int] = None, sub2api_group_ids: List[int] = None, sub2api_proxy_id: int = None, sub2api_model_mapping: dict = None, auto_upload_tm: bool = False, tm_service_ids: List[int] = None):
+ """
+ 异步执行注册任务
+
+ 使用 run_in_executor 将同步任务放入线程池执行,避免阻塞主事件循环
+ """
+ loop = task_manager.get_loop()
+ if loop is None:
+ loop = asyncio.get_event_loop()
+ task_manager.set_loop(loop)
+
+ # 初始化 TaskManager 状态
+ task_manager.update_status(task_uuid, "pending")
+ task_manager.add_log(task_uuid, f"{log_prefix} [系统] 任务 {task_uuid[:8]} 已加入队列" if log_prefix else f"[系统] 任务 {task_uuid[:8]} 已加入队列")
+
+ try:
+ # 在线程池中执行同步任务(传入 log_prefix 和 batch_id 供回调使用)
+ await loop.run_in_executor(
+ task_manager.executor,
+ _run_sync_registration_task,
+ task_uuid,
+ email_service_type,
+ proxy,
+ email_service_config,
+ email_service_id,
+ log_prefix,
+ batch_id,
+ auto_upload_cpa,
+ cpa_service_ids or [],
+ auto_upload_sub2api,
+ sub2api_service_ids or [],
+ auto_upload_tm,
+ tm_service_ids or [],
+ )
+ except Exception as e:
+ logger.error(f"线程池执行异常: {task_uuid}, 错误: {e}")
+ task_manager.add_log(task_uuid, f"[错误] 线程池执行异常: {str(e)}")
+ task_manager.update_status(task_uuid, "failed", error=str(e))
+
+
+def _init_batch_state(batch_id: str, task_uuids: List[str]):
+ """初始化批量任务内存状态"""
+ task_manager.init_batch(batch_id, len(task_uuids))
+ batch_tasks[batch_id] = {
+ "total": len(task_uuids),
+ "completed": 0,
+ "success": 0,
+ "failed": 0,
+ "cancelled": False,
+ "task_uuids": task_uuids,
+ "current_index": 0,
+ "logs": [],
+ "finished": False
+ }
+
+
+def _make_batch_helpers(batch_id: str):
+ """返回 add_batch_log 和 update_batch_status 辅助函数"""
+ def add_batch_log(msg: str):
+ batch_tasks[batch_id]["logs"].append(msg)
+ task_manager.add_batch_log(batch_id, msg)
+
+ def update_batch_status(**kwargs):
+ for key, value in kwargs.items():
+ if key in batch_tasks[batch_id]:
+ batch_tasks[batch_id][key] = value
+ task_manager.update_batch_status(batch_id, **kwargs)
+
+ return add_batch_log, update_batch_status
+
+
+async def run_batch_parallel(
+ batch_id: str,
+ task_uuids: List[str],
+ email_service_type: str,
+ proxy: Optional[str],
+ email_service_config: Optional[dict],
+ email_service_id: Optional[int],
+ concurrency: int,
+ auto_upload_cpa: bool = False,
+ cpa_service_ids: List[int] = None,
+ auto_upload_sub2api: bool = False,
+ sub2api_service_ids: List[int] = None,
+ auto_upload_tm: bool = False,
+ tm_service_ids: List[int] = None,
+):
+ """
+ 并行模式:所有任务同时提交,Semaphore 控制最大并发数
+ """
+ _init_batch_state(batch_id, task_uuids)
+ add_batch_log, update_batch_status = _make_batch_helpers(batch_id)
+ semaphore = asyncio.Semaphore(concurrency)
+ counter_lock = asyncio.Lock()
+ add_batch_log(f"[系统] 并行模式启动,并发数: {concurrency},总任务: {len(task_uuids)}")
+
+ async def _run_one(idx: int, uuid: str):
+ prefix = f"[任务{idx + 1}]"
+ 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,
+ auto_upload_cpa=auto_upload_cpa, cpa_service_ids=cpa_service_ids or [],
+ auto_upload_sub2api=auto_upload_sub2api, sub2api_service_ids=sub2api_service_ids or [], sub2api_group_ids=sub2api_group_ids, sub2api_proxy_id=sub2api_proxy_id, sub2api_model_mapping=sub2api_model_mapping,
+ auto_upload_tm=auto_upload_tm, tm_service_ids=tm_service_ids or [],
+ )
+ with get_db() as db:
+ t = crud.get_registration_task(db, uuid)
+ if t:
+ async with counter_lock:
+ new_completed = batch_tasks[batch_id]["completed"] + 1
+ new_success = batch_tasks[batch_id]["success"]
+ new_failed = batch_tasks[batch_id]["failed"]
+ if t.status == "completed":
+ new_success += 1
+ add_batch_log(f"{prefix} [成功] 注册成功")
+ elif t.status == "failed":
+ new_failed += 1
+ add_batch_log(f"{prefix} [失败] 注册失败: {t.error_message}")
+ update_batch_status(completed=new_completed, success=new_success, failed=new_failed)
+
+ try:
+ await asyncio.gather(*[_run_one(i, u) for i, u in enumerate(task_uuids)], return_exceptions=True)
+ if not task_manager.is_batch_cancelled(batch_id):
+ add_batch_log(f"[完成] 批量任务完成!成功: {batch_tasks[batch_id]['success']}, 失败: {batch_tasks[batch_id]['failed']}")
+ update_batch_status(finished=True, status="completed")
+ else:
+ update_batch_status(finished=True, status="cancelled")
+ except Exception as e:
+ logger.error(f"批量任务 {batch_id} 异常: {e}")
+ add_batch_log(f"[错误] 批量任务异常: {str(e)}")
+ update_batch_status(finished=True, status="failed")
+ finally:
+ batch_tasks[batch_id]["finished"] = True
+
+
+async def run_batch_pipeline(
+ batch_id: str,
+ task_uuids: List[str],
+ email_service_type: str,
+ proxy: Optional[str],
+ email_service_config: Optional[dict],
+ email_service_id: Optional[int],
+ interval_min: int,
+ interval_max: int,
+ concurrency: int,
+ auto_upload_cpa: bool = False,
+ cpa_service_ids: List[int] = None,
+ auto_upload_sub2api: bool = False,
+ sub2api_service_ids: List[int] = None,
+ auto_upload_tm: bool = False,
+ tm_service_ids: List[int] = None,
+):
+ """
+ 流水线模式:每隔 interval 秒启动一个新任务,Semaphore 限制最大并发数
+ """
+ _init_batch_state(batch_id, task_uuids)
+ add_batch_log, update_batch_status = _make_batch_helpers(batch_id)
+ semaphore = asyncio.Semaphore(concurrency)
+ counter_lock = asyncio.Lock()
+ running_tasks_list = []
+ add_batch_log(f"[系统] 流水线模式启动,并发数: {concurrency},总任务: {len(task_uuids)}")
+
+ async def _run_and_release(idx: int, uuid: str, pfx: str):
+ try:
+ await run_registration_task(
+ uuid, email_service_type, proxy, email_service_config, email_service_id,
+ log_prefix=pfx, batch_id=batch_id,
+ auto_upload_cpa=auto_upload_cpa, cpa_service_ids=cpa_service_ids or [],
+ auto_upload_sub2api=auto_upload_sub2api, sub2api_service_ids=sub2api_service_ids or [], sub2api_group_ids=sub2api_group_ids, sub2api_proxy_id=sub2api_proxy_id, sub2api_model_mapping=sub2api_model_mapping,
+ auto_upload_tm=auto_upload_tm, tm_service_ids=tm_service_ids or [],
+ )
+ with get_db() as db:
+ t = crud.get_registration_task(db, uuid)
+ if t:
+ async with counter_lock:
+ new_completed = batch_tasks[batch_id]["completed"] + 1
+ new_success = batch_tasks[batch_id]["success"]
+ new_failed = batch_tasks[batch_id]["failed"]
+ if t.status == "completed":
+ new_success += 1
+ add_batch_log(f"{pfx} [成功] 注册成功")
+ elif t.status == "failed":
+ new_failed += 1
+ add_batch_log(f"{pfx} [失败] 注册失败: {t.error_message}")
+ update_batch_status(completed=new_completed, success=new_success, failed=new_failed)
+ finally:
+ semaphore.release()
+
+ try:
+ for i, task_uuid in enumerate(task_uuids):
+ if task_manager.is_batch_cancelled(batch_id) or batch_tasks[batch_id]["cancelled"]:
+ with get_db() as db:
+ for remaining_uuid in task_uuids[i:]:
+ crud.update_registration_task(db, remaining_uuid, status="cancelled")
+ add_batch_log("[取消] 批量任务已取消")
+ update_batch_status(finished=True, status="cancelled")
+ break
+
+ update_batch_status(current_index=i)
+ await semaphore.acquire()
+ prefix = f"[任务{i + 1}]"
+ add_batch_log(f"{prefix} 开始注册...")
+ t = asyncio.create_task(_run_and_release(i, task_uuid, prefix))
+ running_tasks_list.append(t)
+
+ if i < len(task_uuids) - 1 and not task_manager.is_batch_cancelled(batch_id):
+ wait_time = random.randint(interval_min, interval_max)
+ logger.info(f"批量任务 {batch_id}: 等待 {wait_time} 秒后启动下一个任务")
+ await asyncio.sleep(wait_time)
+
+ if running_tasks_list:
+ await asyncio.gather(*running_tasks_list, return_exceptions=True)
+
+ if not task_manager.is_batch_cancelled(batch_id):
+ add_batch_log(f"[完成] 批量任务完成!成功: {batch_tasks[batch_id]['success']}, 失败: {batch_tasks[batch_id]['failed']}")
+ update_batch_status(finished=True, status="completed")
+ except Exception as e:
+ logger.error(f"批量任务 {batch_id} 异常: {e}")
+ add_batch_log(f"[错误] 批量任务异常: {str(e)}")
+ update_batch_status(finished=True, status="failed")
+ finally:
+ batch_tasks[batch_id]["finished"] = True
+
+
+async def run_batch_registration(
+ batch_id: str,
+ task_uuids: List[str],
+ email_service_type: str,
+ proxy: Optional[str],
+ email_service_config: Optional[dict],
+ email_service_id: Optional[int],
+ interval_min: int,
+ interval_max: int,
+ concurrency: int = 1,
+ mode: str = "pipeline",
+ auto_upload_cpa: bool = False,
+ cpa_service_ids: List[int] = None,
+ auto_upload_sub2api: bool = False,
+ sub2api_service_ids: List[int] = None,
+ auto_upload_tm: bool = False,
+ tm_service_ids: List[int] = None,
+):
+ """根据 mode 分发到并行或流水线执行"""
+ if mode == "parallel":
+ await run_batch_parallel(
+ batch_id, task_uuids, email_service_type, proxy,
+ email_service_config, email_service_id, concurrency,
+ auto_upload_cpa=auto_upload_cpa, cpa_service_ids=cpa_service_ids,
+ auto_upload_sub2api=auto_upload_sub2api, sub2api_service_ids=sub2api_service_ids or [], sub2api_group_ids=sub2api_group_ids, sub2api_proxy_id=sub2api_proxy_id, sub2api_model_mapping=sub2api_model_mapping,
+ auto_upload_tm=auto_upload_tm, tm_service_ids=tm_service_ids,
+ )
+ else:
+ await run_batch_pipeline(
+ batch_id, task_uuids, email_service_type, proxy,
+ email_service_config, email_service_id,
+ interval_min, interval_max, concurrency,
+ auto_upload_cpa=auto_upload_cpa, cpa_service_ids=cpa_service_ids,
+ auto_upload_sub2api=auto_upload_sub2api, sub2api_service_ids=sub2api_service_ids or [], sub2api_group_ids=sub2api_group_ids, sub2api_proxy_id=sub2api_proxy_id, sub2api_model_mapping=sub2api_model_mapping,
+ auto_upload_tm=auto_upload_tm, tm_service_ids=tm_service_ids,
+ )
+
+
+# ============== API Endpoints ==============
+
+@router.post("/start", response_model=RegistrationTaskResponse)
+async def start_registration(
+ request: RegistrationTaskCreate,
+ background_tasks: BackgroundTasks
+):
+ """
+ 启动注册任务
+
+ - email_service_type: 邮箱服务类型 (tempmail, outlook, moe_mail)
+ - proxy: 代理地址
+ - email_service_config: 邮箱服务配置(outlook 需要提供账户信息)
+ """
+ # 验证邮箱服务类型
+ try:
+ EmailServiceType(request.email_service_type)
+ except ValueError:
+ raise HTTPException(
+ status_code=400,
+ detail=f"无效的邮箱服务类型: {request.email_service_type}"
+ )
+
+ # 创建任务
+ task_uuid = str(uuid.uuid4())
+
+ with get_db() as db:
+ task = crud.create_registration_task(
+ db,
+ task_uuid=task_uuid,
+ proxy=request.proxy
+ )
+
+ # 在后台运行注册任务
+ background_tasks.add_task(
+ run_registration_task,
+ task_uuid,
+ request.email_service_type,
+ request.proxy,
+ request.email_service_config,
+ request.email_service_id,
+ "",
+ "",
+ request.auto_upload_cpa,
+ request.cpa_service_ids,
+ request.auto_upload_sub2api,
+ request.sub2api_service_ids,
+ request.sub2api_group_ids if hasattr(request, "sub2api_group_ids") else [],
+ request.sub2api_proxy_id if hasattr(request, "sub2api_proxy_id") else None,
+ request.sub2api_model_mapping if hasattr(request, "sub2api_model_mapping") else None,
+ request.auto_upload_tm,
+ request.tm_service_ids,
+ )
+
+ return task_to_response(task)
+
+
+@router.post("/batch", response_model=BatchRegistrationResponse)
+async def start_batch_registration(
+ request: BatchRegistrationRequest,
+ background_tasks: BackgroundTasks
+):
+ """
+ 启动批量注册任务
+
+ - count: 注册数量 (1-100)
+ - email_service_type: 邮箱服务类型
+ - proxy: 代理地址
+ - interval_min: 最小间隔秒数
+ - interval_max: 最大间隔秒数
+ """
+ # 验证参数
+ if request.count < 1 or request.count > 100:
+ raise HTTPException(status_code=400, detail="注册数量必须在 1-100 之间")
+
+ try:
+ EmailServiceType(request.email_service_type)
+ except ValueError:
+ raise HTTPException(
+ status_code=400,
+ detail=f"无效的邮箱服务类型: {request.email_service_type}"
+ )
+
+ if request.interval_min < 0 or request.interval_max < request.interval_min:
+ raise HTTPException(status_code=400, detail="间隔时间参数无效")
+
+ if not 1 <= request.concurrency <= 50:
+ raise HTTPException(status_code=400, detail="并发数必须在 1-50 之间")
+
+ if request.mode not in ("parallel", "pipeline"):
+ raise HTTPException(status_code=400, detail="模式必须为 parallel 或 pipeline")
+
+ # 创建批量任务
+ batch_id = str(uuid.uuid4())
+ task_uuids = []
+
+ with get_db() as db:
+ for _ in range(request.count):
+ task_uuid = str(uuid.uuid4())
+ task = crud.create_registration_task(
+ db,
+ task_uuid=task_uuid,
+ proxy=request.proxy
+ )
+ task_uuids.append(task_uuid)
+
+ # 获取所有任务
+ with get_db() as db:
+ tasks = [crud.get_registration_task(db, uuid) for uuid in task_uuids]
+
+ # 在后台运行批量注册
+ background_tasks.add_task(
+ run_batch_registration,
+ batch_id,
+ task_uuids,
+ request.email_service_type,
+ request.proxy,
+ request.email_service_config,
+ request.email_service_id,
+ request.interval_min,
+ request.interval_max,
+ request.concurrency,
+ request.mode,
+ request.auto_upload_cpa,
+ request.cpa_service_ids,
+ request.auto_upload_sub2api,
+ request.sub2api_service_ids,
+ request.sub2api_group_ids if hasattr(request, "sub2api_group_ids") else [],
+ request.sub2api_proxy_id if hasattr(request, "sub2api_proxy_id") else None,
+ request.sub2api_model_mapping if hasattr(request, "sub2api_model_mapping") else None,
+ request.auto_upload_tm,
+ request.tm_service_ids,
+ )
+
+ return BatchRegistrationResponse(
+ batch_id=batch_id,
+ count=request.count,
+ tasks=[task_to_response(t) for t in tasks if t]
+ )
+
+
+@router.get("/batch/{batch_id}")
+async def get_batch_status(batch_id: str):
+ """获取批量任务状态"""
+ if batch_id not in batch_tasks:
+ raise HTTPException(status_code=404, detail="批量任务不存在")
+
+ batch = batch_tasks[batch_id]
+ return {
+ "batch_id": batch_id,
+ "total": batch["total"],
+ "completed": batch["completed"],
+ "success": batch["success"],
+ "failed": batch["failed"],
+ "current_index": batch["current_index"],
+ "cancelled": batch["cancelled"],
+ "finished": batch.get("finished", False),
+ "progress": f"{batch['completed']}/{batch['total']}"
+ }
+
+
+@router.post("/batch/{batch_id}/cancel")
+async def cancel_batch(batch_id: str):
+ """取消批量任务"""
+ if batch_id not in batch_tasks:
+ raise HTTPException(status_code=404, detail="批量任务不存在")
+
+ batch = batch_tasks[batch_id]
+ if batch.get("finished"):
+ raise HTTPException(status_code=400, detail="批量任务已完成")
+
+ batch["cancelled"] = True
+ task_manager.cancel_batch(batch_id)
+ return {"success": True, "message": "批量任务取消请求已提交"}
+
+
+@router.get("/tasks", response_model=TaskListResponse)
+async def list_tasks(
+ page: int = Query(1, ge=1),
+ page_size: int = Query(20, ge=1, le=100),
+ status: Optional[str] = Query(None),
+):
+ """获取任务列表"""
+ with get_db() as db:
+ query = db.query(RegistrationTask)
+
+ if status:
+ query = query.filter(RegistrationTask.status == status)
+
+ total = query.count()
+ offset = (page - 1) * page_size
+ tasks = query.order_by(RegistrationTask.created_at.desc()).offset(offset).limit(page_size).all()
+
+ return TaskListResponse(
+ total=total,
+ tasks=[task_to_response(t) for t in tasks]
+ )
+
+
+@router.get("/tasks/{task_uuid}", response_model=RegistrationTaskResponse)
+async def get_task(task_uuid: str):
+ """获取任务详情"""
+ with get_db() as db:
+ task = crud.get_registration_task(db, task_uuid)
+ if not task:
+ raise HTTPException(status_code=404, detail="任务不存在")
+ return task_to_response(task)
+
+
+@router.get("/tasks/{task_uuid}/logs")
+async def get_task_logs(task_uuid: str):
+ """获取任务日志"""
+ with get_db() as db:
+ task = crud.get_registration_task(db, task_uuid)
+ if not task:
+ raise HTTPException(status_code=404, detail="任务不存在")
+
+ logs = task.logs or ""
+ return {
+ "task_uuid": task_uuid,
+ "status": task.status,
+ "logs": logs.split("\n") if logs else []
+ }
+
+
+@router.post("/tasks/{task_uuid}/cancel")
+async def cancel_task(task_uuid: str):
+ """取消任务"""
+ with get_db() as db:
+ task = crud.get_registration_task(db, task_uuid)
+ if not task:
+ raise HTTPException(status_code=404, detail="任务不存在")
+
+ if task.status not in ["pending", "running"]:
+ raise HTTPException(status_code=400, detail="任务已完成或已取消")
+
+ task = crud.update_registration_task(db, task_uuid, status="cancelled")
+
+ return {"success": True, "message": "任务已取消"}
+
+
+@router.delete("/tasks/{task_uuid}")
+async def delete_task(task_uuid: str):
+ """删除任务"""
+ with get_db() as db:
+ task = crud.get_registration_task(db, task_uuid)
+ if not task:
+ raise HTTPException(status_code=404, detail="任务不存在")
+
+ if task.status == "running":
+ raise HTTPException(status_code=400, detail="无法删除运行中的任务")
+
+ crud.delete_registration_task(db, task_uuid)
+
+ return {"success": True, "message": "任务已删除"}
+
+
+@router.get("/stats")
+async def get_registration_stats():
+ """获取注册统计信息"""
+ with get_db() as db:
+ from sqlalchemy import func
+
+ # 按状态统计
+ status_stats = db.query(
+ RegistrationTask.status,
+ func.count(RegistrationTask.id)
+ ).group_by(RegistrationTask.status).all()
+
+ # 今日注册数
+ today = datetime.utcnow().date()
+ today_count = db.query(func.count(RegistrationTask.id)).filter(
+ func.date(RegistrationTask.created_at) == today
+ ).scalar()
+
+ return {
+ "by_status": {status: count for status, count in status_stats},
+ "today_count": today_count
+ }
+
+
+@router.get("/available-services")
+async def get_available_email_services():
+ """
+ 获取可用于注册的邮箱服务列表
+
+ 返回所有已启用的邮箱服务,包括:
+ - tempmail: 临时邮箱(无需配置)
+ - outlook: 已导入的 Outlook 账户
+ - moe_mail: 已配置的自定义域名服务
+ """
+ from ...database.models import EmailService as EmailServiceModel
+ from ...config.settings import get_settings
+
+ settings = get_settings()
+ result = {
+ "tempmail": {
+ "available": True,
+ "count": 1,
+ "services": [{
+ "id": None,
+ "name": "Tempmail.lol",
+ "type": "tempmail",
+ "description": "临时邮箱,自动创建"
+ }]
+ },
+ "outlook": {
+ "available": False,
+ "count": 0,
+ "services": []
+ },
+ "moe_mail": {
+ "available": False,
+ "count": 0,
+ "services": []
+ },
+ "temp_mail": {
+ "available": False,
+ "count": 0,
+ "services": []
+ },
+ "duck_mail": {
+ "available": False,
+ "count": 0,
+ "services": []
+ },
+ "freemail": {
+ "available": False,
+ "count": 0,
+ "services": []
+ },
+ "imap_mail": {
+ "available": False,
+ "count": 0,
+ "services": []
+ }
+ }
+
+ with get_db() as db:
+ # 获取 Outlook 账户
+ outlook_services = db.query(EmailServiceModel).filter(
+ EmailServiceModel.service_type == "outlook",
+ EmailServiceModel.enabled == True
+ ).order_by(EmailServiceModel.priority.asc()).all()
+
+ for service in outlook_services:
+ config = service.config or {}
+ result["outlook"]["services"].append({
+ "id": service.id,
+ "name": service.name,
+ "type": "outlook",
+ "has_oauth": bool(config.get("client_id") and config.get("refresh_token")),
+ "priority": service.priority
+ })
+
+ result["outlook"]["count"] = len(outlook_services)
+ result["outlook"]["available"] = len(outlook_services) > 0
+
+ # 获取自定义域名服务
+ custom_services = db.query(EmailServiceModel).filter(
+ EmailServiceModel.service_type == "moe_mail",
+ EmailServiceModel.enabled == True
+ ).order_by(EmailServiceModel.priority.asc()).all()
+
+ for service in custom_services:
+ config = service.config or {}
+ result["moe_mail"]["services"].append({
+ "id": service.id,
+ "name": service.name,
+ "type": "moe_mail",
+ "default_domain": config.get("default_domain"),
+ "priority": service.priority
+ })
+
+ result["moe_mail"]["count"] = len(custom_services)
+ result["moe_mail"]["available"] = len(custom_services) > 0
+
+ # 如果数据库中没有自定义域名服务,检查 settings
+ if not result["moe_mail"]["available"]:
+ if settings.custom_domain_base_url and settings.custom_domain_api_key:
+ result["moe_mail"]["available"] = True
+ result["moe_mail"]["count"] = 1
+ result["moe_mail"]["services"].append({
+ "id": None,
+ "name": "默认自定义域名服务",
+ "type": "moe_mail",
+ "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
+
+ duck_mail_services = db.query(EmailServiceModel).filter(
+ EmailServiceModel.service_type == "duck_mail",
+ EmailServiceModel.enabled == True
+ ).order_by(EmailServiceModel.priority.asc()).all()
+
+ for service in duck_mail_services:
+ config = service.config or {}
+ result["duck_mail"]["services"].append({
+ "id": service.id,
+ "name": service.name,
+ "type": "duck_mail",
+ "default_domain": config.get("default_domain"),
+ "priority": service.priority
+ })
+
+ result["duck_mail"]["count"] = len(duck_mail_services)
+ result["duck_mail"]["available"] = len(duck_mail_services) > 0
+
+ freemail_services = db.query(EmailServiceModel).filter(
+ EmailServiceModel.service_type == "freemail",
+ EmailServiceModel.enabled == True
+ ).order_by(EmailServiceModel.priority.asc()).all()
+
+ for service in freemail_services:
+ config = service.config or {}
+ result["freemail"]["services"].append({
+ "id": service.id,
+ "name": service.name,
+ "type": "freemail",
+ "domain": config.get("domain"),
+ "priority": service.priority
+ })
+
+ result["freemail"]["count"] = len(freemail_services)
+ result["freemail"]["available"] = len(freemail_services) > 0
+
+ imap_mail_services = db.query(EmailServiceModel).filter(
+ EmailServiceModel.service_type == "imap_mail",
+ EmailServiceModel.enabled == True
+ ).order_by(EmailServiceModel.priority.asc()).all()
+
+ for service in imap_mail_services:
+ config = service.config or {}
+ result["imap_mail"]["services"].append({
+ "id": service.id,
+ "name": service.name,
+ "type": "imap_mail",
+ "email": config.get("email"),
+ "host": config.get("host"),
+ "priority": service.priority
+ })
+
+ result["imap_mail"]["count"] = len(imap_mail_services)
+ result["imap_mail"]["available"] = len(imap_mail_services) > 0
+
+ return result
+
+
+# ============== Outlook 批量注册 API ==============
+
+@router.get("/outlook-accounts", response_model=OutlookAccountsListResponse)
+async def get_outlook_accounts_for_registration():
+ """
+ 获取可用于注册的 Outlook 账户列表
+
+ 返回所有已启用的 Outlook 服务,并检查每个邮箱是否已在 accounts 表中注册
+ """
+ from ...database.models import EmailService as EmailServiceModel
+ from ...database.models import Account
+
+ with get_db() as db:
+ # 获取所有启用的 Outlook 服务
+ outlook_services = db.query(EmailServiceModel).filter(
+ EmailServiceModel.service_type == "outlook",
+ EmailServiceModel.enabled == True
+ ).order_by(EmailServiceModel.priority.asc()).all()
+
+ accounts = []
+ registered_count = 0
+ unregistered_count = 0
+
+ for service in outlook_services:
+ config = service.config or {}
+ email = config.get("email") or service.name
+
+ # 检查是否已注册(查询 accounts 表)
+ existing_account = db.query(Account).filter(
+ Account.email == email
+ ).first()
+
+ is_registered = existing_account is not None
+ if is_registered:
+ registered_count += 1
+ else:
+ unregistered_count += 1
+
+ accounts.append(OutlookAccountForRegistration(
+ id=service.id,
+ email=email,
+ name=service.name,
+ has_oauth=bool(config.get("client_id") and config.get("refresh_token")),
+ is_registered=is_registered,
+ registered_account_id=existing_account.id if existing_account else None
+ ))
+
+ return OutlookAccountsListResponse(
+ total=len(accounts),
+ registered_count=registered_count,
+ unregistered_count=unregistered_count,
+ accounts=accounts
+ )
+
+
+async def run_outlook_batch_registration(
+ batch_id: str,
+ service_ids: List[int],
+ skip_registered: bool,
+ proxy: Optional[str],
+ interval_min: int,
+ interval_max: int,
+ concurrency: int = 1,
+ mode: str = "pipeline",
+ auto_upload_cpa: bool = False,
+ cpa_service_ids: List[int] = None,
+ auto_upload_sub2api: bool = False,
+ sub2api_service_ids: List[int] = None,
+ auto_upload_tm: bool = False,
+ tm_service_ids: List[int] = None,
+):
+ """
+ 异步执行 Outlook 批量注册任务,复用通用并发逻辑
+
+ 将每个 service_id 映射为一个独立的 task_uuid,然后调用
+ run_batch_registration 的并发逻辑
+ """
+ loop = task_manager.get_loop()
+ if loop is None:
+ loop = asyncio.get_event_loop()
+ task_manager.set_loop(loop)
+
+ # 预先为每个 service_id 创建注册任务记录
+ task_uuids = []
+ with get_db() as db:
+ for service_id in service_ids:
+ task_uuid = str(uuid.uuid4())
+ crud.create_registration_task(
+ db,
+ task_uuid=task_uuid,
+ proxy=proxy,
+ email_service_id=service_id
+ )
+ task_uuids.append(task_uuid)
+
+ # 复用通用并发逻辑(outlook 服务类型,每个任务通过 email_service_id 定位账户)
+ await run_batch_registration(
+ batch_id=batch_id,
+ task_uuids=task_uuids,
+ email_service_type="outlook",
+ proxy=proxy,
+ email_service_config=None,
+ email_service_id=None, # 每个任务已绑定了独立的 email_service_id
+ interval_min=interval_min,
+ interval_max=interval_max,
+ concurrency=concurrency,
+ mode=mode,
+ auto_upload_cpa=auto_upload_cpa,
+ cpa_service_ids=cpa_service_ids,
+ auto_upload_sub2api=auto_upload_sub2api,
+ sub2api_service_ids=sub2api_service_ids or [], sub2api_group_ids=sub2api_group_ids, sub2api_proxy_id=sub2api_proxy_id, sub2api_model_mapping=sub2api_model_mapping,
+ auto_upload_tm=auto_upload_tm,
+ tm_service_ids=tm_service_ids,
+ )
+
+
+@router.post("/outlook-batch", response_model=OutlookBatchRegistrationResponse)
+async def start_outlook_batch_registration(
+ request: OutlookBatchRegistrationRequest,
+ background_tasks: BackgroundTasks
+):
+ """
+ 启动 Outlook 批量注册任务
+
+ - service_ids: 选中的 EmailService ID 列表
+ - skip_registered: 是否自动跳过已注册邮箱(默认 True)
+ - proxy: 代理地址
+ - interval_min: 最小间隔秒数
+ - interval_max: 最大间隔秒数
+ """
+ from ...database.models import EmailService as EmailServiceModel
+ from ...database.models import Account
+
+ # 验证参数
+ if not request.service_ids:
+ raise HTTPException(status_code=400, detail="请选择至少一个 Outlook 账户")
+
+ if request.interval_min < 0 or request.interval_max < request.interval_min:
+ raise HTTPException(status_code=400, detail="间隔时间参数无效")
+
+ if not 1 <= request.concurrency <= 50:
+ raise HTTPException(status_code=400, detail="并发数必须在 1-50 之间")
+
+ if request.mode not in ("parallel", "pipeline"):
+ raise HTTPException(status_code=400, detail="模式必须为 parallel 或 pipeline")
+
+ # 过滤掉已注册的邮箱
+ actual_service_ids = request.service_ids
+ skipped_count = 0
+
+ if request.skip_registered:
+ actual_service_ids = []
+ with get_db() as db:
+ for service_id in request.service_ids:
+ service = db.query(EmailServiceModel).filter(
+ EmailServiceModel.id == service_id
+ ).first()
+
+ if not service:
+ continue
+
+ config = service.config or {}
+ email = config.get("email") or service.name
+
+ # 检查是否已注册
+ existing_account = db.query(Account).filter(
+ Account.email == email
+ ).first()
+
+ if existing_account:
+ skipped_count += 1
+ else:
+ actual_service_ids.append(service_id)
+
+ if not actual_service_ids:
+ return OutlookBatchRegistrationResponse(
+ batch_id="",
+ total=len(request.service_ids),
+ skipped=skipped_count,
+ to_register=0,
+ service_ids=[]
+ )
+
+ # 创建批量任务
+ batch_id = str(uuid.uuid4())
+
+ # 初始化批量任务状态
+ batch_tasks[batch_id] = {
+ "total": len(actual_service_ids),
+ "completed": 0,
+ "success": 0,
+ "failed": 0,
+ "skipped": 0,
+ "cancelled": False,
+ "service_ids": actual_service_ids,
+ "current_index": 0,
+ "logs": [],
+ "finished": False
+ }
+
+ # 在后台运行批量注册
+ background_tasks.add_task(
+ run_outlook_batch_registration,
+ batch_id,
+ actual_service_ids,
+ request.skip_registered,
+ request.proxy,
+ request.interval_min,
+ request.interval_max,
+ request.concurrency,
+ request.mode,
+ request.auto_upload_cpa,
+ request.cpa_service_ids,
+ request.auto_upload_sub2api,
+ request.sub2api_service_ids,
+ request.sub2api_group_ids if hasattr(request, "sub2api_group_ids") else [],
+ request.sub2api_proxy_id if hasattr(request, "sub2api_proxy_id") else None,
+ request.sub2api_model_mapping if hasattr(request, "sub2api_model_mapping") else None,
+ request.auto_upload_tm,
+ request.tm_service_ids,
+ )
+
+ return OutlookBatchRegistrationResponse(
+ batch_id=batch_id,
+ total=len(request.service_ids),
+ skipped=skipped_count,
+ to_register=len(actual_service_ids),
+ service_ids=actual_service_ids
+ )
+
+
+@router.get("/outlook-batch/{batch_id}")
+async def get_outlook_batch_status(batch_id: str):
+ """获取 Outlook 批量任务状态"""
+ if batch_id not in batch_tasks:
+ raise HTTPException(status_code=404, detail="批量任务不存在")
+
+ batch = batch_tasks[batch_id]
+ return {
+ "batch_id": batch_id,
+ "total": batch["total"],
+ "completed": batch["completed"],
+ "success": batch["success"],
+ "failed": batch["failed"],
+ "skipped": batch.get("skipped", 0),
+ "current_index": batch["current_index"],
+ "cancelled": batch["cancelled"],
+ "finished": batch.get("finished", False),
+ "logs": batch.get("logs", []),
+ "progress": f"{batch['completed']}/{batch['total']}"
+ }
+
+
+@router.post("/outlook-batch/{batch_id}/cancel")
+async def cancel_outlook_batch(batch_id: str):
+ """取消 Outlook 批量任务"""
+ if batch_id not in batch_tasks:
+ raise HTTPException(status_code=404, detail="批量任务不存在")
+
+ batch = batch_tasks[batch_id]
+ if batch.get("finished"):
+ raise HTTPException(status_code=400, detail="批量任务已完成")
+
+ # 同时更新两个系统的取消状态
+ batch["cancelled"] = True
+ task_manager.cancel_batch(batch_id)
+
+ return {"success": True, "message": "批量任务取消请求已提交"}
diff --git a/src/web/routes/settings.py b/src/web/routes/settings.py
new file mode 100644
index 0000000..d082a88
--- /dev/null
+++ b/src/web/routes/settings.py
@@ -0,0 +1,789 @@
+"""
+设置 API 路由
+"""
+
+import logging
+import os
+from typing import Optional
+
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel
+
+from ...config.settings import get_settings, update_settings
+from ...database import crud
+from ...database.session import get_db
+
+logger = logging.getLogger(__name__)
+router = APIRouter()
+
+
+# ============== Pydantic Models ==============
+
+class SettingItem(BaseModel):
+ """设置项"""
+ key: str
+ value: str
+ description: Optional[str] = None
+ category: str = "general"
+
+
+class SettingUpdateRequest(BaseModel):
+ """设置更新请求"""
+ value: str
+
+
+class ProxySettings(BaseModel):
+ """代理设置"""
+ enabled: bool = False
+ type: str = "http" # http, socks5
+ host: str = "127.0.0.1"
+ port: int = 7890
+ username: Optional[str] = None
+ password: Optional[str] = None
+
+
+class RegistrationSettings(BaseModel):
+ """注册设置"""
+ max_retries: int = 3
+ timeout: int = 120
+ default_password_length: int = 12
+ sleep_min: int = 5
+ sleep_max: int = 30
+ engine: str = "http"
+ playwright_pool_size: int = 5
+
+
+class WebUISettings(BaseModel):
+ """Web UI 设置"""
+ host: Optional[str] = None
+ port: Optional[int] = None
+ debug: Optional[bool] = None
+ access_password: Optional[str] = None
+
+
+class AllSettings(BaseModel):
+ """所有设置"""
+ proxy: ProxySettings
+ registration: RegistrationSettings
+ webui: WebUISettings
+
+
+# ============== API Endpoints ==============
+
+@router.get("")
+async def get_all_settings():
+ """获取所有设置"""
+ settings = get_settings()
+
+ return {
+ "proxy": {
+ "enabled": settings.proxy_enabled,
+ "type": settings.proxy_type,
+ "host": settings.proxy_host,
+ "port": settings.proxy_port,
+ "username": settings.proxy_username,
+ "has_password": bool(settings.proxy_password),
+ "dynamic_enabled": settings.proxy_dynamic_enabled,
+ "dynamic_api_url": settings.proxy_dynamic_api_url,
+ "dynamic_api_key_header": settings.proxy_dynamic_api_key_header,
+ "dynamic_result_field": settings.proxy_dynamic_result_field,
+ "has_dynamic_api_key": bool(settings.proxy_dynamic_api_key and settings.proxy_dynamic_api_key.get_secret_value()),
+ },
+ "registration": {
+ "max_retries": settings.registration_max_retries,
+ "timeout": settings.registration_timeout,
+ "default_password_length": settings.registration_default_password_length,
+ "sleep_min": settings.registration_sleep_min,
+ "sleep_max": settings.registration_sleep_max,
+ "engine": settings.registration_engine,
+ "playwright_pool_size": settings.playwright_pool_size,
+ },
+ "webui": {
+ "host": settings.webui_host,
+ "port": settings.webui_port,
+ "debug": settings.debug,
+ "has_access_password": bool(settings.webui_access_password and settings.webui_access_password.get_secret_value()),
+ },
+ "tempmail": {
+ "base_url": settings.tempmail_base_url,
+ "timeout": settings.tempmail_timeout,
+ "max_retries": settings.tempmail_max_retries,
+ },
+ "email_code": {
+ "timeout": settings.email_code_timeout,
+ "poll_interval": settings.email_code_poll_interval,
+ },
+ }
+
+
+@router.get("/proxy/dynamic")
+async def get_dynamic_proxy_settings():
+ """获取动态代理设置"""
+ settings = get_settings()
+ return {
+ "enabled": settings.proxy_dynamic_enabled,
+ "api_url": settings.proxy_dynamic_api_url,
+ "api_key_header": settings.proxy_dynamic_api_key_header,
+ "result_field": settings.proxy_dynamic_result_field,
+ "has_api_key": bool(settings.proxy_dynamic_api_key and settings.proxy_dynamic_api_key.get_secret_value()),
+ }
+
+
+class DynamicProxySettings(BaseModel):
+ """动态代理设置"""
+ enabled: bool = False
+ api_url: str = ""
+ api_key: Optional[str] = None
+ api_key_header: str = "X-API-Key"
+ result_field: str = ""
+
+
+@router.post("/proxy/dynamic")
+async def update_dynamic_proxy_settings(request: DynamicProxySettings):
+ """更新动态代理设置"""
+ update_dict = {
+ "proxy_dynamic_enabled": request.enabled,
+ "proxy_dynamic_api_url": request.api_url,
+ "proxy_dynamic_api_key_header": request.api_key_header,
+ "proxy_dynamic_result_field": request.result_field,
+ }
+ if request.api_key is not None:
+ update_dict["proxy_dynamic_api_key"] = request.api_key
+
+ update_settings(**update_dict)
+ return {"success": True, "message": "动态代理设置已更新"}
+
+
+@router.post("/proxy/dynamic/test")
+async def test_dynamic_proxy(request: DynamicProxySettings):
+ """测试动态代理 API"""
+ from ...core.dynamic_proxy import fetch_dynamic_proxy
+
+ if not request.api_url:
+ raise HTTPException(status_code=400, detail="请填写动态代理 API 地址")
+
+ # 若未传入 api_key,使用已保存的
+ api_key = request.api_key or ""
+ if not api_key:
+ settings = get_settings()
+ if settings.proxy_dynamic_api_key:
+ api_key = settings.proxy_dynamic_api_key.get_secret_value()
+
+ proxy_url = fetch_dynamic_proxy(
+ api_url=request.api_url,
+ api_key=api_key,
+ api_key_header=request.api_key_header,
+ result_field=request.result_field,
+ )
+
+ if not proxy_url:
+ return {"success": False, "message": "动态代理 API 返回为空或请求失败"}
+
+ # 用获取到的代理测试连通性
+ import time
+ from curl_cffi import requests as cffi_requests
+ try:
+ proxies = {"http": proxy_url, "https": proxy_url}
+ start = time.time()
+ resp = cffi_requests.get(
+ "https://api.ipify.org?format=json",
+ proxies=proxies,
+ timeout=10,
+ impersonate="chrome110"
+ )
+ elapsed = round((time.time() - start) * 1000)
+ if resp.status_code == 200:
+ ip = resp.json().get("ip", "")
+ return {"success": True, "proxy_url": proxy_url, "ip": ip, "response_time": elapsed,
+ "message": f"动态代理可用,出口 IP: {ip},响应时间: {elapsed}ms"}
+ return {"success": False, "proxy_url": proxy_url, "message": f"代理连接失败: HTTP {resp.status_code}"}
+ except Exception as e:
+ return {"success": False, "proxy_url": proxy_url, "message": f"代理连接失败: {e}"}
+
+
+@router.get("/registration")
+async def get_registration_settings():
+ """获取注册设置"""
+ settings = get_settings()
+
+ return {
+ "max_retries": settings.registration_max_retries,
+ "timeout": settings.registration_timeout,
+ "default_password_length": settings.registration_default_password_length,
+ "sleep_min": settings.registration_sleep_min,
+ "sleep_max": settings.registration_sleep_max,
+ "engine": settings.registration_engine,
+ "playwright_pool_size": settings.playwright_pool_size,
+ }
+
+
+@router.post("/registration")
+async def update_registration_settings(request: RegistrationSettings):
+ """更新注册设置"""
+ update_settings(
+ registration_max_retries=request.max_retries,
+ registration_timeout=request.timeout,
+ registration_default_password_length=request.default_password_length,
+ registration_sleep_min=request.sleep_min,
+ registration_sleep_max=request.sleep_max,
+ registration_engine=request.engine,
+ playwright_pool_size=request.playwright_pool_size,
+ )
+
+ return {"success": True, "message": "注册设置已更新"}
+
+
+@router.post("/webui")
+async def update_webui_settings(request: WebUISettings):
+ """更新 Web UI 设置"""
+ update_dict = {}
+ if request.host is not None:
+ update_dict["webui_host"] = request.host
+ if request.port is not None:
+ update_dict["webui_port"] = request.port
+ if request.debug is not None:
+ update_dict["debug"] = request.debug
+ if request.access_password:
+ update_dict["webui_access_password"] = request.access_password
+
+ update_settings(**update_dict)
+ return {"success": True, "message": "Web UI 设置已更新"}
+
+
+@router.get("/database")
+async def get_database_info():
+ """获取数据库信息"""
+ settings = get_settings()
+
+ import os
+ from pathlib import Path
+
+ db_path = settings.database_url
+ if db_path.startswith("sqlite:///"):
+ db_path = db_path[10:]
+
+ db_file = Path(db_path) if os.path.isabs(db_path) else Path(db_path)
+ db_size = db_file.stat().st_size if db_file.exists() else 0
+
+ with get_db() as db:
+ from ...database.models import Account, EmailService, RegistrationTask
+
+ account_count = db.query(Account).count()
+ service_count = db.query(EmailService).count()
+ task_count = db.query(RegistrationTask).count()
+
+ return {
+ "database_url": settings.database_url,
+ "database_size_bytes": db_size,
+ "database_size_mb": round(db_size / (1024 * 1024), 2),
+ "accounts_count": account_count,
+ "email_services_count": service_count,
+ "tasks_count": task_count,
+ }
+
+
+@router.post("/database/backup")
+async def backup_database():
+ """备份数据库"""
+ import shutil
+ from datetime import datetime
+
+ settings = get_settings()
+
+ db_path = settings.database_url
+ if db_path.startswith("sqlite:///"):
+ db_path = db_path[10:]
+
+ if not os.path.exists(db_path):
+ raise HTTPException(status_code=404, detail="数据库文件不存在")
+
+ # 创建备份目录
+ from pathlib import Path as FilePath
+ backup_dir = FilePath(db_path).parent / "backups"
+ backup_dir.mkdir(exist_ok=True)
+
+ # 生成备份文件名
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ backup_path = backup_dir / f"database_backup_{timestamp}.db"
+
+ # 复制数据库文件
+ shutil.copy2(db_path, backup_path)
+
+ return {
+ "success": True,
+ "message": "数据库备份成功",
+ "backup_path": str(backup_path)
+ }
+
+
+@router.post("/database/cleanup")
+async def cleanup_database(
+ days: int = 30,
+ keep_failed: bool = True
+):
+ """清理过期数据"""
+ from datetime import datetime, timedelta
+
+ cutoff_date = datetime.utcnow() - timedelta(days=days)
+
+ with get_db() as db:
+ from ...database.models import RegistrationTask
+ from sqlalchemy import delete
+
+ # 删除旧任务
+ conditions = [RegistrationTask.created_at < cutoff_date]
+ if not keep_failed:
+ conditions.append(RegistrationTask.status != "failed")
+ else:
+ conditions.append(RegistrationTask.status.in_(["completed", "cancelled"]))
+
+ result = db.execute(
+ delete(RegistrationTask).where(*conditions)
+ )
+ db.commit()
+
+ deleted_count = result.rowcount
+
+ return {
+ "success": True,
+ "message": f"已清理 {deleted_count} 条过期任务记录",
+ "deleted_count": deleted_count
+ }
+
+
+@router.get("/logs")
+async def get_recent_logs(
+ lines: int = 100,
+ level: str = "INFO"
+):
+ """获取最近日志"""
+ settings = get_settings()
+
+ log_file = settings.log_file
+ if not log_file:
+ return {"logs": [], "message": "日志文件未配置"}
+
+ from pathlib import Path
+ log_path = Path(log_file)
+
+ if not log_path.exists():
+ return {"logs": [], "message": "日志文件不存在"}
+
+ try:
+ with open(log_path, "r", encoding="utf-8") as f:
+ all_lines = f.readlines()
+ recent_lines = all_lines[-lines:]
+
+ return {
+ "logs": [line.strip() for line in recent_lines],
+ "total_lines": len(all_lines)
+ }
+ except Exception as e:
+ return {"logs": [], "error": str(e)}
+
+
+# ============== 临时邮箱设置 ==============
+
+class TempmailSettings(BaseModel):
+ """临时邮箱设置"""
+ api_url: Optional[str] = None
+ enabled: bool = True
+
+
+class EmailCodeSettings(BaseModel):
+ """验证码等待设置"""
+ timeout: int = 120 # 验证码等待超时(秒)
+ poll_interval: int = 3 # 验证码轮询间隔(秒)
+
+
+@router.get("/tempmail")
+async def get_tempmail_settings():
+ """获取临时邮箱设置"""
+ settings = get_settings()
+
+ return {
+ "api_url": settings.tempmail_base_url,
+ "timeout": settings.tempmail_timeout,
+ "max_retries": settings.tempmail_max_retries,
+ "enabled": True # 临时邮箱默认可用
+ }
+
+
+@router.post("/tempmail")
+async def update_tempmail_settings(request: TempmailSettings):
+ """更新临时邮箱设置"""
+ update_dict = {}
+
+ if request.api_url:
+ update_dict["tempmail_base_url"] = request.api_url
+
+ update_settings(**update_dict)
+
+ return {"success": True, "message": "临时邮箱设置已更新"}
+
+
+# ============== 验证码等待设置 ==============
+
+@router.get("/email-code")
+async def get_email_code_settings():
+ """获取验证码等待设置"""
+ settings = get_settings()
+ return {
+ "timeout": settings.email_code_timeout,
+ "poll_interval": settings.email_code_poll_interval,
+ }
+
+
+@router.post("/email-code")
+async def update_email_code_settings(request: EmailCodeSettings):
+ """更新验证码等待设置"""
+ # 验证参数范围
+ if request.timeout < 30 or request.timeout > 600:
+ raise HTTPException(status_code=400, detail="超时时间必须在 30-600 秒之间")
+ if request.poll_interval < 1 or request.poll_interval > 30:
+ raise HTTPException(status_code=400, detail="轮询间隔必须在 1-30 秒之间")
+
+ update_settings(
+ email_code_timeout=request.timeout,
+ email_code_poll_interval=request.poll_interval,
+ )
+
+ return {"success": True, "message": "验证码等待设置已更新"}
+
+
+# ============== 代理列表 CRUD ==============
+
+class ProxyCreateRequest(BaseModel):
+ """创建代理请求"""
+ name: str
+ type: str = "http" # http, socks5
+ host: str
+ port: int
+ username: Optional[str] = None
+ password: Optional[str] = None
+ enabled: bool = True
+ priority: int = 0
+
+
+class ProxyUpdateRequest(BaseModel):
+ """更新代理请求"""
+ name: Optional[str] = None
+ type: Optional[str] = None
+ host: Optional[str] = None
+ port: Optional[int] = None
+ username: Optional[str] = None
+ password: Optional[str] = None
+ enabled: Optional[bool] = None
+ priority: Optional[int] = None
+
+
+@router.get("/proxies")
+async def get_proxies_list(enabled: Optional[bool] = None):
+ """获取代理列表"""
+ with get_db() as db:
+ proxies = crud.get_proxies(db, enabled=enabled)
+ return {
+ "proxies": [p.to_dict() for p in proxies],
+ "total": len(proxies)
+ }
+
+
+@router.post("/proxies")
+async def create_proxy_item(request: ProxyCreateRequest):
+ """创建代理"""
+ with get_db() as db:
+ proxy = crud.create_proxy(
+ db,
+ name=request.name,
+ type=request.type,
+ host=request.host,
+ port=request.port,
+ username=request.username,
+ password=request.password,
+ enabled=request.enabled,
+ priority=request.priority
+ )
+ return {"success": True, "proxy": proxy.to_dict()}
+
+
+@router.get("/proxies/{proxy_id}")
+async def get_proxy_item(proxy_id: int):
+ """获取单个代理"""
+ with get_db() as db:
+ proxy = crud.get_proxy_by_id(db, proxy_id)
+ if not proxy:
+ raise HTTPException(status_code=404, detail="代理不存在")
+ return proxy.to_dict(include_password=True)
+
+
+@router.patch("/proxies/{proxy_id}")
+async def update_proxy_item(proxy_id: int, request: ProxyUpdateRequest):
+ """更新代理"""
+ with get_db() as db:
+ update_data = {}
+ if request.name is not None:
+ update_data["name"] = request.name
+ if request.type is not None:
+ update_data["type"] = request.type
+ if request.host is not None:
+ update_data["host"] = request.host
+ if request.port is not None:
+ update_data["port"] = request.port
+ if request.username is not None:
+ update_data["username"] = request.username
+ if request.password is not None:
+ update_data["password"] = request.password
+ if request.enabled is not None:
+ update_data["enabled"] = request.enabled
+ if request.priority is not None:
+ update_data["priority"] = request.priority
+
+ proxy = crud.update_proxy(db, proxy_id, **update_data)
+ if not proxy:
+ raise HTTPException(status_code=404, detail="代理不存在")
+ return {"success": True, "proxy": proxy.to_dict()}
+
+
+@router.delete("/proxies/{proxy_id}")
+async def delete_proxy_item(proxy_id: int):
+ """删除代理"""
+ with get_db() as db:
+ success = crud.delete_proxy(db, proxy_id)
+ if not success:
+ raise HTTPException(status_code=404, detail="代理不存在")
+ return {"success": True, "message": "代理已删除"}
+
+
+@router.post("/proxies/{proxy_id}/set-default")
+async def set_proxy_default(proxy_id: int):
+ """将指定代理设为默认"""
+ with get_db() as db:
+ proxy = crud.set_proxy_default(db, proxy_id)
+ if not proxy:
+ raise HTTPException(status_code=404, detail="代理不存在")
+ return {"success": True, "proxy": proxy.to_dict()}
+
+
+@router.post("/proxies/{proxy_id}/test")
+async def test_proxy_item(proxy_id: int):
+ """测试单个代理"""
+ import time
+ from curl_cffi import requests as cffi_requests
+
+ with get_db() as db:
+ proxy = crud.get_proxy_by_id(db, proxy_id)
+ if not proxy:
+ raise HTTPException(status_code=404, detail="代理不存在")
+
+ proxy_url = proxy.proxy_url
+ test_url = "https://api.ipify.org?format=json"
+ start_time = time.time()
+
+ try:
+ proxies = {
+ "http": proxy_url,
+ "https": proxy_url
+ }
+
+ response = cffi_requests.get(
+ test_url,
+ proxies=proxies,
+ timeout=3,
+ impersonate="chrome110"
+ )
+
+ elapsed_time = time.time() - start_time
+
+ if response.status_code == 200:
+ ip_info = response.json()
+ return {
+ "success": True,
+ "ip": ip_info.get("ip", ""),
+ "response_time": round(elapsed_time * 1000),
+ "message": f"代理连接成功,出口 IP: {ip_info.get('ip', 'unknown')}"
+ }
+ else:
+ return {
+ "success": False,
+ "message": f"代理返回错误状态码: {response.status_code}"
+ }
+
+ except Exception as e:
+ return {
+ "success": False,
+ "message": f"代理连接失败: {str(e)}"
+ }
+
+
+@router.post("/proxies/test-all")
+async def test_all_proxies():
+ """测试所有启用的代理"""
+ import time
+ from curl_cffi import requests as cffi_requests
+
+ with get_db() as db:
+ proxies = crud.get_enabled_proxies(db)
+
+ results = []
+ for proxy in proxies:
+ proxy_url = proxy.proxy_url
+ test_url = "https://api.ipify.org?format=json"
+ start_time = time.time()
+
+ try:
+ proxies_dict = {
+ "http": proxy_url,
+ "https": proxy_url
+ }
+
+ response = cffi_requests.get(
+ test_url,
+ proxies=proxies_dict,
+ timeout=3,
+ impersonate="chrome110"
+ )
+
+ elapsed_time = time.time() - start_time
+
+ if response.status_code == 200:
+ ip_info = response.json()
+ results.append({
+ "id": proxy.id,
+ "name": proxy.name,
+ "success": True,
+ "ip": ip_info.get("ip", ""),
+ "response_time": round(elapsed_time * 1000)
+ })
+ else:
+ results.append({
+ "id": proxy.id,
+ "name": proxy.name,
+ "success": False,
+ "message": f"状态码: {response.status_code}"
+ })
+
+ except Exception as e:
+ results.append({
+ "id": proxy.id,
+ "name": proxy.name,
+ "success": False,
+ "message": str(e)
+ })
+
+ success_count = sum(1 for r in results if r["success"])
+ return {
+ "total": len(proxies),
+ "success": success_count,
+ "failed": len(proxies) - success_count,
+ "results": results
+ }
+
+
+@router.post("/proxies/{proxy_id}/enable")
+async def enable_proxy(proxy_id: int):
+ """启用代理"""
+ with get_db() as db:
+ proxy = crud.update_proxy(db, proxy_id, enabled=True)
+ if not proxy:
+ raise HTTPException(status_code=404, detail="代理不存在")
+ return {"success": True, "message": "代理已启用"}
+
+
+@router.post("/proxies/{proxy_id}/disable")
+async def disable_proxy(proxy_id: int):
+ """禁用代理"""
+ with get_db() as db:
+ proxy = crud.update_proxy(db, proxy_id, enabled=False)
+ if not proxy:
+ raise HTTPException(status_code=404, detail="代理不存在")
+ return {"success": True, "message": "代理已禁用"}
+
+
+# ============== Outlook 设置 ==============
+
+class OutlookSettings(BaseModel):
+ """Outlook 设置"""
+ default_client_id: Optional[str] = None
+
+
+@router.get("/outlook")
+async def get_outlook_settings():
+ """获取 Outlook 设置"""
+ settings = get_settings()
+
+ return {
+ "default_client_id": settings.outlook_default_client_id,
+ "provider_priority": settings.outlook_provider_priority,
+ "health_failure_threshold": settings.outlook_health_failure_threshold,
+ "health_disable_duration": settings.outlook_health_disable_duration,
+ }
+
+
+@router.post("/outlook")
+async def update_outlook_settings(request: OutlookSettings):
+ """更新 Outlook 设置"""
+ update_dict = {}
+
+ if request.default_client_id is not None:
+ update_dict["outlook_default_client_id"] = request.default_client_id
+
+ if update_dict:
+ 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.upload.team_manager_upload 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/src/web/routes/upload/__init__.py b/src/web/routes/upload/__init__.py
new file mode 100644
index 0000000..1f776fc
--- /dev/null
+++ b/src/web/routes/upload/__init__.py
@@ -0,0 +1,2 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
\ No newline at end of file
diff --git a/src/web/routes/upload/cpa_services.py b/src/web/routes/upload/cpa_services.py
new file mode 100644
index 0000000..51f8c49
--- /dev/null
+++ b/src/web/routes/upload/cpa_services.py
@@ -0,0 +1,179 @@
+"""
+CPA 服务管理 API 路由
+"""
+
+from typing import List, Optional
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel
+
+from ....database import crud
+from ....database.session import get_db
+from ....core.upload.cpa_upload import test_cpa_connection
+
+router = APIRouter()
+
+
+# ============== Pydantic Models ==============
+
+class CpaServiceCreate(BaseModel):
+ name: str
+ api_url: str
+ api_token: str
+ enabled: bool = True
+ include_proxy_url: bool = False
+ priority: int = 0
+
+
+class CpaServiceUpdate(BaseModel):
+ name: Optional[str] = None
+ api_url: Optional[str] = None
+ api_token: Optional[str] = None
+ enabled: Optional[bool] = None
+ include_proxy_url: Optional[bool] = None
+ priority: Optional[int] = None
+
+
+class CpaServiceResponse(BaseModel):
+ id: int
+ name: str
+ api_url: str
+ has_token: bool
+ enabled: bool
+ include_proxy_url: bool
+ priority: int
+ created_at: Optional[str] = None
+ updated_at: Optional[str] = None
+
+ class Config:
+ from_attributes = True
+
+
+class CpaServiceTestRequest(BaseModel):
+ api_url: Optional[str] = None
+ api_token: Optional[str] = None
+
+
+def _to_response(svc) -> CpaServiceResponse:
+ return CpaServiceResponse(
+ id=svc.id,
+ name=svc.name,
+ api_url=svc.api_url,
+ has_token=bool(svc.api_token),
+ enabled=svc.enabled,
+ include_proxy_url=bool(svc.include_proxy_url),
+ priority=svc.priority,
+ created_at=svc.created_at.isoformat() if svc.created_at else None,
+ updated_at=svc.updated_at.isoformat() if svc.updated_at else None,
+ )
+
+
+# ============== API Endpoints ==============
+
+@router.get("", response_model=List[CpaServiceResponse])
+async def list_cpa_services(enabled: Optional[bool] = None):
+ """获取 CPA 服务列表"""
+ with get_db() as db:
+ services = crud.get_cpa_services(db, enabled=enabled)
+ return [_to_response(s) for s in services]
+
+
+@router.post("", response_model=CpaServiceResponse)
+async def create_cpa_service(request: CpaServiceCreate):
+ """新增 CPA 服务"""
+ with get_db() as db:
+ service = crud.create_cpa_service(
+ db,
+ name=request.name,
+ api_url=request.api_url,
+ api_token=request.api_token,
+ enabled=request.enabled,
+ include_proxy_url=request.include_proxy_url,
+ priority=request.priority,
+ )
+ return _to_response(service)
+
+
+@router.get("/{service_id}", response_model=CpaServiceResponse)
+async def get_cpa_service(service_id: int):
+ """获取单个 CPA 服务详情"""
+ with get_db() as db:
+ service = crud.get_cpa_service_by_id(db, service_id)
+ if not service:
+ raise HTTPException(status_code=404, detail="CPA 服务不存在")
+ return _to_response(service)
+
+
+@router.get("/{service_id}/full")
+async def get_cpa_service_full(service_id: int):
+ """获取 CPA 服务完整配置(含 token)"""
+ with get_db() as db:
+ service = crud.get_cpa_service_by_id(db, service_id)
+ if not service:
+ raise HTTPException(status_code=404, detail="CPA 服务不存在")
+ return {
+ "id": service.id,
+ "name": service.name,
+ "api_url": service.api_url,
+ "api_token": service.api_token,
+ "enabled": service.enabled,
+ "include_proxy_url": bool(service.include_proxy_url),
+ "priority": service.priority,
+ }
+
+
+@router.patch("/{service_id}", response_model=CpaServiceResponse)
+async def update_cpa_service(service_id: int, request: CpaServiceUpdate):
+ """更新 CPA 服务配置"""
+ with get_db() as db:
+ service = crud.get_cpa_service_by_id(db, service_id)
+ if not service:
+ raise HTTPException(status_code=404, detail="CPA 服务不存在")
+
+ update_data = {}
+ if request.name is not None:
+ update_data["name"] = request.name
+ if request.api_url is not None:
+ update_data["api_url"] = request.api_url
+ # api_token 留空则保持原值
+ if request.api_token:
+ update_data["api_token"] = request.api_token
+ if request.enabled is not None:
+ update_data["enabled"] = request.enabled
+ if request.include_proxy_url is not None:
+ update_data["include_proxy_url"] = request.include_proxy_url
+ if request.priority is not None:
+ update_data["priority"] = request.priority
+
+ service = crud.update_cpa_service(db, service_id, **update_data)
+ return _to_response(service)
+
+
+@router.delete("/{service_id}")
+async def delete_cpa_service(service_id: int):
+ """删除 CPA 服务"""
+ with get_db() as db:
+ service = crud.get_cpa_service_by_id(db, service_id)
+ if not service:
+ raise HTTPException(status_code=404, detail="CPA 服务不存在")
+ crud.delete_cpa_service(db, service_id)
+ return {"success": True, "message": f"CPA 服务 {service.name} 已删除"}
+
+
+@router.post("/{service_id}/test")
+async def test_cpa_service(service_id: int):
+ """测试 CPA 服务连接"""
+ with get_db() as db:
+ service = crud.get_cpa_service_by_id(db, service_id)
+ if not service:
+ raise HTTPException(status_code=404, detail="CPA 服务不存在")
+ success, message = test_cpa_connection(service.api_url, service.api_token)
+ return {"success": success, "message": message}
+
+
+@router.post("/test-connection")
+async def test_cpa_connection_direct(request: CpaServiceTestRequest):
+ """直接测试 CPA 连接(用于添加前验证)"""
+ if not request.api_url or not request.api_token:
+ raise HTTPException(status_code=400, detail="api_url 和 api_token 不能为空")
+ success, message = test_cpa_connection(request.api_url, request.api_token)
+ return {"success": success, "message": message}
diff --git a/src/web/routes/upload/sub2api_services.py b/src/web/routes/upload/sub2api_services.py
new file mode 100644
index 0000000..8f34ef7
--- /dev/null
+++ b/src/web/routes/upload/sub2api_services.py
@@ -0,0 +1,253 @@
+"""
+Sub2API 服务管理 API 路由
+"""
+
+from typing import List, Optional
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel
+
+from ....database import crud
+from ....database.session import get_db
+from ....core.upload.sub2api_upload import test_sub2api_connection, batch_upload_to_sub2api
+
+router = APIRouter()
+
+
+# ============== Pydantic Models ==============
+
+class Sub2ApiServiceCreate(BaseModel):
+ name: str
+ api_url: str
+ api_key: str
+ enabled: bool = True
+ priority: int = 0
+
+
+class Sub2ApiServiceUpdate(BaseModel):
+ name: Optional[str] = None
+ api_url: Optional[str] = None
+ api_key: Optional[str] = None
+ enabled: Optional[bool] = None
+ priority: Optional[int] = None
+
+
+class Sub2ApiServiceResponse(BaseModel):
+ id: int
+ name: str
+ api_url: str
+ has_key: bool
+ enabled: bool
+ priority: int
+ created_at: Optional[str] = None
+ updated_at: Optional[str] = None
+
+ class Config:
+ from_attributes = True
+
+
+class Sub2ApiTestRequest(BaseModel):
+ api_url: Optional[str] = None
+ api_key: Optional[str] = None
+
+
+class Sub2ApiUploadRequest(BaseModel):
+ account_ids: List[int]
+ service_id: Optional[int] = None
+ concurrency: int = 3
+ priority: int = 50
+
+
+def _to_response(svc) -> Sub2ApiServiceResponse:
+ return Sub2ApiServiceResponse(
+ id=svc.id,
+ name=svc.name,
+ api_url=svc.api_url,
+ has_key=bool(svc.api_key),
+ enabled=svc.enabled,
+ priority=svc.priority,
+ created_at=svc.created_at.isoformat() if svc.created_at else None,
+ updated_at=svc.updated_at.isoformat() if svc.updated_at else None,
+ )
+
+
+# ============== API Endpoints ==============
+
+@router.get("", response_model=List[Sub2ApiServiceResponse])
+async def list_sub2api_services(enabled: Optional[bool] = None):
+ """获取 Sub2API 服务列表"""
+ with get_db() as db:
+ services = crud.get_sub2api_services(db, enabled=enabled)
+ return [_to_response(s) for s in services]
+
+
+@router.post("", response_model=Sub2ApiServiceResponse)
+async def create_sub2api_service(request: Sub2ApiServiceCreate):
+ """新增 Sub2API 服务"""
+ with get_db() as db:
+ svc = crud.create_sub2api_service(
+ db,
+ name=request.name,
+ api_url=request.api_url,
+ api_key=request.api_key,
+ enabled=request.enabled,
+ priority=request.priority,
+ )
+ return _to_response(svc)
+
+
+@router.get("/{service_id}", response_model=Sub2ApiServiceResponse)
+async def get_sub2api_service(service_id: int):
+ """获取单个 Sub2API 服务详情"""
+ with get_db() as db:
+ svc = crud.get_sub2api_service_by_id(db, service_id)
+ if not svc:
+ raise HTTPException(status_code=404, detail="Sub2API 服务不存在")
+ return _to_response(svc)
+
+
+@router.get("/{service_id}/full")
+async def get_sub2api_service_full(service_id: int):
+ """获取 Sub2API 服务完整配置(含 API Key)"""
+ with get_db() as db:
+ svc = crud.get_sub2api_service_by_id(db, service_id)
+ if not svc:
+ raise HTTPException(status_code=404, detail="Sub2API 服务不存在")
+ return {
+ "id": svc.id,
+ "name": svc.name,
+ "api_url": svc.api_url,
+ "api_key": svc.api_key,
+ "enabled": svc.enabled,
+ "priority": svc.priority,
+ }
+
+
+@router.patch("/{service_id}", response_model=Sub2ApiServiceResponse)
+async def update_sub2api_service(service_id: int, request: Sub2ApiServiceUpdate):
+ """更新 Sub2API 服务配置"""
+ with get_db() as db:
+ svc = crud.get_sub2api_service_by_id(db, service_id)
+ if not svc:
+ raise HTTPException(status_code=404, detail="Sub2API 服务不存在")
+
+ update_data = {}
+ if request.name is not None:
+ update_data["name"] = request.name
+ if request.api_url is not None:
+ update_data["api_url"] = request.api_url
+ # api_key 留空则保持原值
+ if request.api_key:
+ update_data["api_key"] = request.api_key
+ if request.enabled is not None:
+ update_data["enabled"] = request.enabled
+ if request.priority is not None:
+ update_data["priority"] = request.priority
+
+ svc = crud.update_sub2api_service(db, service_id, **update_data)
+ return _to_response(svc)
+
+
+@router.delete("/{service_id}")
+async def delete_sub2api_service(service_id: int):
+ """删除 Sub2API 服务"""
+ with get_db() as db:
+ svc = crud.get_sub2api_service_by_id(db, service_id)
+ if not svc:
+ raise HTTPException(status_code=404, detail="Sub2API 服务不存在")
+ crud.delete_sub2api_service(db, service_id)
+ return {"success": True, "message": f"Sub2API 服务 {svc.name} 已删除"}
+
+
+@router.post("/{service_id}/test")
+async def test_sub2api_service(service_id: int):
+ """测试 Sub2API 服务连接"""
+ with get_db() as db:
+ svc = crud.get_sub2api_service_by_id(db, service_id)
+ if not svc:
+ raise HTTPException(status_code=404, detail="Sub2API 服务不存在")
+ success, message = test_sub2api_connection(svc.api_url, svc.api_key)
+ return {"success": success, "message": message}
+
+
+@router.post("/test-connection")
+async def test_sub2api_connection_direct(request: Sub2ApiTestRequest):
+ """直接测试 Sub2API 连接(用于添加前验证)"""
+ if not request.api_url or not request.api_key:
+ raise HTTPException(status_code=400, detail="api_url 和 api_key 不能为空")
+ success, message = test_sub2api_connection(request.api_url, request.api_key)
+ return {"success": success, "message": message}
+
+
+@router.post("/upload")
+async def upload_accounts_to_sub2api(request: Sub2ApiUploadRequest):
+ """批量上传账号到 Sub2API 平台"""
+ if not request.account_ids:
+ raise HTTPException(status_code=400, detail="账号 ID 列表不能为空")
+
+ with get_db() as db:
+ if request.service_id:
+ svc = crud.get_sub2api_service_by_id(db, request.service_id)
+ else:
+ svcs = crud.get_sub2api_services(db, enabled=True)
+ svc = svcs[0] if svcs else None
+
+ if not svc:
+ raise HTTPException(status_code=400, detail="未找到可用的 Sub2API 服务")
+
+ api_url = svc.api_url
+ api_key = svc.api_key
+
+ results = batch_upload_to_sub2api(
+ request.account_ids,
+ api_url,
+ api_key,
+ concurrency=request.concurrency,
+ priority=request.priority,
+ )
+ return results
+
+
+# ============== Sub2API 远程数据代理 API ==============
+
+@router.get("/{service_id}/groups")
+async def get_sub2api_groups(service_id: int):
+ """代理请求:获取 Sub2API 服务的分组列表"""
+ with get_db() as db:
+ svc = crud.get_sub2api_service_by_id(db, service_id)
+ if not svc:
+ raise HTTPException(status_code=404, detail="Sub2API 服务不存在")
+
+ url = svc.api_url.rstrip("/") + "/api/v1/admin/groups?page_size=100"
+ headers = {"x-api-key": svc.api_key}
+ try:
+ from curl_cffi import requests as cffi_requests
+ resp = cffi_requests.get(url, headers=headers, proxies=None, timeout=10, impersonate="chrome110")
+ if resp.status_code == 200:
+ data = resp.json()
+ items = data.get("data", {}).get("items", [])
+ return [{"id": g["id"], "name": g["name"], "platform": g.get("platform", ""), "account_count": g.get("account_count", 0)} for g in items]
+ return []
+ except Exception as e:
+ raise HTTPException(status_code=502, detail=f"获取分组列表失败: {str(e)}")
+
+
+@router.get("/{service_id}/proxies")
+async def get_sub2api_proxies(service_id: int):
+ """代理请求:获取 Sub2API 服务的代理节点列表"""
+ with get_db() as db:
+ svc = crud.get_sub2api_service_by_id(db, service_id)
+ if not svc:
+ raise HTTPException(status_code=404, detail="Sub2API 服务不存在")
+
+ url = svc.api_url.rstrip("/") + "/api/v1/admin/proxies?page_size=100"
+ headers = {"x-api-key": svc.api_key}
+ try:
+ from curl_cffi import requests as cffi_requests
+ resp = cffi_requests.get(url, headers=headers, proxies=None, timeout=10, impersonate="chrome110")
+ if resp.status_code == 200:
+ data = resp.json()
+ items = data.get("data", {}).get("items", [])
+ return [{"id": p["id"], "name": p["name"], "protocol": p.get("protocol", ""), "status": p.get("status", ""), "country": p.get("country", ""), "ip_address": p.get("ip_address", ""), "account_count": p.get("account_count", 0)} for p in items]
+ return []
+ except Exception as e:
+ raise HTTPException(status_code=502, detail=f"获取代理列表失败: {str(e)}")
diff --git a/src/web/routes/upload/tm_services.py b/src/web/routes/upload/tm_services.py
new file mode 100644
index 0000000..b363139
--- /dev/null
+++ b/src/web/routes/upload/tm_services.py
@@ -0,0 +1,153 @@
+"""
+Team Manager 服务管理 API 路由
+"""
+
+from typing import List, Optional
+from fastapi import APIRouter, HTTPException
+from pydantic import BaseModel
+
+from ....database import crud
+from ....database.session import get_db
+from ....core.upload.team_manager_upload import test_team_manager_connection
+
+router = APIRouter()
+
+
+# ============== Pydantic Models ==============
+
+class TmServiceCreate(BaseModel):
+ name: str
+ api_url: str
+ api_key: str
+ enabled: bool = True
+ priority: int = 0
+
+
+class TmServiceUpdate(BaseModel):
+ name: Optional[str] = None
+ api_url: Optional[str] = None
+ api_key: Optional[str] = None
+ enabled: Optional[bool] = None
+ priority: Optional[int] = None
+
+
+class TmServiceResponse(BaseModel):
+ id: int
+ name: str
+ api_url: str
+ has_key: bool
+ enabled: bool
+ priority: int
+ created_at: Optional[str] = None
+ updated_at: Optional[str] = None
+
+ class Config:
+ from_attributes = True
+
+
+class TmTestRequest(BaseModel):
+ api_url: Optional[str] = None
+ api_key: Optional[str] = None
+
+
+def _to_response(svc) -> TmServiceResponse:
+ return TmServiceResponse(
+ id=svc.id,
+ name=svc.name,
+ api_url=svc.api_url,
+ has_key=bool(svc.api_key),
+ enabled=svc.enabled,
+ priority=svc.priority,
+ created_at=svc.created_at.isoformat() if svc.created_at else None,
+ updated_at=svc.updated_at.isoformat() if svc.updated_at else None,
+ )
+
+
+# ============== API Endpoints ==============
+
+@router.get("", response_model=List[TmServiceResponse])
+async def list_tm_services(enabled: Optional[bool] = None):
+ """获取 Team Manager 服务列表"""
+ with get_db() as db:
+ services = crud.get_tm_services(db, enabled=enabled)
+ return [_to_response(s) for s in services]
+
+
+@router.post("", response_model=TmServiceResponse)
+async def create_tm_service(request: TmServiceCreate):
+ """新增 Team Manager 服务"""
+ with get_db() as db:
+ svc = crud.create_tm_service(
+ db,
+ name=request.name,
+ api_url=request.api_url,
+ api_key=request.api_key,
+ enabled=request.enabled,
+ priority=request.priority,
+ )
+ return _to_response(svc)
+
+
+@router.get("/{service_id}", response_model=TmServiceResponse)
+async def get_tm_service(service_id: int):
+ """获取单个 Team Manager 服务详情"""
+ with get_db() as db:
+ svc = crud.get_tm_service_by_id(db, service_id)
+ if not svc:
+ raise HTTPException(status_code=404, detail="Team Manager 服务不存在")
+ return _to_response(svc)
+
+
+@router.patch("/{service_id}", response_model=TmServiceResponse)
+async def update_tm_service(service_id: int, request: TmServiceUpdate):
+ """更新 Team Manager 服务配置"""
+ with get_db() as db:
+ svc = crud.get_tm_service_by_id(db, service_id)
+ if not svc:
+ raise HTTPException(status_code=404, detail="Team Manager 服务不存在")
+
+ update_data = {}
+ if request.name is not None:
+ update_data["name"] = request.name
+ if request.api_url is not None:
+ update_data["api_url"] = request.api_url
+ if request.api_key:
+ update_data["api_key"] = request.api_key
+ if request.enabled is not None:
+ update_data["enabled"] = request.enabled
+ if request.priority is not None:
+ update_data["priority"] = request.priority
+
+ svc = crud.update_tm_service(db, service_id, **update_data)
+ return _to_response(svc)
+
+
+@router.delete("/{service_id}")
+async def delete_tm_service(service_id: int):
+ """删除 Team Manager 服务"""
+ with get_db() as db:
+ svc = crud.get_tm_service_by_id(db, service_id)
+ if not svc:
+ raise HTTPException(status_code=404, detail="Team Manager 服务不存在")
+ crud.delete_tm_service(db, service_id)
+ return {"success": True, "message": f"Team Manager 服务 {svc.name} 已删除"}
+
+
+@router.post("/{service_id}/test")
+async def test_tm_service(service_id: int):
+ """测试 Team Manager 服务连接"""
+ with get_db() as db:
+ svc = crud.get_tm_service_by_id(db, service_id)
+ if not svc:
+ raise HTTPException(status_code=404, detail="Team Manager 服务不存在")
+ success, message = test_team_manager_connection(svc.api_url, svc.api_key)
+ return {"success": success, "message": message}
+
+
+@router.post("/test-connection")
+async def test_tm_connection_direct(request: TmTestRequest):
+ """直接测试 Team Manager 连接(用于添加前验证)"""
+ if not request.api_url or not request.api_key:
+ raise HTTPException(status_code=400, detail="api_url 和 api_key 不能为空")
+ success, message = test_team_manager_connection(request.api_url, request.api_key)
+ return {"success": success, "message": message}
diff --git a/src/web/routes/websocket.py b/src/web/routes/websocket.py
new file mode 100644
index 0000000..decea56
--- /dev/null
+++ b/src/web/routes/websocket.py
@@ -0,0 +1,170 @@
+"""
+WebSocket 路由
+提供实时日志推送和任务状态更新
+"""
+
+import asyncio
+import logging
+from fastapi import APIRouter, WebSocket, WebSocketDisconnect
+
+from ..task_manager import task_manager
+
+logger = logging.getLogger(__name__)
+router = APIRouter()
+
+
+@router.websocket("/ws/task/{task_uuid}")
+async def task_websocket(websocket: WebSocket, task_uuid: str):
+ """
+ 任务日志 WebSocket
+
+ 消息格式:
+ - 服务端发送: {"type": "log", "task_uuid": "xxx", "message": "...", "timestamp": "..."}
+ - 服务端发送: {"type": "status", "task_uuid": "xxx", "status": "running|completed|failed|cancelled", ...}
+ - 客户端发送: {"type": "ping"} - 心跳
+ - 客户端发送: {"type": "cancel"} - 取消任务
+ """
+ await websocket.accept()
+
+ # 注册连接(会记录当前日志数量,避免重复发送历史日志)
+ task_manager.register_websocket(task_uuid, websocket)
+ logger.info(f"WebSocket 连接已建立: {task_uuid}")
+
+ try:
+ # 发送当前状态
+ status = task_manager.get_status(task_uuid)
+ if status:
+ await websocket.send_json({
+ "type": "status",
+ "task_uuid": task_uuid,
+ **status
+ })
+
+ # 发送历史日志(只发送注册时已存在的日志,避免与实时推送重复)
+ history_logs = task_manager.get_unsent_logs(task_uuid, websocket)
+ for log in history_logs:
+ await websocket.send_json({
+ "type": "log",
+ "task_uuid": task_uuid,
+ "message": log
+ })
+
+ # 保持连接,等待客户端消息
+ while True:
+ try:
+ # 使用 wait_for 实现超时,但不是断开连接
+ # 而是发送心跳检测
+ data = await asyncio.wait_for(
+ websocket.receive_json(),
+ timeout=30.0 # 30秒超时
+ )
+
+ # 处理心跳
+ if data.get("type") == "ping":
+ await websocket.send_json({"type": "pong"})
+
+ # 处理取消请求
+ elif data.get("type") == "cancel":
+ task_manager.cancel_task(task_uuid)
+ await websocket.send_json({
+ "type": "status",
+ "task_uuid": task_uuid,
+ "status": "cancelling",
+ "message": "取消请求已提交"
+ })
+
+ except asyncio.TimeoutError:
+ # 超时,发送心跳检测
+ try:
+ await websocket.send_json({"type": "ping"})
+ except Exception:
+ # 发送失败,可能是连接断开
+ logger.info(f"WebSocket 心跳检测失败: {task_uuid}")
+ break
+
+ except WebSocketDisconnect:
+ logger.info(f"WebSocket 断开: {task_uuid}")
+
+ except Exception as e:
+ logger.error(f"WebSocket 错误: {e}")
+
+ finally:
+ task_manager.unregister_websocket(task_uuid, websocket)
+
+
+@router.websocket("/ws/batch/{batch_id}")
+async def batch_websocket(websocket: WebSocket, batch_id: str):
+ """
+ 批量任务 WebSocket
+
+ 用于批量注册任务的实时状态更新
+
+ 消息格式:
+ - 服务端发送: {"type": "log", "batch_id": "xxx", "message": "...", "timestamp": "..."}
+ - 服务端发送: {"type": "status", "batch_id": "xxx", "status": "running|completed|cancelled", ...}
+ - 客户端发送: {"type": "ping"} - 心跳
+ - 客户端发送: {"type": "cancel"} - 取消批量任务
+ """
+ await websocket.accept()
+
+ # 注册连接(会记录当前日志数量,避免重复发送历史日志)
+ task_manager.register_batch_websocket(batch_id, websocket)
+ logger.info(f"批量任务 WebSocket 连接已建立: {batch_id}")
+
+ try:
+ # 发送当前状态
+ status = task_manager.get_batch_status(batch_id)
+ if status:
+ await websocket.send_json({
+ "type": "status",
+ "batch_id": batch_id,
+ **status
+ })
+
+ # 发送历史日志(只发送注册时已存在的日志,避免与实时推送重复)
+ history_logs = task_manager.get_unsent_batch_logs(batch_id, websocket)
+ for log in history_logs:
+ await websocket.send_json({
+ "type": "log",
+ "batch_id": batch_id,
+ "message": log
+ })
+
+ # 保持连接,等待客户端消息
+ while True:
+ try:
+ data = await asyncio.wait_for(
+ websocket.receive_json(),
+ timeout=30.0
+ )
+
+ # 处理心跳
+ if data.get("type") == "ping":
+ await websocket.send_json({"type": "pong"})
+
+ # 处理取消请求
+ elif data.get("type") == "cancel":
+ task_manager.cancel_batch(batch_id)
+ await websocket.send_json({
+ "type": "status",
+ "batch_id": batch_id,
+ "status": "cancelling",
+ "message": "取消请求已提交"
+ })
+
+ except asyncio.TimeoutError:
+ # 超时,发送心跳检测
+ try:
+ await websocket.send_json({"type": "ping"})
+ except Exception:
+ logger.info(f"批量任务 WebSocket 心跳检测失败: {batch_id}")
+ break
+
+ except WebSocketDisconnect:
+ logger.info(f"批量任务 WebSocket 断开: {batch_id}")
+
+ except Exception as e:
+ logger.error(f"批量任务 WebSocket 错误: {e}")
+
+ finally:
+ task_manager.unregister_batch_websocket(batch_id, websocket)
diff --git a/src/web/task_manager.py b/src/web/task_manager.py
new file mode 100644
index 0000000..31c620b
--- /dev/null
+++ b/src/web/task_manager.py
@@ -0,0 +1,386 @@
+"""
+任务管理器
+负责管理后台任务、日志队列和 WebSocket 推送
+"""
+
+import asyncio
+import logging
+import threading
+from concurrent.futures import ThreadPoolExecutor
+from typing import Dict, Optional, List, Callable, Any
+from collections import defaultdict
+from datetime import datetime
+
+logger = logging.getLogger(__name__)
+
+# 全局线程池(支持最多 50 个并发注册任务)
+_executor = ThreadPoolExecutor(max_workers=50, thread_name_prefix="reg_worker")
+
+# 全局元锁:保护所有 defaultdict 的首次 key 创建(避免多线程竞态)
+_meta_lock = threading.Lock()
+
+# 任务日志队列 (task_uuid -> list of logs)
+_log_queues: Dict[str, List[str]] = defaultdict(list)
+_log_locks: Dict[str, threading.Lock] = {}
+
+# WebSocket 连接管理 (task_uuid -> list of websockets)
+_ws_connections: Dict[str, List] = defaultdict(list)
+_ws_lock = threading.Lock()
+
+# WebSocket 已发送日志索引 (task_uuid -> {websocket: sent_count})
+_ws_sent_index: Dict[str, Dict] = defaultdict(dict)
+
+# 任务状态
+_task_status: Dict[str, dict] = {}
+
+# 任务取消标志
+_task_cancelled: Dict[str, bool] = {}
+
+# 批量任务状态 (batch_id -> dict)
+_batch_status: Dict[str, dict] = {}
+_batch_logs: Dict[str, List[str]] = defaultdict(list)
+_batch_locks: Dict[str, threading.Lock] = {}
+
+
+def _get_log_lock(task_uuid: str) -> threading.Lock:
+ """线程安全地获取或创建任务日志锁"""
+ if task_uuid not in _log_locks:
+ with _meta_lock:
+ if task_uuid not in _log_locks:
+ _log_locks[task_uuid] = threading.Lock()
+ return _log_locks[task_uuid]
+
+
+def _get_batch_lock(batch_id: str) -> threading.Lock:
+ """线程安全地获取或创建批量任务日志锁"""
+ if batch_id not in _batch_locks:
+ with _meta_lock:
+ if batch_id not in _batch_locks:
+ _batch_locks[batch_id] = threading.Lock()
+ return _batch_locks[batch_id]
+
+
+class TaskManager:
+ """任务管理器"""
+
+ def __init__(self):
+ self.executor = _executor
+ self._loop: Optional[asyncio.AbstractEventLoop] = None
+
+ def set_loop(self, loop: asyncio.AbstractEventLoop):
+ """设置事件循环(在 FastAPI 启动时调用)"""
+ self._loop = loop
+
+ def get_loop(self) -> Optional[asyncio.AbstractEventLoop]:
+ """获取事件循环"""
+ return self._loop
+
+ def is_cancelled(self, task_uuid: str) -> bool:
+ """检查任务是否已取消"""
+ return _task_cancelled.get(task_uuid, False)
+
+ def cancel_task(self, task_uuid: str):
+ """取消任务"""
+ _task_cancelled[task_uuid] = True
+ logger.info(f"任务 {task_uuid} 已标记为取消")
+
+ def add_log(self, task_uuid: str, log_message: str):
+ """添加日志并推送到 WebSocket(线程安全)"""
+ # 先广播到 WebSocket,确保实时推送
+ # 然后再添加到队列,这样 get_unsent_logs 不会获取到这条日志
+ if self._loop and self._loop.is_running():
+ try:
+ asyncio.run_coroutine_threadsafe(
+ self._broadcast_log(task_uuid, log_message),
+ self._loop
+ )
+ except Exception as e:
+ logger.warning(f"推送日志到 WebSocket 失败: {e}")
+
+ # 广播后再添加到队列
+ with _get_log_lock(task_uuid):
+ _log_queues[task_uuid].append(log_message)
+
+ async def _broadcast_log(self, task_uuid: str, log_message: str):
+ """广播日志到所有 WebSocket 连接"""
+ with _ws_lock:
+ connections = _ws_connections.get(task_uuid, []).copy()
+ # 注意:不在这里更新 sent_index,因为日志已经通过 add_log 添加到队列
+ # sent_index 应该只在 get_unsent_logs 或发送历史日志时更新
+ # 这样可以避免竞态条件
+
+ for ws in connections:
+ try:
+ await ws.send_json({
+ "type": "log",
+ "task_uuid": task_uuid,
+ "message": log_message,
+ "timestamp": datetime.utcnow().isoformat()
+ })
+ # 发送成功后更新 sent_index
+ with _ws_lock:
+ ws_id = id(ws)
+ if task_uuid in _ws_sent_index and ws_id in _ws_sent_index[task_uuid]:
+ _ws_sent_index[task_uuid][ws_id] += 1
+ except Exception as e:
+ logger.warning(f"WebSocket 发送失败: {e}")
+
+ async def broadcast_status(self, task_uuid: str, status: str, **kwargs):
+ """广播任务状态更新"""
+ with _ws_lock:
+ connections = _ws_connections.get(task_uuid, []).copy()
+
+ message = {
+ "type": "status",
+ "task_uuid": task_uuid,
+ "status": status,
+ "timestamp": datetime.utcnow().isoformat(),
+ **kwargs
+ }
+
+ for ws in connections:
+ try:
+ await ws.send_json(message)
+ except Exception as e:
+ logger.warning(f"WebSocket 发送状态失败: {e}")
+
+ def register_websocket(self, task_uuid: str, websocket):
+ """注册 WebSocket 连接"""
+ with _ws_lock:
+ if task_uuid not in _ws_connections:
+ _ws_connections[task_uuid] = []
+ # 避免重复注册同一个连接
+ if websocket not in _ws_connections[task_uuid]:
+ _ws_connections[task_uuid].append(websocket)
+ # 记录已发送的日志数量,用于发送历史日志时避免重复
+ with _get_log_lock(task_uuid):
+ _ws_sent_index[task_uuid][id(websocket)] = len(_log_queues.get(task_uuid, []))
+ logger.info(f"WebSocket 连接已注册: {task_uuid}")
+ else:
+ logger.warning(f"WebSocket 连接已存在,跳过重复注册: {task_uuid}")
+
+ def get_unsent_logs(self, task_uuid: str, websocket) -> List[str]:
+ """获取未发送给该 WebSocket 的日志"""
+ with _ws_lock:
+ ws_id = id(websocket)
+ sent_count = _ws_sent_index.get(task_uuid, {}).get(ws_id, 0)
+
+ with _get_log_lock(task_uuid):
+ all_logs = _log_queues.get(task_uuid, [])
+ unsent_logs = all_logs[sent_count:]
+ # 更新已发送索引
+ _ws_sent_index[task_uuid][ws_id] = len(all_logs)
+ return unsent_logs
+
+ def unregister_websocket(self, task_uuid: str, websocket):
+ """注销 WebSocket 连接"""
+ with _ws_lock:
+ if task_uuid in _ws_connections:
+ try:
+ _ws_connections[task_uuid].remove(websocket)
+ except ValueError:
+ pass
+ # 清理已发送索引
+ if task_uuid in _ws_sent_index:
+ _ws_sent_index[task_uuid].pop(id(websocket), None)
+ logger.info(f"WebSocket 连接已注销: {task_uuid}")
+
+ def get_logs(self, task_uuid: str) -> List[str]:
+ """获取任务的所有日志"""
+ with _get_log_lock(task_uuid):
+ return _log_queues.get(task_uuid, []).copy()
+
+ def update_status(self, task_uuid: str, status: str, **kwargs):
+ """更新任务状态"""
+ if task_uuid not in _task_status:
+ _task_status[task_uuid] = {}
+
+ _task_status[task_uuid]["status"] = status
+ _task_status[task_uuid].update(kwargs)
+
+ def get_status(self, task_uuid: str) -> Optional[dict]:
+ """获取任务状态"""
+ return _task_status.get(task_uuid)
+
+ def cleanup_task(self, task_uuid: str):
+ """清理任务数据"""
+ # 保留日志队列一段时间,以便后续查询
+ # 只清理取消标志
+ if task_uuid in _task_cancelled:
+ del _task_cancelled[task_uuid]
+
+ # ============== 批量任务管理 ==============
+
+ def init_batch(self, batch_id: str, total: int):
+ """初始化批量任务"""
+ _batch_status[batch_id] = {
+ "status": "running",
+ "total": total,
+ "completed": 0,
+ "success": 0,
+ "failed": 0,
+ "skipped": 0,
+ "current_index": 0,
+ "finished": False
+ }
+ logger.info(f"批量任务 {batch_id} 已初始化,总数: {total}")
+
+ def add_batch_log(self, batch_id: str, log_message: str):
+ """添加批量任务日志并推送"""
+ # 先广播到 WebSocket,确保实时推送
+ if self._loop and self._loop.is_running():
+ try:
+ asyncio.run_coroutine_threadsafe(
+ self._broadcast_batch_log(batch_id, log_message),
+ self._loop
+ )
+ except Exception as e:
+ logger.warning(f"推送批量日志到 WebSocket 失败: {e}")
+
+ # 广播后再添加到队列
+ with _get_batch_lock(batch_id):
+ _batch_logs[batch_id].append(log_message)
+
+ async def _broadcast_batch_log(self, batch_id: str, log_message: str):
+ """广播批量任务日志"""
+ key = f"batch_{batch_id}"
+ with _ws_lock:
+ connections = _ws_connections.get(key, []).copy()
+ # 注意:不在这里更新 sent_index,避免竞态条件
+
+ for ws in connections:
+ try:
+ await ws.send_json({
+ "type": "log",
+ "batch_id": batch_id,
+ "message": log_message,
+ "timestamp": datetime.utcnow().isoformat()
+ })
+ # 发送成功后更新 sent_index
+ with _ws_lock:
+ ws_id = id(ws)
+ if key in _ws_sent_index and ws_id in _ws_sent_index[key]:
+ _ws_sent_index[key][ws_id] += 1
+ except Exception as e:
+ logger.warning(f"WebSocket 发送批量日志失败: {e}")
+
+ def update_batch_status(self, batch_id: str, **kwargs):
+ """更新批量任务状态"""
+ if batch_id not in _batch_status:
+ logger.warning(f"批量任务 {batch_id} 不存在")
+ return
+
+ _batch_status[batch_id].update(kwargs)
+
+ # 异步广播状态更新
+ if self._loop and self._loop.is_running():
+ try:
+ asyncio.run_coroutine_threadsafe(
+ self._broadcast_batch_status(batch_id),
+ self._loop
+ )
+ except Exception as e:
+ logger.warning(f"广播批量状态失败: {e}")
+
+ async def _broadcast_batch_status(self, batch_id: str):
+ """广播批量任务状态"""
+ with _ws_lock:
+ connections = _ws_connections.get(f"batch_{batch_id}", []).copy()
+
+ status = _batch_status.get(batch_id, {})
+
+ for ws in connections:
+ try:
+ await ws.send_json({
+ "type": "status",
+ "batch_id": batch_id,
+ "timestamp": datetime.utcnow().isoformat(),
+ **status
+ })
+ except Exception as e:
+ logger.warning(f"WebSocket 发送批量状态失败: {e}")
+
+ def get_batch_status(self, batch_id: str) -> Optional[dict]:
+ """获取批量任务状态"""
+ return _batch_status.get(batch_id)
+
+ def get_batch_logs(self, batch_id: str) -> List[str]:
+ """获取批量任务日志"""
+ with _get_batch_lock(batch_id):
+ return _batch_logs.get(batch_id, []).copy()
+
+ def is_batch_cancelled(self, batch_id: str) -> bool:
+ """检查批量任务是否已取消"""
+ status = _batch_status.get(batch_id, {})
+ return status.get("cancelled", False)
+
+ def cancel_batch(self, batch_id: str):
+ """取消批量任务"""
+ if batch_id in _batch_status:
+ _batch_status[batch_id]["cancelled"] = True
+ _batch_status[batch_id]["status"] = "cancelling"
+ logger.info(f"批量任务 {batch_id} 已标记为取消")
+
+ def register_batch_websocket(self, batch_id: str, websocket):
+ """注册批量任务 WebSocket 连接"""
+ key = f"batch_{batch_id}"
+ with _ws_lock:
+ if key not in _ws_connections:
+ _ws_connections[key] = []
+ # 避免重复注册同一个连接
+ if websocket not in _ws_connections[key]:
+ _ws_connections[key].append(websocket)
+ # 记录已发送的日志数量,用于发送历史日志时避免重复
+ with _get_batch_lock(batch_id):
+ _ws_sent_index[key][id(websocket)] = len(_batch_logs.get(batch_id, []))
+ logger.info(f"批量任务 WebSocket 连接已注册: {batch_id}")
+ else:
+ logger.warning(f"批量任务 WebSocket 连接已存在,跳过重复注册: {batch_id}")
+
+ def get_unsent_batch_logs(self, batch_id: str, websocket) -> List[str]:
+ """获取未发送给该 WebSocket 的批量任务日志"""
+ key = f"batch_{batch_id}"
+ with _ws_lock:
+ ws_id = id(websocket)
+ sent_count = _ws_sent_index.get(key, {}).get(ws_id, 0)
+
+ with _get_batch_lock(batch_id):
+ all_logs = _batch_logs.get(batch_id, [])
+ unsent_logs = all_logs[sent_count:]
+ # 更新已发送索引
+ _ws_sent_index[key][ws_id] = len(all_logs)
+ return unsent_logs
+
+ def unregister_batch_websocket(self, batch_id: str, websocket):
+ """注销批量任务 WebSocket 连接"""
+ key = f"batch_{batch_id}"
+ with _ws_lock:
+ if key in _ws_connections:
+ try:
+ _ws_connections[key].remove(websocket)
+ except ValueError:
+ pass
+ # 清理已发送索引
+ if key in _ws_sent_index:
+ _ws_sent_index[key].pop(id(websocket), None)
+ logger.info(f"批量任务 WebSocket 连接已注销: {batch_id}")
+
+ def create_log_callback(self, task_uuid: str, prefix: str = "", batch_id: str = "") -> Callable[[str], None]:
+ """创建日志回调函数,可附加任务编号前缀,并同时推送到批量任务频道"""
+ def callback(msg: str):
+ full_msg = f"{prefix} {msg}" if prefix else msg
+ self.add_log(task_uuid, full_msg)
+ # 如果属于批量任务,同步推送到 batch 频道,前端可在混合日志中看到详细步骤
+ if batch_id:
+ self.add_batch_log(batch_id, full_msg)
+ return callback
+
+ def create_check_cancelled_callback(self, task_uuid: str) -> Callable[[], bool]:
+ """创建检查取消的回调函数"""
+ def callback() -> bool:
+ return self.is_cancelled(task_uuid)
+ return callback
+
+
+# 全局实例
+task_manager = TaskManager()
diff --git a/static/css/style.css b/static/css/style.css
new file mode 100644
index 0000000..4c40acc
--- /dev/null
+++ b/static/css/style.css
@@ -0,0 +1,1366 @@
+/*
+ * OpenAI 注册系统 - 主样式表
+ * 现代化、响应式设计,支持暗色模式
+ */
+
+/* CSS 变量 - 亮色主题 */
+:root {
+ /* 主色调 */
+ --primary-color: #10a37f;
+ --primary-hover: #0d8a6a;
+ --primary-light: rgba(16, 163, 127, 0.1);
+ --primary-dark: #0a7d5e;
+
+ /* 语义色 */
+ --danger-color: #ef4444;
+ --danger-hover: #dc2626;
+ --danger-light: rgba(239, 68, 68, 0.1);
+ --warning-color: #f59e0b;
+ --warning-light: rgba(245, 158, 11, 0.1);
+ --success-color: #22c55e;
+ --success-light: rgba(34, 197, 94, 0.1);
+ --info-color: #3b82f6;
+ --info-light: rgba(59, 130, 246, 0.1);
+
+ /* 中性色 */
+ --secondary-color: #6b7280;
+ --background: #f8fafc;
+ --surface: #ffffff;
+ --surface-hover: #f1f5f9;
+ --border: #e2e8f0;
+ --border-light: #f1f5f9;
+
+ /* 文字色 */
+ --text-primary: #0f172a;
+ --text-secondary: #64748b;
+ --text-muted: #94a3b8;
+
+ /* 阴影 */
+ --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
+ --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
+ --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
+ --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
+
+ /* 圆角 */
+ --radius-sm: 4px;
+ --radius: 8px;
+ --radius-lg: 12px;
+ --radius-xl: 16px;
+ --radius-full: 9999px;
+
+ /* 动画 */
+ --transition-fast: 150ms ease;
+ --transition: 200ms ease;
+ --transition-slow: 300ms ease;
+
+ /* 字体 */
+ --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+ --font-mono: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', monospace;
+
+ /* 间距 */
+ --spacing-xs: 4px;
+ --spacing-sm: 8px;
+ --spacing-md: 16px;
+ --spacing-lg: 24px;
+ --spacing-xl: 32px;
+}
+
+/* 暗色主题 */
+[data-theme="dark"] {
+ --primary-color: #34d399;
+ --primary-hover: #6ee7b7;
+ --primary-light: rgba(52, 211, 153, 0.15);
+ --primary-dark: #10b981;
+
+ --danger-color: #f87171;
+ --danger-hover: #fca5a5;
+ --danger-light: rgba(248, 113, 113, 0.15);
+ --warning-color: #fbbf24;
+ --warning-light: rgba(251, 191, 36, 0.15);
+ --success-color: #4ade80;
+ --success-light: rgba(74, 222, 128, 0.15);
+ --info-color: #60a5fa;
+ --info-light: rgba(96, 165, 250, 0.15);
+
+ --secondary-color: #94a3b8;
+ --background: #0f172a;
+ --surface: #1e293b;
+ --surface-hover: #334155;
+ --border: #334155;
+ --border-light: #1e293b;
+
+ --text-primary: #f1f5f9;
+ --text-secondary: #94a3b8;
+ --text-muted: #64748b;
+
+ --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
+ --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.4), 0 1px 2px -1px rgb(0 0 0 / 0.4);
+ --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.4);
+ --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.4);
+}
+
+/* 重置样式 */
+*, *::before, *::after {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+html {
+ font-size: 16px;
+ scroll-behavior: smooth;
+}
+
+body {
+ font-family: var(--font-sans);
+ background-color: var(--background);
+ color: var(--text-primary);
+ line-height: 1.6;
+ min-height: 100vh;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+/* ============================================
+ 布局
+ ============================================ */
+
+.container {
+ max-width: 1280px;
+ margin: 0 auto;
+ padding: 0 var(--spacing-lg);
+}
+
+/* ============================================
+ 导航栏
+ ============================================ */
+
+.navbar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: var(--spacing-lg) 0;
+ background: var(--surface);
+ border-bottom: 1px solid var(--border);
+ margin-bottom: var(--spacing-xl);
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ backdrop-filter: blur(8px);
+}
+
+.nav-brand h1 {
+ font-size: 1.25rem;
+ font-weight: 700;
+ color: var(--primary-color);
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+}
+
+.nav-brand h1::before {
+ content: '';
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ background: var(--primary-color);
+ border-radius: 50%;
+ animation: pulse 2s infinite;
+}
+
+@keyframes pulse {
+ 0%, 100% { opacity: 1; transform: scale(1); }
+ 50% { opacity: 0.5; transform: scale(1.2); }
+}
+
+.nav-links {
+ display: flex;
+ gap: var(--spacing-xs);
+ background: var(--border-light);
+ padding: var(--spacing-xs);
+ border-radius: var(--radius-lg);
+}
+
+.nav-link {
+ text-decoration: none;
+ color: var(--text-secondary);
+ padding: var(--spacing-sm) var(--spacing-md);
+ border-radius: var(--radius);
+ font-size: 0.875rem;
+ font-weight: 500;
+ transition: all var(--transition);
+}
+
+.nav-link:hover {
+ color: var(--text-primary);
+ background: var(--surface);
+}
+
+.nav-link.active {
+ color: var(--primary-color);
+ background: var(--surface);
+ box-shadow: var(--shadow-sm);
+}
+
+/* 主题切换按钮 */
+.theme-toggle {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: var(--spacing-sm);
+ cursor: pointer;
+ color: var(--text-secondary);
+ transition: all var(--transition);
+ margin-left: var(--spacing-md);
+}
+
+.theme-toggle:hover {
+ color: var(--text-primary);
+ background: var(--surface-hover);
+}
+
+/* ============================================
+ 主内容区
+ ============================================ */
+
+.main-content {
+ padding-bottom: 60px;
+}
+
+.page-header {
+ margin-bottom: var(--spacing-xl);
+}
+
+.page-header h2 {
+ font-size: 1.75rem;
+ font-weight: 700;
+ margin-bottom: var(--spacing-xs);
+ letter-spacing: -0.025em;
+}
+
+.subtitle {
+ color: var(--text-secondary);
+ font-size: 0.9375rem;
+}
+
+/* ============================================
+ 卡片
+ ============================================ */
+
+.card {
+ background: var(--surface);
+ border-radius: var(--radius-lg);
+ border: 1px solid var(--border);
+ margin-bottom: var(--spacing-lg);
+ box-shadow: var(--shadow-sm);
+ overflow: hidden;
+ transition: box-shadow var(--transition);
+}
+
+.card:hover {
+ box-shadow: var(--shadow);
+}
+
+.card-header {
+ padding: var(--spacing-md) var(--spacing-lg);
+ border-bottom: 1px solid var(--border);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ background: var(--surface);
+}
+
+.card-header h3 {
+ font-size: 0.9375rem;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.card-body {
+ padding: var(--spacing-lg);
+}
+
+/* 工具栏卡片允许下拉菜单溢出 */
+.card.toolbar-card {
+ overflow: visible;
+}
+
+.card-body.toolbar {
+ overflow: visible;
+}
+
+/* ============================================
+ 表单元素
+ ============================================ */
+
+.form-group {
+ margin-bottom: var(--spacing-md);
+}
+
+.form-group label {
+ display: block;
+ margin-bottom: var(--spacing-xs);
+ font-weight: 500;
+ font-size: 0.8125rem;
+ color: var(--text-secondary);
+}
+
+.form-group input,
+.form-group select,
+.form-group textarea {
+ width: 100%;
+ padding: 10px 14px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ font-size: 0.875rem;
+ font-family: inherit;
+ background: var(--surface);
+ color: var(--text-primary);
+ transition: all var(--transition);
+}
+
+.form-group input:hover,
+.form-group select:hover,
+.form-group textarea:hover {
+ border-color: var(--text-muted);
+}
+
+.form-group input:focus,
+.form-group select:focus,
+.form-group textarea:focus {
+ outline: none;
+ border-color: var(--primary-color);
+ box-shadow: 0 0 0 3px var(--primary-light);
+}
+
+.form-group input::placeholder,
+.form-group textarea::placeholder {
+ color: var(--text-muted);
+}
+
+.form-group textarea {
+ resize: vertical;
+ min-height: 100px;
+}
+
+.form-row {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: var(--spacing-md);
+}
+
+.form-actions {
+ display: flex;
+ gap: var(--spacing-sm);
+ margin-top: var(--spacing-lg);
+ padding-top: var(--spacing-lg);
+ border-top: 1px solid var(--border-light);
+}
+
+/* Checkbox 样式优化 */
+.form-group input[type="checkbox"] {
+ width: auto;
+ margin-right: var(--spacing-sm);
+ accent-color: var(--primary-color);
+}
+
+/* ============================================
+ 按钮
+ ============================================ */
+
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--spacing-sm);
+ padding: 10px 20px;
+ font-size: 0.875rem;
+ font-weight: 500;
+ font-family: inherit;
+ border-radius: var(--radius);
+ border: none;
+ cursor: pointer;
+ transition: all var(--transition);
+ white-space: nowrap;
+}
+
+.btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.btn-primary {
+ background: var(--primary-color);
+ color: white;
+}
+
+.btn-primary:hover:not(:disabled) {
+ background: var(--primary-hover);
+ transform: translateY(-1px);
+ box-shadow: var(--shadow-md);
+}
+
+.btn-primary:active:not(:disabled) {
+ transform: translateY(0);
+}
+
+.btn-secondary {
+ background: var(--surface-hover);
+ color: var(--text-primary);
+ border: 1px solid var(--border);
+}
+
+.btn-secondary:hover:not(:disabled) {
+ background: var(--border);
+ border-color: var(--text-muted);
+}
+
+.btn-danger {
+ background: var(--danger-color);
+ color: white;
+}
+
+.btn-danger:hover:not(:disabled) {
+ background: var(--danger-hover);
+ transform: translateY(-1px);
+}
+
+.btn-warning {
+ background: var(--warning-color);
+ color: white;
+}
+
+.btn-warning:hover:not(:disabled) {
+ background: #d97706;
+}
+
+.btn-ghost {
+ background: transparent;
+ color: var(--text-secondary);
+}
+
+.btn-copy-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 20px;
+ height: 20px;
+ padding: 0;
+ font-size: 11px;
+ line-height: 1;
+ background: var(--surface-hover);
+ border: 1px solid var(--border);
+ border-radius: 50%;
+ cursor: pointer;
+ color: var(--text-muted);
+ flex-shrink: 0;
+ transition: background 0.15s, color 0.15s;
+}
+
+.btn-copy-icon:hover {
+ background: var(--primary-light);
+ color: var(--primary-color);
+ border-color: var(--primary-color);
+}
+
+.btn-ghost:hover:not(:disabled) {
+ background: var(--surface-hover);
+ color: var(--text-primary);
+}
+
+.btn-sm {
+ padding: 6px 12px;
+ font-size: 0.75rem;
+}
+
+.btn-lg {
+ padding: 14px 28px;
+ font-size: 1rem;
+}
+
+.btn-icon {
+ padding: var(--spacing-sm);
+ width: 36px;
+ height: 36px;
+}
+
+/* 加载状态 */
+.btn.loading {
+ position: relative;
+ color: transparent;
+ pointer-events: none;
+}
+
+.btn.loading::after {
+ content: '';
+ position: absolute;
+ width: 16px;
+ height: 16px;
+ border: 2px solid currentColor;
+ border-right-color: transparent;
+ border-radius: 50%;
+ animation: spin 0.75s linear infinite;
+ color: white;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+/* ============================================
+ 控制台日志
+ ============================================ */
+
+.console-log {
+ background: linear-gradient(180deg, #1a1b26 0%, #16161e 100%);
+ color: #a9b1d6;
+ padding: var(--spacing-md);
+ border-radius: var(--radius);
+ font-family: var(--font-mono);
+ font-size: 0.8125rem;
+ height: 320px;
+ overflow-y: auto;
+ line-height: 1.7;
+ position: relative;
+}
+
+.console-log::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 40px;
+ background: linear-gradient(180deg, #1a1b26 0%, transparent 100%);
+ pointer-events: none;
+ z-index: 1;
+}
+
+.log-line {
+ margin-bottom: 2px;
+ white-space: pre-wrap;
+ word-break: break-all;
+ padding: 2px 0;
+ animation: fadeIn 0.2s ease;
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; transform: translateY(-4px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+.log-line.info { color: #7aa2f7; }
+.log-line.success { color: #9ece6a; }
+.log-line.error { color: #f7768e; }
+.log-line.warning { color: #e0af68; }
+.log-line.debug { color: #565f89; }
+
+/* 时间戳样式 */
+.log-line .timestamp {
+ color: #565f89;
+ margin-right: var(--spacing-sm);
+}
+
+/* ============================================
+ 状态徽章
+ ============================================ */
+
+.status-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-xs);
+ padding: 4px 12px;
+ border-radius: var(--radius-full);
+ font-size: 0.75rem;
+ font-weight: 600;
+ letter-spacing: 0.025em;
+}
+
+.status-badge::before {
+ content: '';
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: currentColor;
+}
+
+.status-badge.active,
+.status-badge.running {
+ background: var(--success-light);
+ color: var(--success-color);
+}
+
+.status-badge.completed {
+ background: var(--success-light);
+ color: var(--success-color);
+}
+
+.status-badge.pending,
+.status-badge.waiting {
+ background: var(--info-light);
+ color: var(--info-color);
+}
+
+.status-badge.failed,
+.status-badge.error {
+ background: var(--danger-light);
+ color: var(--danger-color);
+}
+
+.status-badge.expired,
+.status-badge.warning {
+ background: var(--warning-light);
+ color: var(--warning-color);
+}
+
+.status-badge.banned {
+ background: var(--danger-light);
+ color: var(--danger-color);
+}
+
+.status-badge.disabled {
+ background: var(--border);
+ color: var(--text-muted);
+}
+
+/* ============================================
+ 统计卡片
+ ============================================ */
+
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: var(--spacing-md);
+ margin-bottom: var(--spacing-lg);
+}
+
+.stat-card {
+ background: var(--surface);
+ border-radius: var(--radius-lg);
+ border: 1px solid var(--border);
+ padding: var(--spacing-lg);
+ position: relative;
+ overflow: hidden;
+ transition: all var(--transition);
+}
+
+.stat-card::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 3px;
+ background: var(--border);
+}
+
+.stat-card:hover {
+ transform: translateY(-2px);
+ box-shadow: var(--shadow-md);
+}
+
+.stat-card.success::before { background: var(--success-color); }
+.stat-card.warning::before { background: var(--warning-color); }
+.stat-card.danger::before { background: var(--danger-color); }
+.stat-card.info::before { background: var(--info-color); }
+
+.stat-value {
+ font-size: 2rem;
+ font-weight: 700;
+ color: var(--text-primary);
+ line-height: 1.2;
+}
+
+.stat-label {
+ color: var(--text-secondary);
+ font-size: 0.8125rem;
+ margin-top: var(--spacing-xs);
+}
+
+.stat-card.success .stat-value { color: var(--success-color); }
+.stat-card.warning .stat-value { color: var(--warning-color); }
+.stat-card.danger .stat-value { color: var(--danger-color); }
+.stat-card.info .stat-value { color: var(--info-color); }
+
+/* ============================================
+ 数据表格
+ ============================================ */
+
+.table-container {
+ overflow-x: auto;
+ border-radius: var(--radius);
+}
+
+.data-table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.data-table th,
+.data-table td {
+ padding: var(--spacing-md);
+ text-align: left;
+ border-bottom: 1px solid var(--border-light);
+}
+
+.data-table th {
+ font-weight: 600;
+ color: var(--text-secondary);
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ background: var(--surface-hover);
+ position: sticky;
+ top: 0;
+}
+
+.data-table tbody tr {
+ transition: background var(--transition-fast);
+}
+
+.data-table tbody tr:hover {
+ background: var(--surface-hover);
+}
+
+.data-table tbody tr:last-child td {
+ border-bottom: none;
+}
+
+.data-table input[type="checkbox"] {
+ width: 16px;
+ height: 16px;
+ accent-color: var(--primary-color);
+ cursor: pointer;
+}
+
+/* ============================================
+ 工具栏
+ ============================================ */
+
+.toolbar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: var(--spacing-md);
+ overflow: visible;
+}
+
+.toolbar-left,
+.toolbar-right {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+}
+
+.form-select,
+.form-input {
+ padding: 8px 12px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ font-size: 0.875rem;
+ background: var(--surface);
+ color: var(--text-primary);
+ transition: all var(--transition);
+}
+
+.form-select:hover,
+.form-input:hover {
+ border-color: var(--text-muted);
+}
+
+.form-select:focus,
+.form-input:focus {
+ outline: none;
+ border-color: var(--primary-color);
+ box-shadow: 0 0 0 3px var(--primary-light);
+}
+
+/* ============================================
+ 分页
+ ============================================ */
+
+.pagination {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: var(--spacing-md);
+ margin-top: var(--spacing-lg);
+ padding-top: var(--spacing-lg);
+ border-top: 1px solid var(--border-light);
+}
+
+#page-info {
+ color: var(--text-secondary);
+ font-size: 0.875rem;
+ min-width: 100px;
+ text-align: center;
+}
+
+/* ============================================
+ 标签页
+ ============================================ */
+
+.tabs {
+ display: flex;
+ gap: var(--spacing-xs);
+ margin-bottom: var(--spacing-lg);
+ background: var(--surface);
+ padding: var(--spacing-xs);
+ border-radius: var(--radius-lg);
+ border: 1px solid var(--border);
+}
+
+.tab-btn {
+ flex: 1;
+ padding: var(--spacing-sm) var(--spacing-md);
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ font-size: 0.875rem;
+ font-weight: 500;
+ font-family: inherit;
+ color: var(--text-secondary);
+ border-radius: var(--radius);
+ transition: all var(--transition);
+}
+
+.tab-btn:hover {
+ color: var(--text-primary);
+ background: var(--surface-hover);
+}
+
+.tab-btn.active {
+ color: var(--primary-color);
+ background: var(--primary-light);
+}
+
+.tab-content {
+ display: none;
+ animation: fadeIn 0.2s ease;
+}
+
+.tab-content.active {
+ display: block;
+}
+
+/* ============================================
+ 模态框
+ ============================================ */
+
+.modal {
+ display: none;
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.5);
+ backdrop-filter: blur(4px);
+ z-index: 1000;
+ align-items: center;
+ justify-content: center;
+ padding: var(--spacing-lg);
+}
+
+.modal.active {
+ display: flex;
+ animation: fadeIn 0.2s ease;
+}
+
+.modal-content {
+ background: var(--surface);
+ border-radius: var(--radius-xl);
+ max-width: 560px;
+ width: 100%;
+ max-height: 85vh;
+ overflow: hidden;
+ box-shadow: var(--shadow-lg);
+ animation: slideUp 0.3s ease;
+}
+
+@keyframes slideUp {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.modal-header {
+ padding: var(--spacing-lg);
+ border-bottom: 1px solid var(--border);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.modal-header h3 {
+ font-size: 1.125rem;
+ font-weight: 600;
+}
+
+.modal-close {
+ background: transparent;
+ border: none;
+ font-size: 1.5rem;
+ cursor: pointer;
+ color: var(--text-muted);
+ width: 32px;
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: var(--radius);
+ transition: all var(--transition);
+}
+
+.modal-close:hover {
+ color: var(--text-primary);
+ background: var(--surface-hover);
+}
+
+.modal-body {
+ padding: var(--spacing-lg);
+ overflow-y: auto;
+ max-height: calc(85vh - 140px);
+}
+
+/* ============================================
+ 下拉菜单
+ ============================================ */
+
+.dropdown {
+ position: relative;
+ display: inline-block;
+}
+
+.dropdown-menu {
+ display: none;
+ position: absolute;
+ right: 0;
+ top: calc(100% + 4px);
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ min-width: 160px;
+ box-shadow: var(--shadow-lg);
+ z-index: 100;
+ overflow: hidden;
+ animation: fadeIn 0.15s ease;
+}
+
+.dropdown-menu.active {
+ display: block;
+}
+
+.dropdown-item {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+ padding: var(--spacing-sm) var(--spacing-md);
+ color: var(--text-primary);
+ text-decoration: none;
+ font-size: 0.875rem;
+ transition: background var(--transition-fast);
+}
+
+.dropdown-item:hover {
+ background: var(--surface-hover);
+}
+
+.dropdown-item.danger {
+ color: var(--danger-color);
+}
+
+.dropdown-item.danger:hover {
+ background: var(--danger-light);
+}
+
+/* ============================================
+ Toast 通知
+ ============================================ */
+
+.toast-container {
+ position: fixed;
+ top: var(--spacing-lg);
+ right: var(--spacing-lg);
+ z-index: 2000;
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-sm);
+}
+
+.toast {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+ padding: var(--spacing-md) var(--spacing-lg);
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow-lg);
+ font-size: 0.875rem;
+ animation: slideIn 0.3s ease;
+ max-width: 400px;
+}
+
+@keyframes slideIn {
+ from {
+ opacity: 0;
+ transform: translateX(100%);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+
+.toast.success {
+ border-left: 3px solid var(--success-color);
+}
+
+.toast.error {
+ border-left: 3px solid var(--danger-color);
+}
+
+.toast.warning {
+ border-left: 3px solid var(--warning-color);
+}
+
+.toast.info {
+ border-left: 3px solid var(--info-color);
+}
+
+/* ============================================
+ 进度条
+ ============================================ */
+
+.progress-bar-container {
+ background: var(--border);
+ border-radius: var(--radius-full);
+ height: 8px;
+ overflow: hidden;
+ margin-bottom: var(--spacing-md);
+}
+
+.progress-bar {
+ background: linear-gradient(90deg, var(--primary-color), var(--primary-dark));
+ height: 100%;
+ border-radius: var(--radius-full);
+ transition: width 0.3s ease;
+ position: relative;
+}
+
+.progress-bar.indeterminate {
+ background: linear-gradient(90deg,
+ transparent 0%,
+ var(--primary-color) 50%,
+ transparent 100%);
+ animation: indeterminate 1.5s infinite linear;
+}
+
+@keyframes indeterminate {
+ from { transform: translateX(-100%); }
+ to { transform: translateX(100%); }
+}
+
+/* ============================================
+ 空状态
+ ============================================ */
+
+.empty-state {
+ text-align: center;
+ padding: var(--spacing-xl) var(--spacing-lg);
+ color: var(--text-secondary);
+}
+
+.empty-state-icon {
+ font-size: 3rem;
+ margin-bottom: var(--spacing-md);
+ opacity: 0.5;
+}
+
+.empty-state-title {
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: var(--spacing-xs);
+}
+
+.empty-state-description {
+ font-size: 0.875rem;
+ max-width: 300px;
+ margin: 0 auto;
+}
+
+/* ============================================
+ 骨架屏
+ ============================================ */
+
+.skeleton {
+ background: linear-gradient(90deg,
+ var(--border) 25%,
+ var(--surface-hover) 50%,
+ var(--border) 75%);
+ background-size: 200% 100%;
+ animation: skeleton-loading 1.5s infinite;
+ border-radius: var(--radius-sm);
+}
+
+@keyframes skeleton-loading {
+ 0% { background-position: 200% 0; }
+ 100% { background-position: -200% 0; }
+}
+
+.skeleton-text {
+ height: 16px;
+ margin-bottom: var(--spacing-sm);
+}
+
+.skeleton-text:last-child {
+ width: 60%;
+}
+
+/* ============================================
+ 信息网格
+ ============================================ */
+
+.info-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: var(--spacing-md);
+}
+
+.info-item {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.info-item .label {
+ color: var(--text-secondary);
+ font-size: 0.8125rem;
+}
+
+.info-item .value {
+ font-size: 1rem;
+ font-weight: 500;
+ word-break: break-all;
+}
+
+/* ============================================
+ 导入区域
+ ============================================ */
+
+.import-info {
+ background: var(--info-light);
+ border: 1px solid var(--info-color);
+ border-radius: var(--radius);
+ padding: var(--spacing-md);
+ margin-bottom: var(--spacing-md);
+ font-size: 0.875rem;
+}
+
+.import-info p {
+ margin-bottom: var(--spacing-sm);
+}
+
+.import-info ul {
+ margin: var(--spacing-sm) 0;
+ padding-left: var(--spacing-lg);
+}
+
+.import-info li {
+ margin-bottom: var(--spacing-xs);
+}
+
+.import-info code {
+ background: var(--surface);
+ padding: 2px 6px;
+ border-radius: var(--radius-sm);
+ font-family: var(--font-mono);
+ font-size: 0.8125rem;
+}
+
+.import-stats {
+ display: flex;
+ gap: var(--spacing-lg);
+ padding: var(--spacing-md);
+ background: var(--success-light);
+ border-radius: var(--radius);
+}
+
+.import-errors {
+ background: var(--danger-light);
+ border-radius: var(--radius);
+ padding: var(--spacing-md);
+ font-size: 0.8125rem;
+ color: var(--danger-color);
+}
+
+/* ============================================
+ 批量统计
+ ============================================ */
+
+.batch-stats {
+ display: flex;
+ justify-content: space-around;
+ gap: var(--spacing-lg);
+ text-align: center;
+}
+
+.batch-stats span {
+ color: var(--text-secondary);
+ font-size: 0.875rem;
+}
+
+.batch-stats strong {
+ display: block;
+ font-size: 1.5rem;
+ color: var(--text-primary);
+ margin-top: var(--spacing-xs);
+ font-weight: 700;
+}
+
+/* ============================================
+ 响应式设计
+ ============================================ */
+
+@media (max-width: 1024px) {
+ .stats-grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+@media (max-width: 768px) {
+ .container {
+ padding: 0 var(--spacing-md);
+ }
+
+ .navbar {
+ flex-direction: column;
+ gap: var(--spacing-md);
+ padding: var(--spacing-md) 0;
+ }
+
+ .nav-links {
+ width: 100%;
+ justify-content: center;
+ }
+
+ .toolbar {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .toolbar-left,
+ .toolbar-right {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .form-row {
+ grid-template-columns: 1fr;
+ }
+
+ .stats-grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ .info-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .tabs {
+ flex-wrap: wrap;
+ }
+
+ .tab-btn {
+ flex: auto;
+ }
+
+ .batch-stats {
+ flex-direction: column;
+ gap: var(--spacing-md);
+ }
+
+ .modal-content {
+ max-width: 100%;
+ margin: var(--spacing-md);
+ max-height: 90vh;
+ }
+}
+
+@media (max-width: 480px) {
+ .stats-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .stat-card {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+
+ .stat-value {
+ font-size: 1.5rem;
+ }
+
+ .btn {
+ width: 100%;
+ }
+
+ .form-actions {
+ flex-direction: column;
+ }
+}
+
+/* ============================================
+ 滚动条样式
+ ============================================ */
+
+::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--border);
+ border-radius: 3px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--text-muted);
+}
+
+/* ============================================
+ 选择样式
+ ============================================ */
+
+::selection {
+ background: var(--primary-light);
+ color: var(--primary-dark);
+}
+
+/* ============================================
+ 打印样式
+ ============================================ */
+
+@media print {
+ .navbar,
+ .btn,
+ .modal {
+ display: none !important;
+ }
+
+ .card {
+ break-inside: avoid;
+ box-shadow: none;
+ border: 1px solid #ddd;
+ }
+}
diff --git a/static/js/accounts.js b/static/js/accounts.js
new file mode 100644
index 0000000..10f83d4
--- /dev/null
+++ b/static/js/accounts.js
@@ -0,0 +1,1268 @@
+/**
+ * 账号管理页面 JavaScript
+ * 使用 utils.js 中的工具库
+ */
+
+// 状态
+let currentPage = 1;
+let pageSize = 20;
+let totalAccounts = 0;
+let selectedAccounts = new Set();
+let isLoading = false;
+let selectAllPages = false; // 是否选中了全部页
+let currentFilters = { status: '', email_service: '', search: '' }; // 当前筛选条件
+const refreshingAccountIds = new Set();
+let isBatchValidating = false;
+
+// DOM 元素
+const elements = {
+ table: document.getElementById('accounts-table'),
+ totalAccounts: document.getElementById('total-accounts'),
+ activeAccounts: document.getElementById('active-accounts'),
+ expiredAccounts: document.getElementById('expired-accounts'),
+ failedAccounts: document.getElementById('failed-accounts'),
+ filterStatus: document.getElementById('filter-status'),
+ filterService: document.getElementById('filter-service'),
+ searchInput: document.getElementById('search-input'),
+ refreshBtn: document.getElementById('refresh-btn'),
+ batchRefreshBtn: document.getElementById('batch-refresh-btn'),
+ batchValidateBtn: document.getElementById('batch-validate-btn'),
+ batchUploadBtn: document.getElementById('batch-upload-btn'),
+ batchCheckSubBtn: document.getElementById('batch-check-sub-btn'),
+ batchDeleteBtn: document.getElementById('batch-delete-btn'),
+ exportBtn: document.getElementById('export-btn'),
+ exportMenu: document.getElementById('export-menu'),
+ selectAll: document.getElementById('select-all'),
+ prevPage: document.getElementById('prev-page'),
+ nextPage: document.getElementById('next-page'),
+ pageInfo: document.getElementById('page-info'),
+ detailModal: document.getElementById('detail-modal'),
+ modalBody: document.getElementById('modal-body'),
+ closeModal: document.getElementById('close-modal')
+};
+
+// 初始化
+document.addEventListener('DOMContentLoaded', () => {
+ loadStats();
+ loadAccounts();
+ initEventListeners();
+ updateBatchButtons(); // 初始化按钮状态
+ renderSelectAllBanner();
+});
+
+// 事件监听
+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));
+
+ // 快捷键聚焦搜索
+ elements.searchInput.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape') {
+ elements.searchInput.blur();
+ elements.searchInput.value = '';
+ resetSelectAllPages();
+ loadAccounts();
+ }
+ });
+
+ // 刷新
+ elements.refreshBtn.addEventListener('click', () => {
+ loadStats();
+ loadAccounts();
+ toast.info('已刷新');
+ });
+
+ // 批量刷新Token
+ elements.batchRefreshBtn.addEventListener('click', handleBatchRefresh);
+
+ // 批量验证Token
+ elements.batchValidateBtn.addEventListener('click', handleBatchValidate);
+
+ // 批量检测订阅
+ elements.batchCheckSubBtn.addEventListener('click', handleBatchCheckSubscription);
+
+ // 上传下拉菜单
+ const uploadMenu = document.getElementById('upload-menu');
+ elements.batchUploadBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ uploadMenu.classList.toggle('active');
+ });
+ document.getElementById('batch-upload-cpa-item').addEventListener('click', (e) => { e.preventDefault(); uploadMenu.classList.remove('active'); handleBatchUploadCpa(); });
+ document.getElementById('batch-upload-sub2api-item').addEventListener('click', (e) => { e.preventDefault(); uploadMenu.classList.remove('active'); handleBatchUploadSub2Api(); });
+ document.getElementById('batch-upload-tm-item').addEventListener('click', (e) => { e.preventDefault(); uploadMenu.classList.remove('active'); handleBatchUploadTm(); });
+
+ // 批量删除
+ elements.batchDeleteBtn.addEventListener('click', handleBatchDelete);
+
+ // 全选(当前页)
+ elements.selectAll.addEventListener('change', (e) => {
+ const checkboxes = elements.table.querySelectorAll('input[type="checkbox"][data-id]');
+ checkboxes.forEach(cb => {
+ cb.checked = e.target.checked;
+ const id = parseInt(cb.dataset.id);
+ if (e.target.checked) {
+ selectedAccounts.add(id);
+ } else {
+ selectedAccounts.delete(id);
+ }
+ });
+ if (!e.target.checked) {
+ selectAllPages = false;
+ }
+ updateBatchButtons();
+ renderSelectAllBanner();
+ });
+
+ // 分页
+ elements.prevPage.addEventListener('click', () => {
+ if (currentPage > 1 && !isLoading) {
+ currentPage--;
+ loadAccounts();
+ }
+ });
+
+ elements.nextPage.addEventListener('click', () => {
+ const totalPages = Math.ceil(totalAccounts / pageSize);
+ if (currentPage < totalPages && !isLoading) {
+ currentPage++;
+ loadAccounts();
+ }
+ });
+
+ // 导出
+ elements.exportBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ elements.exportMenu.classList.toggle('active');
+ });
+
+ delegate(elements.exportMenu, 'click', '.dropdown-item', (e, target) => {
+ e.preventDefault();
+ const format = target.dataset.format;
+ exportAccounts(format);
+ elements.exportMenu.classList.remove('active');
+ });
+
+ // 关闭模态框
+ elements.closeModal.addEventListener('click', () => {
+ elements.detailModal.classList.remove('active');
+ });
+
+ elements.detailModal.addEventListener('click', (e) => {
+ if (e.target === elements.detailModal) {
+ elements.detailModal.classList.remove('active');
+ }
+ });
+
+ // 点击其他地方关闭下拉菜单
+ document.addEventListener('click', () => {
+ elements.exportMenu.classList.remove('active');
+ uploadMenu.classList.remove('active');
+ document.querySelectorAll('#accounts-table .dropdown-menu.active').forEach(m => m.classList.remove('active'));
+ });
+}
+
+// 加载统计信息
+async function loadStats() {
+ try {
+ const data = await api.get('/accounts/stats/summary');
+
+ elements.totalAccounts.textContent = format.number(data.total || 0);
+ elements.activeAccounts.textContent = format.number(data.by_status?.active || 0);
+ elements.expiredAccounts.textContent = format.number(data.by_status?.expired || 0);
+ elements.failedAccounts.textContent = format.number(data.by_status?.failed || 0);
+
+ // 添加动画效果
+ animateValue(elements.totalAccounts, data.total || 0);
+ } catch (error) {
+ console.error('加载统计信息失败:', error);
+ }
+}
+
+// 数字动画
+function animateValue(element, value) {
+ element.style.transition = 'transform 0.2s ease';
+ element.style.transform = 'scale(1.1)';
+ setTimeout(() => {
+ element.style.transform = 'scale(1)';
+ }, 200);
+}
+
+// 加载账号列表
+async function loadAccounts() {
+ if (isLoading) return;
+ isLoading = true;
+
+ // 显示加载状态
+ elements.table.innerHTML = `
+
+ |
+
+ |
+
+ `;
+
+ // 记录当前筛选条件
+ 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 (currentFilters.status) {
+ params.append('status', currentFilters.status);
+ }
+
+ if (currentFilters.email_service) {
+ params.append('email_service', currentFilters.email_service);
+ }
+
+ if (currentFilters.search) {
+ params.append('search', currentFilters.search);
+ }
+
+ try {
+ const data = await api.get(`/accounts?${params}`);
+ totalAccounts = data.total;
+ renderAccounts(data.accounts);
+ updatePagination();
+ } catch (error) {
+ console.error('加载账号列表失败:', error);
+ elements.table.innerHTML = `
+
+
+
+ ❌
+ 加载失败
+ 请检查网络连接后重试
+
+ |
+
+ `;
+ } finally {
+ isLoading = false;
+ }
+}
+
+// 渲染账号列表
+function renderAccounts(accounts) {
+ if (accounts.length === 0) {
+ elements.table.innerHTML = `
+
+
+
+ 📭
+ 暂无数据
+ 没有找到符合条件的账号记录
+
+ |
+
+ `;
+ return;
+ }
+
+ elements.table.innerHTML = accounts.map(account => `
+
+ |
+
+ |
+ ${account.id} |
+
+
+ ${escapeHtml(account.email)}
+
+
+ |
+
+ ${account.password
+ ? `
+ ${escapeHtml(account.password.substring(0, 4) + '****')}
+
+ `
+ : '-'}
+ |
+ ${getServiceTypeText(account.email_service)} |
+ ${getStatusIcon(account.status)} |
+
+
+ ${account.cpa_uploaded
+ ? `✓`
+ : `-`}
+
+ |
+
+
+ ${account.subscription_type
+ ? `${account.subscription_type}`
+ : `-`}
+
+ |
+ ${format.date(account.last_refresh) || '-'} |
+
+
+ |
+
+ `).join('');
+
+ // 绑定复选框事件
+ elements.table.querySelectorAll('input[type="checkbox"][data-id]').forEach(cb => {
+ cb.addEventListener('change', (e) => {
+ const id = parseInt(e.target.dataset.id);
+ if (e.target.checked) {
+ selectedAccounts.add(id);
+ } else {
+ selectedAccounts.delete(id);
+ selectAllPages = false;
+ }
+ // 同步全选框状态
+ const allChecked = elements.table.querySelectorAll('input[type="checkbox"][data-id]');
+ const checkedCount = elements.table.querySelectorAll('input[type="checkbox"][data-id]:checked').length;
+ elements.selectAll.checked = allChecked.length > 0 && checkedCount === allChecked.length;
+ elements.selectAll.indeterminate = checkedCount > 0 && checkedCount < allChecked.length;
+ updateBatchButtons();
+ renderSelectAllBanner();
+ });
+ });
+
+ // 绑定复制邮箱按钮
+ elements.table.querySelectorAll('.copy-email-btn').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ copyToClipboard(btn.dataset.email);
+ });
+ });
+
+ // 绑定复制密码按钮
+ elements.table.querySelectorAll('.copy-pwd-btn').forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ copyToClipboard(btn.dataset.pwd);
+ });
+ });
+
+ // 渲染后同步全选框状态
+ const allCbs = elements.table.querySelectorAll('input[type="checkbox"][data-id]');
+ const checkedCbs = elements.table.querySelectorAll('input[type="checkbox"][data-id]:checked');
+ elements.selectAll.checked = allCbs.length > 0 && checkedCbs.length === allCbs.length;
+ elements.selectAll.indeterminate = checkedCbs.length > 0 && checkedCbs.length < allCbs.length;
+ renderSelectAllBanner();
+}
+
+// 切换密码显示
+function togglePassword(element, password) {
+ if (element.dataset.revealed === 'true') {
+ element.textContent = password.substring(0, 4) + '****';
+ element.classList.add('password-hidden');
+ element.dataset.revealed = 'false';
+ } else {
+ element.textContent = password;
+ element.classList.remove('password-hidden');
+ element.dataset.revealed = 'true';
+ }
+}
+
+// 更新分页
+function updatePagination() {
+ const totalPages = Math.max(1, Math.ceil(totalAccounts / pageSize));
+
+ elements.prevPage.disabled = currentPage <= 1;
+ elements.nextPage.disabled = currentPage >= totalPages;
+
+ elements.pageInfo.textContent = `第 ${currentPage} 页 / 共 ${totalPages} 页`;
+}
+
+// 重置全选所有页状态
+function resetSelectAllPages() {
+ selectAllPages = false;
+ selectedAccounts.clear();
+ updateBatchButtons();
+ renderSelectAllBanner();
+}
+
+// 构建批量请求体(含 select_all 和筛选参数)
+function buildBatchPayload(extraFields = {}) {
+ if (selectAllPages) {
+ return {
+ ids: [],
+ select_all: true,
+ status_filter: currentFilters.status || null,
+ email_service_filter: currentFilters.email_service || null,
+ search_filter: currentFilters.search || null,
+ ...extraFields
+ };
+ }
+ return { ids: Array.from(selectedAccounts), ...extraFields };
+}
+
+// 获取有效选中数量(select_all 时用总数)
+function getEffectiveCount() {
+ return selectAllPages ? totalAccounts : selectedAccounts.size;
+}
+
+// 渲染全选横幅
+function renderSelectAllBanner() {
+ let banner = document.getElementById('select-all-banner');
+ const totalPages = Math.ceil(totalAccounts / pageSize);
+ const currentPageSize = elements.table.querySelectorAll('input[type="checkbox"][data-id]').length;
+ const checkedOnPage = elements.table.querySelectorAll('input[type="checkbox"][data-id]:checked').length;
+ const allPageSelected = currentPageSize > 0 && checkedOnPage === currentPageSize;
+
+ // 只在全选了当前页且有多页时显示横幅
+ if (!allPageSelected || totalPages <= 1 || totalAccounts <= pageSize) {
+ if (banner) banner.remove();
+ return;
+ }
+
+ if (!banner) {
+ banner = document.createElement('div');
+ banner.id = 'select-all-banner';
+ banner.style.cssText = 'background:var(--primary-light,#e8f0fe);color:var(--primary-color,#1a73e8);padding:8px 16px;text-align:center;font-size:0.875rem;border-bottom:1px solid var(--border-color);';
+ const tableContainer = document.querySelector('.table-container');
+ if (tableContainer) tableContainer.insertAdjacentElement('beforebegin', banner);
+ }
+
+ if (selectAllPages) {
+ banner.innerHTML = `已选中全部 ${totalAccounts} 条记录。`;
+ } else {
+ banner.innerHTML = `当前页已全选 ${checkedOnPage} 条。`;
+ }
+}
+
+// 选中所有页
+function selectAllPagesAction() {
+ selectAllPages = true;
+ updateBatchButtons();
+ renderSelectAllBanner();
+}
+
+// 更新批量操作按钮
+function updateBatchButtons() {
+ const count = getEffectiveCount();
+ elements.batchDeleteBtn.disabled = count === 0;
+ elements.batchRefreshBtn.disabled = count === 0;
+ elements.batchValidateBtn.disabled = count === 0;
+ elements.batchUploadBtn.disabled = count === 0;
+ elements.batchCheckSubBtn.disabled = count === 0;
+ elements.exportBtn.disabled = count === 0;
+
+ elements.batchDeleteBtn.textContent = count > 0 ? `🗑️ 删除 (${count})` : '🗑️ 批量删除';
+ elements.batchRefreshBtn.textContent = count > 0 ? `🔄 刷新 (${count})` : '🔄 刷新Token';
+ elements.batchValidateBtn.textContent = count > 0 ? `✅ 验证 (${count})` : '✅ 验证Token';
+ elements.batchUploadBtn.textContent = count > 0 ? `☁️ 上传 (${count})` : '☁️ 上传';
+ elements.batchCheckSubBtn.textContent = count > 0 ? `🔍 检测 (${count})` : '🔍 检测订阅';
+}
+
+// 刷新单个账号Token
+async function refreshToken(id) {
+ if (refreshingAccountIds.has(id)) {
+ toast.info('该账号正在刷新,请稍候...');
+ return;
+ }
+ refreshingAccountIds.add(id);
+
+ try {
+ toast.info('正在刷新Token...');
+ const result = await api.post(`/accounts/${id}/refresh`);
+
+ if (result.success) {
+ toast.success('Token刷新成功');
+ loadAccounts();
+ } else {
+ toast.error('刷新失败: ' + (result.error || '未知错误'));
+ }
+ } catch (error) {
+ toast.error('刷新失败: ' + error.message);
+ } finally {
+ refreshingAccountIds.delete(id);
+ }
+}
+
+// 批量刷新Token
+async function handleBatchRefresh() {
+ const count = getEffectiveCount();
+ if (count === 0) return;
+
+ const confirmed = await confirm(`确定要刷新选中的 ${count} 个账号的Token吗?`);
+ if (!confirmed) return;
+
+ elements.batchRefreshBtn.disabled = true;
+ elements.batchRefreshBtn.textContent = '刷新中...';
+
+ try {
+ const result = await api.post('/accounts/batch-refresh', buildBatchPayload());
+ toast.success(`成功刷新 ${result.success_count} 个,失败 ${result.failed_count} 个`);
+ loadAccounts();
+ } catch (error) {
+ toast.error('批量刷新失败: ' + error.message);
+ } finally {
+ updateBatchButtons();
+ }
+}
+
+// 批量验证Token
+async function handleBatchValidate() {
+ if (getEffectiveCount() === 0) return;
+ if (isBatchValidating) {
+ toast.info('批量验证进行中,请稍候...');
+ return;
+ }
+
+ isBatchValidating = true;
+
+ elements.batchValidateBtn.disabled = true;
+ elements.batchValidateBtn.textContent = '验证中...';
+
+ try {
+ const result = await api.post('/accounts/batch-validate', buildBatchPayload(), { timeoutMs: 120000 });
+ toast.info(`有效: ${result.valid_count},无效: ${result.invalid_count}`);
+ loadAccounts();
+ } catch (error) {
+ toast.error('批量验证失败: ' + error.message);
+ } finally {
+ isBatchValidating = false;
+ updateBatchButtons();
+ }
+}
+
+// 查看账号详情
+async function viewAccount(id) {
+ try {
+ const account = await api.get(`/accounts/${id}`);
+ const tokens = await api.get(`/accounts/${id}/tokens`);
+
+ elements.modalBody.innerHTML = `
+
+
+ 邮箱
+
+ ${escapeHtml(account.email)}
+
+
+
+
+ 密码
+
+ ${account.password
+ ? `${escapeHtml(account.password)}
+ `
+ : '-'}
+
+
+
+ 邮箱服务
+ ${getServiceTypeText(account.email_service)}
+
+
+ 状态
+
+
+ ${getStatusText('account', account.status)}
+
+
+
+
+ 注册时间
+ ${format.date(account.registered_at)}
+
+
+ 最后刷新
+ ${format.date(account.last_refresh) || '-'}
+
+
+ Account ID
+
+ ${escapeHtml(account.account_id || '-')}
+
+
+
+ Workspace ID
+
+ ${escapeHtml(account.workspace_id || '-')}
+
+
+
+ Client ID
+
+ ${escapeHtml(account.client_id || '-')}
+
+
+
+
Access Token
+
+ ${escapeHtml(tokens.access_token || '-')}
+ ${tokens.access_token ? `` : ''}
+
+
+
+
Refresh Token
+
+ ${escapeHtml(tokens.refresh_token || '-')}
+ ${tokens.refresh_token ? `` : ''}
+
+
+
+
Cookies(支付用)
+
+
+
+
+
+
+
+
+
+ `;
+
+ elements.detailModal.classList.add('active');
+ } catch (error) {
+ toast.error('加载账号详情失败: ' + error.message);
+ }
+}
+
+// 复制邮箱
+function copyEmail(email) {
+ copyToClipboard(email);
+}
+
+// 删除账号
+async function deleteAccount(id, email) {
+ const confirmed = await confirm(`确定要删除账号 ${email} 吗?此操作不可恢复。`);
+ if (!confirmed) return;
+
+ try {
+ await api.delete(`/accounts/${id}`);
+ toast.success('账号已删除');
+ selectedAccounts.delete(id);
+ loadStats();
+ loadAccounts();
+ } catch (error) {
+ toast.error('删除失败: ' + error.message);
+ }
+}
+
+// 批量删除
+async function handleBatchDelete() {
+ const count = getEffectiveCount();
+ if (count === 0) return;
+
+ const confirmed = await confirm(`确定要删除选中的 ${count} 个账号吗?此操作不可恢复。`);
+ if (!confirmed) return;
+
+ try {
+ const result = await api.post('/accounts/batch-delete', buildBatchPayload());
+ toast.success(`成功删除 ${result.deleted_count} 个账号`);
+ selectedAccounts.clear();
+ selectAllPages = false;
+ loadStats();
+ loadAccounts();
+ } catch (error) {
+ toast.error('删除失败: ' + error.message);
+ }
+}
+
+// 导出账号
+async function exportAccounts(format) {
+ const count = getEffectiveCount();
+ if (count === 0) {
+ toast.warning('请先选择要导出的账号');
+ return;
+ }
+
+ toast.info(`正在导出 ${count} 个账号...`);
+
+ try {
+ const response = await fetch('/api/accounts/export/' + format, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(buildBatchPayload())
+ });
+
+ if (!response.ok) {
+ throw new Error(`导出失败: HTTP ${response.status}`);
+ }
+
+ // 获取文件内容
+ const blob = await response.blob();
+
+ // 从 Content-Disposition 获取文件名
+ const disposition = response.headers.get('Content-Disposition');
+ let filename = `accounts_${Date.now()}.${(format === 'cpa' || format === 'sub2api') ? 'json' : format}`;
+ if (disposition) {
+ const match = disposition.match(/filename=(.+)/);
+ if (match) {
+ filename = match[1];
+ }
+ }
+
+ // 创建下载链接
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ window.URL.revokeObjectURL(url);
+ a.remove();
+
+ toast.success('导出成功');
+ } catch (error) {
+ console.error('导出失败:', error);
+ toast.error('导出失败: ' + error.message);
+ }
+}
+
+// HTML 转义
+function escapeHtml(text) {
+ if (!text) return '';
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+}
+
+// ============== CPA 服务选择 ==============
+
+// 弹出 CPA 服务选择框,返回 Promise<{cpa_service_id: number|null}|null>
+// null 表示用户取消,{cpa_service_id: null} 表示使用全局配置
+function selectCpaService() {
+ return new Promise(async (resolve) => {
+ const modal = document.getElementById('cpa-service-modal');
+ const listEl = document.getElementById('cpa-service-list');
+ const closeBtn = document.getElementById('close-cpa-modal');
+ const cancelBtn = document.getElementById('cancel-cpa-modal-btn');
+ const globalBtn = document.getElementById('cpa-use-global-btn');
+
+ // 加载服务列表
+ listEl.innerHTML = '加载中...
';
+ modal.classList.add('active');
+
+ let services = [];
+ try {
+ services = await api.get('/cpa-services?enabled=true');
+ } catch (e) {
+ services = [];
+ }
+
+ if (services.length === 0) {
+ listEl.innerHTML = '暂无已启用的 CPA 服务,将使用全局配置
';
+ } else {
+ listEl.innerHTML = services.map(s => `
+
+
+
${escapeHtml(s.name)}
+
${escapeHtml(s.api_url)}
+
+
选择
+
+ `).join('');
+
+ listEl.querySelectorAll('.cpa-service-item').forEach(item => {
+ item.addEventListener('mouseenter', () => item.style.background = 'var(--surface-hover)');
+ item.addEventListener('mouseleave', () => item.style.background = '');
+ item.addEventListener('click', () => {
+ cleanup();
+ resolve({ cpa_service_id: parseInt(item.dataset.id) });
+ });
+ });
+ }
+
+ function cleanup() {
+ modal.classList.remove('active');
+ closeBtn.removeEventListener('click', onCancel);
+ cancelBtn.removeEventListener('click', onCancel);
+ globalBtn.removeEventListener('click', onGlobal);
+ }
+ function onCancel() { cleanup(); resolve(null); }
+ function onGlobal() { cleanup(); resolve({ cpa_service_id: null }); }
+
+ closeBtn.addEventListener('click', onCancel);
+ cancelBtn.addEventListener('click', onCancel);
+ globalBtn.addEventListener('click', onGlobal);
+ });
+}
+
+// 统一上传入口:弹出目标选择
+async function uploadAccount(id) {
+ const targets = [
+ { label: '☁️ 上传到 CPA', value: 'cpa' },
+ { label: '🔗 上传到 Sub2API', value: 'sub2api' },
+ { label: '🚀 上传到 Team Manager', value: 'tm' },
+ ];
+
+ const choice = await new Promise((resolve) => {
+ const modal = document.createElement('div');
+ modal.className = 'modal active';
+ modal.innerHTML = `
+
+
+
+ ${targets.map(t => `
+
+ `).join('')}
+
+
`;
+ document.body.appendChild(modal);
+ modal.querySelector('#_upload-close').addEventListener('click', () => { modal.remove(); resolve(null); });
+ modal.addEventListener('click', (e) => { if (e.target === modal) { modal.remove(); resolve(null); } });
+ modal.querySelectorAll('button[data-val]').forEach(btn => {
+ btn.addEventListener('click', () => { modal.remove(); resolve(btn.dataset.val); });
+ });
+ });
+
+ if (!choice) return;
+ if (choice === 'cpa') return uploadToCpa(id);
+ if (choice === 'sub2api') return uploadToSub2Api(id);
+ if (choice === 'tm') return uploadToTm(id);
+}
+
+// 上传单个账号到CPA
+async function uploadToCpa(id) {
+ const choice = await selectCpaService();
+ if (choice === null) return; // 用户取消
+
+ try {
+ toast.info('正在上传到CPA...');
+ const payload = {};
+ if (choice.cpa_service_id != null) payload.cpa_service_id = choice.cpa_service_id;
+ const result = await api.post(`/accounts/${id}/upload-cpa`, payload);
+
+ if (result.success) {
+ toast.success('上传成功');
+ loadAccounts();
+ } else {
+ toast.error('上传失败: ' + (result.error || '未知错误'));
+ }
+ } catch (error) {
+ toast.error('上传失败: ' + error.message);
+ }
+}
+
+// 批量上传到CPA
+async function handleBatchUploadCpa() {
+ const count = getEffectiveCount();
+ if (count === 0) return;
+
+ const choice = await selectCpaService();
+ if (choice === null) return; // 用户取消
+
+ const confirmed = await confirm(`确定要将选中的 ${count} 个账号上传到CPA吗?`);
+ if (!confirmed) return;
+
+ elements.batchUploadBtn.disabled = true;
+ elements.batchUploadBtn.textContent = '上传中...';
+
+ try {
+ const payload = buildBatchPayload();
+ if (choice.cpa_service_id != null) payload.cpa_service_id = choice.cpa_service_id;
+ const result = await api.post('/accounts/batch-upload-cpa', payload);
+
+ let message = `成功: ${result.success_count}`;
+ if (result.failed_count > 0) message += `, 失败: ${result.failed_count}`;
+ if (result.skipped_count > 0) message += `, 跳过: ${result.skipped_count}`;
+
+ toast.success(message);
+ loadAccounts();
+ } catch (error) {
+ toast.error('批量上传失败: ' + error.message);
+ } finally {
+ updateBatchButtons();
+ }
+}
+
+// ============== 订阅状态 ==============
+
+// 手动标记订阅类型
+async function markSubscription(id) {
+ const type = prompt('请输入订阅类型 (plus / team / free):', 'plus');
+ if (!type) return;
+ if (!['plus', 'team', 'free'].includes(type.trim().toLowerCase())) {
+ toast.error('无效的订阅类型,请输入 plus、team 或 free');
+ return;
+ }
+ try {
+ await api.post(`/payment/accounts/${id}/mark-subscription`, {
+ subscription_type: type.trim().toLowerCase()
+ });
+ toast.success('订阅状态已更新');
+ loadAccounts();
+ } catch (e) {
+ toast.error('标记失败: ' + e.message);
+ }
+}
+
+// 批量检测订阅状态
+async function handleBatchCheckSubscription() {
+ const count = getEffectiveCount();
+ if (count === 0) return;
+ const confirmed = await confirm(`确定要检测选中的 ${count} 个账号的订阅状态吗?`);
+ if (!confirmed) return;
+
+ elements.batchCheckSubBtn.disabled = true;
+ elements.batchCheckSubBtn.textContent = '检测中...';
+
+ try {
+ const result = await api.post('/payment/accounts/batch-check-subscription', buildBatchPayload());
+ let message = `成功: ${result.success_count}`;
+ if (result.failed_count > 0) message += `, 失败: ${result.failed_count}`;
+ toast.success(message);
+ loadAccounts();
+ } catch (e) {
+ toast.error('批量检测失败: ' + e.message);
+ } finally {
+ updateBatchButtons();
+ }
+}
+
+// ============== Sub2API 上传 ==============
+
+// 弹出 Sub2API 服务选择框,返回 Promise<{service_id: number|null}|null>
+// null 表示用户取消,{service_id: null} 表示自动选择
+function selectSub2ApiService() {
+ return new Promise(async (resolve) => {
+ const modal = document.getElementById('sub2api-service-modal');
+ const listEl = document.getElementById('sub2api-service-list');
+ const closeBtn = document.getElementById('close-sub2api-modal');
+ const cancelBtn = document.getElementById('cancel-sub2api-modal-btn');
+ const autoBtn = document.getElementById('sub2api-use-auto-btn');
+
+ listEl.innerHTML = '加载中...
';
+ modal.classList.add('active');
+
+ let services = [];
+ try {
+ services = await api.get('/sub2api-services?enabled=true');
+ } catch (e) {
+ services = [];
+ }
+
+ if (services.length === 0) {
+ listEl.innerHTML = '暂无已启用的 Sub2API 服务,将自动选择第一个
';
+ } else {
+ listEl.innerHTML = services.map(s => `
+
+
+
${escapeHtml(s.name)}
+
${escapeHtml(s.api_url)}
+
+
选择
+
+ `).join('');
+
+ listEl.querySelectorAll('.sub2api-service-item').forEach(item => {
+ item.addEventListener('mouseenter', () => item.style.background = 'var(--surface-hover)');
+ item.addEventListener('mouseleave', () => item.style.background = '');
+ item.addEventListener('click', () => {
+ cleanup();
+ resolve({ service_id: parseInt(item.dataset.id) });
+ });
+ });
+ }
+
+ function cleanup() {
+ modal.classList.remove('active');
+ closeBtn.removeEventListener('click', onCancel);
+ cancelBtn.removeEventListener('click', onCancel);
+ autoBtn.removeEventListener('click', onAuto);
+ }
+ function onCancel() { cleanup(); resolve(null); }
+ function onAuto() { cleanup(); resolve({ service_id: null }); }
+
+ closeBtn.addEventListener('click', onCancel);
+ cancelBtn.addEventListener('click', onCancel);
+ autoBtn.addEventListener('click', onAuto);
+ });
+}
+
+// 批量上传到 Sub2API
+async function handleBatchUploadSub2Api() {
+ const count = getEffectiveCount();
+ if (count === 0) return;
+
+ const choice = await selectSub2ApiService();
+ if (choice === null) return; // 用户取消
+
+ const confirmed = await confirm(`确定要将选中的 ${count} 个账号上传到 Sub2API 吗?`);
+ if (!confirmed) return;
+
+ elements.batchUploadBtn.disabled = true;
+ elements.batchUploadBtn.textContent = '上传中...';
+
+ try {
+ const payload = buildBatchPayload();
+ if (choice.service_id != null) payload.service_id = choice.service_id;
+ const result = await api.post('/accounts/batch-upload-sub2api', payload);
+
+ let message = `成功: ${result.success_count}`;
+ if (result.failed_count > 0) message += `, 失败: ${result.failed_count}`;
+ if (result.skipped_count > 0) message += `, 跳过: ${result.skipped_count}`;
+
+ toast.success(message);
+ loadAccounts();
+ } catch (error) {
+ toast.error('批量上传失败: ' + error.message);
+ } finally {
+ updateBatchButtons();
+ }
+}
+
+// ============== Team Manager 上传 ==============
+
+// 上传单账号到 Sub2API
+async function uploadToSub2Api(id) {
+ const choice = await selectSub2ApiService();
+ if (choice === null) return;
+ try {
+ toast.info('正在上传到 Sub2API...');
+ const payload = {};
+ if (choice.service_id != null) payload.service_id = choice.service_id;
+ const result = await api.post(`/accounts/${id}/upload-sub2api`, payload);
+ if (result.success) {
+ toast.success('上传成功');
+ loadAccounts();
+ } else {
+ toast.error('上传失败: ' + (result.error || result.message || '未知错误'));
+ }
+ } catch (e) {
+ toast.error('上传失败: ' + e.message);
+ }
+}
+
+// 弹出 Team Manager 服务选择框,返回 Promise<{service_id: number|null}|null>
+// null 表示用户取消,{service_id: null} 表示自动选择
+function selectTmService() {
+ return new Promise(async (resolve) => {
+ const modal = document.getElementById('tm-service-modal');
+ const listEl = document.getElementById('tm-service-list');
+ const closeBtn = document.getElementById('close-tm-modal');
+ const cancelBtn = document.getElementById('cancel-tm-modal-btn');
+ const autoBtn = document.getElementById('tm-use-auto-btn');
+
+ listEl.innerHTML = '加载中...
';
+ modal.classList.add('active');
+
+ let services = [];
+ try {
+ services = await api.get('/tm-services?enabled=true');
+ } catch (e) {
+ services = [];
+ }
+
+ if (services.length === 0) {
+ listEl.innerHTML = '暂无已启用的 Team Manager 服务,将自动选择第一个
';
+ } else {
+ listEl.innerHTML = services.map(s => `
+
+
+
${escapeHtml(s.name)}
+
${escapeHtml(s.api_url)}
+
+
选择
+
+ `).join('');
+
+ listEl.querySelectorAll('.tm-service-item').forEach(item => {
+ item.addEventListener('mouseenter', () => item.style.background = 'var(--surface-hover)');
+ item.addEventListener('mouseleave', () => item.style.background = '');
+ item.addEventListener('click', () => {
+ cleanup();
+ resolve({ service_id: parseInt(item.dataset.id) });
+ });
+ });
+ }
+
+ function cleanup() {
+ modal.classList.remove('active');
+ closeBtn.removeEventListener('click', onCancel);
+ cancelBtn.removeEventListener('click', onCancel);
+ autoBtn.removeEventListener('click', onAuto);
+ }
+ function onCancel() { cleanup(); resolve(null); }
+ function onAuto() { cleanup(); resolve({ service_id: null }); }
+
+ closeBtn.addEventListener('click', onCancel);
+ cancelBtn.addEventListener('click', onCancel);
+ autoBtn.addEventListener('click', onAuto);
+ });
+}
+
+// 上传单账号到 Team Manager
+async function uploadToTm(id) {
+ const choice = await selectTmService();
+ if (choice === null) return;
+ try {
+ toast.info('正在上传到 Team Manager...');
+ const payload = {};
+ if (choice.service_id != null) payload.service_id = choice.service_id;
+ const result = await api.post(`/accounts/${id}/upload-tm`, payload);
+ if (result.success) {
+ toast.success('上传成功');
+ } else {
+ toast.error('上传失败: ' + (result.message || '未知错误'));
+ }
+ } catch (e) {
+ toast.error('上传失败: ' + e.message);
+ }
+}
+
+// 批量上传到 Team Manager
+async function handleBatchUploadTm() {
+ const count = getEffectiveCount();
+ if (count === 0) return;
+
+ const choice = await selectTmService();
+ if (choice === null) return; // 用户取消
+
+ const confirmed = await confirm(`确定要将选中的 ${count} 个账号上传到 Team Manager 吗?`);
+ if (!confirmed) return;
+
+ elements.batchUploadBtn.disabled = true;
+ elements.batchUploadBtn.textContent = '上传中...';
+
+ try {
+ const payload = buildBatchPayload();
+ if (choice.service_id != null) payload.service_id = choice.service_id;
+ const result = await api.post('/accounts/batch-upload-tm', payload);
+ let message = `成功: ${result.success_count}`;
+ if (result.failed_count > 0) message += `, 失败: ${result.failed_count}`;
+ if (result.skipped_count > 0) message += `, 跳过: ${result.skipped_count}`;
+ toast.success(message);
+ loadAccounts();
+ } catch (e) {
+ toast.error('批量上传失败: ' + e.message);
+ } finally {
+ updateBatchButtons();
+ }
+}
+
+// 更多菜单切换
+function toggleMoreMenu(btn) {
+ const menu = btn.nextElementSibling;
+ const isActive = menu.classList.contains('active');
+ // 关闭所有其他更多菜单
+ document.querySelectorAll('.dropdown-menu.active').forEach(m => m.classList.remove('active'));
+ if (!isActive) menu.classList.add('active');
+}
+
+function closeMoreMenu(el) {
+ const menu = el.closest('.dropdown-menu');
+ if (menu) menu.classList.remove('active');
+}
+
+// 保存账号 Cookies
+async function saveCookies(id) {
+ const textarea = document.getElementById(`cookies-input-${id}`);
+ if (!textarea) return;
+ const cookiesValue = textarea.value.trim();
+ try {
+ await api.patch(`/accounts/${id}`, { cookies: cookiesValue });
+ toast.success('Cookies 已保存');
+ } catch (e) {
+ toast.error('保存 Cookies 失败: ' + e.message);
+ }
+}
+
+// 查询收件箱验证码
+async function checkInboxCode(id) {
+ toast.info('正在查询收件箱...');
+ try {
+ const result = await api.post(`/accounts/${id}/inbox-code`);
+ if (result.success) {
+ showInboxCodeResult(result.code, result.email);
+ } else {
+ toast.error('查询失败: ' + (result.error || '未收到验证码'));
+ }
+ } catch (error) {
+ toast.error('查询失败: ' + error.message);
+ }
+}
+
+function showInboxCodeResult(code, email) {
+ elements.modalBody.innerHTML = `
+
+
+ ${escapeHtml(email)} 最新验证码
+
+
+ ${escapeHtml(code)}
+
+
+
+ `;
+ elements.detailModal.classList.add('active');
+}
diff --git a/static/js/app.js b/static/js/app.js
new file mode 100644
index 0000000..66db746
--- /dev/null
+++ b/static/js/app.js
@@ -0,0 +1,1673 @@
+/**
+ * 注册页面 JavaScript
+ * 使用 utils.js 中的工具库
+ */
+
+// 状态
+let currentTask = null;
+let currentBatch = null;
+let logPollingInterval = null;
+let batchPollingInterval = null;
+let accountsPollingInterval = null;
+let isBatchMode = false;
+let isOutlookBatchMode = false;
+let outlookAccounts = [];
+let taskCompleted = false; // 标记任务是否已完成
+let batchCompleted = false; // 标记批量任务是否已完成
+let taskFinalStatus = null; // 保存任务的最终状态
+let batchFinalStatus = null; // 保存批量任务的最终状态
+let displayedLogs = new Set(); // 用于日志去重
+let toastShown = false; // 标记是否已显示过 toast
+let availableServices = {
+ tempmail: { available: true, services: [] },
+ outlook: { available: false, services: [] },
+ moe_mail: { available: false, services: [] },
+ temp_mail: { available: false, services: [] },
+ duck_mail: { available: false, services: [] },
+ freemail: { available: false, services: [] }
+};
+
+// WebSocket 相关变量
+let webSocket = null;
+let batchWebSocket = null; // 批量任务 WebSocket
+let useWebSocket = true; // 是否使用 WebSocket
+let wsHeartbeatInterval = null; // 心跳定时器
+let batchWsHeartbeatInterval = null; // 批量任务心跳定时器
+let activeTaskUuid = null; // 当前活跃的单任务 UUID(用于页面重新可见时重连)
+let activeBatchId = null; // 当前活跃的批量任务 ID(用于页面重新可见时重连)
+
+// DOM 元素
+const elements = {
+ form: document.getElementById('registration-form'),
+ emailService: document.getElementById('email-service'),
+ regMode: document.getElementById('reg-mode'),
+ regModeGroup: document.getElementById('reg-mode-group'),
+ batchCountGroup: document.getElementById('batch-count-group'),
+ batchCount: document.getElementById('batch-count'),
+ batchOptions: document.getElementById('batch-options'),
+ intervalMin: document.getElementById('interval-min'),
+ intervalMax: document.getElementById('interval-max'),
+ startBtn: document.getElementById('start-btn'),
+ cancelBtn: document.getElementById('cancel-btn'),
+ taskStatusRow: document.getElementById('task-status-row'),
+ batchProgressSection: document.getElementById('batch-progress-section'),
+ consoleLog: document.getElementById('console-log'),
+ clearLogBtn: document.getElementById('clear-log-btn'),
+ // 任务状态
+ taskId: document.getElementById('task-id'),
+ taskEmail: document.getElementById('task-email'),
+ taskStatus: document.getElementById('task-status'),
+ taskService: document.getElementById('task-service'),
+ taskStatusBadge: document.getElementById('task-status-badge'),
+ // 批量状态
+ batchProgressText: document.getElementById('batch-progress-text'),
+ batchProgressPercent: document.getElementById('batch-progress-percent'),
+ progressBar: document.getElementById('progress-bar'),
+ batchSuccess: document.getElementById('batch-success'),
+ batchFailed: document.getElementById('batch-failed'),
+ batchRemaining: document.getElementById('batch-remaining'),
+ // 已注册账号
+ recentAccountsTable: document.getElementById('recent-accounts-table'),
+ refreshAccountsBtn: document.getElementById('refresh-accounts-btn'),
+ // Outlook 批量注册
+ outlookBatchSection: document.getElementById('outlook-batch-section'),
+ outlookAccountsContainer: document.getElementById('outlook-accounts-container'),
+ outlookIntervalMin: document.getElementById('outlook-interval-min'),
+ outlookIntervalMax: document.getElementById('outlook-interval-max'),
+ outlookSkipRegistered: document.getElementById('outlook-skip-registered'),
+ outlookConcurrencyMode: document.getElementById('outlook-concurrency-mode'),
+ outlookConcurrencyCount: document.getElementById('outlook-concurrency-count'),
+ outlookConcurrencyHint: document.getElementById('outlook-concurrency-hint'),
+ outlookIntervalGroup: document.getElementById('outlook-interval-group'),
+ // 批量并发控件
+ concurrencyMode: document.getElementById('concurrency-mode'),
+ concurrencyCount: document.getElementById('concurrency-count'),
+ concurrencyHint: document.getElementById('concurrency-hint'),
+ intervalGroup: document.getElementById('interval-group'),
+ // 注册后自动操作
+ autoUploadCpa: document.getElementById('auto-upload-cpa'),
+ cpaServiceSelectGroup: document.getElementById('cpa-service-select-group'),
+ cpaServiceSelect: document.getElementById('cpa-service-select'),
+ autoUploadSub2api: document.getElementById('auto-upload-sub2api'),
+ sub2apiServiceSelectGroup: document.getElementById('sub2api-service-select-group'),
+ sub2apiServiceSelect: document.getElementById('sub2api-service-select'),
+ // Sub2API 高级配置
+ sub2apiAdvancedGroup: document.getElementById('sub2api-advanced-group'),
+ sub2apiGroupSelect: document.getElementById('sub2api-group-select'),
+ sub2apiProxySelect: document.getElementById('sub2api-proxy-select'),
+ sub2apiModelCheckboxes: document.getElementById('sub2api-model-checkboxes'),
+ sub2apiCustomModels: document.getElementById('sub2api-custom-models'),
+ sub2apiAddModelBtn: document.getElementById('sub2api-add-model-btn'),
+ sub2apiCustomModelTags: document.getElementById('sub2api-custom-model-tags'),
+ autoUploadTm: document.getElementById('auto-upload-tm'),
+ tmServiceSelectGroup: document.getElementById('tm-service-select-group'),
+ tmServiceSelect: document.getElementById('tm-service-select'),
+};
+
+// 初始化
+document.addEventListener('DOMContentLoaded', () => {
+ initEventListeners();
+ loadAvailableServices();
+ loadRecentAccounts();
+ startAccountsPolling();
+ initVisibilityReconnect();
+ restoreActiveTask();
+ initAutoUploadOptions();
+ initSub2apiAdvancedOptions();
+});
+
+// 初始化注册后自动操作选项(CPA / Sub2API / TM)
+async function initAutoUploadOptions() {
+ await Promise.all([
+ loadServiceSelect('/cpa-services?enabled=true', elements.cpaServiceSelect, elements.autoUploadCpa, elements.cpaServiceSelectGroup),
+ loadServiceSelect('/sub2api-services?enabled=true', elements.sub2apiServiceSelect, elements.autoUploadSub2api, elements.sub2apiServiceSelectGroup),
+ loadServiceSelect('/tm-services?enabled=true', elements.tmServiceSelect, elements.autoUploadTm, elements.tmServiceSelectGroup),
+ ]);
+}
+
+// Sub2API 默认模型列表
+const SUB2API_DEFAULT_MODELS = [
+ 'gpt-5.1', 'gpt-5.1-codex', 'gpt-5.1-codex-max', 'gpt-5.1-codex-mini',
+ 'gpt-5.2', 'gpt-5.2-codex', 'gpt-5.3', 'gpt-5.3-codex', 'gpt-5.4'
+];
+
+// 初始化 Sub2API 高级配置(分组、代理、模型)
+function initSub2apiAdvancedOptions() {
+ if (!elements.autoUploadSub2api) return;
+
+ // 渲染默认模型复选框
+ if (elements.sub2apiModelCheckboxes) {
+ elements.sub2apiModelCheckboxes.innerHTML = SUB2API_DEFAULT_MODELS.map(m =>
+ ''
+ ).join('');
+ }
+
+ // 监听 Sub2API 勾选:控制高级配置区显示
+ elements.autoUploadSub2api.addEventListener('change', () => {
+ const show = elements.autoUploadSub2api.checked;
+ if (elements.sub2apiAdvancedGroup) elements.sub2apiAdvancedGroup.style.display = show ? 'block' : 'none';
+ if (show) loadSub2apiRemoteOptions();
+ });
+
+ // "添加"按钮事件:将自定义模型添加为标签
+ if (elements.sub2apiAddModelBtn) {
+ elements.sub2apiAddModelBtn.addEventListener('click', function() {
+ addCustomSub2apiModel();
+ });
+ }
+ // 输入框回车也可添加
+ if (elements.sub2apiCustomModels) {
+ elements.sub2apiCustomModels.addEventListener('keydown', function(e) {
+ if (e.key === 'Enter') { e.preventDefault(); addCustomSub2apiModel(); }
+ });
+ }
+
+ // 监听服务多选下拉中 checkbox 变化
+ if (elements.sub2apiServiceSelect) {
+ elements.sub2apiServiceSelect.addEventListener('change', (e) => {
+ if (e.target.matches('.msd-item input') && elements.autoUploadSub2api.checked) {
+ loadSub2apiRemoteOptions();
+ }
+ });
+ }
+}
+
+// 加载 Sub2API 远程分组和代理列表
+async function loadSub2apiRemoteOptions() {
+ const serviceIds = getSelectedServiceIds(elements.sub2apiServiceSelect);
+ const serviceId = serviceIds.length > 0 ? serviceIds[0] : null;
+ if (!serviceId) {
+ if (elements.sub2apiGroupSelect) elements.sub2apiGroupSelect.innerHTML = '';
+ if (elements.sub2apiProxySelect) elements.sub2apiProxySelect.innerHTML = '';
+ return;
+ }
+
+ // 并行加载分组和代理
+ try {
+ const [groups, proxies] = await Promise.all([
+ api.get('/sub2api-services/' + serviceId + '/groups').catch(() => []),
+ api.get('/sub2api-services/' + serviceId + '/proxies').catch(() => []),
+ ]);
+
+ // 渲染分组选择
+ if (elements.sub2apiGroupSelect) {
+ if (groups.length === 0) {
+ elements.sub2apiGroupSelect.innerHTML = '';
+ } else {
+ elements.sub2apiGroupSelect.innerHTML = groups.map(g =>
+ ''
+ ).join('');
+ }
+ }
+
+ // 渲染代理选择
+ if (elements.sub2apiProxySelect) {
+ var opts = '';
+ proxies.forEach(function(p) {
+ var info = [p.protocol, p.country, p.ip_address].filter(Boolean).join(' | ');
+ opts += '';
+ });
+ elements.sub2apiProxySelect.innerHTML = opts;
+ }
+ } catch (e) {
+ console.error('加载 Sub2API 远程配置失败:', e);
+ }
+}
+
+// 添加自定义模型标签
+function addCustomSub2apiModel() {
+ var input = elements.sub2apiCustomModels;
+ if (!input) return;
+ var names = input.value.trim().split(',').map(function(s) { return s.trim(); }).filter(Boolean);
+ if (names.length === 0) return;
+ names.forEach(function(name) {
+ // 检查是否已存在于预置复选框中
+ var existing = document.querySelector('.sub2api-model-cb[value="' + name + '"]');
+ if (existing) { existing.checked = true; return; }
+ // 检查是否已在自定义标签中
+ if (document.querySelector('.sub2api-custom-tag[data-model="' + name + '"]')) return;
+ // 创建标签
+ var tag = document.createElement('span');
+ tag.className = 'sub2api-custom-tag';
+ tag.setAttribute('data-model', name);
+ tag.style.cssText = 'display:inline-flex;align-items:center;gap:3px;padding:2px 8px;background:var(--primary-bg,#e8f0fe);border:1px solid var(--primary-color,#4a90d9);border-radius:12px;font-size:0.8em;color:var(--primary-color,#4a90d9);';
+ tag.innerHTML = name + ' ×';
+ elements.sub2apiCustomModelTags.appendChild(tag);
+ });
+ input.value = '';
+}
+
+// 移除自定义模型标签
+function removeCustomSub2apiModel(closeBtn) {
+ var tag = closeBtn.parentElement;
+ if (tag) tag.remove();
+}
+
+// 获取 Sub2API 高级配置
+function getSub2apiAdvancedConfig() {
+ var config = {};
+
+ // 分组 IDs
+ if (elements.sub2apiGroupSelect) {
+ var selected = Array.from(elements.sub2apiGroupSelect.selectedOptions).map(function(o) { return parseInt(o.value); }).filter(function(v) { return !isNaN(v); });
+ if (selected.length > 0) config.sub2api_group_ids = selected;
+ }
+
+ // 代理节点 ID
+ if (elements.sub2apiProxySelect && elements.sub2apiProxySelect.value) {
+ config.sub2api_proxy_id = parseInt(elements.sub2apiProxySelect.value);
+ }
+
+ // 模型映射
+ var checkedModels = Array.from(document.querySelectorAll('.sub2api-model-cb:checked')).map(function(cb) { return cb.value; });
+ // 从自定义标签收集模型
+ var customModels = [];
+ if (elements.sub2apiCustomModelTags) {
+ var tags = elements.sub2apiCustomModelTags.querySelectorAll('.sub2api-custom-tag');
+ tags.forEach(function(tag) { customModels.push(tag.getAttribute('data-model')); });
+ }
+ var allModels = [];
+ var seen = {};
+ checkedModels.concat(customModels).forEach(function(m) {
+ if (!seen[m]) { seen[m] = true; allModels.push(m); }
+ });
+ if (allModels.length > 0) {
+ var mapping = {};
+ allModels.forEach(function(m) { mapping[m] = m; });
+ config.sub2api_model_mapping = mapping;
+ }
+
+ return config;
+}
+
+// 通用:构建自定义多选下拉组件并处理联动
+async function loadServiceSelect(apiPath, container, checkbox, selectGroup) {
+ if (!checkbox || !container) return;
+ let services = [];
+ try {
+ services = await api.get(apiPath);
+ } catch (e) {}
+
+ if (!services || services.length === 0) {
+ checkbox.disabled = true;
+ checkbox.title = '请先在设置中添加对应服务';
+ const label = checkbox.closest('label');
+ if (label) label.style.opacity = '0.5';
+ container.innerHTML = '暂无可用服务
';
+ } else {
+ const items = services.map(s =>
+ ``
+ ).join('');
+ container.innerHTML = `
+
+
+ 全部 (${services.length})
+ ▼
+
+
${items}
+
`;
+ // 监听 checkbox 变化,更新触发器文字
+ container.querySelectorAll('.msd-item input').forEach(cb => {
+ cb.addEventListener('change', () => updateMsdLabel(container.id + '-dd'));
+ });
+ // 点击外部关闭
+ document.addEventListener('click', (e) => {
+ const dd = document.getElementById(container.id + '-dd');
+ if (dd && !dd.contains(e.target)) dd.classList.remove('open');
+ }, true);
+ }
+
+ // 联动显示/隐藏服务选择区
+ checkbox.addEventListener('change', () => {
+ if (selectGroup) selectGroup.style.display = checkbox.checked ? 'block' : 'none';
+ });
+}
+
+function toggleMsd(ddId) {
+ const dd = document.getElementById(ddId);
+ if (dd) dd.classList.toggle('open');
+}
+
+function updateMsdLabel(ddId) {
+ const dd = document.getElementById(ddId);
+ if (!dd) return;
+ const all = dd.querySelectorAll('.msd-item input');
+ const checked = dd.querySelectorAll('.msd-item input:checked');
+ const label = dd.querySelector('.msd-label');
+ if (!label) return;
+ if (checked.length === 0) label.textContent = '未选择';
+ else if (checked.length === all.length) label.textContent = `全部 (${all.length})`;
+ else label.textContent = Array.from(checked).map(c => c.nextElementSibling.textContent).join(', ');
+}
+
+// 获取自定义多选下拉中选中的服务 ID 列表
+function getSelectedServiceIds(container) {
+ if (!container) return [];
+ return Array.from(container.querySelectorAll('.msd-item input:checked')).map(cb => parseInt(cb.value));
+}
+
+// 事件监听
+function initEventListeners() {
+ // 注册表单提交
+ elements.form.addEventListener('submit', handleStartRegistration);
+
+ // 注册模式切换
+ elements.regMode.addEventListener('change', handleModeChange);
+
+ // 邮箱服务切换
+ elements.emailService.addEventListener('change', handleServiceChange);
+
+ // 取消按钮
+ elements.cancelBtn.addEventListener('click', handleCancelTask);
+
+ // 清空日志
+ elements.clearLogBtn.addEventListener('click', () => {
+ elements.consoleLog.innerHTML = '[系统] 日志已清空
';
+ displayedLogs.clear(); // 清空日志去重集合
+ });
+
+ // 刷新账号列表
+ elements.refreshAccountsBtn.addEventListener('click', () => {
+ loadRecentAccounts();
+ toast.info('已刷新');
+ });
+
+ // 并发模式切换
+ elements.concurrencyMode.addEventListener('change', () => {
+ handleConcurrencyModeChange(elements.concurrencyMode, elements.concurrencyHint, elements.intervalGroup);
+ });
+ elements.outlookConcurrencyMode.addEventListener('change', () => {
+ handleConcurrencyModeChange(elements.outlookConcurrencyMode, elements.outlookConcurrencyHint, elements.outlookIntervalGroup);
+ });
+}
+
+// 加载可用的邮箱服务
+async function loadAvailableServices() {
+ try {
+ const data = await api.get('/registration/available-services');
+ availableServices = data;
+
+ // 更新邮箱服务选择框
+ updateEmailServiceOptions();
+
+ addLog('info', '[系统] 邮箱服务列表已加载');
+ } catch (error) {
+ console.error('加载邮箱服务列表失败:', error);
+ addLog('warning', '[警告] 加载邮箱服务列表失败');
+ }
+}
+
+// 更新邮箱服务选择框
+function updateEmailServiceOptions() {
+ const select = elements.emailService;
+ select.innerHTML = '';
+
+ // Tempmail
+ if (availableServices.tempmail.available) {
+ const optgroup = document.createElement('optgroup');
+ optgroup.label = '🌐 临时邮箱';
+
+ availableServices.tempmail.services.forEach(service => {
+ const option = document.createElement('option');
+ option.value = `tempmail:${service.id || 'default'}`;
+ option.textContent = service.name;
+ option.dataset.type = 'tempmail';
+ optgroup.appendChild(option);
+ });
+
+ select.appendChild(optgroup);
+ }
+
+ // Outlook
+ if (availableServices.outlook.available) {
+ const optgroup = document.createElement('optgroup');
+ optgroup.label = `📧 Outlook (${availableServices.outlook.count} 个账户)`;
+
+ availableServices.outlook.services.forEach(service => {
+ const option = document.createElement('option');
+ option.value = `outlook:${service.id}`;
+ option.textContent = service.name + (service.has_oauth ? ' (OAuth)' : '');
+ option.dataset.type = 'outlook';
+ option.dataset.serviceId = service.id;
+ optgroup.appendChild(option);
+ });
+
+ select.appendChild(optgroup);
+
+ // Outlook 批量注册选项
+ const batchOption = document.createElement('option');
+ batchOption.value = 'outlook_batch:all';
+ batchOption.textContent = `📋 Outlook 批量注册 (${availableServices.outlook.count} 个账户)`;
+ batchOption.dataset.type = 'outlook_batch';
+ optgroup.appendChild(batchOption);
+ } else {
+ const optgroup = document.createElement('optgroup');
+ optgroup.label = '📧 Outlook (未配置)';
+
+ const option = document.createElement('option');
+ option.value = '';
+ option.textContent = '请先在邮箱服务页面导入账户';
+ option.disabled = true;
+ optgroup.appendChild(option);
+
+ select.appendChild(optgroup);
+ }
+
+ // 自定义域名
+ if (availableServices.moe_mail.available) {
+ const optgroup = document.createElement('optgroup');
+ optgroup.label = `🔗 自定义域名 (${availableServices.moe_mail.count} 个服务)`;
+
+ availableServices.moe_mail.services.forEach(service => {
+ const option = document.createElement('option');
+ option.value = `moe_mail:${service.id || 'default'}`;
+ option.textContent = service.name + (service.default_domain ? ` (@${service.default_domain})` : '');
+ option.dataset.type = 'moe_mail';
+ if (service.id) {
+ option.dataset.serviceId = service.id;
+ }
+ optgroup.appendChild(option);
+ });
+
+ select.appendChild(optgroup);
+ } else {
+ const optgroup = document.createElement('optgroup');
+ optgroup.label = '🔗 自定义域名 (未配置)';
+
+ const option = document.createElement('option');
+ option.value = '';
+ option.textContent = '请先在邮箱服务页面添加服务';
+ option.disabled = true;
+ optgroup.appendChild(option);
+
+ select.appendChild(optgroup);
+ }
+
+ // Temp-Mail(自部署)
+ if (availableServices.temp_mail && availableServices.temp_mail.available) {
+ const optgroup = document.createElement('optgroup');
+ optgroup.label = `📮 Temp-Mail 自部署 (${availableServices.temp_mail.count} 个服务)`;
+
+ availableServices.temp_mail.services.forEach(service => {
+ const option = document.createElement('option');
+ option.value = `temp_mail:${service.id}`;
+ option.textContent = service.name + (service.domain ? ` (@${service.domain})` : '');
+ option.dataset.type = 'temp_mail';
+ option.dataset.serviceId = service.id;
+ optgroup.appendChild(option);
+ });
+
+ select.appendChild(optgroup);
+ }
+
+ // DuckMail
+ if (availableServices.duck_mail && availableServices.duck_mail.available) {
+ const optgroup = document.createElement('optgroup');
+ optgroup.label = `🦆 DuckMail (${availableServices.duck_mail.count} 个服务)`;
+
+ availableServices.duck_mail.services.forEach(service => {
+ const option = document.createElement('option');
+ option.value = `duck_mail:${service.id}`;
+ option.textContent = service.name + (service.default_domain ? ` (@${service.default_domain})` : '');
+ option.dataset.type = 'duck_mail';
+ option.dataset.serviceId = service.id;
+ optgroup.appendChild(option);
+ });
+
+ select.appendChild(optgroup);
+ }
+
+ // Freemail
+ if (availableServices.freemail && availableServices.freemail.available) {
+ const optgroup = document.createElement('optgroup');
+ optgroup.label = `📧 Freemail (${availableServices.freemail.count} 个服务)`;
+
+ availableServices.freemail.services.forEach(service => {
+ const option = document.createElement('option');
+ option.value = `freemail:${service.id}`;
+ option.textContent = service.name + (service.domain ? ` (@${service.domain})` : '');
+ option.dataset.type = 'freemail';
+ option.dataset.serviceId = service.id;
+ optgroup.appendChild(option);
+ });
+
+ select.appendChild(optgroup);
+ }
+}
+
+// 处理邮箱服务切换
+function handleServiceChange(e) {
+ const value = e.target.value;
+ if (!value) return;
+
+ const [type, id] = value.split(':');
+ // 处理 Outlook 批量注册模式
+ if (type === 'outlook_batch') {
+ isOutlookBatchMode = true;
+ elements.outlookBatchSection.style.display = 'block';
+ elements.regModeGroup.style.display = 'none';
+ elements.batchCountGroup.style.display = 'none';
+ elements.batchOptions.style.display = 'none';
+ loadOutlookAccounts();
+ addLog('info', '[系统] 已切换到 Outlook 批量注册模式');
+ return;
+ } else {
+ isOutlookBatchMode = false;
+ elements.outlookBatchSection.style.display = 'none';
+ elements.regModeGroup.style.display = 'block';
+ }
+
+ // 显示服务信息
+ if (type === 'outlook') {
+ const service = availableServices.outlook.services.find(s => s.id == id);
+ if (service) {
+ addLog('info', `[系统] 已选择 Outlook 账户: ${service.name}`);
+ }
+ } else if (type === 'moe_mail') {
+ const service = availableServices.moe_mail.services.find(s => s.id == id);
+ if (service) {
+ addLog('info', `[系统] 已选择自定义域名服务: ${service.name}`);
+ }
+ } else if (type === 'temp_mail') {
+ const service = availableServices.temp_mail.services.find(s => s.id == id);
+ if (service) {
+ addLog('info', `[系统] 已选择 Temp-Mail 自部署服务: ${service.name}`);
+ }
+ } else if (type === 'duck_mail') {
+ const service = availableServices.duck_mail.services.find(s => s.id == id);
+ if (service) {
+ addLog('info', `[系统] 已选择 DuckMail 服务: ${service.name}`);
+ }
+ } else if (type === 'freemail') {
+ const service = availableServices.freemail.services.find(s => s.id == id);
+ if (service) {
+ addLog('info', `[系统] 已选择 Freemail 服务: ${service.name}`);
+ }
+ }
+}
+
+// 模式切换
+function handleModeChange(e) {
+ const mode = e.target.value;
+ isBatchMode = mode === 'batch';
+
+ elements.batchCountGroup.style.display = isBatchMode ? 'block' : 'none';
+ elements.batchOptions.style.display = isBatchMode ? 'block' : 'none';
+}
+
+// 并发模式切换(批量)
+function handleConcurrencyModeChange(selectEl, hintEl, intervalGroupEl) {
+ const mode = selectEl.value;
+ if (mode === 'parallel') {
+ hintEl.textContent = '所有任务分成 N 个并发批次同时执行';
+ intervalGroupEl.style.display = 'none';
+ } else {
+ hintEl.textContent = '同时最多运行 N 个任务,每隔 interval 秒启动新任务';
+ intervalGroupEl.style.display = 'block';
+ }
+}
+
+// 开始注册
+async function handleStartRegistration(e) {
+ e.preventDefault();
+
+ const selectedValue = elements.emailService.value;
+ if (!selectedValue) {
+ toast.error('请选择一个邮箱服务');
+ return;
+ }
+
+ // 处理 Outlook 批量注册模式
+ if (isOutlookBatchMode) {
+ await handleOutlookBatchRegistration();
+ return;
+ }
+
+ const [emailServiceType, serviceId] = selectedValue.split(':');
+
+ // 禁用开始按钮
+ elements.startBtn.disabled = true;
+ elements.cancelBtn.disabled = false;
+
+ // 清空日志
+ elements.consoleLog.innerHTML = '';
+
+ // 构建请求数据(代理从设置中自动获取)
+ const requestData = {
+ email_service_type: emailServiceType,
+ auto_upload_cpa: elements.autoUploadCpa ? elements.autoUploadCpa.checked : false,
+ cpa_service_ids: elements.autoUploadCpa && elements.autoUploadCpa.checked ? getSelectedServiceIds(elements.cpaServiceSelect) : [],
+ auto_upload_sub2api: elements.autoUploadSub2api ? elements.autoUploadSub2api.checked : false,
+ sub2api_service_ids: elements.autoUploadSub2api && elements.autoUploadSub2api.checked ? getSelectedServiceIds(elements.sub2apiServiceSelect) : [],
+ ...(elements.autoUploadSub2api && elements.autoUploadSub2api.checked ? getSub2apiAdvancedConfig() : {}),
+ auto_upload_tm: elements.autoUploadTm ? elements.autoUploadTm.checked : false,
+ tm_service_ids: elements.autoUploadTm && elements.autoUploadTm.checked ? getSelectedServiceIds(elements.tmServiceSelect) : [],
+ };
+
+ // 如果选择了数据库中的服务,传递 service_id
+ if (serviceId && serviceId !== 'default') {
+ requestData.email_service_id = parseInt(serviceId);
+ }
+
+ if (isBatchMode) {
+ await handleBatchRegistration(requestData);
+ } else {
+ await handleSingleRegistration(requestData);
+ }
+}
+
+// 单次注册
+async function handleSingleRegistration(requestData) {
+ // 重置任务状态
+ taskCompleted = false;
+ taskFinalStatus = null;
+ displayedLogs.clear(); // 清空日志去重集合
+ toastShown = false; // 重置 toast 标志
+
+ addLog('info', '[系统] 正在启动注册任务...');
+
+ try {
+ const data = await api.post('/registration/start', requestData);
+
+ currentTask = data;
+ activeTaskUuid = data.task_uuid; // 保存用于重连
+ // 持久化到 sessionStorage,跨页面导航后可恢复
+ sessionStorage.setItem('activeTask', JSON.stringify({ task_uuid: data.task_uuid, mode: 'single' }));
+ addLog('info', `[系统] 任务已创建: ${data.task_uuid}`);
+ showTaskStatus(data);
+ updateTaskStatus('running');
+
+ // 优先使用 WebSocket
+ connectWebSocket(data.task_uuid);
+
+ } catch (error) {
+ addLog('error', `[错误] 启动失败: ${error.message}`);
+ toast.error(error.message);
+ resetButtons();
+ }
+}
+
+
+// ============== WebSocket 功能 ==============
+
+// 连接 WebSocket
+function connectWebSocket(taskUuid) {
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+ const wsUrl = `${protocol}//${window.location.host}/api/ws/task/${taskUuid}`;
+
+ try {
+ webSocket = new WebSocket(wsUrl);
+
+ webSocket.onopen = () => {
+ console.log('WebSocket 连接成功');
+ useWebSocket = true;
+ // 停止轮询(如果有)
+ stopLogPolling();
+ // 开始心跳
+ startWebSocketHeartbeat();
+ };
+
+ webSocket.onmessage = (event) => {
+ const data = JSON.parse(event.data);
+
+ if (data.type === 'log') {
+ const logType = getLogType(data.message);
+ addLog(logType, data.message);
+ } else if (data.type === 'status') {
+ updateTaskStatus(data.status);
+
+ // 检查是否完成
+ if (['completed', 'failed', 'cancelled', 'cancelling'].includes(data.status)) {
+ // 保存最终状态,用于 onclose 判断
+ taskFinalStatus = data.status;
+ taskCompleted = true;
+
+ // 断开 WebSocket(异步操作)
+ disconnectWebSocket();
+
+ // 任务完成后再重置按钮
+ resetButtons();
+
+ // 只显示一次 toast
+ if (!toastShown) {
+ toastShown = true;
+ if (data.status === 'completed') {
+ addLog('success', '[成功] 注册成功!');
+ toast.success('注册成功!');
+ // 刷新账号列表
+ loadRecentAccounts();
+ } else if (data.status === 'failed') {
+ addLog('error', '[错误] 注册失败');
+ toast.error('注册失败');
+ } else if (data.status === 'cancelled' || data.status === 'cancelling') {
+ addLog('warning', '[警告] 任务已取消');
+ }
+ }
+ }
+ } else if (data.type === 'pong') {
+ // 心跳响应,忽略
+ }
+ };
+
+ webSocket.onclose = (event) => {
+ console.log('WebSocket 连接关闭:', event.code);
+ stopWebSocketHeartbeat();
+
+ // 只有在任务未完成且最终状态不是完成状态时才切换到轮询
+ // 使用 taskFinalStatus 而不是 currentTask.status,因为 currentTask 可能已被重置
+ const shouldPoll = !taskCompleted &&
+ taskFinalStatus === null; // 如果 taskFinalStatus 有值,说明任务已完成
+
+ if (shouldPoll && currentTask) {
+ console.log('切换到轮询模式');
+ useWebSocket = false;
+ startLogPolling(currentTask.task_uuid);
+ }
+ };
+
+ webSocket.onerror = (error) => {
+ console.error('WebSocket 错误:', error);
+ // 切换到轮询
+ useWebSocket = false;
+ stopWebSocketHeartbeat();
+ startLogPolling(taskUuid);
+ };
+
+ } catch (error) {
+ console.error('WebSocket 连接失败:', error);
+ useWebSocket = false;
+ startLogPolling(taskUuid);
+ }
+}
+
+// 断开 WebSocket
+function disconnectWebSocket() {
+ stopWebSocketHeartbeat();
+ if (webSocket) {
+ webSocket.close();
+ webSocket = null;
+ }
+}
+
+// 开始心跳
+function startWebSocketHeartbeat() {
+ stopWebSocketHeartbeat();
+ wsHeartbeatInterval = setInterval(() => {
+ if (webSocket && webSocket.readyState === WebSocket.OPEN) {
+ webSocket.send(JSON.stringify({ type: 'ping' }));
+ }
+ }, 25000); // 每 25 秒发送一次心跳
+}
+
+// 停止心跳
+function stopWebSocketHeartbeat() {
+ if (wsHeartbeatInterval) {
+ clearInterval(wsHeartbeatInterval);
+ wsHeartbeatInterval = null;
+ }
+}
+
+// 发送取消请求
+function cancelViaWebSocket() {
+ if (webSocket && webSocket.readyState === WebSocket.OPEN) {
+ webSocket.send(JSON.stringify({ type: 'cancel' }));
+ }
+}
+
+// 批量注册
+async function handleBatchRegistration(requestData) {
+ // 重置批量任务状态
+ batchCompleted = false;
+ batchFinalStatus = null;
+ displayedLogs.clear(); // 清空日志去重集合
+ toastShown = false; // 重置 toast 标志
+
+ const count = parseInt(elements.batchCount.value) || 5;
+ const intervalMin = parseInt(elements.intervalMin.value) || 5;
+ const intervalMax = parseInt(elements.intervalMax.value) || 30;
+ const concurrency = parseInt(elements.concurrencyCount.value) || 3;
+ const mode = elements.concurrencyMode.value || 'pipeline';
+
+ requestData.count = count;
+ requestData.interval_min = intervalMin;
+ requestData.interval_max = intervalMax;
+ requestData.concurrency = Math.min(50, Math.max(1, concurrency));
+ requestData.mode = mode;
+
+ addLog('info', `[系统] 正在启动批量注册任务 (数量: ${count})...`);
+
+ try {
+ const data = await api.post('/registration/batch', requestData);
+
+ currentBatch = data;
+ activeBatchId = data.batch_id; // 保存用于重连
+ // 持久化到 sessionStorage,跨页面导航后可恢复
+ sessionStorage.setItem('activeTask', JSON.stringify({ batch_id: data.batch_id, mode: 'batch', total: data.count }));
+ addLog('info', `[系统] 批量任务已创建: ${data.batch_id}`);
+ addLog('info', `[系统] 共 ${data.count} 个任务已加入队列`);
+ showBatchStatus(data);
+
+ // 优先使用 WebSocket
+ connectBatchWebSocket(data.batch_id);
+
+ } catch (error) {
+ addLog('error', `[错误] 启动失败: ${error.message}`);
+ toast.error(error.message);
+ resetButtons();
+ }
+}
+
+// 取消任务
+async function handleCancelTask() {
+ // 禁用取消按钮,防止重复点击
+ elements.cancelBtn.disabled = true;
+ addLog('info', '[系统] 正在提交取消请求...');
+
+ try {
+ // 批量任务取消(包括普通批量模式和 Outlook 批量模式)
+ if (currentBatch && (isBatchMode || isOutlookBatchMode)) {
+ // 优先通过 WebSocket 取消
+ if (batchWebSocket && batchWebSocket.readyState === WebSocket.OPEN) {
+ batchWebSocket.send(JSON.stringify({ type: 'cancel' }));
+ addLog('warning', '[警告] 批量任务取消请求已提交');
+ toast.info('任务取消请求已提交');
+ } else {
+ // 降级到 REST API
+ const endpoint = isOutlookBatchMode
+ ? `/registration/outlook-batch/${currentBatch.batch_id}/cancel`
+ : `/registration/batch/${currentBatch.batch_id}/cancel`;
+
+ await api.post(endpoint);
+ addLog('warning', '[警告] 批量任务取消请求已提交');
+ toast.info('任务取消请求已提交');
+ stopBatchPolling();
+ resetButtons();
+ }
+ }
+ // 单次任务取消
+ else if (currentTask) {
+ // 优先通过 WebSocket 取消
+ if (webSocket && webSocket.readyState === WebSocket.OPEN) {
+ webSocket.send(JSON.stringify({ type: 'cancel' }));
+ addLog('warning', '[警告] 任务取消请求已提交');
+ toast.info('任务取消请求已提交');
+ } else {
+ // 降级到 REST API
+ await api.post(`/registration/tasks/${currentTask.task_uuid}/cancel`);
+ addLog('warning', '[警告] 任务已取消');
+ toast.info('任务已取消');
+ stopLogPolling();
+ resetButtons();
+ }
+ }
+ // 没有活动任务
+ else {
+ addLog('warning', '[警告] 没有活动的任务可以取消');
+ toast.warning('没有活动的任务');
+ resetButtons();
+ }
+ } catch (error) {
+ addLog('error', `[错误] 取消失败: ${error.message}`);
+ toast.error(error.message);
+ // 恢复取消按钮,允许重试
+ elements.cancelBtn.disabled = false;
+ }
+}
+
+// 开始轮询日志
+function startLogPolling(taskUuid) {
+ let lastLogIndex = 0;
+
+ logPollingInterval = setInterval(async () => {
+ try {
+ const data = await api.get(`/registration/tasks/${taskUuid}/logs`);
+
+ // 更新任务状态
+ updateTaskStatus(data.status);
+
+ // 更新邮箱信息
+ if (data.email) {
+ elements.taskEmail.textContent = data.email;
+ }
+ if (data.email_service) {
+ elements.taskService.textContent = getServiceTypeText(data.email_service);
+ }
+
+ // 添加新日志
+ const logs = data.logs || [];
+ for (let i = lastLogIndex; i < logs.length; i++) {
+ const log = logs[i];
+ const logType = getLogType(log);
+ addLog(logType, log);
+ }
+ lastLogIndex = logs.length;
+
+ // 检查任务是否完成
+ if (['completed', 'failed', 'cancelled'].includes(data.status)) {
+ stopLogPolling();
+ resetButtons();
+
+ // 只显示一次 toast
+ if (!toastShown) {
+ toastShown = true;
+ if (data.status === 'completed') {
+ addLog('success', '[成功] 注册成功!');
+ toast.success('注册成功!');
+ // 刷新账号列表
+ loadRecentAccounts();
+ } else if (data.status === 'failed') {
+ addLog('error', '[错误] 注册失败');
+ toast.error('注册失败');
+ } else if (data.status === 'cancelled') {
+ addLog('warning', '[警告] 任务已取消');
+ }
+ }
+ }
+ } catch (error) {
+ console.error('轮询日志失败:', error);
+ }
+ }, 1000);
+}
+
+// 停止轮询日志
+function stopLogPolling() {
+ if (logPollingInterval) {
+ clearInterval(logPollingInterval);
+ logPollingInterval = null;
+ }
+}
+
+// 开始轮询批量状态
+function startBatchPolling(batchId) {
+ batchPollingInterval = setInterval(async () => {
+ try {
+ const data = await api.get(`/registration/batch/${batchId}`);
+ updateBatchProgress(data);
+
+ // 检查是否完成
+ if (data.finished) {
+ stopBatchPolling();
+ resetButtons();
+
+ // 只显示一次 toast
+ if (!toastShown) {
+ toastShown = true;
+ addLog('info', `[完成] 批量任务完成!成功: ${data.success}, 失败: ${data.failed}`);
+ if (data.success > 0) {
+ toast.success(`批量注册完成,成功 ${data.success} 个`);
+ // 刷新账号列表
+ loadRecentAccounts();
+ } else {
+ toast.warning('批量注册完成,但没有成功注册任何账号');
+ }
+ }
+ }
+ } catch (error) {
+ console.error('轮询批量状态失败:', error);
+ }
+ }, 2000);
+}
+
+// 停止轮询批量状态
+function stopBatchPolling() {
+ if (batchPollingInterval) {
+ clearInterval(batchPollingInterval);
+ batchPollingInterval = null;
+ }
+}
+
+// 显示任务状态
+function showTaskStatus(task) {
+ elements.taskStatusRow.style.display = 'grid';
+ elements.batchProgressSection.style.display = 'none';
+ elements.taskStatusBadge.style.display = 'inline-flex';
+ elements.taskId.textContent = task.task_uuid.substring(0, 8) + '...';
+ elements.taskEmail.textContent = '-';
+ elements.taskService.textContent = '-';
+}
+
+// 更新任务状态
+function updateTaskStatus(status) {
+ const statusInfo = {
+ pending: { text: '等待中', class: 'pending' },
+ running: { text: '运行中', class: 'running' },
+ completed: { text: '已完成', class: 'completed' },
+ failed: { text: '失败', class: 'failed' },
+ cancelled: { text: '已取消', class: 'disabled' }
+ };
+
+ const info = statusInfo[status] || { text: status, class: '' };
+ elements.taskStatusBadge.textContent = info.text;
+ elements.taskStatusBadge.className = `status-badge ${info.class}`;
+ elements.taskStatus.textContent = info.text;
+}
+
+// 显示批量状态
+function showBatchStatus(batch) {
+ elements.batchProgressSection.style.display = 'block';
+ elements.taskStatusRow.style.display = 'none';
+ elements.taskStatusBadge.style.display = 'none';
+ elements.batchProgressText.textContent = `0/${batch.count}`;
+ elements.batchProgressPercent.textContent = '0%';
+ elements.progressBar.style.width = '0%';
+ elements.batchSuccess.textContent = '0';
+ elements.batchFailed.textContent = '0';
+ elements.batchRemaining.textContent = batch.count;
+
+ // 重置计数器
+ elements.batchSuccess.dataset.last = '0';
+ elements.batchFailed.dataset.last = '0';
+}
+
+// 更新批量进度
+function updateBatchProgress(data) {
+ const progress = ((data.completed / data.total) * 100).toFixed(0);
+ elements.batchProgressText.textContent = `${data.completed}/${data.total}`;
+ elements.batchProgressPercent.textContent = `${progress}%`;
+ elements.progressBar.style.width = `${progress}%`;
+ elements.batchSuccess.textContent = data.success;
+ elements.batchFailed.textContent = data.failed;
+ elements.batchRemaining.textContent = data.total - data.completed;
+
+ // 记录日志(避免重复)
+ if (data.completed > 0) {
+ const lastSuccess = parseInt(elements.batchSuccess.dataset.last || '0');
+ const lastFailed = parseInt(elements.batchFailed.dataset.last || '0');
+
+ if (data.success > lastSuccess) {
+ addLog('success', `[成功] 第 ${data.success} 个账号注册成功`);
+ }
+ if (data.failed > lastFailed) {
+ addLog('error', `[失败] 第 ${data.failed} 个账号注册失败`);
+ }
+
+ elements.batchSuccess.dataset.last = data.success;
+ elements.batchFailed.dataset.last = data.failed;
+ }
+}
+
+// 加载最近注册的账号
+async function loadRecentAccounts() {
+ try {
+ const data = await api.get('/accounts?page=1&page_size=10');
+
+ if (data.accounts.length === 0) {
+ elements.recentAccountsTable.innerHTML = `
+
+ |
+
+ |
+
+ `;
+ return;
+ }
+
+ elements.recentAccountsTable.innerHTML = data.accounts.map(account => `
+
+ | ${account.id} |
+
+
+ ${escapeHtml(account.email)}
+
+
+ |
+
+ ${account.password
+ ? `
+ ${escapeHtml(account.password.substring(0, 8))}...
+
+ `
+ : '-'}
+ |
+
+ ${getStatusIcon(account.status)}
+ |
+
+ `).join('');
+
+ // 绑定复制按钮事件
+ elements.recentAccountsTable.querySelectorAll('.copy-email-btn').forEach(btn => {
+ btn.addEventListener('click', (e) => { e.stopPropagation(); copyToClipboard(btn.dataset.email); });
+ });
+ elements.recentAccountsTable.querySelectorAll('.copy-pwd-btn').forEach(btn => {
+ btn.addEventListener('click', (e) => { e.stopPropagation(); copyToClipboard(btn.dataset.pwd); });
+ });
+
+ } catch (error) {
+ console.error('加载账号列表失败:', error);
+ }
+}
+
+// 开始账号列表轮询
+function startAccountsPolling() {
+ // 每30秒刷新一次账号列表
+ accountsPollingInterval = setInterval(() => {
+ loadRecentAccounts();
+ }, 30000);
+}
+
+// 添加日志
+function addLog(type, message) {
+ // 日志去重:使用消息内容的 hash 作为键
+ const logKey = `${type}:${message}`;
+ if (displayedLogs.has(logKey)) {
+ return; // 已经显示过,跳过
+ }
+ displayedLogs.add(logKey);
+
+ // 限制去重集合大小,避免内存泄漏
+ if (displayedLogs.size > 1000) {
+ // 清空一半的记录
+ const keys = Array.from(displayedLogs);
+ keys.slice(0, 500).forEach(k => displayedLogs.delete(k));
+ }
+
+ const line = document.createElement('div');
+ line.className = `log-line ${type}`;
+
+ // 添加时间戳
+ const timestamp = new Date().toLocaleTimeString('zh-CN', {
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit'
+ });
+
+ line.innerHTML = `[${timestamp}]${escapeHtml(message)}`;
+ elements.consoleLog.appendChild(line);
+
+ // 自动滚动到底部
+ elements.consoleLog.scrollTop = elements.consoleLog.scrollHeight;
+
+ // 限制日志行数
+ const lines = elements.consoleLog.querySelectorAll('.log-line');
+ if (lines.length > 500) {
+ lines[0].remove();
+ }
+}
+
+// 获取日志类型
+function getLogType(log) {
+ if (typeof log !== 'string') return 'info';
+
+ const lowerLog = log.toLowerCase();
+ if (lowerLog.includes('error') || lowerLog.includes('失败') || lowerLog.includes('错误')) {
+ return 'error';
+ }
+ if (lowerLog.includes('warning') || lowerLog.includes('警告')) {
+ return 'warning';
+ }
+ if (lowerLog.includes('success') || lowerLog.includes('成功') || lowerLog.includes('完成')) {
+ return 'success';
+ }
+ return 'info';
+}
+
+// 重置按钮状态
+function resetButtons() {
+ elements.startBtn.disabled = false;
+ elements.cancelBtn.disabled = true;
+ currentTask = null;
+ currentBatch = null;
+ isBatchMode = false;
+ // 重置完成标志
+ taskCompleted = false;
+ batchCompleted = false;
+ // 重置最终状态标志
+ taskFinalStatus = null;
+ batchFinalStatus = null;
+ // 清除活跃任务标识
+ activeTaskUuid = null;
+ activeBatchId = null;
+ // 清除 sessionStorage 持久化状态
+ sessionStorage.removeItem('activeTask');
+ // 断开 WebSocket
+ disconnectWebSocket();
+ disconnectBatchWebSocket();
+ // 注意:不重置 isOutlookBatchMode,因为用户可能想继续使用 Outlook 批量模式
+}
+
+// HTML 转义
+function escapeHtml(text) {
+ if (!text) return '';
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+}
+
+
+// ============== Outlook 批量注册功能 ==============
+
+// 加载 Outlook 账户列表
+async function loadOutlookAccounts() {
+ try {
+ elements.outlookAccountsContainer.innerHTML = '加载中...
';
+
+ const data = await api.get('/registration/outlook-accounts');
+ outlookAccounts = data.accounts || [];
+
+ renderOutlookAccountsList();
+
+ addLog('info', `[系统] 已加载 ${data.total} 个 Outlook 账户 (已注册: ${data.registered_count}, 未注册: ${data.unregistered_count})`);
+
+ } catch (error) {
+ console.error('加载 Outlook 账户列表失败:', error);
+ elements.outlookAccountsContainer.innerHTML = `加载失败: ${error.message}
`;
+ addLog('error', `[错误] 加载 Outlook 账户列表失败: ${error.message}`);
+ }
+}
+
+// 渲染 Outlook 账户列表
+function renderOutlookAccountsList() {
+ if (outlookAccounts.length === 0) {
+ elements.outlookAccountsContainer.innerHTML = '没有可用的 Outlook 账户
';
+ return;
+ }
+
+ const html = outlookAccounts.map(account => `
+
+ `).join('');
+
+ elements.outlookAccountsContainer.innerHTML = html;
+}
+
+// 全选
+function selectAllOutlookAccounts() {
+ const checkboxes = document.querySelectorAll('.outlook-account-checkbox');
+ checkboxes.forEach(cb => cb.checked = true);
+}
+
+// 只选未注册
+function selectUnregisteredOutlook() {
+ const items = document.querySelectorAll('.outlook-account-item');
+ items.forEach(item => {
+ const checkbox = item.querySelector('.outlook-account-checkbox');
+ const isRegistered = item.dataset.registered === 'true';
+ checkbox.checked = !isRegistered;
+ });
+}
+
+// 取消全选
+function deselectAllOutlookAccounts() {
+ const checkboxes = document.querySelectorAll('.outlook-account-checkbox');
+ checkboxes.forEach(cb => cb.checked = false);
+}
+
+// 处理 Outlook 批量注册
+async function handleOutlookBatchRegistration() {
+ // 重置批量任务状态
+ batchCompleted = false;
+ batchFinalStatus = null;
+ displayedLogs.clear(); // 清空日志去重集合
+ toastShown = false; // 重置 toast 标志
+
+ // 获取选中的账户
+ const selectedIds = [];
+ document.querySelectorAll('.outlook-account-checkbox:checked').forEach(cb => {
+ selectedIds.push(parseInt(cb.value));
+ });
+
+ if (selectedIds.length === 0) {
+ toast.error('请选择至少一个 Outlook 账户');
+ return;
+ }
+
+ const intervalMin = parseInt(elements.outlookIntervalMin.value) || 5;
+ const intervalMax = parseInt(elements.outlookIntervalMax.value) || 30;
+ const skipRegistered = elements.outlookSkipRegistered.checked;
+ const concurrency = parseInt(elements.outlookConcurrencyCount.value) || 3;
+ const mode = elements.outlookConcurrencyMode.value || 'pipeline';
+
+ // 禁用开始按钮
+ elements.startBtn.disabled = true;
+ elements.cancelBtn.disabled = false;
+
+ // 清空日志
+ elements.consoleLog.innerHTML = '';
+
+ const requestData = {
+ service_ids: selectedIds,
+ skip_registered: skipRegistered,
+ interval_min: intervalMin,
+ interval_max: intervalMax,
+ concurrency: Math.min(50, Math.max(1, concurrency)),
+ mode: mode,
+ auto_upload_cpa: elements.autoUploadCpa ? elements.autoUploadCpa.checked : false,
+ cpa_service_ids: elements.autoUploadCpa && elements.autoUploadCpa.checked ? getSelectedServiceIds(elements.cpaServiceSelect) : [],
+ auto_upload_sub2api: elements.autoUploadSub2api ? elements.autoUploadSub2api.checked : false,
+ sub2api_service_ids: elements.autoUploadSub2api && elements.autoUploadSub2api.checked ? getSelectedServiceIds(elements.sub2apiServiceSelect) : [],
+ ...(elements.autoUploadSub2api && elements.autoUploadSub2api.checked ? getSub2apiAdvancedConfig() : {}),
+ auto_upload_tm: elements.autoUploadTm ? elements.autoUploadTm.checked : false,
+ tm_service_ids: elements.autoUploadTm && elements.autoUploadTm.checked ? getSelectedServiceIds(elements.tmServiceSelect) : [],
+ };
+
+ addLog('info', `[系统] 正在启动 Outlook 批量注册 (${selectedIds.length} 个账户)...`);
+
+ try {
+ const data = await api.post('/registration/outlook-batch', requestData);
+
+ if (data.to_register === 0) {
+ addLog('warning', '[警告] 所有选中的邮箱都已注册,无需重复注册');
+ toast.warning('所有选中的邮箱都已注册');
+ resetButtons();
+ return;
+ }
+
+ currentBatch = { batch_id: data.batch_id, ...data };
+ activeBatchId = data.batch_id; // 保存用于重连
+ // 持久化到 sessionStorage,跨页面导航后可恢复
+ sessionStorage.setItem('activeTask', JSON.stringify({ batch_id: data.batch_id, mode: isOutlookBatchMode ? 'outlook_batch' : 'batch', total: data.to_register }));
+ addLog('info', `[系统] 批量任务已创建: ${data.batch_id}`);
+ addLog('info', `[系统] 总数: ${data.total}, 跳过已注册: ${data.skipped}, 待注册: ${data.to_register}`);
+
+ // 初始化批量状态显示
+ showBatchStatus({ count: data.to_register });
+
+ // 优先使用 WebSocket
+ connectBatchWebSocket(data.batch_id);
+
+ } catch (error) {
+ addLog('error', `[错误] 启动失败: ${error.message}`);
+ toast.error(error.message);
+ resetButtons();
+ }
+}
+
+// ============== 批量任务 WebSocket 功能 ==============
+
+// 连接批量任务 WebSocket
+function connectBatchWebSocket(batchId) {
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+ const wsUrl = `${protocol}//${window.location.host}/api/ws/batch/${batchId}`;
+
+ try {
+ batchWebSocket = new WebSocket(wsUrl);
+
+ batchWebSocket.onopen = () => {
+ console.log('批量任务 WebSocket 连接成功');
+ // 停止轮询(如果有)
+ stopBatchPolling();
+ // 开始心跳
+ startBatchWebSocketHeartbeat();
+ };
+
+ batchWebSocket.onmessage = (event) => {
+ const data = JSON.parse(event.data);
+
+ if (data.type === 'log') {
+ const logType = getLogType(data.message);
+ addLog(logType, data.message);
+ } else if (data.type === 'status') {
+ // 更新进度
+ if (data.total !== undefined) {
+ updateBatchProgress({
+ total: data.total,
+ completed: data.completed || 0,
+ success: data.success || 0,
+ failed: data.failed || 0
+ });
+ }
+
+ // 检查是否完成
+ if (['completed', 'failed', 'cancelled', 'cancelling'].includes(data.status)) {
+ // 保存最终状态,用于 onclose 判断
+ batchFinalStatus = data.status;
+ batchCompleted = true;
+
+ // 断开 WebSocket(异步操作)
+ disconnectBatchWebSocket();
+
+ // 任务完成后再重置按钮
+ resetButtons();
+
+ // 只显示一次 toast
+ if (!toastShown) {
+ toastShown = true;
+ if (data.status === 'completed') {
+ addLog('success', `[完成] Outlook 批量任务完成!成功: ${data.success}, 失败: ${data.failed}, 跳过: ${data.skipped || 0}`);
+ if (data.success > 0) {
+ toast.success(`Outlook 批量注册完成,成功 ${data.success} 个`);
+ loadRecentAccounts();
+ } else {
+ toast.warning('Outlook 批量注册完成,但没有成功注册任何账号');
+ }
+ } else if (data.status === 'failed') {
+ addLog('error', '[错误] 批量任务执行失败');
+ toast.error('批量任务执行失败');
+ } else if (data.status === 'cancelled' || data.status === 'cancelling') {
+ addLog('warning', '[警告] 批量任务已取消');
+ }
+ }
+ }
+ } else if (data.type === 'pong') {
+ // 心跳响应,忽略
+ }
+ };
+
+ batchWebSocket.onclose = (event) => {
+ console.log('批量任务 WebSocket 连接关闭:', event.code);
+ stopBatchWebSocketHeartbeat();
+
+ // 只有在任务未完成且最终状态不是完成状态时才切换到轮询
+ // 使用 batchFinalStatus 而不是 currentBatch.status,因为 currentBatch 可能已被重置
+ const shouldPoll = !batchCompleted &&
+ batchFinalStatus === null; // 如果 batchFinalStatus 有值,说明任务已完成
+
+ if (shouldPoll && currentBatch) {
+ console.log('切换到轮询模式');
+ startOutlookBatchPolling(currentBatch.batch_id);
+ }
+ };
+
+ batchWebSocket.onerror = (error) => {
+ console.error('批量任务 WebSocket 错误:', error);
+ stopBatchWebSocketHeartbeat();
+ // 切换到轮询
+ startOutlookBatchPolling(batchId);
+ };
+
+ } catch (error) {
+ console.error('批量任务 WebSocket 连接失败:', error);
+ startOutlookBatchPolling(batchId);
+ }
+}
+
+// 断开批量任务 WebSocket
+function disconnectBatchWebSocket() {
+ stopBatchWebSocketHeartbeat();
+ if (batchWebSocket) {
+ batchWebSocket.close();
+ batchWebSocket = null;
+ }
+}
+
+// 开始批量任务心跳
+function startBatchWebSocketHeartbeat() {
+ stopBatchWebSocketHeartbeat();
+ batchWsHeartbeatInterval = setInterval(() => {
+ if (batchWebSocket && batchWebSocket.readyState === WebSocket.OPEN) {
+ batchWebSocket.send(JSON.stringify({ type: 'ping' }));
+ }
+ }, 25000); // 每 25 秒发送一次心跳
+}
+
+// 停止批量任务心跳
+function stopBatchWebSocketHeartbeat() {
+ if (batchWsHeartbeatInterval) {
+ clearInterval(batchWsHeartbeatInterval);
+ batchWsHeartbeatInterval = null;
+ }
+}
+
+// 发送批量任务取消请求
+function cancelBatchViaWebSocket() {
+ if (batchWebSocket && batchWebSocket.readyState === WebSocket.OPEN) {
+ batchWebSocket.send(JSON.stringify({ type: 'cancel' }));
+ }
+}
+
+// 开始轮询 Outlook 批量状态(降级方案)
+function startOutlookBatchPolling(batchId) {
+ batchPollingInterval = setInterval(async () => {
+ try {
+ const data = await api.get(`/registration/outlook-batch/${batchId}`);
+
+ // 更新进度
+ updateBatchProgress({
+ total: data.total,
+ completed: data.completed,
+ success: data.success,
+ failed: data.failed
+ });
+
+ // 输出日志
+ if (data.logs && data.logs.length > 0) {
+ const lastLogIndex = batchPollingInterval.lastLogIndex || 0;
+ for (let i = lastLogIndex; i < data.logs.length; i++) {
+ const log = data.logs[i];
+ const logType = getLogType(log);
+ addLog(logType, log);
+ }
+ batchPollingInterval.lastLogIndex = data.logs.length;
+ }
+
+ // 检查是否完成
+ if (data.finished) {
+ stopBatchPolling();
+ resetButtons();
+
+ // 只显示一次 toast
+ if (!toastShown) {
+ toastShown = true;
+ addLog('info', `[完成] Outlook 批量任务完成!成功: ${data.success}, 失败: ${data.failed}, 跳过: ${data.skipped || 0}`);
+ if (data.success > 0) {
+ toast.success(`Outlook 批量注册完成,成功 ${data.success} 个`);
+ loadRecentAccounts();
+ } else {
+ toast.warning('Outlook 批量注册完成,但没有成功注册任何账号');
+ }
+ }
+ }
+ } catch (error) {
+ console.error('轮询 Outlook 批量状态失败:', error);
+ }
+ }, 2000);
+
+ batchPollingInterval.lastLogIndex = 0;
+}
+
+// ============== 页面可见性重连机制 ==============
+
+function initVisibilityReconnect() {
+ document.addEventListener('visibilitychange', () => {
+ if (document.visibilityState !== 'visible') return;
+
+ // 页面重新可见时,检查是否需要重连(针对同页面标签切换场景)
+ const wsDisconnected = !webSocket || webSocket.readyState === WebSocket.CLOSED;
+ const batchWsDisconnected = !batchWebSocket || batchWebSocket.readyState === WebSocket.CLOSED;
+
+ // 单任务重连
+ if (activeTaskUuid && !taskCompleted && wsDisconnected) {
+ console.log('[重连] 页面重新可见,重连单任务 WebSocket:', activeTaskUuid);
+ addLog('info', '[系统] 页面重新激活,正在重连任务监控...');
+ connectWebSocket(activeTaskUuid);
+ }
+
+ // 批量任务重连
+ if (activeBatchId && !batchCompleted && batchWsDisconnected) {
+ console.log('[重连] 页面重新可见,重连批量任务 WebSocket:', activeBatchId);
+ addLog('info', '[系统] 页面重新激活,正在重连批量任务监控...');
+ connectBatchWebSocket(activeBatchId);
+ }
+ });
+}
+
+// 页面加载时恢复进行中的任务(处理跨页面导航后回到注册页的情况)
+async function restoreActiveTask() {
+ const saved = sessionStorage.getItem('activeTask');
+ if (!saved) return;
+
+ let state;
+ try {
+ state = JSON.parse(saved);
+ } catch {
+ sessionStorage.removeItem('activeTask');
+ return;
+ }
+
+ const { mode, task_uuid, batch_id, total } = state;
+
+ if (mode === 'single' && task_uuid) {
+ // 查询任务是否仍在运行
+ try {
+ const data = await api.get(`/registration/tasks/${task_uuid}`);
+ if (['completed', 'failed', 'cancelled'].includes(data.status)) {
+ sessionStorage.removeItem('activeTask');
+ return;
+ }
+ // 任务仍在运行,恢复状态
+ currentTask = data;
+ activeTaskUuid = task_uuid;
+ taskCompleted = false;
+ taskFinalStatus = null;
+ toastShown = false;
+ displayedLogs.clear();
+ elements.startBtn.disabled = true;
+ elements.cancelBtn.disabled = false;
+ showTaskStatus(data);
+ updateTaskStatus(data.status);
+ addLog('info', `[系统] 检测到进行中的任务,正在重连监控... (${task_uuid.substring(0, 8)})`);
+ connectWebSocket(task_uuid);
+ } catch {
+ sessionStorage.removeItem('activeTask');
+ }
+ } else if ((mode === 'batch' || mode === 'outlook_batch') && batch_id) {
+ // 查询批量任务是否仍在运行
+ const endpoint = mode === 'outlook_batch'
+ ? `/registration/outlook-batch/${batch_id}`
+ : `/registration/batch/${batch_id}`;
+ try {
+ const data = await api.get(endpoint);
+ if (data.finished) {
+ sessionStorage.removeItem('activeTask');
+ return;
+ }
+ // 批量任务仍在运行,恢复状态
+ currentBatch = { batch_id, ...data };
+ activeBatchId = batch_id;
+ isOutlookBatchMode = (mode === 'outlook_batch');
+ batchCompleted = false;
+ batchFinalStatus = null;
+ toastShown = false;
+ displayedLogs.clear();
+ elements.startBtn.disabled = true;
+ elements.cancelBtn.disabled = false;
+ showBatchStatus({ count: total || data.total });
+ updateBatchProgress(data);
+ addLog('info', `[系统] 检测到进行中的批量任务,正在重连监控... (${batch_id.substring(0, 8)})`);
+ connectBatchWebSocket(batch_id);
+ } catch {
+ sessionStorage.removeItem('activeTask');
+ }
+ }
+}
diff --git a/static/js/email_services.js b/static/js/email_services.js
new file mode 100644
index 0000000..fafd85b
--- /dev/null
+++ b/static/js/email_services.js
@@ -0,0 +1,789 @@
+/**
+ * 邮箱服务页面 JavaScript
+ */
+
+// 状态
+let outlookServices = [];
+let customServices = []; // 合并 moe_mail + temp_mail + duck_mail + freemail + imap_mail
+let selectedOutlook = new Set();
+let selectedCustom = new Set();
+
+// DOM 元素
+const elements = {
+ // 统计
+ outlookCount: document.getElementById('outlook-count'),
+ customCount: document.getElementById('custom-count'),
+ tempmailStatus: document.getElementById('tempmail-status'),
+ totalEnabled: document.getElementById('total-enabled'),
+
+ // Outlook 导入
+ toggleOutlookImport: document.getElementById('toggle-outlook-import'),
+ outlookImportBody: document.getElementById('outlook-import-body'),
+ outlookImportData: document.getElementById('outlook-import-data'),
+ outlookImportEnabled: document.getElementById('outlook-import-enabled'),
+ outlookImportPriority: document.getElementById('outlook-import-priority'),
+ outlookImportBtn: document.getElementById('outlook-import-btn'),
+ clearImportBtn: document.getElementById('clear-import-btn'),
+ importResult: document.getElementById('import-result'),
+
+ // Outlook 列表
+ outlookTable: document.getElementById('outlook-accounts-table'),
+ selectAllOutlook: document.getElementById('select-all-outlook'),
+ batchDeleteOutlookBtn: document.getElementById('batch-delete-outlook-btn'),
+
+ // 自定义域名(合并)
+ customTable: document.getElementById('custom-services-table'),
+ addCustomBtn: document.getElementById('add-custom-btn'),
+ selectAllCustom: document.getElementById('select-all-custom'),
+
+ // 临时邮箱
+ tempmailForm: document.getElementById('tempmail-form'),
+ tempmailApi: document.getElementById('tempmail-api'),
+ tempmailEnabled: document.getElementById('tempmail-enabled'),
+ testTempmailBtn: document.getElementById('test-tempmail-btn'),
+
+ // 添加自定义域名模态框
+ addCustomModal: document.getElementById('add-custom-modal'),
+ addCustomForm: document.getElementById('add-custom-form'),
+ closeCustomModal: document.getElementById('close-custom-modal'),
+ cancelAddCustom: document.getElementById('cancel-add-custom'),
+ customSubType: document.getElementById('custom-sub-type'),
+ addMoemailFields: document.getElementById('add-moemail-fields'),
+ addTempmailFields: document.getElementById('add-tempmail-fields'),
+ addDuckmailFields: document.getElementById('add-duckmail-fields'),
+ addFreemailFields: document.getElementById('add-freemail-fields'),
+ addImapFields: document.getElementById('add-imap-fields'),
+
+ // 编辑自定义域名模态框
+ editCustomModal: document.getElementById('edit-custom-modal'),
+ editCustomForm: document.getElementById('edit-custom-form'),
+ closeEditCustomModal: document.getElementById('close-edit-custom-modal'),
+ cancelEditCustom: document.getElementById('cancel-edit-custom'),
+ editMoemailFields: document.getElementById('edit-moemail-fields'),
+ editTempmailFields: document.getElementById('edit-tempmail-fields'),
+ editDuckmailFields: document.getElementById('edit-duckmail-fields'),
+ editFreemailFields: document.getElementById('edit-freemail-fields'),
+ editImapFields: document.getElementById('edit-imap-fields'),
+ editCustomTypeBadge: document.getElementById('edit-custom-type-badge'),
+ editCustomSubTypeHidden: document.getElementById('edit-custom-sub-type-hidden'),
+
+ // 编辑 Outlook 模态框
+ editOutlookModal: document.getElementById('edit-outlook-modal'),
+ editOutlookForm: document.getElementById('edit-outlook-form'),
+ closeEditOutlookModal: document.getElementById('close-edit-outlook-modal'),
+ cancelEditOutlook: document.getElementById('cancel-edit-outlook'),
+};
+
+const CUSTOM_SUBTYPE_LABELS = {
+ moemail: '🔗 MoeMail(自定义域名 API)',
+ tempmail: '📮 TempMail(自部署 Cloudflare Worker)',
+ duckmail: '🦆 DuckMail(DuckMail API)',
+ freemail: 'Freemail(自部署 Cloudflare Worker)',
+ imap: '📧 IMAP 邮箱(Gmail/QQ/163等)'
+};
+
+// 初始化
+document.addEventListener('DOMContentLoaded', () => {
+ loadStats();
+ loadOutlookServices();
+ loadCustomServices();
+ loadTempmailConfig();
+ initEventListeners();
+});
+
+// 事件监听
+function initEventListeners() {
+ // Outlook 导入展开/收起
+ elements.toggleOutlookImport.addEventListener('click', () => {
+ const isHidden = elements.outlookImportBody.style.display === 'none';
+ elements.outlookImportBody.style.display = isHidden ? 'block' : 'none';
+ elements.toggleOutlookImport.textContent = isHidden ? '收起' : '展开';
+ });
+
+ // Outlook 导入
+ elements.outlookImportBtn.addEventListener('click', handleOutlookImport);
+ elements.clearImportBtn.addEventListener('click', () => {
+ elements.outlookImportData.value = '';
+ elements.importResult.style.display = 'none';
+ });
+
+ // Outlook 全选
+ elements.selectAllOutlook.addEventListener('change', (e) => {
+ const checkboxes = elements.outlookTable.querySelectorAll('input[type="checkbox"][data-id]');
+ checkboxes.forEach(cb => {
+ cb.checked = e.target.checked;
+ const id = parseInt(cb.dataset.id);
+ if (e.target.checked) selectedOutlook.add(id);
+ else selectedOutlook.delete(id);
+ });
+ updateBatchButtons();
+ });
+
+ // Outlook 批量删除
+ elements.batchDeleteOutlookBtn.addEventListener('click', handleBatchDeleteOutlook);
+
+ // 自定义域名全选
+ elements.selectAllCustom.addEventListener('change', (e) => {
+ const checkboxes = elements.customTable.querySelectorAll('input[type="checkbox"][data-id]');
+ checkboxes.forEach(cb => {
+ cb.checked = e.target.checked;
+ const id = parseInt(cb.dataset.id);
+ if (e.target.checked) selectedCustom.add(id);
+ else selectedCustom.delete(id);
+ });
+ });
+
+ // 添加自定义域名
+ elements.addCustomBtn.addEventListener('click', () => {
+ elements.addCustomForm.reset();
+ switchAddSubType('moemail');
+ elements.addCustomModal.classList.add('active');
+ });
+ elements.closeCustomModal.addEventListener('click', () => elements.addCustomModal.classList.remove('active'));
+ elements.cancelAddCustom.addEventListener('click', () => elements.addCustomModal.classList.remove('active'));
+ elements.addCustomForm.addEventListener('submit', handleAddCustom);
+
+ // 类型切换(添加表单)
+ elements.customSubType.addEventListener('change', (e) => switchAddSubType(e.target.value));
+
+ // 编辑自定义域名
+ elements.closeEditCustomModal.addEventListener('click', () => elements.editCustomModal.classList.remove('active'));
+ elements.cancelEditCustom.addEventListener('click', () => elements.editCustomModal.classList.remove('active'));
+ elements.editCustomForm.addEventListener('submit', handleEditCustom);
+
+ // 编辑 Outlook
+ elements.closeEditOutlookModal.addEventListener('click', () => elements.editOutlookModal.classList.remove('active'));
+ elements.cancelEditOutlook.addEventListener('click', () => elements.editOutlookModal.classList.remove('active'));
+ elements.editOutlookForm.addEventListener('submit', handleEditOutlook);
+
+ // 临时邮箱配置
+ elements.tempmailForm.addEventListener('submit', handleSaveTempmail);
+ elements.testTempmailBtn.addEventListener('click', handleTestTempmail);
+
+ // 点击其他地方关闭更多菜单
+ document.addEventListener('click', () => {
+ document.querySelectorAll('.dropdown-menu.active').forEach(m => m.classList.remove('active'));
+ });
+}
+
+function toggleEmailMoreMenu(btn) {
+ const menu = btn.nextElementSibling;
+ const isActive = menu.classList.contains('active');
+ document.querySelectorAll('.dropdown-menu.active').forEach(m => m.classList.remove('active'));
+ if (!isActive) menu.classList.add('active');
+}
+
+function closeEmailMoreMenu(el) {
+ const menu = el.closest('.dropdown-menu');
+ if (menu) menu.classList.remove('active');
+}
+
+// 切换添加表单子类型
+function switchAddSubType(subType) {
+ elements.customSubType.value = subType;
+ elements.addMoemailFields.style.display = subType === 'moemail' ? '' : 'none';
+ elements.addTempmailFields.style.display = subType === 'tempmail' ? '' : 'none';
+ elements.addDuckmailFields.style.display = subType === 'duckmail' ? '' : 'none';
+ elements.addFreemailFields.style.display = subType === 'freemail' ? '' : 'none';
+ elements.addImapFields.style.display = subType === 'imap' ? '' : 'none';
+}
+
+// 切换编辑表单子类型显示
+function switchEditSubType(subType) {
+ elements.editCustomSubTypeHidden.value = subType;
+ elements.editMoemailFields.style.display = subType === 'moemail' ? '' : 'none';
+ elements.editTempmailFields.style.display = subType === 'tempmail' ? '' : 'none';
+ elements.editDuckmailFields.style.display = subType === 'duckmail' ? '' : 'none';
+ elements.editFreemailFields.style.display = subType === 'freemail' ? '' : 'none';
+ elements.editImapFields.style.display = subType === 'imap' ? '' : 'none';
+ elements.editCustomTypeBadge.textContent = CUSTOM_SUBTYPE_LABELS[subType] || CUSTOM_SUBTYPE_LABELS.moemail;
+}
+
+// 加载统计信息
+async function loadStats() {
+ try {
+ const data = await api.get('/email-services/stats');
+ elements.outlookCount.textContent = data.outlook_count || 0;
+ elements.customCount.textContent = (data.custom_count || 0) + (data.temp_mail_count || 0) + (data.duck_mail_count || 0) + (data.freemail_count || 0) + (data.imap_mail_count || 0);
+ elements.tempmailStatus.textContent = data.tempmail_available ? '可用' : '不可用';
+ elements.totalEnabled.textContent = data.enabled_count || 0;
+ } catch (error) {
+ console.error('加载统计信息失败:', error);
+ }
+}
+
+// 加载 Outlook 服务
+async function loadOutlookServices() {
+ try {
+ const data = await api.get('/email-services?service_type=outlook');
+ outlookServices = data.services || [];
+
+ if (outlookServices.length === 0) {
+ elements.outlookTable.innerHTML = `
+
+
+
+ 📭
+ 暂无 Outlook 账户
+ 请使用上方导入功能添加账户
+
+ |
+
+ `;
+ return;
+ }
+
+ elements.outlookTable.innerHTML = outlookServices.map(service => `
+
+ |
+ ${escapeHtml(service.config?.email || service.name)} |
+
+
+ ${service.config?.has_oauth ? 'OAuth' : '密码'}
+
+ |
+ ${service.enabled ? '✅' : '⭕'} |
+ ${service.priority} |
+ ${format.date(service.last_used)} |
+
+
+ |
+
+ `).join('');
+
+ elements.outlookTable.querySelectorAll('input[type="checkbox"][data-id]').forEach(cb => {
+ cb.addEventListener('change', (e) => {
+ const id = parseInt(e.target.dataset.id);
+ if (e.target.checked) selectedOutlook.add(id);
+ else selectedOutlook.delete(id);
+ updateBatchButtons();
+ });
+ });
+
+ } catch (error) {
+ console.error('加载 Outlook 服务失败:', error);
+ elements.outlookTable.innerHTML = ` |
`;
+ }
+}
+
+function getCustomServiceTypeBadge(subType) {
+ if (subType === 'moemail') {
+ return 'MoeMail';
+ }
+ if (subType === 'tempmail') {
+ return 'TempMail';
+ }
+ if (subType === 'duckmail') {
+ return 'DuckMail';
+ }
+ if (subType === 'freemail') {
+ return 'Freemail';
+ }
+ return 'IMAP';
+}
+
+function getCustomServiceAddress(service) {
+ if (service._subType === 'imap') {
+ const host = service.config?.host || '-';
+ const emailAddr = service.config?.email || '';
+ return `${escapeHtml(host)}${escapeHtml(emailAddr)}
`;
+ }
+ const baseUrl = service.config?.base_url || '-';
+ const domain = service.config?.default_domain || service.config?.domain;
+ if (!domain) {
+ return escapeHtml(baseUrl);
+ }
+ return `${escapeHtml(baseUrl)}默认域名:@${escapeHtml(domain)}
`;
+}
+
+// 加载自定义邮箱服务(moe_mail + temp_mail + duck_mail + freemail 合并)
+async function loadCustomServices() {
+ try {
+ const [r1, r2, r3, r4, r5] = await Promise.all([
+ api.get('/email-services?service_type=moe_mail'),
+ api.get('/email-services?service_type=temp_mail'),
+ api.get('/email-services?service_type=duck_mail'),
+ api.get('/email-services?service_type=freemail'),
+ api.get('/email-services?service_type=imap_mail')
+ ]);
+ customServices = [
+ ...(r1.services || []).map(s => ({ ...s, _subType: 'moemail' })),
+ ...(r2.services || []).map(s => ({ ...s, _subType: 'tempmail' })),
+ ...(r3.services || []).map(s => ({ ...s, _subType: 'duckmail' })),
+ ...(r4.services || []).map(s => ({ ...s, _subType: 'freemail' })),
+ ...(r5.services || []).map(s => ({ ...s, _subType: 'imap' }))
+ ];
+
+ if (customServices.length === 0) {
+ elements.customTable.innerHTML = `
+
+
+
+ 📭
+ 暂无自定义邮箱服务
+ 点击「添加服务」按钮创建新服务
+
+ |
+
+ `;
+ return;
+ }
+
+ elements.customTable.innerHTML = customServices.map(service => {
+ return `
+
+ |
+ ${escapeHtml(service.name)} |
+ ${getCustomServiceTypeBadge(service._subType)} |
+ ${getCustomServiceAddress(service)} |
+ ${service.enabled ? '✅' : '⭕'} |
+ ${service.priority} |
+ ${format.date(service.last_used)} |
+
+
+ |
+
`;
+ }).join('');
+
+ elements.customTable.querySelectorAll('input[type="checkbox"][data-id]').forEach(cb => {
+ cb.addEventListener('change', (e) => {
+ const id = parseInt(e.target.dataset.id);
+ if (e.target.checked) selectedCustom.add(id);
+ else selectedCustom.delete(id);
+ });
+ });
+
+ } catch (error) {
+ console.error('加载自定义邮箱服务失败:', error);
+ }
+}
+
+// 加载临时邮箱配置
+async function loadTempmailConfig() {
+ try {
+ const settings = await api.get('/settings');
+ if (settings.tempmail) {
+ elements.tempmailApi.value = settings.tempmail.api_url || '';
+ elements.tempmailEnabled.checked = settings.tempmail.enabled !== false;
+ }
+ } catch (error) {
+ // 忽略错误
+ }
+}
+
+// Outlook 导入
+async function handleOutlookImport() {
+ const data = elements.outlookImportData.value.trim();
+ if (!data) { toast.error('请输入导入数据'); return; }
+
+ elements.outlookImportBtn.disabled = true;
+ elements.outlookImportBtn.textContent = '导入中...';
+
+ try {
+ const result = await api.post('/email-services/outlook/batch-import', {
+ data: data,
+ enabled: elements.outlookImportEnabled.checked,
+ priority: parseInt(elements.outlookImportPriority.value) || 0
+ });
+
+ elements.importResult.style.display = 'block';
+ elements.importResult.innerHTML = `
+
+ ✅ 成功导入: ${result.success || 0}
+ ❌ 失败: ${result.failed || 0}
+
+ ${result.errors?.length ? `错误详情:${result.errors.map(e => `- ${escapeHtml(e)}
`).join('')}
` : ''}
+ `;
+
+ if (result.success > 0) {
+ toast.success(`成功导入 ${result.success} 个账户`);
+ loadOutlookServices();
+ loadStats();
+ elements.outlookImportData.value = '';
+ }
+ } catch (error) {
+ toast.error('导入失败: ' + error.message);
+ } finally {
+ elements.outlookImportBtn.disabled = false;
+ elements.outlookImportBtn.textContent = '📥 开始导入';
+ }
+}
+
+// 添加自定义邮箱服务(根据子类型区分)
+async function handleAddCustom(e) {
+ e.preventDefault();
+ const formData = new FormData(e.target);
+ const subType = formData.get('sub_type');
+
+ let serviceType, config;
+ if (subType === 'moemail') {
+ serviceType = 'moe_mail';
+ config = {
+ base_url: formData.get('api_url'),
+ api_key: formData.get('api_key'),
+ default_domain: formData.get('domain')
+ };
+ } else if (subType === 'tempmail') {
+ serviceType = 'temp_mail';
+ config = {
+ base_url: formData.get('tm_base_url'),
+ admin_password: formData.get('tm_admin_password'),
+ domain: formData.get('tm_domain'),
+ enable_prefix: true
+ };
+ } else if (subType === 'duckmail') {
+ serviceType = 'duck_mail';
+ config = {
+ base_url: formData.get('dm_base_url'),
+ api_key: formData.get('dm_api_key'),
+ default_domain: formData.get('dm_domain'),
+ password_length: parseInt(formData.get('dm_password_length'), 10) || 12
+ };
+ } else if (subType === 'freemail') {
+ serviceType = 'freemail';
+ config = {
+ base_url: formData.get('fm_base_url'),
+ admin_token: formData.get('fm_admin_token'),
+ domain: formData.get('fm_domain')
+ };
+ } else {
+ serviceType = 'imap_mail';
+ config = {
+ host: formData.get('imap_host'),
+ port: parseInt(formData.get('imap_port'), 10) || 993,
+ use_ssl: formData.get('imap_use_ssl') !== 'false',
+ email: formData.get('imap_email'),
+ password: formData.get('imap_password')
+ };
+ }
+
+ const data = {
+ service_type: serviceType,
+ name: formData.get('name'),
+ config,
+ enabled: formData.get('enabled') === 'on',
+ priority: parseInt(formData.get('priority')) || 0
+ };
+
+ try {
+ await api.post('/email-services', data);
+ toast.success('服务添加成功');
+ elements.addCustomModal.classList.remove('active');
+ e.target.reset();
+ loadCustomServices();
+ loadStats();
+ } catch (error) {
+ toast.error('添加失败: ' + error.message);
+ }
+}
+
+// 切换服务状态
+async function toggleService(id, enabled) {
+ try {
+ await api.patch(`/email-services/${id}`, { enabled });
+ toast.success(enabled ? '已启用' : '已禁用');
+ loadOutlookServices();
+ loadCustomServices();
+ loadStats();
+ } catch (error) {
+ toast.error('操作失败: ' + error.message);
+ }
+}
+
+// 测试服务
+async function testService(id) {
+ try {
+ const result = await api.post(`/email-services/${id}/test`);
+ if (result.success) toast.success('测试成功');
+ else toast.error('测试失败: ' + (result.error || '未知错误'));
+ } catch (error) {
+ toast.error('测试失败: ' + error.message);
+ }
+}
+
+// 删除服务
+async function deleteService(id, name) {
+ const confirmed = await confirm(`确定要删除 "${name}" 吗?`);
+ if (!confirmed) return;
+ try {
+ await api.delete(`/email-services/${id}`);
+ toast.success('已删除');
+ selectedOutlook.delete(id);
+ selectedCustom.delete(id);
+ loadOutlookServices();
+ loadCustomServices();
+ loadStats();
+ } catch (error) {
+ toast.error('删除失败: ' + error.message);
+ }
+}
+
+// 批量删除 Outlook
+async function handleBatchDeleteOutlook() {
+ if (selectedOutlook.size === 0) return;
+ const confirmed = await confirm(`确定要删除选中的 ${selectedOutlook.size} 个账户吗?`);
+ if (!confirmed) return;
+ try {
+ const result = await api.request('/email-services/outlook/batch', {
+ method: 'DELETE',
+ body: Array.from(selectedOutlook)
+ });
+ toast.success(`成功删除 ${result.deleted || selectedOutlook.size} 个账户`);
+ selectedOutlook.clear();
+ loadOutlookServices();
+ loadStats();
+ } catch (error) {
+ toast.error('删除失败: ' + error.message);
+ }
+}
+
+// 保存临时邮箱配置
+async function handleSaveTempmail(e) {
+ e.preventDefault();
+ try {
+ await api.post('/settings/tempmail', {
+ api_url: elements.tempmailApi.value,
+ enabled: elements.tempmailEnabled.checked
+ });
+ toast.success('配置已保存');
+ } catch (error) {
+ toast.error('保存失败: ' + error.message);
+ }
+}
+
+// 测试临时邮箱
+async function handleTestTempmail() {
+ elements.testTempmailBtn.disabled = true;
+ elements.testTempmailBtn.textContent = '测试中...';
+ try {
+ const result = await api.post('/email-services/test-tempmail', {
+ api_url: elements.tempmailApi.value
+ });
+ if (result.success) toast.success('临时邮箱连接正常');
+ else toast.error('连接失败: ' + (result.error || '未知错误'));
+ } catch (error) {
+ toast.error('测试失败: ' + error.message);
+ } finally {
+ elements.testTempmailBtn.disabled = false;
+ elements.testTempmailBtn.textContent = '🔌 测试连接';
+ }
+}
+
+// 更新批量按钮
+function updateBatchButtons() {
+ const count = selectedOutlook.size;
+ elements.batchDeleteOutlookBtn.disabled = count === 0;
+ elements.batchDeleteOutlookBtn.textContent = count > 0 ? `🗑️ 删除选中 (${count})` : '🗑️ 批量删除';
+}
+
+// HTML 转义
+function escapeHtml(text) {
+ if (!text) return '';
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+}
+
+// ============== 编辑功能 ==============
+
+// 编辑自定义邮箱服务(支持 moemail / tempmail / duckmail)
+async function editCustomService(id, subType) {
+ try {
+ const service = await api.get(`/email-services/${id}/full`);
+ const resolvedSubType = subType || (
+ service.service_type === 'temp_mail'
+ ? 'tempmail'
+ : service.service_type === 'duck_mail'
+ ? 'duckmail'
+ : service.service_type === 'freemail'
+ ? 'freemail'
+ : service.service_type === 'imap_mail'
+ ? 'imap'
+ : 'moemail'
+ );
+
+ document.getElementById('edit-custom-id').value = service.id;
+ document.getElementById('edit-custom-name').value = service.name || '';
+ document.getElementById('edit-custom-priority').value = service.priority || 0;
+ document.getElementById('edit-custom-enabled').checked = service.enabled;
+
+ switchEditSubType(resolvedSubType);
+
+ if (resolvedSubType === 'moemail') {
+ document.getElementById('edit-custom-api-url').value = service.config?.base_url || '';
+ document.getElementById('edit-custom-api-key').value = '';
+ document.getElementById('edit-custom-api-key').placeholder = service.config?.api_key ? '已设置,留空保持不变' : 'API Key';
+ document.getElementById('edit-custom-domain').value = service.config?.default_domain || service.config?.domain || '';
+ } else if (resolvedSubType === 'tempmail') {
+ document.getElementById('edit-tm-base-url').value = service.config?.base_url || '';
+ document.getElementById('edit-tm-admin-password').value = '';
+ document.getElementById('edit-tm-admin-password').placeholder = service.config?.admin_password ? '已设置,留空保持不变' : '请输入 Admin 密码';
+ document.getElementById('edit-tm-domain').value = service.config?.domain || '';
+ } else if (resolvedSubType === 'duckmail') {
+ document.getElementById('edit-dm-base-url').value = service.config?.base_url || '';
+ document.getElementById('edit-dm-api-key').value = '';
+ document.getElementById('edit-dm-api-key').placeholder = service.config?.api_key ? '已设置,留空保持不变' : '请输入 API Key(可选)';
+ document.getElementById('edit-dm-domain').value = service.config?.default_domain || '';
+ document.getElementById('edit-dm-password-length').value = service.config?.password_length || 12;
+ } else if (resolvedSubType === 'freemail') {
+ document.getElementById('edit-fm-base-url').value = service.config?.base_url || '';
+ document.getElementById('edit-fm-admin-token').value = '';
+ document.getElementById('edit-fm-admin-token').placeholder = service.config?.admin_token ? '已设置,留空保持不变' : '请输入 Admin Token';
+ document.getElementById('edit-fm-domain').value = service.config?.domain || '';
+ } else {
+ document.getElementById('edit-imap-host').value = service.config?.host || '';
+ document.getElementById('edit-imap-port').value = service.config?.port || 993;
+ document.getElementById('edit-imap-use-ssl').value = service.config?.use_ssl !== false ? 'true' : 'false';
+ document.getElementById('edit-imap-email').value = service.config?.email || '';
+ document.getElementById('edit-imap-password').value = '';
+ document.getElementById('edit-imap-password').placeholder = service.config?.password ? '已设置,留空保持不变' : '请输入密码/授权码';
+ }
+
+ elements.editCustomModal.classList.add('active');
+ } catch (error) {
+ toast.error('获取服务信息失败: ' + error.message);
+ }
+}
+
+// 保存编辑自定义邮箱服务
+async function handleEditCustom(e) {
+ e.preventDefault();
+ const id = document.getElementById('edit-custom-id').value;
+ const formData = new FormData(e.target);
+ const subType = formData.get('sub_type');
+
+ let config;
+ if (subType === 'moemail') {
+ config = {
+ base_url: formData.get('api_url'),
+ default_domain: formData.get('domain')
+ };
+ const apiKey = formData.get('api_key');
+ if (apiKey && apiKey.trim()) config.api_key = apiKey.trim();
+ } else if (subType === 'tempmail') {
+ config = {
+ base_url: formData.get('tm_base_url'),
+ domain: formData.get('tm_domain'),
+ enable_prefix: true
+ };
+ const pwd = formData.get('tm_admin_password');
+ if (pwd && pwd.trim()) config.admin_password = pwd.trim();
+ } else if (subType === 'duckmail') {
+ config = {
+ base_url: formData.get('dm_base_url'),
+ default_domain: formData.get('dm_domain'),
+ password_length: parseInt(formData.get('dm_password_length'), 10) || 12
+ };
+ const apiKey = formData.get('dm_api_key');
+ if (apiKey && apiKey.trim()) config.api_key = apiKey.trim();
+ } else if (subType === 'freemail') {
+ config = {
+ base_url: formData.get('fm_base_url'),
+ domain: formData.get('fm_domain')
+ };
+ const token = formData.get('fm_admin_token');
+ if (token && token.trim()) config.admin_token = token.trim();
+ } else {
+ config = {
+ host: formData.get('imap_host'),
+ port: parseInt(formData.get('imap_port'), 10) || 993,
+ use_ssl: formData.get('imap_use_ssl') !== 'false',
+ email: formData.get('imap_email')
+ };
+ const pwd = formData.get('imap_password');
+ if (pwd && pwd.trim()) config.password = pwd.trim();
+ }
+
+ const updateData = {
+ name: formData.get('name'),
+ priority: parseInt(formData.get('priority')) || 0,
+ enabled: formData.get('enabled') === 'on',
+ config
+ };
+
+ try {
+ await api.patch(`/email-services/${id}`, updateData);
+ toast.success('服务更新成功');
+ elements.editCustomModal.classList.remove('active');
+ loadCustomServices();
+ loadStats();
+ } catch (error) {
+ toast.error('更新失败: ' + error.message);
+ }
+}
+
+// 编辑 Outlook 服务
+async function editOutlookService(id) {
+ try {
+ const service = await api.get(`/email-services/${id}/full`);
+ document.getElementById('edit-outlook-id').value = service.id;
+ document.getElementById('edit-outlook-email').value = service.config?.email || service.name || '';
+ document.getElementById('edit-outlook-password').value = '';
+ document.getElementById('edit-outlook-password').placeholder = service.config?.password ? '已设置,留空保持不变' : '请输入密码';
+ document.getElementById('edit-outlook-client-id').value = service.config?.client_id || '';
+ document.getElementById('edit-outlook-refresh-token').value = '';
+ document.getElementById('edit-outlook-refresh-token').placeholder = service.config?.refresh_token ? '已设置,留空保持不变' : 'OAuth Refresh Token';
+ document.getElementById('edit-outlook-priority').value = service.priority || 0;
+ document.getElementById('edit-outlook-enabled').checked = service.enabled;
+ elements.editOutlookModal.classList.add('active');
+ } catch (error) {
+ toast.error('获取服务信息失败: ' + error.message);
+ }
+}
+
+// 保存编辑 Outlook 服务
+async function handleEditOutlook(e) {
+ e.preventDefault();
+ const id = document.getElementById('edit-outlook-id').value;
+ const formData = new FormData(e.target);
+
+ let currentService;
+ try {
+ currentService = await api.get(`/email-services/${id}/full`);
+ } catch (error) {
+ toast.error('获取服务信息失败');
+ return;
+ }
+
+ const updateData = {
+ name: formData.get('email'),
+ priority: parseInt(formData.get('priority')) || 0,
+ enabled: formData.get('enabled') === 'on',
+ config: {
+ email: formData.get('email'),
+ password: formData.get('password')?.trim() || currentService.config?.password || '',
+ client_id: formData.get('client_id')?.trim() || currentService.config?.client_id || '',
+ refresh_token: formData.get('refresh_token')?.trim() || currentService.config?.refresh_token || ''
+ }
+ };
+
+ try {
+ await api.patch(`/email-services/${id}`, updateData);
+ toast.success('账户更新成功');
+ elements.editOutlookModal.classList.remove('active');
+ loadOutlookServices();
+ loadStats();
+ } catch (error) {
+ toast.error('更新失败: ' + error.message);
+ }
+}
diff --git a/static/js/payment.js b/static/js/payment.js
new file mode 100644
index 0000000..c170387
--- /dev/null
+++ b/static/js/payment.js
@@ -0,0 +1,146 @@
+/**
+ * 支付页面 JavaScript
+ */
+
+const 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',
+};
+
+let selectedPlan = 'plus';
+let generatedLink = '';
+
+// 初始化
+document.addEventListener('DOMContentLoaded', () => {
+ loadAccounts();
+});
+
+// 加载账号列表
+async function loadAccounts() {
+ try {
+ const resp = await fetch('/api/accounts?page=1&page_size=100&status=active');
+ const data = await resp.json();
+ const sel = document.getElementById('account-select');
+ sel.innerHTML = '';
+ (data.accounts || []).forEach(acc => {
+ const opt = document.createElement('option');
+ opt.value = acc.id;
+ opt.textContent = acc.email;
+ sel.appendChild(opt);
+ });
+ } catch (e) {
+ console.error('加载账号失败:', e);
+ }
+}
+
+// 国家切换
+function onCountryChange() {
+ const country = document.getElementById('country-select').value;
+ const currency = COUNTRY_CURRENCY_MAP[country] || 'USD';
+ document.getElementById('currency-display').value = currency;
+}
+
+// 选择套餐
+function selectPlan(plan) {
+ selectedPlan = plan;
+ document.getElementById('plan-plus').classList.toggle('selected', plan === 'plus');
+ document.getElementById('plan-team').classList.toggle('selected', plan === 'team');
+ document.getElementById('team-options').classList.toggle('show', plan === 'team');
+ // 隐藏已生成的链接
+ document.getElementById('link-box').classList.remove('show');
+ generatedLink = '';
+}
+
+// 生成支付链接
+async function generateLink() {
+ const accountId = document.getElementById('account-select').value;
+ if (!accountId) {
+ ui.showToast('请先选择账号', 'warning');
+ return;
+ }
+
+ const country = document.getElementById('country-select').value || 'SG';
+
+ const body = {
+ account_id: parseInt(accountId),
+ plan_type: selectedPlan,
+ country: country,
+ };
+
+ if (selectedPlan === 'team') {
+ body.workspace_name = document.getElementById('workspace-name').value || 'MyTeam';
+ body.seat_quantity = parseInt(document.getElementById('seat-quantity').value) || 5;
+ body.price_interval = document.getElementById('price-interval').value;
+ }
+
+ const btn = document.querySelector('.form-actions .btn-primary');
+ if (btn) { btn.disabled = true; btn.textContent = '生成中...'; }
+
+ try {
+ const resp = await fetch('/api/payment/generate-link', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ const data = await resp.json();
+ if (data.success && data.link) {
+ generatedLink = data.link;
+ document.getElementById('link-text').value = data.link;
+ document.getElementById('link-box').classList.add('show');
+ document.getElementById('open-status').textContent = '';
+ ui.showToast('支付链接生成成功', 'success');
+ } else {
+ ui.showToast(data.detail || '生成链接失败', 'error');
+ }
+ } catch (e) {
+ ui.showToast('请求失败: ' + e.message, 'error');
+ } finally {
+ if (btn) { btn.disabled = false; btn.textContent = '生成支付链接'; }
+ }
+}
+
+// 复制链接
+function copyLink() {
+ if (!generatedLink) return;
+ navigator.clipboard.writeText(generatedLink).then(() => {
+ ui.showToast('已复制到剪贴板', 'success');
+ }).catch(() => {
+ const ta = document.getElementById('link-text');
+ ta.select();
+ document.execCommand('copy');
+ ui.showToast('已复制到剪贴板', 'success');
+ });
+}
+
+// 无痕打开浏览器(携带账号 cookie)
+async function openIncognito() {
+ if (!generatedLink) {
+ ui.showToast('请先生成链接', 'warning');
+ return;
+ }
+ const accountId = document.getElementById('account-select').value;
+ const statusEl = document.getElementById('open-status');
+ statusEl.textContent = '正在打开...';
+ try {
+ const body = { url: generatedLink };
+ if (accountId) body.account_id = parseInt(accountId);
+
+ const resp = await fetch('/api/payment/open-incognito', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ const data = await resp.json();
+ if (data.success) {
+ statusEl.textContent = '已在无痕模式打开浏览器';
+ ui.showToast('无痕浏览器已打开', 'success');
+ } else {
+ statusEl.textContent = data.message || '未找到可用浏览器,请手动复制链接';
+ ui.showToast(data.message || '未找到浏览器', 'warning');
+ }
+ } catch (e) {
+ statusEl.textContent = '请求失败: ' + e.message;
+ ui.showToast('请求失败', 'error');
+ }
+}
diff --git a/static/js/settings.js b/static/js/settings.js
new file mode 100644
index 0000000..f167e15
--- /dev/null
+++ b/static/js/settings.js
@@ -0,0 +1,1548 @@
+/**
+ * 设置页面 JavaScript
+ * 使用 utils.js 中的工具库
+ */
+
+// DOM 元素
+const elements = {
+ tabs: document.querySelectorAll('.tab-btn'),
+ tabContents: document.querySelectorAll('.tab-content'),
+ registrationForm: document.getElementById('registration-settings-form'),
+ backupBtn: document.getElementById('backup-btn'),
+ cleanupBtn: document.getElementById('cleanup-btn'),
+ addEmailServiceBtn: document.getElementById('add-email-service-btn'),
+ addServiceModal: document.getElementById('add-service-modal'),
+ addServiceForm: document.getElementById('add-service-form'),
+ closeServiceModal: document.getElementById('close-service-modal'),
+ cancelAddService: document.getElementById('cancel-add-service'),
+ serviceType: document.getElementById('service-type'),
+ serviceConfigFields: document.getElementById('service-config-fields'),
+ emailServicesTable: document.getElementById('email-services-table'),
+ // Outlook 导入
+ toggleImportBtn: document.getElementById('toggle-import-btn'),
+ outlookImportBody: document.getElementById('outlook-import-body'),
+ outlookImportBtn: document.getElementById('outlook-import-btn'),
+ clearImportBtn: document.getElementById('clear-import-btn'),
+ outlookImportData: document.getElementById('outlook-import-data'),
+ importResult: document.getElementById('import-result'),
+ // 批量操作
+ selectAllServices: document.getElementById('select-all-services'),
+ // 代理列表
+ proxiesTable: document.getElementById('proxies-table'),
+ addProxyBtn: document.getElementById('add-proxy-btn'),
+ testAllProxiesBtn: document.getElementById('test-all-proxies-btn'),
+ addProxyModal: document.getElementById('add-proxy-modal'),
+ proxyItemForm: document.getElementById('proxy-item-form'),
+ closeProxyModal: document.getElementById('close-proxy-modal'),
+ cancelProxyBtn: document.getElementById('cancel-proxy-btn'),
+ proxyModalTitle: document.getElementById('proxy-modal-title'),
+ // 动态代理设置
+ dynamicProxyForm: document.getElementById('dynamic-proxy-form'),
+ testDynamicProxyBtn: document.getElementById('test-dynamic-proxy-btn'),
+ // CPA 服务管理
+ addCpaServiceBtn: document.getElementById('add-cpa-service-btn'),
+ cpaServicesTable: document.getElementById('cpa-services-table'),
+ cpaServiceEditModal: document.getElementById('cpa-service-edit-modal'),
+ closeCpaServiceModal: document.getElementById('close-cpa-service-modal'),
+ cancelCpaServiceBtn: document.getElementById('cancel-cpa-service-btn'),
+ cpaServiceForm: document.getElementById('cpa-service-form'),
+ cpaServiceModalTitle: document.getElementById('cpa-service-modal-title'),
+ testCpaServiceBtn: document.getElementById('test-cpa-service-btn'),
+ // Sub2API 服务管理
+ addSub2ApiServiceBtn: document.getElementById('add-sub2api-service-btn'),
+ sub2ApiServicesTable: document.getElementById('sub2api-services-table'),
+ sub2ApiServiceEditModal: document.getElementById('sub2api-service-edit-modal'),
+ closeSub2ApiServiceModal: document.getElementById('close-sub2api-service-modal'),
+ cancelSub2ApiServiceBtn: document.getElementById('cancel-sub2api-service-btn'),
+ sub2ApiServiceForm: document.getElementById('sub2api-service-form'),
+ sub2ApiServiceModalTitle: document.getElementById('sub2api-service-modal-title'),
+ testSub2ApiServiceBtn: document.getElementById('test-sub2api-service-btn'),
+ // Team Manager 服务管理
+ addTmServiceBtn: document.getElementById('add-tm-service-btn'),
+ tmServicesTable: document.getElementById('tm-services-table'),
+ tmServiceEditModal: document.getElementById('tm-service-edit-modal'),
+ closeTmServiceModal: document.getElementById('close-tm-service-modal'),
+ cancelTmServiceBtn: document.getElementById('cancel-tm-service-btn'),
+ tmServiceForm: document.getElementById('tm-service-form'),
+ tmServiceModalTitle: document.getElementById('tm-service-modal-title'),
+ testTmServiceBtn: document.getElementById('test-tm-service-btn'),
+ // 验证码设置
+ emailCodeForm: document.getElementById('email-code-form'),
+ // Outlook 设置
+ outlookSettingsForm: document.getElementById('outlook-settings-form'),
+ // Web UI 访问控制
+ webuiSettingsForm: document.getElementById('webui-settings-form')
+};
+
+// 选中的服务 ID
+let selectedServiceIds = new Set();
+
+// 初始化
+document.addEventListener('DOMContentLoaded', () => {
+ initTabs();
+ loadSettings();
+ loadEmailServices();
+ loadDatabaseInfo();
+ loadProxies();
+ loadCpaServices();
+ loadSub2ApiServices();
+ loadTmServices();
+ initEventListeners();
+});
+
+document.addEventListener('click', () => {
+ document.querySelectorAll('.dropdown-menu.active').forEach(m => m.classList.remove('active'));
+});
+
+// 初始化标签页
+function initTabs() {
+ elements.tabs.forEach(btn => {
+ btn.addEventListener('click', () => {
+ const tab = btn.dataset.tab;
+
+ elements.tabs.forEach(b => b.classList.remove('active'));
+ elements.tabContents.forEach(c => c.classList.remove('active'));
+
+ btn.classList.add('active');
+ document.getElementById(`${tab}-tab`).classList.add('active');
+ });
+ });
+}
+
+// 事件监听
+function initEventListeners() {
+ // 注册配置表单
+ if (elements.registrationForm) {
+ elements.registrationForm.addEventListener('submit', handleSaveRegistration);
+ }
+
+ // 备份数据库
+ if (elements.backupBtn) {
+ elements.backupBtn.addEventListener('click', handleBackup);
+ }
+
+ // 清理数据
+ if (elements.cleanupBtn) {
+ elements.cleanupBtn.addEventListener('click', handleCleanup);
+ }
+
+ // 添加邮箱服务
+ if (elements.addEmailServiceBtn) {
+ elements.addEmailServiceBtn.addEventListener('click', () => {
+ elements.addServiceModal.classList.add('active');
+ loadServiceConfigFields(elements.serviceType.value);
+ });
+ }
+
+ if (elements.closeServiceModal) {
+ elements.closeServiceModal.addEventListener('click', () => {
+ elements.addServiceModal.classList.remove('active');
+ });
+ }
+
+ if (elements.cancelAddService) {
+ elements.cancelAddService.addEventListener('click', () => {
+ elements.addServiceModal.classList.remove('active');
+ });
+ }
+
+ if (elements.addServiceModal) {
+ elements.addServiceModal.addEventListener('click', (e) => {
+ if (e.target === elements.addServiceModal) {
+ elements.addServiceModal.classList.remove('active');
+ }
+ });
+ }
+
+ // 服务类型切换
+ if (elements.serviceType) {
+ elements.serviceType.addEventListener('change', (e) => {
+ loadServiceConfigFields(e.target.value);
+ });
+ }
+
+ // 添加服务表单
+ if (elements.addServiceForm) {
+ elements.addServiceForm.addEventListener('submit', handleAddService);
+ }
+
+ // Outlook 批量导入展开/折叠
+ if (elements.toggleImportBtn) {
+ elements.toggleImportBtn.addEventListener('click', () => {
+ const isHidden = elements.outlookImportBody.style.display === 'none';
+ elements.outlookImportBody.style.display = isHidden ? 'block' : 'none';
+ elements.toggleImportBtn.textContent = isHidden ? '收起' : '展开';
+ });
+ }
+
+ // Outlook 批量导入
+ if (elements.outlookImportBtn) {
+ elements.outlookImportBtn.addEventListener('click', handleOutlookBatchImport);
+ }
+
+ // 清空导入数据
+ if (elements.clearImportBtn) {
+ elements.clearImportBtn.addEventListener('click', () => {
+ elements.outlookImportData.value = '';
+ elements.importResult.style.display = 'none';
+ });
+ }
+
+ // 全选/取消全选
+ if (elements.selectAllServices) {
+ elements.selectAllServices.addEventListener('change', (e) => {
+ const checkboxes = document.querySelectorAll('.service-checkbox');
+ checkboxes.forEach(cb => cb.checked = e.target.checked);
+ updateSelectedServices();
+ });
+ }
+
+ // 代理列表相关
+ if (elements.addProxyBtn) {
+ elements.addProxyBtn.addEventListener('click', () => openProxyModal());
+ }
+
+ if (elements.testAllProxiesBtn) {
+ elements.testAllProxiesBtn.addEventListener('click', handleTestAllProxies);
+ }
+
+ if (elements.closeProxyModal) {
+ elements.closeProxyModal.addEventListener('click', closeProxyModal);
+ }
+
+ if (elements.cancelProxyBtn) {
+ elements.cancelProxyBtn.addEventListener('click', closeProxyModal);
+ }
+
+ if (elements.addProxyModal) {
+ elements.addProxyModal.addEventListener('click', (e) => {
+ if (e.target === elements.addProxyModal) {
+ closeProxyModal();
+ }
+ });
+ }
+
+ if (elements.proxyItemForm) {
+ elements.proxyItemForm.addEventListener('submit', handleSaveProxyItem);
+ }
+
+ // 动态代理设置
+ if (elements.dynamicProxyForm) {
+ elements.dynamicProxyForm.addEventListener('submit', handleSaveDynamicProxy);
+ }
+ if (elements.testDynamicProxyBtn) {
+ elements.testDynamicProxyBtn.addEventListener('click', handleTestDynamicProxy);
+ }
+
+ // 验证码设置
+ if (elements.emailCodeForm) {
+ elements.emailCodeForm.addEventListener('submit', handleSaveEmailCode);
+ }
+
+ // Outlook 设置
+ if (elements.outlookSettingsForm) {
+ elements.outlookSettingsForm.addEventListener('submit', handleSaveOutlookSettings);
+ }
+
+ if (elements.webuiSettingsForm) {
+ elements.webuiSettingsForm.addEventListener('submit', handleSaveWebuiSettings);
+ }
+ // Team Manager 服务管理
+ if (elements.addTmServiceBtn) {
+ elements.addTmServiceBtn.addEventListener('click', () => openTmServiceModal());
+ }
+ if (elements.closeTmServiceModal) {
+ elements.closeTmServiceModal.addEventListener('click', closeTmServiceModal);
+ }
+ if (elements.cancelTmServiceBtn) {
+ elements.cancelTmServiceBtn.addEventListener('click', closeTmServiceModal);
+ }
+ if (elements.tmServiceEditModal) {
+ elements.tmServiceEditModal.addEventListener('click', (e) => {
+ if (e.target === elements.tmServiceEditModal) closeTmServiceModal();
+ });
+ }
+ if (elements.tmServiceForm) {
+ elements.tmServiceForm.addEventListener('submit', handleSaveTmService);
+ }
+ if (elements.testTmServiceBtn) {
+ elements.testTmServiceBtn.addEventListener('click', handleTestTmService);
+ }
+
+ // CPA 服务管理
+ if (elements.addCpaServiceBtn) {
+ elements.addCpaServiceBtn.addEventListener('click', () => openCpaServiceModal());
+ }
+ if (elements.closeCpaServiceModal) {
+ elements.closeCpaServiceModal.addEventListener('click', closeCpaServiceModal);
+ }
+ if (elements.cancelCpaServiceBtn) {
+ elements.cancelCpaServiceBtn.addEventListener('click', closeCpaServiceModal);
+ }
+ if (elements.cpaServiceEditModal) {
+ elements.cpaServiceEditModal.addEventListener('click', (e) => {
+ if (e.target === elements.cpaServiceEditModal) closeCpaServiceModal();
+ });
+ }
+ if (elements.cpaServiceForm) {
+ elements.cpaServiceForm.addEventListener('submit', handleSaveCpaService);
+ }
+ if (elements.testCpaServiceBtn) {
+ elements.testCpaServiceBtn.addEventListener('click', handleTestCpaService);
+ }
+
+ // Sub2API 服务管理
+ if (elements.addSub2ApiServiceBtn) {
+ elements.addSub2ApiServiceBtn.addEventListener('click', () => openSub2ApiServiceModal());
+ }
+ if (elements.closeSub2ApiServiceModal) {
+ elements.closeSub2ApiServiceModal.addEventListener('click', closeSub2ApiServiceModal);
+ }
+ if (elements.cancelSub2ApiServiceBtn) {
+ elements.cancelSub2ApiServiceBtn.addEventListener('click', closeSub2ApiServiceModal);
+ }
+ if (elements.sub2ApiServiceEditModal) {
+ elements.sub2ApiServiceEditModal.addEventListener('click', (e) => {
+ if (e.target === elements.sub2ApiServiceEditModal) closeSub2ApiServiceModal();
+ });
+ }
+ if (elements.sub2ApiServiceForm) {
+ elements.sub2ApiServiceForm.addEventListener('submit', handleSaveSub2ApiService);
+ }
+ if (elements.testSub2ApiServiceBtn) {
+ elements.testSub2ApiServiceBtn.addEventListener('click', handleTestSub2ApiService);
+ }
+}
+
+// 加载设置
+async function loadSettings() {
+ try {
+ const data = await api.get('/settings');
+
+ // 动态代理设置
+ document.getElementById('dynamic-proxy-enabled').checked = data.proxy?.dynamic_enabled || false;
+ document.getElementById('dynamic-proxy-api-url').value = data.proxy?.dynamic_api_url || '';
+ document.getElementById('dynamic-proxy-api-key-header').value = data.proxy?.dynamic_api_key_header || 'X-API-Key';
+ document.getElementById('dynamic-proxy-result-field').value = data.proxy?.dynamic_result_field || '';
+
+ // 注册配置
+ document.getElementById('max-retries').value = data.registration?.max_retries || 3;
+ document.getElementById('timeout').value = data.registration?.timeout || 120;
+ document.getElementById('password-length').value = data.registration?.default_password_length || 12;
+ document.getElementById('sleep-min').value = data.registration?.sleep_min || 5;
+ document.getElementById('sleep-max').value = data.registration?.sleep_max || 30;
+ if (document.getElementById('registration-engine')) document.getElementById('registration-engine').value = data.registration?.engine || 'http';
+ if (document.getElementById('playwright-pool-size')) document.getElementById('playwright-pool-size').value = data.registration?.playwright_pool_size || 5;
+
+ // 验证码等待配置
+ if (data.email_code) {
+ document.getElementById('email-code-timeout').value = data.email_code.timeout || 120;
+ document.getElementById('email-code-poll-interval').value = data.email_code.poll_interval || 3;
+ }
+
+ // 加载 Outlook 设置
+ loadOutlookSettings();
+
+ // Web UI 访问密码提示
+ if (data.webui?.has_access_password) {
+ const input = document.getElementById('webui-access-password');
+ if (input) {
+ input.value = '';
+ input.placeholder = '已配置,留空保持不变';
+ }
+ }
+
+ } catch (error) {
+ console.error('加载设置失败:', error);
+ toast.error('加载设置失败');
+ }
+}
+
+// 保存 Web UI 设置
+async function handleSaveWebuiSettings(e) {
+ e.preventDefault();
+
+ const accessPassword = document.getElementById('webui-access-password').value;
+ const payload = {
+ access_password: accessPassword || null
+ };
+
+ try {
+ await api.post('/settings/webui', payload);
+ toast.success('Web UI 设置已更新');
+ document.getElementById('webui-access-password').value = '';
+ } catch (error) {
+ console.error('保存 Web UI 设置失败:', error);
+ toast.error('保存 Web UI 设置失败');
+ }
+}
+
+// 加载邮箱服务
+async function loadEmailServices() {
+ // 检查元素是否存在
+ if (!elements.emailServicesTable) return;
+
+ try {
+ const data = await api.get('/email-services');
+ renderEmailServices(data.services);
+ } catch (error) {
+ console.error('加载邮箱服务失败:', error);
+ if (elements.emailServicesTable) {
+ elements.emailServicesTable.innerHTML = `
+
+ |
+
+ |
+
+ `;
+ }
+ }
+}
+
+// 渲染邮箱服务
+function renderEmailServices(services) {
+ // 检查元素是否存在
+ if (!elements.emailServicesTable) return;
+
+ if (services.length === 0) {
+ elements.emailServicesTable.innerHTML = `
+
+
+
+ 📭
+ 暂无配置
+ 点击上方"添加服务"按钮添加邮箱服务
+
+ |
+
+ `;
+ return;
+ }
+
+ elements.emailServicesTable.innerHTML = services.map(service => `
+
+ |
+
+ |
+ ${escapeHtml(service.name)} |
+ ${getServiceTypeText(service.service_type)} |
+ ${service.enabled ? '✅' : '⭕'} |
+ ${service.priority} |
+ ${format.date(service.last_used)} |
+
+
+
+
+
+
+ |
+
+ `).join('');
+}
+
+// 加载数据库信息
+async function loadDatabaseInfo() {
+ try {
+ const data = await api.get('/settings/database');
+
+ document.getElementById('db-size').textContent = `${data.database_size_mb} MB`;
+ document.getElementById('db-accounts').textContent = format.number(data.accounts_count);
+ document.getElementById('db-services').textContent = format.number(data.email_services_count);
+ document.getElementById('db-tasks').textContent = format.number(data.tasks_count);
+
+ } catch (error) {
+ console.error('加载数据库信息失败:', error);
+ }
+}
+
+// 保存注册配置
+async function handleSaveRegistration(e) {
+ e.preventDefault();
+
+ const data = {
+ max_retries: parseInt(document.getElementById('max-retries').value),
+ timeout: parseInt(document.getElementById('timeout').value),
+ default_password_length: parseInt(document.getElementById('password-length').value),
+ sleep_min: parseInt(document.getElementById('sleep-min').value),
+ sleep_max: parseInt(document.getElementById('sleep-max').value),
+ engine: document.getElementById('registration-engine') ? document.getElementById('registration-engine').value : 'http',
+ playwright_pool_size: document.getElementById('playwright-pool-size') ? parseInt(document.getElementById('playwright-pool-size').value) : 5,
+ };
+
+ try {
+ await api.post('/settings/registration', data);
+ toast.success('注册配置已保存');
+ } catch (error) {
+ toast.error('保存失败: ' + error.message);
+ }
+}
+
+// 保存验证码等待配置
+async function handleSaveEmailCode(e) {
+ e.preventDefault();
+
+ const timeout = parseInt(document.getElementById('email-code-timeout').value);
+ const pollInterval = parseInt(document.getElementById('email-code-poll-interval').value);
+
+ // 客户端验证
+ if (timeout < 30 || timeout > 600) {
+ toast.error('等待超时必须在 30-600 秒之间');
+ return;
+ }
+ if (pollInterval < 1 || pollInterval > 30) {
+ toast.error('轮询间隔必须在 1-30 秒之间');
+ return;
+ }
+
+ const data = {
+ timeout: timeout,
+ poll_interval: pollInterval
+ };
+
+ try {
+ await api.post('/settings/email-code', data);
+ toast.success('验证码配置已保存');
+ } catch (error) {
+ toast.error('保存失败: ' + error.message);
+ }
+}
+
+// 备份数据库
+async function handleBackup() {
+ elements.backupBtn.disabled = true;
+ elements.backupBtn.innerHTML = ' 备份中...';
+
+ try {
+ const data = await api.post('/settings/database/backup');
+ toast.success(`备份成功: ${data.backup_path}`);
+ } catch (error) {
+ toast.error('备份失败: ' + error.message);
+ } finally {
+ elements.backupBtn.disabled = false;
+ elements.backupBtn.textContent = '💾 备份数据库';
+ }
+}
+
+// 清理数据
+async function handleCleanup() {
+ const confirmed = await confirm('确定要清理过期数据吗?此操作不可恢复。');
+ if (!confirmed) return;
+
+ elements.cleanupBtn.disabled = true;
+ elements.cleanupBtn.innerHTML = ' 清理中...';
+
+ try {
+ const data = await api.post('/settings/database/cleanup?days=30');
+ toast.success(data.message);
+ loadDatabaseInfo();
+ } catch (error) {
+ toast.error('清理失败: ' + error.message);
+ } finally {
+ elements.cleanupBtn.disabled = false;
+ elements.cleanupBtn.textContent = '🧹 清理过期数据';
+ }
+}
+
+// 加载服务配置字段
+async function loadServiceConfigFields(serviceType) {
+ try {
+ const data = await api.get('/email-services/types');
+ const typeInfo = data.types.find(t => t.value === serviceType);
+
+ if (!typeInfo) {
+ elements.serviceConfigFields.innerHTML = '';
+ return;
+ }
+
+ elements.serviceConfigFields.innerHTML = typeInfo.config_fields.map(field => `
+
+
+
+
+ `).join('');
+
+ } catch (error) {
+ console.error('加载配置字段失败:', error);
+ }
+}
+
+// 添加邮箱服务
+async function handleAddService(e) {
+ e.preventDefault();
+
+ const formData = new FormData(elements.addServiceForm);
+ const config = {};
+
+ elements.serviceConfigFields.querySelectorAll('input').forEach(input => {
+ config[input.name] = input.value;
+ });
+
+ const data = {
+ service_type: formData.get('service_type'),
+ name: formData.get('name'),
+ config: config,
+ enabled: true,
+ priority: 0,
+ };
+
+ try {
+ await api.post('/email-services', data);
+ toast.success('邮箱服务已添加');
+ elements.addServiceModal.classList.remove('active');
+ elements.addServiceForm.reset();
+ loadEmailServices();
+ } catch (error) {
+ toast.error('添加失败: ' + error.message);
+ }
+}
+
+// 测试服务
+async function testService(id) {
+ try {
+ const data = await api.post(`/email-services/${id}/test`);
+ if (data.success) {
+ toast.success('服务连接正常');
+ } else {
+ toast.warning('服务连接失败: ' + data.message);
+ }
+ } catch (error) {
+ toast.error('测试失败: ' + error.message);
+ }
+}
+
+// 切换服务状态
+async function toggleService(id, enabled) {
+ try {
+ const endpoint = enabled ? 'enable' : 'disable';
+ await api.post(`/email-services/${id}/${endpoint}`);
+ toast.success(enabled ? '服务已启用' : '服务已禁用');
+ loadEmailServices();
+ } catch (error) {
+ toast.error('操作失败: ' + error.message);
+ }
+}
+
+// 删除服务
+async function deleteService(id) {
+ const confirmed = await confirm('确定要删除此邮箱服务配置吗?');
+ if (!confirmed) return;
+
+ try {
+ await api.delete(`/email-services/${id}`);
+ toast.success('服务已删除');
+ loadEmailServices();
+ } catch (error) {
+ toast.error('删除失败: ' + error.message);
+ }
+}
+
+// 更新选中的服务
+function updateSelectedServices() {
+ selectedServiceIds.clear();
+ document.querySelectorAll('.service-checkbox:checked').forEach(cb => {
+ selectedServiceIds.add(parseInt(cb.dataset.id));
+ });
+}
+
+// Outlook 批量导入
+async function handleOutlookBatchImport() {
+ const data = elements.outlookImportData.value.trim();
+ if (!data) {
+ toast.warning('请输入要导入的数据');
+ return;
+ }
+
+ const enabled = document.getElementById('outlook-import-enabled').checked;
+ const priority = parseInt(document.getElementById('outlook-import-priority').value) || 0;
+
+ // 解析数据
+ const lines = data.split('\n').filter(line => line.trim() && !line.trim().startsWith('#'));
+ const accounts = [];
+ const errors = [];
+
+ lines.forEach((line, index) => {
+ const parts = line.split('----').map(p => p.trim());
+ if (parts.length < 2) {
+ errors.push(`第 ${index + 1} 行格式错误`);
+ return;
+ }
+
+ const account = {
+ email: parts[0],
+ password: parts[1],
+ client_id: parts[2] || null,
+ refresh_token: parts[3] || null,
+ enabled: enabled,
+ priority: priority
+ };
+
+ if (!account.email.includes('@')) {
+ errors.push(`第 ${index + 1} 行邮箱格式错误: ${account.email}`);
+ return;
+ }
+
+ accounts.push(account);
+ });
+
+ if (errors.length > 0) {
+ elements.importResult.style.display = 'block';
+ elements.importResult.innerHTML = `
+ ${errors.map(e => `
${e}
`).join('')}
+ `;
+ return;
+ }
+
+ elements.outlookImportBtn.disabled = true;
+ elements.outlookImportBtn.innerHTML = ' 导入中...';
+
+ let successCount = 0;
+ let failCount = 0;
+
+ try {
+ for (const account of accounts) {
+ try {
+ await api.post('/email-services', {
+ service_type: 'outlook',
+ name: account.email,
+ config: {
+ email: account.email,
+ password: account.password,
+ client_id: account.client_id,
+ refresh_token: account.refresh_token
+ },
+ enabled: account.enabled,
+ priority: account.priority
+ });
+ successCount++;
+ } catch {
+ failCount++;
+ }
+ }
+
+ elements.importResult.style.display = 'block';
+ elements.importResult.innerHTML = `
+
+ ✅ 成功: ${successCount}
+ ❌ 失败: ${failCount}
+
+ `;
+
+ toast.success(`导入完成,成功 ${successCount} 个`);
+ loadEmailServices();
+
+ } catch (error) {
+ toast.error('导入失败: ' + error.message);
+ } finally {
+ elements.outlookImportBtn.disabled = false;
+ elements.outlookImportBtn.textContent = '📥 开始导入';
+ }
+}
+
+// HTML 转义
+function escapeHtml(text) {
+ if (!text) return '';
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+}
+
+
+// ============================================================================
+// 代理列表管理
+// ============================================================================
+
+// 加载代理列表
+async function loadProxies() {
+ try {
+ const data = await api.get('/settings/proxies');
+ renderProxies(data.proxies);
+ } catch (error) {
+ console.error('加载代理列表失败:', error);
+ elements.proxiesTable.innerHTML = `
+
+ |
+
+ |
+
+ `;
+ }
+}
+
+// 渲染代理列表
+function renderProxies(proxies) {
+ if (!proxies || proxies.length === 0) {
+ elements.proxiesTable.innerHTML = `
+
+
+
+ 🌐
+ 暂无代理
+ 点击"添加代理"按钮添加代理服务器
+
+ |
+
+ `;
+ return;
+ }
+
+ elements.proxiesTable.innerHTML = proxies.map(proxy => `
+
+ | ${proxy.id} |
+ ${escapeHtml(proxy.name)} |
+ ${proxy.type.toUpperCase()} |
+ ${escapeHtml(proxy.host)}:${proxy.port} |
+
+ ${proxy.is_default
+ ? '默认'
+ : ``
+ }
+ |
+ ${proxy.enabled ? '✅' : '⭕'} |
+ ${format.date(proxy.last_used)} |
+
+
+ |
+
+ `).join('');
+}
+
+function toggleSettingsMoreMenu(btn) {
+ const menu = btn.nextElementSibling;
+ const isActive = menu.classList.contains('active');
+ document.querySelectorAll('.dropdown-menu.active').forEach(m => m.classList.remove('active'));
+ if (!isActive) menu.classList.add('active');
+}
+
+function closeSettingsMoreMenu(el) {
+ const menu = el.closest('.dropdown-menu');
+ if (menu) menu.classList.remove('active');
+}
+
+// 设为默认代理
+async function handleSetProxyDefault(id) {
+ try {
+ await api.post(`/settings/proxies/${id}/set-default`);
+ toast.success('已设为默认代理');
+ loadProxies();
+ } catch (error) {
+ toast.error('操作失败: ' + error.message);
+ }
+}
+
+// 打开代理模态框
+function openProxyModal(proxy = null) {
+ elements.proxyModalTitle.textContent = proxy ? '编辑代理' : '添加代理';
+ elements.proxyItemForm.reset();
+
+ document.getElementById('proxy-item-id').value = proxy ? proxy.id : '';
+
+ if (proxy) {
+ document.getElementById('proxy-item-name').value = proxy.name || '';
+ document.getElementById('proxy-item-type').value = proxy.type || 'http';
+ document.getElementById('proxy-item-host').value = proxy.host || '';
+ document.getElementById('proxy-item-port').value = proxy.port || '';
+ document.getElementById('proxy-item-username').value = proxy.username || '';
+ document.getElementById('proxy-item-password').value = '';
+ }
+
+ elements.addProxyModal.classList.add('active');
+}
+
+// 关闭代理模态框
+function closeProxyModal() {
+ elements.addProxyModal.classList.remove('active');
+ elements.proxyItemForm.reset();
+}
+
+// 保存代理
+async function handleSaveProxyItem(e) {
+ e.preventDefault();
+
+ const proxyId = document.getElementById('proxy-item-id').value;
+ const data = {
+ name: document.getElementById('proxy-item-name').value,
+ type: document.getElementById('proxy-item-type').value,
+ host: document.getElementById('proxy-item-host').value,
+ port: parseInt(document.getElementById('proxy-item-port').value),
+ username: document.getElementById('proxy-item-username').value || null,
+ password: document.getElementById('proxy-item-password').value || null,
+ enabled: true
+ };
+
+ try {
+ if (proxyId) {
+ await api.patch(`/settings/proxies/${proxyId}`, data);
+ toast.success('代理已更新');
+ } else {
+ await api.post('/settings/proxies', data);
+ toast.success('代理已添加');
+ }
+ closeProxyModal();
+ loadProxies();
+ } catch (error) {
+ toast.error('保存失败: ' + error.message);
+ }
+}
+
+// 编辑代理
+async function editProxyItem(id) {
+ try {
+ const proxy = await api.get(`/settings/proxies/${id}`);
+ openProxyModal(proxy);
+ } catch (error) {
+ toast.error('获取代理信息失败');
+ }
+}
+
+// 测试单个代理
+async function testProxyItem(id) {
+ try {
+ const result = await api.post(`/settings/proxies/${id}/test`);
+ if (result.success) {
+ toast.success(result.message);
+ } else {
+ toast.error(result.message);
+ }
+ } catch (error) {
+ toast.error('测试失败: ' + error.message);
+ }
+}
+
+// 切换代理状态
+async function toggleProxyItem(id, enabled) {
+ try {
+ const endpoint = enabled ? 'enable' : 'disable';
+ await api.post(`/settings/proxies/${id}/${endpoint}`);
+ toast.success(enabled ? '代理已启用' : '代理已禁用');
+ loadProxies();
+ } catch (error) {
+ toast.error('操作失败: ' + error.message);
+ }
+}
+
+// 删除代理
+async function deleteProxyItem(id) {
+ const confirmed = await confirm('确定要删除此代理吗?');
+ if (!confirmed) return;
+
+ try {
+ await api.delete(`/settings/proxies/${id}`);
+ toast.success('代理已删除');
+ loadProxies();
+ } catch (error) {
+ toast.error('删除失败: ' + error.message);
+ }
+}
+
+// 测试所有代理
+async function handleTestAllProxies() {
+ elements.testAllProxiesBtn.disabled = true;
+ elements.testAllProxiesBtn.innerHTML = ' 测试中...';
+
+ try {
+ const result = await api.post('/settings/proxies/test-all');
+ toast.info(`测试完成: 成功 ${result.success}, 失败 ${result.failed}`);
+ loadProxies();
+ } catch (error) {
+ toast.error('测试失败: ' + error.message);
+ } finally {
+ elements.testAllProxiesBtn.disabled = false;
+ elements.testAllProxiesBtn.textContent = '🔌 测试全部';
+ }
+}
+
+
+// ============================================================================
+// Outlook 设置管理
+// ============================================================================
+
+// 加载 Outlook 设置
+async function loadOutlookSettings() {
+ try {
+ const data = await api.get('/settings/outlook');
+ const el = document.getElementById('outlook-default-client-id');
+ if (el) el.value = data.default_client_id || '';
+ } catch (error) {
+ console.error('加载 Outlook 设置失败:', error);
+ }
+}
+
+// 保存 Outlook 设置
+async function handleSaveOutlookSettings(e) {
+ e.preventDefault();
+ const data = {
+ default_client_id: document.getElementById('outlook-default-client-id').value
+ };
+ try {
+ await api.post('/settings/outlook', data);
+ toast.success('Outlook 设置已保存');
+ } catch (error) {
+ toast.error('保存失败: ' + error.message);
+ }
+}
+
+// ============== 动态代理设置 ==============
+
+async function handleSaveDynamicProxy(e) {
+ e.preventDefault();
+ const data = {
+ enabled: document.getElementById('dynamic-proxy-enabled').checked,
+ api_url: document.getElementById('dynamic-proxy-api-url').value.trim(),
+ api_key: document.getElementById('dynamic-proxy-api-key').value || null,
+ api_key_header: document.getElementById('dynamic-proxy-api-key-header').value.trim() || 'X-API-Key',
+ result_field: document.getElementById('dynamic-proxy-result-field').value.trim()
+ };
+ try {
+ await api.post('/settings/proxy/dynamic', data);
+ toast.success('动态代理设置已保存');
+ document.getElementById('dynamic-proxy-api-key').value = '';
+ } catch (error) {
+ toast.error('保存失败: ' + error.message);
+ }
+}
+
+async function handleTestDynamicProxy() {
+ const apiUrl = document.getElementById('dynamic-proxy-api-url').value.trim();
+ if (!apiUrl) {
+ toast.warning('请先填写动态代理 API 地址');
+ return;
+ }
+ const btn = elements.testDynamicProxyBtn;
+ btn.disabled = true;
+ btn.textContent = '测试中...';
+ try {
+ const result = await api.post('/settings/proxy/dynamic/test', {
+ api_url: apiUrl,
+ api_key: document.getElementById('dynamic-proxy-api-key').value || null,
+ api_key_header: document.getElementById('dynamic-proxy-api-key-header').value.trim() || 'X-API-Key',
+ result_field: document.getElementById('dynamic-proxy-result-field').value.trim()
+ });
+ if (result.success) {
+ toast.success(result.message);
+ } else {
+ toast.error(result.message);
+ }
+ } catch (error) {
+ toast.error('测试失败: ' + error.message);
+ } finally {
+ btn.disabled = false;
+ btn.textContent = '🔌 测试动态代理';
+ }
+}
+
+// ============== Team Manager 服务管理 ==============
+
+async function loadTmServices() {
+ if (!elements.tmServicesTable) return;
+ try {
+ const services = await api.get('/tm-services');
+ renderTmServicesTable(services);
+ } catch (e) {
+ elements.tmServicesTable.innerHTML = `| ${e.message} |
`;
+ }
+}
+
+function renderTmServicesTable(services) {
+ if (!services || services.length === 0) {
+ elements.tmServicesTable.innerHTML = '| 暂无 Team Manager 服务,点击「添加服务」新增 |
';
+ return;
+ }
+ elements.tmServicesTable.innerHTML = services.map(s => `
+
+ | ${escapeHtml(s.name)} |
+ ${escapeHtml(s.api_url)} |
+ ${s.enabled ? '✅' : '⭕'} |
+ ${s.priority} |
+
+
+
+
+ |
+
+ `).join('');
+}
+
+function openTmServiceModal(service = null) {
+ document.getElementById('tm-service-id').value = service ? service.id : '';
+ document.getElementById('tm-service-name').value = service ? service.name : '';
+ document.getElementById('tm-service-url').value = service ? service.api_url : '';
+ document.getElementById('tm-service-key').value = '';
+ document.getElementById('tm-service-priority').value = service ? service.priority : 0;
+ document.getElementById('tm-service-enabled').checked = service ? service.enabled : true;
+ if (service) {
+ document.getElementById('tm-service-key').placeholder = service.has_key ? '已配置,留空保持不变' : '请输入 API Key';
+ } else {
+ document.getElementById('tm-service-key').placeholder = '请输入 API Key';
+ }
+ elements.tmServiceModalTitle.textContent = service ? '编辑 Team Manager 服务' : '添加 Team Manager 服务';
+ elements.tmServiceEditModal.classList.add('active');
+}
+
+function closeTmServiceModal() {
+ elements.tmServiceEditModal.classList.remove('active');
+}
+
+async function editTmService(id) {
+ try {
+ const service = await api.get(`/tm-services/${id}`);
+ openTmServiceModal(service);
+ } catch (e) {
+ toast.error('获取服务信息失败: ' + e.message);
+ }
+}
+
+async function handleSaveTmService(e) {
+ e.preventDefault();
+ const id = document.getElementById('tm-service-id').value;
+ const name = document.getElementById('tm-service-name').value.trim();
+ const apiUrl = document.getElementById('tm-service-url').value.trim();
+ const apiKey = document.getElementById('tm-service-key').value.trim();
+ const priority = parseInt(document.getElementById('tm-service-priority').value) || 0;
+ const enabled = document.getElementById('tm-service-enabled').checked;
+
+ if (!name || !apiUrl) {
+ toast.error('名称和 API URL 不能为空');
+ return;
+ }
+ if (!id && !apiKey) {
+ toast.error('新增服务时 API Key 不能为空');
+ return;
+ }
+
+ try {
+ const payload = { name, api_url: apiUrl, priority, enabled };
+ if (apiKey) payload.api_key = apiKey;
+
+ if (id) {
+ await api.patch(`/tm-services/${id}`, payload);
+ toast.success('服务已更新');
+ } else {
+ payload.api_key = apiKey;
+ await api.post('/tm-services', payload);
+ toast.success('服务已添加');
+ }
+ closeTmServiceModal();
+ loadTmServices();
+ } catch (e) {
+ toast.error('保存失败: ' + e.message);
+ }
+}
+
+async function deleteTmService(id, name) {
+ const confirmed = await confirm(`确定要删除 Team Manager 服务「${name}」吗?`);
+ if (!confirmed) return;
+ try {
+ await api.delete(`/tm-services/${id}`);
+ toast.success('已删除');
+ loadTmServices();
+ } catch (e) {
+ toast.error('删除失败: ' + e.message);
+ }
+}
+
+async function testTmServiceById(id) {
+ try {
+ const result = await api.post(`/tm-services/${id}/test`);
+ if (result.success) {
+ toast.success(result.message);
+ } else {
+ toast.error(result.message);
+ }
+ } catch (e) {
+ toast.error('测试失败: ' + e.message);
+ }
+}
+
+async function handleTestTmService() {
+ const apiUrl = document.getElementById('tm-service-url').value.trim();
+ const apiKey = document.getElementById('tm-service-key').value.trim();
+ const id = document.getElementById('tm-service-id').value;
+
+ if (!apiUrl) {
+ toast.error('请先填写 API URL');
+ return;
+ }
+ if (!id && !apiKey) {
+ toast.error('请先填写 API Key');
+ return;
+ }
+
+ elements.testTmServiceBtn.disabled = true;
+ elements.testTmServiceBtn.textContent = '测试中...';
+
+ try {
+ let result;
+ if (id && !apiKey) {
+ result = await api.post(`/tm-services/${id}/test`);
+ } else {
+ result = await api.post('/tm-services/test-connection', { api_url: apiUrl, api_key: apiKey });
+ }
+ if (result.success) {
+ toast.success(result.message);
+ } else {
+ toast.error(result.message);
+ }
+ } catch (e) {
+ toast.error('测试失败: ' + e.message);
+ } finally {
+ elements.testTmServiceBtn.disabled = false;
+ elements.testTmServiceBtn.textContent = '🔌 测试连接';
+ }
+}
+
+
+// ============== CPA 服务管理 ==============
+
+async function loadCpaServices() {
+ if (!elements.cpaServicesTable) return;
+ try {
+ const services = await api.get('/cpa-services');
+ renderCpaServicesTable(services);
+ } catch (e) {
+ elements.cpaServicesTable.innerHTML = `| ${e.message} |
`;
+ }
+}
+
+function renderCpaServicesTable(services) {
+ if (!services || services.length === 0) {
+ elements.cpaServicesTable.innerHTML = '| 暂无 CPA 服务,点击「添加服务」新增 |
';
+ return;
+ }
+ elements.cpaServicesTable.innerHTML = services.map(s => `
+
+ | ${escapeHtml(s.name)} |
+ ${escapeHtml(s.api_url)} |
+ ${s.include_proxy_url ? '🟢' : '⚪'} |
+ ${s.enabled ? '✅' : '⭕'} |
+ ${s.priority} |
+
+
+
+
+ |
+
+ `).join('');
+}
+
+function openCpaServiceModal(service = null) {
+ document.getElementById('cpa-service-id').value = service ? service.id : '';
+ document.getElementById('cpa-service-name').value = service ? service.name : '';
+ document.getElementById('cpa-service-url').value = service ? service.api_url : '';
+ document.getElementById('cpa-service-token').value = '';
+ document.getElementById('cpa-service-priority').value = service ? service.priority : 0;
+ document.getElementById('cpa-service-enabled').checked = service ? service.enabled : true;
+ document.getElementById('cpa-service-include-proxy-url').checked = service ? !!service.include_proxy_url : false;
+ elements.cpaServiceModalTitle.textContent = service ? '编辑 CPA 服务' : '添加 CPA 服务';
+ elements.cpaServiceEditModal.classList.add('active');
+}
+
+function closeCpaServiceModal() {
+ elements.cpaServiceEditModal.classList.remove('active');
+}
+
+async function editCpaService(id) {
+ try {
+ const service = await api.get(`/cpa-services/${id}`);
+ openCpaServiceModal(service);
+ } catch (e) {
+ toast.error('获取服务信息失败: ' + e.message);
+ }
+}
+
+async function handleSaveCpaService(e) {
+ e.preventDefault();
+ const id = document.getElementById('cpa-service-id').value;
+ const name = document.getElementById('cpa-service-name').value.trim();
+ const apiUrl = document.getElementById('cpa-service-url').value.trim();
+ const apiToken = document.getElementById('cpa-service-token').value.trim();
+ const priority = parseInt(document.getElementById('cpa-service-priority').value) || 0;
+ const enabled = document.getElementById('cpa-service-enabled').checked;
+ const includeProxyUrl = document.getElementById('cpa-service-include-proxy-url').checked;
+
+ if (!name || !apiUrl) {
+ toast.error('名称和 API URL 不能为空');
+ return;
+ }
+ if (!id && !apiToken) {
+ toast.error('新增服务时 API Token 不能为空');
+ return;
+ }
+
+ try {
+ const payload = { name, api_url: apiUrl, priority, enabled, include_proxy_url: includeProxyUrl };
+ if (apiToken) payload.api_token = apiToken;
+
+ if (id) {
+ await api.patch(`/cpa-services/${id}`, payload);
+ toast.success('服务已更新');
+ } else {
+ payload.api_token = apiToken;
+ await api.post('/cpa-services', payload);
+ toast.success('服务已添加');
+ }
+ closeCpaServiceModal();
+ loadCpaServices();
+ } catch (e) {
+ toast.error('保存失败: ' + e.message);
+ }
+}
+
+async function deleteCpaService(id, name) {
+ const confirmed = await confirm(`确定要删除 CPA 服务「${name}」吗?`);
+ if (!confirmed) return;
+ try {
+ await api.delete(`/cpa-services/${id}`);
+ toast.success('已删除');
+ loadCpaServices();
+ } catch (e) {
+ toast.error('删除失败: ' + e.message);
+ }
+}
+
+async function testCpaServiceById(id) {
+ try {
+ const result = await api.post(`/cpa-services/${id}/test`);
+ if (result.success) {
+ toast.success(result.message);
+ } else {
+ toast.error(result.message);
+ }
+ } catch (e) {
+ toast.error('测试失败: ' + e.message);
+ }
+}
+
+async function handleTestCpaService() {
+ const apiUrl = document.getElementById('cpa-service-url').value.trim();
+ const apiToken = document.getElementById('cpa-service-token').value.trim();
+ const id = document.getElementById('cpa-service-id').value;
+
+ if (!apiUrl) {
+ toast.error('请先填写 API URL');
+ return;
+ }
+ // 新增时必须有 token,编辑时 token 可为空(用已保存的)
+ if (!id && !apiToken) {
+ toast.error('请先填写 API Token');
+ return;
+ }
+
+ elements.testCpaServiceBtn.disabled = true;
+ elements.testCpaServiceBtn.textContent = '测试中...';
+
+ try {
+ let result;
+ if (id && !apiToken) {
+ // 编辑时未填 token,直接测试已保存的服务
+ result = await api.post(`/cpa-services/${id}/test`);
+ } else {
+ result = await api.post('/cpa-services/test-connection', { api_url: apiUrl, api_token: apiToken });
+ }
+ if (result.success) {
+ toast.success(result.message);
+ } else {
+ toast.error(result.message);
+ }
+ } catch (e) {
+ toast.error('测试失败: ' + e.message);
+ } finally {
+ elements.testCpaServiceBtn.disabled = false;
+ elements.testCpaServiceBtn.textContent = '🔌 测试连接';
+ }
+}
+
+// ============================================================================
+// Sub2API 服务管理
+// ============================================================================
+
+let _sub2apiEditingId = null;
+
+async function loadSub2ApiServices() {
+ try {
+ const services = await api.get('/sub2api-services');
+ renderSub2ApiServices(services);
+ } catch (e) {
+ if (elements.sub2ApiServicesTable) {
+ elements.sub2ApiServicesTable.innerHTML = '| 加载失败 |
';
+ }
+ }
+}
+
+function renderSub2ApiServices(services) {
+ if (!elements.sub2ApiServicesTable) return;
+ if (!services || services.length === 0) {
+ elements.sub2ApiServicesTable.innerHTML = '| 暂无 Sub2API 服务,点击「添加服务」新增 |
';
+ return;
+ }
+ elements.sub2ApiServicesTable.innerHTML = services.map(s => `
+
+ | ${escapeHtml(s.name)} |
+ ${escapeHtml(s.api_url)} |
+ ${s.enabled ? '✅' : '⭕'} |
+ ${s.priority} |
+
+
+
+
+ |
+
+ `).join('');
+}
+
+function openSub2ApiServiceModal(svc = null) {
+ _sub2apiEditingId = svc ? svc.id : null;
+ elements.sub2ApiServiceModalTitle.textContent = svc ? '编辑 Sub2API 服务' : '添加 Sub2API 服务';
+ elements.sub2ApiServiceForm.reset();
+ document.getElementById('sub2api-service-id').value = svc ? svc.id : '';
+ if (svc) {
+ document.getElementById('sub2api-service-name').value = svc.name || '';
+ document.getElementById('sub2api-service-url').value = svc.api_url || '';
+ document.getElementById('sub2api-service-priority').value = svc.priority ?? 0;
+ document.getElementById('sub2api-service-enabled').checked = svc.enabled !== false;
+ document.getElementById('sub2api-service-key').placeholder = svc.has_key ? '已配置,留空保持不变' : '请输入 API Key';
+ }
+ elements.sub2ApiServiceEditModal.classList.add('active');
+}
+
+function closeSub2ApiServiceModal() {
+ elements.sub2ApiServiceEditModal.classList.remove('active');
+ elements.sub2ApiServiceForm.reset();
+ _sub2apiEditingId = null;
+}
+
+async function editSub2ApiService(id) {
+ try {
+ const svc = await api.get(`/sub2api-services/${id}`);
+ openSub2ApiServiceModal(svc);
+ } catch (e) {
+ toast.error('加载失败: ' + e.message);
+ }
+}
+
+async function deleteSub2ApiService(id, name) {
+ if (!confirm(`确认删除 Sub2API 服务「${name}」?`)) return;
+ try {
+ await api.delete(`/sub2api-services/${id}`);
+ toast.success('服务已删除');
+ loadSub2ApiServices();
+ } catch (e) {
+ toast.error('删除失败: ' + e.message);
+ }
+}
+
+async function handleSaveSub2ApiService(e) {
+ e.preventDefault();
+ const id = document.getElementById('sub2api-service-id').value;
+ const data = {
+ name: document.getElementById('sub2api-service-name').value,
+ api_url: document.getElementById('sub2api-service-url').value,
+ api_key: document.getElementById('sub2api-service-key').value || undefined,
+ priority: parseInt(document.getElementById('sub2api-service-priority').value) || 0,
+ enabled: document.getElementById('sub2api-service-enabled').checked,
+ };
+ if (!id && !data.api_key) {
+ toast.error('请填写 API Key');
+ return;
+ }
+ if (!data.api_key) delete data.api_key;
+
+ try {
+ if (id) {
+ await api.patch(`/sub2api-services/${id}`, data);
+ toast.success('服务已更新');
+ } else {
+ await api.post('/sub2api-services', data);
+ toast.success('服务已添加');
+ }
+ closeSub2ApiServiceModal();
+ loadSub2ApiServices();
+ } catch (e) {
+ toast.error('保存失败: ' + e.message);
+ }
+}
+
+async function testSub2ApiServiceById(id) {
+ try {
+ const result = await api.post(`/sub2api-services/${id}/test`);
+ if (result.success) {
+ toast.success(result.message);
+ } else {
+ toast.error(result.message);
+ }
+ } catch (e) {
+ toast.error('测试失败: ' + e.message);
+ }
+}
+
+async function handleTestSub2ApiService() {
+ const apiUrl = document.getElementById('sub2api-service-url').value.trim();
+ const apiKey = document.getElementById('sub2api-service-key').value.trim();
+ const id = document.getElementById('sub2api-service-id').value;
+
+ if (!apiUrl) {
+ toast.error('请先填写 API URL');
+ return;
+ }
+ if (!id && !apiKey) {
+ toast.error('请先填写 API Key');
+ return;
+ }
+
+ elements.testSub2ApiServiceBtn.disabled = true;
+ elements.testSub2ApiServiceBtn.textContent = '测试中...';
+
+ try {
+ let result;
+ if (id && !apiKey) {
+ result = await api.post(`/sub2api-services/${id}/test`);
+ } else {
+ result = await api.post('/sub2api-services/test-connection', { api_url: apiUrl, api_key: apiKey });
+ }
+ if (result.success) {
+ toast.success(result.message);
+ } else {
+ toast.error(result.message);
+ }
+ } catch (e) {
+ toast.error('测试失败: ' + e.message);
+ } finally {
+ elements.testSub2ApiServiceBtn.disabled = false;
+ elements.testSub2ApiServiceBtn.textContent = '🔌 测试连接';
+ }
+}
+
+function escapeHtml(text) {
+ if (!text) return '';
+ const d = document.createElement('div');
+ d.textContent = text;
+ return d.innerHTML;
+}
diff --git a/static/js/utils.js b/static/js/utils.js
new file mode 100644
index 0000000..9862969
--- /dev/null
+++ b/static/js/utils.js
@@ -0,0 +1,553 @@
+/**
+ * 通用工具库
+ * 包含 Toast 通知、主题切换、工具函数等
+ */
+
+// ============================================
+// Toast 通知系统
+// ============================================
+
+class ToastManager {
+ constructor() {
+ this.container = null;
+ this.init();
+ }
+
+ init() {
+ this.container = document.createElement('div');
+ this.container.className = 'toast-container';
+ document.body.appendChild(this.container);
+ }
+
+ show(message, type = 'info', duration = 4000) {
+ const toast = document.createElement('div');
+ toast.className = `toast ${type}`;
+
+ const icon = this.getIcon(type);
+ toast.innerHTML = `
+ ${icon}
+ ${this.escapeHtml(message)}
+
+ `;
+
+ this.container.appendChild(toast);
+
+ // 自动移除
+ setTimeout(() => {
+ toast.style.animation = 'slideOut 0.3s ease forwards';
+ setTimeout(() => toast.remove(), 300);
+ }, duration);
+
+ return toast;
+ }
+
+ getIcon(type) {
+ const icons = {
+ success: '✓',
+ error: '✕',
+ warning: '⚠',
+ info: 'ℹ'
+ };
+ return icons[type] || icons.info;
+ }
+
+ escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+ }
+
+ success(message, duration) {
+ return this.show(message, 'success', duration);
+ }
+
+ error(message, duration) {
+ return this.show(message, 'error', duration);
+ }
+
+ warning(message, duration) {
+ return this.show(message, 'warning', duration);
+ }
+
+ info(message, duration) {
+ return this.show(message, 'info', duration);
+ }
+}
+
+// 全局 Toast 实例
+const toast = new ToastManager();
+
+// ============================================
+// 主题管理
+// ============================================
+
+class ThemeManager {
+ constructor() {
+ this.theme = this.loadTheme();
+ this.applyTheme();
+ }
+
+ loadTheme() {
+ return localStorage.getItem('theme') || 'light';
+ }
+
+ saveTheme(theme) {
+ localStorage.setItem('theme', theme);
+ }
+
+ applyTheme() {
+ document.documentElement.setAttribute('data-theme', this.theme);
+ this.updateToggleButtons();
+ }
+
+ toggle() {
+ this.theme = this.theme === 'light' ? 'dark' : 'light';
+ this.saveTheme(this.theme);
+ this.applyTheme();
+ }
+
+ setTheme(theme) {
+ this.theme = theme;
+ this.saveTheme(theme);
+ this.applyTheme();
+ }
+
+ updateToggleButtons() {
+ const buttons = document.querySelectorAll('.theme-toggle');
+ buttons.forEach(btn => {
+ btn.innerHTML = this.theme === 'light' ? '🌙' : '☀️';
+ btn.title = this.theme === 'light' ? '切换到暗色模式' : '切换到亮色模式';
+ });
+ }
+}
+
+// 全局主题实例
+const theme = new ThemeManager();
+
+// ============================================
+// 加载状态管理
+// ============================================
+
+class LoadingManager {
+ constructor() {
+ this.activeLoaders = new Set();
+ }
+
+ show(element, text = '加载中...') {
+ if (typeof element === 'string') {
+ element = document.getElementById(element);
+ }
+ if (!element) return;
+
+ element.classList.add('loading');
+ element.dataset.originalText = element.innerHTML;
+ element.innerHTML = ` ${text}`;
+ element.disabled = true;
+ this.activeLoaders.add(element);
+ }
+
+ hide(element) {
+ if (typeof element === 'string') {
+ element = document.getElementById(element);
+ }
+ if (!element) return;
+
+ element.classList.remove('loading');
+ if (element.dataset.originalText) {
+ element.innerHTML = element.dataset.originalText;
+ delete element.dataset.originalText;
+ }
+ element.disabled = false;
+ this.activeLoaders.delete(element);
+ }
+
+ hideAll() {
+ this.activeLoaders.forEach(element => this.hide(element));
+ }
+}
+
+const loading = new LoadingManager();
+
+// ============================================
+// API 请求封装
+// ============================================
+
+class ApiClient {
+ constructor(baseUrl = '/api') {
+ this.baseUrl = baseUrl;
+ }
+
+ async request(path, options = {}) {
+ const url = `${this.baseUrl}${path}`;
+
+ const defaultOptions = {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ };
+
+ const finalOptions = { ...defaultOptions, ...options };
+ const timeoutMs = Number(finalOptions.timeoutMs || 0);
+ delete finalOptions.timeoutMs;
+
+ if (finalOptions.body && typeof finalOptions.body === 'object') {
+ finalOptions.body = JSON.stringify(finalOptions.body);
+ }
+
+ let timeoutId = null;
+ try {
+ if (timeoutMs > 0) {
+ const controller = new AbortController();
+ finalOptions.signal = controller.signal;
+ timeoutId = setTimeout(() => controller.abort(), timeoutMs);
+ }
+
+ const response = await fetch(url, finalOptions);
+ const data = await response.json().catch(() => ({}));
+
+ if (!response.ok) {
+ const error = new Error(data.detail || `HTTP ${response.status}`);
+ error.response = response;
+ error.data = data;
+ throw error;
+ }
+
+ return data;
+ } catch (error) {
+ if (error.name === 'AbortError') {
+ const timeoutError = new Error('请求超时,请稍后重试');
+ throw timeoutError;
+ }
+ // 网络错误处理
+ if (!error.response) {
+ toast.error('网络连接失败,请检查网络');
+ }
+ throw error;
+ } finally {
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+ }
+ }
+
+ get(path, options = {}) {
+ return this.request(path, { ...options, method: 'GET' });
+ }
+
+ post(path, body, options = {}) {
+ return this.request(path, { ...options, method: 'POST', body });
+ }
+
+ put(path, body, options = {}) {
+ return this.request(path, { ...options, method: 'PUT', body });
+ }
+
+ patch(path, body, options = {}) {
+ return this.request(path, { ...options, method: 'PATCH', body });
+ }
+
+ delete(path, options = {}) {
+ return this.request(path, { ...options, method: 'DELETE' });
+ }
+}
+
+const api = new ApiClient();
+
+// ============================================
+// 事件委托助手
+// ============================================
+
+function delegate(element, eventType, selector, handler) {
+ element.addEventListener(eventType, (e) => {
+ const target = e.target.closest(selector);
+ if (target && element.contains(target)) {
+ handler.call(target, e, target);
+ }
+ });
+}
+
+// ============================================
+// 防抖和节流
+// ============================================
+
+function debounce(func, wait) {
+ let timeout;
+ return function executedFunction(...args) {
+ const later = () => {
+ clearTimeout(timeout);
+ func(...args);
+ };
+ clearTimeout(timeout);
+ timeout = setTimeout(later, wait);
+ };
+}
+
+function throttle(func, limit) {
+ let inThrottle;
+ return function executedFunction(...args) {
+ if (!inThrottle) {
+ func(...args);
+ inThrottle = true;
+ setTimeout(() => inThrottle = false, limit);
+ }
+ };
+}
+
+// ============================================
+// 格式化工具
+// ============================================
+
+const format = {
+ date(dateStr) {
+ if (!dateStr) return '-';
+ const date = new Date(dateStr);
+ return date.toLocaleString('zh-CN', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit'
+ });
+ },
+
+ dateShort(dateStr) {
+ if (!dateStr) return '-';
+ const date = new Date(dateStr);
+ return date.toLocaleDateString('zh-CN');
+ },
+
+ relativeTime(dateStr) {
+ if (!dateStr) return '-';
+ const date = new Date(dateStr);
+ const now = new Date();
+ const diff = now - date;
+ const seconds = Math.floor(diff / 1000);
+ const minutes = Math.floor(seconds / 60);
+ const hours = Math.floor(minutes / 60);
+ const days = Math.floor(hours / 24);
+
+ if (seconds < 60) return '刚刚';
+ if (minutes < 60) return `${minutes} 分钟前`;
+ if (hours < 24) return `${hours} 小时前`;
+ if (days < 7) return `${days} 天前`;
+ return this.dateShort(dateStr);
+ },
+
+ bytes(bytes) {
+ if (bytes === 0) return '0 B';
+ const k = 1024;
+ const sizes = ['B', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+ },
+
+ number(num) {
+ if (num === null || num === undefined) return '-';
+ return num.toLocaleString('zh-CN');
+ }
+};
+
+// ============================================
+// 状态映射
+// ============================================
+
+const statusMap = {
+ account: {
+ active: { text: '活跃', class: 'active' },
+ expired: { text: '过期', class: 'expired' },
+ banned: { text: '封禁', class: 'banned' },
+ failed: { text: '失败', class: 'failed' }
+ },
+ task: {
+ pending: { text: '等待中', class: 'pending' },
+ running: { text: '运行中', class: 'running' },
+ completed: { text: '已完成', class: 'completed' },
+ failed: { text: '失败', class: 'failed' },
+ cancelled: { text: '已取消', class: 'disabled' }
+ },
+ service: {
+ tempmail: 'Tempmail.lol',
+ outlook: 'Outlook',
+ moe_mail: 'MoeMail',
+ temp_mail: 'Temp-Mail(自部署)',
+ duck_mail: 'DuckMail',
+ freemail: 'Freemail',
+ imap_mail: 'IMAP 邮箱'
+ }
+};
+
+function getStatusText(type, status) {
+ return statusMap[type]?.[status]?.text || status;
+}
+
+function getStatusClass(type, status) {
+ return statusMap[type]?.[status]?.class || '';
+}
+
+function getServiceTypeText(type) {
+ return statusMap.service[type] || type;
+}
+
+const accountStatusIconMap = {
+ active: { icon: '🟢', title: '活跃' },
+ expired: { icon: '🟡', title: '过期' },
+ banned: { icon: '🔴', title: '封禁' },
+ failed: { icon: '❌', title: '失败' },
+};
+
+function getStatusIcon(status) {
+ const s = accountStatusIconMap[status];
+ if (!s) return `⚪`;
+ return `${s.icon}`;
+}
+
+// ============================================
+// 确认对话框
+// ============================================
+
+function confirm(message, title = '确认操作') {
+ return new Promise((resolve) => {
+ const modal = document.createElement('div');
+ modal.className = 'modal active';
+ modal.innerHTML = `
+
+
+
+
${message}
+
+
+
+
+
+
+ `;
+
+ document.body.appendChild(modal);
+
+ const cancelBtn = modal.querySelector('#confirm-cancel');
+ const okBtn = modal.querySelector('#confirm-ok');
+
+ cancelBtn.onclick = () => {
+ modal.remove();
+ resolve(false);
+ };
+
+ okBtn.onclick = () => {
+ modal.remove();
+ resolve(true);
+ };
+
+ modal.onclick = (e) => {
+ if (e.target === modal) {
+ modal.remove();
+ resolve(false);
+ }
+ };
+ });
+}
+
+// ============================================
+// 复制到剪贴板
+// ============================================
+
+async function copyToClipboard(text) {
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ try {
+ await navigator.clipboard.writeText(text);
+ toast.success('已复制到剪贴板');
+ return true;
+ } catch (err) {
+ // 降级到 execCommand
+ }
+ }
+ try {
+ const ta = document.createElement('textarea');
+ ta.value = text;
+ ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0;pointer-events:none;';
+ document.body.appendChild(ta);
+ ta.focus();
+ ta.select();
+ const ok = document.execCommand('copy');
+ document.body.removeChild(ta);
+ if (ok) {
+ toast.success('已复制到剪贴板');
+ return true;
+ }
+ throw new Error('execCommand failed');
+ } catch (err) {
+ toast.error('复制失败');
+ return false;
+ }
+}
+
+// ============================================
+// 本地存储助手
+// ============================================
+
+const storage = {
+ get(key, defaultValue = null) {
+ try {
+ const value = localStorage.getItem(key);
+ return value ? JSON.parse(value) : defaultValue;
+ } catch {
+ return defaultValue;
+ }
+ },
+
+ set(key, value) {
+ try {
+ localStorage.setItem(key, JSON.stringify(value));
+ return true;
+ } catch {
+ return false;
+ }
+ },
+
+ remove(key) {
+ localStorage.removeItem(key);
+ }
+};
+
+// ============================================
+// 页面初始化
+// ============================================
+
+document.addEventListener('DOMContentLoaded', () => {
+ // 初始化主题
+ theme.applyTheme();
+
+ // 全局键盘快捷键
+ document.addEventListener('keydown', (e) => {
+ // Ctrl/Cmd + K: 聚焦搜索
+ if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
+ e.preventDefault();
+ const searchInput = document.querySelector('#search-input, [type="search"]');
+ if (searchInput) searchInput.focus();
+ }
+
+ // Escape: 关闭模态框
+ if (e.key === 'Escape') {
+ const activeModal = document.querySelector('.modal.active');
+ if (activeModal) activeModal.classList.remove('active');
+ }
+ });
+});
+
+// 导出全局对象
+window.toast = toast;
+window.theme = theme;
+window.loading = loading;
+window.api = api;
+window.format = format;
+window.confirm = confirm;
+window.copyToClipboard = copyToClipboard;
+window.storage = storage;
+window.delegate = delegate;
+window.debounce = debounce;
+window.throttle = throttle;
+window.getStatusText = getStatusText;
+window.getStatusClass = getStatusClass;
+window.getServiceTypeText = getServiceTypeText;
+window.getStatusIcon = getStatusIcon;
diff --git a/templates/accounts.html b/templates/accounts.html
new file mode 100644
index 0000000..32b6731
--- /dev/null
+++ b/templates/accounts.html
@@ -0,0 +1,283 @@
+
+
+
+
+
+ 账号管理 - OpenAI 注册系统
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
选择要上传到的 CPA 服务,或使用全局配置。
+
+
+
+
+
+
+
+
+
+
+
+
选择要上传到的 Team Manager 服务,或自动选择第一个启用的服务。
+
+
+
+
+
+
+
+
+
+
+
+
选择要上传到的 Sub2API 服务,或自动选择第一个启用的服务。
+
+
+
+
+
+
+
+
+
+
diff --git a/templates/email_services.html b/templates/email_services.html
new file mode 100644
index 0000000..4b18766
--- /dev/null
+++ b/templates/email_services.html
@@ -0,0 +1,522 @@
+
+
+
+
+
+ 邮箱服务 - OpenAI 注册系统
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
支持格式:
+
+ 邮箱----密码 (密码认证)
+ 邮箱----密码----client_id----refresh_token (XOAUTH2 认证,推荐)
+
+
每行一个账户,使用四个连字符(----)分隔字段。以 # 开头的行将被忽略。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/templates/index.html b/templates/index.html
new file mode 100644
index 0000000..28b9655
--- /dev/null
+++ b/templates/index.html
@@ -0,0 +1,448 @@
+
+
+
+
+
+ 注册控制台 - OpenAI 注册系统
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0/0
+ 0%
+
+
+
+ ✅ 0
+ ❌ 0
+ ⏳ 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | ID |
+ 邮箱 |
+ 密码 |
+ 状态 |
+
+
+
+
+ |
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/templates/login.html b/templates/login.html
new file mode 100644
index 0000000..8a241fd
--- /dev/null
+++ b/templates/login.html
@@ -0,0 +1,62 @@
+
+
+
+
+
+ 访问验证 - OpenAI 注册系统
+
+
+
+
+
+
+
+
+
+
+ {% if error %}
+
{{ error }}
+ {% endif %}
+
+
+
+
+
+
+
diff --git a/templates/payment.html b/templates/payment.html
new file mode 100644
index 0000000..28bb426
--- /dev/null
+++ b/templates/payment.html
@@ -0,0 +1,172 @@
+
+
+
+
+
+ 支付升级 - OpenAI 注册系统
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/templates/settings.html b/templates/settings.html
new file mode 100644
index 0000000..fe791cb
--- /dev/null
+++ b/templates/settings.html
@@ -0,0 +1,596 @@
+
+
+
+
+
+ 系统设置 - OpenAI 注册系统
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | ID |
+ 名称 |
+ 类型 |
+ 地址 |
+ 默认 |
+ 状态 |
+ 最后使用 |
+ 操作 |
+
+
+
+
+
+
+ 🌐
+ 暂无代理
+ 点击"添加代理"按钮添加代理服务器
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 名称 |
+ API URL |
+ 代理写入 |
+ 状态 |
+ 优先级 |
+ 操作 |
+
+
+
+ | 加载中... |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 名称 |
+ API URL |
+ 状态 |
+ 优先级 |
+ 操作 |
+
+
+
+ | 加载中... |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 名称 |
+ API URL |
+ 状态 |
+ 优先级 |
+ 操作 |
+
+
+
+ | 加载中... |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ - 渐进式检查:前 3 次轮询只检查未读邮件,之后检查所有邮件
+ - 时间戳过滤:自动跳过 OTP 发送前的旧邮件
+ - 验证码去重:避免重复使用同一验证码
+ - 多策略提取:主题优先 → 语义匹配 → 兜底匹配
+ - 发件人验证:严格验证邮件来自 OpenAI 官方
+
+
+
+
+
+
+
+
+
+
+
+
+ 数据库大小
+ -
+
+
+ 账号数量
+ -
+
+
+ 邮箱服务数量
+ -
+
+
+ 任务记录数量
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/test_cpa_upload.py b/tests/test_cpa_upload.py
new file mode 100644
index 0000000..0b9035f
--- /dev/null
+++ b/tests/test_cpa_upload.py
@@ -0,0 +1,150 @@
+from types import SimpleNamespace
+
+from src.core.upload import cpa_upload
+
+
+class FakeResponse:
+ def __init__(self, status_code=200, payload=None, text=""):
+ self.status_code = status_code
+ self._payload = payload
+ self.text = text
+
+ def json(self):
+ if self._payload is None:
+ raise ValueError("no json payload")
+ return self._payload
+
+
+class FakeMime:
+ def __init__(self):
+ self.parts = []
+
+ def addpart(self, **kwargs):
+ self.parts.append(kwargs)
+
+
+def test_upload_to_cpa_accepts_management_root_url(monkeypatch):
+ calls = []
+
+ def fake_post(url, **kwargs):
+ calls.append({"url": url, "kwargs": kwargs})
+ return FakeResponse(status_code=201)
+
+ monkeypatch.setattr(cpa_upload, "CurlMime", FakeMime)
+ monkeypatch.setattr(cpa_upload.cffi_requests, "post", fake_post)
+
+ success, message = cpa_upload.upload_to_cpa(
+ {"email": "tester@example.com"},
+ api_url="https://cpa.example.com/v0/management",
+ api_token="token-123",
+ )
+
+ assert success is True
+ assert message == "上传成功"
+ assert calls[0]["url"] == "https://cpa.example.com/v0/management/auth-files"
+
+
+def test_upload_to_cpa_does_not_double_append_full_endpoint(monkeypatch):
+ calls = []
+
+ def fake_post(url, **kwargs):
+ calls.append({"url": url, "kwargs": kwargs})
+ return FakeResponse(status_code=201)
+
+ monkeypatch.setattr(cpa_upload, "CurlMime", FakeMime)
+ monkeypatch.setattr(cpa_upload.cffi_requests, "post", fake_post)
+
+ success, _ = cpa_upload.upload_to_cpa(
+ {"email": "tester@example.com"},
+ api_url="https://cpa.example.com/v0/management/auth-files",
+ api_token="token-123",
+ )
+
+ assert success is True
+ assert calls[0]["url"] == "https://cpa.example.com/v0/management/auth-files"
+
+
+def test_upload_to_cpa_falls_back_to_raw_json_when_multipart_returns_404(monkeypatch):
+ calls = []
+ responses = [
+ FakeResponse(status_code=404, text="404 page not found"),
+ FakeResponse(status_code=200, payload={"status": "ok"}),
+ ]
+
+ def fake_post(url, **kwargs):
+ calls.append({"url": url, "kwargs": kwargs})
+ return responses.pop(0)
+
+ monkeypatch.setattr(cpa_upload, "CurlMime", FakeMime)
+ monkeypatch.setattr(cpa_upload.cffi_requests, "post", fake_post)
+
+ success, message = cpa_upload.upload_to_cpa(
+ {"email": "tester@example.com", "type": "codex"},
+ api_url="https://cpa.example.com",
+ api_token="token-123",
+ )
+
+ assert success is True
+ assert message == "上传成功"
+ assert calls[0]["kwargs"]["multipart"] is not None
+ assert calls[1]["url"] == "https://cpa.example.com/v0/management/auth-files?name=tester%40example.com.json"
+ assert calls[1]["kwargs"]["headers"]["Content-Type"] == "application/json"
+ assert calls[1]["kwargs"]["data"].startswith(b"{")
+
+
+def test_test_cpa_connection_uses_get_and_normalized_url(monkeypatch):
+ calls = []
+
+ def fake_get(url, **kwargs):
+ calls.append({"url": url, "kwargs": kwargs})
+ return FakeResponse(status_code=200, payload={"files": []})
+
+ monkeypatch.setattr(cpa_upload.cffi_requests, "get", fake_get)
+
+ success, message = cpa_upload.test_cpa_connection(
+ "https://cpa.example.com/v0/management",
+ "token-123",
+ )
+
+ assert success is True
+ assert message == "CPA 连接测试成功"
+ assert calls[0]["url"] == "https://cpa.example.com/v0/management/auth-files"
+ assert calls[0]["kwargs"]["headers"]["Authorization"] == "Bearer token-123"
+
+
+def test_generate_token_json_includes_account_proxy_url_when_enabled():
+ account = SimpleNamespace(
+ email="tester@example.com",
+ expires_at=None,
+ id_token="id-token",
+ account_id="acct-1",
+ access_token="access-token",
+ last_refresh=None,
+ refresh_token="refresh-token",
+ proxy_used="socks5://127.0.0.1:1080",
+ )
+
+ token_data = cpa_upload.generate_token_json(account, include_proxy_url=True)
+
+ assert token_data["proxy_url"] == "socks5://127.0.0.1:1080"
+
+
+def test_generate_token_json_uses_fallback_proxy_when_account_proxy_missing():
+ account = SimpleNamespace(
+ email="tester@example.com",
+ expires_at=None,
+ id_token="id-token",
+ account_id="acct-1",
+ access_token="access-token",
+ last_refresh=None,
+ refresh_token="refresh-token",
+ proxy_used=None,
+ )
+
+ token_data = cpa_upload.generate_token_json(
+ account,
+ include_proxy_url=True,
+ proxy_url="http://proxy.example.com:8080",
+ )
+
+ assert token_data["proxy_url"] == "http://proxy.example.com:8080"
diff --git a/tests/test_duck_mail_service.py b/tests/test_duck_mail_service.py
new file mode 100644
index 0000000..3767f89
--- /dev/null
+++ b/tests/test_duck_mail_service.py
@@ -0,0 +1,143 @@
+from src.services.duck_mail import DuckMailService
+
+
+class FakeResponse:
+ def __init__(self, status_code=200, payload=None, text=""):
+ self.status_code = status_code
+ self._payload = payload
+ self.text = text
+ self.headers = {}
+
+ def json(self):
+ if self._payload is None:
+ raise ValueError("no json payload")
+ return self._payload
+
+
+class FakeHTTPClient:
+ def __init__(self, responses):
+ self.responses = list(responses)
+ self.calls = []
+
+ def request(self, method, url, **kwargs):
+ self.calls.append({
+ "method": method,
+ "url": url,
+ "kwargs": kwargs,
+ })
+ if not self.responses:
+ raise AssertionError(f"未准备响应: {method} {url}")
+ return self.responses.pop(0)
+
+
+def test_create_email_creates_account_and_fetches_token():
+ service = DuckMailService({
+ "base_url": "https://api.duckmail.test",
+ "default_domain": "duckmail.sbs",
+ "api_key": "dk_test_key",
+ "password_length": 10,
+ })
+ fake_client = FakeHTTPClient([
+ FakeResponse(
+ status_code=201,
+ payload={
+ "id": "account-1",
+ "address": "tester@duckmail.sbs",
+ "authType": "email",
+ },
+ ),
+ FakeResponse(
+ payload={
+ "id": "account-1",
+ "token": "token-123",
+ }
+ ),
+ ])
+ service.http_client = fake_client
+
+ email_info = service.create_email()
+
+ assert email_info["email"] == "tester@duckmail.sbs"
+ assert email_info["service_id"] == "account-1"
+ assert email_info["account_id"] == "account-1"
+ assert email_info["token"] == "token-123"
+
+ create_call = fake_client.calls[0]
+ assert create_call["method"] == "POST"
+ assert create_call["url"] == "https://api.duckmail.test/accounts"
+ assert create_call["kwargs"]["json"]["address"].endswith("@duckmail.sbs")
+ assert len(create_call["kwargs"]["json"]["password"]) == 10
+ assert create_call["kwargs"]["headers"]["Authorization"] == "Bearer dk_test_key"
+
+ token_call = fake_client.calls[1]
+ assert token_call["method"] == "POST"
+ assert token_call["url"] == "https://api.duckmail.test/token"
+ assert token_call["kwargs"]["json"] == {
+ "address": "tester@duckmail.sbs",
+ "password": email_info["password"],
+ }
+
+
+def test_get_verification_code_reads_message_detail_and_extracts_code():
+ service = DuckMailService({
+ "base_url": "https://api.duckmail.test",
+ "default_domain": "duckmail.sbs",
+ })
+ fake_client = FakeHTTPClient([
+ FakeResponse(
+ status_code=201,
+ payload={
+ "id": "account-1",
+ "address": "tester@duckmail.sbs",
+ "authType": "email",
+ },
+ ),
+ FakeResponse(
+ payload={
+ "id": "account-1",
+ "token": "token-123",
+ }
+ ),
+ FakeResponse(
+ payload={
+ "hydra:member": [
+ {
+ "id": "msg-1",
+ "from": {
+ "name": "OpenAI",
+ "address": "noreply@openai.com",
+ },
+ "subject": "Your verification code",
+ "createdAt": "2026-03-19T10:00:00Z",
+ }
+ ]
+ }
+ ),
+ FakeResponse(
+ payload={
+ "id": "msg-1",
+ "text": "Your OpenAI verification code is 654321",
+ "html": [],
+ }
+ ),
+ ])
+ service.http_client = fake_client
+
+ email_info = service.create_email()
+ code = service.get_verification_code(
+ email=email_info["email"],
+ email_id=email_info["service_id"],
+ timeout=1,
+ )
+
+ assert code == "654321"
+
+ messages_call = fake_client.calls[2]
+ assert messages_call["method"] == "GET"
+ assert messages_call["url"] == "https://api.duckmail.test/messages"
+ assert messages_call["kwargs"]["headers"]["Authorization"] == "Bearer token-123"
+
+ detail_call = fake_client.calls[3]
+ assert detail_call["method"] == "GET"
+ assert detail_call["url"] == "https://api.duckmail.test/messages/msg-1"
+ assert detail_call["kwargs"]["headers"]["Authorization"] == "Bearer token-123"
diff --git a/tests/test_email_service_duckmail_routes.py b/tests/test_email_service_duckmail_routes.py
new file mode 100644
index 0000000..b8878e5
--- /dev/null
+++ b/tests/test_email_service_duckmail_routes.py
@@ -0,0 +1,94 @@
+import asyncio
+from contextlib import contextmanager
+from pathlib import Path
+
+from src.config.constants import EmailServiceType
+from src.database.models import Base, EmailService
+from src.database.session import DatabaseSessionManager
+from src.services.base import EmailServiceFactory
+from src.web.routes import email as email_routes
+from src.web.routes import registration as registration_routes
+
+
+class DummySettings:
+ custom_domain_base_url = ""
+ custom_domain_api_key = None
+
+
+def test_duck_mail_service_registered():
+ service_type = EmailServiceType("duck_mail")
+ service_class = EmailServiceFactory.get_service_class(service_type)
+ assert service_class is not None
+ assert service_class.__name__ == "DuckMailService"
+
+
+def test_email_service_types_include_duck_mail():
+ result = asyncio.run(email_routes.get_service_types())
+ duckmail_type = next(item for item in result["types"] if item["value"] == "duck_mail")
+
+ assert duckmail_type["label"] == "DuckMail"
+ field_names = [field["name"] for field in duckmail_type["config_fields"]]
+ assert "base_url" in field_names
+ assert "default_domain" in field_names
+ assert "api_key" in field_names
+
+
+def test_filter_sensitive_config_marks_duckmail_api_key():
+ filtered = email_routes.filter_sensitive_config({
+ "base_url": "https://api.duckmail.test",
+ "api_key": "dk_test_key",
+ "default_domain": "duckmail.sbs",
+ })
+
+ assert filtered["base_url"] == "https://api.duckmail.test"
+ assert filtered["default_domain"] == "duckmail.sbs"
+ assert filtered["has_api_key"] is True
+ assert "api_key" not in filtered
+
+
+def test_registration_available_services_include_duck_mail(monkeypatch):
+ runtime_dir = Path("tests_runtime")
+ runtime_dir.mkdir(exist_ok=True)
+ db_path = runtime_dir / "duckmail_routes.db"
+ if db_path.exists():
+ db_path.unlink()
+
+ manager = DatabaseSessionManager(f"sqlite:///{db_path}")
+ Base.metadata.create_all(bind=manager.engine)
+
+ with manager.session_scope() as session:
+ session.add(
+ EmailService(
+ service_type="duck_mail",
+ name="DuckMail 主服务",
+ config={
+ "base_url": "https://api.duckmail.test",
+ "default_domain": "duckmail.sbs",
+ "api_key": "dk_test_key",
+ },
+ enabled=True,
+ priority=0,
+ )
+ )
+
+ @contextmanager
+ def fake_get_db():
+ session = manager.SessionLocal()
+ try:
+ yield session
+ finally:
+ session.close()
+
+ monkeypatch.setattr(registration_routes, "get_db", fake_get_db)
+
+ import src.config.settings as settings_module
+
+ monkeypatch.setattr(settings_module, "get_settings", lambda: DummySettings())
+
+ result = asyncio.run(registration_routes.get_available_email_services())
+
+ assert result["duck_mail"]["available"] is True
+ assert result["duck_mail"]["count"] == 1
+ assert result["duck_mail"]["services"][0]["name"] == "DuckMail 主服务"
+ assert result["duck_mail"]["services"][0]["type"] == "duck_mail"
+ assert result["duck_mail"]["services"][0]["default_domain"] == "duckmail.sbs"
diff --git a/tests/test_static_asset_versioning.py b/tests/test_static_asset_versioning.py
new file mode 100644
index 0000000..ba23cf9
--- /dev/null
+++ b/tests/test_static_asset_versioning.py
@@ -0,0 +1,28 @@
+from pathlib import Path
+import importlib
+
+web_app = importlib.import_module("src.web.app")
+
+
+def test_static_asset_version_is_non_empty_string():
+ version = web_app._build_static_asset_version(web_app.STATIC_DIR)
+
+ assert isinstance(version, str)
+ assert version
+ assert version.isdigit()
+
+
+def test_email_services_template_uses_versioned_static_assets():
+ template = Path("templates/email_services.html").read_text(encoding="utf-8")
+
+ assert '/static/css/style.css?v={{ static_version }}' in template
+ assert '/static/js/utils.js?v={{ static_version }}' in template
+ assert '/static/js/email_services.js?v={{ static_version }}' in template
+
+
+def test_index_template_uses_versioned_static_assets():
+ template = Path("templates/index.html").read_text(encoding="utf-8")
+
+ assert '/static/css/style.css?v={{ static_version }}' in template
+ assert '/static/js/utils.js?v={{ static_version }}' in template
+ assert '/static/js/app.js?v={{ static_version }}' in template
diff --git a/webui.py b/webui.py
new file mode 100644
index 0000000..f107c86
--- /dev/null
+++ b/webui.py
@@ -0,0 +1,174 @@
+"""
+Web UI 启动入口
+"""
+
+import uvicorn
+import logging
+import sys
+from pathlib import Path
+
+# 添加项目根目录到 Python 路径
+# PyInstaller 打包后 __file__ 在临时解压目录,需要用 sys.executable 所在目录作为数据目录
+import os
+if getattr(sys, 'frozen', False):
+ # 打包后:使用可执行文件所在目录
+ project_root = Path(sys.executable).parent
+ _src_root = Path(sys._MEIPASS)
+else:
+ project_root = Path(__file__).parent
+ _src_root = project_root
+sys.path.insert(0, str(_src_root))
+
+from src.core.utils import setup_logging
+from src.database.init_db import initialize_database
+from src.config.settings import get_settings
+
+
+def _load_dotenv():
+ """加载 .env 文件(可执行文件同目录或项目根目录)"""
+ env_path = project_root / ".env"
+ if not env_path.exists():
+ return
+ with open(env_path, encoding="utf-8") as f:
+ for line in f:
+ line = line.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+ key, _, value = line.partition("=")
+ key = key.strip()
+ value = value.strip().strip('"').strip("'")
+ if key and key not in os.environ:
+ os.environ[key] = value
+
+
+def setup_application():
+ """设置应用程序"""
+ # 加载 .env 文件(优先级低于已有环境变量)
+ _load_dotenv()
+
+ # 确保数据目录和日志目录在可执行文件所在目录(打包后也适用)
+ data_dir = project_root / "data"
+ logs_dir = project_root / "logs"
+ data_dir.mkdir(exist_ok=True)
+ logs_dir.mkdir(exist_ok=True)
+
+ # 将数据目录路径注入环境变量,供数据库配置使用
+ os.environ.setdefault("APP_DATA_DIR", str(data_dir))
+ os.environ.setdefault("APP_LOGS_DIR", str(logs_dir))
+
+ # 初始化数据库(必须先于获取设置)
+ try:
+ initialize_database()
+ except Exception as e:
+ print(f"数据库初始化失败: {e}")
+ raise
+
+ # 获取配置(需要数据库已初始化)
+ settings = get_settings()
+
+ # 配置日志(日志文件写到实际 logs 目录)
+ log_file = str(logs_dir / Path(settings.log_file).name)
+ setup_logging(
+ log_level=settings.log_level,
+ log_file=log_file
+ )
+
+ logger = logging.getLogger(__name__)
+ logger.info("数据库初始化完成")
+ logger.info(f"数据目录: {data_dir}")
+ logger.info(f"日志目录: {logs_dir}")
+
+ logger.info("应用程序设置完成")
+ return settings
+
+
+def start_webui():
+ """启动 Web UI"""
+ # 设置应用程序
+ settings = setup_application()
+
+ # 导入 FastAPI 应用(延迟导入以避免循环依赖)
+ from src.web.app import app
+
+ # 配置 uvicorn
+ uvicorn_config = {
+ "app": "src.web.app:app",
+ "host": settings.webui_host,
+ "port": settings.webui_port,
+ "reload": settings.debug,
+ "log_level": "info" if settings.debug else "warning",
+ "access_log": settings.debug,
+ "ws": "websockets",
+ }
+
+ logger = logging.getLogger(__name__)
+ logger.info(f"启动 Web UI 在 http://{settings.webui_host}:{settings.webui_port}")
+ logger.info(f"调试模式: {settings.debug}")
+
+ # 启动服务器
+ uvicorn.run(**uvicorn_config)
+
+
+def main():
+ """主函数"""
+ import argparse
+ import os
+
+ parser = argparse.ArgumentParser(description="OpenAI/Codex CLI 自动注册系统 Web UI")
+ parser.add_argument("--host", help="监听主机 (也可通过 WEBUI_HOST 环境变量设置)")
+ parser.add_argument("--port", type=int, help="监听端口 (也可通过 WEBUI_PORT 环境变量设置)")
+ parser.add_argument("--debug", action="store_true", help="启用调试模式 (也可通过 DEBUG=1 环境变量设置)")
+ parser.add_argument("--reload", action="store_true", help="启用热重载")
+ parser.add_argument("--log-level", help="日志级别 (也可通过 LOG_LEVEL 环境变量设置)")
+ parser.add_argument("--access-password", help="Web UI 访问密钥 (也可通过 WEBUI_ACCESS_PASSWORD 环境变量设置)")
+ args = parser.parse_args()
+
+ # 更新配置
+ from src.config.settings import update_settings
+
+ updates = {}
+
+ # 优先使用命令行参数,如果没有则尝试从环境变量获取
+ host = args.host or os.environ.get("WEBUI_HOST")
+ if host:
+ updates["webui_host"] = host
+
+ port = args.port or os.environ.get("WEBUI_PORT")
+ if port:
+ updates["webui_port"] = int(port)
+
+ debug = args.debug or os.environ.get("DEBUG", "").lower() in ("1", "true", "yes")
+ if debug:
+ updates["debug"] = debug
+
+ log_level = args.log_level or os.environ.get("LOG_LEVEL")
+ if log_level:
+ updates["log_level"] = log_level
+
+ access_password = args.access_password or os.environ.get("WEBUI_ACCESS_PASSWORD")
+ if access_password:
+ updates["webui_access_password"] = access_password
+
+ if updates:
+ update_settings(**updates)
+
+ # 启动 Web UI
+ # 初始化 Playwright 浏览器池(如果引擎设置为 playwright)
+ try:
+ from src.config.settings import get_settings as _gs
+ _cfg = _gs()
+ if getattr(_cfg, 'registration_engine', 'http') == 'playwright':
+ from src.core.playwright_pool import playwright_pool
+ _pool_size = getattr(_cfg, 'playwright_pool_size', 5)
+ if playwright_pool.initialize(pool_size=_pool_size):
+ print(f"[Playwright] 浏览器池已启动,池大小: {_pool_size}")
+ else:
+ print("[Playwright] 浏览器池启动失败,将在首次使用时尝试")
+ except Exception as _pw_err:
+ print(f"[Playwright] 初始化跳过: {_pw_err}")
+
+ start_webui()
+
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file