diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..b21ea405d40b8790ed6005d382c7eb13c5f2dd4c --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# OpenAI 自动注册系统 - 环境变量配置示例 +# 复制此文件为 .env 并填写对应值 + +# ── Web UI 监听地址 ────────────────────────────────────────── +# 监听主机(默认 0.0.0.0) +# APP_HOST=0.0.0.0 + +# 监听端口(默认 8000) +# APP_PORT=8000 + +# Web UI 访问密钥(默认 admin123,强烈建议修改) +# APP_ACCESS_PASSWORD=your_secret_password + +# ── 数据库 ─────────────────────────────────────────────────── +# 本地 SQLite(默认,无需配置) +# APP_DATABASE_URL=data/database.db + +# 远程 PostgreSQL(优先) +# APP_DATABASE_URL=postgresql://user:password@host:5432/dbname diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000000000000000000000000000000000000..803b63335a24ac344b7727d8a197321eb1c0d2db --- /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-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000000000000000000000000000000000000..874d40b5246932268fe388950e6ca628c311cd68 --- /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 0000000000000000000000000000000000000000..6cc5b319fa15d5642c409c8955680432608d454a --- /dev/null +++ b/.gitignore @@ -0,0 +1,98 @@ +# 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/ + +# Local diagnostics / migration artifacts +*.log +analysis_output.txt +broken_server_logs.txt +docker_logs_latest.txt +latest_*.txt +logs_*.txt +logs_198/ +src_23.tar.gz +src_23/ +src_23_extracted/ +*.recovered +/.tmp_*.py + +# Local one-off scripts +/check_sync.py +/clear_and_deploy.py +/clear_logs_198.py +/deploy.py +/deploy2.log +/deploy_198.py +/deploy_probe.py +/download_local.py +/fetch_198_logs.py +/fetch_logs.py +/fetch_logs_198.py +/login.html +/probe_auth_flow.py +/pull_logs.py +/register_198.py +/remote_check.py +/restore_src.py +/rollback.py +/run_probe_198.py +/step1_download_good.py +/step2_upload_to_broken.py +/sync_23.py +/test_openai_js.py +/test_proxy_chatgpt*.py diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000000000000000000000000000000000..9b3082726a968ff8830a0c4eb0c0342ea7e17b16 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,181 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## 项目概览 + +这个仓库是一个基于 Python 3.10+ 与 FastAPI 的 Web 应用,用于通过 Web UI 和带鉴权的外部 API 管理注册任务流程。默认使用 SQLite 持久化运行时状态,也支持 PostgreSQL。 + +当前分支是一个持续维护的修复版,重点是让注册、登录、获取 token 这些链路继续适配近期上游行为变化,包括 Sentinel POW 处理,以及“先注册、再单独登录拿 token”的拆分流程。 + +## 常用命令 + +### 安装依赖 + +```bash +uv sync +``` + +备用方式: + +```bash +pip install -r requirements.txt +``` + +如果使用项目元数据方式安装测试依赖: + +```bash +pip install -e .[dev] +``` + +### 本地运行 + +默认启动 Web UI: + +```bash +python webui.py +``` + +指定监听地址和端口: + +```bash +python webui.py --host 0.0.0.0 --port 8080 +``` + +调试模式(同时启用文档路由): + +```bash +python webui.py --debug +``` + +也可以使用 `pyproject.toml` 中定义的入口: + +```bash +codex-console +``` + +### 数据库初始化 / 维护 + +初始化数据库与默认设置: + +```bash +python -m src.database.init_db +``` + +检查数据库连接: + +```bash +python -m src.database.init_db --check +``` + +重置数据库(破坏性操作): + +```bash +python -m src.database.init_db --reset +``` + +### 测试 + +运行全部测试: + +```bash +pytest +``` + +运行单个测试文件: + +```bash +pytest tests/test_registration_engine.py +``` + +运行单个测试函数: + +```bash +pytest tests/test_registration_engine.py::test_name +``` + +### 打包 + +Windows: + +```bash +build.bat +``` + +Linux/macOS: + +```bash +bash build.sh +``` + +### Docker + +```bash +docker-compose up -d +``` + +## 配置模型 + +- 运行时配置主要通过 `src/config/settings.py` 存在数据库中,而不是静态配置文件。 +- `webui.py` 启动时仍会读取 `.env` 和命令行参数,然后把生效值写回数据库配置,再启动应用。 +- 从 README 和启动逻辑看,优先级是:命令行参数 > 环境变量 /.env > 数据库设置 > 代码默认值。 +- 本地默认数据库是 `data/database.db` 下的 SQLite;PostgreSQL 连接串会在 `src/database/session.py` 中做标准化处理。 + +## 高层架构 + +### 入口层 + +- `webui.py` 是主进程入口,负责准备 `data/` 与 `logs/`、初始化数据库、重载设置、配置日志并启动 Uvicorn。 +- `src/web/app.py` 负责创建 FastAPI 应用、挂载静态资源和模板、注册 API 与 WebSocket 路由,并处理基于 Cookie 的简易 Web UI 登录。 + +### Web 层 + +- `src/web/routes/__init__.py` 统一组装 API: + - `/api/registration`:单任务和批量注册 + - `/api/accounts`、`/api/email-services`、`/api/settings`、`/api/payment` + - `/api/upload/*`:CPA / Sub2API / Team Manager 上传集成 + - `/api/external/*`:给第三方系统调用的外部批次接口 +- `src/web/routes/external/registration.py` 提供外部批次 API,鉴权由 `src/web/deps/external_auth.py` 中的 `X-API-Key` 逻辑控制。 +- `src/web/routes/websocket.py` 与 `src/web/task_manager.py` 负责向 Web UI 实时推送任务日志和状态。 + +### 注册主流程 + +- `src/web/routes/registration.py` 是用户触发注册任务的编排层,负责邮箱服务选择、代理来源解析、任务记录,以及注册后的可选上传动作。 +- `src/core/register.py` 中的 `RegistrationEngine` 是核心流程引擎,负责邮箱创建、OAuth / session 初始化、Sentinel 处理、注册/登录推进、token 获取和日志/result 汇总。 +- `src/core/openai/` 放的是与协议细节强相关的模块,例如 OAuth、浏览器/设备指纹、Sentinel POW、支付相关逻辑、token 刷新。 +- `src/core/http_client.py` 统一封装注册流程所需的 HTTP 会话行为。 + +### 邮箱服务模型 + +- `src/services/base.py` 定义了所有邮箱服务都要实现的抽象接口。 +- 具体实现位于 `src/services/`。 +- Outlook 相关逻辑明显更复杂,集中在 `src/services/outlook/`,里面包括 provider 实现、token 管理、邮件解析和健康检查。 +- 批量任务中的服务选择逻辑位于 `src/core/service_selection.py`。其中 Outlook 有专门限制,避免同一个已配置邮箱在多条批次任务里被静默复用。 + +### 外部批次 API + +- 外部 API 不是另一套独立实现,而是对现有注册能力的封装。 +- `src/core/external_batches/service.py` 会先持久化批次、预先计算邮箱/上传目标分配、为每个 item 创建一个 `RegistrationTask`,然后复用常规注册路由里的 `run_registration_task` 来执行。 +- 批次状态通过 `ExternalRegistrationBatch` 和 `ExternalRegistrationBatchItem` 持久化,服务重启后的恢复入口挂在 `src/web/app.py` 的 startup 逻辑里。 +- 第三方接入说明文档在 `docs/temp-mail-external-api.md`、`docs/external-api-examples.md`、`docs/external-api-quickstart.md`。 + +### 持久化层 + +- ORM 模型定义在 `src/database/models.py`。 +- 本地核心实体主要包括 `Account`、`RegistrationTask`、`EmailService`、`Setting`、`Proxy` 以及各类上传目标配置表。 +- 外部 API 批次状态使用 `ExternalRegistrationBatch` 与 `ExternalRegistrationBatchItem` 两层结构。 +- `src/database/crud.py` 承担大部分业务 CRUD;`src/database/external_batch_crud.py` 处理批次相关持久化。 +- `src/database/session.py` 负责 engine / session 生命周期,也会在启动时执行轻量级 SQLite 自动迁移。 + +### 上传集成 + +- `src/core/registration_upload.py` 是注册完成后的上传分发入口。 +- 不同上传渠道的实现位于 `src/core/upload/`。 +- 上传目标选择和邮箱分配逻辑都收敛在 `src/core/service_selection.py`。 + +## 这个仓库里特别重要的开发说明 + +- `tests/` 下有比较完整的 pytest 测试,适合验证路由行为、任务/WebSocket 状态推送、服务选择逻辑和注册辅助函数。 +- 仓库根目录里没有发现专门的 lint/format/type-check 配置(如 `ruff`、`flake8`、`black`、`mypy` 等),不要默认认为存在固定的格式化或静态检查命令,除非后续显式加入。 +- `tests/conftest.py` 在 Windows 下 monkeypatch 了 `socket.socketpair`,这是为了让测试在该平台正常运行。 +- 打包使用的是 `codex_register.spec` + PyInstaller;`webui.py` 和 `src/web/app.py` 都对冻结环境(`sys.frozen` / `sys._MEIPASS`)做了特殊处理,修改资源路径逻辑时要特别小心。 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..5412c518183592bc54c71d57d4362d00177e083a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# 使用官方 Python 基础镜像 (使用 slim 版本减小体积) +FROM python:3.11-slim + +# 设置工作目录 +WORKDIR /app + +# 设置环境变量 +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + # WebUI 默认配置 + WEBUI_HOST=0.0.0.0 \ + WEBUI_PORT=7860 \ + LOG_LEVEL=info \ + DEBUG=0 \ + WEBUI_ACCESS_PASSWORD=admin123 + +# 安装系统依赖 +# (curl_cffi 等库可能需要编译工具) +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + gcc \ + python3-dev \ + && 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 + +# 复制项目代码 +COPY . . + +# 暴露端口 +EXPOSE 7860 + +# 启动 WebUI +CMD ["python", "webui.py", "--host", "0.0.0.0", "--port", "7860", "--access-password", "admin123"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..14fac913ccf80234b1848540089a3bbcb6e5283d --- /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 index 3f41e1492029d5aa6d9691d5697c1c127e48b764..5d5c93ac92e56f716f68dc37906721388d528f0c 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,252 @@ --- -title: Codex -emoji: 👀 -colorFrom: yellow +title: codex-console +emoji: 🚀 +colorFrom: blue colorTo: indigo sdk: docker +app_port: 7860 pinned: false -license: apache-2.0 --- -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +# 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/) + +## QQ群 + +- 交流群: https://qm.qq.com/q/ZTCKxawxeo + +## 致谢 + +首先感谢上游项目作者 [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 注册与登录链路 + +## 文档 + +- [Temp Mail 域名池与外部注册 API 说明](docs/temp-mail-external-api.md) +- [External API 调用示例](docs/external-api-examples.md) +- [External API 第三方对接速览](docs/external-api-quickstart.md) +- [第三方技术对接文档](docs/third-party-integration-guide.md) +- [External API 网站内管理与 API Key 生成方案](docs/external-api-webui-management-plan.md) +- [HTTP-Only 设备指纹补强建议](docs/http-only-device-fingerprint-hardening.md) + +## 环境要求 + +- 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 run + +```bash +docker run -d \ + -p 7860:7860 \ + --name codex-console \ + ghcr.io//codex-console:latest +``` + +当前 Dockerfile 已经写死以下启动参数: + +- 监听地址:`0.0.0.0` +- 监听端口:`7860` +- Web UI 密码:`admin123` + +因此不需要再额外传环境变量。 + +注意: + +- 当前方案不做持久化,容器重建后数据库和日志会丢失 +- 当前方案的目标只是保证 Web UI 能启动,不保证完整注册链路在公网容器环境可用 + +### Hugging Face Spaces + +如果你要部署到 Hugging Face Spaces: + +1. 新建一个 **Docker Space** +2. 把当前仓库直接推上去 +3. 不需要配置额外环境变量 +4. 不需要开启持久化存储 +5. 等待平台按仓库根目录下的 `Dockerfile` 自动构建 + +当前 Dockerfile 会直接启动: + +```bash +python webui.py --host 0.0.0.0 --port 7860 --access-password admin123 +``` + +部署完成后,直接打开 Space 页面即可。 + +默认登录密码: + +```text +admin123 +``` + +如果后续涉及公网 OAuth 回调,还需要把系统里的 `openai.redirect_uri` 改成真实可访问的域名地址,而不是默认的 `localhost`;但这不影响 Web UI 本身启动。 + +## 使用远程 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 0000000000000000000000000000000000000000..ae7fa457c87ce5c484e715528dabbfac7a4c0d22 --- /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 0000000000000000000000000000000000000000..c0d87464d99649d5c624d116921d20ba187f7da6 --- /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 0000000000000000000000000000000000000000..40af3d7a07cd790ac5ad9b01ebee788d321b6db0 --- /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/deploy_140.py b/deploy_140.py new file mode 100644 index 0000000000000000000000000000000000000000..e202aa21d35d3df4c4940d15f98344c8c677bd21 --- /dev/null +++ b/deploy_140.py @@ -0,0 +1,76 @@ +import os +import zipfile +import paramiko + +HOST = '23.140.140.70' +PASSWORD = '33nKDS80L6ipmedJ3y0j' +PORT = 22 +USER = 'root' +REMOTE_DIR = '/opt/codex-console' + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +ZIP_FILE = os.path.join(BASE_DIR, 'deploy.zip') +EXCLUDE_DIRS = {'.git', '.venv', 'data', 'logs', '__pycache__', '.pytest_cache', '.vscode', '.idea'} + +def create_zip(): + print(f"Creating zip file {ZIP_FILE}...") + with zipfile.ZipFile(ZIP_FILE, 'w', zipfile.ZIP_DEFLATED) as zipf: + for root, dirs, files in os.walk(BASE_DIR): + dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS] + for file in files: + if file.endswith('.zip') or file.endswith('.pyc') or file == 'logs*.txt': + continue + file_path = os.path.join(root, file) + arcname = os.path.relpath(file_path, BASE_DIR) + zipf.write(file_path, arcname) + print("Zip created successfully.") + +def deploy(): + print(f"\n--- Deploying to {HOST} ---") + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect(HOST, port=PORT, username=USER, password=PASSWORD, timeout=30, banner_timeout=30) + print("Connected.") + + ssh.exec_command(f'mkdir -p {REMOTE_DIR}') + ssh.exec_command(f'rm -rf {REMOTE_DIR}/logs/*.log') + ssh.exec_command(f'rm -rf {REMOTE_DIR}/logs/*.txt') + ssh.exec_command(f'mkdir -p {REMOTE_DIR}/data') + ssh.exec_command(f'mkdir -p {REMOTE_DIR}/logs') + + print("Uploading zip file...") + sftp = ssh.open_sftp() + sftp.put(ZIP_FILE, f'{REMOTE_DIR}/deploy.zip') + sftp.close() + print("Upload complete.") + + print("Extracting and running docker-compose...") + commands = [ + f"cd {REMOTE_DIR}", + "unzip -o deploy.zip > /dev/null", + "rm deploy.zip", + "(docker-compose down || docker compose down || true)", + "(docker-compose up -d --build || docker compose up -d --build)" + ] + command = ' && '.join(commands) + stdin, stdout, stderr = ssh.exec_command(command, timeout=120) + + for line in stdout: + print(line.strip()) + for line in stderr: + print("ERR:", line.strip()) + + exit_status = stdout.channel.recv_exit_status() + if exit_status == 0: + print(f"Deployment to {HOST} successful!") + else: + print(f"Deployment to {HOST} failed with exit status {exit_status}") + + ssh.close() + +if __name__ == '__main__': + create_zip() + deploy() + if os.path.exists(ZIP_FILE): + os.remove(ZIP_FILE) + print("Cleaned up local zip file.") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..e579408f4177796469d6edddb86e39d80228920e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +version: '3.8' + +services: + webui: + build: . + ports: + - "1455:1455" + environment: + - WEBUI_HOST=0.0.0.0 + - WEBUI_PORT=1455 + - DEBUG=0 + - LOG_LEVEL=info + # 如果需要访问密码,可以在这里取消注释并设置 + - WEBUI_ACCESS_PASSWORD=admin123 + volumes: + # 挂载数据目录以持久化数据库和日志 + - ./data:/app/data + - ./logs:/app/logs + restart: unless-stopped \ No newline at end of file diff --git a/docs/anti-detection-critical-analysis.md b/docs/anti-detection-critical-analysis.md new file mode 100644 index 0000000000000000000000000000000000000000..9631458b9450cdc0d788f77762d64275ee25c612 --- /dev/null +++ b/docs/anti-detection-critical-analysis.md @@ -0,0 +1,149 @@ +# HTTP-Only 设备指纹补强方案 — 批判性深度分析(v2 修订版) + +> 以反检测专家视角,**结合代码实查**,对原始分析文档以及 v1 批判报告的逐项复核。 +> ⚠️ v2 修正了 v1 中 3 处事实性错误,并新增了 3 个代码中发现的**真实 bug**。 + +--- + +## v1 → v2 勘误表 + +| v1 中的说法 | 判决 | 原因 | +|---|---|---| +| "严重低估 Turnstile" → P0 威胁 | ❌ **说错了** | 注册链路用的是 `openai-sentinel-token`(PoW only),不是 `openai-sentinel-turnstile-token`(VM 字节码)。两条链路完全独立。项目能跑通即为实证。 | +| "`oai-echo-logs` 是 P0 级遗漏" | ❌ **说错了** | `oai-echo-logs` 是 **ChatGPT 对话链路**(`/backend-anon/f/conversation`)的 header,注册链路(`auth.openai.com/api/accounts/*`)从未出现过。混淆了两条链路。 | +| "Auth0 内置 ML bot detection" | ⚠️ **不准确** | 代码中 OpenAI 使用的是 **自有 auth 端点**(`auth.openai.com/api/accounts/authorize/continue` 等),不是标准 Auth0 Universal Login。不能直接套用 Auth0 商业版的 bot detection 文档。 | +| Traceparent / Datadog 应降至 P2 | ✅ **方向对但理由需修正** | 实际代码中存在格式 bug(见下文),这些 header 不仅是低 ROI,而且目前的实现**有害**。 | +| Cookie 属性一致性是盲区 | ✅ **依然成立** | `curl_cffi` 的 cookie jar 自动处理 `Set-Cookie` 属性,不需要手动模拟。但如果有**手动硬编码 cookie**,需要确认属性匹配。 | +| Client Hints 不完整 | ✅ **依然成立,比想象中更严重** | 整个 `src/` 目录中 **零个** `sec-ch-ua` header。 | + +--- + +## 🔴 代码实查发现的真实 Bug + +### Bug 1:`impersonate` 版本与 User-Agent 不匹配 + +```python +# http_client.py L29 — 注册链路使用的 impersonate +impersonate: str = "chrome" # 泛版本,curl_cffi 自动选最新 + +# http_client.py L258-259 — 硬编码的 User-Agent +"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ... Chrome/120.0.0.0 Safari/537.36" +``` + +**问题**:`impersonate="chrome"` 让 curl_cffi 使用最新 Chrome 的 TLS/HTTP2 指纹(可能是 Chrome 131+),但 User-Agent 写死了 `Chrome/120.0.0.0`。TLS 指纹说 Chrome 131,UA 说 Chrome 120 —— **这是一个典型的指纹分裂信号**。 + +**对比**:项目其他模块统一使用 `impersonate="chrome110"`,至少内部一致。 + +**修复建议**:将 impersonate 固定为具体版本(如 `chrome120`),或动态匹配 UA 版本。 + +--- + +### Bug 2:`traceparent` 与 `x-datadog-trace-id` 不一致 + +```python +# register.py L279-288 +def _make_trace_headers(self) -> Dict[str, str]: + trace_id = random.randint(10 ** 17, 10 ** 18 - 1) # 十进制整数 + parent_id = random.randint(10 ** 17, 10 ** 18 - 1) # 十进制整数 + return { + "traceparent": f"00-{uuid.uuid4().hex}-{format(parent_id, '016x')}-01", + # ^^^^^^^^^^^^^^^^ 这是 uuid4 的 32 位 hex + "x-datadog-trace-id": str(trace_id), + # ^^^^^^^^^^^^^ 这是另一个随机数的十进制 + } +``` + +**问题**:`traceparent` 里的 trace-id 是 `uuid4().hex`(32位hex),但 `x-datadog-trace-id` 是另一个完全不相关的十进制随机数。真实 Datadog RUM SDK 中,这两个值**必须对应同一个 trace**。这个不一致比不发这些 header 更糟糕 —— 它变成了一个"我在伪造追踪头"的强信号。 + +**修复建议**:要么不发这些 header(推荐),要么从同一个 trace-id 派生两个值。 + +--- + +### Bug 3:`_submit_login_password` 发送裸 headers + +```python +# register.py L456-464 +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", + }, # ← 没有 oai-device-id, 没有 sentinel, 没有 trace, 没有 origin + data=json.dumps({"password": self.password}), +) +``` + +**问题**:对比 `_submit_auth_start`(提交邮箱阶段),它会注入 `openai-sentinel-token`。但 `_submit_login_password`(提交密码阶段)**完全没有 sentinel**,也没有 `oai-device-id`。这造成了同一会话中两个连续请求之间的 header 风格突变。 + +**风险**:这正是原始分析文档说的"同一会话像多个不同浏览器"的问题。 + +**修复建议**:用 `_build_oauth_json_headers` 统一构建 headers,至少加 `origin` 和 `oai-device-id`。 + +--- + +## ✅ 原始文档经代码验证后的评级调整 + +### 原文档 P0 项验证 + +| 原文档建议 | 代码现状 | 验证结论 | +|---|---|---| +| **统一 session 级浏览器 profile** | `sec-ch-ua` 全项缺失;`impersonate` 与 UA 版本不匹配 | ✅ **确实是 P0**,且比文档描述的更急迫 | +| **`oai-did` 跨域同步** | `_get_device_id` 从 `Set-Cookie` 读取,`_build_oauth_json_headers` 会注入 `oai-device-id` | ⚠️ **部分已做**,但 `_submit_login_password` 等路径丢失了 | +| **Sentinel 按 flow 生成** | `check_sentinel` 和 `_submit_auth_start` 都硬编码 `flow: "authorize_continue"`,`password_verify` 无 sentinel | ✅ **确实需要补**,至少 password_verify 应有自己的 sentinel | +| **Sentinel 请求补 client hints** | sentinel 请求(`http_client.py L376-380`)只有 origin/referer/content-type,无 `sec-ch-ua` | ✅ **确认缺失** | + +--- + +## 修订后的优先级排序(v2 代码实证版) + +### P0(代码中已确认的问题) + +| 项目 | 证据 | +|---|---| +| **修复 impersonate 与 UA 版本分裂** | `impersonate="chrome"` + `Chrome/120.0.0.0` UA,TLS 层与应用层对不上 | +| **统一关键路径的 header 构建** | `_submit_login_password` 是裸请求,和同会话其他请求风格不一致 | +| **补 `sec-ch-ua` 系列 header** | 整个 src/ 中零个 `sec-ch-ua`,真实 Chrome 每个请求都带 | +| **修复或移除 traceparent/datadog 头** | 当前实现的 trace-id 不一致问题反而暴露自动化 | + +### P1(原文档建议 + 代码验证确认有价值) + +| 项目 | 理由 | +|---|---| +| **Sentinel 按 flow 分类** | `password_verify` 完全无 sentinel,和 `authorize_continue` 形成断层 | +| **`oai-did` 在所有 auth 请求中统一注入** | 部分路径丢失 | +| **建立 TLS 指纹自测** | `impersonate="chrome"` 的实际行为依赖 `curl_cffi` 版本,需要客观验证 | + +### P2(灰度验证 / 长期观察) + +| 项目 | 理由 | +|---|---| +| `ext-oai-did` / `auth_session_logging_id` | 前端语义增强,按原文档灰度引入即可 | +| `oai-sc` cookie 研究 | 需要抓包确认注册流程是否涉及 | +| 注册链路 Turnstile 监控 | 当前不需要,但未来可能引入,建议在 sentinel 响应异常时记录完整响应体 | + +--- + +## v1 中仍然准确的部分 + +1. **"一致性 > 随机性"** — 核心判断正确,代码中 Bug 1-3 都是一致性问题 +2. **Cookie 属性关注** — `curl_cffi` 自动处理 `Set-Cookie` 属性,但值得确认无手动覆盖 +3. **网络环境是指纹的一部分** — datacenter IP + `curl_cffi` 的 TLS 即使匹配也不够 +4. **请求时序模拟** — 虽然 `oai-echo-logs` 不适用于 auth 链路,但步骤间延迟仍是好建议 +5. **不堆随机** — 代码实证再次证明,不一致远比缺少更危险 + +--- + +## 最终建议(v2) + +> [!IMPORTANT] +> **最大的威胁不是"缺什么 header",而是已经发的 header 自相矛盾。** 你的注册链路能跑通,说明 PoW + oai-did 这条路线是可行的。现在最值得投入的是修复 Bug 1-3 这种**自我暴露**的问题,而不是追加更多 header。 + +**建议落地顺序**: +1. 修 impersonate/UA 版本对齐(10 分钟) +2. 统一 `_submit_login_password` 的 header 构建(30 分钟) +3. 修复或移除 traceparent/datadog 头(15 分钟) +4. 补 `sec-ch-ua` 系列,绑定到 session profile(1-2 小时) +5. 后续按 P1/P2 灰度推进 + +> 关于 headless browser 兜底:v1 的建议仍成立,但紧迫度降低。当前注册链路能跑通,先把 HTTP-only 的一致性全面修复,headless 作为长期保险即可。 diff --git a/docs/anti-detection-final-recommendations.md b/docs/anti-detection-final-recommendations.md new file mode 100644 index 0000000000000000000000000000000000000000..11847068bf2a2e135f54e1c7a94bce3d8c9e626f --- /dev/null +++ b/docs/anti-detection-final-recommendations.md @@ -0,0 +1,416 @@ +# HTTP-Only 注册链路反检测 — 最终完整修复建议 + +> **版本**:v3 全量版(合并 3 轮分析) +> **方法论**:逐行代码审计 + 真实 Chrome 行为对照 + codex-suite-win 参考实现对比 + 防御者视角 10 维度 threat model +> **范围**:覆盖外壳层(HTTP/TLS)、行为层(时序/流量形状)、内核层(Sentinel PoW 载荷) + +--- + +## 检测维度总览 + +``` +┌─ 外壳层(HTTP 请求长什么样)──────────────────────────────┐ +│ TLS/H2 指纹 · HTTP headers · Client Hints · sec-fetch-* │ +│ traceparent/datadog · 首请求导航特征 │ +├─ 行为层(请求的节奏和模式)──────────────────────────────┤ +│ 步骤间延迟 · 流量入口形状 · 跨账号统计关联 │ +│ 密码/名字/生日的统计指纹 │ +├─ 内核层(PoW token 里嵌的环境数据)──────────────────────┤ +│ 时区 vs IP 地理 · 语言签名 vs Accept-Language │ +│ 屏幕分辨率 · navigator/window 属性池 · 哈希算法版本 │ +└──────────────────────────────────────────────────────────┘ +``` + +--- + +## 注册链路完整请求地图 + +| # | 端点 | 代码位置 | headers 来源 | +|---|---|---|---| +| ① | `GET auth.openai.com/oauth/authorize?...` | `register.py:248` | **无自定义 headers** | +| ② | `POST sentinel.openai.com/backend-api/sentinel/req` | `http_client.py:374` | origin + referer + content-type | +| ③ | `POST auth.openai.com/api/accounts/authorize/continue` | `register.py:372` | referer + accept + content-type + sentinel | +| ④ | `POST auth.openai.com/api/accounts/password/verify` | `register.py:457` | **裸 headers** | +| ⑤ | `POST auth.openai.com/api/accounts/email-otp/validate` | `register.py:2193` | **裸 headers** | +| ⑥ | `POST auth.openai.com/api/accounts/create_account` | `register.py:2281` | **裸 headers** | +| ⑦ | `POST auth.openai.com/oauth/token` | `oauth.py:160` | **独立一次性会话** | + +--- + +## 全部问题清单与优先级 + +### 🔴 P0 — 立即修复(每项都是可被直接检测的暴露点) + +--- + +#### P0-1. Sentinel PoW 语言签名 vs Accept-Language 矛盾 + +**代码位置**:`sentinel.py:18` vs `http_client.py:261` + +```python +# sentinel.py — PoW 内部声称浏览器语言 +_LANGUAGE_SIGNATURE = "en-US,es-US,en,es" # ← 包含西班牙语 + +# http_client.py — HTTP 请求头 +"Accept-Language": "en-US,en;q=0.9" # ← 只有英语 +``` + +**问题**:同一个浏览器的语言配置在 PoW 载荷和 HTTP 请求中对不上。服务端解码 PoW 后可直接比对。 + +**修复**:统一为 `"en-US,en"` 或从 BrowserProfile 中读取。 + +**工时**:5 分钟 + +--- + +#### P0-2. Sentinel PoW 时区硬编码 EST,与代理 IP 地理位置冲突 + +**代码位置**:`sentinel.py:29` + +```python +browser_now = datetime.now(timezone(timedelta(hours=-5))) +# → "GMT-0500 (Eastern Standard Time)" +``` + +**问题**:PoW 永远声称浏览器在美东时区。如果代理 IP 在欧洲/亚洲,IP 地理位置 vs PoW 时区 = 矛盾。 + +**修复**:改用 UTC(`time.gmtime()` + `"GMT+0000 (Coordinated Universal Time)"`),和 codex-suite-win 保持一致。UTC 不暴露地理位置。 + +**工时**:5 分钟 + +--- + +#### P0-3. Sentinel PoW 屏幕分辨率不是真实值 + +**代码位置**:`sentinel.py:17` + +```python +_SCREEN_SIGNATURES = (3000, 3120, 4000, 4160) +# 没有任何真实显示器是这些分辨率 +``` + +**修复**:替换为真实常见值,如 `"1920x1080"`、`"2560x1440"`、`"1366x768"`。codex-suite-win 使用 `"1920x1080"`。 + +**工时**:5 分钟 + +--- + +#### P0-4. impersonate 版本与 User-Agent 版本分裂 + +**代码位置**:`http_client.py:29` + `http_client.py:258` + +```python +impersonate: str = "chrome" # → TLS 层 = 最新 Chrome (131+) +"User-Agent": "...Chrome/120.0.0.0..." # → 应用层 = Chrome 120 +``` + +**问题**:TLS ClientHello 的指纹对应 Chrome 131+,但 UA 声称 Chrome 120。 + +**修复**:建立 BrowserProfile 工厂,绑定 `impersonate` 版本与 UA/sec-ch-ua: + +```python +_CHROME_PROFILES = [ + {"impersonate": "chrome120", "major": 120, "build": 6099, + "sec_ch_ua": '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"'}, + {"impersonate": "chrome131", "major": 131, "build": 6778, + "sec_ch_ua": '"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"'}, + # 按需扩展 +] +``` + +**工时**:1 小时 + +--- + +#### P0-5. 所有请求缺少 sec-ch-ua 三件套 + +**代码位置**:整个 `src/` 目录 — 零个 `sec-ch-ua` + +Chrome 从 2021 年起**每个 HTTPS 请求默认发送**: + +``` +sec-ch-ua: "Chromium";v="120", "Google Chrome";v="120", "Not_A Brand";v="8" +sec-ch-ua-mobile: ?0 +sec-ch-ua-platform: "Windows" +``` + +你的 7 个请求阶段全部缺失。声称是 Chrome 但不发 Chrome 默认 headers = 最基本的检测信号。 + +**修复**:包含在 P0-4 的 BrowserProfile 中。 + +**工时**:含在 P0-4 中 + +--- + +#### P0-6. 注册流程零延迟,全链路 < 2 秒完成 + +**代码位置**:`register.py` 全流程 + +7 个 HTTP 请求之间没有任何 `time.sleep()`,请求背靠背发出。真实用户从打开注册页到完成至少 30-120 秒。 + +**修复**:在关键步骤间加入正态分布延迟: + +```python +import random, time + +def _human_delay(mean=3.0, std=1.0, minimum=1.0): + delay = max(random.gauss(mean, std), minimum) + time.sleep(delay) +``` + +- `_get_device_id` → `_check_sentinel`:延迟 1-3秒 +- `_submit_auth_start` → `_submit_login_password`:延迟 2-5秒("用户在输密码") +- `_validate_verification_code`:延迟 5-15秒("用户在等验证码邮件") +- `_create_user_account`:延迟 2-4秒("用户在填姓名和生日") + +**工时**:30 分钟 + +--- + +#### P0-7. 阶段 ④⑤⑥ 的 headers 风格与 ③ 不一致 + +**代码位置**:`register.py:459`、`register.py:2195`、`register.py:2283` + +`password_verify`、`validate_otp`、`create_account` 只有 3 行最小 headers(referer + accept + content-type),缺少: +- `origin` +- `oai-device-id` +- `sec-ch-ua` 三件套 +- `sec-fetch-*` 三件套 + +同一会话中 ③ 带了 sentinel 和更多 headers,④⑤⑥ 突然"简化" = header 风格突变。 + +**修复**:统一使用 `profile.json_request_headers(referer)` 构建,至少加 `origin` 和 `sec-fetch-*`。 + +**工时**:30 分钟 + +--- + +### 🟡 P1 — 建议修复(降低检测风险、提高耐久性) + +--- + +#### P1-1. traceparent 与 x-datadog-trace-id 值不一致 + +**代码位置**:`register.py:279-288` + +`traceparent` 用 `uuid4().hex` 做 trace-id,`x-datadog-trace-id` 用另一个随机十进制数。两者必须对应同一个 trace。当前实现比不发更糟糕。 + +**修复**:**推荐直接移除**这些 headers。如需保留,从同一 trace-id 派生: + +```python +trace_id_hex = uuid.uuid4().hex +dd_trace_id = int(trace_id_hex[16:], 16) # 低64位转十进制 +``` + +**工时**:15 分钟 + +--- + +#### P1-2. 密码无特殊字符,批量可指纹 + +**代码位置**:`constants.py:179` + +```python +PASSWORD_CHARSET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" +# → 所有账号密码 = [a-zA-Z0-9]{12},无特殊字符 +``` + +同一 IP 注册的所有账号密码都是相同字符集和长度,统计可识别。 + +**修复**: + +```python +PASSWORD_CHARSET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%&*" +DEFAULT_PASSWORD_LENGTH = random.randint(12, 16) +# 确保至少包含 1 特殊字符 +``` + +**工时**:10 分钟 + +--- + +#### P1-3. Name 只有名没有姓 + +**代码位置**:`constants.py:201` + +```python +name = random.choice(FIRST_NAMES) # → "James"(无姓) +``` + +OpenAI about-you 页面要的是 Full Name,只有一个词极罕见。 + +**修复**:加 LAST_NAMES 列表,生成 `"James Smith"` 格式。 + +**工时**:10 分钟 + +--- + +#### P1-4. 首请求(_get_device_id)无浏览器导航 headers + +**代码位置**:`register.py:248` + +```python +response = self.session.get(self.oauth_start.auth_url, timeout=20) +# ← 没有 Accept, 没有 Upgrade-Insecure-Requests, 没有 sec-fetch-* +``` + +真实浏览器导航到页面时的 headers: + +``` +Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 +Upgrade-Insecure-Requests: 1 +Sec-Fetch-Dest: document +Sec-Fetch-Mode: navigate +Sec-Fetch-Site: none +Sec-Fetch-User: ?1 +``` + +**修复**:补上导航请求标准 headers。 + +**工时**:10 分钟 + +--- + +#### P1-5. Sentinel navigator/window 属性池太小 + +**代码位置**:`sentinel.py:19-20` + +```python +_NAVIGATOR_KEYS = ("location", "ontransitionend", "onprogress") # 3 个 +_WINDOW_KEYS = ("window", "document", "navigator") # 3 个 +``` + +codex-suite-win 有 21 个 navigator 属性、6 个 window 属性。你的 3 个会导致多次注册中频繁重复。 + +**修复**:扩展到至少 15+ 个真实 Chrome navigator 属性。 + +**工时**:15 分钟 + +--- + +#### P1-6. Sentinel 请求缺 sec-ch-ua 和 sec-fetch-* + +**代码位置**:`http_client.py:376-380` + +Sentinel 请求只有 origin/referer/content-type,缺少浏览器标准 headers。 + +**修复**:补 `sec-ch-ua` 三件套 + `sec-fetch-dest: empty` + `sec-fetch-mode: cors` + `sec-fetch-site: cross-site`。 + +**工时**:10 分钟 + +--- + +#### P1-7. Sentinel PoW 哈希算法可能不匹配当前版本 + +**代码位置**:`sentinel.py:82` + +你用 SHA3-512,codex-suite-win 用 FNV-1a。这是两种不同的 challenge 协议。需要验证当前 OpenAI 注册链路使用的是哪个版本。 + +**修复**:抓包确认 sentinel/req 响应中的 challenge 格式。如果 OpenAI 已升级,需要更新哈希算法。 + +**工时**:1-2 小时(含调研) + +--- + +#### P1-8. 注册入口跳过 chatgpt.com 前端 + +你的流程直接 GET `auth.openai.com/oauth/authorize`,跳过了真实用户的路径:`chatgpt.com → CSRF → signin/openai → 跳转到 auth`。 + +**修复**:模拟 chatgpt.com 入口流,注入 `ext-oai-did` + `auth_session_logging_id`。 + +**工时**:2-3 小时 + +--- + +### ⚪ P2 — 灰度验证(长期增强) + +--- + +#### P2-1. 跨账号统计关联 + +同一 IP 批量注册时,所有账号共享:单一 Chrome 版本、相同密码字符集/长度、相同名字池、相同 TLS 指纹、相同注册耗时。 + +**修复**:通过 P0-4(多版本 Profile 池)+ P1-2(密码多样化)+ P1-3(名字多样化)+ P0-6(延迟随机化)综合缓解。 + +--- + +#### P2-2. OAuth token exchange 使用独立会话 + +`oauth.py:160` 使用 `cffi_requests.post` 而非 `self.session.post`,不携带前面的 cookies。 + +**修复**:改为使用主会话发送 token exchange 请求。 + +**工时**:30 分钟 + +--- + +#### P2-3. 出生年份均匀分布 + +```python +birth_year = random.randint(current_year - 45, current_year - 18) +# → 均匀分布,但真人年龄是正态分布 +``` + +**修复**:改为正态分布 `int(random.gauss(28, 6))`,限制在 18-45 岁。 + +**工时**:10 分钟 + +--- + +#### P2-4. ext-oai-did / auth_session_logging_id + +codex-suite-win 在 signin 时注入这两个参数,你的项目没有。 + +**修复**:在 OAuth 入口流中补充。 + +**工时**:含在 P1-8 中 + +--- + +## 修复依赖图与落地顺序 + +```mermaid +graph TD + A["P0-4 BrowserProfile 工厂"] --> B["P0-5 sec-ch-ua 全覆盖"] + A --> C["P0-7 统一 header 构建"] + A --> D["P1-6 Sentinel 补 headers"] + A --> G["P1-4 首请求导航 headers"] + + E["P0-1 PoW 语言签名"] --> |独立| E + F["P0-2 PoW 时区"] --> |独立| F + H["P0-3 PoW 屏幕分辨率"] --> |独立| H + I["P0-6 步骤间延迟"] --> |独立| I + + C --> J["P1-1 移除 traceparent"] + + K["P1-2 密码特殊字符"] --> |独立| K + L["P1-3 Name 加姓"] --> |独立| L +``` + +### 推荐执行顺序 + +| 批次 | 内容 | 总工时 | 效果 | +|---|---|---|---| +| **第 1 批** | P0-1 + P0-2 + P0-3(PoW 内部 3 个矛盾) | 15 分钟 | 消除 PoW 载荷的内部自相矛盾 | +| **第 2 批** | P0-6(加延迟) + P1-2(密码)+ P1-3(姓名) | 50 分钟 | 消除行为层最明显的批量特征 | +| **第 3 批** | P0-4 + P0-5(BrowserProfile + sec-ch-ua) | 1 小时 | 消除 TLS/UA 分裂和 Client Hints 缺失 | +| **第 4 批** | P0-7 + P1-4 + P1-6(统一 headers) | 50 分钟 | 消除 header 风格突变 | +| **第 5 批** | P1-1 + P1-5 + P1-7 | 2.5 小时 | 清理 traceparent / 扩展 PoW 属性池 / 确认哈希算法 | +| **第 6 批** | P1-8 + P2-*(入口流 + 灰度项) | 3-4 小时 | 流量形状对齐 | + +--- + +## 修复后的覆盖率预估 + +| 侦察维度 | 修复前 | 第 1-2 批后 | 第 3-4 批后 | 全部完成后 | +|---|---|---|---|---| +| TLS/HTTP2 指纹 | ⚠️ 版本不匹配 | ⚠️ | ✅ | ✅ | +| HTTP headers 一致性 | ❌ 3 个裸端点 | ❌ | ✅ | ✅ | +| Client Hints | ❌ 零个 | ❌ | ✅ | ✅ | +| Datadog 追踪头 | ❌ 值矛盾 | ❌ | ❌ | ✅ | +| 请求时序/节奏 | ❌ 零延迟 | ✅ | ✅ | ✅ | +| 流量入口形状 | ❌ 跳过前端 | ❌ | ❌ | ✅ | +| 账号数据统计模式 | ❌ 批量可指纹 | ✅ | ✅ | ✅ | +| 首请求导航特征 | ❌ 无 headers | ❌ | ✅ | ✅ | +| PoW 内部环境一致性 | ❌ 3 处矛盾 | ✅ | ✅ | ✅ | +| 跨账号统计关联 | ❌ 单一 profile | ⚠️ | ✅ | ✅ | diff --git a/docs/external-api-examples.md b/docs/external-api-examples.md new file mode 100644 index 0000000000000000000000000000000000000000..2a7f8a35485f8898e72fe249dfa4159ae555ca66 --- /dev/null +++ b/docs/external-api-examples.md @@ -0,0 +1,524 @@ +# External API 调用示例 + +本文档提供外部注册 API 的最常用调用示例,便于脚本、面板或第三方系统直接接入。 + +相关主说明见: + +- [Temp Mail 域名池与外部注册 API 说明](temp-mail-external-api.md) + +--- + +## 1. 基本约定 + +假设你的服务地址为: + +```text +http://127.0.0.1:8000/api +``` + +假设外部 API Key 为: + +```text +your_api_key +``` + +所有请求都需要带: + +```http +X-API-Key: your_api_key +``` + +这个值的来源是项目管理员在系统配置里设置的 `external_api_key`。 +当前版本没有开放“申请/查询 API Key”接口,第三方只能由管理员直接发放。 + +--- + +## 2. 查询能力 + +### curl + +```bash +curl -X GET "http://127.0.0.1:8000/api/external/capabilities" \ + -H "X-API-Key: your_api_key" +``` + +### Python + +```python +import requests + +resp = requests.get( + "http://127.0.0.1:8000/api/external/capabilities", + headers={"X-API-Key": "your_api_key"}, + timeout=30, +) +print(resp.status_code) +print(resp.json()) +``` + +假设返回里有: + +```json +{ + "email_types": [ + { + "type": "temp_mail", + "services": [ + {"id": 3, "name": "Worker temp mail A"}, + {"id": 7, "name": "Worker temp mail B"} + ] + } + ], + "upload_providers": [ + { + "provider": "sub2api", + "services": [ + {"id": 1, "name": "sub2-main"}, + {"id": 4, "name": "sub2-backup"} + ] + } + ] +} +``` + +那么: + +- `email.service_id=3` 就是 `Worker temp mail A` +- `email.service_id=7` 就是 `Worker temp mail B` +- `upload.service_id=1` 就是 `sub2-main` +- `upload.service_id=4` 就是 `sub2-backup` + +不要自己猜数字含义,始终以 `capabilities` 返回结果为准。 + +--- + +## 3. 创建注册批次 + +### 3.1 使用 temp_mail,不上传 + +#### curl + +```bash +curl -X POST "http://127.0.0.1:8000/api/external/registration/batches" \ + -H "Content-Type: application/json" \ + -H "X-API-Key: your_api_key" \ + -d '{ + "count": 3, + "email": { + "type": "temp_mail" + }, + "upload": { + "enabled": false + }, + "execution": { + "mode": "pipeline", + "concurrency": 1, + "interval_min": 5, + "interval_max": 10 + } + }' +``` + +#### Python + +```python +import requests + +payload = { + "count": 3, + "email": {"type": "temp_mail"}, + "upload": {"enabled": False}, + "execution": { + "mode": "pipeline", + "concurrency": 1, + "interval_min": 5, + "interval_max": 10, + }, +} + +resp = requests.post( + "http://127.0.0.1:8000/api/external/registration/batches", + headers={ + "X-API-Key": "your_api_key", + "Content-Type": "application/json", + }, + json=payload, + timeout=30, +) + +print(resp.status_code) +print(resp.json()) +``` + +--- + +### 3.2 指定某个邮箱服务并自动上传到 sub2api + +#### curl + +```bash +curl -X POST "http://127.0.0.1:8000/api/external/registration/batches" \ + -H "Content-Type: application/json" \ + -H "X-API-Key: your_api_key" \ + -d '{ + "count": 5, + "idempotency_key": "batch-demo-001", + "email": { + "type": "temp_mail", + "service_id": 1 + }, + "upload": { + "enabled": true, + "provider": "sub2api", + "service_id": 1 + }, + "execution": { + "mode": "parallel", + "concurrency": 3, + "interval_min": 0, + "interval_max": 0 + } + }' +``` + +#### Python + +```python +import requests + +payload = { + "count": 5, + "idempotency_key": "batch-demo-001", + "email": { + "type": "temp_mail", + "service_id": 1, + }, + "upload": { + "enabled": True, + "provider": "sub2api", + "service_id": 1, + }, + "execution": { + "mode": "parallel", + "concurrency": 3, + "interval_min": 0, + "interval_max": 0, + }, +} + +resp = requests.post( + "http://127.0.0.1:8000/api/external/registration/batches", + headers={ + "X-API-Key": "your_api_key", + "Content-Type": "application/json", + }, + json=payload, + timeout=30, +) + +data = resp.json() +print(resp.status_code, data) +batch_uuid = data["batch_uuid"] +``` + +> 说明: +> +> - 首次创建一般返回 `202` +> - 如果 `idempotency_key` 命中历史批次,返回 `200` + +--- + +### 3.3 不指定 service_id,交给系统自动选择 + +```python +import requests + +payload = { + "count": 2, + "email": { + "type": "temp_mail" + }, + "upload": { + "enabled": True, + "provider": "sub2api" + }, + "execution": { + "mode": "pipeline", + "concurrency": 1, + "interval_min": 0, + "interval_max": 0 + } +} + +resp = requests.post( + "http://127.0.0.1:8000/api/external/registration/batches", + headers={ + "X-API-Key": "your_api_key", + "Content-Type": "application/json", + }, + json=payload, + timeout=30, +) + +print(resp.status_code) +print(resp.json()) +``` + +适用场景: + +- 你不关心固定某个邮箱服务 +- 你不关心固定某个上传目标 +- 只希望系统按当前可用配置自动兜底选择 + +--- + +## 附:`failure_category` 示例 + +`failure_category` 是给程序消费的稳定字段,允许值只有: + +- `config` +- `business` +- `transient` +- `null` + +### 示例 1:未失败批次 + +```json +{ + "batch_uuid": "0f8fad5b-d9cb-469f-a165-70867728950e", + "status": "pending", + "requested_count": 3, + "completed_count": 0, + "success_count": 0, + "failed_count": 0, + "upload_success_count": 0, + "upload_failed_count": 0, + "failure_reason": null, + "failure_category": null, + "recent_errors": [], + "idempotent_replay": false +} +``` + +### 示例 2:可重试失败 + +```json +{ + "batch_uuid": "0f8fad5b-d9cb-469f-a165-70867728950e", + "status": "failed", + "requested_count": 3, + "completed_count": 1, + "success_count": 0, + "failed_count": 1, + "upload_success_count": 0, + "upload_failed_count": 0, + "failure_reason": "upstream timeout", + "failure_category": "transient", + "recent_errors": [ + "provider timeout" + ], + "idempotent_replay": false +} +``` + +### 示例 3:配置问题失败 + +```json +{ + "batch_uuid": "0f8fad5b-d9cb-469f-a165-70867728950e", + "status": "failed", + "requested_count": 3, + "completed_count": 0, + "success_count": 0, + "failed_count": 0, + "upload_success_count": 0, + "upload_failed_count": 0, + "failure_reason": "upload service not configured", + "failure_category": "config", + "recent_errors": [ + "upload provider sub2api has no configured service" + ], + "idempotent_replay": false +} +``` + +### 示例 4:cancel 响应 + +`POST /api/external/registration/batches/{batch_uuid}/cancel` 也可能返回 `failure_category`,只是为了和其他批次响应保持一致: + +```json +{ + "batch_uuid": "0f8fad5b-d9cb-469f-a165-70867728950e", + "status": "cancelled", + "failure_reason": null, + "failure_category": null +} +``` + +--- + +## 4. 查询批次状态 + +### curl + +```bash +curl -X GET "http://127.0.0.1:8000/api/external/registration/batches/" \ + -H "X-API-Key: your_api_key" +``` + +### Python + +```python +import requests +import time + +batch_uuid = "replace-with-real-batch-uuid" + +while True: + resp = requests.get( + f"http://127.0.0.1:8000/api/external/registration/batches/{batch_uuid}", + headers={"X-API-Key": "your_api_key"}, + timeout=30, + ) + data = resp.json() + print(data) + + if data["status"] in {"completed", "completed_partial", "failed", "cancelled"}: + break + + time.sleep(5) +``` + +--- + +## 5. 取消批次 + +### curl + +```bash +curl -X POST "http://127.0.0.1:8000/api/external/registration/batches//cancel" \ + -H "X-API-Key: your_api_key" +``` + +### Python + +```python +import requests + +batch_uuid = "replace-with-real-batch-uuid" + +resp = requests.post( + f"http://127.0.0.1:8000/api/external/registration/batches/{batch_uuid}/cancel", + headers={"X-API-Key": "your_api_key"}, + timeout=30, +) + +print(resp.status_code) +print(resp.json()) +``` + +--- + +## 6. 常见返回示例 + +### 6.1 创建成功 + +```json +{ + "batch_uuid": "0f8fad5b-d9cb-469f-a165-70867728950e", + "status": "pending", + "requested_count": 3, + "completed_count": 0, + "success_count": 0, + "failed_count": 0, + "upload_success_count": 0, + "upload_failed_count": 0, + "failure_reason": null, + "created_at": "2026-03-27T12:00:00", + "started_at": null, + "completed_at": null, + "recent_errors": [], + "idempotent_replay": false +} +``` + +### 6.2 参数错误 + +```json +{ + "detail": [ + { + "type": "value_error", + "loc": ["body"], + "msg": "Value error, upload.provider is required when upload.enabled is true", + "input": {} + } + ] +} +``` + +### 6.3 批次不存在 + +```json +{ + "detail": "batch_not_found" +} +``` + +--- + +## 7. 最简 Python 封装示例 + +```python +import time +import requests + + +class ExternalRegisterClient: + def __init__(self, base_url: str, api_key: str): + self.base_url = base_url.rstrip("/") + self.session = requests.Session() + self.session.headers.update({"X-API-Key": api_key}) + + def capabilities(self): + return self.session.get(f"{self.base_url}/external/capabilities", timeout=30).json() + + def create_batch(self, payload: dict): + return self.session.post( + f"{self.base_url}/external/registration/batches", + json=payload, + timeout=30, + ).json() + + def get_batch(self, batch_uuid: str): + return self.session.get( + f"{self.base_url}/external/registration/batches/{batch_uuid}", + timeout=30, + ).json() + + def cancel_batch(self, batch_uuid: str): + return self.session.post( + f"{self.base_url}/external/registration/batches/{batch_uuid}/cancel", + timeout=30, + ).json() + + def wait_batch(self, batch_uuid: str, interval: int = 5): + while True: + data = self.get_batch(batch_uuid) + if data["status"] in {"completed", "completed_partial", "failed", "cancelled"}: + return data + time.sleep(interval) + + +client = ExternalRegisterClient("http://127.0.0.1:8000/api", "your_api_key") + +batch = client.create_batch({ + "count": 2, + "email": {"type": "temp_mail"}, + "upload": {"enabled": False}, + "execution": {"mode": "pipeline", "concurrency": 1, "interval_min": 0, "interval_max": 0}, +}) + +result = client.wait_batch(batch["batch_uuid"]) +print(result) +``` diff --git a/docs/external-api-quickstart.md b/docs/external-api-quickstart.md new file mode 100644 index 0000000000000000000000000000000000000000..ee9d28a87a21f24eed9fa6ea532b7aa0297c4d29 --- /dev/null +++ b/docs/external-api-quickstart.md @@ -0,0 +1,309 @@ +# External API 第三方对接速览 + +如果你是第三方调用方,只需要记住下面这套最小流程。 + +详细字段说明见: + +- [Temp Mail 域名池与外部注册 API 说明](temp-mail-external-api.md) +- [External API 调用示例](external-api-examples.md) + +--- + +## 1. 先查能力 + +调用: + +```http +GET /api/external/capabilities +``` + +目的: + +- 看当前支持哪些邮箱类型 +- 看有哪些上传目标可用 +- 看 `temp_mail` 是否有可用服务 + +你要重点关注返回中的: + +- `email_types[].type` +- `email_types[].services[].id` +- `email_types[].services[].name` +- `upload_providers[].provider` +- `upload_providers[].services[].id` +- `upload_providers[].services[].name` + +示例: + +```json +{ + "email_types": [ + { + "type": "temp_mail", + "available": true, + "services": [ + {"id": 3, "name": "Worker temp mail A", "priority": 0}, + {"id": 7, "name": "Worker temp mail B", "priority": 0} + ] + } + ], + "upload_providers": [ + { + "provider": "sub2api", + "available": true, + "services": [ + {"id": 1, "name": "sub2-main", "priority": 0}, + {"id": 4, "name": "sub2-backup", "priority": 10} + ] + } + ] +} +``` + +这时: + +- `email.service_id=3` 表示选 `Worker temp mail A` +- `email.service_id=7` 表示选 `Worker temp mail B` +- `upload.service_id=1` 表示选 `sub2-main` +- `upload.service_id=4` 表示选 `sub2-backup` + +> 不是 0/1 猜出来的,而是以 `capabilities` 当前返回的 `id + name` 为准。 + +--- + +## 2. 再创建批次 + +调用: + +```http +POST /api/external/registration/batches +``` + +你只需要决定 4 件事: + +1. 要注册多少个:`count` +2. 用哪种邮箱:`email.type` +3. 是否上传:`upload.enabled` +4. 怎么执行:`execution.mode` + +最小示例: + +```json +{ + "count": 10, + "email": { + "type": "temp_mail" + }, + "upload": { + "enabled": false + }, + "execution": { + "mode": "pipeline", + "concurrency": 1, + "interval_min": 0, + "interval_max": 0 + } +} +``` + +如果你不传: + +- `email.service_id` +- `upload.service_id` + +则系统会自动选择当前可用服务。 + +如果你想固定某一个邮箱服务或上传目标,就必须先通过 `capabilities` 拿到对应 `id`。 + +--- + +## 3. 保存 `batch_uuid` + +创建成功后,响应里会返回: + +```json +{ + "batch_uuid": "..." +} +``` + +后续所有查询和取消都依赖这个值。 + +--- + +## 4. 轮询状态 + +调用: + +```http +GET /api/external/registration/batches/{batch_uuid} +``` + +你只需要关注: + +- `status` +- `requested_count` +- `completed_count` +- `success_count` +- `failed_count` +- `upload_success_count` +- `upload_failed_count` +- `recent_errors` + +终态只有这几个: + +- `completed` +- `completed_partial` +- `failed` +- `cancelled` + +--- + +## 5. 如需终止就取消 + +调用: + +```http +POST /api/external/registration/batches/{batch_uuid}/cancel +``` + +仅 `pending` / `running` 批次可以取消。 + +--- + +## 6. 推荐调用策略 + +### 推荐 1:始终传 `idempotency_key` + +这样你的上游系统在重试创建请求时,不会重复创建同一批任务。 + +### 推荐 2:先看 `capabilities`,再决定参数 + +不要写死: + +- 邮箱类型 +- 上传 provider +- service_id + +否则后台一改配置,你的调用方就会直接打空。 + +尤其不要假设: + +- `1` 永远是某个邮箱 +- `1` 永远是某个上传渠道 + +`service_id` 的意义完全取决于当前环境里的实际配置。 + +### 推荐 3:把“成功标准”定义成批次维度 + +不要要求“必须全部成功”。 + +更合理的是读取: + +- `success_count` +- `failed_count` + +再由你的系统决定是否接受这次结果。 + +--- + +## 7. 常见错误处理 + +| 状态码 | 含义 | 建议处理 | +| --- | --- | --- | +| `401` | API Key 错误 | 检查请求头 | +| `403` | 外部 API 未启用 | 联系后台启用 | +| `404` | 批次不存在 | 检查 `batch_uuid` | +| `422` | 请求体格式不合法 | 修正参数结构 | +| `400` | 业务参数非法 | 修正业务参数 | +| `503` | 服务暂时不可用 | 稍后重试 | + +--- + +## 8. 第三方最小接入步骤 + +建议接入顺序: + +1. 接入 `GET /capabilities` +2. 接入 `POST /batches` +3. 接入 `GET /batches/{batch_uuid}` +4. 最后再接 `POST /cancel` + +这样最快能形成闭环。 + +--- + +## 9. 一句话理解 + +外部调用方只负责: + +- 下发批次 +- 轮询状态 +- 读取成功/失败统计 + +系统内部负责: + +- 选邮箱 +- 真正执行注册 +- 上传账号 +- 失败回收与重启恢复 + +--- + +## 10. API Key 从哪里拿 + +外部调用方使用的: + +```http +X-API-Key: 你的APIKey +``` + +这个值不是通过接口获取的,而是由系统管理员在本项目配置里设置后,直接发给对接方。 + +--- + +## 11. `failure_category` 字段 + +批次响应现在会额外返回: + +```json +"failure_category": null +``` + +或: + +```json +"failure_category": "config" +``` + +允许值只有四种: + +- `config` +- `business` +- `transient` +- `null` + +推荐按下面方式消费: + +- `transient`:可按你的策略做退避重试 +- `config`:不要自动重试,先人工检查配置 +- `business`:不要自动重试,先人工检查业务约束 +- `null`:当前批次还没有失败分类,通常表示批次未失败 + +说明: + +- `failure_reason` 继续保留,主要给人看 +- `failure_category` 主要给程序判断 +- `POST /api/external/registration/batches/{batch_uuid}/cancel` 的响应也可能带上该字段;这是为了和其他批次响应保持一致,不需要时可直接忽略 + +### 返回规则 + +- `pending` / `running` / `completed` / `completed_partial` / `cancelled` + - `failure_category = null` +- `failed` + - `failure_category` 一定是 `config | business | transient` 之一 + +也就是说: + +- 对接方自己拿不到 +- 必须由项目维护者提供 +- 如果项目维护者修改了 Key,对接方也要同步更新请求头 diff --git a/docs/external-api-webui-management-plan.md b/docs/external-api-webui-management-plan.md new file mode 100644 index 0000000000000000000000000000000000000000..9364c1d39c43c284b944e6d68528306a6a78369f --- /dev/null +++ b/docs/external-api-webui-management-plan.md @@ -0,0 +1,413 @@ +# External API 网站内管理与 API Key 生成方案 + +本文档是**内部实现方案文档**,用于后续开发“在网站设置页直接启用/关闭 External API,并生成/管理 API Key”功能。 + +这不是给第三方调用方看的文档。 +第三方对接请看: + +- [第三方技术对接文档](third-party-integration-guide.md) +- [External API 调用示例](external-api-examples.md) +- [Temp Mail 域名池与外部注册 API 说明](temp-mail-external-api.md) + +--- + +## 1. 目标 + +在网站设置页中直接提供一块 **External API 管理区域**,让管理员可以: + +- 启用 / 关闭 External API +- 查看当前是否已配置 API Key +- 一键生成新的 API Key +- 手动设置自定义 API Key +- 复制对接所需的 Base URL / Header / 文案 + +目标结果是: + +1. 管理员不需要手动改数据库或配置文件 +2. 第三方对接信息可以直接从网站界面获得 +3. API Key 的展示与重置行为可控,不泄漏旧 Key + +--- + +## 2. 当前基础 + +项目当前已经具备以下底座能力: + +### 2.1 配置项已存在 + +系统配置中已经有: + +- `external_api_enabled` +- `external_api_key` + +### 2.2 外部鉴权已存在 + +当前 External API 鉴权已经生效,固定使用: + +```http +X-API-Key: +``` + +并读取 `external_api_enabled`、`external_api_key` 进行校验。 + +### 2.3 设置页架构已存在 + +当前设置页已经采用成熟的三层模式: + +- `templates/settings.html` +- `static/js/settings.js` +- `src/web/routes/settings.py` + +因此本次不是重做设置系统,而是在现有模式上新增一个“外部 API”分区。 + +--- + +## 3. 页面设计 + +### 3.1 新增标签页 + +在设置页 tabs 中新增一个标签: + +- `外部 API` + +该标签页内新增一张独立卡片,用于配置和展示 External API 状态。 + +### 3.2 页面展示内容 + +页面需要展示: + +- `启用 External API` 开关 +- 当前状态: + - 未启用 + - 已启用但未配置 Key + - 已启用且已配置 Key +- Base URL +- Auth Header +- 当前 Key 状态(仅掩码) +- 操作按钮 + +### 3.3 页面按钮 + +页面提供以下按钮: + +- `保存设置` +- `生成新 API Key` +- `复制当前 Key` +- `复制对接信息` + +说明: + +- `复制当前 Key` 仅在“本次刚生成 / 本次刚手动保存新 Key”后可用 +- 页面刷新后只保留掩码,不长期展示完整旧 Key + +### 3.4 页面固定文案 + +页面上必须明确展示: + +- `Base URL: ${location.origin}/api` +- `Auth Header: X-API-Key` +- “重新生成后旧 Key 立即失效” +- “第三方应先调用 /api/external/capabilities 获取 service_id 对应关系” + +--- + +## 4. 后端接口设计 + +在 `src/web/routes/settings.py` 中新增一组专用接口: + +- `GET /api/settings/external-api` +- `POST /api/settings/external-api` +- `POST /api/settings/external-api/generate` + +### 4.1 GET /api/settings/external-api + +作用: + +- 返回当前 External API 状态与展示信息 + +返回字段: + +```json +{ + "enabled": true, + "has_api_key": true, + "api_key_masked": "sk_live_****abcd", + "api_key_header": "X-API-Key", + "base_url": "http://example.com/api" +} +``` + +规则: + +- 不返回完整旧 Key +- `base_url` 可后端返回,也可前端覆盖展示为 `${location.origin}/api` +- `api_key_header` 固定为 `X-API-Key` + +### 4.2 POST /api/settings/external-api + +作用: + +- 保存启用状态 +- 可选手动设置新的 API Key + +请求体: + +```json +{ + "enabled": true, + "api_key": "custom_key_optional" +} +``` + +规则: + +- `enabled` 必填 +- `api_key` 可选 +- 若 `api_key` 未传或为空字符串,则保持原值不变 +- 若 `api_key` 有值,则覆盖旧 Key + +返回体: + +```json +{ + "success": true, + "message": "External API settings updated", + "generated_api_key": "only_when_new_key_is_set", + "api_key_masked": "sk_live_****abcd" +} +``` + +规则: + +- 只有本次确实设置了新 Key 时,才返回一次性明文 +- 否则只返回掩码 + +### 4.3 POST /api/settings/external-api/generate + +作用: + +- 一键生成新 API Key + +生成策略固定为: + +```python +secrets.token_urlsafe(32) +``` + +返回体: + +```json +{ + "success": true, + "message": "External API key regenerated", + "generated_api_key": "new_generated_key", + "api_key_masked": "new_****mask" +} +``` + +规则: + +- 新 Key 生成后立即覆盖旧 Key +- 旧 Key 立即失效 +- 完整明文只在本次响应里返回一次 + +--- + +## 5. 前端交互规则 + +在 `static/js/settings.js` 中新增一组 External API 专用逻辑。 + +### 5.1 页面加载 + +设置页加载时: + +1. 请求 `GET /api/settings/external-api` +2. 渲染启用状态 +3. 渲染 `has_api_key` +4. 渲染掩码值 +5. 渲染 Base URL / Header + +### 5.2 保存行为 + +点击“保存设置”时: + +- 如果 Key 输入框为空: + - 仅保存 `enabled` + - 不修改现有 Key +- 如果 Key 输入框非空: + - 将该值作为新 Key 保存 + - 成功后前端显示本次返回的明文 Key + +### 5.3 生成行为 + +点击“生成新 API Key”时: + +1. 弹确认提示 +2. 调用 `POST /api/settings/external-api/generate` +3. 成功后显示: + - 新生成的完整 Key + - 复制按钮 + - “旧 Key 已失效”提示 + +### 5.4 复制行为 + +#### 复制当前 Key + +仅当页面内存中存在“本次刚生成 / 刚保存”的完整 Key 时启用。 + +#### 复制对接信息 + +自动拼接一段文本,包含: + +- Base URL +- `X-API-Key` +- 4 个 external API 接口 +- `service_id` 来自 capabilities 的说明 + +建议复制内容固定为: + +```text +Base URL: +http://your-domain:8000/api + +Auth Header: +X-API-Key: your_api_key + +Available Endpoints: +GET /external/capabilities +POST /external/registration/batches +GET /external/registration/batches/{batch_uuid} +POST /external/registration/batches/{batch_uuid}/cancel + +注意: +service_id 不要猜,必须来自 GET /external/capabilities 返回的 id。 +``` + +### 5.5 Base URL 显示规则 + +前端展示统一使用: + +```js +`${location.origin}/api` +``` + +这样可以兼容: + +- 直接 IP 访问 +- 域名访问 +- 反向代理 + +--- + +## 6. 安全策略 + +本期实现固定采用以下策略: + +### 6.1 不返回完整旧 Key + +- `GET /api/settings/external-api` 不允许返回完整旧 Key +- 页面刷新后只显示掩码 + +### 6.2 一次性明文展示 + +完整明文只允许在以下场景返回一次: + +- 手动设置了新 Key +- 点击生成了新 Key + +### 6.3 重新生成立即失效 + +- 新 Key 生效后旧 Key 立即作废 +- 不支持新旧 Key 并存 + +### 6.4 本期不做项 + +本期明确不做: + +- 多 Key 并存 +- Key 历史记录 +- Key 过期时间 +- Key 权限分级 +- Key 审计日志 +- 第三方自助申请 Key + +--- + +## 7. 测试与验收 + +### 7.1 后端测试 + +至少覆盖以下场景: + +- `GET /api/settings/external-api` + - 未启用、未配置 Key + - 已启用、已配置 Key +- `POST /api/settings/external-api` + - 仅更新 `enabled` + - 手动设置新 Key +- `POST /api/settings/external-api/generate` + - 成功生成新 Key +- 鉴权联动 + - 新 Key 可用 + - 旧 Key 失效 + - External API 关闭后统一返回 `403` + +### 7.2 前端测试 + +至少覆盖: + +- 设置页新增“外部 API”标签 +- 区块成功渲染 +- 能展示 Base URL / Header / 掩码状态 +- 能显示生成按钮、保存按钮、复制按钮 + +### 7.3 集成验收 + +手工验收闭环: + +1. 在设置页启用 External API +2. 点击生成新 Key +3. 用新 Key 调 `/api/external/capabilities` 成功 +4. 用旧 Key 调用返回 `401` +5. 关闭 External API 后调用返回 `403` + +--- + +## 8. 实施范围与不做项 + +### 本次实施范围 + +- 网站设置页内管理 External API 开关 +- 网站内生成 / 手动设置 API Key +- 网站内复制对接信息 +- 与现有 External API 鉴权打通 + +### 本次不做 + +- 对接方账号体系 +- 多租户 API Key +- Key 权限模型 +- 调用次数限流 +- WebHook / 回调机制 +- API 文档自动生成页面 + +--- + +## 9. 实施结论 + +这是一个 **中低复杂度** 的增强项。 + +原因: + +- 配置项已经存在 +- 鉴权已经存在 +- 设置页框架已经存在 +- 不需要数据库迁移 + +因此实现重点不在底层,而在: + +- 设置接口补全 +- 设置页交互补全 +- Key 展示与重置策略收口 + +按当前代码结构,适合直接在现有 settings 模块内增量实现。 diff --git a/docs/http-only-device-fingerprint-hardening.md b/docs/http-only-device-fingerprint-hardening.md new file mode 100644 index 0000000000000000000000000000000000000000..f0b2babb2d0aec58fd698a0115dfa9d3d5950cc7 --- /dev/null +++ b/docs/http-only-device-fingerprint-hardening.md @@ -0,0 +1,305 @@ +# HTTP-Only 设备指纹补强建议 + +本文档汇总三类信息后的统一结论,用于指导当前项目在**不引入真实浏览器兜底**前提下,继续补强 OpenAI 注册链路的稳定性: + +1. 当前项目现状(`register_198.py`、`src_23_extracted/*`) +2. `F:\MyTools` 中可借鉴的实现 +3. 联网查到的公开资料、官方说明与公开逆向项目 + +--- + +## 1. 范围与目标 + +本方案只讨论 **HTTP-only 注册链路**,不讨论: + +- AdsPower / 外部浏览器 +- Playwright / Selenium 真实浏览器兜底 +- 完整 JS 浏览器指纹复刻 + +目标是: + +- 提高注册链路的一致性 +- 降低 401 / challenge / callback 异常概率 +- 在现有 `curl_cffi` + OpenAI 专用挑战框架上继续增强 + +--- + +## 2. 统一结论 + +### 2.1 当前项目已经做对的部分 + +当前项目已经具备以下基础能力: + +- `curl_cffi impersonate` 的浏览器网络栈拟态 +- 基础 Chrome 风格请求头 +- 先获取 `oai-did` +- Sentinel PoW 求解 +- `openai-sentinel-token` +- 部分 OAuth JSON 请求里的 `oai-device-id` +- 部分链路里的 `traceparent` / `x-datadog-*` +- 会话内尽量复用已有 `oai-did` + +结论:当前项目不是裸接口脚本,已经具备继续补强的基础。 + +### 2.2 官方与公开资料给出的方向 + +- `curl_cffi` 适合做 **TLS / HTTP2 / JA3 / 浏览器网络层拟态** +- 但 `curl_cffi` **不能解决 JS 浏览器指纹** +- OpenAI 官方网络建议明确涉及: + - `auth.openai.com` + - `chatgpt.com` + - `challenges.cloudflare.com` + - `rum.browser-intake-datadoghq.com` +- 公开逆向项目显示,实际链路可能还会涉及: + - `chat-requirements` + - turnstile / challenge + - 前端行为 / 埋点一致性 + +结论:既然不走真实浏览器,就必须把 **HTTP-only 的一致性** 做到尽量完整。 + +### 2.3 `F:\MyTools` 最值得借鉴的点 + +`F:\MyTools` 中真正高价值的不是“更多随机”,而是: + +1. **整套浏览器指纹绑定为统一 profile** + - `impersonate` + - `User-Agent` + - `sec-ch-ua` + - `sec-ch-ua-full-version` + - platform/version 类字段 + +2. **`oai-did` 同步到 `chatgpt.com` 与 `auth.openai.com`** + +3. **Sentinel 按 flow 生成** + - `authorize_continue` + - `password_verify` + +4. **Sentinel 请求本身补充 client hints** + +5. **OAuth 阶段附带更完整的前端语义参数** + - `ext-oai-did` + - `auth_session_logging_id` + +结论:最该借鉴的是 **一致性设计**,不是整包搬代码。 + +--- + +## 3. 应优先补什么 + +以下按优先级排序。 + +### P0:必须补 + +#### 3.1 统一 session 级浏览器 profile + +为每个注册任务固定一份浏览器 profile,至少统一以下字段: + +- `impersonate` +- `User-Agent` +- `Accept-Language` +- `sec-ch-ua` +- `sec-ch-ua-mobile` +- `sec-ch-ua-platform` +- `sec-ch-ua-full-version` +- `sec-ch-ua-platform-version` + +要求: + +- 同一次注册任务从头到尾固定 +- 所有关键请求共用,不允许不同阶段各自拼装 + +原因: + +- 当前最大的风险之一不是“缺 header”,而是**同一会话像多个不同浏览器** + +#### 3.2 `oai-did` 跨域同步 + +原则: + +- 优先使用服务端返回的 `oai-did` +- 将同一个 did 同步到: + - `chatgpt.com` + - `.auth.openai.com` + - `auth.openai.com` + +原因: + +- 注册链路天然跨域 +- did 只存在单一域时,容易出现链路不一致 + +#### 3.3 Sentinel 改为按 flow 生成 + +至少区分以下 flow: + +- `authorize_continue` +- `password_verify` + +必要时再扩展后续 flow。 + +原因: + +- `F:\MyTools` 与公开实现都表明,不同认证阶段不应简单复用同一类 sentinel 语义 + +#### 3.4 给 Sentinel 请求补 client hints + +Sentinel 请求应与本次任务 profile 保持一致,至少补: + +- `sec-ch-ua` +- `sec-ch-ua-mobile` +- `sec-ch-ua-platform` + +原因: + +- 当前项目的 sentinel 请求仍偏简化 +- 这一步最像“浏览器前端发出的挑战请求” + +--- + +### P1:建议补 + +#### 3.5 统一 auth JSON 请求 header builder + +把以下内容统一注入到所有关键 auth JSON 请求: + +- `oai-device-id` +- `traceparent` +- `tracestate` +- `x-datadog-*` + +不要只在部分接口补,建议统一覆盖: + +- authorize / continue +- password verify +- workspace select +- organization select +- 其他关键 auth.openai.com JSON 接口 + +#### 3.6 灰度评估 `ext-oai-did` / `auth_session_logging_id` + +建议: + +- 先做可开关的灰度支持 +- 不作为硬依赖 +- 通过日志观察引入后是否改善成功率 + +原因: + +- 这两个参数更像“前端语义拟真增强” +- 有价值,但存在随上游前端变动而漂移的风险 + +#### 3.7 降低“本地伪造 did”权重 + +原则: + +- 服务端 did 为主 +- 本地生成 did 只做最后 fallback +- fallback 后整条链路也必须固定复用同一个 did + +--- + +### P2:运维层同样重要 + +#### 3.8 把网络环境视为指纹系统的一部分 + +建议: + +- 避免 TLS 解密代理 +- 优先稳定、干净的出口 +- 同一次注册链路尽量固定一个代理出口 +- 尽量避免频繁切换 IP + +原因: + +- HTTP-only 模式下,网络环境本身就是风控输入 + +--- + +## 4. 当前不建议做什么 + +### 4.1 不建议继续堆“随机” + +例如: + +- 随机 UA 但不绑定 `sec-ch-*` +- 随机 Chrome 版本但 `impersonate` 不匹配 +- 请求头在不同阶段各自手搓 + +这些通常会让链路更不像一个真实浏览器。 + +### 4.2 不建议整包照搬 `F:\MyTools` vendor 注册器 + +原因: + +- vendor 代码混有特定时期的 endpoint / 参数假设 +- 当前项目已有自己修好的流程 +- 直接搬迁容易回归 + +正确方式: + +- 抽取机制 +- 按模块吸收 + +### 4.3 不建议走半吊子的“浏览器仿真” + +既然本方案明确不引入真实浏览器,就不要把目标设成完整 JS 指纹复刻。 + +正确目标应是: + +- 最大化 HTTP-only 一致性 +- 尽量贴近同一浏览器会话发出的请求 + +--- + +## 5. 推荐的落地顺序 + +建议按以下顺序推进: + +### 第一批 + +1. 统一 session 级浏览器 profile +2. `oai-did` 跨域同步 +3. `password_verify` 增加 flow-specific sentinel +4. sentinel 请求补 `sec-ch-*` + +### 第二批 + +5. 统一 auth JSON 请求 header builder +6. 扩大 `oai-device-id + trace/datadog` 覆盖范围 +7. 灰度引入 `ext-oai-did` / `auth_session_logging_id` + +### 第三批 + +8. 结合线上日志做结果回归: + - 401 发生在哪一步 + - callback 失败集中在哪一步 + - did fallback 是否明显更容易失败 + - 哪类代理出口成功率更高 + +--- + +## 6. 当前最推荐优先讨论的补齐项 + +如果只选最值得先做的几项,我建议优先讨论: + +1. **统一 session 级浏览器 profile** +2. **did 跨 `chatgpt.com / auth.openai.com` 同步** +3. **`password_verify` 增加 flow-specific sentinel** +4. **sentinel 请求补 client hints** +5. **统一 auth JSON 请求 header builder** + +--- + +## 7. 参考来源 + +- `curl_cffi` impersonate 文档 + https://curl-cffi.readthedocs.io/en/v0.11.4/impersonate.html + +- OpenAI 官方网络建议 + https://help.openai.com/en/articles/9247338-network-recommendations-for-chatgpt-errors-on-web-and-apps + +- 公开逆向参考 + https://github.com/realasfngl/ChatGPT + +- 本地对照项目 + `F:\MyTools` + diff --git a/docs/register_simplification_analysis.md b/docs/register_simplification_analysis.md new file mode 100644 index 0000000000000000000000000000000000000000..c6774e9ff579ebccb81a27fa190a823ddd2e11dd --- /dev/null +++ b/docs/register_simplification_analysis.md @@ -0,0 +1,114 @@ +# register.py 精简分析报告 + +## 概况 + +| 指标 | 数值 | +|------|------| +| 总行数 | 4282 | +| 方法总数 | 103 | +| 100+ 行巨型方法 | 8 个 | +| >50 行方法 | 15 个 | +| <10 行微型方法 | 12 个 | + +## 功能分区与行数占比 + +| 区域 | 行范围 | 行数 | 占比 | 说明 | +|------|--------|------|------|------| +| 核心注册流程 | L93-L556 | 464 | 11% | 初始化、OAuth、登录、密码提交 | +| **Token 交换** | L557-L827 | **271** | **6%** | `_complete_token_exchange` 一个方法 | +| 登录重启 | L828-L881 | 54 | 1% | `_restart_login_flow` | +| **Cookie/Session 处理** | L882-L978 | 97 | 2% | 提取 session_token、Cookie 工具 | +| **chatgpt.com 会话桥接** | L979-L1591 | **613** | **14%** | 会话捕获、重定向跟踪、桥接登录、Outlook 特化 | +| 密码注册 + 邮箱标记 | L1592-L1689 | 98 | 2% | | +| OTP 验证 + 日志 | L1690-L1873 | 184 | 4% | stage 管理、验证码获取、debug 日志 | +| **日志/调试工具** | L1874-L2136 | 263 | 6% | Cookie debug、账号创建、域名黑名单 | +| **导航/载荷解析** | L2137-L2659 | **523** | **12%** | JSON 解码、文本提取、URL 分类、导航候选 | +| **Consent/Script 资产检查** | L2659-L2956 | **298** | **7%** | Script 资产解析、embedded payload 提取 | +| **Workspace 选择** | L2957-L3368 | **412** | **10%** | workspace/org 获取、选择、callback 探测 | +| **恢复/重试路径** | L3369-L3940 | **572** | **13%** | consent 恢复、重定向跟踪、Outlook 恢复、会话内重登 | +| 主入口 + 保存 | L3941-L4282 | 342 | 8% | authorize replay、run、save_to_database | + +## 精简建议 + +### 1. 导航/载荷解析提取为独立模块(省 ~520 行) + +**区域**: L2137-L2659,共 **523 行、15 个方法** + +这些方法全部是纯工具函数,与 `RegistrationEngine` 状态无关: +- `_decode_auth_cookie_json_segments`、`_decode_cookie_json_payloads` +- `_collect_interesting_text_fragments_from_data` +- `_extract_app_router_push_payloads_from_text` +- `_extract_json_payload_candidates_from_fragment` +- `_sanitize_url_for_log` +- `_remember_resume_candidate`、`_remember_navigation_candidate` 等 + +> **操作**:提取到 `src/core/navigation_parser.py`,RegistrationEngine 调用即可。 + +--- + +### 2. Consent/Script 资产检查合并(省 ~100 行) + +**区域**: L2659-L2956,共 **298 行** + +包含: +- `_inspect_consent_script_assets` (74行) — 下载并解析 JS 脚本 +- `_extract_embedded_payloads_from_text` (47行) +- `_extract_navigation_candidates_from_text` (37行) +- `_normalize_response_text` (17行) +- 多个小型解析方法 + +> **操作**:与"导航解析"合并移到同一个工具模块中。 + +--- + +### 3. chatgpt.com 会话桥接提取为独立模块(省 ~600 行) + +**区域**: L879-L1591,共 **613 行** + +标明 `# chatgpt.com 会话桥接方法 (从 dou-jiang/codex-console 移植)`。 + +包含: +- `_capture_auth_session_tokens` (125行) +- `_follow_chatgpt_auth_redirects` (77行) +- `_bootstrap_chatgpt_signin_for_session` (119行) +- `_bridge_login_for_session_token` (180行) +- `_complete_token_exchange_outlook` (103行) + +> **操作**:提取到 `src/core/chatgpt_bridge.py`,作为独立类或 mixin。 + +--- + +### 4. `_complete_token_exchange` 拆分(当前 271 行) + +这是最大的单个方法,包含多层嵌套的条件分支(about-you 处理、user_exists 检测、authorize replay、consent fallback 等)。 + +> **操作**:将 about-you 分支、user_exists 恢复逻辑各提取为子方法,可减 100+ 行。 + +--- + +### 5. 恢复/重试路径整理(省 ~200 行) + +**区域**: L3369-L3940 + +- `_attempt_session_bound_reauth` (130行) — 已经很独立 +- `_follow_redirects` (128行) — 通用重定向跟踪 +- `_persist_recoverable_outlook_account` (74行) — Outlook 专用 + +> **操作**:`_follow_redirects` 是通用 HTTP 工具,可移到 `http_client.py`。Outlook 恢复逻辑可移到单独模块。 + +--- + +## 精简预估 + +| 策略 | 预估节省 | 操作复杂度 | +|------|----------|-----------| +| 导航/载荷解析 → `navigation_parser.py` | ~520 行 | 低(纯提取) | +| chatgpt 桥接 → `chatgpt_bridge.py` | ~600 行 | 中(需处理状态依赖) | +| Consent/Script → 合并到导航解析 | ~100 行 | 低 | +| `_complete_token_exchange` 拆分 | ~100 行 | 中 | +| `_follow_redirects` → `http_client.py` | ~130 行 | 低 | + +**总计可精简约 1450 行**,register.py 预计从 4282 行降到 ~2800 行。 + +> [!IMPORTANT] +> 所有精简操作都是**代码搬迁和拆分**,不删除任何功能,只是让 register.py 更聚焦于核心注册编排逻辑。 diff --git a/docs/superpowers/plans/2026-03-27-external-failure-category.md b/docs/superpowers/plans/2026-03-27-external-failure-category.md new file mode 100644 index 0000000000000000000000000000000000000000..e71b206238be2c8071abdd888e638b993dd5d0fc --- /dev/null +++ b/docs/superpowers/plans/2026-03-27-external-failure-category.md @@ -0,0 +1,98 @@ +# External Registration Failure Category Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a stable `failure_category` field to external registration batch responses and persist it at batch level for SQLite-backed deployments. + +**Architecture:** Persist one new batch-level column, compute it centrally when a batch reaches terminal state, and expose it through the shared serializer so create/get/cancel/idempotent replay all stay consistent. Keep the implementation minimal: no item-level schema changes, no route-layer inference, no PostgreSQL migration work. + +**Tech Stack:** Python, FastAPI, Pydantic, SQLAlchemy, SQLite, pytest + +--- + +## Chunk 1: Workspace and baseline + +### Task 1: Safe isolated workspace + +**Files:** +- Create: `docs/superpowers/plans/2026-03-27-external-failure-category.md` + +- [x] **Step 1: Create a dedicated worktree and branch** +- [x] **Step 2: Run baseline tests in the worktree** +- [x] **Step 3: Record the plan in this file before code changes** + +## Chunk 2: Tests first + +### Task 2: Route and documentation-facing tests + +**Files:** +- Modify: `tests/test_external_api_routes.py` +- Modify: `docs/external-api-quickstart.md` +- Modify: `docs/external-api-examples.md` + +- [x] **Step 1: Write failing route tests for `failure_category` presence on create/get/cancel/idempotent replay** +- [x] **Step 2: Run the route test subset and confirm the new assertions fail for the expected reason** +- [x] **Step 3: Update external API docs after implementation is stable** + +### Task 3: Service and migration tests + +**Files:** +- Modify: `tests/test_external_batch_service.py` + +- [x] **Step 1: Write failing tests for summary classification, recovery classification, unknown fallback, and non-failed null behavior** +- [x] **Step 2: Include a SQLite migration coverage test for adding the new column to an existing DB** +- [x] **Step 3: Run the service test subset and confirm the new assertions fail for the expected reason** + +## Chunk 3: Minimal production changes + +### Task 4: Persist and compute `failure_category` + +**Files:** +- Modify: `src/database/models.py` +- Modify: `src/database/session.py` +- Modify: `src/core/external_batches/service.py` +- Modify: `src/core/external_batches/recovery.py` + +- [x] **Step 1: Add `failure_category` to `ExternalRegistrationBatch`** +- [x] **Step 2: Add SQLite auto-migration support for the new column** +- [x] **Step 3: Add a single classification helper in the batch service** +- [x] **Step 4: Update summary recomputation to set category on `failed` and clear it otherwise** +- [x] **Step 5: Update recovery logic to persist `transient` for `service_restarted`** +- [x] **Step 6: Update the shared serializer to return `failure_category`** + +## Chunk 4: Verification and review closure + +### Task 5: Verification + +**Files:** +- Verify: `tests/test_external_api_routes.py` +- Verify: `tests/test_external_batch_service.py` + +- [x] **Step 1: Run focused tests until green** +- [x] **Step 2: Run the combined external test suite** +- [x] **Step 3: Inspect diffs and confirm only intended files changed** + +### Task 6: Review loop + +**Files:** +- Review: all changed files + +- [x] **Step 1: Run spec-compliance review** +- [x] **Step 2: Fix any findings and re-run spec review until zero issues** +- [x] **Step 3: Run code-quality review** +- [x] **Step 4: Fix any findings and re-run quality review until zero issues** +- [x] **Step 5: Run final whole-change review and close only when zero issues remain** + +## Locked behavior + +- Batch response adds `failure_category: "config" | "business" | "transient" | null` +- `POST /api/external/registration/batches`, `GET /api/external/registration/batches/{batch_uuid}`, and `POST /api/external/registration/batches/{batch_uuid}/cancel` all return it +- Non-`failed` statuses always serialize `failure_category = null` +- `failed` batches must serialize one of `config | business | transient` +- Idempotent replay returns the originally persisted category +- Classification mapping: + - `service_restarted` -> `transient` + - upload/email service missing, disabled, wrong type/provider, unsupported provider, no enabled services -> `config` + - `outlook requested_service_id cannot be reused when count > 1` -> `business` + - `no_available_email_service` -> `transient` + - `registration_failed` and unknown reasons -> `transient` diff --git a/docs/temp-mail-external-api.md b/docs/temp-mail-external-api.md new file mode 100644 index 0000000000000000000000000000000000000000..4b81e5ad50cfaf8d11de6bc7c3b6ee1152b6da81 --- /dev/null +++ b/docs/temp-mail-external-api.md @@ -0,0 +1,416 @@ +# Temp Mail 域名池与外部注册 API 说明 + +本文档说明本项目最近新增/补强的两部分能力: + +1. `temp_mail` 邮箱服务支持域名池与 Worker 域名 API +2. 外部调用方可通过 HTTP API 创建注册批次、查询状态、取消任务 + +配套文档: + +- [External API 调用示例](external-api-examples.md) +- [External API 第三方对接速览](external-api-quickstart.md) +- [External API 网站内管理与 API Key 生成方案](external-api-webui-management-plan.md) + +--- + +## 1. Temp Mail 域名池 + +### 1.1 适用场景 + +项目内新增了 `temp_mail` 邮箱类型,用于接入你自建的临时邮箱服务,例如: + +- `https://apmail.889110.xyz/` + +服务端支持通过管理接口返回当前可用域名池,例如: + +- `GET /admin/domains` + +请求头: + +- `x-admin-auth: ` + +### 1.2 支持的配置项 + +`temp_mail` 服务配置支持以下字段: + +| 字段 | 必填 | 说明 | +| --- | --- | --- | +| `base_url` | 是 | 临时邮箱服务地址,如 `https://apmail.889110.xyz` | +| `admin_password` | 是 | 管理接口密码,对应请求头 `x-admin-auth` | +| `domains` | 否 | 域名池,数组或逗号分隔字符串,优先级最高 | +| `domain` | 否 | 单个回退域名 | +| `enable_prefix` | 否 | 是否保留旧版 `tmp` 前缀,默认 `false` | +| `timeout` | 否 | 请求超时,默认 `30` | +| `max_retries` | 否 | 请求重试次数,默认 `3` | + +### 1.3 域名解析优先级 + +创建邮箱时,域名来源按以下优先级解析: + +1. `config.domains` +2. Worker `GET /admin/domains` +3. `config.domain` + +如果三者都拿不到可用域名,则创建邮箱失败。 + +### 1.4 随机邮箱生成规则 + +- 默认生成**纯随机**本地名,不再强制 `tmp` 前缀 +- 如果配置 `enable_prefix=true`,则继续使用旧版 `tmpxxxxx` 风格 +- 最终会从当前可用域名池中随机选择一个域名拼接地址 + +### 1.5 可用服务展示 + +以下两个接口会展示 `temp_mail` 的域名摘要: + +- Web UI: `GET /api/registration/available-services` +- 外部 API: `GET /api/external/capabilities` + +返回中会包含: + +- `domain_count` +- `domains_preview` +- `domain_source` + +其中 `domain_source` 可能为: + +- `config_domains` +- `worker_api` +- `config_fallback` +- `none` + +> 注意:接口只暴露安全摘要,不会把 `admin_password` 等敏感信息返回给前端或外部调用方。 + +--- + +## 2. 外部注册 API + +### 2.1 功能概览 + +外部 API 允许调用方不直接操作 Web UI,而是通过 HTTP 请求: + +- 查询当前可用邮箱类型与上传目标 +- 创建异步注册批次 +- 查询批次状态 +- 取消批次 + +### 2.2 鉴权方式 + +需要在系统设置中启用: + +- `external_api_enabled` +- `external_api_key` + +请求头使用: + +```http +X-API-Key: +``` + +未启用时返回 `403`;密钥错误时返回 `401`。 + +### 2.2.1 API Key 从哪里来 + +当前项目里,外部 API Key 不是“调用方自己申请”的,也不是接口动态下发的。 + +它来自系统管理员在项目配置中设置的: + +- `external_api_enabled` +- `external_api_key` + +也就是说: + +1. 管理员先在系统里启用外部 API +2. 管理员在系统里填写一个 `external_api_key` +3. 再把这个值发给第三方调用方 + +第三方真正拿到的就是这个管理员配置的值,然后放到: + +```http +X-API-Key: 你拿到的那个值 +``` + +> 当前版本没有单独的“获取 API Key / 轮换 API Key”开放接口。 +> 如果要换 Key,需要由系统管理员修改配置后,再重新发给对接方。 + +### 2.3 能力查询 + +接口: + +```http +GET /api/external/capabilities +``` + +用于返回: + +- 支持的邮箱类型 +- 当前可用邮箱服务 +- 当前可用上传目标 +- 外部 API 是否启用 + +其中邮箱类型包含数据库内启用的: + +- `outlook` / `moe_mail` / `temp_mail` / `duck_mail` / `freemail` / `imap_mail` + +其中临时邮箱能力在外部 API 中统一使用 `temp_mail` 枚举,不再暴露旧的 `tempmail` 占位。 + +能力接口返回的每个可选服务,都会带: + +- `id` +- `name` +- `priority` + +对于 `temp_mail`,还会带: + +- `domain_count` +- `domains_preview` +- `domain_source` + +### 2.3.1 service_id 不是 0/1 猜出来的 + +第三方在创建批次时看到的: + +- `email.service_id` +- `upload.service_id` + +都不是让你自己猜 “0 代表哪个、1 代表哪个”。 + +正确方式是: + +1. 先调 `GET /api/external/capabilities` +2. 读取返回里的服务列表 +3. 按 `name` 找到你想用的服务 +4. 把该服务对应的 `id` 填回创建请求里的 `service_id` + +也就是说,真正的映射关系来自 `capabilities` 返回值,而不是文档里写死。 + +例如能力返回里可能是: + +```json +{ + "email_types": [ + { + "type": "temp_mail", + "services": [ + {"id": 3, "name": "Worker temp mail A"}, + {"id": 7, "name": "Worker temp mail B"} + ] + } + ], + "upload_providers": [ + { + "provider": "sub2api", + "services": [ + {"id": 1, "name": "sub2-main"}, + {"id": 4, "name": "sub2-backup"} + ] + } + ] +} +``` + +那你在创建批次时: + +- 传 `email.service_id=3`,表示使用 `Worker temp mail A` +- 传 `upload.service_id=4`,表示上传到 `sub2-backup` + +> 结论:`service_id` 只认 `capabilities` 当前返回的 `id`,不要手写猜测,也不要假设某个数字永远对应某个服务。 + +### 2.4 创建注册批次 + +接口: + +```http +POST /api/external/registration/batches +``` + +请求体示例: + +```json +{ + "count": 5, + "idempotency_key": "demo-batch-001", + "email": { + "type": "temp_mail", + "service_id": 1 + }, + "upload": { + "enabled": true, + "provider": "sub2api", + "service_id": 1 + }, + "execution": { + "mode": "pipeline", + "concurrency": 1, + "interval_min": 5, + "interval_max": 15 + } +} +``` + +字段约束: + +- `count`: `1 ~ 100` +- `email.type`: 必填 +- `email.service_id`: 可选 +- `upload.provider`: 仅在 `upload.enabled=true` 时必填 +- `upload.provider`: 仅支持 `sub2api` / `cpa` / `tm` +- `execution.mode`: `pipeline` 或 `parallel` +- `execution.concurrency`: `1 ~ 50` +- `execution.interval_max >= execution.interval_min` + +关于 `service_id` 的默认行为: + +- `email.service_id` 不传:系统按该邮箱类型的当前可用规则自动选 +- `upload.service_id` 不传:系统按该上传 provider 当前启用且优先级最高的服务自动选 + +返回规则: + +- 首次创建成功:HTTP `202` +- 如果 `idempotency_key` 命中历史批次:HTTP `200` + +### 2.5 查询批次状态 + +接口: + +```http +GET /api/external/registration/batches/{batch_uuid} +``` + +返回字段包含: + +- `batch_uuid` +- `status` +- `requested_count` +- `completed_count` +- `success_count` +- `failed_count` +- `upload_success_count` +- `upload_failed_count` +- `failure_reason` +- `recent_errors` + +状态可能包括: + +- `pending` +- `running` +- `completed` +- `completed_partial` +- `failed` +- `cancelled` + +### 2.6 取消批次 + +接口: + +```http +POST /api/external/registration/batches/{batch_uuid}/cancel +``` + +规则: + +- `pending` / `running` 可取消 +- 已结束批次不可重复取消,返回 `400` +- 批次不存在返回 `404` + +### 2.7 错误码约定 + +| 场景 | 状态码 | +| --- | --- | +| API 未启用 | `403` | +| API Key 错误 | `401` | +| 请求体验证失败 | `422` | +| 批次不存在 | `404` | +| 业务参数不合法 | `400` | +| 外部批次服务不可用 | `503` | + +--- + +## 3. 外部批次执行行为 + +### 3.1 持久化 + +外部批次及其子项会持久化到数据库: + +- `external_registration_batches` +- `external_registration_batch_items` + +同时为每个子项生成独立 `registration_task_uuid`,便于复用现有注册执行链路。 + +### 3.2 幂等 + +如果创建批次时传入 `idempotency_key`,系统会优先复用已有批次,避免重复创建。 + +### 3.3 重启恢复 + +服务启动时会尝试恢复中断批次: + +- `pending` / `running` 的外部批次会被标记为失败 +- 失败原因记为 `service_restarted` + +这样可以避免系统重启后外部调用方看到“永远卡在运行中”的脏状态。 + +### 3.4 上传行为 + +如果启用了上传: + +- 注册成功后才会执行上传 +- 会优先按注册结果里的 `email` 查账号 +- 如找不到,再按 `account_id` 回退查找 +- 上传结果会分别统计成功数和失败数 + +--- + +## 4. 当前测试覆盖 + +本轮功能已补充并验证以下覆盖: + +- `temp_mail` 本地名生成与域名随机选择 +- `temp_mail` 域名解析优先级与回退逻辑 +- Worker 域名 API 集成路径 +- `available-services` / `capabilities` 的域名摘要展示 +- 外部 API 鉴权、参数校验、错误码映射 +- 外部批次创建、幂等、状态汇总、取消、重启恢复 +- 外部批次执行中的预失败项、上传成功/失败、`account_id` 回退查找 +- 服务选择的拒绝分支与边界约束 + +全量测试验证结果: + +- `134 passed` + +--- + +## 5. 使用建议 + +### 5.1 如果你想让 `temp_mail` 自动从多个域名中随机取 + +建议只配置: + +- `base_url` +- `admin_password` +- `domain`(仅作回退) + +然后由 Worker `GET /admin/domains` 统一返回真实域名池。 + +### 5.2 如果你想固定只用部分域名 + +直接在该邮箱服务配置中填写: + +```json +{ + "domains": ["a.example.com", "b.example.com"] +} +``` + +此时系统会优先使用配置内域名池,而不会再请求 Worker 域名接口。 + +### 5.3 如果你要给第三方系统对接 + +优先使用: + +- `GET /api/external/capabilities` +- `POST /api/external/registration/batches` +- `GET /api/external/registration/batches/{batch_uuid}` +- `POST /api/external/registration/batches/{batch_uuid}/cancel` + +这样调用方只负责“下发任务”和“查结果”,实际邮箱、上传、执行模式等策略仍由系统内配置统一兜底。 diff --git a/docs/third-party-integration-guide.md b/docs/third-party-integration-guide.md new file mode 100644 index 0000000000000000000000000000000000000000..96a26339dee2b5ecb7cd3c7fda067cdf8f572635 --- /dev/null +++ b/docs/third-party-integration-guide.md @@ -0,0 +1,455 @@ +# 第三方技术对接文档 + +这份文档是给第三方技术人员直接对接用的。 +按本文档接入即可完成: + +- 查询当前可用邮箱/上传服务 +- 创建注册批次 +- 查询批次状态 +- 取消批次 + +如需更详细字段说明,可再参考: + +- [Temp Mail 域名池与外部注册 API 说明](temp-mail-external-api.md) +- [External API 调用示例](external-api-examples.md) +- [External API 网站内管理与 API Key 生成方案](external-api-webui-management-plan.md) + +--- + +## 1. 对接方会拿到什么 + +项目方需要提供给第三方以下信息: + +### 1.1 Base URL + +例如: + +```text +http://your-domain:8000/api +``` + +### 1.2 鉴权请求头 + +```http +X-API-Key: your_api_key +``` + +### 1.3 API Key 从哪里来 + +这个 `your_api_key` 不是第三方自己申请的,也不是通过接口获取的。 + +它来自项目管理员在系统中配置的: + +- `external_api_enabled` +- `external_api_key` + +然后由项目方把这个 Key 发给第三方使用。 + +> 当前版本没有开放“获取 API Key / 轮换 API Key”接口。 +> 如果更换了 Key,项目方需要重新通知第三方同步更新。 + +--- + +## 2. 对接流程总览 + +第三方只需要实现这 4 个接口: + +1. `GET /external/capabilities` +2. `POST /external/registration/batches` +3. `GET /external/registration/batches/{batch_uuid}` +4. `POST /external/registration/batches/{batch_uuid}/cancel` + +推荐接入顺序: + +1. 先接 `capabilities` +2. 再接 `create batch` +3. 再接 `get batch status` +4. 最后接 `cancel batch` + +--- + +## 3. 查询当前可用能力 + +### 3.1 请求 + +```http +GET /api/external/capabilities +X-API-Key: your_api_key +``` + +### 3.2 作用 + +这个接口用于告诉第三方当前环境里: + +- 支持哪些邮箱类型 +- 各邮箱类型下有哪些具体服务 +- 支持哪些上传 provider +- 每个 provider 下有哪些具体服务 + +### 3.3 典型返回示例 + +```json +{ + "email_types": [ + { + "type": "temp_mail", + "available": true, + "count": 2, + "services": [ + { + "id": 3, + "name": "Worker temp mail A", + "type": "temp_mail", + "priority": 0, + "domain_count": 2, + "domains_preview": ["a.example.com", "b.example.com"], + "domain_source": "worker_api" + }, + { + "id": 7, + "name": "Worker temp mail B", + "type": "temp_mail", + "priority": 0, + "domain_count": 1, + "domains_preview": ["c.example.com"], + "domain_source": "config_domains" + } + ] + } + ], + "upload_providers": [ + { + "provider": "sub2api", + "available": true, + "count": 2, + "services": [ + {"id": 1, "name": "sub2-main", "priority": 0}, + {"id": 4, "name": "sub2-backup", "priority": 10} + ] + }, + { + "provider": "cpa", + "available": true, + "count": 1, + "services": [ + {"id": 2, "name": "cpa-main", "priority": 0} + ] + } + ], + "settings": { + "external_api_enabled": true + } +} +``` + +--- + +## 4. `service_id` 怎么识别 + +这是第三方最容易误解的地方。 + +### 4.1 不是靠猜 0 / 1 / 2 + +`service_id` 不是固定编号,也不是文档里写死的顺序编号。 +它必须以当前环境里 `capabilities` 返回的实际 `id` 为准。 + +例如上面的返回里: + +- `email.service_id = 3` 表示 `Worker temp mail A` +- `email.service_id = 7` 表示 `Worker temp mail B` +- `upload.service_id = 1` 表示 `sub2-main` +- `upload.service_id = 4` 表示 `sub2-backup` + +### 4.2 正确使用方式 + +第三方应始终: + +1. 先请求 `GET /external/capabilities` +2. 按 `type/provider + name` 找到目标服务 +3. 读取对应的 `id` +4. 创建批次时把该 `id` 传给 `service_id` + +### 4.3 不传 `service_id` 会怎样 + +如果不传 `service_id`: + +- `email.service_id` 不传:系统自动选该邮箱类型当前可用服务 +- `upload.service_id` 不传:系统自动选该上传 provider 当前启用且优先级最高的服务 + +如果第三方不需要固定某个具体服务,建议直接不传。 + +--- + +## 5. 创建注册批次 + +### 5.1 请求 + +```http +POST /api/external/registration/batches +Content-Type: application/json +X-API-Key: your_api_key +``` + +### 5.2 请求体示例:最简版本 + +```json +{ + "count": 10, + "email": { + "type": "temp_mail" + }, + "upload": { + "enabled": false + }, + "execution": { + "mode": "pipeline", + "concurrency": 1, + "interval_min": 0, + "interval_max": 0 + } +} +``` + +### 5.3 请求体示例:指定邮箱服务并上传 + +```json +{ + "count": 20, + "idempotency_key": "batch-20260327-001", + "email": { + "type": "temp_mail", + "service_id": 3 + }, + "upload": { + "enabled": true, + "provider": "sub2api", + "service_id": 1 + }, + "execution": { + "mode": "parallel", + "concurrency": 5, + "interval_min": 0, + "interval_max": 0 + } +} +``` + +### 5.4 字段说明 + +| 字段 | 必填 | 说明 | +| --- | --- | --- | +| `count` | 是 | 注册数量,范围 `1 ~ 100` | +| `idempotency_key` | 否 | 幂等键,建议传 | +| `email.type` | 是 | 邮箱类型,如 `temp_mail` / `outlook` | +| `email.service_id` | 否 | 指定某个邮箱服务,来源于 `capabilities` | +| `upload.enabled` | 是 | 是否上传 | +| `upload.provider` | 条件必填 | `upload.enabled=true` 时必填,支持 `sub2api` / `cpa` / `tm` | +| `upload.service_id` | 否 | 指定某个上传服务,来源于 `capabilities` | +| `execution.mode` | 否 | `pipeline` 或 `parallel` | +| `execution.concurrency` | 否 | 并发数,范围 `1 ~ 50` | +| `execution.interval_min` | 否 | 最小间隔秒数 | +| `execution.interval_max` | 否 | 最大间隔秒数,必须 `>= interval_min` | + +### 5.5 返回说明 + +- 首次成功创建:HTTP `202` +- 如果 `idempotency_key` 命中已有批次:HTTP `200` + +返回里会带: + +```json +{ + "batch_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "status": "pending", + "requested_count": 20, + "completed_count": 0, + "success_count": 0, + "failed_count": 0, + "upload_success_count": 0, + "upload_failed_count": 0, + "failure_reason": null, + "created_at": "2026-03-27T12:00:00", + "started_at": null, + "completed_at": null, + "recent_errors": [], + "idempotent_replay": false +} +``` + +第三方需要保存: + +- `batch_uuid` + +后续查询和取消都要用它。 + +--- + +## 6. 查询批次状态 + +### 6.1 请求 + +```http +GET /api/external/registration/batches/{batch_uuid} +X-API-Key: your_api_key +``` + +### 6.2 第三方重点关注字段 + +- `status` +- `requested_count` +- `completed_count` +- `success_count` +- `failed_count` +- `upload_success_count` +- `upload_failed_count` +- `failure_reason` +- `recent_errors` + +### 6.3 终态定义 + +以下状态视为任务结束: + +- `completed` +- `completed_partial` +- `failed` +- `cancelled` + +建议第三方以这几个状态作为轮询终止条件。 + +### 6.4 推荐理解方式 + +如果请求注册 20 个账号: + +- `success_count=20`:全部成功 +- `success_count=17, failed_count=3`:部分成功 +- `success_count=0, failed_count=20`:全部失败 + +不要要求系统必须 100% 成功;应根据成功数和失败数做业务判断。 + +--- + +## 7. 取消批次 + +### 7.1 请求 + +```http +POST /api/external/registration/batches/{batch_uuid}/cancel +X-API-Key: your_api_key +``` + +### 7.2 规则 + +- `pending` / `running` 可取消 +- 已结束批次再次取消会返回 `400` +- 批次不存在返回 `404` + +--- + +## 8. 错误码说明 + +| 状态码 | 含义 | 建议处理 | +| --- | --- | --- | +| `401` | API Key 错误 | 检查 `X-API-Key` | +| `403` | 外部 API 未启用 / Key 未配置 | 联系项目方 | +| `404` | 批次不存在 | 检查 `batch_uuid` | +| `422` | 请求体校验失败 | 修正 JSON 结构 | +| `400` | 业务参数错误 | 检查 `service_id` / provider / 状态 | +| `503` | 服务暂时不可用 | 稍后重试 | + +--- + +## 9. 第三方最小实现建议 + +第三方系统至少实现以下逻辑: + +### 9.1 初始化 + +- 保存 `Base URL` +- 保存 `X-API-Key` + +### 9.2 下发任务前 + +- 调一次 `GET /external/capabilities` +- 读取当前可用邮箱类型 +- 如需固定服务,读取对应 `service_id` + +### 9.3 创建任务 + +- 调 `POST /external/registration/batches` +- 保存返回的 `batch_uuid` + +### 9.4 查询任务 + +- 周期轮询 `GET /external/registration/batches/{batch_uuid}` +- 直到进入终态 + +### 9.5 终态处理 + +- `completed`:当作成功 +- `completed_partial`:按成功数/失败数决定业务处理 +- `failed`:整体失败 +- `cancelled`:任务已取消 + +--- + +## 10. 推荐对接原则 + +### 原则 1:永远先读 `capabilities` + +不要在第三方代码里写死: + +- 邮箱类型 +- 上传 provider +- `service_id` + +### 原则 2:建议总是传 `idempotency_key` + +这样上游超时重试时,不会重复创建同一批次。 + +### 原则 3:如果不关心具体服务,就不要传 `service_id` + +直接让系统自动选,能减少耦合。 + +### 原则 4:把结果当作“批次结果”处理 + +第三方调用的是批次任务,不是单账号实时接口。 +请以批次维度读取: + +- 请求数量 +- 成功数量 +- 失败数量 +- 上传成功/失败数量 + +--- + +## 11. 一段可以直接发给技术对接方的话 + +你可以直接把下面这段发给对方: + +```text +对接地址: +http://your-domain:8000/api + +认证方式: +请求头增加: +X-API-Key: your_api_key + +先调用: +GET /external/capabilities +用来获取当前可用邮箱类型、上传 provider,以及每个服务对应的 service_id 和 name。 + +再调用: +POST /external/registration/batches +创建注册批次。 + +查询状态: +GET /external/registration/batches/{batch_uuid} + +取消任务: +POST /external/registration/batches/{batch_uuid}/cancel + +注意: +1. service_id 不要猜,必须来自 capabilities 返回 +2. 如果不指定 service_id,系统会自动选择可用服务 +3. API Key 由项目方提供,不是通过接口申请 +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..06f9cec3d57abc3f2d4e39a4bbfd0064d5f82e46 --- /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 0000000000000000000000000000000000000000..462b7fc7b120d94a941f2ed8faae595a6b3178c1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,15 @@ +certifi>=2024.0.0 +cffi>=1.16.0 +curl_cffi>=0.14.0 +pycparser>=1.21 +pydantic>=2.0.0 +pydantic-settings>=2.0.0 +fastapi>=0.100.0 +uvicorn[standard]>=0.23.0 +jinja2>=3.1.0 +python-multipart>=0.0.6 +sqlalchemy>=2.0.0 +aiosqlite>=0.19.0 +psycopg[binary]>=3.1.18 +# 可选:无痕打开支付页需要 playwright(pip install playwright && playwright install chromium) +# playwright>=1.40.0 \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e5ac097df1b8a54bcd81587c3347ed50e7a1cd7a --- /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 0000000000000000000000000000000000000000..da2f93b79935e6d8f4c07a1ad3e89fbe430e8ccc --- /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 0000000000000000000000000000000000000000..c2e1d0774c4f333b37d5383e9b1162b722d97b5a --- /dev/null +++ b/src/config/constants.py @@ -0,0 +1,413 @@ +""" +常量定义 +""" + +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 的字典 + """ + # 随机选择名字(First + Last) + first_name = random.choice(FIRST_NAMES) + last_name = random.choice(LAST_NAMES) + name = f"{first_name} {last_name}" + + # 生成随机生日(18-45岁) + current_year = datetime.now().year + birth_year = random.randint(current_year - 45, current_year - 18) + birth_month = random.randint(1, 12) + # 根据月份确定天数 + if birth_month in [1, 3, 5, 7, 8, 10, 12]: + birth_day = random.randint(1, 31) + elif birth_month in [4, 6, 9, 11]: + birth_day = random.randint(1, 30) + else: + # 2月,简化处理 + birth_day = random.randint(1, 28) + + birthdate = f"{birth_year}-{birth_month:02d}-{birth_day:02d}" + + return { + "name": name, + "birthdate": birthdate + } + +# 保留默认值供兼容 +DEFAULT_USER_INFO = { + "name": "Neo", + "birthdate": "2000-02-20", +} + +# ============================================================================ +# 代理相关常量 +# ============================================================================ + +PROXY_TYPES = ["http", "socks5", "socks5h"] +DEFAULT_PROXY_CONFIG = { + "enabled": False, + "type": "http", + "host": "127.0.0.1", + "port": 7890, +} + +# ============================================================================ +# 数据库相关常量 +# ============================================================================ + +# 数据库表名 +DB_TABLE_NAMES = { + "accounts": "accounts", + "email_services": "email_services", + "registration_tasks": "registration_tasks", + "settings": "settings", +} + +# 默认设置 +DEFAULT_SETTINGS = [ + # (key, value, description, category) + ("system.name", APP_NAME, "系统名称", "general"), + ("system.version", APP_VERSION, "系统版本", "general"), + ("logs.retention_days", "30", "日志保留天数", "general"), + ("openai.client_id", OAUTH_CLIENT_ID, "OpenAI OAuth Client ID", "openai"), + ("openai.auth_url", OAUTH_AUTH_URL, "OpenAI 认证地址", "openai"), + ("openai.token_url", OAUTH_TOKEN_URL, "OpenAI Token 地址", "openai"), + ("openai.redirect_uri", OAUTH_REDIRECT_URI, "OpenAI 回调地址", "openai"), + ("openai.scope", OAUTH_SCOPE, "OpenAI 权限范围", "openai"), + ("proxy.enabled", "false", "是否启用代理", "proxy"), + ("proxy.type", "http", "代理类型 (http/socks5)", "proxy"), + ("proxy.host", "127.0.0.1", "代理主机", "proxy"), + ("proxy.port", "7890", "代理端口", "proxy"), + ("registration.max_retries", "3", "最大重试次数", "registration"), + ("registration.timeout", "120", "超时时间(秒)", "registration"), + ("registration.default_password_length", "12", "默认密码长度", "registration"), + ("webui.host", "0.0.0.0", "Web UI 监听主机", "webui"), + ("webui.port", "8000", "Web UI 监听端口", "webui"), + ("webui.debug", "true", "调试模式", "webui"), +] + +# ============================================================================ +# Web UI 相关常量 +# ============================================================================ + +# WebSocket 事件 +WEBSOCKET_EVENTS = { + "CONNECT": "connect", + "DISCONNECT": "disconnect", + "LOG": "log", + "STATUS": "status", + "ERROR": "error", + "COMPLETE": "complete", +} + +# API 响应状态码 +API_STATUS_CODES = { + "SUCCESS": 200, + "CREATED": 201, + "BAD_REQUEST": 400, + "UNAUTHORIZED": 401, + "FORBIDDEN": 403, + "NOT_FOUND": 404, + "CONFLICT": 409, + "INTERNAL_ERROR": 500, +} + +# 分页 +DEFAULT_PAGE_SIZE = 20 +MAX_PAGE_SIZE = 100 + +# ============================================================================ +# 错误消息 +# ============================================================================ + +ERROR_MESSAGES = { + # 通用错误 + "DATABASE_ERROR": "数据库操作失败", + "CONFIG_ERROR": "配置错误", + "NETWORK_ERROR": "网络连接失败", + "TIMEOUT": "操作超时", + "VALIDATION_ERROR": "参数验证失败", + + # 邮箱服务错误 + "EMAIL_SERVICE_UNAVAILABLE": "邮箱服务不可用", + "EMAIL_CREATION_FAILED": "创建邮箱失败", + "OTP_NOT_RECEIVED": "未收到验证码", + "OTP_INVALID": "验证码无效", + + # OpenAI 相关错误 + "OPENAI_AUTH_FAILED": "OpenAI 认证失败", + "OPENAI_RATE_LIMIT": "OpenAI 接口限流", + "OPENAI_CAPTCHA": "遇到验证码", + + # 代理错误 + "PROXY_FAILED": "代理连接失败", + "PROXY_AUTH_FAILED": "代理认证失败", + + # 账户错误 + "ACCOUNT_NOT_FOUND": "账户不存在", + "ACCOUNT_ALREADY_EXISTS": "账户已存在", + "ACCOUNT_INVALID": "账户无效", + + # 任务错误 + "TASK_NOT_FOUND": "任务不存在", + "TASK_ALREADY_RUNNING": "任务已在运行中", + "TASK_CANCELLED": "任务已取消", +} + +# ============================================================================ +# 正则表达式 +# ============================================================================ + +REGEX_PATTERNS = { + "EMAIL": r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$", + "URL": r"https?://(?:[-\w.]|(?:%[\da-fA-F]{2}))+", + "IP_ADDRESS": r"\b(?:\d{1,3}\.){3}\d{1,3}\b", + "OTP_CODE": OTP_CODE_PATTERN, +} + +# ============================================================================ +# 时间常量 +# ============================================================================ + +TIME_CONSTANTS = { + "SECOND": 1, + "MINUTE": 60, + "HOUR": 3600, + "DAY": 86400, + "WEEK": 604800, +} + + +# ============================================================================ +# Microsoft/Outlook 相关常量 +# ============================================================================ + +# Microsoft OAuth2 Token 端点 +MICROSOFT_TOKEN_ENDPOINTS = { + # 旧版 IMAP 使用的端点 + "LIVE": "https://login.live.com/oauth20_token.srf", + # 新版 IMAP 使用的端点(需要特定 scope) + "CONSUMERS": "https://login.microsoftonline.com/consumers/oauth2/v2.0/token", + # Graph API 使用的端点 + "COMMON": "https://login.microsoftonline.com/common/oauth2/v2.0/token", +} + +# IMAP 服务器配置 +OUTLOOK_IMAP_SERVERS = { + "OLD": "outlook.office365.com", # 旧版 IMAP + "NEW": "outlook.live.com", # 新版 IMAP +} + +# Microsoft OAuth2 Scopes +MICROSOFT_SCOPES = { + # 旧版 IMAP 不需要特定 scope + "IMAP_OLD": "", + # 新版 IMAP 需要的 scope + "IMAP_NEW": "https://outlook.office.com/IMAP.AccessAsUser.All offline_access", + # Graph API 需要的 scope + "GRAPH_API": "https://graph.microsoft.com/.default", +} + +# Outlook 提供者默认优先级 +OUTLOOK_PROVIDER_PRIORITY = ["imap_new", "imap_old", "graph_api"] diff --git a/src/config/settings.py b/src/config/settings.py new file mode 100644 index 0000000000000000000000000000000000000000..b4f0e5bb97848d617543e22bf463a64eb97acfdb --- /dev/null +++ b/src/config/settings.py @@ -0,0 +1,790 @@ +""" +配置管理 - 完全基于数据库存储 +所有配置都从数据库读取,不再使用环境变量或 .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 + ), + "external_api_enabled": SettingDefinition( + db_key="external_api.enabled", + default_value=False, + category=SettingCategory.GENERAL, + description="是否启用外部 API" + ), + "external_api_key": SettingDefinition( + db_key="external_api.key", + default_value="", + category=SettingCategory.GENERAL, + description="外部 API Key", + is_secret=True + ), + + # 日志配置 + "log_level": SettingDefinition( + db_key="log.level", + default_value="INFO", + category=SettingCategory.LOG, + description="日志级别" + ), + "log_file": SettingDefinition( + db_key="log.file", + default_value="logs/app.log", + category=SettingCategory.LOG, + description="日志文件路径" + ), + "log_retention_days": SettingDefinition( + db_key="log.retention_days", + default_value=30, + category=SettingCategory.LOG, + description="日志保留天数" + ), + + # OpenAI 配置 + "openai_client_id": SettingDefinition( + db_key="openai.client_id", + default_value="app_EMoamEEZ73f0CkXaXp7hrann", + category=SettingCategory.OPENAI, + description="OpenAI OAuth 客户端 ID" + ), + "openai_auth_url": SettingDefinition( + db_key="openai.auth_url", + default_value="https://auth.openai.com/oauth/authorize", + category=SettingCategory.OPENAI, + description="OpenAI OAuth 授权 URL" + ), + "openai_token_url": SettingDefinition( + db_key="openai.token_url", + default_value="https://auth.openai.com/oauth/token", + category=SettingCategory.OPENAI, + description="OpenAI OAuth Token URL" + ), + "openai_redirect_uri": SettingDefinition( + db_key="openai.redirect_uri", + default_value="http://localhost:1455/auth/callback", + category=SettingCategory.OPENAI, + description="OpenAI OAuth 回调 URI" + ), + "openai_scope": SettingDefinition( + db_key="openai.scope", + default_value="openid email profile offline_access", + category=SettingCategory.OPENAI, + description="OpenAI OAuth 权限范围" + ), + + # 代理配置 + "proxy_enabled": SettingDefinition( + db_key="proxy.enabled", + default_value=False, + category=SettingCategory.PROXY, + description="是否启用代理" + ), + "proxy_type": SettingDefinition( + db_key="proxy.type", + default_value="http", + category=SettingCategory.PROXY, + description="代理类型 (http/socks5)" + ), + "proxy_host": SettingDefinition( + db_key="proxy.host", + default_value="127.0.0.1", + category=SettingCategory.PROXY, + description="代理服务器地址" + ), + "proxy_port": SettingDefinition( + db_key="proxy.port", + default_value=7890, + category=SettingCategory.PROXY, + description="代理服务器端口" + ), + "proxy_username": SettingDefinition( + db_key="proxy.username", + default_value="", + category=SettingCategory.PROXY, + description="代理用户名" + ), + "proxy_password": SettingDefinition( + db_key="proxy.password", + default_value="", + category=SettingCategory.PROXY, + description="代理密码", + is_secret=True + ), + "proxy_dynamic_enabled": SettingDefinition( + db_key="proxy.dynamic_enabled", + default_value=False, + category=SettingCategory.PROXY, + description="是否启用动态代理" + ), + "proxy_dynamic_api_url": SettingDefinition( + db_key="proxy.dynamic_api_url", + default_value="", + category=SettingCategory.PROXY, + description="动态代理 API 地址,返回代理 URL 字符串" + ), + "proxy_dynamic_api_key": SettingDefinition( + db_key="proxy.dynamic_api_key", + default_value="", + category=SettingCategory.PROXY, + description="动态代理 API 密钥(可选)", + is_secret=True + ), + "proxy_dynamic_api_key_header": SettingDefinition( + db_key="proxy.dynamic_api_key_header", + default_value="X-API-Key", + category=SettingCategory.PROXY, + description="动态代理 API 密钥请求头名称" + ), + "proxy_dynamic_result_field": SettingDefinition( + db_key="proxy.dynamic_result_field", + default_value="", + category=SettingCategory.PROXY, + description="从 JSON 响应中提取代理 URL 的字段路径(留空则使用响应原文)" + ), + + # 注册配置 + "registration_max_retries": SettingDefinition( + db_key="registration.max_retries", + default_value=3, + category=SettingCategory.REGISTRATION, + description="注册最大重试次数" + ), + "registration_timeout": SettingDefinition( + db_key="registration.timeout", + default_value=120, + category=SettingCategory.REGISTRATION, + description="注册超时时间(秒)" + ), + "registration_default_password_length": SettingDefinition( + db_key="registration.default_password_length", + default_value=12, + category=SettingCategory.REGISTRATION, + description="默认密码长度" + ), + "registration_sleep_min": SettingDefinition( + db_key="registration.sleep_min", + default_value=5, + category=SettingCategory.REGISTRATION, + description="注册间隔最小值(秒)" + ), + "registration_sleep_max": SettingDefinition( + db_key="registration.sleep_max", + default_value=30, + category=SettingCategory.REGISTRATION, + description="注册间隔最大值(秒)" + ), + + # 邮箱服务配置 + "email_service_priority": SettingDefinition( + db_key="email.service_priority", + default_value={"tempmail": 0, "outlook": 1, "moe_mail": 2}, + category=SettingCategory.EMAIL, + description="邮箱服务优先级" + ), + + # Tempmail.lol 配置 + "tempmail_base_url": SettingDefinition( + db_key="tempmail.base_url", + default_value="https://api.tempmail.lol/v2", + category=SettingCategory.TEMPMAIL, + description="Tempmail API 地址" + ), + "tempmail_timeout": SettingDefinition( + db_key="tempmail.timeout", + default_value=30, + category=SettingCategory.TEMPMAIL, + description="Tempmail 超时时间(秒)" + ), + "tempmail_max_retries": SettingDefinition( + db_key="tempmail.max_retries", + default_value=3, + category=SettingCategory.TEMPMAIL, + description="Tempmail 最大重试次数" + ), + + # 自定义域名邮箱配置 + "custom_domain_base_url": SettingDefinition( + db_key="custom_domain.base_url", + default_value="", + category=SettingCategory.CUSTOM_DOMAIN, + description="自定义域名 API 地址" + ), + "custom_domain_api_key": SettingDefinition( + db_key="custom_domain.api_key", + default_value="", + category=SettingCategory.CUSTOM_DOMAIN, + description="自定义域名 API 密钥", + is_secret=True + ), + + # 安全配置 + "encryption_key": SettingDefinition( + db_key="security.encryption_key", + default_value="your-encryption-key-change-in-production", + category=SettingCategory.SECURITY, + description="加密密钥", + is_secret=True + ), + + # Team Manager 配置 + "tm_enabled": SettingDefinition( + db_key="tm.enabled", + default_value=False, + category=SettingCategory.GENERAL, + description="是否启用 Team Manager 上传" + ), + "tm_api_url": SettingDefinition( + db_key="tm.api_url", + default_value="", + category=SettingCategory.GENERAL, + description="Team Manager API 地址" + ), + "tm_api_key": SettingDefinition( + db_key="tm.api_key", + default_value="", + category=SettingCategory.GENERAL, + description="Team Manager API Key", + is_secret=True + ), + + # CPA 上传配置 + "cpa_enabled": SettingDefinition( + db_key="cpa.enabled", + default_value=False, + category=SettingCategory.CPA, + description="是否启用 CPA 上传" + ), + "cpa_api_url": SettingDefinition( + db_key="cpa.api_url", + default_value="", + category=SettingCategory.CPA, + description="CPA API 地址" + ), + "cpa_api_token": SettingDefinition( + db_key="cpa.api_token", + default_value="", + category=SettingCategory.CPA, + description="CPA API Token", + is_secret=True + ), + + # 验证码配置 + "email_code_timeout": SettingDefinition( + db_key="email_code.timeout", + default_value=120, + category=SettingCategory.EMAIL, + description="验证码等待超时时间(秒)" + ), + "email_code_poll_interval": SettingDefinition( + db_key="email_code.poll_interval", + default_value=3, + category=SettingCategory.EMAIL, + description="验证码轮询间隔(秒)" + ), + + # Outlook 配置 + "outlook_provider_priority": SettingDefinition( + db_key="outlook.provider_priority", + default_value=["imap_old", "imap_new", "graph_api"], + category=SettingCategory.EMAIL, + description="Outlook 提供者优先级" + ), + "outlook_health_failure_threshold": SettingDefinition( + db_key="outlook.health_failure_threshold", + default_value=5, + category=SettingCategory.EMAIL, + description="Outlook 提供者连续失败次数阈值" + ), + "outlook_health_disable_duration": SettingDefinition( + db_key="outlook.health_disable_duration", + default_value=60, + category=SettingCategory.EMAIL, + description="Outlook 提供者禁用时长(秒)" + ), + "outlook_default_client_id": SettingDefinition( + db_key="outlook.default_client_id", + default_value="24d9a0ed-8787-4584-883c-2fd79308940a", + category=SettingCategory.EMAIL, + description="Outlook OAuth 默认 Client ID" + ), +} + +# 属性名到数据库键名的映射(用于向后兼容) +DB_SETTING_KEYS = {name: defn.db_key for name, defn in SETTING_DEFINITIONS.items()} + +# 类型定义映射 +SETTING_TYPES: Dict[str, Type] = { + "debug": bool, + "webui_port": int, + "external_api_enabled": bool, + "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, + "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("WEBUI_HOST") or os.environ.get("APP_HOST") + if env_host: + settings_dict["webui_host"] = env_host + env_port = os.environ.get("PORT") or os.environ.get("WEBUI_PORT") or os.environ.get("APP_PORT") + if env_port: + try: + settings_dict["webui_port"] = int(env_port) + except ValueError: + pass + env_password = os.environ.get("WEBUI_ACCESS_PASSWORD") or 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") + external_api_enabled: bool = False + external_api_key: SecretStr = SecretStr("") + + # 日志配置 + 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 + + # 邮箱服务配置 + 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 reload_settings() -> Settings: + """清空缓存并从数据库重新加载配置。""" + global _settings + _settings = None + return get_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 0000000000000000000000000000000000000000..7ec7c6f827369ffc9dde5a0e11e4b4fd3d55ee98 --- /dev/null +++ b/src/core/__init__.py @@ -0,0 +1,32 @@ +""" +核心功能模块 +""" + +from .openai.oauth import OAuthManager, OAuthStart, generate_oauth_url, submit_callback_url +from .http_client import ( + OpenAIHTTPClient, + HTTPClient, + HTTPClientError, + RequestConfig, + create_http_client, + create_openai_client, +) +from .register import RegistrationEngine, RegistrationResult +from .utils import setup_logging, get_data_dir + +__all__ = [ + 'OAuthManager', + 'OAuthStart', + 'generate_oauth_url', + 'submit_callback_url', + 'OpenAIHTTPClient', + 'HTTPClient', + 'HTTPClientError', + 'RequestConfig', + 'create_http_client', + 'create_openai_client', + 'RegistrationEngine', + 'RegistrationResult', + 'setup_logging', + 'get_data_dir', +] diff --git a/src/core/dynamic_proxy.py b/src/core/dynamic_proxy.py new file mode 100644 index 0000000000000000000000000000000000000000..daf8dfa7b480bda6ae495ca606da99cc59142e4e --- /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/email_service_catalog.py b/src/core/email_service_catalog.py new file mode 100644 index 0000000000000000000000000000000000000000..2e07ea1d33f6cde4ede296244a6e99f10f26ad09 --- /dev/null +++ b/src/core/email_service_catalog.py @@ -0,0 +1,152 @@ +"""Helpers to build email-service catalog payloads in a reusable way.""" + +from __future__ import annotations + +from typing import Any, Callable, Dict, Iterable, Optional + +from ..config.settings import get_settings +from ..database import crud +from ..database.session import get_db +from ..services.temp_mail import TempMailService +from ..services.temp_mail_domain_provider import summarize_temp_mail_domains + + +DomainFetcher = Callable[[], Any] +_TEMP_MAIL_ORDER = ["outlook", "moe_mail", "temp_mail", "duck_mail", "freemail", "imap_mail"] + + +def build_temp_mail_service_entry( + service: Any, + *, + fetch_domains: Optional[DomainFetcher] = None, + preview_limit: int = 3, +) -> Dict[str, Any]: + """Build one temp_mail service item with safe domain summary.""" + config = getattr(service, "config", None) or {} + summary = summarize_temp_mail_domains( + config, + fetch_domains=fetch_domains, + preview_limit=preview_limit, + ) + summary.pop("domains", None) + + return { + "id": getattr(service, "id", None), + "name": getattr(service, "name", None), + "type": "temp_mail", + "priority": getattr(service, "priority", 0), + **summary, + } + + +def build_temp_mail_catalog( + services: Iterable[Any], + *, + fetch_domains_by_service: Optional[Callable[[Any], Optional[DomainFetcher]]] = None, + preview_limit: int = 3, +) -> Dict[str, Any]: + """Build a compact temp_mail catalog block for capabilities APIs.""" + entries = [] + for service in services: + fetcher = fetch_domains_by_service(service) if fetch_domains_by_service else None + entries.append( + build_temp_mail_service_entry( + service, + fetch_domains=fetcher, + preview_limit=preview_limit, + ) + ) + + return { + "available": len(entries) > 0, + "count": len(entries), + "services": entries, + } + + +def _safe_temp_mail_fetcher(service: Any) -> Optional[DomainFetcher]: + config = (getattr(service, "config", None) or {}).copy() + try: + temp_mail = TempMailService(config=config, name=getattr(service, "name", None)) + except Exception: + return None + + def _fetch() -> Any: + return temp_mail._fetch_domains_from_worker() # noqa: SLF001 - internal reuse for summary + + return _fetch + + +def _generic_email_service_entry(service: Any) -> Dict[str, Any]: + return { + "id": getattr(service, "id", None), + "name": getattr(service, "name", None), + "type": getattr(service, "service_type", None), + "priority": getattr(service, "priority", 0), + } + + +def _provider_payload(provider: str, services: Iterable[Any]) -> Dict[str, Any]: + items = [ + {"id": svc.id, "name": svc.name, "priority": svc.priority} + for svc in services + ] + return { + "provider": provider, + "available": len(items) > 0, + "count": len(items), + "services": items, + } + + +def build_external_capabilities() -> Dict[str, Any]: + """Build external API capabilities payload from current DB/settings state.""" + settings = get_settings() + + with get_db() as db: + enabled_email_services = crud.get_email_services(db, enabled=True, limit=1000) + grouped: Dict[str, list[Any]] = {} + for service in enabled_email_services: + grouped.setdefault(service.service_type, []).append(service) + + email_types = [] + + ordered_types = [service_type for service_type in _TEMP_MAIL_ORDER if service_type in grouped] + remaining_types = sorted(service_type for service_type in grouped.keys() if service_type not in _TEMP_MAIL_ORDER) + + for service_type in [*ordered_types, *remaining_types]: + services = grouped.get(service_type, []) + if service_type == "temp_mail": + email_types.append( + { + "type": service_type, + **build_temp_mail_catalog( + services, + fetch_domains_by_service=_safe_temp_mail_fetcher, + ), + } + ) + else: + entries = [_generic_email_service_entry(svc) for svc in services] + email_types.append( + { + "type": service_type, + "available": len(entries) > 0, + "count": len(entries), + "services": entries, + } + ) + + upload_providers = [ + _provider_payload("cpa", crud.get_cpa_services(db, enabled=True)), + _provider_payload("sub2api", crud.get_sub2api_services(db, enabled=True)), + _provider_payload("tm", crud.get_tm_services(db, enabled=True)), + ] + + return { + "email_types": email_types, + "upload_providers": upload_providers, + "settings": { + "external_api_enabled": getattr(settings, "external_api_enabled", False), + }, + } diff --git a/src/core/external_batches/__init__.py b/src/core/external_batches/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..4a84b88dd72143446f8865c2c2339df00156fe05 --- /dev/null +++ b/src/core/external_batches/__init__.py @@ -0,0 +1,9 @@ + +from .service import ExternalBatchCreateRequest, ExternalBatchService +from .recovery import recover_interrupted_external_batches + +__all__ = [ + 'ExternalBatchCreateRequest', + 'ExternalBatchService', + 'recover_interrupted_external_batches', +] diff --git a/src/core/external_batches/recovery.py b/src/core/external_batches/recovery.py new file mode 100644 index 0000000000000000000000000000000000000000..d282d69c809a0fe46e59cee99674ab8056e8e4ad --- /dev/null +++ b/src/core/external_batches/recovery.py @@ -0,0 +1,40 @@ + +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy.orm import Session + +from ...database import crud, external_batch_crud + + +def recover_interrupted_external_batches(db: Session) -> int: + recovered = 0 + for batch in external_batch_crud.list_recoverable_batches(db): + items = external_batch_crud.list_batch_items(db, batch.batch_uuid) + for item in items: + if item.status in {'pending', 'running'}: + external_batch_crud.update_batch_item( + db, + item.id, + status='failed', + failure_reason='service_restarted', + upload_status='failed' if item.upload_status == 'pending' else item.upload_status, + completed_at=datetime.utcnow(), + ) + task = crud.get_registration_task_by_uuid(db, item.registration_task_uuid) if item.registration_task_uuid else None + if task and task.status in {'pending', 'running'}: + task.status = 'failed' + task.error_message = 'service_restarted' + task.completed_at = datetime.utcnow() + external_batch_crud.update_batch( + db, + batch.batch_uuid, + status='failed', + failure_reason='service_restarted', + failure_category='transient', + completed_at=datetime.utcnow(), + ) + recovered += 1 + db.commit() + return recovered diff --git a/src/core/external_batches/service.py b/src/core/external_batches/service.py new file mode 100644 index 0000000000000000000000000000000000000000..5d48a6f95af6b02b7a59670df6a983c8797bb38d --- /dev/null +++ b/src/core/external_batches/service.py @@ -0,0 +1,448 @@ + +from __future__ import annotations + +import asyncio +from dataclasses import asdict +from datetime import datetime +from typing import Optional +from uuid import uuid4 + +from pydantic import BaseModel +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from ...database import crud, external_batch_crud +from ...database.models import Account, ExternalRegistrationBatchItem, RegistrationTask +from ..registration_upload import upload_registered_account +from ..service_selection import UploadTarget, build_email_item_assignments, resolve_upload_target + + +TERMINAL_ITEM_STATUSES = {'completed', 'failed', 'cancelled'} +CONFIG_FAILURE_PREFIXES = ( + 'unsupported upload provider:', + 'no enabled upload services for provider ', + 'no enabled email services for type ', +) +BUSINESS_FAILURE_REASONS = { + 'outlook requested_service_id cannot be reused when count > 1', +} +TRANSIENT_FAILURE_REASONS = { + 'service_restarted', + 'no_available_email_service', + 'registration_failed', +} + + +class ExternalBatchCreateRequest(BaseModel): + count: int + idempotency_key: Optional[str] = None + email_type: str + email_service_id: Optional[int] = None + upload_enabled: bool = False + upload_provider: Optional[str] = None + upload_service_id: Optional[int] = None + mode: str = 'pipeline' + concurrency: int = 1 + interval_min: int = 5 + interval_max: int = 30 + + +class ExternalBatchService: + @staticmethod + def _mark_idempotent_replay(batch): + if batch is not None: + setattr(batch, '_idempotent_replay', True) + return batch + + @staticmethod + def _detach_batch(db: Session, batch): + if batch is None: + return None + db.refresh(batch) + db.expunge(batch) + return batch + + def create_batch(self, db: Session, request: ExternalBatchCreateRequest): + if request.idempotency_key: + existing = external_batch_crud.get_batch_by_idempotency_key(db, request.idempotency_key) + if existing: + return self._mark_idempotent_replay(self._detach_batch(db, existing)) + + upload_target = None + if request.upload_enabled: + upload_target = resolve_upload_target( + db, + provider=request.upload_provider or '', + requested_service_id=request.upload_service_id, + ) + + assignments = build_email_item_assignments( + db, + email_type=request.email_type, + count=request.count, + requested_service_id=request.email_service_id, + ) + + batch_uuid = str(uuid4()) + batch = external_batch_crud.create_batch( + db, + batch_uuid=batch_uuid, + idempotency_key=request.idempotency_key, + status='pending', + email_service_type=request.email_type, + upload_enabled=request.upload_enabled, + upload_provider=upload_target.provider if upload_target else None, + requested_count=request.count, + request_payload=request.model_dump(), + runtime_snapshot={ + 'email_assignments': [asdict(assignment) for assignment in assignments], + 'upload_target': asdict(upload_target) if upload_target else None, + }, + recent_errors=[], + ) + + for assignment in assignments: + task_uuid = str(uuid4()) + db.add( + RegistrationTask( + task_uuid=task_uuid, + status='pending', + email_service_id=assignment.service_id, + ) + ) + db.flush() + external_batch_crud.create_batch_item( + db, + batch_id=batch.id, + item_index=assignment.item_index, + status='pending', + failure_reason=assignment.failure_reason, + registration_task_uuid=task_uuid, + selected_email_service_id=assignment.service_id, + upload_status='pending' if request.upload_enabled and not assignment.failure_reason else ('not_enabled' if not request.upload_enabled else 'skipped'), + ) + + try: + db.commit() + except IntegrityError: + db.rollback() + if request.idempotency_key: + existing = external_batch_crud.get_batch_by_idempotency_key(db, request.idempotency_key) + if existing: + return self._mark_idempotent_replay(self._detach_batch(db, existing)) + raise + + return self._detach_batch(db, external_batch_crud.get_batch_by_uuid(db, batch_uuid)) + + @staticmethod + def _classify_failure_category(failure_reason: Optional[str]) -> str: + reason = (failure_reason or '').strip().lower() + if not reason: + return 'transient' + if reason in BUSINESS_FAILURE_REASONS: + return 'business' + if reason in TRANSIENT_FAILURE_REASONS: + return 'transient' + if reason.startswith(CONFIG_FAILURE_PREFIXES): + return 'config' + if reason.startswith('upload service ') and ( + ' not found' in reason + or ' is disabled' in reason + or ' does not belong to provider ' in reason + ): + return 'config' + if reason.startswith('email service ') and ( + ' not found' in reason + or ' is disabled' in reason + or ' does not belong to type ' in reason + ): + return 'config' + return 'transient' + + @staticmethod + def _select_failure_reason(batch, items) -> Optional[str]: + if batch.failure_reason: + return batch.failure_reason + for item in items: + if item.failure_reason: + return item.failure_reason + for item in items: + if item.upload_status == 'failed' and item.upload_error: + return item.upload_error + return None + + def recompute_summary(self, db: Session, batch_uuid: str): + batch = external_batch_crud.get_batch_by_uuid(db, batch_uuid) + if not batch: + raise ValueError(f'batch not found: {batch_uuid}') + + items = external_batch_crud.list_batch_items(db, batch_uuid) + completed_count = sum(1 for item in items if item.status in TERMINAL_ITEM_STATUSES) + success_count = sum(1 for item in items if item.status == 'completed') + failed_count = sum(1 for item in items if item.status == 'failed') + cancelled_count = sum(1 for item in items if item.status == 'cancelled') + upload_success_count = sum(1 for item in items if item.upload_status == 'success') + upload_failed_count = sum(1 for item in items if item.upload_status == 'failed') + + recent_errors = [] + for item in items: + if item.failure_reason: + recent_errors.append({'item_index': item.item_index, 'reason': item.failure_reason}) + elif item.upload_status == 'failed' and item.upload_error: + recent_errors.append({'item_index': item.item_index, 'reason': item.upload_error}) + + if completed_count < batch.requested_count: + status = 'running' if batch.started_at else 'pending' + completed_at = None + elif batch.cancel_requested or cancelled_count: + status = 'cancelled' + completed_at = datetime.utcnow() + elif batch.failure_reason == 'service_restarted': + status = 'failed' + completed_at = datetime.utcnow() + elif success_count == batch.requested_count and failed_count == 0: + status = 'completed' + completed_at = datetime.utcnow() + elif success_count > 0: + status = 'completed_partial' + completed_at = datetime.utcnow() + else: + status = 'failed' + completed_at = datetime.utcnow() + + failure_category = None + if status == 'failed': + failure_category = self._classify_failure_category(self._select_failure_reason(batch, items)) + + updated = external_batch_crud.update_batch( + db, + batch_uuid, + status=status, + completed_count=completed_count, + success_count=success_count, + failed_count=failed_count, + upload_success_count=upload_success_count, + upload_failed_count=upload_failed_count, + recent_errors=recent_errors[-20:], + completed_at=completed_at, + failure_category=failure_category, + ) + db.commit() + return self._detach_batch(db, updated) + + def ensure_failure_category(self, db: Session, batch): + if batch is None: + return None + if batch.status != 'failed' or batch.failure_category: + return batch + items = external_batch_crud.list_batch_items(db, batch.batch_uuid) + updated = external_batch_crud.update_batch( + db, + batch.batch_uuid, + failure_category=self._classify_failure_category(self._select_failure_reason(batch, items)), + ) + db.commit() + return self._detach_batch(db, updated) + + async def run_batch(self, batch_uuid: str): + from ...database.session import get_db + from ...web.routes.registration import run_registration_task + + with get_db() as db: + batch = external_batch_crud.update_batch(db, batch_uuid, status='running', started_at=datetime.utcnow()) + db.commit() + runtime_snapshot = (batch.runtime_snapshot or {}).copy() + upload_target_data = runtime_snapshot.get('upload_target') + request_payload = batch.request_payload or {} + mode = request_payload.get('mode', 'pipeline') + concurrency = int(request_payload.get('concurrency', 1) or 1) + items = external_batch_crud.list_batch_items(db, batch_uuid) + email_service_type = batch.email_service_type + + upload_target = UploadTarget(**upload_target_data) if upload_target_data else None + + async def execute_item(item_id: int): + with get_db() as db: + item = db.query(ExternalRegistrationBatchItem).filter(ExternalRegistrationBatchItem.id == item_id).first() + batch = external_batch_crud.get_batch_by_uuid(db, batch_uuid) + if not item or not batch: + return + if batch.cancel_requested: + external_batch_crud.update_batch_item(db, item.id, status='cancelled', upload_status='skipped', completed_at=datetime.utcnow()) + task = crud.get_registration_task_by_uuid(db, item.registration_task_uuid) + if task: + task.status = 'cancelled' + task.completed_at = datetime.utcnow() + db.commit() + self.recompute_summary(db, batch_uuid) + return + if item.failure_reason: + external_batch_crud.update_batch_item(db, item.id, status='failed', completed_at=datetime.utcnow()) + task = crud.get_registration_task_by_uuid(db, item.registration_task_uuid) + if task: + task.status = 'failed' + task.error_message = item.failure_reason + task.completed_at = datetime.utcnow() + db.commit() + self.recompute_summary(db, batch_uuid) + return + + external_batch_crud.update_batch_item(db, item.id, status='running', started_at=datetime.utcnow()) + selected_service_id = item.selected_email_service_id + db.commit() + + await run_registration_task( + item.registration_task_uuid, + email_service_type, + None, + None, + selected_service_id, + '[external]', + batch_uuid, + False, + [], + False, + [], + False, + [], + ) + + with get_db() as db: + item = db.query(ExternalRegistrationBatchItem).filter(ExternalRegistrationBatchItem.id == item_id).first() + task = crud.get_registration_task_by_uuid(db, item.registration_task_uuid) + item_status = 'completed' if task and task.status == 'completed' else ('cancelled' if task and task.status == 'cancelled' else 'failed') + update_kwargs = {'status': item_status, 'completed_at': datetime.utcnow()} + if item_status == 'failed': + update_kwargs['failure_reason'] = (task.error_message if task and task.error_message else 'registration_failed') + external_batch_crud.update_batch_item(db, item.id, **update_kwargs) + + if item_status == 'completed' and upload_target is not None: + email = task.result.get('email') if task and task.result else None + account = None + if email: + account = db.query(Account).filter(Account.email == email).order_by(Account.id.desc()).first() + if account is None and task and task.result: + account = db.query(Account).filter(Account.account_id == task.result.get('account_id')).order_by(Account.id.desc()).first() + upload_result = upload_registered_account(account, upload_target) + external_batch_crud.update_batch_item( + db, + item.id, + upload_status='success' if upload_result.success else 'failed', + upload_error=None if upload_result.success else upload_result.message, + ) + db.commit() + self.recompute_summary(db, batch_uuid) + + if mode == 'parallel': + semaphore = asyncio.Semaphore(max(1, concurrency)) + + async def guarded(item_id: int): + async with semaphore: + await execute_item(item_id) + + await asyncio.gather(*(guarded(item.id) for item in items)) + else: + for item in items: + await execute_item(item.id) + + with get_db() as db: + batch = external_batch_crud.get_batch_by_uuid(db, batch_uuid) + if batch and batch.cancel_requested: + external_batch_crud.update_batch(db, batch_uuid, status='cancelled', completed_at=datetime.utcnow()) + db.commit() + self.recompute_summary(db, batch_uuid) + + def request_cancel(self, db: Session, batch_uuid: str): + batch = external_batch_crud.get_batch_by_uuid(db, batch_uuid) + if not batch: + return None + if batch.status not in {'pending', 'running'}: + raise ValueError('batch is already finished') + updated = external_batch_crud.update_batch(db, batch_uuid, cancel_requested=True) + db.commit() + return self._detach_batch(db, updated) + + +def _serialize_batch(batch) -> dict: + return { + 'batch_uuid': batch.batch_uuid, + 'status': batch.status, + 'requested_count': batch.requested_count, + 'completed_count': batch.completed_count, + 'success_count': batch.success_count, + 'failed_count': batch.failed_count, + 'upload_success_count': batch.upload_success_count, + 'upload_failed_count': batch.upload_failed_count, + 'failure_reason': batch.failure_reason, + 'failure_category': batch.failure_category, + 'created_at': batch.created_at.isoformat() if batch.created_at else None, + 'started_at': batch.started_at.isoformat() if batch.started_at else None, + 'completed_at': batch.completed_at.isoformat() if batch.completed_at else None, + 'recent_errors': batch.recent_errors or [], + } + + +def _request_from_payload(payload: dict) -> ExternalBatchCreateRequest: + email = payload.get('email') or {} + upload = payload.get('upload') or {} + execution = payload.get('execution') or {} + return ExternalBatchCreateRequest( + count=payload['count'], + idempotency_key=payload.get('idempotency_key'), + email_type=email.get('type'), + email_service_id=email.get('service_id'), + upload_enabled=bool(upload.get('enabled')), + upload_provider=upload.get('provider'), + upload_service_id=upload.get('service_id'), + mode=execution.get('mode', 'pipeline'), + concurrency=execution.get('concurrency', 1), + interval_min=execution.get('interval_min', 5), + interval_max=execution.get('interval_max', 30), + ) + + +def create_external_registration_batch(payload: dict, background_tasks=None) -> dict: + from ...database.session import get_db + + request = _request_from_payload(payload) + service = ExternalBatchService() + is_replay = False + with get_db() as db: + existing = external_batch_crud.get_batch_by_idempotency_key(db, request.idempotency_key) if request.idempotency_key else None + if existing is not None: + existing = service.ensure_failure_category(db, existing) + response = _serialize_batch(existing) + response['idempotent_replay'] = True + return response + batch = service.create_batch(db, request) + is_replay = bool(getattr(batch, '_idempotent_replay', False)) + if is_replay: + batch = service.ensure_failure_category(db, batch) + if background_tasks is not None: + background_tasks.add_task(service.run_batch, batch.batch_uuid) + response = _serialize_batch(batch) + response['idempotent_replay'] = is_replay + return response + + +def get_external_registration_batch_status(batch_uuid: str) -> dict: + from ...database.session import get_db + + service = ExternalBatchService() + with get_db() as db: + batch = external_batch_crud.get_batch_by_uuid(db, batch_uuid) + if batch is None: + raise ValueError('batch_not_found') + batch = service.ensure_failure_category(db, batch) + return _serialize_batch(batch) + + +def cancel_external_registration_batch(batch_uuid: str) -> dict: + from ...database.session import get_db + + service = ExternalBatchService() + with get_db() as db: + batch = service.request_cancel(db, batch_uuid) + if batch is None: + raise ValueError('batch_not_found') + return _serialize_batch(batch) diff --git a/src/core/fetch_logs.py b/src/core/fetch_logs.py new file mode 100644 index 0000000000000000000000000000000000000000..eee166efdfdbd160621acb0b68402a7b23f615a9 --- /dev/null +++ b/src/core/fetch_logs.py @@ -0,0 +1,33 @@ +import paramiko +import sys + +def fetch_logs(ip, password, lines=1000): + print(f"\n--- Fetching logs from {ip} ---") + try: + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect(ip, username='root', password=password, timeout=10) + + # Get the current time on the server to provide context + stdin, stdout, stderr = ssh.exec_command('date') + print(f"Server Time: {stdout.read().decode('utf-8').strip()}") + + # Fetch docker logs + cmd = f"docker logs --tail {lines} codex-console-webui-1 2>&1 | grep -E 'WARN|ERROR|注册|about-you|login_flow_failed|cookie|token|callback'" + stdin, stdout, stderr = ssh.exec_command(cmd) + out = stdout.read().decode('utf-8').strip() + + if out: + filename = f"logs_{ip}.txt" + with open(filename, "w", encoding="utf-8") as f: + f.write(out) + print(f"Saved {len(out.splitlines())} lines to {filename}") + else: + print("No matching logs found.") + + ssh.close() + except Exception as e: + print(f"Error checking {ip}: {e}") + +fetch_logs('198.46.152.138', 's3IEn8ClaxA4H99Zv7', 5000) +fetch_logs('23.140.140.70', '33nKDS80L6ipmedJ3y0j', 5000) diff --git a/src/core/http_client.py b/src/core/http_client.py new file mode 100644 index 0000000000000000000000000000000000000000..60a4d8625ab8801dd3f307b6b1df0a1ba3858499 --- /dev/null +++ b/src/core/http_client.py @@ -0,0 +1,436 @@ +""" +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 +from .openai.browser_profile import BrowserProfile, get_random_profile + + +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, + profile: Optional[BrowserProfile] = None, + ): + """ + 初始化 OpenAI HTTP 客户端 + + Args: + proxy_url: 代理 URL + config: 请求配置 + profile: 浏览器指纹 Profile,不传则随机生成 + """ + super().__init__(proxy_url, config) + + # 浏览器指纹 Profile + self.profile = profile or get_random_profile() + + # 用 Profile 的 impersonate 覆盖默认值 + self.config.impersonate = self.profile.impersonate + + # OpenAI 特定的默认配置 + if config is None: + self.config.timeout = 30 + self.config.max_retries = 3 + + # 默认请求头(从 Profile 取值,保证一致性) + self.default_headers = { + "User-Agent": self.profile.user_agent, + "Accept": "application/json", + "Accept-Language": "en-US,en;q=0.9", + "Accept-Encoding": "gzip, deflate, br", + "Connection": "keep-alive", + "sec-ch-ua": self.profile.sec_ch_ua, + "sec-ch-ua-mobile": self.profile.sec_ch_ua_mobile, + "sec-ch-ua-platform": self.profile.sec_ch_ua_platform, + "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.profile.user_agent) + sen_req_body = json.dumps({ + "p": pow_token, + "id": did, + "flow": "authorize_continue", + }, separators=(",", ":")) + + response = self.post( + OPENAI_API_ENDPOINTS["sentinel"], + headers=self.profile.sentinel_headers(), + 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 0000000000000000000000000000000000000000..3a2da6d2cfc4e7c26987e364485a6a967a74ef2d --- /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_profile.py b/src/core/openai/browser_profile.py new file mode 100644 index 0000000000000000000000000000000000000000..630117f2e4736c967f8d684c7a128c5ced02bc4e --- /dev/null +++ b/src/core/openai/browser_profile.py @@ -0,0 +1,146 @@ +""" +浏览器指纹 Profile 工厂 + +提供多版本 Chrome 浏览器指纹配置,确保 TLS impersonate、User-Agent、 +sec-ch-ua 三者版本一致,消除反检测信号。 +""" + +import random +from dataclasses import dataclass, field +from typing import Dict, Optional + + +# Chrome 版本指纹池 — impersonate 标识必须与 curl_cffi 支持的版本匹配 +_CHROME_PROFILES = [ + { + "major": 131, "impersonate": "chrome131", + "build": 6778, "patch_range": (69, 205), + "sec_ch_ua": '"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"', + }, + { + "major": 133, "impersonate": "chrome133a", + "build": 6943, "patch_range": (33, 153), + "sec_ch_ua": '"Not(A:Brand";v="99", "Google Chrome";v="133", "Chromium";v="133"', + }, + { + "major": 136, "impersonate": "chrome136", + "build": 7103, "patch_range": (48, 175), + "sec_ch_ua": '"Chromium";v="136", "Google Chrome";v="136", "Not.A/Brand";v="99"', + }, +] + + +@dataclass(frozen=True) +class BrowserProfile: + """单次会话绑定的浏览器指纹 Profile""" + + impersonate: str + major: int + full_version: str + user_agent: str + sec_ch_ua: str + sec_ch_ua_mobile: str = "?0" + sec_ch_ua_platform: str = '"Windows"' + + # ---- 通用 sec-ch-ua 三件套 ---- + + def _ch_ua_headers(self) -> Dict[str, str]: + """返回 sec-ch-ua 三件套,Chrome 每个 HTTPS 请求默认发送。""" + return { + "sec-ch-ua": self.sec_ch_ua, + "sec-ch-ua-mobile": self.sec_ch_ua_mobile, + "sec-ch-ua-platform": self.sec_ch_ua_platform, + } + + # ---- 页面导航请求 headers(GET HTML 页面)---- + + def navigation_headers(self, referer: Optional[str] = None) -> Dict[str, str]: + """ + 浏览器导航到页面时的 headers(如 GET auth.openai.com/oauth/authorize)。 + 对应 Sec-Fetch-Mode: navigate。 + """ + headers: Dict[str, str] = { + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8", + "accept-language": "en-US,en;q=0.9", + "upgrade-insecure-requests": "1", + "sec-fetch-dest": "document", + "sec-fetch-mode": "navigate", + "sec-fetch-user": "?1", + "user-agent": self.user_agent, + } + headers.update(self._ch_ua_headers()) + + if referer: + headers["referer"] = referer + headers["sec-fetch-site"] = "cross-site" + else: + headers["sec-fetch-site"] = "none" + + return headers + + # ---- JSON API 请求 headers(POST auth.openai.com/api/*)---- + + def json_api_headers( + self, + referer: str, + origin: str = "https://auth.openai.com", + oai_did: Optional[str] = None, + ) -> Dict[str, str]: + """ + JSON API 请求的统一 headers(如 signup、password_verify、validate_otp、create_account)。 + 对应 Sec-Fetch-Mode: cors。 + """ + headers: Dict[str, str] = { + "accept": "application/json", + "content-type": "application/json", + "origin": origin, + "referer": referer, + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", + } + headers.update(self._ch_ua_headers()) + + if oai_did: + headers["oai-device-id"] = oai_did + + return headers + + # ---- Sentinel 请求 headers ---- + + def sentinel_headers(self) -> Dict[str, str]: + """ + Sentinel PoW 请求的 headers(POST sentinel.openai.com)。 + 对应跨域 CORS 请求。 + """ + headers: Dict[str, str] = { + "origin": "https://sentinel.openai.com", + "referer": "https://sentinel.openai.com/backend-api/sentinel/frame.html?sv=20260219f9f6", + "content-type": "text/plain;charset=UTF-8", + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", + } + headers.update(self._ch_ua_headers()) + return headers + + +def get_random_profile() -> BrowserProfile: + """从 Chrome 版本池中随机选择一个 Profile,整个注册会话内保持一致。""" + cfg = random.choice(_CHROME_PROFILES) + major = cfg["major"] + build = cfg["build"] + patch = random.randint(*cfg["patch_range"]) + full_ver = f"{major}.0.{build}.{patch}" + ua = ( + f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + f"AppleWebKit/537.36 (KHTML, like Gecko) " + f"Chrome/{full_ver} Safari/537.36" + ) + return BrowserProfile( + impersonate=cfg["impersonate"], + major=major, + full_version=full_ver, + user_agent=ua, + sec_ch_ua=cfg["sec_ch_ua"], + ) diff --git a/src/core/openai/oauth.py b/src/core/openai/oauth.py new file mode 100644 index 0000000000000000000000000000000000000000..de64f935d94a138b7b8fe52ce9fc6fac3ca20f5d --- /dev/null +++ b/src/core/openai/oauth.py @@ -0,0 +1,410 @@ +""" +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 generate_oauth_url_no_prompt( + *, + redirect_uri: str = OAUTH_REDIRECT_URI, + scope: str = OAUTH_SCOPE, + client_id: str = OAUTH_CLIENT_ID, + login_hint: Optional[str] = None +) -> OAuthStart: + """ + 生成 OAuth 授权 URL(不带 prompt=login) + + 用于 Outlook 重登后已持有 auth cookie 的场景, + 让 OpenAI 直接复用当前登录态而不强制重新登录。 + """ + 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", + "id_token_add_organizations": "true", + "codex_cli_simplified_flow": "true", + "prompt": "none", + } + if login_hint: + params["login_hint"] = login_hint + auth_url = f"{OAUTH_AUTH_URL}?{urllib.parse.urlencode(params)}" + return OAuthStart( + auth_url=auth_url, + state=state, + code_verifier=code_verifier, + redirect_uri=redirect_uri, + ) + + +def submit_callback_url( + *, + callback_url: str, + expected_state: str, + code_verifier: str, + redirect_uri: str = OAUTH_REDIRECT_URI, + client_id: str = OAUTH_CLIENT_ID, + token_url: str = OAUTH_TOKEN_URL, + proxy_url: Optional[str] = None +) -> str: + """ + 处理 OAuth 回调 URL,获取访问令牌 + + Args: + callback_url: 回调 URL + expected_state: 预期的 state 值 + code_verifier: PKCE code_verifier + redirect_uri: 回调地址 + client_id: OpenAI Client ID + token_url: Token 交换地址 + proxy_url: 代理 URL + + Returns: + 包含访问令牌等信息的 JSON 字符串 + + Raises: + RuntimeError: OAuth 错误 + ValueError: 缺少必要参数或 state 不匹配 + """ + cb = _parse_callback_url(callback_url) + if cb["error"]: + desc = cb["error_description"] + raise RuntimeError(f"oauth error: {cb['error']}: {desc}".strip()) + + if not cb["code"]: + raise ValueError("callback url missing ?code=") + if not cb["state"]: + raise ValueError("callback url missing ?state=") + if cb["state"] != expected_state: + raise ValueError("state mismatch") + + token_resp = _post_form( + token_url, + { + "grant_type": "authorization_code", + "client_id": client_id, + "code": cb["code"], + "redirect_uri": redirect_uri, + "code_verifier": code_verifier, + }, + proxy_url=proxy_url + ) + + access_token = (token_resp.get("access_token") or "").strip() + refresh_token = (token_resp.get("refresh_token") or "").strip() + id_token = (token_resp.get("id_token") or "").strip() + expires_in = _to_int(token_resp.get("expires_in")) + + claims = _jwt_claims_no_verify(id_token) + email = str(claims.get("email") or "").strip() + auth_claims = claims.get("https://api.openai.com/auth") or {} + account_id = str(auth_claims.get("chatgpt_account_id") or "").strip() + + now = int(time.time()) + expired_rfc3339 = time.strftime( + "%Y-%m-%dT%H:%M:%SZ", time.gmtime(now + max(expires_in, 0)) + ) + now_rfc3339 = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now)) + + config = { + "id_token": id_token, + "access_token": access_token, + "refresh_token": refresh_token, + "account_id": account_id, + "last_refresh": now_rfc3339, + "email": email, + "type": "codex", + "expired": expired_rfc3339, + } + + return json.dumps(config, ensure_ascii=False, separators=(",", ":")) + + +class OAuthManager: + """OAuth 管理器""" + + def __init__( + self, + client_id: str = OAUTH_CLIENT_ID, + auth_url: str = OAUTH_AUTH_URL, + token_url: str = OAUTH_TOKEN_URL, + redirect_uri: str = OAUTH_REDIRECT_URI, + scope: str = OAUTH_SCOPE, + proxy_url: Optional[str] = None + ): + self.client_id = client_id + self.auth_url = auth_url + self.token_url = token_url + self.redirect_uri = redirect_uri + self.scope = scope + self.proxy_url = proxy_url + + def start_oauth(self) -> OAuthStart: + """开始 OAuth 流程""" + return generate_oauth_url( + redirect_uri=self.redirect_uri, + scope=self.scope, + client_id=self.client_id + ) + + def handle_callback( + self, + callback_url: str, + expected_state: str, + code_verifier: str + ) -> Dict[str, Any]: + """处理 OAuth 回调""" + result_json = submit_callback_url( + callback_url=callback_url, + expected_state=expected_state, + code_verifier=code_verifier, + redirect_uri=self.redirect_uri, + client_id=self.client_id, + token_url=self.token_url, + proxy_url=self.proxy_url + ) + return json.loads(result_json) + + def extract_account_info(self, id_token: str) -> Dict[str, Any]: + """从 ID Token 中提取账户信息""" + claims = _jwt_claims_no_verify(id_token) + email = str(claims.get("email") or "").strip() + auth_claims = claims.get("https://api.openai.com/auth") or {} + account_id = str(auth_claims.get("chatgpt_account_id") or "").strip() + + return { + "email": email, + "account_id": account_id, + "claims": claims + } \ No newline at end of file diff --git a/src/core/openai/payment.py b/src/core/openai/payment.py new file mode 100644 index 0000000000000000000000000000000000000000..d584a5856ce6f529bc5113508863f14f27fd946c --- /dev/null +++ b/src/core/openai/payment.py @@ -0,0 +1,261 @@ +""" +支付核心逻辑 — 生成 Plus/Team 支付链接、无痕打开浏览器、检测订阅状态 +""" + +import logging +import subprocess +import sys +from typing import Optional + +from curl_cffi import requests as cffi_requests + +from ...database.models import Account + +logger = logging.getLogger(__name__) + +PAYMENT_CHECKOUT_URL = "https://chatgpt.com/backend-api/payments/checkout" +TEAM_CHECKOUT_BASE_URL = "https://chatgpt.com/checkout/openai_llc/" + + +def _build_proxies(proxy: Optional[str]) -> Optional[dict]: + if proxy: + return {"http": proxy, "https": proxy} + return None + + +_COUNTRY_CURRENCY_MAP = { + "SG": "SGD", + "US": "USD", + "TR": "TRY", + "JP": "JPY", + "HK": "HKD", + "GB": "GBP", + "EU": "EUR", + "AU": "AUD", + "CA": "CAD", + "IN": "INR", + "BR": "BRL", + "MX": "MXN", +} + + +def _extract_oai_did(cookies_str: str) -> Optional[str]: + """从 cookie 字符串中提取 oai-device-id""" + for part in cookies_str.split(";"): + part = part.strip() + if part.startswith("oai-did="): + return part[len("oai-did="):].strip() + return None + + +def _parse_cookie_str(cookies_str: str, domain: str) -> list: + """将 'key=val; key2=val2' 格式解析为 Playwright cookie 列表""" + cookies = [] + for part in cookies_str.split(";"): + part = part.strip() + if "=" not in part: + continue + name, _, value = part.partition("=") + cookies.append({ + "name": name.strip(), + "value": value.strip(), + "domain": domain, + "path": "/", + }) + return cookies + + +def _open_url_system_browser(url: str) -> bool: + """回退方案:调用系统浏览器以无痕模式打开""" + platform = sys.platform + try: + if platform == "win32": + for browser, flag in [("chrome", "--incognito"), ("msedge", "--inprivate")]: + try: + subprocess.Popen(f'start {browser} {flag} "{url}"', shell=True) + return True + except Exception: + continue + elif platform == "darwin": + subprocess.Popen(["open", "-a", "Google Chrome", "--args", "--incognito", url]) + return True + else: + for binary in ["google-chrome", "chromium-browser", "chromium"]: + try: + subprocess.Popen([binary, "--incognito", url]) + return True + except FileNotFoundError: + continue + except Exception as e: + logger.warning(f"系统浏览器无痕打开失败: {e}") + return False + + +def generate_plus_link( + account: Account, + proxy: Optional[str] = None, + country: str = "SG", +) -> str: + """生成 Plus 支付链接(后端携带账号 cookie 发请求)""" + if not account.access_token: + raise ValueError("账号缺少 access_token") + + currency = _COUNTRY_CURRENCY_MAP.get(country, "USD") + headers = { + "Authorization": f"Bearer {account.access_token}", + "Content-Type": "application/json", + "oai-language": "zh-CN", + } + if account.cookies: + headers["cookie"] = account.cookies + oai_did = _extract_oai_did(account.cookies) + if oai_did: + headers["oai-device-id"] = oai_did + + payload = { + "plan_name": "chatgptplusplan", + "billing_details": {"country": country, "currency": currency}, + "promo_campaign": { + "promo_campaign_id": "plus-1-month-free", + "is_coupon_from_query_param": False, + }, + "checkout_ui_mode": "custom", + } + + resp = cffi_requests.post( + PAYMENT_CHECKOUT_URL, + headers=headers, + json=payload, + proxies=_build_proxies(proxy), + timeout=30, + impersonate="chrome110", + ) + resp.raise_for_status() + data = resp.json() + if "checkout_session_id" in data: + return TEAM_CHECKOUT_BASE_URL + data["checkout_session_id"] + raise ValueError(data.get("detail", "API 未返回 checkout_session_id")) + + +def generate_team_link( + account: Account, + workspace_name: str = "MyTeam", + price_interval: str = "month", + seat_quantity: int = 5, + proxy: Optional[str] = None, + country: str = "SG", +) -> str: + """生成 Team 支付链接(后端携带账号 cookie 发请求)""" + if not account.access_token: + raise ValueError("账号缺少 access_token") + + currency = _COUNTRY_CURRENCY_MAP.get(country, "USD") + headers = { + "Authorization": f"Bearer {account.access_token}", + "Content-Type": "application/json", + "oai-language": "zh-CN", + } + if account.cookies: + headers["cookie"] = account.cookies + oai_did = _extract_oai_did(account.cookies) + if oai_did: + headers["oai-device-id"] = oai_did + + payload = { + "plan_name": "chatgptteamplan", + "team_plan_data": { + "workspace_name": workspace_name, + "price_interval": price_interval, + "seat_quantity": seat_quantity, + }, + "billing_details": {"country": country, "currency": currency}, + "promo_campaign": { + "promo_campaign_id": "team-1-month-free", + "is_coupon_from_query_param": True, + }, + "cancel_url": "https://chatgpt.com/#pricing", + "checkout_ui_mode": "custom", + } + + resp = cffi_requests.post( + PAYMENT_CHECKOUT_URL, + headers=headers, + json=payload, + proxies=_build_proxies(proxy), + timeout=30, + impersonate="chrome110", + ) + resp.raise_for_status() + data = resp.json() + if "checkout_session_id" in data: + return TEAM_CHECKOUT_BASE_URL + data["checkout_session_id"] + raise ValueError(data.get("detail", "API 未返回 checkout_session_id")) + + +def open_url_incognito(url: str, cookies_str: Optional[str] = None) -> bool: + """用 Playwright 以无痕模式打开 URL,可注入 cookie""" + import threading + try: + from playwright.sync_api import sync_playwright + except ImportError: + logger.warning("playwright 未安装,回退到系统浏览器") + return _open_url_system_browser(url) + + def _launch(): + try: + with sync_playwright() as p: + browser = p.chromium.launch(headless=False, args=["--incognito"]) + ctx = browser.new_context() + if cookies_str: + ctx.add_cookies(_parse_cookie_str(cookies_str, "chatgpt.com")) + page = ctx.new_page() + page.goto(url) + # 保持窗口打开直到用户关闭 + page.wait_for_timeout(300_000) # 最多等待 5 分钟 + except Exception as e: + logger.warning(f"Playwright 无痕打开失败: {e}") + + threading.Thread(target=_launch, daemon=True).start() + return True + + +def check_subscription_status(account: Account, proxy: Optional[str] = None) -> str: + """ + 检测账号当前订阅状态。 + + Returns: + 'free' / 'plus' / 'team' + """ + if not account.access_token: + raise ValueError("账号缺少 access_token") + + headers = { + "Authorization": f"Bearer {account.access_token}", + "Content-Type": "application/json", + } + + resp = cffi_requests.get( + "https://chatgpt.com/backend-api/me", + headers=headers, + proxies=_build_proxies(proxy), + timeout=20, + impersonate="chrome110", + ) + resp.raise_for_status() + data = resp.json() + + # 解析订阅类型 + plan = data.get("plan_type") or "" + if "team" in plan.lower(): + return "team" + if "plus" in plan.lower(): + return "plus" + + # 尝试从 orgs 或 workspace 信息判断 + orgs = data.get("orgs", {}).get("data", []) + for org in orgs: + settings_ = org.get("settings", {}) + if settings_.get("workspace_plan_type") in ("team", "enterprise"): + return "team" + + return "free" diff --git a/src/core/openai/sentinel.py b/src/core/openai/sentinel.py new file mode 100644 index 0000000000000000000000000000000000000000..b2dff1b0861d9fe8ec05c836132c4374496fe838 --- /dev/null +++ b/src/core/openai/sentinel.py @@ -0,0 +1,108 @@ +"""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 = ("1920x1080", "2560x1440", "1366x768", "1536x864", "1440x900") +_LANGUAGE_SIGNATURE = "en-US,en" +_NAVIGATOR_KEYS = ( + "vendorSub", "productSub", "vendor", "maxTouchPoints", + "scheduling", "userActivation", "doNotTrack", "geolocation", + "connection", "plugins", "mimeTypes", "pdfViewerEnabled", + "webkitTemporaryStorage", "webkitPersistentStorage", + "hardwareConcurrency", "cookieEnabled", "credentials", + "mediaDevices", "permissions", "locks", "ink", +) +_WINDOW_KEYS = ( + "Object", "Function", "Array", "Number", "parseFloat", + "undefined", "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.utc) + return browser_now.strftime("%a %b %d %Y %H:%M:%S") + " GMT+0000 (Coordinated Universal 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 0000000000000000000000000000000000000000..394c56e22cbf300c9e5a1cd7d1b3367e1941aa90 --- /dev/null +++ b/src/core/openai/token_refresh.py @@ -0,0 +1,332 @@ +""" +Token 刷新模块 +支持 Session Token 和 OAuth Refresh Token 两种刷新方式 +""" + +import logging +import json +import time +from typing import Optional, Dict, Any, Tuple +from dataclasses import dataclass +from datetime import datetime, timedelta + +from curl_cffi import requests as cffi_requests + +from ...config.settings import get_settings +from ...database.session import get_db +from ...database import crud +from ...database.models import Account + +logger = logging.getLogger(__name__) + + +@dataclass +class TokenRefreshResult: + """Token 刷新结果""" + success: bool + access_token: str = "" + refresh_token: str = "" + expires_at: Optional[datetime] = None + error_message: str = "" + + +class TokenRefreshManager: + """ + Token 刷新管理器 + 支持两种刷新方式: + 1. Session Token 刷新(优先) + 2. OAuth Refresh Token 刷新 + """ + + # OpenAI OAuth 端点 + SESSION_URL = "https://chatgpt.com/api/auth/session" + TOKEN_URL = "https://auth.openai.com/oauth/token" + + def __init__(self, proxy_url: Optional[str] = None): + """ + 初始化 Token 刷新管理器 + + Args: + proxy_url: 代理 URL + """ + self.proxy_url = proxy_url + self.settings = get_settings() + + def _create_session(self) -> cffi_requests.Session: + """创建 HTTP 会话""" + session = cffi_requests.Session(impersonate="chrome120", proxy=self.proxy_url) + return session + + def refresh_by_session_token(self, session_token: str) -> TokenRefreshResult: + """ + 使用 Session Token 刷新 + + Args: + session_token: 会话令牌 + + Returns: + TokenRefreshResult: 刷新结果 + """ + result = TokenRefreshResult(success=False) + + try: + session = self._create_session() + + # 设置会话 Cookie + session.cookies.set( + "__Secure-next-auth.session-token", + session_token, + domain=".chatgpt.com", + path="/" + ) + + # 请求会话端点 + response = session.get( + self.SESSION_URL, + headers={ + "accept": "application/json", + "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + }, + timeout=30 + ) + + if response.status_code != 200: + result.error_message = f"Session token 刷新失败: HTTP {response.status_code}" + logger.warning(result.error_message) + return result + + data = response.json() + + # 提取 access_token + access_token = data.get("accessToken") + if not access_token: + result.error_message = "Session token 刷新失败: 未找到 accessToken" + logger.warning(result.error_message) + return result + + # 提取过期时间 + expires_at = None + expires_str = data.get("expires") + if expires_str: + try: + expires_at = datetime.fromisoformat(expires_str.replace("Z", "+00:00")) + except: + pass + + result.success = True + result.access_token = access_token + result.expires_at = expires_at + + logger.info(f"Session token 刷新成功,过期时间: {expires_at}") + return result + + except Exception as e: + result.error_message = f"Session token 刷新异常: {str(e)}" + logger.error(result.error_message) + return result + + def refresh_by_oauth_token( + self, + refresh_token: str, + client_id: Optional[str] = None + ) -> TokenRefreshResult: + """ + 使用 OAuth Refresh Token 刷新 + + Args: + refresh_token: OAuth 刷新令牌 + client_id: OAuth Client ID + + Returns: + TokenRefreshResult: 刷新结果 + """ + result = TokenRefreshResult(success=False) + + try: + session = self._create_session() + + # 使用配置的 client_id 或默认值 + client_id = client_id or self.settings.openai_client_id + + # 构建请求体 + token_data = { + "client_id": client_id, + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "redirect_uri": self.settings.openai_redirect_uri + } + + response = session.post( + self.TOKEN_URL, + headers={ + "content-type": "application/x-www-form-urlencoded", + "accept": "application/json" + }, + data=token_data, + timeout=30 + ) + + if response.status_code != 200: + result.error_message = 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 刷新") + + # 尝试 OAuth Refresh Token + if account.refresh_token: + logger.info(f"尝试使用 OAuth Refresh Token 刷新账号 {account.email}") + result = self.refresh_by_oauth_token( + refresh_token=account.refresh_token, + client_id=account.client_id + ) + return result + + # 无可用刷新方式 + return TokenRefreshResult( + success=False, + error_message="账号没有可用的刷新方式(缺少 session_token 和 refresh_token)" + ) + + def validate_token(self, access_token: str) -> Tuple[bool, Optional[str]]: + """ + 验证 Access Token 是否有效 + + Args: + access_token: 访问令牌 + + Returns: + Tuple[bool, Optional[str]]: (是否有效, 错误信息) + """ + try: + session = self._create_session() + + # 调用 OpenAI API 验证 token + response = session.get( + "https://chatgpt.com/backend-api/me", + headers={ + "authorization": f"Bearer {access_token}", + "accept": "application/json" + }, + timeout=30 + ) + + if response.status_code == 200: + return True, None + elif response.status_code == 401: + return False, "Token 无效或已过期" + elif response.status_code == 403: + return False, "账号可能被封禁" + else: + return False, f"验证失败: HTTP {response.status_code}" + + except Exception as e: + return False, f"验证异常: {str(e)}" + + +def refresh_account_token(account_id: int, proxy_url: Optional[str] = None) -> TokenRefreshResult: + """ + 刷新指定账号的 Token 并更新数据库 + + Args: + account_id: 账号 ID + proxy_url: 代理 URL + + Returns: + TokenRefreshResult: 刷新结果 + """ + with get_db() as db: + account = crud.get_account_by_id(db, account_id) + if not account: + return TokenRefreshResult(success=False, error_message="账号不存在") + + manager = TokenRefreshManager(proxy_url=proxy_url) + result = manager.refresh_account(account) + + if result.success: + # 更新数据库 + update_data = { + "access_token": result.access_token, + "last_refresh": datetime.utcnow() + } + + if result.refresh_token: + update_data["refresh_token"] = result.refresh_token + + if result.expires_at: + update_data["expires_at"] = result.expires_at + + crud.update_account(db, account_id, **update_data) + + return result + + +def validate_account_token(account_id: int, proxy_url: Optional[str] = None) -> Tuple[bool, Optional[str]]: + """ + 验证指定账号的 Token 是否有效 + + Args: + account_id: 账号 ID + proxy_url: 代理 URL + + Returns: + Tuple[bool, Optional[str]]: (是否有效, 错误信息) + """ + with get_db() as db: + account = crud.get_account_by_id(db, account_id) + if not account: + return False, "账号不存在" + + if not account.access_token: + return False, "账号没有 access_token" + + manager = TokenRefreshManager(proxy_url=proxy_url) + return manager.validate_token(account.access_token) diff --git a/src/core/register.py b/src/core/register.py new file mode 100644 index 0000000000000000000000000000000000000000..739e6af29b10b71ed18942d612e8670803fadf53 --- /dev/null +++ b/src/core/register.py @@ -0,0 +1,4558 @@ +""" +注册流程引擎 +从 main.py 中提取并重构的注册流程 +""" + +import re +import json +import time +import random +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, _decode_jwt_segment, _jwt_claims_no_verify, generate_oauth_url_no_prompt +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, + PASSWORD_SPECIAL, + PASSWORD_MIN_LENGTH, + PASSWORD_MAX_LENGTH, + AccountStatus, + TaskStatus, +) +from ..config.settings import get_settings + + +logger = logging.getLogger(__name__) + + +@dataclass +class RegistrationResult: + """注册结果""" + success: bool + email: str = "" + password: str = "" # 注册密码 + account_id: str = "" + workspace_id: str = "" + access_token: str = "" + refresh_token: str = "" + id_token: str = "" + session_token: str = "" # 会话令牌 + error_message: str = "" + logs: list = None + metadata: dict = None + source: str = "register" # 'register' 或 'login',区分账号来源 + + def to_dict(self) -> Dict[str, Any]: + """转换为字典""" + return { + "success": self.success, + "email": self.email, + "password": self.password, + "account_id": self.account_id, + "workspace_id": self.workspace_id, + "access_token": self.access_token[:20] + "..." if self.access_token else "", + "refresh_token": self.refresh_token[:20] + "..." if self.refresh_token else "", + "id_token": self.id_token[:20] + "..." if self.id_token else "", + "session_token": self.session_token[:20] + "..." if self.session_token else "", + "error_message": self.error_message, + "logs": self.logs or [], + "metadata": self.metadata or {}, + "source": self.source, + } + + +@dataclass +class SignupFormResult: + """提交注册表单的结果""" + success: bool + page_type: str = "" # 响应中的 page.type 字段 + is_existing_account: bool = False # 是否为已注册账号 + response_data: Dict[str, Any] = None # 完整的响应数据 + error_message: str = "" + + +class RegistrationEngine: + """ + 注册引擎 + 负责协调邮箱服务、OAuth 流程和 OpenAI API 调用 + """ + + def __init__( + self, + email_service: BaseEmailService, + proxy_url: Optional[str] = None, + proxy_source: Optional[str] = None, + callback_logger: Optional[Callable[[str], None]] = None, + task_uuid: Optional[str] = None + ): + """ + 初始化注册引擎 + + Args: + email_service: 邮箱服务实例 + proxy_url: 代理 URL + proxy_source: 代理来源('explicit', 'auto', 'direct') + callback_logger: 日志回调函数 + task_uuid: 任务 UUID(用于数据库记录) + """ + self.email_service = email_service + self.proxy_url = proxy_url + self.proxy_source = proxy_source or "direct" + self.callback_logger = callback_logger or (lambda msg: logger.info(msg)) + self.task_uuid = task_uuid + + # 创建 HTTP 客户端 + self.http_client = OpenAIHTTPClient(proxy_url=proxy_url) + + # 创建 OAuth 管理器 + settings = get_settings() + self.oauth_manager = OAuthManager( + client_id=settings.openai_client_id, + auth_url=settings.openai_auth_url, + token_url=settings.openai_token_url, + redirect_uri=settings.openai_redirect_uri, + scope=settings.openai_scope, + proxy_url=proxy_url # 传递代理配置 + ) + + # 状态变量 + self.email: Optional[str] = None + self.password: Optional[str] = None # 注册密码 + self.email_info: Optional[Dict[str, Any]] = None + self.oauth_start: Optional[OAuthStart] = None + self.session: Optional[cffi_requests.Session] = None + self.session_token: Optional[str] = None # 会话令牌 + self.logs: list = [] + self._otp_stage: str = "signup_otp" + self._otp_sent_at: Optional[float] = None # OTP 发送时间戳 + self._is_existing_account: bool = False # 是否为已注册账号(用于自动登录) + self._token_acquisition_requires_login: bool = False # 新注册账号需要二次登录拿 token + self._last_registration_error: Optional[str] = None + self._account_created: bool = False + self._about_you_resume_attempts: int = 0 + self._recovery_account_id: Optional[int] = None + self._recovery_mode: bool = False + self._workspace_context: Dict[str, Any] = {} + self._workspace_resolution_source: Optional[str] = None + self._workspace_resolution_error: Optional[str] = None + self._oauth_resume_source: Optional[str] = None + self._session_bound_reauth_attempted: bool = False + self._session_bound_reauth_otp_cycles: int = 0 + self._last_create_account_error_code: Optional[str] = None + self._last_create_account_error_message: Optional[str] = None + self._last_create_account_user_exists: bool = False + self._about_you_user_exists_without_resume_attempts: int = 0 + + def _log(self, message: str, level: str = "info"): + """记录日志""" + timestamp = datetime.now().strftime("%H:%M:%S") + log_message = f"[{timestamp}] {message}" + + # 添加到日志列表 + self.logs.append(log_message) + + # 调用回调函数 + if self.callback_logger: + self.callback_logger(log_message) + + # 记录到数据库(如果有关联任务) + if self.task_uuid: + try: + with get_db() as db: + crud.append_task_log(db, self.task_uuid, log_message) + except Exception as e: + logger.warning(f"记录任务日志失败: {e}") + + # 根据级别记录到日志系统 + if level == "error": + logger.error(message) + elif level == "warning": + logger.warning(message) + else: + logger.info(message) + + def _generate_password(self, length: int = DEFAULT_PASSWORD_LENGTH) -> str: + """生成随机密码(随机长度,保证含特殊字符)""" + actual_length = random.randint(PASSWORD_MIN_LENGTH, PASSWORD_MAX_LENGTH) + # 保证至少包含 1 小写、1 大写、1 数字、1 特殊字符 + password = [ + secrets.choice(string.ascii_lowercase), + secrets.choice(string.ascii_uppercase), + secrets.choice(string.digits), + secrets.choice(PASSWORD_SPECIAL), + ] + password.extend(secrets.choice(PASSWORD_CHARSET) for _ in range(actual_length - 4)) + secrets.SystemRandom().shuffle(password) + return ''.join(password) + + @staticmethod + def _human_delay(mean: float = 3.0, std: float = 1.0, minimum: float = 1.0) -> None: + """模拟人类操作间隔(正态分布延迟)""" + delay = max(random.gauss(mean, std), minimum) + time.sleep(delay) + + def _check_ip_location(self) -> Tuple[bool, Optional[str]]: + """检查 IP 地理位置""" + try: + return self.http_client.check_ip_location() + except Exception as e: + self._log(f"检查 IP 地理位置失败: {e}", "error") + return False, None + + def _create_email(self) -> bool: + """创建邮箱""" + try: + self._log(f"正在创建 {self.email_service.service_type.value} 邮箱,先给新账号整个收件箱...") + self.email_info = self.email_service.create_email() + + if not self.email_info or "email" not in self.email_info: + self._log("创建邮箱失败: 返回信息不完整", "error") + return False + + self.email = self.email_info["email"] + self._log(f"邮箱已就位,地址新鲜出炉: {self.email}") + return True + + except Exception as e: + self._log(f"创建邮箱失败: {e}", "error") + return False + + def _start_oauth(self) -> bool: + """开始 OAuth 流程""" + try: + self._log("开始 OAuth 授权流程,去门口刷个脸...") + self.oauth_start = self.oauth_manager.start_oauth() + self._log(f"OAuth URL 已备好,通道已经打开: {self.oauth_start.auth_url[:80]}...") + return True + except Exception as e: + self._log(f"生成 OAuth URL 失败: {e}", "error") + return False + + def _init_session(self) -> bool: + """初始化会话""" + try: + self.session = self.http_client.session + return True + except Exception as e: + self._log(f"初始化会话失败: {e}", "error") + return False + + def _warm_entry_flow(self) -> None: + """轻量模拟 chatgpt.com 首页访问,建立真实 cookie jar 和引荐链。""" + try: + self._log("模拟浏览器访问 chatgpt.com 首页...") + self.session.get( + "https://chatgpt.com/", + headers=self.http_client.profile.navigation_headers(), + timeout=15, + allow_redirects=True, + ) + self._human_delay(mean=2.0, std=0.8, minimum=1.0) + self._log("chatgpt.com 首页访问完成,cookie jar 已预热") + except Exception as e: + self._log(f"chatgpt.com 预热失败(不影响注册): {e}", "warning") + + 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, + headers=self.http_client.profile.navigation_headers(), + timeout=20 + ) + did = self.session.cookies.get("oai-did") + + if did: + self._log(f"Device ID: {did}") + return did + + self._log( + f"获取 Device ID 失败: 未返回 oai-did Cookie (HTTP {response.status_code}, 第 {attempt}/{max_attempts} 次)", + "warning" if attempt < max_attempts else "error" + ) + except Exception as e: + self._log( + f"获取 Device ID 失败: {e} (第 {attempt}/{max_attempts} 次)", + "warning" if attempt < max_attempts else "error" + ) + + if attempt < max_attempts: + time.sleep(attempt) + self.http_client.close() + self.session = self.http_client.session + + return None + + def _get_session_user_agent(self) -> Optional[str]: + session_headers = getattr(self.session, "headers", None) or {} + return session_headers.get("User-Agent") or session_headers.get("user-agent") + + def _build_oauth_json_headers(self, referer: str) -> Dict[str, str]: + session_cookies = getattr(self.session, "cookies", None) or {} + device_id = None + if hasattr(session_cookies, "get"): + device_id = session_cookies.get("oai-did") + elif isinstance(session_cookies, dict): + device_id = session_cookies.get("oai-did") + + return self.http_client.profile.json_api_headers( + referer=referer, + oai_did=device_id, + ) + + 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: Optional[str], + referer: str, + log_label: str, + record_existing_account: bool = True, + ) -> SignupFormResult: + """ + 提交授权入口表单 + + Returns: + SignupFormResult: 提交结果,包含账号状态判断 + """ + try: + request_payload = { + "username": { + "value": self.email, + "kind": "email", + }, + } + if screen_hint: + request_payload["screen_hint"] = screen_hint + + request_body = json.dumps(request_payload) + + headers = self.http_client.profile.json_api_headers( + referer=referer, + oai_did=session_cookies.get("oai-did") if hasattr(session_cookies := (getattr(self.session, "cookies", None) or {}), "get") else None, + ) + + if sen_token: + sentinel = json.dumps({ + "p": "", + "t": "", + "c": sen_token, + "id": did, + "flow": "authorize_continue", + }) + headers["openai-sentinel-token"] = sentinel + + response = self.session.post( + OPENAI_API_ENDPOINTS["signup"], + headers=headers, + data=request_body, + ) + + self._log(f"{log_label}状态: {response.status_code}") + + if response.status_code != 200: + return SignupFormResult( + success=False, + error_message=f"HTTP {response.status_code}: {response.text[:200]}" + ) + + # 解析响应判断账号状态 + try: + response_data = response.json() + self._remember_workspace_payload(f"{screen_hint}_start", response_data) + self._remember_navigation_from_response(f"{screen_hint}_start", response) + 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: + self._log(f"{log_label}失败: {e}", "error") + return SignupFormResult(success=False, error_message=str(e)) + + 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], + *, + screen_hint: Optional[str] = None, + ) -> SignupFormResult: + """提交登录入口表单。""" + return self._submit_auth_start( + did, + sen_token, + screen_hint=screen_hint, + referer="https://auth.openai.com/log-in", + log_label="提交登录入口", + record_existing_account=False, + ) + + def _submit_login_password(self) -> SignupFormResult: + """提交登录密码,进入邮箱验证码页面。""" + try: + # 人类节奏:用户在输入密码,等待 2-5 秒 + self._human_delay(mean=3.5, std=1.0, minimum=2.0) + + response = self.session.post( + OPENAI_API_ENDPOINTS["password_verify"], + headers=self.http_client.profile.json_api_headers( + referer="https://auth.openai.com/log-in/password", + ), + data=json.dumps({"password": self.password}), + ) + + self._log(f"提交登录密码状态: {response.status_code}") + + if response.status_code != 200: + return SignupFormResult( + success=False, + error_message=f"HTTP {response.status_code}: {response.text[:200]}" + ) + + response_data = response.json() + self._remember_workspace_payload("login_password", response_data) + self._remember_navigation_from_response("login_password", response) + 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: + self._log(f"提交登录密码失败: {e}", "error") + return SignupFormResult(success=False, error_message=str(e)) + + def _reset_auth_flow(self) -> None: + """重置会话,准备重新发起 OAuth 流程。""" + self.http_client.close() + self.session = None + self.oauth_start = None + self.session_token = None + self._otp_stage = "signup_otp" + self._otp_sent_at = None + self._session_bound_reauth_attempted = False + self._session_bound_reauth_otp_cycles = 0 + self._reset_workspace_context() + + 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 + + # 入口流模拟:先访问 chatgpt.com 首页,建立真实的 cookie 和引荐链 + self._warm_entry_flow() + + self._log(f"{label}: OAuth 流程准备开跑,系好鞋带...") + if not self._start_oauth(): + return None, None + + self._log(f"{label}: 领取 Device ID 通行证...") + did = self._get_device_id() + if not did: + return None, None + + # 人类节奏:获取 DID 后等待 1-3 秒 + self._human_delay(mean=2.0, std=0.7, minimum=1.0) + + self._log(f"{label}: 解一道 Sentinel POW 小题,答对才给进...") + sen_token = self._check_sentinel(did) + if not sen_token: + return did, None + + # 人类节奏:Sentinel 通过后等待 1-2 秒 + self._human_delay(mean=1.5, std=0.5, minimum=0.8) + + self._log(f"{label}: Sentinel 点头放行,继续前进") + return did, sen_token + + def _resolve_oauth_callback_url(self) -> Tuple[Optional[str], Optional[str], Optional[str]]: + workspace_id = self._get_workspace_id() + callback_url = str(self._workspace_context.get("callback_url") or "").strip() + + if workspace_id: + self._workspace_context["resolved_workspace_id"] = workspace_id + self._oauth_resume_source = self._oauth_resume_source or "workspace_select_required" + callback_url = self._continue_from_workspace_selection(workspace_id) + if not callback_url: + return None, workspace_id, "选择 Workspace 失败" + return callback_url, workspace_id, None + + if callback_url: + self._mark_workspace_resolution("callback_available_before_workspace") + self._oauth_resume_source = self._oauth_resume_source or "callback_found_from_validate_response" + return callback_url, None, None + + callback_url = self._resume_oauth_callback() + if callback_url: + return callback_url, None, None + + if self._workspace_context.get("reentered_login"): + return None, None, "OAuth 恢复链路重新进入登录页" + return None, None, "OAuth 续跑失败" + + def _complete_token_exchange(self, result: RegistrationResult, skip_otp_validation: bool = False) -> bool: + """在登录态已建立后,继续完成 workspace 和 OAuth token 获取。""" + callback_url: Optional[str] = None + workspace_id: Optional[str] = result.workspace_id or None + resolution_error: Optional[str] = None + + if self._account_created and not self._token_acquisition_requires_login: + self._log("建号后先尝试沿当前会话完成 OAuth,避免过早重新登录") + callback_url, workspace_id, resolution_error = self._resolve_oauth_callback_url() + if workspace_id: + result.workspace_id = workspace_id + if callback_url: + self._log("建号后当前会话已拿到 callback,跳过重新登录") + else: + self._log( + "建号后当前会话未完成 OAuth,回退到重新登录拿 token: " + f"{resolution_error or 'current_session_unresolved'}", + "warning", + ) + login_ready, login_error = self._restart_login_flow() + if not login_ready: + result.error_message = login_error + return False + + if not callback_url: + if not skip_otp_validation: + otp_stage = "relogin_otp" if self._token_acquisition_requires_login else "signup_otp" + code = self._get_verification_code( + stage=otp_stage, + timeout=self._get_verification_timeout(otp_stage), + ) + if not code: + result.error_message = "获取验证码失败" + return False + + if not self._validate_verification_code(code): + result.error_message = "验证码校验失败" + return False + + about_you_url = self._find_about_you_candidate() + if about_you_url and self._about_you_resume_attempts < 3: + if getattr(self, "_last_create_account_user_exists", False): + self._log("检测到 about-you 且曾经触发过 user_already_exists 死锁,放弃尝试该账号以防封IP", level="error") + result.error_message = "账号状态异常(半成品死锁)" + return False + + self._about_you_resume_attempts += 1 + self._log( + "恢复链路命中 about-you,先补完建号再重启登录: " + f"{self._sanitize_url_for_log(about_you_url)} " + f"(attempt {self._about_you_resume_attempts}/3)" + ) + callback_url = None + if self._account_created: + self._log( + f"恢复链路命中 about-you,但账号已建成 (_account_created=True)," + f"跳过 create_account,直接尝试 authorize replay 拿 callback..." + ) + callback_url = self._attempt_authorize_replay(allow_session_bound_reauth=False, reentry_log_level="warning") + if callback_url: + self._log("账号已建成 + authorize replay 成功拿到 callback,跳过建号循环") + else: + callback_url = self._attempt_direct_consent_recovery() + if callback_url: + self._log("账号已建成 + direct consent fallback 成功拿到 callback") + else: + if self._about_you_resume_attempts >= 2: + result.error_message = "账号已建成但无法获取 OAuth callback" + return False + login_ready, login_error = self._restart_login_flow() + if not login_ready: + result.error_message = login_error + return False + return self._complete_token_exchange(result) + else: + self._oauth_resume_source = self._oauth_resume_source or "continue_url_resume" + self._set_recovery_debug_summary( + "about_you_requires_account_creation", + about_you_url=about_you_url, + attempt=self._about_you_resume_attempts, + ) + if not self._create_user_account(allow_existing_account=True, about_you_url=about_you_url): + result.error_message = "创建用户账户失败" + return False + + unresolved_existing_account_without_resume = False + if not callback_url: + self._account_created = True + callback_after_create = str(self._workspace_context.get("callback_url") or "").strip() + workspace_after_create = None + auth_cookie = self.session.cookies.get("oai-client-auth-session") if self.session else None + if auth_cookie: + workspace_after_create = self._get_workspace_id() + unresolved_existing_account_without_resume = False + + self._log( + "about-you 建号后快照: " + f"callback_after_create={'yes' if callback_after_create else 'no'}, " + f"workspace_after_create={workspace_after_create or '-'}, " + f"auth_cookie_present={'yes' if auth_cookie else 'no'}, " + f"resume_url_after_create={self._sanitize_url_for_log(self._workspace_context.get('resume_url')) or '-'}, " + f"user_exists={self._last_create_account_user_exists}, " + f"user_exists_attempt={self._about_you_user_exists_without_resume_attempts}" + ) + + if callback_after_create or workspace_after_create: + self._about_you_user_exists_without_resume_attempts = 0 + callback_url, workspace_id, resolution_error = self._resolve_oauth_callback_url() + if callback_url: + if workspace_id: + result.workspace_id = workspace_id + self._log("about-you 处理后当前会话已拿到 callback,直接继续换 token") + else: + self._log("about-you 处理后当前会话未完成 callback,回退到重新登录续跑", "warning") + else: + callback_url = None + workspace_id = None + resolution_error = None + resume_url_after_create = str(self._workspace_context.get("resume_url") or "").strip() + if resume_url_after_create and not self._is_about_you_url(resume_url_after_create): + self._log( + "about-you 处理后当前会话仍未暴露 callback/workspace,但已缓存恢复 URL,先沿当前会话续跑" + ) + callback_url = self._resume_oauth_callback() + if callback_url: + self._about_you_user_exists_without_resume_attempts = 0 + self._log("about-you 处理后通过当前会话恢复 URL 拿到 callback,直接继续换 token") + else: + resolution_error = ( + "OAuth 恢复链路重新进入登录页" + if self._workspace_context.get("reentered_login") + else "OAuth 续跑失败" + ) + self._set_recovery_debug_summary( + "about_you_user_exists_resume_attempt_failed", + about_you_url=about_you_url, + resume_url=resume_url_after_create, + error=resolution_error, + ) + self._log("about-you 处理后恢复 URL 未拿到 callback,回退到重新登录续跑", "warning") + unresolved_existing_account_without_resume = ( + self._last_create_account_user_exists + and ( + not resume_url_after_create + or self._is_about_you_url(resume_url_after_create) + ) + ) + if unresolved_existing_account_without_resume: + self._log( + "about-you 处理后当前会话仍未暴露 callback/workspace,先尝试同会话 authorize replay" + ) + callback_url = self._attempt_authorize_replay( + allow_session_bound_reauth=False, + reentry_log_level="warning", + ) + if callback_url: + self._about_you_user_exists_without_resume_attempts = 0 + self._set_recovery_debug_summary( + "about_you_user_exists_authorize_replay_callback_resolved", + about_you_url=about_you_url, + callback_url=callback_url, + ) + self._log( + "about-you/user_already_exists 后通过同会话 authorize replay 拿到 callback,直接继续换 token" + ) + else: + login_challenge_url = self._find_cached_resume_candidate("login_challenge_resume") + if login_challenge_url: + self._log( + "about-you/user_already_exists 后 authorize replay 命中过 login_challenge," + "尝试 direct consent fallback", + "warning", + ) + callback_url = self._attempt_direct_consent_recovery() + if callback_url: + self._about_you_user_exists_without_resume_attempts = 0 + self._set_recovery_debug_summary( + "about_you_user_exists_direct_consent_callback_resolved", + about_you_url=about_you_url, + callback_url=callback_url, + ) + self._log( + "about-you/user_already_exists 后通过 direct consent fallback 拿到 callback," + "直接继续换 token" + ) + else: + resolution_error = ( + "OAuth 恢复链路重新进入登录页" + if self._workspace_context.get("reentered_login") + else "OAuth 续跑失败" + ) + self._about_you_user_exists_without_resume_attempts += 1 + self._oauth_resume_source = "about_you_user_exists_without_resume" + self._set_recovery_debug_summary( + "about_you_user_exists_without_resume", + attempt=self._about_you_user_exists_without_resume_attempts, + about_you_url=about_you_url, + error_code=self._last_create_account_error_code, + error=resolution_error, + ) + self._mark_workspace_resolution( + "about_you_user_exists_without_resume", + "about-you 返回 user_already_exists,但当前会话未暴露 callback/workspace", + ) + self._log( + "about-you/user_already_exists 后同会话 authorize replay 未拿到 callback,回退到重新登录续跑", + "warning", + ) + + if not callback_url: + if ( + unresolved_existing_account_without_resume + and self._about_you_user_exists_without_resume_attempts >= 2 + ): + result.error_message = "about-you 返回 user_already_exists,但连续两次未暴露 callback/workspace" + self._oauth_resume_source = "about_you_user_exists_without_resume_exhausted" + self._set_recovery_debug_summary( + "about_you_user_exists_without_resume_exhausted", + attempt=self._about_you_user_exists_without_resume_attempts, + about_you_url=about_you_url, + error_code=self._last_create_account_error_code, + ) + self._mark_workspace_resolution( + "about_you_user_exists_without_resume", + result.error_message, + ) + self._log( + "about-you/user_already_exists 连续两次未拿到 callback/workspace,停止重复登录以避免触发 OTP 限制", + "error", + ) + return False + login_ready, login_error = self._restart_login_flow() + if not login_ready: + result.error_message = login_error + return False + return self._complete_token_exchange(result) + else: + callback_url, workspace_id, resolution_error = self._resolve_oauth_callback_url() + + if workspace_id: + result.workspace_id = workspace_id + if not callback_url: + result.error_message = resolution_error or "OAuth 续跑失败" + return False + + token_info = self._handle_oauth_callback(callback_url) + if not token_info: + 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" + + if not result.workspace_id: + workspace_id = self._get_workspace_id() + if not workspace_id: + self._mark_workspace_resolution("token_acquired_without_workspace", "获取 Workspace ID 失败") + result.error_message = "获取 Workspace ID 失败" + return False + result.workspace_id = workspace_id + + session_cookie = self.session.cookies.get("__Secure-next-auth.session-token") + if session_cookie: + self.session_token = session_cookie + result.session_token = session_cookie + + return True + + def _restart_login_flow(self) -> Tuple[bool, str]: + """新注册账号完成建号后,重新发起一次登录流程拿 token。""" + self._token_acquisition_requires_login = True + self._log( + "重新登录前保留当前恢复快照: " + f"callback={self._sanitize_url_for_log(self._workspace_context.get('callback_url')) or '-'}, " + f"resume={self._sanitize_url_for_log(self._workspace_context.get('resume_url')) or '-'}, " + f"terminal={self._sanitize_url_for_log(self._workspace_context.get('redirect_terminal_url')) or '-'}, " + f"resume_source={self._oauth_resume_source or '-'}", + "warning", + ) + 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 验证失败" + + import time + max_429_retries = 3 + backoff_delays = [15, 30, 45] + login_start_result = None + for i in range(max_429_retries + 1): + login_start_result = self._submit_login_start(did, sen_token, screen_hint="login") + if login_start_result.success: + break + if "429" in login_start_result.error_message and i < max_429_retries: + delay = backoff_delays[i] + self._log(f"重新登录遇到 HTTP 429 速率限制,等待 {delay}s 后重试 (第 {i+1} 次)...", "warning") + time.sleep(delay) + sen_token = self._check_sentinel(did) or sen_token + else: + break + + 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'}" + self._prepare_relogin_otp_flow("重新登录") + return True, "" + + # ====================================================================== + # chatgpt.com 会话桥接方法 (从 dou-jiang/codex-console 移植) + # ====================================================================== + + @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())) + + @staticmethod + def _extract_session_token_from_cookie_jar(cookie_jar) -> str: + """从 requests CookieJar 中提取 session token。""" + if not cookie_jar: + return "" + for name in ("__Secure-next-auth.session-token", "_Secure-next-auth.session-token"): + val = str(cookie_jar.get(name) or "").strip() + if val: + return val + return "" + + @staticmethod + def _flatten_set_cookie_headers(response) -> str: + """将 response 的所有 Set-Cookie 头拼成一行文本。""" + if response is None: + return "" + headers = getattr(response, "headers", None) + if not headers: + return "" + try: + raw = headers.get_all("Set-Cookie") if hasattr(headers, "get_all") else [] + except Exception: + raw = [] + if not raw: + val = str(headers.get("Set-Cookie") or "").strip() + return val + return "; ".join(str(v) for v in raw if v) + + @staticmethod + def _extract_request_cookie_header(response) -> str: + """从 response.request 提取发送时的 Cookie header。""" + if response is None: + return "" + req = getattr(response, "request", None) + if not req: + return "" + headers = getattr(req, "headers", {}) or {} + return str(headers.get("Cookie") or "").strip() + + def _dump_session_cookies(self) -> str: + """将当前 session 的所有 cookie 导出为文本。""" + if not self.session: + return "" + parts = [] + try: + for cookie in self.session.cookies: + parts.append(f"{cookie.name}={cookie.value}") + except Exception: + pass + return "; ".join(parts) + + 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, access_hint=None) -> bool: + """ + 直接通过 /api/auth/session 捕获 session_token + access_token。 + """ + 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 头兜底 + 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 再请求一次 + 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 + + 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 _follow_chatgpt_auth_redirects(self, start_url: str): + """ + 手动跟踪 chatgpt.com signin 后的 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 + 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 _bootstrap_chatgpt_signin_for_session(self, result) -> bool: + """ + chatgpt.com 会话桥接:csrf -> signin/openai -> 跟随跳转 -> auth/session, + 目标是在 auth.openai.com 登录态已建立后,通过 chatgpt.com 拿到 session_token。 + """ + self._log("Session Token 还没就位,尝试 chatgpt.com 会话桥接...") + 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 登录页时,在 auth 侧重新走一遍完整登录来建立会话 + if "auth.openai.com/log-in" in str(final_url or "").lower(): + if not getattr(self, '_bridge_login_active', False): + self._log("会话桥接进入登录页,尝试自动登录后继续抓取 session_token...") + if self._bridge_login_for_session_token(result): + return True + else: + self._log("会话桥接进入登录页,但已处于桥接登录中,跳过递归", "warning") + + 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) -> 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 + + 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 + + # 等待并校验桥接登录的 OTP + bridge_otp_ok = False + code = self._get_verification_code(stage="relogin_otp", timeout=90) + if code: + bridge_otp_ok = self._validate_verification_code(code) + if not bridge_otp_ok: + # 重试一次 + self._log("会话桥接自动登录首轮 OTP 未命中,重发重试...", "warning") + self._retrigger_login_otp() + code = self._get_verification_code(stage="relogin_otp", timeout=90) + if code: + bridge_otp_ok = self._validate_verification_code(code) + if not bridge_otp_ok: + self._log("会话桥接自动登录验证码校验失败", "warning") + return False + + # OTP 成功后,尝试多种方式获取 session_token + self._log("会话桥接自动登录 OTP 通过,开始多路径抓取 token...") + + # 路径 0 (优先): 检查 validate_otp 是否已通过 _remember_navigation_from_response 记录了 chatgpt callback URL + # 日志分析发现:第三次 OTP 成功后 validate_otp 返回 external_url,continue_url 就是 + # https://chatgpt.com/api/auth/callback/openai?code=... 形式的 OAuth 授权码回调。 + # 只需直接 GET 这个 URL,chatgpt.com 就会在 Set-Cookie 中返回 session_token。 + cached_callback = str(self._workspace_context.get("callback_url") or "").strip() + if cached_callback and "/api/auth/callback/openai" in cached_callback: + self._log( + f"路径0: validate_otp 已捕获 chatgpt callback URL,直接 GET 换 token: " + f"{self._sanitize_url_for_log(cached_callback)}" + ) + try: + cb_resp = self.session.get( + cached_callback, + allow_redirects=True, + timeout=30, + headers={ + "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" + ), + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + }, + ) + # 从 Set-Cookie 中提取 session_token + st = "" + for cookie in self.session.cookies: + cookie_name = getattr(cookie, "name", "") + if "session-token" in cookie_name.lower() or cookie_name == "__Secure-next-auth.session-token": + st = getattr(cookie, "value", "") + break + if not st: + # 也检查响应头中的 Set-Cookie + set_cookie_headers = cb_resp.headers.get("set-cookie", "") + if "session-token=" in str(set_cookie_headers): + import re as _re + m = _re.search(r"session-token=([^;]+)", str(set_cookie_headers)) + if m: + st = m.group(1) + if st and len(st) > 20: + result.session_token = st + self.session_token = st + self._log(f"路径0: 直接 GET chatgpt callback 成功拿到 session_token ✓ (len={len(st)})") + # 补取 access_token:通过 /api/auth/session 获取 accessToken + try: + self._warmup_chatgpt_session() + self._capture_auth_session_tokens(result, access_hint=result.access_token) + if result.access_token: + self._log(f"路径0: 补取 access_token 成功 ✓ (len={len(result.access_token)})") + else: + self._log("路径0: 补取 access_token 未命中(仅有 session_token)", "warning") + except Exception as e: + self._log(f"路径0: 补取 access_token 异常: {e}", "warning") + return True + else: + self._log( + f"路径0: GET chatgpt callback 完成但未找到 session_token " + f"(status={cb_resp.status_code}, cookies={len(list(self.session.cookies))}), " + f"继续尝试其他路径...", + "warning", + ) + except Exception as e: + self._log(f"路径0: GET chatgpt callback 异常: {e},继续尝试其他路径...", "warning") + elif cached_callback: + self._log( + f"路径0: 已有 callback URL 但不是 chatgpt 类型,跳过直接 GET: " + f"{self._sanitize_url_for_log(cached_callback)}", + "warning", + ) + + # 路径 1: 直接 capture(有些场景 OTP 后 auth 会话足够) + self._warmup_chatgpt_session() + + if self._capture_auth_session_tokens(result, access_hint=result.access_token): + self._log("会话桥接自动登录在 OTP 后直接命中 session_token ✓") + return True + + # 路径 2: 尝试 about_you 补交,然后 capture + self._log("会话桥接自动登录: 路径1未命中,尝试补提交 about-you...") + self._create_user_account(allow_existing_account=True) + self._warmup_chatgpt_session() + if self._capture_auth_session_tokens(result, access_hint=result.access_token): + self._log("会话桥接自动登录在 about-you 后命中 session_token ✓") + return True + + # 路径 3: 尝试走 workspace → select → follow_redirects → capture + self._log("会话桥接自动登录: 路径2未命中,尝试 workspace 路径...") + workspace_id = self._get_workspace_id() + if not workspace_id: + workspace_id = str(result.workspace_id or "").strip() + if workspace_id: + result.workspace_id = workspace_id + continue_url = self._select_workspace(workspace_id) + if continue_url: + callback_url, final_url = self._follow_redirects(continue_url) + self._log(f"会话桥接 workspace 路径: callback={'有' if callback_url else '无'}") + self._warmup_chatgpt_session() + if self._capture_auth_session_tokens(result, access_hint=result.access_token): + self._log("会话桥接自动登录 workspace 路径命中 session_token ✓") + return True + + # 路径 4: 重新走完整 chatgpt signin → authorize → follow_redirects + self._log("会话桥接自动登录: 前3路径均未命中,重走 chatgpt signin...") + self._bridge_login_active = True + try: + if self._bootstrap_chatgpt_signin_for_session(result): + self._log("会话桥接自动登录二次 bootstrap 命中 session_token ✓") + return True + finally: + self._bridge_login_active = False + + self._log("会话桥接自动登录所有路径均未命中 session_token", "warning") + return False + except Exception as e: + self._log(f"会话桥接自动登录异常: {e}", "warning") + return False + + def _retrigger_login_otp(self) -> bool: + """在当前已登录会话中重新触发 OTP 发送。""" + self._log("尝试在当前会话中原地重发登录验证码...") + return self._send_verification_code( + stage="relogin_otp", + referer="https://auth.openai.com/email-verification", + allow_failure=True, + ) + + def _complete_token_exchange_outlook(self, result: RegistrationResult) -> bool: + """ + Outlook 入口链路专属收尾路径: + 走「登录 OTP -> Workspace -> OAuth callback」主干, + 含 3 级 OTP 降级重试,避免 Outlook 轮询卡死。 + """ + self._log("Outlook 专属链路: 等待登录验证码...") + + # ---- 第 1 级:直接等待 OTP(90s) ---- + attempted_codes: set = set() + code = self._get_verification_code( + stage="relogin_otp", + timeout=90, + ) + login_otp_ok = False + if code: + attempted_codes.add(code) + login_otp_ok = self._validate_verification_code(code) + + # ---- 第 2 级:原地重发 OTP 后再等 ---- + if not login_otp_ok: + self._log("登录验证码首轮未命中,尝试原地重发 OTP 后再校验...", "warning") + resent = self._retrigger_login_otp() + if resent: + code = self._get_verification_code( + stage="relogin_otp", + timeout=90, + ) + if code and code not in attempted_codes: + attempted_codes.add(code) + login_otp_ok = self._validate_verification_code(code) + elif code and code in attempted_codes: + self._log(f"原地重发后取到重复验证码 {code},跳过", "warning") + + # ---- 第 3 级:完整重登后再等 ---- + if not login_otp_ok: + self._log("登录验证码仍未命中,尝试完整重登后再校验...", "warning") + login_ready, login_error = self._restart_login_flow() + if not login_ready: + result.error_message = f"Outlook 重登失败: {login_error}" + return False + + code = self._get_verification_code( + stage="relogin_otp", + timeout=120, + ) + if code and code not in attempted_codes: + attempted_codes.add(code) + login_otp_ok = self._validate_verification_code(code) + elif code and code in attempted_codes: + self._log(f"完整重登后取到重复验证码 {code},跳过", "warning") + + if not login_otp_ok: + result.error_message = "Outlook 登录验证码 3 级重试全部失败" + return False + + # ---- Outlook OTP 通过后:补 about-you + 多层降级 OAuth token 恢复 ---- + self._log("Outlook 专属链路: OTP 验证通过,补全 about-you 并启动 OAuth token recovery...") + + # 1. 补全 about-you 建号状态 + account_ok = self._create_user_account(allow_existing_account=True) + if account_ok: + self._account_created = True + self._log("Outlook 链路: about-you 建号/确认完成 ✓") + else: + self._log("Outlook 链路: about-you 建号失败,仍尝试获取 token...", "warning") + + # 2. 多层降级 OAuth token 恢复 + fresh_callback_url = self._attempt_oauth_token_recovery() + + if fresh_callback_url: + # 3. 用 callback 完成 token 交换 + token_info = self._handle_oauth_callback(fresh_callback_url) + if token_info: + 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" + self._log("Outlook 链路: OAuth token recovery 成功获取 access_token ✓") + + # 补全 workspace + if not result.workspace_id: + result.workspace_id = self._get_workspace_id() or "" + + # session_token 从 cookie 中尝试获取 + session_cookie = self.session.cookies.get("__Secure-next-auth.session-token") if self.session else None + if session_cookie: + self.session_token = session_cookie + result.session_token = session_cookie + else: + self._log("Outlook 链路: OAuth callback 处理失败", "warning") + else: + self._log("Outlook 链路: OAuth token recovery 未拿到 callback,回退到通用流程...", "warning") + # 回退到通用流程(含 authorize replay 等老路径) + self._complete_token_exchange(result, skip_otp_validation=True) + + # 4. 如果还没有 session_token,尝试 chatgpt.com 桥接补全 + if not result.session_token: + self._log("Outlook 链路: 尝试 chatgpt 桥接辅助获取 session_token...", "warning") + try: + result.password = self.password or "" + result.source = "login" if self._is_existing_account else "register" + if self._bootstrap_chatgpt_signin_for_session(result): + self._log("Outlook 链路: chatgpt 会话桥接成功补充 session_token ✓") + else: + self._warmup_chatgpt_session() + self._capture_auth_session_tokens(result) + except Exception as e: + self._log(f"辅助 chatgpt 桥接异常(忽略): {e}", "warning") + + has_session = bool(result.session_token) + has_access = bool(result.access_token) + self._log( + f"Outlook 链路 token 捕获结果: session_token={'有' if has_session else '无'}, " + f"access_token={'有' if has_access else '无'}" + ) + + if not has_session and not has_access: + result.error_message = "Outlook 链路: session_token 和 access_token 均未获取到" + return False + + self._log("Outlook 专属链路完成: token 获取成功 ✓") + return True + + def _attempt_oauth_token_recovery(self) -> Optional[str]: + """Outlook OTP 通过后的多层降级 OAuth token 恢复。 + + 策略层级: + Step A: strip prompt=login 重放原始 authorize URL(复用已满足的 login challenge) + Step B: 全新 OAuth + prompt=none + login_hint(静默 SSO 尝试) + + 返回 callback URL 或 None。 + """ + import urllib.parse + + # ── Step A: strip prompt=login 重放原始 authorize URL ── + callback = self._step_a_replay_authorize_without_prompt() + if callback: + return callback + + # ── Step B: 全新 OAuth + prompt=none + login_hint ── + callback = self._step_b_fresh_oauth_silent() + if callback: + return callback + + self._log("Outlook token recovery: Step A + Step B 均未获取 callback", "warning") + return None + + # ──────────────────────────────────────────────────────────── + # Step A: 去掉 prompt=login,重放原始 authorize URL + # ──────────────────────────────────────────────────────────── + def _step_a_replay_authorize_without_prompt(self) -> Optional[str]: + """从原始 self.oauth_start.auth_url 中去掉 prompt=login 并手动跟踪重定向。""" + import urllib.parse + + if not self.oauth_start: + self._log("Step A: 没有可用的原始 authorize URL,跳过", "warning") + return None + + try: + original_url = self.oauth_start.auth_url + parsed = urllib.parse.urlsplit(original_url) + params = urllib.parse.parse_qs(parsed.query, keep_blank_values=True) + params.pop("prompt", None) # 去掉 prompt=login + new_query = urllib.parse.urlencode( + {k: v[0] if isinstance(v, list) and len(v) == 1 else v for k, v in params.items()}, + doseq=True, + ) + replay_url = urllib.parse.urlunsplit( + (parsed.scheme, parsed.netloc, parsed.path, new_query, "") + ) + self._log( + f"Step A: strip prompt=login 重放: {self._sanitize_url_for_log(replay_url)}" + ) + + # 手动逐跳跟踪重定向(最多 12 跳) + redirect_uri = self.oauth_start.redirect_uri + current_url = replay_url + max_hops = 12 + for hop in range(max_hops): + resp = self.session.get( + current_url, + headers={ + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "referer": "https://auth.openai.com/", + }, + allow_redirects=False, + timeout=20, + ) + location = str(resp.headers.get("Location") or "").strip() + self._log( + f"Step A hop {hop + 1}/{max_hops}: status={resp.status_code}, " + f"location={self._sanitize_url_for_log(location) or '-'}" + ) + + # 非重定向状态码 → 停止 + if resp.status_code not in (301, 302, 303, 307, 308): + final_url = str(resp.url or current_url) + resp_len = len(resp.text or "") if hasattr(resp, 'text') else 0 + self._log( + f"Step A: 终止于 status={resp.status_code}, " + f"url={self._sanitize_url_for_log(final_url)}, body_len={resp_len}" + ) + # 检查 consent 页面 → 记录但无法用 HTTP 处理(需 JS 渲染) + if self._is_consent_url(final_url): + self._log("Step A: 终止于 consent 页(JS 渲染,HTTP 无法处理)", "warning") + break + + if not location: + self._log("Step A: 重定向无 Location 头,终止", "warning") + break + + # 解析 Location(可能是相对路径) + next_url = urllib.parse.urljoin(current_url, location) + + # ★ 核心检测:Location 含 code= 和 state= → 拿到 callback! + if "code=" in next_url and "state=" in next_url: + self._log(f"Step A: 在重定向中拿到 callback ✓") + return next_url + + # 如果 Location 指向 localhost(redirect_uri 就是 localhost), + # 说明 callback 就在这里,但不应实际访问(本地没有服务) + if next_url.startswith(redirect_uri): + self._log(f"Step A: 重定向目标是 redirect_uri (localhost),直接提取 ✓") + return next_url + + # 如果被打回了 /log-in → login challenge 不匹配,Step A 失败 + if self._is_login_page_url(next_url): + self._log("Step A: 被重定向到 /log-in,login challenge 不匹配", "warning") + return None + + current_url = next_url + + self._log("Step A: 达到最大重定向次数或无 callback", "warning") + return None + + except Exception as e: + self._log(f"Step A 异常: {e}", "warning") + return None + + # ──────────────────────────────────────────────────────────── + # Step B: 全新 OAuth + prompt=none + login_hint + # ──────────────────────────────────────────────────────────── + def _step_b_fresh_oauth_silent(self) -> Optional[str]: + """生成全新 OAuth URL(prompt=none + login_hint),尝试静默 SSO。""" + import urllib.parse + + try: + fresh_oauth = generate_oauth_url_no_prompt(login_hint=self.email) + self.oauth_start = fresh_oauth # 更新 oauth_start 用于后续 token 交换 + self._log( + f"Step B: 发起静默 OAuth(prompt=none, login_hint={self.email}): " + f"{self._sanitize_url_for_log(fresh_oauth.auth_url)}" + ) + + redirect_uri = fresh_oauth.redirect_uri + current_url = fresh_oauth.auth_url + max_hops = 12 + for hop in range(max_hops): + resp = self.session.get( + current_url, + headers={ + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "referer": "https://auth.openai.com/", + }, + allow_redirects=False, + timeout=20, + ) + location = str(resp.headers.get("Location") or "").strip() + self._log( + f"Step B hop {hop + 1}/{max_hops}: status={resp.status_code}, " + f"location={self._sanitize_url_for_log(location) or '-'}" + ) + + if resp.status_code not in (301, 302, 303, 307, 308): + final_url = str(resp.url or current_url) + resp_len = len(resp.text or "") if hasattr(resp, 'text') else 0 + self._log( + f"Step B: 终止于 status={resp.status_code}, " + f"url={self._sanitize_url_for_log(final_url)}, body_len={resp_len}" + ) + if self._is_consent_url(final_url): + self._log("Step B: 终止于 consent 页(JS 渲染,HTTP 无法处理)", "warning") + break + + if not location: + break + + next_url = urllib.parse.urljoin(current_url, location) + + # callback 检测 + if "code=" in next_url and "state=" in next_url: + self._log("Step B: 在重定向中拿到 callback ✓") + return next_url + if next_url.startswith(redirect_uri): + self._log("Step B: 重定向目标是 redirect_uri,直接提取 ✓") + return next_url + + # error=login_required → 静默授权不被支持 + if "error=login_required" in next_url or "error=consent_required" in next_url: + self._log(f"Step B: 静默授权返回错误: {self._sanitize_url_for_log(next_url)}", "warning") + return None + + # 被打回登录页 + if self._is_login_page_url(next_url): + self._log("Step B: 被重定向到 /log-in,静默授权失败", "warning") + return None + + current_url = next_url + + self._log("Step B: 达到最大重定向次数或无 callback", "warning") + return None + + except Exception as e: + self._log(f"Step B 异常: {e}", "warning") + return None + + # ──────────────────────────────────────────────────────────── + # URL 分类辅助方法 + # ──────────────────────────────────────────────────────────── + @staticmethod + def _is_login_page_url(url: str) -> bool: + """判断 URL 是否指向 OpenAI 的登录页面。""" + if not url: + return False + return ( + "/log-in" in url + or "/login" in url + or "/sign-in" in url + ) and "auth.openai.com" in url + + @staticmethod + def _is_consent_url(url: str) -> bool: + """判断 URL 是否指向 consent 授权页面。""" + if not url: + return False + return "consent" in url.lower() and "auth.openai.com" in url + + def _register_password(self, did: str, sen_token: Optional[str]) -> Tuple[bool, Optional[str]]: + """注册密码""" + try: + self._last_registration_error = None + # 生成密码 + password = self._generate_password() + self.password = password # 保存密码到实例变量 + self._log(f"生成密码: {password}") + + # 提交密码注册 + register_body = json.dumps({ + "password": password, + "username": self.email + }) + + headers = { + "referer": "https://auth.openai.com/create-account/password", + "accept": "application/json", + "content-type": "application/json", + } + + if sen_token: + sentinel = json.dumps({ + "p": "", + "t": "", + "c": sen_token, + "id": did, + "flow": "authorize_continue", + }) + headers["openai-sentinel-token"] = sentinel + + response = self.session.post( + OPENAI_API_ENDPOINTS["register"], + headers=headers, + 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", "") + error_msg_lower = error_msg.lower() + + if ( + "already" in error_msg_lower + or "exists" in error_msg_lower + or error_code == "user_exists" + or "failed to register username" in error_msg_lower + ): + self._last_registration_error = "邮箱已被占用或疑似已注册,请更换新的 Outlook 邮箱" + self._mark_email_as_registered("username_rejected_or_existing_account") + elif error_msg: + self._last_registration_error = f"注册密码失败: {error_msg}" + except Exception: + pass + + if not self._last_registration_error: + self._last_registration_error = "注册密码失败" + return False, None + + return True, password + + except Exception as e: + self._log(f"密码注册失败: {e}", "error") + self._last_registration_error = "注册密码失败" + return False, None + + def _mark_email_as_registered( + self, + register_failed_reason: str = "email_already_registered_on_openai", + ): + """标记邮箱为已注册状态(用于防止重复尝试)""" + 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": register_failed_reason} + ) + self._log(f"已在数据库中标记邮箱 {self.email} 为已注册状态") + except Exception as e: + logger.warning(f"标记邮箱状态失败: {e}") + + def _set_verification_stage(self, stage: str) -> None: + self._otp_stage = stage + if ( + self.email + and hasattr(self.email_service, "set_verification_stage") + ): + try: + self.email_service.set_verification_stage(self.email, stage) + except Exception as e: + logger.warning(f"同步 OTP 阶段失败: {e}") + + def _get_verification_timeout(self, stage: Optional[str] = None) -> int: + actual_stage = stage or self._otp_stage + if ( + self.email_service.service_type == EmailServiceType.OUTLOOK + and actual_stage == "relogin_otp" + ): + return 180 + return 120 + + def _send_verification_code( + self, + *, + stage: str = "signup_otp", + referer: str = "https://auth.openai.com/create-account/password", + allow_failure: bool = False, + ) -> bool: + """发送验证码""" + try: + self._set_verification_stage(stage) + self._otp_sent_at = time.time() + + response = self.session.get( + OPENAI_API_ENDPOINTS["send_otp"], + headers={ + "referer": referer, + "accept": "application/json", + }, + ) + + level = "warning" if allow_failure and response.status_code != 200 else "info" + self._log(f"{stage} verification send status: {response.status_code}", level) + return response.status_code == 200 + + except Exception as e: + self._log( + f"{stage} verification send failed: {e}", + "warning" if allow_failure else "error", + ) + return False + + def _get_verification_code( + self, + *, + stage: Optional[str] = None, + timeout: Optional[int] = None, + ) -> Optional[str]: + """获取验证码""" + try: + actual_stage = stage or self._otp_stage + actual_timeout = timeout or self._get_verification_timeout(actual_stage) + self._set_verification_stage(actual_stage) + self._log(f"Waiting for {actual_stage} verification code for {self.email}...") + + email_id = self.email_info.get("service_id") if self.email_info else None + code = self.email_service.get_verification_code( + email=self.email, + email_id=email_id, + timeout=actual_timeout, + pattern=OTP_CODE_PATTERN, + otp_sent_at=self._otp_sent_at, + ) + + if code: + self._log(f"Got {actual_stage} verification code: {code}") + self._log_email_service_verification_debug("OTP retrieval debug") + return code + self._log(f"Timed out waiting for {actual_stage} verification code", "error") + self._log_email_service_verification_debug("OTP retrieval debug") + return None + + except Exception as e: + self._log(f"获取验证码失败: {e}", "error") + self._log_email_service_verification_debug("OTP retrieval debug") + return None + + def _log_email_service_verification_debug(self, label: str) -> None: + if not self.email or not hasattr(self.email_service, "get_last_verification_debug"): + return + + try: + debug = self.email_service.get_last_verification_debug(self.email) + except Exception as e: + logger.warning(f"读取邮箱验证码调试信息失败: {e}") + return + + if not isinstance(debug, dict) or not debug: + return + + parts = [ + f"stage={debug.get('stage') or '-'}", + f"polls={debug.get('poll_count') or 0}", + f"status={debug.get('last_status') or '-'}", + f"otp_sent_at={debug.get('otp_sent_at') or 0}", + f"min_ts={debug.get('min_timestamp') or 0}", + f"fresh_verifications={debug.get('fresh_verification_count') or 0}", + f"fresh_preferred={debug.get('fresh_preferred_sender_count') or 0}", + f"stale_preferred={debug.get('stale_preferred_sender_count') or 0}", + f"available_fresh_verifications={debug.get('available_fresh_verification_count') or 0}", + f"available_fresh_preferred={debug.get('available_fresh_preferred_sender_count') or 0}", + f"used_fresh_preferred={debug.get('used_fresh_preferred_sender_count') or 0}", + f"selected_sender={debug.get('selected_sender') or '-'}", + f"selected_code={debug.get('selected_code') or '-'}", + f"selected_ts={debug.get('selected_received_timestamp') or '-'}", + f"deferred_generic_only_polls={debug.get('deferred_generic_only_polls') or 0}", + ] + + candidates = debug.get("candidate_summaries") or [] + if candidates: + formatted_candidates = [] + for item in candidates[:5]: + formatted_candidates.append( + "sender={sender},ts={ts},delta={delta},code={code},preferred={preferred}".format( + sender=item.get("sender") or "-", + ts=item.get("received_timestamp") or "-", + delta=item.get("delta_from_otp_sent") if item.get("delta_from_otp_sent") is not None else "-", + code=item.get("code") or "-", + preferred=item.get("is_preferred_sender") is True, + ) + ) + parts.append("candidates=" + " | ".join(formatted_candidates)) + + self._log(f"{label}: " + "; ".join(parts)) + + def _log_response_debug( + self, + label: str, + response: Any, + payload: Optional[Dict[str, Any]] = None, + *, + level: str = "info", + ) -> None: + if response is None: + return + + headers = getattr(response, "headers", {}) or {} + request_id = "" + if isinstance(headers, dict): + request_id = ( + str(headers.get("x-request-id") or headers.get("request-id") or "").strip() + ) + + parts = [ + f"status={getattr(response, 'status_code', '-')}", + f"url={self._sanitize_url_for_log(getattr(response, 'url', '') or '-')}", + ] + if request_id: + parts.append(f"request_id={request_id}") + + if isinstance(payload, dict): + page_type = str((payload.get("page") or {}).get("type") or "").strip() + continue_url = str(payload.get("continue_url") or "").strip() + callback_url = str(payload.get("callback_url") or "").strip() + error_info = payload.get("error") or {} + error_code = str(error_info.get("code") or "").strip() + error_message = str(error_info.get("message") or "").strip() + + if page_type: + parts.append(f"page_type={page_type}") + if continue_url: + parts.append(f"continue_url={self._sanitize_url_for_log(continue_url)}") + if callback_url: + parts.append(f"callback_url={self._sanitize_url_for_log(callback_url)}") + if error_code: + parts.append(f"error_code={error_code}") + if error_message: + parts.append(f"error_message={error_message[:240]}") + + text_excerpt = str(getattr(response, "text", "") or "").strip() + if text_excerpt: + parts.append(f"text={text_excerpt[:240]}") + + self._log(f"{label}: " + "; ".join(parts), level) + + def _log_session_cookie_debug(self, label: str) -> None: + if not self.session: + return + + parts = [] + + auth_cookie = self.session.cookies.get("oai-client-auth-session") + if auth_cookie: + decoded_segments, failed_segments = self._decode_auth_cookie_json_segments(auth_cookie) + auth_parts = [ + f"decoded={len(decoded_segments)}", + f"failed={failed_segments}", + ] + if decoded_segments: + primary = decoded_segments[0] + for key in [ + "email", + "email_verified", + "email_verification_mode", + "original_screen_hint", + "session_id", + ]: + value = primary.get(key) + if value in (None, "", []): + continue + value_text = str(value) + if key == "session_id" and len(value_text) > 12: + value_text = value_text[:12] + "..." + auth_parts.append(f"{key}={value_text}") + parts.append("auth_cookie(" + ", ".join(auth_parts) + ")") + else: + parts.append("auth_cookie=missing") + + next_auth_cookie = self.session.cookies.get("__Secure-next-auth.session-token") + if next_auth_cookie: + claims = _jwt_claims_no_verify(next_auth_cookie) + workspace_id = self._extract_workspace_id_from_data(claims) + claim_keys = ",".join(sorted(claims.keys())[:6]) if isinstance(claims, dict) else "-" + parts.append(f"next_auth(workspace={workspace_id or '-'}, keys={claim_keys or '-'})") + else: + parts.append("next_auth=missing") + + for name in ["login_session", "auth_provider"]: + if self.session.cookies.get(name): + parts.append(f"{name}=present") + + for name in ["auth-session-minimized", "unified_session_manifest"]: + cookie_value = self.session.cookies.get(name) + if not cookie_value: + continue + payloads = self._decode_cookie_json_payloads(cookie_value) + workspace_id = None + sample_keys = "-" + for payload in payloads: + workspace_id = self._extract_workspace_id_from_data(payload) + if workspace_id: + break + if payloads: + sample_keys = ",".join(sorted(payloads[0].keys())[:6]) or "-" + parts.append( + f"{name}(decoded={len(payloads)}, workspace={workspace_id or '-'}, keys={sample_keys})" + ) + + self._log(f"{label}: " + "; ".join(parts)) + + def _validate_verification_code(self, code: str) -> bool: + """验证验证码""" + try: + code_body = f'{{"code":"{code}"}}' + + response = self.session.post( + OPENAI_API_ENDPOINTS["validate_otp"], + headers=self.http_client.profile.json_api_headers( + referer="https://auth.openai.com/email-verification", + ), + data=code_body, + ) + + self._log(f"验证码校验状态: {response.status_code}") + try: + response_data = response.json() + except Exception: + response_data = None + + self._log_response_debug( + "validate_otp response", + response, + response_data, + level="warning" if response.status_code != 200 else "info", + ) + self._log_session_cookie_debug("validate_otp cookies") + if response.status_code == 200: + self._remember_workspace_payload("validate_otp", response_data) + self._remember_navigation_from_response("validate_otp", response) + self._log_navigation_snapshot("验证码校验后导航快照") + return response.status_code == 200 + + except Exception as e: + self._log(f"验证验证码失败: {e}", "error") + return False + + def _prime_about_you_page(self, about_you_url: str) -> bool: + """预取 about-you 页面,尽量复用浏览器的会话建立顺序。""" + try: + candidate = str(about_you_url or "").strip() + if not candidate: + return True + + self._log( + "预取 about-you 页面,补齐建号前会话状态: " + f"{self._sanitize_url_for_log(candidate)}" + ) + response = self.session.get( + candidate, + headers={ + "referer": "https://auth.openai.com/email-verification", + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + }, + timeout=20, + ) + self._log(f"about-you 页面状态: {response.status_code}") + self._log_response_debug("about_you_page response", response, level="info") + self._log_session_cookie_debug("about_you_page cookies") + self._remember_navigation_from_response("about_you_page", response) + self._log_navigation_snapshot("about-you 页面预取后导航快照") + + if response.status_code >= 400: + self._log( + f"about-you 页面预取失败: HTTP {response.status_code}", + "warning", + ) + return False + + return True + + except Exception as e: + self._log(f"预取 about-you 页面失败: {e}", "error") + return False + + def _create_user_account( + self, + allow_existing_account: bool = False, + about_you_url: Optional[str] = None, + ) -> bool: + """创建用户账户""" + try: + self._last_create_account_error_code = None + self._last_create_account_error_message = None + self._last_create_account_user_exists = False + if about_you_url and not self._prime_about_you_page(about_you_url): + return False + + user_info = generate_random_user_info() + self._log(f"生成用户信息: {user_info['name']}, 生日: {user_info['birthdate']}") + create_account_body = json.dumps(user_info) + + # 人类节奏:用户在填写姓名和生日,等待 2-4 秒 + self._human_delay(mean=3.0, std=0.8, minimum=2.0) + + response = self.session.post( + OPENAI_API_ENDPOINTS["create_account"], + headers=self.http_client.profile.json_api_headers( + referer="https://auth.openai.com/about-you", + ), + data=create_account_body, + ) + + self._log(f"账户创建状态: {response.status_code}") + try: + response_data = response.json() + except Exception: + response_data = None + self._log_response_debug( + "create_account response", + response, + response_data, + level="warning" if response.status_code != 200 else "info", + ) + self._log_session_cookie_debug("create_account cookies") + self._remember_workspace_payload("create_account", response_data) + self._remember_navigation_from_response("create_account", response) + self._log_navigation_snapshot("建号后导航快照") + page_type = "" + continue_url = "" + callback_hint = "" + if isinstance(response_data, dict): + page_type = str((response_data.get("page") or {}).get("type") or "").strip() + continue_url = str(response_data.get("continue_url") or "").strip() + callback_hint = str(response_data.get("callback_url") or "").strip() + response_keys = ",".join(sorted(response_data.keys())) if isinstance(response_data, dict) else "-" + self._log( + "create_account 快照: " + f"status={response.status_code}, " + f"page_type={page_type or '-'}, " + f"continue_url={self._sanitize_url_for_log(continue_url) or '-'}, " + f"callback_url={self._sanitize_url_for_log(callback_hint) or '-'}, " + f"response_keys={response_keys}" + ) + + if response.status_code != 200: + error_payload = response_data if isinstance(response_data, dict) else None + error_info = error_payload.get("error", {}) if isinstance(error_payload, dict) else {} + error_code = str(error_info.get("code") or "").strip().lower() + error_message = str(error_info.get("message") or "").strip() + self._last_create_account_error_code = error_code or None + self._last_create_account_error_message = error_message or None + + if error_code == "registration_disallowed" and self.email and "@" in self.email: + domain = self.email.rsplit("@", 1)[-1] + if domain: + self._blacklist_email_domain(domain) + + error_message_lower = error_message.lower() + if allow_existing_account and ( + error_code == "user_already_exists" + or "already exists for this email address" in error_message_lower + ): + self._last_create_account_user_exists = True + self._log("about-you 建号返回 user_already_exists,按已建号继续恢复登录") + return True + + error_text = response.text[:200] + if not error_text and error_payload is not None: + error_text = json.dumps(error_payload, ensure_ascii=False)[:200] + self._log(f"账户创建失败: {error_text}", "warning") + return False + + return True + + except Exception as e: + self._log(f"创建账户失败: {e}", "error") + return False + + def _blacklist_email_domain(self, domain: str) -> None: + """将被 OpenAI 拒绝的邮箱域名加入黑名单""" + try: + with get_db() as db: + setting = crud.get_setting(db, "email.domain_blacklist") + if setting and setting.value: + try: + blacklist = json.loads(setting.value) + except (json.JSONDecodeError, TypeError): + blacklist = [] + else: + blacklist = [] + + domain_lower = domain.lower().strip() + if domain_lower not in blacklist: + blacklist.append(domain_lower) + crud.set_setting( + db, + key="email.domain_blacklist", + value=json.dumps(blacklist), + description="被 OpenAI 拒绝注册的邮箱域名黑名单", + category="email", + ) + self._log(f"已将域名 {domain_lower} 加入黑名单 (共 {len(blacklist)} 个)") + else: + self._log(f"域名 {domain_lower} 已在黑名单中,跳过") + except Exception as e: + logger.warning(f"写入邮箱域名黑名单失败: {e}") + + def _decode_auth_cookie_json_segments(self, auth_cookie: str) -> Tuple[list, int]: + import base64 + from urllib.parse import unquote + + decoded_segments = [] + failed_segments = 0 + seen_payloads = set() + candidates = [str(auth_cookie or "").strip()] + decoded_cookie = unquote(candidates[0]) if candidates and candidates[0] else "" + if decoded_cookie and decoded_cookie != candidates[0]: + candidates.append(decoded_cookie) + + for candidate in candidates: + if not candidate: + continue + + normalized = candidate + if ( + (normalized.startswith('"') and normalized.endswith('"')) + or (normalized.startswith("'") and normalized.endswith("'")) + ): + normalized = normalized[1:-1] + + segments = [segment for segment in normalized.split(".") if segment] + if normalized and "." in normalized: + first_segment = normalized.split(".", 1)[0] + if first_segment: + segments.insert(0, first_segment) + + for segment in segments: + try: + pad = "=" * ((4 - (len(segment) % 4)) % 4) + decoded = base64.urlsafe_b64decode((segment + pad).encode("ascii")) + decoded_json = json.loads(decoded.decode("utf-8")) + if isinstance(decoded_json, dict): + payload_key = json.dumps(decoded_json, sort_keys=True, ensure_ascii=True) + if payload_key not in seen_payloads: + seen_payloads.add(payload_key) + decoded_segments.append(decoded_json) + except Exception: + failed_segments += 1 + return decoded_segments, failed_segments + + def _decode_cookie_json_payloads(self, cookie_value: Any) -> List[Dict[str, Any]]: + import base64 + from urllib.parse import unquote + + queue = [str(cookie_value or "").strip()] + seen_candidates = set() + seen_payloads = set() + payloads: List[Dict[str, Any]] = [] + + def remember_payload(payload: Any) -> None: + if isinstance(payload, dict): + payload_key = json.dumps(payload, sort_keys=True, ensure_ascii=True) + if payload_key in seen_payloads: + return + seen_payloads.add(payload_key) + payloads.append(payload) + return + if isinstance(payload, list): + for item in payload: + if isinstance(item, (dict, list)): + remember_payload(item) + elif isinstance(item, str) and item.strip(): + queue.append(item.strip()) + + while queue: + candidate = str(queue.pop(0) or "").strip() + if not candidate or candidate in seen_candidates: + continue + seen_candidates.add(candidate) + + if ( + (candidate.startswith('"') and candidate.endswith('"')) + or (candidate.startswith("'") and candidate.endswith("'")) + ): + queue.append(candidate[1:-1].strip()) + + decoded_candidate = unquote(candidate) + if decoded_candidate and decoded_candidate != candidate: + queue.append(decoded_candidate) + + try: + decoded_json = json.loads(candidate) + except Exception: + decoded_json = None + if decoded_json is not None: + if isinstance(decoded_json, str): + queue.append(decoded_json.strip()) + else: + remember_payload(decoded_json) + + base64_candidates = [candidate] + if "." in candidate: + first_segment = candidate.split(".", 1)[0].strip() + if first_segment: + base64_candidates.append(first_segment) + base64_candidates.extend( + segment.strip() for segment in candidate.split(".") if segment.strip() + ) + + for segment in base64_candidates: + try: + pad = "=" * ((4 - (len(segment) % 4)) % 4) + decoded_text = base64.urlsafe_b64decode((segment + pad).encode("ascii")).decode("utf-8") + except Exception: + continue + decoded_text = decoded_text.strip() + if decoded_text: + queue.append(decoded_text) + + return payloads + + def _collect_interesting_text_fragments_from_data( + self, + data: Any, + fragments: List[str], + seen: set, + ) -> None: + if isinstance(data, str): + fragment = self._normalize_response_text(data) + if not fragment: + return + fragment_lower = fragment.lower() + interesting_tokens = ( + "http://", + "https://", + "localhost", + "callback", + "continue_url", + "workspace", + "workspaces", + "organization", + "orgs", + "consent", + "/api/", + ) + if any(token in fragment_lower for token in interesting_tokens): + if fragment not in seen: + seen.add(fragment) + fragments.append(fragment) + return + + if isinstance(data, dict): + for value in data.values(): + self._collect_interesting_text_fragments_from_data(value, fragments, seen) + return + + if isinstance(data, list): + for item in data: + self._collect_interesting_text_fragments_from_data(item, fragments, seen) + + def _extract_app_router_push_payloads_from_text(self, text: Any) -> List[str]: + normalized = self._normalize_response_text(text) + if not normalized: + return [] + + import re + + payloads: List[str] = [] + seen = set() + for match in re.finditer(r'push\(\s*', normalized): + prefix = normalized[max(0, match.start() - 96):match.start()].lower() + if "__next_f" not in prefix: + continue + payload_text = self._extract_json_block_from_text(normalized, match.end()) + if payload_text and payload_text not in seen: + seen.add(payload_text) + payloads.append(payload_text) + + return payloads + + def _extract_app_router_text_fragments_from_text(self, text: Any) -> List[str]: + fragments: List[str] = [] + seen = set() + for payload_text in self._extract_app_router_push_payloads_from_text(text): + try: + decoded = json.loads(payload_text) + except Exception: + continue + self._collect_interesting_text_fragments_from_data(decoded, fragments, seen) + + return fragments + + def _extract_json_payload_candidates_from_fragment(self, fragment: str) -> List[str]: + candidate = self._normalize_response_text(fragment) + if not candidate: + return [] + + import re + + payloads: List[str] = [] + seen = set() + hints = [ + "workspaces", + "workspace", + "callback_url", + "continue_url", + "redirect_url", + "location", + "orgs", + "organization", + ] + + for hint in hints: + for match in re.finditer(re.escape(hint), candidate, re.IGNORECASE): + lower_bound = max(0, match.start() - 4096) + start_index = candidate.rfind("{", lower_bound, match.start() + 1) + while start_index != -1: + payload_text = self._extract_json_block_from_text(candidate, start_index) + if payload_text and match.start() < start_index + len(payload_text): + if payload_text not in seen: + seen.add(payload_text) + payloads.append(payload_text) + break + start_index = candidate.rfind("{", lower_bound, start_index) + + return payloads + + def _reset_workspace_context(self) -> None: + self._workspace_context = { + "payloads": {}, + "continue_url": None, + "callback_url": None, + "resume_url": None, + "resume_source": None, + "redirect_locations": [], + "token_info": None, + "navigation_urls": [], + "redirect_terminal_url": None, + "redirect_terminal_status": None, + "reentered_login": False, + "last_login_challenge_url": None, + "last_recovery_debug_summary": None, + "resolved_workspace_id": None, + } + self._workspace_resolution_source = None + self._workspace_resolution_error = None + self._oauth_resume_source = None + + def _sanitize_url_for_log(self, url: Optional[str]) -> str: + candidate = str(url or "").strip() + if not candidate: + return "" + try: + import urllib.parse + + parsed = urllib.parse.urlsplit(candidate) + query_pairs = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True) + sanitized_query = [] + for key, value in query_pairs: + if key.lower() in { + "code", + "state", + "id_token", + "access_token", + "refresh_token", + "login_challenge", + "consent_challenge", + }: + value = "" + elif len(value) > 24: + value = f"{value[:8]}...{value[-4:]}" + sanitized_query.append((key, value)) + return urllib.parse.urlunsplit( + ( + parsed.scheme, + parsed.netloc, + parsed.path, + urllib.parse.urlencode(sanitized_query), + parsed.fragment, + ) + ) + except Exception: + return candidate + + def _set_recovery_debug_summary(self, reason: str, **details: Any) -> None: + parts = [reason] + for key, value in details.items(): + text = str(value or "").strip() + if not text: + continue + if "url" in key.lower(): + text = self._sanitize_url_for_log(text) + parts.append(f"{key}={text}") + self._workspace_context["last_recovery_debug_summary"] = "; ".join(parts) + + def _find_cached_resume_candidate(self, resume_source: str) -> Optional[str]: + navigation_urls = self._workspace_context.get("navigation_urls", []) or [] + for item in reversed(navigation_urls): + candidate = str(item.get("url") or "").strip() + source = str(item.get("source") or "").strip() + if candidate and self._classify_resume_source(source, candidate) == resume_source: + return candidate + return None + + def _is_about_you_url(self, url: Optional[str]) -> bool: + candidate = str(url or "").strip().lower() + if not candidate: + return False + return "/about-you" in candidate or candidate.endswith("about_you") + + def _extract_about_you_candidate_from_data(self, data: Any) -> Optional[str]: + if isinstance(data, dict): + for key, value in data.items(): + if isinstance(value, str): + value_text = value.strip() + if self._is_about_you_url(value_text): + return value_text + if str(key).strip().lower() == "type" and value_text.lower() in {"about_you", "about-you"}: + return "https://auth.openai.com/about-you" + candidate = self._extract_about_you_candidate_from_data(value) + if candidate: + return candidate + elif isinstance(data, list): + for item in data: + candidate = self._extract_about_you_candidate_from_data(item) + if candidate: + return candidate + return None + + def _find_about_you_candidate(self) -> Optional[str]: + resume_url = str(self._workspace_context.get("resume_url") or "").strip() + if self._is_about_you_url(resume_url): + return resume_url + + payloads = self._workspace_context.get("payloads", {}) or {} + for payload in reversed(list(payloads.values())): + candidate = self._extract_about_you_candidate_from_data(payload) + if candidate: + return candidate + + navigation_urls = self._workspace_context.get("navigation_urls", []) or [] + for item in reversed(navigation_urls): + candidate = str(item.get("url") or "").strip() + if self._is_about_you_url(candidate): + return candidate + + return None + + def _resume_candidate_priority(self, source_name: str, candidate: str) -> int: + candidate_lower = str(candidate or "").strip().lower() + source_lower = str(source_name or "").strip().lower() + if not candidate_lower: + return -1 + + priority = 0 + if self._is_login_page_url(candidate): + priority = max(priority, 10) + if "/email-verification" in candidate_lower: + priority = max(priority, 20) + if "login_challenge=" in candidate_lower: + priority = max(priority, 30) + if "consent_challenge=" in candidate_lower: + priority = max(priority, 35) + if "/add-phone" in candidate_lower: + priority = max(priority, 36) + if "/sign-in-with-chatgpt/" in candidate_lower or "/consent" in candidate_lower: + priority = max(priority, 38) + if "/workspace/select" in candidate_lower: + priority = max(priority, 42) + if "/organization/select" in candidate_lower: + priority = max(priority, 45) + if self._is_about_you_url(candidate): + priority = max(priority, 40) + if "/api/oauth/oauth2/auth" in candidate_lower: + priority = max(priority, 50) + + if "validate_otp" in source_lower: + priority += 3 + elif "login_password" in source_lower: + priority += 2 + elif "login_start" in source_lower: + priority += 1 + + return priority + + def _remember_resume_candidate(self, source_name: str, candidate: Optional[str]) -> None: + candidate_text = str(candidate or "").strip() + if not candidate_text: + return + + resume_source = self._classify_resume_source(source_name, candidate_text) + if not resume_source: + return + + current_resume = str(self._workspace_context.get("resume_url") or "").strip() + if not current_resume: + self._workspace_context["resume_url"] = candidate_text + self._workspace_context["resume_source"] = resume_source + return + + if current_resume == candidate_text: + return + + current_priority = self._resume_candidate_priority( + str(self._workspace_context.get("resume_source") or ""), + current_resume, + ) + candidate_priority = self._resume_candidate_priority(source_name, candidate_text) + + if candidate_priority > current_priority: + self._log( + f"Resume candidate upgraded: " + f"current={self._sanitize_url_for_log(current_resume) or '-'}; " + f"new={self._sanitize_url_for_log(candidate_text)}" + ) + self._workspace_context["resume_url"] = candidate_text + self._workspace_context["resume_source"] = resume_source + return + + self._log( + f"Resume candidate ignored: " + f"current={self._sanitize_url_for_log(current_resume) or '-'}; " + f"new={self._sanitize_url_for_log(candidate_text)}" + ) + + def _log_navigation_snapshot(self, label: str) -> None: + self._log( + f"{label}: callback={self._sanitize_url_for_log(self._workspace_context.get('callback_url')) or '-'}, " + f"resume={self._sanitize_url_for_log(self._workspace_context.get('resume_url')) or '-'}, " + f"terminal={self._sanitize_url_for_log(self._workspace_context.get('redirect_terminal_url')) or '-'}, " + f"candidates={len(self._workspace_context.get('navigation_urls', []) or [])}" + ) + + def _log_navigation_candidates(self, label: str, limit: int = 8) -> None: + items = self._workspace_context.get("navigation_urls", []) or [] + if not items: + self._log(f"{label}: no navigation candidates recorded") + return + tail = items[-limit:] + formatted = " | ".join( + f"{item.get('source')}={self._sanitize_url_for_log(item.get('url'))}" + for item in tail + ) + self._log(f"{label}: {formatted}") + + def _remember_workspace_payload(self, name: str, payload: Optional[Dict[str, Any]]) -> None: + if not isinstance(payload, dict): + return + + self._workspace_context.setdefault("payloads", {})[name] = payload + self._log( + f"Payload[{name}] keys: {','.join(sorted(payload.keys())) or '-'}" + ) + + callback_url = self._extract_callback_url_from_data(payload) + if callback_url and not self._workspace_context.get("callback_url"): + self._workspace_context["callback_url"] = callback_url + self._oauth_resume_source = self._oauth_resume_source or f"callback_found_from_{name}_payload" + + resume_url = self._extract_resume_url_from_data(payload) + if resume_url: + self._remember_resume_candidate(f"{name}_continue_url", resume_url) + + def _remember_navigation_candidate( + self, + source_name: str, + url: Optional[str], + base_url: Optional[str] = None, + ) -> None: + candidate = str(url or "").strip() + if not candidate: + return + + if base_url: + import urllib.parse + + candidate = urllib.parse.urljoin(base_url, candidate) + + self._workspace_context.setdefault("navigation_urls", []).append( + {"source": source_name, "url": candidate} + ) + + if "code=" in candidate and "state=" in candidate and not self._workspace_context.get("callback_url"): + self._workspace_context["callback_url"] = candidate + if ( + not self._oauth_resume_source + or ( + "about_you" in source_name.lower() + and self._oauth_resume_source in { + "continue_url_resume", + "resume_url_found_after_validate_otp", + "resume_url_found_from_navigation", + } + ) + ): + self._oauth_resume_source = f"callback_found_from_{source_name}" + return + + resume_source = self._classify_resume_source(source_name, candidate) + if resume_source: + self._remember_resume_candidate(source_name, candidate) + if resume_source == "login_challenge_resume": + self._workspace_context["last_login_challenge_url"] = candidate + + def _extract_consent_script_asset_urls(self, base_url: str, text: Any) -> List[str]: + normalized = self._normalize_response_text(text) + if not normalized: + return [] + + import urllib.parse + + urls: List[str] = [] + seen = set() + for match in re.finditer(r']+src=["\']([^"\']+)["\']', normalized, re.IGNORECASE): + src = str(match.group(1) or "").strip() + if not src: + continue + candidate = urllib.parse.urljoin(base_url or "https://auth.openai.com", src) + parsed = urllib.parse.urlsplit(candidate) + if parsed.scheme not in {"http", "https"}: + continue + if parsed.netloc and parsed.netloc != "auth.openai.com": + continue + if candidate in seen: + continue + seen.add(candidate) + urls.append(candidate) + return urls + + def _inspect_consent_script_assets(self, source_name: str, final_url: str, response_text: Any) -> None: + script_urls = self._extract_consent_script_asset_urls(final_url, response_text) + if not script_urls: + self._log(f"Navigation source={source_name}: consent_script_assets=0") + return + + self._log( + "Navigation source=" + f"{source_name}: consent_script_assets={len(script_urls)} " + + " | ".join(self._sanitize_url_for_log(url) for url in script_urls[:6]) + ) + + import os + import urllib.parse + + hint_values: List[str] = [] + hint_seen = set() + for script_index, script_url in enumerate(script_urls[:6], start=1): + try: + headers = {"accept": "*/*"} + user_agent = self._get_session_user_agent() + if user_agent: + headers["user-agent"] = user_agent + if final_url: + headers["referer"] = final_url + + asset_response = self.session.get( + script_url, + headers=headers, + timeout=15, + ) + asset_text = self._normalize_response_text(getattr(asset_response, "text", "") or "") + if not asset_text: + continue + + asset_name = os.path.basename(urllib.parse.urlsplit(script_url).path) or f"asset-{script_index}" + asset_lower = asset_text.lower() + for token in ("clientaction", "workspace/select", "organization/select"): + if token not in asset_lower: + continue + hint = f"{asset_name}:{token}" + if hint in hint_seen: + continue + hint_seen.add(hint) + hint_values.append(hint) + + asset_candidates = self._extract_navigation_candidates_from_text(asset_text) + for candidate_index, candidate in enumerate(asset_candidates, start=1): + self._remember_navigation_candidate( + f"{source_name}_script_{script_index}_candidate_{candidate_index}", + candidate, + base_url=script_url, + ) + + asset_payloads = self._extract_embedded_payloads_from_text(asset_text) + for payload_index, payload in enumerate(asset_payloads, start=1): + self._remember_workspace_payload( + f"{source_name}_script_{script_index}_payload_{payload_index}", + payload, + ) + except Exception as e: + self._log( + "Navigation source=" + f"{source_name}: consent_script_fetch_failed=" + f"{self._sanitize_url_for_log(script_url)} error={e}", + "warning", + ) + + if hint_values: + self._log( + f"Navigation source={source_name}: consent_bundle_hints=" + + " | ".join(hint_values[:12]) + ) + + def _remember_navigation_from_response(self, source_name: str, response: Any) -> None: + if response is None: + return + + self._log( + f"Navigation source={source_name}: status={getattr(response, 'status_code', '-')}, " + f"url={self._sanitize_url_for_log(getattr(response, 'url', '') or '-')}" + ) + final_url = str(getattr(response, "url", "") or "").strip() + self._remember_navigation_candidate(f"{source_name}_response_url", final_url) + + headers = getattr(response, "headers", {}) or {} + location = headers.get("Location") if isinstance(headers, dict) else None + self._remember_navigation_candidate(f"{source_name}_location", location, base_url=final_url) + + history = getattr(response, "history", []) or [] + for idx, history_item in enumerate(history, start=1): + history_url = str(getattr(history_item, "url", "") or "").strip() + self._remember_navigation_candidate(f"{source_name}_history_{idx}_url", history_url) + history_headers = getattr(history_item, "headers", {}) or {} + history_location = history_headers.get("Location") if isinstance(history_headers, dict) else None + self._remember_navigation_candidate( + f"{source_name}_history_{idx}_location", + history_location, + base_url=history_url, + ) + + response_text = getattr(response, "text", "") or "" + text_candidates = self._extract_navigation_candidates_from_text(response_text) + if text_candidates: + self._log( + f"Navigation source={source_name}: text_candidates=" + f"{' | '.join(self._sanitize_url_for_log(candidate) for candidate in text_candidates[:6])}" + ) + for idx, candidate in enumerate(text_candidates, start=1): + self._remember_navigation_candidate(f"{source_name}_text_{idx}", candidate, base_url=final_url) + + app_router_fragments = self._extract_app_router_text_fragments_from_text(response_text) + if app_router_fragments: + self._log( + f"Navigation source={source_name}: app_router_fragments={len(app_router_fragments)}" + ) + + embedded_payloads = self._extract_embedded_payloads_from_text(response_text) + if embedded_payloads: + self._log( + f"Navigation source={source_name}: embedded_payloads={len(embedded_payloads)}" + ) + for idx, payload in enumerate(embedded_payloads, start=1): + self._remember_workspace_payload(f"{source_name}_text_payload_{idx}", payload) + + if self._is_consent_url(final_url): + normalized_text = self._normalize_response_text(response_text) + self._log( + f"Navigation source={source_name}: consent_diag text_len={len(normalized_text)}, " + f"text_candidates={len(text_candidates)}, " + f"embedded_payloads={len(embedded_payloads)}, " + f"app_router_fragments={len(app_router_fragments)}" + ) + self._inspect_consent_script_assets(source_name, final_url, response_text) + if normalized_text and not text_candidates and not embedded_payloads and not app_router_fragments: + excerpt = re.sub(r"\s+", " ", normalized_text)[:240] + self._log(f"Navigation source={source_name}: consent_excerpt={excerpt}") + + def _normalize_response_text(self, text: Any) -> str: + raw_text = str(text or "").strip() + if not raw_text: + return "" + + import html + import re + + normalized = html.unescape(raw_text) + normalized = re.sub( + r"\\+u([0-9a-fA-F]{4})", + lambda match: chr(int(match.group(1), 16)), + normalized, + ) + normalized = normalized.replace("\\/", "/") + return normalized + + def _extract_json_block_from_text(self, text: str, start_index: int) -> Optional[str]: + candidate = str(text or "") + if start_index < 0 or start_index >= len(candidate): + return None + + index = start_index + while index < len(candidate) and candidate[index].isspace(): + index += 1 + if index >= len(candidate) or candidate[index] not in "{[": + return None + + opening = candidate[index] + closing = "}" if opening == "{" else "]" + depth = 0 + in_string = False + escaped = False + + for cursor in range(index, len(candidate)): + char = candidate[cursor] + if in_string: + if escaped: + escaped = False + continue + if char == "\\": + escaped = True + continue + if char == '"': + in_string = False + continue + + if char == '"': + in_string = True + continue + if char == opening: + depth += 1 + continue + if char == closing: + depth -= 1 + if depth == 0: + return candidate[index:cursor + 1] + + return None + + def _parse_embedded_payload_candidate(self, payload_text: str) -> List[Dict[str, Any]]: + candidate = str(payload_text or "").strip() + if not candidate: + return [] + + try: + decoded = json.loads(candidate) + except Exception: + return [] + + if isinstance(decoded, dict): + return [decoded] + if isinstance(decoded, list): + return [item for item in decoded if isinstance(item, dict)] + return [] + + def _extract_embedded_payloads_from_text(self, text: Any) -> List[Dict[str, Any]]: + normalized = self._normalize_response_text(text) + if not normalized: + return [] + + import re + + payloads: List[Dict[str, Any]] = [] + seen = set() + + def remember(payload_text: str) -> None: + for payload in self._parse_embedded_payload_candidate(payload_text): + payload_key = json.dumps(payload, sort_keys=True, ensure_ascii=True) + if payload_key in seen: + continue + seen.add(payload_key) + payloads.append(payload) + + for match in re.finditer( + r']+id=["\']__NEXT_DATA__["\'][^>]*>(.*?)', + normalized, + re.IGNORECASE | re.DOTALL, + ): + remember(match.group(1)) + + assignment_patterns = [ + r'window\.__STATE__\s*=\s*', + r'window\.__NEXT_DATA__\s*=\s*', + r'__NEXT_DATA__\s*=\s*', + r'__STATE__\s*=\s*', + ] + for pattern in assignment_patterns: + for match in re.finditer(pattern, normalized): + payload_text = self._extract_json_block_from_text(normalized, match.end()) + if payload_text: + remember(payload_text) + + for payload_text in self._extract_app_router_push_payloads_from_text(normalized): + remember(payload_text) + + for fragment in self._extract_app_router_text_fragments_from_text(normalized): + remember(fragment) + for payload_text in self._extract_json_payload_candidates_from_fragment(fragment): + remember(payload_text) + + return payloads + + def _extract_navigation_candidates_from_text(self, text: Any) -> List[str]: + normalized = self._normalize_response_text(text) + if not normalized: + return [] + + import re + patterns = [ + r'["\']((?:https?://|/)[^"\']+)["\']', + r'((?:https?://|/)[^\s<>"\']+)', + ] + + candidates: List[str] = [] + seen = set() + + def remember_from_text_blob(candidate_text: str) -> None: + for pattern in patterns: + for match in re.finditer(pattern, candidate_text): + candidate = next((group for group in match.groups() if group), "").strip() + if not candidate: + continue + candidate = candidate.rstrip(".,;)}]\\") + if candidate in seen: + continue + if "code=" in candidate and "state=" in candidate: + seen.add(candidate) + candidates.append(candidate) + continue + if self._classify_resume_source("response_text", candidate): + seen.add(candidate) + candidates.append(candidate) + + remember_from_text_blob(normalized) + for fragment in self._extract_app_router_text_fragments_from_text(normalized): + remember_from_text_blob(fragment) + + return candidates + + def _extract_workspace_id_from_data(self, data: Any) -> Optional[str]: + if isinstance(data, dict): + workspaces = data.get("workspaces") + if isinstance(workspaces, list): + for workspace in workspaces: + if isinstance(workspace, dict): + workspace_id = str(workspace.get("id") or "").strip() + if workspace_id: + return workspace_id + workspace = data.get("workspace") + if isinstance(workspace, dict): + workspace_id = str(workspace.get("id") or "").strip() + if workspace_id: + return workspace_id + if isinstance(workspace, str) and workspace.strip(): + return workspace.strip() + for value in data.values(): + workspace_id = self._extract_workspace_id_from_data(value) + if workspace_id: + return workspace_id + elif isinstance(data, list): + for item in data: + workspace_id = self._extract_workspace_id_from_data(item) + if workspace_id: + return workspace_id + return None + + def _extract_callback_url_from_data(self, data: Any) -> Optional[str]: + if isinstance(data, dict): + for key, value in data.items(): + if isinstance(value, str) and "code=" in value and "state=" in value: + if key in {"callback_url", "url", "redirect_url", "location", "continue_url"}: + return value.strip() + callback_url = self._extract_callback_url_from_data(value) + if callback_url: + return callback_url + elif isinstance(data, list): + for item in data: + callback_url = self._extract_callback_url_from_data(item) + if callback_url: + return callback_url + return None + + def _extract_resume_url_from_data(self, data: Any) -> Optional[str]: + if isinstance(data, dict): + for key, value in data.items(): + if isinstance(value, str) and self._classify_resume_source(str(key), value): + return value.strip() + resume_url = self._extract_resume_url_from_data(value) + if resume_url: + return resume_url + elif isinstance(data, list): + for item in data: + resume_url = self._extract_resume_url_from_data(item) + if resume_url: + return resume_url + return None + + def _classify_resume_source(self, source_name: str, candidate: str) -> Optional[str]: + candidate_lower = str(candidate or "").strip().lower() + source_lower = str(source_name or "").strip().lower() + if not candidate_lower: + return None + if "login_challenge=" in candidate_lower: + return "login_challenge_resume" + if "consent_challenge=" in candidate_lower: + return "consent_challenge_resume" + if "/sign-in-with-chatgpt/" in candidate_lower or "/consent" in candidate_lower: + return "consent_resume" + if "/workspace/select" in candidate_lower: + return "workspace_select_resume" + if "/organization/select" in candidate_lower: + return "organization_select_resume" + if "continue_url" in source_lower: + return "continue_url_resume" + if "/api/oauth/oauth2/auth" in candidate_lower: + if "validate_otp" in source_lower: + return "resume_url_found_after_validate_otp" + if "login_password" in source_lower: + return "resume_url_reused_from_login_password" + return "resume_url_found_from_navigation" + return None + + def _is_login_page_url(self, url: Optional[str]) -> bool: + candidate = str(url or "").strip().lower() + if not candidate: + return False + return candidate.endswith("/log-in") or "/log-in?" in candidate or "auth.openai.com/log-in" in candidate + + def _is_consent_url(self, url: Optional[str]) -> bool: + candidate = str(url or "").strip().lower() + if not candidate: + return False + return "/sign-in-with-chatgpt/" in candidate or "/consent" in candidate + + def _mark_workspace_resolution(self, source: str, error: Optional[str] = None) -> None: + self._workspace_resolution_source = source + self._workspace_resolution_error = error + + def _get_workspace_id(self) -> Optional[str]: + """获取 Workspace ID""" + try: + auth_cookie = self.session.cookies.get("oai-client-auth-session") + if auth_cookie: + decoded_segments, failed_segments = self._decode_auth_cookie_json_segments(auth_cookie) + self._log( + f"Workspace 解析: auth_cookie decoded_segments={len(decoded_segments)}, " + f"failed_segments={failed_segments}" + ) + for payload in decoded_segments: + workspace_id = self._extract_workspace_id_from_data(payload) + if workspace_id: + self._mark_workspace_resolution("auth_cookie") + self._log(f"Workspace ID: {workspace_id}") + return workspace_id + + if decoded_segments: + self._mark_workspace_resolution( + "auth_cookie_decoded_without_workspace", + "获取 Workspace ID 失败", + ) + if failed_segments: + self._log("授权 Cookie 仅部分可解码,且没有 workspace 信息", "warning") + else: + self._log("授权 Cookie 里没有 workspace 信息", "warning") + else: + self._mark_workspace_resolution("auth_cookie_unreadable", "获取 Workspace ID 失败") + self._log("授权 Cookie 无法解码成 JSON", "warning") + else: + self._mark_workspace_resolution("auth_cookie_missing", "获取 Workspace ID 失败") + self._log("未能获取到授权 Cookie", "warning") + + next_auth_cookie = self.session.cookies.get("__Secure-next-auth.session-token") + if next_auth_cookie: + self._log("Workspace 解析: 检查 next-auth session token") + for segment in next_auth_cookie.split("."): + payload = _decode_jwt_segment(segment) + workspace_id = self._extract_workspace_id_from_data(payload) + if workspace_id: + self._mark_workspace_resolution("next_auth_session_token") + self._log(f"Workspace ID: {workspace_id}") + return workspace_id + + for cookie_name in ["auth-session-minimized", "unified_session_manifest"]: + cookie_value = self.session.cookies.get(cookie_name) + if not cookie_value: + continue + + payloads = self._decode_cookie_json_payloads(cookie_value) + self._log( + f"Workspace 解析: 检查 {cookie_name}, decoded_payloads={len(payloads)}" + ) + for payload in payloads: + workspace_id = self._extract_workspace_id_from_data(payload) + if workspace_id: + self._mark_workspace_resolution(cookie_name) + self._log(f"Workspace ID: {workspace_id}") + return workspace_id + if payloads: + key_preview = " | ".join( + ",".join(sorted(payload.keys())[:6]) or "-" + for payload in payloads[:2] + if isinstance(payload, dict) + ) + if key_preview: + self._log( + f"Workspace 解析: {cookie_name} payload keys={key_preview}" + ) + + for source_name, payload in self._workspace_context.get("payloads", {}).items(): + workspace_id = self._extract_workspace_id_from_data(payload) + if workspace_id: + self._mark_workspace_resolution(f"{source_name}_payload") + self._log(f"Workspace ID: {workspace_id}") + return workspace_id + if self._workspace_context.get("payloads"): + self._log( + "Workspace 解析: payload 来源未命中 " + + ",".join(sorted(self._workspace_context.get("payloads", {}).keys())) + ) + + resolved_workspace_id = str(self._workspace_context.get("resolved_workspace_id") or "").strip() + if resolved_workspace_id: + self._mark_workspace_resolution("resolved_workspace_cache") + self._log(f"Workspace ID: {resolved_workspace_id}") + return resolved_workspace_id + + token_info = self._workspace_context.get("token_info") or {} + id_token = token_info.get("id_token") or "" + if id_token: + self._log("Workspace 解析: 检查 id_token claims") + claims = _jwt_claims_no_verify(id_token) + workspace_id = self._extract_workspace_id_from_data(claims) + if workspace_id: + self._mark_workspace_resolution("id_token_claims") + self._log(f"Workspace ID: {workspace_id}") + return workspace_id + + if self._workspace_context.get("callback_url"): + self._mark_workspace_resolution("callback_available_before_workspace", "获取 Workspace ID 失败") + self._log("已经拿到 callback URL,但还没有 workspace 信息", "warning") + self._set_recovery_debug_summary( + "callback_without_workspace", + callback_url=self._workspace_context.get("callback_url"), + ) + return None + + if not self._workspace_resolution_source: + self._mark_workspace_resolution("workspace_not_found", "获取 Workspace ID 失败") + self._log("当前所有已知来源都没有 workspace 信息", "error") + self._set_recovery_debug_summary( + "workspace_not_found", + resolution_source=self._workspace_resolution_source, + terminal_url=self._workspace_context.get("redirect_terminal_url"), + ) + return None + + except Exception as e: + self._mark_workspace_resolution("workspace_lookup_exception", str(e)) + self._log(f"获取 Workspace ID 失败: {e}", "error") + return None + + def _select_workspace(self, workspace_id: str) -> Optional[str]: + """选择 Workspace""" + try: + import urllib.parse + + select_body = f'{{"workspace_id":"{workspace_id}"}}' + + response = self.session.post( + OPENAI_API_ENDPOINTS["select_workspace"], + headers=self._build_oauth_json_headers( + "https://auth.openai.com/sign-in-with-chatgpt/codex/consent" + ), + data=select_body, + ) + + try: + response_data = response.json() + except Exception: + response_data = None + + self._remember_workspace_payload("select_workspace", response_data) + self._remember_navigation_from_response("select_workspace", response) + + callback_url = str(self._workspace_context.get("callback_url") or "").strip() + if callback_url: + return callback_url + + if response.status_code in [301, 302, 303, 307, 308]: + location = str((response.headers or {}).get("Location") or "").strip() + if location: + return urllib.parse.urljoin(OPENAI_API_ENDPOINTS["select_workspace"], location) + + if response.status_code != 200: + self._log(f"选择 workspace 失败: {response.status_code}", "error") + self._log(f"响应: {response.text[:200]}", "warning") + return None + + continue_url = str((response_data or {}).get("continue_url") or "").strip() + if not continue_url: + self._log("workspace/select 响应里缺少 continue_url", "error") + return None + + self._workspace_context["continue_url"] = continue_url + self._log(f"Continue URL: {continue_url[:100]}...") + return continue_url + + except Exception as e: + self._log(f"选择 Workspace 失败: {e}", "error") + return None + + def _extract_org_selection_from_data(self, data: Any) -> Tuple[Optional[str], Optional[str]]: + if isinstance(data, dict): + orgs = data.get("orgs") + if isinstance(orgs, list): + for org in orgs: + if not isinstance(org, dict): + continue + org_id = str(org.get("id") or "").strip() + project_id = None + projects = org.get("projects") + if isinstance(projects, list): + for project in projects: + if not isinstance(project, dict): + continue + project_id = str(project.get("id") or "").strip() or None + if project_id: + break + if org_id: + return org_id, project_id + for value in data.values(): + org_id, project_id = self._extract_org_selection_from_data(value) + if org_id: + return org_id, project_id + elif isinstance(data, list): + for item in data: + org_id, project_id = self._extract_org_selection_from_data(item) + if org_id: + return org_id, project_id + return None, None + + def _select_organization( + self, + org_id: str, + project_id: Optional[str] = None, + *, + referer: Optional[str] = None, + ) -> Optional[str]: + try: + import urllib.parse + + body: Dict[str, Any] = {"org_id": org_id} + if project_id: + body["project_id"] = project_id + + response = self.session.post( + "https://auth.openai.com/api/accounts/organization/select", + headers=self._build_oauth_json_headers( + referer or "https://auth.openai.com/sign-in-with-chatgpt/codex/consent" + ), + data=json.dumps(body), + ) + + try: + response_data = response.json() + except Exception: + response_data = None + + self._remember_workspace_payload("select_organization", response_data) + self._remember_navigation_from_response("select_organization", response) + + callback_url = str(self._workspace_context.get("callback_url") or "").strip() + if callback_url: + return callback_url + + if response.status_code in [301, 302, 303, 307, 308]: + location = str((response.headers or {}).get("Location") or "").strip() + if location: + return urllib.parse.urljoin( + "https://auth.openai.com/api/accounts/organization/select", + location, + ) + + if response.status_code != 200: + self._log(f"选择 organization 失败: {response.status_code}", "error") + self._log(f"响应: {response.text[:200]}", "warning") + return None + + continue_url = str((response_data or {}).get("continue_url") or "").strip() + if continue_url: + self._workspace_context["continue_url"] = continue_url + self._log(f"Organization Continue URL: {continue_url[:100]}...") + return continue_url + + self._log("organization/select 响应里缺少 continue_url", "error") + return None + + except Exception as e: + self._log(f"选择 Organization 失败: {e}", "error") + return None + + def _probe_callback_via_allow_redirects( + self, + url: str, + *, + referer: Optional[str] = None, + ) -> Optional[str]: + try: + import re + + headers = { + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Upgrade-Insecure-Requests": "1", + } + session_headers = getattr(self.session, "headers", None) or {} + user_agent = session_headers.get("User-Agent") or session_headers.get("user-agent") + if user_agent: + headers["User-Agent"] = user_agent + if referer: + headers["Referer"] = referer + + response = self.session.get( + url, + headers=headers, + allow_redirects=True, + timeout=15, + ) + self._remember_navigation_from_response("allow_redirect_probe", response) + callback_url = str(self._workspace_context.get("callback_url") or "").strip() + if callback_url: + self._log( + "allow_redirect probe 命中 callback: " + f"{self._sanitize_url_for_log(callback_url)}" + ) + return callback_url + except Exception as e: + import re + + maybe_localhost = re.search(r'(https?://localhost[^\s\'"]+)', str(e)) + if maybe_localhost: + callback_url = maybe_localhost.group(1) + self._remember_navigation_candidate("allow_redirect_probe_exception", callback_url) + self._log( + "allow_redirect probe 从异常中命中 callback: " + f"{self._sanitize_url_for_log(callback_url)}" + ) + return callback_url + self._log(f"allow_redirect probe 失败: {e}", "warning") + + return str(self._workspace_context.get("callback_url") or "").strip() or None + + def _continue_from_workspace_selection(self, workspace_id: str) -> Optional[str]: + continue_url = self._select_workspace(workspace_id) + callback_url = str(self._workspace_context.get("callback_url") or "").strip() + if callback_url: + return callback_url + if not continue_url: + return None + + select_payload = self._workspace_context.get("payloads", {}).get("select_workspace") or {} + org_id, project_id = self._extract_org_selection_from_data(select_payload) + page_type = "" + if isinstance(select_payload, dict): + page_type = str((select_payload.get("page") or {}).get("type") or "").strip().lower() + payload_text = "" + if select_payload: + try: + payload_text = json.dumps(select_payload, ensure_ascii=True).lower() + except Exception: + payload_text = str(select_payload).lower() + + requires_org_selection = bool(org_id) and ( + "organization" in str(continue_url).lower() + or "organization" in page_type + or "organization" in payload_text + ) + if requires_org_selection: + self._log("workspace/select 响应提示还需要 organization 选择,继续补齐") + continue_url = self._select_organization(org_id, project_id, referer=continue_url) + callback_url = str(self._workspace_context.get("callback_url") or "").strip() + if callback_url: + return callback_url + if not continue_url: + return None + + callback_url = self._follow_redirects(continue_url) + if callback_url: + return callback_url + callback_url = self._probe_callback_via_allow_redirects(continue_url, referer=continue_url) + if callback_url: + return callback_url + return str(self._workspace_context.get("callback_url") or "").strip() or None + + def _attempt_direct_consent_recovery(self, consent_url: Optional[str] = None) -> Optional[str]: + import urllib.parse + + consent_url = str(consent_url or "https://auth.openai.com/sign-in-with-chatgpt/codex/consent").strip() + if consent_url.startswith("/"): + consent_url = urllib.parse.urljoin("https://auth.openai.com", consent_url) + self._oauth_resume_source = "direct_consent_fallback" + self._log( + "当前会话命中 consent 链路,尝试 direct consent fallback: " + f"{self._sanitize_url_for_log(consent_url)}", + "warning", + ) + + callback_url = self._follow_redirects(consent_url) + if callback_url: + self._set_recovery_debug_summary( + "direct_consent_callback_resolved", + consent_url=consent_url, + callback_url=callback_url, + ) + return callback_url + + callback_url = str(self._workspace_context.get("callback_url") or "").strip() + if callback_url: + self._set_recovery_debug_summary( + "direct_consent_cached_callback_resolved", + consent_url=consent_url, + callback_url=callback_url, + ) + return callback_url + + callback_url = self._probe_callback_via_allow_redirects(consent_url, referer=consent_url) + if callback_url: + self._set_recovery_debug_summary( + "direct_consent_allow_redirect_callback_resolved", + consent_url=consent_url, + callback_url=callback_url, + ) + return callback_url + + workspace_id = self._get_workspace_id() + if workspace_id: + self._workspace_context["resolved_workspace_id"] = workspace_id + callback_url = self._continue_from_workspace_selection(workspace_id) + if callback_url: + self._set_recovery_debug_summary( + "direct_consent_workspace_resolved", + consent_url=consent_url, + workspace_id=workspace_id, + callback_url=callback_url, + ) + return callback_url + + self._set_recovery_debug_summary( + "direct_consent_fallback_failed", + consent_url=consent_url, + resolution_source=self._workspace_resolution_source, + terminal_url=self._workspace_context.get("redirect_terminal_url"), + ) + self._log("direct consent fallback 未拿到 callback/workspace", "warning") + return None + + def _continue_same_session_after_resume(self, label: str) -> Optional[str]: + callback_url = str(self._workspace_context.get("callback_url") or "").strip() + if callback_url: + self._set_recovery_debug_summary( + f"{label}_cached_callback_resolved", + callback_url=callback_url, + ) + return callback_url + + workspace_id = self._get_workspace_id() + if workspace_id: + self._workspace_context["resolved_workspace_id"] = workspace_id + self._log( + f"{label}: 当前会话已拿到 workspace={workspace_id},继续走 workspace/select" + ) + callback_url = self._continue_from_workspace_selection(workspace_id) + if callback_url: + self._set_recovery_debug_summary( + f"{label}_workspace_resolved", + workspace_id=workspace_id, + callback_url=callback_url, + ) + return callback_url + + consent_url = self._find_cached_resume_candidate("consent_resume") + if consent_url: + self._log( + f"{label}: 当前会话命中 consent 候选,继续同会话恢复: " + f"{self._sanitize_url_for_log(consent_url)}", + "warning", + ) + callback_url = self._attempt_direct_consent_recovery(consent_url) + if callback_url: + return callback_url + + return None + + def _follow_redirects(self, start_url: str) -> Optional[str]: + """跟随重定向链,寻找回调 URL""" + try: + import urllib.parse + + current_url = start_url + referer_url: Optional[str] = None + max_redirects = 6 + self._workspace_context["redirect_terminal_url"] = None + self._workspace_context["redirect_terminal_status"] = None + self._workspace_context["reentered_login"] = False + + for i in range(max_redirects): + self._remember_navigation_candidate(f"redirect_{i+1}_request_url", current_url) + self._log( + f"重定向 {i+1}/{max_redirects}: {self._sanitize_url_for_log(current_url)[:160]}" + ) + + headers = { + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Upgrade-Insecure-Requests": "1", + } + session_headers = getattr(self.session, "headers", None) or {} + user_agent = session_headers.get("User-Agent") or session_headers.get("user-agent") + if user_agent: + headers["User-Agent"] = user_agent + if referer_url: + headers["Referer"] = referer_url + + response = self.session.get( + current_url, + headers=headers, + allow_redirects=False, + timeout=15 + ) + self._remember_navigation_from_response(f"redirect_{i+1}", response) + + final_url = str(getattr(response, "url", "") or current_url).strip() or current_url + referer_url = final_url + self._workspace_context["redirect_terminal_url"] = final_url + self._workspace_context["redirect_terminal_status"] = response.status_code + self._log( + f"重定向响应: status={response.status_code}, final={self._sanitize_url_for_log(final_url) or '-'}" + ) + + callback_url = str(self._workspace_context.get("callback_url") or "").strip() + if callback_url: + self._set_recovery_debug_summary( + "callback_found_in_redirect_chain", + start_url=start_url, + callback_url=callback_url, + ) + return callback_url + + location = response.headers.get("Location") or "" + + # 如果不是重定向状态码,停止 + if response.status_code not in [301, 302, 303, 307, 308]: + login_challenge_url = self._find_cached_resume_candidate("login_challenge_resume") + started_from_login_challenge = ( + self._classify_resume_source("start_url", start_url) == "login_challenge_resume" + ) + if self._is_login_page_url(final_url) and (not login_challenge_url or started_from_login_challenge): + self._workspace_context["reentered_login"] = True + self._set_recovery_debug_summary( + "bare_login_reentry", + start_url=start_url, + terminal_url=final_url, + ) + elif self._is_login_page_url(final_url): + self._set_recovery_debug_summary( + "login_reentry_with_cached_login_challenge", + start_url=start_url, + terminal_url=final_url, + login_challenge_url=login_challenge_url, + ) + return None + + if not location: + login_challenge_url = self._find_cached_resume_candidate("login_challenge_resume") + started_from_login_challenge = ( + self._classify_resume_source("start_url", start_url) == "login_challenge_resume" + ) + if self._is_login_page_url(final_url) and (not login_challenge_url or started_from_login_challenge): + self._workspace_context["reentered_login"] = True + self._set_recovery_debug_summary( + "bare_login_reentry_without_location", + start_url=start_url, + terminal_url=final_url, + ) + elif self._is_login_page_url(final_url): + self._set_recovery_debug_summary( + "login_reentry_with_cached_login_challenge", + start_url=start_url, + terminal_url=final_url, + login_challenge_url=login_challenge_url, + ) + return None + + # 构建下一个 URL + next_url = urllib.parse.urljoin(current_url, location) + self._workspace_context.setdefault("redirect_locations", []).append(next_url) + self._remember_navigation_candidate(f"redirect_{i+1}_next_url", next_url) + self._log(f"重定向目标: {self._sanitize_url_for_log(next_url)}") + + callback_url = str(self._workspace_context.get("callback_url") or "").strip() + if callback_url: + self._set_recovery_debug_summary( + "callback_found_from_redirect_location", + start_url=start_url, + callback_url=callback_url, + ) + return callback_url + + current_url = next_url + + self._log("未能在重定向链中找到回调 URL", "error") + self._set_recovery_debug_summary( + "redirect_chain_exhausted", + start_url=start_url, + terminal_url=self._workspace_context.get("redirect_terminal_url"), + ) + return None + + except Exception as e: + self._log(f"跟随重定向失败: {e}", "error") + return None + + def _handle_oauth_callback(self, callback_url: str) -> Optional[Dict[str, Any]]: + """处理 OAuth 回调""" + try: + if not self.oauth_start: + self._log("OAuth 流程未初始化", "error") + return None + + self._log("处理 OAuth 回调,最后一哆嗦,稳住别抖...") + token_info = self.oauth_manager.handle_callback( + callback_url=callback_url, + expected_state=self.oauth_start.state, + code_verifier=self.oauth_start.code_verifier + ) + + self._workspace_context["token_info"] = token_info + self._log("OAuth 授权成功,通关文牒到手") + return token_info + + except Exception as e: + self._log(f"处理 OAuth 回调失败: {e}", "error") + return None + + def _is_recoverable_outlook_account(self, account) -> bool: + if not account: + return False + if account.email_service != EmailServiceType.OUTLOOK.value: + return False + if account.status != "failed": + return False + extra_data = account.extra_data or {} + return ( + extra_data.get("register_failed_reason") == "token_recovery_pending" + and extra_data.get("recovery_ready") is True + and bool(account.password) + ) + + def _load_recoverable_outlook_account(self) -> Optional[Dict[str, Any]]: + if self.email_service.service_type != EmailServiceType.OUTLOOK or not self.email: + return None + + try: + with get_db() as db: + account = crud.get_account_by_email(db, self.email) + if not self._is_recoverable_outlook_account(account): + return None + extra_data = dict(account.extra_data or {}) + return { + "id": account.id, + "email": account.email, + "password": account.password, + "account_created": bool(extra_data.get("account_created")), + } + except Exception as e: + self._log(f"检查 Outlook 可恢复账号失败: {e}", "warning") + return None + + def _persist_recoverable_outlook_account(self, error_message: str) -> bool: + if self.email_service.service_type != EmailServiceType.OUTLOOK: + return False + if not self.email or not self.password: + return False + if not self._account_created and not self._recovery_mode: + return False + + try: + with get_db() as db: + existing = crud.get_account_by_email(db, self.email) + base_extra = dict(existing.extra_data or {}) if existing else {} + workspace_resolution_source = ( + self._workspace_resolution_source + or base_extra.get("last_workspace_resolution_source") + ) + workspace_resolution_error = ( + self._workspace_resolution_error + or base_extra.get("last_workspace_resolution_error") + ) + base_extra.update( + { + "recovery_ready": True, + "account_created": True, + "token_acquired": False, + "workspace_acquired": False, + "register_failed_reason": "token_recovery_pending", + "last_recovery_error": error_message, + "last_otp_stage": self._otp_stage, + "last_oauth_resume_source": self._oauth_resume_source + or base_extra.get("last_oauth_resume_source"), + "last_workspace_resolution_source": workspace_resolution_source, + "last_workspace_resolution_error": workspace_resolution_error, + "last_recovery_debug_summary": self._workspace_context.get("last_recovery_debug_summary") + or base_extra.get("last_recovery_debug_summary"), + } + ) + + if existing: + crud.update_account( + db, + existing.id, + password=self.password, + client_id=get_settings().openai_client_id, + email_service=self.email_service.service_type.value, + email_service_id=self.email_info.get("service_id") + if self.email_info + else existing.email_service_id, + proxy_used=self.proxy_url, + status="failed", + extra_data=base_extra, + source=existing.source or "register", + ) + self._recovery_account_id = existing.id + return True + + account = crud.create_account( + db, + email=self.email, + password=self.password, + client_id=get_settings().openai_client_id, + email_service=self.email_service.service_type.value, + email_service_id=self.email_info.get("service_id") if self.email_info else None, + proxy_used=self.proxy_url, + extra_data=base_extra, + status="failed", + source="register", + ) + self._recovery_account_id = account.id + return True + except Exception as e: + self._log(f"保存可恢复 Outlook 账号失败: {e}", "warning") + return False + + def _fail_with_recovery(self, result: RegistrationResult, error_message: str) -> RegistrationResult: + result.error_message = error_message + self._persist_recoverable_outlook_account(error_message) + return result + + def _prepare_relogin_otp_flow(self, label: str) -> None: + self._token_acquisition_requires_login = True + self._set_verification_stage("relogin_otp") + if self.email_service.service_type == EmailServiceType.OUTLOOK: + resend_ok = self._send_verification_code( + stage="relogin_otp", + referer="https://auth.openai.com/log-in/password", + allow_failure=True, + ) + if not resend_ok: + self._log(f"{label}: relogin_otp resend failed, falling back to polling", "warning") + + def _start_saved_password_recovery(self) -> Tuple[bool, str]: + self._recovery_mode = True + self._is_existing_account = True + 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, screen_hint="login") + 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'}" + + self._prepare_relogin_otp_flow("恢复登录") + return True, "" + + def _attempt_session_bound_reauth(self) -> Optional[str]: + if self._session_bound_reauth_attempted: + self._oauth_resume_source = "session_bound_reauth_exhausted" + self._set_recovery_debug_summary( + "session_bound_reauth_exhausted", + terminal_url=self._workspace_context.get("redirect_terminal_url"), + ) + self._log("会话内重登已尝试过一次,停止继续恢复", "warning") + return None + + self._session_bound_reauth_attempted = True + self._oauth_resume_source = "session_bound_reauth" + self._reset_workspace_context() + self._log("恢复链路回到裸登录页,尝试在当前会话内重做登录", "warning") + + if not self.session and not self._init_session(): + self._set_recovery_debug_summary("session_bound_reauth_session_init_failed") + return None + + did = self.session.cookies.get("oai-did") if self.session else None + reused_did = bool(did) + if reused_did: + self._log(f"会话内重登: 复用当前 Device ID {did}") + else: + if not self.oauth_start: + self._oauth_resume_source = "session_bound_reauth_missing_oauth_start" + self._set_recovery_debug_summary("session_bound_reauth_missing_oauth_start") + self._log("会话内重登缺少 OAuth 上下文,无法补取 Device ID", "warning") + return None + self._log("会话内重登: 当前会话没有 Device ID,补取一次") + did = self._get_device_id() + if not did: + self._oauth_resume_source = "session_bound_reauth_device_id_failed" + self._set_recovery_debug_summary("session_bound_reauth_device_id_failed") + return None + + sen_token = self._check_sentinel(did) + if not sen_token: + self._oauth_resume_source = "session_bound_reauth_sentinel_failed" + self._set_recovery_debug_summary( + "session_bound_reauth_sentinel_failed", + reused_did=reused_did, + ) + return None + + login_start_result = self._submit_login_start(did, sen_token) + if not login_start_result.success: + self._oauth_resume_source = "session_bound_reauth_login_start_failed" + self._set_recovery_debug_summary( + "session_bound_reauth_login_start_failed", + reused_did=reused_did, + error=login_start_result.error_message, + ) + return None + + if login_start_result.page_type == OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]: + password_result = self._submit_login_password() + if not password_result.success: + self._oauth_resume_source = "session_bound_reauth_password_failed" + self._set_recovery_debug_summary( + "session_bound_reauth_password_failed", + reused_did=reused_did, + error=password_result.error_message, + ) + return None + if not password_result.is_existing_account: + self._oauth_resume_source = "session_bound_reauth_failed_to_reach_otp" + self._set_recovery_debug_summary( + "session_bound_reauth_failed_to_reach_otp", + page_type=password_result.page_type, + ) + return None + elif login_start_result.page_type != OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]: + self._oauth_resume_source = "session_bound_reauth_failed_to_reach_otp" + self._set_recovery_debug_summary( + "session_bound_reauth_failed_to_reach_otp", + page_type=login_start_result.page_type, + ) + return None + + self._session_bound_reauth_otp_cycles += 1 + if self._session_bound_reauth_otp_cycles > 1: + self._oauth_resume_source = "session_bound_reauth_exhausted" + self._set_recovery_debug_summary("session_bound_reauth_exhausted", reason="otp_cycle_limit") + self._log("会话内重登 OTP 已达到上限,停止继续恢复", "warning") + return None + + self._prepare_relogin_otp_flow("会话内重登") + code = self._get_verification_code( + stage="relogin_otp", + timeout=self._get_verification_timeout("relogin_otp"), + ) + if not code: + self._oauth_resume_source = "session_bound_reauth_otp_timeout" + self._set_recovery_debug_summary("session_bound_reauth_otp_timeout") + return None + + if not self._validate_verification_code(code): + self._oauth_resume_source = "session_bound_reauth_validate_failed" + self._set_recovery_debug_summary("session_bound_reauth_validate_failed") + return None + + self._oauth_resume_source = "session_bound_reauth" + callback_url, _workspace_id, resolution_error = self._resolve_oauth_callback_url() + if callback_url: + self._oauth_resume_source = "session_bound_reauth" + self._set_recovery_debug_summary( + "session_bound_reauth_callback_resolved", + callback_url=callback_url, + reused_did=reused_did, + ) + return callback_url + + if self._workspace_context.get("reentered_login"): + self._oauth_resume_source = "session_bound_reauth_reentered_login" + self._set_recovery_debug_summary( + "session_bound_reauth_reentered_login", + terminal_url=self._workspace_context.get("redirect_terminal_url"), + reused_did=reused_did, + ) + return None + + self._oauth_resume_source = "session_bound_reauth_failed" + self._set_recovery_debug_summary( + "session_bound_reauth_failed", + error=resolution_error, + reused_did=reused_did, + ) + return None + + def _attempt_authorize_replay( + self, + *, + allow_session_bound_reauth: bool = True, + reentry_log_level: str = "error", + ) -> Optional[str]: + if not self.oauth_start: + self._oauth_resume_source = "authorize_replay_failed" + self._log("OAuth 续跑失败:当前没有可重放的 authorize URL", "error") + return None + + callback_url = self._follow_redirects(self.oauth_start.auth_url) + if callback_url: + if self._oauth_resume_source != "session_bound_reauth": + self._oauth_resume_source = "callback_found_from_authorize_replay" + return callback_url + + login_challenge_url = self._find_cached_resume_candidate("login_challenge_resume") + if login_challenge_url: + if self._oauth_resume_source != "session_bound_reauth": + self._oauth_resume_source = "login_challenge_resume" + self._log( + "authorize replay 后拿到 login_challenge,尝试二次恢复: " + f"{self._sanitize_url_for_log(login_challenge_url)}" + ) + callback_url = self._follow_redirects(login_challenge_url) + if callback_url: + return callback_url + + if self._workspace_context.get("reentered_login"): + if allow_session_bound_reauth: + callback_url = self._attempt_session_bound_reauth() + if callback_url: + return callback_url + if self._oauth_resume_source != "session_bound_reauth_reentered_login": + self._oauth_resume_source = "fresh_authorize_replay_reentered_login" + self._log("OAuth 恢复链路重新进入登录页,无法继续当前流程", reentry_log_level) + return None + + self._oauth_resume_source = "authorize_replay_failed" + self._log("OAuth 续跑失败:重放 authorize 链路后仍未拿到 callback", "error") + return None + + def _resume_oauth_callback(self) -> Optional[str]: + callback_url = str(self._workspace_context.get("callback_url") or "").strip() + if callback_url: + self._oauth_resume_source = self._oauth_resume_source or "callback_found_from_cached_navigation" + self._set_recovery_debug_summary("cached_callback_available", callback_url=callback_url) + return callback_url + + resume_url = str(self._workspace_context.get("resume_url") or "").strip() + if resume_url: + resume_source = str(self._workspace_context.get("resume_source") or "").strip() + if not resume_source: + resume_source = ( + self._classify_resume_source("cached_resume_url", resume_url) + or "resume_url_found_after_validate_otp" + ) + self._oauth_resume_source = resume_source + self._log( + f"尝试沿缓存的 OAuth 恢复链路继续: {resume_source} -> " + f"{self._sanitize_url_for_log(resume_url)}" + ) + callback_url = self._follow_redirects(resume_url) + if callback_url: + return callback_url + callback_url = self._continue_same_session_after_resume("resume_url_follow_up") + if callback_url: + return callback_url + upgraded_resume_url = str(self._workspace_context.get("resume_url") or "").strip() + if ( + upgraded_resume_url + and upgraded_resume_url != resume_url + and not self._is_about_you_url(upgraded_resume_url) + ): + upgraded_resume_source = ( + str(self._workspace_context.get("resume_source") or "").strip() + or self._classify_resume_source("upgraded_resume_url", upgraded_resume_url) + or "resume_url_found_from_navigation" + ) + if self._oauth_resume_source != "session_bound_reauth": + self._oauth_resume_source = upgraded_resume_source + self._log( + "恢复链路在页面提取后升级续跑 URL,继续尝试: " + f"{upgraded_resume_source} -> {self._sanitize_url_for_log(upgraded_resume_url)}" + ) + callback_url = self._follow_redirects(upgraded_resume_url) + if callback_url: + return callback_url + callback_url = self._continue_same_session_after_resume("upgraded_resume_follow_up") + if callback_url: + return callback_url + login_challenge_url = self._find_cached_resume_candidate("login_challenge_resume") + if login_challenge_url and login_challenge_url != resume_url: + if self._oauth_resume_source != "session_bound_reauth": + self._oauth_resume_source = "login_challenge_resume" + self._log( + "恢复链路命中过 login_challenge,中转页再次续跑: " + f"{self._sanitize_url_for_log(login_challenge_url)}" + ) + callback_url = self._follow_redirects(login_challenge_url) + if callback_url: + return callback_url + callback_url = self._continue_same_session_after_resume("login_challenge_follow_up") + if callback_url: + return callback_url + if self._workspace_context.get("reentered_login"): + callback_url = self._attempt_session_bound_reauth() + if callback_url: + return callback_url + self._log("OAuth 恢复链路重新进入登录页,停止继续恢复", "warning") + return None + + return self._attempt_authorize_replay() + + def _activate_recovered_account(self, result: RegistrationResult) -> bool: + if not self._recovery_mode: + return False + + try: + with get_db() as db: + account = crud.get_account_by_email(db, result.email) + if not account: + return False + + merged_extra = dict(account.extra_data or {}) + merged_extra.update( + { + "email_service": self.email_service.service_type.value, + "proxy_used": self.proxy_url, + "registered_at": datetime.now().isoformat(), + "is_existing_account": True, + "token_acquired_via_relogin": self._token_acquisition_requires_login, + "recovery_ready": False, + "account_created": True, + "token_acquired": True, + "workspace_acquired": bool(result.workspace_id), + "last_oauth_resume_source": self._oauth_resume_source + or merged_extra.get("last_oauth_resume_source"), + "last_workspace_resolution_source": self._workspace_resolution_source + or merged_extra.get("last_workspace_resolution_source"), + "last_workspace_resolution_error": self._workspace_resolution_error + or merged_extra.get("last_workspace_resolution_error"), + "last_recovery_debug_summary": self._workspace_context.get("last_recovery_debug_summary") + or merged_extra.get("last_recovery_debug_summary"), + } + ) + + account.password = result.password + account.client_id = get_settings().openai_client_id + account.session_token = result.session_token + account.email_service = self.email_service.service_type.value + account.email_service_id = ( + self.email_info.get("service_id") if self.email_info else account.email_service_id + ) + account.account_id = result.account_id + account.workspace_id = result.workspace_id + account.access_token = result.access_token + account.refresh_token = result.refresh_token + account.id_token = result.id_token + account.proxy_used = self.proxy_url + account.status = "active" + account.extra_data = merged_extra + account.source = account.source or result.source + db.commit() + db.refresh(account) + result.metadata = merged_extra + result.metadata["database_saved"] = True + return True + except Exception as e: + self._log(f"更新恢复账号失败: {e}", "warning") + return False + + def run(self) -> RegistrationResult: + result = RegistrationResult(success=False, logs=self.logs) + + try: + self._is_existing_account = False + self._token_acquisition_requires_login = False + self._otp_stage = "signup_otp" + self._otp_sent_at = None + self._last_registration_error = None + self._account_created = False + self._about_you_resume_attempts = 0 + self._about_you_user_exists_without_resume_attempts = 0 + self._recovery_mode = False + self._recovery_account_id = None + self._session_bound_reauth_attempted = False + self._session_bound_reauth_otp_cycles = 0 + self._reset_workspace_context() + + ip_ok, location = self._check_ip_location() + if not ip_ok: + result.error_message = f"IP 地理位置不支持: {location}" + return result + + if not self._create_email(): + result.error_message = "创建邮箱失败" + return result + + result.email = self.email + + recoverable_account = self._load_recoverable_outlook_account() + if recoverable_account: + self._recovery_account_id = recoverable_account["id"] + self.password = recoverable_account["password"] + self._account_created = bool(recoverable_account.get("account_created")) + login_ready, login_error = self._start_saved_password_recovery() + if not login_ready: + return self._fail_with_recovery(result, login_error) + else: + did, sen_token = self._prepare_authorize_flow("首次授权") + if not did: + result.error_message = "获取 Device ID 失败" + return result + if not sen_token: + result.error_message = "Sentinel POW 验证失败" + return result + + 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: + password_ok, _ = self._register_password(did, sen_token) + if not password_ok: + result.error_message = self._last_registration_error or "注册密码失败" + return result + + if not self._send_verification_code(): + result.error_message = "发送验证码失败" + return result + + code = self._get_verification_code() + if not code: + result.error_message = "获取验证码失败" + return result + + if not self._validate_verification_code(code): + result.error_message = "验证码校验失败" + return result + + if not self._create_user_account(): + result.error_message = "创建用户账户失败" + return result + + self._account_created = True + self._log("新账号建号完成,先保留当前会话,交给统一 token 阶段决定是否需要重新登录") + + # 根据邮箱类型选择 token 获取路径 + service_type_raw = getattr(self.email_service, "service_type", "") + service_type_value = str( + getattr(service_type_raw, "value", service_type_raw) or "" + ).strip().lower() + + if service_type_value == "outlook": + # Outlook 专属链路:全量走 3 级 OTP 重试 + chatgpt 桥接补全 + if self._is_existing_account: + self._log("检测到已注册 Outlook 邮箱,使用专属 token 获取路径(已注册账号重登 + 3 级 OTP 重试)") + else: + self._log("检测到 Outlook 邮箱,使用专属 token 获取路径(注册后重登 + 3 级 OTP 重试)") + # 恢复模式下(已走过 _start_saved_password_recovery)可能已处于 OTP 阶段, + # 不需要重新 _restart_login_flow;非恢复模式则需要重登 + if not self._recovery_mode: + login_ready, login_error = self._restart_login_flow() + if not login_ready: + return self._fail_with_recovery(result, login_error) + if not self._complete_token_exchange_outlook(result): + return self._fail_with_recovery(result, result.error_message) + else: + # 临时邮箱及其他:走原有通用路径 + if not self._complete_token_exchange(result): + return self._fail_with_recovery(result, result.error_message) + + result.success = True + result.metadata = { + "email_service": self.email_service.service_type.value, + "proxy_used": self.proxy_url, + "registered_at": datetime.now().isoformat(), + "is_existing_account": self._is_existing_account, + "token_acquired_via_relogin": self._token_acquisition_requires_login, + "recovery_ready": False, + "account_created": True, + "token_acquired": True, + "workspace_acquired": bool(result.workspace_id), + "last_oauth_resume_source": self._oauth_resume_source, + "last_workspace_resolution_source": self._workspace_resolution_source, + "last_workspace_resolution_error": self._workspace_resolution_error, + "last_recovery_debug_summary": self._workspace_context.get("last_recovery_debug_summary"), + } + + if self._recovery_mode: + self._activate_recovered_account(result) + + return result + + except Exception as e: + self._log(f"注册过程中发生未预期错误: {e}", "error") + result.error_message = str(e) + return self._fail_with_recovery(result, result.error_message) + + + def save_to_database(self, result: RegistrationResult) -> bool: + """ + 保存注册结果到数据库 + + Args: + result: 注册结果 + + Returns: + 是否保存成功 + """ + if not result.success: + return False + + try: + # 获取默认 client_id + settings = get_settings() + + with get_db() as db: + # 保存账户信息 + account = crud.create_account( + db, + email=result.email, + password=result.password, + client_id=settings.openai_client_id, + session_token=result.session_token, + email_service=self.email_service.service_type.value, + email_service_id=self.email_info.get("service_id") if self.email_info else None, + account_id=result.account_id, + workspace_id=result.workspace_id, + access_token=result.access_token, + refresh_token=result.refresh_token, + id_token=result.id_token, + proxy_used=self.proxy_url, + extra_data=result.metadata, + source=result.source + ) + + self._log(f"账户已存进数据库,落袋为安,ID: {account.id}") + return True + + except Exception as e: + self._log(f"保存到数据库失败: {e}", "error") + return False diff --git a/src/core/registration_post_create.py b/src/core/registration_post_create.py new file mode 100644 index 0000000000000000000000000000000000000000..c58c07b228dea9bad4407a3c2a47a4be9561aac3 --- /dev/null +++ b/src/core/registration_post_create.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +from typing import Any, Callable, Dict, Optional + + +POST_CREATE_RESUME_SOURCE = "post_create_continue" +POST_CREATE_REENTERED_LOGIN_SOURCE = "post_create_continue_reentered_login" + + +def is_post_create_continue_url(url: Optional[str]) -> bool: + candidate = str(url or "").strip().lower() + if not candidate: + return False + return any( + token in candidate + for token in ( + "/add-phone", + "/sign-in-with-chatgpt/", + "/consent", + "/workspace/select", + "/organization/select", + ) + ) + + +def extract_post_create_continue_url(data: Any) -> Optional[str]: + if isinstance(data, dict): + for key, value in data.items(): + if isinstance(value, str): + candidate = value.strip() + key_text = str(key).strip().lower() + if key_text == "type" and candidate.lower() in {"add_phone", "add-phone"}: + return "https://auth.openai.com/add-phone" + if key_text in {"continue_url", "url", "location", "redirect_url"} and is_post_create_continue_url(candidate): + return candidate + if is_post_create_continue_url(candidate): + return candidate + candidate = extract_post_create_continue_url(value) + if candidate: + return candidate + elif isinstance(data, list): + for item in data: + candidate = extract_post_create_continue_url(item) + if candidate: + return candidate + return None + + +def get_locked_post_create_continue_url(workspace_context: Dict[str, Any]) -> str: + return str(workspace_context.get("post_create_continue_url") or "").strip() + + +def lock_post_create_continue_url( + workspace_context: Dict[str, Any], + candidate: Optional[str], + *, + source_name: str, + log_fn: Callable[[str], None], + sanitize_url: Callable[[Optional[str]], str], +) -> bool: + candidate_text = str(candidate or "").strip() + if not is_post_create_continue_url(candidate_text): + return False + + current_locked = get_locked_post_create_continue_url(workspace_context) + current_resume = str(workspace_context.get("resume_url") or "").strip() + + if current_locked == candidate_text and current_resume == candidate_text: + workspace_context["post_create_continue_source"] = source_name + return True + + if current_resume and current_resume != candidate_text: + log_fn( + "post_create_continue_overrode_cached_resume: " + f"current={sanitize_url(current_resume) or '-'}; " + f"new={sanitize_url(candidate_text)}" + ) + + if current_locked and current_locked != candidate_text: + log_fn( + "post_create_continue_upgraded: " + f"current={sanitize_url(current_locked)}; " + f"new={sanitize_url(candidate_text)}" + ) + else: + log_fn(f"post_create_continue_locked: {sanitize_url(candidate_text)}") + + workspace_context["post_create_continue_url"] = candidate_text + workspace_context["post_create_continue_source"] = source_name + workspace_context["continue_url"] = candidate_text + workspace_context["resume_url"] = candidate_text + workspace_context["resume_source"] = POST_CREATE_RESUME_SOURCE + return True + + +def should_preserve_locked_post_create_continue( + workspace_context: Dict[str, Any], + candidate: Optional[str], +) -> bool: + current_locked = get_locked_post_create_continue_url(workspace_context) + if not current_locked: + return False + + current_resume = str(workspace_context.get("resume_url") or "").strip() + candidate_text = str(candidate or "").strip() + if not candidate_text or candidate_text == current_resume: + return False + + if current_resume != current_locked: + return False + + return not is_post_create_continue_url(candidate_text) + + +def build_post_create_failure_details( + *, + locked_url: Optional[str], + terminal_url: Optional[str], + resolution_error: Optional[str], + reentered_login: bool, +) -> Dict[str, Optional[str]]: + if reentered_login: + error_message = "账号已创建,但 post-create 续跑重新进入登录页" + summary_reason = POST_CREATE_REENTERED_LOGIN_SOURCE + else: + error_message = "账号已创建,但 post-create 续跑失败" + summary_reason = "post_create_continue_failed" + + return { + "error_message": error_message, + "resume_source": summary_reason, + "summary_reason": summary_reason, + "workspace_resolution_source": POST_CREATE_RESUME_SOURCE, + "locked_url": str(locked_url or "").strip() or None, + "terminal_url": str(terminal_url or "").strip() or None, + "resolution_error": str(resolution_error or "").strip() or None, + } diff --git a/src/core/registration_upload.py b/src/core/registration_upload.py new file mode 100644 index 0000000000000000000000000000000000000000..7b22e239860e8fde5d6aba95d0329e7c18ec759c --- /dev/null +++ b/src/core/registration_upload.py @@ -0,0 +1,48 @@ + +from __future__ import annotations + +from dataclasses import dataclass + +from ..database.models import Account +from .service_selection import UploadTarget +from .upload.cpa_upload import generate_token_json, upload_to_cpa +from .upload.sub2api_upload import upload_to_sub2api +from .upload.team_manager_upload import upload_to_team_manager + + +@dataclass +class UploadExecutionResult: + success: bool + provider: str + service_id: int + message: str + + +def upload_registered_account(account: Account, target: UploadTarget) -> UploadExecutionResult: + if account is None: + return UploadExecutionResult(False, target.provider, target.service_id, 'account_not_found') + if not account.access_token: + return UploadExecutionResult(False, target.provider, target.service_id, 'missing_access_token') + + if target.provider == 'cpa': + ok, message = upload_to_cpa( + generate_token_json(account), + api_url=target.config_snapshot['api_url'], + api_token=target.config_snapshot['api_token'], + ) + elif target.provider == 'sub2api': + ok, message = upload_to_sub2api( + [account], + target.config_snapshot['api_url'], + target.config_snapshot['api_key'], + ) + elif target.provider == 'tm': + ok, message = upload_to_team_manager( + account, + target.config_snapshot['api_url'], + target.config_snapshot['api_key'], + ) + else: + return UploadExecutionResult(False, target.provider, target.service_id, 'unsupported_provider') + + return UploadExecutionResult(ok, target.provider, target.service_id, message) diff --git a/src/core/service_selection.py b/src/core/service_selection.py new file mode 100644 index 0000000000000000000000000000000000000000..2948eebdf019f9969f2a76ea9e2138c7171ff151 --- /dev/null +++ b/src/core/service_selection.py @@ -0,0 +1,163 @@ + +from __future__ import annotations + +import random +from dataclasses import dataclass +from typing import Any, Optional + +from sqlalchemy.orm import Session + +from ..database import crud + + +@dataclass +class EmailItemAssignment: + item_index: int + service_id: Optional[int] + service_type: str + failure_reason: Optional[str] = None + config_snapshot: Optional[dict[str, Any]] = None + + +@dataclass +class UploadTarget: + provider: str + service_id: int + service_name: str + config_snapshot: dict[str, Any] + + +def _get_enabled_email_services(db: Session, email_type: str): + return crud.get_email_services(db, service_type=email_type, enabled=True) + + +def _get_email_service_or_raise(db: Session, email_type: str, requested_service_id: int): + service = crud.get_email_service_by_id(db, requested_service_id) + if not service: + raise ValueError(f"email service {requested_service_id} not found") + if not service.enabled: + raise ValueError(f"email service {requested_service_id} is disabled") + if service.service_type != email_type: + raise ValueError(f"email service {requested_service_id} does not belong to type {email_type}") + return service + + +def build_email_item_assignments( + db: Session, + *, + email_type: str, + count: int, + requested_service_id: Optional[int] = None, +) -> list[EmailItemAssignment]: + if count < 1: + raise ValueError('count must be >= 1') + + if requested_service_id is not None: + service = _get_email_service_or_raise(db, email_type, requested_service_id) + if email_type == 'outlook' and count > 1: + raise ValueError('outlook requested_service_id cannot be reused when count > 1') + return [ + EmailItemAssignment( + item_index=index, + service_id=service.id, + service_type=email_type, + config_snapshot=(service.config or {}).copy(), + ) + for index in range(count) + ] + + services = _get_enabled_email_services(db, email_type) + if not services: + raise ValueError(f'no enabled email services for type {email_type}') + + if email_type == 'outlook': + assignments: list[EmailItemAssignment] = [] + for index in range(count): + if index < len(services): + service = services[index] + assignments.append( + EmailItemAssignment( + item_index=index, + service_id=service.id, + service_type=email_type, + config_snapshot=(service.config or {}).copy(), + ) + ) + else: + assignments.append( + EmailItemAssignment( + item_index=index, + service_id=None, + service_type=email_type, + failure_reason='no_available_email_service', + ) + ) + return assignments + + if email_type == 'temp_mail': + min_priority = min(service.priority for service in services) + pool = [service for service in services if service.priority == min_priority] + return [ + EmailItemAssignment( + item_index=index, + service_id=(selected := random.choice(pool)).id, + service_type=email_type, + config_snapshot=(selected.config or {}).copy(), + ) + for index in range(count) + ] + + service = services[0] + return [ + EmailItemAssignment( + item_index=index, + service_id=service.id, + service_type=email_type, + config_snapshot=(service.config or {}).copy(), + ) + for index in range(count) + ] + + +def resolve_upload_target(db: Session, *, provider: str, requested_service_id: Optional[int] = None) -> UploadTarget: + provider = (provider or '').strip().lower() + provider_to_crud = { + 'cpa': (crud.get_cpa_service_by_id, lambda session: crud.get_cpa_services(session, enabled=True)), + 'sub2api': (crud.get_sub2api_service_by_id, lambda session: crud.get_sub2api_services(session, enabled=True)), + 'tm': (crud.get_tm_service_by_id, lambda session: crud.get_tm_services(session, enabled=True)), + } + if provider not in provider_to_crud: + raise ValueError(f'unsupported upload provider: {provider}') + + get_one, list_enabled = provider_to_crud[provider] + if requested_service_id is not None: + service = get_one(db, requested_service_id) + if not service: + for other_provider, (other_get_one, _) in provider_to_crud.items(): + if other_provider == provider: + continue + if other_get_one(db, requested_service_id): + raise ValueError(f'upload service {requested_service_id} does not belong to provider {provider}') + raise ValueError(f'upload service {requested_service_id} not found') + if not getattr(service, 'enabled', False): + raise ValueError(f'upload service {requested_service_id} is disabled') + service_pool = {svc.id for svc in list_enabled(db)} + if service.id not in service_pool: + raise ValueError(f'upload service {requested_service_id} does not belong to provider {provider}') + else: + services = list_enabled(db) + if not services: + raise ValueError(f'no enabled upload services for provider {provider}') + service = services[0] + + if provider == 'cpa': + config_snapshot = {'api_url': service.api_url, 'api_token': service.api_token} + else: + config_snapshot = {'api_url': service.api_url, 'api_key': service.api_key} + + return UploadTarget( + provider=provider, + service_id=service.id, + service_name=service.name, + config_snapshot=config_snapshot, + ) diff --git a/src/core/upload/__init__.py b/src/core/upload/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..059e515720963a36856b388ba146cdc24ca98b08 --- /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 0000000000000000000000000000000000000000..900cff6abad7107bfcf67e7f11739f6823211662 --- /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 0000000000000000000000000000000000000000..8df79609eff104d93e421a9863a1947ce39fdb63 --- /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 0000000000000000000000000000000000000000..119734862fa69be2d36aa776232c61b4ecd4d435 --- /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 0000000000000000000000000000000000000000..80ab61fe05375ea86c1e83f513d36af2c910b4aa --- /dev/null +++ b/src/core/utils.py @@ -0,0 +1,570 @@ +""" +通用工具函数 +""" + +import os +import sys +import json +import time +import random +import string +import secrets +import hashlib +import logging +import base64 +import re +import uuid +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional, Union, Callable +from pathlib import Path + +from ..config.constants import PASSWORD_CHARSET, DEFAULT_PASSWORD_LENGTH +from ..config.settings import get_settings + + +def setup_logging( + log_level: str = "INFO", + log_file: Optional[str] = None, + log_format: str = "%(asctime)s [%(levelname)s] %(name)s: %(message)s" +) -> logging.Logger: + """ + 配置日志系统 + + Args: + log_level: 日志级别 (DEBUG, INFO, WARNING, ERROR, CRITICAL) + log_file: 日志文件路径,如果不指定则只输出到控制台 + log_format: 日志格式 + + Returns: + 根日志记录器 + """ + # 设置日志级别 + numeric_level = getattr(logging, log_level.upper(), None) + if not isinstance(numeric_level, int): + numeric_level = logging.INFO + + # 配置根日志记录器 + root_logger = logging.getLogger() + root_logger.setLevel(numeric_level) + + # 清除现有的处理器 + root_logger.handlers.clear() + + # 创建格式化器 + formatter = logging.Formatter(log_format) + + # 控制台处理器 + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setFormatter(formatter) + console_handler.setLevel(numeric_level) + root_logger.addHandler(console_handler) + + # 文件处理器(如果指定了日志文件) + if log_file: + # 确保日志目录存在 + log_dir = os.path.dirname(log_file) + if log_dir: + os.makedirs(log_dir, exist_ok=True) + + file_handler = logging.FileHandler(log_file, encoding="utf-8") + file_handler.setFormatter(formatter) + file_handler.setLevel(numeric_level) + root_logger.addHandler(file_handler) + + return root_logger + + +def generate_password(length: int = DEFAULT_PASSWORD_LENGTH) -> str: + """ + 生成随机密码 + + Args: + length: 密码长度 + + Returns: + 随机密码字符串 + """ + if length < 4: + length = 4 + + # 确保密码包含至少一个大写字母、一个小写字母和一个数字 + password = [ + secrets.choice(string.ascii_lowercase), + secrets.choice(string.ascii_uppercase), + secrets.choice(string.digits), + ] + + # 添加剩余字符 + password.extend(secrets.choice(PASSWORD_CHARSET) for _ in range(length - 3)) + + # 随机打乱 + secrets.SystemRandom().shuffle(password) + + return ''.join(password) + + +def generate_random_string(length: int = 8) -> str: + """ + 生成随机字符串(仅字母) + + Args: + length: 字符串长度 + + Returns: + 随机字符串 + """ + chars = string.ascii_letters + return ''.join(secrets.choice(chars) for _ in range(length)) + + +def generate_uuid() -> str: + """生成 UUID 字符串""" + return str(uuid.uuid4()) + + +def get_timestamp() -> int: + """获取当前时间戳(秒)""" + return int(time.time()) + + +def format_datetime(dt: Optional[datetime] = None, fmt: str = "%Y-%m-%d %H:%M:%S") -> str: + """ + 格式化日期时间 + + Args: + dt: 日期时间对象,如果为 None 则使用当前时间 + fmt: 格式字符串 + + Returns: + 格式化后的字符串 + """ + if dt is None: + dt = datetime.now() + return dt.strftime(fmt) + + +def parse_datetime(dt_str: str, fmt: str = "%Y-%m-%d %H:%M:%S") -> Optional[datetime]: + """ + 解析日期时间字符串 + + Args: + dt_str: 日期时间字符串 + fmt: 格式字符串 + + Returns: + 日期时间对象,如果解析失败返回 None + """ + try: + return datetime.strptime(dt_str, fmt) + except (ValueError, TypeError): + return None + + +def human_readable_size(size_bytes: int) -> str: + """ + 将字节大小转换为人类可读的格式 + + Args: + size_bytes: 字节大小 + + Returns: + 人类可读的字符串 + """ + if size_bytes < 0: + return "0 B" + + units = ["B", "KB", "MB", "GB", "TB", "PB"] + unit_index = 0 + + while size_bytes >= 1024 and unit_index < len(units) - 1: + size_bytes /= 1024 + unit_index += 1 + + return f"{size_bytes:.2f} {units[unit_index]}" + + +def retry_with_backoff( + func: Callable, + max_retries: int = 3, + base_delay: float = 1.0, + max_delay: float = 30.0, + backoff_factor: float = 2.0, + exceptions: tuple = (Exception,) +) -> Any: + """ + 带有指数退避的重试装饰器/函数 + + Args: + func: 要重试的函数 + max_retries: 最大重试次数 + base_delay: 基础延迟(秒) + max_delay: 最大延迟(秒) + backoff_factor: 退避因子 + exceptions: 要捕获的异常类型 + + Returns: + 函数的返回值 + + Raises: + 最后一次尝试的异常 + """ + last_exception = None + + for attempt in range(max_retries + 1): + try: + return func() + except exceptions as e: + last_exception = e + + # 如果是最后一次尝试,直接抛出异常 + if attempt == max_retries: + break + + # 计算延迟时间 + delay = min(base_delay * (backoff_factor ** attempt), max_delay) + + # 添加随机抖动 + delay *= (0.5 + random.random()) + + # 记录日志 + logger = logging.getLogger(__name__) + logger.warning( + f"尝试 {func.__name__} 失败 (attempt {attempt + 1}/{max_retries + 1}): {e}. " + f"等待 {delay:.2f} 秒后重试..." + ) + + time.sleep(delay) + + # 所有重试都失败,抛出最后一个异常 + raise last_exception + + +class RetryDecorator: + """重试装饰器类""" + + def __init__( + self, + max_retries: int = 3, + base_delay: float = 1.0, + max_delay: float = 30.0, + backoff_factor: float = 2.0, + exceptions: tuple = (Exception,) + ): + self.max_retries = max_retries + self.base_delay = base_delay + self.max_delay = max_delay + self.backoff_factor = backoff_factor + self.exceptions = exceptions + + def __call__(self, func: Callable) -> Callable: + """装饰器调用""" + def wrapper(*args, **kwargs): + def func_to_retry(): + return func(*args, **kwargs) + + return retry_with_backoff( + func_to_retry, + max_retries=self.max_retries, + base_delay=self.base_delay, + max_delay=self.max_delay, + backoff_factor=self.backoff_factor, + exceptions=self.exceptions + ) + + return wrapper + + +def validate_email(email: str) -> bool: + """ + 验证邮箱地址格式 + + Args: + email: 邮箱地址 + + Returns: + 是否有效 + """ + pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" + return bool(re.match(pattern, email)) + + +def validate_url(url: str) -> bool: + """ + 验证 URL 格式 + + Args: + url: URL + + Returns: + 是否有效 + """ + pattern = r"^https?://[^\s/$.?#].[^\s]*$" + return bool(re.match(pattern, url)) + + +def sanitize_filename(filename: str) -> str: + """ + 清理文件名,移除不安全的字符 + + Args: + filename: 原始文件名 + + Returns: + 清理后的文件名 + """ + # 移除危险字符 + filename = re.sub(r'[<>:"/\\|?*]', '_', filename) + # 移除控制字符 + filename = ''.join(char for char in filename if ord(char) >= 32) + # 限制长度 + if len(filename) > 255: + name, ext = os.path.splitext(filename) + filename = name[:255 - len(ext)] + ext + return filename + + +def read_json_file(filepath: str) -> Optional[Dict[str, Any]]: + """ + 读取 JSON 文件 + + Args: + filepath: 文件路径 + + Returns: + JSON 数据,如果读取失败返回 None + """ + try: + with open(filepath, 'r', encoding='utf-8') as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError, IOError) as e: + logging.getLogger(__name__).warning(f"读取 JSON 文件失败: {filepath} - {e}") + return None + + +def write_json_file(filepath: str, data: Dict[str, Any], indent: int = 2) -> bool: + """ + 写入 JSON 文件 + + Args: + filepath: 文件路径 + data: 要写入的数据 + indent: 缩进空格数 + + Returns: + 是否成功 + """ + try: + # 确保目录存在 + os.makedirs(os.path.dirname(filepath), exist_ok=True) + + with open(filepath, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=indent) + + return True + except (IOError, TypeError) as e: + logging.getLogger(__name__).error(f"写入 JSON 文件失败: {filepath} - {e}") + return False + + +def get_project_root() -> Path: + """ + 获取项目根目录 + + Returns: + 项目根目录 Path 对象 + """ + # 当前文件所在目录 + current_dir = Path(__file__).parent + + # 向上查找直到找到项目根目录(包含 pyproject.toml 或 setup.py) + for parent in [current_dir] + list(current_dir.parents): + if (parent / "pyproject.toml").exists() or (parent / "setup.py").exists(): + return parent + + # 如果找不到,返回当前目录的父目录 + return current_dir.parent + + +def get_data_dir() -> Path: + """ + 获取数据目录 + + Returns: + 数据目录 Path 对象 + """ + settings = get_settings() + if not settings.database_url.startswith("sqlite"): + data_dir = Path(os.environ.get("APP_DATA_DIR", "data")) + data_dir.mkdir(parents=True, exist_ok=True) + return data_dir + data_dir = Path(settings.database_url).parent + + # 如果 database_url 是 SQLite URL,提取路径 + if settings.database_url.startswith("sqlite:///"): + db_path = settings.database_url[10:] # 移除 "sqlite:///" + data_dir = Path(db_path).parent + + # 确保目录存在 + data_dir.mkdir(parents=True, exist_ok=True) + + return data_dir + + +def get_logs_dir() -> Path: + """ + 获取日志目录 + + Returns: + 日志目录 Path 对象 + """ + settings = get_settings() + log_file = Path(settings.log_file) + log_dir = log_file.parent + + # 确保目录存在 + log_dir.mkdir(parents=True, exist_ok=True) + + return log_dir + + +def format_duration(seconds: int) -> str: + """ + 格式化持续时间 + + Args: + seconds: 秒数 + + Returns: + 格式化的持续时间字符串 + """ + if seconds < 60: + return f"{seconds}秒" + + minutes, seconds = divmod(seconds, 60) + if minutes < 60: + return f"{minutes}分{seconds}秒" + + hours, minutes = divmod(minutes, 60) + if hours < 24: + return f"{hours}小时{minutes}分" + + days, hours = divmod(hours, 24) + return f"{days}天{hours}小时" + + +def mask_sensitive_data(data: Union[str, Dict, List], mask_char: str = "*") -> Union[str, Dict, List]: + """ + 掩码敏感数据 + + Args: + data: 要掩码的数据 + mask_char: 掩码字符 + + Returns: + 掩码后的数据 + """ + if isinstance(data, str): + # 如果是邮箱,掩码中间部分 + if "@" in data: + local, domain = data.split("@", 1) + if len(local) > 2: + masked_local = local[0] + mask_char * (len(local) - 2) + local[-1] + else: + masked_local = mask_char * len(local) + return f"{masked_local}@{domain}" + + # 如果是 token 或密钥,掩码大部分内容 + if len(data) > 10: + return data[:4] + mask_char * (len(data) - 8) + data[-4:] + return mask_char * len(data) + + elif isinstance(data, dict): + masked_dict = {} + for key, value in data.items(): + # 敏感字段名 + sensitive_keys = ["password", "token", "secret", "key", "auth", "credential"] + if any(sensitive in key.lower() for sensitive in sensitive_keys): + masked_dict[key] = mask_sensitive_data(value, mask_char) + else: + masked_dict[key] = value + return masked_dict + + elif isinstance(data, list): + return [mask_sensitive_data(item, mask_char) for item in data] + + return data + + +def calculate_md5(data: Union[str, bytes]) -> str: + """ + 计算 MD5 哈希 + + Args: + data: 要哈希的数据 + + Returns: + MD5 哈希字符串 + """ + if isinstance(data, str): + data = data.encode('utf-8') + + return hashlib.md5(data).hexdigest() + + +def calculate_sha256(data: Union[str, bytes]) -> str: + """ + 计算 SHA256 哈希 + + Args: + data: 要哈希的数据 + + Returns: + SHA256 哈希字符串 + """ + if isinstance(data, str): + data = data.encode('utf-8') + + return hashlib.sha256(data).hexdigest() + + +def base64_encode(data: Union[str, bytes]) -> str: + """Base64 编码""" + if isinstance(data, str): + data = data.encode('utf-8') + + return base64.b64encode(data).decode('utf-8') + + +def base64_decode(data: str) -> str: + """Base64 解码""" + try: + decoded = base64.b64decode(data) + return decoded.decode('utf-8') + except (base64.binascii.Error, UnicodeDecodeError): + return "" + + +class Timer: + """计时器上下文管理器""" + + def __init__(self, name: str = "操作"): + self.name = name + self.start_time = None + self.elapsed = None + + def __enter__(self): + self.start_time = time.time() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.elapsed = time.time() - self.start_time + logger = logging.getLogger(__name__) + logger.debug(f"{self.name} 耗时: {self.elapsed:.2f} 秒") + + def get_elapsed(self) -> float: + """获取经过的时间(秒)""" + if self.elapsed is not None: + return self.elapsed + if self.start_time is not None: + return time.time() - self.start_time + return 0.0 diff --git a/src/database/__init__.py b/src/database/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..38a39afbf8a532c0c25b45302c041aad75fdbdf8 --- /dev/null +++ b/src/database/__init__.py @@ -0,0 +1,22 @@ +""" +数据库模块 +""" + +from .models import Base, Account, EmailService, RegistrationTask, Setting +from .session import get_db, init_database, get_session_manager, DatabaseSessionManager +from . import crud +from . import external_batch_crud + +__all__ = [ + 'Base', + 'Account', + 'EmailService', + 'RegistrationTask', + 'Setting', + 'get_db', + 'init_database', + 'get_session_manager', + 'DatabaseSessionManager', + 'crud', + 'external_batch_crud', +] diff --git a/src/database/crud.py b/src/database/crud.py new file mode 100644 index 0000000000000000000000000000000000000000..4750969cfbc72bdc60c7c8683183336ff6e1985d --- /dev/null +++ b/src/database/crud.py @@ -0,0 +1,714 @@ +""" +数据库 CRUD 操作 +""" + +from typing import List, Optional, Dict, Any, Union +from datetime import datetime, timedelta +from sqlalchemy.orm import Session +from sqlalchemy import and_, or_, desc, asc, func + +from .models import Account, EmailService, RegistrationTask, Setting, Proxy, CpaService, Sub2ApiService + + +# ============================================================================ +# 账户 CRUD +# ============================================================================ + +def create_account( + db: Session, + email: str, + email_service: str, + password: Optional[str] = None, + client_id: Optional[str] = None, + session_token: Optional[str] = None, + email_service_id: Optional[str] = None, + account_id: Optional[str] = None, + workspace_id: Optional[str] = None, + access_token: Optional[str] = None, + refresh_token: Optional[str] = None, + id_token: Optional[str] = None, + proxy_used: Optional[str] = None, + expires_at: Optional['datetime'] = None, + extra_data: Optional[Dict[str, Any]] = None, + status: Optional[str] = None, + source: Optional[str] = None +) -> Account: + """创建新账户""" + db_account = Account( + email=email, + password=password, + client_id=client_id, + session_token=session_token, + email_service=email_service, + email_service_id=email_service_id, + account_id=account_id, + workspace_id=workspace_id, + access_token=access_token, + refresh_token=refresh_token, + id_token=id_token, + proxy_used=proxy_used, + expires_at=expires_at, + extra_data=extra_data or {}, + status=status or 'active', + source=source or 'register', + registered_at=datetime.utcnow() + ) + db.add(db_account) + db.commit() + db.refresh(db_account) + return db_account + + +def get_account_by_id(db: Session, account_id: int) -> Optional[Account]: + """根据 ID 获取账户""" + return db.query(Account).filter(Account.id == account_id).first() + + +def get_account_by_email(db: Session, email: str) -> Optional[Account]: + """根据邮箱获取账户""" + return db.query(Account).filter(Account.email == email).first() + + +def get_accounts( + db: Session, + skip: int = 0, + limit: int = 100, + email_service: Optional[str] = None, + status: Optional[str] = None, + search: Optional[str] = None +) -> List[Account]: + """获取账户列表(支持分页、筛选)""" + query = db.query(Account) + + if email_service: + query = query.filter(Account.email_service == email_service) + + if status: + query = query.filter(Account.status == status) + + if search: + search_filter = or_( + Account.email.ilike(f"%{search}%"), + Account.account_id.ilike(f"%{search}%"), + Account.workspace_id.ilike(f"%{search}%") + ) + query = query.filter(search_filter) + + query = query.order_by(desc(Account.created_at)).offset(skip).limit(limit) + return query.all() + + +def update_account( + db: Session, + account_id: int, + **kwargs +) -> Optional[Account]: + """更新账户信息""" + db_account = get_account_by_id(db, account_id) + if not db_account: + return None + + for key, value in kwargs.items(): + if hasattr(db_account, key) and value is not None: + setattr(db_account, key, value) + + db.commit() + db.refresh(db_account) + return db_account + + +def delete_account(db: Session, account_id: int) -> bool: + """删除账户""" + db_account = get_account_by_id(db, account_id) + if not db_account: + return False + + db.delete(db_account) + db.commit() + return True + + +def delete_accounts_batch(db: Session, account_ids: List[int]) -> int: + """批量删除账户""" + result = db.query(Account).filter(Account.id.in_(account_ids)).delete(synchronize_session=False) + db.commit() + return result + + +def get_accounts_count( + db: Session, + email_service: Optional[str] = None, + status: Optional[str] = None +) -> int: + """获取账户数量""" + query = db.query(func.count(Account.id)) + + if email_service: + query = query.filter(Account.email_service == email_service) + + if status: + query = query.filter(Account.status == status) + + return query.scalar() + + +# ============================================================================ +# 邮箱服务 CRUD +# ============================================================================ + +def create_email_service( + db: Session, + service_type: str, + name: str, + config: Dict[str, Any], + enabled: bool = True, + priority: int = 0 +) -> EmailService: + """创建邮箱服务配置""" + db_service = EmailService( + service_type=service_type, + name=name, + config=config, + enabled=enabled, + priority=priority + ) + db.add(db_service) + db.commit() + db.refresh(db_service) + return db_service + + +def get_email_service_by_id(db: Session, service_id: int) -> Optional[EmailService]: + """根据 ID 获取邮箱服务""" + return db.query(EmailService).filter(EmailService.id == service_id).first() + + +def get_email_services( + db: Session, + service_type: Optional[str] = None, + enabled: Optional[bool] = None, + skip: int = 0, + limit: int = 100 +) -> List[EmailService]: + """获取邮箱服务列表""" + query = db.query(EmailService) + + if service_type: + query = query.filter(EmailService.service_type == service_type) + + if enabled is not None: + query = query.filter(EmailService.enabled == enabled) + + query = query.order_by( + asc(EmailService.priority), + desc(EmailService.last_used) + ).offset(skip).limit(limit) + + return query.all() + + +def update_email_service( + db: Session, + service_id: int, + **kwargs +) -> Optional[EmailService]: + """更新邮箱服务配置""" + db_service = get_email_service_by_id(db, service_id) + if not db_service: + return None + + for key, value in kwargs.items(): + if hasattr(db_service, key) and value is not None: + setattr(db_service, key, value) + + db.commit() + db.refresh(db_service) + return db_service + + +def delete_email_service(db: Session, service_id: int) -> bool: + """删除邮箱服务配置""" + db_service = get_email_service_by_id(db, service_id) + if not db_service: + return False + + db.delete(db_service) + db.commit() + return True + + +# ============================================================================ +# 注册任务 CRUD +# ============================================================================ + +def create_registration_task( + db: Session, + task_uuid: str, + email_service_id: Optional[int] = None, + proxy: Optional[str] = None +) -> RegistrationTask: + """创建注册任务""" + db_task = RegistrationTask( + task_uuid=task_uuid, + email_service_id=email_service_id, + proxy=proxy, + status='pending' + ) + db.add(db_task) + db.commit() + db.refresh(db_task) + return db_task + + +def get_registration_task_by_uuid(db: Session, task_uuid: str) -> Optional[RegistrationTask]: + """根据 UUID 获取注册任务""" + return db.query(RegistrationTask).filter(RegistrationTask.task_uuid == task_uuid).first() + + +def get_registration_tasks( + db: Session, + status: Optional[str] = None, + skip: int = 0, + limit: int = 100 +) -> List[RegistrationTask]: + """获取注册任务列表""" + query = db.query(RegistrationTask) + + if status: + query = query.filter(RegistrationTask.status == status) + + query = query.order_by(desc(RegistrationTask.created_at)).offset(skip).limit(limit) + return query.all() + + +def update_registration_task( + db: Session, + task_uuid: str, + **kwargs +) -> Optional[RegistrationTask]: + """更新注册任务状态""" + db_task = get_registration_task_by_uuid(db, task_uuid) + if not db_task: + return None + + for key, value in kwargs.items(): + if hasattr(db_task, key): + setattr(db_task, key, value) + + db.commit() + db.refresh(db_task) + return db_task + + +def append_task_log(db: Session, task_uuid: str, log_message: str) -> bool: + """追加任务日志""" + db_task = get_registration_task_by_uuid(db, task_uuid) + if not db_task: + return False + + if db_task.logs: + db_task.logs += f"\n{log_message}" + else: + db_task.logs = log_message + + db.commit() + return True + + +def delete_registration_task(db: Session, task_uuid: str) -> bool: + """删除注册任务""" + db_task = get_registration_task_by_uuid(db, task_uuid) + if not db_task: + return False + + db.delete(db_task) + db.commit() + return True + + +# 为 API 路由添加别名 +get_account = get_account_by_id +get_registration_task = get_registration_task_by_uuid + + +# ============================================================================ +# 设置 CRUD +# ============================================================================ + +def get_setting(db: Session, key: str) -> Optional[Setting]: + """获取设置""" + return db.query(Setting).filter(Setting.key == key).first() + + +def get_settings_by_category(db: Session, category: str) -> List[Setting]: + """根据分类获取设置""" + return db.query(Setting).filter(Setting.category == category).all() + + +def set_setting( + db: Session, + key: str, + value: str, + description: Optional[str] = None, + category: str = 'general' +) -> Setting: + """设置或更新配置项""" + db_setting = get_setting(db, key) + if db_setting: + db_setting.value = value + db_setting.description = description or db_setting.description + db_setting.category = category + db_setting.updated_at = datetime.utcnow() + else: + db_setting = Setting( + key=key, + value=value, + description=description, + category=category + ) + db.add(db_setting) + + db.commit() + db.refresh(db_setting) + return db_setting + + +def delete_setting(db: Session, key: str) -> bool: + """删除设置""" + db_setting = get_setting(db, key) + if not db_setting: + return False + + db.delete(db_setting) + db.commit() + return True + + +# ============================================================================ +# 代理 CRUD +# ============================================================================ + +def create_proxy( + db: Session, + name: str, + type: str, + host: str, + port: int, + username: Optional[str] = None, + password: Optional[str] = None, + enabled: bool = True, + priority: int = 0 +) -> Proxy: + """创建代理配置""" + db_proxy = Proxy( + name=name, + type=type, + host=host, + port=port, + username=username, + password=password, + enabled=enabled, + priority=priority + ) + db.add(db_proxy) + db.commit() + db.refresh(db_proxy) + return db_proxy + + +def get_proxy_by_id(db: Session, proxy_id: int) -> Optional[Proxy]: + """根据 ID 获取代理""" + return db.query(Proxy).filter(Proxy.id == proxy_id).first() + + +def get_proxies( + db: Session, + enabled: Optional[bool] = None, + skip: int = 0, + limit: int = 100 +) -> List[Proxy]: + """获取代理列表""" + query = db.query(Proxy) + + if enabled is not None: + query = query.filter(Proxy.enabled == enabled) + + query = query.order_by(desc(Proxy.created_at)).offset(skip).limit(limit) + return query.all() + + +def get_enabled_proxies(db: Session) -> List[Proxy]: + """获取所有启用的代理""" + return db.query(Proxy).filter(Proxy.enabled == True).all() + + +def update_proxy( + db: Session, + proxy_id: int, + **kwargs +) -> Optional[Proxy]: + """更新代理配置""" + db_proxy = get_proxy_by_id(db, proxy_id) + if not db_proxy: + return None + + for key, value in kwargs.items(): + if hasattr(db_proxy, key): + setattr(db_proxy, key, value) + + db.commit() + db.refresh(db_proxy) + return db_proxy + + +def delete_proxy(db: Session, proxy_id: int) -> bool: + """删除代理配置""" + db_proxy = get_proxy_by_id(db, proxy_id) + if not db_proxy: + return False + + db.delete(db_proxy) + db.commit() + return True + + +def update_proxy_last_used(db: Session, proxy_id: int) -> bool: + """更新代理最后使用时间""" + db_proxy = get_proxy_by_id(db, proxy_id) + if not db_proxy: + return False + + db_proxy.last_used = datetime.utcnow() + db.commit() + return True + + +def get_random_proxy(db: Session) -> Optional[Proxy]: + """随机获取一个启用的代理,优先返回 is_default=True 的代理""" + import random + # 优先返回默认代理 + default_proxy = db.query(Proxy).filter(Proxy.enabled == True, Proxy.is_default == True).first() + if default_proxy: + return default_proxy + proxies = get_enabled_proxies(db) + if not proxies: + return None + return random.choice(proxies) + + +def set_proxy_default(db: Session, proxy_id: int) -> Optional[Proxy]: + """将指定代理设为默认,同时清除其他代理的默认标记""" + # 清除所有默认标记 + db.query(Proxy).filter(Proxy.is_default == True).update({"is_default": False}) + # 设置新的默认代理 + proxy = db.query(Proxy).filter(Proxy.id == proxy_id).first() + if proxy: + proxy.is_default = True + db.commit() + db.refresh(proxy) + return proxy + + +def get_proxies_count(db: Session, enabled: Optional[bool] = None) -> int: + """获取代理数量""" + query = db.query(func.count(Proxy.id)) + if enabled is not None: + query = query.filter(Proxy.enabled == enabled) + return query.scalar() + + +# ============================================================================ +# CPA 服务 CRUD +# ============================================================================ + +def create_cpa_service( + db: Session, + name: str, + api_url: str, + api_token: str, + enabled: bool = True, + 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 \ No newline at end of file diff --git a/src/database/external_batch_crud.py b/src/database/external_batch_crud.py new file mode 100644 index 0000000000000000000000000000000000000000..d4ca545214e7bfa360a1246e038e7cfd1b86a8f2 --- /dev/null +++ b/src/database/external_batch_crud.py @@ -0,0 +1,91 @@ + +from __future__ import annotations + +from datetime import datetime +from typing import Optional, Sequence + +from sqlalchemy.orm import Session + +from .models import ExternalRegistrationBatch, ExternalRegistrationBatchItem + + +def create_batch(db: Session, **kwargs) -> ExternalRegistrationBatch: + batch = ExternalRegistrationBatch(**kwargs) + db.add(batch) + db.flush() + db.refresh(batch) + return batch + + +def create_batch_item(db: Session, **kwargs) -> ExternalRegistrationBatchItem: + item = ExternalRegistrationBatchItem(**kwargs) + db.add(item) + db.flush() + db.refresh(item) + return item + + +def get_batch_by_uuid(db: Session, batch_uuid: str) -> Optional[ExternalRegistrationBatch]: + return db.query(ExternalRegistrationBatch).filter(ExternalRegistrationBatch.batch_uuid == batch_uuid).first() + + +def get_batch_by_idempotency_key(db: Session, idempotency_key: str) -> Optional[ExternalRegistrationBatch]: + if not idempotency_key: + return None + return db.query(ExternalRegistrationBatch).filter(ExternalRegistrationBatch.idempotency_key == idempotency_key).first() + + +def list_batch_items(db: Session, batch_uuid: str) -> list[ExternalRegistrationBatchItem]: + return ( + db.query(ExternalRegistrationBatchItem) + .join(ExternalRegistrationBatch, ExternalRegistrationBatch.id == ExternalRegistrationBatchItem.batch_id) + .filter(ExternalRegistrationBatch.batch_uuid == batch_uuid) + .order_by(ExternalRegistrationBatchItem.item_index.asc(), ExternalRegistrationBatchItem.id.asc()) + .all() + ) + + +def list_recoverable_batches(db: Session) -> list[ExternalRegistrationBatch]: + return ( + db.query(ExternalRegistrationBatch) + .filter(ExternalRegistrationBatch.status.in_(("pending", "running"))) + .order_by(ExternalRegistrationBatch.created_at.asc(), ExternalRegistrationBatch.id.asc()) + .all() + ) + + +def update_batch(db: Session, batch_uuid: str, **kwargs) -> Optional[ExternalRegistrationBatch]: + batch = get_batch_by_uuid(db, batch_uuid) + if not batch: + return None + for key, value in kwargs.items(): + setattr(batch, key, value) + batch.updated_at = datetime.utcnow() + db.flush() + db.refresh(batch) + return batch + + +def update_batch_item(db: Session, item_id: int, **kwargs) -> Optional[ExternalRegistrationBatchItem]: + item = db.query(ExternalRegistrationBatchItem).filter(ExternalRegistrationBatchItem.id == item_id).first() + if not item: + return None + for key, value in kwargs.items(): + setattr(item, key, value) + item.updated_at = datetime.utcnow() + db.flush() + db.refresh(item) + return item + + +def list_items_by_status(db: Session, batch_uuid: str, statuses: Sequence[str]) -> list[ExternalRegistrationBatchItem]: + return ( + db.query(ExternalRegistrationBatchItem) + .join(ExternalRegistrationBatch, ExternalRegistrationBatch.id == ExternalRegistrationBatchItem.batch_id) + .filter( + ExternalRegistrationBatch.batch_uuid == batch_uuid, + ExternalRegistrationBatchItem.status.in_(tuple(statuses)), + ) + .order_by(ExternalRegistrationBatchItem.item_index.asc()) + .all() + ) diff --git a/src/database/init_db.py b/src/database/init_db.py new file mode 100644 index 0000000000000000000000000000000000000000..58ea4b0ea7da98dc2c6f44319a123f1cf0f063b1 --- /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 0000000000000000000000000000000000000000..9a819b7071570d1a5af7311025c535f9e776d31e --- /dev/null +++ b/src/database/models.py @@ -0,0 +1,283 @@ +""" +SQLAlchemy ORM 模型定义 +""" + +from datetime import datetime +from typing import Optional, Dict, Any +import json +from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.types import TypeDecorator +from sqlalchemy.orm import relationship + +Base = declarative_base() + + +class JSONEncodedDict(TypeDecorator): + """JSON 编码字典类型""" + impl = Text + + def process_bind_param(self, value: Optional[Dict[str, Any]], dialect): + if value is None: + return None + return json.dumps(value, ensure_ascii=False) + + def process_result_value(self, value: Optional[str], dialect): + if value is None: + return None + return json.loads(value) + + +class Account(Base): + """已注册账号表""" + __tablename__ = 'accounts' + + id = Column(Integer, primary_key=True, autoincrement=True) + email = Column(String(255), nullable=False, unique=True, index=True) + password = Column(String(255)) # 注册密码(明文存储) + access_token = Column(Text) + refresh_token = Column(Text) + id_token = Column(Text) + session_token = Column(Text) # 会话令牌(优先刷新方式) + client_id = Column(String(255)) # OAuth Client ID + account_id = Column(String(255)) + workspace_id = Column(String(255)) + email_service = Column(String(50), nullable=False) # 'tempmail', 'outlook', 'moe_mail' + email_service_id = Column(String(255)) # 邮箱服务中的ID + proxy_used = Column(String(255)) + registered_at = Column(DateTime, default=datetime.utcnow) + last_refresh = Column(DateTime) # 最后刷新时间 + expires_at = Column(DateTime) # Token 过期时间 + status = Column(String(20), default='active') # 'active', 'expired', 'banned', 'failed' + extra_data = Column(JSONEncodedDict) # 额外信息存储 + cpa_uploaded = Column(Boolean, default=False) # 是否已上传到 CPA + cpa_uploaded_at = Column(DateTime) # 上传时间 + source = Column(String(20), default='register') # 'register' 或 'login',区分账号来源 + subscription_type = Column(String(20)) # None / 'plus' / 'team' + subscription_at = Column(DateTime) # 订阅开通时间 + cookies = Column(Text) # 完整 cookie 字符串,用于支付请求 + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + def to_dict(self) -> Dict[str, Any]: + """转换为字典""" + return { + 'id': self.id, + 'email': self.email, + 'password': self.password, + 'client_id': self.client_id, + 'email_service': self.email_service, + 'account_id': self.account_id, + 'workspace_id': self.workspace_id, + 'registered_at': self.registered_at.isoformat() if self.registered_at else None, + 'last_refresh': self.last_refresh.isoformat() if self.last_refresh else None, + 'expires_at': self.expires_at.isoformat() if self.expires_at else None, + 'status': self.status, + 'proxy_used': self.proxy_used, + 'cpa_uploaded': self.cpa_uploaded, + 'cpa_uploaded_at': self.cpa_uploaded_at.isoformat() if self.cpa_uploaded_at else None, + 'source': self.source, + 'subscription_type': self.subscription_type, + 'subscription_at': self.subscription_at.isoformat() if self.subscription_at else None, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None + } + + +class EmailService(Base): + """邮箱服务配置表""" + __tablename__ = 'email_services' + + id = Column(Integer, primary_key=True, autoincrement=True) + service_type = Column(String(50), nullable=False) # 'outlook', 'moe_mail' + name = Column(String(100), nullable=False) + config = Column(JSONEncodedDict, nullable=False) # 服务配置(加密存储) + enabled = Column(Boolean, default=True) + priority = Column(Integer, default=0) # 使用优先级 + last_used = Column(DateTime) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + +class RegistrationTask(Base): + """注册任务表""" + __tablename__ = 'registration_tasks' + + id = Column(Integer, primary_key=True, autoincrement=True) + task_uuid = Column(String(36), unique=True, nullable=False, index=True) # 任务唯一标识 + status = Column(String(20), default='pending') # 'pending', 'running', 'completed', 'failed', 'cancelled' + email_service_id = Column(Integer, ForeignKey('email_services.id'), index=True) # 使用的邮箱服务 + proxy = Column(String(255)) # 使用的代理 + logs = Column(Text) # 注册过程日志 + result = Column(JSONEncodedDict) # 注册结果 + error_message = Column(Text) + created_at = Column(DateTime, default=datetime.utcnow) + started_at = Column(DateTime) + completed_at = Column(DateTime) + + # 关系 + email_service = relationship('EmailService') + + +class Setting(Base): + """系统设置表""" + __tablename__ = 'settings' + + key = Column(String(100), primary_key=True) + value = Column(Text) + description = Column(Text) + category = Column(String(50), default='general') # 'general', 'email', 'proxy', 'openai' + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + +class CpaService(Base): + """CPA 服务配置表""" + __tablename__ = 'cpa_services' + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(100), nullable=False) # 服务名称 + api_url = Column(String(500), nullable=False) # API URL + api_token = Column(Text, nullable=False) # API Token + enabled = Column(Boolean, default=True) + 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 ExternalRegistrationBatch(Base): + """Persistent external registration batch.""" + __tablename__ = 'external_registration_batches' + + id = Column(Integer, primary_key=True, autoincrement=True) + batch_uuid = Column(String(36), unique=True, nullable=False, index=True) + idempotency_key = Column(String(255), unique=True, nullable=True, index=True) + status = Column(String(32), default='pending', nullable=False) + cancel_requested = Column(Boolean, default=False, nullable=False) + failure_reason = Column(String(255)) + failure_category = Column(String(20)) + email_service_type = Column(String(50), nullable=False) + upload_enabled = Column(Boolean, default=False, nullable=False) + upload_provider = Column(String(50)) + requested_count = Column(Integer, default=0, nullable=False) + completed_count = Column(Integer, default=0, nullable=False) + success_count = Column(Integer, default=0, nullable=False) + failed_count = Column(Integer, default=0, nullable=False) + upload_success_count = Column(Integer, default=0, nullable=False) + upload_failed_count = Column(Integer, default=0, nullable=False) + request_payload = Column(JSONEncodedDict) + runtime_snapshot = Column(JSONEncodedDict) + recent_errors = Column(JSONEncodedDict) + created_at = Column(DateTime, default=datetime.utcnow) + started_at = Column(DateTime) + completed_at = Column(DateTime) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + items = relationship('ExternalRegistrationBatchItem', back_populates='batch', cascade='all, delete-orphan') + + +class ExternalRegistrationBatchItem(Base): + """Per-item persistent state for an external registration batch.""" + __tablename__ = 'external_registration_batch_items' + + id = Column(Integer, primary_key=True, autoincrement=True) + batch_id = Column(Integer, ForeignKey('external_registration_batches.id'), nullable=False, index=True) + item_index = Column(Integer, nullable=False) + status = Column(String(32), default='pending', nullable=False) + failure_reason = Column(String(255)) + registration_task_uuid = Column(String(36), index=True) + selected_email_service_id = Column(Integer, ForeignKey('email_services.id'), index=True) + upload_status = Column(String(32), default='not_enabled', nullable=False) + upload_error = Column(Text) + created_at = Column(DateTime, default=datetime.utcnow) + started_at = Column(DateTime) + completed_at = Column(DateTime) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + batch = relationship('ExternalRegistrationBatch', back_populates='items') + email_service = relationship('EmailService') + + +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 0000000000000000000000000000000000000000..02a64919d10f2b69beec0f4cd1e6dc7183ec55fb --- /dev/null +++ b/src/database/session.py @@ -0,0 +1,183 @@ +""" +数据库会话管理 +""" + +from contextlib import contextmanager +from typing import Generator +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker, Session +from sqlalchemy.exc import SQLAlchemyError +import os +import logging + +from .models import Base + +logger = logging.getLogger(__name__) + + +def _build_sqlalchemy_url(database_url: str) -> str: + if database_url.startswith("postgresql://"): + return "postgresql+psycopg://" + database_url[len("postgresql://"):] + if database_url.startswith("postgres://"): + return "postgresql+psycopg://" + database_url[len("postgres://"):] + return database_url + + +class DatabaseSessionManager: + """数据库会话管理器""" + + def __init__(self, database_url: str = None): + if database_url is None: + env_url = os.environ.get("APP_DATABASE_URL") or os.environ.get("DATABASE_URL") + if env_url: + database_url = env_url + else: + # 优先使用 APP_DATA_DIR 环境变量(PyInstaller 打包后由 webui.py 设置) + data_dir = os.environ.get('APP_DATA_DIR') or os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(__file__))), + 'data' + ) + db_path = os.path.join(data_dir, 'database.db') + # 确保目录存在 + os.makedirs(data_dir, exist_ok=True) + database_url = f"sqlite:///{db_path}" + + self.database_url = _build_sqlalchemy_url(database_url) + self.engine = create_engine( + self.database_url, + connect_args={"check_same_thread": False} if self.database_url.startswith("sqlite") else {}, + echo=False, # 设置为 True 可以查看所有 SQL 语句 + pool_pre_ping=True # 连接池预检查 + ) + self.SessionLocal = sessionmaker(autocommit=False, autoflush=False, expire_on_commit=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"), + ("external_registration_batches", "failure_category", "VARCHAR(20)"), + ] + + # 确保新表存在(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 0000000000000000000000000000000000000000..ad29d3e5db1da75216d5d66fddded7be9bb5a943 --- /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 0000000000000000000000000000000000000000..aba923f22fc0b0fcf06ad6c50c63915545ede906 --- /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 0000000000000000000000000000000000000000..deb911ab209a47a4ace43b38487c3d6940b9e2ed --- /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 0000000000000000000000000000000000000000..e1e0587b0b919266194f4b9ef4f23c06ea7bca32 --- /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 0000000000000000000000000000000000000000..01573f645283654835bbe184e73c61b7687a6d8e --- /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 0000000000000000000000000000000000000000..dad6594ed0a68853959a020bdc9d53f1458fd11c --- /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 0000000000000000000000000000000000000000..fbdd660d2a875ec77b846fcffccf96c0f252cc8b --- /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 0000000000000000000000000000000000000000..6f427d5911ef880598dfb0d50b647725a65d90e2 --- /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 0000000000000000000000000000000000000000..335b11e934854f4f5b4a3a85f51297516271f0a6 --- /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 0000000000000000000000000000000000000000..8d0f553d00b750a76c40db0e9e56594cb5c5dc85 --- /dev/null +++ b/src/services/outlook/email_parser.py @@ -0,0 +1,236 @@ +""" +邮件解析和验证码提取 +""" + +import logging +import re +from typing import Optional, List, Dict, Any, Tuple + +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 email_delivery_key(self, email: EmailMessage) -> Tuple[int, str, str]: + """Build a stable key for a specific delivered email.""" + return ( + int(email.received_timestamp or 0), + (email.sender or "").strip().lower(), + (email.subject or "").strip().lower(), + ) + + def find_verification_code_in_emails( + self, + emails: List[EmailMessage], + target_email: Optional[str] = None, + min_timestamp: int = 0, + used_email_keys: Optional[set] = None, + ) -> Optional[Tuple[str, Tuple[int, str, str]]]: + """ + 从邮件列表中查找验证码 + + Args: + emails: 邮件列表 + target_email: 目标邮箱地址 + min_timestamp: 最小时间戳(用于过滤旧邮件) + used_email_keys: 已处理过的邮件投递集合(用于去重) + + Returns: + (验证码, 邮件投递键),如果未找到返回 None + """ + used_email_keys = used_email_keys or set() + + for email in emails: + # 时间戳过滤 + if min_timestamp > 0 and email.received_timestamp > 0: + if email.received_timestamp < min_timestamp: + logger.debug(f"跳过旧邮件: {email.subject[:50]}") + continue + + # 检查是否是 OpenAI 验证邮件 + if not self.is_openai_verification_email(email, target_email): + continue + + # 提取验证码 + code = self.extract_verification_code(email) + if code: + delivery_key = self.email_delivery_key(email) + if delivery_key in used_email_keys: + logger.debug(f"跳过已处理的邮件投递: {delivery_key}") + continue + + logger.info( + f"[{target_email or 'unknown'}] 找到验证码: {code}, " + f"邮件主题: {email.subject[:30]}" + ) + return code, delivery_key + + 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 0000000000000000000000000000000000000000..c68ed4ee7fff10e4b0fe41d4dd03597c33cd6b92 --- /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 0000000000000000000000000000000000000000..d6fe6a11ac0c3b33d3c045671e8fb3dbef299256 --- /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 0000000000000000000000000000000000000000..0d6c072e72f4b6f4b5da124c8ccd696d12c3c4e9 --- /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 0000000000000000000000000000000000000000..00cec808445317a28861cb9120ade538449cef3c --- /dev/null +++ b/src/services/outlook/providers/graph_api.py @@ -0,0 +1,250 @@ +""" +Graph API 提供者 +使用 Microsoft Graph REST API +""" + +import json +import logging +from typing import List, Optional +from datetime import datetime + +from curl_cffi import requests as _requests + +from ..base import ProviderType, EmailMessage +from ..account import OutlookAccount +from ..token_manager import TokenManager +from .base import OutlookProvider, ProviderConfig + + +logger = logging.getLogger(__name__) + + +class GraphAPIProvider(OutlookProvider): + """ + Graph API 提供者 + 使用 Microsoft Graph REST API 获取邮件 + 需要 graph.microsoft.com/.default scope + """ + + # Graph API 端点 + GRAPH_API_BASE = "https://graph.microsoft.com/v1.0" + MESSAGES_ENDPOINT = "/me/mailFolders/inbox/messages" + + @property + def provider_type(self) -> ProviderType: + return ProviderType.GRAPH_API + + def __init__( + self, + account: OutlookAccount, + config: Optional[ProviderConfig] = None, + ): + super().__init__(account, config) + + # Token 管理器 + self._token_manager: Optional[TokenManager] = None + + # 注意:Graph API 必须使用 OAuth2 + if not account.has_oauth(): + logger.warning( + f"[{self.account.email}] Graph API 提供者需要 OAuth2 配置 " + f"(client_id + refresh_token)" + ) + + def connect(self) -> bool: + """ + 验证连接(获取 Token) + + Returns: + 是否连接成功 + """ + if not self.account.has_oauth(): + error = "Graph API 需要 OAuth2 配置" + self.record_failure(error) + logger.error(f"[{self.account.email}] {error}") + return False + + if not self._token_manager: + self._token_manager = TokenManager( + self.account, + ProviderType.GRAPH_API, + self.config.proxy_url, + self.config.timeout, + ) + + # 尝试获取 Token + token = self._token_manager.get_access_token() + if token: + self._connected = True + self.record_success() + logger.info(f"[{self.account.email}] Graph API 连接成功") + return True + + return False + + def disconnect(self): + """断开连接(清除状态)""" + self._connected = False + + def get_recent_emails( + self, + count: int = 20, + only_unseen: bool = True, + ) -> List[EmailMessage]: + """ + 获取最近的邮件 + + Args: + count: 获取数量 + only_unseen: 是否只获取未读 + + Returns: + 邮件列表 + """ + if not self._connected: + if not self.connect(): + return [] + + try: + # 获取 Access Token + token = self._token_manager.get_access_token() + if not token: + self.record_failure("无法获取 Access Token") + return [] + + # 构建 API 请求 + url = f"{self.GRAPH_API_BASE}{self.MESSAGES_ENDPOINT}" + + params = { + "$top": count, + "$select": "id,subject,from,toRecipients,receivedDateTime,isRead,hasAttachments,bodyPreview,body", + "$orderby": "receivedDateTime desc", + } + + # 只获取未读邮件 + if only_unseen: + params["$filter"] = "isRead eq false" + + # 构建代理配置 + proxies = None + if self.config.proxy_url: + proxies = {"http": self.config.proxy_url, "https": self.config.proxy_url} + + # 发送请求(curl_cffi 自动对 params 进行 URL 编码) + resp = _requests.get( + url, + params=params, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/json", + "Prefer": "outlook.body-content-type='text'", + }, + proxies=proxies, + timeout=self.config.timeout, + impersonate="chrome110", + ) + + if resp.status_code == 401: + # Token 无 Graph 权限(client_id 未授权),清除缓存但不记录健康失败 + # 避免因权限不足导致健康检查器禁用该提供者,影响其他账户 + if self._token_manager: + self._token_manager.clear_cache() + self._connected = False + logger.warning(f"[{self.account.email}] Graph API 返回 401,client_id 可能无 Graph 权限,跳过") + return [] + + if resp.status_code != 200: + error_body = resp.text[:200] + self.record_failure(f"HTTP {resp.status_code}: {error_body}") + logger.error(f"[{self.account.email}] Graph API 请求失败: HTTP {resp.status_code}") + return [] + + data = resp.json() + + # 解析邮件 + messages = data.get("value", []) + emails = [] + + for msg in messages: + try: + email_msg = self._parse_graph_message(msg) + if email_msg: + emails.append(email_msg) + except Exception as e: + logger.warning(f"[{self.account.email}] 解析 Graph API 邮件失败: {e}") + + self.record_success() + return emails + + except Exception as e: + self.record_failure(str(e)) + logger.error(f"[{self.account.email}] Graph API 获取邮件失败: {e}") + return [] + + def _parse_graph_message(self, msg: dict) -> Optional[EmailMessage]: + """ + 解析 Graph API 消息 + + Args: + msg: Graph API 消息对象 + + Returns: + EmailMessage 对象 + """ + # 解析发件人 + from_info = msg.get("from", {}) + sender_info = from_info.get("emailAddress", {}) + sender = sender_info.get("address", "") + + # 解析收件人 + recipients = [] + for recipient in msg.get("toRecipients", []): + addr_info = recipient.get("emailAddress", {}) + addr = addr_info.get("address", "") + if addr: + recipients.append(addr) + + # 解析日期 + received_at = None + received_timestamp = 0 + try: + date_str = msg.get("receivedDateTime", "") + if date_str: + # ISO 8601 格式 + received_at = datetime.fromisoformat(date_str.replace("Z", "+00:00")) + received_timestamp = int(received_at.timestamp()) + except Exception: + pass + + # 获取正文 + body_info = msg.get("body", {}) + body = body_info.get("content", "") + body_preview = msg.get("bodyPreview", "") + + return EmailMessage( + id=msg.get("id", ""), + subject=msg.get("subject", ""), + sender=sender, + recipients=recipients, + body=body, + body_preview=body_preview, + received_at=received_at, + received_timestamp=received_timestamp, + is_read=msg.get("isRead", False), + has_attachments=msg.get("hasAttachments", False), + ) + + def test_connection(self) -> bool: + """ + 测试 Graph API 连接 + + Returns: + 连接是否正常 + """ + try: + # 尝试获取一封邮件来测试连接 + emails = self.get_recent_emails(count=1, only_unseen=False) + return True + except Exception as e: + logger.warning(f"[{self.account.email}] Graph API 连接测试失败: {e}") + return False diff --git a/src/services/outlook/providers/imap_new.py b/src/services/outlook/providers/imap_new.py new file mode 100644 index 0000000000000000000000000000000000000000..5daa2f390ea0fcdfb90dcdfc43a27eba18d79153 --- /dev/null +++ b/src/services/outlook/providers/imap_new.py @@ -0,0 +1,231 @@ +""" +新版 IMAP 提供者 +使用 outlook.live.com 服务器和 login.microsoftonline.com/consumers Token 端点 +""" + +import email +import imaplib +import logging +from email.header import decode_header +from email.utils import parsedate_to_datetime +from typing import List, Optional + +from ..base import ProviderType, EmailMessage +from ..account import OutlookAccount +from ..token_manager import TokenManager +from .base import OutlookProvider, ProviderConfig +from .imap_old import IMAPOldProvider + + +logger = logging.getLogger(__name__) + + +class IMAPNewProvider(OutlookProvider): + """ + 新版 IMAP 提供者 + 使用 outlook.live.com:993 和 login.microsoftonline.com/consumers Token 端点 + 需要 IMAP.AccessAsUser.All scope + """ + + # IMAP 服务器配置 + IMAP_HOST = "outlook.live.com" + IMAP_PORT = 993 + + @property + def provider_type(self) -> ProviderType: + return ProviderType.IMAP_NEW + + def __init__( + self, + account: OutlookAccount, + config: Optional[ProviderConfig] = None, + ): + super().__init__(account, config) + + # IMAP 连接 + self._conn: Optional[imaplib.IMAP4_SSL] = None + + # Token 管理器 + self._token_manager: Optional[TokenManager] = None + + # 注意:新版 IMAP 必须使用 OAuth2 + if not account.has_oauth(): + logger.warning( + f"[{self.account.email}] 新版 IMAP 提供者需要 OAuth2 配置 " + f"(client_id + refresh_token)" + ) + + def connect(self) -> bool: + """ + 连接到 IMAP 服务器 + + Returns: + 是否连接成功 + """ + if self._connected and self._conn: + try: + self._conn.noop() + return True + except Exception: + self.disconnect() + + # 新版 IMAP 必须使用 OAuth2,无 OAuth 时静默跳过,不记录健康失败 + if not self.account.has_oauth(): + logger.debug(f"[{self.account.email}] 跳过 IMAP_NEW(无 OAuth)") + return False + + try: + logger.debug(f"[{self.account.email}] 正在连接 IMAP ({self.IMAP_HOST})...") + + # 创建连接 + self._conn = imaplib.IMAP4_SSL( + self.IMAP_HOST, + self.IMAP_PORT, + timeout=self.config.timeout, + ) + + # XOAUTH2 认证 + if self._authenticate_xoauth2(): + self._connected = True + self.record_success() + logger.info(f"[{self.account.email}] 新版 IMAP 连接成功 (XOAUTH2)") + return True + + return False + + except Exception as e: + self.disconnect() + self.record_failure(str(e)) + logger.error(f"[{self.account.email}] 新版 IMAP 连接失败: {e}") + return False + + def _authenticate_xoauth2(self) -> bool: + """ + 使用 XOAUTH2 认证 + + Returns: + 是否认证成功 + """ + if not self._token_manager: + self._token_manager = TokenManager( + self.account, + ProviderType.IMAP_NEW, + self.config.proxy_url, + self.config.timeout, + ) + + # 获取 Access Token + token = self._token_manager.get_access_token() + if not token: + logger.error(f"[{self.account.email}] 获取 IMAP Token 失败") + return False + + try: + # 构建 XOAUTH2 认证字符串 + auth_string = f"user={self.account.email}\x01auth=Bearer {token}\x01\x01" + self._conn.authenticate("XOAUTH2", lambda _: auth_string.encode("utf-8")) + return True + except Exception as e: + logger.error(f"[{self.account.email}] XOAUTH2 认证异常: {e}") + # 清除缓存的 Token + self._token_manager.clear_cache() + return False + + def disconnect(self): + """断开 IMAP 连接""" + if self._conn: + try: + self._conn.close() + except Exception: + pass + try: + self._conn.logout() + except Exception: + pass + self._conn = None + + self._connected = False + + def get_recent_emails( + self, + count: int = 20, + only_unseen: bool = True, + ) -> List[EmailMessage]: + """ + 获取最近的邮件 + + Args: + count: 获取数量 + only_unseen: 是否只获取未读 + + Returns: + 邮件列表 + """ + if not self._connected: + if not self.connect(): + return [] + + try: + # 选择收件箱 + self._conn.select("INBOX", readonly=True) + + # 搜索邮件 + flag = "UNSEEN" if only_unseen else "ALL" + status, data = self._conn.search(None, flag) + + if status != "OK" or not data or not data[0]: + return [] + + # 获取最新的邮件 ID + ids = data[0].split() + recent_ids = ids[-count:][::-1] + + emails = [] + for msg_id in recent_ids: + try: + email_msg = self._fetch_email(msg_id) + if email_msg: + emails.append(email_msg) + except Exception as e: + logger.warning(f"[{self.account.email}] 解析邮件失败 (ID: {msg_id}): {e}") + + return emails + + except Exception as e: + self.record_failure(str(e)) + logger.error(f"[{self.account.email}] 获取邮件失败: {e}") + return [] + + def _fetch_email(self, msg_id: bytes) -> Optional[EmailMessage]: + """获取并解析单封邮件""" + status, data = self._conn.fetch(msg_id, "(RFC822)") + if status != "OK" or not data or not data[0]: + return None + + raw = b"" + for part in data: + if isinstance(part, tuple) and len(part) > 1: + raw = part[1] + break + + if not raw: + return None + + return self._parse_email(raw) + + @staticmethod + def _parse_email(raw: bytes) -> EmailMessage: + """解析原始邮件""" + # 使用旧版提供者的解析方法 + return IMAPOldProvider._parse_email(raw) + + def test_connection(self) -> bool: + """测试 IMAP 连接""" + try: + with self: + self._conn.select("INBOX", readonly=True) + self._conn.search(None, "ALL") + return True + except Exception as e: + logger.warning(f"[{self.account.email}] 新版 IMAP 连接测试失败: {e}") + return False diff --git a/src/services/outlook/providers/imap_old.py b/src/services/outlook/providers/imap_old.py new file mode 100644 index 0000000000000000000000000000000000000000..e46f3edfb7e234a3f052714b2eb7f346e3438b8f --- /dev/null +++ b/src/services/outlook/providers/imap_old.py @@ -0,0 +1,345 @@ +""" +旧版 IMAP 提供者 +使用 outlook.office365.com 服务器和 login.live.com Token 端点 +""" + +import email +import imaplib +import logging +from email.header import decode_header +from email.utils import parsedate_to_datetime +from typing import List, Optional + +from ..base import ProviderType, EmailMessage +from ..account import OutlookAccount +from ..token_manager import TokenManager +from .base import OutlookProvider, ProviderConfig + + +logger = logging.getLogger(__name__) + + +class IMAPOldProvider(OutlookProvider): + """ + 旧版 IMAP 提供者 + 使用 outlook.office365.com:993 和 login.live.com Token 端点 + """ + + # IMAP 服务器配置 + IMAP_HOST = "outlook.office365.com" + IMAP_PORT = 993 + + @property + def provider_type(self) -> ProviderType: + return ProviderType.IMAP_OLD + + def __init__( + self, + account: OutlookAccount, + config: Optional[ProviderConfig] = None, + ): + super().__init__(account, config) + + # IMAP 连接 + self._conn: Optional[imaplib.IMAP4_SSL] = None + + # Token 管理器 + self._token_manager: Optional[TokenManager] = None + + def connect(self) -> bool: + """ + 连接到 IMAP 服务器 + + Returns: + 是否连接成功 + """ + if self._connected and self._conn: + # 检查现有连接 + try: + self._conn.noop() + return True + except Exception: + self.disconnect() + + try: + logger.debug(f"[{self.account.email}] 正在连接 IMAP ({self.IMAP_HOST})...") + + # 创建连接 + self._conn = imaplib.IMAP4_SSL( + self.IMAP_HOST, + self.IMAP_PORT, + timeout=self.config.timeout, + ) + + # 尝试 XOAUTH2 认证 + if self.account.has_oauth(): + if self._authenticate_xoauth2(): + self._connected = True + self.record_success() + logger.info(f"[{self.account.email}] IMAP 连接成功 (XOAUTH2)") + return True + else: + logger.warning(f"[{self.account.email}] XOAUTH2 认证失败,尝试密码认证") + + # 密码认证 + if self.account.password: + self._conn.login(self.account.email, self.account.password) + self._connected = True + self.record_success() + logger.info(f"[{self.account.email}] IMAP 连接成功 (密码认证)") + return True + + raise ValueError("没有可用的认证方式") + + except Exception as e: + self.disconnect() + self.record_failure(str(e)) + logger.error(f"[{self.account.email}] IMAP 连接失败: {e}") + return False + + def _authenticate_xoauth2(self) -> bool: + """ + 使用 XOAUTH2 认证 + + Returns: + 是否认证成功 + """ + if not self._token_manager: + self._token_manager = TokenManager( + self.account, + ProviderType.IMAP_OLD, + self.config.proxy_url, + self.config.timeout, + ) + + # 获取 Access Token + token = self._token_manager.get_access_token() + if not token: + return False + + try: + # 构建 XOAUTH2 认证字符串 + auth_string = f"user={self.account.email}\x01auth=Bearer {token}\x01\x01" + self._conn.authenticate("XOAUTH2", lambda _: auth_string.encode("utf-8")) + return True + except Exception as e: + logger.debug(f"[{self.account.email}] XOAUTH2 认证异常: {e}") + # 清除缓存的 Token + self._token_manager.clear_cache() + return False + + def disconnect(self): + """断开 IMAP 连接""" + if self._conn: + try: + self._conn.close() + except Exception: + pass + try: + self._conn.logout() + except Exception: + pass + self._conn = None + + self._connected = False + + def get_recent_emails( + self, + count: int = 20, + only_unseen: bool = True, + ) -> List[EmailMessage]: + """ + 获取最近的邮件 + + Args: + count: 获取数量 + only_unseen: 是否只获取未读 + + Returns: + 邮件列表 + """ + if not self._connected: + if not self.connect(): + return [] + + try: + # 选择收件箱 + self._conn.select("INBOX", readonly=True) + + # 搜索邮件 + flag = "UNSEEN" if only_unseen else "ALL" + status, data = self._conn.search(None, flag) + + if status != "OK" or not data or not data[0]: + return [] + + # 获取最新的邮件 ID + ids = data[0].split() + recent_ids = ids[-count:][::-1] # 倒序,最新的在前 + + emails = [] + for msg_id in recent_ids: + try: + email_msg = self._fetch_email(msg_id) + if email_msg: + emails.append(email_msg) + except Exception as e: + logger.warning(f"[{self.account.email}] 解析邮件失败 (ID: {msg_id}): {e}") + + return emails + + except Exception as e: + self.record_failure(str(e)) + logger.error(f"[{self.account.email}] 获取邮件失败: {e}") + return [] + + def _fetch_email(self, msg_id: bytes) -> Optional[EmailMessage]: + """ + 获取并解析单封邮件 + + Args: + msg_id: 邮件 ID + + Returns: + EmailMessage 对象,失败返回 None + """ + status, data = self._conn.fetch(msg_id, "(RFC822)") + if status != "OK" or not data or not data[0]: + return None + + # 获取原始邮件内容 + raw = b"" + for part in data: + if isinstance(part, tuple) and len(part) > 1: + raw = part[1] + break + + if not raw: + return None + + return self._parse_email(raw) + + @staticmethod + def _parse_email(raw: bytes) -> EmailMessage: + """ + 解析原始邮件 + + Args: + raw: 原始邮件数据 + + Returns: + EmailMessage 对象 + """ + # 移除 BOM + if raw.startswith(b"\xef\xbb\xbf"): + raw = raw[3:] + + msg = email.message_from_bytes(raw) + + # 解析邮件头 + subject = IMAPOldProvider._decode_header(msg.get("Subject", "")) + sender = IMAPOldProvider._decode_header(msg.get("From", "")) + to = IMAPOldProvider._decode_header(msg.get("To", "")) + delivered_to = IMAPOldProvider._decode_header(msg.get("Delivered-To", "")) + x_original_to = IMAPOldProvider._decode_header(msg.get("X-Original-To", "")) + date_str = IMAPOldProvider._decode_header(msg.get("Date", "")) + + # 提取正文 + body = IMAPOldProvider._extract_body(msg) + + # 解析日期 + received_timestamp = 0 + received_at = None + try: + if date_str: + received_at = parsedate_to_datetime(date_str) + received_timestamp = int(received_at.timestamp()) + except Exception: + pass + + # 构建收件人列表 + recipients = [r for r in [to, delivered_to, x_original_to] if r] + + return EmailMessage( + id=msg.get("Message-ID", ""), + subject=subject, + sender=sender, + recipients=recipients, + body=body, + received_at=received_at, + received_timestamp=received_timestamp, + is_read=False, # 搜索的是未读邮件 + raw_data=raw[:500] if len(raw) > 500 else raw, + ) + + @staticmethod + def _decode_header(header: str) -> str: + """解码邮件头""" + if not header: + return "" + + parts = [] + for chunk, encoding in decode_header(header): + if isinstance(chunk, bytes): + try: + decoded = chunk.decode(encoding or "utf-8", errors="replace") + parts.append(decoded) + except Exception: + parts.append(chunk.decode("utf-8", errors="replace")) + else: + parts.append(str(chunk)) + + return "".join(parts).strip() + + @staticmethod + def _extract_body(msg) -> str: + """提取邮件正文""" + import html as html_module + import re + + texts = [] + parts = msg.walk() if msg.is_multipart() else [msg] + + for part in parts: + content_type = part.get_content_type() + if content_type not in ("text/plain", "text/html"): + continue + + payload = part.get_payload(decode=True) + if not payload: + continue + + charset = part.get_content_charset() or "utf-8" + try: + text = payload.decode(charset, errors="replace") + except LookupError: + text = payload.decode("utf-8", errors="replace") + + # 如果是 HTML,移除标签 + if "]+>", " ", text) + + texts.append(text) + + # 合并并清理文本 + combined = " ".join(texts) + combined = html_module.unescape(combined) + combined = re.sub(r"\s+", " ", combined).strip() + + return combined + + def test_connection(self) -> bool: + """ + 测试 IMAP 连接 + + Returns: + 连接是否正常 + """ + try: + with self: + self._conn.select("INBOX", readonly=True) + self._conn.search(None, "ALL") + return True + except Exception as e: + logger.warning(f"[{self.account.email}] IMAP 连接测试失败: {e}") + return False diff --git a/src/services/outlook/service.py b/src/services/outlook/service.py new file mode 100644 index 0000000000000000000000000000000000000000..b72ca19f4fe10bbc220fc417d94545b24146415d --- /dev/null +++ b/src/services/outlook/service.py @@ -0,0 +1,712 @@ +""" +Outlook 邮箱服务主类 +支持多种 IMAP/API 连接方式,自动故障切换 +""" + +import logging +import threading +import time +from typing import Optional, Dict, Any, List + +from ..base import BaseEmailService, EmailServiceError, EmailServiceStatus, EmailServiceType +from ...config.constants import EmailServiceType as ServiceType +from ...config.settings import get_settings +from .account import OutlookAccount +from .base import ProviderType, EmailMessage +from .email_parser import EmailParser, get_email_parser +from .health_checker import HealthChecker, FailoverManager +from .providers.base import OutlookProvider, ProviderConfig +from .providers.imap_old import IMAPOldProvider +from .providers.imap_new import IMAPNewProvider +from .providers.graph_api import GraphAPIProvider + + +logger = logging.getLogger(__name__) + + +# 默认提供者优先级 +# IMAP_OLD 最兼容(只需 login.live.com token),IMAP_NEW 次之,Graph API 最后 +# 原因:部分 client_id 没有 Graph API 权限,但有 IMAP 权限 +DEFAULT_PROVIDER_PRIORITY = [ + ProviderType.IMAP_OLD, + ProviderType.IMAP_NEW, + ProviderType.GRAPH_API, +] + + +def get_email_code_settings() -> dict: + """获取验证码等待配置""" + settings = get_settings() + return { + "timeout": settings.email_code_timeout, + "poll_interval": settings.email_code_poll_interval, + } + + +class OutlookService(BaseEmailService): + """ + Outlook 邮箱服务 + 支持多种 IMAP/API 连接方式,自动故障切换 + """ + + def __init__(self, config: Dict[str, Any] = None, name: str = None): + """ + 初始化 Outlook 服务 + + Args: + config: 配置字典,支持以下键: + - accounts: Outlook 账户列表 + - provider_priority: 提供者优先级列表 + - health_failure_threshold: 连续失败次数阈值 + - health_disable_duration: 禁用时长(秒) + - timeout: 请求超时时间 + - proxy_url: 代理 URL + name: 服务名称 + """ + super().__init__(ServiceType.OUTLOOK, name) + + # 默认配置 + default_config = { + "accounts": [], + "provider_priority": [p.value for p in DEFAULT_PROVIDER_PRIORITY], + "health_failure_threshold": 5, + "health_disable_duration": 60, + "timeout": 30, + "proxy_url": None, + } + + self.config = {**default_config, **(config or {})} + + # 解析提供者优先级 + self.provider_priority = [ + ProviderType(p) for p in self.config.get("provider_priority", []) + ] + if not self.provider_priority: + self.provider_priority = DEFAULT_PROVIDER_PRIORITY + + # 提供者配置 + self.provider_config = ProviderConfig( + timeout=self.config.get("timeout", 30), + proxy_url=self.config.get("proxy_url"), + health_failure_threshold=self.config.get("health_failure_threshold", 3), + health_disable_duration=self.config.get("health_disable_duration", 300), + ) + + # 获取默认 client_id(供无 client_id 的账户使用) + try: + _default_client_id = get_settings().outlook_default_client_id + except Exception: + _default_client_id = "24d9a0ed-8787-4584-883c-2fd79308940a" + + # 解析账户 + self.accounts: List[OutlookAccount] = [] + self._current_account_index = 0 + self._account_lock = threading.Lock() + + # 支持两种配置格式 + if "email" in self.config and "password" in self.config: + account = OutlookAccount.from_config(self.config) + if not account.client_id and _default_client_id: + account.client_id = _default_client_id + if account.validate(): + self.accounts.append(account) + else: + for account_config in self.config.get("accounts", []): + account = OutlookAccount.from_config(account_config) + if not account.client_id and _default_client_id: + account.client_id = _default_client_id + if account.validate(): + self.accounts.append(account) + + if not self.accounts: + logger.warning("未配置有效的 Outlook 账户") + + # 健康检查器和故障切换管理器 + self.health_checker = HealthChecker( + failure_threshold=self.provider_config.health_failure_threshold, + disable_duration=self.provider_config.health_disable_duration, + ) + self.failover_manager = FailoverManager( + health_checker=self.health_checker, + priority_order=self.provider_priority, + ) + + # 邮件解析器 + self.email_parser = get_email_parser() + + # 提供者实例缓存: (email, provider_type) -> OutlookProvider + self._providers: Dict[tuple, OutlookProvider] = {} + self._provider_lock = threading.Lock() + + # IMAP 连接限制(防止限流) + self._imap_semaphore = threading.Semaphore(5) + + # 邮件投递去重机制:避免重复消费同一封邮件,但允许新邮件复用相同验证码 + self._used_email_deliveries: Dict[tuple, set] = {} + self._verification_stage_by_email: Dict[str, str] = {} + self._last_verification_debug: Dict[str, Dict[str, Any]] = {} + + 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 set_verification_stage(self, email: str, stage: str) -> None: + """Track the current OTP stage for each Outlook mailbox. + + When switching stages (e.g. signup_otp → relogin_otp), carry over + already-consumed email deliveries so the new stage won't re-consume + OTP emails that were already used in the previous stage. + """ + email_lower = email.lower() + old_stage = self._verification_stage_by_email.get(email_lower) + self._verification_stage_by_email[email_lower] = stage + + # 跨阶段继承已消费的邮件投递记录 + if old_stage and old_stage != stage: + old_key = (email_lower, old_stage) + new_key = (email_lower, stage) + old_used = self._used_email_deliveries.get(old_key, set()) + if old_used: + if new_key not in self._used_email_deliveries: + self._used_email_deliveries[new_key] = set() + self._used_email_deliveries[new_key].update(old_used) + logger.info( + f"[{email}] 阶段切换 {old_stage} → {stage}," + f"继承 {len(old_used)} 条已消费邮件记录" + ) + + def _is_preferred_openai_otp_sender(self, email: EmailMessage) -> bool: + sender = (email.sender or "").lower() + return "otp@" in sender and ".openai.com" in sender + + def _build_candidate_summary( + self, + email: EmailMessage, + *, + target_email: str, + otp_sent_at: Optional[float], + ) -> Dict[str, Any]: + is_openai = self.email_parser.is_openai_verification_email(email, target_email=target_email) + code = self.email_parser.extract_verification_code(email) if is_openai else None + received_ts = int(email.received_timestamp or 0) + otp_ts = int(otp_sent_at or 0) + return { + "sender": email.sender, + "subject": email.subject, + "received_timestamp": received_ts, + "delta_from_otp_sent": received_ts - otp_ts if otp_ts else None, + "is_openai_verification": is_openai, + "is_preferred_sender": self._is_preferred_openai_otp_sender(email), + "code": code, + } + + def _format_candidate_summary(self, summary: Dict[str, Any]) -> str: + sender = str(summary.get("sender") or "-") + received_ts = summary.get("received_timestamp") + delta = summary.get("delta_from_otp_sent") + code = str(summary.get("code") or "-") + preferred = "preferred" if summary.get("is_preferred_sender") else "generic" + return ( + f"sender={sender}, ts={received_ts}, delta={delta}, " + f"code={code}, kind={preferred}" + ) + + def get_last_verification_debug(self, email: str) -> Dict[str, Any]: + return dict(self._last_verification_debug.get(email.lower(), {})) + + @staticmethod + def _is_email_within_otp_window(email: EmailMessage, min_timestamp: int) -> bool: + received_timestamp = int(getattr(email, "received_timestamp", 0) or 0) + return not ( + min_timestamp > 0 + and received_timestamp > 0 + and received_timestamp < min_timestamp + ) + + def _is_email_delivery_unused( + self, + email: EmailMessage, + used_email_deliveries: set, + ) -> bool: + return self.email_parser.email_delivery_key(email) not in used_email_deliveries + + 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]}" + ) + + # 初始化邮件投递去重集合 + stage = self._verification_stage_by_email.get(email.lower(), "signup_otp") + logger.info(f"[{email}] verification stage: {stage}") + used_delivery_key = (email.lower(), stage) + if used_delivery_key not in self._used_email_deliveries: + self._used_email_deliveries[used_delivery_key] = set() + used_email_deliveries = self._used_email_deliveries[used_delivery_key] + debug_state = { + "stage": stage, + "poll_count": 0, + "otp_sent_at": int(otp_sent_at or 0), + "min_timestamp": int((otp_sent_at - 60) if otp_sent_at else 0), + "deferred_generic_only_polls": 0, + "fresh_verification_count": 0, + "fresh_preferred_sender_count": 0, + "stale_preferred_sender_count": 0, + "available_fresh_verification_count": 0, + "available_fresh_preferred_sender_count": 0, + "used_fresh_preferred_sender_count": 0, + "selected_sender": None, + "selected_code": None, + "selected_received_timestamp": None, + "candidate_summaries": [], + "last_status": "waiting", + } + self._last_verification_debug[email.lower()] = debug_state + + # 计算最小时间戳(留出 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 + debug_state["poll_count"] = poll_count + + # 渐进式邮件检查:前 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)} 封邮件" + ) + candidate_summaries = [ + self._build_candidate_summary( + item, + target_email=email, + otp_sent_at=otp_sent_at, + ) + for item in emails[:5] + ] + debug_state["candidate_summaries"] = candidate_summaries + if candidate_summaries: + logger.info( + f"[{email}] OTP candidates poll={poll_count}: " + + " | ".join( + self._format_candidate_summary(item) + for item in candidate_summaries + ) + ) + + candidate_emails = emails + if stage == "relogin_otp": + verification_emails = [ + item + for item in emails + if self.email_parser.is_openai_verification_email( + item, + target_email=email, + ) + ] + fresh_verification_emails = [ + item + for item in verification_emails + if self._is_email_within_otp_window(item, min_timestamp) + ] + preferred_sender_emails = [ + item + for item in verification_emails + if self._is_preferred_openai_otp_sender(item) + ] + fresh_preferred_sender_emails = [ + item + for item in preferred_sender_emails + if self._is_email_within_otp_window(item, min_timestamp) + ] + available_fresh_verification_emails = [ + item + for item in fresh_verification_emails + if self._is_email_delivery_unused(item, used_email_deliveries) + ] + available_fresh_preferred_sender_emails = [ + item + for item in fresh_preferred_sender_emails + if self._is_email_delivery_unused(item, used_email_deliveries) + ] + stale_preferred_sender_count = ( + len(preferred_sender_emails) - len(fresh_preferred_sender_emails) + ) + used_fresh_preferred_sender_count = ( + len(fresh_preferred_sender_emails) - len(available_fresh_preferred_sender_emails) + ) + debug_state["fresh_verification_count"] = len(fresh_verification_emails) + debug_state["fresh_preferred_sender_count"] = len(fresh_preferred_sender_emails) + debug_state["stale_preferred_sender_count"] = stale_preferred_sender_count + debug_state["available_fresh_verification_count"] = len(available_fresh_verification_emails) + debug_state["available_fresh_preferred_sender_count"] = len(available_fresh_preferred_sender_emails) + debug_state["used_fresh_preferred_sender_count"] = used_fresh_preferred_sender_count + + if available_fresh_preferred_sender_emails: + candidate_emails = available_fresh_preferred_sender_emails + logger.info( + f"[{email}] relogin_otp 命中首选 OTP 发件人,优先使用 " + f"{len(available_fresh_preferred_sender_emails)} 封当前窗口内且未消费的 otp@*.openai.com 邮件" + ) + elif available_fresh_verification_emails and poll_count < 3: + debug_state["deferred_generic_only_polls"] += 1 + debug_state["last_status"] = "deferred_generic_only" + if stale_preferred_sender_count: + logger.info( + f"[{email}] relogin_otp 仅发现 {len(available_fresh_verification_emails)} 封当前窗口内通用验证码," + f"同时过滤掉 {stale_preferred_sender_count} 封过旧 otp@*.openai.com 邮件,继续等待" + ) + if used_fresh_preferred_sender_count: + logger.info( + f"[{email}] relogin_otp 当前窗口内有 {used_fresh_preferred_sender_count} 封 preferred 邮件已消费," + "本轮不再重复使用" + ) + logger.info( + f"[{email}] relogin_otp 暂缓消费通用验证码邮件,继续等待 otp@*.openai.com" + ) + time.sleep(poll_interval) + continue + elif available_fresh_verification_emails: + candidate_emails = available_fresh_verification_emails + if stale_preferred_sender_count: + logger.info( + f"[{email}] relogin_otp 当前窗口内没有可用的 otp@*.openai.com 邮件," + f"改用 {len(available_fresh_verification_emails)} 封当前窗口内通用验证码," + f"已过滤 {stale_preferred_sender_count} 封过旧 preferred 邮件" + ) + if used_fresh_preferred_sender_count: + logger.info( + f"[{email}] relogin_otp 当前窗口内的 preferred 邮件都已消费," + f"改用 {len(available_fresh_verification_emails)} 封未消费验证码" + ) + elif verification_emails: + candidate_emails = verification_emails + + # 从邮件中查找验证码 + match = self.email_parser.find_verification_code_in_emails( + candidate_emails, + target_email=email, + min_timestamp=min_timestamp, + used_email_keys=used_email_deliveries, + ) + + if match: + code, delivery_key = match + used_email_deliveries.add(delivery_key) + selected_email = next( + ( + item + for item in candidate_emails + if self.email_parser.email_delivery_key(item) == delivery_key + ), + None, + ) + if selected_email is not None: + debug_state["selected_sender"] = selected_email.sender + debug_state["selected_received_timestamp"] = int(selected_email.received_timestamp or 0) + debug_state["selected_code"] = code + debug_state["last_status"] = "selected" + 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) + debug_state["last_status"] = "timeout" + 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 0000000000000000000000000000000000000000..77e54f20b5d3fcbc1f52bce0a03dd2a03717a793 --- /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 0000000000000000000000000000000000000000..3fd6a7df69a8f232baa5238cf56969d59f0e0bd4 --- /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 0000000000000000000000000000000000000000..807ab6468823bed9025d6a404525b32e37e95ee6 --- /dev/null +++ b/src/services/temp_mail.py @@ -0,0 +1,724 @@ +""" +Temp-Mail 邮箱服务实现 +基于自部署 Cloudflare Worker 临时邮箱服务 +接口文档参见 plan/temp-mail.md +""" + +import re +import time +import json +import logging +from datetime import datetime +from email import message_from_string +from email.header import decode_header, make_header +from email.message import Message +from email.policy import default as email_policy +from html import unescape +from typing import Optional, Dict, Any, List + +from .base import BaseEmailService, EmailServiceError, EmailServiceType +from ..core.http_client import HTTPClient, RequestConfig +from ..config.constants import OTP_CODE_PATTERN +from .temp_mail_addressing import generate_local_part, choose_domain +from .temp_mail_domain_provider import resolve_temp_mail_domains, summarize_temp_mail_domains + + +logger = logging.getLogger(__name__) + +SEMANTIC_OTP_PATTERNS = ( + re.compile(r"(?:openai\s+verification\s+code\s+is|verification\s+code\s+is|code\s+is)\s*[::]?\s*(\d{6})", re.I), + re.compile(r"(?:openai\s+verification\s+code|verification\s+code|验证码)\s*[::]?\s*(\d{6})", re.I), +) + + +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(可选,作为 domains/worker 回退) + - domains: 可选域名池,数组或逗号分隔字符串(优先级最高) + - enable_prefix: 是否启用前缀,默认 False + - timeout: 请求超时时间,默认 30 + - max_retries: 最大重试次数,默认 3 + name: 服务名称 + """ + super().__init__(EmailServiceType.TEMP_MAIL, name) + + required_keys = ["base_url", "admin_password"] + 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": False, + "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]] = {} + self._verification_state: Dict[str, Dict[str, Any]] = {} + self._last_verification_debug: Dict[str, Dict[str, Any]] = {} + + def _decode_mime_header(self, value: str) -> str: + """解码 MIME 头,兼容 RFC 2047 编码主题。""" + if not value: + return "" + try: + return str(make_header(decode_header(value))) + except Exception: + return value + + def _extract_body_from_message(self, message: Message) -> str: + """从 MIME 邮件对象中提取可读正文。""" + parts: List[str] = [] + + if message.is_multipart(): + for part in message.walk(): + if part.get_content_maintype() == "multipart": + continue + + content_type = (part.get_content_type() or "").lower() + if content_type not in ("text/plain", "text/html"): + continue + + try: + payload = part.get_payload(decode=True) + charset = part.get_content_charset() or "utf-8" + text = payload.decode(charset, errors="replace") if payload else "" + except Exception: + try: + text = part.get_content() + except Exception: + text = "" + + if content_type == "text/html": + text = re.sub(r"<[^>]+>", " ", text) + parts.append(text) + else: + try: + payload = message.get_payload(decode=True) + charset = message.get_content_charset() or "utf-8" + body = payload.decode(charset, errors="replace") if payload else "" + except Exception: + try: + body = message.get_content() + except Exception: + body = str(message.get_payload() or "") + + if "html" in (message.get_content_type() or "").lower(): + body = re.sub(r"<[^>]+>", " ", body) + parts.append(body) + + return unescape("\n".join(part for part in parts if part).strip()) + + def _extract_mail_fields(self, mail: Dict[str, Any]) -> Dict[str, str]: + """统一提取邮件字段,兼容 raw MIME 和不同 Worker 返回格式。""" + sender = str( + mail.get("source") + or mail.get("from") + or mail.get("from_address") + or mail.get("fromAddress") + or "" + ).strip() + subject = str(mail.get("subject") or mail.get("title") or "").strip() + body_text = str( + mail.get("text") + or mail.get("body") + or mail.get("content") + or mail.get("html") + or "" + ).strip() + raw = str(mail.get("raw") or "").strip() + + if raw: + try: + message = message_from_string(raw, policy=email_policy) + sender = sender or self._decode_mime_header(message.get("From", "")) + subject = subject or self._decode_mime_header(message.get("Subject", "")) + parsed_body = self._extract_body_from_message(message) + if parsed_body: + body_text = f"{body_text}\n{parsed_body}".strip() if body_text else parsed_body + except Exception as e: + logger.debug(f"解析 TempMail raw 邮件失败: {e}") + body_text = f"{body_text}\n{raw}".strip() if body_text else raw + + body_text = unescape(re.sub(r"<[^>]+>", " ", body_text)) + return { + "sender": sender, + "subject": subject, + "body": body_text, + "raw": raw, + } + + def _extract_body_from_raw_mail(self, raw: str) -> str: + """Extract a readable body from raw MIME content without searching header noise.""" + if not raw: + return "" + + try: + message = message_from_string(raw, policy=email_policy) + return self._extract_body_from_message(message) + except Exception as e: + logger.debug(f"解析 TempMail raw 正文失败: {e}") + return "" + + @staticmethod + def _find_code_in_text(text: str, *, semantic_first: bool = True, pattern: str = OTP_CODE_PATTERN) -> Optional[str]: + normalized = unescape(re.sub(r"<[^>]+>", " ", str(text or ""))).strip() + if not normalized: + return None + + if semantic_first: + for candidate_pattern in SEMANTIC_OTP_PATTERNS: + match = candidate_pattern.search(normalized) + if match: + return match.group(1) + + match = re.search(pattern, normalized) + return match.group(1) if match else None + + def _extract_verification_code_from_mail( + self, + parsed_mail: Dict[str, str], + pattern: str = OTP_CODE_PATTERN, + ) -> Optional[str]: + """Prefer body-derived OTPs and avoid matching digits from headers/domain names.""" + body_text = str(parsed_mail.get("body") or "").strip() + subject = str(parsed_mail.get("subject") or "").strip() + raw_text = str(parsed_mail.get("raw") or "").strip() + raw_body_text = self._extract_body_from_raw_mail(raw_text) + + for text in (body_text, raw_body_text, subject): + code = self._find_code_in_text(text, semantic_first=True, pattern=pattern) + if code: + return code + + for text in (body_text, raw_body_text): + code = self._find_code_in_text(text, semantic_first=False, pattern=pattern) + if code: + return code + + return self._find_code_in_text(subject, semantic_first=False, pattern=pattern) + + def _get_verification_state(self, email: str) -> Dict[str, Any]: + state = self._verification_state.setdefault( + email, + { + "stage": "signup_otp", + "consumed_mail_ids": set(), + "last_code": None, + "last_mail_id": None, + "last_mail_timestamp": None, + "last_stage": None, + }, + ) + consumed_mail_ids = state.get("consumed_mail_ids") + if not isinstance(consumed_mail_ids, set): + state["consumed_mail_ids"] = set(consumed_mail_ids or []) + return state + + def set_verification_stage(self, email: str, stage: str) -> None: + state = self._get_verification_state(email) + state["stage"] = stage or "signup_otp" + + @staticmethod + def _extract_mail_timestamp(mail: Dict[str, Any]) -> Optional[float]: + for key in ("createdAt", "created_at", "receivedAt", "received_at", "timestamp", "date"): + value = mail.get(key) + if value in (None, ""): + continue + + if isinstance(value, (int, float)): + numeric = float(value) + return numeric / 1000 if numeric > 1_000_000_000_000 else numeric + + if isinstance(value, str): + stripped = value.strip() + if not stripped: + continue + if stripped.isdigit(): + numeric = float(stripped) + return numeric / 1000 if numeric > 1_000_000_000_000 else numeric + try: + return datetime.fromisoformat(stripped.replace("Z", "+00:00")).timestamp() + except ValueError: + continue + + return None + + def _admin_headers(self) -> Dict[str, str]: + """构造 admin 请求头""" + return { + "x-admin-auth": self.config["admin_password"], + "Content-Type": "application/json", + "Accept": "application/json", + } + + @staticmethod + def _address_jwt_headers(jwt: str) -> Dict[str, str]: + return { + "Authorization": f"Bearer {jwt}", + "Content-Type": "application/json", + "Accept": "application/json", + } + + def _fetch_domains_from_worker(self) -> Dict[str, Any]: + """Fetch temp-mail domains from worker admin endpoint.""" + return self._make_request("GET", "/admin/domains") + + def get_domain_summary(self, preview_limit: int = 3) -> Dict[str, Any]: + """Return safe domain summary for API/capabilities output.""" + summary = summarize_temp_mail_domains( + self.config, + fetch_domains=self._fetch_domains_from_worker, + preview_limit=preview_limit, + ) + summary.pop("domains", None) + return summary + + 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(用作标识) + """ + enable_prefix = False # 强制禁用前缀,忽略数据库配置 + name = generate_local_part(enable_prefix=enable_prefix) + domains = resolve_temp_mail_domains( + self.config, + fetch_domains=self._fetch_domains_from_worker, + ) + if not domains: + raise EmailServiceError("TempMail 未找到可用域名,请检查 domains/domain 或 /admin/domains") + domain = choose_domain(domains) + + body = { + "enablePrefix": enable_prefix, + "name": name, + "domain": domain, + } + + try: + response = self._make_request("POST", "/admin/new_address", json=body) + + address = response.get("address", "").strip() + jwt = response.get("jwt", "").strip() + + if not address: + raise EmailServiceError(f"API 返回数据不完整: {response}") + + email_info = { + "email": address, + "jwt": jwt, + "service_id": address, + "id": address, + "created_at": time.time(), + } + + # 缓存 jwt,供获取验证码时使用 + self._email_cache[address] = email_info + + logger.info(f"成功创建 TempMail 邮箱: {address}") + self.update_status(True) + return email_info + + except Exception as e: + self.update_status(False, e) + if isinstance(e, EmailServiceError): + raise + raise EmailServiceError(f"创建邮箱失败: {e}") + + def _fetch_api_mails(self, jwt: str) -> Dict[str, Any]: + """Fetch mailbox contents through the worker address-scoped API.""" + return self._make_request( + "GET", + "/api/mails", + params={"limit": 20, "offset": 0}, + headers=self._address_jwt_headers(jwt), + ) + + def _fetch_admin_mails(self, email: str) -> Dict[str, Any]: + """Fetch mailbox contents through the worker admin API.""" + return self._make_request( + "GET", + "/admin/mails", + params={"limit": 20, "offset": 0, "address": email}, + ) + + def _fetch_mail_batch( + self, + email: str, + jwt: Optional[str], + ) -> tuple[List[Dict[str, Any]], str, Optional[str]]: + """Prefer address-scoped API mails, then fall back to admin mails.""" + last_error: Optional[str] = None + + if jwt: + try: + response = self._fetch_api_mails(jwt) + mails = response.get("results", response if isinstance(response, list) else []) + if isinstance(mails, list): + return mails, "/api/mails", None + last_error = f"/api/mails returned unexpected payload: {response}" + except Exception as e: + last_error = str(e) + cached = {**self._email_cache.get(email, {}), "disable_user_api": True, "disable_api_mails": True} + self._email_cache[email] = cached + logger.warning(f"TempMail /api/mails 失败,切换到 admin 拉取邮件: {email} - {e}") + + try: + response = self._fetch_admin_mails(email) + mails = response.get("results", response if isinstance(response, list) else []) + if isinstance(mails, list): + return mails, "/admin/mails", last_error + if last_error: + last_error = f"{last_error}; /admin/mails returned unexpected payload: {response}" + else: + last_error = f"/admin/mails returned unexpected payload: {response}" + except Exception as e: + admin_error = str(e) + if last_error: + last_error = f"{last_error}; {admin_error}" + else: + last_error = admin_error + + return [], "unavailable", last_error + + 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]: + """Fetch a verification code from TempMail.""" + logger.info(f"??? TempMail ?? {email} ?????...") + + start_time = time.time() + seen_mail_ids: set = set() + poll_count = 0 + last_error: Optional[str] = None + verification_state = self._get_verification_state(email) + stage = str(verification_state.get("stage") or "signup_otp") + consumed_mail_ids = verification_state.setdefault("consumed_mail_ids", set()) + last_code = verification_state.get("last_code") + last_stage = verification_state.get("last_stage") + min_timestamp = float(otp_sent_at or 0) + debug_state = { + "stage": stage, + "poll_count": 0, + "last_status": "waiting", + "otp_sent_at": otp_sent_at or 0, + "min_timestamp": min_timestamp, + "fresh_verification_count": 0, + "fresh_preferred_sender_count": 0, + "stale_preferred_sender_count": 0, + "available_fresh_verification_count": 0, + "available_fresh_preferred_sender_count": 0, + "used_fresh_preferred_sender_count": 0, + "selected_sender": None, + "selected_code": None, + "selected_received_timestamp": None, + "deferred_generic_only_polls": 0, + "candidate_summaries": [], + } + self._last_verification_debug[email.lower()] = debug_state + + cached = self._email_cache.get(email, {}) + jwt = None if cached.get("disable_user_api") or cached.get("disable_api_mails") else cached.get("jwt") + + while time.time() - start_time < timeout: + poll_count += 1 + debug_state["poll_count"] = poll_count + try: + mails, source_path, request_error = self._fetch_mail_batch(email, jwt) + if request_error: + last_error = request_error + if not isinstance(mails, list): + time.sleep(3) + continue + + matched_candidates: List[Dict[str, Any]] = [] + debug_candidates: List[Dict[str, Any]] = [] + + for index, mail in enumerate(mails): + mail_id = mail.get("id") + if not mail_id or mail_id in seen_mail_ids: + continue + + seen_mail_ids.add(mail_id) + if mail_id in consumed_mail_ids: + continue + + parsed = self._extract_mail_fields(mail) + sender = parsed["sender"].lower() + subject = parsed["subject"] + body_text = parsed["body"] + content = f"{sender}\n{subject}\n{body_text}".strip() + + if "openai" not in sender and "openai" not in content.lower(): + continue + + code = self._extract_verification_code_from_mail(parsed, pattern=pattern) + if not code: + continue + + mail_ts = self._extract_mail_timestamp(mail) + is_fresh = otp_sent_at is None or (mail_ts is not None and mail_ts >= otp_sent_at) + freshness_rank = 1 if otp_sent_at is None else 0 + if otp_sent_at is not None: + if mail_ts is not None: + if mail_ts < otp_sent_at: + debug_state["stale_preferred_sender_count"] += 1 + continue + freshness_rank = 2 + debug_state["available_fresh_verification_count"] += 1 + debug_state["available_fresh_preferred_sender_count"] += 1 + debug_state["fresh_preferred_sender_count"] += 1 + elif stage == "relogin_otp": + debug_state["deferred_generic_only_polls"] += 1 + continue + + if stage == "relogin_otp" and code == last_code and last_stage == "signup_otp": + continue + + debug_state["fresh_verification_count"] += int(is_fresh) + debug_candidates.append( + { + "sender": parsed["sender"] or "-", + "received_timestamp": mail_ts, + "delta_from_otp_sent": None if otp_sent_at is None or mail_ts is None else round(mail_ts - otp_sent_at, 3), + "code": code, + "preferred": bool(is_fresh), + } + ) + matched_candidates.append( + { + "code": code, + "mail_id": mail_id, + "sender": parsed["sender"] or "-", + "timestamp": mail_ts if mail_ts is not None else float("-inf"), + "freshness_rank": freshness_rank, + "index": index, + } + ) + + if debug_candidates: + debug_state["candidate_summaries"] = debug_candidates[:5] + + if matched_candidates: + matched_candidates.sort( + key=lambda item: (item["freshness_rank"], item["timestamp"], item["index"]), + reverse=True, + ) + selected = matched_candidates[0] + code = selected["code"] + selected_mail_id = selected["mail_id"] + consumed_mail_ids.add(selected_mail_id) + verification_state["last_code"] = code + verification_state["last_mail_id"] = selected_mail_id + verification_state["last_mail_timestamp"] = ( + None if selected["timestamp"] == float("-inf") else selected["timestamp"] + ) + verification_state["last_stage"] = stage + debug_state["last_status"] = "matched" + debug_state["selected_sender"] = selected["sender"] + debug_state["selected_code"] = code + debug_state["selected_received_timestamp"] = ( + None if selected["timestamp"] == float("-inf") else selected["timestamp"] + ) + if selected["freshness_rank"] >= 2: + debug_state["used_fresh_preferred_sender_count"] = 1 + wait_duration = max(time.time() - start_time, 0.0) + logger.info( + f"TempMail verification code found for {email}: {code} " + f"(source={source_path}, stage={stage}, polls={poll_count}, wait={wait_duration:.2f}s)" + ) + self.update_status(True) + return code + + except Exception as e: + last_error = str(e) + logger.debug(f"?? TempMail ?????: {e}") + + time.sleep(3) + + wait_duration = max(time.time() - start_time, 0.0) + debug_state["last_status"] = "timeout" + logger.warning( + f"?? TempMail ?????: {email}; polls={poll_count}; wait={wait_duration:.2f}s; " + f"last_error={last_error or '-'}" + ) + return None + + def get_last_verification_debug(self, email: str) -> Dict[str, Any]: + return dict(self._last_verification_debug.get(email.lower(), {})) + + 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/temp_mail_addressing.py b/src/services/temp_mail_addressing.py new file mode 100644 index 0000000000000000000000000000000000000000..03544bdd7cfe22a465089554b85071bd3fd8c5d3 --- /dev/null +++ b/src/services/temp_mail_addressing.py @@ -0,0 +1,32 @@ +"""Helpers for Temp-Mail local-part generation and domain choice.""" + +from __future__ import annotations + +import random +import string +from typing import Optional, Sequence + + +def _rng(rng: Optional[random.Random] = None) -> random.Random: + return rng or random.SystemRandom() + + +def generate_local_part(*, enable_prefix: bool = False, rng: Optional[random.Random] = None) -> str: + """Generate a variable-length alphanumeric local-part. + + Keeps the previous style (letters+digits+letters), but defaults to no tmp prefix. + """ + r = _rng(rng) + letters = ''.join(r.choices(string.ascii_lowercase, k=5)) + digits = ''.join(r.choices(string.digits, k=r.randint(1, 3))) + suffix = ''.join(r.choices(string.ascii_lowercase, k=r.randint(1, 3))) + base = f"{letters}{digits}{suffix}" + return f"tmp{base}" if enable_prefix else base + + +def choose_domain(domains: Sequence[str], *, rng: Optional[random.Random] = None) -> str: + """Choose one domain from candidate pool.""" + if not domains: + raise ValueError("domains is empty") + r = _rng(rng) + return r.choice(list(domains)) diff --git a/src/services/temp_mail_domain_provider.py b/src/services/temp_mail_domain_provider.py new file mode 100644 index 0000000000000000000000000000000000000000..71cb1df8035dcb28b3c164faeae2aa173737e074 --- /dev/null +++ b/src/services/temp_mail_domain_provider.py @@ -0,0 +1,96 @@ +"""Domain resolution helpers for Temp-Mail service.""" + +from __future__ import annotations + +import logging +from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple + +logger = logging.getLogger(__name__) + +DomainFetcher = Callable[[], Any] + + +def normalize_domains(value: Any) -> List[str]: + """Normalize domains from list/string payload into unique ordered list.""" + if value is None: + return [] + + values: Iterable[Any] + if isinstance(value, str): + values = [part.strip() for part in value.split(",")] + elif isinstance(value, Sequence): + values = value + else: + return [] + + seen = set() + domains: List[str] = [] + for item in values: + text = str(item or "").strip().lower() + if not text: + continue + if "." not in text: + continue + if text in seen: + continue + seen.add(text) + domains.append(text) + return domains + + +def _domains_from_worker_response(payload: Any) -> List[str]: + if isinstance(payload, dict): + return normalize_domains(payload.get("domains")) + return normalize_domains(payload) + + +def _resolve_with_source(config: Optional[Dict[str, Any]], fetch_domains: Optional[DomainFetcher]) -> Tuple[List[str], str]: + cfg = config or {} + + configured_pool = normalize_domains(cfg.get("domains")) + if configured_pool: + return configured_pool, "config_domains" + + if fetch_domains is not None: + try: + worker_domains = _domains_from_worker_response(fetch_domains()) + if worker_domains: + return worker_domains, "worker_api" + except Exception as exc: # pragma: no cover - exercised via tests + logger.debug("Temp-Mail domains endpoint unavailable, fallback to config.domain: %s", exc) + + fallback = normalize_domains(cfg.get("domain")) + if fallback: + return fallback, "config_fallback" + + return [], "none" + + +def resolve_temp_mail_domains( + config: Optional[Dict[str, Any]], + *, + fetch_domains: Optional[DomainFetcher] = None, +) -> List[str]: + """Resolve available domains for a temp_mail service config.""" + domains, _ = _resolve_with_source(config, fetch_domains) + return domains + + +def summarize_temp_mail_domains( + config: Optional[Dict[str, Any]], + *, + fetch_domains: Optional[DomainFetcher] = None, + preview_limit: int = 3, +) -> Dict[str, Any]: + """Build safe domain summary for API output/capabilities.""" + domains, source = _resolve_with_source(config, fetch_domains) + preview_count = max(0, int(preview_limit)) + preview = domains[:preview_count] if preview_count else [] + primary = domains[0] if domains else None + return { + "domain": primary, + "domain_count": len(domains), + "domains_preview": preview, + "domain_source": source, + "domains": domains, + } diff --git a/src/services/tempmail.py b/src/services/tempmail.py new file mode 100644 index 0000000000000000000000000000000000000000..c8c2c77626cf20fce78d6c6684e9b71a85c7119c --- /dev/null +++ b/src/services/tempmail.py @@ -0,0 +1,432 @@ +""" +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 + + @staticmethod + def _get_domain_blacklist() -> set: + """从数据库读取被拉黑的邮箱域名列表""" + try: + from ..database import crud + from ..database.session import get_db + with get_db() as db: + setting = crud.get_setting(db, "email.domain_blacklist") + if setting and setting.value: + return set(json.loads(setting.value)) + except Exception: + pass + return set() + + 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: 创建时间戳 + """ + blacklist = self._get_domain_blacklist() + max_blacklist_retries = 5 + + for attempt in range(1, max_blacklist_retries + 1): + 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 返回数据不完整") + + # 检查域名黑名单 + domain = email.rsplit("@", 1)[-1].lower() if "@" in email else "" + if blacklist and domain in blacklist: + logger.warning( + f"域名 {domain} 在黑名单中,重新申请邮箱 ({attempt}/{max_blacklist_retries})" + ) + continue + + # 缓存邮箱信息 + 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 EmailServiceError: + raise + except Exception as e: + self.update_status(False, e) + raise EmailServiceError(f"创建 Tempmail.lol 邮箱失败: {e}") + + # 所有重试都命中黑名单 + self.update_status(False, EmailServiceError("连续命中黑名单域名")) + raise EmailServiceError( + f"Tempmail.lol 连续 {max_blacklist_retries} 次都命中黑名单域名,无法创建邮箱" + ) + + 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 diff --git a/src/web/__init__.py b/src/web/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..f722b0b98498ca559efd5ed5af4eee2290ba80aa --- /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 0000000000000000000000000000000000000000..7d43e79860ad683de9b10423c02de7ad353ee14e --- /dev/null +++ b/src/web/app.py @@ -0,0 +1,221 @@ +""" +FastAPI 应用主文件 +轻量级 Web UI,支持注册、账号管理、设置 +""" + +import logging +import sys +import secrets +import hmac +import hashlib +from typing import Optional +from pathlib import Path + +from fastapi import FastAPI, Request, Form +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import HTMLResponse, RedirectResponse + +from ..config.settings import get_settings +from .routes import api_router +from .routes.websocket import router as ws_router +from .task_manager import task_manager + +logger = logging.getLogger(__name__) + +# 获取项目根目录 +# PyInstaller 打包后静态资源在 sys._MEIPASS,开发时在源码根目录 +if getattr(sys, 'frozen', False): + _RESOURCE_ROOT = Path(sys._MEIPASS) +else: + _RESOURCE_ROOT = Path(__file__).parent.parent.parent + +# 静态文件和模板目录 +STATIC_DIR = _RESOURCE_ROOT / "static" +TEMPLATES_DIR = _RESOURCE_ROOT / "templates" + + +def _build_static_asset_version(static_dir: Path) -> str: + """基于静态文件最后修改时间生成版本号,避免部署后浏览器继续使用旧缓存。""" + latest_mtime = 0 + if static_dir.exists(): + for path in static_dir.rglob("*"): + if path.is_file(): + latest_mtime = max(latest_mtime, int(path.stat().st_mtime)) + return str(latest_mtime or 1) + + +def create_app() -> FastAPI: + """创建 FastAPI 应用实例""" + settings = get_settings() + + app = FastAPI( + title=settings.app_name, + version=settings.app_version, + description="OpenAI/Codex CLI 自动注册系统 Web UI", + docs_url="/api/docs" if settings.debug else None, + redoc_url="/api/redoc" if settings.debug else None, + ) + + # CORS 中间件 + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # 挂载静态文件 + if STATIC_DIR.exists(): + app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") + logger.info(f"静态文件目录: {STATIC_DIR}") + else: + # 创建静态目录 + STATIC_DIR.mkdir(parents=True, exist_ok=True) + app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") + logger.info(f"创建静态文件目录: {STATIC_DIR}") + + # 创建模板目录 + if not TEMPLATES_DIR.exists(): + TEMPLATES_DIR.mkdir(parents=True, exist_ok=True) + logger.info(f"创建模板目录: {TEMPLATES_DIR}") + + # 注册 API 路由 + app.include_router(api_router, prefix="/api") + + # 注册 WebSocket 路由 + app.include_router(ws_router, prefix="/api") + + # 模板引擎 + templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) + templates.env.globals["static_version"] = _build_static_asset_version(STATIC_DIR) + + def _auth_token(password: str) -> str: + secret = get_settings().webui_secret_key.get_secret_value().encode("utf-8") + return hmac.new(secret, password.encode("utf-8"), hashlib.sha256).hexdigest() + + def _is_authenticated(request: Request) -> bool: + cookie = request.cookies.get("webui_auth") + expected = _auth_token(get_settings().webui_access_password.get_secret_value()) + return bool(cookie) and secrets.compare_digest(cookie, expected) + + def _redirect_to_login(request: Request) -> RedirectResponse: + return RedirectResponse(url=f"/login?next={request.url.path}", status_code=302) + + @app.get("/login", response_class=HTMLResponse) + async def login_page(request: Request, next: Optional[str] = "/"): + """登录页面""" + return templates.TemplateResponse( + request, + "login.html", + {"request": request, "error": "", "next": next or "/"} + ) + + @app.post("/login") + async def login_submit(request: Request, password: str = Form(...), next: Optional[str] = "/"): + """处理登录提交""" + expected = get_settings().webui_access_password.get_secret_value() + if not secrets.compare_digest(password, expected): + return templates.TemplateResponse( + request, + "login.html", + {"request": request, "error": "密码错误", "next": next or "/"}, + status_code=401 + ) + + response = RedirectResponse(url=next or "/", status_code=302) + response.set_cookie("webui_auth", _auth_token(expected), httponly=True, samesite="lax") + return response + + @app.get("/logout") + async def logout(request: Request, next: Optional[str] = "/login"): + """退出登录""" + response = RedirectResponse(url=next or "/login", status_code=302) + response.delete_cookie("webui_auth") + return response + + @app.get("/", response_class=HTMLResponse) + async def index(request: Request): + """首页 - 注册页面""" + if not _is_authenticated(request): + return _redirect_to_login(request) + return templates.TemplateResponse(request, "index.html", {"request": request}) + + @app.get("/accounts", response_class=HTMLResponse) + async def accounts_page(request: Request): + """账号管理页面""" + if not _is_authenticated(request): + return _redirect_to_login(request) + return templates.TemplateResponse(request, "accounts.html", {"request": request}) + + @app.get("/email-services", response_class=HTMLResponse) + async def email_services_page(request: Request): + """邮箱服务管理页面""" + if not _is_authenticated(request): + return _redirect_to_login(request) + return templates.TemplateResponse(request, "email_services.html", {"request": request}) + + @app.get("/settings", response_class=HTMLResponse) + async def settings_page(request: Request): + """设置页面""" + if not _is_authenticated(request): + return _redirect_to_login(request) + return templates.TemplateResponse(request, "settings.html", {"request": request}) + + @app.get("/payment", response_class=HTMLResponse) + async def payment_page(request: Request): + """支付页面""" + return templates.TemplateResponse(request, "payment.html", {"request": request}) + + @app.on_event("startup") + async def startup_event(): + """应用启动事件""" + import asyncio + from ..database.init_db import initialize_database + from ..database.session import get_db + + # 确保数据库已初始化(reload 模式下子进程也需要初始化) + try: + initialize_database() + except Exception as e: + logger.warning(f"数据库初始化: {e}") + + recover_interrupted_external_batches = None + try: + from ..core.external_batches.recovery import recover_interrupted_external_batches as recovery_func + recover_interrupted_external_batches = recovery_func + except Exception as e: + logger.warning(f"外部批次恢复模块不可用: {e}") + + # 收敛重启中断的外部批次任务 + try: + if recover_interrupted_external_batches is not None: + with get_db() as db: + recovered = recover_interrupted_external_batches(db) + if recovered: + logger.warning(f"启动恢复:已收敛 {recovered} 个外部批次为 failed(service_restarted)") + except Exception as e: + logger.warning(f"外部批次恢复失败: {e}") + + # 设置 TaskManager 的事件循环 + loop = asyncio.get_event_loop() + task_manager.set_loop(loop) + + logger.info("=" * 50) + logger.info(f"{settings.app_name} v{settings.app_version} 启动中,程序正在伸懒腰...") + logger.info(f"调试模式: {settings.debug}") + logger.info(f"数据库连接已接好线: {settings.database_url}") + logger.info("=" * 50) + + @app.on_event("shutdown") + async def shutdown_event(): + """应用关闭事件""" + logger.info("应用关闭,今天先收摊啦") + + return app + + +# 创建全局应用实例 +app = create_app() diff --git a/src/web/deps/__init__.py b/src/web/deps/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..3ba6072462047a436dc59f27ca447c214214ff1f --- /dev/null +++ b/src/web/deps/__init__.py @@ -0,0 +1 @@ +"""Web dependencies package.""" diff --git a/src/web/deps/external_auth.py b/src/web/deps/external_auth.py new file mode 100644 index 0000000000000000000000000000000000000000..44544e5ddda6db61980451b3768c0a7297eb5d28 --- /dev/null +++ b/src/web/deps/external_auth.py @@ -0,0 +1,45 @@ +"""External API authentication dependency.""" + +from __future__ import annotations + +import secrets +from typing import Optional + +from fastapi import Header, HTTPException, status + +from ...config.settings import get_settings + + +def _get_external_api_key_value() -> str: + settings = get_settings() + api_key = getattr(settings, "external_api_key", None) + if api_key is None: + return "" + try: + return api_key.get_secret_value() + except AttributeError: + return str(api_key) + + +def require_external_api_key(x_api_key: Optional[str] = Header(default=None, alias="X-API-Key")) -> None: + """Validate external API access policy and API key.""" + settings = get_settings() + if not getattr(settings, "external_api_enabled", False): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="External API is disabled", + ) + + expected_key = _get_external_api_key_value() + if not expected_key: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="External API key is not configured", + ) + + provided = x_api_key or "" + if not secrets.compare_digest(provided, expected_key): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid API key", + ) diff --git a/src/web/routes/__init__.py b/src/web/routes/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..70d1a2c67a73ffd9aa327db74d68a4d6ca388f96 --- /dev/null +++ b/src/web/routes/__init__.py @@ -0,0 +1,25 @@ +"""API routes module.""" + +from fastapi import APIRouter + +from .accounts import router as accounts_router +from .registration import router as registration_router +from .settings import router as settings_router +from .email import router as email_services_router +from .payment import router as payment_router +from .upload.cpa_services import router as cpa_services_router +from .upload.sub2api_services import router as sub2api_services_router +from .upload.tm_services import router as tm_services_router +from .external import router as external_router + +api_router = APIRouter() + +api_router.include_router(accounts_router, prefix="/accounts", tags=["accounts"]) +api_router.include_router(registration_router, prefix="/registration", tags=["registration"]) +api_router.include_router(settings_router, prefix="/settings", tags=["settings"]) +api_router.include_router(email_services_router, prefix="/email-services", tags=["email-services"]) +api_router.include_router(payment_router, prefix="/payment", tags=["payment"]) +api_router.include_router(cpa_services_router, prefix="/cpa-services", tags=["cpa-services"]) +api_router.include_router(sub2api_services_router, prefix="/sub2api-services", tags=["sub2api-services"]) +api_router.include_router(tm_services_router, prefix="/tm-services", tags=["tm-services"]) +api_router.include_router(external_router, prefix="/external", tags=["external"]) diff --git a/src/web/routes/accounts.py b/src/web/routes/accounts.py new file mode 100644 index 0000000000000000000000000000000000000000..a6a597fcb8c7dbcd65ebb358b6cd5d90974b1b73 --- /dev/null +++ b/src/web/routes/accounts.py @@ -0,0 +1,1063 @@ +""" +账号管理 API 路由 +""" +import io +import json +import logging +import zipfile +from datetime import datetime +from typing import List, Optional + +from fastapi import APIRouter, HTTPException, Query, BackgroundTasks, Body +from fastapi.responses import StreamingResponse +from pydantic import BaseModel + +from ...config.constants import AccountStatus +from ...config.settings import get_settings +from ...core.openai.token_refresh import refresh_account_token as do_refresh +from ...core.openai.token_refresh import validate_account_token as do_validate +from ...core.upload.cpa_upload import generate_token_json, batch_upload_to_cpa, upload_to_cpa +from ...core.upload.team_manager_upload import upload_to_team_manager, batch_upload_to_team_manager +from ...core.upload.sub2api_upload import batch_upload_to_sub2api, upload_to_sub2api + +from ...core.dynamic_proxy import get_proxy_url_for_task +from ...database import crud +from ...database.models import Account +from ...database.session import get_db + +logger = logging.getLogger(__name__) +router = APIRouter() + + +def _get_proxy(request_proxy: Optional[str] = None) -> Optional[str]: + """获取代理 URL,策略与注册流程一致:代理列表 → 动态代理 → 静态配置""" + if request_proxy: + return request_proxy + with get_db() as db: + proxy = crud.get_random_proxy(db) + if proxy: + return proxy.proxy_url + proxy_url = get_proxy_url_for_task() + if proxy_url: + return proxy_url + return get_settings().proxy_url + + +# ============== Pydantic Models ============== + +class AccountResponse(BaseModel): + """账号响应模型""" + id: int + email: str + password: Optional[str] = None + client_id: Optional[str] = None + email_service: str + account_id: Optional[str] = None + workspace_id: Optional[str] = None + registered_at: Optional[str] = None + last_refresh: Optional[str] = None + expires_at: Optional[str] = None + status: str + proxy_used: Optional[str] = None + cpa_uploaded: bool = False + cpa_uploaded_at: Optional[str] = None + cookies: Optional[str] = None + created_at: Optional[str] = None + updated_at: Optional[str] = None + + class Config: + from_attributes = True + + +class AccountListResponse(BaseModel): + """账号列表响应""" + total: int + accounts: List[AccountResponse] + + +class AccountUpdateRequest(BaseModel): + """账号更新请求""" + status: Optional[str] = None + metadata: Optional[dict] = None + cookies: Optional[str] = None # 完整 cookie 字符串,用于支付请求 + + +class BatchDeleteRequest(BaseModel): + """批量删除请求""" + ids: List[int] = [] + select_all: bool = False + status_filter: Optional[str] = None + email_service_filter: Optional[str] = None + search_filter: Optional[str] = None + + +class BatchUpdateRequest(BaseModel): + """批量更新请求""" + ids: List[int] + status: str + + +# ============== Helper Functions ============== + +def resolve_account_ids( + db, + ids: List[int], + select_all: bool = False, + status_filter: Optional[str] = None, + email_service_filter: Optional[str] = None, + search_filter: Optional[str] = None, +) -> List[int]: + """当 select_all=True 时查询全部符合条件的 ID,否则直接返回传入的 ids""" + if not select_all: + return ids + query = db.query(Account.id) + if status_filter: + query = query.filter(Account.status == status_filter) + if email_service_filter: + query = query.filter(Account.email_service == email_service_filter) + if search_filter: + pattern = f"%{search_filter}%" + query = query.filter( + (Account.email.ilike(pattern)) | (Account.account_id.ilike(pattern)) + ) + return [row[0] for row in query.all()] + + +def account_to_response(account: Account) -> AccountResponse: + """转换 Account 模型为响应模型""" + return AccountResponse( + id=account.id, + email=account.email, + password=account.password, + client_id=account.client_id, + email_service=account.email_service, + account_id=account.account_id, + workspace_id=account.workspace_id, + registered_at=account.registered_at.isoformat() if account.registered_at else None, + last_refresh=account.last_refresh.isoformat() if account.last_refresh else None, + expires_at=account.expires_at.isoformat() if account.expires_at else None, + status=account.status, + proxy_used=account.proxy_used, + cpa_uploaded=account.cpa_uploaded or False, + cpa_uploaded_at=account.cpa_uploaded_at.isoformat() if account.cpa_uploaded_at else None, + cookies=account.cookies, + created_at=account.created_at.isoformat() if account.created_at else None, + updated_at=account.updated_at.isoformat() if account.updated_at else None, + ) + + +# ============== API Endpoints ============== + +@router.get("", response_model=AccountListResponse) +async def list_accounts( + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(20, ge=1, le=100, description="每页数量"), + status: Optional[str] = Query(None, description="状态筛选"), + email_service: Optional[str] = Query(None, description="邮箱服务筛选"), + search: Optional[str] = Query(None, description="搜索关键词"), +): + """ + 获取账号列表 + + 支持分页、状态筛选、邮箱服务筛选和搜索 + """ + with get_db() as db: + # 构建查询 + query = db.query(Account) + + # 状态筛选 + if status: + query = query.filter(Account.status == status) + + # 邮箱服务筛选 + if email_service: + query = query.filter(Account.email_service == email_service) + + # 搜索 + if search: + search_pattern = f"%{search}%" + query = query.filter( + (Account.email.ilike(search_pattern)) | + (Account.account_id.ilike(search_pattern)) + ) + + # 统计总数 + total = query.count() + + # 分页 + offset = (page - 1) * page_size + accounts = query.order_by(Account.created_at.desc()).offset(offset).limit(page_size).all() + + return AccountListResponse( + total=total, + accounts=[account_to_response(acc) for acc in accounts] + ) + + +@router.get("/{account_id}", response_model=AccountResponse) +async def get_account(account_id: int): + """获取单个账号详情""" + with get_db() as db: + account = crud.get_account_by_id(db, account_id) + if not account: + raise HTTPException(status_code=404, detail="账号不存在") + return account_to_response(account) + + +@router.get("/{account_id}/tokens") +async def get_account_tokens(account_id: int): + """获取账号的 Token 信息""" + with get_db() as db: + account = crud.get_account_by_id(db, account_id) + if not account: + raise HTTPException(status_code=404, detail="账号不存在") + + return { + "id": account.id, + "email": account.email, + "access_token": account.access_token, + "refresh_token": account.refresh_token, + "id_token": account.id_token, + "has_tokens": bool(account.access_token and account.refresh_token), + } + + +@router.patch("/{account_id}", response_model=AccountResponse) +async def update_account(account_id: int, request: AccountUpdateRequest): + """更新账号状态""" + with get_db() as db: + account = crud.get_account_by_id(db, account_id) + if not account: + raise HTTPException(status_code=404, detail="账号不存在") + + update_data = {} + if request.status: + if request.status not in [e.value for e in AccountStatus]: + raise HTTPException(status_code=400, detail="无效的状态值") + update_data["status"] = request.status + + if request.metadata: + current_metadata = account.metadata or {} + current_metadata.update(request.metadata) + update_data["metadata"] = current_metadata + + if request.cookies is not None: + # 留空则清空,非空则更新 + update_data["cookies"] = request.cookies or None + + account = crud.update_account(db, account_id, **update_data) + return account_to_response(account) + + +@router.get("/{account_id}/cookies") +async def get_account_cookies(account_id: int): + """获取账号的 cookie 字符串(仅供支付使用)""" + with get_db() as db: + account = crud.get_account_by_id(db, account_id) + if not account: + raise HTTPException(status_code=404, detail="账号不存在") + return {"account_id": account_id, "cookies": account.cookies or ""} + + +@router.delete("/{account_id}") +async def delete_account(account_id: int): + """删除单个账号""" + with get_db() as db: + account = crud.get_account_by_id(db, account_id) + if not account: + raise HTTPException(status_code=404, detail="账号不存在") + + crud.delete_account(db, account_id) + return {"success": True, "message": f"账号 {account.email} 已删除"} + + +@router.post("/batch-delete") +async def batch_delete_accounts(request: BatchDeleteRequest): + """批量删除账号""" + with get_db() as db: + ids = resolve_account_ids( + db, request.ids, request.select_all, + request.status_filter, request.email_service_filter, request.search_filter + ) + deleted_count = 0 + errors = [] + + for account_id in ids: + try: + account = crud.get_account_by_id(db, account_id) + if account: + crud.delete_account(db, account_id) + deleted_count += 1 + except Exception as e: + errors.append(f"ID {account_id}: {str(e)}") + + return { + "success": True, + "deleted_count": deleted_count, + "errors": errors if errors else None + } + + +@router.post("/batch-update") +async def batch_update_accounts(request: BatchUpdateRequest): + """批量更新账号状态""" + if request.status not in [e.value for e in AccountStatus]: + raise HTTPException(status_code=400, detail="无效的状态值") + + with get_db() as db: + updated_count = 0 + errors = [] + + for account_id in request.ids: + try: + account = crud.get_account_by_id(db, account_id) + if account: + crud.update_account(db, account_id, status=request.status) + updated_count += 1 + except Exception as e: + errors.append(f"ID {account_id}: {str(e)}") + + return { + "success": True, + "updated_count": updated_count, + "errors": errors if errors else None + } + + +class BatchExportRequest(BaseModel): + """批量导出请求""" + ids: List[int] = [] + select_all: bool = False + status_filter: Optional[str] = None + email_service_filter: Optional[str] = None + search_filter: Optional[str] = None + + +@router.post("/export/json") +async def export_accounts_json(request: BatchExportRequest): + """导出账号为 JSON 格式""" + with get_db() as db: + ids = resolve_account_ids( + db, request.ids, request.select_all, + request.status_filter, request.email_service_filter, request.search_filter + ) + accounts = db.query(Account).filter(Account.id.in_(ids)).all() + + export_data = [] + for acc in accounts: + export_data.append({ + "email": acc.email, + "password": acc.password, + "client_id": acc.client_id, + "account_id": acc.account_id, + "workspace_id": acc.workspace_id, + "access_token": acc.access_token, + "refresh_token": acc.refresh_token, + "id_token": acc.id_token, + "session_token": acc.session_token, + "email_service": acc.email_service, + "registered_at": acc.registered_at.isoformat() if acc.registered_at else None, + "last_refresh": acc.last_refresh.isoformat() if acc.last_refresh else None, + "expires_at": acc.expires_at.isoformat() if acc.expires_at else None, + "status": acc.status, + }) + + # 生成文件名 + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"accounts_{timestamp}.json" + + # 返回 JSON 响应 + content = json.dumps(export_data, ensure_ascii=False, indent=2) + + return StreamingResponse( + iter([content]), + media_type="application/json", + headers={"Content-Disposition": f"attachment; filename={filename}"} + ) + + +@router.post("/export/csv") +async def export_accounts_csv(request: BatchExportRequest): + """导出账号为 CSV 格式""" + import csv + import io + + with get_db() as db: + ids = resolve_account_ids( + db, request.ids, request.select_all, + request.status_filter, request.email_service_filter, request.search_filter + ) + accounts = db.query(Account).filter(Account.id.in_(ids)).all() + + # 创建 CSV 内容 + output = io.StringIO() + writer = csv.writer(output) + + # 写入表头 + writer.writerow([ + "ID", "Email", "Password", "Client ID", + "Account ID", "Workspace ID", + "Access Token", "Refresh Token", "ID Token", "Session Token", + "Email Service", "Status", "Registered At", "Last Refresh", "Expires At" + ]) + + # 写入数据 + for acc in accounts: + writer.writerow([ + acc.id, + acc.email, + acc.password or "", + acc.client_id or "", + acc.account_id or "", + acc.workspace_id or "", + acc.access_token or "", + acc.refresh_token or "", + acc.id_token or "", + acc.session_token or "", + acc.email_service, + acc.status, + acc.registered_at.isoformat() if acc.registered_at else "", + acc.last_refresh.isoformat() if acc.last_refresh else "", + acc.expires_at.isoformat() if acc.expires_at else "" + ]) + + # 生成文件名 + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"accounts_{timestamp}.csv" + + return StreamingResponse( + iter([output.getvalue()]), + media_type="text/csv", + headers={"Content-Disposition": f"attachment; filename={filename}"} + ) + + +@router.post("/export/sub2api") +async def export_accounts_sub2api(request: BatchExportRequest): + """导出账号为 Sub2Api 格式(所有选中账号合并到一个 JSON 的 accounts 数组中)""" + + def make_account_entry(acc) -> dict: + expires_at = int(acc.expires_at.timestamp()) if acc.expires_at else 0 + return { + "name": acc.email, + "platform": "openai", + "type": "oauth", + "credentials": { + "access_token": acc.access_token or "", + "chatgpt_account_id": acc.account_id or "", + "chatgpt_user_id": "", + "client_id": acc.client_id or "", + "expires_at": expires_at, + "expires_in": 863999, + "model_mapping": { + "gpt-5.1": "gpt-5.1", + "gpt-5.1-codex": "gpt-5.1-codex", + "gpt-5.1-codex-max": "gpt-5.1-codex-max", + "gpt-5.1-codex-mini": "gpt-5.1-codex-mini", + "gpt-5.2": "gpt-5.2", + "gpt-5.2-codex": "gpt-5.2-codex", + "gpt-5.3": "gpt-5.3", + "gpt-5.3-codex": "gpt-5.3-codex", + "gpt-5.4": "gpt-5.4" + }, + "organization_id": acc.workspace_id or "", + "refresh_token": acc.refresh_token or "" + }, + "extra": {}, + "concurrency": 10, + "priority": 1, + "rate_multiplier": 1, + "auto_pause_on_expired": True + } + + with get_db() as db: + ids = resolve_account_ids( + db, request.ids, request.select_all, + request.status_filter, request.email_service_filter, request.search_filter + ) + accounts = db.query(Account).filter(Account.id.in_(ids)).all() + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + payload = { + "proxies": [], + "accounts": [make_account_entry(acc) for acc in accounts] + } + content = json.dumps(payload, ensure_ascii=False, indent=2) + + if len(accounts) == 1: + filename = f"{accounts[0].email}_sub2api.json" + else: + filename = f"sub2api_tokens_{timestamp}.json" + + return StreamingResponse( + iter([content]), + media_type="application/json", + headers={"Content-Disposition": f"attachment; filename={filename}"} + ) + + +@router.post("/export/cpa") +async def export_accounts_cpa(request: BatchExportRequest): + """导出账号为 CPA Token JSON 格式(每个账号单独一个 JSON 文件,打包为 ZIP)""" + with get_db() as db: + ids = resolve_account_ids( + db, request.ids, request.select_all, + request.status_filter, request.email_service_filter, request.search_filter + ) + accounts = db.query(Account).filter(Account.id.in_(ids)).all() + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + + if len(accounts) == 1: + # 单个账号直接返回 JSON 文件 + acc = accounts[0] + token_data = generate_token_json(acc) + content = json.dumps(token_data, ensure_ascii=False, indent=2) + filename = f"{acc.email}.json" + return StreamingResponse( + iter([content]), + media_type="application/json", + headers={"Content-Disposition": f"attachment; filename={filename}"} + ) + + # 多个账号打包为 ZIP + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf: + for acc in accounts: + token_data = generate_token_json(acc) + content = json.dumps(token_data, ensure_ascii=False, indent=2) + zf.writestr(f"{acc.email}.json", content) + + zip_buffer.seek(0) + zip_filename = f"cpa_tokens_{timestamp}.zip" + return StreamingResponse( + zip_buffer, + media_type="application/zip", + headers={"Content-Disposition": f"attachment; filename={zip_filename}"} + ) + + +@router.get("/stats/summary") +async def get_accounts_stats(): + """获取账号统计信息""" + with get_db() as db: + from sqlalchemy import func + + # 总数 + total = db.query(func.count(Account.id)).scalar() + + # 按状态统计 + status_stats = db.query( + Account.status, + func.count(Account.id) + ).group_by(Account.status).all() + + # 按邮箱服务统计 + service_stats = db.query( + Account.email_service, + func.count(Account.id) + ).group_by(Account.email_service).all() + + return { + "total": total, + "by_status": {status: count for status, count in status_stats}, + "by_email_service": {service: count for service, count in service_stats} + } + + +# ============== Token 刷新相关 ============== + +class TokenRefreshRequest(BaseModel): + """Token 刷新请求""" + proxy: Optional[str] = None + + +class BatchRefreshRequest(BaseModel): + """批量刷新请求""" + ids: List[int] = [] + proxy: Optional[str] = None + select_all: bool = False + status_filter: Optional[str] = None + email_service_filter: Optional[str] = None + search_filter: Optional[str] = None + + +class TokenValidateRequest(BaseModel): + """Token 验证请求""" + proxy: Optional[str] = None + + +class BatchValidateRequest(BaseModel): + """批量验证请求""" + ids: List[int] = [] + proxy: Optional[str] = None + select_all: bool = False + status_filter: Optional[str] = None + email_service_filter: Optional[str] = None + search_filter: Optional[str] = None + + +@router.post("/batch-refresh") +async def batch_refresh_tokens(request: BatchRefreshRequest, background_tasks: BackgroundTasks): + """批量刷新账号 Token""" + proxy = _get_proxy(request.proxy) + + results = { + "success_count": 0, + "failed_count": 0, + "errors": [] + } + + with get_db() as db: + ids = resolve_account_ids( + db, request.ids, request.select_all, + request.status_filter, request.email_service_filter, request.search_filter + ) + + for account_id in ids: + try: + result = do_refresh(account_id, proxy) + if result.success: + results["success_count"] += 1 + else: + results["failed_count"] += 1 + results["errors"].append({"id": account_id, "error": result.error_message}) + except Exception as e: + results["failed_count"] += 1 + results["errors"].append({"id": account_id, "error": str(e)}) + + return results + + +@router.post("/{account_id}/refresh") +async def refresh_account_token(account_id: int, request: Optional[TokenRefreshRequest] = Body(default=None)): + """刷新单个账号的 Token""" + proxy = _get_proxy(request.proxy if request else None) + result = do_refresh(account_id, proxy) + + if result.success: + return { + "success": True, + "message": "Token 刷新成功", + "expires_at": result.expires_at.isoformat() if result.expires_at else None + } + else: + return { + "success": False, + "error": result.error_message + } + + +@router.post("/batch-validate") +async def batch_validate_tokens(request: BatchValidateRequest): + """批量验证账号 Token 有效性""" + proxy = _get_proxy(request.proxy) + + results = { + "valid_count": 0, + "invalid_count": 0, + "details": [] + } + + with get_db() as db: + ids = resolve_account_ids( + db, request.ids, request.select_all, + request.status_filter, request.email_service_filter, request.search_filter + ) + + for account_id in ids: + try: + is_valid, error = do_validate(account_id, proxy) + results["details"].append({ + "id": account_id, + "valid": is_valid, + "error": error + }) + if is_valid: + results["valid_count"] += 1 + else: + results["invalid_count"] += 1 + except Exception as e: + results["invalid_count"] += 1 + results["details"].append({ + "id": account_id, + "valid": False, + "error": str(e) + }) + + return results + + +@router.post("/{account_id}/validate") +async def validate_account_token(account_id: int, request: Optional[TokenValidateRequest] = Body(default=None)): + """验证单个账号的 Token 有效性""" + proxy = _get_proxy(request.proxy if request else None) + is_valid, error = do_validate(account_id, proxy) + + return { + "id": account_id, + "valid": is_valid, + "error": error + } + + +# ============== CPA 上传相关 ============== + +class CPAUploadRequest(BaseModel): + """CPA 上传请求""" + proxy: Optional[str] = None + cpa_service_id: Optional[int] = None # 指定 CPA 服务 ID,不传则使用全局配置 + + +class BatchCPAUploadRequest(BaseModel): + """批量 CPA 上传请求""" + ids: List[int] = [] + proxy: Optional[str] = None + select_all: bool = False + status_filter: Optional[str] = None + email_service_filter: Optional[str] = None + search_filter: Optional[str] = None + cpa_service_id: Optional[int] = None # 指定 CPA 服务 ID,不传则使用全局配置 + + +@router.post("/batch-upload-cpa") +async def batch_upload_accounts_to_cpa(request: BatchCPAUploadRequest): + """批量上传账号到 CPA""" + + proxy = request.proxy 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 0000000000000000000000000000000000000000..e5f4dc8c72454fd2eab0d1c705cf953156b568db --- /dev/null +++ b/src/web/routes/email.py @@ -0,0 +1,610 @@ +""" +邮箱服务配置 API 路由 +""" + +import logging +from typing import List, Optional, Dict, Any + +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel + +from ...database import crud +from ...database.session import get_db +from ...database.models import EmailService as EmailServiceModel +from ...services import EmailServiceFactory, EmailServiceType + +logger = logging.getLogger(__name__) +router = APIRouter() + + +# ============== Pydantic Models ============== + +class EmailServiceCreate(BaseModel): + """创建邮箱服务请求""" + service_type: str + name: str + config: Dict[str, Any] + enabled: bool = True + priority: int = 0 + + +class EmailServiceUpdate(BaseModel): + """更新邮箱服务请求""" + name: Optional[str] = None + config: Optional[Dict[str, Any]] = None + enabled: Optional[bool] = None + priority: Optional[int] = None + + +class EmailServiceResponse(BaseModel): + """邮箱服务响应""" + id: int + service_type: str + name: str + enabled: bool + priority: int + config: Optional[Dict[str, Any]] = None # 过滤敏感信息后的配置 + last_used: Optional[str] = None + created_at: Optional[str] = None + updated_at: Optional[str] = None + + class Config: + from_attributes = True + + +class EmailServiceListResponse(BaseModel): + """邮箱服务列表响应""" + total: int + services: List[EmailServiceResponse] + + +class ServiceTestResult(BaseModel): + """服务测试结果""" + success: bool + message: str + details: Optional[Dict[str, Any]] = None + + +class OutlookBatchImportRequest(BaseModel): + """Outlook 批量导入请求""" + data: str # 多行数据,每行格式: 邮箱----密码 或 邮箱----密码----client_id----refresh_token + enabled: bool = True + priority: int = 0 + + +class OutlookBatchImportResponse(BaseModel): + """Outlook 批量导入响应""" + total: int + success: int + failed: int + accounts: List[Dict[str, Any]] + errors: List[str] + + +# ============== Helper Functions ============== + +# 敏感字段列表,返回响应时需要过滤 +SENSITIVE_FIELDS = {'password', 'api_key', 'refresh_token', 'access_token', 'admin_token'} + +def filter_sensitive_config(config: Dict[str, Any]) -> Dict[str, Any]: + """过滤敏感配置信息""" + if not config: + return {} + + filtered = {} + for key, value in config.items(): + if key in SENSITIVE_FIELDS: + # 敏感字段不返回,但标记是否存在 + filtered[f"has_{key}"] = bool(value) + else: + filtered[key] = value + + # 为 Outlook 计算是否有 OAuth + if config.get('client_id') and config.get('refresh_token'): + filtered['has_oauth'] = True + + return filtered + + +def service_to_response(service: EmailServiceModel) -> EmailServiceResponse: + """转换服务模型为响应""" + return EmailServiceResponse( + id=service.id, + service_type=service.service_type, + name=service.name, + enabled=service.enabled, + priority=service.priority, + config=filter_sensitive_config(service.config), + last_used=service.last_used.isoformat() if service.last_used else None, + created_at=service.created_at.isoformat() if service.created_at else None, + updated_at=service.updated_at.isoformat() if service.updated_at else None, + ) + + +# ============== API Endpoints ============== + +@router.get("/stats") +async def get_email_services_stats(): + """获取邮箱服务统计信息""" + with get_db() as db: + from sqlalchemy import func + + # 按类型统计 + type_stats = db.query( + EmailServiceModel.service_type, + func.count(EmailServiceModel.id) + ).group_by(EmailServiceModel.service_type).all() + + # 启用数量 + enabled_count = db.query(func.count(EmailServiceModel.id)).filter( + EmailServiceModel.enabled == True + ).scalar() + + stats = { + 'outlook_count': 0, + 'custom_count': 0, + 'temp_mail_count': 0, + 'duck_mail_count': 0, + 'freemail_count': 0, + 'imap_mail_count': 0, + 'tempmail_available': True, # 临时邮箱始终可用 + 'enabled_count': enabled_count + } + + for service_type, count in type_stats: + if service_type == 'outlook': + stats['outlook_count'] = count + elif service_type == 'moe_mail': + stats['custom_count'] = count + elif service_type == 'temp_mail': + stats['temp_mail_count'] = count + elif service_type == 'duck_mail': + stats['duck_mail_count'] = count + elif service_type == 'freemail': + stats['freemail_count'] = count + elif service_type == 'imap_mail': + stats['imap_mail_count'] = count + + return stats + + +@router.get("/types") +async def get_service_types(): + """获取支持的邮箱服务类型""" + return { + "types": [ + { + "value": "tempmail", + "label": "Tempmail.lol", + "description": "临时邮箱服务,无需配置", + "config_fields": [ + {"name": "base_url", "label": "API 地址", "default": "https://api.tempmail.lol/v2", "required": False}, + {"name": "timeout", "label": "超时时间", "default": 30, "required": False}, + ] + }, + { + "value": "outlook", + "label": "Outlook", + "description": "Outlook 邮箱,需要配置账户信息", + "config_fields": [ + {"name": "email", "label": "邮箱地址", "required": True}, + {"name": "password", "label": "密码", "required": True}, + {"name": "client_id", "label": "OAuth Client ID", "required": False}, + {"name": "refresh_token", "label": "OAuth Refresh Token", "required": False}, + ] + }, + { + "value": "moe_mail", + "label": "MoeMail", + "description": "自定义域名邮箱服务", + "config_fields": [ + {"name": "base_url", "label": "API 地址", "required": True}, + {"name": "api_key", "label": "API Key", "required": True}, + {"name": "default_domain", "label": "默认域名", "required": False}, + ] + }, + { + "value": "temp_mail", + "label": "Temp-Mail(自部署)", + "description": "自部署 Cloudflare Worker 临时邮箱,admin 模式管理", + "config_fields": [ + {"name": "base_url", "label": "Worker 地址", "required": True, "placeholder": "https://mail.example.com"}, + {"name": "admin_password", "label": "Admin 密码", "required": True, "secret": True}, + {"name": "domain", "label": "邮箱域名", "required": True, "placeholder": "example.com"}, + {"name": "enable_prefix", "label": "启用前缀", "required": False, "default": False}, + ] + }, + { + "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/external/__init__.py b/src/web/routes/external/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..6be5c527da5011b889d91b341971d881ec51279c --- /dev/null +++ b/src/web/routes/external/__init__.py @@ -0,0 +1,10 @@ +"""External API router package.""" + +from fastapi import APIRouter + +from .capabilities import router as capabilities_router +from .registration import router as registration_router + +router = APIRouter() +router.include_router(capabilities_router, prefix="/capabilities", tags=["external-capabilities"]) +router.include_router(registration_router, prefix="/registration", tags=["external-registration"]) diff --git a/src/web/routes/external/capabilities.py b/src/web/routes/external/capabilities.py new file mode 100644 index 0000000000000000000000000000000000000000..a27366fb874791d5cf0a81d07361a133e10be240 --- /dev/null +++ b/src/web/routes/external/capabilities.py @@ -0,0 +1,33 @@ +"""External capabilities route.""" + +from __future__ import annotations + +from typing import Any, Dict + +from fastapi import APIRouter, Depends, HTTPException, status + +from ...deps.external_auth import require_external_api_key + +router = APIRouter(dependencies=[Depends(require_external_api_key)]) + + +def _get_external_capabilities() -> Dict[str, Any]: + """Adapter hook for core capability provider (implemented separately).""" + try: + from ....core.email_service_catalog import build_external_capabilities # type: ignore + except Exception as exc: # pragma: no cover - handled by route + raise RuntimeError("external_capabilities_provider_unavailable") from exc + + return build_external_capabilities() + + +@router.get("", summary="Get external API capabilities") +def get_external_capabilities() -> Dict[str, Any]: + try: + payload = _get_external_capabilities() + return payload + except RuntimeError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=str(exc), + ) from exc diff --git a/src/web/routes/external/registration.py b/src/web/routes/external/registration.py new file mode 100644 index 0000000000000000000000000000000000000000..4e3a8aea341887a60bfa1b089c8b2a06654f3275 --- /dev/null +++ b/src/web/routes/external/registration.py @@ -0,0 +1,122 @@ + +"""External registration batch routes.""" + +from __future__ import annotations + +from typing import Any, Dict, Literal, Optional + +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Response, status +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from ...deps.external_auth import require_external_api_key + +router = APIRouter(dependencies=[Depends(require_external_api_key)]) + + +class ExternalEmailSelection(BaseModel): + model_config = ConfigDict(extra="forbid") + + type: str = Field(..., min_length=1) + service_id: Optional[int] = None + + +class ExternalUploadSelection(BaseModel): + model_config = ConfigDict(extra="forbid") + + enabled: bool = False + provider: Optional[Literal["sub2api", "cpa", "tm"]] = None + service_id: Optional[int] = None + + +class ExternalExecutionOptions(BaseModel): + model_config = ConfigDict(extra="forbid") + + mode: Literal["pipeline", "parallel"] = "pipeline" + concurrency: int = Field(default=1, ge=1, le=10000) + interval_min: int = Field(default=5, ge=0) + interval_max: int = Field(default=30, ge=0) + + @model_validator(mode="after") + def validate_interval_bounds(self): + if self.interval_max < self.interval_min: + raise ValueError("interval_max must be greater than or equal to interval_min") + return self + + +class ExternalBatchCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + count: int = Field(..., ge=1, le=10000000) + idempotency_key: Optional[str] = None + email: ExternalEmailSelection + upload: ExternalUploadSelection = Field(default_factory=ExternalUploadSelection) + execution: ExternalExecutionOptions = Field(default_factory=ExternalExecutionOptions) + + @model_validator(mode="after") + def validate_upload(self): + if self.upload.enabled and not self.upload.provider: + raise ValueError("upload.provider is required when upload.enabled is true") + return self + + +def _create_external_batch(payload: Dict[str, Any], background_tasks: Optional[BackgroundTasks] = None) -> Dict[str, Any]: + try: + from ....core.external_batches.service import create_external_registration_batch # type: ignore + except Exception as exc: # pragma: no cover - handled by route + raise RuntimeError("external_batch_service_unavailable") from exc + return create_external_registration_batch(payload, background_tasks=background_tasks) + + +def _get_external_batch(batch_uuid: str) -> Dict[str, Any]: + try: + from ....core.external_batches.service import get_external_registration_batch_status # type: ignore + except Exception as exc: # pragma: no cover + raise RuntimeError("external_batch_service_unavailable") from exc + return get_external_registration_batch_status(batch_uuid) + + +def _cancel_external_batch(batch_uuid: str) -> Dict[str, Any]: + try: + from ....core.external_batches.service import cancel_external_registration_batch # type: ignore + except Exception as exc: # pragma: no cover + raise RuntimeError("external_batch_service_unavailable") from exc + return cancel_external_registration_batch(batch_uuid) + + +def _translate_error(exc: Exception) -> HTTPException: + detail = str(exc) + code = status.HTTP_404_NOT_FOUND if detail == "batch_not_found" else status.HTTP_400_BAD_REQUEST + return HTTPException(status_code=code, detail=detail) + + +@router.post("/batches", status_code=status.HTTP_202_ACCEPTED, summary="Create external registration batch") +def create_external_registration_batch(request: ExternalBatchCreateRequest, background_tasks: BackgroundTasks = None, response: Response = None) -> Dict[str, Any]: + try: + result = _create_external_batch(request.model_dump(), background_tasks=background_tasks) + if result.get("idempotent_replay") is True and response is not None: + response.status_code = status.HTTP_200_OK + return result + except ValueError as exc: + raise _translate_error(exc) from exc + except RuntimeError as exc: + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc + + +@router.get("/batches/{batch_uuid}", summary="Get external registration batch status") +def get_external_registration_batch(batch_uuid: str) -> Dict[str, Any]: + try: + return _get_external_batch(batch_uuid) + except ValueError as exc: + raise _translate_error(exc) from exc + except RuntimeError as exc: + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc + + +@router.post("/batches/{batch_uuid}/cancel", summary="Cancel external registration batch") +def cancel_external_registration_batch(batch_uuid: str) -> Dict[str, Any]: + try: + return _cancel_external_batch(batch_uuid) + except ValueError as exc: + raise _translate_error(exc) from exc + except RuntimeError as exc: + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc diff --git a/src/web/routes/payment.py b/src/web/routes/payment.py new file mode 100644 index 0000000000000000000000000000000000000000..ef9ff6b35e68f72470998dc96548a82039f3d5a6 --- /dev/null +++ b/src/web/routes/payment.py @@ -0,0 +1,182 @@ +""" +支付相关 API 路由 +""" + +import logging +from typing import Optional, List +from datetime import datetime + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from ...database.session import get_db +from ...database.models import Account +from ...database import crud +from ...config.settings import get_settings +from .accounts import resolve_account_ids +from ...core.openai.payment import ( + generate_plus_link, + generate_team_link, + open_url_incognito, + check_subscription_status, +) + +logger = logging.getLogger(__name__) +router = APIRouter() + + +# ============== Pydantic Models ============== + +class GenerateLinkRequest(BaseModel): + account_id: int + plan_type: str # 'plus' or 'team' + workspace_name: str = "MyTeam" + price_interval: str = "month" + seat_quantity: int = 5 + proxy: Optional[str] = None + auto_open: bool = False # 生成后是否自动无痕打开 + country: str = "SG" # 计费国家,决定货币 # 生成后是否自动无痕打开 + + +class OpenIncognitoRequest(BaseModel): + url: str + account_id: Optional[int] = None # 可选,用于注入账号 cookie + + +class MarkSubscriptionRequest(BaseModel): + subscription_type: str # 'free' / 'plus' / 'team' + + +class BatchCheckSubscriptionRequest(BaseModel): + ids: List[int] = [] + proxy: Optional[str] = None + select_all: bool = False + status_filter: Optional[str] = None + email_service_filter: Optional[str] = None + search_filter: Optional[str] = None + + +# ============== 支付链接生成 ============== + +@router.post("/generate-link") +def generate_payment_link(request: GenerateLinkRequest): + """生成 Plus 或 Team 支付链接,可选自动无痕打开""" + with get_db() as db: + account = db.query(Account).filter(Account.id == request.account_id).first() + if not account: + raise HTTPException(status_code=404, detail="账号不存在") + + proxy = request.proxy or get_settings().proxy_url + + try: + if request.plan_type == "plus": + link = generate_plus_link(account, proxy, country=request.country) + elif request.plan_type == "team": + link = generate_team_link( + account, + workspace_name=request.workspace_name, + price_interval=request.price_interval, + seat_quantity=request.seat_quantity, + proxy=proxy, + country=request.country, + ) + else: + raise HTTPException(status_code=400, detail="plan_type 必须为 plus 或 team") + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"生成支付链接失败: {e}") + raise HTTPException(status_code=500, detail=f"生成链接失败: {str(e)}") + + opened = False + if request.auto_open and link: + cookies_str = account.cookies if account else None + opened = open_url_incognito(link, cookies_str) + + return { + "success": True, + "link": link, + "plan_type": request.plan_type, + "auto_opened": opened, + } + + +@router.post("/open-incognito") +def open_browser_incognito(request: OpenIncognitoRequest): + """后端以无痕模式打开指定 URL,可注入账号 cookie""" + if not request.url: + raise HTTPException(status_code=400, detail="URL 不能为空") + + cookies_str = None + if request.account_id: + with get_db() as db: + account = db.query(Account).filter(Account.id == request.account_id).first() + if account: + cookies_str = account.cookies + + success = open_url_incognito(request.url, cookies_str) + if success: + return {"success": True, "message": "已在无痕模式打开浏览器"} + return {"success": False, "message": "未找到可用的浏览器,请手动复制链接"} + + +# ============== 订阅状态 ============== + +@router.post("/accounts/batch-check-subscription") +def batch_check_subscription(request: BatchCheckSubscriptionRequest): + """批量检测账号订阅状态""" + proxy = request.proxy or get_settings().proxy_url + + results = {"success_count": 0, "failed_count": 0, "details": []} + + with get_db() as db: + ids = resolve_account_ids( + db, request.ids, request.select_all, + request.status_filter, request.email_service_filter, request.search_filter + ) + for account_id in ids: + account = db.query(Account).filter(Account.id == account_id).first() + if not account: + results["failed_count"] += 1 + results["details"].append( + {"id": account_id, "email": None, "success": False, "error": "账号不存在"} + ) + continue + + try: + status = check_subscription_status(account, proxy) + account.subscription_type = None if status == "free" else status + account.subscription_at = datetime.utcnow() if status != "free" else account.subscription_at + db.commit() + results["success_count"] += 1 + results["details"].append( + {"id": account_id, "email": account.email, "success": True, "subscription_type": status} + ) + except Exception as e: + results["failed_count"] += 1 + results["details"].append( + {"id": account_id, "email": account.email, "success": False, "error": str(e)} + ) + + return results + + +@router.post("/accounts/{account_id}/mark-subscription") +def mark_subscription(account_id: int, request: MarkSubscriptionRequest): + """手动标记账号订阅类型""" + allowed = ("free", "plus", "team") + if request.subscription_type not in allowed: + raise HTTPException(status_code=400, detail=f"subscription_type 必须为 {allowed}") + + with get_db() as db: + account = db.query(Account).filter(Account.id == account_id).first() + if not account: + raise HTTPException(status_code=404, detail="账号不存在") + + account.subscription_type = None if request.subscription_type == "free" else request.subscription_type + account.subscription_at = datetime.utcnow() if request.subscription_type != "free" else None + db.commit() + + return {"success": True, "subscription_type": request.subscription_type} + + diff --git a/src/web/routes/registration.py b/src/web/routes/registration.py new file mode 100644 index 0000000000000000000000000000000000000000..e567a07711c2df838c49a9c478ff7fa766fc2ef0 --- /dev/null +++ b/src/web/routes/registration.py @@ -0,0 +1,1638 @@ +""" +注册任务 API 路由 +""" + +import asyncio +import logging +import uuid +import random +import threading +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] = {} +_outlook_selection_lock = threading.Lock() +_OUTLOOK_CONSUMED_REASON_VALUES = { + "email_already_registered_on_openai", + "username_rejected_or_existing_account", +} +_OUTLOOK_RECOVERABLE_REASON = "token_recovery_pending" + + +def _get_consumed_outlook_placeholder(db, email: Optional[str]): + """Return the failed placeholder account for a consumed Outlook mailbox.""" + if not email: + return None + + from ...database.models import Account + + account = db.query(Account).filter(Account.email == email).first() + if not account or account.status != "failed": + return None + + extra_data = account.extra_data or {} + if extra_data.get("register_failed_reason") in _OUTLOOK_CONSUMED_REASON_VALUES: + return account + + return None + + +def _get_recoverable_outlook_account(db, email: Optional[str]): + """Return the failed Outlook account that should be retried via password recovery.""" + if not email: + return None + + from ...database.models import Account + + account = db.query(Account).filter(Account.email == email).first() + if not account or account.status != "failed": + return None + + extra_data = account.extra_data or {} + if ( + account.email_service == "outlook" + and extra_data.get("register_failed_reason") == _OUTLOOK_RECOVERABLE_REASON + and extra_data.get("recovery_ready") is True + and bool(account.password) + ): + return account + + return None + + +def _build_consumed_outlook_error(email: Optional[str]) -> str: + mailbox = email or "该 Outlook 邮箱" + return f"Outlook 邮箱已消耗或疑似已注册: {mailbox},请导入新的 Outlook 账号" + + +# ============== 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 _resolve_proxy_for_service( + db, + service_type: EmailServiceType, + explicit_proxy: Optional[str], +) -> Tuple[Optional[str], Optional[int], str]: + """Resolve the effective proxy for a registration task.""" + normalized_proxy = explicit_proxy.strip() if isinstance(explicit_proxy, str) else explicit_proxy + if normalized_proxy: + return normalized_proxy, None, "explicit" + + proxy_url, proxy_id = get_proxy_for_registration(db) + if proxy_url: + return proxy_url, proxy_id, "auto" + return None, None, "direct" + + +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") + + # 确定使用的代理 + # 如果前端传入了代理参数,使用传入的 + # 否则从代理列表或系统设置中获取 + + service_type = EmailServiceType(email_service_type) + settings = get_settings() + actual_proxy_url: Optional[str] = None + proxy_id = None + proxy_source = "direct" + effective_email_service_id = email_service_id if email_service_id is not None else task.email_service_id + + # 优先使用数据库中配置的邮箱服务 + if effective_email_service_id is not None: + from ...database.models import EmailService as EmailServiceModel + db_service = db.query(EmailServiceModel).filter( + EmailServiceModel.id == effective_email_service_id, + EmailServiceModel.enabled == True + ).first() + + if db_service: + service_type = EmailServiceType(db_service.service_type) + actual_proxy_url, proxy_id, proxy_source = _resolve_proxy_for_service( + db=db, + service_type=service_type, + explicit_proxy=proxy, + ) + config = _normalize_email_service_config(service_type, db_service.config, actual_proxy_url) + if service_type == EmailServiceType.OUTLOOK: + consumed_placeholder = _get_consumed_outlook_placeholder(db, config.get("email")) + if consumed_placeholder: + raise ValueError(_build_consumed_outlook_error(config.get("email"))) + # 更新任务关联的邮箱服务 + 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"邮箱服务不存在或已禁用: {effective_email_service_id}") + else: + # ???????????? + actual_proxy_url, proxy_id, proxy_source = _resolve_proxy_for_service( + db=db, + service_type=service_type, + explicit_proxy=proxy, + ) + 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, RegistrationTask as RegistrationTaskModel + with _outlook_selection_lock: + # 获取所有启用的 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_service_ids = [svc.id for svc in outlook_services] + reserved_service_ids = { + row.email_service_id + for row in db.query(RegistrationTaskModel.email_service_id).filter( + RegistrationTaskModel.task_uuid != task_uuid, + RegistrationTaskModel.status.in_(["pending", "running"]), + RegistrationTaskModel.email_service_id.isnot(None), + RegistrationTaskModel.email_service_id.in_(outlook_service_ids) + ).all() + } + + selected_service = None + consumed_outlook_count = 0 + for svc in outlook_services: + email = svc.config.get("email") if svc.config else None + if not email: + continue + if svc.id in reserved_service_ids: + logger.info(f"跳过被其他注册任务预留的 Outlook 账户: {email}") + continue + consumed_placeholder = _get_consumed_outlook_placeholder(db, email) + if consumed_placeholder: + consumed_outlook_count += 1 + logger.info(f"跳过已消耗的 Outlook 账户: {email}") + continue + # 检查是否已在 accounts 表中注册 + existing = db.query(Account).filter(Account.email == email).first() + recoverable_account = _get_recoverable_outlook_account(db, email) + if recoverable_account: + selected_service = svc + logger.info(f"选择可恢复的 Outlook 账户: {email}") + break + if not existing: + selected_service = svc + logger.info(f"选择未注册的 Outlook 账户: {email}") + break + 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: + if consumed_outlook_count == len(outlook_services): + raise ValueError("所有 Outlook 邮箱都已消耗或疑似已注册,请导入新的 Outlook 账号") + 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 {} + crud.update_registration_task(db, task_uuid, proxy=actual_proxy_url) + if actual_proxy_url: + logger.info(f"?? {task_uuid} ????[{proxy_source}]: {actual_proxy_url[:50]}...") + else: + logger.info(f"?? {task_uuid} ??????? (source={proxy_source})") + + 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, + proxy_source=proxy_source, + callback_logger=log_callback, + task_uuid=task_uuid + ) + + # 执行注册 + result = engine.run() + + if result.success: + # 更新代理使用时间 + update_proxy_usage(db, proxy_id) + + # 保存到数据库 + if not ((result.metadata or {}).get("database_saved") is True): + 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 or saved_account.session_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 or saved_account.session_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 or saved_account.session_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-10000000) + - email_service_type: 邮箱服务类型 + - proxy: 代理地址 + - interval_min: 最小间隔秒数 + - interval_max: 最大间隔秒数 + """ + # 验证参数 + if request.count < 1 or request.count > 10000000: + raise HTTPException(status_code=400, detail="注册数量必须在 1-10000000 之间") + + 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 <= 10000: + raise HTTPException(status_code=400, detail="并发数必须在 1-10000 之间") + + 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 "" + return { + "task_uuid": task_uuid, + "status": task.status, + "logs": logs.split("\n") if logs else [] + } + + +@router.post("/tasks/{task_uuid}/cancel") +async def cancel_task(task_uuid: str): + """取消任务""" + with get_db() as db: + task = crud.get_registration_task(db, task_uuid) + if not task: + raise HTTPException(status_code=404, detail="任务不存在") + + if task.status not in ["pending", "running"]: + raise HTTPException(status_code=400, detail="任务已完成或已取消") + + task = crud.update_registration_task(db, task_uuid, status="cancelled") + + return {"success": True, "message": "任务已取消"} + + +@router.delete("/tasks/{task_uuid}") +async def delete_task(task_uuid: str): + """删除任务""" + with get_db() as db: + task = crud.get_registration_task(db, task_uuid) + if not task: + raise HTTPException(status_code=404, detail="任务不存在") + + if task.status == "running": + raise HTTPException(status_code=400, detail="无法删除运行中的任务") + + crud.delete_registration_task(db, task_uuid) + + return {"success": True, "message": "任务已删除"} + + +@router.get("/stats") +async def get_registration_stats(): + """获取注册统计信息""" + with get_db() as db: + from sqlalchemy import func + + # 按状态统计 + status_stats = db.query( + RegistrationTask.status, + func.count(RegistrationTask.id) + ).group_by(RegistrationTask.status).all() + + # 今日注册数 + today = datetime.utcnow().date() + today_count = db.query(func.count(RegistrationTask.id)).filter( + func.date(RegistrationTask.created_at) == today + ).scalar() + + return { + "by_status": {status: count for status, count in status_stats}, + "today_count": today_count + } + + +@router.get("/available-services") +async def get_available_email_services(): + """ + 获取可用于注册的邮箱服务列表 + + 返回所有已启用的邮箱服务,包括: + - tempmail: 临时邮箱(无需配置) + - outlook: 已导入的 Outlook 账户 + - moe_mail: 已配置的自定义域名服务 + """ + from ...database.models import EmailService as EmailServiceModel + from ...config.settings import get_settings + from ...core.email_service_catalog import build_temp_mail_catalog + from ...services.temp_mail import TempMailService + + 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() + + def _temp_mail_fetcher(service): + try: + temp_mail_service = TempMailService((service.config or {}).copy(), service.name) + except Exception: + return None + return temp_mail_service._fetch_domains_from_worker + + result["temp_mail"] = build_temp_mail_catalog( + temp_mail_services, + fetch_domains_by_service=_temp_mail_fetcher, + ) + + 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 <= 10000: + raise HTTPException(status_code=400, detail="并发数必须在 1-10000 之间") + + 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() + recoverable_account = _get_recoverable_outlook_account(db, email) + + if existing_account and not recoverable_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 0000000000000000000000000000000000000000..d096fa4db53081765056aa1808250e61f7e83e5b --- /dev/null +++ b/src/web/routes/settings.py @@ -0,0 +1,781 @@ +""" +设置 API 路由 +""" + +import logging +import os +from typing import Optional + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from ...config.settings import get_settings, update_settings +from ...database import crud +from ...database.session import get_db + +logger = logging.getLogger(__name__) +router = APIRouter() + + +# ============== Pydantic Models ============== + +class SettingItem(BaseModel): + """设置项""" + key: str + value: str + description: Optional[str] = None + category: str = "general" + + +class SettingUpdateRequest(BaseModel): + """设置更新请求""" + value: str + + +class ProxySettings(BaseModel): + """代理设置""" + enabled: bool = False + type: str = "http" # http, socks5 + host: str = "127.0.0.1" + port: int = 7890 + username: Optional[str] = None + password: Optional[str] = None + + +class RegistrationSettings(BaseModel): + """注册设置""" + max_retries: int = 3 + timeout: int = 120 + default_password_length: int = 12 + sleep_min: int = 5 + sleep_max: int = 30 + + +class WebUISettings(BaseModel): + """Web UI 设置""" + host: Optional[str] = None + port: Optional[int] = None + debug: Optional[bool] = None + access_password: Optional[str] = None + + +class AllSettings(BaseModel): + """所有设置""" + proxy: ProxySettings + registration: RegistrationSettings + webui: WebUISettings + + +# ============== API Endpoints ============== + +@router.get("") +async def get_all_settings(): + """获取所有设置""" + settings = get_settings() + + return { + "proxy": { + "enabled": settings.proxy_enabled, + "type": settings.proxy_type, + "host": settings.proxy_host, + "port": settings.proxy_port, + "username": settings.proxy_username, + "has_password": bool(settings.proxy_password), + "dynamic_enabled": settings.proxy_dynamic_enabled, + "dynamic_api_url": settings.proxy_dynamic_api_url, + "dynamic_api_key_header": settings.proxy_dynamic_api_key_header, + "dynamic_result_field": settings.proxy_dynamic_result_field, + "has_dynamic_api_key": bool(settings.proxy_dynamic_api_key and settings.proxy_dynamic_api_key.get_secret_value()), + }, + "registration": { + "max_retries": settings.registration_max_retries, + "timeout": settings.registration_timeout, + "default_password_length": settings.registration_default_password_length, + "sleep_min": settings.registration_sleep_min, + "sleep_max": settings.registration_sleep_max, + }, + "webui": { + "host": settings.webui_host, + "port": settings.webui_port, + "debug": settings.debug, + "has_access_password": bool(settings.webui_access_password and settings.webui_access_password.get_secret_value()), + }, + "tempmail": { + "base_url": settings.tempmail_base_url, + "timeout": settings.tempmail_timeout, + "max_retries": settings.tempmail_max_retries, + }, + "email_code": { + "timeout": settings.email_code_timeout, + "poll_interval": settings.email_code_poll_interval, + }, + } + + +@router.get("/proxy/dynamic") +async def get_dynamic_proxy_settings(): + """获取动态代理设置""" + settings = get_settings() + return { + "enabled": settings.proxy_dynamic_enabled, + "api_url": settings.proxy_dynamic_api_url, + "api_key_header": settings.proxy_dynamic_api_key_header, + "result_field": settings.proxy_dynamic_result_field, + "has_api_key": bool(settings.proxy_dynamic_api_key and settings.proxy_dynamic_api_key.get_secret_value()), + } + + +class DynamicProxySettings(BaseModel): + """动态代理设置""" + enabled: bool = False + api_url: str = "" + api_key: Optional[str] = None + api_key_header: str = "X-API-Key" + result_field: str = "" + + +@router.post("/proxy/dynamic") +async def update_dynamic_proxy_settings(request: DynamicProxySettings): + """更新动态代理设置""" + update_dict = { + "proxy_dynamic_enabled": request.enabled, + "proxy_dynamic_api_url": request.api_url, + "proxy_dynamic_api_key_header": request.api_key_header, + "proxy_dynamic_result_field": request.result_field, + } + if request.api_key is not None: + update_dict["proxy_dynamic_api_key"] = request.api_key + + update_settings(**update_dict) + return {"success": True, "message": "动态代理设置已更新"} + + +@router.post("/proxy/dynamic/test") +async def test_dynamic_proxy(request: DynamicProxySettings): + """测试动态代理 API""" + from ...core.dynamic_proxy import fetch_dynamic_proxy + + if not request.api_url: + raise HTTPException(status_code=400, detail="请填写动态代理 API 地址") + + # 若未传入 api_key,使用已保存的 + api_key = request.api_key or "" + if not api_key: + settings = get_settings() + if settings.proxy_dynamic_api_key: + api_key = settings.proxy_dynamic_api_key.get_secret_value() + + proxy_url = fetch_dynamic_proxy( + api_url=request.api_url, + api_key=api_key, + api_key_header=request.api_key_header, + result_field=request.result_field, + ) + + if not proxy_url: + return {"success": False, "message": "动态代理 API 返回为空或请求失败"} + + # 用获取到的代理测试连通性 + import time + from curl_cffi import requests as cffi_requests + try: + proxies = {"http": proxy_url, "https": proxy_url} + start = time.time() + resp = cffi_requests.get( + "https://api.ipify.org?format=json", + proxies=proxies, + timeout=10, + impersonate="chrome110" + ) + elapsed = round((time.time() - start) * 1000) + if resp.status_code == 200: + ip = resp.json().get("ip", "") + return {"success": True, "proxy_url": proxy_url, "ip": ip, "response_time": elapsed, + "message": f"动态代理可用,出口 IP: {ip},响应时间: {elapsed}ms"} + return {"success": False, "proxy_url": proxy_url, "message": f"代理连接失败: HTTP {resp.status_code}"} + except Exception as e: + return {"success": False, "proxy_url": proxy_url, "message": f"代理连接失败: {e}"} + + +@router.get("/registration") +async def get_registration_settings(): + """获取注册设置""" + settings = get_settings() + + return { + "max_retries": settings.registration_max_retries, + "timeout": settings.registration_timeout, + "default_password_length": settings.registration_default_password_length, + "sleep_min": settings.registration_sleep_min, + "sleep_max": settings.registration_sleep_max, + } + + +@router.post("/registration") +async def update_registration_settings(request: RegistrationSettings): + """更新注册设置""" + update_settings( + registration_max_retries=request.max_retries, + registration_timeout=request.timeout, + registration_default_password_length=request.default_password_length, + registration_sleep_min=request.sleep_min, + registration_sleep_max=request.sleep_max, + ) + + return {"success": True, "message": "注册设置已更新"} + + +@router.post("/webui") +async def update_webui_settings(request: WebUISettings): + """更新 Web UI 设置""" + update_dict = {} + if request.host is not None: + update_dict["webui_host"] = request.host + if request.port is not None: + update_dict["webui_port"] = request.port + if request.debug is not None: + update_dict["debug"] = request.debug + if request.access_password: + update_dict["webui_access_password"] = request.access_password + + update_settings(**update_dict) + return {"success": True, "message": "Web UI 设置已更新"} + + +@router.get("/database") +async def get_database_info(): + """获取数据库信息""" + settings = get_settings() + + import os + from pathlib import Path + + db_path = settings.database_url + if db_path.startswith("sqlite:///"): + db_path = db_path[10:] + + db_file = Path(db_path) if os.path.isabs(db_path) else Path(db_path) + db_size = db_file.stat().st_size if db_file.exists() else 0 + + with get_db() as db: + from ...database.models import Account, EmailService, RegistrationTask + + account_count = db.query(Account).count() + service_count = db.query(EmailService).count() + task_count = db.query(RegistrationTask).count() + + return { + "database_url": settings.database_url, + "database_size_bytes": db_size, + "database_size_mb": round(db_size / (1024 * 1024), 2), + "accounts_count": account_count, + "email_services_count": service_count, + "tasks_count": task_count, + } + + +@router.post("/database/backup") +async def backup_database(): + """备份数据库""" + import shutil + from datetime import datetime + + settings = get_settings() + + db_path = settings.database_url + if db_path.startswith("sqlite:///"): + db_path = db_path[10:] + + if not os.path.exists(db_path): + raise HTTPException(status_code=404, detail="数据库文件不存在") + + # 创建备份目录 + from pathlib import Path as FilePath + backup_dir = FilePath(db_path).parent / "backups" + backup_dir.mkdir(exist_ok=True) + + # 生成备份文件名 + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + backup_path = backup_dir / f"database_backup_{timestamp}.db" + + # 复制数据库文件 + shutil.copy2(db_path, backup_path) + + return { + "success": True, + "message": "数据库备份成功", + "backup_path": str(backup_path) + } + + +@router.post("/database/cleanup") +async def cleanup_database( + days: int = 30, + keep_failed: bool = True +): + """清理过期数据""" + from datetime import datetime, timedelta + + cutoff_date = datetime.utcnow() - timedelta(days=days) + + with get_db() as db: + from ...database.models import RegistrationTask + from sqlalchemy import delete + + # 删除旧任务 + conditions = [RegistrationTask.created_at < cutoff_date] + if not keep_failed: + conditions.append(RegistrationTask.status != "failed") + else: + conditions.append(RegistrationTask.status.in_(["completed", "cancelled"])) + + result = db.execute( + delete(RegistrationTask).where(*conditions) + ) + db.commit() + + deleted_count = result.rowcount + + return { + "success": True, + "message": f"已清理 {deleted_count} 条过期任务记录", + "deleted_count": deleted_count + } + + +@router.get("/logs") +async def get_recent_logs( + lines: int = 100, + level: str = "INFO" +): + """获取最近日志""" + settings = get_settings() + + log_file = settings.log_file + if not log_file: + return {"logs": [], "message": "日志文件未配置"} + + from pathlib import Path + log_path = Path(log_file) + + if not log_path.exists(): + return {"logs": [], "message": "日志文件不存在"} + + try: + with open(log_path, "r", encoding="utf-8") as f: + all_lines = f.readlines() + recent_lines = all_lines[-lines:] + + return { + "logs": [line.strip() for line in recent_lines], + "total_lines": len(all_lines) + } + except Exception as e: + return {"logs": [], "error": str(e)} + + +# ============== 临时邮箱设置 ============== + +class TempmailSettings(BaseModel): + """临时邮箱设置""" + api_url: Optional[str] = None + enabled: bool = True + + +class EmailCodeSettings(BaseModel): + """验证码等待设置""" + timeout: int = 120 # 验证码等待超时(秒) + poll_interval: int = 3 # 验证码轮询间隔(秒) + + +@router.get("/tempmail") +async def get_tempmail_settings(): + """获取临时邮箱设置""" + settings = get_settings() + + return { + "api_url": settings.tempmail_base_url, + "timeout": settings.tempmail_timeout, + "max_retries": settings.tempmail_max_retries, + "enabled": True # 临时邮箱默认可用 + } + + +@router.post("/tempmail") +async def update_tempmail_settings(request: TempmailSettings): + """更新临时邮箱设置""" + update_dict = {} + + if request.api_url: + update_dict["tempmail_base_url"] = request.api_url + + update_settings(**update_dict) + + return {"success": True, "message": "临时邮箱设置已更新"} + + +# ============== 验证码等待设置 ============== + +@router.get("/email-code") +async def get_email_code_settings(): + """获取验证码等待设置""" + settings = get_settings() + return { + "timeout": settings.email_code_timeout, + "poll_interval": settings.email_code_poll_interval, + } + + +@router.post("/email-code") +async def update_email_code_settings(request: EmailCodeSettings): + """更新验证码等待设置""" + # 验证参数范围 + if request.timeout < 30 or request.timeout > 600: + raise HTTPException(status_code=400, detail="超时时间必须在 30-600 秒之间") + if request.poll_interval < 1 or request.poll_interval > 30: + raise HTTPException(status_code=400, detail="轮询间隔必须在 1-30 秒之间") + + update_settings( + email_code_timeout=request.timeout, + email_code_poll_interval=request.poll_interval, + ) + + return {"success": True, "message": "验证码等待设置已更新"} + + +# ============== 代理列表 CRUD ============== + +class ProxyCreateRequest(BaseModel): + """创建代理请求""" + name: str + type: str = "http" # http, socks5 + host: str + port: int + username: Optional[str] = None + password: Optional[str] = None + enabled: bool = True + priority: int = 0 + + +class ProxyUpdateRequest(BaseModel): + """更新代理请求""" + name: Optional[str] = None + type: Optional[str] = None + host: Optional[str] = None + port: Optional[int] = None + username: Optional[str] = None + password: Optional[str] = None + enabled: Optional[bool] = None + priority: Optional[int] = None + + +@router.get("/proxies") +async def get_proxies_list(enabled: Optional[bool] = None): + """获取代理列表""" + with get_db() as db: + proxies = crud.get_proxies(db, enabled=enabled) + return { + "proxies": [p.to_dict() for p in proxies], + "total": len(proxies) + } + + +@router.post("/proxies") +async def create_proxy_item(request: ProxyCreateRequest): + """创建代理""" + with get_db() as db: + proxy = crud.create_proxy( + db, + name=request.name, + type=request.type, + host=request.host, + port=request.port, + username=request.username, + password=request.password, + enabled=request.enabled, + priority=request.priority + ) + return {"success": True, "proxy": proxy.to_dict()} + + +@router.get("/proxies/{proxy_id}") +async def get_proxy_item(proxy_id: int): + """获取单个代理""" + with get_db() as db: + proxy = crud.get_proxy_by_id(db, proxy_id) + if not proxy: + raise HTTPException(status_code=404, detail="代理不存在") + return proxy.to_dict(include_password=True) + + +@router.patch("/proxies/{proxy_id}") +async def update_proxy_item(proxy_id: int, request: ProxyUpdateRequest): + """更新代理""" + with get_db() as db: + update_data = {} + if request.name is not None: + update_data["name"] = request.name + if request.type is not None: + update_data["type"] = request.type + if request.host is not None: + update_data["host"] = request.host + if request.port is not None: + update_data["port"] = request.port + if request.username is not None: + update_data["username"] = request.username + if request.password is not None: + update_data["password"] = request.password + if request.enabled is not None: + update_data["enabled"] = request.enabled + if request.priority is not None: + update_data["priority"] = request.priority + + proxy = crud.update_proxy(db, proxy_id, **update_data) + if not proxy: + raise HTTPException(status_code=404, detail="代理不存在") + return {"success": True, "proxy": proxy.to_dict()} + + +@router.delete("/proxies/{proxy_id}") +async def delete_proxy_item(proxy_id: int): + """删除代理""" + with get_db() as db: + success = crud.delete_proxy(db, proxy_id) + if not success: + raise HTTPException(status_code=404, detail="代理不存在") + return {"success": True, "message": "代理已删除"} + + +@router.post("/proxies/{proxy_id}/set-default") +async def set_proxy_default(proxy_id: int): + """将指定代理设为默认""" + with get_db() as db: + proxy = crud.set_proxy_default(db, proxy_id) + if not proxy: + raise HTTPException(status_code=404, detail="代理不存在") + return {"success": True, "proxy": proxy.to_dict()} + + +@router.post("/proxies/{proxy_id}/test") +async def test_proxy_item(proxy_id: int): + """测试单个代理""" + import time + from curl_cffi import requests as cffi_requests + + with get_db() as db: + proxy = crud.get_proxy_by_id(db, proxy_id) + if not proxy: + raise HTTPException(status_code=404, detail="代理不存在") + + proxy_url = proxy.proxy_url + test_url = "https://api.ipify.org?format=json" + start_time = time.time() + + try: + proxies = { + "http": proxy_url, + "https": proxy_url + } + + response = cffi_requests.get( + test_url, + proxies=proxies, + timeout=3, + impersonate="chrome110" + ) + + elapsed_time = time.time() - start_time + + if response.status_code == 200: + ip_info = response.json() + return { + "success": True, + "ip": ip_info.get("ip", ""), + "response_time": round(elapsed_time * 1000), + "message": f"代理连接成功,出口 IP: {ip_info.get('ip', 'unknown')}" + } + else: + return { + "success": False, + "message": f"代理返回错误状态码: {response.status_code}" + } + + except Exception as e: + return { + "success": False, + "message": f"代理连接失败: {str(e)}" + } + + +@router.post("/proxies/test-all") +async def test_all_proxies(): + """测试所有启用的代理""" + import time + from curl_cffi import requests as cffi_requests + + with get_db() as db: + proxies = crud.get_enabled_proxies(db) + + results = [] + for proxy in proxies: + proxy_url = proxy.proxy_url + test_url = "https://api.ipify.org?format=json" + start_time = time.time() + + try: + proxies_dict = { + "http": proxy_url, + "https": proxy_url + } + + response = cffi_requests.get( + test_url, + proxies=proxies_dict, + timeout=3, + impersonate="chrome110" + ) + + elapsed_time = time.time() - start_time + + if response.status_code == 200: + ip_info = response.json() + results.append({ + "id": proxy.id, + "name": proxy.name, + "success": True, + "ip": ip_info.get("ip", ""), + "response_time": round(elapsed_time * 1000) + }) + else: + results.append({ + "id": proxy.id, + "name": proxy.name, + "success": False, + "message": f"状态码: {response.status_code}" + }) + + except Exception as e: + results.append({ + "id": proxy.id, + "name": proxy.name, + "success": False, + "message": str(e) + }) + + success_count = sum(1 for r in results if r["success"]) + return { + "total": len(proxies), + "success": success_count, + "failed": len(proxies) - success_count, + "results": results + } + + +@router.post("/proxies/{proxy_id}/enable") +async def enable_proxy(proxy_id: int): + """启用代理""" + with get_db() as db: + proxy = crud.update_proxy(db, proxy_id, enabled=True) + if not proxy: + raise HTTPException(status_code=404, detail="代理不存在") + return {"success": True, "message": "代理已启用"} + + +@router.post("/proxies/{proxy_id}/disable") +async def disable_proxy(proxy_id: int): + """禁用代理""" + with get_db() as db: + proxy = crud.update_proxy(db, proxy_id, enabled=False) + if not proxy: + raise HTTPException(status_code=404, detail="代理不存在") + return {"success": True, "message": "代理已禁用"} + + +# ============== Outlook 设置 ============== + +class OutlookSettings(BaseModel): + """Outlook 设置""" + default_client_id: Optional[str] = None + + +@router.get("/outlook") +async def get_outlook_settings(): + """获取 Outlook 设置""" + settings = get_settings() + + return { + "default_client_id": settings.outlook_default_client_id, + "provider_priority": settings.outlook_provider_priority, + "health_failure_threshold": settings.outlook_health_failure_threshold, + "health_disable_duration": settings.outlook_health_disable_duration, + } + + +@router.post("/outlook") +async def update_outlook_settings(request: OutlookSettings): + """更新 Outlook 设置""" + update_dict = {} + + if request.default_client_id is not None: + update_dict["outlook_default_client_id"] = request.default_client_id + + if update_dict: + update_settings(**update_dict) + + return {"success": True, "message": "Outlook 设置已更新"} + + +# ============== Team Manager 设置 ============== + +class TeamManagerSettings(BaseModel): + """Team Manager 设置""" + enabled: bool = False + api_url: str = "" + api_key: str = "" + + +class TeamManagerTestRequest(BaseModel): + """Team Manager 测试请求""" + api_url: str + api_key: str + + +@router.get("/team-manager") +async def get_team_manager_settings(): + """获取 Team Manager 设置""" + settings = get_settings() + return { + "enabled": settings.tm_enabled, + "api_url": settings.tm_api_url, + "has_api_key": bool(settings.tm_api_key and settings.tm_api_key.get_secret_value()), + } + + +@router.post("/team-manager") +async def update_team_manager_settings(request: TeamManagerSettings): + """更新 Team Manager 设置""" + update_dict = { + "tm_enabled": request.enabled, + "tm_api_url": request.api_url, + } + if request.api_key: + update_dict["tm_api_key"] = request.api_key + update_settings(**update_dict) + return {"success": True, "message": "Team Manager 设置已更新"} + + +@router.post("/team-manager/test") +async def test_team_manager_connection(request: TeamManagerTestRequest): + """测试 Team Manager 连接""" + from ...core.upload.team_manager_upload import test_team_manager_connection as do_test + + settings = get_settings() + api_key = request.api_key + if api_key == 'use_saved_key' or not api_key: + if settings.tm_api_key: + api_key = settings.tm_api_key.get_secret_value() + else: + return {"success": False, "message": "未配置 API Key"} + + success, message = do_test(request.api_url, api_key) + return {"success": success, "message": message} diff --git a/src/web/routes/upload/__init__.py b/src/web/routes/upload/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..1f776fc63741b86529101e69d5dde27b5df0c0a2 --- /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 0000000000000000000000000000000000000000..f98ec2f283c1821635d67aef577a37d9ef16d6fd --- /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 0000000000000000000000000000000000000000..ddd77592e0b37443f8571d48adf6620d2521e056 --- /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 0000000000000000000000000000000000000000..b363139ea6dba8881b24104b97f9acf8a2369934 --- /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 0000000000000000000000000000000000000000..d864f8377a854a2c69499fb286c6e56db474bce6 --- /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 0000000000000000000000000000000000000000..3a9ad27f0a7a7d5d30041643dc61bf306940b547 --- /dev/null +++ b/src/web/task_manager.py @@ -0,0 +1,428 @@ +""" +任务管理器 +负责管理后台任务、日志队列和 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__) + +# 全局线程池(支持最多 10000 个并发注册任务) +_executor = ThreadPoolExecutor(max_workers=10000, 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] = {} + + normalized_kwargs = dict(kwargs) + updated_at = datetime.utcnow().isoformat() + + # 终态补全关键字段,便于前端和 WebSocket 消费。 + if status == "failed": + error = normalized_kwargs.get("error") + if isinstance(error, Exception): + error = str(error) + if not error: + error = "Task failed" + normalized_kwargs["error"] = str(error) + normalized_kwargs.setdefault("message", "Task execution failed") + elif status == "completed": + normalized_kwargs.setdefault("message", "Task completed") + elif status == "cancelled": + normalized_kwargs.setdefault("message", "Task cancelled") + elif status == "cancelling": + normalized_kwargs.setdefault("message", "Task cancellation requested") + + _task_status[task_uuid]["status"] = status + _task_status[task_uuid]["updated_at"] = updated_at + _task_status[task_uuid].update(normalized_kwargs) + + # 若事件循环可用,自动推送单任务状态到 WebSocket。 + if self._loop and self._loop.is_running(): + try: + try: + running_loop = asyncio.get_running_loop() + except RuntimeError: + running_loop = None + + if running_loop is self._loop: + payload = {k: v for k, v in _task_status[task_uuid].items() if k != "status"} + asyncio.create_task( + self.broadcast_status(task_uuid, status, **payload) + ) + else: + payload = {k: v for k, v in _task_status[task_uuid].items() if k != "status"} + asyncio.run_coroutine_threadsafe( + self.broadcast_status(task_uuid, status, **payload), + 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 0000000000000000000000000000000000000000..4c40acc9e725c434818ebec3c4eee1d78fde4465 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,1366 @@ +/* + * OpenAI 注册系统 - 主样式表 + * 现代化、响应式设计,支持暗色模式 + */ + +/* CSS 变量 - 亮色主题 */ +:root { + /* 主色调 */ + --primary-color: #10a37f; + --primary-hover: #0d8a6a; + --primary-light: rgba(16, 163, 127, 0.1); + --primary-dark: #0a7d5e; + + /* 语义色 */ + --danger-color: #ef4444; + --danger-hover: #dc2626; + --danger-light: rgba(239, 68, 68, 0.1); + --warning-color: #f59e0b; + --warning-light: rgba(245, 158, 11, 0.1); + --success-color: #22c55e; + --success-light: rgba(34, 197, 94, 0.1); + --info-color: #3b82f6; + --info-light: rgba(59, 130, 246, 0.1); + + /* 中性色 */ + --secondary-color: #6b7280; + --background: #f8fafc; + --surface: #ffffff; + --surface-hover: #f1f5f9; + --border: #e2e8f0; + --border-light: #f1f5f9; + + /* 文字色 */ + --text-primary: #0f172a; + --text-secondary: #64748b; + --text-muted: #94a3b8; + + /* 阴影 */ + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + + /* 圆角 */ + --radius-sm: 4px; + --radius: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + --radius-full: 9999px; + + /* 动画 */ + --transition-fast: 150ms ease; + --transition: 200ms ease; + --transition-slow: 300ms ease; + + /* 字体 */ + --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + --font-mono: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', monospace; + + /* 间距 */ + --spacing-xs: 4px; + --spacing-sm: 8px; + --spacing-md: 16px; + --spacing-lg: 24px; + --spacing-xl: 32px; +} + +/* 暗色主题 */ +[data-theme="dark"] { + --primary-color: #34d399; + --primary-hover: #6ee7b7; + --primary-light: rgba(52, 211, 153, 0.15); + --primary-dark: #10b981; + + --danger-color: #f87171; + --danger-hover: #fca5a5; + --danger-light: rgba(248, 113, 113, 0.15); + --warning-color: #fbbf24; + --warning-light: rgba(251, 191, 36, 0.15); + --success-color: #4ade80; + --success-light: rgba(74, 222, 128, 0.15); + --info-color: #60a5fa; + --info-light: rgba(96, 165, 250, 0.15); + + --secondary-color: #94a3b8; + --background: #0f172a; + --surface: #1e293b; + --surface-hover: #334155; + --border: #334155; + --border-light: #1e293b; + + --text-primary: #f1f5f9; + --text-secondary: #94a3b8; + --text-muted: #64748b; + + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3); + --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.4), 0 1px 2px -1px rgb(0 0 0 / 0.4); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.4); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.4); +} + +/* 重置样式 */ +*, *::before, *::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html { + font-size: 16px; + scroll-behavior: smooth; +} + +body { + font-family: var(--font-sans); + background-color: var(--background); + color: var(--text-primary); + line-height: 1.6; + min-height: 100vh; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* ============================================ + 布局 + ============================================ */ + +.container { + max-width: 1280px; + margin: 0 auto; + padding: 0 var(--spacing-lg); +} + +/* ============================================ + 导航栏 + ============================================ */ + +.navbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--spacing-lg) 0; + background: var(--surface); + border-bottom: 1px solid var(--border); + margin-bottom: var(--spacing-xl); + position: sticky; + top: 0; + z-index: 100; + backdrop-filter: blur(8px); +} + +.nav-brand h1 { + font-size: 1.25rem; + font-weight: 700; + color: var(--primary-color); + display: flex; + align-items: center; + gap: var(--spacing-sm); +} + +.nav-brand h1::before { + content: ''; + display: inline-block; + width: 8px; + height: 8px; + background: var(--primary-color); + border-radius: 50%; + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.5; transform: scale(1.2); } +} + +.nav-links { + display: flex; + gap: var(--spacing-xs); + background: var(--border-light); + padding: var(--spacing-xs); + border-radius: var(--radius-lg); +} + +.nav-link { + text-decoration: none; + color: var(--text-secondary); + padding: var(--spacing-sm) var(--spacing-md); + border-radius: var(--radius); + font-size: 0.875rem; + font-weight: 500; + transition: all var(--transition); +} + +.nav-link:hover { + color: var(--text-primary); + background: var(--surface); +} + +.nav-link.active { + color: var(--primary-color); + background: var(--surface); + box-shadow: var(--shadow-sm); +} + +/* 主题切换按钮 */ +.theme-toggle { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: var(--spacing-sm); + cursor: pointer; + color: var(--text-secondary); + transition: all var(--transition); + margin-left: var(--spacing-md); +} + +.theme-toggle:hover { + color: var(--text-primary); + background: var(--surface-hover); +} + +/* ============================================ + 主内容区 + ============================================ */ + +.main-content { + padding-bottom: 60px; +} + +.page-header { + margin-bottom: var(--spacing-xl); +} + +.page-header h2 { + font-size: 1.75rem; + font-weight: 700; + margin-bottom: var(--spacing-xs); + letter-spacing: -0.025em; +} + +.subtitle { + color: var(--text-secondary); + font-size: 0.9375rem; +} + +/* ============================================ + 卡片 + ============================================ */ + +.card { + background: var(--surface); + border-radius: var(--radius-lg); + border: 1px solid var(--border); + margin-bottom: var(--spacing-lg); + box-shadow: var(--shadow-sm); + overflow: hidden; + transition: box-shadow var(--transition); +} + +.card:hover { + box-shadow: var(--shadow); +} + +.card-header { + padding: var(--spacing-md) var(--spacing-lg); + border-bottom: 1px solid var(--border); + display: flex; + justify-content: space-between; + align-items: center; + background: var(--surface); +} + +.card-header h3 { + font-size: 0.9375rem; + font-weight: 600; + color: var(--text-primary); +} + +.card-body { + padding: var(--spacing-lg); +} + +/* 工具栏卡片允许下拉菜单溢出 */ +.card.toolbar-card { + overflow: visible; +} + +.card-body.toolbar { + overflow: visible; +} + +/* ============================================ + 表单元素 + ============================================ */ + +.form-group { + margin-bottom: var(--spacing-md); +} + +.form-group label { + display: block; + margin-bottom: var(--spacing-xs); + font-weight: 500; + font-size: 0.8125rem; + color: var(--text-secondary); +} + +.form-group input, +.form-group select, +.form-group textarea { + width: 100%; + padding: 10px 14px; + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 0.875rem; + font-family: inherit; + background: var(--surface); + color: var(--text-primary); + transition: all var(--transition); +} + +.form-group input:hover, +.form-group select:hover, +.form-group textarea:hover { + border-color: var(--text-muted); +} + +.form-group input:focus, +.form-group select:focus, +.form-group textarea:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px var(--primary-light); +} + +.form-group input::placeholder, +.form-group textarea::placeholder { + color: var(--text-muted); +} + +.form-group textarea { + resize: vertical; + min-height: 100px; +} + +.form-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--spacing-md); +} + +.form-actions { + display: flex; + gap: var(--spacing-sm); + margin-top: var(--spacing-lg); + padding-top: var(--spacing-lg); + border-top: 1px solid var(--border-light); +} + +/* Checkbox 样式优化 */ +.form-group input[type="checkbox"] { + width: auto; + margin-right: var(--spacing-sm); + accent-color: var(--primary-color); +} + +/* ============================================ + 按钮 + ============================================ */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--spacing-sm); + padding: 10px 20px; + font-size: 0.875rem; + font-weight: 500; + font-family: inherit; + border-radius: var(--radius); + border: none; + cursor: pointer; + transition: all var(--transition); + white-space: nowrap; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-primary { + background: var(--primary-color); + color: white; +} + +.btn-primary:hover:not(:disabled) { + background: var(--primary-hover); + transform: translateY(-1px); + box-shadow: var(--shadow-md); +} + +.btn-primary:active:not(:disabled) { + transform: translateY(0); +} + +.btn-secondary { + background: var(--surface-hover); + color: var(--text-primary); + border: 1px solid var(--border); +} + +.btn-secondary:hover:not(:disabled) { + background: var(--border); + border-color: var(--text-muted); +} + +.btn-danger { + background: var(--danger-color); + color: white; +} + +.btn-danger:hover:not(:disabled) { + background: var(--danger-hover); + transform: translateY(-1px); +} + +.btn-warning { + background: var(--warning-color); + color: white; +} + +.btn-warning:hover:not(:disabled) { + background: #d97706; +} + +.btn-ghost { + background: transparent; + color: var(--text-secondary); +} + +.btn-copy-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding: 0; + font-size: 11px; + line-height: 1; + background: var(--surface-hover); + border: 1px solid var(--border); + border-radius: 50%; + cursor: pointer; + color: var(--text-muted); + flex-shrink: 0; + transition: background 0.15s, color 0.15s; +} + +.btn-copy-icon:hover { + background: var(--primary-light); + color: var(--primary-color); + border-color: var(--primary-color); +} + +.btn-ghost:hover:not(:disabled) { + background: var(--surface-hover); + color: var(--text-primary); +} + +.btn-sm { + padding: 6px 12px; + font-size: 0.75rem; +} + +.btn-lg { + padding: 14px 28px; + font-size: 1rem; +} + +.btn-icon { + padding: var(--spacing-sm); + width: 36px; + height: 36px; +} + +/* 加载状态 */ +.btn.loading { + position: relative; + color: transparent; + pointer-events: none; +} + +.btn.loading::after { + content: ''; + position: absolute; + width: 16px; + height: 16px; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + animation: spin 0.75s linear infinite; + color: white; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ============================================ + 控制台日志 + ============================================ */ + +.console-log { + background: linear-gradient(180deg, #1a1b26 0%, #16161e 100%); + color: #a9b1d6; + padding: var(--spacing-md); + border-radius: var(--radius); + font-family: var(--font-mono); + font-size: 0.8125rem; + height: 320px; + overflow-y: auto; + line-height: 1.7; + position: relative; +} + +.console-log::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 40px; + background: linear-gradient(180deg, #1a1b26 0%, transparent 100%); + pointer-events: none; + z-index: 1; +} + +.log-line { + margin-bottom: 2px; + white-space: pre-wrap; + word-break: break-all; + padding: 2px 0; + animation: fadeIn 0.2s ease; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + +.log-line.info { color: #7aa2f7; } +.log-line.success { color: #9ece6a; } +.log-line.error { color: #f7768e; } +.log-line.warning { color: #e0af68; } +.log-line.debug { color: #565f89; } + +/* 时间戳样式 */ +.log-line .timestamp { + color: #565f89; + margin-right: var(--spacing-sm); +} + +/* ============================================ + 状态徽章 + ============================================ */ + +.status-badge { + display: inline-flex; + align-items: center; + gap: var(--spacing-xs); + padding: 4px 12px; + border-radius: var(--radius-full); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.025em; +} + +.status-badge::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} + +.status-badge.active, +.status-badge.running { + background: var(--success-light); + color: var(--success-color); +} + +.status-badge.completed { + background: var(--success-light); + color: var(--success-color); +} + +.status-badge.pending, +.status-badge.waiting { + background: var(--info-light); + color: var(--info-color); +} + +.status-badge.failed, +.status-badge.error { + background: var(--danger-light); + color: var(--danger-color); +} + +.status-badge.expired, +.status-badge.warning { + background: var(--warning-light); + color: var(--warning-color); +} + +.status-badge.banned { + background: var(--danger-light); + color: var(--danger-color); +} + +.status-badge.disabled { + background: var(--border); + color: var(--text-muted); +} + +/* ============================================ + 统计卡片 + ============================================ */ + +.stats-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--spacing-md); + margin-bottom: var(--spacing-lg); +} + +.stat-card { + background: var(--surface); + border-radius: var(--radius-lg); + border: 1px solid var(--border); + padding: var(--spacing-lg); + position: relative; + overflow: hidden; + transition: all var(--transition); +} + +.stat-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: var(--border); +} + +.stat-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +.stat-card.success::before { background: var(--success-color); } +.stat-card.warning::before { background: var(--warning-color); } +.stat-card.danger::before { background: var(--danger-color); } +.stat-card.info::before { background: var(--info-color); } + +.stat-value { + font-size: 2rem; + font-weight: 700; + color: var(--text-primary); + line-height: 1.2; +} + +.stat-label { + color: var(--text-secondary); + font-size: 0.8125rem; + margin-top: var(--spacing-xs); +} + +.stat-card.success .stat-value { color: var(--success-color); } +.stat-card.warning .stat-value { color: var(--warning-color); } +.stat-card.danger .stat-value { color: var(--danger-color); } +.stat-card.info .stat-value { color: var(--info-color); } + +/* ============================================ + 数据表格 + ============================================ */ + +.table-container { + overflow-x: auto; + border-radius: var(--radius); +} + +.data-table { + width: 100%; + border-collapse: collapse; +} + +.data-table th, +.data-table td { + padding: var(--spacing-md); + text-align: left; + border-bottom: 1px solid var(--border-light); +} + +.data-table th { + font-weight: 600; + color: var(--text-secondary); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + background: var(--surface-hover); + position: sticky; + top: 0; +} + +.data-table tbody tr { + transition: background var(--transition-fast); +} + +.data-table tbody tr:hover { + background: var(--surface-hover); +} + +.data-table tbody tr:last-child td { + border-bottom: none; +} + +.data-table input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--primary-color); + cursor: pointer; +} + +/* ============================================ + 工具栏 + ============================================ */ + +.toolbar { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: var(--spacing-md); + overflow: visible; +} + +.toolbar-left, +.toolbar-right { + display: flex; + align-items: center; + gap: var(--spacing-sm); +} + +.form-select, +.form-input { + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 0.875rem; + background: var(--surface); + color: var(--text-primary); + transition: all var(--transition); +} + +.form-select:hover, +.form-input:hover { + border-color: var(--text-muted); +} + +.form-select:focus, +.form-input:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px var(--primary-light); +} + +/* ============================================ + 分页 + ============================================ */ + +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: var(--spacing-md); + margin-top: var(--spacing-lg); + padding-top: var(--spacing-lg); + border-top: 1px solid var(--border-light); +} + +#page-info { + color: var(--text-secondary); + font-size: 0.875rem; + min-width: 100px; + text-align: center; +} + +/* ============================================ + 标签页 + ============================================ */ + +.tabs { + display: flex; + gap: var(--spacing-xs); + margin-bottom: var(--spacing-lg); + background: var(--surface); + padding: var(--spacing-xs); + border-radius: var(--radius-lg); + border: 1px solid var(--border); +} + +.tab-btn { + flex: 1; + padding: var(--spacing-sm) var(--spacing-md); + background: transparent; + border: none; + cursor: pointer; + font-size: 0.875rem; + font-weight: 500; + font-family: inherit; + color: var(--text-secondary); + border-radius: var(--radius); + transition: all var(--transition); +} + +.tab-btn:hover { + color: var(--text-primary); + background: var(--surface-hover); +} + +.tab-btn.active { + color: var(--primary-color); + background: var(--primary-light); +} + +.tab-content { + display: none; + animation: fadeIn 0.2s ease; +} + +.tab-content.active { + display: block; +} + +/* ============================================ + 模态框 + ============================================ */ + +.modal { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + z-index: 1000; + align-items: center; + justify-content: center; + padding: var(--spacing-lg); +} + +.modal.active { + display: flex; + animation: fadeIn 0.2s ease; +} + +.modal-content { + background: var(--surface); + border-radius: var(--radius-xl); + max-width: 560px; + width: 100%; + max-height: 85vh; + overflow: hidden; + box-shadow: var(--shadow-lg); + animation: slideUp 0.3s ease; +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.modal-header { + padding: var(--spacing-lg); + border-bottom: 1px solid var(--border); + display: flex; + justify-content: space-between; + align-items: center; +} + +.modal-header h3 { + font-size: 1.125rem; + font-weight: 600; +} + +.modal-close { + background: transparent; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: var(--text-muted); + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius); + transition: all var(--transition); +} + +.modal-close:hover { + color: var(--text-primary); + background: var(--surface-hover); +} + +.modal-body { + padding: var(--spacing-lg); + overflow-y: auto; + max-height: calc(85vh - 140px); +} + +/* ============================================ + 下拉菜单 + ============================================ */ + +.dropdown { + position: relative; + display: inline-block; +} + +.dropdown-menu { + display: none; + position: absolute; + right: 0; + top: calc(100% + 4px); + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + min-width: 160px; + box-shadow: var(--shadow-lg); + z-index: 100; + overflow: hidden; + animation: fadeIn 0.15s ease; +} + +.dropdown-menu.active { + display: block; +} + +.dropdown-item { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-md); + color: var(--text-primary); + text-decoration: none; + font-size: 0.875rem; + transition: background var(--transition-fast); +} + +.dropdown-item:hover { + background: var(--surface-hover); +} + +.dropdown-item.danger { + color: var(--danger-color); +} + +.dropdown-item.danger:hover { + background: var(--danger-light); +} + +/* ============================================ + Toast 通知 + ============================================ */ + +.toast-container { + position: fixed; + top: var(--spacing-lg); + right: var(--spacing-lg); + z-index: 2000; + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.toast { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-md) var(--spacing-lg); + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-lg); + font-size: 0.875rem; + animation: slideIn 0.3s ease; + max-width: 400px; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateX(100%); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.toast.success { + border-left: 3px solid var(--success-color); +} + +.toast.error { + border-left: 3px solid var(--danger-color); +} + +.toast.warning { + border-left: 3px solid var(--warning-color); +} + +.toast.info { + border-left: 3px solid var(--info-color); +} + +/* ============================================ + 进度条 + ============================================ */ + +.progress-bar-container { + background: var(--border); + border-radius: var(--radius-full); + height: 8px; + overflow: hidden; + margin-bottom: var(--spacing-md); +} + +.progress-bar { + background: linear-gradient(90deg, var(--primary-color), var(--primary-dark)); + height: 100%; + border-radius: var(--radius-full); + transition: width 0.3s ease; + position: relative; +} + +.progress-bar.indeterminate { + background: linear-gradient(90deg, + transparent 0%, + var(--primary-color) 50%, + transparent 100%); + animation: indeterminate 1.5s infinite linear; +} + +@keyframes indeterminate { + from { transform: translateX(-100%); } + to { transform: translateX(100%); } +} + +/* ============================================ + 空状态 + ============================================ */ + +.empty-state { + text-align: center; + padding: var(--spacing-xl) var(--spacing-lg); + color: var(--text-secondary); +} + +.empty-state-icon { + font-size: 3rem; + margin-bottom: var(--spacing-md); + opacity: 0.5; +} + +.empty-state-title { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: var(--spacing-xs); +} + +.empty-state-description { + font-size: 0.875rem; + max-width: 300px; + margin: 0 auto; +} + +/* ============================================ + 骨架屏 + ============================================ */ + +.skeleton { + background: linear-gradient(90deg, + var(--border) 25%, + var(--surface-hover) 50%, + var(--border) 75%); + background-size: 200% 100%; + animation: skeleton-loading 1.5s infinite; + border-radius: var(--radius-sm); +} + +@keyframes skeleton-loading { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +.skeleton-text { + height: 16px; + margin-bottom: var(--spacing-sm); +} + +.skeleton-text:last-child { + width: 60%; +} + +/* ============================================ + 信息网格 + ============================================ */ + +.info-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--spacing-md); +} + +.info-item { + display: flex; + flex-direction: column; + gap: 2px; +} + +.info-item .label { + color: var(--text-secondary); + font-size: 0.8125rem; +} + +.info-item .value { + font-size: 1rem; + font-weight: 500; + word-break: break-all; +} + +/* ============================================ + 导入区域 + ============================================ */ + +.import-info { + background: var(--info-light); + border: 1px solid var(--info-color); + border-radius: var(--radius); + padding: var(--spacing-md); + margin-bottom: var(--spacing-md); + font-size: 0.875rem; +} + +.import-info p { + margin-bottom: var(--spacing-sm); +} + +.import-info ul { + margin: var(--spacing-sm) 0; + padding-left: var(--spacing-lg); +} + +.import-info li { + margin-bottom: var(--spacing-xs); +} + +.import-info code { + background: var(--surface); + padding: 2px 6px; + border-radius: var(--radius-sm); + font-family: var(--font-mono); + font-size: 0.8125rem; +} + +.import-stats { + display: flex; + gap: var(--spacing-lg); + padding: var(--spacing-md); + background: var(--success-light); + border-radius: var(--radius); +} + +.import-errors { + background: var(--danger-light); + border-radius: var(--radius); + padding: var(--spacing-md); + font-size: 0.8125rem; + color: var(--danger-color); +} + +/* ============================================ + 批量统计 + ============================================ */ + +.batch-stats { + display: flex; + justify-content: space-around; + gap: var(--spacing-lg); + text-align: center; +} + +.batch-stats span { + color: var(--text-secondary); + font-size: 0.875rem; +} + +.batch-stats strong { + display: block; + font-size: 1.5rem; + color: var(--text-primary); + margin-top: var(--spacing-xs); + font-weight: 700; +} + +/* ============================================ + 响应式设计 + ============================================ */ + +@media (max-width: 1024px) { + .stats-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 768px) { + .container { + padding: 0 var(--spacing-md); + } + + .navbar { + flex-direction: column; + gap: var(--spacing-md); + padding: var(--spacing-md) 0; + } + + .nav-links { + width: 100%; + justify-content: center; + } + + .toolbar { + flex-direction: column; + align-items: stretch; + } + + .toolbar-left, + .toolbar-right { + flex-direction: column; + align-items: stretch; + } + + .form-row { + grid-template-columns: 1fr; + } + + .stats-grid { + grid-template-columns: repeat(2, 1fr); + } + + .info-grid { + grid-template-columns: 1fr; + } + + .tabs { + flex-wrap: wrap; + } + + .tab-btn { + flex: auto; + } + + .batch-stats { + flex-direction: column; + gap: var(--spacing-md); + } + + .modal-content { + max-width: 100%; + margin: var(--spacing-md); + max-height: 90vh; + } +} + +@media (max-width: 480px) { + .stats-grid { + grid-template-columns: 1fr; + } + + .stat-card { + display: flex; + align-items: center; + justify-content: space-between; + } + + .stat-value { + font-size: 1.5rem; + } + + .btn { + width: 100%; + } + + .form-actions { + flex-direction: column; + } +} + +/* ============================================ + 滚动条样式 + ============================================ */ + +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} + +/* ============================================ + 选择样式 + ============================================ */ + +::selection { + background: var(--primary-light); + color: var(--primary-dark); +} + +/* ============================================ + 打印样式 + ============================================ */ + +@media print { + .navbar, + .btn, + .modal { + display: none !important; + } + + .card { + break-inside: avoid; + box-shadow: none; + border: 1px solid #ddd; + } +} diff --git a/static/js/accounts.js b/static/js/accounts.js new file mode 100644 index 0000000000000000000000000000000000000000..fe9848c31b078c724dbbcc5820ca146960a66e58 --- /dev/null +++ b/static/js/accounts.js @@ -0,0 +1,1251 @@ +/** + * 账号管理页面 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)} + ${getStatusIcon(account.status)} + +
+ ${account.cpa_uploaded + ? `✓` + : `-`} +
+ + +
+ ${account.subscription_type + ? `${account.subscription_type}` + : `-`} +
+ + ${format.date(account.last_refresh) || '-'} + +
+ + + +
+ + + `).join(''); + + // 绑定复选框事件 + elements.table.querySelectorAll('input[type="checkbox"][data-id]').forEach(cb => { + cb.addEventListener('change', (e) => { + const id = parseInt(e.target.dataset.id); + if (e.target.checked) { + selectedAccounts.add(id); + } else { + selectedAccounts.delete(id); + selectAllPages = false; + } + // 同步全选框状态 + const allChecked = elements.table.querySelectorAll('input[type="checkbox"][data-id]'); + const checkedCount = elements.table.querySelectorAll('input[type="checkbox"][data-id]:checked').length; + elements.selectAll.checked = allChecked.length > 0 && checkedCount === allChecked.length; + elements.selectAll.indeterminate = checkedCount > 0 && checkedCount < allChecked.length; + updateBatchButtons(); + renderSelectAllBanner(); + }); + }); + + // 绑定复制邮箱按钮 + elements.table.querySelectorAll('.copy-email-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + copyToClipboard(btn.dataset.email); + }); + }); + + // 绑定复制密码按钮 + elements.table.querySelectorAll('.copy-pwd-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + copyToClipboard(btn.dataset.pwd); + }); + }); + + // 渲染后同步全选框状态 + const allCbs = elements.table.querySelectorAll('input[type="checkbox"][data-id]'); + const checkedCbs = elements.table.querySelectorAll('input[type="checkbox"][data-id]:checked'); + elements.selectAll.checked = allCbs.length > 0 && checkedCbs.length === allCbs.length; + elements.selectAll.indeterminate = checkedCbs.length > 0 && checkedCbs.length < allCbs.length; + renderSelectAllBanner(); +} + +// 切换密码显示 +function togglePassword(element, password) { + if (element.dataset.revealed === 'true') { + element.textContent = password.substring(0, 4) + '****'; + element.classList.add('password-hidden'); + element.dataset.revealed = 'false'; + } else { + element.textContent = password; + element.classList.remove('password-hidden'); + element.dataset.revealed = 'true'; + } +} + +// 更新分页 +function updatePagination() { + const totalPages = Math.max(1, Math.ceil(totalAccounts / pageSize)); + + elements.prevPage.disabled = currentPage <= 1; + elements.nextPage.disabled = currentPage >= totalPages; + + elements.pageInfo.textContent = `第 ${currentPage} 页 / 共 ${totalPages} 页`; +} + +// 重置全选所有页状态 +function resetSelectAllPages() { + selectAllPages = false; + selectedAccounts.clear(); + updateBatchButtons(); + renderSelectAllBanner(); +} + +// 构建批量请求体(含 select_all 和筛选参数) +function buildBatchPayload(extraFields = {}) { + if (selectAllPages) { + return { + ids: [], + select_all: true, + status_filter: currentFilters.status || null, + email_service_filter: currentFilters.email_service || null, + search_filter: currentFilters.search || null, + ...extraFields + }; + } + return { ids: Array.from(selectedAccounts), ...extraFields }; +} + +// 获取有效选中数量(select_all 时用总数) +function getEffectiveCount() { + return selectAllPages ? totalAccounts : selectedAccounts.size; +} + +// 渲染全选横幅 +function renderSelectAllBanner() { + let banner = document.getElementById('select-all-banner'); + const totalPages = Math.ceil(totalAccounts / pageSize); + const currentPageSize = elements.table.querySelectorAll('input[type="checkbox"][data-id]').length; + const checkedOnPage = elements.table.querySelectorAll('input[type="checkbox"][data-id]:checked').length; + const allPageSelected = currentPageSize > 0 && checkedOnPage === currentPageSize; + + // 只在全选了当前页且有多页时显示横幅 + if (!allPageSelected || totalPages <= 1 || totalAccounts <= pageSize) { + if (banner) banner.remove(); + return; + } + + if (!banner) { + banner = document.createElement('div'); + banner.id = 'select-all-banner'; + banner.style.cssText = 'background:var(--primary-light,#e8f0fe);color:var(--primary-color,#1a73e8);padding:8px 16px;text-align:center;font-size:0.875rem;border-bottom:1px solid var(--border-color);'; + const tableContainer = document.querySelector('.table-container'); + if (tableContainer) tableContainer.insertAdjacentElement('beforebegin', banner); + } + + if (selectAllPages) { + banner.innerHTML = `已选中全部 ${totalAccounts} 条记录。`; + } else { + banner.innerHTML = `当前页已全选 ${checkedOnPage} 条。`; + } +} + +// 选中所有页 +function selectAllPagesAction() { + selectAllPages = true; + updateBatchButtons(); + renderSelectAllBanner(); +} + +// 更新批量操作按钮 +function updateBatchButtons() { + const count = getEffectiveCount(); + elements.batchDeleteBtn.disabled = count === 0; + elements.batchRefreshBtn.disabled = count === 0; + elements.batchValidateBtn.disabled = count === 0; + elements.batchUploadBtn.disabled = count === 0; + elements.batchCheckSubBtn.disabled = count === 0; + elements.exportBtn.disabled = count === 0; + + elements.batchDeleteBtn.textContent = count > 0 ? `🗑️ 删除 (${count})` : '🗑️ 批量删除'; + elements.batchRefreshBtn.textContent = count > 0 ? `🔄 刷新 (${count})` : '🔄 刷新Token'; + elements.batchValidateBtn.textContent = count > 0 ? `✅ 验证 (${count})` : '✅ 验证Token'; + elements.batchUploadBtn.textContent = count > 0 ? `☁️ 上传 (${count})` : '☁️ 上传'; + elements.batchCheckSubBtn.textContent = count > 0 ? `🔍 检测 (${count})` : '🔍 检测订阅'; +} + +// 刷新单个账号Token +async function refreshToken(id) { + 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 ? `` : ''} +
+
+
+ Cookies(支付用) +
+ + +
+
+
+
+ +
+ `; + + elements.detailModal.classList.add('active'); + } catch (error) { + toast.error('加载账号详情失败: ' + error.message); + } +} + +// 复制邮箱 +function copyEmail(email) { + copyToClipboard(email); +} + +// 删除账号 +async function deleteAccount(id, email) { + const confirmed = await confirm(`确定要删除账号 ${email} 吗?此操作不可恢复。`); + if (!confirmed) return; + + try { + await api.delete(`/accounts/${id}`); + toast.success('账号已删除'); + selectedAccounts.delete(id); + loadStats(); + loadAccounts(); + } catch (error) { + toast.error('删除失败: ' + error.message); + } +} + +// 批量删除 +async function handleBatchDelete() { + const count = getEffectiveCount(); + if (count === 0) return; + + const confirmed = await confirm(`确定要删除选中的 ${count} 个账号吗?此操作不可恢复。`); + if (!confirmed) return; + + try { + const result = await api.post('/accounts/batch-delete', buildBatchPayload()); + toast.success(`成功删除 ${result.deleted_count} 个账号`); + selectedAccounts.clear(); + selectAllPages = false; + loadStats(); + loadAccounts(); + } catch (error) { + toast.error('删除失败: ' + error.message); + } +} + +// 导出账号 +async function exportAccounts(format) { + const count = getEffectiveCount(); + if (count === 0) { + toast.warning('请先选择要导出的账号'); + return; + } + + toast.info(`正在导出 ${count} 个账号...`); + + try { + const response = await fetch('/api/accounts/export/' + format, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(buildBatchPayload()) + }); + + if (!response.ok) { + throw new Error(`导出失败: HTTP ${response.status}`); + } + + // 获取文件内容 + const blob = await response.blob(); + + // 从 Content-Disposition 获取文件名 + const disposition = response.headers.get('Content-Disposition'); + let filename = `accounts_${Date.now()}.${(format === 'cpa' || format === 'sub2api') ? 'json' : format}`; + if (disposition) { + const match = disposition.match(/filename=(.+)/); + if (match) { + filename = match[1]; + } + } + + // 创建下载链接 + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + a.remove(); + + toast.success('导出成功'); + } catch (error) { + console.error('导出失败:', error); + toast.error('导出失败: ' + error.message); + } +} + +// HTML 转义 +function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// ============== CPA 服务选择 ============== + +// 弹出 CPA 服务选择框,返回 Promise<{cpa_service_id: number|null}|null> +// null 表示用户取消,{cpa_service_id: null} 表示使用全局配置 +function selectCpaService() { + return new Promise(async (resolve) => { + const modal = document.getElementById('cpa-service-modal'); + const listEl = document.getElementById('cpa-service-list'); + const closeBtn = document.getElementById('close-cpa-modal'); + const cancelBtn = document.getElementById('cancel-cpa-modal-btn'); + const globalBtn = document.getElementById('cpa-use-global-btn'); + + // 加载服务列表 + listEl.innerHTML = '
加载中...
'; + modal.classList.add('active'); + + let services = []; + try { + services = await api.get('/cpa-services?enabled=true'); + } catch (e) { + services = []; + } + + if (services.length === 0) { + listEl.innerHTML = '
暂无已启用的 CPA 服务,将使用全局配置
'; + } else { + listEl.innerHTML = services.map(s => ` +
+
+
${escapeHtml(s.name)}
+
${escapeHtml(s.api_url)}
+
+ 选择 +
+ `).join(''); + + listEl.querySelectorAll('.cpa-service-item').forEach(item => { + item.addEventListener('mouseenter', () => item.style.background = 'var(--surface-hover)'); + item.addEventListener('mouseleave', () => item.style.background = ''); + item.addEventListener('click', () => { + cleanup(); + resolve({ cpa_service_id: parseInt(item.dataset.id) }); + }); + }); + } + + function cleanup() { + modal.classList.remove('active'); + closeBtn.removeEventListener('click', onCancel); + cancelBtn.removeEventListener('click', onCancel); + globalBtn.removeEventListener('click', onGlobal); + } + function onCancel() { cleanup(); resolve(null); } + function onGlobal() { cleanup(); resolve({ cpa_service_id: null }); } + + closeBtn.addEventListener('click', onCancel); + cancelBtn.addEventListener('click', onCancel); + globalBtn.addEventListener('click', onGlobal); + }); +} + +// 统一上传入口:弹出目标选择 +async function uploadAccount(id) { + const targets = [ + { label: '☁️ 上传到 CPA', value: 'cpa' }, + { label: '🔗 上传到 Sub2API', value: 'sub2api' }, + { label: '🚀 上传到 Team Manager', value: 'tm' }, + ]; + + const choice = await new Promise((resolve) => { + const modal = document.createElement('div'); + modal.className = 'modal active'; + modal.innerHTML = ` + `; + document.body.appendChild(modal); + modal.querySelector('#_upload-close').addEventListener('click', () => { modal.remove(); resolve(null); }); + modal.addEventListener('click', (e) => { if (e.target === modal) { modal.remove(); resolve(null); } }); + modal.querySelectorAll('button[data-val]').forEach(btn => { + btn.addEventListener('click', () => { modal.remove(); resolve(btn.dataset.val); }); + }); + }); + + if (!choice) return; + if (choice === 'cpa') return uploadToCpa(id); + if (choice === 'sub2api') return uploadToSub2Api(id); + if (choice === 'tm') return uploadToTm(id); +} + +// 上传单个账号到CPA +async function uploadToCpa(id) { + const choice = await selectCpaService(); + if (choice === null) return; // 用户取消 + + try { + toast.info('正在上传到CPA...'); + const payload = {}; + if (choice.cpa_service_id != null) payload.cpa_service_id = choice.cpa_service_id; + const result = await api.post(`/accounts/${id}/upload-cpa`, payload); + + if (result.success) { + toast.success('上传成功'); + loadAccounts(); + } else { + toast.error('上传失败: ' + (result.error || '未知错误')); + } + } catch (error) { + toast.error('上传失败: ' + error.message); + } +} + +// 批量上传到CPA +async function handleBatchUploadCpa() { + const count = getEffectiveCount(); + if (count === 0) return; + + const choice = await selectCpaService(); + if (choice === null) return; // 用户取消 + + const confirmed = await confirm(`确定要将选中的 ${count} 个账号上传到CPA吗?`); + if (!confirmed) return; + + elements.batchUploadBtn.disabled = true; + elements.batchUploadBtn.textContent = '上传中...'; + + try { + const payload = buildBatchPayload(); + if (choice.cpa_service_id != null) payload.cpa_service_id = choice.cpa_service_id; + const result = await api.post('/accounts/batch-upload-cpa', payload); + + let message = `成功: ${result.success_count}`; + if (result.failed_count > 0) message += `, 失败: ${result.failed_count}`; + if (result.skipped_count > 0) message += `, 跳过: ${result.skipped_count}`; + + toast.success(message); + loadAccounts(); + } catch (error) { + toast.error('批量上传失败: ' + error.message); + } finally { + updateBatchButtons(); + } +} + +// ============== 订阅状态 ============== + +// 手动标记订阅类型 +async function markSubscription(id) { + const type = prompt('请输入订阅类型 (plus / team / free):', 'plus'); + if (!type) return; + if (!['plus', 'team', 'free'].includes(type.trim().toLowerCase())) { + toast.error('无效的订阅类型,请输入 plus、team 或 free'); + return; + } + try { + await api.post(`/payment/accounts/${id}/mark-subscription`, { + subscription_type: type.trim().toLowerCase() + }); + toast.success('订阅状态已更新'); + loadAccounts(); + } catch (e) { + toast.error('标记失败: ' + e.message); + } +} + +// 批量检测订阅状态 +async function handleBatchCheckSubscription() { + const count = getEffectiveCount(); + if (count === 0) return; + const confirmed = await confirm(`确定要检测选中的 ${count} 个账号的订阅状态吗?`); + if (!confirmed) return; + + elements.batchCheckSubBtn.disabled = true; + elements.batchCheckSubBtn.textContent = '检测中...'; + + try { + const result = await api.post('/payment/accounts/batch-check-subscription', buildBatchPayload()); + let message = `成功: ${result.success_count}`; + if (result.failed_count > 0) message += `, 失败: ${result.failed_count}`; + toast.success(message); + loadAccounts(); + } catch (e) { + toast.error('批量检测失败: ' + e.message); + } finally { + updateBatchButtons(); + } +} + +// ============== Sub2API 上传 ============== + +// 弹出 Sub2API 服务选择框,返回 Promise<{service_id: number|null}|null> +// null 表示用户取消,{service_id: null} 表示自动选择 +function selectSub2ApiService() { + return new Promise(async (resolve) => { + const modal = document.getElementById('sub2api-service-modal'); + const listEl = document.getElementById('sub2api-service-list'); + const closeBtn = document.getElementById('close-sub2api-modal'); + const cancelBtn = document.getElementById('cancel-sub2api-modal-btn'); + const autoBtn = document.getElementById('sub2api-use-auto-btn'); + + listEl.innerHTML = '
加载中...
'; + modal.classList.add('active'); + + let services = []; + try { + services = await api.get('/sub2api-services?enabled=true'); + } catch (e) { + services = []; + } + + if (services.length === 0) { + listEl.innerHTML = '
暂无已启用的 Sub2API 服务,将自动选择第一个
'; + } else { + listEl.innerHTML = services.map(s => ` +
+
+
${escapeHtml(s.name)}
+
${escapeHtml(s.api_url)}
+
+ 选择 +
+ `).join(''); + + listEl.querySelectorAll('.sub2api-service-item').forEach(item => { + item.addEventListener('mouseenter', () => item.style.background = 'var(--surface-hover)'); + item.addEventListener('mouseleave', () => item.style.background = ''); + item.addEventListener('click', () => { + cleanup(); + resolve({ service_id: parseInt(item.dataset.id) }); + }); + }); + } + + function cleanup() { + modal.classList.remove('active'); + closeBtn.removeEventListener('click', onCancel); + cancelBtn.removeEventListener('click', onCancel); + autoBtn.removeEventListener('click', onAuto); + } + function onCancel() { cleanup(); resolve(null); } + function onAuto() { cleanup(); resolve({ service_id: null }); } + + closeBtn.addEventListener('click', onCancel); + cancelBtn.addEventListener('click', onCancel); + autoBtn.addEventListener('click', onAuto); + }); +} + +// 批量上传到 Sub2API +async function handleBatchUploadSub2Api() { + const count = getEffectiveCount(); + if (count === 0) return; + + const choice = await selectSub2ApiService(); + if (choice === null) return; // 用户取消 + + const confirmed = await confirm(`确定要将选中的 ${count} 个账号上传到 Sub2API 吗?`); + if (!confirmed) return; + + elements.batchUploadBtn.disabled = true; + elements.batchUploadBtn.textContent = '上传中...'; + + try { + const payload = buildBatchPayload(); + if (choice.service_id != null) payload.service_id = choice.service_id; + const result = await api.post('/accounts/batch-upload-sub2api', payload); + + let message = `成功: ${result.success_count}`; + if (result.failed_count > 0) message += `, 失败: ${result.failed_count}`; + if (result.skipped_count > 0) message += `, 跳过: ${result.skipped_count}`; + + toast.success(message); + loadAccounts(); + } catch (error) { + toast.error('批量上传失败: ' + error.message); + } finally { + updateBatchButtons(); + } +} + +// ============== Team Manager 上传 ============== + +// 上传单账号到 Sub2API +async function uploadToSub2Api(id) { + const choice = await selectSub2ApiService(); + if (choice === null) return; + try { + toast.info('正在上传到 Sub2API...'); + const payload = {}; + if (choice.service_id != null) payload.service_id = choice.service_id; + const result = await api.post(`/accounts/${id}/upload-sub2api`, payload); + if (result.success) { + toast.success('上传成功'); + loadAccounts(); + } else { + toast.error('上传失败: ' + (result.error || result.message || '未知错误')); + } + } catch (e) { + toast.error('上传失败: ' + e.message); + } +} + +// 弹出 Team Manager 服务选择框,返回 Promise<{service_id: number|null}|null> +// null 表示用户取消,{service_id: null} 表示自动选择 +function selectTmService() { + return new Promise(async (resolve) => { + const modal = document.getElementById('tm-service-modal'); + const listEl = document.getElementById('tm-service-list'); + const closeBtn = document.getElementById('close-tm-modal'); + const cancelBtn = document.getElementById('cancel-tm-modal-btn'); + const autoBtn = document.getElementById('tm-use-auto-btn'); + + listEl.innerHTML = '
加载中...
'; + modal.classList.add('active'); + + let services = []; + try { + services = await api.get('/tm-services?enabled=true'); + } catch (e) { + services = []; + } + + if (services.length === 0) { + listEl.innerHTML = '
暂无已启用的 Team Manager 服务,将自动选择第一个
'; + } else { + listEl.innerHTML = services.map(s => ` +
+
+
${escapeHtml(s.name)}
+
${escapeHtml(s.api_url)}
+
+ 选择 +
+ `).join(''); + + listEl.querySelectorAll('.tm-service-item').forEach(item => { + item.addEventListener('mouseenter', () => item.style.background = 'var(--surface-hover)'); + item.addEventListener('mouseleave', () => item.style.background = ''); + item.addEventListener('click', () => { + cleanup(); + resolve({ service_id: parseInt(item.dataset.id) }); + }); + }); + } + + function cleanup() { + modal.classList.remove('active'); + closeBtn.removeEventListener('click', onCancel); + cancelBtn.removeEventListener('click', onCancel); + autoBtn.removeEventListener('click', onAuto); + } + function onCancel() { cleanup(); resolve(null); } + function onAuto() { cleanup(); resolve({ service_id: null }); } + + closeBtn.addEventListener('click', onCancel); + cancelBtn.addEventListener('click', onCancel); + autoBtn.addEventListener('click', onAuto); + }); +} + +// 上传单账号到 Team Manager +async function uploadToTm(id) { + const choice = await selectTmService(); + if (choice === null) return; + try { + toast.info('正在上传到 Team Manager...'); + const payload = {}; + if (choice.service_id != null) payload.service_id = choice.service_id; + const result = await api.post(`/accounts/${id}/upload-tm`, payload); + if (result.success) { + toast.success('上传成功'); + } else { + toast.error('上传失败: ' + (result.message || '未知错误')); + } + } catch (e) { + toast.error('上传失败: ' + e.message); + } +} + +// 批量上传到 Team Manager +async function handleBatchUploadTm() { + const count = getEffectiveCount(); + if (count === 0) return; + + const choice = await selectTmService(); + if (choice === null) return; // 用户取消 + + const confirmed = await confirm(`确定要将选中的 ${count} 个账号上传到 Team Manager 吗?`); + if (!confirmed) return; + + elements.batchUploadBtn.disabled = true; + elements.batchUploadBtn.textContent = '上传中...'; + + try { + const payload = buildBatchPayload(); + if (choice.service_id != null) payload.service_id = choice.service_id; + const result = await api.post('/accounts/batch-upload-tm', payload); + let message = `成功: ${result.success_count}`; + if (result.failed_count > 0) message += `, 失败: ${result.failed_count}`; + if (result.skipped_count > 0) message += `, 跳过: ${result.skipped_count}`; + toast.success(message); + loadAccounts(); + } catch (e) { + toast.error('批量上传失败: ' + e.message); + } finally { + updateBatchButtons(); + } +} + +// 更多菜单切换 +function toggleMoreMenu(btn) { + const menu = btn.nextElementSibling; + const isActive = menu.classList.contains('active'); + // 关闭所有其他更多菜单 + document.querySelectorAll('.dropdown-menu.active').forEach(m => m.classList.remove('active')); + if (!isActive) menu.classList.add('active'); +} + +function closeMoreMenu(el) { + const menu = el.closest('.dropdown-menu'); + if (menu) menu.classList.remove('active'); +} + +// 保存账号 Cookies +async function saveCookies(id) { + const textarea = document.getElementById(`cookies-input-${id}`); + if (!textarea) return; + const cookiesValue = textarea.value.trim(); + try { + await api.patch(`/accounts/${id}`, { cookies: cookiesValue }); + toast.success('Cookies 已保存'); + } catch (e) { + toast.error('保存 Cookies 失败: ' + e.message); + } +} + +// 查询收件箱验证码 +async function checkInboxCode(id) { + toast.info('正在查询收件箱...'); + try { + const result = await api.post(`/accounts/${id}/inbox-code`); + if (result.success) { + showInboxCodeResult(result.code, result.email); + } else { + toast.error('查询失败: ' + (result.error || '未收到验证码')); + } + } catch (error) { + toast.error('查询失败: ' + error.message); + } +} + +function showInboxCodeResult(code, email) { + elements.modalBody.innerHTML = ` +
+
+ ${escapeHtml(email)} 最新验证码 +
+
+ ${escapeHtml(code)} +
+ +
+ `; + elements.detailModal.classList.add('active'); +} diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000000000000000000000000000000000000..433145ac1620ae6b08938391543400ffaa308cf5 --- /dev/null +++ b/static/js/app.js @@ -0,0 +1,1531 @@ +/** + * 注册页面 JavaScript + * 使用 utils.js 中的工具库 + */ + +// 状态 +let currentTask = null; +let currentBatch = null; +let logPollingInterval = null; +let batchPollingInterval = null; +let accountsPollingInterval = null; +let isBatchMode = false; +let isOutlookBatchMode = false; +let outlookAccounts = []; +let taskCompleted = false; // 标记任务是否已完成 +let batchCompleted = false; // 标记批量任务是否已完成 +let taskFinalStatus = null; // 保存任务的最终状态 +let batchFinalStatus = null; // 保存批量任务的最终状态 +let displayedLogs = new Set(); // 用于日志去重 +let toastShown = false; // 标记是否已显示过 toast +let availableServices = { + tempmail: { available: true, services: [] }, + outlook: { available: false, services: [] }, + moe_mail: { available: false, services: [] }, + temp_mail: { available: false, services: [] }, + duck_mail: { available: false, services: [] }, + freemail: { available: false, services: [] } +}; + +// WebSocket 相关变量 +let webSocket = null; +let batchWebSocket = null; // 批量任务 WebSocket +let useWebSocket = true; // 是否使用 WebSocket +let wsHeartbeatInterval = null; // 心跳定时器 +let batchWsHeartbeatInterval = null; // 批量任务心跳定时器 +let activeTaskUuid = null; // 当前活跃的单任务 UUID(用于页面重新可见时重连) +let activeBatchId = null; // 当前活跃的批量任务 ID(用于页面重新可见时重连) + +// DOM 元素 +const elements = { + form: document.getElementById('registration-form'), + emailService: document.getElementById('email-service'), + regMode: document.getElementById('reg-mode'), + regModeGroup: document.getElementById('reg-mode-group'), + batchCountGroup: document.getElementById('batch-count-group'), + batchCount: document.getElementById('batch-count'), + batchOptions: document.getElementById('batch-options'), + intervalMin: document.getElementById('interval-min'), + intervalMax: document.getElementById('interval-max'), + startBtn: document.getElementById('start-btn'), + cancelBtn: document.getElementById('cancel-btn'), + taskStatusRow: document.getElementById('task-status-row'), + batchProgressSection: document.getElementById('batch-progress-section'), + consoleLog: document.getElementById('console-log'), + clearLogBtn: document.getElementById('clear-log-btn'), + // 任务状态 + taskId: document.getElementById('task-id'), + taskEmail: document.getElementById('task-email'), + taskStatus: document.getElementById('task-status'), + taskService: document.getElementById('task-service'), + taskStatusBadge: document.getElementById('task-status-badge'), + // 批量状态 + batchProgressText: document.getElementById('batch-progress-text'), + batchProgressPercent: document.getElementById('batch-progress-percent'), + progressBar: document.getElementById('progress-bar'), + batchSuccess: document.getElementById('batch-success'), + batchFailed: document.getElementById('batch-failed'), + batchRemaining: document.getElementById('batch-remaining'), + // 已注册账号 + recentAccountsTable: document.getElementById('recent-accounts-table'), + refreshAccountsBtn: document.getElementById('refresh-accounts-btn'), + // Outlook 批量注册 + outlookBatchSection: document.getElementById('outlook-batch-section'), + outlookAccountsContainer: document.getElementById('outlook-accounts-container'), + outlookIntervalMin: document.getElementById('outlook-interval-min'), + outlookIntervalMax: document.getElementById('outlook-interval-max'), + outlookSkipRegistered: document.getElementById('outlook-skip-registered'), + outlookConcurrencyMode: document.getElementById('outlook-concurrency-mode'), + outlookConcurrencyCount: document.getElementById('outlook-concurrency-count'), + outlookConcurrencyHint: document.getElementById('outlook-concurrency-hint'), + outlookIntervalGroup: document.getElementById('outlook-interval-group'), + // 批量并发控件 + concurrencyMode: document.getElementById('concurrency-mode'), + concurrencyCount: document.getElementById('concurrency-count'), + concurrencyHint: document.getElementById('concurrency-hint'), + intervalGroup: document.getElementById('interval-group'), + // 注册后自动操作 + autoUploadCpa: document.getElementById('auto-upload-cpa'), + cpaServiceSelectGroup: document.getElementById('cpa-service-select-group'), + cpaServiceSelect: document.getElementById('cpa-service-select'), + autoUploadSub2api: document.getElementById('auto-upload-sub2api'), + sub2apiServiceSelectGroup: document.getElementById('sub2api-service-select-group'), + sub2apiServiceSelect: document.getElementById('sub2api-service-select'), + autoUploadTm: document.getElementById('auto-upload-tm'), + tmServiceSelectGroup: document.getElementById('tm-service-select-group'), + tmServiceSelect: document.getElementById('tm-service-select'), +}; + +// 初始化 +document.addEventListener('DOMContentLoaded', () => { + initEventListeners(); + loadAvailableServices(); + loadRecentAccounts(); + startAccountsPolling(); + initVisibilityReconnect(); + restoreActiveTask(); + initAutoUploadOptions(); +}); + +// 初始化注册后自动操作选项(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 (isTerminalTaskStatus(data.status)) { + // 保存最终状态,用于 onclose 判断 + taskFinalStatus = data.status; + taskCompleted = true; + if (currentTask) { + currentTask.status = data.status; + } + stopLogPolling(); + + // 断开 WebSocket(异步操作) + disconnectWebSocket(); + + // 任务完成后再重置按钮 + resetButtons(); + + // 只显示一次 toast + if (!toastShown) { + toastShown = true; + if (data.status === 'completed') { + addLog('success', '[成功] 注册成功!'); + toast.success('注册成功!'); + // 刷新账号列表 + loadRecentAccounts(); + } else if (data.status === 'failed') { + const errorDetail = data.error || data.message || ''; + addLog('error', errorDetail ? `[错误] 注册失败: ${errorDetail}` : '[错误] 注册失败'); + toast.error(errorDetail ? `注册失败: ${errorDetail}` : '注册失败'); + } else if (data.status === 'cancelled' || data.status === 'cancelling') { + const cancelMsg = data.message || '[警告] 任务已取消'; + addLog('warning', cancelMsg); + } + } + } + } 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); + // 切换到轮询 + stopWebSocketHeartbeat(); + const shouldPoll = !taskCompleted && taskFinalStatus === null; + if (shouldPoll) { + useWebSocket = false; + 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(10000, 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) { + if (logPollingInterval) { + return; + } + + let lastLogIndex = 0; + const pollOnce = 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 (isTerminalTaskStatus(data.status)) { + taskFinalStatus = data.status; + taskCompleted = true; + if (currentTask) { + currentTask.status = data.status; + } + stopLogPolling(); + resetButtons(); + + // 只显示一次 toast + if (!toastShown) { + toastShown = true; + if (data.status === 'completed') { + addLog('success', '[成功] 注册成功!'); + toast.success('注册成功!'); + // 刷新账号列表 + loadRecentAccounts(); + } else if (data.status === 'failed') { + const errorDetail = data.error || data.message || ''; + addLog('error', errorDetail ? `[错误] 注册失败: ${errorDetail}` : '[错误] 注册失败'); + toast.error(errorDetail ? `注册失败: ${errorDetail}` : '注册失败'); + } else if (data.status === 'cancelled' || data.status === 'cancelling') { + const cancelMsg = data.message || '[警告] 任务已取消'; + addLog('warning', cancelMsg); + } + } + } + } catch (error) { + console.error('轮询日志失败:', error); + } + }; + + logPollingInterval = setInterval(pollOnce, 1000); + pollOnce(); +} + +// 停止轮询日志 +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' }, + cancelling: { 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 isTerminalTaskStatus(status) { + return ['completed', 'failed', 'cancelled', 'cancelling'].includes(status); +} + +// 显示批量状态 +function showBatchStatus(batch) { + elements.batchProgressSection.style.display = 'block'; + elements.taskStatusRow.style.display = 'none'; + elements.taskStatusBadge.style.display = 'none'; + elements.batchProgressText.textContent = `0/${batch.count}`; + elements.batchProgressPercent.textContent = '0%'; + elements.progressBar.style.width = '0%'; + elements.batchSuccess.textContent = '0'; + elements.batchFailed.textContent = '0'; + elements.batchRemaining.textContent = batch.count; + + // 重置计数器 + elements.batchSuccess.dataset.last = '0'; + elements.batchFailed.dataset.last = '0'; +} + +// 更新批量进度 +function updateBatchProgress(data) { + const progress = ((data.completed / data.total) * 100).toFixed(0); + elements.batchProgressText.textContent = `${data.completed}/${data.total}`; + elements.batchProgressPercent.textContent = `${progress}%`; + elements.progressBar.style.width = `${progress}%`; + elements.batchSuccess.textContent = data.success; + elements.batchFailed.textContent = data.failed; + elements.batchRemaining.textContent = data.total - data.completed; + + // 记录日志(避免重复) + if (data.completed > 0) { + const lastSuccess = parseInt(elements.batchSuccess.dataset.last || '0'); + const lastFailed = parseInt(elements.batchFailed.dataset.last || '0'); + + if (data.success > lastSuccess) { + addLog('success', `[成功] 第 ${data.success} 个账号注册成功`); + } + if (data.failed > lastFailed) { + addLog('error', `[失败] 第 ${data.failed} 个账号注册失败`); + } + + elements.batchSuccess.dataset.last = data.success; + elements.batchFailed.dataset.last = data.failed; + } +} + +// 加载最近注册的账号 +async function loadRecentAccounts() { + try { + const data = await api.get('/accounts?page=1&page_size=10'); + + if (data.accounts.length === 0) { + elements.recentAccountsTable.innerHTML = ` + + +
+
📭
+
暂无已注册账号
+
+ + + `; + return; + } + + elements.recentAccountsTable.innerHTML = data.accounts.map(account => ` + + ${account.id} + + + ${escapeHtml(account.email)} + + + + + ${account.password + ? ` + ${escapeHtml(account.password.substring(0, 8))}... + + ` + : '-'} + + + ${getStatusIcon(account.status)} + + + `).join(''); + + // 绑定复制按钮事件 + elements.recentAccountsTable.querySelectorAll('.copy-email-btn').forEach(btn => { + btn.addEventListener('click', (e) => { e.stopPropagation(); copyToClipboard(btn.dataset.email); }); + }); + elements.recentAccountsTable.querySelectorAll('.copy-pwd-btn').forEach(btn => { + btn.addEventListener('click', (e) => { e.stopPropagation(); copyToClipboard(btn.dataset.pwd); }); + }); + + } catch (error) { + console.error('加载账号列表失败:', error); + } +} + +// 开始账号列表轮询 +function startAccountsPolling() { + // 每30秒刷新一次账号列表 + accountsPollingInterval = setInterval(() => { + loadRecentAccounts(); + }, 30000); +} + +// 添加日志 +function addLog(type, message) { + // 日志去重:使用消息内容的 hash 作为键 + const logKey = `${type}:${message}`; + if (displayedLogs.has(logKey)) { + return; // 已经显示过,跳过 + } + displayedLogs.add(logKey); + + // 限制去重集合大小,避免内存泄漏 + if (displayedLogs.size > 1000) { + // 清空一半的记录 + const keys = Array.from(displayedLogs); + keys.slice(0, 500).forEach(k => displayedLogs.delete(k)); + } + + const line = document.createElement('div'); + line.className = `log-line ${type}`; + + // 添加时间戳 + const timestamp = new Date().toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + + line.innerHTML = `[${timestamp}]${escapeHtml(message)}`; + elements.consoleLog.appendChild(line); + + // 自动滚动到底部 + elements.consoleLog.scrollTop = elements.consoleLog.scrollHeight; + + // 限制日志行数 + const lines = elements.consoleLog.querySelectorAll('.log-line'); + if (lines.length > 500) { + lines[0].remove(); + } +} + +// 获取日志类型 +function getLogType(log) { + if (typeof log !== 'string') return 'info'; + + const lowerLog = log.toLowerCase(); + if (lowerLog.includes('error') || lowerLog.includes('失败') || lowerLog.includes('错误')) { + return 'error'; + } + if (lowerLog.includes('warning') || lowerLog.includes('警告')) { + return 'warning'; + } + if (lowerLog.includes('success') || lowerLog.includes('成功') || lowerLog.includes('完成')) { + return 'success'; + } + return 'info'; +} + +// 重置按钮状态 +function resetButtons() { + elements.startBtn.disabled = false; + elements.cancelBtn.disabled = true; + currentTask = null; + currentBatch = null; + isBatchMode = false; + // 重置完成标志 + taskCompleted = false; + batchCompleted = false; + // 重置最终状态标志 + taskFinalStatus = null; + batchFinalStatus = null; + // 清除活跃任务标识 + activeTaskUuid = null; + activeBatchId = null; + // 清除 sessionStorage 持久化状态 + sessionStorage.removeItem('activeTask'); + // 断开 WebSocket + disconnectWebSocket(); + disconnectBatchWebSocket(); + // 注意:不重置 isOutlookBatchMode,因为用户可能想继续使用 Outlook 批量模式 +} + +// HTML 转义 +function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + + +// ============== Outlook 批量注册功能 ============== + +// 加载 Outlook 账户列表 +async function loadOutlookAccounts() { + try { + elements.outlookAccountsContainer.innerHTML = '
加载中...
'; + + const data = await api.get('/registration/outlook-accounts'); + outlookAccounts = data.accounts || []; + + renderOutlookAccountsList(); + + addLog('info', `[系统] 已加载 ${data.total} 个 Outlook 账户 (已注册: ${data.registered_count}, 未注册: ${data.unregistered_count})`); + + } catch (error) { + console.error('加载 Outlook 账户列表失败:', error); + elements.outlookAccountsContainer.innerHTML = `
加载失败: ${error.message}
`; + addLog('error', `[错误] 加载 Outlook 账户列表失败: ${error.message}`); + } +} + +// 渲染 Outlook 账户列表 +function renderOutlookAccountsList() { + if (outlookAccounts.length === 0) { + elements.outlookAccountsContainer.innerHTML = '
没有可用的 Outlook 账户
'; + return; + } + + const html = outlookAccounts.map(account => ` + + `).join(''); + + elements.outlookAccountsContainer.innerHTML = html; +} + +// 全选 +function selectAllOutlookAccounts() { + const checkboxes = document.querySelectorAll('.outlook-account-checkbox'); + checkboxes.forEach(cb => cb.checked = true); +} + +// 只选未注册 +function selectUnregisteredOutlook() { + const items = document.querySelectorAll('.outlook-account-item'); + items.forEach(item => { + const checkbox = item.querySelector('.outlook-account-checkbox'); + const isRegistered = item.dataset.registered === 'true'; + checkbox.checked = !isRegistered; + }); +} + +// 取消全选 +function deselectAllOutlookAccounts() { + const checkboxes = document.querySelectorAll('.outlook-account-checkbox'); + checkboxes.forEach(cb => cb.checked = false); +} + +// 处理 Outlook 批量注册 +async function handleOutlookBatchRegistration() { + // 重置批量任务状态 + batchCompleted = false; + batchFinalStatus = null; + displayedLogs.clear(); // 清空日志去重集合 + toastShown = false; // 重置 toast 标志 + + // 获取选中的账户 + const selectedIds = []; + document.querySelectorAll('.outlook-account-checkbox:checked').forEach(cb => { + selectedIds.push(parseInt(cb.value)); + }); + + if (selectedIds.length === 0) { + toast.error('请选择至少一个 Outlook 账户'); + return; + } + + const intervalMin = parseInt(elements.outlookIntervalMin.value) || 5; + const intervalMax = parseInt(elements.outlookIntervalMax.value) || 30; + const skipRegistered = elements.outlookSkipRegistered.checked; + const concurrency = parseInt(elements.outlookConcurrencyCount.value) || 3; + const mode = elements.outlookConcurrencyMode.value || 'pipeline'; + + // 禁用开始按钮 + elements.startBtn.disabled = true; + elements.cancelBtn.disabled = false; + + // 清空日志 + elements.consoleLog.innerHTML = ''; + + const requestData = { + service_ids: selectedIds, + skip_registered: skipRegistered, + interval_min: intervalMin, + interval_max: intervalMax, + concurrency: Math.min(10000, 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 0000000000000000000000000000000000000000..fafd85b4d9ba62564af337cc1cf86eeb46a967fa --- /dev/null +++ b/static/js/email_services.js @@ -0,0 +1,789 @@ +/** + * 邮箱服务页面 JavaScript + */ + +// 状态 +let outlookServices = []; +let customServices = []; // 合并 moe_mail + temp_mail + duck_mail + freemail + imap_mail +let selectedOutlook = new Set(); +let selectedCustom = new Set(); + +// DOM 元素 +const elements = { + // 统计 + outlookCount: document.getElementById('outlook-count'), + customCount: document.getElementById('custom-count'), + tempmailStatus: document.getElementById('tempmail-status'), + totalEnabled: document.getElementById('total-enabled'), + + // Outlook 导入 + toggleOutlookImport: document.getElementById('toggle-outlook-import'), + outlookImportBody: document.getElementById('outlook-import-body'), + outlookImportData: document.getElementById('outlook-import-data'), + outlookImportEnabled: document.getElementById('outlook-import-enabled'), + outlookImportPriority: document.getElementById('outlook-import-priority'), + outlookImportBtn: document.getElementById('outlook-import-btn'), + clearImportBtn: document.getElementById('clear-import-btn'), + importResult: document.getElementById('import-result'), + + // Outlook 列表 + outlookTable: document.getElementById('outlook-accounts-table'), + selectAllOutlook: document.getElementById('select-all-outlook'), + batchDeleteOutlookBtn: document.getElementById('batch-delete-outlook-btn'), + + // 自定义域名(合并) + customTable: document.getElementById('custom-services-table'), + addCustomBtn: document.getElementById('add-custom-btn'), + selectAllCustom: document.getElementById('select-all-custom'), + + // 临时邮箱 + tempmailForm: document.getElementById('tempmail-form'), + tempmailApi: document.getElementById('tempmail-api'), + tempmailEnabled: document.getElementById('tempmail-enabled'), + testTempmailBtn: document.getElementById('test-tempmail-btn'), + + // 添加自定义域名模态框 + addCustomModal: document.getElementById('add-custom-modal'), + addCustomForm: document.getElementById('add-custom-form'), + closeCustomModal: document.getElementById('close-custom-modal'), + cancelAddCustom: document.getElementById('cancel-add-custom'), + customSubType: document.getElementById('custom-sub-type'), + addMoemailFields: document.getElementById('add-moemail-fields'), + addTempmailFields: document.getElementById('add-tempmail-fields'), + addDuckmailFields: document.getElementById('add-duckmail-fields'), + addFreemailFields: document.getElementById('add-freemail-fields'), + addImapFields: document.getElementById('add-imap-fields'), + + // 编辑自定义域名模态框 + editCustomModal: document.getElementById('edit-custom-modal'), + editCustomForm: document.getElementById('edit-custom-form'), + closeEditCustomModal: document.getElementById('close-edit-custom-modal'), + cancelEditCustom: document.getElementById('cancel-edit-custom'), + editMoemailFields: document.getElementById('edit-moemail-fields'), + editTempmailFields: document.getElementById('edit-tempmail-fields'), + editDuckmailFields: document.getElementById('edit-duckmail-fields'), + editFreemailFields: document.getElementById('edit-freemail-fields'), + editImapFields: document.getElementById('edit-imap-fields'), + editCustomTypeBadge: document.getElementById('edit-custom-type-badge'), + editCustomSubTypeHidden: document.getElementById('edit-custom-sub-type-hidden'), + + // 编辑 Outlook 模态框 + editOutlookModal: document.getElementById('edit-outlook-modal'), + editOutlookForm: document.getElementById('edit-outlook-form'), + closeEditOutlookModal: document.getElementById('close-edit-outlook-modal'), + cancelEditOutlook: document.getElementById('cancel-edit-outlook'), +}; + +const CUSTOM_SUBTYPE_LABELS = { + moemail: '🔗 MoeMail(自定义域名 API)', + tempmail: '📮 TempMail(自部署 Cloudflare Worker)', + duckmail: '🦆 DuckMail(DuckMail API)', + freemail: 'Freemail(自部署 Cloudflare Worker)', + imap: '📧 IMAP 邮箱(Gmail/QQ/163等)' +}; + +// 初始化 +document.addEventListener('DOMContentLoaded', () => { + loadStats(); + loadOutlookServices(); + loadCustomServices(); + loadTempmailConfig(); + initEventListeners(); +}); + +// 事件监听 +function initEventListeners() { + // Outlook 导入展开/收起 + elements.toggleOutlookImport.addEventListener('click', () => { + const isHidden = elements.outlookImportBody.style.display === 'none'; + elements.outlookImportBody.style.display = isHidden ? 'block' : 'none'; + elements.toggleOutlookImport.textContent = isHidden ? '收起' : '展开'; + }); + + // Outlook 导入 + elements.outlookImportBtn.addEventListener('click', handleOutlookImport); + elements.clearImportBtn.addEventListener('click', () => { + elements.outlookImportData.value = ''; + elements.importResult.style.display = 'none'; + }); + + // Outlook 全选 + elements.selectAllOutlook.addEventListener('change', (e) => { + const checkboxes = elements.outlookTable.querySelectorAll('input[type="checkbox"][data-id]'); + checkboxes.forEach(cb => { + cb.checked = e.target.checked; + const id = parseInt(cb.dataset.id); + if (e.target.checked) selectedOutlook.add(id); + else selectedOutlook.delete(id); + }); + updateBatchButtons(); + }); + + // Outlook 批量删除 + elements.batchDeleteOutlookBtn.addEventListener('click', handleBatchDeleteOutlook); + + // 自定义域名全选 + elements.selectAllCustom.addEventListener('change', (e) => { + const checkboxes = elements.customTable.querySelectorAll('input[type="checkbox"][data-id]'); + checkboxes.forEach(cb => { + cb.checked = e.target.checked; + const id = parseInt(cb.dataset.id); + if (e.target.checked) selectedCustom.add(id); + else selectedCustom.delete(id); + }); + }); + + // 添加自定义域名 + elements.addCustomBtn.addEventListener('click', () => { + elements.addCustomForm.reset(); + switchAddSubType('moemail'); + elements.addCustomModal.classList.add('active'); + }); + elements.closeCustomModal.addEventListener('click', () => elements.addCustomModal.classList.remove('active')); + elements.cancelAddCustom.addEventListener('click', () => elements.addCustomModal.classList.remove('active')); + elements.addCustomForm.addEventListener('submit', handleAddCustom); + + // 类型切换(添加表单) + elements.customSubType.addEventListener('change', (e) => switchAddSubType(e.target.value)); + + // 编辑自定义域名 + elements.closeEditCustomModal.addEventListener('click', () => elements.editCustomModal.classList.remove('active')); + elements.cancelEditCustom.addEventListener('click', () => elements.editCustomModal.classList.remove('active')); + elements.editCustomForm.addEventListener('submit', handleEditCustom); + + // 编辑 Outlook + elements.closeEditOutlookModal.addEventListener('click', () => elements.editOutlookModal.classList.remove('active')); + elements.cancelEditOutlook.addEventListener('click', () => elements.editOutlookModal.classList.remove('active')); + elements.editOutlookForm.addEventListener('submit', handleEditOutlook); + + // 临时邮箱配置 + elements.tempmailForm.addEventListener('submit', handleSaveTempmail); + elements.testTempmailBtn.addEventListener('click', handleTestTempmail); + + // 点击其他地方关闭更多菜单 + document.addEventListener('click', () => { + document.querySelectorAll('.dropdown-menu.active').forEach(m => m.classList.remove('active')); + }); +} + +function toggleEmailMoreMenu(btn) { + const menu = btn.nextElementSibling; + const isActive = menu.classList.contains('active'); + document.querySelectorAll('.dropdown-menu.active').forEach(m => m.classList.remove('active')); + if (!isActive) menu.classList.add('active'); +} + +function closeEmailMoreMenu(el) { + const menu = el.closest('.dropdown-menu'); + if (menu) menu.classList.remove('active'); +} + +// 切换添加表单子类型 +function switchAddSubType(subType) { + elements.customSubType.value = subType; + elements.addMoemailFields.style.display = subType === 'moemail' ? '' : 'none'; + elements.addTempmailFields.style.display = subType === 'tempmail' ? '' : 'none'; + elements.addDuckmailFields.style.display = subType === 'duckmail' ? '' : 'none'; + elements.addFreemailFields.style.display = subType === 'freemail' ? '' : 'none'; + elements.addImapFields.style.display = subType === 'imap' ? '' : 'none'; +} + +// 切换编辑表单子类型显示 +function switchEditSubType(subType) { + elements.editCustomSubTypeHidden.value = subType; + elements.editMoemailFields.style.display = subType === 'moemail' ? '' : 'none'; + elements.editTempmailFields.style.display = subType === 'tempmail' ? '' : 'none'; + elements.editDuckmailFields.style.display = subType === 'duckmail' ? '' : 'none'; + elements.editFreemailFields.style.display = subType === 'freemail' ? '' : 'none'; + elements.editImapFields.style.display = subType === 'imap' ? '' : 'none'; + elements.editCustomTypeBadge.textContent = CUSTOM_SUBTYPE_LABELS[subType] || CUSTOM_SUBTYPE_LABELS.moemail; +} + +// 加载统计信息 +async function loadStats() { + try { + const data = await api.get('/email-services/stats'); + elements.outlookCount.textContent = data.outlook_count || 0; + elements.customCount.textContent = (data.custom_count || 0) + (data.temp_mail_count || 0) + (data.duck_mail_count || 0) + (data.freemail_count || 0) + (data.imap_mail_count || 0); + elements.tempmailStatus.textContent = data.tempmail_available ? '可用' : '不可用'; + elements.totalEnabled.textContent = data.enabled_count || 0; + } catch (error) { + console.error('加载统计信息失败:', error); + } +} + +// 加载 Outlook 服务 +async function loadOutlookServices() { + try { + const data = await api.get('/email-services?service_type=outlook'); + outlookServices = data.services || []; + + if (outlookServices.length === 0) { + elements.outlookTable.innerHTML = ` + + +
+
📭
+
暂无 Outlook 账户
+
请使用上方导入功能添加账户
+
+ + + `; + return; + } + + elements.outlookTable.innerHTML = outlookServices.map(service => ` + + + ${escapeHtml(service.config?.email || service.name)} + + + ${service.config?.has_oauth ? 'OAuth' : '密码'} + + + ${service.enabled ? '✅' : '⭕'} + ${service.priority} + ${format.date(service.last_used)} + +
+ + + +
+ + + `).join(''); + + elements.outlookTable.querySelectorAll('input[type="checkbox"][data-id]').forEach(cb => { + cb.addEventListener('change', (e) => { + const id = parseInt(e.target.dataset.id); + if (e.target.checked) selectedOutlook.add(id); + else selectedOutlook.delete(id); + updateBatchButtons(); + }); + }); + + } catch (error) { + console.error('加载 Outlook 服务失败:', error); + elements.outlookTable.innerHTML = `
❌
加载失败
`; + } +} + +function getCustomServiceTypeBadge(subType) { + if (subType === 'moemail') { + return 'MoeMail'; + } + if (subType === 'tempmail') { + return 'TempMail'; + } + if (subType === 'duckmail') { + return 'DuckMail'; + } + if (subType === 'freemail') { + return 'Freemail'; + } + return 'IMAP'; +} + +function getCustomServiceAddress(service) { + if (service._subType === 'imap') { + const host = service.config?.host || '-'; + const emailAddr = service.config?.email || ''; + return `${escapeHtml(host)}
${escapeHtml(emailAddr)}
`; + } + const baseUrl = service.config?.base_url || '-'; + const domain = service.config?.default_domain || service.config?.domain; + if (!domain) { + return escapeHtml(baseUrl); + } + return `${escapeHtml(baseUrl)}
默认域名:@${escapeHtml(domain)}
`; +} + +// 加载自定义邮箱服务(moe_mail + temp_mail + duck_mail + freemail 合并) +async function loadCustomServices() { + try { + const [r1, r2, r3, r4, r5] = await Promise.all([ + api.get('/email-services?service_type=moe_mail'), + api.get('/email-services?service_type=temp_mail'), + api.get('/email-services?service_type=duck_mail'), + api.get('/email-services?service_type=freemail'), + api.get('/email-services?service_type=imap_mail') + ]); + customServices = [ + ...(r1.services || []).map(s => ({ ...s, _subType: 'moemail' })), + ...(r2.services || []).map(s => ({ ...s, _subType: 'tempmail' })), + ...(r3.services || []).map(s => ({ ...s, _subType: 'duckmail' })), + ...(r4.services || []).map(s => ({ ...s, _subType: 'freemail' })), + ...(r5.services || []).map(s => ({ ...s, _subType: 'imap' })) + ]; + + if (customServices.length === 0) { + elements.customTable.innerHTML = ` + + +
+
📭
+
暂无自定义邮箱服务
+
点击「添加服务」按钮创建新服务
+
+ + + `; + return; + } + + elements.customTable.innerHTML = customServices.map(service => { + return ` + + + ${escapeHtml(service.name)} + ${getCustomServiceTypeBadge(service._subType)} + ${getCustomServiceAddress(service)} + ${service.enabled ? '✅' : '⭕'} + ${service.priority} + ${format.date(service.last_used)} + +
+ + + +
+ + `; + }).join(''); + + elements.customTable.querySelectorAll('input[type="checkbox"][data-id]').forEach(cb => { + cb.addEventListener('change', (e) => { + const id = parseInt(e.target.dataset.id); + if (e.target.checked) selectedCustom.add(id); + else selectedCustom.delete(id); + }); + }); + + } catch (error) { + console.error('加载自定义邮箱服务失败:', error); + } +} + +// 加载临时邮箱配置 +async function loadTempmailConfig() { + try { + const settings = await api.get('/settings'); + if (settings.tempmail) { + elements.tempmailApi.value = settings.tempmail.api_url || ''; + elements.tempmailEnabled.checked = settings.tempmail.enabled !== false; + } + } catch (error) { + // 忽略错误 + } +} + +// Outlook 导入 +async function handleOutlookImport() { + const data = elements.outlookImportData.value.trim(); + if (!data) { toast.error('请输入导入数据'); return; } + + elements.outlookImportBtn.disabled = true; + elements.outlookImportBtn.textContent = '导入中...'; + + try { + const result = await api.post('/email-services/outlook/batch-import', { + data: data, + enabled: elements.outlookImportEnabled.checked, + priority: parseInt(elements.outlookImportPriority.value) || 0 + }); + + elements.importResult.style.display = 'block'; + elements.importResult.innerHTML = ` +
+ ✅ 成功导入: ${result.success || 0} + ❌ 失败: ${result.failed || 0} +
+ ${result.errors?.length ? `
错误详情:
    ${result.errors.map(e => `
  • ${escapeHtml(e)}
  • `).join('')}
` : ''} + `; + + if (result.success > 0) { + toast.success(`成功导入 ${result.success} 个账户`); + loadOutlookServices(); + loadStats(); + elements.outlookImportData.value = ''; + } + } catch (error) { + toast.error('导入失败: ' + error.message); + } finally { + elements.outlookImportBtn.disabled = false; + elements.outlookImportBtn.textContent = '📥 开始导入'; + } +} + +// 添加自定义邮箱服务(根据子类型区分) +async function handleAddCustom(e) { + e.preventDefault(); + const formData = new FormData(e.target); + const subType = formData.get('sub_type'); + + let serviceType, config; + if (subType === 'moemail') { + serviceType = 'moe_mail'; + config = { + base_url: formData.get('api_url'), + api_key: formData.get('api_key'), + default_domain: formData.get('domain') + }; + } else if (subType === 'tempmail') { + serviceType = 'temp_mail'; + config = { + base_url: formData.get('tm_base_url'), + admin_password: formData.get('tm_admin_password'), + domain: formData.get('tm_domain'), + enable_prefix: true + }; + } else if (subType === 'duckmail') { + serviceType = 'duck_mail'; + config = { + base_url: formData.get('dm_base_url'), + api_key: formData.get('dm_api_key'), + default_domain: formData.get('dm_domain'), + password_length: parseInt(formData.get('dm_password_length'), 10) || 12 + }; + } else if (subType === 'freemail') { + serviceType = 'freemail'; + config = { + base_url: formData.get('fm_base_url'), + admin_token: formData.get('fm_admin_token'), + domain: formData.get('fm_domain') + }; + } else { + serviceType = 'imap_mail'; + config = { + host: formData.get('imap_host'), + port: parseInt(formData.get('imap_port'), 10) || 993, + use_ssl: formData.get('imap_use_ssl') !== 'false', + email: formData.get('imap_email'), + password: formData.get('imap_password') + }; + } + + const data = { + service_type: serviceType, + name: formData.get('name'), + config, + enabled: formData.get('enabled') === 'on', + priority: parseInt(formData.get('priority')) || 0 + }; + + try { + await api.post('/email-services', data); + toast.success('服务添加成功'); + elements.addCustomModal.classList.remove('active'); + e.target.reset(); + loadCustomServices(); + loadStats(); + } catch (error) { + toast.error('添加失败: ' + error.message); + } +} + +// 切换服务状态 +async function toggleService(id, enabled) { + try { + await api.patch(`/email-services/${id}`, { enabled }); + toast.success(enabled ? '已启用' : '已禁用'); + loadOutlookServices(); + loadCustomServices(); + loadStats(); + } catch (error) { + toast.error('操作失败: ' + error.message); + } +} + +// 测试服务 +async function testService(id) { + try { + const result = await api.post(`/email-services/${id}/test`); + if (result.success) toast.success('测试成功'); + else toast.error('测试失败: ' + (result.error || '未知错误')); + } catch (error) { + toast.error('测试失败: ' + error.message); + } +} + +// 删除服务 +async function deleteService(id, name) { + const confirmed = await confirm(`确定要删除 "${name}" 吗?`); + if (!confirmed) return; + try { + await api.delete(`/email-services/${id}`); + toast.success('已删除'); + selectedOutlook.delete(id); + selectedCustom.delete(id); + loadOutlookServices(); + loadCustomServices(); + loadStats(); + } catch (error) { + toast.error('删除失败: ' + error.message); + } +} + +// 批量删除 Outlook +async function handleBatchDeleteOutlook() { + if (selectedOutlook.size === 0) return; + const confirmed = await confirm(`确定要删除选中的 ${selectedOutlook.size} 个账户吗?`); + if (!confirmed) return; + try { + const result = await api.request('/email-services/outlook/batch', { + method: 'DELETE', + body: Array.from(selectedOutlook) + }); + toast.success(`成功删除 ${result.deleted || selectedOutlook.size} 个账户`); + selectedOutlook.clear(); + loadOutlookServices(); + loadStats(); + } catch (error) { + toast.error('删除失败: ' + error.message); + } +} + +// 保存临时邮箱配置 +async function handleSaveTempmail(e) { + e.preventDefault(); + try { + await api.post('/settings/tempmail', { + api_url: elements.tempmailApi.value, + enabled: elements.tempmailEnabled.checked + }); + toast.success('配置已保存'); + } catch (error) { + toast.error('保存失败: ' + error.message); + } +} + +// 测试临时邮箱 +async function handleTestTempmail() { + elements.testTempmailBtn.disabled = true; + elements.testTempmailBtn.textContent = '测试中...'; + try { + const result = await api.post('/email-services/test-tempmail', { + api_url: elements.tempmailApi.value + }); + if (result.success) toast.success('临时邮箱连接正常'); + else toast.error('连接失败: ' + (result.error || '未知错误')); + } catch (error) { + toast.error('测试失败: ' + error.message); + } finally { + elements.testTempmailBtn.disabled = false; + elements.testTempmailBtn.textContent = '🔌 测试连接'; + } +} + +// 更新批量按钮 +function updateBatchButtons() { + const count = selectedOutlook.size; + elements.batchDeleteOutlookBtn.disabled = count === 0; + elements.batchDeleteOutlookBtn.textContent = count > 0 ? `🗑️ 删除选中 (${count})` : '🗑️ 批量删除'; +} + +// HTML 转义 +function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// ============== 编辑功能 ============== + +// 编辑自定义邮箱服务(支持 moemail / tempmail / duckmail) +async function editCustomService(id, subType) { + try { + const service = await api.get(`/email-services/${id}/full`); + const resolvedSubType = subType || ( + service.service_type === 'temp_mail' + ? 'tempmail' + : service.service_type === 'duck_mail' + ? 'duckmail' + : service.service_type === 'freemail' + ? 'freemail' + : service.service_type === 'imap_mail' + ? 'imap' + : 'moemail' + ); + + document.getElementById('edit-custom-id').value = service.id; + document.getElementById('edit-custom-name').value = service.name || ''; + document.getElementById('edit-custom-priority').value = service.priority || 0; + document.getElementById('edit-custom-enabled').checked = service.enabled; + + switchEditSubType(resolvedSubType); + + if (resolvedSubType === 'moemail') { + document.getElementById('edit-custom-api-url').value = service.config?.base_url || ''; + document.getElementById('edit-custom-api-key').value = ''; + document.getElementById('edit-custom-api-key').placeholder = service.config?.api_key ? '已设置,留空保持不变' : 'API Key'; + document.getElementById('edit-custom-domain').value = service.config?.default_domain || service.config?.domain || ''; + } else if (resolvedSubType === 'tempmail') { + document.getElementById('edit-tm-base-url').value = service.config?.base_url || ''; + document.getElementById('edit-tm-admin-password').value = ''; + document.getElementById('edit-tm-admin-password').placeholder = service.config?.admin_password ? '已设置,留空保持不变' : '请输入 Admin 密码'; + document.getElementById('edit-tm-domain').value = service.config?.domain || ''; + } else if (resolvedSubType === 'duckmail') { + document.getElementById('edit-dm-base-url').value = service.config?.base_url || ''; + document.getElementById('edit-dm-api-key').value = ''; + document.getElementById('edit-dm-api-key').placeholder = service.config?.api_key ? '已设置,留空保持不变' : '请输入 API Key(可选)'; + document.getElementById('edit-dm-domain').value = service.config?.default_domain || ''; + document.getElementById('edit-dm-password-length').value = service.config?.password_length || 12; + } else if (resolvedSubType === 'freemail') { + document.getElementById('edit-fm-base-url').value = service.config?.base_url || ''; + document.getElementById('edit-fm-admin-token').value = ''; + document.getElementById('edit-fm-admin-token').placeholder = service.config?.admin_token ? '已设置,留空保持不变' : '请输入 Admin Token'; + document.getElementById('edit-fm-domain').value = service.config?.domain || ''; + } else { + document.getElementById('edit-imap-host').value = service.config?.host || ''; + document.getElementById('edit-imap-port').value = service.config?.port || 993; + document.getElementById('edit-imap-use-ssl').value = service.config?.use_ssl !== false ? 'true' : 'false'; + document.getElementById('edit-imap-email').value = service.config?.email || ''; + document.getElementById('edit-imap-password').value = ''; + document.getElementById('edit-imap-password').placeholder = service.config?.password ? '已设置,留空保持不变' : '请输入密码/授权码'; + } + + elements.editCustomModal.classList.add('active'); + } catch (error) { + toast.error('获取服务信息失败: ' + error.message); + } +} + +// 保存编辑自定义邮箱服务 +async function handleEditCustom(e) { + e.preventDefault(); + const id = document.getElementById('edit-custom-id').value; + const formData = new FormData(e.target); + const subType = formData.get('sub_type'); + + let config; + if (subType === 'moemail') { + config = { + base_url: formData.get('api_url'), + default_domain: formData.get('domain') + }; + const apiKey = formData.get('api_key'); + if (apiKey && apiKey.trim()) config.api_key = apiKey.trim(); + } else if (subType === 'tempmail') { + config = { + base_url: formData.get('tm_base_url'), + domain: formData.get('tm_domain'), + enable_prefix: true + }; + const pwd = formData.get('tm_admin_password'); + if (pwd && pwd.trim()) config.admin_password = pwd.trim(); + } else if (subType === 'duckmail') { + config = { + base_url: formData.get('dm_base_url'), + default_domain: formData.get('dm_domain'), + password_length: parseInt(formData.get('dm_password_length'), 10) || 12 + }; + const apiKey = formData.get('dm_api_key'); + if (apiKey && apiKey.trim()) config.api_key = apiKey.trim(); + } else if (subType === 'freemail') { + config = { + base_url: formData.get('fm_base_url'), + domain: formData.get('fm_domain') + }; + const token = formData.get('fm_admin_token'); + if (token && token.trim()) config.admin_token = token.trim(); + } else { + config = { + host: formData.get('imap_host'), + port: parseInt(formData.get('imap_port'), 10) || 993, + use_ssl: formData.get('imap_use_ssl') !== 'false', + email: formData.get('imap_email') + }; + const pwd = formData.get('imap_password'); + if (pwd && pwd.trim()) config.password = pwd.trim(); + } + + const updateData = { + name: formData.get('name'), + priority: parseInt(formData.get('priority')) || 0, + enabled: formData.get('enabled') === 'on', + config + }; + + try { + await api.patch(`/email-services/${id}`, updateData); + toast.success('服务更新成功'); + elements.editCustomModal.classList.remove('active'); + loadCustomServices(); + loadStats(); + } catch (error) { + toast.error('更新失败: ' + error.message); + } +} + +// 编辑 Outlook 服务 +async function editOutlookService(id) { + try { + const service = await api.get(`/email-services/${id}/full`); + document.getElementById('edit-outlook-id').value = service.id; + document.getElementById('edit-outlook-email').value = service.config?.email || service.name || ''; + document.getElementById('edit-outlook-password').value = ''; + document.getElementById('edit-outlook-password').placeholder = service.config?.password ? '已设置,留空保持不变' : '请输入密码'; + document.getElementById('edit-outlook-client-id').value = service.config?.client_id || ''; + document.getElementById('edit-outlook-refresh-token').value = ''; + document.getElementById('edit-outlook-refresh-token').placeholder = service.config?.refresh_token ? '已设置,留空保持不变' : 'OAuth Refresh Token'; + document.getElementById('edit-outlook-priority').value = service.priority || 0; + document.getElementById('edit-outlook-enabled').checked = service.enabled; + elements.editOutlookModal.classList.add('active'); + } catch (error) { + toast.error('获取服务信息失败: ' + error.message); + } +} + +// 保存编辑 Outlook 服务 +async function handleEditOutlook(e) { + e.preventDefault(); + const id = document.getElementById('edit-outlook-id').value; + const formData = new FormData(e.target); + + let currentService; + try { + currentService = await api.get(`/email-services/${id}/full`); + } catch (error) { + toast.error('获取服务信息失败'); + return; + } + + const updateData = { + name: formData.get('email'), + priority: parseInt(formData.get('priority')) || 0, + enabled: formData.get('enabled') === 'on', + config: { + email: formData.get('email'), + password: formData.get('password')?.trim() || currentService.config?.password || '', + client_id: formData.get('client_id')?.trim() || currentService.config?.client_id || '', + refresh_token: formData.get('refresh_token')?.trim() || currentService.config?.refresh_token || '' + } + }; + + try { + await api.patch(`/email-services/${id}`, updateData); + toast.success('账户更新成功'); + elements.editOutlookModal.classList.remove('active'); + loadOutlookServices(); + loadStats(); + } catch (error) { + toast.error('更新失败: ' + error.message); + } +} diff --git a/static/js/payment.js b/static/js/payment.js new file mode 100644 index 0000000000000000000000000000000000000000..c1703879d23ac3de7b83978b2df2efba3b4c361e --- /dev/null +++ b/static/js/payment.js @@ -0,0 +1,146 @@ +/** + * 支付页面 JavaScript + */ + +const COUNTRY_CURRENCY_MAP = { + SG: 'SGD', US: 'USD', TR: 'TRY', JP: 'JPY', + HK: 'HKD', GB: 'GBP', EU: 'EUR', AU: 'AUD', + CA: 'CAD', IN: 'INR', BR: 'BRL', MX: 'MXN', +}; + +let selectedPlan = 'plus'; +let generatedLink = ''; + +// 初始化 +document.addEventListener('DOMContentLoaded', () => { + loadAccounts(); +}); + +// 加载账号列表 +async function loadAccounts() { + try { + const resp = await fetch('/api/accounts?page=1&page_size=100&status=active'); + const data = await resp.json(); + const sel = document.getElementById('account-select'); + sel.innerHTML = ''; + (data.accounts || []).forEach(acc => { + const opt = document.createElement('option'); + opt.value = acc.id; + opt.textContent = acc.email; + sel.appendChild(opt); + }); + } catch (e) { + console.error('加载账号失败:', e); + } +} + +// 国家切换 +function onCountryChange() { + const country = document.getElementById('country-select').value; + const currency = COUNTRY_CURRENCY_MAP[country] || 'USD'; + document.getElementById('currency-display').value = currency; +} + +// 选择套餐 +function selectPlan(plan) { + selectedPlan = plan; + document.getElementById('plan-plus').classList.toggle('selected', plan === 'plus'); + document.getElementById('plan-team').classList.toggle('selected', plan === 'team'); + document.getElementById('team-options').classList.toggle('show', plan === 'team'); + // 隐藏已生成的链接 + document.getElementById('link-box').classList.remove('show'); + generatedLink = ''; +} + +// 生成支付链接 +async function generateLink() { + const accountId = document.getElementById('account-select').value; + if (!accountId) { + ui.showToast('请先选择账号', 'warning'); + return; + } + + const country = document.getElementById('country-select').value || 'SG'; + + const body = { + account_id: parseInt(accountId), + plan_type: selectedPlan, + country: country, + }; + + if (selectedPlan === 'team') { + body.workspace_name = document.getElementById('workspace-name').value || 'MyTeam'; + body.seat_quantity = parseInt(document.getElementById('seat-quantity').value) || 5; + body.price_interval = document.getElementById('price-interval').value; + } + + const btn = document.querySelector('.form-actions .btn-primary'); + if (btn) { btn.disabled = true; btn.textContent = '生成中...'; } + + try { + const resp = await fetch('/api/payment/generate-link', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await resp.json(); + if (data.success && data.link) { + generatedLink = data.link; + document.getElementById('link-text').value = data.link; + document.getElementById('link-box').classList.add('show'); + document.getElementById('open-status').textContent = ''; + ui.showToast('支付链接生成成功', 'success'); + } else { + ui.showToast(data.detail || '生成链接失败', 'error'); + } + } catch (e) { + ui.showToast('请求失败: ' + e.message, 'error'); + } finally { + if (btn) { btn.disabled = false; btn.textContent = '生成支付链接'; } + } +} + +// 复制链接 +function copyLink() { + if (!generatedLink) return; + navigator.clipboard.writeText(generatedLink).then(() => { + ui.showToast('已复制到剪贴板', 'success'); + }).catch(() => { + const ta = document.getElementById('link-text'); + ta.select(); + document.execCommand('copy'); + ui.showToast('已复制到剪贴板', 'success'); + }); +} + +// 无痕打开浏览器(携带账号 cookie) +async function openIncognito() { + if (!generatedLink) { + ui.showToast('请先生成链接', 'warning'); + return; + } + const accountId = document.getElementById('account-select').value; + const statusEl = document.getElementById('open-status'); + statusEl.textContent = '正在打开...'; + try { + const body = { url: generatedLink }; + if (accountId) body.account_id = parseInt(accountId); + + const resp = await fetch('/api/payment/open-incognito', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await resp.json(); + if (data.success) { + statusEl.textContent = '已在无痕模式打开浏览器'; + ui.showToast('无痕浏览器已打开', 'success'); + } else { + statusEl.textContent = data.message || '未找到可用浏览器,请手动复制链接'; + ui.showToast(data.message || '未找到浏览器', 'warning'); + } + } catch (e) { + statusEl.textContent = '请求失败: ' + e.message; + ui.showToast('请求失败', 'error'); + } +} diff --git a/static/js/settings.js b/static/js/settings.js new file mode 100644 index 0000000000000000000000000000000000000000..595f03813f2271dd03cddb3875987540ad265a88 --- /dev/null +++ b/static/js/settings.js @@ -0,0 +1,1541 @@ +/** + * 设置页面 JavaScript + * 使用 utils.js 中的工具库 + */ + +// DOM 元素 +const elements = { + tabs: document.querySelectorAll('.tab-btn'), + tabContents: document.querySelectorAll('.tab-content'), + registrationForm: document.getElementById('registration-settings-form'), + backupBtn: document.getElementById('backup-btn'), + cleanupBtn: document.getElementById('cleanup-btn'), + addEmailServiceBtn: document.getElementById('add-email-service-btn'), + addServiceModal: document.getElementById('add-service-modal'), + addServiceForm: document.getElementById('add-service-form'), + closeServiceModal: document.getElementById('close-service-modal'), + cancelAddService: document.getElementById('cancel-add-service'), + serviceType: document.getElementById('service-type'), + serviceConfigFields: document.getElementById('service-config-fields'), + emailServicesTable: document.getElementById('email-services-table'), + // Outlook 导入 + toggleImportBtn: document.getElementById('toggle-import-btn'), + outlookImportBody: document.getElementById('outlook-import-body'), + outlookImportBtn: document.getElementById('outlook-import-btn'), + clearImportBtn: document.getElementById('clear-import-btn'), + outlookImportData: document.getElementById('outlook-import-data'), + importResult: document.getElementById('import-result'), + // 批量操作 + selectAllServices: document.getElementById('select-all-services'), + // 代理列表 + proxiesTable: document.getElementById('proxies-table'), + addProxyBtn: document.getElementById('add-proxy-btn'), + testAllProxiesBtn: document.getElementById('test-all-proxies-btn'), + addProxyModal: document.getElementById('add-proxy-modal'), + proxyItemForm: document.getElementById('proxy-item-form'), + closeProxyModal: document.getElementById('close-proxy-modal'), + cancelProxyBtn: document.getElementById('cancel-proxy-btn'), + proxyModalTitle: document.getElementById('proxy-modal-title'), + // 动态代理设置 + dynamicProxyForm: document.getElementById('dynamic-proxy-form'), + testDynamicProxyBtn: document.getElementById('test-dynamic-proxy-btn'), + // CPA 服务管理 + addCpaServiceBtn: document.getElementById('add-cpa-service-btn'), + cpaServicesTable: document.getElementById('cpa-services-table'), + cpaServiceEditModal: document.getElementById('cpa-service-edit-modal'), + closeCpaServiceModal: document.getElementById('close-cpa-service-modal'), + cancelCpaServiceBtn: document.getElementById('cancel-cpa-service-btn'), + cpaServiceForm: document.getElementById('cpa-service-form'), + cpaServiceModalTitle: document.getElementById('cpa-service-modal-title'), + testCpaServiceBtn: document.getElementById('test-cpa-service-btn'), + // Sub2API 服务管理 + addSub2ApiServiceBtn: document.getElementById('add-sub2api-service-btn'), + sub2ApiServicesTable: document.getElementById('sub2api-services-table'), + sub2ApiServiceEditModal: document.getElementById('sub2api-service-edit-modal'), + closeSub2ApiServiceModal: document.getElementById('close-sub2api-service-modal'), + cancelSub2ApiServiceBtn: document.getElementById('cancel-sub2api-service-btn'), + sub2ApiServiceForm: document.getElementById('sub2api-service-form'), + sub2ApiServiceModalTitle: document.getElementById('sub2api-service-modal-title'), + testSub2ApiServiceBtn: document.getElementById('test-sub2api-service-btn'), + // Team Manager 服务管理 + addTmServiceBtn: document.getElementById('add-tm-service-btn'), + tmServicesTable: document.getElementById('tm-services-table'), + tmServiceEditModal: document.getElementById('tm-service-edit-modal'), + closeTmServiceModal: document.getElementById('close-tm-service-modal'), + cancelTmServiceBtn: document.getElementById('cancel-tm-service-btn'), + tmServiceForm: document.getElementById('tm-service-form'), + tmServiceModalTitle: document.getElementById('tm-service-modal-title'), + testTmServiceBtn: document.getElementById('test-tm-service-btn'), + // 验证码设置 + emailCodeForm: document.getElementById('email-code-form'), + // Outlook 设置 + outlookSettingsForm: document.getElementById('outlook-settings-form'), + // Web UI 访问控制 + webuiSettingsForm: document.getElementById('webui-settings-form') +}; + +// 选中的服务 ID +let selectedServiceIds = new Set(); + +// 初始化 +document.addEventListener('DOMContentLoaded', () => { + initTabs(); + loadSettings(); + loadEmailServices(); + loadDatabaseInfo(); + loadProxies(); + loadCpaServices(); + loadSub2ApiServices(); + loadTmServices(); + initEventListeners(); +}); + +document.addEventListener('click', () => { + document.querySelectorAll('.dropdown-menu.active').forEach(m => m.classList.remove('active')); +}); + +// 初始化标签页 +function initTabs() { + elements.tabs.forEach(btn => { + btn.addEventListener('click', () => { + const tab = btn.dataset.tab; + + elements.tabs.forEach(b => b.classList.remove('active')); + elements.tabContents.forEach(c => c.classList.remove('active')); + + btn.classList.add('active'); + document.getElementById(`${tab}-tab`).classList.add('active'); + }); + }); +} + +// 事件监听 +function initEventListeners() { + // 注册配置表单 + if (elements.registrationForm) { + elements.registrationForm.addEventListener('submit', handleSaveRegistration); + } + + // 备份数据库 + if (elements.backupBtn) { + elements.backupBtn.addEventListener('click', handleBackup); + } + + // 清理数据 + if (elements.cleanupBtn) { + elements.cleanupBtn.addEventListener('click', handleCleanup); + } + + // 添加邮箱服务 + if (elements.addEmailServiceBtn) { + elements.addEmailServiceBtn.addEventListener('click', () => { + elements.addServiceModal.classList.add('active'); + loadServiceConfigFields(elements.serviceType.value); + }); + } + + if (elements.closeServiceModal) { + elements.closeServiceModal.addEventListener('click', () => { + elements.addServiceModal.classList.remove('active'); + }); + } + + if (elements.cancelAddService) { + elements.cancelAddService.addEventListener('click', () => { + elements.addServiceModal.classList.remove('active'); + }); + } + + if (elements.addServiceModal) { + elements.addServiceModal.addEventListener('click', (e) => { + if (e.target === elements.addServiceModal) { + elements.addServiceModal.classList.remove('active'); + } + }); + } + + // 服务类型切换 + if (elements.serviceType) { + elements.serviceType.addEventListener('change', (e) => { + loadServiceConfigFields(e.target.value); + }); + } + + // 添加服务表单 + if (elements.addServiceForm) { + elements.addServiceForm.addEventListener('submit', handleAddService); + } + + // Outlook 批量导入展开/折叠 + if (elements.toggleImportBtn) { + elements.toggleImportBtn.addEventListener('click', () => { + const isHidden = elements.outlookImportBody.style.display === 'none'; + elements.outlookImportBody.style.display = isHidden ? 'block' : 'none'; + elements.toggleImportBtn.textContent = isHidden ? '收起' : '展开'; + }); + } + + // Outlook 批量导入 + if (elements.outlookImportBtn) { + elements.outlookImportBtn.addEventListener('click', handleOutlookBatchImport); + } + + // 清空导入数据 + if (elements.clearImportBtn) { + elements.clearImportBtn.addEventListener('click', () => { + elements.outlookImportData.value = ''; + elements.importResult.style.display = 'none'; + }); + } + + // 全选/取消全选 + if (elements.selectAllServices) { + elements.selectAllServices.addEventListener('change', (e) => { + const checkboxes = document.querySelectorAll('.service-checkbox'); + checkboxes.forEach(cb => cb.checked = e.target.checked); + updateSelectedServices(); + }); + } + + // 代理列表相关 + if (elements.addProxyBtn) { + elements.addProxyBtn.addEventListener('click', () => openProxyModal()); + } + + if (elements.testAllProxiesBtn) { + elements.testAllProxiesBtn.addEventListener('click', handleTestAllProxies); + } + + if (elements.closeProxyModal) { + elements.closeProxyModal.addEventListener('click', closeProxyModal); + } + + if (elements.cancelProxyBtn) { + elements.cancelProxyBtn.addEventListener('click', closeProxyModal); + } + + if (elements.addProxyModal) { + elements.addProxyModal.addEventListener('click', (e) => { + if (e.target === elements.addProxyModal) { + closeProxyModal(); + } + }); + } + + if (elements.proxyItemForm) { + elements.proxyItemForm.addEventListener('submit', handleSaveProxyItem); + } + + // 动态代理设置 + if (elements.dynamicProxyForm) { + elements.dynamicProxyForm.addEventListener('submit', handleSaveDynamicProxy); + } + if (elements.testDynamicProxyBtn) { + elements.testDynamicProxyBtn.addEventListener('click', handleTestDynamicProxy); + } + + // 验证码设置 + if (elements.emailCodeForm) { + elements.emailCodeForm.addEventListener('submit', handleSaveEmailCode); + } + + // Outlook 设置 + if (elements.outlookSettingsForm) { + elements.outlookSettingsForm.addEventListener('submit', handleSaveOutlookSettings); + } + + if (elements.webuiSettingsForm) { + elements.webuiSettingsForm.addEventListener('submit', handleSaveWebuiSettings); + } + // Team Manager 服务管理 + if (elements.addTmServiceBtn) { + elements.addTmServiceBtn.addEventListener('click', () => openTmServiceModal()); + } + if (elements.closeTmServiceModal) { + elements.closeTmServiceModal.addEventListener('click', closeTmServiceModal); + } + if (elements.cancelTmServiceBtn) { + elements.cancelTmServiceBtn.addEventListener('click', closeTmServiceModal); + } + if (elements.tmServiceEditModal) { + elements.tmServiceEditModal.addEventListener('click', (e) => { + if (e.target === elements.tmServiceEditModal) closeTmServiceModal(); + }); + } + if (elements.tmServiceForm) { + elements.tmServiceForm.addEventListener('submit', handleSaveTmService); + } + if (elements.testTmServiceBtn) { + elements.testTmServiceBtn.addEventListener('click', handleTestTmService); + } + + // CPA 服务管理 + if (elements.addCpaServiceBtn) { + elements.addCpaServiceBtn.addEventListener('click', () => openCpaServiceModal()); + } + if (elements.closeCpaServiceModal) { + elements.closeCpaServiceModal.addEventListener('click', closeCpaServiceModal); + } + if (elements.cancelCpaServiceBtn) { + elements.cancelCpaServiceBtn.addEventListener('click', closeCpaServiceModal); + } + if (elements.cpaServiceEditModal) { + elements.cpaServiceEditModal.addEventListener('click', (e) => { + if (e.target === elements.cpaServiceEditModal) closeCpaServiceModal(); + }); + } + if (elements.cpaServiceForm) { + elements.cpaServiceForm.addEventListener('submit', handleSaveCpaService); + } + if (elements.testCpaServiceBtn) { + elements.testCpaServiceBtn.addEventListener('click', handleTestCpaService); + } + + // Sub2API 服务管理 + if (elements.addSub2ApiServiceBtn) { + elements.addSub2ApiServiceBtn.addEventListener('click', () => openSub2ApiServiceModal()); + } + if (elements.closeSub2ApiServiceModal) { + elements.closeSub2ApiServiceModal.addEventListener('click', closeSub2ApiServiceModal); + } + if (elements.cancelSub2ApiServiceBtn) { + elements.cancelSub2ApiServiceBtn.addEventListener('click', closeSub2ApiServiceModal); + } + if (elements.sub2ApiServiceEditModal) { + elements.sub2ApiServiceEditModal.addEventListener('click', (e) => { + if (e.target === elements.sub2ApiServiceEditModal) closeSub2ApiServiceModal(); + }); + } + if (elements.sub2ApiServiceForm) { + elements.sub2ApiServiceForm.addEventListener('submit', handleSaveSub2ApiService); + } + if (elements.testSub2ApiServiceBtn) { + elements.testSub2ApiServiceBtn.addEventListener('click', handleTestSub2ApiService); + } +} + +// 加载设置 +async function loadSettings() { + try { + const data = await api.get('/settings'); + + // 动态代理设置 + document.getElementById('dynamic-proxy-enabled').checked = data.proxy?.dynamic_enabled || false; + document.getElementById('dynamic-proxy-api-url').value = data.proxy?.dynamic_api_url || ''; + document.getElementById('dynamic-proxy-api-key-header').value = data.proxy?.dynamic_api_key_header || 'X-API-Key'; + document.getElementById('dynamic-proxy-result-field').value = data.proxy?.dynamic_result_field || ''; + + // 注册配置 + document.getElementById('max-retries').value = data.registration?.max_retries || 3; + document.getElementById('timeout').value = data.registration?.timeout || 120; + document.getElementById('password-length').value = data.registration?.default_password_length || 12; + document.getElementById('sleep-min').value = data.registration?.sleep_min || 5; + document.getElementById('sleep-max').value = data.registration?.sleep_max || 30; + + // 验证码等待配置 + if (data.email_code) { + document.getElementById('email-code-timeout').value = data.email_code.timeout || 120; + document.getElementById('email-code-poll-interval').value = data.email_code.poll_interval || 3; + } + + // 加载 Outlook 设置 + loadOutlookSettings(); + + // Web UI 访问密码提示 + if (data.webui?.has_access_password) { + const input = document.getElementById('webui-access-password'); + if (input) { + input.value = ''; + input.placeholder = '已配置,留空保持不变'; + } + } + + } catch (error) { + console.error('加载设置失败:', error); + toast.error('加载设置失败'); + } +} + +// 保存 Web UI 设置 +async function handleSaveWebuiSettings(e) { + e.preventDefault(); + + const accessPassword = document.getElementById('webui-access-password').value; + const payload = { + access_password: accessPassword || null + }; + + try { + await api.post('/settings/webui', payload); + toast.success('Web UI 设置已更新'); + document.getElementById('webui-access-password').value = ''; + } catch (error) { + console.error('保存 Web UI 设置失败:', error); + toast.error('保存 Web UI 设置失败'); + } +} + +// 加载邮箱服务 +async function loadEmailServices() { + // 检查元素是否存在 + if (!elements.emailServicesTable) return; + + try { + const data = await api.get('/email-services'); + renderEmailServices(data.services); + } catch (error) { + console.error('加载邮箱服务失败:', error); + if (elements.emailServicesTable) { + elements.emailServicesTable.innerHTML = ` + + +
+
❌
+
加载失败
+
+ + + `; + } + } +} + +// 渲染邮箱服务 +function renderEmailServices(services) { + // 检查元素是否存在 + if (!elements.emailServicesTable) return; + + if (services.length === 0) { + elements.emailServicesTable.innerHTML = ` + + +
+
📭
+
暂无配置
+
点击上方"添加服务"按钮添加邮箱服务
+
+ + + `; + return; + } + + elements.emailServicesTable.innerHTML = services.map(service => ` + + + + + ${escapeHtml(service.name)} + ${getServiceTypeText(service.service_type)} + ${service.enabled ? '✅' : '⭕'} + ${service.priority} + ${format.date(service.last_used)} + +
+ + + +
+ + + `).join(''); +} + +// 加载数据库信息 +async function loadDatabaseInfo() { + try { + const data = await api.get('/settings/database'); + + document.getElementById('db-size').textContent = `${data.database_size_mb} MB`; + document.getElementById('db-accounts').textContent = format.number(data.accounts_count); + document.getElementById('db-services').textContent = format.number(data.email_services_count); + document.getElementById('db-tasks').textContent = format.number(data.tasks_count); + + } catch (error) { + console.error('加载数据库信息失败:', error); + } +} + +// 保存注册配置 +async function handleSaveRegistration(e) { + e.preventDefault(); + + const data = { + max_retries: parseInt(document.getElementById('max-retries').value), + timeout: parseInt(document.getElementById('timeout').value), + default_password_length: parseInt(document.getElementById('password-length').value), + sleep_min: parseInt(document.getElementById('sleep-min').value), + sleep_max: parseInt(document.getElementById('sleep-max').value), + }; + + try { + await api.post('/settings/registration', data); + toast.success('注册配置已保存'); + } catch (error) { + toast.error('保存失败: ' + error.message); + } +} + +// 保存验证码等待配置 +async function handleSaveEmailCode(e) { + e.preventDefault(); + + const timeout = parseInt(document.getElementById('email-code-timeout').value); + const pollInterval = parseInt(document.getElementById('email-code-poll-interval').value); + + // 客户端验证 + if (timeout < 30 || timeout > 600) { + toast.error('等待超时必须在 30-600 秒之间'); + return; + } + if (pollInterval < 1 || pollInterval > 30) { + toast.error('轮询间隔必须在 1-30 秒之间'); + return; + } + + const data = { + timeout: timeout, + poll_interval: pollInterval + }; + + try { + await api.post('/settings/email-code', data); + toast.success('验证码配置已保存'); + } catch (error) { + toast.error('保存失败: ' + error.message); + } +} + +// 备份数据库 +async function handleBackup() { + elements.backupBtn.disabled = true; + elements.backupBtn.innerHTML = ' 备份中...'; + + try { + const data = await api.post('/settings/database/backup'); + toast.success(`备份成功: ${data.backup_path}`); + } catch (error) { + toast.error('备份失败: ' + error.message); + } finally { + elements.backupBtn.disabled = false; + elements.backupBtn.textContent = '💾 备份数据库'; + } +} + +// 清理数据 +async function handleCleanup() { + const confirmed = await confirm('确定要清理过期数据吗?此操作不可恢复。'); + if (!confirmed) return; + + elements.cleanupBtn.disabled = true; + elements.cleanupBtn.innerHTML = ' 清理中...'; + + try { + const data = await api.post('/settings/database/cleanup?days=30'); + toast.success(data.message); + loadDatabaseInfo(); + } catch (error) { + toast.error('清理失败: ' + error.message); + } finally { + elements.cleanupBtn.disabled = false; + elements.cleanupBtn.textContent = '🧹 清理过期数据'; + } +} + +// 加载服务配置字段 +async function loadServiceConfigFields(serviceType) { + try { + const data = await api.get('/email-services/types'); + const typeInfo = data.types.find(t => t.value === serviceType); + + if (!typeInfo) { + elements.serviceConfigFields.innerHTML = ''; + return; + } + + elements.serviceConfigFields.innerHTML = typeInfo.config_fields.map(field => ` +
+ + +
+ `).join(''); + + } catch (error) { + console.error('加载配置字段失败:', error); + } +} + +// 添加邮箱服务 +async function handleAddService(e) { + e.preventDefault(); + + const formData = new FormData(elements.addServiceForm); + const config = {}; + + elements.serviceConfigFields.querySelectorAll('input').forEach(input => { + config[input.name] = input.value; + }); + + const data = { + service_type: formData.get('service_type'), + name: formData.get('name'), + config: config, + enabled: true, + priority: 0, + }; + + try { + await api.post('/email-services', data); + toast.success('邮箱服务已添加'); + elements.addServiceModal.classList.remove('active'); + elements.addServiceForm.reset(); + loadEmailServices(); + } catch (error) { + toast.error('添加失败: ' + error.message); + } +} + +// 测试服务 +async function testService(id) { + try { + const data = await api.post(`/email-services/${id}/test`); + if (data.success) { + toast.success('服务连接正常'); + } else { + toast.warning('服务连接失败: ' + data.message); + } + } catch (error) { + toast.error('测试失败: ' + error.message); + } +} + +// 切换服务状态 +async function toggleService(id, enabled) { + try { + const endpoint = enabled ? 'enable' : 'disable'; + await api.post(`/email-services/${id}/${endpoint}`); + toast.success(enabled ? '服务已启用' : '服务已禁用'); + loadEmailServices(); + } catch (error) { + toast.error('操作失败: ' + error.message); + } +} + +// 删除服务 +async function deleteService(id) { + const confirmed = await confirm('确定要删除此邮箱服务配置吗?'); + if (!confirmed) return; + + try { + await api.delete(`/email-services/${id}`); + toast.success('服务已删除'); + loadEmailServices(); + } catch (error) { + toast.error('删除失败: ' + error.message); + } +} + +// 更新选中的服务 +function updateSelectedServices() { + selectedServiceIds.clear(); + document.querySelectorAll('.service-checkbox:checked').forEach(cb => { + selectedServiceIds.add(parseInt(cb.dataset.id)); + }); +} + +// Outlook 批量导入 +async function handleOutlookBatchImport() { + const data = elements.outlookImportData.value.trim(); + if (!data) { + toast.warning('请输入要导入的数据'); + return; + } + + const enabled = document.getElementById('outlook-import-enabled').checked; + const priority = parseInt(document.getElementById('outlook-import-priority').value) || 0; + + // 解析数据 + const lines = data.split('\n').filter(line => line.trim() && !line.trim().startsWith('#')); + const accounts = []; + const errors = []; + + lines.forEach((line, index) => { + const parts = line.split('----').map(p => p.trim()); + if (parts.length < 2) { + errors.push(`第 ${index + 1} 行格式错误`); + return; + } + + const account = { + email: parts[0], + password: parts[1], + client_id: parts[2] || null, + refresh_token: parts[3] || null, + enabled: enabled, + priority: priority + }; + + if (!account.email.includes('@')) { + errors.push(`第 ${index + 1} 行邮箱格式错误: ${account.email}`); + return; + } + + accounts.push(account); + }); + + if (errors.length > 0) { + elements.importResult.style.display = 'block'; + elements.importResult.innerHTML = ` +
${errors.map(e => `
${e}
`).join('')}
+ `; + return; + } + + elements.outlookImportBtn.disabled = true; + elements.outlookImportBtn.innerHTML = ' 导入中...'; + + let successCount = 0; + let failCount = 0; + + try { + for (const account of accounts) { + try { + await api.post('/email-services', { + service_type: 'outlook', + name: account.email, + config: { + email: account.email, + password: account.password, + client_id: account.client_id, + refresh_token: account.refresh_token + }, + enabled: account.enabled, + priority: account.priority + }); + successCount++; + } catch { + failCount++; + } + } + + elements.importResult.style.display = 'block'; + elements.importResult.innerHTML = ` +
+ ✅ 成功: ${successCount} + ❌ 失败: ${failCount} +
+ `; + + toast.success(`导入完成,成功 ${successCount} 个`); + loadEmailServices(); + + } catch (error) { + toast.error('导入失败: ' + error.message); + } finally { + elements.outlookImportBtn.disabled = false; + elements.outlookImportBtn.textContent = '📥 开始导入'; + } +} + +// HTML 转义 +function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + + +// ============================================================================ +// 代理列表管理 +// ============================================================================ + +// 加载代理列表 +async function loadProxies() { + try { + const data = await api.get('/settings/proxies'); + renderProxies(data.proxies); + } catch (error) { + console.error('加载代理列表失败:', error); + elements.proxiesTable.innerHTML = ` + + +
+
❌
+
加载失败
+
+ + + `; + } +} + +// 渲染代理列表 +function renderProxies(proxies) { + if (!proxies || proxies.length === 0) { + elements.proxiesTable.innerHTML = ` + + +
+
🌐
+
暂无代理
+
点击"添加代理"按钮添加代理服务器
+
+ + + `; + return; + } + + elements.proxiesTable.innerHTML = proxies.map(proxy => ` + + ${proxy.id} + ${escapeHtml(proxy.name)} + ${proxy.type.toUpperCase()} + ${escapeHtml(proxy.host)}:${proxy.port} + + ${proxy.is_default + ? '默认' + : `` + } + + ${proxy.enabled ? '✅' : '⭕'} + ${format.date(proxy.last_used)} + +
+ + + +
+ + + `).join(''); +} + +function toggleSettingsMoreMenu(btn) { + const menu = btn.nextElementSibling; + const isActive = menu.classList.contains('active'); + document.querySelectorAll('.dropdown-menu.active').forEach(m => m.classList.remove('active')); + if (!isActive) menu.classList.add('active'); +} + +function closeSettingsMoreMenu(el) { + const menu = el.closest('.dropdown-menu'); + if (menu) menu.classList.remove('active'); +} + +// 设为默认代理 +async function handleSetProxyDefault(id) { + try { + await api.post(`/settings/proxies/${id}/set-default`); + toast.success('已设为默认代理'); + loadProxies(); + } catch (error) { + toast.error('操作失败: ' + error.message); + } +} + +// 打开代理模态框 +function openProxyModal(proxy = null) { + elements.proxyModalTitle.textContent = proxy ? '编辑代理' : '添加代理'; + elements.proxyItemForm.reset(); + + document.getElementById('proxy-item-id').value = proxy ? proxy.id : ''; + + if (proxy) { + document.getElementById('proxy-item-name').value = proxy.name || ''; + document.getElementById('proxy-item-type').value = proxy.type || 'http'; + document.getElementById('proxy-item-host').value = proxy.host || ''; + document.getElementById('proxy-item-port').value = proxy.port || ''; + document.getElementById('proxy-item-username').value = proxy.username || ''; + document.getElementById('proxy-item-password').value = ''; + } + + elements.addProxyModal.classList.add('active'); +} + +// 关闭代理模态框 +function closeProxyModal() { + elements.addProxyModal.classList.remove('active'); + elements.proxyItemForm.reset(); +} + +// 保存代理 +async function handleSaveProxyItem(e) { + e.preventDefault(); + + const proxyId = document.getElementById('proxy-item-id').value; + const data = { + name: document.getElementById('proxy-item-name').value, + type: document.getElementById('proxy-item-type').value, + host: document.getElementById('proxy-item-host').value, + port: parseInt(document.getElementById('proxy-item-port').value), + username: document.getElementById('proxy-item-username').value || null, + password: document.getElementById('proxy-item-password').value || null, + enabled: true + }; + + try { + if (proxyId) { + await api.patch(`/settings/proxies/${proxyId}`, data); + toast.success('代理已更新'); + } else { + await api.post('/settings/proxies', data); + toast.success('代理已添加'); + } + closeProxyModal(); + loadProxies(); + } catch (error) { + toast.error('保存失败: ' + error.message); + } +} + +// 编辑代理 +async function editProxyItem(id) { + try { + const proxy = await api.get(`/settings/proxies/${id}`); + openProxyModal(proxy); + } catch (error) { + toast.error('获取代理信息失败'); + } +} + +// 测试单个代理 +async function testProxyItem(id) { + try { + const result = await api.post(`/settings/proxies/${id}/test`); + if (result.success) { + toast.success(result.message); + } else { + toast.error(result.message); + } + } catch (error) { + toast.error('测试失败: ' + error.message); + } +} + +// 切换代理状态 +async function toggleProxyItem(id, enabled) { + try { + const endpoint = enabled ? 'enable' : 'disable'; + await api.post(`/settings/proxies/${id}/${endpoint}`); + toast.success(enabled ? '代理已启用' : '代理已禁用'); + loadProxies(); + } catch (error) { + toast.error('操作失败: ' + error.message); + } +} + +// 删除代理 +async function deleteProxyItem(id) { + const confirmed = await confirm('确定要删除此代理吗?'); + if (!confirmed) return; + + try { + await api.delete(`/settings/proxies/${id}`); + toast.success('代理已删除'); + loadProxies(); + } catch (error) { + toast.error('删除失败: ' + error.message); + } +} + +// 测试所有代理 +async function handleTestAllProxies() { + elements.testAllProxiesBtn.disabled = true; + elements.testAllProxiesBtn.innerHTML = ' 测试中...'; + + try { + const result = await api.post('/settings/proxies/test-all'); + toast.info(`测试完成: 成功 ${result.success}, 失败 ${result.failed}`); + loadProxies(); + } catch (error) { + toast.error('测试失败: ' + error.message); + } finally { + elements.testAllProxiesBtn.disabled = false; + elements.testAllProxiesBtn.textContent = '🔌 测试全部'; + } +} + + +// ============================================================================ +// Outlook 设置管理 +// ============================================================================ + +// 加载 Outlook 设置 +async function loadOutlookSettings() { + try { + const data = await api.get('/settings/outlook'); + const el = document.getElementById('outlook-default-client-id'); + if (el) el.value = data.default_client_id || ''; + } catch (error) { + console.error('加载 Outlook 设置失败:', error); + } +} + +// 保存 Outlook 设置 +async function handleSaveOutlookSettings(e) { + e.preventDefault(); + const data = { + default_client_id: document.getElementById('outlook-default-client-id').value + }; + try { + await api.post('/settings/outlook', data); + toast.success('Outlook 设置已保存'); + } catch (error) { + toast.error('保存失败: ' + error.message); + } +} + +// ============== 动态代理设置 ============== + +async function handleSaveDynamicProxy(e) { + e.preventDefault(); + const data = { + enabled: document.getElementById('dynamic-proxy-enabled').checked, + api_url: document.getElementById('dynamic-proxy-api-url').value.trim(), + api_key: document.getElementById('dynamic-proxy-api-key').value || null, + api_key_header: document.getElementById('dynamic-proxy-api-key-header').value.trim() || 'X-API-Key', + result_field: document.getElementById('dynamic-proxy-result-field').value.trim() + }; + try { + await api.post('/settings/proxy/dynamic', data); + toast.success('动态代理设置已保存'); + document.getElementById('dynamic-proxy-api-key').value = ''; + } catch (error) { + toast.error('保存失败: ' + error.message); + } +} + +async function handleTestDynamicProxy() { + const apiUrl = document.getElementById('dynamic-proxy-api-url').value.trim(); + if (!apiUrl) { + toast.warning('请先填写动态代理 API 地址'); + return; + } + const btn = elements.testDynamicProxyBtn; + btn.disabled = true; + btn.textContent = '测试中...'; + try { + const result = await api.post('/settings/proxy/dynamic/test', { + api_url: apiUrl, + api_key: document.getElementById('dynamic-proxy-api-key').value || null, + api_key_header: document.getElementById('dynamic-proxy-api-key-header').value.trim() || 'X-API-Key', + result_field: document.getElementById('dynamic-proxy-result-field').value.trim() + }); + if (result.success) { + toast.success(result.message); + } else { + toast.error(result.message); + } + } catch (error) { + toast.error('测试失败: ' + error.message); + } finally { + btn.disabled = false; + btn.textContent = '🔌 测试动态代理'; + } +} + +// ============== Team Manager 服务管理 ============== + +async function loadTmServices() { + if (!elements.tmServicesTable) return; + try { + const services = await api.get('/tm-services'); + renderTmServicesTable(services); + } catch (e) { + elements.tmServicesTable.innerHTML = `${e.message}`; + } +} + +function renderTmServicesTable(services) { + if (!services || services.length === 0) { + elements.tmServicesTable.innerHTML = '暂无 Team Manager 服务,点击「添加服务」新增'; + return; + } + elements.tmServicesTable.innerHTML = services.map(s => ` + + ${escapeHtml(s.name)} + ${escapeHtml(s.api_url)} + ${s.enabled ? '✅' : '⭕'} + ${s.priority} + + + + + + + `).join(''); +} + +function openTmServiceModal(service = null) { + document.getElementById('tm-service-id').value = service ? service.id : ''; + document.getElementById('tm-service-name').value = service ? service.name : ''; + document.getElementById('tm-service-url').value = service ? service.api_url : ''; + document.getElementById('tm-service-key').value = ''; + document.getElementById('tm-service-priority').value = service ? service.priority : 0; + document.getElementById('tm-service-enabled').checked = service ? service.enabled : true; + if (service) { + document.getElementById('tm-service-key').placeholder = service.has_key ? '已配置,留空保持不变' : '请输入 API Key'; + } else { + document.getElementById('tm-service-key').placeholder = '请输入 API Key'; + } + elements.tmServiceModalTitle.textContent = service ? '编辑 Team Manager 服务' : '添加 Team Manager 服务'; + elements.tmServiceEditModal.classList.add('active'); +} + +function closeTmServiceModal() { + elements.tmServiceEditModal.classList.remove('active'); +} + +async function editTmService(id) { + try { + const service = await api.get(`/tm-services/${id}`); + openTmServiceModal(service); + } catch (e) { + toast.error('获取服务信息失败: ' + e.message); + } +} + +async function handleSaveTmService(e) { + e.preventDefault(); + const id = document.getElementById('tm-service-id').value; + const name = document.getElementById('tm-service-name').value.trim(); + const apiUrl = document.getElementById('tm-service-url').value.trim(); + const apiKey = document.getElementById('tm-service-key').value.trim(); + const priority = parseInt(document.getElementById('tm-service-priority').value) || 0; + const enabled = document.getElementById('tm-service-enabled').checked; + + if (!name || !apiUrl) { + toast.error('名称和 API URL 不能为空'); + return; + } + if (!id && !apiKey) { + toast.error('新增服务时 API Key 不能为空'); + return; + } + + try { + const payload = { name, api_url: apiUrl, priority, enabled }; + if (apiKey) payload.api_key = apiKey; + + if (id) { + await api.patch(`/tm-services/${id}`, payload); + toast.success('服务已更新'); + } else { + payload.api_key = apiKey; + await api.post('/tm-services', payload); + toast.success('服务已添加'); + } + closeTmServiceModal(); + loadTmServices(); + } catch (e) { + toast.error('保存失败: ' + e.message); + } +} + +async function deleteTmService(id, name) { + const confirmed = await confirm(`确定要删除 Team Manager 服务「${name}」吗?`); + if (!confirmed) return; + try { + await api.delete(`/tm-services/${id}`); + toast.success('已删除'); + loadTmServices(); + } catch (e) { + toast.error('删除失败: ' + e.message); + } +} + +async function testTmServiceById(id) { + try { + const result = await api.post(`/tm-services/${id}/test`); + if (result.success) { + toast.success(result.message); + } else { + toast.error(result.message); + } + } catch (e) { + toast.error('测试失败: ' + e.message); + } +} + +async function handleTestTmService() { + const apiUrl = document.getElementById('tm-service-url').value.trim(); + const apiKey = document.getElementById('tm-service-key').value.trim(); + const id = document.getElementById('tm-service-id').value; + + if (!apiUrl) { + toast.error('请先填写 API URL'); + return; + } + if (!id && !apiKey) { + toast.error('请先填写 API Key'); + return; + } + + elements.testTmServiceBtn.disabled = true; + elements.testTmServiceBtn.textContent = '测试中...'; + + try { + let result; + if (id && !apiKey) { + result = await api.post(`/tm-services/${id}/test`); + } else { + result = await api.post('/tm-services/test-connection', { api_url: apiUrl, api_key: apiKey }); + } + if (result.success) { + toast.success(result.message); + } else { + toast.error(result.message); + } + } catch (e) { + toast.error('测试失败: ' + e.message); + } finally { + elements.testTmServiceBtn.disabled = false; + elements.testTmServiceBtn.textContent = '🔌 测试连接'; + } +} + + +// ============== CPA 服务管理 ============== + +async function loadCpaServices() { + if (!elements.cpaServicesTable) return; + try { + const services = await api.get('/cpa-services'); + renderCpaServicesTable(services); + } catch (e) { + elements.cpaServicesTable.innerHTML = `${e.message}`; + } +} + +function renderCpaServicesTable(services) { + if (!services || services.length === 0) { + elements.cpaServicesTable.innerHTML = '暂无 CPA 服务,点击「添加服务」新增'; + return; + } + elements.cpaServicesTable.innerHTML = services.map(s => ` + + ${escapeHtml(s.name)} + ${escapeHtml(s.api_url)} + ${s.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) { + if (!confirm(`确认删除 Sub2API 服务「${name}」?`)) return; + try { + await api.delete(`/sub2api-services/${id}`); + toast.success('服务已删除'); + loadSub2ApiServices(); + } catch (e) { + toast.error('删除失败: ' + e.message); + } +} + +async function handleSaveSub2ApiService(e) { + e.preventDefault(); + const id = document.getElementById('sub2api-service-id').value; + const data = { + name: document.getElementById('sub2api-service-name').value, + api_url: document.getElementById('sub2api-service-url').value, + api_key: document.getElementById('sub2api-service-key').value || undefined, + priority: parseInt(document.getElementById('sub2api-service-priority').value) || 0, + enabled: document.getElementById('sub2api-service-enabled').checked, + }; + if (!id && !data.api_key) { + toast.error('请填写 API Key'); + return; + } + if (!data.api_key) delete data.api_key; + + try { + if (id) { + await api.patch(`/sub2api-services/${id}`, data); + toast.success('服务已更新'); + } else { + await api.post('/sub2api-services', data); + toast.success('服务已添加'); + } + closeSub2ApiServiceModal(); + loadSub2ApiServices(); + } catch (e) { + toast.error('保存失败: ' + e.message); + } +} + +async function testSub2ApiServiceById(id) { + try { + const result = await api.post(`/sub2api-services/${id}/test`); + if (result.success) { + toast.success(result.message); + } else { + toast.error(result.message); + } + } catch (e) { + toast.error('测试失败: ' + e.message); + } +} + +async function handleTestSub2ApiService() { + const apiUrl = document.getElementById('sub2api-service-url').value.trim(); + const apiKey = document.getElementById('sub2api-service-key').value.trim(); + const id = document.getElementById('sub2api-service-id').value; + + if (!apiUrl) { + toast.error('请先填写 API URL'); + return; + } + if (!id && !apiKey) { + toast.error('请先填写 API Key'); + return; + } + + elements.testSub2ApiServiceBtn.disabled = true; + elements.testSub2ApiServiceBtn.textContent = '测试中...'; + + try { + let result; + if (id && !apiKey) { + result = await api.post(`/sub2api-services/${id}/test`); + } else { + result = await api.post('/sub2api-services/test-connection', { api_url: apiUrl, api_key: apiKey }); + } + if (result.success) { + toast.success(result.message); + } else { + toast.error(result.message); + } + } catch (e) { + toast.error('测试失败: ' + e.message); + } finally { + elements.testSub2ApiServiceBtn.disabled = false; + elements.testSub2ApiServiceBtn.textContent = '🔌 测试连接'; + } +} + +function escapeHtml(text) { + if (!text) return ''; + const d = document.createElement('div'); + d.textContent = text; + return d.innerHTML; +} diff --git a/static/js/utils.js b/static/js/utils.js new file mode 100644 index 0000000000000000000000000000000000000000..b7b5dab005f3f1e6eb42c81369a09a08acd240a3 --- /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 0000000000000000000000000000000000000000..32b67314b683c12b5d3d46f00a4efdd932ba88dd --- /dev/null +++ b/templates/accounts.html @@ -0,0 +1,283 @@ + + + + + + 账号管理 - OpenAI 注册系统 + + + + + +
+ + + + +
+ + + +
+
+
0
+
总账号数
+
+
+
0
+
活跃账号
+
+
+
0
+
过期账号
+
+
+
0
+
失败账号
+
+
+ + +
+
+
+ + + + + +
+ +
+ + + + + + + +
+
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + +
ID邮箱密码邮箱服务状态CPA订阅最后刷新操作
+
+
+
+
+
+
+
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + + diff --git a/templates/email_services.html b/templates/email_services.html new file mode 100644 index 0000000000000000000000000000000000000000..4b18766fa7f94002e642bc871d6a3cb06b12b86a --- /dev/null +++ b/templates/email_services.html @@ -0,0 +1,522 @@ + + + + + + 邮箱服务 - OpenAI 注册系统 + + + + +
+ + + + +
+ + + +
+
+
0
+
Outlook 账户
+
+
+
0
+
自定义邮箱
+
+
+
可用
+
临时邮箱
+
+
+
0
+
已启用服务
+
+
+ + +
+
+

📥 Outlook 批量导入

+ +
+ +
+ + +
+
+

🔗 自定义邮箱服务

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

📧 Outlook 账户列表

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

🌐 临时邮箱配置

+
+
+
+
+ + +
+
+ +
+
+ + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000000000000000000000000000000000000..2c5b5191a86da6b8758d2068e4302817a6a2f449 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,416 @@ + + + + + + 注册控制台 - OpenAI 注册系统 + + + + + +
+ + + + +
+
+ +
+
+
+

📝 注册设置

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

💻 监控台

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

📋 已注册账号

+
+ + 查看全部 +
+
+
+
+ + + + + + + + + + + + + + +
ID邮箱密码状态
+
+
📭
+
暂无已注册账号
+
+
+
+
+
+
+
+
+
+ + + + + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000000000000000000000000000000000000..8a241fd9bebb2350590a1d53ebc8564bd78f94dc --- /dev/null +++ b/templates/login.html @@ -0,0 +1,62 @@ + + + + + + 访问验证 - OpenAI 注册系统 + + + + + +
+ +
+ + diff --git a/templates/payment.html b/templates/payment.html new file mode 100644 index 0000000000000000000000000000000000000000..28bb426b598d5a3ae620ebeec6b9fa29eb279812 --- /dev/null +++ b/templates/payment.html @@ -0,0 +1,172 @@ + + + + + + 支付升级 - OpenAI 注册系统 + + + + + +
+ + + + +
+ + +
+
+

选择套餐

+
+
+ +
+
+

Plus

+

个人订阅,$20/月

+
+
+

Team

+

团队订阅,按座位计费

+
+
+ + +
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ +
+ + + +
+
+
+
+ + + + + diff --git a/templates/settings.html b/templates/settings.html new file mode 100644 index 0000000000000000000000000000000000000000..e925ce3ad208de96c2341cb8deb23d6d9400f5f7 --- /dev/null +++ b/templates/settings.html @@ -0,0 +1,575 @@ + + + + + + 系统设置 - OpenAI 注册系统 + + + + +
+ + + + +
+ + + +
+ + + + + + + +
+ + +
+ +
+
+

动态代理配置

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

代理列表

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

Web UI 访问密码

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

☁️ CPA 服务

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

🔗 Sub2API 服务

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

🚀 Team Manager 服务

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

Outlook OAuth 配置

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

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

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

注册配置

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

验证码等待配置

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

验证码获取策略

+
+
+
    +
  • 渐进式检查:前 3 次轮询只检查未读邮件,之后检查所有邮件
  • +
  • 时间戳过滤:自动跳过 OTP 发送前的旧邮件
  • +
  • 验证码去重:避免重复使用同一验证码
  • +
  • 多策略提取:主题优先 → 语义匹配 → 兜底匹配
  • +
  • 发件人验证:严格验证邮件来自 OpenAI 官方
  • +
+
+
+
+ + +
+
+
+

数据库信息

+
+
+
+
+ 数据库大小 + - +
+
+ 账号数量 + - +
+
+ 邮箱服务数量 + - +
+
+ 任务记录数量 + - +
+
+ +
+ + +
+
+
+
+
+
+ + + + + diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..f6eb09a5cea0a2ab5108818445b639fa2250e01e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,46 @@ +import socket +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + + +def _fallback_socketpair_no_peer_check(family=socket.AF_INET, type=socket.SOCK_STREAM, proto=0): + if family == socket.AF_INET: + host = "127.0.0.1" + elif family == socket.AF_INET6: + host = "::1" + else: + raise ValueError("Only AF_INET and AF_INET6 socket address families are supported") + if type != socket.SOCK_STREAM: + raise ValueError("Only SOCK_STREAM socket type is supported") + if proto != 0: + raise ValueError("Only protocol zero is supported") + + listener = socket.socket(family, type, proto) + try: + listener.bind((host, 0)) + listener.listen(1) + address = listener.getsockname()[:2] + client = socket.socket(family, type, proto) + try: + client.setblocking(False) + try: + client.connect(address) + except (BlockingIOError, InterruptedError): + pass + client.setblocking(True) + server, _ = listener.accept() + except Exception: + client.close() + raise + finally: + listener.close() + + return server, client + + +if sys.platform.startswith("win"): + socket.socketpair = _fallback_socketpair_no_peer_check diff --git a/tests/test_cpa_upload.py b/tests/test_cpa_upload.py new file mode 100644 index 0000000000000000000000000000000000000000..82125bcac17c181891c83addf3d98cea4c4fabd1 --- /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 0000000000000000000000000000000000000000..3767f89458d19b221e868485cdc8d5bc43580775 --- /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_catalog.py b/tests/test_email_service_catalog.py new file mode 100644 index 0000000000000000000000000000000000000000..01d461a802ca8a6b4a1b449b20b8a52d3689f66d --- /dev/null +++ b/tests/test_email_service_catalog.py @@ -0,0 +1,129 @@ +from contextlib import contextmanager +from pathlib import Path + +import src.config.settings as settings_module +from src.core import email_service_catalog +from src.database.models import Base, CpaService, EmailService, Sub2ApiService, TeamManagerService +from src.database.session import DatabaseSessionManager + + +class DummySettings: + external_api_enabled = True + + +@contextmanager +def _session_context(manager): + session = manager.SessionLocal() + try: + yield session + finally: + session.close() + + +def _make_db(name: str): + runtime_dir = Path("tests_runtime") + runtime_dir.mkdir(exist_ok=True) + db_path = runtime_dir / name + if db_path.exists(): + db_path.unlink() + manager = DatabaseSessionManager(f"sqlite:///{db_path}") + Base.metadata.create_all(bind=manager.engine) + return manager + + +def test_build_external_capabilities_does_not_expose_legacy_tempmail_placeholder(monkeypatch): + manager = _make_db("email_service_catalog_no_legacy_tempmail.db") + + monkeypatch.setattr(email_service_catalog, "get_db", lambda: _session_context(manager)) + monkeypatch.setattr(email_service_catalog, "get_settings", lambda: DummySettings()) + + payload = email_service_catalog.build_external_capabilities() + + assert payload["email_types"] == [] + + +def test_build_external_capabilities_survives_temp_mail_configs_without_worker_credentials(monkeypatch): + manager = _make_db("email_service_catalog_capabilities.db") + with manager.session_scope() as session: + session.add_all([ + EmailService( + service_type="temp_mail", + name="Temp pool", + config={"domains": ["alpha.example.com", "beta.example.com"]}, + enabled=True, + priority=0, + ), + CpaService(name="CPA", api_url="https://cpa.test", api_token="token", enabled=True, priority=0), + Sub2ApiService(name="Sub2", api_url="https://sub2.test", api_key="key", enabled=True, priority=0), + TeamManagerService(name="TM", api_url="https://tm.test", api_key="key", enabled=True, priority=0), + ]) + + monkeypatch.setattr(email_service_catalog, "get_db", lambda: _session_context(manager)) + monkeypatch.setattr(email_service_catalog, "get_settings", lambda: DummySettings()) + + payload = email_service_catalog.build_external_capabilities() + + assert all(item["type"] != "tempmail" for item in payload["email_types"]) + + temp_mail = next(item for item in payload["email_types"] if item["type"] == "temp_mail") + assert temp_mail["available"] is True + assert temp_mail["count"] == 1 + assert temp_mail["services"][0]["domain_count"] == 2 + assert temp_mail["services"][0]["domains_preview"] == ["alpha.example.com", "beta.example.com"] + assert temp_mail["services"][0]["domain_source"] == "config_domains" + + settings = payload["settings"] + assert settings["external_api_enabled"] is True + assert {provider["provider"] for provider in payload["upload_providers"]} == {"cpa", "sub2api", "tm"} + + +def test_build_external_capabilities_uses_worker_domain_summary_when_credentials_exist(monkeypatch): + manager = _make_db("email_service_catalog_worker_domains.db") + with manager.session_scope() as session: + session.add( + EmailService( + service_type="temp_mail", + name="Temp worker", + config={ + "base_url": "https://apmail.889110.xyz", + "admin_password": "admin888", + "domain": "fallback.example.com", + }, + enabled=True, + priority=0, + ) + ) + + monkeypatch.setattr(email_service_catalog, "get_db", lambda: _session_context(manager)) + monkeypatch.setattr(email_service_catalog, "get_settings", lambda: DummySettings()) + monkeypatch.setattr(email_service_catalog.TempMailService, "_fetch_domains_from_worker", lambda self: {"domains": ["w1.example.com", "w2.example.com"]}) + + payload = email_service_catalog.build_external_capabilities() + + temp_mail = next(item for item in payload["email_types"] if item["type"] == "temp_mail") + assert temp_mail["services"][0]["domain_source"] == "worker_api" + assert temp_mail["services"][0]["domains_preview"] == ["w1.example.com", "w2.example.com"] + + +def test_build_external_capabilities_temp_mail_gracefully_handles_empty_summary(monkeypatch): + manager = _make_db("email_service_catalog_empty_domains.db") + with manager.session_scope() as session: + session.add( + EmailService( + service_type="temp_mail", + name="Temp empty", + config={}, + enabled=True, + priority=0, + ) + ) + + monkeypatch.setattr(email_service_catalog, "get_db", lambda: _session_context(manager)) + monkeypatch.setattr(email_service_catalog, "get_settings", lambda: DummySettings()) + + payload = email_service_catalog.build_external_capabilities() + + temp_mail = next(item for item in payload["email_types"] if item["type"] == "temp_mail") + assert temp_mail["services"][0]["domain_source"] == "none" + assert temp_mail["services"][0]["domain_count"] == 0 + assert temp_mail["services"][0]["domains_preview"] == [] diff --git a/tests/test_email_service_duckmail_routes.py b/tests/test_email_service_duckmail_routes.py new file mode 100644 index 0000000000000000000000000000000000000000..b8878e594d697b918f91b26f7a71ede99c712de7 --- /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_external_api_app.py b/tests/test_external_api_app.py new file mode 100644 index 0000000000000000000000000000000000000000..d4de71cd8fe9b45e2ff4a04f94c1f47dc4854974 --- /dev/null +++ b/tests/test_external_api_app.py @@ -0,0 +1,155 @@ +import builtins +import shutil +import sys +import tempfile +import types +from pathlib import Path + +from fastapi.testclient import TestClient + +import webui +import src.config.settings as settings_module +import src.database.session as db_session +from src.web.app import create_app +from src.web.routes import api_router + + +def test_api_router_mounts_external_routes(): + paths = {route.path for route in api_router.routes} + assert "/external/capabilities" in paths + assert "/external/registration/batches" in paths + + +def test_app_startup_tolerates_missing_external_recovery(monkeypatch): + import src.database.init_db as init_db + import src.database.session as db_session + + monkeypatch.setattr(init_db, "initialize_database", lambda: None) + + class _DummyDbContext: + def __enter__(self): + return object() + + def __exit__(self, exc_type, exc, tb): + return False + + monkeypatch.setattr(db_session, "get_db", lambda: _DummyDbContext()) + + original_import = builtins.__import__ + + def _patched_import(name, globals=None, locals=None, fromlist=(), level=0): + if "external_batches.recovery" in name: + raise ModuleNotFoundError(name) + if name.endswith("external_batches") and fromlist and "recovery" in fromlist: + raise ModuleNotFoundError(name) + return original_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", _patched_import) + + app = create_app() + with TestClient(app) as client: + response = client.get("/login") + assert response.status_code == 200 + + +def test_start_webui_uses_preloaded_app_instance(monkeypatch): + fake_app = object() + + class _Settings: + webui_host = "127.0.0.1" + webui_port = 1455 + debug = False + + fake_module = types.ModuleType("src.web.app") + fake_module.app = fake_app + captured = {} + + monkeypatch.setattr(webui, "setup_application", lambda: _Settings()) + monkeypatch.setitem(sys.modules, "src.web.app", fake_module) + monkeypatch.setattr(webui.uvicorn, "run", lambda **kwargs: captured.update(kwargs)) + + webui.start_webui() + + assert captured["app"] is fake_app + assert captured["host"] == "127.0.0.1" + assert captured["port"] == 1455 + + +def test_main_prefers_port_environment_variable(monkeypatch): + captured = {} + + monkeypatch.setenv("PORT", "7860") + monkeypatch.delenv("WEBUI_PORT", raising=False) + monkeypatch.delenv("APP_PORT", raising=False) + monkeypatch.delenv("WEBUI_HOST", raising=False) + monkeypatch.delenv("APP_HOST", raising=False) + monkeypatch.setattr(sys, "argv", ["webui.py"]) + monkeypatch.setattr(webui, "initialize_database", lambda: None) + monkeypatch.setattr(webui, "start_webui", lambda: None) + + import src.config.settings as settings_module + monkeypatch.setattr(settings_module, "update_settings", lambda **kwargs: captured.update(kwargs)) + + webui.main() + + assert captured["webui_port"] == 7860 + + +def test_main_uses_webui_env_when_port_missing(monkeypatch): + captured = {} + + monkeypatch.delenv("PORT", raising=False) + monkeypatch.setenv("WEBUI_PORT", "8080") + monkeypatch.setenv("WEBUI_HOST", "0.0.0.0") + monkeypatch.delenv("APP_PORT", raising=False) + monkeypatch.delenv("APP_HOST", raising=False) + monkeypatch.setattr(sys, "argv", ["webui.py"]) + monkeypatch.setattr(webui, "initialize_database", lambda: None) + monkeypatch.setattr(webui, "start_webui", lambda: None) + + import src.config.settings as settings_module + monkeypatch.setattr(settings_module, "update_settings", lambda **kwargs: captured.update(kwargs)) + + webui.main() + + assert captured["webui_host"] == "0.0.0.0" + assert captured["webui_port"] == 8080 + + +def test_setup_application_refreshes_stale_settings_cache(monkeypatch): + from src.database.init_db import initialize_database + + temp_root = Path(tempfile.mkdtemp()) + try: + data_dir = temp_root / "data" + logs_dir = temp_root / "logs" + data_dir.mkdir() + logs_dir.mkdir() + + monkeypatch.setattr(webui, "project_root", temp_root) + monkeypatch.setenv("APP_DATA_DIR", str(data_dir)) + monkeypatch.setenv("APP_LOGS_DIR", str(logs_dir)) + monkeypatch.setattr(db_session, "_db_manager", None) + monkeypatch.setattr(settings_module, "_settings", None) + monkeypatch.setattr(webui, "setup_logging", lambda **kwargs: None) + + initialize_database() + settings_module.update_settings( + external_api_enabled=True, + external_api_key="test-external-key", + ) + + monkeypatch.setattr( + settings_module, + "_settings", + settings_module.Settings(external_api_enabled=False), + ) + + settings = webui.setup_application() + + assert settings.external_api_enabled is True + assert settings.external_api_key.get_secret_value() == "test-external-key" + db_session._db_manager.engine.dispose() + monkeypatch.setattr(db_session, "_db_manager", None) + finally: + shutil.rmtree(temp_root, ignore_errors=True) diff --git a/tests/test_external_api_routes.py b/tests/test_external_api_routes.py new file mode 100644 index 0000000000000000000000000000000000000000..3ee5db1d9103f398d9dd528be5a9b12c5621e74d --- /dev/null +++ b/tests/test_external_api_routes.py @@ -0,0 +1,385 @@ +from types import SimpleNamespace +from contextlib import contextmanager + +import pytest +from fastapi import FastAPI +from fastapi import HTTPException +from fastapi.testclient import TestClient + +from src.core.external_batches.service import ExternalBatchCreateRequest, ExternalBatchService +from src.database import external_batch_crud +from src.database.models import Base, EmailService +from src.database.session import DatabaseSessionManager +import src.web.routes.external as external_router_package +import src.web.routes.external.capabilities as capabilities_routes +import src.web.routes.external.registration as registration_routes +import src.web.deps.external_auth as external_auth + + +def _settings(enabled: bool, api_key: str = "test-key"): + return SimpleNamespace( + external_api_enabled=enabled, + external_api_key=SimpleNamespace(get_secret_value=lambda: api_key), + ) + + +def _client(monkeypatch): + monkeypatch.setattr(external_auth, "get_settings", lambda: _settings(True, "abc")) + app = FastAPI() + app.include_router(external_router_package.router, prefix="/external") + return TestClient(app) + + +def _make_db(name: str): + from pathlib import Path + + runtime_dir = Path("tests_runtime") + runtime_dir.mkdir(exist_ok=True) + db_path = runtime_dir / name + if db_path.exists(): + db_path.unlink() + manager = DatabaseSessionManager(f"sqlite:///{db_path}") + Base.metadata.create_all(bind=manager.engine) + return manager + + +def _seed_temp_mail(session): + session.add(EmailService(service_type="temp_mail", name="tm-1", config={"domain": "a.test"}, enabled=True, priority=0)) + + +def test_external_auth_requires_api_key(monkeypatch): + monkeypatch.setattr(external_auth, "get_settings", lambda: _settings(True, "abc")) + + with pytest.raises(HTTPException) as exc: + external_auth.require_external_api_key(x_api_key=None) + + assert exc.value.status_code == 401 + + +def test_external_auth_rejects_when_disabled(monkeypatch): + monkeypatch.setattr(external_auth, "get_settings", lambda: _settings(False, "abc")) + + with pytest.raises(HTTPException) as exc: + external_auth.require_external_api_key(x_api_key="abc") + + assert exc.value.status_code == 403 + + +def test_external_auth_accepts_valid_api_key(monkeypatch): + monkeypatch.setattr(external_auth, "get_settings", lambda: _settings(True, "abc")) + + assert external_auth.require_external_api_key(x_api_key="abc") is None + + +def test_external_capabilities_route_uses_adapter(monkeypatch): + monkeypatch.setattr( + capabilities_routes, + "_get_external_capabilities", + lambda: {"email_types": [{"type": "temp_mail", "available": True}], "upload_providers": []}, + ) + + response = capabilities_routes.get_external_capabilities() + + assert response["email_types"][0]["type"] == "temp_mail" + + +def test_external_registration_routes_use_service_adapter(monkeypatch): + created = {"batch_uuid": "b-1", "status": "pending", "requested_count": 2, "idempotent_replay": False} + + monkeypatch.setattr(registration_routes, "_create_external_batch", lambda payload, background_tasks=None: created) + monkeypatch.setattr(registration_routes, "_get_external_batch", lambda batch_uuid: {"batch_uuid": batch_uuid, "status": "running"}) + monkeypatch.setattr(registration_routes, "_cancel_external_batch", lambda batch_uuid: {"batch_uuid": batch_uuid, "status": "cancelled"}) + + request = registration_routes.ExternalBatchCreateRequest( + count=2, + email={"type": "temp_mail"}, + upload={"enabled": False}, + execution={"mode": "pipeline", "concurrency": 1, "interval_min": 0, "interval_max": 1}, + ) + + create_resp = registration_routes.create_external_registration_batch(request) + status_resp = registration_routes.get_external_registration_batch("b-1") + cancel_resp = registration_routes.cancel_external_registration_batch("b-1") + + assert create_resp["batch_uuid"] == "b-1" + assert status_resp["status"] == "running" + assert cancel_resp["status"] == "cancelled" + + +def test_external_registration_create_rejects_invalid_interval_bounds(monkeypatch): + client = _client(monkeypatch) + + response = client.post( + "/external/registration/batches", + headers={"X-API-Key": "abc"}, + json={ + "count": 2, + "email": {"type": "temp_mail"}, + "upload": {"enabled": False}, + "execution": {"mode": "pipeline", "concurrency": 1, "interval_min": 5, "interval_max": 2}, + }, + ) + + assert response.status_code == 422 + assert "interval_max" in response.text + + +def test_external_registration_create_requires_upload_provider_when_enabled(monkeypatch): + client = _client(monkeypatch) + + response = client.post( + "/external/registration/batches", + headers={"X-API-Key": "abc"}, + json={ + "count": 1, + "email": {"type": "temp_mail"}, + "upload": {"enabled": True}, + "execution": {"mode": "pipeline", "concurrency": 1, "interval_min": 0, "interval_max": 0}, + }, + ) + + assert response.status_code == 422 + assert "upload.provider" in response.text + + +def test_external_registration_create_rejects_extra_fields(monkeypatch): + client = _client(monkeypatch) + + response = client.post( + "/external/registration/batches", + headers={"X-API-Key": "abc"}, + json={ + "count": 1, + "email": {"type": "temp_mail", "unexpected": True}, + "upload": {"enabled": False}, + "execution": {"mode": "pipeline", "concurrency": 1, "interval_min": 0, "interval_max": 0}, + }, + ) + + assert response.status_code == 422 + assert "unexpected" in response.text + + +def test_external_registration_create_returns_200_for_idempotent_replay(monkeypatch): + monkeypatch.setattr( + registration_routes, + "_create_external_batch", + lambda payload, background_tasks=None: { + "batch_uuid": "b-1", + "status": "pending", + "requested_count": 1, + "idempotent_replay": True, + }, + ) + client = _client(monkeypatch) + + response = client.post( + "/external/registration/batches", + headers={"X-API-Key": "abc"}, + json={ + "count": 1, + "idempotency_key": "same-batch", + "email": {"type": "temp_mail"}, + "upload": {"enabled": False}, + "execution": {"mode": "pipeline", "concurrency": 1, "interval_min": 0, "interval_max": 0}, + }, + ) + + assert response.status_code == 200 + assert response.json()["idempotent_replay"] is True + + +def test_external_registration_status_maps_batch_not_found_to_404(monkeypatch): + monkeypatch.setattr(registration_routes, "_get_external_batch", lambda batch_uuid: (_ for _ in ()).throw(ValueError("batch_not_found"))) + client = _client(monkeypatch) + + response = client.get("/external/registration/batches/missing", headers={"X-API-Key": "abc"}) + + assert response.status_code == 404 + assert response.json()["detail"] == "batch_not_found" + + +def test_external_registration_create_maps_service_unavailable_to_503(monkeypatch): + monkeypatch.setattr(registration_routes, "_create_external_batch", lambda payload, background_tasks=None: (_ for _ in ()).throw(RuntimeError("external_batch_service_unavailable"))) + client = _client(monkeypatch) + + response = client.post( + "/external/registration/batches", + headers={"X-API-Key": "abc"}, + json={ + "count": 1, + "email": {"type": "temp_mail"}, + "upload": {"enabled": False}, + "execution": {"mode": "pipeline", "concurrency": 1, "interval_min": 0, "interval_max": 0}, + }, + ) + + assert response.status_code == 503 + assert response.json()["detail"] == "external_batch_service_unavailable" + + +def test_external_registration_cancel_maps_finished_batch_to_400(monkeypatch): + monkeypatch.setattr(registration_routes, "_cancel_external_batch", lambda batch_uuid: (_ for _ in ()).throw(ValueError("batch is already finished"))) + client = _client(monkeypatch) + + response = client.post("/external/registration/batches/b-1/cancel", headers={"X-API-Key": "abc"}) + + assert response.status_code == 400 + assert response.json()["detail"] == "batch is already finished" + + +def test_external_registration_create_returns_failure_category(monkeypatch): + monkeypatch.setattr( + registration_routes, + "_create_external_batch", + lambda payload, background_tasks=None: { + "batch_uuid": "b-create", + "status": "pending", + "requested_count": 1, + "failure_category": None, + "idempotent_replay": False, + }, + ) + client = _client(monkeypatch) + + response = client.post( + "/external/registration/batches", + headers={"X-API-Key": "abc"}, + json={ + "count": 1, + "email": {"type": "temp_mail"}, + "upload": {"enabled": False}, + "execution": {"mode": "pipeline", "concurrency": 1, "interval_min": 0, "interval_max": 0}, + }, + ) + + assert response.status_code == 202 + assert "failure_category" in response.json() + assert response.json()["failure_category"] is None + + +def test_external_registration_get_returns_failure_category(monkeypatch): + monkeypatch.setattr( + registration_routes, + "_get_external_batch", + lambda batch_uuid: { + "batch_uuid": batch_uuid, + "status": "failed", + "failure_category": "transient", + }, + ) + client = _client(monkeypatch) + batch_uuid = "b-get" + + response = client.get(f"/external/registration/batches/{batch_uuid}", headers={"X-API-Key": "abc"}) + + assert response.status_code == 200 + assert response.json()["batch_uuid"] == batch_uuid + assert "failure_category" in response.json() + assert response.json()["failure_category"] == "transient" + + +def test_external_registration_cancel_returns_failure_category(monkeypatch): + monkeypatch.setattr( + registration_routes, + "_cancel_external_batch", + lambda batch_uuid: { + "batch_uuid": batch_uuid, + "status": "cancelled", + "failure_category": None, + }, + ) + client = _client(monkeypatch) + batch_uuid = "b-cancel" + + response = client.post(f"/external/registration/batches/{batch_uuid}/cancel", headers={"X-API-Key": "abc"}) + + assert response.status_code == 200 + assert "failure_category" in response.json() + assert response.json()["failure_category"] is None + + +def test_external_registration_idempotent_replay_returns_failure_category(monkeypatch): + monkeypatch.setattr( + registration_routes, + "_create_external_batch", + lambda payload, background_tasks=None: { + "batch_uuid": "b-idempotent", + "status": "failed", + "requested_count": 1, + "failure_category": "transient", + "idempotent_replay": True, + }, + ) + client = _client(monkeypatch) + payload = { + "count": 1, + "idempotency_key": "unique-key", + "email": {"type": "temp_mail"}, + "upload": {"enabled": False}, + "execution": {"mode": "pipeline", "concurrency": 1, "interval_min": 0, "interval_max": 0}, + } + + response = client.post("/external/registration/batches", headers={"X-API-Key": "abc"}, json=payload) + + assert response.status_code == 200 + assert response.json()["idempotent_replay"] is True + assert response.json()["failure_category"] == "transient" + + +def test_external_registration_get_backfills_legacy_failed_batch_failure_category(monkeypatch): + manager = _make_db("external_api_routes_backfill_get.db") + service = ExternalBatchService() + with manager.session_scope() as session: + _seed_temp_mail(session) + + request = ExternalBatchCreateRequest( + count=1, + idempotency_key=None, + email_type="temp_mail", + email_service_id=None, + upload_enabled=False, + upload_provider=None, + upload_service_id=None, + mode="pipeline", + concurrency=1, + interval_min=0, + interval_max=0, + ) + + with manager.session_scope() as session: + batch = service.create_batch(session, request) + item = external_batch_crud.list_batch_items(session, batch.batch_uuid)[0] + external_batch_crud.update_batch( + session, + batch.batch_uuid, + status="failed", + failure_reason=None, + failure_category=None, + ) + external_batch_crud.update_batch_item( + session, + item.id, + status="failed", + failure_reason="no_available_email_service", + ) + + @contextmanager + def _db_context(): + session = manager.SessionLocal() + try: + yield session + finally: + session.close() + + monkeypatch.setattr("src.database.session.get_db", _db_context) + client = _client(monkeypatch) + + response = client.get(f"/external/registration/batches/{batch.batch_uuid}", headers={"X-API-Key": "abc"}) + + with manager.session_scope() as session: + refreshed = external_batch_crud.get_batch_by_uuid(session, batch.batch_uuid) + + assert response.status_code == 200 + assert response.json()["failure_category"] == "transient" + assert refreshed.failure_category == "transient" diff --git a/tests/test_external_api_settings.py b/tests/test_external_api_settings.py new file mode 100644 index 0000000000000000000000000000000000000000..becacea37e7f1e6d45762dd68984629493d22432 --- /dev/null +++ b/tests/test_external_api_settings.py @@ -0,0 +1,28 @@ +from src.config.settings import SETTING_DEFINITIONS, get_settings, update_settings + + +def test_external_api_settings_definitions_exist(): + assert "external_api_enabled" in SETTING_DEFINITIONS + assert SETTING_DEFINITIONS["external_api_enabled"].db_key == "external_api.enabled" + + assert "external_api_key" in SETTING_DEFINITIONS + assert SETTING_DEFINITIONS["external_api_key"].db_key == "external_api.key" + assert SETTING_DEFINITIONS["external_api_key"].is_secret is True + + +def test_update_settings_supports_external_api_fields(): + current = get_settings() + old_enabled = getattr(current, "external_api_enabled", False) + old_key = getattr(current, "external_api_key", None) + old_key_value = old_key.get_secret_value() if old_key else "" + + try: + settings = update_settings(external_api_enabled=True, external_api_key="demo-key") + + assert settings.external_api_enabled is True + assert settings.external_api_key.get_secret_value() == "demo-key" + + latest = get_settings() + assert latest.external_api_enabled is True + finally: + update_settings(external_api_enabled=old_enabled, external_api_key=old_key_value) diff --git a/tests/test_external_batch_service.py b/tests/test_external_batch_service.py new file mode 100644 index 0000000000000000000000000000000000000000..da86db4fb31acfd61fcac6c6689d4418f75010d4 --- /dev/null +++ b/tests/test_external_batch_service.py @@ -0,0 +1,1096 @@ + +from contextlib import contextmanager +from datetime import datetime +from pathlib import Path +from types import SimpleNamespace +import sqlite3 + +import pytest + +from src.database.models import Base, Account, EmailService, RegistrationTask, Sub2ApiService +from src.database.session import DatabaseSessionManager +from src.database import external_batch_crud +import src.core.external_batches.service as external_batch_service_module +import src.web.routes.registration as registration_routes +from src.core.external_batches.service import ExternalBatchCreateRequest, ExternalBatchService +from src.core.external_batches.recovery import recover_interrupted_external_batches + + +def _make_db(name: str): + runtime_dir = Path("tests_runtime") + runtime_dir.mkdir(exist_ok=True) + db_path = runtime_dir / name + if db_path.exists(): + db_path.unlink() + manager = DatabaseSessionManager(f"sqlite:///{db_path}") + Base.metadata.create_all(bind=manager.engine) + return manager + + +def _seed_temp_mail(session): + session.add(EmailService(service_type="temp_mail", name="tm-1", config={"domain": "a.test"}, enabled=True, priority=0)) + + +@contextmanager +def _session_context(manager): + session = manager.SessionLocal() + try: + yield session + finally: + session.close() + + +def _finalize_batch_with_failure(session, service, batch_uuid, failure_reason, item_reason=None): + external_batch_crud.update_batch(session, batch_uuid, failure_reason=failure_reason) + items = external_batch_crud.list_batch_items(session, batch_uuid) + for item in items: + external_batch_crud.update_batch_item( + session, + item.id, + status="failed", + failure_reason=item_reason or failure_reason, + ) + return service.recompute_summary(session, batch_uuid) + + +def _run_scenario_and_assert_categories(manager, scenario): + service = ExternalBatchService() + with manager.session_scope() as session: + _seed_temp_mail(session) + request = ExternalBatchCreateRequest( + count=2, + idempotency_key=None, + email_type="temp_mail", + email_service_id=None, + upload_enabled=False, + upload_provider=None, + upload_service_id=None, + mode="pipeline", + concurrency=1, + interval_min=0, + interval_max=0, + ) + + with manager.session_scope() as session: + batch = service.create_batch(session, request) + scenario(session, batch.batch_uuid) + refreshed = service.recompute_summary(session, batch.batch_uuid) + return refreshed + + +def test_create_batch_persists_batch_and_items_and_is_idempotent(): + manager = _make_db("external_batch_create.db") + service = ExternalBatchService() + with manager.session_scope() as session: + _seed_temp_mail(session) + + request = ExternalBatchCreateRequest( + count=3, + idempotency_key="batch-001", + email_type="temp_mail", + email_service_id=None, + upload_enabled=False, + upload_provider=None, + upload_service_id=None, + mode="pipeline", + concurrency=1, + interval_min=1, + interval_max=2, + ) + + with manager.session_scope() as session: + first = service.create_batch(session, request) + second = service.create_batch(session, request) + items = external_batch_crud.list_batch_items(session, first.batch_uuid) + + assert first.id == second.id + assert first.batch_uuid == second.batch_uuid + assert len(items) == 3 + assert [item.item_index for item in items] == [0, 1, 2] + assert all(item.registration_task_uuid for item in items) + + +def test_recompute_summary_marks_completed_partial_and_counts_uploads(): + manager = _make_db("external_batch_summary.db") + service = ExternalBatchService() + with manager.session_scope() as session: + _seed_temp_mail(session) + session.add(Sub2ApiService(name="sub2-1", api_url="https://sub2.test", api_key="k1", enabled=True, priority=0)) + + request = ExternalBatchCreateRequest( + count=3, + idempotency_key=None, + email_type="temp_mail", + email_service_id=None, + upload_enabled=True, + upload_provider="sub2api", + upload_service_id=1, + mode="pipeline", + concurrency=1, + interval_min=1, + interval_max=1, + ) + + with manager.session_scope() as session: + batch = service.create_batch(session, request) + items = external_batch_crud.list_batch_items(session, batch.batch_uuid) + external_batch_crud.update_batch_item(session, items[0].id, status="completed", upload_status="success") + external_batch_crud.update_batch_item(session, items[1].id, status="failed", failure_reason="no_available_email_service", upload_status="skipped") + external_batch_crud.update_batch_item(session, items[2].id, status="completed", upload_status="failed", upload_error="upload_failed") + refreshed = service.recompute_summary(session, batch.batch_uuid) + + assert refreshed.status == "completed_partial" + assert refreshed.completed_count == 3 + assert refreshed.success_count == 2 + assert refreshed.failed_count == 1 + assert refreshed.upload_success_count == 1 + assert refreshed.upload_failed_count == 1 + + +def test_recovery_marks_interrupted_batches_and_registration_tasks_failed(): + manager = _make_db("external_batch_recovery.db") + service = ExternalBatchService() + with manager.session_scope() as session: + _seed_temp_mail(session) + + request = ExternalBatchCreateRequest( + count=2, + idempotency_key=None, + email_type="temp_mail", + email_service_id=None, + upload_enabled=False, + upload_provider=None, + upload_service_id=None, + mode="parallel", + concurrency=2, + interval_min=0, + interval_max=0, + ) + + with manager.session_scope() as session: + batch = service.create_batch(session, request) + items = external_batch_crud.list_batch_items(session, batch.batch_uuid) + external_batch_crud.update_batch(session, batch.batch_uuid, status="running") + external_batch_crud.update_batch_item(session, items[0].id, status="running") + external_batch_crud.update_batch_item(session, items[1].id, status="pending") + session.query(RegistrationTask).filter(RegistrationTask.task_uuid == items[0].registration_task_uuid).update({"status": "running"}) + session.query(RegistrationTask).filter(RegistrationTask.task_uuid == items[1].registration_task_uuid).update({"status": "pending"}) + + with manager.session_scope() as session: + recover_interrupted_external_batches(session) + refreshed = external_batch_crud.get_batch_by_uuid(session, batch.batch_uuid) + refreshed_items = external_batch_crud.list_batch_items(session, batch.batch_uuid) + reg_tasks = {task.task_uuid: task for task in session.query(RegistrationTask).all()} + + assert refreshed.status == "failed" + assert refreshed.failure_reason == "service_restarted" + assert refreshed.failure_category == "transient" + assert all(item.status == "failed" for item in refreshed_items) + assert all(item.failure_reason == "service_restarted" for item in refreshed_items) + assert all(reg_tasks[item.registration_task_uuid].status == "failed" for item in refreshed_items) + + +def test_run_batch_pipeline_completes_items_and_uploads(monkeypatch): + manager = _make_db("external_batch_run_pipeline.db") + service = ExternalBatchService() + with manager.session_scope() as session: + _seed_temp_mail(session) + session.add(Sub2ApiService(name="sub2-1", api_url="https://sub2.test", api_key="k1", enabled=True, priority=0)) + + request = ExternalBatchCreateRequest( + count=2, + idempotency_key=None, + email_type="temp_mail", + email_service_id=None, + upload_enabled=True, + upload_provider="sub2api", + upload_service_id=1, + mode="pipeline", + concurrency=1, + interval_min=0, + interval_max=0, + ) + + with manager.session_scope() as session: + batch = service.create_batch(session, request) + + monkeypatch.setattr("src.database.session.get_db", lambda: _session_context(manager)) + + async def _fake_run_registration_task(task_uuid, *_args, **_kwargs): + with manager.session_scope() as session: + task = session.query(RegistrationTask).filter(RegistrationTask.task_uuid == task_uuid).first() + task.status = "completed" + task.result = {"email": f"{task_uuid}@example.com", "account_id": f"acc-{task_uuid}"} + session.add( + Account( + email=f"{task_uuid}@example.com", + password="pw", + account_id=f"acc-{task_uuid}", + email_service="temp_mail", + email_service_id="svc-1", + status="active", + source="register", + ) + ) + + monkeypatch.setattr(registration_routes, "run_registration_task", _fake_run_registration_task) + monkeypatch.setattr( + external_batch_service_module, + "upload_registered_account", + lambda account, target: SimpleNamespace(success=True, message="ok", account=account, target=target), + ) + + import asyncio + + asyncio.run(service.run_batch(batch.batch_uuid)) + + with manager.session_scope() as session: + refreshed = external_batch_crud.get_batch_by_uuid(session, batch.batch_uuid) + items = external_batch_crud.list_batch_items(session, batch.batch_uuid) + + assert refreshed.status == "completed" + assert refreshed.success_count == 2 + assert refreshed.upload_success_count == 2 + assert all(item.status == "completed" for item in items) + assert all(item.upload_status == "success" for item in items) + + +def test_run_batch_marks_prefailed_item_without_calling_registration(monkeypatch): + manager = _make_db("external_batch_prefailed_item.db") + service = ExternalBatchService() + with manager.session_scope() as session: + _seed_temp_mail(session) + + request = ExternalBatchCreateRequest( + count=1, + idempotency_key=None, + email_type="temp_mail", + email_service_id=None, + upload_enabled=False, + upload_provider=None, + upload_service_id=None, + mode="pipeline", + concurrency=1, + interval_min=0, + interval_max=0, + ) + + with manager.session_scope() as session: + batch = service.create_batch(session, request) + item = external_batch_crud.list_batch_items(session, batch.batch_uuid)[0] + external_batch_crud.update_batch_item(session, item.id, failure_reason="no_available_email_service") + task_uuid = item.registration_task_uuid + + monkeypatch.setattr("src.database.session.get_db", lambda: _session_context(manager)) + + calls = {"count": 0} + + async def _fake_run_registration_task(*_args, **_kwargs): + calls["count"] += 1 + + monkeypatch.setattr(registration_routes, "run_registration_task", _fake_run_registration_task) + + import asyncio + + asyncio.run(service.run_batch(batch.batch_uuid)) + + with manager.session_scope() as session: + refreshed = external_batch_crud.get_batch_by_uuid(session, batch.batch_uuid) + item = external_batch_crud.list_batch_items(session, batch.batch_uuid)[0] + task = session.query(RegistrationTask).filter(RegistrationTask.task_uuid == task_uuid).first() + + assert calls["count"] == 0 + assert refreshed.status == "failed" + assert item.status == "failed" + assert item.failure_reason == "no_available_email_service" + assert task.status == "failed" + assert task.error_message == "no_available_email_service" + + +def test_run_batch_uses_account_id_fallback_for_upload_lookup(monkeypatch): + manager = _make_db("external_batch_account_id_fallback.db") + service = ExternalBatchService() + with manager.session_scope() as session: + _seed_temp_mail(session) + session.add(Sub2ApiService(name="sub2-1", api_url="https://sub2.test", api_key="k1", enabled=True, priority=0)) + + request = ExternalBatchCreateRequest( + count=1, + idempotency_key=None, + email_type="temp_mail", + email_service_id=None, + upload_enabled=True, + upload_provider="sub2api", + upload_service_id=1, + mode="pipeline", + concurrency=1, + interval_min=0, + interval_max=0, + ) + + with manager.session_scope() as session: + batch = service.create_batch(session, request) + + monkeypatch.setattr("src.database.session.get_db", lambda: _session_context(manager)) + + async def _fake_run_registration_task(task_uuid, *_args, **_kwargs): + with manager.session_scope() as session: + task = session.query(RegistrationTask).filter(RegistrationTask.task_uuid == task_uuid).first() + task.status = "completed" + task.result = {"account_id": f"acc-{task_uuid}"} + session.add( + Account( + email=f"stored-{task_uuid}@example.com", + password="pw", + account_id=f"acc-{task_uuid}", + email_service="temp_mail", + email_service_id="svc-1", + status="active", + source="register", + ) + ) + + seen = {} + + def _fake_upload(account, target): + seen["email"] = account.email if account else None + seen["provider"] = target.provider if target else None + return SimpleNamespace(success=False, message="upload_failed") + + monkeypatch.setattr(registration_routes, "run_registration_task", _fake_run_registration_task) + monkeypatch.setattr(external_batch_service_module, "upload_registered_account", _fake_upload) + + import asyncio + + asyncio.run(service.run_batch(batch.batch_uuid)) + + with manager.session_scope() as session: + refreshed = external_batch_crud.get_batch_by_uuid(session, batch.batch_uuid) + item = external_batch_crud.list_batch_items(session, batch.batch_uuid)[0] + + assert seen["provider"] == "sub2api" + assert seen["email"].startswith("stored-") + assert refreshed.status == "completed" + assert refreshed.upload_failed_count == 1 + assert item.upload_status == "failed" + assert item.upload_error == "upload_failed" + + +def test_request_cancel_rejects_finished_batch(): + manager = _make_db("external_batch_cancel_finished.db") + service = ExternalBatchService() + with manager.session_scope() as session: + _seed_temp_mail(session) + + request = ExternalBatchCreateRequest( + count=1, + idempotency_key=None, + email_type="temp_mail", + email_service_id=None, + upload_enabled=False, + upload_provider=None, + upload_service_id=None, + mode="pipeline", + concurrency=1, + interval_min=0, + interval_max=0, + ) + + with manager.session_scope() as session: + batch = service.create_batch(session, request) + external_batch_crud.update_batch(session, batch.batch_uuid, status="completed") + + with manager.session_scope() as session: + with pytest.raises(ValueError, match="already finished"): + service.request_cancel(session, batch.batch_uuid) + + +def test_recompute_summary_classifies_service_restarted_as_transient(): + manager = _make_db("external_batch_failure_category_service_restarted.db") + service = ExternalBatchService() + with manager.session_scope() as session: + _seed_temp_mail(session) + + request = ExternalBatchCreateRequest( + count=2, + idempotency_key=None, + email_type="temp_mail", + email_service_id=None, + upload_enabled=False, + upload_provider=None, + upload_service_id=None, + mode="pipeline", + concurrency=1, + interval_min=0, + interval_max=0, + ) + + with manager.session_scope() as session: + batch = service.create_batch(session, request) + refreshed = _finalize_batch_with_failure(session, service, batch.batch_uuid, "service_restarted") + + assert refreshed.failure_category == "transient" + + +def test_recompute_summary_classifies_config_reasons_as_config(): + manager = _make_db("external_batch_failure_category_config.db") + service = ExternalBatchService() + with manager.session_scope() as session: + _seed_temp_mail(session) + + request = ExternalBatchCreateRequest( + count=2, + idempotency_key=None, + email_type="temp_mail", + email_service_id=None, + upload_enabled=False, + upload_provider=None, + upload_service_id=None, + mode="pipeline", + concurrency=1, + interval_min=0, + interval_max=0, + ) + + reason = "no enabled upload services for provider sub2api" + with manager.session_scope() as session: + batch = service.create_batch(session, request) + refreshed = _finalize_batch_with_failure(session, service, batch.batch_uuid, reason) + + assert refreshed.failure_category == "config" + + +def test_recompute_summary_classifies_business_reasons_as_business(): + manager = _make_db("external_batch_failure_category_business.db") + service = ExternalBatchService() + with manager.session_scope() as session: + _seed_temp_mail(session) + + request = ExternalBatchCreateRequest( + count=2, + idempotency_key=None, + email_type="temp_mail", + email_service_id=None, + upload_enabled=False, + upload_provider=None, + upload_service_id=None, + mode="pipeline", + concurrency=1, + interval_min=0, + interval_max=0, + ) + + reason = "outlook requested_service_id cannot be reused when count > 1" + with manager.session_scope() as session: + batch = service.create_batch(session, request) + refreshed = _finalize_batch_with_failure(session, service, batch.batch_uuid, reason) + + assert refreshed.failure_category == "business" + + +def test_recompute_summary_classifies_no_available_email_service_as_transient(): + manager = _make_db("external_batch_failure_category_no_available.db") + service = ExternalBatchService() + with manager.session_scope() as session: + _seed_temp_mail(session) + + request = ExternalBatchCreateRequest( + count=2, + idempotency_key=None, + email_type="temp_mail", + email_service_id=None, + upload_enabled=False, + upload_provider=None, + upload_service_id=None, + mode="pipeline", + concurrency=1, + interval_min=0, + interval_max=0, + ) + + with manager.session_scope() as session: + batch = service.create_batch(session, request) + refreshed = _finalize_batch_with_failure( + session, + service, + batch.batch_uuid, + None, + item_reason="no_available_email_service", + ) + + assert refreshed.failure_category == "transient" + + +def test_recompute_summary_defaults_unknown_failure_to_transient(): + manager = _make_db("external_batch_failure_category_unknown.db") + service = ExternalBatchService() + with manager.session_scope() as session: + _seed_temp_mail(session) + + request = ExternalBatchCreateRequest( + count=2, + idempotency_key=None, + email_type="temp_mail", + email_service_id=None, + upload_enabled=False, + upload_provider=None, + upload_service_id=None, + mode="pipeline", + concurrency=1, + interval_min=0, + interval_max=0, + ) + + reason = "registration_failed" + with manager.session_scope() as session: + batch = service.create_batch(session, request) + refreshed = _finalize_batch_with_failure(session, service, batch.batch_uuid, reason) + + assert refreshed.failure_category == "transient" + + +def test_recompute_summary_uses_upload_error_as_transient_fallback(): + manager = _make_db("external_batch_failure_category_upload_error.db") + service = ExternalBatchService() + with manager.session_scope() as session: + _seed_temp_mail(session) + session.add(Sub2ApiService(name="sub2-upload-fallback", api_url="https://sub2.test", api_key="k1", enabled=True, priority=0)) + + request = ExternalBatchCreateRequest( + count=2, + idempotency_key=None, + email_type="temp_mail", + email_service_id=None, + upload_enabled=True, + upload_provider="sub2api", + upload_service_id=1, + mode="pipeline", + concurrency=1, + interval_min=0, + interval_max=0, + ) + + with manager.session_scope() as session: + batch = service.create_batch(session, request) + for item in external_batch_crud.list_batch_items(session, batch.batch_uuid): + external_batch_crud.update_batch_item( + session, + item.id, + status="failed", + upload_status="failed", + upload_error="provider timeout", + ) + refreshed = service.recompute_summary(session, batch.batch_uuid) + + assert refreshed.status == "failed" + assert refreshed.failure_category == "transient" + + +def test_recompute_summary_prefers_batch_failure_reason_for_classification(): + manager = _make_db("external_batch_failure_category_priority.db") + service = ExternalBatchService() + with manager.session_scope() as session: + _seed_temp_mail(session) + + request = ExternalBatchCreateRequest( + count=2, + idempotency_key=None, + email_type="temp_mail", + email_service_id=None, + upload_enabled=False, + upload_provider=None, + upload_service_id=None, + mode="pipeline", + concurrency=1, + interval_min=0, + interval_max=0, + ) + + with manager.session_scope() as session: + batch = service.create_batch(session, request) + external_batch_crud.update_batch( + session, + batch.batch_uuid, + failure_reason="unsupported upload provider: custom", + ) + for item in external_batch_crud.list_batch_items(session, batch.batch_uuid): + external_batch_crud.update_batch_item( + session, + item.id, + status="failed", + failure_reason="outlook requested_service_id cannot be reused when count > 1", + ) + refreshed = service.recompute_summary(session, batch.batch_uuid) + + assert refreshed.status == "failed" + assert refreshed.failure_category == "config" + + +def test_non_failed_statuses_keep_null_failure_category(): + manager = _make_db("external_batch_failure_category_non_failed.db") + + scenarios = [ + ("pending", lambda session, batch_uuid: None), + ( + "running", + lambda session, batch_uuid: ( + external_batch_crud.update_batch( + session, batch_uuid, started_at=datetime.utcnow() + ), + external_batch_crud.update_batch_item( + session, + external_batch_crud.list_batch_items(session, batch_uuid)[0].id, + status="running", + ), + ), + ), + ( + "completed", + lambda session, batch_uuid: [ + external_batch_crud.update_batch_item(session, item.id, status="completed") + for item in external_batch_crud.list_batch_items(session, batch_uuid) + ], + ), + ( + "completed_partial", + lambda session, batch_uuid: ( + external_batch_crud.update_batch_item( + session, + external_batch_crud.list_batch_items(session, batch_uuid)[0].id, + status="completed", + ), + external_batch_crud.update_batch_item( + session, + external_batch_crud.list_batch_items(session, batch_uuid)[1].id, + status="failed", + failure_reason="upload_failed", + ), + ), + ), + ( + "cancelled", + lambda session, batch_uuid: ( + external_batch_crud.update_batch(session, batch_uuid, cancel_requested=True), + [ + external_batch_crud.update_batch_item( + session, + item.id, + status="cancelled", + ) + for item in external_batch_crud.list_batch_items(session, batch_uuid) + ], + ), + ), + ] + + for expected_status, scenario in scenarios: + refreshed = _run_scenario_and_assert_categories(manager, scenario) + assert refreshed.status == expected_status + assert refreshed.failure_category is None + + +def test_sqlite_migration_adds_failure_category_column(): + runtime_dir = Path("tests_runtime") + runtime_dir.mkdir(exist_ok=True) + db_path = runtime_dir / "external_batch_failure_category_migration.db" + if db_path.exists(): + db_path.unlink() + + conn = sqlite3.connect(db_path) + columns = """ + id INTEGER PRIMARY KEY, + batch_uuid VARCHAR(36) NOT NULL, + status VARCHAR(32) NOT NULL, + failure_reason VARCHAR(255), + created_at DATETIME, + updated_at DATETIME + """ + conn.execute(f"CREATE TABLE external_registration_batches ({columns})") + conn.commit() + conn.close() + + manager = DatabaseSessionManager(f"sqlite:///{db_path}") + manager.migrate_tables() + manager.migrate_tables() + + with sqlite3.connect(db_path) as conn: + info = conn.execute("PRAGMA table_info('external_registration_batches')").fetchall() + column_names = [row[1] for row in info] + assert column_names.count("failure_category") == 1 + + +def test_create_external_registration_batch_replay_returns_persisted_failure_category(monkeypatch): + manager = _make_db("external_batch_failure_category_replay.db") + service = ExternalBatchService() + with manager.session_scope() as session: + _seed_temp_mail(session) + + request = ExternalBatchCreateRequest( + count=1, + idempotency_key="replay-key", + email_type="temp_mail", + email_service_id=None, + upload_enabled=False, + upload_provider=None, + upload_service_id=None, + mode="pipeline", + concurrency=1, + interval_min=0, + interval_max=0, + ) + + with manager.session_scope() as session: + batch = service.create_batch(session, request) + external_batch_crud.update_batch( + session, + batch.batch_uuid, + status="failed", + failure_reason="unsupported upload provider: custom", + failure_category="config", + ) + + monkeypatch.setattr("src.database.session.get_db", lambda: _session_context(manager)) + + replay = external_batch_service_module.create_external_registration_batch( + { + "count": 1, + "idempotency_key": "replay-key", + "email": {"type": "temp_mail"}, + "upload": {"enabled": False}, + "execution": {"mode": "pipeline", "concurrency": 1, "interval_min": 0, "interval_max": 0}, + }, + background_tasks=None, + ) + + assert replay["idempotent_replay"] is True + assert replay["failure_category"] == "config" + + +def test_get_external_registration_batch_status_backfills_missing_failure_category(monkeypatch): + manager = _make_db("external_batch_failure_category_backfill_get.db") + service = ExternalBatchService() + with manager.session_scope() as session: + _seed_temp_mail(session) + + request = ExternalBatchCreateRequest( + count=1, + idempotency_key=None, + email_type="temp_mail", + email_service_id=None, + upload_enabled=False, + upload_provider=None, + upload_service_id=None, + mode="pipeline", + concurrency=1, + interval_min=0, + interval_max=0, + ) + + with manager.session_scope() as session: + batch = service.create_batch(session, request) + external_batch_crud.update_batch( + session, + batch.batch_uuid, + status="failed", + failure_reason="unsupported upload provider: custom", + failure_category=None, + ) + + monkeypatch.setattr("src.database.session.get_db", lambda: _session_context(manager)) + + response = external_batch_service_module.get_external_registration_batch_status(batch.batch_uuid) + + with manager.session_scope() as session: + refreshed = external_batch_crud.get_batch_by_uuid(session, batch.batch_uuid) + + assert response["failure_category"] == "config" + assert refreshed.failure_category == "config" + + +def test_create_external_registration_batch_replay_backfills_missing_failure_category(monkeypatch): + manager = _make_db("external_batch_failure_category_backfill_replay.db") + service = ExternalBatchService() + with manager.session_scope() as session: + _seed_temp_mail(session) + + request = ExternalBatchCreateRequest( + count=1, + idempotency_key="replay-backfill-key", + email_type="temp_mail", + email_service_id=None, + upload_enabled=False, + upload_provider=None, + upload_service_id=None, + mode="pipeline", + concurrency=1, + interval_min=0, + interval_max=0, + ) + + with manager.session_scope() as session: + batch = service.create_batch(session, request) + external_batch_crud.update_batch( + session, + batch.batch_uuid, + status="failed", + failure_reason="unsupported upload provider: custom", + failure_category=None, + ) + + monkeypatch.setattr("src.database.session.get_db", lambda: _session_context(manager)) + + replay = external_batch_service_module.create_external_registration_batch( + { + "count": 1, + "idempotency_key": "replay-backfill-key", + "email": {"type": "temp_mail"}, + "upload": {"enabled": False}, + "execution": {"mode": "pipeline", "concurrency": 1, "interval_min": 0, "interval_max": 0}, + }, + background_tasks=None, + ) + + with manager.session_scope() as session: + refreshed = external_batch_crud.get_batch_by_uuid(session, batch.batch_uuid) + + assert replay["idempotent_replay"] is True + assert replay["failure_category"] == "config" + assert refreshed.failure_category == "config" + + +def test_get_external_registration_batch_status_backfills_from_item_failure_reason(monkeypatch): + manager = _make_db("external_batch_failure_category_backfill_item_reason.db") + service = ExternalBatchService() + with manager.session_scope() as session: + _seed_temp_mail(session) + + request = ExternalBatchCreateRequest( + count=1, + idempotency_key=None, + email_type="temp_mail", + email_service_id=None, + upload_enabled=False, + upload_provider=None, + upload_service_id=None, + mode="pipeline", + concurrency=1, + interval_min=0, + interval_max=0, + ) + + with manager.session_scope() as session: + batch = service.create_batch(session, request) + item = external_batch_crud.list_batch_items(session, batch.batch_uuid)[0] + external_batch_crud.update_batch( + session, + batch.batch_uuid, + status="failed", + failure_reason=None, + failure_category=None, + ) + external_batch_crud.update_batch_item( + session, + item.id, + status="failed", + failure_reason="no_available_email_service", + ) + + monkeypatch.setattr("src.database.session.get_db", lambda: _session_context(manager)) + + response = external_batch_service_module.get_external_registration_batch_status(batch.batch_uuid) + + assert response["failure_category"] == "transient" + + +def test_get_external_registration_batch_status_backfills_from_upload_error(monkeypatch): + manager = _make_db("external_batch_failure_category_backfill_upload_error.db") + service = ExternalBatchService() + with manager.session_scope() as session: + _seed_temp_mail(session) + + request = ExternalBatchCreateRequest( + count=1, + idempotency_key=None, + email_type="temp_mail", + email_service_id=None, + upload_enabled=False, + upload_provider=None, + upload_service_id=None, + mode="pipeline", + concurrency=1, + interval_min=0, + interval_max=0, + ) + + with manager.session_scope() as session: + batch = service.create_batch(session, request) + item = external_batch_crud.list_batch_items(session, batch.batch_uuid)[0] + external_batch_crud.update_batch( + session, + batch.batch_uuid, + status="failed", + failure_reason=None, + failure_category=None, + ) + external_batch_crud.update_batch_item( + session, + item.id, + status="failed", + upload_status="failed", + upload_error="provider timeout", + ) + + monkeypatch.setattr("src.database.session.get_db", lambda: _session_context(manager)) + + response = external_batch_service_module.get_external_registration_batch_status(batch.batch_uuid) + + assert response["failure_category"] == "transient" + + +def test_create_external_registration_batch_marks_replay_true_when_create_batch_returns_replay(monkeypatch): + fake_batch = SimpleNamespace( + batch_uuid="race-batch", + status="failed", + requested_count=1, + completed_count=1, + success_count=0, + failed_count=1, + upload_success_count=0, + upload_failed_count=0, + failure_reason="unsupported upload provider: custom", + failure_category=None, + created_at=None, + started_at=None, + completed_at=None, + recent_errors=[], + _idempotent_replay=True, + ) + + @contextmanager + def _noop_db(): + yield object() + + monkeypatch.setattr("src.database.session.get_db", _noop_db) + monkeypatch.setattr(external_batch_crud, "get_batch_by_idempotency_key", lambda db, key: None) + monkeypatch.setattr(ExternalBatchService, "create_batch", lambda self, db, request: fake_batch) + + def _ensure(self, db, batch): + batch.failure_category = "config" + return batch + + monkeypatch.setattr(ExternalBatchService, "ensure_failure_category", _ensure) + + response = external_batch_service_module.create_external_registration_batch( + { + "count": 1, + "idempotency_key": "race-key", + "email": {"type": "temp_mail"}, + "upload": {"enabled": False}, + "execution": {"mode": "pipeline", "concurrency": 1, "interval_min": 0, "interval_max": 0}, + }, + background_tasks=None, + ) + + assert response["idempotent_replay"] is True + assert response["failure_category"] == "config" + + +def test_create_external_registration_batch_marks_replay_true_for_non_failed_detached_batch(monkeypatch): + fake_batch = SimpleNamespace( + batch_uuid="race-batch-pending", + status="pending", + requested_count=1, + completed_count=0, + success_count=0, + failed_count=0, + upload_success_count=0, + upload_failed_count=0, + failure_reason=None, + failure_category=None, + created_at=None, + started_at=None, + completed_at=None, + recent_errors=[], + _idempotent_replay=True, + ) + + @contextmanager + def _noop_db(): + yield object() + + monkeypatch.setattr("src.database.session.get_db", _noop_db) + monkeypatch.setattr(external_batch_crud, "get_batch_by_idempotency_key", lambda db, key: None) + monkeypatch.setattr(ExternalBatchService, "create_batch", lambda self, db, request: fake_batch) + + response = external_batch_service_module.create_external_registration_batch( + { + "count": 1, + "idempotency_key": "race-key-pending", + "email": {"type": "temp_mail"}, + "upload": {"enabled": False}, + "execution": {"mode": "pipeline", "concurrency": 1, "interval_min": 0, "interval_max": 0}, + }, + background_tasks=None, + ) + + assert response["idempotent_replay"] is True + assert response["failure_category"] is None + + +def test_create_external_registration_batch_keeps_replay_flag_when_backfill_returns_new_batch(monkeypatch): + replay_batch = SimpleNamespace( + batch_uuid="race-batch-failed", + status="failed", + requested_count=1, + completed_count=1, + success_count=0, + failed_count=1, + upload_success_count=0, + upload_failed_count=0, + failure_reason="unsupported upload provider: custom", + failure_category=None, + created_at=None, + started_at=None, + completed_at=None, + recent_errors=[], + _idempotent_replay=True, + ) + updated_batch = SimpleNamespace( + batch_uuid="race-batch-failed", + status="failed", + requested_count=1, + completed_count=1, + success_count=0, + failed_count=1, + upload_success_count=0, + upload_failed_count=0, + failure_reason="unsupported upload provider: custom", + failure_category="config", + created_at=None, + started_at=None, + completed_at=None, + recent_errors=[], + ) + + @contextmanager + def _noop_db(): + yield object() + + monkeypatch.setattr("src.database.session.get_db", _noop_db) + monkeypatch.setattr(external_batch_crud, "get_batch_by_idempotency_key", lambda db, key: None) + monkeypatch.setattr(ExternalBatchService, "create_batch", lambda self, db, request: replay_batch) + monkeypatch.setattr(ExternalBatchService, "ensure_failure_category", lambda self, db, batch: updated_batch) + + response = external_batch_service_module.create_external_registration_batch( + { + "count": 1, + "idempotency_key": "race-key-failed", + "email": {"type": "temp_mail"}, + "upload": {"enabled": False}, + "execution": {"mode": "pipeline", "concurrency": 1, "interval_min": 0, "interval_max": 0}, + }, + background_tasks=None, + ) + + assert response["idempotent_replay"] is True + assert response["failure_category"] == "config" diff --git a/tests/test_registration_available_services_temp_mail.py b/tests/test_registration_available_services_temp_mail.py new file mode 100644 index 0000000000000000000000000000000000000000..64f67943b70a942c2b79841987b928c7db9e579c --- /dev/null +++ b/tests/test_registration_available_services_temp_mail.py @@ -0,0 +1,100 @@ +from contextlib import contextmanager +from pathlib import Path + +from src.database.models import Base, EmailService +from src.database.session import DatabaseSessionManager +from src.services.temp_mail import TempMailService +from src.web.routes import registration as registration_routes + + +class DummySettings: + custom_domain_base_url = "" + custom_domain_api_key = None + + +@contextmanager +def _session_context(manager): + session = manager.SessionLocal() + try: + yield session + finally: + session.close() + + +def _run_coroutine_without_awaits(coro): + try: + coro.send(None) + except StopIteration as exc: + return exc.value + raise AssertionError("coroutine unexpectedly awaited") + + +def test_registration_available_services_exposes_temp_mail_domain_pool(monkeypatch): + runtime_dir = Path("tests_runtime") + runtime_dir.mkdir(exist_ok=True) + db_path = runtime_dir / "registration_available_temp_mail.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="temp_mail", + name="Worker temp mail", + config={"domains": ["alpha.example.com", "beta.example.com"]}, + enabled=True, + priority=0, + ) + ) + + monkeypatch.setattr(registration_routes, "get_db", lambda: _session_context(manager)) + monkeypatch.setattr(registration_routes, "get_settings", lambda: DummySettings()) + + payload = _run_coroutine_without_awaits(registration_routes.get_available_email_services()) + + service = payload["temp_mail"]["services"][0] + assert payload["temp_mail"]["available"] is True + assert payload["temp_mail"]["count"] == 1 + assert service["name"] == "Worker temp mail" + assert service["domain_count"] == 2 + assert service["domains_preview"] == ["alpha.example.com", "beta.example.com"] + assert service["domain_source"] == "config_domains" + + +def test_registration_available_services_uses_worker_domain_pool_when_config_pool_missing(monkeypatch): + runtime_dir = Path("tests_runtime") + runtime_dir.mkdir(exist_ok=True) + db_path = runtime_dir / "registration_available_temp_mail_worker.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="temp_mail", + name="Worker-backed temp mail", + config={ + "base_url": "https://apmail.889110.xyz", + "admin_password": "admin888", + "domain": "fallback.example.com", + }, + enabled=True, + priority=0, + ) + ) + + monkeypatch.setattr(registration_routes, "get_db", lambda: _session_context(manager)) + monkeypatch.setattr(registration_routes, "get_settings", lambda: DummySettings()) + monkeypatch.setattr(TempMailService, "_fetch_domains_from_worker", lambda self: {"domains": ["worker-a.example.com", "worker-b.example.com"]}) + + payload = _run_coroutine_without_awaits(registration_routes.get_available_email_services()) + + service = payload["temp_mail"]["services"][0] + assert service["domain_source"] == "worker_api" + assert service["domains_preview"] == ["worker-a.example.com", "worker-b.example.com"] diff --git a/tests/test_registration_engine.py b/tests/test_registration_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..184bc44bfd1d4afb4094103e99631565705bc958 --- /dev/null +++ b/tests/test_registration_engine.py @@ -0,0 +1,3998 @@ +import base64 +import json +import urllib.parse +from contextlib import contextmanager +from datetime import datetime +from pathlib import Path + +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 +from src.services.outlook.base import EmailMessage +from src.services.outlook.service import OutlookService + + +class DummyResponse: + def __init__(self, status_code=200, payload=None, text="", headers=None, on_return=None, url="", history=None): + self.status_code = status_code + self._payload = payload + self.text = text + self.headers = headers or {} + self.on_return = on_return + self.url = url + self.history = history or [] + + 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"(?' + return DummyResponse( + status_code=200, + payload={}, + text=text, + url="https://auth.example.test/email-verification", + on_return=setter, + ) + + +def _response_with_about_you_next_data_callback( + callback_url="http://localhost:1455/auth/callback?code=code-next-data&state=state-1", +): + encoded_callback = callback_url.replace(":", "\\u003A").replace("/", "\\u002F") + text = ( + '' + ) + return DummyResponse( + status_code=200, + text=text, + url="https://auth.example.test/about-you", + ) + + +def _response_with_about_you_next_data_resume( + resume_url="https://auth.example.test/api/oauth/oauth2/auth?client_id=client-1&state=resume-next", +): + encoded_resume = resume_url.replace(":", "\\u003A").replace("/", "\\u002F") + text = ( + '' + ) + return DummyResponse( + status_code=200, + text=text, + url="https://auth.example.test/about-you", + ) + + +def _response_with_consent_next_data_workspace( + workspace_id="ws-consent-next-data", + session_token="session-consent-next-data", +): + payload = { + "props": { + "pageProps": { + "session": { + "workspaces": [{"id": workspace_id}], + }, + "page": {"type": "sign_in_with_chatgpt_codex_consent"}, + } + } + } + + def setter(session): + session.cookies["oai-client-auth-session"] = _workspace_cookie_without_workspace() + session.cookies["__Secure-next-auth.session-token"] = session_token + + text = ( + '" + ) + return DummyResponse( + status_code=200, + text=text, + url="https://auth.openai.com/sign-in-with-chatgpt/codex/consent", + on_return=setter, + ) + + +def _response_with_consent_app_router_workspace( + workspace_id="ws-consent-app-router", + session_token="session-consent-app-router", +): + payload_fragment = json.dumps( + { + "session": { + "workspaces": [{"id": workspace_id}], + }, + "page": {"type": "sign_in_with_chatgpt_codex_consent"}, + } + ) + + def setter(session): + session.cookies["oai-client-auth-session"] = _workspace_cookie_without_workspace() + session.cookies["__Secure-next-auth.session-token"] = session_token + + text = ( + "" + ) + return DummyResponse( + status_code=200, + text=text, + url="https://auth.openai.com/sign-in-with-chatgpt/codex/consent", + on_return=setter, + ) + + +def _manifest_cookie(payload): + encoded = base64.urlsafe_b64encode( + json.dumps(payload).encode("utf-8") + ).decode("ascii").rstrip("=") + return urllib.parse.quote(encoded) + + +def _response_with_username_rejected(): + payload = { + "error": { + "message": "Failed to register username. Please try again.", + "code": "bad_request", + } + } + return DummyResponse(status_code=400, payload=payload, text=json.dumps(payload)) + + +@contextmanager +def _fake_get_db_context(): + yield object() + + +def _make_engine_session_manager(name): + runtime_dir = Path("tests_runtime") + runtime_dir.mkdir(exist_ok=True) + db_path = runtime_dir / name + if db_path.exists(): + try: + db_path.unlink() + except PermissionError: + db_path = runtime_dir / f"{db_path.stem}-{datetime.now().timestamp():.0f}{db_path.suffix}" + from src.database.models import Base + from src.database.session import DatabaseSessionManager + + manager = DatabaseSessionManager(f"sqlite:///{db_path}") + Base.metadata.create_all(bind=manager.engine) + return manager + + +def _build_outlook_username_rejection_engine(monkeypatch): + created_accounts = [] + + monkeypatch.setattr("src.core.register.get_db", _fake_get_db_context) + monkeypatch.setattr("src.core.register.crud.get_account_by_email", lambda db, email: None) + monkeypatch.setattr( + "src.core.register.crud.create_account", + lambda db, **kwargs: created_accounts.append(kwargs), + ) + + email_service = FakeOutlookEmailService([]) + email_service.create_email = lambda config=None: { + "email": "StephanieChavez3037@outlook.com", + "service_id": "mailbox-1", + } + + engine = RegistrationEngine(email_service) + engine.oauth_manager = FakeOAuthManager() + return engine, created_accounts + + +def test_check_sentinel_sends_non_empty_pow(monkeypatch): + session = QueueSession([ + ("POST", OPENAI_API_ENDPOINTS["sentinel"], DummyResponse(payload={"token": "sentinel-token"})), + ]) + client = OpenAIHTTPClient() + client._session = session + + monkeypatch.setattr( + "src.core.http_client.build_sentinel_pow_token", + lambda user_agent: "gAAAAACpow-token", + ) + + token = client.check_sentinel("device-1") + + assert token == "sentinel-token" + body = json.loads(session.calls[0]["kwargs"]["data"]) + assert body["id"] == "device-1" + assert body["flow"] == "authorize_continue" + assert body["p"] == "gAAAAACpow-token" + + +def test_run_registers_then_relogs_to_fetch_token(): + session_one = QueueSession([ + ("GET", "https://auth.example.test/flow/1", _response_with_did("did-1")), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["PASSWORD_REGISTRATION"]}}), + ), + ("POST", OPENAI_API_ENDPOINTS["register"], DummyResponse(payload={})), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ("POST", OPENAI_API_ENDPOINTS["validate_otp"], DummyResponse(payload={})), + ("POST", OPENAI_API_ENDPOINTS["create_account"], DummyResponse(payload={})), + ]) + session_two = QueueSession([ + ("GET", "https://auth.example.test/flow/2", _response_with_did("did-2")), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]}}), + ), + ( + "POST", + OPENAI_API_ENDPOINTS["password_verify"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}}), + ), + ("POST", OPENAI_API_ENDPOINTS["validate_otp"], _response_with_login_cookies()), + ( + "POST", + OPENAI_API_ENDPOINTS["select_workspace"], + DummyResponse(payload={"continue_url": "https://auth.example.test/continue"}), + ), + ( + "GET", + "https://auth.example.test/continue", + DummyResponse( + status_code=302, + headers={"Location": "http://localhost:1455/auth/callback?code=code-2&state=state-2"}, + ), + ), + ]) + + email_service = FakeEmailService(["123456", "654321"]) + engine = RegistrationEngine(email_service) + fake_oauth = FakeOAuthManager() + engine.http_client = FakeOpenAIClient([session_one, session_two], ["sentinel-1", "sentinel-2"]) + engine.oauth_manager = fake_oauth + + result = engine.run() + + assert result.success is True + assert result.source == "register" + assert result.workspace_id == "ws-1" + assert result.session_token == "session-1" + assert fake_oauth.start_calls == 2 + assert len(email_service.otp_requests) == 2 + assert all(item["otp_sent_at"] is not None for item in email_service.otp_requests) + assert sum(1 for call in session_one.calls if call["url"] == OPENAI_API_ENDPOINTS["send_otp"]) == 1 + assert sum(1 for call in session_two.calls if call["url"] == OPENAI_API_ENDPOINTS["send_otp"]) == 0 + assert sum(1 for call in session_one.calls if call["url"] == OPENAI_API_ENDPOINTS["select_workspace"]) == 0 + assert sum(1 for call in session_two.calls if call["url"] == OPENAI_API_ENDPOINTS["select_workspace"]) == 1 + relogin_start_body = json.loads(session_two.calls[1]["kwargs"]["data"]) + assert relogin_start_body["screen_hint"] == "login" + assert relogin_start_body["username"]["value"] == "tester@example.com" + password_verify_body = json.loads(session_two.calls[2]["kwargs"]["data"]) + assert password_verify_body == {"password": result.password} + assert result.metadata["token_acquired_via_relogin"] is True + + +def test_run_prefers_current_session_after_create_account_add_phone_consent_before_relogin(): + def create_account_add_phone(session): + session.cookies["oai-client-auth-session"] = _workspace_cookie_without_workspace() + session.cookies["__Secure-next-auth.session-token"] = "session-add-phone" + return DummyResponse( + payload={ + "page": {"type": "add_phone"}, + "continue_url": "https://auth.example.test/add-phone", + }, + url=OPENAI_API_ENDPOINTS["create_account"], + ) + + def consent_page_with_workspace(session): + session.cookies["oai-client-auth-session"] = _workspace_cookie("ws-add-phone") + session.cookies["__Secure-next-auth.session-token"] = "session-add-phone" + return DummyResponse( + status_code=200, + url="https://auth.openai.com/sign-in-with-chatgpt/codex/consent", + text="consent", + ) + + session = QueueSession([ + ("GET", "https://auth.example.test/flow/1", _response_with_did("did-1")), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["PASSWORD_REGISTRATION"]}}), + ), + ("POST", OPENAI_API_ENDPOINTS["register"], DummyResponse(payload={})), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ("POST", OPENAI_API_ENDPOINTS["validate_otp"], DummyResponse(payload={})), + ("POST", OPENAI_API_ENDPOINTS["create_account"], create_account_add_phone), + ( + "GET", + "https://auth.example.test/add-phone", + DummyResponse( + status_code=200, + url="https://auth.example.test/add-phone", + text=( + "" + ), + ), + ), + ("GET", "https://auth.openai.com/sign-in-with-chatgpt/codex/consent", consent_page_with_workspace), + ("GET", "https://auth.openai.com/sign-in-with-chatgpt/codex/consent", consent_page_with_workspace), + ( + "POST", + OPENAI_API_ENDPOINTS["select_workspace"], + DummyResponse(payload={"continue_url": "https://auth.example.test/consent-continue"}), + ), + ( + "GET", + "https://auth.example.test/consent-continue", + DummyResponse( + status_code=302, + headers={"Location": "http://localhost:1455/auth/callback?code=code-add-phone&state=state-1"}, + ), + ), + ]) + + email_service = FakeEmailService(["123456"]) + engine = RegistrationEngine(email_service) + fake_oauth = FakeOAuthManagerWithWorkspace("ws-add-phone") + engine.http_client = FakeOpenAIClient([session], ["sentinel-1"]) + engine.oauth_manager = fake_oauth + + result = engine.run() + + assert result.success is True + assert result.workspace_id == "ws-add-phone" + assert result.session_token == "session-add-phone" + assert fake_oauth.start_calls == 1 + assert len(email_service.otp_requests) == 1 + assert any(call["url"] == "https://auth.example.test/add-phone" for call in session.calls) + assert any(call["url"] == "https://auth.openai.com/sign-in-with-chatgpt/codex/consent" for call in session.calls) + assert sum(1 for call in session.calls if call["url"] == OPENAI_API_ENDPOINTS["select_workspace"]) == 1 + assert result.metadata["token_acquired_via_relogin"] is False + + +def test_get_workspace_id_reads_workspace_from_cookie_payload_segment(): + engine = RegistrationEngine(FakeEmailService([])) + engine.session = QueueSession([]) + engine.session.cookies["oai-client-auth-session"] = _workspace_cookie("ws-cookie") + + workspace_id = engine._get_workspace_id() + + assert workspace_id == "ws-cookie" + + +def test_get_workspace_id_returns_none_when_cookie_has_no_workspace(): + engine = RegistrationEngine(FakeEmailService([])) + engine.session = QueueSession([]) + engine.session.cookies["oai-client-auth-session"] = _workspace_cookie_without_workspace() + + workspace_id = engine._get_workspace_id() + + assert workspace_id is None + assert any("workspace" in log.lower() for log in engine.logs) + + +def test_get_workspace_id_reads_workspace_from_quoted_first_segment_cookie(): + engine = RegistrationEngine(FakeEmailService([])) + engine.session = QueueSession([]) + engine.session.cookies["oai-client-auth-session"] = _quoted_first_segment_workspace_cookie("ws-quoted") + + workspace_id = engine._get_workspace_id() + + assert workspace_id == "ws-quoted" + + +def test_get_workspace_id_reads_workspace_from_next_auth_session_token(): + engine = RegistrationEngine(FakeEmailService([])) + engine.session = QueueSession([]) + engine.session.cookies["oai-client-auth-session"] = _workspace_cookie_without_workspace() + engine.session.cookies["__Secure-next-auth.session-token"] = _next_auth_session_token("ws-next-auth") + + workspace_id = engine._get_workspace_id() + + assert workspace_id == "ws-next-auth" + + +def test_get_workspace_id_reads_workspace_from_unified_session_manifest_cookie(): + engine = RegistrationEngine(FakeEmailService([])) + engine.session = QueueSession([]) + engine.session.cookies["oai-client-auth-session"] = _workspace_cookie_without_workspace() + engine.session.cookies["unified_session_manifest"] = _manifest_cookie( + {"workspaces": [{"id": "ws-manifest"}]} + ) + + workspace_id = engine._get_workspace_id() + + assert workspace_id == "ws-manifest" + + +def test_select_workspace_includes_oauth_context_headers(): + engine = RegistrationEngine(FakeEmailService([])) + engine.session = QueueSession([ + ( + "POST", + OPENAI_API_ENDPOINTS["select_workspace"], + DummyResponse(payload={"continue_url": "https://auth.example.test/next"}), + ), + ]) + engine.session.headers = {"User-Agent": "UA-test/1.0"} + engine.session.cookies["oai-did"] = "did-test-1" + + continue_url = engine._select_workspace("ws-header-check") + + assert continue_url == "https://auth.example.test/next" + headers = engine.session.calls[0]["kwargs"]["headers"] + assert headers["accept"] == "application/json" + assert headers["content-type"] == "application/json" + assert headers["origin"] == "https://auth.openai.com" + assert headers["referer"] == "https://auth.openai.com/sign-in-with-chatgpt/codex/consent" + assert headers["user-agent"] == "UA-test/1.0" + assert headers["oai-device-id"] == "did-test-1" + assert "traceparent" in headers + assert "x-datadog-trace-id" in headers + + +def test_select_organization_includes_oauth_context_headers(): + organization_select_url = "https://auth.openai.com/api/accounts/organization/select" + engine = RegistrationEngine(FakeEmailService([])) + engine.session = QueueSession([ + ( + "POST", + organization_select_url, + DummyResponse(payload={"continue_url": "https://auth.example.test/org-next"}), + ), + ]) + engine.session.headers = {"User-Agent": "UA-org/2.0"} + engine.session.cookies["oai-did"] = "did-org-1" + + continue_url = engine._select_organization( + "org-1", + "proj-1", + referer="https://auth.example.test/organization-step", + ) + + assert continue_url == "https://auth.example.test/org-next" + headers = engine.session.calls[0]["kwargs"]["headers"] + assert headers["accept"] == "application/json" + assert headers["content-type"] == "application/json" + assert headers["origin"] == "https://auth.openai.com" + assert headers["referer"] == "https://auth.example.test/organization-step" + assert headers["user-agent"] == "UA-org/2.0" + assert headers["oai-device-id"] == "did-org-1" + assert "traceparent" in headers + assert "x-datadog-parent-id" in headers + + +def test_remember_navigation_from_consent_logs_script_assets_and_bundle_hints(): + route_script_url = "https://auth.openai.com/assets/route-D83ftS1Y.js" + main_script_url = "https://auth.openai.com/assets/main.js" + engine = RegistrationEngine(FakeEmailService([])) + engine.session = QueueSession([ + ( + "GET", + route_script_url, + DummyResponse( + status_code=200, + url=route_script_url, + text='const clientAction="/api/accounts/workspace/select"; const org="/api/accounts/organization/select";', + ), + ), + ( + "GET", + main_script_url, + DummyResponse( + status_code=200, + url=main_script_url, + text="console.log('noop')", + ), + ), + ]) + engine.session.headers = {"User-Agent": "UA-assets/1.0"} + + response = DummyResponse( + status_code=200, + url="https://auth.openai.com/sign-in-with-chatgpt/codex/consent", + text=( + "" + '' + '' + "consent" + ), + ) + + engine._remember_navigation_from_response("redirect_1", response) + + assert any("consent_script_assets=2" in log for log in engine.logs) + assert any("route-D83ftS1Y.js" in log for log in engine.logs) + assert any("workspace/select" in log for log in engine.logs) + assert any("organization/select" in log for log in engine.logs) + + +def test_resolve_oauth_callback_runs_organization_select_when_workspace_select_requires_it(): + organization_select_url = "https://auth.openai.com/api/accounts/organization/select" + engine = RegistrationEngine(FakeEmailService([])) + engine.session = QueueSession([ + ( + "POST", + OPENAI_API_ENDPOINTS["select_workspace"], + DummyResponse( + payload={ + "continue_url": "https://auth.example.test/organization", + "page": {"type": "organization_selection"}, + "data": { + "orgs": [ + { + "id": "org-1", + "projects": [{"id": "proj-1"}], + } + ] + }, + } + ), + ), + ( + "POST", + organization_select_url, + DummyResponse(payload={"continue_url": "https://auth.example.test/org-continue"}), + ), + ( + "GET", + "https://auth.example.test/org-continue", + DummyResponse( + status_code=302, + headers={"Location": "http://localhost:1455/auth/callback?code=code-org&state=state-1"}, + ), + ), + ]) + engine.session.cookies["oai-client-auth-session"] = _workspace_cookie("ws-org") + + callback_url, workspace_id, resolution_error = engine._resolve_oauth_callback_url() + + assert callback_url == "http://localhost:1455/auth/callback?code=code-org&state=state-1" + assert workspace_id == "ws-org" + assert resolution_error is None + assert any(call["url"] == organization_select_url for call in engine.session.calls) + + +def test_register_password_marks_username_rejection_as_consumed(monkeypatch): + engine, created_accounts = _build_outlook_username_rejection_engine(monkeypatch) + engine.email = "StephanieChavez3037@outlook.com" + engine.email_info = {"service_id": "mailbox-1"} + engine.session = QueueSession([ + ("POST", OPENAI_API_ENDPOINTS["register"], _response_with_username_rejected()), + ]) + + ok, password = engine._register_password() + + assert ok is False + assert password is None + assert len(created_accounts) == 1 + assert created_accounts[0]["email"] == "StephanieChavez3037@outlook.com" + assert created_accounts[0]["status"] == "failed" + + +def test_run_reports_specific_error_for_username_rejected_outlook(monkeypatch): + engine, _created_accounts = _build_outlook_username_rejection_engine(monkeypatch) + engine.http_client = FakeOpenAIClient([ + QueueSession([ + ("GET", "https://auth.example.test/flow/1", _response_with_did("did-1")), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["PASSWORD_REGISTRATION"]}}), + ), + ("POST", OPENAI_API_ENDPOINTS["register"], _response_with_username_rejected()), + ]) + ], ["sentinel-1"]) + + result = engine.run() + + assert result.success is False + assert "注册密码失败" not in result.error_message + assert any(token in result.error_message for token in ("已被占用", "疑似已注册", "已注册")) + + +def test_existing_account_login_uses_auto_sent_otp_without_manual_send(): + session = QueueSession([ + ("GET", "https://auth.example.test/flow/1", _response_with_did("did-1")), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}}), + ), + ("POST", OPENAI_API_ENDPOINTS["validate_otp"], _response_with_login_cookies("ws-existing", "session-existing")), + ( + "POST", + OPENAI_API_ENDPOINTS["select_workspace"], + DummyResponse(payload={"continue_url": "https://auth.example.test/continue-existing"}), + ), + ( + "GET", + "https://auth.example.test/continue-existing", + DummyResponse( + status_code=302, + headers={"Location": "http://localhost:1455/auth/callback?code=code-1&state=state-1"}, + ), + ), + ]) + + email_service = FakeEmailService(["246810"]) + engine = RegistrationEngine(email_service) + fake_oauth = FakeOAuthManager() + engine.http_client = FakeOpenAIClient([session], ["sentinel-1"]) + engine.oauth_manager = fake_oauth + + result = engine.run() + + assert result.success is True + assert result.source == "login" + assert fake_oauth.start_calls == 1 + assert sum(1 for call in session.calls if call["url"] == OPENAI_API_ENDPOINTS["send_otp"]) == 0 + assert len(email_service.otp_requests) == 1 + assert email_service.otp_requests[0]["otp_sent_at"] is not None + assert result.metadata["token_acquired_via_relogin"] is False + + +def test_run_registers_then_relogs_outlook_with_manual_resend_and_longer_timeout(): + session_one = QueueSession([ + ("GET", "https://auth.example.test/flow/1", _response_with_did("did-1")), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["PASSWORD_REGISTRATION"]}}), + ), + ("POST", OPENAI_API_ENDPOINTS["register"], DummyResponse(payload={})), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ("POST", OPENAI_API_ENDPOINTS["validate_otp"], DummyResponse(payload={})), + ("POST", OPENAI_API_ENDPOINTS["create_account"], DummyResponse(payload={})), + ]) + session_two = QueueSession([ + ("GET", "https://auth.example.test/flow/2", _response_with_did("did-2")), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]}}), + ), + ( + "POST", + OPENAI_API_ENDPOINTS["password_verify"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}}), + ), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ("POST", OPENAI_API_ENDPOINTS["validate_otp"], _response_with_login_cookies()), + ( + "POST", + OPENAI_API_ENDPOINTS["select_workspace"], + DummyResponse(payload={"continue_url": "https://auth.example.test/continue"}), + ), + ( + "GET", + "https://auth.example.test/continue", + DummyResponse( + status_code=302, + headers={"Location": "http://localhost:1455/auth/callback?code=code-2&state=state-2"}, + ), + ), + ]) + + email_service = FakeOutlookEmailService(["123456", "123456"]) + engine = RegistrationEngine(email_service) + fake_oauth = FakeOAuthManager() + engine.http_client = FakeOpenAIClient([session_one, session_two], ["sentinel-1", "sentinel-2"]) + engine.oauth_manager = fake_oauth + + result = engine.run() + + assert result.success is True + assert email_service.verification_stages[0]["stage"] == "signup_otp" + assert email_service.verification_stages[-1]["stage"] == "relogin_otp" + assert {item["stage"] for item in email_service.verification_stages} == {"signup_otp", "relogin_otp"} + assert [item["stage"] for item in email_service.otp_requests] == ["signup_otp", "relogin_otp"] + assert email_service.otp_requests[0]["timeout"] == 120 + assert email_service.otp_requests[1]["timeout"] == 180 + assert sum(1 for call in session_two.calls if call["url"] == OPENAI_API_ENDPOINTS["send_otp"]) == 1 + assert session_two.calls[3]["kwargs"]["headers"]["referer"] == "https://auth.openai.com/log-in/password" + + +def test_run_registers_then_relogs_outlook_keeps_polling_when_manual_resend_fails(): + session_one = QueueSession([ + ("GET", "https://auth.example.test/flow/1", _response_with_did("did-1")), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["PASSWORD_REGISTRATION"]}}), + ), + ("POST", OPENAI_API_ENDPOINTS["register"], DummyResponse(payload={})), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ("POST", OPENAI_API_ENDPOINTS["validate_otp"], DummyResponse(payload={})), + ("POST", OPENAI_API_ENDPOINTS["create_account"], DummyResponse(payload={})), + ]) + session_two = QueueSession([ + ("GET", "https://auth.example.test/flow/2", _response_with_did("did-2")), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]}}), + ), + ( + "POST", + OPENAI_API_ENDPOINTS["password_verify"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}}), + ), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(status_code=500, payload={})), + ("POST", OPENAI_API_ENDPOINTS["validate_otp"], _response_with_login_cookies("ws-fallback", "session-fallback")), + ( + "POST", + OPENAI_API_ENDPOINTS["select_workspace"], + DummyResponse(payload={"continue_url": "https://auth.example.test/continue-fallback"}), + ), + ( + "GET", + "https://auth.example.test/continue-fallback", + DummyResponse( + status_code=302, + headers={"Location": "http://localhost:1455/auth/callback?code=code-2&state=state-2"}, + ), + ), + ]) + + email_service = FakeOutlookEmailService(["123456", "123456"]) + engine = RegistrationEngine(email_service) + fake_oauth = FakeOAuthManager() + engine.http_client = FakeOpenAIClient([session_one, session_two], ["sentinel-1", "sentinel-2"]) + engine.oauth_manager = fake_oauth + + result = engine.run() + + assert result.success is True + assert result.workspace_id == "ws-fallback" + assert sum(1 for call in session_two.calls if call["url"] == OPENAI_API_ENDPOINTS["send_otp"]) == 1 + assert email_service.otp_requests[1]["timeout"] == 180 + + +def test_run_persists_recoverable_outlook_account_when_workspace_lookup_fails(monkeypatch): + from src.database.models import Account + + manager = _make_engine_session_manager("recoverable-workspace-fail.db") + + @contextmanager + def fake_get_db(): + session = manager.SessionLocal() + try: + yield session + finally: + session.close() + + monkeypatch.setattr("src.core.register.get_db", fake_get_db) + + session_one = QueueSession([ + ("GET", "https://auth.example.test/flow/1", _response_with_did("did-1")), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["PASSWORD_REGISTRATION"]}}), + ), + ("POST", OPENAI_API_ENDPOINTS["register"], DummyResponse(payload={})), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ("POST", OPENAI_API_ENDPOINTS["validate_otp"], DummyResponse(payload={})), + ("POST", OPENAI_API_ENDPOINTS["create_account"], DummyResponse(payload={})), + ]) + session_two = QueueSession([ + ("GET", "https://auth.example.test/flow/2", _response_with_did("did-2")), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]}}), + ), + ( + "POST", + OPENAI_API_ENDPOINTS["password_verify"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}}), + ), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ("POST", OPENAI_API_ENDPOINTS["validate_otp"], _response_with_login_cookies_without_workspace()), + ( + "GET", + "https://auth.example.test/flow/2", + DummyResponse(status_code=200, payload={}, url="https://auth.example.test/flow/2"), + ), + ]) + + email_service = FakeOutlookEmailService(["123456", "654321", "777888"]) + engine = RegistrationEngine(email_service) + engine.http_client = FakeOpenAIClient([session_one, session_two], ["sentinel-1", "sentinel-2"]) + engine.oauth_manager = FakeOAuthManager() + + result = engine.run() + + assert result.success is False + assert result.error_message == "OAuth 续跑失败" + + with manager.session_scope() as session: + account = session.query(Account).filter(Account.email == "tester@example.com").first() + account_snapshot = { + "password": account.password, + "status": account.status, + "email_service": account.email_service, + "extra_data": dict(account.extra_data or {}), + } if account else None + + assert account_snapshot is not None + assert account_snapshot["password"] + assert account_snapshot["status"] == "failed" + assert account_snapshot["email_service"] == "outlook" + assert account_snapshot["extra_data"]["recovery_ready"] is True + assert account_snapshot["extra_data"]["account_created"] is True + assert account_snapshot["extra_data"]["token_acquired"] is False + assert account_snapshot["extra_data"]["register_failed_reason"] == "token_recovery_pending" + assert account_snapshot["extra_data"]["last_recovery_error"] == "OAuth 续跑失败" + assert account_snapshot["extra_data"]["last_oauth_resume_source"] == "authorize_replay_failed" + + +def test_run_recovers_outlook_account_with_saved_password_and_updates_existing_record(monkeypatch): + from src.database import crud + from src.database.models import Account + + manager = _make_engine_session_manager("recoverable-success.db") + + with manager.session_scope() as session: + crud.create_account( + session, + email="tester@example.com", + password="saved-password", + email_service="outlook", + email_service_id="mailbox-1", + status="failed", + source="register", + extra_data={ + "recovery_ready": True, + "account_created": True, + "token_acquired": False, + "register_failed_reason": "token_recovery_pending", + "last_recovery_error": "获取 Workspace ID 失败", + }, + ) + + @contextmanager + def fake_get_db(): + session = manager.SessionLocal() + try: + yield session + finally: + session.close() + + monkeypatch.setattr("src.core.register.get_db", fake_get_db) + + session = QueueSession([ + ("GET", "https://auth.example.test/flow/1", _response_with_did("did-1")), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]}}), + ), + ( + "POST", + OPENAI_API_ENDPOINTS["password_verify"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}}), + ), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ("POST", OPENAI_API_ENDPOINTS["validate_otp"], _response_with_login_cookies("ws-recovered", "session-recovered")), + ( + "POST", + OPENAI_API_ENDPOINTS["select_workspace"], + DummyResponse(payload={"continue_url": "https://auth.example.test/recover"}), + ), + ( + "GET", + "https://auth.example.test/recover", + DummyResponse( + status_code=302, + headers={"Location": "http://localhost:1455/auth/callback?code=code-1&state=state-1"}, + ), + ), + ]) + + email_service = FakeOutlookEmailService(["789012"]) + engine = RegistrationEngine(email_service) + engine.http_client = FakeOpenAIClient([session], ["sentinel-1", "sentinel-2"]) + engine.oauth_manager = FakeOAuthManager() + + result = engine.run() + + assert result.success is True + assert result.metadata["database_saved"] is True + assert not any(call["url"] == OPENAI_API_ENDPOINTS["register"] for call in session.calls) + assert not any(call["url"] == OPENAI_API_ENDPOINTS["create_account"] for call in session.calls) + assert [item["stage"] for item in email_service.otp_requests] == ["relogin_otp"] + assert email_service.otp_requests[0]["timeout"] == 180 + + with manager.session_scope() as session_db: + accounts = session_db.query(Account).filter(Account.email == "tester@example.com").all() + assert len(accounts) == 1 + account = accounts[0] + assert account.status == "active" + assert account.password == "saved-password" + assert account.workspace_id == "ws-recovered" + assert account.access_token == "access-1" + assert account.extra_data["recovery_ready"] is False + assert account.extra_data["token_acquired"] is True + + +def test_run_recovers_outlook_account_uses_relogin_otp_and_manual_resend(monkeypatch): + from src.database import crud + + manager = _make_engine_session_manager("recoverable-relogin-otp.db") + + with manager.session_scope() as session: + crud.create_account( + session, + email="tester@example.com", + password="saved-password", + email_service="outlook", + email_service_id="mailbox-1", + status="failed", + source="register", + extra_data={ + "recovery_ready": True, + "account_created": True, + "token_acquired": False, + "register_failed_reason": "token_recovery_pending", + "last_recovery_error": "获取 Workspace ID 失败", + }, + ) + + @contextmanager + def fake_get_db(): + session = manager.SessionLocal() + try: + yield session + finally: + session.close() + + monkeypatch.setattr("src.core.register.get_db", fake_get_db) + + session = QueueSession([ + ("GET", "https://auth.example.test/flow/1", _response_with_did("did-1")), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]}}), + ), + ( + "POST", + OPENAI_API_ENDPOINTS["password_verify"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}}), + ), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ("POST", OPENAI_API_ENDPOINTS["validate_otp"], _response_with_login_cookies("ws-recovered", "session-recovered")), + ( + "POST", + OPENAI_API_ENDPOINTS["select_workspace"], + DummyResponse(payload={"continue_url": "https://auth.example.test/recover"}), + ), + ( + "GET", + "https://auth.example.test/recover", + DummyResponse( + status_code=302, + headers={"Location": "http://localhost:1455/auth/callback?code=code-1&state=state-1"}, + ), + ), + ]) + + email_service = FakeOutlookEmailService(["789012"]) + engine = RegistrationEngine(email_service) + engine.http_client = FakeOpenAIClient([session], ["sentinel-1", "sentinel-2"]) + engine.oauth_manager = FakeOAuthManager() + + result = engine.run() + + assert result.success is True + assert result.metadata["database_saved"] is True + assert email_service.verification_stages[-1]["stage"] == "relogin_otp" + assert [item["stage"] for item in email_service.otp_requests] == ["relogin_otp"] + assert email_service.otp_requests[0]["timeout"] == 180 + send_otp_calls = [call for call in session.calls if call["url"] == OPENAI_API_ENDPOINTS["send_otp"]] + assert len(send_otp_calls) == 1 + assert send_otp_calls[0]["kwargs"]["headers"]["referer"] == "https://auth.openai.com/log-in/password" + + +def test_run_persists_recoverable_outlook_account_on_relogin_otp_timeout(monkeypatch): + from src.database.models import Account + from src.database import crud + + manager = _make_engine_session_manager("recoverable-relogin-timeout.db") + + with manager.session_scope() as session: + crud.create_account( + session, + email="tester@example.com", + password="saved-password", + email_service="outlook", + email_service_id="mailbox-1", + status="failed", + source="register", + extra_data={ + "recovery_ready": True, + "account_created": True, + "token_acquired": False, + "register_failed_reason": "token_recovery_pending", + "last_recovery_error": "获取 Workspace ID 失败", + }, + ) + + @contextmanager + def fake_get_db(): + session = manager.SessionLocal() + try: + yield session + finally: + session.close() + + monkeypatch.setattr("src.core.register.get_db", fake_get_db) + + session = QueueSession([ + ("GET", "https://auth.example.test/flow/1", _response_with_did("did-1")), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]}}), + ), + ( + "POST", + OPENAI_API_ENDPOINTS["password_verify"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}}), + ), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ]) + + email_service = FakeOutlookEmailService([None]) + engine = RegistrationEngine(email_service) + engine.http_client = FakeOpenAIClient([session], ["sentinel-1"]) + engine.oauth_manager = FakeOAuthManager() + + result = engine.run() + + assert result.success is False + assert result.error_message == "获取验证码失败" + assert email_service.verification_stages[-1]["stage"] == "relogin_otp" + assert [item["stage"] for item in email_service.otp_requests] == ["relogin_otp"] + assert email_service.otp_requests[0]["timeout"] == 180 + + with manager.session_scope() as session_db: + account = session_db.query(Account).filter(Account.email == "tester@example.com").first() + extra_data = dict(account.extra_data or {}) + + assert extra_data["recovery_ready"] is True + assert extra_data["token_acquired"] is False + assert extra_data["last_recovery_error"] == "获取验证码失败" + assert extra_data["last_otp_stage"] == "relogin_otp" + + +def test_run_uses_workspace_from_validate_otp_response_payload(): + session_one = QueueSession([ + ("GET", "https://auth.example.test/flow/1", _response_with_did("did-1")), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["PASSWORD_REGISTRATION"]}}), + ), + ("POST", OPENAI_API_ENDPOINTS["register"], DummyResponse(payload={})), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ("POST", OPENAI_API_ENDPOINTS["validate_otp"], DummyResponse(payload={})), + ("POST", OPENAI_API_ENDPOINTS["create_account"], DummyResponse(payload={})), + ]) + session_two = QueueSession([ + ("GET", "https://auth.example.test/flow/2", _response_with_did("did-2")), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]}}), + ), + ( + "POST", + OPENAI_API_ENDPOINTS["password_verify"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}}), + ), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ("POST", OPENAI_API_ENDPOINTS["validate_otp"], _response_with_payload_workspace_only("ws-payload")), + ( + "POST", + OPENAI_API_ENDPOINTS["select_workspace"], + DummyResponse(payload={"continue_url": "https://auth.example.test/continue-payload"}), + ), + ( + "GET", + "https://auth.example.test/continue-payload", + DummyResponse( + status_code=302, + headers={"Location": "http://localhost:1455/auth/callback?code=code-2&state=state-2"}, + ), + ), + ]) + + email_service = FakeOutlookEmailService(["123456", "654321"]) + engine = RegistrationEngine(email_service) + engine.http_client = FakeOpenAIClient( + [session_one, session_two], + ["sentinel-1", "sentinel-2", "sentinel-3"], + ) + engine.oauth_manager = FakeOAuthManager() + + result = engine.run() + + assert result.success is True + assert result.workspace_id == "ws-payload" + + +def test_set_verification_stage_syncs_to_stage_aware_temp_mail_service(): + email_service = FakeStageAwareTempMailService([]) + engine = RegistrationEngine(email_service) + engine.email = "tester@example.com" + + engine._set_verification_stage("signup_otp") + engine._set_verification_stage("relogin_otp") + + assert [item["stage"] for item in email_service.verification_stages] == ["signup_otp", "relogin_otp"] + + +def test_run_can_complete_oauth_callback_before_workspace_is_resolved(): + session_one = QueueSession([ + ("GET", "https://auth.example.test/flow/1", _response_with_did("did-1")), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["PASSWORD_REGISTRATION"]}}), + ), + ("POST", OPENAI_API_ENDPOINTS["register"], DummyResponse(payload={})), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ("POST", OPENAI_API_ENDPOINTS["validate_otp"], DummyResponse(payload={})), + ("POST", OPENAI_API_ENDPOINTS["create_account"], DummyResponse(payload={})), + ]) + session_two = QueueSession([ + ("GET", "https://auth.example.test/flow/2", _response_with_did("did-2")), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]}}), + ), + ( + "POST", + OPENAI_API_ENDPOINTS["password_verify"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}}), + ), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ("POST", OPENAI_API_ENDPOINTS["validate_otp"], _response_with_callback_before_workspace()), + ]) + + email_service = FakeOutlookEmailService(["123456", "654321"]) + engine = RegistrationEngine(email_service) + engine.http_client = FakeOpenAIClient( + [session_one, session_two], + ["sentinel-1", "sentinel-2", "sentinel-3"], + ) + engine.oauth_manager = FakeOAuthManagerWithWorkspace("ws-token-fallback") + + result = engine.run() + + assert result.success is True + assert result.workspace_id == "ws-token-fallback" + assert not any(call["url"] == OPENAI_API_ENDPOINTS["select_workspace"] for call in session_two.calls) + + +def test_run_recovers_outlook_account_by_resuming_login_challenge_after_validate_otp(monkeypatch): + from src.database import crud + from src.database.models import Account + + manager = _make_engine_session_manager("recoverable-login-challenge-resume.db") + + with manager.session_scope() as session: + crud.create_account( + session, + email="tester@example.com", + password="saved-password", + email_service="outlook", + email_service_id="mailbox-1", + status="failed", + source="register", + extra_data={ + "recovery_ready": True, + "account_created": True, + "token_acquired": False, + "register_failed_reason": "token_recovery_pending", + "last_recovery_error": "获取 Workspace ID 失败", + }, + ) + + @contextmanager + def fake_get_db(): + session = manager.SessionLocal() + try: + yield session + finally: + session.close() + + monkeypatch.setattr("src.core.register.get_db", fake_get_db) + + session = QueueSession([ + ("GET", "https://auth.example.test/flow/1", _response_with_did("did-1")), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]}}), + ), + ( + "POST", + OPENAI_API_ENDPOINTS["password_verify"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}}), + ), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ("POST", OPENAI_API_ENDPOINTS["validate_otp"], _response_with_resume_history()), + ( + "GET", + "https://auth.example.test/login-challenge?login_challenge=challenge-1", + DummyResponse( + status_code=302, + headers={"Location": "http://localhost:1455/auth/callback?code=code-1&state=state-1"}, + ), + ), + ]) + + email_service = FakeOutlookEmailService(["789012"]) + engine = RegistrationEngine(email_service) + engine.http_client = FakeOpenAIClient([session], ["sentinel-1"]) + engine.oauth_manager = FakeOAuthManagerWithWorkspace("ws-replayed") + + result = engine.run() + + assert result.success is True + assert result.workspace_id == "ws-replayed" + assert result.metadata["database_saved"] is True + assert not any(call["url"] == OPENAI_API_ENDPOINTS["select_workspace"] for call in session.calls) + assert session.calls[-1]["url"] == "https://auth.example.test/login-challenge?login_challenge=challenge-1" + assert result.metadata["last_oauth_resume_source"] == "login_challenge_resume" + + with manager.session_scope() as session_db: + account = session_db.query(Account).filter(Account.email == "tester@example.com").first() + assert account.status == "active" + assert account.workspace_id == "ws-replayed" + + +def test_run_recovers_outlook_account_from_email_verification_page_text_resume(monkeypatch): + from src.database import crud + from src.database.models import Account + + manager = _make_engine_session_manager("recoverable-email-verification-resume.db") + + with manager.session_scope() as session: + crud.create_account( + session, + email="tester@example.com", + password="saved-password", + email_service="outlook", + email_service_id="mailbox-1", + status="failed", + source="register", + extra_data={ + "recovery_ready": True, + "account_created": True, + "token_acquired": False, + "register_failed_reason": "token_recovery_pending", + "last_recovery_error": "获取 Workspace ID 失败", + }, + ) + + @contextmanager + def fake_get_db(): + session = manager.SessionLocal() + try: + yield session + finally: + session.close() + + monkeypatch.setattr("src.core.register.get_db", fake_get_db) + + resume_url = "https://auth.example.test/api/oauth/oauth2/auth?client_id=client-1&state=resume-1" + session = QueueSession([ + ("GET", "https://auth.example.test/flow/1", _response_with_did("did-1")), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]}}), + ), + ( + "POST", + OPENAI_API_ENDPOINTS["password_verify"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}}), + ), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ("POST", OPENAI_API_ENDPOINTS["validate_otp"], _response_with_email_verification_resume_text(resume_url)), + ( + "GET", + resume_url, + DummyResponse( + status_code=302, + headers={"Location": "http://localhost:1455/auth/callback?code=code-1&state=state-1"}, + ), + ), + ]) + + email_service = FakeOutlookEmailService(["789012"]) + engine = RegistrationEngine(email_service) + engine.http_client = FakeOpenAIClient([session], ["sentinel-1"]) + engine.oauth_manager = FakeOAuthManagerWithWorkspace("ws-email-verification") + + result = engine.run() + + assert result.success is True + assert result.workspace_id == "ws-email-verification" + assert not any(call["url"] == OPENAI_API_ENDPOINTS["select_workspace"] for call in session.calls) + assert any(call["url"] == resume_url for call in session.calls) + assert result.metadata["last_oauth_resume_source"] in { + "resume_url_found_after_validate_otp", + "continue_url_resume", + "resume_url_found_from_navigation", + } + + with manager.session_scope() as session_db: + account = session_db.query(Account).filter(Account.email == "tester@example.com").first() + assert account.status == "active" + assert account.workspace_id == "ws-email-verification" + + +def test_run_recovers_outlook_account_by_completing_about_you_before_relogin(monkeypatch): + from src.database import crud + from src.database.models import Account + + manager = _make_engine_session_manager("recoverable-about-you-success.db") + + with manager.session_scope() as session: + crud.create_account( + session, + email="tester@example.com", + password="saved-password", + email_service="outlook", + email_service_id="mailbox-1", + status="failed", + source="register", + extra_data={ + "recovery_ready": True, + "account_created": True, + "token_acquired": False, + "register_failed_reason": "token_recovery_pending", + "last_recovery_error": "获取 Workspace ID 失败", + }, + ) + + @contextmanager + def fake_get_db(): + session = manager.SessionLocal() + try: + yield session + finally: + session.close() + + monkeypatch.setattr("src.core.register.get_db", fake_get_db) + + session = QueueSession([ + ("GET", "https://auth.example.test/flow/1", _response_with_did("did-1")), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse( + payload={ + "page": {"type": OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]}, + "method": "POST", + "continue_url": "https://auth.example.test/log-in/password", + } + ), + ), + ( + "POST", + OPENAI_API_ENDPOINTS["password_verify"], + DummyResponse( + payload={ + "page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}, + "method": "POST", + "continue_url": "https://auth.example.test/email-verification", + } + ), + ), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ( + "POST", + OPENAI_API_ENDPOINTS["validate_otp"], + DummyResponse( + payload={ + "page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}, + "method": "POST", + "continue_url": "https://auth.example.test/about-you", + } + ), + ), + ( + "GET", + "https://auth.example.test/about-you", + DummyResponse(status_code=200, url="https://auth.example.test/about-you", text="about you"), + ), + ("POST", OPENAI_API_ENDPOINTS["create_account"], DummyResponse(payload={})), + ("GET", "https://auth.example.test/flow/2", _response_with_did("did-2")), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]}}), + ), + ( + "POST", + OPENAI_API_ENDPOINTS["password_verify"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}}), + ), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ( + "POST", + OPENAI_API_ENDPOINTS["validate_otp"], + _response_with_callback_before_workspace( + callback_url="http://localhost:1455/auth/callback?code=code-about-you&state=state-2", + session_token="session-about-you", + ), + ), + ]) + + email_service = FakeOutlookEmailService(["123456", "654321"]) + engine = RegistrationEngine(email_service) + engine.http_client = FakeOpenAIClient([session], ["sentinel-1", "sentinel-2"]) + engine.oauth_manager = FakeOAuthManagerWithWorkspace("ws-about-you") + + result = engine.run() + + assert result.success is True + assert result.workspace_id == "ws-about-you" + assert result.session_token == "session-about-you" + assert any(call["url"] == OPENAI_API_ENDPOINTS["create_account"] for call in session.calls) + assert len([call for call in session.calls if call["url"] == OPENAI_API_ENDPOINTS["validate_otp"]]) == 2 + + with manager.session_scope() as session_db: + account = session_db.query(Account).filter(Account.email == "tester@example.com").first() + assert account.status == "active" + assert account.workspace_id == "ws-about-you" + + +def test_run_recovers_outlook_account_when_about_you_create_account_reports_user_exists(monkeypatch): + from src.database import crud + from src.database.models import Account + + manager = _make_engine_session_manager("recoverable-about-you-user-exists.db") + + with manager.session_scope() as session: + crud.create_account( + session, + email="tester@example.com", + password="saved-password", + email_service="outlook", + email_service_id="mailbox-1", + status="failed", + source="register", + extra_data={ + "recovery_ready": True, + "account_created": True, + "token_acquired": False, + "register_failed_reason": "token_recovery_pending", + "last_recovery_error": "鑾峰彇 Workspace ID 澶辫触", + }, + ) + + @contextmanager + def fake_get_db(): + session = manager.SessionLocal() + try: + yield session + finally: + session.close() + + monkeypatch.setattr("src.core.register.get_db", fake_get_db) + + session = QueueSession([ + ("GET", "https://auth.example.test/flow/1", _response_with_did("did-1")), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse( + payload={ + "page": {"type": OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]}, + "method": "POST", + "continue_url": "https://auth.example.test/log-in/password", + } + ), + ), + ( + "POST", + OPENAI_API_ENDPOINTS["password_verify"], + DummyResponse( + payload={ + "page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}, + "method": "POST", + "continue_url": "https://auth.example.test/email-verification", + } + ), + ), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ( + "POST", + OPENAI_API_ENDPOINTS["validate_otp"], + DummyResponse( + payload={ + "page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}, + "method": "POST", + "continue_url": "https://auth.example.test/about-you", + } + ), + ), + ( + "GET", + "https://auth.example.test/about-you", + DummyResponse(status_code=200, url="https://auth.example.test/about-you", text="about you"), + ), + ( + "POST", + OPENAI_API_ENDPOINTS["create_account"], + DummyResponse( + status_code=400, + payload={ + "error": { + "message": "An account already exists for this email address.", + "code": "user_already_exists", + } + }, + text='{"error":{"message":"An account already exists for this email address.","code":"user_already_exists"}}', + ), + ), + ( + "GET", + "https://auth.example.test/flow/1", + DummyResponse( + status_code=302, + headers={"Location": "https://auth.example.test/log-in"}, + ), + ), + ( + "GET", + "https://auth.example.test/log-in", + DummyResponse(status_code=200, payload={}, url="https://auth.example.test/log-in"), + ), + ("GET", "https://auth.example.test/flow/2", _response_with_did("did-2")), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]}}), + ), + ( + "POST", + OPENAI_API_ENDPOINTS["password_verify"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}}), + ), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ( + "POST", + OPENAI_API_ENDPOINTS["validate_otp"], + _response_with_callback_before_workspace( + callback_url="http://localhost:1455/auth/callback?code=code-about-you-exists&state=state-2", + session_token="session-about-you-exists", + ), + ), + ]) + + email_service = FakeOutlookEmailService(["123456", "654321"]) + engine = RegistrationEngine(email_service) + engine.http_client = FakeOpenAIClient([session], ["sentinel-1", "sentinel-2"]) + engine.oauth_manager = FakeOAuthManagerWithWorkspace("ws-about-you-exists") + + result = engine.run() + + assert result.success is True + assert result.workspace_id == "ws-about-you-exists" + assert result.session_token == "session-about-you-exists" + assert any(call["url"] == OPENAI_API_ENDPOINTS["create_account"] for call in session.calls) + assert len([call for call in session.calls if call["url"] == OPENAI_API_ENDPOINTS["validate_otp"]]) == 2 + + with manager.session_scope() as session_db: + account = session_db.query(Account).filter(Account.email == "tester@example.com").first() + assert account.status == "active" + assert account.workspace_id == "ws-about-you-exists" + + +def test_run_recovers_outlook_account_from_same_session_after_about_you_user_exists(monkeypatch): + from src.database import crud + from src.database.models import Account + + manager = _make_engine_session_manager("recoverable-about-you-same-session.db") + + with manager.session_scope() as session: + crud.create_account( + session, + email="tester@example.com", + password="saved-password", + email_service="outlook", + email_service_id="mailbox-1", + status="failed", + source="register", + extra_data={ + "recovery_ready": True, + "account_created": True, + "token_acquired": False, + "register_failed_reason": "token_recovery_pending", + "last_recovery_error": "鑾峰彇 Workspace ID 澶辫触", + }, + ) + + @contextmanager + def fake_get_db(): + session = manager.SessionLocal() + try: + yield session + finally: + session.close() + + monkeypatch.setattr("src.core.register.get_db", fake_get_db) + + def create_account_user_exists_with_callback(): + def setter(session): + session.cookies["oai-client-auth-session"] = _workspace_cookie_without_workspace() + session.cookies["__Secure-next-auth.session-token"] = "session-about-you-same-session" + + return DummyResponse( + status_code=400, + payload={ + "error": { + "message": "An account already exists for this email address.", + "code": "user_already_exists", + }, + "callback_url": "http://localhost:1455/auth/callback?code=code-about-you-same-session&state=state-1", + }, + text='{"error":{"message":"An account already exists for this email address.","code":"user_already_exists"}}', + on_return=setter, + ) + + session = QueueSession([ + ("GET", "https://auth.example.test/flow/1", _response_with_did("did-1")), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse( + payload={ + "page": {"type": OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]}, + "method": "POST", + "continue_url": "https://auth.example.test/log-in/password", + } + ), + ), + ( + "POST", + OPENAI_API_ENDPOINTS["password_verify"], + DummyResponse( + payload={ + "page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}, + "method": "POST", + "continue_url": "https://auth.example.test/email-verification", + } + ), + ), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ( + "POST", + OPENAI_API_ENDPOINTS["validate_otp"], + DummyResponse( + payload={ + "page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}, + "method": "POST", + "continue_url": "https://auth.example.test/about-you", + } + ), + ), + ( + "GET", + "https://auth.example.test/about-you", + DummyResponse(status_code=200, url="https://auth.example.test/about-you", text="about you"), + ), + ("POST", OPENAI_API_ENDPOINTS["create_account"], create_account_user_exists_with_callback()), + ]) + + email_service = FakeOutlookEmailService(["123456"]) + engine = RegistrationEngine(email_service) + engine.http_client = FakeOpenAIClient([session], ["sentinel-1"]) + engine.oauth_manager = FakeOAuthManagerWithWorkspace("ws-about-you-same-session") + + result = engine.run() + + assert result.success is True + assert result.workspace_id == "ws-about-you-same-session" + assert result.session_token == "session-about-you-same-session" + assert len([call for call in session.calls if call["url"].startswith("https://auth.example.test/flow/")]) == 1 + + with manager.session_scope() as session_db: + account = session_db.query(Account).filter(Account.email == "tester@example.com").first() + assert account.status == "active" + assert account.workspace_id == "ws-about-you-same-session" + + +def test_run_recovers_outlook_account_from_authorize_replay_after_about_you_user_exists(monkeypatch): + from src.database import crud + from src.database.models import Account + + manager = _make_engine_session_manager("recoverable-about-you-authorize-replay.db") + + with manager.session_scope() as session: + crud.create_account( + session, + email="tester@example.com", + password="saved-password", + email_service="outlook", + email_service_id="mailbox-1", + status="failed", + source="register", + extra_data={ + "recovery_ready": True, + "account_created": True, + "token_acquired": False, + "register_failed_reason": "token_recovery_pending", + "last_recovery_error": "获取 Workspace ID 失败", + }, + ) + + @contextmanager + def fake_get_db(): + session = manager.SessionLocal() + try: + yield session + finally: + session.close() + + monkeypatch.setattr("src.core.register.get_db", fake_get_db) + + user_exists_response = DummyResponse( + status_code=400, + payload={ + "error": { + "message": "An account already exists for this email address.", + "code": "user_already_exists", + } + }, + text='{"error":{"message":"An account already exists for this email address.","code":"user_already_exists"}}', + ) + + oauth_replay_url = ( + "https://auth.example.test/api/oauth/oauth2/auth" + "?client_id=client-1&state=resume-1" + ) + login_challenge_url = ( + "https://auth.example.test/api/accounts/login?login_challenge=challenge-after-about-you" + ) + + session = QueueSession([ + ("GET", "https://auth.example.test/flow/1", _response_with_did("did-1")), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]}}), + ), + ( + "POST", + OPENAI_API_ENDPOINTS["password_verify"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}}), + ), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ( + "POST", + OPENAI_API_ENDPOINTS["validate_otp"], + DummyResponse( + payload={ + "page": {"type": "about_you"}, + "method": "GET", + "continue_url": "https://auth.example.test/about-you", + }, + on_return=_response_with_login_cookies_without_workspace().on_return, + ), + ), + ( + "GET", + "https://auth.example.test/about-you", + DummyResponse(status_code=200, url="https://auth.example.test/about-you", text="about you"), + ), + ("POST", OPENAI_API_ENDPOINTS["create_account"], user_exists_response), + ( + "GET", + "https://auth.example.test/flow/1", + DummyResponse( + status_code=302, + headers={"Location": oauth_replay_url}, + ), + ), + ( + "GET", + oauth_replay_url, + DummyResponse( + status_code=302, + headers={"Location": login_challenge_url}, + ), + ), + ( + "GET", + login_challenge_url, + DummyResponse( + status_code=302, + headers={"Location": "http://localhost:1455/auth/callback?code=code-about-you-replay&state=state-1"}, + ), + ), + ]) + + email_service = FakeOutlookEmailService(["654321"]) + engine = RegistrationEngine(email_service) + engine.http_client = FakeOpenAIClient([session], ["sentinel-1"]) + engine.oauth_manager = FakeOAuthManagerWithWorkspace("ws-about-you-authorize-replay") + + result = engine.run() + + assert result.success is True + assert result.workspace_id == "ws-about-you-authorize-replay" + assert result.metadata["last_oauth_resume_source"] == "callback_found_from_authorize_replay" + assert len([call for call in session.calls if call["url"] == OPENAI_API_ENDPOINTS["validate_otp"]]) == 1 + assert len([call for call in session.calls if call["url"] == "https://auth.example.test/flow/1"]) == 2 + assert [item["stage"] for item in email_service.otp_requests] == ["relogin_otp"] + + with manager.session_scope() as session_db: + account = session_db.query(Account).filter(Account.email == "tester@example.com").first() + assert account.status == "active" + assert account.workspace_id == "ws-about-you-authorize-replay" + + +def test_run_recovers_outlook_account_from_direct_consent_after_about_you_user_exists(monkeypatch): + from src.database import crud + from src.database.models import Account + + manager = _make_engine_session_manager("recoverable-about-you-direct-consent.db") + + with manager.session_scope() as session: + crud.create_account( + session, + email="tester@example.com", + password="saved-password", + email_service="outlook", + email_service_id="mailbox-1", + status="failed", + source="register", + extra_data={ + "recovery_ready": True, + "account_created": True, + "token_acquired": False, + "register_failed_reason": "token_recovery_pending", + "last_recovery_error": "获取 Workspace ID 失败", + }, + ) + + @contextmanager + def fake_get_db(): + session = manager.SessionLocal() + try: + yield session + finally: + session.close() + + monkeypatch.setattr("src.core.register.get_db", fake_get_db) + + user_exists_response = DummyResponse( + status_code=400, + payload={ + "error": { + "message": "An account already exists for this email address.", + "code": "user_already_exists", + } + }, + text='{"error":{"message":"An account already exists for this email address.","code":"user_already_exists"}}', + ) + + oauth_replay_url = ( + "https://auth.example.test/api/oauth/oauth2/auth" + "?client_id=client-1&state=resume-1" + ) + login_challenge_url = ( + "https://auth.example.test/api/accounts/login?login_challenge=challenge-after-about-you" + ) + consent_url = "https://auth.openai.com/sign-in-with-chatgpt/codex/consent" + + def consent_response(): + def setter(queue_session): + queue_session.cookies["oai-client-auth-session"] = _workspace_cookie("ws-about-you-direct-consent") + queue_session.cookies["__Secure-next-auth.session-token"] = "session-about-you-direct-consent" + + return DummyResponse( + status_code=200, + url=consent_url, + text="consent", + on_return=setter, + ) + + session = QueueSession([ + ("GET", "https://auth.example.test/flow/1", _response_with_did("did-1")), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]}}), + ), + ( + "POST", + OPENAI_API_ENDPOINTS["password_verify"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}}), + ), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ( + "POST", + OPENAI_API_ENDPOINTS["validate_otp"], + DummyResponse( + payload={ + "page": {"type": "about_you"}, + "method": "GET", + "continue_url": "https://auth.example.test/about-you", + }, + on_return=_response_with_login_cookies_without_workspace("session-about-you-initial").on_return, + ), + ), + ( + "GET", + "https://auth.example.test/about-you", + DummyResponse(status_code=200, url="https://auth.example.test/about-you", text="about you"), + ), + ("POST", OPENAI_API_ENDPOINTS["create_account"], user_exists_response), + ( + "GET", + "https://auth.example.test/flow/1", + DummyResponse( + status_code=302, + headers={"Location": oauth_replay_url}, + ), + ), + ( + "GET", + oauth_replay_url, + DummyResponse( + status_code=302, + headers={"Location": login_challenge_url}, + ), + ), + ( + "GET", + login_challenge_url, + DummyResponse( + status_code=302, + headers={"Location": "https://auth.example.test/log-in"}, + ), + ), + ( + "GET", + "https://auth.example.test/log-in", + DummyResponse( + status_code=200, + url="https://auth.example.test/log-in", + text="login", + ), + ), + ( + "GET", + login_challenge_url, + DummyResponse( + status_code=302, + headers={"Location": "https://auth.example.test/log-in"}, + ), + ), + ( + "GET", + "https://auth.example.test/log-in", + DummyResponse( + status_code=200, + url="https://auth.example.test/log-in", + text="login again", + ), + ), + ("GET", consent_url, consent_response()), + ("GET", consent_url, consent_response()), + ( + "POST", + OPENAI_API_ENDPOINTS["select_workspace"], + DummyResponse(payload={"continue_url": "https://auth.example.test/consent-continue"}), + ), + ( + "GET", + "https://auth.example.test/consent-continue", + DummyResponse( + status_code=302, + headers={"Location": "http://localhost:1455/auth/callback?code=code-about-you-consent&state=state-1"}, + ), + ), + ]) + + email_service = FakeOutlookEmailService(["654321"]) + engine = RegistrationEngine(email_service) + engine.http_client = FakeOpenAIClient([session], ["sentinel-1"]) + engine.oauth_manager = FakeOAuthManagerWithWorkspace("ws-about-you-direct-consent") + + result = engine.run() + + assert result.success is True + assert result.workspace_id == "ws-about-you-direct-consent" + assert result.session_token == "session-about-you-direct-consent" + assert any(call["url"] == consent_url for call in session.calls) + assert any(call["url"] == OPENAI_API_ENDPOINTS["select_workspace"] for call in session.calls) + + with manager.session_scope() as session_db: + account = session_db.query(Account).filter(Account.email == "tester@example.com").first() + assert account.status == "active" + assert account.workspace_id == "ws-about-you-direct-consent" + + +def test_run_recovers_outlook_account_from_direct_consent_next_data_workspace(monkeypatch): + from src.database import crud + from src.database.models import Account + + manager = _make_engine_session_manager("recoverable-about-you-direct-consent-next-data.db") + + with manager.session_scope() as session: + crud.create_account( + session, + email="tester@example.com", + password="saved-password", + email_service="outlook", + email_service_id="mailbox-1", + status="failed", + source="register", + extra_data={ + "recovery_ready": True, + "account_created": True, + "token_acquired": False, + "register_failed_reason": "token_recovery_pending", + "last_recovery_error": "获取 Workspace ID 失败", + }, + ) + + @contextmanager + def fake_get_db(): + session = manager.SessionLocal() + try: + yield session + finally: + session.close() + + monkeypatch.setattr("src.core.register.get_db", fake_get_db) + + user_exists_response = DummyResponse( + status_code=400, + payload={ + "error": { + "message": "An account already exists for this email address.", + "code": "user_already_exists", + } + }, + text='{"error":{"message":"An account already exists for this email address.","code":"user_already_exists"}}', + ) + + oauth_replay_url = ( + "https://auth.example.test/api/oauth/oauth2/auth" + "?client_id=client-1&state=resume-1" + ) + login_challenge_url = ( + "https://auth.example.test/api/accounts/login?login_challenge=challenge-after-about-you" + ) + consent_url = "https://auth.openai.com/sign-in-with-chatgpt/codex/consent" + organization_select_url = "https://auth.openai.com/api/accounts/organization/select" + + session = QueueSession([ + ("GET", "https://auth.example.test/flow/1", _response_with_did("did-1")), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]}}), + ), + ( + "POST", + OPENAI_API_ENDPOINTS["password_verify"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}}), + ), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ( + "POST", + OPENAI_API_ENDPOINTS["validate_otp"], + DummyResponse( + payload={ + "page": {"type": "about_you"}, + "method": "GET", + "continue_url": "https://auth.example.test/about-you", + }, + on_return=_response_with_login_cookies_without_workspace("session-about-you-initial").on_return, + ), + ), + ( + "GET", + "https://auth.example.test/about-you", + DummyResponse(status_code=200, url="https://auth.example.test/about-you", text="about you"), + ), + ("POST", OPENAI_API_ENDPOINTS["create_account"], user_exists_response), + ( + "GET", + "https://auth.example.test/flow/1", + DummyResponse( + status_code=302, + headers={"Location": oauth_replay_url}, + ), + ), + ( + "GET", + oauth_replay_url, + DummyResponse( + status_code=302, + headers={"Location": login_challenge_url}, + ), + ), + ( + "GET", + login_challenge_url, + DummyResponse( + status_code=302, + headers={"Location": "https://auth.example.test/log-in"}, + ), + ), + ( + "GET", + "https://auth.example.test/log-in", + DummyResponse( + status_code=200, + url="https://auth.example.test/log-in", + text="login", + ), + ), + ( + "GET", + login_challenge_url, + DummyResponse( + status_code=302, + headers={"Location": "https://auth.example.test/log-in"}, + ), + ), + ( + "GET", + "https://auth.example.test/log-in", + DummyResponse( + status_code=200, + url="https://auth.example.test/log-in", + text="login again", + ), + ), + ("GET", consent_url, _response_with_consent_next_data_workspace("ws-about-you-consent-html")), + ("GET", consent_url, _response_with_consent_next_data_workspace("ws-about-you-consent-html")), + ( + "POST", + OPENAI_API_ENDPOINTS["select_workspace"], + DummyResponse( + payload={ + "continue_url": "https://auth.example.test/organization", + "page": {"type": "organization_selection"}, + "data": { + "orgs": [ + { + "id": "org-consent", + "projects": [{"id": "proj-consent"}], + } + ] + }, + } + ), + ), + ( + "POST", + organization_select_url, + DummyResponse( + status_code=302, + headers={"Location": "http://localhost:1455/auth/callback?code=code-about-you-consent-html&state=state-1"}, + ), + ), + ]) + + email_service = FakeOutlookEmailService(["654321"]) + engine = RegistrationEngine(email_service) + engine.http_client = FakeOpenAIClient([session], ["sentinel-1"]) + engine.oauth_manager = FakeOAuthManagerWithWorkspace("ws-about-you-consent-html") + + result = engine.run() + + assert result.success is True + assert result.workspace_id == "ws-about-you-consent-html" + assert result.session_token == "session-consent-next-data" + assert any(call["url"] == consent_url for call in session.calls) + assert any(call["url"] == OPENAI_API_ENDPOINTS["select_workspace"] for call in session.calls) + assert any(call["url"] == organization_select_url for call in session.calls) + + with manager.session_scope() as session_db: + account = session_db.query(Account).filter(Account.email == "tester@example.com").first() + assert account.status == "active" + assert account.workspace_id == "ws-about-you-consent-html" + + +def test_run_recovers_outlook_account_from_direct_consent_app_router_workspace(monkeypatch): + from src.database import crud + from src.database.models import Account + + manager = _make_engine_session_manager("recoverable-about-you-direct-consent-app-router.db") + + with manager.session_scope() as session: + crud.create_account( + session, + email="tester@example.com", + password="saved-password", + email_service="outlook", + email_service_id="mailbox-1", + status="failed", + source="register", + extra_data={ + "recovery_ready": True, + "account_created": True, + "token_acquired": False, + "register_failed_reason": "token_recovery_pending", + "last_recovery_error": "鑾峰彇 Workspace ID 澶辫触", + }, + ) + + @contextmanager + def fake_get_db(): + session = manager.SessionLocal() + try: + yield session + finally: + session.close() + + monkeypatch.setattr("src.core.register.get_db", fake_get_db) + + user_exists_response = DummyResponse( + status_code=400, + payload={ + "error": { + "message": "An account already exists for this email address.", + "code": "user_already_exists", + } + }, + text='{"error":{"message":"An account already exists for this email address.","code":"user_already_exists"}}', + ) + + oauth_replay_url = ( + "https://auth.example.test/api/oauth/oauth2/auth" + "?client_id=client-1&state=resume-1" + ) + login_challenge_url = ( + "https://auth.example.test/api/accounts/login?login_challenge=challenge-after-about-you" + ) + consent_url = "https://auth.openai.com/sign-in-with-chatgpt/codex/consent" + + session = QueueSession([ + ("GET", "https://auth.example.test/flow/1", _response_with_did("did-1")), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]}}), + ), + ( + "POST", + OPENAI_API_ENDPOINTS["password_verify"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}}), + ), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ( + "POST", + OPENAI_API_ENDPOINTS["validate_otp"], + DummyResponse( + payload={ + "page": {"type": "about_you"}, + "method": "GET", + "continue_url": "https://auth.example.test/about-you", + }, + on_return=_response_with_login_cookies_without_workspace("session-about-you-initial").on_return, + ), + ), + ( + "GET", + "https://auth.example.test/about-you", + DummyResponse(status_code=200, url="https://auth.example.test/about-you", text="about you"), + ), + ("POST", OPENAI_API_ENDPOINTS["create_account"], user_exists_response), + ( + "GET", + "https://auth.example.test/flow/1", + DummyResponse( + status_code=302, + headers={"Location": oauth_replay_url}, + ), + ), + ( + "GET", + oauth_replay_url, + DummyResponse( + status_code=302, + headers={"Location": login_challenge_url}, + ), + ), + ( + "GET", + login_challenge_url, + DummyResponse( + status_code=302, + headers={"Location": "https://auth.example.test/log-in"}, + ), + ), + ( + "GET", + "https://auth.example.test/log-in", + DummyResponse( + status_code=200, + url="https://auth.example.test/log-in", + text="login", + ), + ), + ( + "GET", + login_challenge_url, + DummyResponse( + status_code=302, + headers={"Location": "https://auth.example.test/log-in"}, + ), + ), + ( + "GET", + "https://auth.example.test/log-in", + DummyResponse( + status_code=200, + url="https://auth.example.test/log-in", + text="login again", + ), + ), + ("GET", consent_url, _response_with_consent_app_router_workspace("ws-about-you-consent-rsc")), + ("GET", consent_url, _response_with_consent_app_router_workspace("ws-about-you-consent-rsc")), + ( + "POST", + OPENAI_API_ENDPOINTS["select_workspace"], + DummyResponse(payload={"continue_url": "https://auth.example.test/consent-continue"}), + ), + ( + "GET", + "https://auth.example.test/consent-continue", + DummyResponse( + status_code=302, + headers={"Location": "http://localhost:1455/auth/callback?code=code-about-you-consent-rsc&state=state-1"}, + ), + ), + ]) + + email_service = FakeOutlookEmailService(["654321"]) + engine = RegistrationEngine(email_service) + engine.http_client = FakeOpenAIClient([session], ["sentinel-1"]) + engine.oauth_manager = FakeOAuthManagerWithWorkspace("ws-about-you-consent-rsc") + + result = engine.run() + + assert result.success is True + assert result.workspace_id == "ws-about-you-consent-rsc" + assert result.session_token == "session-consent-app-router" + assert any(call["url"] == consent_url for call in session.calls) + assert any(call["url"] == OPENAI_API_ENDPOINTS["select_workspace"] for call in session.calls) + + with manager.session_scope() as session_db: + account = session_db.query(Account).filter(Account.email == "tester@example.com").first() + assert account.status == "active" + assert account.workspace_id == "ws-about-you-consent-rsc" + + +def test_run_retries_about_you_submission_after_relogin_when_second_session_still_requires_it(monkeypatch): + from src.database import crud + from src.database.models import Account + + manager = _make_engine_session_manager("recoverable-about-you-repeat-after-relogin.db") + + with manager.session_scope() as session: + crud.create_account( + session, + email="tester@example.com", + password="saved-password", + email_service="outlook", + email_service_id="mailbox-1", + status="failed", + source="register", + extra_data={ + "recovery_ready": True, + "account_created": True, + "token_acquired": False, + "register_failed_reason": "token_recovery_pending", + "last_recovery_error": "获取 Workspace ID 失败", + }, + ) + + @contextmanager + def fake_get_db(): + session = manager.SessionLocal() + try: + yield session + finally: + session.close() + + monkeypatch.setattr("src.core.register.get_db", fake_get_db) + + session = QueueSession([ + ("GET", "https://auth.example.test/flow/1", _response_with_did("did-1")), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse( + payload={ + "page": {"type": OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]}, + "method": "POST", + "continue_url": "https://auth.example.test/log-in/password", + } + ), + ), + ( + "POST", + OPENAI_API_ENDPOINTS["password_verify"], + DummyResponse( + payload={ + "page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}, + "method": "POST", + "continue_url": "https://auth.example.test/email-verification", + } + ), + ), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ( + "POST", + OPENAI_API_ENDPOINTS["validate_otp"], + DummyResponse( + payload={ + "page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}, + "method": "GET", + "continue_url": "https://auth.example.test/about-you", + } + ), + ), + ( + "GET", + "https://auth.example.test/about-you", + DummyResponse(status_code=200, url="https://auth.example.test/about-you", text="about you"), + ), + ( + "POST", + OPENAI_API_ENDPOINTS["create_account"], + DummyResponse( + status_code=400, + payload={ + "error": { + "message": "An account already exists for this email address.", + "code": "user_already_exists", + } + }, + text='{"error":{"message":"An account already exists for this email address.","code":"user_already_exists"}}', + ), + ), + ( + "GET", + "https://auth.example.test/flow/1", + DummyResponse( + status_code=302, + headers={"Location": "https://auth.example.test/log-in"}, + ), + ), + ( + "GET", + "https://auth.example.test/log-in", + DummyResponse( + status_code=200, + url="https://auth.example.test/log-in", + text="login", + ), + ), + ("GET", "https://auth.example.test/flow/2", _response_with_did("did-2")), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]}}), + ), + ( + "POST", + OPENAI_API_ENDPOINTS["password_verify"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}}), + ), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ( + "POST", + OPENAI_API_ENDPOINTS["validate_otp"], + DummyResponse( + payload={ + "page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}, + "method": "GET", + "continue_url": "https://auth.example.test/about-you", + }, + on_return=lambda queue_session: queue_session.cookies.__setitem__( + "__Secure-next-auth.session-token", + "session-about-you-repeat", + ), + ), + ), + ( + "GET", + "https://auth.example.test/about-you", + DummyResponse(status_code=200, url="https://auth.example.test/about-you", text="about you again"), + ), + ( + "POST", + OPENAI_API_ENDPOINTS["create_account"], + DummyResponse( + status_code=400, + payload={ + "error": { + "message": "An account already exists for this email address.", + "code": "user_already_exists", + }, + "callback_url": "http://localhost:1455/auth/callback?code=code-about-you-repeat&state=state-2", + }, + text='{"error":{"message":"An account already exists for this email address.","code":"user_already_exists"}}', + ), + ), + ]) + + email_service = FakeOutlookEmailService(["123456", "654321"]) + engine = RegistrationEngine(email_service) + engine.http_client = FakeOpenAIClient([session], ["sentinel-1", "sentinel-2"]) + engine.oauth_manager = FakeOAuthManagerWithWorkspace("ws-about-you-repeat") + + result = engine.run() + + assert result.success is True + assert result.workspace_id == "ws-about-you-repeat" + assert result.session_token == "session-about-you-repeat" + assert len([call for call in session.calls if call["url"] == OPENAI_API_ENDPOINTS["create_account"]]) == 2 + + with manager.session_scope() as session_db: + account = session_db.query(Account).filter(Account.email == "tester@example.com").first() + assert account.status == "active" + assert account.workspace_id == "ws-about-you-repeat" + + +def test_run_recovers_outlook_account_from_about_you_next_data_callback(monkeypatch): + from src.database import crud + from src.database.models import Account + + manager = _make_engine_session_manager("recoverable-about-you-next-data-callback.db") + + with manager.session_scope() as session: + crud.create_account( + session, + email="tester@example.com", + password="saved-password", + email_service="outlook", + email_service_id="mailbox-1", + status="failed", + source="register", + extra_data={ + "recovery_ready": True, + "account_created": True, + "token_acquired": False, + "register_failed_reason": "token_recovery_pending", + "last_recovery_error": "获取 Workspace ID 失败", + }, + ) + + @contextmanager + def fake_get_db(): + session = manager.SessionLocal() + try: + yield session + finally: + session.close() + + monkeypatch.setattr("src.core.register.get_db", fake_get_db) + + session = QueueSession([ + ("GET", "https://auth.example.test/flow/1", _response_with_did("did-1")), + ("POST", OPENAI_API_ENDPOINTS["signup"], DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]}})), + ("POST", OPENAI_API_ENDPOINTS["password_verify"], DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}})), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ( + "POST", + OPENAI_API_ENDPOINTS["validate_otp"], + DummyResponse( + payload={ + "page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}, + "method": "GET", + "continue_url": "https://auth.example.test/about-you", + } + ), + ), + ("GET", "https://auth.example.test/about-you", _response_with_about_you_next_data_callback()), + ( + "POST", + OPENAI_API_ENDPOINTS["create_account"], + DummyResponse( + status_code=400, + payload={ + "error": { + "message": "An account already exists for this email address.", + "code": "user_already_exists", + } + }, + text='{"error":{"message":"An account already exists for this email address.","code":"user_already_exists"}}', + ), + ), + ]) + + email_service = FakeOutlookEmailService(["123456"]) + engine = RegistrationEngine(email_service) + engine.http_client = FakeOpenAIClient([session], ["sentinel-1"]) + engine.oauth_manager = FakeOAuthManagerWithWorkspace("ws-about-you-next-data") + + result = engine.run() + + assert result.success is True + assert result.workspace_id == "ws-about-you-next-data" + assert len([call for call in session.calls if call["url"] == OPENAI_API_ENDPOINTS["validate_otp"]]) == 1 + assert result.metadata["last_oauth_resume_source"] in { + "callback_found_from_about_you_page_text_1", + "callback_found_from_cached_navigation", + } + + with manager.session_scope() as session_db: + account = session_db.query(Account).filter(Account.email == "tester@example.com").first() + assert account.status == "active" + assert account.workspace_id == "ws-about-you-next-data" + + +def test_run_recovers_outlook_account_from_about_you_next_data_resume(monkeypatch): + from src.database import crud + from src.database.models import Account + + manager = _make_engine_session_manager("recoverable-about-you-next-data-resume.db") + + with manager.session_scope() as session: + crud.create_account( + session, + email="tester@example.com", + password="saved-password", + email_service="outlook", + email_service_id="mailbox-1", + status="failed", + source="register", + extra_data={ + "recovery_ready": True, + "account_created": True, + "token_acquired": False, + "register_failed_reason": "token_recovery_pending", + "last_recovery_error": "获取 Workspace ID 失败", + }, + ) + + @contextmanager + def fake_get_db(): + session = manager.SessionLocal() + try: + yield session + finally: + session.close() + + monkeypatch.setattr("src.core.register.get_db", fake_get_db) + + resume_url = "https://auth.example.test/api/oauth/oauth2/auth?client_id=client-1&state=resume-about-you" + session = QueueSession([ + ("GET", "https://auth.example.test/flow/1", _response_with_did("did-1")), + ("POST", OPENAI_API_ENDPOINTS["signup"], DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]}})), + ("POST", OPENAI_API_ENDPOINTS["password_verify"], DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}})), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ( + "POST", + OPENAI_API_ENDPOINTS["validate_otp"], + DummyResponse( + payload={ + "page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}, + "method": "GET", + "continue_url": "https://auth.example.test/about-you", + } + ), + ), + ("GET", "https://auth.example.test/about-you", _response_with_about_you_next_data_resume(resume_url)), + ( + "POST", + OPENAI_API_ENDPOINTS["create_account"], + DummyResponse( + status_code=400, + payload={ + "error": { + "message": "An account already exists for this email address.", + "code": "user_already_exists", + } + }, + text='{"error":{"message":"An account already exists for this email address.","code":"user_already_exists"}}', + ), + ), + ( + "GET", + resume_url, + DummyResponse( + status_code=302, + headers={"Location": "http://localhost:1455/auth/callback?code=code-about-you-resume&state=state-1"}, + ), + ), + ]) + + email_service = FakeOutlookEmailService(["123456"]) + engine = RegistrationEngine(email_service) + engine.http_client = FakeOpenAIClient([session], ["sentinel-1"]) + engine.oauth_manager = FakeOAuthManagerWithWorkspace("ws-about-you-next-resume") + + result = engine.run() + + assert result.success is True + assert result.workspace_id == "ws-about-you-next-resume" + assert any(call["url"] == resume_url for call in session.calls) + assert len([call for call in session.calls if call["url"] == OPENAI_API_ENDPOINTS["validate_otp"]]) == 1 + + with manager.session_scope() as session_db: + account = session_db.query(Account).filter(Account.email == "tester@example.com").first() + assert account.status == "active" + assert account.workspace_id == "ws-about-you-next-resume" + + +def test_run_stops_relogin_after_repeated_about_you_user_exists_without_resume_evidence(monkeypatch): + from src.database import crud + from src.database.models import Account + + manager = _make_engine_session_manager("recoverable-about-you-user-exists-exhausted.db") + + with manager.session_scope() as session: + crud.create_account( + session, + email="tester@example.com", + password="saved-password", + email_service="outlook", + email_service_id="mailbox-1", + status="failed", + source="register", + extra_data={ + "recovery_ready": True, + "account_created": True, + "token_acquired": False, + "register_failed_reason": "token_recovery_pending", + "last_recovery_error": "获取 Workspace ID 失败", + }, + ) + + @contextmanager + def fake_get_db(): + session = manager.SessionLocal() + try: + yield session + finally: + session.close() + + monkeypatch.setattr("src.core.register.get_db", fake_get_db) + + user_exists_response = DummyResponse( + status_code=400, + payload={ + "error": { + "message": "An account already exists for this email address.", + "code": "user_already_exists", + } + }, + text='{"error":{"message":"An account already exists for this email address.","code":"user_already_exists"}}', + ) + + session = QueueSession([ + ("GET", "https://auth.example.test/flow/1", _response_with_did("did-1")), + ("POST", OPENAI_API_ENDPOINTS["signup"], DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]}})), + ("POST", OPENAI_API_ENDPOINTS["password_verify"], DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}})), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ( + "POST", + OPENAI_API_ENDPOINTS["validate_otp"], + DummyResponse( + payload={ + "page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}, + "method": "GET", + "continue_url": "https://auth.example.test/about-you", + } + ), + ), + ("GET", "https://auth.example.test/about-you", DummyResponse(status_code=200, url="https://auth.example.test/about-you", text="about you")), + ("POST", OPENAI_API_ENDPOINTS["create_account"], user_exists_response), + ( + "GET", + "https://auth.example.test/flow/1", + DummyResponse( + status_code=302, + headers={"Location": "https://auth.example.test/log-in"}, + ), + ), + ( + "GET", + "https://auth.example.test/log-in", + DummyResponse( + status_code=200, + url="https://auth.example.test/log-in", + text="login", + ), + ), + ("GET", "https://auth.example.test/flow/2", _response_with_did("did-2")), + ("POST", OPENAI_API_ENDPOINTS["signup"], DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]}})), + ("POST", OPENAI_API_ENDPOINTS["password_verify"], DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}})), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ( + "POST", + OPENAI_API_ENDPOINTS["validate_otp"], + DummyResponse( + payload={ + "page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}, + "method": "GET", + "continue_url": "https://auth.example.test/about-you", + } + ), + ), + ("GET", "https://auth.example.test/about-you", DummyResponse(status_code=200, url="https://auth.example.test/about-you", text="about you again")), + ("POST", OPENAI_API_ENDPOINTS["create_account"], user_exists_response), + ( + "GET", + "https://auth.example.test/flow/2", + DummyResponse( + status_code=302, + headers={"Location": "https://auth.example.test/log-in"}, + ), + ), + ( + "GET", + "https://auth.example.test/log-in", + DummyResponse( + status_code=200, + url="https://auth.example.test/log-in", + text="login again", + ), + ), + ]) + + email_service = FakeOutlookEmailService(["123456", "654321"]) + engine = RegistrationEngine(email_service) + engine.http_client = FakeOpenAIClient([session], ["sentinel-1", "sentinel-2"]) + engine.oauth_manager = FakeOAuthManager() + + result = engine.run() + + assert result.success is False + assert result.error_message == "about-you 返回 user_already_exists,但连续两次未暴露 callback/workspace" + assert len([call for call in session.calls if call["url"] == OPENAI_API_ENDPOINTS["validate_otp"]]) == 2 + assert any("停止重复登录" in log for log in engine.logs) + + with manager.session_scope() as session_db: + account = session_db.query(Account).filter(Account.email == "tester@example.com").first() + assert account.status == "failed" + assert account.extra_data["last_recovery_error"] == result.error_message + assert account.extra_data["last_oauth_resume_source"] == "about_you_user_exists_without_resume_exhausted" + assert account.extra_data["last_workspace_resolution_source"] == "about_you_user_exists_without_resume" + + +def test_run_recovers_outlook_account_from_authorize_replay_login_challenge(monkeypatch): + from src.database import crud + from src.database.models import Account + + manager = _make_engine_session_manager("recoverable-authorize-login-challenge-success.db") + + with manager.session_scope() as session: + crud.create_account( + session, + email="tester@example.com", + password="saved-password", + email_service="outlook", + email_service_id="mailbox-1", + status="failed", + source="register", + extra_data={ + "recovery_ready": True, + "account_created": True, + "token_acquired": False, + "register_failed_reason": "token_recovery_pending", + "last_recovery_error": "获取 Workspace ID 失败", + }, + ) + + @contextmanager + def fake_get_db(): + session = manager.SessionLocal() + try: + yield session + finally: + session.close() + + monkeypatch.setattr("src.core.register.get_db", fake_get_db) + + session = QueueSession([ + ("GET", "https://auth.example.test/flow/1", _response_with_did("did-1")), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]}}), + ), + ( + "POST", + OPENAI_API_ENDPOINTS["password_verify"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}}), + ), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ("POST", OPENAI_API_ENDPOINTS["validate_otp"], _response_with_login_cookies_without_workspace()), + ( + "GET", + "https://auth.example.test/flow/1", + DummyResponse( + status_code=302, + headers={"Location": "https://auth.example.test/login-challenge?login_challenge=replay-success"}, + ), + ), + ( + "GET", + "https://auth.example.test/login-challenge?login_challenge=replay-success", + DummyResponse( + status_code=302, + headers={"Location": "http://localhost:1455/auth/callback?code=code-1&state=state-1"}, + ), + ), + ]) + + email_service = FakeOutlookEmailService(["789012"]) + engine = RegistrationEngine(email_service) + engine.http_client = FakeOpenAIClient([session], ["sentinel-1"]) + engine.oauth_manager = FakeOAuthManagerWithWorkspace("ws-authorize-replay") + + result = engine.run() + + assert result.success is True + assert result.workspace_id == "ws-authorize-replay" + assert result.metadata["last_oauth_resume_source"] == "callback_found_from_authorize_replay" + assert "callback_found_in_redirect_chain" in (result.metadata["last_recovery_debug_summary"] or "") + assert "%3Credacted%3E" in (result.metadata["last_recovery_debug_summary"] or "") + + with manager.session_scope() as session_db: + account = session_db.query(Account).filter(Account.email == "tester@example.com").first() + assert account.status == "active" + assert account.workspace_id == "ws-authorize-replay" + + +def test_submit_login_start_omits_screen_hint_like_login_web(): + session = QueueSession([ + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]}}), + ), + ]) + + email_service = FakeEmailService([]) + engine = RegistrationEngine(email_service) + engine.session = session + engine.email = "tester@example.com" + + result = engine._submit_login_start("did-1", "sentinel-1") + + assert result.success is True + payload = json.loads(session.calls[0]["kwargs"]["data"]) + assert payload == { + "username": { + "value": "tester@example.com", + "kind": "email", + }, + } + + +def test_start_saved_password_recovery_uses_login_screen_hint(): + session = QueueSession([ + ("GET", "https://auth.example.test/flow/1", _response_with_did("did-1")), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]}}), + ), + ( + "POST", + OPENAI_API_ENDPOINTS["password_verify"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}}), + ), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ]) + + email_service = FakeOutlookEmailService([]) + engine = RegistrationEngine(email_service) + engine.email = "tester@example.com" + engine.password = "saved-password" + engine.http_client = FakeOpenAIClient([session], ["sentinel-1"]) + engine.oauth_manager = FakeOAuthManager() + + ok, error = engine._start_saved_password_recovery() + + assert ok is True + assert error == "" + payload = json.loads(session.calls[1]["kwargs"]["data"]) + assert payload["screen_hint"] == "login" + + +def test_run_recovers_outlook_account_with_session_bound_reauth_after_bare_login(monkeypatch): + from src.database import crud + from src.database.models import Account + + manager = _make_engine_session_manager("recoverable-session-bound-reauth-success.db") + + with manager.session_scope() as session: + crud.create_account( + session, + email="tester@example.com", + password="saved-password", + email_service="outlook", + email_service_id="mailbox-1", + status="failed", + source="register", + extra_data={ + "recovery_ready": True, + "account_created": True, + "token_acquired": False, + "register_failed_reason": "token_recovery_pending", + "last_recovery_error": "获取 Workspace ID 失败", + }, + ) + + @contextmanager + def fake_get_db(): + session = manager.SessionLocal() + try: + yield session + finally: + session.close() + + monkeypatch.setattr("src.core.register.get_db", fake_get_db) + + session = QueueSession([ + ("GET", "https://auth.example.test/flow/1", _response_with_did("did-1")), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]}}), + ), + ( + "POST", + OPENAI_API_ENDPOINTS["password_verify"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}}), + ), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ("POST", OPENAI_API_ENDPOINTS["validate_otp"], _response_with_login_cookies_without_workspace()), + ( + "GET", + "https://auth.example.test/flow/1", + DummyResponse( + status_code=302, + headers={"Location": "https://auth.example.test/login-challenge?login_challenge=reauth-1"}, + ), + ), + ( + "GET", + "https://auth.example.test/login-challenge?login_challenge=reauth-1", + DummyResponse( + status_code=302, + headers={"Location": "https://auth.example.test/log-in"}, + ), + ), + ( + "GET", + "https://auth.example.test/log-in", + DummyResponse(status_code=200, payload={}, url="https://auth.example.test/log-in"), + ), + ( + "GET", + "https://auth.example.test/login-challenge?login_challenge=reauth-1", + DummyResponse( + status_code=302, + headers={"Location": "https://auth.example.test/log-in"}, + ), + ), + ( + "GET", + "https://auth.example.test/log-in", + DummyResponse(status_code=200, payload={}, url="https://auth.example.test/log-in"), + ), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]}}), + ), + ( + "POST", + OPENAI_API_ENDPOINTS["password_verify"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}}), + ), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ( + "POST", + OPENAI_API_ENDPOINTS["validate_otp"], + _response_with_callback_before_workspace( + callback_url="http://localhost:1455/auth/callback?code=code-reauth&state=state-reauth", + session_token="session-reauth", + ), + ), + ]) + + email_service = FakeOutlookEmailService(["123456", "654321"]) + engine = RegistrationEngine(email_service) + engine.http_client = FakeOpenAIClient([session], ["sentinel-1", "sentinel-2"]) + engine.oauth_manager = FakeOAuthManagerWithWorkspace("ws-session-reauth") + + result = engine.run() + + assert result.success is True + assert result.workspace_id == "ws-session-reauth" + assert result.session_token == "session-reauth" + assert result.metadata["last_oauth_resume_source"] == "session_bound_reauth" + assert "session_bound_reauth_callback_resolved" in (result.metadata["last_recovery_debug_summary"] or "") + assert len([call for call in session.calls if call["url"] == OPENAI_API_ENDPOINTS["validate_otp"]]) == 2 + assert [item["stage"] for item in email_service.otp_requests] == ["relogin_otp", "relogin_otp"] + + with manager.session_scope() as session_db: + account = session_db.query(Account).filter(Account.email == "tester@example.com").first() + assert account.status == "active" + assert account.workspace_id == "ws-session-reauth" + + +def test_run_reports_reentered_login_page_when_fresh_authorize_replay_loops_back_to_log_in(monkeypatch): + from src.database.models import Account + + manager = _make_engine_session_manager("recoverable-authorize-replay-fail.db") + + @contextmanager + def fake_get_db(): + session = manager.SessionLocal() + try: + yield session + finally: + session.close() + + monkeypatch.setattr("src.core.register.get_db", fake_get_db) + + session_one = QueueSession([ + ("GET", "https://auth.example.test/flow/1", _response_with_did("did-1")), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["PASSWORD_REGISTRATION"]}}), + ), + ("POST", OPENAI_API_ENDPOINTS["register"], DummyResponse(payload={})), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ("POST", OPENAI_API_ENDPOINTS["validate_otp"], DummyResponse(payload={})), + ("POST", OPENAI_API_ENDPOINTS["create_account"], DummyResponse(payload={})), + ]) + session_two = QueueSession([ + ("GET", "https://auth.example.test/flow/2", _response_with_did("did-2")), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]}}), + ), + ( + "POST", + OPENAI_API_ENDPOINTS["password_verify"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}}), + ), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ("POST", OPENAI_API_ENDPOINTS["validate_otp"], _response_with_login_cookies_without_workspace()), + ( + "GET", + "https://auth.example.test/flow/2", + DummyResponse( + status_code=302, + headers={"Location": "https://auth.example.test/login-challenge?login_challenge=replay-1"}, + ), + ), + ( + "GET", + "https://auth.example.test/login-challenge?login_challenge=replay-1", + DummyResponse( + status_code=302, + headers={"Location": "https://auth.example.test/log-in"}, + ), + ), + ( + "GET", + "https://auth.example.test/log-in", + DummyResponse(status_code=200, payload={}, url="https://auth.example.test/log-in"), + ), + ( + "GET", + "https://auth.example.test/login-challenge?login_challenge=replay-1", + DummyResponse( + status_code=302, + headers={"Location": "https://auth.example.test/log-in"}, + ), + ), + ( + "GET", + "https://auth.example.test/log-in", + DummyResponse(status_code=200, payload={}, url="https://auth.example.test/log-in"), + ), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]}}), + ), + ( + "POST", + OPENAI_API_ENDPOINTS["password_verify"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}}), + ), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ("POST", OPENAI_API_ENDPOINTS["validate_otp"], _response_with_login_cookies_without_workspace()), + ( + "GET", + "https://auth.example.test/flow/2", + DummyResponse( + status_code=302, + headers={"Location": "https://auth.example.test/login-challenge?login_challenge=replay-2"}, + ), + ), + ( + "GET", + "https://auth.example.test/login-challenge?login_challenge=replay-2", + DummyResponse( + status_code=302, + headers={"Location": "https://auth.example.test/log-in"}, + ), + ), + ( + "GET", + "https://auth.example.test/log-in", + DummyResponse(status_code=200, payload={}, url="https://auth.example.test/log-in"), + ), + ( + "GET", + "https://auth.example.test/login-challenge?login_challenge=replay-2", + DummyResponse( + status_code=302, + headers={"Location": "https://auth.example.test/log-in"}, + ), + ), + ( + "GET", + "https://auth.example.test/log-in", + DummyResponse(status_code=200, payload={}, url="https://auth.example.test/log-in"), + ), + ]) + + email_service = FakeOutlookEmailService(["123456", "654321", "777888"]) + engine = RegistrationEngine(email_service) + engine.http_client = FakeOpenAIClient( + [session_one, session_two], + ["sentinel-1", "sentinel-2", "sentinel-3"], + ) + engine.oauth_manager = FakeOAuthManager() + + result = engine.run() + + assert result.success is False + assert result.error_message == "OAuth 恢复链路重新进入登录页" + + with manager.session_scope() as session_db: + account = session_db.query(Account).filter(Account.email == "tester@example.com").first() + extra_data = dict(account.extra_data or {}) + + assert extra_data["recovery_ready"] is True + assert extra_data["last_recovery_error"] == "OAuth 恢复链路重新进入登录页" + assert extra_data["last_oauth_resume_source"] == "session_bound_reauth_reentered_login" + assert "session_bound_reauth_reentered_login" in (extra_data.get("last_recovery_debug_summary") or "") + + +def test_run_records_workspace_resolution_metadata_when_all_sources_missing(monkeypatch): + from src.database.models import Account + + manager = _make_engine_session_manager("recoverable-workspace-metadata.db") + + @contextmanager + def fake_get_db(): + session = manager.SessionLocal() + try: + yield session + finally: + session.close() + + monkeypatch.setattr("src.core.register.get_db", fake_get_db) + + session_one = QueueSession([ + ("GET", "https://auth.example.test/flow/1", _response_with_did("did-1")), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["PASSWORD_REGISTRATION"]}}), + ), + ("POST", OPENAI_API_ENDPOINTS["register"], DummyResponse(payload={})), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ("POST", OPENAI_API_ENDPOINTS["validate_otp"], DummyResponse(payload={})), + ("POST", OPENAI_API_ENDPOINTS["create_account"], DummyResponse(payload={})), + ]) + session_two = QueueSession([ + ("GET", "https://auth.example.test/flow/2", _response_with_did("did-2")), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]}}), + ), + ( + "POST", + OPENAI_API_ENDPOINTS["password_verify"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}}), + ), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ("POST", OPENAI_API_ENDPOINTS["validate_otp"], _response_with_login_cookies_without_workspace()), + ]) + + email_service = FakeOutlookEmailService(["123456", "654321"]) + engine = RegistrationEngine(email_service) + engine.http_client = FakeOpenAIClient([session_one, session_two], ["sentinel-1", "sentinel-2"]) + engine.oauth_manager = FakeOAuthManager() + + result = engine.run() + + assert result.success is False + + with manager.session_scope() as session: + account = session.query(Account).filter(Account.email == "tester@example.com").first() + extra_data = dict(account.extra_data or {}) + + assert extra_data["workspace_acquired"] is False + assert extra_data["last_workspace_resolution_source"] == "auth_cookie_decoded_without_workspace" + assert extra_data["last_workspace_resolution_error"] == "获取 Workspace ID 失败" + + +def _outlook_verification_email(code, received_timestamp): + return EmailMessage( + id=f"msg-{code}-{received_timestamp}", + subject=f"Your ChatGPT code is {code}", + sender="noreply@tm.openai.com", + recipients=["tester@outlook.com"], + body=f"Your ChatGPT code is {code}", + received_at=datetime.fromtimestamp(received_timestamp), + received_timestamp=received_timestamp, + is_read=False, + ) + + +def _outlook_verification_email_from_sender(code, received_timestamp, sender): + return EmailMessage( + id=f"msg-{code}-{received_timestamp}-{sender}", + subject=f"Your ChatGPT code is {code}", + sender=sender, + recipients=["tester@outlook.com"], + body=f"Your ChatGPT code is {code}", + received_at=datetime.fromtimestamp(received_timestamp), + received_timestamp=received_timestamp, + is_read=False, + ) + + +def test_outlook_verification_codes_are_isolated_by_stage(monkeypatch): + service = OutlookService({ + "email": "tester@outlook.com", + "password": "pw", + }) + emails = [_outlook_verification_email("123456", 1_700_000_000)] + + monkeypatch.setattr( + "src.services.outlook.service.get_email_code_settings", + lambda: {"timeout": 1, "poll_interval": 0}, + ) + monkeypatch.setattr(service, "_try_providers_for_emails", lambda account, count=15, only_unseen=True: emails) + + service.set_verification_stage("tester@outlook.com", "signup_otp") + first_code = service.get_verification_code( + email="tester@outlook.com", + timeout=1, + otp_sent_at=1_700_000_000, + ) + + service.set_verification_stage("tester@outlook.com", "relogin_otp") + second_code = service.get_verification_code( + email="tester@outlook.com", + timeout=1, + otp_sent_at=1_700_000_000, + ) + + assert first_code == "123456" + assert second_code == "123456" + + +def test_outlook_verification_rejects_codes_outside_current_window(monkeypatch): + service = OutlookService({ + "email": "tester@outlook.com", + "password": "pw", + }) + old_email = [_outlook_verification_email("123456", 1_700_000_000)] + + monkeypatch.setattr( + "src.services.outlook.service.get_email_code_settings", + lambda: {"timeout": 1, "poll_interval": 0}, + ) + monkeypatch.setattr(service, "_try_providers_for_emails", lambda account, count=15, only_unseen=True: old_email) + + service.set_verification_stage("tester@outlook.com", "relogin_otp") + code = service.get_verification_code( + email="tester@outlook.com", + timeout=1, + otp_sent_at=1_700_000_200, + ) + + assert code is None + + +def test_outlook_verification_allows_same_code_from_newer_email_in_same_stage(monkeypatch): + service = OutlookService({ + "email": "tester@outlook.com", + "password": "pw", + }) + first_email = _outlook_verification_email("123456", 1_700_000_000) + second_email = _outlook_verification_email("123456", 1_700_000_030) + batches = [ + [first_email], + [second_email, first_email], + ] + + monkeypatch.setattr( + "src.services.outlook.service.get_email_code_settings", + lambda: {"timeout": 1, "poll_interval": 0}, + ) + monkeypatch.setattr( + service, + "_try_providers_for_emails", + lambda account, count=15, only_unseen=True: batches.pop(0) if batches else [second_email, first_email], + ) + + service.set_verification_stage("tester@outlook.com", "relogin_otp") + first_code = service.get_verification_code( + email="tester@outlook.com", + timeout=1, + otp_sent_at=1_700_000_000, + ) + + service.set_verification_stage("tester@outlook.com", "relogin_otp") + second_code = service.get_verification_code( + email="tester@outlook.com", + timeout=1, + otp_sent_at=1_700_000_030, + ) + + assert first_code == "123456" + assert second_code == "123456" + + +def test_outlook_relogin_prefers_otp_sender_before_generic_noreply(monkeypatch): + service = OutlookService({ + "email": "tester@outlook.com", + "password": "pw", + }) + generic_email = _outlook_verification_email_from_sender( + "111111", + 1_700_000_000, + "noreply@tm.openai.com", + ) + preferred_email = _outlook_verification_email_from_sender( + "222222", + 1_700_000_010, + "OpenAI ", + ) + batches = [ + [generic_email], + [preferred_email, generic_email], + ] + + monkeypatch.setattr( + "src.services.outlook.service.get_email_code_settings", + lambda: {"timeout": 1, "poll_interval": 0}, + ) + monkeypatch.setattr( + service, + "_try_providers_for_emails", + lambda account, count=15, only_unseen=True: batches.pop(0) if batches else [preferred_email, generic_email], + ) + + service.set_verification_stage("tester@outlook.com", "relogin_otp") + code = service.get_verification_code( + email="tester@outlook.com", + timeout=1, + otp_sent_at=1_699_999_990, + ) + + assert code == "222222" + + +def test_outlook_verification_debug_tracks_poll_and_selected_sender(monkeypatch): + service = OutlookService({ + "email": "tester@outlook.com", + "password": "pw", + }) + generic_email = _outlook_verification_email_from_sender( + "111111", + 1_700_000_000, + "noreply@tm.openai.com", + ) + preferred_email = _outlook_verification_email_from_sender( + "222222", + 1_700_000_010, + "OpenAI ", + ) + batches = [ + [generic_email], + [preferred_email, generic_email], + ] + + monkeypatch.setattr( + "src.services.outlook.service.get_email_code_settings", + lambda: {"timeout": 1, "poll_interval": 0}, + ) + monkeypatch.setattr( + service, + "_try_providers_for_emails", + lambda account, count=15, only_unseen=True: batches.pop(0) if batches else [preferred_email, generic_email], + ) + + service.set_verification_stage("tester@outlook.com", "relogin_otp") + code = service.get_verification_code( + email="tester@outlook.com", + timeout=1, + otp_sent_at=1_699_999_990, + ) + debug = service.get_last_verification_debug("tester@outlook.com") + + assert code == "222222" + assert debug["stage"] == "relogin_otp" + assert debug["poll_count"] == 2 + assert debug["selected_sender"] == "OpenAI " + assert debug["selected_code"] == "222222" + assert debug["deferred_generic_only_polls"] == 1 + + +def test_outlook_verification_uses_fresh_generic_when_preferred_candidates_are_stale(monkeypatch): + service = OutlookService({ + "email": "tester@outlook.com", + "password": "pw", + }) + otp_sent_at = 1_700_000_200 + fresh_generic_email = _outlook_verification_email_from_sender( + "333333", + otp_sent_at, + "noreply@tm.openai.com", + ) + stale_preferred_email = _outlook_verification_email_from_sender( + "222222", + otp_sent_at - 600, + "OpenAI ", + ) + + monkeypatch.setattr( + "src.services.outlook.service.get_email_code_settings", + lambda: {"timeout": 1, "poll_interval": 0}, + ) + monkeypatch.setattr( + service, + "_try_providers_for_emails", + lambda account, count=15, only_unseen=True: [fresh_generic_email, stale_preferred_email], + ) + + service.set_verification_stage("tester@outlook.com", "relogin_otp") + code = service.get_verification_code( + email="tester@outlook.com", + timeout=1, + otp_sent_at=otp_sent_at, + ) + debug = service.get_last_verification_debug("tester@outlook.com") + + assert code == "333333" + assert debug["selected_sender"] == "noreply@tm.openai.com" + assert debug["selected_code"] == "333333" + assert debug["fresh_verification_count"] == 1 + assert debug["fresh_preferred_sender_count"] == 0 + assert debug["stale_preferred_sender_count"] == 1 + + +def test_outlook_verification_uses_unused_generic_when_fresh_preferred_was_already_consumed(monkeypatch): + service = OutlookService({ + "email": "tester@outlook.com", + "password": "pw", + }) + first_preferred_email = _outlook_verification_email_from_sender( + "444444", + 1_700_000_000, + "OpenAI ", + ) + second_generic_email = _outlook_verification_email_from_sender( + "444444", + 1_700_000_030, + "noreply@tm.openai.com", + ) + batches = [ + [first_preferred_email], + [second_generic_email, first_preferred_email], + [second_generic_email, first_preferred_email], + [second_generic_email, first_preferred_email], + ] + + monkeypatch.setattr( + "src.services.outlook.service.get_email_code_settings", + lambda: {"timeout": 1, "poll_interval": 0}, + ) + monkeypatch.setattr( + service, + "_try_providers_for_emails", + lambda account, count=15, only_unseen=True: batches.pop(0) if batches else [second_generic_email, first_preferred_email], + ) + + service.set_verification_stage("tester@outlook.com", "relogin_otp") + first_code = service.get_verification_code( + email="tester@outlook.com", + timeout=1, + otp_sent_at=1_700_000_000, + ) + + service.set_verification_stage("tester@outlook.com", "relogin_otp") + second_code = service.get_verification_code( + email="tester@outlook.com", + timeout=1, + otp_sent_at=1_700_000_030, + ) + debug = service.get_last_verification_debug("tester@outlook.com") + + assert first_code == "444444" + assert second_code == "444444" + assert debug["selected_sender"] == "noreply@tm.openai.com" + assert debug["available_fresh_preferred_sender_count"] == 0 + assert debug["used_fresh_preferred_sender_count"] == 1 + + +def test_validate_otp_logs_error_context_for_non_200_response(): + session = QueueSession([ + ( + "POST", + OPENAI_API_ENDPOINTS["validate_otp"], + DummyResponse( + status_code=400, + payload={ + "error": { + "message": "Incorrect code", + "code": "invalid_request_error", + } + }, + text='{"error":{"message":"Incorrect code","code":"invalid_request_error"}}', + headers={"x-request-id": "req-validate-1"}, + url=OPENAI_API_ENDPOINTS["validate_otp"], + ), + ), + ]) + + engine = RegistrationEngine(FakeEmailService([])) + engine.session = session + + ok = engine._validate_verification_code("123456") + + assert ok is False + assert any("req-validate-1" in log for log in engine.logs) + assert any("invalid_request_error" in log for log in engine.logs) + assert any("Incorrect code" in log for log in engine.logs) diff --git a/tests/test_registration_outlook_selection.py b/tests/test_registration_outlook_selection.py new file mode 100644 index 0000000000000000000000000000000000000000..63747b20da1bf6057ed3dfb597042642c6f51cff --- /dev/null +++ b/tests/test_registration_outlook_selection.py @@ -0,0 +1,517 @@ +from contextlib import contextmanager +from pathlib import Path +import threading +import asyncio +from types import SimpleNamespace + +from src.config.constants import EmailServiceType +from src.core.register import RegistrationResult +from src.database.models import Account, Base, EmailService, RegistrationTask +from src.database.session import DatabaseSessionManager +from src.web.routes import registration as registration_routes + + +def _make_session_manager(name): + runtime_dir = Path("tests_runtime") + runtime_dir.mkdir(exist_ok=True) + db_path = runtime_dir / name + if db_path.exists(): + db_path.unlink() + manager = DatabaseSessionManager(f"sqlite:///{db_path}") + Base.metadata.create_all(bind=manager.engine) + return manager + + +@contextmanager +def _patched_get_db(manager): + session = manager.SessionLocal() + try: + yield session + finally: + session.close() + + +def _seed_outlook_services(session): + first = EmailService( + service_type="outlook", + name="Outlook A", + config={ + "email": "reserved-a@example.com", + "client_id": "client-a", + "refresh_token": "refresh-a", + }, + enabled=True, + priority=0, + ) + second = EmailService( + service_type="outlook", + name="Outlook B", + config={ + "email": "available-b@example.com", + "client_id": "client-b", + "refresh_token": "refresh-b", + }, + enabled=True, + priority=1, + ) + session.add_all([first, second]) + session.commit() + session.refresh(first) + session.refresh(second) + return first, second + + +def _seed_failed_outlook_placeholder(session, email, service_name="placeholder"): + account = Account( + email=email, + password="", + email_service="outlook", + email_service_id=str(service_name), + status="failed", + source="register", + extra_data={ + "register_failed_reason": "username_rejected_or_existing_account", + }, + ) + session.add(account) + session.commit() + session.refresh(account) + return account + + +def _seed_recoverable_outlook_account(session, email, service_name="placeholder"): + account = Account( + email=email, + password="saved-password", + email_service="outlook", + email_service_id=str(service_name), + status="failed", + source="register", + extra_data={ + "register_failed_reason": "token_recovery_pending", + "recovery_ready": True, + "account_created": True, + "token_acquired": False, + }, + ) + session.add(account) + session.commit() + session.refresh(account) + return account + + +def _patch_registration_selection(monkeypatch, selected): + def fake_create(service_type, config, name=None): + selected["service_type"] = service_type + selected["config"] = dict(config) + selected["name"] = name + return SimpleNamespace(service_type=service_type, config=config, name=name) + + def fake_run(self): + return RegistrationResult(success=False, error_message="stop after selection") + + monkeypatch.setattr(registration_routes.EmailServiceFactory, "create", fake_create) + monkeypatch.setattr(registration_routes.RegistrationEngine, "run", fake_run) + + +def test_outlook_execution_uses_task_bound_email_service_id_when_runtime_arg_is_none(monkeypatch): + manager = _make_session_manager("outlook-selection-bound.db") + selected = {} + + with manager.session_scope() as session: + first, second = _seed_outlook_services(session) + session.add( + RegistrationTask( + task_uuid="task-bound-email-service", + status="pending", + email_service_id=second.id, + ) + ) + session.commit() + + @contextmanager + def fake_get_db(): + with _patched_get_db(manager) as session: + yield session + + monkeypatch.setattr(registration_routes, "get_db", fake_get_db) + _patch_registration_selection(monkeypatch, selected) + + registration_routes._run_sync_registration_task( + task_uuid="task-bound-email-service", + email_service_type="outlook", + proxy=None, + email_service_config=None, + email_service_id=None, + ) + + assert selected["service_type"] == EmailServiceType.OUTLOOK + assert selected["config"]["email"] == "available-b@example.com" + assert selected["config"]["client_id"] == "client-b" + + +def test_outlook_auto_selection_skips_failed_placeholder_account(monkeypatch): + manager = _make_session_manager("outlook-selection-failed-placeholder.db") + selected = {} + + with manager.session_scope() as session: + first, second = _seed_outlook_services(session) + _seed_failed_outlook_placeholder(session, "reserved-a@example.com") + session.add( + RegistrationTask( + task_uuid="task-skip-failed-placeholder", + status="pending", + ) + ) + session.commit() + + @contextmanager + def fake_get_db(): + with _patched_get_db(manager) as session: + yield session + + monkeypatch.setattr(registration_routes, "get_db", fake_get_db) + _patch_registration_selection(monkeypatch, selected) + + registration_routes._run_sync_registration_task( + task_uuid="task-skip-failed-placeholder", + email_service_type="outlook", + proxy=None, + email_service_config=None, + email_service_id=None, + ) + + assert selected["service_type"] == EmailServiceType.OUTLOOK + assert selected["config"]["email"] == "available-b@example.com" + assert selected["config"]["client_id"] == "client-b" + + +def test_outlook_auto_selection_reports_consumed_accounts_with_clear_error(monkeypatch): + manager = _make_session_manager("outlook-selection-all-consumed.db") + + with manager.session_scope() as session: + first, second = _seed_outlook_services(session) + _seed_failed_outlook_placeholder(session, "reserved-a@example.com") + _seed_failed_outlook_placeholder(session, "available-b@example.com") + session.add( + RegistrationTask( + task_uuid="task-all-consumed", + status="pending", + ) + ) + session.commit() + + @contextmanager + def fake_get_db(): + with _patched_get_db(manager) as session: + yield session + + monkeypatch.setattr(registration_routes, "get_db", fake_get_db) + + registration_routes._run_sync_registration_task( + task_uuid="task-all-consumed", + email_service_type="outlook", + proxy=None, + email_service_config=None, + email_service_id=None, + ) + + with _patched_get_db(manager) as session: + task = session.query(RegistrationTask).filter_by(task_uuid="task-all-consumed").first() + + assert task.status == "failed" + assert "已消耗" in (task.error_message or "") + assert "Outlook" in (task.error_message or "") + + +def test_outlook_bound_consumed_service_is_rejected_before_engine_runs(monkeypatch): + manager = _make_session_manager("outlook-selection-bound-consumed.db") + selected = {} + + with manager.session_scope() as session: + first, second = _seed_outlook_services(session) + _seed_failed_outlook_placeholder(session, "available-b@example.com") + session.add( + RegistrationTask( + task_uuid="task-bound-consumed", + status="pending", + email_service_id=second.id, + ) + ) + session.commit() + + @contextmanager + def fake_get_db(): + with _patched_get_db(manager) as session: + yield session + + monkeypatch.setattr(registration_routes, "get_db", fake_get_db) + + def fake_create(service_type, config, name=None): + selected["service_type"] = service_type + selected["config"] = dict(config) + selected["name"] = name + return SimpleNamespace(service_type=service_type, config=config, name=name) + + def fake_run(self): + selected["engine_ran"] = True + return RegistrationResult(success=False, error_message="should not be reached") + + monkeypatch.setattr(registration_routes.EmailServiceFactory, "create", fake_create) + monkeypatch.setattr(registration_routes.RegistrationEngine, "run", fake_run) + + registration_routes._run_sync_registration_task( + task_uuid="task-bound-consumed", + email_service_type="outlook", + proxy=None, + email_service_config=None, + email_service_id=None, + ) + + with _patched_get_db(manager) as session: + task = session.query(RegistrationTask).filter_by(task_uuid="task-bound-consumed").first() + + assert selected == {} + assert task.status == "failed" + assert "已消耗" in (task.error_message or "") + assert "Outlook" in (task.error_message or "") + + +def test_outlook_auto_selection_skips_reserved_pending_task_and_uses_next_available_service(monkeypatch): + manager = _make_session_manager("outlook-selection-reserved.db") + selected = {} + + with manager.session_scope() as session: + first, second = _seed_outlook_services(session) + session.add_all( + [ + RegistrationTask( + task_uuid="reserved-task", + status="running", + email_service_id=first.id, + ), + RegistrationTask( + task_uuid="selected-task", + status="pending", + ), + ] + ) + session.commit() + + @contextmanager + def fake_get_db(): + with _patched_get_db(manager) as session: + yield session + + monkeypatch.setattr(registration_routes, "get_db", fake_get_db) + _patch_registration_selection(monkeypatch, selected) + + registration_routes._run_sync_registration_task( + task_uuid="selected-task", + email_service_type="outlook", + proxy=None, + email_service_config=None, + email_service_id=None, + ) + + assert selected["service_type"] == EmailServiceType.OUTLOOK + assert selected["config"]["email"] == "available-b@example.com" + assert selected["config"]["client_id"] == "client-b" + + +def test_outlook_auto_selection_reserves_account_atomically_across_concurrent_tasks(monkeypatch): + manager = _make_session_manager("outlook-selection-concurrent.db") + selections = {} + + with manager.session_scope() as session: + first, second = _seed_outlook_services(session) + session.add_all( + [ + RegistrationTask(task_uuid="task-1", status="pending"), + RegistrationTask(task_uuid="task-2", status="pending"), + ] + ) + session.commit() + + @contextmanager + def fake_get_db(): + with _patched_get_db(manager) as session: + yield session + + real_update = registration_routes.crud.update_registration_task + task_1_reached_reservation = threading.Event() + allow_task_1_reservation = threading.Event() + + def fake_update_registration_task(db, task_uuid, **kwargs): + if "email_service_id" in kwargs and task_uuid == "task-1": + task_1_reached_reservation.set() + allow_task_1_reservation.wait(timeout=5) + return real_update(db, task_uuid, **kwargs) + + def fake_create(service_type, config, name=None): + selections[threading.current_thread().name] = dict(config) + return SimpleNamespace(service_type=service_type, config=config, name=name) + + def fake_run(self): + return RegistrationResult(success=False, error_message="stop after selection") + + monkeypatch.setattr(registration_routes, "get_db", fake_get_db) + monkeypatch.setattr(registration_routes.crud, "update_registration_task", fake_update_registration_task) + monkeypatch.setattr(registration_routes.EmailServiceFactory, "create", fake_create) + monkeypatch.setattr(registration_routes.RegistrationEngine, "run", fake_run) + + errors = [] + + def run_task(task_uuid, release_after_entry=False): + try: + if release_after_entry: + task_1_reached_reservation.wait(timeout=5) + registration_routes._run_sync_registration_task( + task_uuid=task_uuid, + email_service_type="outlook", + proxy=None, + email_service_config=None, + email_service_id=None, + ) + except Exception as exc: # pragma: no cover - test diagnostics + errors.append(exc) + finally: + if release_after_entry: + allow_task_1_reservation.set() + + thread_1 = threading.Thread(target=run_task, args=("task-1", False), name="task-1-thread") + thread_2 = threading.Thread(target=run_task, args=("task-2", True), name="task-2-thread") + + thread_1.start() + thread_2.start() + thread_1.join(timeout=10) + thread_2.join(timeout=10) + + assert not errors + assert selections["task-1-thread"]["email"] == "reserved-a@example.com" + assert selections["task-2-thread"]["email"] == "available-b@example.com" + + +def test_outlook_auto_selection_keeps_recoverable_account_available(monkeypatch): + manager = _make_session_manager("outlook-selection-recoverable.db") + selected = {} + + with manager.session_scope() as session: + _seed_outlook_services(session) + _seed_recoverable_outlook_account(session, "reserved-a@example.com") + session.add( + RegistrationTask( + task_uuid="task-recoverable-selection", + status="pending", + ) + ) + session.commit() + + @contextmanager + def fake_get_db(): + with _patched_get_db(manager) as session: + yield session + + monkeypatch.setattr(registration_routes, "get_db", fake_get_db) + _patch_registration_selection(monkeypatch, selected) + + registration_routes._run_sync_registration_task( + task_uuid="task-recoverable-selection", + email_service_type="outlook", + proxy=None, + email_service_config=None, + email_service_id=None, + ) + + assert selected["service_type"] == EmailServiceType.OUTLOOK + assert selected["config"]["email"] == "reserved-a@example.com" + + +def test_outlook_bound_recoverable_service_runs_instead_of_being_rejected(monkeypatch): + manager = _make_session_manager("outlook-selection-bound-recoverable.db") + selected = {} + + with manager.session_scope() as session: + first, second = _seed_outlook_services(session) + _seed_recoverable_outlook_account(session, "available-b@example.com") + session.add( + RegistrationTask( + task_uuid="task-bound-recoverable", + status="pending", + email_service_id=second.id, + ) + ) + session.commit() + + @contextmanager + def fake_get_db(): + with _patched_get_db(manager) as session: + yield session + + monkeypatch.setattr(registration_routes, "get_db", fake_get_db) + + def fake_create(service_type, config, name=None): + selected["service_type"] = service_type + selected["config"] = dict(config) + selected["name"] = name + return SimpleNamespace(service_type=service_type, config=config, name=name) + + def fake_run(self): + selected["engine_ran"] = True + return RegistrationResult(success=False, error_message="recovery still failed") + + monkeypatch.setattr(registration_routes.EmailServiceFactory, "create", fake_create) + monkeypatch.setattr(registration_routes.RegistrationEngine, "run", fake_run) + + registration_routes._run_sync_registration_task( + task_uuid="task-bound-recoverable", + email_service_type="outlook", + proxy=None, + email_service_config=None, + email_service_id=None, + ) + + with _patched_get_db(manager) as session: + task = session.query(RegistrationTask).filter_by(task_uuid="task-bound-recoverable").first() + + assert selected["engine_ran"] is True + assert selected["config"]["email"] == "available-b@example.com" + assert task.status == "failed" + assert task.error_message == "recovery still failed" + + +def test_outlook_batch_skip_registered_keeps_recoverable_accounts(monkeypatch): + manager = _make_session_manager("outlook-batch-recoverable.db") + + with manager.session_scope() as session: + first, _second = _seed_outlook_services(session) + first_id = first.id + _seed_recoverable_outlook_account(session, "reserved-a@example.com") + + @contextmanager + def fake_get_db(): + with _patched_get_db(manager) as session: + yield session + + monkeypatch.setattr(registration_routes, "get_db", fake_get_db) + + async def fake_batch_runner(*args, **kwargs): + return None + + monkeypatch.setattr(registration_routes, "run_outlook_batch_registration", fake_batch_runner) + + async def invoke(): + from fastapi import BackgroundTasks + + return await registration_routes.start_outlook_batch_registration( + registration_routes.OutlookBatchRegistrationRequest( + service_ids=[first_id], + skip_registered=True, + ), + BackgroundTasks(), + ) + + response = asyncio.run(invoke()) + + assert response.to_register == 1 + assert response.service_ids == [first_id] diff --git a/tests/test_registration_post_create.py b/tests/test_registration_post_create.py new file mode 100644 index 0000000000000000000000000000000000000000..bbacbb2382443d743dabef111a56b0f733336830 --- /dev/null +++ b/tests/test_registration_post_create.py @@ -0,0 +1,261 @@ +from contextlib import contextmanager + +from src.config.constants import OPENAI_API_ENDPOINTS, OPENAI_PAGE_TYPES +from src.core.register import RegistrationEngine +from src.core.registration_post_create import ( + POST_CREATE_REENTERED_LOGIN_SOURCE, + POST_CREATE_RESUME_SOURCE, +) +from src.database.models import Account +from tests.test_registration_engine import ( + DummyResponse, + FakeEmailService, + FakeOpenAIClient, + FakeOAuthManagerWithWorkspace, + FakeOutlookEmailService, + QueueSession, + _make_engine_session_manager, + _response_with_did, + _workspace_cookie, +) + + +def test_run_prefers_locked_post_create_continue_over_cached_about_you(): + def create_account_add_phone(session): + return DummyResponse( + payload={ + "page": {"type": "add_phone"}, + "continue_url": "https://auth.example.test/add-phone", + }, + url=OPENAI_API_ENDPOINTS["create_account"], + ) + + def consent_page_with_workspace(session): + session.cookies["oai-client-auth-session"] = _workspace_cookie("ws-post-create") + session.cookies["__Secure-next-auth.session-token"] = "session-post-create" + return DummyResponse( + status_code=200, + url="https://auth.openai.com/sign-in-with-chatgpt/codex/consent", + text="consent", + ) + + session = QueueSession([ + ("GET", "https://auth.example.test/flow/1", _response_with_did("did-1")), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["PASSWORD_REGISTRATION"]}}), + ), + ("POST", OPENAI_API_ENDPOINTS["register"], DummyResponse(payload={})), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ( + "POST", + OPENAI_API_ENDPOINTS["validate_otp"], + DummyResponse( + payload={ + "page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}, + "method": "GET", + "continue_url": "https://auth.example.test/about-you", + } + ), + ), + ("POST", OPENAI_API_ENDPOINTS["create_account"], create_account_add_phone), + ( + "GET", + "https://auth.example.test/add-phone", + DummyResponse( + status_code=200, + url="https://auth.example.test/add-phone", + text=( + "" + ), + ), + ), + ("GET", "https://auth.openai.com/sign-in-with-chatgpt/codex/consent", consent_page_with_workspace), + ("GET", "https://auth.openai.com/sign-in-with-chatgpt/codex/consent", consent_page_with_workspace), + ( + "POST", + OPENAI_API_ENDPOINTS["select_workspace"], + DummyResponse(payload={"continue_url": "https://auth.example.test/post-create-continue"}), + ), + ( + "GET", + "https://auth.example.test/post-create-continue", + DummyResponse( + status_code=302, + headers={"Location": "http://localhost:1455/auth/callback?code=code-post-create&state=state-1"}, + ), + ), + ]) + + email_service = FakeEmailService(["123456"]) + engine = RegistrationEngine(email_service) + engine.http_client = FakeOpenAIClient([session], ["sentinel-1"]) + engine.oauth_manager = FakeOAuthManagerWithWorkspace("ws-post-create") + + result = engine.run() + + assert result.success is True + assert result.workspace_id == "ws-post-create" + assert result.session_token == "session-post-create" + assert result.metadata["token_acquired_via_relogin"] is False + assert result.metadata["last_oauth_resume_source"] == POST_CREATE_RESUME_SOURCE + assert any("post_create_continue_overrode_cached_resume" in log for log in engine.logs) + assert any("post_create_continue_followed" in log for log in engine.logs) + assert any(call["url"] == "https://auth.example.test/add-phone" for call in session.calls) + assert len([call for call in session.calls if call["url"] == OPENAI_API_ENDPOINTS["validate_otp"]]) == 1 + + +def test_run_can_complete_post_create_direct_consent_without_relogin(): + def create_account_direct_consent(session): + return DummyResponse( + payload={ + "page": {"type": "consent"}, + "continue_url": "https://auth.openai.com/sign-in-with-chatgpt/codex/consent", + }, + url=OPENAI_API_ENDPOINTS["create_account"], + ) + + def consent_page_with_workspace(session): + session.cookies["oai-client-auth-session"] = _workspace_cookie("ws-post-create-consent") + session.cookies["__Secure-next-auth.session-token"] = "session-post-create-consent" + return DummyResponse( + status_code=200, + url="https://auth.openai.com/sign-in-with-chatgpt/codex/consent", + text="consent", + ) + + session = QueueSession([ + ("GET", "https://auth.example.test/flow/1", _response_with_did("did-1")), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["PASSWORD_REGISTRATION"]}}), + ), + ("POST", OPENAI_API_ENDPOINTS["register"], DummyResponse(payload={})), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ("POST", OPENAI_API_ENDPOINTS["validate_otp"], DummyResponse(payload={})), + ("POST", OPENAI_API_ENDPOINTS["create_account"], create_account_direct_consent), + ("GET", "https://auth.openai.com/sign-in-with-chatgpt/codex/consent", consent_page_with_workspace), + ( + "POST", + OPENAI_API_ENDPOINTS["select_workspace"], + DummyResponse(payload={"continue_url": "https://auth.example.test/post-create-consent-continue"}), + ), + ( + "GET", + "https://auth.example.test/post-create-consent-continue", + DummyResponse( + status_code=302, + headers={"Location": "http://localhost:1455/auth/callback?code=code-post-create-consent&state=state-1"}, + ), + ), + ]) + + email_service = FakeEmailService(["123456"]) + engine = RegistrationEngine(email_service) + engine.http_client = FakeOpenAIClient([session], ["sentinel-1"]) + engine.oauth_manager = FakeOAuthManagerWithWorkspace("ws-post-create-consent") + + result = engine.run() + + assert result.success is True + assert result.workspace_id == "ws-post-create-consent" + assert result.metadata["token_acquired_via_relogin"] is False + assert result.metadata["last_oauth_resume_source"] == POST_CREATE_RESUME_SOURCE + assert len([call for call in session.calls if call["url"] == OPENAI_API_ENDPOINTS["validate_otp"]]) == 1 + + +def test_run_fails_post_create_reentry_without_relogin_and_persists_recovery(monkeypatch): + manager = _make_engine_session_manager("post-create-reentered-login.db") + + @contextmanager + def fake_get_db(): + session = manager.SessionLocal() + try: + yield session + finally: + session.close() + + monkeypatch.setattr("src.core.register.get_db", fake_get_db) + + def create_account_add_phone(session): + return DummyResponse( + payload={ + "page": {"type": "add_phone"}, + "continue_url": "https://auth.example.test/add-phone", + }, + url=OPENAI_API_ENDPOINTS["create_account"], + ) + + session = QueueSession([ + ("GET", "https://auth.example.test/flow/1", _response_with_did("did-1")), + ( + "POST", + OPENAI_API_ENDPOINTS["signup"], + DummyResponse(payload={"page": {"type": OPENAI_PAGE_TYPES["PASSWORD_REGISTRATION"]}}), + ), + ("POST", OPENAI_API_ENDPOINTS["register"], DummyResponse(payload={})), + ("GET", OPENAI_API_ENDPOINTS["send_otp"], DummyResponse(payload={})), + ( + "POST", + OPENAI_API_ENDPOINTS["validate_otp"], + DummyResponse( + payload={ + "page": {"type": OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]}, + "method": "GET", + "continue_url": "https://auth.example.test/about-you", + } + ), + ), + ("POST", OPENAI_API_ENDPOINTS["create_account"], create_account_add_phone), + ( + "GET", + "https://auth.example.test/add-phone", + DummyResponse( + status_code=302, + headers={"Location": "https://auth.example.test/log-in"}, + ), + ), + ( + "GET", + "https://auth.example.test/log-in", + DummyResponse( + status_code=200, + url="https://auth.example.test/log-in", + text="login", + ), + ), + ( + "GET", + "https://auth.example.test/log-in", + DummyResponse( + status_code=200, + url="https://auth.example.test/log-in", + text="login diagnostic", + ), + ), + ]) + + email_service = FakeOutlookEmailService(["123456"]) + engine = RegistrationEngine(email_service) + engine.http_client = FakeOpenAIClient([session], ["sentinel-1"]) + engine.oauth_manager = FakeOAuthManagerWithWorkspace("ws-unused") + + result = engine.run() + + assert result.success is False + assert result.error_message == "账号已创建,但 post-create 续跑重新进入登录页" + assert len([call for call in session.calls if call["url"] == OPENAI_API_ENDPOINTS["validate_otp"]]) == 1 + assert len([call for call in session.calls if call["url"] == "https://auth.example.test/flow/2"]) == 0 + assert any("post_create_continue_reentered_login" in log for log in engine.logs) + + with manager.session_scope() as session_db: + account = session_db.query(Account).filter(Account.email == "tester@example.com").first() + assert account is not None + assert account.status == "failed" + assert account.extra_data["last_recovery_error"] == result.error_message + assert account.extra_data["last_oauth_resume_source"] == POST_CREATE_REENTERED_LOGIN_SOURCE + assert account.extra_data["last_workspace_resolution_source"] == POST_CREATE_RESUME_SOURCE diff --git a/tests/test_registration_route_helpers.py b/tests/test_registration_route_helpers.py new file mode 100644 index 0000000000000000000000000000000000000000..2618297e7926834dc69d79773b00e8b5ae37c7bd --- /dev/null +++ b/tests/test_registration_route_helpers.py @@ -0,0 +1,39 @@ +from src.config.constants import EmailServiceType +from src.web.routes.registration import _resolve_proxy_for_service + + +def test_temp_mail_without_explicit_proxy_stays_direct(monkeypatch): + def unexpected_proxy_lookup(_db): + raise AssertionError("auto proxy lookup should not run for temp_mail") + + monkeypatch.setattr( + "src.web.routes.registration.get_proxy_for_registration", + unexpected_proxy_lookup, + ) + + proxy_url, proxy_id, proxy_source = _resolve_proxy_for_service( + db=None, + service_type=EmailServiceType.TEMP_MAIL, + explicit_proxy=None, + ) + + assert proxy_url is None + assert proxy_id is None + assert proxy_source == "direct" + + +def test_non_temp_mail_without_explicit_proxy_uses_auto_proxy(monkeypatch): + monkeypatch.setattr( + "src.web.routes.registration.get_proxy_for_registration", + lambda _db: ("http://auto-proxy:8080", 7), + ) + + proxy_url, proxy_id, proxy_source = _resolve_proxy_for_service( + db=None, + service_type=EmailServiceType.TEMPMAIL, + explicit_proxy=None, + ) + + assert proxy_url == "http://auto-proxy:8080" + assert proxy_id == 7 + assert proxy_source == "auto" diff --git a/tests/test_service_selection.py b/tests/test_service_selection.py new file mode 100644 index 0000000000000000000000000000000000000000..4366352d6319a079ba79f528e1a35c5302c9ee9f --- /dev/null +++ b/tests/test_service_selection.py @@ -0,0 +1,127 @@ + +from pathlib import Path + +import pytest + +from src.database.models import Base, EmailService, CpaService, Sub2ApiService, TeamManagerService +from src.database.session import DatabaseSessionManager +from src.core.service_selection import ( + build_email_item_assignments, + resolve_upload_target, +) + + +def _make_db(name: str): + runtime_dir = Path("tests_runtime") + runtime_dir.mkdir(exist_ok=True) + db_path = runtime_dir / name + if db_path.exists(): + db_path.unlink() + manager = DatabaseSessionManager(f"sqlite:///{db_path}") + Base.metadata.create_all(bind=manager.engine) + return manager + + +def test_temp_mail_assignments_only_use_highest_priority_pool(): + manager = _make_db("service_selection_temp_mail.db") + with manager.session_scope() as session: + session.add_all([ + EmailService(service_type="temp_mail", name="tm-a", config={"domain": "a.test"}, enabled=True, priority=0), + EmailService(service_type="temp_mail", name="tm-b", config={"domain": "b.test"}, enabled=True, priority=0), + EmailService(service_type="temp_mail", name="tm-c", config={"domain": "c.test"}, enabled=True, priority=10), + ]) + + with manager.session_scope() as session: + assignments = build_email_item_assignments(session, email_type="temp_mail", count=12) + + assert len(assignments) == 12 + assert {item.service_id for item in assignments if item.service_id is not None} <= {1, 2} + assert all(item.failure_reason is None for item in assignments) + + +def test_outlook_specific_service_cannot_be_reused_for_multiple_items(): + manager = _make_db("service_selection_outlook_specific.db") + with manager.session_scope() as session: + session.add(EmailService(service_type="outlook", name="outlook-1", config={"email": "a@example.com"}, enabled=True, priority=0)) + + with manager.session_scope() as session: + with pytest.raises(ValueError, match="count > 1"): + build_email_item_assignments(session, email_type="outlook", count=2, requested_service_id=1) + + +def test_outlook_auto_selection_marks_overflow_items_unavailable(): + manager = _make_db("service_selection_outlook_auto.db") + with manager.session_scope() as session: + session.add_all([ + EmailService(service_type="outlook", name="outlook-1", config={"email": "a@example.com"}, enabled=True, priority=0), + EmailService(service_type="outlook", name="outlook-2", config={"email": "b@example.com"}, enabled=True, priority=0), + ]) + + with manager.session_scope() as session: + assignments = build_email_item_assignments(session, email_type="outlook", count=3) + + assert [item.service_id for item in assignments[:2]] == [1, 2] + assert assignments[2].service_id is None + assert assignments[2].failure_reason == "no_available_email_service" + + +def test_resolve_upload_target_uses_first_enabled_service_by_priority(): + manager = _make_db("service_selection_upload.db") + with manager.session_scope() as session: + session.add_all([ + Sub2ApiService(name="sub2-low", api_url="https://low.test", api_key="k1", enabled=True, priority=10), + Sub2ApiService(name="sub2-high", api_url="https://high.test", api_key="k2", enabled=True, priority=0), + CpaService(name="cpa-1", api_url="https://cpa.test", api_token="c1", enabled=True, priority=0), + TeamManagerService(name="tm-1", api_url="https://tm.test", api_key="t1", enabled=True, priority=0), + ]) + + with manager.session_scope() as session: + target = resolve_upload_target(session, provider="sub2api") + + assert target.provider == "sub2api" + assert target.service_id == 2 + assert target.service_name == "sub2-high" + + +def test_resolve_upload_target_rejects_missing_or_mismatched_service(): + manager = _make_db("service_selection_upload_mismatch.db") + with manager.session_scope() as session: + session.add(CpaService(name="cpa-1", api_url="https://cpa.test", api_token="c1", enabled=True, priority=0)) + + with manager.session_scope() as session: + with pytest.raises(ValueError, match="does not belong"): + resolve_upload_target(session, provider="sub2api", requested_service_id=1) + + +def test_build_email_item_assignments_rejects_invalid_count(): + manager = _make_db("service_selection_invalid_count.db") + + with manager.session_scope() as session: + with pytest.raises(ValueError, match="count must be >= 1"): + build_email_item_assignments(session, email_type="temp_mail", count=0) + + +def test_requested_email_service_must_exist_and_be_enabled(): + manager = _make_db("service_selection_requested_email.db") + with manager.session_scope() as session: + session.add(EmailService(service_type="temp_mail", name="tm-1", config={"domain": "a.test"}, enabled=False, priority=0)) + + with manager.session_scope() as session: + with pytest.raises(ValueError, match="not found"): + build_email_item_assignments(session, email_type="temp_mail", count=1, requested_service_id=99) + with pytest.raises(ValueError, match="is disabled"): + build_email_item_assignments(session, email_type="temp_mail", count=1, requested_service_id=1) + + +def test_resolve_upload_target_rejects_unsupported_provider_and_disabled_service(): + manager = _make_db("service_selection_upload_rejects.db") + with manager.session_scope() as session: + session.add(Sub2ApiService(name="sub2-disabled", api_url="https://sub2.test", api_key="k1", enabled=False, priority=0)) + + with manager.session_scope() as session: + with pytest.raises(ValueError, match="unsupported upload provider"): + resolve_upload_target(session, provider="unknown") + with pytest.raises(ValueError, match="is disabled"): + resolve_upload_target(session, provider="sub2api", requested_service_id=1) + with pytest.raises(ValueError, match="no enabled upload services"): + resolve_upload_target(session, provider="sub2api") diff --git a/tests/test_static_asset_versioning.py b/tests/test_static_asset_versioning.py new file mode 100644 index 0000000000000000000000000000000000000000..ba23cf9756cb481d13be945f6ba12ab436ae57a4 --- /dev/null +++ b/tests/test_static_asset_versioning.py @@ -0,0 +1,28 @@ +from pathlib import Path +import importlib + +web_app = importlib.import_module("src.web.app") + + +def test_static_asset_version_is_non_empty_string(): + version = web_app._build_static_asset_version(web_app.STATIC_DIR) + + assert isinstance(version, str) + assert version + assert version.isdigit() + + +def test_email_services_template_uses_versioned_static_assets(): + template = Path("templates/email_services.html").read_text(encoding="utf-8") + + assert '/static/css/style.css?v={{ static_version }}' in template + assert '/static/js/utils.js?v={{ static_version }}' in template + assert '/static/js/email_services.js?v={{ static_version }}' in template + + +def test_index_template_uses_versioned_static_assets(): + template = Path("templates/index.html").read_text(encoding="utf-8") + + assert '/static/css/style.css?v={{ static_version }}' in template + assert '/static/js/utils.js?v={{ static_version }}' in template + assert '/static/js/app.js?v={{ static_version }}' in template diff --git a/tests/test_task_websocket_status_push.py b/tests/test_task_websocket_status_push.py new file mode 100644 index 0000000000000000000000000000000000000000..90e2026948275010bc101366990271f839aece18 --- /dev/null +++ b/tests/test_task_websocket_status_push.py @@ -0,0 +1,185 @@ +import asyncio +import threading + +from fastapi import WebSocketDisconnect + +from src.web import task_manager as task_manager_module +from src.web.routes import websocket as websocket_routes +from src.web.task_manager import TaskManager + + +class DummyWebSocket: + def __init__(self): + self.sent = [] + self.accepted = False + self._incoming: asyncio.Queue = asyncio.Queue() + + async def accept(self): + self.accepted = True + + async def send_json(self, message): + self.sent.append(message) + + async def receive_json(self): + item = await self._incoming.get() + if isinstance(item, Exception): + raise item + return item + + async def disconnect(self): + await self._incoming.put(WebSocketDisconnect()) + + +def _reset_task_manager_state(): + task_manager_module._log_queues.clear() + task_manager_module._log_locks.clear() + task_manager_module._ws_connections.clear() + task_manager_module._ws_sent_index.clear() + task_manager_module._task_status.clear() + task_manager_module._task_cancelled.clear() + task_manager_module._batch_status.clear() + task_manager_module._batch_logs.clear() + task_manager_module._batch_locks.clear() + + +def test_update_status_broadcasts_terminal_failed_with_error_when_loop_available(): + async def scenario(): + _reset_task_manager_state() + manager = TaskManager() + manager.set_loop(asyncio.get_running_loop()) + ws = DummyWebSocket() + manager.register_websocket("task-failed", ws) + + manager.update_status("task-failed", "failed") + await asyncio.sleep(0.05) + + assert ws.sent, "expected websocket status push for failed status" + failed_messages = [m for m in ws.sent if m.get("type") == "status" and m.get("status") == "failed"] + assert failed_messages, "expected failed status message" + assert failed_messages[-1].get("error"), "failed status must include error" + assert manager.get_status("task-failed").get("error") + + asyncio.run(scenario()) + + +def test_update_status_broadcasts_terminal_completed_payload_when_loop_available(): + async def scenario(): + _reset_task_manager_state() + manager = TaskManager() + manager.set_loop(asyncio.get_running_loop()) + ws = DummyWebSocket() + manager.register_websocket("task-completed", ws) + + manager.update_status("task-completed", "completed", email="done@example.com") + await asyncio.sleep(0.05) + + completed_messages = [m for m in ws.sent if m.get("type") == "status" and m.get("status") == "completed"] + assert completed_messages, "expected completed status message" + assert completed_messages[-1].get("email") == "done@example.com" + assert completed_messages[-1].get("updated_at") + + asyncio.run(scenario()) + + +def test_task_websocket_sends_initial_status_snapshot(): + async def scenario(): + _reset_task_manager_state() + manager = TaskManager() + manager.update_status("task-initial", "running", message="booting") + ws = DummyWebSocket() + + original = websocket_routes.task_manager + websocket_routes.task_manager = manager + try: + task = asyncio.create_task(websocket_routes.task_websocket(ws, "task-initial")) + await asyncio.sleep(0.01) + await ws.disconnect() + await task + finally: + websocket_routes.task_manager = original + + assert ws.accepted + status_messages = [m for m in ws.sent if m.get("type") == "status"] + assert status_messages, "expected initial status snapshot on connect" + assert status_messages[0].get("status") == "running" + assert status_messages[0].get("message") == "booting" + + asyncio.run(scenario()) + + +def test_task_websocket_receives_terminal_update_after_connect(): + async def scenario(): + _reset_task_manager_state() + manager = TaskManager() + manager.set_loop(asyncio.get_running_loop()) + ws = DummyWebSocket() + + original = websocket_routes.task_manager + websocket_routes.task_manager = manager + try: + task = asyncio.create_task(websocket_routes.task_websocket(ws, "task-live")) + await asyncio.sleep(0.01) + manager.update_status("task-live", "failed", error="boom") + await asyncio.sleep(0.05) + await ws.disconnect() + await task + finally: + websocket_routes.task_manager = original + + terminal_messages = [m for m in ws.sent if m.get("type") == "status" and m.get("status") == "failed"] + assert terminal_messages, "expected live failed status over websocket" + assert terminal_messages[-1].get("error") == "boom" + + asyncio.run(scenario()) + + +def test_update_status_broadcasts_from_worker_thread_when_loop_available(): + async def scenario(): + _reset_task_manager_state() + manager = TaskManager() + manager.set_loop(asyncio.get_running_loop()) + ws = DummyWebSocket() + manager.register_websocket("task-threaded", ws) + + worker = threading.Thread( + target=lambda: manager.update_status( + "task-threaded", + "completed", + email="thread@example.com" + ) + ) + worker.start() + worker.join() + await asyncio.sleep(0.05) + + completed_messages = [ + m for m in ws.sent + if m.get("type") == "status" and m.get("status") == "completed" + ] + assert completed_messages, "expected threaded status push over websocket" + assert completed_messages[-1].get("email") == "thread@example.com" + assert completed_messages[-1].get("message") + + asyncio.run(scenario()) + + +def test_update_status_terminal_defaults_include_useful_fields(): + _reset_task_manager_state() + manager = TaskManager() + + manager.update_status("task-term", "completed") + completed = manager.get_status("task-term") + assert completed["message"] + + manager.update_status("task-term", "failed") + failed = manager.get_status("task-term") + assert failed["message"] + assert failed["error"] + + manager.update_status("task-term", "cancelled") + cancelled = manager.get_status("task-term") + assert cancelled["message"] + + manager.update_status("task-term", "cancelling") + cancelling = manager.get_status("task-term") + assert cancelling["message"] diff --git a/tests/test_temp_mail_addressing.py b/tests/test_temp_mail_addressing.py new file mode 100644 index 0000000000000000000000000000000000000000..d502d9c772b0b3687f8dffe86ad3eaa43c9c16be --- /dev/null +++ b/tests/test_temp_mail_addressing.py @@ -0,0 +1,34 @@ +import random + +import pytest + +from src.services.temp_mail_addressing import choose_domain, generate_local_part + + +def test_generate_local_part_defaults_to_no_tmp_prefix(): + rng = random.Random(123) + name = generate_local_part(enable_prefix=False, rng=rng) + + assert name + assert not name.startswith("tmp") + assert name.isalnum() + + +def test_generate_local_part_keeps_legacy_prefix_when_enabled(): + rng = random.Random(123) + name = generate_local_part(enable_prefix=True, rng=rng) + + assert name.startswith("tmp") + assert name[3:].isalnum() + + +def test_choose_domain_uses_available_domains(): + rng = random.Random(1) + selected = choose_domain(["a.example.com", "b.example.com"], rng=rng) + + assert selected in {"a.example.com", "b.example.com"} + + +def test_choose_domain_raises_for_empty_pool(): + with pytest.raises(ValueError): + choose_domain([]) diff --git a/tests/test_temp_mail_domain_provider.py b/tests/test_temp_mail_domain_provider.py new file mode 100644 index 0000000000000000000000000000000000000000..9a0073ea39dba6a26885a47b4f6fadb6bc8c5870 --- /dev/null +++ b/tests/test_temp_mail_domain_provider.py @@ -0,0 +1,79 @@ +from src.services.temp_mail_domain_provider import resolve_temp_mail_domains, summarize_temp_mail_domains +from src.core.email_service_catalog import build_temp_mail_service_entry + + +def test_resolve_prefers_config_domains_and_skips_worker_fetch(): + called = {"value": False} + + def _fetcher(): + called["value"] = True + return {"domains": ["ignored.example.com"]} + + domains = resolve_temp_mail_domains( + { + "domains": ["a.example.com", "a.example.com", " b.example.com ", ""], + "domain": "fallback.example.com", + }, + fetch_domains=_fetcher, + ) + + assert domains == ["a.example.com", "b.example.com"] + assert called["value"] is False + + +def test_resolve_uses_worker_domains_when_config_pool_missing(): + domains = resolve_temp_mail_domains( + {"domain": "fallback.example.com"}, + fetch_domains=lambda: {"domains": ["w1.example.com", "w2.example.com", "w1.example.com"]}, + ) + + assert domains == ["w1.example.com", "w2.example.com"] + + +def test_resolve_falls_back_to_single_domain_when_worker_unavailable(): + def _boom(): + raise RuntimeError("worker down") + + domains = resolve_temp_mail_domains( + {"domain": "fallback.example.com"}, + fetch_domains=_boom, + ) + + assert domains == ["fallback.example.com"] + + +def test_summarize_returns_preview_and_source(): + summary = summarize_temp_mail_domains( + {"domain": "fallback.example.com"}, + fetch_domains=lambda: {"domains": ["d1.example.com", "d2.example.com", "d3.example.com", "d4.example.com"]}, + preview_limit=3, + ) + + assert summary["domain_source"] == "worker_api" + assert summary["domain_count"] == 4 + assert summary["domains_preview"] == ["d1.example.com", "d2.example.com", "d3.example.com"] + assert summary["domain"] == "d1.example.com" + + +def test_catalog_entry_exposes_temp_mail_domain_summary_without_secrets(): + class Service: + id = 12 + name = "mail-worker" + priority = 2 + config = { + "domain": "fallback.example.com", + "admin_password": "admin888", + } + + entry = build_temp_mail_service_entry( + Service(), + fetch_domains=lambda: {"domains": ["d1.example.com", "d2.example.com"]}, + preview_limit=1, + ) + + assert entry["id"] == 12 + assert entry["type"] == "temp_mail" + assert entry["domain_count"] == 2 + assert entry["domains_preview"] == ["d1.example.com"] + assert entry["domain_source"] == "worker_api" + assert "admin_password" not in entry diff --git a/tests/test_temp_mail_service.py b/tests/test_temp_mail_service.py new file mode 100644 index 0000000000000000000000000000000000000000..5fe2cddd659bb12d00f2f87542bc6fa759b5da4b --- /dev/null +++ b/tests/test_temp_mail_service.py @@ -0,0 +1,502 @@ +import pytest + +from src.services.temp_mail import TempMailService + + +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") + 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"missing fake response for {method} {url}") + return self.responses.pop(0) + + +RAW_OPENAI_MAIL = """From: OpenAI +Subject: Your verification code +Content-Type: text/plain; charset=utf-8 + +Your OpenAI verification code is 654321 +""" + +RAW_OPENAI_MAIL_WITH_DOMAIN_DIGITS = """To: tester@889110.xyz +From: OpenAI +Subject: Verify your email +Content-Type: text/plain; charset=utf-8 + +Your OpenAI verification code is 456789 +""" + + +def _install_fake_clock(monkeypatch, start=0.0): + clock = {"value": float(start)} + + def fake_time(): + return clock["value"] + + def fake_sleep(seconds): + clock["value"] += float(seconds) + + monkeypatch.setattr("src.services.temp_mail.time.time", fake_time) + monkeypatch.setattr("src.services.temp_mail.time.sleep", fake_sleep) + return clock + + +def test_get_verification_code_falls_back_to_admin_when_user_api_token_is_rejected(): + service = TempMailService( + { + "base_url": "https://apmail.889110.xyz", + "admin_password": "admin888", + "domain": "fallback.example.com", + } + ) + email = "tmpabc123@fallback.example.com" + service._email_cache[email] = {"email": email, "jwt": "expired-jwt"} + service.http_client = FakeHTTPClient( + [ + FakeResponse(status_code=401, payload={"error": "Your token has expired, please login again"}), + FakeResponse( + payload={ + "results": [ + { + "id": "mail-1", + "source": "OpenAI ", + "subject": "OpenAI verification code", + "text": "Your OpenAI verification code is 654321", + } + ], + "total": 1, + } + ), + ] + ) + + code = service.get_verification_code(email, timeout=3) + + assert code == "654321" + assert service._email_cache[email]["disable_user_api"] is True + assert service.http_client.calls[0]["url"] == "https://apmail.889110.xyz/api/mails" + assert service.http_client.calls[1]["url"] == "https://apmail.889110.xyz/admin/mails" + assert service.http_client.calls[1]["kwargs"]["params"]["address"] == email + + +def test_get_verification_code_skips_user_api_after_token_marked_unavailable(): + service = TempMailService( + { + "base_url": "https://apmail.889110.xyz", + "admin_password": "admin888", + "domain": "fallback.example.com", + } + ) + email = "tmpabc123@fallback.example.com" + service._email_cache[email] = { + "email": email, + "jwt": "expired-jwt", + "disable_user_api": True, + } + service.http_client = FakeHTTPClient( + [ + FakeResponse( + payload={ + "results": [ + { + "id": "mail-2", + "source": "OpenAI ", + "subject": "OpenAI verification code", + "text": "Use code 123456 to continue", + } + ], + "total": 1, + } + ), + ] + ) + + code = service.get_verification_code(email, timeout=3) + + assert code == "123456" + assert len(service.http_client.calls) == 1 + assert service.http_client.calls[0]["url"] == "https://apmail.889110.xyz/admin/mails" + + +def test_get_verification_code_prefers_api_mails_with_address_jwt(monkeypatch): + _install_fake_clock(monkeypatch) + + service = TempMailService( + { + "base_url": "https://mail.example.test", + "admin_password": "admin-secret", + "domains": ["example.test"], + } + ) + service.http_client = FakeHTTPClient( + [ + FakeResponse(payload={"address": "tester@example.test", "jwt": "address-jwt-123"}), + FakeResponse( + payload={ + "results": [ + { + "id": "mail-1", + "raw": RAW_OPENAI_MAIL, + "address": "tester@example.test", + } + ], + "total": 1, + } + ), + ] + ) + + email_info = service.create_email() + code = service.get_verification_code(email_info["email"], timeout=10) + + assert code == "654321" + assert service.http_client.calls[1]["url"] == "https://mail.example.test/api/mails" + assert service.http_client.calls[1]["kwargs"]["headers"]["Authorization"] == "Bearer address-jwt-123" + + +def test_get_verification_code_falls_back_to_admin_when_api_mails_fails(monkeypatch): + _install_fake_clock(monkeypatch) + + service = TempMailService( + { + "base_url": "https://mail.example.test", + "admin_password": "admin-secret", + "domains": ["example.test"], + } + ) + service.http_client = FakeHTTPClient( + [ + FakeResponse(payload={"address": "tester@example.test", "jwt": "address-jwt-123"}), + FakeResponse(status_code=401, payload={"error": "unauthorized"}), + FakeResponse( + payload={ + "results": [ + { + "id": "mail-2", + "source": "noreply@openai.com", + "subject": "Your verification code", + "text": "Your OpenAI verification code is 112233", + "address": "tester@example.test", + } + ], + "total": 1, + } + ), + ] + ) + + email_info = service.create_email() + code = service.get_verification_code(email_info["email"], timeout=10) + + assert code == "112233" + assert service.http_client.calls[1]["url"] == "https://mail.example.test/api/mails" + assert service.http_client.calls[2]["url"] == "https://mail.example.test/admin/mails" + + +def test_get_verification_code_ignores_domain_digits_in_raw_headers(monkeypatch): + _install_fake_clock(monkeypatch) + + service = TempMailService( + { + "base_url": "https://mail.example.test", + "admin_password": "admin-secret", + "domains": ["889110.xyz"], + } + ) + service.http_client = FakeHTTPClient( + [ + FakeResponse(payload={"address": "tester@889110.xyz", "jwt": "address-jwt-123"}), + FakeResponse( + payload={ + "results": [ + { + "id": "mail-3", + "source": "noreply@openai.com", + "subject": "Verify your email", + "text": "Thanks for signing up.", + "raw": RAW_OPENAI_MAIL_WITH_DOMAIN_DIGITS, + "address": "tester@889110.xyz", + } + ], + "total": 1, + } + ), + ] + ) + + email_info = service.create_email() + code = service.get_verification_code(email_info["email"], timeout=10) + + assert code == "456789" + + +def test_get_verification_code_prefers_mail_received_after_otp_sent(monkeypatch): + _install_fake_clock(monkeypatch) + + service = TempMailService( + { + "base_url": "https://mail.example.test", + "admin_password": "admin-secret", + "domains": ["example.test"], + } + ) + service.http_client = FakeHTTPClient( + [ + FakeResponse(payload={"address": "tester@example.test", "jwt": "address-jwt-123"}), + FakeResponse( + payload={ + "results": [ + { + "id": "mail-old", + "source": "noreply@openai.com", + "subject": "Your verification code", + "text": "Your OpenAI verification code is 111111", + "createdAt": 1700000000, + "address": "tester@example.test", + }, + { + "id": "mail-new", + "source": "noreply@openai.com", + "subject": "Your verification code", + "text": "Your OpenAI verification code is 222222", + "createdAt": 1700000020, + "address": "tester@example.test", + }, + ], + "total": 2, + } + ), + ] + ) + + email_info = service.create_email() + code = service.get_verification_code(email_info["email"], timeout=10, otp_sent_at=1700000010) + + assert code == "222222" + + +def test_relogin_stage_does_not_reuse_consumed_signup_mail(monkeypatch): + _install_fake_clock(monkeypatch) + + service = TempMailService( + { + "base_url": "https://mail.example.test", + "admin_password": "admin-secret", + "domains": ["example.test"], + } + ) + service.http_client = FakeHTTPClient( + [ + FakeResponse(payload={"address": "tester@example.test", "jwt": "address-jwt-123"}), + FakeResponse( + payload={ + "results": [ + { + "id": "mail-signup", + "source": "noreply@openai.com", + "subject": "Your verification code", + "text": "Your OpenAI verification code is 111111", + "address": "tester@example.test", + } + ], + "total": 1, + } + ), + FakeResponse( + payload={ + "results": [ + { + "id": "mail-signup", + "source": "noreply@openai.com", + "subject": "Your verification code", + "text": "Your OpenAI verification code is 111111", + "address": "tester@example.test", + } + ], + "total": 1, + } + ), + ] + ) + + email_info = service.create_email() + first_code = service.get_verification_code(email_info["email"], timeout=1) + service.set_verification_stage(email_info["email"], "relogin_otp") + second_code = service.get_verification_code( + email_info["email"], + timeout=1, + otp_sent_at=1700000010, + ) + + assert first_code == "111111" + assert second_code is None + + +def test_create_email_uses_worker_domains_and_default_no_prefix(): + service = TempMailService( + { + "base_url": "https://apmail.889110.xyz", + "admin_password": "admin888", + "domain": "fallback.example.com", + } + ) + service.http_client = FakeHTTPClient( + [ + FakeResponse(payload={"domains": ["d1.example.com", "d2.example.com"]}), + FakeResponse(payload={"address": "abc123@d2.example.com", "jwt": "jwt-1"}), + ] + ) + + created = service.create_email() + + assert created["email"].endswith("@d2.example.com") or created["email"].endswith("@d1.example.com") + + domains_call = service.http_client.calls[0] + assert domains_call["method"] == "GET" + assert domains_call["url"] == "https://apmail.889110.xyz/admin/domains" + + create_call = service.http_client.calls[1] + assert create_call["method"] == "POST" + payload = create_call["kwargs"]["json"] + assert payload["enablePrefix"] is False + assert not payload["name"].startswith("tmp") + assert payload["domain"] in {"d1.example.com", "d2.example.com"} + + +def test_create_email_falls_back_to_config_domain_when_domains_api_fails(): + service = TempMailService( + { + "base_url": "https://apmail.889110.xyz", + "admin_password": "admin888", + "domain": "fallback.example.com", + } + ) + service.http_client = FakeHTTPClient( + [ + FakeResponse(status_code=500, payload={"error": "boom"}), + FakeResponse(payload={"address": "abc123@fallback.example.com", "jwt": "jwt-1"}), + ] + ) + + created = service.create_email() + + assert created["email"].endswith("@fallback.example.com") + create_call = service.http_client.calls[1] + assert create_call["kwargs"]["json"]["domain"] == "fallback.example.com" + + +def test_create_email_supports_configured_domain_pool_without_required_domain(): + service = TempMailService( + { + "base_url": "https://apmail.889110.xyz", + "admin_password": "admin888", + "domains": ["pool.example.com"], + } + ) + service.http_client = FakeHTTPClient( + [ + FakeResponse(payload={"address": "abc123@pool.example.com", "jwt": "jwt-1"}), + ] + ) + + created = service.create_email() + + assert created["email"].endswith("@pool.example.com") + assert len(service.http_client.calls) == 1 + assert service.http_client.calls[0]["kwargs"]["json"]["domain"] == "pool.example.com" + + +def test_create_email_fails_when_no_domains_available_anywhere(): + service = TempMailService( + { + "base_url": "https://apmail.889110.xyz", + "admin_password": "admin888", + } + ) + service.http_client = FakeHTTPClient( + [ + FakeResponse(payload={"domains": []}), + ] + ) + + with pytest.raises(Exception, match="未找到可用域名|可用域名"): + service.create_email() + + +def test_create_email_falls_back_when_worker_returns_empty_domain_list(): + service = TempMailService( + { + "base_url": "https://apmail.889110.xyz", + "admin_password": "admin888", + "domain": "fallback.example.com", + } + ) + service.http_client = FakeHTTPClient( + [ + FakeResponse(payload={"domains": []}), + FakeResponse(payload={"address": "abc123@fallback.example.com", "jwt": "jwt-1"}), + ] + ) + + created = service.create_email() + + assert created["email"].endswith("@fallback.example.com") + + +def test_create_email_raises_when_api_response_missing_address(): + service = TempMailService( + { + "base_url": "https://apmail.889110.xyz", + "admin_password": "admin888", + "domain": "fallback.example.com", + } + ) + service.http_client = FakeHTTPClient( + [ + FakeResponse(payload={"domains": ["fallback.example.com"]}), + FakeResponse(payload={"jwt": "jwt-1"}), + ] + ) + + with pytest.raises(Exception, match="返回数据不完整|数据不完整"): + service.create_email() + + +def test_create_email_admin_requests_include_admin_auth_header(): + service = TempMailService( + { + "base_url": "https://apmail.889110.xyz", + "admin_password": "admin888", + "domain": "fallback.example.com", + "enable_prefix": True, + } + ) + service.http_client = FakeHTTPClient( + [ + FakeResponse(payload={"domains": ["fallback.example.com"]}), + FakeResponse(payload={"address": "tmpabc123@fallback.example.com", "jwt": "jwt-1"}), + ] + ) + + created = service.create_email() + + assert created["email"].endswith("@fallback.example.com") + for call in service.http_client.calls: + assert call["kwargs"]["headers"]["x-admin-auth"] == "admin888" + assert service.http_client.calls[1]["kwargs"]["json"]["enablePrefix"] is True diff --git a/tests/test_webui_templates.py b/tests/test_webui_templates.py new file mode 100644 index 0000000000000000000000000000000000000000..8a2dedf57e03f61291d47ce8a2e353d3e3f81d87 --- /dev/null +++ b/tests/test_webui_templates.py @@ -0,0 +1,12 @@ +from fastapi.testclient import TestClient + +from src.web.app import create_app + + +def test_login_page_renders_successfully(): + client = TestClient(create_app()) + + response = client.get("/login") + + assert response.status_code == 200 + assert "login" in response.text.lower() diff --git a/tmp_app_core.js b/tmp_app_core.js new file mode 100644 index 0000000000000000000000000000000000000000..bec7ac2f24bb9b941837cb011db8dba96a1c5c09 --- /dev/null +++ b/tmp_app_core.js @@ -0,0 +1,11 @@ +const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/segment-D0siyHcc.js","assets/datadog-BTgRrIvw.js","assets/react-intl-CIPQd2Ij.js","assets/statsig-CDDMC_8Z.js"])))=>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 0000000000000000000000000000000000000000..d119049788c77393921b5e979250b37ce03537fd --- /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 0000000000000000000000000000000000000000..f0f845400d92682e3798d4d17418d7814aa0bb08 --- /dev/null +++ b/webui.py @@ -0,0 +1,174 @@ +""" +Web UI 启动入口 +""" + +import uvicorn +import logging +import sys +import asyncio + +if sys.platform == 'win32': + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + + # Monkeypatch uvicorn to prevent it from resetting the policy to Proactor + try: + import uvicorn.loops.asyncio + uvicorn.loops.asyncio.setup = lambda: None + except ImportError: + pass +from pathlib import Path + +# 添加项目根目录到 Python 路径 +# PyInstaller 打包后 __file__ 在临时解压目录,需要用 sys.executable 所在目录作为数据目录 +import os +if getattr(sys, 'frozen', False): + # 打包后:使用可执行文件所在目录 + project_root = Path(sys.executable).parent + _src_root = Path(sys._MEIPASS) +else: + project_root = Path(__file__).parent + _src_root = project_root +sys.path.insert(0, str(_src_root)) + +from src.core.utils import setup_logging +from src.database.init_db import initialize_database +from src.config.settings import get_settings, reload_settings + + +def _load_dotenv(): + """加载 .env 文件(可执行文件同目录或项目根目录)""" + env_path = project_root / ".env" + if not env_path.exists(): + return + with open(env_path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + value = value.strip().strip('"').strip("'") + if key and key not in os.environ: + os.environ[key] = value + + +def setup_application(): + """设置应用程序""" + # 加载 .env 文件(优先级低于已有环境变量) + _load_dotenv() + + # 确保数据目录和日志目录在可执行文件所在目录(打包后也适用) + data_dir = project_root / "data" + logs_dir = project_root / "logs" + data_dir.mkdir(exist_ok=True) + logs_dir.mkdir(exist_ok=True) + + # 将数据目录路径注入环境变量,供数据库配置使用 + os.environ.setdefault("APP_DATA_DIR", str(data_dir)) + os.environ.setdefault("APP_LOGS_DIR", str(logs_dir)) + + # 初始化数据库(必须先于获取设置) + try: + initialize_database() + except Exception as e: + print(f"数据库初始化失败: {e}") + raise + + # 获取配置(需要数据库已初始化) + settings = reload_settings() + + # 配置日志(日志文件写到实际 logs 目录) + log_file = str(logs_dir / Path(settings.log_file).name) + setup_logging( + log_level=settings.log_level, + log_file=log_file + ) + + logger = logging.getLogger(__name__) + logger.info("数据库初始化完成,地基已经打好") + logger.info(f"数据目录已安顿好: {data_dir}") + logger.info(f"日志目录也已就位: {logs_dir}") + + logger.info("应用程序设置完成,齿轮已经咔哒一声卡上了") + return settings + + +def start_webui(): + """启动 Web UI""" + # 设置应用程序 + settings = setup_application() + + # 导入 FastAPI 应用(延迟导入以避免循环依赖) + from src.web.app import app + + # 配置 uvicorn + uvicorn_config = { + "app": app, + "host": settings.webui_host, + "port": settings.webui_port, + "reload": False, + "log_level": "info" if settings.debug else "warning", + "access_log": settings.debug, + "ws": "websockets", + "loop": "asyncio", + } + + 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() + + initialize_database() + + # 更新配置 + from src.config.settings import update_settings + + updates = {} + + # 优先使用命令行参数,如果没有则尝试从环境变量获取 + host = args.host or os.environ.get("WEBUI_HOST") or os.environ.get("APP_HOST") + if host: + updates["webui_host"] = host + + port = args.port or os.environ.get("PORT") or os.environ.get("WEBUI_PORT") or os.environ.get("APP_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()