feat: Phase 3.4 Developer API Management - API Keys, OpenAPI Clients, Call Stats, Slow Logs

This commit is contained in:
qyyfw committed 2026-07-03 19:04:49 +08:00
1 parent 1d32385f17
commit e7d1c39c18
8 files changed
+1359 -4

No files matched your search

@@ -0,0 +1,658 @@
"""
Developer API Management - Admin endpoints for API Keys, OpenAPI Clients,
API Call Statistics, and Slow Request Logs.
"""
import secrets
import hashlib
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import func, desc, asc
from sqlalchemy.orm import Session
from ...database import get_db
from ...models import (
ApiKey, OpenApiClient, ApiCallStat, SlowRequestLog, User
)
router = APIRouter()
CST_TZ = timezone(timedelta(hours=8))
def cst_now():
return datetime.now(CST_TZ)
def fmt_time(dt):
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=CST_TZ).isoformat()
return dt.isoformat()
# ============================================================
# Pydantic Models
# ============================================================
class ApiKeyCreate(BaseModel):
user_id: str
name: str = ""
description: str = ""
rate_limit: int = 60
expired_at: str = None
class ApiKeyUpdate(BaseModel):
name: str = None
description: str = None
rate_limit: int = None
status: str = None
expired_at: str = None
class OpenAPIClientCreate(BaseModel):
user_id: str
name: str
allowed_models: list = None
rate_limit: int = 60
class OpenAPIClientUpdate(BaseModel):
name: str = None
allowed_models: list = None
rate_limit: int = None
status: str = None
# ============================================================
# Helper functions
# ============================================================
def _generate_api_key():
"""Generate a unique API key string."""
return "sk-" + secrets.token_hex(24)
def _generate_secret():
"""Generate a secret string."""
return secrets.token_hex(32)
def _generate_client_id():
"""Generate a unique client ID."""
return "cli_" + secrets.token_hex(12)
def _generate_client_secret():
"""Generate a client secret."""
return secrets.token_hex(32)
def _serialize_api_key(key, user=None):
return {
"id": key.id,
"user_id": key.user_id,
"username": user.username if user else None,
"api_key": key.api_key,
"name": key.name,
"description": getattr(key, "description", "") or "",
"rate_limit": getattr(key, "rate_limit", 60) or 60,
"total_calls": getattr(key, "total_calls", 0) or 0,
"status": key.status,
"last_used_at": fmt_time(key.last_used_at),
"created_at": fmt_time(key.created_at),
"expired_at": fmt_time(key.expired_at),
}
def _serialize_client(client, user=None):
return {
"id": client.id,
"user_id": client.user_id,
"username": user.username if user else None,
"name": client.name,
"client_id": client.client_id,
"allowed_models": client.allowed_models or [],
"rate_limit": client.rate_limit,
"total_calls": getattr(client, "total_calls", 0) or 0,
"last_used_at": fmt_time(getattr(client, "last_used_at", None)),
"status": client.status,
"created_at": fmt_time(client.created_at),
}
def _serialize_call_stat(stat):
avg_time = 0.0
if stat.call_count and stat.call_count > 0:
avg_time = round(stat.total_response_time / stat.call_count, 3)
error_rate = 0.0
if stat.call_count and stat.call_count > 0:
error_rate = round(stat.error_count / stat.call_count * 100, 2)
return {
"id": stat.id,
"endpoint": stat.endpoint,
"method": stat.method,
"call_count": stat.call_count,
"error_count": stat.error_count,
"error_rate": error_rate,
"avg_response_time": avg_time,
"total_response_time": round(stat.total_response_time, 3),
"last_called_at": fmt_time(stat.last_called_at),
"created_at": fmt_time(stat.created_at),
"updated_at": fmt_time(stat.updated_at),
}
def _serialize_slow_log(log):
return {
"id": log.id,
"request_id": log.request_id,
"method": log.method,
"endpoint": log.endpoint,
"status_code": log.status_code,
"response_time": round(log.response_time, 3),
"threshold": round(log.threshold, 2),
"created_at": fmt_time(log.created_at),
}
# ============================================================
# Stats Overview
# ============================================================
@router.get("/stats/overview")
async def get_stats_overview(db: Session = Depends(get_db)):
"""Get developer API dashboard statistics."""
now = cst_now()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
today_start_naive = today_start.replace(tzinfo=None)
# API Key stats
total_keys = db.query(func.count(ApiKey.id)).scalar() or 0
active_keys = db.query(func.count(ApiKey.id)).filter(ApiKey.status == "active").scalar() or 0
revoked_keys = db.query(func.count(ApiKey.id)).filter(ApiKey.status == "revoked").scalar() or 0
# OpenAPI Client stats
total_clients = db.query(func.count(OpenApiClient.id)).scalar() or 0
active_clients = db.query(func.count(OpenApiClient.id)).filter(OpenApiClient.status == "active").scalar() or 0
# Call stats
total_calls = db.query(func.sum(ApiCallStat.call_count)).scalar() or 0
total_errors = db.query(func.sum(ApiCallStat.error_count)).scalar() or 0
total_response_time = db.query(func.sum(ApiCallStat.total_response_time)).scalar() or 0.0
avg_response_time = round(total_response_time / total_calls, 3) if total_calls > 0 else 0.0
error_rate = round(total_errors / total_calls * 100, 2) if total_calls > 0 else 0.0
# Today's calls (approximate by last_called_at)
today_calls = db.query(func.sum(ApiCallStat.call_count)).filter(
ApiCallStat.last_called_at >= today_start_naive
).scalar() or 0
# Slow request count
slow_log_count = db.query(func.count(SlowRequestLog.id)).scalar() or 0
today_slow = db.query(func.count(SlowRequestLog.id)).filter(
SlowRequestLog.created_at >= today_start_naive
).scalar() or 0
# Top endpoints by call count
top_endpoints = db.query(ApiCallStat).order_by(desc(ApiCallStat.call_count)).limit(5).all()
top_endpoints_data = [
{
"endpoint": s.endpoint,
"method": s.method,
"call_count": s.call_count,
"error_count": s.error_count,
}
for s in top_endpoints
]
return {
"code": 0,
"data": {
"api_keys": {
"total": total_keys,
"active": active_keys,
"revoked": revoked_keys,
},
"openapi_clients": {
"total": total_clients,
"active": active_clients,
},
"call_stats": {
"total_calls": total_calls,
"today_calls": today_calls,
"total_errors": total_errors,
"error_rate": error_rate,
"avg_response_time": avg_response_time,
},
"slow_logs": {
"total": slow_log_count,
"today": today_slow,
},
"top_endpoints": top_endpoints_data,
"server_time": cst_now().isoformat(),
}
}
# ============================================================
# API Key Management
# ============================================================
@router.get("/api-keys")
async def list_api_keys(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
status: str = Query(None),
keyword: str = Query(None),
db: Session = Depends(get_db),
):
"""List all API keys with pagination."""
query = db.query(ApiKey, User).outerjoin(User, ApiKey.user_id == User.id)
if status:
query = query.filter(ApiKey.status == status)
if keyword:
query = query.filter(
(ApiKey.name.ilike(f"%{keyword}%")) |
(ApiKey.api_key.ilike(f"%{keyword}%")) |
(User.username.ilike(f"%{keyword}%"))
)
total = query.count()
items = query.order_by(desc(ApiKey.created_at)).offset((page - 1) * page_size).limit(page_size).all()
return {
"code": 0,
"data": {
"total": total,
"page": page,
"page_size": page_size,
"items": [_serialize_api_key(k, u) for k, u in items],
}
}
@router.post("/api-keys")
async def create_api_key(body: ApiKeyCreate, db: Session = Depends(get_db)):
"""Create a new API key."""
# Verify user exists
user = db.query(User).filter(User.id == body.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Check if model has description/rate_limit/total_calls columns
new_key = ApiKey(
user_id=body.user_id,
api_key=_generate_api_key(),
secret=_generate_secret(),
name=body.name,
status="active",
created_at=datetime.now(),
)
# Set optional fields if they exist on the model
try:
new_key.description = body.description
except Exception:
pass
try:
new_key.rate_limit = body.rate_limit
except Exception:
pass
try:
new_key.total_calls = 0
except Exception:
pass
if body.expired_at:
try:
new_key.expired_at = datetime.fromisoformat(body.expired_at.replace("Z", ""))
except Exception:
pass
db.add(new_key)
db.commit()
db.refresh(new_key)
return {
"code": 0,
"data": _serialize_api_key(new_key, user),
"message": "API Key created successfully",
}
@router.put("/api-keys/{key_id}")
async def update_api_key(key_id: str, body: ApiKeyUpdate, db: Session = Depends(get_db)):
"""Update an API key."""
key = db.query(ApiKey).filter(ApiKey.id == key_id).first()
if not key:
raise HTTPException(status_code=404, detail="API Key not found")
if body.name is not None:
key.name = body.name
if body.status is not None:
key.status = body.status
if body.rate_limit is not None:
try:
key.rate_limit = body.rate_limit
except Exception:
pass
if body.description is not None:
try:
key.description = body.description
except Exception:
pass
if body.expired_at is not None:
try:
key.expired_at = datetime.fromisoformat(body.expired_at.replace("Z", ""))
except Exception:
pass
db.commit()
db.refresh(key)
user = db.query(User).filter(User.id == key.user_id).first()
return {"code": 0, "data": _serialize_api_key(key, user), "message": "Updated"}
@router.post("/api-keys/{key_id}/revoke")
async def revoke_api_key(key_id: str, db: Session = Depends(get_db)):
"""Revoke an API key."""
key = db.query(ApiKey).filter(ApiKey.id == key_id).first()
if not key:
raise HTTPException(status_code=404, detail="API Key not found")
key.status = "revoked"
db.commit()
return {"code": 0, "message": "API Key revoked"}
@router.delete("/api-keys/{key_id}")
async def delete_api_key(key_id: str, db: Session = Depends(get_db)):
"""Delete an API key permanently."""
key = db.query(ApiKey).filter(ApiKey.id == key_id).first()
if not key:
raise HTTPException(status_code=404, detail="API Key not found")
db.delete(key)
db.commit()
return {"code": 0, "message": "API Key deleted"}
# ============================================================
# OpenAPI Client Management
# ============================================================
@router.get("/openapi-clients")
async def list_openapi_clients(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
status: str = Query(None),
keyword: str = Query(None),
db: Session = Depends(get_db),
):
"""List all OpenAPI clients."""
query = db.query(OpenApiClient, User).outerjoin(User, OpenApiClient.user_id == User.id)
if status:
query = query.filter(OpenApiClient.status == status)
if keyword:
query = query.filter(
(OpenApiClient.name.ilike(f"%{keyword}%")) |
(OpenApiClient.client_id.ilike(f"%{keyword}%")) |
(User.username.ilike(f"%{keyword}%"))
)
total = query.count()
items = query.order_by(desc(OpenApiClient.created_at)).offset((page - 1) * page_size).limit(page_size).all()
return {
"code": 0,
"data": {
"total": total,
"page": page,
"page_size": page_size,
"items": [_serialize_client(c, u) for c, u in items],
}
}
@router.post("/openapi-clients")
async def create_openapi_client(body: OpenAPIClientCreate, db: Session = Depends(get_db)):
"""Create a new OpenAPI client."""
user = db.query(User).filter(User.id == body.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
new_client = OpenApiClient(
user_id=body.user_id,
name=body.name,
client_id=_generate_client_id(),
client_secret=_generate_client_secret(),
allowed_models=body.allowed_models,
rate_limit=body.rate_limit,
status="active",
created_at=datetime.now(),
)
try:
new_client.total_calls = 0
except Exception:
pass
try:
new_client.last_used_at = None
except Exception:
pass
db.add(new_client)
db.commit()
db.refresh(new_client)
result = _serialize_client(new_client, user)
result["client_secret"] = new_client.client_secret # Only show on create
return {"code": 0, "data": result, "message": "OpenAPI Client created"}
@router.put("/openapi-clients/{client_id}")
async def update_openapi_client(client_id: str, body: OpenAPIClientUpdate, db: Session = Depends(get_db)):
"""Update an OpenAPI client."""
client = db.query(OpenApiClient).filter(OpenApiClient.id == client_id).first()
if not client:
raise HTTPException(status_code=404, detail="OpenAPI Client not found")
if body.name is not None:
client.name = body.name
if body.allowed_models is not None:
client.allowed_models = body.allowed_models
if body.rate_limit is not None:
client.rate_limit = body.rate_limit
if body.status is not None:
client.status = body.status
db.commit()
db.refresh(client)
user = db.query(User).filter(User.id == client.user_id).first()
return {"code": 0, "data": _serialize_client(client, user), "message": "Updated"}
@router.post("/openapi-clients/{client_id}/reset-secret")
async def reset_client_secret(client_id: str, db: Session = Depends(get_db)):
"""Reset the client secret."""
client = db.query(OpenApiClient).filter(OpenApiClient.id == client_id).first()
if not client:
raise HTTPException(status_code=404, detail="OpenAPI Client not found")
client.client_secret = _generate_client_secret()
db.commit()
db.refresh(client)
return {"code": 0, "data": {"client_secret": client.client_secret}, "message": "Secret reset"}
@router.delete("/openapi-clients/{client_id}")
async def delete_openapi_client(client_id: str, db: Session = Depends(get_db)):
"""Delete an OpenAPI client."""
client = db.query(OpenApiClient).filter(OpenApiClient.id == client_id).first()
if not client:
raise HTTPException(status_code=404, detail="OpenAPI Client not found")
db.delete(client)
db.commit()
return {"code": 0, "message": "OpenAPI Client deleted"}
# ============================================================
# API Call Statistics
# ============================================================
@router.get("/call-stats")
async def list_call_stats(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
sort_by: str = Query("call_count"),
sort_order: str = Query("desc"),
keyword: str = Query(None),
db: Session = Depends(get_db),
):
"""List API call statistics."""
query = db.query(ApiCallStat)
if keyword:
query = query.filter(ApiCallStat.endpoint.ilike(f"%{keyword}%"))
sort_column = getattr(ApiCallStat, sort_by, ApiCallStat.call_count)
if sort_order == "asc":
query = query.order_by(asc(sort_column))
else:
query = query.order_by(desc(sort_column))
total = query.count()
items = query.offset((page - 1) * page_size).limit(page_size).all()
return {
"code": 0,
"data": {
"total": total,
"page": page,
"page_size": page_size,
"items": [_serialize_call_stat(s) for s in items],
}
}
@router.get("/call-stats/overview")
async def call_stats_overview(db: Session = Depends(get_db)):
"""Get call stats summary."""
total_endpoints = db.query(func.count(ApiCallStat.id)).scalar() or 0
total_calls = db.query(func.sum(ApiCallStat.call_count)).scalar() or 0
total_errors = db.query(func.sum(ApiCallStat.error_count)).scalar() or 0
avg_time = 0.0
if total_calls and total_calls > 0:
total_time = db.query(func.sum(ApiCallStat.total_response_time)).scalar() or 0.0
avg_time = round(total_time / total_calls, 3)
# Method distribution
method_stats = db.query(
ApiCallStat.method,
func.sum(ApiCallStat.call_count).label("count")
).group_by(ApiCallStat.method).all()
return {
"code": 0,
"data": {
"total_endpoints": total_endpoints,
"total_calls": total_calls,
"total_errors": total_errors,
"error_rate": round(total_errors / total_calls * 100, 2) if total_calls > 0 else 0.0,
"avg_response_time": avg_time,
"method_distribution": [
{"method": m, "count": c} for m, c in method_stats
],
}
}
# ============================================================
# Slow Request Logs
# ============================================================
@router.get("/slow-logs")
async def list_slow_logs(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
keyword: str = Query(None),
min_time: float = Query(None),
db: Session = Depends(get_db),
):
"""List slow request logs."""
query = db.query(SlowRequestLog)
if keyword:
query = query.filter(SlowRequestLog.endpoint.ilike(f"%{keyword}%"))
if min_time is not None:
query = query.filter(SlowRequestLog.response_time >= min_time)
total = query.count()
items = query.order_by(desc(SlowRequestLog.created_at)).offset((page - 1) * page_size).limit(page_size).all()
return {
"code": 0,
"data": {
"total": total,
"page": page,
"page_size": page_size,
"items": [_serialize_slow_log(l) for l in items],
}
}
@router.delete("/slow-logs")
async def clear_slow_logs(db: Session = Depends(get_db)):
"""Clear all slow request logs."""
deleted = db.query(SlowRequestLog).delete()
db.commit()
return {"code": 0, "message": f"Cleared {deleted} slow request logs"}
@router.get("/slow-logs/stats")
async def slow_logs_stats(db: Session = Depends(get_db)):
"""Get slow request log statistics."""
now = cst_now()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None)
total = db.query(func.count(SlowRequestLog.id)).scalar() or 0
today = db.query(func.count(SlowRequestLog.id)).filter(
SlowRequestLog.created_at >= today_start
).scalar() or 0
avg_time = db.query(func.avg(SlowRequestLog.response_time)).scalar() or 0.0
max_time = db.query(func.max(SlowRequestLog.response_time)).scalar() or 0.0
# Distribution by status code
status_dist = db.query(
SlowRequestLog.status_code,
func.count(SlowRequestLog.id).label("count")
).group_by(SlowRequestLog.status_code).all()
return {
"code": 0,
"data": {
"total": total,
"today": today,
"avg_response_time": round(float(avg_time), 3),
"max_response_time": round(float(max_time), 3),
"status_distribution": [
{"status_code": s, "count": c} for s, c in status_dist
],
}
}
+5
View File
@@ -65,6 +65,9 @@ class ApiKey(Base):
last_used_at = Column(DateTime, default=None)
created_at = Column(DateTime, default=datetime.now)
expired_at = Column(DateTime, default=None)
description = Column(String(500), default=None, comment="密钥描述")
rate_limit = Column(Integer, default=60, comment="速率限制(请求/分钟)")
total_calls = Column(BigInteger, default=0, comment="总调用次数")
# ============================================================
@@ -770,6 +773,8 @@ class OpenApiClient(Base):
rate_limit = Column(Integer, default=60)
status = Column(String(10), nullable=False, default="active")
created_at = Column(DateTime, default=datetime.now)
total_calls = Column(BigInteger, default=0, comment="总调用次数")
last_used_at = Column(DateTime, default=None, comment="最后使用时间")
# ============================================================
+2 -1
View File
@@ -34,7 +34,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.api import auth, chat, channels, knowledge, market, token_usage, payment, profile, notifications, assets, evolution
from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, storage_admin, automation_admin, mcp_admin
from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, storage_admin, automation_admin, mcp_admin, developer_admin
from app.config import setup_file_logging, SLOW_REQUEST_THRESHOLD
from app.database import SessionLocal
@@ -422,6 +422,7 @@ app.include_router(knowledge_admin.router, prefix="/app/api/admin/knowledge", ta
app.include_router(storage_admin.router, prefix="/app/api/admin/storage", tags=["后台-存储配置"])
app.include_router(automation_admin.router, prefix="/app/api/admin/automation", tags=["后台-自动化任务"])
app.include_router(mcp_admin.router, prefix="/app/api/admin/mcp", tags=["后台-MCP服务"])
app.include_router(developer_admin.router, prefix="/app/api/admin/developer", tags=["后台-开发者API"])