Merge pull request #20 from wold9527/main

添加绑卡和实用功能
This commit is contained in:
凌晨瑞雪至 authored and GitHub committed 2026-03-25 10:21:32 +08:00
commit 2a0d3abfaa
58 files changed
+17428 -1559

No files matched your search

+7
View File
@@ -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
+61
View File
@@ -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
View File
@@ -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"]
+10 -5
View File
@@ -8,11 +8,8 @@
[![Python](https://img.shields.io/badge/Python-3.10%2B-blue.svg)](https://www.python.org/)
## QQ群
- 交流群:291638849
- https://qm.qq.com/q/ZTCKxawxeo
## Telegram交流频道
- https://t.me/cysq2026
- 交流群: https://qm.qq.com/q/ZTCKxawxeo
## 致谢
@@ -129,13 +126,21 @@ codex-console.exe --access-password mypassword
docker-compose up -d
```
你可以在 `docker-compose.yml` 中修改环境变量,比如端口和访问密码。
你可以在 `docker-compose.yml` 中修改环境变量,比如端口和访问密码。
如果需要看“全自动绑卡”的可视化浏览器,打开:
- noVNC: `http://127.0.0.1:6080`
### 使用 docker run
```bash
docker run -d \
-p 1455:1455 \
-p 6080:6080 \
-e DISPLAY=:99 \
-e ENABLE_VNC=1 \
-e VNC_PORT=5900 \
-e NOVNC_PORT=6080 \
-e WEBUI_HOST=0.0.0.0 \
-e WEBUI_PORT=1455 \
-e WEBUI_ACCESS_PASSWORD=your_secure_password \
+9 -1
View File
@@ -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
+27
View File
@@ -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
View File
@@ -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
+35
View File
@@ -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
+245
View File
@@ -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"
+8
View File
@@ -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}
+164
View File
@@ -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
+767
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+482
View File
@@ -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)
+108 -29
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+61
View File
@@ -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
+7 -167
View File
@@ -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
View File
@@ -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)
+4 -4
View File
@@ -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
View File
@@ -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}"
+4 -1
View File
@@ -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 已处理,此处兜底)
+22 -5
View File
@@ -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]}"
+60 -54
View File
@@ -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
+31 -18
View File
@@ -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
+43 -18
View File
@@ -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
+30 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+10 -1
View File
@@ -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},
]
+123
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+26 -8
View File
@@ -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
View File
@@ -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,
+4 -126
View File
@@ -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
+10
View File
@@ -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
View File
@@ -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
View File
@@ -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
+94
View File
@@ -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 作为键
+1 -1
View File
@@ -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>
+277
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+66 -287
View File
@@ -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
View File
@@ -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">&times;</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>
+740
View File
@@ -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">&times;</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">&times;</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>
+57
View File
@@ -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>
+57
View File
@@ -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>
+6 -2
View File
@@ -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
View File
@@ -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">
+248
View File
@@ -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
View File
@@ -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>&nbsp;</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
View File
@@ -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>
+291
View File
@@ -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"
+6
View File
@@ -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("数据库初始化完成,地基已经打好")