Upload 137 files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +19 -0
- .github/workflows/build.yml +111 -0
- .github/workflows/docker-publish.yml +68 -0
- .gitignore +98 -0
- CLAUDE.md +181 -0
- Dockerfile +37 -0
- LICENSE +21 -0
- README.md +246 -5
- build.bat +51 -0
- build.sh +50 -0
- codex_register.spec +151 -0
- deploy_140.py +76 -0
- docker-compose.yml +19 -0
- docs/anti-detection-critical-analysis.md +149 -0
- docs/anti-detection-final-recommendations.md +416 -0
- docs/external-api-examples.md +524 -0
- docs/external-api-quickstart.md +309 -0
- docs/external-api-webui-management-plan.md +413 -0
- docs/http-only-device-fingerprint-hardening.md +305 -0
- docs/register_simplification_analysis.md +114 -0
- docs/superpowers/plans/2026-03-27-external-failure-category.md +98 -0
- docs/temp-mail-external-api.md +416 -0
- docs/third-party-integration-guide.md +455 -0
- pyproject.toml +43 -0
- requirements.txt +15 -0
- src/__init__.py +24 -0
- src/config/__init__.py +53 -0
- src/config/constants.py +413 -0
- src/config/settings.py +790 -0
- src/core/__init__.py +32 -0
- src/core/dynamic_proxy.py +118 -0
- src/core/email_service_catalog.py +152 -0
- src/core/external_batches/__init__.py +9 -0
- src/core/external_batches/recovery.py +40 -0
- src/core/external_batches/service.py +448 -0
- src/core/fetch_logs.py +33 -0
- src/core/http_client.py +436 -0
- src/core/openai/__init__.py +3 -0
- src/core/openai/browser_profile.py +146 -0
- src/core/openai/oauth.py +410 -0
- src/core/openai/payment.py +261 -0
- src/core/openai/sentinel.py +108 -0
- src/core/openai/token_refresh.py +332 -0
- src/core/register.py +0 -0
- src/core/registration_post_create.py +137 -0
- src/core/registration_upload.py +48 -0
- src/core/service_selection.py +163 -0
- src/core/upload/__init__.py +3 -0
- src/core/upload/cpa_upload.py +312 -0
- 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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: indigo
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
-
license: apache-2.0
|
| 9 |
---
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
| 18 |
+
[](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)}"
|