diff --git a/backend/app/api/api_keys.py b/backend/app/api/api_keys.py new file mode 100644 index 0000000..667ca82 --- /dev/null +++ b/backend/app/api/api_keys.py @@ -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} diff --git a/backend/app/api/usage.py b/backend/app/api/usage.py new file mode 100644 index 0000000..d673fdc --- /dev/null +++ b/backend/app/api/usage.py @@ -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} diff --git a/backend/app/main.py b/backend/app/main.py index 524e4a1..473b3e9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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=["可观测性"]) diff --git a/backend/app/services/api_key_manager.py b/backend/app/services/api_key_manager.py index af5bcfd..9680835 100644 --- a/backend/app/services/api_key_manager.py +++ b/backend/app/services/api_key_manager.py @@ -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): diff --git a/backend/tests/test_api/test_api_keys_api.py b/backend/tests/test_api/test_api_keys_api.py new file mode 100644 index 0000000..32a9a7c --- /dev/null +++ b/backend/tests/test_api/test_api_keys_api.py @@ -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() diff --git a/frontend/app/(dashboard)/dashboard/settings/page.tsx b/frontend/app/(dashboard)/dashboard/settings/page.tsx index 6326f7a..730cf25 100644 --- a/frontend/app/(dashboard)/dashboard/settings/page.tsx +++ b/frontend/app/(dashboard)/dashboard/settings/page.tsx @@ -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 = { + 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 = { + 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 = { + 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>({}); + const [dialogOpen, setDialogOpen] = useState(false); + const [selectedEngine, setSelectedEngine] = useState(""); + const [apiKeyInput, setApiKeyInput] = useState(""); + const [showKey, setShowKey] = useState(false); + const [saving, setSaving] = useState(false); + const [validating, setValidating] = useState(null); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(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 = {}; + 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 = {}; + 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 ( +
+
+
+
+ +
+
+
+ {profile.label} + {tierConfig.label} +
+

+ 输入 ¥{profile.inputPrice}/百万Token · 输出 ¥{profile.outputPrice}/百万Token +

+
+
+ + + {statusConfig.label} + +
+ +
+ {profile.hasFreeTier && ( + + + 含免费额度 + + )} + {profile.requiresOwnKey && ( + + + 需自带Key + + )} + + + {profile.envVar} + +
+ +
+ {status === "unconfigured" ? ( + + ) : ( + <> + + + + )} +
+
+ ); + } + + return ( +
+ {error && ( +
+ + {error} +
+ )} + + {success && ( +
+ + {success} +
+ )} + + + +
+
+ + + 国内引擎 + + 国内AI引擎API密钥配置 +
+ +
+
+ +
+ {domesticEngines.map(([key, profile]) => renderEngineCard(key, profile))} +
+
+
+ + + + + + 国际引擎 + + 国际AI引擎API密钥配置(需科学上网环境) + + +
+ {internationalEngines.map(([key, profile]) => renderEngineCard(key, profile))} +
+
+
+ + + + + + 成本说明 + + 各引擎价格层级和免费额度信息 + + +
+
+ {(["free", "low", "mid", "high"] as TierType[]).map((tier) => ( +
+ {TIER_CONFIG[tier].label} +

+ {tier === "free" && "输入/输出价格极低,含免费额度"} + {tier === "low" && "输入/输出价格较低,部分含免费额度"} + {tier === "mid" && "中等价格,适合高频调用场景"} + {tier === "high" && "价格较高,需自带API Key"} +

+
+ ))} +
+

+ 价格单位:元/百万Token。实际费用以各引擎官方定价为准。标注"需自带Key"的引擎不提供平台共享Key,需配置您自己的API密钥。 +

+
+
+
+ + + + + 添加API Key + 为指定AI引擎配置API密钥 + +
+
+ + +
+
+ +
+ setApiKeyInput(e.target.value)} + className="pr-10" + /> + +
+ {selectedEngine && ENGINE_PROFILES[selectedEngine] && ( +

+ 环境变量名: {ENGINE_PROFILES[selectedEngine].envVar} +

+ )} +
+
+ + + + +
+
+
+ ); +} + export default function SettingsPage() { return (
@@ -873,6 +1281,7 @@ export default function SettingsPage() { 个人资料 密码修改 订阅管理 + API配置 告警设置 引导 @@ -901,6 +1310,9 @@ export default function SettingsPage() { + + + diff --git a/frontend/app/(dashboard)/dashboard/usage/page.tsx b/frontend/app/(dashboard)/dashboard/usage/page.tsx new file mode 100644 index 0000000..3f83014 --- /dev/null +++ b/frontend/app/(dashboard)/dashboard/usage/page.tsx @@ -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 = { + "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 ( +
+ + + + +
+ + {percentage.toFixed(1)}% + + 已使用 +
+
+ ); +} + +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 ( + + + {c.label} + + ); +} + +export default function UsagePage() { + const [timeRange, setTimeRange] = useState("7d"); + + const { data: usageData, isLoading, refresh } = useApi( + `/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 ( +
+
+
+

用量统计

+

监控AI引擎调用成本和配额使用情况

+
+
+ {(["7d", "30d", "month"] as TimeRange[]).map((range) => ( + + ))} + +
+
+ + {isLoading ? ( +
+ +

正在加载用量数据...

+
+ ) : ( + <> +
+ + +
+
+ +
+
+

本月已用金额

+

{formatCurrency(data.quota.used)}

+
+
+
+
+ + +
+
+ +
+
+

配额上限

+

{formatCurrency(data.quota.limit)}

+
+
+
+
+ + +
+
+
+ +
+
+

配额状态

+ +
+
+ +
+
+
+
+ +
+ + + 用量趋势 + 每日成本变化趋势 + + +
+ + + + + `¥${v}`} + /> + [formatCurrency(value), "成本"]} + labelFormatter={(label) => `日期: ${label}`} + /> + + + +
+
+
+ + + + 引擎用量分布 + 各引擎成本占比 + + +
+ + + + {data.engineDistribution.map((_, index) => ( + + ))} + + [formatCurrency(value), "成本"]} + /> + ( + {value} + )} + /> + + +
+
+
+
+ + + + 引擎用量明细 + + 共 {totalQueries} 次查询,总成本 {formatCurrency(totalCost)} + + + + + + + 引擎名称 + 查询次数 + 输入Token + 输出Token + 成本 + + + + {data.engineUsage.map((item) => ( + + {item.label} + {item.queries.toLocaleString()} + {formatTokenCount(item.inputTokens)} + {formatTokenCount(item.outputTokens)} + {formatCurrency(item.cost)} + + ))} + +
+
+
+ + )} +
+ ); +}