From 0513cbe974cd3831fa736eb52c9284986a79915b Mon Sep 17 00:00:00 2001 From: doujiang <527191253@qq.com> Date: Wed, 25 Mar 2026 20:19:46 +0800 Subject: [PATCH 1/3] chore: snapshot current project state --- .env.example | 26 + .github/workflows/build.yml | 111 + .github/workflows/docker-image.yml | 61 + .github/workflows/docker-publish.yml | 68 + .gitignore | 58 + Dockerfile | 51 + LICENSE | 21 + README.md | 230 ++ build.bat | 51 + build.sh | 50 + codex_register.spec | 151 + docker-compose.yml | 27 + make-test-win.bat | 27 + pyproject.toml | 43 + requirements.txt | 15 + scripts/docker/start-webui.sh | 35 + scripts/make_windows_test_bundle.ps1 | 245 ++ src/__init__.py | 24 + src/config/__init__.py | 53 + src/config/constants.py | 399 +++ src/config/project_notice.py | 31 + src/config/settings.py | 775 +++++ src/core/__init__.py | 32 + src/core/db_logs.py | 164 + src/core/dynamic_proxy.py | 118 + src/core/http_client.py | 429 +++ src/core/openai/__init__.py | 3 + src/core/openai/browser_bind.py | 1311 ++++++++ src/core/openai/oauth.py | 370 +++ src/core/openai/overview.py | 767 +++++ src/core/openai/payment.py | 1249 +++++++ src/core/openai/random_billing.py | 482 +++ src/core/openai/sentinel.py | 98 + src/core/openai/token_refresh.py | 411 +++ src/core/register.py | 2817 ++++++++++++++++ src/core/timezone_utils.py | 61 + src/core/upload/__init__.py | 3 + src/core/upload/cpa_upload.py | 312 ++ src/core/upload/sub2api_upload.py | 224 ++ src/core/upload/team_manager_upload.py | 204 ++ src/core/utils.py | 583 ++++ src/database/__init__.py | 20 + src/database/crud.py | 716 ++++ src/database/init_db.py | 86 + src/database/models.py | 288 ++ src/database/session.py | 186 ++ 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 | 245 ++ 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 | 256 ++ src/services/outlook/providers/imap_new.py | 244 ++ src/services/outlook/providers/imap_old.py | 370 +++ src/services/outlook/service.py | 508 +++ src/services/outlook/token_manager.py | 239 ++ src/services/outlook_legacy_mail.py | 763 +++++ src/services/temp_mail.py | 864 +++++ src/services/tempmail.py | 400 +++ src/web/__init__.py | 7 + src/web/app.py | 291 ++ src/web/routes/__init__.py | 28 + src/web/routes/accounts.py | 2287 +++++++++++++ src/web/routes/email.py | 619 ++++ src/web/routes/logs.py | 123 + src/web/routes/payment.py | 3299 +++++++++++++++++++ src/web/routes/registration.py | 1550 +++++++++ src/web/routes/settings.py | 888 +++++ src/web/routes/upload/__init__.py | 2 + src/web/routes/upload/cpa_services.py | 171 + src/web/routes/upload/sub2api_services.py | 207 ++ src/web/routes/upload/tm_services.py | 153 + src/web/routes/websocket.py | 170 + src/web/task_manager.py | 396 +++ static/css/style.css | 1461 ++++++++ static/js/accounts.js | 1340 ++++++++ static/js/accounts_overview.js | 1050 ++++++ static/js/app.js | 1598 +++++++++ static/js/email_services.js | 789 +++++ static/js/logs.js | 277 ++ static/js/payment.js | 1856 +++++++++++ static/js/settings.js | 1598 +++++++++ static/js/utils.js | 536 +++ templates/accounts.html | 346 ++ templates/accounts_overview.html | 741 +++++ templates/auto_team.html | 58 + templates/card_pool.html | 58 + templates/email_services.html | 527 +++ templates/index.html | 545 +++ templates/login.html | 63 + templates/logs.html | 249 ++ templates/partials/site_notice.html | 33 + templates/payment.html | 655 ++++ templates/settings.html | 600 ++++ tests/test_cpa_upload.py | 110 + tests/test_duck_mail_service.py | 143 + tests/test_email_service_duckmail_routes.py | 94 + tests/test_registration_engine.py | 296 ++ tests/test_static_asset_versioning.py | 28 + tests/test_temp_mail_service.py | 291 ++ tmp_app_core.js | 11 + tmp_redirectToPage.js | 2 + webui.py | 174 + 111 files changed, 45750 insertions(+) create mode 100644 .env.example create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/docker-image.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 make-test-win.bat create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 scripts/docker/start-webui.sh create mode 100644 scripts/make_windows_test_bundle.ps1 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/project_notice.py create mode 100644 src/config/settings.py create mode 100644 src/core/__init__.py create mode 100644 src/core/db_logs.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/browser_bind.py create mode 100644 src/core/openai/oauth.py create mode 100644 src/core/openai/overview.py create mode 100644 src/core/openai/payment.py create mode 100644 src/core/openai/random_billing.py create mode 100644 src/core/openai/sentinel.py create mode 100644 src/core/openai/token_refresh.py create mode 100644 src/core/register.py create mode 100644 src/core/timezone_utils.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/logs.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/accounts_overview.js create mode 100644 static/js/app.js create mode 100644 static/js/email_services.js create mode 100644 static/js/logs.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/accounts_overview.html create mode 100644 templates/auto_team.html create mode 100644 templates/card_pool.html create mode 100644 templates/email_services.html create mode 100644 templates/index.html create mode 100644 templates/login.html create mode 100644 templates/logs.html create mode 100644 templates/partials/site_notice.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_registration_engine.py create mode 100644 tests/test_static_asset_versioning.py create mode 100644 tests/test_temp_mail_service.py create mode 100644 tmp_app_core.js create mode 100644 tmp_redirectToPage.js create mode 100644 webui.py diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..821fd168 --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# 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 + +# ── 第三方自动绑卡(可选) ─────────────────────────────────── +# 第三方绑卡 API 地址(支持填 Workers 根域名,后端会自动补全路径) +# BIND_CARD_API_URL=https://twilight-river-f148.482091502.workers.dev/ + +# 第三方绑卡 API Key(可留空,后端会先尝试无鉴权) +# BIND_CARD_API_KEY=your_api_key_here diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..803b6333 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,111 @@ +name: Multi-platform Build Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + version: + description: "Version tag, for example v1.0.0" + required: false + default: "dev" + +jobs: + build: + name: Build ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + source_name: codex-console.exe + asset_name: codex-console-windows-x64.exe + - os: ubuntu-latest + source_name: codex-console + asset_name: codex-console-linux-x64 + - os: macos-latest + source_name: codex-console + asset_name: codex-console-macos-arm64 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: "pip" + + - name: Install dependencies + run: | + pip install -r requirements.txt pyinstaller + + - name: Build binary + run: | + pyinstaller codex_register.spec --clean --noconfirm + + - name: Rename packaged binary + shell: bash + run: | + mv "dist/${{ matrix.source_name }}" "dist/${{ matrix.asset_name }}" + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.asset_name }} + path: dist/${{ matrix.asset_name }} + if-no-files-found: error + + release: + name: Create release + needs: build + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + permissions: + contents: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: dist/ + merge-multiple: true + + - name: Create GitHub release + uses: softprops/action-gh-release@v2 + with: + files: dist/* + generate_release_notes: true + body: | + ## codex-console + + ### Download + | Platform | File | + |------|------| + | Windows x64 | `codex-console-windows-x64.exe` | + | Linux x64 | `codex-console-linux-x64` | + | macOS ARM64 | `codex-console-macos-arm64` | + + ### Usage + ```bash + # Linux/macOS may need execute permission first + chmod +x codex-console-* + + # Start Web UI + ./codex-console + + # Custom port + ./codex-console --port 8080 + + # Debug mode + ./codex-console --debug + + # Set Web UI password + ./codex-console --access-password mypassword + ``` diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 00000000..167b6fec --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,61 @@ +name: Docker Image CI + +on: + push: + branches: [ "master", "main" ] + tags: [ 'v*.*.*' ] + pull_request: + branches: [ "master", "main" ] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push-image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - 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 }} + # 修改这里:使用 GHCR_TOKEN 而不是 GITHUB_TOKEN + password: ${{ secrets.GHCR_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest + type=ref,event=branch + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=sha + + + + + - 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 diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 00000000..874d40b5 --- /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 00000000..c93cf6e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,58 @@ +# 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/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..5cdb5df8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,51 @@ +# 使用官方 Python 基础镜像 (使用 slim 版本减小体积) +FROM python:3.11-slim + +# 设置工作目录 +WORKDIR /app + +# 设置环境变量 +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + # WebUI 默认配置 + WEBUI_HOST=0.0.0.0 \ + WEBUI_PORT=1455 \ + DISPLAY=:99 \ + ENABLE_VNC=1 \ + VNC_PORT=5900 \ + NOVNC_PORT=6080 \ + LOG_LEVEL=info \ + DEBUG=0 + +# 安装系统依赖 +# (curl_cffi 等库可能需要编译工具) +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + gcc \ + python3-dev \ + xvfb \ + fluxbox \ + x11vnc \ + websockify \ + novnc \ + ca-certificates \ + && 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 \ + && python -m playwright install --with-deps chromium + +# 复制项目代码 +COPY . . +COPY scripts/docker/start-webui.sh /app/scripts/docker/start-webui.sh +RUN chmod +x /app/scripts/docker/start-webui.sh + +# 暴露端口 +EXPOSE 1455 +EXPOSE 6080 +EXPOSE 5900 + +# 启动 WebUI +CMD ["/app/scripts/docker/start-webui.sh"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..14fac913 --- /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 00000000..e5b3ab02 --- /dev/null +++ b/README.md @@ -0,0 +1,230 @@ +# codex-console + +基于 [cnlimiter/codex-manager](https://github.com/cnlimiter/codex-manager) 持续修复和维护的增强版本。 + +这个版本的目标很直接: 把近期 OpenAI 注册链路里那些“昨天还能跑,今天突然翻车”的坑补上,让注册、登录、拿 token、打包运行都更稳一点。 + +[![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/) + +- GitHub Repo: [https://github.com/dou-jiang/codex-console](https://github.com/dou-jiang/codex-console) + +## QQ群 + +- 交流群: [291638849??????](https://qm.qq.com/q/4TETC3mWco) +- Telegram ??: [codex_console](https://t.me/codex_console) + +## 致谢 + +首先感谢上游项目作者 [cnlimiter](https://github.com/cnlimiter) 提供的优秀基础工程。 + +本仓库是在原项目思路和结构之上进行兼容性修复、流程调整和体验优化,适合作为一个“当前可用的修复维护版”继续使用。 + +## 这个分支修了什么 + +为适配当前注册链路,这个分支重点补了下面几个问题: + +1. 新增 Sentinel POW 求解逻辑 + OpenAI 现在会强制校验 Sentinel POW,原先直接传空值已经不行了,这里补上了实际求解流程。 + +2. 注册和登录拆成两段 + 现在注册完成后通常不会直接返回可用 token,而是跳转到绑定手机或后续页面。 + 本分支改成“先注册成功,再单独走一次登录流程拿 token”,避免卡死在旧逻辑里。 + +3. 去掉重复发送验证码 + 登录流程里服务端本身会自动发送验证码邮件,旧逻辑再手动发一次,容易让新旧验证码打架。 + 现在改成直接等待系统自动发来的那封验证码邮件。 + +4. 修复重新登录流程的页面判断问题 + 针对重新登录时页面流转变化,调整了登录入口和密码提交逻辑,减少卡在错误页面的情况。 + +5. 优化终端和 Web UI 提示文案 + 保留可读性的前提下,把一些提示改得更友好一点,出错时至少不至于像在挨骂。 + +## 核心能力 + +- Web UI 管理注册任务和账号数据 +- 支持批量注册、日志实时查看、基础任务管理 +- 支持多种邮箱服务接码 +- 支持 SQLite 和远程 PostgreSQL +- 支持打包为 Windows/Linux/macOS 可执行文件 +- 更适配当前 OpenAI 注册与登录链路 + +## 环境要求 + +- Python 3.10+ +- `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-console.exe --access-password mypassword +``` + +启动后访问: + +[http://127.0.0.1:8000](http://127.0.0.1:8000) + +## Docker 部署 + +### 使用 docker-compose + +```bash +docker-compose up -d +``` + +你可以在 `docker-compose.yml` 中修改环境变量,比如端口和访问密码。 +如果需要看“全自动绑卡”的可视化浏览器,打开: + +- noVNC: `http://127.0.0.1:6080` + +### 使用 docker run + +```bash +docker run -d \ + -p 1455:1455 \ + -p 6080:6080 \ + -e DISPLAY=:99 \ + -e ENABLE_VNC=1 \ + -e VNC_PORT=5900 \ + -e NOVNC_PORT=6080 \ + -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-console \ + ghcr.io//codex-console: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`。 + +## 打包为可执行文件 + +```bash +# Windows +build.bat + +# Linux/macOS +bash build.sh +``` + +Windows 打包完成后,默认会在 `dist/` 目录生成类似下面的文件: + +```text +dist/codex-console-windows-X64.exe +``` + +如果打包失败,优先检查: + +- Python 是否已加入 PATH +- 依赖是否安装完整 +- 杀毒软件是否拦截了 PyInstaller 产物 +- 终端里是否有更具体的报错日志 + +## 项目定位 + +这个仓库更适合作为: + +- 原项目的修复增强版 +- 当前注册链路的兼容维护版 +- 自己二次开发的基础版本 + +如果你准备公开发布,建议在仓库描述里明确写上: + +`Forked and fixed from cnlimiter/codex-manager` + +这样既方便别人理解来源,也对上游作者更尊重。 + +## 仓库命名 + +当前仓库名: + +`codex-console` + +## 免责声明 + +本项目仅供学习、研究和技术交流使用,请遵守相关平台和服务条款,不要用于违规、滥用或非法用途。 + +因使用本项目产生的任何风险和后果,由使用者自行承担。 + +## ???? + +????????????????????????????? + +?? ??????????????????????????????????????????????????????????????????????????????????? + diff --git a/build.bat b/build.bat new file mode 100644 index 00000000..ae7fa457 --- /dev/null +++ b/build.bat @@ -0,0 +1,51 @@ +@echo off +setlocal +setlocal enabledelayedexpansion + +echo === Build platform: Windows === + +where python >nul 2>nul +if errorlevel 1 ( + echo Python was not found in PATH. + exit /b 1 +) + +if exist requirements.txt ( + echo Installing project dependencies... + python -m pip install -r requirements.txt + if errorlevel 1 ( + echo Failed to install project dependencies. + exit /b 1 + ) +) + +echo Installing PyInstaller... +python -m pip install pyinstaller --quiet +if errorlevel 1 ( + echo Failed to install PyInstaller. + exit /b 1 +) + +echo Running PyInstaller... +python -m PyInstaller codex_register.spec --clean --noconfirm +if errorlevel 1 ( + echo PyInstaller build failed. + exit /b 1 +) + +if exist dist\codex-console.exe ( + for /f "tokens=*" %%i in ('powershell -Command "[System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture"') do set ARCH=%%i + set OUTPUT=dist\codex-console-windows-!ARCH!.exe + if exist "!OUTPUT!" del /F /Q "!OUTPUT!" >nul 2>nul + move /Y dist\codex-console.exe "!OUTPUT!" >nul + if errorlevel 1 ( + echo === Build complete, but final rename was blocked. === + echo Close any running copy of !OUTPUT! and rename dist\codex-console.exe manually if needed. + echo Fresh binary is available at: dist\codex-console.exe + exit /b 0 + ) + echo === Build complete: !OUTPUT! === +) else ( + echo === Build failed: dist\codex-console.exe not found === + exit /b 1 +) diff --git a/build.sh b/build.sh new file mode 100644 index 00000000..c0d87464 --- /dev/null +++ b/build.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# Cross-platform build script. Run it on the target OS you want to package for. + +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-console-${PLATFORM}-${ARCH}${EXT}" + +echo "=== Build platform: ${PLATFORM} (${ARCH}) ===" +echo "=== Output file: dist/${OUTPUT_NAME} ===" + +# Install build dependency. +pip install pyinstaller --quiet 2>/dev/null || \ + uv run --with pyinstaller pyinstaller --version > /dev/null 2>&1 + +# Run PyInstaller. Prefer uv when available. +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 + +# Rename the generated binary to include platform metadata. +rm -f "dist/${OUTPUT_NAME}" +mv "dist/codex-console${EXT}" "dist/${OUTPUT_NAME}" 2>/dev/null || \ + mv "dist/codex-console" "dist/${OUTPUT_NAME}" 2>/dev/null + +echo "=== Build complete: dist/${OUTPUT_NAME} ===" +ls -lh "dist/${OUTPUT_NAME}" diff --git a/codex_register.spec b/codex_register.spec new file mode 100644 index 00000000..40af3d7a --- /dev/null +++ b/codex_register.spec @@ -0,0 +1,151 @@ +# -*- 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_mail', + 'src.core.openai.oauth', + 'src.core.openai.token_refresh', + 'src.core.upload.cpa_upload', + 'src.core.upload.sub2api_upload', + 'src.core.upload.team_manager_upload', + 'src.web.routes.accounts', + 'src.web.routes.email', + '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-console', + 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 00000000..95aa46e6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +version: '3.8' + +services: + webui: + build: . + shm_size: "1gb" + ports: + - "1455:1455" + - "6080:6080" + # 如需使用 VNC 客户端直连可打开: + # - "5900:5900" + environment: + - WEBUI_HOST=0.0.0.0 + - WEBUI_PORT=1455 + - DISPLAY=:99 + - ENABLE_VNC=1 + - VNC_PORT=5900 + - NOVNC_PORT=6080 + - DEBUG=0 + - LOG_LEVEL=info + # 如果需要访问密码,可以在这里取消注释并设置 + - WEBUI_ACCESS_PASSWORD=admin123 + volumes: + # 挂载数据目录以持久化数据库和日志 + - ./data:/app/data + - ./logs:/app/logs + restart: unless-stopped diff --git a/make-test-win.bat b/make-test-win.bat new file mode 100644 index 00000000..bd3d76b9 --- /dev/null +++ b/make-test-win.bat @@ -0,0 +1,27 @@ +@echo off +setlocal +cd /d "%~dp0" + +set "PS_CMD=" +where powershell.exe >nul 2>nul +if not errorlevel 1 set "PS_CMD=powershell.exe" +if not defined PS_CMD ( + where pwsh.exe >nul 2>nul + if not errorlevel 1 set "PS_CMD=pwsh.exe" +) +if not defined PS_CMD ( + echo. + echo PowerShell not found. Install Windows PowerShell or PowerShell 7 first. + exit /b 1 +) + +%PS_CMD% -ExecutionPolicy Bypass -File ".\scripts\make_windows_test_bundle.ps1" +if errorlevel 1 ( + echo. + echo Failed to create test bundle. + exit /b 1 +) + +echo. +echo Test bundle created successfully. +echo You can run: ..\codex-console2-win-test\start-webui.bat diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..06f9cec3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,43 @@ +[project] +name = "codex-console" +version = "1.0.4" +description = "OpenAI account management console" +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-console = "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 00000000..6131f37f --- /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 +# 自动绑卡(local_auto)依赖 +playwright>=1.40.0 diff --git a/scripts/docker/start-webui.sh b/scripts/docker/start-webui.sh new file mode 100644 index 00000000..8a7ef920 --- /dev/null +++ b/scripts/docker/start-webui.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +export DISPLAY="${DISPLAY:-:99}" +ENABLE_VNC="${ENABLE_VNC:-1}" +VNC_PORT="${VNC_PORT:-5900}" +NOVNC_PORT="${NOVNC_PORT:-6080}" + +if [[ "${ENABLE_VNC}" == "1" || "${ENABLE_VNC,,}" == "true" ]]; then + echo "[docker] starting Xvfb on ${DISPLAY}" + Xvfb "${DISPLAY}" -screen 0 1366x900x24 -ac +extension RANDR >/tmp/xvfb.log 2>&1 & + + echo "[docker] starting fluxbox window manager" + fluxbox >/tmp/fluxbox.log 2>&1 & + + echo "[docker] starting x11vnc on :${VNC_PORT}" + x11vnc \ + -display "${DISPLAY}" \ + -rfbport "${VNC_PORT}" \ + -forever \ + -shared \ + -nopw \ + >/tmp/x11vnc.log 2>&1 & + + NOVNC_WEB_ROOT="/usr/share/novnc" + if [[ ! -d "${NOVNC_WEB_ROOT}" ]]; then + NOVNC_WEB_ROOT="/usr/share/novnc/" + fi + echo "[docker] starting noVNC on :${NOVNC_PORT} (web=${NOVNC_WEB_ROOT})" + websockify --web="${NOVNC_WEB_ROOT}" "${NOVNC_PORT}" "127.0.0.1:${VNC_PORT}" >/tmp/novnc.log 2>&1 & +fi + +echo "[docker] starting webui..." +exec python webui.py + diff --git a/scripts/make_windows_test_bundle.ps1 b/scripts/make_windows_test_bundle.ps1 new file mode 100644 index 00000000..ef5ea2f4 --- /dev/null +++ b/scripts/make_windows_test_bundle.ps1 @@ -0,0 +1,245 @@ +param( + [string]$TargetDir = "", + [string]$ListenHost = "127.0.0.1", + [int]$Port = 1455, + [string]$AccessPassword = "admin123" +) + +$ErrorActionPreference = "Stop" + +function Write-AsciiCrlfFile { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Content + ) + $normalized = [regex]::Replace($Content, "`r?`n", "`r`n") + [System.IO.File]::WriteAllText($Path, $normalized, [System.Text.Encoding]::ASCII) +} + +$RepoRoot = Split-Path -Parent $PSScriptRoot +if (-not $TargetDir) { + $TargetDir = Join-Path (Split-Path -Parent $RepoRoot) "codex-console2-win-test" +} + +Write-Host "[bundle] repo: $RepoRoot" +Write-Host "[bundle] target: $TargetDir" + +New-Item -ItemType Directory -Path $TargetDir -Force | Out-Null + +$distDir = Join-Path $RepoRoot "dist" +$exeCandidates = @() +if (Test-Path $distDir) { + $exeCandidates += Get-ChildItem -Path $distDir -Filter "codex-console-windows-*.exe" -File -ErrorAction SilentlyContinue + $exeCandidates += Get-ChildItem -Path $distDir -Filter "codex-console.exe" -File -ErrorAction SilentlyContinue +} + +$mode = "source" +if ($exeCandidates.Count -gt 0) { + $latestExe = $exeCandidates | Sort-Object LastWriteTime -Descending | Select-Object -First 1 + Copy-Item $latestExe.FullName (Join-Path $TargetDir "codex-console.exe") -Force + $mode = "exe" + Write-Host "[bundle] use exe: $($latestExe.Name)" +} +else { + Write-Host "[bundle] exe not found, fallback to source mode." + $copyItems = @( + "webui.py", + "requirements.txt", + "pyproject.toml", + ".env.example", + "templates", + "static", + "src" + ) + foreach ($item in $copyItems) { + $srcPath = Join-Path $RepoRoot $item + if (Test-Path $srcPath) { + $dstPath = Join-Path $TargetDir $item + if (Test-Path $dstPath) { + Remove-Item -Path $dstPath -Recurse -Force + } + Copy-Item $srcPath $dstPath -Recurse -Force + } + } +} + +$envFile = Join-Path $TargetDir ".env" +if (-not (Test-Path $envFile)) { + $envLines = @( + "# Generated by scripts/make_windows_test_bundle.ps1", + "WEBUI_HOST=$ListenHost", + "WEBUI_PORT=$Port", + "WEBUI_ACCESS_PASSWORD=$AccessPassword", + "DEBUG=false", + "LOG_LEVEL=INFO" + ) + Set-Content -Path $envFile -Value $envLines -Encoding UTF8 +} + +$startScript = @" +@echo off +setlocal +cd /d "%~dp0" + +set "WEBUI_HOST=$ListenHost" +set "WEBUI_PORT=$Port" +set "WEBUI_ACCESS_PASSWORD=$AccessPassword" +set "LAUNCHER_LOG=%~dp0launcher-debug.log" +set "EXIT_CODE=0" +set "PY_EXE=" +set "PY_ARGS=" + +> "%LAUNCHER_LOG%" echo launcher start: %DATE% %TIME% +>> "%LAUNCHER_LOG%" echo cwd: %CD% + +echo =================================================== +echo codex-console2 Windows test launcher +echo Host: %WEBUI_HOST% Port: %WEBUI_PORT% +echo =================================================== + +if exist "codex-console.exe" goto RUN_EXE +goto RUN_SOURCE + +:RUN_EXE +echo [mode] exe +>> "%LAUNCHER_LOG%" echo [mode] exe +start "" "http://%WEBUI_HOST%:%WEBUI_PORT%" +codex-console.exe --host %WEBUI_HOST% --port %WEBUI_PORT% --access-password %WEBUI_ACCESS_PASSWORD% >> "%LAUNCHER_LOG%" 2>&1 +set "EXIT_CODE=%ERRORLEVEL%" +if not "%EXIT_CODE%"=="0" ( + echo. + echo codex-console.exe exited with code %EXIT_CODE% + goto END +) +echo. +echo codex-console.exe exited normally. +goto END + +:RUN_SOURCE +echo [mode] source +>> "%LAUNCHER_LOG%" echo [mode] source +if not exist "requirements.txt" ( + echo requirements.txt not found in %CD% + set "EXIT_CODE=1" + goto END +) + +where python >nul 2>nul +if not errorlevel 1 ( + python -c "import sys" >nul 2>nul + if not errorlevel 1 ( + set "PY_EXE=python" + set "PY_ARGS=" + ) +) + +if not defined PY_EXE ( + where py >nul 2>nul + if not errorlevel 1 ( + py -3 -c "import sys" >nul 2>nul + if not errorlevel 1 ( + set "PY_EXE=py" + set "PY_ARGS=-3" + ) + ) +) + +if not defined PY_EXE ( + echo Valid Python 3 launcher not found in PATH. Install Python 3.10+ first. + echo Download: https://www.python.org/downloads/windows/ + set "EXIT_CODE=1" + goto END +) +echo python launcher: %PY_EXE% %PY_ARGS% +>> "%LAUNCHER_LOG%" echo python launcher: %PY_EXE% %PY_ARGS% + +if not exist ".venv\Scripts\python.exe" ( + echo creating venv... + "%PY_EXE%" %PY_ARGS% -m venv .venv >> "%LAUNCHER_LOG%" 2>&1 + if errorlevel 1 ( + echo failed to create venv + set "EXIT_CODE=1" + goto END + ) +) + +.venv\Scripts\python.exe -c "import fastapi,uvicorn,sqlalchemy,jinja2" >> "%LAUNCHER_LOG%" 2>&1 +if errorlevel 1 ( + echo installing dependencies... + .venv\Scripts\python.exe -m pip install --upgrade pip >> "%LAUNCHER_LOG%" 2>&1 + .venv\Scripts\python.exe -m pip install -r requirements.txt >> "%LAUNCHER_LOG%" 2>&1 + if errorlevel 1 ( + echo failed to install dependencies + set "EXIT_CODE=1" + goto END + ) +) + +if not exist ".venv\.playwright_chromium_ok" ( + .venv\Scripts\python.exe -c "import playwright" >nul 2>nul + if errorlevel 1 ( + echo playwright package missing, installing... + .venv\Scripts\python.exe -m pip install playwright >> "%LAUNCHER_LOG%" 2>&1 + ) + echo installing playwright chromium ^(first run may take a while^)... + .venv\Scripts\python.exe -m playwright install chromium >> "%LAUNCHER_LOG%" 2>&1 + if not errorlevel 1 ( + type nul > ".venv\.playwright_chromium_ok" + ) +) + +start "" "http://%WEBUI_HOST%:%WEBUI_PORT%" +echo starting webui... +.venv\Scripts\python.exe webui.py --host %WEBUI_HOST% --port %WEBUI_PORT% --access-password %WEBUI_ACCESS_PASSWORD% >> "%LAUNCHER_LOG%" 2>&1 +set "EXIT_CODE=%ERRORLEVEL%" +if not "%EXIT_CODE%"=="0" ( + echo. + echo webui exited with code %EXIT_CODE% + echo check logs under: %CD%\logs +) +goto END + +:END +echo. +echo launcher exit code: %EXIT_CODE% +echo debug log: %LAUNCHER_LOG% +if not "%EXIT_CODE%"=="0" ( + echo 启动失败,请把 launcher-debug.log 发我。 +) +pause +exit /b %EXIT_CODE% +"@ +Write-AsciiCrlfFile -Path (Join-Path $TargetDir "start-webui.bat") -Content $startScript + +$stopScript = @" +@echo off +setlocal +echo stopping codex-console.exe ... +taskkill /IM codex-console.exe /F >nul 2>nul +echo done. +"@ +Write-AsciiCrlfFile -Path (Join-Path $TargetDir "stop-webui.bat") -Content $stopScript + +$readme = @" +# codex-console2 Windows test bundle + +Generated from: $RepoRoot +Mode: $mode + +## Quick start + +1. Double click `start-webui.bat` +2. Open: `http://${ListenHost}:$Port` +3. Password: $AccessPassword + +## Notes + +- If `codex-console.exe` exists, launcher runs exe mode. +- If exe is missing, launcher runs source mode and auto-creates `.venv`. +"@ +Set-Content -Path (Join-Path $TargetDir "README-WIN-TEST.md") -Value $readme -Encoding UTF8 + +Write-Host "" +Write-Host "[bundle] done." +Write-Host "[bundle] mode: $mode" +Write-Host "[bundle] run: $TargetDir\start-webui.bat" diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 00000000..e5ac097d --- /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 00000000..da2f93b7 --- /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 00000000..9e787a2e --- /dev/null +++ b/src/config/constants.py @@ -0,0 +1,399 @@ +""" +常量定义 +""" + +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", + "password_verify": "https://auth.openai.com/api/accounts/password/verify", + "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": "create_account_password", # 新账号,需要设置密码 + "LOGIN_PASSWORD": "login_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/project_notice.py b/src/config/project_notice.py new file mode 100644 index 00000000..5939ac17 --- /dev/null +++ b/src/config/project_notice.py @@ -0,0 +1,31 @@ +"""Shared project notice content for terminal and Web UI.""" + +PROJECT_NOTICE = { + "title": "项目声明", + "free_notice": "本项目永久免费开源,如果你是付费购买的,请立即找卖家退款。", + "disclaimer": ( + "免责声明:本工具仅供学习和研究使用,使用本工具产生的一切后果由使用者自行承担。" + "请遵守相关服务的使用条款,不要用于任何违法或不当用途。" + "如有侵权,请及时联系,会及时删除。" + ), + "github_repo_name": "dou-jiang/codex-console", + "github_repo_url": "https://github.com/dou-jiang/codex-console", + "qq_group_id": "291638849", + "qq_group_url": "https://qm.qq.com/q/4TETC3mWco", + "telegram_name": "codex_console", + "telegram_url": "https://t.me/codex_console", +} + + +def build_terminal_notice_lines() -> list[str]: + """Build terminal-friendly notice lines.""" + return [ + "=" * 72, + "项目声明", + PROJECT_NOTICE["free_notice"], + f"GitHub 仓库 {PROJECT_NOTICE['github_repo_name']}:{PROJECT_NOTICE['github_repo_url']}", + f"QQ交流群 {PROJECT_NOTICE['qq_group_id']}:{PROJECT_NOTICE['qq_group_url']}", + f"Telegram频道 {PROJECT_NOTICE['telegram_name']}:{PROJECT_NOTICE['telegram_url']}", + PROJECT_NOTICE["disclaimer"], + "=" * 72, + ] diff --git a/src/config/settings.py b/src/config/settings.py new file mode 100644 index 00000000..ac4bb9ad --- /dev/null +++ b/src/config/settings.py @@ -0,0 +1,775 @@ +""" +配置管理 - 完全基于数据库存储 +所有配置都从数据库读取,不再使用环境变量或 .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="注册间隔最大值(秒)" + ), + "registration_entry_flow": SettingDefinition( + db_key="registration.entry_flow", + default_value="native", + category=SettingCategory.REGISTRATION, + description="注册入口链路(native=原本链路, abcard=ABCard入口链路;Outlook 邮箱会自动走 Outlook 链路)" + ), + + # 邮箱服务配置 + "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_entry_flow": str, + "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_entry_flow: str = "native" + + # 邮箱服务配置 + 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 00000000..7ec7c6f8 --- /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/db_logs.py b/src/core/db_logs.py new file mode 100644 index 00000000..00257d98 --- /dev/null +++ b/src/core/db_logs.py @@ -0,0 +1,164 @@ +""" +后台日志入库与清理 +""" + +from __future__ import annotations + +import logging +import threading +import traceback +from datetime import datetime, timedelta +from typing import Any, Dict, Optional + +from sqlalchemy import func + +from ..config.settings import get_settings +from ..database.models import AppLog +from ..database.session import get_db + + +_INSTALL_LOCK = threading.Lock() +_INSTALLED = False + +_SKIP_LOGGER_PREFIXES = ( + "sqlalchemy", + "uvicorn.access", + "watchfiles", +) + + +def _should_skip_record(record: logging.LogRecord) -> bool: + logger_name = str(record.name or "") + if not logger_name: + return False + for prefix in _SKIP_LOGGER_PREFIXES: + if logger_name.startswith(prefix): + return True + return False + + +class DatabaseLogHandler(logging.Handler): + """ + 将日志写入 app_logs 表。 + 为避免递归写日志,emit 内部不再产生日志。 + """ + + def __init__(self, min_level: int = logging.INFO): + super().__init__(level=min_level) + self._local = threading.local() + + def emit(self, record: logging.LogRecord) -> None: + if getattr(self._local, "busy", False): + return + if record.levelno < self.level: + return + if _should_skip_record(record): + return + + message = "" + exception_text = None + try: + self._local.busy = True + message = record.getMessage() + if record.exc_info: + exception_text = "".join(traceback.format_exception(*record.exc_info))[-4000:] + elif record.exc_text: + exception_text = str(record.exc_text)[-4000:] + + with get_db() as db: + db.add( + AppLog( + level=record.levelname, + logger=str(record.name or "root"), + module=str(record.module or ""), + pathname=str(record.pathname or ""), + lineno=int(record.lineno or 0), + message=str(message or ""), + exception=exception_text, + created_at=datetime.utcfromtimestamp(record.created), + ) + ) + db.commit() + except Exception: + self.handleError(record) + finally: + self._local.busy = False + + +def install_database_log_handler(min_level: int = logging.INFO) -> bool: + """ + 安装数据库日志处理器(全局仅安装一次)。 + Returns: + 是否本次新安装 + """ + global _INSTALLED + with _INSTALL_LOCK: + if _INSTALLED: + return False + + root_logger = logging.getLogger() + if any(isinstance(handler, DatabaseLogHandler) for handler in root_logger.handlers): + _INSTALLED = True + return False + + handler = DatabaseLogHandler(min_level=min_level) + root_logger.addHandler(handler) + _INSTALLED = True + return True + + +def cleanup_database_logs( + retention_days: Optional[int] = None, + max_rows: int = 50000, +) -> Dict[str, Any]: + """ + 清理后台日志: + 1) 删除超过 retention_days 的日志 + 2) 若总量超过 max_rows,删除最旧的超量部分 + """ + settings = get_settings() + keep_days = int(retention_days if retention_days is not None else settings.log_retention_days or 30) + keep_days = max(1, keep_days) + max_rows = max(1000, int(max_rows)) + cutoff = datetime.utcnow() - timedelta(days=keep_days) + + deleted_by_age = 0 + deleted_by_limit = 0 + + with get_db() as db: + deleted_by_age = ( + db.query(AppLog) + .filter(AppLog.created_at < cutoff) + .delete(synchronize_session=False) + ) + db.commit() + + total = db.query(func.count(AppLog.id)).scalar() or 0 + if total > max_rows: + overflow = int(total - max_rows) + overflow_ids = [ + row_id + for (row_id,) in db.query(AppLog.id) + .order_by(AppLog.created_at.asc(), AppLog.id.asc()) + .limit(overflow) + .all() + ] + if overflow_ids: + deleted_by_limit = ( + db.query(AppLog) + .filter(AppLog.id.in_(overflow_ids)) + .delete(synchronize_session=False) + ) + db.commit() + + remaining = db.query(func.count(AppLog.id)).scalar() or 0 + + return { + "retention_days": keep_days, + "max_rows": max_rows, + "deleted_by_age": int(deleted_by_age or 0), + "deleted_by_limit": int(deleted_by_limit or 0), + "deleted_total": int((deleted_by_age or 0) + (deleted_by_limit or 0)), + "remaining": int(remaining or 0), + } + diff --git a/src/core/dynamic_proxy.py b/src/core/dynamic_proxy.py new file mode 100644 index 00000000..daf8dfa7 --- /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 00000000..f870b346 --- /dev/null +++ b/src/core/http_client.py @@ -0,0 +1,429 @@ +""" +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 +from .openai.sentinel import SentinelPOWError, build_sentinel_pow_token + + +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: + pow_token = build_sentinel_pow_token(self.default_headers.get("User-Agent", "")) + sen_req_body = json.dumps({ + "p": pow_token, + "id": did, + "flow": "authorize_continue", + }, separators=(",", ":")) + + 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 SentinelPOWError as e: + logger.error(f"Sentinel POW 求解失败: {e}") + 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) diff --git a/src/core/openai/__init__.py b/src/core/openai/__init__.py new file mode 100644 index 00000000..3a2da6d2 --- /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/browser_bind.py b/src/core/openai/browser_bind.py new file mode 100644 index 00000000..38dfe335 --- /dev/null +++ b/src/core/openai/browser_bind.py @@ -0,0 +1,1311 @@ +""" +本地浏览器自动绑卡(参考 ABCard 的 checkout 自动化流程)。 + +说明: +- 仅做自动填写和提交,不做验证码绕过。 +- 若检测到 challenge(如 hCaptcha/3DS),返回 need_user_action,由前端提示用户手动完成。 +""" + +from __future__ import annotations + +import json +import logging +import os +import random +import re +import shutil +import subprocess +import tempfile +import time +import urllib.request +from typing import Any, Dict, List, Optional, Tuple + +logger = logging.getLogger(__name__) + +_STRIPE_IFRAME_SELECTOR = 'iframe[name*="__privateStripeFrame"]' +_SUCCESS_TEXT_TOKENS = ( + "payment successful", + "successfully subscribed", + "welcome to", + "订阅成功", + "支付成功", +) +_FAIL_TEXT_PATTERNS = ( + ("card was declined", "银行卡被拒"), + ("your card was declined", "银行卡被拒"), + ("card_declined", "银行卡被拒"), + ("insufficient funds", "余额不足"), + ("expired card", "卡已过期"), + ("authentication_required", "需要验证"), + ("payment failed", "支付失败"), + ("unable to process", "无法处理支付"), + ("invalid card", "卡信息无效"), +) + +_COOKIE_ATTR_NAMES = { + "path", + "domain", + "expires", + "max-age", + "samesite", + "secure", + "httponly", + "priority", + "partitioned", +} +_COOKIE_NAME_PATTERN = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$") +_SAFE_COOKIE_NAME_ALLOWLIST = { + "__Secure-next-auth.session-token", + "oai-did", + "oai-client-auth-session", + "__cf_bm", + "cf_clearance", +} + + +def _parse_cookie_str(cookies_str: str, domain: str) -> List[dict]: + cookies: List[dict] = [] + text = str(cookies_str or "").strip() + if not text: + return cookies + for item in text.split(";"): + if "=" not in item: + continue + key, value = item.split("=", 1) + key = key.strip() + value = value.strip() + if not key: + continue + cookies.append( + { + "name": key, + "value": value, + "domain": domain, + "path": "/", + "httpOnly": False, + "secure": True, + "sameSite": "Lax", + } + ) + return cookies + + +def _sanitize_cookie_value(value: str) -> str: + text = str(value or "").strip().strip('"').strip("'") + if not text: + return "" + text = text.replace("\r", "").replace("\n", "") + if ";" in text: + text = text.split(";", 1)[0].strip() + return text + + +def _parse_cookie_pairs(cookies_str: str) -> Dict[str, str]: + text = str(cookies_str or "").strip() + if not text: + return {} + + result: Dict[str, str] = {} + for item in text.split(";"): + raw = str(item or "").strip() + if not raw or "=" not in raw: + continue + name_raw, value_raw = raw.split("=", 1) + name = str(name_raw or "").strip() + value = _sanitize_cookie_value(value_raw) + if not name or not value: + continue + if name.lower() in _COOKIE_ATTR_NAMES: + continue + if not _COOKIE_NAME_PATTERN.match(name): + continue + prev = str(result.get(name) or "") + if (not prev) or (len(value) > len(prev)): + result[name] = value + return result + + +def _build_playwright_cookie_items( + cookies_str: str, + resolved_session: str, + resolved_did: str, +) -> List[dict]: + """ + 构造可被 Playwright 接受的 cookie 列表: + - 过滤属性项/非法名称 + - 仅注入安全白名单 cookie + - __Host- 前缀不携带 domain(避免 Invalid cookie fields) + """ + cookie_map = _parse_cookie_pairs(cookies_str) + + session = _sanitize_cookie_value(resolved_session) + did = _sanitize_cookie_value(resolved_did) + if session: + cookie_map["__Secure-next-auth.session-token"] = session + if did: + cookie_map["oai-did"] = did + + items: List[dict] = [] + for name, value in cookie_map.items(): + if name not in _SAFE_COOKIE_NAME_ALLOWLIST and not name.startswith("__Host-"): + continue + if not value: + continue + + if name.startswith("__Host-"): + items.append( + { + "name": name, + "value": value, + "url": "https://chatgpt.com/", + "secure": True, + "sameSite": "Lax", + } + ) + continue + + items.append( + { + "name": name, + "value": value, + "domain": ".chatgpt.com", + "path": "/", + "httpOnly": bool(name in ("__Secure-next-auth.session-token", "oai-client-auth-session")), + "secure": True, + "sameSite": "Lax", + } + ) + return items + + +def _add_cookies_resilient(context, cookies: List[dict], stage: str) -> None: + if not cookies: + return + try: + context.add_cookies(cookies) + return + except Exception as exc: + names = [str(c.get("name") or "") for c in cookies] + logger.warning("%s 注入完整 cookie 失败,将降级重试: %s | names=%s", stage, exc, ",".join(names[:12])) + + # 降级:仅注入最关键的 session + did + minimal: List[dict] = [] + for item in cookies: + name = str(item.get("name") or "") + if name in ("__Secure-next-auth.session-token", "oai-did"): + minimal.append(item) + if not minimal: + raise RuntimeError("cookie injection failed and no minimal cookies available") + context.add_cookies(minimal) + + +def _extract_cookie_value(cookies_str: str, name: str) -> str: + text = str(cookies_str or "") + if not text: + return "" + prefix = f"{name}=" + for item in text.split(";"): + item = item.strip() + if item.startswith(prefix): + return item[len(prefix) :].strip() + return "" + + +def _extract_session_token_from_cookie_text(cookies_str: str) -> str: + text = str(cookies_str or "") + if not text: + return "" + + direct = _extract_cookie_value(text, "__Secure-next-auth.session-token") + if direct: + return direct + + chunks: Dict[int, str] = {} + for raw in text.split(";"): + item = str(raw or "").strip() + if not item or "=" not in item: + continue + key, value = item.split("=", 1) + name = str(key or "").strip() + if not name.startswith("__Secure-next-auth.session-token."): + continue + try: + idx = int(name.rsplit(".", 1)[-1]) + except Exception: + continue + chunks[idx] = str(value or "").strip() + if chunks: + return "".join(chunks[idx] for idx in sorted(chunks.keys())) + return "" + + +def _extract_cookie_value_from_items(items: List[dict], name: str) -> str: + for item in items or []: + try: + key = str(item.get("name") or "").strip() + if key != name: + continue + return str(item.get("value") or "").strip() + except Exception: + continue + return "" + + +def _extract_session_token_from_items(items: List[dict]) -> str: + direct = _extract_cookie_value_from_items(items, "__Secure-next-auth.session-token") + if direct: + return direct + chunks: Dict[int, str] = {} + for item in items or []: + try: + name = str(item.get("name") or "").strip() + if not name.startswith("__Secure-next-auth.session-token."): + continue + idx = int(name.rsplit(".", 1)[-1]) + chunks[idx] = str(item.get("value") or "").strip() + except Exception: + continue + if chunks: + return "".join(chunks[idx] for idx in sorted(chunks.keys())) + return "" + + +def _normalize_exp_year(exp_year: str) -> str: + digits = re.sub(r"\D", "", str(exp_year or "")) + if not digits: + return "" + if len(digits) >= 2: + return digits[-2:] + return digits.zfill(2) + + +def _find_chrome_binary() -> str: + env_path = str(os.getenv("CHROME_PATH") or "").strip() + candidates = [ + env_path, + os.path.expanduser("~/.cache/ms-playwright/chromium-*/chrome-linux/chrome"), + "/ms-playwright/chromium-*/chrome-linux/chrome", + os.path.expandvars(r"%ProgramFiles%\Google\Chrome\Application\chrome.exe"), + os.path.expandvars(r"%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe"), + os.path.expandvars(r"%LocalAppData%\Google\Chrome\Application\chrome.exe"), + os.path.expandvars(r"%ProgramFiles%\Microsoft\Edge\Application\msedge.exe"), + os.path.expandvars(r"%ProgramFiles(x86)%\Microsoft\Edge\Application\msedge.exe"), + "/usr/bin/google-chrome", + "/usr/bin/chromium-browser", + "/usr/bin/chromium", + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + ] + for binary in candidates: + value = str(binary or "").strip() + if "*" in value: + try: + import glob + matched = sorted(glob.glob(value)) + except Exception: + matched = [] + for mv in matched: + if mv and os.path.exists(mv): + return mv + continue + if value and os.path.exists(value): + return value + + # fallback to PATH lookup + import shutil as _which_lib + + for name in ("chrome", "google-chrome", "chromium-browser", "chromium", "msedge"): + value = _which_lib.which(name) + if value: + return value + return "" + + +def _simulate_human_behavior(page) -> None: + try: + for _ in range(random.randint(3, 6)): + x = random.randint(100, 1100) + y = random.randint(120, 760) + page.mouse.move(x, y, steps=random.randint(8, 18)) + time.sleep(random.uniform(0.08, 0.25)) + page.mouse.wheel(0, random.randint(120, 260)) + time.sleep(random.uniform(0.2, 0.5)) + page.mouse.wheel(0, -random.randint(80, 180)) + except Exception: + pass + + +def _try_click_hcaptcha_checkbox(page) -> bool: + # 先从 frame URL 检测 + try: + for frame in page.frames: + url = str(getattr(frame, "url", "") or "").lower() + if "hcaptcha" not in url: + continue + try: + frame_el = frame.frame_element() + box = frame_el.bounding_box() if frame_el else None + if box and box.get("width", 0) > 20 and box.get("height", 0) > 20: + page.mouse.click(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2) + return True + except Exception: + continue + except Exception: + pass + + # 再从 DOM iframe 兜底 + try: + candidates = page.query_selector_all('iframe[src*="hcaptcha"], iframe[title*="hCaptcha"], iframe[title*="security challenge"]') + for iframe_el in candidates: + box = iframe_el.bounding_box() + if box and box.get("width", 0) > 20 and box.get("height", 0) > 20: + page.mouse.click(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2) + return True + except Exception: + pass + return False + + +def _try_click_challenge_continue(page) -> bool: + """ + 挑战页上的常见继续按钮兜底点击(3DS/hCaptcha)。 + """ + selectors = ( + 'button:has-text("Verify")', + 'button:has-text("Continue")', + 'button:has-text("Complete")', + 'button:has-text("Authorize")', + 'button:has-text("确认")', + 'button:has-text("继续")', + 'button:has-text("完成")', + '[data-testid*="continue"]', + ) + for selector in selectors: + try: + btn = page.query_selector(selector) + if btn and btn.is_visible(): + btn.click() + return True + except Exception: + continue + return False + + +def _detect_challenge_in_context(context, primary_page): + """ + 扫描当前 context 的所有页面,返回是否存在 challenge,以及命中的页面。 + """ + pages = [] + try: + pages = list(getattr(context, "pages", []) or []) + except Exception: + pages = [] + if primary_page and primary_page not in pages: + pages.append(primary_page) + if not pages: + return False, primary_page + + for pg in list(reversed(pages)): + try: + body = _extract_page_text(pg, 2000).lower() + if _detect_challenge(pg, body): + return True, pg + except Exception: + continue + return False, primary_page + + +def _auto_bind_with_cdp_checkout( + *, + checkout_url: str, + cookies_str: str, + session_token: str, + access_token: str, + device_id: str, + card_number: str, + exp_month: str, + exp_year: str, + cvc: str, + billing_name: str, + billing_country: str, + billing_line1: str, + billing_city: str, + billing_state: str, + billing_postal: str, + proxy: Optional[str] = None, + timeout_seconds: int = 180, + post_submit_wait_seconds: int = 90, + headless: bool = False, +) -> Dict[str, Any]: + """ + ABCard 风格: 外部 Chrome + CDP 连接,降低自动化特征后执行 checkout 填卡。 + """ + checkout_url = str(checkout_url or "").strip() + if not checkout_url: + return { + "success": False, + "need_user_action": False, + "error": "checkout_url empty", + "stage": "cdp_input", + "driver": "cdp", + "fallback_recommended": True, + } + + try: + from playwright.sync_api import sync_playwright + except Exception: + return { + "success": False, + "need_user_action": True, + "error": "playwright not installed", + "stage": "cdp_bootstrap", + "driver": "cdp", + "fallback_recommended": True, + } + + resolved_session = str(session_token or "").strip() or _extract_session_token_from_cookie_text(cookies_str) + resolved_did = str(device_id or "").strip() or _extract_cookie_value(cookies_str, "oai-did") + session_missing = not bool(resolved_session) + if session_missing: + logger.warning("CDP 自动绑卡缺少 session token,将继续尝试无会话模式") + + chrome_binary = _find_chrome_binary() + if not chrome_binary: + return { + "success": False, + "need_user_action": False, + "error": "chrome binary not found", + "stage": "cdp_chrome_not_found", + "driver": "cdp", + "fallback_recommended": True, + } + + timeout_seconds = max(int(timeout_seconds), 60) + post_submit_wait_seconds = max(int(post_submit_wait_seconds), 30) + cdp_port = random.randint(9320, 9480) + user_data_dir = tempfile.mkdtemp(prefix=f"codex-cdp-{cdp_port}-") + cdp_url = f"http://127.0.0.1:{cdp_port}" + chrome_args = [ + chrome_binary, + f"--remote-debugging-port={cdp_port}", + "--no-sandbox", + "--disable-dev-shm-usage", + "--no-first-run", + "--no-default-browser-check", + "--disable-extensions", + "--disable-background-networking", + "--disable-sync", + "--window-size=1366,900", + f"--user-data-dir={user_data_dir}", + "about:blank", + ] + if headless: + chrome_args.extend(["--headless=new", "--disable-gpu"]) + else: + # 无头设备上维持 WebGL 指纹可用 + chrome_args.extend([ + "--use-gl=angle", + "--use-angle=swiftshader-webgl", + "--enable-unsafe-swiftshader", + ]) + if proxy: + chrome_args.append(f"--proxy-server={proxy}") + + chrome_proc = None + try: + chrome_proc = subprocess.Popen( + chrome_args, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + cdp_ready = False + for _ in range(22): + try: + with urllib.request.urlopen(f"{cdp_url}/json/version", timeout=2) as resp: + data = json.loads(resp.read() or b"{}") + if data.get("Browser"): + cdp_ready = True + break + except Exception: + time.sleep(0.5) + if not cdp_ready: + return { + "success": False, + "need_user_action": False, + "error": "chrome cdp port not responding", + "stage": "cdp_unavailable", + "driver": "cdp", + "fallback_recommended": True, + } + + with sync_playwright() as p: + browser = p.chromium.connect_over_cdp(cdp_url) + context = None + try: + context = browser.new_context( + viewport={"width": 1366, "height": 900}, + 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" + ), + ) + + cookies = _build_playwright_cookie_items( + cookies_str=cookies_str, + resolved_session=resolved_session, + resolved_did=resolved_did, + ) + if cookies: + _add_cookies_resilient(context, cookies, stage="CDP") + + page = context.new_page() + page.set_default_timeout(timeout_seconds * 1000) + + page.goto("https://chatgpt.com/", wait_until="domcontentloaded", timeout=60000) + cf_ok, cf_note = _wait_for_cloudflare(page, max_wait_seconds=min(timeout_seconds, 90)) + if not cf_ok: + return { + "success": False, + "need_user_action": True, + "pending": True, + "error": cf_note, + "stage": "cdp_cloudflare", + "driver": "cdp", + "current_url": page.url, + "fallback_recommended": False, + } + + page.goto(checkout_url, wait_until="domcontentloaded", timeout=60000) + time.sleep(1.5) + redirect_url = str(page.url or "") + if "/checkout/" not in redirect_url: + return { + "success": False, + "need_user_action": False, + "pending": False, + "error": "checkout redirected, verify session" if not session_missing else "checkout redirected, missing session token", + "stage": "cdp_session_missing" if session_missing else "cdp_navigate_checkout", + "driver": "cdp", + "current_url": redirect_url, + "fallback_recommended": True, + } + + if not _wait_for_stripe_iframe(page, max_wait_seconds=min(timeout_seconds, 90)): + return { + "success": False, + "need_user_action": True, + "pending": True, + "error": "stripe iframe not loaded", + "stage": "cdp_wait_stripe", + "driver": "cdp", + "current_url": page.url, + "fallback_recommended": False, + } + + _simulate_human_behavior(page) + + ok, reason = _fill_payment_iframe( + page, + card_number=card_number, + exp_month=exp_month, + exp_year=exp_year, + cvc=cvc, + ) + if not ok: + return { + "success": False, + "need_user_action": False, + "error": reason, + "stage": "cdp_fill_card", + "driver": "cdp", + "current_url": page.url, + "fallback_recommended": True, + } + + _fill_address_iframe( + page, + billing_name=billing_name, + billing_line1=billing_line1, + billing_zip=billing_postal, + billing_country=billing_country, + billing_city=billing_city, + billing_state=billing_state, + ) + + submit_btn = _find_submit_button(page) + if not submit_btn: + return { + "success": False, + "need_user_action": True, + "pending": True, + "error": "submit button not found", + "stage": "cdp_find_submit", + "driver": "cdp", + "current_url": page.url, + "fallback_recommended": False, + } + + submit_btn.click() + challenge_click_count = 0 + challenge_seen = False + challenge_last_seen_at = 0.0 + resubmit_count = 0 + # challenge 场景常比普通提交更慢,给更长观察窗口,避免过早返回 need_user_action。 + deadline = time.monotonic() + max(post_submit_wait_seconds, 150) + while time.monotonic() < deadline: + current_url = str(page.url or "") + body = _extract_page_text(page, 2500) + body_lower = body.lower() + url_lower = current_url.lower() + + if any(token in url_lower for token in ("subscribed=true", "success", "/settings/subscription")): + return { + "success": True, + "need_user_action": False, + "stage": "cdp_confirmed", + "driver": "cdp", + "current_url": current_url, + "fallback_recommended": False, + } + + if any(token in body_lower for token in _SUCCESS_TEXT_TOKENS): + return { + "success": True, + "need_user_action": False, + "stage": "cdp_confirmed_text", + "driver": "cdp", + "current_url": current_url, + "fallback_recommended": False, + } + + for pattern, msg in _FAIL_TEXT_PATTERNS: + if pattern in body_lower: + return { + "success": False, + "need_user_action": False, + "error": msg, + "stage": "cdp_declined", + "driver": "cdp", + "current_url": current_url, + "fallback_recommended": False, + } + + detected, challenge_page = _detect_challenge_in_context(context, page) + if detected: + challenge_seen = True + challenge_last_seen_at = time.monotonic() + clicked = False + if challenge_click_count < 6: + clicked = _try_click_hcaptcha_checkbox(challenge_page) + if clicked: + challenge_click_count += 1 + logger.info( + "CDP challenge 自动点击尝试: attempt=%s current_url=%s", + challenge_click_count, + str(getattr(challenge_page, "url", "") or current_url)[:120], + ) + if not clicked: + clicked = _try_click_challenge_continue(challenge_page) + # challenge 出现后继续等待,不要立刻判失败。 + time.sleep(3.0 if clicked else 2.0) + continue + + if challenge_seen: + # challenge 消失后触发一次重提交流程,避免停在“已验证但未确认支付”。 + elapsed = time.monotonic() - challenge_last_seen_at + if elapsed >= 4 and resubmit_count < 2: + try: + retry_btn = _find_submit_button(page) + if retry_btn and retry_btn.is_visible(): + retry_btn.click() + resubmit_count += 1 + logger.info( + "CDP challenge 后自动重提交流程: attempt=%s current_url=%s", + resubmit_count, + current_url[:120], + ) + time.sleep(2.5) + continue + except Exception: + pass + + time.sleep(3) + + if challenge_seen: + return { + "success": False, + "need_user_action": True, + "pending": True, + "error": "challenge_detected", + "stage": "cdp_challenge", + "driver": "cdp", + "current_url": str(page.url or ""), + "fallback_recommended": False, + } + + return { + "success": False, + "need_user_action": True, + "pending": True, + "error": "payment_result_timeout", + "stage": "cdp_timeout", + "driver": "cdp", + "current_url": str(page.url or ""), + "fallback_recommended": False, + } + finally: + try: + if context is not None: + context.close() + except Exception: + pass + try: + browser.close() + except Exception: + pass + except Exception as exc: + return { + "success": False, + "need_user_action": False, + "error": f"cdp_exception: {exc}", + "stage": "cdp_exception", + "driver": "cdp", + "fallback_recommended": True, + } + finally: + if chrome_proc is not None: + try: + chrome_proc.terminate() + chrome_proc.wait(timeout=4) + except Exception: + try: + chrome_proc.kill() + except Exception: + pass + shutil.rmtree(user_data_dir, ignore_errors=True) + + +def _wait_for_cloudflare(page, max_wait_seconds: int) -> Tuple[bool, str]: + max_wait_seconds = max(int(max_wait_seconds), 15) + rounds = max(max_wait_seconds // 3, 1) + for _ in range(rounds): + try: + title = str(page.title() or "").lower() + url = str(page.url or "").lower() + body = str(page.evaluate("document.body ? document.body.innerText.slice(0, 500) : ''") or "").lower() + except Exception: + time.sleep(3) + continue + challenge = ( + "just a moment" in title + or "请稍候" in title + or "/cdn-cgi/challenge-platform" in url + or "verify you are human" in body + ) + if not challenge: + return True, "" + time.sleep(3) + return False, "Cloudflare challenge not passed in time" + + +def _wait_for_stripe_iframe(page, max_wait_seconds: int) -> bool: + max_wait_seconds = max(int(max_wait_seconds), 15) + rounds = max(max_wait_seconds // 3, 1) + for _ in range(rounds): + try: + iframe_elements = page.query_selector_all(_STRIPE_IFRAME_SELECTOR) + visible = [] + for el in iframe_elements: + box = el.bounding_box() or {} + if box.get("height", 0) > 30 and box.get("width", 0) > 120: + visible.append(el) + if visible: + return True + except Exception: + pass + time.sleep(3) + return False + + +def _fill_payment_iframe(page, card_number: str, exp_month: str, exp_year: str, cvc: str) -> Tuple[bool, str]: + card_number = re.sub(r"\D", "", str(card_number or "")) + exp_month = re.sub(r"\D", "", str(exp_month or "")).zfill(2)[:2] + exp_year = _normalize_exp_year(exp_year) + cvc = re.sub(r"\D", "", str(cvc or ""))[:4] + if not card_number or not exp_month or not exp_year or not cvc: + return False, "card fields incomplete" + + exp_text = f"{exp_month}{exp_year}" + iframe_elements = page.query_selector_all(_STRIPE_IFRAME_SELECTOR) + payment_el = None + backup_el = None + + for iframe_el in iframe_elements: + try: + box = iframe_el.bounding_box() + if not box or box.get("height", 0) < 30: + continue + frame_obj = iframe_el.content_frame() + if frame_obj and "elements-inner-payment" in str(frame_obj.url or ""): + payment_el = iframe_el + break + if backup_el is None and box.get("height", 0) < 220: + backup_el = iframe_el + except Exception: + continue + if payment_el is None: + payment_el = backup_el + if payment_el is None: + return False, "payment iframe not found" + + box = payment_el.bounding_box() + if not box: + return False, "payment iframe box unavailable" + + payment_el.scroll_into_view_if_needed() + time.sleep(0.3) + page.mouse.click(box["x"] + 80, box["y"] + 24) + time.sleep(0.5) + + # Stripe Payment Element 常见流程:卡号 -> 到期日 -> CVC + page.keyboard.type(card_number, delay=45) + time.sleep(0.4) + page.keyboard.type(exp_text, delay=45) + time.sleep(0.3) + page.keyboard.type(cvc, delay=45) + time.sleep(0.4) + return True, "" + + +def _fill_address_iframe( + page, + billing_name: str, + billing_line1: str, + billing_zip: str, + billing_country: str, + billing_city: str = "", + billing_state: str = "", +) -> Tuple[bool, str]: + iframe_elements = page.query_selector_all(_STRIPE_IFRAME_SELECTOR) + address_el = None + address_frame = None + for iframe_el in iframe_elements: + try: + box = iframe_el.bounding_box() + if not box or box.get("height", 0) < 30: + continue + frame_obj = iframe_el.content_frame() + if frame_obj and "elements-inner-address" in str(frame_obj.url or ""): + address_el = iframe_el + address_frame = frame_obj + break + except Exception: + continue + + if not address_el or not address_frame: + return False, "address iframe not found" + + def _click_and_type(selector: str, value: str) -> bool: + value = str(value or "").strip() + if not value: + return True + try: + address_el.scroll_into_view_if_needed() + time.sleep(0.2) + rect = address_frame.evaluate( + """(sel) => { + const el = document.querySelector(sel); + if (!el) return null; + const r = el.getBoundingClientRect(); + return {x: r.x + r.width / 2, y: r.y + r.height / 2}; + }""", + selector, + ) + box = address_el.bounding_box() + if not rect or not box: + return False + page.mouse.click(box["x"] + rect["x"], box["y"] + rect["y"]) + time.sleep(0.2) + page.keyboard.press("Control+a") + page.keyboard.press("Backspace") + page.keyboard.type(value, delay=35) + time.sleep(0.2) + return True + except Exception: + return False + + try: + address_frame.evaluate( + """(country) => { + const sel = document.querySelector('select[name="country"]'); + if (!sel || !country) return; + const nativeSet = Object.getOwnPropertyDescriptor( + window.HTMLSelectElement.prototype, 'value' + ).set; + nativeSet.call(sel, country); + sel.dispatchEvent(new Event('change', {bubbles: true})); + }""", + str(billing_country or "US").upper(), + ) + time.sleep(0.5) + except Exception: + pass + + _click_and_type('input[name="name"]', billing_name) + _click_and_type('input[name="addressLine1"]', billing_line1) + + # 城市/州/邮编在部分布局下会在 addressLine1 之后出现,优先 JS 赋值兜底。 + try: + address_frame.evaluate( + """(data) => { + function setInput(name, value) { + const el = document.querySelector('input[name="' + name + '"]'); + if (!el || !value) return; + const nativeSet = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, 'value' + ).set; + nativeSet.call(el, value); + el.dispatchEvent(new Event('input', {bubbles: true})); + el.dispatchEvent(new Event('change', {bubbles: true})); + el.dispatchEvent(new Event('blur', {bubbles: true})); + } + function setSelect(name, value) { + const el = document.querySelector('select[name="' + name + '"]'); + if (!el || !value) return; + const nativeSet = Object.getOwnPropertyDescriptor( + window.HTMLSelectElement.prototype, 'value' + ).set; + nativeSet.call(el, value); + el.dispatchEvent(new Event('change', {bubbles: true})); + el.dispatchEvent(new Event('blur', {bubbles: true})); + } + if (data.city) setInput('locality', data.city); + if (data.state) setSelect('administrativeArea', data.state); + if (data.postal) setInput('postalCode', data.postal); + }""", + {"city": billing_city, "state": billing_state, "postal": billing_zip}, + ) + except Exception: + # 忽略地址补充失败,避免硬中断 + pass + return True, "" + + +def _find_submit_button(page): + selectors = ( + '[data-testid="checkout-submit"]', + 'button[type="submit"]', + 'button:has-text("Subscribe")', + 'button:has-text("Pay")', + 'button:has-text("Confirm")', + 'button:has-text("订阅")', + 'button:has-text("支付")', + 'button:has-text("确认")', + ) + for selector in selectors: + try: + btn = page.query_selector(selector) + if btn and btn.is_visible(): + return btn + except Exception: + continue + return None + + +def _extract_page_text(page, max_len: int = 2000) -> str: + try: + text = str(page.evaluate("document.body ? document.body.innerText : ''") or "") + except Exception: + return "" + return text[:max_len] + + +def _detect_challenge(page, page_text_lower: str) -> bool: + challenge_tokens = ( + "hcaptcha", + "3d secure", + "requires_action", + "authentication required", + "verify you are human", + "complete verification", + "challenge", + ) + if any(token in page_text_lower for token in challenge_tokens): + return True + try: + for frame in page.frames: + url = str(getattr(frame, "url", "") or "").lower() + if any(token in url for token in ("hcaptcha", "3ds", "challenge")): + return True + except Exception: + pass + return False + + +def auto_bind_checkout_with_playwright( + *, + checkout_url: str, + cookies_str: str, + session_token: str, + access_token: str, + device_id: str, + card_number: str, + exp_month: str, + exp_year: str, + cvc: str, + billing_name: str, + billing_country: str, + billing_line1: str, + billing_city: str, + billing_state: str, + billing_postal: str, + proxy: Optional[str] = None, + timeout_seconds: int = 180, + post_submit_wait_seconds: int = 90, + headless: bool = False, +) -> Dict[str, Any]: + """ + 在 chatgpt checkout 页面执行自动填卡。 + 返回结构: + - success=True: 浏览器提交流程出现成功信号 + - success=False + need_user_action=True: 需要人工继续(challenge/超时) + - success=False + need_user_action=False: 明确失败 + """ + checkout_url = str(checkout_url or "").strip() + if not checkout_url: + return {"success": False, "need_user_action": False, "error": "checkout_url empty"} + + # 优先使用 ABCard 风格 CDP 模式,降低自动化痕迹; + # 若判定为“环境性失败”再回退到标准 Playwright 模式。 + cdp_result = _auto_bind_with_cdp_checkout( + checkout_url=checkout_url, + cookies_str=cookies_str, + session_token=session_token, + access_token=access_token, + device_id=device_id, + card_number=card_number, + exp_month=exp_month, + exp_year=exp_year, + cvc=cvc, + billing_name=billing_name, + billing_country=billing_country, + billing_line1=billing_line1, + billing_city=billing_city, + billing_state=billing_state, + billing_postal=billing_postal, + proxy=proxy, + timeout_seconds=timeout_seconds, + post_submit_wait_seconds=post_submit_wait_seconds, + headless=headless, + ) + if cdp_result.get("success"): + return cdp_result + if not bool(cdp_result.get("fallback_recommended", False)): + return cdp_result + logger.warning( + "CDP 自动绑卡回退到标准 Playwright: stage=%s error=%s", + cdp_result.get("stage"), + cdp_result.get("error"), + ) + + try: + from playwright.sync_api import sync_playwright + except Exception: + return { + "success": False, + "need_user_action": True, + "error": "playwright not installed (pip install playwright && playwright install chromium)", + "stage": "bootstrap", + } + + launch_kwargs: Dict[str, Any] = {"headless": bool(headless)} + proxy_server = str(proxy or "").strip() + if proxy_server: + launch_kwargs["proxy"] = {"server": proxy_server} + + timeout_seconds = max(int(timeout_seconds), 60) + post_submit_wait_seconds = max(int(post_submit_wait_seconds), 30) + + with sync_playwright() as p: + browser = p.chromium.launch(**launch_kwargs) + try: + context = browser.new_context( + viewport={"width": 1366, "height": 900}, + user_agent=( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/122.0.0.0 Safari/537.36" + ), + ) + + # 注入账号 cookie(优先使用账号管理中已保存 cookies) + session_token = str(session_token or "").strip() or _extract_session_token_from_cookie_text(cookies_str) + access_token = str(access_token or "").strip() + device_id = str(device_id or "").strip() or _extract_cookie_value(cookies_str, "oai-did") + cookies = _build_playwright_cookie_items( + cookies_str=cookies_str, + resolved_session=session_token, + resolved_did=device_id, + ) + if cookies: + _add_cookies_resilient(context, cookies, stage="Playwright") + + page = context.new_page() + page.set_default_timeout(timeout_seconds * 1000) + + # 先热身 chatgpt 首页,减少 checkout 重定向概率 + page.goto("https://chatgpt.com/", wait_until="domcontentloaded", timeout=60000) + cf_ok, cf_note = _wait_for_cloudflare(page, max_wait_seconds=min(timeout_seconds, 90)) + if not cf_ok: + return { + "success": False, + "need_user_action": True, + "error": cf_note, + "stage": "cloudflare", + "current_url": page.url, + } + + page.goto(checkout_url, wait_until="domcontentloaded", timeout=60000) + time.sleep(1.5) + redirect_url = str(page.url or "") + if "/checkout/" not in redirect_url: + body = _extract_page_text(page, 600) + return { + "success": False, + "need_user_action": True, + "error": "checkout page redirected, please verify login/session", + "stage": "navigate_checkout", + "current_url": redirect_url, + "body_preview": body, + } + + if not _wait_for_stripe_iframe(page, max_wait_seconds=min(timeout_seconds, 90)): + return { + "success": False, + "need_user_action": True, + "error": "stripe payment iframe not loaded", + "stage": "wait_stripe", + "current_url": page.url, + } + + ok, reason = _fill_payment_iframe( + page, + card_number=card_number, + exp_month=exp_month, + exp_year=exp_year, + cvc=cvc, + ) + if not ok: + return {"success": False, "need_user_action": False, "error": reason, "stage": "fill_card"} + + _fill_address_iframe( + page, + billing_name=billing_name, + billing_line1=billing_line1, + billing_zip=billing_postal, + billing_country=billing_country, + billing_city=billing_city, + billing_state=billing_state, + ) + + submit_btn = _find_submit_button(page) + if not submit_btn: + return { + "success": False, + "need_user_action": True, + "error": "submit button not found", + "stage": "find_submit", + "current_url": page.url, + } + submit_btn.click() + + challenge_seen = False + challenge_click_count = 0 + challenge_last_seen_at = 0.0 + resubmit_count = 0 + deadline = time.monotonic() + max(post_submit_wait_seconds, 150) + while time.monotonic() < deadline: + current_url = str(page.url or "") + body = _extract_page_text(page, 2500) + body_lower = body.lower() + url_lower = current_url.lower() + + if any(token in url_lower for token in ("subscribed=true", "success", "/settings/subscription")): + return {"success": True, "need_user_action": False, "stage": "confirmed", "current_url": current_url} + + if any(token in body_lower for token in _SUCCESS_TEXT_TOKENS): + return {"success": True, "need_user_action": False, "stage": "confirmed_text", "current_url": current_url} + + for pattern, msg in _FAIL_TEXT_PATTERNS: + if pattern in body_lower: + return { + "success": False, + "need_user_action": False, + "error": msg, + "stage": "declined", + "current_url": current_url, + } + + detected, challenge_page = _detect_challenge_in_context(context, page) + if detected: + challenge_seen = True + challenge_last_seen_at = time.monotonic() + clicked = False + if challenge_click_count < 6: + clicked = _try_click_hcaptcha_checkbox(challenge_page) + if clicked: + challenge_click_count += 1 + logger.info( + "Playwright challenge 自动点击尝试: attempt=%s current_url=%s", + challenge_click_count, + str(getattr(challenge_page, "url", "") or current_url)[:120], + ) + if not clicked: + clicked = _try_click_challenge_continue(challenge_page) + time.sleep(3.0 if clicked else 2.0) + continue + + if challenge_seen: + elapsed = time.monotonic() - challenge_last_seen_at + if elapsed >= 4 and resubmit_count < 2: + try: + retry_btn = _find_submit_button(page) + if retry_btn and retry_btn.is_visible(): + retry_btn.click() + resubmit_count += 1 + logger.info( + "Playwright challenge 后自动重提交流程: attempt=%s current_url=%s", + resubmit_count, + current_url[:120], + ) + time.sleep(2.5) + continue + except Exception: + pass + + time.sleep(3) + + if challenge_seen: + return { + "success": False, + "need_user_action": True, + "pending": True, + "error": "challenge_detected", + "stage": "challenge", + "current_url": str(page.url or ""), + } + + return { + "success": False, + "need_user_action": True, + "pending": True, + "error": "payment_result_timeout", + "stage": "timeout", + "current_url": str(page.url or ""), + } + finally: + try: + browser.close() + except Exception: + pass diff --git a/src/core/openai/oauth.py b/src/core/openai/oauth.py new file mode 100644 index 00000000..e8dc0fa6 --- /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/overview.py b/src/core/openai/overview.py new file mode 100644 index 00000000..640c2b20 --- /dev/null +++ b/src/core/openai/overview.py @@ -0,0 +1,767 @@ +""" +Codex 账号总览数据抓取与解析 +""" + +from __future__ import annotations + +import base64 +import json +import logging +import re +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional, Tuple + +from curl_cffi import requests as cffi_requests + +from ...database.models import Account + +logger = logging.getLogger(__name__) + +_USAGE_ENDPOINTS: Tuple[Tuple[str, str, bool], ...] = ( + # required=True: 核心稳定端点,失败计入 errors + ("me", "https://chatgpt.com/backend-api/me", True), + ("wham_usage", "https://chatgpt.com/backend-api/wham/usage", True), + # required=False: 非核心端点,仅做补充,失败时静默降级 + ("codex_usage", "https://chatgpt.com/backend-api/codex/usage", False), +) + +_NUMERIC_KEYS_USED = ("used", "usage", "consumed", "spent", "count", "current", "value") +_NUMERIC_KEYS_TOTAL = ("total", "limit", "max", "quota", "allowance", "capacity") +_NUMERIC_KEYS_REMAINING = ("remaining", "left", "available", "remain") +_NUMERIC_KEYS_PERCENT = ("percent", "percentage", "ratio", "remaining_percent", "left_percent") +_RESET_AT_KEYS = ( + "reset_at", + "resets_at", + "next_reset", + "next_reset_at", + "reset_time", + "renew_at", + "expires_at", + "expired_at", +) +_RESET_IN_KEYS = ("reset_in", "time_to_reset", "ttl_seconds", "remaining_seconds", "seconds_to_reset") +_HOURLY_WINDOW_MAX_SECONDS = 12 * 60 * 60 +_WEEKLY_WINDOW_MIN_SECONDS = 5 * 24 * 60 * 60 + + +def _build_proxies(proxy: Optional[str]) -> Optional[dict]: + if not proxy: + return None + return {"http": proxy, "https": proxy} + + +def _extract_cookie_value(cookies_str: str, key: str) -> Optional[str]: + if not cookies_str: + return None + prefix = f"{key}=" + for part in cookies_str.split(";"): + item = part.strip() + if item.startswith(prefix): + return item[len(prefix):].strip() + return None + + +def _resolve_chatgpt_account_id(account: Account) -> Optional[str]: + token_account_id = ( + _extract_chatgpt_account_id_from_jwt(account.access_token) + or _extract_chatgpt_account_id_from_jwt(account.id_token) + ) + for candidate in (token_account_id, account.account_id, account.workspace_id): + value = str(candidate or "").strip() + if value: + return value + return None + + +def _decode_jwt_payload(token: Optional[str]) -> Optional[Dict[str, Any]]: + text = str(token or "").strip() + if not text or "." not in text: + return None + parts = text.split(".") + if len(parts) < 2: + return None + payload_part = parts[1] + if not payload_part: + return None + padding = "=" * (-len(payload_part) % 4) + try: + raw = base64.urlsafe_b64decode(payload_part + padding) + data = json.loads(raw.decode("utf-8")) + return data if isinstance(data, dict) else None + except Exception: + return None + + +def _extract_auth_claim(payload: Optional[Dict[str, Any]]) -> Dict[str, Any]: + if not isinstance(payload, dict): + return {} + auth = payload.get("https://api.openai.com/auth") + if isinstance(auth, dict): + return auth + auth = payload.get("auth_data") + if isinstance(auth, dict): + return auth + return {} + + +def _extract_chatgpt_account_id_from_jwt(token: Optional[str]) -> Optional[str]: + payload = _decode_jwt_payload(token) + if not payload: + return None + auth = _extract_auth_claim(payload) + for key in ("chatgpt_account_id", "account_id", "workspace_id"): + value = str(auth.get(key) or payload.get(key) or "").strip() + if value: + return value + return None + + +def _extract_chatgpt_plan_from_jwt(token: Optional[str]) -> Optional[str]: + payload = _decode_jwt_payload(token) + if not payload: + return None + auth = _extract_auth_claim(payload) + candidates = [ + auth.get("chatgpt_plan_type"), + auth.get("plan_type"), + auth.get("subscription_plan"), + payload.get("chatgpt_plan_type"), + payload.get("plan_type"), + payload.get("subscription_plan"), + payload.get("subscription_tier"), + ] + for item in candidates: + if isinstance(item, str) and item.strip(): + return _normalize_plan(item) + return None + + +def _build_headers(account: Account) -> Dict[str, str]: + headers = { + "Authorization": f"Bearer {account.access_token}", + "Content-Type": "application/json", + } + account_id = _resolve_chatgpt_account_id(account) + if account_id: + headers["ChatGPT-Account-Id"] = account_id + if account.cookies: + headers["cookie"] = account.cookies + oai_did = _extract_cookie_value(account.cookies, "oai-did") + if oai_did: + headers["oai-device-id"] = oai_did + return headers + + +def _request_json(url: str, headers: Dict[str, str], proxy: Optional[str]) -> Dict[str, Any]: + resp = cffi_requests.get( + url, + headers=headers, + proxies=_build_proxies(proxy), + timeout=20, + impersonate="chrome110", + ) + resp.raise_for_status() + data = resp.json() + if isinstance(data, dict): + return data + return {"data": data} + + +def _extract_http_status(exc: Exception) -> Optional[int]: + response = getattr(exc, "response", None) + if response is not None: + status = getattr(response, "status_code", None) + if isinstance(status, int): + return status + match = re.search(r"HTTP Error\s+(\d{3})", str(exc)) + if match: + try: + return int(match.group(1)) + except Exception: + return None + return None + + +def _request_json_with_proxy_fallback(url: str, headers: Dict[str, str], proxy: Optional[str]) -> Dict[str, Any]: + """ + 配额抓取优先按当前代理请求;若代理异常则回退直连重试一次。 + """ + try: + return _request_json(url, headers, proxy) + except Exception as proxy_exc: + if not proxy: + raise + status = _extract_http_status(proxy_exc) + # 某些账号/端点天然 403/404,这里降噪避免刷屏 warning。 + if status in (401, 403, 404): + logger.debug("概览请求代理回退直连: url=%s status=%s err=%s", url, status, proxy_exc) + else: + logger.warning("概览请求代理失败,回退直连重试: url=%s err=%s", url, proxy_exc) + return _request_json(url, headers, None) + + +def _to_float(value: Any) -> Optional[float]: + if value is None: + return None + if isinstance(value, bool): + return None + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + raw = value.strip() + if not raw: + return None + try: + return float(raw) + except ValueError: + return None + return None + + +def _pick_number(payload: Dict[str, Any], keys: Tuple[str, ...]) -> Optional[float]: + lowered = {str(k).lower(): v for k, v in payload.items()} + for key in keys: + for actual_key, value in lowered.items(): + if key == actual_key or actual_key.endswith(f"_{key}") or actual_key.endswith(f".{key}"): + number = _to_float(value) + if number is not None: + return number + return None + + +def _try_parse_epoch(value: float) -> Optional[datetime]: + if value <= 0: + return None + # 同时兼容秒和毫秒 + if value > 10_000_000_000: + value = value / 1000.0 + try: + return datetime.fromtimestamp(value, tz=timezone.utc) + except Exception: + return None + + +def _normalize_datetime(value: Any) -> Optional[datetime]: + if value is None: + return None + if isinstance(value, datetime): + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + number = _to_float(value) + if number is not None: + return _try_parse_epoch(number) + if isinstance(value, str): + raw = value.strip() + if not raw: + return None + try: + if raw.endswith("Z"): + raw = raw[:-1] + "+00:00" + dt = datetime.fromisoformat(raw) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + except ValueError: + return None + return None + + +def _format_duration(seconds: Optional[float]) -> str: + if seconds is None: + return "-" + sec = max(int(seconds), 0) + if sec < 60: + return f"{sec}秒" + minutes, remain_seconds = divmod(sec, 60) + if minutes < 60: + return f"{minutes}分{remain_seconds}秒" + hours, remain_minutes = divmod(minutes, 60) + if hours < 24: + return f"{hours}小时{remain_minutes}分" + days, remain_hours = divmod(hours, 24) + return f"{days}天{remain_hours}小时" + + +def _detect_window_match(path: str, payload: Dict[str, Any], window: str) -> bool: + path_lower = path.lower() + if window == "hourly": + keywords = ("hourly", "hour", "per_hour", "5h", "5_hour") + else: + keywords = ("weekly", "week", "per_week", "7d", "7_day") + + if any(token in path_lower for token in keywords): + return True + + window_value = str(payload.get("window") or payload.get("period") or payload.get("scope") or "").lower() + return any(token == window_value or token in window_value for token in keywords) + + +def _extract_quota_from_rate_limit_window(window_payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: + if not isinstance(window_payload, dict): + return None + + used_percent = _to_float(window_payload.get("used_percent")) + remaining_percent = _to_float(window_payload.get("remaining_percent")) + # 兼容比例值(0~1)与百分比值(0~100) + if used_percent is not None and 0 <= used_percent <= 1: + used_percent *= 100.0 + if remaining_percent is not None and 0 <= remaining_percent <= 1: + remaining_percent *= 100.0 + if remaining_percent is None and used_percent is not None: + remaining_percent = 100.0 - used_percent + if remaining_percent is not None: + remaining_percent = max(0.0, min(100.0, remaining_percent)) + + total = ( + _to_float(window_payload.get("total")) + or _to_float(window_payload.get("limit")) + or _to_float(window_payload.get("max")) + or _to_float(window_payload.get("capacity")) + ) + used = _to_float(window_payload.get("used")) + remaining = _to_float(window_payload.get("remaining")) + limit_window_seconds = _to_float( + window_payload.get("limit_window_seconds") + or window_payload.get("window_seconds") + ) + if limit_window_seconds is not None and limit_window_seconds <= 0: + limit_window_seconds = None + + if total is not None and remaining is None and remaining_percent is not None: + remaining = total * (remaining_percent / 100.0) + if total is not None and used is None and remaining is not None: + used = max(total - remaining, 0.0) + if total is not None and remaining is None and used is not None: + remaining = max(total - used, 0.0) + + reset_at = _normalize_datetime( + window_payload.get("resets_at") + or window_payload.get("reset_at") + or window_payload.get("next_reset_at") + or window_payload.get("next_reset") + ) + reset_in_seconds = _to_float( + window_payload.get("resets_in_seconds") + or window_payload.get("remaining_seconds") + or window_payload.get("seconds_to_reset") + or window_payload.get("reset_in") + ) + if reset_in_seconds is None and reset_at: + reset_in_seconds = max((reset_at - datetime.now(timezone.utc)).total_seconds(), 0.0) + if reset_at is None and reset_in_seconds is not None: + reset_at = datetime.now(timezone.utc).replace(microsecond=0) + timedelta(seconds=int(reset_in_seconds)) + + if all(v is None for v in (total, used, remaining, remaining_percent, reset_at, reset_in_seconds)): + return None + + if remaining_percent is None and total and total > 0 and remaining is not None: + remaining_percent = max(0.0, min(100.0, (remaining / total) * 100.0)) + + reset_in_text = _format_duration(reset_in_seconds) + if reset_in_seconds is None and reset_at: + reset_in_text = _format_duration((reset_at - datetime.now(timezone.utc)).total_seconds()) + + return { + "used": int(used) if used is not None else None, + "total": int(total) if total is not None else None, + "remaining": int(remaining) if remaining is not None else None, + "percentage": round(float(remaining_percent), 2) if remaining_percent is not None else None, + "reset_at": reset_at.isoformat() if reset_at else None, + "reset_in_text": reset_in_text, + "window_seconds": int(limit_window_seconds) if limit_window_seconds is not None else None, + "status": "ok", + } + + +def _infer_rate_limit_window_type(window_payload: Dict[str, Any], window_key: str) -> Tuple[str, bool]: + """ + 推断 rate_limit 窗口类型。 + 返回: (window_type, confident) + """ + seconds = _to_float(window_payload.get("limit_window_seconds") or window_payload.get("window_seconds")) + if seconds is not None and seconds > 0: + if seconds >= _WEEKLY_WINDOW_MIN_SECONDS: + return "weekly", True + if seconds <= _HOURLY_WINDOW_MAX_SECONDS: + return "hourly", True + return ("hourly", False) if window_key == "primary_window" else ("weekly", False) + + +def _select_rate_limit_window(rate_limit: Dict[str, Any], target_window: str) -> Optional[Tuple[str, Dict[str, Any]]]: + entries: List[Tuple[str, Dict[str, Any], str, bool]] = [] + for key in ("primary_window", "secondary_window"): + raw = rate_limit.get(key) + if not isinstance(raw, dict): + continue + inferred, confident = _infer_rate_limit_window_type(raw, key) + entries.append((key, raw, inferred, confident)) + + if not entries: + return None + + # 1) 优先使用“基于窗口时长”的高置信匹配(避免把 7d 窗口误判成 5h)。 + for key, raw, inferred, confident in entries: + if confident and inferred == target_window: + return key, raw + + # 2) 再使用普通匹配(无时长信息时退化到 key 语义)。 + for key, raw, inferred, _ in entries: + if inferred == target_window: + return key, raw + + # 3) 保留历史 key 语义兜底(兼容单窗口账号场景,避免 5 小时或周配额出现 --)。 + fallback_key = "primary_window" if target_window == "hourly" else "secondary_window" + for key, raw, _, _ in entries: + if key == fallback_key: + return key, raw + return None + + +def _iter_rate_limit_candidates(payload: Dict[str, Any]) -> List[Tuple[str, Dict[str, Any]]]: + """ + 从 payload 中提取可能的 rate_limit 容器。 + """ + if not isinstance(payload, dict): + return [] + candidates: List[Tuple[str, Dict[str, Any]]] = [] + + direct = payload.get("rate_limit") + if isinstance(direct, dict): + candidates.append(("rate_limit", direct)) + + for parent_key in ("usage", "data", "quota", "limits", "codex"): + parent = payload.get(parent_key) + if not isinstance(parent, dict): + continue + nested = parent.get("rate_limit") + if isinstance(nested, dict): + candidates.append((f"{parent_key}.rate_limit", nested)) + + return candidates + + +def _extract_quota_from_rate_limit(window: str, payloads: Dict[str, Dict[str, Any]]) -> Optional[Dict[str, Any]]: + """ + 对齐 cockpit:优先 wham/usage.rate_limit;缺失时使用 codex/usage.rate_limit 兜底。 + """ + for source_name in ("wham_usage", "codex_usage"): + payload = payloads.get(source_name) or {} + if not isinstance(payload, dict): + continue + + for source_path, rate_limit in _iter_rate_limit_candidates(payload): + selected = _select_rate_limit_window(rate_limit, window) + if not selected: + continue + selected_key, selected_payload = selected + parsed = _extract_quota_from_rate_limit_window(selected_payload) + if parsed: + parsed["source"] = f"{source_name}.{source_path}.{selected_key}" + return parsed + + # 兼容某些接口直接把 primary/secondary_window 放在顶层 + top_level_rate_limit = { + "primary_window": payload.get("primary_window"), + "secondary_window": payload.get("secondary_window"), + } + selected_direct = _select_rate_limit_window(top_level_rate_limit, window) + if not selected_direct: + continue + selected_key, selected_payload = selected_direct + parsed_direct = _extract_quota_from_rate_limit_window(selected_payload) + if parsed_direct: + parsed_direct["source"] = f"{source_name}.{selected_key}" + return parsed_direct + return None + + +def _extract_code_review_quota(payloads: Dict[str, Dict[str, Any]]) -> Dict[str, Any]: + """ + Code Review 配额:优先读取 wham/usage.code_review_rate_limit 的 primary/secondary_window。 + """ + payload = payloads.get("wham_usage") or {} + if not isinstance(payload, dict): + return { + "used": None, + "total": None, + "remaining": None, + "percentage": None, + "reset_at": None, + "reset_in_text": "-", + "status": "unknown", + } + + review_rate_limit = payload.get("code_review_rate_limit") + if isinstance(review_rate_limit, dict): + for window_key in ("primary_window", "secondary_window"): + parsed = _extract_quota_from_rate_limit_window(review_rate_limit.get(window_key) or {}) + if parsed: + return parsed + + return { + "used": None, + "total": None, + "remaining": None, + "percentage": None, + "reset_at": None, + "reset_in_text": "-", + "status": "unknown", + } + + +def _extract_quota_candidate(payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: + used = _pick_number(payload, _NUMERIC_KEYS_USED) + total = _pick_number(payload, _NUMERIC_KEYS_TOTAL) + remaining = _pick_number(payload, _NUMERIC_KEYS_REMAINING) + percentage = _pick_number(payload, _NUMERIC_KEYS_PERCENT) + + reset_at: Optional[datetime] = None + for key in _RESET_AT_KEYS: + if key in payload: + reset_at = _normalize_datetime(payload.get(key)) + if reset_at: + break + + reset_in_seconds = None + for key in _RESET_IN_KEYS: + if key in payload: + reset_in_seconds = _to_float(payload.get(key)) + if reset_in_seconds is not None: + break + + if remaining is None and total is not None and used is not None: + remaining = max(total - used, 0) + if used is None and total is not None and remaining is not None: + used = max(total - remaining, 0) + + if percentage is not None: + # 兼容 0~1 的比例值 + if 0 <= percentage <= 1: + percentage *= 100 + elif total and total > 0 and remaining is not None: + percentage = (remaining / total) * 100 + + if reset_at is None and reset_in_seconds is not None: + reset_at = datetime.now(timezone.utc).replace(microsecond=0) + timedelta(seconds=int(reset_in_seconds)) + + if all(v is None for v in (used, total, remaining, percentage, reset_at, reset_in_seconds)): + return None + + percentage = max(0, min(float(percentage or 0), 100)) + reset_in_text = _format_duration(reset_in_seconds) + if reset_in_seconds is None and reset_at: + delta = (reset_at - datetime.now(timezone.utc)).total_seconds() + reset_in_text = _format_duration(delta) + + return { + "used": int(used) if used is not None else None, + "total": int(total) if total is not None else None, + "remaining": int(remaining) if remaining is not None else None, + "percentage": round(percentage, 2) if percentage is not None else None, + "reset_at": reset_at.isoformat() if reset_at else None, + "reset_in_text": reset_in_text, + "status": "ok", + } + + +def _walk_candidates(payload: Any, window: str, prefix: str = "") -> List[Dict[str, Any]]: + candidates: List[Dict[str, Any]] = [] + if isinstance(payload, dict): + if _detect_window_match(prefix, payload, window): + parsed = _extract_quota_candidate(payload) + if parsed: + candidates.append(parsed) + for key, value in payload.items(): + child_prefix = f"{prefix}.{key}" if prefix else str(key) + candidates.extend(_walk_candidates(value, window, child_prefix)) + elif isinstance(payload, list): + for idx, item in enumerate(payload): + child_prefix = f"{prefix}[{idx}]" + candidates.extend(_walk_candidates(item, window, child_prefix)) + return candidates + + +def _extract_quota(window: str, payloads: Dict[str, Dict[str, Any]]) -> Dict[str, Any]: + # 先按 cockpit-tools 的核心结构解析:rate_limit.primary/secondary_window.used_percent + # primary=5小时窗口,secondary=周窗口。 + strict = _extract_quota_from_rate_limit(window, payloads) + if strict: + return strict + + return { + "used": None, + "total": None, + "remaining": None, + "percentage": None, + "reset_at": None, + "reset_in_text": "-", + "status": "unknown", + } + + +def _normalize_plan(raw_plan: Optional[str]) -> str: + plan = (raw_plan or "").strip().lower() + if not plan: + return "Basic" + if "enterprise" in plan or "team" in plan: + return "Team" + if "plus" in plan: + return "Plus" + if "pro" in plan: + return "Pro" + if "free" in plan or "basic" in plan: + return "Basic" + return plan.capitalize() + + +def _extract_plan_string_candidates(me_payload: Dict[str, Any]) -> List[str]: + """ + 提取常见 plan 字段的字符串候选值。 + """ + candidates: List[str] = [] + + def _append(value: Any): + if isinstance(value, str): + text = value.strip() + if text: + candidates.append(text) + + _append(me_payload.get("plan_type")) + _append(me_payload.get("plan")) + _append(me_payload.get("subscription_plan")) + _append(me_payload.get("account_plan")) + _append(me_payload.get("subscription_tier")) + _append(me_payload.get("chatgpt_plan_type")) + _append(me_payload.get("tier")) + _append(me_payload.get("planType")) + + account_block = me_payload.get("account") + if isinstance(account_block, dict): + _append(account_block.get("plan_type")) + _append(account_block.get("plan")) + _append(account_block.get("subscription_plan")) + _append(account_block.get("subscription_tier")) + _append(account_block.get("chatgpt_plan_type")) + + subscription_block = me_payload.get("subscription") + if isinstance(subscription_block, dict): + _append(subscription_block.get("plan_type")) + _append(subscription_block.get("plan")) + _append(subscription_block.get("product")) + _append(subscription_block.get("tier")) + + return candidates + + +def _detect_plan_from_payload(payload: Dict[str, Any], source_name: str) -> Optional[Tuple[str, str]]: + if not isinstance(payload, dict) or not payload: + return None + for raw in _extract_plan_string_candidates(payload): + normalized = _normalize_plan(raw) + if normalized in ("Team", "Plus", "Pro", "Basic"): + return normalized, f"{source_name}.plan" + return None + + +def _detect_plan(account: Account, payloads: Dict[str, Dict[str, Any]]) -> Tuple[str, str]: + me = payloads.get("me") or {} + if isinstance(me, dict) and me: + # 1) 直接 plan 字段优先 + for raw in _extract_plan_string_candidates(me): + normalized = _normalize_plan(raw) + if normalized in ("Team", "Plus", "Pro"): + return normalized, "me.plan" + if normalized == "Basic" and raw.strip(): + # 明确返回 free/basic 也算有效信号 + return normalized, "me.plan" + + # 2) org/workspace 信息判断 Team + orgs = [] + org_block = me.get("orgs") + if isinstance(org_block, dict): + orgs = org_block.get("data") or [] + if isinstance(orgs, list): + for org in orgs: + if not isinstance(org, dict): + continue + settings_ = org.get("settings") + if isinstance(settings_, dict): + workspace_plan_type = str(settings_.get("workspace_plan_type") or "").strip().lower() + if workspace_plan_type in ("team", "enterprise"): + return "Team", "me.org.workspace_plan_type" + org_plan = _normalize_plan(str(org.get("plan_type") or org.get("plan") or "")) + if org_plan in ("Team", "Plus", "Pro"): + return org_plan, "me.org.plan" + + # 3) 订阅布尔信号(无法区分 Team/Plus 时按 Plus 处理) + bool_markers = ( + me.get("has_paid_subscription"), + me.get("has_active_subscription"), + me.get("is_paid"), + me.get("is_subscribed"), + ) + if any(v is True for v in bool_markers): + return "Plus", "me.subscription_flag" + + # Cockpit-tools 同款核心信号:wham/usage 的 plan_type + for source_name in ("wham_usage", "codex_usage"): + detected = _detect_plan_from_payload(payloads.get(source_name) or {}, source_name) + if detected: + return detected + + # JWT claim 兜底(不依赖刷新 token) + for source_name, token in ( + ("id_token.chatgpt_plan_type", account.id_token), + ("access_token.chatgpt_plan_type", account.access_token), + ): + normalized = _extract_chatgpt_plan_from_jwt(token) + if normalized in ("Team", "Plus", "Pro", "Basic"): + return normalized, source_name + + # fallback 到数据库订阅字段 + if account.subscription_type: + return _normalize_plan(account.subscription_type), "db.subscription_type" + return "Basic", "default" + + +def fetch_codex_overview(account: Account, proxy: Optional[str] = None) -> Dict[str, Any]: + """ + 从 OpenAI 接口抓取 Codex 账号概览(计划类型 + 配额) + """ + if not account.access_token: + raise ValueError("账号缺少 access_token") + + headers = _build_headers(account) + payloads: Dict[str, Dict[str, Any]] = {} + errors: List[str] = [] + + for source_name, url, required in _USAGE_ENDPOINTS: + try: + payloads[source_name] = _request_json_with_proxy_fallback(url, headers, proxy) + except Exception as exc: + status = _extract_http_status(exc) + # 可选端点在 401/403/404 场景静默降级,避免影响总览刷新日志可读性。 + if not required and status in (401, 403, 404): + logger.debug("概览可选端点降级跳过: source=%s status=%s", source_name, status) + continue + errors.append(f"{source_name}: {exc}") + + if not payloads: + raise RuntimeError("所有概览接口请求失败") + + plan_type, plan_source = _detect_plan(account, payloads) + hourly_quota = _extract_quota("hourly", payloads) + weekly_quota = _extract_quota("weekly", payloads) + code_review_quota = _extract_code_review_quota(payloads) + + return { + "plan_type": plan_type, + "plan_source": plan_source, + "hourly_quota": hourly_quota, + "weekly_quota": weekly_quota, + "code_review_quota": code_review_quota, + "sources": list(payloads.keys()), + "fetched_at": datetime.now(timezone.utc).isoformat(), + "errors": errors[:3], + } diff --git a/src/core/openai/payment.py b/src/core/openai/payment.py new file mode 100644 index 00000000..5abfeda4 --- /dev/null +++ b/src/core/openai/payment.py @@ -0,0 +1,1249 @@ +""" +支付核心逻辑 — 生成 Plus/Team 支付链接、无痕打开浏览器、检测订阅状态 +""" + +import logging +import base64 +import json +import re +import subprocess +import sys +import uuid +from typing import Any, Dict, List, Optional +from urllib.parse import urlencode, urljoin, unquote + +from curl_cffi import requests as cffi_requests + +from ...database.models import Account +from .overview import fetch_codex_overview + +logger = logging.getLogger(__name__) + +PAYMENT_CHECKOUT_URL = "https://chatgpt.com/backend-api/payments/checkout" +ACCOUNT_CHECK_URL = "https://chatgpt.com/backend-api/wham/accounts/check" +TEAM_CHECKOUT_BASE_URL = "https://chatgpt.com/checkout/openai_llc/" +AIMIZY_PAY_URL = "https://team.aimizy.com/pay" +SENTINEL_REQ_URL = "https://sentinel.openai.com/backend-api/sentinel/req" +CHECKOUT_LINK_REGEX = re.compile(r"https://chatgpt\.com/checkout/openai_llc/cs_[A-Za-z0-9_-]+", re.IGNORECASE) +CHECKOUT_SESSION_REGEX = re.compile(r"\bcs_[A-Za-z0-9_-]+\b", re.IGNORECASE) +PUBLISHABLE_KEY_REGEX = re.compile(r"\bpk_(?:live|test)_[A-Za-z0-9]+\b", re.IGNORECASE) +_CONNECTIVITY_ERROR_KEYWORDS = ( + "failed to connect", + "could not connect to server", + "connection refused", + "timed out", + "timeout", + "temporary failure in name resolution", + "name or service not known", + "proxy connect", + "network is unreachable", + "curl: (7)", + "curl: (28)", + "curl: (35)", + "curl: (56)", +) + + +def _build_proxies(proxy: Optional[str]) -> Optional[dict]: + if proxy: + return {"http": proxy, "https": proxy} + return None + + +def _is_connectivity_error(err: Any) -> bool: + text = str(err or "").strip().lower() + if not text: + return False + return any(token in text for token in _CONNECTIVITY_ERROR_KEYWORDS) + + +def _extract_link_from_payload(data) -> Optional[str]: + """从任意结构中提取 URL 字段。""" + if isinstance(data, str): + text = data.strip() + if text.startswith("/checkout/openai_llc/"): + return f"https://chatgpt.com{text}" + if text.startswith("http://") or text.startswith("https://"): + return text + normalized = _extract_checkout_link_from_text(text) + if normalized: + return normalized + return None + + if isinstance(data, dict): + for key in ( + "checkout_url", + "checkoutUrl", + "redirect_url", + "redirectUrl", + "checkout_link", + "checkoutLink", + "short_url", + "shortUrl", + "short_link", + "shortLink", + "pay_url", + "payUrl", + "link", + "url", + ): + value = data.get(key) + if isinstance(value, str): + raw = value.strip() + if raw.startswith("/checkout/openai_llc/"): + return f"https://chatgpt.com{raw}" + if raw.startswith(("http://", "https://")): + return raw + normalized = _extract_checkout_link_from_text(raw) + if normalized: + return normalized + + for value in data.values(): + url = _extract_link_from_payload(value) + if url: + return url + + if isinstance(data, list): + for item in data: + url = _extract_link_from_payload(item) + if url: + return url + + return None + + +def _build_checkout_link(session_id: str) -> str: + return TEAM_CHECKOUT_BASE_URL + session_id + + +def _extract_checkout_session_id(text: str) -> Optional[str]: + if not text: + return None + match = CHECKOUT_SESSION_REGEX.search(text) + if match: + return match.group(0) + return None + + +def _extract_publishable_key(text: str) -> Optional[str]: + if not text: + return None + match = PUBLISHABLE_KEY_REGEX.search(text) + if match: + return match.group(0) + return None + + +def _extract_first_string_by_keys(data, keys: tuple[str, ...]) -> Optional[str]: + if isinstance(data, dict): + for key in keys: + value = data.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + for value in data.values(): + nested = _extract_first_string_by_keys(value, keys) + if nested: + return nested + return None + if isinstance(data, list): + for item in data: + nested = _extract_first_string_by_keys(item, keys) + if nested: + return nested + return None + + +def _extract_publishable_key_from_payload(data) -> Optional[str]: + direct = _extract_first_string_by_keys( + data, + ( + "publishable_key", + "publishableKey", + "stripe_publishable_key", + "stripePublishableKey", + "pk", + ), + ) + if direct: + parsed = _extract_publishable_key(direct) + return parsed or direct + + text = str(data or "") + return _extract_publishable_key(text) + + +def _build_checkout_bundle_from_payload(data, proxy: Optional[str] = None) -> Dict[str, Optional[str]]: + checkout_url = _extract_checkout_link_from_payload(data, proxy=proxy) + + session_id = _extract_first_string_by_keys( + data, + ("checkout_session_id", "checkoutSessionId", "session_id", "sessionId", "id"), + ) + if session_id and not str(session_id).startswith("cs_"): + session_id = _extract_checkout_session_id(str(session_id)) + elif session_id: + session_id = str(session_id).strip() + + if not session_id: + session_id = _extract_checkout_session_id(str(checkout_url or "")) or _extract_checkout_session_id(str(data or "")) + + publishable_key = _extract_publishable_key_from_payload(data) + client_secret = _extract_first_string_by_keys( + data, + ("client_secret", "clientSecret", "stripe_client_secret", "stripeClientSecret"), + ) + + return { + "checkout_url": checkout_url, + "checkout_session_id": session_id, + "publishable_key": publishable_key, + "client_secret": client_secret, + } + + +def _is_official_checkout_link(link: Optional[str]) -> bool: + return isinstance(link, str) and CHECKOUT_LINK_REGEX.search(link.strip()) is not None + + +def _contains_sensitive_token_in_url(link: Optional[str]) -> bool: + if not isinstance(link, str): + return False + lower = link.lower() + return ( + "access_token=" in lower + or "accesstoken=" in lower + or "?token=" in lower + or "&token=" in lower + ) + + +def _extract_checkout_link_from_text(text: str) -> Optional[str]: + if not text: + return None + raw = str(text).strip() + if not raw: + return None + + if raw.startswith("/checkout/openai_llc/"): + return f"https://chatgpt.com{raw}" + + direct = CHECKOUT_LINK_REGEX.search(raw) + if direct: + return direct.group(0) + + decoded = unquote(raw) + if decoded != raw: + direct_decoded = CHECKOUT_LINK_REGEX.search(decoded) + if direct_decoded: + return direct_decoded.group(0) + if decoded.startswith("/checkout/openai_llc/"): + return f"https://chatgpt.com{decoded}" + + session_id = _extract_checkout_session_id(raw) or _extract_checkout_session_id(decoded) + if session_id: + return _build_checkout_link(session_id) + + return None + + +def _normalize_checkout_link(link: Optional[str], proxy: Optional[str] = None) -> Optional[str]: + """ + 将第三方返回的支付链接尽量归一化为 chatgpt 官方 checkout 链接。 + """ + if not isinstance(link, str): + return None + current = link.strip() + if not current: + return None + + normalized = _extract_checkout_link_from_text(current) + if normalized: + return normalized + + proxies = _build_proxies(proxy) + # 先尝试自动重定向,部分短链依赖多跳 30x 才能落到 checkout。 + try: + resp_follow = cffi_requests.get( + current, + proxies=proxies, + timeout=25, + impersonate="chrome110", + allow_redirects=True, + ) + for candidate in ( + str(getattr(resp_follow, "url", "") or ""), + resp_follow.headers.get("Location"), + resp_follow.headers.get("location"), + resp_follow.text or "", + ): + maybe = _extract_checkout_link_from_text(candidate) + if maybe: + return maybe + except Exception: + pass + + for _ in range(5): + try: + resp = cffi_requests.get( + current, + proxies=proxies, + timeout=20, + impersonate="chrome110", + allow_redirects=False, + ) + except Exception: + break + + location = resp.headers.get("Location") or resp.headers.get("location") + if isinstance(location, str) and location.strip(): + next_url = urljoin(current, location.strip()) + normalized_next = _extract_checkout_link_from_text(next_url) + if normalized_next: + return normalized_next + current = next_url + continue + + body = resp.text or "" + normalized_body = _extract_checkout_link_from_text(body) + if normalized_body: + return normalized_body + break + + return link + + +def _extract_checkout_link_from_payload(data, proxy: Optional[str] = None) -> Optional[str]: + """ + 从响应体中提取可直达 chatgpt checkout 的链接。 + """ + if data is None: + return None + + url = _extract_link_from_payload(data) + if url: + return _normalize_checkout_link(url, proxy=proxy) + + if isinstance(data, dict): + for key in ("checkout_session_id", "session_id", "id"): + value = data.get(key) + if isinstance(value, str) and value.startswith("cs_"): + return _build_checkout_link(value.strip()) + if isinstance(value, dict): + nested_id = value.get("id") + if isinstance(nested_id, str) and nested_id.startswith("cs_"): + return _build_checkout_link(nested_id.strip()) + + for value in data.values(): + nested = _extract_checkout_link_from_payload(value, proxy=proxy) + if nested: + return nested + + if isinstance(data, list): + for item in data: + nested = _extract_checkout_link_from_payload(item, proxy=proxy) + if nested: + return nested + + text = str(data) + direct = CHECKOUT_LINK_REGEX.search(text) + if direct: + return direct.group(0) + session_id = _extract_checkout_session_id(text) + if session_id: + return _build_checkout_link(session_id) + return None + + +def generate_aimizy_payment_link( + account: Account, + plan_type: str = "plus", + proxy: Optional[str] = None, + country: str = "US", + currency: str = "USD", +) -> str: + """ + 基于 Access Token 生成 aimizy 站内代付短链。 + 先尝试常见 API 形态,失败后回退到可直接打开的 pay URL。 + """ + if not account.access_token: + raise ValueError("账号缺少 access_token") + + token = account.access_token.strip() + if not token: + raise ValueError("账号 access_token 为空") + + payload = { + "access_token": token, + "accessToken": token, + "token": token, + "bearer": f"Bearer {token}", + "plan_type": plan_type, + "plan": plan_type, + "country": country, + "currency": currency, + "region": country, + } + proxies = _build_proxies(proxy) + common_headers = { + "Accept": "application/json,text/plain,*/*", + "Content-Type": "application/json", + "Authorization": f"Bearer {token}", + } + + attempts = [ + ("POST", AIMIZY_PAY_URL, {"json": payload, "headers": common_headers}), + ("POST", AIMIZY_PAY_URL, {"data": payload, "headers": {"Accept": "application/json,text/plain,*/*"}}), + ("POST", f"{AIMIZY_PAY_URL}/api/generate", {"json": payload, "headers": common_headers}), + ("POST", f"{AIMIZY_PAY_URL}/api/create", {"json": payload, "headers": common_headers}), + ("POST", f"{AIMIZY_PAY_URL}/api/pay", {"json": payload, "headers": common_headers}), + ("POST", f"{AIMIZY_PAY_URL}/api/payment-link", {"json": payload, "headers": common_headers}), + ("GET", AIMIZY_PAY_URL, {"params": payload, "headers": {"Accept": "application/json,text/plain,*/*"}}), + ("GET", f"{AIMIZY_PAY_URL}/api/generate", {"params": payload, "headers": {"Accept": "application/json,text/plain,*/*"}}), + ] + + for method, url, kwargs in attempts: + try: + resp = cffi_requests.request( + method, + url, + proxies=proxies, + timeout=20, + impersonate="chrome110", + **kwargs, + ) + + # 某些服务会返回 302 + Location + location = resp.headers.get("Location") or resp.headers.get("location") + if isinstance(location, str) and location.startswith(("http://", "https://")): + candidate = _normalize_checkout_link(location, proxy=proxy) + if candidate and _is_official_checkout_link(candidate): + return candidate + + if resp.status_code >= 400: + logger.debug(f"aimizy 链接生成尝试失败: {method} {url} -> HTTP {resp.status_code}") + continue + + content_type = (resp.headers.get("content-type") or "").lower() + if "application/json" in content_type: + try: + data = resp.json() + except Exception: + data = None + link = _extract_checkout_link_from_payload(data, proxy=proxy) or _extract_link_from_payload(data) + if link: + candidate = _normalize_checkout_link(link, proxy=proxy) + if candidate and _is_official_checkout_link(candidate): + return candidate + + text = (resp.text or "").strip() + if text.startswith(("http://", "https://")): + candidate = _normalize_checkout_link(text, proxy=proxy) + if candidate and _is_official_checkout_link(candidate): + return candidate + + # 尝试从非 JSON 文本中找 URL + match = re.search(r"https?://[^\s\"'<>]+", text) + if match: + candidate = _normalize_checkout_link(match.group(0), proxy=proxy) + if candidate and _is_official_checkout_link(candidate): + return candidate + + # 再兜底:直接扫描 cs_xxx 并拼接 checkout 链接 + parsed_from_text = _extract_checkout_link_from_text(text) + if parsed_from_text: + return parsed_from_text + + except Exception as e: + logger.debug(f"aimizy 链接生成尝试异常: {method} {url} -> {e}") + + # 最终回退:构造可直接进入站内代付页的链接 + query = urlencode( + { + "access_token": token, + "accessToken": token, + "plan_type": plan_type, + "country": country, + "currency": currency, + } + ) + fallback_url = f"{AIMIZY_PAY_URL}?{query}" + fallback_candidate = _normalize_checkout_link(fallback_url, proxy=proxy) + if _is_official_checkout_link(fallback_candidate): + return fallback_candidate + raise ValueError("未能从代付通道解析到官方 checkout 链接,请先确认账号 token 有效") + + +_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 _resolve_chatgpt_account_id(account: Account) -> Optional[str]: + # 优先从当前 token 解析,避免数据库里旧 account_id 导致订阅误判。 + token_account_id = ( + _extract_chatgpt_account_id_from_jwt(account.access_token) + or _extract_chatgpt_account_id_from_jwt(account.id_token) + ) + for candidate in (token_account_id, account.account_id, account.workspace_id): + value = str(candidate or "").strip() + if value: + return value + return None + + +def _decode_jwt_payload(token: Optional[str]) -> Optional[Dict[str, Any]]: + text = str(token or "").strip() + if not text or "." not in text: + return None + parts = text.split(".") + if len(parts) < 2: + return None + payload_part = parts[1] + if not payload_part: + return None + padding = "=" * (-len(payload_part) % 4) + try: + raw = base64.urlsafe_b64decode(payload_part + padding) + data = json.loads(raw.decode("utf-8")) + return data if isinstance(data, dict) else None + except Exception: + return None + + +def _extract_auth_claim(payload: Optional[Dict[str, Any]]) -> Dict[str, Any]: + if not isinstance(payload, dict): + return {} + auth = payload.get("https://api.openai.com/auth") + if isinstance(auth, dict): + return auth + auth = payload.get("auth_data") + if isinstance(auth, dict): + return auth + return {} + + +def _extract_chatgpt_account_id_from_jwt(token: Optional[str]) -> Optional[str]: + payload = _decode_jwt_payload(token) + if not payload: + return None + auth = _extract_auth_claim(payload) + for key in ( + "chatgpt_account_id", + "account_id", + "workspace_id", + ): + value = str(auth.get(key) or payload.get(key) or "").strip() + if value: + return value + return None + + +def _extract_chatgpt_plan_from_jwt(token: Optional[str]) -> Optional[str]: + payload = _decode_jwt_payload(token) + if not payload: + return None + auth = _extract_auth_claim(payload) + candidates = [ + auth.get("chatgpt_plan_type"), + auth.get("plan_type"), + auth.get("subscription_plan"), + payload.get("chatgpt_plan_type"), + payload.get("plan_type"), + payload.get("subscription_plan"), + payload.get("subscription_tier"), + ] + for item in candidates: + mapped = _map_plan_to_subscription(item if isinstance(item, str) else None) + if mapped in ("plus", "team", "free"): + return mapped + return None + + +def _collect_plan_candidates(value: Any) -> List[str]: + candidates: List[str] = [] + if isinstance(value, dict): + for key, item in value.items(): + key_lower = str(key).strip().lower() + if key_lower in { + "plan_type", + "plan", + "subscription_plan", + "subscription_tier", + "chatgpt_plan_type", + "tier", + "workspace_plan_type", + "product", + } and isinstance(item, str): + text = item.strip() + if text: + candidates.append(text) + if isinstance(item, (dict, list)): + candidates.extend(_collect_plan_candidates(item)) + elif isinstance(value, list): + for item in value: + candidates.extend(_collect_plan_candidates(item)) + return candidates + + +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 _resolve_oai_device_id(account: Account) -> str: + if account.cookies: + oai_did = _extract_oai_did(account.cookies) + if oai_did: + return oai_did + return str(uuid.uuid4()) + + +def _build_openai_sentinel_token( + account: Account, + device_id: str, + proxy: Optional[str] = None, +) -> Optional[str]: + """ + 生成 openai-sentinel-token(失败时返回 None,不阻断主流程)。 + """ + if not device_id: + return None + + body = json.dumps({"p": "", "id": device_id, "flow": "authorize_continue"}) + 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", + "Accept": "application/json", + "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" + ), + } + if account.cookies: + headers["cookie"] = account.cookies + + try: + resp = cffi_requests.post( + SENTINEL_REQ_URL, + headers=headers, + data=body, + proxies=_build_proxies(proxy), + timeout=20, + impersonate="chrome110", + ) + if resp.status_code != 200: + logger.debug(f"sentinel token 获取失败: HTTP {resp.status_code}") + return None + data = resp.json() if resp.content else {} + token = str(data.get("token") or "").strip() + if not token: + logger.debug("sentinel token 为空") + return None + return json.dumps( + { + "p": "", + "t": "", + "c": token, + "id": device_id, + "flow": "authorize_continue", + } + ) + except Exception as exc: + logger.debug(f"sentinel token 获取异常: {exc}") + 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 _build_checkout_request_headers(account: Account, proxy: Optional[str]) -> Dict[str, str]: + if not account.access_token: + raise ValueError("账号缺少 access_token") + + device_id = _resolve_oai_device_id(account) + headers = { + "Authorization": f"Bearer {account.access_token}", + "Content-Type": "application/json", + "Accept": "application/json", + "Origin": "https://chatgpt.com", + "Referer": "https://chatgpt.com/", + "oai-language": "zh-CN", + "oai-device-id": device_id, + "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" + ), + } + if account.cookies: + headers["cookie"] = account.cookies + chatgpt_account_id = _resolve_chatgpt_account_id(account) + if chatgpt_account_id: + headers["ChatGPT-Account-Id"] = chatgpt_account_id + + sentinel_token = _build_openai_sentinel_token(account, device_id=device_id, proxy=proxy) + if sentinel_token: + headers["openai-sentinel-token"] = sentinel_token + + return headers + + +def _request_checkout_bundle(account: Account, payload: Dict[str, Any], proxy: Optional[str] = None) -> Dict[str, Optional[str]]: + headers = _build_checkout_request_headers(account, proxy=proxy) + proxy_candidates: List[Optional[str]] = [proxy] + if proxy: + proxy_candidates.append(None) + + last_err: Optional[Exception] = None + for idx, candidate_proxy in enumerate(proxy_candidates): + try: + resp = cffi_requests.post( + PAYMENT_CHECKOUT_URL, + headers=headers, + json=payload, + proxies=_build_proxies(candidate_proxy), + timeout=30, + impersonate="chrome110", + ) + resp.raise_for_status() + data = resp.json() + bundle = _build_checkout_bundle_from_payload(data, proxy=candidate_proxy) + if bundle.get("checkout_url"): + if idx > 0: + logger.warning( + "官方 checkout 代理请求失败后直连成功: account_id=%s email=%s", + account.id, + account.email, + ) + return bundle + raise ValueError(data.get("detail", "API 未返回可用 checkout 链接")) + except Exception as exc: + last_err = exc + should_retry_direct = ( + idx == 0 + and proxy is not None + and candidate_proxy is not None + and _is_connectivity_error(exc) + ) + if should_retry_direct: + logger.warning( + "官方 checkout 代理请求网络异常,改为直连重试: account_id=%s email=%s error=%s", + account.id, + account.email, + exc, + ) + continue + raise + + if last_err: + raise last_err + raise RuntimeError("官方 checkout 请求失败") + + +def generate_plus_checkout_bundle( + account: Account, + proxy: Optional[str] = None, + country: str = "US", +) -> Dict[str, Optional[str]]: + """生成 Plus checkout 全量信息(含 checkout_session_id/publishable_key)。""" + currency = _COUNTRY_CURRENCY_MAP.get(country, "USD") + 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", + } + return _request_checkout_bundle(account=account, payload=payload, proxy=proxy) + + +def generate_team_checkout_bundle( + account: Account, + workspace_name: str = "MyTeam", + price_interval: str = "month", + seat_quantity: int = 5, + proxy: Optional[str] = None, + country: str = "US", +) -> Dict[str, Optional[str]]: + """生成 Team checkout 全量信息(含 checkout_session_id/publishable_key)。""" + currency = _COUNTRY_CURRENCY_MAP.get(country, "USD") + 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}, + "cancel_url": "https://chatgpt.com/?promo_campaign=team-1-month-free#team-pricing", + "promo_campaign": { + "promo_campaign_id": "team-1-month-free", + "is_coupon_from_query_param": False, + }, + "checkout_ui_mode": "custom", + } + return _request_checkout_bundle(account=account, payload=payload, proxy=proxy) + + +def generate_plus_link( + account: Account, + proxy: Optional[str] = None, + country: str = "US", +) -> str: + bundle = generate_plus_checkout_bundle(account=account, proxy=proxy, country=country) + return str(bundle.get("checkout_url") or "") + + +def generate_team_link( + account: Account, + workspace_name: str = "MyTeam", + price_interval: str = "month", + seat_quantity: int = 5, + proxy: Optional[str] = None, + country: str = "US", +) -> str: + bundle = generate_team_checkout_bundle( + account=account, + workspace_name=workspace_name, + price_interval=price_interval, + seat_quantity=seat_quantity, + proxy=proxy, + country=country, + ) + return str(bundle.get("checkout_url") or "") + + +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 _map_plan_to_subscription(plan: Optional[str]) -> Optional[str]: + text = (plan or "").strip().lower() + if not text: + return None + if "team" in text or "enterprise" in text: + return "team" + if "plus" in text or "pro" in text: + return "plus" + if "free" in text or "basic" in text: + return "free" + return None + + +def check_subscription_status_detail(account: Account, proxy: Optional[str] = None) -> Dict[str, Any]: + """ + 检测账号当前订阅状态。 + 返回带来源与置信度的详细结果,便于绑卡任务诊断。 + """ + if not account.access_token: + raise ValueError("账号缺少 access_token") + + headers = { + "Authorization": f"Bearer {account.access_token}", + "Content-Type": "application/json", + } + chatgpt_account_id = _resolve_chatgpt_account_id(account) + if chatgpt_account_id: + headers["ChatGPT-Account-Id"] = chatgpt_account_id + logger.info( + "订阅检测上下文: email=%s chatgpt_account_id=%s proxy=%s", + account.email, + chatgpt_account_id or "-", + proxy or "-", + ) + if account.cookies: + headers["cookie"] = account.cookies + oai_did = _extract_oai_did(account.cookies) + if oai_did: + headers["oai-device-id"] = oai_did + + successful_sources: List[str] = [] + errors: List[str] = [] + weak_free_source: Optional[str] = None + explicit_free_source: Optional[str] = None + explicit_free_value: Optional[str] = None + + def _result(status: str, source: str, confidence: str, note: Optional[str] = None) -> Dict[str, Any]: + payload = { + "status": status, + "source": source, + "confidence": confidence, + "errors": errors[:4], + "successful_sources": successful_sources[:4], + } + if note: + payload["note"] = note + return payload + + def _analyze_me_payload(data: Any, source_prefix: str) -> Optional[Dict[str, Any]]: + nonlocal weak_free_source, explicit_free_source, explicit_free_value + if not isinstance(data, dict): + return None + + candidates = [ + data.get("plan_type"), + data.get("plan"), + data.get("subscription_plan"), + data.get("subscription_tier"), + data.get("chatgpt_plan_type"), + ] + account_block = data.get("account") + if isinstance(account_block, dict): + candidates.extend([ + account_block.get("plan_type"), + account_block.get("plan"), + account_block.get("subscription_plan"), + account_block.get("subscription_tier"), + account_block.get("chatgpt_plan_type"), + ]) + subscription_block = data.get("subscription") + if isinstance(subscription_block, dict): + candidates.extend([ + subscription_block.get("plan_type"), + subscription_block.get("plan"), + subscription_block.get("product"), + subscription_block.get("tier"), + ]) + + for item in candidates: + mapped = _map_plan_to_subscription(item) + if mapped in ("plus", "team"): + return _result(mapped, f"{source_prefix}.plan", "high") + if mapped == "free" and not explicit_free_source: + explicit_free_source = f"{source_prefix}.plan" + explicit_free_value = str(item) + + orgs = data.get("orgs", {}).get("data", []) + if isinstance(orgs, list): + for org in orgs: + if not isinstance(org, dict): + continue + settings_ = org.get("settings", {}) + if isinstance(settings_, dict): + workspace_plan = str(settings_.get("workspace_plan_type") or "").lower() + if workspace_plan in ("team", "enterprise"): + return _result("team", f"{source_prefix}.org.workspace_plan_type", "high") + mapped = _map_plan_to_subscription(org.get("plan_type") or org.get("plan")) + if mapped in ("plus", "team"): + return _result(mapped, f"{source_prefix}.org.plan", "high") + + bool_markers = ( + data.get("has_paid_subscription"), + data.get("has_active_subscription"), + data.get("is_paid"), + data.get("is_subscribed"), + ) + if any(v is True for v in bool_markers): + return _result("plus", f"{source_prefix}.subscription_flag", "medium") + if all(v is False for v in bool_markers if v is not None): + weak_free_source = weak_free_source or f"{source_prefix}.subscription_flag_false" + explicit_free_value = explicit_free_value or "all_false" + return None + + def _analyze_usage_payload(data: Any, source_prefix: str) -> Optional[Dict[str, Any]]: + nonlocal weak_free_source, explicit_free_value + if not isinstance(data, dict): + return None + usage_candidates = [ + data.get("plan_type"), + data.get("plan"), + data.get("subscription_plan"), + data.get("subscription_tier"), + data.get("chatgpt_plan_type"), + data.get("tier"), + ] + for item in usage_candidates: + mapped = _map_plan_to_subscription(item) + if mapped in ("plus", "team"): + return _result(mapped, f"{source_prefix}.plan", "high") + if mapped == "free": + weak_free_source = weak_free_source or f"{source_prefix}.plan" + explicit_free_value = explicit_free_value or str(item) + + rate_limit = data.get("rate_limit") + code_review_limit = data.get("code_review_rate_limit") + if isinstance(rate_limit, dict) or isinstance(code_review_limit, dict): + weak_free_source = weak_free_source or f"{source_prefix}.rate_limit_only" + return None + + # 1) me 接口 + try: + resp = cffi_requests.get( + "https://chatgpt.com/backend-api/me", + headers=headers, + proxies=_build_proxies(proxy), + timeout=20, + impersonate="chrome110", + ) + resp.raise_for_status() + successful_sources.append("me") + data = resp.json() if resp.content else {} + detected = _analyze_me_payload(data, "me") + if detected: + return detected + except Exception as exc: + errors.append(f"me: {exc}") + + # 1.5) 去掉 ChatGPT-Account-Id 再测一次 me(避免错误 workspace 作用域导致误判 free) + if chatgpt_account_id: + try: + headers_no_scope = dict(headers) + headers_no_scope.pop("ChatGPT-Account-Id", None) + resp_no_scope = cffi_requests.get( + "https://chatgpt.com/backend-api/me", + headers=headers_no_scope, + proxies=_build_proxies(proxy), + timeout=20, + impersonate="chrome110", + ) + resp_no_scope.raise_for_status() + successful_sources.append("me_no_scope") + data_no_scope = resp_no_scope.json() if resp_no_scope.content else {} + detected = _analyze_me_payload(data_no_scope, "me.no_scope") + if detected: + logger.info( + "订阅检测无作用域复核命中: email=%s source=%s confidence=%s", + account.email, + detected.get("source"), + detected.get("confidence"), + ) + return detected + except Exception as exc: + errors.append(f"me_no_scope: {exc}") + + # 2) wham/usage(Cockpit-tools 同款核心) + try: + usage_resp = cffi_requests.get( + "https://chatgpt.com/backend-api/wham/usage", + headers=headers, + proxies=_build_proxies(proxy), + timeout=20, + impersonate="chrome110", + ) + usage_resp.raise_for_status() + successful_sources.append("wham_usage") + usage_data = usage_resp.json() if usage_resp.content else {} + detected = _analyze_usage_payload(usage_data, "wham_usage") + if detected: + return detected + except Exception as exc: + errors.append(f"wham_usage: {exc}") + + # 3) wham/accounts/check(Cockpit-tools 用于账号资料同步的官方口径) + try: + account_check_resp = cffi_requests.get( + ACCOUNT_CHECK_URL, + headers=headers, + proxies=_build_proxies(proxy), + timeout=20, + impersonate="chrome110", + ) + account_check_resp.raise_for_status() + successful_sources.append("wham_accounts_check") + account_check_data = account_check_resp.json() if account_check_resp.content else {} + for raw in _collect_plan_candidates(account_check_data): + mapped = _map_plan_to_subscription(raw) + if mapped in ("plus", "team"): + return _result(mapped, "wham_accounts_check.plan", "medium") + if mapped == "free": + weak_free_source = weak_free_source or "wham_accounts_check.plan" + explicit_free_value = explicit_free_value or str(raw) + except Exception as exc: + errors.append(f"wham_accounts_check: {exc}") + + # 3.5) 作用域复核:前面仍未命中付费时,去掉 ChatGPT-Account-Id 再测 usage + accounts/check + if chatgpt_account_id: + headers_no_scope = dict(headers) + headers_no_scope.pop("ChatGPT-Account-Id", None) + try: + usage_no_scope_resp = cffi_requests.get( + "https://chatgpt.com/backend-api/wham/usage", + headers=headers_no_scope, + proxies=_build_proxies(proxy), + timeout=20, + impersonate="chrome110", + ) + usage_no_scope_resp.raise_for_status() + successful_sources.append("wham_usage_no_scope") + usage_no_scope_data = usage_no_scope_resp.json() if usage_no_scope_resp.content else {} + detected = _analyze_usage_payload(usage_no_scope_data, "wham_usage.no_scope") + if detected: + logger.info( + "订阅检测 usage 无作用域复核命中: email=%s source=%s confidence=%s", + account.email, + detected.get("source"), + detected.get("confidence"), + ) + return detected + except Exception as exc: + errors.append(f"wham_usage_no_scope: {exc}") + + try: + account_check_no_scope_resp = cffi_requests.get( + ACCOUNT_CHECK_URL, + headers=headers_no_scope, + proxies=_build_proxies(proxy), + timeout=20, + impersonate="chrome110", + ) + account_check_no_scope_resp.raise_for_status() + successful_sources.append("wham_accounts_check_no_scope") + account_check_no_scope_data = account_check_no_scope_resp.json() if account_check_no_scope_resp.content else {} + for raw in _collect_plan_candidates(account_check_no_scope_data): + mapped = _map_plan_to_subscription(raw) + if mapped in ("plus", "team"): + return _result(mapped, "wham_accounts_check.no_scope.plan", "medium") + if mapped == "free": + weak_free_source = weak_free_source or "wham_accounts_check.no_scope.plan" + explicit_free_value = explicit_free_value or str(raw) + except Exception as exc: + errors.append(f"wham_accounts_check_no_scope: {exc}") + + # 4) 概览接口兜底(跨 endpoint 聚合) + try: + overview = fetch_codex_overview(account, proxy=proxy) + successful_sources.append("overview") + mapped = _map_plan_to_subscription(overview.get("plan_type")) + if mapped in ("plus", "team"): + return _result(mapped, f"overview.{overview.get('plan_source') or 'plan'}", "medium") + if mapped == "free": + weak_free_source = weak_free_source or f"overview.{overview.get('plan_source') or 'plan'}" + for err in overview.get("errors") or []: + if err: + errors.append(f"overview: {err}") + except Exception as exc: + errors.append(f"overview: {exc}") + + # 5) JWT claim 兜底(不依赖刷新 token) + jwt_candidates = ( + ("id_token.chatgpt_plan_type", _extract_chatgpt_plan_from_jwt(account.id_token)), + ("access_token.chatgpt_plan_type", _extract_chatgpt_plan_from_jwt(account.access_token)), + ) + for source_name, mapped in jwt_candidates: + if mapped in ("plus", "team"): + return _result(mapped, source_name, "medium", note="jwt_claim") + if mapped == "free": + weak_free_source = weak_free_source or source_name + explicit_free_value = explicit_free_value or "jwt_free" + + # 6) 明确 free 信号 + if explicit_free_source: + return _result( + "free", + explicit_free_source, + "high", + note=f"explicit_free={explicit_free_value or '-'}", + ) + + # 7) 检测信号弱时,优先使用数据库缓存的已订阅状态避免误判为 free + cached = _map_plan_to_subscription(account.subscription_type) + if cached in ("plus", "team"): + return _result( + cached, + "db.subscription_type", + "low", + note=f"api_ambiguous={weak_free_source or 'no_plan_signal'}", + ) + + # 8) 若所有检测接口都失败,抛错而不是误判 free + if not successful_sources: + raise RuntimeError("订阅检测接口全部失败: " + " | ".join(errors[:3])) + + # 9) 仍无有效订阅信号,返回低置信度 free + return _result( + "free", + weak_free_source or "fallback.default_free", + "low", + note="no_paid_signal", + ) + + +def check_subscription_status(account: Account, proxy: Optional[str] = None) -> str: + """ + 兼容旧调用:仅返回 'free' / 'plus' / 'team'。 + """ + detail = check_subscription_status_detail(account, proxy=proxy) + status = str(detail.get("status") or "free").lower() + if status not in ("free", "plus", "team"): + return "free" + return status diff --git a/src/core/openai/random_billing.py b/src/core/openai/random_billing.py new file mode 100644 index 00000000..bfb515f0 --- /dev/null +++ b/src/core/openai/random_billing.py @@ -0,0 +1,482 @@ +""" +Random billing profile helper. + +Strategy: +1) Try meiguodizhi pages by country. +2) If parsing fails, fall back to local random profile. +""" + +from __future__ import annotations + +import html +import logging +import os +import random +import re +from typing import Dict, List, Optional +from urllib.parse import urljoin + +from curl_cffi import requests as cffi_requests + +logger = logging.getLogger(__name__) + +BASE_URL = "https://www.meiguodizhi.com" +ENABLE_EXTERNAL_SOURCE = str(os.getenv("RANDOM_BILLING_ENABLE_EXTERNAL", "")).strip().lower() in { + "1", + "true", + "yes", + "on", +} + +COUNTRY_CURRENCY_MAP: Dict[str, str] = { + "US": "USD", + "GB": "GBP", + "CA": "CAD", + "AU": "AUD", + "SG": "SGD", + "HK": "HKD", + "JP": "JPY", + "DE": "EUR", + "FR": "EUR", + "IT": "EUR", + "ES": "EUR", +} + +COUNTRY_ROUTE_CANDIDATES: Dict[str, List[str]] = { + "US": [ + "/usa-address", + "/usa-address/hot-city-Seattle?hl=en", + "/?hl=en", + ], + "GB": ["/uk-address", "/gb-address"], + "CA": ["/canada-address", "/ca-address"], + "AU": ["/australia-address", "/au-address"], + "SG": ["/singapore-address", "/sg-address"], + "HK": ["/hongkong-address", "/hk-address"], + "JP": ["/japan-address", "/jp-address"], + "DE": ["/germany-address", "/de-address"], + "FR": ["/france-address", "/fr-address"], + "IT": ["/italy-address", "/it-address"], + "ES": ["/spain-address", "/es-address"], +} + +FIRST_NAMES = [ + "James", + "Olivia", + "Noah", + "Emma", + "Liam", + "Sophia", + "Ethan", + "Mia", + "Aiden", + "Ava", + "Lucas", + "Amelia", + "Henry", + "Harper", +] + +LAST_NAMES = [ + "Smith", + "Johnson", + "Williams", + "Brown", + "Jones", + "Garcia", + "Miller", + "Davis", + "Wilson", + "Anderson", + "Taylor", + "Thomas", +] + +STREET_NAMES = [ + "Main St", + "Oak Ave", + "Maple Dr", + "Pine St", + "Cedar Ln", + "Park Ave", + "Sunset Blvd", + "River Rd", +] + +STREET_SUFFIXES = ["St", "Ave", "Blvd", "Dr", "Ln", "Way", "Ct", "Pl", "Rd"] +US_STREET_BASES = [ + "Washington", + "Lincoln", + "Franklin", + "Jefferson", + "Madison", + "Jackson", + "Monroe", + "Adams", + "Wilson", + "Lake", + "Hill", + "Sunset", + "Park", + "Riverside", + "Highland", + "Center", + "Valley", + "Cedar", + "Pine", + "Maple", + "Willow", + "Cherry", + "Elm", + "Locust", + "Meadow", +] + +US_STATE_CITY_POOL: Dict[str, Dict[str, List[str] | str]] = { + "CA": {"name": "California", "cities": ["Los Angeles", "San Francisco", "San Diego", "San Jose"], "zip_prefix": "9"}, + "NY": {"name": "New York", "cities": ["New York", "Brooklyn", "Buffalo", "Rochester"], "zip_prefix": "1"}, + "TX": {"name": "Texas", "cities": ["Houston", "Dallas", "Austin", "San Antonio"], "zip_prefix": "7"}, + "FL": {"name": "Florida", "cities": ["Miami", "Orlando", "Tampa", "Jacksonville"], "zip_prefix": "3"}, + "WA": {"name": "Washington", "cities": ["Seattle", "Spokane", "Tacoma", "Bellevue"], "zip_prefix": "9"}, + "IL": {"name": "Illinois", "cities": ["Chicago", "Aurora", "Naperville", "Rockford"], "zip_prefix": "6"}, + "PA": {"name": "Pennsylvania", "cities": ["Philadelphia", "Pittsburgh", "Allentown", "Erie"], "zip_prefix": "1"}, + "OH": {"name": "Ohio", "cities": ["Columbus", "Cleveland", "Cincinnati", "Toledo"], "zip_prefix": "4"}, + "GA": {"name": "Georgia", "cities": ["Atlanta", "Savannah", "Augusta", "Macon"], "zip_prefix": "3"}, + "NC": {"name": "North Carolina", "cities": ["Charlotte", "Raleigh", "Greensboro", "Durham"], "zip_prefix": "2"}, + "VA": {"name": "Virginia", "cities": ["Virginia Beach", "Richmond", "Norfolk", "Arlington"], "zip_prefix": "2"}, + "MA": {"name": "Massachusetts", "cities": ["Boston", "Worcester", "Cambridge", "Springfield"], "zip_prefix": "0"}, + "NJ": {"name": "New Jersey", "cities": ["Newark", "Jersey City", "Paterson", "Edison"], "zip_prefix": "0"}, + "MI": {"name": "Michigan", "cities": ["Detroit", "Grand Rapids", "Lansing", "Ann Arbor"], "zip_prefix": "4"}, + "AZ": {"name": "Arizona", "cities": ["Phoenix", "Tucson", "Mesa", "Chandler"], "zip_prefix": "8"}, + "CO": {"name": "Colorado", "cities": ["Denver", "Colorado Springs", "Aurora", "Fort Collins"], "zip_prefix": "8"}, + "NV": {"name": "Nevada", "cities": ["Las Vegas", "Reno", "Henderson", "North Las Vegas"], "zip_prefix": "8"}, + "OR": {"name": "Oregon", "cities": ["Portland", "Salem", "Eugene", "Gresham"], "zip_prefix": "9"}, + "MN": {"name": "Minnesota", "cities": ["Minneapolis", "Saint Paul", "Rochester", "Bloomington"], "zip_prefix": "5"}, + "MO": {"name": "Missouri", "cities": ["Kansas City", "St. Louis", "Springfield", "Columbia"], "zip_prefix": "6"}, + "TN": {"name": "Tennessee", "cities": ["Nashville", "Memphis", "Knoxville", "Chattanooga"], "zip_prefix": "3"}, + "IN": {"name": "Indiana", "cities": ["Indianapolis", "Fort Wayne", "Evansville", "South Bend"], "zip_prefix": "4"}, + "WI": {"name": "Wisconsin", "cities": ["Milwaukee", "Madison", "Green Bay", "Kenosha"], "zip_prefix": "5"}, + "MD": {"name": "Maryland", "cities": ["Baltimore", "Columbia", "Germantown", "Rockville"], "zip_prefix": "2"}, + "SC": {"name": "South Carolina", "cities": ["Charleston", "Columbia", "Greenville", "Myrtle Beach"], "zip_prefix": "2"}, + "AL": {"name": "Alabama", "cities": ["Birmingham", "Montgomery", "Huntsville", "Mobile"], "zip_prefix": "3"}, + "LA": {"name": "Louisiana", "cities": ["New Orleans", "Baton Rouge", "Shreveport", "Lafayette"], "zip_prefix": "7"}, + "OK": {"name": "Oklahoma", "cities": ["Oklahoma City", "Tulsa", "Norman", "Edmond"], "zip_prefix": "7"}, + "KY": {"name": "Kentucky", "cities": ["Louisville", "Lexington", "Bowling Green", "Owensboro"], "zip_prefix": "4"}, + "UT": {"name": "Utah", "cities": ["Salt Lake City", "Provo", "West Valley City", "Ogden"], "zip_prefix": "8"}, +} + +LOCAL_ADDRESS_POOL: Dict[str, List[Dict[str, str]]] = { + "US": [ + {"city": "San Jose", "state": "CA", "postal": "95112"}, + {"city": "Austin", "state": "TX", "postal": "78701"}, + {"city": "Seattle", "state": "WA", "postal": "98101"}, + {"city": "Miami", "state": "FL", "postal": "33101"}, + {"city": "Boston", "state": "MA", "postal": "02108"}, + ], + "GB": [ + {"city": "London", "state": "London", "postal": "SW1A 1AA"}, + {"city": "Manchester", "state": "England", "postal": "M1 1AE"}, + ], + "CA": [ + {"city": "Toronto", "state": "ON", "postal": "M5V 2T6"}, + {"city": "Vancouver", "state": "BC", "postal": "V6B 1A1"}, + ], + "AU": [ + {"city": "Sydney", "state": "NSW", "postal": "2000"}, + {"city": "Melbourne", "state": "VIC", "postal": "3000"}, + ], + "SG": [{"city": "Singapore", "state": "SG", "postal": "018956"}], + "HK": [{"city": "Hong Kong", "state": "HK", "postal": "000000"}], + "JP": [ + {"city": "Tokyo", "state": "Tokyo", "postal": "100-0001"}, + {"city": "Osaka", "state": "Osaka", "postal": "530-0001"}, + ], + "DE": [ + {"city": "Berlin", "state": "BE", "postal": "10115"}, + {"city": "Munich", "state": "BY", "postal": "80331"}, + ], + "FR": [ + {"city": "Paris", "state": "IDF", "postal": "75001"}, + {"city": "Lyon", "state": "ARA", "postal": "69001"}, + ], + "IT": [ + {"city": "Rome", "state": "RM", "postal": "00118"}, + {"city": "Milan", "state": "MI", "postal": "20121"}, + ], + "ES": [ + {"city": "Madrid", "state": "MD", "postal": "28001"}, + {"city": "Barcelona", "state": "CT", "postal": "08001"}, + ], +} + + +def _normalize_country(country: Optional[str]) -> str: + code = str(country or "").strip().upper() + if not code: + return "US" + if code in COUNTRY_CURRENCY_MAP: + return code + return "US" + + +def _request_text(url: str, proxy: Optional[str]) -> str: + headers = { + "User-Agent": "codex-console2/random-billing", + "Accept": "text/html,application/xhtml+xml", + } + proxies = {"http": proxy, "https": proxy} if proxy else None + if proxy: + # 显式代理:按用户传入值请求。 + resp = cffi_requests.get( + url, + headers=headers, + proxies=proxies, + timeout=25, + impersonate="chrome110", + ) + else: + # 无显式代理:禁用环境变量代理,避免被系统 HTTP(S)_PROXY 污染。 + with cffi_requests.Session() as session: + try: + session.trust_env = False + except Exception: + pass + resp = session.get( + url, + headers=headers, + proxies=None, + timeout=25, + impersonate="chrome110", + ) + resp.raise_for_status() + return str(resp.text or "") + + +def _extract_random_url(page_html: str, page_url: str) -> Optional[str]: + text = str(page_html or "") + patterns = [ + r']+href="([^"]+)"[^>]*>\s*随机地址\s*', + r"]+href='([^']+)'[^>]*>\s*随机地址\s*", + r"location\.href\s*=\s*['\"]([^'\"]+)['\"]", + ] + for pattern in patterns: + match = re.search(pattern, text, flags=re.IGNORECASE) + if not match: + continue + href = str(match.group(1) or "").strip() + if not href: + continue + return urljoin(page_url, href) + return None + + +def _extract_by_patterns(text: str, patterns: List[str]) -> str: + for pattern in patterns: + match = re.search(pattern, text, flags=re.IGNORECASE | re.DOTALL) + if not match: + continue + value = html.unescape(str(match.group(1) or "").strip()) + value = re.sub(r"\s+", " ", value).strip() + if value and len(value) <= 120: + return value + return "" + + +def _extract_text_after_label(page_text: str, label: str) -> str: + pattern = rf"{re.escape(label)}\s*\n\s*([^\n]{{2,80}})" + match = re.search(pattern, page_text, flags=re.IGNORECASE) + if not match: + return "" + value = str(match.group(1) or "").strip() + if value in {"街道", "城市", "州", "邮编", "全名"}: + return "" + return value + + +def _build_us_line1() -> str: + number = random.randint(18, 9999) + base = random.choice(US_STREET_BASES) + suffix = random.choice(STREET_SUFFIXES) + line1 = f"{number} {base} {suffix}" + if random.random() < 0.28: + line1 += f" Apt {random.randint(1, 999)}" + return line1 + + +def _build_us_postal(prefix: str) -> str: + safe_prefix = str(prefix or "").strip() + if not safe_prefix or not safe_prefix[0].isdigit(): + safe_prefix = str(random.randint(0, 9)) + return f"{safe_prefix[0]}{random.randint(0, 9999):04d}" + + +def _build_local_geo_profile(country_code: str, reason: Optional[str] = None, *, fallback_source: bool = False) -> Dict[str, str]: + if country_code == "US": + state_code, state_obj = random.choice(list(US_STATE_CITY_POOL.items())) + city = str(random.choice(list(state_obj.get("cities", []) or ["Seattle"]))) + state_name = str(state_obj.get("name", state_code)) + postal_code = _build_us_postal(str(state_obj.get("zip_prefix", "9"))) + profile = { + "billing_name": f"{random.choice(FIRST_NAMES)} {random.choice(LAST_NAMES)}", + "country_code": "US", + "currency": "USD", + "address_line1": _build_us_line1(), + "address_city": city, + "address_state": state_code, + "address_state_name": state_name, + "postal_code": postal_code, + "source": "local_geo_fallback" if fallback_source else "local_geo", + } + else: + pool = LOCAL_ADDRESS_POOL.get(country_code) or LOCAL_ADDRESS_POOL["US"] + picked = random.choice(pool) + number = random.randint(18, 9999) + line1 = f"{number} {random.choice(STREET_NAMES)}" + profile = { + "billing_name": f"{random.choice(FIRST_NAMES)} {random.choice(LAST_NAMES)}", + "country_code": country_code, + "currency": COUNTRY_CURRENCY_MAP.get(country_code, "USD"), + "address_line1": line1, + "address_city": picked.get("city", ""), + "address_state": picked.get("state", ""), + "postal_code": picked.get("postal", ""), + "source": "local_geo_fallback" if fallback_source else "local_geo", + } + if reason: + profile["fallback_reason"] = str(reason)[:220] + return profile + + +def _parse_profile_from_html(page_html: str, country_code: str) -> Optional[Dict[str, str]]: + raw = str(page_html or "") + if not raw: + return None + compact = raw.replace("\r", "\n") + text_only = re.sub(r"<[^>]+>", "\n", compact) + text_only = html.unescape(text_only) + text_only = re.sub(r"[ \t]+", " ", text_only) + text_only = re.sub(r"\n+", "\n", text_only) + + name = _extract_by_patterns( + compact, + [ + r'"(?:full_?name|name)"\s*:\s*"([^"]+)"', + r'name=["\'](?:full_?name|name)["\'][^>]*value=["\']([^"\']+)["\']', + r'id=["\'](?:full_?name|name)["\'][^>]*value=["\']([^"\']+)["\']', + ], + ) or _extract_text_after_label(text_only, "全名") + + line1 = _extract_by_patterns( + compact, + [ + r'"(?:street|address|address_line1|line1)"\s*:\s*"([^"]+)"', + r'name=["\'](?:street|address|address_line1|line1)["\'][^>]*value=["\']([^"\']+)["\']', + ], + ) or _extract_text_after_label(text_only, "街道") + + city = _extract_by_patterns( + compact, + [ + r'"(?:city|locality)"\s*:\s*"([^"]+)"', + r'name=["\'](?:city|locality)["\'][^>]*value=["\']([^"\']+)["\']', + ], + ) or _extract_text_after_label(text_only, "城市") + + state = _extract_by_patterns( + compact, + [ + r'"(?:state|province|administrativeArea)"\s*:\s*"([^"]+)"', + r'name=["\'](?:state|province|administrativeArea)["\'][^>]*value=["\']([^"\']+)["\']', + ], + ) or _extract_text_after_label(text_only, "州") + + postal = _extract_by_patterns( + compact, + [ + r'"(?:postal|zip|zipcode|postalCode)"\s*:\s*"([^"]+)"', + r'name=["\'](?:postal|zip|zipcode|postalCode)["\'][^>]*value=["\']([^"\']+)["\']', + ], + ) or _extract_text_after_label(text_only, "邮编") + + profile = { + "billing_name": name, + "country_code": country_code, + "currency": COUNTRY_CURRENCY_MAP.get(country_code, "USD"), + "address_line1": line1, + "address_city": city, + "address_state": state, + "postal_code": postal, + "source": "meiguodizhi", + } + required = ("billing_name", "address_line1", "address_city", "address_state", "postal_code") + if all(str(profile.get(key) or "").strip() for key in required): + return profile + return None + + +def _build_local_profile(country_code: str, reason: Optional[str] = None) -> Dict[str, str]: + return _build_local_geo_profile(country_code, reason=reason, fallback_source=True) + + +def _iter_country_pages(country_code: str) -> List[str]: + pages: List[str] = [] + for path in COUNTRY_ROUTE_CANDIDATES.get(country_code, []): + if not path.startswith("/"): + path = "/" + path + pages.append(urljoin(BASE_URL, path)) + if not pages: + pages.append(urljoin(BASE_URL, "/usa-address")) + # 兜底再补一层首页,部分站点路径偶发调整时可避免直接全量失败。 + pages.append(urljoin(BASE_URL, "/?hl=en")) + # 去重并保持顺序 + ordered_unique: List[str] = [] + seen = set() + for item in pages: + if item in seen: + continue + seen.add(item) + ordered_unique.append(item) + return ordered_unique + + +def generate_random_billing_profile(country: Optional[str], proxy: Optional[str] = None) -> Dict[str, str]: + country_code = _normalize_country(country) + # 默认本地生成,确保可用性与速度;需要外部源时可配置 RANDOM_BILLING_ENABLE_EXTERNAL=true。 + if not ENABLE_EXTERNAL_SOURCE: + return _build_local_geo_profile(country_code) + + pages = _iter_country_pages(country_code) + attempts: List[str] = [] + proxy_candidates: List[Optional[str]] = [] + for value in (proxy, None): + if value not in proxy_candidates: + proxy_candidates.append(value) + + for page_url in pages: + for proxy_item in proxy_candidates: + try: + page_html = _request_text(page_url, proxy_item) + random_url = _extract_random_url(page_html, page_url) + html_candidates = [(page_html, page_url)] + if random_url: + try: + random_html = _request_text(random_url, proxy_item) + html_candidates.insert(0, (random_html, random_url)) + except Exception as random_exc: + attempts.append(f"{random_url}: {random_exc}") + + for html_content, used_url in html_candidates: + parsed = _parse_profile_from_html(html_content, country_code) + if parsed: + parsed["source_url"] = used_url + if proxy_item: + parsed["proxy_used"] = "on" + return parsed + attempts.append(f"{page_url}: parse_empty") + except Exception as exc: + attempts.append(f"{page_url}: {exc}") + continue + + reason = "; ".join(attempts[-3:]) if attempts else "unknown_error" + logger.warning("random billing fallback triggered: country=%s reason=%s", country_code, reason) + return _build_local_geo_profile(country_code, reason=reason, fallback_source=True) diff --git a/src/core/openai/sentinel.py b/src/core/openai/sentinel.py new file mode 100644 index 00000000..fcbd7f83 --- /dev/null +++ b/src/core/openai/sentinel.py @@ -0,0 +1,98 @@ +"""Helpers for OpenAI Sentinel proof-of-work tokens.""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import random +import time +import uuid +from datetime import datetime, timedelta, timezone +from typing import Sequence + + +DEFAULT_SENTINEL_DIFF = "0fffff" +DEFAULT_MAX_ITERATIONS = 500_000 +_SCREEN_SIGNATURES = (3000, 3120, 4000, 4160) +_LANGUAGE_SIGNATURE = "en-US,es-US,en,es" +_NAVIGATOR_KEYS = ("location", "ontransitionend", "onprogress") +_WINDOW_KEYS = ("window", "document", "navigator") + + +class SentinelPOWError(RuntimeError): + """Raised when a Sentinel proof-of-work token cannot be solved.""" + + +def _format_browser_time() -> str: + """Match the browser-style timestamp used by public Sentinel solvers.""" + browser_now = datetime.now(timezone(timedelta(hours=-5))) + return browser_now.strftime("%a %b %d %Y %H:%M:%S") + " GMT-0500 (Eastern Standard Time)" + + +def build_sentinel_config(user_agent: str) -> list: + """Build a browser-like fingerprint payload for the Sentinel PoW solver.""" + perf_ms = time.perf_counter() * 1000 + epoch_ms = (time.time() * 1000) - perf_ms + return [ + random.choice(_SCREEN_SIGNATURES), + _format_browser_time(), + 4294705152, + 0, + user_agent, + "", + "", + "en-US", + _LANGUAGE_SIGNATURE, + 0, + random.choice(_NAVIGATOR_KEYS), + "location", + random.choice(_WINDOW_KEYS), + perf_ms, + str(uuid.uuid4()), + "", + 8, + epoch_ms, + ] + + +def _encode_pow_payload(config: Sequence[object], nonce: int) -> bytes: + prefix = (json.dumps(config[:3], separators=(",", ":"), ensure_ascii=False)[:-1] + ",").encode("utf-8") + middle = ( + "," + json.dumps(config[4:9], separators=(",", ":"), ensure_ascii=False)[1:-1] + "," + ).encode("utf-8") + suffix = ("," + json.dumps(config[10:], separators=(",", ":"), ensure_ascii=False)[1:]).encode("utf-8") + body = prefix + str(nonce).encode("ascii") + middle + str(nonce >> 1).encode("ascii") + suffix + return base64.b64encode(body) + + +def solve_sentinel_pow( + seed: str, + difficulty: str, + config: Sequence[object], + max_iterations: int = DEFAULT_MAX_ITERATIONS, +) -> str: + """Solve the Sentinel PoW challenge and return the base64 payload.""" + seed_bytes = seed.encode("utf-8") + target = bytes.fromhex(difficulty) + prefix_length = len(target) + + for nonce in range(max_iterations): + encoded = _encode_pow_payload(config, nonce) + digest = hashlib.sha3_512(seed_bytes + encoded).digest() + if digest[:prefix_length] <= target: + return encoded.decode("ascii") + + raise SentinelPOWError(f"failed to solve sentinel pow after {max_iterations} attempts") + + +def build_sentinel_pow_token( + user_agent: str, + difficulty: str = DEFAULT_SENTINEL_DIFF, + max_iterations: int = DEFAULT_MAX_ITERATIONS, +) -> str: + """Build the `p` token required by the Sentinel request endpoint.""" + config = build_sentinel_config(user_agent) + seed = format(random.random()) + solution = solve_sentinel_pow(seed, difficulty, config, max_iterations=max_iterations) + return f"gAAAAAC{solution}" diff --git a/src/core/openai/token_refresh.py b/src/core/openai/token_refresh.py new file mode 100644 index 00000000..a3f070f1 --- /dev/null +++ b/src/core/openai/token_refresh.py @@ -0,0 +1,411 @@ +""" +Token 刷新模块 +支持 Session Token 和 OAuth Refresh Token 两种刷新方式 +""" + +import logging +import json +import time +from http.cookies import SimpleCookie +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 ...config.constants import AccountStatus +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 + + @staticmethod + def _extract_session_token_from_cookies(cookies: Optional[str]) -> Optional[str]: + """从完整 Cookie 字符串中提取 __Secure-next-auth.session-token。""" + text = str(cookies or "").strip() + if not text: + return None + try: + jar = SimpleCookie() + jar.load(text) + token = jar.get("__Secure-next-auth.session-token") + value = token.value if token else None + return value or None + except Exception: + return None + + def _create_direct_session(self) -> cffi_requests.Session: + """创建直连会话(不走代理)。""" + return cffi_requests.Session(impersonate="chrome120") + + def refresh_by_session_token(self, session_token: str) -> TokenRefreshResult: + """ + 使用 Session Token 刷新 + + Args: + session_token: 会话令牌 + + Returns: + TokenRefreshResult: 刷新结果 + """ + result = TokenRefreshResult(success=False) + + try: + def _request_once(session: cffi_requests.Session): + session.cookies.set( + "__Secure-next-auth.session-token", + session_token, + domain=".chatgpt.com", + path="/" + ) + return 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 + ) + + session = self._create_session() + response = _request_once(session) + + # 代理通道触发地区/风控时,自动回退直连重试一次 + if ( + response.status_code in (401, 403) + and self.proxy_url + ): + body = (response.text or "")[:500].lower() + if "unsupported_country_region_territory" in body or "request_forbidden" in body: + logger.warning("Session token 刷新触发地区限制,尝试直连重试") + direct_session = self._create_direct_session() + response = _request_once(direct_session) + + 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: + # 使用配置的 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 + } + + def _request_once(session: cffi_requests.Session): + return session.post( + self.TOKEN_URL, + headers={ + "content-type": "application/x-www-form-urlencoded", + "accept": "application/json" + }, + data=token_data, + timeout=30 + ) + + session = self._create_session() + response = _request_once(session) + + # 典型场景:代理出口地区受限导致 403,改为直连再试一次 + if response.status_code == 403 and self.proxy_url: + body = (response.text or "")[:500].lower() + if "unsupported_country_region_territory" in body: + logger.warning("OAuth token 刷新触发地区限制,尝试直连重试") + direct_session = self._create_direct_session() + response = _request_once(direct_session) + + if response.status_code != 200: + result.error_message = f"OAuth token 刷新失败: HTTP {response.status_code}" + 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 刷新") + + # 若 session_token 字段为空,但 cookies 里有 next-auth 会话,仍可尝试会话刷新 + cookie_session_token = self._extract_session_token_from_cookies(getattr(account, "cookies", None)) + if cookie_session_token: + logger.info(f"尝试使用 Cookies 中的 Session Token 刷新账号 {account.email}") + result = self.refresh_by_session_token(cookie_session_token) + if result.success: + return result + logger.warning("Cookies 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: + # 无 Token 直接归类为 failed,便于账号管理按“失败”筛选定位问题账号。 + if account.status != AccountStatus.FAILED.value: + crud.update_account(db, account_id, status=AccountStatus.FAILED.value) + return False, "账号没有 access_token" + + manager = TokenRefreshManager(proxy_url=proxy_url) + is_valid, error = manager.validate_token(account.access_token) + + # 验证后回写账号状态,确保前端筛选(active/expired/banned/failed)与验证结果一致。 + error_text = str(error or "").lower() + if is_valid: + next_status = AccountStatus.ACTIVE.value + elif ( + "过期" in error_text + or "expired" in error_text + or "401" in error_text + or "invalid" in error_text + ): + next_status = AccountStatus.EXPIRED.value + elif ( + "封禁" in error_text + or "banned" in error_text + or "forbidden" in error_text + or "403" in error_text + ): + next_status = AccountStatus.BANNED.value + else: + next_status = AccountStatus.FAILED.value + + if account.status != next_status: + crud.update_account(db, account_id, status=next_status) + + return is_valid, error diff --git a/src/core/register.py b/src/core/register.py new file mode 100644 index 00000000..f4a098c9 --- /dev/null +++ b/src/core/register.py @@ -0,0 +1,2817 @@ +""" +注册流程引擎 +从 main.py 中提取并重构的注册流程 +""" + +import re +import json +import time +import logging +import secrets +import string +import uuid +from typing import Optional, Dict, Any, Tuple, Callable, List +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 = "" # 会话令牌 + device_id: str = "" # oai-did + 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 "", + "device_id": self.device_id, + "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 # 传递代理配置 + ) + entry_flow = str(getattr(settings, "registration_entry_flow", "native") or "native").strip().lower() + # 配置层仅保留 native/abcard;Outlook 邮箱在执行时自动切换 outlook 链路。 + self.registration_entry_flow: str = entry_flow if entry_flow in {"native", "abcard"} else "native" + + # 状态变量 + self.email: Optional[str] = None + self.inbox_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.device_id: Optional[str] = None # oai-did + self.logs: list = [] + self._otp_sent_at: Optional[float] = None # OTP 发送时间戳 + self._is_existing_account: bool = False # 是否为已注册账号(用于自动登录) + self._token_acquisition_requires_login: bool = False # 新注册账号需要二次登录拿 token + self._create_account_continue_url: Optional[str] = None # create_account 返回的 continue_url(ABCard链路兜底) + self._create_account_workspace_id: Optional[str] = None + self._create_account_account_id: Optional[str] = None + self._create_account_refresh_token: Optional[str] = None + self._last_validate_otp_continue_url: Optional[str] = None + self._last_validate_otp_workspace_id: Optional[str] = None + self._last_register_password_error: Optional[str] = None + self._last_otp_validation_code: Optional[str] = None + self._last_otp_validation_status_code: Optional[int] = None + self._last_otp_validation_outcome: str = "" # success/http_non_200/network_timeout/network_error + + 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 _dump_session_cookies(self) -> str: + """导出当前会话 cookies(用于后续支付/绑卡自动化)。""" + if not self.session: + return "" + try: + cookie_map: dict[str, str] = {} + order: list[str] = [] + + def _push(name: Optional[str], value: Optional[str]): + key = str(name or "").strip() + val = str(value or "").strip() + if not key: + return + if key not in cookie_map: + cookie_map[key] = val + order.append(key) + return + # 同名 cookie 可能来自不同域/路径:优先保留非空且更长值,避免空值覆盖有效分片。 + prev = str(cookie_map.get(key) or "").strip() + if (not prev and val) or (val and len(val) > len(prev)): + cookie_map[key] = val + + # 1) 常规 requests/curl_cffi 字典接口 + try: + for key, value in self.session.cookies.items(): + _push(key, value) + except Exception: + pass + + # 2) CookieJar 接口(可拿到分片 cookie) + try: + jar = getattr(self.session.cookies, "jar", None) + if jar is not None: + for cookie in jar: + _push(getattr(cookie, "name", ""), getattr(cookie, "value", "")) + except Exception: + pass + + # 3) 关键 cookie 兜底读取 + for key in ( + "oai-did", + "oai-client-auth-session", + "__Secure-next-auth.session-token", + "_Secure-next-auth.session-token", + ): + try: + _push(key, self.session.cookies.get(key)) + except Exception: + continue + + pairs = [(k, cookie_map.get(k, "")) for k in order if k] + return "; ".join(f"{k}={v}" for k, v in pairs if k) + except Exception: + return "" + + @staticmethod + def _extract_session_token_from_cookie_jar(cookie_jar) -> str: + """ + 从 CookieJar 中提取 next-auth session token(兼容分片 + 重复域名)。 + """ + if not cookie_jar: + return "" + + entries: list[tuple[str, str]] = [] + try: + for key, value in cookie_jar.items(): + entries.append((str(key or "").strip(), str(value or "").strip())) + except Exception: + pass + + try: + jar = getattr(cookie_jar, "jar", None) + if jar is not None: + for cookie in jar: + entries.append( + ( + str(getattr(cookie, "name", "") or "").strip(), + str(getattr(cookie, "value", "") or "").strip(), + ) + ) + except Exception: + pass + + direct_candidates = [ + val + for name, val in entries + if name in ("__Secure-next-auth.session-token", "_Secure-next-auth.session-token") and val + ] + if direct_candidates: + return max(direct_candidates, key=len) + + chunk_map: dict[int, str] = {} + for name, value in entries: + if not ( + name.startswith("__Secure-next-auth.session-token.") + or name.startswith("_Secure-next-auth.session-token.") + ): + continue + if not value: + continue + try: + idx = int(name.rsplit(".", 1)[-1]) + except Exception: + continue + prev = chunk_map.get(idx, "") + if not prev or len(value) > len(prev): + chunk_map[idx] = value + + if chunk_map: + return "".join(chunk_map[i] for i in sorted(chunk_map.keys())) + return "" + + @staticmethod + def _flatten_set_cookie_headers(response) -> str: + """ + 合并多条 Set-Cookie(包含分片 cookie)。 + """ + try: + headers = getattr(response, "headers", None) + if headers is None: + return "" + if hasattr(headers, "get_list"): + values = headers.get_list("set-cookie") + if values: + return " | ".join(str(v or "") for v in values if v is not None) + if hasattr(headers, "get_all"): + values = headers.get_all("set-cookie") + if values: + return " | ".join(str(v or "") for v in values if v is not None) + return str(headers.get("set-cookie") or "") + except Exception: + return "" + + @staticmethod + def _extract_request_cookie_header(response) -> str: + """ + 从响应对象关联的请求头中提取 Cookie。 + 对齐 F12 Network -> Request Headers -> Cookie 的观测路径。 + """ + try: + request_obj = getattr(response, "request", None) + if request_obj is None: + return "" + headers = getattr(request_obj, "headers", None) + if headers is None: + return "" + + if hasattr(headers, "get"): + value = headers.get("cookie") or headers.get("Cookie") + if value: + return str(value) + + try: + for key, value in dict(headers).items(): + if str(key or "").strip().lower() == "cookie" and value: + return str(value) + except Exception: + pass + except Exception: + pass + return "" + + 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 + + raw_email = str(self.email_info["email"] or "").strip() + normalized_email = raw_email.lower() + + # 保留原始收件地址,注册链路统一使用规范化邮箱,规避 "Failed to register username"。 + self.inbox_email = raw_email + self.email = normalized_email + self.email_info["email"] = normalized_email + + if raw_email and raw_email != normalized_email: + self._log(f"邮箱规范化: {raw_email} -> {normalized_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 not did: + # 对齐 ABCard:部分环境 cookie 不落盘,尝试从 HTML 文本提取 + try: + m = re.search(r'oai-did["\s:=]+([a-f0-9-]{36})', str(response.text or ""), re.IGNORECASE) + if m: + did = str(m.group(1) or "").strip() + if did: + try: + self.session.cookies.set("oai-did", did, domain=".chatgpt.com", path="/") + except Exception: + pass + except Exception: + pass + + 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 + + # 对齐 ABCard:无法从响应拿到 did 时,优先复用上次成功 did,再使用 UUID 兜底。 + fallback_did = str(self.device_id or "").strip() or str(uuid.uuid4()) + try: + if self.session: + self.session.cookies.set("oai-did", fallback_did, domain=".chatgpt.com", path="/") + except Exception: + pass + self._log(f"未获取到 oai-did,使用兜底 Device ID: {fallback_did}", "warning") + return fallback_did + + def _check_sentinel(self, did: str) -> Optional[str]: + """检查 Sentinel 拦截""" + try: + sen_token = self.http_client.check_sentinel(did) + if sen_token: + self._log(f"Sentinel token 获取成功") + return sen_token + self._log("Sentinel 检查失败: 未获取到 token", "warning") + return None + + except Exception as e: + self._log(f"Sentinel 检查异常: {e}", "warning") + return None + + def _submit_auth_start( + self, + did: str, + sen_token: Optional[str], + *, + screen_hint: str, + referer: str, + log_label: str, + record_existing_account: bool = True, + ) -> SignupFormResult: + """ + 提交授权入口表单 + + Returns: + SignupFormResult: 提交结果,包含账号状态判断 + """ + max_attempts = 3 + current_did = str(did or "").strip() + current_sen_token = str(sen_token or "").strip() if sen_token else None + for attempt in range(1, max_attempts + 1): + try: + request_body = json.dumps({ + "username": { + "value": self.email, + "kind": "email", + }, + "screen_hint": screen_hint, + }) + + headers = { + "referer": referer, + "accept": "application/json", + "content-type": "application/json", + } + + if current_sen_token: + sentinel = json.dumps({ + "p": "", + "t": "", + "c": current_sen_token, + "id": current_did, + "flow": "authorize_continue", + }) + headers["openai-sentinel-token"] = sentinel + + response = self.session.post( + OPENAI_API_ENDPOINTS["signup"], + headers=headers, + data=request_body, + ) + + self._log(f"{log_label}状态: {response.status_code}") + + if response.status_code == 429 and attempt < max_attempts: + wait_seconds = min(18, 5 * attempt) + self._log( + f"{log_label}命中限流 429(第 {attempt}/{max_attempts} 次),{wait_seconds}s 后自动重试...", + "warning", + ) + time.sleep(wait_seconds) + continue + + # 部分网络/会话边界情况下会返回 409,做自愈重试而非直接失败。 + if response.status_code == 409 and attempt < max_attempts: + wait_seconds = min(10, 2 * attempt) + self._log( + f"{log_label}命中 409(第 {attempt}/{max_attempts} 次)," + f"会话上下文可能冲突,{wait_seconds}s 后自动重试...", + "warning", + ) + # 尝试刷新 sentinel,避免 token 过期导致冲突。 + try: + refreshed = self._check_sentinel(current_did) + if refreshed: + current_sen_token = refreshed + except Exception: + pass + # 预热一次授权页,帮助服务端重建登录上下文。 + try: + if self.oauth_start and getattr(self.oauth_start, "auth_url", None): + self.session.get(str(self.oauth_start.auth_url), timeout=12) + except Exception: + pass + time.sleep(wait_seconds) + continue + + 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._otp_sent_at = time.time() + if record_existing_account: + self._log(f"检测到已注册账号,将自动切换到登录流程") + self._is_existing_account = True + else: + self._log("登录流程已触发,等待系统自动发送的验证码") + + 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: + if attempt < max_attempts: + self._log( + f"{log_label}异常(第 {attempt}/{max_attempts} 次): {e},准备重试...", + "warning", + ) + time.sleep(2 * attempt) + continue + self._log(f"{log_label}失败: {e}", "error") + return SignupFormResult(success=False, error_message=str(e)) + + return SignupFormResult(success=False, error_message=f"{log_label}失败: 超过最大重试次数") + + def _submit_signup_form( + self, + did: str, + sen_token: Optional[str], + *, + record_existing_account: bool = True, + ) -> SignupFormResult: + """提交注册入口表单。""" + return self._submit_auth_start( + did, + sen_token, + screen_hint="signup", + referer="https://auth.openai.com/create-account", + log_label="提交注册表单", + record_existing_account=record_existing_account, + ) + + def _submit_login_start(self, did: str, sen_token: Optional[str]) -> SignupFormResult: + """提交登录入口表单。""" + return self._submit_auth_start( + did, + sen_token, + screen_hint="login", + referer="https://auth.openai.com/log-in", + log_label="提交登录入口", + record_existing_account=False, + ) + + def _submit_login_password(self) -> SignupFormResult: + """提交登录密码,进入邮箱验证码页面。""" + max_attempts = 3 + password_text = str(self.password or "").strip() + if not password_text and self.email: + try: + with get_db() as db: + account = crud.get_account_by_email(db, self.email) + db_password = str(getattr(account, "password", "") or "").strip() if account else "" + if db_password: + self.password = db_password + password_text = db_password + self._log("登录阶段未发现内存密码,已从账号库回填密码") + except Exception as e: + self._log(f"登录阶段尝试回填密码失败: {e}", "warning") + + if not password_text: + return SignupFormResult( + success=False, + error_message="登录密码为空:该邮箱可能是已存在账号但当前任务未持有密码", + ) + + for attempt in range(1, max_attempts + 1): + try: + response = self.session.post( + OPENAI_API_ENDPOINTS["password_verify"], + headers={ + "referer": "https://auth.openai.com/log-in/password", + "accept": "application/json", + "content-type": "application/json", + }, + data=json.dumps({"password": self.password}), + ) + + self._log(f"提交登录密码状态: {response.status_code}") + + if response.status_code == 429 and attempt < max_attempts: + wait_seconds = min(18, 5 * attempt) + self._log( + f"提交登录密码命中限流 429(第 {attempt}/{max_attempts} 次),{wait_seconds}s 后自动重试...", + "warning", + ) + time.sleep(wait_seconds) + continue + + if response.status_code == 401 and attempt < max_attempts: + body = str(response.text or "") + if "invalid_username_or_password" in body: + wait_seconds = min(12, 3 * attempt) + self._log( + f"提交登录密码命中 401(第 {attempt}/{max_attempts} 次)," + f"疑似密码尚未生效或历史账号密码不一致,{wait_seconds}s 后自动重试...", + "warning", + ) + time.sleep(wait_seconds) + continue + + if response.status_code != 200: + return SignupFormResult( + success=False, + error_message=f"HTTP {response.status_code}: {response.text[:200]}" + ) + + 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._otp_sent_at = time.time() + self._log("登录密码校验通过,等待系统自动发送的验证码") + + return SignupFormResult( + success=True, + page_type=page_type, + is_existing_account=is_existing, + response_data=response_data, + ) + + except Exception as e: + if attempt < max_attempts: + self._log( + f"提交登录密码异常(第 {attempt}/{max_attempts} 次): {e},准备重试...", + "warning", + ) + time.sleep(2 * attempt) + continue + self._log(f"提交登录密码失败: {e}", "error") + return SignupFormResult(success=False, error_message=str(e)) + + return SignupFormResult(success=False, error_message="提交登录密码失败: 超过最大重试次数") + + def _reset_auth_flow(self) -> None: + """重置会话,准备重新发起 OAuth 流程。""" + self.http_client.close() + self.session = None + self.oauth_start = None + self.session_token = None + self._otp_sent_at = None + + def _prepare_authorize_flow(self, label: str) -> Tuple[Optional[str], Optional[str]]: + """初始化当前阶段的授权流程,返回 device id 和 sentinel token。""" + self._log(f"{label}: 先把会话热热身...") + if not self._init_session(): + return None, None + + self._log(f"{label}: OAuth 流程准备开跑,系好鞋带...") + if not self._start_oauth(): + return None, None + + self._log(f"{label}: 领取 Device ID 通行证...") + did = str(self._get_device_id() or "").strip() + if not did: + return None, None + + self.device_id = did + + self._log(f"{label}: 解一道 Sentinel POW 小题,答对才给进...") + sen_token = self._check_sentinel(did) + if not sen_token: + return did, None + + self._log(f"{label}: Sentinel 点头放行,继续前进") + return did, sen_token + + @staticmethod + def _extract_session_token_from_cookie_text(cookie_text: str) -> str: + """从 Cookie 文本中提取 next-auth session token(兼容分片)。""" + text = str(cookie_text or "") + if not text: + return "" + + direct = re.search(r"(?:^|[;,]\s*)(?:__|_)Secure-next-auth\.session-token=([^;,]*)", text) + if direct: + direct_val = str(direct.group(1) or "").strip().strip('"').strip("'") + if direct_val: + return direct_val + + parts = re.findall(r"(?:__|_)Secure-next-auth\.session-token\.(\d+)=([^;,]*)", text) + if not parts: + return "" + + chunk_map = {} + for idx, value in parts: + try: + clean_value = str(value or "").strip().strip('"').strip("'") + if clean_value: + chunk_map[int(idx)] = clean_value + except Exception: + continue + if not chunk_map: + return "" + return "".join(chunk_map[i] for i in sorted(chunk_map.keys())) + + def _warmup_chatgpt_session(self) -> None: + """ + 仅预热 chatgpt 首页,避免提前消费一次性 continue_url。 + """ + try: + self.session.get( + "https://chatgpt.com/", + headers={ + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "referer": "https://auth.openai.com/", + "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" + ), + }, + timeout=20, + ) + except Exception as e: + self._log(f"chatgpt 首页预热异常: {e}", "warning") + + def _capture_auth_session_tokens(self, result: RegistrationResult, access_hint: Optional[str] = None) -> bool: + """ + 直接通过 /api/auth/session 捕获 session_token + access_token。 + 这是 ABCard Phase 1 的关键路径。 + """ + access_token = str(access_hint or "").strip() + set_cookie_text = "" + request_cookie_text = "" + try: + headers = { + "accept": "application/json", + "referer": "https://chatgpt.com/", + "origin": "https://chatgpt.com", + "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" + ), + "cache-control": "no-cache", + "pragma": "no-cache", + } + if access_token: + headers["authorization"] = f"Bearer {access_token}" + response = self.session.get( + "https://chatgpt.com/api/auth/session", + headers=headers, + timeout=20, + ) + set_cookie_text = self._flatten_set_cookie_headers(response) + request_cookie_text = self._extract_request_cookie_header(response) + if response.status_code == 200: + try: + data = response.json() or {} + access_from_json = str(data.get("accessToken") or "").strip() + if access_from_json: + access_token = access_from_json + except Exception: + pass + else: + self._log(f"/api/auth/session 返回异常状态: {response.status_code}", "warning") + except Exception as e: + self._log(f"获取 auth/session 失败: {e}", "warning") + + # 1) 直接从 cookie jar 拿 + session_token = self._extract_session_token_from_cookie_jar(self.session.cookies) + + # 2) 从完整 cookies 文本兜底(含分片) + if not session_token: + session_token = self._extract_session_token_from_cookie_text(self._dump_session_cookies()) + + # 3) 从 set-cookie 兜底(含分片) + if not session_token and set_cookie_text: + session_token = self._extract_session_token_from_cookie_text(set_cookie_text) + + # 4) 从请求 Cookie 头兜底(对齐 F12 Network 观测) + if not session_token and request_cookie_text: + session_token = self._extract_session_token_from_cookie_text(request_cookie_text) + + # 兜底:已有 access_token 但无 session_token 时,带 Bearer 再请求一次 auth/session + if (not session_token) and access_token: + try: + retry_response = self.session.get( + "https://chatgpt.com/api/auth/session", + headers={ + "accept": "application/json", + "referer": "https://chatgpt.com/", + "origin": "https://chatgpt.com", + "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" + ), + "authorization": f"Bearer {access_token}", + "cache-control": "no-cache", + "pragma": "no-cache", + }, + timeout=20, + ) + retry_set_cookie = self._flatten_set_cookie_headers(retry_response) + retry_request_cookie = self._extract_request_cookie_header(retry_response) + if not session_token: + session_token = self._extract_session_token_from_cookie_jar(self.session.cookies) + if not session_token: + session_token = self._extract_session_token_from_cookie_text(self._dump_session_cookies()) + if not session_token and retry_set_cookie: + session_token = self._extract_session_token_from_cookie_text(retry_set_cookie) + if not session_token and retry_request_cookie: + session_token = self._extract_session_token_from_cookie_text(retry_request_cookie) + except Exception as e: + self._log(f"Bearer 兜底换 session_token 失败: {e}", "warning") + + if not session_token: + cookies_text = self._dump_session_cookies() + raw_direct_match = re.search( + r"(?:^|[;,]\s*)(?:__|_)Secure-next-auth\.session-token=([^;,]*)", + cookies_text, + ) + raw_direct_len = len(str(raw_direct_match.group(1) or "").strip()) if raw_direct_match else 0 + chunk_count = len(re.findall(r"(?:__|_)Secure-next-auth\.session-token\.(\d+)=", cookies_text)) + req_cookie_len = len(str(request_cookie_text or "").strip()) + self._log( + f"auth/session 仍未命中 session_token(raw_direct_len={raw_direct_len}, chunks={chunk_count}, req_cookie_len={req_cookie_len})", + "warning", + ) + + # 设备 ID 同步 + did = "" + try: + did = str(self.session.cookies.get("oai-did") or "").strip() + except Exception: + did = "" + if did: + self.device_id = did + result.device_id = did + + if session_token: + self.session_token = session_token + result.session_token = session_token + if access_token: + result.access_token = access_token + + self._log( + "Auth Session 捕获结果: session_token=" + + ("有" if bool(result.session_token) else "无") + + ", access_token=" + + ("有" if bool(result.access_token) else "无") + ) + return bool(result.session_token and result.access_token) + + def _bootstrap_chatgpt_signin_for_session(self, result: RegistrationResult) -> bool: + """ + 对齐 ABCard 的补会话路径: + csrf -> signin/openai -> 跟随跳转 -> auth/session,目标是拿到 session_token。 + """ + self._log("Session Token 还没就位,尝试 ABCard 同款会话桥接...") + self._warmup_chatgpt_session() + csrf_token = "" + auth_url = "" + try: + csrf_resp = self.session.get( + "https://chatgpt.com/api/auth/csrf", + headers={ + "accept": "application/json", + "referer": "https://chatgpt.com/auth/login", + "origin": "https://chatgpt.com", + "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" + ), + }, + timeout=20, + ) + if csrf_resp.status_code == 200: + csrf_token = str((csrf_resp.json() or {}).get("csrfToken") or "").strip() + else: + self._log(f"csrf 获取失败: HTTP {csrf_resp.status_code}", "warning") + except Exception as e: + self._log(f"csrf 获取异常: {e}", "warning") + + if not csrf_token: + self._log("csrf token 为空,跳过会话桥接", "warning") + return False + + try: + signin_resp = self.session.post( + "https://chatgpt.com/api/auth/signin/openai", + headers={ + "accept": "application/json", + "content-type": "application/x-www-form-urlencoded", + "origin": "https://chatgpt.com", + "referer": "https://chatgpt.com/auth/login", + "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" + ), + }, + data={ + "csrfToken": csrf_token, + "callbackUrl": "https://chatgpt.com/", + "json": "true", + }, + timeout=20, + ) + if signin_resp.status_code == 200: + auth_url = str((signin_resp.json() or {}).get("url") or "").strip() + else: + self._log(f"signin/openai 失败: HTTP {signin_resp.status_code}", "warning") + except Exception as e: + self._log(f"signin/openai 异常: {e}", "warning") + + if not auth_url: + self._log("signin/openai 未返回 auth_url,跳过会话桥接", "warning") + return False + + callback_url = "" + final_url = auth_url + try: + callback_url, final_url = self._follow_chatgpt_auth_redirects(auth_url) + except Exception as e: + self._log(f"会话桥接重定向跟踪异常: {e}", "warning") + callback_url = "" + final_url = auth_url + + # 若已拿到 callback,补打一跳确保 next-auth callback 被完整执行。 + if callback_url and "error=" not in callback_url: + try: + self.session.get( + callback_url, + headers={ + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "referer": "https://chatgpt.com/auth/login", + "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" + ), + }, + allow_redirects=True, + timeout=25, + ) + except Exception as e: + self._log(f"会话桥接 callback 补跳异常: {e}", "warning") + elif callback_url and "error=" in callback_url: + self._log(f"会话桥接回调返回错误参数: {callback_url[:140]}...", "warning") + else: + self._log(f"会话桥接未命中 callback,final_url={final_url[:120]}...", "warning") + # 命中 auth.openai 登录页时,尝试自动登录补会话(对齐 ABCard 的登录态建立思路)。 + if "auth.openai.com/log-in" in str(final_url or "").lower(): + self._log("会话桥接进入登录页,尝试自动登录后继续抓取 session_token...") + if self._bridge_login_for_session_token(result): + return True + + self._warmup_chatgpt_session() + cookie_text = self._dump_session_cookies() + direct_token = self._extract_session_token_from_cookie_text(cookie_text) + has_direct = bool(direct_token) + chunk_count = len(re.findall(r"(?:__|_)Secure-next-auth\.session-token\.(\d+)=", cookie_text)) + if direct_token and not result.session_token: + self.session_token = direct_token + result.session_token = direct_token + self._log(f"会话桥接已缓存 session_token(len={len(direct_token)})") + self._log( + f"会话桥接后 cookie 概览: direct={'有' if has_direct else '无'}, chunks={chunk_count}" + ) + return self._capture_auth_session_tokens(result, access_hint=result.access_token) + + def _bridge_login_for_session_token(self, result: RegistrationResult) -> bool: + """ + 当 chatgpt signin/openai 跳回 auth.openai 登录页时,自动补一次登录流程: + login -> password -> email otp -> workspace -> auth/session。 + """ + try: + if not self.email or not self.password: + self._log("会话桥接自动登录缺少邮箱或密码,无法继续", "warning") + return False + + did = "" + try: + did = str(self.session.cookies.get("oai-did") or "").strip() + except Exception: + did = "" + if not did: + did = str(uuid.uuid4()) + try: + self.session.cookies.set("oai-did", did, domain=".chatgpt.com", path="/") + except Exception: + pass + self.device_id = did + result.device_id = result.device_id or did + + sen_token = self._check_sentinel(did) + login_start_result = self._submit_login_start(did, sen_token) + if not login_start_result.success: + self._log( + f"会话桥接自动登录入口失败: {login_start_result.error_message}", + "warning", + ) + return False + page_type = str(login_start_result.page_type or "").strip() + if page_type == OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]: + self._log("会话桥接自动登录已直达邮箱验证码页,跳过密码提交") + elif page_type == OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]: + password_result = self._submit_login_password() + if not password_result.success: + self._log( + f"会话桥接自动登录提交密码失败: {password_result.error_message}", + "warning", + ) + return False + if not password_result.is_existing_account: + self._log( + f"会话桥接自动登录未进入邮箱验证码页: {password_result.page_type or 'unknown'}", + "warning", + ) + return False + else: + self._log( + f"会话桥接自动登录入口返回未知页面: {page_type or 'unknown'}", + "warning", + ) + return False + + if not self._verify_email_otp_with_retry(stage_label="会话桥接登录验证码", max_attempts=3): + self._log("会话桥接自动登录验证码校验失败", "warning") + return False + + # OTP 成功后先直接抓一次 auth/session,避免无谓依赖 workspace 流程。 + self._warmup_chatgpt_session() + if self._capture_auth_session_tokens(result, access_hint=result.access_token): + self._log("会话桥接自动登录在 OTP 后已命中 session_token") + return True + + workspace_id = self._get_workspace_id() + if not workspace_id: + workspace_id = str(result.workspace_id or "").strip() + if workspace_id: + self._log(f"会话桥接自动登录复用已知 workspace_id: {workspace_id}") + if not workspace_id: + self._log("会话桥接自动登录未获取到 workspace_id", "warning") + return False + result.workspace_id = workspace_id + + continue_url = self._select_workspace(workspace_id) + if not continue_url: + cached_continue = str(self._create_account_continue_url or "").strip() + if cached_continue: + continue_url = cached_continue + self._log("会话桥接自动登录未获取到 continue_url,改用 create_account 缓存 continue_url", "warning") + else: + self._log("会话桥接自动登录未获取到 continue_url", "warning") + return False + + callback_url, final_url = self._follow_redirects(continue_url) + self._log( + f"会话桥接自动登录重定向完成: callback={'有' if callback_url else '无'}, final={str(final_url or '')[:100]}..." + ) + + self._warmup_chatgpt_session() + return self._capture_auth_session_tokens(result, access_hint=result.access_token) + except Exception as e: + self._log(f"会话桥接自动登录异常: {e}", "warning") + return False + + def _follow_chatgpt_auth_redirects(self, start_url: str) -> Tuple[str, str]: + """ + 对齐 ABCard 的 next-auth 重定向跟踪: + - 手动跟踪 30x + - 识别 /api/auth/callback/openai + Returns: + (callback_url, final_url) + """ + import urllib.parse + + current_url = str(start_url or "").strip() + callback_url = "" + bridged_header_token = "" + if not current_url: + return "", "" + + max_redirects = 12 + ua = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + ) + for i in range(max_redirects): + self._log(f"会话桥接重定向 {i+1}/{max_redirects}: {current_url[:120]}...") + if "/api/auth/callback/openai" in current_url and not callback_url: + callback_url = current_url + + resp = self.session.get( + current_url, + headers={ + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "referer": "https://chatgpt.com/", + "user-agent": ua, + }, + timeout=25, + allow_redirects=False, + ) + + # 直接从每一跳响应头 Set-Cookie 抓 session_token(对齐 F12 Network 视角) + set_cookie_text = self._flatten_set_cookie_headers(resp) + token_from_header = self._extract_session_token_from_cookie_text(set_cookie_text) + if token_from_header: + bridged_header_token = token_from_header + # 同时写入两种命名兼容,避免库在不同平台下键名差异。 + for name in ("__Secure-next-auth.session-token", "_Secure-next-auth.session-token"): + for domain in (".chatgpt.com", "chatgpt.com"): + try: + self.session.cookies.set(name, token_from_header, domain=domain, path="/") + except Exception: + continue + self._log( + f"会话桥接命中 Set-Cookie session_token(len={len(token_from_header)})" + ) + + if resp.status_code not in (301, 302, 303, 307, 308): + break + + location = str(resp.headers.get("Location") or "").strip() + if not location: + break + current_url = urllib.parse.urljoin(current_url, location) + + if callback_url and not str(current_url or "").startswith("https://chatgpt.com/"): + try: + self.session.get( + "https://chatgpt.com/", + headers={ + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "referer": current_url, + "user-agent": ua, + }, + timeout=20, + ) + except Exception: + pass + + self._log( + f"会话桥接重定向结束: callback={'有' if callback_url else '无'}, " + f"set_cookie_token={'有' if bool(bridged_header_token) else '无'}, final={current_url[:120]}..." + ) + return callback_url, current_url + + def _complete_token_exchange(self, result: RegistrationResult, require_login_otp: bool = True) -> bool: + """在登录态已建立后,补齐 session/access,并尽量获取 OAuth token。""" + if require_login_otp: + self._log("等待登录验证码到场,最后这位嘉宾还在路上...") + self._log("核对登录验证码,验明正身一下...") + if not self._verify_email_otp_with_retry(stage_label="登录验证码", max_attempts=3): + result.error_message = "验证码校验失败" + return False + else: + self._log("ABCard 入口链路:跳过二次登录验证码,直接进入 workspace + redirect + auth/session 抓取") + + self._log("摸一下 Workspace ID,看看该坐哪桌...") + workspace_id = self._get_workspace_id() + continue_url = "" + if workspace_id: + result.workspace_id = workspace_id + + self._log("选择 Workspace,安排个靠谱座位...") + continue_url = self._select_workspace(workspace_id) + if not continue_url: + cached_continue = str(self._create_account_continue_url or "").strip() + if cached_continue: + continue_url = cached_continue + self._log("workspace/select 未返回 continue_url,改用 create_account 缓存 continue_url", "warning") + else: + result.error_message = "选择 Workspace 失败" + return False + else: + cached_continue = str(self._create_account_continue_url or "").strip() + if cached_continue: + continue_url = cached_continue + self._log("未获取到 Workspace ID,改用 create_account 缓存 continue_url 继续链路", "warning") + else: + result.error_message = "获取 Workspace ID 失败" + return False + + self._log("顺着重定向面包屑往前走,别跟丢了...") + callback_url, final_url = self._follow_redirects(continue_url) + self._log( + f"重定向链完成,callback={'有' if callback_url else '无'},final={final_url[:100]}..." + ) + self._log("重定向链结束,直接请求 /api/auth/session 抓取 session/access...") + captured = self._capture_auth_session_tokens(result, access_hint=result.access_token) + if not captured: + self._log("直抓未命中,补一次 chatgpt 预热后再抓取...", "warning") + self._warmup_chatgpt_session() + captured = self._capture_auth_session_tokens(result, access_hint=result.access_token) + final_url_lower = str(final_url or "").lower() + add_phone_gate = ("auth.openai.com/add-phone" in final_url_lower) + + # ABCard 入口常见失败点:被 add-phone 风控页截断,导致拿不到 callback/session。 + if add_phone_gate and (not callback_url) and (not captured): + self._log("检测到 auth.openai.com/add-phone 风控页,当前链路未完成 OAuth 回调", "warning") + if (not require_login_otp) and (not self._is_existing_account): + self._log("ABCard 入口命中 add-phone,回退原生重登链路再试一次...", "warning") + login_ready, login_error = self._restart_login_flow() + if not login_ready: + result.error_message = f"ABCard 回退原生链路失败: {login_error}" + return False + return self._complete_token_exchange(result, require_login_otp=True) + result.error_message = "命中 add-phone 风控页,未获取到 session_token" + return False + + callback_has_error = bool( + callback_url and ("error=" in callback_url) and ("code=" not in callback_url) + ) + if callback_url: + if callback_has_error: + self._log(f"回调返回错误参数,跳过 OAuth 回调: {callback_url[:140]}...", "warning") + if not captured: + result.error_message = "OAuth 回调返回 access_denied,且未获取到 auth/session" + return False + else: + self._log("处理 OAuth 回调,准备把 token 请出来...") + token_info = self._handle_oauth_callback(callback_url) + if token_info: + result.account_id = token_info.get("account_id", "") + result.access_token = token_info.get("access_token", "") or result.access_token + result.refresh_token = token_info.get("refresh_token", "") + result.id_token = token_info.get("id_token", "") + elif captured: + self._log("OAuth 回调失败,但 session/access 已拿到,继续后续流程", "warning") + else: + result.error_message = "处理 OAuth 回调失败" + return False + else: + if captured: + self._log("未拿到 callback_url,但 session/access 已拿到,继续后续流程", "warning") + else: + result.error_message = "跟随重定向链失败" + return False + + result.password = self.password or "" + result.source = "login" if self._is_existing_account else "register" + result.device_id = result.device_id or str(self.device_id or "") + + 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("Session Token 也捞到了,今天这网没白连") + + if not result.access_token or not result.session_token: + # 再捞一次,避免某些链路里 session 建立稍慢 + self._capture_auth_session_tokens(result, access_hint=result.access_token) + if not result.session_token: + # 对齐 ABCard:尝试走 csrf + signin/openai 的会话桥接。 + self._bootstrap_chatgpt_signin_for_session(result) + if not result.session_token: + result.session_token = self._extract_session_token_from_cookie_text(self._dump_session_cookies()) + if not result.device_id: + result.device_id = str(self.device_id or self.session.cookies.get("oai-did") or "") + + if not result.access_token: + result.error_message = "未获取到 access_token" + return False + if not result.session_token: + native_register_flow = (self.registration_entry_flow == "native") and (not self._is_existing_account) + if native_register_flow: + # 对齐 K:\1\2 备份:原生注册流程里 session_token 不做阻断。 + self._log( + "当前链路未拿到 session_token,先保存账号并标记待补会话(可在账号详情/支付页一键补全)", + "warning", + ) + else: + # 非原生注册入口仍保持强制,避免后续流程不可用。 + if not self._ensure_session_token_strict(result, max_rounds=2): + result.error_message = "未获取到 session_token(强制要求)" + self._log( + "强制模式未拿到 session_token,本次注册判定失败,请检查网络/代理与登录回调链路", + "error", + ) + return False + + return True + + def _complete_token_exchange_native_backup(self, result: RegistrationResult) -> bool: + """ + 原生入口对齐备份版收尾链路: + 登录验证码 -> Workspace -> redirect -> OAuth callback -> token 入袋。 + """ + def _is_registration_gate_url(url: str) -> bool: + u = str(url or "").strip().lower() + if not u: + return False + return ("auth.openai.com/about-you" in u) or ("auth.openai.com/add-phone" in u) + + self._log("等待登录验证码到场,最后这位嘉宾还在路上...") + self._log("核对登录验证码,验明正身一下...") + login_otp_tried_codes: set[str] = set() + login_otp_ok = self._verify_email_otp_with_retry( + stage_label="登录验证码", + max_attempts=1, + fetch_timeout=120, + attempted_codes=login_otp_tried_codes, + ) + if not login_otp_ok: + self._log("登录验证码首轮未命中,尝试在当前会话原地重发 OTP 后再校验...", "warning") + resent = self._send_verification_code(referer="https://auth.openai.com/email-verification") + if resent: + login_otp_ok = self._verify_email_otp_with_retry( + stage_label="登录验证码(原地重发)", + max_attempts=2, + fetch_timeout=120, + attempted_codes=login_otp_tried_codes, + ) + + if not login_otp_ok: + self._log("登录验证码仍未命中,尝试重触发登录 OTP 后再校验...", "warning") + if not self._retrigger_login_otp(): + self._log("重触发登录 OTP 失败,尝试完整重登链路后再校验一次...", "warning") + login_ready, login_error = self._restart_login_flow() + if not login_ready: + result.error_message = f"登录验证码重触发失败,且完整重登失败: {login_error}" + return False + login_otp_ok = self._verify_email_otp_with_retry( + stage_label="登录验证码(重发)", + max_attempts=3, + fetch_timeout=120, + attempted_codes=login_otp_tried_codes, + ) + if not login_otp_ok: + result.error_message = "验证码校验失败" + return False + + self._log("摸一下 Workspace ID,看看该坐哪桌...") + workspace_id = str(self._last_validate_otp_workspace_id or "").strip() + if workspace_id: + self._log(f"使用 OTP 返回的 Workspace ID: {workspace_id}") + if not workspace_id: + workspace_id = str(self._get_workspace_id() or "").strip() + if workspace_id: + result.workspace_id = workspace_id + + continue_url = "" + otp_continue = str(self._last_validate_otp_continue_url or "").strip() + if otp_continue and _is_registration_gate_url(otp_continue): + self._log("OTP 返回 continue_url 指向注册门页(about-you/add-phone),本轮收尾忽略该地址", "warning") + otp_continue = "" + + cached_continue = str(self._create_account_continue_url or "").strip() + if cached_continue and _is_registration_gate_url(cached_continue): + self._log("create_account 缓存 continue_url 指向注册门页(about-you/add-phone),本轮收尾忽略该地址", "warning") + cached_continue = "" + + if workspace_id: + self._log("选择 Workspace,安排个靠谱座位...") + continue_url = str(self._select_workspace(workspace_id) or "").strip() + if not continue_url: + self._log("workspace/select 未返回 continue_url,尝试 OAuth authorize 兜底", "warning") + + if not continue_url: + oauth_start_url = str( + ( + getattr(self.oauth_start, "auth_url", "") + or getattr(self.oauth_start, "url", "") + if self.oauth_start + else "" + ) + or "" + ).strip() + if oauth_start_url: + continue_url = oauth_start_url + self._log("使用 OAuth authorize URL 作为兜底 continue_url", "warning") + + if not continue_url and otp_continue: + continue_url = otp_continue + self._log("使用 OTP 返回 continue_url 继续授权链路", "warning") + + if not continue_url and cached_continue: + continue_url = cached_continue + self._log("使用 create_account 缓存 continue_url 作为兜底", "warning") + + if not continue_url: + result.error_message = "获取 continue_url 失败" + return False + + self._log("顺着重定向面包屑往前走,别跟丢了...") + callback_url, _final_url = self._follow_redirects(continue_url) + if not callback_url: + self._log("未命中 OAuth 回调,尝试 auth/session 兜底抓取 token...", "warning") + self._capture_auth_session_tokens(result, access_hint=result.access_token) + if not result.account_id: + result.account_id = str(self._create_account_account_id or "").strip() + if not result.workspace_id: + result.workspace_id = str(workspace_id or self._create_account_workspace_id or "").strip() + if not result.refresh_token: + result.refresh_token = str(self._create_account_refresh_token or "").strip() + if result.access_token: + result.password = self.password or "" + result.source = "login" if self._is_existing_account else "register" + result.device_id = result.device_id or str(self.device_id or "") + self._log("未命中 callback,已通过 auth/session 兜底拿到 Access Token,继续完成注册", "warning") + return True + + # 对新注册账号放宽:账号已创建成功时允许“注册成功、token 待补” + if (not self._is_existing_account) and self._create_account_account_id: + result.account_id = result.account_id or str(self._create_account_account_id or "").strip() + result.workspace_id = result.workspace_id or str(workspace_id or self._create_account_workspace_id or "").strip() + result.refresh_token = result.refresh_token or str(self._create_account_refresh_token or "").strip() + result.password = self.password or "" + result.source = "register" + result.device_id = result.device_id or str(self.device_id or "") + self._log("回调链路未命中且未抓到 Access Token,但账号已创建成功;按注册成功收尾(token 待后续补齐)", "warning") + return True + + result.error_message = "跟随重定向链失败" + return False + + self._log("处理 OAuth 回调,准备把 token 请出来...") + token_info = self._handle_oauth_callback(callback_url) + if not token_info: + if (not self._is_existing_account) and self._create_account_account_id: + result.account_id = result.account_id or str(self._create_account_account_id or "").strip() + result.workspace_id = result.workspace_id or str(workspace_id or self._create_account_workspace_id or "").strip() + result.refresh_token = result.refresh_token or str(self._create_account_refresh_token or "").strip() + result.password = self.password or "" + result.source = "register" + result.device_id = result.device_id or str(self.device_id or "") + self._log("OAuth 回调处理失败,但账号已创建成功;按注册成功收尾(token 待后续补齐)", "warning") + return True + result.error_message = "处理 OAuth 回调失败" + return False + + 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" + result.device_id = result.device_id or str(self.device_id or "") + + 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("Session Token 也捞到了,今天这网没白连") + + return True + + def _complete_token_exchange_outlook(self, result: RegistrationResult) -> bool: + """ + Outlook 入口链路(迁移版): + 对齐 codex-console-main-clean 的收尾流程, + 走「登录 OTP -> Workspace -> OAuth callback」主干,避免 ABCard/native 增强链路干扰。 + 同时补齐“第二封验证码”重试链路,避免 Outlook 轮询卡死。 + """ + self._log("等待登录验证码到场,最后这位嘉宾还在路上...") + self._log("核对登录验证码,验明正身一下...") + login_otp_tried_codes: set[str] = set() + login_otp_ok = self._verify_email_otp_with_retry( + stage_label="登录验证码", + max_attempts=1, + fetch_timeout=90, + attempted_codes=login_otp_tried_codes, + ) + if not login_otp_ok: + self._log("登录验证码首轮未命中,先尝试当前会话原地重发 OTP 后再校验...", "warning") + resent = self._send_verification_code(referer="https://auth.openai.com/email-verification") + if resent: + login_otp_ok = self._verify_email_otp_with_retry( + stage_label="登录验证码(原地重发)", + max_attempts=2, + fetch_timeout=90, + attempted_codes=login_otp_tried_codes, + ) + + if not login_otp_ok: + self._log("登录验证码仍未命中,尝试重触发登录 OTP 后再校验...", "warning") + if not self._retrigger_login_otp(): + self._log("重触发登录 OTP 失败,尝试完整重登链路后再校验一次...", "warning") + login_ready, login_error = self._restart_login_flow() + if not login_ready: + result.error_message = f"登录验证码重触发失败,且完整重登失败: {login_error}" + return False + + login_otp_ok = self._verify_email_otp_with_retry( + stage_label="登录验证码(重发)", + max_attempts=3, + fetch_timeout=120, + attempted_codes=login_otp_tried_codes, + ) + if not login_otp_ok: + result.error_message = "验证码校验失败" + return False + + self._log("摸一下 Workspace ID,看看该坐哪桌...") + workspace_id = str(self._last_validate_otp_workspace_id or "").strip() + if workspace_id: + self._log(f"使用 OTP 返回的 Workspace ID: {workspace_id}") + if not workspace_id: + workspace_id = str(self._get_workspace_id() or "").strip() + if not workspace_id: + workspace_id = str(self._last_validate_otp_workspace_id or self._create_account_workspace_id or "").strip() + if workspace_id: + self._log(f"Workspace ID(缓存): {workspace_id}", "warning") + + continue_url = "" + if workspace_id: + result.workspace_id = workspace_id + self._log("选择 Workspace,安排个靠谱座位...") + continue_url = str(self._select_workspace(workspace_id) or "").strip() + if not continue_url: + self._log("workspace/select 未返回 continue_url,尝试使用缓存 continue_url", "warning") + else: + self._log("未获取到 Workspace ID,尝试直接使用缓存 continue_url", "warning") + + if not continue_url: + continue_url = str(self._last_validate_otp_continue_url or self._create_account_continue_url or "").strip() + if continue_url: + self._log("使用缓存 continue_url 继续授权链路", "warning") + + if not continue_url: + result.error_message = "获取 Workspace ID 失败" + return False + + self._log("顺着重定向面包屑往前走,别跟丢了...") + callback_url, _final_url = self._follow_redirects(continue_url) + if not callback_url: + result.error_message = "跟随重定向链失败" + return False + + self._log("处理 OAuth 回调,准备把 token 请出来...") + token_info = self._handle_oauth_callback(callback_url) + if not token_info: + result.error_message = "处理 OAuth 回调失败" + return False + + result.account_id = str(token_info.get("account_id") or result.account_id or "").strip() + result.access_token = str(token_info.get("access_token") or result.access_token or "").strip() + result.refresh_token = str(token_info.get("refresh_token") or result.refresh_token or "").strip() + result.id_token = str(token_info.get("id_token") or result.id_token or "").strip() + result.password = self.password or "" + result.source = "login" if self._is_existing_account else "register" + result.device_id = result.device_id or str(self.device_id or "") + + if not result.account_id: + result.account_id = str(self._create_account_account_id or "").strip() + if not result.workspace_id: + result.workspace_id = str(self._create_account_workspace_id or "").strip() + if not result.refresh_token: + result.refresh_token = str(self._create_account_refresh_token or "").strip() + + 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("Session Token 也捞到了,今天这网没白连") + + if not result.access_token: + result.error_message = "未获取到 access_token" + return False + + return True + + def _ensure_session_token_strict(self, result: RegistrationResult, max_rounds: int = 2) -> bool: + """ + 强制确保 session_token 可用。 + - 先走 auth/session 直抓 + - 再走 ABCard 同款会话桥接 + 连续多轮失败则返回 False。 + """ + if result.session_token: + return True + + rounds = max(int(max_rounds), 1) + for idx in range(rounds): + self._log(f"强制补会话 round {idx + 1}/{rounds}:尝试补抓 session_token ...") + + self._warmup_chatgpt_session() + self._capture_auth_session_tokens(result, access_hint=result.access_token) + if result.session_token: + self._log("强制补会话成功:auth/session 已拿到 session_token") + return True + + self._bootstrap_chatgpt_signin_for_session(result) + if result.session_token: + self._log("强制补会话成功:桥接链路已拿到 session_token") + return True + + fallback_token = self._extract_session_token_from_cookie_text(self._dump_session_cookies()) + if fallback_token: + result.session_token = fallback_token + self.session_token = fallback_token + self._log("强制补会话成功:cookie 文本兜底命中 session_token") + return True + + self._log("强制补会话本轮未命中 session_token", "warning") + + return False + + def _capture_native_core_tokens(self, result: RegistrationResult) -> bool: + """ + 原生注册入口的轻量 token 抓取: + - 不做二次登录 + - 不强依赖 session_token + - 尽量补齐 account/workspace/access/refresh + """ + try: + client_id = str(getattr(self.oauth_manager, "client_id", "") or "").strip() + if client_id: + self._log(f"原生入口 token 抓取: Client ID: {client_id}") + + if (not result.account_id) and self._create_account_account_id: + result.account_id = str(self._create_account_account_id or "").strip() + self._log(f"原生入口 token 抓取: 复用 create_account Account ID: {result.account_id}") + if (not result.refresh_token) and self._create_account_refresh_token: + result.refresh_token = str(self._create_account_refresh_token or "").strip() + self._log("原生入口 token 抓取: 复用 create_account Refresh Token") + + workspace_id = str(result.workspace_id or "").strip() + if not workspace_id: + workspace_id = str(self._create_account_workspace_id or "").strip() + if not workspace_id: + workspace_id = str(self._get_workspace_id() or "").strip() + if workspace_id: + result.workspace_id = workspace_id + self._log(f"原生入口 token 抓取: Workspace ID: {workspace_id}") + else: + self._log("原生入口 token 抓取: 未获取到 Workspace ID", "warning") + + continue_url = "" + if workspace_id: + continue_url = str(self._select_workspace(workspace_id) or "").strip() + if not continue_url: + cached_continue = str(self._create_account_continue_url or "").strip() + if cached_continue: + continue_url = cached_continue + self._log("原生入口 token 抓取: 使用 create_account 缓存 continue_url", "warning") + + callback_url: Optional[str] = None + final_url = "" + if continue_url: + self._log("原生入口 token 抓取: 跟随重定向链获取 OAuth callback...") + callback_url, final_url = self._follow_redirects(continue_url) + self._log( + f"原生入口 token 抓取: 重定向完成,callback={'有' if callback_url else '无'},final={str(final_url)[:100]}..." + ) + else: + self._log("原生入口 token 抓取: 未获得 continue_url,跳过 callback 交换", "warning") + + callback_has_error = bool( + callback_url and ("error=" in callback_url) and ("code=" not in callback_url) + ) + if callback_url and (not callback_has_error): + token_info = self._handle_oauth_callback(callback_url) + if token_info: + result.account_id = str(token_info.get("account_id") or result.account_id or "").strip() + result.access_token = str(token_info.get("access_token") or result.access_token or "").strip() + result.refresh_token = str(token_info.get("refresh_token") or result.refresh_token or "").strip() + result.id_token = str(token_info.get("id_token") or result.id_token or "").strip() + self._log( + "原生入口 token 抓取结果: " + f"account_id={'有' if bool(result.account_id) else '无'}, " + f"access={'有' if bool(result.access_token) else '无'}, " + f"refresh={'有' if bool(result.refresh_token) else '无'}" + ) + else: + self._log("原生入口 token 抓取: OAuth 回调处理失败", "warning") + elif callback_has_error: + self._log(f"原生入口 token 抓取: callback 含 error,跳过 token 交换: {callback_url[:140]}...", "warning") + else: + self._log("原生入口 token 抓取: 未命中 callback_url", "warning") + + # 不走重登,仅轻量探测 auth/session 里的 accessToken(不依赖 session_token)。 + if not result.access_token: + self._capture_access_token_light(result) + + if (not result.account_id) and result.id_token: + try: + account_info = self.oauth_manager.extract_account_info(result.id_token) + result.account_id = str(account_info.get("account_id") or "").strip() + except Exception: + pass + if (not result.account_id) and result.access_token: + token_acc = self._extract_account_id_from_access_token(result.access_token) + if token_acc: + result.account_id = token_acc + self._log(f"原生入口 token 抓取: 从 access_token 解析 Account ID: {token_acc}") + if not result.workspace_id: + try: + workspace_id_after = str(self._get_workspace_id() or "").strip() + if workspace_id_after: + result.workspace_id = workspace_id_after + self._log(f"原生入口 token 抓取: 二次获取 Workspace ID 成功: {workspace_id_after}") + except Exception: + pass + + missing = [] + if not result.account_id: + missing.append("Account ID") + if not result.workspace_id: + missing.append("Workspace ID") + if not result.access_token: + missing.append("Access Token") + if not result.refresh_token: + missing.append("Refresh Token") + if missing: + self._log(f"原生入口 token 抓取: 未获取字段 -> {', '.join(missing)}", "warning") + + return bool(result.access_token and result.refresh_token) + except Exception as e: + self._log(f"原生入口 token 抓取异常: {e}", "warning") + return False + + def _capture_access_token_light(self, result: RegistrationResult) -> bool: + """轻量从 /api/auth/session 抓 accessToken(不依赖 session_token)。""" + try: + response = self.session.get( + "https://chatgpt.com/api/auth/session", + headers={ + "accept": "application/json", + "referer": "https://chatgpt.com/", + }, + timeout=20, + ) + if response.status_code != 200: + self._log(f"原生入口轻量 auth/session 状态异常: {response.status_code}", "warning") + return False + data = response.json() or {} + access_token = str(data.get("accessToken") or "").strip() + if access_token: + result.access_token = access_token + self._log("原生入口轻量 auth/session 命中 Access Token") + return True + self._log("原生入口轻量 auth/session 未命中 Access Token", "warning") + return False + except Exception as e: + self._log(f"原生入口轻量 auth/session 异常: {e}", "warning") + return False + + def _extract_account_id_from_access_token(self, access_token: str) -> str: + """从 access_token 的 JWT payload 尝试解析 chatgpt_account_id。""" + try: + raw = str(access_token or "").strip() + if raw.count(".") < 2: + return "" + payload = raw.split(".")[1] + import base64 + pad = "=" * ((4 - (len(payload) % 4)) % 4) + decoded = base64.urlsafe_b64decode((payload + pad).encode("ascii")) + claims = json.loads(decoded.decode("utf-8")) + if not isinstance(claims, dict): + return "" + auth_claims = claims.get("https://api.openai.com/auth") or {} + account_id = str( + auth_claims.get("chatgpt_account_id") + or claims.get("chatgpt_account_id") + or "" + ).strip() + return account_id + except Exception: + return "" + + def _ensure_native_required_tokens(self, result: RegistrationResult) -> bool: + """ + 原生注册入口要求拿齐: + Account ID / Workspace ID / Client ID / Access Token / Refresh Token + """ + try: + if (not result.account_id) and result.id_token: + try: + account_info = self.oauth_manager.extract_account_info(result.id_token) + result.account_id = str(account_info.get("account_id") or "").strip() + except Exception: + pass + if (not result.account_id) and result.access_token: + result.account_id = self._extract_account_id_from_access_token(result.access_token) + + if not result.workspace_id: + result.workspace_id = str(self._get_workspace_id() or "").strip() + if (not result.refresh_token) and self._create_account_refresh_token: + result.refresh_token = str(self._create_account_refresh_token or "").strip() + + settings = get_settings() + client_id = str( + getattr(settings, "openai_client_id", "") + or getattr(self.oauth_manager, "client_id", "") + or "" + ).strip() + + missing = [] + if not result.account_id: + missing.append("Account ID") + if not result.workspace_id: + missing.append("Workspace ID") + if not client_id: + missing.append("Client ID") + if not result.access_token: + missing.append("Access Token") + if not result.refresh_token: + missing.append("Refresh Token") + + if missing: + self._log(f"原生入口关键参数缺失: {', '.join(missing)}", "error") + return False + + self._log( + "原生入口关键参数校验通过: " + f"Account ID={result.account_id}, Workspace ID={result.workspace_id}, " + f"Client ID={client_id}, Access=有, Refresh=有" + ) + return True + except Exception as e: + self._log(f"原生入口关键参数校验异常: {e}", "error") + return False + + def _restart_login_flow(self) -> Tuple[bool, str]: + """新注册账号完成建号后,重新发起一次登录流程拿 token。""" + self._token_acquisition_requires_login = True + self._log("注册这边忙完了,再走一趟登录把 token 请出来,收个尾...") + self._reset_auth_flow() + + did, sen_token = self._prepare_authorize_flow("重新登录") + if not did: + return False, "重新登录时获取 Device ID 失败" + if not sen_token: + return False, "重新登录时 Sentinel POW 验证失败" + + login_start_result = self._submit_login_start(did, sen_token) + if not login_start_result.success: + return False, f"重新登录提交邮箱失败: {login_start_result.error_message}" + if login_start_result.page_type != OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]: + return False, f"重新登录未进入密码页面: {login_start_result.page_type or 'unknown'}" + + password_result = self._submit_login_password() + if not password_result.success: + return False, f"重新登录提交密码失败: {password_result.error_message}" + if not password_result.is_existing_account: + return False, f"重新登录未进入验证码页面: {password_result.page_type or 'unknown'}" + return True, "" + + def _retrigger_login_otp(self) -> bool: + """ + 在“登录验证码”阶段重触发 OTP 发送。 + 优先复用登录链路(login_start -> login_password),避免误走注册 OTP 流程。 + """ + try: + did = str(self.device_id or self.session.cookies.get("oai-did") or "").strip() + if not did: + did = str(uuid.uuid4()) + try: + self.session.cookies.set("oai-did", did, domain=".chatgpt.com", path="/") + except Exception: + pass + self.device_id = did + + sen_token = self._check_sentinel(did) + login_start_result = self._submit_login_start(did, sen_token) + if not login_start_result.success: + self._log( + f"重触发登录 OTP 失败:提交登录入口失败: {login_start_result.error_message}", + "warning", + ) + return False + + page_type = str(login_start_result.page_type or "").strip() + if page_type == OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]: + self._log("重触发登录 OTP 成功:已直达邮箱验证码页") + return True + + if page_type != OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]: + self._log(f"重触发登录 OTP 失败:未进入密码页({page_type or 'unknown'})", "warning") + return False + + password_result = self._submit_login_password() + if not password_result.success: + self._log(f"重触发登录 OTP 失败:提交登录密码失败: {password_result.error_message}", "warning") + return False + if not password_result.is_existing_account: + self._log( + f"重触发登录 OTP 失败:密码后未进入验证码页({password_result.page_type or 'unknown'})", + "warning", + ) + return False + + self._log("重触发登录 OTP 成功:已进入邮箱验证码页") + return True + except Exception as e: + self._log(f"重触发登录 OTP 异常: {e}", "warning") + return False + + def _register_password(self, did: Optional[str] = None, sen_token: Optional[str] = None) -> Tuple[bool, Optional[str]]: + """注册密码""" + try: + self._last_register_password_error = None + # 生成密码 + 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", "") + normalized_error_msg = str(error_msg or "").strip() + normalized_error_code = str(error_code or "").strip() + + # 检测邮箱已注册的情况 + if "already" in normalized_error_msg.lower() or "exists" in normalized_error_msg.lower() or normalized_error_code == "user_exists": + self._log(f"邮箱 {self.email} 可能已在 OpenAI 注册过", "error") + # 标记此邮箱为已注册状态 + self._mark_email_as_registered() + self._last_register_password_error = "该邮箱可能已在 OpenAI 注册,建议更换邮箱或改走登录流程" + elif "failed to register username" in normalized_error_msg.lower(): + self._last_register_password_error = ( + "OpenAI 拒绝当前邮箱用户名(可能已占用或触发风控),建议更换邮箱后重试" + ) + if did: + self._log("检测到用户名注册失败,尝试登录入口探测邮箱是否已存在...", "warning") + try: + probe = self._submit_login_start(did, sen_token) + if probe.success and probe.page_type in ( + OPENAI_PAGE_TYPES["LOGIN_PASSWORD"], + OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"], + ): + self._log("登录入口探测命中:该邮箱大概率已是 OpenAI 账号", "warning") + self._mark_email_as_registered() + self._last_register_password_error = ( + "该邮箱已存在 OpenAI 账号。" + "若是刚刚注册中断,请优先使用上一轮任务日志里的“生成密码”走登录续跑;" + "拿不到旧密码再更换邮箱。" + ) + except Exception as probe_error: + self._log(f"登录入口探测失败: {probe_error}", "warning") + else: + self._last_register_password_error = ( + f"注册密码接口返回异常: {normalized_error_msg or f'HTTP {response.status_code}'}" + ) + except Exception: + self._last_register_password_error = f"注册密码接口返回异常: HTTP {response.status_code}" + + return False, None + + return True, password + + except Exception as e: + self._log(f"密码注册失败: {e}", "error") + self._last_register_password_error = str(e) + 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, referer: Optional[str] = None) -> bool: + """发送验证码""" + try: + # 记录发送时间戳 + self._otp_sent_at = time.time() + send_referer = str(referer or "https://auth.openai.com/create-account/password").strip() + + response = self.session.get( + OPENAI_API_ENDPOINTS["send_otp"], + headers={ + "referer": send_referer, + "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, timeout: Optional[int] = None) -> Optional[str]: + """获取验证码""" + try: + mailbox_email = str(self.inbox_email or self.email or "").strip() + self._log(f"正在等待邮箱 {mailbox_email} 的验证码...") + + email_id = self.email_info.get("service_id") if self.email_info else None + fetch_timeout = int(timeout) if timeout and int(timeout) > 0 else 120 + code = self.email_service.get_verification_code( + email=mailbox_email, + email_id=email_id, + timeout=fetch_timeout, + 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: + self._last_otp_validation_code = str(code or "").strip() + self._last_otp_validation_status_code = None + self._last_otp_validation_outcome = "" + 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}") + self._last_otp_validation_status_code = int(response.status_code) + self._last_otp_validation_outcome = "success" if response.status_code == 200 else "http_non_200" + if response.status_code == 200: + # 记录 OTP 校验返回中的 continue/workspace 提示,供 native 收尾兜底 + try: + import urllib.parse as urlparse + payload = response.json() or {} + candidates: List[Dict[str, Any]] = [] + if isinstance(payload, dict): + candidates.append(payload) + for key in ("data", "result", "next", "payload"): + value = payload.get(key) + if isinstance(value, dict): + candidates.append(value) + + found_continue = "" + found_workspace = "" + for item in candidates: + if not isinstance(item, dict): + continue + if not found_workspace: + found_workspace = str( + item.get("workspace_id") + or item.get("workspaceId") + or item.get("default_workspace_id") + or ((item.get("workspace") or {}).get("id") if isinstance(item.get("workspace"), dict) else "") + or "" + ).strip() + if not found_continue: + for key in ("continue_url", "continueUrl", "next_url", "nextUrl", "redirect_url", "redirectUrl", "url"): + candidate = str(item.get(key) or "").strip() + if not candidate: + continue + if candidate.startswith("/"): + candidate = urlparse.urljoin(OPENAI_API_ENDPOINTS["validate_otp"], candidate) + found_continue = candidate + break + if found_workspace and found_continue: + break + + if found_workspace: + self._last_validate_otp_workspace_id = found_workspace + self._log(f"OTP 校验返回 Workspace ID: {found_workspace}") + if found_continue: + self._last_validate_otp_continue_url = found_continue + self._log(f"OTP 校验返回 continue_url: {found_continue[:100]}...") + except Exception as parse_err: + self._log(f"解析 OTP 校验返回信息失败: {parse_err}", "warning") + + return response.status_code == 200 + + except Exception as e: + err_text = str(e or "").lower() + if ( + "timed out" in err_text + or "timeout" in err_text + or "curl: (28)" in err_text + or "operation timed out" in err_text + ): + self._last_otp_validation_outcome = "network_timeout" + else: + self._last_otp_validation_outcome = "network_error" + self._log(f"验证验证码失败: {e}", "error") + return False + + def _verify_email_otp_with_retry( + self, + stage_label: str = "验证码", + max_attempts: int = 3, + fetch_timeout: Optional[int] = None, + attempted_codes: Optional[set[str]] = None, + ) -> bool: + """ + 获取并校验验证码(带重试)。 + 用于规避邮箱里历史验证码导致的 400(第一次取到旧码,第二次取新码)。 + """ + # 每轮验证码阶段开始前,清理上轮 OTP 校验缓存,避免 continue_url/workspace 被旧阶段污染。 + self._last_validate_otp_continue_url = None + self._last_validate_otp_workspace_id = None + if attempted_codes is None: + attempted_codes = set() + for attempt in range(1, max_attempts + 1): + code = ( + self._get_verification_code(timeout=fetch_timeout) + if fetch_timeout + else self._get_verification_code() + ) + if not code: + if attempt < max_attempts: + self._log( + f"{stage_label}第 {attempt}/{max_attempts} 次未取到验证码,稍后重试...", + "warning", + ) + time.sleep(2) + continue + return False + + if code in attempted_codes: + allow_same_code_retry = ( + self._last_otp_validation_code == code + and self._last_otp_validation_outcome in {"network_timeout", "network_error"} + ) + if allow_same_code_retry: + self._log( + f"{stage_label}第 {attempt}/{max_attempts} 次命中重复验证码 {code}," + f"但上次校验为网络异常({self._last_otp_validation_outcome}),重试同码...", + "warning", + ) + if self._validate_verification_code(code): + return True + if attempt < max_attempts: + time.sleep(2) + continue + return False + + if attempt < max_attempts: + self._log( + f"{stage_label}第 {attempt}/{max_attempts} 次命中重复验证码 {code},等待新邮件...", + "warning", + ) + time.sleep(2) + continue + return False + + attempted_codes.add(code) + + if self._validate_verification_code(code): + return True + + if attempt < max_attempts: + self._log( + f"{stage_label}第 {attempt}/{max_attempts} 次校验未通过,疑似旧验证码,自动重试下一封...", + "warning", + ) + time.sleep(2) + + 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 + + try: + data = response.json() or {} + continue_url = str(data.get("continue_url") or "").strip() + if continue_url: + self._create_account_continue_url = continue_url + self._log(f"create_account 返回 continue_url,已缓存: {continue_url[:100]}...") + account_id = str( + data.get("account_id") + or data.get("chatgpt_account_id") + or (data.get("account") or {}).get("id") + or "" + ).strip() + if account_id: + self._create_account_account_id = account_id + self._log(f"create_account 返回 account_id,已缓存: {account_id}") + workspace_id = str( + data.get("workspace_id") + or data.get("default_workspace_id") + or (data.get("workspace") or {}).get("id") + or "" + ).strip() + if (not workspace_id) and isinstance(data.get("workspaces"), list) and data.get("workspaces"): + workspace_id = str((data.get("workspaces")[0] or {}).get("id") or "").strip() + if workspace_id: + self._create_account_workspace_id = workspace_id + self._log(f"create_account 返回 workspace_id,已缓存: {workspace_id}") + refresh_token = str(data.get("refresh_token") or "").strip() + if refresh_token: + self._create_account_refresh_token = refresh_token + self._log("create_account 返回 refresh_token,已缓存") + except Exception: + pass + + return True + + except Exception as e: + self._log(f"创建账户失败: {e}", "error") + return False + + def _get_workspace_id(self) -> Optional[str]: + """获取 Workspace ID""" + try: + def _extract_workspace_id(payload: Any) -> str: + if not isinstance(payload, dict): + return "" + workspace_id = str( + payload.get("workspace_id") + or payload.get("default_workspace_id") + or ((payload.get("workspace") or {}).get("id") if isinstance(payload.get("workspace"), dict) else "") + or "" + ).strip() + if workspace_id: + return workspace_id + workspaces = payload.get("workspaces") or [] + if isinstance(workspaces, list) and workspaces: + return str((workspaces[0] or {}).get("id") or "").strip() + return "" + + auth_cookie = str(self.session.cookies.get("oai-client-auth-session") or "").strip() + if not auth_cookie: + self._log("未能获取到授权 Cookie,尝试从 auth-info 里取 workspace", "warning") + + # 解码 JWT + import base64 + import json as json_module + import urllib.parse as urlparse + + try: + candidate_payloads: List[str] = [] + if auth_cookie: + segments = auth_cookie.split(".") + # 对齐 ABCard:优先 JWT payload 段(第 2 段) + if len(segments) >= 2 and segments[1]: + candidate_payloads.append(segments[1]) + if segments and segments[0]: + candidate_payloads.append(segments[0]) + # 极端情况下 cookie 可能直接是 JSON 字符串 + candidate_payloads.append(auth_cookie) + + for payload in candidate_payloads: + raw = str(payload or "").strip() + if not raw: + continue + auth_json = None + try: + pad = "=" * ((4 - (len(raw) % 4)) % 4) + decoded = base64.urlsafe_b64decode((raw + pad).encode("ascii")) + auth_json = json_module.loads(decoded.decode("utf-8")) + except Exception: + try: + auth_json = json_module.loads(raw) + except Exception: + auth_json = None + + workspace_id = _extract_workspace_id(auth_json) + if workspace_id: + self._log(f"Workspace ID: {workspace_id}") + return workspace_id + + # 兜底:从 oai-client-auth-info(URL 编码 JSON)提取 workspace + auth_info_raw = str(self.session.cookies.get("oai-client-auth-info") or "").strip() + if auth_info_raw: + auth_info_text = auth_info_raw + for _ in range(2): + decoded = urlparse.unquote(auth_info_text) + if decoded == auth_info_text: + break + auth_info_text = decoded + try: + auth_info_json = json_module.loads(auth_info_text) + workspace_id = _extract_workspace_id(auth_info_json) + if workspace_id: + self._log(f"Workspace ID (auth-info): {workspace_id}") + return workspace_id + except Exception as auth_info_err: + self._log(f"解析 auth-info Cookie 失败: {auth_info_err}", "warning") + + # 兜底:复用 create_account 缓存 + cached_workspace = str(self._create_account_workspace_id or "").strip() + if cached_workspace: + self._log(f"Workspace ID (create_account缓存): {cached_workspace}") + return cached_workspace + + self._log("授权 Cookie 里没有 workspace 信息", "warning") + return None + + except Exception as e: + self._log(f"解析授权 Cookie 失败: {e}", "warning") + 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", + "accept": "application/json", + }, + data=select_body, + allow_redirects=False, + ) + + # 兼容 30x:部分环境 continue_url 在 Location 头里。 + location = str(response.headers.get("Location") or "").strip() + if response.status_code in [301, 302, 303, 307, 308] and location: + import urllib.parse + continue_url = urllib.parse.urljoin(OPENAI_API_ENDPOINTS["select_workspace"], location) + self._log(f"Continue URL (Location): {continue_url[:100]}...") + return continue_url + + if response.status_code != 200: + self._log(f"选择 workspace 失败: {response.status_code}", "error") + self._log(f"响应: {response.text[:200]}", "warning") + return None + + continue_url = "" + try: + continue_url = str((response.json() or {}).get("continue_url") or "").strip() + except Exception as json_err: + body_text = str(response.text or "") + self._log(f"workspace/select 非 JSON 响应,尝试文本兜底解析: {json_err}", "warning") + # 兜底1:HTML/文本里直接包含 continue_url + m = re.search(r'"continue_url"\s*:\s*"([^"]+)"', body_text) + if m: + continue_url = str(m.group(1) or "").strip() + # 兜底2:返回页内含 auth.openai.com/oauth/authorize 链接 + if not continue_url: + m2 = re.search(r"https://auth\.openai\.com/[^\s\"'<>]+", body_text) + if m2: + continue_url = str(m2.group(0) or "").strip() + + if not continue_url: + if location: + import urllib.parse + continue_url = urllib.parse.urljoin(OPENAI_API_ENDPOINTS["select_workspace"], location) + else: + self._log("workspace/select 响应里缺少 continue_url", "error") + return None + + if continue_url: + continue_url = continue_url.replace("\\/", "/") + self._log(f"Continue URL: {continue_url[:100]}...") + return continue_url + + return None + + except Exception as e: + self._log(f"选择 Workspace 失败: {e}", "error") + return None + + def _follow_redirects(self, start_url: str) -> Tuple[Optional[str], str]: + """手动跟随重定向链,返回 (callback_url, final_url)。""" + try: + def _is_oauth_callback(url: str) -> bool: + try: + import urllib.parse as _urlparse + + parsed = _urlparse.urlparse(url) + path = (parsed.path or "").lower() + if ("/auth/callback" not in path) and ("/api/auth/callback/openai" not in path): + return False + query = _urlparse.parse_qs(parsed.query or "", keep_blank_values=True) + # 只要带 code 或 error,就认为已经进入回调阶段(避免被本地 503 干扰识别) + return bool(query.get("code") or query.get("error")) + except Exception: + return False + + current_url = start_url + callback_url: Optional[str] = None + max_redirects = 12 + + for i in range(max_redirects): + self._log(f"重定向 {i+1}/{max_redirects}: {current_url[:100]}...") + if _is_oauth_callback(current_url) and not callback_url: + callback_url = current_url + self._log(f"命中回调 URL: {current_url[:120]}...") + # 已拿到 callback,不再请求本地 callback 地址,避免 503 干扰后续判断 + break + + response = self.session.get( + current_url, + allow_redirects=False, + timeout=15 + ) + + location = response.headers.get("Location") or "" + + if "/api/auth/callback/openai" in current_url and not callback_url: + callback_url = current_url + + # 如果不是重定向状态码,停止 + 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) + + # 命中回调时仅记录,不提前返回;继续跟到底,让 next-auth 充分落 cookie。 + if _is_oauth_callback(next_url) and not callback_url: + callback_url = next_url + self._log(f"找到回调 URL: {next_url[:100]}...") + current_url = next_url + break + + current_url = next_url + + # 对齐 ABCard:补打一跳 chatgpt 首页,确保 next-auth cookie 完整落地。 + try: + if not current_url.rstrip("/").endswith("chatgpt.com"): + self.session.get( + "https://chatgpt.com/", + headers={ + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "referer": current_url, + "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" + ), + }, + timeout=20, + ) + except Exception as home_err: + self._log(f"重定向结束后首页补跳异常: {home_err}", "warning") + + if not callback_url: + self._log("未能在重定向链中找到回调 URL", "warning") + return callback_url, current_url + + except Exception as e: + self._log(f"跟随重定向失败: {e}", "error") + return None, start_url + + 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._is_existing_account = False + self._token_acquisition_requires_login = False + self._otp_sent_at = None + self._create_account_continue_url = None + self._create_account_workspace_id = None + self._create_account_account_id = None + self._create_account_refresh_token = None + self._last_validate_otp_continue_url = None + self._last_validate_otp_workspace_id = None + + self._log("=" * 60) + self._log("注册流程启动,开始替你敲门") + self._log("=" * 60) + self._log(f"注册入口链路配置: {self.registration_entry_flow}") + configured_entry_flow = self.registration_entry_flow + service_type_raw = getattr(self.email_service, "service_type", "") + service_type_value = str(getattr(service_type_raw, "value", service_type_raw) or "").strip().lower() + effective_entry_flow = configured_entry_flow + if service_type_value == "outlook": + self._log("检测到 Outlook 邮箱,自动使用 Outlook 入口链路(无需在设置中选择)") + effective_entry_flow = "outlook" + + # 1. 检查 IP 地理位置 + self._log("1. 先看看这条网络从哪儿来,别一开局就站错片场...") + 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. 准备首轮授权流程 + did, sen_token = self._prepare_authorize_flow("首次授权") + if not did: + result.error_message = "获取 Device ID 失败" + return result + result.device_id = did + if not sen_token: + result.error_message = "Sentinel POW 验证失败" + return result + + # 4. 提交注册入口邮箱 + self._log("4. 递上邮箱,看看 OpenAI 这球怎么接...") + signup_result = self._submit_signup_form(did, sen_token) + if not signup_result.success: + result.error_message = f"提交注册表单失败: {signup_result.error_message}" + return result + + if self._is_existing_account: + self._log("检测到这是老朋友账号,直接切去登录拿 token,不走弯路") + else: + self._log("5. 设置密码,别让小偷偷笑...") + password_ok, _ = self._register_password(did, sen_token) + if not password_ok: + result.error_message = self._last_register_password_error or "注册密码失败" + return result + + self._log("6. 催一下注册验证码出门,邮差该冲刺了...") + if not self._send_verification_code(): + result.error_message = "发送验证码失败" + return result + + self._log("7. 等验证码飞来,邮箱请注意查收...") + self._log("8. 对一下验证码,看看是不是本人...") + if not self._verify_email_otp_with_retry(stage_label="注册验证码", max_attempts=3): + result.error_message = "验证验证码失败" + return result + + self._log("9. 给账号办个正式户口,名字写档案里...") + if not self._create_user_account(): + result.error_message = "创建用户账户失败" + return result + + if effective_entry_flow in {"native", "outlook"}: + login_ready, login_error = self._restart_login_flow() + if not login_ready: + result.error_message = login_error + return result + if effective_entry_flow == "outlook": + self._log("注册入口链路: Outlook(迁移版,按朋友版 Outlook 主流程收尾)") + else: + self._log("注册入口链路: ABCard(新账号不重登,直接抓取会话)") + + if effective_entry_flow == "native": + if not self._complete_token_exchange_native_backup(result): + return result + elif effective_entry_flow == "outlook": + if not self._complete_token_exchange_outlook(result): + return result + else: + use_abcard_entry = (effective_entry_flow == "abcard") and (not self._is_existing_account) + if not self._complete_token_exchange(result, require_login_otp=not use_abcard_entry): + return result + + # 10. 完成 + self._log("=" * 60) + if self._is_existing_account: + self._log("登录成功,老朋友顺利回家") + else: + self._log("注册成功,账号已经稳稳落地,可以开香槟了") + self._log(f"邮箱: {result.email}") + self._log(f"Device ID: {result.device_id or '-'}") + self._log(f"Account ID: {result.account_id}") + self._log(f"Workspace ID: {result.workspace_id}") + self._log("=" * 60) + + result.success = True + settings = get_settings() + client_id = str(getattr(settings, "openai_client_id", "") or getattr(self.oauth_manager, "client_id", "") or "").strip() + 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, + "token_acquired_via_relogin": self._token_acquisition_requires_login, + "client_id": client_id, + "device_id": result.device_id, + "has_session_token": bool(result.session_token), + "has_access_token": bool(result.access_token), + "has_refresh_token": bool(result.refresh_token), + "registration_entry_flow": configured_entry_flow, + "registration_entry_flow_effective": effective_entry_flow, + # 对齐 K:\1\2:原生入口允许无 session_token 成功,但会标记待补。 + "session_token_pending": (effective_entry_flow == "native") and (not bool(result.session_token)), + } + + 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, + cookies=self._dump_session_cookies(), + 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/timezone_utils.py b/src/core/timezone_utils.py new file mode 100644 index 00000000..73051c92 --- /dev/null +++ b/src/core/timezone_utils.py @@ -0,0 +1,61 @@ +""" +时区工具(统一使用北京时间/上海时区展示) +""" + +from __future__ import annotations + +import os +import time +from datetime import datetime, timezone, timedelta + +try: + from zoneinfo import ZoneInfo +except Exception: # pragma: no cover + ZoneInfo = None # type: ignore[assignment] + + +UTC = timezone.utc +if ZoneInfo is not None: + SHANGHAI_TZ = ZoneInfo("Asia/Shanghai") +else: # 兼容极端环境 + SHANGHAI_TZ = timezone(timedelta(hours=8)) + + +def apply_process_timezone() -> None: + """ + 尝试将进程默认时区设置为 Asia/Shanghai。 + """ + try: + os.environ.setdefault("TZ", "Asia/Shanghai") + if hasattr(time, "tzset"): + time.tzset() + except Exception: + # 不阻断主流程 + pass + + +def now_shanghai() -> datetime: + return datetime.now(UTC).astimezone(SHANGHAI_TZ) + + +def to_utc(dt: datetime | None) -> datetime | None: + if dt is None: + return None + if dt.tzinfo is None: + return dt.replace(tzinfo=UTC) + return dt.astimezone(UTC) + + +def to_shanghai(dt: datetime | None) -> datetime | None: + if dt is None: + return None + if dt.tzinfo is None: + # 历史库里是 naive UTC,按 UTC 解释再转上海 + dt = dt.replace(tzinfo=UTC) + return dt.astimezone(SHANGHAI_TZ) + + +def to_shanghai_iso(dt: datetime | None) -> str | None: + local_dt = to_shanghai(dt) + return local_dt.isoformat() if local_dt else None + diff --git a/src/core/upload/__init__.py b/src/core/upload/__init__.py new file mode 100644 index 00000000..059e5157 --- /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 00000000..900cff6a --- /dev/null +++ b/src/core/upload/cpa_upload.py @@ -0,0 +1,312 @@ +""" +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) -> dict: + """ + 生成 CPA 格式的 Token JSON + + Args: + account: 账号模型实例 + + Returns: + CPA 格式的 Token 字典 + """ + return { + "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 "", + } + + +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, +) -> dict: + """ + 批量上传账号到 CPA 管理平台 + + Args: + account_ids: 账号 ID 列表 + proxy: 可选的代理 URL + api_url: 指定 CPA API URL(优先于全局配置) + api_token: 指定 CPA API Token(优先于全局配置) + + 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) + + # 上传 + 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 00000000..8df79609 --- /dev/null +++ b/src/core/upload/sub2api_upload.py @@ -0,0 +1,224 @@ +""" +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, +) -> Tuple[bool, str]: + """ + 上传账号列表到 Sub2API 平台(不走代理) + + Args: + accounts: 账号模型实例列表 + api_url: Sub2API 地址,如 http://host + api_key: Admin API Key(x-api-key header) + concurrency: 账号并发数,默认 3 + priority: 账号优先级,默认 50 + + 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, +) -> 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) + + 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 00000000..11973486 --- /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 00000000..b2bd4ff5 --- /dev/null +++ b/src/core/utils.py @@ -0,0 +1,583 @@ +""" +通用工具函数 +""" + +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, timezone +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 +from .timezone_utils import SHANGHAI_TZ + + +class ShanghaiTimeFormatter(logging.Formatter): + """ + 强制日志 asctime 输出为上海时间,避免容器/服务器时区差异。 + """ + + def formatTime(self, record, datefmt=None): # noqa: N802 + dt = datetime.fromtimestamp(record.created, tz=timezone.utc).astimezone(SHANGHAI_TZ) + if datefmt: + return dt.strftime(datefmt) + return dt.strftime("%Y-%m-%d %H:%M:%S") + + +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 = ShanghaiTimeFormatter(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 00000000..1ee05b91 --- /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 00000000..1e5609ba --- /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, + cookies: 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, + cookies=cookies, + 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, + priority: int = 0 +) -> CpaService: + """创建 CPA 服务配置""" + db_service = CpaService( + name=name, + api_url=api_url, + api_token=api_token, + enabled=enabled, + 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 diff --git a/src/database/init_db.py b/src/database/init_db.py new file mode 100644 index 00000000..58ea4b0e --- /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 00000000..2dda5c8e --- /dev/null +++ b/src/database/models.py @@ -0,0 +1,288 @@ +""" +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) + bind_card_tasks = relationship("BindCardTask", back_populates="account") + + 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 BindCardTask(Base): + """绑卡任务表""" + __tablename__ = "bind_card_tasks" + + id = Column(Integer, primary_key=True, autoincrement=True) + account_id = Column(Integer, ForeignKey("accounts.id"), nullable=False, index=True) + plan_type = Column(String(20), nullable=False) # plus / team + workspace_name = Column(String(255)) + price_interval = Column(String(20)) + seat_quantity = Column(Integer) + country = Column(String(10), default="US") + currency = Column(String(10), default="USD") + checkout_url = Column(Text, nullable=False) + checkout_session_id = Column(String(120)) + publishable_key = Column(String(255)) + client_secret = Column(Text) + checkout_source = Column(String(50)) # openai_checkout / aimizy_fallback + bind_mode = Column(String(30), default="semi_auto") # semi_auto / third_party / local_auto + status = Column(String(20), default="link_ready") # link_ready / opened / waiting_user_action / verifying / completed / failed + last_error = Column(Text) + opened_at = Column(DateTime) + last_checked_at = Column(DateTime) + completed_at = Column(DateTime) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # 关系 + account = relationship("Account", back_populates="bind_card_tasks") + + +class AppLog(Base): + """应用日志表(后台日志监控)""" + __tablename__ = "app_logs" + + id = Column(Integer, primary_key=True, autoincrement=True) + level = Column(String(20), nullable=False, index=True) + logger = Column(String(255), nullable=False, index=True) + module = Column(String(255)) + pathname = Column(String(500)) + lineno = Column(Integer) + message = Column(Text, nullable=False) + exception = Column(Text) + created_at = Column(DateTime, default=datetime.utcnow, index=True) + + def to_dict(self) -> Dict[str, Any]: + return { + "id": self.id, + "level": self.level, + "logger": self.logger, + "module": self.module, + "pathname": self.pathname, + "lineno": self.lineno, + "message": self.message, + "exception": self.exception, + "created_at": self.created_at.isoformat() if self.created_at else None, + } + + +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) + 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}" diff --git a/src/database/session.py b/src/database/session.py new file mode 100644 index 00000000..4d35759b --- /dev/null +++ b/src/database/session.py @@ -0,0 +1,186 @@ +""" +数据库会话管理 +""" + +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"), + ("bind_card_tasks", "checkout_session_id", "VARCHAR(120)"), + ("bind_card_tasks", "publishable_key", "VARCHAR(255)"), + ("bind_card_tasks", "client_secret", "TEXT"), + ("bind_card_tasks", "bind_mode", "VARCHAR(30) DEFAULT 'semi_auto'"), + ] + + # 确保新表存在(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 00000000..ad29d3e5 --- /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 00000000..aba923f2 --- /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 00000000..deb911ab --- /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 00000000..e1e0587b --- /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 00000000..01573f64 --- /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 00000000..dad6594e --- /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 00000000..fbdd660d --- /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 00000000..6f427d59 --- /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 00000000..335b11e9 --- /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 00000000..0ca66c39 --- /dev/null +++ b/src/services/outlook/email_parser.py @@ -0,0 +1,245 @@ +""" +邮件解析和验证码提取 +""" + +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, + used_fingerprints: Optional[set] = None, + ) -> Optional[str]: + """ + 从邮件列表中查找验证码 + + Args: + emails: 邮件列表 + target_email: 目标邮箱地址 + min_timestamp: 最小时间戳(用于过滤旧邮件) + used_codes: 兼容旧参数(仅在无邮件 ID/时间时兜底) + used_fingerprints: 已使用邮件指纹集合,指纹规则为 "时间戳|邮件ID|验证码" + + Returns: + 验证码字符串,如果未找到返回 None + """ + # 注意:不能用 `or set()`,否则传入空集合时会被替换,导致跨轮询去重失效。 + if used_codes is None: + used_codes = set() + if used_fingerprints is None: + used_fingerprints = 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: + mail_ts = int(email.received_timestamp or 0) + mail_id = str(email.id or "").strip() or "-" + fingerprint = f"{mail_ts}|{mail_id}|{code}" + + # 主去重:时间 + 邮件ID + 验证码 + if fingerprint in used_fingerprints: + logger.debug(f"跳过已使用验证码指纹: {fingerprint}") + continue + + # 兜底去重:如果邮件没有 ID 且没有时间戳,才退回到纯验证码去重 + if mail_id == "-" and mail_ts <= 0 and code in used_codes: + logger.debug(f"跳过已使用的验证码(兜底): {code}") + continue + + used_fingerprints.add(fingerprint) + used_codes.add(code) + 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 00000000..c68ed4ee --- /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 00000000..d6fe6a11 --- /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 00000000..0d6c072e --- /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 00000000..318e85b5 --- /dev/null +++ b/src/services/outlook/providers/graph_api.py @@ -0,0 +1,256 @@ +""" +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" + # 验证邮件可能落在垃圾箱/已删除,需多文件夹轮询 + MESSAGE_FOLDERS = [ + "inbox", + "junkemail", + "deleteditems", + "archive", + ] + + @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 [] + + # 构建代理配置 + proxies = None + if self.config.proxy_url: + proxies = {"http": self.config.proxy_url, "https": self.config.proxy_url} + emails = [] + seen_ids = set() + + for folder in self.MESSAGE_FOLDERS: + url = f"{self.GRAPH_API_BASE}/me/mailFolders/{folder}/messages" + params = { + "$top": count, + "$select": "id,subject,from,toRecipients,receivedDateTime,isRead,hasAttachments,bodyPreview,body", + "$orderby": "receivedDateTime desc", + } + if only_unseen: + params["$filter"] = "isRead eq false" + + 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: + logger.debug(f"[{self.account.email}] Graph API 跳过文件夹 {folder}: HTTP {resp.status_code}") + continue + + data = resp.json() + messages = data.get("value", []) + if not messages: + continue + + for msg in messages: + try: + email_msg = self._parse_graph_message(msg) + if not email_msg: + continue + key = email_msg.id or "" + if key and key in seen_ids: + continue + if key: + seen_ids.add(key) + 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 00000000..7ea14258 --- /dev/null +++ b/src/services/outlook/providers/imap_new.py @@ -0,0 +1,244 @@ +""" +新版 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: + flag = "UNSEEN" if only_unseen else "ALL" + emails = [] + seen_keys = set() + + for mailbox in IMAPOldProvider.SEARCH_MAILBOXES: + try: + status, _ = self._conn.select(mailbox, readonly=True) + if status != "OK": + continue + + status, data = self._conn.search(None, flag) + if status != "OK" or not data or not data[0]: + continue + + ids = data[0].split() + recent_ids = ids[-count:][::-1] + + for msg_id in recent_ids: + try: + email_msg = self._fetch_email(msg_id) + if not email_msg: + continue + + dedupe_key = email_msg.id or f"{mailbox}:{msg_id.decode(errors='ignore')}" + if dedupe_key in seen_keys: + continue + + seen_keys.add(dedupe_key) + emails.append(email_msg) + except Exception as e: + logger.warning( + f"[{self.account.email}] 解析邮件失败 ({mailbox}, ID: {msg_id}): {e}" + ) + except Exception as e: + logger.debug(f"[{self.account.email}] 跳过邮箱文件夹 {mailbox}: {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 00000000..0c19775e --- /dev/null +++ b/src/services/outlook/providers/imap_old.py @@ -0,0 +1,370 @@ +""" +旧版 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 + # 验证邮件有时会进入垃圾邮件/存档等文件夹,需多文件夹轮询 + SEARCH_MAILBOXES = [ + "INBOX", + "Junk", + "Junk Email", + "Junk E-mail", + "Spam", + "Deleted Items", + "Trash", + "Clutter", + "Archive", + ] + + @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: + flag = "UNSEEN" if only_unseen else "ALL" + emails = [] + seen_keys = set() + + for mailbox in self.SEARCH_MAILBOXES: + try: + status, _ = self._conn.select(mailbox, readonly=True) + if status != "OK": + continue + + status, data = self._conn.search(None, flag) + if status != "OK" or not data or not data[0]: + continue + + ids = data[0].split() + recent_ids = ids[-count:][::-1] # 倒序,最新的在前 + + for msg_id in recent_ids: + try: + email_msg = self._fetch_email(msg_id) + if not email_msg: + continue + + dedupe_key = email_msg.id or f"{mailbox}:{msg_id.decode(errors='ignore')}" + if dedupe_key in seen_keys: + continue + + seen_keys.add(dedupe_key) + emails.append(email_msg) + except Exception as e: + logger.warning( + f"[{self.account.email}] 解析邮件失败 ({mailbox}, ID: {msg_id}): {e}" + ) + except Exception as e: + logger.debug(f"[{self.account.email}] 跳过邮箱文件夹 {mailbox}: {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 00000000..858bf610 --- /dev/null +++ b/src/services/outlook/service.py @@ -0,0 +1,508 @@ +""" +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, +] + +# OTP 发送时间的容忍偏差(秒) +# 与 clean 版本保持一致,避免把上一阶段验证码误判为当前验证码。 +OTP_TIME_SKEW_SECONDS = 5 + + +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) + + # 验证码去重机制(按“时间戳+邮件ID+验证码”指纹) + self._used_codes: Dict[str, set] = {} + # 验证码阶段标记(按 otp_sent_at 重置去重,避免“第二封验证码与第一封相同”被误判为旧码) + self._used_codes_stage_marker: Dict[str, int] = {} + + 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]}" + ) + + # 初始化验证码指纹去重集合 + email_key = str(email or "").strip().lower() + if email_key not in self._used_codes: + self._used_codes[email_key] = set() + used_fingerprints = self._used_codes[email_key] + + # 按 OTP 发送时间重置去重集合,避免不同阶段共用同一验证码时被误跳过 + if otp_sent_at: + try: + stage_marker = int(float(otp_sent_at)) + prev_marker = self._used_codes_stage_marker.get(email_key) + if prev_marker is None or abs(stage_marker - prev_marker) > 3: + if used_fingerprints: + logger.info( + f"[{email}] 检测到新的验证码阶段,重置去重缓存(上一阶段已记 {len(used_fingerprints)} 条指纹)" + ) + used_fingerprints.clear() + self._used_codes_stage_marker[email_key] = stage_marker + except Exception: + pass + + # 计算最小时间戳(仅容忍小时钟偏差,避免命中上一阶段旧验证码) + min_timestamp = (otp_sent_at - OTP_TIME_SKEW_SECONDS) 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_fingerprints=used_fingerprints, + ) + + if 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 00000000..77e54f20 --- /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 00000000..3fd6a7df --- /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 00000000..cbbaea85 --- /dev/null +++ b/src/services/temp_mail.py @@ -0,0 +1,864 @@ +""" +Temp-Mail 邮箱服务实现 +基于自部署 Cloudflare Worker 临时邮箱服务 +接口文档参见 plan/temp-mail.md +""" + +import re +import time +import json +import logging +from datetime import datetime, timezone +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 email.utils import parsedate_to_datetime +from html import unescape +from typing import Optional, Dict, Any, List, Tuple + +from .base import BaseEmailService, EmailServiceError, EmailServiceType +from ..core.http_client import HTTPClient, RequestConfig +from ..config.constants import OTP_CODE_PATTERN, OTP_CODE_SEMANTIC_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]] = {} + # 记录每个邮箱上一次成功使用的邮件 ID,避免重复使用旧验证码 + self._last_used_mail_ids: Dict[str, str] = {} + + 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 _is_openai_otp_mail(self, sender: str, subject: str, body: str, raw: str) -> bool: + """ + 判断是否是 OpenAI 验证码邮件。 + 只看 openai 关键字容易误命中营销/通知邮件,这里增加 OTP 语义词过滤。 + """ + sender_l = str(sender or "").lower() + subject_l = str(subject or "").lower() + body_l = str(body or "").lower() + raw_l = str(raw or "").lower() + blob = f"{sender_l}\n{subject_l}\n{body_l}\n{raw_l}" + + if "openai" not in sender_l and "openai" not in blob: + return False + + otp_keywords = ( + "verification code", + "verify", + "one-time code", + "one time code", + "otp", + "log in", + "login", + "security code", + "验证码", + ) + return any(keyword in blob for keyword in otp_keywords) + + def _extract_otp_code(self, content: str, pattern: str) -> Tuple[Optional[str], bool]: + """ + 提取验证码并返回是否语义命中。 + 优先语义匹配(code is 123456),降低误匹配邮件正文中随机 6 位数字的概率。 + """ + text = str(content or "") + if not text: + return None, False + + semantic_match = re.search(OTP_CODE_SEMANTIC_PATTERN, text, re.IGNORECASE) + if semantic_match: + return semantic_match.group(1), True + + simple_match = re.search(pattern, text) + if simple_match: + return simple_match.group(1), False + + return None, False + + def _admin_headers(self) -> Dict[str, str]: + """构造 admin 请求头""" + headers = { + "x-admin-auth": self.config["admin_password"], + "Content-Type": "application/json", + "Accept": "application/json", + } + custom_auth = (self.config.get("custom_auth") or "").strip() + if custom_auth: + headers["x-custom-auth"] = custom_auth + return headers + + def _extract_mails_from_response(self, response: Any) -> List[Dict[str, Any]]: + """ + 从不同返回结构中提取邮件列表。 + + 兼容以下常见格式: + - {"results": [...]} + - {"mails": [...]} + - {"data": [...]} + - {"items": [...]} + - 直接返回 [...] + """ + if isinstance(response, list): + return [mail for mail in response if isinstance(mail, dict)] + + if not isinstance(response, dict): + return [] + + for key in ("results", "mails", "data", "items", "list"): + value = response.get(key) + if isinstance(value, list): + return [mail for mail in value if isinstance(mail, dict)] + + return [] + + def _mail_appears_for_email(self, mail: Dict[str, Any], email: str) -> bool: + """判断邮件是否属于指定邮箱。""" + target = (email or "").strip().lower() + if not target: + return False + + candidate_fields = ( + mail.get("address"), + mail.get("email"), + mail.get("to"), + mail.get("to_address"), + mail.get("toAddress"), + mail.get("target"), + mail.get("recipient"), + ) + for value in candidate_fields: + text = str(value or "").strip().lower() + if text and target in text: + return True + + parsed = self._extract_mail_fields(mail) + text_blob = "\n".join( + [ + str(parsed.get("sender") or ""), + str(parsed.get("subject") or ""), + str(parsed.get("body") or ""), + str(parsed.get("raw") or ""), + ] + ).lower() + return target in text_blob + + def _fetch_mails_once(self, email: str, jwt: Optional[str], email_id: Optional[str] = None) -> List[Dict[str, Any]]: + """ + 获取一次邮件列表,按新旧接口顺序回退: + 1) /api/mails (Bearer jwt) + 2) /user_api/mails (x-user-token) + 3) /admin/mails (按地址过滤) + 4) /admin/mails (不过滤,客户端二次筛选) + """ + attempts: List[Dict[str, Any]] = [] + if jwt: + attempts.extend([ + { + "path": "/api/mails", + "params": {"limit": 50, "offset": 0}, + "headers": { + "Authorization": f"Bearer {jwt}", + "Accept": "application/json", + }, + }, + { + "path": "/user_api/mails", + "params": {"limit": 50, "offset": 0}, + "headers": { + "x-user-token": jwt, + "Accept": "application/json", + }, + }, + ]) + + attempts.append({ + "path": "/admin/mails", + "params": {"limit": 80, "offset": 0, "address": email}, + "headers": {"Accept": "application/json"}, + }) + if email_id and email_id != email: + attempts.append({ + "path": "/admin/mails", + "params": {"limit": 80, "offset": 0, "address": email_id}, + "headers": {"Accept": "application/json"}, + }) + attempts.append({ + "path": "/admin/mails", + "params": {"limit": 120, "offset": 0}, + "headers": {"Accept": "application/json"}, + }) + + for attempt in attempts: + path = attempt["path"] + try: + response = self._make_request( + "GET", + path, + params=attempt["params"], + headers=attempt["headers"], + ) + mails = self._extract_mails_from_response(response) + if mails and "address" not in attempt["params"]: + mails = [mail for mail in mails if self._mail_appears_for_email(mail, email)] + if mails: + return mails + logger.debug(f"TempMail 接口 {path} 返回无可用邮件列表: {response}") + except Exception as e: + logger.debug(f"TempMail 接口 {path} 读取失败,尝试回退: {e}") + + return [] + + def _extract_mail_detail_from_response(self, response: Any) -> Optional[Dict[str, Any]]: + """从详情接口响应里提取单封邮件对象。""" + if isinstance(response, dict): + if response: + if all(k in response for k in ("subject", "text")) or response.get("raw"): + return response + for key in ("mail", "data", "result", "item"): + value = response.get(key) + if isinstance(value, dict): + return value + return None + + def _fetch_mail_detail(self, mail_id: str, jwt: Optional[str]) -> Optional[Dict[str, Any]]: + """ + 尝试获取单封邮件详情(部分部署的列表接口只返回摘要,不含正文)。 + """ + if not mail_id: + return None + + attempts: List[Dict[str, Any]] = [] + if jwt: + attempts.extend([ + { + "path": f"/api/mails/{mail_id}", + "headers": { + "Authorization": f"Bearer {jwt}", + "Accept": "application/json", + }, + }, + { + "path": f"/user_api/mails/{mail_id}", + "headers": { + "x-user-token": jwt, + "Accept": "application/json", + }, + }, + ]) + attempts.append( + { + "path": f"/admin/mails/{mail_id}", + "headers": {"Accept": "application/json"}, + } + ) + + for attempt in attempts: + try: + response = self._make_request("GET", attempt["path"], headers=attempt["headers"]) + detail = self._extract_mail_detail_from_response(response) + if detail: + return detail + except Exception as e: + logger.debug(f"TempMail 详情接口 {attempt['path']} 读取失败: {e}") + return None + + def _parse_mail_timestamp(self, value: Any) -> Optional[float]: + """将邮件时间字段解析为 Unix 时间戳(秒)。""" + if value is None: + return None + + if isinstance(value, (int, float)): + ts = float(value) + # 兼容毫秒时间戳 + if ts > 10**12: + ts = ts / 1000.0 + return ts if ts > 0 else None + + text = str(value).strip() + if not text: + return None + + if text.isdigit(): + ts = float(text) + if ts > 10**12: + ts = ts / 1000.0 + return ts if ts > 0 else None + + try: + ts = float(text) + if ts > 10**12: + ts = ts / 1000.0 + if ts > 0: + return ts + except ValueError: + pass + + iso_text = text + if iso_text.endswith("Z"): + iso_text = iso_text[:-1] + "+00:00" + + try: + dt = datetime.fromisoformat(iso_text) + if dt.tzinfo is None: + # Worker 侧常返回无时区时间,默认按 UTC 解析,避免被本机时区误差误判为旧邮件。 + dt = dt.replace(tzinfo=timezone.utc) + return dt.timestamp() + except ValueError: + pass + + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M:%S.%f"): + try: + dt = datetime.strptime(text, fmt).replace(tzinfo=timezone.utc) + return dt.timestamp() + except ValueError: + continue + + return None + + def _extract_mail_timestamp(self, mail: Dict[str, Any]) -> Optional[float]: + """从不同字段中提取邮件时间戳。""" + for key in ("createdAt", "created_at", "date", "created", "timestamp", "time"): + ts = self._parse_mail_timestamp(mail.get(key)) + if ts is not None: + return ts + # 某些 Worker 只在 raw 里带 Date 头,这里做二次解析。 + raw = str(mail.get("raw") or "").strip() + if raw: + try: + message = message_from_string(raw, policy=email_policy) + date_header = str(message.get("Date") or "").strip() + if date_header: + dt = parsedate_to_datetime(date_header) + if dt is not None: + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.timestamp() + except Exception: + pass + return None + + def _extract_mail_id(self, mail: Dict[str, Any]) -> str: + """提取邮件唯一标识,兼容不同字段;缺失时生成稳定回退 ID。""" + for key in ("id", "mail_id", "mailId", "_id", "uuid"): + value = mail.get(key) + if value is not None and str(value).strip(): + return str(value).strip() + + fallback = "|".join( + str(mail.get(key) or "").strip() + for key in ("createdAt", "created_at", "date", "source", "from", "subject", "title") + ) + return fallback or str(hash(json.dumps(mail, sort_keys=True, ensure_ascii=False))) + + 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() + address_id = str( + response.get("address_id") + or response.get("id") + or response.get("addressId") + or "" + ).strip() + + if not address: + raise EmailServiceError(f"API 返回数据不完整: {response}") + + email_info = { + "email": address, + "jwt": jwt, + "address_id": address_id, + "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() + last_used_mail_id = self._last_used_mail_ids.get(email) + unknown_ts_grace_seconds = 15 + + # 优先使用用户级 JWT,回退到 admin API + cached = self._email_cache.get(email, {}) + jwt = cached.get("jwt") + address_id = ( + str(cached.get("address_id") or "").strip() + or str(email_id or "").strip() + or None + ) + poll_count = 0 + + while time.time() - start_time < timeout: + poll_count += 1 + try: + mails = self._fetch_mails_once(email=email, jwt=jwt, email_id=address_id) + if not mails: + if poll_count == 1 or poll_count % 5 == 0: + logger.info( + f"TempMail 轮询[{email}] 第 {poll_count} 次: 暂无邮件(已等待 {int(time.time() - start_time)}s)" + ) + time.sleep(3) + continue + + if poll_count == 1 or poll_count % 3 == 0: + logger.info( + f"TempMail 轮询[{email}] 第 {poll_count} 次: 收到 {len(mails)} 封候选邮件" + ) + + candidates: List[Dict[str, Any]] = [] + unknown_ts_candidates: List[Dict[str, Any]] = [] + + for mail in mails: + mail_id = self._extract_mail_id(mail) + if mail_id in seen_mail_ids: + continue + + if last_used_mail_id and mail_id == last_used_mail_id: + continue + + seen_mail_ids.add(mail_id) + + # 过滤发送验证码之前的旧邮件,避免取到上一轮 OTP + mail_ts = self._extract_mail_timestamp(mail) + if otp_sent_at: + if mail_ts is not None and mail_ts + 2 < otp_sent_at: + continue + + 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 not self._is_openai_otp_mail(sender, subject, body_text, raw_text): + continue + + code, semantic_hit = self._extract_otp_code(content, pattern) + if not code: + # 部分部署列表接口只含摘要;尝试拉单封详情再匹配一次。 + detail = self._fetch_mail_detail(mail_id=mail_id, jwt=jwt) + if detail: + detail_parsed = self._extract_mail_fields(detail) + detail_ts = self._extract_mail_timestamp(detail) + if detail_ts is not None: + mail_ts = detail_ts + detail_content = ( + f"{detail_parsed['sender']}\n" + f"{detail_parsed['subject']}\n" + f"{detail_parsed['body']}\n" + f"{detail_parsed['raw']}" + ).strip() + if not self._is_openai_otp_mail( + detail_parsed["sender"], + detail_parsed["subject"], + detail_parsed["body"], + detail_parsed["raw"], + ): + continue + code, semantic_hit = self._extract_otp_code(detail_content, pattern) + + if not code: + continue + + candidate = { + "mail_id": mail_id, + "code": code, + "mail_ts": mail_ts, + "semantic_hit": bool(semantic_hit), + "is_recent": bool( + otp_sent_at and (mail_ts is not None) and (mail_ts + 2 >= otp_sent_at) + ), + } + if otp_sent_at and mail_ts is None: + unknown_ts_candidates.append(candidate) + else: + candidates.append(candidate) + + elapsed = time.time() - start_time + if otp_sent_at and (not candidates) and unknown_ts_candidates and elapsed < unknown_ts_grace_seconds: + # 先等一小段时间,优先等待可解析时间戳的新邮件,避免立刻捞到历史旧码。 + logger.debug( + "TempMail 轮询[%s]: 存在无时间戳邮件,等待 %.0fs 后再回退使用", + email, + unknown_ts_grace_seconds, + ) + time.sleep(3) + continue + + all_candidates = candidates + unknown_ts_candidates + if all_candidates: + best = sorted( + all_candidates, + key=lambda item: ( + 1 if item.get("is_recent") else 0, + 1 if item.get("mail_ts") is not None else 0, + float(item.get("mail_ts") or 0.0), + 1 if item.get("semantic_hit") else 0, + ), + reverse=True, + )[0] + code = str(best["code"]) + self._last_used_mail_ids[email] = str(best["mail_id"]) + logger.info( + "从 TempMail 邮箱 %s 找到验证码: %s(mail_id=%s ts=%s semantic=%s)", + email, + code, + best["mail_id"], + best.get("mail_ts"), + best.get("semantic_hit"), + ) + 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 00000000..e5d0f2b9 --- /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 00000000..f722b0b9 --- /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 00000000..b679341d --- /dev/null +++ b/src/web/app.py @@ -0,0 +1,291 @@ +""" +FastAPI 应用主文件 +轻量级 Web UI,支持注册、账号管理、设置 +""" + +import logging +import sys +import secrets +import hmac +import hashlib +from typing import Optional, Dict, Any +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 ..config.project_notice import PROJECT_NOTICE +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) + templates.env.globals["project_notice"] = PROJECT_NOTICE + + def _render_template( + request: Request, + name: str, + context: Optional[Dict[str, Any]] = None, + status_code: int = 200, + ) -> HTMLResponse: + """ + 兼容不同 Starlette 版本的 TemplateResponse 签名: + - 旧版: TemplateResponse(name, context, status_code=...) + - 新版: TemplateResponse(request, name, context, status_code=...) + """ + template_context: Dict[str, Any] = {"request": request} + if context: + template_context.update(context) + + try: + return templates.TemplateResponse( + request=request, + name=name, + context=template_context, + status_code=status_code, + ) + except TypeError: + return templates.TemplateResponse( + name, + template_context, + status_code=status_code, + ) + + 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 _render_template( + request, + "login.html", + {"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 _render_template( + request, + "login.html", + {"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 _render_template(request, "index.html") + + @app.get("/accounts", response_class=HTMLResponse) + async def accounts_page(request: Request): + """账号管理页面""" + if not _is_authenticated(request): + return _redirect_to_login(request) + return _render_template(request, "accounts.html") + + @app.get("/accounts-overview", response_class=HTMLResponse) + async def accounts_overview_page(request: Request): + """账号总览页面""" + if not _is_authenticated(request): + return _redirect_to_login(request) + return _render_template(request, "accounts_overview.html") + + @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 _render_template(request, "email_services.html") + + @app.get("/settings", response_class=HTMLResponse) + async def settings_page(request: Request): + """设置页面""" + if not _is_authenticated(request): + return _redirect_to_login(request) + return _render_template(request, "settings.html") + + @app.get("/payment", response_class=HTMLResponse) + async def payment_page(request: Request): + """支付页面""" + return _render_template(request, "payment.html") + + @app.get("/card-pool", response_class=HTMLResponse) + async def card_pool_page(request: Request): + """卡池页面(占位)""" + if not _is_authenticated(request): + return _redirect_to_login(request) + return _render_template(request, "card_pool.html") + + @app.get("/auto-team", response_class=HTMLResponse) + async def auto_team_page(request: Request): + """自动进 Team 页面(占位)""" + if not _is_authenticated(request): + return _redirect_to_login(request) + return _render_template(request, "auto_team.html") + + @app.get("/logs", response_class=HTMLResponse) + async def logs_page(request: Request): + """后台日志页面""" + if not _is_authenticated(request): + return _redirect_to_login(request) + return _render_template(request, "logs.html") + + @app.on_event("startup") + async def startup_event(): + """应用启动事件""" + import asyncio + from ..database.init_db import initialize_database + from ..core.db_logs import cleanup_database_logs + + # 确保数据库已初始化(reload 模式下子进程也需要初始化) + try: + initialize_database() + except Exception as e: + logger.warning(f"数据库初始化: {e}") + + # 设置 TaskManager 的事件循环 + loop = asyncio.get_event_loop() + task_manager.set_loop(loop) + + async def run_log_cleanup_once(): + try: + result = await asyncio.to_thread(cleanup_database_logs) + logger.info( + "后台日志清理完成: 删除 %s 条,剩余 %s 条", + result.get("deleted_total", 0), + result.get("remaining", 0), + ) + except Exception as exc: + logger.warning(f"后台日志清理失败: {exc}") + + async def periodic_log_cleanup(): + while True: + try: + await asyncio.sleep(3600) # 每小时清理一次 + await run_log_cleanup_once() + except asyncio.CancelledError: + break + except Exception as exc: + logger.warning(f"后台日志定时清理异常: {exc}") + + # 启动时先执行一次,再开启定时任务 + await run_log_cleanup_once() + app.state.log_cleanup_task = asyncio.create_task(periodic_log_cleanup()) + + 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(): + """应用关闭事件""" + cleanup_task = getattr(app.state, "log_cleanup_task", None) + if cleanup_task: + cleanup_task.cancel() + 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 00000000..70a2a574 --- /dev/null +++ b/src/web/routes/__init__.py @@ -0,0 +1,28 @@ +""" +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 .logs import router as logs_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(logs_router, prefix="/logs", tags=["logs"]) +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 00000000..e18b086d --- /dev/null +++ b/src/web/routes/accounts.py @@ -0,0 +1,2287 @@ +""" +账号管理 API 路由 +""" +import io +import json +import logging +import re +import zipfile +import base64 +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, HTTPException, Query, BackgroundTasks, Body +from fastapi.responses import StreamingResponse +from pydantic import BaseModel +from sqlalchemy import func + +from ...config.constants import AccountStatus +from ...config.settings import get_settings +from ...core.openai.overview import fetch_codex_overview +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() + +CURRENT_ACCOUNT_SETTING_KEY = "codex.current_account_id" +OVERVIEW_EXTRA_DATA_KEY = "codex_overview" +OVERVIEW_CARD_REMOVED_KEY = "codex_overview_card_removed" +OVERVIEW_CACHE_TTL_SECONDS = 300 # 5 分钟 +PAID_SUBSCRIPTION_TYPES = ("plus", "team") +INVALID_ACCOUNT_STATUSES = ( + AccountStatus.FAILED.value, + AccountStatus.EXPIRED.value, + AccountStatus.BANNED.value, +) + + +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 + + +def _apply_status_filter(query, status: Optional[str]): + """ + 统一状态筛选: + - failed/invalid 视为“无效账号集合”(failed + expired + banned) + - 其他值按精确状态筛选 + """ + normalized = (status or "").strip().lower() + if not normalized: + return query + if normalized in {"failed", "invalid"}: + return query.filter(Account.status.in_(INVALID_ACCOUNT_STATUSES)) + return query.filter(Account.status == normalized) + + +# ============== 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 + device_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 + subscription_type: Optional[str] = None + subscription_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 字符串,用于支付请求 + session_token: Optional[str] = None + + +class ManualAccountCreateRequest(BaseModel): + """手动创建账号请求""" + email: str + password: str + email_service: Optional[str] = "manual" + status: Optional[str] = AccountStatus.ACTIVE.value + client_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 + session_token: Optional[str] = None + cookies: Optional[str] = None + proxy_used: Optional[str] = None + source: Optional[str] = "manual" + subscription_type: Optional[str] = None + metadata: Optional[dict] = None + + +class AccountImportItem(BaseModel): + """账号导入项(支持按账号详情字段导入)""" + email: str + password: Optional[str] = None + email_service: Optional[str] = "manual" + status: Optional[str] = AccountStatus.ACTIVE.value + client_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 + session_token: Optional[str] = None + cookies: Optional[str] = None + proxy_used: Optional[str] = None + source: Optional[str] = "import" + subscription_type: Optional[str] = None + plan_type: Optional[str] = None + auth_mode: Optional[str] = None + user_id: Optional[str] = None + organization_id: Optional[str] = None + account_name: Optional[str] = None + account_structure: Optional[str] = None + tokens: Optional[dict] = None + quota: Optional[dict] = None + tags: Optional[Any] = None + created_at: Optional[Any] = None + last_used: Optional[Any] = None + metadata: Optional[dict] = None + + +class ImportAccountsRequest(BaseModel): + """批量导入账号请求""" + accounts: List[dict] + overwrite: bool = False + + +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 + + +class OverviewRefreshRequest(BaseModel): + """账号总览刷新请求""" + ids: List[int] = [] + force: bool = True + select_all: bool = False + status_filter: Optional[str] = None + email_service_filter: Optional[str] = None + search_filter: Optional[str] = None + proxy: Optional[str] = None + + +class OverviewCardDeleteRequest(BaseModel): + """账号总览卡片删除(仅从卡片移除,不删除账号)""" + ids: List[int] = [] + select_all: bool = False + status_filter: Optional[str] = None + email_service_filter: Optional[str] = None + search_filter: Optional[str] = None + + +# ============== 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 = _apply_status_filter(query, 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, + device_id=_resolve_account_device_id(account), + 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, + subscription_type=account.subscription_type, + subscription_at=account.subscription_at.isoformat() if account.subscription_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, + ) + + +def _extract_cookie_value(cookies_text: Optional[str], cookie_name: str) -> str: + text = str(cookies_text or "") + if not text: + return "" + pattern = re.compile(rf"(?:^|;\s*){re.escape(cookie_name)}=([^;]+)") + match = pattern.search(text) + return str(match.group(1) or "").strip() if match else "" + + +def _extract_session_token_from_cookie_text(cookies_text: Optional[str]) -> str: + """从完整 cookie 字符串中提取 next-auth session token(兼容分片)。""" + text = str(cookies_text or "") + if not text: + return "" + + direct = re.search(r"(?:^|;\s*)__Secure-next-auth\.session-token=([^;]+)", text) + if direct: + return str(direct.group(1) or "").strip() + + parts = re.findall(r"(?:^|;\s*)__Secure-next-auth\.session-token\.(\d+)=([^;]+)", text) + if not parts: + return "" + + chunk_map = {} + for idx, value in parts: + try: + chunk_map[int(idx)] = str(value or "") + except Exception: + continue + if not chunk_map: + return "" + + return "".join(chunk_map[i] for i in sorted(chunk_map.keys())) + + +def _resolve_account_device_id(account: Account) -> str: + """ + 解析账号 device_id(兼容历史数据): + 1) account.device_id(若模型未来扩展该字段) + 2) cookies 里的 oai-did + 3) extra_data 中的 device_id/oai_did/oai-device-id + """ + direct = str(getattr(account, "device_id", "") or "").strip() + if direct: + return direct + + did_in_cookie = _extract_cookie_value(getattr(account, "cookies", None), "oai-did") + if did_in_cookie: + return did_in_cookie + + extra_data = getattr(account, "extra_data", None) + if isinstance(extra_data, dict): + for key in ("device_id", "oai_did", "oai-device-id"): + value = str(extra_data.get(key) or "").strip() + if value: + return value + return "" + + +def _resolve_account_session_token(account: Account) -> str: + """解析账号 session_token(优先 DB 字段,其次 cookies 文本)。""" + db_token = str(getattr(account, "session_token", "") or "").strip() + if db_token: + return db_token + return _extract_session_token_from_cookie_text(getattr(account, "cookies", None)) + + +def _parse_iso_datetime(value: Optional[str]) -> Optional[datetime]: + if not value: + return None + try: + text = value.replace("Z", "+00:00") + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + except Exception: + return None + + +def _normalize_plan_type(raw_plan: Optional[str]) -> str: + value = (raw_plan or "").strip().lower() + if not value: + return "Basic" + if "team" in value or "enterprise" in value: + return "Team" + if "plus" in value: + return "Plus" + if "pro" in value: + return "Pro" + if "free" in value or "basic" in value: + return "Basic" + return value.capitalize() + + +def _build_unknown_quota() -> dict: + return { + "used": None, + "total": None, + "remaining": None, + "percentage": None, + "reset_at": None, + "reset_in_text": "-", + "status": "unknown", + } + + +def _fallback_overview(account: Account, error_message: Optional[str] = None, stale: bool = False) -> dict: + data = { + "plan_type": _normalize_plan_type(account.subscription_type), + "plan_source": "db.subscription_type" if account.subscription_type else "default", + "hourly_quota": _build_unknown_quota(), + "weekly_quota": _build_unknown_quota(), + "code_review_quota": _build_unknown_quota(), + "fetched_at": datetime.now(timezone.utc).isoformat(), + "sources": [], + "stale": stale, + } + if error_message: + data["error"] = error_message + return data + + +def _is_overview_cache_stale(cached_overview: Optional[dict]) -> bool: + if not isinstance(cached_overview, dict): + return True + fetched_at = _parse_iso_datetime(cached_overview.get("fetched_at")) + if not fetched_at: + return True + age = datetime.now(timezone.utc) - fetched_at + return age > timedelta(seconds=OVERVIEW_CACHE_TTL_SECONDS) + + +def _get_current_account_id(db) -> Optional[int]: + setting = crud.get_setting(db, CURRENT_ACCOUNT_SETTING_KEY) + if not setting or not setting.value: + return None + try: + return int(setting.value) + except (TypeError, ValueError): + return None + + +def _set_current_account_id(db, account_id: int): + crud.set_setting( + db, + key=CURRENT_ACCOUNT_SETTING_KEY, + value=str(account_id), + description="当前切换中的 Codex 账号 ID", + category="accounts", + ) + + +def _is_overview_card_removed(account: Account) -> bool: + extra_data = account.extra_data if isinstance(account.extra_data, dict) else {} + return bool(extra_data.get(OVERVIEW_CARD_REMOVED_KEY)) + + +def _set_overview_card_removed(account: Account, removed: bool): + extra_data = account.extra_data if isinstance(account.extra_data, dict) else {} + merged = dict(extra_data) + if removed: + merged[OVERVIEW_CARD_REMOVED_KEY] = True + else: + merged.pop(OVERVIEW_CARD_REMOVED_KEY, None) + account.extra_data = merged + + +def _write_current_account_snapshot(account: Account) -> Optional[str]: + """ + 写入当前账号快照文件,便于外部流程读取当前账号令牌。 + """ + try: + data_dir = Path("data") + data_dir.mkdir(parents=True, exist_ok=True) + output_file = data_dir / "current_codex_account.json" + payload = { + "id": account.id, + "email": account.email, + "plan_type": _normalize_plan_type(account.subscription_type), + "access_token": account.access_token, + "refresh_token": account.refresh_token, + "id_token": account.id_token, + "session_token": account.session_token, + "account_id": account.account_id, + "workspace_id": account.workspace_id, + "updated_at": datetime.now(timezone.utc).isoformat(), + } + output_file.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + return str(output_file) + except Exception as exc: + logger.warning(f"写入 current_codex_account.json 失败: {exc}") + return None + + +def _plan_to_subscription_type(plan_type: Optional[str]) -> Optional[str]: + key = (plan_type or "").strip().lower() + if key.startswith("team"): + return "team" + if key.startswith("plus"): + return "plus" + return None + + +def _normalize_subscription_input(value: Optional[str]) -> Optional[str]: + raw = str(value or "").strip().lower() + if not raw: + return None + if raw in ("team", "enterprise"): + return "team" + if raw in ("plus", "pro"): + return "plus" + if raw in ("free", "basic", "none", "null"): + return None + if "team" in raw: + return "team" + if "plus" in raw or "pro" in raw: + return "plus" + return None + + +def _is_paid_subscription(value: Optional[str]) -> bool: + """是否为付费订阅(plus/team)。""" + normalized = _normalize_subscription_input(value) + return normalized in PAID_SUBSCRIPTION_TYPES + + +def _pick_first_text(*values: Any) -> Optional[str]: + for value in values: + if value is None: + continue + text = str(value).strip() + if text: + return text + return None + + +def _decode_jwt_payload_unverified(token: Optional[str]) -> Dict[str, Any]: + """ + 无签名校验解码 JWT payload,仅用于导入兜底字段提取。 + """ + text = str(token or "").strip() + if not text or "." not in text: + return {} + try: + parts = text.split(".") + if len(parts) < 2: + return {} + payload_b64 = parts[1] + padding = "=" * (-len(payload_b64) % 4) + payload_raw = base64.urlsafe_b64decode((payload_b64 + padding).encode("utf-8")) + payload = json.loads(payload_raw.decode("utf-8")) + return payload if isinstance(payload, dict) else {} + except Exception: + return {} + + +def _get_nested(data: Dict[str, Any], path: List[str]) -> Any: + cur: Any = data + for key in path: + if not isinstance(cur, dict): + return None + cur = cur.get(key) + return cur + + +def _get_account_overview_data( + db, + account: Account, + force_refresh: bool = False, + proxy: Optional[str] = None, + allow_network: bool = True, +) -> tuple[dict, bool]: + updated = False + extra_data = account.extra_data if isinstance(account.extra_data, dict) else {} + cached = extra_data.get(OVERVIEW_EXTRA_DATA_KEY) if isinstance(extra_data, dict) else None + cache_stale = _is_overview_cache_stale(cached) + + if not account.access_token: + if cached: + stale_cached = dict(cached) + stale_cached["stale"] = True + stale_cached["error"] = "missing_access_token" + return stale_cached, updated + return _fallback_overview(account, error_message="missing_access_token"), updated + + if not force_refresh and cached and not cache_stale: + return cached, updated + + # 首屏卡片列表默认走“缓存优先”模式,避免首次进入被远端配额请求阻塞导致网络异常。 + if not allow_network: + if cached: + stale_cached = dict(cached) + if cache_stale: + stale_cached["stale"] = True + stale_cached.setdefault("error", "cache_stale") + return stale_cached, updated + return _fallback_overview(account, error_message="cache_miss", stale=True), updated + + try: + overview = fetch_codex_overview(account, proxy=proxy) + if cached and not force_refresh: + for key in ("hourly_quota", "weekly_quota", "code_review_quota"): + if ( + isinstance(cached.get(key), dict) + and isinstance(overview.get(key), dict) + and overview[key].get("status") == "unknown" + and cached[key].get("status") == "ok" + ): + overview[key] = cached[key] + + # 用高置信度来源同步本地订阅状态,确保 Plus/Team 判断可复用。 + plan_source = str(overview.get("plan_source") or "") + trusted_plan_sources = ( + "me.", + "wham_usage.", + "codex_usage.", + "id_token.", + "access_token.", + ) + if any(plan_source.startswith(prefix) for prefix in trusted_plan_sources): + current_sub = _normalize_subscription_input(account.subscription_type) + detected_sub = _plan_to_subscription_type(overview.get("plan_type")) + # 避免把本地已确认的付费订阅(plus/team)被远端偶发 free/basic 覆盖降级。 + if detected_sub and current_sub != detected_sub: + account.subscription_type = detected_sub + account.subscription_at = datetime.utcnow() if detected_sub else None + updated = True + elif not detected_sub and current_sub in PAID_SUBSCRIPTION_TYPES: + logger.info( + "总览订阅同步跳过降级: email=%s current=%s detected=%s source=%s", + account.email, + current_sub, + detected_sub or "free/basic", + plan_source, + ) + + merged_extra = dict(extra_data) + merged_extra[OVERVIEW_EXTRA_DATA_KEY] = overview + account.extra_data = merged_extra + updated = True + return overview, updated + except Exception as exc: + logger.warning(f"刷新账号[{account.email}]总览失败: {exc}") + if cached: + stale_cached = dict(cached) + stale_cached["stale"] = True + stale_cached["error"] = str(exc) + return stale_cached, updated + return _fallback_overview(account, error_message=str(exc), stale=True), updated + + +# ============== API Endpoints ============== + +@router.post("", response_model=AccountResponse) +async def create_manual_account(request: ManualAccountCreateRequest): + """ + 手动新增账号(邮箱 + 密码)。 + """ + email = (request.email or "").strip().lower() + password = (request.password or "").strip() + email_service = (request.email_service or "manual").strip() or "manual" + status = request.status or AccountStatus.ACTIVE.value + source = (request.source or "manual").strip() or "manual" + subscription_type = _normalize_subscription_input(request.subscription_type) + + if not email or "@" not in email: + raise HTTPException(status_code=400, detail="邮箱格式不正确") + if not password: + raise HTTPException(status_code=400, detail="密码不能为空") + if status not in [e.value for e in AccountStatus]: + raise HTTPException(status_code=400, detail="无效的状态值") + + with get_db() as db: + exists = crud.get_account_by_email(db, email) + if exists: + raise HTTPException(status_code=409, detail="该邮箱账号已存在") + + try: + account = crud.create_account( + db, + email=email, + password=password, + email_service=email_service, + status=status, + source=source, + client_id=request.client_id, + account_id=request.account_id, + workspace_id=request.workspace_id, + access_token=request.access_token, + refresh_token=request.refresh_token, + id_token=request.id_token, + session_token=request.session_token, + cookies=request.cookies, + proxy_used=request.proxy_used, + extra_data=request.metadata or {}, + ) + if subscription_type: + account.subscription_type = subscription_type + account.subscription_at = datetime.utcnow() + db.commit() + db.refresh(account) + except Exception as exc: + logger.error(f"手动创建账号失败: {exc}") + raise HTTPException(status_code=500, detail="创建账号失败") + + return account_to_response(account) + + +@router.post("/import") +async def import_accounts(request: ImportAccountsRequest): + """ + 一键导入账号(账号总览卡片使用)。 + 支持按账号详情字段导入;可选覆盖同邮箱已有账号。 + """ + items = request.accounts or [] + if not items: + raise HTTPException(status_code=400, detail="导入数据为空") + + max_import = 1000 + if len(items) > max_import: + raise HTTPException(status_code=400, detail=f"单次最多导入 {max_import} 条") + + result = { + "success": True, + "total": len(items), + "created": 0, + "updated": 0, + "skipped": 0, + "failed": 0, + "errors": [], + } + + def _safe_text(value: Optional[str]) -> Optional[str]: + if value is None: + return None + text = str(value).strip() + return text if text else None + + with get_db() as db: + for index, raw_item in enumerate(items, start=1): + if not isinstance(raw_item, dict): + result["failed"] += 1 + result["errors"].append( + {"index": index, "email": "-", "error": "导入项必须是 JSON 对象"} + ) + continue + + try: + item = AccountImportItem.model_validate(raw_item) + except Exception as exc: + result["failed"] += 1 + result["errors"].append( + {"index": index, "email": str(raw_item.get("email") or "-"), "error": f"字段格式错误: {exc}"} + ) + continue + + token_bundle = item.tokens if isinstance(item.tokens, dict) else {} + access_token = _pick_first_text(item.access_token, token_bundle.get("access_token"), token_bundle.get("accessToken")) + refresh_token = _pick_first_text(item.refresh_token, token_bundle.get("refresh_token"), token_bundle.get("refreshToken")) + id_token = _pick_first_text(item.id_token, token_bundle.get("id_token"), token_bundle.get("idToken")) + session_token = _pick_first_text( + item.session_token, + token_bundle.get("session_token"), + token_bundle.get("sessionToken"), + ) + client_id = _pick_first_text(item.client_id, token_bundle.get("client_id"), token_bundle.get("clientId")) + + access_claims = _decode_jwt_payload_unverified(access_token) + id_claims = _decode_jwt_payload_unverified(id_token) + + auth_claims = {} + for claims in (access_claims, id_claims): + auth_obj = _get_nested(claims, ["https://api.openai.com/auth"]) + if isinstance(auth_obj, dict): + auth_claims = auth_obj + break + + account_id_value = _pick_first_text( + item.account_id, + raw_item.get("account_id"), + auth_claims.get("chatgpt_account_id"), + ) + workspace_id_value = _pick_first_text( + item.workspace_id, + raw_item.get("workspace_id"), + account_id_value, + ) + + if not client_id: + id_aud = id_claims.get("aud") + id_aud_first = id_aud[0] if isinstance(id_aud, list) and id_aud else None + client_id = _pick_first_text( + access_claims.get("client_id"), + id_aud_first, + ) + + email = str(item.email or "").strip().lower() + if not email or "@" not in email: + result["failed"] += 1 + result["errors"].append({"index": index, "email": email or "-", "error": "邮箱格式不正确"}) + continue + + status = str(item.status or AccountStatus.ACTIVE.value).strip().lower() + if status not in [e.value for e in AccountStatus]: + status = AccountStatus.ACTIVE.value + + email_service = str(item.email_service or "manual").strip() or "manual" + source = str(item.source or "import").strip() or "import" + subscription_type = ( + _normalize_subscription_input(item.subscription_type) + or _normalize_subscription_input(item.plan_type) + or _normalize_subscription_input(_pick_first_text( + raw_item.get("plan_type"), + auth_claims.get("chatgpt_plan_type"), + )) + ) + metadata = dict(item.metadata) if isinstance(item.metadata, dict) else {} + for extra_key in ( + "id", + "auth_mode", + "user_id", + "organization_id", + "account_name", + "account_structure", + "quota", + "tags", + "created_at", + "last_used", + "usage_updated_at", + "plan_type", + ): + value = raw_item.get(extra_key) + if value is not None: + metadata[extra_key] = value + if isinstance(token_bundle, dict) and token_bundle: + metadata["tokens_shape"] = list(token_bundle.keys()) + + exists = crud.get_account_by_email(db, email) + if exists and not request.overwrite: + result["skipped"] += 1 + continue + + try: + if exists and request.overwrite: + update_payload = { + "password": _safe_text(item.password), + "email_service": email_service, + "status": status, + "client_id": _safe_text(client_id), + "account_id": _safe_text(account_id_value), + "workspace_id": _safe_text(workspace_id_value), + "access_token": _safe_text(access_token), + "refresh_token": _safe_text(refresh_token), + "id_token": _safe_text(id_token), + "session_token": _safe_text(session_token), + "cookies": item.cookies if item.cookies is not None else None, + "proxy_used": _safe_text(item.proxy_used), + "source": source, + "extra_data": metadata, + "last_refresh": datetime.utcnow(), + } + clean_update_payload = {k: v for k, v in update_payload.items() if v is not None} + account = crud.update_account(db, exists.id, **clean_update_payload) + if account is None: + raise RuntimeError("更新账号失败") + account.subscription_type = subscription_type + account.subscription_at = datetime.utcnow() if subscription_type else None + db.commit() + result["updated"] += 1 + continue + + account = crud.create_account( + db, + email=email, + password=_safe_text(item.password), + client_id=_safe_text(client_id), + session_token=_safe_text(session_token), + email_service=email_service, + account_id=_safe_text(account_id_value), + workspace_id=_safe_text(workspace_id_value), + access_token=_safe_text(access_token), + refresh_token=_safe_text(refresh_token), + id_token=_safe_text(id_token), + cookies=item.cookies, + proxy_used=_safe_text(item.proxy_used), + extra_data=metadata, + status=status, + source=source, + ) + if subscription_type: + account.subscription_type = subscription_type + account.subscription_at = datetime.utcnow() + db.commit() + result["created"] += 1 + except Exception as exc: + result["failed"] += 1 + result["errors"].append({"index": index, "email": email, "error": str(exc)}) + + return result + + +@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 = _apply_status_filter(query, 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("/overview/cards") +async def list_accounts_overview_cards( + refresh: bool = Query(False, description="是否强制刷新远端配额"), + search: Optional[str] = Query(None, description="按邮箱搜索"), + status: Optional[str] = Query(None, description="状态筛选"), + email_service: Optional[str] = Query(None, description="邮箱服务筛选"), + proxy: Optional[str] = Query(None, description="可选代理地址"), +): + """ + 账号总览卡片数据。 + """ + with get_db() as db: + query = db.query(Account).filter( + func.lower(Account.subscription_type).in_(PAID_SUBSCRIPTION_TYPES) + ) + if search: + pattern = f"%{search}%" + query = query.filter((Account.email.ilike(pattern)) | (Account.account_id.ilike(pattern))) + if status: + query = _apply_status_filter(query, status) + if email_service: + query = query.filter(Account.email_service == email_service) + + accounts = [ + account + for account in query.order_by(Account.created_at.desc()).all() + if not _is_overview_card_removed(account) + ] + current_account_id = _get_current_account_id(db) + global_proxy = _get_proxy(proxy) + # 卡片列表接口默认“缓存优先”,避免首次进入或新增卡片后触发全量远端请求造成页面卡死。 + # 需要强制刷新时统一走 /overview/refresh。 + allow_network = False + if refresh: + logger.info("overview/cards 接口忽略 refresh 参数,改由 /overview/refresh 执行远端刷新") + + rows = [] + db_updated = False + + for account in accounts: + account_proxy = (account.proxy_used or "").strip() or global_proxy + overview, updated = _get_account_overview_data( + db, + account, + force_refresh=refresh, + proxy=account_proxy, + allow_network=allow_network, + ) + db_updated = db_updated or updated + + overview_plan_raw = overview.get("plan_type") + db_plan_raw = account.subscription_type + has_db_subscription = bool(str(db_plan_raw or "").strip()) + # 与账号管理保持一致:卡片套餐优先使用 DB 的 subscription_type。 + effective_plan_raw = db_plan_raw if has_db_subscription else overview_plan_raw + effective_plan_source = ( + "db.subscription_type" + if has_db_subscription + else (overview.get("plan_source") or "default") + ) + if not _is_paid_subscription(effective_plan_raw): + # Codex 账号管理仅允许 plus/team 账号进入。 + continue + + rows.append( + { + "id": account.id, + "email": account.email, + "status": account.status, + "email_service": account.email_service, + "created_at": account.created_at.isoformat() if account.created_at else None, + "last_refresh": account.last_refresh.isoformat() if account.last_refresh else None, + "current": account.id == current_account_id, + "has_access_token": bool(account.access_token), + "plan_type": _normalize_plan_type(effective_plan_raw), + "plan_source": effective_plan_source, + "has_plus_or_team": _plan_to_subscription_type(effective_plan_raw) is not None, + "hourly_quota": overview.get("hourly_quota") or _build_unknown_quota(), + "weekly_quota": overview.get("weekly_quota") or _build_unknown_quota(), + "code_review_quota": overview.get("code_review_quota") or _build_unknown_quota(), + "overview_fetched_at": overview.get("fetched_at"), + "overview_stale": bool(overview.get("stale")), + "overview_error": overview.get("error"), + } + ) + + if db_updated: + db.commit() + + return { + "total": len(rows), + "current_account_id": current_account_id, + "cache_ttl_seconds": OVERVIEW_CACHE_TTL_SECONDS, + "network_mode": "refresh" if allow_network else "cache_only", + "proxy": global_proxy or None, + "accounts": rows, + "refreshed_at": datetime.now(timezone.utc).isoformat(), + } + + +@router.get("/overview/cards/addable") +async def list_accounts_overview_addable( + search: Optional[str] = Query(None, description="按邮箱搜索"), + status: Optional[str] = Query(None, description="状态筛选"), + email_service: Optional[str] = Query(None, description="邮箱服务筛选"), +): + """读取已从卡片删除的账号,用于“添加账号”里重新添加。""" + with get_db() as db: + query = db.query(Account) + if search: + pattern = f"%{search}%" + query = query.filter((Account.email.ilike(pattern)) | (Account.account_id.ilike(pattern))) + if status: + query = _apply_status_filter(query, status) + if email_service: + query = query.filter(Account.email_service == email_service) + + accounts = query.order_by(Account.created_at.desc()).all() + rows = [] + for account in accounts: + if not _is_overview_card_removed(account): + continue + if not _is_paid_subscription(account.subscription_type): + continue + rows.append( + { + "id": account.id, + "email": account.email, + "status": account.status, + "email_service": account.email_service, + "subscription_type": account.subscription_type or "free", + "has_access_token": bool(account.access_token), + "created_at": account.created_at.isoformat() if account.created_at else None, + } + ) + + return { + "total": len(rows), + "accounts": rows, + } + + +@router.get("/overview/cards/selectable") +async def list_accounts_overview_selectable( + search: Optional[str] = Query(None, description="按邮箱搜索"), + status: Optional[str] = Query(None, description="状态筛选"), + email_service: Optional[str] = Query(None, description="邮箱服务筛选"), +): + """读取账号管理中的可选账号,用于账号总览添加/重新添加。""" + with get_db() as db: + query = db.query(Account) + if search: + pattern = f"%{search}%" + query = query.filter((Account.email.ilike(pattern)) | (Account.account_id.ilike(pattern))) + if status: + query = _apply_status_filter(query, status) + if email_service: + query = query.filter(Account.email_service == email_service) + + accounts = query.order_by(Account.created_at.desc()).all() + rows = [] + for account in accounts: + # 仅返回当前未在卡片中的账号(即已从卡片移除) + if not _is_overview_card_removed(account): + continue + if not _is_paid_subscription(account.subscription_type): + continue + rows.append( + { + "id": account.id, + "email": account.email, + "password": account.password or "", + "status": account.status, + "email_service": account.email_service, + "subscription_type": account.subscription_type or "free", + "client_id": account.client_id or "", + "account_id": account.account_id or "", + "workspace_id": account.workspace_id or "", + "has_access_token": bool(account.access_token), + "created_at": account.created_at.isoformat() if account.created_at else None, + } + ) + + return { + "total": len(rows), + "accounts": rows, + } + + +@router.post("/overview/cards/remove") +async def remove_accounts_overview_cards(request: OverviewCardDeleteRequest): + """从账号总览卡片移除(软删除,不影响账号管理列表)。""" + 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, + ) + removed_count = 0 + missing_ids = [] + for account_id in ids: + account = crud.get_account_by_id(db, account_id) + if not account: + missing_ids.append(account_id) + continue + if not _is_overview_card_removed(account): + removed_count += 1 + _set_overview_card_removed(account, True) + + db.commit() + return { + "success": True, + "removed_count": removed_count, + "total": len(ids), + "missing_ids": missing_ids, + } + + +@router.post("/overview/cards/{account_id}/restore") +async def restore_accounts_overview_card(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="账号不存在") + if not _is_paid_subscription(account.subscription_type): + raise HTTPException(status_code=400, detail="仅 plus/team 账号可进入 Codex 账号管理") + + _set_overview_card_removed(account, False) + db.commit() + return {"success": True, "id": account.id, "email": account.email} + + +@router.post("/overview/cards/{account_id}/attach") +async def attach_accounts_overview_card(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="账号不存在") + if not _is_paid_subscription(account.subscription_type): + raise HTTPException(status_code=400, detail="仅 plus/team 账号可进入 Codex 账号管理") + + was_removed = _is_overview_card_removed(account) + _set_overview_card_removed(account, False) + db.commit() + return { + "success": True, + "id": account.id, + "email": account.email, + "already_in_cards": not was_removed, + } + + +@router.post("/overview/refresh") +async def refresh_accounts_overview(request: OverviewRefreshRequest): + """ + 批量刷新账号总览数据。 + """ + proxy = _get_proxy(request.proxy) + result = {"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, + ) + if not ids: + # 默认仅刷新“卡片里可见的付费账号”,避免无关账号导致全量阻塞。 + candidates = db.query(Account).filter( + func.lower(Account.subscription_type).in_(PAID_SUBSCRIPTION_TYPES) + ).order_by(Account.created_at.desc()).all() + ids = [acc.id for acc in candidates if not _is_overview_card_removed(acc)] + + logger.info( + "账号总览刷新开始: target_count=%s force=%s select_all=%s proxy=%s", + len(ids), + bool(request.force), + bool(request.select_all), + proxy or "-", + ) + + for account_id in ids: + account = crud.get_account_by_id(db, account_id) + if not account: + result["failed_count"] += 1 + result["details"].append({"id": account_id, "success": False, "error": "账号不存在"}) + logger.warning("账号总览刷新失败: account_id=%s error=账号不存在", account_id) + continue + if (not _is_paid_subscription(account.subscription_type)) or _is_overview_card_removed(account): + result["details"].append( + { + "id": account.id, + "email": account.email, + "success": False, + "error": "账号不在 Codex 卡片范围内,已跳过", + } + ) + continue + + account_proxy = (account.proxy_used or "").strip() or proxy + overview, updated = _get_account_overview_data( + db, + account, + force_refresh=request.force, + proxy=account_proxy, + allow_network=True, + ) + if updated: + db.commit() + + if overview.get("hourly_quota", {}).get("status") == "unknown" and overview.get("weekly_quota", {}).get("status") == "unknown": + result["failed_count"] += 1 + result["details"].append( + { + "id": account.id, + "email": account.email, + "success": False, + "error": overview.get("error") or "未获取到配额数据", + } + ) + logger.warning( + "账号总览刷新失败: account_id=%s email=%s error=%s", + account.id, + account.email, + overview.get("error") or "未获取到配额数据", + ) + else: + result["success_count"] += 1 + result["details"].append( + { + "id": account.id, + "email": account.email, + "success": True, + "plan_type": overview.get("plan_type"), + } + ) + logger.info( + "账号总览刷新成功: account_id=%s email=%s plan=%s hourly=%s weekly=%s code_review=%s hourly_source=%s weekly_source=%s", + account.id, + account.email, + overview.get("plan_type") or "-", + overview.get("hourly_quota", {}).get("percentage"), + overview.get("weekly_quota", {}).get("percentage"), + overview.get("code_review_quota", {}).get("percentage"), + overview.get("hourly_quota", {}).get("source"), + overview.get("weekly_quota", {}).get("source"), + ) + + logger.info( + "账号总览刷新完成: success=%s failed=%s", + result["success_count"], + result["failed_count"], + ) + + return result + + +@router.get("/current") +async def get_current_account(): + """获取当前已切换的账号""" + with get_db() as db: + current_id = _get_current_account_id(db) + if not current_id: + return {"current_account_id": None, "account": None} + account = crud.get_account_by_id(db, current_id) + if not account: + return {"current_account_id": None, "account": None} + return { + "current_account_id": account.id, + "account": { + "id": account.id, + "email": account.email, + "status": account.status, + "email_service": account.email_service, + "plan_type": _normalize_plan_type(account.subscription_type), + }, + } + + +@router.post("/{account_id}/switch") +async def switch_current_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="账号不存在") + + _set_current_account_id(db, account_id) + snapshot_path = _write_current_account_snapshot(account) + + return { + "success": True, + "current_account_id": account_id, + "email": account.email, + "snapshot_file": snapshot_path, + } + + +@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="账号不存在") + + resolved_session_token = _resolve_account_session_token(account) + session_source = "db" if str(account.session_token or "").strip() else ("cookies" if resolved_session_token else "none") + + # 若 DB 为空但 cookies 可解析到 session_token,自动回写,避免后续重复解析。 + if resolved_session_token and not str(account.session_token or "").strip(): + account.session_token = resolved_session_token + account.last_refresh = datetime.utcnow() + db.commit() + db.refresh(account) + + return { + "id": account.id, + "email": account.email, + "access_token": account.access_token, + "refresh_token": account.refresh_token, + "id_token": account.id_token, + "session_token": resolved_session_token, + "session_token_source": session_source, + "device_id": _resolve_account_device_id(account), + "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 + + if request.session_token is not None: + # 留空则清空,非空则更新 + update_data["session_token"] = request.session_token or None + update_data["last_refresh"] = datetime.utcnow() + + 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} + } + + +@router.get("/stats/overview") +async def get_accounts_overview(): + """获取账号总览统计信息(用于总览页面)""" + with get_db() as db: + total = db.query(func.count(Account.id)).scalar() or 0 + active_count = db.query(func.count(Account.id)).filter( + Account.status == AccountStatus.ACTIVE.value + ).scalar() or 0 + + with_access_token = db.query(func.count(Account.id)).filter( + Account.access_token.isnot(None), + Account.access_token != "", + ).scalar() or 0 + with_refresh_token = db.query(func.count(Account.id)).filter( + Account.refresh_token.isnot(None), + Account.refresh_token != "", + ).scalar() or 0 + without_access_token = max(total - with_access_token, 0) + + cpa_uploaded_count = db.query(func.count(Account.id)).filter( + Account.cpa_uploaded.is_(True) + ).scalar() or 0 + + 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() + + source_stats = db.query( + Account.source, + func.count(Account.id), + ).group_by(Account.source).all() + + subscription_stats = db.query( + Account.subscription_type, + func.count(Account.id), + ).group_by(Account.subscription_type).all() + + recent_accounts = db.query(Account).order_by(Account.created_at.desc()).limit(10).all() + + return { + "total": total, + "active_count": active_count, + "token_stats": { + "with_access_token": with_access_token, + "with_refresh_token": with_refresh_token, + "without_access_token": without_access_token, + }, + "cpa_uploaded_count": cpa_uploaded_count, + "by_status": {status or "unknown": count for status, count in status_stats}, + "by_email_service": {service or "unknown": count for service, count in service_stats}, + "by_source": {source or "unknown": count for source, count in source_stats}, + "by_subscription": { + (subscription or "free"): count for subscription, count in subscription_stats + }, + "recent_accounts": [ + { + "id": acc.id, + "email": acc.email, + "status": acc.status, + "email_service": acc.email_service, + "source": acc.source, + "subscription_type": acc.subscription_type or "free", + "created_at": acc.created_at.isoformat() if acc.created_at else None, + "last_refresh": acc.last_refresh.isoformat() if acc.last_refresh else None, + } + for acc in recent_accounts + ], + } + + +# ============== 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: + # 异常账号兜底打标 failed,保证前端“失败”筛选可见。 + try: + with get_db() as db: + account = crud.get_account_by_id(db, account_id) + if account and account.status != AccountStatus.FAILED.value: + crud.update_account(db, account_id, status=AccountStatus.FAILED.value) + except Exception: + pass + 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 if request.proxy else get_settings().proxy_url + + # 解析指定的 CPA 服务 + cpa_api_url = None + cpa_api_token = None + 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 + + 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) + 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 and request.proxy else get_settings().proxy_url + cpa_service_id = request.cpa_service_id if request else None + + # 解析指定的 CPA 服务 + cpa_api_url = None + cpa_api_token = None + 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 + + 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) + + # 上传 + 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 + + +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 + + +@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, + ) + 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 00000000..69317239 --- /dev/null +++ b/src/web/routes/email.py @@ -0,0 +1,619 @@ +""" +邮箱服务配置 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', + 'admin_password', + 'custom_auth', +} + +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": "custom_auth", "label": "Custom Auth(可选)", "required": False, "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/logs.py b/src/web/routes/logs.py new file mode 100644 index 00000000..5a4131ac --- /dev/null +++ b/src/web/routes/logs.py @@ -0,0 +1,123 @@ +""" +后台日志 API +""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Optional + +from fastapi import APIRouter, Query, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy import func, or_ + +from ...core.db_logs import cleanup_database_logs +from ...core.timezone_utils import to_shanghai_iso +from ...database.models import AppLog +from ...database.session import get_db + + +router = APIRouter() + + +def _serialize_log_row(row: AppLog) -> dict: + payload = row.to_dict() + payload["created_at"] = to_shanghai_iso(row.created_at) + return payload + + +class CleanupLogsRequest(BaseModel): + retention_days: Optional[int] = Field(default=None, ge=1, le=3650) + max_rows: int = Field(default=50000, ge=1000, le=5000000) + + +@router.get("") +def list_logs( + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(100, ge=1, le=500, description="每页数量"), + level: Optional[str] = Query(None, description="日志级别"), + logger_name: Optional[str] = Query(None, description="logger 名称关键词"), + keyword: Optional[str] = Query(None, description="消息关键词"), + since_minutes: Optional[int] = Query(None, ge=1, le=10080, description="仅返回最近 N 分钟"), +): + with get_db() as db: + query = db.query(AppLog) + + if level: + query = query.filter(AppLog.level == level.upper()) + + if logger_name: + query = query.filter(AppLog.logger.ilike(f"%{logger_name.strip()}%")) + + if keyword: + pattern = f"%{keyword.strip()}%" + query = query.filter( + or_( + AppLog.message.ilike(pattern), + AppLog.logger.ilike(pattern), + AppLog.module.ilike(pattern), + ) + ) + + if since_minutes: + since_at = datetime.utcnow() - timedelta(minutes=since_minutes) + query = query.filter(AppLog.created_at >= since_at) + + total = query.count() + offset = (page - 1) * page_size + rows = ( + query.order_by(AppLog.created_at.desc(), AppLog.id.desc()) + .offset(offset) + .limit(page_size) + .all() + ) + + return { + "total": total, + "page": page, + "page_size": page_size, + "logs": [_serialize_log_row(row) for row in rows], + } + + +@router.get("/stats") +def log_stats(): + with get_db() as db: + total = db.query(func.count(AppLog.id)).scalar() or 0 + latest = db.query(func.max(AppLog.created_at)).scalar() + grouped = db.query(AppLog.level, func.count(AppLog.id)).group_by(AppLog.level).all() + + level_counts = {str(level or "UNKNOWN"): int(count or 0) for level, count in grouped} + return { + "total": int(total), + "latest_at": to_shanghai_iso(latest), + "levels": level_counts, + } + + +@router.post("/cleanup") +def cleanup_logs(request: CleanupLogsRequest): + result = cleanup_database_logs( + retention_days=request.retention_days, + max_rows=request.max_rows, + ) + return {"success": True, **result} + + +@router.delete("") +def clear_logs(confirm: bool = Query(False, description="确认清空日志")): + """ + 清空日志表(硬删除)。 + """ + if not confirm: + raise HTTPException(status_code=400, detail="请传入 confirm=true 以确认清空日志") + + with get_db() as db: + deleted = db.query(AppLog).delete(synchronize_session=False) + db.commit() + + return { + "success": True, + "deleted_total": int(deleted or 0), + "remaining": 0, + } diff --git a/src/web/routes/payment.py b/src/web/routes/payment.py new file mode 100644 index 00000000..ee27ce57 --- /dev/null +++ b/src/web/routes/payment.py @@ -0,0 +1,3299 @@ +""" +支付相关 API 路由 +""" + +import logging +import os +import re +import uuid +from typing import Optional, List +from datetime import datetime +import time +from urllib.parse import urlparse, urlunparse + +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel, Field +from sqlalchemy import or_ +from sqlalchemy.orm import joinedload +from curl_cffi import requests as cffi_requests + +from ...database.session import get_db +from ...database.models import Account, BindCardTask, EmailService as EmailServiceModel +from ...config.settings import get_settings +from ...config.constants import OPENAI_PAGE_TYPES +from ...services import EmailServiceFactory, EmailServiceType +from ...core.register import RegistrationEngine +from .accounts import resolve_account_ids +from ...core.openai.payment import ( + generate_plus_checkout_bundle, + generate_team_checkout_bundle, + generate_aimizy_payment_link, + open_url_incognito, + check_subscription_status_detail, +) +from ...core.openai.browser_bind import auto_bind_checkout_with_playwright +from ...core.openai.random_billing import generate_random_billing_profile +from ...core.openai.token_refresh import TokenRefreshManager +from ...core.dynamic_proxy import get_proxy_url_for_task + +logger = logging.getLogger(__name__) +router = APIRouter() +CHECKOUT_SESSION_REGEX = re.compile(r"\bcs_[A-Za-z0-9_-]+\b", re.IGNORECASE) +THIRD_PARTY_BIND_API_URL_ENV = "BIND_CARD_API_URL" +THIRD_PARTY_BIND_API_KEY_ENV = "BIND_CARD_API_KEY" +THIRD_PARTY_BIND_API_DEFAULT = "https://twilight-river-f148.482091502.workers.dev/" +THIRD_PARTY_BIND_PATH_DEFAULT = "/api/v1/bind-card" +CHECKOUT_CONNECTIVITY_ERROR_KEYWORDS = ( + "failed to connect", + "could not connect to server", + "connection refused", + "timed out", + "timeout", + "temporary failure in name resolution", + "name or service not known", + "proxy connect", + "network is unreachable", + "curl: (7)", + "curl: (28)", + "curl: (35)", + "curl: (56)", +) +REGION_BLOCK_ERROR_KEYWORDS = ( + "unsupported_country_region_territory", + "country, region, or territory not supported", + "request_forbidden", +) +CHECKOUT_COUNTRY_CURRENCY_MAP = { + "US": "USD", + "GB": "GBP", + "CA": "CAD", + "AU": "AUD", + "SG": "SGD", + "HK": "HKD", + "JP": "JPY", + "TR": "TRY", + "IN": "INR", + "BR": "BRL", + "MX": "MXN", + "DE": "EUR", + "FR": "EUR", + "IT": "EUR", + "ES": "EUR", + "EU": "EUR", +} + + +def _is_official_checkout_link(link: Optional[str]) -> bool: + return isinstance(link, str) and link.startswith("https://chatgpt.com/checkout/openai_llc/") + + +def _is_checkout_connectivity_error(err: Exception) -> bool: + text = str(err or "").strip().lower() + if not text: + return False + return any(token in text for token in CHECKOUT_CONNECTIVITY_ERROR_KEYWORDS) + + +def _is_region_block_error_text(text: Optional[str]) -> bool: + raw = str(text or "").strip().lower() + if not raw: + return False + return any(token in raw for token in REGION_BLOCK_ERROR_KEYWORDS) + + +def _normalize_checkout_country(country: Optional[str]) -> str: + code = str(country or "US").strip().upper() + if code in CHECKOUT_COUNTRY_CURRENCY_MAP: + return code + return "US" + + +def _normalize_checkout_currency(country: str, currency: Optional[str]) -> str: + raw = str(currency or "").strip().upper() + if raw: + return raw + return CHECKOUT_COUNTRY_CURRENCY_MAP.get(country, "USD") + + +def _normalize_proxy_value(proxy: Optional[str]) -> str: + return str(proxy or "").strip() + + +def _build_proxy_candidates( + explicit_proxy: Optional[str], + account: Optional[Account] = None, + *, + include_direct: bool = True, +) -> List[Optional[str]]: + """ + 代理候选顺序: + 1) 显式传入 + 2) 账号历史代理(注册时成功线路) + 3) 系统全局代理 + 4) 直连(可选) + """ + candidates: List[Optional[str]] = [] + seen = set() + + account_proxy = _normalize_proxy_value(getattr(account, "proxy_used", None) if account else None) + settings_proxy = _normalize_proxy_value(get_settings().proxy_url) + explicit_proxy_norm = _normalize_proxy_value(explicit_proxy) + + for item in (explicit_proxy_norm, account_proxy, settings_proxy): + if not item or item in seen: + continue + candidates.append(item) + seen.add(item) + + if include_direct: + candidates.append(None) + elif not candidates: + return [] + + if not candidates: + return [None] + return candidates + + +def _resolve_runtime_proxy(explicit_proxy: Optional[str], account: Optional[Account] = None) -> Optional[str]: + """ + 选一个首选代理,给非轮询型接口使用。 + """ + for candidate in _build_proxy_candidates(explicit_proxy, account, include_direct=False): + if candidate: + return candidate + try: + dynamic_proxy = _normalize_proxy_value(get_proxy_url_for_task()) + except Exception: + dynamic_proxy = "" + if dynamic_proxy: + return dynamic_proxy + return None + + +def _serialize_bind_card_task(task: BindCardTask) -> dict: + account_email = task.account.email if task.account else None + return { + "id": task.id, + "account_id": task.account_id, + "account_email": account_email, + "plan_type": task.plan_type, + "workspace_name": task.workspace_name, + "price_interval": task.price_interval, + "seat_quantity": task.seat_quantity, + "country": task.country, + "currency": task.currency, + "checkout_url": task.checkout_url, + "checkout_session_id": task.checkout_session_id, + "publishable_key": task.publishable_key, + "has_client_secret": bool(getattr(task, "client_secret", None)), + "checkout_source": task.checkout_source, + "bind_mode": task.bind_mode or "semi_auto", + "status": task.status, + "last_error": task.last_error, + "opened_at": task.opened_at.isoformat() if task.opened_at else None, + "last_checked_at": task.last_checked_at.isoformat() if task.last_checked_at else None, + "completed_at": task.completed_at.isoformat() if task.completed_at else None, + "created_at": task.created_at.isoformat() if task.created_at else None, + "updated_at": task.updated_at.isoformat() if task.updated_at else None, + } + + +def _extract_checkout_session_id_from_url(url: Optional[str]) -> Optional[str]: + text = str(url or "").strip() + if not text: + return None + match = CHECKOUT_SESSION_REGEX.search(text) + if match: + return match.group(0) + return None + + +def _resolve_account_device_id(account: Account) -> str: + """ + 兼容解析账号 device id。 + 历史模型未包含 device_id 字段,需从 cookies/extra_data 兜底读取。 + """ + direct = str(getattr(account, "device_id", "") or "").strip() + if direct: + return direct + + cookies_text = str(getattr(account, "cookies", "") or "") + if cookies_text: + match = re.search(r"(?:^|;\s*)oai-did=([^;]+)", cookies_text) + if match: + value = str(match.group(1) or "").strip() + if value: + return value + + extra_data = getattr(account, "extra_data", None) + if isinstance(extra_data, dict): + for key in ("device_id", "oai_did", "oai-device-id"): + value = str(extra_data.get(key) or "").strip() + if value: + return value + return str(uuid.uuid4()) + + +def _extract_cookie_value(cookies_text: Optional[str], cookie_name: str) -> str: + text = str(cookies_text or "") + if not text: + return "" + pattern = re.compile(rf"(?:^|;\s*){re.escape(cookie_name)}=([^;]+)") + match = pattern.search(text) + if not match: + return "" + return str(match.group(1) or "").strip() + + +def _extract_session_token_from_cookie_text(cookies_text: Optional[str]) -> str: + text = str(cookies_text or "") + if not text: + return "" + + direct = _extract_cookie_value(text, "__Secure-next-auth.session-token") + if direct: + return direct + + # NextAuth 可能把大 token 分片为 .0/.1/.2... + chunks: dict[int, str] = {} + for raw in text.split(";"): + item = str(raw or "").strip() + if not item or "=" not in item: + continue + name, value = item.split("=", 1) + key = str(name or "").strip() + if not key.startswith("__Secure-next-auth.session-token."): + continue + try: + idx = int(key.rsplit(".", 1)[-1]) + except Exception: + continue + chunks[idx] = str(value or "").strip() + if chunks: + return "".join(chunks[idx] for idx in sorted(chunks.keys())) + return "" + + +def _extract_session_token_from_cookie_jar(cookie_jar) -> str: + try: + direct = str(cookie_jar.get("__Secure-next-auth.session-token") or "").strip() + except Exception: + direct = "" + if direct: + return direct + + chunks: dict[int, str] = {} + try: + items = list(cookie_jar.items()) + except Exception: + items = [] + for key, value in items: + name = str(key or "").strip() + if not name.startswith("__Secure-next-auth.session-token."): + continue + try: + idx = int(name.rsplit(".", 1)[-1]) + except Exception: + continue + chunks[idx] = str(value or "").strip() + if chunks: + return "".join(chunks[idx] for idx in sorted(chunks.keys())) + return "" + + +def _extract_session_token_chunks_from_cookie_text(cookies_text: Optional[str]) -> List[int]: + text = str(cookies_text or "") + if not text: + return [] + indices: List[int] = [] + seen = set() + for raw in text.split(";"): + item = str(raw or "").strip() + if not item or "=" not in item: + continue + name, _ = item.split("=", 1) + key = str(name or "").strip() + if not key.startswith("__Secure-next-auth.session-token."): + continue + try: + idx = int(key.rsplit(".", 1)[-1]) + except Exception: + continue + if idx in seen: + continue + seen.add(idx) + indices.append(idx) + return sorted(indices) + + +def _mask_secret(value: Optional[str], keep_start: int = 6, keep_end: int = 4) -> str: + text = str(value or "").strip() + if not text: + return "" + if len(text) <= keep_start + keep_end + 2: + return "*" * len(text) + return f"{text[:keep_start]}...{text[-keep_end:]}" + + +def _probe_auth_session_context(account: Account, proxy: Optional[str]) -> dict: + """ + 对当前账号做一次实时 session 探测,帮助定位“缺 session token”的根因。 + """ + session = cffi_requests.Session( + impersonate="chrome120", + proxy=proxy, + ) + _seed_cookie_jar_from_text(session, account.cookies) + + device_id = _resolve_account_device_id(account) + if device_id: + try: + session.cookies.set("oai-did", device_id, domain=".chatgpt.com", path="/") + except Exception: + pass + + headers = { + "Accept": "application/json", + "Referer": "https://chatgpt.com/", + "Origin": "https://chatgpt.com", + "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" + ), + } + access_token = str(account.access_token or "").strip() + if access_token: + headers["Authorization"] = f"Bearer {access_token}" + + try: + # 先热身主页,提升 next-auth 会话链路稳定性 + session.get( + "https://chatgpt.com/", + headers={ + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Referer": "https://chatgpt.com/", + "User-Agent": headers["User-Agent"], + }, + timeout=20, + ) + except Exception: + pass + + result = { + "ok": False, + "http_status": None, + "session_token_found": False, + "session_token_preview": "", + "access_token_in_session_json": False, + "access_token_preview": "", + "error": "", + } + + try: + resp = session.get( + "https://chatgpt.com/api/auth/session", + headers=headers, + timeout=25, + ) + result["http_status"] = int(getattr(resp, "status_code", 0) or 0) + + session_token = _extract_session_token_from_cookie_jar(getattr(resp, "cookies", None)) + if not session_token: + session_token = _extract_session_token_from_cookie_jar(getattr(session, "cookies", None)) + if not session_token: + set_cookie = ( + " | ".join(resp.headers.get_list("set-cookie")) + if hasattr(resp.headers, "get_list") + else str(resp.headers.get("set-cookie") or "") + ) + match_direct = re.search(r"__Secure-next-auth\.session-token=([^;,\s]+)", set_cookie) + if match_direct: + session_token = str(match_direct.group(1) or "").strip() + else: + chunk_matches = re.findall(r"__Secure-next-auth\.session-token\.(\d+)=([^;,\s]+)", set_cookie) + if chunk_matches: + chunk_map = {int(i): v for i, v in chunk_matches if str(i).isdigit()} + if chunk_map: + session_token = "".join(chunk_map[idx] for idx in sorted(chunk_map.keys())) + + payload = {} + try: + payload = resp.json() if resp.content else {} + except Exception: + payload = {} + session_access = str(payload.get("accessToken") or "").strip() + + result["session_token_found"] = bool(session_token) + result["session_token_preview"] = _mask_secret(session_token) + result["access_token_in_session_json"] = bool(session_access) + result["access_token_preview"] = _mask_secret(session_access) + result["ok"] = result["http_status"] == 200 + return result + except Exception as exc: + result["error"] = str(exc) + return result + + +def _force_fetch_nextauth_session_token( + *, + access_token: Optional[str], + cookies_text: Optional[str], + device_id: Optional[str], + proxy: Optional[str], +) -> tuple[str, str]: + """ + 尝试通过 /api/auth/session 强制换取 __Secure-next-auth.session-token。 + Returns: + (session_token, fresh_access_token) + """ + initial_access = str(access_token or "").strip() + latest_access = initial_access + proxy_norm = str(proxy or "").strip() + proxy_candidates: List[Optional[str]] = [proxy_norm] if proxy_norm else [None] + + for proxy_item in proxy_candidates: + session = cffi_requests.Session( + impersonate="chrome120", + proxy=proxy_item, + ) + _seed_cookie_jar_from_text(session, cookies_text) + + did = str(device_id or "").strip() + if did: + try: + session.cookies.set("oai-did", did, domain=".chatgpt.com", path="/") + except Exception: + pass + + headers = { + "Accept": "application/json", + "Referer": "https://chatgpt.com/", + "Origin": "https://chatgpt.com", + "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" + ), + } + access = latest_access + if access: + headers["Authorization"] = f"Bearer {access}" + + try: + session.get( + "https://chatgpt.com/", + headers={ + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Referer": "https://chatgpt.com/", + "User-Agent": headers["User-Agent"], + }, + timeout=20, + ) + except Exception: + pass + + for _ in range(2): + resp = session.get( + "https://chatgpt.com/api/auth/session", + headers=headers, + timeout=25, + ) + + token = _extract_session_token_from_cookie_jar(getattr(resp, "cookies", None)) + if not token: + token = _extract_session_token_from_cookie_jar(getattr(session, "cookies", None)) + if not token: + set_cookie = ( + " | ".join(resp.headers.get_list("set-cookie")) + if hasattr(resp.headers, "get_list") + else str(resp.headers.get("set-cookie") or "") + ) + match_direct = re.search(r"__Secure-next-auth\.session-token=([^;,\s]+)", set_cookie) + if match_direct: + token = str(match_direct.group(1) or "").strip() + else: + chunk_matches = re.findall(r"__Secure-next-auth\.session-token\.(\d+)=([^;,\s]+)", set_cookie) + if chunk_matches: + chunk_map = {int(i): v for i, v in chunk_matches if str(i).isdigit()} + if chunk_map: + token = "".join(chunk_map[idx] for idx in sorted(chunk_map.keys())) + + fresh_access = "" + try: + data = resp.json() if resp.content else {} + except Exception: + data = {} + if isinstance(data, dict): + fresh_access = str(data.get("accessToken") or "").strip() + + if token: + return token, (fresh_access or access or initial_access) + if fresh_access: + access = fresh_access + latest_access = fresh_access + headers["Authorization"] = f"Bearer {fresh_access}" + + # 常见地区限制:带代理失败时自动切到下一候选(通常是直连) + if proxy_item and resp.status_code in (401, 403) and _is_region_block_error_text(resp.text): + break + + return "", latest_access + + +def _extract_session_token_from_auth_response(resp, session) -> str: + token = _extract_session_token_from_cookie_jar(getattr(resp, "cookies", None)) + if token: + return token + token = _extract_session_token_from_cookie_jar(getattr(session, "cookies", None)) + if token: + return token + + set_cookie = ( + " | ".join(resp.headers.get_list("set-cookie")) + if hasattr(resp.headers, "get_list") + else str(resp.headers.get("set-cookie") or "") + ) + match_direct = re.search(r"__Secure-next-auth\.session-token=([^;,\s]+)", set_cookie) + if match_direct: + return str(match_direct.group(1) or "").strip() + + chunk_matches = re.findall(r"__Secure-next-auth\.session-token\.(\d+)=([^;,\s]+)", set_cookie) + if chunk_matches: + chunk_map = {int(i): v for i, v in chunk_matches if str(i).isdigit()} + if chunk_map: + return "".join(chunk_map[idx] for idx in sorted(chunk_map.keys())) + return "" + + +def _merge_cookie_text_with_session_jar(cookies_text: Optional[str], session) -> str: + merged = str(cookies_text or "").strip() + try: + items = list(session.cookies.items()) + except Exception: + items = [] + for name, value in items: + key = str(name or "").strip() + val = str(value or "").strip() + if not key or not val: + continue + merged = _upsert_cookie(merged, key, val) + return merged + + +def _bootstrap_session_token_by_abcard_bridge(account: Account, proxy: Optional[str]) -> tuple[str, str, str]: + """ + ABCard 同款 next-auth 会话桥接: + 1) /api/auth/csrf + 2) /api/auth/signin/openai + 3) /api/auth/session + Returns: + (session_token, fresh_access_token, merged_cookies_text) + """ + session = cffi_requests.Session( + impersonate="chrome120", + proxy=proxy, + ) + base_cookies = str(account.cookies or "").strip() + _seed_cookie_jar_from_text(session, base_cookies) + + device_id = _resolve_account_device_id(account) + if device_id: + try: + session.cookies.set("oai-did", device_id, domain=".chatgpt.com", path="/") + except Exception: + pass + + ua = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + ) + common_headers = { + "User-Agent": ua, + "Accept": "application/json", + "Referer": "https://chatgpt.com/auth/login", + "Origin": "https://chatgpt.com", + } + + try: + session.get( + "https://chatgpt.com/auth/login", + headers={ + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "User-Agent": ua, + "Referer": "https://chatgpt.com/", + }, + timeout=20, + ) + except Exception: + pass + + csrf_resp = session.get( + "https://chatgpt.com/api/auth/csrf", + headers=common_headers, + timeout=25, + ) + if csrf_resp.status_code >= 400: + raise RuntimeError(f"csrf_failed_http_{csrf_resp.status_code}") + + try: + csrf_token = str((csrf_resp.json() or {}).get("csrfToken") or "").strip() + except Exception: + csrf_token = "" + if not csrf_token: + raise RuntimeError("csrf_token_missing") + + signin_resp = session.post( + "https://chatgpt.com/api/auth/signin/openai", + headers={ + **common_headers, + "Content-Type": "application/x-www-form-urlencoded", + }, + data={ + "csrfToken": csrf_token, + "callbackUrl": "https://chatgpt.com/", + "json": "true", + }, + timeout=25, + ) + if signin_resp.status_code >= 400: + raise RuntimeError(f"signin_openai_failed_http_{signin_resp.status_code}") + + auth_url = "" + try: + auth_url = str((signin_resp.json() or {}).get("url") or "").strip() + except Exception: + auth_url = "" + if auth_url: + try: + session.get( + auth_url, + headers={ + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Referer": "https://chatgpt.com/auth/login", + "User-Agent": ua, + }, + timeout=30, + allow_redirects=True, + ) + except Exception: + pass + + latest_access = str(account.access_token or "").strip() + session_headers = { + "Accept": "application/json", + "Referer": "https://chatgpt.com/", + "Origin": "https://chatgpt.com", + "User-Agent": ua, + } + if latest_access: + session_headers["Authorization"] = f"Bearer {latest_access}" + + session_token = "" + for _ in range(2): + resp = session.get( + "https://chatgpt.com/api/auth/session", + headers=session_headers, + timeout=25, + ) + if resp.status_code >= 400 and not latest_access: + continue + + token_inner = _extract_session_token_from_auth_response(resp, session) + if token_inner: + session_token = token_inner + + try: + payload = resp.json() if resp.content else {} + except Exception: + payload = {} + if isinstance(payload, dict): + fresh_access = str(payload.get("accessToken") or "").strip() + if fresh_access: + latest_access = fresh_access + session_headers["Authorization"] = f"Bearer {fresh_access}" + + if session_token: + break + + merged_cookies = _merge_cookie_text_with_session_jar(base_cookies, session) + if session_token: + merged_cookies = _upsert_cookie(merged_cookies, "__Secure-next-auth.session-token", session_token) + if device_id: + merged_cookies = _upsert_cookie(merged_cookies, "oai-did", device_id) + + return session_token, latest_access, merged_cookies + + +def _normalize_email_service_config_for_session_bootstrap( + service_type: EmailServiceType, + config: Optional[dict], + proxy_url: Optional[str] = None, +) -> dict: + normalized = dict(config or {}) + + 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") + + # IMAP/Outlook 等可按需使用代理;Temp-Mail/Freemail 强制直连。 + if proxy_url and "proxy_url" not in normalized and service_type not in (EmailServiceType.TEMP_MAIL, EmailServiceType.FREEMAIL): + normalized["proxy_url"] = proxy_url + + return normalized + + +def _resolve_email_service_for_account_session_bootstrap(db, account: Account, proxy: Optional[str]): + raw_type = str(account.email_service or "").strip().lower() + if not raw_type: + raise RuntimeError("账号缺少 email_service") + try: + service_type = EmailServiceType(raw_type) + except Exception as exc: + raise RuntimeError(f"不支持的邮箱服务类型: {raw_type}") from exc + + settings = get_settings() + services = ( + db.query(EmailServiceModel) + .filter(EmailServiceModel.service_type == service_type.value, EmailServiceModel.enabled == True) + .order_by(EmailServiceModel.priority.asc(), EmailServiceModel.id.asc()) + .all() + ) + + selected = None + if services: + # Outlook/IMAP 优先匹配同邮箱配置,避免拿错账户。 + if service_type in (EmailServiceType.OUTLOOK, EmailServiceType.IMAP_MAIL): + email_lower = str(account.email or "").strip().lower() + for svc in services: + cfg_email = str((svc.config or {}).get("email") or "").strip().lower() + if cfg_email and cfg_email == email_lower: + selected = svc + break + if not selected: + selected = services[0] + + if selected and selected.config: + config = _normalize_email_service_config_for_session_bootstrap(service_type, selected.config, proxy) + elif service_type == EmailServiceType.TEMPMAIL: + config = { + "base_url": settings.tempmail_base_url, + "timeout": settings.tempmail_timeout, + "max_retries": settings.tempmail_max_retries, + "proxy_url": proxy, + } + else: + raise RuntimeError( + f"未找到可用邮箱服务配置(type={service_type.value}),无法自动获取登录验证码" + ) + + service = EmailServiceFactory.create(service_type, config, name=f"session_bootstrap_{service_type.value}") + return service + + +def _bootstrap_session_token_by_relogin(db, account: Account, proxy: Optional[str]) -> str: + """ + 二级兜底:用账号邮箱+密码走一次登录链路,自动收 OTP 并补齐 session token。 + """ + email = str(account.email or "").strip() + password = str(account.password or "").strip() + if not email or not password: + logger.info( + "会话补全登录跳过:账号缺少邮箱或密码 account_id=%s email=%s", + account.id, + account.email, + ) + return "" + + try: + email_service = _resolve_email_service_for_account_session_bootstrap(db, account, proxy) + except Exception as exc: + logger.warning( + "会话补全登录无法创建邮箱服务: account_id=%s email=%s error=%s", + account.id, + account.email, + exc, + ) + return "" + + engine = RegistrationEngine( + email_service=email_service, + proxy_url=proxy, + callback_logger=lambda msg: logger.info("会话补全登录: %s", msg), + task_uuid=None, + ) + engine.email = email + engine.password = password + engine.email_info = {"service_id": account.email_service_id} if account.email_service_id else {} + + try: + did, sen_token = engine._prepare_authorize_flow("会话补全登录") + if not did: + return "" + if not sen_token: + # 对齐 ABCard:sentinel 偶发失败时,仍尝试无 sentinel 登录链路,避免卡死。 + logger.warning( + "会话补全登录 sentinel 缺失,继续尝试无 sentinel 登录: account_id=%s email=%s", + account.id, + account.email, + ) + + login_start = engine._submit_login_start(did, sen_token) + if not login_start.success: + if _is_region_block_error_text(login_start.error_message): + logger.warning( + "会话补全登录入口地区受限: account_id=%s email=%s proxy=%s error=%s", + account.id, + account.email, + "on" if proxy else "off", + login_start.error_message, + ) + logger.warning( + "会话补全登录入口失败: account_id=%s email=%s error=%s", + account.id, + account.email, + login_start.error_message, + ) + return "" + + if login_start.page_type == OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]: + password_result = engine._submit_login_password() + if not password_result.success or not password_result.is_existing_account: + logger.warning( + "会话补全登录密码阶段失败: account_id=%s email=%s page_type=%s err=%s", + account.id, + account.email, + password_result.page_type, + password_result.error_message, + ) + return "" + elif login_start.page_type != OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]: + logger.warning( + "会话补全登录入口返回未知页面: account_id=%s email=%s page_type=%s", + account.id, + account.email, + login_start.page_type, + ) + return "" + + engine._log("等待登录验证码到场,最后这位嘉宾还在路上...") + engine._log("核对登录验证码,验明正身一下...") + if not engine._verify_email_otp_with_retry(stage_label="会话补全验证码", max_attempts=3): + logger.warning( + "会话补全登录验证码阶段失败: account_id=%s email=%s", + account.id, + account.email, + ) + return "" + + fresh_cookies = engine._dump_session_cookies() + # 兜底拼装关键 cookie,避免个别环境 cookie jar 导出不全。 + try: + did_cookie = str(engine.session.cookies.get("oai-did") or "").strip() if engine.session else "" + except Exception: + did_cookie = "" + try: + auth_cookie = str(engine.session.cookies.get("oai-client-auth-session") or "").strip() if engine.session else "" + except Exception: + auth_cookie = "" + if did_cookie: + fresh_cookies = _upsert_cookie(fresh_cookies, "oai-did", did_cookie) + if auth_cookie: + fresh_cookies = _upsert_cookie(fresh_cookies, "oai-client-auth-session", auth_cookie) + + session_token = _extract_session_token_from_cookie_text(fresh_cookies) + forced_access = str(account.access_token or "").strip() + if not session_token: + forced_token, forced_access_new = _force_fetch_nextauth_session_token( + access_token=forced_access, + cookies_text=fresh_cookies, + device_id=did_cookie or _resolve_account_device_id(account), + proxy=proxy, + ) + if forced_token: + session_token = forced_token + fresh_cookies = _upsert_cookie(fresh_cookies, "__Secure-next-auth.session-token", forced_token) + if forced_access_new: + forced_access = forced_access_new + + if not session_token: + logger.warning("会话补全登录未拿到 session_token: account_id=%s email=%s", account.id, account.email) + if fresh_cookies: + account.cookies = fresh_cookies + if forced_access: + account.access_token = forced_access + account.last_refresh = datetime.utcnow() + db.commit() + return "" + + if forced_access: + account.access_token = forced_access + if fresh_cookies: + account.cookies = fresh_cookies + account.session_token = session_token + account.last_refresh = datetime.utcnow() + db.commit() + db.refresh(account) + logger.info("会话补全登录成功: account_id=%s email=%s", account.id, account.email) + return session_token + except Exception as exc: + logger.warning("会话补全登录异常: account_id=%s email=%s error=%s", account.id, account.email, exc) + return "" + + +def _upsert_cookie(cookies_text: Optional[str], cookie_name: str, cookie_value: str) -> str: + target_name = str(cookie_name or "").strip() + target_value = str(cookie_value or "").strip() + if not target_name: + return str(cookies_text or "").strip() + + pairs: List[tuple[str, str]] = [] + seen = False + for item in str(cookies_text or "").split(";"): + raw = str(item or "").strip() + if not raw or "=" not in raw: + continue + name, value = raw.split("=", 1) + name = name.strip() + value = value.strip() + if not name: + continue + if name == target_name: + if target_value: + pairs.append((name, target_value)) + seen = True + else: + pairs.append((name, value)) + + if not seen and target_value: + pairs.append((target_name, target_value)) + + return "; ".join(f"{k}={v}" for k, v in pairs if k) + + +def _seed_cookie_jar_from_text(session, cookies_text: Optional[str]) -> None: + """ + 将 account.cookies 中的键值回灌到会话 cookie jar,便于重定向链正确续会话。 + """ + text = str(cookies_text or "").strip() + if not text: + return + for item in text.split(";"): + raw = str(item or "").strip() + if not raw or "=" not in raw: + continue + name, value = raw.split("=", 1) + key = str(name or "").strip() + val = str(value or "").strip() + if not key: + continue + for domain in (".chatgpt.com", "chatgpt.com"): + try: + session.cookies.set(key, val, domain=domain, path="/") + except Exception: + continue + + +def _bootstrap_session_token_for_local_auto(db, account: Account, proxy: Optional[str]) -> str: + """ + 尝试为 local_auto 自动补齐 session token(避免 cdp_session_missing)。 + """ + existing = str(account.session_token or "").strip() or _extract_session_token_from_cookie_text(account.cookies) + if existing: + if not account.session_token: + account.session_token = existing + account.cookies = _upsert_cookie(account.cookies, "__Secure-next-auth.session-token", existing) + db.commit() + db.refresh(account) + return existing + + def _extract_session_from_response(resp, session) -> str: + token_inner = _extract_session_token_from_cookie_jar(getattr(resp, "cookies", None)) + if token_inner: + return token_inner + token_inner = _extract_session_token_from_cookie_jar(getattr(session, "cookies", None)) + if token_inner: + return token_inner + set_cookie = ( + " | ".join(resp.headers.get_list("set-cookie")) + if hasattr(resp.headers, "get_list") + else str(resp.headers.get("set-cookie") or "") + ) + match_direct = re.search(r"__Secure-next-auth\.session-token=([^;,\s]+)", set_cookie) + if match_direct: + return str(match_direct.group(1) or "").strip() + chunk_matches = re.findall(r"__Secure-next-auth\.session-token\.(\d+)=([^;,\s]+)", set_cookie) + if chunk_matches: + chunk_map = {int(i): v for i, v in chunk_matches if str(i).isdigit()} + if chunk_map: + return "".join(chunk_map[idx] for idx in sorted(chunk_map.keys())) + return "" + + def _request_session_token( + *, + proxy_item: Optional[str], + with_auth: bool, + with_cookies: bool, + ) -> str: + access_token = str(account.access_token or "").strip() + if with_auth and not access_token: + return "" + + session = cffi_requests.Session( + impersonate="chrome120", + proxy=proxy_item, + ) + _seed_cookie_jar_from_text(session, account.cookies) + + device_id = _resolve_account_device_id(account) + if device_id: + try: + session.cookies.set("oai-did", device_id, domain=".chatgpt.com") + except Exception: + pass + + headers = { + "Accept": "application/json", + "Referer": "https://chatgpt.com/", + "Origin": "https://chatgpt.com", + "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" + ), + } + if with_auth: + headers["Authorization"] = f"Bearer {access_token}" + + # 先热身主页,尽可能让 cookie/session 状态完整再请求 auth/session + try: + session.get( + "https://chatgpt.com/", + headers={ + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Referer": "https://chatgpt.com/", + "User-Agent": headers["User-Agent"], + }, + timeout=25, + ) + except Exception: + pass + + if with_cookies and account.cookies: + try: + session.headers.update({"cookie": str(account.cookies)}) + except Exception: + pass + + resp = session.get( + "https://chatgpt.com/api/auth/session", + headers=headers, + timeout=25, + ) + + token_inner = _extract_session_from_response(resp, session) + try: + data = resp.json() if resp.content else {} + except Exception: + data = {} + if isinstance(data, dict): + fresh_access = str(data.get("accessToken") or "").strip() + if fresh_access: + account.access_token = fresh_access + + if token_inner: + # 若 session 接口返回了新 accessToken,一并回写,避免后续继续用旧 token。 + return token_inner + + # 部分场景 session cookie 在第二次调用才下发,这里补一次重试。 + retry_resp = session.get( + "https://chatgpt.com/api/auth/session", + headers=headers, + timeout=25, + ) + token_retry = _extract_session_from_response(retry_resp, session) + try: + data = retry_resp.json() if retry_resp.content else {} + except Exception: + data = {} + if isinstance(data, dict): + fresh_access = str(data.get("accessToken") or "").strip() + if fresh_access: + account.access_token = fresh_access + if token_retry: + return token_retry + return "" + + # 按 ABCard 风格:先独立会话 + access token,再尝试带 cookies;每组先代理后直连。 + attempt_matrix = [ + (True, False), # with_auth, with_cookies + (True, True), + (False, True), + ] + # 会话补全只走代理网络,避免直连触发地区限制导致 403 卡死。 + proxy_candidates = _build_proxy_candidates(proxy, account, include_direct=False) + if not proxy_candidates: + logger.warning( + "本地自动绑卡会话补全缺少可用代理: account_id=%s email=%s", + account.id, + account.email, + ) + return "" + + errors: List[str] = [] + token = "" + for with_auth, with_cookies in attempt_matrix: + for proxy_item in proxy_candidates: + try: + token = _request_session_token( + proxy_item=proxy_item, + with_auth=with_auth, + with_cookies=with_cookies, + ) + if token: + break + except Exception as exc: + errors.append( + f"proxy={'on' if proxy_item else 'off'} auth={with_auth} cookies={with_cookies} err={exc}" + ) + if token: + break + + # 一级兜底:ABCard 同款 next-auth 桥接链路(csrf -> signin/openai -> auth/session)。 + if not token: + for proxy_item in proxy_candidates: + try: + bridged_token, bridged_access, bridged_cookies = _bootstrap_session_token_by_abcard_bridge( + account=account, + proxy=proxy_item, + ) + if bridged_access: + account.access_token = bridged_access + if bridged_cookies: + account.cookies = bridged_cookies + if bridged_token: + token = bridged_token + break + except Exception as exc: + errors.append(f"abcard_bridge proxy={'on' if proxy_item else 'off'} err={exc}") + + # 若仍失败,尝试刷新 token 后再跑一次核心路径(auth+无cookies) + if not token and (account.refresh_token or account.session_token): + try: + manager = TokenRefreshManager(proxy_url=proxy) + refresh_result = manager.refresh_account(account) + if refresh_result.success: + account.access_token = refresh_result.access_token + if refresh_result.refresh_token: + account.refresh_token = refresh_result.refresh_token + if refresh_result.expires_at: + account.expires_at = refresh_result.expires_at + account.last_refresh = datetime.utcnow() + db.commit() + db.refresh(account) + for proxy_item in proxy_candidates: + try: + token = _request_session_token( + proxy_item=proxy_item, + with_auth=True, + with_cookies=False, + ) + if token: + break + except Exception as exc: + errors.append(f"after_refresh proxy={'on' if proxy_item else 'off'} err={exc}") + except Exception as exc: + errors.append(f"refresh_failed={exc}") + + if not token: + # 二级兜底:走一次账号登录链路(含邮箱验证码)自动补会话。 + # 逐个代理候选尝试(显式/账号历史/全局),避免落到受限直连。 + for relogin_proxy in proxy_candidates: + token = _bootstrap_session_token_by_relogin( + db=db, + account=account, + proxy=relogin_proxy, + ) + if token: + break + + if not token: + if errors: + logger.warning( + "本地自动绑卡会话补全失败: account_id=%s email=%s detail=%s", + account.id, + account.email, + " | ".join(errors[-4:]), + ) + else: + logger.info( + "本地自动绑卡会话补全未命中 session token: account_id=%s email=%s", + account.id, + account.email, + ) + return "" + + account.session_token = token + account.cookies = _upsert_cookie(account.cookies, "__Secure-next-auth.session-token", token) + db.commit() + db.refresh(account) + logger.info( + "本地自动绑卡会话补全成功: account_id=%s email=%s", + account.id, + account.email, + ) + return token + + +def _build_official_checkout_url(checkout_session_id: Optional[str]) -> Optional[str]: + cs_id = str(checkout_session_id or "").strip() + if not cs_id: + return None + return f"https://chatgpt.com/checkout/openai_llc/{cs_id}" + + +def _mask_card_number(number: Optional[str]) -> str: + digits = "".join(ch for ch in str(number or "") if ch.isdigit()) + if not digits: + return "-" + if len(digits) <= 8: + return f"{digits[:2]}****{digits[-2:]}" + return f"{digits[:4]}****{digits[-4:]}" + + +def _mark_task_paid_pending_sync(task: BindCardTask, reason: str) -> None: + now = datetime.utcnow() + task.status = "paid_pending_sync" + task.completed_at = None + task.last_checked_at = now + task.last_error = reason + + +def _resolve_third_party_bind_api_url(request_url: Optional[str]) -> Optional[str]: + raw = ( + str(request_url or "").strip() + or str(os.getenv(THIRD_PARTY_BIND_API_URL_ENV) or "").strip() + or THIRD_PARTY_BIND_API_DEFAULT + ) + normalized = _normalize_third_party_bind_api_url(raw) + return normalized or None + + +def _resolve_third_party_bind_api_key(request_key: Optional[str]) -> Optional[str]: + token = str(request_key or "").strip() or str(os.getenv(THIRD_PARTY_BIND_API_KEY_ENV) or "").strip() + return token or None + + +def _normalize_third_party_bind_api_url(raw_url: Optional[str]) -> Optional[str]: + text = str(raw_url or "").strip() + if not text: + return None + if "://" not in text: + text = "https://" + text + try: + parsed = urlparse(text) + except Exception: + return None + if not parsed.scheme or not parsed.netloc: + return None + path = parsed.path or "" + if not path or path == "/": + path = THIRD_PARTY_BIND_PATH_DEFAULT + path = "/" + path.lstrip("/") + normalized = parsed._replace(path=path, params="", fragment="") + return urlunparse(normalized) + + +def _build_third_party_bind_api_candidates(api_url: str) -> List[str]: + """ + 对第三方地址做容错: + - 支持只给根域名(自动补 /api/v1/bind-card) + - 支持给到 /api/v1(自动补 /bind-card) + - 保留原始路径作为首选 + """ + normalized = _normalize_third_party_bind_api_url(api_url) + if not normalized: + return [] + + candidates: List[str] = [] + + def _append(url: Optional[str]): + value = str(url or "").strip() + if value and value not in candidates: + candidates.append(value) + + _append(normalized) + parsed = urlparse(normalized) + base = f"{parsed.scheme}://{parsed.netloc}" + path = (parsed.path or "").rstrip("/") + lower = path.lower() + + if lower in ("", "/"): + _append(base + THIRD_PARTY_BIND_PATH_DEFAULT) + elif lower.endswith("/api/v1"): + _append(base + path + "/bind-card") + _append(base + THIRD_PARTY_BIND_PATH_DEFAULT) + elif not lower.endswith("/bind-card"): + _append(base + THIRD_PARTY_BIND_PATH_DEFAULT) + + return candidates + + +def _parse_third_party_response(resp) -> dict: + if not (resp.content or b""): + return {"ok": True} + + content_type = (resp.headers.get("content-type") or "").lower() + if "application/json" in content_type: + try: + data = resp.json() + if isinstance(data, dict): + return data + return {"data": data} + except Exception: + pass + + raw = str(resp.text or "").strip() + if raw.startswith("{") and raw.endswith("}"): + try: + data = resp.json() + if isinstance(data, dict): + return data + except Exception: + pass + return {"raw": raw[:1000]} + + +def _invoke_third_party_bind_api( + *, + api_url: str, + api_key: Optional[str], + payload: dict, + proxy: Optional[str] = None, +) -> tuple[dict, str]: + headers = { + "Accept": "*/*", + "Content-Type": "application/json", + "User-Agent": "codex-console2/third-party-bind", + } + key = str(api_key or "").strip() + if key: + headers["X-API-Key"] = key + headers["Authorization"] = f"Bearer {key}" + + url_candidates = _build_third_party_bind_api_candidates(api_url) + if not url_candidates: + raise RuntimeError("第三方绑卡 API 地址无效") + + proxy_candidates: List[Optional[str]] = [] + for value in (proxy, None): + if value not in proxy_candidates: + proxy_candidates.append(value) + + errors: List[str] = [] + for candidate_url in url_candidates: + for proxy_item in proxy_candidates: + proxies = {"http": proxy_item, "https": proxy_item} if proxy_item else None + for attempt in range(1, 3): + try: + resp = cffi_requests.post( + candidate_url, + headers=headers, + json=payload, + proxies=proxies, + timeout=120, + impersonate="chrome110", + ) + + if resp.status_code >= 400: + body = (resp.text or "")[:500] + err = f"{candidate_url} status={resp.status_code} proxy={'on' if proxy_item else 'off'} body={body}" + errors.append(err) + retryable = resp.status_code in (408, 409, 425, 429, 500, 502, 503, 504) + endpoint_maybe_wrong = resp.status_code in (404, 405) + if attempt < 2 and retryable: + time.sleep(0.6 * attempt) + continue + if endpoint_maybe_wrong: + break + raise RuntimeError(f"第三方绑卡请求失败: HTTP {resp.status_code} - {body}") + + parsed = _parse_third_party_response(resp) + if isinstance(parsed, dict): + parsed["_meta_endpoint"] = candidate_url + parsed["_meta_proxy"] = "on" if proxy_item else "off" + parsed["_meta_attempt"] = attempt + return parsed, candidate_url + except Exception as exc: + err = f"{candidate_url} proxy={'on' if proxy_item else 'off'} attempt={attempt} error={exc}" + errors.append(err) + if attempt < 2: + time.sleep(0.6 * attempt) + continue + summary = " | ".join(errors[-4:]) if errors else "unknown_error" + raise RuntimeError(f"第三方绑卡请求失败,已尝试多路由: {summary}") + + +def _sanitize_third_party_response(payload: dict) -> dict: + if not isinstance(payload, dict): + return {"result": str(payload)[:500]} + safe: dict = {} + for key, value in payload.items(): + key_lower = str(key or "").lower() + if any(token in key_lower for token in ("card", "cvc", "cvv", "number", "profile", "pan")): + safe[key] = "***" + continue + if isinstance(value, (str, int, float, bool)) or value is None: + safe[key] = value + else: + safe[key] = str(value)[:500] + return safe + + +def _extract_third_party_status_snapshot(payload: dict) -> dict: + """从第三方返回体中提取支付状态快照(兼容 data/result 嵌套)。""" + if not isinstance(payload, dict): + return {} + + blocks = [payload] + data_block = payload.get("data") + if isinstance(data_block, dict): + blocks.append(data_block) + nested_result = data_block.get("result") + if isinstance(nested_result, dict): + blocks.append(nested_result) + top_result = payload.get("result") + if isinstance(top_result, dict): + blocks.append(top_result) + + def _pick(*keys: str) -> str: + for block in blocks: + for key in keys: + value = block.get(key) + if value is None: + continue + text = str(value).strip() + if text: + return text + return "" + + return { + "payment_status": _pick("payment_status"), + "checkout_status": _pick("checkout_status"), + "setup_intent_status": _pick("setup_intent_status"), + "payment_intent_status": _pick("payment_intent_status"), + "submission_attempt_state": _pick("submission_attempt_state"), + "next_action_type": _pick("next_action_type"), + "failure_reason": _pick("failure_reason", "reason"), + "status": _pick("status", "state"), + "code": _pick("code"), + "message": _pick("message", "error", "detail"), + "task_id": _pick("task_id", "request_id", "job_id"), + "checkout_session_id": _pick("checkout_session_id", "session_id"), + } + + +def _assess_third_party_submission_result(payload: dict) -> dict: + """ + 第三方绑卡结果三态判定: + - success: 已明确支付成功(如 payment_status=paid) + - pending: 已提交但仍需用户挑战/等待异步处理 + - failed: 明确失败 + """ + snapshot = _extract_third_party_status_snapshot(payload) + payment_status = snapshot.get("payment_status", "").strip().lower() + checkout_status = snapshot.get("checkout_status", "").strip().lower() + setup_intent_status = snapshot.get("setup_intent_status", "").strip().lower() + payment_intent_status = snapshot.get("payment_intent_status", "").strip().lower() + submission_state = snapshot.get("submission_attempt_state", "").strip().lower() + next_action_type = snapshot.get("next_action_type", "").strip().lower() + failure_reason = snapshot.get("failure_reason", "").strip().lower() + status_text = snapshot.get("status", "").strip().lower() + message = snapshot.get("message", "").strip() + success_flag = payload.get("success") if isinstance(payload, dict) else None + + # 1) 明确成功信号(你提供的口径:payment_status=paid) + if payment_status in ("paid", "succeeded", "success"): + return {"state": "success", "reason": "", "snapshot": snapshot} + if checkout_status in ("paid", "complete", "completed"): + return {"state": "success", "reason": "", "snapshot": snapshot} + + # 2) 明确失败信号 + fail_tokens = ("fail", "error", "invalid", "denied", "forbidden", "declined", "reject", "cancel") + if success_flag is False: + reason = message or failure_reason or "third_party_success_false" + return {"state": "failed", "reason": reason[:300], "snapshot": snapshot} + if payment_status in ("failed", "canceled", "cancelled", "expired", "void"): + reason = failure_reason or message or f"payment_status={payment_status}" + return {"state": "failed", "reason": reason[:300], "snapshot": snapshot} + if any(token in status_text for token in fail_tokens): + reason = message or failure_reason or f"status={status_text}" + return {"state": "failed", "reason": reason[:300], "snapshot": snapshot} + if any(token in failure_reason for token in fail_tokens): + reason = failure_reason or message or "failure_reason" + return {"state": "failed", "reason": reason[:300], "snapshot": snapshot} + low_message = message.lower() + if low_message and any(token in low_message for token in fail_tokens): + return {"state": "failed", "reason": message[:300], "snapshot": snapshot} + + # 3) 常见 pending 场景 + pending_signals = ( + payment_status in ("unpaid", "pending", "processing", "requires_action", "unknown"), + checkout_status in ("open", "pending", "processing"), + setup_intent_status in ("requires_action", "processing", "requires_confirmation"), + payment_intent_status in ("requires_action", "processing", "unknown", "requires_confirmation"), + submission_state in ("unknown", "pending", "processing"), + bool(next_action_type), + bool(snapshot.get("task_id")), + ) + if any(pending_signals): + reason = failure_reason or message or "pending_confirmation" + return {"state": "pending", "reason": reason[:300], "snapshot": snapshot} + + # 4) 兜底: success=true 且无明确 paid,也视为 pending(仅代表“受理成功”) + if success_flag is True: + return {"state": "pending", "reason": message[:300], "snapshot": snapshot} + + # 5) 其他未知返回,按 pending 处理,交给后续轮询 + 订阅校验收敛 + return {"state": "pending", "reason": message[:300], "snapshot": snapshot} + + +def _is_third_party_challenge_pending(assessment: dict) -> bool: + """ + 判定第三方是否已进入“需要人工挑战”的 pending 状态。 + 常见信号: requires_action / intent_confirmation_challenge / 3DS / hcaptcha。 + """ + if not isinstance(assessment, dict): + return False + snapshot = assessment.get("snapshot") if isinstance(assessment.get("snapshot"), dict) else {} + reason = str(assessment.get("reason") or "").strip().lower() + next_action_type = str(snapshot.get("next_action_type") or "").strip().lower() + setup_intent_status = str(snapshot.get("setup_intent_status") or "").strip().lower() + payment_intent_status = str(snapshot.get("payment_intent_status") or "").strip().lower() + failure_reason = str(snapshot.get("failure_reason") or "").strip().lower() + + tokens = ( + reason, + next_action_type, + setup_intent_status, + payment_intent_status, + failure_reason, + ) + challenge_keywords = ( + "requires_action", + "intent_confirmation_challenge", + "authentication_required", + "3ds", + "challenge", + "hcaptcha", + ) + for text in tokens: + if any(keyword in text for keyword in challenge_keywords): + return True + return False + + +def _build_third_party_status_api_candidates(api_url: str) -> List[str]: + normalized = _normalize_third_party_bind_api_url(api_url) + if not normalized: + return [] + parsed = urlparse(normalized) + base = f"{parsed.scheme}://{parsed.netloc}" + path = (parsed.path or "").rstrip("/") + candidates: List[str] = [] + + def _append(item: str): + value = str(item or "").strip() + if value and value not in candidates: + candidates.append(value) + + # 优先和 bind-card 同前缀的常见状态接口 + if path.endswith("/bind-card"): + prefix = path[: -len("/bind-card")] + _append(base + prefix + "/bind-card/status") + _append(base + prefix + "/bind-card/result") + _append(base + prefix + "/bind-card/query") + _append(base + prefix + "/payment-status") + _append(base + prefix + "/checkout-status") + _append(base + prefix + "/status") + + # 通用 fallback + _append(base + "/api/v1/bind-card/status") + _append(base + "/api/v1/bind-card/result") + _append(base + "/api/v1/bind-card/query") + _append(base + "/api/v1/payment-status") + _append(base + "/api/v1/status") + return candidates + + +def _poll_third_party_bind_status( + *, + api_url: str, + api_key: Optional[str], + checkout_session_id: str, + proxy: Optional[str], + timeout_seconds: int, + interval_seconds: int, + status_hints: Optional[dict] = None, +) -> dict: + """ + 轮询第三方状态接口(若服务支持): + 返回 {"state": success/pending/failed/unsupported, "endpoint": ..., "snapshot": ...} + """ + if timeout_seconds <= 0: + return {"state": "unsupported", "reason": "poll_disabled"} + + endpoints = _build_third_party_status_api_candidates(api_url) + if not endpoints: + return {"state": "unsupported", "reason": "no_status_endpoint"} + + headers = { + "Accept": "*/*", + "Content-Type": "application/json", + "User-Agent": "codex-console2/third-party-bind-status", + } + key = str(api_key or "").strip() + if key: + headers["X-API-Key"] = key + headers["Authorization"] = f"Bearer {key}" + + deadline = time.monotonic() + max(timeout_seconds, 0) + last_assess: Optional[dict] = None + last_endpoint = "" + attempts = 0 + visited = False + + while time.monotonic() < deadline: + attempts += 1 + hints = status_hints if isinstance(status_hints, dict) else {} + hint_task_id = str( + hints.get("task_id") + or hints.get("request_id") + or hints.get("job_id") + or "" + ).strip() + query_payload = { + "checkout_session_id": checkout_session_id, + "session_id": checkout_session_id, + "cs_id": checkout_session_id, + } + if hint_task_id: + query_payload.update( + { + "task_id": hint_task_id, + "request_id": hint_task_id, + "job_id": hint_task_id, + "id": hint_task_id, + } + ) + for endpoint in endpoints: + for proxy_item in (proxy, None): + proxies = {"http": proxy_item, "https": proxy_item} if proxy_item else None + # 一些服务用 GET + query,一些服务用 POST + body,这里都试一次 + request_variants = ( + ("GET", {"params": query_payload}), + ("POST", {"json": query_payload}), + ) + for method, extra in request_variants: + try: + visited = True + if method == "GET": + resp = cffi_requests.get( + endpoint, + headers=headers, + proxies=proxies, + timeout=25, + impersonate="chrome110", + **extra, + ) + else: + resp = cffi_requests.post( + endpoint, + headers=headers, + proxies=proxies, + timeout=25, + impersonate="chrome110", + **extra, + ) + if resp.status_code in (404, 405): + continue + if resp.status_code >= 400: + continue + data = _parse_third_party_response(resp) + assess = _assess_third_party_submission_result(data if isinstance(data, dict) else {}) + assess["endpoint"] = endpoint + assess["proxy"] = "on" if proxy_item else "off" + assess["attempt"] = attempts + last_assess = assess + last_endpoint = endpoint + state = str(assess.get("state") or "").lower() + if state in ("success", "failed"): + return assess + except Exception: + continue + time.sleep(max(interval_seconds, 2)) + + if not visited: + return {"state": "unsupported", "reason": "status_endpoint_unavailable"} + if last_assess: + return last_assess + return {"state": "pending", "reason": "status_pending_timeout", "endpoint": last_endpoint} + + +def _refresh_account_token_for_subscription_check(account: Account, proxy: Optional[str]) -> tuple[bool, Optional[str]]: + """ + 刷新账号 Access Token(优先 session_token,其次 refresh_token)。 + """ + manager = TokenRefreshManager(proxy_url=proxy) + refresh_result = manager.refresh_account(account) + # 代理通道遇到地区限制时,再做一次直连兜底,避免“检测订阅”被 403 卡住。 + if ( + not refresh_result.success + and proxy + and "unsupported_country_region_territory" in str(refresh_result.error_message or "").lower() + ): + logger.warning( + "订阅检测 token 刷新遇到地区限制,尝试直连重试: account_id=%s email=%s", + account.id, + account.email, + ) + manager = TokenRefreshManager(proxy_url=None) + refresh_result = manager.refresh_account(account) + + if not refresh_result.success: + return False, refresh_result.error_message or "token_refresh_failed" + + if refresh_result.access_token: + account.access_token = refresh_result.access_token + if refresh_result.refresh_token: + account.refresh_token = refresh_result.refresh_token + if refresh_result.expires_at: + account.expires_at = refresh_result.expires_at + account.last_refresh = datetime.utcnow() + return True, None + + +def _check_subscription_detail_with_retry( + db, + account: Account, + proxy: Optional[str], + allow_token_refresh: bool, +) -> tuple[dict, bool]: + """ + 订阅检测 + 一次 token 刷新重试: + - 检测异常时尝试刷新 token 后重试 + - 检测到 free 且低置信度时,也尝试刷新 token 后重试 + Returns: + (detail, refreshed) + """ + refreshed = False + + try: + detail = check_subscription_status_detail(account, proxy) + except Exception as first_exc: + if not allow_token_refresh: + raise + ok, err = _refresh_account_token_for_subscription_check(account, proxy) + if not ok: + raise RuntimeError(f"{first_exc}; token刷新失败: {err}") + db.commit() + refreshed = True + detail = check_subscription_status_detail(account, proxy) + detail = dict(detail or {}) + detail["token_refreshed"] = True + return detail, refreshed + + status = str((detail or {}).get("status") or "free").lower() + confidence = str((detail or {}).get("confidence") or "low").lower() + source = str((detail or {}).get("source") or "").lower() + should_refresh_on_free = ( + confidence != "high" + or source.startswith("wham_usage.") + ) + if allow_token_refresh and status == "free" and should_refresh_on_free: + ok, err = _refresh_account_token_for_subscription_check(account, proxy) + if ok: + db.commit() + refreshed = True + detail = check_subscription_status_detail(account, proxy) + detail = dict(detail or {}) + detail["token_refreshed"] = True + return detail, refreshed + logger.warning( + "订阅检测触发token刷新但失败: account_id=%s email=%s err=%s", + account.id, + account.email, + err, + ) + + # 代理环境下若仍为 free,增加一次直连复核,降低地区/线路噪音影响。 + if proxy and status == "free": + try: + direct_detail = check_subscription_status_detail(account, proxy=None) + direct_status = str((direct_detail or {}).get("status") or "free").lower() + direct_conf = str((direct_detail or {}).get("confidence") or "low").lower() + logger.info( + "订阅检测直连复核: account_id=%s email=%s status=%s source=%s confidence=%s", + account.id, + account.email, + direct_status, + (direct_detail or {}).get("source"), + direct_conf, + ) + if direct_status in ("plus", "team"): + direct_detail = dict(direct_detail or {}) + direct_detail["checked_without_proxy"] = True + return direct_detail, refreshed + if confidence != "high": + direct_detail = dict(direct_detail or {}) + direct_detail["checked_without_proxy"] = True + return direct_detail, refreshed + except Exception as direct_exc: + logger.warning( + "订阅检测直连复核失败: account_id=%s email=%s error=%s", + account.id, + account.email, + direct_exc, + ) + + return detail, refreshed + + +def _generate_checkout_link_for_account( + account: Account, + request: "CheckoutRequestBase", + proxy: Optional[str], +) -> tuple[str, str, Optional[str], Optional[str], Optional[str], Optional[str]]: + if request.plan_type not in ("plus", "team"): + raise HTTPException(status_code=400, detail="plan_type 必须为 plus 或 team") + + # 优先官方 checkout,保证直接落到 chatgpt.com 绑卡页面。 + source = "openai_checkout" + fallback_reason = None + checkout_session_id: Optional[str] = None + publishable_key: Optional[str] = None + client_secret: Optional[str] = None + request.country = _normalize_checkout_country(request.country) + request.currency = _normalize_checkout_currency(request.country, getattr(request, "currency", None)) + try: + if request.plan_type == "plus": + bundle = generate_plus_checkout_bundle( + account=account, + proxy=proxy, + country=request.country, + ) + else: + bundle = generate_team_checkout_bundle( + account=account, + workspace_name=request.workspace_name, + price_interval=request.price_interval, + seat_quantity=request.seat_quantity, + proxy=proxy, + country=request.country, + ) + link = str(bundle.get("checkout_url") or "") + checkout_session_id = str(bundle.get("checkout_session_id") or "").strip() or None + publishable_key = str(bundle.get("publishable_key") or "").strip() or None + client_secret = str(bundle.get("client_secret") or "").strip() or None + except Exception as direct_err: + if _is_checkout_connectivity_error(direct_err): + logger.warning( + "官方 checkout 网络连接失败,不回退 aimizy: account_id=%s email=%s error=%s", + account.id, + account.email, + direct_err, + ) + raise HTTPException( + status_code=502, + detail=f"官方 checkout 网络连接失败,请检查代理或网络后重试: {direct_err}", + ) + # 官方接口失败时,回退到 aimizy 渠道(仍会尝试归一化为官方 checkout 链接)。 + source = "aimizy_fallback" + fallback_reason = str(direct_err) + logger.warning(f"官方 checkout 生成失败,回退 aimizy: {direct_err}") + link = generate_aimizy_payment_link( + account=account, + plan_type=request.plan_type, + proxy=proxy, + country=request.country, + currency=request.currency, + ) + + if not isinstance(link, str) or not link.strip(): + raise ValueError("未获取到支付链接,请检查账号 Token/Cookies 是否有效") + + if not checkout_session_id: + checkout_session_id = _extract_checkout_session_id_from_url(link) + + return link, source, fallback_reason, checkout_session_id, publishable_key, client_secret + + +# ============== Pydantic Models ============== + +class CheckoutRequestBase(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 + country: str = "US" + currency: Optional[str] = "USD" + + +class GenerateLinkRequest(CheckoutRequestBase): + auto_open: bool = False # 生成后是否自动无痕打开 + + +class CreateBindCardTaskRequest(CheckoutRequestBase): + auto_open: bool = False + bind_mode: str = "semi_auto" # semi_auto / third_party / local_auto + + +class OpenIncognitoRequest(BaseModel): + url: str + account_id: Optional[int] = None # 可选,用于注入账号 cookie + + +class SyncBindCardTaskRequest(BaseModel): + proxy: Optional[str] = None + + +class MarkUserActionRequest(BaseModel): + proxy: Optional[str] = None + timeout_seconds: int = Field(default=180, ge=30, le=300) + interval_seconds: int = Field(default=10, ge=5, le=30) + + +class ThirdPartyCardRequest(BaseModel): + number: str + exp_month: str + exp_year: str + cvc: str + + +class ThirdPartyProfileRequest(BaseModel): + name: str + email: Optional[str] = None + country: str = "US" + line1: str + city: str + state: str + postal: str + + +class ThirdPartyAutoBindRequest(BaseModel): + api_url: Optional[str] = None + api_key: Optional[str] = None + proxy: Optional[str] = None + timeout_seconds: int = Field(default=120, ge=30, le=300) + interval_seconds: int = Field(default=10, ge=5, le=30) + third_party_poll_timeout_seconds: int = Field(default=60, ge=0, le=300) + third_party_poll_interval_seconds: int = Field(default=6, ge=2, le=30) + card: ThirdPartyCardRequest + profile: ThirdPartyProfileRequest + + +class LocalAutoBindRequest(BaseModel): + proxy: Optional[str] = None + browser_timeout_seconds: int = Field(default=180, ge=60, le=600) + post_submit_wait_seconds: int = Field(default=90, ge=30, le=300) + verify_timeout_seconds: int = Field(default=180, ge=30, le=300) + verify_interval_seconds: int = Field(default=10, ge=5, le=30) + headless: bool = False + card: ThirdPartyCardRequest + profile: ThirdPartyProfileRequest + + +class MarkSubscriptionRequest(BaseModel): + subscription_type: str # 'free' / 'plus' / 'team' + + +class BatchCheckSubscriptionRequest(BaseModel): + ids: List[int] = [] + proxy: Optional[str] = None + select_all: bool = False + status_filter: Optional[str] = None + email_service_filter: Optional[str] = None + search_filter: Optional[str] = None + + +class SaveSessionTokenRequest(BaseModel): + session_token: str + merge_cookie: bool = True + + +# ============== 支付链接生成 ============== + + +@router.get("/random-billing") +def get_random_billing_profile( + country: str = Query("US", description="国家代码,如 US/GB/CA"), + proxy: Optional[str] = Query(None, description="可选代理"), +): + """ + 按国家随机生成账单资料。 + 优先 meiguodizhi,失败自动降级到本地模板。 + """ + try: + # 随机地址仅使用显式传入代理;不再默认继承系统代理配置。 + proxy_url = _normalize_proxy_value(proxy) or None + profile = generate_random_billing_profile(country=country, proxy=proxy_url) + return { + "success": True, + "profile": profile, + } + except Exception as exc: + logger.error("随机账单资料生成失败: country=%s error=%s", country, exc) + raise HTTPException(status_code=500, detail=f"随机账单资料生成失败: {exc}") + + +@router.get("/accounts/{account_id}/session-diagnostic") +def get_account_session_diagnostic( + account_id: int, + probe: bool = Query(True, description="是否执行一次实时会话探测"), + proxy: Optional[str] = Query(None, description="会话探测代理"), +): + """ + 会话诊断: + - 账号是否具备 access/session/device 基础条件 + - cookies 中 session token 是否为分片形式 + - 可选实时请求 /api/auth/session 验证会话可用性 + """ + with get_db() as db: + account = db.query(Account).filter(Account.id == account_id).first() + if not account: + raise HTTPException(status_code=404, detail="账号不存在") + + access_token = str(account.access_token or "").strip() + refresh_token = str(account.refresh_token or "").strip() + session_token_db = str(account.session_token or "").strip() + cookies_text = str(account.cookies or "") + device_id = _resolve_account_device_id(account) + session_token_cookie = _extract_session_token_from_cookie_text(cookies_text) + session_chunk_indices = _extract_session_token_chunks_from_cookie_text(cookies_text) + resolved_session_token = session_token_db or session_token_cookie + + notes: List[str] = [] + if not access_token: + notes.append("缺少 access_token(无法走 auth/session 探测授权头)") + if not resolved_session_token: + notes.append("未发现 session_token(DB 与 cookies 都为空)") + if session_chunk_indices and not session_token_cookie: + notes.append("发现 session 分片但未能拼接,请检查 cookies 原文完整性") + if not device_id: + notes.append("缺少 oai-did(会话建立成功率会下降)") + + probe_result = None + if probe: + probe_proxy = _resolve_runtime_proxy(proxy, account) + probe_result = _probe_auth_session_context(account, probe_proxy) + if not probe_result.get("ok"): + notes.append( + "实时探测未通过:" + + ( + str(probe_result.get("error") or "").strip() + or f"http_status={probe_result.get('http_status')}" + ) + ) + + recommendation = "会话完整,可直接执行全自动绑卡" + if not resolved_session_token and access_token: + recommendation = "建议先用 access_token 预热 /api/auth/session,再执行全自动" + elif not access_token and not resolved_session_token: + recommendation = "账号会话信息不足,建议重新登录一次并回写 cookies/session_token" + elif probe_result and (not probe_result.get("session_token_found")): + recommendation = "建议检查代理线路与账号登录态,必要时切直连重试" + can_login_bootstrap = bool(str(account.password or "").strip()) and bool(str(account.email_service or "").strip()) + if (not resolved_session_token) and can_login_bootstrap: + recommendation = "可尝试后端自动登录补会话(账号密码+邮箱验证码)后再执行全自动" + + return { + "success": True, + "diagnostic": { + "account_id": account.id, + "email": account.email, + "token_state": { + "has_access_token": bool(access_token), + "access_token_len": len(access_token), + "access_token_preview": _mask_secret(access_token), + "has_refresh_token": bool(refresh_token), + "refresh_token_len": len(refresh_token), + "has_session_token_db": bool(session_token_db), + "session_token_db_len": len(session_token_db), + "session_token_db_preview": _mask_secret(session_token_db), + "has_session_token_cookie": bool(session_token_cookie), + "session_token_cookie_len": len(session_token_cookie), + "session_token_cookie_preview": _mask_secret(session_token_cookie), + "resolved_session_token_len": len(resolved_session_token), + "resolved_session_token_preview": _mask_secret(resolved_session_token), + }, + "cookie_state": { + "has_cookies": bool(cookies_text.strip()), + "cookies_len": len(cookies_text), + "has_oai_did": bool(_extract_cookie_value(cookies_text, "oai-did")), + "resolved_oai_did": _mask_secret(device_id), + "session_chunk_count": len(session_chunk_indices), + "session_chunk_indices": session_chunk_indices, + }, + "bootstrap_capability": { + "can_login_bootstrap": can_login_bootstrap, + "has_password": bool(str(account.password or "").strip()), + "email_service_type": str(account.email_service or ""), + "email_service_mailbox_id": str(account.email_service_id or ""), + }, + "probe": probe_result, + "notes": notes, + "recommendation": recommendation, + "checked_at": datetime.utcnow().isoformat(), + }, + } + + +@router.post("/accounts/{account_id}/session-bootstrap") +def bootstrap_account_session_token( + account_id: int, + proxy: Optional[str] = Query(None, description="会话补全代理"), +): + """ + 主动触发一次会话补全: + 1) 先走 API 级 session 探测补全 + 2) 失败后自动走账号登录链路(邮箱验证码)补全 + """ + with get_db() as db: + account = db.query(Account).filter(Account.id == account_id).first() + if not account: + raise HTTPException(status_code=404, detail="账号不存在") + + runtime_proxy = _resolve_runtime_proxy(proxy, account) + token = _bootstrap_session_token_for_local_auto(db, account, runtime_proxy) + if not token: + return { + "success": False, + "message": "会话补全未命中 session_token", + "account_id": account.id, + "email": account.email, + } + + return { + "success": True, + "message": "会话补全成功", + "account_id": account.id, + "email": account.email, + "session_token_len": len(str(token or "")), + "session_token_preview": _mask_secret(token), + } + + +@router.post("/accounts/{account_id}/session-token") +def save_account_session_token( + account_id: int, + request: SaveSessionTokenRequest, +): + """ + 手动写入 session_token(ABCard 兜底模式)。 + """ + token = str(request.session_token or "").strip() + if not token: + raise HTTPException(status_code=400, detail="session_token 不能为空") + + 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.session_token = token + if request.merge_cookie: + account.cookies = _upsert_cookie(account.cookies, "__Secure-next-auth.session-token", token) + account.last_refresh = datetime.utcnow() + db.commit() + db.refresh(account) + + logger.info( + "手动写入 session_token: account_id=%s email=%s token_len=%s merge_cookie=%s", + account.id, + account.email, + len(token), + bool(request.merge_cookie), + ) + return { + "success": True, + "account_id": account.id, + "email": account.email, + "session_token_len": len(token), + "session_token_preview": _mask_secret(token), + "message": "session_token 已保存", + } + + +@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 = _resolve_runtime_proxy(request.proxy, account) + + try: + link, source, fallback_reason, checkout_session_id, publishable_key, client_secret = _generate_checkout_link_for_account( + account=account, + request=request, + proxy=proxy, + ) + except HTTPException: + raise + 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, + "is_official_checkout": _is_official_checkout_link(link), + "plan_type": request.plan_type, + "country": _normalize_checkout_country(request.country), + "currency": _normalize_checkout_currency(_normalize_checkout_country(request.country), request.currency), + "auto_opened": opened, + "source": source, + "fallback_reason": fallback_reason, + "checkout_session_id": checkout_session_id, + "publishable_key": publishable_key, + "has_client_secret": bool(client_secret), + } + + +@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": "未找到可用的浏览器,请手动复制链接"} + + +# ============== 绑卡任务(A 方案) ============== + +@router.post("/bind-card/tasks") +def create_bind_card_task(request: CreateBindCardTaskRequest): + """创建绑卡任务(从账号管理中选择账号)""" + bind_mode = str(request.bind_mode or "semi_auto").strip().lower() + if bind_mode not in ("semi_auto", "third_party", "local_auto"): + raise HTTPException(status_code=400, detail="bind_mode 必须为 semi_auto / third_party / local_auto") + + 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="账号不存在") + + logger.info( + "创建绑卡任务: account_id=%s email=%s plan=%s country=%s mode=%s auto_open=%s", + account.id, account.email, request.plan_type, request.country, bind_mode, request.auto_open + ) + + proxy = _resolve_runtime_proxy(request.proxy, account) + try: + link, source, fallback_reason, checkout_session_id, publishable_key, client_secret = _generate_checkout_link_for_account( + account=account, + request=request, + proxy=proxy, + ) + except HTTPException: + raise + 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)}") + + task = BindCardTask( + account_id=account.id, + plan_type=request.plan_type, + workspace_name=request.workspace_name if request.plan_type == "team" else None, + price_interval=request.price_interval if request.plan_type == "team" else None, + seat_quantity=request.seat_quantity if request.plan_type == "team" else None, + country=_normalize_checkout_country(request.country), + currency=_normalize_checkout_currency(_normalize_checkout_country(request.country), request.currency), + checkout_url=link, + checkout_session_id=checkout_session_id, + publishable_key=publishable_key, + client_secret=client_secret, + checkout_source=source, + bind_mode=bind_mode, + status="link_ready", + ) + db.add(task) + db.commit() + db.refresh(task) + + logger.info( + "绑卡任务已创建: task_id=%s account_id=%s plan=%s source=%s status=%s", + task.id, task.account_id, task.plan_type, source, task.status + ) + + opened = False + if request.auto_open and bind_mode == "semi_auto" and link: + opened = open_url_incognito(link, account.cookies if account else None) + if opened: + task.status = "opened" + task.opened_at = datetime.utcnow() + db.commit() + db.refresh(task) + logger.info("绑卡任务自动打开成功: task_id=%s mode=%s", task.id, bind_mode) + else: + logger.warning("绑卡任务自动打开失败: task_id=%s mode=%s", task.id, bind_mode) + + return { + "success": True, + "task": _serialize_bind_card_task(task), + "link": link, + "is_official_checkout": _is_official_checkout_link(link), + "source": source, + "fallback_reason": fallback_reason, + "auto_opened": opened, + "checkout_session_id": checkout_session_id, + "publishable_key": publishable_key, + "has_client_secret": bool(client_secret), + } + + +@router.get("/bind-card/tasks") +def list_bind_card_tasks( + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(20, ge=1, le=100, description="每页数量"), + status: Optional[str] = Query(None, description="状态筛选"), + search: Optional[str] = Query(None, description="按邮箱搜索"), +): + """绑卡任务列表""" + with get_db() as db: + query = db.query(BindCardTask).options(joinedload(BindCardTask.account)) + if status: + query = query.filter(BindCardTask.status == status) + if search: + pattern = f"%{search}%" + query = query.join(Account, BindCardTask.account_id == Account.id).filter( + or_(Account.email.ilike(pattern), Account.account_id.ilike(pattern)) + ) + + total = query.count() + offset = (page - 1) * page_size + tasks = query.order_by(BindCardTask.created_at.desc()).offset(offset).limit(page_size).all() + + # 自动收敛任务状态:如果账号已是 plus/team,任务自动标记完成。 + now = datetime.utcnow() + changed = False + changed_count = 0 + for task in tasks: + account = task.account + sub_type = str(getattr(account, "subscription_type", "") or "").lower() + if sub_type in ("plus", "team") and task.status != "completed": + task.status = "completed" + task.completed_at = task.completed_at or getattr(account, "subscription_at", None) or now + task.last_error = None + task.last_checked_at = now + changed = True + changed_count += 1 + + if changed: + db.commit() + logger.info("绑卡任务状态自动收敛完成: updated_count=%s", changed_count) + + return { + "total": total, + "tasks": [_serialize_bind_card_task(task) for task in tasks], + } + + +@router.post("/bind-card/tasks/{task_id}/open") +def open_bind_card_task(task_id: int): + """打开绑卡任务对应的 checkout 链接""" + with get_db() as db: + task = db.query(BindCardTask).options(joinedload(BindCardTask.account)).filter(BindCardTask.id == task_id).first() + if not task: + raise HTTPException(status_code=404, detail="绑卡任务不存在") + if not task.checkout_url: + raise HTTPException(status_code=400, detail="任务缺少 checkout 链接") + + cookies_str = task.account.cookies if task.account else None + logger.info("打开绑卡任务: task_id=%s account_id=%s", task.id, task.account_id) + opened = open_url_incognito(task.checkout_url, cookies_str) + if opened: + if str(task.status or "") not in ("paid_pending_sync", "completed"): + task.status = "opened" + task.opened_at = datetime.utcnow() + task.last_error = None + db.commit() + db.refresh(task) + logger.info("绑卡任务打开成功: task_id=%s", task.id) + return {"success": True, "task": _serialize_bind_card_task(task)} + + task.last_error = "未找到可用的浏览器" + db.commit() + logger.warning("绑卡任务打开失败: task_id=%s reason=%s", task.id, task.last_error) + raise HTTPException(status_code=500, detail="未找到可用的浏览器,请手动复制链接") + + +@router.post("/bind-card/tasks/{task_id}/auto-bind-third-party") +def auto_bind_bind_card_task_third_party(task_id: int, request: ThirdPartyAutoBindRequest): + """ + 通过第三方 API 自动提交绑卡(A+B 方案)。 + A: 三态判定(success/pending/failed) + B: 尝试轮询第三方状态接口,能确认 paid 就标记 paid_pending_sync(等待订阅同步) + """ + third_party_response_safe: dict = {} + api_url_for_log = "" + third_party_assessment: dict = {} + third_party_status_poll: dict = {} + with get_db() as db: + task = db.query(BindCardTask).options(joinedload(BindCardTask.account)).filter(BindCardTask.id == task_id).first() + if not task: + raise HTTPException(status_code=404, detail="绑卡任务不存在") + account = task.account + if not account: + raise HTTPException(status_code=404, detail="任务关联账号不存在") + + checkout_session_id = str(task.checkout_session_id or "").strip() or _extract_checkout_session_id_from_url(task.checkout_url) + publishable_key = str(task.publishable_key or "").strip() + if not checkout_session_id: + raise HTTPException(status_code=400, detail="任务缺少 checkout_session_id,请重新创建任务") + if not publishable_key: + raise HTTPException(status_code=400, detail="任务缺少 publishable_key,请重新创建任务") + + api_url = _resolve_third_party_bind_api_url(request.api_url) + api_key = _resolve_third_party_bind_api_key(request.api_key) + if not api_url: + raise HTTPException(status_code=400, detail=f"缺少第三方 API 地址(request.api_url 或环境变量 {THIRD_PARTY_BIND_API_URL_ENV})") + api_url_for_log = api_url + + proxy = _resolve_runtime_proxy(request.proxy, account) + payload = { + "checkout_session_id": checkout_session_id, + "publishable_key": publishable_key, + "client_secret": str(getattr(task, "client_secret", "") or "").strip() or None, + "checkout_url": str(task.checkout_url or "").strip() or None, + "plan_type": str(task.plan_type or "").strip().lower(), + "country": "US", + "currency": "USD", + "card": { + "number": str(request.card.number or "").strip(), + "exp_month": str(request.card.exp_month or "").strip().zfill(2), + "exp_year": str(request.card.exp_year or "").strip()[-2:], + "cvc": str(request.card.cvc or "").strip(), + }, + "profile": { + "name": str(request.profile.name or "").strip(), + "email": str(request.profile.email or account.email or "").strip(), + "country": str(request.profile.country or "US").strip().upper(), + "line1": str(request.profile.line1 or "").strip(), + "city": str(request.profile.city or "").strip(), + "state": str(request.profile.state or "").strip(), + "postal": str(request.profile.postal or "").strip(), + }, + } + + if not payload["card"]["number"] or not payload["card"]["cvc"]: + raise HTTPException(status_code=400, detail="卡号/CVC 不能为空") + + logger.info( + "第三方自动绑卡提交开始: task_id=%s account_id=%s mode=third_party api_url=%s has_api_key=%s cs_id=%s card=%s", + task.id, + account.id, + api_url, + "yes" if api_key else "no", + checkout_session_id[:24] + "...", + _mask_card_number(payload["card"]["number"]), + ) + + task.bind_mode = "third_party" + task.status = "verifying" + task.last_error = None + task.last_checked_at = datetime.utcnow() + db.commit() + + try: + third_party_response, used_endpoint = _invoke_third_party_bind_api( + api_url=api_url, + api_key=api_key, + payload=payload, + proxy=proxy, + ) + third_party_response_safe = _sanitize_third_party_response(third_party_response) + third_party_assessment = _assess_third_party_submission_result(third_party_response) + assess_state = str(third_party_assessment.get("state") or "pending").lower() + assess_reason = str(third_party_assessment.get("reason") or "").strip() + assess_snapshot = third_party_assessment.get("snapshot") if isinstance(third_party_assessment, dict) else {} + payment_status = str((assess_snapshot or {}).get("payment_status") or "").lower() + checkout_status = str((assess_snapshot or {}).get("checkout_status") or "").lower() + setup_intent_status = str((assess_snapshot or {}).get("setup_intent_status") or "").lower() + logger.info( + "第三方自动绑卡提交评估: task_id=%s account_id=%s endpoint=%s state=%s payment_status=%s checkout_status=%s setup_intent_status=%s reason=%s", + task.id, + account.id, + used_endpoint, + assess_state, + payment_status or "-", + checkout_status or "-", + setup_intent_status or "-", + assess_reason or "-", + ) + + if assess_state == "failed": + task.status = "failed" + task.last_error = f"第三方返回失败: {assess_reason or 'unknown'}" + task.last_checked_at = datetime.utcnow() + db.commit() + logger.warning( + "第三方自动绑卡返回业务失败: task_id=%s account_id=%s endpoint=%s reason=%s response=%s", + task.id, + account.id, + used_endpoint, + assess_reason, + third_party_response_safe, + ) + raise HTTPException(status_code=400, detail=f"第三方返回失败: {assess_reason or 'unknown'}") + + paid_hint_status = payment_status or "paid" + paid_hint_reason = assess_reason or "third_party_paid_signal" + + # B 方案:若提交后仍 pending,尝试轮询第三方状态接口确认是否已 paid。 + if assess_state == "pending": + # 若第三方明确返回 challenge/requires_action,直接切换待用户完成,避免无意义轮询超时。 + if _is_third_party_challenge_pending(third_party_assessment): + task.status = "waiting_user_action" + task.last_checked_at = datetime.utcnow() + hint_reason = assess_reason or "requires_action" + hint_payment_status = payment_status or "unknown" + task.last_error = ( + f"第三方已受理并进入挑战流程(payment_status={hint_payment_status}, reason={hint_reason})。" + "请打开 checkout 页面完成 challenge 后点击“我已完成支付”或“同步订阅”。" + ) + db.commit() + db.refresh(task) + logger.info( + "第三方自动绑卡检测到挑战态,转人工继续: task_id=%s account_id=%s payment_status=%s reason=%s", + task.id, + account.id, + hint_payment_status, + hint_reason, + ) + return { + "success": True, + "verified": False, + "pending": True, + "need_user_action": True, + "subscription_type": str(account.subscription_type or "free"), + "task": _serialize_bind_card_task(task), + "account_id": account.id, + "account_email": account.email, + "third_party": { + "submitted": True, + "api_url": used_endpoint, + "assessment": third_party_assessment, + "poll": {}, + "response": third_party_response_safe, + }, + } + + third_party_status_poll = _poll_third_party_bind_status( + api_url=used_endpoint, + api_key=api_key, + checkout_session_id=checkout_session_id, + proxy=proxy, + timeout_seconds=request.third_party_poll_timeout_seconds, + interval_seconds=request.third_party_poll_interval_seconds, + status_hints=assess_snapshot or {}, + ) + poll_state = str((third_party_status_poll or {}).get("state") or "pending").lower() + poll_reason = str((third_party_status_poll or {}).get("reason") or "").strip() + poll_snapshot = (third_party_status_poll or {}).get("snapshot") or {} + poll_payment_status = str((poll_snapshot or {}).get("payment_status") or "").lower() + logger.info( + "第三方自动绑卡状态轮询: task_id=%s account_id=%s endpoint=%s state=%s payment_status=%s reason=%s", + task.id, + account.id, + str((third_party_status_poll or {}).get("endpoint") or used_endpoint), + poll_state, + poll_payment_status or "-", + poll_reason or "-", + ) + + if poll_state == "failed": + task.status = "failed" + task.last_error = f"第三方状态失败: {poll_reason or 'unknown'}" + task.last_checked_at = datetime.utcnow() + db.commit() + raise HTTPException(status_code=400, detail=f"第三方状态失败: {poll_reason or 'unknown'}") + + if poll_state != "success": + task.status = "waiting_user_action" + task.last_checked_at = datetime.utcnow() + hint_reason = poll_reason or assess_reason or "pending_confirmation" + hint_payment_status = poll_payment_status or payment_status or "unknown" + task.last_error = ( + f"第三方已受理,等待支付最终状态(payment_status={hint_payment_status}, reason={hint_reason})。" + "如页面要求 challenge,请完成后点击“我已完成支付”或“同步订阅”。" + ) + db.commit() + db.refresh(task) + return { + "success": True, + "verified": False, + "pending": True, + "need_user_action": True, + "subscription_type": str(account.subscription_type or "free"), + "task": _serialize_bind_card_task(task), + "account_id": account.id, + "account_email": account.email, + "third_party": { + "submitted": True, + "api_url": used_endpoint, + "assessment": third_party_assessment, + "poll": third_party_status_poll, + "response": third_party_response_safe, + }, + } + + # poll 成功视为支付确认,不阻塞在订阅轮询 + paid_hint_status = poll_payment_status or payment_status or "paid" + paid_hint_reason = poll_reason or assess_reason or "third_party_paid_signal" + + logger.info( + "第三方自动绑卡提交成功: task_id=%s account_id=%s endpoint=%s response_keys=%s", + task.id, + account.id, + used_endpoint, + ",".join(sorted(third_party_response_safe.keys())), + ) + api_url_for_log = used_endpoint + _mark_task_paid_pending_sync( + task, + ( + f"支付已确认(payment_status={paid_hint_status}, reason={paid_hint_reason})。" + "等待订阅状态同步,可点击“同步订阅”刷新。" + ), + ) + db.commit() + db.refresh(task) + return { + "success": True, + "verified": False, + "paid_confirmed": True, + "subscription_type": str(account.subscription_type or "free"), + "task": _serialize_bind_card_task(task), + "account_id": account.id, + "account_email": account.email, + "third_party": { + "submitted": True, + "api_url": api_url_for_log, + "assessment": third_party_assessment, + "poll": third_party_status_poll, + "response": third_party_response_safe, + }, + } + except HTTPException: + raise + except Exception as exc: + task.status = "failed" + task.last_error = f"第三方绑卡提交失败: {exc}" + task.last_checked_at = datetime.utcnow() + db.commit() + logger.warning("第三方自动绑卡提交失败: task_id=%s error=%s", task.id, exc) + raise HTTPException(status_code=500, detail=str(exc)) + + +@router.post("/bind-card/tasks/{task_id}/auto-bind-local") +def auto_bind_bind_card_task_local(task_id: int, request: LocalAutoBindRequest): + """ + 本地自动绑卡(参考 ABCard 的浏览器自动化流程)。 + - 成功信号后标记 paid_pending_sync(等待订阅同步) + - challenge/超时等待用户完成时,回到 waiting_user_action + """ + browser_result: dict = {} + with get_db() as db: + task = db.query(BindCardTask).options(joinedload(BindCardTask.account)).filter(BindCardTask.id == task_id).first() + if not task: + raise HTTPException(status_code=404, detail="绑卡任务不存在") + account = task.account + if not account: + raise HTTPException(status_code=404, detail="任务关联账号不存在") + + checkout_session_id = str(task.checkout_session_id or "").strip() or _extract_checkout_session_id_from_url(task.checkout_url) + checkout_url = ( + _build_official_checkout_url(checkout_session_id) + or str(task.checkout_url or "").strip() + ) + if not checkout_url: + raise HTTPException(status_code=400, detail="任务缺少 checkout 链接,请重新创建任务") + + card_number = str(request.card.number or "").strip() + card_cvc = str(request.card.cvc or "").strip() + if not card_number or not card_cvc: + raise HTTPException(status_code=400, detail="卡号/CVC 不能为空") + + task.bind_mode = "local_auto" + task.status = "verifying" + task.last_error = None + task.last_checked_at = datetime.utcnow() + db.commit() + + logger.info( + "本地自动绑卡执行开始: task_id=%s account_id=%s email=%s checkout=%s headless=%s card=%s", + task.id, + account.id, + account.email, + checkout_url[:80], + bool(request.headless), + _mask_card_number(card_number), + ) + runtime_proxy = _resolve_runtime_proxy(request.proxy, account) + resolved_device_id = _resolve_account_device_id(account) + if not resolved_device_id: + logger.warning("本地自动绑卡缺少 oai-did: task_id=%s account_id=%s email=%s", task.id, account.id, account.email) + resolved_session_token = str(account.session_token or "").strip() or _extract_session_token_from_cookie_text( + account.cookies + ) + if not resolved_session_token and not runtime_proxy: + task.status = "failed" + task.last_checked_at = datetime.utcnow() + task.last_error = ( + "当前账号缺少 session_token,且未检测到可用代理。" + "请在设置中配置代理(或为本次任务传入 proxy)后重试全自动。" + ) + db.commit() + logger.warning( + "本地自动绑卡会话补全阻断: task_id=%s account_id=%s email=%s reason=no_proxy", + task.id, + account.id, + account.email, + ) + raise HTTPException(status_code=400, detail=task.last_error) + if not resolved_session_token: + resolved_session_token = _bootstrap_session_token_for_local_auto( + db=db, + account=account, + proxy=runtime_proxy, + ) + if not resolved_session_token: + task.status = "failed" + task.last_checked_at = datetime.utcnow() + task.last_error = ( + "会话补全未拿到 session_token。" + "请先在支付页执行“会话诊断/自动补会话”," + "或手动粘贴 session_token 后再重试全自动绑卡。" + ) + db.commit() + logger.warning( + "本地自动绑卡缺少 session_token,已阻断执行: task_id=%s account_id=%s email=%s", + task.id, + account.id, + account.email, + ) + raise HTTPException(status_code=400, detail=task.last_error) + + try: + browser_result = auto_bind_checkout_with_playwright( + checkout_url=checkout_url, + cookies_str=str(account.cookies or ""), + session_token=resolved_session_token, + access_token=str(account.access_token or ""), + device_id=resolved_device_id, + card_number=card_number, + exp_month=str(request.card.exp_month or ""), + exp_year=str(request.card.exp_year or ""), + cvc=card_cvc, + billing_name=str(request.profile.name or "").strip(), + billing_country=str(request.profile.country or "US").strip().upper(), + billing_line1=str(request.profile.line1 or "").strip(), + billing_city=str(request.profile.city or "").strip(), + billing_state=str(request.profile.state or "").strip(), + billing_postal=str(request.profile.postal or "").strip(), + proxy=runtime_proxy, + timeout_seconds=request.browser_timeout_seconds, + post_submit_wait_seconds=request.post_submit_wait_seconds, + headless=bool(request.headless), + ) + except Exception as exc: + task.status = "failed" + task.last_error = f"本地自动绑卡执行异常: {exc}" + task.last_checked_at = datetime.utcnow() + db.commit() + logger.warning("本地自动绑卡执行异常: task_id=%s account_id=%s error=%s", task.id, account.id, exc) + raise HTTPException(status_code=500, detail=f"本地自动绑卡执行失败: {exc}") + + success = bool(browser_result.get("success")) + need_user_action = bool(browser_result.get("need_user_action")) + pending = bool(browser_result.get("pending")) + stage = str(browser_result.get("stage") or "").strip() + message = str(browser_result.get("error") or browser_result.get("message") or "").strip() + current_url = str(browser_result.get("current_url") or "").strip() + + if not success: + if "playwright not installed" in message.lower(): + task.status = "failed" + task.last_checked_at = datetime.utcnow() + task.last_error = ( + "本地自动绑卡环境缺少 Playwright/Chromium。" + "请先执行: pip install playwright && playwright install chromium" + ) + db.commit() + logger.warning( + "本地自动绑卡环境缺失: task_id=%s account_id=%s error=%s", + task.id, + account.id, + message or "-", + ) + raise HTTPException(status_code=400, detail=task.last_error) + + if need_user_action or pending: + if stage in ("cdp_session_missing", "navigate_checkout"): + hint = ( + "自动会话未建立(checkout 重定向到首页)。" + "请先在“支付页-半自动”打开一次官方 checkout 完成登录态预热," + "随后再次执行“全自动”。" + ) + else: + hint = ( + f"本地自动绑卡已执行到 {stage or 'unknown'},当前需要人工继续完成({message or 'challenge_or_pending'})。" + "请在支付页完成后点击“我已完成支付”或“同步订阅”。" + ) + manual_opened = False + if stage in ("cdp_challenge", "challenge"): + try: + manual_opened = open_url_incognito(checkout_url, str(account.cookies or "")) + except Exception: + manual_opened = False + if manual_opened: + hint += " 已自动为你打开手动验证窗口。" + task.status = "waiting_user_action" + task.last_checked_at = datetime.utcnow() + task.last_error = hint + db.commit() + db.refresh(task) + logger.info( + "本地自动绑卡需人工继续: task_id=%s account_id=%s stage=%s msg=%s url=%s", + task.id, + account.id, + stage or "-", + message or "-", + current_url[:100] if current_url else "-", + ) + return { + "success": True, + "verified": False, + "pending": True, + "need_user_action": True, + "subscription_type": str(account.subscription_type or "free"), + "task": _serialize_bind_card_task(task), + "account_id": account.id, + "account_email": account.email, + "manual_opened": manual_opened, + "local_auto": browser_result, + } + + task.status = "failed" + task.last_checked_at = datetime.utcnow() + task.last_error = f"本地自动绑卡失败: {message or 'unknown_error'}" + db.commit() + logger.warning( + "本地自动绑卡失败: task_id=%s account_id=%s stage=%s msg=%s", + task.id, + account.id, + stage or "-", + message or "-", + ) + raise HTTPException(status_code=400, detail=task.last_error) + + logger.info( + "本地自动绑卡浏览器阶段成功: task_id=%s account_id=%s stage=%s url=%s", + task.id, + account.id, + stage or "-", + current_url[:100] if current_url else "-", + ) + _mark_task_paid_pending_sync( + task, + ( + f"本地自动绑卡已完成支付提交(stage={stage or 'complete'})。" + "等待订阅状态同步,可点击“同步订阅”刷新。" + ), + ) + db.commit() + db.refresh(task) + return { + "success": True, + "verified": False, + "paid_confirmed": True, + "subscription_type": str(account.subscription_type or "free"), + "task": _serialize_bind_card_task(task), + "account_id": account.id, + "account_email": account.email, + "local_auto": browser_result, + } + + +@router.post("/bind-card/tasks/{task_id}/sync-subscription") +def sync_bind_card_task_subscription(task_id: int, request: SyncBindCardTaskRequest): + """同步任务账号订阅状态,并回写到账号管理""" + with get_db() as db: + task = db.query(BindCardTask).options(joinedload(BindCardTask.account)).filter(BindCardTask.id == task_id).first() + if not task: + raise HTTPException(status_code=404, detail="绑卡任务不存在") + account = task.account + if not account: + raise HTTPException(status_code=404, detail="任务关联账号不存在") + + proxy = _resolve_runtime_proxy(request.proxy, account) + now = datetime.utcnow() + try: + detail, refreshed = _check_subscription_detail_with_retry( + db=db, + account=account, + proxy=proxy, + allow_token_refresh=True, + ) + status = str(detail.get("status") or "free").lower() + source = str(detail.get("source") or "unknown") + confidence = str(detail.get("confidence") or "low") + logger.info( + "绑卡任务同步订阅: task_id=%s account_id=%s status=%s source=%s confidence=%s token_refreshed=%s", + task.id, account.id, status, source, confidence, refreshed + ) + except Exception as exc: + task.status = "failed" + task.last_error = str(exc) + task.last_checked_at = now + db.commit() + logger.warning("绑卡任务同步订阅失败: task_id=%s error=%s", task.id, exc) + raise HTTPException(status_code=500, detail=f"订阅检测失败: {exc}") + + # 仅在高置信度 free 时清空;低置信度 free 不覆盖已有订阅 + if status in ("plus", "team"): + account.subscription_type = status + account.subscription_at = now + elif status == "free": + if str(detail.get("confidence") or "").lower() == "high": + account.subscription_type = None + account.subscription_at = None + + task.last_checked_at = now + if status in ("plus", "team"): + task.status = "completed" + task.completed_at = now + task.last_error = None + else: + task.completed_at = None + note = str(detail.get("note") or "").strip() + confidence_text = str(detail.get("confidence") or "unknown") + confidence_lower = confidence_text.lower() + source_text = str(detail.get("source") or "unknown") + task_status_now = str(task.status or "") + has_checkout_context = bool(task.checkout_session_id or task.checkout_url) + should_keep_paid_pending = ( + task_status_now == "paid_pending_sync" + or ( + status == "free" + and confidence_lower != "high" + and has_checkout_context + and task_status_now in ("waiting_user_action", "verifying", "link_ready") + ) + ) + if should_keep_paid_pending: + task.status = "paid_pending_sync" + task.last_error = ( + f"已确认支付,订阅暂未同步(当前: {status}, source={source_text}, " + f"confidence={confidence_text}" + + (f", note={note}" if note else "") + + ")。可稍后再次点击“同步订阅”。" + ) + else: + task.status = "waiting_user_action" + task.last_error = None + if confidence_lower != "high": + task.last_error = ( + f"订阅判定低置信度(source={source_text}, " + f"confidence={confidence_text}" + + (f", note={note}" if note else "") + + "),请稍后重试。" + ) + + db.commit() + db.refresh(task) + + return { + "success": True, + "subscription_type": status, + "detail": detail, + "task": _serialize_bind_card_task(task), + "account_id": account.id, + "account_email": account.email, + } + + +@router.post("/bind-card/tasks/{task_id}/mark-user-action") +def mark_bind_card_task_user_action(task_id: int, request: MarkUserActionRequest): + """ + 用户确认“已完成支付”后,自动轮询订阅状态一段时间: + - 命中 plus/team -> completed + - 超时未命中 -> paid_pending_sync 或 waiting_user_action + """ + with get_db() as db: + task = db.query(BindCardTask).options(joinedload(BindCardTask.account)).filter(BindCardTask.id == task_id).first() + if not task: + raise HTTPException(status_code=404, detail="绑卡任务不存在") + account = task.account + if not account: + raise HTTPException(status_code=404, detail="任务关联账号不存在") + + proxy = _resolve_runtime_proxy(request.proxy, account) + timeout_seconds = int(request.timeout_seconds) + interval_seconds = int(request.interval_seconds) + logger.info( + "绑卡任务开始验证: task_id=%s account_id=%s timeout=%ss interval=%ss", + task.id, account.id, timeout_seconds, interval_seconds + ) + + previous_status = str(task.status or "") + now = datetime.utcnow() + task.status = "verifying" + task.last_error = None + task.last_checked_at = now + db.commit() + + deadline = time.monotonic() + timeout_seconds + checks = 0 + last_status = "free" + token_refresh_used = False + + while time.monotonic() < deadline: + checks += 1 + try: + detail, refreshed = _check_subscription_detail_with_retry( + db=db, + account=account, + proxy=proxy, + allow_token_refresh=not token_refresh_used, + ) + if refreshed: + token_refresh_used = True + status = str(detail.get("status") or "free").lower() + last_status = status + logger.info( + "绑卡任务验证轮询: task_id=%s attempt=%s status=%s source=%s confidence=%s token_refreshed=%s", + task.id, checks, status, detail.get("source"), detail.get("confidence"), bool(detail.get("token_refreshed")) + ) + except Exception as exc: + failed_at = datetime.utcnow() + task.status = "failed" + task.last_error = f"订阅检测失败: {exc}" + task.last_checked_at = failed_at + db.commit() + logger.warning("绑卡任务验证失败: task_id=%s attempt=%s error=%s", task.id, checks, exc) + raise HTTPException(status_code=500, detail=f"订阅检测失败: {exc}") + + checked_at = datetime.utcnow() + if status in ("plus", "team"): + account.subscription_type = status + account.subscription_at = checked_at + elif status == "free": + # 低置信度 free 不覆盖已有订阅,避免误判清空 + if str(detail.get("confidence") or "").lower() == "high": + account.subscription_type = None + account.subscription_at = None + task.last_checked_at = checked_at + + if status in ("plus", "team"): + task.status = "completed" + task.completed_at = checked_at + task.last_error = None + db.commit() + db.refresh(task) + logger.info( + "绑卡任务验证成功: task_id=%s attempts=%s status=%s source=%s", + task.id, checks, status, detail.get("source") + ) + return { + "success": True, + "verified": True, + "checks": checks, + "subscription_type": status, + "detail": detail, + "token_refresh_used": token_refresh_used, + "task": _serialize_bind_card_task(task), + "account_id": account.id, + "account_email": account.email, + } + + db.commit() + if time.monotonic() + interval_seconds >= deadline: + break + time.sleep(interval_seconds) + + timeout_msg = ( + f"在 {timeout_seconds} 秒内未检测到订阅变更(当前: {last_status}," + f"source={detail.get('source') if 'detail' in locals() else 'unknown'}," + f"confidence={detail.get('confidence') if 'detail' in locals() else 'unknown'}," + f"token_refreshed={token_refresh_used}" + + ( + f",note={str(detail.get('note') or '').strip()}" + if "detail" in locals() and detail and detail.get("note") + else "" + ) + + ")。" + ) + timeout_confidence = str((detail if "detail" in locals() else {}).get("confidence") or "unknown").lower() + timeout_source = str((detail if "detail" in locals() else {}).get("source") or "unknown") + should_keep_paid_pending = ( + previous_status == "paid_pending_sync" + or (last_status == "free" and timeout_confidence != "high") + ) + if should_keep_paid_pending: + task.status = "paid_pending_sync" + task.last_error = ( + timeout_msg + + "支付可能已完成但订阅同步延迟" + + f"(source={timeout_source}, confidence={timeout_confidence})。" + + "请稍后点“同步订阅”重试。" + ) + else: + task.status = "waiting_user_action" + task.last_error = timeout_msg + "请稍后点击“同步订阅”重试。" + task.last_checked_at = datetime.utcnow() + task.completed_at = None + db.commit() + db.refresh(task) + logger.warning( + "绑卡任务验证超时: task_id=%s attempts=%s last_status=%s last_error=%s", + task.id, checks, last_status, task.last_error + ) + + return { + "success": True, + "verified": False, + "checks": checks, + "subscription_type": last_status, + "detail": detail if "detail" in locals() else None, + "token_refresh_used": token_refresh_used, + "task": _serialize_bind_card_task(task), + "account_id": account.id, + "account_email": account.email, + } + + +@router.delete("/bind-card/tasks/{task_id}") +def delete_bind_card_task(task_id: int): + """删除绑卡任务""" + with get_db() as db: + task = db.query(BindCardTask).filter(BindCardTask.id == task_id).first() + if not task: + raise HTTPException(status_code=404, detail="绑卡任务不存在") + logger.info("删除绑卡任务: task_id=%s account_id=%s", task.id, task.account_id) + db.delete(task) + db.commit() + return {"success": True, "task_id": task_id} + + +# ============== 订阅状态 ============== + +@router.post("/accounts/batch-check-subscription") +def batch_check_subscription(request: BatchCheckSubscriptionRequest): + """批量检测账号订阅状态""" + explicit_proxy = _normalize_proxy_value(request.proxy) + + 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: + runtime_proxy = _resolve_runtime_proxy(explicit_proxy, account) + detail, refreshed = _check_subscription_detail_with_retry( + db=db, + account=account, + proxy=runtime_proxy, + allow_token_refresh=True, + ) + status = str(detail.get("status") or "free").lower() + confidence = str(detail.get("confidence") or "low").lower() + + if status in ("plus", "team"): + account.subscription_type = status + account.subscription_at = datetime.utcnow() + elif status == "free" and confidence == "high": + account.subscription_type = None + account.subscription_at = None + + db.commit() + results["success_count"] += 1 + results["details"].append( + { + "id": account_id, + "email": account.email, + "success": True, + "subscription_type": status, + "confidence": confidence, + "source": detail.get("source"), + "token_refreshed": refreshed, + } + ) + 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 00000000..b2cdcd5c --- /dev/null +++ b/src/web/routes/registration.py @@ -0,0 +1,1550 @@ +""" +注册任务 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 ...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 列表 + 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] = [] + 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] = [] + 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, 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: + token_data = generate_token_json(saved_account) + _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 + 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) + 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, 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 [], + 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 [], + 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, + 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, + 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.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.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 "" + result = task.result if isinstance(task.result, dict) else {} + email = result.get("email") + service_type = task.email_service.service_type if task.email_service else None + return { + "task_uuid": task_uuid, + "status": task.status, + "email": email, + "email_service": service_type, + "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_status_stats = db.query( + RegistrationTask.status, + func.count(RegistrationTask.id) + ).filter( + func.date(RegistrationTask.created_at) == today + ).group_by(RegistrationTask.status).all() + + today_count = db.query(func.count(RegistrationTask.id)).filter( + func.date(RegistrationTask.created_at) == today + ).scalar() + + today_by_status = {status: count for status, count in today_status_stats} + today_success = int(today_by_status.get("completed", 0)) + today_failed = int(today_by_status.get("failed", 0)) + today_total = int(today_count or 0) + today_success_rate = round((today_success / today_total) * 100, 1) if today_total > 0 else 0.0 + + return { + "by_status": {status: count for status, count in status_stats}, + "today_count": today_total, + "today_total": today_total, + "today_success": today_success, + "today_failed": today_failed, + "today_success_rate": today_success_rate, + "today_by_status": today_by_status, + } + + +@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, + 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.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 00000000..692e7f1e --- /dev/null +++ b/src/web/routes/settings.py @@ -0,0 +1,888 @@ +""" +设置 API 路由 +""" + +import logging +import os +from typing import Optional + +from fastapi import APIRouter, HTTPException, UploadFile, File +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 + entry_flow: str = "native" + + +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() + + entry_flow_raw = str(settings.registration_entry_flow or "native").strip().lower() + entry_flow = "abcard" if entry_flow_raw == "abcard" else "native" + + 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, + "entry_flow": entry_flow, + }, + "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() + + entry_flow_raw = str(settings.registration_entry_flow or "native").strip().lower() + entry_flow = "abcard" if entry_flow_raw == "abcard" else "native" + + 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, + "entry_flow": entry_flow, + } + + +@router.post("/registration") +async def update_registration_settings(request: RegistrationSettings): + """更新注册设置""" + flow_raw = (request.entry_flow or "native").strip().lower() + # 兼容旧前端历史值:outlook -> native(Outlook 邮箱会在运行时自动走 outlook 链路)。 + flow = "native" if flow_raw == "outlook" else flow_raw + if flow not in {"native", "abcard"}: + raise HTTPException(status_code=400, detail="entry_flow 仅支持 native / abcard") + + 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_entry_flow=flow, + ) + + 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/import") +async def import_database(file: UploadFile = File(...)): + """导入数据库(自动备份后覆盖当前 SQLite 文件)""" + import shutil + import tempfile + from datetime import datetime + from pathlib import Path as FilePath + from ...database.session import get_session_manager + + settings = get_settings() + + db_path = settings.database_url + if not db_path.startswith("sqlite:///"): + raise HTTPException(status_code=400, detail="当前仅支持 SQLite 数据库导入") + + db_path = db_path[10:] + db_file = FilePath(db_path) + + # 校验上传扩展名 + filename = (file.filename or "").lower() + allowed_ext = (".db", ".sqlite", ".sqlite3") + if filename and not filename.endswith(allowed_ext): + raise HTTPException(status_code=400, detail="仅支持 .db / .sqlite / .sqlite3 文件") + + if not db_file.exists(): + raise HTTPException(status_code=404, detail="数据库文件不存在") + + # 先落地到临时文件,再校验头,避免脏写 + temp_path = None + try: + db_file.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + prefix="db_import_", + suffix=".db", + dir=str(db_file.parent), + delete=False + ) as tmp: + temp_path = FilePath(tmp.name) + while True: + chunk = await file.read(1024 * 1024) + if not chunk: + break + tmp.write(chunk) + + if not temp_path.exists() or temp_path.stat().st_size < 100: + raise HTTPException(status_code=400, detail="导入文件无效或为空") + + # SQLite 文件头校验 + with temp_path.open("rb") as f: + header = f.read(16) + if not header.startswith(b"SQLite format 3\x00"): + raise HTTPException(status_code=400, detail="文件不是有效的 SQLite 数据库") + + # 先释放数据库连接,避免 Windows 下文件被占用 + session_manager = get_session_manager() + session_manager.engine.dispose() + + # 导入前自动备份 + backup_dir = db_file.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_before_import_{timestamp}.db" + shutil.copy2(db_file, backup_path) + + # 清理 WAL/SHM,避免替换后出现旧事务残留 + wal_file = FilePath(f"{db_file}-wal") + shm_file = FilePath(f"{db_file}-shm") + for sidecar in (wal_file, shm_file): + try: + if sidecar.exists(): + sidecar.unlink() + except Exception: + logger.warning("清理 SQLite 附属文件失败: %s", sidecar) + + os.replace(str(temp_path), str(db_file)) + + logger.info("数据库导入成功: file=%s backup=%s", file.filename, backup_path) + return { + "success": True, + "message": "数据库导入成功", + "backup_path": str(backup_path), + } + finally: + await file.close() + if temp_path and temp_path.exists(): + try: + temp_path.unlink() + except Exception: + pass + + +@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 00000000..1f776fc6 --- /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 00000000..f98ec2f2 --- /dev/null +++ b/src/web/routes/upload/cpa_services.py @@ -0,0 +1,171 @@ +""" +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 + priority: int = 0 + + +class CpaServiceUpdate(BaseModel): + name: Optional[str] = None + api_url: Optional[str] = None + api_token: Optional[str] = None + enabled: Optional[bool] = None + priority: Optional[int] = None + + +class CpaServiceResponse(BaseModel): + id: int + name: str + api_url: str + has_token: bool + enabled: 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, + 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, + 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, + "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.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 00000000..ddd77592 --- /dev/null +++ b/src/web/routes/upload/sub2api_services.py @@ -0,0 +1,207 @@ +""" +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 diff --git a/src/web/routes/upload/tm_services.py b/src/web/routes/upload/tm_services.py new file mode 100644 index 00000000..b363139e --- /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 00000000..d864f837 --- /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 00000000..fde9adf7 --- /dev/null +++ b/src/web/task_manager.py @@ -0,0 +1,396 @@ +""" +任务管理器 +负责管理后台任务、日志队列和 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) + + # 与批量任务保持一致:状态变更后主动广播,避免前端只停留在初始 pending。 + if self._loop and self._loop.is_running(): + try: + asyncio.run_coroutine_threadsafe( + self.broadcast_status(task_uuid, status, **kwargs), + self._loop, + ) + except Exception as e: + logger.warning(f"广播任务状态失败: {e}") + + 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 00000000..f1cff73d --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,1461 @@ +/* + * 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; +} + +.site-notice-wrapper { + padding: var(--spacing-md) 0 0; +} + +.site-notice { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + padding: 14px 18px; + border: 1px solid rgba(245, 158, 11, 0.28); + border-radius: var(--radius-lg); + background: linear-gradient(135deg, rgba(245, 158, 11, 0.14), rgba(16, 163, 127, 0.08)); + box-shadow: var(--shadow-sm); +} + +.site-notice-heading { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + color: var(--text-primary); + font-size: 0.95rem; +} + +.site-notice-heading strong { + color: #b45309; +} + +.site-notice-text { + color: var(--text-secondary); + font-size: 0.875rem; + margin: 0; +} + +.site-notice-links { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.site-notice-links a { + display: inline-flex; + align-items: center; + min-height: 36px; + padding: 0 14px; + border-radius: var(--radius-full); + background: var(--surface); + border: 1px solid var(--border); + color: var(--primary-color); + text-decoration: none; + font-size: 0.875rem; + font-weight: 600; + transition: all var(--transition); +} + +.site-notice-links a:hover { + color: var(--primary-hover); + border-color: var(--primary-color); + transform: translateY(-1px); +} + +/* ============================================ + 布局 + ============================================ */ + +.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-success { + background: #10a37f; + color: #fff; +} + +.btn-success:hover:not(:disabled) { + background: #0f8f70; + transform: translateY(-1px); + box-shadow: var(--shadow-md); +} + +.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); +} + +/* 邮箱服务页:仅拉高“自定义邮箱服务”模块 */ +.custom-email-services-card .table-container { + min-height: 320px; +} + +.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) { + .site-notice { + padding: 12px 14px; + } + + .site-notice-heading { + font-size: 0.9rem; + } + + .site-notice-links a { + width: 100%; + justify-content: center; + } + + .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; + } + + .custom-email-services-card .table-container { + min-height: 220px; + } + +} + +@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 00000000..7038e041 --- /dev/null +++ b/static/js/accounts.js @@ -0,0 +1,1340 @@ +/** + * 账号管理页面 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: '' }; // 当前筛选条件 + +// 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)} + ${renderAccountStatusDot(account.status)} + +
+ ${account.cpa_uploaded + ? `` + : `-`} +
+ + + ${renderSubscriptionStatus(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 normalizeSubscriptionType(subscriptionType) { + const raw = String(subscriptionType || '').trim().toLowerCase(); + if (!raw) return ''; + if (raw.includes('team') || raw.includes('enterprise')) return 'team'; + if (raw.includes('plus') || raw.includes('pro')) return 'plus'; + if (raw.includes('free') || raw.includes('basic')) return 'free'; + return raw; +} + +function hasActiveSubscription(subscriptionType) { + const normalized = normalizeSubscriptionType(subscriptionType); + return normalized === 'plus' || normalized === 'team'; +} + +function renderAccountStatusDot(status) { + const normalized = String(status || '').trim().toLowerCase(); + const dotClass = ['active', 'expired', 'banned', 'failed'].includes(normalized) + ? normalized + : 'unknown'; + const title = getStatusText('account', normalized) || normalized || '-'; + return ` + + `; +} + +function renderSubscriptionStatus(subscriptionType) { + const normalized = normalizeSubscriptionType(subscriptionType); + const subscribed = hasActiveSubscription(normalized); + const dotClass = subscribed ? 'subscribed' : 'unsubscribed'; + const label = subscribed ? normalized.toUpperCase() : 'FREE'; + const title = subscribed + ? `已订阅: ${normalized}` + : '未检测到 Plus/Team 订阅'; + return ` +
+ + ${escapeHtml(label)} +
+ `; +} + +// 切换密码显示 +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) { + 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); + } +} + +// 批量刷新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; + + elements.batchValidateBtn.disabled = true; + elements.batchValidateBtn.textContent = '验证中...'; + + try { + const result = await api.post('/accounts/batch-validate', buildBatchPayload()); + toast.info(`有效: ${result.valid_count},无效: ${result.invalid_count}`); + loadAccounts(); + } catch (error) { + toast.error('批量验证失败: ' + error.message); + } finally { + 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 ? `` : ''} +
+
+
+ Session Token +
+ ${escapeHtml(tokens.session_token || account.session_token || '-')} + ${(tokens.session_token || account.session_token) + ? `` + : '' + } + + ${tokens.session_token_source ? `来源: ${escapeHtml(tokens.session_token_source)}` : ''} +
+
+
+ Device ID +
+ ${escapeHtml(tokens.device_id || account.device_id || '-')} + ${(tokens.device_id || account.device_id) ? `` : ''} +
+
+
+ Cookies(支付用) +
+ + +
+
+
+
+ +
+ `; + + elements.detailModal.classList.add('active'); + } catch (error) { + toast.error('加载账号详情失败: ' + error.message); + } +} + +async function bootstrapSessionToken(id) { + try { + const result = await api.post(`/payment/accounts/${id}/session-bootstrap`, {}); + if (result && result.success) { + toast.success('Session Token 补全成功'); + } else { + toast.warning(result?.message || '未补全到 Session Token'); + } + } catch (error) { + toast.error('补全 Session Token 失败: ' + error.message); + } finally { + await viewAccount(id); + loadAccounts(); + } +} + +async function editSessionToken(id, currentToken = '') { + const current = String(currentToken || ''); + const nextToken = window.prompt('请输入新的 Session Token(留空将清空)', current); + if (nextToken === null) return; + try { + await api.patch(`/accounts/${id}`, { session_token: String(nextToken).trim() }); + toast.success('Session Token 已更新'); + } catch (error) { + toast.error('更新 Session Token 失败: ' + error.message); + } finally { + await viewAccount(id); + loadAccounts(); + } +} + +// 复制邮箱 +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/accounts_overview.js b/static/js/accounts_overview.js new file mode 100644 index 00000000..1d360305 --- /dev/null +++ b/static/js/accounts_overview.js @@ -0,0 +1,1050 @@ +/** + * 账号总览页面脚本 + * 上半区:原有总览布局 + * 下半区:卡片管理布局(按设计图增强) + */ + +const VIEW_MODE_STORAGE_KEY = 'accounts_overview_view_mode'; + +const overviewState = { + summary: null, + cards: [], + filteredCards: [], + selectedCardIds: new Set(), + addableCards: [], + viewMode: storage.get(VIEW_MODE_STORAGE_KEY, 'grid') || 'grid', + planFilter: 'all', + sortMode: 'created_desc', + cardRefreshIntervalMin: 7, + cardRefreshTimer: null, + cardCountdownTimer: null, + cardNextRefreshAt: null, +}; + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text ?? ''; + return div.innerHTML; +} + +function setText(id, value) { + const el = document.getElementById(id); + if (el) el.textContent = format.number(value || 0); +} + +function toSortedEntries(data) { + return Object.entries(data || {}).sort((a, b) => Number(b[1] || 0) - Number(a[1] || 0)); +} + +const SERVICE_DIST_PALETTE = [ + '#3b82f6', // blue + '#10b981', // emerald + '#f59e0b', // amber + '#8b5cf6', // violet + '#ef4444', // red + '#06b6d4', // cyan + '#84cc16', // lime + '#f97316', // orange +]; + +function hashText(text) { + const str = String(text || ''); + let hash = 0; + for (let i = 0; i < str.length; i += 1) { + hash = ((hash << 5) - hash) + str.charCodeAt(i); + hash |= 0; + } + return Math.abs(hash); +} + +function getDistributionBarColor(containerId, key, index) { + const value = String(key || '').trim().toLowerCase(); + + if (containerId === 'dist-subscription') { + if (value.includes('team')) return '#8b5cf6'; // team: purple + if (value.includes('plus')) return '#10b981'; + if (value.includes('pro')) return '#0ea5e9'; + if (value.includes('free')) return '#94a3b8'; + } + + if (containerId === 'dist-status') { + if (value === 'active') return '#f59e0b'; // active: orange + if (value === 'failed' || value === 'banned') return '#ef4444'; + if (value === 'expired') return '#64748b'; + return '#3b82f6'; + } + + if (containerId === 'dist-service') { + const paletteIndex = hashText(value || index) % SERVICE_DIST_PALETTE.length; + return SERVICE_DIST_PALETTE[paletteIndex]; + } + + if (containerId === 'dist-source') { + if (value === 'register') return '#3b82f6'; + if (value === 'login') return '#10b981'; + } + + return '#3b82f6'; +} + +function renderDistribution(containerId, data, labelFormatter) { + const container = document.getElementById(containerId); + if (!container) return; + + const entries = toSortedEntries(data); + if (!entries.length) { + container.innerHTML = '
暂无数据
'; + return; + } + + const maxValue = Math.max(...entries.map(([, count]) => Number(count || 0)), 1); + container.innerHTML = entries.map(([key, count], index) => { + const value = Number(count || 0); + const width = Math.max((value / maxValue) * 100, 2); + const label = labelFormatter ? labelFormatter(key) : key; + const barColor = getDistributionBarColor(containerId, key, index); + return ` +
+
${escapeHtml(label)}
+
+
${format.number(value)}
+
+ `; + }).join(''); +} + +function formatSource(value) { + const sourceMap = { + register: '注册', + login: '登录', + unknown: '未知', + }; + return sourceMap[value] || value || '-'; +} + +function formatSubscription(value) { + const key = (value || 'free').toLowerCase(); + const map = { + free: '免费', + plus: 'Plus', + team: 'Team', + pro: 'Pro', + }; + return map[key] || key; +} + +function normalizePlan(planType) { + const value = String(planType || '').toLowerCase(); + if (value.includes('team')) return 'team'; + if (value.includes('plus')) return 'plus'; + if (value.includes('pro')) return 'pro'; + if (value.includes('free') || value.includes('basic')) return 'free'; + return 'free'; +} + +function getPlanText(planType) { + const plan = normalizePlan(planType); + if (plan === 'team') return 'TEAM'; + if (plan === 'plus') return 'PLUS'; + if (plan === 'pro') return 'PRO'; + return 'FREE'; +} + +function getPlanClass(planType) { + const plan = normalizePlan(planType); + if (plan === 'team') return 'team'; + if (plan === 'plus' || plan === 'pro') return 'plus'; + return 'free'; +} + +function parsePercent(quota) { + const raw = quota?.percentage; + if (raw === null || raw === undefined || Number.isNaN(Number(raw))) return null; + return Math.max(0, Math.min(100, Number(raw))); +} + +function getProgressTone(percentage) { + if (percentage === null) { + return { valueClass: 'tone-gray', barClass: 'tone-gray-bar' }; + } + if (percentage < 10) { + return { valueClass: 'tone-red', barClass: 'tone-red-bar' }; + } + if (percentage >= 90) { + return { valueClass: 'tone-green', barClass: 'tone-green-bar' }; + } + return { valueClass: 'tone-orange', barClass: 'tone-orange-bar' }; +} + +function parseTimeMs(value) { + const ms = Date.parse(value || ''); + return Number.isNaN(ms) ? null : ms; +} + +function formatMinuteCountdown(targetMs) { + if (!targetMs) return '-'; + const diffMs = targetMs - Date.now(); + if (diffMs <= 0) return '0分'; + + const totalMinutes = Math.max(1, Math.ceil(diffMs / 60000)); + const days = Math.floor(totalMinutes / 1440); + const hours = Math.floor((totalMinutes % 1440) / 60); + const mins = totalMinutes % 60; + + if (days > 0) return `${days}天${hours}小时${mins}分`; + if (hours > 0) return `${hours}小时${mins}分`; + return `${mins}分`; +} + +function formatQuotaResetText(quota) { + const resetAt = parseTimeMs(quota?.reset_at); + const percent = parsePercent(quota); + if (resetAt) { + const absolute = format.date(new Date(resetAt).toISOString()); + if (percent !== null && percent <= 0) { + return `已用尽,下次 ${absolute}`; + } + return `${formatMinuteCountdown(resetAt)} (${absolute})`; + } + return quota?.reset_in_text || '-'; +} + +function renderQuotaItem(title, quota) { + const percent = parsePercent(quota); + const tone = getProgressTone(percent); + const showPercent = percent === null ? '--' : `${Math.round(percent)}%`; + const width = percent === null ? 0 : percent; + const resetText = formatQuotaResetText(quota); + + return ` +
+
+ ${escapeHtml(title)} + ${showPercent} +
+
+
重置: ${escapeHtml(resetText)}
+
+ `; +} + +function renderRecentAccounts(accounts) { + const tbody = document.getElementById('recent-accounts-table'); + if (!tbody) return; + + if (!accounts || !accounts.length) { + tbody.innerHTML = ` + +
暂无账号数据
+ + `; + return; + } + + tbody.innerHTML = accounts.map((item) => ` + + ${item.id || '-'} + ${escapeHtml(item.email || '-')} + ${escapeHtml(getStatusText('account', item.status) || '-')} + ${escapeHtml(getServiceTypeText(item.email_service || '') || '-')} + ${escapeHtml(formatSource(item.source))} + ${escapeHtml(formatSubscription(item.subscription_type))} + ${format.date(item.created_at)} + ${format.date(item.last_refresh)} + + `).join(''); +} + +async function loadLegacyOverview() { + try { + const data = await api.get('/accounts/stats/overview'); + overviewState.summary = data; + + setText('ov-total', data.total); + setText('ov-active', data.active_count); + setText('ov-access-token', data.token_stats?.with_access_token || 0); + setText('ov-cpa-uploaded', data.cpa_uploaded_count); + + renderDistribution('dist-status', data.by_status, (status) => getStatusText('account', status)); + renderDistribution('dist-service', data.by_email_service, (service) => getServiceTypeText(service)); + renderDistribution('dist-subscription', data.by_subscription, formatSubscription); + renderDistribution('dist-source', data.by_source, formatSource); + renderRecentAccounts(data.recent_accounts || []); + } catch (error) { + toast.error(`加载总览统计失败: ${error.message || '未知错误'}`); + } +} + +function updatePlanFilterOptions() { + const select = document.getElementById('card-plan-filter'); + if (!select) return; + + const sourceCards = overviewState.cards; + const total = sourceCards.length; + const freeCount = sourceCards.filter((item) => normalizePlan(item.plan_type) === 'free').length; + const plusCount = sourceCards.filter((item) => normalizePlan(item.plan_type) === 'plus').length; + const teamCount = sourceCards.filter((item) => normalizePlan(item.plan_type) === 'team').length; + const currentCount = sourceCards.filter((item) => Boolean(item.current)).length; + + const currentValue = overviewState.planFilter; + select.innerHTML = ` + + + + + + `; + select.value = ['all', 'free', 'plus', 'team', 'current'].includes(currentValue) ? currentValue : 'all'; +} + +function syncSelectedCardIds() { + const existing = new Set( + overviewState.cards + .map((item) => Number(item.id)) + .filter((id) => Number.isFinite(id)) + ); + const next = new Set(); + for (const id of overviewState.selectedCardIds) { + if (existing.has(id)) next.add(id); + } + overviewState.selectedCardIds = next; +} + +function updateSelectionInfo() { + const info = document.getElementById('card-selection-info'); + if (!info) return; + const selected = overviewState.selectedCardIds.size; + const total = overviewState.filteredCards.length; + info.textContent = `已选择 ${selected} 个账号 / 当前列表 ${total} 个`; +} + +function parseDateScore(value) { + const ts = Date.parse(value || ''); + return Number.isNaN(ts) ? 0 : ts; +} + +function sortCards(cards) { + const rows = [...cards]; + const sortMode = overviewState.sortMode; + + rows.sort((a, b) => { + if (sortMode === 'created_asc') { + return parseDateScore(a.created_at) - parseDateScore(b.created_at); + } + if (sortMode === 'hourly_desc') { + return (parsePercent(b.hourly_quota) ?? -1) - (parsePercent(a.hourly_quota) ?? -1); + } + if (sortMode === 'weekly_desc') { + return (parsePercent(b.weekly_quota) ?? -1) - (parsePercent(a.weekly_quota) ?? -1); + } + if (sortMode === 'email_asc') { + return String(a.email || '').localeCompare(String(b.email || ''), 'zh-CN'); + } + return parseDateScore(b.created_at) - parseDateScore(a.created_at); + }); + + return rows; +} + +function applyCardFilters() { + const keyword = (document.getElementById('card-search-input')?.value || '').trim().toLowerCase(); + const planFilter = overviewState.planFilter; + + let rows = [...overviewState.cards]; + + if (keyword) { + rows = rows.filter((item) => String(item.email || '').toLowerCase().includes(keyword)); + } + + if (planFilter === 'free') { + rows = rows.filter((item) => normalizePlan(item.plan_type) === 'free'); + } else if (planFilter === 'plus') { + rows = rows.filter((item) => normalizePlan(item.plan_type) === 'plus'); + } else if (planFilter === 'team') { + rows = rows.filter((item) => normalizePlan(item.plan_type) === 'team'); + } else if (planFilter === 'current') { + rows = rows.filter((item) => Boolean(item.current)); + } + + overviewState.filteredCards = sortCards(rows); + renderPlanCards(); + updateSelectionInfo(); +} + +function setViewMode(mode) { + overviewState.viewMode = mode === 'list' ? 'list' : 'grid'; + storage.set(VIEW_MODE_STORAGE_KEY, overviewState.viewMode); + + const listBtn = document.getElementById('view-list-btn'); + const gridBtn = document.getElementById('view-grid-btn'); + const container = document.getElementById('plan-cards-container'); + if (listBtn) listBtn.classList.toggle('active', overviewState.viewMode === 'list'); + if (gridBtn) gridBtn.classList.toggle('active', overviewState.viewMode === 'grid'); + if (container) container.classList.toggle('view-list', overviewState.viewMode === 'list'); +} + +function renderPlanCards() { + const container = document.getElementById('plan-cards-container'); + const empty = document.getElementById('plan-cards-empty'); + if (!container || !empty) return; + + setViewMode(overviewState.viewMode); + + if (!overviewState.filteredCards.length) { + container.innerHTML = ''; + empty.style.display = 'block'; + return; + } + + empty.style.display = 'none'; + container.innerHTML = overviewState.filteredCards.map((account) => { + const accountId = Number(account.id); + const checked = overviewState.selectedCardIds.has(accountId) ? 'checked' : ''; + const codeReviewQuota = account.code_review_quota || null; + const hasCodeReviewQuota = Boolean( + codeReviewQuota && + ( + codeReviewQuota.status === 'ok' || + codeReviewQuota.percentage !== null && codeReviewQuota.percentage !== undefined || + codeReviewQuota.reset_at || + (codeReviewQuota.reset_in_text && codeReviewQuota.reset_in_text !== '-') + ) + ); + const codeReviewHtml = hasCodeReviewQuota + ? renderQuotaItem('Code Review', codeReviewQuota) + : ''; + + return ` +
+
+ +
${escapeHtml(account.email || '-')}
+
+ ${account.current ? '当前' : ''} + ${getPlanText(account.plan_type)} +
+
+ + ${renderQuotaItem('5小时配额', account.hourly_quota)} + ${renderQuotaItem('周配额', account.weekly_quota)} + ${codeReviewHtml} + +
+
+ ${format.date(account.created_at)} +
+ + + +
+
+
+ `; + }).join(''); +} + +async function loadPlanCards(forceRefresh = false) { + try { + const data = await api.get('/accounts/overview/cards'); + overviewState.cards = Array.isArray(data?.accounts) ? data.accounts : []; + syncSelectedCardIds(); + updatePlanFilterOptions(); + applyCardFilters(); + } catch (error) { + overviewState.cards = []; + overviewState.filteredCards = []; + overviewState.selectedCardIds = new Set(); + updatePlanFilterOptions(); + renderPlanCards(); + updateSelectionInfo(); + toast.error(`加载订阅卡片失败: ${error?.message || '未知错误'}`); + } +} + +async function refreshSelectedOrAll(force = true, silent = false) { + const selectedIds = Array.from(overviewState.selectedCardIds); + const visibleIds = overviewState.filteredCards + .map((item) => Number(item.id)) + .filter((id) => Number.isFinite(id)); + const targetIds = selectedIds.length ? selectedIds : visibleIds; + try { + if (!targetIds.length) { + await loadPlanCards(false); + if (!silent) toast.info('当前没有可刷新的卡片'); + return; + } + + await api.post('/accounts/overview/refresh', { + ids: targetIds, + force, + select_all: false, + }); + await loadPlanCards(false); + if (!silent) toast.success(`已刷新 ${targetIds.length} 个账号配额`); + } catch (error) { + if (!silent) toast.error(`刷新失败: ${error.message || '未知错误'}`); + } +} + +function setCardRefreshLoading(button, loading) { + if (!button) return; + button.classList.toggle('is-loading', Boolean(loading)); + button.disabled = Boolean(loading); + button.setAttribute('aria-busy', loading ? 'true' : 'false'); + if (!loading) button.blur(); +} + +async function refreshSingleCard(accountId, button = null) { + if (!Number.isFinite(Number(accountId))) return; + if (button?.classList.contains('is-loading')) return; + setCardRefreshLoading(button, true); + toast.info(`正在刷新账号 #${accountId} 配额...`, 1500); + try { + const result = await api.post('/accounts/overview/refresh', { + ids: [accountId], + force: true, + select_all: false, + }); + + const details = Array.isArray(result?.details) ? result.details : []; + const currentDetail = details.find((item) => Number(item?.id) === Number(accountId)); + if (currentDetail && currentDetail.success === false) { + toast.warning( + `刷新完成但未拿到新配额: ${currentDetail.error || '未知原因'}`, + 4500 + ); + } else { + toast.success(`账号 #${accountId} 配额刷新完成`); + } + await loadPlanCards(false); + } catch (error) { + toast.error(`刷新失败: ${error?.message || '未知错误'}`); + } finally { + setCardRefreshLoading(button, false); + } +} + +async function removeSingleCard(accountId) { + const id = Number(accountId); + if (!Number.isFinite(id)) return; + const ok = await confirm('确认从卡片列表删除该账号吗?(不会删除账号管理数据)', '删除卡片'); + if (!ok) return; + await api.post('/accounts/overview/cards/remove', { + ids: [id], + select_all: false, + }); + overviewState.selectedCardIds.delete(id); + await loadPlanCards(false); + await loadAddableAccounts(); + toast.success('卡片已删除,可在“添加账号”里重新添加'); +} + +async function loadAddableAccounts() { + try { + const data = await api.get('/accounts/overview/cards/selectable'); + overviewState.addableCards = Array.isArray(data?.accounts) ? data.accounts : []; + } catch (error) { + overviewState.addableCards = []; + console.warn('load addable cards failed', error); + } + renderAddableAccounts(); +} + +function renderAddableAccounts() { + const select = document.getElementById('overview-add-existing-select'); + if (!select) return; + const options = overviewState.addableCards.map((item) => { + const plan = getPlanText(item.subscription_type || 'free'); + const tokenTag = item.has_access_token ? '有Token' : '无Token'; + return ``; + }); + select.innerHTML = `${options.join('')}`; +} + +function getSelectedExistingAccount() { + const select = document.getElementById('overview-add-existing-select'); + const id = Number(select?.value || 0); + if (!Number.isFinite(id) || id <= 0) return null; + return overviewState.addableCards.find((item) => Number(item.id) === id) || null; +} + +function fillAddFormFromExistingSelection() { + const selected = getSelectedExistingAccount(); + if (!selected) return; + + setFieldValue('overview-add-email', selected.email || ''); + setFieldValue('overview-add-password', selected.password || ''); + setFieldValue('overview-add-email-service', selected.email_service || 'manual'); + setFieldValue('overview-add-subscription', normalizePlan(selected.subscription_type || 'free')); + setFieldValue('overview-add-client-id', selected.client_id || ''); + setFieldValue('overview-add-account-id', selected.account_id || ''); + setFieldValue('overview-add-workspace-id', selected.workspace_id || ''); + setFieldValue('overview-add-status', selected.status || 'active'); +} + +async function restoreSelectedAddableAccount() { + const select = document.getElementById('overview-add-existing-select'); + const id = Number(select?.value || 0); + if (!Number.isFinite(id) || id <= 0) { + toast.warning('请先选择一个账号'); + return; + } + await api.post(`/accounts/overview/cards/${id}/attach`, {}); + await loadAddableAccounts(); + await loadPlanCards(false); + await loadLegacyOverview(); + toast.success('账号已添加到卡片'); +} + +function extractFilenameFromDisposition(contentDisposition) { + const raw = String(contentDisposition || ''); + const match = raw.match(/filename="?([^"]+)"?/i); + return match ? match[1] : ''; +} + +async function downloadAccountsExportJson(payload, fallbackNamePrefix) { + const resp = await fetch('/api/accounts/export/json', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (!resp.ok) { + let detail = ''; + try { + const errData = await resp.json(); + detail = errData?.detail || ''; + } catch { + detail = ''; + } + throw new Error(detail || `HTTP ${resp.status}`); + } + + const blob = await resp.blob(); + const contentDisposition = resp.headers.get('Content-Disposition') || ''; + const filename = + extractFilenameFromDisposition(contentDisposition) || + `${fallbackNamePrefix}_${Date.now()}.json`; + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} + +function getSelectedIds() { + return Array.from(overviewState.selectedCardIds).filter((id) => Number.isFinite(Number(id))); +} + +function openModalById(id) { + const modal = document.getElementById(id); + if (modal) modal.classList.add('active'); +} + +function closeModalById(id) { + const modal = document.getElementById(id); + if (modal) modal.classList.remove('active'); +} + +function resetAddModalFields() { + setFieldValue('overview-add-email', ''); + setFieldValue('overview-add-password', ''); + setFieldValue('overview-add-email-service', 'manual'); + setFieldValue('overview-add-subscription', ''); + setFieldValue('overview-add-client-id', ''); + setFieldValue('overview-add-account-id', ''); + setFieldValue('overview-add-workspace-id', ''); + setFieldValue('overview-add-status', 'active'); + setFieldValue('overview-add-existing-select', ''); +} + +function setFieldValue(id, value) { + const node = document.getElementById(id); + if (!node) return; + node.value = value ?? ''; +} + +async function submitAddAccount() { + const selectedExisting = getSelectedExistingAccount(); + if (selectedExisting) { + await api.post(`/accounts/overview/cards/${Number(selectedExisting.id)}/attach`, {}); + closeModalById('overview-add-modal'); + resetAddModalFields(); + await loadAddableAccounts(); + await loadPlanCards(false); + await loadLegacyOverview(); + toast.success('已从账号管理直接添加到卡片'); + return; + } + + const email = (document.getElementById('overview-add-email')?.value || '').trim(); + const password = (document.getElementById('overview-add-password')?.value || '').trim(); + const emailService = (document.getElementById('overview-add-email-service')?.value || 'manual').trim() || 'manual'; + const subscriptionType = (document.getElementById('overview-add-subscription')?.value || '').trim(); + const clientId = (document.getElementById('overview-add-client-id')?.value || '').trim(); + const accountId = (document.getElementById('overview-add-account-id')?.value || '').trim(); + const workspaceId = (document.getElementById('overview-add-workspace-id')?.value || '').trim(); + const status = (document.getElementById('overview-add-status')?.value || 'active').trim() || 'active'; + + if (!email || !password) { + toast.warning('邮箱和密码为必填项'); + return; + } + + const payload = { + email, + password, + email_service: emailService, + subscription_type: subscriptionType || null, + client_id: clientId || null, + account_id: accountId || null, + workspace_id: workspaceId || null, + status, + source: 'manual', + }; + await api.post('/accounts', payload); + closeModalById('overview-add-modal'); + resetAddModalFields(); + await loadAddableAccounts(); + await loadPlanCards(false); + await loadLegacyOverview(); + toast.success('账号已添加'); +} + +async function submitImportAccounts() { + const input = (document.getElementById('overview-import-json')?.value || '').trim(); + const overwrite = Boolean(document.getElementById('overview-import-overwrite')?.checked); + if (!input) { + toast.warning('请先粘贴 JSON'); + return; + } + + let parsed; + try { + parsed = JSON.parse(input); + } catch (error) { + toast.error(`JSON 解析失败: ${error.message || '格式错误'}`); + return; + } + const accounts = Array.isArray(parsed) + ? parsed + : (Array.isArray(parsed?.accounts) ? parsed.accounts : []); + if (!accounts.length) { + toast.warning('JSON 必须是非空数组'); + return; + } + + const result = await api.post('/accounts/import', { + accounts, + overwrite, + }); + closeModalById('overview-import-modal'); + toast.success( + `导入完成:新增 ${result?.created || 0},更新 ${result?.updated || 0},跳过 ${result?.skipped || 0},失败 ${result?.failed || 0}` + ); + await loadPlanCards(false); + await loadLegacyOverview(); + await loadAddableAccounts(); +} + +async function removeSelectedAccounts() { + let ids = getSelectedIds(); + if (!ids.length) { + ids = overviewState.filteredCards + .map((item) => Number(item.id)) + .filter((id) => Number.isFinite(id)); + if (!ids.length) { + toast.warning('当前没有可删除的账号'); + return; + } + } + + const ok = await confirm(`确认从卡片列表删除 ${ids.length} 个账号吗?(不会删除账号管理数据)`, '批量删除卡片'); + if (!ok) return; + + const result = await api.post('/accounts/overview/cards/remove', { ids, select_all: false }); + const removedCount = Number(result?.removed_count || 0); + toast.success(`删除完成:${removedCount} 个卡片`); + overviewState.selectedCardIds.clear(); + await loadAddableAccounts(); + await loadPlanCards(false); +} + +async function exportAllVisibleAccounts() { + const ids = overviewState.filteredCards + .map((item) => Number(item.id)) + .filter((id) => Number.isFinite(id)); + if (!ids.length) { + toast.warning('当前没有可导出的账号'); + return; + } + await downloadAccountsExportJson({ ids, select_all: false }, 'overview_all_accounts'); + toast.success(`已导出 ${ids.length} 个账号`); +} + +async function exportSingleCard(accountId) { + const id = Number(accountId); + if (!Number.isFinite(id)) return; + await downloadAccountsExportJson({ ids: [id], select_all: false }, `overview_account_${id}`); + toast.success(`账号 #${id} 配置已导出`); +} + +function updateCardNextRefreshText() { + const el = document.getElementById('card-next-refresh'); + if (!el) return; + if (!overviewState.cardNextRefreshAt) { + el.textContent = '下次刷新 --'; + return; + } + const remainSec = Math.max(0, Math.floor((overviewState.cardNextRefreshAt - Date.now()) / 1000)); + const min = Math.floor(remainSec / 60); + const sec = remainSec % 60; + el.textContent = `下次刷新 ${min}:${String(sec).padStart(2, '0')}`; +} + +function restartCardAutoRefresh() { + if (overviewState.cardRefreshTimer) { + clearInterval(overviewState.cardRefreshTimer); + overviewState.cardRefreshTimer = null; + } + if (overviewState.cardCountdownTimer) { + clearInterval(overviewState.cardCountdownTimer); + overviewState.cardCountdownTimer = null; + } + + const intervalMs = overviewState.cardRefreshIntervalMin * 60 * 1000; + overviewState.cardNextRefreshAt = Date.now() + intervalMs; + updateCardNextRefreshText(); + + overviewState.cardRefreshTimer = setInterval(async () => { + await refreshSelectedOrAll(true, true); + overviewState.cardNextRefreshAt = Date.now() + intervalMs; + updateCardNextRefreshText(); + }, intervalMs); + + let ticks = 0; + overviewState.cardCountdownTimer = setInterval(() => { + updateCardNextRefreshText(); + ticks += 1; + if (ticks % 30 === 0 && overviewState.filteredCards.length) { + renderPlanCards(); + } + }, 1000); +} + +function bindEvents() { + const legacyRefreshBtn = document.getElementById('legacy-refresh-btn'); + if (legacyRefreshBtn) { + legacyRefreshBtn.addEventListener('click', async () => { + await loadLegacyOverview(); + await loadPlanCards(true); + }); + } + + const cardSearchInput = document.getElementById('card-search-input'); + if (cardSearchInput) { + cardSearchInput.addEventListener('input', debounce(applyCardFilters, 240)); + } + + const planFilter = document.getElementById('card-plan-filter'); + if (planFilter) { + planFilter.addEventListener('change', () => { + overviewState.planFilter = planFilter.value || 'all'; + applyCardFilters(); + }); + } + + const sortMode = document.getElementById('card-sort-mode'); + if (sortMode) { + sortMode.addEventListener('change', () => { + overviewState.sortMode = sortMode.value || 'created_desc'; + applyCardFilters(); + }); + } + + const viewListBtn = document.getElementById('view-list-btn'); + const viewGridBtn = document.getElementById('view-grid-btn'); + if (viewListBtn) { + viewListBtn.addEventListener('click', () => { + setViewMode('list'); + renderPlanCards(); + }); + } + if (viewGridBtn) { + viewGridBtn.addEventListener('click', () => { + setViewMode('grid'); + renderPlanCards(); + }); + } + + const cardRefreshSelect = document.getElementById('card-refresh-interval'); + if (cardRefreshSelect) { + cardRefreshSelect.value = String(overviewState.cardRefreshIntervalMin); + cardRefreshSelect.addEventListener('change', () => { + const value = Number(cardRefreshSelect.value || 7); + overviewState.cardRefreshIntervalMin = [5, 7, 10].includes(value) ? value : 7; + restartCardAutoRefresh(); + }); + } + + const addBtn = document.getElementById('card-add-btn'); + if (addBtn) { + addBtn.addEventListener('click', async () => { + resetAddModalFields(); + await loadAddableAccounts(); + openModalById('overview-add-modal'); + }); + } + + const toolbarRefreshBtn = document.getElementById('card-refresh-btn'); + if (toolbarRefreshBtn) { + toolbarRefreshBtn.addEventListener('click', async () => { + try { + await refreshSelectedOrAll(true, true); + await Promise.all([ + loadLegacyOverview(), + loadAddableAccounts(), + ]); + toast.success('账号总览已刷新'); + } catch (error) { + toast.error(`刷新失败: ${error?.message || '未知错误'}`); + } + }); + } + + const importBtn = document.getElementById('card-import-btn'); + if (importBtn) { + importBtn.addEventListener('click', () => openModalById('overview-import-modal')); + } + + const deleteBtn = document.getElementById('card-delete-btn'); + if (deleteBtn) { + deleteBtn.addEventListener('click', async () => { + try { + await removeSelectedAccounts(); + } catch (error) { + toast.error(`删除失败: ${error?.message || '未知错误'}`); + } + }); + } + + const exportAllBtn = document.getElementById('card-export-all-btn'); + if (exportAllBtn) { + exportAllBtn.addEventListener('click', async () => { + try { + await exportAllVisibleAccounts(); + } catch (error) { + toast.error(`导出失败: ${error?.message || '未知错误'}`); + } + }); + } + + const addModalIds = ['overview-add-modal', 'overview-import-modal']; + addModalIds.forEach((modalId) => { + const modal = document.getElementById(modalId); + if (!modal) return; + modal.addEventListener('click', (event) => { + if (event.target === modal) { + closeModalById(modalId); + } + }); + }); + + document.getElementById('overview-add-close')?.addEventListener('click', () => closeModalById('overview-add-modal')); + document.getElementById('overview-add-cancel')?.addEventListener('click', () => closeModalById('overview-add-modal')); + document.getElementById('overview-add-existing-refresh')?.addEventListener('click', async () => { + try { + await loadAddableAccounts(); + toast.success('已刷新账号管理列表'); + } catch (error) { + toast.error(`刷新失败: ${error?.message || '未知错误'}`); + } + }); + document.getElementById('overview-add-existing-select')?.addEventListener('change', () => { + fillAddFormFromExistingSelection(); + }); + document.getElementById('overview-add-existing-submit')?.addEventListener('click', async () => { + try { + await restoreSelectedAddableAccount(); + } catch (error) { + toast.error(`恢复失败: ${error?.message || '未知错误'}`); + } + }); + document.getElementById('overview-add-submit')?.addEventListener('click', async () => { + try { + await submitAddAccount(); + } catch (error) { + toast.error(`添加失败: ${error?.message || '未知错误'}`); + } + }); + + document.getElementById('overview-import-close')?.addEventListener('click', () => closeModalById('overview-import-modal')); + document.getElementById('overview-import-cancel')?.addEventListener('click', () => closeModalById('overview-import-modal')); + document.getElementById('overview-import-submit')?.addEventListener('click', async () => { + try { + await submitImportAccounts(); + } catch (error) { + toast.error(`导入失败: ${error?.message || '未知错误'}`); + } + }); + + const cardContainer = document.getElementById('plan-cards-container'); + if (cardContainer) { + cardContainer.addEventListener('change', (event) => { + const checkbox = event.target.closest('input[data-role="select-card"]'); + if (!checkbox) return; + const id = Number(checkbox.dataset.id); + if (!Number.isFinite(id)) return; + if (checkbox.checked) { + overviewState.selectedCardIds.add(id); + } else { + overviewState.selectedCardIds.delete(id); + } + updateSelectionInfo(); + }); + + cardContainer.addEventListener('click', async (event) => { + const button = event.target.closest('button[data-action]'); + if (!button) return; + const action = button.dataset.action; + const accountId = Number(button.dataset.id); + if (!Number.isFinite(accountId)) return; + + if (action === 'refresh') { + await refreshSingleCard(accountId, button); + return; + } + if (action === 'export') { + await exportSingleCard(accountId); + return; + } + if (action === 'remove') { + await removeSingleCard(accountId); + } + }); + } +} + +document.addEventListener('DOMContentLoaded', async () => { + const sortMode = document.getElementById('card-sort-mode'); + if (sortMode) sortMode.value = overviewState.sortMode; + setViewMode(overviewState.viewMode); + bindEvents(); + const initResults = await Promise.allSettled([ + loadLegacyOverview(), + loadPlanCards(false), + loadAddableAccounts(), + ]); + initResults.forEach((item, index) => { + if (item.status === 'rejected') { + const target = index === 0 ? '总览统计' : '卡片列表'; + toast.warning(`${target}初始化失败,已降级显示`); + } + }); + restartCardAutoRefresh(); +}); diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 00000000..5a00e755 --- /dev/null +++ b/static/js/app.js @@ -0,0 +1,1598 @@ +/** + * 注册页面 JavaScript + * 使用 utils.js 中的工具库 + */ + +// 状态 +let currentTask = null; +let currentBatch = null; +let logPollingInterval = null; +let batchPollingInterval = null; +let accountsPollingInterval = null; +let todayStatsPollingInterval = null; +let todayStatsResetInterval = 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'), + // 今日统计 + todayStatsTotal: document.getElementById('today-stats-total'), + todayStatsSuccess: document.getElementById('today-stats-success'), + todayStatsFailed: document.getElementById('today-stats-failed'), + todayStatsRate: document.getElementById('today-stats-rate'), + todayStatsReset: document.getElementById('today-stats-reset'), + // 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'), + 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(); + loadTodayStats(true); + startTodayStatsPolling(); + startTodayStatsResetTicker(); + initVisibilityReconnect(); + restoreActiveTask(); + initAutoUploadOptions(); +}); + +// 初始化注册后自动操作选项(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), + ]); +} + +// 通用:构建自定义多选下拉组件并处理联动 +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) : [], + 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 (data.email) { + elements.taskEmail.textContent = data.email; + } + if (data.email_service) { + elements.taskService.textContent = getServiceTypeText(data.email_service); + } + + // 检查是否完成 + 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 renderTodayStats(total, success, failed, rate) { + if (elements.todayStatsTotal) { + elements.todayStatsTotal.textContent = String(Math.max(0, total)); + } + if (elements.todayStatsSuccess) { + elements.todayStatsSuccess.textContent = String(Math.max(0, success)); + } + if (elements.todayStatsFailed) { + elements.todayStatsFailed.textContent = String(Math.max(0, failed)); + } + if (elements.todayStatsRate) { + const safeRate = Math.max(0, rate); + const rateCard = elements.todayStatsRate.closest('.today-stat-rate'); + elements.todayStatsRate.textContent = `${safeRate.toFixed(1)}%`; + elements.todayStatsRate.classList.remove('rate-high', 'rate-mid', 'rate-low'); + if (rateCard) { + rateCard.classList.remove('rate-high', 'rate-mid', 'rate-low'); + } + if (safeRate >= 70) { + elements.todayStatsRate.classList.add('rate-high'); + if (rateCard) rateCard.classList.add('rate-high'); + } else if (safeRate < 40) { + elements.todayStatsRate.classList.add('rate-low'); + if (rateCard) rateCard.classList.add('rate-low'); + } else { + elements.todayStatsRate.classList.add('rate-mid'); + if (rateCard) rateCard.classList.add('rate-mid'); + } + } +} + +async function loadTodayStats(silent = true) { + try { + const data = await api.get('/registration/stats'); + const byStatus = data?.by_status || {}; + const total = Number(data?.today_total ?? data?.today_count ?? 0); + const success = Number(data?.today_success ?? byStatus.completed ?? 0); + const failed = Number(data?.today_failed ?? byStatus.failed ?? 0); + const fallbackRate = total > 0 ? (success / total) * 100 : 0; + const rate = Number(data?.today_success_rate ?? fallbackRate); + renderTodayStats(total, success, failed, Number.isFinite(rate) ? rate : 0); + } catch (error) { + console.error('加载今日统计失败:', error); + if (!silent) { + toast.error('加载今日统计失败'); + } + } +} + +function updateTodayStatsResetText() { + if (!elements.todayStatsReset) return; + const now = new Date(); + const next = new Date(); + next.setHours(24, 0, 0, 0); + const remain = Math.max(0, next.getTime() - now.getTime()); + const hours = Math.floor(remain / 3600000); + const minutes = Math.floor((remain % 3600000) / 60000); + elements.todayStatsReset.textContent = `重置剩余 ${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`; +} + +function startTodayStatsResetTicker() { + updateTodayStatsResetText(); + if (todayStatsResetInterval) { + clearInterval(todayStatsResetInterval); + } + todayStatsResetInterval = setInterval(updateTodayStatsResetText, 60000); +} + +function startTodayStatsPolling() { + if (todayStatsPollingInterval) { + clearInterval(todayStatsPollingInterval); + } + todayStatsPollingInterval = setInterval(() => { + loadTodayStats(true); + }, 60000); +} + +// 添加日志 +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) : [], + 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 00000000..463b1a80 --- /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/logs.js b/static/js/logs.js new file mode 100644 index 00000000..fca660f5 --- /dev/null +++ b/static/js/logs.js @@ -0,0 +1,277 @@ +/** + * 后台日志页面 + */ + +const logsState = { + page: 1, + pageSize: 100, + total: 0, + autoRefreshSeconds: 10, + timer: null, + filters: { + level: "", + logger_name: "", + keyword: "", + since_minutes: "", + }, +}; + +const el = { + filterLevel: document.getElementById("filter-level"), + filterLogger: document.getElementById("filter-logger"), + filterKeyword: document.getElementById("filter-keyword"), + filterSinceMinutes: document.getElementById("filter-since-minutes"), + autoRefreshSeconds: document.getElementById("auto-refresh-seconds"), + pageSizeSelect: document.getElementById("page-size-select"), + refreshBtn: document.getElementById("refresh-logs-btn"), + clearFiltersBtn: document.getElementById("clear-filters-btn"), + cleanupBtn: document.getElementById("cleanup-logs-btn"), + clearLogsBtn: document.getElementById("clear-logs-btn"), + cleanupRetentionDays: document.getElementById("cleanup-retention-days"), + cleanupMaxRows: document.getElementById("cleanup-max-rows"), + logConsole: document.getElementById("log-console"), + summary: document.getElementById("logs-summary"), + pageInfo: document.getElementById("page-info"), + prevPageBtn: document.getElementById("prev-page-btn"), + nextPageBtn: document.getElementById("next-page-btn"), + latestLogTime: document.getElementById("latest-log-time"), + statTotal: document.getElementById("stat-total"), + statInfo: document.getElementById("stat-info"), + statWarning: document.getElementById("stat-warning"), + statError: document.getElementById("stat-error"), + statCritical: document.getElementById("stat-critical"), +}; + +function escapeHtml(value) { + const div = document.createElement("div"); + div.textContent = String(value ?? ""); + return div.innerHTML; +} + +function getLevelClass(level) { + const text = String(level || "").toUpperCase(); + return `log-level ${text}`; +} + +function setLogsSummary() { + const from = logsState.total === 0 ? 0 : ((logsState.page - 1) * logsState.pageSize + 1); + const to = Math.min(logsState.total, logsState.page * logsState.pageSize); + el.summary.textContent = `共 ${logsState.total} 条,当前 ${from}-${to}`; + el.pageInfo.textContent = `第 ${logsState.page} 页`; + el.prevPageBtn.disabled = logsState.page <= 1; + el.nextPageBtn.disabled = to >= logsState.total; +} + +function renderLogs(rows) { + if (!Array.isArray(rows) || rows.length === 0) { + el.logConsole.innerHTML = ` +
+ - + INFO + system + 暂无日志 +
+ `; + return; + } + + el.logConsole.innerHTML = rows.map((row) => { + const level = String(row.level || "INFO").toUpperCase(); + const msg = row.exception ? `${row.message || ""}\n${row.exception}` : (row.message || ""); + return ` +
+ ${escapeHtml(format.date(row.created_at))} + ${escapeHtml(level)} + ${escapeHtml(row.logger || "-")} + ${escapeHtml(msg || "-")} +
+ `; + }).join(""); +} + +function collectFilters() { + logsState.filters.level = String(el.filterLevel.value || "").trim().toUpperCase(); + logsState.filters.logger_name = String(el.filterLogger.value || "").trim(); + logsState.filters.keyword = String(el.filterKeyword.value || "").trim(); + logsState.filters.since_minutes = String(el.filterSinceMinutes.value || "").trim(); +} + +function buildLogParams() { + collectFilters(); + const params = new URLSearchParams({ + page: String(logsState.page), + page_size: String(logsState.pageSize), + }); + if (logsState.filters.level) params.set("level", logsState.filters.level); + if (logsState.filters.logger_name) params.set("logger_name", logsState.filters.logger_name); + if (logsState.filters.keyword) params.set("keyword", logsState.filters.keyword); + if (logsState.filters.since_minutes) params.set("since_minutes", logsState.filters.since_minutes); + return params.toString(); +} + +async function loadLogs(silent = false) { + if (!silent) { + loading.show(el.refreshBtn, "加载中..."); + } + try { + const query = buildLogParams(); + const data = await api.get(`/logs?${query}`); + logsState.total = Number(data.total || 0); + renderLogs(data.logs || []); + setLogsSummary(); + } catch (error) { + toast.error(`加载日志失败: ${error?.message || error}`); + } finally { + if (!silent) { + loading.hide(el.refreshBtn); + } + } +} + +async function loadStats(silent = false) { + try { + const data = await api.get("/logs/stats"); + const levels = data.levels || {}; + el.statTotal.textContent = format.number(data.total || 0); + el.statInfo.textContent = format.number(levels.INFO || 0); + el.statWarning.textContent = format.number(levels.WARNING || 0); + el.statError.textContent = format.number(levels.ERROR || 0); + el.statCritical.textContent = format.number(levels.CRITICAL || 0); + el.latestLogTime.textContent = `最新日志: ${data.latest_at ? format.date(data.latest_at) : "-"}`; + } catch (error) { + if (!silent) { + toast.error(`加载统计失败: ${error?.message || error}`); + } + } +} + +function resetFilters() { + el.filterLevel.value = ""; + el.filterLogger.value = ""; + el.filterKeyword.value = ""; + el.filterSinceMinutes.value = ""; + logsState.page = 1; + loadLogs(); +} + +async function cleanupLogs() { + const retentionDays = Number(el.cleanupRetentionDays.value || 30); + const maxRows = Number(el.cleanupMaxRows.value || 50000); + const ok = await confirm( + `确认清理日志吗?\n保留天数=${retentionDays},最大条数=${maxRows}`, + "清理后台日志" + ); + if (!ok) return; + + try { + loading.show(el.cleanupBtn, "清理中..."); + const data = await api.post("/logs/cleanup", { + retention_days: retentionDays, + max_rows: maxRows, + }); + toast.success(`清理完成:删除 ${data.deleted_total || 0} 条,剩余 ${data.remaining || 0} 条`); + logsState.page = 1; + await Promise.all([loadLogs(true), loadStats(true)]); + setLogsSummary(); + } catch (error) { + toast.error(`清理失败: ${error?.message || error}`); + } finally { + loading.hide(el.cleanupBtn); + } +} + +async function clearAllLogs() { + const ok = await confirm("确认清空全部后台日志吗?该操作不可恢复。", "清空后台日志"); + if (!ok) return; + + try { + loading.show(el.clearLogsBtn, "清空中..."); + const data = await api.delete("/logs?confirm=true"); + toast.success(`清空完成:删除 ${data.deleted_total || 0} 条`); + logsState.page = 1; + await Promise.all([loadLogs(true), loadStats(true)]); + setLogsSummary(); + } catch (error) { + toast.error(`清空失败: ${error?.message || error}`); + } finally { + loading.hide(el.clearLogsBtn); + } +} + +function restartAutoRefresh() { + if (logsState.timer) { + clearInterval(logsState.timer); + logsState.timer = null; + } + if (!logsState.autoRefreshSeconds) return; + logsState.timer = setInterval(() => { + loadLogs(true); + loadStats(true); + }, logsState.autoRefreshSeconds * 1000); +} + +function bindEvents() { + el.refreshBtn.addEventListener("click", () => { + loadLogs(); + loadStats(); + }); + el.clearFiltersBtn.addEventListener("click", resetFilters); + el.cleanupBtn.addEventListener("click", cleanupLogs); + el.clearLogsBtn?.addEventListener("click", clearAllLogs); + + el.pageSizeSelect.addEventListener("change", () => { + logsState.pageSize = Number(el.pageSizeSelect.value || 100); + logsState.page = 1; + loadLogs(); + }); + el.prevPageBtn.addEventListener("click", () => { + if (logsState.page <= 1) return; + logsState.page -= 1; + loadLogs(); + }); + el.nextPageBtn.addEventListener("click", () => { + const maxPage = Math.max(1, Math.ceil(logsState.total / logsState.pageSize)); + if (logsState.page >= maxPage) return; + logsState.page += 1; + loadLogs(); + }); + + [ + el.filterLevel, + el.filterLogger, + el.filterKeyword, + el.filterSinceMinutes, + ].forEach((node) => { + node.addEventListener("change", () => { + logsState.page = 1; + loadLogs(); + }); + }); + el.filterLogger.addEventListener("input", debounce(() => { + logsState.page = 1; + loadLogs(true); + }, 300)); + el.filterKeyword.addEventListener("input", debounce(() => { + logsState.page = 1; + loadLogs(true); + }, 300)); + + el.autoRefreshSeconds.addEventListener("change", () => { + logsState.autoRefreshSeconds = Number(el.autoRefreshSeconds.value || 0); + restartAutoRefresh(); + }); +} + +document.addEventListener("DOMContentLoaded", async () => { + bindEvents(); + await Promise.all([loadLogs(), loadStats()]); + logsState.autoRefreshSeconds = Number(el.autoRefreshSeconds.value || 0); + restartAutoRefresh(); + + window.addEventListener("beforeunload", () => { + if (logsState.timer) { + clearInterval(logsState.timer); + logsState.timer = null; + } + }); +}); diff --git a/static/js/payment.js b/static/js/payment.js new file mode 100644 index 00000000..3ff0ca4c --- /dev/null +++ b/static/js/payment.js @@ -0,0 +1,1856 @@ +/** + * 支付页面 JavaScript + * 支付页面:半自动 + 第三方自动绑卡 + 全自动绑卡任务管理 + 用户完成后自动验订阅 + */ + +const COUNTRY_CURRENCY_MAP = { + US: "USD", + GB: "GBP", + CA: "CAD", + AU: "AUD", + SG: "SGD", + HK: "HKD", + JP: "JPY", + TR: "TRY", + IN: "INR", + BR: "BRL", + MX: "MXN", + DE: "EUR", + FR: "EUR", + IT: "EUR", + ES: "EUR", + EU: "EUR", +}; + +const BILLING_STORAGE_KEY = "payment.billing_profile_non_sensitive"; +const BILLING_TEMPLATE_STORAGE_KEY = "payment.billing_templates_v1"; +const THIRD_PARTY_BIND_URL_STORAGE_KEY = "payment.third_party_bind_api_url"; +const BIND_MODE_STORAGE_KEY = "payment.bind_mode"; +const THIRD_PARTY_BIND_DEFAULT_URL = "https://twilight-river-f148.482091502.workers.dev/"; +const BILLING_TEMPLATE_MAX = 200; +const BILLING_COUNTRY_CURRENCY_MAP = { + US: "USD", + GB: "GBP", + CA: "CAD", + AU: "AUD", + SG: "SGD", + HK: "HKD", + JP: "JPY", + DE: "EUR", + FR: "EUR", + IT: "EUR", + ES: "EUR", +}; + +const COUNTRY_ALIAS_MAP = { + us: { code: "US", currency: "USD" }, + usa: { code: "US", currency: "USD" }, + "united states": { code: "US", currency: "USD" }, + 美国: { code: "US", currency: "USD" }, + uk: { code: "GB", currency: "GBP" }, + gb: { code: "GB", currency: "GBP" }, + england: { code: "GB", currency: "GBP" }, + "united kingdom": { code: "GB", currency: "GBP" }, + 英国: { code: "GB", currency: "GBP" }, + ca: { code: "CA", currency: "CAD" }, + canada: { code: "CA", currency: "CAD" }, + 加拿大: { code: "CA", currency: "CAD" }, + au: { code: "AU", currency: "AUD" }, + australia: { code: "AU", currency: "AUD" }, + 澳大利亚: { code: "AU", currency: "AUD" }, + sg: { code: "SG", currency: "SGD" }, + singapore: { code: "SG", currency: "SGD" }, + 新加坡: { code: "SG", currency: "SGD" }, + hk: { code: "HK", currency: "HKD" }, + "hong kong": { code: "HK", currency: "HKD" }, + 香港: { code: "HK", currency: "HKD" }, + jp: { code: "JP", currency: "JPY" }, + japan: { code: "JP", currency: "JPY" }, + 日本: { code: "JP", currency: "JPY" }, + de: { code: "DE", currency: "EUR" }, + germany: { code: "DE", currency: "EUR" }, + 德国: { code: "DE", currency: "EUR" }, + fr: { code: "FR", currency: "EUR" }, + france: { code: "FR", currency: "EUR" }, + 法国: { code: "FR", currency: "EUR" }, + it: { code: "IT", currency: "EUR" }, + italy: { code: "IT", currency: "EUR" }, + 意大利: { code: "IT", currency: "EUR" }, + es: { code: "ES", currency: "EUR" }, + spain: { code: "ES", currency: "EUR" }, + 西班牙: { code: "ES", currency: "EUR" }, +}; + +let selectedPlan = "plus"; +let generatedLink = ""; +let isGeneratingCheckoutLink = false; +let paymentAccounts = []; + +const bindTaskState = { + page: 1, + pageSize: 50, + status: "", + search: "", +}; +let bindTaskAutoRefreshTimer = null; + +let billingBatchProfiles = []; + +function formatErrorMessage(error) { + if (!error) return "未知错误"; + if (typeof error === "string") return error; + + // ApiClient 会把后端错误挂在 error.data 上 + const detail = error?.data?.detail ?? error?.message; + if (typeof detail === "string" && detail && detail !== "[object Object]") { + return detail; + } + try { + return JSON.stringify(detail || error); + } catch { + return String(detail || error); + } +} + +function escapeHtml(value) { + const div = document.createElement("div"); + div.textContent = String(value ?? ""); + return div.innerHTML; +} + +function yesNo(value) { + return value ? "是" : "否"; +} + +function showSessionDiagnosticPanel(text) { + const panel = document.getElementById("session-diagnostic-panel"); + const pre = document.getElementById("session-diagnostic-text"); + if (!panel || !pre) return; + pre.textContent = String(text || ""); + panel.classList.add("show"); +} + +function clearSessionDiagnosticPanel() { + const panel = document.getElementById("session-diagnostic-panel"); + const pre = document.getElementById("session-diagnostic-text"); + if (!panel || !pre) return; + pre.textContent = ""; + panel.classList.remove("show"); +} + +function formatSessionDiagnosticPayload(payload) { + if (!payload || typeof payload !== "object") { + return "诊断结果为空"; + } + const token = payload.token_state || {}; + const cookie = payload.cookie_state || {}; + const bootstrap = payload.bootstrap_capability || {}; + const probe = payload.probe || null; + const notes = Array.isArray(payload.notes) ? payload.notes : []; + + const lines = [ + `账号: ${payload.email || "-"} (ID=${payload.account_id || "-"})`, + `Access Token: ${yesNo(token.has_access_token)} | len=${token.access_token_len || 0} | ${token.access_token_preview || "-"}`, + `Refresh Token: ${yesNo(token.has_refresh_token)} | len=${token.refresh_token_len || 0}`, + `Session(DB): ${yesNo(token.has_session_token_db)} | len=${token.session_token_db_len || 0} | ${token.session_token_db_preview || "-"}`, + `Session(Cookie): ${yesNo(token.has_session_token_cookie)} | len=${token.session_token_cookie_len || 0} | ${token.session_token_cookie_preview || "-"}`, + `Session(Resolved): len=${token.resolved_session_token_len || 0} | ${token.resolved_session_token_preview || "-"}`, + `Cookies: ${yesNo(cookie.has_cookies)} | len=${cookie.cookies_len || 0}`, + `oai-did: ${yesNo(cookie.has_oai_did)} | ${cookie.resolved_oai_did || "-"}`, + `Session 分片: count=${cookie.session_chunk_count || 0} | [${(cookie.session_chunk_indices || []).join(", ")}]`, + `自动补会话能力: ${yesNo(bootstrap.can_login_bootstrap)} | has_password=${yesNo(bootstrap.has_password)} | email_service=${bootstrap.email_service_type || "-"}`, + ]; + + if (probe) { + lines.push( + `实时探测: ok=${yesNo(probe.ok)} | http=${probe.http_status ?? "-"} | session=${yesNo(probe.session_token_found)} | session_json_access=${yesNo(probe.access_token_in_session_json)}` + ); + if (probe.session_token_preview) { + lines.push(`探测 session 预览: ${probe.session_token_preview}`); + } + if (probe.access_token_preview) { + lines.push(`探测 access 预览: ${probe.access_token_preview}`); + } + if (probe.error) { + lines.push(`探测错误: ${probe.error}`); + } + } + + if (notes.length) { + lines.push("诊断备注:"); + notes.forEach((n) => lines.push(`- ${n}`)); + } + if (payload.recommendation) { + lines.push(`建议: ${payload.recommendation}`); + } + if (payload.checked_at) { + lines.push(`检查时间: ${payload.checked_at}`); + } + return lines.join("\n"); +} + +async function runSessionDiagnostic() { + const accountId = Number(document.getElementById("account-select")?.value || 0); + if (!accountId) { + toast.warning("请先选择账号"); + return; + } + setButtonLoading("session-diagnostic-btn", "诊断中...", true); + showSessionDiagnosticPanel("正在诊断会话上下文,请稍候..."); + try { + const data = await api.get(`/payment/accounts/${accountId}/session-diagnostic?probe=1`); + const diag = data?.diagnostic || {}; + showSessionDiagnosticPanel(formatSessionDiagnosticPayload(diag)); + toast.success("会话诊断完成"); + } catch (error) { + const message = formatErrorMessage(error); + showSessionDiagnosticPanel(`会话诊断失败: ${message}`); + toast.error(`会话诊断失败: ${message}`); + } finally { + setButtonLoading("session-diagnostic-btn", "诊断中...", false); + } +} + +async function runSessionBootstrap() { + const accountId = Number(document.getElementById("account-select")?.value || 0); + if (!accountId) { + toast.warning("请先选择账号"); + return; + } + setButtonLoading("session-bootstrap-btn", "补全中...", true); + showSessionDiagnosticPanel("正在执行会话补全,请稍候(可能需要等待邮箱验证码)..."); + try { + const data = await api.post(`/payment/accounts/${accountId}/session-bootstrap`, {}); + if (data?.success) { + toast.success(`会话补全成功(len=${data?.session_token_len || 0})`); + } else { + toast.warning(data?.message || "会话补全未命中"); + } + await runSessionDiagnostic(); + } catch (error) { + const message = formatErrorMessage(error); + showSessionDiagnosticPanel(`会话补全失败: ${message}`); + toast.error(`会话补全失败: ${message}`); + } finally { + setButtonLoading("session-bootstrap-btn", "补全中...", false); + } +} + +async function saveManualSessionToken() { + const accountId = Number(document.getElementById("account-select")?.value || 0); + if (!accountId) { + toast.warning("请先选择账号"); + return; + } + const sessionToken = String(document.getElementById("manual-session-token-input")?.value || "").trim(); + if (!sessionToken) { + toast.warning("请先粘贴 session_token"); + return; + } + + setButtonLoading("save-session-token-btn", "保存中...", true); + try { + const data = await api.post(`/payment/accounts/${accountId}/session-token`, { + session_token: sessionToken, + merge_cookie: true, + }); + if (data?.success) { + toast.success(`Session Token 已保存(len=${data?.session_token_len || 0})`); + await runSessionDiagnostic(); + return; + } + toast.warning(data?.message || "保存完成,但未返回 success"); + } catch (error) { + toast.error(`保存 Session Token 失败: ${formatErrorMessage(error)}`); + } finally { + setButtonLoading("save-session-token-btn", "保存中...", false); + } +} + +function getInputValue(id) { + return (document.getElementById(id)?.value || "").trim(); +} + +function setInputValue(id, value) { + const el = document.getElementById(id); + if (!el) return; + el.value = value ?? ""; +} + +function maskCardNumber(cardNumber) { + const digits = String(cardNumber || "").replace(/\D/g, ""); + if (!digits) return "-"; + if (digits.length <= 8) return `${digits.slice(0, 2)}****${digits.slice(-2)}`; + return `${digits.slice(0, 4)}****${digits.slice(-4)}`; +} + +function resolveCountryAlias(raw) { + const key = String(raw || "").trim(); + if (!key) return null; + const normalized = key.toLowerCase(); + if (COUNTRY_ALIAS_MAP[normalized]) { + return COUNTRY_ALIAS_MAP[normalized]; + } + const upper = key.toUpperCase(); + if (BILLING_COUNTRY_CURRENCY_MAP[upper]) { + return { + code: upper, + currency: BILLING_COUNTRY_CURRENCY_MAP[upper], + }; + } + return null; +} + +function normalizeMonth(value) { + const digits = String(value || "").replace(/\D/g, ""); + if (!digits) return ""; + return digits.slice(0, 2).padStart(2, "0"); +} + +function normalizeYear(value) { + const digits = String(value || "").replace(/\D/g, ""); + if (!digits) return ""; + if (digits.length === 2) return `20${digits}`; + return digits.slice(0, 4); +} + +function formatExpiryInput(month, year) { + const mm = normalizeMonth(month); + const rawYear = String(year || "").replace(/\D/g, ""); + const yy = rawYear ? rawYear.slice(-2) : ""; + if (!mm && !yy) return ""; + return yy ? `${mm}/${yy}` : mm; +} + +function parseExpiryInput(value) { + const raw = String(value || "").trim(); + if (!raw) { + return { exp_month: "", exp_year: "" }; + } + + const slashMatch = raw.match(/^(\d{1,2})\s*\/\s*(\d{1,4})$/); + if (slashMatch) { + return { + exp_month: normalizeMonth(slashMatch[1]), + exp_year: normalizeYear(slashMatch[2]), + }; + } + + const digits = raw.replace(/\D/g, ""); + if (!digits) { + return { exp_month: "", exp_year: "" }; + } + if (digits.length <= 2) { + return { exp_month: normalizeMonth(digits), exp_year: "" }; + } + return { + exp_month: normalizeMonth(digits.slice(0, 2)), + exp_year: normalizeYear(digits.slice(2)), + }; +} + +function normalizeExpiryInputForTyping(value) { + const digits = String(value || "").replace(/\D/g, "").slice(0, 6); + if (!digits) return ""; + if (digits.length <= 2) return digits; + return `${digits.slice(0, 2)}/${digits.slice(2)}`; +} + +function parseCardText(rawText) { + const text = String(rawText || "").trim(); + if (!text) return {}; + + const lines = text + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const kv = {}; + for (const line of lines) { + const match = line.match(/^(.+?)\s*[::]\s*(.+)$/); + if (match) { + kv[match[1].trim().toLowerCase()] = match[2].trim(); + } + } + + const result = {}; + + // 卡号 + const cardKeyCandidates = [ + "卡号", + "card number", + "card", + "card_number", + ]; + for (const key of cardKeyCandidates) { + if (!kv[key]) continue; + const digits = kv[key].replace(/\D/g, ""); + if (digits.length >= 13 && digits.length <= 19) { + result.card_number = digits; + break; + } + } + + if (!result.card_number) { + for (const line of lines) { + const m = line.replace(/-/g, " ").match(/^(\d{13,19})\s+(0[1-9]|1[0-2])\s+(\d{2,4})\s+(\d{3,4})$/); + if (m) { + result.card_number = m[1]; + result.exp_month = normalizeMonth(m[2]); + result.exp_year = normalizeYear(m[3]); + result.cvv = m[4]; + break; + } + } + } + + if (!result.card_number) { + for (const line of lines) { + const digits = line.replace(/\D/g, ""); + if (digits.length >= 13 && digits.length <= 19) { + result.card_number = digits; + break; + } + } + } + + // 有效期 + const expiryKeyCandidates = ["有效期", "exp", "expiry", "expiration", "exp_date"]; + for (const key of expiryKeyCandidates) { + if (!kv[key]) continue; + const value = kv[key]; + let m = value.match(/(0[1-9]|1[0-2])\s*\/\s*(\d{2,4})/); + if (m) { + result.exp_month = normalizeMonth(m[1]); + result.exp_year = normalizeYear(m[2]); + break; + } + m = value.match(/^(0[1-9]|1[0-2])(\d{2,4})$/); + if (m) { + result.exp_month = normalizeMonth(m[1]); + result.exp_year = normalizeYear(m[2]); + break; + } + } + if (!result.exp_month || !result.exp_year) { + for (const line of lines) { + const m = line.match(/\b(0[1-9]|1[0-2])\s*\/\s*(\d{2,4})\b/); + if (m) { + result.exp_month = normalizeMonth(m[1]); + result.exp_year = normalizeYear(m[2]); + break; + } + } + } + + // CVV + const cvvKeyCandidates = ["cvv", "cvc", "安全码"]; + for (const key of cvvKeyCandidates) { + if (!kv[key]) continue; + const m = kv[key].match(/\b(\d{3,4})\b/); + if (m) { + result.cvv = m[1]; + break; + } + } + if (!result.cvv) { + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]; + if (!/(cvv|cvc|安全码)/i.test(line)) continue; + const direct = line.match(/\b(\d{3,4})\b/); + if (direct) { + result.cvv = direct[1]; + break; + } + const next = lines[i + 1] || ""; + const m = next.match(/\b(\d{3,4})\b/); + if (m) { + result.cvv = m[1]; + break; + } + } + } + + // 姓名 + const nameKeyCandidates = ["姓名", "name", "cardholder", "持卡人"]; + for (const key of nameKeyCandidates) { + if (kv[key]) { + result.billing_name = kv[key]; + break; + } + } + if (!result.billing_name) { + for (const line of lines) { + if (/^[A-Z][a-z]+(\s+[A-Z][a-z]+){0,4}$/.test(line)) { + result.billing_name = line; + break; + } + } + } + + // 地址字段 + const addressLine = kv["地址"] || kv["address"] || kv["address_line1"] || ""; + const city = kv["城市"] || kv["city"] || ""; + const state = kv["州"] || kv["state"] || kv["省"] || ""; + const postal = kv["邮编"] || kv["postal_code"] || kv["zip"] || kv["zipcode"] || kv["zip_code"] || ""; + const countryRaw = kv["国家"] || kv["country"] || kv["地区"] || ""; + + if (addressLine) result.address_line1 = addressLine; + if (city) result.address_city = city; + if (state) result.address_state = state; + if (postal) result.postal_code = postal; + + if (countryRaw) { + const country = resolveCountryAlias(countryRaw); + if (country) { + result.country_code = country.code; + result.currency = country.currency; + } + } + + // 账单地址单行模式 + if (!result.address_line1) { + let addressCandidate = ""; + for (const line of lines) { + if (/(账单地址|billing\s*address)/i.test(line)) { + addressCandidate = line.replace(/^.*?(账单地址|billing\s*address)\s*[::]?\s*/i, "").trim(); + if (addressCandidate) break; + } + } + if (!addressCandidate) { + addressCandidate = lines.find((line) => line.includes(",") && /\d/.test(line)) || ""; + } + if (addressCandidate) { + result.raw_address = addressCandidate; + const parts = addressCandidate.split(",").map((item) => item.trim()).filter(Boolean); + if (parts.length) { + result.address_line1 = parts[0]; + } + if (!result.postal_code) { + const zip = addressCandidate.match(/\b(\d{5}(?:-\d{4})?)\b/); + if (zip) result.postal_code = zip[1]; + } + if (!result.address_state) { + const stateCode = addressCandidate.match(/\b([A-Z]{2})\b/); + if (stateCode) result.address_state = stateCode[1]; + } + const suffix = parts[parts.length - 1]; + if (!result.country_code && suffix) { + const country = resolveCountryAlias(suffix); + if (country) { + result.country_code = country.code; + result.currency = country.currency; + } + } + } + } + + return result; +} + +function buildParsedSummary(parsed) { + const parts = []; + if (parsed.card_number) parts.push(`卡号: ${maskCardNumber(parsed.card_number)}`); + if (parsed.exp_month || parsed.exp_year) { + parts.push(`有效期: ${formatExpiryInput(parsed.exp_month, parsed.exp_year) || "--/--"}`); + } + if (parsed.cvv) parts.push("CVV: ***"); + if (parsed.billing_name) parts.push(`姓名: ${parsed.billing_name}`); + if (parsed.address_line1 || parsed.raw_address) { + parts.push(`地址: ${parsed.raw_address || parsed.address_line1}`); + } + if (parsed.country_code) parts.push(`国家: ${parsed.country_code}`); + return parts; +} + +function fillBillingForm(parsed) { + if (!parsed || typeof parsed !== "object") return; + + if (parsed.card_number) setInputValue("card-number-input", parsed.card_number); + if (parsed.exp_month || parsed.exp_year) { + setInputValue("card-expiry-input", formatExpiryInput(parsed.exp_month, parsed.exp_year)); + } + if (parsed.cvc || parsed.cvv) setInputValue("card-cvc-input", String(parsed.cvc || parsed.cvv || "")); + if (parsed.billing_name) setInputValue("billing-name-input", parsed.billing_name); + if (parsed.address_line1) setInputValue("billing-line1-input", parsed.address_line1); + if (parsed.address_city) setInputValue("billing-city-input", parsed.address_city); + if (parsed.address_state) setInputValue("billing-state-input", parsed.address_state); + if (parsed.postal_code) setInputValue("billing-postal-input", parsed.postal_code); + + if (parsed.country_code) { + const countryEl = document.getElementById("billing-country-input"); + if (countryEl) countryEl.value = parsed.country_code; + } + + if (parsed.currency) { + setInputValue("billing-currency-input", parsed.currency); + } else { + onBillingCountryChanged(); + } + + persistBillingProfileNonSensitive(); +} + +function onBillingCountryChanged() { + const country = (document.getElementById("billing-country-input")?.value || "US").toUpperCase(); + const currencyEl = document.getElementById("billing-currency-input"); + if (!currencyEl) return; + if (!currencyEl.value || currencyEl.value === "USD" || currencyEl.value.length < 3) { + currencyEl.value = BILLING_COUNTRY_CURRENCY_MAP[country] || "USD"; + } +} + +function setRandomBillingHint(message, mode = "info") { + const hintEl = document.getElementById("random-billing-hint"); + if (!hintEl) return; + hintEl.textContent = String(message || ""); + if (mode === "success") { + hintEl.style.color = "var(--success-color)"; + return; + } + if (mode === "error") { + hintEl.style.color = "var(--danger-color)"; + return; + } + hintEl.style.color = "var(--text-secondary)"; +} + +async function randomBillingByCountry() { + const country = String(getInputValue("billing-country-input") || "US").toUpperCase(); + setButtonLoading("random-billing-btn", "生成中...", true); + setRandomBillingHint("正在获取随机账单资料...", "info"); + try { + const data = await api.get(`/payment/random-billing?country=${encodeURIComponent(country)}`); + const profile = data?.profile || {}; + if (!profile || typeof profile !== "object") { + throw new Error("返回的账单资料格式无效"); + } + + fillBillingForm({ + billing_name: profile.billing_name || "", + country_code: profile.country_code || country, + currency: profile.currency || "", + address_line1: profile.address_line1 || "", + address_city: profile.address_city || "", + address_state: profile.address_state || "", + postal_code: profile.postal_code || "", + }); + + const source = String(profile.source || "unknown"); + let sourceLabel = "本地兜底"; + if (source === "meiguodizhi") sourceLabel = "meiguodizhi"; + if (source === "local_geo") sourceLabel = "本地生成"; + if (source === "local_geo_fallback") sourceLabel = "本地兜底"; + const fallbackReason = String(profile.fallback_reason || "").trim(); + const city = String(profile.address_city || "-"); + const state = String(profile.address_state || "-"); + const postal = String(profile.postal_code || "-"); + if ((source === "local_fallback" || source === "local_geo_fallback") && fallbackReason) { + setRandomBillingHint(`来源: ${sourceLabel} | ${city}, ${state}, ${postal} | 外部失败: ${fallbackReason}`, "error"); + toast.warning(`外部地址源不可用,已切换本地兜底:${fallbackReason}`); + } else { + setRandomBillingHint(`来源: ${sourceLabel} | ${city}, ${state}, ${postal}`, "success"); + toast.success(`已按 ${country} 随机填充账单资料(${sourceLabel})`); + } + } catch (error) { + setRandomBillingHint(`随机失败: ${formatErrorMessage(error)}`, "error"); + toast.error(`随机账单资料失败: ${formatErrorMessage(error)}`); + } finally { + setButtonLoading("random-billing-btn", "生成中...", false); + } +} + +function collectBillingFormData() { + const expiry = parseExpiryInput(getInputValue("card-expiry-input")); + return { + card_number: getInputValue("card-number-input").replace(/\D/g, ""), + exp_month: expiry.exp_month, + exp_year: expiry.exp_year, + cvc: getInputValue("card-cvc-input").replace(/\D/g, ""), + billing_name: getInputValue("billing-name-input"), + country_code: getInputValue("billing-country-input").toUpperCase(), + currency: getInputValue("billing-currency-input").toUpperCase(), + address_line1: getInputValue("billing-line1-input"), + address_city: getInputValue("billing-city-input"), + address_state: getInputValue("billing-state-input"), + postal_code: getInputValue("billing-postal-input"), + }; +} + +function persistBillingProfileNonSensitive() { + const data = collectBillingFormData(); + storage.set(BILLING_STORAGE_KEY, { + billing_name: data.billing_name, + country_code: data.country_code, + currency: data.currency, + address_line1: data.address_line1, + address_city: data.address_city, + address_state: data.address_state, + postal_code: data.postal_code, + }); +} + +function restoreBillingProfileNonSensitive() { + const saved = storage.get(BILLING_STORAGE_KEY, null); + if (!saved || typeof saved !== "object") { + setInputValue("billing-country-input", "US"); + setInputValue("billing-currency-input", "USD"); + return; + } + if (saved.billing_name) setInputValue("billing-name-input", saved.billing_name); + if (saved.country_code) setInputValue("billing-country-input", saved.country_code); + if (saved.currency) setInputValue("billing-currency-input", saved.currency); + if (saved.address_line1) setInputValue("billing-line1-input", saved.address_line1); + if (saved.address_city) setInputValue("billing-city-input", saved.address_city); + if (saved.address_state) setInputValue("billing-state-input", saved.address_state); + if (saved.postal_code) setInputValue("billing-postal-input", saved.postal_code); + onBillingCountryChanged(); +} + +function getBillingTemplates() { + const raw = storage.get(BILLING_TEMPLATE_STORAGE_KEY, []); + if (!Array.isArray(raw)) return []; + return raw + .filter((item) => item && typeof item === "object" && item.id && item.name && item.data) + .slice(0, BILLING_TEMPLATE_MAX); +} + +function saveBillingTemplates(list) { + const safeList = Array.isArray(list) ? list.slice(0, BILLING_TEMPLATE_MAX) : []; + storage.set(BILLING_TEMPLATE_STORAGE_KEY, safeList); +} + +function normalizeTemplateData(data) { + const source = data && typeof data === "object" ? data : {}; + return { + card_number: String(source.card_number || "").replace(/\D/g, ""), + exp_month: normalizeMonth(source.exp_month), + exp_year: normalizeYear(source.exp_year), + cvc: String(source.cvc || source.cvv || "").replace(/\D/g, "").slice(0, 4), + billing_name: String(source.billing_name || "").trim(), + country_code: String(source.country_code || "US").trim().toUpperCase() || "US", + currency: String(source.currency || "").trim().toUpperCase(), + address_line1: String(source.address_line1 || "").trim(), + address_city: String(source.address_city || "").trim(), + address_state: String(source.address_state || "").trim(), + postal_code: String(source.postal_code || "").trim(), + }; +} + +function refreshBillingTemplateSelect(selectedId = "") { + const selectEl = document.getElementById("billing-template-select"); + if (!selectEl) return; + const templates = getBillingTemplates(); + + const options = ['']; + templates.forEach((tpl) => { + options.push(``); + }); + selectEl.innerHTML = options.join(""); + + if (selectedId && templates.some((tpl) => tpl.id === selectedId)) { + selectEl.value = selectedId; + } +} + +function saveCurrentAsTemplate() { + const name = getInputValue("billing-template-name"); + if (!name) { + toast.warning("请先填写模板名称"); + return; + } + + const form = normalizeTemplateData(collectBillingFormData()); + const hasValue = Boolean( + form.card_number || + (form.exp_month && form.exp_year) || + form.cvc || + form.billing_name || + form.address_line1 + ); + if (!hasValue) { + toast.warning("当前表单为空,无法保存模板"); + return; + } + + const templates = getBillingTemplates(); + const normalizedName = name.toLowerCase(); + const existing = templates.find((tpl) => String(tpl.name || "").toLowerCase() === normalizedName); + const nowIso = new Date().toISOString(); + if (existing) { + existing.data = form; + existing.updated_at = nowIso; + saveBillingTemplates(templates); + refreshBillingTemplateSelect(existing.id); + toast.success(`模板已更新: ${name}`); + return; + } + + const newTemplate = { + id: `tpl_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`, + name, + data: form, + created_at: nowIso, + updated_at: nowIso, + }; + templates.unshift(newTemplate); + saveBillingTemplates(templates); + refreshBillingTemplateSelect(newTemplate.id); + toast.success(`模板已保存: ${name}`); +} + +function applySelectedTemplate() { + const selectEl = document.getElementById("billing-template-select"); + if (!selectEl || !selectEl.value) { + toast.warning("请先选择模板"); + return; + } + const templates = getBillingTemplates(); + const selected = templates.find((tpl) => tpl.id === selectEl.value); + if (!selected) { + toast.warning("模板不存在或已删除"); + refreshBillingTemplateSelect(); + return; + } + fillBillingForm(selected.data || {}); + setInputValue("billing-template-name", selected.name || ""); + toast.success(`已应用模板: ${selected.name}`); +} + +async function deleteSelectedTemplate() { + const selectEl = document.getElementById("billing-template-select"); + if (!selectEl || !selectEl.value) { + toast.warning("请先选择模板"); + return; + } + + const templates = getBillingTemplates(); + const selected = templates.find((tpl) => tpl.id === selectEl.value); + if (!selected) { + toast.warning("模板不存在或已删除"); + refreshBillingTemplateSelect(); + return; + } + + const ok = await confirm(`确认删除模板「${selected.name}」吗?`, "删除模板"); + if (!ok) return; + + const next = templates.filter((tpl) => tpl.id !== selected.id); + saveBillingTemplates(next); + refreshBillingTemplateSelect(); + setInputValue("billing-template-name", ""); + toast.success(`模板已删除: ${selected.name}`); +} + +function saveBatchProfilesAsTemplates() { + if (!billingBatchProfiles.length) { + toast.warning("请先批量识别文本"); + return; + } + + const templates = getBillingTemplates(); + const now = new Date(); + let saved = 0; + + billingBatchProfiles.forEach((item, idx) => { + const parsed = item?.parsed || {}; + const data = normalizeTemplateData({ + card_number: parsed.card_number, + exp_month: parsed.exp_month, + exp_year: parsed.exp_year, + cvc: parsed.cvv, + billing_name: parsed.billing_name, + country_code: parsed.country_code, + currency: parsed.currency, + address_line1: parsed.address_line1, + address_city: parsed.address_city, + address_state: parsed.address_state, + postal_code: parsed.postal_code, + }); + const hasValue = Boolean( + data.card_number || + (data.exp_month && data.exp_year) || + data.cvc || + data.billing_name || + data.address_line1 + ); + if (!hasValue) return; + + const suffix = data.card_number ? data.card_number.slice(-4) : String(idx + 1).padStart(2, "0"); + const name = `批量模板-${now.toISOString().slice(0, 10)}-${suffix}`; + templates.unshift({ + id: `tpl_${Date.now()}_${Math.random().toString(16).slice(2, 8)}_${idx}`, + name, + data, + created_at: now.toISOString(), + updated_at: now.toISOString(), + }); + saved += 1; + }); + + if (!saved) { + toast.warning("批量记录里没有可保存的模板"); + return; + } + + saveBillingTemplates(templates); + refreshBillingTemplateSelect(); + toast.success(`已保存 ${saved} 个模板`); +} + +function setParseResult(message, type = "info") { + const resultEl = document.getElementById("billing-parse-result"); + if (!resultEl) return; + resultEl.textContent = message || ""; + if (type === "error") { + resultEl.style.color = "var(--danger-color)"; + } else if (type === "success") { + resultEl.style.color = "var(--success-color)"; + } else { + resultEl.style.color = "var(--text-secondary)"; + } +} + +function parseSingleBillingText() { + const text = getInputValue("billing-paste-text"); + if (!text) { + setParseResult("请先粘贴文本", "error"); + return; + } + + const parsed = parseCardText(text); + const summary = buildParsedSummary(parsed); + if (!summary.length) { + setParseResult("未识别到可用信息,请检查文本格式", "error"); + return; + } + + fillBillingForm(parsed); + setParseResult(`识别成功: ${summary.join(" | ")}`, "success"); +} + +function splitBatchBlocks(rawText) { + const blocks = String(rawText || "") + .split(/\n\s*\n+/) + .map((part) => part.trim()) + .filter(Boolean); + if (blocks.length > 1) return blocks; + return String(rawText || "") + .split(/(?:^-{3,}|^={3,})/m) + .map((part) => part.trim()) + .filter(Boolean); +} + +function renderBatchProfiles() { + const wrap = document.getElementById("billing-batch-wrap"); + const summary = document.getElementById("billing-batch-summary"); + const tbody = document.getElementById("billing-batch-table"); + if (!wrap || !summary || !tbody) return; + + if (!billingBatchProfiles.length) { + wrap.style.display = "none"; + tbody.innerHTML = ""; + summary.textContent = ""; + return; + } + + wrap.style.display = ""; + summary.textContent = `已识别 ${billingBatchProfiles.length} 条资料,点击“填充”可写入下方表单。`; + tbody.innerHTML = billingBatchProfiles.map((item, index) => { + const parsed = item.parsed || {}; + const address = parsed.raw_address || [ + parsed.address_line1, + parsed.address_city, + parsed.address_state, + parsed.postal_code, + ].filter(Boolean).join(", "); + return ` + + ${index + 1} + ${escapeHtml(maskCardNumber(parsed.card_number))} + ${escapeHtml(formatExpiryInput(parsed.exp_month, parsed.exp_year) || "-")} + ${escapeHtml(parsed.cvv ? "***" : "-")} + ${escapeHtml(parsed.country_code || "-")} + ${escapeHtml(address || "-")} + + + `; + }).join(""); +} + +function parseBatchBillingText() { + const text = getInputValue("billing-paste-text"); + if (!text) { + setParseResult("请先粘贴文本", "error"); + return; + } + + const blocks = splitBatchBlocks(text); + if (!blocks.length) { + setParseResult("未检测到可解析的文本块", "error"); + return; + } + + billingBatchProfiles = blocks + .map((blockText) => ({ raw: blockText, parsed: parseCardText(blockText) })) + .filter((item) => { + const parsed = item.parsed || {}; + return Boolean( + parsed.card_number || + parsed.exp_month || + parsed.exp_year || + parsed.cvv || + parsed.address_line1 || + parsed.raw_address + ); + }); + + renderBatchProfiles(); + if (!billingBatchProfiles.length) { + setParseResult("批量识别完成,但没有可用记录", "error"); + return; + } + + setParseResult(`批量识别成功:共 ${billingBatchProfiles.length} 条`, "success"); +} + +function fillFromBatchProfile(index) { + const item = billingBatchProfiles[index]; + if (!item) return; + fillBillingForm(item.parsed || {}); + const summary = buildParsedSummary(item.parsed || {}); + setParseResult(`已填充第 ${index + 1} 条:${summary.join(" | ")}`, "success"); +} + +function clearBillingText() { + setInputValue("billing-paste-text", ""); + billingBatchProfiles = []; + renderBatchProfiles(); + setParseResult("", "info"); +} + +function bindBillingEvents() { + document.getElementById("parse-billing-btn")?.addEventListener("click", parseSingleBillingText); + document.getElementById("parse-batch-btn")?.addEventListener("click", parseBatchBillingText); + document.getElementById("clear-billing-btn")?.addEventListener("click", clearBillingText); + document.getElementById("save-billing-template-btn")?.addEventListener("click", saveCurrentAsTemplate); + document.getElementById("apply-billing-template-btn")?.addEventListener("click", applySelectedTemplate); + document.getElementById("delete-billing-template-btn")?.addEventListener("click", deleteSelectedTemplate); + document.getElementById("save-batch-template-btn")?.addEventListener("click", saveBatchProfilesAsTemplates); + document.getElementById("billing-template-select")?.addEventListener("change", (event) => { + const selectValue = event?.target?.value || ""; + if (!selectValue) { + setInputValue("billing-template-name", ""); + return; + } + const selected = getBillingTemplates().find((tpl) => tpl.id === selectValue); + if (selected) { + setInputValue("billing-template-name", selected.name || ""); + } + }); + + document.getElementById("billing-country-input")?.addEventListener("change", () => { + onBillingCountryChanged(); + persistBillingProfileNonSensitive(); + setRandomBillingHint(""); + }); + + [ + "card-number-input", + "card-expiry-input", + "card-cvc-input", + "billing-name-input", + "billing-country-input", + "billing-currency-input", + "billing-line1-input", + "billing-city-input", + "billing-state-input", + "billing-postal-input", + ].forEach((id) => { + const node = document.getElementById(id); + node?.addEventListener("input", debounce(() => { + persistBillingProfileNonSensitive(); + resetGenerateLinkButtonState(); + }, 200)); + node?.addEventListener("change", resetGenerateLinkButtonState); + }); + + document.getElementById("card-expiry-input")?.addEventListener("input", (event) => { + const el = event.target; + if (!el) return; + const next = normalizeExpiryInputForTyping(el.value); + if (el.value !== next) { + el.value = next; + } + }); + + document.getElementById("card-number-input")?.addEventListener("input", (event) => { + const el = event.target; + if (!el) return; + const digits = String(el.value || "").replace(/\D/g, "").slice(0, 19); + const grouped = digits.replace(/(\d{4})(?=\d)/g, "$1 ").trim(); + if (el.value !== grouped) { + el.value = grouped; + } + }); + + document.getElementById("card-cvc-input")?.addEventListener("input", (event) => { + const el = event.target; + if (!el) return; + const digits = String(el.value || "").replace(/\D/g, "").slice(0, 4); + if (el.value !== digits) { + el.value = digits; + } + }); +} + +function getTaskStatusText(status) { + const mapping = { + link_ready: "待打开", + opened: "已打开", + waiting_user_action: "待用户完成", + paid_pending_sync: "已支付待同步", + verifying: "验证中", + completed: "已完成", + failed: "失败", + }; + return mapping[status] || status || "-"; +} + +function startBindTaskAutoRefresh() { + stopBindTaskAutoRefresh(); + bindTaskAutoRefreshTimer = setInterval(() => { + const bindTaskTab = document.getElementById("tab-content-bind-task"); + if (!bindTaskTab?.classList.contains("active")) return; + loadBindCardTasks(true); + }, 20000); +} + +function stopBindTaskAutoRefresh() { + if (!bindTaskAutoRefreshTimer) return; + clearInterval(bindTaskAutoRefreshTimer); + bindTaskAutoRefreshTimer = null; +} + +function setButtonLoading(buttonId, loadingText, isLoading) { + const btn = document.getElementById(buttonId); + if (!btn) return; + if (isLoading) { + if (!btn.dataset.originalText) { + btn.dataset.originalText = btn.textContent || ""; + } + btn.disabled = true; + btn.textContent = loadingText; + return; + } + btn.disabled = false; + btn.textContent = btn.dataset.originalText || btn.textContent; +} + +function resetGenerateLinkButtonState() { + const btn = document.getElementById("generate-link-btn"); + if (!btn) return; + btn.disabled = false; + if (btn.dataset.originalText) { + btn.textContent = btn.dataset.originalText; + } +} + +function getBindMode() { + return (document.getElementById("bind-mode-select")?.value || "semi_auto").trim() || "semi_auto"; +} + +function updateSemiAutoActionsVisibility(mode) { + const isSemiAuto = (mode || getBindMode()) === "semi_auto"; + const loginBtn = document.getElementById("semi-login-gpt-btn"); + const emailBtn = document.getElementById("semi-account-email-btn"); + if (loginBtn) { + loginBtn.style.display = isSemiAuto ? "" : "none"; + } + if (emailBtn) { + emailBtn.style.display = isSemiAuto ? "" : "none"; + } +} + +function getSelectedAccountEmail() { + const accountId = Number(document.getElementById("account-select")?.value || 0); + if (!accountId) return ""; + const matched = (paymentAccounts || []).find((acc) => Number(acc?.id || 0) === accountId); + return String(matched?.email || "").trim(); +} + +function updateSelectedAccountEmailLabel() { + const emailBtn = document.getElementById("semi-account-email-btn"); + if (!emailBtn) return; + const email = getSelectedAccountEmail(); + emailBtn.textContent = "邮箱"; + emailBtn.title = email ? `当前账号: ${email}(点击查询最新验证码)` : "请先选择账号"; +} + +async function openGptOfficialLogin() { + const accountId = Number(document.getElementById("account-select")?.value || 0); + if (!accountId) { + toast.warning("请先选择账号"); + return; + } + const loginUrl = "https://chatgpt.com/auth/login"; + try { + const data = await api.post("/payment/open-incognito", { + url: loginUrl, + account_id: accountId, + }); + if (data?.success) { + toast.success("已打开 GPT 官方登录页(无痕)"); + return; + } + window.open(loginUrl, "_blank", "noopener,noreferrer"); + toast.warning(data?.message || "未找到可用浏览器,已在当前浏览器打开"); + } catch (error) { + window.open(loginUrl, "_blank", "noopener,noreferrer"); + toast.warning(`打开无痕失败,已在当前浏览器打开: ${formatErrorMessage(error)}`); + } +} + +async function fetchSelectedAccountInbox() { + const accountId = Number(document.getElementById("account-select")?.value || 0); + const email = getSelectedAccountEmail(); + if (!accountId || !email) { + toast.warning("请先选择账号"); + return; + } + toast.info(`正在查询 ${email} 收件箱...`); + try { + const result = await api.post(`/accounts/${accountId}/inbox-code`, {}); + if (result?.success && result?.code) { + const code = String(result.code).trim(); + copyToClipboard(code); + toast.success(`${email} 最新验证码: ${code}(已复制)`, 8000); + return; + } + toast.error(`查询失败: ${result?.error || "未收到验证码"}`); + } catch (error) { + toast.error(`查询失败: ${formatErrorMessage(error)}`); + } +} + +function onBindModeChange() { + const mode = getBindMode(); + const thirdPartyPanel = document.getElementById("third-party-config"); + if (thirdPartyPanel) { + thirdPartyPanel.style.display = mode === "third_party" ? "" : "none"; + } + + const actionBtn = document.getElementById("create-bind-task-btn"); + if (actionBtn) { + if (mode === "third_party") { + actionBtn.textContent = "创建并执行第三方自动绑卡"; + } else if (mode === "local_auto") { + actionBtn.textContent = "创建并执行全自动绑卡"; + } else { + actionBtn.textContent = "生成并加入绑卡任务(半自动)"; + } + } + updateSemiAutoActionsVisibility(mode); + updateSelectedAccountEmailLabel(); + storage.set(BIND_MODE_STORAGE_KEY, mode); +} + +function collectThirdPartyConfig() { + const apiUrl = getInputValue("third-party-api-url"); + const apiKey = getInputValue("third-party-api-key"); + return { api_url: apiUrl, api_key: apiKey }; +} + +function restoreBindModeConfig() { + const modeSelect = document.getElementById("bind-mode-select"); + const savedMode = String(storage.get(BIND_MODE_STORAGE_KEY, "semi_auto") || "semi_auto"); + if (modeSelect) { + modeSelect.value = ["semi_auto", "third_party", "local_auto"].includes(savedMode) ? savedMode : "semi_auto"; + } + + const savedApiUrl = String(storage.get(THIRD_PARTY_BIND_URL_STORAGE_KEY, "") || "").trim(); + const initialApiUrl = savedApiUrl || THIRD_PARTY_BIND_DEFAULT_URL; + setInputValue("third-party-api-url", initialApiUrl); + if (!savedApiUrl) { + storage.set(THIRD_PARTY_BIND_URL_STORAGE_KEY, initialApiUrl); + } + onBindModeChange(); +} + +function switchPaymentTab(tab) { + const isLink = tab === "link"; + document.getElementById("tab-btn-link")?.classList.toggle("active", isLink); + document.getElementById("tab-btn-bind-task")?.classList.toggle("active", !isLink); + document.getElementById("tab-content-link")?.classList.toggle("active", isLink); + document.getElementById("tab-content-bind-task")?.classList.toggle("active", !isLink); + if (!isLink) { + loadBindCardTasks(true); + } +} + +function getCheckoutPayload() { + const accountId = Number(document.getElementById("account-select")?.value || 0); + if (!accountId) { + throw new Error("请先选择账号"); + } + const payload = { + account_id: accountId, + plan_type: selectedPlan, + country: (document.getElementById("country-select")?.value || "US").toUpperCase(), + currency: (document.getElementById("currency-display")?.value || "USD").toUpperCase(), + }; + if (selectedPlan === "team") { + payload.workspace_name = document.getElementById("workspace-name")?.value || "MyTeam"; + payload.seat_quantity = Number(document.getElementById("seat-quantity")?.value || 5) || 5; + payload.price_interval = document.getElementById("price-interval")?.value || "month"; + } + return payload; +} + +function showGeneratedLink(data) { + generatedLink = data.link || ""; + const linkText = document.getElementById("link-text"); + const linkBox = document.getElementById("link-box"); + const statusEl = document.getElementById("open-status"); + if (!linkText || !linkBox || !statusEl) return; + + linkText.value = generatedLink; + linkBox.classList.add("show"); + + const source = data.source ? `来源: ${data.source}` : ""; + const routeHint = data.is_official_checkout + ? "已拿到官方 checkout 链接,可直接绑卡" + : "当前是中转链接,点击“直接打开支付页”继续跳转"; + statusEl.textContent = [source, routeHint].filter(Boolean).join(" | "); +} + +document.addEventListener("DOMContentLoaded", () => { + const countrySelect = document.getElementById("country-select"); + const currencyDisplay = document.getElementById("currency-display"); + if (countrySelect) { + countrySelect.value = countrySelect.value || "US"; + } + if (currencyDisplay) { + currencyDisplay.value = currencyDisplay.value || "USD"; + } + + const searchInput = document.getElementById("bind-task-search"); + if (searchInput) { + searchInput.addEventListener( + "input", + debounce(() => { + bindTaskState.search = (searchInput.value || "").trim(); + bindTaskState.page = 1; + loadBindCardTasks(); + }, 250) + ); + } + + const statusSelect = document.getElementById("bind-task-status"); + if (statusSelect) { + statusSelect.addEventListener("change", () => { + bindTaskState.status = statusSelect.value || ""; + bindTaskState.page = 1; + loadBindCardTasks(); + }); + } + document.getElementById("account-select")?.addEventListener("change", () => { + resetGenerateLinkButtonState(); + updateSelectedAccountEmailLabel(); + }); + + bindBillingEvents(); + restoreBillingProfileNonSensitive(); + refreshBillingTemplateSelect(); + restoreBindModeConfig(); + + document.getElementById("third-party-api-url")?.addEventListener( + "input", + debounce(() => { + const apiUrl = getInputValue("third-party-api-url"); + storage.set(THIRD_PARTY_BIND_URL_STORAGE_KEY, apiUrl); + }, 200) + ); + document.getElementById("bind-mode-select")?.addEventListener("change", onBindModeChange); + + loadAccounts(); + onCountryChange(); + loadBindCardTasks(); + startBindTaskAutoRefresh(); + switchPaymentTab("link"); + + window.addEventListener("beforeunload", stopBindTaskAutoRefresh); +}); + +// 加载账号列表 +async function loadAccounts() { + try { + // 后端 page_size 最大为 100,超限会返回 422。 + // 这里读取账号管理列表,不按状态硬过滤,避免“有账号但选不到”。 + const data = await api.get("/accounts?page=1&page_size=100"); + const sel = document.getElementById("account-select"); + if (!sel) return; + + sel.innerHTML = ''; + paymentAccounts = Array.isArray(data.accounts) ? data.accounts : []; + (data.accounts || []).forEach((acc) => { + const opt = document.createElement("option"); + opt.value = acc.id; + const subText = acc.subscription_type ? ` (${String(acc.subscription_type).toUpperCase()})` : ""; + opt.textContent = `${acc.email}${subText}`; + sel.appendChild(opt); + }); + updateSelectedAccountEmailLabel(); + updateSemiAutoActionsVisibility(getBindMode()); + } catch (e) { + toast.error(`加载账号失败: ${formatErrorMessage(e)}`); + } +} + +// 国家切换 +function onCountryChange() { + const country = document.getElementById("country-select")?.value || "US"; + const currency = COUNTRY_CURRENCY_MAP[country] || "USD"; + const currencyEl = document.getElementById("currency-display"); + if (currencyEl) { + currencyEl.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 = ""; + resetGenerateLinkButtonState(); +} + +// 生成支付链接 +async function generateLink() { + if (isGeneratingCheckoutLink) { + return; + } + + let payload; + try { + payload = getCheckoutPayload(); + } catch (err) { + toast.warning(err.message || "参数不完整"); + return; + } + + isGeneratingCheckoutLink = true; + setButtonLoading("generate-link-btn", "生成中...", true); + try { + const data = await api.post("/payment/generate-link", payload); + if (!data?.success || !data?.link) { + throw new Error(data?.detail || "生成链接失败"); + } + showGeneratedLink(data); + toast.success("支付链接生成成功"); + } catch (e) { + toast.error(`生成链接失败: ${formatErrorMessage(e)}`); + } finally { + isGeneratingCheckoutLink = false; + setButtonLoading("generate-link-btn", "生成中...", false); + resetGenerateLinkButtonState(); + } +} + +async function submitThirdPartyAutoBind(task, bindData) { + const thirdParty = collectThirdPartyConfig(); + const apiUrl = String(thirdParty.api_url || "").trim(); + const apiKey = String(thirdParty.api_key || "").trim(); + if (apiUrl) { + storage.set(THIRD_PARTY_BIND_URL_STORAGE_KEY, apiUrl); + } + + const expYear = String(bindData.exp_year || "").replace(/\D/g, ""); + const payload = { + api_url: apiUrl || undefined, + api_key: apiKey || undefined, + timeout_seconds: 180, + interval_seconds: 10, + card: { + number: String(bindData.card_number || "").replace(/\D/g, ""), + exp_month: String(bindData.exp_month || "").replace(/\D/g, "").padStart(2, "0").slice(0, 2), + exp_year: (expYear.slice(-2) || expYear || "").padStart(2, "0"), + cvc: String(bindData.cvc || "").replace(/\D/g, "").slice(0, 4), + }, + profile: { + name: String(bindData.billing_name || "").trim(), + email: String(task?.account_email || "").trim() || undefined, + country: String(bindData.country_code || "US").toUpperCase(), + line1: String(bindData.address_line1 || "").trim(), + city: String(bindData.address_city || "").trim(), + state: String(bindData.address_state || "").trim(), + postal: String(bindData.postal_code || "").trim(), + }, + }; + + return api.post(`/payment/bind-card/tasks/${task.id}/auto-bind-third-party`, payload); +} + +async function submitLocalAutoBind(task, bindData) { + const expYear = String(bindData.exp_year || "").replace(/\D/g, ""); + const payload = { + browser_timeout_seconds: 220, + post_submit_wait_seconds: 90, + verify_timeout_seconds: 180, + verify_interval_seconds: 10, + headless: false, + card: { + number: String(bindData.card_number || "").replace(/\D/g, ""), + exp_month: String(bindData.exp_month || "").replace(/\D/g, "").padStart(2, "0").slice(0, 2), + exp_year: (expYear.slice(-2) || expYear || "").padStart(2, "0"), + cvc: String(bindData.cvc || "").replace(/\D/g, "").slice(0, 4), + }, + profile: { + name: String(bindData.billing_name || "").trim(), + email: String(task?.account_email || "").trim() || undefined, + country: String(bindData.country_code || "US").toUpperCase(), + line1: String(bindData.address_line1 || "").trim(), + city: String(bindData.address_city || "").trim(), + state: String(bindData.address_state || "").trim(), + postal: String(bindData.postal_code || "").trim(), + }, + }; + return api.post(`/payment/bind-card/tasks/${task.id}/auto-bind-local`, payload); +} + +async function runLocalAutoBindInBackground(task, bindData) { + try { + const autoResult = await submitLocalAutoBind(task, bindData); + if (autoResult?.verified) { + toast.success(`任务 #${task.id} 全自动绑卡完成: ${String(autoResult.subscription_type || "").toUpperCase()}`); + } else if (autoResult?.paid_confirmed) { + toast.success(`任务 #${task.id} 已确认支付,等待订阅同步(可点“同步订阅”)`, 7000); + } else if (autoResult?.pending || autoResult?.need_user_action) { + const stage = String(autoResult?.local_auto?.stage || autoResult?.local_auto?.error || "challenge").toUpperCase(); + toast.warning( + `任务 #${task.id} 全自动绑卡已提交(${stage}),请在支付页完成验证后点击“我已完成支付”或“同步订阅”。`, + 9000 + ); + } else { + const sub = String(autoResult?.subscription_type || "free").toUpperCase(); + toast.warning(`任务 #${task.id} 全自动绑卡执行完成,但当前订阅为 ${sub},请稍后再同步`, 7000); + } + } catch (autoErr) { + toast.error(`任务 #${task.id} 全自动绑卡失败: ${formatErrorMessage(autoErr)}`); + } finally { + try { + await loadBindCardTasks(); + } catch (_) { + // 忽略刷新异常,避免覆盖前面的业务提示 + } + } +} + +// 生成并创建绑卡任务 +async function createBindCardTask() { + let payload; + try { + payload = getCheckoutPayload(); + } catch (err) { + toast.warning(err.message || "参数不完整"); + return; + } + + const bindMode = getBindMode(); + const bindData = collectBillingFormData(); + const missing = []; + if (!bindData.card_number) missing.push("卡号"); + if (!bindData.exp_month || !bindData.exp_year) missing.push("有效期"); + if (!bindData.cvc) missing.push("CVC"); + if (!bindData.billing_name) missing.push("姓名"); + if (!bindData.address_line1) missing.push("地址"); + if (!bindData.postal_code) missing.push("邮编"); + if (missing.length && bindMode === "semi_auto") { + toast.warning(`绑卡资料未完整:${missing.join("、")}(本次仅创建半自动任务,不会阻断)`, 5000); + } + if (missing.length && (bindMode === "third_party" || bindMode === "local_auto")) { + const modeText = bindMode === "third_party" ? "第三方自动绑卡" : "全自动绑卡"; + toast.warning(`${modeText}需要完整资料:${missing.join("、")}`, 5000); + return; + } + + payload.auto_open = Boolean(document.getElementById("bind-auto-open")?.checked); + payload.bind_mode = bindMode; + + setButtonLoading("create-bind-task-btn", "创建中...", true); + try { + const data = await api.post("/payment/bind-card/tasks", payload); + if (!data?.success || !data?.task) { + throw new Error(data?.detail || "创建绑卡任务失败"); + } + + if (data.link) { + showGeneratedLink({ + link: data.link, + source: data.source, + is_official_checkout: data.is_official_checkout, + }); + } + + if (bindMode === "third_party") { + toast.info(`任务 #${data.task.id} 已创建,正在调用第三方自动绑卡...`, 3000); + try { + const autoResult = await submitThirdPartyAutoBind(data.task, bindData); + if (autoResult?.verified) { + toast.success(`任务 #${data.task.id} 自动绑卡完成: ${String(autoResult.subscription_type || "").toUpperCase()}`); + } else if (autoResult?.paid_confirmed) { + toast.success(`任务 #${data.task.id} 已确认支付,等待订阅同步(可点“同步订阅”)`, 7000); + } else if (autoResult?.pending || autoResult?.need_user_action) { + const tp = autoResult?.third_party || {}; + const assess = tp?.assessment || {}; + const snapshot = assess?.snapshot || {}; + const paymentStatus = String(snapshot?.payment_status || "").toUpperCase() || "UNKNOWN"; + toast.warning( + `任务 #${data.task.id} 第三方已受理(payment_status=${paymentStatus}),可能需要 challenge;请在支付页完成后点“我已完成支付”或“同步订阅”。`, + 9000 + ); + } else { + const sub = String(autoResult?.subscription_type || "free").toUpperCase(); + toast.warning(`任务 #${data.task.id} 第三方提交成功,但当前订阅为 ${sub},请稍后再同步`, 7000); + } + } catch (autoErr) { + toast.error(`任务 #${data.task.id} 第三方自动绑卡失败: ${formatErrorMessage(autoErr)}`); + } + } else if (bindMode === "local_auto") { + toast.info(`任务 #${data.task.id} 已创建,已在后台执行全自动绑卡,可继续修改参数并创建新任务`, 5000); + runLocalAutoBindInBackground(data.task, { ...bindData }); + } else { + toast.success(`绑卡任务已创建 #${data.task.id}${data.auto_opened ? ",浏览器已打开" : ""}`); + } + switchPaymentTab("bind-task"); + await loadBindCardTasks(); + } catch (e) { + toast.error(`创建绑卡任务失败: ${formatErrorMessage(e)}`); + } finally { + setButtonLoading("create-bind-task-btn", "创建中...", false); + } +} + +function copyLink() { + if (!generatedLink) { + toast.warning("请先生成链接"); + return; + } + copyToClipboard(generatedLink); +} + +// 在当前浏览器直接打开支付页(适合 Docker/远程部署场景) +function openCheckout() { + if (!generatedLink) { + toast.warning("请先生成链接"); + return; + } + const w = window.open(generatedLink, "_blank", "noopener,noreferrer"); + if (!w) { + window.location.href = generatedLink; + } +} + +// 无痕打开浏览器(携带账号 cookie) +async function openIncognito() { + if (!generatedLink) { + toast.warning("请先生成链接"); + return; + } + const accountId = Number(document.getElementById("account-select")?.value || 0); + const statusEl = document.getElementById("open-status"); + if (statusEl) { + statusEl.textContent = "正在打开..."; + } + try { + const body = { url: generatedLink }; + if (accountId) body.account_id = accountId; + const data = await api.post("/payment/open-incognito", body); + if (data?.success) { + if (statusEl) statusEl.textContent = "已在无痕模式打开浏览器"; + toast.success("无痕浏览器已打开"); + } else { + if (statusEl) statusEl.textContent = data?.message || "未找到可用浏览器,请手动复制链接"; + toast.warning(data?.message || "未找到可用浏览器"); + } + } catch (e) { + if (statusEl) statusEl.textContent = `请求失败: ${formatErrorMessage(e)}`; + toast.error(`请求失败: ${formatErrorMessage(e)}`); + } +} + +async function loadBindCardTasks(silent = false) { + const tbody = document.getElementById("bind-card-task-table"); + if (!tbody) return; + + if (!silent) { + setButtonLoading("refresh-bind-task-btn", "刷新中...", true); + } + try { + const params = new URLSearchParams({ + page: String(bindTaskState.page), + page_size: String(bindTaskState.pageSize), + }); + if (bindTaskState.status) params.set("status", bindTaskState.status); + if (bindTaskState.search) params.set("search", bindTaskState.search); + + const data = await api.get(`/payment/bind-card/tasks?${params.toString()}`); + const tasks = data?.tasks || []; + + if (!tasks.length) { + tbody.innerHTML = ` + +
暂无绑卡任务
+ + `; + return; + } + + tbody.innerHTML = tasks.map((task) => ` + + ${task.id} + ${escapeHtml(task.account_email || "-")} + ${String(task.plan_type || "-").toUpperCase()} + ${escapeHtml(getTaskStatusText(task.status))} + + + ${escapeHtml(task.checkout_url || "-")} + + + ${escapeHtml(task.checkout_source || "-")} + ${format.date(task.created_at)} + +
+ + + + +
+ ${task.last_error ? `
${escapeHtml(task.last_error)}
` : ""} + + + `).join(""); + } catch (e) { + tbody.innerHTML = ` + +
加载失败: ${escapeHtml(formatErrorMessage(e))}
+ + `; + } finally { + if (!silent) { + setButtonLoading("refresh-bind-task-btn", "刷新中...", false); + } + } +} + +async function openBindCardTask(taskId) { + try { + const data = await api.post(`/payment/bind-card/tasks/${taskId}/open`, {}); + if (data?.success) { + toast.success(`任务 #${taskId} 已尝试打开`); + await loadBindCardTasks(); + return; + } + throw new Error(data?.detail || "打开失败"); + } catch (e) { + toast.error(`打开任务失败: ${formatErrorMessage(e)}`); + } +} + +async function markBindCardTaskUserAction(taskId) { + try { + toast.info(`任务 #${taskId} 正在验证订阅,最多等待 180 秒...`, 3000); + const data = await api.post(`/payment/bind-card/tasks/${taskId}/mark-user-action`, { + timeout_seconds: 180, + interval_seconds: 10, + }); + if (data?.verified) { + toast.success(`任务 #${taskId} 验证成功: ${String(data.subscription_type || "").toUpperCase()}`); + } else { + const sub = String(data?.subscription_type || "free").toUpperCase(); + const source = String(data?.detail?.source || "unknown"); + const confidence = String(data?.detail?.confidence || "unknown"); + const note = String(data?.detail?.note || ""); + const suffix = note ? `, note=${note}` : ""; + toast.warning( + `任务 #${taskId} 暂未检测到订阅(当前 ${sub}, source=${source}, confidence=${confidence}${suffix}),已切回待用户完成`, + 7000 + ); + } + await loadBindCardTasks(); + } catch (e) { + // 兼容旧后端:如果 mark-user-action 尚未部署,自动降级到 sync-subscription。 + const detail = String(e?.data?.detail || "").toLowerCase(); + const isRouteNotFound = e?.response?.status === 404 && detail === "not found"; + if (isRouteNotFound) { + try { + const fallback = await api.post(`/payment/bind-card/tasks/${taskId}/sync-subscription`, {}); + const sub = String(fallback?.subscription_type || "free").toUpperCase(); + if (sub === "PLUS" || sub === "TEAM") { + toast.success(`任务 #${taskId} 已通过兼容模式同步成功: ${sub}`); + } else { + toast.warning(`任务 #${taskId} 兼容同步完成,但当前仍是 ${sub}`, 5000); + } + await loadBindCardTasks(); + return; + } catch (fallbackErr) { + toast.error(`验证订阅失败(兼容模式也失败): ${formatErrorMessage(fallbackErr)}`); + return; + } + } + toast.error(`验证订阅失败: ${formatErrorMessage(e)}`); + } +} + +async function syncBindCardTask(taskId) { + try { + const data = await api.post(`/payment/bind-card/tasks/${taskId}/sync-subscription`, {}); + const sub = String(data?.subscription_type || "free").toUpperCase(); + const source = String(data?.detail?.source || "unknown"); + const confidence = String(data?.detail?.confidence || "unknown"); + const note = String(data?.detail?.note || ""); + const suffix = note ? `, note=${note}` : ""; + const msg = `同步完成: ${sub} (source=${source}, confidence=${confidence}${suffix})`; + if (sub === "PLUS" || sub === "TEAM") { + toast.success(msg); + } else { + toast.warning(msg, 7000); + } + await loadBindCardTasks(); + } catch (e) { + toast.error(`同步订阅失败: ${formatErrorMessage(e)}`); + } +} + +async function deleteBindCardTask(taskId) { + const ok = await confirm(`确认删除绑卡任务 #${taskId} 吗?`, "删除任务"); + if (!ok) return; + + try { + await api.delete(`/payment/bind-card/tasks/${taskId}`); + toast.success(`任务 #${taskId} 已删除`); + await loadBindCardTasks(); + } catch (e) { + toast.error(`删除任务失败: ${formatErrorMessage(e)}`); + } +} + +window.selectPlan = selectPlan; +window.generateLink = generateLink; +window.createBindCardTask = createBindCardTask; +window.copyLink = copyLink; +window.openCheckout = openCheckout; +window.openIncognito = openIncognito; +window.onBindModeChange = onBindModeChange; +window.switchPaymentTab = switchPaymentTab; +window.loadBindCardTasks = loadBindCardTasks; +window.openBindCardTask = openBindCardTask; +window.markBindCardTaskUserAction = markBindCardTaskUserAction; +window.syncBindCardTask = syncBindCardTask; +window.deleteBindCardTask = deleteBindCardTask; +window.fillFromBatchProfile = fillFromBatchProfile; +window.randomBillingByCountry = randomBillingByCountry; diff --git a/static/js/settings.js b/static/js/settings.js new file mode 100644 index 00000000..46aba297 --- /dev/null +++ b/static/js/settings.js @@ -0,0 +1,1598 @@ +/** + * 设置页面 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'), + importDbBtn: document.getElementById('import-db-btn'), + dbImportFile: document.getElementById('db-import-file'), + 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.importDbBtn && elements.dbImportFile) { + elements.importDbBtn.addEventListener('click', () => { + elements.dbImportFile.value = ''; + elements.dbImportFile.click(); + }); + elements.dbImportFile.addEventListener('change', handleImportDatabase); + } + + // 清理数据 + 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; + const entryFlowRaw = String(data.registration?.entry_flow || 'native').toLowerCase(); + const entryFlow = entryFlowRaw === 'abcard' ? 'abcard' : 'native'; + document.getElementById('registration-entry-flow').value = entryFlow; + document.getElementById('sleep-min').value = data.registration?.sleep_min || 5; + document.getElementById('sleep-max').value = data.registration?.sleep_max || 30; + + // 验证码等待配置 + 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), + entry_flow: document.getElementById('registration-entry-flow').value || 'native', + sleep_min: parseInt(document.getElementById('sleep-min').value), + sleep_max: parseInt(document.getElementById('sleep-max').value), + }; + + 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 handleImportDatabase() { + const file = elements.dbImportFile?.files?.[0]; + if (!file) return; + + const confirmed = await confirm( + `确定导入数据库文件「${file.name}」吗?系统会先自动备份当前数据库,再执行覆盖导入。` + ); + if (!confirmed) { + elements.dbImportFile.value = ''; + return; + } + + const originText = elements.importDbBtn.textContent; + elements.importDbBtn.disabled = true; + elements.importDbBtn.innerHTML = ' 导入中...'; + + try { + const formData = new FormData(); + formData.append('file', file); + + const resp = await fetch('/api/settings/database/import', { + method: 'POST', + body: formData + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) { + throw new Error(data.detail || `HTTP ${resp.status}`); + } + + const backupText = data.backup_path ? `,备份: ${data.backup_path}` : ''; + toast.success(`导入成功,已覆盖数据库${backupText}`); + await loadDatabaseInfo(); + } catch (error) { + toast.error('导入失败: ' + error.message); + } finally { + elements.dbImportFile.value = ''; + elements.importDbBtn.disabled = false; + elements.importDbBtn.textContent = originText || '📥 导入数据'; + } +} + +// 清理数据 +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.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; + 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; + + 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 }; + 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) { + const confirmed = await confirm(`确认删除 Sub2API 服务「${name}」吗?`, "删除 Sub2API 服务"); + if (!confirmed) 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 00000000..b7b5dab0 --- /dev/null +++ b/static/js/utils.js @@ -0,0 +1,536 @@ +/** + * 通用工具库 + * 包含 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 }; + + if (finalOptions.body && typeof finalOptions.body === 'object') { + finalOptions.body = JSON.stringify(finalOptions.body); + } + + try { + 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.response) { + toast.error('网络连接失败,请检查网络'); + } + throw error; + } + } + + 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 00000000..fa28f782 --- /dev/null +++ b/templates/accounts.html @@ -0,0 +1,346 @@ + + + + + + 账号管理 - OpenAI 注册系统 + + + + + + {% include "partials/site_notice.html" %} +
+ + + + +
+ + + +
+
+
0
+
总账号数
+
+
+
0
+
活跃账号
+
+
+
0
+
过期账号
+
+
+
0
+
失败账号
+
+
+ + +
+
+
+ + + + + +
+ +
+ + + + + + + +
+
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + +
ID邮箱密码邮箱服务状态CPA订阅最后刷新操作
+
+
+
+
+
+
+
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + diff --git a/templates/accounts_overview.html b/templates/accounts_overview.html new file mode 100644 index 00000000..b0c603f6 --- /dev/null +++ b/templates/accounts_overview.html @@ -0,0 +1,741 @@ + + + + + + 账号总览 - OpenAI 注册系统 + + + + + {% include "partials/site_notice.html" %} +
+ + +
+ + +
+
+
0
+
总账号数
+
+
+
0
+
活跃账号
+
+
+
0
+
有 Access Token
+
+
+
0
+
已上传 CPA
+
+
+ +
+
+

状态分布

+
+
+
+

邮箱服务分布

+
+
+
+

订阅分布

+
+
+
+

来源分布

+
+
+
+ +

Codex 账号管理

+
+ +
+
+
+
+ +
+ +
+ + +
+ + + + +
+ +
+ + + + + +
+
+ +
+
+ 已选择 0 个账号 + | + 下次刷新 -- +
+
+ 自动刷新 + +
+
+
+ +
+ +
+ + + + +
+
+ + + + + diff --git a/templates/auto_team.html b/templates/auto_team.html new file mode 100644 index 00000000..c15d2c4b --- /dev/null +++ b/templates/auto_team.html @@ -0,0 +1,58 @@ + + + + + + 自动进team - OpenAI 注册系统 + + + + + {% include "partials/site_notice.html" %} +
+ + +
+ +
功能未开发!
+
+
+ + + + diff --git a/templates/card_pool.html b/templates/card_pool.html new file mode 100644 index 00000000..90eb878b --- /dev/null +++ b/templates/card_pool.html @@ -0,0 +1,58 @@ + + + + + + 卡池 - OpenAI 注册系统 + + + + + {% include "partials/site_notice.html" %} +
+ + +
+ +
功能未开发!
+
+
+ + + + diff --git a/templates/email_services.html b/templates/email_services.html new file mode 100644 index 00000000..61377fc9 --- /dev/null +++ b/templates/email_services.html @@ -0,0 +1,527 @@ + + + + + + 邮箱服务 - OpenAI 注册系统 + + + + + {% include "partials/site_notice.html" %} +
+ + + + +
+ + + +
+
+
0
+
Outlook 账户
+
+
+
0
+
自定义邮箱
+
+
+
可用
+
临时邮箱
+
+
+
0
+
已启用服务
+
+
+ + +
+
+

📥 Outlook 批量导入

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

📧 Outlook 账户列表

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

🌐 临时邮箱配置

+
+
+
+
+ + +
+
+ +
+
+ + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 00000000..3890e1d7 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,545 @@ + + + + + + 注册控制台 - OpenAI 注册系统 + + + + + + {% include "partials/site_notice.html" %} +
+ + + + +
+
+
+

📊 今日统计

+ 重置剩余 --:-- +
+
+
+
+
注册
+
0
+
+
+
成功
+
0
+
+
+
失败
+
0
+
+
+
成功率
+
0%
+
+
+
+
+ +
+ +
+
+
+

📝 注册设置

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

💻 监控台

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

📋 已注册账号

+
+ + 查看全部 +
+
+
+
+ + + + + + + + + + + + + + +
ID邮箱密码状态
+
+
📭
+
暂无已注册账号
+
+
+
+
+
+
+
+
+
+ + + + + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 00000000..673dc874 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,63 @@ + + + + + + 访问验证 - OpenAI 注册系统 + + + + + + {% include "partials/site_notice.html" %} +
+ +
+ + diff --git a/templates/logs.html b/templates/logs.html new file mode 100644 index 00000000..a3c655aa --- /dev/null +++ b/templates/logs.html @@ -0,0 +1,249 @@ + + + + + + 后台日志 - OpenAI 注册系统 + + + + + {% include "partials/site_notice.html" %} +
+ + +
+ + +
+
+

筛选与控制

+
+ + +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ 清理策略: + + + + + 系统也会每小时自动清理一次 +
+
+
+ +
+
+

统计

+ 最新日志: - +
+
+
+
总量
0
+
INFO
0
+
WARNING
0
+
ERROR
0
+
CRITICAL
0
+
+
+
+ +
+
+

日志流

+ - +
+
+
+
+ - + INFO + system + 加载中... +
+
+
+ +
+
+
+ + + + + diff --git a/templates/partials/site_notice.html b/templates/partials/site_notice.html new file mode 100644 index 00000000..6f3ae08a --- /dev/null +++ b/templates/partials/site_notice.html @@ -0,0 +1,33 @@ +{% set notice = project_notice if project_notice is defined else { + "title": "项目声明", + "free_notice": "本项目永久免费开源,如果你是付费购买的,请立即找卖家退款。", + "disclaimer": "免责声明:本工具仅供学习和研究使用,使用本工具产生的一切后果由使用者自行承担。请遵守相关服务的使用条款,不要用于任何违法或不当用途。如有侵权,请及时联系,会及时删除。", + "github_repo_name": "dou-jiang/codex-console", + "github_repo_url": "https://github.com/dou-jiang/codex-console", + "qq_group_id": "291638849", + "qq_group_url": "https://qm.qq.com/q/4TETC3mWco", + "telegram_name": "codex_console", + "telegram_url": "https://t.me/codex_console" +} %} + diff --git a/templates/payment.html b/templates/payment.html new file mode 100644 index 00000000..85fd49b7 --- /dev/null +++ b/templates/payment.html @@ -0,0 +1,655 @@ + + + + + + 支付升级 - OpenAI 注册系统 + + + + + + {% include "partials/site_notice.html" %} +
+ + + + +
+ + +
+ + +
+ + + +
+
+
+

绑卡任务列表

+
+
+
+
+ + +
+
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + +
ID邮箱套餐状态Checkout来源创建时间操作
+
加载中...
+
+
+

+ 说明:支持三种流程:半自动(人工提交)、全自动绑卡(本地浏览器执行)、 + 第三方自动绑卡(外部 API 提交);系统会在任务列表中自动跟踪订阅回调状态。 +

+
+
+
+
+
+ + + + + diff --git a/templates/settings.html b/templates/settings.html new file mode 100644 index 00000000..051ea0ea --- /dev/null +++ b/templates/settings.html @@ -0,0 +1,600 @@ + + + + + + 系统设置 - OpenAI 注册系统 + + + + + + {% include "partials/site_notice.html" %} +
+ + + + +
+ + + +
+ + + + + + + +
+ + +
+ +
+
+

动态代理配置

+ 通过 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 则使用此默认值。

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

注册配置

+
+
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+
+ + + 切换后将影响后续新建注册任务的入口流程;Outlook 邮箱会自动使用 Outlook 链路,无需手动设置 +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+ +
+
+
+
+
+ + +
+
+
+

验证码等待配置

+ 配置 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 00000000..82125bca --- /dev/null +++ b/tests/test_cpa_upload.py @@ -0,0 +1,110 @@ +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" diff --git a/tests/test_duck_mail_service.py b/tests/test_duck_mail_service.py new file mode 100644 index 00000000..3767f894 --- /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 00000000..b8878e59 --- /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_registration_engine.py b/tests/test_registration_engine.py new file mode 100644 index 00000000..e662b8f9 --- /dev/null +++ b/tests/test_registration_engine.py @@ -0,0 +1,296 @@ +import base64 +import json + +from src.config.constants import EmailServiceType, OPENAI_API_ENDPOINTS, OPENAI_PAGE_TYPES +from src.core.http_client import OpenAIHTTPClient +from src.core.openai.oauth import OAuthStart +from src.core.register import RegistrationEngine +from src.services.base import BaseEmailService + + +class DummyResponse: + def __init__(self, status_code=200, payload=None, text="", headers=None, on_return=None): + self.status_code = status_code + self._payload = payload + self.text = text + self.headers = headers or {} + self.on_return = on_return + + def json(self): + if self._payload is None: + raise ValueError("no json payload") + return self._payload + + +class QueueSession: + def __init__(self, steps): + self.steps = list(steps) + self.calls = [] + self.cookies = {} + + def get(self, url, **kwargs): + return self._request("GET", url, **kwargs) + + def post(self, url, **kwargs): + return self._request("POST", url, **kwargs) + + def request(self, method, url, **kwargs): + return self._request(method.upper(), url, **kwargs) + + def close(self): + return None + + def _request(self, method, url, **kwargs): + self.calls.append({ + "method": method, + "url": url, + "kwargs": kwargs, + }) + if not self.steps: + raise AssertionError(f"unexpected request: {method} {url}") + expected_method, expected_url, response = self.steps.pop(0) + assert method == expected_method + assert url == expected_url + if callable(response): + response = response(self) + if response.on_return: + response.on_return(self) + return response + + +class FakeEmailService(BaseEmailService): + def __init__(self, codes): + super().__init__(EmailServiceType.TEMPMAIL) + self.codes = list(codes) + self.otp_requests = [] + + def create_email(self, config=None): + return { + "email": "tester@example.com", + "service_id": "mailbox-1", + } + + def get_verification_code(self, email, email_id=None, timeout=120, pattern=r"(?i.map(i=>d[i]); +var Xn=Object.defineProperty;var Ct=e=>{throw TypeError(e)};var Zn=(e,t,n)=>t in e?Xn(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n;var L=(e,t,n)=>Zn(e,typeof t!="symbol"?t+"":t,n),pt=(e,t,n)=>t.has(e)||Ct("Cannot "+n);var je=(e,t,n)=>(pt(e,t,"read from private field"),n?n.call(e):t.get(e)),gt=(e,t,n)=>t.has(e)?Ct("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,n),At=(e,t,n,a)=>(pt(e,t,"write to private field"),a?a.call(e,n):t.set(e,n),n);import{a as st,b as He,r as g,j,s as se}from"./statsig-CDDMC_8Z.js";import{z as r}from"./zod-BeXw7Xwl.js";import{d as H,D as Qn,_ as tn}from"./datadog-BTgRrIvw.js";import{AnalyticsBrowser as er}from"./segment-D0siyHcc.js";import{r as tr,b as nr,c as rr,d as ar,e as ir,i as sr}from"./react-router-Dv7ITjcY.js";import{u as or,b as _r}from"./app-components-DjlQZIFe.js";import{d as cr,a as nn,u as ur}from"./react-intl-CIPQd2Ij.js";import{s as lr,b as Er,a as dr}from"./app-components-old-app-DTUyxsIZ.js";import{j as Sr,k as Cr,l as pr}from"./phone-number-C6DHDTQA.js";import{U as W}from"./styles-BHk20sZa.js";var rn=typeof global=="object"&&global&&global.Object===Object&&global,gr=typeof self=="object"&&self&&self.Object===Object&&self,x=rn||gr||Function("return this")(),de=x.Symbol,an=Object.prototype,Ar=an.hasOwnProperty,hr=an.toString,Ae=de?de.toStringTag:void 0;function fr(e){var t=Ar.call(e,Ae),n=e[Ae];try{e[Ae]=void 0;var a=!0}catch{}var s=hr.call(e);return a&&(t?e[Ae]=n:delete e[Ae]),s}var Tr=Object.prototype,Or=Tr.toString;function mr(e){return Or.call(e)}var Ir="[object Null]",Nr="[object Undefined]",ht=de?de.toStringTag:void 0;function te(e){return e==null?e===void 0?Nr:Ir:ht&&ht in Object(e)?fr(e):mr(e)}function Q(e){return e!=null&&typeof e=="object"}var me=Array.isArray;function sn(e){var t=typeof e;return e!=null&&(t=="object"||t=="function")}var Pr="[object AsyncFunction]",Lr="[object Function]",br="[object GeneratorFunction]",Rr="[object Proxy]";function on(e){if(!sn(e))return!1;var t=te(e);return t==Lr||t==br||t==Pr||t==Rr}var Ve=x["__core-js_shared__"],ft=function(){var e=/[^.]+$/.exec(Ve&&Ve.keys&&Ve.keys.IE_PROTO||"");return e?"Symbol(src)_1."+e:""}();function yr(e){return!!ft&&ft in e}var vr=Function.prototype,wr=vr.toString;function ce(e){if(e!=null){try{return wr.call(e)}catch{}try{return e+""}catch{}}return""}var Ur=/[\\^$.*+?()[\]{}|]/g,Dr=/^\[object .+?Constructor\]$/,Wr=Function.prototype,Fr=Object.prototype,Mr=Wr.toString,Gr=Fr.hasOwnProperty,Yr=RegExp("^"+Mr.call(Gr).replace(Ur,"\\$&").replace(/hasOwnProperty|(function).*?(?=\\\()| for .+?(?=\\\])/g,"$1.*?")+"$");function kr(e){if(!sn(e)||yr(e))return!1;var t=on(e)?Yr:Dr;return t.test(ce(e))}function jr(e,t){return e==null?void 0:e[t]}function Ce(e,t){var n=jr(e,t);return kr(n)?n:void 0}var Qe=Ce(x,"WeakMap");function et(){}var Hr=9007199254740991,Vr=/^(?:0|[1-9]\d*)$/;function xr(e,t){var n=typeof e;return t=t??Hr,!!t&&(n=="number"||n!="symbol"&&Vr.test(e))&&e>-1&&e%1==0&&e-1&&e%1==0&&e<=Br}function Kr(e){return e!=null&&cn(e.length)&&!on(e)}var $r=Object.prototype;function qr(e){var t=e&&e.constructor,n=typeof t=="function"&&t.prototype||$r;return e===n}function zr(e,t){for(var n=-1,a=Array(e);++n-1}function ti(e,t){var n=this.__data__,a=We(n,e);return a<0?(++this.size,n.push([e,t])):n[a][1]=t,this}function B(e){var t=-1,n=e==null?0:e.length;for(this.clear();++tc))return!1;var l=i.get(e),C=i.get(t);if(l&&C)return l==t&&C==e;var p=-1,E=!0,N=n&ji?new De:void 0;for(i.set(e,t),i.set(t,e);++p{setTimeout(()=>{s(new An)},t)});return Promise.race([e,n])}function hn(){return typeof document>"u"}function Pc(e){if(!("data"in e))return;const t=e.data;typeof t=="string"&&t.trim().match(/\D/)&&e.preventDefault()}function Lc(e,t=()=>new Error(`Invalid value: ${e}`)){throw t()}async function fn(e){const t=typeof e=="function"?e:()=>e,n=performance.now(),a=await t(),i=performance.now()-n;return{result:a,durationMs:i}}var Ee;class ps{constructor(){gt(this,Ee,!1)}initialize(){it()||je(this,Ee)||(H.init({applicationId:"a10b421a-4c98-4e3a-9b20-89ebbf54befb",clientToken:"pub87c677d9c9bf34b387f60a689c1990a6",site:"datadoghq.com",service:"login-web",env:"prod",version:"f305bca6afc89159e811fd824396d6daaf880760",sessionSampleRate:100,sessionReplaySampleRate:1,trackUserInteractions:!0,trackResources:!0,trackLongTasks:!0,allowedTracingUrls:[location.origin],defaultPrivacyLevel:Qn.MASK_USER_INPUT,trackViewsManually:!0,trackFeatureFlagsForEvents:["vital","action","long_task","resource"],beforeSend:t=>!(t.type==="error"&&Ts(t.error))}),At(this,Ee,!0))}setContext({clientId:t,appNameEnum:n,sessionLoggingId:a,track:s,deviceId:i}){this.ensureInitializationBefore(()=>{H.setGlobalContext({clientId:t,appNameEnum:n,sessionLoggingId:a,track:s,deviceId:i,usesCdn:"f305bca6afc89159e811fd824396d6daaf880760".endsWith("-with_cdn")})})}addAction(t,n){this.ensureInitializationBefore(()=>{H.addAction(t,n)})}addError(t,n){this.ensureInitializationBefore(()=>{H.addError(t,n)})}addTiming(t,n){this.ensureInitializationBefore(()=>{H.addTiming(t,n)})}addPageView({name:t,routeId:n,isError:a}){this.ensureInitializationBefore(()=>{H.startView({name:t,context:{routeId:n,isError:a}})})}addFeatureFlagEvaluation(t,n){this.ensureInitializationBefore(()=>{H.addFeatureFlagEvaluation(t,n)})}startDurationVital(t,n){this.ensureInitializationBefore(()=>{H.startDurationVital(t,n)})}stopDurationVital(t,n){this.ensureInitializationBefore(()=>{H.stopDurationVital(t,n)})}async trackAsDurationVital(t,n,a){this.startDurationVital(t,a);try{return await n()}finally{this.stopDurationVital(t)}}ensureInitializationBefore(t){it()||(je(this,Ee)||this.initialize(),t())}}Ee=new WeakMap;const m=new ps,gs=["Fetch is aborted","Load failed","ResizeObserver loop completed with undelivered notifications.","Object Not Found Matching Id","Object.hasOwn is not a function","csp_violation: 'eval' blocked","Web translate error: received error callback from translatePage"],As=["font-src"],Ke=[],hs=100,fs=500,Ts=e=>{var s;if(Ke.length>=fs||(s=e.stack)!=null&&s.includes("-extension://")||gs.some(i=>{var o;return(o=e.message)==null?void 0:o.includes(i)})||As.some(i=>{var o;return(o=e.type)==null?void 0:o.includes(i)}))return!0;const t=Date.now();Ke.push(t);const n=t-1e3*60;return Ke.filter(i=>i=hs};let ve;const Os=new Uint8Array(16);function ms(){if(!ve&&(ve=typeof crypto<"u"&&crypto.getRandomValues&&crypto.getRandomValues.bind(crypto),!ve))throw new Error("crypto.getRandomValues() not supported. See https://github.com/uuidjs/uuid#getrandomvalues-not-supported");return ve(Os)}const R=[];for(let e=0;e<256;++e)R.push((e+256).toString(16).slice(1));function Is(e,t=0){return R[e[t+0]]+R[e[t+1]]+R[e[t+2]]+R[e[t+3]]+"-"+R[e[t+4]]+R[e[t+5]]+"-"+R[e[t+6]]+R[e[t+7]]+"-"+R[e[t+8]]+R[e[t+9]]+"-"+R[e[t+10]]+R[e[t+11]]+R[e[t+12]]+R[e[t+13]]+R[e[t+14]]+R[e[t+15]]}const Ns=typeof crypto<"u"&&crypto.randomUUID&&crypto.randomUUID.bind(crypto),Gt={randomUUID:Ns};function Ps(e,t,n){if(Gt.randomUUID&&!t&&!e)return Gt.randomUUID();e=e||{};const a=e.random||(e.rng||ms)();return a[6]=a[6]&15|64,a[8]=a[8]&63|128,Is(a)}function bc(e){return new URLSearchParams(window.location.search).get(e)}function Ls(e){const t=document.cookie.split(";");for(let n=0;nst.StatsigClient.instance(Tn);async function Rc(e=1e3){try{const t=J();await Promise.race([t.flush(),new Promise(n=>setTimeout(n,e))])}catch{}}async function yc({clientId:e,appNameEnum:t,originator:n,sessionLoggingId:a,statsigBootstrap:s}){if(new st.StatsigClient(Tn,{locale:navigator.language,appVersion:"f305bca6afc89159e811fd824396d6daaf880760",userAgent:navigator.userAgent,customIDs:{[Rs]:Oe,[ys]:Oe,stableID:Oe},custom:{client_id:e,app_name_enum:t,originator:n,[vs]:a,client_type:"web"}},{environment:{tier:ws()},plugins:[new He.StatsigAutoCapturePlugin({eventFilterFunc:i=>i.eventName===He.AutoCaptureEventName.PERFORMANCE||i.eventName===He.AutoCaptureEventName.WEB_VITALS})]}),J().on("gate_evaluation",({gate:i})=>{$e("gate",i.name,i.value)}),J().on("experiment_evaluation",({experiment:i})=>{$e("experiment",i.name,i.value)}),J().on("layer_evaluation",({layer:i})=>{i.__value&&$e("layer",i.name,i.__value)}),s){J().dataAdapter.setData(s),J().initializeSync();return}await J().initializeAsync()}function T(e){J().logEvent(e.eventName,e.value,e.metadata)}function $e(e,t,n){if(n&&pi(n))for(const[a,s]of Object.entries(n))m.addFeatureFlagEvaluation(`${e}__${t}__${a}`,s);else m.addFeatureFlagEvaluation(`${e}__${t}`,n)}const _t=g.createContext({getHasAnyErrorView:()=>!1,registerErrorView:et,unregisterErrorView:et});function vc(){const{getHasAnyErrorView:e}=g.useContext(_t);return e}function wc(){const e=g.useId(),{registerErrorView:t,unregisterErrorView:n}=g.useContext(_t),[a]=g.useState(()=>ot(t));a(e),g.useEffect(()=>()=>{n(e)},[e,n])}const Uc=({children:e})=>{const t=g.useRef(new Set),n=g.useCallback(i=>{t.current.add(i)},[]),a=g.useCallback(i=>{t.current.delete(i)},[]),s=g.useCallback(()=>t.current.size>0,[]);return j.jsx(_t.Provider,{value:{getHasAnyErrorView:s,registerErrorView:n,unregisterErrorView:a},children:e})},Us=r.enum(["api","apple","chat","sora","sora2","juno","tailoros","jam","teammate","sky"]),On=g.createContext(null),Dc=({appNameEnum:e,children:t})=>{const n=Us.safeParse(e);return j.jsx(On.Provider,{value:n.success?n.data:null,children:t})};function Wc(){return g.useContext(On)}let mn=()=>{throw new Error("getUserScopedGlobals not yet registered")};const Fc=e=>{mn=e},k=()=>mn();class Mc{constructor(){L(this,"latestClientAuthSessionCacheEntry",null)}read(){return this.latestClientAuthSessionCacheEntry}readByChecksum(t){var n;return((n=this.latestClientAuthSessionCacheEntry)==null?void 0:n.checksum)===t?this.latestClientAuthSessionCacheEntry:null}write(t){return this.latestClientAuthSessionCacheEntry=t,t}writeFromDumpResponse(t){return this.write({checksum:t.checksum,sessionId:t.session_id,session:t.client_auth_session})}clear(){this.latestClientAuthSessionCacheEntry=null}}class he extends Error{}he.prototype.name="InvalidTokenError";function Ds(e){return decodeURIComponent(atob(e).replace(/(.)/g,(t,n)=>{let a=n.charCodeAt(0).toString(16).toUpperCase();return a.length<2&&(a="0"+a),"%"+a}))}function Ws(e){let t=e.replace(/-/g,"+").replace(/_/g,"/");switch(t.length%4){case 0:break;case 2:t+="==";break;case 3:t+="=";break;default:throw new Error("base64 string is not of the correct length")}try{return Ds(t)}catch{return atob(t)}}function In(e,t){if(typeof e!="string")throw new he("Invalid token specified: must be a string");t||(t={});const n=t.header===!0?0:1,a=e.split(".")[n];if(typeof a!="string")throw new he(`Invalid token specified: missing part #${n+1}`);let s;try{s=Ws(a)}catch(i){throw new he(`Invalid token specified: invalid base64 for part #${n+1} (${i.message})`)}try{return JSON.parse(s)}catch(i){throw new he(`Invalid token specified: invalid json for part #${n+1} (${i.message})`)}}const Fs=Cr().map(e=>r.literal(e)),Ms=r.union(Fs),Y=r.enum(["totp","recovery_code","email","sms","push_auth","passkey"]),Nn=r.discriminatedUnion("factor_type",[r.object({id:r.string(),factor_type:r.literal(Y.enum.recovery_code)}),r.object({id:r.string(),factor_type:r.literal(Y.enum.totp)}),r.object({id:r.string(),factor_type:r.literal(Y.enum.email),metadata:r.object({email:r.string()})}),r.object({id:r.string(),factor_type:r.literal(Y.enum.sms),metadata:r.object({phone_number:r.string(),verified_channels:r.array(r.string())})}),r.object({id:r.string(),factor_type:r.literal(Y.enum.push_auth),metadata:r.object({mfa_request_id:r.string().optional().nullable()}).nullable()}),r.object({id:r.string(),factor_type:r.literal(Y.enum.passkey)})]);r.object({factor_id:r.string(),factors:r.array(Nn),errors:r.object({formErrors:r.optional(r.array(r.string())),fieldErrors:r.optional(r.object({code:r.optional(r.array(r.string()))}))}).optional()});const Pn=r.discriminatedUnion("factor_type",[r.object({id:r.string(),factor_type:r.literal(Y.enum.recovery_code),metadata:r.object({secret:r.string()})}),r.object({id:r.string(),factor_type:r.literal(Y.enum.totp),metadata:r.object({secret:r.string(),qr_code_secret_url:r.string()})})]);r.object({factor_id:r.string(),factors:r.array(Pn),errors:r.object({formErrors:r.optional(r.array(r.string())),fieldErrors:r.optional(r.object({code:r.optional(r.array(r.string()))}))}).optional()});const Gs=r.enum(["whatsapp"]),ee=r.enum(["email","phone_number"]),Gc=ee.enum.email,$=r.object({value:r.string(),kind:ee}),Yc=e=>e===ee.enum.email||e===ee.enum.phone_number;var F=(e=>(e.ACCESS_FLOW_USER_ACTION_TYPE_UNSPECIFIED="ACCESS_FLOW_USER_ACTION_TYPE_UNSPECIFIED",e.ACCESS_FLOW_USER_ACTION_TYPE_CONTINUE="ACCESS_FLOW_USER_ACTION_TYPE_CONTINUE",e.ACCESS_FLOW_USER_ACTION_TYPE_CONTINUE_WITH_GOOGLE="ACCESS_FLOW_USER_ACTION_TYPE_CONTINUE_WITH_GOOGLE",e.ACCESS_FLOW_USER_ACTION_TYPE_CONTINUE_WITH_MICROSOFT="ACCESS_FLOW_USER_ACTION_TYPE_CONTINUE_WITH_MICROSOFT",e.ACCESS_FLOW_USER_ACTION_TYPE_CONTINUE_WITH_APPLE="ACCESS_FLOW_USER_ACTION_TYPE_CONTINUE_WITH_APPLE",e.ACCESS_FLOW_USER_ACTION_TYPE_CONTINUE_WITH_SSO="ACCESS_FLOW_USER_ACTION_TYPE_CONTINUE_WITH_SSO",e.ACCESS_FLOW_USER_ACTION_TYPE_CONTINUE_WITH_PASSWORD="ACCESS_FLOW_USER_ACTION_TYPE_CONTINUE_WITH_PASSWORD",e.ACCESS_FLOW_USER_ACTION_TYPE_PREVIOUS_STEP="ACCESS_FLOW_USER_ACTION_TYPE_PREVIOUS_STEP",e.ACCESS_FLOW_USER_ACTION_TYPE_EDIT_USERNAME="ACCESS_FLOW_USER_ACTION_TYPE_EDIT_USERNAME",e.ACCESS_FLOW_USER_ACTION_TYPE_EXIT="ACCESS_FLOW_USER_ACTION_TYPE_EXIT",e.ACCESS_FLOW_USER_ACTION_TYPE_EXIT_STAY_LOGGED_OUT_LINK_CLICKED="ACCESS_FLOW_USER_ACTION_TYPE_EXIT_STAY_LOGGED_OUT_LINK_CLICKED",e.ACCESS_FLOW_USER_ACTION_TYPE_EXIT_CLOSE_BUTTON_CLICKED="ACCESS_FLOW_USER_ACTION_TYPE_EXIT_CLOSE_BUTTON_CLICKED",e.ACCESS_FLOW_USER_ACTION_TYPE_RESEND="ACCESS_FLOW_USER_ACTION_TYPE_RESEND",e.ACCESS_FLOW_USER_ACTION_TYPE_INPUT="ACCESS_FLOW_USER_ACTION_TYPE_INPUT",e.ACCESS_FLOW_USER_ACTION_TYPE_AUTOFILL="ACCESS_FLOW_USER_ACTION_TYPE_AUTOFILL",e.ACCESS_FLOW_USER_ACTION_TYPE_SWITCH_TO_EMAIL="ACCESS_FLOW_USER_ACTION_TYPE_SWITCH_TO_EMAIL",e.ACCESS_FLOW_USER_ACTION_TYPE_SWITCH_TO_PHONE="ACCESS_FLOW_USER_ACTION_TYPE_SWITCH_TO_PHONE",e.ACCESS_FLOW_USER_ACTION_TYPE_FORGOT_PASSWORD="ACCESS_FLOW_USER_ACTION_TYPE_FORGOT_PASSWORD",e.ACCESS_FLOW_USER_ACTION_TYPE_CONSUME_OTP_DEEPLINK="ACCESS_FLOW_USER_ACTION_TYPE_CONSUME_OTP_DEEPLINK",e.ACCESS_FLOW_USER_ACTION_TYPE_CONSUME_MFA_DEEPLINK="ACCESS_FLOW_USER_ACTION_TYPE_CONSUME_MFA_DEEPLINK",e.ACCESS_FLOW_USER_ACTION_TYPE_COPY_RECOVERY_CODE="ACCESS_FLOW_USER_ACTION_TYPE_COPY_RECOVERY_CODE",e.ACCESS_FLOW_USER_ACTION_TYPE_MFA_SELECT_TOTP_CODE="ACCESS_FLOW_USER_ACTION_TYPE_MFA_SELECT_TOTP_CODE",e.ACCESS_FLOW_USER_ACTION_TYPE_MFA_SELECT_SMS_CODE="ACCESS_FLOW_USER_ACTION_TYPE_MFA_SELECT_SMS_CODE",e.ACCESS_FLOW_USER_ACTION_TYPE_MFA_SELECT_EMAIL_CODE="ACCESS_FLOW_USER_ACTION_TYPE_MFA_SELECT_EMAIL_CODE",e.ACCESS_FLOW_USER_ACTION_TYPE_MFA_SELECT_RECOVERY_CODE="ACCESS_FLOW_USER_ACTION_TYPE_MFA_SELECT_RECOVERY_CODE",e.ACCESS_FLOW_USER_ACTION_TYPE_MFA_SELECT_PUSH_AUTH_CODE="ACCESS_FLOW_USER_ACTION_TYPE_MFA_SELECT_PUSH_AUTH_CODE",e.ACCESS_FLOW_USER_ACTION_TYPE_MFA_SELECT_UNKNOWN_FACTOR="ACCESS_FLOW_USER_ACTION_TYPE_MFA_SELECT_UNKNOWN_FACTOR",e.ACCESS_FLOW_USER_ACTION_TYPE_LOGIN_INSTEAD_LINK_CLICKED="ACCESS_FLOW_USER_ACTION_TYPE_LOGIN_INSTEAD_LINK_CLICKED",e.ACCESS_FLOW_USER_ACTION_TYPE_SIGNUP_INSTEAD_LINK_CLICKED="ACCESS_FLOW_USER_ACTION_TYPE_SIGNUP_INSTEAD_LINK_CLICKED",e.ACCESS_FLOW_USER_ACTION_TYPE_TERMS_OF_USE_CLICK="ACCESS_FLOW_USER_ACTION_TYPE_TERMS_OF_USE_CLICK",e.ACCESS_FLOW_USER_ACTION_TYPE_PRIVACY_POLICY_CLICK="ACCESS_FLOW_USER_ACTION_TYPE_PRIVACY_POLICY_CLICK",e.ACCESS_FLOW_USER_ACTION_TYPE_WORDMARK_CLICKED="ACCESS_FLOW_USER_ACTION_TYPE_WORDMARK_CLICKED",e.ACCESS_FLOW_USER_ACTION_TYPE_SEND_EMAIL_OTP="ACCESS_FLOW_USER_ACTION_TYPE_SEND_EMAIL_OTP",e.ACCESS_FLOW_USER_ACTION_TYPE_CANCEL_WEB_FLOW="ACCESS_FLOW_USER_ACTION_TYPE_CANCEL_WEB_FLOW",e.ACCESS_FLOW_USER_ACTION_TYPE_LOGIN_WITH_ONE_TIME_CODE="ACCESS_FLOW_USER_ACTION_TYPE_LOGIN_WITH_ONE_TIME_CODE",e.ACCESS_FLOW_USER_ACTION_TYPE_SIGNUP_WITH_ONE_TIME_CODE="ACCESS_FLOW_USER_ACTION_TYPE_SIGNUP_WITH_ONE_TIME_CODE",e.ACCESS_FLOW_USER_ACTION_TYPE_BACK_TO_LOGIN="ACCESS_FLOW_USER_ACTION_TYPE_BACK_TO_LOGIN",e.ACCESS_FLOW_USER_ACTION_TYPE_MFA_SELECT_PASSKEY="ACCESS_FLOW_USER_ACTION_TYPE_MFA_SELECT_PASSKEY",e.ACCESS_FLOW_USER_ACTION_TYPE_ACQUIRE_AUTOFILL_CREDENTIALS="ACCESS_FLOW_USER_ACTION_TYPE_ACQUIRE_AUTOFILL_CREDENTIALS",e.ACCESS_FLOW_USER_ACTION_TYPE_STORE_AUTOFILL_CREDENTIALS="ACCESS_FLOW_USER_ACTION_TYPE_STORE_AUTOFILL_CREDENTIALS",e.ACCESS_FLOW_USER_ACTION_TYPE_NATIVE_FOREGROUND="ACCESS_FLOW_USER_ACTION_TYPE_NATIVE_FOREGROUND",e.ACCESS_FLOW_USER_ACTION_TYPE_NATIVE_BACKGROUND="ACCESS_FLOW_USER_ACTION_TYPE_NATIVE_BACKGROUND",e.ACCESS_FLOW_USER_ACTION_TYPE_REMOVE_SAVED_ACCOUNT_CLICKED="ACCESS_FLOW_USER_ACTION_TYPE_REMOVE_SAVED_ACCOUNT_CLICKED",e.ACCESS_FLOW_USER_ACTION_TYPE_SWITCH_TO_GENERIC_LOGIN_CLICKED="ACCESS_FLOW_USER_ACTION_TYPE_SWITCH_TO_GENERIC_LOGIN_CLICKED",e.ACCESS_FLOW_USER_ACTION_TYPE_TRY_ANOTHER_WAY_FROM_PASSKEY="ACCESS_FLOW_USER_ACTION_TYPE_TRY_ANOTHER_WAY_FROM_PASSKEY",e.ACCESS_FLOW_USER_ACTION_TYPE_EXIT_ESCAPE_KEY_PRESSED="ACCESS_FLOW_USER_ACTION_TYPE_EXIT_ESCAPE_KEY_PRESSED",e.ACCESS_FLOW_USER_ACTION_TYPE_EXIT_CLOSE_LINK_CLICKED="ACCESS_FLOW_USER_ACTION_TYPE_EXIT_CLOSE_LINK_CLICKED",e.ACCESS_FLOW_USER_ACTION_TYPE_EXIT_BACKDROP_CLICKED="ACCESS_FLOW_USER_ACTION_TYPE_EXIT_BACKDROP_CLICKED",e.ACCESS_FLOW_USER_ACTION_TYPE_SWITCH_TO_AGE="ACCESS_FLOW_USER_ACTION_TYPE_SWITCH_TO_AGE",e.ACCESS_FLOW_USER_ACTION_TYPE_SWITCH_TO_DATE_OF_BIRTH="ACCESS_FLOW_USER_ACTION_TYPE_SWITCH_TO_DATE_OF_BIRTH",e.ACCESS_FLOW_USER_ACTION_TYPE_SHOW_AGE_FALLBACK_CONFIRMATION="ACCESS_FLOW_USER_ACTION_TYPE_SHOW_AGE_FALLBACK_CONFIRMATION",e.ACCESS_FLOW_USER_ACTION_TYPE_CONFIRM_AGE_FALLBACK="ACCESS_FLOW_USER_ACTION_TYPE_CONFIRM_AGE_FALLBACK",e.ACCESS_FLOW_USER_ACTION_TYPE_CANCEL_AGE_FALLBACK_CONFIRMATION="ACCESS_FLOW_USER_ACTION_TYPE_CANCEL_AGE_FALLBACK_CONFIRMATION",e.UNRECOGNIZED="UNRECOGNIZED",e))(F||{}),fe=(e=>(e.ACCESS_FLOW_FIELD_UNSPECIFIED="ACCESS_FLOW_FIELD_UNSPECIFIED",e.ACCESS_FLOW_FIELD_EMAIL="ACCESS_FLOW_FIELD_EMAIL",e.ACCESS_FLOW_FIELD_PHONE="ACCESS_FLOW_FIELD_PHONE",e.ACCESS_FLOW_FIELD_COUNTRY_CODE="ACCESS_FLOW_FIELD_COUNTRY_CODE",e.ACCESS_FLOW_FIELD_PASSWORD="ACCESS_FLOW_FIELD_PASSWORD",e.ACCESS_FLOW_FIELD_OTP="ACCESS_FLOW_FIELD_OTP",e.ACCESS_FLOW_FIELD_NAME="ACCESS_FLOW_FIELD_NAME",e.ACCESS_FLOW_FIELD_BIRTHDAY="ACCESS_FLOW_FIELD_BIRTHDAY",e.ACCESS_FLOW_FIELD_CONFIRM_PASSWORD="ACCESS_FLOW_FIELD_CONFIRM_PASSWORD",e.UNRECOGNIZED="UNRECOGNIZED",e))(fe||{}),A=(e=>(e.ACCESS_FLOW_PAGE_TYPE_UNSPECIFIED="ACCESS_FLOW_PAGE_TYPE_UNSPECIFIED",e.ACCESS_FLOW_PAGE_TYPE_ABOUT_YOU="ACCESS_FLOW_PAGE_TYPE_ABOUT_YOU",e.ACCESS_FLOW_PAGE_TYPE_ADD_EMAIL="ACCESS_FLOW_PAGE_TYPE_ADD_EMAIL",e.ACCESS_FLOW_PAGE_TYPE_APPLE_INTELLIGENCE_START="ACCESS_FLOW_PAGE_TYPE_APPLE_INTELLIGENCE_START",e.ACCESS_FLOW_PAGE_TYPE_CONTACT_VERIFICATION="ACCESS_FLOW_PAGE_TYPE_CONTACT_VERIFICATION",e.ACCESS_FLOW_PAGE_TYPE_CREATE_ACCOUNT_LATER="ACCESS_FLOW_PAGE_TYPE_CREATE_ACCOUNT_LATER",e.ACCESS_FLOW_PAGE_TYPE_CREATE_ACCOUNT_LATER_QUEUED="ACCESS_FLOW_PAGE_TYPE_CREATE_ACCOUNT_LATER_QUEUED",e.ACCESS_FLOW_PAGE_TYPE_CREATE_ACCOUNT_PASSWORD="ACCESS_FLOW_PAGE_TYPE_CREATE_ACCOUNT_PASSWORD",e.ACCESS_FLOW_PAGE_TYPE_CREATE_ACCOUNT_START="ACCESS_FLOW_PAGE_TYPE_CREATE_ACCOUNT_START",e.ACCESS_FLOW_PAGE_TYPE_EMAIL_OTP_SEND="ACCESS_FLOW_PAGE_TYPE_EMAIL_OTP_SEND",e.ACCESS_FLOW_PAGE_TYPE_EMAIL_OTP_VERIFICATION="ACCESS_FLOW_PAGE_TYPE_EMAIL_OTP_VERIFICATION",e.ACCESS_FLOW_PAGE_TYPE_ERROR="ACCESS_FLOW_PAGE_TYPE_ERROR",e.ACCESS_FLOW_PAGE_TYPE_EXTERNAL_URL="ACCESS_FLOW_PAGE_TYPE_EXTERNAL_URL",e.ACCESS_FLOW_PAGE_TYPE_IDENTITY_VERIFICATION="ACCESS_FLOW_PAGE_TYPE_IDENTITY_VERIFICATION",e.ACCESS_FLOW_PAGE_TYPE_CHATGPT_WEB_LOGIN_MODAL="ACCESS_FLOW_PAGE_TYPE_CHATGPT_WEB_LOGIN_MODAL",e.ACCESS_FLOW_PAGE_TYPE_CHATGPT_WEB_LOG_BACK_IN_MODAL="ACCESS_FLOW_PAGE_TYPE_CHATGPT_WEB_LOG_BACK_IN_MODAL",e.ACCESS_FLOW_PAGE_TYPE_LOGIN_OR_SIGNUP_START="ACCESS_FLOW_PAGE_TYPE_LOGIN_OR_SIGNUP_START",e.ACCESS_FLOW_PAGE_TYPE_LOGIN_PASSWORD="ACCESS_FLOW_PAGE_TYPE_LOGIN_PASSWORD",e.ACCESS_FLOW_PAGE_TYPE_LOGIN_START="ACCESS_FLOW_PAGE_TYPE_LOGIN_START",e.ACCESS_FLOW_PAGE_TYPE_MFA_CHALLENGE="ACCESS_FLOW_PAGE_TYPE_MFA_CHALLENGE",e.ACCESS_FLOW_PAGE_TYPE_MFA_CHALLENGE_SELECTION="ACCESS_FLOW_PAGE_TYPE_MFA_CHALLENGE_SELECTION",e.ACCESS_FLOW_PAGE_TYPE_MFA_ENROLL="ACCESS_FLOW_PAGE_TYPE_MFA_ENROLL",e.ACCESS_FLOW_PAGE_TYPE_PHONE_OTP_SEND="ACCESS_FLOW_PAGE_TYPE_PHONE_OTP_SEND",e.ACCESS_FLOW_PAGE_TYPE_PUSH_AUTH_VERIFICATION="ACCESS_FLOW_PAGE_TYPE_PUSH_AUTH_VERIFICATION",e.ACCESS_FLOW_PAGE_TYPE_RESET_PASSWORD_START="ACCESS_FLOW_PAGE_TYPE_RESET_PASSWORD_START",e.ACCESS_FLOW_PAGE_TYPE_RESET_PASSWORD_NEW_PASSWORD="ACCESS_FLOW_PAGE_TYPE_RESET_PASSWORD_NEW_PASSWORD",e.ACCESS_FLOW_PAGE_TYPE_RESET_PASSWORD_SUCCESS="ACCESS_FLOW_PAGE_TYPE_RESET_PASSWORD_SUCCESS",e.ACCESS_FLOW_PAGE_TYPE_SIGN_IN_WITH_CHATGPT_CONSENT="ACCESS_FLOW_PAGE_TYPE_SIGN_IN_WITH_CHATGPT_CONSENT",e.ACCESS_FLOW_PAGE_TYPE_SIGN_IN_WITH_CHATGPT_ORG="ACCESS_FLOW_PAGE_TYPE_SIGN_IN_WITH_CHATGPT_ORG",e.ACCESS_FLOW_PAGE_TYPE_SIGN_IN_WITH_CHATGPT_CODEX_CONSENT="ACCESS_FLOW_PAGE_TYPE_SIGN_IN_WITH_CHATGPT_CODEX_CONSENT",e.ACCESS_FLOW_PAGE_TYPE_SIGN_IN_WITH_CHATGPT_CODEX_ORG="ACCESS_FLOW_PAGE_TYPE_SIGN_IN_WITH_CHATGPT_CODEX_ORG",e.ACCESS_FLOW_PAGE_TYPE_SSO="ACCESS_FLOW_PAGE_TYPE_SSO",e.ACCESS_FLOW_PAGE_TYPE_TOKEN_EXCHANGE="ACCESS_FLOW_PAGE_TYPE_TOKEN_EXCHANGE",e.ACCESS_FLOW_PAGE_TYPE_WORKSPACE="ACCESS_FLOW_PAGE_TYPE_WORKSPACE",e.ACCESS_FLOW_PAGE_TYPE_END="ACCESS_FLOW_PAGE_TYPE_END",e.ACCESS_FLOW_PAGE_TYPE_MOBILE_LEGACY_FINISH_ONBOARDING="ACCESS_FLOW_PAGE_TYPE_MOBILE_LEGACY_FINISH_ONBOARDING",e.ACCESS_FLOW_PAGE_TYPE_CHATGPT_WEB_LOGIN_PAGE="ACCESS_FLOW_PAGE_TYPE_CHATGPT_WEB_LOGIN_PAGE",e.ACCESS_FLOW_PAGE_TYPE_LOGIN_PASSKEY="ACCESS_FLOW_PAGE_TYPE_LOGIN_PASSKEY",e.ACCESS_FLOW_PAGE_TYPE_EMAIL_ROLLBACK_CONFIRM="ACCESS_FLOW_PAGE_TYPE_EMAIL_ROLLBACK_CONFIRM",e.UNRECOGNIZED="UNRECOGNIZED",e))(A||{}),Ln=(e=>(e.ACCESS_FLOW_VALIDATION_TYPE_UNSPECIFIED="ACCESS_FLOW_VALIDATION_TYPE_UNSPECIFIED",e.ACCESS_FLOW_VALIDATION_TYPE_BACKEND_API="ACCESS_FLOW_VALIDATION_TYPE_BACKEND_API",e.ACCESS_FLOW_VALIDATION_TYPE_CLIENT="ACCESS_FLOW_VALIDATION_TYPE_CLIENT",e.UNRECOGNIZED="UNRECOGNIZED",e))(Ln||{}),Pe=(e=>(e.ACCESS_FLOW_API_STATUS_UNSPECIFIED="ACCESS_FLOW_API_STATUS_UNSPECIFIED",e.ACCESS_FLOW_API_STATUS_SUCCESS="ACCESS_FLOW_API_STATUS_SUCCESS",e.ACCESS_FLOW_API_STATUS_FAILURE="ACCESS_FLOW_API_STATUS_FAILURE",e.UNRECOGNIZED="UNRECOGNIZED",e))(Pe||{});const q={$type:"protobuf_analytics_events.v1.AccessFlowUserAction"},Ys={$type:"protobuf_analytics_events.v1.AccessFlowPageLoad"},ks={$type:"protobuf_analytics_events.v1.AccessFlowValidationError"},bn={$type:"protobuf_analytics_events.v1.AccessFlowApiInvocation"};/*! + * cookie + * Copyright(c) 2012-2014 Roman Shtylman + * Copyright(c) 2015 Douglas Christopher Wilson + * MIT Licensed + */var Rn=js;function js(e,t){if(typeof e!="string")throw new TypeError("argument str must be a string");var n={},a=e.length;if(a<2)return n;var s=t&&t.decode||Hs,i=0,o=0,c=0;do{if(o=e.indexOf("=",i),o===-1)break;if(c=e.indexOf(";",i),c===-1)c=a;else if(o>c){i=e.lastIndexOf(";",o-1)+1;continue}var u=Yt(e,i,o),l=kt(e,o,u),C=e.slice(u,l);if(!n.hasOwnProperty(C)){var p=Yt(e,o+1,c),E=kt(e,c,p);e.charCodeAt(p)===34&&e.charCodeAt(E-1)===34&&(p++,E--);var N=e.slice(p,E);n[C]=Vs(N,s)}i=c+1}while(in;){var a=e.charCodeAt(--t);if(a!==32&&a!==9)return t+1}return n}function Hs(e){return e.indexOf("%")!==-1?decodeURIComponent(e):e}function Vs(e,t){try{return t(e)}catch{return e}}const xs=Object.prototype.toString,Bs=e=>xs.call(e)==="[object Error]",Ks=new Set(["network error","Failed to fetch","NetworkError when attempting to fetch resource.","The Internet connection appears to be offline.","Network request failed","fetch failed","terminated"," A network error occurred.","Network connection lost"]);function $s(e){if(!(e&&Bs(e)&&e.name==="TypeError"&&typeof e.message=="string"))return!1;const{message:n,stack:a}=e;return n==="Load failed"?a===void 0||"__sentry_captured__"in e:n.startsWith("error sending request for url")?!0:Ks.has(n)}function qs(e){if(typeof e=="number"){if(e<0)throw new TypeError("Expected `retries` to be a non-negative number.");if(Number.isNaN(e))throw new TypeError("Expected `retries` to be a valid number or Infinity, got NaN.")}else if(e!==void 0)throw new TypeError("Expected `retries` to be a number or Infinity.")}function we(e,t,{min:n=0,allowInfinity:a=!1}={}){if(t!==void 0){if(typeof t!="number"||Number.isNaN(t))throw new TypeError(`Expected \`${e}\` to be a number${a?" or Infinity":""}.`);if(!a&&!Number.isFinite(t))throw new TypeError(`Expected \`${e}\` to be a finite number.`);if(t{const a=n.retries-(t-1);return Object.freeze({error:e,attemptNumber:t,retriesLeft:a})};function Xs(e,t){const n=t.randomize?Math.random()+1:1;let a=Math.round(n*Math.max(t.minTimeout,1)*t.factor**(e-1));return a=Math.min(a,t.maxTimeout),a}async function Zs(e,t,n,a,s){var p;let i=e;if(i instanceof Error||(i=new TypeError(`Non-error was thrown: "${i}". You should only throw errors.`)),i instanceof zs)throw i.originalError;if(i instanceof TypeError&&!$s(i))throw i;const o=Js(i,t,n);await n.onFailedAttempt(o);const c=Date.now();if(c-a>=s||t>=n.retries+1||!await n.shouldRetry(o))throw i;const u=Xs(t,n),l=s-(c-a);if(l<=0)throw i;const C=Math.min(u,l);C>0&&await new Promise((E,N)=>{var b,D;const f=()=>{var v;clearTimeout(P),(v=n.signal)==null||v.removeEventListener("abort",f),N(n.signal.reason)},P=setTimeout(()=>{var v;(v=n.signal)==null||v.removeEventListener("abort",f),E()},C);n.unref&&((b=P.unref)==null||b.call(P)),(D=n.signal)==null||D.addEventListener("abort",f,{once:!0})}),(p=n.signal)==null||p.throwIfAborted()}async function Qs(e,t={}){var o,c,u;if(t={...t},qs(t.retries),Object.hasOwn(t,"forever"))throw new Error("The `forever` option is no longer supported. For many use-cases, you can set `retries: Infinity` instead.");t.retries??(t.retries=10),t.factor??(t.factor=2),t.minTimeout??(t.minTimeout=1e3),t.maxTimeout??(t.maxTimeout=Number.POSITIVE_INFINITY),t.randomize??(t.randomize=!1),t.onFailedAttempt??(t.onFailedAttempt=()=>{}),t.shouldRetry??(t.shouldRetry=()=>!0),we("factor",t.factor,{min:0,allowInfinity:!1}),we("minTimeout",t.minTimeout,{min:0,allowInfinity:!1}),we("maxTimeout",t.maxTimeout,{min:0,allowInfinity:!0});const n=t.maxRetryTime??Number.POSITIVE_INFINITY;we("maxRetryTime",n,{min:0,allowInfinity:!0}),t.factor>0||(t.factor=1),(o=t.signal)==null||o.throwIfAborted();let a=0;const s=Date.now(),i=n;for(;a(e.CHOOSE_AN_ACCOUNT="CHOOSE_AN_ACCOUNT",e.LEGACY_LOGIN_WEB_PAGE_SPLAT="LEGACY_LOGIN_WEB_PAGE_SPLAT",e.LEGACY_LOGIN_WEB_PAGE_INDEX="LEGACY_LOGIN_WEB_PAGE_INDEX",e.ADVANCED_ACCOUNT_SECURITY_ENROLL="ADVANCED_ACCOUNT_SECURITY_ENROLL",e.ADVANCED_ACCOUNT_SECURITY_SECURE_METHODS="ADVANCED_ACCOUNT_SECURITY_SECURE_METHODS",e.ADVANCED_ACCOUNT_SECURITY_ENROLL_COMPLETE="ADVANCED_ACCOUNT_SECURITY_ENROLL_COMPLETE",e.ADVANCED_ACCOUNT_SECURITY_RECOVERY_KEYS="ADVANCED_ACCOUNT_SECURITY_RECOVERY_KEYS",e.ADVANCED_ACCOUNT_SECURITY_RECOVERY_KEYS_GENERATE="ADVANCED_ACCOUNT_SECURITY_RECOVERY_KEYS_GENERATE",e.ABOUT_YOU="ABOUT_YOU",e.ACCOUNT_CREATION_TEMPORARILY_UNAVAILABLE="ACCOUNT_CREATION_TEMPORARILY_UNAVAILABLE",e.ADD_EMAIL="ADD_EMAIL",e.ADD_PHONE="ADD_PHONE",e.CONTACT_VERIFICATION="CONTACT_VERIFICATION",e.CREATE_ACCOUNT="CREATE_ACCOUNT",e.CREATE_ACCOUNT_PASSWORD="CREATE_ACCOUNT_PASSWORD",e.CREATE_ACCOUNT_LATER="CREATE_ACCOUNT_LATER",e.CREATE_ACCOUNT_LATER_QUEUED="CREATE_ACCOUNT_LATER_QUEUED",e.DEVICE_AUTH_CALLBACK="DEVICE_AUTH_CALLBACK",e.EMAIL_VERIFICATION="EMAIL_VERIFICATION",e.EMAIL_ROLLBACK_SUCCESS="EMAIL_ROLLBACK_SUCCESS",e.EMAIL_ROLLBACK_REJECTED="EMAIL_ROLLBACK_REJECTED",e.EMAIL_ROLLBACK_CONFIRM="EMAIL_ROLLBACK_CONFIRM",e.ERROR="ERROR",e.ADD_PASSWORD_NEW_PASSWORD="ADD_PASSWORD_NEW_PASSWORD",e.LOG_IN="LOG_IN",e.LOG_IN_PASSWORD="LOG_IN_PASSWORD",e.LOG_IN_PASSKEY="LOG_IN_PASSKEY",e.JOIN_WAITLIST="JOIN_WAITLIST",e.JOIN_WAITLIST_ADDED="JOIN_WAITLIST_ADDED",e.AUTH_CHALLENGE_SELECTION="AUTH_CHALLENGE_SELECTION",e.AUTH_CHALLENGE_METHOD="AUTH_CHALLENGE_METHOD",e.MFA_CHALLENGE_SELECTION="MFA_CHALLENGE_SELECTION",e.MFA_CHALLENGE_METHOD="MFA_CHALLENGE_METHOD",e.MFA_ENROLL_METHOD="MFA_ENROLL_METHOD",e.PHONE_VERIFICATION="PHONE_VERIFICATION",e.PASSKEY_ENROLL="PASSKEY_ENROLL",e.PASSKEY_PRF_SYNC="PASSKEY_PRF_SYNC",e.PUSH_AUTH_VERIFICATION="PUSH_AUTH_VERIFICATION",e.RESET_PASSWORD_START="RESET_PASSWORD_START",e.RESET_PASSWORD_NEW_PASSWORD="RESET_PASSWORD_NEW_PASSWORD",e.RESET_PASSWORD_SUCCESS="RESET_PASSWORD_SUCCESS",e.SIGN_IN_WITH_CHATGPT_CODEX_CONSENT="SIGN_IN_WITH_CHATGPT_CODEX_CONSENT",e.SIGN_IN_WITH_CHATGPT_CODEX_ORGANIZATION="SIGN_IN_WITH_CHATGPT_CODEX_ORGANIZATION",e.SIGN_IN_WITH_CHATGPT_CONSENT="SIGN_IN_WITH_CHATGPT_CONSENT",e.SSO_SELECTION="SSO_SELECTION",e.START="START",e.VERIFY_YOUR_IDENTITY="VERIFY_YOUR_IDENTITY",e.WAITLIST="WAITLIST",e.WORKSPACE_SELECTION="WORKSPACE_SELECTION",e.UNIFIED_LOG_IN_OR_SIGN_UP_INPUT="UNIFIED_LOG_IN_OR_SIGN_UP_INPUT",e.UNKNOWN="UNKNOWN",e))(_||{});function no(e){return Object.values(_).includes(e)}function G(e){switch(e){case _.LEGACY_LOGIN_WEB_PAGE_SPLAT:case _.LEGACY_LOGIN_WEB_PAGE_INDEX:case _.DEVICE_AUTH_CALLBACK:case _.JOIN_WAITLIST:case _.JOIN_WAITLIST_ADDED:case _.WAITLIST:case _.UNKNOWN:case _.ACCOUNT_CREATION_TEMPORARILY_UNAVAILABLE:case _.EMAIL_ROLLBACK_SUCCESS:case _.EMAIL_ROLLBACK_REJECTED:case _.ADD_PASSWORD_NEW_PASSWORD:case _.ADD_PHONE:case _.PHONE_VERIFICATION:case _.ADVANCED_ACCOUNT_SECURITY_ENROLL:case _.ADVANCED_ACCOUNT_SECURITY_SECURE_METHODS:case _.ADVANCED_ACCOUNT_SECURITY_ENROLL_COMPLETE:case _.ADVANCED_ACCOUNT_SECURITY_RECOVERY_KEYS:case _.ADVANCED_ACCOUNT_SECURITY_RECOVERY_KEYS_GENERATE:case _.PASSKEY_PRF_SYNC:case _.AUTH_CHALLENGE_SELECTION:case _.AUTH_CHALLENGE_METHOD:case _.CHOOSE_AN_ACCOUNT:return A.ACCESS_FLOW_PAGE_TYPE_UNSPECIFIED;case _.ABOUT_YOU:return A.ACCESS_FLOW_PAGE_TYPE_ABOUT_YOU;case _.LOG_IN:return A.ACCESS_FLOW_PAGE_TYPE_LOGIN_START;case _.UNIFIED_LOG_IN_OR_SIGN_UP_INPUT:return A.ACCESS_FLOW_PAGE_TYPE_LOGIN_OR_SIGNUP_START;case _.START:return A.ACCESS_FLOW_PAGE_TYPE_APPLE_INTELLIGENCE_START;case _.LOG_IN_PASSWORD:return A.ACCESS_FLOW_PAGE_TYPE_LOGIN_PASSWORD;case _.LOG_IN_PASSKEY:return A.ACCESS_FLOW_PAGE_TYPE_LOGIN_PASSWORD;case _.CREATE_ACCOUNT:return A.ACCESS_FLOW_PAGE_TYPE_CREATE_ACCOUNT_START;case _.CREATE_ACCOUNT_PASSWORD:return A.ACCESS_FLOW_PAGE_TYPE_CREATE_ACCOUNT_PASSWORD;case _.CREATE_ACCOUNT_LATER:return A.ACCESS_FLOW_PAGE_TYPE_CREATE_ACCOUNT_LATER;case _.CREATE_ACCOUNT_LATER_QUEUED:return A.ACCESS_FLOW_PAGE_TYPE_CREATE_ACCOUNT_LATER_QUEUED;case _.ADD_EMAIL:return A.ACCESS_FLOW_PAGE_TYPE_ADD_EMAIL;case _.CONTACT_VERIFICATION:return A.ACCESS_FLOW_PAGE_TYPE_CONTACT_VERIFICATION;case _.EMAIL_VERIFICATION:return A.ACCESS_FLOW_PAGE_TYPE_EMAIL_OTP_VERIFICATION;case _.EMAIL_ROLLBACK_CONFIRM:return A.ACCESS_FLOW_PAGE_TYPE_EMAIL_ROLLBACK_CONFIRM;case _.MFA_CHALLENGE_SELECTION:case _.MFA_CHALLENGE_METHOD:return A.ACCESS_FLOW_PAGE_TYPE_MFA_CHALLENGE;case _.MFA_ENROLL_METHOD:case _.PASSKEY_ENROLL:return A.ACCESS_FLOW_PAGE_TYPE_MFA_ENROLL;case _.PUSH_AUTH_VERIFICATION:return A.ACCESS_FLOW_PAGE_TYPE_PUSH_AUTH_VERIFICATION;case _.RESET_PASSWORD_START:return A.ACCESS_FLOW_PAGE_TYPE_RESET_PASSWORD_START;case _.RESET_PASSWORD_NEW_PASSWORD:return A.ACCESS_FLOW_PAGE_TYPE_RESET_PASSWORD_NEW_PASSWORD;case _.RESET_PASSWORD_SUCCESS:return A.ACCESS_FLOW_PAGE_TYPE_RESET_PASSWORD_SUCCESS;case _.SIGN_IN_WITH_CHATGPT_CONSENT:return A.ACCESS_FLOW_PAGE_TYPE_SIGN_IN_WITH_CHATGPT_CONSENT;case _.SIGN_IN_WITH_CHATGPT_CODEX_CONSENT:return A.ACCESS_FLOW_PAGE_TYPE_SIGN_IN_WITH_CHATGPT_CODEX_CONSENT;case _.SIGN_IN_WITH_CHATGPT_CODEX_ORGANIZATION:return A.ACCESS_FLOW_PAGE_TYPE_SIGN_IN_WITH_CHATGPT_CODEX_ORG;case _.SSO_SELECTION:return A.ACCESS_FLOW_PAGE_TYPE_SSO;case _.WORKSPACE_SELECTION:return A.ACCESS_FLOW_PAGE_TYPE_WORKSPACE;case _.VERIFY_YOUR_IDENTITY:return A.ACCESS_FLOW_PAGE_TYPE_IDENTITY_VERIFICATION;case _.ERROR:return A.ACCESS_FLOW_PAGE_TYPE_ERROR;default:return e}}async function yn(e,t){if(hn())return;const{maybeWriteClientAuthSessionCacheFromNavigationJson:n}=await tn(async()=>{const{maybeWriteClientAuthSessionCacheFromNavigationJson:a}=await Promise.resolve().then(()=>cc);return{maybeWriteClientAuthSessionCacheFromNavigationJson:a}},void 0);await n(e,t)}async function kc(e,t,n,{intercept:a,routeId:s}={}){var f;a=a??(()=>{});const i=new URL(e.url),o=new Headers(n==null?void 0:n.headers);o.set("Accept","application/json");const{result:c,durationMs:u}=await fn(fetch(t,{credentials:"include",...n,headers:o})),l=c.clone(),C=await c.json();k().eventLogger.logStructuredEvent(bn,{path:t.toString(),status:c.ok?Pe.ACCESS_FLOW_API_STATUS_SUCCESS:Pe.ACCESS_FLOW_API_STATUS_FAILURE,responseCode:c.status,latencyMs:u,page:s?G(s):void 0,errorMessage:c.ok||(f=C==null?void 0:C.error)==null?void 0:f.message}),c.ok&&await yn(C,t.toString());const p=await a({status:c.status,json:C});if(p)return p;if(!c.ok||typeof C!="object")throw l;const{continue_url:E=null}=C;if(!E)throw Response.json("Missing a continue_url",{status:400,statusText:"Missing a continue_url"});const N=new URL(E);return N.origin!==i.origin||N.pathname.startsWith("/api/")?tr(E):nr(E)}async function jc(e,t,n,{intercept:a,routeId:s}={}){var P;const i=new Headers(n==null?void 0:n.headers);i.set("Accept","application/json"),i.set("Content-Type","application/json");const o=`${k().authapiBaseUrl}${t}`,c={credentials:"include",method:"POST",signal:e.signal,...n,headers:i},{result:u,durationMs:l}=await fn(Qs(async()=>{const b=await fetch(o,c);if(b.status>=500&&b.status<=599)throw new Error(`Server error ${b.status}`);return b},{retries:1,factor:2,minTimeout:100,randomize:!0,signal:e.signal})),C=u.clone(),p=u.headers.get("Content-Type");if(!(p!=null&&p.includes("application/json")))throw console.info("Invalid content type",p,await u.text()),Response.json(`Invalid content type: ${p}`,{status:400,statusText:`Invalid content type: ${p}`});const E=await u.json();k().eventLogger.logStructuredEvent(bn,{path:t,status:u.ok?Pe.ACCESS_FLOW_API_STATUS_SUCCESS:Pe.ACCESS_FLOW_API_STATUS_FAILURE,responseCode:u.status,latencyMs:l,page:s?G(s):void 0,errorMessage:u.ok||(P=E==null?void 0:E.error)==null?void 0:P.message}),u.ok&&await yn(E,t);const N=a?await a({status:u.status,json:E}):void 0;if(N)return{page:N,responseHeaders:u.headers};if(!u.ok||typeof E!="object")throw C;const{page:f=null}=E;if(!f)throw Response.json("Missing a page",{status:400,statusText:"Missing a page"});return{page:f,responseHeaders:u.headers}}const Ge=e=>hn()?e.headers.get("cookie")??"":document.cookie;function vn(e,t){return Rn(e)[t]||null}const ro=r.object({connection_name:r.string(),title:r.string(),connection_provider:r.number().int().nullish()}),ao=r.object({id:r.string(),name:r.string(),username:$,avatar_url:r.string().nullish()}),io=r.enum(["ble","cable","hybrid","internal","nfc","usb"]),so=r.string(),jt=r.enum(["sms","whatsapp"]),oo=r.object({type:r.literal("public-key"),id:r.string(),transports:r.array(io).optional()}).passthrough(),_o=r.object({challenge:r.string(),timeout:r.number().optional(),rpId:r.string().optional(),allowCredentials:r.array(oo).optional(),userVerification:r.enum(["required","preferred","discouraged"]).optional(),extensions:r.record(r.unknown()).optional()}).passthrough(),co=r.object({mfa_request_id:r.string(),challenge_mode:r.string().optional().nullable(),match_number:r.string().optional().nullable()}),uo=r.object({passkey_request_options:_o,mfa_request_id:r.string(),use_browser_autofill:r.boolean().optional(),fallback_allowed:r.boolean().optional()}).passthrough(),lo=r.enum(["aas"]),Eo=r.enum(["passkey","recovery-code"]),Ht=r.object({}).passthrough(),So=r.object({challenge_type:lo,user_id:r.string(),available_methods:r.object({passkey:Ht.optional(),"recovery-code":Ht.optional()}).passthrough(),completed_methods:r.array(Eo)}),Ye=r.object({session_id:r.string().optional(),auth_session_logging_id:r.string().optional(),screen_hint:r.string().nullish(),codex_error_state:r.string().optional(),username:$.optional(),username_verified:r.boolean().optional(),email:r.string().optional(),email_verified:r.boolean().optional(),set_phone_number:r.string().optional(),set_phone_number_verified:r.boolean().optional(),set_phone_verification_channel:jt.optional(),phone_number:r.string().optional(),phone_number_verified:r.boolean().optional(),phone_verification_channel:jt.optional(),current_email:r.string().optional(),previous_email:r.string().optional(),name:r.string().optional(),birthdate:r.string().optional(),chatgpt_plan_type:r.string().optional(),unified_sessions:r.array(ao).optional(),sso:r.object({connections:r.array(ro),must_use_enterprise_sso:r.boolean().default(!1)}).optional(),third_party_login_association:Gs.optional(),workspaces:r.array(r.object({id:r.string(),name:r.string().nullish(),profile_picture_url:r.string().nullish(),profile_picture_alt_text:r.string().nullish(),kind:r.enum(["personal","organization"]),beta_settings:r.object({codex_device_code_auth:r.boolean().optional()}).optional()})).optional(),openai_client_id:r.string().optional(),originator:r.string().optional(),originator_display_name:r.string().optional(),logo_uri:r.string().optional(),policy_uri:r.string().optional(),tos_uri:r.string().optional(),app_name_enum:r.string().optional(),promo:r.string().optional(),signup_source:r.string().optional(),device_id:r.string().optional(),persona_inquiry:r.object({id:r.string()}).optional(),auth_challenge_data:So.optional(),mfa_factors:r.array(Nn).optional(),mfa_enrollment_factors:r.array(Pn).optional(),aas_enabled:r.boolean().optional(),login_start_url:r.string().optional(),is_device_authorization:r.boolean().optional(),country_code_hint:Ms.optional().catch(void 0),original_screen_hint:r.string().optional(),passkey_challenge_option:uo.optional(),push_auth_challenge_state:co.optional(),email_verification_mode:so.optional(),passwordless_disabled:r.boolean().optional(),passwordless_otp_from_password_redirect:r.boolean().optional(),reauth:r.boolean().optional(),post_login_add_password:r.boolean().optional()}),Co="oai-client-auth-session",po="login_web_enable_double_reads_for_get_client_auth_session";async function wn(e){const t=vn(e,Co);if(!t)return{is_missing_session:!0};const n=In(t,{header:!0}),a=Ye.safeParse(n);return a.success?{...a.data,is_missing_session:!1}:{is_missing_session:!0}}async function Hc(e){const t=await wn(Ge(e)),{statsigClient:n,maybeShadowCompareClientAuthSession:a}=k();return!t.is_missing_session&&(n!=null&&n.checkGate(po))&&a(e).catch(()=>{}),t}const go=g.createContext(null);function Ao(){const e=g.useContext(go);if(!e)throw new Error("useClientAuthSession must be used within a ClientAuthSessionProvider");return e}const ho=r.object({client_auth_session:Ye,checksum:r.string(),session_id:r.string().nullable()});class le extends Error{constructor(n,a){super(a??`Failed to fetch client auth session dump (${n})`);L(this,"status");L(this,"retryable");this.name="ClientAuthSessionDumpFetchError",this.status=n,this.retryable=n>=500}}async function fo({request:e}){var o;const t=new Headers(await k().getAuthapiHeaderMixins(e));t.set("Accept","application/json");const n={method:"GET",credentials:"include",headers:t,signal:e.signal};let a;try{a=await fetch(`${k().authapiBaseUrl}/client_auth_session_dump`,n)}catch{throw new le(503)}if(!a.ok){let c;try{const u=await a.clone().json();c=(o=u==null?void 0:u.error)==null?void 0:o.message}catch{}throw new le(a.status,c)}let s;try{s=await a.json()}catch{throw new le(500,"Invalid client auth session dump response body.")}const i=ho.safeParse(s);if(!i.success)throw new le(500,"Invalid client auth session dump response shape.");return i.data}const To="auth-session-minimized-client-checksum";function ct(e){const t=Rn(e)[To]||null;if(!t)return null;try{const n=JSON.parse(t);return typeof n.affinity=="string"?n.affinity:null}catch{return null}}const qe="client_auth_session_cache_cookie_mismatch",Oo="client_auth_session_cache_cookie_equal",Vt="login_web_client_auth_session_dump_fetch_needed";let V=null;function mo({cacheSession:e,cookieSession:t}){const n=Object.prototype.hasOwnProperty,a=[],s=[],i=[],o=Array.from(new Set([...Object.keys(t),...Object.keys(e)])).sort();for(const c of o){const u=n.call(t,c);if(!n.call(e,c)){a.push(c);continue}if(!u){s.push(c);continue}Me(t[c],e[c])||i.push(c)}return{diffKeys:i,keysPresentInCookieButNotCache:a,keysPresentInCacheButNotCookie:s,diffCount:i.length}}async function Io({request:e}){const t=await wn(Ge(e));if(!t.is_missing_session)try{const n=await No({request:e}),a=Ye.parse(t);if(Me(n.session,a)){m.addAction(Oo,{});return}const s=mo({cacheSession:n.session,cookieSession:a});m.addAction(qe,{mismatchKind:"session_payload_differs",...s})}catch(n){if(n instanceof le&&n.status===404){m.addAction(qe,{mismatchKind:"missing_session"});return}n instanceof le&&n.status>=500&&m.addAction(qe,{mismatchKind:"turtle_error"})}}const Vc=e=>{const t=Ge(e),n=ct(t);if((V==null?void 0:V.checksumSignal)===n)return V.promise;const a=Io({request:e}).finally(()=>{(V==null?void 0:V.promise)===a&&(V=null)});return V={checksumSignal:n,promise:a},a};async function No({request:e}){const{clientAuthSessionCache:t}=k(),n=Ge(e),a=t.read();if(a){const i=ct(n);if(a.checksum===i)return a;m.addAction(Vt,{reason:"stale_cache"})}else m.addAction(Vt,{reason:"missing_cache"});const s=await fo({request:e});return t.writeFromDumpResponse(s)}const Po=r.object({session_id:r.string().default(""),openai_client_id:r.string().default(""),app_name_enum:r.string().default(""),auth_session_logging_id:r.string().default(""),originator:r.string().default("")}),Un=r.object({track:r.enum(["stable","canary"]).default("stable"),requestStartMillis:r.number().default(-1),isDefaultBootstrapValue:r.boolean().default(!1),statsigBootstrap:r.string().nullable().default(null),shouldConsumeStatsigBootstrap:r.boolean().default(!1),immutableClientSessionMetadata:Po.default({}),experimentEvaluations:r.array(r.object({layerName:r.string(),experimentName:r.string(),groupName:r.string()})).default([])}),Dn=Un.parse({isDefaultBootstrapValue:!0}),Lo="bootstrap-inert-script";function xc(){try{const e=document.getElementById(Lo),t=e==null?void 0:e.textContent;if(!t)throw new Error("Bootstrap data not found");return{bootstrap:Un.parse(JSON.parse(t)),bytesCount:t.length}}catch(e){return m.addError(e,{message:"Error parsing bootstrap data"}),{bootstrap:Dn,bytesCount:0}}}const y=[];for(let e=0;e<256;++e)y.push((e+256).toString(16).slice(1));function bo(e,t=0){return(y[e[t+0]]+y[e[t+1]]+y[e[t+2]]+y[e[t+3]]+"-"+y[e[t+4]]+y[e[t+5]]+"-"+y[e[t+6]]+y[e[t+7]]+"-"+y[e[t+8]]+y[e[t+9]]+"-"+y[e[t+10]]+y[e[t+11]]+y[e[t+12]]+y[e[t+13]]+y[e[t+14]]+y[e[t+15]]).toLowerCase()}let ze;const Ro=new Uint8Array(16);function yo(){if(!ze){if(typeof crypto>"u"||!crypto.getRandomValues)throw new Error("crypto.getRandomValues() not supported. See https://github.com/uuidjs/uuid#getrandomvalues-not-supported");ze=crypto.getRandomValues.bind(crypto)}return ze(Ro)}const vo=typeof crypto<"u"&&crypto.randomUUID&&crypto.randomUUID.bind(crypto),xt={randomUUID:vo};function wo(e,t,n){var s;if(xt.randomUUID&&!t&&!e)return xt.randomUUID();e=e||{};const a=e.random??((s=e.rng)==null?void 0:s.call(e))??yo();if(a.length<16)throw new Error("Random bytes length must be >= 16");return a[6]=a[6]&15|64,a[8]=a[8]&63|128,bo(a)}const Uo="javascript-client",Do=3e4,Bt="__protobuf_structured_event__",Wo=new Set(["localhost","127.0.0.1","testserver"]),Fo={getPunchOut:()=>{},count:(e,t)=>{},addError:(e,t)=>{},addAction:(e,t)=>{}};function Mo(){let e,t;return{promise:new Promise((a,s)=>{e=a,t=s}),resolve:e,reject:t}}class Go{constructor({appName:t,appVersion:n,deviceId:a,browserLocale:s,options:i,settings:o,instrumentation:c=Fo}){this.initializeResolvedGate=Mo(),this.pendingStructuredTrackPromises=new Set,this.appName=t,this.appVersion=n,this.instrumentation=c,this.statscTags={app_name:this.appName,app_version:this.appVersion},this.analytics=new Promise(u=>{tn(()=>import("./segment-D0siyHcc.js"),__vite__mapDeps([0,1,2,3])).then(l=>{if(!l){this.instrumentation.count("AnalyticsLogger.segmentImport.failed",this.statscTags);return}const C=new l.AnalyticsBrowser;u([C])})}),this.deviceId=a,this.browserLocale=s,this.options=i,this.settings=o}async initialize({user:t,statsigClient:n}){t&&(this.user=t),n&&(this.statsigClient=n),this.initializePromise||(this.instrumentation.count("AnalyticsLogger.initialize.start",this.statscTags),this.initializePromise=(async()=>{if(this.structuredEventTransport=this.shouldUseStatsigLogEventTransport()?"statsig":"segment",this.structuredEventTransport==="statsig"){this.instrumentation.count("AnalyticsLogger.initialize.success",this.statscTags);return}const[a]=await this.analytics;a.load(this.settings,this.options).catch(s=>{this.instrumentation.count("AnalyticsLogger.initialize.failed",this.statscTags)}),a.ready(()=>{this.instrumentation.count("AnalyticsLogger.initialize.success",this.statscTags)})})().then(()=>{this.initializeResolvedGate.resolve()}).catch(a=>{throw this.initializeResolvedGate.reject(a),a})),await this.initializePromise}getServedHostname(){var n;const t=(n=globalThis.location)==null?void 0:n.hostname;if(!(typeof t!="string"||t.trim().length===0))return t.toLowerCase()}getServedOrigin(){var n;const t=(n=globalThis.location)==null?void 0:n.origin;if(!(typeof t!="string"||t.trim().length===0))return t}getTopLevelHost(t){const n=t.trim().toLowerCase(),a=n.split(".").filter(Boolean);return a.length<2?n:a.slice(-2).join(".")}isSameTopLevelHost(t,n){return typeof n!="string"||n.trim().length===0?!1:t===n.toLowerCase()||this.getTopLevelHost(t)===this.getTopLevelHost(n)}isClientEventsServiceLogEventUrl(t){let n;try{n=new URL(t,this.getServedOrigin()??"https://chatgpt.com")}catch{return!1}const a=n.hostname.toLowerCase(),s=n.pathname.replace(/\/+$/,"").toLowerCase(),i=s.endsWith("/v1/rgstr")||s.endsWith("/v1/log_event"),o=Wo.has(a),c=this.isSameTopLevelHost(a,this.getServedHostname());return!i||!o&&!c?!1:s.includes("/ces/")||o}shouldUseStatsigLogEventTransport(){var n,a;if(typeof((n=this.statsigClient)==null?void 0:n.logEvent)!="function")return!1;const t=(a=this.statsigClient.networkConfig)==null?void 0:a.logEventUrl;return typeof t=="string"&&t.trim().length>0&&this.isClientEventsServiceLogEventUrl(t)}buildSegmentEnvelope(t,n,a){var E,N,f,P,b,D,v,ge,re,ae;const s=t.$type,i=(E=this.statsigClient)==null?void 0:E.getContext(),o=i==null?void 0:i.user,c=(i==null?void 0:i.stableID)??void 0;let u;if(i){const ue=(o==null?void 0:o.locale)??this.browserLocale,be=ue?ue.split("-")[0]:void 0;u={stableId:c,sdkType:Uo,sdkVersion:st.SDK_VERSION,sessionId:(f=(N=i==null?void 0:i.session)==null?void 0:N.data)==null?void 0:f.sessionID,appIdentifier:this.appName,appVersion:this.appVersion,locale:ue,language:be}}if(o){let ue;o.customIDs&&(ue=Object.fromEntries(Object.entries(o.customIDs).filter(Re=>typeof Re[1]=="string")));let be;if(o.custom){const Re=Object.entries(o.custom).flatMap(zn=>{const[Jn,dt]=zn;if(dt===void 0)return[];try{const St=JSON.stringify(dt);return St===void 0?[]:[[Jn,St]]}catch{return[]}});Re.length>0&&(be=Object.fromEntries(Re))}const qn={userId:o.userID,customIds:ue,email:o.email,ip:o.ip,userAgent:o.userAgent,country:o.country,locale:o.locale,appVersion:o.appVersion,custom:be};u&&(u.user=qn)}const l=(b=(P=this.user)==null?void 0:P.traits)==null?void 0:b.is_openai_internal,C={userId:((D=this.user)==null?void 0:D.userId)??"",deviceId:this.deviceId,authStatus:this.user?"logged_in":"logged_out",planType:(ge=(v=this.user)==null?void 0:v.traits)==null?void 0:ge.plan_type,workspaceId:(ae=(re=this.user)==null?void 0:re.traits)==null?void 0:ae.workspace_id,isOpenaiInternal:l},p={};return{eventId:a.eventId,eventCreatedAt:a.eventCreatedAt,eventType:"web",userParams:C,deviceParams:p,statsigMetadataV2:u,eventParams:{"@type":`openai.buf.dev/openai/protobuf-analytics-events/${s}`,...n},punchOutInfoToken:this.instrumentation.getPunchOut(),clientMetadata:{name:this.appName,version:this.appVersion}}}buildStatsigEnvelope(t,n,a){const s=t.$type,i={};return{eventId:a.eventId,eventCreatedAt:a.eventCreatedAt,eventType:"web",deviceParams:i,eventParams:{"@type":`openai.buf.dev/openai/protobuf-analytics-events/${s}`,...n},punchOutInfoToken:this.instrumentation.getPunchOut()}}normalizeEventName(t){return(t.split(".").pop()??t).replace(/([a-z0-9])([A-Z])/g,"$1_$2").replace(/([A-Z])([A-Z][a-z])/g,"$1_$2").toLowerCase()}settleWithin(t,n){let a;const s=new Promise(o=>{a=setTimeout(o,n)}),i=t.catch(()=>{}).then(()=>{});return Promise.race([i,s]).finally(()=>{a!=null&&clearTimeout(a)})}async trackStructuredEvent(t,n){const a={eventId:wo(),eventCreatedAt:new Date().toISOString()},s=this.initializeResolvedGate.promise.then(()=>this.sendStructuredEvent(t,n,a));this.trackStructuredTrackPromise(s),await s}trackStructuredTrackPromise(t){const n=this.settleWithin(t,Do);this.pendingStructuredTrackPromises.add(n),n.finally(()=>{this.pendingStructuredTrackPromises.delete(n)})}async sendStructuredEvent(t,n,a){const s=this.normalizeEventName(t.$type);if(this.structuredEventTransport==="statsig"){const o=this.buildStatsigEnvelope(t,n,a);try{this.statsigClient.logEvent({eventName:Bt,metadata:o})}catch(c){this.instrumentation.count("AnalyticsLogger.statsigLogEvent.failed",this.statscTags),c instanceof Error&&this.instrumentation.addError(c,{eventName:s})}}else{const o=this.buildSegmentEnvelope(t,n,a),c=this.analytics.then(([u])=>u.track(Bt,o)).catch(()=>{});this.trackStructuredTrackPromise(c),await this.analytics}const i={platform:"web",event_name:s,...this.statscTags};this.instrumentation.count("analytics_event_tracked",i)}async drainPendingStructuredTracks(t){for(;;){const n=t-Date.now();if(n<=0)return;const a=Array.from(this.pendingStructuredTrackPromises);if(a.length===0)return;await Promise.race([Promise.allSettled(a).then(()=>{}),new Promise(s=>{setTimeout(s,n)})])}}async flush(t=1e3){var i;const n=Number.isFinite(t)&&t>=0?t:1e3,a=Date.now()+n;await this.drainPendingStructuredTracks(a).catch(()=>{});const s=a-Date.now();s>0&&this.structuredEventTransport==="statsig"&&typeof((i=this.statsigClient)==null?void 0:i.flush)=="function"&&await Promise.race([this.statsigClient.flush().catch(()=>{}).then(()=>{}),new Promise(o=>{setTimeout(o,s)})])}async trackCounter(t,n){return this.trackStructuredEvent(to,{counterName:t,metricValue:n})}}class Yo{constructor({appName:t,deviceId:n,options:a,settings:s}){L(this,"analytics");L(this,"appName");L(this,"deviceId");L(this,"options");L(this,"settings");this.analytics=new er,this.appName=t,this.deviceId=n,this.options=a,this.settings=s,this.analytics.addSourceMiddleware(ko(this))}initialize(){this.analytics.load(this.settings,this.options).catch(t=>{console.error("Failed to load analytics",t)})}page({name:t,routeId:n,isError:a}){this.analytics.page("Identity",t,{route_id:n,is_error:a,search:"redacted",url:"redacted",referrer:"redacted",origin:"login-web"})}track(t,n){this.analytics.track(t,{...n,openai_app:this.appName,origin:"login-web"})}getExtraContext(){const{openai_client_id:t="",app_name_enum:n="",auth_session_logging_id:a=""}=k().immutableClientSessionMetadata;return{app_name:this.appName,app_version:"f305bca6afc89159e811fd824396d6daaf880760",device_id:this.deviceId,auth_session_logging_id:a,openai_client_id:t,app_name_enum:n}}}function ko(e){return({payload:t,next:n})=>{t.obj.context={...t.obj.context,...e.getExtraContext()},n(t)}}const Wn="chatgpt.com/ces",Fn="https",jo=Ss(),Ho={strategy:"batching",config:{size:10,timeout:6e3}},Kt=jo?Ho:void 0,Mn={disableClientPersistence:!0,integrations:{"Segment.io":{apiHost:`${Wn}/v1`,protocol:Fn,...Kt?{deliveryStrategy:Kt}:void 0}}},Gn={writeKey:"oai",cdnURL:`${Fn}://${Wn}`},Bc={[_.LEGACY_LOGIN_WEB_PAGE_SPLAT]:()=>"Legacy Login Web Page",[_.LEGACY_LOGIN_WEB_PAGE_INDEX]:()=>"Legacy Login Web Page",[_.ADVANCED_ACCOUNT_SECURITY_ENROLL]:()=>"Advanced Account Security Enrollment",[_.ADVANCED_ACCOUNT_SECURITY_SECURE_METHODS]:()=>"Advanced Account Security Secure Methods",[_.ADVANCED_ACCOUNT_SECURITY_ENROLL_COMPLETE]:()=>"Advanced Account Security Enrollment Complete",[_.ADVANCED_ACCOUNT_SECURITY_RECOVERY_KEYS]:()=>"Advanced Account Security Recovery Keys",[_.ADVANCED_ACCOUNT_SECURITY_RECOVERY_KEYS_GENERATE]:()=>"Advanced Account Security Recovery Keys Generate",[_.ABOUT_YOU]:()=>"About You",[_.ACCOUNT_CREATION_TEMPORARILY_UNAVAILABLE]:()=>"Account Creation Temporarily Unavailable",[_.ADD_PASSWORD_NEW_PASSWORD]:()=>"Add Password New Password",[_.ADD_EMAIL]:()=>"Add Email",[_.ADD_PHONE]:()=>"Add Phone",[_.CHOOSE_AN_ACCOUNT]:()=>"Choose An Account",[_.CONTACT_VERIFICATION]:()=>"Contact Verification",[_.CREATE_ACCOUNT]:()=>"Create Account",[_.CREATE_ACCOUNT_PASSWORD]:()=>"Create Account Password",[_.CREATE_ACCOUNT_LATER]:()=>"Create Account Later",[_.CREATE_ACCOUNT_LATER_QUEUED]:()=>"Create Account Later Queued",[_.DEVICE_AUTH_CALLBACK]:()=>"Device Authorization Callback",[_.EMAIL_VERIFICATION]:()=>"Email Verification",[_.EMAIL_ROLLBACK_SUCCESS]:()=>"Email Rollback Success",[_.EMAIL_ROLLBACK_REJECTED]:()=>"Email Rollback Rejected",[_.EMAIL_ROLLBACK_CONFIRM]:()=>"Email Rollback Confirm",[_.ERROR]:()=>"Error",[_.LOG_IN]:()=>"Log In",[_.LOG_IN_PASSWORD]:()=>"Log In Password",[_.LOG_IN_PASSKEY]:()=>"Log In Passkey",[_.UNIFIED_LOG_IN_OR_SIGN_UP_INPUT]:()=>"Unified Login Or Signup Input Page",[_.JOIN_WAITLIST]:()=>"Join Waitlist",[_.JOIN_WAITLIST_ADDED]:()=>"Join Waitlist Added",[_.AUTH_CHALLENGE_SELECTION]:()=>"Auth Challenge Selection",[_.AUTH_CHALLENGE_METHOD]:()=>"Auth Challenge Method",[_.MFA_CHALLENGE_SELECTION]:()=>"MFA Challenge Selection",[_.MFA_CHALLENGE_METHOD]:()=>"MFA Challenge Method",[_.MFA_ENROLL_METHOD]:()=>"MFA Enroll Method",[_.PHONE_VERIFICATION]:()=>"Phone Verification",[_.PASSKEY_ENROLL]:()=>"Passkey Enroll",[_.PASSKEY_PRF_SYNC]:()=>"Passkey PRF Sync",[_.PUSH_AUTH_VERIFICATION]:()=>"Push Auth Verification",[_.RESET_PASSWORD_START]:()=>"Reset Password Start",[_.RESET_PASSWORD_NEW_PASSWORD]:()=>"Reset Password New Password",[_.RESET_PASSWORD_SUCCESS]:()=>"Reset Password Success",[_.SIGN_IN_WITH_CHATGPT_CONSENT]:()=>"Sign In With ChatGPT Consent",[_.SSO_SELECTION]:()=>"SSO Selection",[_.START]:()=>"Log In or Sign Up",[_.VERIFY_YOUR_IDENTITY]:()=>"Verify Your Identity",[_.WAITLIST]:()=>"Waitlist",[_.WORKSPACE_SELECTION]:()=>"Workspace Selection",[_.SIGN_IN_WITH_CHATGPT_CODEX_CONSENT]:()=>"Sign In With ChatGPT Codex Consent",[_.SIGN_IN_WITH_CHATGPT_CODEX_ORGANIZATION]:()=>"Sign In With ChatGPT Codex Organization",[_.UNKNOWN]:()=>"Unknown"},Yn="login_web",Vo="f305bca6afc89159e811fd824396d6daaf880760",xo=navigator.language,Ue=new Yo({appName:Yn,settings:Gn,options:Mn,deviceId:Oe}),Je=new Go({appName:Yn,appVersion:Vo,deviceId:Oe,browserLocale:xo,options:Mn,settings:Gn}),S={initialize({statsigClient:e}={}){Ue.initialize(),Je.initialize({user:void 0,statsigClient:e})},logEvent(e,t){Ue.track(e,t)},logPageView(e){Ue.page(e)},logStructuredEvent(e,t){Je.trackStructuredEvent(e,t)},trackCounter(e,t){return Je.trackCounter(e,t)},getClient(){return Ue}},$t=6;function ut(e){return r.string({required_error:e.formatMessage({id:"emailVerification.codeRequired",defaultMessage:"The verification code is required",description:"Error message shown when the verification code field is empty on the email verification page."})}).length($t,e.formatMessage({id:"emailVerification.incorrectCodeLength",defaultMessage:"The verification code should be exactly {length} characters long",description:"Error message shown when the verification code length is incorrect on the email verification page."},{length:$t})).regex(/^\d*$/,e.formatMessage({id:"emailVerification.invalidCodeFormat",defaultMessage:"Code must contain only numbers",description:"Error message shown when a user types in non-number characters for their one-time-passcode that has only numbers."}))}r.enum(["resend","validate"]);const d=r.enum(["advanced_account_security_enroll","about_you","add_password_new_password","add_email","add_phone","apple_intelligence_start","auth_challenge","contact_verification","choose_an_account","create_account_later","create_account_later_queued","create_account_password","login_passkey","create_account_start","email_otp_send","email_otp_verification","error","external_url","identity_verification","login_or_signup_start","login_password","login_start","mfa_challenge","mfa_enroll","phone_otp_send","phone_otp_verification","push_auth_verification","reset_password_start","reset_password_new_password","reset_password_success","sign_in_with_chatgpt_consent","sign_in_with_chatgpt_codex_consent","sign_in_with_chatgpt_codex_org","sso","token_exchange","workspace","__test__"]),Se=r.enum(["validate","resend"]);function Bo(e){const t=r.object({intent:r.literal(Se.enum.validate),username_kind:ee,code:ut(e)}),n=r.object({intent:r.literal(Se.enum.resend),username_kind:ee});return r.discriminatedUnion("intent",[t,n])}function Kc(e){return r.object({origin_page_type:d.extract([d.enum.contact_verification]),data:Bo(e)})}class Ko extends Error{constructor({userVisibleMessage:n,errorCode:a,metadata:s}){super(a);L(this,"errorCode");L(this,"userVisibleErrorId");L(this,"userVisibleMessage");L(this,"metadata");this.name="DetailedRouteError",this.userVisibleMessage=n,this.errorCode=a,this.userVisibleErrorId=$o(),this.metadata=s}}function $o(){return Math.floor(Math.random()*1e8).toString().padStart(8,"0")}var h=(e=>(e.pageLoad="Page Load",e.useSso="Use Sso",e.useEmail="Use Email",e.usePhone="Use Phone",e.invalidEmail="Invalid Email",e.invalidPhoneNumber="Invalid Phone Number",e.loginLinkClicked="Log in Link Clicked",e.signupLinkClicked="Sign up Link Clicked",e.onboardUserInfoComplete="Onboarding: User Info: Complete",e.ageFallbackTriggered="Onboarding: Age Fallback Triggered",e.ageFallbackSubmit="Onboarding: Age Fallback Submit",e.ageFallbackUseDob="Onboarding: Age Fallback Use DOB",e.ageFallbackConfirmShown="Onboarding: Age Fallback Confirm Shown",e.ageFallbackConfirmCanceled="Onboarding: Age Fallback Confirm Canceled",e.verifyPassword="Verify Password",e.verifyPasswordResult="Verify Password Result",e.registerUser="Register User",e.validateOtp="Validate OTP",e.resendOtp="Resend OTP",e.emailAlreadyVerified="Email Already Verified",e.wasDeniedAccountCreation="Was Denied Account Creation",e.missingSessionDetected="Missing Session Detected",e.missingSessionErrorPageAction="Missing Session Error Page Action",e.wordmarkClicked="Wordmark Clicked",e.usernameKindSwitched="Username Kind Switched",e.passkeyEnrollmentFailed="Temp Passkey Enrollment Failed",e.issueMfaChallengeFailed="Issue MFA Challenge Failed",e.reissueMfaChallengeFailed="Reissue MFA Challenge Failed",e))(h||{});const qo=new Proxy({},{get:()=>et}),kn=g.createContext(qo);function $c(){return g.useContext(kn)}function zo(e){switch(e){case"google-oauth2":return F.ACCESS_FLOW_USER_ACTION_TYPE_CONTINUE_WITH_GOOGLE;case"windowslive":return F.ACCESS_FLOW_USER_ACTION_TYPE_CONTINUE_WITH_MICROSOFT;case"apple":return F.ACCESS_FLOW_USER_ACTION_TYPE_CONTINUE_WITH_APPLE;default:return F.ACCESS_FLOW_USER_ACTION_TYPE_UNSPECIFIED}}function Jo(e){switch(e){case ee.enum.email:return F.ACCESS_FLOW_USER_ACTION_TYPE_SWITCH_TO_EMAIL;case ee.enum.phone_number:return F.ACCESS_FLOW_USER_ACTION_TYPE_SWITCH_TO_PHONE;default:return e}}function qt(e,t=1e3){return e.length<=t?e:`${e.slice(0,t)}…`}function Xo(e){return Object.fromEntries(Object.entries(e).filter(([,t])=>t!==void 0))}function Zo(e){return Object.fromEntries(Object.entries(e).map(([t,n])=>[t,String(n)]))}function M(){return k().immutableClientSessionMetadata.auth_session_logging_id||"missing"}const jn={logSwitchToSignUp({showedInvalidEmailError:e,fromRoute:t}){const n={typedInvalidEmail:e?"true":"false",loginWebUI:"new",route:t};S.logEvent(h.signupLinkClicked,n),S.logStructuredEvent(q,{authSessionLoggingId:M(),actionType:F.ACCESS_FLOW_USER_ACTION_TYPE_SIGNUP_INSTEAD_LINK_CLICKED,page:G(t)}),T({eventName:"login_web_signup_link_clicked",value:t,metadata:n})},logSwitchToLogIn({showedInvalidEmailError:e,fromRoute:t}){const n={typedInvalidEmail:e?"true":"false",loginWebUI:"new",route:t};S.logEvent(h.loginLinkClicked,n),S.logStructuredEvent(q,{authSessionLoggingId:M(),actionType:F.ACCESS_FLOW_USER_ACTION_TYPE_LOGIN_INSTEAD_LINK_CLICKED,page:G(t)}),T({eventName:"login_web_login_link_clicked",value:t,metadata:n})},logSocialSso({connection:e,fromRoute:t}){const n={flow:"authapi",loginWebUI:"new",connection:e,route:t};S.logEvent(h.useSso,n),S.logStructuredEvent(q,{authSessionLoggingId:M(),actionType:zo(e),page:G(t)}),T({eventName:"login_web_use_sso",value:e,metadata:n})},logContinueWithUsername({isInputPhoneNumber:e,fromRoute:t}){const n={flow:"authapi",loginWebUI:"new",route:t};S.logEvent(e?h.usePhone:h.useEmail,n),S.logStructuredEvent(q,{authSessionLoggingId:M(),actionType:F.ACCESS_FLOW_USER_ACTION_TYPE_CONTINUE,page:G(t),field:e?fe.ACCESS_FLOW_FIELD_PHONE:fe.ACCESS_FLOW_FIELD_EMAIL}),T({eventName:e?"login_web_use_phone_number":"login_web_use_email",value:t,metadata:n})},logInvalidInput({isInputPhoneNumber:e,fromRoute:t}){const n={flow:"authapi",loginWebUI:"new",route:t};S.logEvent(e?h.invalidPhoneNumber:h.invalidEmail,n),S.logStructuredEvent(ks,{authSessionLoggingId:M(),page:G(t),field:e?fe.ACCESS_FLOW_FIELD_PHONE:fe.ACCESS_FLOW_FIELD_EMAIL,validationType:Ln.ACCESS_FLOW_VALIDATION_TYPE_CLIENT}),T({eventName:e?"login_web_invalid_phone_number":"login_web_invalid_email",value:t,metadata:n})},logOnboardUserInfoComplete(){const e={flow:"authapi",loginWebUI:"new"};S.logEvent(h.onboardUserInfoComplete,e),T({eventName:"login_web_onboarding_user_info_complete",metadata:e})},logAgeFallbackTriggered(){const e={flow:"authapi",loginWebUI:"new",route:_.ABOUT_YOU};S.logEvent(h.ageFallbackTriggered,e),T({eventName:"login_web_age_fallback_triggered",metadata:e})},logAgeFallbackSubmit(){const e={flow:"authapi",loginWebUI:"new",route:_.ABOUT_YOU};S.logEvent(h.ageFallbackSubmit,e),T({eventName:"login_web_age_fallback_submit",metadata:e})},logAgeFallbackUseDob(){const e={flow:"authapi",loginWebUI:"new",route:_.ABOUT_YOU};S.logEvent(h.ageFallbackUseDob,e),T({eventName:"login_web_age_fallback_use_dob",metadata:e})},logAgeFallbackConfirmShown(){const e={flow:"authapi",loginWebUI:"new",route:_.ABOUT_YOU};S.logEvent(h.ageFallbackConfirmShown,e),T({eventName:"login_web_age_fallback_confirm_shown",metadata:e})},logAgeFallbackConfirmCanceled(){const e={flow:"authapi",loginWebUI:"new",route:_.ABOUT_YOU};S.logEvent(h.ageFallbackConfirmCanceled,e),T({eventName:"login_web_age_fallback_confirm_canceled",metadata:e})},logPageView({name:e,routeId:t,isError:n,previousRouteId:a,transitionLatencyMs:s}){S.logPageView({name:e,routeId:t,isError:n});const i={authSessionLoggingId:M(),page:G(t)};a&&(i.previousPage=G(a)),s!==void 0&&(i.transitionLatencyMs=s),S.logStructuredEvent(Ys,i),T({eventName:`Login Web: Page View: ${e}`,value:t,metadata:{routeId:t,isError:`${n}`}}),m.addPageView({name:e,routeId:t,isError:n})},logIssueMfaChallengeFailed({factorType:e}){const t={factorType:e};S.logEvent(h.issueMfaChallengeFailed,t),T({eventName:"login_web_issue_mfa_challenge_failed",value:e,metadata:t})},logPasskeyFailure({error:e,httpResponseBody:t,mfaTokenUserId:n,...a}){const s=a.errorName??(e&&typeof e=="object"&&"name"in e?String(e.name):void 0),i=a.errorMessage??(e&&typeof e=="object"&&"message"in e?String(e.message):void 0);S.logStructuredEvent(eo,{authSessionLoggingId:M(),stage:a.stage,isAutoAttempt:a.isAutoAttempt,originAppName:a.originAppName,creationOptionsSource:a.creationOptionsSource,normalizedErrorCode:a.normalizedErrorCode,normalizedErrorMessage:a.normalizedErrorMessage,rawErrorName:a.rawErrorName,rawErrorMessage:a.rawErrorMessage,rawErrorCode:a.rawErrorCode,errorName:s,errorMessage:i,errorStack:a.errorStack,httpStatus:a.httpStatus,httpStatusText:a.httpStatusText,httpResponseBody:t&&qt(t,1e3),httpRequestId:a.httpRequestId,redirectPath:a.redirectPath,hasMfaToken:a.hasMfaToken,passkeyUsage:a.passkeyUsage,mfaTokenUserId:n});const o=Xo({...a,originAppName:a.originAppName,errorName:s,errorMessage:i,mfaTokenUserId:n,httpResponseBody:t&&qt(t,1e3),httpRequestId:a.httpRequestId}),c=Object.keys(o).length>0?Zo(o):void 0;T({eventName:a.statsigEventName,value:a.stage,metadata:c}),m.addAction(a.statsigEventName,o)},logReissueMfaChallengeFailed({factorType:e}){const t={factorType:e};S.logEvent(h.reissueMfaChallengeFailed,t),T({eventName:"login_web_reissue_mfa_challenge_failed",value:e,metadata:t})},logFallbackErrorPage({error:e}){m.addError(e,e instanceof Ko?{errorCode:e.errorCode,userVisibleErrorId:e.userVisibleErrorId,metadata:e.metadata}:void 0)},logMissingSessionDetected({routeId:e,durationMs:t}){m.addAction("login_web_missing_session_detected",{routeId:e,...t!==void 0&&{durationMs:t}}),S.logEvent(h.missingSessionDetected,{routeId:e,...t!==void 0&&{durationMs:t}});const n={};e&&(n.routeId=e),t!==void 0&&(n.durationMs=String(t)),T({eventName:"Login Web: Missing Session Detected",value:e,metadata:Object.keys(n).length?n:void 0})},logMissingSessionErrorPageAction({action:e}){S.logEvent(h.missingSessionErrorPageAction,{action:e}),T({eventName:"Login Web: Missing Session Error Page Action",value:e})},logWasDeniedAccountCreation({email:e,phoneNumber:t,pageShown:n}){S.logEvent(h.wasDeniedAccountCreation,{email:e,phoneNumber:t,pageShown:n})},logVerifyPassword({usernameKind:e}){S.logEvent(h.verifyPassword,{usernameKind:e}),T({eventName:"login_web_verify_password",value:e})},logVerifyPasswordResult({usernameKind:e,result:t}){S.logEvent(h.verifyPasswordResult,{usernameKind:e,result:t}),T({eventName:"login_web_verify_password_result",value:t,metadata:{usernameKind:e,result:t}})},logRegisterUser({usernameKind:e}){S.logEvent(h.registerUser,{usernameKind:e}),T({eventName:"login_web_register_user",value:e})},logContactVerification(e){e.intent===Se.enum.validate?(S.logEvent(h.validateOtp,e),T({eventName:"login_web_validate_otp",metadata:e})):e.intent===Se.enum.resend&&(S.logEvent(h.resendOtp,e),T({eventName:"login_web_resend_otp",metadata:e}))},logEmailAlreadyVerified({routeId:e,authSessionLoggingId:t,screenHint:n}){const a={routeId:e,...t?{authSessionLoggingId:t}:{},...n?{screenHint:n}:{}};S.logEvent(h.emailAlreadyVerified,a),T({eventName:"login_web_email_already_verified",value:e,metadata:a})},logWordmarkClicked({isNavigatable:e,fromRoute:t}){S.logEvent(h.wordmarkClicked,{isNavigatable:e}),T({eventName:"login_web_wordmark_clicked",value:e?"true":"false"}),S.logStructuredEvent(q,{authSessionLoggingId:M(),actionType:F.ACCESS_FLOW_USER_ACTION_TYPE_WORDMARK_CLICKED,page:G(t)})},logUsernameKindSwitched({usernameKind:e,fromRoute:t}){S.logEvent(h.usernameKindSwitched,{usernameKind:e});const n=Jo(e);S.logStructuredEvent(q,{authSessionLoggingId:M(),actionType:n,page:G(t)}),T({eventName:"login_web_username_kind_switched",value:e})},logUserInputAction({actionType:e,page:t,field:n}){S.logStructuredEvent(q,{authSessionLoggingId:M(),actionType:e??F.ACCESS_FLOW_USER_ACTION_TYPE_INPUT,page:t,field:n})},logStructuredEvent(e,t){S.logStructuredEvent(e,{authSessionLoggingId:M(),...t})},logContinueAction(e,t){S.logStructuredEvent(q,{authSessionLoggingId:M(),actionType:t??F.ACCESS_FLOW_USER_ACTION_TYPE_CONTINUE,page:G(e)})}},qc=jn,zc=({children:e})=>j.jsx(kn.Provider,{value:jn,children:e});function zt({useSentinelDomain:e}){return new Promise((t,n)=>{const a=document.createElement("script");a.type="text/javascript",a.src=e?"https://sentinel.openai.com/backend-api/sentinel/sdk.js":"https://chatgpt.com/backend-api/sentinel/sdk.js",a.async=!0,a.defer=!0,a.onload=t,a.onerror=n,document.getElementsByTagName("head")[0].appendChild(a)})}const Qo=async()=>{try{await zt({useSentinelDomain:!0})}catch(e){m.addError(e,{message:"Failed to load Sentinel SDK script, retrying with chatgpt.com domain"});try{await zt({useSentinelDomain:!1})}catch(t){throw m.addError(t,{message:"Failed to load Sentinel SDK script from chatgpt.com domain after trying sentinel domain"}),t}}},Hn=(()=>{let e;return()=>(e||(e=Qo()),e)})();function Jc(e,t){const n={};return n["OpenAI-Sentinel-Token"]=e,t&&(n["OpenAI-Sentinel-SO-Token"]=t),n}function Xe(e,t,n){if(e!==void 0)return(t??n)-e}function e_(e,t,n){const a=performance.now(),s=Xe(n.helperStartedAtMs,n.sdkReadyCompletedAtMs,a),i=Xe(n.sentinelTokenStartedAtMs,n.sentinelTokenCompletedAtMs,a),o=Xe(n.sessionObserverTokenStartedAtMs,n.sessionObserverTokenCompletedAtMs,a);m.addAction("sentinel_sdk_token_fetch_timeout",{flow:e,timeout_ms:t,timeout_cause:n.sdkReadyCompletedAtMs===void 0?"sdk_ready":n.sentinelTokenCompletedAtMs===void 0?"sentinel_token_mint":"session_observer_token_mint",sdk_ready_completed:n.sdkReadyCompletedAtMs!==void 0,sentinel_token_started:n.sentinelTokenStartedAtMs!==void 0,sentinel_token_completed:n.sentinelTokenCompletedAtMs!==void 0,session_observer_token_started:n.sessionObserverTokenStartedAtMs!==void 0,session_observer_token_completed:n.sessionObserverTokenCompletedAtMs!==void 0,...s!==void 0?{sdk_ready_duration_ms:s}:{},...i!==void 0?{sentinel_token_duration_ms:i}:{},...o!==void 0?{session_observer_token_duration_ms:o}:{}})}async function lt(e){return await Hn(),e&&(e.sdkReadyCompletedAtMs=performance.now()),window.SentinelSDK}async function t_(e,t=lt(),n){const a=await t;if(a===void 0||typeof a.token!="function")return JSON.stringify({e:"q2n8w7x5z1"});n&&(n.sentinelTokenStartedAtMs=performance.now());try{return await a.token(e)}catch(s){return m.addError(s,{message:"Sentinel SDK token fetch failed",flow:e}),JSON.stringify({e:"k9d4s6v3b2"})}finally{n&&(n.sentinelTokenCompletedAtMs=performance.now())}}function Xc(e){return Hn().then(async()=>{try{const t=window.SentinelSDK;return t===void 0||typeof t.init!="function"?null:await t.init(e)}catch(t){return m.addError(t,{message:"Sentinel prefetch requirements failed for flow",flow:e}),null}}).catch(()=>null)}function n_(e,t,n,a,s){return s===void 0?e:new Promise((i,o)=>{const c=setTimeout(()=>{e_(t,s,n);const u=a();if(u!==null){i(u);return}o(new An)},s);e.then(u=>{clearTimeout(c),i(u)},u=>{clearTimeout(c),o(u)})})}function Zc(e,{timeoutMs:t}={}){const n={helperStartedAtMs:performance.now()},a=lt(n);let s,i;const o=t_(e,a,n).then(u=>(s=u,u)),c=r_(e,a,n).then(u=>(i=u,u));return n_(Promise.all([o,c]).then(([u,l])=>({sentinelToken:u,sessionObserverToken:l})),e,n,()=>s!==void 0?{sentinelToken:s,sessionObserverToken:i??null}:i?{sentinelToken:JSON.stringify({e:"k9d4s6v3b2"}),sessionObserverToken:i}:null,t)}async function r_(e,t=lt(),n){const a=await t;if(a===void 0||typeof a.sessionObserverToken!="function")return null;n&&(n.sessionObserverTokenStartedAtMs=performance.now());try{return await a.sessionObserverToken(e)}catch(s){return m.addError(s,{message:"Sentinel SDK session observer token fetch failed"}),null}finally{n&&(n.sessionObserverTokenCompletedAtMs=performance.now())}}const a_=g.createContext(null);function Qc(){const e=g.useContext(a_);return e||Dn}function i_(){return rr().at(-1)}function eu(){const{id:e}=i_()??{id:_.UNKNOWN};return no(e)?e:_.UNKNOWN}const s_=()=>()=>{};function tu(e){return g.useSyncExternalStore(s_,e,()=>{})}function o_(e){const t=g.useRef(e);return or(()=>{t.current=e},[e]),t}function nu(e){const t=o_(e);g.useEffect(()=>t.current(),[t])}const w=cr({missingEmailOrPhoneNumber:{id:"authApiFailure.missingEmailOrPhoneNumber",defaultMessage:"You must provide at least an email or phone number to continue.",description:"Error message when the user did not provide an email or a phone number when signing in."},cannotUsePlusEmail:{id:"authApiFailure.cannotUsePlusEmail",defaultMessage:"If you want to use a + in the openai email, please use username and password.",description:"Error message when the user's email contains a '+' (plus) character."},ssoRequired:{id:"authApiFailure.ssoRequired",defaultMessage:"Please use your organization's SSO to access your account.",description:"Error message when the user's organization has SSO (single sign-on) configured but the user used a non-SSO login method."},emailVerificationRequired:{id:"authApiFailure.emailVerificationRequired",defaultMessage:"You must verify your email with OpenAI before continuing.",description:"Error message when the user tried to sign in with their email but that email address is not yet verified."},phoneNumberVerificationRequired:{id:"authApiFailure.phoneNumberVerificationRequired",defaultMessage:"You must verify your phone number with OpenAI before continuing.",description:"Error message when the user tried to sign in with their phone number but that phone number is not yet verified."},chatGptAccountMissing:{id:"authApiFailure.chatGptAccountMissing",defaultMessage:"No eligible ChatGPT account found.",description:"Error message when a personal ChatGPT account is required for the application the user tries to access but no such account was found."},chatGptAccountMissingSso:{id:"authApiFailure.chatGptAccountMissingSso",defaultMessage:"No eligible ChatGPT account found. Authenticate with SSO to access your available account.",description:"Error message when a workspace requires SSO but the user logged in without SSO."},tokenMissingEmailClaim:{id:"authApiFailure.TokenMissingEmailClaim",defaultMessage:"OpenAI requires an email address to access our services, but the identity provider you signed in with did not provide one.",description:"OpenAI requires an email address to access our services, but the identity provider you signed in with did not provide one."},loginRequestExpired:{id:"authApiFailure.LoginRequestExpired",defaultMessage:"Your login session has expired, please try again.",description:"Your login session has expired, please try again."},pushAuthLoginRequestDenied:{id:"authApiFailure.PushAuthLoginRequestDenied",defaultMessage:"Your login request was denied. If this was a mistake, please try signing in again.",description:"Your login request was denied, please try signing in again."},thirdPartyPolicyCheckFailed:{id:"authApiFailure.ThirdPartyPolicyCheckFailed",defaultMessage:"We couldn't verify whether this account is eligible for this app. Please try again.",description:"Error shown when a third-party app policy check fails and login-web should avoid displaying the untranslated backend user message."},thirdPartyPhoneAccountNotSupported:{id:"authApiFailure.ThirdPartyPhoneAccountNotSupported",defaultMessage:"Signing in to this application with a phone-number account is not supported.",description:"Error shown when a user tries to sign in to a third-party app with a phone-number account."},thirdPartyTenantPolicyDenied:{id:"authApiFailure.ThirdPartyTenantPolicyDenied",defaultMessage:"You can't sign in to this application with ChatGPT because one of your tenants does not allow it.",description:"Error shown when a tenant-level third-party app policy blocks the user from signing in with ChatGPT."},thirdPartyWorkspacePlanBlocked:{id:"authApiFailure.ThirdPartyWorkspacePlanBlocked",defaultMessage:"Your workspaces or plan types are not eligible to sign in to this application with ChatGPT.",description:"Error shown when the user's workspace plans are not eligible for third-party app sign-in with ChatGPT."},errorWithCode:{id:"authApiFailure.errorWithCode",defaultMessage:"An error occurred during authentication ({code}). Please try again.",description:"Generic authentication error message with an error code."},genericError:{id:"authApiFailure.genericError",defaultMessage:"An error occurred during authentication. Please try again.",description:"Generic authentication error message."}}),__={missing_email_or_phone_number:w.missingEmailOrPhoneNumber,cannot_use_plus_email:w.cannotUsePlusEmail,sso_required:w.ssoRequired,email_verification_required:w.emailVerificationRequired,phone_number_verification_required:w.phoneNumberVerificationRequired,chatgpt_account_missing:w.chatGptAccountMissing,chatgpt_account_sso_required:w.chatGptAccountMissingSso,token_missing_email_claim:w.tokenMissingEmailClaim,login_request_expired:w.loginRequestExpired,push_auth_login_request_denied:w.pushAuthLoginRequestDenied,"3p_login_account_type_policy_check_failed":w.thirdPartyPolicyCheckFailed,"3p_login_phone_account_not_supported":w.thirdPartyPhoneAccountNotSupported,"3p_login_tenant_policy_denied":w.thirdPartyTenantPolicyDenied,"3p_login_workspace_plan_blocked":w.thirdPartyWorkspacePlanBlocked};function ru(e){const t=e?__[e]:void 0;return t?{descriptor:t}:e?{descriptor:w.errorWithCode,values:{code:e}}:{descriptor:w.genericError}}const c_=g.createContext({});function au(){return g.useContext(c_)}const u_=g.createContext(void 0);function iu(){return g.useContext(u_)}function su(){return se.useLayer("auth_login_signup_holdout_layer").get("is_font_improvements_enabled",!1)}r.object({type:d.extract([d.enum.token_exchange]),continue_url:r.string(),payload:r.object({state:r.string(),code:r.string()}).passthrough()});const ou=r.enum(["revert_to_previous_email","keep_current_email"]);function l_(){return new Uint8Array(32).buffer}function X(e,t){m.addAction(`${t}.${e}`)}function E_(e,t){if(X("login_web_passkey_prf_handle_called",t),(e==null?void 0:e.enabled)!==!0){X("login_web_passkey_prf_handle_return_prf_not_enabled",t);return}const n=e.results,a=n==null?void 0:n.first;if(a==null){m.addError(new Error("Passkey PRF extension was enabled but results.first was missing"),{message:"Passkey PRF extension was enabled but results.first was missing",hasPrfResults:!!n,entryPoint:t}),X("login_web_passkey_prf_handle_return_missing_first",t);return}X("login_web_passkey_prf_handle_return_success",t)}function Et(e,t){X("login_web_passkey_prf_filter_called",t);const n=e.clientExtensionResults,a=n==null?void 0:n.prf;if(!n)return X("login_web_passkey_prf_filter_return_no_client_extension_results",t),e;if(!a)return X("login_web_passkey_prf_filter_return_no_prf_extension",t),e;E_(a,t);const s={...n,prf:{...a,results:{...a.results??{},first:l_()}}};return X("login_web_passkey_prf_filter_return_sanitized",t),{...e,clientExtensionResults:s}}async function _u(e,...t){const n=await lr(...t);return Et(n,e)}const Vn="https://auth.openai.com/api/mfa";class _e extends Error{constructor(n,a,s,i,o,c,u){super(n);L(this,"message");L(this,"httpStatusCode",0);L(this,"httpStatusText","");L(this,"responseBody","");L(this,"url","");L(this,"httpRequestId","");L(this,"code","http-error");this.message=n,this.name="PasskeyEnrollmentAPIError",this.httpStatusCode=a,this.httpStatusText=s,this.responseBody=i,this.url=o,this.httpRequestId=c,this.code=u}}function d_(e){if(!e||typeof e!="object"||!("eval"in e))return!1;const t=e.eval;if(!t||typeof t!="object")return!1;const n=t.first;return typeof n=="string"||n instanceof ArrayBuffer}function xn(e){var n;const t=(n=e==null?void 0:e.extensions)==null?void 0:n.prf;return t&&d_(t)&&typeof t.eval.first=="string"&&(t.eval.first=Er(t.eval.first)),e}async function pe({response:e,url:t,operation:n}){const a=await e.text().catch(()=>{}),s=e.status===401?"session-expired":"http-error",i=e.headers.get("x-request-id")??void 0;throw new _e(`${n} failed (${e.status})`,e.status,e.statusText,a,t,i,s)}function cu(e){return e instanceof _e?{httpStatus:e.httpStatusCode??void 0,httpStatusText:e.httpStatusText??void 0,httpResponseBody:e.responseBody??void 0,httpRequestId:e.httpRequestId??void 0}:{}}async function uu(e){const t=`${Vn}/public/passkey/enrollment/start`,n=await fetch(t,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:e})});n.ok||await pe({response:n,url:t,operation:"start"});const{passkey_creation_options:a}=await n.json();if(!a){const s=n.headers.get("x-request-id")??void 0;throw new _e("Server response missing creation options",n.status,n.statusText,void 0,t,s)}return xn(a)}async function lu({token:e,originAppName:t,passkeyCreationResponse:n}){const a=Et(n,"passkey_enrollment_creation"),s=`${Vn}/public/passkey/enrollment/finish`,i=await fetch(s,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({token:e,origin_app_name:t,passkey_creation_response:a})});i.ok||await pe({response:i,url:s,operation:"finish"});const o=await i.json();if(!o.credential_id){const c=i.headers.get("x-request-id")??void 0;throw new _e("Server response missing credential_id",i.status,i.statusText,void 0,s,c)}return o}function Eu(e){return e==="API"?"/settings/profile?tab=security":e==="androidchat"||e==="ioschat"?"/settings/Security":"/#settings/Security"}function du(e,t){return t==="API"?e.searchParams.append("passkey_created",""):t==="androidchat"||t==="ioschat"?e.searchParams.set("message","passkey_created"):e.searchParams.set("_tm","passkey_created"),e}const ke="https://auth.openai.com/api/accounts";async function Su({signal:e}={}){const t=`${ke}/advanced-account-security/passkeys`,n=await fetch(t,{method:"GET",credentials:"include",signal:e,headers:{Accept:"application/json"}});return n.ok||await pe({response:n,url:t,operation:"get passkeys"}),(await n.json()).passkeys??[]}async function Cu({signal:e}={}){const t=`${ke}/advanced-account-security/passkey/enrollment/start`,n=await fetch(t,{method:"POST",credentials:"include",signal:e,headers:{Accept:"application/json"}}),a=n.headers.get("x-request-id")??void 0;n.ok||await pe({response:n,url:t,operation:"start"});const s=await n.json(),i=s.passkey_creation_options;if(!i)throw new _e("Server response missing creation options",n.status,n.statusText,void 0,t,a);if(!s.mfa_request_id)throw new _e("Server response missing mfa_request_id",n.status,n.statusText,void 0,t,a);return{passkeyCreationOptions:xn(i),mfaRequestId:s.mfa_request_id}}async function pu({mfaRequestId:e,passkeyCreationResponse:t,signal:n}){const a=`${ke}/advanced-account-security/passkey/enrollment/finish`,s=Et(t,"passkey_enrollment_creation"),i=await fetch(a,{method:"POST",credentials:"include",signal:n,headers:{"Content-Type":"application/json",Accept:"application/json"},body:JSON.stringify({mfa_request_id:e,passkey_creation_response:s})}),o=i.headers.get("x-request-id")??void 0;i.ok||await pe({response:i,url:a,operation:"finish"});const c=await i.json();if(!c.credential_id)throw new _e("Server response missing credential_id",i.status,i.statusText,void 0,a,o);return{credentialId:c.credential_id}}async function gu({factorId:e,signal:t}){const n=`${ke}/advanced-account-security/passkey/delete`,a=await fetch(n,{method:"POST",credentials:"include",signal:t,headers:{"Content-Type":"application/json",Accept:"application/json"},body:JSON.stringify({factor_id:e})});return a.ok||await pe({response:a,url:n,operation:"delete passkey"}),{message:(await a.json()).message??"Factor deleted"}}async function Au({getCreationOptions:e,finishEnrollment:t,isAutoAttempt:n,onFailure:a}){let s="fetched",i,o;try{const u=await e();i=u.creationOptions,s=u.creationOptionsSource,o=u.context}catch(u){return a({error:u,stage:"passkey_enrollment_start",creationOptionsSource:s,isAutoAttempt:n}),!1}let c;try{c=await dr(i)}catch(u){return a({error:u,stage:"passkey_enrollment_start",creationOptionsSource:s,isAutoAttempt:n}),!1}try{return await t({registrationResponse:c,context:o}),!0}catch(u){return a({error:u,stage:"passkey_enrollment_finish",creationOptionsSource:s,isAutoAttempt:n}),!1}}function ne(e,t,n){const a=se.useDynamicConfig(e),s=t.safeParse(a.value);return s.success?s.data:n}const S_="https://help.openai.com/en/articles/7039943-data-usage-for-consumer-services-faq",C_="https://help.openai.com/en/articles/10517909-korea-consent-form-information#h_46357d857c",p_="https://help.openai.com/en/articles/10517909-korea-consent-form-information#h_22484fb895",g_="https://help.openai.com/en/articles/10517909-korea-consent-form-information#h_46357d857c",A_="https://openai.com/policies/terms-of-use",h_="https://openai.com/policies/privacy-policy",f_="https://openai.com/ko-KR/policies/kr-privacy-policy-addendum",T_=r.object({help_center_url:r.string().optional(),overseas_transfer_consent_url:r.string().optional(),personal_info_consent_url:r.string().optional(),third_party_consent_url:r.string().optional()});function hu(){const e=ne("login_web_external_url_override",T_,{});return{helpCenterUrl:e.help_center_url??S_,overseasTransferConsentUrl:e.overseas_transfer_consent_url??C_,personalInfoConsentUrl:e.personal_info_consent_url??p_,thirdPartyConsentUrl:e.third_party_consent_url??g_,termsOfUseUrl:A_,privacyPolicyUrl:h_,koreaAddendumUrl:f_}}function O_(e){const t=e.formatMessage({id:"aboutYou.invalidName",defaultMessage:"Hmm, that doesn't look right. Try again?",description:"Error message shown when the name field in the 'About you' form is not a valid name."});return r.string({required_error:e.formatMessage({id:"aboutYou.nameRequired",defaultMessage:"Please enter name to continue",description:"Error message shown when the name field is empty on the about you page"})}).trim().max(96,t).regex(/^[\p{L}\p{M}\p{P}\s]+$/u,t)}function m_(e,t){const s=(t==null?void 0:t.enforceMinimumAge)??!1,i=new Date,o=new Date(i.getTime()-409968e7),c=e.formatMessage({id:"aboutYou.invalidBirthday",defaultMessage:"Hmm, that doesn't look right. Try again?",description:"Error message shown when the birthday field in the 'About you' form is not a valid date."}),u=e.formatMessage({id:"aboutYou.restrictedBirthdayError",defaultMessage:"We can't create an account with that info. Try again.",description:"Error message displayed when the provided birthday is too young (under 5 years)."});return r.string({required_error:e.formatMessage({id:"aboutYou.birthdayRequired",defaultMessage:"Please enter birthday to continue",description:"Error message shown when the birthday field is empty on the about you page"})}).date().superRefine((l,C)=>{const p=new Date(`${l}T00:00:00Z`);if(pi){C.addIssue({code:r.ZodIssueCode.too_big,type:"date",inclusive:!1,maximum:i.getTime(),message:c});return}if(!s)return;const E=new Date(i);E.setUTCFullYear(E.getUTCFullYear()-5),p>E&&C.addIssue({code:r.ZodIssueCode.too_big,type:"date",inclusive:!0,maximum:E.getTime(),message:u})})}function fu(e){const t=e.formatMessage({id:"tellUsAboutYou.requiredCheckboxErrorMessage",defaultMessage:"Please accept to continue",description:"Error message for required checkboxes"});return r.boolean({required_error:t}).refine(n=>n,{message:t})}function Tu(e,t){return r.object({origin_page_type:d.extract([d.enum.about_you]),data:r.object({name:O_(e),birthday:m_(e,t)})})}r.object({origin_page_type:d}).passthrough();class Ou extends Error{constructor(t){super(t)}}class mu extends Error{constructor(t){super(t)}}function I(e,t){return r.object({status:r.literal(e),data:r.object({error:r.object({code:r.literal(t)})})})}function Iu(e){return new Response(JSON.stringify({error:{code:e.shape.data.shape.error.shape.code.value}}),{status:e.shape.status.value,headers:{"Content-Type":"application/json"}})}const Nu=I(400,"bad_request"),Pu=I(400,"phone_number_in_use"),Lu=I(400,"phone_carrier_unknown"),bu=r.union([I(400,"invalid_phone_number"),I(400,"unhandled_provider_error")]),Ru=I(400,"voip_phone_disallowed"),yu=I(400,"landline_disallowed"),vu=I(400,"password_contains_user_info"),wu=I(400,"password_too_weak"),Uu=r.object({status:r.literal(400),data:r.object({error:r.object({code:r.literal("string_above_max_length"),param:r.literal("password")})})}),I_=I(401,"wrong_email_otp_code"),N_=I(401,"wrong_phone_otp_code"),Du=I(400,"invalid_input"),Wu=I(400,"fraud_guard"),Fu=I(400,"invalid_request");r.union([I_,N_]);const Mu=I(400,"max_check_attempts"),Gu=I(400,"rate_limit_exceeded"),Yu=I(400,"password_already_used"),ku=I(401,"invalid_username_or_password"),ju=I(400,"invalid_username"),Hu=I(401,"password_reset_required"),Vu=I(403,"incorrect_code"),xu=r.object({status:r.literal(429)}),Bu=r.object({status:r.literal(400)});I(409,"invalid_state");I(409,"preauth_cookie_invalid");const Bn=r.string().min(12),Ku=r.object({origin_page_type:r.literal(d.enum.add_password_new_password),data:r.object({password:Bn})}),$u=r.object({origin_page_type:d.extract([d.enum.choose_an_account]),intent:r.enum(["select","remove"]),session_id:r.string()}),P_=r.enum(["passwordless_signup_send_otp"]),qu=r.object({origin_page_type:d.extract([d.enum.create_account_password]),data:r.union([r.object({username:$,password:Bn}),r.object({intent:r.literal(P_.enum.passwordless_signup_send_otp)})])}),U=r.enum(["google","microsoft","apple","email","phone_number"]),L_=r.enum(["existing_user","new_user"]),Le=r.object({name:U,enabled:r.boolean(),tempDisabled:r.boolean(),kind:r.enum(["social","username"]),loginStrategy:L_}),zu={[U.enum.google]:"google-oauth2",[U.enum.microsoft]:"windowslive",[U.enum.apple]:"apple"},b_=r.object({kind:r.literal("username"),username:$}),R_=r.object({kind:r.literal("connection"),connection:U.exclude([U.enum.email,U.enum.phone_number])}),Ju=r.object({origin_page_type:d.extract([d.enum.create_account_start]),data:r.discriminatedUnion("kind",[b_,R_])}),Xu=r.object({origin_page_type:d.extract([d.enum.email_otp_send])});function y_(e){const t=r.object({intent:r.literal(Se.enum.validate),code:ut(e)}),n=r.object({intent:r.literal(Se.enum.resend)});return r.discriminatedUnion("intent",[t,n])}function Zu(e){return r.object({origin_page_type:d.extract([d.enum.email_otp_verification]),data:y_(e)})}const v_=r.object({kind:r.literal("username"),username:$}),w_=r.object({kind:r.literal("connection"),connection:U.exclude([U.enum.email,U.enum.phone_number])}),Qu=r.object({origin_page_type:d.extract([d.enum.login_or_signup_start]),data:r.discriminatedUnion("kind",[v_,w_])}),Kn=r.enum(["verify","try_another_way"]),U_=r.object({issued_at:r.number().int().nonnegative(),challenge:r.string().min(1),signature:r.string().min(1)}),D_=r.object({intent:r.literal(Kn.enum.verify),mfa_request_id:r.string(),passkey_challenge_response:r.unknown(),usernameless_passkey_challenge_data:U_.optional(),using_conditional_ui:r.boolean().optional()}),W_=r.object({intent:r.literal(Kn.enum.try_another_way),username:$.optional()}),F_=r.union([D_,W_]),el=r.object({origin_page_type:d.extract([d.enum.login_passkey]),data:F_}),Jt=r.enum(["validate","passwordless_login_send_otp","passkey"]),tl=r.object({origin_page_type:d.extract([d.enum.login_password]),data:r.discriminatedUnion("intent",[r.object({intent:r.literal(Jt.enum.validate),username:$,password:r.string()}),r.object({intent:r.literal(Jt.enum.passwordless_login_send_otp)})])}),M_=r.object({kind:r.literal("username"),username:$}),G_=r.object({kind:r.literal("connection"),connection:U.exclude([U.enum.email,U.enum.phone_number])}),nl=r.object({origin_page_type:d.extract([d.enum.login_start]),data:r.discriminatedUnion("kind",[M_,G_])}),Xt=r.enum(["validate","reissue"]),Y_=r.discriminatedUnion("intent",[r.object({intent:r.literal(Xt.enum.validate),factor_id:r.string(),factor_type:Y,code:r.string().nullable(),passkey_challenge_response:r.string().optional().nullable(),mfa_request_id:r.string().optional().nullable()}),r.object({intent:r.literal(Xt.enum.reissue),factor_id:r.string(),factor_type:Y,force_fresh_challenge:r.boolean(),mfa_request_id:r.string().optional().nullable()})]),rl=r.object({origin_page_type:d.extract([d.enum.mfa_challenge]),data:Y_}),k_=r.object({factor_id:r.string(),factor_type:Y,code:r.string()}),al=r.object({origin_page_type:d.extract([d.enum.mfa_enroll]),data:k_}),il=r.object({origin_page_type:d.extract([d.enum.phone_otp_send])}),Zt=r.enum(["validate","resend"]);function j_(e){const t=r.object({intent:r.literal(Zt.enum.validate),code:ut(e)}),n=r.object({intent:r.literal(Zt.enum.resend)});return r.discriminatedUnion("intent",[t,n])}function sl(e){return r.object({origin_page_type:d.extract([d.enum.phone_otp_verification]),data:j_(e)})}const Ze=r.enum(["finalize","resend_push_auth_challenge","send_email_otp"]),H_=r.discriminatedUnion("intent",[r.object({intent:r.literal(Ze.enum.finalize),mfa_session_id:r.string()}),r.object({intent:r.literal(Ze.enum.resend_push_auth_challenge),mfa_session_id:r.string()}),r.object({intent:r.literal(Ze.enum.send_email_otp)})]),ol=r.object({origin_page_type:d.extract([d.enum.push_auth_verification]),data:H_}),_l=r.object({origin_page_type:r.literal(d.enum.reset_password_new_password),data:r.object({password:r.string(),username:$.optional()})}),Qt=r.enum(["send_otp","passwordless_login_send_otp","back_to_login"]),cl=r.object({origin_page_type:r.literal(d.enum.reset_password_start),intent:Qt.default(Qt.enum.send_otp)}),V_=(e,t)=>t.formatMessage(nn({id:"form_validation.unexpected_message",description:`A message for an unexpected issue with a field in a form. We try to provide specific messages like "the value you entered isn't a valid date" but this is a backup in case there's an error we haven't specifically accounted for.`,defaultMessage:"Unexpected issue with this field: {errorMessage}"}),{errorMessage:e.message??JSON.stringify(e)});function ul(e){return t=>{const n=e(t);return a=>a.flatMap(s=>{const i=n.map(o=>o(s)).filter(o=>o!==null);return i.length>0?i:[V_(s,t)]})}}function ll(e,t,n){return a=>!Me(a.path,e)||!t(a)?null:typeof n=="function"?n(a):n}function El(e){const[t,n]=g.useState(!!e.status);return g.useEffect(()=>{e.status&&n(!0)},[e.status]),a=>{if(a.value!==void 0||e.status||t)return a.errors}}function dl(e){const t=g.useRef(!1);return(...n)=>{t.current||(e(...n),t.current=!0)}}function Sl(e,t){const[n,a]=ar(),s=e.safeParse(Object.fromEntries(n)),i=a;return[s.success?s.data:t,i]}function en(e){try{const t=new URL(e);return t.protocol==="openai:"||["chatgpt.com","openai.com","chatgpt-staging.com","openai.org"].includes(t.hostname)}catch{return!1}}const Cl=r.object({email:r.string().default(""),phone_number:r.string().default(""),login_url:r.string().default("").transform(e=>en(e)?e:""),no_auth_url:r.string().default("").transform(e=>en(e)?e:""),app_name_enum:r.string().default("")}),x_=e=>({message:nn({id:"form_validation.unexpected_message",description:`A message for an unexpected issue with a field in a form. We try to provide specific messages like "the value you entered isn't a valid date" but this is a backup in case there's an error we haven't specifically accounted for.`,defaultMessage:"Unexpected issue with this field: {errorMessage}"}),values:{errorMessage:e.message??JSON.stringify(e)}});function pl(...e){return t=>t.flatMap(n=>{const a=e.map(s=>s(n)).filter(s=>s!==null);return a.length>0?a:[x_(n)]})}function gl(e,t,n){return a=>!Me(a.path,e)||!t(a)?null:typeof n=="function"?n(a):{message:n}}function Al(e){return e.code===r.ZodIssueCode.invalid_type&&(e.received===r.ZodParsedType.null||e.received===r.ZodParsedType.undefined)}function B_(){const e=ur();return t=>e.formatMessage(t.message,t.values)}function hl(e){const t=B_(),[n,a]=g.useState(!!e.status);return g.useEffect(()=>{e.status&&a(!0)},[e.status]),s=>{var i;if(s.value!==void 0||e.status||n)return(i=s.errors)==null?void 0:i.map(t)}}function fl(e){try{return JSON.parse(e)}catch{return!1}}function Tl(e){return r.string({required_error:e.formatMessage({id:"logIn.phoneRequired",defaultMessage:"Phone number is required.",description:"Error message shown when the phone number field is empty on the create account page"})}).refine(t=>pr(t),e.formatMessage({id:"logIn.invalidPhoneFormat",defaultMessage:"Phone number is not valid.",description:"Error message shown when the phone number format is invalid on the create account page"}))}const K_=["google","apple","microsoft","email","phone_number"],$_=r.object({options:U.array()});function q_({loginStrategy:e}){return ne(`login_web_${e}_login_option_google`,Le,{name:"google",enabled:!0,tempDisabled:!1,kind:"social",loginStrategy:e})}function z_({loginStrategy:e}){return ne(`login_web_${e}_login_option_microsoft`,Le,{name:"microsoft",enabled:!0,tempDisabled:!1,kind:"social",loginStrategy:e})}function J_({loginStrategy:e}){return ne(`login_web_${e}_login_option_apple`,Le,{name:"apple",enabled:!0,tempDisabled:!1,kind:"social",loginStrategy:e})}function X_({loginStrategy:e}){return ne(`login_web_${e}_login_option_email`,Le,{name:"email",enabled:!0,tempDisabled:!1,kind:"username",loginStrategy:e})}function Z_({loginStrategy:e}){const t=ne(`login_web_${e}_login_option_phone_number`,Le,{name:"phone_number",enabled:e==="existing_user",tempDisabled:!1,kind:"username",loginStrategy:e}),n=se.useLayer("chatgpt_phone_signup_layer"),a=se.useGateValue("chatgpt-signup-allow-phone");if(e!=="new_user")return t;const s=n.get("signup_allow_phone_from_login_web",!1),i=n.get("in_phone_signup_holdout_from_login_web",!1),o=a||s,c=t.enabled;return{...t,enabled:!i&&c&&o}}function Ol({loginStrategy:e}){const t=q_({loginStrategy:e}),n=z_({loginStrategy:e}),a=J_({loginStrategy:e}),s=X_({loginStrategy:e}),i=Z_({loginStrategy:e}),{options:o}=ne("web_login_options_list",$_,{options:K_}),u=[t,n,a,s,i].filter(l=>l&&l.enabled&&o.includes(l.name)).sort((l,C)=>o.indexOf(l.name)-o.indexOf(C.name));return{googleOption:t,microsoftOption:n,appleOption:a,emailOption:s,phoneNumberOption:i,optionsForDisplay:u}}const ml="team-1-month-free";function Q_(){const e=se.useGateValue("simplified_auth_screen_var3_feature_launch_gate"),t=se.useLayer("auth_login_signup_holdout_layer").get("is_in_sso_up_holdout",!1);return!e||t?ec:"google_and_others_and_username"}function Il(){const t=Q_()==="google_and_others_and_username",n=se.useLayer("auth_login_signup_holdout_layer").get("is_use_phone_a_link",!1);return{isSocialAuthPrioritized:t,isUsePhoneALink:n}}const ec="username_and_socials",Nl=()=>{const{signup_source:e,promo:t}=Ao();return e!=="business"?{enabled:!1,promo:""}:{enabled:!0,promo:t}},tc=r.object({redirect_url:r.string().default("")});function Pl(){const{redirect_url:e}=ne("signup-waitlist-urls",tc,{redirect_url:""});return e||null}function Ll(e,t){return e.formatMessage({id:"thirdPartyLoginSubtitle",defaultMessage:`{app, select, + whatsapp {to link your account to WhatsApp} + other {to link your account} + }`,description:"Subtitle on a page where the user can log into an account or create a new one that shows an external app that the user will connect their account to. The page this is shown on could be a login page with a title like 'Welcome back' or an account creation page with a title like 'Create an account'."},{app:t})}const bl=()=>{const e=[W.dotFaint,W.dotMedium,W.dotSolid],t=[W.dotSolid,W.dotMedium,W.dotFaint];return j.jsxs("div",{className:W.decoration,"aria-hidden":"true",children:[j.jsx("div",{className:W.dotGroup,children:e.map((n,a)=>j.jsx("span",{className:`${W.dot} ${n}`},`left-${a}`))}),j.jsx("div",{className:W.iconCircle,children:j.jsx(_r,{className:W.passkeyIcon})}),j.jsx("div",{className:W.dotGroup,children:t.map((n,a)=>j.jsx("span",{className:`${W.dot} ${n}`},`right-${a}`))})]})};function Rl(e){return r.string({required_error:e.formatMessage({id:"login.emailRequired",defaultMessage:"Email is required.",description:"Error message shown when the email field is empty on the create account page"})}).email(e.formatMessage({id:"logIn.invalidEmailFormat",defaultMessage:"Email is not valid.",description:"Error message shown when the email format is invalid on the create account page"}))}function yl(e,t){return typeof(e==null?void 0:e.mfa_request_id)!="string"||e.mfa_request_id!==t?{challengeMode:null,matchNumber:null}:{challengeMode:typeof e.challenge_mode=="string"?e.challenge_mode:null,matchNumber:typeof e.match_number=="string"?e.match_number:null}}class vl{static async fetchTrustedDevicesOnLogin(){try{const n=await fetch("https://auth.openai.com/api/accounts/push-auth/devices/on-login",{method:"GET",headers:{Accept:"application/json"},credentials:"include"});return n.ok?(await n.json()).devices??[]:[]}catch{throw new Error("Failed to fetch trusted devices for push auth verification")}}}async function wl(e,t){const n=btoa(JSON.stringify({kind:"AuthApiFailure",errorCode:t}));let a;try{a=k().immutableClientSessionMetadata.session_id}catch{}const s=new URLSearchParams({payload:n,...a?{session_id:a}:{}});e(`/error?${s.toString()}`,{replace:!0})}function Ul(e,t){const n=new URLSearchParams({error:t});e(`/mfa-challenge?${n.toString()}`,{replace:!0})}const nc=r.object({id:r.string(),public_id:r.string().nullish(),title:r.string().nullish()}),rc=r.object({id:r.string(),name:r.string().nullish(),title:r.string().nullish(),profile_picture_url:r.string().nullish(),profile_picture_alt_text:r.string().nullish(),projects:r.array(nc).optional(),personal:r.boolean().default(!1),completed_platform_onboarding:r.boolean().default(!1).nullish()});function Dl(e){sessionStorage.setItem("organizations",JSON.stringify(e))}function Wl(){const e=r.array(rc).safeParse(JSON.parse(sessionStorage.getItem("organizations")??"[]"));return e.success?(e.data.forEach(t=>{t.projects&&t.projects.sort((n,a)=>{const s=n.title==="Sky",i=a.title==="Sky";return s&&!i?-1:!s&&i?1:0})}),e.data):[]}const $n="missing_error_page_verifier_session",ac=r.object({code:r.literal($n),reason:r.enum(["missing_query_verifier_id","missing_cookie_verifier_id","verifier_id_mismatch"])});function Fl(e){throw ir({code:$n,reason:e},{status:400,statusText:"Invalid Session"})}function Ml(e){return sr(e)?ac.safeParse(e.data).success:!1}const ic=r.object({id:r.string()}),sc="error_page_verifier";function Gl(e){const t=vn(e,sc);if(!t)return{is_missing_verifier:!0};try{const n=In(t,{header:!0}),a=ic.safeParse(n);if(a.success)return{verifier_id:a.data.id,is_missing_verifier:!1}}catch{return{is_missing_verifier:!0}}return{is_missing_verifier:!0}}function oc(e,t){if(!e){m.addAction("login_web_missing_client_auth_session_payload_in_navigation",{path:t});return}if(typeof e!="object"||Array.isArray(e)){m.addAction("login_web_invalid_client_auth_session_payload_in_navigation",{path:t});return}return e["oai-client-auth-session"]}async function _c(e,t){const{clientAuthSessionCache:n}=k(),a=oc(e,t);if(a===void 0)return;const s=Ye.safeParse(a);if(!s.success)return;const i=ct(document.cookie);if(!i){m.addAction("login_web_missing_client_auth_session_minimized_checksum",{navigationPath:t});return}m.addAction("login_web_writing_client_auth_session_to_cache",{path:t}),n.write({checksum:i,sessionId:typeof s.data.session_id=="string"?s.data.session_id:null,session:s.data})}const cc=Object.freeze(Object.defineProperty({__proto__:null,maybeWriteClientAuthSessionCacheFromNavigationJson:_c},Symbol.toStringTag,{value:"Module"}));export{Cl as $,Us as A,Lo as B,go as C,Gc as D,S as E,Hn as F,k as G,zc as H,Dc as I,Uc as J,Fc as K,qc as L,Oe as M,Vc as N,Mc as O,_e as P,ou as Q,_ as R,Hc as S,i_ as T,ee as U,wc as V,Su as W,Cu as X,pu as Y,gu as Z,kc as _,eu as a,no as a$,hl as a0,pl as a1,gl as a2,Al as a3,El as a4,Tl as a5,d as a6,Ao as a7,Bo as a8,Pc as a9,_u as aA,Kn as aB,bl as aC,Rl as aD,zu as aE,Eu as aF,vl as aG,yl as aH,wl as aI,Qt as aJ,Wu as aK,Yu as aL,vu as aM,wu as aN,Me as aO,rc as aP,Dl as aQ,Wl as aR,Tc as aS,Nl as aT,ml as aU,hu as aV,ot as aW,iu as aX,au as aY,su as aZ,vc as a_,$t as aa,Se as ab,Mu as ac,Nc as ad,t_ as ae,Jc as af,Xc as ag,Bn as ah,P_ as ai,F as aj,Rc as ak,Ko as al,q as am,y_ as an,j_ as ao,Zt as ap,ru as aq,nu as ar,U as as,Pl as at,Il as au,Ll as av,ul as aw,Yc as ax,ll as ay,Jt as az,Wc as b,Bc as b0,Zc as b1,ks as b2,A as b3,O_ as b4,m_ as b5,fu as b6,fl as b7,ro as b8,$ as b9,ju as bA,Xu as bB,Zu as bC,Qu as bD,xu as bE,el as bF,tl as bG,ku as bH,Hu as bI,nl as bJ,rl as bK,mu as bL,Vu as bM,al as bN,il as bO,sl as bP,ol as bQ,_l as bR,cl as bS,bc as bT,Ul as ba,Xt as bb,Gl as bc,Ge as bd,Fl as be,Ml as bf,Tu as bg,Ou as bh,jc as bi,Ku as bj,$u as bk,Kc as bl,Iu as bm,Du as bn,Gu as bo,qu as bp,Fu as bq,Nu as br,Pu as bs,Ru as bt,yu as bu,Lu as bv,bu as bw,Uu as bx,Bu as by,Ju as bz,tu as c,$c as d,mc as e,Ic as f,Ol as g,Lc as h,Oc as i,dl as j,fe as k,ne as l,G as m,et as n,Sl as o,cu as p,lu as q,Au as r,uu as s,du as t,Qc as u,In as v,m as w,xc as x,yc as y,J as z}; +//# sourceMappingURL=app-core-BVP192sF.js.map diff --git a/tmp_redirectToPage.js b/tmp_redirectToPage.js new file mode 100644 index 00000000..d1190497 --- /dev/null +++ b/tmp_redirectToPage.js @@ -0,0 +1,2 @@ +import{e as z,r as Y,b as K,m as c}from"./react-router-Dv7ITjcY.js";import{bg as Q,bh as f,bi as l,R as h,a6 as s,bj as X,aL as L,aM as I,aN as T,bk as Z,bl as x,ab as V,ac as v,bm as j,aK as P,bn as D,bo as F,bp as ee,ai as se,bq as b,br as re,bs as ae,bt as te,bu as ne,bv as oe,bw as ie,bx as de,by as J,bz as ue,bA as O,U as E,aE as A,bB as ce,bC as pe,bD as le,bE as M,bF as me,aB as fe,bG as he,az as ge,bH as we,bI as _e,bJ as ye,bK as Pe,S as N,bL as R,bb as Ee,bM as W,bN as ve,bO as be,bP as Oe,ap as Me,bQ as Se,bR as Re,bS as Ie,aJ as C,h as G}from"./app-core-BVP192sF.js";import{z as _}from"./zod-BeXw7Xwl.js";const Te=_.object({status:_.literal(400),json:_.object({error:_.object({code:_.literal("registration_disallowed")})})}),Ae=_.object({status:_.literal(400),json:_.object({error:_.object({code:_.literal("registration_disallowed_too_young")})})}),Ne=(r,t,e)=>{const a=Q(e.intl).safeParse(t);if(!a.success)throw new f(a.error.message);return l(r,"/create_account",{...e.init,method:"POST",body:JSON.stringify({name:a.data.data.name,birthdate:a.data.data.birthday})},{authapiBaseUrlOverride:e.authapiBaseUrlOverride,routeId:h.ABOUT_YOU,intercept:i=>{if(Te.safeParse(i).success)return{type:s.enum.about_you,payload:{errors:{formErrors:[e.intl.formatMessage({id:"aboutYou.cantCreateYourAccount",defaultMessage:"We can't create your account due to our Terms of Use",description:"Error message displayed when the user is not allowed to create an account for a generic reason."})]}}};if(Ae.safeParse(i).success)return{type:s.enum.about_you,payload:{errors:{formErrors:[e.intl.formatMessage({id:"aboutYou.restrictedBirthdayError",defaultMessage:"We can't create an account with that info. Try again.",description:"Error message displayed when the provided birthday is too young (under 5 years)."})]}}}}})},Ue=(r,t,e)=>{const a=X.safeParse(t);if(!a.success)throw new f(a.error.message);const{password:i}=a.data.data;return l(r,"/password/add",{...e.init,method:"POST",body:JSON.stringify({password:i})},{authapiBaseUrlOverride:e.authapiBaseUrlOverride,routeId:h.ADD_PASSWORD_NEW_PASSWORD,intercept:n=>{const d={status:n.status,data:n.json};if(L.safeParse(d).success)return{type:s.enum.add_password_new_password,payload:{errors:{fieldErrors:{password:[e.intl.formatMessage({id:"resetPasswordNewPassword.passwordMustNotBeReused",defaultMessage:"Password must not be reused",description:"Error message shown when a new password is reused from the previous password"})]}}}};if(I.safeParse(d).success)return{type:s.enum.add_password_new_password,payload:{errors:{fieldErrors:{password:[e.intl.formatMessage({id:"resetPasswordNewPassword.passwordContainsUserInfo",defaultMessage:"Password must not contain personal information like your email, phone number, or name",description:"Error message displayed when the user tries to reset their password with a password that contains user information"})]}}}};if(T.safeParse(d).success)return{type:s.enum.add_password_new_password,payload:{errors:{fieldErrors:{password:[e.intl.formatMessage({id:"resetPasswordNewPassword.passwordTooWeak",defaultMessage:"Password is too weak",description:"Error message displayed when the user tries to reset their password with a password that is too weak"})]}}}}}})},ke=(r,t,e)=>{const a=Z.safeParse(t);if(!a.success)throw new f(a.error.message);const{path:i,method:n}=a.data.intent==="select"?{path:"/session/select",method:"POST"}:{path:"/session/remove",method:"DELETE"};return l(r,i,{...e.init,method:n,body:JSON.stringify({session_id:a.data.session_id})},{routeId:h.CHOOSE_AN_ACCOUNT})},Be=async(r,t,{intl:e,init:a,authapiBaseUrlOverride:i})=>{var p;const n=x(e).safeParse(t);if(!n.success)throw new f(n.error.message);const d=n.data.data.username_kind==="email"?"/email-otp":"/phone-otp";if(n.data.data.intent===V.enum.validate)return l(r,`${d}/validate`,{...a,method:"POST",body:JSON.stringify({code:n.data.data.code})},{authapiBaseUrlOverride:i,routeId:h.CONTACT_VERIFICATION,intercept:({status:m,json:w})=>{if(v.safeParse({status:m,data:w}).success)throw j(v);if(P.safeParse({status:m,data:w}).success)return{type:s.enum.contact_verification,payload:{errors:{formErrors:[e.formatMessage({id:"contactVerification.fraudGuard",description:"Error message displayed when a phone number fails fraud guard checks.",defaultMessage:"Unable to send a text message to this phone number"})]}}};if(m===401||D.safeParse({status:m,data:w}).success)return{type:s.enum.contact_verification,payload:{errors:{fieldErrors:{code:[e.formatMessage({id:"contactVerification.incorrectCode",description:"Error message displayed when the user enters an incorrect OTP code.",defaultMessage:"Incorrect code"})]}}}}}});const o=await fetch(`${i??"https://auth.openai.com/api/accounts"}${d}/resend`,{...a,method:"POST",credentials:"include",signal:r.signal});if(o.ok)return{page:{type:s.enum.contact_verification},responseHeaders:o.headers};const u=(p=o.headers.get("Content-Type"))!=null&&p.includes("application/json")?await o.clone().json():null;if(F.safeParse({status:o.status,data:u}).success)return{page:{type:s.enum.contact_verification,payload:{errors:{formErrors:[e.formatMessage({id:"contactVerification.tooManyResentOtps",defaultMessage:"Tried to resend too many times. Please try again later.",description:"Error message for too many attempts"})]}}},responseHeaders:o.headers};if(n.data.data.username_kind==="phone_number"&&P.safeParse({status:o.status,data:u}).success)return{page:{type:s.enum.contact_verification,payload:{errors:{formErrors:[e.formatMessage({id:"contactVerification.fraudGuard",description:"Error message displayed when a phone number fails fraud guard checks.",defaultMessage:"Unable to send a text message to this phone number"})]}}},responseHeaders:o.headers};throw o},Ce=(r,t,{intl:e,init:a,authapiBaseUrlOverride:i})=>{const n=ee.safeParse(t);if(!n.success)throw new f(n.error.message);if("intent"in n.data.data&&n.data.data.intent===se.enum.passwordless_signup_send_otp)return l(r,"/passwordless/send-otp",{...a},{authapiBaseUrlOverride:i,routeId:h.CREATE_ACCOUNT_PASSWORD,intercept:o=>{const u={status:o.status,data:o.json};if(b.safeParse(u).success)return{type:s.enum.create_account_password,payload:{errors:{formErrors:[e.formatMessage({id:"createAccountPassword.passwordlessSignupSendOtp.InvalidRequest",defaultMessage:"We couldn’t send you a one-time code. Please try again or create account with a password.",description:"Shown when the create account password page cannot send a one-time code due to an invalid request"})]}}}}});const d=n.data.data;return l(r,"/user/register",{...a,body:JSON.stringify({password:d.password,username:d.username.value})},{authapiBaseUrlOverride:i,routeId:h.CREATE_ACCOUNT_PASSWORD,intercept:o=>{const u={status:o.status,data:o.json},p=d.username.kind==="email"?e.formatMessage({id:"createAccountPassword.emailAlreadyInUse",defaultMessage:"An account for this email address already exists",description:"Error message displayed when the user tries to create an account with a username that is already in use."}):e.formatMessage({id:"createAccountPassword.phoneAlreadyInUse",defaultMessage:"An account for this phone number already exists",description:"Error message displayed when the user tries to create an account with a username that is already in use."});if(re.safeParse(u).success)return{type:s.enum.create_account_password,payload:{errors:{formErrors:[p]}}};if(ae.safeParse(u).success)return{type:s.enum.create_account_password,payload:{errors:{formErrors:[p]}}};if(te.safeParse(u).success)return{type:s.enum.create_account_password,payload:{errors:{formErrors:[e.formatMessage({id:"createAccountPassword.voipPhoneNumberDisallowed",defaultMessage:"It looks like this is a virtual phone number (also known as VoIP). Please provide a valid, non-virtual phone number to continue.",description:"Error message displayed when the user tries to create an account with a VoIP (virtual phone number) and it's not allowed, asking them to use a valid phone number."})]}}};if(ne.safeParse(u).success)return{type:s.enum.create_account_password,payload:{errors:{formErrors:[e.formatMessage({id:"createAccountPassword.landlineDisallowed",defaultMessage:"The phone number you entered does not appear to be a mobile number. Please provide a valid mobile phone number that can receive SMS messages to continue.",description:"Error message displayed when the user tries to create an account with a non-mobile phone number, asking them to use a valid mobile phone number that can receive SMS message."})]}}};if(oe.safeParse(u).success)return{type:s.enum.create_account_password,payload:{errors:{formErrors:[e.formatMessage({id:"createAccountPassword.unsupportedPhoneNumber",defaultMessage:"The phone number you entered is not supported, please try a different one.",description:"Error message displayed when the user tries to create an account with an unsupported phone number."})]}}};if(ie.safeParse(u).success)return{type:s.enum.create_account_password,payload:{errors:{formErrors:[e.formatMessage({id:"createAccountPassword.invalidPhoneNumber",defaultMessage:"The phone number you entered is invalid. Please check and try again.",description:"Error message displayed when the user tries to create an account with an invalid phone number."})]}}};if(I.safeParse(u).success)return{type:s.enum.create_account_password,payload:{errors:{fieldErrors:{password:[e.formatMessage({id:"createAccountPassword.passwordContainsUserInfo",defaultMessage:"Password must not contain personal information like your email, phone number, or name",description:"Error message displayed when the user tries to create an account with a password that contains user information"})]}}}};if(T.safeParse(u).success)return{type:s.enum.create_account_password,payload:{errors:{fieldErrors:{password:[e.formatMessage({id:"createAccountPassword.passwordTooWeak",defaultMessage:"Password is too weak",description:"Error message displayed when the user tries to create an account with a password that is too weak"})]}}}};if(de.safeParse(u).success)return{type:s.enum.create_account_password,payload:{errors:{fieldErrors:{password:[e.formatMessage({id:"createAccountPassword.passwordTooLong",defaultMessage:"Password must be no more than 512 characters",description:"Error message displayed when the user tries to create an account with a password that exceeds the maximum allowed length"})]}}}};if(J.safeParse(u).success)return{type:s.enum.create_account_password,payload:{errors:{formErrors:[e.formatMessage({id:"createAccountPassword.genericBadRequest",defaultMessage:"Failed to create account. Please try again",description:"Generic error message when account creation fails with a generic bad request error without a specific error code"})]}}}}})},He=(r,t,e)=>{const a=ue.safeParse(t);if(!a.success)throw new f(a.error.message);const i=qe(a.data);return l(r,"/authorize/continue",{...e.init,body:JSON.stringify(i)},{authapiBaseUrlOverride:e.authapiBaseUrlOverride,routeId:h.CREATE_ACCOUNT,intercept:n=>{const d={status:n.status,data:n.json};if(O.safeParse(d).success&&a.data.data.kind==="username"){const o=a.data.data.username.kind===E.enum.phone_number;return{type:s.enum.create_account_start,payload:{errors:{fieldErrors:{username:[e.intl.formatMessage(o?{id:"createAccountStart.invalidPhoneNumber",defaultMessage:"Phone number is not valid.",description:"Error message shown when the server reports an invalid phone number during create account start."}:{id:"createAccountStart.invalidEmail",defaultMessage:"Email is not valid.",description:"Error message shown when the server reports an invalid email during create account start."})]}}}}}}})};function qe({data:r}){return r.kind==="username"?{username:r.username,screen_hint:"signup"}:{connection:A[r.connection]}}const Le=(r,t,{init:e,authapiBaseUrlOverride:a})=>{const i=ce.safeParse(t);if(!i.success)throw new f(i.error.message);return l(r,"/email-otp/send",{...e,method:"GET"},{authapiBaseUrlOverride:a})},Ve=async(r,t,{intl:e,init:a,authapiBaseUrlOverride:i})=>{const n=pe(e).safeParse(t);if(!n.success)throw new f(n.error.message);const d="/email-otp";if(n.data.data.intent===V.enum.validate)return l(r,`${d}/validate`,{...a,method:"POST",body:JSON.stringify({code:n.data.data.code})},{authapiBaseUrlOverride:i,routeId:h.EMAIL_VERIFICATION,intercept:({status:u})=>{if(u===429)return{type:s.enum.email_otp_verification,payload:{errors:{formErrors:[e.formatMessage({id:"emailVerification.tooManyValidateOtps",defaultMessage:"Too many attempts. Please try again later.",description:"Error message when the user tries to validate the OTP code too many times"})]}}};if(u===401)return{type:s.enum.email_otp_verification,payload:{errors:{fieldErrors:{code:[e.formatMessage({id:"emailVerification.incorrectCode",description:"Error message displayed when the user enters an incorrect OTP code.",defaultMessage:"Incorrect code"})]}}}}}});const o=await fetch(`${i??"https://auth.openai.com/api/accounts"}${d}/resend`,{...a,method:"POST",credentials:"include",signal:r.signal});if(o.ok)return{page:{type:s.enum.email_otp_verification},responseHeaders:o.headers};if(o.status===429)return{page:{type:s.enum.email_otp_verification,payload:{errors:{formErrors:[e.formatMessage({id:"emailVerification.tooManyResentOtps",defaultMessage:"Too many attempts. Please try again later.",description:"Error message when the user tries to resend the OTP code too many times"})]}}},responseHeaders:o.headers};throw o},je=(r,t,e)=>{const a=le.safeParse(t);if(!a.success)throw new f(a.error.message);const i=De(a.data);return l(r,"/authorize/continue",{...e.init,body:JSON.stringify(i)},{authapiBaseUrlOverride:e.authapiBaseUrlOverride,routeId:h.UNIFIED_LOG_IN_OR_SIGN_UP_INPUT,intercept:n=>{const d={status:n.status,data:n.json};if(O.safeParse(d).success&&a.data.data.kind==="username"){const o=a.data.data.username.kind===E.enum.phone_number;return{type:s.enum.login_or_signup_start,payload:{errors:{fieldErrors:{username:[e.intl.formatMessage(o?{id:"loginOrSignupStart.invalidPhoneNumber",defaultMessage:"Phone number is not valid.",description:"Error message shown when the server reports an invalid phone number during login-or-signup start."}:{id:"loginOrSignupStart.invalidEmail",defaultMessage:"Email is not valid.",description:"Error message shown when the server reports an invalid email during login-or-signup start."})]}}}}}if(M.safeParse(d).success)return{type:s.enum.login_or_signup_start,payload:{errors:{formErrors:[e.intl.formatMessage({id:"loginOrSignupStart.tooManyRequests",defaultMessage:"Rate limit exceeded, please try again later",description:"Error message shown when the user is rate limited because they have made too many requests during login-or-signup start."})]}}}}})};function De({data:r}){return r.kind==="username"?{username:r.username,screen_hint:"login_or_signup"}:{connection:A[r.connection]}}const Fe=(r,t,e)=>{const a=me.safeParse(t);if(!a.success)throw new f(a.error.message);if(a.data.data.intent===fe.enum.try_another_way){const{username:u}=a.data.data;return l(r,"/authorize/continue",{...e.init,body:JSON.stringify({...u?{username:u,screen_hint:"login_or_signup"}:{},from_login_screen:"passkey"})},{authapiBaseUrlOverride:e.authapiBaseUrlOverride,routeId:h.LOG_IN_PASSKEY,intercept:p=>{const m={status:p.status,data:p.json};if(O.safeParse(m).success&&u){const w=u.kind===E.enum.phone_number;return{type:s.enum.login_or_signup_start,payload:{errors:{fieldErrors:{username:[e.intl.formatMessage(w?{id:"loginOrSignupStart.invalidPhoneNumber",defaultMessage:"Phone number is not valid.",description:"Error message shown when the server reports an invalid phone number during login-or-signup start."}:{id:"loginOrSignupStart.invalidEmail",defaultMessage:"Email is not valid.",description:"Error message shown when the server reports an invalid email during login-or-signup start."})]}}}}}if(M.safeParse(m).success)return{type:s.enum.login_or_signup_start,payload:{errors:{formErrors:[e.intl.formatMessage({id:"loginOrSignupStart.tooManyRequests",defaultMessage:"Rate limit exceeded, please try again later",description:"Error message shown when the user is rate limited because they have made too many requests during login-or-signup start."})]}}}}})}const{mfa_request_id:i,passkey_challenge_response:n,usernameless_passkey_challenge_data:d,using_conditional_ui:o}=a.data.data;return l(r,"/passkey/verify",{...e.init,method:"POST",body:JSON.stringify({mfa_request_id:i,passkey_challenge_response:n,...d!==void 0?{usernameless_passkey_challenge_data:d}:{},...o!==void 0?{using_conditional_ui:o}:{}})},{authapiBaseUrlOverride:e.authapiBaseUrlOverride,routeId:h.LOG_IN_PASSKEY})},Je=(r,t,e)=>{const a=he.safeParse(t);if(!a.success)throw new f(a.error.message);if(a.data.data.intent===ge.enum.passwordless_login_send_otp)return l(r,"/passwordless/send-otp",{...e.init},{authapiBaseUrlOverride:e.authapiBaseUrlOverride,routeId:h.LOG_IN_PASSWORD,intercept:d=>{const o={status:d.status,data:d.json};if(b.safeParse(o).success)return{type:s.enum.login_password,payload:{errors:{formErrors:[e.intl.formatMessage({id:"loginPassword.passwordlessLoginSendOtp.InvalidRequest",defaultMessage:"We couldn’t send you a one-time code. Please try again or continue with your password.",description:"Shown when the login password page cannot send a one-time code due to an invalid request"})]}}}}});const{username:i,password:n}=a.data.data;return l(r,"/password/verify",{...e.init,method:"POST",body:JSON.stringify({password:n})},{authapiBaseUrlOverride:e.authapiBaseUrlOverride,routeId:h.LOG_IN_PASSWORD,intercept:d=>{const o={status:d.status,data:d.json};if(we.safeParse(o).success){const u=(i==null?void 0:i.kind)===E.enum.phone_number;return{type:s.enum.login_password,payload:{errors:{fieldErrors:{password:[e.intl.formatMessage(u?{id:"logInPassword.incorrectPhonePassword",defaultMessage:"Incorrect phone number or password",description:"Error message shown when a user enters an incorrect password for phone-password login."}:{id:"logInPassword.incorrectEmailPassword",defaultMessage:"Incorrect email address or password",description:"Error message shown when a user enters an incorrect password for email-password login."})]}}}}}if(_e.safeParse(o).success)return{type:s.enum.login_password,payload:{errors:{fieldErrors:{password:[e.intl.formatMessage({id:"logInPassword.passwordResetRequired",defaultMessage:"For improved account protection, please change your password and then log in again",description:"Error message shown when the user must reset their password before logging in."})]}}}}}})},We=(r,t,e)=>{const a=ye.safeParse(t);if(!a.success)throw new f(a.error.message);const i=Ge(a.data);return l(r,"/authorize/continue",{...e.init,method:"POST",body:JSON.stringify(i)},{authapiBaseUrlOverride:e.authapiBaseUrlOverride,routeId:h.LOG_IN,intercept:n=>{const d={status:n.status,data:n.json};if(O.safeParse(d).success&&a.data.data.kind==="username"){const o=a.data.data.username.kind===E.enum.phone_number;return{type:s.enum.login_start,payload:{errors:{fieldErrors:{username:[e.intl.formatMessage(o?{id:"loginStart.invalidPhoneNumber",defaultMessage:"Phone number is not valid.",description:"Error message shown when the server reports an invalid phone number during login start."}:{id:"loginStart.invalidEmail",defaultMessage:"Email is not valid.",description:"Error message shown when the server reports an invalid email during login start."})]}}}}}}})};function Ge({data:r}){return r.kind==="username"?{username:r.username}:{connection:A[r.connection]}}const $e=async(r,t,e)=>{var U;const a=Pe.safeParse(t);if(!a.success)throw new f(a.error.message);const{intent:i,factor_id:n,factor_type:d}=a.data.data,{mfa_factors:o,passkey_challenge_option:u}=await N(r);if(!o)throw new R("MFA factors not found");const p=o.find(y=>y.id===n);if(!p)throw new R("MFA factor not found");if(i===Ee.enum.validate){const y={id:n,type:d,code:a.data.data.code};if(p.factor_type!=="passkey"&&y.code==null)throw new f("Missing verification code");const k=a.data.data.passkey_challenge_response;if(k)try{y.passkey_challenge_response=JSON.parse(k)}catch(S){throw console.error("Invalid passkey challenge response payload",S),new f("Invalid passkey challenge response payload")}else if(p.factor_type==="passkey")throw new f("Missing passkey challenge response");return y.mfa_request_id=a.data.data.mfa_request_id??H(p,u),l(r,"/mfa/verify",{...e.init,method:"POST",body:JSON.stringify(y)},{authapiBaseUrlOverride:e.authapiBaseUrlOverride,routeId:h.MFA_CHALLENGE_METHOD,intercept:({status:S,json:$})=>{const B={status:S,data:$};if(W.safeParse(B).success)return{type:s.enum.mfa_challenge,payload:{factor_id:n,factors:o,errors:{fieldErrors:{code:[e.intl.formatMessage({id:"mfaChallenge.incorrectCode",defaultMessage:"Incorrect code. Please try again.",description:"Error message displayed when the user enters an incorrect code."})]}}}};if(M.safeParse(B).success)return{type:s.enum.mfa_challenge,payload:{factor_id:n,factors:o,errors:{fieldErrors:{code:[e.intl.formatMessage({id:"mfaChallenge.tooManyRequests",defaultMessage:"Too many requests. Please try again later.",description:"Error message displayed when the user has made too many requests."})]}}}}}})}const m=new Headers((U=e.init)==null?void 0:U.headers);m.set("Content-Type","application/json"),m.set("Accept","application/json");const w=await fetch(`${e.authapiBaseUrlOverride??"https://auth.openai.com/api/accounts"}/mfa/issue_challenge`,{...e.init,credentials:"include",method:"POST",signal:r.signal,headers:m,body:JSON.stringify({id:n,type:d,force_fresh_challenge:a.data.data.force_fresh_challenge,mfa_request_id:a.data.data.mfa_request_id??H(p,u)})});if(w.ok)return{page:{type:s.enum.mfa_challenge,payload:{factor_id:n,factors:o}},responseHeaders:w.headers};if(w.status===429)return{page:{type:s.enum.mfa_challenge,payload:{factor_id:n,factors:o,errors:{fieldErrors:{code:[e.intl.formatMessage({id:"mfaChallenge.tooManyRequests",defaultMessage:"Too many requests. Please try again later.",description:"Error message displayed when the user has made too many requests."})]}}}},responseHeaders:w.headers};throw w};function H(r,t){var e;if(r.factor_type==="push_auth")return((e=r.metadata)==null?void 0:e.mfa_request_id)??void 0;if(r.factor_type==="passkey")return t==null?void 0:t.mfa_request_id}async function q(r){const{mfa_enrollment_factors:t}=await N(r);if(!t)throw new R("MFA enrollment factors not found");return t}const ze=async(r,t,e)=>{const a=ve.safeParse(t);if(!a.success)throw new f(a.error.message);const{factor_id:i,factor_type:n,code:d}=a.data.data;return l(r,"/mfa/activate",{...e.init,method:"POST",body:JSON.stringify({id:i,type:n,code:d})},{authapiBaseUrlOverride:e.authapiBaseUrlOverride,routeId:h.MFA_ENROLL_METHOD,intercept:async({status:o,json:u})=>{const p={status:o,data:u};if(W.safeParse(p).success){const m=await q(r);return{type:s.enum.mfa_enroll,payload:{factor_id:i,factors:m,errors:{fieldErrors:{code:[e.intl.formatMessage({id:"mfaEnroll.incorrectCode",defaultMessage:"Incorrect code. Please try again.",description:"Error message displayed when the user enters an incorrect code during enrollment."})]}}}}}if(M.safeParse(p).success){const m=await q(r);return{type:s.enum.mfa_enroll,payload:{factor_id:i,factors:m,errors:{formErrors:[e.intl.formatMessage({id:"mfaEnroll.tooManyRequests",defaultMessage:"Too many requests. Please try again later.",description:"Error message displayed when the user has made too many requests during enrollment."})]}}}}}})},Ye=(r,t,{init:e,authapiBaseUrlOverride:a})=>{const i=be.safeParse(t);if(!i.success)throw new f(i.error.message);return l(r,"/phone-otp/send",{...e,method:"GET"},{authapiBaseUrlOverride:a})},Ke=async(r,t,{intl:e,init:a,authapiBaseUrlOverride:i})=>{var p;const n=Oe(e).safeParse(t);if(!n.success)throw new f(n.error.message);const d="/phone-otp";if(n.data.data.intent===Me.enum.validate)return l(r,`${d}/validate`,{...a,method:"POST",body:JSON.stringify({code:n.data.data.code})},{authapiBaseUrlOverride:i,routeId:h.PHONE_VERIFICATION,intercept:({status:m,json:w})=>{if(v.safeParse({status:m,data:w}).success)throw j(v);if(P.safeParse({status:m,data:w}).success)return{type:s.enum.phone_otp_verification,payload:{errors:{formErrors:[e.formatMessage({id:"phoneVerification.fraudGuard",description:"Error message displayed when a phone number fails fraud guard checks.",defaultMessage:"Unable to send a text message to this phone number"})]}}};if(m===401||D.safeParse({status:m,data:w}).success)return{type:s.enum.phone_otp_verification,payload:{errors:{fieldErrors:{code:[e.formatMessage({id:"phoneVerification.incorrectCode",description:"Error message displayed when the user enters an incorrect OTP code.",defaultMessage:"Incorrect code"})]}}}}}});const o=await fetch(`${i??"https://auth.openai.com/api/accounts"}${d}/resend`,{...a,method:"POST",credentials:"include",signal:r.signal});if(o.ok)return{page:{type:s.enum.phone_otp_verification},responseHeaders:o.headers};const u=(p=o.headers.get("Content-Type"))!=null&&p.includes("application/json")?await o.clone().json():null;if(F.safeParse({status:o.status,data:u}).success)return{page:{type:s.enum.phone_otp_verification,payload:{errors:{formErrors:[e.formatMessage({id:"phoneVerification.tooManyResentOtps",defaultMessage:"Tried to resend too many times. Please try again later.",description:"Error message for too many attempts"})]}}},responseHeaders:o.headers};if(P.safeParse({status:o.status,data:u}).success)return{page:{type:s.enum.phone_otp_verification,payload:{errors:{formErrors:[e.formatMessage({id:"phoneVerification.fraudGuard",description:"Error message displayed when a phone number fails fraud guard checks.",defaultMessage:"Unable to send a text message to this phone number"})]}}},responseHeaders:o.headers};throw o},Qe=async(r,t,e)=>{var u;const a=Se.safeParse(t);if(!a.success)throw new f(a.error.message);const i=a.data.data.intent;if(i==="finalize"){const{mfa_session_id:p}=a.data.data;return l(r,"/push-auth/validate",{...e.init,method:"POST",body:JSON.stringify({mfa_session_id:p}),signal:r.signal,credentials:"include"},{authapiBaseUrlOverride:e.authapiBaseUrlOverride,routeId:h.PUSH_AUTH_VERIFICATION})}if(i==="send_email_otp")return l(r,"/email-otp/send",{...e.init,method:"POST",signal:r.signal,credentials:"include"},{authapiBaseUrlOverride:e.authapiBaseUrlOverride,routeId:h.PUSH_AUTH_VERIFICATION});const{mfa_session_id:n}=a.data.data,d=new Headers((u=e.init)==null?void 0:u.headers);d.set("Accept","application/json"),d.set("Content-Type","application/json");const o=await fetch(`${e.authapiBaseUrlOverride??"https://auth.openai.com/api/accounts"}/push-auth/resend`,{...e.init,method:"POST",credentials:"include",signal:r.signal,headers:d,body:JSON.stringify({mfa_session_id:n})});if(o.ok){const{session_id:p}=await N(r),m=new URLSearchParams;return p&&m.set("session_id",p),{page:{type:s.enum.push_auth_verification,payload:{mfa_request_id:n,query_params:m.toString()}},responseHeaders:o.headers}}throw o},Xe=(r,t,e)=>{const a=Re.safeParse(t);if(!a.success)throw new f(a.error.message);const{password:i}=a.data.data;return l(r,"/password/reset",{...e.init,method:"POST",body:JSON.stringify({password:i})},{authapiBaseUrlOverride:e.authapiBaseUrlOverride,routeId:h.RESET_PASSWORD_NEW_PASSWORD,intercept:n=>{const d={status:n.status,data:n.json};if(L.safeParse(d).success)return{type:s.enum.reset_password_new_password,payload:{errors:{fieldErrors:{password:[e.intl.formatMessage({id:"resetPasswordNewPassword.passwordMustNotBeReused",defaultMessage:"Password must not be reused",description:"Error message shown when a new password is reused from the previous password"})]}}}};if(I.safeParse(d).success)return{type:s.enum.reset_password_new_password,payload:{errors:{fieldErrors:{password:[e.intl.formatMessage({id:"resetPasswordNewPassword.passwordContainsUserInfo",defaultMessage:"Password must not contain personal information like your email, phone number, or name",description:"Error message displayed when the user tries to reset their password with a password that contains user information"})]}}}};if(T.safeParse(d).success)return{type:s.enum.reset_password_new_password,payload:{errors:{fieldErrors:{password:[e.intl.formatMessage({id:"resetPasswordNewPassword.passwordTooWeak",defaultMessage:"Password is too weak",description:"Error message displayed when the user tries to reset their password with a password that is too weak"})]}}}};if(J.safeParse(d).success)return{type:s.enum.reset_password_new_password,payload:{errors:{formErrors:[e.intl.formatMessage({id:"resetPasswordNewPassword.genericBadRequest",defaultMessage:"Failed to reset password. Please try again",description:"Generic error message when resetting password fails with a generic bad request error without a specific error code"})]}}}}})},Ze=(r,t,e)=>{const a=Ie.safeParse(t);if(!a.success)throw new f(a.error.message);return(a.data.intent??C.enum.send_otp)===C.enum.passwordless_login_send_otp?l(r,"/passwordless/send-otp",{...e.init,method:"POST"},{authapiBaseUrlOverride:e.authapiBaseUrlOverride,routeId:h.RESET_PASSWORD_START,intercept:n=>{const d={status:n.status,data:n.json};if(b.safeParse(d).success)return{type:s.enum.reset_password_start,payload:{errors:{formErrors:[e.intl.formatMessage({id:"resetPasswordStart.passwordlessLoginSendOtp.InvalidRequest",defaultMessage:"Invalid request",description:"Shown when send one-time code for passwordless login fails with invalid request"})]}}}}}):l(r,"/password/send-otp",{...e.init,method:"POST"},{authapiBaseUrlOverride:e.authapiBaseUrlOverride,routeId:h.RESET_PASSWORD_START,intercept:n=>{const d={status:n.status,data:n.json};if(P.safeParse(d).success)return{type:s.enum.reset_password_start,payload:{errors:{formErrors:[e.intl.formatMessage({id:"resetPasswordStart.fraudGuard",description:"Error message displayed when a phone number fails fraud guard checks.",defaultMessage:"Unable to send a text message to this phone number"})]}}};if(b.safeParse(d).success)return{type:s.enum.reset_password_start,payload:{errors:{formErrors:[e.intl.formatMessage({id:"resetPasswordStart.invalidRequest",defaultMessage:"Invalid request",description:"Shown when reset password start fails with invalid request"})]}}}}})};function as(r,t,e){switch(t.origin_page_type){case s.enum.advanced_account_security_enroll:return g();case s.enum.about_you:return Ne(r,t,e);case s.enum.add_password_new_password:return Ue(r,t,e);case s.enum.add_email:return g();case s.enum.add_phone:return g();case s.enum.apple_intelligence_start:return g();case s.enum.auth_challenge:return g();case s.enum.contact_verification:return Be(r,t,e);case s.enum.phone_otp_verification:return Ke(r,t,e);case s.enum.choose_an_account:return ke(r,t,e);case s.enum.create_account_later:return g();case s.enum.create_account_later_queued:return g();case s.enum.create_account_password:return Ce(r,t,e);case s.enum.create_account_start:return He(r,t,e);case s.enum.email_otp_send:return Le(r,t,e);case s.enum.email_otp_verification:return Ve(r,t,e);case s.enum.error:return g();case s.enum.external_url:return g();case s.enum.identity_verification:return g();case s.enum.login_password:return Je(r,t,e);case s.enum.login_passkey:return Fe(r,t,e);case s.enum.login_start:return We(r,t,e);case s.enum.login_or_signup_start:return je(r,t,e);case s.enum.mfa_challenge:return $e(r,t,e);case s.enum.mfa_enroll:return ze(r,t,e);case s.enum.phone_otp_send:return Ye(r,t,e);case s.enum.push_auth_verification:return Qe(r,t,e);case s.enum.reset_password_new_password:return Xe(r,t,e);case s.enum.reset_password_start:return Ze(r,t,e);case s.enum.reset_password_success:return g();case s.enum.sign_in_with_chatgpt_consent:return g();case s.enum.sign_in_with_chatgpt_codex_consent:return g();case s.enum.sign_in_with_chatgpt_codex_org:return g();case s.enum.sso:return g();case s.enum.token_exchange:return g();case s.enum.workspace:return g();case s.enum.__test__:return g();default:G(t,()=>z({error:"Unknown page type"},{status:400}))}}const g=async()=>({page:{type:s.enum.error},responseHeaders:new Headers});function xe(r){switch(r.type){case s.enum.advanced_account_security_enroll:return"/advanced-account-security";case s.enum.about_you:return c("/about-you");case s.enum.add_email:return c("/add-email");case s.enum.add_phone:return c("/add-phone");case s.enum.add_password_new_password:return c("/add-password/new-password");case s.enum.apple_intelligence_start:return c("/start");case s.enum.auth_challenge:return r.payload.method_id?c("/auth_challenge/:id",{id:r.payload.method_id}):c("/auth_challenge");case s.enum.contact_verification:return c("/contact-verification");case s.enum.phone_otp_verification:return c("/phone-verification");case s.enum.choose_an_account:return c("/choose-an-account");case s.enum.create_account_later:return c("/create-account-later");case s.enum.create_account_later_queued:return c("/create-account-later/queued");case s.enum.create_account_password:return c("/create-account/password");case s.enum.create_account_start:return c("/create-account");case s.enum.email_otp_send:return"https://auth.openai.com/api/accounts/email-otp/send";case s.enum.email_otp_verification:return c("/email-verification");case s.enum.error:return c("/error");case s.enum.external_url:return r.payload.url;case s.enum.identity_verification:return c("/verify-your-identity");case s.enum.login_passkey:return"/log-in/passkey";case s.enum.login_password:return c("/log-in/password");case s.enum.login_start:return c("/log-in");case s.enum.login_or_signup_start:return c("/log-in-or-create-account");case s.enum.mfa_challenge:return r.payload.factor_id?c("/mfa-challenge/:id",{id:r.payload.factor_id}):c("/mfa-challenge");case s.enum.mfa_enroll:return c("/mfa-enroll/:id",{id:r.payload.factor_id});case s.enum.phone_otp_send:return"https://auth.openai.com/api/accounts/phone-otp/send";case s.enum.push_auth_verification:return c("/push-auth-verification/:mfa_request_id",{mfa_request_id:r.payload.mfa_request_id})+`?${r.payload.query_params}`;case s.enum.reset_password_start:return c("/reset-password");case s.enum.reset_password_new_password:return c("/reset-password/new-password");case s.enum.reset_password_success:return c("/reset-password/success");case s.enum.sign_in_with_chatgpt_consent:return c("/sign-in-with-chatgpt/consent");case s.enum.sign_in_with_chatgpt_codex_consent:return c("/sign-in-with-chatgpt/codex/consent");case s.enum.sign_in_with_chatgpt_codex_org:return c("/sign-in-with-chatgpt/codex/organization");case s.enum.sso:return c("/sso");case s.enum.workspace:return c("/workspace");case s.enum.token_exchange:throw new Error(`no web mapping for pageType: ${r.type}`);case s.enum.__test__:return r.payload.path;default:G(r)}}function ts(r,t){const e=xe(r),a=new URL(t.url),i=new URL(e,a.origin);return i.origin!==a.origin||i.pathname.startsWith("/api/")?Y(e):K(e)}export{as as n,ts as r}; +//# sourceMappingURL=redirectToPage-QVIYfPch.js.map diff --git a/webui.py b/webui.py new file mode 100644 index 00000000..12e5cb36 --- /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.core.timezone_utils import apply_process_timezone +from src.core.db_logs import install_database_log_handler +from src.database.init_db import initialize_database +from src.config.settings import get_settings +from src.config.project_notice import build_terminal_notice_lines + + +def _print_project_notice(): + """Print the project notice to the terminal on startup.""" + for line in build_terminal_notice_lines(): + print(line) + + +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(): + """设置应用程序""" + # 统一进程时区为北京时间,避免容器默认 UTC 导致时间错位 + apply_process_timezone() + + # 加载 .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 + ) + install_database_log_handler() + + logger = logging.getLogger(__name__) + logger.info("数据库初始化完成,地基已经打好") + logger.info(f"数据目录已安顿好: {data_dir}") + logger.info(f"日志目录也已就位: {logs_dir}") + + logger.info("应用程序设置完成,齿轮已经咔哒一声卡上了") + return settings + + +def start_webui(): + _print_project_notice() + """启动 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 + start_webui() + + +if __name__ == "__main__": + main() From f46613b4176503b1d348f082f3e917696c8b72e8 Mon Sep 17 00:00:00 2001 From: doujiang <527191253@qq.com> Date: Wed, 25 Mar 2026 20:35:00 +0800 Subject: [PATCH 2/3] release: prepare v1.1 --- pyproject.toml | 2 +- src/config/constants.py | 2 +- src/config/settings.py | 4 ++-- src/services/temp_mail.py | 1 + 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 06f9cec3..9f02d207 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "codex-console" -version = "1.0.4" +version = "1.1.0" description = "OpenAI account management console" requires-python = ">=3.10" dependencies = [ diff --git a/src/config/constants.py b/src/config/constants.py index 9e787a2e..0949de3e 100644 --- a/src/config/constants.py +++ b/src/config/constants.py @@ -45,7 +45,7 @@ class EmailServiceType(str, Enum): # ============================================================================ APP_NAME = "OpenAI/Codex CLI 自动注册系统" -APP_VERSION = "2.0.0" +APP_VERSION = "1.1.0" APP_DESCRIPTION = "自动注册 OpenAI/Codex CLI 账号的系统" # ============================================================================ diff --git a/src/config/settings.py b/src/config/settings.py index ac4bb9ad..a929c0d8 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -48,7 +48,7 @@ class SettingDefinition: ), "app_version": SettingDefinition( db_key="app.version", - default_value="2.0.0", + default_value="1.1.0", category=SettingCategory.GENERAL, description="应用版本" ), @@ -592,7 +592,7 @@ class Settings(BaseModel): # 应用信息 app_name: str = "OpenAI/Codex CLI 自动注册系统" - app_version: str = "2.0.0" + app_version: str = "1.1.0" debug: bool = False # 数据库配置 diff --git a/src/services/temp_mail.py b/src/services/temp_mail.py index cbbaea85..d2605292 100644 --- a/src/services/temp_mail.py +++ b/src/services/temp_mail.py @@ -178,6 +178,7 @@ def _is_openai_otp_mail(self, sender: str, subject: str, body: str, raw: str) -> return False otp_keywords = ( + "verification", "verification code", "verify", "one-time code", From 34029e497d37ad56a76b5861a94e7b2523d11e58 Mon Sep 17 00:00:00 2001 From: doujiang <527191253@qq.com> Date: Wed, 25 Mar 2026 20:54:38 +0800 Subject: [PATCH 3/3] docs: refresh version notes in README --- README.md | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index e5b3ab02..22b2584a 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ ## QQ群 -- 交流群: [291638849??????](https://qm.qq.com/q/4TETC3mWco) -- Telegram ??: [codex_console](https://t.me/codex_console) +- 交流群: [291638849(点击加群)](https://qm.qq.com/q/4TETC3mWco) +- Telegram 频道: [codex_console](https://t.me/codex_console) ## 致谢 @@ -20,9 +20,9 @@ 本仓库是在原项目思路和结构之上进行兼容性修复、流程调整和体验优化,适合作为一个“当前可用的修复维护版”继续使用。 -## 这个分支修了什么 +## 版本更新 -为适配当前注册链路,这个分支重点补了下面几个问题: +### v1.0 1. 新增 Sentinel POW 求解逻辑 OpenAI 现在会强制校验 Sentinel POW,原先直接传空值已经不行了,这里补上了实际求解流程。 @@ -41,6 +41,22 @@ 5. 优化终端和 Web UI 提示文案 保留可读性的前提下,把一些提示改得更友好一点,出错时至少不至于像在挨骂。 +### v1.1 + +1. 修复注册流程中的问题,解决 Outlook 和临时邮箱收不到邮件导致注册卡住、无法完成注册的问题。 + +2. 修复无法检查订阅状态的问题,提升订阅识别和状态检查的可用性。 + +3. 新增绑卡半自动模式,支持自动随机地址;3DS 无法跳过,需按实际流程完成验证。 + +4. 新增已订阅账号管理功能,支持查看和管理账号额度。 + +5. 新增后台日志功能,并补充数据导出与导入能力,方便排查问题和迁移数据。 + +6. 优化部分 UI 细节与交互体验,减少页面操作时的割裂感。 + +7. 补充细节稳定性处理,尽量减少注册、订阅检测和账号管理过程中出现卡住或误判的情况。 + ## 核心能力 - Web UI 管理注册任务和账号数据 @@ -222,9 +238,4 @@ dist/codex-console-windows-X64.exe 因使用本项目产生的任何风险和后果,由使用者自行承担。 -## ???? - -????????????????????????????? - -?? ???????????????????????????????????????????????????????????????????????????????????