Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ config/setting.toml
config/setting_warp.toml
config/setting_warp_example.toml

# 测试文件
test_*
# 根目录临时测试文件
/test_*
tests/__pycache__/
*.har
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,14 @@ python main.py
- **用户名**: `admin`
- **密码**: `admin`

## 📈 监控接口

- `GET /health`:公开健康检查,返回服务是否存活、活跃 Token 数、即将过期 Token 数、已过期 Token 数、429 禁用数等摘要
- `GET /metrics`:Prometheus 指标接口
- `GET /api/tokens`:管理接口,返回 `at_expires`、`at_expired`、`at_expiring_within_1h`、`ban_reason`、`consecutive_error_count` 等 Token 状态

Prometheus 可直接抓 `/metrics`。如果部署到 Kubernetes,建议只在集群内抓取,并在 Ingress/Gateway 层单独限制 `/metrics` 的外部访问。

### 模型测试页面

访问 **http://localhost:8000/test** 可打开内置的模型测试页面,支持:
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ python-multipart==0.0.20
python-dateutil==2.8.2
playwright>=1.40.0
nodriver>=0.48.0
prometheus-client==0.22.1
32 changes: 28 additions & 4 deletions src/api/admin.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Admin API routes"""
import asyncio
import json
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Header, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
Expand All @@ -15,6 +16,7 @@
from ..core.auth import AuthManager
from ..core.database import Database
from ..core.config import config
from ..core.monitoring import build_public_health_snapshot
from ..services.token_manager import TokenManager
from ..services.proxy_manager import ProxyManager
from ..services.concurrency_manager import ConcurrencyManager
Expand Down Expand Up @@ -639,12 +641,31 @@ async def get_tokens(token: str = Depends(verify_admin_token)):
"""Get all tokens with statistics"""
token_rows = await db.get_all_tokens_with_stats()
to_iso = lambda value: value.isoformat() if hasattr(value, "isoformat") else value
now = datetime.now(timezone.utc)

def normalize_dt(value):
if not value:
return None
if isinstance(value, str):
try:
value = datetime.fromisoformat(value.replace("Z", "+00:00"))
except Exception:
return None
if getattr(value, "tzinfo", None) is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)

return [{
"id": row.get("id"),
"st": row.get("st"), # Session Token for editing
"at": row.get("at"), # Access Token for editing (从ST转换而来)
"at_expires": to_iso(row.get("at_expires")) if row.get("at_expires") else None, # 🆕 AT过期时间
"at_expired": bool(normalize_dt(row.get("at_expires")) and normalize_dt(row.get("at_expires")) <= now),
"at_expiring_within_1h": bool(
normalize_dt(row.get("at_expires"))
and normalize_dt(row.get("at_expires")) > now
and (normalize_dt(row.get("at_expires")) - now).total_seconds() < 3600
),
"token": row.get("at"), # 兼容前端 token.token 的访问方式
"email": row.get("email"),
"name": row.get("name"),
Expand All @@ -664,7 +685,12 @@ async def get_tokens(token: str = Depends(verify_admin_token)):
"video_concurrency": row.get("video_concurrency"),
"image_count": row.get("image_count", 0),
"video_count": row.get("video_count", 0),
"error_count": row.get("error_count", 0)
"error_count": row.get("error_count", 0),
"today_error_count": row.get("today_error_count", 0),
"consecutive_error_count": row.get("consecutive_error_count", 0),
"last_error_at": to_iso(row.get("last_error_at")) if row.get("last_error_at") else None,
"ban_reason": row.get("ban_reason"),
"banned_at": to_iso(row.get("banned_at")) if row.get("banned_at") else None,
} for row in token_rows] # 直接返回数组,兼容前端


Expand Down Expand Up @@ -1232,11 +1258,9 @@ async def logout(token: str = Depends(verify_admin_token)):
async def health_check():
"""Public health check endpoint - no auth required"""
try:
stats = await db.get_dashboard_stats()
has_active_tokens = stats.get("active_tokens", 0) > 0
return await build_public_health_snapshot(db)
except Exception:
return {"backend_running": True, "has_active_tokens": False}
return {"backend_running": True, "has_active_tokens": has_active_tokens}


@router.get("/api/stats")
Expand Down
51 changes: 32 additions & 19 deletions src/core/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import aiosqlite
import json
from contextlib import asynccontextmanager
from datetime import datetime
from datetime import date, datetime
from typing import Optional, List, Dict, Any
from pathlib import Path
from .models import Token, TokenStats, Task, RequestLog, AdminConfig, ProxyConfig, GenerationConfig, CacheConfig, Project, CaptchaConfig, PluginConfig, CallLogicConfig
Expand Down Expand Up @@ -32,6 +32,10 @@ async def _configure_connection(self, db):
await db.execute(f"PRAGMA busy_timeout = {self._busy_timeout_ms}")
await db.execute("PRAGMA foreign_keys = ON")

def _current_stats_date(self) -> str:
"""Return the logical date used by daily token statistics."""
return date.today().isoformat()

@asynccontextmanager
async def _connect(self, *, write: bool = False):
"""Open a configured SQLite connection and optionally serialize writes."""
Expand Down Expand Up @@ -901,23 +905,30 @@ async def get_all_tokens_with_stats(self) -> List[Dict[str, Any]]:
"""Get all tokens with merged statistics in one query"""
async with self._connect() as db:
db.row_factory = aiosqlite.Row
today = self._current_stats_date()
cursor = await db.execute("""
SELECT
t.*,
COALESCE(ts.image_count, 0) AS image_count,
COALESCE(ts.video_count, 0) AS video_count,
COALESCE(ts.error_count, 0) AS error_count
COALESCE(ts.error_count, 0) AS error_count,
COALESCE(CASE WHEN ts.today_date = ? THEN ts.today_image_count ELSE 0 END, 0) AS today_image_count,
COALESCE(CASE WHEN ts.today_date = ? THEN ts.today_video_count ELSE 0 END, 0) AS today_video_count,
COALESCE(CASE WHEN ts.today_date = ? THEN ts.today_error_count ELSE 0 END, 0) AS today_error_count,
COALESCE(ts.consecutive_error_count, 0) AS consecutive_error_count,
ts.last_error_at AS last_error_at
FROM tokens t
LEFT JOIN token_stats ts ON ts.token_id = t.id
ORDER BY t.created_at DESC
""")
""", (today, today, today))
rows = await cursor.fetchall()
return [dict(row) for row in rows]

async def get_dashboard_stats(self) -> Dict[str, int]:
"""Get dashboard counters with aggregated SQL queries"""
async with self._connect() as db:
db.row_factory = aiosqlite.Row
today = self._current_stats_date()

token_cursor = await db.execute("""
SELECT
Expand All @@ -932,11 +943,11 @@ async def get_dashboard_stats(self) -> Dict[str, int]:
COALESCE(SUM(image_count), 0) AS total_images,
COALESCE(SUM(video_count), 0) AS total_videos,
COALESCE(SUM(error_count), 0) AS total_errors,
COALESCE(SUM(today_image_count), 0) AS today_images,
COALESCE(SUM(today_video_count), 0) AS today_videos,
COALESCE(SUM(today_error_count), 0) AS today_errors
COALESCE(SUM(CASE WHEN today_date = ? THEN today_image_count ELSE 0 END), 0) AS today_images,
COALESCE(SUM(CASE WHEN today_date = ? THEN today_video_count ELSE 0 END), 0) AS today_videos,
COALESCE(SUM(CASE WHEN today_date = ? THEN today_error_count ELSE 0 END), 0) AS today_errors
FROM token_stats
""")
""", (today, today, today))
stats_row = await stats_cursor.fetchone()

token_data = dict(token_row) if token_row else {}
Expand Down Expand Up @@ -987,9 +998,8 @@ async def update_token(self, token_id: int, **kwargs):
params = []

for key, value in kwargs.items():
if value is not None:
updates.append(f"{key} = ?")
params.append(value)
updates.append(f"{key} = ?")
params.append(value)

if updates:
params.append(token_id)
Expand Down Expand Up @@ -1114,19 +1124,20 @@ async def get_token_stats(self, token_id: int) -> Optional[TokenStats]:

async def increment_image_count(self, token_id: int):
"""Increment image generation count with daily reset"""
from datetime import date
async with self._connect(write=True) as db:
today = str(date.today())
today = self._current_stats_date()
# Get current stats
cursor = await db.execute("SELECT today_date FROM token_stats WHERE token_id = ?", (token_id,))
row = await cursor.fetchone()

# If date changed, reset today's count
# If date changed, reset all daily counters before recording today's image usage.
if row and row[0] != today:
await db.execute("""
UPDATE token_stats
SET image_count = image_count + 1,
today_image_count = 1,
today_video_count = 0,
today_error_count = 0,
today_date = ?
WHERE token_id = ?
""", (today, token_id))
Expand All @@ -1143,19 +1154,20 @@ async def increment_image_count(self, token_id: int):

async def increment_video_count(self, token_id: int):
"""Increment video generation count with daily reset"""
from datetime import date
async with self._connect(write=True) as db:
today = str(date.today())
today = self._current_stats_date()
# Get current stats
cursor = await db.execute("SELECT today_date FROM token_stats WHERE token_id = ?", (token_id,))
row = await cursor.fetchone()

# If date changed, reset today's count
# If date changed, reset all daily counters before recording today's video usage.
if row and row[0] != today:
await db.execute("""
UPDATE token_stats
SET video_count = video_count + 1,
today_image_count = 0,
today_video_count = 1,
today_error_count = 0,
today_date = ?
WHERE token_id = ?
""", (today, token_id))
Expand All @@ -1178,19 +1190,20 @@ async def increment_error_count(self, token_id: int):
- consecutive_error_count: Consecutive errors (reset on success/enable)
- today_error_count: Today's errors (reset on date change)
"""
from datetime import date
async with self._connect(write=True) as db:
today = str(date.today())
today = self._current_stats_date()
# Get current stats
cursor = await db.execute("SELECT today_date FROM token_stats WHERE token_id = ?", (token_id,))
row = await cursor.fetchone()

# If date changed, reset today's error count
# If date changed, reset all daily counters before recording today's error.
if row and row[0] != today:
await db.execute("""
UPDATE token_stats
SET error_count = error_count + 1,
consecutive_error_count = consecutive_error_count + 1,
today_image_count = 0,
today_video_count = 0,
today_error_count = 1,
today_date = ?,
last_error_at = CURRENT_TIMESTAMP
Expand Down
Loading
Loading