账号总览
+账号总体状态、分布和订阅账号卡片视图
+From b8a41612f59fb0cb665c4bd357d8646e9ea38d0c Mon Sep 17 00:00:00 2001 From: wold9527 <65499233+wold9527@users.noreply.github.com> Date: Wed, 25 Mar 2026 10:00:37 +0800 Subject: [PATCH] Initial commit --- .env.example | 7 + .github/workflows/docker-image.yml | 61 + Dockerfile | 19 +- README.md | 15 +- docker-compose.yml | 10 +- make-test-win.bat | 27 + requirements.txt | 4 +- scripts/docker/start-webui.sh | 35 + scripts/make_windows_test_bundle.ps1 | 245 ++ src/config/settings.py | 8 + src/core/db_logs.py | 164 + src/core/openai/browser_bind.py | 1311 ++++++++ src/core/openai/overview.py | 767 +++++ src/core/openai/payment.py | 1154 ++++++- src/core/openai/random_billing.py | 482 +++ src/core/openai/token_refresh.py | 137 +- src/core/register.py | 2168 +++++++++++-- src/core/timezone_utils.py | 61 + src/core/upload/sub2api_upload.py | 174 +- src/core/utils.py | 17 +- src/database/crud.py | 8 +- src/database/models.py | 62 +- src/database/session.py | 5 +- src/services/outlook/email_parser.py | 27 +- src/services/outlook/providers/graph_api.py | 114 +- src/services/outlook/providers/imap_new.py | 49 +- src/services/outlook/providers/imap_old.py | 61 +- src/services/outlook/service.py | 39 +- src/services/temp_mail.py | 471 ++- src/web/app.py | 110 +- src/web/routes/__init__.py | 2 + src/web/routes/accounts.py | 1258 +++++++- src/web/routes/email.py | 11 +- src/web/routes/logs.py | 123 + src/web/routes/payment.py | 3177 ++++++++++++++++++- src/web/routes/registration.py | 34 +- src/web/routes/settings.py | 109 +- src/web/routes/upload/sub2api_services.py | 130 +- src/web/task_manager.py | 10 + static/css/style.css | 186 +- static/js/accounts.js | 189 +- static/js/accounts_overview.js | 1050 ++++++ static/js/app.js | 94 + static/js/email_services.js | 2 +- static/js/logs.js | 277 ++ static/js/payment.js | 1882 ++++++++++- static/js/settings.js | 353 +-- templates/accounts.html | 84 +- templates/accounts_overview.html | 740 +++++ templates/auto_team.html | 57 + templates/card_pool.html | 57 + templates/email_services.html | 8 +- templates/index.html | 130 +- templates/logs.html | 248 ++ templates/payment.html | 622 +++- templates/settings.html | 45 +- tests/test_temp_mail_service.py | 291 ++ webui.py | 6 + 58 files changed, 17428 insertions(+), 1559 deletions(-) create mode 100644 .github/workflows/docker-image.yml create mode 100644 make-test-win.bat create mode 100644 scripts/docker/start-webui.sh create mode 100644 scripts/make_windows_test_bundle.ps1 create mode 100644 src/core/db_logs.py create mode 100644 src/core/openai/browser_bind.py create mode 100644 src/core/openai/overview.py create mode 100644 src/core/openai/random_billing.py create mode 100644 src/core/timezone_utils.py create mode 100644 src/web/routes/logs.py create mode 100644 static/js/accounts_overview.js create mode 100644 static/js/logs.js create mode 100644 templates/accounts_overview.html create mode 100644 templates/auto_team.html create mode 100644 templates/card_pool.html create mode 100644 templates/logs.html create mode 100644 tests/test_temp_mail_service.py diff --git a/.env.example b/.env.example index b21ea40..821fd16 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,10 @@ # 远程 PostgreSQL(优先) # APP_DATABASE_URL=postgresql://user:password@host:5432/dbname + +# ── 第三方自动绑卡(可选) ─────────────────────────────────── +# 第三方绑卡 API 地址(支持填 Workers 根域名,后端会自动补全路径) +# BIND_CARD_API_URL=https://twilight-river-f148.482091502.workers.dev/ + +# 第三方绑卡 API Key(可留空,后端会先尝试无鉴权) +# BIND_CARD_API_KEY=your_api_key_here diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 0000000..167b6fe --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,61 @@ +name: Docker Image CI + +on: + push: + branches: [ "master", "main" ] + tags: [ 'v*.*.*' ] + pull_request: + branches: [ "master", "main" ] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push-image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to the Container registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + # 修改这里:使用 GHCR_TOKEN 而不是 GITHUB_TOKEN + password: ${{ secrets.GHCR_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest + type=ref,event=branch + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=sha + + + + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile index db63397..5cdb5df 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,10 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ # WebUI 默认配置 WEBUI_HOST=0.0.0.0 \ WEBUI_PORT=1455 \ + DISPLAY=:99 \ + ENABLE_VNC=1 \ + VNC_PORT=5900 \ + NOVNC_PORT=6080 \ LOG_LEVEL=info \ DEBUG=0 @@ -19,18 +23,29 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends \ gcc \ python3-dev \ + xvfb \ + fluxbox \ + x11vnc \ + websockify \ + novnc \ + ca-certificates \ && rm -rf /var/lib/apt/lists/* # 复制依赖文件并安装 COPY requirements.txt . RUN pip install --no-cache-dir --upgrade pip \ - && pip install --no-cache-dir -r requirements.txt + && pip install --no-cache-dir -r requirements.txt \ + && python -m playwright install --with-deps chromium # 复制项目代码 COPY . . +COPY scripts/docker/start-webui.sh /app/scripts/docker/start-webui.sh +RUN chmod +x /app/scripts/docker/start-webui.sh # 暴露端口 EXPOSE 1455 +EXPOSE 6080 +EXPOSE 5900 # 启动 WebUI -CMD ["python", "webui.py"] +CMD ["/app/scripts/docker/start-webui.sh"] diff --git a/README.md b/README.md index e99b6d0..9b275fd 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,8 @@ [](https://www.python.org/) ## QQ群 -- 交流群:291638849 -- https://qm.qq.com/q/ZTCKxawxeo -## Telegram交流频道 -- https://t.me/cysq2026 +- 交流群: https://qm.qq.com/q/ZTCKxawxeo ## 致谢 @@ -129,13 +126,21 @@ codex-console.exe --access-password mypassword docker-compose up -d ``` -你可以在 `docker-compose.yml` 中修改环境变量,比如端口和访问密码。 +你可以在 `docker-compose.yml` 中修改环境变量,比如端口和访问密码。 +如果需要看“全自动绑卡”的可视化浏览器,打开: + +- noVNC: `http://127.0.0.1:6080` ### 使用 docker run ```bash docker run -d \ -p 1455:1455 \ + -p 6080:6080 \ + -e DISPLAY=:99 \ + -e ENABLE_VNC=1 \ + -e VNC_PORT=5900 \ + -e NOVNC_PORT=6080 \ -e WEBUI_HOST=0.0.0.0 \ -e WEBUI_PORT=1455 \ -e WEBUI_ACCESS_PASSWORD=your_secure_password \ diff --git a/docker-compose.yml b/docker-compose.yml index e579408..95aa46e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,11 +3,19 @@ version: '3.8' services: webui: build: . + shm_size: "1gb" ports: - "1455:1455" + - "6080:6080" + # 如需使用 VNC 客户端直连可打开: + # - "5900:5900" environment: - WEBUI_HOST=0.0.0.0 - WEBUI_PORT=1455 + - DISPLAY=:99 + - ENABLE_VNC=1 + - VNC_PORT=5900 + - NOVNC_PORT=6080 - DEBUG=0 - LOG_LEVEL=info # 如果需要访问密码,可以在这里取消注释并设置 @@ -16,4 +24,4 @@ services: # 挂载数据目录以持久化数据库和日志 - ./data:/app/data - ./logs:/app/logs - restart: unless-stopped \ No newline at end of file + restart: unless-stopped diff --git a/make-test-win.bat b/make-test-win.bat new file mode 100644 index 0000000..bd3d76b --- /dev/null +++ b/make-test-win.bat @@ -0,0 +1,27 @@ +@echo off +setlocal +cd /d "%~dp0" + +set "PS_CMD=" +where powershell.exe >nul 2>nul +if not errorlevel 1 set "PS_CMD=powershell.exe" +if not defined PS_CMD ( + where pwsh.exe >nul 2>nul + if not errorlevel 1 set "PS_CMD=pwsh.exe" +) +if not defined PS_CMD ( + echo. + echo PowerShell not found. Install Windows PowerShell or PowerShell 7 first. + exit /b 1 +) + +%PS_CMD% -ExecutionPolicy Bypass -File ".\scripts\make_windows_test_bundle.ps1" +if errorlevel 1 ( + echo. + echo Failed to create test bundle. + exit /b 1 +) + +echo. +echo Test bundle created successfully. +echo You can run: ..\codex-console2-win-test\start-webui.bat diff --git a/requirements.txt b/requirements.txt index 462b7fc..6131f37 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,5 +11,5 @@ python-multipart>=0.0.6 sqlalchemy>=2.0.0 aiosqlite>=0.19.0 psycopg[binary]>=3.1.18 -# 可选:无痕打开支付页需要 playwright(pip install playwright && playwright install chromium) -# playwright>=1.40.0 \ No newline at end of file +# 自动绑卡(local_auto)依赖 +playwright>=1.40.0 diff --git a/scripts/docker/start-webui.sh b/scripts/docker/start-webui.sh new file mode 100644 index 0000000..8a7ef92 --- /dev/null +++ b/scripts/docker/start-webui.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +export DISPLAY="${DISPLAY:-:99}" +ENABLE_VNC="${ENABLE_VNC:-1}" +VNC_PORT="${VNC_PORT:-5900}" +NOVNC_PORT="${NOVNC_PORT:-6080}" + +if [[ "${ENABLE_VNC}" == "1" || "${ENABLE_VNC,,}" == "true" ]]; then + echo "[docker] starting Xvfb on ${DISPLAY}" + Xvfb "${DISPLAY}" -screen 0 1366x900x24 -ac +extension RANDR >/tmp/xvfb.log 2>&1 & + + echo "[docker] starting fluxbox window manager" + fluxbox >/tmp/fluxbox.log 2>&1 & + + echo "[docker] starting x11vnc on :${VNC_PORT}" + x11vnc \ + -display "${DISPLAY}" \ + -rfbport "${VNC_PORT}" \ + -forever \ + -shared \ + -nopw \ + >/tmp/x11vnc.log 2>&1 & + + NOVNC_WEB_ROOT="/usr/share/novnc" + if [[ ! -d "${NOVNC_WEB_ROOT}" ]]; then + NOVNC_WEB_ROOT="/usr/share/novnc/" + fi + echo "[docker] starting noVNC on :${NOVNC_PORT} (web=${NOVNC_WEB_ROOT})" + websockify --web="${NOVNC_WEB_ROOT}" "${NOVNC_PORT}" "127.0.0.1:${VNC_PORT}" >/tmp/novnc.log 2>&1 & +fi + +echo "[docker] starting webui..." +exec python webui.py + diff --git a/scripts/make_windows_test_bundle.ps1 b/scripts/make_windows_test_bundle.ps1 new file mode 100644 index 0000000..ef5ea2f --- /dev/null +++ b/scripts/make_windows_test_bundle.ps1 @@ -0,0 +1,245 @@ +param( + [string]$TargetDir = "", + [string]$ListenHost = "127.0.0.1", + [int]$Port = 1455, + [string]$AccessPassword = "admin123" +) + +$ErrorActionPreference = "Stop" + +function Write-AsciiCrlfFile { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Content + ) + $normalized = [regex]::Replace($Content, "`r?`n", "`r`n") + [System.IO.File]::WriteAllText($Path, $normalized, [System.Text.Encoding]::ASCII) +} + +$RepoRoot = Split-Path -Parent $PSScriptRoot +if (-not $TargetDir) { + $TargetDir = Join-Path (Split-Path -Parent $RepoRoot) "codex-console2-win-test" +} + +Write-Host "[bundle] repo: $RepoRoot" +Write-Host "[bundle] target: $TargetDir" + +New-Item -ItemType Directory -Path $TargetDir -Force | Out-Null + +$distDir = Join-Path $RepoRoot "dist" +$exeCandidates = @() +if (Test-Path $distDir) { + $exeCandidates += Get-ChildItem -Path $distDir -Filter "codex-console-windows-*.exe" -File -ErrorAction SilentlyContinue + $exeCandidates += Get-ChildItem -Path $distDir -Filter "codex-console.exe" -File -ErrorAction SilentlyContinue +} + +$mode = "source" +if ($exeCandidates.Count -gt 0) { + $latestExe = $exeCandidates | Sort-Object LastWriteTime -Descending | Select-Object -First 1 + Copy-Item $latestExe.FullName (Join-Path $TargetDir "codex-console.exe") -Force + $mode = "exe" + Write-Host "[bundle] use exe: $($latestExe.Name)" +} +else { + Write-Host "[bundle] exe not found, fallback to source mode." + $copyItems = @( + "webui.py", + "requirements.txt", + "pyproject.toml", + ".env.example", + "templates", + "static", + "src" + ) + foreach ($item in $copyItems) { + $srcPath = Join-Path $RepoRoot $item + if (Test-Path $srcPath) { + $dstPath = Join-Path $TargetDir $item + if (Test-Path $dstPath) { + Remove-Item -Path $dstPath -Recurse -Force + } + Copy-Item $srcPath $dstPath -Recurse -Force + } + } +} + +$envFile = Join-Path $TargetDir ".env" +if (-not (Test-Path $envFile)) { + $envLines = @( + "# Generated by scripts/make_windows_test_bundle.ps1", + "WEBUI_HOST=$ListenHost", + "WEBUI_PORT=$Port", + "WEBUI_ACCESS_PASSWORD=$AccessPassword", + "DEBUG=false", + "LOG_LEVEL=INFO" + ) + Set-Content -Path $envFile -Value $envLines -Encoding UTF8 +} + +$startScript = @" +@echo off +setlocal +cd /d "%~dp0" + +set "WEBUI_HOST=$ListenHost" +set "WEBUI_PORT=$Port" +set "WEBUI_ACCESS_PASSWORD=$AccessPassword" +set "LAUNCHER_LOG=%~dp0launcher-debug.log" +set "EXIT_CODE=0" +set "PY_EXE=" +set "PY_ARGS=" + +> "%LAUNCHER_LOG%" echo launcher start: %DATE% %TIME% +>> "%LAUNCHER_LOG%" echo cwd: %CD% + +echo =================================================== +echo codex-console2 Windows test launcher +echo Host: %WEBUI_HOST% Port: %WEBUI_PORT% +echo =================================================== + +if exist "codex-console.exe" goto RUN_EXE +goto RUN_SOURCE + +:RUN_EXE +echo [mode] exe +>> "%LAUNCHER_LOG%" echo [mode] exe +start "" "http://%WEBUI_HOST%:%WEBUI_PORT%" +codex-console.exe --host %WEBUI_HOST% --port %WEBUI_PORT% --access-password %WEBUI_ACCESS_PASSWORD% >> "%LAUNCHER_LOG%" 2>&1 +set "EXIT_CODE=%ERRORLEVEL%" +if not "%EXIT_CODE%"=="0" ( + echo. + echo codex-console.exe exited with code %EXIT_CODE% + goto END +) +echo. +echo codex-console.exe exited normally. +goto END + +:RUN_SOURCE +echo [mode] source +>> "%LAUNCHER_LOG%" echo [mode] source +if not exist "requirements.txt" ( + echo requirements.txt not found in %CD% + set "EXIT_CODE=1" + goto END +) + +where python >nul 2>nul +if not errorlevel 1 ( + python -c "import sys" >nul 2>nul + if not errorlevel 1 ( + set "PY_EXE=python" + set "PY_ARGS=" + ) +) + +if not defined PY_EXE ( + where py >nul 2>nul + if not errorlevel 1 ( + py -3 -c "import sys" >nul 2>nul + if not errorlevel 1 ( + set "PY_EXE=py" + set "PY_ARGS=-3" + ) + ) +) + +if not defined PY_EXE ( + echo Valid Python 3 launcher not found in PATH. Install Python 3.10+ first. + echo Download: https://www.python.org/downloads/windows/ + set "EXIT_CODE=1" + goto END +) +echo python launcher: %PY_EXE% %PY_ARGS% +>> "%LAUNCHER_LOG%" echo python launcher: %PY_EXE% %PY_ARGS% + +if not exist ".venv\Scripts\python.exe" ( + echo creating venv... + "%PY_EXE%" %PY_ARGS% -m venv .venv >> "%LAUNCHER_LOG%" 2>&1 + if errorlevel 1 ( + echo failed to create venv + set "EXIT_CODE=1" + goto END + ) +) + +.venv\Scripts\python.exe -c "import fastapi,uvicorn,sqlalchemy,jinja2" >> "%LAUNCHER_LOG%" 2>&1 +if errorlevel 1 ( + echo installing dependencies... + .venv\Scripts\python.exe -m pip install --upgrade pip >> "%LAUNCHER_LOG%" 2>&1 + .venv\Scripts\python.exe -m pip install -r requirements.txt >> "%LAUNCHER_LOG%" 2>&1 + if errorlevel 1 ( + echo failed to install dependencies + set "EXIT_CODE=1" + goto END + ) +) + +if not exist ".venv\.playwright_chromium_ok" ( + .venv\Scripts\python.exe -c "import playwright" >nul 2>nul + if errorlevel 1 ( + echo playwright package missing, installing... + .venv\Scripts\python.exe -m pip install playwright >> "%LAUNCHER_LOG%" 2>&1 + ) + echo installing playwright chromium ^(first run may take a while^)... + .venv\Scripts\python.exe -m playwright install chromium >> "%LAUNCHER_LOG%" 2>&1 + if not errorlevel 1 ( + type nul > ".venv\.playwright_chromium_ok" + ) +) + +start "" "http://%WEBUI_HOST%:%WEBUI_PORT%" +echo starting webui... +.venv\Scripts\python.exe webui.py --host %WEBUI_HOST% --port %WEBUI_PORT% --access-password %WEBUI_ACCESS_PASSWORD% >> "%LAUNCHER_LOG%" 2>&1 +set "EXIT_CODE=%ERRORLEVEL%" +if not "%EXIT_CODE%"=="0" ( + echo. + echo webui exited with code %EXIT_CODE% + echo check logs under: %CD%\logs +) +goto END + +:END +echo. +echo launcher exit code: %EXIT_CODE% +echo debug log: %LAUNCHER_LOG% +if not "%EXIT_CODE%"=="0" ( + echo 启动失败,请把 launcher-debug.log 发我。 +) +pause +exit /b %EXIT_CODE% +"@ +Write-AsciiCrlfFile -Path (Join-Path $TargetDir "start-webui.bat") -Content $startScript + +$stopScript = @" +@echo off +setlocal +echo stopping codex-console.exe ... +taskkill /IM codex-console.exe /F >nul 2>nul +echo done. +"@ +Write-AsciiCrlfFile -Path (Join-Path $TargetDir "stop-webui.bat") -Content $stopScript + +$readme = @" +# codex-console2 Windows test bundle + +Generated from: $RepoRoot +Mode: $mode + +## Quick start + +1. Double click `start-webui.bat` +2. Open: `http://${ListenHost}:$Port` +3. Password: $AccessPassword + +## Notes + +- If `codex-console.exe` exists, launcher runs exe mode. +- If exe is missing, launcher runs source mode and auto-creates `.venv`. +"@ +Set-Content -Path (Join-Path $TargetDir "README-WIN-TEST.md") -Value $readme -Encoding UTF8 + +Write-Host "" +Write-Host "[bundle] done." +Write-Host "[bundle] mode: $mode" +Write-Host "[bundle] run: $TargetDir\start-webui.bat" diff --git a/src/config/settings.py b/src/config/settings.py index 2803afd..ac4bb9a 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -248,6 +248,12 @@ SETTING_DEFINITIONS: Dict[str, SettingDefinition] = { category=SettingCategory.REGISTRATION, description="注册间隔最大值(秒)" ), + "registration_entry_flow": SettingDefinition( + db_key="registration.entry_flow", + default_value="native", + category=SettingCategory.REGISTRATION, + description="注册入口链路(native=原本链路, abcard=ABCard入口链路;Outlook 邮箱会自动走 Outlook 链路)" + ), # 邮箱服务配置 "email_service_priority": SettingDefinition( @@ -400,6 +406,7 @@ SETTING_TYPES: Dict[str, Type] = { "registration_default_password_length": int, "registration_sleep_min": int, "registration_sleep_max": int, + "registration_entry_flow": str, "email_service_priority": dict, "tempmail_timeout": int, "tempmail_max_retries": int, @@ -663,6 +670,7 @@ class Settings(BaseModel): registration_default_password_length: int = 12 registration_sleep_min: int = 5 registration_sleep_max: int = 30 + registration_entry_flow: str = "native" # 邮箱服务配置 email_service_priority: Dict[str, int] = {"tempmail": 0, "outlook": 1, "moe_mail": 2} diff --git a/src/core/db_logs.py b/src/core/db_logs.py new file mode 100644 index 0000000..00257d9 --- /dev/null +++ b/src/core/db_logs.py @@ -0,0 +1,164 @@ +""" +后台日志入库与清理 +""" + +from __future__ import annotations + +import logging +import threading +import traceback +from datetime import datetime, timedelta +from typing import Any, Dict, Optional + +from sqlalchemy import func + +from ..config.settings import get_settings +from ..database.models import AppLog +from ..database.session import get_db + + +_INSTALL_LOCK = threading.Lock() +_INSTALLED = False + +_SKIP_LOGGER_PREFIXES = ( + "sqlalchemy", + "uvicorn.access", + "watchfiles", +) + + +def _should_skip_record(record: logging.LogRecord) -> bool: + logger_name = str(record.name or "") + if not logger_name: + return False + for prefix in _SKIP_LOGGER_PREFIXES: + if logger_name.startswith(prefix): + return True + return False + + +class DatabaseLogHandler(logging.Handler): + """ + 将日志写入 app_logs 表。 + 为避免递归写日志,emit 内部不再产生日志。 + """ + + def __init__(self, min_level: int = logging.INFO): + super().__init__(level=min_level) + self._local = threading.local() + + def emit(self, record: logging.LogRecord) -> None: + if getattr(self._local, "busy", False): + return + if record.levelno < self.level: + return + if _should_skip_record(record): + return + + message = "" + exception_text = None + try: + self._local.busy = True + message = record.getMessage() + if record.exc_info: + exception_text = "".join(traceback.format_exception(*record.exc_info))[-4000:] + elif record.exc_text: + exception_text = str(record.exc_text)[-4000:] + + with get_db() as db: + db.add( + AppLog( + level=record.levelname, + logger=str(record.name or "root"), + module=str(record.module or ""), + pathname=str(record.pathname or ""), + lineno=int(record.lineno or 0), + message=str(message or ""), + exception=exception_text, + created_at=datetime.utcfromtimestamp(record.created), + ) + ) + db.commit() + except Exception: + self.handleError(record) + finally: + self._local.busy = False + + +def install_database_log_handler(min_level: int = logging.INFO) -> bool: + """ + 安装数据库日志处理器(全局仅安装一次)。 + Returns: + 是否本次新安装 + """ + global _INSTALLED + with _INSTALL_LOCK: + if _INSTALLED: + return False + + root_logger = logging.getLogger() + if any(isinstance(handler, DatabaseLogHandler) for handler in root_logger.handlers): + _INSTALLED = True + return False + + handler = DatabaseLogHandler(min_level=min_level) + root_logger.addHandler(handler) + _INSTALLED = True + return True + + +def cleanup_database_logs( + retention_days: Optional[int] = None, + max_rows: int = 50000, +) -> Dict[str, Any]: + """ + 清理后台日志: + 1) 删除超过 retention_days 的日志 + 2) 若总量超过 max_rows,删除最旧的超量部分 + """ + settings = get_settings() + keep_days = int(retention_days if retention_days is not None else settings.log_retention_days or 30) + keep_days = max(1, keep_days) + max_rows = max(1000, int(max_rows)) + cutoff = datetime.utcnow() - timedelta(days=keep_days) + + deleted_by_age = 0 + deleted_by_limit = 0 + + with get_db() as db: + deleted_by_age = ( + db.query(AppLog) + .filter(AppLog.created_at < cutoff) + .delete(synchronize_session=False) + ) + db.commit() + + total = db.query(func.count(AppLog.id)).scalar() or 0 + if total > max_rows: + overflow = int(total - max_rows) + overflow_ids = [ + row_id + for (row_id,) in db.query(AppLog.id) + .order_by(AppLog.created_at.asc(), AppLog.id.asc()) + .limit(overflow) + .all() + ] + if overflow_ids: + deleted_by_limit = ( + db.query(AppLog) + .filter(AppLog.id.in_(overflow_ids)) + .delete(synchronize_session=False) + ) + db.commit() + + remaining = db.query(func.count(AppLog.id)).scalar() or 0 + + return { + "retention_days": keep_days, + "max_rows": max_rows, + "deleted_by_age": int(deleted_by_age or 0), + "deleted_by_limit": int(deleted_by_limit or 0), + "deleted_total": int((deleted_by_age or 0) + (deleted_by_limit or 0)), + "remaining": int(remaining or 0), + } + diff --git a/src/core/openai/browser_bind.py b/src/core/openai/browser_bind.py new file mode 100644 index 0000000..38dfe33 --- /dev/null +++ b/src/core/openai/browser_bind.py @@ -0,0 +1,1311 @@ +""" +本地浏览器自动绑卡(参考 ABCard 的 checkout 自动化流程)。 + +说明: +- 仅做自动填写和提交,不做验证码绕过。 +- 若检测到 challenge(如 hCaptcha/3DS),返回 need_user_action,由前端提示用户手动完成。 +""" + +from __future__ import annotations + +import json +import logging +import os +import random +import re +import shutil +import subprocess +import tempfile +import time +import urllib.request +from typing import Any, Dict, List, Optional, Tuple + +logger = logging.getLogger(__name__) + +_STRIPE_IFRAME_SELECTOR = 'iframe[name*="__privateStripeFrame"]' +_SUCCESS_TEXT_TOKENS = ( + "payment successful", + "successfully subscribed", + "welcome to", + "订阅成功", + "支付成功", +) +_FAIL_TEXT_PATTERNS = ( + ("card was declined", "银行卡被拒"), + ("your card was declined", "银行卡被拒"), + ("card_declined", "银行卡被拒"), + ("insufficient funds", "余额不足"), + ("expired card", "卡已过期"), + ("authentication_required", "需要验证"), + ("payment failed", "支付失败"), + ("unable to process", "无法处理支付"), + ("invalid card", "卡信息无效"), +) + +_COOKIE_ATTR_NAMES = { + "path", + "domain", + "expires", + "max-age", + "samesite", + "secure", + "httponly", + "priority", + "partitioned", +} +_COOKIE_NAME_PATTERN = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$") +_SAFE_COOKIE_NAME_ALLOWLIST = { + "__Secure-next-auth.session-token", + "oai-did", + "oai-client-auth-session", + "__cf_bm", + "cf_clearance", +} + + +def _parse_cookie_str(cookies_str: str, domain: str) -> List[dict]: + cookies: List[dict] = [] + text = str(cookies_str or "").strip() + if not text: + return cookies + for item in text.split(";"): + if "=" not in item: + continue + key, value = item.split("=", 1) + key = key.strip() + value = value.strip() + if not key: + continue + cookies.append( + { + "name": key, + "value": value, + "domain": domain, + "path": "/", + "httpOnly": False, + "secure": True, + "sameSite": "Lax", + } + ) + return cookies + + +def _sanitize_cookie_value(value: str) -> str: + text = str(value or "").strip().strip('"').strip("'") + if not text: + return "" + text = text.replace("\r", "").replace("\n", "") + if ";" in text: + text = text.split(";", 1)[0].strip() + return text + + +def _parse_cookie_pairs(cookies_str: str) -> Dict[str, str]: + text = str(cookies_str or "").strip() + if not text: + return {} + + result: Dict[str, str] = {} + for item in text.split(";"): + raw = str(item or "").strip() + if not raw or "=" not in raw: + continue + name_raw, value_raw = raw.split("=", 1) + name = str(name_raw or "").strip() + value = _sanitize_cookie_value(value_raw) + if not name or not value: + continue + if name.lower() in _COOKIE_ATTR_NAMES: + continue + if not _COOKIE_NAME_PATTERN.match(name): + continue + prev = str(result.get(name) or "") + if (not prev) or (len(value) > len(prev)): + result[name] = value + return result + + +def _build_playwright_cookie_items( + cookies_str: str, + resolved_session: str, + resolved_did: str, +) -> List[dict]: + """ + 构造可被 Playwright 接受的 cookie 列表: + - 过滤属性项/非法名称 + - 仅注入安全白名单 cookie + - __Host- 前缀不携带 domain(避免 Invalid cookie fields) + """ + cookie_map = _parse_cookie_pairs(cookies_str) + + session = _sanitize_cookie_value(resolved_session) + did = _sanitize_cookie_value(resolved_did) + if session: + cookie_map["__Secure-next-auth.session-token"] = session + if did: + cookie_map["oai-did"] = did + + items: List[dict] = [] + for name, value in cookie_map.items(): + if name not in _SAFE_COOKIE_NAME_ALLOWLIST and not name.startswith("__Host-"): + continue + if not value: + continue + + if name.startswith("__Host-"): + items.append( + { + "name": name, + "value": value, + "url": "https://chatgpt.com/", + "secure": True, + "sameSite": "Lax", + } + ) + continue + + items.append( + { + "name": name, + "value": value, + "domain": ".chatgpt.com", + "path": "/", + "httpOnly": bool(name in ("__Secure-next-auth.session-token", "oai-client-auth-session")), + "secure": True, + "sameSite": "Lax", + } + ) + return items + + +def _add_cookies_resilient(context, cookies: List[dict], stage: str) -> None: + if not cookies: + return + try: + context.add_cookies(cookies) + return + except Exception as exc: + names = [str(c.get("name") or "") for c in cookies] + logger.warning("%s 注入完整 cookie 失败,将降级重试: %s | names=%s", stage, exc, ",".join(names[:12])) + + # 降级:仅注入最关键的 session + did + minimal: List[dict] = [] + for item in cookies: + name = str(item.get("name") or "") + if name in ("__Secure-next-auth.session-token", "oai-did"): + minimal.append(item) + if not minimal: + raise RuntimeError("cookie injection failed and no minimal cookies available") + context.add_cookies(minimal) + + +def _extract_cookie_value(cookies_str: str, name: str) -> str: + text = str(cookies_str or "") + if not text: + return "" + prefix = f"{name}=" + for item in text.split(";"): + item = item.strip() + if item.startswith(prefix): + return item[len(prefix) :].strip() + return "" + + +def _extract_session_token_from_cookie_text(cookies_str: str) -> str: + text = str(cookies_str or "") + if not text: + return "" + + direct = _extract_cookie_value(text, "__Secure-next-auth.session-token") + if direct: + return direct + + chunks: Dict[int, str] = {} + for raw in text.split(";"): + item = str(raw or "").strip() + if not item or "=" not in item: + continue + key, value = item.split("=", 1) + name = str(key or "").strip() + if not name.startswith("__Secure-next-auth.session-token."): + continue + try: + idx = int(name.rsplit(".", 1)[-1]) + except Exception: + continue + chunks[idx] = str(value or "").strip() + if chunks: + return "".join(chunks[idx] for idx in sorted(chunks.keys())) + return "" + + +def _extract_cookie_value_from_items(items: List[dict], name: str) -> str: + for item in items or []: + try: + key = str(item.get("name") or "").strip() + if key != name: + continue + return str(item.get("value") or "").strip() + except Exception: + continue + return "" + + +def _extract_session_token_from_items(items: List[dict]) -> str: + direct = _extract_cookie_value_from_items(items, "__Secure-next-auth.session-token") + if direct: + return direct + chunks: Dict[int, str] = {} + for item in items or []: + try: + name = str(item.get("name") or "").strip() + if not name.startswith("__Secure-next-auth.session-token."): + continue + idx = int(name.rsplit(".", 1)[-1]) + chunks[idx] = str(item.get("value") or "").strip() + except Exception: + continue + if chunks: + return "".join(chunks[idx] for idx in sorted(chunks.keys())) + return "" + + +def _normalize_exp_year(exp_year: str) -> str: + digits = re.sub(r"\D", "", str(exp_year or "")) + if not digits: + return "" + if len(digits) >= 2: + return digits[-2:] + return digits.zfill(2) + + +def _find_chrome_binary() -> str: + env_path = str(os.getenv("CHROME_PATH") or "").strip() + candidates = [ + env_path, + os.path.expanduser("~/.cache/ms-playwright/chromium-*/chrome-linux/chrome"), + "/ms-playwright/chromium-*/chrome-linux/chrome", + os.path.expandvars(r"%ProgramFiles%\Google\Chrome\Application\chrome.exe"), + os.path.expandvars(r"%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe"), + os.path.expandvars(r"%LocalAppData%\Google\Chrome\Application\chrome.exe"), + os.path.expandvars(r"%ProgramFiles%\Microsoft\Edge\Application\msedge.exe"), + os.path.expandvars(r"%ProgramFiles(x86)%\Microsoft\Edge\Application\msedge.exe"), + "/usr/bin/google-chrome", + "/usr/bin/chromium-browser", + "/usr/bin/chromium", + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + ] + for binary in candidates: + value = str(binary or "").strip() + if "*" in value: + try: + import glob + matched = sorted(glob.glob(value)) + except Exception: + matched = [] + for mv in matched: + if mv and os.path.exists(mv): + return mv + continue + if value and os.path.exists(value): + return value + + # fallback to PATH lookup + import shutil as _which_lib + + for name in ("chrome", "google-chrome", "chromium-browser", "chromium", "msedge"): + value = _which_lib.which(name) + if value: + return value + return "" + + +def _simulate_human_behavior(page) -> None: + try: + for _ in range(random.randint(3, 6)): + x = random.randint(100, 1100) + y = random.randint(120, 760) + page.mouse.move(x, y, steps=random.randint(8, 18)) + time.sleep(random.uniform(0.08, 0.25)) + page.mouse.wheel(0, random.randint(120, 260)) + time.sleep(random.uniform(0.2, 0.5)) + page.mouse.wheel(0, -random.randint(80, 180)) + except Exception: + pass + + +def _try_click_hcaptcha_checkbox(page) -> bool: + # 先从 frame URL 检测 + try: + for frame in page.frames: + url = str(getattr(frame, "url", "") or "").lower() + if "hcaptcha" not in url: + continue + try: + frame_el = frame.frame_element() + box = frame_el.bounding_box() if frame_el else None + if box and box.get("width", 0) > 20 and box.get("height", 0) > 20: + page.mouse.click(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2) + return True + except Exception: + continue + except Exception: + pass + + # 再从 DOM iframe 兜底 + try: + candidates = page.query_selector_all('iframe[src*="hcaptcha"], iframe[title*="hCaptcha"], iframe[title*="security challenge"]') + for iframe_el in candidates: + box = iframe_el.bounding_box() + if box and box.get("width", 0) > 20 and box.get("height", 0) > 20: + page.mouse.click(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2) + return True + except Exception: + pass + return False + + +def _try_click_challenge_continue(page) -> bool: + """ + 挑战页上的常见继续按钮兜底点击(3DS/hCaptcha)。 + """ + selectors = ( + 'button:has-text("Verify")', + 'button:has-text("Continue")', + 'button:has-text("Complete")', + 'button:has-text("Authorize")', + 'button:has-text("确认")', + 'button:has-text("继续")', + 'button:has-text("完成")', + '[data-testid*="continue"]', + ) + for selector in selectors: + try: + btn = page.query_selector(selector) + if btn and btn.is_visible(): + btn.click() + return True + except Exception: + continue + return False + + +def _detect_challenge_in_context(context, primary_page): + """ + 扫描当前 context 的所有页面,返回是否存在 challenge,以及命中的页面。 + """ + pages = [] + try: + pages = list(getattr(context, "pages", []) or []) + except Exception: + pages = [] + if primary_page and primary_page not in pages: + pages.append(primary_page) + if not pages: + return False, primary_page + + for pg in list(reversed(pages)): + try: + body = _extract_page_text(pg, 2000).lower() + if _detect_challenge(pg, body): + return True, pg + except Exception: + continue + return False, primary_page + + +def _auto_bind_with_cdp_checkout( + *, + checkout_url: str, + cookies_str: str, + session_token: str, + access_token: str, + device_id: str, + card_number: str, + exp_month: str, + exp_year: str, + cvc: str, + billing_name: str, + billing_country: str, + billing_line1: str, + billing_city: str, + billing_state: str, + billing_postal: str, + proxy: Optional[str] = None, + timeout_seconds: int = 180, + post_submit_wait_seconds: int = 90, + headless: bool = False, +) -> Dict[str, Any]: + """ + ABCard 风格: 外部 Chrome + CDP 连接,降低自动化特征后执行 checkout 填卡。 + """ + checkout_url = str(checkout_url or "").strip() + if not checkout_url: + return { + "success": False, + "need_user_action": False, + "error": "checkout_url empty", + "stage": "cdp_input", + "driver": "cdp", + "fallback_recommended": True, + } + + try: + from playwright.sync_api import sync_playwright + except Exception: + return { + "success": False, + "need_user_action": True, + "error": "playwright not installed", + "stage": "cdp_bootstrap", + "driver": "cdp", + "fallback_recommended": True, + } + + resolved_session = str(session_token or "").strip() or _extract_session_token_from_cookie_text(cookies_str) + resolved_did = str(device_id or "").strip() or _extract_cookie_value(cookies_str, "oai-did") + session_missing = not bool(resolved_session) + if session_missing: + logger.warning("CDP 自动绑卡缺少 session token,将继续尝试无会话模式") + + chrome_binary = _find_chrome_binary() + if not chrome_binary: + return { + "success": False, + "need_user_action": False, + "error": "chrome binary not found", + "stage": "cdp_chrome_not_found", + "driver": "cdp", + "fallback_recommended": True, + } + + timeout_seconds = max(int(timeout_seconds), 60) + post_submit_wait_seconds = max(int(post_submit_wait_seconds), 30) + cdp_port = random.randint(9320, 9480) + user_data_dir = tempfile.mkdtemp(prefix=f"codex-cdp-{cdp_port}-") + cdp_url = f"http://127.0.0.1:{cdp_port}" + chrome_args = [ + chrome_binary, + f"--remote-debugging-port={cdp_port}", + "--no-sandbox", + "--disable-dev-shm-usage", + "--no-first-run", + "--no-default-browser-check", + "--disable-extensions", + "--disable-background-networking", + "--disable-sync", + "--window-size=1366,900", + f"--user-data-dir={user_data_dir}", + "about:blank", + ] + if headless: + chrome_args.extend(["--headless=new", "--disable-gpu"]) + else: + # 无头设备上维持 WebGL 指纹可用 + chrome_args.extend([ + "--use-gl=angle", + "--use-angle=swiftshader-webgl", + "--enable-unsafe-swiftshader", + ]) + if proxy: + chrome_args.append(f"--proxy-server={proxy}") + + chrome_proc = None + try: + chrome_proc = subprocess.Popen( + chrome_args, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + cdp_ready = False + for _ in range(22): + try: + with urllib.request.urlopen(f"{cdp_url}/json/version", timeout=2) as resp: + data = json.loads(resp.read() or b"{}") + if data.get("Browser"): + cdp_ready = True + break + except Exception: + time.sleep(0.5) + if not cdp_ready: + return { + "success": False, + "need_user_action": False, + "error": "chrome cdp port not responding", + "stage": "cdp_unavailable", + "driver": "cdp", + "fallback_recommended": True, + } + + with sync_playwright() as p: + browser = p.chromium.connect_over_cdp(cdp_url) + context = None + try: + context = browser.new_context( + viewport={"width": 1366, "height": 900}, + user_agent=( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/131.0.0.0 Safari/537.36" + ), + ) + + cookies = _build_playwright_cookie_items( + cookies_str=cookies_str, + resolved_session=resolved_session, + resolved_did=resolved_did, + ) + if cookies: + _add_cookies_resilient(context, cookies, stage="CDP") + + page = context.new_page() + page.set_default_timeout(timeout_seconds * 1000) + + page.goto("https://chatgpt.com/", wait_until="domcontentloaded", timeout=60000) + cf_ok, cf_note = _wait_for_cloudflare(page, max_wait_seconds=min(timeout_seconds, 90)) + if not cf_ok: + return { + "success": False, + "need_user_action": True, + "pending": True, + "error": cf_note, + "stage": "cdp_cloudflare", + "driver": "cdp", + "current_url": page.url, + "fallback_recommended": False, + } + + page.goto(checkout_url, wait_until="domcontentloaded", timeout=60000) + time.sleep(1.5) + redirect_url = str(page.url or "") + if "/checkout/" not in redirect_url: + return { + "success": False, + "need_user_action": False, + "pending": False, + "error": "checkout redirected, verify session" if not session_missing else "checkout redirected, missing session token", + "stage": "cdp_session_missing" if session_missing else "cdp_navigate_checkout", + "driver": "cdp", + "current_url": redirect_url, + "fallback_recommended": True, + } + + if not _wait_for_stripe_iframe(page, max_wait_seconds=min(timeout_seconds, 90)): + return { + "success": False, + "need_user_action": True, + "pending": True, + "error": "stripe iframe not loaded", + "stage": "cdp_wait_stripe", + "driver": "cdp", + "current_url": page.url, + "fallback_recommended": False, + } + + _simulate_human_behavior(page) + + ok, reason = _fill_payment_iframe( + page, + card_number=card_number, + exp_month=exp_month, + exp_year=exp_year, + cvc=cvc, + ) + if not ok: + return { + "success": False, + "need_user_action": False, + "error": reason, + "stage": "cdp_fill_card", + "driver": "cdp", + "current_url": page.url, + "fallback_recommended": True, + } + + _fill_address_iframe( + page, + billing_name=billing_name, + billing_line1=billing_line1, + billing_zip=billing_postal, + billing_country=billing_country, + billing_city=billing_city, + billing_state=billing_state, + ) + + submit_btn = _find_submit_button(page) + if not submit_btn: + return { + "success": False, + "need_user_action": True, + "pending": True, + "error": "submit button not found", + "stage": "cdp_find_submit", + "driver": "cdp", + "current_url": page.url, + "fallback_recommended": False, + } + + submit_btn.click() + challenge_click_count = 0 + challenge_seen = False + challenge_last_seen_at = 0.0 + resubmit_count = 0 + # challenge 场景常比普通提交更慢,给更长观察窗口,避免过早返回 need_user_action。 + deadline = time.monotonic() + max(post_submit_wait_seconds, 150) + while time.monotonic() < deadline: + current_url = str(page.url or "") + body = _extract_page_text(page, 2500) + body_lower = body.lower() + url_lower = current_url.lower() + + if any(token in url_lower for token in ("subscribed=true", "success", "/settings/subscription")): + return { + "success": True, + "need_user_action": False, + "stage": "cdp_confirmed", + "driver": "cdp", + "current_url": current_url, + "fallback_recommended": False, + } + + if any(token in body_lower for token in _SUCCESS_TEXT_TOKENS): + return { + "success": True, + "need_user_action": False, + "stage": "cdp_confirmed_text", + "driver": "cdp", + "current_url": current_url, + "fallback_recommended": False, + } + + for pattern, msg in _FAIL_TEXT_PATTERNS: + if pattern in body_lower: + return { + "success": False, + "need_user_action": False, + "error": msg, + "stage": "cdp_declined", + "driver": "cdp", + "current_url": current_url, + "fallback_recommended": False, + } + + detected, challenge_page = _detect_challenge_in_context(context, page) + if detected: + challenge_seen = True + challenge_last_seen_at = time.monotonic() + clicked = False + if challenge_click_count < 6: + clicked = _try_click_hcaptcha_checkbox(challenge_page) + if clicked: + challenge_click_count += 1 + logger.info( + "CDP challenge 自动点击尝试: attempt=%s current_url=%s", + challenge_click_count, + str(getattr(challenge_page, "url", "") or current_url)[:120], + ) + if not clicked: + clicked = _try_click_challenge_continue(challenge_page) + # challenge 出现后继续等待,不要立刻判失败。 + time.sleep(3.0 if clicked else 2.0) + continue + + if challenge_seen: + # challenge 消失后触发一次重提交流程,避免停在“已验证但未确认支付”。 + elapsed = time.monotonic() - challenge_last_seen_at + if elapsed >= 4 and resubmit_count < 2: + try: + retry_btn = _find_submit_button(page) + if retry_btn and retry_btn.is_visible(): + retry_btn.click() + resubmit_count += 1 + logger.info( + "CDP challenge 后自动重提交流程: attempt=%s current_url=%s", + resubmit_count, + current_url[:120], + ) + time.sleep(2.5) + continue + except Exception: + pass + + time.sleep(3) + + if challenge_seen: + return { + "success": False, + "need_user_action": True, + "pending": True, + "error": "challenge_detected", + "stage": "cdp_challenge", + "driver": "cdp", + "current_url": str(page.url or ""), + "fallback_recommended": False, + } + + return { + "success": False, + "need_user_action": True, + "pending": True, + "error": "payment_result_timeout", + "stage": "cdp_timeout", + "driver": "cdp", + "current_url": str(page.url or ""), + "fallback_recommended": False, + } + finally: + try: + if context is not None: + context.close() + except Exception: + pass + try: + browser.close() + except Exception: + pass + except Exception as exc: + return { + "success": False, + "need_user_action": False, + "error": f"cdp_exception: {exc}", + "stage": "cdp_exception", + "driver": "cdp", + "fallback_recommended": True, + } + finally: + if chrome_proc is not None: + try: + chrome_proc.terminate() + chrome_proc.wait(timeout=4) + except Exception: + try: + chrome_proc.kill() + except Exception: + pass + shutil.rmtree(user_data_dir, ignore_errors=True) + + +def _wait_for_cloudflare(page, max_wait_seconds: int) -> Tuple[bool, str]: + max_wait_seconds = max(int(max_wait_seconds), 15) + rounds = max(max_wait_seconds // 3, 1) + for _ in range(rounds): + try: + title = str(page.title() or "").lower() + url = str(page.url or "").lower() + body = str(page.evaluate("document.body ? document.body.innerText.slice(0, 500) : ''") or "").lower() + except Exception: + time.sleep(3) + continue + challenge = ( + "just a moment" in title + or "请稍候" in title + or "/cdn-cgi/challenge-platform" in url + or "verify you are human" in body + ) + if not challenge: + return True, "" + time.sleep(3) + return False, "Cloudflare challenge not passed in time" + + +def _wait_for_stripe_iframe(page, max_wait_seconds: int) -> bool: + max_wait_seconds = max(int(max_wait_seconds), 15) + rounds = max(max_wait_seconds // 3, 1) + for _ in range(rounds): + try: + iframe_elements = page.query_selector_all(_STRIPE_IFRAME_SELECTOR) + visible = [] + for el in iframe_elements: + box = el.bounding_box() or {} + if box.get("height", 0) > 30 and box.get("width", 0) > 120: + visible.append(el) + if visible: + return True + except Exception: + pass + time.sleep(3) + return False + + +def _fill_payment_iframe(page, card_number: str, exp_month: str, exp_year: str, cvc: str) -> Tuple[bool, str]: + card_number = re.sub(r"\D", "", str(card_number or "")) + exp_month = re.sub(r"\D", "", str(exp_month or "")).zfill(2)[:2] + exp_year = _normalize_exp_year(exp_year) + cvc = re.sub(r"\D", "", str(cvc or ""))[:4] + if not card_number or not exp_month or not exp_year or not cvc: + return False, "card fields incomplete" + + exp_text = f"{exp_month}{exp_year}" + iframe_elements = page.query_selector_all(_STRIPE_IFRAME_SELECTOR) + payment_el = None + backup_el = None + + for iframe_el in iframe_elements: + try: + box = iframe_el.bounding_box() + if not box or box.get("height", 0) < 30: + continue + frame_obj = iframe_el.content_frame() + if frame_obj and "elements-inner-payment" in str(frame_obj.url or ""): + payment_el = iframe_el + break + if backup_el is None and box.get("height", 0) < 220: + backup_el = iframe_el + except Exception: + continue + if payment_el is None: + payment_el = backup_el + if payment_el is None: + return False, "payment iframe not found" + + box = payment_el.bounding_box() + if not box: + return False, "payment iframe box unavailable" + + payment_el.scroll_into_view_if_needed() + time.sleep(0.3) + page.mouse.click(box["x"] + 80, box["y"] + 24) + time.sleep(0.5) + + # Stripe Payment Element 常见流程:卡号 -> 到期日 -> CVC + page.keyboard.type(card_number, delay=45) + time.sleep(0.4) + page.keyboard.type(exp_text, delay=45) + time.sleep(0.3) + page.keyboard.type(cvc, delay=45) + time.sleep(0.4) + return True, "" + + +def _fill_address_iframe( + page, + billing_name: str, + billing_line1: str, + billing_zip: str, + billing_country: str, + billing_city: str = "", + billing_state: str = "", +) -> Tuple[bool, str]: + iframe_elements = page.query_selector_all(_STRIPE_IFRAME_SELECTOR) + address_el = None + address_frame = None + for iframe_el in iframe_elements: + try: + box = iframe_el.bounding_box() + if not box or box.get("height", 0) < 30: + continue + frame_obj = iframe_el.content_frame() + if frame_obj and "elements-inner-address" in str(frame_obj.url or ""): + address_el = iframe_el + address_frame = frame_obj + break + except Exception: + continue + + if not address_el or not address_frame: + return False, "address iframe not found" + + def _click_and_type(selector: str, value: str) -> bool: + value = str(value or "").strip() + if not value: + return True + try: + address_el.scroll_into_view_if_needed() + time.sleep(0.2) + rect = address_frame.evaluate( + """(sel) => { + const el = document.querySelector(sel); + if (!el) return null; + const r = el.getBoundingClientRect(); + return {x: r.x + r.width / 2, y: r.y + r.height / 2}; + }""", + selector, + ) + box = address_el.bounding_box() + if not rect or not box: + return False + page.mouse.click(box["x"] + rect["x"], box["y"] + rect["y"]) + time.sleep(0.2) + page.keyboard.press("Control+a") + page.keyboard.press("Backspace") + page.keyboard.type(value, delay=35) + time.sleep(0.2) + return True + except Exception: + return False + + try: + address_frame.evaluate( + """(country) => { + const sel = document.querySelector('select[name="country"]'); + if (!sel || !country) return; + const nativeSet = Object.getOwnPropertyDescriptor( + window.HTMLSelectElement.prototype, 'value' + ).set; + nativeSet.call(sel, country); + sel.dispatchEvent(new Event('change', {bubbles: true})); + }""", + str(billing_country or "US").upper(), + ) + time.sleep(0.5) + except Exception: + pass + + _click_and_type('input[name="name"]', billing_name) + _click_and_type('input[name="addressLine1"]', billing_line1) + + # 城市/州/邮编在部分布局下会在 addressLine1 之后出现,优先 JS 赋值兜底。 + try: + address_frame.evaluate( + """(data) => { + function setInput(name, value) { + const el = document.querySelector('input[name="' + name + '"]'); + if (!el || !value) return; + const nativeSet = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, 'value' + ).set; + nativeSet.call(el, value); + el.dispatchEvent(new Event('input', {bubbles: true})); + el.dispatchEvent(new Event('change', {bubbles: true})); + el.dispatchEvent(new Event('blur', {bubbles: true})); + } + function setSelect(name, value) { + const el = document.querySelector('select[name="' + name + '"]'); + if (!el || !value) return; + const nativeSet = Object.getOwnPropertyDescriptor( + window.HTMLSelectElement.prototype, 'value' + ).set; + nativeSet.call(el, value); + el.dispatchEvent(new Event('change', {bubbles: true})); + el.dispatchEvent(new Event('blur', {bubbles: true})); + } + if (data.city) setInput('locality', data.city); + if (data.state) setSelect('administrativeArea', data.state); + if (data.postal) setInput('postalCode', data.postal); + }""", + {"city": billing_city, "state": billing_state, "postal": billing_zip}, + ) + except Exception: + # 忽略地址补充失败,避免硬中断 + pass + return True, "" + + +def _find_submit_button(page): + selectors = ( + '[data-testid="checkout-submit"]', + 'button[type="submit"]', + 'button:has-text("Subscribe")', + 'button:has-text("Pay")', + 'button:has-text("Confirm")', + 'button:has-text("订阅")', + 'button:has-text("支付")', + 'button:has-text("确认")', + ) + for selector in selectors: + try: + btn = page.query_selector(selector) + if btn and btn.is_visible(): + return btn + except Exception: + continue + return None + + +def _extract_page_text(page, max_len: int = 2000) -> str: + try: + text = str(page.evaluate("document.body ? document.body.innerText : ''") or "") + except Exception: + return "" + return text[:max_len] + + +def _detect_challenge(page, page_text_lower: str) -> bool: + challenge_tokens = ( + "hcaptcha", + "3d secure", + "requires_action", + "authentication required", + "verify you are human", + "complete verification", + "challenge", + ) + if any(token in page_text_lower for token in challenge_tokens): + return True + try: + for frame in page.frames: + url = str(getattr(frame, "url", "") or "").lower() + if any(token in url for token in ("hcaptcha", "3ds", "challenge")): + return True + except Exception: + pass + return False + + +def auto_bind_checkout_with_playwright( + *, + checkout_url: str, + cookies_str: str, + session_token: str, + access_token: str, + device_id: str, + card_number: str, + exp_month: str, + exp_year: str, + cvc: str, + billing_name: str, + billing_country: str, + billing_line1: str, + billing_city: str, + billing_state: str, + billing_postal: str, + proxy: Optional[str] = None, + timeout_seconds: int = 180, + post_submit_wait_seconds: int = 90, + headless: bool = False, +) -> Dict[str, Any]: + """ + 在 chatgpt checkout 页面执行自动填卡。 + 返回结构: + - success=True: 浏览器提交流程出现成功信号 + - success=False + need_user_action=True: 需要人工继续(challenge/超时) + - success=False + need_user_action=False: 明确失败 + """ + checkout_url = str(checkout_url or "").strip() + if not checkout_url: + return {"success": False, "need_user_action": False, "error": "checkout_url empty"} + + # 优先使用 ABCard 风格 CDP 模式,降低自动化痕迹; + # 若判定为“环境性失败”再回退到标准 Playwright 模式。 + cdp_result = _auto_bind_with_cdp_checkout( + checkout_url=checkout_url, + cookies_str=cookies_str, + session_token=session_token, + access_token=access_token, + device_id=device_id, + card_number=card_number, + exp_month=exp_month, + exp_year=exp_year, + cvc=cvc, + billing_name=billing_name, + billing_country=billing_country, + billing_line1=billing_line1, + billing_city=billing_city, + billing_state=billing_state, + billing_postal=billing_postal, + proxy=proxy, + timeout_seconds=timeout_seconds, + post_submit_wait_seconds=post_submit_wait_seconds, + headless=headless, + ) + if cdp_result.get("success"): + return cdp_result + if not bool(cdp_result.get("fallback_recommended", False)): + return cdp_result + logger.warning( + "CDP 自动绑卡回退到标准 Playwright: stage=%s error=%s", + cdp_result.get("stage"), + cdp_result.get("error"), + ) + + try: + from playwright.sync_api import sync_playwright + except Exception: + return { + "success": False, + "need_user_action": True, + "error": "playwright not installed (pip install playwright && playwright install chromium)", + "stage": "bootstrap", + } + + launch_kwargs: Dict[str, Any] = {"headless": bool(headless)} + proxy_server = str(proxy or "").strip() + if proxy_server: + launch_kwargs["proxy"] = {"server": proxy_server} + + timeout_seconds = max(int(timeout_seconds), 60) + post_submit_wait_seconds = max(int(post_submit_wait_seconds), 30) + + with sync_playwright() as p: + browser = p.chromium.launch(**launch_kwargs) + try: + context = browser.new_context( + viewport={"width": 1366, "height": 900}, + user_agent=( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/122.0.0.0 Safari/537.36" + ), + ) + + # 注入账号 cookie(优先使用账号管理中已保存 cookies) + session_token = str(session_token or "").strip() or _extract_session_token_from_cookie_text(cookies_str) + access_token = str(access_token or "").strip() + device_id = str(device_id or "").strip() or _extract_cookie_value(cookies_str, "oai-did") + cookies = _build_playwright_cookie_items( + cookies_str=cookies_str, + resolved_session=session_token, + resolved_did=device_id, + ) + if cookies: + _add_cookies_resilient(context, cookies, stage="Playwright") + + page = context.new_page() + page.set_default_timeout(timeout_seconds * 1000) + + # 先热身 chatgpt 首页,减少 checkout 重定向概率 + page.goto("https://chatgpt.com/", wait_until="domcontentloaded", timeout=60000) + cf_ok, cf_note = _wait_for_cloudflare(page, max_wait_seconds=min(timeout_seconds, 90)) + if not cf_ok: + return { + "success": False, + "need_user_action": True, + "error": cf_note, + "stage": "cloudflare", + "current_url": page.url, + } + + page.goto(checkout_url, wait_until="domcontentloaded", timeout=60000) + time.sleep(1.5) + redirect_url = str(page.url or "") + if "/checkout/" not in redirect_url: + body = _extract_page_text(page, 600) + return { + "success": False, + "need_user_action": True, + "error": "checkout page redirected, please verify login/session", + "stage": "navigate_checkout", + "current_url": redirect_url, + "body_preview": body, + } + + if not _wait_for_stripe_iframe(page, max_wait_seconds=min(timeout_seconds, 90)): + return { + "success": False, + "need_user_action": True, + "error": "stripe payment iframe not loaded", + "stage": "wait_stripe", + "current_url": page.url, + } + + ok, reason = _fill_payment_iframe( + page, + card_number=card_number, + exp_month=exp_month, + exp_year=exp_year, + cvc=cvc, + ) + if not ok: + return {"success": False, "need_user_action": False, "error": reason, "stage": "fill_card"} + + _fill_address_iframe( + page, + billing_name=billing_name, + billing_line1=billing_line1, + billing_zip=billing_postal, + billing_country=billing_country, + billing_city=billing_city, + billing_state=billing_state, + ) + + submit_btn = _find_submit_button(page) + if not submit_btn: + return { + "success": False, + "need_user_action": True, + "error": "submit button not found", + "stage": "find_submit", + "current_url": page.url, + } + submit_btn.click() + + challenge_seen = False + challenge_click_count = 0 + challenge_last_seen_at = 0.0 + resubmit_count = 0 + deadline = time.monotonic() + max(post_submit_wait_seconds, 150) + while time.monotonic() < deadline: + current_url = str(page.url or "") + body = _extract_page_text(page, 2500) + body_lower = body.lower() + url_lower = current_url.lower() + + if any(token in url_lower for token in ("subscribed=true", "success", "/settings/subscription")): + return {"success": True, "need_user_action": False, "stage": "confirmed", "current_url": current_url} + + if any(token in body_lower for token in _SUCCESS_TEXT_TOKENS): + return {"success": True, "need_user_action": False, "stage": "confirmed_text", "current_url": current_url} + + for pattern, msg in _FAIL_TEXT_PATTERNS: + if pattern in body_lower: + return { + "success": False, + "need_user_action": False, + "error": msg, + "stage": "declined", + "current_url": current_url, + } + + detected, challenge_page = _detect_challenge_in_context(context, page) + if detected: + challenge_seen = True + challenge_last_seen_at = time.monotonic() + clicked = False + if challenge_click_count < 6: + clicked = _try_click_hcaptcha_checkbox(challenge_page) + if clicked: + challenge_click_count += 1 + logger.info( + "Playwright challenge 自动点击尝试: attempt=%s current_url=%s", + challenge_click_count, + str(getattr(challenge_page, "url", "") or current_url)[:120], + ) + if not clicked: + clicked = _try_click_challenge_continue(challenge_page) + time.sleep(3.0 if clicked else 2.0) + continue + + if challenge_seen: + elapsed = time.monotonic() - challenge_last_seen_at + if elapsed >= 4 and resubmit_count < 2: + try: + retry_btn = _find_submit_button(page) + if retry_btn and retry_btn.is_visible(): + retry_btn.click() + resubmit_count += 1 + logger.info( + "Playwright challenge 后自动重提交流程: attempt=%s current_url=%s", + resubmit_count, + current_url[:120], + ) + time.sleep(2.5) + continue + except Exception: + pass + + time.sleep(3) + + if challenge_seen: + return { + "success": False, + "need_user_action": True, + "pending": True, + "error": "challenge_detected", + "stage": "challenge", + "current_url": str(page.url or ""), + } + + return { + "success": False, + "need_user_action": True, + "pending": True, + "error": "payment_result_timeout", + "stage": "timeout", + "current_url": str(page.url or ""), + } + finally: + try: + browser.close() + except Exception: + pass diff --git a/src/core/openai/overview.py b/src/core/openai/overview.py new file mode 100644 index 0000000..640c2b2 --- /dev/null +++ b/src/core/openai/overview.py @@ -0,0 +1,767 @@ +""" +Codex 账号总览数据抓取与解析 +""" + +from __future__ import annotations + +import base64 +import json +import logging +import re +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional, Tuple + +from curl_cffi import requests as cffi_requests + +from ...database.models import Account + +logger = logging.getLogger(__name__) + +_USAGE_ENDPOINTS: Tuple[Tuple[str, str, bool], ...] = ( + # required=True: 核心稳定端点,失败计入 errors + ("me", "https://chatgpt.com/backend-api/me", True), + ("wham_usage", "https://chatgpt.com/backend-api/wham/usage", True), + # required=False: 非核心端点,仅做补充,失败时静默降级 + ("codex_usage", "https://chatgpt.com/backend-api/codex/usage", False), +) + +_NUMERIC_KEYS_USED = ("used", "usage", "consumed", "spent", "count", "current", "value") +_NUMERIC_KEYS_TOTAL = ("total", "limit", "max", "quota", "allowance", "capacity") +_NUMERIC_KEYS_REMAINING = ("remaining", "left", "available", "remain") +_NUMERIC_KEYS_PERCENT = ("percent", "percentage", "ratio", "remaining_percent", "left_percent") +_RESET_AT_KEYS = ( + "reset_at", + "resets_at", + "next_reset", + "next_reset_at", + "reset_time", + "renew_at", + "expires_at", + "expired_at", +) +_RESET_IN_KEYS = ("reset_in", "time_to_reset", "ttl_seconds", "remaining_seconds", "seconds_to_reset") +_HOURLY_WINDOW_MAX_SECONDS = 12 * 60 * 60 +_WEEKLY_WINDOW_MIN_SECONDS = 5 * 24 * 60 * 60 + + +def _build_proxies(proxy: Optional[str]) -> Optional[dict]: + if not proxy: + return None + return {"http": proxy, "https": proxy} + + +def _extract_cookie_value(cookies_str: str, key: str) -> Optional[str]: + if not cookies_str: + return None + prefix = f"{key}=" + for part in cookies_str.split(";"): + item = part.strip() + if item.startswith(prefix): + return item[len(prefix):].strip() + return None + + +def _resolve_chatgpt_account_id(account: Account) -> Optional[str]: + token_account_id = ( + _extract_chatgpt_account_id_from_jwt(account.access_token) + or _extract_chatgpt_account_id_from_jwt(account.id_token) + ) + for candidate in (token_account_id, account.account_id, account.workspace_id): + value = str(candidate or "").strip() + if value: + return value + return None + + +def _decode_jwt_payload(token: Optional[str]) -> Optional[Dict[str, Any]]: + text = str(token or "").strip() + if not text or "." not in text: + return None + parts = text.split(".") + if len(parts) < 2: + return None + payload_part = parts[1] + if not payload_part: + return None + padding = "=" * (-len(payload_part) % 4) + try: + raw = base64.urlsafe_b64decode(payload_part + padding) + data = json.loads(raw.decode("utf-8")) + return data if isinstance(data, dict) else None + except Exception: + return None + + +def _extract_auth_claim(payload: Optional[Dict[str, Any]]) -> Dict[str, Any]: + if not isinstance(payload, dict): + return {} + auth = payload.get("https://api.openai.com/auth") + if isinstance(auth, dict): + return auth + auth = payload.get("auth_data") + if isinstance(auth, dict): + return auth + return {} + + +def _extract_chatgpt_account_id_from_jwt(token: Optional[str]) -> Optional[str]: + payload = _decode_jwt_payload(token) + if not payload: + return None + auth = _extract_auth_claim(payload) + for key in ("chatgpt_account_id", "account_id", "workspace_id"): + value = str(auth.get(key) or payload.get(key) or "").strip() + if value: + return value + return None + + +def _extract_chatgpt_plan_from_jwt(token: Optional[str]) -> Optional[str]: + payload = _decode_jwt_payload(token) + if not payload: + return None + auth = _extract_auth_claim(payload) + candidates = [ + auth.get("chatgpt_plan_type"), + auth.get("plan_type"), + auth.get("subscription_plan"), + payload.get("chatgpt_plan_type"), + payload.get("plan_type"), + payload.get("subscription_plan"), + payload.get("subscription_tier"), + ] + for item in candidates: + if isinstance(item, str) and item.strip(): + return _normalize_plan(item) + return None + + +def _build_headers(account: Account) -> Dict[str, str]: + headers = { + "Authorization": f"Bearer {account.access_token}", + "Content-Type": "application/json", + } + account_id = _resolve_chatgpt_account_id(account) + if account_id: + headers["ChatGPT-Account-Id"] = account_id + if account.cookies: + headers["cookie"] = account.cookies + oai_did = _extract_cookie_value(account.cookies, "oai-did") + if oai_did: + headers["oai-device-id"] = oai_did + return headers + + +def _request_json(url: str, headers: Dict[str, str], proxy: Optional[str]) -> Dict[str, Any]: + resp = cffi_requests.get( + url, + headers=headers, + proxies=_build_proxies(proxy), + timeout=20, + impersonate="chrome110", + ) + resp.raise_for_status() + data = resp.json() + if isinstance(data, dict): + return data + return {"data": data} + + +def _extract_http_status(exc: Exception) -> Optional[int]: + response = getattr(exc, "response", None) + if response is not None: + status = getattr(response, "status_code", None) + if isinstance(status, int): + return status + match = re.search(r"HTTP Error\s+(\d{3})", str(exc)) + if match: + try: + return int(match.group(1)) + except Exception: + return None + return None + + +def _request_json_with_proxy_fallback(url: str, headers: Dict[str, str], proxy: Optional[str]) -> Dict[str, Any]: + """ + 配额抓取优先按当前代理请求;若代理异常则回退直连重试一次。 + """ + try: + return _request_json(url, headers, proxy) + except Exception as proxy_exc: + if not proxy: + raise + status = _extract_http_status(proxy_exc) + # 某些账号/端点天然 403/404,这里降噪避免刷屏 warning。 + if status in (401, 403, 404): + logger.debug("概览请求代理回退直连: url=%s status=%s err=%s", url, status, proxy_exc) + else: + logger.warning("概览请求代理失败,回退直连重试: url=%s err=%s", url, proxy_exc) + return _request_json(url, headers, None) + + +def _to_float(value: Any) -> Optional[float]: + if value is None: + return None + if isinstance(value, bool): + return None + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + raw = value.strip() + if not raw: + return None + try: + return float(raw) + except ValueError: + return None + return None + + +def _pick_number(payload: Dict[str, Any], keys: Tuple[str, ...]) -> Optional[float]: + lowered = {str(k).lower(): v for k, v in payload.items()} + for key in keys: + for actual_key, value in lowered.items(): + if key == actual_key or actual_key.endswith(f"_{key}") or actual_key.endswith(f".{key}"): + number = _to_float(value) + if number is not None: + return number + return None + + +def _try_parse_epoch(value: float) -> Optional[datetime]: + if value <= 0: + return None + # 同时兼容秒和毫秒 + if value > 10_000_000_000: + value = value / 1000.0 + try: + return datetime.fromtimestamp(value, tz=timezone.utc) + except Exception: + return None + + +def _normalize_datetime(value: Any) -> Optional[datetime]: + if value is None: + return None + if isinstance(value, datetime): + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + number = _to_float(value) + if number is not None: + return _try_parse_epoch(number) + if isinstance(value, str): + raw = value.strip() + if not raw: + return None + try: + if raw.endswith("Z"): + raw = raw[:-1] + "+00:00" + dt = datetime.fromisoformat(raw) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + except ValueError: + return None + return None + + +def _format_duration(seconds: Optional[float]) -> str: + if seconds is None: + return "-" + sec = max(int(seconds), 0) + if sec < 60: + return f"{sec}秒" + minutes, remain_seconds = divmod(sec, 60) + if minutes < 60: + return f"{minutes}分{remain_seconds}秒" + hours, remain_minutes = divmod(minutes, 60) + if hours < 24: + return f"{hours}小时{remain_minutes}分" + days, remain_hours = divmod(hours, 24) + return f"{days}天{remain_hours}小时" + + +def _detect_window_match(path: str, payload: Dict[str, Any], window: str) -> bool: + path_lower = path.lower() + if window == "hourly": + keywords = ("hourly", "hour", "per_hour", "5h", "5_hour") + else: + keywords = ("weekly", "week", "per_week", "7d", "7_day") + + if any(token in path_lower for token in keywords): + return True + + window_value = str(payload.get("window") or payload.get("period") or payload.get("scope") or "").lower() + return any(token == window_value or token in window_value for token in keywords) + + +def _extract_quota_from_rate_limit_window(window_payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: + if not isinstance(window_payload, dict): + return None + + used_percent = _to_float(window_payload.get("used_percent")) + remaining_percent = _to_float(window_payload.get("remaining_percent")) + # 兼容比例值(0~1)与百分比值(0~100) + if used_percent is not None and 0 <= used_percent <= 1: + used_percent *= 100.0 + if remaining_percent is not None and 0 <= remaining_percent <= 1: + remaining_percent *= 100.0 + if remaining_percent is None and used_percent is not None: + remaining_percent = 100.0 - used_percent + if remaining_percent is not None: + remaining_percent = max(0.0, min(100.0, remaining_percent)) + + total = ( + _to_float(window_payload.get("total")) + or _to_float(window_payload.get("limit")) + or _to_float(window_payload.get("max")) + or _to_float(window_payload.get("capacity")) + ) + used = _to_float(window_payload.get("used")) + remaining = _to_float(window_payload.get("remaining")) + limit_window_seconds = _to_float( + window_payload.get("limit_window_seconds") + or window_payload.get("window_seconds") + ) + if limit_window_seconds is not None and limit_window_seconds <= 0: + limit_window_seconds = None + + if total is not None and remaining is None and remaining_percent is not None: + remaining = total * (remaining_percent / 100.0) + if total is not None and used is None and remaining is not None: + used = max(total - remaining, 0.0) + if total is not None and remaining is None and used is not None: + remaining = max(total - used, 0.0) + + reset_at = _normalize_datetime( + window_payload.get("resets_at") + or window_payload.get("reset_at") + or window_payload.get("next_reset_at") + or window_payload.get("next_reset") + ) + reset_in_seconds = _to_float( + window_payload.get("resets_in_seconds") + or window_payload.get("remaining_seconds") + or window_payload.get("seconds_to_reset") + or window_payload.get("reset_in") + ) + if reset_in_seconds is None and reset_at: + reset_in_seconds = max((reset_at - datetime.now(timezone.utc)).total_seconds(), 0.0) + if reset_at is None and reset_in_seconds is not None: + reset_at = datetime.now(timezone.utc).replace(microsecond=0) + timedelta(seconds=int(reset_in_seconds)) + + if all(v is None for v in (total, used, remaining, remaining_percent, reset_at, reset_in_seconds)): + return None + + if remaining_percent is None and total and total > 0 and remaining is not None: + remaining_percent = max(0.0, min(100.0, (remaining / total) * 100.0)) + + reset_in_text = _format_duration(reset_in_seconds) + if reset_in_seconds is None and reset_at: + reset_in_text = _format_duration((reset_at - datetime.now(timezone.utc)).total_seconds()) + + return { + "used": int(used) if used is not None else None, + "total": int(total) if total is not None else None, + "remaining": int(remaining) if remaining is not None else None, + "percentage": round(float(remaining_percent), 2) if remaining_percent is not None else None, + "reset_at": reset_at.isoformat() if reset_at else None, + "reset_in_text": reset_in_text, + "window_seconds": int(limit_window_seconds) if limit_window_seconds is not None else None, + "status": "ok", + } + + +def _infer_rate_limit_window_type(window_payload: Dict[str, Any], window_key: str) -> Tuple[str, bool]: + """ + 推断 rate_limit 窗口类型。 + 返回: (window_type, confident) + """ + seconds = _to_float(window_payload.get("limit_window_seconds") or window_payload.get("window_seconds")) + if seconds is not None and seconds > 0: + if seconds >= _WEEKLY_WINDOW_MIN_SECONDS: + return "weekly", True + if seconds <= _HOURLY_WINDOW_MAX_SECONDS: + return "hourly", True + return ("hourly", False) if window_key == "primary_window" else ("weekly", False) + + +def _select_rate_limit_window(rate_limit: Dict[str, Any], target_window: str) -> Optional[Tuple[str, Dict[str, Any]]]: + entries: List[Tuple[str, Dict[str, Any], str, bool]] = [] + for key in ("primary_window", "secondary_window"): + raw = rate_limit.get(key) + if not isinstance(raw, dict): + continue + inferred, confident = _infer_rate_limit_window_type(raw, key) + entries.append((key, raw, inferred, confident)) + + if not entries: + return None + + # 1) 优先使用“基于窗口时长”的高置信匹配(避免把 7d 窗口误判成 5h)。 + for key, raw, inferred, confident in entries: + if confident and inferred == target_window: + return key, raw + + # 2) 再使用普通匹配(无时长信息时退化到 key 语义)。 + for key, raw, inferred, _ in entries: + if inferred == target_window: + return key, raw + + # 3) 保留历史 key 语义兜底(兼容单窗口账号场景,避免 5 小时或周配额出现 --)。 + fallback_key = "primary_window" if target_window == "hourly" else "secondary_window" + for key, raw, _, _ in entries: + if key == fallback_key: + return key, raw + return None + + +def _iter_rate_limit_candidates(payload: Dict[str, Any]) -> List[Tuple[str, Dict[str, Any]]]: + """ + 从 payload 中提取可能的 rate_limit 容器。 + """ + if not isinstance(payload, dict): + return [] + candidates: List[Tuple[str, Dict[str, Any]]] = [] + + direct = payload.get("rate_limit") + if isinstance(direct, dict): + candidates.append(("rate_limit", direct)) + + for parent_key in ("usage", "data", "quota", "limits", "codex"): + parent = payload.get(parent_key) + if not isinstance(parent, dict): + continue + nested = parent.get("rate_limit") + if isinstance(nested, dict): + candidates.append((f"{parent_key}.rate_limit", nested)) + + return candidates + + +def _extract_quota_from_rate_limit(window: str, payloads: Dict[str, Dict[str, Any]]) -> Optional[Dict[str, Any]]: + """ + 对齐 cockpit:优先 wham/usage.rate_limit;缺失时使用 codex/usage.rate_limit 兜底。 + """ + for source_name in ("wham_usage", "codex_usage"): + payload = payloads.get(source_name) or {} + if not isinstance(payload, dict): + continue + + for source_path, rate_limit in _iter_rate_limit_candidates(payload): + selected = _select_rate_limit_window(rate_limit, window) + if not selected: + continue + selected_key, selected_payload = selected + parsed = _extract_quota_from_rate_limit_window(selected_payload) + if parsed: + parsed["source"] = f"{source_name}.{source_path}.{selected_key}" + return parsed + + # 兼容某些接口直接把 primary/secondary_window 放在顶层 + top_level_rate_limit = { + "primary_window": payload.get("primary_window"), + "secondary_window": payload.get("secondary_window"), + } + selected_direct = _select_rate_limit_window(top_level_rate_limit, window) + if not selected_direct: + continue + selected_key, selected_payload = selected_direct + parsed_direct = _extract_quota_from_rate_limit_window(selected_payload) + if parsed_direct: + parsed_direct["source"] = f"{source_name}.{selected_key}" + return parsed_direct + return None + + +def _extract_code_review_quota(payloads: Dict[str, Dict[str, Any]]) -> Dict[str, Any]: + """ + Code Review 配额:优先读取 wham/usage.code_review_rate_limit 的 primary/secondary_window。 + """ + payload = payloads.get("wham_usage") or {} + if not isinstance(payload, dict): + return { + "used": None, + "total": None, + "remaining": None, + "percentage": None, + "reset_at": None, + "reset_in_text": "-", + "status": "unknown", + } + + review_rate_limit = payload.get("code_review_rate_limit") + if isinstance(review_rate_limit, dict): + for window_key in ("primary_window", "secondary_window"): + parsed = _extract_quota_from_rate_limit_window(review_rate_limit.get(window_key) or {}) + if parsed: + return parsed + + return { + "used": None, + "total": None, + "remaining": None, + "percentage": None, + "reset_at": None, + "reset_in_text": "-", + "status": "unknown", + } + + +def _extract_quota_candidate(payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: + used = _pick_number(payload, _NUMERIC_KEYS_USED) + total = _pick_number(payload, _NUMERIC_KEYS_TOTAL) + remaining = _pick_number(payload, _NUMERIC_KEYS_REMAINING) + percentage = _pick_number(payload, _NUMERIC_KEYS_PERCENT) + + reset_at: Optional[datetime] = None + for key in _RESET_AT_KEYS: + if key in payload: + reset_at = _normalize_datetime(payload.get(key)) + if reset_at: + break + + reset_in_seconds = None + for key in _RESET_IN_KEYS: + if key in payload: + reset_in_seconds = _to_float(payload.get(key)) + if reset_in_seconds is not None: + break + + if remaining is None and total is not None and used is not None: + remaining = max(total - used, 0) + if used is None and total is not None and remaining is not None: + used = max(total - remaining, 0) + + if percentage is not None: + # 兼容 0~1 的比例值 + if 0 <= percentage <= 1: + percentage *= 100 + elif total and total > 0 and remaining is not None: + percentage = (remaining / total) * 100 + + if reset_at is None and reset_in_seconds is not None: + reset_at = datetime.now(timezone.utc).replace(microsecond=0) + timedelta(seconds=int(reset_in_seconds)) + + if all(v is None for v in (used, total, remaining, percentage, reset_at, reset_in_seconds)): + return None + + percentage = max(0, min(float(percentage or 0), 100)) + reset_in_text = _format_duration(reset_in_seconds) + if reset_in_seconds is None and reset_at: + delta = (reset_at - datetime.now(timezone.utc)).total_seconds() + reset_in_text = _format_duration(delta) + + return { + "used": int(used) if used is not None else None, + "total": int(total) if total is not None else None, + "remaining": int(remaining) if remaining is not None else None, + "percentage": round(percentage, 2) if percentage is not None else None, + "reset_at": reset_at.isoformat() if reset_at else None, + "reset_in_text": reset_in_text, + "status": "ok", + } + + +def _walk_candidates(payload: Any, window: str, prefix: str = "") -> List[Dict[str, Any]]: + candidates: List[Dict[str, Any]] = [] + if isinstance(payload, dict): + if _detect_window_match(prefix, payload, window): + parsed = _extract_quota_candidate(payload) + if parsed: + candidates.append(parsed) + for key, value in payload.items(): + child_prefix = f"{prefix}.{key}" if prefix else str(key) + candidates.extend(_walk_candidates(value, window, child_prefix)) + elif isinstance(payload, list): + for idx, item in enumerate(payload): + child_prefix = f"{prefix}[{idx}]" + candidates.extend(_walk_candidates(item, window, child_prefix)) + return candidates + + +def _extract_quota(window: str, payloads: Dict[str, Dict[str, Any]]) -> Dict[str, Any]: + # 先按 cockpit-tools 的核心结构解析:rate_limit.primary/secondary_window.used_percent + # primary=5小时窗口,secondary=周窗口。 + strict = _extract_quota_from_rate_limit(window, payloads) + if strict: + return strict + + return { + "used": None, + "total": None, + "remaining": None, + "percentage": None, + "reset_at": None, + "reset_in_text": "-", + "status": "unknown", + } + + +def _normalize_plan(raw_plan: Optional[str]) -> str: + plan = (raw_plan or "").strip().lower() + if not plan: + return "Basic" + if "enterprise" in plan or "team" in plan: + return "Team" + if "plus" in plan: + return "Plus" + if "pro" in plan: + return "Pro" + if "free" in plan or "basic" in plan: + return "Basic" + return plan.capitalize() + + +def _extract_plan_string_candidates(me_payload: Dict[str, Any]) -> List[str]: + """ + 提取常见 plan 字段的字符串候选值。 + """ + candidates: List[str] = [] + + def _append(value: Any): + if isinstance(value, str): + text = value.strip() + if text: + candidates.append(text) + + _append(me_payload.get("plan_type")) + _append(me_payload.get("plan")) + _append(me_payload.get("subscription_plan")) + _append(me_payload.get("account_plan")) + _append(me_payload.get("subscription_tier")) + _append(me_payload.get("chatgpt_plan_type")) + _append(me_payload.get("tier")) + _append(me_payload.get("planType")) + + account_block = me_payload.get("account") + if isinstance(account_block, dict): + _append(account_block.get("plan_type")) + _append(account_block.get("plan")) + _append(account_block.get("subscription_plan")) + _append(account_block.get("subscription_tier")) + _append(account_block.get("chatgpt_plan_type")) + + subscription_block = me_payload.get("subscription") + if isinstance(subscription_block, dict): + _append(subscription_block.get("plan_type")) + _append(subscription_block.get("plan")) + _append(subscription_block.get("product")) + _append(subscription_block.get("tier")) + + return candidates + + +def _detect_plan_from_payload(payload: Dict[str, Any], source_name: str) -> Optional[Tuple[str, str]]: + if not isinstance(payload, dict) or not payload: + return None + for raw in _extract_plan_string_candidates(payload): + normalized = _normalize_plan(raw) + if normalized in ("Team", "Plus", "Pro", "Basic"): + return normalized, f"{source_name}.plan" + return None + + +def _detect_plan(account: Account, payloads: Dict[str, Dict[str, Any]]) -> Tuple[str, str]: + me = payloads.get("me") or {} + if isinstance(me, dict) and me: + # 1) 直接 plan 字段优先 + for raw in _extract_plan_string_candidates(me): + normalized = _normalize_plan(raw) + if normalized in ("Team", "Plus", "Pro"): + return normalized, "me.plan" + if normalized == "Basic" and raw.strip(): + # 明确返回 free/basic 也算有效信号 + return normalized, "me.plan" + + # 2) org/workspace 信息判断 Team + orgs = [] + org_block = me.get("orgs") + if isinstance(org_block, dict): + orgs = org_block.get("data") or [] + if isinstance(orgs, list): + for org in orgs: + if not isinstance(org, dict): + continue + settings_ = org.get("settings") + if isinstance(settings_, dict): + workspace_plan_type = str(settings_.get("workspace_plan_type") or "").strip().lower() + if workspace_plan_type in ("team", "enterprise"): + return "Team", "me.org.workspace_plan_type" + org_plan = _normalize_plan(str(org.get("plan_type") or org.get("plan") or "")) + if org_plan in ("Team", "Plus", "Pro"): + return org_plan, "me.org.plan" + + # 3) 订阅布尔信号(无法区分 Team/Plus 时按 Plus 处理) + bool_markers = ( + me.get("has_paid_subscription"), + me.get("has_active_subscription"), + me.get("is_paid"), + me.get("is_subscribed"), + ) + if any(v is True for v in bool_markers): + return "Plus", "me.subscription_flag" + + # Cockpit-tools 同款核心信号:wham/usage 的 plan_type + for source_name in ("wham_usage", "codex_usage"): + detected = _detect_plan_from_payload(payloads.get(source_name) or {}, source_name) + if detected: + return detected + + # JWT claim 兜底(不依赖刷新 token) + for source_name, token in ( + ("id_token.chatgpt_plan_type", account.id_token), + ("access_token.chatgpt_plan_type", account.access_token), + ): + normalized = _extract_chatgpt_plan_from_jwt(token) + if normalized in ("Team", "Plus", "Pro", "Basic"): + return normalized, source_name + + # fallback 到数据库订阅字段 + if account.subscription_type: + return _normalize_plan(account.subscription_type), "db.subscription_type" + return "Basic", "default" + + +def fetch_codex_overview(account: Account, proxy: Optional[str] = None) -> Dict[str, Any]: + """ + 从 OpenAI 接口抓取 Codex 账号概览(计划类型 + 配额) + """ + if not account.access_token: + raise ValueError("账号缺少 access_token") + + headers = _build_headers(account) + payloads: Dict[str, Dict[str, Any]] = {} + errors: List[str] = [] + + for source_name, url, required in _USAGE_ENDPOINTS: + try: + payloads[source_name] = _request_json_with_proxy_fallback(url, headers, proxy) + except Exception as exc: + status = _extract_http_status(exc) + # 可选端点在 401/403/404 场景静默降级,避免影响总览刷新日志可读性。 + if not required and status in (401, 403, 404): + logger.debug("概览可选端点降级跳过: source=%s status=%s", source_name, status) + continue + errors.append(f"{source_name}: {exc}") + + if not payloads: + raise RuntimeError("所有概览接口请求失败") + + plan_type, plan_source = _detect_plan(account, payloads) + hourly_quota = _extract_quota("hourly", payloads) + weekly_quota = _extract_quota("weekly", payloads) + code_review_quota = _extract_code_review_quota(payloads) + + return { + "plan_type": plan_type, + "plan_source": plan_source, + "hourly_quota": hourly_quota, + "weekly_quota": weekly_quota, + "code_review_quota": code_review_quota, + "sources": list(payloads.keys()), + "fetched_at": datetime.now(timezone.utc).isoformat(), + "errors": errors[:3], + } diff --git a/src/core/openai/payment.py b/src/core/openai/payment.py index d584a58..5abfeda 100644 --- a/src/core/openai/payment.py +++ b/src/core/openai/payment.py @@ -3,18 +3,45 @@ """ import logging +import base64 +import json +import re import subprocess import sys -from typing import Optional +import uuid +from typing import Any, Dict, List, Optional +from urllib.parse import urlencode, urljoin, unquote from curl_cffi import requests as cffi_requests from ...database.models import Account +from .overview import fetch_codex_overview logger = logging.getLogger(__name__) PAYMENT_CHECKOUT_URL = "https://chatgpt.com/backend-api/payments/checkout" +ACCOUNT_CHECK_URL = "https://chatgpt.com/backend-api/wham/accounts/check" TEAM_CHECKOUT_BASE_URL = "https://chatgpt.com/checkout/openai_llc/" +AIMIZY_PAY_URL = "https://team.aimizy.com/pay" +SENTINEL_REQ_URL = "https://sentinel.openai.com/backend-api/sentinel/req" +CHECKOUT_LINK_REGEX = re.compile(r"https://chatgpt\.com/checkout/openai_llc/cs_[A-Za-z0-9_-]+", re.IGNORECASE) +CHECKOUT_SESSION_REGEX = re.compile(r"\bcs_[A-Za-z0-9_-]+\b", re.IGNORECASE) +PUBLISHABLE_KEY_REGEX = re.compile(r"\bpk_(?:live|test)_[A-Za-z0-9]+\b", re.IGNORECASE) +_CONNECTIVITY_ERROR_KEYWORDS = ( + "failed to connect", + "could not connect to server", + "connection refused", + "timed out", + "timeout", + "temporary failure in name resolution", + "name or service not known", + "proxy connect", + "network is unreachable", + "curl: (7)", + "curl: (28)", + "curl: (35)", + "curl: (56)", +) def _build_proxies(proxy: Optional[str]) -> Optional[dict]: @@ -23,6 +50,429 @@ def _build_proxies(proxy: Optional[str]) -> Optional[dict]: return None +def _is_connectivity_error(err: Any) -> bool: + text = str(err or "").strip().lower() + if not text: + return False + return any(token in text for token in _CONNECTIVITY_ERROR_KEYWORDS) + + +def _extract_link_from_payload(data) -> Optional[str]: + """从任意结构中提取 URL 字段。""" + if isinstance(data, str): + text = data.strip() + if text.startswith("/checkout/openai_llc/"): + return f"https://chatgpt.com{text}" + if text.startswith("http://") or text.startswith("https://"): + return text + normalized = _extract_checkout_link_from_text(text) + if normalized: + return normalized + return None + + if isinstance(data, dict): + for key in ( + "checkout_url", + "checkoutUrl", + "redirect_url", + "redirectUrl", + "checkout_link", + "checkoutLink", + "short_url", + "shortUrl", + "short_link", + "shortLink", + "pay_url", + "payUrl", + "link", + "url", + ): + value = data.get(key) + if isinstance(value, str): + raw = value.strip() + if raw.startswith("/checkout/openai_llc/"): + return f"https://chatgpt.com{raw}" + if raw.startswith(("http://", "https://")): + return raw + normalized = _extract_checkout_link_from_text(raw) + if normalized: + return normalized + + for value in data.values(): + url = _extract_link_from_payload(value) + if url: + return url + + if isinstance(data, list): + for item in data: + url = _extract_link_from_payload(item) + if url: + return url + + return None + + +def _build_checkout_link(session_id: str) -> str: + return TEAM_CHECKOUT_BASE_URL + session_id + + +def _extract_checkout_session_id(text: str) -> Optional[str]: + if not text: + return None + match = CHECKOUT_SESSION_REGEX.search(text) + if match: + return match.group(0) + return None + + +def _extract_publishable_key(text: str) -> Optional[str]: + if not text: + return None + match = PUBLISHABLE_KEY_REGEX.search(text) + if match: + return match.group(0) + return None + + +def _extract_first_string_by_keys(data, keys: tuple[str, ...]) -> Optional[str]: + if isinstance(data, dict): + for key in keys: + value = data.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + for value in data.values(): + nested = _extract_first_string_by_keys(value, keys) + if nested: + return nested + return None + if isinstance(data, list): + for item in data: + nested = _extract_first_string_by_keys(item, keys) + if nested: + return nested + return None + + +def _extract_publishable_key_from_payload(data) -> Optional[str]: + direct = _extract_first_string_by_keys( + data, + ( + "publishable_key", + "publishableKey", + "stripe_publishable_key", + "stripePublishableKey", + "pk", + ), + ) + if direct: + parsed = _extract_publishable_key(direct) + return parsed or direct + + text = str(data or "") + return _extract_publishable_key(text) + + +def _build_checkout_bundle_from_payload(data, proxy: Optional[str] = None) -> Dict[str, Optional[str]]: + checkout_url = _extract_checkout_link_from_payload(data, proxy=proxy) + + session_id = _extract_first_string_by_keys( + data, + ("checkout_session_id", "checkoutSessionId", "session_id", "sessionId", "id"), + ) + if session_id and not str(session_id).startswith("cs_"): + session_id = _extract_checkout_session_id(str(session_id)) + elif session_id: + session_id = str(session_id).strip() + + if not session_id: + session_id = _extract_checkout_session_id(str(checkout_url or "")) or _extract_checkout_session_id(str(data or "")) + + publishable_key = _extract_publishable_key_from_payload(data) + client_secret = _extract_first_string_by_keys( + data, + ("client_secret", "clientSecret", "stripe_client_secret", "stripeClientSecret"), + ) + + return { + "checkout_url": checkout_url, + "checkout_session_id": session_id, + "publishable_key": publishable_key, + "client_secret": client_secret, + } + + +def _is_official_checkout_link(link: Optional[str]) -> bool: + return isinstance(link, str) and CHECKOUT_LINK_REGEX.search(link.strip()) is not None + + +def _contains_sensitive_token_in_url(link: Optional[str]) -> bool: + if not isinstance(link, str): + return False + lower = link.lower() + return ( + "access_token=" in lower + or "accesstoken=" in lower + or "?token=" in lower + or "&token=" in lower + ) + + +def _extract_checkout_link_from_text(text: str) -> Optional[str]: + if not text: + return None + raw = str(text).strip() + if not raw: + return None + + if raw.startswith("/checkout/openai_llc/"): + return f"https://chatgpt.com{raw}" + + direct = CHECKOUT_LINK_REGEX.search(raw) + if direct: + return direct.group(0) + + decoded = unquote(raw) + if decoded != raw: + direct_decoded = CHECKOUT_LINK_REGEX.search(decoded) + if direct_decoded: + return direct_decoded.group(0) + if decoded.startswith("/checkout/openai_llc/"): + return f"https://chatgpt.com{decoded}" + + session_id = _extract_checkout_session_id(raw) or _extract_checkout_session_id(decoded) + if session_id: + return _build_checkout_link(session_id) + + return None + + +def _normalize_checkout_link(link: Optional[str], proxy: Optional[str] = None) -> Optional[str]: + """ + 将第三方返回的支付链接尽量归一化为 chatgpt 官方 checkout 链接。 + """ + if not isinstance(link, str): + return None + current = link.strip() + if not current: + return None + + normalized = _extract_checkout_link_from_text(current) + if normalized: + return normalized + + proxies = _build_proxies(proxy) + # 先尝试自动重定向,部分短链依赖多跳 30x 才能落到 checkout。 + try: + resp_follow = cffi_requests.get( + current, + proxies=proxies, + timeout=25, + impersonate="chrome110", + allow_redirects=True, + ) + for candidate in ( + str(getattr(resp_follow, "url", "") or ""), + resp_follow.headers.get("Location"), + resp_follow.headers.get("location"), + resp_follow.text or "", + ): + maybe = _extract_checkout_link_from_text(candidate) + if maybe: + return maybe + except Exception: + pass + + for _ in range(5): + try: + resp = cffi_requests.get( + current, + proxies=proxies, + timeout=20, + impersonate="chrome110", + allow_redirects=False, + ) + except Exception: + break + + location = resp.headers.get("Location") or resp.headers.get("location") + if isinstance(location, str) and location.strip(): + next_url = urljoin(current, location.strip()) + normalized_next = _extract_checkout_link_from_text(next_url) + if normalized_next: + return normalized_next + current = next_url + continue + + body = resp.text or "" + normalized_body = _extract_checkout_link_from_text(body) + if normalized_body: + return normalized_body + break + + return link + + +def _extract_checkout_link_from_payload(data, proxy: Optional[str] = None) -> Optional[str]: + """ + 从响应体中提取可直达 chatgpt checkout 的链接。 + """ + if data is None: + return None + + url = _extract_link_from_payload(data) + if url: + return _normalize_checkout_link(url, proxy=proxy) + + if isinstance(data, dict): + for key in ("checkout_session_id", "session_id", "id"): + value = data.get(key) + if isinstance(value, str) and value.startswith("cs_"): + return _build_checkout_link(value.strip()) + if isinstance(value, dict): + nested_id = value.get("id") + if isinstance(nested_id, str) and nested_id.startswith("cs_"): + return _build_checkout_link(nested_id.strip()) + + for value in data.values(): + nested = _extract_checkout_link_from_payload(value, proxy=proxy) + if nested: + return nested + + if isinstance(data, list): + for item in data: + nested = _extract_checkout_link_from_payload(item, proxy=proxy) + if nested: + return nested + + text = str(data) + direct = CHECKOUT_LINK_REGEX.search(text) + if direct: + return direct.group(0) + session_id = _extract_checkout_session_id(text) + if session_id: + return _build_checkout_link(session_id) + return None + + +def generate_aimizy_payment_link( + account: Account, + plan_type: str = "plus", + proxy: Optional[str] = None, + country: str = "US", + currency: str = "USD", +) -> str: + """ + 基于 Access Token 生成 aimizy 站内代付短链。 + 先尝试常见 API 形态,失败后回退到可直接打开的 pay URL。 + """ + if not account.access_token: + raise ValueError("账号缺少 access_token") + + token = account.access_token.strip() + if not token: + raise ValueError("账号 access_token 为空") + + payload = { + "access_token": token, + "accessToken": token, + "token": token, + "bearer": f"Bearer {token}", + "plan_type": plan_type, + "plan": plan_type, + "country": country, + "currency": currency, + "region": country, + } + proxies = _build_proxies(proxy) + common_headers = { + "Accept": "application/json,text/plain,*/*", + "Content-Type": "application/json", + "Authorization": f"Bearer {token}", + } + + attempts = [ + ("POST", AIMIZY_PAY_URL, {"json": payload, "headers": common_headers}), + ("POST", AIMIZY_PAY_URL, {"data": payload, "headers": {"Accept": "application/json,text/plain,*/*"}}), + ("POST", f"{AIMIZY_PAY_URL}/api/generate", {"json": payload, "headers": common_headers}), + ("POST", f"{AIMIZY_PAY_URL}/api/create", {"json": payload, "headers": common_headers}), + ("POST", f"{AIMIZY_PAY_URL}/api/pay", {"json": payload, "headers": common_headers}), + ("POST", f"{AIMIZY_PAY_URL}/api/payment-link", {"json": payload, "headers": common_headers}), + ("GET", AIMIZY_PAY_URL, {"params": payload, "headers": {"Accept": "application/json,text/plain,*/*"}}), + ("GET", f"{AIMIZY_PAY_URL}/api/generate", {"params": payload, "headers": {"Accept": "application/json,text/plain,*/*"}}), + ] + + for method, url, kwargs in attempts: + try: + resp = cffi_requests.request( + method, + url, + proxies=proxies, + timeout=20, + impersonate="chrome110", + **kwargs, + ) + + # 某些服务会返回 302 + Location + location = resp.headers.get("Location") or resp.headers.get("location") + if isinstance(location, str) and location.startswith(("http://", "https://")): + candidate = _normalize_checkout_link(location, proxy=proxy) + if candidate and _is_official_checkout_link(candidate): + return candidate + + if resp.status_code >= 400: + logger.debug(f"aimizy 链接生成尝试失败: {method} {url} -> HTTP {resp.status_code}") + continue + + content_type = (resp.headers.get("content-type") or "").lower() + if "application/json" in content_type: + try: + data = resp.json() + except Exception: + data = None + link = _extract_checkout_link_from_payload(data, proxy=proxy) or _extract_link_from_payload(data) + if link: + candidate = _normalize_checkout_link(link, proxy=proxy) + if candidate and _is_official_checkout_link(candidate): + return candidate + + text = (resp.text or "").strip() + if text.startswith(("http://", "https://")): + candidate = _normalize_checkout_link(text, proxy=proxy) + if candidate and _is_official_checkout_link(candidate): + return candidate + + # 尝试从非 JSON 文本中找 URL + match = re.search(r"https?://[^\s\"'<>]+", text) + if match: + candidate = _normalize_checkout_link(match.group(0), proxy=proxy) + if candidate and _is_official_checkout_link(candidate): + return candidate + + # 再兜底:直接扫描 cs_xxx 并拼接 checkout 链接 + parsed_from_text = _extract_checkout_link_from_text(text) + if parsed_from_text: + return parsed_from_text + + except Exception as e: + logger.debug(f"aimizy 链接生成尝试异常: {method} {url} -> {e}") + + # 最终回退:构造可直接进入站内代付页的链接 + query = urlencode( + { + "access_token": token, + "accessToken": token, + "plan_type": plan_type, + "country": country, + "currency": currency, + } + ) + fallback_url = f"{AIMIZY_PAY_URL}?{query}" + fallback_candidate = _normalize_checkout_link(fallback_url, proxy=proxy) + if _is_official_checkout_link(fallback_candidate): + return fallback_candidate + raise ValueError("未能从代付通道解析到官方 checkout 链接,请先确认账号 token 有效") + + _COUNTRY_CURRENCY_MAP = { "SG": "SGD", "US": "USD", @@ -39,6 +489,113 @@ _COUNTRY_CURRENCY_MAP = { } +def _resolve_chatgpt_account_id(account: Account) -> Optional[str]: + # 优先从当前 token 解析,避免数据库里旧 account_id 导致订阅误判。 + token_account_id = ( + _extract_chatgpt_account_id_from_jwt(account.access_token) + or _extract_chatgpt_account_id_from_jwt(account.id_token) + ) + for candidate in (token_account_id, account.account_id, account.workspace_id): + value = str(candidate or "").strip() + if value: + return value + return None + + +def _decode_jwt_payload(token: Optional[str]) -> Optional[Dict[str, Any]]: + text = str(token or "").strip() + if not text or "." not in text: + return None + parts = text.split(".") + if len(parts) < 2: + return None + payload_part = parts[1] + if not payload_part: + return None + padding = "=" * (-len(payload_part) % 4) + try: + raw = base64.urlsafe_b64decode(payload_part + padding) + data = json.loads(raw.decode("utf-8")) + return data if isinstance(data, dict) else None + except Exception: + return None + + +def _extract_auth_claim(payload: Optional[Dict[str, Any]]) -> Dict[str, Any]: + if not isinstance(payload, dict): + return {} + auth = payload.get("https://api.openai.com/auth") + if isinstance(auth, dict): + return auth + auth = payload.get("auth_data") + if isinstance(auth, dict): + return auth + return {} + + +def _extract_chatgpt_account_id_from_jwt(token: Optional[str]) -> Optional[str]: + payload = _decode_jwt_payload(token) + if not payload: + return None + auth = _extract_auth_claim(payload) + for key in ( + "chatgpt_account_id", + "account_id", + "workspace_id", + ): + value = str(auth.get(key) or payload.get(key) or "").strip() + if value: + return value + return None + + +def _extract_chatgpt_plan_from_jwt(token: Optional[str]) -> Optional[str]: + payload = _decode_jwt_payload(token) + if not payload: + return None + auth = _extract_auth_claim(payload) + candidates = [ + auth.get("chatgpt_plan_type"), + auth.get("plan_type"), + auth.get("subscription_plan"), + payload.get("chatgpt_plan_type"), + payload.get("plan_type"), + payload.get("subscription_plan"), + payload.get("subscription_tier"), + ] + for item in candidates: + mapped = _map_plan_to_subscription(item if isinstance(item, str) else None) + if mapped in ("plus", "team", "free"): + return mapped + return None + + +def _collect_plan_candidates(value: Any) -> List[str]: + candidates: List[str] = [] + if isinstance(value, dict): + for key, item in value.items(): + key_lower = str(key).strip().lower() + if key_lower in { + "plan_type", + "plan", + "subscription_plan", + "subscription_tier", + "chatgpt_plan_type", + "tier", + "workspace_plan_type", + "product", + } and isinstance(item, str): + text = item.strip() + if text: + candidates.append(text) + if isinstance(item, (dict, list)): + candidates.extend(_collect_plan_candidates(item)) + elif isinstance(value, list): + for item in value: + candidates.extend(_collect_plan_candidates(item)) + return candidates + + def _extract_oai_did(cookies_str: str) -> Optional[str]: """从 cookie 字符串中提取 oai-device-id""" for part in cookies_str.split(";"): @@ -48,6 +605,70 @@ def _extract_oai_did(cookies_str: str) -> Optional[str]: return None +def _resolve_oai_device_id(account: Account) -> str: + if account.cookies: + oai_did = _extract_oai_did(account.cookies) + if oai_did: + return oai_did + return str(uuid.uuid4()) + + +def _build_openai_sentinel_token( + account: Account, + device_id: str, + proxy: Optional[str] = None, +) -> Optional[str]: + """ + 生成 openai-sentinel-token(失败时返回 None,不阻断主流程)。 + """ + if not device_id: + return None + + body = json.dumps({"p": "", "id": device_id, "flow": "authorize_continue"}) + headers = { + "Origin": "https://sentinel.openai.com", + "Referer": "https://sentinel.openai.com/backend-api/sentinel/frame.html?sv=20260219f9f6", + "Content-Type": "text/plain;charset=UTF-8", + "Accept": "application/json", + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + ), + } + if account.cookies: + headers["cookie"] = account.cookies + + try: + resp = cffi_requests.post( + SENTINEL_REQ_URL, + headers=headers, + data=body, + proxies=_build_proxies(proxy), + timeout=20, + impersonate="chrome110", + ) + if resp.status_code != 200: + logger.debug(f"sentinel token 获取失败: HTTP {resp.status_code}") + return None + data = resp.json() if resp.content else {} + token = str(data.get("token") or "").strip() + if not token: + logger.debug("sentinel token 为空") + return None + return json.dumps( + { + "p": "", + "t": "", + "c": token, + "id": device_id, + "flow": "authorize_continue", + } + ) + except Exception as exc: + logger.debug(f"sentinel token 获取异常: {exc}") + return None + + def _parse_cookie_str(cookies_str: str, domain: str) -> list: """将 'key=val; key2=val2' 格式解析为 Playwright cookie 列表""" cookies = [] @@ -91,27 +712,96 @@ def _open_url_system_browser(url: str) -> bool: return False -def generate_plus_link( - account: Account, - proxy: Optional[str] = None, - country: str = "SG", -) -> str: - """生成 Plus 支付链接(后端携带账号 cookie 发请求)""" +def _build_checkout_request_headers(account: Account, proxy: Optional[str]) -> Dict[str, str]: if not account.access_token: raise ValueError("账号缺少 access_token") - currency = _COUNTRY_CURRENCY_MAP.get(country, "USD") + device_id = _resolve_oai_device_id(account) headers = { "Authorization": f"Bearer {account.access_token}", "Content-Type": "application/json", + "Accept": "application/json", + "Origin": "https://chatgpt.com", + "Referer": "https://chatgpt.com/", "oai-language": "zh-CN", + "oai-device-id": device_id, + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + ), } if account.cookies: headers["cookie"] = account.cookies - oai_did = _extract_oai_did(account.cookies) - if oai_did: - headers["oai-device-id"] = oai_did + chatgpt_account_id = _resolve_chatgpt_account_id(account) + if chatgpt_account_id: + headers["ChatGPT-Account-Id"] = chatgpt_account_id + sentinel_token = _build_openai_sentinel_token(account, device_id=device_id, proxy=proxy) + if sentinel_token: + headers["openai-sentinel-token"] = sentinel_token + + return headers + + +def _request_checkout_bundle(account: Account, payload: Dict[str, Any], proxy: Optional[str] = None) -> Dict[str, Optional[str]]: + headers = _build_checkout_request_headers(account, proxy=proxy) + proxy_candidates: List[Optional[str]] = [proxy] + if proxy: + proxy_candidates.append(None) + + last_err: Optional[Exception] = None + for idx, candidate_proxy in enumerate(proxy_candidates): + try: + resp = cffi_requests.post( + PAYMENT_CHECKOUT_URL, + headers=headers, + json=payload, + proxies=_build_proxies(candidate_proxy), + timeout=30, + impersonate="chrome110", + ) + resp.raise_for_status() + data = resp.json() + bundle = _build_checkout_bundle_from_payload(data, proxy=candidate_proxy) + if bundle.get("checkout_url"): + if idx > 0: + logger.warning( + "官方 checkout 代理请求失败后直连成功: account_id=%s email=%s", + account.id, + account.email, + ) + return bundle + raise ValueError(data.get("detail", "API 未返回可用 checkout 链接")) + except Exception as exc: + last_err = exc + should_retry_direct = ( + idx == 0 + and proxy is not None + and candidate_proxy is not None + and _is_connectivity_error(exc) + ) + if should_retry_direct: + logger.warning( + "官方 checkout 代理请求网络异常,改为直连重试: account_id=%s email=%s error=%s", + account.id, + account.email, + exc, + ) + continue + raise + + if last_err: + raise last_err + raise RuntimeError("官方 checkout 请求失败") + + +def generate_plus_checkout_bundle( + account: Account, + proxy: Optional[str] = None, + country: str = "US", +) -> Dict[str, Optional[str]]: + """生成 Plus checkout 全量信息(含 checkout_session_id/publishable_key)。""" + currency = _COUNTRY_CURRENCY_MAP.get(country, "USD") payload = { "plan_name": "chatgptplusplan", "billing_details": {"country": country, "currency": currency}, @@ -121,46 +811,19 @@ def generate_plus_link( }, "checkout_ui_mode": "custom", } - - resp = cffi_requests.post( - PAYMENT_CHECKOUT_URL, - headers=headers, - json=payload, - proxies=_build_proxies(proxy), - timeout=30, - impersonate="chrome110", - ) - resp.raise_for_status() - data = resp.json() - if "checkout_session_id" in data: - return TEAM_CHECKOUT_BASE_URL + data["checkout_session_id"] - raise ValueError(data.get("detail", "API 未返回 checkout_session_id")) + return _request_checkout_bundle(account=account, payload=payload, proxy=proxy) -def generate_team_link( +def generate_team_checkout_bundle( account: Account, workspace_name: str = "MyTeam", price_interval: str = "month", seat_quantity: int = 5, proxy: Optional[str] = None, - country: str = "SG", -) -> str: - """生成 Team 支付链接(后端携带账号 cookie 发请求)""" - if not account.access_token: - raise ValueError("账号缺少 access_token") - + country: str = "US", +) -> Dict[str, Optional[str]]: + """生成 Team checkout 全量信息(含 checkout_session_id/publishable_key)。""" currency = _COUNTRY_CURRENCY_MAP.get(country, "USD") - headers = { - "Authorization": f"Bearer {account.access_token}", - "Content-Type": "application/json", - "oai-language": "zh-CN", - } - if account.cookies: - headers["cookie"] = account.cookies - oai_did = _extract_oai_did(account.cookies) - if oai_did: - headers["oai-device-id"] = oai_did - payload = { "plan_name": "chatgptteamplan", "team_plan_data": { @@ -169,27 +832,42 @@ def generate_team_link( "seat_quantity": seat_quantity, }, "billing_details": {"country": country, "currency": currency}, + "cancel_url": "https://chatgpt.com/?promo_campaign=team-1-month-free#team-pricing", "promo_campaign": { "promo_campaign_id": "team-1-month-free", - "is_coupon_from_query_param": True, + "is_coupon_from_query_param": False, }, - "cancel_url": "https://chatgpt.com/#pricing", "checkout_ui_mode": "custom", } + return _request_checkout_bundle(account=account, payload=payload, proxy=proxy) - resp = cffi_requests.post( - PAYMENT_CHECKOUT_URL, - headers=headers, - json=payload, - proxies=_build_proxies(proxy), - timeout=30, - impersonate="chrome110", + +def generate_plus_link( + account: Account, + proxy: Optional[str] = None, + country: str = "US", +) -> str: + bundle = generate_plus_checkout_bundle(account=account, proxy=proxy, country=country) + return str(bundle.get("checkout_url") or "") + + +def generate_team_link( + account: Account, + workspace_name: str = "MyTeam", + price_interval: str = "month", + seat_quantity: int = 5, + proxy: Optional[str] = None, + country: str = "US", +) -> str: + bundle = generate_team_checkout_bundle( + account=account, + workspace_name=workspace_name, + price_interval=price_interval, + seat_quantity=seat_quantity, + proxy=proxy, + country=country, ) - resp.raise_for_status() - data = resp.json() - if "checkout_session_id" in data: - return TEAM_CHECKOUT_BASE_URL + data["checkout_session_id"] - raise ValueError(data.get("detail", "API 未返回 checkout_session_id")) + return str(bundle.get("checkout_url") or "") def open_url_incognito(url: str, cookies_str: Optional[str] = None) -> bool: @@ -219,12 +897,23 @@ def open_url_incognito(url: str, cookies_str: Optional[str] = None) -> bool: return True -def check_subscription_status(account: Account, proxy: Optional[str] = None) -> str: +def _map_plan_to_subscription(plan: Optional[str]) -> Optional[str]: + text = (plan or "").strip().lower() + if not text: + return None + if "team" in text or "enterprise" in text: + return "team" + if "plus" in text or "pro" in text: + return "plus" + if "free" in text or "basic" in text: + return "free" + return None + + +def check_subscription_status_detail(account: Account, proxy: Optional[str] = None) -> Dict[str, Any]: """ 检测账号当前订阅状态。 - - Returns: - 'free' / 'plus' / 'team' + 返回带来源与置信度的详细结果,便于绑卡任务诊断。 """ if not account.access_token: raise ValueError("账号缺少 access_token") @@ -233,29 +922,328 @@ def check_subscription_status(account: Account, proxy: Optional[str] = None) -> "Authorization": f"Bearer {account.access_token}", "Content-Type": "application/json", } - - resp = cffi_requests.get( - "https://chatgpt.com/backend-api/me", - headers=headers, - proxies=_build_proxies(proxy), - timeout=20, - impersonate="chrome110", + chatgpt_account_id = _resolve_chatgpt_account_id(account) + if chatgpt_account_id: + headers["ChatGPT-Account-Id"] = chatgpt_account_id + logger.info( + "订阅检测上下文: email=%s chatgpt_account_id=%s proxy=%s", + account.email, + chatgpt_account_id or "-", + proxy or "-", ) - resp.raise_for_status() - data = resp.json() + if account.cookies: + headers["cookie"] = account.cookies + oai_did = _extract_oai_did(account.cookies) + if oai_did: + headers["oai-device-id"] = oai_did - # 解析订阅类型 - plan = data.get("plan_type") or "" - if "team" in plan.lower(): - return "team" - if "plus" in plan.lower(): - return "plus" + successful_sources: List[str] = [] + errors: List[str] = [] + weak_free_source: Optional[str] = None + explicit_free_source: Optional[str] = None + explicit_free_value: Optional[str] = None - # 尝试从 orgs 或 workspace 信息判断 - orgs = data.get("orgs", {}).get("data", []) - for org in orgs: - settings_ = org.get("settings", {}) - if settings_.get("workspace_plan_type") in ("team", "enterprise"): - return "team" + def _result(status: str, source: str, confidence: str, note: Optional[str] = None) -> Dict[str, Any]: + payload = { + "status": status, + "source": source, + "confidence": confidence, + "errors": errors[:4], + "successful_sources": successful_sources[:4], + } + if note: + payload["note"] = note + return payload - return "free" + def _analyze_me_payload(data: Any, source_prefix: str) -> Optional[Dict[str, Any]]: + nonlocal weak_free_source, explicit_free_source, explicit_free_value + if not isinstance(data, dict): + return None + + candidates = [ + data.get("plan_type"), + data.get("plan"), + data.get("subscription_plan"), + data.get("subscription_tier"), + data.get("chatgpt_plan_type"), + ] + account_block = data.get("account") + if isinstance(account_block, dict): + candidates.extend([ + account_block.get("plan_type"), + account_block.get("plan"), + account_block.get("subscription_plan"), + account_block.get("subscription_tier"), + account_block.get("chatgpt_plan_type"), + ]) + subscription_block = data.get("subscription") + if isinstance(subscription_block, dict): + candidates.extend([ + subscription_block.get("plan_type"), + subscription_block.get("plan"), + subscription_block.get("product"), + subscription_block.get("tier"), + ]) + + for item in candidates: + mapped = _map_plan_to_subscription(item) + if mapped in ("plus", "team"): + return _result(mapped, f"{source_prefix}.plan", "high") + if mapped == "free" and not explicit_free_source: + explicit_free_source = f"{source_prefix}.plan" + explicit_free_value = str(item) + + orgs = data.get("orgs", {}).get("data", []) + if isinstance(orgs, list): + for org in orgs: + if not isinstance(org, dict): + continue + settings_ = org.get("settings", {}) + if isinstance(settings_, dict): + workspace_plan = str(settings_.get("workspace_plan_type") or "").lower() + if workspace_plan in ("team", "enterprise"): + return _result("team", f"{source_prefix}.org.workspace_plan_type", "high") + mapped = _map_plan_to_subscription(org.get("plan_type") or org.get("plan")) + if mapped in ("plus", "team"): + return _result(mapped, f"{source_prefix}.org.plan", "high") + + bool_markers = ( + data.get("has_paid_subscription"), + data.get("has_active_subscription"), + data.get("is_paid"), + data.get("is_subscribed"), + ) + if any(v is True for v in bool_markers): + return _result("plus", f"{source_prefix}.subscription_flag", "medium") + if all(v is False for v in bool_markers if v is not None): + weak_free_source = weak_free_source or f"{source_prefix}.subscription_flag_false" + explicit_free_value = explicit_free_value or "all_false" + return None + + def _analyze_usage_payload(data: Any, source_prefix: str) -> Optional[Dict[str, Any]]: + nonlocal weak_free_source, explicit_free_value + if not isinstance(data, dict): + return None + usage_candidates = [ + data.get("plan_type"), + data.get("plan"), + data.get("subscription_plan"), + data.get("subscription_tier"), + data.get("chatgpt_plan_type"), + data.get("tier"), + ] + for item in usage_candidates: + mapped = _map_plan_to_subscription(item) + if mapped in ("plus", "team"): + return _result(mapped, f"{source_prefix}.plan", "high") + if mapped == "free": + weak_free_source = weak_free_source or f"{source_prefix}.plan" + explicit_free_value = explicit_free_value or str(item) + + rate_limit = data.get("rate_limit") + code_review_limit = data.get("code_review_rate_limit") + if isinstance(rate_limit, dict) or isinstance(code_review_limit, dict): + weak_free_source = weak_free_source or f"{source_prefix}.rate_limit_only" + return None + + # 1) me 接口 + try: + resp = cffi_requests.get( + "https://chatgpt.com/backend-api/me", + headers=headers, + proxies=_build_proxies(proxy), + timeout=20, + impersonate="chrome110", + ) + resp.raise_for_status() + successful_sources.append("me") + data = resp.json() if resp.content else {} + detected = _analyze_me_payload(data, "me") + if detected: + return detected + except Exception as exc: + errors.append(f"me: {exc}") + + # 1.5) 去掉 ChatGPT-Account-Id 再测一次 me(避免错误 workspace 作用域导致误判 free) + if chatgpt_account_id: + try: + headers_no_scope = dict(headers) + headers_no_scope.pop("ChatGPT-Account-Id", None) + resp_no_scope = cffi_requests.get( + "https://chatgpt.com/backend-api/me", + headers=headers_no_scope, + proxies=_build_proxies(proxy), + timeout=20, + impersonate="chrome110", + ) + resp_no_scope.raise_for_status() + successful_sources.append("me_no_scope") + data_no_scope = resp_no_scope.json() if resp_no_scope.content else {} + detected = _analyze_me_payload(data_no_scope, "me.no_scope") + if detected: + logger.info( + "订阅检测无作用域复核命中: email=%s source=%s confidence=%s", + account.email, + detected.get("source"), + detected.get("confidence"), + ) + return detected + except Exception as exc: + errors.append(f"me_no_scope: {exc}") + + # 2) wham/usage(Cockpit-tools 同款核心) + try: + usage_resp = cffi_requests.get( + "https://chatgpt.com/backend-api/wham/usage", + headers=headers, + proxies=_build_proxies(proxy), + timeout=20, + impersonate="chrome110", + ) + usage_resp.raise_for_status() + successful_sources.append("wham_usage") + usage_data = usage_resp.json() if usage_resp.content else {} + detected = _analyze_usage_payload(usage_data, "wham_usage") + if detected: + return detected + except Exception as exc: + errors.append(f"wham_usage: {exc}") + + # 3) wham/accounts/check(Cockpit-tools 用于账号资料同步的官方口径) + try: + account_check_resp = cffi_requests.get( + ACCOUNT_CHECK_URL, + headers=headers, + proxies=_build_proxies(proxy), + timeout=20, + impersonate="chrome110", + ) + account_check_resp.raise_for_status() + successful_sources.append("wham_accounts_check") + account_check_data = account_check_resp.json() if account_check_resp.content else {} + for raw in _collect_plan_candidates(account_check_data): + mapped = _map_plan_to_subscription(raw) + if mapped in ("plus", "team"): + return _result(mapped, "wham_accounts_check.plan", "medium") + if mapped == "free": + weak_free_source = weak_free_source or "wham_accounts_check.plan" + explicit_free_value = explicit_free_value or str(raw) + except Exception as exc: + errors.append(f"wham_accounts_check: {exc}") + + # 3.5) 作用域复核:前面仍未命中付费时,去掉 ChatGPT-Account-Id 再测 usage + accounts/check + if chatgpt_account_id: + headers_no_scope = dict(headers) + headers_no_scope.pop("ChatGPT-Account-Id", None) + try: + usage_no_scope_resp = cffi_requests.get( + "https://chatgpt.com/backend-api/wham/usage", + headers=headers_no_scope, + proxies=_build_proxies(proxy), + timeout=20, + impersonate="chrome110", + ) + usage_no_scope_resp.raise_for_status() + successful_sources.append("wham_usage_no_scope") + usage_no_scope_data = usage_no_scope_resp.json() if usage_no_scope_resp.content else {} + detected = _analyze_usage_payload(usage_no_scope_data, "wham_usage.no_scope") + if detected: + logger.info( + "订阅检测 usage 无作用域复核命中: email=%s source=%s confidence=%s", + account.email, + detected.get("source"), + detected.get("confidence"), + ) + return detected + except Exception as exc: + errors.append(f"wham_usage_no_scope: {exc}") + + try: + account_check_no_scope_resp = cffi_requests.get( + ACCOUNT_CHECK_URL, + headers=headers_no_scope, + proxies=_build_proxies(proxy), + timeout=20, + impersonate="chrome110", + ) + account_check_no_scope_resp.raise_for_status() + successful_sources.append("wham_accounts_check_no_scope") + account_check_no_scope_data = account_check_no_scope_resp.json() if account_check_no_scope_resp.content else {} + for raw in _collect_plan_candidates(account_check_no_scope_data): + mapped = _map_plan_to_subscription(raw) + if mapped in ("plus", "team"): + return _result(mapped, "wham_accounts_check.no_scope.plan", "medium") + if mapped == "free": + weak_free_source = weak_free_source or "wham_accounts_check.no_scope.plan" + explicit_free_value = explicit_free_value or str(raw) + except Exception as exc: + errors.append(f"wham_accounts_check_no_scope: {exc}") + + # 4) 概览接口兜底(跨 endpoint 聚合) + try: + overview = fetch_codex_overview(account, proxy=proxy) + successful_sources.append("overview") + mapped = _map_plan_to_subscription(overview.get("plan_type")) + if mapped in ("plus", "team"): + return _result(mapped, f"overview.{overview.get('plan_source') or 'plan'}", "medium") + if mapped == "free": + weak_free_source = weak_free_source or f"overview.{overview.get('plan_source') or 'plan'}" + for err in overview.get("errors") or []: + if err: + errors.append(f"overview: {err}") + except Exception as exc: + errors.append(f"overview: {exc}") + + # 5) JWT claim 兜底(不依赖刷新 token) + jwt_candidates = ( + ("id_token.chatgpt_plan_type", _extract_chatgpt_plan_from_jwt(account.id_token)), + ("access_token.chatgpt_plan_type", _extract_chatgpt_plan_from_jwt(account.access_token)), + ) + for source_name, mapped in jwt_candidates: + if mapped in ("plus", "team"): + return _result(mapped, source_name, "medium", note="jwt_claim") + if mapped == "free": + weak_free_source = weak_free_source or source_name + explicit_free_value = explicit_free_value or "jwt_free" + + # 6) 明确 free 信号 + if explicit_free_source: + return _result( + "free", + explicit_free_source, + "high", + note=f"explicit_free={explicit_free_value or '-'}", + ) + + # 7) 检测信号弱时,优先使用数据库缓存的已订阅状态避免误判为 free + cached = _map_plan_to_subscription(account.subscription_type) + if cached in ("plus", "team"): + return _result( + cached, + "db.subscription_type", + "low", + note=f"api_ambiguous={weak_free_source or 'no_plan_signal'}", + ) + + # 8) 若所有检测接口都失败,抛错而不是误判 free + if not successful_sources: + raise RuntimeError("订阅检测接口全部失败: " + " | ".join(errors[:3])) + + # 9) 仍无有效订阅信号,返回低置信度 free + return _result( + "free", + weak_free_source or "fallback.default_free", + "low", + note="no_paid_signal", + ) + + +def check_subscription_status(account: Account, proxy: Optional[str] = None) -> str: + """ + 兼容旧调用:仅返回 'free' / 'plus' / 'team'。 + """ + detail = check_subscription_status_detail(account, proxy=proxy) + status = str(detail.get("status") or "free").lower() + if status not in ("free", "plus", "team"): + return "free" + return status diff --git a/src/core/openai/random_billing.py b/src/core/openai/random_billing.py new file mode 100644 index 0000000..bfb515f --- /dev/null +++ b/src/core/openai/random_billing.py @@ -0,0 +1,482 @@ +""" +Random billing profile helper. + +Strategy: +1) Try meiguodizhi pages by country. +2) If parsing fails, fall back to local random profile. +""" + +from __future__ import annotations + +import html +import logging +import os +import random +import re +from typing import Dict, List, Optional +from urllib.parse import urljoin + +from curl_cffi import requests as cffi_requests + +logger = logging.getLogger(__name__) + +BASE_URL = "https://www.meiguodizhi.com" +ENABLE_EXTERNAL_SOURCE = str(os.getenv("RANDOM_BILLING_ENABLE_EXTERNAL", "")).strip().lower() in { + "1", + "true", + "yes", + "on", +} + +COUNTRY_CURRENCY_MAP: Dict[str, str] = { + "US": "USD", + "GB": "GBP", + "CA": "CAD", + "AU": "AUD", + "SG": "SGD", + "HK": "HKD", + "JP": "JPY", + "DE": "EUR", + "FR": "EUR", + "IT": "EUR", + "ES": "EUR", +} + +COUNTRY_ROUTE_CANDIDATES: Dict[str, List[str]] = { + "US": [ + "/usa-address", + "/usa-address/hot-city-Seattle?hl=en", + "/?hl=en", + ], + "GB": ["/uk-address", "/gb-address"], + "CA": ["/canada-address", "/ca-address"], + "AU": ["/australia-address", "/au-address"], + "SG": ["/singapore-address", "/sg-address"], + "HK": ["/hongkong-address", "/hk-address"], + "JP": ["/japan-address", "/jp-address"], + "DE": ["/germany-address", "/de-address"], + "FR": ["/france-address", "/fr-address"], + "IT": ["/italy-address", "/it-address"], + "ES": ["/spain-address", "/es-address"], +} + +FIRST_NAMES = [ + "James", + "Olivia", + "Noah", + "Emma", + "Liam", + "Sophia", + "Ethan", + "Mia", + "Aiden", + "Ava", + "Lucas", + "Amelia", + "Henry", + "Harper", +] + +LAST_NAMES = [ + "Smith", + "Johnson", + "Williams", + "Brown", + "Jones", + "Garcia", + "Miller", + "Davis", + "Wilson", + "Anderson", + "Taylor", + "Thomas", +] + +STREET_NAMES = [ + "Main St", + "Oak Ave", + "Maple Dr", + "Pine St", + "Cedar Ln", + "Park Ave", + "Sunset Blvd", + "River Rd", +] + +STREET_SUFFIXES = ["St", "Ave", "Blvd", "Dr", "Ln", "Way", "Ct", "Pl", "Rd"] +US_STREET_BASES = [ + "Washington", + "Lincoln", + "Franklin", + "Jefferson", + "Madison", + "Jackson", + "Monroe", + "Adams", + "Wilson", + "Lake", + "Hill", + "Sunset", + "Park", + "Riverside", + "Highland", + "Center", + "Valley", + "Cedar", + "Pine", + "Maple", + "Willow", + "Cherry", + "Elm", + "Locust", + "Meadow", +] + +US_STATE_CITY_POOL: Dict[str, Dict[str, List[str] | str]] = { + "CA": {"name": "California", "cities": ["Los Angeles", "San Francisco", "San Diego", "San Jose"], "zip_prefix": "9"}, + "NY": {"name": "New York", "cities": ["New York", "Brooklyn", "Buffalo", "Rochester"], "zip_prefix": "1"}, + "TX": {"name": "Texas", "cities": ["Houston", "Dallas", "Austin", "San Antonio"], "zip_prefix": "7"}, + "FL": {"name": "Florida", "cities": ["Miami", "Orlando", "Tampa", "Jacksonville"], "zip_prefix": "3"}, + "WA": {"name": "Washington", "cities": ["Seattle", "Spokane", "Tacoma", "Bellevue"], "zip_prefix": "9"}, + "IL": {"name": "Illinois", "cities": ["Chicago", "Aurora", "Naperville", "Rockford"], "zip_prefix": "6"}, + "PA": {"name": "Pennsylvania", "cities": ["Philadelphia", "Pittsburgh", "Allentown", "Erie"], "zip_prefix": "1"}, + "OH": {"name": "Ohio", "cities": ["Columbus", "Cleveland", "Cincinnati", "Toledo"], "zip_prefix": "4"}, + "GA": {"name": "Georgia", "cities": ["Atlanta", "Savannah", "Augusta", "Macon"], "zip_prefix": "3"}, + "NC": {"name": "North Carolina", "cities": ["Charlotte", "Raleigh", "Greensboro", "Durham"], "zip_prefix": "2"}, + "VA": {"name": "Virginia", "cities": ["Virginia Beach", "Richmond", "Norfolk", "Arlington"], "zip_prefix": "2"}, + "MA": {"name": "Massachusetts", "cities": ["Boston", "Worcester", "Cambridge", "Springfield"], "zip_prefix": "0"}, + "NJ": {"name": "New Jersey", "cities": ["Newark", "Jersey City", "Paterson", "Edison"], "zip_prefix": "0"}, + "MI": {"name": "Michigan", "cities": ["Detroit", "Grand Rapids", "Lansing", "Ann Arbor"], "zip_prefix": "4"}, + "AZ": {"name": "Arizona", "cities": ["Phoenix", "Tucson", "Mesa", "Chandler"], "zip_prefix": "8"}, + "CO": {"name": "Colorado", "cities": ["Denver", "Colorado Springs", "Aurora", "Fort Collins"], "zip_prefix": "8"}, + "NV": {"name": "Nevada", "cities": ["Las Vegas", "Reno", "Henderson", "North Las Vegas"], "zip_prefix": "8"}, + "OR": {"name": "Oregon", "cities": ["Portland", "Salem", "Eugene", "Gresham"], "zip_prefix": "9"}, + "MN": {"name": "Minnesota", "cities": ["Minneapolis", "Saint Paul", "Rochester", "Bloomington"], "zip_prefix": "5"}, + "MO": {"name": "Missouri", "cities": ["Kansas City", "St. Louis", "Springfield", "Columbia"], "zip_prefix": "6"}, + "TN": {"name": "Tennessee", "cities": ["Nashville", "Memphis", "Knoxville", "Chattanooga"], "zip_prefix": "3"}, + "IN": {"name": "Indiana", "cities": ["Indianapolis", "Fort Wayne", "Evansville", "South Bend"], "zip_prefix": "4"}, + "WI": {"name": "Wisconsin", "cities": ["Milwaukee", "Madison", "Green Bay", "Kenosha"], "zip_prefix": "5"}, + "MD": {"name": "Maryland", "cities": ["Baltimore", "Columbia", "Germantown", "Rockville"], "zip_prefix": "2"}, + "SC": {"name": "South Carolina", "cities": ["Charleston", "Columbia", "Greenville", "Myrtle Beach"], "zip_prefix": "2"}, + "AL": {"name": "Alabama", "cities": ["Birmingham", "Montgomery", "Huntsville", "Mobile"], "zip_prefix": "3"}, + "LA": {"name": "Louisiana", "cities": ["New Orleans", "Baton Rouge", "Shreveport", "Lafayette"], "zip_prefix": "7"}, + "OK": {"name": "Oklahoma", "cities": ["Oklahoma City", "Tulsa", "Norman", "Edmond"], "zip_prefix": "7"}, + "KY": {"name": "Kentucky", "cities": ["Louisville", "Lexington", "Bowling Green", "Owensboro"], "zip_prefix": "4"}, + "UT": {"name": "Utah", "cities": ["Salt Lake City", "Provo", "West Valley City", "Ogden"], "zip_prefix": "8"}, +} + +LOCAL_ADDRESS_POOL: Dict[str, List[Dict[str, str]]] = { + "US": [ + {"city": "San Jose", "state": "CA", "postal": "95112"}, + {"city": "Austin", "state": "TX", "postal": "78701"}, + {"city": "Seattle", "state": "WA", "postal": "98101"}, + {"city": "Miami", "state": "FL", "postal": "33101"}, + {"city": "Boston", "state": "MA", "postal": "02108"}, + ], + "GB": [ + {"city": "London", "state": "London", "postal": "SW1A 1AA"}, + {"city": "Manchester", "state": "England", "postal": "M1 1AE"}, + ], + "CA": [ + {"city": "Toronto", "state": "ON", "postal": "M5V 2T6"}, + {"city": "Vancouver", "state": "BC", "postal": "V6B 1A1"}, + ], + "AU": [ + {"city": "Sydney", "state": "NSW", "postal": "2000"}, + {"city": "Melbourne", "state": "VIC", "postal": "3000"}, + ], + "SG": [{"city": "Singapore", "state": "SG", "postal": "018956"}], + "HK": [{"city": "Hong Kong", "state": "HK", "postal": "000000"}], + "JP": [ + {"city": "Tokyo", "state": "Tokyo", "postal": "100-0001"}, + {"city": "Osaka", "state": "Osaka", "postal": "530-0001"}, + ], + "DE": [ + {"city": "Berlin", "state": "BE", "postal": "10115"}, + {"city": "Munich", "state": "BY", "postal": "80331"}, + ], + "FR": [ + {"city": "Paris", "state": "IDF", "postal": "75001"}, + {"city": "Lyon", "state": "ARA", "postal": "69001"}, + ], + "IT": [ + {"city": "Rome", "state": "RM", "postal": "00118"}, + {"city": "Milan", "state": "MI", "postal": "20121"}, + ], + "ES": [ + {"city": "Madrid", "state": "MD", "postal": "28001"}, + {"city": "Barcelona", "state": "CT", "postal": "08001"}, + ], +} + + +def _normalize_country(country: Optional[str]) -> str: + code = str(country or "").strip().upper() + if not code: + return "US" + if code in COUNTRY_CURRENCY_MAP: + return code + return "US" + + +def _request_text(url: str, proxy: Optional[str]) -> str: + headers = { + "User-Agent": "codex-console2/random-billing", + "Accept": "text/html,application/xhtml+xml", + } + proxies = {"http": proxy, "https": proxy} if proxy else None + if proxy: + # 显式代理:按用户传入值请求。 + resp = cffi_requests.get( + url, + headers=headers, + proxies=proxies, + timeout=25, + impersonate="chrome110", + ) + else: + # 无显式代理:禁用环境变量代理,避免被系统 HTTP(S)_PROXY 污染。 + with cffi_requests.Session() as session: + try: + session.trust_env = False + except Exception: + pass + resp = session.get( + url, + headers=headers, + proxies=None, + timeout=25, + impersonate="chrome110", + ) + resp.raise_for_status() + return str(resp.text or "") + + +def _extract_random_url(page_html: str, page_url: str) -> Optional[str]: + text = str(page_html or "") + patterns = [ + r']+href="([^"]+)"[^>]*>\s*随机地址\s*', + r"]+href='([^']+)'[^>]*>\s*随机地址\s*", + r"location\.href\s*=\s*['\"]([^'\"]+)['\"]", + ] + for pattern in patterns: + match = re.search(pattern, text, flags=re.IGNORECASE) + if not match: + continue + href = str(match.group(1) or "").strip() + if not href: + continue + return urljoin(page_url, href) + return None + + +def _extract_by_patterns(text: str, patterns: List[str]) -> str: + for pattern in patterns: + match = re.search(pattern, text, flags=re.IGNORECASE | re.DOTALL) + if not match: + continue + value = html.unescape(str(match.group(1) or "").strip()) + value = re.sub(r"\s+", " ", value).strip() + if value and len(value) <= 120: + return value + return "" + + +def _extract_text_after_label(page_text: str, label: str) -> str: + pattern = rf"{re.escape(label)}\s*\n\s*([^\n]{{2,80}})" + match = re.search(pattern, page_text, flags=re.IGNORECASE) + if not match: + return "" + value = str(match.group(1) or "").strip() + if value in {"街道", "城市", "州", "邮编", "全名"}: + return "" + return value + + +def _build_us_line1() -> str: + number = random.randint(18, 9999) + base = random.choice(US_STREET_BASES) + suffix = random.choice(STREET_SUFFIXES) + line1 = f"{number} {base} {suffix}" + if random.random() < 0.28: + line1 += f" Apt {random.randint(1, 999)}" + return line1 + + +def _build_us_postal(prefix: str) -> str: + safe_prefix = str(prefix or "").strip() + if not safe_prefix or not safe_prefix[0].isdigit(): + safe_prefix = str(random.randint(0, 9)) + return f"{safe_prefix[0]}{random.randint(0, 9999):04d}" + + +def _build_local_geo_profile(country_code: str, reason: Optional[str] = None, *, fallback_source: bool = False) -> Dict[str, str]: + if country_code == "US": + state_code, state_obj = random.choice(list(US_STATE_CITY_POOL.items())) + city = str(random.choice(list(state_obj.get("cities", []) or ["Seattle"]))) + state_name = str(state_obj.get("name", state_code)) + postal_code = _build_us_postal(str(state_obj.get("zip_prefix", "9"))) + profile = { + "billing_name": f"{random.choice(FIRST_NAMES)} {random.choice(LAST_NAMES)}", + "country_code": "US", + "currency": "USD", + "address_line1": _build_us_line1(), + "address_city": city, + "address_state": state_code, + "address_state_name": state_name, + "postal_code": postal_code, + "source": "local_geo_fallback" if fallback_source else "local_geo", + } + else: + pool = LOCAL_ADDRESS_POOL.get(country_code) or LOCAL_ADDRESS_POOL["US"] + picked = random.choice(pool) + number = random.randint(18, 9999) + line1 = f"{number} {random.choice(STREET_NAMES)}" + profile = { + "billing_name": f"{random.choice(FIRST_NAMES)} {random.choice(LAST_NAMES)}", + "country_code": country_code, + "currency": COUNTRY_CURRENCY_MAP.get(country_code, "USD"), + "address_line1": line1, + "address_city": picked.get("city", ""), + "address_state": picked.get("state", ""), + "postal_code": picked.get("postal", ""), + "source": "local_geo_fallback" if fallback_source else "local_geo", + } + if reason: + profile["fallback_reason"] = str(reason)[:220] + return profile + + +def _parse_profile_from_html(page_html: str, country_code: str) -> Optional[Dict[str, str]]: + raw = str(page_html or "") + if not raw: + return None + compact = raw.replace("\r", "\n") + text_only = re.sub(r"<[^>]+>", "\n", compact) + text_only = html.unescape(text_only) + text_only = re.sub(r"[ \t]+", " ", text_only) + text_only = re.sub(r"\n+", "\n", text_only) + + name = _extract_by_patterns( + compact, + [ + r'"(?:full_?name|name)"\s*:\s*"([^"]+)"', + r'name=["\'](?:full_?name|name)["\'][^>]*value=["\']([^"\']+)["\']', + r'id=["\'](?:full_?name|name)["\'][^>]*value=["\']([^"\']+)["\']', + ], + ) or _extract_text_after_label(text_only, "全名") + + line1 = _extract_by_patterns( + compact, + [ + r'"(?:street|address|address_line1|line1)"\s*:\s*"([^"]+)"', + r'name=["\'](?:street|address|address_line1|line1)["\'][^>]*value=["\']([^"\']+)["\']', + ], + ) or _extract_text_after_label(text_only, "街道") + + city = _extract_by_patterns( + compact, + [ + r'"(?:city|locality)"\s*:\s*"([^"]+)"', + r'name=["\'](?:city|locality)["\'][^>]*value=["\']([^"\']+)["\']', + ], + ) or _extract_text_after_label(text_only, "城市") + + state = _extract_by_patterns( + compact, + [ + r'"(?:state|province|administrativeArea)"\s*:\s*"([^"]+)"', + r'name=["\'](?:state|province|administrativeArea)["\'][^>]*value=["\']([^"\']+)["\']', + ], + ) or _extract_text_after_label(text_only, "州") + + postal = _extract_by_patterns( + compact, + [ + r'"(?:postal|zip|zipcode|postalCode)"\s*:\s*"([^"]+)"', + r'name=["\'](?:postal|zip|zipcode|postalCode)["\'][^>]*value=["\']([^"\']+)["\']', + ], + ) or _extract_text_after_label(text_only, "邮编") + + profile = { + "billing_name": name, + "country_code": country_code, + "currency": COUNTRY_CURRENCY_MAP.get(country_code, "USD"), + "address_line1": line1, + "address_city": city, + "address_state": state, + "postal_code": postal, + "source": "meiguodizhi", + } + required = ("billing_name", "address_line1", "address_city", "address_state", "postal_code") + if all(str(profile.get(key) or "").strip() for key in required): + return profile + return None + + +def _build_local_profile(country_code: str, reason: Optional[str] = None) -> Dict[str, str]: + return _build_local_geo_profile(country_code, reason=reason, fallback_source=True) + + +def _iter_country_pages(country_code: str) -> List[str]: + pages: List[str] = [] + for path in COUNTRY_ROUTE_CANDIDATES.get(country_code, []): + if not path.startswith("/"): + path = "/" + path + pages.append(urljoin(BASE_URL, path)) + if not pages: + pages.append(urljoin(BASE_URL, "/usa-address")) + # 兜底再补一层首页,部分站点路径偶发调整时可避免直接全量失败。 + pages.append(urljoin(BASE_URL, "/?hl=en")) + # 去重并保持顺序 + ordered_unique: List[str] = [] + seen = set() + for item in pages: + if item in seen: + continue + seen.add(item) + ordered_unique.append(item) + return ordered_unique + + +def generate_random_billing_profile(country: Optional[str], proxy: Optional[str] = None) -> Dict[str, str]: + country_code = _normalize_country(country) + # 默认本地生成,确保可用性与速度;需要外部源时可配置 RANDOM_BILLING_ENABLE_EXTERNAL=true。 + if not ENABLE_EXTERNAL_SOURCE: + return _build_local_geo_profile(country_code) + + pages = _iter_country_pages(country_code) + attempts: List[str] = [] + proxy_candidates: List[Optional[str]] = [] + for value in (proxy, None): + if value not in proxy_candidates: + proxy_candidates.append(value) + + for page_url in pages: + for proxy_item in proxy_candidates: + try: + page_html = _request_text(page_url, proxy_item) + random_url = _extract_random_url(page_html, page_url) + html_candidates = [(page_html, page_url)] + if random_url: + try: + random_html = _request_text(random_url, proxy_item) + html_candidates.insert(0, (random_html, random_url)) + except Exception as random_exc: + attempts.append(f"{random_url}: {random_exc}") + + for html_content, used_url in html_candidates: + parsed = _parse_profile_from_html(html_content, country_code) + if parsed: + parsed["source_url"] = used_url + if proxy_item: + parsed["proxy_used"] = "on" + return parsed + attempts.append(f"{page_url}: parse_empty") + except Exception as exc: + attempts.append(f"{page_url}: {exc}") + continue + + reason = "; ".join(attempts[-3:]) if attempts else "unknown_error" + logger.warning("random billing fallback triggered: country=%s reason=%s", country_code, reason) + return _build_local_geo_profile(country_code, reason=reason, fallback_source=True) diff --git a/src/core/openai/token_refresh.py b/src/core/openai/token_refresh.py index 394c56e..a3f070f 100644 --- a/src/core/openai/token_refresh.py +++ b/src/core/openai/token_refresh.py @@ -6,6 +6,7 @@ Token 刷新模块 import logging import json import time +from http.cookies import SimpleCookie from typing import Optional, Dict, Any, Tuple from dataclasses import dataclass from datetime import datetime, timedelta @@ -13,6 +14,7 @@ from datetime import datetime, timedelta from curl_cffi import requests as cffi_requests from ...config.settings import get_settings +from ...config.constants import AccountStatus from ...database.session import get_db from ...database import crud from ...database.models import Account @@ -57,6 +59,25 @@ class TokenRefreshManager: session = cffi_requests.Session(impersonate="chrome120", proxy=self.proxy_url) return session + @staticmethod + def _extract_session_token_from_cookies(cookies: Optional[str]) -> Optional[str]: + """从完整 Cookie 字符串中提取 __Secure-next-auth.session-token。""" + text = str(cookies or "").strip() + if not text: + return None + try: + jar = SimpleCookie() + jar.load(text) + token = jar.get("__Secure-next-auth.session-token") + value = token.value if token else None + return value or None + except Exception: + return None + + def _create_direct_session(self) -> cffi_requests.Session: + """创建直连会话(不走代理)。""" + return cffi_requests.Session(impersonate="chrome120") + def refresh_by_session_token(self, session_token: str) -> TokenRefreshResult: """ 使用 Session Token 刷新 @@ -70,25 +91,35 @@ class TokenRefreshManager: result = TokenRefreshResult(success=False) try: + def _request_once(session: cffi_requests.Session): + session.cookies.set( + "__Secure-next-auth.session-token", + session_token, + domain=".chatgpt.com", + path="/" + ) + return session.get( + self.SESSION_URL, + headers={ + "accept": "application/json", + "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + }, + timeout=30 + ) + session = self._create_session() + response = _request_once(session) - # 设置会话 Cookie - session.cookies.set( - "__Secure-next-auth.session-token", - session_token, - domain=".chatgpt.com", - path="/" - ) - - # 请求会话端点 - response = session.get( - self.SESSION_URL, - headers={ - "accept": "application/json", - "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" - }, - timeout=30 - ) + # 代理通道触发地区/风控时,自动回退直连重试一次 + if ( + response.status_code in (401, 403) + and self.proxy_url + ): + body = (response.text or "")[:500].lower() + if "unsupported_country_region_territory" in body or "request_forbidden" in body: + logger.warning("Session token 刷新触发地区限制,尝试直连重试") + direct_session = self._create_direct_session() + response = _request_once(direct_session) if response.status_code != 200: result.error_message = f"Session token 刷新失败: HTTP {response.status_code}" @@ -143,8 +174,6 @@ class TokenRefreshManager: result = TokenRefreshResult(success=False) try: - session = self._create_session() - # 使用配置的 client_id 或默认值 client_id = client_id or self.settings.openai_client_id @@ -156,15 +185,27 @@ class TokenRefreshManager: "redirect_uri": self.settings.openai_redirect_uri } - response = session.post( - self.TOKEN_URL, - headers={ - "content-type": "application/x-www-form-urlencoded", - "accept": "application/json" - }, - data=token_data, - timeout=30 - ) + def _request_once(session: cffi_requests.Session): + return session.post( + self.TOKEN_URL, + headers={ + "content-type": "application/x-www-form-urlencoded", + "accept": "application/json" + }, + data=token_data, + timeout=30 + ) + + session = self._create_session() + response = _request_once(session) + + # 典型场景:代理出口地区受限导致 403,改为直连再试一次 + if response.status_code == 403 and self.proxy_url: + body = (response.text or "")[:500].lower() + if "unsupported_country_region_territory" in body: + logger.warning("OAuth token 刷新触发地区限制,尝试直连重试") + direct_session = self._create_direct_session() + response = _request_once(direct_session) if response.status_code != 200: result.error_message = f"OAuth token 刷新失败: HTTP {response.status_code}" @@ -221,6 +262,15 @@ class TokenRefreshManager: return result logger.warning(f"Session Token 刷新失败,尝试 OAuth 刷新") + # 若 session_token 字段为空,但 cookies 里有 next-auth 会话,仍可尝试会话刷新 + cookie_session_token = self._extract_session_token_from_cookies(getattr(account, "cookies", None)) + if cookie_session_token: + logger.info(f"尝试使用 Cookies 中的 Session Token 刷新账号 {account.email}") + result = self.refresh_by_session_token(cookie_session_token) + if result.success: + return result + logger.warning("Cookies Session Token 刷新失败,尝试 OAuth 刷新") + # 尝试 OAuth Refresh Token if account.refresh_token: logger.info(f"尝试使用 OAuth Refresh Token 刷新账号 {account.email}") @@ -326,7 +376,36 @@ def validate_account_token(account_id: int, proxy_url: Optional[str] = None) -> return False, "账号不存在" if not account.access_token: + # 无 Token 直接归类为 failed,便于账号管理按“失败”筛选定位问题账号。 + if account.status != AccountStatus.FAILED.value: + crud.update_account(db, account_id, status=AccountStatus.FAILED.value) return False, "账号没有 access_token" manager = TokenRefreshManager(proxy_url=proxy_url) - return manager.validate_token(account.access_token) + is_valid, error = manager.validate_token(account.access_token) + + # 验证后回写账号状态,确保前端筛选(active/expired/banned/failed)与验证结果一致。 + error_text = str(error or "").lower() + if is_valid: + next_status = AccountStatus.ACTIVE.value + elif ( + "过期" in error_text + or "expired" in error_text + or "401" in error_text + or "invalid" in error_text + ): + next_status = AccountStatus.EXPIRED.value + elif ( + "封禁" in error_text + or "banned" in error_text + or "forbidden" in error_text + or "403" in error_text + ): + next_status = AccountStatus.BANNED.value + else: + next_status = AccountStatus.FAILED.value + + if account.status != next_status: + crud.update_account(db, account_id, status=next_status) + + return is_valid, error diff --git a/src/core/register.py b/src/core/register.py index 8ca0e75..f4a098c 100644 --- a/src/core/register.py +++ b/src/core/register.py @@ -9,7 +9,8 @@ import time import logging import secrets import string -from typing import Optional, Dict, Any, Tuple, Callable +import uuid +from typing import Optional, Dict, Any, Tuple, Callable, List from dataclasses import dataclass from datetime import datetime @@ -48,6 +49,7 @@ class RegistrationResult: refresh_token: str = "" id_token: str = "" session_token: str = "" # 会话令牌 + device_id: str = "" # oai-did error_message: str = "" logs: list = None metadata: dict = None @@ -65,6 +67,7 @@ class RegistrationResult: "refresh_token": self.refresh_token[:20] + "..." if self.refresh_token else "", "id_token": self.id_token[:20] + "..." if self.id_token else "", "session_token": self.session_token[:20] + "..." if self.session_token else "", + "device_id": self.device_id, "error_message": self.error_message, "logs": self.logs or [], "metadata": self.metadata or {}, @@ -122,18 +125,33 @@ class RegistrationEngine: scope=settings.openai_scope, proxy_url=proxy_url # 传递代理配置 ) + entry_flow = str(getattr(settings, "registration_entry_flow", "native") or "native").strip().lower() + # 配置层仅保留 native/abcard;Outlook 邮箱在执行时自动切换 outlook 链路。 + self.registration_entry_flow: str = entry_flow if entry_flow in {"native", "abcard"} else "native" # 状态变量 self.email: Optional[str] = None + self.inbox_email: Optional[str] = None # 邮箱服务原始地址(用于收件) self.password: Optional[str] = None # 注册密码 self.email_info: Optional[Dict[str, Any]] = None self.oauth_start: Optional[OAuthStart] = None self.session: Optional[cffi_requests.Session] = None self.session_token: Optional[str] = None # 会话令牌 + self.device_id: Optional[str] = None # oai-did self.logs: list = [] self._otp_sent_at: Optional[float] = None # OTP 发送时间戳 self._is_existing_account: bool = False # 是否为已注册账号(用于自动登录) self._token_acquisition_requires_login: bool = False # 新注册账号需要二次登录拿 token + self._create_account_continue_url: Optional[str] = None # create_account 返回的 continue_url(ABCard链路兜底) + self._create_account_workspace_id: Optional[str] = None + self._create_account_account_id: Optional[str] = None + self._create_account_refresh_token: Optional[str] = None + self._last_validate_otp_continue_url: Optional[str] = None + self._last_validate_otp_workspace_id: Optional[str] = None + self._last_register_password_error: Optional[str] = None + self._last_otp_validation_code: Optional[str] = None + self._last_otp_validation_status_code: Optional[int] = None + self._last_otp_validation_outcome: str = "" # success/http_non_200/network_timeout/network_error def _log(self, message: str, level: str = "info"): """记录日志""" @@ -163,6 +181,168 @@ class RegistrationEngine: else: logger.info(message) + def _dump_session_cookies(self) -> str: + """导出当前会话 cookies(用于后续支付/绑卡自动化)。""" + if not self.session: + return "" + try: + cookie_map: dict[str, str] = {} + order: list[str] = [] + + def _push(name: Optional[str], value: Optional[str]): + key = str(name or "").strip() + val = str(value or "").strip() + if not key: + return + if key not in cookie_map: + cookie_map[key] = val + order.append(key) + return + # 同名 cookie 可能来自不同域/路径:优先保留非空且更长值,避免空值覆盖有效分片。 + prev = str(cookie_map.get(key) or "").strip() + if (not prev and val) or (val and len(val) > len(prev)): + cookie_map[key] = val + + # 1) 常规 requests/curl_cffi 字典接口 + try: + for key, value in self.session.cookies.items(): + _push(key, value) + except Exception: + pass + + # 2) CookieJar 接口(可拿到分片 cookie) + try: + jar = getattr(self.session.cookies, "jar", None) + if jar is not None: + for cookie in jar: + _push(getattr(cookie, "name", ""), getattr(cookie, "value", "")) + except Exception: + pass + + # 3) 关键 cookie 兜底读取 + for key in ( + "oai-did", + "oai-client-auth-session", + "__Secure-next-auth.session-token", + "_Secure-next-auth.session-token", + ): + try: + _push(key, self.session.cookies.get(key)) + except Exception: + continue + + pairs = [(k, cookie_map.get(k, "")) for k in order if k] + return "; ".join(f"{k}={v}" for k, v in pairs if k) + except Exception: + return "" + + @staticmethod + def _extract_session_token_from_cookie_jar(cookie_jar) -> str: + """ + 从 CookieJar 中提取 next-auth session token(兼容分片 + 重复域名)。 + """ + if not cookie_jar: + return "" + + entries: list[tuple[str, str]] = [] + try: + for key, value in cookie_jar.items(): + entries.append((str(key or "").strip(), str(value or "").strip())) + except Exception: + pass + + try: + jar = getattr(cookie_jar, "jar", None) + if jar is not None: + for cookie in jar: + entries.append( + ( + str(getattr(cookie, "name", "") or "").strip(), + str(getattr(cookie, "value", "") or "").strip(), + ) + ) + except Exception: + pass + + direct_candidates = [ + val + for name, val in entries + if name in ("__Secure-next-auth.session-token", "_Secure-next-auth.session-token") and val + ] + if direct_candidates: + return max(direct_candidates, key=len) + + chunk_map: dict[int, str] = {} + for name, value in entries: + if not ( + name.startswith("__Secure-next-auth.session-token.") + or name.startswith("_Secure-next-auth.session-token.") + ): + continue + if not value: + continue + try: + idx = int(name.rsplit(".", 1)[-1]) + except Exception: + continue + prev = chunk_map.get(idx, "") + if not prev or len(value) > len(prev): + chunk_map[idx] = value + + if chunk_map: + return "".join(chunk_map[i] for i in sorted(chunk_map.keys())) + return "" + + @staticmethod + def _flatten_set_cookie_headers(response) -> str: + """ + 合并多条 Set-Cookie(包含分片 cookie)。 + """ + try: + headers = getattr(response, "headers", None) + if headers is None: + return "" + if hasattr(headers, "get_list"): + values = headers.get_list("set-cookie") + if values: + return " | ".join(str(v or "") for v in values if v is not None) + if hasattr(headers, "get_all"): + values = headers.get_all("set-cookie") + if values: + return " | ".join(str(v or "") for v in values if v is not None) + return str(headers.get("set-cookie") or "") + except Exception: + return "" + + @staticmethod + def _extract_request_cookie_header(response) -> str: + """ + 从响应对象关联的请求头中提取 Cookie。 + 对齐 F12 Network -> Request Headers -> Cookie 的观测路径。 + """ + try: + request_obj = getattr(response, "request", None) + if request_obj is None: + return "" + headers = getattr(request_obj, "headers", None) + if headers is None: + return "" + + if hasattr(headers, "get"): + value = headers.get("cookie") or headers.get("Cookie") + if value: + return str(value) + + try: + for key, value in dict(headers).items(): + if str(key or "").strip().lower() == "cookie" and value: + return str(value) + except Exception: + pass + except Exception: + pass + return "" + def _generate_password(self, length: int = DEFAULT_PASSWORD_LENGTH) -> str: """生成随机密码""" return ''.join(secrets.choice(PASSWORD_CHARSET) for _ in range(length)) @@ -185,7 +365,17 @@ class RegistrationEngine: self._log("创建邮箱失败: 返回信息不完整", "error") return False - self.email = self.email_info["email"] + raw_email = str(self.email_info["email"] or "").strip() + normalized_email = raw_email.lower() + + # 保留原始收件地址,注册链路统一使用规范化邮箱,规避 "Failed to register username"。 + self.inbox_email = raw_email + self.email = normalized_email + self.email_info["email"] = normalized_email + + if raw_email and raw_email != normalized_email: + self._log(f"邮箱规范化: {raw_email} -> {normalized_email}") + self._log(f"邮箱已就位,地址新鲜出炉: {self.email}") return True @@ -230,6 +420,20 @@ class RegistrationEngine: ) did = self.session.cookies.get("oai-did") + if not did: + # 对齐 ABCard:部分环境 cookie 不落盘,尝试从 HTML 文本提取 + try: + m = re.search(r'oai-did["\s:=]+([a-f0-9-]{36})', str(response.text or ""), re.IGNORECASE) + if m: + did = str(m.group(1) or "").strip() + if did: + try: + self.session.cookies.set("oai-did", did, domain=".chatgpt.com", path="/") + except Exception: + pass + except Exception: + pass + if did: self._log(f"Device ID: {did}") return did @@ -249,7 +453,15 @@ class RegistrationEngine: self.http_client.close() self.session = self.http_client.session - return None + # 对齐 ABCard:无法从响应拿到 did 时,优先复用上次成功 did,再使用 UUID 兜底。 + fallback_did = str(self.device_id or "").strip() or str(uuid.uuid4()) + try: + if self.session: + self.session.cookies.set("oai-did", fallback_did, domain=".chatgpt.com", path="/") + except Exception: + pass + self._log(f"未获取到 oai-did,使用兜底 Device ID: {fallback_did}", "warning") + return fallback_did def _check_sentinel(self, did: str) -> Optional[str]: """检查 Sentinel 拦截""" @@ -281,76 +493,122 @@ class RegistrationEngine: Returns: SignupFormResult: 提交结果,包含账号状态判断 """ - try: - request_body = json.dumps({ - "username": { - "value": self.email, - "kind": "email", - }, - "screen_hint": screen_hint, - }) - - headers = { - "referer": referer, - "accept": "application/json", - "content-type": "application/json", - } - - if sen_token: - sentinel = json.dumps({ - "p": "", - "t": "", - "c": sen_token, - "id": did, - "flow": "authorize_continue", - }) - headers["openai-sentinel-token"] = sentinel - - response = self.session.post( - OPENAI_API_ENDPOINTS["signup"], - headers=headers, - data=request_body, - ) - - self._log(f"{log_label}状态: {response.status_code}") - - if response.status_code != 200: - return SignupFormResult( - success=False, - error_message=f"HTTP {response.status_code}: {response.text[:200]}" - ) - - # 解析响应判断账号状态 + max_attempts = 3 + current_did = str(did or "").strip() + current_sen_token = str(sen_token or "").strip() if sen_token else None + for attempt in range(1, max_attempts + 1): try: - response_data = response.json() - page_type = response_data.get("page", {}).get("type", "") - self._log(f"响应页面类型: {page_type}") + request_body = json.dumps({ + "username": { + "value": self.email, + "kind": "email", + }, + "screen_hint": screen_hint, + }) - is_existing = page_type == OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"] + headers = { + "referer": referer, + "accept": "application/json", + "content-type": "application/json", + } - if is_existing: - self._otp_sent_at = time.time() - if record_existing_account: - self._log(f"检测到已注册账号,将自动切换到登录流程") - self._is_existing_account = True - else: - self._log("登录流程已触发,等待系统自动发送的验证码") + if current_sen_token: + sentinel = json.dumps({ + "p": "", + "t": "", + "c": current_sen_token, + "id": current_did, + "flow": "authorize_continue", + }) + headers["openai-sentinel-token"] = sentinel - return SignupFormResult( - success=True, - page_type=page_type, - is_existing_account=is_existing, - response_data=response_data + response = self.session.post( + OPENAI_API_ENDPOINTS["signup"], + headers=headers, + data=request_body, ) - except Exception as parse_error: - self._log(f"解析响应失败: {parse_error}", "warning") - # 无法解析,默认成功 - return SignupFormResult(success=True) + self._log(f"{log_label}状态: {response.status_code}") - except Exception as e: - self._log(f"{log_label}失败: {e}", "error") - return SignupFormResult(success=False, error_message=str(e)) + if response.status_code == 429 and attempt < max_attempts: + wait_seconds = min(18, 5 * attempt) + self._log( + f"{log_label}命中限流 429(第 {attempt}/{max_attempts} 次),{wait_seconds}s 后自动重试...", + "warning", + ) + time.sleep(wait_seconds) + continue + + # 部分网络/会话边界情况下会返回 409,做自愈重试而非直接失败。 + if response.status_code == 409 and attempt < max_attempts: + wait_seconds = min(10, 2 * attempt) + self._log( + f"{log_label}命中 409(第 {attempt}/{max_attempts} 次)," + f"会话上下文可能冲突,{wait_seconds}s 后自动重试...", + "warning", + ) + # 尝试刷新 sentinel,避免 token 过期导致冲突。 + try: + refreshed = self._check_sentinel(current_did) + if refreshed: + current_sen_token = refreshed + except Exception: + pass + # 预热一次授权页,帮助服务端重建登录上下文。 + try: + if self.oauth_start and getattr(self.oauth_start, "auth_url", None): + self.session.get(str(self.oauth_start.auth_url), timeout=12) + except Exception: + pass + time.sleep(wait_seconds) + continue + + if response.status_code != 200: + return SignupFormResult( + success=False, + error_message=f"HTTP {response.status_code}: {response.text[:200]}" + ) + + # 解析响应判断账号状态 + try: + response_data = response.json() + page_type = response_data.get("page", {}).get("type", "") + self._log(f"响应页面类型: {page_type}") + + is_existing = page_type == OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"] + + if is_existing: + self._otp_sent_at = time.time() + if record_existing_account: + self._log(f"检测到已注册账号,将自动切换到登录流程") + self._is_existing_account = True + else: + self._log("登录流程已触发,等待系统自动发送的验证码") + + return SignupFormResult( + success=True, + page_type=page_type, + is_existing_account=is_existing, + response_data=response_data + ) + + except Exception as parse_error: + self._log(f"解析响应失败: {parse_error}", "warning") + # 无法解析,默认成功 + return SignupFormResult(success=True) + + except Exception as e: + if attempt < max_attempts: + self._log( + f"{log_label}异常(第 {attempt}/{max_attempts} 次): {e},准备重试...", + "warning", + ) + time.sleep(2 * attempt) + continue + self._log(f"{log_label}失败: {e}", "error") + return SignupFormResult(success=False, error_message=str(e)) + + return SignupFormResult(success=False, error_message=f"{log_label}失败: 超过最大重试次数") def _submit_signup_form( self, @@ -382,44 +640,95 @@ class RegistrationEngine: def _submit_login_password(self) -> SignupFormResult: """提交登录密码,进入邮箱验证码页面。""" - try: - response = self.session.post( - OPENAI_API_ENDPOINTS["password_verify"], - headers={ - "referer": "https://auth.openai.com/log-in/password", - "accept": "application/json", - "content-type": "application/json", - }, - data=json.dumps({"password": self.password}), + max_attempts = 3 + password_text = str(self.password or "").strip() + if not password_text and self.email: + try: + with get_db() as db: + account = crud.get_account_by_email(db, self.email) + db_password = str(getattr(account, "password", "") or "").strip() if account else "" + if db_password: + self.password = db_password + password_text = db_password + self._log("登录阶段未发现内存密码,已从账号库回填密码") + except Exception as e: + self._log(f"登录阶段尝试回填密码失败: {e}", "warning") + + if not password_text: + return SignupFormResult( + success=False, + error_message="登录密码为空:该邮箱可能是已存在账号但当前任务未持有密码", ) - self._log(f"提交登录密码状态: {response.status_code}") - - if response.status_code != 200: - return SignupFormResult( - success=False, - error_message=f"HTTP {response.status_code}: {response.text[:200]}" + for attempt in range(1, max_attempts + 1): + try: + response = self.session.post( + OPENAI_API_ENDPOINTS["password_verify"], + headers={ + "referer": "https://auth.openai.com/log-in/password", + "accept": "application/json", + "content-type": "application/json", + }, + data=json.dumps({"password": self.password}), ) - response_data = response.json() - page_type = response_data.get("page", {}).get("type", "") - self._log(f"登录密码响应页面类型: {page_type}") + self._log(f"提交登录密码状态: {response.status_code}") - is_existing = page_type == OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"] - if is_existing: - self._otp_sent_at = time.time() - self._log("登录密码校验通过,等待系统自动发送的验证码") + if response.status_code == 429 and attempt < max_attempts: + wait_seconds = min(18, 5 * attempt) + self._log( + f"提交登录密码命中限流 429(第 {attempt}/{max_attempts} 次),{wait_seconds}s 后自动重试...", + "warning", + ) + time.sleep(wait_seconds) + continue - return SignupFormResult( - success=True, - page_type=page_type, - is_existing_account=is_existing, - response_data=response_data, - ) + if response.status_code == 401 and attempt < max_attempts: + body = str(response.text or "") + if "invalid_username_or_password" in body: + wait_seconds = min(12, 3 * attempt) + self._log( + f"提交登录密码命中 401(第 {attempt}/{max_attempts} 次)," + f"疑似密码尚未生效或历史账号密码不一致,{wait_seconds}s 后自动重试...", + "warning", + ) + time.sleep(wait_seconds) + continue - except Exception as e: - self._log(f"提交登录密码失败: {e}", "error") - return SignupFormResult(success=False, error_message=str(e)) + if response.status_code != 200: + return SignupFormResult( + success=False, + error_message=f"HTTP {response.status_code}: {response.text[:200]}" + ) + + response_data = response.json() + page_type = response_data.get("page", {}).get("type", "") + self._log(f"登录密码响应页面类型: {page_type}") + + is_existing = page_type == OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"] + if is_existing: + self._otp_sent_at = time.time() + self._log("登录密码校验通过,等待系统自动发送的验证码") + + return SignupFormResult( + success=True, + page_type=page_type, + is_existing_account=is_existing, + response_data=response_data, + ) + + except Exception as e: + if attempt < max_attempts: + self._log( + f"提交登录密码异常(第 {attempt}/{max_attempts} 次): {e},准备重试...", + "warning", + ) + time.sleep(2 * attempt) + continue + self._log(f"提交登录密码失败: {e}", "error") + return SignupFormResult(success=False, error_message=str(e)) + + return SignupFormResult(success=False, error_message="提交登录密码失败: 超过最大重试次数") def _reset_auth_flow(self) -> None: """重置会话,准备重新发起 OAuth 流程。""" @@ -440,10 +749,12 @@ class RegistrationEngine: return None, None self._log(f"{label}: 领取 Device ID 通行证...") - did = self._get_device_id() + did = str(self._get_device_id() or "").strip() if not did: return None, None + self.device_id = did + self._log(f"{label}: 解一道 Sentinel POW 小题,答对才给进...") sen_token = self._check_sentinel(did) if not sen_token: @@ -452,35 +763,854 @@ class RegistrationEngine: self._log(f"{label}: Sentinel 点头放行,继续前进") return did, sen_token - def _complete_token_exchange(self, result: RegistrationResult) -> bool: - """在登录态已建立后,继续完成 workspace 和 OAuth token 获取。""" - self._log("等待登录验证码到场,最后这位嘉宾还在路上...") - code = self._get_verification_code() - if not code: - result.error_message = "获取验证码失败" + @staticmethod + def _extract_session_token_from_cookie_text(cookie_text: str) -> str: + """从 Cookie 文本中提取 next-auth session token(兼容分片)。""" + text = str(cookie_text or "") + if not text: + return "" + + direct = re.search(r"(?:^|[;,]\s*)(?:__|_)Secure-next-auth\.session-token=([^;,]*)", text) + if direct: + direct_val = str(direct.group(1) or "").strip().strip('"').strip("'") + if direct_val: + return direct_val + + parts = re.findall(r"(?:__|_)Secure-next-auth\.session-token\.(\d+)=([^;,]*)", text) + if not parts: + return "" + + chunk_map = {} + for idx, value in parts: + try: + clean_value = str(value or "").strip().strip('"').strip("'") + if clean_value: + chunk_map[int(idx)] = clean_value + except Exception: + continue + if not chunk_map: + return "" + return "".join(chunk_map[i] for i in sorted(chunk_map.keys())) + + def _warmup_chatgpt_session(self) -> None: + """ + 仅预热 chatgpt 首页,避免提前消费一次性 continue_url。 + """ + try: + self.session.get( + "https://chatgpt.com/", + headers={ + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "referer": "https://auth.openai.com/", + "user-agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + ), + }, + timeout=20, + ) + except Exception as e: + self._log(f"chatgpt 首页预热异常: {e}", "warning") + + def _capture_auth_session_tokens(self, result: RegistrationResult, access_hint: Optional[str] = None) -> bool: + """ + 直接通过 /api/auth/session 捕获 session_token + access_token。 + 这是 ABCard Phase 1 的关键路径。 + """ + access_token = str(access_hint or "").strip() + set_cookie_text = "" + request_cookie_text = "" + try: + headers = { + "accept": "application/json", + "referer": "https://chatgpt.com/", + "origin": "https://chatgpt.com", + "user-agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + ), + "cache-control": "no-cache", + "pragma": "no-cache", + } + if access_token: + headers["authorization"] = f"Bearer {access_token}" + response = self.session.get( + "https://chatgpt.com/api/auth/session", + headers=headers, + timeout=20, + ) + set_cookie_text = self._flatten_set_cookie_headers(response) + request_cookie_text = self._extract_request_cookie_header(response) + if response.status_code == 200: + try: + data = response.json() or {} + access_from_json = str(data.get("accessToken") or "").strip() + if access_from_json: + access_token = access_from_json + except Exception: + pass + else: + self._log(f"/api/auth/session 返回异常状态: {response.status_code}", "warning") + except Exception as e: + self._log(f"获取 auth/session 失败: {e}", "warning") + + # 1) 直接从 cookie jar 拿 + session_token = self._extract_session_token_from_cookie_jar(self.session.cookies) + + # 2) 从完整 cookies 文本兜底(含分片) + if not session_token: + session_token = self._extract_session_token_from_cookie_text(self._dump_session_cookies()) + + # 3) 从 set-cookie 兜底(含分片) + if not session_token and set_cookie_text: + session_token = self._extract_session_token_from_cookie_text(set_cookie_text) + + # 4) 从请求 Cookie 头兜底(对齐 F12 Network 观测) + if not session_token and request_cookie_text: + session_token = self._extract_session_token_from_cookie_text(request_cookie_text) + + # 兜底:已有 access_token 但无 session_token 时,带 Bearer 再请求一次 auth/session + if (not session_token) and access_token: + try: + retry_response = self.session.get( + "https://chatgpt.com/api/auth/session", + headers={ + "accept": "application/json", + "referer": "https://chatgpt.com/", + "origin": "https://chatgpt.com", + "user-agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + ), + "authorization": f"Bearer {access_token}", + "cache-control": "no-cache", + "pragma": "no-cache", + }, + timeout=20, + ) + retry_set_cookie = self._flatten_set_cookie_headers(retry_response) + retry_request_cookie = self._extract_request_cookie_header(retry_response) + if not session_token: + session_token = self._extract_session_token_from_cookie_jar(self.session.cookies) + if not session_token: + session_token = self._extract_session_token_from_cookie_text(self._dump_session_cookies()) + if not session_token and retry_set_cookie: + session_token = self._extract_session_token_from_cookie_text(retry_set_cookie) + if not session_token and retry_request_cookie: + session_token = self._extract_session_token_from_cookie_text(retry_request_cookie) + except Exception as e: + self._log(f"Bearer 兜底换 session_token 失败: {e}", "warning") + + if not session_token: + cookies_text = self._dump_session_cookies() + raw_direct_match = re.search( + r"(?:^|[;,]\s*)(?:__|_)Secure-next-auth\.session-token=([^;,]*)", + cookies_text, + ) + raw_direct_len = len(str(raw_direct_match.group(1) or "").strip()) if raw_direct_match else 0 + chunk_count = len(re.findall(r"(?:__|_)Secure-next-auth\.session-token\.(\d+)=", cookies_text)) + req_cookie_len = len(str(request_cookie_text or "").strip()) + self._log( + f"auth/session 仍未命中 session_token(raw_direct_len={raw_direct_len}, chunks={chunk_count}, req_cookie_len={req_cookie_len})", + "warning", + ) + + # 设备 ID 同步 + did = "" + try: + did = str(self.session.cookies.get("oai-did") or "").strip() + except Exception: + did = "" + if did: + self.device_id = did + result.device_id = did + + if session_token: + self.session_token = session_token + result.session_token = session_token + if access_token: + result.access_token = access_token + + self._log( + "Auth Session 捕获结果: session_token=" + + ("有" if bool(result.session_token) else "无") + + ", access_token=" + + ("有" if bool(result.access_token) else "无") + ) + return bool(result.session_token and result.access_token) + + def _bootstrap_chatgpt_signin_for_session(self, result: RegistrationResult) -> bool: + """ + 对齐 ABCard 的补会话路径: + csrf -> signin/openai -> 跟随跳转 -> auth/session,目标是拿到 session_token。 + """ + self._log("Session Token 还没就位,尝试 ABCard 同款会话桥接...") + self._warmup_chatgpt_session() + csrf_token = "" + auth_url = "" + try: + csrf_resp = self.session.get( + "https://chatgpt.com/api/auth/csrf", + headers={ + "accept": "application/json", + "referer": "https://chatgpt.com/auth/login", + "origin": "https://chatgpt.com", + "user-agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + ), + }, + timeout=20, + ) + if csrf_resp.status_code == 200: + csrf_token = str((csrf_resp.json() or {}).get("csrfToken") or "").strip() + else: + self._log(f"csrf 获取失败: HTTP {csrf_resp.status_code}", "warning") + except Exception as e: + self._log(f"csrf 获取异常: {e}", "warning") + + if not csrf_token: + self._log("csrf token 为空,跳过会话桥接", "warning") return False + try: + signin_resp = self.session.post( + "https://chatgpt.com/api/auth/signin/openai", + headers={ + "accept": "application/json", + "content-type": "application/x-www-form-urlencoded", + "origin": "https://chatgpt.com", + "referer": "https://chatgpt.com/auth/login", + "user-agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + ), + }, + data={ + "csrfToken": csrf_token, + "callbackUrl": "https://chatgpt.com/", + "json": "true", + }, + timeout=20, + ) + if signin_resp.status_code == 200: + auth_url = str((signin_resp.json() or {}).get("url") or "").strip() + else: + self._log(f"signin/openai 失败: HTTP {signin_resp.status_code}", "warning") + except Exception as e: + self._log(f"signin/openai 异常: {e}", "warning") + + if not auth_url: + self._log("signin/openai 未返回 auth_url,跳过会话桥接", "warning") + return False + + callback_url = "" + final_url = auth_url + try: + callback_url, final_url = self._follow_chatgpt_auth_redirects(auth_url) + except Exception as e: + self._log(f"会话桥接重定向跟踪异常: {e}", "warning") + callback_url = "" + final_url = auth_url + + # 若已拿到 callback,补打一跳确保 next-auth callback 被完整执行。 + if callback_url and "error=" not in callback_url: + try: + self.session.get( + callback_url, + headers={ + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "referer": "https://chatgpt.com/auth/login", + "user-agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + ), + }, + allow_redirects=True, + timeout=25, + ) + except Exception as e: + self._log(f"会话桥接 callback 补跳异常: {e}", "warning") + elif callback_url and "error=" in callback_url: + self._log(f"会话桥接回调返回错误参数: {callback_url[:140]}...", "warning") + else: + self._log(f"会话桥接未命中 callback,final_url={final_url[:120]}...", "warning") + # 命中 auth.openai 登录页时,尝试自动登录补会话(对齐 ABCard 的登录态建立思路)。 + if "auth.openai.com/log-in" in str(final_url or "").lower(): + self._log("会话桥接进入登录页,尝试自动登录后继续抓取 session_token...") + if self._bridge_login_for_session_token(result): + return True + + self._warmup_chatgpt_session() + cookie_text = self._dump_session_cookies() + direct_token = self._extract_session_token_from_cookie_text(cookie_text) + has_direct = bool(direct_token) + chunk_count = len(re.findall(r"(?:__|_)Secure-next-auth\.session-token\.(\d+)=", cookie_text)) + if direct_token and not result.session_token: + self.session_token = direct_token + result.session_token = direct_token + self._log(f"会话桥接已缓存 session_token(len={len(direct_token)})") + self._log( + f"会话桥接后 cookie 概览: direct={'有' if has_direct else '无'}, chunks={chunk_count}" + ) + return self._capture_auth_session_tokens(result, access_hint=result.access_token) + + def _bridge_login_for_session_token(self, result: RegistrationResult) -> bool: + """ + 当 chatgpt signin/openai 跳回 auth.openai 登录页时,自动补一次登录流程: + login -> password -> email otp -> workspace -> auth/session。 + """ + try: + if not self.email or not self.password: + self._log("会话桥接自动登录缺少邮箱或密码,无法继续", "warning") + return False + + did = "" + try: + did = str(self.session.cookies.get("oai-did") or "").strip() + except Exception: + did = "" + if not did: + did = str(uuid.uuid4()) + try: + self.session.cookies.set("oai-did", did, domain=".chatgpt.com", path="/") + except Exception: + pass + self.device_id = did + result.device_id = result.device_id or did + + sen_token = self._check_sentinel(did) + login_start_result = self._submit_login_start(did, sen_token) + if not login_start_result.success: + self._log( + f"会话桥接自动登录入口失败: {login_start_result.error_message}", + "warning", + ) + return False + page_type = str(login_start_result.page_type or "").strip() + if page_type == OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]: + self._log("会话桥接自动登录已直达邮箱验证码页,跳过密码提交") + elif page_type == OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]: + password_result = self._submit_login_password() + if not password_result.success: + self._log( + f"会话桥接自动登录提交密码失败: {password_result.error_message}", + "warning", + ) + return False + if not password_result.is_existing_account: + self._log( + f"会话桥接自动登录未进入邮箱验证码页: {password_result.page_type or 'unknown'}", + "warning", + ) + return False + else: + self._log( + f"会话桥接自动登录入口返回未知页面: {page_type or 'unknown'}", + "warning", + ) + return False + + if not self._verify_email_otp_with_retry(stage_label="会话桥接登录验证码", max_attempts=3): + self._log("会话桥接自动登录验证码校验失败", "warning") + return False + + # OTP 成功后先直接抓一次 auth/session,避免无谓依赖 workspace 流程。 + self._warmup_chatgpt_session() + if self._capture_auth_session_tokens(result, access_hint=result.access_token): + self._log("会话桥接自动登录在 OTP 后已命中 session_token") + return True + + workspace_id = self._get_workspace_id() + if not workspace_id: + workspace_id = str(result.workspace_id or "").strip() + if workspace_id: + self._log(f"会话桥接自动登录复用已知 workspace_id: {workspace_id}") + if not workspace_id: + self._log("会话桥接自动登录未获取到 workspace_id", "warning") + return False + result.workspace_id = workspace_id + + continue_url = self._select_workspace(workspace_id) + if not continue_url: + cached_continue = str(self._create_account_continue_url or "").strip() + if cached_continue: + continue_url = cached_continue + self._log("会话桥接自动登录未获取到 continue_url,改用 create_account 缓存 continue_url", "warning") + else: + self._log("会话桥接自动登录未获取到 continue_url", "warning") + return False + + callback_url, final_url = self._follow_redirects(continue_url) + self._log( + f"会话桥接自动登录重定向完成: callback={'有' if callback_url else '无'}, final={str(final_url or '')[:100]}..." + ) + + self._warmup_chatgpt_session() + return self._capture_auth_session_tokens(result, access_hint=result.access_token) + except Exception as e: + self._log(f"会话桥接自动登录异常: {e}", "warning") + return False + + def _follow_chatgpt_auth_redirects(self, start_url: str) -> Tuple[str, str]: + """ + 对齐 ABCard 的 next-auth 重定向跟踪: + - 手动跟踪 30x + - 识别 /api/auth/callback/openai + Returns: + (callback_url, final_url) + """ + import urllib.parse + + current_url = str(start_url or "").strip() + callback_url = "" + bridged_header_token = "" + if not current_url: + return "", "" + + max_redirects = 12 + ua = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + ) + for i in range(max_redirects): + self._log(f"会话桥接重定向 {i+1}/{max_redirects}: {current_url[:120]}...") + if "/api/auth/callback/openai" in current_url and not callback_url: + callback_url = current_url + + resp = self.session.get( + current_url, + headers={ + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "referer": "https://chatgpt.com/", + "user-agent": ua, + }, + timeout=25, + allow_redirects=False, + ) + + # 直接从每一跳响应头 Set-Cookie 抓 session_token(对齐 F12 Network 视角) + set_cookie_text = self._flatten_set_cookie_headers(resp) + token_from_header = self._extract_session_token_from_cookie_text(set_cookie_text) + if token_from_header: + bridged_header_token = token_from_header + # 同时写入两种命名兼容,避免库在不同平台下键名差异。 + for name in ("__Secure-next-auth.session-token", "_Secure-next-auth.session-token"): + for domain in (".chatgpt.com", "chatgpt.com"): + try: + self.session.cookies.set(name, token_from_header, domain=domain, path="/") + except Exception: + continue + self._log( + f"会话桥接命中 Set-Cookie session_token(len={len(token_from_header)})" + ) + + if resp.status_code not in (301, 302, 303, 307, 308): + break + + location = str(resp.headers.get("Location") or "").strip() + if not location: + break + current_url = urllib.parse.urljoin(current_url, location) + + if callback_url and not str(current_url or "").startswith("https://chatgpt.com/"): + try: + self.session.get( + "https://chatgpt.com/", + headers={ + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "referer": current_url, + "user-agent": ua, + }, + timeout=20, + ) + except Exception: + pass + + self._log( + f"会话桥接重定向结束: callback={'有' if callback_url else '无'}, " + f"set_cookie_token={'有' if bool(bridged_header_token) else '无'}, final={current_url[:120]}..." + ) + return callback_url, current_url + + def _complete_token_exchange(self, result: RegistrationResult, require_login_otp: bool = True) -> bool: + """在登录态已建立后,补齐 session/access,并尽量获取 OAuth token。""" + if require_login_otp: + self._log("等待登录验证码到场,最后这位嘉宾还在路上...") + self._log("核对登录验证码,验明正身一下...") + if not self._verify_email_otp_with_retry(stage_label="登录验证码", max_attempts=3): + result.error_message = "验证码校验失败" + return False + else: + self._log("ABCard 入口链路:跳过二次登录验证码,直接进入 workspace + redirect + auth/session 抓取") + + self._log("摸一下 Workspace ID,看看该坐哪桌...") + workspace_id = self._get_workspace_id() + continue_url = "" + if workspace_id: + result.workspace_id = workspace_id + + self._log("选择 Workspace,安排个靠谱座位...") + continue_url = self._select_workspace(workspace_id) + if not continue_url: + cached_continue = str(self._create_account_continue_url or "").strip() + if cached_continue: + continue_url = cached_continue + self._log("workspace/select 未返回 continue_url,改用 create_account 缓存 continue_url", "warning") + else: + result.error_message = "选择 Workspace 失败" + return False + else: + cached_continue = str(self._create_account_continue_url or "").strip() + if cached_continue: + continue_url = cached_continue + self._log("未获取到 Workspace ID,改用 create_account 缓存 continue_url 继续链路", "warning") + else: + result.error_message = "获取 Workspace ID 失败" + return False + + self._log("顺着重定向面包屑往前走,别跟丢了...") + callback_url, final_url = self._follow_redirects(continue_url) + self._log( + f"重定向链完成,callback={'有' if callback_url else '无'},final={final_url[:100]}..." + ) + self._log("重定向链结束,直接请求 /api/auth/session 抓取 session/access...") + captured = self._capture_auth_session_tokens(result, access_hint=result.access_token) + if not captured: + self._log("直抓未命中,补一次 chatgpt 预热后再抓取...", "warning") + self._warmup_chatgpt_session() + captured = self._capture_auth_session_tokens(result, access_hint=result.access_token) + final_url_lower = str(final_url or "").lower() + add_phone_gate = ("auth.openai.com/add-phone" in final_url_lower) + + # ABCard 入口常见失败点:被 add-phone 风控页截断,导致拿不到 callback/session。 + if add_phone_gate and (not callback_url) and (not captured): + self._log("检测到 auth.openai.com/add-phone 风控页,当前链路未完成 OAuth 回调", "warning") + if (not require_login_otp) and (not self._is_existing_account): + self._log("ABCard 入口命中 add-phone,回退原生重登链路再试一次...", "warning") + login_ready, login_error = self._restart_login_flow() + if not login_ready: + result.error_message = f"ABCard 回退原生链路失败: {login_error}" + return False + return self._complete_token_exchange(result, require_login_otp=True) + result.error_message = "命中 add-phone 风控页,未获取到 session_token" + return False + + callback_has_error = bool( + callback_url and ("error=" in callback_url) and ("code=" not in callback_url) + ) + if callback_url: + if callback_has_error: + self._log(f"回调返回错误参数,跳过 OAuth 回调: {callback_url[:140]}...", "warning") + if not captured: + result.error_message = "OAuth 回调返回 access_denied,且未获取到 auth/session" + return False + else: + self._log("处理 OAuth 回调,准备把 token 请出来...") + token_info = self._handle_oauth_callback(callback_url) + if token_info: + result.account_id = token_info.get("account_id", "") + result.access_token = token_info.get("access_token", "") or result.access_token + result.refresh_token = token_info.get("refresh_token", "") + result.id_token = token_info.get("id_token", "") + elif captured: + self._log("OAuth 回调失败,但 session/access 已拿到,继续后续流程", "warning") + else: + result.error_message = "处理 OAuth 回调失败" + return False + else: + if captured: + self._log("未拿到 callback_url,但 session/access 已拿到,继续后续流程", "warning") + else: + result.error_message = "跟随重定向链失败" + return False + + result.password = self.password or "" + result.source = "login" if self._is_existing_account else "register" + result.device_id = result.device_id or str(self.device_id or "") + + session_cookie = self.session.cookies.get("__Secure-next-auth.session-token") + if session_cookie: + self.session_token = session_cookie + result.session_token = session_cookie + self._log("Session Token 也捞到了,今天这网没白连") + + if not result.access_token or not result.session_token: + # 再捞一次,避免某些链路里 session 建立稍慢 + self._capture_auth_session_tokens(result, access_hint=result.access_token) + if not result.session_token: + # 对齐 ABCard:尝试走 csrf + signin/openai 的会话桥接。 + self._bootstrap_chatgpt_signin_for_session(result) + if not result.session_token: + result.session_token = self._extract_session_token_from_cookie_text(self._dump_session_cookies()) + if not result.device_id: + result.device_id = str(self.device_id or self.session.cookies.get("oai-did") or "") + + if not result.access_token: + result.error_message = "未获取到 access_token" + return False + if not result.session_token: + native_register_flow = (self.registration_entry_flow == "native") and (not self._is_existing_account) + if native_register_flow: + # 对齐 K:\1\2 备份:原生注册流程里 session_token 不做阻断。 + self._log( + "当前链路未拿到 session_token,先保存账号并标记待补会话(可在账号详情/支付页一键补全)", + "warning", + ) + else: + # 非原生注册入口仍保持强制,避免后续流程不可用。 + if not self._ensure_session_token_strict(result, max_rounds=2): + result.error_message = "未获取到 session_token(强制要求)" + self._log( + "强制模式未拿到 session_token,本次注册判定失败,请检查网络/代理与登录回调链路", + "error", + ) + return False + + return True + + def _complete_token_exchange_native_backup(self, result: RegistrationResult) -> bool: + """ + 原生入口对齐备份版收尾链路: + 登录验证码 -> Workspace -> redirect -> OAuth callback -> token 入袋。 + """ + def _is_registration_gate_url(url: str) -> bool: + u = str(url or "").strip().lower() + if not u: + return False + return ("auth.openai.com/about-you" in u) or ("auth.openai.com/add-phone" in u) + + self._log("等待登录验证码到场,最后这位嘉宾还在路上...") self._log("核对登录验证码,验明正身一下...") - if not self._validate_verification_code(code): + login_otp_tried_codes: set[str] = set() + login_otp_ok = self._verify_email_otp_with_retry( + stage_label="登录验证码", + max_attempts=1, + fetch_timeout=120, + attempted_codes=login_otp_tried_codes, + ) + if not login_otp_ok: + self._log("登录验证码首轮未命中,尝试在当前会话原地重发 OTP 后再校验...", "warning") + resent = self._send_verification_code(referer="https://auth.openai.com/email-verification") + if resent: + login_otp_ok = self._verify_email_otp_with_retry( + stage_label="登录验证码(原地重发)", + max_attempts=2, + fetch_timeout=120, + attempted_codes=login_otp_tried_codes, + ) + + if not login_otp_ok: + self._log("登录验证码仍未命中,尝试重触发登录 OTP 后再校验...", "warning") + if not self._retrigger_login_otp(): + self._log("重触发登录 OTP 失败,尝试完整重登链路后再校验一次...", "warning") + login_ready, login_error = self._restart_login_flow() + if not login_ready: + result.error_message = f"登录验证码重触发失败,且完整重登失败: {login_error}" + return False + login_otp_ok = self._verify_email_otp_with_retry( + stage_label="登录验证码(重发)", + max_attempts=3, + fetch_timeout=120, + attempted_codes=login_otp_tried_codes, + ) + if not login_otp_ok: + result.error_message = "验证码校验失败" + return False + + self._log("摸一下 Workspace ID,看看该坐哪桌...") + workspace_id = str(self._last_validate_otp_workspace_id or "").strip() + if workspace_id: + self._log(f"使用 OTP 返回的 Workspace ID: {workspace_id}") + if not workspace_id: + workspace_id = str(self._get_workspace_id() or "").strip() + if workspace_id: + result.workspace_id = workspace_id + + continue_url = "" + otp_continue = str(self._last_validate_otp_continue_url or "").strip() + if otp_continue and _is_registration_gate_url(otp_continue): + self._log("OTP 返回 continue_url 指向注册门页(about-you/add-phone),本轮收尾忽略该地址", "warning") + otp_continue = "" + + cached_continue = str(self._create_account_continue_url or "").strip() + if cached_continue and _is_registration_gate_url(cached_continue): + self._log("create_account 缓存 continue_url 指向注册门页(about-you/add-phone),本轮收尾忽略该地址", "warning") + cached_continue = "" + + if workspace_id: + self._log("选择 Workspace,安排个靠谱座位...") + continue_url = str(self._select_workspace(workspace_id) or "").strip() + if not continue_url: + self._log("workspace/select 未返回 continue_url,尝试 OAuth authorize 兜底", "warning") + + if not continue_url: + oauth_start_url = str( + ( + getattr(self.oauth_start, "auth_url", "") + or getattr(self.oauth_start, "url", "") + if self.oauth_start + else "" + ) + or "" + ).strip() + if oauth_start_url: + continue_url = oauth_start_url + self._log("使用 OAuth authorize URL 作为兜底 continue_url", "warning") + + if not continue_url and otp_continue: + continue_url = otp_continue + self._log("使用 OTP 返回 continue_url 继续授权链路", "warning") + + if not continue_url and cached_continue: + continue_url = cached_continue + self._log("使用 create_account 缓存 continue_url 作为兜底", "warning") + + if not continue_url: + result.error_message = "获取 continue_url 失败" + return False + + self._log("顺着重定向面包屑往前走,别跟丢了...") + callback_url, _final_url = self._follow_redirects(continue_url) + if not callback_url: + self._log("未命中 OAuth 回调,尝试 auth/session 兜底抓取 token...", "warning") + self._capture_auth_session_tokens(result, access_hint=result.access_token) + if not result.account_id: + result.account_id = str(self._create_account_account_id or "").strip() + if not result.workspace_id: + result.workspace_id = str(workspace_id or self._create_account_workspace_id or "").strip() + if not result.refresh_token: + result.refresh_token = str(self._create_account_refresh_token or "").strip() + if result.access_token: + result.password = self.password or "" + result.source = "login" if self._is_existing_account else "register" + result.device_id = result.device_id or str(self.device_id or "") + self._log("未命中 callback,已通过 auth/session 兜底拿到 Access Token,继续完成注册", "warning") + return True + + # 对新注册账号放宽:账号已创建成功时允许“注册成功、token 待补” + if (not self._is_existing_account) and self._create_account_account_id: + result.account_id = result.account_id or str(self._create_account_account_id or "").strip() + result.workspace_id = result.workspace_id or str(workspace_id or self._create_account_workspace_id or "").strip() + result.refresh_token = result.refresh_token or str(self._create_account_refresh_token or "").strip() + result.password = self.password or "" + result.source = "register" + result.device_id = result.device_id or str(self.device_id or "") + self._log("回调链路未命中且未抓到 Access Token,但账号已创建成功;按注册成功收尾(token 待后续补齐)", "warning") + return True + + result.error_message = "跟随重定向链失败" + return False + + self._log("处理 OAuth 回调,准备把 token 请出来...") + token_info = self._handle_oauth_callback(callback_url) + if not token_info: + if (not self._is_existing_account) and self._create_account_account_id: + result.account_id = result.account_id or str(self._create_account_account_id or "").strip() + result.workspace_id = result.workspace_id or str(workspace_id or self._create_account_workspace_id or "").strip() + result.refresh_token = result.refresh_token or str(self._create_account_refresh_token or "").strip() + result.password = self.password or "" + result.source = "register" + result.device_id = result.device_id or str(self.device_id or "") + self._log("OAuth 回调处理失败,但账号已创建成功;按注册成功收尾(token 待后续补齐)", "warning") + return True + result.error_message = "处理 OAuth 回调失败" + return False + + result.account_id = token_info.get("account_id", "") + result.access_token = token_info.get("access_token", "") + result.refresh_token = token_info.get("refresh_token", "") + result.id_token = token_info.get("id_token", "") + result.password = self.password or "" + result.source = "login" if self._is_existing_account else "register" + result.device_id = result.device_id or str(self.device_id or "") + + session_cookie = self.session.cookies.get("__Secure-next-auth.session-token") + if session_cookie: + self.session_token = session_cookie + result.session_token = session_cookie + self._log("Session Token 也捞到了,今天这网没白连") + + return True + + def _complete_token_exchange_outlook(self, result: RegistrationResult) -> bool: + """ + Outlook 入口链路(迁移版): + 对齐 codex-console-main-clean 的收尾流程, + 走「登录 OTP -> Workspace -> OAuth callback」主干,避免 ABCard/native 增强链路干扰。 + 同时补齐“第二封验证码”重试链路,避免 Outlook 轮询卡死。 + """ + self._log("等待登录验证码到场,最后这位嘉宾还在路上...") + self._log("核对登录验证码,验明正身一下...") + login_otp_tried_codes: set[str] = set() + login_otp_ok = self._verify_email_otp_with_retry( + stage_label="登录验证码", + max_attempts=1, + fetch_timeout=90, + attempted_codes=login_otp_tried_codes, + ) + if not login_otp_ok: + self._log("登录验证码首轮未命中,先尝试当前会话原地重发 OTP 后再校验...", "warning") + resent = self._send_verification_code(referer="https://auth.openai.com/email-verification") + if resent: + login_otp_ok = self._verify_email_otp_with_retry( + stage_label="登录验证码(原地重发)", + max_attempts=2, + fetch_timeout=90, + attempted_codes=login_otp_tried_codes, + ) + + if not login_otp_ok: + self._log("登录验证码仍未命中,尝试重触发登录 OTP 后再校验...", "warning") + if not self._retrigger_login_otp(): + self._log("重触发登录 OTP 失败,尝试完整重登链路后再校验一次...", "warning") + login_ready, login_error = self._restart_login_flow() + if not login_ready: + result.error_message = f"登录验证码重触发失败,且完整重登失败: {login_error}" + return False + + login_otp_ok = self._verify_email_otp_with_retry( + stage_label="登录验证码(重发)", + max_attempts=3, + fetch_timeout=120, + attempted_codes=login_otp_tried_codes, + ) + if not login_otp_ok: result.error_message = "验证码校验失败" return False self._log("摸一下 Workspace ID,看看该坐哪桌...") - workspace_id = self._get_workspace_id() + workspace_id = str(self._last_validate_otp_workspace_id or "").strip() + if workspace_id: + self._log(f"使用 OTP 返回的 Workspace ID: {workspace_id}") if not workspace_id: + workspace_id = str(self._get_workspace_id() or "").strip() + if not workspace_id: + workspace_id = str(self._last_validate_otp_workspace_id or self._create_account_workspace_id or "").strip() + if workspace_id: + self._log(f"Workspace ID(缓存): {workspace_id}", "warning") + + continue_url = "" + if workspace_id: + result.workspace_id = workspace_id + self._log("选择 Workspace,安排个靠谱座位...") + continue_url = str(self._select_workspace(workspace_id) or "").strip() + if not continue_url: + self._log("workspace/select 未返回 continue_url,尝试使用缓存 continue_url", "warning") + else: + self._log("未获取到 Workspace ID,尝试直接使用缓存 continue_url", "warning") + + if not continue_url: + continue_url = str(self._last_validate_otp_continue_url or self._create_account_continue_url or "").strip() + if continue_url: + self._log("使用缓存 continue_url 继续授权链路", "warning") + + if not continue_url: result.error_message = "获取 Workspace ID 失败" return False - result.workspace_id = workspace_id - - self._log("选择 Workspace,安排个靠谱座位...") - continue_url = self._select_workspace(workspace_id) - if not continue_url: - result.error_message = "选择 Workspace 失败" - return False - self._log("顺着重定向面包屑往前走,别跟丢了...") - callback_url = self._follow_redirects(continue_url) + callback_url, _final_url = self._follow_redirects(continue_url) if not callback_url: result.error_message = "跟随重定向链失败" return False @@ -491,12 +1621,20 @@ class RegistrationEngine: result.error_message = "处理 OAuth 回调失败" return False - result.account_id = token_info.get("account_id", "") - result.access_token = token_info.get("access_token", "") - result.refresh_token = token_info.get("refresh_token", "") - result.id_token = token_info.get("id_token", "") + result.account_id = str(token_info.get("account_id") or result.account_id or "").strip() + result.access_token = str(token_info.get("access_token") or result.access_token or "").strip() + result.refresh_token = str(token_info.get("refresh_token") or result.refresh_token or "").strip() + result.id_token = str(token_info.get("id_token") or result.id_token or "").strip() result.password = self.password or "" result.source = "login" if self._is_existing_account else "register" + result.device_id = result.device_id or str(self.device_id or "") + + if not result.account_id: + result.account_id = str(self._create_account_account_id or "").strip() + if not result.workspace_id: + result.workspace_id = str(self._create_account_workspace_id or "").strip() + if not result.refresh_token: + result.refresh_token = str(self._create_account_refresh_token or "").strip() session_cookie = self.session.cookies.get("__Secure-next-auth.session-token") if session_cookie: @@ -504,8 +1642,264 @@ class RegistrationEngine: result.session_token = session_cookie self._log("Session Token 也捞到了,今天这网没白连") + if not result.access_token: + result.error_message = "未获取到 access_token" + return False + return True + def _ensure_session_token_strict(self, result: RegistrationResult, max_rounds: int = 2) -> bool: + """ + 强制确保 session_token 可用。 + - 先走 auth/session 直抓 + - 再走 ABCard 同款会话桥接 + 连续多轮失败则返回 False。 + """ + if result.session_token: + return True + + rounds = max(int(max_rounds), 1) + for idx in range(rounds): + self._log(f"强制补会话 round {idx + 1}/{rounds}:尝试补抓 session_token ...") + + self._warmup_chatgpt_session() + self._capture_auth_session_tokens(result, access_hint=result.access_token) + if result.session_token: + self._log("强制补会话成功:auth/session 已拿到 session_token") + return True + + self._bootstrap_chatgpt_signin_for_session(result) + if result.session_token: + self._log("强制补会话成功:桥接链路已拿到 session_token") + return True + + fallback_token = self._extract_session_token_from_cookie_text(self._dump_session_cookies()) + if fallback_token: + result.session_token = fallback_token + self.session_token = fallback_token + self._log("强制补会话成功:cookie 文本兜底命中 session_token") + return True + + self._log("强制补会话本轮未命中 session_token", "warning") + + return False + + def _capture_native_core_tokens(self, result: RegistrationResult) -> bool: + """ + 原生注册入口的轻量 token 抓取: + - 不做二次登录 + - 不强依赖 session_token + - 尽量补齐 account/workspace/access/refresh + """ + try: + client_id = str(getattr(self.oauth_manager, "client_id", "") or "").strip() + if client_id: + self._log(f"原生入口 token 抓取: Client ID: {client_id}") + + if (not result.account_id) and self._create_account_account_id: + result.account_id = str(self._create_account_account_id or "").strip() + self._log(f"原生入口 token 抓取: 复用 create_account Account ID: {result.account_id}") + if (not result.refresh_token) and self._create_account_refresh_token: + result.refresh_token = str(self._create_account_refresh_token or "").strip() + self._log("原生入口 token 抓取: 复用 create_account Refresh Token") + + workspace_id = str(result.workspace_id or "").strip() + if not workspace_id: + workspace_id = str(self._create_account_workspace_id or "").strip() + if not workspace_id: + workspace_id = str(self._get_workspace_id() or "").strip() + if workspace_id: + result.workspace_id = workspace_id + self._log(f"原生入口 token 抓取: Workspace ID: {workspace_id}") + else: + self._log("原生入口 token 抓取: 未获取到 Workspace ID", "warning") + + continue_url = "" + if workspace_id: + continue_url = str(self._select_workspace(workspace_id) or "").strip() + if not continue_url: + cached_continue = str(self._create_account_continue_url or "").strip() + if cached_continue: + continue_url = cached_continue + self._log("原生入口 token 抓取: 使用 create_account 缓存 continue_url", "warning") + + callback_url: Optional[str] = None + final_url = "" + if continue_url: + self._log("原生入口 token 抓取: 跟随重定向链获取 OAuth callback...") + callback_url, final_url = self._follow_redirects(continue_url) + self._log( + f"原生入口 token 抓取: 重定向完成,callback={'有' if callback_url else '无'},final={str(final_url)[:100]}..." + ) + else: + self._log("原生入口 token 抓取: 未获得 continue_url,跳过 callback 交换", "warning") + + callback_has_error = bool( + callback_url and ("error=" in callback_url) and ("code=" not in callback_url) + ) + if callback_url and (not callback_has_error): + token_info = self._handle_oauth_callback(callback_url) + if token_info: + result.account_id = str(token_info.get("account_id") or result.account_id or "").strip() + result.access_token = str(token_info.get("access_token") or result.access_token or "").strip() + result.refresh_token = str(token_info.get("refresh_token") or result.refresh_token or "").strip() + result.id_token = str(token_info.get("id_token") or result.id_token or "").strip() + self._log( + "原生入口 token 抓取结果: " + f"account_id={'有' if bool(result.account_id) else '无'}, " + f"access={'有' if bool(result.access_token) else '无'}, " + f"refresh={'有' if bool(result.refresh_token) else '无'}" + ) + else: + self._log("原生入口 token 抓取: OAuth 回调处理失败", "warning") + elif callback_has_error: + self._log(f"原生入口 token 抓取: callback 含 error,跳过 token 交换: {callback_url[:140]}...", "warning") + else: + self._log("原生入口 token 抓取: 未命中 callback_url", "warning") + + # 不走重登,仅轻量探测 auth/session 里的 accessToken(不依赖 session_token)。 + if not result.access_token: + self._capture_access_token_light(result) + + if (not result.account_id) and result.id_token: + try: + account_info = self.oauth_manager.extract_account_info(result.id_token) + result.account_id = str(account_info.get("account_id") or "").strip() + except Exception: + pass + if (not result.account_id) and result.access_token: + token_acc = self._extract_account_id_from_access_token(result.access_token) + if token_acc: + result.account_id = token_acc + self._log(f"原生入口 token 抓取: 从 access_token 解析 Account ID: {token_acc}") + if not result.workspace_id: + try: + workspace_id_after = str(self._get_workspace_id() or "").strip() + if workspace_id_after: + result.workspace_id = workspace_id_after + self._log(f"原生入口 token 抓取: 二次获取 Workspace ID 成功: {workspace_id_after}") + except Exception: + pass + + missing = [] + if not result.account_id: + missing.append("Account ID") + if not result.workspace_id: + missing.append("Workspace ID") + if not result.access_token: + missing.append("Access Token") + if not result.refresh_token: + missing.append("Refresh Token") + if missing: + self._log(f"原生入口 token 抓取: 未获取字段 -> {', '.join(missing)}", "warning") + + return bool(result.access_token and result.refresh_token) + except Exception as e: + self._log(f"原生入口 token 抓取异常: {e}", "warning") + return False + + def _capture_access_token_light(self, result: RegistrationResult) -> bool: + """轻量从 /api/auth/session 抓 accessToken(不依赖 session_token)。""" + try: + response = self.session.get( + "https://chatgpt.com/api/auth/session", + headers={ + "accept": "application/json", + "referer": "https://chatgpt.com/", + }, + timeout=20, + ) + if response.status_code != 200: + self._log(f"原生入口轻量 auth/session 状态异常: {response.status_code}", "warning") + return False + data = response.json() or {} + access_token = str(data.get("accessToken") or "").strip() + if access_token: + result.access_token = access_token + self._log("原生入口轻量 auth/session 命中 Access Token") + return True + self._log("原生入口轻量 auth/session 未命中 Access Token", "warning") + return False + except Exception as e: + self._log(f"原生入口轻量 auth/session 异常: {e}", "warning") + return False + + def _extract_account_id_from_access_token(self, access_token: str) -> str: + """从 access_token 的 JWT payload 尝试解析 chatgpt_account_id。""" + try: + raw = str(access_token or "").strip() + if raw.count(".") < 2: + return "" + payload = raw.split(".")[1] + import base64 + pad = "=" * ((4 - (len(payload) % 4)) % 4) + decoded = base64.urlsafe_b64decode((payload + pad).encode("ascii")) + claims = json.loads(decoded.decode("utf-8")) + if not isinstance(claims, dict): + return "" + auth_claims = claims.get("https://api.openai.com/auth") or {} + account_id = str( + auth_claims.get("chatgpt_account_id") + or claims.get("chatgpt_account_id") + or "" + ).strip() + return account_id + except Exception: + return "" + + def _ensure_native_required_tokens(self, result: RegistrationResult) -> bool: + """ + 原生注册入口要求拿齐: + Account ID / Workspace ID / Client ID / Access Token / Refresh Token + """ + try: + if (not result.account_id) and result.id_token: + try: + account_info = self.oauth_manager.extract_account_info(result.id_token) + result.account_id = str(account_info.get("account_id") or "").strip() + except Exception: + pass + if (not result.account_id) and result.access_token: + result.account_id = self._extract_account_id_from_access_token(result.access_token) + + if not result.workspace_id: + result.workspace_id = str(self._get_workspace_id() or "").strip() + if (not result.refresh_token) and self._create_account_refresh_token: + result.refresh_token = str(self._create_account_refresh_token or "").strip() + + settings = get_settings() + client_id = str( + getattr(settings, "openai_client_id", "") + or getattr(self.oauth_manager, "client_id", "") + or "" + ).strip() + + missing = [] + if not result.account_id: + missing.append("Account ID") + if not result.workspace_id: + missing.append("Workspace ID") + if not client_id: + missing.append("Client ID") + if not result.access_token: + missing.append("Access Token") + if not result.refresh_token: + missing.append("Refresh Token") + + if missing: + self._log(f"原生入口关键参数缺失: {', '.join(missing)}", "error") + return False + + self._log( + "原生入口关键参数校验通过: " + f"Account ID={result.account_id}, Workspace ID={result.workspace_id}, " + f"Client ID={client_id}, Access=有, Refresh=有" + ) + return True + except Exception as e: + self._log(f"原生入口关键参数校验异常: {e}", "error") + return False + def _restart_login_flow(self) -> Tuple[bool, str]: """新注册账号完成建号后,重新发起一次登录流程拿 token。""" self._token_acquisition_requires_login = True @@ -531,9 +1925,60 @@ class RegistrationEngine: return False, f"重新登录未进入验证码页面: {password_result.page_type or 'unknown'}" return True, "" - def _register_password(self) -> Tuple[bool, Optional[str]]: + def _retrigger_login_otp(self) -> bool: + """ + 在“登录验证码”阶段重触发 OTP 发送。 + 优先复用登录链路(login_start -> login_password),避免误走注册 OTP 流程。 + """ + try: + did = str(self.device_id or self.session.cookies.get("oai-did") or "").strip() + if not did: + did = str(uuid.uuid4()) + try: + self.session.cookies.set("oai-did", did, domain=".chatgpt.com", path="/") + except Exception: + pass + self.device_id = did + + sen_token = self._check_sentinel(did) + login_start_result = self._submit_login_start(did, sen_token) + if not login_start_result.success: + self._log( + f"重触发登录 OTP 失败:提交登录入口失败: {login_start_result.error_message}", + "warning", + ) + return False + + page_type = str(login_start_result.page_type or "").strip() + if page_type == OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]: + self._log("重触发登录 OTP 成功:已直达邮箱验证码页") + return True + + if page_type != OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]: + self._log(f"重触发登录 OTP 失败:未进入密码页({page_type or 'unknown'})", "warning") + return False + + password_result = self._submit_login_password() + if not password_result.success: + self._log(f"重触发登录 OTP 失败:提交登录密码失败: {password_result.error_message}", "warning") + return False + if not password_result.is_existing_account: + self._log( + f"重触发登录 OTP 失败:密码后未进入验证码页({password_result.page_type or 'unknown'})", + "warning", + ) + return False + + self._log("重触发登录 OTP 成功:已进入邮箱验证码页") + return True + except Exception as e: + self._log(f"重触发登录 OTP 异常: {e}", "warning") + return False + + def _register_password(self, did: Optional[str] = None, sen_token: Optional[str] = None) -> Tuple[bool, Optional[str]]: """注册密码""" try: + self._last_register_password_error = None # 生成密码 password = self._generate_password() self.password = password # 保存密码到实例变量 @@ -566,14 +2011,42 @@ class RegistrationEngine: error_json = response.json() error_msg = error_json.get("error", {}).get("message", "") error_code = error_json.get("error", {}).get("code", "") + normalized_error_msg = str(error_msg or "").strip() + normalized_error_code = str(error_code or "").strip() # 检测邮箱已注册的情况 - if "already" in error_msg.lower() or "exists" in error_msg.lower() or error_code == "user_exists": + if "already" in normalized_error_msg.lower() or "exists" in normalized_error_msg.lower() or normalized_error_code == "user_exists": self._log(f"邮箱 {self.email} 可能已在 OpenAI 注册过", "error") # 标记此邮箱为已注册状态 self._mark_email_as_registered() + self._last_register_password_error = "该邮箱可能已在 OpenAI 注册,建议更换邮箱或改走登录流程" + elif "failed to register username" in normalized_error_msg.lower(): + self._last_register_password_error = ( + "OpenAI 拒绝当前邮箱用户名(可能已占用或触发风控),建议更换邮箱后重试" + ) + if did: + self._log("检测到用户名注册失败,尝试登录入口探测邮箱是否已存在...", "warning") + try: + probe = self._submit_login_start(did, sen_token) + if probe.success and probe.page_type in ( + OPENAI_PAGE_TYPES["LOGIN_PASSWORD"], + OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"], + ): + self._log("登录入口探测命中:该邮箱大概率已是 OpenAI 账号", "warning") + self._mark_email_as_registered() + self._last_register_password_error = ( + "该邮箱已存在 OpenAI 账号。" + "若是刚刚注册中断,请优先使用上一轮任务日志里的“生成密码”走登录续跑;" + "拿不到旧密码再更换邮箱。" + ) + except Exception as probe_error: + self._log(f"登录入口探测失败: {probe_error}", "warning") + else: + self._last_register_password_error = ( + f"注册密码接口返回异常: {normalized_error_msg or f'HTTP {response.status_code}'}" + ) except Exception: - pass + self._last_register_password_error = f"注册密码接口返回异常: HTTP {response.status_code}" return False, None @@ -581,6 +2054,7 @@ class RegistrationEngine: except Exception as e: self._log(f"密码注册失败: {e}", "error") + self._last_register_password_error = str(e) return False, None def _mark_email_as_registered(self): @@ -604,16 +2078,17 @@ class RegistrationEngine: except Exception as e: logger.warning(f"标记邮箱状态失败: {e}") - def _send_verification_code(self) -> bool: + def _send_verification_code(self, referer: Optional[str] = None) -> bool: """发送验证码""" try: # 记录发送时间戳 self._otp_sent_at = time.time() + send_referer = str(referer or "https://auth.openai.com/create-account/password").strip() response = self.session.get( OPENAI_API_ENDPOINTS["send_otp"], headers={ - "referer": "https://auth.openai.com/create-account/password", + "referer": send_referer, "accept": "application/json", }, ) @@ -625,16 +2100,18 @@ class RegistrationEngine: self._log(f"发送验证码失败: {e}", "error") return False - def _get_verification_code(self) -> Optional[str]: + def _get_verification_code(self, timeout: Optional[int] = None) -> Optional[str]: """获取验证码""" try: - self._log(f"正在等待邮箱 {self.email} 的验证码...") + mailbox_email = str(self.inbox_email or self.email or "").strip() + self._log(f"正在等待邮箱 {mailbox_email} 的验证码...") email_id = self.email_info.get("service_id") if self.email_info else None + fetch_timeout = int(timeout) if timeout and int(timeout) > 0 else 120 code = self.email_service.get_verification_code( - email=self.email, + email=mailbox_email, email_id=email_id, - timeout=120, + timeout=fetch_timeout, pattern=OTP_CODE_PATTERN, otp_sent_at=self._otp_sent_at, ) @@ -653,6 +2130,9 @@ class RegistrationEngine: def _validate_verification_code(self, code: str) -> bool: """验证验证码""" try: + self._last_otp_validation_code = str(code or "").strip() + self._last_otp_validation_status_code = None + self._last_otp_validation_outcome = "" code_body = f'{{"code":"{code}"}}' response = self.session.post( @@ -666,12 +2146,144 @@ class RegistrationEngine: ) self._log(f"验证码校验状态: {response.status_code}") + self._last_otp_validation_status_code = int(response.status_code) + self._last_otp_validation_outcome = "success" if response.status_code == 200 else "http_non_200" + if response.status_code == 200: + # 记录 OTP 校验返回中的 continue/workspace 提示,供 native 收尾兜底 + try: + import urllib.parse as urlparse + payload = response.json() or {} + candidates: List[Dict[str, Any]] = [] + if isinstance(payload, dict): + candidates.append(payload) + for key in ("data", "result", "next", "payload"): + value = payload.get(key) + if isinstance(value, dict): + candidates.append(value) + + found_continue = "" + found_workspace = "" + for item in candidates: + if not isinstance(item, dict): + continue + if not found_workspace: + found_workspace = str( + item.get("workspace_id") + or item.get("workspaceId") + or item.get("default_workspace_id") + or ((item.get("workspace") or {}).get("id") if isinstance(item.get("workspace"), dict) else "") + or "" + ).strip() + if not found_continue: + for key in ("continue_url", "continueUrl", "next_url", "nextUrl", "redirect_url", "redirectUrl", "url"): + candidate = str(item.get(key) or "").strip() + if not candidate: + continue + if candidate.startswith("/"): + candidate = urlparse.urljoin(OPENAI_API_ENDPOINTS["validate_otp"], candidate) + found_continue = candidate + break + if found_workspace and found_continue: + break + + if found_workspace: + self._last_validate_otp_workspace_id = found_workspace + self._log(f"OTP 校验返回 Workspace ID: {found_workspace}") + if found_continue: + self._last_validate_otp_continue_url = found_continue + self._log(f"OTP 校验返回 continue_url: {found_continue[:100]}...") + except Exception as parse_err: + self._log(f"解析 OTP 校验返回信息失败: {parse_err}", "warning") + return response.status_code == 200 except Exception as e: + err_text = str(e or "").lower() + if ( + "timed out" in err_text + or "timeout" in err_text + or "curl: (28)" in err_text + or "operation timed out" in err_text + ): + self._last_otp_validation_outcome = "network_timeout" + else: + self._last_otp_validation_outcome = "network_error" self._log(f"验证验证码失败: {e}", "error") return False + def _verify_email_otp_with_retry( + self, + stage_label: str = "验证码", + max_attempts: int = 3, + fetch_timeout: Optional[int] = None, + attempted_codes: Optional[set[str]] = None, + ) -> bool: + """ + 获取并校验验证码(带重试)。 + 用于规避邮箱里历史验证码导致的 400(第一次取到旧码,第二次取新码)。 + """ + # 每轮验证码阶段开始前,清理上轮 OTP 校验缓存,避免 continue_url/workspace 被旧阶段污染。 + self._last_validate_otp_continue_url = None + self._last_validate_otp_workspace_id = None + if attempted_codes is None: + attempted_codes = set() + for attempt in range(1, max_attempts + 1): + code = ( + self._get_verification_code(timeout=fetch_timeout) + if fetch_timeout + else self._get_verification_code() + ) + if not code: + if attempt < max_attempts: + self._log( + f"{stage_label}第 {attempt}/{max_attempts} 次未取到验证码,稍后重试...", + "warning", + ) + time.sleep(2) + continue + return False + + if code in attempted_codes: + allow_same_code_retry = ( + self._last_otp_validation_code == code + and self._last_otp_validation_outcome in {"network_timeout", "network_error"} + ) + if allow_same_code_retry: + self._log( + f"{stage_label}第 {attempt}/{max_attempts} 次命中重复验证码 {code}," + f"但上次校验为网络异常({self._last_otp_validation_outcome}),重试同码...", + "warning", + ) + if self._validate_verification_code(code): + return True + if attempt < max_attempts: + time.sleep(2) + continue + return False + + if attempt < max_attempts: + self._log( + f"{stage_label}第 {attempt}/{max_attempts} 次命中重复验证码 {code},等待新邮件...", + "warning", + ) + time.sleep(2) + continue + return False + + attempted_codes.add(code) + + if self._validate_verification_code(code): + return True + + if attempt < max_attempts: + self._log( + f"{stage_label}第 {attempt}/{max_attempts} 次校验未通过,疑似旧验证码,自动重试下一封...", + "warning", + ) + time.sleep(2) + + return False + def _create_user_account(self) -> bool: """创建用户账户""" try: @@ -695,6 +2307,39 @@ class RegistrationEngine: self._log(f"账户创建失败: {response.text[:200]}", "warning") return False + try: + data = response.json() or {} + continue_url = str(data.get("continue_url") or "").strip() + if continue_url: + self._create_account_continue_url = continue_url + self._log(f"create_account 返回 continue_url,已缓存: {continue_url[:100]}...") + account_id = str( + data.get("account_id") + or data.get("chatgpt_account_id") + or (data.get("account") or {}).get("id") + or "" + ).strip() + if account_id: + self._create_account_account_id = account_id + self._log(f"create_account 返回 account_id,已缓存: {account_id}") + workspace_id = str( + data.get("workspace_id") + or data.get("default_workspace_id") + or (data.get("workspace") or {}).get("id") + or "" + ).strip() + if (not workspace_id) and isinstance(data.get("workspaces"), list) and data.get("workspaces"): + workspace_id = str((data.get("workspaces")[0] or {}).get("id") or "").strip() + if workspace_id: + self._create_account_workspace_id = workspace_id + self._log(f"create_account 返回 workspace_id,已缓存: {workspace_id}") + refresh_token = str(data.get("refresh_token") or "").strip() + if refresh_token: + self._create_account_refresh_token = refresh_token + self._log("create_account 返回 refresh_token,已缓存") + except Exception: + pass + return True except Exception as e: @@ -704,42 +2349,92 @@ class RegistrationEngine: def _get_workspace_id(self) -> Optional[str]: """获取 Workspace ID""" try: - auth_cookie = self.session.cookies.get("oai-client-auth-session") + def _extract_workspace_id(payload: Any) -> str: + if not isinstance(payload, dict): + return "" + workspace_id = str( + payload.get("workspace_id") + or payload.get("default_workspace_id") + or ((payload.get("workspace") or {}).get("id") if isinstance(payload.get("workspace"), dict) else "") + or "" + ).strip() + if workspace_id: + return workspace_id + workspaces = payload.get("workspaces") or [] + if isinstance(workspaces, list) and workspaces: + return str((workspaces[0] or {}).get("id") or "").strip() + return "" + + auth_cookie = str(self.session.cookies.get("oai-client-auth-session") or "").strip() if not auth_cookie: - self._log("未能获取到授权 Cookie", "error") - return None + self._log("未能获取到授权 Cookie,尝试从 auth-info 里取 workspace", "warning") # 解码 JWT import base64 import json as json_module + import urllib.parse as urlparse try: - segments = auth_cookie.split(".") - if len(segments) < 1: - self._log("授权 Cookie 格式错误", "error") - return None + candidate_payloads: List[str] = [] + if auth_cookie: + segments = auth_cookie.split(".") + # 对齐 ABCard:优先 JWT payload 段(第 2 段) + if len(segments) >= 2 and segments[1]: + candidate_payloads.append(segments[1]) + if segments and segments[0]: + candidate_payloads.append(segments[0]) + # 极端情况下 cookie 可能直接是 JSON 字符串 + candidate_payloads.append(auth_cookie) - # 解码第一个 segment - payload = segments[0] - pad = "=" * ((4 - (len(payload) % 4)) % 4) - decoded = base64.urlsafe_b64decode((payload + pad).encode("ascii")) - auth_json = json_module.loads(decoded.decode("utf-8")) + for payload in candidate_payloads: + raw = str(payload or "").strip() + if not raw: + continue + auth_json = None + try: + pad = "=" * ((4 - (len(raw) % 4)) % 4) + decoded = base64.urlsafe_b64decode((raw + pad).encode("ascii")) + auth_json = json_module.loads(decoded.decode("utf-8")) + except Exception: + try: + auth_json = json_module.loads(raw) + except Exception: + auth_json = None - workspaces = auth_json.get("workspaces") or [] - if not workspaces: - self._log("授权 Cookie 里没有 workspace 信息", "error") - return None + workspace_id = _extract_workspace_id(auth_json) + if workspace_id: + self._log(f"Workspace ID: {workspace_id}") + return workspace_id - workspace_id = str((workspaces[0] or {}).get("id") or "").strip() - if not workspace_id: - self._log("无法解析 workspace_id", "error") - return None + # 兜底:从 oai-client-auth-info(URL 编码 JSON)提取 workspace + auth_info_raw = str(self.session.cookies.get("oai-client-auth-info") or "").strip() + if auth_info_raw: + auth_info_text = auth_info_raw + for _ in range(2): + decoded = urlparse.unquote(auth_info_text) + if decoded == auth_info_text: + break + auth_info_text = decoded + try: + auth_info_json = json_module.loads(auth_info_text) + workspace_id = _extract_workspace_id(auth_info_json) + if workspace_id: + self._log(f"Workspace ID (auth-info): {workspace_id}") + return workspace_id + except Exception as auth_info_err: + self._log(f"解析 auth-info Cookie 失败: {auth_info_err}", "warning") - self._log(f"Workspace ID: {workspace_id}") - return workspace_id + # 兜底:复用 create_account 缓存 + cached_workspace = str(self._create_account_workspace_id or "").strip() + if cached_workspace: + self._log(f"Workspace ID (create_account缓存): {cached_workspace}") + return cached_workspace + + self._log("授权 Cookie 里没有 workspace 信息", "warning") + return None except Exception as e: - self._log(f"解析授权 Cookie 失败: {e}", "error") + self._log(f"解析授权 Cookie 失败: {e}", "warning") return None except Exception as e: @@ -756,35 +2451,88 @@ class RegistrationEngine: headers={ "referer": "https://auth.openai.com/sign-in-with-chatgpt/codex/consent", "content-type": "application/json", + "accept": "application/json", }, data=select_body, + allow_redirects=False, ) + # 兼容 30x:部分环境 continue_url 在 Location 头里。 + location = str(response.headers.get("Location") or "").strip() + if response.status_code in [301, 302, 303, 307, 308] and location: + import urllib.parse + continue_url = urllib.parse.urljoin(OPENAI_API_ENDPOINTS["select_workspace"], location) + self._log(f"Continue URL (Location): {continue_url[:100]}...") + return continue_url + if response.status_code != 200: self._log(f"选择 workspace 失败: {response.status_code}", "error") self._log(f"响应: {response.text[:200]}", "warning") return None - continue_url = str((response.json() or {}).get("continue_url") or "").strip() - if not continue_url: - self._log("workspace/select 响应里缺少 continue_url", "error") - return None + continue_url = "" + try: + continue_url = str((response.json() or {}).get("continue_url") or "").strip() + except Exception as json_err: + body_text = str(response.text or "") + self._log(f"workspace/select 非 JSON 响应,尝试文本兜底解析: {json_err}", "warning") + # 兜底1:HTML/文本里直接包含 continue_url + m = re.search(r'"continue_url"\s*:\s*"([^"]+)"', body_text) + if m: + continue_url = str(m.group(1) or "").strip() + # 兜底2:返回页内含 auth.openai.com/oauth/authorize 链接 + if not continue_url: + m2 = re.search(r"https://auth\.openai\.com/[^\s\"'<>]+", body_text) + if m2: + continue_url = str(m2.group(0) or "").strip() - self._log(f"Continue URL: {continue_url[:100]}...") - return continue_url + if not continue_url: + if location: + import urllib.parse + continue_url = urllib.parse.urljoin(OPENAI_API_ENDPOINTS["select_workspace"], location) + else: + self._log("workspace/select 响应里缺少 continue_url", "error") + return None + + if continue_url: + continue_url = continue_url.replace("\\/", "/") + self._log(f"Continue URL: {continue_url[:100]}...") + return continue_url + + return None except Exception as e: self._log(f"选择 Workspace 失败: {e}", "error") return None - def _follow_redirects(self, start_url: str) -> Optional[str]: - """跟随重定向链,寻找回调 URL""" + def _follow_redirects(self, start_url: str) -> Tuple[Optional[str], str]: + """手动跟随重定向链,返回 (callback_url, final_url)。""" try: + def _is_oauth_callback(url: str) -> bool: + try: + import urllib.parse as _urlparse + + parsed = _urlparse.urlparse(url) + path = (parsed.path or "").lower() + if ("/auth/callback" not in path) and ("/api/auth/callback/openai" not in path): + return False + query = _urlparse.parse_qs(parsed.query or "", keep_blank_values=True) + # 只要带 code 或 error,就认为已经进入回调阶段(避免被本地 503 干扰识别) + return bool(query.get("code") or query.get("error")) + except Exception: + return False + current_url = start_url - max_redirects = 6 + callback_url: Optional[str] = None + max_redirects = 12 for i in range(max_redirects): self._log(f"重定向 {i+1}/{max_redirects}: {current_url[:100]}...") + if _is_oauth_callback(current_url) and not callback_url: + callback_url = current_url + self._log(f"命中回调 URL: {current_url[:120]}...") + # 已拿到 callback,不再请求本地 callback 地址,避免 503 干扰后续判断 + break response = self.session.get( current_url, @@ -794,6 +2542,9 @@ class RegistrationEngine: location = response.headers.get("Location") or "" + if "/api/auth/callback/openai" in current_url and not callback_url: + callback_url = current_url + # 如果不是重定向状态码,停止 if response.status_code not in [301, 302, 303, 307, 308]: self._log(f"非重定向状态码: {response.status_code}") @@ -807,19 +2558,40 @@ class RegistrationEngine: import urllib.parse next_url = urllib.parse.urljoin(current_url, location) - # 检查是否包含回调参数 - if "code=" in next_url and "state=" in next_url: + # 命中回调时仅记录,不提前返回;继续跟到底,让 next-auth 充分落 cookie。 + if _is_oauth_callback(next_url) and not callback_url: + callback_url = next_url self._log(f"找到回调 URL: {next_url[:100]}...") - return next_url + current_url = next_url + break current_url = next_url - self._log("未能在重定向链中找到回调 URL", "error") - return None + # 对齐 ABCard:补打一跳 chatgpt 首页,确保 next-auth cookie 完整落地。 + try: + if not current_url.rstrip("/").endswith("chatgpt.com"): + self.session.get( + "https://chatgpt.com/", + headers={ + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "referer": current_url, + "user-agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + ), + }, + timeout=20, + ) + except Exception as home_err: + self._log(f"重定向结束后首页补跳异常: {home_err}", "warning") + + if not callback_url: + self._log("未能在重定向链中找到回调 URL", "warning") + return callback_url, current_url except Exception as e: self._log(f"跟随重定向失败: {e}", "error") - return None + return None, start_url def _handle_oauth_callback(self, callback_url: str) -> Optional[Dict[str, Any]]: """处理 OAuth 回调""" @@ -860,10 +2632,24 @@ class RegistrationEngine: self._is_existing_account = False self._token_acquisition_requires_login = False self._otp_sent_at = None + self._create_account_continue_url = None + self._create_account_workspace_id = None + self._create_account_account_id = None + self._create_account_refresh_token = None + self._last_validate_otp_continue_url = None + self._last_validate_otp_workspace_id = None self._log("=" * 60) self._log("注册流程启动,开始替你敲门") self._log("=" * 60) + self._log(f"注册入口链路配置: {self.registration_entry_flow}") + configured_entry_flow = self.registration_entry_flow + service_type_raw = getattr(self.email_service, "service_type", "") + service_type_value = str(getattr(service_type_raw, "value", service_type_raw) or "").strip().lower() + effective_entry_flow = configured_entry_flow + if service_type_value == "outlook": + self._log("检测到 Outlook 邮箱,自动使用 Outlook 入口链路(无需在设置中选择)") + effective_entry_flow = "outlook" # 1. 检查 IP 地理位置 self._log("1. 先看看这条网络从哪儿来,别一开局就站错片场...") @@ -888,6 +2674,7 @@ class RegistrationEngine: if not did: result.error_message = "获取 Device ID 失败" return result + result.device_id = did if not sen_token: result.error_message = "Sentinel POW 验证失败" return result @@ -903,9 +2690,9 @@ class RegistrationEngine: self._log("检测到这是老朋友账号,直接切去登录拿 token,不走弯路") else: self._log("5. 设置密码,别让小偷偷笑...") - password_ok, _ = self._register_password() + password_ok, _ = self._register_password(did, sen_token) if not password_ok: - result.error_message = "注册密码失败" + result.error_message = self._last_register_password_error or "注册密码失败" return result self._log("6. 催一下注册验证码出门,邮差该冲刺了...") @@ -914,13 +2701,8 @@ class RegistrationEngine: return result self._log("7. 等验证码飞来,邮箱请注意查收...") - code = self._get_verification_code() - if not code: - result.error_message = "获取验证码失败" - return result - self._log("8. 对一下验证码,看看是不是本人...") - if not self._validate_verification_code(code): + if not self._verify_email_otp_with_retry(stage_label="注册验证码", max_attempts=3): result.error_message = "验证验证码失败" return result @@ -929,13 +2711,26 @@ class RegistrationEngine: result.error_message = "创建用户账户失败" return result - login_ready, login_error = self._restart_login_flow() - if not login_ready: - result.error_message = login_error - return result + if effective_entry_flow in {"native", "outlook"}: + login_ready, login_error = self._restart_login_flow() + if not login_ready: + result.error_message = login_error + return result + if effective_entry_flow == "outlook": + self._log("注册入口链路: Outlook(迁移版,按朋友版 Outlook 主流程收尾)") + else: + self._log("注册入口链路: ABCard(新账号不重登,直接抓取会话)") - if not self._complete_token_exchange(result): - return result + if effective_entry_flow == "native": + if not self._complete_token_exchange_native_backup(result): + return result + elif effective_entry_flow == "outlook": + if not self._complete_token_exchange_outlook(result): + return result + else: + use_abcard_entry = (effective_entry_flow == "abcard") and (not self._is_existing_account) + if not self._complete_token_exchange(result, require_login_otp=not use_abcard_entry): + return result # 10. 完成 self._log("=" * 60) @@ -944,17 +2739,29 @@ class RegistrationEngine: else: self._log("注册成功,账号已经稳稳落地,可以开香槟了") self._log(f"邮箱: {result.email}") + self._log(f"Device ID: {result.device_id or '-'}") self._log(f"Account ID: {result.account_id}") self._log(f"Workspace ID: {result.workspace_id}") self._log("=" * 60) result.success = True + settings = get_settings() + client_id = str(getattr(settings, "openai_client_id", "") or getattr(self.oauth_manager, "client_id", "") or "").strip() result.metadata = { "email_service": self.email_service.service_type.value, "proxy_used": self.proxy_url, "registered_at": datetime.now().isoformat(), "is_existing_account": self._is_existing_account, "token_acquired_via_relogin": self._token_acquisition_requires_login, + "client_id": client_id, + "device_id": result.device_id, + "has_session_token": bool(result.session_token), + "has_access_token": bool(result.access_token), + "has_refresh_token": bool(result.refresh_token), + "registration_entry_flow": configured_entry_flow, + "registration_entry_flow_effective": effective_entry_flow, + # 对齐 K:\1\2:原生入口允许无 session_token 成功,但会标记待补。 + "session_token_pending": (effective_entry_flow == "native") and (not bool(result.session_token)), } return result @@ -989,6 +2796,7 @@ class RegistrationEngine: password=result.password, client_id=settings.openai_client_id, session_token=result.session_token, + cookies=self._dump_session_cookies(), email_service=self.email_service.service_type.value, email_service_id=self.email_info.get("service_id") if self.email_info else None, account_id=result.account_id, diff --git a/src/core/timezone_utils.py b/src/core/timezone_utils.py new file mode 100644 index 0000000..73051c9 --- /dev/null +++ b/src/core/timezone_utils.py @@ -0,0 +1,61 @@ +""" +时区工具(统一使用北京时间/上海时区展示) +""" + +from __future__ import annotations + +import os +import time +from datetime import datetime, timezone, timedelta + +try: + from zoneinfo import ZoneInfo +except Exception: # pragma: no cover + ZoneInfo = None # type: ignore[assignment] + + +UTC = timezone.utc +if ZoneInfo is not None: + SHANGHAI_TZ = ZoneInfo("Asia/Shanghai") +else: # 兼容极端环境 + SHANGHAI_TZ = timezone(timedelta(hours=8)) + + +def apply_process_timezone() -> None: + """ + 尝试将进程默认时区设置为 Asia/Shanghai。 + """ + try: + os.environ.setdefault("TZ", "Asia/Shanghai") + if hasattr(time, "tzset"): + time.tzset() + except Exception: + # 不阻断主流程 + pass + + +def now_shanghai() -> datetime: + return datetime.now(UTC).astimezone(SHANGHAI_TZ) + + +def to_utc(dt: datetime | None) -> datetime | None: + if dt is None: + return None + if dt.tzinfo is None: + return dt.replace(tzinfo=UTC) + return dt.astimezone(UTC) + + +def to_shanghai(dt: datetime | None) -> datetime | None: + if dt is None: + return None + if dt.tzinfo is None: + # 历史库里是 naive UTC,按 UTC 解释再转上海 + dt = dt.replace(tzinfo=UTC) + return dt.astimezone(SHANGHAI_TZ) + + +def to_shanghai_iso(dt: datetime | None) -> str | None: + local_dt = to_shanghai(dt) + return local_dt.isoformat() if local_dt else None + diff --git a/src/core/upload/sub2api_upload.py b/src/core/upload/sub2api_upload.py index 35e12f7..8df7960 100644 --- a/src/core/upload/sub2api_upload.py +++ b/src/core/upload/sub2api_upload.py @@ -3,165 +3,25 @@ Sub2API 账号上传功能 将账号以 sub2api-data 格式批量导入到 Sub2API 平台 """ +import json import logging from datetime import datetime, timezone -from typing import Any, List, Optional, Tuple +from typing import List, Tuple, Optional from curl_cffi import requests as cffi_requests -from ...database.models import Account from ...database.session import get_db +from ...database.models import Account logger = logging.getLogger(__name__) -def _extract_sub2api_data(response) -> Any: - try: - payload = response.json() - except Exception: - payload = None - - if response.status_code >= 400: - if isinstance(payload, dict) and payload.get("message"): - raise ValueError(str(payload["message"])) - response_text = (response.text or "").strip() - raise ValueError( - f"HTTP {response.status_code}" + (f" - {response_text[:200]}" if response_text else "") - ) - - if isinstance(payload, dict): - if payload.get("code") not in (None, 0): - raise ValueError(str(payload.get("message") or "Sub2API 返回错误")) - return payload.get("data", payload) - - if payload is None: - raise ValueError("Sub2API 返回了无法解析的响应") - - return payload - - -def _normalize_remote_sub2api_proxy(proxy: dict) -> dict: - protocol = str(proxy.get("protocol") or "").strip() - host = str(proxy.get("host") or "").strip() - try: - port = int(proxy.get("port") or 0) - except (TypeError, ValueError): - port = 0 - - normalized = { - "id": proxy.get("id"), - "name": str(proxy.get("name") or "").strip(), - "protocol": protocol, - "host": host, - "port": port, - "username": str(proxy.get("username") or "").strip(), - "password": str(proxy.get("password") or "").strip(), - "status": str(proxy.get("status") or "inactive").strip() or "inactive", - } - if not normalized["name"]: - if normalized["id"] is not None: - normalized["name"] = f"Proxy {normalized['id']}" - else: - normalized["name"] = f"{protocol}://{host}:{port}" - return normalized - - -def _build_sub2api_proxy_key(proxy: dict) -> str: - normalized = _normalize_remote_sub2api_proxy(proxy) - return ( - f"{normalized['protocol']}|{normalized['host']}|{normalized['port']}|" - f"{normalized['username']}|{normalized['password']}" - ) - - -def _build_sub2api_proxy_payload(proxy: dict) -> dict: - normalized = _normalize_remote_sub2api_proxy(proxy) - if not normalized["protocol"] or not normalized["host"] or normalized["port"] <= 0: - raise ValueError(f"远端 Sub2API 代理 {normalized['id']} 配置不完整,无法生成上传数据") - - return { - "proxy_key": _build_sub2api_proxy_key(normalized), - "name": normalized["name"], - "protocol": normalized["protocol"], - "host": normalized["host"], - "port": normalized["port"], - "username": normalized["username"], - "password": normalized["password"], - "status": normalized["status"], - } - - -def fetch_remote_sub2api_proxies(api_url: str, api_key: str) -> List[dict]: - if not api_url: - raise ValueError("Sub2API URL 未配置") - if not api_key: - raise ValueError("Sub2API API Key 未配置") - - url = api_url.rstrip("/") + "/api/v1/admin/proxies/all" - - try: - response = cffi_requests.get( - url, - headers={"x-api-key": api_key}, - proxies=None, - timeout=15, - impersonate="chrome110", - ) - data = _extract_sub2api_data(response) - if not isinstance(data, list): - raise ValueError("远端 Sub2API 代理列表格式异常") - return [_normalize_remote_sub2api_proxy(item) for item in data if isinstance(item, dict)] - except ValueError: - raise - except cffi_requests.exceptions.ConnectionError as e: - raise ValueError(f"无法连接到远端 Sub2API 服务: {e}") from e - except cffi_requests.exceptions.Timeout as e: - raise ValueError("拉取远端 Sub2API 代理列表超时") from e - except Exception as e: - logger.error(f"拉取远端 Sub2API 代理列表异常: {e}") - raise ValueError(f"拉取远端 Sub2API 代理列表失败: {e}") from e - - -def fetch_remote_sub2api_proxy(api_url: str, api_key: str, proxy_id: int) -> dict: - if proxy_id is None: - raise ValueError("远端 Sub2API 代理 ID 不能为空") - if not api_url: - raise ValueError("Sub2API URL 未配置") - if not api_key: - raise ValueError("Sub2API API Key 未配置") - - url = api_url.rstrip("/") + f"/api/v1/admin/proxies/{proxy_id}" - - try: - response = cffi_requests.get( - url, - headers={"x-api-key": api_key}, - proxies=None, - timeout=15, - impersonate="chrome110", - ) - data = _extract_sub2api_data(response) - if not isinstance(data, dict): - raise ValueError("远端 Sub2API 代理详情格式异常") - return _normalize_remote_sub2api_proxy(data) - except ValueError: - raise - except cffi_requests.exceptions.ConnectionError as e: - raise ValueError(f"无法连接到远端 Sub2API 服务: {e}") from e - except cffi_requests.exceptions.Timeout as e: - raise ValueError("拉取远端 Sub2API 代理详情超时") from e - except Exception as e: - logger.error(f"拉取远端 Sub2API 代理详情异常: {e}") - raise ValueError(f"拉取远端 Sub2API 代理详情失败: {e}") from e - - def upload_to_sub2api( accounts: List[Account], api_url: str, api_key: str, concurrency: int = 3, priority: int = 50, - proxy_id: Optional[int] = None, ) -> Tuple[bool, str]: """ 上传账号列表到 Sub2API 平台(不走代理) @@ -172,7 +32,6 @@ def upload_to_sub2api( api_key: Admin API Key(x-api-key header) concurrency: 账号并发数,默认 3 priority: 账号优先级,默认 50 - proxy_id: 远端 Sub2API 代理 ID,用于生成 Sub2API 识别的 proxy_key(可选) Returns: (成功标志, 消息) @@ -188,20 +47,12 @@ def upload_to_sub2api( exported_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") - proxy_payload = None - if proxy_id is not None: - try: - remote_proxy = fetch_remote_sub2api_proxy(api_url, api_key, proxy_id) - proxy_payload = _build_sub2api_proxy_payload(remote_proxy) - except ValueError as e: - return False, str(e) - account_items = [] for acc in accounts: if not acc.access_token: continue expires_at = int(acc.expires_at.timestamp()) if acc.expires_at else 0 - account_item = { + account_items.append({ "name": acc.email, "platform": "openai", "type": "oauth", @@ -231,10 +82,7 @@ def upload_to_sub2api( "priority": priority, "rate_multiplier": 1, "auto_pause_on_expired": True, - } - if proxy_payload is not None: - account_item["proxy_key"] = proxy_payload["proxy_key"] - account_items.append(account_item) + }) if not account_items: return False, "所有账号均缺少 access_token,无法上传" @@ -244,7 +92,7 @@ def upload_to_sub2api( "type": "sub2api-data", "version": 1, "exported_at": exported_at, - "proxies": [proxy_payload] if proxy_payload is not None else [], + "proxies": [], "accounts": account_items, }, "skip_default_group_bind": True, @@ -290,7 +138,6 @@ def batch_upload_to_sub2api( api_key: str, concurrency: int = 3, priority: int = 50, - proxy_id: Optional[int] = None, ) -> dict: """ 批量上传指定 ID 的账号到 Sub2API 平台 @@ -322,14 +169,7 @@ def batch_upload_to_sub2api( if not accounts: return results - success, message = upload_to_sub2api( - accounts, - api_url, - api_key, - concurrency, - priority, - proxy_id=proxy_id, - ) + success, message = upload_to_sub2api(accounts, api_url, api_key, concurrency, priority) if success: for acc in accounts: diff --git a/src/core/utils.py b/src/core/utils.py index 80ab61f..b2bd4ff 100644 --- a/src/core/utils.py +++ b/src/core/utils.py @@ -14,12 +14,25 @@ import logging import base64 import re import uuid -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional, Union, Callable from pathlib import Path from ..config.constants import PASSWORD_CHARSET, DEFAULT_PASSWORD_LENGTH from ..config.settings import get_settings +from .timezone_utils import SHANGHAI_TZ + + +class ShanghaiTimeFormatter(logging.Formatter): + """ + 强制日志 asctime 输出为上海时间,避免容器/服务器时区差异。 + """ + + def formatTime(self, record, datefmt=None): # noqa: N802 + dt = datetime.fromtimestamp(record.created, tz=timezone.utc).astimezone(SHANGHAI_TZ) + if datefmt: + return dt.strftime(datefmt) + return dt.strftime("%Y-%m-%d %H:%M:%S") def setup_logging( @@ -51,7 +64,7 @@ def setup_logging( root_logger.handlers.clear() # 创建格式化器 - formatter = logging.Formatter(log_format) + formatter = ShanghaiTimeFormatter(log_format) # 控制台处理器 console_handler = logging.StreamHandler(sys.stdout) diff --git a/src/database/crud.py b/src/database/crud.py index e14dd3c..1e5609b 100644 --- a/src/database/crud.py +++ b/src/database/crud.py @@ -27,6 +27,7 @@ def create_account( access_token: Optional[str] = None, refresh_token: Optional[str] = None, id_token: Optional[str] = None, + cookies: Optional[str] = None, proxy_used: Optional[str] = None, expires_at: Optional['datetime'] = None, extra_data: Optional[Dict[str, Any]] = None, @@ -46,6 +47,7 @@ def create_account( access_token=access_token, refresh_token=refresh_token, id_token=id_token, + cookies=cookies, proxy_used=proxy_used, expires_at=expires_at, extra_data=extra_data or {}, @@ -596,15 +598,13 @@ def create_sub2api_service( api_url: str, api_key: str, enabled: bool = True, - priority: int = 0, - default_remote_proxy_id: Optional[int] = None, + priority: int = 0 ) -> Sub2ApiService: """创建 Sub2API 服务配置""" svc = Sub2ApiService( name=name, api_url=api_url, api_key=api_key, - default_remote_proxy_id=default_remote_proxy_id, enabled=enabled, priority=priority, ) @@ -713,4 +713,4 @@ def delete_tm_service(db: Session, service_id: int) -> bool: return False db.delete(svc) db.commit() - return True \ No newline at end of file + return True diff --git a/src/database/models.py b/src/database/models.py index 17880e0..2dda5c8 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -58,6 +58,7 @@ class Account(Base): cookies = Column(Text) # 完整 cookie 字符串,用于支付请求 created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + bind_card_tasks = relationship("BindCardTask", back_populates="account") def to_dict(self) -> Dict[str, Any]: """转换为字典""" @@ -119,6 +120,64 @@ class RegistrationTask(Base): email_service = relationship('EmailService') +class BindCardTask(Base): + """绑卡任务表""" + __tablename__ = "bind_card_tasks" + + id = Column(Integer, primary_key=True, autoincrement=True) + account_id = Column(Integer, ForeignKey("accounts.id"), nullable=False, index=True) + plan_type = Column(String(20), nullable=False) # plus / team + workspace_name = Column(String(255)) + price_interval = Column(String(20)) + seat_quantity = Column(Integer) + country = Column(String(10), default="US") + currency = Column(String(10), default="USD") + checkout_url = Column(Text, nullable=False) + checkout_session_id = Column(String(120)) + publishable_key = Column(String(255)) + client_secret = Column(Text) + checkout_source = Column(String(50)) # openai_checkout / aimizy_fallback + bind_mode = Column(String(30), default="semi_auto") # semi_auto / third_party / local_auto + status = Column(String(20), default="link_ready") # link_ready / opened / waiting_user_action / verifying / completed / failed + last_error = Column(Text) + opened_at = Column(DateTime) + last_checked_at = Column(DateTime) + completed_at = Column(DateTime) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # 关系 + account = relationship("Account", back_populates="bind_card_tasks") + + +class AppLog(Base): + """应用日志表(后台日志监控)""" + __tablename__ = "app_logs" + + id = Column(Integer, primary_key=True, autoincrement=True) + level = Column(String(20), nullable=False, index=True) + logger = Column(String(255), nullable=False, index=True) + module = Column(String(255)) + pathname = Column(String(500)) + lineno = Column(Integer) + message = Column(Text, nullable=False) + exception = Column(Text) + created_at = Column(DateTime, default=datetime.utcnow, index=True) + + def to_dict(self) -> Dict[str, Any]: + return { + "id": self.id, + "level": self.level, + "logger": self.logger, + "module": self.module, + "pathname": self.pathname, + "lineno": self.lineno, + "message": self.message, + "exception": self.exception, + "created_at": self.created_at.isoformat() if self.created_at else None, + } + + class Setting(Base): """系统设置表""" __tablename__ = 'settings' @@ -152,7 +211,6 @@ class Sub2ApiService(Base): name = Column(String(100), nullable=False) # 服务名称 api_url = Column(String(500), nullable=False) # API URL (host) api_key = Column(Text, nullable=False) # x-api-key - default_remote_proxy_id = Column(Integer, nullable=True) # 默认远端代理 ID enabled = Column(Boolean, default=True) priority = Column(Integer, default=0) # 优先级 created_at = Column(DateTime, default=datetime.utcnow) @@ -227,4 +285,4 @@ class Proxy(Base): if self.username and self.password: auth = f"{self.username}:{self.password}@" - return f"{scheme}://{auth}{self.host}:{self.port}" \ No newline at end of file + return f"{scheme}://{auth}{self.host}:{self.port}" diff --git a/src/database/session.py b/src/database/session.py index 4c1dd24..4d35759 100644 --- a/src/database/session.py +++ b/src/database/session.py @@ -111,7 +111,10 @@ class DatabaseSessionManager: ("accounts", "subscription_at", "DATETIME"), ("accounts", "cookies", "TEXT"), ("proxies", "is_default", "BOOLEAN DEFAULT 0"), - ("sub2api_services", "default_remote_proxy_id", "INTEGER"), + ("bind_card_tasks", "checkout_session_id", "VARCHAR(120)"), + ("bind_card_tasks", "publishable_key", "VARCHAR(255)"), + ("bind_card_tasks", "client_secret", "TEXT"), + ("bind_card_tasks", "bind_mode", "VARCHAR(30) DEFAULT 'semi_auto'"), ] # 确保新表存在(create_tables 已处理,此处兜底) diff --git a/src/services/outlook/email_parser.py b/src/services/outlook/email_parser.py index 84d5228..0ca66c3 100644 --- a/src/services/outlook/email_parser.py +++ b/src/services/outlook/email_parser.py @@ -129,6 +129,7 @@ class EmailParser: target_email: Optional[str] = None, min_timestamp: int = 0, used_codes: Optional[set] = None, + used_fingerprints: Optional[set] = None, ) -> Optional[str]: """ 从邮件列表中查找验证码 @@ -137,12 +138,17 @@ class EmailParser: emails: 邮件列表 target_email: 目标邮箱地址 min_timestamp: 最小时间戳(用于过滤旧邮件) - used_codes: 已使用的验证码集合(用于去重) + used_codes: 兼容旧参数(仅在无邮件 ID/时间时兜底) + used_fingerprints: 已使用邮件指纹集合,指纹规则为 "时间戳|邮件ID|验证码" Returns: 验证码字符串,如果未找到返回 None """ - used_codes = used_codes or set() + # 注意:不能用 `or set()`,否则传入空集合时会被替换,导致跨轮询去重失效。 + if used_codes is None: + used_codes = set() + if used_fingerprints is None: + used_fingerprints = set() for email in emails: # 时间戳过滤 @@ -158,11 +164,22 @@ class EmailParser: # 提取验证码 code = self.extract_verification_code(email) if code: - # 去重检查 - if code in used_codes: - logger.debug(f"跳过已使用的验证码: {code}") + mail_ts = int(email.received_timestamp or 0) + mail_id = str(email.id or "").strip() or "-" + fingerprint = f"{mail_ts}|{mail_id}|{code}" + + # 主去重:时间 + 邮件ID + 验证码 + if fingerprint in used_fingerprints: + logger.debug(f"跳过已使用验证码指纹: {fingerprint}") continue + # 兜底去重:如果邮件没有 ID 且没有时间戳,才退回到纯验证码去重 + if mail_id == "-" and mail_ts <= 0 and code in used_codes: + logger.debug(f"跳过已使用的验证码(兜底): {code}") + continue + + used_fingerprints.add(fingerprint) + used_codes.add(code) logger.info( f"[{target_email or 'unknown'}] 找到验证码: {code}, " f"邮件主题: {email.subject[:30]}" diff --git a/src/services/outlook/providers/graph_api.py b/src/services/outlook/providers/graph_api.py index 00cec80..318e85b 100644 --- a/src/services/outlook/providers/graph_api.py +++ b/src/services/outlook/providers/graph_api.py @@ -28,7 +28,13 @@ class GraphAPIProvider(OutlookProvider): # Graph API 端点 GRAPH_API_BASE = "https://graph.microsoft.com/v1.0" - MESSAGES_ENDPOINT = "/me/mailFolders/inbox/messages" + # 验证邮件可能落在垃圾箱/已删除,需多文件夹轮询 + MESSAGE_FOLDERS = [ + "inbox", + "junkemail", + "deleteditems", + "archive", + ] @property def provider_type(self) -> ProviderType: @@ -112,66 +118,66 @@ class GraphAPIProvider(OutlookProvider): self.record_failure("无法获取 Access Token") return [] - # 构建 API 请求 - url = f"{self.GRAPH_API_BASE}{self.MESSAGES_ENDPOINT}" - - params = { - "$top": count, - "$select": "id,subject,from,toRecipients,receivedDateTime,isRead,hasAttachments,bodyPreview,body", - "$orderby": "receivedDateTime desc", - } - - # 只获取未读邮件 - if only_unseen: - params["$filter"] = "isRead eq false" - # 构建代理配置 proxies = None if self.config.proxy_url: proxies = {"http": self.config.proxy_url, "https": self.config.proxy_url} - - # 发送请求(curl_cffi 自动对 params 进行 URL 编码) - resp = _requests.get( - url, - params=params, - headers={ - "Authorization": f"Bearer {token}", - "Accept": "application/json", - "Prefer": "outlook.body-content-type='text'", - }, - proxies=proxies, - timeout=self.config.timeout, - impersonate="chrome110", - ) - - if resp.status_code == 401: - # Token 无 Graph 权限(client_id 未授权),清除缓存但不记录健康失败 - # 避免因权限不足导致健康检查器禁用该提供者,影响其他账户 - if self._token_manager: - self._token_manager.clear_cache() - self._connected = False - logger.warning(f"[{self.account.email}] Graph API 返回 401,client_id 可能无 Graph 权限,跳过") - return [] - - if resp.status_code != 200: - error_body = resp.text[:200] - self.record_failure(f"HTTP {resp.status_code}: {error_body}") - logger.error(f"[{self.account.email}] Graph API 请求失败: HTTP {resp.status_code}") - return [] - - data = resp.json() - - # 解析邮件 - messages = data.get("value", []) emails = [] + seen_ids = set() - for msg in messages: - try: - email_msg = self._parse_graph_message(msg) - if email_msg: + for folder in self.MESSAGE_FOLDERS: + url = f"{self.GRAPH_API_BASE}/me/mailFolders/{folder}/messages" + params = { + "$top": count, + "$select": "id,subject,from,toRecipients,receivedDateTime,isRead,hasAttachments,bodyPreview,body", + "$orderby": "receivedDateTime desc", + } + if only_unseen: + params["$filter"] = "isRead eq false" + + resp = _requests.get( + url, + params=params, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/json", + "Prefer": "outlook.body-content-type='text'", + }, + proxies=proxies, + timeout=self.config.timeout, + impersonate="chrome110", + ) + + if resp.status_code == 401: + # Token 无 Graph 权限(client_id 未授权),清除缓存但不记录健康失败 + if self._token_manager: + self._token_manager.clear_cache() + self._connected = False + logger.warning(f"[{self.account.email}] Graph API 返回 401,client_id 可能无 Graph 权限,跳过") + return [] + + if resp.status_code != 200: + logger.debug(f"[{self.account.email}] Graph API 跳过文件夹 {folder}: HTTP {resp.status_code}") + continue + + data = resp.json() + messages = data.get("value", []) + if not messages: + continue + + for msg in messages: + try: + email_msg = self._parse_graph_message(msg) + if not email_msg: + continue + key = email_msg.id or "" + if key and key in seen_ids: + continue + if key: + seen_ids.add(key) emails.append(email_msg) - except Exception as e: - logger.warning(f"[{self.account.email}] 解析 Graph API 邮件失败: {e}") + except Exception as e: + logger.warning(f"[{self.account.email}] 解析 Graph API 邮件失败: {e}") self.record_success() return emails diff --git a/src/services/outlook/providers/imap_new.py b/src/services/outlook/providers/imap_new.py index 5daa2f3..7ea1425 100644 --- a/src/services/outlook/providers/imap_new.py +++ b/src/services/outlook/providers/imap_new.py @@ -166,28 +166,41 @@ class IMAPNewProvider(OutlookProvider): return [] try: - # 选择收件箱 - self._conn.select("INBOX", readonly=True) - - # 搜索邮件 flag = "UNSEEN" if only_unseen else "ALL" - status, data = self._conn.search(None, flag) - - if status != "OK" or not data or not data[0]: - return [] - - # 获取最新的邮件 ID - ids = data[0].split() - recent_ids = ids[-count:][::-1] - emails = [] - for msg_id in recent_ids: + seen_keys = set() + + for mailbox in IMAPOldProvider.SEARCH_MAILBOXES: try: - email_msg = self._fetch_email(msg_id) - if email_msg: - emails.append(email_msg) + status, _ = self._conn.select(mailbox, readonly=True) + if status != "OK": + continue + + status, data = self._conn.search(None, flag) + if status != "OK" or not data or not data[0]: + continue + + ids = data[0].split() + recent_ids = ids[-count:][::-1] + + for msg_id in recent_ids: + try: + email_msg = self._fetch_email(msg_id) + if not email_msg: + continue + + dedupe_key = email_msg.id or f"{mailbox}:{msg_id.decode(errors='ignore')}" + if dedupe_key in seen_keys: + continue + + seen_keys.add(dedupe_key) + emails.append(email_msg) + except Exception as e: + logger.warning( + f"[{self.account.email}] 解析邮件失败 ({mailbox}, ID: {msg_id}): {e}" + ) except Exception as e: - logger.warning(f"[{self.account.email}] 解析邮件失败 (ID: {msg_id}): {e}") + logger.debug(f"[{self.account.email}] 跳过邮箱文件夹 {mailbox}: {e}") return emails diff --git a/src/services/outlook/providers/imap_old.py b/src/services/outlook/providers/imap_old.py index e46f3ed..0c19775 100644 --- a/src/services/outlook/providers/imap_old.py +++ b/src/services/outlook/providers/imap_old.py @@ -28,6 +28,18 @@ class IMAPOldProvider(OutlookProvider): # IMAP 服务器配置 IMAP_HOST = "outlook.office365.com" IMAP_PORT = 993 + # 验证邮件有时会进入垃圾邮件/存档等文件夹,需多文件夹轮询 + SEARCH_MAILBOXES = [ + "INBOX", + "Junk", + "Junk Email", + "Junk E-mail", + "Spam", + "Deleted Items", + "Trash", + "Clutter", + "Archive", + ] @property def provider_type(self) -> ProviderType: @@ -163,28 +175,41 @@ class IMAPOldProvider(OutlookProvider): return [] try: - # 选择收件箱 - self._conn.select("INBOX", readonly=True) - - # 搜索邮件 flag = "UNSEEN" if only_unseen else "ALL" - status, data = self._conn.search(None, flag) - - if status != "OK" or not data or not data[0]: - return [] - - # 获取最新的邮件 ID - ids = data[0].split() - recent_ids = ids[-count:][::-1] # 倒序,最新的在前 - emails = [] - for msg_id in recent_ids: + seen_keys = set() + + for mailbox in self.SEARCH_MAILBOXES: try: - email_msg = self._fetch_email(msg_id) - if email_msg: - emails.append(email_msg) + status, _ = self._conn.select(mailbox, readonly=True) + if status != "OK": + continue + + status, data = self._conn.search(None, flag) + if status != "OK" or not data or not data[0]: + continue + + ids = data[0].split() + recent_ids = ids[-count:][::-1] # 倒序,最新的在前 + + for msg_id in recent_ids: + try: + email_msg = self._fetch_email(msg_id) + if not email_msg: + continue + + dedupe_key = email_msg.id or f"{mailbox}:{msg_id.decode(errors='ignore')}" + if dedupe_key in seen_keys: + continue + + seen_keys.add(dedupe_key) + emails.append(email_msg) + except Exception as e: + logger.warning( + f"[{self.account.email}] 解析邮件失败 ({mailbox}, ID: {msg_id}): {e}" + ) except Exception as e: - logger.warning(f"[{self.account.email}] 解析邮件失败 (ID: {msg_id}): {e}") + logger.debug(f"[{self.account.email}] 跳过邮箱文件夹 {mailbox}: {e}") return emails diff --git a/src/services/outlook/service.py b/src/services/outlook/service.py index 321d8b3..858bf61 100644 --- a/src/services/outlook/service.py +++ b/src/services/outlook/service.py @@ -33,6 +33,10 @@ DEFAULT_PROVIDER_PRIORITY = [ ProviderType.GRAPH_API, ] +# OTP 发送时间的容忍偏差(秒) +# 与 clean 版本保持一致,避免把上一阶段验证码误判为当前验证码。 +OTP_TIME_SKEW_SECONDS = 5 + def get_email_code_settings() -> dict: """获取验证码等待配置""" @@ -141,8 +145,10 @@ class OutlookService(BaseEmailService): # IMAP 连接限制(防止限流) self._imap_semaphore = threading.Semaphore(5) - # 验证码去重机制 + # 验证码去重机制(按“时间戳+邮件ID+验证码”指纹) self._used_codes: Dict[str, set] = {} + # 验证码阶段标记(按 otp_sent_at 重置去重,避免“第二封验证码与第一封相同”被误判为旧码) + self._used_codes_stage_marker: Dict[str, int] = {} def _get_provider( self, @@ -333,13 +339,29 @@ class OutlookService(BaseEmailService): f"提供者优先级: {[p.value for p in self.provider_priority]}" ) - # 初始化验证码去重集合 - if email not in self._used_codes: - self._used_codes[email] = set() - used_codes = self._used_codes[email] + # 初始化验证码指纹去重集合 + email_key = str(email or "").strip().lower() + if email_key not in self._used_codes: + self._used_codes[email_key] = set() + used_fingerprints = self._used_codes[email_key] - # 计算最小时间戳(留出 60 秒时钟偏差) - min_timestamp = (otp_sent_at - 60) if otp_sent_at else 0 + # 按 OTP 发送时间重置去重集合,避免不同阶段共用同一验证码时被误跳过 + if otp_sent_at: + try: + stage_marker = int(float(otp_sent_at)) + prev_marker = self._used_codes_stage_marker.get(email_key) + if prev_marker is None or abs(stage_marker - prev_marker) > 3: + if used_fingerprints: + logger.info( + f"[{email}] 检测到新的验证码阶段,重置去重缓存(上一阶段已记 {len(used_fingerprints)} 条指纹)" + ) + used_fingerprints.clear() + self._used_codes_stage_marker[email_key] = stage_marker + except Exception: + pass + + # 计算最小时间戳(仅容忍小时钟偏差,避免命中上一阶段旧验证码) + min_timestamp = (otp_sent_at - OTP_TIME_SKEW_SECONDS) if otp_sent_at else 0 start_time = time.time() poll_count = 0 @@ -368,11 +390,10 @@ class OutlookService(BaseEmailService): emails, target_email=email, min_timestamp=min_timestamp, - used_codes=used_codes, + used_fingerprints=used_fingerprints, ) if code: - used_codes.add(code) elapsed = int(time.time() - start_time) logger.info( f"[{email}] 找到验证码: {code}," diff --git a/src/services/temp_mail.py b/src/services/temp_mail.py index 8d6bcbd..cbbaea8 100644 --- a/src/services/temp_mail.py +++ b/src/services/temp_mail.py @@ -8,16 +8,18 @@ import re import time import json import logging +from datetime import datetime, timezone from email import message_from_string from email.header import decode_header, make_header from email.message import Message from email.policy import default as email_policy +from email.utils import parsedate_to_datetime from html import unescape -from typing import Optional, Dict, Any, List +from typing import Optional, Dict, Any, List, Tuple from .base import BaseEmailService, EmailServiceError, EmailServiceType from ..core.http_client import HTTPClient, RequestConfig -from ..config.constants import OTP_CODE_PATTERN +from ..config.constants import OTP_CODE_PATTERN, OTP_CODE_SEMANTIC_PATTERN logger = logging.getLogger(__name__) @@ -67,6 +69,8 @@ class TempMailService(BaseEmailService): # 邮箱缓存:email -> {jwt, address} self._email_cache: Dict[str, Dict[str, Any]] = {} + # 记录每个邮箱上一次成功使用的邮件 ID,避免重复使用旧验证码 + self._last_used_mail_ids: Dict[str, str] = {} def _decode_mime_header(self, value: str) -> str: """解码 MIME 头,兼容 RFC 2047 编码主题。""" @@ -159,13 +163,326 @@ class TempMailService(BaseEmailService): "raw": raw, } + def _is_openai_otp_mail(self, sender: str, subject: str, body: str, raw: str) -> bool: + """ + 判断是否是 OpenAI 验证码邮件。 + 只看 openai 关键字容易误命中营销/通知邮件,这里增加 OTP 语义词过滤。 + """ + sender_l = str(sender or "").lower() + subject_l = str(subject or "").lower() + body_l = str(body or "").lower() + raw_l = str(raw or "").lower() + blob = f"{sender_l}\n{subject_l}\n{body_l}\n{raw_l}" + + if "openai" not in sender_l and "openai" not in blob: + return False + + otp_keywords = ( + "verification code", + "verify", + "one-time code", + "one time code", + "otp", + "log in", + "login", + "security code", + "验证码", + ) + return any(keyword in blob for keyword in otp_keywords) + + def _extract_otp_code(self, content: str, pattern: str) -> Tuple[Optional[str], bool]: + """ + 提取验证码并返回是否语义命中。 + 优先语义匹配(code is 123456),降低误匹配邮件正文中随机 6 位数字的概率。 + """ + text = str(content or "") + if not text: + return None, False + + semantic_match = re.search(OTP_CODE_SEMANTIC_PATTERN, text, re.IGNORECASE) + if semantic_match: + return semantic_match.group(1), True + + simple_match = re.search(pattern, text) + if simple_match: + return simple_match.group(1), False + + return None, False + def _admin_headers(self) -> Dict[str, str]: """构造 admin 请求头""" - return { + headers = { "x-admin-auth": self.config["admin_password"], "Content-Type": "application/json", "Accept": "application/json", } + custom_auth = (self.config.get("custom_auth") or "").strip() + if custom_auth: + headers["x-custom-auth"] = custom_auth + return headers + + def _extract_mails_from_response(self, response: Any) -> List[Dict[str, Any]]: + """ + 从不同返回结构中提取邮件列表。 + + 兼容以下常见格式: + - {"results": [...]} + - {"mails": [...]} + - {"data": [...]} + - {"items": [...]} + - 直接返回 [...] + """ + if isinstance(response, list): + return [mail for mail in response if isinstance(mail, dict)] + + if not isinstance(response, dict): + return [] + + for key in ("results", "mails", "data", "items", "list"): + value = response.get(key) + if isinstance(value, list): + return [mail for mail in value if isinstance(mail, dict)] + + return [] + + def _mail_appears_for_email(self, mail: Dict[str, Any], email: str) -> bool: + """判断邮件是否属于指定邮箱。""" + target = (email or "").strip().lower() + if not target: + return False + + candidate_fields = ( + mail.get("address"), + mail.get("email"), + mail.get("to"), + mail.get("to_address"), + mail.get("toAddress"), + mail.get("target"), + mail.get("recipient"), + ) + for value in candidate_fields: + text = str(value or "").strip().lower() + if text and target in text: + return True + + parsed = self._extract_mail_fields(mail) + text_blob = "\n".join( + [ + str(parsed.get("sender") or ""), + str(parsed.get("subject") or ""), + str(parsed.get("body") or ""), + str(parsed.get("raw") or ""), + ] + ).lower() + return target in text_blob + + def _fetch_mails_once(self, email: str, jwt: Optional[str], email_id: Optional[str] = None) -> List[Dict[str, Any]]: + """ + 获取一次邮件列表,按新旧接口顺序回退: + 1) /api/mails (Bearer jwt) + 2) /user_api/mails (x-user-token) + 3) /admin/mails (按地址过滤) + 4) /admin/mails (不过滤,客户端二次筛选) + """ + attempts: List[Dict[str, Any]] = [] + if jwt: + attempts.extend([ + { + "path": "/api/mails", + "params": {"limit": 50, "offset": 0}, + "headers": { + "Authorization": f"Bearer {jwt}", + "Accept": "application/json", + }, + }, + { + "path": "/user_api/mails", + "params": {"limit": 50, "offset": 0}, + "headers": { + "x-user-token": jwt, + "Accept": "application/json", + }, + }, + ]) + + attempts.append({ + "path": "/admin/mails", + "params": {"limit": 80, "offset": 0, "address": email}, + "headers": {"Accept": "application/json"}, + }) + if email_id and email_id != email: + attempts.append({ + "path": "/admin/mails", + "params": {"limit": 80, "offset": 0, "address": email_id}, + "headers": {"Accept": "application/json"}, + }) + attempts.append({ + "path": "/admin/mails", + "params": {"limit": 120, "offset": 0}, + "headers": {"Accept": "application/json"}, + }) + + for attempt in attempts: + path = attempt["path"] + try: + response = self._make_request( + "GET", + path, + params=attempt["params"], + headers=attempt["headers"], + ) + mails = self._extract_mails_from_response(response) + if mails and "address" not in attempt["params"]: + mails = [mail for mail in mails if self._mail_appears_for_email(mail, email)] + if mails: + return mails + logger.debug(f"TempMail 接口 {path} 返回无可用邮件列表: {response}") + except Exception as e: + logger.debug(f"TempMail 接口 {path} 读取失败,尝试回退: {e}") + + return [] + + def _extract_mail_detail_from_response(self, response: Any) -> Optional[Dict[str, Any]]: + """从详情接口响应里提取单封邮件对象。""" + if isinstance(response, dict): + if response: + if all(k in response for k in ("subject", "text")) or response.get("raw"): + return response + for key in ("mail", "data", "result", "item"): + value = response.get(key) + if isinstance(value, dict): + return value + return None + + def _fetch_mail_detail(self, mail_id: str, jwt: Optional[str]) -> Optional[Dict[str, Any]]: + """ + 尝试获取单封邮件详情(部分部署的列表接口只返回摘要,不含正文)。 + """ + if not mail_id: + return None + + attempts: List[Dict[str, Any]] = [] + if jwt: + attempts.extend([ + { + "path": f"/api/mails/{mail_id}", + "headers": { + "Authorization": f"Bearer {jwt}", + "Accept": "application/json", + }, + }, + { + "path": f"/user_api/mails/{mail_id}", + "headers": { + "x-user-token": jwt, + "Accept": "application/json", + }, + }, + ]) + attempts.append( + { + "path": f"/admin/mails/{mail_id}", + "headers": {"Accept": "application/json"}, + } + ) + + for attempt in attempts: + try: + response = self._make_request("GET", attempt["path"], headers=attempt["headers"]) + detail = self._extract_mail_detail_from_response(response) + if detail: + return detail + except Exception as e: + logger.debug(f"TempMail 详情接口 {attempt['path']} 读取失败: {e}") + return None + + def _parse_mail_timestamp(self, value: Any) -> Optional[float]: + """将邮件时间字段解析为 Unix 时间戳(秒)。""" + if value is None: + return None + + if isinstance(value, (int, float)): + ts = float(value) + # 兼容毫秒时间戳 + if ts > 10**12: + ts = ts / 1000.0 + return ts if ts > 0 else None + + text = str(value).strip() + if not text: + return None + + if text.isdigit(): + ts = float(text) + if ts > 10**12: + ts = ts / 1000.0 + return ts if ts > 0 else None + + try: + ts = float(text) + if ts > 10**12: + ts = ts / 1000.0 + if ts > 0: + return ts + except ValueError: + pass + + iso_text = text + if iso_text.endswith("Z"): + iso_text = iso_text[:-1] + "+00:00" + + try: + dt = datetime.fromisoformat(iso_text) + if dt.tzinfo is None: + # Worker 侧常返回无时区时间,默认按 UTC 解析,避免被本机时区误差误判为旧邮件。 + dt = dt.replace(tzinfo=timezone.utc) + return dt.timestamp() + except ValueError: + pass + + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M:%S.%f"): + try: + dt = datetime.strptime(text, fmt).replace(tzinfo=timezone.utc) + return dt.timestamp() + except ValueError: + continue + + return None + + def _extract_mail_timestamp(self, mail: Dict[str, Any]) -> Optional[float]: + """从不同字段中提取邮件时间戳。""" + for key in ("createdAt", "created_at", "date", "created", "timestamp", "time"): + ts = self._parse_mail_timestamp(mail.get(key)) + if ts is not None: + return ts + # 某些 Worker 只在 raw 里带 Date 头,这里做二次解析。 + raw = str(mail.get("raw") or "").strip() + if raw: + try: + message = message_from_string(raw, policy=email_policy) + date_header = str(message.get("Date") or "").strip() + if date_header: + dt = parsedate_to_datetime(date_header) + if dt is not None: + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.timestamp() + except Exception: + pass + return None + + def _extract_mail_id(self, mail: Dict[str, Any]) -> str: + """提取邮件唯一标识,兼容不同字段;缺失时生成稳定回退 ID。""" + for key in ("id", "mail_id", "mailId", "_id", "uuid"): + value = mail.get(key) + if value is not None and str(value).strip(): + return str(value).strip() + + fallback = "|".join( + str(mail.get(key) or "").strip() + for key in ("createdAt", "created_at", "date", "source", "from", "subject", "title") + ) + return fallback or str(hash(json.dumps(mail, sort_keys=True, ensure_ascii=False))) def _make_request(self, method: str, path: str, **kwargs) -> Any: """ @@ -247,6 +564,12 @@ class TempMailService(BaseEmailService): address = response.get("address", "").strip() jwt = response.get("jwt", "").strip() + address_id = str( + response.get("address_id") + or response.get("id") + or response.get("addressId") + or "" + ).strip() if not address: raise EmailServiceError(f"API 返回数据不完整: {response}") @@ -254,6 +577,7 @@ class TempMailService(BaseEmailService): email_info = { "email": address, "jwt": jwt, + "address_id": address_id, "service_id": address, "id": address, "created_at": time.time(), @@ -288,7 +612,7 @@ class TempMailService(BaseEmailService): email_id: 未使用,保留接口兼容 timeout: 超时时间(秒) pattern: 验证码正则 - otp_sent_at: OTP 发送时间戳(暂未使用) + otp_sent_at: OTP 发送时间戳(用于过滤旧邮件) Returns: 验证码字符串,超时返回 None @@ -297,40 +621,55 @@ class TempMailService(BaseEmailService): start_time = time.time() seen_mail_ids: set = set() + last_used_mail_id = self._last_used_mail_ids.get(email) + unknown_ts_grace_seconds = 15 # 优先使用用户级 JWT,回退到 admin API cached = self._email_cache.get(email, {}) jwt = cached.get("jwt") + address_id = ( + str(cached.get("address_id") or "").strip() + or str(email_id or "").strip() + or None + ) + poll_count = 0 while time.time() - start_time < timeout: + poll_count += 1 try: - if jwt: - response = self._make_request( - "GET", - "/user_api/mails", - params={"limit": 20, "offset": 0}, - headers={"x-user-token": jwt, "Content-Type": "application/json", "Accept": "application/json"}, - ) - else: - response = self._make_request( - "GET", - "/admin/mails", - params={"limit": 20, "offset": 0, "address": email}, - ) - - # /user_api/mails 和 /admin/mails 返回格式相同: {"results": [...], "total": N} - mails = response.get("results", []) - if not isinstance(mails, list): + mails = self._fetch_mails_once(email=email, jwt=jwt, email_id=address_id) + if not mails: + if poll_count == 1 or poll_count % 5 == 0: + logger.info( + f"TempMail 轮询[{email}] 第 {poll_count} 次: 暂无邮件(已等待 {int(time.time() - start_time)}s)" + ) time.sleep(3) continue + if poll_count == 1 or poll_count % 3 == 0: + logger.info( + f"TempMail 轮询[{email}] 第 {poll_count} 次: 收到 {len(mails)} 封候选邮件" + ) + + candidates: List[Dict[str, Any]] = [] + unknown_ts_candidates: List[Dict[str, Any]] = [] + for mail in mails: - mail_id = mail.get("id") - if not mail_id or mail_id in seen_mail_ids: + mail_id = self._extract_mail_id(mail) + if mail_id in seen_mail_ids: + continue + + if last_used_mail_id and mail_id == last_used_mail_id: continue seen_mail_ids.add(mail_id) + # 过滤发送验证码之前的旧邮件,避免取到上一轮 OTP + mail_ts = self._extract_mail_timestamp(mail) + if otp_sent_at: + if mail_ts is not None and mail_ts + 2 < otp_sent_at: + continue + parsed = self._extract_mail_fields(mail) sender = parsed["sender"].lower() subject = parsed["subject"] @@ -338,16 +677,86 @@ class TempMailService(BaseEmailService): raw_text = parsed["raw"] content = f"{sender}\n{subject}\n{body_text}\n{raw_text}".strip() - # 只处理 OpenAI 邮件 - if "openai" not in sender and "openai" not in content.lower(): + # 只处理 OpenAI 验证码类邮件(避免误命中通知类邮件) + if not self._is_openai_otp_mail(sender, subject, body_text, raw_text): continue - match = re.search(pattern, content) - if match: - code = match.group(1) - logger.info(f"从 TempMail 邮箱 {email} 找到验证码: {code}") - self.update_status(True) - return code + code, semantic_hit = self._extract_otp_code(content, pattern) + if not code: + # 部分部署列表接口只含摘要;尝试拉单封详情再匹配一次。 + detail = self._fetch_mail_detail(mail_id=mail_id, jwt=jwt) + if detail: + detail_parsed = self._extract_mail_fields(detail) + detail_ts = self._extract_mail_timestamp(detail) + if detail_ts is not None: + mail_ts = detail_ts + detail_content = ( + f"{detail_parsed['sender']}\n" + f"{detail_parsed['subject']}\n" + f"{detail_parsed['body']}\n" + f"{detail_parsed['raw']}" + ).strip() + if not self._is_openai_otp_mail( + detail_parsed["sender"], + detail_parsed["subject"], + detail_parsed["body"], + detail_parsed["raw"], + ): + continue + code, semantic_hit = self._extract_otp_code(detail_content, pattern) + + if not code: + continue + + candidate = { + "mail_id": mail_id, + "code": code, + "mail_ts": mail_ts, + "semantic_hit": bool(semantic_hit), + "is_recent": bool( + otp_sent_at and (mail_ts is not None) and (mail_ts + 2 >= otp_sent_at) + ), + } + if otp_sent_at and mail_ts is None: + unknown_ts_candidates.append(candidate) + else: + candidates.append(candidate) + + elapsed = time.time() - start_time + if otp_sent_at and (not candidates) and unknown_ts_candidates and elapsed < unknown_ts_grace_seconds: + # 先等一小段时间,优先等待可解析时间戳的新邮件,避免立刻捞到历史旧码。 + logger.debug( + "TempMail 轮询[%s]: 存在无时间戳邮件,等待 %.0fs 后再回退使用", + email, + unknown_ts_grace_seconds, + ) + time.sleep(3) + continue + + all_candidates = candidates + unknown_ts_candidates + if all_candidates: + best = sorted( + all_candidates, + key=lambda item: ( + 1 if item.get("is_recent") else 0, + 1 if item.get("mail_ts") is not None else 0, + float(item.get("mail_ts") or 0.0), + 1 if item.get("semantic_hit") else 0, + ), + reverse=True, + )[0] + code = str(best["code"]) + self._last_used_mail_ids[email] = str(best["mail_id"]) + logger.info( + "从 TempMail 邮箱 %s 找到验证码: %s(mail_id=%s ts=%s semantic=%s)", + email, + code, + best["mail_id"], + best.get("mail_ts"), + best.get("semantic_hit"), + ) + self.update_status(True) + return code except Exception as e: logger.debug(f"检查 TempMail 邮件时出错: {e}") diff --git a/src/web/app.py b/src/web/app.py index 09fb509..9bd6a4d 100644 --- a/src/web/app.py +++ b/src/web/app.py @@ -8,7 +8,7 @@ import sys import secrets import hmac import hashlib -from typing import Optional +from typing import Optional, Dict, Any from pathlib import Path from fastapi import FastAPI, Request, Form @@ -92,6 +92,35 @@ def create_app() -> FastAPI: templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) templates.env.globals["static_version"] = _build_static_asset_version(STATIC_DIR) + def _render_template( + request: Request, + name: str, + context: Optional[Dict[str, Any]] = None, + status_code: int = 200, + ) -> HTMLResponse: + """ + 兼容不同 Starlette 版本的 TemplateResponse 签名: + - 旧版: TemplateResponse(name, context, status_code=...) + - 新版: TemplateResponse(request, name, context, status_code=...) + """ + template_context: Dict[str, Any] = {"request": request} + if context: + template_context.update(context) + + try: + return templates.TemplateResponse( + request=request, + name=name, + context=template_context, + status_code=status_code, + ) + except TypeError: + return templates.TemplateResponse( + name, + template_context, + status_code=status_code, + ) + def _auth_token(password: str) -> str: secret = get_settings().webui_secret_key.get_secret_value().encode("utf-8") return hmac.new(secret, password.encode("utf-8"), hashlib.sha256).hexdigest() @@ -107,9 +136,10 @@ def create_app() -> FastAPI: @app.get("/login", response_class=HTMLResponse) async def login_page(request: Request, next: Optional[str] = "/"): """登录页面""" - return templates.TemplateResponse( + return _render_template( + request, "login.html", - {"request": request, "error": "", "next": next or "/"} + {"error": "", "next": next or "/"}, ) @app.post("/login") @@ -117,10 +147,11 @@ def create_app() -> FastAPI: """处理登录提交""" expected = get_settings().webui_access_password.get_secret_value() if not secrets.compare_digest(password, expected): - return templates.TemplateResponse( + return _render_template( + request, "login.html", - {"request": request, "error": "密码错误", "next": next or "/"}, - status_code=401 + {"error": "密码错误", "next": next or "/"}, + status_code=401, ) response = RedirectResponse(url=next or "/", status_code=302) @@ -139,39 +170,68 @@ def create_app() -> FastAPI: """首页 - 注册页面""" if not _is_authenticated(request): return _redirect_to_login(request) - return templates.TemplateResponse("index.html", {"request": request}) + return _render_template(request, "index.html") @app.get("/accounts", response_class=HTMLResponse) async def accounts_page(request: Request): """账号管理页面""" if not _is_authenticated(request): return _redirect_to_login(request) - return templates.TemplateResponse("accounts.html", {"request": request}) + return _render_template(request, "accounts.html") + + @app.get("/accounts-overview", response_class=HTMLResponse) + async def accounts_overview_page(request: Request): + """账号总览页面""" + if not _is_authenticated(request): + return _redirect_to_login(request) + return _render_template(request, "accounts_overview.html") @app.get("/email-services", response_class=HTMLResponse) async def email_services_page(request: Request): """邮箱服务管理页面""" if not _is_authenticated(request): return _redirect_to_login(request) - return templates.TemplateResponse("email_services.html", {"request": request}) + return _render_template(request, "email_services.html") @app.get("/settings", response_class=HTMLResponse) async def settings_page(request: Request): """设置页面""" if not _is_authenticated(request): return _redirect_to_login(request) - return templates.TemplateResponse("settings.html", {"request": request}) + return _render_template(request, "settings.html") @app.get("/payment", response_class=HTMLResponse) async def payment_page(request: Request): """支付页面""" - return templates.TemplateResponse("payment.html", {"request": request}) + return _render_template(request, "payment.html") + + @app.get("/card-pool", response_class=HTMLResponse) + async def card_pool_page(request: Request): + """卡池页面(占位)""" + if not _is_authenticated(request): + return _redirect_to_login(request) + return _render_template(request, "card_pool.html") + + @app.get("/auto-team", response_class=HTMLResponse) + async def auto_team_page(request: Request): + """自动进 Team 页面(占位)""" + if not _is_authenticated(request): + return _redirect_to_login(request) + return _render_template(request, "auto_team.html") + + @app.get("/logs", response_class=HTMLResponse) + async def logs_page(request: Request): + """后台日志页面""" + if not _is_authenticated(request): + return _redirect_to_login(request) + return _render_template(request, "logs.html") @app.on_event("startup") async def startup_event(): """应用启动事件""" import asyncio from ..database.init_db import initialize_database + from ..core.db_logs import cleanup_database_logs # 确保数据库已初始化(reload 模式下子进程也需要初始化) try: @@ -183,6 +243,31 @@ def create_app() -> FastAPI: loop = asyncio.get_event_loop() task_manager.set_loop(loop) + async def run_log_cleanup_once(): + try: + result = await asyncio.to_thread(cleanup_database_logs) + logger.info( + "后台日志清理完成: 删除 %s 条,剩余 %s 条", + result.get("deleted_total", 0), + result.get("remaining", 0), + ) + except Exception as exc: + logger.warning(f"后台日志清理失败: {exc}") + + async def periodic_log_cleanup(): + while True: + try: + await asyncio.sleep(3600) # 每小时清理一次 + await run_log_cleanup_once() + except asyncio.CancelledError: + break + except Exception as exc: + logger.warning(f"后台日志定时清理异常: {exc}") + + # 启动时先执行一次,再开启定时任务 + await run_log_cleanup_once() + app.state.log_cleanup_task = asyncio.create_task(periodic_log_cleanup()) + logger.info("=" * 50) logger.info(f"{settings.app_name} v{settings.app_version} 启动中,程序正在伸懒腰...") logger.info(f"调试模式: {settings.debug}") @@ -192,6 +277,9 @@ def create_app() -> FastAPI: @app.on_event("shutdown") async def shutdown_event(): """应用关闭事件""" + cleanup_task = getattr(app.state, "log_cleanup_task", None) + if cleanup_task: + cleanup_task.cancel() logger.info("应用关闭,今天先收摊啦") return app diff --git a/src/web/routes/__init__.py b/src/web/routes/__init__.py index 7748775..70a2a57 100644 --- a/src/web/routes/__init__.py +++ b/src/web/routes/__init__.py @@ -9,6 +9,7 @@ from .registration import router as registration_router from .settings import router as settings_router from .email import router as email_services_router from .payment import router as payment_router +from .logs import router as logs_router from .upload.cpa_services import router as cpa_services_router from .upload.sub2api_services import router as sub2api_services_router from .upload.tm_services import router as tm_services_router @@ -21,6 +22,7 @@ api_router.include_router(registration_router, prefix="/registration", tags=["re api_router.include_router(settings_router, prefix="/settings", tags=["settings"]) api_router.include_router(email_services_router, prefix="/email-services", tags=["email-services"]) api_router.include_router(payment_router, prefix="/payment", tags=["payment"]) +api_router.include_router(logs_router, prefix="/logs", tags=["logs"]) api_router.include_router(cpa_services_router, prefix="/cpa-services", tags=["cpa-services"]) api_router.include_router(sub2api_services_router, prefix="/sub2api-services", tags=["sub2api-services"]) api_router.include_router(tm_services_router, prefix="/tm-services", tags=["tm-services"]) diff --git a/src/web/routes/accounts.py b/src/web/routes/accounts.py index 5f6c86f..e18b086 100644 --- a/src/web/routes/accounts.py +++ b/src/web/routes/accounts.py @@ -4,16 +4,21 @@ import io import json import logging +import re import zipfile -from datetime import datetime -from typing import List, Optional +import base64 +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional from fastapi import APIRouter, HTTPException, Query, BackgroundTasks, Body from fastapi.responses import StreamingResponse from pydantic import BaseModel +from sqlalchemy import func from ...config.constants import AccountStatus from ...config.settings import get_settings +from ...core.openai.overview import fetch_codex_overview from ...core.openai.token_refresh import refresh_account_token as do_refresh from ...core.openai.token_refresh import validate_account_token as do_validate from ...core.upload.cpa_upload import generate_token_json, batch_upload_to_cpa, upload_to_cpa @@ -28,6 +33,17 @@ from ...database.session import get_db logger = logging.getLogger(__name__) router = APIRouter() +CURRENT_ACCOUNT_SETTING_KEY = "codex.current_account_id" +OVERVIEW_EXTRA_DATA_KEY = "codex_overview" +OVERVIEW_CARD_REMOVED_KEY = "codex_overview_card_removed" +OVERVIEW_CACHE_TTL_SECONDS = 300 # 5 分钟 +PAID_SUBSCRIPTION_TYPES = ("plus", "team") +INVALID_ACCOUNT_STATUSES = ( + AccountStatus.FAILED.value, + AccountStatus.EXPIRED.value, + AccountStatus.BANNED.value, +) + def _get_proxy(request_proxy: Optional[str] = None) -> Optional[str]: """获取代理 URL,策略与注册流程一致:代理列表 → 动态代理 → 静态配置""" @@ -43,6 +59,20 @@ def _get_proxy(request_proxy: Optional[str] = None) -> Optional[str]: return get_settings().proxy_url +def _apply_status_filter(query, status: Optional[str]): + """ + 统一状态筛选: + - failed/invalid 视为“无效账号集合”(failed + expired + banned) + - 其他值按精确状态筛选 + """ + normalized = (status or "").strip().lower() + if not normalized: + return query + if normalized in {"failed", "invalid"}: + return query.filter(Account.status.in_(INVALID_ACCOUNT_STATUSES)) + return query.filter(Account.status == normalized) + + # ============== Pydantic Models ============== class AccountResponse(BaseModel): @@ -54,6 +84,7 @@ class AccountResponse(BaseModel): email_service: str account_id: Optional[str] = None workspace_id: Optional[str] = None + device_id: Optional[str] = None registered_at: Optional[str] = None last_refresh: Optional[str] = None expires_at: Optional[str] = None @@ -61,6 +92,8 @@ class AccountResponse(BaseModel): proxy_used: Optional[str] = None cpa_uploaded: bool = False cpa_uploaded_at: Optional[str] = None + subscription_type: Optional[str] = None + subscription_at: Optional[str] = None cookies: Optional[str] = None created_at: Optional[str] = None updated_at: Optional[str] = None @@ -80,6 +113,64 @@ class AccountUpdateRequest(BaseModel): status: Optional[str] = None metadata: Optional[dict] = None cookies: Optional[str] = None # 完整 cookie 字符串,用于支付请求 + session_token: Optional[str] = None + + +class ManualAccountCreateRequest(BaseModel): + """手动创建账号请求""" + email: str + password: str + email_service: Optional[str] = "manual" + status: Optional[str] = AccountStatus.ACTIVE.value + client_id: Optional[str] = None + account_id: Optional[str] = None + workspace_id: Optional[str] = None + access_token: Optional[str] = None + refresh_token: Optional[str] = None + id_token: Optional[str] = None + session_token: Optional[str] = None + cookies: Optional[str] = None + proxy_used: Optional[str] = None + source: Optional[str] = "manual" + subscription_type: Optional[str] = None + metadata: Optional[dict] = None + + +class AccountImportItem(BaseModel): + """账号导入项(支持按账号详情字段导入)""" + email: str + password: Optional[str] = None + email_service: Optional[str] = "manual" + status: Optional[str] = AccountStatus.ACTIVE.value + client_id: Optional[str] = None + account_id: Optional[str] = None + workspace_id: Optional[str] = None + access_token: Optional[str] = None + refresh_token: Optional[str] = None + id_token: Optional[str] = None + session_token: Optional[str] = None + cookies: Optional[str] = None + proxy_used: Optional[str] = None + source: Optional[str] = "import" + subscription_type: Optional[str] = None + plan_type: Optional[str] = None + auth_mode: Optional[str] = None + user_id: Optional[str] = None + organization_id: Optional[str] = None + account_name: Optional[str] = None + account_structure: Optional[str] = None + tokens: Optional[dict] = None + quota: Optional[dict] = None + tags: Optional[Any] = None + created_at: Optional[Any] = None + last_used: Optional[Any] = None + metadata: Optional[dict] = None + + +class ImportAccountsRequest(BaseModel): + """批量导入账号请求""" + accounts: List[dict] + overwrite: bool = False class BatchDeleteRequest(BaseModel): @@ -97,6 +188,26 @@ class BatchUpdateRequest(BaseModel): status: str +class OverviewRefreshRequest(BaseModel): + """账号总览刷新请求""" + ids: List[int] = [] + force: bool = True + select_all: bool = False + status_filter: Optional[str] = None + email_service_filter: Optional[str] = None + search_filter: Optional[str] = None + proxy: Optional[str] = None + + +class OverviewCardDeleteRequest(BaseModel): + """账号总览卡片删除(仅从卡片移除,不删除账号)""" + ids: List[int] = [] + select_all: bool = False + status_filter: Optional[str] = None + email_service_filter: Optional[str] = None + search_filter: Optional[str] = None + + # ============== Helper Functions ============== def resolve_account_ids( @@ -112,7 +223,7 @@ def resolve_account_ids( return ids query = db.query(Account.id) if status_filter: - query = query.filter(Account.status == status_filter) + query = _apply_status_filter(query, status_filter) if email_service_filter: query = query.filter(Account.email_service == email_service_filter) if search_filter: @@ -133,6 +244,7 @@ def account_to_response(account: Account) -> AccountResponse: email_service=account.email_service, account_id=account.account_id, workspace_id=account.workspace_id, + device_id=_resolve_account_device_id(account), registered_at=account.registered_at.isoformat() if account.registered_at else None, last_refresh=account.last_refresh.isoformat() if account.last_refresh else None, expires_at=account.expires_at.isoformat() if account.expires_at else None, @@ -140,14 +252,616 @@ def account_to_response(account: Account) -> AccountResponse: proxy_used=account.proxy_used, cpa_uploaded=account.cpa_uploaded or False, cpa_uploaded_at=account.cpa_uploaded_at.isoformat() if account.cpa_uploaded_at else None, + subscription_type=account.subscription_type, + subscription_at=account.subscription_at.isoformat() if account.subscription_at else None, cookies=account.cookies, created_at=account.created_at.isoformat() if account.created_at else None, updated_at=account.updated_at.isoformat() if account.updated_at else None, ) +def _extract_cookie_value(cookies_text: Optional[str], cookie_name: str) -> str: + text = str(cookies_text or "") + if not text: + return "" + pattern = re.compile(rf"(?:^|;\s*){re.escape(cookie_name)}=([^;]+)") + match = pattern.search(text) + return str(match.group(1) or "").strip() if match else "" + + +def _extract_session_token_from_cookie_text(cookies_text: Optional[str]) -> str: + """从完整 cookie 字符串中提取 next-auth session token(兼容分片)。""" + text = str(cookies_text or "") + if not text: + return "" + + direct = re.search(r"(?:^|;\s*)__Secure-next-auth\.session-token=([^;]+)", text) + if direct: + return str(direct.group(1) or "").strip() + + parts = re.findall(r"(?:^|;\s*)__Secure-next-auth\.session-token\.(\d+)=([^;]+)", text) + if not parts: + return "" + + chunk_map = {} + for idx, value in parts: + try: + chunk_map[int(idx)] = str(value or "") + except Exception: + continue + if not chunk_map: + return "" + + return "".join(chunk_map[i] for i in sorted(chunk_map.keys())) + + +def _resolve_account_device_id(account: Account) -> str: + """ + 解析账号 device_id(兼容历史数据): + 1) account.device_id(若模型未来扩展该字段) + 2) cookies 里的 oai-did + 3) extra_data 中的 device_id/oai_did/oai-device-id + """ + direct = str(getattr(account, "device_id", "") or "").strip() + if direct: + return direct + + did_in_cookie = _extract_cookie_value(getattr(account, "cookies", None), "oai-did") + if did_in_cookie: + return did_in_cookie + + extra_data = getattr(account, "extra_data", None) + if isinstance(extra_data, dict): + for key in ("device_id", "oai_did", "oai-device-id"): + value = str(extra_data.get(key) or "").strip() + if value: + return value + return "" + + +def _resolve_account_session_token(account: Account) -> str: + """解析账号 session_token(优先 DB 字段,其次 cookies 文本)。""" + db_token = str(getattr(account, "session_token", "") or "").strip() + if db_token: + return db_token + return _extract_session_token_from_cookie_text(getattr(account, "cookies", None)) + + +def _parse_iso_datetime(value: Optional[str]) -> Optional[datetime]: + if not value: + return None + try: + text = value.replace("Z", "+00:00") + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + except Exception: + return None + + +def _normalize_plan_type(raw_plan: Optional[str]) -> str: + value = (raw_plan or "").strip().lower() + if not value: + return "Basic" + if "team" in value or "enterprise" in value: + return "Team" + if "plus" in value: + return "Plus" + if "pro" in value: + return "Pro" + if "free" in value or "basic" in value: + return "Basic" + return value.capitalize() + + +def _build_unknown_quota() -> dict: + return { + "used": None, + "total": None, + "remaining": None, + "percentage": None, + "reset_at": None, + "reset_in_text": "-", + "status": "unknown", + } + + +def _fallback_overview(account: Account, error_message: Optional[str] = None, stale: bool = False) -> dict: + data = { + "plan_type": _normalize_plan_type(account.subscription_type), + "plan_source": "db.subscription_type" if account.subscription_type else "default", + "hourly_quota": _build_unknown_quota(), + "weekly_quota": _build_unknown_quota(), + "code_review_quota": _build_unknown_quota(), + "fetched_at": datetime.now(timezone.utc).isoformat(), + "sources": [], + "stale": stale, + } + if error_message: + data["error"] = error_message + return data + + +def _is_overview_cache_stale(cached_overview: Optional[dict]) -> bool: + if not isinstance(cached_overview, dict): + return True + fetched_at = _parse_iso_datetime(cached_overview.get("fetched_at")) + if not fetched_at: + return True + age = datetime.now(timezone.utc) - fetched_at + return age > timedelta(seconds=OVERVIEW_CACHE_TTL_SECONDS) + + +def _get_current_account_id(db) -> Optional[int]: + setting = crud.get_setting(db, CURRENT_ACCOUNT_SETTING_KEY) + if not setting or not setting.value: + return None + try: + return int(setting.value) + except (TypeError, ValueError): + return None + + +def _set_current_account_id(db, account_id: int): + crud.set_setting( + db, + key=CURRENT_ACCOUNT_SETTING_KEY, + value=str(account_id), + description="当前切换中的 Codex 账号 ID", + category="accounts", + ) + + +def _is_overview_card_removed(account: Account) -> bool: + extra_data = account.extra_data if isinstance(account.extra_data, dict) else {} + return bool(extra_data.get(OVERVIEW_CARD_REMOVED_KEY)) + + +def _set_overview_card_removed(account: Account, removed: bool): + extra_data = account.extra_data if isinstance(account.extra_data, dict) else {} + merged = dict(extra_data) + if removed: + merged[OVERVIEW_CARD_REMOVED_KEY] = True + else: + merged.pop(OVERVIEW_CARD_REMOVED_KEY, None) + account.extra_data = merged + + +def _write_current_account_snapshot(account: Account) -> Optional[str]: + """ + 写入当前账号快照文件,便于外部流程读取当前账号令牌。 + """ + try: + data_dir = Path("data") + data_dir.mkdir(parents=True, exist_ok=True) + output_file = data_dir / "current_codex_account.json" + payload = { + "id": account.id, + "email": account.email, + "plan_type": _normalize_plan_type(account.subscription_type), + "access_token": account.access_token, + "refresh_token": account.refresh_token, + "id_token": account.id_token, + "session_token": account.session_token, + "account_id": account.account_id, + "workspace_id": account.workspace_id, + "updated_at": datetime.now(timezone.utc).isoformat(), + } + output_file.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + return str(output_file) + except Exception as exc: + logger.warning(f"写入 current_codex_account.json 失败: {exc}") + return None + + +def _plan_to_subscription_type(plan_type: Optional[str]) -> Optional[str]: + key = (plan_type or "").strip().lower() + if key.startswith("team"): + return "team" + if key.startswith("plus"): + return "plus" + return None + + +def _normalize_subscription_input(value: Optional[str]) -> Optional[str]: + raw = str(value or "").strip().lower() + if not raw: + return None + if raw in ("team", "enterprise"): + return "team" + if raw in ("plus", "pro"): + return "plus" + if raw in ("free", "basic", "none", "null"): + return None + if "team" in raw: + return "team" + if "plus" in raw or "pro" in raw: + return "plus" + return None + + +def _is_paid_subscription(value: Optional[str]) -> bool: + """是否为付费订阅(plus/team)。""" + normalized = _normalize_subscription_input(value) + return normalized in PAID_SUBSCRIPTION_TYPES + + +def _pick_first_text(*values: Any) -> Optional[str]: + for value in values: + if value is None: + continue + text = str(value).strip() + if text: + return text + return None + + +def _decode_jwt_payload_unverified(token: Optional[str]) -> Dict[str, Any]: + """ + 无签名校验解码 JWT payload,仅用于导入兜底字段提取。 + """ + text = str(token or "").strip() + if not text or "." not in text: + return {} + try: + parts = text.split(".") + if len(parts) < 2: + return {} + payload_b64 = parts[1] + padding = "=" * (-len(payload_b64) % 4) + payload_raw = base64.urlsafe_b64decode((payload_b64 + padding).encode("utf-8")) + payload = json.loads(payload_raw.decode("utf-8")) + return payload if isinstance(payload, dict) else {} + except Exception: + return {} + + +def _get_nested(data: Dict[str, Any], path: List[str]) -> Any: + cur: Any = data + for key in path: + if not isinstance(cur, dict): + return None + cur = cur.get(key) + return cur + + +def _get_account_overview_data( + db, + account: Account, + force_refresh: bool = False, + proxy: Optional[str] = None, + allow_network: bool = True, +) -> tuple[dict, bool]: + updated = False + extra_data = account.extra_data if isinstance(account.extra_data, dict) else {} + cached = extra_data.get(OVERVIEW_EXTRA_DATA_KEY) if isinstance(extra_data, dict) else None + cache_stale = _is_overview_cache_stale(cached) + + if not account.access_token: + if cached: + stale_cached = dict(cached) + stale_cached["stale"] = True + stale_cached["error"] = "missing_access_token" + return stale_cached, updated + return _fallback_overview(account, error_message="missing_access_token"), updated + + if not force_refresh and cached and not cache_stale: + return cached, updated + + # 首屏卡片列表默认走“缓存优先”模式,避免首次进入被远端配额请求阻塞导致网络异常。 + if not allow_network: + if cached: + stale_cached = dict(cached) + if cache_stale: + stale_cached["stale"] = True + stale_cached.setdefault("error", "cache_stale") + return stale_cached, updated + return _fallback_overview(account, error_message="cache_miss", stale=True), updated + + try: + overview = fetch_codex_overview(account, proxy=proxy) + if cached and not force_refresh: + for key in ("hourly_quota", "weekly_quota", "code_review_quota"): + if ( + isinstance(cached.get(key), dict) + and isinstance(overview.get(key), dict) + and overview[key].get("status") == "unknown" + and cached[key].get("status") == "ok" + ): + overview[key] = cached[key] + + # 用高置信度来源同步本地订阅状态,确保 Plus/Team 判断可复用。 + plan_source = str(overview.get("plan_source") or "") + trusted_plan_sources = ( + "me.", + "wham_usage.", + "codex_usage.", + "id_token.", + "access_token.", + ) + if any(plan_source.startswith(prefix) for prefix in trusted_plan_sources): + current_sub = _normalize_subscription_input(account.subscription_type) + detected_sub = _plan_to_subscription_type(overview.get("plan_type")) + # 避免把本地已确认的付费订阅(plus/team)被远端偶发 free/basic 覆盖降级。 + if detected_sub and current_sub != detected_sub: + account.subscription_type = detected_sub + account.subscription_at = datetime.utcnow() if detected_sub else None + updated = True + elif not detected_sub and current_sub in PAID_SUBSCRIPTION_TYPES: + logger.info( + "总览订阅同步跳过降级: email=%s current=%s detected=%s source=%s", + account.email, + current_sub, + detected_sub or "free/basic", + plan_source, + ) + + merged_extra = dict(extra_data) + merged_extra[OVERVIEW_EXTRA_DATA_KEY] = overview + account.extra_data = merged_extra + updated = True + return overview, updated + except Exception as exc: + logger.warning(f"刷新账号[{account.email}]总览失败: {exc}") + if cached: + stale_cached = dict(cached) + stale_cached["stale"] = True + stale_cached["error"] = str(exc) + return stale_cached, updated + return _fallback_overview(account, error_message=str(exc), stale=True), updated + + # ============== API Endpoints ============== +@router.post("", response_model=AccountResponse) +async def create_manual_account(request: ManualAccountCreateRequest): + """ + 手动新增账号(邮箱 + 密码)。 + """ + email = (request.email or "").strip().lower() + password = (request.password or "").strip() + email_service = (request.email_service or "manual").strip() or "manual" + status = request.status or AccountStatus.ACTIVE.value + source = (request.source or "manual").strip() or "manual" + subscription_type = _normalize_subscription_input(request.subscription_type) + + if not email or "@" not in email: + raise HTTPException(status_code=400, detail="邮箱格式不正确") + if not password: + raise HTTPException(status_code=400, detail="密码不能为空") + if status not in [e.value for e in AccountStatus]: + raise HTTPException(status_code=400, detail="无效的状态值") + + with get_db() as db: + exists = crud.get_account_by_email(db, email) + if exists: + raise HTTPException(status_code=409, detail="该邮箱账号已存在") + + try: + account = crud.create_account( + db, + email=email, + password=password, + email_service=email_service, + status=status, + source=source, + client_id=request.client_id, + account_id=request.account_id, + workspace_id=request.workspace_id, + access_token=request.access_token, + refresh_token=request.refresh_token, + id_token=request.id_token, + session_token=request.session_token, + cookies=request.cookies, + proxy_used=request.proxy_used, + extra_data=request.metadata or {}, + ) + if subscription_type: + account.subscription_type = subscription_type + account.subscription_at = datetime.utcnow() + db.commit() + db.refresh(account) + except Exception as exc: + logger.error(f"手动创建账号失败: {exc}") + raise HTTPException(status_code=500, detail="创建账号失败") + + return account_to_response(account) + + +@router.post("/import") +async def import_accounts(request: ImportAccountsRequest): + """ + 一键导入账号(账号总览卡片使用)。 + 支持按账号详情字段导入;可选覆盖同邮箱已有账号。 + """ + items = request.accounts or [] + if not items: + raise HTTPException(status_code=400, detail="导入数据为空") + + max_import = 1000 + if len(items) > max_import: + raise HTTPException(status_code=400, detail=f"单次最多导入 {max_import} 条") + + result = { + "success": True, + "total": len(items), + "created": 0, + "updated": 0, + "skipped": 0, + "failed": 0, + "errors": [], + } + + def _safe_text(value: Optional[str]) -> Optional[str]: + if value is None: + return None + text = str(value).strip() + return text if text else None + + with get_db() as db: + for index, raw_item in enumerate(items, start=1): + if not isinstance(raw_item, dict): + result["failed"] += 1 + result["errors"].append( + {"index": index, "email": "-", "error": "导入项必须是 JSON 对象"} + ) + continue + + try: + item = AccountImportItem.model_validate(raw_item) + except Exception as exc: + result["failed"] += 1 + result["errors"].append( + {"index": index, "email": str(raw_item.get("email") or "-"), "error": f"字段格式错误: {exc}"} + ) + continue + + token_bundle = item.tokens if isinstance(item.tokens, dict) else {} + access_token = _pick_first_text(item.access_token, token_bundle.get("access_token"), token_bundle.get("accessToken")) + refresh_token = _pick_first_text(item.refresh_token, token_bundle.get("refresh_token"), token_bundle.get("refreshToken")) + id_token = _pick_first_text(item.id_token, token_bundle.get("id_token"), token_bundle.get("idToken")) + session_token = _pick_first_text( + item.session_token, + token_bundle.get("session_token"), + token_bundle.get("sessionToken"), + ) + client_id = _pick_first_text(item.client_id, token_bundle.get("client_id"), token_bundle.get("clientId")) + + access_claims = _decode_jwt_payload_unverified(access_token) + id_claims = _decode_jwt_payload_unverified(id_token) + + auth_claims = {} + for claims in (access_claims, id_claims): + auth_obj = _get_nested(claims, ["https://api.openai.com/auth"]) + if isinstance(auth_obj, dict): + auth_claims = auth_obj + break + + account_id_value = _pick_first_text( + item.account_id, + raw_item.get("account_id"), + auth_claims.get("chatgpt_account_id"), + ) + workspace_id_value = _pick_first_text( + item.workspace_id, + raw_item.get("workspace_id"), + account_id_value, + ) + + if not client_id: + id_aud = id_claims.get("aud") + id_aud_first = id_aud[0] if isinstance(id_aud, list) and id_aud else None + client_id = _pick_first_text( + access_claims.get("client_id"), + id_aud_first, + ) + + email = str(item.email or "").strip().lower() + if not email or "@" not in email: + result["failed"] += 1 + result["errors"].append({"index": index, "email": email or "-", "error": "邮箱格式不正确"}) + continue + + status = str(item.status or AccountStatus.ACTIVE.value).strip().lower() + if status not in [e.value for e in AccountStatus]: + status = AccountStatus.ACTIVE.value + + email_service = str(item.email_service or "manual").strip() or "manual" + source = str(item.source or "import").strip() or "import" + subscription_type = ( + _normalize_subscription_input(item.subscription_type) + or _normalize_subscription_input(item.plan_type) + or _normalize_subscription_input(_pick_first_text( + raw_item.get("plan_type"), + auth_claims.get("chatgpt_plan_type"), + )) + ) + metadata = dict(item.metadata) if isinstance(item.metadata, dict) else {} + for extra_key in ( + "id", + "auth_mode", + "user_id", + "organization_id", + "account_name", + "account_structure", + "quota", + "tags", + "created_at", + "last_used", + "usage_updated_at", + "plan_type", + ): + value = raw_item.get(extra_key) + if value is not None: + metadata[extra_key] = value + if isinstance(token_bundle, dict) and token_bundle: + metadata["tokens_shape"] = list(token_bundle.keys()) + + exists = crud.get_account_by_email(db, email) + if exists and not request.overwrite: + result["skipped"] += 1 + continue + + try: + if exists and request.overwrite: + update_payload = { + "password": _safe_text(item.password), + "email_service": email_service, + "status": status, + "client_id": _safe_text(client_id), + "account_id": _safe_text(account_id_value), + "workspace_id": _safe_text(workspace_id_value), + "access_token": _safe_text(access_token), + "refresh_token": _safe_text(refresh_token), + "id_token": _safe_text(id_token), + "session_token": _safe_text(session_token), + "cookies": item.cookies if item.cookies is not None else None, + "proxy_used": _safe_text(item.proxy_used), + "source": source, + "extra_data": metadata, + "last_refresh": datetime.utcnow(), + } + clean_update_payload = {k: v for k, v in update_payload.items() if v is not None} + account = crud.update_account(db, exists.id, **clean_update_payload) + if account is None: + raise RuntimeError("更新账号失败") + account.subscription_type = subscription_type + account.subscription_at = datetime.utcnow() if subscription_type else None + db.commit() + result["updated"] += 1 + continue + + account = crud.create_account( + db, + email=email, + password=_safe_text(item.password), + client_id=_safe_text(client_id), + session_token=_safe_text(session_token), + email_service=email_service, + account_id=_safe_text(account_id_value), + workspace_id=_safe_text(workspace_id_value), + access_token=_safe_text(access_token), + refresh_token=_safe_text(refresh_token), + id_token=_safe_text(id_token), + cookies=item.cookies, + proxy_used=_safe_text(item.proxy_used), + extra_data=metadata, + status=status, + source=source, + ) + if subscription_type: + account.subscription_type = subscription_type + account.subscription_at = datetime.utcnow() + db.commit() + result["created"] += 1 + except Exception as exc: + result["failed"] += 1 + result["errors"].append({"index": index, "email": email, "error": str(exc)}) + + return result + + @router.get("", response_model=AccountListResponse) async def list_accounts( page: int = Query(1, ge=1, description="页码"), @@ -167,7 +881,7 @@ async def list_accounts( # 状态筛选 if status: - query = query.filter(Account.status == status) + query = _apply_status_filter(query, status) # 邮箱服务筛选 if email_service: @@ -194,6 +908,414 @@ async def list_accounts( ) +@router.get("/overview/cards") +async def list_accounts_overview_cards( + refresh: bool = Query(False, description="是否强制刷新远端配额"), + search: Optional[str] = Query(None, description="按邮箱搜索"), + status: Optional[str] = Query(None, description="状态筛选"), + email_service: Optional[str] = Query(None, description="邮箱服务筛选"), + proxy: Optional[str] = Query(None, description="可选代理地址"), +): + """ + 账号总览卡片数据。 + """ + with get_db() as db: + query = db.query(Account).filter( + func.lower(Account.subscription_type).in_(PAID_SUBSCRIPTION_TYPES) + ) + if search: + pattern = f"%{search}%" + query = query.filter((Account.email.ilike(pattern)) | (Account.account_id.ilike(pattern))) + if status: + query = _apply_status_filter(query, status) + if email_service: + query = query.filter(Account.email_service == email_service) + + accounts = [ + account + for account in query.order_by(Account.created_at.desc()).all() + if not _is_overview_card_removed(account) + ] + current_account_id = _get_current_account_id(db) + global_proxy = _get_proxy(proxy) + # 卡片列表接口默认“缓存优先”,避免首次进入或新增卡片后触发全量远端请求造成页面卡死。 + # 需要强制刷新时统一走 /overview/refresh。 + allow_network = False + if refresh: + logger.info("overview/cards 接口忽略 refresh 参数,改由 /overview/refresh 执行远端刷新") + + rows = [] + db_updated = False + + for account in accounts: + account_proxy = (account.proxy_used or "").strip() or global_proxy + overview, updated = _get_account_overview_data( + db, + account, + force_refresh=refresh, + proxy=account_proxy, + allow_network=allow_network, + ) + db_updated = db_updated or updated + + overview_plan_raw = overview.get("plan_type") + db_plan_raw = account.subscription_type + has_db_subscription = bool(str(db_plan_raw or "").strip()) + # 与账号管理保持一致:卡片套餐优先使用 DB 的 subscription_type。 + effective_plan_raw = db_plan_raw if has_db_subscription else overview_plan_raw + effective_plan_source = ( + "db.subscription_type" + if has_db_subscription + else (overview.get("plan_source") or "default") + ) + if not _is_paid_subscription(effective_plan_raw): + # Codex 账号管理仅允许 plus/team 账号进入。 + continue + + rows.append( + { + "id": account.id, + "email": account.email, + "status": account.status, + "email_service": account.email_service, + "created_at": account.created_at.isoformat() if account.created_at else None, + "last_refresh": account.last_refresh.isoformat() if account.last_refresh else None, + "current": account.id == current_account_id, + "has_access_token": bool(account.access_token), + "plan_type": _normalize_plan_type(effective_plan_raw), + "plan_source": effective_plan_source, + "has_plus_or_team": _plan_to_subscription_type(effective_plan_raw) is not None, + "hourly_quota": overview.get("hourly_quota") or _build_unknown_quota(), + "weekly_quota": overview.get("weekly_quota") or _build_unknown_quota(), + "code_review_quota": overview.get("code_review_quota") or _build_unknown_quota(), + "overview_fetched_at": overview.get("fetched_at"), + "overview_stale": bool(overview.get("stale")), + "overview_error": overview.get("error"), + } + ) + + if db_updated: + db.commit() + + return { + "total": len(rows), + "current_account_id": current_account_id, + "cache_ttl_seconds": OVERVIEW_CACHE_TTL_SECONDS, + "network_mode": "refresh" if allow_network else "cache_only", + "proxy": global_proxy or None, + "accounts": rows, + "refreshed_at": datetime.now(timezone.utc).isoformat(), + } + + +@router.get("/overview/cards/addable") +async def list_accounts_overview_addable( + search: Optional[str] = Query(None, description="按邮箱搜索"), + status: Optional[str] = Query(None, description="状态筛选"), + email_service: Optional[str] = Query(None, description="邮箱服务筛选"), +): + """读取已从卡片删除的账号,用于“添加账号”里重新添加。""" + with get_db() as db: + query = db.query(Account) + if search: + pattern = f"%{search}%" + query = query.filter((Account.email.ilike(pattern)) | (Account.account_id.ilike(pattern))) + if status: + query = _apply_status_filter(query, status) + if email_service: + query = query.filter(Account.email_service == email_service) + + accounts = query.order_by(Account.created_at.desc()).all() + rows = [] + for account in accounts: + if not _is_overview_card_removed(account): + continue + if not _is_paid_subscription(account.subscription_type): + continue + rows.append( + { + "id": account.id, + "email": account.email, + "status": account.status, + "email_service": account.email_service, + "subscription_type": account.subscription_type or "free", + "has_access_token": bool(account.access_token), + "created_at": account.created_at.isoformat() if account.created_at else None, + } + ) + + return { + "total": len(rows), + "accounts": rows, + } + + +@router.get("/overview/cards/selectable") +async def list_accounts_overview_selectable( + search: Optional[str] = Query(None, description="按邮箱搜索"), + status: Optional[str] = Query(None, description="状态筛选"), + email_service: Optional[str] = Query(None, description="邮箱服务筛选"), +): + """读取账号管理中的可选账号,用于账号总览添加/重新添加。""" + with get_db() as db: + query = db.query(Account) + if search: + pattern = f"%{search}%" + query = query.filter((Account.email.ilike(pattern)) | (Account.account_id.ilike(pattern))) + if status: + query = _apply_status_filter(query, status) + if email_service: + query = query.filter(Account.email_service == email_service) + + accounts = query.order_by(Account.created_at.desc()).all() + rows = [] + for account in accounts: + # 仅返回当前未在卡片中的账号(即已从卡片移除) + if not _is_overview_card_removed(account): + continue + if not _is_paid_subscription(account.subscription_type): + continue + rows.append( + { + "id": account.id, + "email": account.email, + "password": account.password or "", + "status": account.status, + "email_service": account.email_service, + "subscription_type": account.subscription_type or "free", + "client_id": account.client_id or "", + "account_id": account.account_id or "", + "workspace_id": account.workspace_id or "", + "has_access_token": bool(account.access_token), + "created_at": account.created_at.isoformat() if account.created_at else None, + } + ) + + return { + "total": len(rows), + "accounts": rows, + } + + +@router.post("/overview/cards/remove") +async def remove_accounts_overview_cards(request: OverviewCardDeleteRequest): + """从账号总览卡片移除(软删除,不影响账号管理列表)。""" + with get_db() as db: + ids = resolve_account_ids( + db, + request.ids, + request.select_all, + request.status_filter, + request.email_service_filter, + request.search_filter, + ) + removed_count = 0 + missing_ids = [] + for account_id in ids: + account = crud.get_account_by_id(db, account_id) + if not account: + missing_ids.append(account_id) + continue + if not _is_overview_card_removed(account): + removed_count += 1 + _set_overview_card_removed(account, True) + + db.commit() + return { + "success": True, + "removed_count": removed_count, + "total": len(ids), + "missing_ids": missing_ids, + } + + +@router.post("/overview/cards/{account_id}/restore") +async def restore_accounts_overview_card(account_id: int): + """恢复单个已删除的总览卡片。""" + with get_db() as db: + account = crud.get_account_by_id(db, account_id) + if not account: + raise HTTPException(status_code=404, detail="账号不存在") + if not _is_paid_subscription(account.subscription_type): + raise HTTPException(status_code=400, detail="仅 plus/team 账号可进入 Codex 账号管理") + + _set_overview_card_removed(account, False) + db.commit() + return {"success": True, "id": account.id, "email": account.email} + + +@router.post("/overview/cards/{account_id}/attach") +async def attach_accounts_overview_card(account_id: int): + """从账号管理选择账号附加到总览卡片(已存在时保持幂等)。""" + with get_db() as db: + account = crud.get_account_by_id(db, account_id) + if not account: + raise HTTPException(status_code=404, detail="账号不存在") + if not _is_paid_subscription(account.subscription_type): + raise HTTPException(status_code=400, detail="仅 plus/team 账号可进入 Codex 账号管理") + + was_removed = _is_overview_card_removed(account) + _set_overview_card_removed(account, False) + db.commit() + return { + "success": True, + "id": account.id, + "email": account.email, + "already_in_cards": not was_removed, + } + + +@router.post("/overview/refresh") +async def refresh_accounts_overview(request: OverviewRefreshRequest): + """ + 批量刷新账号总览数据。 + """ + proxy = _get_proxy(request.proxy) + result = {"success_count": 0, "failed_count": 0, "details": []} + + with get_db() as db: + ids = resolve_account_ids( + db, + request.ids, + request.select_all, + request.status_filter, + request.email_service_filter, + request.search_filter, + ) + if not ids: + # 默认仅刷新“卡片里可见的付费账号”,避免无关账号导致全量阻塞。 + candidates = db.query(Account).filter( + func.lower(Account.subscription_type).in_(PAID_SUBSCRIPTION_TYPES) + ).order_by(Account.created_at.desc()).all() + ids = [acc.id for acc in candidates if not _is_overview_card_removed(acc)] + + logger.info( + "账号总览刷新开始: target_count=%s force=%s select_all=%s proxy=%s", + len(ids), + bool(request.force), + bool(request.select_all), + proxy or "-", + ) + + for account_id in ids: + account = crud.get_account_by_id(db, account_id) + if not account: + result["failed_count"] += 1 + result["details"].append({"id": account_id, "success": False, "error": "账号不存在"}) + logger.warning("账号总览刷新失败: account_id=%s error=账号不存在", account_id) + continue + if (not _is_paid_subscription(account.subscription_type)) or _is_overview_card_removed(account): + result["details"].append( + { + "id": account.id, + "email": account.email, + "success": False, + "error": "账号不在 Codex 卡片范围内,已跳过", + } + ) + continue + + account_proxy = (account.proxy_used or "").strip() or proxy + overview, updated = _get_account_overview_data( + db, + account, + force_refresh=request.force, + proxy=account_proxy, + allow_network=True, + ) + if updated: + db.commit() + + if overview.get("hourly_quota", {}).get("status") == "unknown" and overview.get("weekly_quota", {}).get("status") == "unknown": + result["failed_count"] += 1 + result["details"].append( + { + "id": account.id, + "email": account.email, + "success": False, + "error": overview.get("error") or "未获取到配额数据", + } + ) + logger.warning( + "账号总览刷新失败: account_id=%s email=%s error=%s", + account.id, + account.email, + overview.get("error") or "未获取到配额数据", + ) + else: + result["success_count"] += 1 + result["details"].append( + { + "id": account.id, + "email": account.email, + "success": True, + "plan_type": overview.get("plan_type"), + } + ) + logger.info( + "账号总览刷新成功: account_id=%s email=%s plan=%s hourly=%s weekly=%s code_review=%s hourly_source=%s weekly_source=%s", + account.id, + account.email, + overview.get("plan_type") or "-", + overview.get("hourly_quota", {}).get("percentage"), + overview.get("weekly_quota", {}).get("percentage"), + overview.get("code_review_quota", {}).get("percentage"), + overview.get("hourly_quota", {}).get("source"), + overview.get("weekly_quota", {}).get("source"), + ) + + logger.info( + "账号总览刷新完成: success=%s failed=%s", + result["success_count"], + result["failed_count"], + ) + + return result + + +@router.get("/current") +async def get_current_account(): + """获取当前已切换的账号""" + with get_db() as db: + current_id = _get_current_account_id(db) + if not current_id: + return {"current_account_id": None, "account": None} + account = crud.get_account_by_id(db, current_id) + if not account: + return {"current_account_id": None, "account": None} + return { + "current_account_id": account.id, + "account": { + "id": account.id, + "email": account.email, + "status": account.status, + "email_service": account.email_service, + "plan_type": _normalize_plan_type(account.subscription_type), + }, + } + + +@router.post("/{account_id}/switch") +async def switch_current_account(account_id: int): + """ + 一键切换当前账号。 + """ + with get_db() as db: + account = crud.get_account_by_id(db, account_id) + if not account: + raise HTTPException(status_code=404, detail="账号不存在") + + _set_current_account_id(db, account_id) + snapshot_path = _write_current_account_snapshot(account) + + return { + "success": True, + "current_account_id": account_id, + "email": account.email, + "snapshot_file": snapshot_path, + } + + @router.get("/{account_id}", response_model=AccountResponse) async def get_account(account_id: int): """获取单个账号详情""" @@ -212,12 +1334,25 @@ async def get_account_tokens(account_id: int): if not account: raise HTTPException(status_code=404, detail="账号不存在") + resolved_session_token = _resolve_account_session_token(account) + session_source = "db" if str(account.session_token or "").strip() else ("cookies" if resolved_session_token else "none") + + # 若 DB 为空但 cookies 可解析到 session_token,自动回写,避免后续重复解析。 + if resolved_session_token and not str(account.session_token or "").strip(): + account.session_token = resolved_session_token + account.last_refresh = datetime.utcnow() + db.commit() + db.refresh(account) + return { "id": account.id, "email": account.email, "access_token": account.access_token, "refresh_token": account.refresh_token, "id_token": account.id_token, + "session_token": resolved_session_token, + "session_token_source": session_source, + "device_id": _resolve_account_device_id(account), "has_tokens": bool(account.access_token and account.refresh_token), } @@ -245,6 +1380,11 @@ async def update_account(account_id: int, request: AccountUpdateRequest): # 留空则清空,非空则更新 update_data["cookies"] = request.cookies or None + if request.session_token is not None: + # 留空则清空,非空则更新 + update_data["session_token"] = request.session_token or None + update_data["last_refresh"] = datetime.utcnow() + account = crud.update_account(db, account_id, **update_data) return account_to_response(account) @@ -565,6 +1705,82 @@ async def get_accounts_stats(): } +@router.get("/stats/overview") +async def get_accounts_overview(): + """获取账号总览统计信息(用于总览页面)""" + with get_db() as db: + total = db.query(func.count(Account.id)).scalar() or 0 + active_count = db.query(func.count(Account.id)).filter( + Account.status == AccountStatus.ACTIVE.value + ).scalar() or 0 + + with_access_token = db.query(func.count(Account.id)).filter( + Account.access_token.isnot(None), + Account.access_token != "", + ).scalar() or 0 + with_refresh_token = db.query(func.count(Account.id)).filter( + Account.refresh_token.isnot(None), + Account.refresh_token != "", + ).scalar() or 0 + without_access_token = max(total - with_access_token, 0) + + cpa_uploaded_count = db.query(func.count(Account.id)).filter( + Account.cpa_uploaded.is_(True) + ).scalar() or 0 + + status_stats = db.query( + Account.status, + func.count(Account.id), + ).group_by(Account.status).all() + + service_stats = db.query( + Account.email_service, + func.count(Account.id), + ).group_by(Account.email_service).all() + + source_stats = db.query( + Account.source, + func.count(Account.id), + ).group_by(Account.source).all() + + subscription_stats = db.query( + Account.subscription_type, + func.count(Account.id), + ).group_by(Account.subscription_type).all() + + recent_accounts = db.query(Account).order_by(Account.created_at.desc()).limit(10).all() + + return { + "total": total, + "active_count": active_count, + "token_stats": { + "with_access_token": with_access_token, + "with_refresh_token": with_refresh_token, + "without_access_token": without_access_token, + }, + "cpa_uploaded_count": cpa_uploaded_count, + "by_status": {status or "unknown": count for status, count in status_stats}, + "by_email_service": {service or "unknown": count for service, count in service_stats}, + "by_source": {source or "unknown": count for source, count in source_stats}, + "by_subscription": { + (subscription or "free"): count for subscription, count in subscription_stats + }, + "recent_accounts": [ + { + "id": acc.id, + "email": acc.email, + "status": acc.status, + "email_service": acc.email_service, + "source": acc.source, + "subscription_type": acc.subscription_type or "free", + "created_at": acc.created_at.isoformat() if acc.created_at else None, + "last_refresh": acc.last_refresh.isoformat() if acc.last_refresh else None, + } + for acc in recent_accounts + ], + } + + # ============== Token 刷新相关 ============== class TokenRefreshRequest(BaseModel): @@ -678,6 +1894,14 @@ async def batch_validate_tokens(request: BatchValidateRequest): else: results["invalid_count"] += 1 except Exception as e: + # 异常账号兜底打标 failed,保证前端“失败”筛选可见。 + try: + with get_db() as db: + account = crud.get_account_by_id(db, account_id) + if account and account.status != AccountStatus.FAILED.value: + crud.update_account(db, account_id, status=AccountStatus.FAILED.value) + except Exception: + pass results["invalid_count"] += 1 results["details"].append({ "id": account_id, @@ -796,7 +2020,6 @@ class Sub2ApiUploadRequest(BaseModel): service_id: Optional[int] = None concurrency: int = 3 priority: int = 50 - proxy_id: Optional[int] = None # 远端 Sub2API 代理 ID class BatchSub2ApiUploadRequest(BaseModel): @@ -809,7 +2032,6 @@ class BatchSub2ApiUploadRequest(BaseModel): service_id: Optional[int] = None # 指定 Sub2API 服务 ID,不传则使用第一个启用的 concurrency: int = 3 priority: int = 50 - proxy_id: Optional[int] = None # 远端 Sub2API 代理 ID @router.post("/batch-upload-sub2api") @@ -819,7 +2041,6 @@ async def batch_upload_accounts_to_sub2api(request: BatchSub2ApiUploadRequest): # 解析指定的 Sub2API 服务 api_url = None api_key = None - svc = None if request.service_id: with get_db() as db: svc = crud.get_sub2api_service_by_id(db, request.service_id) @@ -831,11 +2052,10 @@ async def batch_upload_accounts_to_sub2api(request: BatchSub2ApiUploadRequest): with get_db() as db: svcs = crud.get_sub2api_services(db, enabled=True) if svcs: - svc = svcs[0] - api_url = svc.api_url - api_key = svc.api_key + api_url = svcs[0].api_url + api_key = svcs[0].api_key - if not api_url or not api_key or not svc: + if not api_url or not api_key: raise HTTPException(status_code=400, detail="未找到可用的 Sub2API 服务,请先在设置中配置") with get_db() as db: @@ -844,13 +2064,10 @@ async def batch_upload_accounts_to_sub2api(request: BatchSub2ApiUploadRequest): request.status_filter, request.email_service_filter, request.search_filter ) - selected_proxy_id = request.proxy_id if "proxy_id" in request.model_fields_set else svc.default_remote_proxy_id - results = batch_upload_to_sub2api( ids, api_url, api_key, concurrency=request.concurrency, priority=request.priority, - proxy_id=selected_proxy_id, ) return results @@ -862,11 +2079,9 @@ async def upload_account_to_sub2api(account_id: int, request: Optional[Sub2ApiUp service_id = request.service_id if request else None concurrency = request.concurrency if request else 3 priority = request.priority if request else 50 - proxy_id = request.proxy_id if request and "proxy_id" in request.model_fields_set else None api_url = None api_key = None - svc = None if service_id: with get_db() as db: svc = crud.get_sub2api_service_by_id(db, service_id) @@ -878,11 +2093,10 @@ async def upload_account_to_sub2api(account_id: int, request: Optional[Sub2ApiUp with get_db() as db: svcs = crud.get_sub2api_services(db, enabled=True) if svcs: - svc = svcs[0] - api_url = svc.api_url - api_key = svc.api_key + api_url = svcs[0].api_url + api_key = svcs[0].api_key - if not api_url or not api_key or not svc: + if not api_url or not api_key: raise HTTPException(status_code=400, detail="未找到可用的 Sub2API 服务,请先在设置中配置") with get_db() as db: @@ -894,9 +2108,7 @@ async def upload_account_to_sub2api(account_id: int, request: Optional[Sub2ApiUp success, message = upload_to_sub2api( [account], api_url, api_key, - concurrency=concurrency, - priority=priority, - proxy_id=proxy_id if request and "proxy_id" in request.model_fields_set else svc.default_remote_proxy_id, + concurrency=concurrency, priority=priority ) if success: return {"success": True, "message": message} diff --git a/src/web/routes/email.py b/src/web/routes/email.py index 5f0123c..6931723 100644 --- a/src/web/routes/email.py +++ b/src/web/routes/email.py @@ -84,7 +84,15 @@ class OutlookBatchImportResponse(BaseModel): # ============== Helper Functions ============== # 敏感字段列表,返回响应时需要过滤 -SENSITIVE_FIELDS = {'password', 'api_key', 'refresh_token', 'access_token', 'admin_token'} +SENSITIVE_FIELDS = { + 'password', + 'api_key', + 'refresh_token', + 'access_token', + 'admin_token', + 'admin_password', + 'custom_auth', +} def filter_sensitive_config(config: Dict[str, Any]) -> Dict[str, Any]: """过滤敏感配置信息""" @@ -210,6 +218,7 @@ async def get_service_types(): "config_fields": [ {"name": "base_url", "label": "Worker 地址", "required": True, "placeholder": "https://mail.example.com"}, {"name": "admin_password", "label": "Admin 密码", "required": True, "secret": True}, + {"name": "custom_auth", "label": "Custom Auth(可选)", "required": False, "secret": True}, {"name": "domain", "label": "邮箱域名", "required": True, "placeholder": "example.com"}, {"name": "enable_prefix", "label": "启用前缀", "required": False, "default": True}, ] diff --git a/src/web/routes/logs.py b/src/web/routes/logs.py new file mode 100644 index 0000000..5a4131a --- /dev/null +++ b/src/web/routes/logs.py @@ -0,0 +1,123 @@ +""" +后台日志 API +""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Optional + +from fastapi import APIRouter, Query, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy import func, or_ + +from ...core.db_logs import cleanup_database_logs +from ...core.timezone_utils import to_shanghai_iso +from ...database.models import AppLog +from ...database.session import get_db + + +router = APIRouter() + + +def _serialize_log_row(row: AppLog) -> dict: + payload = row.to_dict() + payload["created_at"] = to_shanghai_iso(row.created_at) + return payload + + +class CleanupLogsRequest(BaseModel): + retention_days: Optional[int] = Field(default=None, ge=1, le=3650) + max_rows: int = Field(default=50000, ge=1000, le=5000000) + + +@router.get("") +def list_logs( + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(100, ge=1, le=500, description="每页数量"), + level: Optional[str] = Query(None, description="日志级别"), + logger_name: Optional[str] = Query(None, description="logger 名称关键词"), + keyword: Optional[str] = Query(None, description="消息关键词"), + since_minutes: Optional[int] = Query(None, ge=1, le=10080, description="仅返回最近 N 分钟"), +): + with get_db() as db: + query = db.query(AppLog) + + if level: + query = query.filter(AppLog.level == level.upper()) + + if logger_name: + query = query.filter(AppLog.logger.ilike(f"%{logger_name.strip()}%")) + + if keyword: + pattern = f"%{keyword.strip()}%" + query = query.filter( + or_( + AppLog.message.ilike(pattern), + AppLog.logger.ilike(pattern), + AppLog.module.ilike(pattern), + ) + ) + + if since_minutes: + since_at = datetime.utcnow() - timedelta(minutes=since_minutes) + query = query.filter(AppLog.created_at >= since_at) + + total = query.count() + offset = (page - 1) * page_size + rows = ( + query.order_by(AppLog.created_at.desc(), AppLog.id.desc()) + .offset(offset) + .limit(page_size) + .all() + ) + + return { + "total": total, + "page": page, + "page_size": page_size, + "logs": [_serialize_log_row(row) for row in rows], + } + + +@router.get("/stats") +def log_stats(): + with get_db() as db: + total = db.query(func.count(AppLog.id)).scalar() or 0 + latest = db.query(func.max(AppLog.created_at)).scalar() + grouped = db.query(AppLog.level, func.count(AppLog.id)).group_by(AppLog.level).all() + + level_counts = {str(level or "UNKNOWN"): int(count or 0) for level, count in grouped} + return { + "total": int(total), + "latest_at": to_shanghai_iso(latest), + "levels": level_counts, + } + + +@router.post("/cleanup") +def cleanup_logs(request: CleanupLogsRequest): + result = cleanup_database_logs( + retention_days=request.retention_days, + max_rows=request.max_rows, + ) + return {"success": True, **result} + + +@router.delete("") +def clear_logs(confirm: bool = Query(False, description="确认清空日志")): + """ + 清空日志表(硬删除)。 + """ + if not confirm: + raise HTTPException(status_code=400, detail="请传入 confirm=true 以确认清空日志") + + with get_db() as db: + deleted = db.query(AppLog).delete(synchronize_session=False) + db.commit() + + return { + "success": True, + "deleted_total": int(deleted or 0), + "remaining": 0, + } diff --git a/src/web/routes/payment.py b/src/web/routes/payment.py index ef9ff6b..ee27ce5 100644 --- a/src/web/routes/payment.py +++ b/src/web/routes/payment.py @@ -3,39 +3,1958 @@ """ import logging +import os +import re +import uuid from typing import Optional, List from datetime import datetime +import time +from urllib.parse import urlparse, urlunparse -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel, Field +from sqlalchemy import or_ +from sqlalchemy.orm import joinedload +from curl_cffi import requests as cffi_requests from ...database.session import get_db -from ...database.models import Account -from ...database import crud +from ...database.models import Account, BindCardTask, EmailService as EmailServiceModel from ...config.settings import get_settings +from ...config.constants import OPENAI_PAGE_TYPES +from ...services import EmailServiceFactory, EmailServiceType +from ...core.register import RegistrationEngine from .accounts import resolve_account_ids from ...core.openai.payment import ( - generate_plus_link, - generate_team_link, + generate_plus_checkout_bundle, + generate_team_checkout_bundle, + generate_aimizy_payment_link, open_url_incognito, - check_subscription_status, + check_subscription_status_detail, ) +from ...core.openai.browser_bind import auto_bind_checkout_with_playwright +from ...core.openai.random_billing import generate_random_billing_profile +from ...core.openai.token_refresh import TokenRefreshManager +from ...core.dynamic_proxy import get_proxy_url_for_task logger = logging.getLogger(__name__) router = APIRouter() +CHECKOUT_SESSION_REGEX = re.compile(r"\bcs_[A-Za-z0-9_-]+\b", re.IGNORECASE) +THIRD_PARTY_BIND_API_URL_ENV = "BIND_CARD_API_URL" +THIRD_PARTY_BIND_API_KEY_ENV = "BIND_CARD_API_KEY" +THIRD_PARTY_BIND_API_DEFAULT = "https://twilight-river-f148.482091502.workers.dev/" +THIRD_PARTY_BIND_PATH_DEFAULT = "/api/v1/bind-card" +CHECKOUT_CONNECTIVITY_ERROR_KEYWORDS = ( + "failed to connect", + "could not connect to server", + "connection refused", + "timed out", + "timeout", + "temporary failure in name resolution", + "name or service not known", + "proxy connect", + "network is unreachable", + "curl: (7)", + "curl: (28)", + "curl: (35)", + "curl: (56)", +) +REGION_BLOCK_ERROR_KEYWORDS = ( + "unsupported_country_region_territory", + "country, region, or territory not supported", + "request_forbidden", +) +CHECKOUT_COUNTRY_CURRENCY_MAP = { + "US": "USD", + "GB": "GBP", + "CA": "CAD", + "AU": "AUD", + "SG": "SGD", + "HK": "HKD", + "JP": "JPY", + "TR": "TRY", + "IN": "INR", + "BR": "BRL", + "MX": "MXN", + "DE": "EUR", + "FR": "EUR", + "IT": "EUR", + "ES": "EUR", + "EU": "EUR", +} + + +def _is_official_checkout_link(link: Optional[str]) -> bool: + return isinstance(link, str) and link.startswith("https://chatgpt.com/checkout/openai_llc/") + + +def _is_checkout_connectivity_error(err: Exception) -> bool: + text = str(err or "").strip().lower() + if not text: + return False + return any(token in text for token in CHECKOUT_CONNECTIVITY_ERROR_KEYWORDS) + + +def _is_region_block_error_text(text: Optional[str]) -> bool: + raw = str(text or "").strip().lower() + if not raw: + return False + return any(token in raw for token in REGION_BLOCK_ERROR_KEYWORDS) + + +def _normalize_checkout_country(country: Optional[str]) -> str: + code = str(country or "US").strip().upper() + if code in CHECKOUT_COUNTRY_CURRENCY_MAP: + return code + return "US" + + +def _normalize_checkout_currency(country: str, currency: Optional[str]) -> str: + raw = str(currency or "").strip().upper() + if raw: + return raw + return CHECKOUT_COUNTRY_CURRENCY_MAP.get(country, "USD") + + +def _normalize_proxy_value(proxy: Optional[str]) -> str: + return str(proxy or "").strip() + + +def _build_proxy_candidates( + explicit_proxy: Optional[str], + account: Optional[Account] = None, + *, + include_direct: bool = True, +) -> List[Optional[str]]: + """ + 代理候选顺序: + 1) 显式传入 + 2) 账号历史代理(注册时成功线路) + 3) 系统全局代理 + 4) 直连(可选) + """ + candidates: List[Optional[str]] = [] + seen = set() + + account_proxy = _normalize_proxy_value(getattr(account, "proxy_used", None) if account else None) + settings_proxy = _normalize_proxy_value(get_settings().proxy_url) + explicit_proxy_norm = _normalize_proxy_value(explicit_proxy) + + for item in (explicit_proxy_norm, account_proxy, settings_proxy): + if not item or item in seen: + continue + candidates.append(item) + seen.add(item) + + if include_direct: + candidates.append(None) + elif not candidates: + return [] + + if not candidates: + return [None] + return candidates + + +def _resolve_runtime_proxy(explicit_proxy: Optional[str], account: Optional[Account] = None) -> Optional[str]: + """ + 选一个首选代理,给非轮询型接口使用。 + """ + for candidate in _build_proxy_candidates(explicit_proxy, account, include_direct=False): + if candidate: + return candidate + try: + dynamic_proxy = _normalize_proxy_value(get_proxy_url_for_task()) + except Exception: + dynamic_proxy = "" + if dynamic_proxy: + return dynamic_proxy + return None + + +def _serialize_bind_card_task(task: BindCardTask) -> dict: + account_email = task.account.email if task.account else None + return { + "id": task.id, + "account_id": task.account_id, + "account_email": account_email, + "plan_type": task.plan_type, + "workspace_name": task.workspace_name, + "price_interval": task.price_interval, + "seat_quantity": task.seat_quantity, + "country": task.country, + "currency": task.currency, + "checkout_url": task.checkout_url, + "checkout_session_id": task.checkout_session_id, + "publishable_key": task.publishable_key, + "has_client_secret": bool(getattr(task, "client_secret", None)), + "checkout_source": task.checkout_source, + "bind_mode": task.bind_mode or "semi_auto", + "status": task.status, + "last_error": task.last_error, + "opened_at": task.opened_at.isoformat() if task.opened_at else None, + "last_checked_at": task.last_checked_at.isoformat() if task.last_checked_at else None, + "completed_at": task.completed_at.isoformat() if task.completed_at else None, + "created_at": task.created_at.isoformat() if task.created_at else None, + "updated_at": task.updated_at.isoformat() if task.updated_at else None, + } + + +def _extract_checkout_session_id_from_url(url: Optional[str]) -> Optional[str]: + text = str(url or "").strip() + if not text: + return None + match = CHECKOUT_SESSION_REGEX.search(text) + if match: + return match.group(0) + return None + + +def _resolve_account_device_id(account: Account) -> str: + """ + 兼容解析账号 device id。 + 历史模型未包含 device_id 字段,需从 cookies/extra_data 兜底读取。 + """ + direct = str(getattr(account, "device_id", "") or "").strip() + if direct: + return direct + + cookies_text = str(getattr(account, "cookies", "") or "") + if cookies_text: + match = re.search(r"(?:^|;\s*)oai-did=([^;]+)", cookies_text) + if match: + value = str(match.group(1) or "").strip() + if value: + return value + + extra_data = getattr(account, "extra_data", None) + if isinstance(extra_data, dict): + for key in ("device_id", "oai_did", "oai-device-id"): + value = str(extra_data.get(key) or "").strip() + if value: + return value + return str(uuid.uuid4()) + + +def _extract_cookie_value(cookies_text: Optional[str], cookie_name: str) -> str: + text = str(cookies_text or "") + if not text: + return "" + pattern = re.compile(rf"(?:^|;\s*){re.escape(cookie_name)}=([^;]+)") + match = pattern.search(text) + if not match: + return "" + return str(match.group(1) or "").strip() + + +def _extract_session_token_from_cookie_text(cookies_text: Optional[str]) -> str: + text = str(cookies_text or "") + if not text: + return "" + + direct = _extract_cookie_value(text, "__Secure-next-auth.session-token") + if direct: + return direct + + # NextAuth 可能把大 token 分片为 .0/.1/.2... + chunks: dict[int, str] = {} + for raw in text.split(";"): + item = str(raw or "").strip() + if not item or "=" not in item: + continue + name, value = item.split("=", 1) + key = str(name or "").strip() + if not key.startswith("__Secure-next-auth.session-token."): + continue + try: + idx = int(key.rsplit(".", 1)[-1]) + except Exception: + continue + chunks[idx] = str(value or "").strip() + if chunks: + return "".join(chunks[idx] for idx in sorted(chunks.keys())) + return "" + + +def _extract_session_token_from_cookie_jar(cookie_jar) -> str: + try: + direct = str(cookie_jar.get("__Secure-next-auth.session-token") or "").strip() + except Exception: + direct = "" + if direct: + return direct + + chunks: dict[int, str] = {} + try: + items = list(cookie_jar.items()) + except Exception: + items = [] + for key, value in items: + name = str(key or "").strip() + if not name.startswith("__Secure-next-auth.session-token."): + continue + try: + idx = int(name.rsplit(".", 1)[-1]) + except Exception: + continue + chunks[idx] = str(value or "").strip() + if chunks: + return "".join(chunks[idx] for idx in sorted(chunks.keys())) + return "" + + +def _extract_session_token_chunks_from_cookie_text(cookies_text: Optional[str]) -> List[int]: + text = str(cookies_text or "") + if not text: + return [] + indices: List[int] = [] + seen = set() + for raw in text.split(";"): + item = str(raw or "").strip() + if not item or "=" not in item: + continue + name, _ = item.split("=", 1) + key = str(name or "").strip() + if not key.startswith("__Secure-next-auth.session-token."): + continue + try: + idx = int(key.rsplit(".", 1)[-1]) + except Exception: + continue + if idx in seen: + continue + seen.add(idx) + indices.append(idx) + return sorted(indices) + + +def _mask_secret(value: Optional[str], keep_start: int = 6, keep_end: int = 4) -> str: + text = str(value or "").strip() + if not text: + return "" + if len(text) <= keep_start + keep_end + 2: + return "*" * len(text) + return f"{text[:keep_start]}...{text[-keep_end:]}" + + +def _probe_auth_session_context(account: Account, proxy: Optional[str]) -> dict: + """ + 对当前账号做一次实时 session 探测,帮助定位“缺 session token”的根因。 + """ + session = cffi_requests.Session( + impersonate="chrome120", + proxy=proxy, + ) + _seed_cookie_jar_from_text(session, account.cookies) + + device_id = _resolve_account_device_id(account) + if device_id: + try: + session.cookies.set("oai-did", device_id, domain=".chatgpt.com", path="/") + except Exception: + pass + + headers = { + "Accept": "application/json", + "Referer": "https://chatgpt.com/", + "Origin": "https://chatgpt.com", + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + ), + } + access_token = str(account.access_token or "").strip() + if access_token: + headers["Authorization"] = f"Bearer {access_token}" + + try: + # 先热身主页,提升 next-auth 会话链路稳定性 + session.get( + "https://chatgpt.com/", + headers={ + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Referer": "https://chatgpt.com/", + "User-Agent": headers["User-Agent"], + }, + timeout=20, + ) + except Exception: + pass + + result = { + "ok": False, + "http_status": None, + "session_token_found": False, + "session_token_preview": "", + "access_token_in_session_json": False, + "access_token_preview": "", + "error": "", + } + + try: + resp = session.get( + "https://chatgpt.com/api/auth/session", + headers=headers, + timeout=25, + ) + result["http_status"] = int(getattr(resp, "status_code", 0) or 0) + + session_token = _extract_session_token_from_cookie_jar(getattr(resp, "cookies", None)) + if not session_token: + session_token = _extract_session_token_from_cookie_jar(getattr(session, "cookies", None)) + if not session_token: + set_cookie = ( + " | ".join(resp.headers.get_list("set-cookie")) + if hasattr(resp.headers, "get_list") + else str(resp.headers.get("set-cookie") or "") + ) + match_direct = re.search(r"__Secure-next-auth\.session-token=([^;,\s]+)", set_cookie) + if match_direct: + session_token = str(match_direct.group(1) or "").strip() + else: + chunk_matches = re.findall(r"__Secure-next-auth\.session-token\.(\d+)=([^;,\s]+)", set_cookie) + if chunk_matches: + chunk_map = {int(i): v for i, v in chunk_matches if str(i).isdigit()} + if chunk_map: + session_token = "".join(chunk_map[idx] for idx in sorted(chunk_map.keys())) + + payload = {} + try: + payload = resp.json() if resp.content else {} + except Exception: + payload = {} + session_access = str(payload.get("accessToken") or "").strip() + + result["session_token_found"] = bool(session_token) + result["session_token_preview"] = _mask_secret(session_token) + result["access_token_in_session_json"] = bool(session_access) + result["access_token_preview"] = _mask_secret(session_access) + result["ok"] = result["http_status"] == 200 + return result + except Exception as exc: + result["error"] = str(exc) + return result + + +def _force_fetch_nextauth_session_token( + *, + access_token: Optional[str], + cookies_text: Optional[str], + device_id: Optional[str], + proxy: Optional[str], +) -> tuple[str, str]: + """ + 尝试通过 /api/auth/session 强制换取 __Secure-next-auth.session-token。 + Returns: + (session_token, fresh_access_token) + """ + initial_access = str(access_token or "").strip() + latest_access = initial_access + proxy_norm = str(proxy or "").strip() + proxy_candidates: List[Optional[str]] = [proxy_norm] if proxy_norm else [None] + + for proxy_item in proxy_candidates: + session = cffi_requests.Session( + impersonate="chrome120", + proxy=proxy_item, + ) + _seed_cookie_jar_from_text(session, cookies_text) + + did = str(device_id or "").strip() + if did: + try: + session.cookies.set("oai-did", did, domain=".chatgpt.com", path="/") + except Exception: + pass + + headers = { + "Accept": "application/json", + "Referer": "https://chatgpt.com/", + "Origin": "https://chatgpt.com", + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + ), + } + access = latest_access + if access: + headers["Authorization"] = f"Bearer {access}" + + try: + session.get( + "https://chatgpt.com/", + headers={ + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Referer": "https://chatgpt.com/", + "User-Agent": headers["User-Agent"], + }, + timeout=20, + ) + except Exception: + pass + + for _ in range(2): + resp = session.get( + "https://chatgpt.com/api/auth/session", + headers=headers, + timeout=25, + ) + + token = _extract_session_token_from_cookie_jar(getattr(resp, "cookies", None)) + if not token: + token = _extract_session_token_from_cookie_jar(getattr(session, "cookies", None)) + if not token: + set_cookie = ( + " | ".join(resp.headers.get_list("set-cookie")) + if hasattr(resp.headers, "get_list") + else str(resp.headers.get("set-cookie") or "") + ) + match_direct = re.search(r"__Secure-next-auth\.session-token=([^;,\s]+)", set_cookie) + if match_direct: + token = str(match_direct.group(1) or "").strip() + else: + chunk_matches = re.findall(r"__Secure-next-auth\.session-token\.(\d+)=([^;,\s]+)", set_cookie) + if chunk_matches: + chunk_map = {int(i): v for i, v in chunk_matches if str(i).isdigit()} + if chunk_map: + token = "".join(chunk_map[idx] for idx in sorted(chunk_map.keys())) + + fresh_access = "" + try: + data = resp.json() if resp.content else {} + except Exception: + data = {} + if isinstance(data, dict): + fresh_access = str(data.get("accessToken") or "").strip() + + if token: + return token, (fresh_access or access or initial_access) + if fresh_access: + access = fresh_access + latest_access = fresh_access + headers["Authorization"] = f"Bearer {fresh_access}" + + # 常见地区限制:带代理失败时自动切到下一候选(通常是直连) + if proxy_item and resp.status_code in (401, 403) and _is_region_block_error_text(resp.text): + break + + return "", latest_access + + +def _extract_session_token_from_auth_response(resp, session) -> str: + token = _extract_session_token_from_cookie_jar(getattr(resp, "cookies", None)) + if token: + return token + token = _extract_session_token_from_cookie_jar(getattr(session, "cookies", None)) + if token: + return token + + set_cookie = ( + " | ".join(resp.headers.get_list("set-cookie")) + if hasattr(resp.headers, "get_list") + else str(resp.headers.get("set-cookie") or "") + ) + match_direct = re.search(r"__Secure-next-auth\.session-token=([^;,\s]+)", set_cookie) + if match_direct: + return str(match_direct.group(1) or "").strip() + + chunk_matches = re.findall(r"__Secure-next-auth\.session-token\.(\d+)=([^;,\s]+)", set_cookie) + if chunk_matches: + chunk_map = {int(i): v for i, v in chunk_matches if str(i).isdigit()} + if chunk_map: + return "".join(chunk_map[idx] for idx in sorted(chunk_map.keys())) + return "" + + +def _merge_cookie_text_with_session_jar(cookies_text: Optional[str], session) -> str: + merged = str(cookies_text or "").strip() + try: + items = list(session.cookies.items()) + except Exception: + items = [] + for name, value in items: + key = str(name or "").strip() + val = str(value or "").strip() + if not key or not val: + continue + merged = _upsert_cookie(merged, key, val) + return merged + + +def _bootstrap_session_token_by_abcard_bridge(account: Account, proxy: Optional[str]) -> tuple[str, str, str]: + """ + ABCard 同款 next-auth 会话桥接: + 1) /api/auth/csrf + 2) /api/auth/signin/openai + 3) /api/auth/session + Returns: + (session_token, fresh_access_token, merged_cookies_text) + """ + session = cffi_requests.Session( + impersonate="chrome120", + proxy=proxy, + ) + base_cookies = str(account.cookies or "").strip() + _seed_cookie_jar_from_text(session, base_cookies) + + device_id = _resolve_account_device_id(account) + if device_id: + try: + session.cookies.set("oai-did", device_id, domain=".chatgpt.com", path="/") + except Exception: + pass + + ua = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + ) + common_headers = { + "User-Agent": ua, + "Accept": "application/json", + "Referer": "https://chatgpt.com/auth/login", + "Origin": "https://chatgpt.com", + } + + try: + session.get( + "https://chatgpt.com/auth/login", + headers={ + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "User-Agent": ua, + "Referer": "https://chatgpt.com/", + }, + timeout=20, + ) + except Exception: + pass + + csrf_resp = session.get( + "https://chatgpt.com/api/auth/csrf", + headers=common_headers, + timeout=25, + ) + if csrf_resp.status_code >= 400: + raise RuntimeError(f"csrf_failed_http_{csrf_resp.status_code}") + + try: + csrf_token = str((csrf_resp.json() or {}).get("csrfToken") or "").strip() + except Exception: + csrf_token = "" + if not csrf_token: + raise RuntimeError("csrf_token_missing") + + signin_resp = session.post( + "https://chatgpt.com/api/auth/signin/openai", + headers={ + **common_headers, + "Content-Type": "application/x-www-form-urlencoded", + }, + data={ + "csrfToken": csrf_token, + "callbackUrl": "https://chatgpt.com/", + "json": "true", + }, + timeout=25, + ) + if signin_resp.status_code >= 400: + raise RuntimeError(f"signin_openai_failed_http_{signin_resp.status_code}") + + auth_url = "" + try: + auth_url = str((signin_resp.json() or {}).get("url") or "").strip() + except Exception: + auth_url = "" + if auth_url: + try: + session.get( + auth_url, + headers={ + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Referer": "https://chatgpt.com/auth/login", + "User-Agent": ua, + }, + timeout=30, + allow_redirects=True, + ) + except Exception: + pass + + latest_access = str(account.access_token or "").strip() + session_headers = { + "Accept": "application/json", + "Referer": "https://chatgpt.com/", + "Origin": "https://chatgpt.com", + "User-Agent": ua, + } + if latest_access: + session_headers["Authorization"] = f"Bearer {latest_access}" + + session_token = "" + for _ in range(2): + resp = session.get( + "https://chatgpt.com/api/auth/session", + headers=session_headers, + timeout=25, + ) + if resp.status_code >= 400 and not latest_access: + continue + + token_inner = _extract_session_token_from_auth_response(resp, session) + if token_inner: + session_token = token_inner + + try: + payload = resp.json() if resp.content else {} + except Exception: + payload = {} + if isinstance(payload, dict): + fresh_access = str(payload.get("accessToken") or "").strip() + if fresh_access: + latest_access = fresh_access + session_headers["Authorization"] = f"Bearer {fresh_access}" + + if session_token: + break + + merged_cookies = _merge_cookie_text_with_session_jar(base_cookies, session) + if session_token: + merged_cookies = _upsert_cookie(merged_cookies, "__Secure-next-auth.session-token", session_token) + if device_id: + merged_cookies = _upsert_cookie(merged_cookies, "oai-did", device_id) + + return session_token, latest_access, merged_cookies + + +def _normalize_email_service_config_for_session_bootstrap( + service_type: EmailServiceType, + config: Optional[dict], + proxy_url: Optional[str] = None, +) -> dict: + normalized = dict(config or {}) + + if "api_url" in normalized and "base_url" not in normalized: + normalized["base_url"] = normalized.pop("api_url") + + if service_type == EmailServiceType.MOE_MAIL: + if "domain" in normalized and "default_domain" not in normalized: + normalized["default_domain"] = normalized.pop("domain") + elif service_type in (EmailServiceType.TEMP_MAIL, EmailServiceType.FREEMAIL): + if "default_domain" in normalized and "domain" not in normalized: + normalized["domain"] = normalized.pop("default_domain") + elif service_type == EmailServiceType.DUCK_MAIL: + if "domain" in normalized and "default_domain" not in normalized: + normalized["default_domain"] = normalized.pop("domain") + + # IMAP/Outlook 等可按需使用代理;Temp-Mail/Freemail 强制直连。 + if proxy_url and "proxy_url" not in normalized and service_type not in (EmailServiceType.TEMP_MAIL, EmailServiceType.FREEMAIL): + normalized["proxy_url"] = proxy_url + + return normalized + + +def _resolve_email_service_for_account_session_bootstrap(db, account: Account, proxy: Optional[str]): + raw_type = str(account.email_service or "").strip().lower() + if not raw_type: + raise RuntimeError("账号缺少 email_service") + try: + service_type = EmailServiceType(raw_type) + except Exception as exc: + raise RuntimeError(f"不支持的邮箱服务类型: {raw_type}") from exc + + settings = get_settings() + services = ( + db.query(EmailServiceModel) + .filter(EmailServiceModel.service_type == service_type.value, EmailServiceModel.enabled == True) + .order_by(EmailServiceModel.priority.asc(), EmailServiceModel.id.asc()) + .all() + ) + + selected = None + if services: + # Outlook/IMAP 优先匹配同邮箱配置,避免拿错账户。 + if service_type in (EmailServiceType.OUTLOOK, EmailServiceType.IMAP_MAIL): + email_lower = str(account.email or "").strip().lower() + for svc in services: + cfg_email = str((svc.config or {}).get("email") or "").strip().lower() + if cfg_email and cfg_email == email_lower: + selected = svc + break + if not selected: + selected = services[0] + + if selected and selected.config: + config = _normalize_email_service_config_for_session_bootstrap(service_type, selected.config, proxy) + elif service_type == EmailServiceType.TEMPMAIL: + config = { + "base_url": settings.tempmail_base_url, + "timeout": settings.tempmail_timeout, + "max_retries": settings.tempmail_max_retries, + "proxy_url": proxy, + } + else: + raise RuntimeError( + f"未找到可用邮箱服务配置(type={service_type.value}),无法自动获取登录验证码" + ) + + service = EmailServiceFactory.create(service_type, config, name=f"session_bootstrap_{service_type.value}") + return service + + +def _bootstrap_session_token_by_relogin(db, account: Account, proxy: Optional[str]) -> str: + """ + 二级兜底:用账号邮箱+密码走一次登录链路,自动收 OTP 并补齐 session token。 + """ + email = str(account.email or "").strip() + password = str(account.password or "").strip() + if not email or not password: + logger.info( + "会话补全登录跳过:账号缺少邮箱或密码 account_id=%s email=%s", + account.id, + account.email, + ) + return "" + + try: + email_service = _resolve_email_service_for_account_session_bootstrap(db, account, proxy) + except Exception as exc: + logger.warning( + "会话补全登录无法创建邮箱服务: account_id=%s email=%s error=%s", + account.id, + account.email, + exc, + ) + return "" + + engine = RegistrationEngine( + email_service=email_service, + proxy_url=proxy, + callback_logger=lambda msg: logger.info("会话补全登录: %s", msg), + task_uuid=None, + ) + engine.email = email + engine.password = password + engine.email_info = {"service_id": account.email_service_id} if account.email_service_id else {} + + try: + did, sen_token = engine._prepare_authorize_flow("会话补全登录") + if not did: + return "" + if not sen_token: + # 对齐 ABCard:sentinel 偶发失败时,仍尝试无 sentinel 登录链路,避免卡死。 + logger.warning( + "会话补全登录 sentinel 缺失,继续尝试无 sentinel 登录: account_id=%s email=%s", + account.id, + account.email, + ) + + login_start = engine._submit_login_start(did, sen_token) + if not login_start.success: + if _is_region_block_error_text(login_start.error_message): + logger.warning( + "会话补全登录入口地区受限: account_id=%s email=%s proxy=%s error=%s", + account.id, + account.email, + "on" if proxy else "off", + login_start.error_message, + ) + logger.warning( + "会话补全登录入口失败: account_id=%s email=%s error=%s", + account.id, + account.email, + login_start.error_message, + ) + return "" + + if login_start.page_type == OPENAI_PAGE_TYPES["LOGIN_PASSWORD"]: + password_result = engine._submit_login_password() + if not password_result.success or not password_result.is_existing_account: + logger.warning( + "会话补全登录密码阶段失败: account_id=%s email=%s page_type=%s err=%s", + account.id, + account.email, + password_result.page_type, + password_result.error_message, + ) + return "" + elif login_start.page_type != OPENAI_PAGE_TYPES["EMAIL_OTP_VERIFICATION"]: + logger.warning( + "会话补全登录入口返回未知页面: account_id=%s email=%s page_type=%s", + account.id, + account.email, + login_start.page_type, + ) + return "" + + engine._log("等待登录验证码到场,最后这位嘉宾还在路上...") + engine._log("核对登录验证码,验明正身一下...") + if not engine._verify_email_otp_with_retry(stage_label="会话补全验证码", max_attempts=3): + logger.warning( + "会话补全登录验证码阶段失败: account_id=%s email=%s", + account.id, + account.email, + ) + return "" + + fresh_cookies = engine._dump_session_cookies() + # 兜底拼装关键 cookie,避免个别环境 cookie jar 导出不全。 + try: + did_cookie = str(engine.session.cookies.get("oai-did") or "").strip() if engine.session else "" + except Exception: + did_cookie = "" + try: + auth_cookie = str(engine.session.cookies.get("oai-client-auth-session") or "").strip() if engine.session else "" + except Exception: + auth_cookie = "" + if did_cookie: + fresh_cookies = _upsert_cookie(fresh_cookies, "oai-did", did_cookie) + if auth_cookie: + fresh_cookies = _upsert_cookie(fresh_cookies, "oai-client-auth-session", auth_cookie) + + session_token = _extract_session_token_from_cookie_text(fresh_cookies) + forced_access = str(account.access_token or "").strip() + if not session_token: + forced_token, forced_access_new = _force_fetch_nextauth_session_token( + access_token=forced_access, + cookies_text=fresh_cookies, + device_id=did_cookie or _resolve_account_device_id(account), + proxy=proxy, + ) + if forced_token: + session_token = forced_token + fresh_cookies = _upsert_cookie(fresh_cookies, "__Secure-next-auth.session-token", forced_token) + if forced_access_new: + forced_access = forced_access_new + + if not session_token: + logger.warning("会话补全登录未拿到 session_token: account_id=%s email=%s", account.id, account.email) + if fresh_cookies: + account.cookies = fresh_cookies + if forced_access: + account.access_token = forced_access + account.last_refresh = datetime.utcnow() + db.commit() + return "" + + if forced_access: + account.access_token = forced_access + if fresh_cookies: + account.cookies = fresh_cookies + account.session_token = session_token + account.last_refresh = datetime.utcnow() + db.commit() + db.refresh(account) + logger.info("会话补全登录成功: account_id=%s email=%s", account.id, account.email) + return session_token + except Exception as exc: + logger.warning("会话补全登录异常: account_id=%s email=%s error=%s", account.id, account.email, exc) + return "" + + +def _upsert_cookie(cookies_text: Optional[str], cookie_name: str, cookie_value: str) -> str: + target_name = str(cookie_name or "").strip() + target_value = str(cookie_value or "").strip() + if not target_name: + return str(cookies_text or "").strip() + + pairs: List[tuple[str, str]] = [] + seen = False + for item in str(cookies_text or "").split(";"): + raw = str(item or "").strip() + if not raw or "=" not in raw: + continue + name, value = raw.split("=", 1) + name = name.strip() + value = value.strip() + if not name: + continue + if name == target_name: + if target_value: + pairs.append((name, target_value)) + seen = True + else: + pairs.append((name, value)) + + if not seen and target_value: + pairs.append((target_name, target_value)) + + return "; ".join(f"{k}={v}" for k, v in pairs if k) + + +def _seed_cookie_jar_from_text(session, cookies_text: Optional[str]) -> None: + """ + 将 account.cookies 中的键值回灌到会话 cookie jar,便于重定向链正确续会话。 + """ + text = str(cookies_text or "").strip() + if not text: + return + for item in text.split(";"): + raw = str(item or "").strip() + if not raw or "=" not in raw: + continue + name, value = raw.split("=", 1) + key = str(name or "").strip() + val = str(value or "").strip() + if not key: + continue + for domain in (".chatgpt.com", "chatgpt.com"): + try: + session.cookies.set(key, val, domain=domain, path="/") + except Exception: + continue + + +def _bootstrap_session_token_for_local_auto(db, account: Account, proxy: Optional[str]) -> str: + """ + 尝试为 local_auto 自动补齐 session token(避免 cdp_session_missing)。 + """ + existing = str(account.session_token or "").strip() or _extract_session_token_from_cookie_text(account.cookies) + if existing: + if not account.session_token: + account.session_token = existing + account.cookies = _upsert_cookie(account.cookies, "__Secure-next-auth.session-token", existing) + db.commit() + db.refresh(account) + return existing + + def _extract_session_from_response(resp, session) -> str: + token_inner = _extract_session_token_from_cookie_jar(getattr(resp, "cookies", None)) + if token_inner: + return token_inner + token_inner = _extract_session_token_from_cookie_jar(getattr(session, "cookies", None)) + if token_inner: + return token_inner + set_cookie = ( + " | ".join(resp.headers.get_list("set-cookie")) + if hasattr(resp.headers, "get_list") + else str(resp.headers.get("set-cookie") or "") + ) + match_direct = re.search(r"__Secure-next-auth\.session-token=([^;,\s]+)", set_cookie) + if match_direct: + return str(match_direct.group(1) or "").strip() + chunk_matches = re.findall(r"__Secure-next-auth\.session-token\.(\d+)=([^;,\s]+)", set_cookie) + if chunk_matches: + chunk_map = {int(i): v for i, v in chunk_matches if str(i).isdigit()} + if chunk_map: + return "".join(chunk_map[idx] for idx in sorted(chunk_map.keys())) + return "" + + def _request_session_token( + *, + proxy_item: Optional[str], + with_auth: bool, + with_cookies: bool, + ) -> str: + access_token = str(account.access_token or "").strip() + if with_auth and not access_token: + return "" + + session = cffi_requests.Session( + impersonate="chrome120", + proxy=proxy_item, + ) + _seed_cookie_jar_from_text(session, account.cookies) + + device_id = _resolve_account_device_id(account) + if device_id: + try: + session.cookies.set("oai-did", device_id, domain=".chatgpt.com") + except Exception: + pass + + headers = { + "Accept": "application/json", + "Referer": "https://chatgpt.com/", + "Origin": "https://chatgpt.com", + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + ), + } + if with_auth: + headers["Authorization"] = f"Bearer {access_token}" + + # 先热身主页,尽可能让 cookie/session 状态完整再请求 auth/session + try: + session.get( + "https://chatgpt.com/", + headers={ + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Referer": "https://chatgpt.com/", + "User-Agent": headers["User-Agent"], + }, + timeout=25, + ) + except Exception: + pass + + if with_cookies and account.cookies: + try: + session.headers.update({"cookie": str(account.cookies)}) + except Exception: + pass + + resp = session.get( + "https://chatgpt.com/api/auth/session", + headers=headers, + timeout=25, + ) + + token_inner = _extract_session_from_response(resp, session) + try: + data = resp.json() if resp.content else {} + except Exception: + data = {} + if isinstance(data, dict): + fresh_access = str(data.get("accessToken") or "").strip() + if fresh_access: + account.access_token = fresh_access + + if token_inner: + # 若 session 接口返回了新 accessToken,一并回写,避免后续继续用旧 token。 + return token_inner + + # 部分场景 session cookie 在第二次调用才下发,这里补一次重试。 + retry_resp = session.get( + "https://chatgpt.com/api/auth/session", + headers=headers, + timeout=25, + ) + token_retry = _extract_session_from_response(retry_resp, session) + try: + data = retry_resp.json() if retry_resp.content else {} + except Exception: + data = {} + if isinstance(data, dict): + fresh_access = str(data.get("accessToken") or "").strip() + if fresh_access: + account.access_token = fresh_access + if token_retry: + return token_retry + return "" + + # 按 ABCard 风格:先独立会话 + access token,再尝试带 cookies;每组先代理后直连。 + attempt_matrix = [ + (True, False), # with_auth, with_cookies + (True, True), + (False, True), + ] + # 会话补全只走代理网络,避免直连触发地区限制导致 403 卡死。 + proxy_candidates = _build_proxy_candidates(proxy, account, include_direct=False) + if not proxy_candidates: + logger.warning( + "本地自动绑卡会话补全缺少可用代理: account_id=%s email=%s", + account.id, + account.email, + ) + return "" + + errors: List[str] = [] + token = "" + for with_auth, with_cookies in attempt_matrix: + for proxy_item in proxy_candidates: + try: + token = _request_session_token( + proxy_item=proxy_item, + with_auth=with_auth, + with_cookies=with_cookies, + ) + if token: + break + except Exception as exc: + errors.append( + f"proxy={'on' if proxy_item else 'off'} auth={with_auth} cookies={with_cookies} err={exc}" + ) + if token: + break + + # 一级兜底:ABCard 同款 next-auth 桥接链路(csrf -> signin/openai -> auth/session)。 + if not token: + for proxy_item in proxy_candidates: + try: + bridged_token, bridged_access, bridged_cookies = _bootstrap_session_token_by_abcard_bridge( + account=account, + proxy=proxy_item, + ) + if bridged_access: + account.access_token = bridged_access + if bridged_cookies: + account.cookies = bridged_cookies + if bridged_token: + token = bridged_token + break + except Exception as exc: + errors.append(f"abcard_bridge proxy={'on' if proxy_item else 'off'} err={exc}") + + # 若仍失败,尝试刷新 token 后再跑一次核心路径(auth+无cookies) + if not token and (account.refresh_token or account.session_token): + try: + manager = TokenRefreshManager(proxy_url=proxy) + refresh_result = manager.refresh_account(account) + if refresh_result.success: + account.access_token = refresh_result.access_token + if refresh_result.refresh_token: + account.refresh_token = refresh_result.refresh_token + if refresh_result.expires_at: + account.expires_at = refresh_result.expires_at + account.last_refresh = datetime.utcnow() + db.commit() + db.refresh(account) + for proxy_item in proxy_candidates: + try: + token = _request_session_token( + proxy_item=proxy_item, + with_auth=True, + with_cookies=False, + ) + if token: + break + except Exception as exc: + errors.append(f"after_refresh proxy={'on' if proxy_item else 'off'} err={exc}") + except Exception as exc: + errors.append(f"refresh_failed={exc}") + + if not token: + # 二级兜底:走一次账号登录链路(含邮箱验证码)自动补会话。 + # 逐个代理候选尝试(显式/账号历史/全局),避免落到受限直连。 + for relogin_proxy in proxy_candidates: + token = _bootstrap_session_token_by_relogin( + db=db, + account=account, + proxy=relogin_proxy, + ) + if token: + break + + if not token: + if errors: + logger.warning( + "本地自动绑卡会话补全失败: account_id=%s email=%s detail=%s", + account.id, + account.email, + " | ".join(errors[-4:]), + ) + else: + logger.info( + "本地自动绑卡会话补全未命中 session token: account_id=%s email=%s", + account.id, + account.email, + ) + return "" + + account.session_token = token + account.cookies = _upsert_cookie(account.cookies, "__Secure-next-auth.session-token", token) + db.commit() + db.refresh(account) + logger.info( + "本地自动绑卡会话补全成功: account_id=%s email=%s", + account.id, + account.email, + ) + return token + + +def _build_official_checkout_url(checkout_session_id: Optional[str]) -> Optional[str]: + cs_id = str(checkout_session_id or "").strip() + if not cs_id: + return None + return f"https://chatgpt.com/checkout/openai_llc/{cs_id}" + + +def _mask_card_number(number: Optional[str]) -> str: + digits = "".join(ch for ch in str(number or "") if ch.isdigit()) + if not digits: + return "-" + if len(digits) <= 8: + return f"{digits[:2]}****{digits[-2:]}" + return f"{digits[:4]}****{digits[-4:]}" + + +def _mark_task_paid_pending_sync(task: BindCardTask, reason: str) -> None: + now = datetime.utcnow() + task.status = "paid_pending_sync" + task.completed_at = None + task.last_checked_at = now + task.last_error = reason + + +def _resolve_third_party_bind_api_url(request_url: Optional[str]) -> Optional[str]: + raw = ( + str(request_url or "").strip() + or str(os.getenv(THIRD_PARTY_BIND_API_URL_ENV) or "").strip() + or THIRD_PARTY_BIND_API_DEFAULT + ) + normalized = _normalize_third_party_bind_api_url(raw) + return normalized or None + + +def _resolve_third_party_bind_api_key(request_key: Optional[str]) -> Optional[str]: + token = str(request_key or "").strip() or str(os.getenv(THIRD_PARTY_BIND_API_KEY_ENV) or "").strip() + return token or None + + +def _normalize_third_party_bind_api_url(raw_url: Optional[str]) -> Optional[str]: + text = str(raw_url or "").strip() + if not text: + return None + if "://" not in text: + text = "https://" + text + try: + parsed = urlparse(text) + except Exception: + return None + if not parsed.scheme or not parsed.netloc: + return None + path = parsed.path or "" + if not path or path == "/": + path = THIRD_PARTY_BIND_PATH_DEFAULT + path = "/" + path.lstrip("/") + normalized = parsed._replace(path=path, params="", fragment="") + return urlunparse(normalized) + + +def _build_third_party_bind_api_candidates(api_url: str) -> List[str]: + """ + 对第三方地址做容错: + - 支持只给根域名(自动补 /api/v1/bind-card) + - 支持给到 /api/v1(自动补 /bind-card) + - 保留原始路径作为首选 + """ + normalized = _normalize_third_party_bind_api_url(api_url) + if not normalized: + return [] + + candidates: List[str] = [] + + def _append(url: Optional[str]): + value = str(url or "").strip() + if value and value not in candidates: + candidates.append(value) + + _append(normalized) + parsed = urlparse(normalized) + base = f"{parsed.scheme}://{parsed.netloc}" + path = (parsed.path or "").rstrip("/") + lower = path.lower() + + if lower in ("", "/"): + _append(base + THIRD_PARTY_BIND_PATH_DEFAULT) + elif lower.endswith("/api/v1"): + _append(base + path + "/bind-card") + _append(base + THIRD_PARTY_BIND_PATH_DEFAULT) + elif not lower.endswith("/bind-card"): + _append(base + THIRD_PARTY_BIND_PATH_DEFAULT) + + return candidates + + +def _parse_third_party_response(resp) -> dict: + if not (resp.content or b""): + return {"ok": True} + + content_type = (resp.headers.get("content-type") or "").lower() + if "application/json" in content_type: + try: + data = resp.json() + if isinstance(data, dict): + return data + return {"data": data} + except Exception: + pass + + raw = str(resp.text or "").strip() + if raw.startswith("{") and raw.endswith("}"): + try: + data = resp.json() + if isinstance(data, dict): + return data + except Exception: + pass + return {"raw": raw[:1000]} + + +def _invoke_third_party_bind_api( + *, + api_url: str, + api_key: Optional[str], + payload: dict, + proxy: Optional[str] = None, +) -> tuple[dict, str]: + headers = { + "Accept": "*/*", + "Content-Type": "application/json", + "User-Agent": "codex-console2/third-party-bind", + } + key = str(api_key or "").strip() + if key: + headers["X-API-Key"] = key + headers["Authorization"] = f"Bearer {key}" + + url_candidates = _build_third_party_bind_api_candidates(api_url) + if not url_candidates: + raise RuntimeError("第三方绑卡 API 地址无效") + + proxy_candidates: List[Optional[str]] = [] + for value in (proxy, None): + if value not in proxy_candidates: + proxy_candidates.append(value) + + errors: List[str] = [] + for candidate_url in url_candidates: + for proxy_item in proxy_candidates: + proxies = {"http": proxy_item, "https": proxy_item} if proxy_item else None + for attempt in range(1, 3): + try: + resp = cffi_requests.post( + candidate_url, + headers=headers, + json=payload, + proxies=proxies, + timeout=120, + impersonate="chrome110", + ) + + if resp.status_code >= 400: + body = (resp.text or "")[:500] + err = f"{candidate_url} status={resp.status_code} proxy={'on' if proxy_item else 'off'} body={body}" + errors.append(err) + retryable = resp.status_code in (408, 409, 425, 429, 500, 502, 503, 504) + endpoint_maybe_wrong = resp.status_code in (404, 405) + if attempt < 2 and retryable: + time.sleep(0.6 * attempt) + continue + if endpoint_maybe_wrong: + break + raise RuntimeError(f"第三方绑卡请求失败: HTTP {resp.status_code} - {body}") + + parsed = _parse_third_party_response(resp) + if isinstance(parsed, dict): + parsed["_meta_endpoint"] = candidate_url + parsed["_meta_proxy"] = "on" if proxy_item else "off" + parsed["_meta_attempt"] = attempt + return parsed, candidate_url + except Exception as exc: + err = f"{candidate_url} proxy={'on' if proxy_item else 'off'} attempt={attempt} error={exc}" + errors.append(err) + if attempt < 2: + time.sleep(0.6 * attempt) + continue + summary = " | ".join(errors[-4:]) if errors else "unknown_error" + raise RuntimeError(f"第三方绑卡请求失败,已尝试多路由: {summary}") + + +def _sanitize_third_party_response(payload: dict) -> dict: + if not isinstance(payload, dict): + return {"result": str(payload)[:500]} + safe: dict = {} + for key, value in payload.items(): + key_lower = str(key or "").lower() + if any(token in key_lower for token in ("card", "cvc", "cvv", "number", "profile", "pan")): + safe[key] = "***" + continue + if isinstance(value, (str, int, float, bool)) or value is None: + safe[key] = value + else: + safe[key] = str(value)[:500] + return safe + + +def _extract_third_party_status_snapshot(payload: dict) -> dict: + """从第三方返回体中提取支付状态快照(兼容 data/result 嵌套)。""" + if not isinstance(payload, dict): + return {} + + blocks = [payload] + data_block = payload.get("data") + if isinstance(data_block, dict): + blocks.append(data_block) + nested_result = data_block.get("result") + if isinstance(nested_result, dict): + blocks.append(nested_result) + top_result = payload.get("result") + if isinstance(top_result, dict): + blocks.append(top_result) + + def _pick(*keys: str) -> str: + for block in blocks: + for key in keys: + value = block.get(key) + if value is None: + continue + text = str(value).strip() + if text: + return text + return "" + + return { + "payment_status": _pick("payment_status"), + "checkout_status": _pick("checkout_status"), + "setup_intent_status": _pick("setup_intent_status"), + "payment_intent_status": _pick("payment_intent_status"), + "submission_attempt_state": _pick("submission_attempt_state"), + "next_action_type": _pick("next_action_type"), + "failure_reason": _pick("failure_reason", "reason"), + "status": _pick("status", "state"), + "code": _pick("code"), + "message": _pick("message", "error", "detail"), + "task_id": _pick("task_id", "request_id", "job_id"), + "checkout_session_id": _pick("checkout_session_id", "session_id"), + } + + +def _assess_third_party_submission_result(payload: dict) -> dict: + """ + 第三方绑卡结果三态判定: + - success: 已明确支付成功(如 payment_status=paid) + - pending: 已提交但仍需用户挑战/等待异步处理 + - failed: 明确失败 + """ + snapshot = _extract_third_party_status_snapshot(payload) + payment_status = snapshot.get("payment_status", "").strip().lower() + checkout_status = snapshot.get("checkout_status", "").strip().lower() + setup_intent_status = snapshot.get("setup_intent_status", "").strip().lower() + payment_intent_status = snapshot.get("payment_intent_status", "").strip().lower() + submission_state = snapshot.get("submission_attempt_state", "").strip().lower() + next_action_type = snapshot.get("next_action_type", "").strip().lower() + failure_reason = snapshot.get("failure_reason", "").strip().lower() + status_text = snapshot.get("status", "").strip().lower() + message = snapshot.get("message", "").strip() + success_flag = payload.get("success") if isinstance(payload, dict) else None + + # 1) 明确成功信号(你提供的口径:payment_status=paid) + if payment_status in ("paid", "succeeded", "success"): + return {"state": "success", "reason": "", "snapshot": snapshot} + if checkout_status in ("paid", "complete", "completed"): + return {"state": "success", "reason": "", "snapshot": snapshot} + + # 2) 明确失败信号 + fail_tokens = ("fail", "error", "invalid", "denied", "forbidden", "declined", "reject", "cancel") + if success_flag is False: + reason = message or failure_reason or "third_party_success_false" + return {"state": "failed", "reason": reason[:300], "snapshot": snapshot} + if payment_status in ("failed", "canceled", "cancelled", "expired", "void"): + reason = failure_reason or message or f"payment_status={payment_status}" + return {"state": "failed", "reason": reason[:300], "snapshot": snapshot} + if any(token in status_text for token in fail_tokens): + reason = message or failure_reason or f"status={status_text}" + return {"state": "failed", "reason": reason[:300], "snapshot": snapshot} + if any(token in failure_reason for token in fail_tokens): + reason = failure_reason or message or "failure_reason" + return {"state": "failed", "reason": reason[:300], "snapshot": snapshot} + low_message = message.lower() + if low_message and any(token in low_message for token in fail_tokens): + return {"state": "failed", "reason": message[:300], "snapshot": snapshot} + + # 3) 常见 pending 场景 + pending_signals = ( + payment_status in ("unpaid", "pending", "processing", "requires_action", "unknown"), + checkout_status in ("open", "pending", "processing"), + setup_intent_status in ("requires_action", "processing", "requires_confirmation"), + payment_intent_status in ("requires_action", "processing", "unknown", "requires_confirmation"), + submission_state in ("unknown", "pending", "processing"), + bool(next_action_type), + bool(snapshot.get("task_id")), + ) + if any(pending_signals): + reason = failure_reason or message or "pending_confirmation" + return {"state": "pending", "reason": reason[:300], "snapshot": snapshot} + + # 4) 兜底: success=true 且无明确 paid,也视为 pending(仅代表“受理成功”) + if success_flag is True: + return {"state": "pending", "reason": message[:300], "snapshot": snapshot} + + # 5) 其他未知返回,按 pending 处理,交给后续轮询 + 订阅校验收敛 + return {"state": "pending", "reason": message[:300], "snapshot": snapshot} + + +def _is_third_party_challenge_pending(assessment: dict) -> bool: + """ + 判定第三方是否已进入“需要人工挑战”的 pending 状态。 + 常见信号: requires_action / intent_confirmation_challenge / 3DS / hcaptcha。 + """ + if not isinstance(assessment, dict): + return False + snapshot = assessment.get("snapshot") if isinstance(assessment.get("snapshot"), dict) else {} + reason = str(assessment.get("reason") or "").strip().lower() + next_action_type = str(snapshot.get("next_action_type") or "").strip().lower() + setup_intent_status = str(snapshot.get("setup_intent_status") or "").strip().lower() + payment_intent_status = str(snapshot.get("payment_intent_status") or "").strip().lower() + failure_reason = str(snapshot.get("failure_reason") or "").strip().lower() + + tokens = ( + reason, + next_action_type, + setup_intent_status, + payment_intent_status, + failure_reason, + ) + challenge_keywords = ( + "requires_action", + "intent_confirmation_challenge", + "authentication_required", + "3ds", + "challenge", + "hcaptcha", + ) + for text in tokens: + if any(keyword in text for keyword in challenge_keywords): + return True + return False + + +def _build_third_party_status_api_candidates(api_url: str) -> List[str]: + normalized = _normalize_third_party_bind_api_url(api_url) + if not normalized: + return [] + parsed = urlparse(normalized) + base = f"{parsed.scheme}://{parsed.netloc}" + path = (parsed.path or "").rstrip("/") + candidates: List[str] = [] + + def _append(item: str): + value = str(item or "").strip() + if value and value not in candidates: + candidates.append(value) + + # 优先和 bind-card 同前缀的常见状态接口 + if path.endswith("/bind-card"): + prefix = path[: -len("/bind-card")] + _append(base + prefix + "/bind-card/status") + _append(base + prefix + "/bind-card/result") + _append(base + prefix + "/bind-card/query") + _append(base + prefix + "/payment-status") + _append(base + prefix + "/checkout-status") + _append(base + prefix + "/status") + + # 通用 fallback + _append(base + "/api/v1/bind-card/status") + _append(base + "/api/v1/bind-card/result") + _append(base + "/api/v1/bind-card/query") + _append(base + "/api/v1/payment-status") + _append(base + "/api/v1/status") + return candidates + + +def _poll_third_party_bind_status( + *, + api_url: str, + api_key: Optional[str], + checkout_session_id: str, + proxy: Optional[str], + timeout_seconds: int, + interval_seconds: int, + status_hints: Optional[dict] = None, +) -> dict: + """ + 轮询第三方状态接口(若服务支持): + 返回 {"state": success/pending/failed/unsupported, "endpoint": ..., "snapshot": ...} + """ + if timeout_seconds <= 0: + return {"state": "unsupported", "reason": "poll_disabled"} + + endpoints = _build_third_party_status_api_candidates(api_url) + if not endpoints: + return {"state": "unsupported", "reason": "no_status_endpoint"} + + headers = { + "Accept": "*/*", + "Content-Type": "application/json", + "User-Agent": "codex-console2/third-party-bind-status", + } + key = str(api_key or "").strip() + if key: + headers["X-API-Key"] = key + headers["Authorization"] = f"Bearer {key}" + + deadline = time.monotonic() + max(timeout_seconds, 0) + last_assess: Optional[dict] = None + last_endpoint = "" + attempts = 0 + visited = False + + while time.monotonic() < deadline: + attempts += 1 + hints = status_hints if isinstance(status_hints, dict) else {} + hint_task_id = str( + hints.get("task_id") + or hints.get("request_id") + or hints.get("job_id") + or "" + ).strip() + query_payload = { + "checkout_session_id": checkout_session_id, + "session_id": checkout_session_id, + "cs_id": checkout_session_id, + } + if hint_task_id: + query_payload.update( + { + "task_id": hint_task_id, + "request_id": hint_task_id, + "job_id": hint_task_id, + "id": hint_task_id, + } + ) + for endpoint in endpoints: + for proxy_item in (proxy, None): + proxies = {"http": proxy_item, "https": proxy_item} if proxy_item else None + # 一些服务用 GET + query,一些服务用 POST + body,这里都试一次 + request_variants = ( + ("GET", {"params": query_payload}), + ("POST", {"json": query_payload}), + ) + for method, extra in request_variants: + try: + visited = True + if method == "GET": + resp = cffi_requests.get( + endpoint, + headers=headers, + proxies=proxies, + timeout=25, + impersonate="chrome110", + **extra, + ) + else: + resp = cffi_requests.post( + endpoint, + headers=headers, + proxies=proxies, + timeout=25, + impersonate="chrome110", + **extra, + ) + if resp.status_code in (404, 405): + continue + if resp.status_code >= 400: + continue + data = _parse_third_party_response(resp) + assess = _assess_third_party_submission_result(data if isinstance(data, dict) else {}) + assess["endpoint"] = endpoint + assess["proxy"] = "on" if proxy_item else "off" + assess["attempt"] = attempts + last_assess = assess + last_endpoint = endpoint + state = str(assess.get("state") or "").lower() + if state in ("success", "failed"): + return assess + except Exception: + continue + time.sleep(max(interval_seconds, 2)) + + if not visited: + return {"state": "unsupported", "reason": "status_endpoint_unavailable"} + if last_assess: + return last_assess + return {"state": "pending", "reason": "status_pending_timeout", "endpoint": last_endpoint} + + +def _refresh_account_token_for_subscription_check(account: Account, proxy: Optional[str]) -> tuple[bool, Optional[str]]: + """ + 刷新账号 Access Token(优先 session_token,其次 refresh_token)。 + """ + manager = TokenRefreshManager(proxy_url=proxy) + refresh_result = manager.refresh_account(account) + # 代理通道遇到地区限制时,再做一次直连兜底,避免“检测订阅”被 403 卡住。 + if ( + not refresh_result.success + and proxy + and "unsupported_country_region_territory" in str(refresh_result.error_message or "").lower() + ): + logger.warning( + "订阅检测 token 刷新遇到地区限制,尝试直连重试: account_id=%s email=%s", + account.id, + account.email, + ) + manager = TokenRefreshManager(proxy_url=None) + refresh_result = manager.refresh_account(account) + + if not refresh_result.success: + return False, refresh_result.error_message or "token_refresh_failed" + + if refresh_result.access_token: + account.access_token = refresh_result.access_token + if refresh_result.refresh_token: + account.refresh_token = refresh_result.refresh_token + if refresh_result.expires_at: + account.expires_at = refresh_result.expires_at + account.last_refresh = datetime.utcnow() + return True, None + + +def _check_subscription_detail_with_retry( + db, + account: Account, + proxy: Optional[str], + allow_token_refresh: bool, +) -> tuple[dict, bool]: + """ + 订阅检测 + 一次 token 刷新重试: + - 检测异常时尝试刷新 token 后重试 + - 检测到 free 且低置信度时,也尝试刷新 token 后重试 + Returns: + (detail, refreshed) + """ + refreshed = False + + try: + detail = check_subscription_status_detail(account, proxy) + except Exception as first_exc: + if not allow_token_refresh: + raise + ok, err = _refresh_account_token_for_subscription_check(account, proxy) + if not ok: + raise RuntimeError(f"{first_exc}; token刷新失败: {err}") + db.commit() + refreshed = True + detail = check_subscription_status_detail(account, proxy) + detail = dict(detail or {}) + detail["token_refreshed"] = True + return detail, refreshed + + status = str((detail or {}).get("status") or "free").lower() + confidence = str((detail or {}).get("confidence") or "low").lower() + source = str((detail or {}).get("source") or "").lower() + should_refresh_on_free = ( + confidence != "high" + or source.startswith("wham_usage.") + ) + if allow_token_refresh and status == "free" and should_refresh_on_free: + ok, err = _refresh_account_token_for_subscription_check(account, proxy) + if ok: + db.commit() + refreshed = True + detail = check_subscription_status_detail(account, proxy) + detail = dict(detail or {}) + detail["token_refreshed"] = True + return detail, refreshed + logger.warning( + "订阅检测触发token刷新但失败: account_id=%s email=%s err=%s", + account.id, + account.email, + err, + ) + + # 代理环境下若仍为 free,增加一次直连复核,降低地区/线路噪音影响。 + if proxy and status == "free": + try: + direct_detail = check_subscription_status_detail(account, proxy=None) + direct_status = str((direct_detail or {}).get("status") or "free").lower() + direct_conf = str((direct_detail or {}).get("confidence") or "low").lower() + logger.info( + "订阅检测直连复核: account_id=%s email=%s status=%s source=%s confidence=%s", + account.id, + account.email, + direct_status, + (direct_detail or {}).get("source"), + direct_conf, + ) + if direct_status in ("plus", "team"): + direct_detail = dict(direct_detail or {}) + direct_detail["checked_without_proxy"] = True + return direct_detail, refreshed + if confidence != "high": + direct_detail = dict(direct_detail or {}) + direct_detail["checked_without_proxy"] = True + return direct_detail, refreshed + except Exception as direct_exc: + logger.warning( + "订阅检测直连复核失败: account_id=%s email=%s error=%s", + account.id, + account.email, + direct_exc, + ) + + return detail, refreshed + + +def _generate_checkout_link_for_account( + account: Account, + request: "CheckoutRequestBase", + proxy: Optional[str], +) -> tuple[str, str, Optional[str], Optional[str], Optional[str], Optional[str]]: + if request.plan_type not in ("plus", "team"): + raise HTTPException(status_code=400, detail="plan_type 必须为 plus 或 team") + + # 优先官方 checkout,保证直接落到 chatgpt.com 绑卡页面。 + source = "openai_checkout" + fallback_reason = None + checkout_session_id: Optional[str] = None + publishable_key: Optional[str] = None + client_secret: Optional[str] = None + request.country = _normalize_checkout_country(request.country) + request.currency = _normalize_checkout_currency(request.country, getattr(request, "currency", None)) + try: + if request.plan_type == "plus": + bundle = generate_plus_checkout_bundle( + account=account, + proxy=proxy, + country=request.country, + ) + else: + bundle = generate_team_checkout_bundle( + account=account, + workspace_name=request.workspace_name, + price_interval=request.price_interval, + seat_quantity=request.seat_quantity, + proxy=proxy, + country=request.country, + ) + link = str(bundle.get("checkout_url") or "") + checkout_session_id = str(bundle.get("checkout_session_id") or "").strip() or None + publishable_key = str(bundle.get("publishable_key") or "").strip() or None + client_secret = str(bundle.get("client_secret") or "").strip() or None + except Exception as direct_err: + if _is_checkout_connectivity_error(direct_err): + logger.warning( + "官方 checkout 网络连接失败,不回退 aimizy: account_id=%s email=%s error=%s", + account.id, + account.email, + direct_err, + ) + raise HTTPException( + status_code=502, + detail=f"官方 checkout 网络连接失败,请检查代理或网络后重试: {direct_err}", + ) + # 官方接口失败时,回退到 aimizy 渠道(仍会尝试归一化为官方 checkout 链接)。 + source = "aimizy_fallback" + fallback_reason = str(direct_err) + logger.warning(f"官方 checkout 生成失败,回退 aimizy: {direct_err}") + link = generate_aimizy_payment_link( + account=account, + plan_type=request.plan_type, + proxy=proxy, + country=request.country, + currency=request.currency, + ) + + if not isinstance(link, str) or not link.strip(): + raise ValueError("未获取到支付链接,请检查账号 Token/Cookies 是否有效") + + if not checkout_session_id: + checkout_session_id = _extract_checkout_session_id_from_url(link) + + return link, source, fallback_reason, checkout_session_id, publishable_key, client_secret # ============== Pydantic Models ============== -class GenerateLinkRequest(BaseModel): +class CheckoutRequestBase(BaseModel): account_id: int plan_type: str # 'plus' or 'team' workspace_name: str = "MyTeam" price_interval: str = "month" seat_quantity: int = 5 proxy: Optional[str] = None + country: str = "US" + currency: Optional[str] = "USD" + + +class GenerateLinkRequest(CheckoutRequestBase): auto_open: bool = False # 生成后是否自动无痕打开 - country: str = "SG" # 计费国家,决定货币 # 生成后是否自动无痕打开 + + +class CreateBindCardTaskRequest(CheckoutRequestBase): + auto_open: bool = False + bind_mode: str = "semi_auto" # semi_auto / third_party / local_auto class OpenIncognitoRequest(BaseModel): @@ -43,6 +1962,56 @@ class OpenIncognitoRequest(BaseModel): account_id: Optional[int] = None # 可选,用于注入账号 cookie +class SyncBindCardTaskRequest(BaseModel): + proxy: Optional[str] = None + + +class MarkUserActionRequest(BaseModel): + proxy: Optional[str] = None + timeout_seconds: int = Field(default=180, ge=30, le=300) + interval_seconds: int = Field(default=10, ge=5, le=30) + + +class ThirdPartyCardRequest(BaseModel): + number: str + exp_month: str + exp_year: str + cvc: str + + +class ThirdPartyProfileRequest(BaseModel): + name: str + email: Optional[str] = None + country: str = "US" + line1: str + city: str + state: str + postal: str + + +class ThirdPartyAutoBindRequest(BaseModel): + api_url: Optional[str] = None + api_key: Optional[str] = None + proxy: Optional[str] = None + timeout_seconds: int = Field(default=120, ge=30, le=300) + interval_seconds: int = Field(default=10, ge=5, le=30) + third_party_poll_timeout_seconds: int = Field(default=60, ge=0, le=300) + third_party_poll_interval_seconds: int = Field(default=6, ge=2, le=30) + card: ThirdPartyCardRequest + profile: ThirdPartyProfileRequest + + +class LocalAutoBindRequest(BaseModel): + proxy: Optional[str] = None + browser_timeout_seconds: int = Field(default=180, ge=60, le=600) + post_submit_wait_seconds: int = Field(default=90, ge=30, le=300) + verify_timeout_seconds: int = Field(default=180, ge=30, le=300) + verify_interval_seconds: int = Field(default=10, ge=5, le=30) + headless: bool = False + card: ThirdPartyCardRequest + profile: ThirdPartyProfileRequest + + class MarkSubscriptionRequest(BaseModel): subscription_type: str # 'free' / 'plus' / 'team' @@ -56,8 +2025,214 @@ class BatchCheckSubscriptionRequest(BaseModel): search_filter: Optional[str] = None +class SaveSessionTokenRequest(BaseModel): + session_token: str + merge_cookie: bool = True + + # ============== 支付链接生成 ============== + +@router.get("/random-billing") +def get_random_billing_profile( + country: str = Query("US", description="国家代码,如 US/GB/CA"), + proxy: Optional[str] = Query(None, description="可选代理"), +): + """ + 按国家随机生成账单资料。 + 优先 meiguodizhi,失败自动降级到本地模板。 + """ + try: + # 随机地址仅使用显式传入代理;不再默认继承系统代理配置。 + proxy_url = _normalize_proxy_value(proxy) or None + profile = generate_random_billing_profile(country=country, proxy=proxy_url) + return { + "success": True, + "profile": profile, + } + except Exception as exc: + logger.error("随机账单资料生成失败: country=%s error=%s", country, exc) + raise HTTPException(status_code=500, detail=f"随机账单资料生成失败: {exc}") + + +@router.get("/accounts/{account_id}/session-diagnostic") +def get_account_session_diagnostic( + account_id: int, + probe: bool = Query(True, description="是否执行一次实时会话探测"), + proxy: Optional[str] = Query(None, description="会话探测代理"), +): + """ + 会话诊断: + - 账号是否具备 access/session/device 基础条件 + - cookies 中 session token 是否为分片形式 + - 可选实时请求 /api/auth/session 验证会话可用性 + """ + with get_db() as db: + account = db.query(Account).filter(Account.id == account_id).first() + if not account: + raise HTTPException(status_code=404, detail="账号不存在") + + access_token = str(account.access_token or "").strip() + refresh_token = str(account.refresh_token or "").strip() + session_token_db = str(account.session_token or "").strip() + cookies_text = str(account.cookies or "") + device_id = _resolve_account_device_id(account) + session_token_cookie = _extract_session_token_from_cookie_text(cookies_text) + session_chunk_indices = _extract_session_token_chunks_from_cookie_text(cookies_text) + resolved_session_token = session_token_db or session_token_cookie + + notes: List[str] = [] + if not access_token: + notes.append("缺少 access_token(无法走 auth/session 探测授权头)") + if not resolved_session_token: + notes.append("未发现 session_token(DB 与 cookies 都为空)") + if session_chunk_indices and not session_token_cookie: + notes.append("发现 session 分片但未能拼接,请检查 cookies 原文完整性") + if not device_id: + notes.append("缺少 oai-did(会话建立成功率会下降)") + + probe_result = None + if probe: + probe_proxy = _resolve_runtime_proxy(proxy, account) + probe_result = _probe_auth_session_context(account, probe_proxy) + if not probe_result.get("ok"): + notes.append( + "实时探测未通过:" + + ( + str(probe_result.get("error") or "").strip() + or f"http_status={probe_result.get('http_status')}" + ) + ) + + recommendation = "会话完整,可直接执行全自动绑卡" + if not resolved_session_token and access_token: + recommendation = "建议先用 access_token 预热 /api/auth/session,再执行全自动" + elif not access_token and not resolved_session_token: + recommendation = "账号会话信息不足,建议重新登录一次并回写 cookies/session_token" + elif probe_result and (not probe_result.get("session_token_found")): + recommendation = "建议检查代理线路与账号登录态,必要时切直连重试" + can_login_bootstrap = bool(str(account.password or "").strip()) and bool(str(account.email_service or "").strip()) + if (not resolved_session_token) and can_login_bootstrap: + recommendation = "可尝试后端自动登录补会话(账号密码+邮箱验证码)后再执行全自动" + + return { + "success": True, + "diagnostic": { + "account_id": account.id, + "email": account.email, + "token_state": { + "has_access_token": bool(access_token), + "access_token_len": len(access_token), + "access_token_preview": _mask_secret(access_token), + "has_refresh_token": bool(refresh_token), + "refresh_token_len": len(refresh_token), + "has_session_token_db": bool(session_token_db), + "session_token_db_len": len(session_token_db), + "session_token_db_preview": _mask_secret(session_token_db), + "has_session_token_cookie": bool(session_token_cookie), + "session_token_cookie_len": len(session_token_cookie), + "session_token_cookie_preview": _mask_secret(session_token_cookie), + "resolved_session_token_len": len(resolved_session_token), + "resolved_session_token_preview": _mask_secret(resolved_session_token), + }, + "cookie_state": { + "has_cookies": bool(cookies_text.strip()), + "cookies_len": len(cookies_text), + "has_oai_did": bool(_extract_cookie_value(cookies_text, "oai-did")), + "resolved_oai_did": _mask_secret(device_id), + "session_chunk_count": len(session_chunk_indices), + "session_chunk_indices": session_chunk_indices, + }, + "bootstrap_capability": { + "can_login_bootstrap": can_login_bootstrap, + "has_password": bool(str(account.password or "").strip()), + "email_service_type": str(account.email_service or ""), + "email_service_mailbox_id": str(account.email_service_id or ""), + }, + "probe": probe_result, + "notes": notes, + "recommendation": recommendation, + "checked_at": datetime.utcnow().isoformat(), + }, + } + + +@router.post("/accounts/{account_id}/session-bootstrap") +def bootstrap_account_session_token( + account_id: int, + proxy: Optional[str] = Query(None, description="会话补全代理"), +): + """ + 主动触发一次会话补全: + 1) 先走 API 级 session 探测补全 + 2) 失败后自动走账号登录链路(邮箱验证码)补全 + """ + with get_db() as db: + account = db.query(Account).filter(Account.id == account_id).first() + if not account: + raise HTTPException(status_code=404, detail="账号不存在") + + runtime_proxy = _resolve_runtime_proxy(proxy, account) + token = _bootstrap_session_token_for_local_auto(db, account, runtime_proxy) + if not token: + return { + "success": False, + "message": "会话补全未命中 session_token", + "account_id": account.id, + "email": account.email, + } + + return { + "success": True, + "message": "会话补全成功", + "account_id": account.id, + "email": account.email, + "session_token_len": len(str(token or "")), + "session_token_preview": _mask_secret(token), + } + + +@router.post("/accounts/{account_id}/session-token") +def save_account_session_token( + account_id: int, + request: SaveSessionTokenRequest, +): + """ + 手动写入 session_token(ABCard 兜底模式)。 + """ + token = str(request.session_token or "").strip() + if not token: + raise HTTPException(status_code=400, detail="session_token 不能为空") + + with get_db() as db: + account = db.query(Account).filter(Account.id == account_id).first() + if not account: + raise HTTPException(status_code=404, detail="账号不存在") + + account.session_token = token + if request.merge_cookie: + account.cookies = _upsert_cookie(account.cookies, "__Secure-next-auth.session-token", token) + account.last_refresh = datetime.utcnow() + db.commit() + db.refresh(account) + + logger.info( + "手动写入 session_token: account_id=%s email=%s token_len=%s merge_cookie=%s", + account.id, + account.email, + len(token), + bool(request.merge_cookie), + ) + return { + "success": True, + "account_id": account.id, + "email": account.email, + "session_token_len": len(token), + "session_token_preview": _mask_secret(token), + "message": "session_token 已保存", + } + + @router.post("/generate-link") def generate_payment_link(request: GenerateLinkRequest): """生成 Plus 或 Team 支付链接,可选自动无痕打开""" @@ -66,22 +2241,16 @@ def generate_payment_link(request: GenerateLinkRequest): if not account: raise HTTPException(status_code=404, detail="账号不存在") - proxy = request.proxy or get_settings().proxy_url + proxy = _resolve_runtime_proxy(request.proxy, account) try: - if request.plan_type == "plus": - link = generate_plus_link(account, proxy, country=request.country) - elif request.plan_type == "team": - link = generate_team_link( - account, - workspace_name=request.workspace_name, - price_interval=request.price_interval, - seat_quantity=request.seat_quantity, - proxy=proxy, - country=request.country, - ) - else: - raise HTTPException(status_code=400, detail="plan_type 必须为 plus 或 team") + link, source, fallback_reason, checkout_session_id, publishable_key, client_secret = _generate_checkout_link_for_account( + account=account, + request=request, + proxy=proxy, + ) + except HTTPException: + raise except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: @@ -96,8 +2265,16 @@ def generate_payment_link(request: GenerateLinkRequest): return { "success": True, "link": link, + "is_official_checkout": _is_official_checkout_link(link), "plan_type": request.plan_type, + "country": _normalize_checkout_country(request.country), + "currency": _normalize_checkout_currency(_normalize_checkout_country(request.country), request.currency), "auto_opened": opened, + "source": source, + "fallback_reason": fallback_reason, + "checkout_session_id": checkout_session_id, + "publishable_key": publishable_key, + "has_client_secret": bool(client_secret), } @@ -120,12 +2297,932 @@ def open_browser_incognito(request: OpenIncognitoRequest): return {"success": False, "message": "未找到可用的浏览器,请手动复制链接"} +# ============== 绑卡任务(A 方案) ============== + +@router.post("/bind-card/tasks") +def create_bind_card_task(request: CreateBindCardTaskRequest): + """创建绑卡任务(从账号管理中选择账号)""" + bind_mode = str(request.bind_mode or "semi_auto").strip().lower() + if bind_mode not in ("semi_auto", "third_party", "local_auto"): + raise HTTPException(status_code=400, detail="bind_mode 必须为 semi_auto / third_party / local_auto") + + with get_db() as db: + account = db.query(Account).filter(Account.id == request.account_id).first() + if not account: + raise HTTPException(status_code=404, detail="账号不存在") + + logger.info( + "创建绑卡任务: account_id=%s email=%s plan=%s country=%s mode=%s auto_open=%s", + account.id, account.email, request.plan_type, request.country, bind_mode, request.auto_open + ) + + proxy = _resolve_runtime_proxy(request.proxy, account) + try: + link, source, fallback_reason, checkout_session_id, publishable_key, client_secret = _generate_checkout_link_for_account( + account=account, + request=request, + proxy=proxy, + ) + except HTTPException: + raise + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"创建绑卡任务失败: {e}") + raise HTTPException(status_code=500, detail=f"创建绑卡任务失败: {str(e)}") + + task = BindCardTask( + account_id=account.id, + plan_type=request.plan_type, + workspace_name=request.workspace_name if request.plan_type == "team" else None, + price_interval=request.price_interval if request.plan_type == "team" else None, + seat_quantity=request.seat_quantity if request.plan_type == "team" else None, + country=_normalize_checkout_country(request.country), + currency=_normalize_checkout_currency(_normalize_checkout_country(request.country), request.currency), + checkout_url=link, + checkout_session_id=checkout_session_id, + publishable_key=publishable_key, + client_secret=client_secret, + checkout_source=source, + bind_mode=bind_mode, + status="link_ready", + ) + db.add(task) + db.commit() + db.refresh(task) + + logger.info( + "绑卡任务已创建: task_id=%s account_id=%s plan=%s source=%s status=%s", + task.id, task.account_id, task.plan_type, source, task.status + ) + + opened = False + if request.auto_open and bind_mode == "semi_auto" and link: + opened = open_url_incognito(link, account.cookies if account else None) + if opened: + task.status = "opened" + task.opened_at = datetime.utcnow() + db.commit() + db.refresh(task) + logger.info("绑卡任务自动打开成功: task_id=%s mode=%s", task.id, bind_mode) + else: + logger.warning("绑卡任务自动打开失败: task_id=%s mode=%s", task.id, bind_mode) + + return { + "success": True, + "task": _serialize_bind_card_task(task), + "link": link, + "is_official_checkout": _is_official_checkout_link(link), + "source": source, + "fallback_reason": fallback_reason, + "auto_opened": opened, + "checkout_session_id": checkout_session_id, + "publishable_key": publishable_key, + "has_client_secret": bool(client_secret), + } + + +@router.get("/bind-card/tasks") +def list_bind_card_tasks( + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(20, ge=1, le=100, description="每页数量"), + status: Optional[str] = Query(None, description="状态筛选"), + search: Optional[str] = Query(None, description="按邮箱搜索"), +): + """绑卡任务列表""" + with get_db() as db: + query = db.query(BindCardTask).options(joinedload(BindCardTask.account)) + if status: + query = query.filter(BindCardTask.status == status) + if search: + pattern = f"%{search}%" + query = query.join(Account, BindCardTask.account_id == Account.id).filter( + or_(Account.email.ilike(pattern), Account.account_id.ilike(pattern)) + ) + + total = query.count() + offset = (page - 1) * page_size + tasks = query.order_by(BindCardTask.created_at.desc()).offset(offset).limit(page_size).all() + + # 自动收敛任务状态:如果账号已是 plus/team,任务自动标记完成。 + now = datetime.utcnow() + changed = False + changed_count = 0 + for task in tasks: + account = task.account + sub_type = str(getattr(account, "subscription_type", "") or "").lower() + if sub_type in ("plus", "team") and task.status != "completed": + task.status = "completed" + task.completed_at = task.completed_at or getattr(account, "subscription_at", None) or now + task.last_error = None + task.last_checked_at = now + changed = True + changed_count += 1 + + if changed: + db.commit() + logger.info("绑卡任务状态自动收敛完成: updated_count=%s", changed_count) + + return { + "total": total, + "tasks": [_serialize_bind_card_task(task) for task in tasks], + } + + +@router.post("/bind-card/tasks/{task_id}/open") +def open_bind_card_task(task_id: int): + """打开绑卡任务对应的 checkout 链接""" + with get_db() as db: + task = db.query(BindCardTask).options(joinedload(BindCardTask.account)).filter(BindCardTask.id == task_id).first() + if not task: + raise HTTPException(status_code=404, detail="绑卡任务不存在") + if not task.checkout_url: + raise HTTPException(status_code=400, detail="任务缺少 checkout 链接") + + cookies_str = task.account.cookies if task.account else None + logger.info("打开绑卡任务: task_id=%s account_id=%s", task.id, task.account_id) + opened = open_url_incognito(task.checkout_url, cookies_str) + if opened: + if str(task.status or "") not in ("paid_pending_sync", "completed"): + task.status = "opened" + task.opened_at = datetime.utcnow() + task.last_error = None + db.commit() + db.refresh(task) + logger.info("绑卡任务打开成功: task_id=%s", task.id) + return {"success": True, "task": _serialize_bind_card_task(task)} + + task.last_error = "未找到可用的浏览器" + db.commit() + logger.warning("绑卡任务打开失败: task_id=%s reason=%s", task.id, task.last_error) + raise HTTPException(status_code=500, detail="未找到可用的浏览器,请手动复制链接") + + +@router.post("/bind-card/tasks/{task_id}/auto-bind-third-party") +def auto_bind_bind_card_task_third_party(task_id: int, request: ThirdPartyAutoBindRequest): + """ + 通过第三方 API 自动提交绑卡(A+B 方案)。 + A: 三态判定(success/pending/failed) + B: 尝试轮询第三方状态接口,能确认 paid 就标记 paid_pending_sync(等待订阅同步) + """ + third_party_response_safe: dict = {} + api_url_for_log = "" + third_party_assessment: dict = {} + third_party_status_poll: dict = {} + with get_db() as db: + task = db.query(BindCardTask).options(joinedload(BindCardTask.account)).filter(BindCardTask.id == task_id).first() + if not task: + raise HTTPException(status_code=404, detail="绑卡任务不存在") + account = task.account + if not account: + raise HTTPException(status_code=404, detail="任务关联账号不存在") + + checkout_session_id = str(task.checkout_session_id or "").strip() or _extract_checkout_session_id_from_url(task.checkout_url) + publishable_key = str(task.publishable_key or "").strip() + if not checkout_session_id: + raise HTTPException(status_code=400, detail="任务缺少 checkout_session_id,请重新创建任务") + if not publishable_key: + raise HTTPException(status_code=400, detail="任务缺少 publishable_key,请重新创建任务") + + api_url = _resolve_third_party_bind_api_url(request.api_url) + api_key = _resolve_third_party_bind_api_key(request.api_key) + if not api_url: + raise HTTPException(status_code=400, detail=f"缺少第三方 API 地址(request.api_url 或环境变量 {THIRD_PARTY_BIND_API_URL_ENV})") + api_url_for_log = api_url + + proxy = _resolve_runtime_proxy(request.proxy, account) + payload = { + "checkout_session_id": checkout_session_id, + "publishable_key": publishable_key, + "client_secret": str(getattr(task, "client_secret", "") or "").strip() or None, + "checkout_url": str(task.checkout_url or "").strip() or None, + "plan_type": str(task.plan_type or "").strip().lower(), + "country": "US", + "currency": "USD", + "card": { + "number": str(request.card.number or "").strip(), + "exp_month": str(request.card.exp_month or "").strip().zfill(2), + "exp_year": str(request.card.exp_year or "").strip()[-2:], + "cvc": str(request.card.cvc or "").strip(), + }, + "profile": { + "name": str(request.profile.name or "").strip(), + "email": str(request.profile.email or account.email or "").strip(), + "country": str(request.profile.country or "US").strip().upper(), + "line1": str(request.profile.line1 or "").strip(), + "city": str(request.profile.city or "").strip(), + "state": str(request.profile.state or "").strip(), + "postal": str(request.profile.postal or "").strip(), + }, + } + + if not payload["card"]["number"] or not payload["card"]["cvc"]: + raise HTTPException(status_code=400, detail="卡号/CVC 不能为空") + + logger.info( + "第三方自动绑卡提交开始: task_id=%s account_id=%s mode=third_party api_url=%s has_api_key=%s cs_id=%s card=%s", + task.id, + account.id, + api_url, + "yes" if api_key else "no", + checkout_session_id[:24] + "...", + _mask_card_number(payload["card"]["number"]), + ) + + task.bind_mode = "third_party" + task.status = "verifying" + task.last_error = None + task.last_checked_at = datetime.utcnow() + db.commit() + + try: + third_party_response, used_endpoint = _invoke_third_party_bind_api( + api_url=api_url, + api_key=api_key, + payload=payload, + proxy=proxy, + ) + third_party_response_safe = _sanitize_third_party_response(third_party_response) + third_party_assessment = _assess_third_party_submission_result(third_party_response) + assess_state = str(third_party_assessment.get("state") or "pending").lower() + assess_reason = str(third_party_assessment.get("reason") or "").strip() + assess_snapshot = third_party_assessment.get("snapshot") if isinstance(third_party_assessment, dict) else {} + payment_status = str((assess_snapshot or {}).get("payment_status") or "").lower() + checkout_status = str((assess_snapshot or {}).get("checkout_status") or "").lower() + setup_intent_status = str((assess_snapshot or {}).get("setup_intent_status") or "").lower() + logger.info( + "第三方自动绑卡提交评估: task_id=%s account_id=%s endpoint=%s state=%s payment_status=%s checkout_status=%s setup_intent_status=%s reason=%s", + task.id, + account.id, + used_endpoint, + assess_state, + payment_status or "-", + checkout_status or "-", + setup_intent_status or "-", + assess_reason or "-", + ) + + if assess_state == "failed": + task.status = "failed" + task.last_error = f"第三方返回失败: {assess_reason or 'unknown'}" + task.last_checked_at = datetime.utcnow() + db.commit() + logger.warning( + "第三方自动绑卡返回业务失败: task_id=%s account_id=%s endpoint=%s reason=%s response=%s", + task.id, + account.id, + used_endpoint, + assess_reason, + third_party_response_safe, + ) + raise HTTPException(status_code=400, detail=f"第三方返回失败: {assess_reason or 'unknown'}") + + paid_hint_status = payment_status or "paid" + paid_hint_reason = assess_reason or "third_party_paid_signal" + + # B 方案:若提交后仍 pending,尝试轮询第三方状态接口确认是否已 paid。 + if assess_state == "pending": + # 若第三方明确返回 challenge/requires_action,直接切换待用户完成,避免无意义轮询超时。 + if _is_third_party_challenge_pending(third_party_assessment): + task.status = "waiting_user_action" + task.last_checked_at = datetime.utcnow() + hint_reason = assess_reason or "requires_action" + hint_payment_status = payment_status or "unknown" + task.last_error = ( + f"第三方已受理并进入挑战流程(payment_status={hint_payment_status}, reason={hint_reason})。" + "请打开 checkout 页面完成 challenge 后点击“我已完成支付”或“同步订阅”。" + ) + db.commit() + db.refresh(task) + logger.info( + "第三方自动绑卡检测到挑战态,转人工继续: task_id=%s account_id=%s payment_status=%s reason=%s", + task.id, + account.id, + hint_payment_status, + hint_reason, + ) + return { + "success": True, + "verified": False, + "pending": True, + "need_user_action": True, + "subscription_type": str(account.subscription_type or "free"), + "task": _serialize_bind_card_task(task), + "account_id": account.id, + "account_email": account.email, + "third_party": { + "submitted": True, + "api_url": used_endpoint, + "assessment": third_party_assessment, + "poll": {}, + "response": third_party_response_safe, + }, + } + + third_party_status_poll = _poll_third_party_bind_status( + api_url=used_endpoint, + api_key=api_key, + checkout_session_id=checkout_session_id, + proxy=proxy, + timeout_seconds=request.third_party_poll_timeout_seconds, + interval_seconds=request.third_party_poll_interval_seconds, + status_hints=assess_snapshot or {}, + ) + poll_state = str((third_party_status_poll or {}).get("state") or "pending").lower() + poll_reason = str((third_party_status_poll or {}).get("reason") or "").strip() + poll_snapshot = (third_party_status_poll or {}).get("snapshot") or {} + poll_payment_status = str((poll_snapshot or {}).get("payment_status") or "").lower() + logger.info( + "第三方自动绑卡状态轮询: task_id=%s account_id=%s endpoint=%s state=%s payment_status=%s reason=%s", + task.id, + account.id, + str((third_party_status_poll or {}).get("endpoint") or used_endpoint), + poll_state, + poll_payment_status or "-", + poll_reason or "-", + ) + + if poll_state == "failed": + task.status = "failed" + task.last_error = f"第三方状态失败: {poll_reason or 'unknown'}" + task.last_checked_at = datetime.utcnow() + db.commit() + raise HTTPException(status_code=400, detail=f"第三方状态失败: {poll_reason or 'unknown'}") + + if poll_state != "success": + task.status = "waiting_user_action" + task.last_checked_at = datetime.utcnow() + hint_reason = poll_reason or assess_reason or "pending_confirmation" + hint_payment_status = poll_payment_status or payment_status or "unknown" + task.last_error = ( + f"第三方已受理,等待支付最终状态(payment_status={hint_payment_status}, reason={hint_reason})。" + "如页面要求 challenge,请完成后点击“我已完成支付”或“同步订阅”。" + ) + db.commit() + db.refresh(task) + return { + "success": True, + "verified": False, + "pending": True, + "need_user_action": True, + "subscription_type": str(account.subscription_type or "free"), + "task": _serialize_bind_card_task(task), + "account_id": account.id, + "account_email": account.email, + "third_party": { + "submitted": True, + "api_url": used_endpoint, + "assessment": third_party_assessment, + "poll": third_party_status_poll, + "response": third_party_response_safe, + }, + } + + # poll 成功视为支付确认,不阻塞在订阅轮询 + paid_hint_status = poll_payment_status or payment_status or "paid" + paid_hint_reason = poll_reason or assess_reason or "third_party_paid_signal" + + logger.info( + "第三方自动绑卡提交成功: task_id=%s account_id=%s endpoint=%s response_keys=%s", + task.id, + account.id, + used_endpoint, + ",".join(sorted(third_party_response_safe.keys())), + ) + api_url_for_log = used_endpoint + _mark_task_paid_pending_sync( + task, + ( + f"支付已确认(payment_status={paid_hint_status}, reason={paid_hint_reason})。" + "等待订阅状态同步,可点击“同步订阅”刷新。" + ), + ) + db.commit() + db.refresh(task) + return { + "success": True, + "verified": False, + "paid_confirmed": True, + "subscription_type": str(account.subscription_type or "free"), + "task": _serialize_bind_card_task(task), + "account_id": account.id, + "account_email": account.email, + "third_party": { + "submitted": True, + "api_url": api_url_for_log, + "assessment": third_party_assessment, + "poll": third_party_status_poll, + "response": third_party_response_safe, + }, + } + except HTTPException: + raise + except Exception as exc: + task.status = "failed" + task.last_error = f"第三方绑卡提交失败: {exc}" + task.last_checked_at = datetime.utcnow() + db.commit() + logger.warning("第三方自动绑卡提交失败: task_id=%s error=%s", task.id, exc) + raise HTTPException(status_code=500, detail=str(exc)) + + +@router.post("/bind-card/tasks/{task_id}/auto-bind-local") +def auto_bind_bind_card_task_local(task_id: int, request: LocalAutoBindRequest): + """ + 本地自动绑卡(参考 ABCard 的浏览器自动化流程)。 + - 成功信号后标记 paid_pending_sync(等待订阅同步) + - challenge/超时等待用户完成时,回到 waiting_user_action + """ + browser_result: dict = {} + with get_db() as db: + task = db.query(BindCardTask).options(joinedload(BindCardTask.account)).filter(BindCardTask.id == task_id).first() + if not task: + raise HTTPException(status_code=404, detail="绑卡任务不存在") + account = task.account + if not account: + raise HTTPException(status_code=404, detail="任务关联账号不存在") + + checkout_session_id = str(task.checkout_session_id or "").strip() or _extract_checkout_session_id_from_url(task.checkout_url) + checkout_url = ( + _build_official_checkout_url(checkout_session_id) + or str(task.checkout_url or "").strip() + ) + if not checkout_url: + raise HTTPException(status_code=400, detail="任务缺少 checkout 链接,请重新创建任务") + + card_number = str(request.card.number or "").strip() + card_cvc = str(request.card.cvc or "").strip() + if not card_number or not card_cvc: + raise HTTPException(status_code=400, detail="卡号/CVC 不能为空") + + task.bind_mode = "local_auto" + task.status = "verifying" + task.last_error = None + task.last_checked_at = datetime.utcnow() + db.commit() + + logger.info( + "本地自动绑卡执行开始: task_id=%s account_id=%s email=%s checkout=%s headless=%s card=%s", + task.id, + account.id, + account.email, + checkout_url[:80], + bool(request.headless), + _mask_card_number(card_number), + ) + runtime_proxy = _resolve_runtime_proxy(request.proxy, account) + resolved_device_id = _resolve_account_device_id(account) + if not resolved_device_id: + logger.warning("本地自动绑卡缺少 oai-did: task_id=%s account_id=%s email=%s", task.id, account.id, account.email) + resolved_session_token = str(account.session_token or "").strip() or _extract_session_token_from_cookie_text( + account.cookies + ) + if not resolved_session_token and not runtime_proxy: + task.status = "failed" + task.last_checked_at = datetime.utcnow() + task.last_error = ( + "当前账号缺少 session_token,且未检测到可用代理。" + "请在设置中配置代理(或为本次任务传入 proxy)后重试全自动。" + ) + db.commit() + logger.warning( + "本地自动绑卡会话补全阻断: task_id=%s account_id=%s email=%s reason=no_proxy", + task.id, + account.id, + account.email, + ) + raise HTTPException(status_code=400, detail=task.last_error) + if not resolved_session_token: + resolved_session_token = _bootstrap_session_token_for_local_auto( + db=db, + account=account, + proxy=runtime_proxy, + ) + if not resolved_session_token: + task.status = "failed" + task.last_checked_at = datetime.utcnow() + task.last_error = ( + "会话补全未拿到 session_token。" + "请先在支付页执行“会话诊断/自动补会话”," + "或手动粘贴 session_token 后再重试全自动绑卡。" + ) + db.commit() + logger.warning( + "本地自动绑卡缺少 session_token,已阻断执行: task_id=%s account_id=%s email=%s", + task.id, + account.id, + account.email, + ) + raise HTTPException(status_code=400, detail=task.last_error) + + try: + browser_result = auto_bind_checkout_with_playwright( + checkout_url=checkout_url, + cookies_str=str(account.cookies or ""), + session_token=resolved_session_token, + access_token=str(account.access_token or ""), + device_id=resolved_device_id, + card_number=card_number, + exp_month=str(request.card.exp_month or ""), + exp_year=str(request.card.exp_year or ""), + cvc=card_cvc, + billing_name=str(request.profile.name or "").strip(), + billing_country=str(request.profile.country or "US").strip().upper(), + billing_line1=str(request.profile.line1 or "").strip(), + billing_city=str(request.profile.city or "").strip(), + billing_state=str(request.profile.state or "").strip(), + billing_postal=str(request.profile.postal or "").strip(), + proxy=runtime_proxy, + timeout_seconds=request.browser_timeout_seconds, + post_submit_wait_seconds=request.post_submit_wait_seconds, + headless=bool(request.headless), + ) + except Exception as exc: + task.status = "failed" + task.last_error = f"本地自动绑卡执行异常: {exc}" + task.last_checked_at = datetime.utcnow() + db.commit() + logger.warning("本地自动绑卡执行异常: task_id=%s account_id=%s error=%s", task.id, account.id, exc) + raise HTTPException(status_code=500, detail=f"本地自动绑卡执行失败: {exc}") + + success = bool(browser_result.get("success")) + need_user_action = bool(browser_result.get("need_user_action")) + pending = bool(browser_result.get("pending")) + stage = str(browser_result.get("stage") or "").strip() + message = str(browser_result.get("error") or browser_result.get("message") or "").strip() + current_url = str(browser_result.get("current_url") or "").strip() + + if not success: + if "playwright not installed" in message.lower(): + task.status = "failed" + task.last_checked_at = datetime.utcnow() + task.last_error = ( + "本地自动绑卡环境缺少 Playwright/Chromium。" + "请先执行: pip install playwright && playwright install chromium" + ) + db.commit() + logger.warning( + "本地自动绑卡环境缺失: task_id=%s account_id=%s error=%s", + task.id, + account.id, + message or "-", + ) + raise HTTPException(status_code=400, detail=task.last_error) + + if need_user_action or pending: + if stage in ("cdp_session_missing", "navigate_checkout"): + hint = ( + "自动会话未建立(checkout 重定向到首页)。" + "请先在“支付页-半自动”打开一次官方 checkout 完成登录态预热," + "随后再次执行“全自动”。" + ) + else: + hint = ( + f"本地自动绑卡已执行到 {stage or 'unknown'},当前需要人工继续完成({message or 'challenge_or_pending'})。" + "请在支付页完成后点击“我已完成支付”或“同步订阅”。" + ) + manual_opened = False + if stage in ("cdp_challenge", "challenge"): + try: + manual_opened = open_url_incognito(checkout_url, str(account.cookies or "")) + except Exception: + manual_opened = False + if manual_opened: + hint += " 已自动为你打开手动验证窗口。" + task.status = "waiting_user_action" + task.last_checked_at = datetime.utcnow() + task.last_error = hint + db.commit() + db.refresh(task) + logger.info( + "本地自动绑卡需人工继续: task_id=%s account_id=%s stage=%s msg=%s url=%s", + task.id, + account.id, + stage or "-", + message or "-", + current_url[:100] if current_url else "-", + ) + return { + "success": True, + "verified": False, + "pending": True, + "need_user_action": True, + "subscription_type": str(account.subscription_type or "free"), + "task": _serialize_bind_card_task(task), + "account_id": account.id, + "account_email": account.email, + "manual_opened": manual_opened, + "local_auto": browser_result, + } + + task.status = "failed" + task.last_checked_at = datetime.utcnow() + task.last_error = f"本地自动绑卡失败: {message or 'unknown_error'}" + db.commit() + logger.warning( + "本地自动绑卡失败: task_id=%s account_id=%s stage=%s msg=%s", + task.id, + account.id, + stage or "-", + message or "-", + ) + raise HTTPException(status_code=400, detail=task.last_error) + + logger.info( + "本地自动绑卡浏览器阶段成功: task_id=%s account_id=%s stage=%s url=%s", + task.id, + account.id, + stage or "-", + current_url[:100] if current_url else "-", + ) + _mark_task_paid_pending_sync( + task, + ( + f"本地自动绑卡已完成支付提交(stage={stage or 'complete'})。" + "等待订阅状态同步,可点击“同步订阅”刷新。" + ), + ) + db.commit() + db.refresh(task) + return { + "success": True, + "verified": False, + "paid_confirmed": True, + "subscription_type": str(account.subscription_type or "free"), + "task": _serialize_bind_card_task(task), + "account_id": account.id, + "account_email": account.email, + "local_auto": browser_result, + } + + +@router.post("/bind-card/tasks/{task_id}/sync-subscription") +def sync_bind_card_task_subscription(task_id: int, request: SyncBindCardTaskRequest): + """同步任务账号订阅状态,并回写到账号管理""" + with get_db() as db: + task = db.query(BindCardTask).options(joinedload(BindCardTask.account)).filter(BindCardTask.id == task_id).first() + if not task: + raise HTTPException(status_code=404, detail="绑卡任务不存在") + account = task.account + if not account: + raise HTTPException(status_code=404, detail="任务关联账号不存在") + + proxy = _resolve_runtime_proxy(request.proxy, account) + now = datetime.utcnow() + try: + detail, refreshed = _check_subscription_detail_with_retry( + db=db, + account=account, + proxy=proxy, + allow_token_refresh=True, + ) + status = str(detail.get("status") or "free").lower() + source = str(detail.get("source") or "unknown") + confidence = str(detail.get("confidence") or "low") + logger.info( + "绑卡任务同步订阅: task_id=%s account_id=%s status=%s source=%s confidence=%s token_refreshed=%s", + task.id, account.id, status, source, confidence, refreshed + ) + except Exception as exc: + task.status = "failed" + task.last_error = str(exc) + task.last_checked_at = now + db.commit() + logger.warning("绑卡任务同步订阅失败: task_id=%s error=%s", task.id, exc) + raise HTTPException(status_code=500, detail=f"订阅检测失败: {exc}") + + # 仅在高置信度 free 时清空;低置信度 free 不覆盖已有订阅 + if status in ("plus", "team"): + account.subscription_type = status + account.subscription_at = now + elif status == "free": + if str(detail.get("confidence") or "").lower() == "high": + account.subscription_type = None + account.subscription_at = None + + task.last_checked_at = now + if status in ("plus", "team"): + task.status = "completed" + task.completed_at = now + task.last_error = None + else: + task.completed_at = None + note = str(detail.get("note") or "").strip() + confidence_text = str(detail.get("confidence") or "unknown") + confidence_lower = confidence_text.lower() + source_text = str(detail.get("source") or "unknown") + task_status_now = str(task.status or "") + has_checkout_context = bool(task.checkout_session_id or task.checkout_url) + should_keep_paid_pending = ( + task_status_now == "paid_pending_sync" + or ( + status == "free" + and confidence_lower != "high" + and has_checkout_context + and task_status_now in ("waiting_user_action", "verifying", "link_ready") + ) + ) + if should_keep_paid_pending: + task.status = "paid_pending_sync" + task.last_error = ( + f"已确认支付,订阅暂未同步(当前: {status}, source={source_text}, " + f"confidence={confidence_text}" + + (f", note={note}" if note else "") + + ")。可稍后再次点击“同步订阅”。" + ) + else: + task.status = "waiting_user_action" + task.last_error = None + if confidence_lower != "high": + task.last_error = ( + f"订阅判定低置信度(source={source_text}, " + f"confidence={confidence_text}" + + (f", note={note}" if note else "") + + "),请稍后重试。" + ) + + db.commit() + db.refresh(task) + + return { + "success": True, + "subscription_type": status, + "detail": detail, + "task": _serialize_bind_card_task(task), + "account_id": account.id, + "account_email": account.email, + } + + +@router.post("/bind-card/tasks/{task_id}/mark-user-action") +def mark_bind_card_task_user_action(task_id: int, request: MarkUserActionRequest): + """ + 用户确认“已完成支付”后,自动轮询订阅状态一段时间: + - 命中 plus/team -> completed + - 超时未命中 -> paid_pending_sync 或 waiting_user_action + """ + with get_db() as db: + task = db.query(BindCardTask).options(joinedload(BindCardTask.account)).filter(BindCardTask.id == task_id).first() + if not task: + raise HTTPException(status_code=404, detail="绑卡任务不存在") + account = task.account + if not account: + raise HTTPException(status_code=404, detail="任务关联账号不存在") + + proxy = _resolve_runtime_proxy(request.proxy, account) + timeout_seconds = int(request.timeout_seconds) + interval_seconds = int(request.interval_seconds) + logger.info( + "绑卡任务开始验证: task_id=%s account_id=%s timeout=%ss interval=%ss", + task.id, account.id, timeout_seconds, interval_seconds + ) + + previous_status = str(task.status or "") + now = datetime.utcnow() + task.status = "verifying" + task.last_error = None + task.last_checked_at = now + db.commit() + + deadline = time.monotonic() + timeout_seconds + checks = 0 + last_status = "free" + token_refresh_used = False + + while time.monotonic() < deadline: + checks += 1 + try: + detail, refreshed = _check_subscription_detail_with_retry( + db=db, + account=account, + proxy=proxy, + allow_token_refresh=not token_refresh_used, + ) + if refreshed: + token_refresh_used = True + status = str(detail.get("status") or "free").lower() + last_status = status + logger.info( + "绑卡任务验证轮询: task_id=%s attempt=%s status=%s source=%s confidence=%s token_refreshed=%s", + task.id, checks, status, detail.get("source"), detail.get("confidence"), bool(detail.get("token_refreshed")) + ) + except Exception as exc: + failed_at = datetime.utcnow() + task.status = "failed" + task.last_error = f"订阅检测失败: {exc}" + task.last_checked_at = failed_at + db.commit() + logger.warning("绑卡任务验证失败: task_id=%s attempt=%s error=%s", task.id, checks, exc) + raise HTTPException(status_code=500, detail=f"订阅检测失败: {exc}") + + checked_at = datetime.utcnow() + if status in ("plus", "team"): + account.subscription_type = status + account.subscription_at = checked_at + elif status == "free": + # 低置信度 free 不覆盖已有订阅,避免误判清空 + if str(detail.get("confidence") or "").lower() == "high": + account.subscription_type = None + account.subscription_at = None + task.last_checked_at = checked_at + + if status in ("plus", "team"): + task.status = "completed" + task.completed_at = checked_at + task.last_error = None + db.commit() + db.refresh(task) + logger.info( + "绑卡任务验证成功: task_id=%s attempts=%s status=%s source=%s", + task.id, checks, status, detail.get("source") + ) + return { + "success": True, + "verified": True, + "checks": checks, + "subscription_type": status, + "detail": detail, + "token_refresh_used": token_refresh_used, + "task": _serialize_bind_card_task(task), + "account_id": account.id, + "account_email": account.email, + } + + db.commit() + if time.monotonic() + interval_seconds >= deadline: + break + time.sleep(interval_seconds) + + timeout_msg = ( + f"在 {timeout_seconds} 秒内未检测到订阅变更(当前: {last_status}," + f"source={detail.get('source') if 'detail' in locals() else 'unknown'}," + f"confidence={detail.get('confidence') if 'detail' in locals() else 'unknown'}," + f"token_refreshed={token_refresh_used}" + + ( + f",note={str(detail.get('note') or '').strip()}" + if "detail" in locals() and detail and detail.get("note") + else "" + ) + + ")。" + ) + timeout_confidence = str((detail if "detail" in locals() else {}).get("confidence") or "unknown").lower() + timeout_source = str((detail if "detail" in locals() else {}).get("source") or "unknown") + should_keep_paid_pending = ( + previous_status == "paid_pending_sync" + or (last_status == "free" and timeout_confidence != "high") + ) + if should_keep_paid_pending: + task.status = "paid_pending_sync" + task.last_error = ( + timeout_msg + + "支付可能已完成但订阅同步延迟" + + f"(source={timeout_source}, confidence={timeout_confidence})。" + + "请稍后点“同步订阅”重试。" + ) + else: + task.status = "waiting_user_action" + task.last_error = timeout_msg + "请稍后点击“同步订阅”重试。" + task.last_checked_at = datetime.utcnow() + task.completed_at = None + db.commit() + db.refresh(task) + logger.warning( + "绑卡任务验证超时: task_id=%s attempts=%s last_status=%s last_error=%s", + task.id, checks, last_status, task.last_error + ) + + return { + "success": True, + "verified": False, + "checks": checks, + "subscription_type": last_status, + "detail": detail if "detail" in locals() else None, + "token_refresh_used": token_refresh_used, + "task": _serialize_bind_card_task(task), + "account_id": account.id, + "account_email": account.email, + } + + +@router.delete("/bind-card/tasks/{task_id}") +def delete_bind_card_task(task_id: int): + """删除绑卡任务""" + with get_db() as db: + task = db.query(BindCardTask).filter(BindCardTask.id == task_id).first() + if not task: + raise HTTPException(status_code=404, detail="绑卡任务不存在") + logger.info("删除绑卡任务: task_id=%s account_id=%s", task.id, task.account_id) + db.delete(task) + db.commit() + return {"success": True, "task_id": task_id} + + # ============== 订阅状态 ============== @router.post("/accounts/batch-check-subscription") def batch_check_subscription(request: BatchCheckSubscriptionRequest): """批量检测账号订阅状态""" - proxy = request.proxy or get_settings().proxy_url + explicit_proxy = _normalize_proxy_value(request.proxy) results = {"success_count": 0, "failed_count": 0, "details": []} @@ -144,13 +3241,35 @@ def batch_check_subscription(request: BatchCheckSubscriptionRequest): continue try: - status = check_subscription_status(account, proxy) - account.subscription_type = None if status == "free" else status - account.subscription_at = datetime.utcnow() if status != "free" else account.subscription_at + runtime_proxy = _resolve_runtime_proxy(explicit_proxy, account) + detail, refreshed = _check_subscription_detail_with_retry( + db=db, + account=account, + proxy=runtime_proxy, + allow_token_refresh=True, + ) + status = str(detail.get("status") or "free").lower() + confidence = str(detail.get("confidence") or "low").lower() + + if status in ("plus", "team"): + account.subscription_type = status + account.subscription_at = datetime.utcnow() + elif status == "free" and confidence == "high": + account.subscription_type = None + account.subscription_at = None + db.commit() results["success_count"] += 1 results["details"].append( - {"id": account_id, "email": account.email, "success": True, "subscription_type": status} + { + "id": account_id, + "email": account.email, + "success": True, + "subscription_type": status, + "confidence": confidence, + "source": detail.get("source"), + "token_refreshed": refreshed, + } ) except Exception as e: results["failed_count"] += 1 @@ -178,5 +3297,3 @@ def mark_subscription(account_id: int, request: MarkSubscriptionRequest): db.commit() return {"success": True, "subscription_type": request.subscription_type} - - diff --git a/src/web/routes/registration.py b/src/web/routes/registration.py index da386fe..b2cdcd5 100644 --- a/src/web/routes/registration.py +++ b/src/web/routes/registration.py @@ -462,12 +462,7 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: if not _svc: continue log_callback(f"[Sub2API] 正在把账号发往服务站: {_svc.name}") - _ok, _msg = upload_to_sub2api( - [saved_account], - _svc.api_url, - _svc.api_key, - proxy_id=_svc.default_remote_proxy_id, - ) + _ok, _msg = upload_to_sub2api([saved_account], _svc.api_url, _svc.api_key) log_callback(f"[Sub2API] {'成功' if _ok else '失败'}({_svc.name}): {_msg}") except Exception as _e: log_callback(f"[Sub2API] 异常({_sid}): {_e}") @@ -1017,9 +1012,14 @@ async def get_task_logs(task_uuid: str): raise HTTPException(status_code=404, detail="任务不存在") logs = task.logs or "" + result = task.result if isinstance(task.result, dict) else {} + email = result.get("email") + service_type = task.email_service.service_type if task.email_service else None return { "task_uuid": task_uuid, "status": task.status, + "email": email, + "email_service": service_type, "logs": logs.split("\n") if logs else [] } @@ -1068,15 +1068,33 @@ async def get_registration_stats(): func.count(RegistrationTask.id) ).group_by(RegistrationTask.status).all() - # 今日注册数 + # 今日统计 today = datetime.utcnow().date() + today_status_stats = db.query( + RegistrationTask.status, + func.count(RegistrationTask.id) + ).filter( + func.date(RegistrationTask.created_at) == today + ).group_by(RegistrationTask.status).all() + today_count = db.query(func.count(RegistrationTask.id)).filter( func.date(RegistrationTask.created_at) == today ).scalar() + today_by_status = {status: count for status, count in today_status_stats} + today_success = int(today_by_status.get("completed", 0)) + today_failed = int(today_by_status.get("failed", 0)) + today_total = int(today_count or 0) + today_success_rate = round((today_success / today_total) * 100, 1) if today_total > 0 else 0.0 + return { "by_status": {status: count for status, count in status_stats}, - "today_count": today_count + "today_count": today_total, + "today_total": today_total, + "today_success": today_success, + "today_failed": today_failed, + "today_success_rate": today_success_rate, + "today_by_status": today_by_status, } diff --git a/src/web/routes/settings.py b/src/web/routes/settings.py index d096fa4..692e7f1 100644 --- a/src/web/routes/settings.py +++ b/src/web/routes/settings.py @@ -6,7 +6,7 @@ import logging import os from typing import Optional -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, UploadFile, File from pydantic import BaseModel from ...config.settings import get_settings, update_settings @@ -49,6 +49,7 @@ class RegistrationSettings(BaseModel): default_password_length: int = 12 sleep_min: int = 5 sleep_max: int = 30 + entry_flow: str = "native" class WebUISettings(BaseModel): @@ -73,6 +74,9 @@ async def get_all_settings(): """获取所有设置""" settings = get_settings() + entry_flow_raw = str(settings.registration_entry_flow or "native").strip().lower() + entry_flow = "abcard" if entry_flow_raw == "abcard" else "native" + return { "proxy": { "enabled": settings.proxy_enabled, @@ -93,6 +97,7 @@ async def get_all_settings(): "default_password_length": settings.registration_default_password_length, "sleep_min": settings.registration_sleep_min, "sleep_max": settings.registration_sleep_max, + "entry_flow": entry_flow, }, "webui": { "host": settings.webui_host, @@ -202,24 +207,35 @@ async def get_registration_settings(): """获取注册设置""" settings = get_settings() + entry_flow_raw = str(settings.registration_entry_flow or "native").strip().lower() + entry_flow = "abcard" if entry_flow_raw == "abcard" else "native" + return { "max_retries": settings.registration_max_retries, "timeout": settings.registration_timeout, "default_password_length": settings.registration_default_password_length, "sleep_min": settings.registration_sleep_min, "sleep_max": settings.registration_sleep_max, + "entry_flow": entry_flow, } @router.post("/registration") async def update_registration_settings(request: RegistrationSettings): """更新注册设置""" + flow_raw = (request.entry_flow or "native").strip().lower() + # 兼容旧前端历史值:outlook -> native(Outlook 邮箱会在运行时自动走 outlook 链路)。 + flow = "native" if flow_raw == "outlook" else flow_raw + if flow not in {"native", "abcard"}: + raise HTTPException(status_code=400, detail="entry_flow 仅支持 native / abcard") + update_settings( registration_max_retries=request.max_retries, registration_timeout=request.timeout, registration_default_password_length=request.default_password_length, registration_sleep_min=request.sleep_min, registration_sleep_max=request.sleep_max, + registration_entry_flow=flow, ) return {"success": True, "message": "注册设置已更新"} @@ -308,6 +324,97 @@ async def backup_database(): } +@router.post("/database/import") +async def import_database(file: UploadFile = File(...)): + """导入数据库(自动备份后覆盖当前 SQLite 文件)""" + import shutil + import tempfile + from datetime import datetime + from pathlib import Path as FilePath + from ...database.session import get_session_manager + + settings = get_settings() + + db_path = settings.database_url + if not db_path.startswith("sqlite:///"): + raise HTTPException(status_code=400, detail="当前仅支持 SQLite 数据库导入") + + db_path = db_path[10:] + db_file = FilePath(db_path) + + # 校验上传扩展名 + filename = (file.filename or "").lower() + allowed_ext = (".db", ".sqlite", ".sqlite3") + if filename and not filename.endswith(allowed_ext): + raise HTTPException(status_code=400, detail="仅支持 .db / .sqlite / .sqlite3 文件") + + if not db_file.exists(): + raise HTTPException(status_code=404, detail="数据库文件不存在") + + # 先落地到临时文件,再校验头,避免脏写 + temp_path = None + try: + db_file.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + prefix="db_import_", + suffix=".db", + dir=str(db_file.parent), + delete=False + ) as tmp: + temp_path = FilePath(tmp.name) + while True: + chunk = await file.read(1024 * 1024) + if not chunk: + break + tmp.write(chunk) + + if not temp_path.exists() or temp_path.stat().st_size < 100: + raise HTTPException(status_code=400, detail="导入文件无效或为空") + + # SQLite 文件头校验 + with temp_path.open("rb") as f: + header = f.read(16) + if not header.startswith(b"SQLite format 3\x00"): + raise HTTPException(status_code=400, detail="文件不是有效的 SQLite 数据库") + + # 先释放数据库连接,避免 Windows 下文件被占用 + session_manager = get_session_manager() + session_manager.engine.dispose() + + # 导入前自动备份 + backup_dir = db_file.parent / "backups" + backup_dir.mkdir(exist_ok=True) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + backup_path = backup_dir / f"database_backup_before_import_{timestamp}.db" + shutil.copy2(db_file, backup_path) + + # 清理 WAL/SHM,避免替换后出现旧事务残留 + wal_file = FilePath(f"{db_file}-wal") + shm_file = FilePath(f"{db_file}-shm") + for sidecar in (wal_file, shm_file): + try: + if sidecar.exists(): + sidecar.unlink() + except Exception: + logger.warning("清理 SQLite 附属文件失败: %s", sidecar) + + os.replace(str(temp_path), str(db_file)) + + logger.info("数据库导入成功: file=%s backup=%s", file.filename, backup_path) + return { + "success": True, + "message": "数据库导入成功", + "backup_path": str(backup_path), + } + finally: + await file.close() + if temp_path and temp_path.exists(): + try: + temp_path.unlink() + except Exception: + pass + + @router.post("/database/cleanup") async def cleanup_database( days: int = 30, diff --git a/src/web/routes/upload/sub2api_services.py b/src/web/routes/upload/sub2api_services.py index 7843ea2..ddd7759 100644 --- a/src/web/routes/upload/sub2api_services.py +++ b/src/web/routes/upload/sub2api_services.py @@ -8,11 +8,7 @@ from pydantic import BaseModel from ....database import crud from ....database.session import get_db -from ....core.upload.sub2api_upload import ( - batch_upload_to_sub2api, - fetch_remote_sub2api_proxies, - test_sub2api_connection, -) +from ....core.upload.sub2api_upload import test_sub2api_connection, batch_upload_to_sub2api router = APIRouter() @@ -23,7 +19,6 @@ class Sub2ApiServiceCreate(BaseModel): name: str api_url: str api_key: str - default_remote_proxy_id: Optional[int] = None enabled: bool = True priority: int = 0 @@ -32,7 +27,6 @@ class Sub2ApiServiceUpdate(BaseModel): name: Optional[str] = None api_url: Optional[str] = None api_key: Optional[str] = None - default_remote_proxy_id: Optional[int] = None enabled: Optional[bool] = None priority: Optional[int] = None @@ -42,7 +36,6 @@ class Sub2ApiServiceResponse(BaseModel): name: str api_url: str has_key: bool - default_remote_proxy_id: Optional[int] = None enabled: bool priority: int created_at: Optional[str] = None @@ -53,10 +46,8 @@ class Sub2ApiServiceResponse(BaseModel): class Sub2ApiTestRequest(BaseModel): - service_id: Optional[int] = None api_url: Optional[str] = None api_key: Optional[str] = None - default_remote_proxy_id: Optional[int] = None class Sub2ApiUploadRequest(BaseModel): @@ -64,22 +55,6 @@ class Sub2ApiUploadRequest(BaseModel): service_id: Optional[int] = None concurrency: int = 3 priority: int = 50 - proxy_id: Optional[int] = None - - -class Sub2ApiRemoteProxyResponse(BaseModel): - id: int - name: str - protocol: str - host: str - port: int - username: str = "" - status: str - - -class Sub2ApiRemoteProxyListResponse(BaseModel): - service: Sub2ApiServiceResponse - proxies: List[Sub2ApiRemoteProxyResponse] def _to_response(svc) -> Sub2ApiServiceResponse: @@ -88,7 +63,6 @@ def _to_response(svc) -> Sub2ApiServiceResponse: name=svc.name, api_url=svc.api_url, has_key=bool(svc.api_key), - default_remote_proxy_id=svc.default_remote_proxy_id, enabled=svc.enabled, priority=svc.priority, created_at=svc.created_at.isoformat() if svc.created_at else None, @@ -96,52 +70,6 @@ def _to_response(svc) -> Sub2ApiServiceResponse: ) -def _resolve_sub2api_service(service_id: Optional[int] = None): - with get_db() as db: - if service_id: - svc = crud.get_sub2api_service_by_id(db, service_id) - else: - services = crud.get_sub2api_services(db, enabled=True) - svc = services[0] if services else None - - if not svc: - raise HTTPException(status_code=400, detail="未找到可用的 Sub2API 服务") - return svc - - -def _to_remote_proxy_response(proxy: dict) -> Sub2ApiRemoteProxyResponse: - proxy_id = proxy.get("id") - if proxy_id is None: - raise ValueError("远端 Sub2API 代理缺少 id") - - return Sub2ApiRemoteProxyResponse( - id=int(proxy_id), - name=str(proxy.get("name") or "").strip() or f"Proxy {proxy_id}", - protocol=str(proxy.get("protocol") or "").strip(), - host=str(proxy.get("host") or "").strip(), - port=int(proxy.get("port") or 0), - username=str(proxy.get("username") or "").strip(), - status=str(proxy.get("status") or "inactive").strip() or "inactive", - ) - - -def _resolve_temp_sub2api_request(request: Sub2ApiTestRequest): - svc = None - if request.service_id is not None: - with get_db() as db: - svc = crud.get_sub2api_service_by_id(db, request.service_id) - if not svc: - raise HTTPException(status_code=404, detail="Sub2API 服务不存在") - - api_url = (request.api_url or (svc.api_url if svc else "")).strip() - api_key = (request.api_key or (svc.api_key if svc else "")).strip() - - if not api_url or not api_key: - raise HTTPException(status_code=400, detail="api_url 和 api_key 不能为空") - - return svc, api_url, api_key - - # ============== API Endpoints ============== @router.get("", response_model=List[Sub2ApiServiceResponse]) @@ -163,55 +91,10 @@ async def create_sub2api_service(request: Sub2ApiServiceCreate): api_key=request.api_key, enabled=request.enabled, priority=request.priority, - default_remote_proxy_id=request.default_remote_proxy_id, ) return _to_response(svc) -@router.get("/remote-proxies", response_model=Sub2ApiRemoteProxyListResponse) -async def list_remote_sub2api_proxies(service_id: Optional[int] = None): - """拉取目标 Sub2API 服务中的远端代理列表""" - svc = _resolve_sub2api_service(service_id) - - try: - proxies = fetch_remote_sub2api_proxies(svc.api_url, svc.api_key) - proxy_items = [_to_remote_proxy_response(proxy) for proxy in proxies] - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) from e - - return Sub2ApiRemoteProxyListResponse( - service=_to_response(svc), - proxies=proxy_items, - ) - - -@router.post("/remote-proxies", response_model=Sub2ApiRemoteProxyListResponse) -async def list_remote_sub2api_proxies_direct(request: Sub2ApiTestRequest): - """按临时 Sub2API 配置拉取远端代理列表(用于新增前选择默认代理)""" - svc, api_url, api_key = _resolve_temp_sub2api_request(request) - - try: - proxies = fetch_remote_sub2api_proxies(api_url, api_key) - proxy_items = [_to_remote_proxy_response(proxy) for proxy in proxies] - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) from e - - return Sub2ApiRemoteProxyListResponse( - service=_to_response(svc) if svc else Sub2ApiServiceResponse( - id=0, - name="未保存服务", - api_url=api_url, - has_key=bool(api_key), - default_remote_proxy_id=request.default_remote_proxy_id, - enabled=True, - priority=0, - created_at=None, - updated_at=None, - ), - proxies=proxy_items, - ) - - @router.get("/{service_id}", response_model=Sub2ApiServiceResponse) async def get_sub2api_service(service_id: int): """获取单个 Sub2API 服务详情""" @@ -234,7 +117,6 @@ async def get_sub2api_service_full(service_id: int): "name": svc.name, "api_url": svc.api_url, "api_key": svc.api_key, - "default_remote_proxy_id": svc.default_remote_proxy_id, "enabled": svc.enabled, "priority": svc.priority, } @@ -256,8 +138,6 @@ async def update_sub2api_service(service_id: int, request: Sub2ApiServiceUpdate) # api_key 留空则保持原值 if request.api_key: update_data["api_key"] = request.api_key - if "default_remote_proxy_id" in request.model_fields_set: - update_data["default_remote_proxy_id"] = request.default_remote_proxy_id if request.enabled is not None: update_data["enabled"] = request.enabled if request.priority is not None: @@ -292,8 +172,9 @@ async def test_sub2api_service(service_id: int): @router.post("/test-connection") async def test_sub2api_connection_direct(request: Sub2ApiTestRequest): """直接测试 Sub2API 连接(用于添加前验证)""" - _svc, api_url, api_key = _resolve_temp_sub2api_request(request) - success, message = test_sub2api_connection(api_url, api_key) + if not request.api_url or not request.api_key: + raise HTTPException(status_code=400, detail="api_url 和 api_key 不能为空") + success, message = test_sub2api_connection(request.api_url, request.api_key) return {"success": success, "message": message} @@ -316,14 +197,11 @@ async def upload_accounts_to_sub2api(request: Sub2ApiUploadRequest): api_url = svc.api_url api_key = svc.api_key - selected_proxy_id = request.proxy_id if "proxy_id" in request.model_fields_set else svc.default_remote_proxy_id - results = batch_upload_to_sub2api( request.account_ids, api_url, api_key, concurrency=request.concurrency, priority=request.priority, - proxy_id=selected_proxy_id, ) return results diff --git a/src/web/task_manager.py b/src/web/task_manager.py index 0e10065..fde9adf 100644 --- a/src/web/task_manager.py +++ b/src/web/task_manager.py @@ -198,6 +198,16 @@ class TaskManager: _task_status[task_uuid]["status"] = status _task_status[task_uuid].update(kwargs) + # 与批量任务保持一致:状态变更后主动广播,避免前端只停留在初始 pending。 + if self._loop and self._loop.is_running(): + try: + asyncio.run_coroutine_threadsafe( + self.broadcast_status(task_uuid, status, **kwargs), + self._loop, + ) + except Exception as e: + logger.warning(f"广播任务状态失败: {e}") + def get_status(self, task_uuid: str) -> Optional[dict]: """获取任务状态""" return _task_status.get(task_uuid) diff --git a/static/css/style.css b/static/css/style.css index 76305a2..a82b48a 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -357,171 +357,6 @@ body { border-top: 1px solid var(--border-light); } -.sub2api-remote-proxy-row { - display: flex; - align-items: stretch; - gap: var(--spacing-sm); -} - -.sub2api-remote-proxy-picker { - position: relative; - flex: 1; -} - -.sub2api-remote-proxy-trigger { - width: 100%; - min-height: 44px; - padding: 10px 14px; - border: 1px solid var(--border); - border-radius: var(--radius); - background: var(--surface); - color: var(--text-primary); - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--spacing-sm); - cursor: pointer; - transition: all var(--transition); - text-align: left; -} - -.sub2api-remote-proxy-trigger:hover, -.sub2api-remote-proxy-trigger[aria-expanded="true"] { - border-color: var(--primary-color); - box-shadow: 0 0 0 3px var(--primary-light); -} - -.sub2api-remote-proxy-trigger-content { - flex: 1; - min-width: 0; -} - -.sub2api-remote-proxy-trigger-arrow { - color: var(--text-muted); - font-size: 0.875rem; - flex-shrink: 0; -} - -.sub2api-remote-proxy-placeholder { - color: var(--text-muted); - font-size: 0.875rem; -} - -.sub2api-remote-proxy-summary-title-row, -.sub2api-remote-proxy-option-title-row { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 6px; -} - -.sub2api-remote-proxy-summary-name, -.sub2api-remote-proxy-option-name { - font-size: 0.875rem; - font-weight: 500; - color: var(--text-primary); -} - -.sub2api-remote-proxy-summary-meta, -.sub2api-remote-proxy-option-meta { - margin-top: 4px; - font-size: 0.75rem; - color: var(--text-muted); - line-height: 1.4; -} - -.sub2api-remote-proxy-pill { - display: inline-flex; - align-items: center; - padding: 2px 8px; - border-radius: var(--radius-full); - background: var(--info-light); - color: var(--info-color); - font-size: 0.7rem; - font-weight: 600; - letter-spacing: 0.02em; -} - -.sub2api-remote-proxy-dropdown { - position: absolute; - top: calc(100% + 6px); - left: 0; - right: 0; - z-index: 20; - display: none; - background: var(--surface); - border: 1px solid var(--border); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-lg); - overflow: hidden; -} - -.sub2api-remote-proxy-dropdown.active { - display: block; -} - -.sub2api-remote-proxy-options { - max-height: 320px; - overflow-y: auto; - padding: var(--spacing-sm); - display: flex; - flex-direction: column; - gap: var(--spacing-sm); -} - -.sub2api-remote-proxy-option { - width: 100%; - border: 1px solid var(--border); - border-radius: var(--radius); - background: var(--surface); - color: inherit; - padding: 10px 12px; - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - cursor: pointer; - transition: all var(--transition-fast); - text-align: left; -} - -.sub2api-remote-proxy-option:hover { - background: var(--surface-hover); - border-color: var(--text-muted); -} - -.sub2api-remote-proxy-option.selected { - border-color: var(--primary-color); - background: var(--primary-light); -} - -.sub2api-remote-proxy-option-main { - min-width: 0; - flex: 1; -} - -.sub2api-remote-proxy-option-check { - flex-shrink: 0; - font-size: 0.75rem; - font-weight: 600; - color: var(--text-muted); -} - -.sub2api-remote-proxy-option.selected .sub2api-remote-proxy-option-check { - color: var(--primary-color); -} - -.sub2api-remote-proxy-btn { - flex-shrink: 0; -} - -.sub2api-remote-proxy-hint { - margin-top: var(--spacing-xs); - font-size: 0.75rem; - color: var(--text-muted); - line-height: 1.5; -} - /* Checkbox 样式优化 */ .form-group input[type="checkbox"] { width: auto; @@ -590,6 +425,17 @@ body { transform: translateY(-1px); } +.btn-success { + background: #10a37f; + color: #fff; +} + +.btn-success:hover:not(:disabled) { + background: #0f8f70; + transform: translateY(-1px); + box-shadow: var(--shadow-md); +} + .btn-warning { background: var(--warning-color); color: white; @@ -856,6 +702,11 @@ body { border-radius: var(--radius); } +/* 邮箱服务页:仅拉高“自定义邮箱服务”模块 */ +.custom-email-services-card .table-container { + min-height: 320px; +} + .data-table { width: 100%; border-collapse: collapse; @@ -1455,6 +1306,11 @@ body { margin: var(--spacing-md); max-height: 90vh; } + + .custom-email-services-card .table-container { + min-height: 220px; + } + } @media (max-width: 480px) { diff --git a/static/js/accounts.js b/static/js/accounts.js index 60c86cc..7038e04 100644 --- a/static/js/accounts.js +++ b/static/js/accounts.js @@ -304,7 +304,7 @@ function renderAccounts(accounts) { : '-'}
账号总体状态、分布和订阅账号卡片视图
+页面占位
+页面占位
+统一查看后端运行日志,支持筛选、自动刷新、定时清理
+为账号生成 Plus 或 Team 订阅支付链接
个人订阅,$20/月
-团队订阅,按座位计费
-个人订阅,$20/月
+团队订阅,按座位计费
+用于后续自动填卡流程准备。当前阶段不会把完整卡号/CVC写入后端数据库。
+ +建议优先点“直接打开支付页”;“无痕打开”仅在本机部署后端时可用。
+| ID | +邮箱 | +套餐 | +状态 | +Checkout | +来源 | +创建时间 | +操作 | +
|---|---|---|---|---|---|---|---|
|
+ 加载中...
+ |
+ |||||||
+ 说明:支持三种流程:半自动(人工提交)、全自动绑卡(本地浏览器执行)、 + 第三方自动绑卡(外部 API 提交);系统会在任务列表中自动跟踪订阅回调状态。 +