From 0f9948ffc3417a36b1b350965292ae272506d338 Mon Sep 17 00:00:00 2001 From: 237899745 <237899745@qq.com> Date: Sun, 22 Mar 2026 00:24:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20codex-register=20with=20Sub2API?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA=20+=20Playwright=E5=BC=95=E6=93=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 0 .env.example | 19 + .github/workflows/build.yml | 131 ++ .github/workflows/docker-publish.yml | 68 + .gitignore | 60 + Dockerfile | 57 + LICENSE | 21 + README.md | 383 +++++ build.bat | 20 + build.sh | 49 + codex_register.spec | 149 ++ docker-compose.yml | 18 + pyproject.toml | 43 + requirements.txt | 15 + src/__init__.py | 24 + src/config/__init__.py | 53 + src/config/constants.py | 397 +++++ src/config/settings.py | 771 +++++++++ src/core/__init__.py | 32 + src/core/dynamic_proxy.py | 118 ++ src/core/http_client.py | 420 +++++ src/core/openai/__init__.py | 3 + src/core/openai/oauth.py | 370 ++++ src/core/openai/payment.py | 261 +++ src/core/openai/token_refresh.py | 361 ++++ src/core/playwright_pool.py | 213 +++ src/core/register.py | 883 ++++++++++ src/core/register_playwright.py | 767 +++++++++ src/core/upload/__init__.py | 3 + src/core/upload/cpa_upload.py | 330 ++++ src/core/upload/sub2api_upload.py | 233 +++ src/core/upload/team_manager_upload.py | 204 +++ src/core/utils.py | 570 +++++++ src/database/__init__.py | 20 + src/database/crud.py | 716 ++++++++ src/database/init_db.py | 86 + src/database/models.py | 230 +++ src/database/session.py | 183 ++ src/services/__init__.py | 73 + src/services/base.py | 386 +++++ src/services/duck_mail.py | 366 ++++ src/services/freemail.py | 324 ++++ src/services/imap_mail.py | 217 +++ src/services/moe_mail.py | 556 ++++++ src/services/outlook/__init__.py | 8 + src/services/outlook/account.py | 51 + src/services/outlook/base.py | 153 ++ src/services/outlook/email_parser.py | 228 +++ src/services/outlook/health_checker.py | 312 ++++ src/services/outlook/providers/__init__.py | 29 + src/services/outlook/providers/base.py | 180 ++ src/services/outlook/providers/graph_api.py | 250 +++ src/services/outlook/providers/imap_new.py | 231 +++ src/services/outlook/providers/imap_old.py | 345 ++++ src/services/outlook/service.py | 487 ++++++ src/services/outlook/token_manager.py | 239 +++ src/services/outlook_legacy_mail.py | 763 +++++++++ src/services/temp_mail.py | 455 +++++ src/services/tempmail.py | 400 +++++ src/web/__init__.py | 7 + src/web/app.py | 201 +++ src/web/routes/__init__.py | 26 + src/web/routes/accounts.py | 1086 ++++++++++++ src/web/routes/email.py | 610 +++++++ src/web/routes/payment.py | 182 ++ src/web/routes/registration.py | 1549 +++++++++++++++++ src/web/routes/settings.py | 789 +++++++++ src/web/routes/upload/__init__.py | 2 + src/web/routes/upload/cpa_services.py | 179 ++ src/web/routes/upload/sub2api_services.py | 253 +++ src/web/routes/upload/tm_services.py | 153 ++ src/web/routes/websocket.py | 170 ++ src/web/task_manager.py | 386 +++++ static/css/style.css | 1366 +++++++++++++++ static/js/accounts.js | 1268 ++++++++++++++ static/js/app.js | 1673 +++++++++++++++++++ static/js/email_services.js | 789 +++++++++ static/js/payment.js | 146 ++ static/js/settings.js | 1548 +++++++++++++++++ static/js/utils.js | 553 ++++++ templates/accounts.html | 283 ++++ templates/email_services.html | 522 ++++++ templates/index.html | 448 +++++ templates/login.html | 62 + templates/payment.html | 172 ++ templates/settings.html | 596 +++++++ tests/test_cpa_upload.py | 150 ++ tests/test_duck_mail_service.py | 143 ++ tests/test_email_service_duckmail_routes.py | 94 ++ tests/test_static_asset_versioning.py | 28 + webui.py | 174 ++ 91 files changed, 29942 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/docker-publish.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 build.bat create mode 100644 build.sh create mode 100644 codex_register.spec create mode 100644 docker-compose.yml create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 src/__init__.py create mode 100644 src/config/__init__.py create mode 100644 src/config/constants.py create mode 100644 src/config/settings.py create mode 100644 src/core/__init__.py create mode 100644 src/core/dynamic_proxy.py create mode 100644 src/core/http_client.py create mode 100644 src/core/openai/__init__.py create mode 100644 src/core/openai/oauth.py create mode 100644 src/core/openai/payment.py create mode 100644 src/core/openai/token_refresh.py create mode 100644 src/core/playwright_pool.py create mode 100644 src/core/register.py create mode 100644 src/core/register_playwright.py create mode 100644 src/core/upload/__init__.py create mode 100644 src/core/upload/cpa_upload.py create mode 100644 src/core/upload/sub2api_upload.py create mode 100644 src/core/upload/team_manager_upload.py create mode 100644 src/core/utils.py create mode 100644 src/database/__init__.py create mode 100644 src/database/crud.py create mode 100644 src/database/init_db.py create mode 100644 src/database/models.py create mode 100644 src/database/session.py create mode 100644 src/services/__init__.py create mode 100644 src/services/base.py create mode 100644 src/services/duck_mail.py create mode 100644 src/services/freemail.py create mode 100644 src/services/imap_mail.py create mode 100644 src/services/moe_mail.py create mode 100644 src/services/outlook/__init__.py create mode 100644 src/services/outlook/account.py create mode 100644 src/services/outlook/base.py create mode 100644 src/services/outlook/email_parser.py create mode 100644 src/services/outlook/health_checker.py create mode 100644 src/services/outlook/providers/__init__.py create mode 100644 src/services/outlook/providers/base.py create mode 100644 src/services/outlook/providers/graph_api.py create mode 100644 src/services/outlook/providers/imap_new.py create mode 100644 src/services/outlook/providers/imap_old.py create mode 100644 src/services/outlook/service.py create mode 100644 src/services/outlook/token_manager.py create mode 100644 src/services/outlook_legacy_mail.py create mode 100644 src/services/temp_mail.py create mode 100644 src/services/tempmail.py create mode 100644 src/web/__init__.py create mode 100644 src/web/app.py create mode 100644 src/web/routes/__init__.py create mode 100644 src/web/routes/accounts.py create mode 100644 src/web/routes/email.py create mode 100644 src/web/routes/payment.py create mode 100644 src/web/routes/registration.py create mode 100644 src/web/routes/settings.py create mode 100644 src/web/routes/upload/__init__.py create mode 100644 src/web/routes/upload/cpa_services.py create mode 100644 src/web/routes/upload/sub2api_services.py create mode 100644 src/web/routes/upload/tm_services.py create mode 100644 src/web/routes/websocket.py create mode 100644 src/web/task_manager.py create mode 100644 static/css/style.css create mode 100644 static/js/accounts.js create mode 100644 static/js/app.js create mode 100644 static/js/email_services.js create mode 100644 static/js/payment.js create mode 100644 static/js/settings.js create mode 100644 static/js/utils.js create mode 100644 templates/accounts.html create mode 100644 templates/email_services.html create mode 100644 templates/index.html create mode 100644 templates/login.html create mode 100644 templates/payment.html create mode 100644 templates/settings.html create mode 100644 tests/test_cpa_upload.py create mode 100644 tests/test_duck_mail_service.py create mode 100644 tests/test_email_service_duckmail_routes.py create mode 100644 tests/test_static_asset_versioning.py create mode 100644 webui.py 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: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![Python](https://img.shields.io/badge/Python-3.10%2B-blue.svg)](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} + + + + + + + + ${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 = ` + `; + 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 = ` + + `; + + 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 注册系统 + + + + + +
+ + + + +
+ + + +
+
+
0
+
总账号数
+
+
+
0
+
活跃账号
+
+
+
0
+
过期账号
+
+
+
0
+
失败账号
+
+
+ + +
+
+
+ + + + + +
+ +
+ + + + + + + +
+
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + +
ID邮箱密码邮箱服务状态CPA订阅最后刷新操作
+
+
+
+
+
+
+
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + 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 注册系统 + + + + +
+ + + + +
+ + + +
+
+
0
+
Outlook 账户
+
+
+
0
+
自定义邮箱
+
+
+
可用
+
临时邮箱
+
+
+
0
+
已启用服务
+
+
+ + +
+
+

📥 Outlook 批量导入

+ +
+ +
+ + +
+
+

🔗 自定义邮箱服务

+ +
+
+
+ + + + + + + + + + + + + + + + + + +
名称类型地址状态优先级最后使用操作
+
+
+
+
+
+
+
+
+ + +
+
+

📧 Outlook 账户列表

+ +
+
+
+ + + + + + + + + + + + + + + + + +
邮箱认证方式状态优先级最后使用操作
+
+
+
+
+
+
+
+
+ + +
+
+

🌐 临时邮箱配置

+
+
+
+
+ + +
+
+ +
+
+ + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + 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 注册系统 + + + + + +
+ + + + +
+
+ +
+
+
+

📝 注册设置

+
+
+
+
+ + +
+ + + + +
+ + +
+ + + + + +
+ + + + + + + + + + + + + + + + +
+ +
+ + +
+
+
+
+
+ + +
+ +
+
+

💻 监控台

+
+ + +
+
+
+ + + + + + + +
+
[系统] 准备就绪,等待开始注册...
+
+
+
+ + +
+
+

📋 已注册账号

+
+ + 查看全部 +
+
+
+
+ + + + + + + + + + + + + + +
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 注册系统 + + + + + +
+ +
+ + 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 注册系统 + + + + + +
+ + + + +
+ + +
+
+

选择套餐

+
+
+ +
+
+

Plus

+

个人订阅,$20/月

+
+
+

Team

+

团队订阅,按座位计费

+
+
+ + +
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ +
+ + + +
+
+
+
+ + + + + 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 注册系统 + + + + +
+ + + + +
+ + + +
+ + + + + + + +
+ + +
+ +
+
+

动态代理配置

+ 通过 API 每次获取新代理 IP,优先级高于代理列表 +
+
+
+
+ +
+
+ + + 每次注册任务启动时调用此 API 获取代理 URL +
+
+
+ + +
+
+ + +
+
+
+ + + 若 API 返回 JSON,填写点号分隔的字段路径提取代理 URL;留空则将响应原文作为代理 URL +
+
+ + +
+
+
+
+ + +
+
+

代理列表

+
+ + +
+
+
+
+ + + + + + + + + + + + + + + + + + +
ID名称类型地址默认状态最后使用操作
+
+
🌐
+
暂无代理
+
点击"添加代理"按钮添加代理服务器
+
+
+
+
+
+
+ + +
+
+
+

Web UI 访问密码

+ 用于访问页面的密码,留空表示不修改 +
+
+
+
+ + +
+
+ +
+
+
+
+
+ + + + + +
+ +
+
+

☁️ CPA 服务

+ +
+
+
+ + + + + + + + + + + + + + +
名称API URL代理写入状态优先级操作
加载中...
+
+
+
+ + +
+
+

🔗 Sub2API 服务

+ +
+
+
+ + + + + + + + + + + + + +
名称API URL状态优先级操作
加载中...
+
+
+
+ + +
+
+

🚀 Team Manager 服务

+ +
+
+
+ + + + + + + + + + + + + +
名称API URL状态优先级操作
加载中...
+
+
+
+
+ + + + + + + + + + + +
+
+
+

Outlook OAuth 配置

+ 配置 Outlook 邮箱 OAuth 认证参数 +
+
+
+
+ + +

Outlook OAuth 应用的 Client ID。导入账户时未填写 client_id 则使用此默认值。

+
+ +
+ +
+
+
+
+
+ + +
+
+
+

注册配置

+
+
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+
+ + +

Playwright 使用真实浏览器环境,解决 workspace Cookie 问题

+
+
+ + +

并发浏览器上下文数量

+
+
+ +
+ +
+
+
+
+
+ + +
+
+
+

验证码等待配置

+ 配置 Outlook 邮箱验证码获取的超时时间和轮询间隔 +
+
+
+
+
+ + + 等待验证码的最大时间,建议 60-300 秒 +
+ +
+ + + 检查邮箱的时间间隔,建议 2-5 秒 +
+
+ +
+ +
+
+
+
+ +
+
+

验证码获取策略

+
+
+
    +
  • 渐进式检查:前 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