webhook commited on
Commit
89419ea
·
verified ·
1 Parent(s): 21ce730

Upload 137 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +19 -0
  2. .github/workflows/build.yml +111 -0
  3. .github/workflows/docker-publish.yml +68 -0
  4. .gitignore +98 -0
  5. CLAUDE.md +181 -0
  6. Dockerfile +37 -0
  7. LICENSE +21 -0
  8. README.md +246 -5
  9. build.bat +51 -0
  10. build.sh +50 -0
  11. codex_register.spec +151 -0
  12. deploy_140.py +76 -0
  13. docker-compose.yml +19 -0
  14. docs/anti-detection-critical-analysis.md +149 -0
  15. docs/anti-detection-final-recommendations.md +416 -0
  16. docs/external-api-examples.md +524 -0
  17. docs/external-api-quickstart.md +309 -0
  18. docs/external-api-webui-management-plan.md +413 -0
  19. docs/http-only-device-fingerprint-hardening.md +305 -0
  20. docs/register_simplification_analysis.md +114 -0
  21. docs/superpowers/plans/2026-03-27-external-failure-category.md +98 -0
  22. docs/temp-mail-external-api.md +416 -0
  23. docs/third-party-integration-guide.md +455 -0
  24. pyproject.toml +43 -0
  25. requirements.txt +15 -0
  26. src/__init__.py +24 -0
  27. src/config/__init__.py +53 -0
  28. src/config/constants.py +413 -0
  29. src/config/settings.py +790 -0
  30. src/core/__init__.py +32 -0
  31. src/core/dynamic_proxy.py +118 -0
  32. src/core/email_service_catalog.py +152 -0
  33. src/core/external_batches/__init__.py +9 -0
  34. src/core/external_batches/recovery.py +40 -0
  35. src/core/external_batches/service.py +448 -0
  36. src/core/fetch_logs.py +33 -0
  37. src/core/http_client.py +436 -0
  38. src/core/openai/__init__.py +3 -0
  39. src/core/openai/browser_profile.py +146 -0
  40. src/core/openai/oauth.py +410 -0
  41. src/core/openai/payment.py +261 -0
  42. src/core/openai/sentinel.py +108 -0
  43. src/core/openai/token_refresh.py +332 -0
  44. src/core/register.py +0 -0
  45. src/core/registration_post_create.py +137 -0
  46. src/core/registration_upload.py +48 -0
  47. src/core/service_selection.py +163 -0
  48. src/core/upload/__init__.py +3 -0
  49. src/core/upload/cpa_upload.py +312 -0
  50. src/core/upload/sub2api_upload.py +224 -0
.env.example ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # OpenAI 自动注册系统 - 环境变量配置示例
2
+ # 复制此文件为 .env 并填写对应值
3
+
4
+ # ── Web UI 监听地址 ──────────────────────────────────────────
5
+ # 监听主机(默认 0.0.0.0)
6
+ # APP_HOST=0.0.0.0
7
+
8
+ # 监听端口(默认 8000)
9
+ # APP_PORT=8000
10
+
11
+ # Web UI 访问密钥(默认 admin123,强烈建议修改)
12
+ # APP_ACCESS_PASSWORD=your_secret_password
13
+
14
+ # ── 数据库 ───────────────────────────────────────────────────
15
+ # 本地 SQLite(默认,无需配置)
16
+ # APP_DATABASE_URL=data/database.db
17
+
18
+ # 远程 PostgreSQL(优先)
19
+ # APP_DATABASE_URL=postgresql://user:password@host:5432/dbname
.github/workflows/build.yml ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Multi-platform Build Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+ workflow_dispatch:
8
+ inputs:
9
+ version:
10
+ description: "Version tag, for example v1.0.0"
11
+ required: false
12
+ default: "dev"
13
+
14
+ jobs:
15
+ build:
16
+ name: Build ${{ matrix.os }}
17
+ runs-on: ${{ matrix.os }}
18
+ strategy:
19
+ fail-fast: false
20
+ matrix:
21
+ include:
22
+ - os: windows-latest
23
+ source_name: codex-console.exe
24
+ asset_name: codex-console-windows-x64.exe
25
+ - os: ubuntu-latest
26
+ source_name: codex-console
27
+ asset_name: codex-console-linux-x64
28
+ - os: macos-latest
29
+ source_name: codex-console
30
+ asset_name: codex-console-macos-arm64
31
+
32
+ steps:
33
+ - name: Checkout code
34
+ uses: actions/checkout@v4
35
+
36
+ - name: Setup Python 3.11
37
+ uses: actions/setup-python@v5
38
+ with:
39
+ python-version: "3.11"
40
+ cache: "pip"
41
+
42
+ - name: Install dependencies
43
+ run: |
44
+ pip install -r requirements.txt pyinstaller
45
+
46
+ - name: Build binary
47
+ run: |
48
+ pyinstaller codex_register.spec --clean --noconfirm
49
+
50
+ - name: Rename packaged binary
51
+ shell: bash
52
+ run: |
53
+ mv "dist/${{ matrix.source_name }}" "dist/${{ matrix.asset_name }}"
54
+
55
+ - name: Upload build artifact
56
+ uses: actions/upload-artifact@v4
57
+ with:
58
+ name: ${{ matrix.asset_name }}
59
+ path: dist/${{ matrix.asset_name }}
60
+ if-no-files-found: error
61
+
62
+ release:
63
+ name: Create release
64
+ needs: build
65
+ runs-on: ubuntu-latest
66
+ if: startsWith(github.ref, 'refs/tags/')
67
+ permissions:
68
+ contents: write
69
+
70
+ steps:
71
+ - name: Checkout code
72
+ uses: actions/checkout@v4
73
+
74
+ - name: Download all artifacts
75
+ uses: actions/download-artifact@v4
76
+ with:
77
+ path: dist/
78
+ merge-multiple: true
79
+
80
+ - name: Create GitHub release
81
+ uses: softprops/action-gh-release@v2
82
+ with:
83
+ files: dist/*
84
+ generate_release_notes: true
85
+ body: |
86
+ ## codex-console
87
+
88
+ ### Download
89
+ | Platform | File |
90
+ |------|------|
91
+ | Windows x64 | `codex-console-windows-x64.exe` |
92
+ | Linux x64 | `codex-console-linux-x64` |
93
+ | macOS ARM64 | `codex-console-macos-arm64` |
94
+
95
+ ### Usage
96
+ ```bash
97
+ # Linux/macOS may need execute permission first
98
+ chmod +x codex-console-*
99
+
100
+ # Start Web UI
101
+ ./codex-console
102
+
103
+ # Custom port
104
+ ./codex-console --port 8080
105
+
106
+ # Debug mode
107
+ ./codex-console --debug
108
+
109
+ # Set Web UI password
110
+ ./codex-console --access-password mypassword
111
+ ```
.github/workflows/docker-publish.yml ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Docker Image CI
2
+
3
+ on:
4
+ push:
5
+ branches: [ "master", "main" ]
6
+ # 当发布新版本时触发
7
+ tags: [ 'v*.*.*' ]
8
+ pull_request:
9
+ branches: [ "master", "main" ]
10
+
11
+ env:
12
+ # GitHub Container Registry 的地址
13
+ REGISTRY: ghcr.io
14
+ # 镜像名称,默认为 GitHub 用户名/仓库名
15
+ IMAGE_NAME: ${{ github.repository }}
16
+
17
+ jobs:
18
+ build-and-push-image:
19
+ runs-on: ubuntu-latest
20
+ permissions:
21
+ contents: read
22
+ packages: write
23
+ # 如果需要签名生成的镜像,可以使用 id-token: write
24
+
25
+ steps:
26
+ - name: Checkout repository
27
+ uses: actions/checkout@v4
28
+
29
+ # 设置 Docker Buildx 用于构建多平台镜像 (可选)
30
+ - name: Set up Docker Buildx
31
+ uses: docker/setup-buildx-action@v3
32
+
33
+ # 登录到 Docker 镜像仓库
34
+ # 如果只是在 PR 中测试构建,则跳过登录
35
+ - name: Log in to the Container registry
36
+ if: github.event_name != 'pull_request'
37
+ uses: docker/login-action@v3
38
+ with:
39
+ registry: ${{ env.REGISTRY }}
40
+ username: ${{ github.actor }}
41
+ password: ${{ secrets.GITHUB_TOKEN }}
42
+
43
+ # 提取 Docker 镜像的元数据(标签、注释等)
44
+ - name: Extract metadata (tags, labels) for Docker
45
+ id: meta
46
+ uses: docker/metadata-action@v5
47
+ with:
48
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
49
+ tags: |
50
+ type=schedule
51
+ type=ref,event=branch
52
+ type=ref,event=pr
53
+ type=semver,pattern={{version}}
54
+ type=semver,pattern={{major}}.{{minor}}
55
+ type=semver,pattern={{major}}
56
+ type=sha
57
+ type=raw,value=latest,enable={{is_default_branch}}
58
+
59
+ # 构建并推送 Docker 镜像
60
+ - name: Build and push Docker image
61
+ uses: docker/build-push-action@v5
62
+ with:
63
+ context: .
64
+ push: ${{ github.event_name != 'pull_request' }}
65
+ tags: ${{ steps.meta.outputs.tags }}
66
+ labels: ${{ steps.meta.outputs.labels }}
67
+ cache-from: type=gha
68
+ cache-to: type=gha,mode=max
.gitignore ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ build/
8
+ develop-eggs/
9
+ dist/
10
+ downloads/
11
+ eggs/
12
+ .eggs/
13
+ lib/
14
+ lib64/
15
+ parts/
16
+ sdist/
17
+ var/
18
+ wheels/
19
+ *.egg-info/
20
+ .installed.cfg
21
+ *.egg
22
+
23
+ # Virtual Environment
24
+ .venv/
25
+ venv/
26
+ ENV/
27
+ env/
28
+ uv.lock
29
+
30
+ # IDE
31
+ .idea/
32
+ .vscode/
33
+ *.swp
34
+ *.swo
35
+ *~
36
+
37
+ # Data and Logs
38
+ data/
39
+ logs/
40
+ *.db
41
+ *.sqlite
42
+ *.sqlite3
43
+
44
+ # Token files
45
+ token_*.json
46
+
47
+ # Environment
48
+ .env
49
+ .env.local
50
+ *.local
51
+
52
+ # OS
53
+ .DS_Store
54
+ Thumbs.db
55
+
56
+ # Project specific
57
+ backups/
58
+ /out/
59
+
60
+ # Local diagnostics / migration artifacts
61
+ *.log
62
+ analysis_output.txt
63
+ broken_server_logs.txt
64
+ docker_logs_latest.txt
65
+ latest_*.txt
66
+ logs_*.txt
67
+ logs_198/
68
+ src_23.tar.gz
69
+ src_23/
70
+ src_23_extracted/
71
+ *.recovered
72
+ /.tmp_*.py
73
+
74
+ # Local one-off scripts
75
+ /check_sync.py
76
+ /clear_and_deploy.py
77
+ /clear_logs_198.py
78
+ /deploy.py
79
+ /deploy2.log
80
+ /deploy_198.py
81
+ /deploy_probe.py
82
+ /download_local.py
83
+ /fetch_198_logs.py
84
+ /fetch_logs.py
85
+ /fetch_logs_198.py
86
+ /login.html
87
+ /probe_auth_flow.py
88
+ /pull_logs.py
89
+ /register_198.py
90
+ /remote_check.py
91
+ /restore_src.py
92
+ /rollback.py
93
+ /run_probe_198.py
94
+ /step1_download_good.py
95
+ /step2_upload_to_broken.py
96
+ /sync_23.py
97
+ /test_openai_js.py
98
+ /test_proxy_chatgpt*.py
CLAUDE.md ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## 项目概览
6
+
7
+ 这个仓库是一个基于 Python 3.10+ 与 FastAPI 的 Web 应用,用于通过 Web UI 和带鉴权的外部 API 管理注册任务流程。默认使用 SQLite 持久化运行时状态,也支持 PostgreSQL。
8
+
9
+ 当前分支是一个持续维护的修复版,重点是让注册、登录、获取 token 这些链路继续适配近期上游行为变化,包括 Sentinel POW 处理,以及“先注册、再单独登录拿 token”的拆分流程。
10
+
11
+ ## 常用命令
12
+
13
+ ### 安装依赖
14
+
15
+ ```bash
16
+ uv sync
17
+ ```
18
+
19
+ 备用方式:
20
+
21
+ ```bash
22
+ pip install -r requirements.txt
23
+ ```
24
+
25
+ 如果使用项目元数据方式安装测试依赖:
26
+
27
+ ```bash
28
+ pip install -e .[dev]
29
+ ```
30
+
31
+ ### 本地运行
32
+
33
+ 默认启动 Web UI:
34
+
35
+ ```bash
36
+ python webui.py
37
+ ```
38
+
39
+ 指定监听地址和端口:
40
+
41
+ ```bash
42
+ python webui.py --host 0.0.0.0 --port 8080
43
+ ```
44
+
45
+ 调试模式(同时启用文档路由):
46
+
47
+ ```bash
48
+ python webui.py --debug
49
+ ```
50
+
51
+ 也可以使用 `pyproject.toml` 中定义的入口:
52
+
53
+ ```bash
54
+ codex-console
55
+ ```
56
+
57
+ ### 数据库初始化 / 维护
58
+
59
+ 初始化数据库与默认设置:
60
+
61
+ ```bash
62
+ python -m src.database.init_db
63
+ ```
64
+
65
+ 检查数据库连接:
66
+
67
+ ```bash
68
+ python -m src.database.init_db --check
69
+ ```
70
+
71
+ 重置数据库(破坏性操作):
72
+
73
+ ```bash
74
+ python -m src.database.init_db --reset
75
+ ```
76
+
77
+ ### 测试
78
+
79
+ 运行全部测试:
80
+
81
+ ```bash
82
+ pytest
83
+ ```
84
+
85
+ 运行单个测试文件:
86
+
87
+ ```bash
88
+ pytest tests/test_registration_engine.py
89
+ ```
90
+
91
+ 运行单个测试函数:
92
+
93
+ ```bash
94
+ pytest tests/test_registration_engine.py::test_name
95
+ ```
96
+
97
+ ### 打包
98
+
99
+ Windows:
100
+
101
+ ```bash
102
+ build.bat
103
+ ```
104
+
105
+ Linux/macOS:
106
+
107
+ ```bash
108
+ bash build.sh
109
+ ```
110
+
111
+ ### Docker
112
+
113
+ ```bash
114
+ docker-compose up -d
115
+ ```
116
+
117
+ ## 配置模型
118
+
119
+ - 运行时配置主要通过 `src/config/settings.py` 存在数据库中,而不是静态配置文件。
120
+ - `webui.py` 启动时仍会读取 `.env` 和命令行参数,然后把生效值写回数据库配置,再启动应用。
121
+ - 从 README 和启动逻辑看,优先级是:命令行参数 > 环境变量 /.env > 数据库设置 > 代码默认值。
122
+ - 本地默认数据库是 `data/database.db` 下的 SQLite;PostgreSQL 连接串会在 `src/database/session.py` 中做标准化处理。
123
+
124
+ ## 高层架构
125
+
126
+ ### 入口层
127
+
128
+ - `webui.py` 是主进程入口,负责准备 `data/` 与 `logs/`、初始化数据库、重载设置、配置日志并启动 Uvicorn。
129
+ - `src/web/app.py` 负责创建 FastAPI 应用、挂载静态资源和模板、注册 API 与 WebSocket 路由,并处理基于 Cookie 的简易 Web UI 登录。
130
+
131
+ ### Web 层
132
+
133
+ - `src/web/routes/__init__.py` 统一组装 API:
134
+ - `/api/registration`:单任务和批量注册
135
+ - `/api/accounts`、`/api/email-services`、`/api/settings`、`/api/payment`
136
+ - `/api/upload/*`:CPA / Sub2API / Team Manager 上传集成
137
+ - `/api/external/*`:给第三方系统调用的外部批次接口
138
+ - `src/web/routes/external/registration.py` 提供外部批次 API,鉴权由 `src/web/deps/external_auth.py` 中的 `X-API-Key` 逻辑控制。
139
+ - `src/web/routes/websocket.py` 与 `src/web/task_manager.py` 负责向 Web UI 实时推送任务日志和状态。
140
+
141
+ ### 注册主流程
142
+
143
+ - `src/web/routes/registration.py` 是用户触发注册任务的编排层,负责邮箱服务选择、代理来源解析、任务记录,以及注册后的可选上传动作。
144
+ - `src/core/register.py` 中的 `RegistrationEngine` 是核心流程引擎,负责邮箱创建、OAuth / session 初始化、Sentinel 处理、注册/登录推进、token 获取和日志/result 汇总。
145
+ - `src/core/openai/` 放的是与协议细节强相关的模块,例如 OAuth、浏览器/设备指纹、Sentinel POW、支付相关逻辑、token 刷新。
146
+ - `src/core/http_client.py` 统一封装注册流程所需的 HTTP 会话行为。
147
+
148
+ ### 邮箱服务模型
149
+
150
+ - `src/services/base.py` 定义了所有邮箱服务都要实现的抽象接口。
151
+ - 具体实现位于 `src/services/`。
152
+ - Outlook 相关逻辑明显更复杂,集中在 `src/services/outlook/`,里面包括 provider 实现、token 管理、邮件解析和健康检查。
153
+ - 批量任务中的服务选择逻辑位于 `src/core/service_selection.py`。其中 Outlook 有专门限制,避免同一个已配置邮箱在多条批次任务里被静默复用。
154
+
155
+ ### 外部批次 API
156
+
157
+ - 外部 API 不是另一套独立实现,而是对现有注册能力的封装。
158
+ - `src/core/external_batches/service.py` 会先持久化批次、预先计算邮箱/上传目标分配、为每个 item 创建一个 `RegistrationTask`,然后复用常规注册路由里的 `run_registration_task` 来执行。
159
+ - 批次状态通过 `ExternalRegistrationBatch` 和 `ExternalRegistrationBatchItem` 持久化,服务重启后的恢复入口挂在 `src/web/app.py` 的 startup 逻辑里。
160
+ - 第三方接入说明文档在 `docs/temp-mail-external-api.md`、`docs/external-api-examples.md`、`docs/external-api-quickstart.md`。
161
+
162
+ ### 持久化层
163
+
164
+ - ORM 模型定义在 `src/database/models.py`。
165
+ - 本地核心实体主要包括 `Account`、`RegistrationTask`、`EmailService`、`Setting`、`Proxy` 以及各类上传目标配置表。
166
+ - 外部 API 批次状态使用 `ExternalRegistrationBatch` 与 `ExternalRegistrationBatchItem` 两层结构。
167
+ - `src/database/crud.py` 承担大部分业务 CRUD;`src/database/external_batch_crud.py` 处理批次相关持久化。
168
+ - `src/database/session.py` 负责 engine / session 生命周期,也会在启动时执行轻量级 SQLite 自动迁移。
169
+
170
+ ### 上传集成
171
+
172
+ - `src/core/registration_upload.py` 是注册完成后的上传分发入口。
173
+ - 不同上传渠道的实现位于 `src/core/upload/`。
174
+ - 上传目标选择和邮箱分配逻辑都收敛在 `src/core/service_selection.py`。
175
+
176
+ ## 这个仓库里特别重要的开发说明
177
+
178
+ - `tests/` 下有比较完整的 pytest 测试,适合验证路由行为、任务/WebSocket 状态推送、服务选择逻辑和注册辅助函数。
179
+ - 仓库根目录里没有发现专门的 lint/format/type-check 配置(如 `ruff`、`flake8`、`black`、`mypy` 等),不要默认认为存在固定的格式化或静态检查命令,除非后续显式加入。
180
+ - `tests/conftest.py` 在 Windows 下 monkeypatch 了 `socket.socketpair`,这是为了让测试在该平台正常运行。
181
+ - 打包使用的是 `codex_register.spec` + PyInstaller;`webui.py` 和 `src/web/app.py` 都对冻结环境(`sys.frozen` / `sys._MEIPASS`)做了特殊处理,修改资源路径逻辑时要特别小心。
Dockerfile ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 使用官方 Python 基础镜像 (使用 slim 版本减小体积)
2
+ FROM python:3.11-slim
3
+
4
+ # 设置工作目录
5
+ WORKDIR /app
6
+
7
+ # 设置环境变量
8
+ ENV PYTHONDONTWRITEBYTECODE=1 \
9
+ PYTHONUNBUFFERED=1 \
10
+ # WebUI 默认配置
11
+ WEBUI_HOST=0.0.0.0 \
12
+ WEBUI_PORT=7860 \
13
+ LOG_LEVEL=info \
14
+ DEBUG=0 \
15
+ WEBUI_ACCESS_PASSWORD=admin123
16
+
17
+ # 安装系统依赖
18
+ # (curl_cffi 等库可能需要编译工具)
19
+ RUN apt-get update \
20
+ && apt-get install -y --no-install-recommends \
21
+ gcc \
22
+ python3-dev \
23
+ && rm -rf /var/lib/apt/lists/*
24
+
25
+ # 复制依赖文件并安装
26
+ COPY requirements.txt .
27
+ RUN pip install --no-cache-dir --upgrade pip \
28
+ && pip install --no-cache-dir -r requirements.txt
29
+
30
+ # 复制项目代码
31
+ COPY . .
32
+
33
+ # 暴露端口
34
+ EXPOSE 7860
35
+
36
+ # 启动 WebUI
37
+ CMD ["python", "webui.py", "--host", "0.0.0.0", "--port", "7860", "--access-password", "admin123"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -1,11 +1,252 @@
1
  ---
2
- title: Codex
3
- emoji: 👀
4
- colorFrom: yellow
5
  colorTo: indigo
6
  sdk: docker
 
7
  pinned: false
8
- license: apache-2.0
9
  ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: codex-console
3
+ emoji: 🚀
4
+ colorFrom: blue
5
  colorTo: indigo
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
 
9
  ---
10
 
11
+ # codex-console
12
+
13
+ 基于 [cnlimiter/codex-manager](https://github.com/cnlimiter/codex-manager) 持续修复和维护的增强版本。
14
+
15
+ 这个版本的目标很直接: 把近期 OpenAI 注册链路里那些“昨天还能跑,今天突然翻车”的坑补上,让注册、登录、拿 token、打包运行都更稳一点。
16
+
17
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
18
+ [![Python](https://img.shields.io/badge/Python-3.10%2B-blue.svg)](https://www.python.org/)
19
+
20
+ ## QQ群
21
+
22
+ - 交流群: https://qm.qq.com/q/ZTCKxawxeo
23
+
24
+ ## 致谢
25
+
26
+ 首先感谢上游项目作者 [cnlimiter](https://github.com/cnlimiter) 提供的优秀基础工程。
27
+
28
+ 本仓库是在原项目思路和结构之上进行兼容性修复、流程调整和体验优化,适合作为一个“当前可用的修复维护版”继续使用。
29
+
30
+ ## 这个分支修了什么
31
+
32
+ 为适配当前注册链路,这个分支重点补了下面几个问题:
33
+
34
+ 1. 新增 Sentinel POW 求解逻辑
35
+ OpenAI 现在会强制校验 Sentinel POW,原先直接传空值已经不行了,这里补上了实际求解流程。
36
+
37
+ 2. 注册和登录拆成两段
38
+ 现在注册完成后通常不会直接返回可用 token,而是跳转到绑定手机或后续页面。
39
+ 本分支改成“先注册成功,再单独走一次登录流程拿 token”,避免卡死在旧逻辑里。
40
+
41
+ 3. 去掉重复发送验证码
42
+ 登录流程里服务端本身会自动发送验证码邮件,旧逻辑再手动发一次,容易让新旧验证码打架。
43
+ 现在改成直接等待系统自动发来的那封验证码邮件。
44
+
45
+ 4. 修复重新登录流程的页面判断问题
46
+ 针对重新登录时页面流转变化,调整了登录入口和密码提交逻辑,减少卡在错误页面的情况。
47
+
48
+ 5. 优化终端和 Web UI 提示文案
49
+ 保留可读性的前提下,把一些提示改得更友好一点,出错时至少不至于像在挨骂。
50
+
51
+ ## 核心能力
52
+
53
+ - Web UI 管理注册任务和账号数据
54
+ - 支持批量注册、日志实时查看、基础任务管理
55
+ - 支持多种邮箱服务接码
56
+ - 支持 SQLite 和远程 PostgreSQL
57
+ - 支持打包为 Windows/Linux/macOS 可执行文件
58
+ - 更适配当前 OpenAI 注册与登录链路
59
+
60
+ ## 文档
61
+
62
+ - [Temp Mail 域名池与外部注册 API 说明](docs/temp-mail-external-api.md)
63
+ - [External API 调用示例](docs/external-api-examples.md)
64
+ - [External API 第三方对接速览](docs/external-api-quickstart.md)
65
+ - [第三方技术对接文档](docs/third-party-integration-guide.md)
66
+ - [External API 网站内管理与 API Key 生成方案](docs/external-api-webui-management-plan.md)
67
+ - [HTTP-Only 设备指纹补强建议](docs/http-only-device-fingerprint-hardening.md)
68
+
69
+ ## 环境要求
70
+
71
+ - Python 3.10+
72
+ - `uv`(推荐)或 `pip`
73
+
74
+ ## 安装依赖
75
+
76
+ ```bash
77
+ # 使用 uv(推荐)
78
+ uv sync
79
+
80
+ # 或使用 pip
81
+ pip install -r requirements.txt
82
+ ```
83
+
84
+ ## 环境变量配置
85
+
86
+ 可选。复制 `.env.example` 为 `.env` 后按需修改:
87
+
88
+ ```bash
89
+ cp .env.example .env
90
+ ```
91
+
92
+ 常用变量如下:
93
+
94
+ | 变量 | 说明 | 默认值 |
95
+ | --- | --- | --- |
96
+ | `APP_HOST` | 监听主机 | `0.0.0.0` |
97
+ | `APP_PORT` | 监听端口 | `8000` |
98
+ | `APP_ACCESS_PASSWORD` | Web UI 访问密钥 | `admin123` |
99
+ | `APP_DATABASE_URL` | 数据库连接字符串 | `data/database.db` |
100
+
101
+ 优先级:
102
+
103
+ `命令行参数 > 环境变量(.env) > 数据库设置 > 默认值`
104
+
105
+ ## 启动 Web UI
106
+
107
+ ```bash
108
+ # 默认启动(127.0.0.1:8000)
109
+ python webui.py
110
+
111
+ # 指定地址和端口
112
+ python webui.py --host 0.0.0.0 --port 8080
113
+
114
+ # 调试模式(热重载)
115
+ python webui.py --debug
116
+
117
+ # 设置 Web UI 访问密钥
118
+ python webui.py --access-password mypassword
119
+
120
+ # 组合参数
121
+ python webui.py --host 0.0.0.0 --port 8080 --access-password mypassword
122
+ ```
123
+
124
+ 说明:
125
+
126
+ - `--access-password` 的优先级高于数据库中的密钥设置
127
+ - 该参数只对本次启动生效
128
+ - 打包后的 exe 也支持这个参数
129
+
130
+ 例如:
131
+
132
+ ```bash
133
+ codex-console.exe --access-password mypassword
134
+ ```
135
+
136
+ 启动后访问:
137
+
138
+ [http://127.0.0.1:8000](http://127.0.0.1:8000)
139
+
140
+ ## Docker 部署
141
+
142
+ ### 使用 docker-compose
143
+
144
+ ```bash
145
+ docker-compose up -d
146
+ ```
147
+
148
+ ### 使用 docker run
149
+
150
+ ```bash
151
+ docker run -d \
152
+ -p 7860:7860 \
153
+ --name codex-console \
154
+ ghcr.io/<yourname>/codex-console:latest
155
+ ```
156
+
157
+ 当前 Dockerfile 已经写死以下启动参数:
158
+
159
+ - 监听地址:`0.0.0.0`
160
+ - 监听端口:`7860`
161
+ - Web UI 密码:`admin123`
162
+
163
+ 因此不需要再额外传环境变量。
164
+
165
+ 注意:
166
+
167
+ - 当前方案不做持久化,容器重建后数据库和日志会丢失
168
+ - 当前方案的目标只是保证 Web UI 能启动,不保证完整注册链路在公网容器环境可用
169
+
170
+ ### Hugging Face Spaces
171
+
172
+ 如果你要部署到 Hugging Face Spaces:
173
+
174
+ 1. 新建一个 **Docker Space**
175
+ 2. 把当前仓库直接推上去
176
+ 3. 不需要配置额外环境变量
177
+ 4. 不需要开启持久化存储
178
+ 5. 等待平台按仓库根目录下的 `Dockerfile` 自动构建
179
+
180
+ 当前 Dockerfile 会直接启动:
181
+
182
+ ```bash
183
+ python webui.py --host 0.0.0.0 --port 7860 --access-password admin123
184
+ ```
185
+
186
+ 部署完成后,直接打开 Space 页面即可。
187
+
188
+ 默认登录密码:
189
+
190
+ ```text
191
+ admin123
192
+ ```
193
+
194
+ 如果后续涉及公网 OAuth 回调,还需要把系统里的 `openai.redirect_uri` 改成真实可访问的域名地址,而不是默认的 `localhost`;但这不影响 Web UI 本身启动。
195
+
196
+ ## 使用远程 PostgreSQL
197
+
198
+ ```bash
199
+ export APP_DATABASE_URL="postgresql://user:password@host:5432/dbname"
200
+ python webui.py
201
+ ```
202
+
203
+ 也支持 `DATABASE_URL`,但优先级低于 `APP_DATABASE_URL`。
204
+
205
+ ## 打包为可执行文件
206
+
207
+ ```bash
208
+ # Windows
209
+ build.bat
210
+
211
+ # Linux/macOS
212
+ bash build.sh
213
+ ```
214
+
215
+ Windows 打包完成后,默认会在 `dist/` 目录生成类似下面的文件:
216
+
217
+ ```text
218
+ dist/codex-console-windows-X64.exe
219
+ ```
220
+
221
+ 如果打包失败,优先检查:
222
+
223
+ - Python 是否已加入 PATH
224
+ - 依赖是否安装完整
225
+ - 杀毒软件是否拦截了 PyInstaller 产物
226
+ - 终端里是否有更具体的报错日志
227
+
228
+ ## 项目定位
229
+
230
+ 这个仓库更适合作为:
231
+
232
+ - 原项目的修复增强版
233
+ - 当前注册链路的兼容维护版
234
+ - 自己二次开发的基础版本
235
+
236
+ 如果你准备公开发布,建议在仓库描述里明确写上:
237
+
238
+ `Forked and fixed from cnlimiter/codex-manager`
239
+
240
+ 这样既方便别人理解来源,也对上游作者更尊重。
241
+
242
+ ## 仓库命名
243
+
244
+ 当前仓库名:
245
+
246
+ `codex-console`
247
+
248
+ ## 免责声明
249
+
250
+ 本项目仅供学习、研究和技术交流使用,请遵守相关平台和服务条款,不要用于违规、滥用或非法用途。
251
+
252
+ 因使用本项目产生的任何风险和后果,由使用者自行承担。
build.bat ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @echo off
2
+ setlocal
3
+ setlocal enabledelayedexpansion
4
+
5
+ echo === Build platform: Windows ===
6
+
7
+ where python >nul 2>nul
8
+ if errorlevel 1 (
9
+ echo Python was not found in PATH.
10
+ exit /b 1
11
+ )
12
+
13
+ if exist requirements.txt (
14
+ echo Installing project dependencies...
15
+ python -m pip install -r requirements.txt
16
+ if errorlevel 1 (
17
+ echo Failed to install project dependencies.
18
+ exit /b 1
19
+ )
20
+ )
21
+
22
+ echo Installing PyInstaller...
23
+ python -m pip install pyinstaller --quiet
24
+ if errorlevel 1 (
25
+ echo Failed to install PyInstaller.
26
+ exit /b 1
27
+ )
28
+
29
+ echo Running PyInstaller...
30
+ python -m PyInstaller codex_register.spec --clean --noconfirm
31
+ if errorlevel 1 (
32
+ echo PyInstaller build failed.
33
+ exit /b 1
34
+ )
35
+
36
+ if exist dist\codex-console.exe (
37
+ for /f "tokens=*" %%i in ('powershell -Command "[System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture"') do set ARCH=%%i
38
+ set OUTPUT=dist\codex-console-windows-!ARCH!.exe
39
+ if exist "!OUTPUT!" del /F /Q "!OUTPUT!" >nul 2>nul
40
+ move /Y dist\codex-console.exe "!OUTPUT!" >nul
41
+ if errorlevel 1 (
42
+ echo === Build complete, but final rename was blocked. ===
43
+ echo Close any running copy of !OUTPUT! and rename dist\codex-console.exe manually if needed.
44
+ echo Fresh binary is available at: dist\codex-console.exe
45
+ exit /b 0
46
+ )
47
+ echo === Build complete: !OUTPUT! ===
48
+ ) else (
49
+ echo === Build failed: dist\codex-console.exe not found ===
50
+ exit /b 1
51
+ )
build.sh ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # Cross-platform build script. Run it on the target OS you want to package for.
3
+
4
+ set -e
5
+
6
+ OS=$(uname -s)
7
+ ARCH=$(uname -m)
8
+
9
+ case "$OS" in
10
+ Darwin)
11
+ PLATFORM="macos"
12
+ EXT=""
13
+ ;;
14
+ Linux)
15
+ PLATFORM="linux"
16
+ EXT=""
17
+ ;;
18
+ MINGW*|CYGWIN*|MSYS*)
19
+ PLATFORM="windows"
20
+ EXT=".exe"
21
+ ;;
22
+ *)
23
+ PLATFORM="$OS"
24
+ EXT=""
25
+ ;;
26
+ esac
27
+
28
+ OUTPUT_NAME="codex-console-${PLATFORM}-${ARCH}${EXT}"
29
+
30
+ echo "=== Build platform: ${PLATFORM} (${ARCH}) ==="
31
+ echo "=== Output file: dist/${OUTPUT_NAME} ==="
32
+
33
+ # Install build dependency.
34
+ pip install pyinstaller --quiet 2>/dev/null || \
35
+ uv run --with pyinstaller pyinstaller --version > /dev/null 2>&1
36
+
37
+ # Run PyInstaller. Prefer uv when available.
38
+ if command -v uv &>/dev/null; then
39
+ uv run --with pyinstaller pyinstaller codex_register.spec --clean --noconfirm
40
+ else
41
+ pyinstaller codex_register.spec --clean --noconfirm
42
+ fi
43
+
44
+ # Rename the generated binary to include platform metadata.
45
+ rm -f "dist/${OUTPUT_NAME}"
46
+ mv "dist/codex-console${EXT}" "dist/${OUTPUT_NAME}" 2>/dev/null || \
47
+ mv "dist/codex-console" "dist/${OUTPUT_NAME}" 2>/dev/null
48
+
49
+ echo "=== Build complete: dist/${OUTPUT_NAME} ==="
50
+ ls -lh "dist/${OUTPUT_NAME}"
codex_register.spec ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- mode: python ; coding: utf-8 -*-
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ block_cipher = None
7
+
8
+ a = Analysis(
9
+ ['webui.py'],
10
+ pathex=['.'],
11
+ binaries=[],
12
+ datas=[
13
+ ('templates', 'templates'),
14
+ ('static', 'static'),
15
+ ('src', 'src'),
16
+ ],
17
+ hiddenimports=[
18
+ 'uvicorn.logging',
19
+ 'uvicorn.loops',
20
+ 'uvicorn.loops.auto',
21
+ 'uvicorn.loops.asyncio',
22
+ 'uvicorn.loops.uvloop',
23
+ 'uvicorn.protocols',
24
+ 'uvicorn.protocols.http',
25
+ 'uvicorn.protocols.http.auto',
26
+ 'uvicorn.protocols.http.h11_impl',
27
+ 'uvicorn.protocols.http.httptools_impl',
28
+ 'uvicorn.protocols.websockets',
29
+ 'uvicorn.protocols.websockets.auto',
30
+ 'uvicorn.protocols.websockets.websockets_impl',
31
+ 'uvicorn.protocols.websockets.wsproto_impl',
32
+ 'uvicorn.lifespan',
33
+ 'uvicorn.lifespan.off',
34
+ 'uvicorn.lifespan.on',
35
+ 'fastapi',
36
+ 'fastapi.middleware',
37
+ 'fastapi.middleware.cors',
38
+ 'fastapi.staticfiles',
39
+ 'fastapi.templating',
40
+ 'starlette',
41
+ 'starlette.routing',
42
+ 'starlette.middleware',
43
+ 'starlette.staticfiles',
44
+ 'starlette.templating',
45
+ 'jinja2',
46
+ 'sqlalchemy',
47
+ 'sqlalchemy.orm',
48
+ 'sqlalchemy.orm.session',
49
+ 'sqlalchemy.orm.decl_api',
50
+ 'sqlalchemy.ext.declarative',
51
+ 'sqlalchemy.engine',
52
+ 'sqlalchemy.engine.create',
53
+ 'sqlalchemy.engine.url',
54
+ 'sqlalchemy.pool',
55
+ 'sqlalchemy.sql',
56
+ 'sqlalchemy.sql.schema',
57
+ 'sqlalchemy.sql.sqltypes',
58
+ 'sqlalchemy.dialects',
59
+ 'sqlalchemy.dialects.sqlite',
60
+ 'sqlalchemy.dialects.sqlite.pysqlite',
61
+ 'aiosqlite',
62
+ 'pydantic',
63
+ 'pydantic_settings',
64
+ 'curl_cffi',
65
+ 'curl_cffi.requests',
66
+ 'email.mime',
67
+ 'email.mime.text',
68
+ 'email.mime.multipart',
69
+ 'imaplib',
70
+ 'h11',
71
+ 'anyio',
72
+ 'anyio.lowlevel',
73
+ 'click',
74
+ 'src.web.app',
75
+ 'src.web.routes',
76
+ 'src.config.settings',
77
+ 'src.config.constants',
78
+ 'src.database.models',
79
+ 'src.database.session',
80
+ 'src.database.crud',
81
+ 'src.database.init_db',
82
+ 'src.core.register',
83
+ 'src.core.http_client',
84
+ 'src.core.utils',
85
+ 'src.services.base',
86
+ 'src.services.tempmail',
87
+ 'src.services.moe_mail',
88
+ 'src.services.outlook',
89
+ 'src.services.outlook.account',
90
+ 'src.services.outlook.base',
91
+ 'src.services.outlook.email_parser',
92
+ 'src.services.outlook.health_checker',
93
+ 'src.services.outlook.service',
94
+ 'src.services.outlook.token_manager',
95
+ 'src.services.outlook.providers',
96
+ 'src.services.outlook.providers.base',
97
+ 'src.services.outlook.providers.graph_api',
98
+ 'src.services.outlook.providers.imap_new',
99
+ 'src.services.outlook.providers.imap_old',
100
+ 'src.services.outlook_legacy_mail',
101
+ 'src.core.openai.oauth',
102
+ 'src.core.openai.token_refresh',
103
+ 'src.core.upload.cpa_upload',
104
+ 'src.core.upload.sub2api_upload',
105
+ 'src.core.upload.team_manager_upload',
106
+ 'src.web.routes.accounts',
107
+ 'src.web.routes.email',
108
+ 'src.web.routes.registration',
109
+ 'src.web.routes.settings',
110
+ 'src.web.routes.websocket',
111
+ 'src.web.task_manager',
112
+ ],
113
+ hookspath=[],
114
+ hooksconfig={},
115
+ runtime_hooks=[],
116
+ excludes=[
117
+ 'tkinter',
118
+ 'matplotlib',
119
+ 'numpy',
120
+ 'pandas',
121
+ 'PIL',
122
+ 'pytest',
123
+ ],
124
+ win_no_prefer_redirects=False,
125
+ win_private_assemblies=False,
126
+ cipher=block_cipher,
127
+ noarchive=False,
128
+ )
129
+
130
+ pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
131
+
132
+ exe = EXE(
133
+ pyz,
134
+ a.scripts,
135
+ a.binaries,
136
+ a.datas,
137
+ [],
138
+ name='codex-console',
139
+ debug=False,
140
+ bootloader_ignore_signals=False,
141
+ strip=False,
142
+ upx=True,
143
+ upx_exclude=[],
144
+ runtime_tmpdir=None,
145
+ console=True,
146
+ disable_windowed_traceback=False,
147
+ argv_emulation=False,
148
+ target_arch=None,
149
+ codesign_identity=None,
150
+ entitlements_file=None,
151
+ )
deploy_140.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import zipfile
3
+ import paramiko
4
+
5
+ HOST = '23.140.140.70'
6
+ PASSWORD = '33nKDS80L6ipmedJ3y0j'
7
+ PORT = 22
8
+ USER = 'root'
9
+ REMOTE_DIR = '/opt/codex-console'
10
+
11
+ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
12
+ ZIP_FILE = os.path.join(BASE_DIR, 'deploy.zip')
13
+ EXCLUDE_DIRS = {'.git', '.venv', 'data', 'logs', '__pycache__', '.pytest_cache', '.vscode', '.idea'}
14
+
15
+ def create_zip():
16
+ print(f"Creating zip file {ZIP_FILE}...")
17
+ with zipfile.ZipFile(ZIP_FILE, 'w', zipfile.ZIP_DEFLATED) as zipf:
18
+ for root, dirs, files in os.walk(BASE_DIR):
19
+ dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS]
20
+ for file in files:
21
+ if file.endswith('.zip') or file.endswith('.pyc') or file == 'logs*.txt':
22
+ continue
23
+ file_path = os.path.join(root, file)
24
+ arcname = os.path.relpath(file_path, BASE_DIR)
25
+ zipf.write(file_path, arcname)
26
+ print("Zip created successfully.")
27
+
28
+ def deploy():
29
+ print(f"\n--- Deploying to {HOST} ---")
30
+ ssh = paramiko.SSHClient()
31
+ ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
32
+ ssh.connect(HOST, port=PORT, username=USER, password=PASSWORD, timeout=30, banner_timeout=30)
33
+ print("Connected.")
34
+
35
+ ssh.exec_command(f'mkdir -p {REMOTE_DIR}')
36
+ ssh.exec_command(f'rm -rf {REMOTE_DIR}/logs/*.log')
37
+ ssh.exec_command(f'rm -rf {REMOTE_DIR}/logs/*.txt')
38
+ ssh.exec_command(f'mkdir -p {REMOTE_DIR}/data')
39
+ ssh.exec_command(f'mkdir -p {REMOTE_DIR}/logs')
40
+
41
+ print("Uploading zip file...")
42
+ sftp = ssh.open_sftp()
43
+ sftp.put(ZIP_FILE, f'{REMOTE_DIR}/deploy.zip')
44
+ sftp.close()
45
+ print("Upload complete.")
46
+
47
+ print("Extracting and running docker-compose...")
48
+ commands = [
49
+ f"cd {REMOTE_DIR}",
50
+ "unzip -o deploy.zip > /dev/null",
51
+ "rm deploy.zip",
52
+ "(docker-compose down || docker compose down || true)",
53
+ "(docker-compose up -d --build || docker compose up -d --build)"
54
+ ]
55
+ command = ' && '.join(commands)
56
+ stdin, stdout, stderr = ssh.exec_command(command, timeout=120)
57
+
58
+ for line in stdout:
59
+ print(line.strip())
60
+ for line in stderr:
61
+ print("ERR:", line.strip())
62
+
63
+ exit_status = stdout.channel.recv_exit_status()
64
+ if exit_status == 0:
65
+ print(f"Deployment to {HOST} successful!")
66
+ else:
67
+ print(f"Deployment to {HOST} failed with exit status {exit_status}")
68
+
69
+ ssh.close()
70
+
71
+ if __name__ == '__main__':
72
+ create_zip()
73
+ deploy()
74
+ if os.path.exists(ZIP_FILE):
75
+ os.remove(ZIP_FILE)
76
+ print("Cleaned up local zip file.")
docker-compose.yml ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: '3.8'
2
+
3
+ services:
4
+ webui:
5
+ build: .
6
+ ports:
7
+ - "1455:1455"
8
+ environment:
9
+ - WEBUI_HOST=0.0.0.0
10
+ - WEBUI_PORT=1455
11
+ - DEBUG=0
12
+ - LOG_LEVEL=info
13
+ # 如果需要访问密码,可以在这里取消注释并设置
14
+ - WEBUI_ACCESS_PASSWORD=admin123
15
+ volumes:
16
+ # 挂载数据目录以持久化数据库和日志
17
+ - ./data:/app/data
18
+ - ./logs:/app/logs
19
+ restart: unless-stopped
docs/anti-detection-critical-analysis.md ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # HTTP-Only 设备指纹补强方案 — 批判性深度分析(v2 修订版)
2
+
3
+ > 以反检测专家视角,**结合代码实查**,对原始分析文档以及 v1 批判报告的逐项复核。
4
+ > ⚠️ v2 修正了 v1 中 3 处事实性错误,并新增了 3 个代码中发现的**真实 bug**。
5
+
6
+ ---
7
+
8
+ ## v1 → v2 勘误表
9
+
10
+ | v1 中的说法 | 判决 | 原因 |
11
+ |---|---|---|
12
+ | "严重低估 Turnstile" → P0 威胁 | ❌ **说错了** | 注册链路用的是 `openai-sentinel-token`(PoW only),不是 `openai-sentinel-turnstile-token`(VM 字节码)。两条链路完全独立。项目能跑通即为实证。 |
13
+ | "`oai-echo-logs` 是 P0 级遗漏" | ❌ **说错了** | `oai-echo-logs` 是 **ChatGPT 对话链路**(`/backend-anon/f/conversation`)的 header,注册链路(`auth.openai.com/api/accounts/*`)从未出现过。混淆了两条链路。 |
14
+ | "Auth0 内置 ML bot detection" | ⚠️ **不准确** | 代码中 OpenAI 使用的是 **自有 auth 端点**(`auth.openai.com/api/accounts/authorize/continue` 等),不是标准 Auth0 Universal Login。不能直接套用 Auth0 商业版的 bot detection 文档。 |
15
+ | Traceparent / Datadog 应降至 P2 | ✅ **方向对但理由需修正** | 实际代码中存在格式 bug(见下文),这些 header 不仅是低 ROI,而且目前的实现**有害**。 |
16
+ | Cookie 属性一致性是盲区 | ✅ **依然成立** | `curl_cffi` 的 cookie jar 自动处理 `Set-Cookie` 属性,不需要手动模拟。但如果有**手动硬编码 cookie**,需要确认属性匹配。 |
17
+ | Client Hints 不完整 | ✅ **依然成立,比想象中更严重** | 整个 `src/` 目录中 **零个** `sec-ch-ua` header。 |
18
+
19
+ ---
20
+
21
+ ## 🔴 代码实查发现的真实 Bug
22
+
23
+ ### Bug 1:`impersonate` 版本与 User-Agent 不匹配
24
+
25
+ ```python
26
+ # http_client.py L29 — 注册链路使用的 impersonate
27
+ impersonate: str = "chrome" # 泛版本,curl_cffi 自动选最新
28
+
29
+ # http_client.py L258-259 — 硬编码的 User-Agent
30
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ... Chrome/120.0.0.0 Safari/537.36"
31
+ ```
32
+
33
+ **问题**:`impersonate="chrome"` 让 curl_cffi 使用最新 Chrome 的 TLS/HTTP2 指纹(可能是 Chrome 131+),但 User-Agent 写死了 `Chrome/120.0.0.0`。TLS 指纹说 Chrome 131,UA 说 Chrome 120 —— **这是一个典型的指纹分裂信号**。
34
+
35
+ **对比**:项目其他模块统一使用 `impersonate="chrome110"`,至少内部一致。
36
+
37
+ **修复建议**:将 impersonate 固定为具体版本(如 `chrome120`),或动态匹配 UA 版本。
38
+
39
+ ---
40
+
41
+ ### Bug 2:`traceparent` 与 `x-datadog-trace-id` 不一致
42
+
43
+ ```python
44
+ # register.py L279-288
45
+ def _make_trace_headers(self) -> Dict[str, str]:
46
+ trace_id = random.randint(10 ** 17, 10 ** 18 - 1) # 十进制整数
47
+ parent_id = random.randint(10 ** 17, 10 ** 18 - 1) # 十进制整数
48
+ return {
49
+ "traceparent": f"00-{uuid.uuid4().hex}-{format(parent_id, '016x')}-01",
50
+ # ^^^^^^^^^^^^^^^^ 这是 uuid4 的 32 位 hex
51
+ "x-datadog-trace-id": str(trace_id),
52
+ # ^^^^^^^^^^^^^ 这是另一个随机数的十进制
53
+ }
54
+ ```
55
+
56
+ **问题**:`traceparent` 里的 trace-id 是 `uuid4().hex`(32位hex),但 `x-datadog-trace-id` 是另一个完全不相关的十进制随机数。真实 Datadog RUM SDK 中,这两个值**必须对应同一个 trace**。这个不一致比不发这些 header 更糟糕 —— 它变成了一个"我在伪造追踪头"的强信号。
57
+
58
+ **修复建议**:要么不发这些 header(推荐),要么从同一个 trace-id 派生两个值。
59
+
60
+ ---
61
+
62
+ ### Bug 3:`_submit_login_password` 发送裸 headers
63
+
64
+ ```python
65
+ # register.py L456-464
66
+ response = self.session.post(
67
+ OPENAI_API_ENDPOINTS["password_verify"],
68
+ headers={
69
+ "referer": "https://auth.openai.com/log-in/password",
70
+ "accept": "application/json",
71
+ "content-type": "application/json",
72
+ }, # ← 没有 oai-device-id, 没有 sentinel, 没有 trace, 没有 origin
73
+ data=json.dumps({"password": self.password}),
74
+ )
75
+ ```
76
+
77
+ **问题**:对比 `_submit_auth_start`(提交邮箱阶段),它会注入 `openai-sentinel-token`。但 `_submit_login_password`(提交密码阶段)**完全没有 sentinel**,也没有 `oai-device-id`。这造成了同一会话中两个连续请求之间的 header 风格突变。
78
+
79
+ **风险**:这正是原始分析文档说的"同一会话像多个不同浏览器"的问题。
80
+
81
+ **修复建议**:用 `_build_oauth_json_headers` 统一构建 headers,至少加 `origin` 和 `oai-device-id`。
82
+
83
+ ---
84
+
85
+ ## ✅ 原始文档经代码验证后的评级调整
86
+
87
+ ### 原文档 P0 项验证
88
+
89
+ | 原文档建议 | 代码现状 | 验证结论 |
90
+ |---|---|---|
91
+ | **统一 session 级浏览器 profile** | `sec-ch-ua` 全项缺失;`impersonate` 与 UA 版本不匹配 | ✅ **确实是 P0**,且比文档描述的更急迫 |
92
+ | **`oai-did` 跨域同步** | `_get_device_id` 从 `Set-Cookie` 读取,`_build_oauth_json_headers` 会注入 `oai-device-id` | ⚠️ **部分已做**,但 `_submit_login_password` 等路径丢失了 |
93
+ | **Sentinel 按 flow 生成** | `check_sentinel` 和 `_submit_auth_start` 都硬编码 `flow: "authorize_continue"`,`password_verify` 无 sentinel | ✅ **确实需要补**,至少 password_verify 应有自己的 sentinel |
94
+ | **Sentinel 请求补 client hints** | sentinel 请求(`http_client.py L376-380`)只有 origin/referer/content-type,无 `sec-ch-ua` | ✅ **确认缺失** |
95
+
96
+ ---
97
+
98
+ ## 修订后的优先级排序(v2 代码实证版)
99
+
100
+ ### P0(代码中已确认的问题)
101
+
102
+ | 项目 | 证据 |
103
+ |---|---|
104
+ | **修复 impersonate 与 UA 版本分裂** | `impersonate="chrome"` + `Chrome/120.0.0.0` UA,TLS 层与应用层对不上 |
105
+ | **统一关键路径的 header 构建** | `_submit_login_password` 是裸请求,和同会话其他请求风格不一致 |
106
+ | **补 `sec-ch-ua` 系列 header** | 整个 src/ 中零个 `sec-ch-ua`,真实 Chrome 每个请求都带 |
107
+ | **修复或移除 traceparent/datadog 头** | 当前实现的 trace-id 不一致问题反而暴露自动化 |
108
+
109
+ ### P1(原文档建议 + 代码验证确认有价值)
110
+
111
+ | 项目 | 理由 |
112
+ |---|---|
113
+ | **Sentinel 按 flow 分类** | `password_verify` 完全无 sentinel,和 `authorize_continue` 形成断层 |
114
+ | **`oai-did` 在所有 auth 请求中统一注入** | 部分路径丢失 |
115
+ | **建立 TLS 指纹自测** | `impersonate="chrome"` 的实际行为依赖 `curl_cffi` 版本,需要客观验证 |
116
+
117
+ ### P2(灰度验证 / 长期观察)
118
+
119
+ | 项目 | 理由 |
120
+ |---|---|
121
+ | `ext-oai-did` / `auth_session_logging_id` | 前端语义增强,按原文档灰度引入即可 |
122
+ | `oai-sc` cookie 研究 | 需要抓包确认注册流程是否涉及 |
123
+ | 注册链路 Turnstile 监控 | 当前不需要,但未来可能引入,建议在 sentinel 响应异常时记录完整响应体 |
124
+
125
+ ---
126
+
127
+ ## v1 中仍然准确的部分
128
+
129
+ 1. **"一致性 > 随机性"** — 核心判断正确,代码中 Bug 1-3 都是一致性问题
130
+ 2. **Cookie 属性关注** — `curl_cffi` 自动处理 `Set-Cookie` 属性,但值得确认无手动覆盖
131
+ 3. **网络环境是指纹的一部分** — datacenter IP + `curl_cffi` 的 TLS 即使匹配也不够
132
+ 4. **请求时序模拟** — 虽然 `oai-echo-logs` 不适用于 auth 链路,但步骤间延迟仍是好建议
133
+ 5. **不堆随机** — 代码实证再次证明,不一致远比缺少更危险
134
+
135
+ ---
136
+
137
+ ## 最终建议(v2)
138
+
139
+ > [!IMPORTANT]
140
+ > **最大的威胁不是"缺什么 header",而是已经发的 header 自相矛盾。** 你的注册链路能跑通,说明 PoW + oai-did 这条路线是可行的。现在最值得投入的是修复 Bug 1-3 这种**自我暴露**的问题,而不是追加更多 header。
141
+
142
+ **建议落地顺序**:
143
+ 1. 修 impersonate/UA 版本对齐(10 分钟)
144
+ 2. 统一 `_submit_login_password` 的 header 构建(30 分钟)
145
+ 3. 修复或移除 traceparent/datadog 头(15 分钟)
146
+ 4. 补 `sec-ch-ua` 系列,绑定到 session profile(1-2 小时)
147
+ 5. 后续按 P1/P2 灰度推进
148
+
149
+ > 关于 headless browser 兜底:v1 的建议仍成立,但紧迫度降低。当前注册链路能跑通,先把 HTTP-only 的一致性全面修复,headless 作为长期保险即可。
docs/anti-detection-final-recommendations.md ADDED
@@ -0,0 +1,416 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # HTTP-Only 注册链路反检测 — 最终完整修复建议
2
+
3
+ > **版本**:v3 全量版(合并 3 轮分析)
4
+ > **方法论**:逐行代码审计 + 真实 Chrome 行为对照 + codex-suite-win 参考实现对比 + 防御者视角 10 维度 threat model
5
+ > **范围**:覆盖外壳层(HTTP/TLS)、行为层(时序/流量形状)、内核层(Sentinel PoW 载荷)
6
+
7
+ ---
8
+
9
+ ## 检测维度总览
10
+
11
+ ```
12
+ ┌─ 外壳层(HTTP 请求长什么样)──────────────────────────────┐
13
+ │ TLS/H2 指纹 · HTTP headers · Client Hints · sec-fetch-* │
14
+ │ traceparent/datadog · 首请求导航特征 │
15
+ ├─ 行为层(请求的节奏和模式)──────────────────────────────┤
16
+ │ 步骤间延迟 · 流量入口形状 · 跨账号统计关联 │
17
+ │ 密码/名字/生日的统计指纹 │
18
+ ├─ 内核层(PoW token 里嵌的环境数据)──────────────────────┤
19
+ │ 时区 vs IP 地理 · 语言签名 vs Accept-Language │
20
+ │ 屏幕分辨率 · navigator/window 属性池 · 哈希算法版本 │
21
+ └──────────────────────────────────────────────────────────┘
22
+ ```
23
+
24
+ ---
25
+
26
+ ## 注册链路完整请求地图
27
+
28
+ | # | 端点 | 代码位置 | headers 来源 |
29
+ |---|---|---|---|
30
+ | ① | `GET auth.openai.com/oauth/authorize?...` | `register.py:248` | **无自定义 headers** |
31
+ | ② | `POST sentinel.openai.com/backend-api/sentinel/req` | `http_client.py:374` | origin + referer + content-type |
32
+ | ③ | `POST auth.openai.com/api/accounts/authorize/continue` | `register.py:372` | referer + accept + content-type + sentinel |
33
+ | ④ | `POST auth.openai.com/api/accounts/password/verify` | `register.py:457` | **裸 headers** |
34
+ | ⑤ | `POST auth.openai.com/api/accounts/email-otp/validate` | `register.py:2193` | **裸 headers** |
35
+ | ⑥ | `POST auth.openai.com/api/accounts/create_account` | `register.py:2281` | **裸 headers** |
36
+ | ⑦ | `POST auth.openai.com/oauth/token` | `oauth.py:160` | **独立一次性会话** |
37
+
38
+ ---
39
+
40
+ ## 全部问题清单与优先级
41
+
42
+ ### 🔴 P0 — 立即修复(每项都是可被直接检测的暴露点)
43
+
44
+ ---
45
+
46
+ #### P0-1. Sentinel PoW 语言签名 vs Accept-Language 矛盾
47
+
48
+ **代码位置**:`sentinel.py:18` vs `http_client.py:261`
49
+
50
+ ```python
51
+ # sentinel.py — PoW 内部声称浏览器语言
52
+ _LANGUAGE_SIGNATURE = "en-US,es-US,en,es" # ← 包含西班牙语
53
+
54
+ # http_client.py — HTTP 请求头
55
+ "Accept-Language": "en-US,en;q=0.9" # ← 只有英语
56
+ ```
57
+
58
+ **问题**:同一个浏览器的语言配置在 PoW 载荷和 HTTP 请求中对不上。服务端解码 PoW 后可直接比对。
59
+
60
+ **修复**:统一为 `"en-US,en"` 或从 BrowserProfile 中读取。
61
+
62
+ **工时**:5 分钟
63
+
64
+ ---
65
+
66
+ #### P0-2. Sentinel PoW 时区硬编码 EST,与代理 IP 地理位置冲突
67
+
68
+ **代码位置**:`sentinel.py:29`
69
+
70
+ ```python
71
+ browser_now = datetime.now(timezone(timedelta(hours=-5)))
72
+ # → "GMT-0500 (Eastern Standard Time)"
73
+ ```
74
+
75
+ **问题**:PoW 永远声称浏览器在美东时区。如果代理 IP 在欧洲/亚洲,IP 地理位置 vs PoW 时区 = 矛盾。
76
+
77
+ **修复**:改用 UTC(`time.gmtime()` + `"GMT+0000 (Coordinated Universal Time)"`),和 codex-suite-win 保持一致。UTC 不暴露地理位置。
78
+
79
+ **工时**:5 分钟
80
+
81
+ ---
82
+
83
+ #### P0-3. Sentinel PoW 屏幕分辨率不是真实值
84
+
85
+ **代码位置**:`sentinel.py:17`
86
+
87
+ ```python
88
+ _SCREEN_SIGNATURES = (3000, 3120, 4000, 4160)
89
+ # 没有任何真实显示器是这些分辨率
90
+ ```
91
+
92
+ **修复**:替换为真实常见值,如 `"1920x1080"`、`"2560x1440"`、`"1366x768"`。codex-suite-win 使用 `"1920x1080"`。
93
+
94
+ **工时**:5 分钟
95
+
96
+ ---
97
+
98
+ #### P0-4. impersonate 版本与 User-Agent 版本分裂
99
+
100
+ **代码位置**:`http_client.py:29` + `http_client.py:258`
101
+
102
+ ```python
103
+ impersonate: str = "chrome" # → TLS 层 = 最新 Chrome (131+)
104
+ "User-Agent": "...Chrome/120.0.0.0..." # → 应用层 = Chrome 120
105
+ ```
106
+
107
+ **问题**:TLS ClientHello 的指纹对应 Chrome 131+,但 UA 声称 Chrome 120。
108
+
109
+ **修复**:建立 BrowserProfile 工厂,绑定 `impersonate` 版本与 UA/sec-ch-ua:
110
+
111
+ ```python
112
+ _CHROME_PROFILES = [
113
+ {"impersonate": "chrome120", "major": 120, "build": 6099,
114
+ "sec_ch_ua": '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"'},
115
+ {"impersonate": "chrome131", "major": 131, "build": 6778,
116
+ "sec_ch_ua": '"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"'},
117
+ # 按需扩展
118
+ ]
119
+ ```
120
+
121
+ **工时**:1 小时
122
+
123
+ ---
124
+
125
+ #### P0-5. 所有请求缺少 sec-ch-ua 三件套
126
+
127
+ **代码位置**:整个 `src/` 目录 — 零个 `sec-ch-ua`
128
+
129
+ Chrome 从 2021 年起**每个 HTTPS 请求默认发送**:
130
+
131
+ ```
132
+ sec-ch-ua: "Chromium";v="120", "Google Chrome";v="120", "Not_A Brand";v="8"
133
+ sec-ch-ua-mobile: ?0
134
+ sec-ch-ua-platform: "Windows"
135
+ ```
136
+
137
+ 你的 7 个请求阶段全部缺失。声称是 Chrome 但不发 Chrome 默认 headers = 最基本的检测信号。
138
+
139
+ **修复**:包含在 P0-4 的 BrowserProfile 中。
140
+
141
+ **工时**:含在 P0-4 中
142
+
143
+ ---
144
+
145
+ #### P0-6. 注册流程零延迟,全链路 < 2 秒完成
146
+
147
+ **代码位置**:`register.py` 全流程
148
+
149
+ 7 个 HTTP 请求之间没有任何 `time.sleep()`,请求背靠背发出。真实用户从打开注册页到完成至少 30-120 秒。
150
+
151
+ **修复**:在关键步骤间加入正态分布延迟:
152
+
153
+ ```python
154
+ import random, time
155
+
156
+ def _human_delay(mean=3.0, std=1.0, minimum=1.0):
157
+ delay = max(random.gauss(mean, std), minimum)
158
+ time.sleep(delay)
159
+ ```
160
+
161
+ - `_get_device_id` → `_check_sentinel`:延迟 1-3秒
162
+ - `_submit_auth_start` → `_submit_login_password`:延迟 2-5秒("用户在输密码")
163
+ - `_validate_verification_code`:延迟 5-15秒("用户在等验证码邮件")
164
+ - `_create_user_account`:延迟 2-4秒("用户在填姓名和生日")
165
+
166
+ **工时**:30 分钟
167
+
168
+ ---
169
+
170
+ #### P0-7. 阶段 ④⑤⑥ 的 headers 风格与 ③ 不一致
171
+
172
+ **代码位置**:`register.py:459`、`register.py:2195`、`register.py:2283`
173
+
174
+ `password_verify`、`validate_otp`、`create_account` 只有 3 行最小 headers(referer + accept + content-type),缺少:
175
+ - `origin`
176
+ - `oai-device-id`
177
+ - `sec-ch-ua` 三件套
178
+ - `sec-fetch-*` 三件套
179
+
180
+ 同一会话中 ③ 带了 sentinel 和更多 headers,④⑤⑥ 突然"简化" = header 风格突变。
181
+
182
+ **修复**:统一使用 `profile.json_request_headers(referer)` 构建,至少加 `origin` 和 `sec-fetch-*`。
183
+
184
+ **工时**:30 分钟
185
+
186
+ ---
187
+
188
+ ### 🟡 P1 — 建议修复(降低检测风险、提高耐久性)
189
+
190
+ ---
191
+
192
+ #### P1-1. traceparent 与 x-datadog-trace-id 值不一致
193
+
194
+ **代码位置**:`register.py:279-288`
195
+
196
+ `traceparent` 用 `uuid4().hex` 做 trace-id,`x-datadog-trace-id` 用另一个随机十进制数。两者必须对应同一个 trace。当前实现比不发更糟糕。
197
+
198
+ **修复**:**推荐直接移除**这些 headers。如需保留,从同一 trace-id 派生:
199
+
200
+ ```python
201
+ trace_id_hex = uuid.uuid4().hex
202
+ dd_trace_id = int(trace_id_hex[16:], 16) # 低64位转十进制
203
+ ```
204
+
205
+ **工时**:15 分钟
206
+
207
+ ---
208
+
209
+ #### P1-2. 密码无特殊字符,批量可指纹
210
+
211
+ **代码位置**:`constants.py:179`
212
+
213
+ ```python
214
+ PASSWORD_CHARSET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
215
+ # → 所有账号密码 = [a-zA-Z0-9]{12},无特殊字符
216
+ ```
217
+
218
+ 同一 IP 注册的所有账号密码都是相同字符集和长度,统计可识别。
219
+
220
+ **修复**:
221
+
222
+ ```python
223
+ PASSWORD_CHARSET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%&*"
224
+ DEFAULT_PASSWORD_LENGTH = random.randint(12, 16)
225
+ # 确保至少包含 1 特殊字符
226
+ ```
227
+
228
+ **工时**:10 分钟
229
+
230
+ ---
231
+
232
+ #### P1-3. Name 只有名没有姓
233
+
234
+ **代码位置**:`constants.py:201`
235
+
236
+ ```python
237
+ name = random.choice(FIRST_NAMES) # → "James"(无姓)
238
+ ```
239
+
240
+ OpenAI about-you 页面要的是 Full Name,只有一个词极罕见。
241
+
242
+ **修复**:加 LAST_NAMES 列表,生成 `"James Smith"` 格式。
243
+
244
+ **工时**:10 分钟
245
+
246
+ ---
247
+
248
+ #### P1-4. 首请求(_get_device_id)无浏览器导航 headers
249
+
250
+ **代码位置**:`register.py:248`
251
+
252
+ ```python
253
+ response = self.session.get(self.oauth_start.auth_url, timeout=20)
254
+ # ← 没有 Accept, 没有 Upgrade-Insecure-Requests, 没有 sec-fetch-*
255
+ ```
256
+
257
+ 真实浏览器导航到页面时的 headers:
258
+
259
+ ```
260
+ Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
261
+ Upgrade-Insecure-Requests: 1
262
+ Sec-Fetch-Dest: document
263
+ Sec-Fetch-Mode: navigate
264
+ Sec-Fetch-Site: none
265
+ Sec-Fetch-User: ?1
266
+ ```
267
+
268
+ **修复**:补上导航请求标准 headers。
269
+
270
+ **工时**:10 分钟
271
+
272
+ ---
273
+
274
+ #### P1-5. Sentinel navigator/window 属性池太小
275
+
276
+ **代码位置**:`sentinel.py:19-20`
277
+
278
+ ```python
279
+ _NAVIGATOR_KEYS = ("location", "ontransitionend", "onprogress") # 3 个
280
+ _WINDOW_KEYS = ("window", "document", "navigator") # 3 个
281
+ ```
282
+
283
+ codex-suite-win 有 21 个 navigator 属性、6 个 window 属性。你的 3 个会导致多次注册中频繁重复。
284
+
285
+ **修复**:扩展到至少 15+ 个真实 Chrome navigator 属性。
286
+
287
+ **工时**:15 分钟
288
+
289
+ ---
290
+
291
+ #### P1-6. Sentinel 请求缺 sec-ch-ua 和 sec-fetch-*
292
+
293
+ **代码位置**:`http_client.py:376-380`
294
+
295
+ Sentinel 请求只有 origin/referer/content-type,缺少浏览器标准 headers。
296
+
297
+ **修复**:补 `sec-ch-ua` 三件套 + `sec-fetch-dest: empty` + `sec-fetch-mode: cors` + `sec-fetch-site: cross-site`。
298
+
299
+ **工时**:10 分钟
300
+
301
+ ---
302
+
303
+ #### P1-7. Sentinel PoW 哈希算法可能不匹配当前版本
304
+
305
+ **代码位置**:`sentinel.py:82`
306
+
307
+ 你用 SHA3-512,codex-suite-win 用 FNV-1a。这是两种不同的 challenge 协议。需要验证当前 OpenAI 注册链路使用的是哪个版本。
308
+
309
+ **修复**:抓包确认 sentinel/req 响应中的 challenge 格式。如果 OpenAI 已升级,需要更新哈希算法。
310
+
311
+ **工时**:1-2 小时(含调研)
312
+
313
+ ---
314
+
315
+ #### P1-8. 注册入口跳过 chatgpt.com 前端
316
+
317
+ 你的流程直接 GET `auth.openai.com/oauth/authorize`,跳过了真实用户的路径:`chatgpt.com → CSRF → signin/openai → 跳转到 auth`。
318
+
319
+ **修复**:模拟 chatgpt.com 入口流,注入 `ext-oai-did` + `auth_session_logging_id`。
320
+
321
+ **工时**:2-3 小时
322
+
323
+ ---
324
+
325
+ ### ⚪ P2 — 灰度验证(长期增强)
326
+
327
+ ---
328
+
329
+ #### P2-1. 跨账号统计关联
330
+
331
+ 同一 IP 批量注册时,所有账号共享:单一 Chrome 版本、相同密码字符集/长度、相同名字池、相同 TLS 指纹、相同注册耗时。
332
+
333
+ **修复**:通过 P0-4(多版本 Profile 池)+ P1-2(密码多样化)+ P1-3(名字多样化)+ P0-6(延迟随机化)综合缓解。
334
+
335
+ ---
336
+
337
+ #### P2-2. OAuth token exchange 使用独立会话
338
+
339
+ `oauth.py:160` 使用 `cffi_requests.post` 而非 `self.session.post`,不携带前面的 cookies。
340
+
341
+ **修复**:改为使用主会话发送 token exchange 请求。
342
+
343
+ **工时**:30 分钟
344
+
345
+ ---
346
+
347
+ #### P2-3. 出生年份均匀分布
348
+
349
+ ```python
350
+ birth_year = random.randint(current_year - 45, current_year - 18)
351
+ # → 均匀分布,但真人年龄是正态分布
352
+ ```
353
+
354
+ **修复**:改为正态分布 `int(random.gauss(28, 6))`,限制在 18-45 岁。
355
+
356
+ **工时**:10 分钟
357
+
358
+ ---
359
+
360
+ #### P2-4. ext-oai-did / auth_session_logging_id
361
+
362
+ codex-suite-win 在 signin 时注入这两个参数,你的项目没有。
363
+
364
+ **修复**:在 OAuth 入口流中补充。
365
+
366
+ **工时**:含在 P1-8 中
367
+
368
+ ---
369
+
370
+ ## 修复依赖图与落地顺序
371
+
372
+ ```mermaid
373
+ graph TD
374
+ A["P0-4 BrowserProfile 工厂"] --> B["P0-5 sec-ch-ua 全覆盖"]
375
+ A --> C["P0-7 统一 header 构建"]
376
+ A --> D["P1-6 Sentinel 补 headers"]
377
+ A --> G["P1-4 首请求导航 headers"]
378
+
379
+ E["P0-1 PoW 语言签名"] --> |独立| E
380
+ F["P0-2 PoW 时区"] --> |独立| F
381
+ H["P0-3 PoW 屏幕分辨率"] --> |独立| H
382
+ I["P0-6 步骤间延迟"] --> |独立| I
383
+
384
+ C --> J["P1-1 移除 traceparent"]
385
+
386
+ K["P1-2 密码特殊字符"] --> |独立| K
387
+ L["P1-3 Name 加姓"] --> |独立| L
388
+ ```
389
+
390
+ ### 推荐执行顺序
391
+
392
+ | 批次 | 内容 | 总工时 | 效果 |
393
+ |---|---|---|---|
394
+ | **第 1 批** | P0-1 + P0-2 + P0-3(PoW 内部 3 个矛盾) | 15 分钟 | 消除 PoW 载荷的内部自相矛盾 |
395
+ | **第 2 批** | P0-6(加延迟) + P1-2(密码)+ P1-3(姓名) | 50 分钟 | 消除行为层最明显的批量特征 |
396
+ | **第 3 批** | P0-4 + P0-5(BrowserProfile + sec-ch-ua) | 1 小时 | 消除 TLS/UA 分裂和 Client Hints 缺失 |
397
+ | **第 4 批** | P0-7 + P1-4 + P1-6(统一 headers) | 50 分钟 | 消除 header 风格突变 |
398
+ | **第 5 批** | P1-1 + P1-5 + P1-7 | 2.5 小时 | 清理 traceparent / 扩展 PoW 属性池 / 确认哈希算法 |
399
+ | **第 6 批** | P1-8 + P2-*(入口流 + 灰度项) | 3-4 小时 | 流量形状对齐 |
400
+
401
+ ---
402
+
403
+ ## 修复后的覆盖率预估
404
+
405
+ | 侦察维度 | 修复前 | 第 1-2 批后 | 第 3-4 批后 | 全部完成后 |
406
+ |---|---|---|---|---|
407
+ | TLS/HTTP2 指纹 | ⚠️ 版本不匹配 | ⚠️ | ✅ | ✅ |
408
+ | HTTP headers 一致性 | ❌ 3 个裸端点 | ❌ | ✅ | ✅ |
409
+ | Client Hints | ❌ 零个 | ❌ | ✅ | ✅ |
410
+ | Datadog 追踪头 | ❌ 值矛盾 | ❌ | ❌ | ✅ |
411
+ | 请求时序/节奏 | ❌ 零延迟 | ✅ | ✅ | ✅ |
412
+ | 流量入口形状 | ❌ 跳过前端 | ❌ | ❌ | ✅ |
413
+ | 账号数据统计模式 | ❌ 批量可指纹 | ✅ | ✅ | ✅ |
414
+ | 首请求导航特征 | ❌ 无 headers | ❌ | ✅ | ✅ |
415
+ | PoW 内部环境一致性 | ❌ 3 处矛盾 | ✅ | ✅ | ✅ |
416
+ | 跨账号统计关联 | ❌ 单一 profile | ⚠️ | ✅ | ✅ |
docs/external-api-examples.md ADDED
@@ -0,0 +1,524 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # External API 调用示例
2
+
3
+ 本文档提供外部注册 API 的最常用调用示例,便于脚本、面板或第三方系统直接接入。
4
+
5
+ 相关主说明见:
6
+
7
+ - [Temp Mail 域名池与外部注册 API 说明](temp-mail-external-api.md)
8
+
9
+ ---
10
+
11
+ ## 1. 基本约定
12
+
13
+ 假设你的服务地址为:
14
+
15
+ ```text
16
+ http://127.0.0.1:8000/api
17
+ ```
18
+
19
+ 假设外部 API Key 为:
20
+
21
+ ```text
22
+ your_api_key
23
+ ```
24
+
25
+ 所有请求都需要带:
26
+
27
+ ```http
28
+ X-API-Key: your_api_key
29
+ ```
30
+
31
+ 这个值的来源是项目管理员在系统配置里设置的 `external_api_key`。
32
+ 当前版本没有开放“申请/查询 API Key”接口,第三方只能由管理员直接发放。
33
+
34
+ ---
35
+
36
+ ## 2. 查询能力
37
+
38
+ ### curl
39
+
40
+ ```bash
41
+ curl -X GET "http://127.0.0.1:8000/api/external/capabilities" \
42
+ -H "X-API-Key: your_api_key"
43
+ ```
44
+
45
+ ### Python
46
+
47
+ ```python
48
+ import requests
49
+
50
+ resp = requests.get(
51
+ "http://127.0.0.1:8000/api/external/capabilities",
52
+ headers={"X-API-Key": "your_api_key"},
53
+ timeout=30,
54
+ )
55
+ print(resp.status_code)
56
+ print(resp.json())
57
+ ```
58
+
59
+ 假设返回里有:
60
+
61
+ ```json
62
+ {
63
+ "email_types": [
64
+ {
65
+ "type": "temp_mail",
66
+ "services": [
67
+ {"id": 3, "name": "Worker temp mail A"},
68
+ {"id": 7, "name": "Worker temp mail B"}
69
+ ]
70
+ }
71
+ ],
72
+ "upload_providers": [
73
+ {
74
+ "provider": "sub2api",
75
+ "services": [
76
+ {"id": 1, "name": "sub2-main"},
77
+ {"id": 4, "name": "sub2-backup"}
78
+ ]
79
+ }
80
+ ]
81
+ }
82
+ ```
83
+
84
+ 那么:
85
+
86
+ - `email.service_id=3` 就是 `Worker temp mail A`
87
+ - `email.service_id=7` 就是 `Worker temp mail B`
88
+ - `upload.service_id=1` 就是 `sub2-main`
89
+ - `upload.service_id=4` 就是 `sub2-backup`
90
+
91
+ 不要自己猜数字含义,始终以 `capabilities` 返回结果为准。
92
+
93
+ ---
94
+
95
+ ## 3. 创建注册批次
96
+
97
+ ### 3.1 使用 temp_mail,不上传
98
+
99
+ #### curl
100
+
101
+ ```bash
102
+ curl -X POST "http://127.0.0.1:8000/api/external/registration/batches" \
103
+ -H "Content-Type: application/json" \
104
+ -H "X-API-Key: your_api_key" \
105
+ -d '{
106
+ "count": 3,
107
+ "email": {
108
+ "type": "temp_mail"
109
+ },
110
+ "upload": {
111
+ "enabled": false
112
+ },
113
+ "execution": {
114
+ "mode": "pipeline",
115
+ "concurrency": 1,
116
+ "interval_min": 5,
117
+ "interval_max": 10
118
+ }
119
+ }'
120
+ ```
121
+
122
+ #### Python
123
+
124
+ ```python
125
+ import requests
126
+
127
+ payload = {
128
+ "count": 3,
129
+ "email": {"type": "temp_mail"},
130
+ "upload": {"enabled": False},
131
+ "execution": {
132
+ "mode": "pipeline",
133
+ "concurrency": 1,
134
+ "interval_min": 5,
135
+ "interval_max": 10,
136
+ },
137
+ }
138
+
139
+ resp = requests.post(
140
+ "http://127.0.0.1:8000/api/external/registration/batches",
141
+ headers={
142
+ "X-API-Key": "your_api_key",
143
+ "Content-Type": "application/json",
144
+ },
145
+ json=payload,
146
+ timeout=30,
147
+ )
148
+
149
+ print(resp.status_code)
150
+ print(resp.json())
151
+ ```
152
+
153
+ ---
154
+
155
+ ### 3.2 指定某个邮箱服务并自动上传到 sub2api
156
+
157
+ #### curl
158
+
159
+ ```bash
160
+ curl -X POST "http://127.0.0.1:8000/api/external/registration/batches" \
161
+ -H "Content-Type: application/json" \
162
+ -H "X-API-Key: your_api_key" \
163
+ -d '{
164
+ "count": 5,
165
+ "idempotency_key": "batch-demo-001",
166
+ "email": {
167
+ "type": "temp_mail",
168
+ "service_id": 1
169
+ },
170
+ "upload": {
171
+ "enabled": true,
172
+ "provider": "sub2api",
173
+ "service_id": 1
174
+ },
175
+ "execution": {
176
+ "mode": "parallel",
177
+ "concurrency": 3,
178
+ "interval_min": 0,
179
+ "interval_max": 0
180
+ }
181
+ }'
182
+ ```
183
+
184
+ #### Python
185
+
186
+ ```python
187
+ import requests
188
+
189
+ payload = {
190
+ "count": 5,
191
+ "idempotency_key": "batch-demo-001",
192
+ "email": {
193
+ "type": "temp_mail",
194
+ "service_id": 1,
195
+ },
196
+ "upload": {
197
+ "enabled": True,
198
+ "provider": "sub2api",
199
+ "service_id": 1,
200
+ },
201
+ "execution": {
202
+ "mode": "parallel",
203
+ "concurrency": 3,
204
+ "interval_min": 0,
205
+ "interval_max": 0,
206
+ },
207
+ }
208
+
209
+ resp = requests.post(
210
+ "http://127.0.0.1:8000/api/external/registration/batches",
211
+ headers={
212
+ "X-API-Key": "your_api_key",
213
+ "Content-Type": "application/json",
214
+ },
215
+ json=payload,
216
+ timeout=30,
217
+ )
218
+
219
+ data = resp.json()
220
+ print(resp.status_code, data)
221
+ batch_uuid = data["batch_uuid"]
222
+ ```
223
+
224
+ > 说明:
225
+ >
226
+ > - 首次创建一般返回 `202`
227
+ > - 如果 `idempotency_key` 命中历史批次,返回 `200`
228
+
229
+ ---
230
+
231
+ ### 3.3 不指定 service_id,交给系统自动选择
232
+
233
+ ```python
234
+ import requests
235
+
236
+ payload = {
237
+ "count": 2,
238
+ "email": {
239
+ "type": "temp_mail"
240
+ },
241
+ "upload": {
242
+ "enabled": True,
243
+ "provider": "sub2api"
244
+ },
245
+ "execution": {
246
+ "mode": "pipeline",
247
+ "concurrency": 1,
248
+ "interval_min": 0,
249
+ "interval_max": 0
250
+ }
251
+ }
252
+
253
+ resp = requests.post(
254
+ "http://127.0.0.1:8000/api/external/registration/batches",
255
+ headers={
256
+ "X-API-Key": "your_api_key",
257
+ "Content-Type": "application/json",
258
+ },
259
+ json=payload,
260
+ timeout=30,
261
+ )
262
+
263
+ print(resp.status_code)
264
+ print(resp.json())
265
+ ```
266
+
267
+ 适用场景:
268
+
269
+ - 你不关心固定某个邮箱服务
270
+ - 你不关心固定某个上传目标
271
+ - 只希望系统按当前可用配置自动兜底选择
272
+
273
+ ---
274
+
275
+ ## 附:`failure_category` 示例
276
+
277
+ `failure_category` 是给程序消费的稳定字段,允许值只有:
278
+
279
+ - `config`
280
+ - `business`
281
+ - `transient`
282
+ - `null`
283
+
284
+ ### 示例 1:未失败批次
285
+
286
+ ```json
287
+ {
288
+ "batch_uuid": "0f8fad5b-d9cb-469f-a165-70867728950e",
289
+ "status": "pending",
290
+ "requested_count": 3,
291
+ "completed_count": 0,
292
+ "success_count": 0,
293
+ "failed_count": 0,
294
+ "upload_success_count": 0,
295
+ "upload_failed_count": 0,
296
+ "failure_reason": null,
297
+ "failure_category": null,
298
+ "recent_errors": [],
299
+ "idempotent_replay": false
300
+ }
301
+ ```
302
+
303
+ ### 示例 2:可重试失败
304
+
305
+ ```json
306
+ {
307
+ "batch_uuid": "0f8fad5b-d9cb-469f-a165-70867728950e",
308
+ "status": "failed",
309
+ "requested_count": 3,
310
+ "completed_count": 1,
311
+ "success_count": 0,
312
+ "failed_count": 1,
313
+ "upload_success_count": 0,
314
+ "upload_failed_count": 0,
315
+ "failure_reason": "upstream timeout",
316
+ "failure_category": "transient",
317
+ "recent_errors": [
318
+ "provider timeout"
319
+ ],
320
+ "idempotent_replay": false
321
+ }
322
+ ```
323
+
324
+ ### 示例 3:配置问题失败
325
+
326
+ ```json
327
+ {
328
+ "batch_uuid": "0f8fad5b-d9cb-469f-a165-70867728950e",
329
+ "status": "failed",
330
+ "requested_count": 3,
331
+ "completed_count": 0,
332
+ "success_count": 0,
333
+ "failed_count": 0,
334
+ "upload_success_count": 0,
335
+ "upload_failed_count": 0,
336
+ "failure_reason": "upload service not configured",
337
+ "failure_category": "config",
338
+ "recent_errors": [
339
+ "upload provider sub2api has no configured service"
340
+ ],
341
+ "idempotent_replay": false
342
+ }
343
+ ```
344
+
345
+ ### 示例 4:cancel 响应
346
+
347
+ `POST /api/external/registration/batches/{batch_uuid}/cancel` 也可能返回 `failure_category`,只是为了和其他批次响应保持一致:
348
+
349
+ ```json
350
+ {
351
+ "batch_uuid": "0f8fad5b-d9cb-469f-a165-70867728950e",
352
+ "status": "cancelled",
353
+ "failure_reason": null,
354
+ "failure_category": null
355
+ }
356
+ ```
357
+
358
+ ---
359
+
360
+ ## 4. 查询批次状态
361
+
362
+ ### curl
363
+
364
+ ```bash
365
+ curl -X GET "http://127.0.0.1:8000/api/external/registration/batches/<batch_uuid>" \
366
+ -H "X-API-Key: your_api_key"
367
+ ```
368
+
369
+ ### Python
370
+
371
+ ```python
372
+ import requests
373
+ import time
374
+
375
+ batch_uuid = "replace-with-real-batch-uuid"
376
+
377
+ while True:
378
+ resp = requests.get(
379
+ f"http://127.0.0.1:8000/api/external/registration/batches/{batch_uuid}",
380
+ headers={"X-API-Key": "your_api_key"},
381
+ timeout=30,
382
+ )
383
+ data = resp.json()
384
+ print(data)
385
+
386
+ if data["status"] in {"completed", "completed_partial", "failed", "cancelled"}:
387
+ break
388
+
389
+ time.sleep(5)
390
+ ```
391
+
392
+ ---
393
+
394
+ ## 5. 取消批次
395
+
396
+ ### curl
397
+
398
+ ```bash
399
+ curl -X POST "http://127.0.0.1:8000/api/external/registration/batches/<batch_uuid>/cancel" \
400
+ -H "X-API-Key: your_api_key"
401
+ ```
402
+
403
+ ### Python
404
+
405
+ ```python
406
+ import requests
407
+
408
+ batch_uuid = "replace-with-real-batch-uuid"
409
+
410
+ resp = requests.post(
411
+ f"http://127.0.0.1:8000/api/external/registration/batches/{batch_uuid}/cancel",
412
+ headers={"X-API-Key": "your_api_key"},
413
+ timeout=30,
414
+ )
415
+
416
+ print(resp.status_code)
417
+ print(resp.json())
418
+ ```
419
+
420
+ ---
421
+
422
+ ## 6. 常见返回示例
423
+
424
+ ### 6.1 创建成功
425
+
426
+ ```json
427
+ {
428
+ "batch_uuid": "0f8fad5b-d9cb-469f-a165-70867728950e",
429
+ "status": "pending",
430
+ "requested_count": 3,
431
+ "completed_count": 0,
432
+ "success_count": 0,
433
+ "failed_count": 0,
434
+ "upload_success_count": 0,
435
+ "upload_failed_count": 0,
436
+ "failure_reason": null,
437
+ "created_at": "2026-03-27T12:00:00",
438
+ "started_at": null,
439
+ "completed_at": null,
440
+ "recent_errors": [],
441
+ "idempotent_replay": false
442
+ }
443
+ ```
444
+
445
+ ### 6.2 参数错误
446
+
447
+ ```json
448
+ {
449
+ "detail": [
450
+ {
451
+ "type": "value_error",
452
+ "loc": ["body"],
453
+ "msg": "Value error, upload.provider is required when upload.enabled is true",
454
+ "input": {}
455
+ }
456
+ ]
457
+ }
458
+ ```
459
+
460
+ ### 6.3 批次不存在
461
+
462
+ ```json
463
+ {
464
+ "detail": "batch_not_found"
465
+ }
466
+ ```
467
+
468
+ ---
469
+
470
+ ## 7. 最简 Python 封装示例
471
+
472
+ ```python
473
+ import time
474
+ import requests
475
+
476
+
477
+ class ExternalRegisterClient:
478
+ def __init__(self, base_url: str, api_key: str):
479
+ self.base_url = base_url.rstrip("/")
480
+ self.session = requests.Session()
481
+ self.session.headers.update({"X-API-Key": api_key})
482
+
483
+ def capabilities(self):
484
+ return self.session.get(f"{self.base_url}/external/capabilities", timeout=30).json()
485
+
486
+ def create_batch(self, payload: dict):
487
+ return self.session.post(
488
+ f"{self.base_url}/external/registration/batches",
489
+ json=payload,
490
+ timeout=30,
491
+ ).json()
492
+
493
+ def get_batch(self, batch_uuid: str):
494
+ return self.session.get(
495
+ f"{self.base_url}/external/registration/batches/{batch_uuid}",
496
+ timeout=30,
497
+ ).json()
498
+
499
+ def cancel_batch(self, batch_uuid: str):
500
+ return self.session.post(
501
+ f"{self.base_url}/external/registration/batches/{batch_uuid}/cancel",
502
+ timeout=30,
503
+ ).json()
504
+
505
+ def wait_batch(self, batch_uuid: str, interval: int = 5):
506
+ while True:
507
+ data = self.get_batch(batch_uuid)
508
+ if data["status"] in {"completed", "completed_partial", "failed", "cancelled"}:
509
+ return data
510
+ time.sleep(interval)
511
+
512
+
513
+ client = ExternalRegisterClient("http://127.0.0.1:8000/api", "your_api_key")
514
+
515
+ batch = client.create_batch({
516
+ "count": 2,
517
+ "email": {"type": "temp_mail"},
518
+ "upload": {"enabled": False},
519
+ "execution": {"mode": "pipeline", "concurrency": 1, "interval_min": 0, "interval_max": 0},
520
+ })
521
+
522
+ result = client.wait_batch(batch["batch_uuid"])
523
+ print(result)
524
+ ```
docs/external-api-quickstart.md ADDED
@@ -0,0 +1,309 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # External API 第三方对接速览
2
+
3
+ 如果你是第三方调用方,只需要记住下面这套最小流程。
4
+
5
+ 详细字段说明见:
6
+
7
+ - [Temp Mail 域名池与外部注册 API 说明](temp-mail-external-api.md)
8
+ - [External API 调用示例](external-api-examples.md)
9
+
10
+ ---
11
+
12
+ ## 1. 先查能力
13
+
14
+ 调用:
15
+
16
+ ```http
17
+ GET /api/external/capabilities
18
+ ```
19
+
20
+ 目的:
21
+
22
+ - 看当前支持哪些邮箱类型
23
+ - 看有哪些上传目标可用
24
+ - 看 `temp_mail` 是否有可用服务
25
+
26
+ 你要重点关注返回中的:
27
+
28
+ - `email_types[].type`
29
+ - `email_types[].services[].id`
30
+ - `email_types[].services[].name`
31
+ - `upload_providers[].provider`
32
+ - `upload_providers[].services[].id`
33
+ - `upload_providers[].services[].name`
34
+
35
+ 示例:
36
+
37
+ ```json
38
+ {
39
+ "email_types": [
40
+ {
41
+ "type": "temp_mail",
42
+ "available": true,
43
+ "services": [
44
+ {"id": 3, "name": "Worker temp mail A", "priority": 0},
45
+ {"id": 7, "name": "Worker temp mail B", "priority": 0}
46
+ ]
47
+ }
48
+ ],
49
+ "upload_providers": [
50
+ {
51
+ "provider": "sub2api",
52
+ "available": true,
53
+ "services": [
54
+ {"id": 1, "name": "sub2-main", "priority": 0},
55
+ {"id": 4, "name": "sub2-backup", "priority": 10}
56
+ ]
57
+ }
58
+ ]
59
+ }
60
+ ```
61
+
62
+ 这时:
63
+
64
+ - `email.service_id=3` 表示选 `Worker temp mail A`
65
+ - `email.service_id=7` 表示选 `Worker temp mail B`
66
+ - `upload.service_id=1` 表示选 `sub2-main`
67
+ - `upload.service_id=4` 表示选 `sub2-backup`
68
+
69
+ > 不是 0/1 猜出来的,而是以 `capabilities` 当前返回的 `id + name` 为准。
70
+
71
+ ---
72
+
73
+ ## 2. 再创建批次
74
+
75
+ 调用:
76
+
77
+ ```http
78
+ POST /api/external/registration/batches
79
+ ```
80
+
81
+ 你只需要决定 4 件事:
82
+
83
+ 1. 要注册多少个:`count`
84
+ 2. 用哪种邮箱:`email.type`
85
+ 3. 是否上传:`upload.enabled`
86
+ 4. 怎么执行:`execution.mode`
87
+
88
+ 最小示例:
89
+
90
+ ```json
91
+ {
92
+ "count": 10,
93
+ "email": {
94
+ "type": "temp_mail"
95
+ },
96
+ "upload": {
97
+ "enabled": false
98
+ },
99
+ "execution": {
100
+ "mode": "pipeline",
101
+ "concurrency": 1,
102
+ "interval_min": 0,
103
+ "interval_max": 0
104
+ }
105
+ }
106
+ ```
107
+
108
+ 如果你不传:
109
+
110
+ - `email.service_id`
111
+ - `upload.service_id`
112
+
113
+ 则系统会自动选择当前可用服务。
114
+
115
+ 如果你想固定某一个邮箱服务或上传目标,就必须先通过 `capabilities` 拿到对应 `id`。
116
+
117
+ ---
118
+
119
+ ## 3. 保存 `batch_uuid`
120
+
121
+ 创建成功后,响应里会返回:
122
+
123
+ ```json
124
+ {
125
+ "batch_uuid": "..."
126
+ }
127
+ ```
128
+
129
+ 后续所有查询和取消都依赖这个值。
130
+
131
+ ---
132
+
133
+ ## 4. 轮询状态
134
+
135
+ 调用:
136
+
137
+ ```http
138
+ GET /api/external/registration/batches/{batch_uuid}
139
+ ```
140
+
141
+ 你只需要关注:
142
+
143
+ - `status`
144
+ - `requested_count`
145
+ - `completed_count`
146
+ - `success_count`
147
+ - `failed_count`
148
+ - `upload_success_count`
149
+ - `upload_failed_count`
150
+ - `recent_errors`
151
+
152
+ 终态只有这几个:
153
+
154
+ - `completed`
155
+ - `completed_partial`
156
+ - `failed`
157
+ - `cancelled`
158
+
159
+ ---
160
+
161
+ ## 5. 如需终止就取消
162
+
163
+ 调用:
164
+
165
+ ```http
166
+ POST /api/external/registration/batches/{batch_uuid}/cancel
167
+ ```
168
+
169
+ 仅 `pending` / `running` 批次可以取消。
170
+
171
+ ---
172
+
173
+ ## 6. 推荐调用策略
174
+
175
+ ### 推荐 1:始终传 `idempotency_key`
176
+
177
+ 这样你的上游系统在重试创建请求时,不会重复创建同一批任务。
178
+
179
+ ### 推荐 2:先看 `capabilities`,再决定参数
180
+
181
+ 不要写死:
182
+
183
+ - 邮箱类型
184
+ - 上传 provider
185
+ - service_id
186
+
187
+ 否则后台一改配置,你的调用方就会直接打空。
188
+
189
+ 尤其不要假设:
190
+
191
+ - `1` 永远是某个邮箱
192
+ - `1` 永远是某个上传渠道
193
+
194
+ `service_id` 的意义完全取决于当前环境里的实际配置。
195
+
196
+ ### 推荐 3:把“成功标准”定义成批次维度
197
+
198
+ 不要要求“必须全部成功”。
199
+
200
+ 更合理的是读取:
201
+
202
+ - `success_count`
203
+ - `failed_count`
204
+
205
+ 再由你的系统决定是否接受这次结果。
206
+
207
+ ---
208
+
209
+ ## 7. 常见错误处理
210
+
211
+ | 状态码 | 含义 | 建议处理 |
212
+ | --- | --- | --- |
213
+ | `401` | API Key 错误 | 检查请求头 |
214
+ | `403` | 外部 API 未启用 | 联系后台启用 |
215
+ | `404` | 批次不存在 | 检查 `batch_uuid` |
216
+ | `422` | 请求体格式不合法 | 修正参数结构 |
217
+ | `400` | 业务参数非法 | 修正业务参数 |
218
+ | `503` | 服务暂时不可用 | 稍后重试 |
219
+
220
+ ---
221
+
222
+ ## 8. 第三方最小接入步骤
223
+
224
+ 建议接入顺序:
225
+
226
+ 1. 接入 `GET /capabilities`
227
+ 2. 接入 `POST /batches`
228
+ 3. 接入 `GET /batches/{batch_uuid}`
229
+ 4. 最后再接 `POST /cancel`
230
+
231
+ 这样最快能形成闭环。
232
+
233
+ ---
234
+
235
+ ## 9. 一句话理解
236
+
237
+ 外部调用方只负责:
238
+
239
+ - 下发批次
240
+ - 轮询状态
241
+ - 读取成功/失败统计
242
+
243
+ 系统内部负责:
244
+
245
+ - 选邮箱
246
+ - 真正执行注册
247
+ - 上传账号
248
+ - 失败回收与重启恢复
249
+
250
+ ---
251
+
252
+ ## 10. API Key 从哪里拿
253
+
254
+ 外部调用方使用的:
255
+
256
+ ```http
257
+ X-API-Key: 你的APIKey
258
+ ```
259
+
260
+ 这个值不是通过接口获取的,而是由系统管理员在本项目配置里设置后,直接发给对接方。
261
+
262
+ ---
263
+
264
+ ## 11. `failure_category` 字段
265
+
266
+ 批次响应现在会额外返回:
267
+
268
+ ```json
269
+ "failure_category": null
270
+ ```
271
+
272
+ 或:
273
+
274
+ ```json
275
+ "failure_category": "config"
276
+ ```
277
+
278
+ 允许值只有四种:
279
+
280
+ - `config`
281
+ - `business`
282
+ - `transient`
283
+ - `null`
284
+
285
+ 推荐按下面方式消费:
286
+
287
+ - `transient`:可按你的策略做退避重试
288
+ - `config`:不要自动重试,先人工检查配置
289
+ - `business`:不要自动重试,先人工检查业务约束
290
+ - `null`:当前批次还没有失败分类,通常表示批次未失败
291
+
292
+ 说明:
293
+
294
+ - `failure_reason` 继续保留,主要给人看
295
+ - `failure_category` 主要给程序判断
296
+ - `POST /api/external/registration/batches/{batch_uuid}/cancel` 的响应也可能带上该字段;这是为了和其他批次响应保持一致,不需要时可直接忽略
297
+
298
+ ### 返回规则
299
+
300
+ - `pending` / `running` / `completed` / `completed_partial` / `cancelled`
301
+ - `failure_category = null`
302
+ - `failed`
303
+ - `failure_category` 一定是 `config | business | transient` 之一
304
+
305
+ 也就是说:
306
+
307
+ - 对接方自己拿不到
308
+ - 必须由项目维护者提供
309
+ - 如果项目维护者修改了 Key,对接方也要同步更新请求头
docs/external-api-webui-management-plan.md ADDED
@@ -0,0 +1,413 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # External API 网站内管理与 API Key 生成方案
2
+
3
+ 本文档是**内部实现方案文档**,用于后续开发“在网站设置页直接启用/关闭 External API,并生成/管理 API Key”功能。
4
+
5
+ 这不是给第三方调用方看的文档。
6
+ 第三方对接请看:
7
+
8
+ - [第三方技术对接文档](third-party-integration-guide.md)
9
+ - [External API 调用示例](external-api-examples.md)
10
+ - [Temp Mail 域名池与外部注册 API 说明](temp-mail-external-api.md)
11
+
12
+ ---
13
+
14
+ ## 1. 目标
15
+
16
+ 在网站设置页中直接提供一块 **External API 管理区域**,让管理员可以:
17
+
18
+ - 启用 / 关闭 External API
19
+ - 查看当前是否已配置 API Key
20
+ - 一键生成新的 API Key
21
+ - 手动设置自定义 API Key
22
+ - 复制对接所需的 Base URL / Header / 文案
23
+
24
+ 目标结果是:
25
+
26
+ 1. 管理员不需要手动改数据库或配置文件
27
+ 2. 第三方对接信息可以直接从网站界面获得
28
+ 3. API Key 的展示与重置行为可控,不泄漏旧 Key
29
+
30
+ ---
31
+
32
+ ## 2. 当前基础
33
+
34
+ 项目当前已经具备以下底座能力:
35
+
36
+ ### 2.1 配置项已存在
37
+
38
+ 系统配置中已经有:
39
+
40
+ - `external_api_enabled`
41
+ - `external_api_key`
42
+
43
+ ### 2.2 外部鉴权已存在
44
+
45
+ 当前 External API 鉴权已经生效,固定使用:
46
+
47
+ ```http
48
+ X-API-Key: <api_key>
49
+ ```
50
+
51
+ 并读取 `external_api_enabled`、`external_api_key` 进行校验。
52
+
53
+ ### 2.3 设置页架构已存在
54
+
55
+ 当前设置页已经采用成熟的三层模式:
56
+
57
+ - `templates/settings.html`
58
+ - `static/js/settings.js`
59
+ - `src/web/routes/settings.py`
60
+
61
+ 因此本次不是重做设置系统,而是在现有模式上新增一个“外部 API”分区。
62
+
63
+ ---
64
+
65
+ ## 3. 页面设计
66
+
67
+ ### 3.1 新增标签页
68
+
69
+ 在设置页 tabs 中新增一个标签:
70
+
71
+ - `外部 API`
72
+
73
+ 该标签页内新增一张独立卡片,用于配置和展示 External API 状态。
74
+
75
+ ### 3.2 页面展示内容
76
+
77
+ 页面需要展示:
78
+
79
+ - `启用 External API` 开关
80
+ - 当前状态:
81
+ - 未启用
82
+ - 已启用但未配置 Key
83
+ - 已启用且已配置 Key
84
+ - Base URL
85
+ - Auth Header
86
+ - 当前 Key 状态(仅掩码)
87
+ - 操作按钮
88
+
89
+ ### 3.3 页面按钮
90
+
91
+ 页面提供以下按钮:
92
+
93
+ - `保存设置`
94
+ - `生成新 API Key`
95
+ - `复制当前 Key`
96
+ - `复制对接信息`
97
+
98
+ 说明:
99
+
100
+ - `复制当前 Key` 仅在“本次刚生成 / 本次刚手动保存新 Key”后可用
101
+ - 页面刷新后只保留掩码,不长期展示完整旧 Key
102
+
103
+ ### 3.4 页面固定文案
104
+
105
+ 页面上必须明确展示:
106
+
107
+ - `Base URL: ${location.origin}/api`
108
+ - `Auth Header: X-API-Key`
109
+ - “重新生成后旧 Key 立即失效”
110
+ - “第三方应先调用 /api/external/capabilities 获取 service_id 对应关系”
111
+
112
+ ---
113
+
114
+ ## 4. 后端接口设计
115
+
116
+ 在 `src/web/routes/settings.py` 中新增一组专用接口:
117
+
118
+ - `GET /api/settings/external-api`
119
+ - `POST /api/settings/external-api`
120
+ - `POST /api/settings/external-api/generate`
121
+
122
+ ### 4.1 GET /api/settings/external-api
123
+
124
+ 作用:
125
+
126
+ - 返回当前 External API 状态与展示信息
127
+
128
+ 返回字段:
129
+
130
+ ```json
131
+ {
132
+ "enabled": true,
133
+ "has_api_key": true,
134
+ "api_key_masked": "sk_live_****abcd",
135
+ "api_key_header": "X-API-Key",
136
+ "base_url": "http://example.com/api"
137
+ }
138
+ ```
139
+
140
+ 规则:
141
+
142
+ - 不返回完整旧 Key
143
+ - `base_url` 可后端返回,也可前端覆盖展示为 `${location.origin}/api`
144
+ - `api_key_header` 固定为 `X-API-Key`
145
+
146
+ ### 4.2 POST /api/settings/external-api
147
+
148
+ 作用:
149
+
150
+ - 保存启用状态
151
+ - 可选手动设置新的 API Key
152
+
153
+ 请求体:
154
+
155
+ ```json
156
+ {
157
+ "enabled": true,
158
+ "api_key": "custom_key_optional"
159
+ }
160
+ ```
161
+
162
+ 规则:
163
+
164
+ - `enabled` 必填
165
+ - `api_key` 可选
166
+ - 若 `api_key` 未传或为空字符串,则保持原值不变
167
+ - 若 `api_key` 有值,则覆盖旧 Key
168
+
169
+ 返回体:
170
+
171
+ ```json
172
+ {
173
+ "success": true,
174
+ "message": "External API settings updated",
175
+ "generated_api_key": "only_when_new_key_is_set",
176
+ "api_key_masked": "sk_live_****abcd"
177
+ }
178
+ ```
179
+
180
+ 规则:
181
+
182
+ - 只有本次确实设置了新 Key 时,才返回一次性明文
183
+ - 否则只返回掩码
184
+
185
+ ### 4.3 POST /api/settings/external-api/generate
186
+
187
+ 作用:
188
+
189
+ - 一键生成新 API Key
190
+
191
+ 生成策略固定为:
192
+
193
+ ```python
194
+ secrets.token_urlsafe(32)
195
+ ```
196
+
197
+ 返回体:
198
+
199
+ ```json
200
+ {
201
+ "success": true,
202
+ "message": "External API key regenerated",
203
+ "generated_api_key": "new_generated_key",
204
+ "api_key_masked": "new_****mask"
205
+ }
206
+ ```
207
+
208
+ 规则:
209
+
210
+ - 新 Key 生成后立即覆盖旧 Key
211
+ - 旧 Key 立即失效
212
+ - 完整明文只在本次响应里返回一次
213
+
214
+ ---
215
+
216
+ ## 5. 前端交互规则
217
+
218
+ 在 `static/js/settings.js` 中新增一组 External API 专用逻辑。
219
+
220
+ ### 5.1 页面加载
221
+
222
+ 设置页加载时:
223
+
224
+ 1. 请求 `GET /api/settings/external-api`
225
+ 2. 渲染启用状态
226
+ 3. 渲染 `has_api_key`
227
+ 4. 渲染掩码值
228
+ 5. 渲染 Base URL / Header
229
+
230
+ ### 5.2 保存行为
231
+
232
+ 点击“保存设置”时:
233
+
234
+ - 如果 Key 输入框为空:
235
+ - 仅保存 `enabled`
236
+ - 不修改现有 Key
237
+ - 如果 Key 输入框非空:
238
+ - 将该值作为新 Key ���存
239
+ - 成功后前端显示本次返回的明文 Key
240
+
241
+ ### 5.3 生成行为
242
+
243
+ 点击“生成新 API Key”时:
244
+
245
+ 1. 弹确认提示
246
+ 2. 调用 `POST /api/settings/external-api/generate`
247
+ 3. 成功后显示:
248
+ - 新生成的完整 Key
249
+ - 复制按钮
250
+ - “旧 Key 已失效”提示
251
+
252
+ ### 5.4 复制行为
253
+
254
+ #### 复制当前 Key
255
+
256
+ 仅当页面内存中存在“本次刚生成 / 刚保存”的完整 Key 时启用。
257
+
258
+ #### 复制对接信息
259
+
260
+ 自动拼接一段文本,包含:
261
+
262
+ - Base URL
263
+ - `X-API-Key`
264
+ - 4 个 external API 接口
265
+ - `service_id` 来自 capabilities 的说明
266
+
267
+ 建议复制内容固定为:
268
+
269
+ ```text
270
+ Base URL:
271
+ http://your-domain:8000/api
272
+
273
+ Auth Header:
274
+ X-API-Key: your_api_key
275
+
276
+ Available Endpoints:
277
+ GET /external/capabilities
278
+ POST /external/registration/batches
279
+ GET /external/registration/batches/{batch_uuid}
280
+ POST /external/registration/batches/{batch_uuid}/cancel
281
+
282
+ 注意:
283
+ service_id 不要猜,必须来自 GET /external/capabilities 返回的 id。
284
+ ```
285
+
286
+ ### 5.5 Base URL 显示规则
287
+
288
+ 前端展示统一使用:
289
+
290
+ ```js
291
+ `${location.origin}/api`
292
+ ```
293
+
294
+ 这样可以兼容:
295
+
296
+ - 直接 IP 访问
297
+ - 域名访问
298
+ - 反向代理
299
+
300
+ ---
301
+
302
+ ## 6. 安全策略
303
+
304
+ 本期实现固定采用以下策略:
305
+
306
+ ### 6.1 不返回完整旧 Key
307
+
308
+ - `GET /api/settings/external-api` 不允许返回完整旧 Key
309
+ - 页面刷新后只显示掩码
310
+
311
+ ### 6.2 一次性明文展示
312
+
313
+ 完整明文只允许在以下场景返回一次:
314
+
315
+ - 手动设置了新 Key
316
+ - 点击生成了新 Key
317
+
318
+ ### 6.3 重新生成立即失效
319
+
320
+ - 新 Key 生效后旧 Key 立即作废
321
+ - 不支持新旧 Key 并存
322
+
323
+ ### 6.4 本期不做项
324
+
325
+ 本期明确不做:
326
+
327
+ - 多 Key 并存
328
+ - Key 历史记录
329
+ - Key 过期时间
330
+ - Key 权限分级
331
+ - Key 审计日志
332
+ - 第三方自助申请 Key
333
+
334
+ ---
335
+
336
+ ## 7. 测试与验收
337
+
338
+ ### 7.1 后端测试
339
+
340
+ 至少覆盖以下场景:
341
+
342
+ - `GET /api/settings/external-api`
343
+ - 未启用、未配置 Key
344
+ - 已启用、已配置 Key
345
+ - `POST /api/settings/external-api`
346
+ - 仅更新 `enabled`
347
+ - 手动设置新 Key
348
+ - `POST /api/settings/external-api/generate`
349
+ - 成功生成新 Key
350
+ - 鉴权联动
351
+ - 新 Key 可用
352
+ - 旧 Key 失效
353
+ - External API 关闭后统一返回 `403`
354
+
355
+ ### 7.2 前端测试
356
+
357
+ 至少覆盖:
358
+
359
+ - 设置页新增“外部 API”标签
360
+ - 区块成功渲染
361
+ - 能展示 Base URL / Header / 掩码状态
362
+ - 能显示生成按钮、保存按钮、复制按钮
363
+
364
+ ### 7.3 集成验收
365
+
366
+ 手工验收闭环:
367
+
368
+ 1. 在设置页启用 External API
369
+ 2. 点击生成新 Key
370
+ 3. 用新 Key 调 `/api/external/capabilities` 成功
371
+ 4. 用旧 Key 调用返回 `401`
372
+ 5. 关闭 External API 后调用返回 `403`
373
+
374
+ ---
375
+
376
+ ## 8. 实施范围与不做项
377
+
378
+ ### 本次实施范围
379
+
380
+ - 网站设置页内管理 External API 开关
381
+ - 网站内生成 / 手动设置 API Key
382
+ - 网站内复制对接信息
383
+ - 与现有 External API 鉴权打通
384
+
385
+ ### 本次不做
386
+
387
+ - 对接方账号体系
388
+ - 多租户 API Key
389
+ - Key 权限模型
390
+ - 调用次数限流
391
+ - WebHook / 回调机制
392
+ - API 文档自动生成页面
393
+
394
+ ---
395
+
396
+ ## 9. 实施结论
397
+
398
+ 这是一个 **中低复杂度** 的增强项。
399
+
400
+ 原因:
401
+
402
+ - 配置项已经存在
403
+ - 鉴权已经存在
404
+ - 设置页框架已经存在
405
+ - 不需要数据库迁移
406
+
407
+ 因此实现重点不在底层,而在:
408
+
409
+ - 设置接口补全
410
+ - 设置页交互补全
411
+ - Key 展示与重置策略收口
412
+
413
+ 按当前代码结构,适合直接在现有 settings 模块内增量实现。
docs/http-only-device-fingerprint-hardening.md ADDED
@@ -0,0 +1,305 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # HTTP-Only 设备指纹补强建议
2
+
3
+ 本文档汇总三类信息后的统一结论,用于指导当前项目在**不引入真实浏览器兜底**前提下,继续补强 OpenAI 注册链路的稳定性:
4
+
5
+ 1. 当前项目现状(`register_198.py`、`src_23_extracted/*`)
6
+ 2. `F:\MyTools` 中可借鉴的实现
7
+ 3. 联网查到的公开资料、官方说明与公开逆向项目
8
+
9
+ ---
10
+
11
+ ## 1. 范围与目标
12
+
13
+ 本方案只讨论 **HTTP-only 注册链路**,不讨论:
14
+
15
+ - AdsPower / 外部浏览器
16
+ - Playwright / Selenium 真实浏览器兜底
17
+ - 完整 JS 浏览器指纹复刻
18
+
19
+ 目标是:
20
+
21
+ - 提高注册链路的一致性
22
+ - 降低 401 / challenge / callback 异常概率
23
+ - 在现有 `curl_cffi` + OpenAI 专用挑战框架上继续增强
24
+
25
+ ---
26
+
27
+ ## 2. 统一结论
28
+
29
+ ### 2.1 当前项目已经做对的部分
30
+
31
+ 当前项目已经具备以下基础能力:
32
+
33
+ - `curl_cffi impersonate` 的浏览器网络栈拟态
34
+ - 基础 Chrome 风格请求头
35
+ - 先获取 `oai-did`
36
+ - Sentinel PoW 求解
37
+ - `openai-sentinel-token`
38
+ - 部分 OAuth JSON 请求里的 `oai-device-id`
39
+ - 部分链路里的 `traceparent` / `x-datadog-*`
40
+ - 会话内尽量复用已有 `oai-did`
41
+
42
+ 结论:当前项目不是裸接口脚本,已经具备继续补强的基础。
43
+
44
+ ### 2.2 官方与公开资料给出的方向
45
+
46
+ - `curl_cffi` 适合做 **TLS / HTTP2 / JA3 / 浏览器网络层拟态**
47
+ - 但 `curl_cffi` **不能解决 JS 浏览器指纹**
48
+ - OpenAI 官方网络建议明确涉及:
49
+ - `auth.openai.com`
50
+ - `chatgpt.com`
51
+ - `challenges.cloudflare.com`
52
+ - `rum.browser-intake-datadoghq.com`
53
+ - 公开逆向项目显示,实际链路可能还会涉及:
54
+ - `chat-requirements`
55
+ - turnstile / challenge
56
+ - 前端行为 / 埋点一致性
57
+
58
+ 结论:既然不走真实浏览器,就必须把 **HTTP-only 的一致性** 做到尽量完整。
59
+
60
+ ### 2.3 `F:\MyTools` 最值得借鉴的点
61
+
62
+ `F:\MyTools` 中真正高价值的不是“更多随机”,而是:
63
+
64
+ 1. **整套浏览器指纹绑定为统一 profile**
65
+ - `impersonate`
66
+ - `User-Agent`
67
+ - `sec-ch-ua`
68
+ - `sec-ch-ua-full-version`
69
+ - platform/version 类字段
70
+
71
+ 2. **`oai-did` 同步到 `chatgpt.com` 与 `auth.openai.com`**
72
+
73
+ 3. **Sentinel 按 flow 生成**
74
+ - `authorize_continue`
75
+ - `password_verify`
76
+
77
+ 4. **Sentinel 请求本身补充 client hints**
78
+
79
+ 5. **OAuth 阶段附带更完整的前端语义参数**
80
+ - `ext-oai-did`
81
+ - `auth_session_logging_id`
82
+
83
+ 结论:最该借鉴的是 **一致性设计**,不是整包搬代码。
84
+
85
+ ---
86
+
87
+ ## 3. 应优先补什么
88
+
89
+ 以下按优先级排序。
90
+
91
+ ### P0:必须补
92
+
93
+ #### 3.1 统一 session 级浏览器 profile
94
+
95
+ 为每个注册任务固定一份浏览器 profile,至少统一以下字段:
96
+
97
+ - `impersonate`
98
+ - `User-Agent`
99
+ - `Accept-Language`
100
+ - `sec-ch-ua`
101
+ - `sec-ch-ua-mobile`
102
+ - `sec-ch-ua-platform`
103
+ - `sec-ch-ua-full-version`
104
+ - `sec-ch-ua-platform-version`
105
+
106
+ 要求:
107
+
108
+ - 同一次注册任务从头到尾固定
109
+ - 所有关键请求共用,不允许不同阶段各自拼装
110
+
111
+ 原因:
112
+
113
+ - 当前最大的风险之一不是“缺 header”,而是**同一会话像多个不同浏览器**
114
+
115
+ #### 3.2 `oai-did` 跨域同步
116
+
117
+ 原则:
118
+
119
+ - 优先使用服务端返回的 `oai-did`
120
+ - 将同一个 did 同步到:
121
+ - `chatgpt.com`
122
+ - `.auth.openai.com`
123
+ - `auth.openai.com`
124
+
125
+ 原因:
126
+
127
+ - 注册链路天然跨域
128
+ - did 只存在单一域时,容易出现链路不一致
129
+
130
+ #### 3.3 Sentinel 改为按 flow 生成
131
+
132
+ 至少区分以下 flow:
133
+
134
+ - `authorize_continue`
135
+ - `password_verify`
136
+
137
+ 必要时再扩展后续 flow。
138
+
139
+ 原因:
140
+
141
+ - `F:\MyTools` 与公开实现都表明,不同认证阶段不应简单复用同一类 sentinel 语义
142
+
143
+ #### 3.4 给 Sentinel 请求补 client hints
144
+
145
+ Sentinel 请求应与本次任务 profile 保持一致,至少补:
146
+
147
+ - `sec-ch-ua`
148
+ - `sec-ch-ua-mobile`
149
+ - `sec-ch-ua-platform`
150
+
151
+ 原因:
152
+
153
+ - 当前项目的 sentinel 请求仍偏简化
154
+ - 这一步最像“浏览器前端发出的挑战请求”
155
+
156
+ ---
157
+
158
+ ### P1:建议补
159
+
160
+ #### 3.5 统一 auth JSON 请求 header builder
161
+
162
+ 把以下内容统一注入到所有关键 auth JSON 请求:
163
+
164
+ - `oai-device-id`
165
+ - `traceparent`
166
+ - `tracestate`
167
+ - `x-datadog-*`
168
+
169
+ 不要只在部分接口补,建议统一覆盖:
170
+
171
+ - authorize / continue
172
+ - password verify
173
+ - workspace select
174
+ - organization select
175
+ - 其他关键 auth.openai.com JSON 接口
176
+
177
+ #### 3.6 灰度评估 `ext-oai-did` / `auth_session_logging_id`
178
+
179
+ 建议:
180
+
181
+ - 先做可开关的灰度支持
182
+ - 不作为硬依赖
183
+ - 通过日志观察引入后是否改善成功率
184
+
185
+ 原因:
186
+
187
+ - 这两个参数更像“前端语义拟真增强”
188
+ - 有价值,但存在随上游前端变动而漂移的风险
189
+
190
+ #### 3.7 降低“本地伪造 did”权重
191
+
192
+ 原则:
193
+
194
+ - 服务端 did 为主
195
+ - 本地生成 did 只做最后 fallback
196
+ - fallback 后整条链路也必须固定复用同一个 did
197
+
198
+ ---
199
+
200
+ ### P2:运维层同样重要
201
+
202
+ #### 3.8 把网络环境视为指纹系统的一部分
203
+
204
+ 建���:
205
+
206
+ - 避免 TLS 解密代理
207
+ - 优先稳定、干净的出口
208
+ - 同一次注册链路尽量固定一个代理出口
209
+ - 尽量避免频繁切换 IP
210
+
211
+ 原因:
212
+
213
+ - HTTP-only 模式下,网络环境本身就是风控输入
214
+
215
+ ---
216
+
217
+ ## 4. 当前不建议做什么
218
+
219
+ ### 4.1 不建议继续堆“随机”
220
+
221
+ 例如:
222
+
223
+ - 随机 UA 但不绑定 `sec-ch-*`
224
+ - 随机 Chrome 版本但 `impersonate` 不匹配
225
+ - 请求头在不同阶段各自手搓
226
+
227
+ 这些通常会让链路更不像一个真实浏览器。
228
+
229
+ ### 4.2 不建议整包照搬 `F:\MyTools` vendor 注册器
230
+
231
+ 原因:
232
+
233
+ - vendor 代码混有特定时期的 endpoint / 参数假设
234
+ - 当前项目已有自己修好的流程
235
+ - 直接搬迁容易回归
236
+
237
+ 正确方式:
238
+
239
+ - 抽取机制
240
+ - 按模块吸收
241
+
242
+ ### 4.3 不建议走半吊子的“浏览器仿真”
243
+
244
+ 既然本方案明确不引入真实浏览器,就不要把目标设成完整 JS 指纹复刻。
245
+
246
+ 正确目标应是:
247
+
248
+ - 最大化 HTTP-only 一致性
249
+ - 尽量贴近同一浏览器会话发出的请求
250
+
251
+ ---
252
+
253
+ ## 5. 推荐的落地顺序
254
+
255
+ 建议按以下顺序推进:
256
+
257
+ ### 第一批
258
+
259
+ 1. 统一 session 级浏览器 profile
260
+ 2. `oai-did` 跨域同步
261
+ 3. `password_verify` 增加 flow-specific sentinel
262
+ 4. sentinel 请求补 `sec-ch-*`
263
+
264
+ ### 第二批
265
+
266
+ 5. 统一 auth JSON 请求 header builder
267
+ 6. 扩大 `oai-device-id + trace/datadog` 覆盖范围
268
+ 7. 灰度引入 `ext-oai-did` / `auth_session_logging_id`
269
+
270
+ ### 第三批
271
+
272
+ 8. 结合线上日志做结果回归:
273
+ - 401 发生在哪一步
274
+ - callback 失败集中在哪一步
275
+ - did fallback 是否明显更容易失败
276
+ - 哪类代理出口成功率更高
277
+
278
+ ---
279
+
280
+ ## 6. 当前最推荐优先讨论的补齐项
281
+
282
+ 如果只选最值得先做的几项,我建议优先讨论:
283
+
284
+ 1. **统一 session 级浏览器 profile**
285
+ 2. **did 跨 `chatgpt.com / auth.openai.com` 同步**
286
+ 3. **`password_verify` 增加 flow-specific sentinel**
287
+ 4. **sentinel 请求补 client hints**
288
+ 5. **统一 auth JSON 请求 header builder**
289
+
290
+ ---
291
+
292
+ ## 7. 参考来源
293
+
294
+ - `curl_cffi` impersonate 文档
295
+ https://curl-cffi.readthedocs.io/en/v0.11.4/impersonate.html
296
+
297
+ - OpenAI 官方网络建议
298
+ https://help.openai.com/en/articles/9247338-network-recommendations-for-chatgpt-errors-on-web-and-apps
299
+
300
+ - 公开逆向参考
301
+ https://github.com/realasfngl/ChatGPT
302
+
303
+ - 本地对照项目
304
+ `F:\MyTools`
305
+
docs/register_simplification_analysis.md ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # register.py 精简分析报告
2
+
3
+ ## 概况
4
+
5
+ | 指标 | 数值 |
6
+ |------|------|
7
+ | 总行数 | 4282 |
8
+ | 方法总数 | 103 |
9
+ | 100+ 行巨型方法 | 8 个 |
10
+ | >50 行方法 | 15 个 |
11
+ | <10 行微型方法 | 12 个 |
12
+
13
+ ## 功能分区与行数占比
14
+
15
+ | 区域 | 行范围 | 行数 | 占比 | 说明 |
16
+ |------|--------|------|------|------|
17
+ | 核心注册流程 | L93-L556 | 464 | 11% | 初始化、OAuth、登录、密码提交 |
18
+ | **Token 交换** | L557-L827 | **271** | **6%** | `_complete_token_exchange` 一个方法 |
19
+ | 登录重启 | L828-L881 | 54 | 1% | `_restart_login_flow` |
20
+ | **Cookie/Session 处理** | L882-L978 | 97 | 2% | 提取 session_token、Cookie 工具 |
21
+ | **chatgpt.com 会话桥接** | L979-L1591 | **613** | **14%** | 会话捕获、重定向跟踪、桥接登录、Outlook 特化 |
22
+ | 密码注册 + 邮箱标记 | L1592-L1689 | 98 | 2% | |
23
+ | OTP 验证 + 日志 | L1690-L1873 | 184 | 4% | stage 管理、验证码获取、debug 日志 |
24
+ | **日志/调试工具** | L1874-L2136 | 263 | 6% | Cookie debug、账号创建、域名黑名单 |
25
+ | **导航/载荷解析** | L2137-L2659 | **523** | **12%** | JSON 解码、文本提取、URL 分类、导航候选 |
26
+ | **Consent/Script 资产检查** | L2659-L2956 | **298** | **7%** | Script 资产解析、embedded payload 提取 |
27
+ | **Workspace 选择** | L2957-L3368 | **412** | **10%** | workspace/org 获取、选择、callback 探测 |
28
+ | **恢复/重试路径** | L3369-L3940 | **572** | **13%** | consent 恢复、重定向跟踪、Outlook 恢复、会话内重登 |
29
+ | 主入口 + 保存 | L3941-L4282 | 342 | 8% | authorize replay、run、save_to_database |
30
+
31
+ ## 精简建议
32
+
33
+ ### 1. 导航/载荷解析提取为独立模块(省 ~520 行)
34
+
35
+ **区域**: L2137-L2659,共 **523 行、15 个方法**
36
+
37
+ 这些方法全部是纯工具函数,与 `RegistrationEngine` 状态无关:
38
+ - `_decode_auth_cookie_json_segments`、`_decode_cookie_json_payloads`
39
+ - `_collect_interesting_text_fragments_from_data`
40
+ - `_extract_app_router_push_payloads_from_text`
41
+ - `_extract_json_payload_candidates_from_fragment`
42
+ - `_sanitize_url_for_log`
43
+ - `_remember_resume_candidate`、`_remember_navigation_candidate` 等
44
+
45
+ > **操作**:提取到 `src/core/navigation_parser.py`,RegistrationEngine 调用即可。
46
+
47
+ ---
48
+
49
+ ### 2. Consent/Script 资产检查合并(省 ~100 行)
50
+
51
+ **区域**: L2659-L2956,共 **298 行**
52
+
53
+ 包含:
54
+ - `_inspect_consent_script_assets` (74行) — 下载并解析 JS 脚本
55
+ - `_extract_embedded_payloads_from_text` (47行)
56
+ - `_extract_navigation_candidates_from_text` (37行)
57
+ - `_normalize_response_text` (17行)
58
+ - 多个小型解析方法
59
+
60
+ > **操作**:与"导航解析"合并移到同一个工具模块中。
61
+
62
+ ---
63
+
64
+ ### 3. chatgpt.com 会话桥接提取为独立模块(省 ~600 行)
65
+
66
+ **区域**: L879-L1591,共 **613 行**
67
+
68
+ 标明 `# chatgpt.com 会话桥接方法 (从 dou-jiang/codex-console 移植)`。
69
+
70
+ 包含:
71
+ - `_capture_auth_session_tokens` (125行)
72
+ - `_follow_chatgpt_auth_redirects` (77行)
73
+ - `_bootstrap_chatgpt_signin_for_session` (119行)
74
+ - `_bridge_login_for_session_token` (180行)
75
+ - `_complete_token_exchange_outlook` (103行)
76
+
77
+ > **操作**:提取到 `src/core/chatgpt_bridge.py`,作为独立类或 mixin。
78
+
79
+ ---
80
+
81
+ ### 4. `_complete_token_exchange` 拆分(当前 271 行)
82
+
83
+ 这是最大的单个方法,包含多层嵌套的条件分支(about-you 处理、user_exists 检测、authorize replay、consent fallback 等)。
84
+
85
+ > **操作**:将 about-you 分支、user_exists 恢复逻辑各提取为子方法,可减 100+ 行。
86
+
87
+ ---
88
+
89
+ ### 5. 恢复/重试路径整理(省 ~200 行)
90
+
91
+ **区域**: L3369-L3940
92
+
93
+ - `_attempt_session_bound_reauth` (130行) — 已经很独立
94
+ - `_follow_redirects` (128行) — 通用重定向跟踪
95
+ - `_persist_recoverable_outlook_account` (74行) — Outlook 专用
96
+
97
+ > **操作**:`_follow_redirects` 是通用 HTTP 工具,可移到 `http_client.py`。Outlook 恢复逻辑可移到单独模块。
98
+
99
+ ---
100
+
101
+ ## 精简预估
102
+
103
+ | 策略 | 预估节省 | 操作复杂度 |
104
+ |------|----------|-----------|
105
+ | 导航/载荷解析 → `navigation_parser.py` | ~520 行 | 低(纯提取) |
106
+ | chatgpt 桥接 → `chatgpt_bridge.py` | ~600 行 | 中(需处理状态依赖) |
107
+ | Consent/Script → 合并到导航解析 | ~100 行 | 低 |
108
+ | `_complete_token_exchange` 拆分 | ~100 行 | 中 |
109
+ | `_follow_redirects` → `http_client.py` | ~130 行 | 低 |
110
+
111
+ **总计可精简约 1450 行**,register.py 预计从 4282 行降到 ~2800 行。
112
+
113
+ > [!IMPORTANT]
114
+ > 所有精简操作都是**代码搬迁和拆分**,不删除任何功能,只是让 register.py 更聚焦于核心注册编排逻辑。
docs/superpowers/plans/2026-03-27-external-failure-category.md ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # External Registration Failure Category Implementation Plan
2
+
3
+ > **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.
4
+
5
+ **Goal:** Add a stable `failure_category` field to external registration batch responses and persist it at batch level for SQLite-backed deployments.
6
+
7
+ **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.
8
+
9
+ **Tech Stack:** Python, FastAPI, Pydantic, SQLAlchemy, SQLite, pytest
10
+
11
+ ---
12
+
13
+ ## Chunk 1: Workspace and baseline
14
+
15
+ ### Task 1: Safe isolated workspace
16
+
17
+ **Files:**
18
+ - Create: `docs/superpowers/plans/2026-03-27-external-failure-category.md`
19
+
20
+ - [x] **Step 1: Create a dedicated worktree and branch**
21
+ - [x] **Step 2: Run baseline tests in the worktree**
22
+ - [x] **Step 3: Record the plan in this file before code changes**
23
+
24
+ ## Chunk 2: Tests first
25
+
26
+ ### Task 2: Route and documentation-facing tests
27
+
28
+ **Files:**
29
+ - Modify: `tests/test_external_api_routes.py`
30
+ - Modify: `docs/external-api-quickstart.md`
31
+ - Modify: `docs/external-api-examples.md`
32
+
33
+ - [x] **Step 1: Write failing route tests for `failure_category` presence on create/get/cancel/idempotent replay**
34
+ - [x] **Step 2: Run the route test subset and confirm the new assertions fail for the expected reason**
35
+ - [x] **Step 3: Update external API docs after implementation is stable**
36
+
37
+ ### Task 3: Service and migration tests
38
+
39
+ **Files:**
40
+ - Modify: `tests/test_external_batch_service.py`
41
+
42
+ - [x] **Step 1: Write failing tests for summary classification, recovery classification, unknown fallback, and non-failed null behavior**
43
+ - [x] **Step 2: Include a SQLite migration coverage test for adding the new column to an existing DB**
44
+ - [x] **Step 3: Run the service test subset and confirm the new assertions fail for the expected reason**
45
+
46
+ ## Chunk 3: Minimal production changes
47
+
48
+ ### Task 4: Persist and compute `failure_category`
49
+
50
+ **Files:**
51
+ - Modify: `src/database/models.py`
52
+ - Modify: `src/database/session.py`
53
+ - Modify: `src/core/external_batches/service.py`
54
+ - Modify: `src/core/external_batches/recovery.py`
55
+
56
+ - [x] **Step 1: Add `failure_category` to `ExternalRegistrationBatch`**
57
+ - [x] **Step 2: Add SQLite auto-migration support for the new column**
58
+ - [x] **Step 3: Add a single classification helper in the batch service**
59
+ - [x] **Step 4: Update summary recomputation to set category on `failed` and clear it otherwise**
60
+ - [x] **Step 5: Update recovery logic to persist `transient` for `service_restarted`**
61
+ - [x] **Step 6: Update the shared serializer to return `failure_category`**
62
+
63
+ ## Chunk 4: Verification and review closure
64
+
65
+ ### Task 5: Verification
66
+
67
+ **Files:**
68
+ - Verify: `tests/test_external_api_routes.py`
69
+ - Verify: `tests/test_external_batch_service.py`
70
+
71
+ - [x] **Step 1: Run focused tests until green**
72
+ - [x] **Step 2: Run the combined external test suite**
73
+ - [x] **Step 3: Inspect diffs and confirm only intended files changed**
74
+
75
+ ### Task 6: Review loop
76
+
77
+ **Files:**
78
+ - Review: all changed files
79
+
80
+ - [x] **Step 1: Run spec-compliance review**
81
+ - [x] **Step 2: Fix any findings and re-run spec review until zero issues**
82
+ - [x] **Step 3: Run code-quality review**
83
+ - [x] **Step 4: Fix any findings and re-run quality review until zero issues**
84
+ - [x] **Step 5: Run final whole-change review and close only when zero issues remain**
85
+
86
+ ## Locked behavior
87
+
88
+ - Batch response adds `failure_category: "config" | "business" | "transient" | null`
89
+ - `POST /api/external/registration/batches`, `GET /api/external/registration/batches/{batch_uuid}`, and `POST /api/external/registration/batches/{batch_uuid}/cancel` all return it
90
+ - Non-`failed` statuses always serialize `failure_category = null`
91
+ - `failed` batches must serialize one of `config | business | transient`
92
+ - Idempotent replay returns the originally persisted category
93
+ - Classification mapping:
94
+ - `service_restarted` -> `transient`
95
+ - upload/email service missing, disabled, wrong type/provider, unsupported provider, no enabled services -> `config`
96
+ - `outlook requested_service_id cannot be reused when count > 1` -> `business`
97
+ - `no_available_email_service` -> `transient`
98
+ - `registration_failed` and unknown reasons -> `transient`
docs/temp-mail-external-api.md ADDED
@@ -0,0 +1,416 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Temp Mail 域名池与外部注册 API 说明
2
+
3
+ 本文档说明本项目最近新增/补强的两部分能力:
4
+
5
+ 1. `temp_mail` 邮箱服务支持域名池与 Worker 域名 API
6
+ 2. 外部调用方可通过 HTTP API 创建注册批次、查询状态、取消任务
7
+
8
+ 配套文档:
9
+
10
+ - [External API 调用示例](external-api-examples.md)
11
+ - [External API 第三方对接速览](external-api-quickstart.md)
12
+ - [External API 网站内管理与 API Key 生成方案](external-api-webui-management-plan.md)
13
+
14
+ ---
15
+
16
+ ## 1. Temp Mail 域名池
17
+
18
+ ### 1.1 适用场景
19
+
20
+ 项目内新增了 `temp_mail` 邮箱类型,用于接入你自建的临时邮箱服务,例如:
21
+
22
+ - `https://apmail.889110.xyz/`
23
+
24
+ 服务端支持通过管理接口返回当前可用域名池,例如:
25
+
26
+ - `GET /admin/domains`
27
+
28
+ 请求头:
29
+
30
+ - `x-admin-auth: <admin_password>`
31
+
32
+ ### 1.2 支持的配置项
33
+
34
+ `temp_mail` 服务配置支持以下字段:
35
+
36
+ | 字段 | 必填 | 说明 |
37
+ | --- | --- | --- |
38
+ | `base_url` | 是 | 临时邮箱服务地址,如 `https://apmail.889110.xyz` |
39
+ | `admin_password` | 是 | 管理接口密码,对应请求头 `x-admin-auth` |
40
+ | `domains` | 否 | 域名池,数组或逗号分隔字符串,优先级最高 |
41
+ | `domain` | 否 | 单个回退域名 |
42
+ | `enable_prefix` | 否 | 是否保留旧版 `tmp` 前缀,默认 `false` |
43
+ | `timeout` | 否 | 请求超时,默认 `30` |
44
+ | `max_retries` | 否 | 请求重试次数,默认 `3` |
45
+
46
+ ### 1.3 域名解析优先级
47
+
48
+ 创建邮箱时,域名来源按以下优先级解析:
49
+
50
+ 1. `config.domains`
51
+ 2. Worker `GET /admin/domains`
52
+ 3. `config.domain`
53
+
54
+ 如果三者都拿不到可用域名,则创建邮箱失败。
55
+
56
+ ### 1.4 随机邮箱生成规则
57
+
58
+ - 默认生成**纯随机**本地名,不再强制 `tmp` 前缀
59
+ - 如果配置 `enable_prefix=true`,则继续使用旧版 `tmpxxxxx` 风格
60
+ - 最终会从当前可用域名池中随机选择一个域名拼接地址
61
+
62
+ ### 1.5 可用服务展示
63
+
64
+ 以下两个接口会展示 `temp_mail` 的域名摘要:
65
+
66
+ - Web UI: `GET /api/registration/available-services`
67
+ - 外部 API: `GET /api/external/capabilities`
68
+
69
+ 返回中会包含:
70
+
71
+ - `domain_count`
72
+ - `domains_preview`
73
+ - `domain_source`
74
+
75
+ 其中 `domain_source` 可能为:
76
+
77
+ - `config_domains`
78
+ - `worker_api`
79
+ - `config_fallback`
80
+ - `none`
81
+
82
+ > 注意:接口只暴露安全摘要,不会把 `admin_password` 等敏感信息返回给前端或外部调用方。
83
+
84
+ ---
85
+
86
+ ## 2. 外部注册 API
87
+
88
+ ### 2.1 功能概览
89
+
90
+ 外部 API 允许调用方不直接操作 Web UI,而是通过 HTTP 请求:
91
+
92
+ - 查询当前可用邮箱类型与上传目标
93
+ - 创建异步注册批次
94
+ - 查询批次状态
95
+ - 取消批次
96
+
97
+ ### 2.2 鉴权方式
98
+
99
+ 需要在系统设置中启用:
100
+
101
+ - `external_api_enabled`
102
+ - `external_api_key`
103
+
104
+ 请求头使用:
105
+
106
+ ```http
107
+ X-API-Key: <your_api_key>
108
+ ```
109
+
110
+ 未启用时返回 `403`;密钥错误时返回 `401`。
111
+
112
+ ### 2.2.1 API Key 从哪里来
113
+
114
+ 当前项目里,外部 API Key 不是“调用方自己申请”的,也不是接口动态下发的。
115
+
116
+ 它来自系统管理员在项目配置中设置的:
117
+
118
+ - `external_api_enabled`
119
+ - `external_api_key`
120
+
121
+ 也就是说:
122
+
123
+ 1. 管理员先在系统里启用外部 API
124
+ 2. 管理员在系统里填写一个 `external_api_key`
125
+ 3. 再把这个值发给第三方调用方
126
+
127
+ 第三方真正拿到的就是这个管理员配置的值,然后放到:
128
+
129
+ ```http
130
+ X-API-Key: 你拿到的那个值
131
+ ```
132
+
133
+ > 当前版本没有单独的“获取 API Key / 轮换 API Key”开放接口。
134
+ > 如果要换 Key,需要由系统管理员修改配置后,再重新发给对接方。
135
+
136
+ ### 2.3 能力查询
137
+
138
+ 接口:
139
+
140
+ ```http
141
+ GET /api/external/capabilities
142
+ ```
143
+
144
+ 用于返回:
145
+
146
+ - 支持的邮箱类型
147
+ - 当前可用邮箱服务
148
+ - 当前可用上传目标
149
+ - 外部 API 是否启用
150
+
151
+ 其中邮箱类型包含数据库内启用的:
152
+
153
+ - `outlook` / `moe_mail` / `temp_mail` / `duck_mail` / `freemail` / `imap_mail`
154
+
155
+ 其中临时邮箱能力在外部 API 中统一使用 `temp_mail` 枚举,不再暴露旧的 `tempmail` 占位。
156
+
157
+ 能力接口返回的每个可选服务,都会带:
158
+
159
+ - `id`
160
+ - `name`
161
+ - `priority`
162
+
163
+ 对于 `temp_mail`,还会带:
164
+
165
+ - `domain_count`
166
+ - `domains_preview`
167
+ - `domain_source`
168
+
169
+ ### 2.3.1 service_id 不是 0/1 猜出来的
170
+
171
+ 第三方在创建批次时看到的:
172
+
173
+ - `email.service_id`
174
+ - `upload.service_id`
175
+
176
+ 都不是让你自己猜 “0 代表哪个、1 代表哪个”。
177
+
178
+ 正确方式是:
179
+
180
+ 1. 先调 `GET /api/external/capabilities`
181
+ 2. 读取返回里的服务列表
182
+ 3. 按 `name` 找到你想用的服务
183
+ 4. 把该服务对应的 `id` 填回创建请求里的 `service_id`
184
+
185
+ 也就是说,真正的映射关系来自 `capabilities` 返回值,而不是文档里写死。
186
+
187
+ 例如能力返回里可能是:
188
+
189
+ ```json
190
+ {
191
+ "email_types": [
192
+ {
193
+ "type": "temp_mail",
194
+ "services": [
195
+ {"id": 3, "name": "Worker temp mail A"},
196
+ {"id": 7, "name": "Worker temp mail B"}
197
+ ]
198
+ }
199
+ ],
200
+ "upload_providers": [
201
+ {
202
+ "provider": "sub2api",
203
+ "services": [
204
+ {"id": 1, "name": "sub2-main"},
205
+ {"id": 4, "name": "sub2-backup"}
206
+ ]
207
+ }
208
+ ]
209
+ }
210
+ ```
211
+
212
+ 那你在创建批次时:
213
+
214
+ - 传 `email.service_id=3`,表示使用 `Worker temp mail A`
215
+ - 传 `upload.service_id=4`,表示上传到 `sub2-backup`
216
+
217
+ > 结论:`service_id` 只认 `capabilities` 当前返回的 `id`,不要手写猜测,也不要假设某个数字永远对应某个服务。
218
+
219
+ ### 2.4 创建注册批次
220
+
221
+ 接口:
222
+
223
+ ```http
224
+ POST /api/external/registration/batches
225
+ ```
226
+
227
+ 请求体示例:
228
+
229
+ ```json
230
+ {
231
+ "count": 5,
232
+ "idempotency_key": "demo-batch-001",
233
+ "email": {
234
+ "type": "temp_mail",
235
+ "service_id": 1
236
+ },
237
+ "upload": {
238
+ "enabled": true,
239
+ "provider": "sub2api",
240
+ "service_id": 1
241
+ },
242
+ "execution": {
243
+ "mode": "pipeline",
244
+ "concurrency": 1,
245
+ "interval_min": 5,
246
+ "interval_max": 15
247
+ }
248
+ }
249
+ ```
250
+
251
+ 字段约束:
252
+
253
+ - `count`: `1 ~ 100`
254
+ - `email.type`: 必填
255
+ - `email.service_id`: 可选
256
+ - `upload.provider`: 仅在 `upload.enabled=true` 时必填
257
+ - `upload.provider`: 仅支持 `sub2api` / `cpa` / `tm`
258
+ - `execution.mode`: `pipeline` 或 `parallel`
259
+ - `execution.concurrency`: `1 ~ 50`
260
+ - `execution.interval_max >= execution.interval_min`
261
+
262
+ 关于 `service_id` 的默认行为:
263
+
264
+ - `email.service_id` 不传:系统按该邮箱类型的当前可用规则自动选
265
+ - `upload.service_id` 不传:系统按该上传 provider 当前启用且优先级最高的服务自动选
266
+
267
+ 返回规则:
268
+
269
+ - 首次创建成功:HTTP `202`
270
+ - 如果 `idempotency_key` 命中历史批次:HTTP `200`
271
+
272
+ ### 2.5 查询批次状态
273
+
274
+ 接口:
275
+
276
+ ```http
277
+ GET /api/external/registration/batches/{batch_uuid}
278
+ ```
279
+
280
+ 返回字段包含:
281
+
282
+ - `batch_uuid`
283
+ - `status`
284
+ - `requested_count`
285
+ - `completed_count`
286
+ - `success_count`
287
+ - `failed_count`
288
+ - `upload_success_count`
289
+ - `upload_failed_count`
290
+ - `failure_reason`
291
+ - `recent_errors`
292
+
293
+ 状态可能包括:
294
+
295
+ - `pending`
296
+ - `running`
297
+ - `completed`
298
+ - `completed_partial`
299
+ - `failed`
300
+ - `cancelled`
301
+
302
+ ### 2.6 取消批次
303
+
304
+ 接口:
305
+
306
+ ```http
307
+ POST /api/external/registration/batches/{batch_uuid}/cancel
308
+ ```
309
+
310
+ 规则:
311
+
312
+ - `pending` / `running` 可取消
313
+ - 已结束批次不可重复取消,返回 `400`
314
+ - 批次不存在返回 `404`
315
+
316
+ ### 2.7 错误码约定
317
+
318
+ | 场景 | 状态码 |
319
+ | --- | --- |
320
+ | API 未启用 | `403` |
321
+ | API Key 错误 | `401` |
322
+ | 请求体验证失败 | `422` |
323
+ | 批次不存在 | `404` |
324
+ | 业务参数不合法 | `400` |
325
+ | 外部批次服务不可用 | `503` |
326
+
327
+ ---
328
+
329
+ ## 3. 外部批次执行行为
330
+
331
+ ### 3.1 持久化
332
+
333
+ 外部批次及其子项会持久化到数据库:
334
+
335
+ - `external_registration_batches`
336
+ - `external_registration_batch_items`
337
+
338
+ 同时为每个子项生成独立 `registration_task_uuid`,便于复用现有注册执行链路。
339
+
340
+ ### 3.2 幂等
341
+
342
+ 如果创建批次时传入 `idempotency_key`,系统会优先复用已有批次,避免重复创建。
343
+
344
+ ### 3.3 重启恢复
345
+
346
+ 服务启动时会尝试恢复中断批次:
347
+
348
+ - `pending` / `running` 的外部批次会被标记为失败
349
+ - 失败原因记为 `service_restarted`
350
+
351
+ 这样可以避免系统重启后外部调用方看到“永远卡在运行中”的脏状态。
352
+
353
+ ### 3.4 上传行为
354
+
355
+ 如果启用了上传:
356
+
357
+ - 注册成功后才会执行上传
358
+ - 会优先按注册结果里的 `email` 查账号
359
+ - 如找不到,再按 `account_id` 回退查找
360
+ - 上传结果会分别统计成功数和失败数
361
+
362
+ ---
363
+
364
+ ## 4. 当前测试覆盖
365
+
366
+ 本轮功能已补充并验证以下覆盖:
367
+
368
+ - `temp_mail` 本地名生成与域名随机选择
369
+ - `temp_mail` 域名解析优先级与回退逻辑
370
+ - Worker 域名 API 集成路径
371
+ - `available-services` / `capabilities` 的域名摘要展示
372
+ - 外部 API 鉴权、参数校验、错误码映射
373
+ - 外部批次创建、幂等、状态汇总、取消、重启恢复
374
+ - 外部批次执行中的预失败项、上传成功/失败、`account_id` 回退查找
375
+ - 服务选择的拒绝分支与边界约束
376
+
377
+ 全量测试验证结果:
378
+
379
+ - `134 passed`
380
+
381
+ ---
382
+
383
+ ## 5. 使用建议
384
+
385
+ ### 5.1 如果你想让 `temp_mail` 自动从多个域名中随机取
386
+
387
+ 建议只配置:
388
+
389
+ - `base_url`
390
+ - `admin_password`
391
+ - `domain`(仅作回退)
392
+
393
+ 然后由 Worker `GET /admin/domains` 统一返回真实域名池。
394
+
395
+ ### 5.2 如果你想固定只用部分域名
396
+
397
+ 直接在该邮箱服务配置中填写:
398
+
399
+ ```json
400
+ {
401
+ "domains": ["a.example.com", "b.example.com"]
402
+ }
403
+ ```
404
+
405
+ 此时系统会优先使用配置内域名池,而不会再请求 Worker 域名接口。
406
+
407
+ ### 5.3 如果你要给第三方系统对接
408
+
409
+ 优先使用:
410
+
411
+ - `GET /api/external/capabilities`
412
+ - `POST /api/external/registration/batches`
413
+ - `GET /api/external/registration/batches/{batch_uuid}`
414
+ - `POST /api/external/registration/batches/{batch_uuid}/cancel`
415
+
416
+ 这样调用方只负责“下发任务”和“查结果”,实际邮箱、上传、执行模式等策略仍由系统内配置统一兜底。
docs/third-party-integration-guide.md ADDED
@@ -0,0 +1,455 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 第三方技术对接文档
2
+
3
+ 这份文档是给第三方技术人员直接对接用的。
4
+ 按本文档接入即可完成:
5
+
6
+ - 查询当前可用邮箱/上传服务
7
+ - 创建注册批次
8
+ - 查询批次状态
9
+ - 取消批次
10
+
11
+ 如需更详细字段说明,可再参考:
12
+
13
+ - [Temp Mail 域名池与外部注册 API 说明](temp-mail-external-api.md)
14
+ - [External API 调用示例](external-api-examples.md)
15
+ - [External API 网站内管理与 API Key 生成方案](external-api-webui-management-plan.md)
16
+
17
+ ---
18
+
19
+ ## 1. 对接方会拿到什么
20
+
21
+ 项目方需要提供给第三方以下信息:
22
+
23
+ ### 1.1 Base URL
24
+
25
+ 例如:
26
+
27
+ ```text
28
+ http://your-domain:8000/api
29
+ ```
30
+
31
+ ### 1.2 鉴权请求头
32
+
33
+ ```http
34
+ X-API-Key: your_api_key
35
+ ```
36
+
37
+ ### 1.3 API Key 从哪里来
38
+
39
+ 这个 `your_api_key` 不是第三方自己申请的,也不是通过接口获取的。
40
+
41
+ 它来自项目管理员在系统中配置的:
42
+
43
+ - `external_api_enabled`
44
+ - `external_api_key`
45
+
46
+ 然后由项目方把这个 Key 发给第三方使用。
47
+
48
+ > 当前版本没有开放“获取 API Key / 轮换 API Key”接口。
49
+ > 如果更换了 Key,项目方需要重新通知第三方同步更新。
50
+
51
+ ---
52
+
53
+ ## 2. 对接流程总览
54
+
55
+ 第三方只需要实现这 4 个接口:
56
+
57
+ 1. `GET /external/capabilities`
58
+ 2. `POST /external/registration/batches`
59
+ 3. `GET /external/registration/batches/{batch_uuid}`
60
+ 4. `POST /external/registration/batches/{batch_uuid}/cancel`
61
+
62
+ 推荐接入顺序:
63
+
64
+ 1. 先接 `capabilities`
65
+ 2. 再接 `create batch`
66
+ 3. 再接 `get batch status`
67
+ 4. 最后接 `cancel batch`
68
+
69
+ ---
70
+
71
+ ## 3. 查询当前可用能力
72
+
73
+ ### 3.1 请求
74
+
75
+ ```http
76
+ GET /api/external/capabilities
77
+ X-API-Key: your_api_key
78
+ ```
79
+
80
+ ### 3.2 作用
81
+
82
+ 这个接口用于告诉第三方当前环境里:
83
+
84
+ - 支持哪些邮箱类型
85
+ - 各邮箱类型下有哪些具体服务
86
+ - 支持哪些上传 provider
87
+ - 每个 provider 下有哪些具体服务
88
+
89
+ ### 3.3 典型返回示例
90
+
91
+ ```json
92
+ {
93
+ "email_types": [
94
+ {
95
+ "type": "temp_mail",
96
+ "available": true,
97
+ "count": 2,
98
+ "services": [
99
+ {
100
+ "id": 3,
101
+ "name": "Worker temp mail A",
102
+ "type": "temp_mail",
103
+ "priority": 0,
104
+ "domain_count": 2,
105
+ "domains_preview": ["a.example.com", "b.example.com"],
106
+ "domain_source": "worker_api"
107
+ },
108
+ {
109
+ "id": 7,
110
+ "name": "Worker temp mail B",
111
+ "type": "temp_mail",
112
+ "priority": 0,
113
+ "domain_count": 1,
114
+ "domains_preview": ["c.example.com"],
115
+ "domain_source": "config_domains"
116
+ }
117
+ ]
118
+ }
119
+ ],
120
+ "upload_providers": [
121
+ {
122
+ "provider": "sub2api",
123
+ "available": true,
124
+ "count": 2,
125
+ "services": [
126
+ {"id": 1, "name": "sub2-main", "priority": 0},
127
+ {"id": 4, "name": "sub2-backup", "priority": 10}
128
+ ]
129
+ },
130
+ {
131
+ "provider": "cpa",
132
+ "available": true,
133
+ "count": 1,
134
+ "services": [
135
+ {"id": 2, "name": "cpa-main", "priority": 0}
136
+ ]
137
+ }
138
+ ],
139
+ "settings": {
140
+ "external_api_enabled": true
141
+ }
142
+ }
143
+ ```
144
+
145
+ ---
146
+
147
+ ## 4. `service_id` 怎么识别
148
+
149
+ 这是第三方最容易误解的地方。
150
+
151
+ ### 4.1 不是靠猜 0 / 1 / 2
152
+
153
+ `service_id` 不是固定编号,也不是文档里写死的顺序编号。
154
+ 它必须以当前环境里 `capabilities` 返回的实际 `id` 为准。
155
+
156
+ 例如上面的返回里:
157
+
158
+ - `email.service_id = 3` 表示 `Worker temp mail A`
159
+ - `email.service_id = 7` 表示 `Worker temp mail B`
160
+ - `upload.service_id = 1` 表示 `sub2-main`
161
+ - `upload.service_id = 4` 表示 `sub2-backup`
162
+
163
+ ### 4.2 正确使用方式
164
+
165
+ 第三方应始终:
166
+
167
+ 1. 先请求 `GET /external/capabilities`
168
+ 2. 按 `type/provider + name` 找到目标服务
169
+ 3. 读取对应的 `id`
170
+ 4. 创建批次时把该 `id` 传给 `service_id`
171
+
172
+ ### 4.3 不传 `service_id` 会怎样
173
+
174
+ 如果不传 `service_id`:
175
+
176
+ - `email.service_id` 不传:系统自动选该邮箱类型当前可用服务
177
+ - `upload.service_id` 不传:系统自动选该上传 provider 当前启用且优先级最高的服务
178
+
179
+ 如果第三方不需要固定某个具体服务,建议直接不传。
180
+
181
+ ---
182
+
183
+ ## 5. 创建注册批次
184
+
185
+ ### 5.1 请求
186
+
187
+ ```http
188
+ POST /api/external/registration/batches
189
+ Content-Type: application/json
190
+ X-API-Key: your_api_key
191
+ ```
192
+
193
+ ### 5.2 请求体示例:最简版本
194
+
195
+ ```json
196
+ {
197
+ "count": 10,
198
+ "email": {
199
+ "type": "temp_mail"
200
+ },
201
+ "upload": {
202
+ "enabled": false
203
+ },
204
+ "execution": {
205
+ "mode": "pipeline",
206
+ "concurrency": 1,
207
+ "interval_min": 0,
208
+ "interval_max": 0
209
+ }
210
+ }
211
+ ```
212
+
213
+ ### 5.3 请求体示例:指定邮箱服务并上传
214
+
215
+ ```json
216
+ {
217
+ "count": 20,
218
+ "idempotency_key": "batch-20260327-001",
219
+ "email": {
220
+ "type": "temp_mail",
221
+ "service_id": 3
222
+ },
223
+ "upload": {
224
+ "enabled": true,
225
+ "provider": "sub2api",
226
+ "service_id": 1
227
+ },
228
+ "execution": {
229
+ "mode": "parallel",
230
+ "concurrency": 5,
231
+ "interval_min": 0,
232
+ "interval_max": 0
233
+ }
234
+ }
235
+ ```
236
+
237
+ ### 5.4 字段说明
238
+
239
+ | 字段 | 必填 | 说明 |
240
+ | --- | --- | --- |
241
+ | `count` | 是 | 注册数量,范围 `1 ~ 100` |
242
+ | `idempotency_key` | 否 | 幂等键,建议传 |
243
+ | `email.type` | 是 | 邮箱类型,如 `temp_mail` / `outlook` |
244
+ | `email.service_id` | 否 | 指定某个邮箱服务,来源于 `capabilities` |
245
+ | `upload.enabled` | 是 | 是否上传 |
246
+ | `upload.provider` | 条件必填 | `upload.enabled=true` 时必填,支持 `sub2api` / `cpa` / `tm` |
247
+ | `upload.service_id` | 否 | 指定某个上传服务,来源于 `capabilities` |
248
+ | `execution.mode` | 否 | `pipeline` 或 `parallel` |
249
+ | `execution.concurrency` | 否 | 并发数,范围 `1 ~ 50` |
250
+ | `execution.interval_min` | 否 | 最小间隔秒数 |
251
+ | `execution.interval_max` | 否 | 最大间隔秒数,必须 `>= interval_min` |
252
+
253
+ ### 5.5 返回说明
254
+
255
+ - 首次成功创建:HTTP `202`
256
+ - 如果 `idempotency_key` 命中已有批次:HTTP `200`
257
+
258
+ 返回里会带:
259
+
260
+ ```json
261
+ {
262
+ "batch_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
263
+ "status": "pending",
264
+ "requested_count": 20,
265
+ "completed_count": 0,
266
+ "success_count": 0,
267
+ "failed_count": 0,
268
+ "upload_success_count": 0,
269
+ "upload_failed_count": 0,
270
+ "failure_reason": null,
271
+ "created_at": "2026-03-27T12:00:00",
272
+ "started_at": null,
273
+ "completed_at": null,
274
+ "recent_errors": [],
275
+ "idempotent_replay": false
276
+ }
277
+ ```
278
+
279
+ 第三方需要保存:
280
+
281
+ - `batch_uuid`
282
+
283
+ 后续查询和取消都要用它。
284
+
285
+ ---
286
+
287
+ ## 6. 查询批次状态
288
+
289
+ ### 6.1 请求
290
+
291
+ ```http
292
+ GET /api/external/registration/batches/{batch_uuid}
293
+ X-API-Key: your_api_key
294
+ ```
295
+
296
+ ### 6.2 第三方重点关注字段
297
+
298
+ - `status`
299
+ - `requested_count`
300
+ - `completed_count`
301
+ - `success_count`
302
+ - `failed_count`
303
+ - `upload_success_count`
304
+ - `upload_failed_count`
305
+ - `failure_reason`
306
+ - `recent_errors`
307
+
308
+ ### 6.3 终态定义
309
+
310
+ 以下状态视为任务结束:
311
+
312
+ - `completed`
313
+ - `completed_partial`
314
+ - `failed`
315
+ - `cancelled`
316
+
317
+ 建议第三方以这几个状态作为轮询终止条件。
318
+
319
+ ### 6.4 推荐理解方式
320
+
321
+ 如果请求注册 20 个账号:
322
+
323
+ - `success_count=20`:全部成功
324
+ - `success_count=17, failed_count=3`:部分成功
325
+ - `success_count=0, failed_count=20`:全部失败
326
+
327
+ 不要要求系统必须 100% 成功;应根据成功数和失败数做业务判断。
328
+
329
+ ---
330
+
331
+ ## 7. 取消批次
332
+
333
+ ### 7.1 请求
334
+
335
+ ```http
336
+ POST /api/external/registration/batches/{batch_uuid}/cancel
337
+ X-API-Key: your_api_key
338
+ ```
339
+
340
+ ### 7.2 规则
341
+
342
+ - `pending` / `running` 可取消
343
+ - 已结束批次再次取消会返回 `400`
344
+ - 批次不存在返回 `404`
345
+
346
+ ---
347
+
348
+ ## 8. 错误码说明
349
+
350
+ | 状态码 | 含义 | 建议处理 |
351
+ | --- | --- | --- |
352
+ | `401` | API Key 错误 | 检查 `X-API-Key` |
353
+ | `403` | 外部 API 未启用 / Key 未配置 | 联系项目方 |
354
+ | `404` | 批次不存在 | 检查 `batch_uuid` |
355
+ | `422` | 请求体校验失败 | 修正 JSON 结构 |
356
+ | `400` | 业务参数错误 | 检查 `service_id` / provider / 状态 |
357
+ | `503` | 服务暂时不可用 | 稍后重试 |
358
+
359
+ ---
360
+
361
+ ## 9. 第三方最小实现建议
362
+
363
+ 第三方系统至少实现以下逻辑:
364
+
365
+ ### 9.1 初始化
366
+
367
+ - 保存 `Base URL`
368
+ - 保存 `X-API-Key`
369
+
370
+ ### 9.2 下发任务前
371
+
372
+ - 调一次 `GET /external/capabilities`
373
+ - 读取当前可用邮箱类型
374
+ - 如需固定服务,读取对应 `service_id`
375
+
376
+ ### 9.3 创建任务
377
+
378
+ - 调 `POST /external/registration/batches`
379
+ - 保存返回的 `batch_uuid`
380
+
381
+ ### 9.4 查询任务
382
+
383
+ - 周期轮询 `GET /external/registration/batches/{batch_uuid}`
384
+ - 直到进入终态
385
+
386
+ ### 9.5 终态处理
387
+
388
+ - `completed`:当作成功
389
+ - `completed_partial`:按成功数/失败数决定业务处理
390
+ - `failed`:整体失败
391
+ - `cancelled`:任务已取消
392
+
393
+ ---
394
+
395
+ ## 10. 推荐对接原则
396
+
397
+ ### 原则 1:永远先读 `capabilities`
398
+
399
+ 不要在第三方代码里写死:
400
+
401
+ - 邮箱类型
402
+ - 上传 provider
403
+ - `service_id`
404
+
405
+ ### 原则 2:建议总是传 `idempotency_key`
406
+
407
+ 这样上游超时重试时,不会重复创建同一批次。
408
+
409
+ ### 原则 3:如果不关心具体服务,就不要传 `service_id`
410
+
411
+ 直接让系统自动选,能减少耦合。
412
+
413
+ ### 原则 4:把结果当作“批次结果”处理
414
+
415
+ 第三方调用的是批次任务,不是单账号实时接口。
416
+ 请以批次维度读取:
417
+
418
+ - 请求数量
419
+ - 成功数量
420
+ - 失败数量
421
+ - 上传成功/失败数量
422
+
423
+ ---
424
+
425
+ ## 11. 一段可以直接发给技术对接方的话
426
+
427
+ 你可以直接把下面这段发给对方:
428
+
429
+ ```text
430
+ 对接地址:
431
+ http://your-domain:8000/api
432
+
433
+ 认证方式:
434
+ 请求头增加:
435
+ X-API-Key: your_api_key
436
+
437
+ 先调用:
438
+ GET /external/capabilities
439
+ 用来获取当前可用邮箱类型、上传 provider,以及每个服务对应的 service_id 和 name。
440
+
441
+ 再调用:
442
+ POST /external/registration/batches
443
+ 创建注册批次。
444
+
445
+ 查询状态:
446
+ GET /external/registration/batches/{batch_uuid}
447
+
448
+ 取消任务:
449
+ POST /external/registration/batches/{batch_uuid}/cancel
450
+
451
+ 注意:
452
+ 1. service_id 不要猜,必须来自 capabilities 返回
453
+ 2. 如果不指定 service_id,系统会自动选择可用服务
454
+ 3. API Key 由项目方提供,不是通过接口申请
455
+ ```
pyproject.toml ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "codex-console"
3
+ version = "1.0.4"
4
+ description = "OpenAI account management console"
5
+ requires-python = ">=3.10"
6
+ dependencies = [
7
+ "curl-cffi>=0.14.0",
8
+ "fastapi>=0.100.0",
9
+ "uvicorn>=0.23.0",
10
+ "jinja2>=3.1.0",
11
+ "python-multipart>=0.0.6",
12
+ "pydantic>=2.0.0",
13
+ "pydantic-settings>=2.0.0",
14
+ "sqlalchemy>=2.0.0",
15
+ "aiosqlite>=0.19.0",
16
+ "psycopg[binary]>=3.1.18",
17
+ "websockets>=16.0",
18
+ "path>=17.1.1",
19
+ ]
20
+
21
+ [project.optional-dependencies]
22
+ dev = [
23
+ "pytest>=7.0.0",
24
+ "httpx>=0.24.0",
25
+ ]
26
+ payment = [
27
+ "playwright>=1.40.0",
28
+ ]
29
+
30
+ [project.scripts]
31
+ codex-console = "webui:main"
32
+
33
+ [build-system]
34
+ requires = ["hatchling"]
35
+ build-backend = "hatchling.build"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src"]
39
+
40
+ [dependency-groups]
41
+ dev = [
42
+ "pyinstaller>=6.19.0",
43
+ ]
requirements.txt ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ certifi>=2024.0.0
2
+ cffi>=1.16.0
3
+ curl_cffi>=0.14.0
4
+ pycparser>=1.21
5
+ pydantic>=2.0.0
6
+ pydantic-settings>=2.0.0
7
+ fastapi>=0.100.0
8
+ uvicorn[standard]>=0.23.0
9
+ jinja2>=3.1.0
10
+ python-multipart>=0.0.6
11
+ sqlalchemy>=2.0.0
12
+ aiosqlite>=0.19.0
13
+ psycopg[binary]>=3.1.18
14
+ # 可选:无痕打开支付页需要 playwright(pip install playwright && playwright install chromium)
15
+ # playwright>=1.40.0
src/__init__.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ OpenAI/Codex CLI 自动注册系统
3
+ """
4
+
5
+ from .config import get_settings, EmailServiceType
6
+ from .database import get_db, Account, EmailService, RegistrationTask
7
+ from .core import RegistrationEngine, RegistrationResult
8
+ from .services import EmailServiceFactory, BaseEmailService
9
+
10
+ __version__ = "2.0.0"
11
+ __author__ = "Yasal"
12
+
13
+ __all__ = [
14
+ 'get_settings',
15
+ 'EmailServiceType',
16
+ 'get_db',
17
+ 'Account',
18
+ 'EmailService',
19
+ 'RegistrationTask',
20
+ 'RegistrationEngine',
21
+ 'RegistrationResult',
22
+ 'EmailServiceFactory',
23
+ 'BaseEmailService',
24
+ ]
src/config/__init__.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 配置模块
3
+ """
4
+
5
+ from .settings import (
6
+ Settings,
7
+ get_settings,
8
+ update_settings,
9
+ get_database_url,
10
+ init_default_settings,
11
+ get_setting_definition,
12
+ get_all_setting_definitions,
13
+ SETTING_DEFINITIONS,
14
+ SettingCategory,
15
+ SettingDefinition,
16
+ )
17
+ from .constants import (
18
+ AccountStatus,
19
+ TaskStatus,
20
+ EmailServiceType,
21
+ APP_NAME,
22
+ APP_VERSION,
23
+ OTP_CODE_PATTERN,
24
+ DEFAULT_PASSWORD_LENGTH,
25
+ PASSWORD_CHARSET,
26
+ DEFAULT_USER_INFO,
27
+ generate_random_user_info,
28
+ OPENAI_API_ENDPOINTS,
29
+ )
30
+
31
+ __all__ = [
32
+ 'Settings',
33
+ 'get_settings',
34
+ 'update_settings',
35
+ 'get_database_url',
36
+ 'init_default_settings',
37
+ 'get_setting_definition',
38
+ 'get_all_setting_definitions',
39
+ 'SETTING_DEFINITIONS',
40
+ 'SettingCategory',
41
+ 'SettingDefinition',
42
+ 'AccountStatus',
43
+ 'TaskStatus',
44
+ 'EmailServiceType',
45
+ 'APP_NAME',
46
+ 'APP_VERSION',
47
+ 'OTP_CODE_PATTERN',
48
+ 'DEFAULT_PASSWORD_LENGTH',
49
+ 'PASSWORD_CHARSET',
50
+ 'DEFAULT_USER_INFO',
51
+ 'generate_random_user_info',
52
+ 'OPENAI_API_ENDPOINTS',
53
+ ]
src/config/constants.py ADDED
@@ -0,0 +1,413 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 常量定义
3
+ """
4
+
5
+ import random
6
+ from datetime import datetime
7
+ from enum import Enum
8
+ from typing import Dict, List, Tuple
9
+
10
+
11
+ # ============================================================================
12
+ # 枚举类型
13
+ # ============================================================================
14
+
15
+ class AccountStatus(str, Enum):
16
+ """账户状态"""
17
+ ACTIVE = "active"
18
+ EXPIRED = "expired"
19
+ BANNED = "banned"
20
+ FAILED = "failed"
21
+
22
+
23
+ class TaskStatus(str, Enum):
24
+ """任务状态"""
25
+ PENDING = "pending"
26
+ RUNNING = "running"
27
+ COMPLETED = "completed"
28
+ FAILED = "failed"
29
+ CANCELLED = "cancelled"
30
+
31
+
32
+ class EmailServiceType(str, Enum):
33
+ """邮箱服务类型"""
34
+ TEMPMAIL = "tempmail"
35
+ OUTLOOK = "outlook"
36
+ MOE_MAIL = "moe_mail"
37
+ TEMP_MAIL = "temp_mail"
38
+ DUCK_MAIL = "duck_mail"
39
+ FREEMAIL = "freemail"
40
+ IMAP_MAIL = "imap_mail"
41
+
42
+
43
+ # ============================================================================
44
+ # 应用常量
45
+ # ============================================================================
46
+
47
+ APP_NAME = "OpenAI/Codex CLI 自动注册系统"
48
+ APP_VERSION = "2.0.0"
49
+ APP_DESCRIPTION = "自动注册 OpenAI/Codex CLI 账号的系统"
50
+
51
+ # ============================================================================
52
+ # OpenAI OAuth 相关常量
53
+ # ============================================================================
54
+
55
+ # OAuth 参数
56
+ OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
57
+ OAUTH_AUTH_URL = "https://auth.openai.com/oauth/authorize"
58
+ OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token"
59
+ OAUTH_REDIRECT_URI = "http://localhost:1455/auth/callback"
60
+ OAUTH_SCOPE = "openid email profile offline_access"
61
+
62
+ # OpenAI API 端点
63
+ OPENAI_API_ENDPOINTS = {
64
+ "sentinel": "https://sentinel.openai.com/backend-api/sentinel/req",
65
+ "signup": "https://auth.openai.com/api/accounts/authorize/continue",
66
+ "register": "https://auth.openai.com/api/accounts/user/register",
67
+ "password_verify": "https://auth.openai.com/api/accounts/password/verify",
68
+ "send_otp": "https://auth.openai.com/api/accounts/email-otp/send",
69
+ "validate_otp": "https://auth.openai.com/api/accounts/email-otp/validate",
70
+ "create_account": "https://auth.openai.com/api/accounts/create_account",
71
+ "select_workspace": "https://auth.openai.com/api/accounts/workspace/select",
72
+ }
73
+
74
+ # OpenAI 页面类型(用于判断账号状态)
75
+ OPENAI_PAGE_TYPES = {
76
+ "EMAIL_OTP_VERIFICATION": "email_otp_verification", # 已注册账号,需要 OTP 验证
77
+ "PASSWORD_REGISTRATION": "create_account_password", # 新账号,需要设置密码
78
+ "LOGIN_PASSWORD": "login_password", # 登录流程,需要输入密码
79
+ }
80
+
81
+ # ============================================================================
82
+ # 邮箱服务相关常量
83
+ # ============================================================================
84
+
85
+ # Tempmail.lol API 端点
86
+ TEMPMAIL_API_ENDPOINTS = {
87
+ "create_inbox": "/inbox/create",
88
+ "get_inbox": "/inbox",
89
+ }
90
+
91
+ # 自定义域名邮箱 API 端点
92
+ CUSTOM_DOMAIN_API_ENDPOINTS = {
93
+ "get_config": "/api/config",
94
+ "create_email": "/api/emails/generate",
95
+ "list_emails": "/api/emails",
96
+ "get_email_messages": "/api/emails/{emailId}",
97
+ "delete_email": "/api/emails/{emailId}",
98
+ "get_message": "/api/emails/{emailId}/{messageId}",
99
+ }
100
+
101
+ # 邮箱服务默认配置
102
+ EMAIL_SERVICE_DEFAULTS = {
103
+ "tempmail": {
104
+ "base_url": "https://api.tempmail.lol/v2",
105
+ "timeout": 30,
106
+ "max_retries": 3,
107
+ },
108
+ "outlook": {
109
+ "imap_server": "outlook.office365.com",
110
+ "imap_port": 993,
111
+ "smtp_server": "smtp.office365.com",
112
+ "smtp_port": 587,
113
+ "timeout": 30,
114
+ },
115
+ "moe_mail": {
116
+ "base_url": "", # 需要用户配置
117
+ "api_key_header": "X-API-Key",
118
+ "timeout": 30,
119
+ "max_retries": 3,
120
+ },
121
+ "duck_mail": {
122
+ "base_url": "",
123
+ "default_domain": "",
124
+ "password_length": 12,
125
+ "timeout": 30,
126
+ "max_retries": 3,
127
+ },
128
+ "freemail": {
129
+ "base_url": "",
130
+ "admin_token": "",
131
+ "domain": "",
132
+ "timeout": 30,
133
+ "max_retries": 3,
134
+ },
135
+ "imap_mail": {
136
+ "host": "",
137
+ "port": 993,
138
+ "use_ssl": True,
139
+ "email": "",
140
+ "password": "",
141
+ "timeout": 30,
142
+ "max_retries": 3,
143
+ }
144
+ }
145
+
146
+ # ============================================================================
147
+ # 注册流程相关常量
148
+ # ============================================================================
149
+
150
+ # 验证码相关
151
+ OTP_CODE_PATTERN = r"(?<!\d)(\d{6})(?!\d)"
152
+ OTP_MAX_ATTEMPTS = 40 # 最大轮询次数
153
+
154
+ # 验证码提取正则(增强版)
155
+ # 简单匹配:任意 6 位数字
156
+ OTP_CODE_SIMPLE_PATTERN = r"(?<!\d)(\d{6})(?!\d)"
157
+ # 语义匹配:带上下文的验证码(如 "code is 123456", "验证码 123456")
158
+ OTP_CODE_SEMANTIC_PATTERN = r'(?:code\s+is|验证码[是为]?\s*[::]?\s*)(\d{6})'
159
+
160
+ # OpenAI 验证邮件发件��
161
+ OPENAI_EMAIL_SENDERS = [
162
+ "noreply@openai.com",
163
+ "no-reply@openai.com",
164
+ "@openai.com", # 精确域名匹配
165
+ ".openai.com", # 子域名匹配(如 otp@tm1.openai.com)
166
+ ]
167
+
168
+ # OpenAI 验证邮件关键词
169
+ OPENAI_VERIFICATION_KEYWORDS = [
170
+ "verify your email",
171
+ "verification code",
172
+ "验证码",
173
+ "your openai code",
174
+ "code is",
175
+ "one-time code",
176
+ ]
177
+
178
+ # 密码生成
179
+ PASSWORD_CHARSET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%&*"
180
+ PASSWORD_SPECIAL = "!@#$%&*"
181
+ DEFAULT_PASSWORD_LENGTH = 12
182
+ PASSWORD_MIN_LENGTH = 12
183
+ PASSWORD_MAX_LENGTH = 16
184
+
185
+ # 用户信息生成(用于注册)
186
+
187
+ FIRST_NAMES = [
188
+ "James", "John", "Robert", "Michael", "William", "David", "Richard", "Joseph",
189
+ "Daniel", "Matthew", "Andrew", "Joshua", "Christopher", "Anthony", "Kevin", "Brian",
190
+ "Emma", "Olivia", "Ava", "Isabella", "Sophia", "Mia", "Charlotte", "Amelia",
191
+ "Harper", "Evelyn", "Abigail", "Emily", "Elizabeth", "Natalie", "Victoria", "Samantha",
192
+ "Liam", "Noah", "Ethan", "Lucas", "Mason", "Oliver", "Elijah", "Aiden",
193
+ "Henry", "Sebastian", "Jack", "Benjamin", "Logan", "Alexander", "Nathan", "Ryan",
194
+ "Grace", "Lily", "Chloe", "Zoey", "Nora", "Aria", "Hazel", "Aurora",
195
+ ]
196
+
197
+ LAST_NAMES = [
198
+ "Smith", "Johnson", "Brown", "Davis", "Wilson", "Moore", "Taylor", "Clark",
199
+ "Hall", "Young", "Anderson", "Thomas", "Jackson", "White", "Harris", "Martin",
200
+ "Thompson", "Garcia", "Robinson", "Lewis", "Walker", "Allen", "King", "Wright",
201
+ "Scott", "Green", "Baker", "Adams", "Nelson", "Carter", "Mitchell", "Roberts",
202
+ "Turner", "Phillips", "Campbell", "Parker", "Evans", "Edwards", "Collins", "Stewart",
203
+ ]
204
+
205
+ def generate_random_user_info() -> dict:
206
+ """
207
+ 生成随机用户信息
208
+
209
+ Returns:
210
+ 包含 name 和 birthdate 的字典
211
+ """
212
+ # 随机选择名字(First + Last)
213
+ first_name = random.choice(FIRST_NAMES)
214
+ last_name = random.choice(LAST_NAMES)
215
+ name = f"{first_name} {last_name}"
216
+
217
+ # 生成随机生日(18-45岁)
218
+ current_year = datetime.now().year
219
+ birth_year = random.randint(current_year - 45, current_year - 18)
220
+ birth_month = random.randint(1, 12)
221
+ # 根据月份确定天数
222
+ if birth_month in [1, 3, 5, 7, 8, 10, 12]:
223
+ birth_day = random.randint(1, 31)
224
+ elif birth_month in [4, 6, 9, 11]:
225
+ birth_day = random.randint(1, 30)
226
+ else:
227
+ # 2月,简化处理
228
+ birth_day = random.randint(1, 28)
229
+
230
+ birthdate = f"{birth_year}-{birth_month:02d}-{birth_day:02d}"
231
+
232
+ return {
233
+ "name": name,
234
+ "birthdate": birthdate
235
+ }
236
+
237
+ # 保留默认值供兼容
238
+ DEFAULT_USER_INFO = {
239
+ "name": "Neo",
240
+ "birthdate": "2000-02-20",
241
+ }
242
+
243
+ # ============================================================================
244
+ # 代理相关常量
245
+ # ============================================================================
246
+
247
+ PROXY_TYPES = ["http", "socks5", "socks5h"]
248
+ DEFAULT_PROXY_CONFIG = {
249
+ "enabled": False,
250
+ "type": "http",
251
+ "host": "127.0.0.1",
252
+ "port": 7890,
253
+ }
254
+
255
+ # ============================================================================
256
+ # 数据库相关常量
257
+ # ============================================================================
258
+
259
+ # 数据库表名
260
+ DB_TABLE_NAMES = {
261
+ "accounts": "accounts",
262
+ "email_services": "email_services",
263
+ "registration_tasks": "registration_tasks",
264
+ "settings": "settings",
265
+ }
266
+
267
+ # 默认设置
268
+ DEFAULT_SETTINGS = [
269
+ # (key, value, description, category)
270
+ ("system.name", APP_NAME, "系统名称", "general"),
271
+ ("system.version", APP_VERSION, "系统版本", "general"),
272
+ ("logs.retention_days", "30", "日志保留天数", "general"),
273
+ ("openai.client_id", OAUTH_CLIENT_ID, "OpenAI OAuth Client ID", "openai"),
274
+ ("openai.auth_url", OAUTH_AUTH_URL, "OpenAI 认证地址", "openai"),
275
+ ("openai.token_url", OAUTH_TOKEN_URL, "OpenAI Token 地址", "openai"),
276
+ ("openai.redirect_uri", OAUTH_REDIRECT_URI, "OpenAI 回调地址", "openai"),
277
+ ("openai.scope", OAUTH_SCOPE, "OpenAI 权限范围", "openai"),
278
+ ("proxy.enabled", "false", "是否启用代理", "proxy"),
279
+ ("proxy.type", "http", "代理类型 (http/socks5)", "proxy"),
280
+ ("proxy.host", "127.0.0.1", "代理主机", "proxy"),
281
+ ("proxy.port", "7890", "代理端口", "proxy"),
282
+ ("registration.max_retries", "3", "最大重试次数", "registration"),
283
+ ("registration.timeout", "120", "超时时间(秒)", "registration"),
284
+ ("registration.default_password_length", "12", "默认密码长度", "registration"),
285
+ ("webui.host", "0.0.0.0", "Web UI 监听主机", "webui"),
286
+ ("webui.port", "8000", "Web UI 监听端口", "webui"),
287
+ ("webui.debug", "true", "调试模式", "webui"),
288
+ ]
289
+
290
+ # ============================================================================
291
+ # Web UI 相关常量
292
+ # ============================================================================
293
+
294
+ # WebSocket 事件
295
+ WEBSOCKET_EVENTS = {
296
+ "CONNECT": "connect",
297
+ "DISCONNECT": "disconnect",
298
+ "LOG": "log",
299
+ "STATUS": "status",
300
+ "ERROR": "error",
301
+ "COMPLETE": "complete",
302
+ }
303
+
304
+ # API 响应状态码
305
+ API_STATUS_CODES = {
306
+ "SUCCESS": 200,
307
+ "CREATED": 201,
308
+ "BAD_REQUEST": 400,
309
+ "UNAUTHORIZED": 401,
310
+ "FORBIDDEN": 403,
311
+ "NOT_FOUND": 404,
312
+ "CONFLICT": 409,
313
+ "INTERNAL_ERROR": 500,
314
+ }
315
+
316
+ # 分页
317
+ DEFAULT_PAGE_SIZE = 20
318
+ MAX_PAGE_SIZE = 100
319
+
320
+ # ============================================================================
321
+ # 错误消息
322
+ # ============================================================================
323
+
324
+ ERROR_MESSAGES = {
325
+ # 通用错误
326
+ "DATABASE_ERROR": "数据库操作失败",
327
+ "CONFIG_ERROR": "配置错误",
328
+ "NETWORK_ERROR": "网络连接失败",
329
+ "TIMEOUT": "操作超时",
330
+ "VALIDATION_ERROR": "参数验证失败",
331
+
332
+ # 邮箱服务错误
333
+ "EMAIL_SERVICE_UNAVAILABLE": "邮箱服务不可用",
334
+ "EMAIL_CREATION_FAILED": "创建邮箱失败",
335
+ "OTP_NOT_RECEIVED": "未收到验证码",
336
+ "OTP_INVALID": "验证码无效",
337
+
338
+ # OpenAI 相关错误
339
+ "OPENAI_AUTH_FAILED": "OpenAI 认证失败",
340
+ "OPENAI_RATE_LIMIT": "OpenAI 接口限流",
341
+ "OPENAI_CAPTCHA": "遇到验证码",
342
+
343
+ # 代理错误
344
+ "PROXY_FAILED": "代理连接失败",
345
+ "PROXY_AUTH_FAILED": "代理认证失败",
346
+
347
+ # 账户错误
348
+ "ACCOUNT_NOT_FOUND": "账户不存在",
349
+ "ACCOUNT_ALREADY_EXISTS": "账户已存在",
350
+ "ACCOUNT_INVALID": "账户无效",
351
+
352
+ # 任务错误
353
+ "TASK_NOT_FOUND": "任务不存在",
354
+ "TASK_ALREADY_RUNNING": "任务已在运行中",
355
+ "TASK_CANCELLED": "任务已取消",
356
+ }
357
+
358
+ # ============================================================================
359
+ # 正则表达式
360
+ # ============================================================================
361
+
362
+ REGEX_PATTERNS = {
363
+ "EMAIL": r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$",
364
+ "URL": r"https?://(?:[-\w.]|(?:%[\da-fA-F]{2}))+",
365
+ "IP_ADDRESS": r"\b(?:\d{1,3}\.){3}\d{1,3}\b",
366
+ "OTP_CODE": OTP_CODE_PATTERN,
367
+ }
368
+
369
+ # ============================================================================
370
+ # 时间常量
371
+ # ============================================================================
372
+
373
+ TIME_CONSTANTS = {
374
+ "SECOND": 1,
375
+ "MINUTE": 60,
376
+ "HOUR": 3600,
377
+ "DAY": 86400,
378
+ "WEEK": 604800,
379
+ }
380
+
381
+
382
+ # ============================================================================
383
+ # Microsoft/Outlook 相关常量
384
+ # ============================================================================
385
+
386
+ # Microsoft OAuth2 Token 端点
387
+ MICROSOFT_TOKEN_ENDPOINTS = {
388
+ # 旧版 IMAP 使用的端点
389
+ "LIVE": "https://login.live.com/oauth20_token.srf",
390
+ # 新版 IMAP 使用的端点(需要特定 scope)
391
+ "CONSUMERS": "https://login.microsoftonline.com/consumers/oauth2/v2.0/token",
392
+ # Graph API 使用的端点
393
+ "COMMON": "https://login.microsoftonline.com/common/oauth2/v2.0/token",
394
+ }
395
+
396
+ # IMAP 服务器配置
397
+ OUTLOOK_IMAP_SERVERS = {
398
+ "OLD": "outlook.office365.com", # 旧版 IMAP
399
+ "NEW": "outlook.live.com", # 新版 IMAP
400
+ }
401
+
402
+ # Microsoft OAuth2 Scopes
403
+ MICROSOFT_SCOPES = {
404
+ # 旧版 IMAP 不需要特定 scope
405
+ "IMAP_OLD": "",
406
+ # 新版 IMAP 需要的 scope
407
+ "IMAP_NEW": "https://outlook.office.com/IMAP.AccessAsUser.All offline_access",
408
+ # Graph API 需要的 scope
409
+ "GRAPH_API": "https://graph.microsoft.com/.default",
410
+ }
411
+
412
+ # Outlook 提供者默认优先级
413
+ OUTLOOK_PROVIDER_PRIORITY = ["imap_new", "imap_old", "graph_api"]
src/config/settings.py ADDED
@@ -0,0 +1,790 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 配置管理 - 完全基于数据库存储
3
+ 所有配置都从数据库读取,不再使用环境变量或 .env 文件
4
+ """
5
+
6
+ import os
7
+ from typing import Optional, Dict, Any, Type, List
8
+ from enum import Enum
9
+ from pydantic import BaseModel, field_validator
10
+ from pydantic.types import SecretStr
11
+ from dataclasses import dataclass
12
+
13
+
14
+ class SettingCategory(str, Enum):
15
+ """设置分类"""
16
+ GENERAL = "general"
17
+ DATABASE = "database"
18
+ WEBUI = "webui"
19
+ LOG = "log"
20
+ OPENAI = "openai"
21
+ PROXY = "proxy"
22
+ REGISTRATION = "registration"
23
+ EMAIL = "email"
24
+ TEMPMAIL = "tempmail"
25
+ CUSTOM_DOMAIN = "moe_mail"
26
+ SECURITY = "security"
27
+ CPA = "cpa"
28
+
29
+
30
+ @dataclass
31
+ class SettingDefinition:
32
+ """设置定义"""
33
+ db_key: str
34
+ default_value: Any
35
+ category: SettingCategory
36
+ description: str = ""
37
+ is_secret: bool = False
38
+
39
+
40
+ # 所有配置项定义(包含数据库键名、默认值、分类、描述)
41
+ SETTING_DEFINITIONS: Dict[str, SettingDefinition] = {
42
+ # 应用信息
43
+ "app_name": SettingDefinition(
44
+ db_key="app.name",
45
+ default_value="OpenAI/Codex CLI 自动注册系统",
46
+ category=SettingCategory.GENERAL,
47
+ description="应用名称"
48
+ ),
49
+ "app_version": SettingDefinition(
50
+ db_key="app.version",
51
+ default_value="2.0.0",
52
+ category=SettingCategory.GENERAL,
53
+ description="应用版本"
54
+ ),
55
+ "debug": SettingDefinition(
56
+ db_key="app.debug",
57
+ default_value=False,
58
+ category=SettingCategory.GENERAL,
59
+ description="调试模式"
60
+ ),
61
+
62
+ # 数据库配置
63
+ "database_url": SettingDefinition(
64
+ db_key="database.url",
65
+ default_value="data/database.db",
66
+ category=SettingCategory.DATABASE,
67
+ description="数据库路径或连接字符串"
68
+ ),
69
+
70
+ # Web UI 配置
71
+ "webui_host": SettingDefinition(
72
+ db_key="webui.host",
73
+ default_value="0.0.0.0",
74
+ category=SettingCategory.WEBUI,
75
+ description="Web UI 监听地址"
76
+ ),
77
+ "webui_port": SettingDefinition(
78
+ db_key="webui.port",
79
+ default_value=8000,
80
+ category=SettingCategory.WEBUI,
81
+ description="Web UI 监听端口"
82
+ ),
83
+ "webui_secret_key": SettingDefinition(
84
+ db_key="webui.secret_key",
85
+ default_value="your-secret-key-change-in-production",
86
+ category=SettingCategory.WEBUI,
87
+ description="Web UI 密钥",
88
+ is_secret=True
89
+ ),
90
+ "webui_access_password": SettingDefinition(
91
+ db_key="webui.access_password",
92
+ default_value="admin123",
93
+ category=SettingCategory.WEBUI,
94
+ description="Web UI 访问密码",
95
+ is_secret=True
96
+ ),
97
+ "external_api_enabled": SettingDefinition(
98
+ db_key="external_api.enabled",
99
+ default_value=False,
100
+ category=SettingCategory.GENERAL,
101
+ description="是否启用外部 API"
102
+ ),
103
+ "external_api_key": SettingDefinition(
104
+ db_key="external_api.key",
105
+ default_value="",
106
+ category=SettingCategory.GENERAL,
107
+ description="外部 API Key",
108
+ is_secret=True
109
+ ),
110
+
111
+ # 日志配置
112
+ "log_level": SettingDefinition(
113
+ db_key="log.level",
114
+ default_value="INFO",
115
+ category=SettingCategory.LOG,
116
+ description="日志级别"
117
+ ),
118
+ "log_file": SettingDefinition(
119
+ db_key="log.file",
120
+ default_value="logs/app.log",
121
+ category=SettingCategory.LOG,
122
+ description="日志文件路径"
123
+ ),
124
+ "log_retention_days": SettingDefinition(
125
+ db_key="log.retention_days",
126
+ default_value=30,
127
+ category=SettingCategory.LOG,
128
+ description="日志保留天数"
129
+ ),
130
+
131
+ # OpenAI 配置
132
+ "openai_client_id": SettingDefinition(
133
+ db_key="openai.client_id",
134
+ default_value="app_EMoamEEZ73f0CkXaXp7hrann",
135
+ category=SettingCategory.OPENAI,
136
+ description="OpenAI OAuth 客户端 ID"
137
+ ),
138
+ "openai_auth_url": SettingDefinition(
139
+ db_key="openai.auth_url",
140
+ default_value="https://auth.openai.com/oauth/authorize",
141
+ category=SettingCategory.OPENAI,
142
+ description="OpenAI OAuth 授权 URL"
143
+ ),
144
+ "openai_token_url": SettingDefinition(
145
+ db_key="openai.token_url",
146
+ default_value="https://auth.openai.com/oauth/token",
147
+ category=SettingCategory.OPENAI,
148
+ description="OpenAI OAuth Token URL"
149
+ ),
150
+ "openai_redirect_uri": SettingDefinition(
151
+ db_key="openai.redirect_uri",
152
+ default_value="http://localhost:1455/auth/callback",
153
+ category=SettingCategory.OPENAI,
154
+ description="OpenAI OAuth 回调 URI"
155
+ ),
156
+ "openai_scope": SettingDefinition(
157
+ db_key="openai.scope",
158
+ default_value="openid email profile offline_access",
159
+ category=SettingCategory.OPENAI,
160
+ description="OpenAI OAuth 权限范围"
161
+ ),
162
+
163
+ # 代理配置
164
+ "proxy_enabled": SettingDefinition(
165
+ db_key="proxy.enabled",
166
+ default_value=False,
167
+ category=SettingCategory.PROXY,
168
+ description="是否启用代理"
169
+ ),
170
+ "proxy_type": SettingDefinition(
171
+ db_key="proxy.type",
172
+ default_value="http",
173
+ category=SettingCategory.PROXY,
174
+ description="代理类型 (http/socks5)"
175
+ ),
176
+ "proxy_host": SettingDefinition(
177
+ db_key="proxy.host",
178
+ default_value="127.0.0.1",
179
+ category=SettingCategory.PROXY,
180
+ description="代理服务器地址"
181
+ ),
182
+ "proxy_port": SettingDefinition(
183
+ db_key="proxy.port",
184
+ default_value=7890,
185
+ category=SettingCategory.PROXY,
186
+ description="代理服务器端口"
187
+ ),
188
+ "proxy_username": SettingDefinition(
189
+ db_key="proxy.username",
190
+ default_value="",
191
+ category=SettingCategory.PROXY,
192
+ description="代理用户名"
193
+ ),
194
+ "proxy_password": SettingDefinition(
195
+ db_key="proxy.password",
196
+ default_value="",
197
+ category=SettingCategory.PROXY,
198
+ description="代理密码",
199
+ is_secret=True
200
+ ),
201
+ "proxy_dynamic_enabled": SettingDefinition(
202
+ db_key="proxy.dynamic_enabled",
203
+ default_value=False,
204
+ category=SettingCategory.PROXY,
205
+ description="是否启用动态代理"
206
+ ),
207
+ "proxy_dynamic_api_url": SettingDefinition(
208
+ db_key="proxy.dynamic_api_url",
209
+ default_value="",
210
+ category=SettingCategory.PROXY,
211
+ description="动态代理 API 地址,返回代理 URL 字符串"
212
+ ),
213
+ "proxy_dynamic_api_key": SettingDefinition(
214
+ db_key="proxy.dynamic_api_key",
215
+ default_value="",
216
+ category=SettingCategory.PROXY,
217
+ description="动态代理 API 密钥(可选)",
218
+ is_secret=True
219
+ ),
220
+ "proxy_dynamic_api_key_header": SettingDefinition(
221
+ db_key="proxy.dynamic_api_key_header",
222
+ default_value="X-API-Key",
223
+ category=SettingCategory.PROXY,
224
+ description="动态代理 API 密钥请求头名称"
225
+ ),
226
+ "proxy_dynamic_result_field": SettingDefinition(
227
+ db_key="proxy.dynamic_result_field",
228
+ default_value="",
229
+ category=SettingCategory.PROXY,
230
+ description="从 JSON 响应中提取代理 URL 的字段路径(留空则使用响应原文)"
231
+ ),
232
+
233
+ # 注册配置
234
+ "registration_max_retries": SettingDefinition(
235
+ db_key="registration.max_retries",
236
+ default_value=3,
237
+ category=SettingCategory.REGISTRATION,
238
+ description="注册最大重试次数"
239
+ ),
240
+ "registration_timeout": SettingDefinition(
241
+ db_key="registration.timeout",
242
+ default_value=120,
243
+ category=SettingCategory.REGISTRATION,
244
+ description="注册超时时间(秒)"
245
+ ),
246
+ "registration_default_password_length": SettingDefinition(
247
+ db_key="registration.default_password_length",
248
+ default_value=12,
249
+ category=SettingCategory.REGISTRATION,
250
+ description="默认密码长度"
251
+ ),
252
+ "registration_sleep_min": SettingDefinition(
253
+ db_key="registration.sleep_min",
254
+ default_value=5,
255
+ category=SettingCategory.REGISTRATION,
256
+ description="注册间隔最小值(秒)"
257
+ ),
258
+ "registration_sleep_max": SettingDefinition(
259
+ db_key="registration.sleep_max",
260
+ default_value=30,
261
+ category=SettingCategory.REGISTRATION,
262
+ description="注册间隔最大值(秒)"
263
+ ),
264
+
265
+ # 邮箱服务配置
266
+ "email_service_priority": SettingDefinition(
267
+ db_key="email.service_priority",
268
+ default_value={"tempmail": 0, "outlook": 1, "moe_mail": 2},
269
+ category=SettingCategory.EMAIL,
270
+ description="邮箱服务优先级"
271
+ ),
272
+
273
+ # Tempmail.lol 配置
274
+ "tempmail_base_url": SettingDefinition(
275
+ db_key="tempmail.base_url",
276
+ default_value="https://api.tempmail.lol/v2",
277
+ category=SettingCategory.TEMPMAIL,
278
+ description="Tempmail API 地址"
279
+ ),
280
+ "tempmail_timeout": SettingDefinition(
281
+ db_key="tempmail.timeout",
282
+ default_value=30,
283
+ category=SettingCategory.TEMPMAIL,
284
+ description="Tempmail 超时时间(秒)"
285
+ ),
286
+ "tempmail_max_retries": SettingDefinition(
287
+ db_key="tempmail.max_retries",
288
+ default_value=3,
289
+ category=SettingCategory.TEMPMAIL,
290
+ description="Tempmail 最大重试次数"
291
+ ),
292
+
293
+ # 自定义域名邮箱配置
294
+ "custom_domain_base_url": SettingDefinition(
295
+ db_key="custom_domain.base_url",
296
+ default_value="",
297
+ category=SettingCategory.CUSTOM_DOMAIN,
298
+ description="自定义域名 API 地址"
299
+ ),
300
+ "custom_domain_api_key": SettingDefinition(
301
+ db_key="custom_domain.api_key",
302
+ default_value="",
303
+ category=SettingCategory.CUSTOM_DOMAIN,
304
+ description="自定义域名 API 密钥",
305
+ is_secret=True
306
+ ),
307
+
308
+ # 安全配置
309
+ "encryption_key": SettingDefinition(
310
+ db_key="security.encryption_key",
311
+ default_value="your-encryption-key-change-in-production",
312
+ category=SettingCategory.SECURITY,
313
+ description="加密密钥",
314
+ is_secret=True
315
+ ),
316
+
317
+ # Team Manager 配置
318
+ "tm_enabled": SettingDefinition(
319
+ db_key="tm.enabled",
320
+ default_value=False,
321
+ category=SettingCategory.GENERAL,
322
+ description="是否启用 Team Manager 上传"
323
+ ),
324
+ "tm_api_url": SettingDefinition(
325
+ db_key="tm.api_url",
326
+ default_value="",
327
+ category=SettingCategory.GENERAL,
328
+ description="Team Manager API 地址"
329
+ ),
330
+ "tm_api_key": SettingDefinition(
331
+ db_key="tm.api_key",
332
+ default_value="",
333
+ category=SettingCategory.GENERAL,
334
+ description="Team Manager API Key",
335
+ is_secret=True
336
+ ),
337
+
338
+ # CPA 上传配置
339
+ "cpa_enabled": SettingDefinition(
340
+ db_key="cpa.enabled",
341
+ default_value=False,
342
+ category=SettingCategory.CPA,
343
+ description="是否启用 CPA 上传"
344
+ ),
345
+ "cpa_api_url": SettingDefinition(
346
+ db_key="cpa.api_url",
347
+ default_value="",
348
+ category=SettingCategory.CPA,
349
+ description="CPA API 地址"
350
+ ),
351
+ "cpa_api_token": SettingDefinition(
352
+ db_key="cpa.api_token",
353
+ default_value="",
354
+ category=SettingCategory.CPA,
355
+ description="CPA API Token",
356
+ is_secret=True
357
+ ),
358
+
359
+ # 验证码配置
360
+ "email_code_timeout": SettingDefinition(
361
+ db_key="email_code.timeout",
362
+ default_value=120,
363
+ category=SettingCategory.EMAIL,
364
+ description="验证码等待超时时间(秒)"
365
+ ),
366
+ "email_code_poll_interval": SettingDefinition(
367
+ db_key="email_code.poll_interval",
368
+ default_value=3,
369
+ category=SettingCategory.EMAIL,
370
+ description="验证码轮询间隔(秒)"
371
+ ),
372
+
373
+ # Outlook 配置
374
+ "outlook_provider_priority": SettingDefinition(
375
+ db_key="outlook.provider_priority",
376
+ default_value=["imap_old", "imap_new", "graph_api"],
377
+ category=SettingCategory.EMAIL,
378
+ description="Outlook 提供者优先级"
379
+ ),
380
+ "outlook_health_failure_threshold": SettingDefinition(
381
+ db_key="outlook.health_failure_threshold",
382
+ default_value=5,
383
+ category=SettingCategory.EMAIL,
384
+ description="Outlook 提供者连续失败次数阈值"
385
+ ),
386
+ "outlook_health_disable_duration": SettingDefinition(
387
+ db_key="outlook.health_disable_duration",
388
+ default_value=60,
389
+ category=SettingCategory.EMAIL,
390
+ description="Outlook 提供者禁用时长(秒)"
391
+ ),
392
+ "outlook_default_client_id": SettingDefinition(
393
+ db_key="outlook.default_client_id",
394
+ default_value="24d9a0ed-8787-4584-883c-2fd79308940a",
395
+ category=SettingCategory.EMAIL,
396
+ description="Outlook OAuth 默认 Client ID"
397
+ ),
398
+ }
399
+
400
+ # 属性名到数据库键名的映射(用于向后兼容)
401
+ DB_SETTING_KEYS = {name: defn.db_key for name, defn in SETTING_DEFINITIONS.items()}
402
+
403
+ # 类型定义映射
404
+ SETTING_TYPES: Dict[str, Type] = {
405
+ "debug": bool,
406
+ "webui_port": int,
407
+ "external_api_enabled": bool,
408
+ "log_retention_days": int,
409
+ "proxy_enabled": bool,
410
+ "proxy_port": int,
411
+ "proxy_dynamic_enabled": bool,
412
+ "registration_max_retries": int,
413
+ "registration_timeout": int,
414
+ "registration_default_password_length": int,
415
+ "registration_sleep_min": int,
416
+ "registration_sleep_max": int,
417
+ "email_service_priority": dict,
418
+ "tempmail_timeout": int,
419
+ "tempmail_max_retries": int,
420
+ "tm_enabled": bool,
421
+ "cpa_enabled": bool,
422
+ "email_code_timeout": int,
423
+ "email_code_poll_interval": int,
424
+ "outlook_provider_priority": list,
425
+ "outlook_health_failure_threshold": int,
426
+ "outlook_health_disable_duration": int,
427
+ }
428
+
429
+ # 需要作为 SecretStr 处理的字段
430
+ SECRET_FIELDS = {name for name, defn in SETTING_DEFINITIONS.items() if defn.is_secret}
431
+
432
+
433
+ def _convert_value(attr_name: str, value: str) -> Any:
434
+ """将数据库字符串值转换为正确的类型"""
435
+ if attr_name in SECRET_FIELDS:
436
+ return SecretStr(value) if value else SecretStr("")
437
+
438
+ target_type = SETTING_TYPES.get(attr_name, str)
439
+
440
+ if target_type == bool:
441
+ if isinstance(value, bool):
442
+ return value
443
+ return str(value).lower() in ("true", "1", "yes", "on")
444
+ elif target_type == int:
445
+ if isinstance(value, int):
446
+ return value
447
+ return int(value) if value else 0
448
+ elif target_type == dict:
449
+ if isinstance(value, dict):
450
+ return value
451
+ if not value:
452
+ return {}
453
+ import json
454
+ import ast
455
+ try:
456
+ return json.loads(value)
457
+ except (json.JSONDecodeError, ValueError):
458
+ try:
459
+ return ast.literal_eval(value)
460
+ except Exception:
461
+ return {}
462
+ elif target_type == list:
463
+ if isinstance(value, list):
464
+ return value
465
+ if not value:
466
+ return []
467
+ import json
468
+ import ast
469
+ try:
470
+ return json.loads(value)
471
+ except (json.JSONDecodeError, ValueError):
472
+ try:
473
+ return ast.literal_eval(value)
474
+ except Exception:
475
+ return []
476
+ else:
477
+ return value
478
+
479
+
480
+ def _normalize_database_url(url: str) -> str:
481
+ if url.startswith("postgres://"):
482
+ return "postgresql+psycopg://" + url[len("postgres://"):]
483
+ if url.startswith("postgresql://"):
484
+ return "postgresql+psycopg://" + url[len("postgresql://"):]
485
+ return url
486
+
487
+
488
+ def _value_to_string(value: Any) -> str:
489
+ """将值转换为数据库存储的字符串"""
490
+ if isinstance(value, SecretStr):
491
+ return value.get_secret_value()
492
+ elif isinstance(value, bool):
493
+ return "true" if value else "false"
494
+ elif isinstance(value, (dict, list)):
495
+ import json
496
+ return json.dumps(value)
497
+ elif value is None:
498
+ return ""
499
+ else:
500
+ return str(value)
501
+
502
+
503
+ def init_default_settings() -> None:
504
+ """
505
+ 初始化数据库中的默认设置
506
+ 如果设置项不存在,则创建并设置默认值
507
+ """
508
+ try:
509
+ from ..database.session import get_db
510
+ from ..database.crud import get_setting, set_setting
511
+
512
+ with get_db() as db:
513
+ for attr_name, defn in SETTING_DEFINITIONS.items():
514
+ existing = get_setting(db, defn.db_key)
515
+ if not existing:
516
+ default_value = defn.default_value
517
+ if attr_name == "database_url":
518
+ env_url = os.environ.get("APP_DATABASE_URL") or os.environ.get("DATABASE_URL")
519
+ if env_url:
520
+ default_value = _normalize_database_url(env_url)
521
+ default_value = _value_to_string(default_value)
522
+ set_setting(
523
+ db,
524
+ defn.db_key,
525
+ default_value,
526
+ category=defn.category.value,
527
+ description=defn.description
528
+ )
529
+ print(f"[Settings] 初始化默认设置: {defn.db_key} = {default_value if not defn.is_secret else '***'}")
530
+ except Exception as e:
531
+ if "未初始化" not in str(e):
532
+ print(f"[Settings] 初始化默认设置失败: {e}")
533
+
534
+
535
+ def _load_settings_from_db() -> Dict[str, Any]:
536
+ """从数据库加载所有设置"""
537
+ try:
538
+ from ..database.session import get_db
539
+ from ..database.crud import get_setting
540
+
541
+ settings_dict = {}
542
+ with get_db() as db:
543
+ for attr_name, defn in SETTING_DEFINITIONS.items():
544
+ db_setting = get_setting(db, defn.db_key)
545
+ if db_setting:
546
+ settings_dict[attr_name] = _convert_value(attr_name, db_setting.value)
547
+ else:
548
+ # 数据库中没有此设置,使用默认值
549
+ settings_dict[attr_name] = _convert_value(attr_name, _value_to_string(defn.default_value))
550
+ env_url = os.environ.get("APP_DATABASE_URL") or os.environ.get("DATABASE_URL")
551
+ if env_url:
552
+ settings_dict["database_url"] = _normalize_database_url(env_url)
553
+ env_host = os.environ.get("WEBUI_HOST") or os.environ.get("APP_HOST")
554
+ if env_host:
555
+ settings_dict["webui_host"] = env_host
556
+ env_port = os.environ.get("PORT") or os.environ.get("WEBUI_PORT") or os.environ.get("APP_PORT")
557
+ if env_port:
558
+ try:
559
+ settings_dict["webui_port"] = int(env_port)
560
+ except ValueError:
561
+ pass
562
+ env_password = os.environ.get("WEBUI_ACCESS_PASSWORD") or os.environ.get("APP_ACCESS_PASSWORD")
563
+ if env_password:
564
+ settings_dict["webui_access_password"] = env_password
565
+ return settings_dict
566
+ except Exception as e:
567
+ if "未初始化" not in str(e):
568
+ print(f"[Settings] 从数据库加载设置失败: {e},使用默认值")
569
+ return {name: defn.default_value for name, defn in SETTING_DEFINITIONS.items()}
570
+
571
+
572
+ def _save_settings_to_db(**kwargs) -> None:
573
+ """保存设置到数据库"""
574
+ try:
575
+ from ..database.session import get_db
576
+ from ..database.crud import set_setting
577
+
578
+ with get_db() as db:
579
+ for attr_name, value in kwargs.items():
580
+ if attr_name in SETTING_DEFINITIONS:
581
+ defn = SETTING_DEFINITIONS[attr_name]
582
+ str_value = _value_to_string(value)
583
+ set_setting(
584
+ db,
585
+ defn.db_key,
586
+ str_value,
587
+ category=defn.category.value,
588
+ description=defn.description
589
+ )
590
+ except Exception as e:
591
+ if "未初始化" not in str(e):
592
+ print(f"[Settings] 保存设置到数据库失败: {e}")
593
+
594
+
595
+ class Settings(BaseModel):
596
+ """
597
+ 应用配置 - 完全基于数据库存储
598
+ """
599
+
600
+ # 应用信息
601
+ app_name: str = "OpenAI/Codex CLI 自动注册系统"
602
+ app_version: str = "2.0.0"
603
+ debug: bool = False
604
+
605
+ # 数据库配置
606
+ database_url: str = "data/database.db"
607
+
608
+ @field_validator('database_url', mode='before')
609
+ @classmethod
610
+ def validate_database_url(cls, v):
611
+ if isinstance(v, str):
612
+ if v.startswith(("postgres://", "postgresql://")):
613
+ return _normalize_database_url(v)
614
+ if v.startswith(("postgresql+psycopg://", "postgresql+psycopg2://")):
615
+ return v
616
+ if isinstance(v, str) and v.startswith("sqlite:///"):
617
+ return v
618
+ if isinstance(v, str) and not v.startswith(("sqlite:///", "postgresql://", "postgresql+psycopg://", "postgresql+psycopg2://", "mysql://")):
619
+ # 如果是文件路径,转换为 SQLite URL
620
+ if os.path.isabs(v) or ":/" not in v:
621
+ return f"sqlite:///{v}"
622
+ return v
623
+
624
+ # Web UI 配置
625
+ webui_host: str = "0.0.0.0"
626
+ webui_port: int = 8000
627
+ webui_secret_key: SecretStr = SecretStr("your-secret-key-change-in-production")
628
+ webui_access_password: SecretStr = SecretStr("admin123")
629
+ external_api_enabled: bool = False
630
+ external_api_key: SecretStr = SecretStr("")
631
+
632
+ # 日志配置
633
+ log_level: str = "INFO"
634
+ log_file: str = "logs/app.log"
635
+ log_retention_days: int = 30
636
+
637
+ # OpenAI 配置
638
+ openai_client_id: str = "app_EMoamEEZ73f0CkXaXp7hrann"
639
+ openai_auth_url: str = "https://auth.openai.com/oauth/authorize"
640
+ openai_token_url: str = "https://auth.openai.com/oauth/token"
641
+ openai_redirect_uri: str = "http://localhost:1455/auth/callback"
642
+ openai_scope: str = "openid email profile offline_access"
643
+
644
+ # 代理配置
645
+ proxy_enabled: bool = False
646
+ proxy_type: str = "http"
647
+ proxy_host: str = "127.0.0.1"
648
+ proxy_port: int = 7890
649
+ proxy_username: Optional[str] = None
650
+ proxy_password: Optional[SecretStr] = None
651
+ proxy_dynamic_enabled: bool = False
652
+ proxy_dynamic_api_url: str = ""
653
+ proxy_dynamic_api_key: Optional[SecretStr] = None
654
+ proxy_dynamic_api_key_header: str = "X-API-Key"
655
+ proxy_dynamic_result_field: str = ""
656
+
657
+ @property
658
+ def proxy_url(self) -> Optional[str]:
659
+ """获取完整的代理 URL"""
660
+ if not self.proxy_enabled:
661
+ return None
662
+
663
+ if self.proxy_type == "http":
664
+ scheme = "http"
665
+ elif self.proxy_type == "socks5":
666
+ scheme = "socks5"
667
+ else:
668
+ return None
669
+
670
+ auth = ""
671
+ if self.proxy_username and self.proxy_password:
672
+ auth = f"{self.proxy_username}:{self.proxy_password.get_secret_value()}@"
673
+
674
+ return f"{scheme}://{auth}{self.proxy_host}:{self.proxy_port}"
675
+
676
+ # 注册配置
677
+ registration_max_retries: int = 3
678
+ registration_timeout: int = 120
679
+ registration_default_password_length: int = 12
680
+ registration_sleep_min: int = 5
681
+ registration_sleep_max: int = 30
682
+
683
+ # 邮箱服务配置
684
+ email_service_priority: Dict[str, int] = {"tempmail": 0, "outlook": 1, "moe_mail": 2}
685
+
686
+ # Tempmail.lol 配置
687
+ tempmail_base_url: str = "https://api.tempmail.lol/v2"
688
+ tempmail_timeout: int = 30
689
+ tempmail_max_retries: int = 3
690
+
691
+ # 自定义域名邮箱配置
692
+ custom_domain_base_url: str = ""
693
+ custom_domain_api_key: Optional[SecretStr] = None
694
+
695
+ # 安全配置
696
+ encryption_key: SecretStr = SecretStr("your-encryption-key-change-in-production")
697
+
698
+ # Team Manager 配置
699
+ tm_enabled: bool = False
700
+ tm_api_url: str = ""
701
+ tm_api_key: Optional[SecretStr] = None
702
+
703
+ # CPA 上传配置
704
+ cpa_enabled: bool = False
705
+ cpa_api_url: str = ""
706
+ cpa_api_token: SecretStr = SecretStr("")
707
+
708
+ # 验证码配置
709
+ email_code_timeout: int = 120
710
+ email_code_poll_interval: int = 3
711
+
712
+ # Outlook 配置
713
+ outlook_provider_priority: List[str] = ["imap_old", "imap_new", "graph_api"]
714
+ outlook_health_failure_threshold: int = 5
715
+ outlook_health_disable_duration: int = 60
716
+ outlook_default_client_id: str = "24d9a0ed-8787-4584-883c-2fd79308940a"
717
+
718
+
719
+ # 全局配置实例
720
+ _settings: Optional[Settings] = None
721
+
722
+
723
+ def get_settings() -> Settings:
724
+ """
725
+ 获取全局配置实例(单例模式)
726
+ 完全从数据库加载配置
727
+ """
728
+ global _settings
729
+ if _settings is None:
730
+ # 先初始化默认设置(如果数据库中没有的话)
731
+ init_default_settings()
732
+ # 从数据库加载所有设置
733
+ settings_dict = _load_settings_from_db()
734
+ _settings = Settings(**settings_dict)
735
+ return _settings
736
+
737
+
738
+ def update_settings(**kwargs) -> Settings:
739
+ """
740
+ 更新配置并保存到数据库
741
+ """
742
+ global _settings
743
+ if _settings is None:
744
+ _settings = get_settings()
745
+
746
+ # 创建新的配置实例
747
+ updated_data = _settings.model_dump()
748
+ updated_data.update(kwargs)
749
+ _settings = Settings(**updated_data)
750
+
751
+ # 保存到数据库
752
+ _save_settings_to_db(**kwargs)
753
+
754
+ return _settings
755
+
756
+
757
+ def reload_settings() -> Settings:
758
+ """清空缓存并从数据库重新加载配置。"""
759
+ global _settings
760
+ _settings = None
761
+ return get_settings()
762
+
763
+
764
+ def get_database_url() -> str:
765
+ """
766
+ 获取数据库 URL(处理相对路径)
767
+ """
768
+ settings = get_settings()
769
+ url = settings.database_url
770
+
771
+ # 如果 URL 是相对路径,转换为绝对路径
772
+ if url.startswith("sqlite:///"):
773
+ path = url[10:] # 移除 "sqlite:///"
774
+ if not os.path.isabs(path):
775
+ # 转换为相对于项目根目录的路径
776
+ project_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
777
+ abs_path = os.path.join(project_root, path)
778
+ return f"sqlite:///{abs_path}"
779
+
780
+ return url
781
+
782
+
783
+ def get_setting_definition(attr_name: str) -> Optional[SettingDefinition]:
784
+ """获取设置项的定义信息"""
785
+ return SETTING_DEFINITIONS.get(attr_name)
786
+
787
+
788
+ def get_all_setting_definitions() -> Dict[str, SettingDefinition]:
789
+ """获取所有设置项的定义"""
790
+ return SETTING_DEFINITIONS.copy()
src/core/__init__.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 核心功能模块
3
+ """
4
+
5
+ from .openai.oauth import OAuthManager, OAuthStart, generate_oauth_url, submit_callback_url
6
+ from .http_client import (
7
+ OpenAIHTTPClient,
8
+ HTTPClient,
9
+ HTTPClientError,
10
+ RequestConfig,
11
+ create_http_client,
12
+ create_openai_client,
13
+ )
14
+ from .register import RegistrationEngine, RegistrationResult
15
+ from .utils import setup_logging, get_data_dir
16
+
17
+ __all__ = [
18
+ 'OAuthManager',
19
+ 'OAuthStart',
20
+ 'generate_oauth_url',
21
+ 'submit_callback_url',
22
+ 'OpenAIHTTPClient',
23
+ 'HTTPClient',
24
+ 'HTTPClientError',
25
+ 'RequestConfig',
26
+ 'create_http_client',
27
+ 'create_openai_client',
28
+ 'RegistrationEngine',
29
+ 'RegistrationResult',
30
+ 'setup_logging',
31
+ 'get_data_dir',
32
+ ]
src/core/dynamic_proxy.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 动态代理获取模块
3
+ 支持通过外部 API 获取动态代理 URL
4
+ """
5
+
6
+ import logging
7
+ import re
8
+ from typing import Optional
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def fetch_dynamic_proxy(api_url: str, api_key: str = "", api_key_header: str = "X-API-Key", result_field: str = "") -> Optional[str]:
14
+ """
15
+ 从代理 API 获取代理 URL
16
+
17
+ Args:
18
+ api_url: 代理 API 地址,响应应为代理 URL 字符串或含代理 URL 的 JSON
19
+ api_key: API 密钥(可选)
20
+ api_key_header: API 密钥请求头名称
21
+ result_field: 从 JSON 响应中提取代理 URL 的字段路径,支持点号分隔(如 "data.proxy"),留空则使用响应原文
22
+
23
+ Returns:
24
+ 代理 URL 字符串(如 http://user:pass@host:port),失败返回 None
25
+ """
26
+ try:
27
+ from curl_cffi import requests as cffi_requests
28
+
29
+ headers = {}
30
+ if api_key:
31
+ headers[api_key_header] = api_key
32
+
33
+ response = cffi_requests.get(
34
+ api_url,
35
+ headers=headers,
36
+ timeout=10,
37
+ impersonate="chrome110"
38
+ )
39
+
40
+ if response.status_code != 200:
41
+ logger.warning(f"动态代理 API 返回错误状态码: {response.status_code}")
42
+ return None
43
+
44
+ text = response.text.strip()
45
+
46
+ # 尝试解析 JSON
47
+ if result_field or text.startswith("{") or text.startswith("["):
48
+ try:
49
+ import json
50
+ data = json.loads(text)
51
+ if result_field:
52
+ # 按点号路径逐层提取
53
+ for key in result_field.split("."):
54
+ if isinstance(data, dict):
55
+ data = data.get(key)
56
+ elif isinstance(data, list) and key.isdigit():
57
+ data = data[int(key)]
58
+ else:
59
+ data = None
60
+ if data is None:
61
+ break
62
+ proxy_url = str(data).strip() if data is not None else None
63
+ else:
64
+ # 无指定字段,尝试常见键名
65
+ for key in ("proxy", "url", "proxy_url", "data", "ip"):
66
+ val = data.get(key) if isinstance(data, dict) else None
67
+ if val:
68
+ proxy_url = str(val).strip()
69
+ break
70
+ else:
71
+ proxy_url = text
72
+ except (ValueError, AttributeError):
73
+ proxy_url = text
74
+ else:
75
+ proxy_url = text
76
+
77
+ if not proxy_url:
78
+ logger.warning("动态代理 API 返回空代理 URL")
79
+ return None
80
+
81
+ # 若未包含协议头,默认加 http://
82
+ if not re.match(r'^(http|socks5)://', proxy_url):
83
+ proxy_url = "http://" + proxy_url
84
+
85
+ logger.info(f"动态代理获取成功: {proxy_url[:40]}..." if len(proxy_url) > 40 else f"动态代理获取成功: {proxy_url}")
86
+ return proxy_url
87
+
88
+ except Exception as e:
89
+ logger.error(f"获取动态代理失败: {e}")
90
+ return None
91
+
92
+
93
+ def get_proxy_url_for_task() -> Optional[str]:
94
+ """
95
+ 为注册任务获取代理 URL。
96
+ 优先使用动态代理(若启用),否则使用静态代理配置。
97
+
98
+ Returns:
99
+ 代理 URL 或 None
100
+ """
101
+ from ..config.settings import get_settings
102
+ settings = get_settings()
103
+
104
+ # 优先使用动态代理
105
+ if settings.proxy_dynamic_enabled and settings.proxy_dynamic_api_url:
106
+ api_key = settings.proxy_dynamic_api_key.get_secret_value() if settings.proxy_dynamic_api_key else ""
107
+ proxy_url = fetch_dynamic_proxy(
108
+ api_url=settings.proxy_dynamic_api_url,
109
+ api_key=api_key,
110
+ api_key_header=settings.proxy_dynamic_api_key_header,
111
+ result_field=settings.proxy_dynamic_result_field,
112
+ )
113
+ if proxy_url:
114
+ return proxy_url
115
+ logger.warning("动态代理获取失败,回退到静态代理")
116
+
117
+ # 使用静态代理
118
+ return settings.proxy_url
src/core/email_service_catalog.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Helpers to build email-service catalog payloads in a reusable way."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Callable, Dict, Iterable, Optional
6
+
7
+ from ..config.settings import get_settings
8
+ from ..database import crud
9
+ from ..database.session import get_db
10
+ from ..services.temp_mail import TempMailService
11
+ from ..services.temp_mail_domain_provider import summarize_temp_mail_domains
12
+
13
+
14
+ DomainFetcher = Callable[[], Any]
15
+ _TEMP_MAIL_ORDER = ["outlook", "moe_mail", "temp_mail", "duck_mail", "freemail", "imap_mail"]
16
+
17
+
18
+ def build_temp_mail_service_entry(
19
+ service: Any,
20
+ *,
21
+ fetch_domains: Optional[DomainFetcher] = None,
22
+ preview_limit: int = 3,
23
+ ) -> Dict[str, Any]:
24
+ """Build one temp_mail service item with safe domain summary."""
25
+ config = getattr(service, "config", None) or {}
26
+ summary = summarize_temp_mail_domains(
27
+ config,
28
+ fetch_domains=fetch_domains,
29
+ preview_limit=preview_limit,
30
+ )
31
+ summary.pop("domains", None)
32
+
33
+ return {
34
+ "id": getattr(service, "id", None),
35
+ "name": getattr(service, "name", None),
36
+ "type": "temp_mail",
37
+ "priority": getattr(service, "priority", 0),
38
+ **summary,
39
+ }
40
+
41
+
42
+ def build_temp_mail_catalog(
43
+ services: Iterable[Any],
44
+ *,
45
+ fetch_domains_by_service: Optional[Callable[[Any], Optional[DomainFetcher]]] = None,
46
+ preview_limit: int = 3,
47
+ ) -> Dict[str, Any]:
48
+ """Build a compact temp_mail catalog block for capabilities APIs."""
49
+ entries = []
50
+ for service in services:
51
+ fetcher = fetch_domains_by_service(service) if fetch_domains_by_service else None
52
+ entries.append(
53
+ build_temp_mail_service_entry(
54
+ service,
55
+ fetch_domains=fetcher,
56
+ preview_limit=preview_limit,
57
+ )
58
+ )
59
+
60
+ return {
61
+ "available": len(entries) > 0,
62
+ "count": len(entries),
63
+ "services": entries,
64
+ }
65
+
66
+
67
+ def _safe_temp_mail_fetcher(service: Any) -> Optional[DomainFetcher]:
68
+ config = (getattr(service, "config", None) or {}).copy()
69
+ try:
70
+ temp_mail = TempMailService(config=config, name=getattr(service, "name", None))
71
+ except Exception:
72
+ return None
73
+
74
+ def _fetch() -> Any:
75
+ return temp_mail._fetch_domains_from_worker() # noqa: SLF001 - internal reuse for summary
76
+
77
+ return _fetch
78
+
79
+
80
+ def _generic_email_service_entry(service: Any) -> Dict[str, Any]:
81
+ return {
82
+ "id": getattr(service, "id", None),
83
+ "name": getattr(service, "name", None),
84
+ "type": getattr(service, "service_type", None),
85
+ "priority": getattr(service, "priority", 0),
86
+ }
87
+
88
+
89
+ def _provider_payload(provider: str, services: Iterable[Any]) -> Dict[str, Any]:
90
+ items = [
91
+ {"id": svc.id, "name": svc.name, "priority": svc.priority}
92
+ for svc in services
93
+ ]
94
+ return {
95
+ "provider": provider,
96
+ "available": len(items) > 0,
97
+ "count": len(items),
98
+ "services": items,
99
+ }
100
+
101
+
102
+ def build_external_capabilities() -> Dict[str, Any]:
103
+ """Build external API capabilities payload from current DB/settings state."""
104
+ settings = get_settings()
105
+
106
+ with get_db() as db:
107
+ enabled_email_services = crud.get_email_services(db, enabled=True, limit=1000)
108
+ grouped: Dict[str, list[Any]] = {}
109
+ for service in enabled_email_services:
110
+ grouped.setdefault(service.service_type, []).append(service)
111
+
112
+ email_types = []
113
+
114
+ ordered_types = [service_type for service_type in _TEMP_MAIL_ORDER if service_type in grouped]
115
+ remaining_types = sorted(service_type for service_type in grouped.keys() if service_type not in _TEMP_MAIL_ORDER)
116
+
117
+ for service_type in [*ordered_types, *remaining_types]:
118
+ services = grouped.get(service_type, [])
119
+ if service_type == "temp_mail":
120
+ email_types.append(
121
+ {
122
+ "type": service_type,
123
+ **build_temp_mail_catalog(
124
+ services,
125
+ fetch_domains_by_service=_safe_temp_mail_fetcher,
126
+ ),
127
+ }
128
+ )
129
+ else:
130
+ entries = [_generic_email_service_entry(svc) for svc in services]
131
+ email_types.append(
132
+ {
133
+ "type": service_type,
134
+ "available": len(entries) > 0,
135
+ "count": len(entries),
136
+ "services": entries,
137
+ }
138
+ )
139
+
140
+ upload_providers = [
141
+ _provider_payload("cpa", crud.get_cpa_services(db, enabled=True)),
142
+ _provider_payload("sub2api", crud.get_sub2api_services(db, enabled=True)),
143
+ _provider_payload("tm", crud.get_tm_services(db, enabled=True)),
144
+ ]
145
+
146
+ return {
147
+ "email_types": email_types,
148
+ "upload_providers": upload_providers,
149
+ "settings": {
150
+ "external_api_enabled": getattr(settings, "external_api_enabled", False),
151
+ },
152
+ }
src/core/external_batches/__init__.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+
2
+ from .service import ExternalBatchCreateRequest, ExternalBatchService
3
+ from .recovery import recover_interrupted_external_batches
4
+
5
+ __all__ = [
6
+ 'ExternalBatchCreateRequest',
7
+ 'ExternalBatchService',
8
+ 'recover_interrupted_external_batches',
9
+ ]
src/core/external_batches/recovery.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ from __future__ import annotations
3
+
4
+ from datetime import datetime
5
+
6
+ from sqlalchemy.orm import Session
7
+
8
+ from ...database import crud, external_batch_crud
9
+
10
+
11
+ def recover_interrupted_external_batches(db: Session) -> int:
12
+ recovered = 0
13
+ for batch in external_batch_crud.list_recoverable_batches(db):
14
+ items = external_batch_crud.list_batch_items(db, batch.batch_uuid)
15
+ for item in items:
16
+ if item.status in {'pending', 'running'}:
17
+ external_batch_crud.update_batch_item(
18
+ db,
19
+ item.id,
20
+ status='failed',
21
+ failure_reason='service_restarted',
22
+ upload_status='failed' if item.upload_status == 'pending' else item.upload_status,
23
+ completed_at=datetime.utcnow(),
24
+ )
25
+ task = crud.get_registration_task_by_uuid(db, item.registration_task_uuid) if item.registration_task_uuid else None
26
+ if task and task.status in {'pending', 'running'}:
27
+ task.status = 'failed'
28
+ task.error_message = 'service_restarted'
29
+ task.completed_at = datetime.utcnow()
30
+ external_batch_crud.update_batch(
31
+ db,
32
+ batch.batch_uuid,
33
+ status='failed',
34
+ failure_reason='service_restarted',
35
+ failure_category='transient',
36
+ completed_at=datetime.utcnow(),
37
+ )
38
+ recovered += 1
39
+ db.commit()
40
+ return recovered
src/core/external_batches/service.py ADDED
@@ -0,0 +1,448 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ from dataclasses import asdict
6
+ from datetime import datetime
7
+ from typing import Optional
8
+ from uuid import uuid4
9
+
10
+ from pydantic import BaseModel
11
+ from sqlalchemy.exc import IntegrityError
12
+ from sqlalchemy.orm import Session
13
+
14
+ from ...database import crud, external_batch_crud
15
+ from ...database.models import Account, ExternalRegistrationBatchItem, RegistrationTask
16
+ from ..registration_upload import upload_registered_account
17
+ from ..service_selection import UploadTarget, build_email_item_assignments, resolve_upload_target
18
+
19
+
20
+ TERMINAL_ITEM_STATUSES = {'completed', 'failed', 'cancelled'}
21
+ CONFIG_FAILURE_PREFIXES = (
22
+ 'unsupported upload provider:',
23
+ 'no enabled upload services for provider ',
24
+ 'no enabled email services for type ',
25
+ )
26
+ BUSINESS_FAILURE_REASONS = {
27
+ 'outlook requested_service_id cannot be reused when count > 1',
28
+ }
29
+ TRANSIENT_FAILURE_REASONS = {
30
+ 'service_restarted',
31
+ 'no_available_email_service',
32
+ 'registration_failed',
33
+ }
34
+
35
+
36
+ class ExternalBatchCreateRequest(BaseModel):
37
+ count: int
38
+ idempotency_key: Optional[str] = None
39
+ email_type: str
40
+ email_service_id: Optional[int] = None
41
+ upload_enabled: bool = False
42
+ upload_provider: Optional[str] = None
43
+ upload_service_id: Optional[int] = None
44
+ mode: str = 'pipeline'
45
+ concurrency: int = 1
46
+ interval_min: int = 5
47
+ interval_max: int = 30
48
+
49
+
50
+ class ExternalBatchService:
51
+ @staticmethod
52
+ def _mark_idempotent_replay(batch):
53
+ if batch is not None:
54
+ setattr(batch, '_idempotent_replay', True)
55
+ return batch
56
+
57
+ @staticmethod
58
+ def _detach_batch(db: Session, batch):
59
+ if batch is None:
60
+ return None
61
+ db.refresh(batch)
62
+ db.expunge(batch)
63
+ return batch
64
+
65
+ def create_batch(self, db: Session, request: ExternalBatchCreateRequest):
66
+ if request.idempotency_key:
67
+ existing = external_batch_crud.get_batch_by_idempotency_key(db, request.idempotency_key)
68
+ if existing:
69
+ return self._mark_idempotent_replay(self._detach_batch(db, existing))
70
+
71
+ upload_target = None
72
+ if request.upload_enabled:
73
+ upload_target = resolve_upload_target(
74
+ db,
75
+ provider=request.upload_provider or '',
76
+ requested_service_id=request.upload_service_id,
77
+ )
78
+
79
+ assignments = build_email_item_assignments(
80
+ db,
81
+ email_type=request.email_type,
82
+ count=request.count,
83
+ requested_service_id=request.email_service_id,
84
+ )
85
+
86
+ batch_uuid = str(uuid4())
87
+ batch = external_batch_crud.create_batch(
88
+ db,
89
+ batch_uuid=batch_uuid,
90
+ idempotency_key=request.idempotency_key,
91
+ status='pending',
92
+ email_service_type=request.email_type,
93
+ upload_enabled=request.upload_enabled,
94
+ upload_provider=upload_target.provider if upload_target else None,
95
+ requested_count=request.count,
96
+ request_payload=request.model_dump(),
97
+ runtime_snapshot={
98
+ 'email_assignments': [asdict(assignment) for assignment in assignments],
99
+ 'upload_target': asdict(upload_target) if upload_target else None,
100
+ },
101
+ recent_errors=[],
102
+ )
103
+
104
+ for assignment in assignments:
105
+ task_uuid = str(uuid4())
106
+ db.add(
107
+ RegistrationTask(
108
+ task_uuid=task_uuid,
109
+ status='pending',
110
+ email_service_id=assignment.service_id,
111
+ )
112
+ )
113
+ db.flush()
114
+ external_batch_crud.create_batch_item(
115
+ db,
116
+ batch_id=batch.id,
117
+ item_index=assignment.item_index,
118
+ status='pending',
119
+ failure_reason=assignment.failure_reason,
120
+ registration_task_uuid=task_uuid,
121
+ selected_email_service_id=assignment.service_id,
122
+ upload_status='pending' if request.upload_enabled and not assignment.failure_reason else ('not_enabled' if not request.upload_enabled else 'skipped'),
123
+ )
124
+
125
+ try:
126
+ db.commit()
127
+ except IntegrityError:
128
+ db.rollback()
129
+ if request.idempotency_key:
130
+ existing = external_batch_crud.get_batch_by_idempotency_key(db, request.idempotency_key)
131
+ if existing:
132
+ return self._mark_idempotent_replay(self._detach_batch(db, existing))
133
+ raise
134
+
135
+ return self._detach_batch(db, external_batch_crud.get_batch_by_uuid(db, batch_uuid))
136
+
137
+ @staticmethod
138
+ def _classify_failure_category(failure_reason: Optional[str]) -> str:
139
+ reason = (failure_reason or '').strip().lower()
140
+ if not reason:
141
+ return 'transient'
142
+ if reason in BUSINESS_FAILURE_REASONS:
143
+ return 'business'
144
+ if reason in TRANSIENT_FAILURE_REASONS:
145
+ return 'transient'
146
+ if reason.startswith(CONFIG_FAILURE_PREFIXES):
147
+ return 'config'
148
+ if reason.startswith('upload service ') and (
149
+ ' not found' in reason
150
+ or ' is disabled' in reason
151
+ or ' does not belong to provider ' in reason
152
+ ):
153
+ return 'config'
154
+ if reason.startswith('email service ') and (
155
+ ' not found' in reason
156
+ or ' is disabled' in reason
157
+ or ' does not belong to type ' in reason
158
+ ):
159
+ return 'config'
160
+ return 'transient'
161
+
162
+ @staticmethod
163
+ def _select_failure_reason(batch, items) -> Optional[str]:
164
+ if batch.failure_reason:
165
+ return batch.failure_reason
166
+ for item in items:
167
+ if item.failure_reason:
168
+ return item.failure_reason
169
+ for item in items:
170
+ if item.upload_status == 'failed' and item.upload_error:
171
+ return item.upload_error
172
+ return None
173
+
174
+ def recompute_summary(self, db: Session, batch_uuid: str):
175
+ batch = external_batch_crud.get_batch_by_uuid(db, batch_uuid)
176
+ if not batch:
177
+ raise ValueError(f'batch not found: {batch_uuid}')
178
+
179
+ items = external_batch_crud.list_batch_items(db, batch_uuid)
180
+ completed_count = sum(1 for item in items if item.status in TERMINAL_ITEM_STATUSES)
181
+ success_count = sum(1 for item in items if item.status == 'completed')
182
+ failed_count = sum(1 for item in items if item.status == 'failed')
183
+ cancelled_count = sum(1 for item in items if item.status == 'cancelled')
184
+ upload_success_count = sum(1 for item in items if item.upload_status == 'success')
185
+ upload_failed_count = sum(1 for item in items if item.upload_status == 'failed')
186
+
187
+ recent_errors = []
188
+ for item in items:
189
+ if item.failure_reason:
190
+ recent_errors.append({'item_index': item.item_index, 'reason': item.failure_reason})
191
+ elif item.upload_status == 'failed' and item.upload_error:
192
+ recent_errors.append({'item_index': item.item_index, 'reason': item.upload_error})
193
+
194
+ if completed_count < batch.requested_count:
195
+ status = 'running' if batch.started_at else 'pending'
196
+ completed_at = None
197
+ elif batch.cancel_requested or cancelled_count:
198
+ status = 'cancelled'
199
+ completed_at = datetime.utcnow()
200
+ elif batch.failure_reason == 'service_restarted':
201
+ status = 'failed'
202
+ completed_at = datetime.utcnow()
203
+ elif success_count == batch.requested_count and failed_count == 0:
204
+ status = 'completed'
205
+ completed_at = datetime.utcnow()
206
+ elif success_count > 0:
207
+ status = 'completed_partial'
208
+ completed_at = datetime.utcnow()
209
+ else:
210
+ status = 'failed'
211
+ completed_at = datetime.utcnow()
212
+
213
+ failure_category = None
214
+ if status == 'failed':
215
+ failure_category = self._classify_failure_category(self._select_failure_reason(batch, items))
216
+
217
+ updated = external_batch_crud.update_batch(
218
+ db,
219
+ batch_uuid,
220
+ status=status,
221
+ completed_count=completed_count,
222
+ success_count=success_count,
223
+ failed_count=failed_count,
224
+ upload_success_count=upload_success_count,
225
+ upload_failed_count=upload_failed_count,
226
+ recent_errors=recent_errors[-20:],
227
+ completed_at=completed_at,
228
+ failure_category=failure_category,
229
+ )
230
+ db.commit()
231
+ return self._detach_batch(db, updated)
232
+
233
+ def ensure_failure_category(self, db: Session, batch):
234
+ if batch is None:
235
+ return None
236
+ if batch.status != 'failed' or batch.failure_category:
237
+ return batch
238
+ items = external_batch_crud.list_batch_items(db, batch.batch_uuid)
239
+ updated = external_batch_crud.update_batch(
240
+ db,
241
+ batch.batch_uuid,
242
+ failure_category=self._classify_failure_category(self._select_failure_reason(batch, items)),
243
+ )
244
+ db.commit()
245
+ return self._detach_batch(db, updated)
246
+
247
+ async def run_batch(self, batch_uuid: str):
248
+ from ...database.session import get_db
249
+ from ...web.routes.registration import run_registration_task
250
+
251
+ with get_db() as db:
252
+ batch = external_batch_crud.update_batch(db, batch_uuid, status='running', started_at=datetime.utcnow())
253
+ db.commit()
254
+ runtime_snapshot = (batch.runtime_snapshot or {}).copy()
255
+ upload_target_data = runtime_snapshot.get('upload_target')
256
+ request_payload = batch.request_payload or {}
257
+ mode = request_payload.get('mode', 'pipeline')
258
+ concurrency = int(request_payload.get('concurrency', 1) or 1)
259
+ items = external_batch_crud.list_batch_items(db, batch_uuid)
260
+ email_service_type = batch.email_service_type
261
+
262
+ upload_target = UploadTarget(**upload_target_data) if upload_target_data else None
263
+
264
+ async def execute_item(item_id: int):
265
+ with get_db() as db:
266
+ item = db.query(ExternalRegistrationBatchItem).filter(ExternalRegistrationBatchItem.id == item_id).first()
267
+ batch = external_batch_crud.get_batch_by_uuid(db, batch_uuid)
268
+ if not item or not batch:
269
+ return
270
+ if batch.cancel_requested:
271
+ external_batch_crud.update_batch_item(db, item.id, status='cancelled', upload_status='skipped', completed_at=datetime.utcnow())
272
+ task = crud.get_registration_task_by_uuid(db, item.registration_task_uuid)
273
+ if task:
274
+ task.status = 'cancelled'
275
+ task.completed_at = datetime.utcnow()
276
+ db.commit()
277
+ self.recompute_summary(db, batch_uuid)
278
+ return
279
+ if item.failure_reason:
280
+ external_batch_crud.update_batch_item(db, item.id, status='failed', completed_at=datetime.utcnow())
281
+ task = crud.get_registration_task_by_uuid(db, item.registration_task_uuid)
282
+ if task:
283
+ task.status = 'failed'
284
+ task.error_message = item.failure_reason
285
+ task.completed_at = datetime.utcnow()
286
+ db.commit()
287
+ self.recompute_summary(db, batch_uuid)
288
+ return
289
+
290
+ external_batch_crud.update_batch_item(db, item.id, status='running', started_at=datetime.utcnow())
291
+ selected_service_id = item.selected_email_service_id
292
+ db.commit()
293
+
294
+ await run_registration_task(
295
+ item.registration_task_uuid,
296
+ email_service_type,
297
+ None,
298
+ None,
299
+ selected_service_id,
300
+ '[external]',
301
+ batch_uuid,
302
+ False,
303
+ [],
304
+ False,
305
+ [],
306
+ False,
307
+ [],
308
+ )
309
+
310
+ with get_db() as db:
311
+ item = db.query(ExternalRegistrationBatchItem).filter(ExternalRegistrationBatchItem.id == item_id).first()
312
+ task = crud.get_registration_task_by_uuid(db, item.registration_task_uuid)
313
+ item_status = 'completed' if task and task.status == 'completed' else ('cancelled' if task and task.status == 'cancelled' else 'failed')
314
+ update_kwargs = {'status': item_status, 'completed_at': datetime.utcnow()}
315
+ if item_status == 'failed':
316
+ update_kwargs['failure_reason'] = (task.error_message if task and task.error_message else 'registration_failed')
317
+ external_batch_crud.update_batch_item(db, item.id, **update_kwargs)
318
+
319
+ if item_status == 'completed' and upload_target is not None:
320
+ email = task.result.get('email') if task and task.result else None
321
+ account = None
322
+ if email:
323
+ account = db.query(Account).filter(Account.email == email).order_by(Account.id.desc()).first()
324
+ if account is None and task and task.result:
325
+ account = db.query(Account).filter(Account.account_id == task.result.get('account_id')).order_by(Account.id.desc()).first()
326
+ upload_result = upload_registered_account(account, upload_target)
327
+ external_batch_crud.update_batch_item(
328
+ db,
329
+ item.id,
330
+ upload_status='success' if upload_result.success else 'failed',
331
+ upload_error=None if upload_result.success else upload_result.message,
332
+ )
333
+ db.commit()
334
+ self.recompute_summary(db, batch_uuid)
335
+
336
+ if mode == 'parallel':
337
+ semaphore = asyncio.Semaphore(max(1, concurrency))
338
+
339
+ async def guarded(item_id: int):
340
+ async with semaphore:
341
+ await execute_item(item_id)
342
+
343
+ await asyncio.gather(*(guarded(item.id) for item in items))
344
+ else:
345
+ for item in items:
346
+ await execute_item(item.id)
347
+
348
+ with get_db() as db:
349
+ batch = external_batch_crud.get_batch_by_uuid(db, batch_uuid)
350
+ if batch and batch.cancel_requested:
351
+ external_batch_crud.update_batch(db, batch_uuid, status='cancelled', completed_at=datetime.utcnow())
352
+ db.commit()
353
+ self.recompute_summary(db, batch_uuid)
354
+
355
+ def request_cancel(self, db: Session, batch_uuid: str):
356
+ batch = external_batch_crud.get_batch_by_uuid(db, batch_uuid)
357
+ if not batch:
358
+ return None
359
+ if batch.status not in {'pending', 'running'}:
360
+ raise ValueError('batch is already finished')
361
+ updated = external_batch_crud.update_batch(db, batch_uuid, cancel_requested=True)
362
+ db.commit()
363
+ return self._detach_batch(db, updated)
364
+
365
+
366
+ def _serialize_batch(batch) -> dict:
367
+ return {
368
+ 'batch_uuid': batch.batch_uuid,
369
+ 'status': batch.status,
370
+ 'requested_count': batch.requested_count,
371
+ 'completed_count': batch.completed_count,
372
+ 'success_count': batch.success_count,
373
+ 'failed_count': batch.failed_count,
374
+ 'upload_success_count': batch.upload_success_count,
375
+ 'upload_failed_count': batch.upload_failed_count,
376
+ 'failure_reason': batch.failure_reason,
377
+ 'failure_category': batch.failure_category,
378
+ 'created_at': batch.created_at.isoformat() if batch.created_at else None,
379
+ 'started_at': batch.started_at.isoformat() if batch.started_at else None,
380
+ 'completed_at': batch.completed_at.isoformat() if batch.completed_at else None,
381
+ 'recent_errors': batch.recent_errors or [],
382
+ }
383
+
384
+
385
+ def _request_from_payload(payload: dict) -> ExternalBatchCreateRequest:
386
+ email = payload.get('email') or {}
387
+ upload = payload.get('upload') or {}
388
+ execution = payload.get('execution') or {}
389
+ return ExternalBatchCreateRequest(
390
+ count=payload['count'],
391
+ idempotency_key=payload.get('idempotency_key'),
392
+ email_type=email.get('type'),
393
+ email_service_id=email.get('service_id'),
394
+ upload_enabled=bool(upload.get('enabled')),
395
+ upload_provider=upload.get('provider'),
396
+ upload_service_id=upload.get('service_id'),
397
+ mode=execution.get('mode', 'pipeline'),
398
+ concurrency=execution.get('concurrency', 1),
399
+ interval_min=execution.get('interval_min', 5),
400
+ interval_max=execution.get('interval_max', 30),
401
+ )
402
+
403
+
404
+ def create_external_registration_batch(payload: dict, background_tasks=None) -> dict:
405
+ from ...database.session import get_db
406
+
407
+ request = _request_from_payload(payload)
408
+ service = ExternalBatchService()
409
+ is_replay = False
410
+ with get_db() as db:
411
+ existing = external_batch_crud.get_batch_by_idempotency_key(db, request.idempotency_key) if request.idempotency_key else None
412
+ if existing is not None:
413
+ existing = service.ensure_failure_category(db, existing)
414
+ response = _serialize_batch(existing)
415
+ response['idempotent_replay'] = True
416
+ return response
417
+ batch = service.create_batch(db, request)
418
+ is_replay = bool(getattr(batch, '_idempotent_replay', False))
419
+ if is_replay:
420
+ batch = service.ensure_failure_category(db, batch)
421
+ if background_tasks is not None:
422
+ background_tasks.add_task(service.run_batch, batch.batch_uuid)
423
+ response = _serialize_batch(batch)
424
+ response['idempotent_replay'] = is_replay
425
+ return response
426
+
427
+
428
+ def get_external_registration_batch_status(batch_uuid: str) -> dict:
429
+ from ...database.session import get_db
430
+
431
+ service = ExternalBatchService()
432
+ with get_db() as db:
433
+ batch = external_batch_crud.get_batch_by_uuid(db, batch_uuid)
434
+ if batch is None:
435
+ raise ValueError('batch_not_found')
436
+ batch = service.ensure_failure_category(db, batch)
437
+ return _serialize_batch(batch)
438
+
439
+
440
+ def cancel_external_registration_batch(batch_uuid: str) -> dict:
441
+ from ...database.session import get_db
442
+
443
+ service = ExternalBatchService()
444
+ with get_db() as db:
445
+ batch = service.request_cancel(db, batch_uuid)
446
+ if batch is None:
447
+ raise ValueError('batch_not_found')
448
+ return _serialize_batch(batch)
src/core/fetch_logs.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import paramiko
2
+ import sys
3
+
4
+ def fetch_logs(ip, password, lines=1000):
5
+ print(f"\n--- Fetching logs from {ip} ---")
6
+ try:
7
+ ssh = paramiko.SSHClient()
8
+ ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
9
+ ssh.connect(ip, username='root', password=password, timeout=10)
10
+
11
+ # Get the current time on the server to provide context
12
+ stdin, stdout, stderr = ssh.exec_command('date')
13
+ print(f"Server Time: {stdout.read().decode('utf-8').strip()}")
14
+
15
+ # Fetch docker logs
16
+ cmd = f"docker logs --tail {lines} codex-console-webui-1 2>&1 | grep -E 'WARN|ERROR|注册|about-you|login_flow_failed|cookie|token|callback'"
17
+ stdin, stdout, stderr = ssh.exec_command(cmd)
18
+ out = stdout.read().decode('utf-8').strip()
19
+
20
+ if out:
21
+ filename = f"logs_{ip}.txt"
22
+ with open(filename, "w", encoding="utf-8") as f:
23
+ f.write(out)
24
+ print(f"Saved {len(out.splitlines())} lines to {filename}")
25
+ else:
26
+ print("No matching logs found.")
27
+
28
+ ssh.close()
29
+ except Exception as e:
30
+ print(f"Error checking {ip}: {e}")
31
+
32
+ fetch_logs('198.46.152.138', 's3IEn8ClaxA4H99Zv7', 5000)
33
+ fetch_logs('23.140.140.70', '33nKDS80L6ipmedJ3y0j', 5000)
src/core/http_client.py ADDED
@@ -0,0 +1,436 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ HTTP 客户端封装
3
+ 基于 curl_cffi 的 HTTP 请求封装,支持代理和错误处理
4
+ """
5
+
6
+ import time
7
+ import json
8
+ from typing import Optional, Dict, Any, Union, Tuple
9
+ from dataclasses import dataclass
10
+ import logging
11
+
12
+ from curl_cffi import requests as cffi_requests
13
+ from curl_cffi.requests import Session, Response
14
+
15
+ from ..config.constants import ERROR_MESSAGES
16
+ from ..config.settings import get_settings
17
+ from .openai.sentinel import SentinelPOWError, build_sentinel_pow_token
18
+ from .openai.browser_profile import BrowserProfile, get_random_profile
19
+
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ @dataclass
25
+ class RequestConfig:
26
+ """HTTP 请求配置"""
27
+ timeout: int = 30
28
+ max_retries: int = 3
29
+ retry_delay: float = 1.0
30
+ impersonate: str = "chrome"
31
+ verify_ssl: bool = True
32
+ follow_redirects: bool = True
33
+
34
+
35
+ class HTTPClientError(Exception):
36
+ """HTTP 客户端异常"""
37
+ pass
38
+
39
+
40
+ class HTTPClient:
41
+ """
42
+ HTTP 客户端封装
43
+ 支持代理、重试、错误处理和会话管理
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ proxy_url: Optional[str] = None,
49
+ config: Optional[RequestConfig] = None,
50
+ session: Optional[Session] = None
51
+ ):
52
+ """
53
+ 初始化 HTTP 客户端
54
+
55
+ Args:
56
+ proxy_url: 代理 URL,如 "http://127.0.0.1:7890"
57
+ config: 请求配置
58
+ session: 可重用的会话对象
59
+ """
60
+ self.proxy_url = proxy_url
61
+ self.config = config or RequestConfig()
62
+ self._session = session
63
+
64
+ @property
65
+ def proxies(self) -> Optional[Dict[str, str]]:
66
+ """获取代理配置"""
67
+ if not self.proxy_url:
68
+ return None
69
+ return {
70
+ "http": self.proxy_url,
71
+ "https": self.proxy_url,
72
+ }
73
+
74
+ @property
75
+ def session(self) -> Session:
76
+ """获取会话对象(单例)"""
77
+ if self._session is None:
78
+ self._session = Session(
79
+ proxies=self.proxies,
80
+ impersonate=self.config.impersonate,
81
+ verify=self.config.verify_ssl,
82
+ timeout=self.config.timeout
83
+ )
84
+ return self._session
85
+
86
+ def request(
87
+ self,
88
+ method: str,
89
+ url: str,
90
+ **kwargs
91
+ ) -> Response:
92
+ """
93
+ 发送 HTTP 请求
94
+
95
+ Args:
96
+ method: HTTP 方法 (GET, POST, PUT, DELETE, etc.)
97
+ url: 请求 URL
98
+ **kwargs: 其他请求参数
99
+
100
+ Returns:
101
+ Response 对象
102
+
103
+ Raises:
104
+ HTTPClientError: 请求失败
105
+ """
106
+ # 设置默认参数
107
+ kwargs.setdefault("timeout", self.config.timeout)
108
+ kwargs.setdefault("allow_redirects", self.config.follow_redirects)
109
+
110
+ # 添加代理配置
111
+ if self.proxies and "proxies" not in kwargs:
112
+ kwargs["proxies"] = self.proxies
113
+
114
+ last_exception = None
115
+ for attempt in range(self.config.max_retries):
116
+ try:
117
+ response = self.session.request(method, url, **kwargs)
118
+
119
+ # 检查响应状态码
120
+ if response.status_code >= 400:
121
+ logger.warning(
122
+ f"HTTP {response.status_code} for {method} {url}"
123
+ f" (attempt {attempt + 1}/{self.config.max_retries})"
124
+ )
125
+
126
+ # 如果是服务器错误,重试
127
+ if response.status_code >= 500 and attempt < self.config.max_retries - 1:
128
+ time.sleep(self.config.retry_delay * (attempt + 1))
129
+ continue
130
+
131
+ return response
132
+
133
+ except (cffi_requests.RequestsError, ConnectionError, TimeoutError) as e:
134
+ last_exception = e
135
+ logger.warning(
136
+ f"请求失败: {method} {url} (attempt {attempt + 1}/{self.config.max_retries}): {e}"
137
+ )
138
+
139
+ if attempt < self.config.max_retries - 1:
140
+ time.sleep(self.config.retry_delay * (attempt + 1))
141
+ else:
142
+ break
143
+
144
+ raise HTTPClientError(
145
+ f"请求失败,最大重试次数已达: {method} {url} - {last_exception}"
146
+ )
147
+
148
+ def get(self, url: str, **kwargs) -> Response:
149
+ """发送 GET 请求"""
150
+ return self.request("GET", url, **kwargs)
151
+
152
+ def post(self, url: str, data: Any = None, json: Any = None, **kwargs) -> Response:
153
+ """发送 POST 请求"""
154
+ return self.request("POST", url, data=data, json=json, **kwargs)
155
+
156
+ def put(self, url: str, data: Any = None, json: Any = None, **kwargs) -> Response:
157
+ """发送 PUT 请求"""
158
+ return self.request("PUT", url, data=data, json=json, **kwargs)
159
+
160
+ def delete(self, url: str, **kwargs) -> Response:
161
+ """发送 DELETE 请求"""
162
+ return self.request("DELETE", url, **kwargs)
163
+
164
+ def head(self, url: str, **kwargs) -> Response:
165
+ """发送 HEAD 请求"""
166
+ return self.request("HEAD", url, **kwargs)
167
+
168
+ def options(self, url: str, **kwargs) -> Response:
169
+ """发送 OPTIONS 请求"""
170
+ return self.request("OPTIONS", url, **kwargs)
171
+
172
+ def patch(self, url: str, data: Any = None, json: Any = None, **kwargs) -> Response:
173
+ """发送 PATCH 请求"""
174
+ return self.request("PATCH", url, data=data, json=json, **kwargs)
175
+
176
+ def download_file(self, url: str, filepath: str, chunk_size: int = 8192) -> None:
177
+ """
178
+ 下载文件
179
+
180
+ Args:
181
+ url: 文件 URL
182
+ filepath: 保存路径
183
+ chunk_size: 块大小
184
+
185
+ Raises:
186
+ HTTPClientError: 下载失败
187
+ """
188
+ try:
189
+ response = self.get(url, stream=True)
190
+ response.raise_for_status()
191
+
192
+ with open(filepath, 'wb') as f:
193
+ for chunk in response.iter_content(chunk_size=chunk_size):
194
+ if chunk:
195
+ f.write(chunk)
196
+
197
+ except Exception as e:
198
+ raise HTTPClientError(f"下载文件失败: {url} - {e}")
199
+
200
+ def check_proxy(self, test_url: str = "https://httpbin.org/ip") -> bool:
201
+ """
202
+ 检查代理是否可用
203
+
204
+ Args:
205
+ test_url: 测试 URL
206
+
207
+ Returns:
208
+ bool: 代理是否可用
209
+ """
210
+ if not self.proxy_url:
211
+ return False
212
+
213
+ try:
214
+ response = self.get(test_url, timeout=10)
215
+ return response.status_code == 200
216
+ except Exception:
217
+ return False
218
+
219
+ def close(self):
220
+ """关闭会话"""
221
+ if self._session:
222
+ self._session.close()
223
+ self._session = None
224
+
225
+ def __enter__(self):
226
+ return self
227
+
228
+ def __exit__(self, exc_type, exc_val, exc_tb):
229
+ self.close()
230
+
231
+
232
+ class OpenAIHTTPClient(HTTPClient):
233
+ """
234
+ OpenAI 专用 HTTP 客户端
235
+ 包含 OpenAI API 特定的请求方法
236
+ """
237
+
238
+ def __init__(
239
+ self,
240
+ proxy_url: Optional[str] = None,
241
+ config: Optional[RequestConfig] = None,
242
+ profile: Optional[BrowserProfile] = None,
243
+ ):
244
+ """
245
+ 初始化 OpenAI HTTP 客户端
246
+
247
+ Args:
248
+ proxy_url: 代理 URL
249
+ config: 请求配置
250
+ profile: 浏览器指纹 Profile,不传则随机生成
251
+ """
252
+ super().__init__(proxy_url, config)
253
+
254
+ # 浏览器指纹 Profile
255
+ self.profile = profile or get_random_profile()
256
+
257
+ # 用 Profile 的 impersonate 覆盖默认值
258
+ self.config.impersonate = self.profile.impersonate
259
+
260
+ # OpenAI 特定的默认配置
261
+ if config is None:
262
+ self.config.timeout = 30
263
+ self.config.max_retries = 3
264
+
265
+ # 默认请求头(从 Profile 取值,保证一致性)
266
+ self.default_headers = {
267
+ "User-Agent": self.profile.user_agent,
268
+ "Accept": "application/json",
269
+ "Accept-Language": "en-US,en;q=0.9",
270
+ "Accept-Encoding": "gzip, deflate, br",
271
+ "Connection": "keep-alive",
272
+ "sec-ch-ua": self.profile.sec_ch_ua,
273
+ "sec-ch-ua-mobile": self.profile.sec_ch_ua_mobile,
274
+ "sec-ch-ua-platform": self.profile.sec_ch_ua_platform,
275
+ "Sec-Fetch-Dest": "empty",
276
+ "Sec-Fetch-Mode": "cors",
277
+ "Sec-Fetch-Site": "same-site",
278
+ }
279
+
280
+ def check_ip_location(self) -> Tuple[bool, Optional[str]]:
281
+ """
282
+ 检查 IP 地理位置
283
+
284
+ Returns:
285
+ Tuple[是否支持, 位置信息]
286
+ """
287
+ try:
288
+ response = self.get("https://cloudflare.com/cdn-cgi/trace", timeout=10)
289
+ trace_text = response.text
290
+
291
+ # 解析位置信息
292
+ import re
293
+ loc_match = re.search(r"loc=([A-Z]+)", trace_text)
294
+ loc = loc_match.group(1) if loc_match else None
295
+
296
+ # 检查是否支持
297
+ if loc in ["CN", "HK", "MO", "TW"]:
298
+ return False, loc
299
+ return True, loc
300
+
301
+ except Exception as e:
302
+ logger.error(f"检查 IP 地理位置失败: {e}")
303
+ return False, None
304
+
305
+ def send_openai_request(
306
+ self,
307
+ endpoint: str,
308
+ method: str = "POST",
309
+ data: Optional[Dict[str, Any]] = None,
310
+ json_data: Optional[Dict[str, Any]] = None,
311
+ headers: Optional[Dict[str, str]] = None,
312
+ **kwargs
313
+ ) -> Dict[str, Any]:
314
+ """
315
+ 发送 OpenAI API 请求
316
+
317
+ Args:
318
+ endpoint: API 端点
319
+ method: HTTP 方法
320
+ data: 表单数据
321
+ json_data: JSON 数据
322
+ headers: 请求头
323
+ **kwargs: 其他参数
324
+
325
+ Returns:
326
+ 响应 JSON 数据
327
+
328
+ Raises:
329
+ HTTPClientError: 请求失败
330
+ """
331
+ # 合并请求头
332
+ request_headers = self.default_headers.copy()
333
+ if headers:
334
+ request_headers.update(headers)
335
+
336
+ # 设置 Content-Type
337
+ if json_data is not None and "Content-Type" not in request_headers:
338
+ request_headers["Content-Type"] = "application/json"
339
+ elif data is not None and "Content-Type" not in request_headers:
340
+ request_headers["Content-Type"] = "application/x-www-form-urlencoded"
341
+
342
+ try:
343
+ response = self.request(
344
+ method,
345
+ endpoint,
346
+ data=data,
347
+ json=json_data,
348
+ headers=request_headers,
349
+ **kwargs
350
+ )
351
+
352
+ # 检查响应状态码
353
+ response.raise_for_status()
354
+
355
+ # 尝试解析 JSON
356
+ try:
357
+ return response.json()
358
+ except json.JSONDecodeError:
359
+ return {"raw_response": response.text}
360
+
361
+ except cffi_requests.RequestsError as e:
362
+ raise HTTPClientError(f"OpenAI 请求失败: {endpoint} - {e}")
363
+
364
+ def check_sentinel(self, did: str, proxies: Optional[Dict] = None) -> Optional[str]:
365
+ """
366
+ 检查 Sentinel 拦截
367
+
368
+ Args:
369
+ did: Device ID
370
+ proxies: 代理配置
371
+
372
+ Returns:
373
+ Sentinel token 或 None
374
+ """
375
+ from ..config.constants import OPENAI_API_ENDPOINTS
376
+
377
+ try:
378
+ pow_token = build_sentinel_pow_token(self.profile.user_agent)
379
+ sen_req_body = json.dumps({
380
+ "p": pow_token,
381
+ "id": did,
382
+ "flow": "authorize_continue",
383
+ }, separators=(",", ":"))
384
+
385
+ response = self.post(
386
+ OPENAI_API_ENDPOINTS["sentinel"],
387
+ headers=self.profile.sentinel_headers(),
388
+ data=sen_req_body,
389
+ )
390
+
391
+ if response.status_code == 200:
392
+ return response.json().get("token")
393
+ else:
394
+ logger.warning(f"Sentinel 检查失败: {response.status_code}")
395
+ return None
396
+
397
+ except SentinelPOWError as e:
398
+ logger.error(f"Sentinel POW 求解失败: {e}")
399
+ return None
400
+ except Exception as e:
401
+ logger.error(f"Sentinel 检查异常: {e}")
402
+ return None
403
+
404
+
405
+ def create_http_client(
406
+ proxy_url: Optional[str] = None,
407
+ config: Optional[RequestConfig] = None
408
+ ) -> HTTPClient:
409
+ """
410
+ 创建 HTTP 客户端工厂函数
411
+
412
+ Args:
413
+ proxy_url: 代理 URL
414
+ config: 请求配置
415
+
416
+ Returns:
417
+ HTTPClient 实例
418
+ """
419
+ return HTTPClient(proxy_url, config)
420
+
421
+
422
+ def create_openai_client(
423
+ proxy_url: Optional[str] = None,
424
+ config: Optional[RequestConfig] = None
425
+ ) -> OpenAIHTTPClient:
426
+ """
427
+ 创建 OpenAI HTTP 客户端工厂函数
428
+
429
+ Args:
430
+ proxy_url: 代理 URL
431
+ config: 请求配置
432
+
433
+ Returns:
434
+ OpenAIHTTPClient 实例
435
+ """
436
+ return OpenAIHTTPClient(proxy_url, config)
src/core/openai/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # @Time : 2026/3/18 19:55
src/core/openai/browser_profile.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 浏览器指纹 Profile 工厂
3
+
4
+ 提供多版本 Chrome 浏览器指纹配置,确保 TLS impersonate、User-Agent、
5
+ sec-ch-ua 三者版本一致,消除反检测信号。
6
+ """
7
+
8
+ import random
9
+ from dataclasses import dataclass, field
10
+ from typing import Dict, Optional
11
+
12
+
13
+ # Chrome 版本指纹池 — impersonate 标识必须与 curl_cffi 支持的版本匹配
14
+ _CHROME_PROFILES = [
15
+ {
16
+ "major": 131, "impersonate": "chrome131",
17
+ "build": 6778, "patch_range": (69, 205),
18
+ "sec_ch_ua": '"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"',
19
+ },
20
+ {
21
+ "major": 133, "impersonate": "chrome133a",
22
+ "build": 6943, "patch_range": (33, 153),
23
+ "sec_ch_ua": '"Not(A:Brand";v="99", "Google Chrome";v="133", "Chromium";v="133"',
24
+ },
25
+ {
26
+ "major": 136, "impersonate": "chrome136",
27
+ "build": 7103, "patch_range": (48, 175),
28
+ "sec_ch_ua": '"Chromium";v="136", "Google Chrome";v="136", "Not.A/Brand";v="99"',
29
+ },
30
+ ]
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class BrowserProfile:
35
+ """单次会话绑定的浏览器指纹 Profile"""
36
+
37
+ impersonate: str
38
+ major: int
39
+ full_version: str
40
+ user_agent: str
41
+ sec_ch_ua: str
42
+ sec_ch_ua_mobile: str = "?0"
43
+ sec_ch_ua_platform: str = '"Windows"'
44
+
45
+ # ---- 通用 sec-ch-ua 三件套 ----
46
+
47
+ def _ch_ua_headers(self) -> Dict[str, str]:
48
+ """返回 sec-ch-ua 三件套,Chrome 每个 HTTPS 请求默认发送。"""
49
+ return {
50
+ "sec-ch-ua": self.sec_ch_ua,
51
+ "sec-ch-ua-mobile": self.sec_ch_ua_mobile,
52
+ "sec-ch-ua-platform": self.sec_ch_ua_platform,
53
+ }
54
+
55
+ # ---- 页面导航请求 headers(GET HTML 页面)----
56
+
57
+ def navigation_headers(self, referer: Optional[str] = None) -> Dict[str, str]:
58
+ """
59
+ 浏览器导航到页面时的 headers(如 GET auth.openai.com/oauth/authorize)。
60
+ 对应 Sec-Fetch-Mode: navigate。
61
+ """
62
+ headers: Dict[str, str] = {
63
+ "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
64
+ "accept-language": "en-US,en;q=0.9",
65
+ "upgrade-insecure-requests": "1",
66
+ "sec-fetch-dest": "document",
67
+ "sec-fetch-mode": "navigate",
68
+ "sec-fetch-user": "?1",
69
+ "user-agent": self.user_agent,
70
+ }
71
+ headers.update(self._ch_ua_headers())
72
+
73
+ if referer:
74
+ headers["referer"] = referer
75
+ headers["sec-fetch-site"] = "cross-site"
76
+ else:
77
+ headers["sec-fetch-site"] = "none"
78
+
79
+ return headers
80
+
81
+ # ---- JSON API 请求 headers(POST auth.openai.com/api/*)----
82
+
83
+ def json_api_headers(
84
+ self,
85
+ referer: str,
86
+ origin: str = "https://auth.openai.com",
87
+ oai_did: Optional[str] = None,
88
+ ) -> Dict[str, str]:
89
+ """
90
+ JSON API 请求的统一 headers(如 signup、password_verify、validate_otp、create_account)。
91
+ 对应 Sec-Fetch-Mode: cors。
92
+ """
93
+ headers: Dict[str, str] = {
94
+ "accept": "application/json",
95
+ "content-type": "application/json",
96
+ "origin": origin,
97
+ "referer": referer,
98
+ "sec-fetch-dest": "empty",
99
+ "sec-fetch-mode": "cors",
100
+ "sec-fetch-site": "same-origin",
101
+ }
102
+ headers.update(self._ch_ua_headers())
103
+
104
+ if oai_did:
105
+ headers["oai-device-id"] = oai_did
106
+
107
+ return headers
108
+
109
+ # ---- Sentinel 请求 headers ----
110
+
111
+ def sentinel_headers(self) -> Dict[str, str]:
112
+ """
113
+ Sentinel PoW 请求的 headers(POST sentinel.openai.com)。
114
+ 对应跨域 CORS 请求。
115
+ """
116
+ headers: Dict[str, str] = {
117
+ "origin": "https://sentinel.openai.com",
118
+ "referer": "https://sentinel.openai.com/backend-api/sentinel/frame.html?sv=20260219f9f6",
119
+ "content-type": "text/plain;charset=UTF-8",
120
+ "sec-fetch-dest": "empty",
121
+ "sec-fetch-mode": "cors",
122
+ "sec-fetch-site": "same-origin",
123
+ }
124
+ headers.update(self._ch_ua_headers())
125
+ return headers
126
+
127
+
128
+ def get_random_profile() -> BrowserProfile:
129
+ """从 Chrome 版本池中随机选择一个 Profile,整个注册会话内保持一致。"""
130
+ cfg = random.choice(_CHROME_PROFILES)
131
+ major = cfg["major"]
132
+ build = cfg["build"]
133
+ patch = random.randint(*cfg["patch_range"])
134
+ full_ver = f"{major}.0.{build}.{patch}"
135
+ ua = (
136
+ f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
137
+ f"AppleWebKit/537.36 (KHTML, like Gecko) "
138
+ f"Chrome/{full_ver} Safari/537.36"
139
+ )
140
+ return BrowserProfile(
141
+ impersonate=cfg["impersonate"],
142
+ major=major,
143
+ full_version=full_ver,
144
+ user_agent=ua,
145
+ sec_ch_ua=cfg["sec_ch_ua"],
146
+ )
src/core/openai/oauth.py ADDED
@@ -0,0 +1,410 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ OpenAI OAuth 授权模块
3
+ 从 main.py 中提取的 OAuth 相关函数
4
+ """
5
+
6
+ import base64
7
+ import hashlib
8
+ import json
9
+ import secrets
10
+ import time
11
+ import urllib.parse
12
+ from dataclasses import dataclass
13
+ from typing import Any, Dict, Optional
14
+
15
+ from curl_cffi import requests as cffi_requests
16
+
17
+ from ...config.constants import (
18
+ OAUTH_CLIENT_ID,
19
+ OAUTH_AUTH_URL,
20
+ OAUTH_TOKEN_URL,
21
+ OAUTH_REDIRECT_URI,
22
+ OAUTH_SCOPE,
23
+ )
24
+
25
+
26
+ def _b64url_no_pad(raw: bytes) -> str:
27
+ """Base64 URL 编码(无填充)"""
28
+ return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
29
+
30
+
31
+ def _sha256_b64url_no_pad(s: str) -> str:
32
+ """SHA256 哈希后 Base64 URL 编码"""
33
+ return _b64url_no_pad(hashlib.sha256(s.encode("ascii")).digest())
34
+
35
+
36
+ def _random_state(nbytes: int = 16) -> str:
37
+ """生成随机 state"""
38
+ return secrets.token_urlsafe(nbytes)
39
+
40
+
41
+ def _pkce_verifier() -> str:
42
+ """生成 PKCE code_verifier"""
43
+ return secrets.token_urlsafe(64)
44
+
45
+
46
+ def _parse_callback_url(callback_url: str) -> Dict[str, str]:
47
+ """解析回调 URL"""
48
+ candidate = callback_url.strip()
49
+ if not candidate:
50
+ return {"code": "", "state": "", "error": "", "error_description": ""}
51
+
52
+ if "://" not in candidate:
53
+ if candidate.startswith("?"):
54
+ candidate = f"http://localhost{candidate}"
55
+ elif any(ch in candidate for ch in "/?#") or ":" in candidate:
56
+ candidate = f"http://{candidate}"
57
+ elif "=" in candidate:
58
+ candidate = f"http://localhost/?{candidate}"
59
+
60
+ parsed = urllib.parse.urlparse(candidate)
61
+ query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
62
+ fragment = urllib.parse.parse_qs(parsed.fragment, keep_blank_values=True)
63
+
64
+ for key, values in fragment.items():
65
+ if key not in query or not query[key] or not (query[key][0] or "").strip():
66
+ query[key] = values
67
+
68
+ def get1(k: str) -> str:
69
+ v = query.get(k, [""])
70
+ return (v[0] or "").strip()
71
+
72
+ code = get1("code")
73
+ state = get1("state")
74
+ error = get1("error")
75
+ error_description = get1("error_description")
76
+
77
+ if code and not state and "#" in code:
78
+ code, state = code.split("#", 1)
79
+
80
+ if not error and error_description:
81
+ error, error_description = error_description, ""
82
+
83
+ return {
84
+ "code": code,
85
+ "state": state,
86
+ "error": error,
87
+ "error_description": error_description,
88
+ }
89
+
90
+
91
+ def _jwt_claims_no_verify(id_token: str) -> Dict[str, Any]:
92
+ """解析 JWT ID Token(不验证签名)"""
93
+ if not id_token or id_token.count(".") < 2:
94
+ return {}
95
+ payload_b64 = id_token.split(".")[1]
96
+ pad = "=" * ((4 - (len(payload_b64) % 4)) % 4)
97
+ try:
98
+ payload = base64.urlsafe_b64decode((payload_b64 + pad).encode("ascii"))
99
+ return json.loads(payload.decode("utf-8"))
100
+ except Exception:
101
+ return {}
102
+
103
+
104
+ def _decode_jwt_segment(seg: str) -> Dict[str, Any]:
105
+ """解码 JWT 片段"""
106
+ raw = (seg or "").strip()
107
+ if not raw:
108
+ return {}
109
+ pad = "=" * ((4 - (len(raw) % 4)) % 4)
110
+ try:
111
+ decoded = base64.urlsafe_b64decode((raw + pad).encode("ascii"))
112
+ return json.loads(decoded.decode("utf-8"))
113
+ except Exception:
114
+ return {}
115
+
116
+
117
+ def _to_int(v: Any) -> int:
118
+ """转换为整数"""
119
+ try:
120
+ return int(v)
121
+ except (TypeError, ValueError):
122
+ return 0
123
+
124
+
125
+ def _post_form(
126
+ url: str,
127
+ data: Dict[str, str],
128
+ timeout: int = 30,
129
+ proxy_url: Optional[str] = None
130
+ ) -> Dict[str, Any]:
131
+ """
132
+ 发送 POST 表单请求
133
+
134
+ Args:
135
+ url: 请求 URL
136
+ data: 表单数据
137
+ timeout: 超时时间
138
+ proxy_url: 代理 URL
139
+
140
+ Returns:
141
+ 响应 JSON 数据
142
+ """
143
+ # 构建代理配置
144
+ proxies = None
145
+ if proxy_url:
146
+ proxies = {
147
+ "http": proxy_url,
148
+ "https": proxy_url,
149
+ }
150
+
151
+ headers = {
152
+ "Content-Type": "application/x-www-form-urlencoded",
153
+ "Accept": "application/json",
154
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
155
+ "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
156
+ }
157
+
158
+ try:
159
+ # 使用 curl_cffi 发送请求,支持代理和浏览器指纹
160
+ response = cffi_requests.post(
161
+ url,
162
+ data=data,
163
+ headers=headers,
164
+ timeout=timeout,
165
+ proxies=proxies,
166
+ impersonate="chrome"
167
+ )
168
+
169
+ if response.status_code != 200:
170
+ raise RuntimeError(
171
+ f"token exchange failed: {response.status_code}: {response.text}"
172
+ )
173
+
174
+ return response.json()
175
+
176
+ except cffi_requests.RequestsError as e:
177
+ raise RuntimeError(f"token exchange failed: network error: {e}") from e
178
+
179
+
180
+ @dataclass(frozen=True)
181
+ class OAuthStart:
182
+ """OAuth 开始信息"""
183
+ auth_url: str
184
+ state: str
185
+ code_verifier: str
186
+ redirect_uri: str
187
+
188
+
189
+ def generate_oauth_url(
190
+ *,
191
+ redirect_uri: str = OAUTH_REDIRECT_URI,
192
+ scope: str = OAUTH_SCOPE,
193
+ client_id: str = OAUTH_CLIENT_ID
194
+ ) -> OAuthStart:
195
+ """
196
+ 生成 OAuth 授权 URL
197
+
198
+ Args:
199
+ redirect_uri: 回调地址
200
+ scope: 权限范围
201
+ client_id: OpenAI Client ID
202
+
203
+ Returns:
204
+ OAuthStart 对象,包含授权 URL 和必要参数
205
+ """
206
+ state = _random_state()
207
+ code_verifier = _pkce_verifier()
208
+ code_challenge = _sha256_b64url_no_pad(code_verifier)
209
+
210
+ params = {
211
+ "client_id": client_id,
212
+ "response_type": "code",
213
+ "redirect_uri": redirect_uri,
214
+ "scope": scope,
215
+ "state": state,
216
+ "code_challenge": code_challenge,
217
+ "code_challenge_method": "S256",
218
+ "prompt": "login",
219
+ "id_token_add_organizations": "true",
220
+ "codex_cli_simplified_flow": "true",
221
+ }
222
+ auth_url = f"{OAUTH_AUTH_URL}?{urllib.parse.urlencode(params)}"
223
+ return OAuthStart(
224
+ auth_url=auth_url,
225
+ state=state,
226
+ code_verifier=code_verifier,
227
+ redirect_uri=redirect_uri,
228
+ )
229
+
230
+
231
+ def generate_oauth_url_no_prompt(
232
+ *,
233
+ redirect_uri: str = OAUTH_REDIRECT_URI,
234
+ scope: str = OAUTH_SCOPE,
235
+ client_id: str = OAUTH_CLIENT_ID,
236
+ login_hint: Optional[str] = None
237
+ ) -> OAuthStart:
238
+ """
239
+ 生成 OAuth 授权 URL(不带 prompt=login)
240
+
241
+ 用于 Outlook 重登后已持有 auth cookie 的场景,
242
+ 让 OpenAI 直接复用当前登录态而不强制重新登录。
243
+ """
244
+ state = _random_state()
245
+ code_verifier = _pkce_verifier()
246
+ code_challenge = _sha256_b64url_no_pad(code_verifier)
247
+
248
+ params = {
249
+ "client_id": client_id,
250
+ "response_type": "code",
251
+ "redirect_uri": redirect_uri,
252
+ "scope": scope,
253
+ "state": state,
254
+ "code_challenge": code_challenge,
255
+ "code_challenge_method": "S256",
256
+ "id_token_add_organizations": "true",
257
+ "codex_cli_simplified_flow": "true",
258
+ "prompt": "none",
259
+ }
260
+ if login_hint:
261
+ params["login_hint"] = login_hint
262
+ auth_url = f"{OAUTH_AUTH_URL}?{urllib.parse.urlencode(params)}"
263
+ return OAuthStart(
264
+ auth_url=auth_url,
265
+ state=state,
266
+ code_verifier=code_verifier,
267
+ redirect_uri=redirect_uri,
268
+ )
269
+
270
+
271
+ def submit_callback_url(
272
+ *,
273
+ callback_url: str,
274
+ expected_state: str,
275
+ code_verifier: str,
276
+ redirect_uri: str = OAUTH_REDIRECT_URI,
277
+ client_id: str = OAUTH_CLIENT_ID,
278
+ token_url: str = OAUTH_TOKEN_URL,
279
+ proxy_url: Optional[str] = None
280
+ ) -> str:
281
+ """
282
+ 处理 OAuth 回调 URL,获取访问令牌
283
+
284
+ Args:
285
+ callback_url: 回调 URL
286
+ expected_state: 预期的 state 值
287
+ code_verifier: PKCE code_verifier
288
+ redirect_uri: 回调地址
289
+ client_id: OpenAI Client ID
290
+ token_url: Token 交换地址
291
+ proxy_url: 代理 URL
292
+
293
+ Returns:
294
+ 包含访问令牌等信息的 JSON 字符串
295
+
296
+ Raises:
297
+ RuntimeError: OAuth 错误
298
+ ValueError: 缺少必要参数或 state 不匹配
299
+ """
300
+ cb = _parse_callback_url(callback_url)
301
+ if cb["error"]:
302
+ desc = cb["error_description"]
303
+ raise RuntimeError(f"oauth error: {cb['error']}: {desc}".strip())
304
+
305
+ if not cb["code"]:
306
+ raise ValueError("callback url missing ?code=")
307
+ if not cb["state"]:
308
+ raise ValueError("callback url missing ?state=")
309
+ if cb["state"] != expected_state:
310
+ raise ValueError("state mismatch")
311
+
312
+ token_resp = _post_form(
313
+ token_url,
314
+ {
315
+ "grant_type": "authorization_code",
316
+ "client_id": client_id,
317
+ "code": cb["code"],
318
+ "redirect_uri": redirect_uri,
319
+ "code_verifier": code_verifier,
320
+ },
321
+ proxy_url=proxy_url
322
+ )
323
+
324
+ access_token = (token_resp.get("access_token") or "").strip()
325
+ refresh_token = (token_resp.get("refresh_token") or "").strip()
326
+ id_token = (token_resp.get("id_token") or "").strip()
327
+ expires_in = _to_int(token_resp.get("expires_in"))
328
+
329
+ claims = _jwt_claims_no_verify(id_token)
330
+ email = str(claims.get("email") or "").strip()
331
+ auth_claims = claims.get("https://api.openai.com/auth") or {}
332
+ account_id = str(auth_claims.get("chatgpt_account_id") or "").strip()
333
+
334
+ now = int(time.time())
335
+ expired_rfc3339 = time.strftime(
336
+ "%Y-%m-%dT%H:%M:%SZ", time.gmtime(now + max(expires_in, 0))
337
+ )
338
+ now_rfc3339 = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now))
339
+
340
+ config = {
341
+ "id_token": id_token,
342
+ "access_token": access_token,
343
+ "refresh_token": refresh_token,
344
+ "account_id": account_id,
345
+ "last_refresh": now_rfc3339,
346
+ "email": email,
347
+ "type": "codex",
348
+ "expired": expired_rfc3339,
349
+ }
350
+
351
+ return json.dumps(config, ensure_ascii=False, separators=(",", ":"))
352
+
353
+
354
+ class OAuthManager:
355
+ """OAuth 管理器"""
356
+
357
+ def __init__(
358
+ self,
359
+ client_id: str = OAUTH_CLIENT_ID,
360
+ auth_url: str = OAUTH_AUTH_URL,
361
+ token_url: str = OAUTH_TOKEN_URL,
362
+ redirect_uri: str = OAUTH_REDIRECT_URI,
363
+ scope: str = OAUTH_SCOPE,
364
+ proxy_url: Optional[str] = None
365
+ ):
366
+ self.client_id = client_id
367
+ self.auth_url = auth_url
368
+ self.token_url = token_url
369
+ self.redirect_uri = redirect_uri
370
+ self.scope = scope
371
+ self.proxy_url = proxy_url
372
+
373
+ def start_oauth(self) -> OAuthStart:
374
+ """开始 OAuth 流程"""
375
+ return generate_oauth_url(
376
+ redirect_uri=self.redirect_uri,
377
+ scope=self.scope,
378
+ client_id=self.client_id
379
+ )
380
+
381
+ def handle_callback(
382
+ self,
383
+ callback_url: str,
384
+ expected_state: str,
385
+ code_verifier: str
386
+ ) -> Dict[str, Any]:
387
+ """处理 OAuth 回调"""
388
+ result_json = submit_callback_url(
389
+ callback_url=callback_url,
390
+ expected_state=expected_state,
391
+ code_verifier=code_verifier,
392
+ redirect_uri=self.redirect_uri,
393
+ client_id=self.client_id,
394
+ token_url=self.token_url,
395
+ proxy_url=self.proxy_url
396
+ )
397
+ return json.loads(result_json)
398
+
399
+ def extract_account_info(self, id_token: str) -> Dict[str, Any]:
400
+ """从 ID Token 中提取账户信息"""
401
+ claims = _jwt_claims_no_verify(id_token)
402
+ email = str(claims.get("email") or "").strip()
403
+ auth_claims = claims.get("https://api.openai.com/auth") or {}
404
+ account_id = str(auth_claims.get("chatgpt_account_id") or "").strip()
405
+
406
+ return {
407
+ "email": email,
408
+ "account_id": account_id,
409
+ "claims": claims
410
+ }
src/core/openai/payment.py ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 支付核心逻辑 — 生成 Plus/Team 支付链接、无痕打开浏览器、检测订阅状态
3
+ """
4
+
5
+ import logging
6
+ import subprocess
7
+ import sys
8
+ from typing import Optional
9
+
10
+ from curl_cffi import requests as cffi_requests
11
+
12
+ from ...database.models import Account
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ PAYMENT_CHECKOUT_URL = "https://chatgpt.com/backend-api/payments/checkout"
17
+ TEAM_CHECKOUT_BASE_URL = "https://chatgpt.com/checkout/openai_llc/"
18
+
19
+
20
+ def _build_proxies(proxy: Optional[str]) -> Optional[dict]:
21
+ if proxy:
22
+ return {"http": proxy, "https": proxy}
23
+ return None
24
+
25
+
26
+ _COUNTRY_CURRENCY_MAP = {
27
+ "SG": "SGD",
28
+ "US": "USD",
29
+ "TR": "TRY",
30
+ "JP": "JPY",
31
+ "HK": "HKD",
32
+ "GB": "GBP",
33
+ "EU": "EUR",
34
+ "AU": "AUD",
35
+ "CA": "CAD",
36
+ "IN": "INR",
37
+ "BR": "BRL",
38
+ "MX": "MXN",
39
+ }
40
+
41
+
42
+ def _extract_oai_did(cookies_str: str) -> Optional[str]:
43
+ """从 cookie 字符串中提取 oai-device-id"""
44
+ for part in cookies_str.split(";"):
45
+ part = part.strip()
46
+ if part.startswith("oai-did="):
47
+ return part[len("oai-did="):].strip()
48
+ return None
49
+
50
+
51
+ def _parse_cookie_str(cookies_str: str, domain: str) -> list:
52
+ """将 'key=val; key2=val2' 格式解析为 Playwright cookie 列表"""
53
+ cookies = []
54
+ for part in cookies_str.split(";"):
55
+ part = part.strip()
56
+ if "=" not in part:
57
+ continue
58
+ name, _, value = part.partition("=")
59
+ cookies.append({
60
+ "name": name.strip(),
61
+ "value": value.strip(),
62
+ "domain": domain,
63
+ "path": "/",
64
+ })
65
+ return cookies
66
+
67
+
68
+ def _open_url_system_browser(url: str) -> bool:
69
+ """回退方案:调用系统浏览器以无痕模式打开"""
70
+ platform = sys.platform
71
+ try:
72
+ if platform == "win32":
73
+ for browser, flag in [("chrome", "--incognito"), ("msedge", "--inprivate")]:
74
+ try:
75
+ subprocess.Popen(f'start {browser} {flag} "{url}"', shell=True)
76
+ return True
77
+ except Exception:
78
+ continue
79
+ elif platform == "darwin":
80
+ subprocess.Popen(["open", "-a", "Google Chrome", "--args", "--incognito", url])
81
+ return True
82
+ else:
83
+ for binary in ["google-chrome", "chromium-browser", "chromium"]:
84
+ try:
85
+ subprocess.Popen([binary, "--incognito", url])
86
+ return True
87
+ except FileNotFoundError:
88
+ continue
89
+ except Exception as e:
90
+ logger.warning(f"系统浏览器无痕打开失败: {e}")
91
+ return False
92
+
93
+
94
+ def generate_plus_link(
95
+ account: Account,
96
+ proxy: Optional[str] = None,
97
+ country: str = "SG",
98
+ ) -> str:
99
+ """生成 Plus 支付链接(后端携带账号 cookie 发请求)"""
100
+ if not account.access_token:
101
+ raise ValueError("账号缺少 access_token")
102
+
103
+ currency = _COUNTRY_CURRENCY_MAP.get(country, "USD")
104
+ headers = {
105
+ "Authorization": f"Bearer {account.access_token}",
106
+ "Content-Type": "application/json",
107
+ "oai-language": "zh-CN",
108
+ }
109
+ if account.cookies:
110
+ headers["cookie"] = account.cookies
111
+ oai_did = _extract_oai_did(account.cookies)
112
+ if oai_did:
113
+ headers["oai-device-id"] = oai_did
114
+
115
+ payload = {
116
+ "plan_name": "chatgptplusplan",
117
+ "billing_details": {"country": country, "currency": currency},
118
+ "promo_campaign": {
119
+ "promo_campaign_id": "plus-1-month-free",
120
+ "is_coupon_from_query_param": False,
121
+ },
122
+ "checkout_ui_mode": "custom",
123
+ }
124
+
125
+ resp = cffi_requests.post(
126
+ PAYMENT_CHECKOUT_URL,
127
+ headers=headers,
128
+ json=payload,
129
+ proxies=_build_proxies(proxy),
130
+ timeout=30,
131
+ impersonate="chrome110",
132
+ )
133
+ resp.raise_for_status()
134
+ data = resp.json()
135
+ if "checkout_session_id" in data:
136
+ return TEAM_CHECKOUT_BASE_URL + data["checkout_session_id"]
137
+ raise ValueError(data.get("detail", "API 未返回 checkout_session_id"))
138
+
139
+
140
+ def generate_team_link(
141
+ account: Account,
142
+ workspace_name: str = "MyTeam",
143
+ price_interval: str = "month",
144
+ seat_quantity: int = 5,
145
+ proxy: Optional[str] = None,
146
+ country: str = "SG",
147
+ ) -> str:
148
+ """生成 Team 支付链接(后端携带账号 cookie 发请求)"""
149
+ if not account.access_token:
150
+ raise ValueError("账号缺少 access_token")
151
+
152
+ currency = _COUNTRY_CURRENCY_MAP.get(country, "USD")
153
+ headers = {
154
+ "Authorization": f"Bearer {account.access_token}",
155
+ "Content-Type": "application/json",
156
+ "oai-language": "zh-CN",
157
+ }
158
+ if account.cookies:
159
+ headers["cookie"] = account.cookies
160
+ oai_did = _extract_oai_did(account.cookies)
161
+ if oai_did:
162
+ headers["oai-device-id"] = oai_did
163
+
164
+ payload = {
165
+ "plan_name": "chatgptteamplan",
166
+ "team_plan_data": {
167
+ "workspace_name": workspace_name,
168
+ "price_interval": price_interval,
169
+ "seat_quantity": seat_quantity,
170
+ },
171
+ "billing_details": {"country": country, "currency": currency},
172
+ "promo_campaign": {
173
+ "promo_campaign_id": "team-1-month-free",
174
+ "is_coupon_from_query_param": True,
175
+ },
176
+ "cancel_url": "https://chatgpt.com/#pricing",
177
+ "checkout_ui_mode": "custom",
178
+ }
179
+
180
+ resp = cffi_requests.post(
181
+ PAYMENT_CHECKOUT_URL,
182
+ headers=headers,
183
+ json=payload,
184
+ proxies=_build_proxies(proxy),
185
+ timeout=30,
186
+ impersonate="chrome110",
187
+ )
188
+ resp.raise_for_status()
189
+ data = resp.json()
190
+ if "checkout_session_id" in data:
191
+ return TEAM_CHECKOUT_BASE_URL + data["checkout_session_id"]
192
+ raise ValueError(data.get("detail", "API 未返回 checkout_session_id"))
193
+
194
+
195
+ def open_url_incognito(url: str, cookies_str: Optional[str] = None) -> bool:
196
+ """用 Playwright 以无痕模式打开 URL,可注入 cookie"""
197
+ import threading
198
+ try:
199
+ from playwright.sync_api import sync_playwright
200
+ except ImportError:
201
+ logger.warning("playwright 未安装,回退到系统浏览器")
202
+ return _open_url_system_browser(url)
203
+
204
+ def _launch():
205
+ try:
206
+ with sync_playwright() as p:
207
+ browser = p.chromium.launch(headless=False, args=["--incognito"])
208
+ ctx = browser.new_context()
209
+ if cookies_str:
210
+ ctx.add_cookies(_parse_cookie_str(cookies_str, "chatgpt.com"))
211
+ page = ctx.new_page()
212
+ page.goto(url)
213
+ # 保持窗口打开直到用户关闭
214
+ page.wait_for_timeout(300_000) # 最多等待 5 分钟
215
+ except Exception as e:
216
+ logger.warning(f"Playwright 无痕打开失败: {e}")
217
+
218
+ threading.Thread(target=_launch, daemon=True).start()
219
+ return True
220
+
221
+
222
+ def check_subscription_status(account: Account, proxy: Optional[str] = None) -> str:
223
+ """
224
+ 检测账号当前订阅状态。
225
+
226
+ Returns:
227
+ 'free' / 'plus' / 'team'
228
+ """
229
+ if not account.access_token:
230
+ raise ValueError("账号缺少 access_token")
231
+
232
+ headers = {
233
+ "Authorization": f"Bearer {account.access_token}",
234
+ "Content-Type": "application/json",
235
+ }
236
+
237
+ resp = cffi_requests.get(
238
+ "https://chatgpt.com/backend-api/me",
239
+ headers=headers,
240
+ proxies=_build_proxies(proxy),
241
+ timeout=20,
242
+ impersonate="chrome110",
243
+ )
244
+ resp.raise_for_status()
245
+ data = resp.json()
246
+
247
+ # 解析订阅类型
248
+ plan = data.get("plan_type") or ""
249
+ if "team" in plan.lower():
250
+ return "team"
251
+ if "plus" in plan.lower():
252
+ return "plus"
253
+
254
+ # 尝试从 orgs 或 workspace 信息判断
255
+ orgs = data.get("orgs", {}).get("data", [])
256
+ for org in orgs:
257
+ settings_ = org.get("settings", {})
258
+ if settings_.get("workspace_plan_type") in ("team", "enterprise"):
259
+ return "team"
260
+
261
+ return "free"
src/core/openai/sentinel.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Helpers for OpenAI Sentinel proof-of-work tokens."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import json
8
+ import random
9
+ import time
10
+ import uuid
11
+ from datetime import datetime, timedelta, timezone
12
+ from typing import Sequence
13
+
14
+
15
+ DEFAULT_SENTINEL_DIFF = "0fffff"
16
+ DEFAULT_MAX_ITERATIONS = 500_000
17
+ _SCREEN_SIGNATURES = ("1920x1080", "2560x1440", "1366x768", "1536x864", "1440x900")
18
+ _LANGUAGE_SIGNATURE = "en-US,en"
19
+ _NAVIGATOR_KEYS = (
20
+ "vendorSub", "productSub", "vendor", "maxTouchPoints",
21
+ "scheduling", "userActivation", "doNotTrack", "geolocation",
22
+ "connection", "plugins", "mimeTypes", "pdfViewerEnabled",
23
+ "webkitTemporaryStorage", "webkitPersistentStorage",
24
+ "hardwareConcurrency", "cookieEnabled", "credentials",
25
+ "mediaDevices", "permissions", "locks", "ink",
26
+ )
27
+ _WINDOW_KEYS = (
28
+ "Object", "Function", "Array", "Number", "parseFloat",
29
+ "undefined", "window", "document", "navigator",
30
+ )
31
+
32
+
33
+ class SentinelPOWError(RuntimeError):
34
+ """Raised when a Sentinel proof-of-work token cannot be solved."""
35
+
36
+
37
+ def _format_browser_time() -> str:
38
+ """Match the browser-style timestamp used by public Sentinel solvers."""
39
+ browser_now = datetime.now(timezone.utc)
40
+ return browser_now.strftime("%a %b %d %Y %H:%M:%S") + " GMT+0000 (Coordinated Universal Time)"
41
+
42
+
43
+ def build_sentinel_config(user_agent: str) -> list:
44
+ """Build a browser-like fingerprint payload for the Sentinel PoW solver."""
45
+ perf_ms = time.perf_counter() * 1000
46
+ epoch_ms = (time.time() * 1000) - perf_ms
47
+ return [
48
+ random.choice(_SCREEN_SIGNATURES),
49
+ _format_browser_time(),
50
+ 4294705152,
51
+ 0,
52
+ user_agent,
53
+ "",
54
+ "",
55
+ "en-US",
56
+ _LANGUAGE_SIGNATURE,
57
+ 0,
58
+ random.choice(_NAVIGATOR_KEYS),
59
+ "location",
60
+ random.choice(_WINDOW_KEYS),
61
+ perf_ms,
62
+ str(uuid.uuid4()),
63
+ "",
64
+ 8,
65
+ epoch_ms,
66
+ ]
67
+
68
+
69
+ def _encode_pow_payload(config: Sequence[object], nonce: int) -> bytes:
70
+ prefix = (json.dumps(config[:3], separators=(",", ":"), ensure_ascii=False)[:-1] + ",").encode("utf-8")
71
+ middle = (
72
+ "," + json.dumps(config[4:9], separators=(",", ":"), ensure_ascii=False)[1:-1] + ","
73
+ ).encode("utf-8")
74
+ suffix = ("," + json.dumps(config[10:], separators=(",", ":"), ensure_ascii=False)[1:]).encode("utf-8")
75
+ body = prefix + str(nonce).encode("ascii") + middle + str(nonce >> 1).encode("ascii") + suffix
76
+ return base64.b64encode(body)
77
+
78
+
79
+ def solve_sentinel_pow(
80
+ seed: str,
81
+ difficulty: str,
82
+ config: Sequence[object],
83
+ max_iterations: int = DEFAULT_MAX_ITERATIONS,
84
+ ) -> str:
85
+ """Solve the Sentinel PoW challenge and return the base64 payload."""
86
+ seed_bytes = seed.encode("utf-8")
87
+ target = bytes.fromhex(difficulty)
88
+ prefix_length = len(target)
89
+
90
+ for nonce in range(max_iterations):
91
+ encoded = _encode_pow_payload(config, nonce)
92
+ digest = hashlib.sha3_512(seed_bytes + encoded).digest()
93
+ if digest[:prefix_length] <= target:
94
+ return encoded.decode("ascii")
95
+
96
+ raise SentinelPOWError(f"failed to solve sentinel pow after {max_iterations} attempts")
97
+
98
+
99
+ def build_sentinel_pow_token(
100
+ user_agent: str,
101
+ difficulty: str = DEFAULT_SENTINEL_DIFF,
102
+ max_iterations: int = DEFAULT_MAX_ITERATIONS,
103
+ ) -> str:
104
+ """Build the `p` token required by the Sentinel request endpoint."""
105
+ config = build_sentinel_config(user_agent)
106
+ seed = format(random.random())
107
+ solution = solve_sentinel_pow(seed, difficulty, config, max_iterations=max_iterations)
108
+ return f"gAAAAAC{solution}"
src/core/openai/token_refresh.py ADDED
@@ -0,0 +1,332 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Token 刷新模块
3
+ 支持 Session Token 和 OAuth Refresh Token 两种刷新方式
4
+ """
5
+
6
+ import logging
7
+ import json
8
+ import time
9
+ from typing import Optional, Dict, Any, Tuple
10
+ from dataclasses import dataclass
11
+ from datetime import datetime, timedelta
12
+
13
+ from curl_cffi import requests as cffi_requests
14
+
15
+ from ...config.settings import get_settings
16
+ from ...database.session import get_db
17
+ from ...database import crud
18
+ from ...database.models import Account
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ @dataclass
24
+ class TokenRefreshResult:
25
+ """Token 刷新结果"""
26
+ success: bool
27
+ access_token: str = ""
28
+ refresh_token: str = ""
29
+ expires_at: Optional[datetime] = None
30
+ error_message: str = ""
31
+
32
+
33
+ class TokenRefreshManager:
34
+ """
35
+ Token 刷新管理器
36
+ 支持两种刷新方式:
37
+ 1. Session Token 刷新(优先)
38
+ 2. OAuth Refresh Token 刷新
39
+ """
40
+
41
+ # OpenAI OAuth 端点
42
+ SESSION_URL = "https://chatgpt.com/api/auth/session"
43
+ TOKEN_URL = "https://auth.openai.com/oauth/token"
44
+
45
+ def __init__(self, proxy_url: Optional[str] = None):
46
+ """
47
+ 初始化 Token 刷新管理器
48
+
49
+ Args:
50
+ proxy_url: 代理 URL
51
+ """
52
+ self.proxy_url = proxy_url
53
+ self.settings = get_settings()
54
+
55
+ def _create_session(self) -> cffi_requests.Session:
56
+ """创建 HTTP 会话"""
57
+ session = cffi_requests.Session(impersonate="chrome120", proxy=self.proxy_url)
58
+ return session
59
+
60
+ def refresh_by_session_token(self, session_token: str) -> TokenRefreshResult:
61
+ """
62
+ 使用 Session Token 刷新
63
+
64
+ Args:
65
+ session_token: 会话令牌
66
+
67
+ Returns:
68
+ TokenRefreshResult: 刷新结果
69
+ """
70
+ result = TokenRefreshResult(success=False)
71
+
72
+ try:
73
+ session = self._create_session()
74
+
75
+ # 设置会话 Cookie
76
+ session.cookies.set(
77
+ "__Secure-next-auth.session-token",
78
+ session_token,
79
+ domain=".chatgpt.com",
80
+ path="/"
81
+ )
82
+
83
+ # 请求会话端点
84
+ response = session.get(
85
+ self.SESSION_URL,
86
+ headers={
87
+ "accept": "application/json",
88
+ "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"
89
+ },
90
+ timeout=30
91
+ )
92
+
93
+ if response.status_code != 200:
94
+ result.error_message = f"Session token 刷新失败: HTTP {response.status_code}"
95
+ logger.warning(result.error_message)
96
+ return result
97
+
98
+ data = response.json()
99
+
100
+ # 提取 access_token
101
+ access_token = data.get("accessToken")
102
+ if not access_token:
103
+ result.error_message = "Session token 刷新失败: 未找到 accessToken"
104
+ logger.warning(result.error_message)
105
+ return result
106
+
107
+ # 提取过期时间
108
+ expires_at = None
109
+ expires_str = data.get("expires")
110
+ if expires_str:
111
+ try:
112
+ expires_at = datetime.fromisoformat(expires_str.replace("Z", "+00:00"))
113
+ except:
114
+ pass
115
+
116
+ result.success = True
117
+ result.access_token = access_token
118
+ result.expires_at = expires_at
119
+
120
+ logger.info(f"Session token 刷新成功,过期时间: {expires_at}")
121
+ return result
122
+
123
+ except Exception as e:
124
+ result.error_message = f"Session token 刷新异常: {str(e)}"
125
+ logger.error(result.error_message)
126
+ return result
127
+
128
+ def refresh_by_oauth_token(
129
+ self,
130
+ refresh_token: str,
131
+ client_id: Optional[str] = None
132
+ ) -> TokenRefreshResult:
133
+ """
134
+ 使用 OAuth Refresh Token 刷新
135
+
136
+ Args:
137
+ refresh_token: OAuth 刷新令牌
138
+ client_id: OAuth Client ID
139
+
140
+ Returns:
141
+ TokenRefreshResult: 刷新结果
142
+ """
143
+ result = TokenRefreshResult(success=False)
144
+
145
+ try:
146
+ session = self._create_session()
147
+
148
+ # 使用配置的 client_id 或默认值
149
+ client_id = client_id or self.settings.openai_client_id
150
+
151
+ # 构建请求体
152
+ token_data = {
153
+ "client_id": client_id,
154
+ "grant_type": "refresh_token",
155
+ "refresh_token": refresh_token,
156
+ "redirect_uri": self.settings.openai_redirect_uri
157
+ }
158
+
159
+ response = session.post(
160
+ self.TOKEN_URL,
161
+ headers={
162
+ "content-type": "application/x-www-form-urlencoded",
163
+ "accept": "application/json"
164
+ },
165
+ data=token_data,
166
+ timeout=30
167
+ )
168
+
169
+ if response.status_code != 200:
170
+ result.error_message = f"OAuth token 刷新失败: HTTP {response.status_code}"
171
+ logger.warning(f"{result.error_message}, 响应: {response.text[:200]}")
172
+ return result
173
+
174
+ data = response.json()
175
+
176
+ # 提取令牌
177
+ access_token = data.get("access_token")
178
+ new_refresh_token = data.get("refresh_token", refresh_token)
179
+ expires_in = data.get("expires_in", 3600)
180
+
181
+ if not access_token:
182
+ result.error_message = "OAuth token 刷新失败: 未找到 access_token"
183
+ logger.warning(result.error_message)
184
+ return result
185
+
186
+ # 计算过期时间
187
+ expires_at = datetime.utcnow() + timedelta(seconds=expires_in)
188
+
189
+ result.success = True
190
+ result.access_token = access_token
191
+ result.refresh_token = new_refresh_token
192
+ result.expires_at = expires_at
193
+
194
+ logger.info(f"OAuth token 刷新成功,过期时间: {expires_at}")
195
+ return result
196
+
197
+ except Exception as e:
198
+ result.error_message = f"OAuth token 刷新异常: {str(e)}"
199
+ logger.error(result.error_message)
200
+ return result
201
+
202
+ def refresh_account(self, account: Account) -> TokenRefreshResult:
203
+ """
204
+ 刷新账号的 Token
205
+
206
+ 优先级:
207
+ 1. Session Token 刷新
208
+ 2. OAuth Refresh Token 刷新
209
+
210
+ Args:
211
+ account: 账号对象
212
+
213
+ Returns:
214
+ TokenRefreshResult: 刷新结果
215
+ """
216
+ # 优先尝试 Session Token
217
+ if account.session_token:
218
+ logger.info(f"尝试使用 Session Token 刷新账号 {account.email}")
219
+ result = self.refresh_by_session_token(account.session_token)
220
+ if result.success:
221
+ return result
222
+ logger.warning(f"Session Token 刷新失败,尝试 OAuth 刷新")
223
+
224
+ # 尝试 OAuth Refresh Token
225
+ if account.refresh_token:
226
+ logger.info(f"尝试使用 OAuth Refresh Token 刷新账号 {account.email}")
227
+ result = self.refresh_by_oauth_token(
228
+ refresh_token=account.refresh_token,
229
+ client_id=account.client_id
230
+ )
231
+ return result
232
+
233
+ # 无可用刷新方式
234
+ return TokenRefreshResult(
235
+ success=False,
236
+ error_message="账号没有可用的刷新方式(缺少 session_token 和 refresh_token)"
237
+ )
238
+
239
+ def validate_token(self, access_token: str) -> Tuple[bool, Optional[str]]:
240
+ """
241
+ 验证 Access Token 是否有效
242
+
243
+ Args:
244
+ access_token: 访问令牌
245
+
246
+ Returns:
247
+ Tuple[bool, Optional[str]]: (是否有效, 错误信息)
248
+ """
249
+ try:
250
+ session = self._create_session()
251
+
252
+ # 调用 OpenAI API 验证 token
253
+ response = session.get(
254
+ "https://chatgpt.com/backend-api/me",
255
+ headers={
256
+ "authorization": f"Bearer {access_token}",
257
+ "accept": "application/json"
258
+ },
259
+ timeout=30
260
+ )
261
+
262
+ if response.status_code == 200:
263
+ return True, None
264
+ elif response.status_code == 401:
265
+ return False, "Token 无效或已过期"
266
+ elif response.status_code == 403:
267
+ return False, "账号可能被封禁"
268
+ else:
269
+ return False, f"验证失败: HTTP {response.status_code}"
270
+
271
+ except Exception as e:
272
+ return False, f"验证异常: {str(e)}"
273
+
274
+
275
+ def refresh_account_token(account_id: int, proxy_url: Optional[str] = None) -> TokenRefreshResult:
276
+ """
277
+ 刷新指定账号的 Token 并更新数据库
278
+
279
+ Args:
280
+ account_id: 账号 ID
281
+ proxy_url: 代理 URL
282
+
283
+ Returns:
284
+ TokenRefreshResult: 刷新结果
285
+ """
286
+ with get_db() as db:
287
+ account = crud.get_account_by_id(db, account_id)
288
+ if not account:
289
+ return TokenRefreshResult(success=False, error_message="账号不存在")
290
+
291
+ manager = TokenRefreshManager(proxy_url=proxy_url)
292
+ result = manager.refresh_account(account)
293
+
294
+ if result.success:
295
+ # 更新数据库
296
+ update_data = {
297
+ "access_token": result.access_token,
298
+ "last_refresh": datetime.utcnow()
299
+ }
300
+
301
+ if result.refresh_token:
302
+ update_data["refresh_token"] = result.refresh_token
303
+
304
+ if result.expires_at:
305
+ update_data["expires_at"] = result.expires_at
306
+
307
+ crud.update_account(db, account_id, **update_data)
308
+
309
+ return result
310
+
311
+
312
+ def validate_account_token(account_id: int, proxy_url: Optional[str] = None) -> Tuple[bool, Optional[str]]:
313
+ """
314
+ 验证指定账号的 Token 是否有效
315
+
316
+ Args:
317
+ account_id: 账号 ID
318
+ proxy_url: ���理 URL
319
+
320
+ Returns:
321
+ Tuple[bool, Optional[str]]: (是否有效, 错误信息)
322
+ """
323
+ with get_db() as db:
324
+ account = crud.get_account_by_id(db, account_id)
325
+ if not account:
326
+ return False, "账号不存在"
327
+
328
+ if not account.access_token:
329
+ return False, "账号没有 access_token"
330
+
331
+ manager = TokenRefreshManager(proxy_url=proxy_url)
332
+ return manager.validate_token(account.access_token)
src/core/register.py ADDED
The diff for this file is too large to render. See raw diff
 
src/core/registration_post_create.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Callable, Dict, Optional
4
+
5
+
6
+ POST_CREATE_RESUME_SOURCE = "post_create_continue"
7
+ POST_CREATE_REENTERED_LOGIN_SOURCE = "post_create_continue_reentered_login"
8
+
9
+
10
+ def is_post_create_continue_url(url: Optional[str]) -> bool:
11
+ candidate = str(url or "").strip().lower()
12
+ if not candidate:
13
+ return False
14
+ return any(
15
+ token in candidate
16
+ for token in (
17
+ "/add-phone",
18
+ "/sign-in-with-chatgpt/",
19
+ "/consent",
20
+ "/workspace/select",
21
+ "/organization/select",
22
+ )
23
+ )
24
+
25
+
26
+ def extract_post_create_continue_url(data: Any) -> Optional[str]:
27
+ if isinstance(data, dict):
28
+ for key, value in data.items():
29
+ if isinstance(value, str):
30
+ candidate = value.strip()
31
+ key_text = str(key).strip().lower()
32
+ if key_text == "type" and candidate.lower() in {"add_phone", "add-phone"}:
33
+ return "https://auth.openai.com/add-phone"
34
+ if key_text in {"continue_url", "url", "location", "redirect_url"} and is_post_create_continue_url(candidate):
35
+ return candidate
36
+ if is_post_create_continue_url(candidate):
37
+ return candidate
38
+ candidate = extract_post_create_continue_url(value)
39
+ if candidate:
40
+ return candidate
41
+ elif isinstance(data, list):
42
+ for item in data:
43
+ candidate = extract_post_create_continue_url(item)
44
+ if candidate:
45
+ return candidate
46
+ return None
47
+
48
+
49
+ def get_locked_post_create_continue_url(workspace_context: Dict[str, Any]) -> str:
50
+ return str(workspace_context.get("post_create_continue_url") or "").strip()
51
+
52
+
53
+ def lock_post_create_continue_url(
54
+ workspace_context: Dict[str, Any],
55
+ candidate: Optional[str],
56
+ *,
57
+ source_name: str,
58
+ log_fn: Callable[[str], None],
59
+ sanitize_url: Callable[[Optional[str]], str],
60
+ ) -> bool:
61
+ candidate_text = str(candidate or "").strip()
62
+ if not is_post_create_continue_url(candidate_text):
63
+ return False
64
+
65
+ current_locked = get_locked_post_create_continue_url(workspace_context)
66
+ current_resume = str(workspace_context.get("resume_url") or "").strip()
67
+
68
+ if current_locked == candidate_text and current_resume == candidate_text:
69
+ workspace_context["post_create_continue_source"] = source_name
70
+ return True
71
+
72
+ if current_resume and current_resume != candidate_text:
73
+ log_fn(
74
+ "post_create_continue_overrode_cached_resume: "
75
+ f"current={sanitize_url(current_resume) or '-'}; "
76
+ f"new={sanitize_url(candidate_text)}"
77
+ )
78
+
79
+ if current_locked and current_locked != candidate_text:
80
+ log_fn(
81
+ "post_create_continue_upgraded: "
82
+ f"current={sanitize_url(current_locked)}; "
83
+ f"new={sanitize_url(candidate_text)}"
84
+ )
85
+ else:
86
+ log_fn(f"post_create_continue_locked: {sanitize_url(candidate_text)}")
87
+
88
+ workspace_context["post_create_continue_url"] = candidate_text
89
+ workspace_context["post_create_continue_source"] = source_name
90
+ workspace_context["continue_url"] = candidate_text
91
+ workspace_context["resume_url"] = candidate_text
92
+ workspace_context["resume_source"] = POST_CREATE_RESUME_SOURCE
93
+ return True
94
+
95
+
96
+ def should_preserve_locked_post_create_continue(
97
+ workspace_context: Dict[str, Any],
98
+ candidate: Optional[str],
99
+ ) -> bool:
100
+ current_locked = get_locked_post_create_continue_url(workspace_context)
101
+ if not current_locked:
102
+ return False
103
+
104
+ current_resume = str(workspace_context.get("resume_url") or "").strip()
105
+ candidate_text = str(candidate or "").strip()
106
+ if not candidate_text or candidate_text == current_resume:
107
+ return False
108
+
109
+ if current_resume != current_locked:
110
+ return False
111
+
112
+ return not is_post_create_continue_url(candidate_text)
113
+
114
+
115
+ def build_post_create_failure_details(
116
+ *,
117
+ locked_url: Optional[str],
118
+ terminal_url: Optional[str],
119
+ resolution_error: Optional[str],
120
+ reentered_login: bool,
121
+ ) -> Dict[str, Optional[str]]:
122
+ if reentered_login:
123
+ error_message = "账号已创建,但 post-create 续跑重新进入登录页"
124
+ summary_reason = POST_CREATE_REENTERED_LOGIN_SOURCE
125
+ else:
126
+ error_message = "账号已创建,但 post-create 续跑失败"
127
+ summary_reason = "post_create_continue_failed"
128
+
129
+ return {
130
+ "error_message": error_message,
131
+ "resume_source": summary_reason,
132
+ "summary_reason": summary_reason,
133
+ "workspace_resolution_source": POST_CREATE_RESUME_SOURCE,
134
+ "locked_url": str(locked_url or "").strip() or None,
135
+ "terminal_url": str(terminal_url or "").strip() or None,
136
+ "resolution_error": str(resolution_error or "").strip() or None,
137
+ }
src/core/registration_upload.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+
6
+ from ..database.models import Account
7
+ from .service_selection import UploadTarget
8
+ from .upload.cpa_upload import generate_token_json, upload_to_cpa
9
+ from .upload.sub2api_upload import upload_to_sub2api
10
+ from .upload.team_manager_upload import upload_to_team_manager
11
+
12
+
13
+ @dataclass
14
+ class UploadExecutionResult:
15
+ success: bool
16
+ provider: str
17
+ service_id: int
18
+ message: str
19
+
20
+
21
+ def upload_registered_account(account: Account, target: UploadTarget) -> UploadExecutionResult:
22
+ if account is None:
23
+ return UploadExecutionResult(False, target.provider, target.service_id, 'account_not_found')
24
+ if not account.access_token:
25
+ return UploadExecutionResult(False, target.provider, target.service_id, 'missing_access_token')
26
+
27
+ if target.provider == 'cpa':
28
+ ok, message = upload_to_cpa(
29
+ generate_token_json(account),
30
+ api_url=target.config_snapshot['api_url'],
31
+ api_token=target.config_snapshot['api_token'],
32
+ )
33
+ elif target.provider == 'sub2api':
34
+ ok, message = upload_to_sub2api(
35
+ [account],
36
+ target.config_snapshot['api_url'],
37
+ target.config_snapshot['api_key'],
38
+ )
39
+ elif target.provider == 'tm':
40
+ ok, message = upload_to_team_manager(
41
+ account,
42
+ target.config_snapshot['api_url'],
43
+ target.config_snapshot['api_key'],
44
+ )
45
+ else:
46
+ return UploadExecutionResult(False, target.provider, target.service_id, 'unsupported_provider')
47
+
48
+ return UploadExecutionResult(ok, target.provider, target.service_id, message)
src/core/service_selection.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ from __future__ import annotations
3
+
4
+ import random
5
+ from dataclasses import dataclass
6
+ from typing import Any, Optional
7
+
8
+ from sqlalchemy.orm import Session
9
+
10
+ from ..database import crud
11
+
12
+
13
+ @dataclass
14
+ class EmailItemAssignment:
15
+ item_index: int
16
+ service_id: Optional[int]
17
+ service_type: str
18
+ failure_reason: Optional[str] = None
19
+ config_snapshot: Optional[dict[str, Any]] = None
20
+
21
+
22
+ @dataclass
23
+ class UploadTarget:
24
+ provider: str
25
+ service_id: int
26
+ service_name: str
27
+ config_snapshot: dict[str, Any]
28
+
29
+
30
+ def _get_enabled_email_services(db: Session, email_type: str):
31
+ return crud.get_email_services(db, service_type=email_type, enabled=True)
32
+
33
+
34
+ def _get_email_service_or_raise(db: Session, email_type: str, requested_service_id: int):
35
+ service = crud.get_email_service_by_id(db, requested_service_id)
36
+ if not service:
37
+ raise ValueError(f"email service {requested_service_id} not found")
38
+ if not service.enabled:
39
+ raise ValueError(f"email service {requested_service_id} is disabled")
40
+ if service.service_type != email_type:
41
+ raise ValueError(f"email service {requested_service_id} does not belong to type {email_type}")
42
+ return service
43
+
44
+
45
+ def build_email_item_assignments(
46
+ db: Session,
47
+ *,
48
+ email_type: str,
49
+ count: int,
50
+ requested_service_id: Optional[int] = None,
51
+ ) -> list[EmailItemAssignment]:
52
+ if count < 1:
53
+ raise ValueError('count must be >= 1')
54
+
55
+ if requested_service_id is not None:
56
+ service = _get_email_service_or_raise(db, email_type, requested_service_id)
57
+ if email_type == 'outlook' and count > 1:
58
+ raise ValueError('outlook requested_service_id cannot be reused when count > 1')
59
+ return [
60
+ EmailItemAssignment(
61
+ item_index=index,
62
+ service_id=service.id,
63
+ service_type=email_type,
64
+ config_snapshot=(service.config or {}).copy(),
65
+ )
66
+ for index in range(count)
67
+ ]
68
+
69
+ services = _get_enabled_email_services(db, email_type)
70
+ if not services:
71
+ raise ValueError(f'no enabled email services for type {email_type}')
72
+
73
+ if email_type == 'outlook':
74
+ assignments: list[EmailItemAssignment] = []
75
+ for index in range(count):
76
+ if index < len(services):
77
+ service = services[index]
78
+ assignments.append(
79
+ EmailItemAssignment(
80
+ item_index=index,
81
+ service_id=service.id,
82
+ service_type=email_type,
83
+ config_snapshot=(service.config or {}).copy(),
84
+ )
85
+ )
86
+ else:
87
+ assignments.append(
88
+ EmailItemAssignment(
89
+ item_index=index,
90
+ service_id=None,
91
+ service_type=email_type,
92
+ failure_reason='no_available_email_service',
93
+ )
94
+ )
95
+ return assignments
96
+
97
+ if email_type == 'temp_mail':
98
+ min_priority = min(service.priority for service in services)
99
+ pool = [service for service in services if service.priority == min_priority]
100
+ return [
101
+ EmailItemAssignment(
102
+ item_index=index,
103
+ service_id=(selected := random.choice(pool)).id,
104
+ service_type=email_type,
105
+ config_snapshot=(selected.config or {}).copy(),
106
+ )
107
+ for index in range(count)
108
+ ]
109
+
110
+ service = services[0]
111
+ return [
112
+ EmailItemAssignment(
113
+ item_index=index,
114
+ service_id=service.id,
115
+ service_type=email_type,
116
+ config_snapshot=(service.config or {}).copy(),
117
+ )
118
+ for index in range(count)
119
+ ]
120
+
121
+
122
+ def resolve_upload_target(db: Session, *, provider: str, requested_service_id: Optional[int] = None) -> UploadTarget:
123
+ provider = (provider or '').strip().lower()
124
+ provider_to_crud = {
125
+ 'cpa': (crud.get_cpa_service_by_id, lambda session: crud.get_cpa_services(session, enabled=True)),
126
+ 'sub2api': (crud.get_sub2api_service_by_id, lambda session: crud.get_sub2api_services(session, enabled=True)),
127
+ 'tm': (crud.get_tm_service_by_id, lambda session: crud.get_tm_services(session, enabled=True)),
128
+ }
129
+ if provider not in provider_to_crud:
130
+ raise ValueError(f'unsupported upload provider: {provider}')
131
+
132
+ get_one, list_enabled = provider_to_crud[provider]
133
+ if requested_service_id is not None:
134
+ service = get_one(db, requested_service_id)
135
+ if not service:
136
+ for other_provider, (other_get_one, _) in provider_to_crud.items():
137
+ if other_provider == provider:
138
+ continue
139
+ if other_get_one(db, requested_service_id):
140
+ raise ValueError(f'upload service {requested_service_id} does not belong to provider {provider}')
141
+ raise ValueError(f'upload service {requested_service_id} not found')
142
+ if not getattr(service, 'enabled', False):
143
+ raise ValueError(f'upload service {requested_service_id} is disabled')
144
+ service_pool = {svc.id for svc in list_enabled(db)}
145
+ if service.id not in service_pool:
146
+ raise ValueError(f'upload service {requested_service_id} does not belong to provider {provider}')
147
+ else:
148
+ services = list_enabled(db)
149
+ if not services:
150
+ raise ValueError(f'no enabled upload services for provider {provider}')
151
+ service = services[0]
152
+
153
+ if provider == 'cpa':
154
+ config_snapshot = {'api_url': service.api_url, 'api_token': service.api_token}
155
+ else:
156
+ config_snapshot = {'api_url': service.api_url, 'api_key': service.api_key}
157
+
158
+ return UploadTarget(
159
+ provider=provider,
160
+ service_id=service.id,
161
+ service_name=service.name,
162
+ config_snapshot=config_snapshot,
163
+ )
src/core/upload/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ # @Time : 2026/3/18 19:54
src/core/upload/cpa_upload.py ADDED
@@ -0,0 +1,312 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ CPA (Codex Protocol API) 上传功能
3
+ """
4
+
5
+ import json
6
+ import logging
7
+ from typing import List, Dict, Any, Tuple, Optional
8
+ from datetime import datetime
9
+ from urllib.parse import quote
10
+
11
+ from curl_cffi import requests as cffi_requests
12
+ from curl_cffi import CurlMime
13
+
14
+ from ...database.session import get_db
15
+ from ...database.models import Account
16
+ from ...config.settings import get_settings
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def _normalize_cpa_auth_files_url(api_url: str) -> str:
22
+ """将用户填写的 CPA 地址规范化为 auth-files 接口地址。"""
23
+ normalized = (api_url or "").strip().rstrip("/")
24
+ lower_url = normalized.lower()
25
+
26
+ if not normalized:
27
+ return ""
28
+
29
+ if lower_url.endswith("/auth-files"):
30
+ return normalized
31
+
32
+ if lower_url.endswith("/v0/management") or lower_url.endswith("/management"):
33
+ return f"{normalized}/auth-files"
34
+
35
+ if lower_url.endswith("/v0"):
36
+ return f"{normalized}/management/auth-files"
37
+
38
+ return f"{normalized}/v0/management/auth-files"
39
+
40
+
41
+ def _build_cpa_headers(api_token: str, content_type: Optional[str] = None) -> dict:
42
+ headers = {
43
+ "Authorization": f"Bearer {api_token}",
44
+ }
45
+ if content_type:
46
+ headers["Content-Type"] = content_type
47
+ return headers
48
+
49
+
50
+ def _extract_cpa_error(response) -> str:
51
+ error_msg = f"上传失败: HTTP {response.status_code}"
52
+ try:
53
+ error_detail = response.json()
54
+ if isinstance(error_detail, dict):
55
+ error_msg = error_detail.get("message", error_msg)
56
+ except Exception:
57
+ error_msg = f"{error_msg} - {response.text[:200]}"
58
+ return error_msg
59
+
60
+
61
+ def _post_cpa_auth_file_multipart(upload_url: str, filename: str, file_content: bytes, api_token: str):
62
+ mime = CurlMime()
63
+ mime.addpart(
64
+ name="file",
65
+ data=file_content,
66
+ filename=filename,
67
+ content_type="application/json",
68
+ )
69
+
70
+ return cffi_requests.post(
71
+ upload_url,
72
+ multipart=mime,
73
+ headers=_build_cpa_headers(api_token),
74
+ proxies=None,
75
+ timeout=30,
76
+ impersonate="chrome110",
77
+ )
78
+
79
+
80
+ def _post_cpa_auth_file_raw_json(upload_url: str, filename: str, file_content: bytes, api_token: str):
81
+ raw_upload_url = f"{upload_url}?name={quote(filename)}"
82
+ return cffi_requests.post(
83
+ raw_upload_url,
84
+ data=file_content,
85
+ headers=_build_cpa_headers(api_token, content_type="application/json"),
86
+ proxies=None,
87
+ timeout=30,
88
+ impersonate="chrome110",
89
+ )
90
+
91
+
92
+ def generate_token_json(account: Account) -> dict:
93
+ """
94
+ 生成 CPA 格式的 Token JSON
95
+
96
+ Args:
97
+ account: 账号模型实例
98
+
99
+ Returns:
100
+ CPA 格式的 Token 字典
101
+ """
102
+ return {
103
+ "type": "codex",
104
+ "email": account.email,
105
+ "expired": account.expires_at.strftime("%Y-%m-%dT%H:%M:%S+08:00") if account.expires_at else "",
106
+ "id_token": account.id_token or "",
107
+ "account_id": account.account_id or "",
108
+ "access_token": account.access_token or "",
109
+ "last_refresh": account.last_refresh.strftime("%Y-%m-%dT%H:%M:%S+08:00") if account.last_refresh else "",
110
+ "refresh_token": account.refresh_token or "",
111
+ }
112
+
113
+
114
+ def upload_to_cpa(
115
+ token_data: dict,
116
+ proxy: str = None,
117
+ api_url: str = None,
118
+ api_token: str = None,
119
+ ) -> Tuple[bool, str]:
120
+ """
121
+ 上传单个账号到 CPA 管理平台(不走代理)
122
+
123
+ Args:
124
+ token_data: Token JSON 数据
125
+ proxy: 保留参数,不使用(CPA 上传始终直连)
126
+ api_url: 指定 CPA API URL(优先于全局配置)
127
+ api_token: 指定 CPA API Token(优先于全局配置)
128
+
129
+ Returns:
130
+ (成功标志, 消息或错误信息)
131
+ """
132
+ settings = get_settings()
133
+
134
+ # 优先使用传入的参数,否则退回全局配置
135
+ effective_url = api_url or settings.cpa_api_url
136
+ effective_token = api_token or (settings.cpa_api_token.get_secret_value() if settings.cpa_api_token else "")
137
+
138
+ # 仅当未指定服务时才检查全局启用开关
139
+ if not api_url and not settings.cpa_enabled:
140
+ return False, "CPA 上传未启用"
141
+
142
+ if not effective_url:
143
+ return False, "CPA API URL 未配置"
144
+
145
+ if not effective_token:
146
+ return False, "CPA API Token 未配置"
147
+
148
+ upload_url = _normalize_cpa_auth_files_url(effective_url)
149
+
150
+ filename = f"{token_data['email']}.json"
151
+ file_content = json.dumps(token_data, ensure_ascii=False, indent=2).encode("utf-8")
152
+
153
+ try:
154
+ response = _post_cpa_auth_file_multipart(
155
+ upload_url,
156
+ filename,
157
+ file_content,
158
+ effective_token,
159
+ )
160
+
161
+ if response.status_code in (200, 201):
162
+ return True, "上传成功"
163
+
164
+ if response.status_code in (404, 405, 415):
165
+ logger.warning("CPA multipart 上传失败,尝试原始 JSON 回退: %s", response.status_code)
166
+ fallback_response = _post_cpa_auth_file_raw_json(
167
+ upload_url,
168
+ filename,
169
+ file_content,
170
+ effective_token,
171
+ )
172
+ if fallback_response.status_code in (200, 201):
173
+ return True, "上传成功"
174
+ response = fallback_response
175
+
176
+ return False, _extract_cpa_error(response)
177
+
178
+ except Exception as e:
179
+ logger.error(f"CPA 上传异常: {e}")
180
+ return False, f"上传异常: {str(e)}"
181
+
182
+
183
+ def batch_upload_to_cpa(
184
+ account_ids: List[int],
185
+ proxy: str = None,
186
+ api_url: str = None,
187
+ api_token: str = None,
188
+ ) -> dict:
189
+ """
190
+ 批量上传账号到 CPA 管理平台
191
+
192
+ Args:
193
+ account_ids: 账号 ID 列表
194
+ proxy: 可选的代理 URL
195
+ api_url: 指定 CPA API URL(优先于全局配置)
196
+ api_token: 指定 CPA API Token(优先于全局配置)
197
+
198
+ Returns:
199
+ 包含成功/失败统计和详情的字典
200
+ """
201
+ results = {
202
+ "success_count": 0,
203
+ "failed_count": 0,
204
+ "skipped_count": 0,
205
+ "details": []
206
+ }
207
+
208
+ with get_db() as db:
209
+ for account_id in account_ids:
210
+ account = db.query(Account).filter(Account.id == account_id).first()
211
+
212
+ if not account:
213
+ results["failed_count"] += 1
214
+ results["details"].append({
215
+ "id": account_id,
216
+ "email": None,
217
+ "success": False,
218
+ "error": "账号不存在"
219
+ })
220
+ continue
221
+
222
+ # 检查是否已有 Token
223
+ if not account.access_token:
224
+ results["skipped_count"] += 1
225
+ results["details"].append({
226
+ "id": account_id,
227
+ "email": account.email,
228
+ "success": False,
229
+ "error": "缺少 Token"
230
+ })
231
+ continue
232
+
233
+ # 生成 Token JSON
234
+ token_data = generate_token_json(account)
235
+
236
+ # 上传
237
+ success, message = upload_to_cpa(token_data, proxy, api_url=api_url, api_token=api_token)
238
+
239
+ if success:
240
+ # 更新数据库状态
241
+ account.cpa_uploaded = True
242
+ account.cpa_uploaded_at = datetime.utcnow()
243
+ db.commit()
244
+
245
+ results["success_count"] += 1
246
+ results["details"].append({
247
+ "id": account_id,
248
+ "email": account.email,
249
+ "success": True,
250
+ "message": message
251
+ })
252
+ else:
253
+ results["failed_count"] += 1
254
+ results["details"].append({
255
+ "id": account_id,
256
+ "email": account.email,
257
+ "success": False,
258
+ "error": message
259
+ })
260
+
261
+ return results
262
+
263
+
264
+ def test_cpa_connection(api_url: str, api_token: str, proxy: str = None) -> Tuple[bool, str]:
265
+ """
266
+ 测试 CPA 连接(不走代理)
267
+
268
+ Args:
269
+ api_url: CPA API URL
270
+ api_token: CPA API Token
271
+ proxy: 保留参数,不使用(CPA 始终直连)
272
+
273
+ Returns:
274
+ (成功标志, 消息)
275
+ """
276
+ if not api_url:
277
+ return False, "API URL 不能为空"
278
+
279
+ if not api_token:
280
+ return False, "API Token 不能为空"
281
+
282
+ test_url = _normalize_cpa_auth_files_url(api_url)
283
+ headers = _build_cpa_headers(api_token)
284
+
285
+ try:
286
+ response = cffi_requests.get(
287
+ test_url,
288
+ headers=headers,
289
+ proxies=None,
290
+ timeout=10,
291
+ impersonate="chrome110",
292
+ )
293
+
294
+ if response.status_code == 200:
295
+ return True, "CPA 连接测试成功"
296
+ if response.status_code == 401:
297
+ return False, "连接成功,但 API Token 无效"
298
+ if response.status_code == 403:
299
+ return False, "连接成功,但服务端未启用远程管理或当前 Token 无权限"
300
+ if response.status_code == 404:
301
+ return False, "未找到 CPA auth-files 接口,请检查 API URL 是否填写为根地址、/v0/management 或完整 auth-files 地址"
302
+ if response.status_code == 503:
303
+ return False, "连接成功,但服务端认证管理器不可用"
304
+
305
+ return False, f"服务器返回异常状态码: {response.status_code}"
306
+
307
+ except cffi_requests.exceptions.ConnectionError as e:
308
+ return False, f"无法连接到服务器: {str(e)}"
309
+ except cffi_requests.exceptions.Timeout:
310
+ return False, "连接超时,请检查网络配置"
311
+ except Exception as e:
312
+ return False, f"连接测试失败: {str(e)}"
src/core/upload/sub2api_upload.py ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Sub2API 账号上传功能
3
+ 将账号以 sub2api-data 格式批量导入到 Sub2API 平台
4
+ """
5
+
6
+ import json
7
+ import logging
8
+ from datetime import datetime, timezone
9
+ from typing import List, Tuple, Optional
10
+
11
+ from curl_cffi import requests as cffi_requests
12
+
13
+ from ...database.session import get_db
14
+ from ...database.models import Account
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def upload_to_sub2api(
20
+ accounts: List[Account],
21
+ api_url: str,
22
+ api_key: str,
23
+ concurrency: int = 3,
24
+ priority: int = 50,
25
+ ) -> Tuple[bool, str]:
26
+ """
27
+ 上传账号列表到 Sub2API 平台(不走代理)
28
+
29
+ Args:
30
+ accounts: 账号模型实例列表
31
+ api_url: Sub2API 地址,如 http://host
32
+ api_key: Admin API Key(x-api-key header)
33
+ concurrency: 账号并发数,默认 3
34
+ priority: 账号优先级,默认 50
35
+
36
+ Returns:
37
+ (成功标志, 消息)
38
+ """
39
+ if not accounts:
40
+ return False, "无可上传的账号"
41
+
42
+ if not api_url:
43
+ return False, "Sub2API URL 未配置"
44
+
45
+ if not api_key:
46
+ return False, "Sub2API API Key 未配置"
47
+
48
+ exported_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
49
+
50
+ account_items = []
51
+ for acc in accounts:
52
+ if not acc.access_token:
53
+ continue
54
+ expires_at = int(acc.expires_at.timestamp()) if acc.expires_at else 0
55
+ account_items.append({
56
+ "name": acc.email,
57
+ "platform": "openai",
58
+ "type": "oauth",
59
+ "credentials": {
60
+ "access_token": acc.access_token,
61
+ "chatgpt_account_id": acc.account_id or "",
62
+ "chatgpt_user_id": "",
63
+ "client_id": acc.client_id or "",
64
+ "expires_at": expires_at,
65
+ "expires_in": 863999,
66
+ "model_mapping": {
67
+ "gpt-5.1": "gpt-5.1",
68
+ "gpt-5.1-codex": "gpt-5.1-codex",
69
+ "gpt-5.1-codex-max": "gpt-5.1-codex-max",
70
+ "gpt-5.1-codex-mini": "gpt-5.1-codex-mini",
71
+ "gpt-5.2": "gpt-5.2",
72
+ "gpt-5.2-codex": "gpt-5.2-codex",
73
+ "gpt-5.3": "gpt-5.3",
74
+ "gpt-5.3-codex": "gpt-5.3-codex",
75
+ "gpt-5.4": "gpt-5.4"
76
+ },
77
+ "organization_id": acc.workspace_id or "",
78
+ "refresh_token": acc.refresh_token or "",
79
+ },
80
+ "extra": {},
81
+ "concurrency": concurrency,
82
+ "priority": priority,
83
+ "rate_multiplier": 1,
84
+ "auto_pause_on_expired": True,
85
+ })
86
+
87
+ if not account_items:
88
+ return False, "所有账号均缺少 access_token,无法上传"
89
+
90
+ payload = {
91
+ "data": {
92
+ "type": "sub2api-data",
93
+ "version": 1,
94
+ "exported_at": exported_at,
95
+ "proxies": [],
96
+ "accounts": account_items,
97
+ },
98
+ "skip_default_group_bind": True,
99
+ }
100
+
101
+ url = api_url.rstrip("/") + "/api/v1/admin/accounts/data"
102
+ headers = {
103
+ "Content-Type": "application/json",
104
+ "x-api-key": api_key,
105
+ "Idempotency-Key": f"import-{exported_at}",
106
+ }
107
+
108
+ try:
109
+ response = cffi_requests.post(
110
+ url,
111
+ json=payload,
112
+ headers=headers,
113
+ proxies=None,
114
+ timeout=30,
115
+ impersonate="chrome110",
116
+ )
117
+
118
+ if response.status_code in (200, 201):
119
+ return True, f"成功上传 {len(account_items)} 个账号"
120
+
121
+ error_msg = f"上传失败: HTTP {response.status_code}"
122
+ try:
123
+ detail = response.json()
124
+ if isinstance(detail, dict):
125
+ error_msg = detail.get("message", error_msg)
126
+ except Exception:
127
+ error_msg = f"{error_msg} - {response.text[:200]}"
128
+ return False, error_msg
129
+
130
+ except Exception as e:
131
+ logger.error(f"Sub2API 上传异常: {e}")
132
+ return False, f"上传异常: {str(e)}"
133
+
134
+
135
+ def batch_upload_to_sub2api(
136
+ account_ids: List[int],
137
+ api_url: str,
138
+ api_key: str,
139
+ concurrency: int = 3,
140
+ priority: int = 50,
141
+ ) -> dict:
142
+ """
143
+ 批量上传指定 ID 的账号到 Sub2API 平台
144
+
145
+ Returns:
146
+ 包含成功/失败/跳过统计和详情的字典
147
+ """
148
+ results = {
149
+ "success_count": 0,
150
+ "failed_count": 0,
151
+ "skipped_count": 0,
152
+ "details": []
153
+ }
154
+
155
+ with get_db() as db:
156
+ accounts = []
157
+ for account_id in account_ids:
158
+ acc = db.query(Account).filter(Account.id == account_id).first()
159
+ if not acc:
160
+ results["failed_count"] += 1
161
+ results["details"].append({"id": account_id, "email": None, "success": False, "error": "账号不存在"})
162
+ continue
163
+ if not acc.access_token:
164
+ results["skipped_count"] += 1
165
+ results["details"].append({"id": account_id, "email": acc.email, "success": False, "error": "缺少 access_token"})
166
+ continue
167
+ accounts.append(acc)
168
+
169
+ if not accounts:
170
+ return results
171
+
172
+ success, message = upload_to_sub2api(accounts, api_url, api_key, concurrency, priority)
173
+
174
+ if success:
175
+ for acc in accounts:
176
+ results["success_count"] += 1
177
+ results["details"].append({"id": acc.id, "email": acc.email, "success": True, "message": message})
178
+ else:
179
+ for acc in accounts:
180
+ results["failed_count"] += 1
181
+ results["details"].append({"id": acc.id, "email": acc.email, "success": False, "error": message})
182
+
183
+ return results
184
+
185
+
186
+ def test_sub2api_connection(api_url: str, api_key: str) -> Tuple[bool, str]:
187
+ """
188
+ 测试 Sub2API 连接(GET /api/v1/admin/accounts/data 探活)
189
+
190
+ Returns:
191
+ (成功标志, 消息)
192
+ """
193
+ if not api_url:
194
+ return False, "API URL 不能为空"
195
+ if not api_key:
196
+ return False, "API Key 不能为空"
197
+
198
+ url = api_url.rstrip("/") + "/api/v1/admin/accounts/data"
199
+ headers = {"x-api-key": api_key}
200
+
201
+ try:
202
+ response = cffi_requests.get(
203
+ url,
204
+ headers=headers,
205
+ proxies=None,
206
+ timeout=10,
207
+ impersonate="chrome110",
208
+ )
209
+
210
+ if response.status_code in (200, 201, 204, 405):
211
+ return True, "Sub2API 连接测试成功"
212
+ if response.status_code == 401:
213
+ return False, "连接成功,但 API Key 无效"
214
+ if response.status_code == 403:
215
+ return False, "连接成功,但权限不足"
216
+
217
+ return False, f"服务器返回异常状态码: {response.status_code}"
218
+
219
+ except cffi_requests.exceptions.ConnectionError as e:
220
+ return False, f"无法连接到服务器: {str(e)}"
221
+ except cffi_requests.exceptions.Timeout:
222
+ return False, "连接超时,请检查网络配置"
223
+ except Exception as e:
224
+ return False, f"连接测试失败: {str(e)}"