Initial commit
This commit is contained in:
1 parent
fa8f030843
commit
b8a41612f5
58 files changed
+17428
-1559
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
+17
-2
@@ -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"]
|
||||
@@ -8,11 +8,8 @@
|
||||
[](https://www.python.org/)
|
||||
|
||||
## QQ群
|
||||
- 交流群:291638849
|
||||
- https://qm.qq.com/q/ZTCKxawxeo
|
||||
|
||||
## Telegram交流频道
|
||||
- https://t.me/cysq2026
|
||||
- 交流群: https://qm.qq.com/q/ZTCKxawxeo
|
||||
|
||||
## 致谢
|
||||
|
||||
@@ -129,13 +126,21 @@ codex-console.exe --access-password mypassword
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
你可以在 `docker-compose.yml` 中修改环境变量,比如端口和访问密码。
|
||||
你可以在 `docker-compose.yml` 中修改环境变量,比如端口和访问密码。
|
||||
如果需要看“全自动绑卡”的可视化浏览器,打开:
|
||||
|
||||
- noVNC: `http://127.0.0.1:6080`
|
||||
|
||||
### 使用 docker run
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
-p 1455:1455 \
|
||||
-p 6080:6080 \
|
||||
-e DISPLAY=:99 \
|
||||
-e ENABLE_VNC=1 \
|
||||
-e VNC_PORT=5900 \
|
||||
-e NOVNC_PORT=6080 \
|
||||
-e WEBUI_HOST=0.0.0.0 \
|
||||
-e WEBUI_PORT=1455 \
|
||||
-e WEBUI_ACCESS_PASSWORD=your_secure_password \
|
||||
|
||||
+9
-1
@@ -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
|
||||
restart: unless-stopped
|
||||
@@ -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
|
||||
+2
-2
@@ -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
|
||||
# 自动绑卡(local_auto)依赖
|
||||
playwright>=1.40.0
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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],
|
||||
}
|
||||
+1071
-83
File diff suppressed because it is too large.
Load diff
@@ -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'<a[^>]+href="([^"]+)"[^>]*>\s*随机地址\s*</a>',
|
||||
r"<a[^>]+href='([^']+)'[^>]*>\s*随机地址\s*</a>",
|
||||
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)
|
||||
@@ -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
|
||||
+1988
-180
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
+15
-2
@@ -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)
|
||||
|
||||
@@ -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
|
||||
return True
|
||||
+60
-2
@@ -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}"
|
||||
return f"{scheme}://{auth}{self.host}:{self.port}"
|
||||
@@ -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 已处理,此处兜底)
|
||||
|
||||
@@ -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]}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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},"
|
||||
|
||||
+440
-31
@@ -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}")
|
||||
|
||||
+99
-11
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
+1235
-23
File diff suppressed because it is too large.
Load diff
+10
-1
@@ -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},
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
+3147
-30
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
+108
-1
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
+21
-165
@@ -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) {
|
||||
|
||||
+96
-93
@@ -304,7 +304,7 @@ function renderAccounts(accounts) {
|
||||
: '-'}
|
||||
</td>
|
||||
<td>${getServiceTypeText(account.email_service)}</td>
|
||||
<td>${getStatusIcon(account.status)}</td>
|
||||
<td>${renderAccountStatusDot(account.status)}</td>
|
||||
<td>
|
||||
<div class="cpa-status">
|
||||
${account.cpa_uploaded
|
||||
@@ -313,23 +313,19 @@ function renderAccounts(accounts) {
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="cpa-status">
|
||||
${account.subscription_type
|
||||
? `<span class="badge uploaded" title="${account.subscription_type}">${account.subscription_type}</span>`
|
||||
: `<span class="badge pending">-</span>`}
|
||||
</div>
|
||||
${renderSubscriptionStatus(account.subscription_type)}
|
||||
</td>
|
||||
<td>${format.date(account.last_refresh) || '-'}</td>
|
||||
<td>
|
||||
<div style="display:flex;gap:4px;align-items:center;white-space:nowrap;">
|
||||
<button class="btn btn-secondary btn-sm" onclick="viewAccount(${account.id})">详情</button>
|
||||
<button class="btn btn-secondary btn-sm" onclick="checkInboxCode(${account.id})">收件箱</button>
|
||||
<div class="dropdown" style="position:relative;">
|
||||
<button class="btn btn-secondary btn-sm" onclick="event.stopPropagation();toggleMoreMenu(this)">更多</button>
|
||||
<div class="dropdown-menu" style="min-width:100px;">
|
||||
<a href="#" class="dropdown-item" onclick="event.preventDefault();closeMoreMenu(this);refreshToken(${account.id})">刷新</a>
|
||||
<a href="#" class="dropdown-item" onclick="event.preventDefault();closeMoreMenu(this);uploadAccount(${account.id})">上传</a>
|
||||
<a href="#" class="dropdown-item" onclick="event.preventDefault();closeMoreMenu(this);markSubscription(${account.id})">标记</a>
|
||||
<a href="#" class="dropdown-item" onclick="event.preventDefault();closeMoreMenu(this);checkInboxCode(${account.id})">收件箱</a>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-danger btn-sm" onclick="deleteAccount(${account.id}, '${escapeHtml(account.email)}')">删除</button>
|
||||
@@ -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 `
|
||||
<div class="account-status-cell" title="${escapeHtml(title)}">
|
||||
<span class="account-status-dot ${dotClass}"></span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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 `
|
||||
<div class="subscription-status ${dotClass}" title="${escapeHtml(title)}">
|
||||
<span class="dot ${dotClass}"></span>
|
||||
<span class="label">${escapeHtml(label)}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 切换密码显示
|
||||
function togglePassword(element, password) {
|
||||
if (element.dataset.revealed === 'true') {
|
||||
@@ -621,6 +660,25 @@ async function viewAccount(id) {
|
||||
${tokens.refresh_token ? `<button class="btn btn-ghost btn-sm" onclick="copyToClipboard('${escapeHtml(tokens.refresh_token)}')" style="margin-left: 8px;">📋</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item" style="grid-column: span 2;">
|
||||
<span class="label">Session Token</span>
|
||||
<div class="value" style="font-size: 0.7rem; word-break: break-all; font-family: var(--font-mono); background: var(--surface-hover); padding: 8px; border-radius: 4px;">
|
||||
${escapeHtml(tokens.session_token || account.session_token || '-')}
|
||||
${(tokens.session_token || account.session_token)
|
||||
? `<button class="btn btn-ghost btn-sm" onclick="copyToClipboard('${escapeHtml(tokens.session_token || account.session_token)}')" style="margin-left: 8px;">📋</button>`
|
||||
: ''
|
||||
}
|
||||
<button class="btn btn-ghost btn-sm" onclick="editSessionToken(${id}, '${escapeHtml(tokens.session_token || account.session_token || '')}')" style="margin-left: 8px;" title="修改 Session Token">✏️</button>
|
||||
${tokens.session_token_source ? `<span style="margin-left:8px;color:var(--text-muted);font-size:0.72rem;">来源: ${escapeHtml(tokens.session_token_source)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item" style="grid-column: span 2;">
|
||||
<span class="label">Device ID</span>
|
||||
<div class="value" style="font-size: 0.75rem; word-break: break-all; font-family: var(--font-mono); background: var(--surface-hover); padding: 8px; border-radius: 4px;">
|
||||
${escapeHtml(tokens.device_id || account.device_id || '-')}
|
||||
${(tokens.device_id || account.device_id) ? `<button class="btn btn-ghost btn-sm" onclick="copyToClipboard('${escapeHtml(tokens.device_id || account.device_id)}')" style="margin-left: 8px;">📋</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item" style="grid-column: span 2;">
|
||||
<span class="label">Cookies(支付用)</span>
|
||||
<div class="value">
|
||||
@@ -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 = '<div style="text-align:center;color:var(--text-muted)">加载中...</div>';
|
||||
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 = '<div style="text-align:center;color:var(--text-muted);padding:12px;">该 Sub2API 服务下暂无可选远端代理,可选择不使用代理继续上传</div>';
|
||||
} else {
|
||||
listEl.innerHTML = proxies.map(proxy => `
|
||||
<div class="sub2api-service-item" data-id="${proxy.id}" style="
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
">
|
||||
<div>
|
||||
<div style="font-weight:500;display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
<span>#${proxy.id} ${escapeHtml(proxy.name)}</span>
|
||||
<span class="badge">${escapeHtml(String(proxy.protocol || '').toUpperCase())}</span>
|
||||
<span class="status-badge ${proxy.status === 'active' ? 'active' : ''}">${escapeHtml(proxy.status || 'inactive')}</span>
|
||||
</div>
|
||||
<div style="font-size:0.8rem;color:var(--text-muted);">${escapeHtml(proxy.host)}:${proxy.port}</div>
|
||||
</div>
|
||||
<span class="badge" style="background:var(--primary);color:#fff;font-size:0.7rem;padding:2px 8px;border-radius:10px;">选择</span>
|
||||
</div>
|
||||
`).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('上传成功');
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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 作为键
|
||||
|
||||
@@ -345,7 +345,7 @@ async function loadCustomServices() {
|
||||
<td><input type="checkbox" data-id="${service.id}" ${selectedCustom.has(service.id) ? 'checked' : ''}></td>
|
||||
<td>${escapeHtml(service.name)}</td>
|
||||
<td>${getCustomServiceTypeBadge(service._subType)}</td>
|
||||
<td style="font-size: 0.75rem;">${getCustomServiceAddress(service)}</td>
|
||||
<td style="font-size: 0.75rem; min-width: 400px;">${getCustomServiceAddress(service)}</td>
|
||||
<td title="${service.enabled ? '已启用' : '已禁用'}">${service.enabled ? '✅' : '⭕'}</td>
|
||||
<td>${service.priority}</td>
|
||||
<td>${format.date(service.last_used)}</td>
|
||||
|
||||
@@ -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 = `
|
||||
<div class="log-line">
|
||||
<span class="log-time">-</span>
|
||||
<span class="log-level INFO">INFO</span>
|
||||
<span class="log-logger">system</span>
|
||||
<span class="log-message">暂无日志</span>
|
||||
</div>
|
||||
`;
|
||||
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 `
|
||||
<div class="log-line">
|
||||
<span class="log-time">${escapeHtml(format.date(row.created_at))}</span>
|
||||
<span class="${getLevelClass(level)}">${escapeHtml(level)}</span>
|
||||
<span class="log-logger" title="${escapeHtml(row.logger || "")}">${escapeHtml(row.logger || "-")}</span>
|
||||
<span class="log-message">${escapeHtml(msg || "-")}</span>
|
||||
</div>
|
||||
`;
|
||||
}).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;
|
||||
}
|
||||
});
|
||||
});
|
||||
+1796
-86
File diff suppressed because it is too large.
Load diff
+66
-287
@@ -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 = '<span class="loading-spinner"></span> 导入中...';
|
||||
|
||||
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 '<span class="sub2api-remote-proxy-placeholder">不设置默认代理</span>';
|
||||
}
|
||||
|
||||
const protocol = option.protocol
|
||||
? `<span class="sub2api-remote-proxy-pill">${escapeHtml(option.protocol)}</span>`
|
||||
: '';
|
||||
const status = option.status
|
||||
? `<span class="status-badge ${getSub2ApiRemoteProxyStatusClass(option.status)}">${escapeHtml(option.status)}</span>`
|
||||
: '';
|
||||
const meta = option.meta
|
||||
? `<div class="sub2api-remote-proxy-summary-meta">${escapeHtml(option.meta)}</div>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="sub2api-remote-proxy-summary-title-row">
|
||||
<span class="sub2api-remote-proxy-summary-name">${escapeHtml(option.title)}</span>
|
||||
${protocol}
|
||||
${status}
|
||||
</div>
|
||||
${meta}
|
||||
`;
|
||||
}
|
||||
|
||||
function buildSub2ApiRemoteProxyOptionMarkup(option, selectedValue) {
|
||||
const isSelected = option.value === selectedValue;
|
||||
const protocol = option.protocol
|
||||
? `<span class="sub2api-remote-proxy-pill">${escapeHtml(option.protocol)}</span>`
|
||||
: '';
|
||||
const status = option.status
|
||||
? `<span class="status-badge ${getSub2ApiRemoteProxyStatusClass(option.status)}">${escapeHtml(option.status)}</span>`
|
||||
: '';
|
||||
const meta = option.meta
|
||||
? `<div class="sub2api-remote-proxy-option-meta">${escapeHtml(option.meta)}</div>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<button type="button" class="sub2api-remote-proxy-option${isSelected ? ' selected' : ''}" data-proxy-value="${escapeHtml(option.value)}">
|
||||
<div class="sub2api-remote-proxy-option-main">
|
||||
<div class="sub2api-remote-proxy-option-title-row">
|
||||
<span class="sub2api-remote-proxy-option-name">${escapeHtml(option.title)}</span>
|
||||
${protocol}
|
||||
${status}
|
||||
</div>
|
||||
${meta}
|
||||
</div>
|
||||
<span class="sub2api-remote-proxy-option-check">${isSelected ? '已选' : '选择'}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
+63
-21
@@ -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);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -56,8 +114,12 @@
|
||||
<div class="nav-links">
|
||||
<a href="/" class="nav-link">注册</a>
|
||||
<a href="/accounts" class="nav-link active">账号管理</a>
|
||||
<a href="/accounts-overview" class="nav-link">账号总览</a>
|
||||
<a href="/email-services" class="nav-link">邮箱服务</a>
|
||||
<a href="/payment" class="nav-link">支付</a>
|
||||
<a href="/card-pool" class="nav-link">卡池</a>
|
||||
<a href="/auto-team" class="nav-link">自动进team</a>
|
||||
<a href="/logs" class="nav-link">后台日志</a>
|
||||
<a href="/settings" class="nav-link">设置</a>
|
||||
<a href="/logout" class="nav-link">退出</a>
|
||||
</div>
|
||||
@@ -172,7 +234,7 @@
|
||||
<th style="width: 80px;">CPA</th>
|
||||
<th style="width: 80px;">订阅</th>
|
||||
<th style="width: 140px;">最后刷新</th>
|
||||
<th style="width: 160px;">操作</th>
|
||||
<th style="width: 220px;">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="accounts-table">
|
||||
@@ -277,26 +339,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sub2API 代理选择模态框 -->
|
||||
<div class="modal" id="sub2api-proxy-modal">
|
||||
<div class="modal-content" style="max-width: 520px;">
|
||||
<div class="modal-header">
|
||||
<h3>🌐 选择远端代理</h3>
|
||||
<button class="modal-close" id="close-sub2api-proxy-modal">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p style="color: var(--text-muted); margin-bottom: 12px; font-size: 0.9rem;">选择目标 Sub2API 服务中已存在的远端代理,或选择不使用代理。</p>
|
||||
<div id="sub2api-proxy-list" style="display: flex; flex-direction: column; gap: 8px; max-height: 320px; overflow-y: auto;">
|
||||
<div style="text-align: center; color: var(--text-muted);">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer" style="padding: 12px 20px; border-top: 1px solid var(--border); display: flex; gap: 8px; justify-content: flex-end;">
|
||||
<button class="btn btn-secondary" id="sub2api-no-proxy-btn">不使用代理</button>
|
||||
<button class="btn btn-secondary" id="cancel-sub2api-proxy-modal-btn">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/utils.js?v={{ static_version }}"></script>
|
||||
<script src="/static/js/accounts.js?v={{ static_version }}"></script>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,740 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>账号总览 - OpenAI 注册系统</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css?v={{ static_version }}">
|
||||
<style>
|
||||
.overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.distribution-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.distribution-row {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr 56px;
|
||||
gap: var(--spacing-sm);
|
||||
align-items: center;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.distribution-label {
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.distribution-bar-wrap {
|
||||
height: 10px;
|
||||
background: var(--border-light);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.distribution-bar {
|
||||
height: 100%;
|
||||
background: var(--primary-color);
|
||||
border-radius: var(--radius-full);
|
||||
min-width: 2px;
|
||||
}
|
||||
|
||||
.distribution-value {
|
||||
text-align: right;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.cards-main-title {
|
||||
margin: 6px 0 10px;
|
||||
font-size: 1.8rem;
|
||||
line-height: 1.15;
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cards-block {
|
||||
margin-top: 0;
|
||||
background: linear-gradient(130deg, #edf1ff 0%, #eef6f2 55%, #f8fafc 100%);
|
||||
border: 1px solid #d8e2f2;
|
||||
border-radius: 22px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.cards-panel {
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
border: 1px solid #d8e2f2;
|
||||
border-radius: 18px;
|
||||
padding: 14px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 8px 22px rgba(28, 49, 78, 0.08);
|
||||
}
|
||||
|
||||
.cards-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cards-toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.cards-toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cards-search-wrap {
|
||||
flex: 0 1 340px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cards-search {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
border: 1px solid #cfdbee;
|
||||
border-radius: 12px;
|
||||
padding: 0 14px;
|
||||
background: #f6f9ff;
|
||||
color: #32486d;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.cards-select {
|
||||
height: 42px;
|
||||
min-width: 140px;
|
||||
border: 1px solid #cfdbee;
|
||||
border-radius: 12px;
|
||||
padding: 0 12px;
|
||||
background: #f6f9ff;
|
||||
color: #32486d;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.view-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid #cfdbee;
|
||||
border-radius: 12px;
|
||||
padding: 4px;
|
||||
background: #f6f9ff;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.view-toggle-btn {
|
||||
width: 34px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #6a7a95;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.view-toggle-btn#view-grid-btn {
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.view-toggle-btn#view-grid-btn.active,
|
||||
.view-toggle-btn#view-list-btn.active {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.view-toggle-btn.active {
|
||||
background: linear-gradient(120deg, #6d28d9, #8b5cf6);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 12px rgba(109, 40, 217, 0.35);
|
||||
}
|
||||
|
||||
.cards-icon-btn {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border: 1px solid #cfdbee;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
color: #4f607d;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.cards-icon-btn.primary {
|
||||
border: 0;
|
||||
color: #fff;
|
||||
background: linear-gradient(120deg, #6d28d9, #8b5cf6);
|
||||
box-shadow: 0 6px 14px rgba(109, 40, 217, 0.38);
|
||||
}
|
||||
|
||||
.cards-status-line {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.84rem;
|
||||
color: #607390;
|
||||
}
|
||||
|
||||
.cards-selection-info {
|
||||
color: #4f607d;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cards-sep {
|
||||
color: #a3afc2;
|
||||
margin: 0 6px;
|
||||
}
|
||||
|
||||
.cards-next-refresh {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cards-auto-refresh {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cards-refresh-select {
|
||||
height: 32px;
|
||||
border: 1px solid #cfdbee;
|
||||
border-radius: 8px;
|
||||
padding: 0 8px;
|
||||
background: #f6f9ff;
|
||||
color: #32486d;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.cards-grid.view-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.quota-card {
|
||||
background: #fff;
|
||||
border: 1px solid #d4dff1;
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
box-shadow: 0 8px 18px rgba(16, 40, 80, 0.07);
|
||||
min-height: 290px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.quota-card.is-current {
|
||||
border: 2px solid #4d76f6;
|
||||
box-shadow: 0 10px 24px rgba(59, 96, 212, 0.2);
|
||||
}
|
||||
|
||||
.quota-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card-check {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.card-check input {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
accent-color: #3a6cf4;
|
||||
}
|
||||
|
||||
.quota-card-email {
|
||||
flex: 1;
|
||||
font-size: 1.04rem;
|
||||
font-weight: 700;
|
||||
color: #27344b;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 64%;
|
||||
}
|
||||
|
||||
.quota-badges {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
border-radius: 999px;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.status-pill.current {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.plan-badge {
|
||||
border-radius: 999px;
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.plan-badge.plus {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.plan-badge.team {
|
||||
background: linear-gradient(120deg, #6d28d9, #8b5cf6);
|
||||
}
|
||||
|
||||
.plan-badge.free {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
.quota-item {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.quota-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
color: #4c5f80;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.quota-row span:first-child {
|
||||
color: #5f708d;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.quota-value {
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.quota-bar {
|
||||
height: 9px;
|
||||
border-radius: 999px;
|
||||
background: #e7edf8;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.quota-bar > span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
width: 0;
|
||||
transition: width .2s ease;
|
||||
}
|
||||
|
||||
.tone-green { color: #22c55e; }
|
||||
.tone-green-bar { background: #22c55e; }
|
||||
.tone-orange { color: #f59e0b; }
|
||||
.tone-orange-bar { background: #f59e0b; }
|
||||
.tone-red { color: #ef4444; }
|
||||
.tone-red-bar { background: #ef4444; }
|
||||
.tone-gray { color: #64748b; }
|
||||
.tone-gray-bar { background: #94a3b8; }
|
||||
|
||||
.quota-reset {
|
||||
margin-top: 6px;
|
||||
font-size: 0.82rem;
|
||||
color: #8594ad;
|
||||
}
|
||||
|
||||
.quota-divider {
|
||||
margin-top: auto;
|
||||
border-top: 1px solid #e5ecf8;
|
||||
}
|
||||
|
||||
.quota-card-foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.quota-time {
|
||||
font-size: 0.8rem;
|
||||
color: #8ca0be;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-action-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid #d7e2f2;
|
||||
border-radius: 10px;
|
||||
background: #eef3fb;
|
||||
color: #4f607d;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: transform .15s ease, background-color .2s ease, box-shadow .2s ease, border-color .2s ease, color .2s ease;
|
||||
}
|
||||
|
||||
.card-action-btn:hover {
|
||||
background: #e4edf9;
|
||||
border-color: #c6d6ed;
|
||||
box-shadow: 0 4px 10px rgba(66, 94, 146, 0.16);
|
||||
}
|
||||
|
||||
.card-action-btn:active {
|
||||
transform: scale(0.92);
|
||||
background: #ddf3eb;
|
||||
border-color: #86cfb8;
|
||||
color: #0f8f70;
|
||||
}
|
||||
|
||||
.card-action-btn:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.card-action-btn:focus-visible {
|
||||
outline: none;
|
||||
border-color: #10a37f;
|
||||
box-shadow: 0 0 0 3px rgba(16, 163, 127, 0.2);
|
||||
}
|
||||
|
||||
.card-action-btn.danger {
|
||||
color: #da4d4d;
|
||||
}
|
||||
|
||||
.card-action-btn.is-loading {
|
||||
color: transparent;
|
||||
pointer-events: none;
|
||||
background: #e7f7f2;
|
||||
border-color: #86cfb8;
|
||||
box-shadow: 0 0 0 2px rgba(16, 163, 127, 0.16);
|
||||
}
|
||||
|
||||
.card-action-btn.is-loading::after {
|
||||
content: "⟳";
|
||||
display: inline-block;
|
||||
color: #0f8f70;
|
||||
animation: cardRefreshSpin .75s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes cardRefreshSpin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.cards-empty {
|
||||
text-align: center;
|
||||
color: #667995;
|
||||
padding: 26px 8px;
|
||||
border: 1px dashed #cdd8eb;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.overview-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cards-main-title {
|
||||
font-size: 1.55rem;
|
||||
}
|
||||
|
||||
.cards-toolbar-left,
|
||||
.cards-toolbar-right {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cards-search-wrap {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.cards-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<nav class="navbar">
|
||||
<div class="nav-brand">
|
||||
<h1>OpenAI 注册系统</h1>
|
||||
</div>
|
||||
<div class="nav-links">
|
||||
<a href="/" class="nav-link">注册</a>
|
||||
<a href="/accounts" class="nav-link">账号管理</a>
|
||||
<a href="/accounts-overview" class="nav-link active">账号总览</a>
|
||||
<a href="/email-services" class="nav-link">邮箱服务</a>
|
||||
<a href="/payment" class="nav-link">支付</a>
|
||||
<a href="/card-pool" class="nav-link">卡池</a>
|
||||
<a href="/auto-team" class="nav-link">自动进team</a>
|
||||
<a href="/logs" class="nav-link">后台日志</a>
|
||||
<a href="/settings" class="nav-link">设置</a>
|
||||
<a href="/logout" class="nav-link">退出</a>
|
||||
</div>
|
||||
<button class="theme-toggle" onclick="theme.toggle()" title="切换主题">🌙</button>
|
||||
</nav>
|
||||
|
||||
<main class="main-content">
|
||||
<div class="page-header">
|
||||
<h2>账号总览</h2>
|
||||
<p class="subtitle">账号总体状态、分布和订阅账号卡片视图</p>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="ov-total">0</div>
|
||||
<div class="stat-label">总账号数</div>
|
||||
</div>
|
||||
<div class="stat-card success">
|
||||
<div class="stat-value" id="ov-active">0</div>
|
||||
<div class="stat-label">活跃账号</div>
|
||||
</div>
|
||||
<div class="stat-card info">
|
||||
<div class="stat-value" id="ov-access-token">0</div>
|
||||
<div class="stat-label">有 Access Token</div>
|
||||
</div>
|
||||
<div class="stat-card warning">
|
||||
<div class="stat-value" id="ov-cpa-uploaded">0</div>
|
||||
<div class="stat-label">已上传 CPA</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overview-grid">
|
||||
<div class="card">
|
||||
<div class="card-header"><h3>状态分布</h3></div>
|
||||
<div class="card-body"><div class="distribution-list" id="dist-status"></div></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header"><h3>邮箱服务分布</h3></div>
|
||||
<div class="card-body"><div class="distribution-list" id="dist-service"></div></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header"><h3>订阅分布</h3></div>
|
||||
<div class="card-body"><div class="distribution-list" id="dist-subscription"></div></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header"><h3>来源分布</h3></div>
|
||||
<div class="card-body"><div class="distribution-list" id="dist-source"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="cards-main-title">Codex 账号管理</h2>
|
||||
<section class="cards-block">
|
||||
|
||||
<div class="cards-panel">
|
||||
<div class="cards-toolbar">
|
||||
<div class="cards-toolbar-left">
|
||||
<div class="cards-search-wrap">
|
||||
<input id="card-search-input" class="cards-search" type="text" placeholder="搜索账号...">
|
||||
</div>
|
||||
|
||||
<div class="view-toggle">
|
||||
<button id="view-list-btn" class="view-toggle-btn" title="列表视图">☰</button>
|
||||
<button id="view-grid-btn" class="view-toggle-btn active" title="卡片视图">▦</button>
|
||||
</div>
|
||||
|
||||
<select id="card-plan-filter" class="cards-select" title="计划筛选">
|
||||
<option value="all">全部 (0)</option>
|
||||
<option value="free">Free</option>
|
||||
<option value="plus">Plus</option>
|
||||
<option value="team">Team</option>
|
||||
<option value="current">当前账号</option>
|
||||
</select>
|
||||
|
||||
<select id="card-sort-mode" class="cards-select" title="排序方式">
|
||||
<option value="created_desc">按创建时间</option>
|
||||
<option value="created_asc">按创建时间(旧→新)</option>
|
||||
<option value="hourly_desc">按 5 小时额度</option>
|
||||
<option value="weekly_desc">按周额度</option>
|
||||
<option value="email_asc">按邮箱排序</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="cards-toolbar-right">
|
||||
<button id="card-add-btn" class="cards-icon-btn primary" title="添加账号">+</button>
|
||||
<button id="card-refresh-btn" class="cards-icon-btn" title="一键刷新">⟳</button>
|
||||
<button id="card-import-btn" class="cards-icon-btn" title="一键导入">⇩</button>
|
||||
<button id="card-delete-btn" class="cards-icon-btn" title="一键删除">🗑</button>
|
||||
<button id="card-export-all-btn" class="cards-icon-btn" title="一键导出">⇪</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cards-status-line">
|
||||
<div>
|
||||
<span id="card-selection-info" class="cards-selection-info">已选择 0 个账号</span>
|
||||
<span class="cards-sep">|</span>
|
||||
<span id="card-next-refresh" class="cards-next-refresh">下次刷新 --</span>
|
||||
</div>
|
||||
<div class="cards-auto-refresh">
|
||||
<span>自动刷新</span>
|
||||
<select id="card-refresh-interval" class="cards-refresh-select" title="自动刷新间隔">
|
||||
<option value="5">5 分钟</option>
|
||||
<option value="7" selected>7 分钟</option>
|
||||
<option value="10">10 分钟</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="plan-cards-container" class="cards-grid view-grid"></div>
|
||||
<div id="plan-cards-empty" class="cards-empty" style="display:none;">没有匹配的账号</div>
|
||||
</section>
|
||||
|
||||
<div class="modal" id="overview-add-modal">
|
||||
<div class="modal-content" style="max-width:680px;">
|
||||
<div class="modal-header">
|
||||
<h3>添加账号</h3>
|
||||
<button class="modal-close" id="overview-add-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="overview-add-email">邮箱 *</label>
|
||||
<input id="overview-add-email" type="text" placeholder="name@example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="overview-add-password">密码 *</label>
|
||||
<input id="overview-add-password" type="text" placeholder="必填">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="overview-add-email-service">邮箱服务</label>
|
||||
<input id="overview-add-email-service" type="text" value="manual">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="overview-add-subscription">订阅</label>
|
||||
<select id="overview-add-subscription">
|
||||
<option value="">无</option>
|
||||
<option value="free">Free</option>
|
||||
<option value="plus">Plus</option>
|
||||
<option value="team">Team</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="overview-add-client-id">Client ID</label>
|
||||
<input id="overview-add-client-id" type="text">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="overview-add-account-id">Account ID</label>
|
||||
<input id="overview-add-account-id" type="text">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="overview-add-workspace-id">Workspace ID</label>
|
||||
<input id="overview-add-workspace-id" type="text">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="overview-add-status">状态</label>
|
||||
<select id="overview-add-status">
|
||||
<option value="active">active</option>
|
||||
<option value="expired">expired</option>
|
||||
<option value="failed">failed</option>
|
||||
<option value="banned">banned</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="overview-add-existing-select">从账号管理选择账号(仅显示当前未在卡片中的账号)</label>
|
||||
<select id="overview-add-existing-select">
|
||||
<option value="">请选择账号</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body" style="padding-top:0;">
|
||||
<div class="form-actions" style="border-top:none;padding-top:0;justify-content:space-between;gap:10px;flex-wrap:wrap;">
|
||||
<div style="display:flex;gap:8px;">
|
||||
<button class="btn btn-secondary" id="overview-add-existing-refresh">刷新账号列表</button>
|
||||
<button class="btn btn-primary" id="overview-add-existing-submit">按所选账号添加</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<button class="btn btn-secondary" id="overview-add-cancel">取消</button>
|
||||
<button class="btn btn-primary" id="overview-add-submit">添加</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal" id="overview-import-modal">
|
||||
<div class="modal-content" style="max-width:760px;">
|
||||
<div class="modal-header">
|
||||
<h3>一键导入账号</h3>
|
||||
<button class="modal-close" id="overview-import-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="overview-import-json">粘贴 JSON(支持账号详情字段)</label>
|
||||
<textarea id="overview-import-json" style="width:100%;min-height:220px;font-family:var(--font-mono);" placeholder='[{"email":"a@example.com","password":"123456","subscription_type":"plus"}]'></textarea>
|
||||
</div>
|
||||
<label style="display:flex;align-items:center;gap:8px;margin-top:8px;">
|
||||
<input id="overview-import-overwrite" type="checkbox"> 覆盖同邮箱已有账号
|
||||
</label>
|
||||
</div>
|
||||
<div class="card-body" style="padding-top:0;">
|
||||
<div class="form-actions" style="border-top:none;padding-top:0;">
|
||||
<button class="btn btn-secondary" id="overview-import-cancel">取消</button>
|
||||
<button class="btn btn-primary" id="overview-import-submit">导入</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/utils.js?v={{ static_version }}"></script>
|
||||
<script src="/static/js/accounts_overview.js?v={{ static_version }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,57 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>自动进team - OpenAI 注册系统</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css?v={{ static_version }}">
|
||||
<style>
|
||||
.feature-placeholder {
|
||||
width: 100%;
|
||||
min-height: 180px;
|
||||
border: 2px dashed var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-hover);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<nav class="navbar">
|
||||
<div class="nav-brand">
|
||||
<h1>OpenAI 注册系统</h1>
|
||||
</div>
|
||||
<div class="nav-links">
|
||||
<a href="/" class="nav-link">注册</a>
|
||||
<a href="/accounts" class="nav-link">账号管理</a>
|
||||
<a href="/accounts-overview" class="nav-link">账号总览</a>
|
||||
<a href="/email-services" class="nav-link">邮箱服务</a>
|
||||
<a href="/payment" class="nav-link">支付</a>
|
||||
<a href="/card-pool" class="nav-link">卡池</a>
|
||||
<a href="/auto-team" class="nav-link active">自动进team</a>
|
||||
<a href="/logs" class="nav-link">后台日志</a>
|
||||
<a href="/settings" class="nav-link">设置</a>
|
||||
<a href="/logout" class="nav-link">退出</a>
|
||||
</div>
|
||||
<button class="theme-toggle" onclick="theme.toggle()" title="切换主题">🌙</button>
|
||||
</nav>
|
||||
|
||||
<main class="main-content">
|
||||
<div class="page-header">
|
||||
<h2>自动进team</h2>
|
||||
<p class="subtitle">页面占位</p>
|
||||
</div>
|
||||
<div class="feature-placeholder">功能未开发!</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/utils.js?v={{ static_version }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,57 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>卡池 - OpenAI 注册系统</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css?v={{ static_version }}">
|
||||
<style>
|
||||
.feature-placeholder {
|
||||
width: 100%;
|
||||
min-height: 180px;
|
||||
border: 2px dashed var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-hover);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<nav class="navbar">
|
||||
<div class="nav-brand">
|
||||
<h1>OpenAI 注册系统</h1>
|
||||
</div>
|
||||
<div class="nav-links">
|
||||
<a href="/" class="nav-link">注册</a>
|
||||
<a href="/accounts" class="nav-link">账号管理</a>
|
||||
<a href="/accounts-overview" class="nav-link">账号总览</a>
|
||||
<a href="/email-services" class="nav-link">邮箱服务</a>
|
||||
<a href="/payment" class="nav-link">支付</a>
|
||||
<a href="/card-pool" class="nav-link active">卡池</a>
|
||||
<a href="/auto-team" class="nav-link">自动进team</a>
|
||||
<a href="/logs" class="nav-link">后台日志</a>
|
||||
<a href="/settings" class="nav-link">设置</a>
|
||||
<a href="/logout" class="nav-link">退出</a>
|
||||
</div>
|
||||
<button class="theme-toggle" onclick="theme.toggle()" title="切换主题">🌙</button>
|
||||
</nav>
|
||||
|
||||
<main class="main-content">
|
||||
<div class="page-header">
|
||||
<h2>卡池</h2>
|
||||
<p class="subtitle">页面占位</p>
|
||||
</div>
|
||||
<div class="feature-placeholder">功能未开发!</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/utils.js?v={{ static_version }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -17,8 +17,12 @@
|
||||
<div class="nav-links">
|
||||
<a href="/" class="nav-link">注册</a>
|
||||
<a href="/accounts" class="nav-link">账号管理</a>
|
||||
<a href="/accounts-overview" class="nav-link">账号总览</a>
|
||||
<a href="/email-services" class="nav-link active">邮箱服务</a>
|
||||
<a href="/payment" class="nav-link">支付</a>
|
||||
<a href="/card-pool" class="nav-link">卡池</a>
|
||||
<a href="/auto-team" class="nav-link">自动进team</a>
|
||||
<a href="/logs" class="nav-link">后台日志</a>
|
||||
<a href="/settings" class="nav-link">设置</a>
|
||||
<a href="/logout" class="nav-link">退出</a>
|
||||
</div>
|
||||
@@ -94,7 +98,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 自定义邮箱管理(含 MoeMail / TempMail / DuckMail) -->
|
||||
<div class="card">
|
||||
<div class="card custom-email-services-card">
|
||||
<div class="card-header">
|
||||
<h3>🔗 自定义邮箱服务</h3>
|
||||
<button class="btn btn-primary btn-sm" id="add-custom-btn">➕ 添加服务</button>
|
||||
@@ -107,7 +111,7 @@
|
||||
<th style="width: 40px;"><input type="checkbox" id="select-all-custom"></th>
|
||||
<th>名称</th>
|
||||
<th style="width: 90px;">类型</th>
|
||||
<th style="width: 200px;">地址</th>
|
||||
<th style="width: 400px;">地址</th>
|
||||
<th style="width: 100px;">状态</th>
|
||||
<th style="width: 80px;">优先级</th>
|
||||
<th style="width: 160px;">最后使用</th>
|
||||
|
||||
+129
-1
@@ -10,20 +10,25 @@
|
||||
/* 两栏布局 */
|
||||
.two-column-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 30% 70%;
|
||||
grid-template-columns: minmax(300px, 3fr) minmax(0, 7fr);
|
||||
gap: var(--spacing-lg);
|
||||
align-items: start;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.left-panel {
|
||||
position: sticky;
|
||||
top: calc(60px + var(--spacing-lg));
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.right-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 任务状态行 */
|
||||
@@ -83,6 +88,94 @@
|
||||
filter: blur(0);
|
||||
}
|
||||
|
||||
.today-stats-strip {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.today-stats-strip .card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.today-stats-reset {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.today-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.today-stat-item {
|
||||
--stat-color: #60a5fa;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 14px;
|
||||
background: #ffffff;
|
||||
padding: 18px 24px;
|
||||
text-align: center;
|
||||
border: 1px solid #d7dde8;
|
||||
min-height: 128px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.today-stat-item::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4px;
|
||||
background: var(--stat-color, #60a5fa);
|
||||
}
|
||||
|
||||
.today-stat-label {
|
||||
order: 2;
|
||||
color: #64748b;
|
||||
margin-top: 6px;
|
||||
line-height: 1.35;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.today-stat-value {
|
||||
order: 1;
|
||||
font-size: clamp(1.8rem, 2.4vw, 2.2rem);
|
||||
line-height: 1.1;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--stat-color, #60a5fa);
|
||||
}
|
||||
|
||||
.today-stat-total { --stat-color: #60a5fa; }
|
||||
.today-stat-success { --stat-color: #22c55e; }
|
||||
.today-stat-failed { --stat-color: #ef4444; }
|
||||
.today-stat-rate { --stat-color: #f59e0b; }
|
||||
|
||||
.today-stat-rate .today-stat-value {
|
||||
font-size: clamp(1.7rem, 2.3vw, 2.05rem);
|
||||
}
|
||||
.today-stat-rate .today-stat-value.rate-high { color: #22c55e; }
|
||||
.today-stat-rate .today-stat-value.rate-low { color: #ef4444; }
|
||||
.today-stat-rate .today-stat-value.rate-mid { color: #f59e0b; }
|
||||
.today-stat-rate.rate-high { --stat-color: #22c55e; }
|
||||
.today-stat-rate.rate-low { --stat-color: #ef4444; }
|
||||
.today-stat-rate.rate-mid { --stat-color: #f59e0b; }
|
||||
|
||||
/* 自定义多选下拉 */
|
||||
.multi-select-dropdown {
|
||||
position: relative;
|
||||
@@ -139,6 +232,10 @@
|
||||
.left-panel {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.today-stats-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -152,8 +249,12 @@
|
||||
<div class="nav-links">
|
||||
<a href="/" class="nav-link active">注册</a>
|
||||
<a href="/accounts" class="nav-link">账号管理</a>
|
||||
<a href="/accounts-overview" class="nav-link">账号总览</a>
|
||||
<a href="/email-services" class="nav-link">邮箱服务</a>
|
||||
<a href="/payment" class="nav-link">支付</a>
|
||||
<a href="/card-pool" class="nav-link">卡池</a>
|
||||
<a href="/auto-team" class="nav-link">自动进team</a>
|
||||
<a href="/logs" class="nav-link">后台日志</a>
|
||||
<a href="/settings" class="nav-link">设置</a>
|
||||
<a href="/logout" class="nav-link">退出</a>
|
||||
</div>
|
||||
@@ -164,6 +265,33 @@
|
||||
|
||||
<!-- 主内容区 - 两栏布局 -->
|
||||
<main class="main-content">
|
||||
<div class="card today-stats-strip">
|
||||
<div class="card-header">
|
||||
<h3>📊 今日统计</h3>
|
||||
<span class="today-stats-reset" id="today-stats-reset">重置剩余 --:--</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="today-stats-grid">
|
||||
<div class="today-stat-item today-stat-total">
|
||||
<div class="today-stat-label">注册</div>
|
||||
<div class="today-stat-value" id="today-stats-total">0</div>
|
||||
</div>
|
||||
<div class="today-stat-item today-stat-success">
|
||||
<div class="today-stat-label">成功</div>
|
||||
<div class="today-stat-value" id="today-stats-success">0</div>
|
||||
</div>
|
||||
<div class="today-stat-item today-stat-failed">
|
||||
<div class="today-stat-label">失败</div>
|
||||
<div class="today-stat-value" id="today-stats-failed">0</div>
|
||||
</div>
|
||||
<div class="today-stat-item today-stat-rate">
|
||||
<div class="today-stat-label">成功率</div>
|
||||
<div class="today-stat-value" id="today-stats-rate">0%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="two-column-layout">
|
||||
<!-- 左侧面板 - 注册设置 -->
|
||||
<div class="left-panel">
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>后台日志 - OpenAI 注册系统</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css?v={{ static_version }}">
|
||||
<style>
|
||||
.logs-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
.filters-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(120px, 1fr));
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
.stat-box {
|
||||
background: var(--surface-hover);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
}
|
||||
.stat-box .label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.stat-box .value {
|
||||
margin-top: 4px;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.log-console {
|
||||
max-height: 560px;
|
||||
overflow-y: auto;
|
||||
border-top: 1px solid var(--border-light);
|
||||
background: var(--bg-secondary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.log-line {
|
||||
display: grid;
|
||||
grid-template-columns: 160px 84px 220px 1fr;
|
||||
gap: var(--spacing-sm);
|
||||
padding: 8px var(--spacing-md);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.log-time { color: var(--text-muted); }
|
||||
.log-level { font-weight: 700; }
|
||||
.log-level.INFO { color: var(--primary-color); }
|
||||
.log-level.WARNING { color: #d97706; }
|
||||
.log-level.ERROR, .log-level.CRITICAL { color: var(--danger-color); }
|
||||
.log-logger {
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.log-message {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.logs-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.logs-toolbar-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cleanup-inline {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cleanup-inline input {
|
||||
width: 120px;
|
||||
}
|
||||
.hint-inline {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.logs-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding-top: var(--spacing-sm);
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.stats-grid { grid-template-columns: repeat(3, minmax(120px, 1fr)); }
|
||||
.log-line { grid-template-columns: 150px 72px 1fr; }
|
||||
.log-message { grid-column: 1 / -1; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid { grid-template-columns: repeat(2, minmax(120px, 1fr)); }
|
||||
.log-line { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<nav class="navbar">
|
||||
<div class="nav-brand">
|
||||
<h1>OpenAI 注册系统</h1>
|
||||
</div>
|
||||
<div class="nav-links">
|
||||
<a href="/" class="nav-link">注册</a>
|
||||
<a href="/accounts" class="nav-link">账号管理</a>
|
||||
<a href="/accounts-overview" class="nav-link">账号总览</a>
|
||||
<a href="/email-services" class="nav-link">邮箱服务</a>
|
||||
<a href="/payment" class="nav-link">支付</a>
|
||||
<a href="/card-pool" class="nav-link">卡池</a>
|
||||
<a href="/auto-team" class="nav-link">自动进team</a>
|
||||
<a href="/logs" class="nav-link active">后台日志</a>
|
||||
<a href="/settings" class="nav-link">设置</a>
|
||||
<a href="/logout" class="nav-link">退出</a>
|
||||
</div>
|
||||
<button class="theme-toggle" onclick="theme.toggle()" title="切换主题">🌙</button>
|
||||
</nav>
|
||||
|
||||
<main class="main-content logs-layout">
|
||||
<div class="page-header">
|
||||
<h2>后台日志监控台</h2>
|
||||
<p class="subtitle">统一查看后端运行日志,支持筛选、自动刷新、定时清理</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>筛选与控制</h3>
|
||||
<div class="logs-toolbar-actions">
|
||||
<button id="refresh-logs-btn" class="btn btn-success">刷新</button>
|
||||
<button id="clear-filters-btn" class="btn btn-ghost">重置筛选</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="filters-grid">
|
||||
<div class="form-group">
|
||||
<label for="filter-level">级别</label>
|
||||
<select id="filter-level">
|
||||
<option value="">全部</option>
|
||||
<option value="INFO">INFO</option>
|
||||
<option value="WARNING">WARNING</option>
|
||||
<option value="ERROR">ERROR</option>
|
||||
<option value="CRITICAL">CRITICAL</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="filter-logger">Logger</label>
|
||||
<input id="filter-logger" type="text" placeholder="例如 src.web.routes.payment">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="filter-keyword">关键词</label>
|
||||
<input id="filter-keyword" type="text" placeholder="错误关键字 / task id">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="filter-since-minutes">最近时间(分钟)</label>
|
||||
<input id="filter-since-minutes" type="number" min="1" max="10080" placeholder="留空=全部">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="auto-refresh-seconds">自动刷新</label>
|
||||
<select id="auto-refresh-seconds">
|
||||
<option value="0">关闭</option>
|
||||
<option value="5">5 秒</option>
|
||||
<option value="10" selected>10 秒</option>
|
||||
<option value="30">30 秒</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="page-size-select">每页条数</label>
|
||||
<select id="page-size-select">
|
||||
<option value="50">50</option>
|
||||
<option value="100" selected>100</option>
|
||||
<option value="200">200</option>
|
||||
<option value="500">500</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cleanup-inline">
|
||||
<span class="hint-inline">清理策略:</span>
|
||||
<input id="cleanup-retention-days" type="number" min="1" max="3650" value="30" placeholder="保留天数">
|
||||
<input id="cleanup-max-rows" type="number" min="1000" max="5000000" value="50000" placeholder="最大保留条数">
|
||||
<button id="cleanup-logs-btn" class="btn btn-danger">立即清理</button>
|
||||
<button id="clear-logs-btn" class="btn btn-danger">清空日志</button>
|
||||
<span class="hint-inline">系统也会每小时自动清理一次</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>统计</h3>
|
||||
<span id="latest-log-time" class="hint-inline">最新日志: -</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="stats-grid">
|
||||
<div class="stat-box"><div class="label">总量</div><div id="stat-total" class="value">0</div></div>
|
||||
<div class="stat-box"><div class="label">INFO</div><div id="stat-info" class="value">0</div></div>
|
||||
<div class="stat-box"><div class="label">WARNING</div><div id="stat-warning" class="value">0</div></div>
|
||||
<div class="stat-box"><div class="label">ERROR</div><div id="stat-error" class="value">0</div></div>
|
||||
<div class="stat-box"><div class="label">CRITICAL</div><div id="stat-critical" class="value">0</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header logs-toolbar">
|
||||
<h3>日志流</h3>
|
||||
<span id="logs-summary" class="hint-inline">-</span>
|
||||
</div>
|
||||
<div class="card-body" style="padding:0;">
|
||||
<div id="log-console" class="log-console">
|
||||
<div class="log-line">
|
||||
<span class="log-time">-</span>
|
||||
<span class="log-level">INFO</span>
|
||||
<span class="log-logger">system</span>
|
||||
<span class="log-message">加载中...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body logs-footer">
|
||||
<button id="prev-page-btn" class="btn btn-ghost btn-sm">上一页</button>
|
||||
<span id="page-info" class="hint-inline">第 1 页</span>
|
||||
<button id="next-page-btn" class="btn btn-ghost btn-sm">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/utils.js?v={{ static_version }}"></script>
|
||||
<script src="/static/js/logs.js?v={{ static_version }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
+552
-70
@@ -25,8 +25,8 @@
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
.plan-card.selected {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(99,102,241,0.15);
|
||||
border-color: #8b5cf6;
|
||||
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.42);
|
||||
}
|
||||
.plan-card h3 { margin: 0 0 8px; font-size: 1.2rem; }
|
||||
.plan-card p { margin: 0; color: var(--text-secondary); font-size: 0.9rem; }
|
||||
@@ -49,6 +49,245 @@
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
.payment-tab-content {
|
||||
margin-top: 0;
|
||||
}
|
||||
.bind-task-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.bind-task-toolbar-left,
|
||||
.bind-task-toolbar-right {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.bind-task-search {
|
||||
min-width: 240px;
|
||||
}
|
||||
.bind-task-status {
|
||||
min-width: 140px;
|
||||
}
|
||||
.bind-task-search.form-input,
|
||||
.bind-task-status.form-select {
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.bind-task-url {
|
||||
display: inline-block;
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
.bind-task-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(110px, 1fr));
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.bind-task-actions .bind-task-action-btn {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 40px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.bind-task-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
padding: 2px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.bind-task-badge.link_ready { background: #dbeafe; color: #1d4ed8; }
|
||||
.bind-task-badge.opened { background: #fef3c7; color: #92400e; }
|
||||
.bind-task-badge.waiting_user_action { background: #e0e7ff; color: #3730a3; }
|
||||
.bind-task-badge.paid_pending_sync { background: #dcfce7; color: #166534; }
|
||||
.bind-task-badge.verifying { background: #ede9fe; color: #6d28d9; }
|
||||
.bind-task-badge.completed { background: #dcfce7; color: #166534; }
|
||||
.bind-task-badge.failed { background: #fee2e2; color: #991b1b; }
|
||||
.bind-data-panel {
|
||||
margin-top: 10px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
.bind-data-panel .form-group {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.bind-data-title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.bind-data-desc {
|
||||
margin: 0 0 12px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
.bind-parse-textarea {
|
||||
width: 100%;
|
||||
min-height: 220px;
|
||||
resize: vertical;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
.bind-parse-actions {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 6px;
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
.bind-parse-actions .btn {
|
||||
min-width: 122px;
|
||||
}
|
||||
.btn-purple {
|
||||
background: linear-gradient(120deg, #7c3aed, #a855f7);
|
||||
color: #fff;
|
||||
border: 0;
|
||||
}
|
||||
.btn-purple:hover {
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
.bind-parse-result {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
min-height: 18px;
|
||||
}
|
||||
.bind-batch-wrap {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.bind-batch-summary {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.bind-form-grid {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.bind-template-toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.bind-template-name {
|
||||
min-width: 180px;
|
||||
max-width: 260px;
|
||||
}
|
||||
.bind-template-select {
|
||||
min-width: 220px;
|
||||
}
|
||||
.bind-template-tip {
|
||||
margin: 0 0 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.bind-mode-panel {
|
||||
margin: 6px 0 6px;
|
||||
padding: 10px;
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
.bind-mode-panel .form-row {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.bind-mode-panel .form-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.bind-card-inline-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 3fr) minmax(128px, 1.2fr) minmax(116px, 1.1fr);
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
max-width: 760px;
|
||||
}
|
||||
.bind-card-inline-row .form-group input {
|
||||
text-align: left;
|
||||
}
|
||||
.bind-card-inline-row .bind-card-col-number {
|
||||
max-width: 100%;
|
||||
min-width: 280px;
|
||||
}
|
||||
.billing-random-group {
|
||||
max-width: 240px;
|
||||
min-width: 200px;
|
||||
}
|
||||
.billing-random-group .btn {
|
||||
width: 100%;
|
||||
}
|
||||
.billing-random-hint {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
min-height: 18px;
|
||||
}
|
||||
.bind-mask {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
.session-diagnostic-panel {
|
||||
margin-top: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary);
|
||||
display: none;
|
||||
}
|
||||
.session-diagnostic-panel.show {
|
||||
display: block;
|
||||
}
|
||||
.session-diagnostic-title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.session-diagnostic-text {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.bind-task-url {
|
||||
max-width: 140px;
|
||||
}
|
||||
.bind-card-inline-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.bind-card-inline-row .bind-card-col-number {
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.billing-random-group {
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.bind-task-actions .bind-task-action-btn {
|
||||
min-width: 0;
|
||||
min-height: 36px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -61,8 +300,12 @@
|
||||
<div class="nav-links">
|
||||
<a href="/" class="nav-link">注册</a>
|
||||
<a href="/accounts" class="nav-link">账号管理</a>
|
||||
<a href="/accounts-overview" class="nav-link">账号总览</a>
|
||||
<a href="/email-services" class="nav-link">邮箱服务</a>
|
||||
<a href="/payment" class="nav-link active">支付</a>
|
||||
<a href="/card-pool" class="nav-link">卡池</a>
|
||||
<a href="/auto-team" class="nav-link">自动进team</a>
|
||||
<a href="/logs" class="nav-link">后台日志</a>
|
||||
<a href="/settings" class="nav-link">设置</a>
|
||||
<a href="/logout" class="nav-link">退出</a>
|
||||
</div>
|
||||
@@ -76,90 +319,329 @@
|
||||
<p class="subtitle">为账号生成 Plus 或 Team 订阅支付链接</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>选择套餐</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- 套餐选择 -->
|
||||
<div class="plan-cards">
|
||||
<div class="plan-card selected" id="plan-plus" onclick="selectPlan('plus')">
|
||||
<h3>Plus</h3>
|
||||
<p>个人订阅,$20/月</p>
|
||||
</div>
|
||||
<div class="plan-card" id="plan-team" onclick="selectPlan('team')">
|
||||
<h3>Team</h3>
|
||||
<p>团队订阅,按座位计费</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tabs">
|
||||
<button class="tab-btn active" id="tab-btn-link" onclick="switchPaymentTab('link')">支付链接</button>
|
||||
<button class="tab-btn" id="tab-btn-bind-task" onclick="switchPaymentTab('bind-task')">绑卡任务</button>
|
||||
</div>
|
||||
|
||||
<!-- 账号选择 -->
|
||||
<div class="form-group">
|
||||
<label for="account-select">选择账号</label>
|
||||
<select id="account-select" style="width:100%">
|
||||
<option value="">-- 加载中... --</option>
|
||||
</select>
|
||||
<div id="tab-content-link" class="tab-content active payment-tab-content">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>选择套餐</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- 套餐选择 -->
|
||||
<div class="plan-cards">
|
||||
<div class="plan-card selected" id="plan-plus" onclick="selectPlan('plus')">
|
||||
<h3>Plus</h3>
|
||||
<p>个人订阅,$20/月</p>
|
||||
</div>
|
||||
<div class="plan-card" id="plan-team" onclick="selectPlan('team')">
|
||||
<h3>Team</h3>
|
||||
<p>团队订阅,按座位计费</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 国家选择 -->
|
||||
<div class="form-row">
|
||||
<!-- 账号选择 -->
|
||||
<div class="form-group">
|
||||
<label for="country-select">计费国家</label>
|
||||
<select id="country-select" onchange="onCountryChange()">
|
||||
<option value="SG">新加坡 (SGD)</option>
|
||||
<option value="US">美国 (USD)</option>
|
||||
<option value="TR">土耳其 (TRY)</option>
|
||||
<option value="JP">日本 (JPY)</option>
|
||||
<option value="HK">香港 (HKD)</option>
|
||||
<option value="GB">英国 (GBP)</option>
|
||||
<option value="AU">澳大利亚 (AUD)</option>
|
||||
<option value="CA">加拿大 (CAD)</option>
|
||||
<option value="IN">印度 (INR)</option>
|
||||
<option value="BR">巴西 (BRL)</option>
|
||||
<option value="MX">墨西哥 (MXN)</option>
|
||||
<label for="account-select">选择账号(来自账号管理)</label>
|
||||
<select id="account-select" style="width:100%">
|
||||
<option value="">-- 加载中... --</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>对应货币</label>
|
||||
<input type="text" id="currency-display" value="SGD" readonly style="background:var(--surface-hover);cursor:default">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Team 额外参数 -->
|
||||
<div class="team-options" id="team-options">
|
||||
<!-- 国家选择 -->
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="workspace-name">工作区名称</label>
|
||||
<input type="text" id="workspace-name" value="MyTeam" placeholder="MyTeam">
|
||||
<label for="country-select">计费国家</label>
|
||||
<select id="country-select" onchange="onCountryChange()">
|
||||
<option value="US" selected>美国 (USD)</option>
|
||||
<option value="GB">英国 (GBP)</option>
|
||||
<option value="CA">加拿大 (CAD)</option>
|
||||
<option value="AU">澳大利亚 (AUD)</option>
|
||||
<option value="SG">新加坡 (SGD)</option>
|
||||
<option value="HK">中国香港 (HKD)</option>
|
||||
<option value="JP">日本 (JPY)</option>
|
||||
<option value="TR">土耳其 (TRY)</option>
|
||||
<option value="IN">印度 (INR)</option>
|
||||
<option value="BR">巴西 (BRL)</option>
|
||||
<option value="MX">墨西哥 (MXN)</option>
|
||||
<option value="DE">德国 (EUR)</option>
|
||||
<option value="FR">法国 (EUR)</option>
|
||||
<option value="IT">意大利 (EUR)</option>
|
||||
<option value="ES">西班牙 (EUR)</option>
|
||||
<option value="EU">欧盟区 (EUR)</option>
|
||||
</select>
|
||||
<small class="hint">选择国家后会自动联动默认货币,可手动调整</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="seat-quantity">座位数量</label>
|
||||
<input type="number" id="seat-quantity" value="5" min="1" max="100">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="price-interval">计费周期</label>
|
||||
<select id="price-interval">
|
||||
<option value="month">月付</option>
|
||||
<option value="year">年付</option>
|
||||
<label>对应货币</label>
|
||||
<select id="currency-display">
|
||||
<option value="USD">USD</option>
|
||||
<option value="EUR">EUR</option>
|
||||
<option value="GBP">GBP</option>
|
||||
<option value="CAD">CAD</option>
|
||||
<option value="AUD">AUD</option>
|
||||
<option value="SGD">SGD</option>
|
||||
<option value="HKD">HKD</option>
|
||||
<option value="JPY">JPY</option>
|
||||
<option value="TRY">TRY</option>
|
||||
<option value="INR">INR</option>
|
||||
<option value="BRL">BRL</option>
|
||||
<option value="MXN">MXN</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作区 -->
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" onclick="generateLink()">生成支付链接</button>
|
||||
</div>
|
||||
|
||||
<!-- 链接结果 -->
|
||||
<div class="link-box" id="link-box">
|
||||
<label>支付链接</label>
|
||||
<textarea class="link-text" id="link-text" readonly></textarea>
|
||||
<div class="form-actions" style="margin-top:10px">
|
||||
<button class="btn btn-secondary" onclick="copyLink()">复制链接</button>
|
||||
<button class="btn btn-primary" onclick="openIncognito()">无痕打开浏览器</button>
|
||||
<!-- Team 额外参数 -->
|
||||
<div class="team-options" id="team-options">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="workspace-name">工作区名称</label>
|
||||
<input type="text" id="workspace-name" value="MyTeam" placeholder="MyTeam">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="seat-quantity">座位数量</label>
|
||||
<input type="number" id="seat-quantity" value="5" min="1" max="100">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="price-interval">计费周期</label>
|
||||
<select id="price-interval">
|
||||
<option value="month">月付</option>
|
||||
<option value="year">年付</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint" id="open-status"></p>
|
||||
|
||||
<div class="bind-data-panel">
|
||||
<h4 class="bind-data-title">绑卡资料</h4>
|
||||
<p class="bind-data-desc">用于后续自动填卡流程准备。当前阶段不会把完整卡号/CVC写入后端数据库。</p>
|
||||
|
||||
<div class="bind-mode-panel">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="bind-mode-select">绑卡模式</label>
|
||||
<select id="bind-mode-select" onchange="onBindModeChange()">
|
||||
<option value="semi_auto" selected>半自动绑卡(本地打开页面手动提交)</option>
|
||||
<option value="local_auto">全自动绑卡(本地执行,ABCard 参考)</option>
|
||||
<option value="third_party">第三方自动绑卡(调用外部 API)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div id="third-party-config" style="display:none;">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="third-party-api-url">第三方 API 地址</label>
|
||||
<input id="third-party-api-url" type="text" placeholder="https://twilight-river-f148.482091502.workers.dev/">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="third-party-api-key">第三方 API Key(可选)</label>
|
||||
<input id="third-party-api-key" type="password" autocomplete="off" placeholder="X-API-Key / Bearer Token">
|
||||
</div>
|
||||
</div>
|
||||
<small class="hint">
|
||||
地址支持只填 Workers 根域名,后端会自动补全常见路径并重试;
|
||||
若留空则自动使用默认地址。API Key 为空时也会先尝试无鉴权调用;
|
||||
同时仍支持环境变量
|
||||
<code>BIND_CARD_API_URL</code>/<code>BIND_CARD_API_KEY</code>。
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="billing-paste-text">粘贴卡片/账单文本</label>
|
||||
<textarea id="billing-paste-text" class="bind-parse-textarea" placeholder="示例1(键值对):
|
||||
卡号: 4242424242424242
|
||||
有效期: 12/30
|
||||
CVV: 123
|
||||
姓名: John Smith
|
||||
地址: 123 Main Street
|
||||
城市: San Francisco
|
||||
州: CA
|
||||
邮编: 94102
|
||||
国家: US
|
||||
|
||||
示例2(纯文本):
|
||||
4242 4242 4242 4242
|
||||
12/30
|
||||
CVV 123
|
||||
38 Pearl Avenue, Louisville, MS 39339, US"></textarea>
|
||||
</div>
|
||||
<div class="form-actions bind-parse-actions">
|
||||
<button id="parse-billing-btn" type="button" class="btn btn-success">识别并填充</button>
|
||||
<button id="parse-batch-btn" type="button" class="btn btn-purple">批量识别</button>
|
||||
<button id="clear-billing-btn" type="button" class="btn btn-danger">清空文本</button>
|
||||
<button id="semi-login-gpt-btn" type="button" class="btn btn-primary" onclick="openGptOfficialLogin()">登录GPT官方</button>
|
||||
<button id="semi-account-email-btn" type="button" class="btn btn-secondary" onclick="fetchSelectedAccountInbox()">邮箱</button>
|
||||
</div>
|
||||
<div id="billing-parse-result" class="bind-parse-result"></div>
|
||||
|
||||
<div id="billing-batch-wrap" class="bind-batch-wrap" style="display:none;">
|
||||
<div id="billing-batch-summary" class="bind-batch-summary"></div>
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 70px;">序号</th>
|
||||
<th style="width: 160px;">卡号</th>
|
||||
<th style="width: 100px;">有效期</th>
|
||||
<th style="width: 90px;">CVV</th>
|
||||
<th style="width: 100px;">国家</th>
|
||||
<th>地址摘要</th>
|
||||
<th style="width: 100px;">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="billing-batch-table"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bind-form-grid">
|
||||
<div class="form-row bind-card-inline-row">
|
||||
<div class="form-group bind-card-col-number">
|
||||
<label for="card-number-input">卡号</label>
|
||||
<input id="card-number-input" type="text" autocomplete="off" placeholder="4242 4242 4242 4242">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="card-expiry-input">有效期</label>
|
||||
<input id="card-expiry-input" type="text" autocomplete="off" placeholder="02/30" maxlength="7">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="card-cvc-input">CVC</label>
|
||||
<input id="card-cvc-input" type="text" autocomplete="off" placeholder="123" maxlength="4">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="billing-name-input">姓名</label>
|
||||
<input id="billing-name-input" type="text" autocomplete="off" placeholder="John Smith">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="billing-country-input">国家</label>
|
||||
<select id="billing-country-input">
|
||||
<option value="US">US</option>
|
||||
<option value="GB">GB</option>
|
||||
<option value="CA">CA</option>
|
||||
<option value="AU">AU</option>
|
||||
<option value="SG">SG</option>
|
||||
<option value="HK">HK</option>
|
||||
<option value="JP">JP</option>
|
||||
<option value="DE">DE</option>
|
||||
<option value="FR">FR</option>
|
||||
<option value="IT">IT</option>
|
||||
<option value="ES">ES</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="billing-currency-input">货币</label>
|
||||
<input id="billing-currency-input" type="text" autocomplete="off" placeholder="USD">
|
||||
</div>
|
||||
<div class="form-group billing-random-group">
|
||||
<label> </label>
|
||||
<button id="random-billing-btn" type="button" class="btn btn-purple" onclick="randomBillingByCountry()">一键随机</button>
|
||||
<div id="random-billing-hint" class="billing-random-hint"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="billing-line1-input">地址</label>
|
||||
<input id="billing-line1-input" type="text" autocomplete="off" placeholder="123 Main Street">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="billing-city-input">城市</label>
|
||||
<input id="billing-city-input" type="text" autocomplete="off" placeholder="San Francisco">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="billing-state-input">州/省</label>
|
||||
<input id="billing-state-input" type="text" autocomplete="off" placeholder="CA">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="billing-postal-input">邮编</label>
|
||||
<input id="billing-postal-input" type="text" autocomplete="off" placeholder="94102">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作区 -->
|
||||
<div class="form-actions">
|
||||
<button id="generate-link-btn" class="btn btn-primary" type="button" onclick="generateLink()">生成支付链接</button>
|
||||
<button id="create-bind-task-btn" class="btn btn-secondary" type="button" onclick="createBindCardTask()">生成并加入绑卡任务(半自动)</button>
|
||||
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-secondary);margin-left:6px;">
|
||||
<input id="bind-auto-open" type="checkbox" checked style="width:auto;">创建任务后自动打开支付页
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- 链接结果 -->
|
||||
<div class="link-box" id="link-box">
|
||||
<label>支付链接</label>
|
||||
<textarea class="link-text" id="link-text" readonly></textarea>
|
||||
<div class="form-actions" style="margin-top:10px">
|
||||
<button class="btn btn-primary" onclick="openCheckout()">直接打开支付页</button>
|
||||
<button class="btn btn-secondary" onclick="copyLink()">复制链接</button>
|
||||
<button class="btn btn-primary" onclick="openIncognito()">无痕打开浏览器</button>
|
||||
</div>
|
||||
<p class="hint" id="open-status">建议优先点“直接打开支付页”;“无痕打开”仅在本机部署后端时可用。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-content-bind-task" class="tab-content payment-tab-content">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>绑卡任务列表</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="bind-task-toolbar">
|
||||
<div class="bind-task-toolbar-left">
|
||||
<input id="bind-task-search" class="bind-task-search form-input" type="text" placeholder="搜索邮箱账号...">
|
||||
<select id="bind-task-status" class="bind-task-status form-select">
|
||||
<option value="">全部状态</option>
|
||||
<option value="link_ready">待打开</option>
|
||||
<option value="opened">已打开</option>
|
||||
<option value="waiting_user_action">待用户完成</option>
|
||||
<option value="paid_pending_sync">已支付待同步</option>
|
||||
<option value="verifying">验证中</option>
|
||||
<option value="completed">已完成</option>
|
||||
<option value="failed">失败</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="bind-task-toolbar-right">
|
||||
<button id="refresh-bind-task-btn" class="btn btn-success btn-sm" onclick="loadBindCardTasks()">刷新列表</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 70px;">ID</th>
|
||||
<th>邮箱</th>
|
||||
<th style="width: 90px;">套餐</th>
|
||||
<th style="width: 110px;">状态</th>
|
||||
<th style="width: 180px;">Checkout</th>
|
||||
<th style="width: 130px;">来源</th>
|
||||
<th style="width: 160px;">创建时间</th>
|
||||
<th style="width: 280px;">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="bind-card-task-table">
|
||||
<tr>
|
||||
<td colspan="8">
|
||||
<div class="empty-state">加载中...</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="hint" style="margin-top: 10px;">
|
||||
说明:支持三种流程:半自动(人工提交)、全自动绑卡(本地浏览器执行)、
|
||||
第三方自动绑卡(外部 API 提交);系统会在任务列表中自动跟踪订阅回调状态。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+25
-20
@@ -6,6 +6,13 @@
|
||||
<title>系统设置 - OpenAI 注册系统</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css?v={{ static_version }}">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚙️</text></svg>">
|
||||
<style>
|
||||
#proxy-tab .proxy-table-container {
|
||||
max-height: 640px;
|
||||
min-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
@@ -17,8 +24,12 @@
|
||||
<div class="nav-links">
|
||||
<a href="/" class="nav-link">注册</a>
|
||||
<a href="/accounts" class="nav-link">账号管理</a>
|
||||
<a href="/accounts-overview" class="nav-link">账号总览</a>
|
||||
<a href="/email-services" class="nav-link">邮箱服务</a>
|
||||
<a href="/payment" class="nav-link">支付</a>
|
||||
<a href="/card-pool" class="nav-link">卡池</a>
|
||||
<a href="/auto-team" class="nav-link">自动进team</a>
|
||||
<a href="/logs" class="nav-link">后台日志</a>
|
||||
<a href="/settings" class="nav-link active">设置</a>
|
||||
<a href="/logout" class="nav-link">退出</a>
|
||||
</div>
|
||||
@@ -99,7 +110,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body" style="padding: 0;">
|
||||
<div class="table-container">
|
||||
<div class="table-container proxy-table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -349,25 +360,6 @@
|
||||
<label for="sub2api-service-key">API Key</label>
|
||||
<input type="password" id="sub2api-service-key" placeholder="编辑时留空则保持原值" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="sub2api-default-remote-proxy-id">默认远端代理</label>
|
||||
<input type="hidden" id="sub2api-default-remote-proxy-id" value="">
|
||||
<div class="sub2api-remote-proxy-row">
|
||||
<div class="sub2api-remote-proxy-picker">
|
||||
<button type="button" class="sub2api-remote-proxy-trigger" id="sub2api-remote-proxy-trigger" aria-expanded="false">
|
||||
<div class="sub2api-remote-proxy-trigger-content" id="sub2api-remote-proxy-trigger-content">
|
||||
<span class="sub2api-remote-proxy-placeholder">不设置默认代理</span>
|
||||
</div>
|
||||
<span class="sub2api-remote-proxy-trigger-arrow">▾</span>
|
||||
</button>
|
||||
<div class="sub2api-remote-proxy-dropdown" id="sub2api-remote-proxy-dropdown">
|
||||
<div class="sub2api-remote-proxy-options" id="sub2api-remote-proxy-options"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary sub2api-remote-proxy-btn" id="load-sub2api-remote-proxies-btn">加载代理</button>
|
||||
</div>
|
||||
<p class="sub2api-remote-proxy-hint">与账号页上传时选择的远端代理一致;仅在未显式选择代理时作为默认值。</p>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="sub2api-service-priority">优先级</label>
|
||||
@@ -486,6 +478,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="registration-entry-flow">注册入口链路</label>
|
||||
<select id="registration-entry-flow" name="entry_flow">
|
||||
<option value="native">原本注册入口链路(当前默认)</option>
|
||||
<option value="abcard">ABCard 入口链路</option>
|
||||
</select>
|
||||
<span class="hint">切换后将影响后续新建注册任务的入口流程;Outlook 邮箱会自动使用 Outlook 链路,无需手动设置</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="sleep-min">最小等待时间 (秒)</label>
|
||||
@@ -580,7 +583,9 @@
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-secondary" id="backup-btn">💾 备份数据库</button>
|
||||
<button class="btn btn-primary" id="import-db-btn">📥 导入数据</button>
|
||||
<button class="btn btn-warning" id="cleanup-btn">🧹 清理过期数据</button>
|
||||
<input type="file" id="db-import-file" accept=".db,.sqlite,.sqlite3" style="display: none;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
from src.services.temp_mail import TempMailService
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status_code=200, payload=None, text=""):
|
||||
self.status_code = status_code
|
||||
self._payload = payload
|
||||
self.text = text
|
||||
self.headers = {}
|
||||
|
||||
def json(self):
|
||||
if self._payload is None:
|
||||
raise ValueError("no json payload")
|
||||
return self._payload
|
||||
|
||||
|
||||
class FakeHTTPClient:
|
||||
def __init__(self, responses):
|
||||
self.responses = list(responses)
|
||||
self.calls = []
|
||||
|
||||
def request(self, method, url, **kwargs):
|
||||
self.calls.append({
|
||||
"method": method,
|
||||
"url": url,
|
||||
"kwargs": kwargs,
|
||||
})
|
||||
if not self.responses:
|
||||
raise AssertionError(f"未准备响应: {method} {url}")
|
||||
return self.responses.pop(0)
|
||||
|
||||
|
||||
def test_get_verification_code_fallbacks_to_admin_when_user_endpoints_fail():
|
||||
service = TempMailService({
|
||||
"base_url": "https://mail.example.com",
|
||||
"admin_password": "admin-secret",
|
||||
"domain": "example.com",
|
||||
})
|
||||
fake_client = FakeHTTPClient([
|
||||
FakeResponse(status_code=401, payload={"error": "unauthorized"}),
|
||||
FakeResponse(status_code=401, payload={"error": "unauthorized"}),
|
||||
FakeResponse(
|
||||
payload={
|
||||
"results": [
|
||||
{
|
||||
"id": "mail-1",
|
||||
"source": "noreply@openai.com",
|
||||
"subject": "OpenAI verification",
|
||||
"text": "Your OpenAI verification code is 654321",
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
}
|
||||
),
|
||||
])
|
||||
service.http_client = fake_client
|
||||
|
||||
email = "tester@example.com"
|
||||
service._email_cache[email] = {"jwt": "jwt-abc"}
|
||||
|
||||
code = service.get_verification_code(email=email, timeout=1)
|
||||
|
||||
assert code == "654321"
|
||||
assert fake_client.calls[0]["url"].endswith("/api/mails")
|
||||
assert fake_client.calls[0]["kwargs"]["headers"]["Authorization"] == "Bearer jwt-abc"
|
||||
assert fake_client.calls[1]["url"].endswith("/user_api/mails")
|
||||
assert fake_client.calls[1]["kwargs"]["headers"]["x-user-token"] == "jwt-abc"
|
||||
assert fake_client.calls[2]["url"].endswith("/admin/mails")
|
||||
assert fake_client.calls[2]["kwargs"]["params"]["address"] == email
|
||||
|
||||
|
||||
def test_get_verification_code_without_jwt_uses_admin_only():
|
||||
service = TempMailService({
|
||||
"base_url": "https://mail.example.com",
|
||||
"admin_password": "admin-secret",
|
||||
"domain": "example.com",
|
||||
})
|
||||
fake_client = FakeHTTPClient([
|
||||
FakeResponse(
|
||||
payload={
|
||||
"results": [
|
||||
{
|
||||
"id": "mail-1",
|
||||
"source": "noreply@openai.com",
|
||||
"subject": "Code",
|
||||
"text": "123456 is your verification code",
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
}
|
||||
),
|
||||
])
|
||||
service.http_client = fake_client
|
||||
|
||||
code = service.get_verification_code(email="nojwt@example.com", timeout=1)
|
||||
|
||||
assert code == "123456"
|
||||
assert len(fake_client.calls) == 1
|
||||
assert fake_client.calls[0]["url"].endswith("/admin/mails")
|
||||
|
||||
|
||||
def test_get_verification_code_skips_last_used_mail_id_between_calls():
|
||||
service = TempMailService({
|
||||
"base_url": "https://mail.example.com",
|
||||
"admin_password": "admin-secret",
|
||||
"domain": "example.com",
|
||||
})
|
||||
fake_client = FakeHTTPClient([
|
||||
FakeResponse(
|
||||
payload={
|
||||
"results": [
|
||||
{
|
||||
"id": "mail-1",
|
||||
"source": "noreply@openai.com",
|
||||
"subject": "Code #1",
|
||||
"text": "111111 is your verification code",
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
}
|
||||
),
|
||||
FakeResponse(
|
||||
payload={
|
||||
"results": [
|
||||
{
|
||||
"id": "mail-1",
|
||||
"source": "noreply@openai.com",
|
||||
"subject": "Code #1",
|
||||
"text": "111111 is your verification code",
|
||||
},
|
||||
{
|
||||
"id": "mail-2",
|
||||
"source": "noreply@openai.com",
|
||||
"subject": "Code #2",
|
||||
"text": "222222 is your verification code",
|
||||
},
|
||||
],
|
||||
"total": 2,
|
||||
}
|
||||
),
|
||||
])
|
||||
service.http_client = fake_client
|
||||
|
||||
code_1 = service.get_verification_code(email="reuse@example.com", timeout=1)
|
||||
code_2 = service.get_verification_code(email="reuse@example.com", timeout=1)
|
||||
|
||||
assert code_1 == "111111"
|
||||
assert code_2 == "222222"
|
||||
|
||||
|
||||
def test_get_verification_code_filters_old_mails_by_otp_sent_at():
|
||||
service = TempMailService({
|
||||
"base_url": "https://mail.example.com",
|
||||
"admin_password": "admin-secret",
|
||||
"domain": "example.com",
|
||||
})
|
||||
otp_sent_at = 1_700_000_000.0
|
||||
fake_client = FakeHTTPClient([
|
||||
FakeResponse(
|
||||
payload={
|
||||
"results": [
|
||||
{
|
||||
"id": "mail-old",
|
||||
"source": "noreply@openai.com",
|
||||
"subject": "Old Code",
|
||||
"text": "333333 is your verification code",
|
||||
"createdAt": otp_sent_at - 30,
|
||||
},
|
||||
{
|
||||
"id": "mail-new",
|
||||
"source": "noreply@openai.com",
|
||||
"subject": "New Code",
|
||||
"text": "444444 is your verification code",
|
||||
"createdAt": otp_sent_at + 5,
|
||||
},
|
||||
],
|
||||
"total": 2,
|
||||
}
|
||||
),
|
||||
])
|
||||
service.http_client = fake_client
|
||||
|
||||
code = service.get_verification_code(
|
||||
email="filter@example.com",
|
||||
timeout=1,
|
||||
otp_sent_at=otp_sent_at,
|
||||
)
|
||||
|
||||
assert code == "444444"
|
||||
|
||||
|
||||
def test_get_verification_code_accepts_mails_key_and_missing_mail_id():
|
||||
service = TempMailService({
|
||||
"base_url": "https://mail.example.com",
|
||||
"admin_password": "admin-secret",
|
||||
"domain": "example.com",
|
||||
})
|
||||
fake_client = FakeHTTPClient([
|
||||
FakeResponse(
|
||||
payload={
|
||||
"mails": [
|
||||
{
|
||||
# 没有 id/mail_id 字段,验证回退 ID 逻辑
|
||||
"source": "noreply@openai.com",
|
||||
"subject": "OpenAI verification",
|
||||
"text": "Your verification code is 987654",
|
||||
"createdAt": "2026-03-23 10:00:00",
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
}
|
||||
),
|
||||
])
|
||||
service.http_client = fake_client
|
||||
|
||||
code = service.get_verification_code(email="format@example.com", timeout=1)
|
||||
|
||||
assert code == "987654"
|
||||
|
||||
|
||||
def test_get_verification_code_fetches_mail_detail_when_list_has_no_body():
|
||||
service = TempMailService({
|
||||
"base_url": "https://mail.example.com",
|
||||
"admin_password": "admin-secret",
|
||||
"domain": "example.com",
|
||||
})
|
||||
fake_client = FakeHTTPClient([
|
||||
FakeResponse(
|
||||
payload={
|
||||
"results": [
|
||||
{
|
||||
"id": "mail-100",
|
||||
"source": "noreply@openai.com",
|
||||
"subject": "OpenAI verification",
|
||||
"createdAt": "2026-03-23T10:00:00Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
FakeResponse(
|
||||
payload={
|
||||
"id": "mail-100",
|
||||
"source": "noreply@openai.com",
|
||||
"subject": "OpenAI verification",
|
||||
"text": "Your OpenAI verification code is 246810",
|
||||
}
|
||||
),
|
||||
])
|
||||
service.http_client = fake_client
|
||||
|
||||
code = service.get_verification_code(email="detail@example.com", timeout=1)
|
||||
|
||||
assert code == "246810"
|
||||
|
||||
|
||||
def test_get_verification_code_admin_unfiltered_fallback():
|
||||
service = TempMailService({
|
||||
"base_url": "https://mail.example.com",
|
||||
"admin_password": "admin-secret",
|
||||
"domain": "example.com",
|
||||
})
|
||||
fake_client = FakeHTTPClient([
|
||||
# /admin/mails?address=... 返回空
|
||||
FakeResponse(payload={"results": []}),
|
||||
# /admin/mails 不带地址过滤,返回包含目标邮箱邮件
|
||||
FakeResponse(
|
||||
payload={
|
||||
"results": [
|
||||
{
|
||||
"id": "mail-200",
|
||||
"address": "target@example.com",
|
||||
"source": "noreply@openai.com",
|
||||
"subject": "Code",
|
||||
"text": "135790 is your verification code",
|
||||
},
|
||||
{
|
||||
"id": "mail-201",
|
||||
"address": "other@example.com",
|
||||
"source": "noreply@openai.com",
|
||||
"subject": "Code",
|
||||
"text": "111111 is your verification code",
|
||||
},
|
||||
]
|
||||
}
|
||||
),
|
||||
])
|
||||
service.http_client = fake_client
|
||||
|
||||
code = service.get_verification_code(email="target@example.com", timeout=1)
|
||||
|
||||
assert code == "135790"
|
||||
@@ -20,6 +20,8 @@ else:
|
||||
sys.path.insert(0, str(_src_root))
|
||||
|
||||
from src.core.utils import setup_logging
|
||||
from src.core.timezone_utils import apply_process_timezone
|
||||
from src.core.db_logs import install_database_log_handler
|
||||
from src.database.init_db import initialize_database
|
||||
from src.config.settings import get_settings
|
||||
|
||||
@@ -43,6 +45,9 @@ def _load_dotenv():
|
||||
|
||||
def setup_application():
|
||||
"""设置应用程序"""
|
||||
# 统一进程时区为北京时间,避免容器默认 UTC 导致时间错位
|
||||
apply_process_timezone()
|
||||
|
||||
# 加载 .env 文件(优先级低于已有环境变量)
|
||||
_load_dotenv()
|
||||
|
||||
@@ -72,6 +77,7 @@ def setup_application():
|
||||
log_level=settings.log_level,
|
||||
log_file=log_file
|
||||
)
|
||||
install_database_log_handler()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info("数据库初始化完成,地基已经打好")
|
||||
|
||||
Reference in new issue
Block a user