feat: API Key管理+用量追踪完整功能链路

后端:
- api_key_manager: 加密存储、脱敏显示、优先级排序、降级策略、Key可用性检测
- smart_router: 分层路由策略(FREE→LOW_COST→MID_COST→HIGH_COST)、国内引擎优先
- usage_tracker: Token消耗统计、成本计算、配额预警(ok/warning/exceeded)
- API端点: /api/v1/api-keys/ (CRUD+verify), /api/v1/usage/ (summary+quota+by-engine)
- 测试: 19个API测试 + 37个服务测试全部通过

前端:
- settings页面API配置标签页: 按国内外分组展示9个引擎、添加/删除/验证Key
- usage页面: 配额概览(环形进度)、用量趋势图(Recharts)、引擎分布饼图、明细表格
- 修复API路径与后端不一致问题
- 修复alerts API参数顺序问题
This commit is contained in:
chiguyong 2026-05-25 15:37:33 +08:00
parent 41c2994222
commit 290ef5a273
7 changed files with 1439 additions and 3 deletions

127
backend/app/api/api_keys.py Normal file
View File

@ -0,0 +1,127 @@
import logging
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from app.api.deps import get_current_user
from app.models.user import User
from app.services.api_key_manager import APIKeyManager, KeySource, KeyStatus
from app.services.smart_router import ENGINE_COST_PROFILES
logger = logging.getLogger(__name__)
router = APIRouter()
_key_manager: APIKeyManager = APIKeyManager()
def set_key_manager(mgr: APIKeyManager) -> None:
global _key_manager
_key_manager = mgr
def get_key_manager() -> APIKeyManager:
return _key_manager
class AddKeyRequest(BaseModel):
engine_type: str
api_key: str
source: str = "user"
class VerifyKeyRequest(BaseModel):
engine_type: str
@router.post("/")
async def add_key(
body: AddKeyRequest,
current_user: User = Depends(get_current_user),
):
source = KeySource.USER if body.source == "user" else KeySource.SYSTEM
config = get_key_manager().add_key(
engine_type=body.engine_type,
api_key=body.api_key,
source=source,
user_id=str(current_user.id),
)
return {
"engine_type": config.engine_type,
"key_hint": config.key_hint,
"status": KeyStatus.ACTIVE.value,
}
@router.get("/")
async def list_keys(
engine_type: str | None = Query(None),
current_user: User = Depends(get_current_user),
):
configs = get_key_manager().list_keys(engine_type=engine_type)
items = [
{
"engine_type": c.engine_type,
"key_hint": c.key_hint,
"source": c.key_source.value,
"status": c.status.value,
}
for c in configs
]
return {"items": items}
@router.post("/verify")
async def verify_key(
body: VerifyKeyRequest,
current_user: User = Depends(get_current_user),
):
mgr = get_key_manager()
api_key = mgr.get_key(body.engine_type, user_id=str(current_user.id))
if not api_key:
api_key = mgr.get_key(body.engine_type)
if not api_key:
api_key = mgr.get_any_available_key(body.engine_type)
if not api_key:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"No API Key configured for {body.engine_type}",
)
key_status = await mgr.verify_key(body.engine_type, api_key)
return {
"engine_type": body.engine_type,
"status": key_status.value,
}
@router.get("/engines")
async def get_engines(
current_user: User = Depends(get_current_user),
):
engines = [
{
"type": profile.engine_type,
"cost_tier": profile.cost_tier.value,
"has_free_tier": profile.has_free_tier,
"requires_own_key": profile.requires_own_key,
"input_price": profile.input_price_per_million,
"output_price": profile.output_price_per_million,
}
for profile in ENGINE_COST_PROFILES.values()
]
return {"engines": engines}
@router.delete("/{engine_type}/{key_hint}")
async def delete_key(
engine_type: str,
key_hint: str,
current_user: User = Depends(get_current_user),
):
deleted = get_key_manager().remove_key(engine_type, key_hint)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="API Key not found",
)
return {"deleted": True}

73
backend/app/api/usage.py Normal file
View File

@ -0,0 +1,73 @@
import logging
from fastapi import APIRouter, Depends, Query
from app.api.deps import get_current_user
from app.models.user import User
from app.services.usage_tracker import UsageTracker
logger = logging.getLogger(__name__)
router = APIRouter()
_usage_tracker: UsageTracker = UsageTracker()
def set_usage_tracker(tracker: UsageTracker) -> None:
global _usage_tracker
_usage_tracker = tracker
def get_usage_tracker() -> UsageTracker:
return _usage_tracker
def _user_id(current_user: User) -> str:
return str(current_user.id)
@router.get("/summary")
async def get_usage_summary(
period: str = Query("month", pattern=r"^(day|week|month)$"),
current_user: User = Depends(get_current_user),
):
summary = get_usage_tracker().get_summary(
user_id=_user_id(current_user),
period=period,
)
return {
"period": summary.period,
"start_date": summary.start_date,
"end_date": summary.end_date,
"total_queries": summary.total_queries,
"total_input_tokens": summary.total_input_tokens,
"total_output_tokens": summary.total_output_tokens,
"total_cost": summary.total_cost,
"by_engine": summary.by_engine,
}
@router.get("/quota")
async def get_quota(
current_user: User = Depends(get_current_user),
):
return get_usage_tracker().check_quota(user_id=_user_id(current_user))
@router.get("/by-engine")
async def get_usage_by_engine(
current_user: User = Depends(get_current_user),
):
summary = get_usage_tracker().get_summary(
user_id=_user_id(current_user),
period="month",
)
engines = [
{
"type": engine_type,
"queries": data["queries"],
"cost": data["cost"],
}
for engine_type, data in summary.by_engine.items()
]
return {"engines": engines}

View File

@ -40,6 +40,8 @@ from app.api.image import router as image_router
from app.api.knowledge_graph import router as knowledge_graph_router
from app.api.ai_engines import router as ai_engines_router
from app.api.detection import router as detection_router
from app.api.api_keys import router as api_keys_router
from app.api.usage import router as usage_router
from app.config import settings
from app.database import engine, Base
from app.schemas.common import ErrorResponse, ErrorCode
@ -169,6 +171,8 @@ app.include_router(image_router, prefix="/api/v1")
app.include_router(knowledge_graph_router, prefix="/api/v1/knowledge-bases")
app.include_router(ai_engines_router, prefix="/api/v1/ai-engines", tags=["AI引擎查询"])
app.include_router(detection_router, prefix="/api/v1/detection", tags=["定时检测任务"])
app.include_router(api_keys_router, prefix="/api/v1/api-keys", tags=["API Key管理"])
app.include_router(usage_router, prefix="/api/v1/usage", tags=["用量追踪"])
@app.get("/health", tags=["可观测性"])

View File

@ -91,6 +91,12 @@ class APIKeyManager:
return self._decrypt(c.encrypted_key)
return None
def get_any_available_key(self, engine_type: str) -> str | None:
for c in self._keys.get(engine_type, []):
if c.status in self._USABLE_STATUSES:
return self._decrypt(c.encrypted_key)
return None
def remove_key(self, engine_type: str, key_hint: str) -> bool:
configs = self._keys.get(engine_type, [])
for i, c in enumerate(configs):

View File

@ -0,0 +1,418 @@
import uuid
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from app.api.deps import get_current_user, get_db
from app.database import Base
from app.main import app
from app.models.user import User
from app.services.api_key_manager import APIKeyManager, KeySource
from app.services.auth import hash_password
from app.services.usage_tracker import UsageTracker
@pytest_asyncio.fixture
async def async_engine():
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest_asyncio.fixture
async def async_session(async_engine):
async_session_maker = async_sessionmaker(
async_engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
autocommit=False,
)
async with async_session_maker() as session:
yield session
@pytest_asyncio.fixture
async def test_user(async_session):
user = User(
id=uuid.uuid4(),
email="test@example.com",
password_hash=hash_password("Test@123456"),
name="Test User",
plan="free",
max_queries=5,
is_active=True,
email_verified=True,
)
async_session.add(user)
await async_session.commit()
await async_session.refresh(user)
return user
@pytest_asyncio.fixture
def key_manager():
return APIKeyManager()
@pytest_asyncio.fixture
def usage_tracker(test_user):
uid = str(test_user.id)
tracker = UsageTracker()
tracker.record(
user_id=uid,
brand_id="brand-1",
engine_type="deepseek",
query="test query",
input_tokens=100,
output_tokens=200,
cost=0.01,
)
tracker.record(
user_id=uid,
brand_id="brand-1",
engine_type="chatgpt",
query="test query 2",
input_tokens=500,
output_tokens=1000,
cost=0.05,
)
return tracker
@pytest_asyncio.fixture
async def async_client(async_session, test_user, key_manager, usage_tracker):
async def override_get_db():
yield async_session
async def override_get_current_user():
return test_user
app.dependency_overrides[get_db] = override_get_db
app.dependency_overrides[get_current_user] = override_get_current_user
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
app.dependency_overrides.clear()
class TestAddAPIKey:
@pytest.mark.asyncio
async def test_add_key_success(self, async_client, key_manager):
from app.api.api_keys import set_key_manager
set_key_manager(key_manager)
response = await async_client.post(
"/api/v1/api-keys/",
json={
"engine_type": "chatgpt",
"api_key": "sk-abcdef1234567890",
"source": "user",
},
)
assert response.status_code == 200
data = response.json()
assert data["engine_type"] == "chatgpt"
assert "key_hint" in data
assert data["key_hint"].startswith("sk-")
assert data["status"] == "active"
assert "api_key" not in data
@pytest.mark.asyncio
async def test_add_key_no_plaintext_in_response(self, async_client, key_manager):
from app.api.api_keys import set_key_manager
set_key_manager(key_manager)
raw_key = "sk-super-secret-key-12345678"
response = await async_client.post(
"/api/v1/api-keys/",
json={
"engine_type": "deepseek",
"api_key": raw_key,
"source": "user",
},
)
assert response.status_code == 200
text = response.text
assert raw_key not in text
class TestListAPIKeys:
@pytest.mark.asyncio
async def test_list_keys_success(self, async_client, key_manager):
from app.api.api_keys import set_key_manager
set_key_manager(key_manager)
key_manager.add_key("chatgpt", "sk-abcdef1234567890", source=KeySource.USER)
key_manager.add_key("deepseek", "dsk-xyz9876543210", source=KeySource.ENV)
response = await async_client.get("/api/v1/api-keys/")
assert response.status_code == 200
data = response.json()
assert "items" in data
assert len(data["items"]) == 2
@pytest.mark.asyncio
async def test_list_keys_filter_by_engine(self, async_client, key_manager):
from app.api.api_keys import set_key_manager
set_key_manager(key_manager)
key_manager.add_key("chatgpt", "sk-abcdef1234567890", source=KeySource.USER)
key_manager.add_key("deepseek", "dsk-xyz9876543210", source=KeySource.ENV)
response = await async_client.get("/api/v1/api-keys/?engine_type=chatgpt")
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 1
assert data["items"][0]["engine_type"] == "chatgpt"
@pytest.mark.asyncio
async def test_list_keys_masked(self, async_client, key_manager):
from app.api.api_keys import set_key_manager
set_key_manager(key_manager)
key_manager.add_key("chatgpt", "sk-abcdef1234567890", source=KeySource.USER)
response = await async_client.get("/api/v1/api-keys/")
assert response.status_code == 200
data = response.json()
for item in data["items"]:
assert "api_key" not in item
assert "encrypted_key" not in item
assert "key_hint" in item
class TestDeleteAPIKey:
@pytest.mark.asyncio
async def test_delete_key_success(self, async_client, key_manager):
from app.api.api_keys import set_key_manager
set_key_manager(key_manager)
config = key_manager.add_key("chatgpt", "sk-abcdef1234567890", source=KeySource.USER)
response = await async_client.delete(
f"/api/v1/api-keys/chatgpt/{config.key_hint}"
)
assert response.status_code == 200
data = response.json()
assert data["deleted"] is True
@pytest.mark.asyncio
async def test_delete_key_not_found(self, async_client, key_manager):
from app.api.api_keys import set_key_manager
set_key_manager(key_manager)
response = await async_client.delete("/api/v1/api-keys/chatgpt/nonexistent")
assert response.status_code == 404
class TestVerifyAPIKey:
@pytest.mark.asyncio
async def test_verify_key_success(self, async_client, key_manager):
from app.api.api_keys import set_key_manager
set_key_manager(key_manager)
key_manager.add_key("chatgpt", "sk-abcdef1234567890", source=KeySource.USER)
response = await async_client.post(
"/api/v1/api-keys/verify",
json={"engine_type": "chatgpt"},
)
assert response.status_code == 200
data = response.json()
assert data["engine_type"] == "chatgpt"
assert data["status"] == "active"
@pytest.mark.asyncio
async def test_verify_key_no_key_configured(self, async_client, key_manager):
from app.api.api_keys import set_key_manager
set_key_manager(key_manager)
response = await async_client.post(
"/api/v1/api-keys/verify",
json={"engine_type": "perplexity"},
)
assert response.status_code == 404
class TestGetEngines:
@pytest.mark.asyncio
async def test_get_engines_success(self, async_client):
response = await async_client.get("/api/v1/api-keys/engines")
assert response.status_code == 200
data = response.json()
assert "engines" in data
assert len(data["engines"]) > 0
engine = data["engines"][0]
assert "type" in engine
assert "cost_tier" in engine
assert "has_free_tier" in engine
assert "requires_own_key" in engine
@pytest.mark.asyncio
async def test_get_engines_contains_deepseek(self, async_client):
response = await async_client.get("/api/v1/api-keys/engines")
assert response.status_code == 200
data = response.json()
types = [e["type"] for e in data["engines"]]
assert "deepseek" in types
deepseek = next(e for e in data["engines"] if e["type"] == "deepseek")
assert deepseek["cost_tier"] == "free"
assert deepseek["has_free_tier"] is True
assert deepseek["requires_own_key"] is False
assert "input_price" in deepseek
assert "output_price" in deepseek
class TestUsageSummary:
@pytest.mark.asyncio
async def test_get_usage_summary(self, async_client, usage_tracker):
from app.api.usage import set_usage_tracker
set_usage_tracker(usage_tracker)
response = await async_client.get("/api/v1/usage/summary?period=month")
assert response.status_code == 200
data = response.json()
assert data["period"] == "month"
assert data["total_queries"] == 2
assert data["total_cost"] > 0
assert "by_engine" in data
@pytest.mark.asyncio
async def test_get_usage_summary_day_period(self, async_client, usage_tracker):
from app.api.usage import set_usage_tracker
set_usage_tracker(usage_tracker)
response = await async_client.get("/api/v1/usage/summary?period=day")
assert response.status_code == 200
data = response.json()
assert data["period"] == "day"
class TestUsageQuota:
@pytest.mark.asyncio
async def test_get_quota(self, async_client, usage_tracker):
from app.api.usage import set_usage_tracker
set_usage_tracker(usage_tracker)
response = await async_client.get("/api/v1/usage/quota")
assert response.status_code == 200
data = response.json()
assert "used" in data
assert "limit" in data
assert "usage_percentage" in data
assert "status" in data
assert data["status"] in ("ok", "warning", "exceeded")
@pytest.mark.asyncio
async def test_get_quota_ok_status(self, async_client, usage_tracker):
from app.api.usage import set_usage_tracker
set_usage_tracker(usage_tracker)
response = await async_client.get("/api/v1/usage/quota")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
assert data["usage_percentage"] < 100
class TestUsageByEngine:
@pytest.mark.asyncio
async def test_get_usage_by_engine(self, async_client, usage_tracker):
from app.api.usage import set_usage_tracker
set_usage_tracker(usage_tracker)
response = await async_client.get("/api/v1/usage/by-engine")
assert response.status_code == 200
data = response.json()
assert "engines" in data
assert len(data["engines"]) > 0
engine = data["engines"][0]
assert "type" in engine
assert "queries" in engine
assert "cost" in engine
@pytest.mark.asyncio
async def test_get_usage_by_engine_contains_data(self, async_client, usage_tracker):
from app.api.usage import set_usage_tracker
set_usage_tracker(usage_tracker)
response = await async_client.get("/api/v1/usage/by-engine")
assert response.status_code == 200
data = response.json()
types = [e["type"] for e in data["engines"]]
assert "deepseek" in types
assert "chatgpt" in types
class TestUnauthorizedAccess:
@pytest.mark.asyncio
async def test_api_keys_unauthorized_returns_401(self, async_session):
async def override_get_db():
yield async_session
app.dependency_overrides[get_db] = override_get_db
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
headers = {"Authorization": "Bearer invalid_token"}
response = await client.get("/api/v1/api-keys/", headers=headers)
assert response.status_code == 401
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_usage_unauthorized_returns_401(self, async_session):
async def override_get_db():
yield async_session
app.dependency_overrides[get_db] = override_get_db
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
headers = {"Authorization": "Bearer invalid_token"}
response = await client.get("/api/v1/usage/summary", headers=headers)
assert response.status_code == 401
app.dependency_overrides.clear()

View File

@ -17,8 +17,18 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Crown, Check, X, Loader2, AlertTriangle, CheckCircle, Compass, Bell, TrendingDown, TrendingUp, Users, Globe } from "lucide-react";
import { Crown, Check, X, Loader2, AlertTriangle, CheckCircle, Compass, Bell, TrendingDown, TrendingUp, Users, Globe, Key, Eye, EyeOff, Shield, Zap, CircleDot, Info } from "lucide-react";
import { api } from "@/lib/api";
import { fetchWithAuth } from "@/lib/api/client";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface PlanFeature {
name: string;
@ -623,7 +633,7 @@ function AlertSettingsTab() {
setLoading(true);
setError(null);
try {
const data = await api.alerts.getSettings(token, selectedBrandId);
const data = await api.alerts.getSettings(selectedBrandId, token);
setSettings((data as { items: AlertSettingItem[] }).items || []);
} catch (err) {
setError(err instanceof Error ? err.message : "加载告警设置失败");
@ -644,7 +654,7 @@ function AlertSettingsTab() {
enabled: s.enabled,
threshold: s.threshold ?? undefined,
}));
await api.alerts.updateSettings(token, updateData);
await api.alerts.updateSettings(updateData, token);
setSuccess(true);
setTimeout(() => setSuccess(false), 3000);
} catch (err) {
@ -860,6 +870,404 @@ function AlertSettingsTab() {
);
}
type TierType = "free" | "low" | "mid" | "high";
type KeyStatus = "configured" | "unconfigured" | "validating" | "invalid";
interface EngineProfile {
label: string;
group: "domestic" | "international";
tier: TierType;
inputPrice: number;
outputPrice: number;
hasFreeTier: boolean;
requiresOwnKey: boolean;
envVar: string;
}
const ENGINE_PROFILES: Record<string, EngineProfile> = {
deepseek: { label: "DeepSeek", group: "domestic", tier: "free", inputPrice: 0.25, outputPrice: 6.0, hasFreeTier: true, requiresOwnKey: false, envVar: "DEEPSEEK_API_KEY" },
qwen: { label: "通义千问", group: "domestic", tier: "free", inputPrice: 0.3, outputPrice: 0.6, hasFreeTier: true, requiresOwnKey: false, envVar: "DASHSCOPE_API_KEY" },
wenxin: { label: "文心一言", group: "domestic", tier: "free", inputPrice: 0.012, outputPrice: 0.012, hasFreeTier: true, requiresOwnKey: false, envVar: "BAIDU_QIANFAN_API_KEY" },
kimi: { label: "Kimi", group: "domestic", tier: "low", inputPrice: 12.0, outputPrice: 12.0, hasFreeTier: true, requiresOwnKey: false, envVar: "MOONSHOT_API_KEY" },
doubao: { label: "豆包", group: "domestic", tier: "low", inputPrice: 0.5, outputPrice: 0.9, hasFreeTier: true, requiresOwnKey: false, envVar: "DOUBAO_API_KEY" },
gemini: { label: "Google Gemini", group: "international", tier: "low", inputPrice: 0.5, outputPrice: 2.0, hasFreeTier: true, requiresOwnKey: false, envVar: "GOOGLE_API_KEY" },
yuanbao: { label: "腾讯元宝", group: "domestic", tier: "mid", inputPrice: 0.8, outputPrice: 2.0, hasFreeTier: true, requiresOwnKey: false, envVar: "HUNYUAN_API_KEY" },
chatgpt: { label: "ChatGPT", group: "international", tier: "high", inputPrice: 1.0, outputPrice: 4.0, hasFreeTier: false, requiresOwnKey: true, envVar: "OPENAI_API_KEY" },
perplexity: { label: "Perplexity", group: "international", tier: "high", inputPrice: 35.0, outputPrice: 35.0, hasFreeTier: false, requiresOwnKey: true, envVar: "PERPLEXITY_API_KEY" },
};
const TIER_CONFIG: Record<TierType, { label: string; className: string }> = {
free: { label: "免费", className: "bg-emerald-100 text-emerald-700 hover:bg-emerald-100" },
low: { label: "低成本", className: "bg-blue-100 text-blue-700 hover:bg-blue-100" },
mid: { label: "中成本", className: "bg-amber-100 text-amber-700 hover:bg-amber-100" },
high: { label: "高成本", className: "bg-red-100 text-red-700 hover:bg-red-100" },
};
const KEY_STATUS_CONFIG: Record<KeyStatus, { label: string; className: string; icon: React.ElementType }> = {
configured: { label: "已配置", className: "bg-emerald-100 text-emerald-700", icon: CheckCircle },
unconfigured: { label: "未配置", className: "bg-gray-100 text-gray-500", icon: X },
validating: { label: "验证中", className: "bg-blue-100 text-blue-700", icon: Loader2 },
invalid: { label: "无效", className: "bg-red-100 text-red-700", icon: AlertTriangle },
};
function ApiConfigTab() {
const { data: session } = useSession();
const token = session?.accessToken;
const [keyStatuses, setKeyStatuses] = useState<Record<string, KeyStatus>>({});
const [dialogOpen, setDialogOpen] = useState(false);
const [selectedEngine, setSelectedEngine] = useState<string>("");
const [apiKeyInput, setApiKeyInput] = useState("");
const [showKey, setShowKey] = useState(false);
const [saving, setSaving] = useState(false);
const [validating, setValidating] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
useEffect(() => {
loadKeyStatuses();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
async function loadKeyStatuses() {
if (!token) return;
try {
const data = await fetchWithAuth("/api/v1/api-keys/", {}, token) as { items: { engine_type: string; status: string; key_hint: string; source: string }[] };
const statuses: Record<string, KeyStatus> = {};
for (const k of data.items) {
statuses[k.engine_type] = k.status === "active" ? "configured" : k.status === "invalid" ? "invalid" : "configured";
}
Object.keys(ENGINE_PROFILES).forEach((engine) => {
if (!(engine in statuses)) {
statuses[engine] = "unconfigured";
}
});
setKeyStatuses(statuses);
} catch {
const statuses: Record<string, KeyStatus> = {};
Object.keys(ENGINE_PROFILES).forEach((engine) => {
statuses[engine] = "unconfigured";
});
setKeyStatuses(statuses);
}
}
async function handleAddKey() {
if (!token || !selectedEngine || !apiKeyInput.trim()) return;
setSaving(true);
setError(null);
try {
await fetchWithAuth("/api/v1/api-keys/", {
method: "POST",
body: JSON.stringify({ engine_type: selectedEngine, api_key: apiKeyInput.trim(), source: "user" }),
}, token);
setKeyStatuses((prev) => ({ ...prev, [selectedEngine]: "configured" }));
setSuccess(`${ENGINE_PROFILES[selectedEngine].label} API Key 已保存`);
setDialogOpen(false);
setSelectedEngine("");
setApiKeyInput("");
setTimeout(() => setSuccess(null), 3000);
} catch (err) {
setError(err instanceof Error ? err.message : "保存失败");
} finally {
setSaving(false);
}
}
async function handleDeleteKey(engine: string) {
if (!token) return;
try {
const listData = await fetchWithAuth("/api/v1/api-keys/", {}, token) as { items: { engine_type: string; key_hint: string; source: string; status: string }[] };
const keyItem = listData.items.find((k) => k.engine_type === engine);
if (!keyItem) {
setError("未找到该引擎的API Key");
return;
}
await fetchWithAuth(`/api/v1/api-keys/${engine}/${encodeURIComponent(keyItem.key_hint)}`, {
method: "DELETE",
}, token);
setKeyStatuses((prev) => ({ ...prev, [engine]: "unconfigured" }));
setSuccess(`${ENGINE_PROFILES[engine].label} API Key 已删除`);
setTimeout(() => setSuccess(null), 3000);
} catch (err) {
setError(err instanceof Error ? err.message : "删除失败");
}
}
async function handleValidateKey(engine: string) {
if (!token) return;
setValidating(engine);
setKeyStatuses((prev) => ({ ...prev, [engine]: "validating" }));
try {
const data = await fetchWithAuth("/api/v1/api-keys/verify", {
method: "POST",
body: JSON.stringify({ engine_type: engine }),
}, token) as { engine_type: string; status: string };
const isValid = data.status === "active";
setKeyStatuses((prev) => ({ ...prev, [engine]: isValid ? "configured" : "invalid" }));
setSuccess(isValid ? `${ENGINE_PROFILES[engine].label} API Key 验证通过` : `${ENGINE_PROFILES[engine].label} API Key 无效`);
setTimeout(() => setSuccess(null), 3000);
} catch {
setKeyStatuses((prev) => ({ ...prev, [engine]: "invalid" }));
setError("验证请求失败");
setTimeout(() => setError(null), 3000);
} finally {
setValidating(null);
}
}
function openAddDialog(engine?: string) {
setSelectedEngine(engine || "");
setApiKeyInput("");
setShowKey(false);
setError(null);
setDialogOpen(true);
}
const domesticEngines = Object.entries(ENGINE_PROFILES).filter(([, p]) => p.group === "domestic");
const internationalEngines = Object.entries(ENGINE_PROFILES).filter(([, p]) => p.group === "international");
function renderEngineCard(engineKey: string, profile: EngineProfile) {
const status = keyStatuses[engineKey] || "unconfigured";
const statusConfig = KEY_STATUS_CONFIG[status];
const tierConfig = TIER_CONFIG[profile.tier];
const StatusIcon = statusConfig.icon;
const isValidating = validating === engineKey;
return (
<div key={engineKey} className="flex flex-col gap-3 rounded-lg border p-4">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-muted">
<Key className="h-4 w-4 text-muted-foreground" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{profile.label}</span>
<Badge className={tierConfig.className}>{tierConfig.label}</Badge>
</div>
<p className="text-xs text-muted-foreground mt-0.5">
¥{profile.inputPrice}/Token · ¥{profile.outputPrice}/Token
</p>
</div>
</div>
<Badge className={statusConfig.className}>
<StatusIcon className={`mr-1 h-3 w-3 ${isValidating ? "animate-spin" : ""}`} />
{statusConfig.label}
</Badge>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{profile.hasFreeTier && (
<span className="flex items-center gap-1">
<Zap className="h-3 w-3 text-emerald-500" />
</span>
)}
{profile.requiresOwnKey && (
<span className="flex items-center gap-1">
<Shield className="h-3 w-3 text-amber-500" />
Key
</span>
)}
<span className="flex items-center gap-1">
<CircleDot className="h-3 w-3" />
{profile.envVar}
</span>
</div>
<div className="flex items-center gap-2 mt-auto">
{status === "unconfigured" ? (
<Button size="sm" onClick={() => openAddDialog(engineKey)}>
<Key className="mr-1 h-3 w-3" />
Key
</Button>
) : (
<>
<Button
size="sm"
variant="outline"
onClick={() => handleValidateKey(engineKey)}
disabled={isValidating}
>
{isValidating ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : (
<Shield className="mr-1 h-3 w-3" />
)}
</Button>
<Button
size="sm"
variant="outline"
className="text-destructive hover:text-destructive"
onClick={() => handleDeleteKey(engineKey)}
disabled={isValidating}
>
</Button>
</>
)}
</div>
</div>
);
}
return (
<div className="space-y-6">
{error && (
<div className="flex items-center gap-2 rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
<AlertTriangle className="h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
)}
{success && (
<div className="flex items-center gap-2 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700">
<CheckCircle className="h-4 w-4 shrink-0" />
<span>{success}</span>
</div>
)}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base flex items-center gap-2">
<Key className="h-5 w-5 text-blue-600" />
</CardTitle>
<CardDescription className="mt-1">AI引擎API密钥配置</CardDescription>
</div>
<Button size="sm" variant="outline" onClick={() => openAddDialog()}>
<Key className="mr-1 h-3 w-3" />
Key
</Button>
</div>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{domesticEngines.map(([key, profile]) => renderEngineCard(key, profile))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Globe className="h-5 w-5 text-purple-600" />
</CardTitle>
<CardDescription>AI引擎API密钥配置</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{internationalEngines.map(([key, profile]) => renderEngineCard(key, profile))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Info className="h-5 w-5 text-muted-foreground" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3 text-sm">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{(["free", "low", "mid", "high"] as TierType[]).map((tier) => (
<div key={tier} className="rounded-lg border p-3">
<Badge className={TIER_CONFIG[tier].className}>{TIER_CONFIG[tier].label}</Badge>
<p className="mt-2 text-xs text-muted-foreground">
{tier === "free" && "输入/输出价格极低,含免费额度"}
{tier === "low" && "输入/输出价格较低,部分含免费额度"}
{tier === "mid" && "中等价格,适合高频调用场景"}
{tier === "high" && "价格较高需自带API Key"}
</p>
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
/Token"需自带Key"KeyAPI密钥
</p>
</div>
</CardContent>
</Card>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>API Key</DialogTitle>
<DialogDescription>AI引擎配置API密钥</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label></Label>
<Select value={selectedEngine} onValueChange={setSelectedEngine}>
<SelectTrigger>
<SelectValue placeholder="请选择引擎" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel></SelectLabel>
{domesticEngines.map(([key, profile]) => (
<SelectItem key={key} value={key}>{profile.label}</SelectItem>
))}
</SelectGroup>
<SelectGroup>
<SelectLabel></SelectLabel>
{internationalEngines.map(([key, profile]) => (
<SelectItem key={key} value={key}>{profile.label}</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>API Key</Label>
<div className="relative">
<Input
type={showKey ? "text" : "password"}
placeholder="请输入API Key"
value={apiKeyInput}
onChange={(e) => setApiKeyInput(e.target.value)}
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3"
onClick={() => setShowKey(!showKey)}
>
{showKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
{selectedEngine && ENGINE_PROFILES[selectedEngine] && (
<p className="text-xs text-muted-foreground">
: {ENGINE_PROFILES[selectedEngine].envVar}
</p>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={saving}>
</Button>
<Button onClick={handleAddKey} disabled={saving || !selectedEngine || !apiKeyInput.trim()}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
export default function SettingsPage() {
return (
<div className="space-y-6">
@ -873,6 +1281,7 @@ export default function SettingsPage() {
<TabsTrigger value="profile"></TabsTrigger>
<TabsTrigger value="password"></TabsTrigger>
<TabsTrigger value="subscription"></TabsTrigger>
<TabsTrigger value="api">API配置</TabsTrigger>
<TabsTrigger value="alerts"></TabsTrigger>
<TabsTrigger value="onboarding"></TabsTrigger>
</TabsList>
@ -901,6 +1310,9 @@ export default function SettingsPage() {
<TabsContent value="subscription" className="mt-4">
<SubscriptionTab />
</TabsContent>
<TabsContent value="api" className="mt-4">
<ApiConfigTab />
</TabsContent>
<TabsContent value="alerts" className="mt-4">
<AlertSettingsTab />
</TabsContent>

View File

@ -0,0 +1,396 @@
"use client";
import { useState, useMemo } from "react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
DollarSign,
TrendingUp,
AlertTriangle,
CheckCircle,
Loader2,
RefreshCw,
} from "lucide-react";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Cell,
Legend,
} from "recharts";
import { useApi } from "@/lib/hooks/use-api";
type TimeRange = "7d" | "30d" | "month";
const TIME_RANGE_LABELS: Record<TimeRange, string> = {
"7d": "最近7天",
"30d": "最近30天",
month: "本月",
};
const PIE_COLORS = [
"#3b82f6",
"#8b5cf6",
"#ec4899",
"#f59e0b",
"#10b981",
"#6366f1",
"#ef4444",
"#14b8a6",
"#f97316",
];
interface QuotaData {
used: number;
limit: number;
percentage: number;
status: "normal" | "warning" | "exceeded";
}
interface TrendItem {
date: string;
cost: number;
}
interface EngineUsageItem {
engine: string;
label: string;
queries: number;
inputTokens: number;
outputTokens: number;
cost: number;
}
interface UsageData {
quota: QuotaData;
trends: TrendItem[];
engineDistribution: { engine: string; label: string; cost: number }[];
engineUsage: EngineUsageItem[];
}
const MOCK_USAGE_DATA: UsageData = {
quota: { used: 45.6, limit: 100, percentage: 45.6, status: "normal" },
trends: [
{ date: "05-19", cost: 5.2 },
{ date: "05-20", cost: 6.8 },
{ date: "05-21", cost: 4.5 },
{ date: "05-22", cost: 7.3 },
{ date: "05-23", cost: 8.1 },
{ date: "05-24", cost: 6.9 },
{ date: "05-25", cost: 7.0 },
],
engineDistribution: [
{ engine: "deepseek", label: "DeepSeek", cost: 15.2 },
{ engine: "chatgpt", label: "ChatGPT", cost: 12.8 },
{ engine: "qwen", label: "通义千问", cost: 8.5 },
{ engine: "gemini", label: "Google Gemini", cost: 5.3 },
{ engine: "kimi", label: "Kimi", cost: 3.8 },
],
engineUsage: [
{ engine: "deepseek", label: "DeepSeek", queries: 1250, inputTokens: 3200000, outputTokens: 1800000, cost: 15.2 },
{ engine: "chatgpt", label: "ChatGPT", queries: 890, inputTokens: 2100000, outputTokens: 1500000, cost: 12.8 },
{ engine: "qwen", label: "通义千问", queries: 720, inputTokens: 1800000, outputTokens: 900000, cost: 8.5 },
{ engine: "gemini", label: "Google Gemini", queries: 450, inputTokens: 1100000, outputTokens: 600000, cost: 5.3 },
{ engine: "kimi", label: "Kimi", queries: 320, inputTokens: 800000, outputTokens: 400000, cost: 3.8 },
{ engine: "wenxin", label: "文心一言", queries: 280, inputTokens: 700000, outputTokens: 350000, cost: 0.02 },
{ engine: "doubao", label: "豆包", queries: 210, inputTokens: 520000, outputTokens: 280000, cost: 0.5 },
{ engine: "yuanbao", label: "腾讯元宝", queries: 150, inputTokens: 380000, outputTokens: 200000, cost: 0.7 },
{ engine: "perplexity", label: "Perplexity", queries: 30, inputTokens: 75000, outputTokens: 40000, cost: 2.6 },
],
};
function formatTokenCount(count: number): string {
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
if (count >= 1000) return `${(count / 1000).toFixed(0)}K`;
return String(count);
}
function formatCurrency(value: number): string {
return `¥${value.toFixed(2)}`;
}
function CircularProgress({ percentage, size = 120, strokeWidth = 10 }: { percentage: number; size?: number; strokeWidth?: number }) {
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const offset = circumference - (percentage / 100) * circumference;
const color =
percentage >= 90 ? "#ef4444" : percentage >= 70 ? "#f59e0b" : "#10b981";
return (
<div className="relative inline-flex items-center justify-center">
<svg width={size} height={size} className="-rotate-90">
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
className="text-muted/30"
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
className="transition-all duration-500"
/>
</svg>
<div className="absolute flex flex-col items-center">
<span className="text-2xl font-bold" style={{ color }}>
{percentage.toFixed(1)}%
</span>
<span className="text-xs text-muted-foreground">使</span>
</div>
</div>
);
}
function QuotaStatusBadge({ status }: { status: "normal" | "warning" | "exceeded" }) {
const config = {
normal: { label: "正常", className: "bg-emerald-100 text-emerald-700 hover:bg-emerald-100", icon: CheckCircle },
warning: { label: "预警", className: "bg-amber-100 text-amber-700 hover:bg-amber-100", icon: AlertTriangle },
exceeded: { label: "超限", className: "bg-red-100 text-red-700 hover:bg-red-100", icon: AlertTriangle },
};
const c = config[status];
const Icon = c.icon;
return (
<Badge className={c.className}>
<Icon className="mr-1 h-3 w-3" />
{c.label}
</Badge>
);
}
export default function UsagePage() {
const [timeRange, setTimeRange] = useState<TimeRange>("7d");
const { data: usageData, isLoading, refresh } = useApi<UsageData>(
`/api/v1/usage/summary?period=${timeRange === "7d" ? "week" : timeRange === "30d" ? "month" : "month"}`
);
const data = usageData || MOCK_USAGE_DATA;
const totalCost = useMemo(() => {
return data.engineUsage.reduce((sum, item) => sum + item.cost, 0);
}, [data.engineUsage]);
const totalQueries = useMemo(() => {
return data.engineUsage.reduce((sum, item) => sum + item.queries, 0);
}, [data.engineUsage]);
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground">AI引擎调用成本和配额使用情况</p>
</div>
<div className="flex items-center gap-2">
{(["7d", "30d", "month"] as TimeRange[]).map((range) => (
<Button
key={range}
variant={timeRange === range ? "default" : "outline"}
size="sm"
onClick={() => setTimeRange(range)}
>
{TIME_RANGE_LABELS[range]}
</Button>
))}
<Button variant="outline" size="sm" onClick={refresh} disabled={isLoading}>
<RefreshCw className={`mr-1 h-3 w-3 ${isLoading ? "animate-spin" : ""}`} />
</Button>
</div>
</div>
{isLoading ? (
<div className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="mt-4 text-sm text-muted-foreground">...</p>
</div>
) : (
<>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-blue-100 p-2">
<DollarSign className="h-5 w-5 text-blue-600" />
</div>
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="text-2xl font-bold">{formatCurrency(data.quota.used)}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-purple-100 p-2">
<TrendingUp className="h-5 w-5 text-purple-600" />
</div>
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="text-2xl font-bold">{formatCurrency(data.quota.limit)}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-emerald-100 p-2">
<CheckCircle className="h-5 w-5 text-emerald-600" />
</div>
<div>
<p className="text-sm text-muted-foreground"></p>
<QuotaStatusBadge status={data.quota.status} />
</div>
</div>
<CircularProgress percentage={data.quota.percentage} size={80} strokeWidth={8} />
</div>
</CardContent>
</Card>
</div>
<div className="grid gap-6 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="text-base"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data.trends}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="date"
tick={{ fontSize: 12 }}
className="text-muted-foreground"
/>
<YAxis
tick={{ fontSize: 12 }}
className="text-muted-foreground"
tickFormatter={(v) => `¥${v}`}
/>
<Tooltip
formatter={(value: number) => [formatCurrency(value), "成本"]}
labelFormatter={(label) => `日期: ${label}`}
/>
<Line
type="monotone"
dataKey="cost"
stroke="#3b82f6"
strokeWidth={2}
dot={{ r: 4, fill: "#3b82f6" }}
activeDot={{ r: 6 }}
/>
</LineChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data.engineDistribution}
cx="50%"
cy="45%"
innerRadius={50}
outerRadius={80}
paddingAngle={2}
dataKey="cost"
nameKey="label"
>
{data.engineDistribution.map((_, index) => (
<Cell key={`cell-${index}`} fill={PIE_COLORS[index % PIE_COLORS.length]} />
))}
</Pie>
<Tooltip
formatter={(value: number) => [formatCurrency(value), "成本"]}
/>
<Legend
verticalAlign="bottom"
iconType="circle"
iconSize={8}
formatter={(value: string) => (
<span className="text-xs">{value}</span>
)}
/>
</PieChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
<CardDescription>
{totalQueries} {formatCurrency(totalCost)}
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right">Token</TableHead>
<TableHead className="text-right">Token</TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.engineUsage.map((item) => (
<TableRow key={item.engine}>
<TableCell className="font-medium">{item.label}</TableCell>
<TableCell className="text-right">{item.queries.toLocaleString()}</TableCell>
<TableCell className="text-right">{formatTokenCount(item.inputTokens)}</TableCell>
<TableCell className="text-right">{formatTokenCount(item.outputTokens)}</TableCell>
<TableCell className="text-right font-medium">{formatCurrency(item.cost)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</>
)}
</div>
);
}