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 @@ [![Python](https://img.shields.io/badge/Python-3.10%2B-blue.svg)](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) { : '-'} ${getServiceTypeText(account.email_service)} - ${getStatusIcon(account.status)} + ${renderAccountStatusDot(account.status)}
${account.cpa_uploaded @@ -313,23 +313,19 @@ function renderAccounts(accounts) {
-
- ${account.subscription_type - ? `${account.subscription_type}` - : `-`} -
+ ${renderSubscriptionStatus(account.subscription_type)} ${format.date(account.last_refresh) || '-'}
+ @@ -382,6 +378,49 @@ function renderAccounts(accounts) { renderSelectAllBanner(); } +function normalizeSubscriptionType(subscriptionType) { + const raw = String(subscriptionType || '').trim().toLowerCase(); + if (!raw) return ''; + if (raw.includes('team') || raw.includes('enterprise')) return 'team'; + if (raw.includes('plus') || raw.includes('pro')) return 'plus'; + if (raw.includes('free') || raw.includes('basic')) return 'free'; + return raw; +} + +function hasActiveSubscription(subscriptionType) { + const normalized = normalizeSubscriptionType(subscriptionType); + return normalized === 'plus' || normalized === 'team'; +} + +function renderAccountStatusDot(status) { + const normalized = String(status || '').trim().toLowerCase(); + const dotClass = ['active', 'expired', 'banned', 'failed'].includes(normalized) + ? normalized + : 'unknown'; + const title = getStatusText('account', normalized) || normalized || '-'; + return ` + + `; +} + +function renderSubscriptionStatus(subscriptionType) { + const normalized = normalizeSubscriptionType(subscriptionType); + const subscribed = hasActiveSubscription(normalized); + const dotClass = subscribed ? 'subscribed' : 'unsubscribed'; + const label = subscribed ? normalized.toUpperCase() : 'FREE'; + const title = subscribed + ? `已订阅: ${normalized}` + : '未检测到 Plus/Team 订阅'; + return ` +
+ + ${escapeHtml(label)} +
+ `; +} + // 切换密码显示 function togglePassword(element, password) { if (element.dataset.revealed === 'true') { @@ -621,6 +660,25 @@ async function viewAccount(id) { ${tokens.refresh_token ? `` : ''}
+
+ Session Token +
+ ${escapeHtml(tokens.session_token || account.session_token || '-')} + ${(tokens.session_token || account.session_token) + ? `` + : '' + } + + ${tokens.session_token_source ? `来源: ${escapeHtml(tokens.session_token_source)}` : ''} +
+
+
+ Device ID +
+ ${escapeHtml(tokens.device_id || account.device_id || '-')} + ${(tokens.device_id || account.device_id) ? `` : ''} +
+
Cookies(支付用)
@@ -646,6 +704,37 @@ async function viewAccount(id) { } } +async function bootstrapSessionToken(id) { + try { + const result = await api.post(`/payment/accounts/${id}/session-bootstrap`, {}); + if (result && result.success) { + toast.success('Session Token 补全成功'); + } else { + toast.warning(result?.message || '未补全到 Session Token'); + } + } catch (error) { + toast.error('补全 Session Token 失败: ' + error.message); + } finally { + await viewAccount(id); + loadAccounts(); + } +} + +async function editSessionToken(id, currentToken = '') { + const current = String(currentToken || ''); + const nextToken = window.prompt('请输入新的 Session Token(留空将清空)', current); + if (nextToken === null) return; + try { + await api.patch(`/accounts/${id}`, { session_token: String(nextToken).trim() }); + toast.success('Session Token 已更新'); + } catch (error) { + toast.error('更新 Session Token 失败: ' + error.message); + } finally { + await viewAccount(id); + loadAccounts(); + } +} + // 复制邮箱 function copyEmail(email) { copyToClipboard(email); @@ -1022,83 +1111,6 @@ function selectSub2ApiService() { }); } -function selectSub2ApiProxy(serviceChoice) { - return new Promise(async (resolve) => { - const modal = document.getElementById('sub2api-proxy-modal'); - const listEl = document.getElementById('sub2api-proxy-list'); - const closeBtn = document.getElementById('close-sub2api-proxy-modal'); - const cancelBtn = document.getElementById('cancel-sub2api-proxy-modal-btn'); - const noProxyBtn = document.getElementById('sub2api-no-proxy-btn'); - - listEl.innerHTML = '
加载中...
'; - modal.classList.add('active'); - - let proxies = []; - try { - const query = serviceChoice && serviceChoice.service_id != null - ? `?service_id=${encodeURIComponent(serviceChoice.service_id)}` - : ''; - const result = await api.get(`/sub2api-services/remote-proxies${query}`); - proxies = result.proxies || []; - } catch (e) { - cleanup(); - toast.error('加载远端 Sub2API 代理失败: ' + e.message); - resolve(null); - return; - } - - if (proxies.length === 0) { - listEl.innerHTML = '
该 Sub2API 服务下暂无可选远端代理,可选择不使用代理继续上传
'; - } else { - listEl.innerHTML = proxies.map(proxy => ` -
-
-
- #${proxy.id} ${escapeHtml(proxy.name)} - ${escapeHtml(String(proxy.protocol || '').toUpperCase())} - ${escapeHtml(proxy.status || 'inactive')} -
-
${escapeHtml(proxy.host)}:${proxy.port}
-
- 选择 -
- `).join(''); - - listEl.querySelectorAll('.sub2api-service-item').forEach(item => { - item.addEventListener('mouseenter', () => item.style.background = 'var(--surface-hover)'); - item.addEventListener('mouseleave', () => item.style.background = ''); - item.addEventListener('click', () => { - cleanup(); - resolve({ proxy_id: parseInt(item.dataset.id) }); - }); - }); - } - - function cleanup() { - modal.classList.remove('active'); - closeBtn.removeEventListener('click', onCancel); - cancelBtn.removeEventListener('click', onCancel); - noProxyBtn.removeEventListener('click', onNoProxy); - } - function onCancel() { cleanup(); resolve(null); } - function onNoProxy() { cleanup(); resolve({ proxy_id: null }); } - - closeBtn.addEventListener('click', onCancel); - cancelBtn.addEventListener('click', onCancel); - noProxyBtn.addEventListener('click', onNoProxy); - }); -} - // 批量上传到 Sub2API async function handleBatchUploadSub2Api() { const count = getEffectiveCount(); @@ -1107,9 +1119,6 @@ async function handleBatchUploadSub2Api() { const choice = await selectSub2ApiService(); if (choice === null) return; // 用户取消 - const proxyInput = await selectSub2ApiProxy(choice); - if (proxyInput === null) return; - const confirmed = await confirm(`确定要将选中的 ${count} 个账号上传到 Sub2API 吗?`); if (!confirmed) return; @@ -1119,7 +1128,6 @@ async function handleBatchUploadSub2Api() { try { const payload = buildBatchPayload(); if (choice.service_id != null) payload.service_id = choice.service_id; - payload.proxy_id = proxyInput.proxy_id; const result = await api.post('/accounts/batch-upload-sub2api', payload); let message = `成功: ${result.success_count}`; @@ -1141,15 +1149,10 @@ async function handleBatchUploadSub2Api() { async function uploadToSub2Api(id) { const choice = await selectSub2ApiService(); if (choice === null) return; - - const proxyInput = await selectSub2ApiProxy(choice); - if (proxyInput === null) return; - try { toast.info('正在上传到 Sub2API...'); const payload = {}; if (choice.service_id != null) payload.service_id = choice.service_id; - payload.proxy_id = proxyInput.proxy_id; const result = await api.post(`/accounts/${id}/upload-sub2api`, payload); if (result.success) { toast.success('上传成功'); diff --git a/static/js/accounts_overview.js b/static/js/accounts_overview.js new file mode 100644 index 0000000..1d36030 --- /dev/null +++ b/static/js/accounts_overview.js @@ -0,0 +1,1050 @@ +/** + * 账号总览页面脚本 + * 上半区:原有总览布局 + * 下半区:卡片管理布局(按设计图增强) + */ + +const VIEW_MODE_STORAGE_KEY = 'accounts_overview_view_mode'; + +const overviewState = { + summary: null, + cards: [], + filteredCards: [], + selectedCardIds: new Set(), + addableCards: [], + viewMode: storage.get(VIEW_MODE_STORAGE_KEY, 'grid') || 'grid', + planFilter: 'all', + sortMode: 'created_desc', + cardRefreshIntervalMin: 7, + cardRefreshTimer: null, + cardCountdownTimer: null, + cardNextRefreshAt: null, +}; + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text ?? ''; + return div.innerHTML; +} + +function setText(id, value) { + const el = document.getElementById(id); + if (el) el.textContent = format.number(value || 0); +} + +function toSortedEntries(data) { + return Object.entries(data || {}).sort((a, b) => Number(b[1] || 0) - Number(a[1] || 0)); +} + +const SERVICE_DIST_PALETTE = [ + '#3b82f6', // blue + '#10b981', // emerald + '#f59e0b', // amber + '#8b5cf6', // violet + '#ef4444', // red + '#06b6d4', // cyan + '#84cc16', // lime + '#f97316', // orange +]; + +function hashText(text) { + const str = String(text || ''); + let hash = 0; + for (let i = 0; i < str.length; i += 1) { + hash = ((hash << 5) - hash) + str.charCodeAt(i); + hash |= 0; + } + return Math.abs(hash); +} + +function getDistributionBarColor(containerId, key, index) { + const value = String(key || '').trim().toLowerCase(); + + if (containerId === 'dist-subscription') { + if (value.includes('team')) return '#8b5cf6'; // team: purple + if (value.includes('plus')) return '#10b981'; + if (value.includes('pro')) return '#0ea5e9'; + if (value.includes('free')) return '#94a3b8'; + } + + if (containerId === 'dist-status') { + if (value === 'active') return '#f59e0b'; // active: orange + if (value === 'failed' || value === 'banned') return '#ef4444'; + if (value === 'expired') return '#64748b'; + return '#3b82f6'; + } + + if (containerId === 'dist-service') { + const paletteIndex = hashText(value || index) % SERVICE_DIST_PALETTE.length; + return SERVICE_DIST_PALETTE[paletteIndex]; + } + + if (containerId === 'dist-source') { + if (value === 'register') return '#3b82f6'; + if (value === 'login') return '#10b981'; + } + + return '#3b82f6'; +} + +function renderDistribution(containerId, data, labelFormatter) { + const container = document.getElementById(containerId); + if (!container) return; + + const entries = toSortedEntries(data); + if (!entries.length) { + container.innerHTML = '
暂无数据
'; + return; + } + + const maxValue = Math.max(...entries.map(([, count]) => Number(count || 0)), 1); + container.innerHTML = entries.map(([key, count], index) => { + const value = Number(count || 0); + const width = Math.max((value / maxValue) * 100, 2); + const label = labelFormatter ? labelFormatter(key) : key; + const barColor = getDistributionBarColor(containerId, key, index); + return ` +
+
${escapeHtml(label)}
+
+
${format.number(value)}
+
+ `; + }).join(''); +} + +function formatSource(value) { + const sourceMap = { + register: '注册', + login: '登录', + unknown: '未知', + }; + return sourceMap[value] || value || '-'; +} + +function formatSubscription(value) { + const key = (value || 'free').toLowerCase(); + const map = { + free: '免费', + plus: 'Plus', + team: 'Team', + pro: 'Pro', + }; + return map[key] || key; +} + +function normalizePlan(planType) { + const value = String(planType || '').toLowerCase(); + if (value.includes('team')) return 'team'; + if (value.includes('plus')) return 'plus'; + if (value.includes('pro')) return 'pro'; + if (value.includes('free') || value.includes('basic')) return 'free'; + return 'free'; +} + +function getPlanText(planType) { + const plan = normalizePlan(planType); + if (plan === 'team') return 'TEAM'; + if (plan === 'plus') return 'PLUS'; + if (plan === 'pro') return 'PRO'; + return 'FREE'; +} + +function getPlanClass(planType) { + const plan = normalizePlan(planType); + if (plan === 'team') return 'team'; + if (plan === 'plus' || plan === 'pro') return 'plus'; + return 'free'; +} + +function parsePercent(quota) { + const raw = quota?.percentage; + if (raw === null || raw === undefined || Number.isNaN(Number(raw))) return null; + return Math.max(0, Math.min(100, Number(raw))); +} + +function getProgressTone(percentage) { + if (percentage === null) { + return { valueClass: 'tone-gray', barClass: 'tone-gray-bar' }; + } + if (percentage < 10) { + return { valueClass: 'tone-red', barClass: 'tone-red-bar' }; + } + if (percentage >= 90) { + return { valueClass: 'tone-green', barClass: 'tone-green-bar' }; + } + return { valueClass: 'tone-orange', barClass: 'tone-orange-bar' }; +} + +function parseTimeMs(value) { + const ms = Date.parse(value || ''); + return Number.isNaN(ms) ? null : ms; +} + +function formatMinuteCountdown(targetMs) { + if (!targetMs) return '-'; + const diffMs = targetMs - Date.now(); + if (diffMs <= 0) return '0分'; + + const totalMinutes = Math.max(1, Math.ceil(diffMs / 60000)); + const days = Math.floor(totalMinutes / 1440); + const hours = Math.floor((totalMinutes % 1440) / 60); + const mins = totalMinutes % 60; + + if (days > 0) return `${days}天${hours}小时${mins}分`; + if (hours > 0) return `${hours}小时${mins}分`; + return `${mins}分`; +} + +function formatQuotaResetText(quota) { + const resetAt = parseTimeMs(quota?.reset_at); + const percent = parsePercent(quota); + if (resetAt) { + const absolute = format.date(new Date(resetAt).toISOString()); + if (percent !== null && percent <= 0) { + return `已用尽,下次 ${absolute}`; + } + return `${formatMinuteCountdown(resetAt)} (${absolute})`; + } + return quota?.reset_in_text || '-'; +} + +function renderQuotaItem(title, quota) { + const percent = parsePercent(quota); + const tone = getProgressTone(percent); + const showPercent = percent === null ? '--' : `${Math.round(percent)}%`; + const width = percent === null ? 0 : percent; + const resetText = formatQuotaResetText(quota); + + return ` +
+
+ ${escapeHtml(title)} + ${showPercent} +
+
+
重置: ${escapeHtml(resetText)}
+
+ `; +} + +function renderRecentAccounts(accounts) { + const tbody = document.getElementById('recent-accounts-table'); + if (!tbody) return; + + if (!accounts || !accounts.length) { + tbody.innerHTML = ` + +
暂无账号数据
+ + `; + return; + } + + tbody.innerHTML = accounts.map((item) => ` + + ${item.id || '-'} + ${escapeHtml(item.email || '-')} + ${escapeHtml(getStatusText('account', item.status) || '-')} + ${escapeHtml(getServiceTypeText(item.email_service || '') || '-')} + ${escapeHtml(formatSource(item.source))} + ${escapeHtml(formatSubscription(item.subscription_type))} + ${format.date(item.created_at)} + ${format.date(item.last_refresh)} + + `).join(''); +} + +async function loadLegacyOverview() { + try { + const data = await api.get('/accounts/stats/overview'); + overviewState.summary = data; + + setText('ov-total', data.total); + setText('ov-active', data.active_count); + setText('ov-access-token', data.token_stats?.with_access_token || 0); + setText('ov-cpa-uploaded', data.cpa_uploaded_count); + + renderDistribution('dist-status', data.by_status, (status) => getStatusText('account', status)); + renderDistribution('dist-service', data.by_email_service, (service) => getServiceTypeText(service)); + renderDistribution('dist-subscription', data.by_subscription, formatSubscription); + renderDistribution('dist-source', data.by_source, formatSource); + renderRecentAccounts(data.recent_accounts || []); + } catch (error) { + toast.error(`加载总览统计失败: ${error.message || '未知错误'}`); + } +} + +function updatePlanFilterOptions() { + const select = document.getElementById('card-plan-filter'); + if (!select) return; + + const sourceCards = overviewState.cards; + const total = sourceCards.length; + const freeCount = sourceCards.filter((item) => normalizePlan(item.plan_type) === 'free').length; + const plusCount = sourceCards.filter((item) => normalizePlan(item.plan_type) === 'plus').length; + const teamCount = sourceCards.filter((item) => normalizePlan(item.plan_type) === 'team').length; + const currentCount = sourceCards.filter((item) => Boolean(item.current)).length; + + const currentValue = overviewState.planFilter; + select.innerHTML = ` + + + + + + `; + select.value = ['all', 'free', 'plus', 'team', 'current'].includes(currentValue) ? currentValue : 'all'; +} + +function syncSelectedCardIds() { + const existing = new Set( + overviewState.cards + .map((item) => Number(item.id)) + .filter((id) => Number.isFinite(id)) + ); + const next = new Set(); + for (const id of overviewState.selectedCardIds) { + if (existing.has(id)) next.add(id); + } + overviewState.selectedCardIds = next; +} + +function updateSelectionInfo() { + const info = document.getElementById('card-selection-info'); + if (!info) return; + const selected = overviewState.selectedCardIds.size; + const total = overviewState.filteredCards.length; + info.textContent = `已选择 ${selected} 个账号 / 当前列表 ${total} 个`; +} + +function parseDateScore(value) { + const ts = Date.parse(value || ''); + return Number.isNaN(ts) ? 0 : ts; +} + +function sortCards(cards) { + const rows = [...cards]; + const sortMode = overviewState.sortMode; + + rows.sort((a, b) => { + if (sortMode === 'created_asc') { + return parseDateScore(a.created_at) - parseDateScore(b.created_at); + } + if (sortMode === 'hourly_desc') { + return (parsePercent(b.hourly_quota) ?? -1) - (parsePercent(a.hourly_quota) ?? -1); + } + if (sortMode === 'weekly_desc') { + return (parsePercent(b.weekly_quota) ?? -1) - (parsePercent(a.weekly_quota) ?? -1); + } + if (sortMode === 'email_asc') { + return String(a.email || '').localeCompare(String(b.email || ''), 'zh-CN'); + } + return parseDateScore(b.created_at) - parseDateScore(a.created_at); + }); + + return rows; +} + +function applyCardFilters() { + const keyword = (document.getElementById('card-search-input')?.value || '').trim().toLowerCase(); + const planFilter = overviewState.planFilter; + + let rows = [...overviewState.cards]; + + if (keyword) { + rows = rows.filter((item) => String(item.email || '').toLowerCase().includes(keyword)); + } + + if (planFilter === 'free') { + rows = rows.filter((item) => normalizePlan(item.plan_type) === 'free'); + } else if (planFilter === 'plus') { + rows = rows.filter((item) => normalizePlan(item.plan_type) === 'plus'); + } else if (planFilter === 'team') { + rows = rows.filter((item) => normalizePlan(item.plan_type) === 'team'); + } else if (planFilter === 'current') { + rows = rows.filter((item) => Boolean(item.current)); + } + + overviewState.filteredCards = sortCards(rows); + renderPlanCards(); + updateSelectionInfo(); +} + +function setViewMode(mode) { + overviewState.viewMode = mode === 'list' ? 'list' : 'grid'; + storage.set(VIEW_MODE_STORAGE_KEY, overviewState.viewMode); + + const listBtn = document.getElementById('view-list-btn'); + const gridBtn = document.getElementById('view-grid-btn'); + const container = document.getElementById('plan-cards-container'); + if (listBtn) listBtn.classList.toggle('active', overviewState.viewMode === 'list'); + if (gridBtn) gridBtn.classList.toggle('active', overviewState.viewMode === 'grid'); + if (container) container.classList.toggle('view-list', overviewState.viewMode === 'list'); +} + +function renderPlanCards() { + const container = document.getElementById('plan-cards-container'); + const empty = document.getElementById('plan-cards-empty'); + if (!container || !empty) return; + + setViewMode(overviewState.viewMode); + + if (!overviewState.filteredCards.length) { + container.innerHTML = ''; + empty.style.display = 'block'; + return; + } + + empty.style.display = 'none'; + container.innerHTML = overviewState.filteredCards.map((account) => { + const accountId = Number(account.id); + const checked = overviewState.selectedCardIds.has(accountId) ? 'checked' : ''; + const codeReviewQuota = account.code_review_quota || null; + const hasCodeReviewQuota = Boolean( + codeReviewQuota && + ( + codeReviewQuota.status === 'ok' || + codeReviewQuota.percentage !== null && codeReviewQuota.percentage !== undefined || + codeReviewQuota.reset_at || + (codeReviewQuota.reset_in_text && codeReviewQuota.reset_in_text !== '-') + ) + ); + const codeReviewHtml = hasCodeReviewQuota + ? renderQuotaItem('Code Review', codeReviewQuota) + : ''; + + return ` +
+
+ +
${escapeHtml(account.email || '-')}
+
+ ${account.current ? '当前' : ''} + ${getPlanText(account.plan_type)} +
+
+ + ${renderQuotaItem('5小时配额', account.hourly_quota)} + ${renderQuotaItem('周配额', account.weekly_quota)} + ${codeReviewHtml} + +
+
+ ${format.date(account.created_at)} +
+ + + +
+
+
+ `; + }).join(''); +} + +async function loadPlanCards(forceRefresh = false) { + try { + const data = await api.get('/accounts/overview/cards'); + overviewState.cards = Array.isArray(data?.accounts) ? data.accounts : []; + syncSelectedCardIds(); + updatePlanFilterOptions(); + applyCardFilters(); + } catch (error) { + overviewState.cards = []; + overviewState.filteredCards = []; + overviewState.selectedCardIds = new Set(); + updatePlanFilterOptions(); + renderPlanCards(); + updateSelectionInfo(); + toast.error(`加载订阅卡片失败: ${error?.message || '未知错误'}`); + } +} + +async function refreshSelectedOrAll(force = true, silent = false) { + const selectedIds = Array.from(overviewState.selectedCardIds); + const visibleIds = overviewState.filteredCards + .map((item) => Number(item.id)) + .filter((id) => Number.isFinite(id)); + const targetIds = selectedIds.length ? selectedIds : visibleIds; + try { + if (!targetIds.length) { + await loadPlanCards(false); + if (!silent) toast.info('当前没有可刷新的卡片'); + return; + } + + await api.post('/accounts/overview/refresh', { + ids: targetIds, + force, + select_all: false, + }); + await loadPlanCards(false); + if (!silent) toast.success(`已刷新 ${targetIds.length} 个账号配额`); + } catch (error) { + if (!silent) toast.error(`刷新失败: ${error.message || '未知错误'}`); + } +} + +function setCardRefreshLoading(button, loading) { + if (!button) return; + button.classList.toggle('is-loading', Boolean(loading)); + button.disabled = Boolean(loading); + button.setAttribute('aria-busy', loading ? 'true' : 'false'); + if (!loading) button.blur(); +} + +async function refreshSingleCard(accountId, button = null) { + if (!Number.isFinite(Number(accountId))) return; + if (button?.classList.contains('is-loading')) return; + setCardRefreshLoading(button, true); + toast.info(`正在刷新账号 #${accountId} 配额...`, 1500); + try { + const result = await api.post('/accounts/overview/refresh', { + ids: [accountId], + force: true, + select_all: false, + }); + + const details = Array.isArray(result?.details) ? result.details : []; + const currentDetail = details.find((item) => Number(item?.id) === Number(accountId)); + if (currentDetail && currentDetail.success === false) { + toast.warning( + `刷新完成但未拿到新配额: ${currentDetail.error || '未知原因'}`, + 4500 + ); + } else { + toast.success(`账号 #${accountId} 配额刷新完成`); + } + await loadPlanCards(false); + } catch (error) { + toast.error(`刷新失败: ${error?.message || '未知错误'}`); + } finally { + setCardRefreshLoading(button, false); + } +} + +async function removeSingleCard(accountId) { + const id = Number(accountId); + if (!Number.isFinite(id)) return; + const ok = await confirm('确认从卡片列表删除该账号吗?(不会删除账号管理数据)', '删除卡片'); + if (!ok) return; + await api.post('/accounts/overview/cards/remove', { + ids: [id], + select_all: false, + }); + overviewState.selectedCardIds.delete(id); + await loadPlanCards(false); + await loadAddableAccounts(); + toast.success('卡片已删除,可在“添加账号”里重新添加'); +} + +async function loadAddableAccounts() { + try { + const data = await api.get('/accounts/overview/cards/selectable'); + overviewState.addableCards = Array.isArray(data?.accounts) ? data.accounts : []; + } catch (error) { + overviewState.addableCards = []; + console.warn('load addable cards failed', error); + } + renderAddableAccounts(); +} + +function renderAddableAccounts() { + const select = document.getElementById('overview-add-existing-select'); + if (!select) return; + const options = overviewState.addableCards.map((item) => { + const plan = getPlanText(item.subscription_type || 'free'); + const tokenTag = item.has_access_token ? '有Token' : '无Token'; + return ``; + }); + select.innerHTML = `${options.join('')}`; +} + +function getSelectedExistingAccount() { + const select = document.getElementById('overview-add-existing-select'); + const id = Number(select?.value || 0); + if (!Number.isFinite(id) || id <= 0) return null; + return overviewState.addableCards.find((item) => Number(item.id) === id) || null; +} + +function fillAddFormFromExistingSelection() { + const selected = getSelectedExistingAccount(); + if (!selected) return; + + setFieldValue('overview-add-email', selected.email || ''); + setFieldValue('overview-add-password', selected.password || ''); + setFieldValue('overview-add-email-service', selected.email_service || 'manual'); + setFieldValue('overview-add-subscription', normalizePlan(selected.subscription_type || 'free')); + setFieldValue('overview-add-client-id', selected.client_id || ''); + setFieldValue('overview-add-account-id', selected.account_id || ''); + setFieldValue('overview-add-workspace-id', selected.workspace_id || ''); + setFieldValue('overview-add-status', selected.status || 'active'); +} + +async function restoreSelectedAddableAccount() { + const select = document.getElementById('overview-add-existing-select'); + const id = Number(select?.value || 0); + if (!Number.isFinite(id) || id <= 0) { + toast.warning('请先选择一个账号'); + return; + } + await api.post(`/accounts/overview/cards/${id}/attach`, {}); + await loadAddableAccounts(); + await loadPlanCards(false); + await loadLegacyOverview(); + toast.success('账号已添加到卡片'); +} + +function extractFilenameFromDisposition(contentDisposition) { + const raw = String(contentDisposition || ''); + const match = raw.match(/filename="?([^"]+)"?/i); + return match ? match[1] : ''; +} + +async function downloadAccountsExportJson(payload, fallbackNamePrefix) { + const resp = await fetch('/api/accounts/export/json', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (!resp.ok) { + let detail = ''; + try { + const errData = await resp.json(); + detail = errData?.detail || ''; + } catch { + detail = ''; + } + throw new Error(detail || `HTTP ${resp.status}`); + } + + const blob = await resp.blob(); + const contentDisposition = resp.headers.get('Content-Disposition') || ''; + const filename = + extractFilenameFromDisposition(contentDisposition) || + `${fallbackNamePrefix}_${Date.now()}.json`; + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} + +function getSelectedIds() { + return Array.from(overviewState.selectedCardIds).filter((id) => Number.isFinite(Number(id))); +} + +function openModalById(id) { + const modal = document.getElementById(id); + if (modal) modal.classList.add('active'); +} + +function closeModalById(id) { + const modal = document.getElementById(id); + if (modal) modal.classList.remove('active'); +} + +function resetAddModalFields() { + setFieldValue('overview-add-email', ''); + setFieldValue('overview-add-password', ''); + setFieldValue('overview-add-email-service', 'manual'); + setFieldValue('overview-add-subscription', ''); + setFieldValue('overview-add-client-id', ''); + setFieldValue('overview-add-account-id', ''); + setFieldValue('overview-add-workspace-id', ''); + setFieldValue('overview-add-status', 'active'); + setFieldValue('overview-add-existing-select', ''); +} + +function setFieldValue(id, value) { + const node = document.getElementById(id); + if (!node) return; + node.value = value ?? ''; +} + +async function submitAddAccount() { + const selectedExisting = getSelectedExistingAccount(); + if (selectedExisting) { + await api.post(`/accounts/overview/cards/${Number(selectedExisting.id)}/attach`, {}); + closeModalById('overview-add-modal'); + resetAddModalFields(); + await loadAddableAccounts(); + await loadPlanCards(false); + await loadLegacyOverview(); + toast.success('已从账号管理直接添加到卡片'); + return; + } + + const email = (document.getElementById('overview-add-email')?.value || '').trim(); + const password = (document.getElementById('overview-add-password')?.value || '').trim(); + const emailService = (document.getElementById('overview-add-email-service')?.value || 'manual').trim() || 'manual'; + const subscriptionType = (document.getElementById('overview-add-subscription')?.value || '').trim(); + const clientId = (document.getElementById('overview-add-client-id')?.value || '').trim(); + const accountId = (document.getElementById('overview-add-account-id')?.value || '').trim(); + const workspaceId = (document.getElementById('overview-add-workspace-id')?.value || '').trim(); + const status = (document.getElementById('overview-add-status')?.value || 'active').trim() || 'active'; + + if (!email || !password) { + toast.warning('邮箱和密码为必填项'); + return; + } + + const payload = { + email, + password, + email_service: emailService, + subscription_type: subscriptionType || null, + client_id: clientId || null, + account_id: accountId || null, + workspace_id: workspaceId || null, + status, + source: 'manual', + }; + await api.post('/accounts', payload); + closeModalById('overview-add-modal'); + resetAddModalFields(); + await loadAddableAccounts(); + await loadPlanCards(false); + await loadLegacyOverview(); + toast.success('账号已添加'); +} + +async function submitImportAccounts() { + const input = (document.getElementById('overview-import-json')?.value || '').trim(); + const overwrite = Boolean(document.getElementById('overview-import-overwrite')?.checked); + if (!input) { + toast.warning('请先粘贴 JSON'); + return; + } + + let parsed; + try { + parsed = JSON.parse(input); + } catch (error) { + toast.error(`JSON 解析失败: ${error.message || '格式错误'}`); + return; + } + const accounts = Array.isArray(parsed) + ? parsed + : (Array.isArray(parsed?.accounts) ? parsed.accounts : []); + if (!accounts.length) { + toast.warning('JSON 必须是非空数组'); + return; + } + + const result = await api.post('/accounts/import', { + accounts, + overwrite, + }); + closeModalById('overview-import-modal'); + toast.success( + `导入完成:新增 ${result?.created || 0},更新 ${result?.updated || 0},跳过 ${result?.skipped || 0},失败 ${result?.failed || 0}` + ); + await loadPlanCards(false); + await loadLegacyOverview(); + await loadAddableAccounts(); +} + +async function removeSelectedAccounts() { + let ids = getSelectedIds(); + if (!ids.length) { + ids = overviewState.filteredCards + .map((item) => Number(item.id)) + .filter((id) => Number.isFinite(id)); + if (!ids.length) { + toast.warning('当前没有可删除的账号'); + return; + } + } + + const ok = await confirm(`确认从卡片列表删除 ${ids.length} 个账号吗?(不会删除账号管理数据)`, '批量删除卡片'); + if (!ok) return; + + const result = await api.post('/accounts/overview/cards/remove', { ids, select_all: false }); + const removedCount = Number(result?.removed_count || 0); + toast.success(`删除完成:${removedCount} 个卡片`); + overviewState.selectedCardIds.clear(); + await loadAddableAccounts(); + await loadPlanCards(false); +} + +async function exportAllVisibleAccounts() { + const ids = overviewState.filteredCards + .map((item) => Number(item.id)) + .filter((id) => Number.isFinite(id)); + if (!ids.length) { + toast.warning('当前没有可导出的账号'); + return; + } + await downloadAccountsExportJson({ ids, select_all: false }, 'overview_all_accounts'); + toast.success(`已导出 ${ids.length} 个账号`); +} + +async function exportSingleCard(accountId) { + const id = Number(accountId); + if (!Number.isFinite(id)) return; + await downloadAccountsExportJson({ ids: [id], select_all: false }, `overview_account_${id}`); + toast.success(`账号 #${id} 配置已导出`); +} + +function updateCardNextRefreshText() { + const el = document.getElementById('card-next-refresh'); + if (!el) return; + if (!overviewState.cardNextRefreshAt) { + el.textContent = '下次刷新 --'; + return; + } + const remainSec = Math.max(0, Math.floor((overviewState.cardNextRefreshAt - Date.now()) / 1000)); + const min = Math.floor(remainSec / 60); + const sec = remainSec % 60; + el.textContent = `下次刷新 ${min}:${String(sec).padStart(2, '0')}`; +} + +function restartCardAutoRefresh() { + if (overviewState.cardRefreshTimer) { + clearInterval(overviewState.cardRefreshTimer); + overviewState.cardRefreshTimer = null; + } + if (overviewState.cardCountdownTimer) { + clearInterval(overviewState.cardCountdownTimer); + overviewState.cardCountdownTimer = null; + } + + const intervalMs = overviewState.cardRefreshIntervalMin * 60 * 1000; + overviewState.cardNextRefreshAt = Date.now() + intervalMs; + updateCardNextRefreshText(); + + overviewState.cardRefreshTimer = setInterval(async () => { + await refreshSelectedOrAll(true, true); + overviewState.cardNextRefreshAt = Date.now() + intervalMs; + updateCardNextRefreshText(); + }, intervalMs); + + let ticks = 0; + overviewState.cardCountdownTimer = setInterval(() => { + updateCardNextRefreshText(); + ticks += 1; + if (ticks % 30 === 0 && overviewState.filteredCards.length) { + renderPlanCards(); + } + }, 1000); +} + +function bindEvents() { + const legacyRefreshBtn = document.getElementById('legacy-refresh-btn'); + if (legacyRefreshBtn) { + legacyRefreshBtn.addEventListener('click', async () => { + await loadLegacyOverview(); + await loadPlanCards(true); + }); + } + + const cardSearchInput = document.getElementById('card-search-input'); + if (cardSearchInput) { + cardSearchInput.addEventListener('input', debounce(applyCardFilters, 240)); + } + + const planFilter = document.getElementById('card-plan-filter'); + if (planFilter) { + planFilter.addEventListener('change', () => { + overviewState.planFilter = planFilter.value || 'all'; + applyCardFilters(); + }); + } + + const sortMode = document.getElementById('card-sort-mode'); + if (sortMode) { + sortMode.addEventListener('change', () => { + overviewState.sortMode = sortMode.value || 'created_desc'; + applyCardFilters(); + }); + } + + const viewListBtn = document.getElementById('view-list-btn'); + const viewGridBtn = document.getElementById('view-grid-btn'); + if (viewListBtn) { + viewListBtn.addEventListener('click', () => { + setViewMode('list'); + renderPlanCards(); + }); + } + if (viewGridBtn) { + viewGridBtn.addEventListener('click', () => { + setViewMode('grid'); + renderPlanCards(); + }); + } + + const cardRefreshSelect = document.getElementById('card-refresh-interval'); + if (cardRefreshSelect) { + cardRefreshSelect.value = String(overviewState.cardRefreshIntervalMin); + cardRefreshSelect.addEventListener('change', () => { + const value = Number(cardRefreshSelect.value || 7); + overviewState.cardRefreshIntervalMin = [5, 7, 10].includes(value) ? value : 7; + restartCardAutoRefresh(); + }); + } + + const addBtn = document.getElementById('card-add-btn'); + if (addBtn) { + addBtn.addEventListener('click', async () => { + resetAddModalFields(); + await loadAddableAccounts(); + openModalById('overview-add-modal'); + }); + } + + const toolbarRefreshBtn = document.getElementById('card-refresh-btn'); + if (toolbarRefreshBtn) { + toolbarRefreshBtn.addEventListener('click', async () => { + try { + await refreshSelectedOrAll(true, true); + await Promise.all([ + loadLegacyOverview(), + loadAddableAccounts(), + ]); + toast.success('账号总览已刷新'); + } catch (error) { + toast.error(`刷新失败: ${error?.message || '未知错误'}`); + } + }); + } + + const importBtn = document.getElementById('card-import-btn'); + if (importBtn) { + importBtn.addEventListener('click', () => openModalById('overview-import-modal')); + } + + const deleteBtn = document.getElementById('card-delete-btn'); + if (deleteBtn) { + deleteBtn.addEventListener('click', async () => { + try { + await removeSelectedAccounts(); + } catch (error) { + toast.error(`删除失败: ${error?.message || '未知错误'}`); + } + }); + } + + const exportAllBtn = document.getElementById('card-export-all-btn'); + if (exportAllBtn) { + exportAllBtn.addEventListener('click', async () => { + try { + await exportAllVisibleAccounts(); + } catch (error) { + toast.error(`导出失败: ${error?.message || '未知错误'}`); + } + }); + } + + const addModalIds = ['overview-add-modal', 'overview-import-modal']; + addModalIds.forEach((modalId) => { + const modal = document.getElementById(modalId); + if (!modal) return; + modal.addEventListener('click', (event) => { + if (event.target === modal) { + closeModalById(modalId); + } + }); + }); + + document.getElementById('overview-add-close')?.addEventListener('click', () => closeModalById('overview-add-modal')); + document.getElementById('overview-add-cancel')?.addEventListener('click', () => closeModalById('overview-add-modal')); + document.getElementById('overview-add-existing-refresh')?.addEventListener('click', async () => { + try { + await loadAddableAccounts(); + toast.success('已刷新账号管理列表'); + } catch (error) { + toast.error(`刷新失败: ${error?.message || '未知错误'}`); + } + }); + document.getElementById('overview-add-existing-select')?.addEventListener('change', () => { + fillAddFormFromExistingSelection(); + }); + document.getElementById('overview-add-existing-submit')?.addEventListener('click', async () => { + try { + await restoreSelectedAddableAccount(); + } catch (error) { + toast.error(`恢复失败: ${error?.message || '未知错误'}`); + } + }); + document.getElementById('overview-add-submit')?.addEventListener('click', async () => { + try { + await submitAddAccount(); + } catch (error) { + toast.error(`添加失败: ${error?.message || '未知错误'}`); + } + }); + + document.getElementById('overview-import-close')?.addEventListener('click', () => closeModalById('overview-import-modal')); + document.getElementById('overview-import-cancel')?.addEventListener('click', () => closeModalById('overview-import-modal')); + document.getElementById('overview-import-submit')?.addEventListener('click', async () => { + try { + await submitImportAccounts(); + } catch (error) { + toast.error(`导入失败: ${error?.message || '未知错误'}`); + } + }); + + const cardContainer = document.getElementById('plan-cards-container'); + if (cardContainer) { + cardContainer.addEventListener('change', (event) => { + const checkbox = event.target.closest('input[data-role="select-card"]'); + if (!checkbox) return; + const id = Number(checkbox.dataset.id); + if (!Number.isFinite(id)) return; + if (checkbox.checked) { + overviewState.selectedCardIds.add(id); + } else { + overviewState.selectedCardIds.delete(id); + } + updateSelectionInfo(); + }); + + cardContainer.addEventListener('click', async (event) => { + const button = event.target.closest('button[data-action]'); + if (!button) return; + const action = button.dataset.action; + const accountId = Number(button.dataset.id); + if (!Number.isFinite(accountId)) return; + + if (action === 'refresh') { + await refreshSingleCard(accountId, button); + return; + } + if (action === 'export') { + await exportSingleCard(accountId); + return; + } + if (action === 'remove') { + await removeSingleCard(accountId); + } + }); + } +} + +document.addEventListener('DOMContentLoaded', async () => { + const sortMode = document.getElementById('card-sort-mode'); + if (sortMode) sortMode.value = overviewState.sortMode; + setViewMode(overviewState.viewMode); + bindEvents(); + const initResults = await Promise.allSettled([ + loadLegacyOverview(), + loadPlanCards(false), + loadAddableAccounts(), + ]); + initResults.forEach((item, index) => { + if (item.status === 'rejected') { + const target = index === 0 ? '总览统计' : '卡片列表'; + toast.warning(`${target}初始化失败,已降级显示`); + } + }); + restartCardAutoRefresh(); +}); diff --git a/static/js/app.js b/static/js/app.js index 543dc0b..5a00e75 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -9,6 +9,8 @@ let currentBatch = null; let logPollingInterval = null; let batchPollingInterval = null; let accountsPollingInterval = null; +let todayStatsPollingInterval = null; +let todayStatsResetInterval = null; let isBatchMode = false; let isOutlookBatchMode = false; let outlookAccounts = []; @@ -69,6 +71,12 @@ const elements = { // 已注册账号 recentAccountsTable: document.getElementById('recent-accounts-table'), refreshAccountsBtn: document.getElementById('refresh-accounts-btn'), + // 今日统计 + todayStatsTotal: document.getElementById('today-stats-total'), + todayStatsSuccess: document.getElementById('today-stats-success'), + todayStatsFailed: document.getElementById('today-stats-failed'), + todayStatsRate: document.getElementById('today-stats-rate'), + todayStatsReset: document.getElementById('today-stats-reset'), // Outlook 批量注册 outlookBatchSection: document.getElementById('outlook-batch-section'), outlookAccountsContainer: document.getElementById('outlook-accounts-container'), @@ -102,6 +110,9 @@ document.addEventListener('DOMContentLoaded', () => { loadAvailableServices(); loadRecentAccounts(); startAccountsPolling(); + loadTodayStats(true); + startTodayStatsPolling(); + startTodayStatsResetTicker(); initVisibilityReconnect(); restoreActiveTask(); initAutoUploadOptions(); @@ -553,6 +564,12 @@ function connectWebSocket(taskUuid) { addLog(logType, data.message); } else if (data.type === 'status') { updateTaskStatus(data.status); + if (data.email) { + elements.taskEmail.textContent = data.email; + } + if (data.email_service) { + elements.taskService.textContent = getServiceTypeText(data.email_service); + } // 检查是否完成 if (['completed', 'failed', 'cancelled', 'cancelling'].includes(data.status)) { @@ -987,6 +1004,83 @@ function startAccountsPolling() { }, 30000); } +function renderTodayStats(total, success, failed, rate) { + if (elements.todayStatsTotal) { + elements.todayStatsTotal.textContent = String(Math.max(0, total)); + } + if (elements.todayStatsSuccess) { + elements.todayStatsSuccess.textContent = String(Math.max(0, success)); + } + if (elements.todayStatsFailed) { + elements.todayStatsFailed.textContent = String(Math.max(0, failed)); + } + if (elements.todayStatsRate) { + const safeRate = Math.max(0, rate); + const rateCard = elements.todayStatsRate.closest('.today-stat-rate'); + elements.todayStatsRate.textContent = `${safeRate.toFixed(1)}%`; + elements.todayStatsRate.classList.remove('rate-high', 'rate-mid', 'rate-low'); + if (rateCard) { + rateCard.classList.remove('rate-high', 'rate-mid', 'rate-low'); + } + if (safeRate >= 70) { + elements.todayStatsRate.classList.add('rate-high'); + if (rateCard) rateCard.classList.add('rate-high'); + } else if (safeRate < 40) { + elements.todayStatsRate.classList.add('rate-low'); + if (rateCard) rateCard.classList.add('rate-low'); + } else { + elements.todayStatsRate.classList.add('rate-mid'); + if (rateCard) rateCard.classList.add('rate-mid'); + } + } +} + +async function loadTodayStats(silent = true) { + try { + const data = await api.get('/registration/stats'); + const byStatus = data?.by_status || {}; + const total = Number(data?.today_total ?? data?.today_count ?? 0); + const success = Number(data?.today_success ?? byStatus.completed ?? 0); + const failed = Number(data?.today_failed ?? byStatus.failed ?? 0); + const fallbackRate = total > 0 ? (success / total) * 100 : 0; + const rate = Number(data?.today_success_rate ?? fallbackRate); + renderTodayStats(total, success, failed, Number.isFinite(rate) ? rate : 0); + } catch (error) { + console.error('加载今日统计失败:', error); + if (!silent) { + toast.error('加载今日统计失败'); + } + } +} + +function updateTodayStatsResetText() { + if (!elements.todayStatsReset) return; + const now = new Date(); + const next = new Date(); + next.setHours(24, 0, 0, 0); + const remain = Math.max(0, next.getTime() - now.getTime()); + const hours = Math.floor(remain / 3600000); + const minutes = Math.floor((remain % 3600000) / 60000); + elements.todayStatsReset.textContent = `重置剩余 ${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`; +} + +function startTodayStatsResetTicker() { + updateTodayStatsResetText(); + if (todayStatsResetInterval) { + clearInterval(todayStatsResetInterval); + } + todayStatsResetInterval = setInterval(updateTodayStatsResetText, 60000); +} + +function startTodayStatsPolling() { + if (todayStatsPollingInterval) { + clearInterval(todayStatsPollingInterval); + } + todayStatsPollingInterval = setInterval(() => { + loadTodayStats(true); + }, 60000); +} + // 添加日志 function addLog(type, message) { // 日志去重:使用消息内容的 hash 作为键 diff --git a/static/js/email_services.js b/static/js/email_services.js index fafd85b..463b1a8 100644 --- a/static/js/email_services.js +++ b/static/js/email_services.js @@ -345,7 +345,7 @@ async function loadCustomServices() { ${escapeHtml(service.name)} ${getCustomServiceTypeBadge(service._subType)} - ${getCustomServiceAddress(service)} + ${getCustomServiceAddress(service)} ${service.enabled ? '✅' : '⭕'} ${service.priority} ${format.date(service.last_used)} diff --git a/static/js/logs.js b/static/js/logs.js new file mode 100644 index 0000000..fca660f --- /dev/null +++ b/static/js/logs.js @@ -0,0 +1,277 @@ +/** + * 后台日志页面 + */ + +const logsState = { + page: 1, + pageSize: 100, + total: 0, + autoRefreshSeconds: 10, + timer: null, + filters: { + level: "", + logger_name: "", + keyword: "", + since_minutes: "", + }, +}; + +const el = { + filterLevel: document.getElementById("filter-level"), + filterLogger: document.getElementById("filter-logger"), + filterKeyword: document.getElementById("filter-keyword"), + filterSinceMinutes: document.getElementById("filter-since-minutes"), + autoRefreshSeconds: document.getElementById("auto-refresh-seconds"), + pageSizeSelect: document.getElementById("page-size-select"), + refreshBtn: document.getElementById("refresh-logs-btn"), + clearFiltersBtn: document.getElementById("clear-filters-btn"), + cleanupBtn: document.getElementById("cleanup-logs-btn"), + clearLogsBtn: document.getElementById("clear-logs-btn"), + cleanupRetentionDays: document.getElementById("cleanup-retention-days"), + cleanupMaxRows: document.getElementById("cleanup-max-rows"), + logConsole: document.getElementById("log-console"), + summary: document.getElementById("logs-summary"), + pageInfo: document.getElementById("page-info"), + prevPageBtn: document.getElementById("prev-page-btn"), + nextPageBtn: document.getElementById("next-page-btn"), + latestLogTime: document.getElementById("latest-log-time"), + statTotal: document.getElementById("stat-total"), + statInfo: document.getElementById("stat-info"), + statWarning: document.getElementById("stat-warning"), + statError: document.getElementById("stat-error"), + statCritical: document.getElementById("stat-critical"), +}; + +function escapeHtml(value) { + const div = document.createElement("div"); + div.textContent = String(value ?? ""); + return div.innerHTML; +} + +function getLevelClass(level) { + const text = String(level || "").toUpperCase(); + return `log-level ${text}`; +} + +function setLogsSummary() { + const from = logsState.total === 0 ? 0 : ((logsState.page - 1) * logsState.pageSize + 1); + const to = Math.min(logsState.total, logsState.page * logsState.pageSize); + el.summary.textContent = `共 ${logsState.total} 条,当前 ${from}-${to}`; + el.pageInfo.textContent = `第 ${logsState.page} 页`; + el.prevPageBtn.disabled = logsState.page <= 1; + el.nextPageBtn.disabled = to >= logsState.total; +} + +function renderLogs(rows) { + if (!Array.isArray(rows) || rows.length === 0) { + el.logConsole.innerHTML = ` +
+ - + INFO + system + 暂无日志 +
+ `; + return; + } + + el.logConsole.innerHTML = rows.map((row) => { + const level = String(row.level || "INFO").toUpperCase(); + const msg = row.exception ? `${row.message || ""}\n${row.exception}` : (row.message || ""); + return ` +
+ ${escapeHtml(format.date(row.created_at))} + ${escapeHtml(level)} + ${escapeHtml(row.logger || "-")} + ${escapeHtml(msg || "-")} +
+ `; + }).join(""); +} + +function collectFilters() { + logsState.filters.level = String(el.filterLevel.value || "").trim().toUpperCase(); + logsState.filters.logger_name = String(el.filterLogger.value || "").trim(); + logsState.filters.keyword = String(el.filterKeyword.value || "").trim(); + logsState.filters.since_minutes = String(el.filterSinceMinutes.value || "").trim(); +} + +function buildLogParams() { + collectFilters(); + const params = new URLSearchParams({ + page: String(logsState.page), + page_size: String(logsState.pageSize), + }); + if (logsState.filters.level) params.set("level", logsState.filters.level); + if (logsState.filters.logger_name) params.set("logger_name", logsState.filters.logger_name); + if (logsState.filters.keyword) params.set("keyword", logsState.filters.keyword); + if (logsState.filters.since_minutes) params.set("since_minutes", logsState.filters.since_minutes); + return params.toString(); +} + +async function loadLogs(silent = false) { + if (!silent) { + loading.show(el.refreshBtn, "加载中..."); + } + try { + const query = buildLogParams(); + const data = await api.get(`/logs?${query}`); + logsState.total = Number(data.total || 0); + renderLogs(data.logs || []); + setLogsSummary(); + } catch (error) { + toast.error(`加载日志失败: ${error?.message || error}`); + } finally { + if (!silent) { + loading.hide(el.refreshBtn); + } + } +} + +async function loadStats(silent = false) { + try { + const data = await api.get("/logs/stats"); + const levels = data.levels || {}; + el.statTotal.textContent = format.number(data.total || 0); + el.statInfo.textContent = format.number(levels.INFO || 0); + el.statWarning.textContent = format.number(levels.WARNING || 0); + el.statError.textContent = format.number(levels.ERROR || 0); + el.statCritical.textContent = format.number(levels.CRITICAL || 0); + el.latestLogTime.textContent = `最新日志: ${data.latest_at ? format.date(data.latest_at) : "-"}`; + } catch (error) { + if (!silent) { + toast.error(`加载统计失败: ${error?.message || error}`); + } + } +} + +function resetFilters() { + el.filterLevel.value = ""; + el.filterLogger.value = ""; + el.filterKeyword.value = ""; + el.filterSinceMinutes.value = ""; + logsState.page = 1; + loadLogs(); +} + +async function cleanupLogs() { + const retentionDays = Number(el.cleanupRetentionDays.value || 30); + const maxRows = Number(el.cleanupMaxRows.value || 50000); + const ok = await confirm( + `确认清理日志吗?\n保留天数=${retentionDays},最大条数=${maxRows}`, + "清理后台日志" + ); + if (!ok) return; + + try { + loading.show(el.cleanupBtn, "清理中..."); + const data = await api.post("/logs/cleanup", { + retention_days: retentionDays, + max_rows: maxRows, + }); + toast.success(`清理完成:删除 ${data.deleted_total || 0} 条,剩余 ${data.remaining || 0} 条`); + logsState.page = 1; + await Promise.all([loadLogs(true), loadStats(true)]); + setLogsSummary(); + } catch (error) { + toast.error(`清理失败: ${error?.message || error}`); + } finally { + loading.hide(el.cleanupBtn); + } +} + +async function clearAllLogs() { + const ok = await confirm("确认清空全部后台日志吗?该操作不可恢复。", "清空后台日志"); + if (!ok) return; + + try { + loading.show(el.clearLogsBtn, "清空中..."); + const data = await api.delete("/logs?confirm=true"); + toast.success(`清空完成:删除 ${data.deleted_total || 0} 条`); + logsState.page = 1; + await Promise.all([loadLogs(true), loadStats(true)]); + setLogsSummary(); + } catch (error) { + toast.error(`清空失败: ${error?.message || error}`); + } finally { + loading.hide(el.clearLogsBtn); + } +} + +function restartAutoRefresh() { + if (logsState.timer) { + clearInterval(logsState.timer); + logsState.timer = null; + } + if (!logsState.autoRefreshSeconds) return; + logsState.timer = setInterval(() => { + loadLogs(true); + loadStats(true); + }, logsState.autoRefreshSeconds * 1000); +} + +function bindEvents() { + el.refreshBtn.addEventListener("click", () => { + loadLogs(); + loadStats(); + }); + el.clearFiltersBtn.addEventListener("click", resetFilters); + el.cleanupBtn.addEventListener("click", cleanupLogs); + el.clearLogsBtn?.addEventListener("click", clearAllLogs); + + el.pageSizeSelect.addEventListener("change", () => { + logsState.pageSize = Number(el.pageSizeSelect.value || 100); + logsState.page = 1; + loadLogs(); + }); + el.prevPageBtn.addEventListener("click", () => { + if (logsState.page <= 1) return; + logsState.page -= 1; + loadLogs(); + }); + el.nextPageBtn.addEventListener("click", () => { + const maxPage = Math.max(1, Math.ceil(logsState.total / logsState.pageSize)); + if (logsState.page >= maxPage) return; + logsState.page += 1; + loadLogs(); + }); + + [ + el.filterLevel, + el.filterLogger, + el.filterKeyword, + el.filterSinceMinutes, + ].forEach((node) => { + node.addEventListener("change", () => { + logsState.page = 1; + loadLogs(); + }); + }); + el.filterLogger.addEventListener("input", debounce(() => { + logsState.page = 1; + loadLogs(true); + }, 300)); + el.filterKeyword.addEventListener("input", debounce(() => { + logsState.page = 1; + loadLogs(true); + }, 300)); + + el.autoRefreshSeconds.addEventListener("change", () => { + logsState.autoRefreshSeconds = Number(el.autoRefreshSeconds.value || 0); + restartAutoRefresh(); + }); +} + +document.addEventListener("DOMContentLoaded", async () => { + bindEvents(); + await Promise.all([loadLogs(), loadStats()]); + logsState.autoRefreshSeconds = Number(el.autoRefreshSeconds.value || 0); + restartAutoRefresh(); + + window.addEventListener("beforeunload", () => { + if (logsState.timer) { + clearInterval(logsState.timer); + logsState.timer = null; + } + }); +}); diff --git a/static/js/payment.js b/static/js/payment.js index c170387..3ff0ca4 100644 --- a/static/js/payment.js +++ b/static/js/payment.js @@ -1,146 +1,1856 @@ /** * 支付页面 JavaScript + * 支付页面:半自动 + 第三方自动绑卡 + 全自动绑卡任务管理 + 用户完成后自动验订阅 */ const COUNTRY_CURRENCY_MAP = { - SG: 'SGD', US: 'USD', TR: 'TRY', JP: 'JPY', - HK: 'HKD', GB: 'GBP', EU: 'EUR', AU: 'AUD', - CA: 'CAD', IN: 'INR', BR: 'BRL', MX: 'MXN', + 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", }; -let selectedPlan = 'plus'; -let generatedLink = ''; +const BILLING_STORAGE_KEY = "payment.billing_profile_non_sensitive"; +const BILLING_TEMPLATE_STORAGE_KEY = "payment.billing_templates_v1"; +const THIRD_PARTY_BIND_URL_STORAGE_KEY = "payment.third_party_bind_api_url"; +const BIND_MODE_STORAGE_KEY = "payment.bind_mode"; +const THIRD_PARTY_BIND_DEFAULT_URL = "https://twilight-river-f148.482091502.workers.dev/"; +const BILLING_TEMPLATE_MAX = 200; +const BILLING_COUNTRY_CURRENCY_MAP = { + US: "USD", + GB: "GBP", + CA: "CAD", + AU: "AUD", + SG: "SGD", + HK: "HKD", + JP: "JPY", + DE: "EUR", + FR: "EUR", + IT: "EUR", + ES: "EUR", +}; + +const COUNTRY_ALIAS_MAP = { + us: { code: "US", currency: "USD" }, + usa: { code: "US", currency: "USD" }, + "united states": { code: "US", currency: "USD" }, + 美国: { code: "US", currency: "USD" }, + uk: { code: "GB", currency: "GBP" }, + gb: { code: "GB", currency: "GBP" }, + england: { code: "GB", currency: "GBP" }, + "united kingdom": { code: "GB", currency: "GBP" }, + 英国: { code: "GB", currency: "GBP" }, + ca: { code: "CA", currency: "CAD" }, + canada: { code: "CA", currency: "CAD" }, + 加拿大: { code: "CA", currency: "CAD" }, + au: { code: "AU", currency: "AUD" }, + australia: { code: "AU", currency: "AUD" }, + 澳大利亚: { code: "AU", currency: "AUD" }, + sg: { code: "SG", currency: "SGD" }, + singapore: { code: "SG", currency: "SGD" }, + 新加坡: { code: "SG", currency: "SGD" }, + hk: { code: "HK", currency: "HKD" }, + "hong kong": { code: "HK", currency: "HKD" }, + 香港: { code: "HK", currency: "HKD" }, + jp: { code: "JP", currency: "JPY" }, + japan: { code: "JP", currency: "JPY" }, + 日本: { code: "JP", currency: "JPY" }, + de: { code: "DE", currency: "EUR" }, + germany: { code: "DE", currency: "EUR" }, + 德国: { code: "DE", currency: "EUR" }, + fr: { code: "FR", currency: "EUR" }, + france: { code: "FR", currency: "EUR" }, + 法国: { code: "FR", currency: "EUR" }, + it: { code: "IT", currency: "EUR" }, + italy: { code: "IT", currency: "EUR" }, + 意大利: { code: "IT", currency: "EUR" }, + es: { code: "ES", currency: "EUR" }, + spain: { code: "ES", currency: "EUR" }, + 西班牙: { code: "ES", currency: "EUR" }, +}; + +let selectedPlan = "plus"; +let generatedLink = ""; +let isGeneratingCheckoutLink = false; +let paymentAccounts = []; + +const bindTaskState = { + page: 1, + pageSize: 50, + status: "", + search: "", +}; +let bindTaskAutoRefreshTimer = null; + +let billingBatchProfiles = []; + +function formatErrorMessage(error) { + if (!error) return "未知错误"; + if (typeof error === "string") return error; + + // ApiClient 会把后端错误挂在 error.data 上 + const detail = error?.data?.detail ?? error?.message; + if (typeof detail === "string" && detail && detail !== "[object Object]") { + return detail; + } + try { + return JSON.stringify(detail || error); + } catch { + return String(detail || error); + } +} + +function escapeHtml(value) { + const div = document.createElement("div"); + div.textContent = String(value ?? ""); + return div.innerHTML; +} + +function yesNo(value) { + return value ? "是" : "否"; +} + +function showSessionDiagnosticPanel(text) { + const panel = document.getElementById("session-diagnostic-panel"); + const pre = document.getElementById("session-diagnostic-text"); + if (!panel || !pre) return; + pre.textContent = String(text || ""); + panel.classList.add("show"); +} + +function clearSessionDiagnosticPanel() { + const panel = document.getElementById("session-diagnostic-panel"); + const pre = document.getElementById("session-diagnostic-text"); + if (!panel || !pre) return; + pre.textContent = ""; + panel.classList.remove("show"); +} + +function formatSessionDiagnosticPayload(payload) { + if (!payload || typeof payload !== "object") { + return "诊断结果为空"; + } + const token = payload.token_state || {}; + const cookie = payload.cookie_state || {}; + const bootstrap = payload.bootstrap_capability || {}; + const probe = payload.probe || null; + const notes = Array.isArray(payload.notes) ? payload.notes : []; + + const lines = [ + `账号: ${payload.email || "-"} (ID=${payload.account_id || "-"})`, + `Access Token: ${yesNo(token.has_access_token)} | len=${token.access_token_len || 0} | ${token.access_token_preview || "-"}`, + `Refresh Token: ${yesNo(token.has_refresh_token)} | len=${token.refresh_token_len || 0}`, + `Session(DB): ${yesNo(token.has_session_token_db)} | len=${token.session_token_db_len || 0} | ${token.session_token_db_preview || "-"}`, + `Session(Cookie): ${yesNo(token.has_session_token_cookie)} | len=${token.session_token_cookie_len || 0} | ${token.session_token_cookie_preview || "-"}`, + `Session(Resolved): len=${token.resolved_session_token_len || 0} | ${token.resolved_session_token_preview || "-"}`, + `Cookies: ${yesNo(cookie.has_cookies)} | len=${cookie.cookies_len || 0}`, + `oai-did: ${yesNo(cookie.has_oai_did)} | ${cookie.resolved_oai_did || "-"}`, + `Session 分片: count=${cookie.session_chunk_count || 0} | [${(cookie.session_chunk_indices || []).join(", ")}]`, + `自动补会话能力: ${yesNo(bootstrap.can_login_bootstrap)} | has_password=${yesNo(bootstrap.has_password)} | email_service=${bootstrap.email_service_type || "-"}`, + ]; + + if (probe) { + lines.push( + `实时探测: ok=${yesNo(probe.ok)} | http=${probe.http_status ?? "-"} | session=${yesNo(probe.session_token_found)} | session_json_access=${yesNo(probe.access_token_in_session_json)}` + ); + if (probe.session_token_preview) { + lines.push(`探测 session 预览: ${probe.session_token_preview}`); + } + if (probe.access_token_preview) { + lines.push(`探测 access 预览: ${probe.access_token_preview}`); + } + if (probe.error) { + lines.push(`探测错误: ${probe.error}`); + } + } + + if (notes.length) { + lines.push("诊断备注:"); + notes.forEach((n) => lines.push(`- ${n}`)); + } + if (payload.recommendation) { + lines.push(`建议: ${payload.recommendation}`); + } + if (payload.checked_at) { + lines.push(`检查时间: ${payload.checked_at}`); + } + return lines.join("\n"); +} + +async function runSessionDiagnostic() { + const accountId = Number(document.getElementById("account-select")?.value || 0); + if (!accountId) { + toast.warning("请先选择账号"); + return; + } + setButtonLoading("session-diagnostic-btn", "诊断中...", true); + showSessionDiagnosticPanel("正在诊断会话上下文,请稍候..."); + try { + const data = await api.get(`/payment/accounts/${accountId}/session-diagnostic?probe=1`); + const diag = data?.diagnostic || {}; + showSessionDiagnosticPanel(formatSessionDiagnosticPayload(diag)); + toast.success("会话诊断完成"); + } catch (error) { + const message = formatErrorMessage(error); + showSessionDiagnosticPanel(`会话诊断失败: ${message}`); + toast.error(`会话诊断失败: ${message}`); + } finally { + setButtonLoading("session-diagnostic-btn", "诊断中...", false); + } +} + +async function runSessionBootstrap() { + const accountId = Number(document.getElementById("account-select")?.value || 0); + if (!accountId) { + toast.warning("请先选择账号"); + return; + } + setButtonLoading("session-bootstrap-btn", "补全中...", true); + showSessionDiagnosticPanel("正在执行会话补全,请稍候(可能需要等待邮箱验证码)..."); + try { + const data = await api.post(`/payment/accounts/${accountId}/session-bootstrap`, {}); + if (data?.success) { + toast.success(`会话补全成功(len=${data?.session_token_len || 0})`); + } else { + toast.warning(data?.message || "会话补全未命中"); + } + await runSessionDiagnostic(); + } catch (error) { + const message = formatErrorMessage(error); + showSessionDiagnosticPanel(`会话补全失败: ${message}`); + toast.error(`会话补全失败: ${message}`); + } finally { + setButtonLoading("session-bootstrap-btn", "补全中...", false); + } +} + +async function saveManualSessionToken() { + const accountId = Number(document.getElementById("account-select")?.value || 0); + if (!accountId) { + toast.warning("请先选择账号"); + return; + } + const sessionToken = String(document.getElementById("manual-session-token-input")?.value || "").trim(); + if (!sessionToken) { + toast.warning("请先粘贴 session_token"); + return; + } + + setButtonLoading("save-session-token-btn", "保存中...", true); + try { + const data = await api.post(`/payment/accounts/${accountId}/session-token`, { + session_token: sessionToken, + merge_cookie: true, + }); + if (data?.success) { + toast.success(`Session Token 已保存(len=${data?.session_token_len || 0})`); + await runSessionDiagnostic(); + return; + } + toast.warning(data?.message || "保存完成,但未返回 success"); + } catch (error) { + toast.error(`保存 Session Token 失败: ${formatErrorMessage(error)}`); + } finally { + setButtonLoading("save-session-token-btn", "保存中...", false); + } +} + +function getInputValue(id) { + return (document.getElementById(id)?.value || "").trim(); +} + +function setInputValue(id, value) { + const el = document.getElementById(id); + if (!el) return; + el.value = value ?? ""; +} + +function maskCardNumber(cardNumber) { + const digits = String(cardNumber || "").replace(/\D/g, ""); + if (!digits) return "-"; + if (digits.length <= 8) return `${digits.slice(0, 2)}****${digits.slice(-2)}`; + return `${digits.slice(0, 4)}****${digits.slice(-4)}`; +} + +function resolveCountryAlias(raw) { + const key = String(raw || "").trim(); + if (!key) return null; + const normalized = key.toLowerCase(); + if (COUNTRY_ALIAS_MAP[normalized]) { + return COUNTRY_ALIAS_MAP[normalized]; + } + const upper = key.toUpperCase(); + if (BILLING_COUNTRY_CURRENCY_MAP[upper]) { + return { + code: upper, + currency: BILLING_COUNTRY_CURRENCY_MAP[upper], + }; + } + return null; +} + +function normalizeMonth(value) { + const digits = String(value || "").replace(/\D/g, ""); + if (!digits) return ""; + return digits.slice(0, 2).padStart(2, "0"); +} + +function normalizeYear(value) { + const digits = String(value || "").replace(/\D/g, ""); + if (!digits) return ""; + if (digits.length === 2) return `20${digits}`; + return digits.slice(0, 4); +} + +function formatExpiryInput(month, year) { + const mm = normalizeMonth(month); + const rawYear = String(year || "").replace(/\D/g, ""); + const yy = rawYear ? rawYear.slice(-2) : ""; + if (!mm && !yy) return ""; + return yy ? `${mm}/${yy}` : mm; +} + +function parseExpiryInput(value) { + const raw = String(value || "").trim(); + if (!raw) { + return { exp_month: "", exp_year: "" }; + } + + const slashMatch = raw.match(/^(\d{1,2})\s*\/\s*(\d{1,4})$/); + if (slashMatch) { + return { + exp_month: normalizeMonth(slashMatch[1]), + exp_year: normalizeYear(slashMatch[2]), + }; + } + + const digits = raw.replace(/\D/g, ""); + if (!digits) { + return { exp_month: "", exp_year: "" }; + } + if (digits.length <= 2) { + return { exp_month: normalizeMonth(digits), exp_year: "" }; + } + return { + exp_month: normalizeMonth(digits.slice(0, 2)), + exp_year: normalizeYear(digits.slice(2)), + }; +} + +function normalizeExpiryInputForTyping(value) { + const digits = String(value || "").replace(/\D/g, "").slice(0, 6); + if (!digits) return ""; + if (digits.length <= 2) return digits; + return `${digits.slice(0, 2)}/${digits.slice(2)}`; +} + +function parseCardText(rawText) { + const text = String(rawText || "").trim(); + if (!text) return {}; + + const lines = text + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const kv = {}; + for (const line of lines) { + const match = line.match(/^(.+?)\s*[::]\s*(.+)$/); + if (match) { + kv[match[1].trim().toLowerCase()] = match[2].trim(); + } + } + + const result = {}; + + // 卡号 + const cardKeyCandidates = [ + "卡号", + "card number", + "card", + "card_number", + ]; + for (const key of cardKeyCandidates) { + if (!kv[key]) continue; + const digits = kv[key].replace(/\D/g, ""); + if (digits.length >= 13 && digits.length <= 19) { + result.card_number = digits; + break; + } + } + + if (!result.card_number) { + for (const line of lines) { + const m = line.replace(/-/g, " ").match(/^(\d{13,19})\s+(0[1-9]|1[0-2])\s+(\d{2,4})\s+(\d{3,4})$/); + if (m) { + result.card_number = m[1]; + result.exp_month = normalizeMonth(m[2]); + result.exp_year = normalizeYear(m[3]); + result.cvv = m[4]; + break; + } + } + } + + if (!result.card_number) { + for (const line of lines) { + const digits = line.replace(/\D/g, ""); + if (digits.length >= 13 && digits.length <= 19) { + result.card_number = digits; + break; + } + } + } + + // 有效期 + const expiryKeyCandidates = ["有效期", "exp", "expiry", "expiration", "exp_date"]; + for (const key of expiryKeyCandidates) { + if (!kv[key]) continue; + const value = kv[key]; + let m = value.match(/(0[1-9]|1[0-2])\s*\/\s*(\d{2,4})/); + if (m) { + result.exp_month = normalizeMonth(m[1]); + result.exp_year = normalizeYear(m[2]); + break; + } + m = value.match(/^(0[1-9]|1[0-2])(\d{2,4})$/); + if (m) { + result.exp_month = normalizeMonth(m[1]); + result.exp_year = normalizeYear(m[2]); + break; + } + } + if (!result.exp_month || !result.exp_year) { + for (const line of lines) { + const m = line.match(/\b(0[1-9]|1[0-2])\s*\/\s*(\d{2,4})\b/); + if (m) { + result.exp_month = normalizeMonth(m[1]); + result.exp_year = normalizeYear(m[2]); + break; + } + } + } + + // CVV + const cvvKeyCandidates = ["cvv", "cvc", "安全码"]; + for (const key of cvvKeyCandidates) { + if (!kv[key]) continue; + const m = kv[key].match(/\b(\d{3,4})\b/); + if (m) { + result.cvv = m[1]; + break; + } + } + if (!result.cvv) { + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]; + if (!/(cvv|cvc|安全码)/i.test(line)) continue; + const direct = line.match(/\b(\d{3,4})\b/); + if (direct) { + result.cvv = direct[1]; + break; + } + const next = lines[i + 1] || ""; + const m = next.match(/\b(\d{3,4})\b/); + if (m) { + result.cvv = m[1]; + break; + } + } + } + + // 姓名 + const nameKeyCandidates = ["姓名", "name", "cardholder", "持卡人"]; + for (const key of nameKeyCandidates) { + if (kv[key]) { + result.billing_name = kv[key]; + break; + } + } + if (!result.billing_name) { + for (const line of lines) { + if (/^[A-Z][a-z]+(\s+[A-Z][a-z]+){0,4}$/.test(line)) { + result.billing_name = line; + break; + } + } + } + + // 地址字段 + const addressLine = kv["地址"] || kv["address"] || kv["address_line1"] || ""; + const city = kv["城市"] || kv["city"] || ""; + const state = kv["州"] || kv["state"] || kv["省"] || ""; + const postal = kv["邮编"] || kv["postal_code"] || kv["zip"] || kv["zipcode"] || kv["zip_code"] || ""; + const countryRaw = kv["国家"] || kv["country"] || kv["地区"] || ""; + + if (addressLine) result.address_line1 = addressLine; + if (city) result.address_city = city; + if (state) result.address_state = state; + if (postal) result.postal_code = postal; + + if (countryRaw) { + const country = resolveCountryAlias(countryRaw); + if (country) { + result.country_code = country.code; + result.currency = country.currency; + } + } + + // 账单地址单行模式 + if (!result.address_line1) { + let addressCandidate = ""; + for (const line of lines) { + if (/(账单地址|billing\s*address)/i.test(line)) { + addressCandidate = line.replace(/^.*?(账单地址|billing\s*address)\s*[::]?\s*/i, "").trim(); + if (addressCandidate) break; + } + } + if (!addressCandidate) { + addressCandidate = lines.find((line) => line.includes(",") && /\d/.test(line)) || ""; + } + if (addressCandidate) { + result.raw_address = addressCandidate; + const parts = addressCandidate.split(",").map((item) => item.trim()).filter(Boolean); + if (parts.length) { + result.address_line1 = parts[0]; + } + if (!result.postal_code) { + const zip = addressCandidate.match(/\b(\d{5}(?:-\d{4})?)\b/); + if (zip) result.postal_code = zip[1]; + } + if (!result.address_state) { + const stateCode = addressCandidate.match(/\b([A-Z]{2})\b/); + if (stateCode) result.address_state = stateCode[1]; + } + const suffix = parts[parts.length - 1]; + if (!result.country_code && suffix) { + const country = resolveCountryAlias(suffix); + if (country) { + result.country_code = country.code; + result.currency = country.currency; + } + } + } + } + + return result; +} + +function buildParsedSummary(parsed) { + const parts = []; + if (parsed.card_number) parts.push(`卡号: ${maskCardNumber(parsed.card_number)}`); + if (parsed.exp_month || parsed.exp_year) { + parts.push(`有效期: ${formatExpiryInput(parsed.exp_month, parsed.exp_year) || "--/--"}`); + } + if (parsed.cvv) parts.push("CVV: ***"); + if (parsed.billing_name) parts.push(`姓名: ${parsed.billing_name}`); + if (parsed.address_line1 || parsed.raw_address) { + parts.push(`地址: ${parsed.raw_address || parsed.address_line1}`); + } + if (parsed.country_code) parts.push(`国家: ${parsed.country_code}`); + return parts; +} + +function fillBillingForm(parsed) { + if (!parsed || typeof parsed !== "object") return; + + if (parsed.card_number) setInputValue("card-number-input", parsed.card_number); + if (parsed.exp_month || parsed.exp_year) { + setInputValue("card-expiry-input", formatExpiryInput(parsed.exp_month, parsed.exp_year)); + } + if (parsed.cvc || parsed.cvv) setInputValue("card-cvc-input", String(parsed.cvc || parsed.cvv || "")); + if (parsed.billing_name) setInputValue("billing-name-input", parsed.billing_name); + if (parsed.address_line1) setInputValue("billing-line1-input", parsed.address_line1); + if (parsed.address_city) setInputValue("billing-city-input", parsed.address_city); + if (parsed.address_state) setInputValue("billing-state-input", parsed.address_state); + if (parsed.postal_code) setInputValue("billing-postal-input", parsed.postal_code); + + if (parsed.country_code) { + const countryEl = document.getElementById("billing-country-input"); + if (countryEl) countryEl.value = parsed.country_code; + } + + if (parsed.currency) { + setInputValue("billing-currency-input", parsed.currency); + } else { + onBillingCountryChanged(); + } + + persistBillingProfileNonSensitive(); +} + +function onBillingCountryChanged() { + const country = (document.getElementById("billing-country-input")?.value || "US").toUpperCase(); + const currencyEl = document.getElementById("billing-currency-input"); + if (!currencyEl) return; + if (!currencyEl.value || currencyEl.value === "USD" || currencyEl.value.length < 3) { + currencyEl.value = BILLING_COUNTRY_CURRENCY_MAP[country] || "USD"; + } +} + +function setRandomBillingHint(message, mode = "info") { + const hintEl = document.getElementById("random-billing-hint"); + if (!hintEl) return; + hintEl.textContent = String(message || ""); + if (mode === "success") { + hintEl.style.color = "var(--success-color)"; + return; + } + if (mode === "error") { + hintEl.style.color = "var(--danger-color)"; + return; + } + hintEl.style.color = "var(--text-secondary)"; +} + +async function randomBillingByCountry() { + const country = String(getInputValue("billing-country-input") || "US").toUpperCase(); + setButtonLoading("random-billing-btn", "生成中...", true); + setRandomBillingHint("正在获取随机账单资料...", "info"); + try { + const data = await api.get(`/payment/random-billing?country=${encodeURIComponent(country)}`); + const profile = data?.profile || {}; + if (!profile || typeof profile !== "object") { + throw new Error("返回的账单资料格式无效"); + } + + fillBillingForm({ + billing_name: profile.billing_name || "", + country_code: profile.country_code || country, + currency: profile.currency || "", + address_line1: profile.address_line1 || "", + address_city: profile.address_city || "", + address_state: profile.address_state || "", + postal_code: profile.postal_code || "", + }); + + const source = String(profile.source || "unknown"); + let sourceLabel = "本地兜底"; + if (source === "meiguodizhi") sourceLabel = "meiguodizhi"; + if (source === "local_geo") sourceLabel = "本地生成"; + if (source === "local_geo_fallback") sourceLabel = "本地兜底"; + const fallbackReason = String(profile.fallback_reason || "").trim(); + const city = String(profile.address_city || "-"); + const state = String(profile.address_state || "-"); + const postal = String(profile.postal_code || "-"); + if ((source === "local_fallback" || source === "local_geo_fallback") && fallbackReason) { + setRandomBillingHint(`来源: ${sourceLabel} | ${city}, ${state}, ${postal} | 外部失败: ${fallbackReason}`, "error"); + toast.warning(`外部地址源不可用,已切换本地兜底:${fallbackReason}`); + } else { + setRandomBillingHint(`来源: ${sourceLabel} | ${city}, ${state}, ${postal}`, "success"); + toast.success(`已按 ${country} 随机填充账单资料(${sourceLabel})`); + } + } catch (error) { + setRandomBillingHint(`随机失败: ${formatErrorMessage(error)}`, "error"); + toast.error(`随机账单资料失败: ${formatErrorMessage(error)}`); + } finally { + setButtonLoading("random-billing-btn", "生成中...", false); + } +} + +function collectBillingFormData() { + const expiry = parseExpiryInput(getInputValue("card-expiry-input")); + return { + card_number: getInputValue("card-number-input").replace(/\D/g, ""), + exp_month: expiry.exp_month, + exp_year: expiry.exp_year, + cvc: getInputValue("card-cvc-input").replace(/\D/g, ""), + billing_name: getInputValue("billing-name-input"), + country_code: getInputValue("billing-country-input").toUpperCase(), + currency: getInputValue("billing-currency-input").toUpperCase(), + address_line1: getInputValue("billing-line1-input"), + address_city: getInputValue("billing-city-input"), + address_state: getInputValue("billing-state-input"), + postal_code: getInputValue("billing-postal-input"), + }; +} + +function persistBillingProfileNonSensitive() { + const data = collectBillingFormData(); + storage.set(BILLING_STORAGE_KEY, { + billing_name: data.billing_name, + country_code: data.country_code, + currency: data.currency, + address_line1: data.address_line1, + address_city: data.address_city, + address_state: data.address_state, + postal_code: data.postal_code, + }); +} + +function restoreBillingProfileNonSensitive() { + const saved = storage.get(BILLING_STORAGE_KEY, null); + if (!saved || typeof saved !== "object") { + setInputValue("billing-country-input", "US"); + setInputValue("billing-currency-input", "USD"); + return; + } + if (saved.billing_name) setInputValue("billing-name-input", saved.billing_name); + if (saved.country_code) setInputValue("billing-country-input", saved.country_code); + if (saved.currency) setInputValue("billing-currency-input", saved.currency); + if (saved.address_line1) setInputValue("billing-line1-input", saved.address_line1); + if (saved.address_city) setInputValue("billing-city-input", saved.address_city); + if (saved.address_state) setInputValue("billing-state-input", saved.address_state); + if (saved.postal_code) setInputValue("billing-postal-input", saved.postal_code); + onBillingCountryChanged(); +} + +function getBillingTemplates() { + const raw = storage.get(BILLING_TEMPLATE_STORAGE_KEY, []); + if (!Array.isArray(raw)) return []; + return raw + .filter((item) => item && typeof item === "object" && item.id && item.name && item.data) + .slice(0, BILLING_TEMPLATE_MAX); +} + +function saveBillingTemplates(list) { + const safeList = Array.isArray(list) ? list.slice(0, BILLING_TEMPLATE_MAX) : []; + storage.set(BILLING_TEMPLATE_STORAGE_KEY, safeList); +} + +function normalizeTemplateData(data) { + const source = data && typeof data === "object" ? data : {}; + return { + card_number: String(source.card_number || "").replace(/\D/g, ""), + exp_month: normalizeMonth(source.exp_month), + exp_year: normalizeYear(source.exp_year), + cvc: String(source.cvc || source.cvv || "").replace(/\D/g, "").slice(0, 4), + billing_name: String(source.billing_name || "").trim(), + country_code: String(source.country_code || "US").trim().toUpperCase() || "US", + currency: String(source.currency || "").trim().toUpperCase(), + address_line1: String(source.address_line1 || "").trim(), + address_city: String(source.address_city || "").trim(), + address_state: String(source.address_state || "").trim(), + postal_code: String(source.postal_code || "").trim(), + }; +} + +function refreshBillingTemplateSelect(selectedId = "") { + const selectEl = document.getElementById("billing-template-select"); + if (!selectEl) return; + const templates = getBillingTemplates(); + + const options = ['']; + templates.forEach((tpl) => { + options.push(``); + }); + selectEl.innerHTML = options.join(""); + + if (selectedId && templates.some((tpl) => tpl.id === selectedId)) { + selectEl.value = selectedId; + } +} + +function saveCurrentAsTemplate() { + const name = getInputValue("billing-template-name"); + if (!name) { + toast.warning("请先填写模板名称"); + return; + } + + const form = normalizeTemplateData(collectBillingFormData()); + const hasValue = Boolean( + form.card_number || + (form.exp_month && form.exp_year) || + form.cvc || + form.billing_name || + form.address_line1 + ); + if (!hasValue) { + toast.warning("当前表单为空,无法保存模板"); + return; + } + + const templates = getBillingTemplates(); + const normalizedName = name.toLowerCase(); + const existing = templates.find((tpl) => String(tpl.name || "").toLowerCase() === normalizedName); + const nowIso = new Date().toISOString(); + if (existing) { + existing.data = form; + existing.updated_at = nowIso; + saveBillingTemplates(templates); + refreshBillingTemplateSelect(existing.id); + toast.success(`模板已更新: ${name}`); + return; + } + + const newTemplate = { + id: `tpl_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`, + name, + data: form, + created_at: nowIso, + updated_at: nowIso, + }; + templates.unshift(newTemplate); + saveBillingTemplates(templates); + refreshBillingTemplateSelect(newTemplate.id); + toast.success(`模板已保存: ${name}`); +} + +function applySelectedTemplate() { + const selectEl = document.getElementById("billing-template-select"); + if (!selectEl || !selectEl.value) { + toast.warning("请先选择模板"); + return; + } + const templates = getBillingTemplates(); + const selected = templates.find((tpl) => tpl.id === selectEl.value); + if (!selected) { + toast.warning("模板不存在或已删除"); + refreshBillingTemplateSelect(); + return; + } + fillBillingForm(selected.data || {}); + setInputValue("billing-template-name", selected.name || ""); + toast.success(`已应用模板: ${selected.name}`); +} + +async function deleteSelectedTemplate() { + const selectEl = document.getElementById("billing-template-select"); + if (!selectEl || !selectEl.value) { + toast.warning("请先选择模板"); + return; + } + + const templates = getBillingTemplates(); + const selected = templates.find((tpl) => tpl.id === selectEl.value); + if (!selected) { + toast.warning("模板不存在或已删除"); + refreshBillingTemplateSelect(); + return; + } + + const ok = await confirm(`确认删除模板「${selected.name}」吗?`, "删除模板"); + if (!ok) return; + + const next = templates.filter((tpl) => tpl.id !== selected.id); + saveBillingTemplates(next); + refreshBillingTemplateSelect(); + setInputValue("billing-template-name", ""); + toast.success(`模板已删除: ${selected.name}`); +} + +function saveBatchProfilesAsTemplates() { + if (!billingBatchProfiles.length) { + toast.warning("请先批量识别文本"); + return; + } + + const templates = getBillingTemplates(); + const now = new Date(); + let saved = 0; + + billingBatchProfiles.forEach((item, idx) => { + const parsed = item?.parsed || {}; + const data = normalizeTemplateData({ + card_number: parsed.card_number, + exp_month: parsed.exp_month, + exp_year: parsed.exp_year, + cvc: parsed.cvv, + billing_name: parsed.billing_name, + country_code: parsed.country_code, + currency: parsed.currency, + address_line1: parsed.address_line1, + address_city: parsed.address_city, + address_state: parsed.address_state, + postal_code: parsed.postal_code, + }); + const hasValue = Boolean( + data.card_number || + (data.exp_month && data.exp_year) || + data.cvc || + data.billing_name || + data.address_line1 + ); + if (!hasValue) return; + + const suffix = data.card_number ? data.card_number.slice(-4) : String(idx + 1).padStart(2, "0"); + const name = `批量模板-${now.toISOString().slice(0, 10)}-${suffix}`; + templates.unshift({ + id: `tpl_${Date.now()}_${Math.random().toString(16).slice(2, 8)}_${idx}`, + name, + data, + created_at: now.toISOString(), + updated_at: now.toISOString(), + }); + saved += 1; + }); + + if (!saved) { + toast.warning("批量记录里没有可保存的模板"); + return; + } + + saveBillingTemplates(templates); + refreshBillingTemplateSelect(); + toast.success(`已保存 ${saved} 个模板`); +} + +function setParseResult(message, type = "info") { + const resultEl = document.getElementById("billing-parse-result"); + if (!resultEl) return; + resultEl.textContent = message || ""; + if (type === "error") { + resultEl.style.color = "var(--danger-color)"; + } else if (type === "success") { + resultEl.style.color = "var(--success-color)"; + } else { + resultEl.style.color = "var(--text-secondary)"; + } +} + +function parseSingleBillingText() { + const text = getInputValue("billing-paste-text"); + if (!text) { + setParseResult("请先粘贴文本", "error"); + return; + } + + const parsed = parseCardText(text); + const summary = buildParsedSummary(parsed); + if (!summary.length) { + setParseResult("未识别到可用信息,请检查文本格式", "error"); + return; + } + + fillBillingForm(parsed); + setParseResult(`识别成功: ${summary.join(" | ")}`, "success"); +} + +function splitBatchBlocks(rawText) { + const blocks = String(rawText || "") + .split(/\n\s*\n+/) + .map((part) => part.trim()) + .filter(Boolean); + if (blocks.length > 1) return blocks; + return String(rawText || "") + .split(/(?:^-{3,}|^={3,})/m) + .map((part) => part.trim()) + .filter(Boolean); +} + +function renderBatchProfiles() { + const wrap = document.getElementById("billing-batch-wrap"); + const summary = document.getElementById("billing-batch-summary"); + const tbody = document.getElementById("billing-batch-table"); + if (!wrap || !summary || !tbody) return; + + if (!billingBatchProfiles.length) { + wrap.style.display = "none"; + tbody.innerHTML = ""; + summary.textContent = ""; + return; + } + + wrap.style.display = ""; + summary.textContent = `已识别 ${billingBatchProfiles.length} 条资料,点击“填充”可写入下方表单。`; + tbody.innerHTML = billingBatchProfiles.map((item, index) => { + const parsed = item.parsed || {}; + const address = parsed.raw_address || [ + parsed.address_line1, + parsed.address_city, + parsed.address_state, + parsed.postal_code, + ].filter(Boolean).join(", "); + return ` + + ${index + 1} + ${escapeHtml(maskCardNumber(parsed.card_number))} + ${escapeHtml(formatExpiryInput(parsed.exp_month, parsed.exp_year) || "-")} + ${escapeHtml(parsed.cvv ? "***" : "-")} + ${escapeHtml(parsed.country_code || "-")} + ${escapeHtml(address || "-")} + + + `; + }).join(""); +} + +function parseBatchBillingText() { + const text = getInputValue("billing-paste-text"); + if (!text) { + setParseResult("请先粘贴文本", "error"); + return; + } + + const blocks = splitBatchBlocks(text); + if (!blocks.length) { + setParseResult("未检测到可解析的文本块", "error"); + return; + } + + billingBatchProfiles = blocks + .map((blockText) => ({ raw: blockText, parsed: parseCardText(blockText) })) + .filter((item) => { + const parsed = item.parsed || {}; + return Boolean( + parsed.card_number || + parsed.exp_month || + parsed.exp_year || + parsed.cvv || + parsed.address_line1 || + parsed.raw_address + ); + }); + + renderBatchProfiles(); + if (!billingBatchProfiles.length) { + setParseResult("批量识别完成,但没有可用记录", "error"); + return; + } + + setParseResult(`批量识别成功:共 ${billingBatchProfiles.length} 条`, "success"); +} + +function fillFromBatchProfile(index) { + const item = billingBatchProfiles[index]; + if (!item) return; + fillBillingForm(item.parsed || {}); + const summary = buildParsedSummary(item.parsed || {}); + setParseResult(`已填充第 ${index + 1} 条:${summary.join(" | ")}`, "success"); +} + +function clearBillingText() { + setInputValue("billing-paste-text", ""); + billingBatchProfiles = []; + renderBatchProfiles(); + setParseResult("", "info"); +} + +function bindBillingEvents() { + document.getElementById("parse-billing-btn")?.addEventListener("click", parseSingleBillingText); + document.getElementById("parse-batch-btn")?.addEventListener("click", parseBatchBillingText); + document.getElementById("clear-billing-btn")?.addEventListener("click", clearBillingText); + document.getElementById("save-billing-template-btn")?.addEventListener("click", saveCurrentAsTemplate); + document.getElementById("apply-billing-template-btn")?.addEventListener("click", applySelectedTemplate); + document.getElementById("delete-billing-template-btn")?.addEventListener("click", deleteSelectedTemplate); + document.getElementById("save-batch-template-btn")?.addEventListener("click", saveBatchProfilesAsTemplates); + document.getElementById("billing-template-select")?.addEventListener("change", (event) => { + const selectValue = event?.target?.value || ""; + if (!selectValue) { + setInputValue("billing-template-name", ""); + return; + } + const selected = getBillingTemplates().find((tpl) => tpl.id === selectValue); + if (selected) { + setInputValue("billing-template-name", selected.name || ""); + } + }); + + document.getElementById("billing-country-input")?.addEventListener("change", () => { + onBillingCountryChanged(); + persistBillingProfileNonSensitive(); + setRandomBillingHint(""); + }); + + [ + "card-number-input", + "card-expiry-input", + "card-cvc-input", + "billing-name-input", + "billing-country-input", + "billing-currency-input", + "billing-line1-input", + "billing-city-input", + "billing-state-input", + "billing-postal-input", + ].forEach((id) => { + const node = document.getElementById(id); + node?.addEventListener("input", debounce(() => { + persistBillingProfileNonSensitive(); + resetGenerateLinkButtonState(); + }, 200)); + node?.addEventListener("change", resetGenerateLinkButtonState); + }); + + document.getElementById("card-expiry-input")?.addEventListener("input", (event) => { + const el = event.target; + if (!el) return; + const next = normalizeExpiryInputForTyping(el.value); + if (el.value !== next) { + el.value = next; + } + }); + + document.getElementById("card-number-input")?.addEventListener("input", (event) => { + const el = event.target; + if (!el) return; + const digits = String(el.value || "").replace(/\D/g, "").slice(0, 19); + const grouped = digits.replace(/(\d{4})(?=\d)/g, "$1 ").trim(); + if (el.value !== grouped) { + el.value = grouped; + } + }); + + document.getElementById("card-cvc-input")?.addEventListener("input", (event) => { + const el = event.target; + if (!el) return; + const digits = String(el.value || "").replace(/\D/g, "").slice(0, 4); + if (el.value !== digits) { + el.value = digits; + } + }); +} + +function getTaskStatusText(status) { + const mapping = { + link_ready: "待打开", + opened: "已打开", + waiting_user_action: "待用户完成", + paid_pending_sync: "已支付待同步", + verifying: "验证中", + completed: "已完成", + failed: "失败", + }; + return mapping[status] || status || "-"; +} + +function startBindTaskAutoRefresh() { + stopBindTaskAutoRefresh(); + bindTaskAutoRefreshTimer = setInterval(() => { + const bindTaskTab = document.getElementById("tab-content-bind-task"); + if (!bindTaskTab?.classList.contains("active")) return; + loadBindCardTasks(true); + }, 20000); +} + +function stopBindTaskAutoRefresh() { + if (!bindTaskAutoRefreshTimer) return; + clearInterval(bindTaskAutoRefreshTimer); + bindTaskAutoRefreshTimer = null; +} + +function setButtonLoading(buttonId, loadingText, isLoading) { + const btn = document.getElementById(buttonId); + if (!btn) return; + if (isLoading) { + if (!btn.dataset.originalText) { + btn.dataset.originalText = btn.textContent || ""; + } + btn.disabled = true; + btn.textContent = loadingText; + return; + } + btn.disabled = false; + btn.textContent = btn.dataset.originalText || btn.textContent; +} + +function resetGenerateLinkButtonState() { + const btn = document.getElementById("generate-link-btn"); + if (!btn) return; + btn.disabled = false; + if (btn.dataset.originalText) { + btn.textContent = btn.dataset.originalText; + } +} + +function getBindMode() { + return (document.getElementById("bind-mode-select")?.value || "semi_auto").trim() || "semi_auto"; +} + +function updateSemiAutoActionsVisibility(mode) { + const isSemiAuto = (mode || getBindMode()) === "semi_auto"; + const loginBtn = document.getElementById("semi-login-gpt-btn"); + const emailBtn = document.getElementById("semi-account-email-btn"); + if (loginBtn) { + loginBtn.style.display = isSemiAuto ? "" : "none"; + } + if (emailBtn) { + emailBtn.style.display = isSemiAuto ? "" : "none"; + } +} + +function getSelectedAccountEmail() { + const accountId = Number(document.getElementById("account-select")?.value || 0); + if (!accountId) return ""; + const matched = (paymentAccounts || []).find((acc) => Number(acc?.id || 0) === accountId); + return String(matched?.email || "").trim(); +} + +function updateSelectedAccountEmailLabel() { + const emailBtn = document.getElementById("semi-account-email-btn"); + if (!emailBtn) return; + const email = getSelectedAccountEmail(); + emailBtn.textContent = "邮箱"; + emailBtn.title = email ? `当前账号: ${email}(点击查询最新验证码)` : "请先选择账号"; +} + +async function openGptOfficialLogin() { + const accountId = Number(document.getElementById("account-select")?.value || 0); + if (!accountId) { + toast.warning("请先选择账号"); + return; + } + const loginUrl = "https://chatgpt.com/auth/login"; + try { + const data = await api.post("/payment/open-incognito", { + url: loginUrl, + account_id: accountId, + }); + if (data?.success) { + toast.success("已打开 GPT 官方登录页(无痕)"); + return; + } + window.open(loginUrl, "_blank", "noopener,noreferrer"); + toast.warning(data?.message || "未找到可用浏览器,已在当前浏览器打开"); + } catch (error) { + window.open(loginUrl, "_blank", "noopener,noreferrer"); + toast.warning(`打开无痕失败,已在当前浏览器打开: ${formatErrorMessage(error)}`); + } +} + +async function fetchSelectedAccountInbox() { + const accountId = Number(document.getElementById("account-select")?.value || 0); + const email = getSelectedAccountEmail(); + if (!accountId || !email) { + toast.warning("请先选择账号"); + return; + } + toast.info(`正在查询 ${email} 收件箱...`); + try { + const result = await api.post(`/accounts/${accountId}/inbox-code`, {}); + if (result?.success && result?.code) { + const code = String(result.code).trim(); + copyToClipboard(code); + toast.success(`${email} 最新验证码: ${code}(已复制)`, 8000); + return; + } + toast.error(`查询失败: ${result?.error || "未收到验证码"}`); + } catch (error) { + toast.error(`查询失败: ${formatErrorMessage(error)}`); + } +} + +function onBindModeChange() { + const mode = getBindMode(); + const thirdPartyPanel = document.getElementById("third-party-config"); + if (thirdPartyPanel) { + thirdPartyPanel.style.display = mode === "third_party" ? "" : "none"; + } + + const actionBtn = document.getElementById("create-bind-task-btn"); + if (actionBtn) { + if (mode === "third_party") { + actionBtn.textContent = "创建并执行第三方自动绑卡"; + } else if (mode === "local_auto") { + actionBtn.textContent = "创建并执行全自动绑卡"; + } else { + actionBtn.textContent = "生成并加入绑卡任务(半自动)"; + } + } + updateSemiAutoActionsVisibility(mode); + updateSelectedAccountEmailLabel(); + storage.set(BIND_MODE_STORAGE_KEY, mode); +} + +function collectThirdPartyConfig() { + const apiUrl = getInputValue("third-party-api-url"); + const apiKey = getInputValue("third-party-api-key"); + return { api_url: apiUrl, api_key: apiKey }; +} + +function restoreBindModeConfig() { + const modeSelect = document.getElementById("bind-mode-select"); + const savedMode = String(storage.get(BIND_MODE_STORAGE_KEY, "semi_auto") || "semi_auto"); + if (modeSelect) { + modeSelect.value = ["semi_auto", "third_party", "local_auto"].includes(savedMode) ? savedMode : "semi_auto"; + } + + const savedApiUrl = String(storage.get(THIRD_PARTY_BIND_URL_STORAGE_KEY, "") || "").trim(); + const initialApiUrl = savedApiUrl || THIRD_PARTY_BIND_DEFAULT_URL; + setInputValue("third-party-api-url", initialApiUrl); + if (!savedApiUrl) { + storage.set(THIRD_PARTY_BIND_URL_STORAGE_KEY, initialApiUrl); + } + onBindModeChange(); +} + +function switchPaymentTab(tab) { + const isLink = tab === "link"; + document.getElementById("tab-btn-link")?.classList.toggle("active", isLink); + document.getElementById("tab-btn-bind-task")?.classList.toggle("active", !isLink); + document.getElementById("tab-content-link")?.classList.toggle("active", isLink); + document.getElementById("tab-content-bind-task")?.classList.toggle("active", !isLink); + if (!isLink) { + loadBindCardTasks(true); + } +} + +function getCheckoutPayload() { + const accountId = Number(document.getElementById("account-select")?.value || 0); + if (!accountId) { + throw new Error("请先选择账号"); + } + const payload = { + account_id: accountId, + plan_type: selectedPlan, + country: (document.getElementById("country-select")?.value || "US").toUpperCase(), + currency: (document.getElementById("currency-display")?.value || "USD").toUpperCase(), + }; + if (selectedPlan === "team") { + payload.workspace_name = document.getElementById("workspace-name")?.value || "MyTeam"; + payload.seat_quantity = Number(document.getElementById("seat-quantity")?.value || 5) || 5; + payload.price_interval = document.getElementById("price-interval")?.value || "month"; + } + return payload; +} + +function showGeneratedLink(data) { + generatedLink = data.link || ""; + const linkText = document.getElementById("link-text"); + const linkBox = document.getElementById("link-box"); + const statusEl = document.getElementById("open-status"); + if (!linkText || !linkBox || !statusEl) return; + + linkText.value = generatedLink; + linkBox.classList.add("show"); + + const source = data.source ? `来源: ${data.source}` : ""; + const routeHint = data.is_official_checkout + ? "已拿到官方 checkout 链接,可直接绑卡" + : "当前是中转链接,点击“直接打开支付页”继续跳转"; + statusEl.textContent = [source, routeHint].filter(Boolean).join(" | "); +} + +document.addEventListener("DOMContentLoaded", () => { + const countrySelect = document.getElementById("country-select"); + const currencyDisplay = document.getElementById("currency-display"); + if (countrySelect) { + countrySelect.value = countrySelect.value || "US"; + } + if (currencyDisplay) { + currencyDisplay.value = currencyDisplay.value || "USD"; + } + + const searchInput = document.getElementById("bind-task-search"); + if (searchInput) { + searchInput.addEventListener( + "input", + debounce(() => { + bindTaskState.search = (searchInput.value || "").trim(); + bindTaskState.page = 1; + loadBindCardTasks(); + }, 250) + ); + } + + const statusSelect = document.getElementById("bind-task-status"); + if (statusSelect) { + statusSelect.addEventListener("change", () => { + bindTaskState.status = statusSelect.value || ""; + bindTaskState.page = 1; + loadBindCardTasks(); + }); + } + document.getElementById("account-select")?.addEventListener("change", () => { + resetGenerateLinkButtonState(); + updateSelectedAccountEmailLabel(); + }); + + bindBillingEvents(); + restoreBillingProfileNonSensitive(); + refreshBillingTemplateSelect(); + restoreBindModeConfig(); + + document.getElementById("third-party-api-url")?.addEventListener( + "input", + debounce(() => { + const apiUrl = getInputValue("third-party-api-url"); + storage.set(THIRD_PARTY_BIND_URL_STORAGE_KEY, apiUrl); + }, 200) + ); + document.getElementById("bind-mode-select")?.addEventListener("change", onBindModeChange); -// 初始化 -document.addEventListener('DOMContentLoaded', () => { loadAccounts(); + onCountryChange(); + loadBindCardTasks(); + startBindTaskAutoRefresh(); + switchPaymentTab("link"); + + window.addEventListener("beforeunload", stopBindTaskAutoRefresh); }); // 加载账号列表 async function loadAccounts() { try { - const resp = await fetch('/api/accounts?page=1&page_size=100&status=active'); - const data = await resp.json(); - const sel = document.getElementById('account-select'); + // 后端 page_size 最大为 100,超限会返回 422。 + // 这里读取账号管理列表,不按状态硬过滤,避免“有账号但选不到”。 + const data = await api.get("/accounts?page=1&page_size=100"); + const sel = document.getElementById("account-select"); + if (!sel) return; + sel.innerHTML = ''; - (data.accounts || []).forEach(acc => { - const opt = document.createElement('option'); + paymentAccounts = Array.isArray(data.accounts) ? data.accounts : []; + (data.accounts || []).forEach((acc) => { + const opt = document.createElement("option"); opt.value = acc.id; - opt.textContent = acc.email; + const subText = acc.subscription_type ? ` (${String(acc.subscription_type).toUpperCase()})` : ""; + opt.textContent = `${acc.email}${subText}`; sel.appendChild(opt); }); + updateSelectedAccountEmailLabel(); + updateSemiAutoActionsVisibility(getBindMode()); } catch (e) { - console.error('加载账号失败:', e); + toast.error(`加载账号失败: ${formatErrorMessage(e)}`); } } // 国家切换 function onCountryChange() { - const country = document.getElementById('country-select').value; - const currency = COUNTRY_CURRENCY_MAP[country] || 'USD'; - document.getElementById('currency-display').value = currency; + const country = document.getElementById("country-select")?.value || "US"; + const currency = COUNTRY_CURRENCY_MAP[country] || "USD"; + const currencyEl = document.getElementById("currency-display"); + if (currencyEl) { + currencyEl.value = currency; + } } // 选择套餐 function selectPlan(plan) { selectedPlan = plan; - document.getElementById('plan-plus').classList.toggle('selected', plan === 'plus'); - document.getElementById('plan-team').classList.toggle('selected', plan === 'team'); - document.getElementById('team-options').classList.toggle('show', plan === 'team'); - // 隐藏已生成的链接 - document.getElementById('link-box').classList.remove('show'); - generatedLink = ''; + document.getElementById("plan-plus")?.classList.toggle("selected", plan === "plus"); + document.getElementById("plan-team")?.classList.toggle("selected", plan === "team"); + document.getElementById("team-options")?.classList.toggle("show", plan === "team"); + + // 切换套餐时隐藏已有链接,避免误用旧链接。 + document.getElementById("link-box")?.classList.remove("show"); + generatedLink = ""; + resetGenerateLinkButtonState(); } // 生成支付链接 async function generateLink() { - const accountId = document.getElementById('account-select').value; - if (!accountId) { - ui.showToast('请先选择账号', 'warning'); + if (isGeneratingCheckoutLink) { return; } - const country = document.getElementById('country-select').value || 'SG'; - - const body = { - account_id: parseInt(accountId), - plan_type: selectedPlan, - country: country, - }; - - if (selectedPlan === 'team') { - body.workspace_name = document.getElementById('workspace-name').value || 'MyTeam'; - body.seat_quantity = parseInt(document.getElementById('seat-quantity').value) || 5; - body.price_interval = document.getElementById('price-interval').value; + let payload; + try { + payload = getCheckoutPayload(); + } catch (err) { + toast.warning(err.message || "参数不完整"); + return; } - const btn = document.querySelector('.form-actions .btn-primary'); - if (btn) { btn.disabled = true; btn.textContent = '生成中...'; } - + isGeneratingCheckoutLink = true; + setButtonLoading("generate-link-btn", "生成中...", true); try { - const resp = await fetch('/api/payment/generate-link', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - const data = await resp.json(); - if (data.success && data.link) { - generatedLink = data.link; - document.getElementById('link-text').value = data.link; - document.getElementById('link-box').classList.add('show'); - document.getElementById('open-status').textContent = ''; - ui.showToast('支付链接生成成功', 'success'); - } else { - ui.showToast(data.detail || '生成链接失败', 'error'); + const data = await api.post("/payment/generate-link", payload); + if (!data?.success || !data?.link) { + throw new Error(data?.detail || "生成链接失败"); } + showGeneratedLink(data); + toast.success("支付链接生成成功"); } catch (e) { - ui.showToast('请求失败: ' + e.message, 'error'); + toast.error(`生成链接失败: ${formatErrorMessage(e)}`); } finally { - if (btn) { btn.disabled = false; btn.textContent = '生成支付链接'; } + isGeneratingCheckoutLink = false; + setButtonLoading("generate-link-btn", "生成中...", false); + resetGenerateLinkButtonState(); + } +} + +async function submitThirdPartyAutoBind(task, bindData) { + const thirdParty = collectThirdPartyConfig(); + const apiUrl = String(thirdParty.api_url || "").trim(); + const apiKey = String(thirdParty.api_key || "").trim(); + if (apiUrl) { + storage.set(THIRD_PARTY_BIND_URL_STORAGE_KEY, apiUrl); + } + + const expYear = String(bindData.exp_year || "").replace(/\D/g, ""); + const payload = { + api_url: apiUrl || undefined, + api_key: apiKey || undefined, + timeout_seconds: 180, + interval_seconds: 10, + card: { + number: String(bindData.card_number || "").replace(/\D/g, ""), + exp_month: String(bindData.exp_month || "").replace(/\D/g, "").padStart(2, "0").slice(0, 2), + exp_year: (expYear.slice(-2) || expYear || "").padStart(2, "0"), + cvc: String(bindData.cvc || "").replace(/\D/g, "").slice(0, 4), + }, + profile: { + name: String(bindData.billing_name || "").trim(), + email: String(task?.account_email || "").trim() || undefined, + country: String(bindData.country_code || "US").toUpperCase(), + line1: String(bindData.address_line1 || "").trim(), + city: String(bindData.address_city || "").trim(), + state: String(bindData.address_state || "").trim(), + postal: String(bindData.postal_code || "").trim(), + }, + }; + + return api.post(`/payment/bind-card/tasks/${task.id}/auto-bind-third-party`, payload); +} + +async function submitLocalAutoBind(task, bindData) { + const expYear = String(bindData.exp_year || "").replace(/\D/g, ""); + const payload = { + browser_timeout_seconds: 220, + post_submit_wait_seconds: 90, + verify_timeout_seconds: 180, + verify_interval_seconds: 10, + headless: false, + card: { + number: String(bindData.card_number || "").replace(/\D/g, ""), + exp_month: String(bindData.exp_month || "").replace(/\D/g, "").padStart(2, "0").slice(0, 2), + exp_year: (expYear.slice(-2) || expYear || "").padStart(2, "0"), + cvc: String(bindData.cvc || "").replace(/\D/g, "").slice(0, 4), + }, + profile: { + name: String(bindData.billing_name || "").trim(), + email: String(task?.account_email || "").trim() || undefined, + country: String(bindData.country_code || "US").toUpperCase(), + line1: String(bindData.address_line1 || "").trim(), + city: String(bindData.address_city || "").trim(), + state: String(bindData.address_state || "").trim(), + postal: String(bindData.postal_code || "").trim(), + }, + }; + return api.post(`/payment/bind-card/tasks/${task.id}/auto-bind-local`, payload); +} + +async function runLocalAutoBindInBackground(task, bindData) { + try { + const autoResult = await submitLocalAutoBind(task, bindData); + if (autoResult?.verified) { + toast.success(`任务 #${task.id} 全自动绑卡完成: ${String(autoResult.subscription_type || "").toUpperCase()}`); + } else if (autoResult?.paid_confirmed) { + toast.success(`任务 #${task.id} 已确认支付,等待订阅同步(可点“同步订阅”)`, 7000); + } else if (autoResult?.pending || autoResult?.need_user_action) { + const stage = String(autoResult?.local_auto?.stage || autoResult?.local_auto?.error || "challenge").toUpperCase(); + toast.warning( + `任务 #${task.id} 全自动绑卡已提交(${stage}),请在支付页完成验证后点击“我已完成支付”或“同步订阅”。`, + 9000 + ); + } else { + const sub = String(autoResult?.subscription_type || "free").toUpperCase(); + toast.warning(`任务 #${task.id} 全自动绑卡执行完成,但当前订阅为 ${sub},请稍后再同步`, 7000); + } + } catch (autoErr) { + toast.error(`任务 #${task.id} 全自动绑卡失败: ${formatErrorMessage(autoErr)}`); + } finally { + try { + await loadBindCardTasks(); + } catch (_) { + // 忽略刷新异常,避免覆盖前面的业务提示 + } + } +} + +// 生成并创建绑卡任务 +async function createBindCardTask() { + let payload; + try { + payload = getCheckoutPayload(); + } catch (err) { + toast.warning(err.message || "参数不完整"); + return; + } + + const bindMode = getBindMode(); + const bindData = collectBillingFormData(); + const missing = []; + if (!bindData.card_number) missing.push("卡号"); + if (!bindData.exp_month || !bindData.exp_year) missing.push("有效期"); + if (!bindData.cvc) missing.push("CVC"); + if (!bindData.billing_name) missing.push("姓名"); + if (!bindData.address_line1) missing.push("地址"); + if (!bindData.postal_code) missing.push("邮编"); + if (missing.length && bindMode === "semi_auto") { + toast.warning(`绑卡资料未完整:${missing.join("、")}(本次仅创建半自动任务,不会阻断)`, 5000); + } + if (missing.length && (bindMode === "third_party" || bindMode === "local_auto")) { + const modeText = bindMode === "third_party" ? "第三方自动绑卡" : "全自动绑卡"; + toast.warning(`${modeText}需要完整资料:${missing.join("、")}`, 5000); + return; + } + + payload.auto_open = Boolean(document.getElementById("bind-auto-open")?.checked); + payload.bind_mode = bindMode; + + setButtonLoading("create-bind-task-btn", "创建中...", true); + try { + const data = await api.post("/payment/bind-card/tasks", payload); + if (!data?.success || !data?.task) { + throw new Error(data?.detail || "创建绑卡任务失败"); + } + + if (data.link) { + showGeneratedLink({ + link: data.link, + source: data.source, + is_official_checkout: data.is_official_checkout, + }); + } + + if (bindMode === "third_party") { + toast.info(`任务 #${data.task.id} 已创建,正在调用第三方自动绑卡...`, 3000); + try { + const autoResult = await submitThirdPartyAutoBind(data.task, bindData); + if (autoResult?.verified) { + toast.success(`任务 #${data.task.id} 自动绑卡完成: ${String(autoResult.subscription_type || "").toUpperCase()}`); + } else if (autoResult?.paid_confirmed) { + toast.success(`任务 #${data.task.id} 已确认支付,等待订阅同步(可点“同步订阅”)`, 7000); + } else if (autoResult?.pending || autoResult?.need_user_action) { + const tp = autoResult?.third_party || {}; + const assess = tp?.assessment || {}; + const snapshot = assess?.snapshot || {}; + const paymentStatus = String(snapshot?.payment_status || "").toUpperCase() || "UNKNOWN"; + toast.warning( + `任务 #${data.task.id} 第三方已受理(payment_status=${paymentStatus}),可能需要 challenge;请在支付页完成后点“我已完成支付”或“同步订阅”。`, + 9000 + ); + } else { + const sub = String(autoResult?.subscription_type || "free").toUpperCase(); + toast.warning(`任务 #${data.task.id} 第三方提交成功,但当前订阅为 ${sub},请稍后再同步`, 7000); + } + } catch (autoErr) { + toast.error(`任务 #${data.task.id} 第三方自动绑卡失败: ${formatErrorMessage(autoErr)}`); + } + } else if (bindMode === "local_auto") { + toast.info(`任务 #${data.task.id} 已创建,已在后台执行全自动绑卡,可继续修改参数并创建新任务`, 5000); + runLocalAutoBindInBackground(data.task, { ...bindData }); + } else { + toast.success(`绑卡任务已创建 #${data.task.id}${data.auto_opened ? ",浏览器已打开" : ""}`); + } + switchPaymentTab("bind-task"); + await loadBindCardTasks(); + } catch (e) { + toast.error(`创建绑卡任务失败: ${formatErrorMessage(e)}`); + } finally { + setButtonLoading("create-bind-task-btn", "创建中...", false); } } -// 复制链接 function copyLink() { - if (!generatedLink) return; - navigator.clipboard.writeText(generatedLink).then(() => { - ui.showToast('已复制到剪贴板', 'success'); - }).catch(() => { - const ta = document.getElementById('link-text'); - ta.select(); - document.execCommand('copy'); - ui.showToast('已复制到剪贴板', 'success'); - }); + if (!generatedLink) { + toast.warning("请先生成链接"); + return; + } + copyToClipboard(generatedLink); +} + +// 在当前浏览器直接打开支付页(适合 Docker/远程部署场景) +function openCheckout() { + if (!generatedLink) { + toast.warning("请先生成链接"); + return; + } + const w = window.open(generatedLink, "_blank", "noopener,noreferrer"); + if (!w) { + window.location.href = generatedLink; + } } // 无痕打开浏览器(携带账号 cookie) async function openIncognito() { if (!generatedLink) { - ui.showToast('请先生成链接', 'warning'); + toast.warning("请先生成链接"); return; } - const accountId = document.getElementById('account-select').value; - const statusEl = document.getElementById('open-status'); - statusEl.textContent = '正在打开...'; + const accountId = Number(document.getElementById("account-select")?.value || 0); + const statusEl = document.getElementById("open-status"); + if (statusEl) { + statusEl.textContent = "正在打开..."; + } try { const body = { url: generatedLink }; - if (accountId) body.account_id = parseInt(accountId); - - const resp = await fetch('/api/payment/open-incognito', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - const data = await resp.json(); - if (data.success) { - statusEl.textContent = '已在无痕模式打开浏览器'; - ui.showToast('无痕浏览器已打开', 'success'); + if (accountId) body.account_id = accountId; + const data = await api.post("/payment/open-incognito", body); + if (data?.success) { + if (statusEl) statusEl.textContent = "已在无痕模式打开浏览器"; + toast.success("无痕浏览器已打开"); } else { - statusEl.textContent = data.message || '未找到可用浏览器,请手动复制链接'; - ui.showToast(data.message || '未找到浏览器', 'warning'); + if (statusEl) statusEl.textContent = data?.message || "未找到可用浏览器,请手动复制链接"; + toast.warning(data?.message || "未找到可用浏览器"); } } catch (e) { - statusEl.textContent = '请求失败: ' + e.message; - ui.showToast('请求失败', 'error'); + if (statusEl) statusEl.textContent = `请求失败: ${formatErrorMessage(e)}`; + toast.error(`请求失败: ${formatErrorMessage(e)}`); } } + +async function loadBindCardTasks(silent = false) { + const tbody = document.getElementById("bind-card-task-table"); + if (!tbody) return; + + if (!silent) { + setButtonLoading("refresh-bind-task-btn", "刷新中...", true); + } + try { + const params = new URLSearchParams({ + page: String(bindTaskState.page), + page_size: String(bindTaskState.pageSize), + }); + if (bindTaskState.status) params.set("status", bindTaskState.status); + if (bindTaskState.search) params.set("search", bindTaskState.search); + + const data = await api.get(`/payment/bind-card/tasks?${params.toString()}`); + const tasks = data?.tasks || []; + + if (!tasks.length) { + tbody.innerHTML = ` + +
暂无绑卡任务
+ + `; + return; + } + + tbody.innerHTML = tasks.map((task) => ` + + ${task.id} + ${escapeHtml(task.account_email || "-")} + ${String(task.plan_type || "-").toUpperCase()} + ${escapeHtml(getTaskStatusText(task.status))} + + + ${escapeHtml(task.checkout_url || "-")} + + + ${escapeHtml(task.checkout_source || "-")} + ${format.date(task.created_at)} + +
+ + + + +
+ ${task.last_error ? `
${escapeHtml(task.last_error)}
` : ""} + + + `).join(""); + } catch (e) { + tbody.innerHTML = ` + +
加载失败: ${escapeHtml(formatErrorMessage(e))}
+ + `; + } finally { + if (!silent) { + setButtonLoading("refresh-bind-task-btn", "刷新中...", false); + } + } +} + +async function openBindCardTask(taskId) { + try { + const data = await api.post(`/payment/bind-card/tasks/${taskId}/open`, {}); + if (data?.success) { + toast.success(`任务 #${taskId} 已尝试打开`); + await loadBindCardTasks(); + return; + } + throw new Error(data?.detail || "打开失败"); + } catch (e) { + toast.error(`打开任务失败: ${formatErrorMessage(e)}`); + } +} + +async function markBindCardTaskUserAction(taskId) { + try { + toast.info(`任务 #${taskId} 正在验证订阅,最多等待 180 秒...`, 3000); + const data = await api.post(`/payment/bind-card/tasks/${taskId}/mark-user-action`, { + timeout_seconds: 180, + interval_seconds: 10, + }); + if (data?.verified) { + toast.success(`任务 #${taskId} 验证成功: ${String(data.subscription_type || "").toUpperCase()}`); + } else { + const sub = String(data?.subscription_type || "free").toUpperCase(); + const source = String(data?.detail?.source || "unknown"); + const confidence = String(data?.detail?.confidence || "unknown"); + const note = String(data?.detail?.note || ""); + const suffix = note ? `, note=${note}` : ""; + toast.warning( + `任务 #${taskId} 暂未检测到订阅(当前 ${sub}, source=${source}, confidence=${confidence}${suffix}),已切回待用户完成`, + 7000 + ); + } + await loadBindCardTasks(); + } catch (e) { + // 兼容旧后端:如果 mark-user-action 尚未部署,自动降级到 sync-subscription。 + const detail = String(e?.data?.detail || "").toLowerCase(); + const isRouteNotFound = e?.response?.status === 404 && detail === "not found"; + if (isRouteNotFound) { + try { + const fallback = await api.post(`/payment/bind-card/tasks/${taskId}/sync-subscription`, {}); + const sub = String(fallback?.subscription_type || "free").toUpperCase(); + if (sub === "PLUS" || sub === "TEAM") { + toast.success(`任务 #${taskId} 已通过兼容模式同步成功: ${sub}`); + } else { + toast.warning(`任务 #${taskId} 兼容同步完成,但当前仍是 ${sub}`, 5000); + } + await loadBindCardTasks(); + return; + } catch (fallbackErr) { + toast.error(`验证订阅失败(兼容模式也失败): ${formatErrorMessage(fallbackErr)}`); + return; + } + } + toast.error(`验证订阅失败: ${formatErrorMessage(e)}`); + } +} + +async function syncBindCardTask(taskId) { + try { + const data = await api.post(`/payment/bind-card/tasks/${taskId}/sync-subscription`, {}); + const sub = String(data?.subscription_type || "free").toUpperCase(); + const source = String(data?.detail?.source || "unknown"); + const confidence = String(data?.detail?.confidence || "unknown"); + const note = String(data?.detail?.note || ""); + const suffix = note ? `, note=${note}` : ""; + const msg = `同步完成: ${sub} (source=${source}, confidence=${confidence}${suffix})`; + if (sub === "PLUS" || sub === "TEAM") { + toast.success(msg); + } else { + toast.warning(msg, 7000); + } + await loadBindCardTasks(); + } catch (e) { + toast.error(`同步订阅失败: ${formatErrorMessage(e)}`); + } +} + +async function deleteBindCardTask(taskId) { + const ok = await confirm(`确认删除绑卡任务 #${taskId} 吗?`, "删除任务"); + if (!ok) return; + + try { + await api.delete(`/payment/bind-card/tasks/${taskId}`); + toast.success(`任务 #${taskId} 已删除`); + await loadBindCardTasks(); + } catch (e) { + toast.error(`删除任务失败: ${formatErrorMessage(e)}`); + } +} + +window.selectPlan = selectPlan; +window.generateLink = generateLink; +window.createBindCardTask = createBindCardTask; +window.copyLink = copyLink; +window.openCheckout = openCheckout; +window.openIncognito = openIncognito; +window.onBindModeChange = onBindModeChange; +window.switchPaymentTab = switchPaymentTab; +window.loadBindCardTasks = loadBindCardTasks; +window.openBindCardTask = openBindCardTask; +window.markBindCardTaskUserAction = markBindCardTaskUserAction; +window.syncBindCardTask = syncBindCardTask; +window.deleteBindCardTask = deleteBindCardTask; +window.fillFromBatchProfile = fillFromBatchProfile; +window.randomBillingByCountry = randomBillingByCountry; diff --git a/static/js/settings.js b/static/js/settings.js index a036b6b..46aba29 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -9,6 +9,8 @@ const elements = { tabContents: document.querySelectorAll('.tab-content'), registrationForm: document.getElementById('registration-settings-form'), backupBtn: document.getElementById('backup-btn'), + importDbBtn: document.getElementById('import-db-btn'), + dbImportFile: document.getElementById('db-import-file'), cleanupBtn: document.getElementById('cleanup-btn'), addEmailServiceBtn: document.getElementById('add-email-service-btn'), addServiceModal: document.getElementById('add-service-modal'), @@ -57,10 +59,6 @@ const elements = { sub2ApiServiceForm: document.getElementById('sub2api-service-form'), sub2ApiServiceModalTitle: document.getElementById('sub2api-service-modal-title'), testSub2ApiServiceBtn: document.getElementById('test-sub2api-service-btn'), - loadSub2ApiRemoteProxiesBtn: document.getElementById('load-sub2api-remote-proxies-btn'), - sub2ApiRemoteProxyTrigger: document.getElementById('sub2api-remote-proxy-trigger'), - sub2ApiRemoteProxyDropdown: document.getElementById('sub2api-remote-proxy-dropdown'), - sub2ApiRemoteProxyOptions: document.getElementById('sub2api-remote-proxy-options'), // Team Manager 服务管理 addTmServiceBtn: document.getElementById('add-tm-service-btn'), tmServicesTable: document.getElementById('tm-services-table'), @@ -94,11 +92,8 @@ document.addEventListener('DOMContentLoaded', () => { initEventListeners(); }); -document.addEventListener('click', (e) => { +document.addEventListener('click', () => { document.querySelectorAll('.dropdown-menu.active').forEach(m => m.classList.remove('active')); - if (!e.target.closest('.sub2api-remote-proxy-picker')) { - closeSub2ApiRemoteProxyDropdown(); - } }); // 初始化标签页 @@ -127,6 +122,14 @@ function initEventListeners() { if (elements.backupBtn) { elements.backupBtn.addEventListener('click', handleBackup); } + // 导入数据库 + if (elements.importDbBtn && elements.dbImportFile) { + elements.importDbBtn.addEventListener('click', () => { + elements.dbImportFile.value = ''; + elements.dbImportFile.click(); + }); + elements.dbImportFile.addEventListener('change', handleImportDatabase); + } // 清理数据 if (elements.cleanupBtn) { @@ -319,26 +322,6 @@ function initEventListeners() { if (elements.testSub2ApiServiceBtn) { elements.testSub2ApiServiceBtn.addEventListener('click', handleTestSub2ApiService); } - if (elements.loadSub2ApiRemoteProxiesBtn) { - elements.loadSub2ApiRemoteProxiesBtn.addEventListener('click', () => loadSub2ApiRemoteProxiesForForm()); - } - if (elements.sub2ApiRemoteProxyTrigger) { - elements.sub2ApiRemoteProxyTrigger.addEventListener('click', (e) => { - e.stopPropagation(); - const opened = toggleSub2ApiRemoteProxyDropdown(); - if (opened) { - loadSub2ApiRemoteProxiesForForm({ silent: true }); - } - }); - } - if (elements.sub2ApiRemoteProxyOptions) { - elements.sub2ApiRemoteProxyOptions.addEventListener('click', (e) => { - const optionBtn = e.target.closest('.sub2api-remote-proxy-option'); - if (!optionBtn) return; - setSub2ApiDefaultRemoteProxyValue(optionBtn.dataset.proxyValue || ''); - closeSub2ApiRemoteProxyDropdown(); - }); - } } // 加载设置 @@ -356,6 +339,9 @@ async function loadSettings() { document.getElementById('max-retries').value = data.registration?.max_retries || 3; document.getElementById('timeout').value = data.registration?.timeout || 120; document.getElementById('password-length').value = data.registration?.default_password_length || 12; + const entryFlowRaw = String(data.registration?.entry_flow || 'native').toLowerCase(); + const entryFlow = entryFlowRaw === 'abcard' ? 'abcard' : 'native'; + document.getElementById('registration-entry-flow').value = entryFlow; document.getElementById('sleep-min').value = data.registration?.sleep_min || 5; document.getElementById('sleep-max').value = data.registration?.sleep_max || 30; @@ -498,6 +484,7 @@ async function handleSaveRegistration(e) { max_retries: parseInt(document.getElementById('max-retries').value), timeout: parseInt(document.getElementById('timeout').value), default_password_length: parseInt(document.getElementById('password-length').value), + entry_flow: document.getElementById('registration-entry-flow').value || 'native', sleep_min: parseInt(document.getElementById('sleep-min').value), sleep_max: parseInt(document.getElementById('sleep-max').value), }; @@ -556,6 +543,48 @@ async function handleBackup() { } } +// 导入数据库 +async function handleImportDatabase() { + const file = elements.dbImportFile?.files?.[0]; + if (!file) return; + + const confirmed = await confirm( + `确定导入数据库文件「${file.name}」吗?系统会先自动备份当前数据库,再执行覆盖导入。` + ); + if (!confirmed) { + elements.dbImportFile.value = ''; + return; + } + + const originText = elements.importDbBtn.textContent; + elements.importDbBtn.disabled = true; + elements.importDbBtn.innerHTML = ' 导入中...'; + + try { + const formData = new FormData(); + formData.append('file', file); + + const resp = await fetch('/api/settings/database/import', { + method: 'POST', + body: formData + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) { + throw new Error(data.detail || `HTTP ${resp.status}`); + } + + const backupText = data.backup_path ? `,备份: ${data.backup_path}` : ''; + toast.success(`导入成功,已覆盖数据库${backupText}`); + await loadDatabaseInfo(); + } catch (error) { + toast.error('导入失败: ' + error.message); + } finally { + elements.dbImportFile.value = ''; + elements.importDbBtn.disabled = false; + elements.importDbBtn.textContent = originText || '📥 导入数据'; + } +} + // 清理数据 async function handleCleanup() { const confirmed = await confirm('确定要清理过期数据吗?此操作不可恢复。'); @@ -1405,243 +1434,6 @@ async function handleTestCpaService() { // ============================================================================ let _sub2apiEditingId = null; -let _sub2apiRemoteProxyOptions = []; - -function normalizeSub2ApiRemoteProxyValue(value) { - return value == null || value === '' ? '' : String(value); -} - -function getSub2ApiDefaultRemoteProxyInput() { - return document.getElementById('sub2api-default-remote-proxy-id'); -} - -function getSub2ApiRemoteProxyTriggerContent() { - return document.getElementById('sub2api-remote-proxy-trigger-content'); -} - -function getSub2ApiRemoteProxyOptionByValue(value) { - const normalizedValue = normalizeSub2ApiRemoteProxyValue(value); - return _sub2apiRemoteProxyOptions.find(option => option.value === normalizedValue) || null; -} - -function getSub2ApiRemoteProxyStatusClass(status) { - const normalizedStatus = String(status || '').trim().toLowerCase(); - if (normalizedStatus === 'active') return 'active'; - if ([ 'inactive', 'disabled', 'offline' ].includes(normalizedStatus)) return 'disabled'; - if ([ 'error', 'failed' ].includes(normalizedStatus)) return 'error'; - if ([ 'warning', 'missing' ].includes(normalizedStatus)) return 'warning'; - return 'pending'; -} - -function buildSub2ApiRemoteProxySummary(option) { - if (!option || option.value === '') { - return '不设置默认代理'; - } - - const protocol = option.protocol - ? `${escapeHtml(option.protocol)}` - : ''; - const status = option.status - ? `${escapeHtml(option.status)}` - : ''; - const meta = option.meta - ? `
${escapeHtml(option.meta)}
` - : ''; - - return ` -
- ${escapeHtml(option.title)} - ${protocol} - ${status} -
- ${meta} - `; -} - -function buildSub2ApiRemoteProxyOptionMarkup(option, selectedValue) { - const isSelected = option.value === selectedValue; - const protocol = option.protocol - ? `${escapeHtml(option.protocol)}` - : ''; - const status = option.status - ? `${escapeHtml(option.status)}` - : ''; - const meta = option.meta - ? `
${escapeHtml(option.meta)}
` - : ''; - - return ` - - `; -} - -function renderSub2ApiRemoteProxyTriggerDisplay() { - const triggerContent = getSub2ApiRemoteProxyTriggerContent(); - const input = getSub2ApiDefaultRemoteProxyInput(); - if (!triggerContent || !input) return; - - const option = getSub2ApiRemoteProxyOptionByValue(input.value); - triggerContent.innerHTML = buildSub2ApiRemoteProxySummary(option); -} - -function renderSub2ApiRemoteProxyOptionList() { - if (!elements.sub2ApiRemoteProxyOptions) return; - const selectedValue = normalizeSub2ApiRemoteProxyValue(getSub2ApiDefaultRemoteProxyInput()?.value); - elements.sub2ApiRemoteProxyOptions.innerHTML = _sub2apiRemoteProxyOptions - .map(option => buildSub2ApiRemoteProxyOptionMarkup(option, selectedValue)) - .join(''); -} - -function setSub2ApiDefaultRemoteProxyValue(value) { - const input = getSub2ApiDefaultRemoteProxyInput(); - if (!input) return; - input.value = normalizeSub2ApiRemoteProxyValue(value); - renderSub2ApiRemoteProxyTriggerDisplay(); - renderSub2ApiRemoteProxyOptionList(); -} - -function closeSub2ApiRemoteProxyDropdown() { - if (elements.sub2ApiRemoteProxyDropdown) { - elements.sub2ApiRemoteProxyDropdown.classList.remove('active'); - } - if (elements.sub2ApiRemoteProxyTrigger) { - elements.sub2ApiRemoteProxyTrigger.setAttribute('aria-expanded', 'false'); - } -} - -function toggleSub2ApiRemoteProxyDropdown() { - if (!elements.sub2ApiRemoteProxyDropdown || !elements.sub2ApiRemoteProxyTrigger) return false; - const willOpen = !elements.sub2ApiRemoteProxyDropdown.classList.contains('active'); - elements.sub2ApiRemoteProxyDropdown.classList.toggle('active', willOpen); - elements.sub2ApiRemoteProxyTrigger.setAttribute('aria-expanded', willOpen ? 'true' : 'false'); - return willOpen; -} - -function resetSub2ApiRemoteProxyOptions(selectedValue = '') { - const normalizedValue = normalizeSub2ApiRemoteProxyValue(selectedValue); - _sub2apiRemoteProxyOptions = [ - { - value: '', - title: '不设置默认代理', - meta: '上传时不默认绑定远端代理', - protocol: '', - status: '', - }, - ]; - - if (normalizedValue) { - _sub2apiRemoteProxyOptions.push({ - value: normalizedValue, - title: `当前默认代理 #${normalizedValue}`, - meta: '点击“加载代理”获取远端详情', - protocol: '', - status: 'warning', - }); - } - - setSub2ApiDefaultRemoteProxyValue(normalizedValue); - closeSub2ApiRemoteProxyDropdown(); -} - -function renderSub2ApiRemoteProxyOptions(proxies, selectedValue = '') { - const normalizedValue = normalizeSub2ApiRemoteProxyValue(selectedValue); - const remoteProxyOptions = (proxies || []).map(proxy => { - const protocol = String(proxy.protocol || '').trim().toUpperCase(); - const host = String(proxy.host || '').trim(); - const port = proxy.port != null ? `:${proxy.port}` : ''; - const meta = host ? `${host}${port}` : '未提供地址'; - const status = String(proxy.status || '').trim() || 'inactive'; - return { - value: String(proxy.id), - title: `#${proxy.id} ${proxy.name || `Proxy ${proxy.id}`}`, - meta, - protocol, - status, - }; - }); - - _sub2apiRemoteProxyOptions = [ - { - value: '', - title: '不设置默认代理', - meta: remoteProxyOptions.length === 0 ? '当前服务下暂无可选远端代理' : '上传时不默认绑定远端代理', - protocol: '', - status: '', - }, - ]; - - if (normalizedValue && !remoteProxyOptions.some(option => option.value === normalizedValue)) { - _sub2apiRemoteProxyOptions.push({ - value: normalizedValue, - title: `当前默认代理 #${normalizedValue}`, - meta: '远端已不存在,请重新选择或清空默认代理', - protocol: '', - status: 'warning', - }); - } - - _sub2apiRemoteProxyOptions.push(...remoteProxyOptions); - setSub2ApiDefaultRemoteProxyValue(normalizedValue); -} - -function setSub2ApiRemoteProxyLoading(loading) { - if (!elements.loadSub2ApiRemoteProxiesBtn) return; - elements.loadSub2ApiRemoteProxiesBtn.disabled = loading; - elements.loadSub2ApiRemoteProxiesBtn.textContent = loading ? '加载中...' : '加载代理'; -} - -async function loadSub2ApiRemoteProxiesForForm({ silent = false } = {}) { - const id = document.getElementById('sub2api-service-id').value.trim(); - const apiUrl = document.getElementById('sub2api-service-url').value.trim(); - const apiKey = document.getElementById('sub2api-service-key').value.trim(); - const input = getSub2ApiDefaultRemoteProxyInput(); - const selectedValue = input ? input.value : ''; - - if (!apiUrl) { - if (!silent) toast.error('请先填写 API URL'); - return; - } - if (!id && !apiKey) { - if (!silent) toast.error('请先填写 API Key'); - return; - } - - const payload = { api_url: apiUrl }; - if (id) payload.service_id = parseInt(id, 10); - if (apiKey) payload.api_key = apiKey; - - setSub2ApiRemoteProxyLoading(true); - try { - const result = await api.post('/sub2api-services/remote-proxies', payload); - const proxies = result.proxies || []; - renderSub2ApiRemoteProxyOptions(proxies, selectedValue); - if (!silent) { - if (proxies.length > 0) { - toast.success(`已加载 ${proxies.length} 个远端代理`); - } else { - toast.info('当前服务下暂无可选远端代理'); - } - } - } catch (e) { - if (!silent) { - toast.error('加载远端代理失败: ' + e.message); - } else { - console.error('加载远端代理失败:', e); - } - } finally { - setSub2ApiRemoteProxyLoading(false); - } -} async function loadSub2ApiServices() { try { @@ -1680,31 +1472,19 @@ function openSub2ApiServiceModal(svc = null) { elements.sub2ApiServiceModalTitle.textContent = svc ? '编辑 Sub2API 服务' : '添加 Sub2API 服务'; elements.sub2ApiServiceForm.reset(); document.getElementById('sub2api-service-id').value = svc ? svc.id : ''; - - resetSub2ApiRemoteProxyOptions(svc ? svc.default_remote_proxy_id : ''); - if (svc) { document.getElementById('sub2api-service-name').value = svc.name || ''; document.getElementById('sub2api-service-url').value = svc.api_url || ''; document.getElementById('sub2api-service-priority').value = svc.priority ?? 0; document.getElementById('sub2api-service-enabled').checked = svc.enabled !== false; document.getElementById('sub2api-service-key').placeholder = svc.has_key ? '已配置,留空保持不变' : '请输入 API Key'; - } else { - document.getElementById('sub2api-service-key').placeholder = '请输入 API Key'; } - elements.sub2ApiServiceEditModal.classList.add('active'); - - if (svc) { - loadSub2ApiRemoteProxiesForForm({ silent: true }); - } } function closeSub2ApiServiceModal() { elements.sub2ApiServiceEditModal.classList.remove('active'); elements.sub2ApiServiceForm.reset(); - resetSub2ApiRemoteProxyOptions(); - setSub2ApiRemoteProxyLoading(false); _sub2apiEditingId = null; } @@ -1718,7 +1498,8 @@ async function editSub2ApiService(id) { } async function deleteSub2ApiService(id, name) { - if (!confirm(`确认删除 Sub2API 服务「${name}」?`)) return; + const confirmed = await confirm(`确认删除 Sub2API 服务「${name}」吗?`, "删除 Sub2API 服务"); + if (!confirmed) return; try { await api.delete(`/sub2api-services/${id}`); toast.success('服务已删除'); @@ -1731,13 +1512,10 @@ async function deleteSub2ApiService(id, name) { async function handleSaveSub2ApiService(e) { e.preventDefault(); const id = document.getElementById('sub2api-service-id').value; - const defaultRemoteProxyValue = getSub2ApiDefaultRemoteProxyInput()?.value || ''; - const parsedDefaultRemoteProxyId = defaultRemoteProxyValue === '' ? null : parseInt(defaultRemoteProxyValue, 10); const data = { name: document.getElementById('sub2api-service-name').value, api_url: document.getElementById('sub2api-service-url').value, api_key: document.getElementById('sub2api-service-key').value || undefined, - default_remote_proxy_id: Number.isNaN(parsedDefaultRemoteProxyId) ? null : parsedDefaultRemoteProxyId, priority: parseInt(document.getElementById('sub2api-service-priority').value) || 0, enabled: document.getElementById('sub2api-service-enabled').checked, }; @@ -1778,7 +1556,7 @@ async function testSub2ApiServiceById(id) { async function handleTestSub2ApiService() { const apiUrl = document.getElementById('sub2api-service-url').value.trim(); const apiKey = document.getElementById('sub2api-service-key').value.trim(); - const id = document.getElementById('sub2api-service-id').value.trim(); + const id = document.getElementById('sub2api-service-id').value; if (!apiUrl) { toast.error('请先填写 API URL'); @@ -1793,11 +1571,12 @@ async function handleTestSub2ApiService() { elements.testSub2ApiServiceBtn.textContent = '测试中...'; try { - const payload = { api_url: apiUrl }; - if (id) payload.service_id = parseInt(id, 10); - if (apiKey) payload.api_key = apiKey; - - const result = await api.post('/sub2api-services/test-connection', payload); + let result; + if (id && !apiKey) { + result = await api.post(`/sub2api-services/${id}/test`); + } else { + result = await api.post('/sub2api-services/test-connection', { api_url: apiUrl, api_key: apiKey }); + } if (result.success) { toast.success(result.message); } else { diff --git a/templates/accounts.html b/templates/accounts.html index 9e7eb5d..0f7e1cf 100644 --- a/templates/accounts.html +++ b/templates/accounts.html @@ -44,6 +44,64 @@ } .cpa-status .badge.uploaded { background: var(--success-color); color: white; } .cpa-status .badge.pending { background: var(--border-color); color: var(--text-secondary); } + .account-status-cell { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + } + .account-status-dot { + width: 12px; + height: 12px; + border-radius: 50%; + display: inline-block; + border: 1px solid rgba(0, 0, 0, 0.12); + } + .account-status-dot.active { + background: var(--success-color); + } + .account-status-dot.expired { + background: var(--warning-color); + } + .account-status-dot.banned, + .account-status-dot.failed { + background: var(--danger-color); + } + .account-status-dot.unknown { + background: var(--border-color); + } + .subscription-status { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-width: 70px; + } + .subscription-status .dot { + width: 12px; + height: 12px; + border-radius: 50%; + display: inline-block; + border: 1px solid rgba(0, 0, 0, 0.12); + } + .subscription-status .dot.subscribed { + background: var(--success-color); + } + .subscription-status .dot.unsubscribed { + background: var(--danger-color); + } + .subscription-status .label { + font-size: 0.72rem; + font-weight: 600; + color: var(--text-secondary); + letter-spacing: 0.2px; + } + .subscription-status.subscribed .label { + color: var(--success-color); + } + .subscription-status.unsubscribed .label { + color: var(--danger-color); + } @@ -56,8 +114,12 @@ @@ -172,7 +234,7 @@ CPA 订阅 最后刷新 - 操作 + 操作 @@ -277,26 +339,6 @@
- - - diff --git a/templates/accounts_overview.html b/templates/accounts_overview.html new file mode 100644 index 0000000..a0947da --- /dev/null +++ b/templates/accounts_overview.html @@ -0,0 +1,740 @@ + + + + + + 账号总览 - OpenAI 注册系统 + + + + +
+ + +
+ + +
+
+
0
+
总账号数
+
+
+
0
+
活跃账号
+
+
+
0
+
有 Access Token
+
+
+
0
+
已上传 CPA
+
+
+ +
+
+

状态分布

+
+
+
+

邮箱服务分布

+
+
+
+

订阅分布

+
+
+
+

来源分布

+
+
+
+ +

Codex 账号管理

+
+ +
+
+
+
+ +
+ +
+ + +
+ + + + +
+ +
+ + + + + +
+
+ +
+
+ 已选择 0 个账号 + | + 下次刷新 -- +
+
+ 自动刷新 + +
+
+
+ +
+ +
+ + + + +
+
+ + + + + diff --git a/templates/auto_team.html b/templates/auto_team.html new file mode 100644 index 0000000..959c984 --- /dev/null +++ b/templates/auto_team.html @@ -0,0 +1,57 @@ + + + + + + 自动进team - OpenAI 注册系统 + + + + +
+ + +
+ +
功能未开发!
+
+
+ + + + diff --git a/templates/card_pool.html b/templates/card_pool.html new file mode 100644 index 0000000..dfed056 --- /dev/null +++ b/templates/card_pool.html @@ -0,0 +1,57 @@ + + + + + + 卡池 - OpenAI 注册系统 + + + + +
+ + +
+ +
功能未开发!
+
+
+ + + + diff --git a/templates/email_services.html b/templates/email_services.html index 4b18766..b28521d 100644 --- a/templates/email_services.html +++ b/templates/email_services.html @@ -17,8 +17,12 @@ @@ -94,7 +98,7 @@ -
+