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:
parent
41c2994222
commit
290ef5a273
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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=["可观测性"])
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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"的引擎不提供平台共享Key,需配置您自己的API密钥。
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue