feat: 添加 API Key 锁定功能和请求头规则系统

1. API Key 锁定功能
- 管理员可锁定/解锁用户的 API Key
- 锁定后用户无法使用、修改或删除该 Key
- 前端显示锁定状态并禁用相关操作

2. 请求头规则系统
- 将 endpoint.headers 升级为 header_rules
- 支持 set(设置)、drop(删除)、rename(重命名)操作
- 前端提供可视化规则编辑界面
- 包含数据迁移脚本

Close #37 Close #86 Close #88
This commit is contained in:
fawney19
2026-01-16 01:18:54 +08:00
parent ea45918561
commit 8e11f3864a
22 changed files with 852 additions and 56 deletions

View File

@@ -210,6 +210,25 @@ async def delete_api_key(key_id: str, request: Request, db: Session = Depends(ge
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.patch("/{key_id}/lock")
async def toggle_lock_api_key(key_id: str, request: Request, db: Session = Depends(get_db)):
"""
切换 API Key 锁定状态
锁定/解锁指定的 API Key。锁定后用户无法使用和操作此密钥。
**路径参数**:
- `key_id`: API Key ID
**返回字段**:
- `id`: API Key ID
- `is_locked`: 新的锁定状态
- `message`: 提示信息
"""
adapter = AdminToggleLockApiKeyAdapter(key_id=key_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.patch("/{key_id}/balance")
async def add_balance_to_key(
key_id: str,
@@ -346,6 +365,7 @@ class AdminListStandaloneKeysAdapter(AdminApiAdapter):
"name": api_key.name,
"key_display": api_key.get_display_key(),
"is_active": api_key.is_active,
"is_locked": api_key.is_locked,
"is_standalone": api_key.is_standalone,
"current_balance_usd": api_key.current_balance_usd,
"balance_used_usd": float(api_key.balance_used_usd or 0),
@@ -541,6 +561,39 @@ class AdminToggleApiKeyAdapter(AdminApiAdapter):
}
class AdminToggleLockApiKeyAdapter(AdminApiAdapter):
"""切换API密钥锁定状态"""
def __init__(self, key_id: str):
self.key_id = key_id
async def handle(self, context): # type: ignore[override]
db = context.db
api_key = db.query(ApiKey).filter(ApiKey.id == self.key_id).first()
if not api_key:
raise NotFoundException("API密钥不存在", "api_key")
api_key.is_locked = not api_key.is_locked
api_key.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(api_key)
logger.info(f"管理员切换API密钥锁定状态: Key ID {self.key_id}, 新状态 {'锁定' if api_key.is_locked else '解锁'}")
context.add_audit_metadata(
action="toggle_lock_api_key",
target_key_id=api_key.id,
user_id=api_key.user_id,
new_lock_status="locked" if api_key.is_locked else "unlocked",
)
return {
"id": api_key.id,
"is_locked": api_key.is_locked,
"message": f"API密钥已{'锁定' if api_key.is_locked else '解锁'}",
}
class AdminDeleteApiKeyAdapter(AdminApiAdapter):
def __init__(self, key_id: str):
self.key_id = key_id
@@ -663,6 +716,7 @@ class AdminGetKeyDetailAdapter(AdminApiAdapter):
"name": api_key.name,
"key_display": api_key.get_display_key(),
"is_active": api_key.is_active,
"is_locked": api_key.is_locked,
"is_standalone": api_key.is_standalone,
"current_balance_usd": api_key.current_balance_usd,
"balance_used_usd": float(api_key.balance_used_usd or 0),

View File

@@ -102,7 +102,7 @@ async def create_provider_endpoint(
- `api_format`: API 格式(如 claude、openai、gemini 等)
- `base_url`: 基础 URL
- `custom_path`: 自定义路径(可选)
- `headers`: 自定义请求头(可选
- `header_rules`: 请求头规则列表(可选,支持 set/drop/rename 操作
- `timeout`: 超时时间(秒,默认 300
- `max_retries`: 最大重试次数(默认 2
- `config`: 额外配置(可选)
@@ -169,7 +169,7 @@ async def update_endpoint(
**请求体字段**(均为可选):
- `base_url`: 基础 URL
- `custom_path`: 自定义路径
- `headers`: 自定义请求头
- `header_rules`: 请求头规则列表
- `timeout`: 超时时间(秒)
- `max_retries`: 最大重试次数
- `is_active`: 是否活跃
@@ -298,13 +298,14 @@ class AdminCreateProviderEndpointAdapter(AdminApiAdapter):
)
now = datetime.now(timezone.utc)
new_endpoint = ProviderEndpoint(
id=str(uuid.uuid4()),
provider_id=self.provider_id,
api_format=self.endpoint_data.api_format,
base_url=self.endpoint_data.base_url,
custom_path=self.endpoint_data.custom_path,
headers=self.endpoint_data.headers,
header_rules=self.endpoint_data.header_rules,
timeout=self.endpoint_data.timeout,
max_retries=self.endpoint_data.max_retries,
is_active=True,
@@ -398,6 +399,7 @@ class AdminUpdateProviderEndpointAdapter(AdminApiAdapter):
raise NotFoundException(f"Endpoint {self.endpoint_id} 不存在")
update_data = self.endpoint_data.model_dump(exclude_unset=True)
# 把 proxy 转换为 dict 存储,支持显式设置为 None 清除代理
if "proxy" in update_data:
if update_data["proxy"] is not None:

View File

@@ -15,11 +15,13 @@ from src.api.handlers.base.chat_adapter_base import get_adapter_class
from src.api.handlers.base.cli_adapter_base import get_cli_adapter_class
from src.config.constants import TimeoutDefaults
from src.core.crypto import crypto_service
from src.core.headers import get_extra_headers_from_endpoint
from src.core.logger import logger
from src.database.database import get_db
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint, User
from src.utils.auth_utils import get_current_user
router = APIRouter(prefix="/api/admin/provider-query", tags=["Provider Query"])
@@ -130,7 +132,7 @@ async def query_available_models(
"api_key": api_key_value,
"base_url": endpoint.base_url,
"api_format": fmt,
"extra_headers": endpoint.headers,
"extra_headers": get_extra_headers_from_endpoint(endpoint),
})
if not endpoint_configs:
@@ -160,7 +162,7 @@ async def query_available_models(
"api_key": api_key_value,
"base_url": endpoint.base_url,
"api_format": endpoint.api_format,
"extra_headers": endpoint.headers,
"extra_headers": get_extra_headers_from_endpoint(endpoint),
})
break # 只取第一个可用的 Key
@@ -321,7 +323,7 @@ async def test_model(
"api_key_id": api_key.id, # 添加API Key ID用于用量记录
"base_url": endpoint.base_url,
"api_format": endpoint.api_format,
"extra_headers": endpoint.headers,
"extra_headers": get_extra_headers_from_endpoint(endpoint),
"timeout": provider.timeout or TimeoutDefaults.HTTP_REQUEST,
}

View File

@@ -773,7 +773,7 @@ class AdminExportConfigAdapter(AdminApiAdapter):
{
"api_format": ep.api_format,
"base_url": ep.base_url,
"headers": ep.headers,
"header_rules": ep.header_rules,
"timeout": ep.timeout,
"max_retries": ep.max_retries,
"is_active": ep.is_active,
@@ -1063,7 +1063,7 @@ class AdminImportConfigAdapter(AdminApiAdapter):
existing_ep.base_url = ep_data.get(
"base_url", existing_ep.base_url
)
existing_ep.headers = ep_data.get("headers")
existing_ep.header_rules = ep_data.get("header_rules")
existing_ep.timeout = ep_data.get("timeout", 300)
existing_ep.max_retries = ep_data.get("max_retries", 2)
existing_ep.is_active = ep_data.get("is_active", True)
@@ -1078,7 +1078,7 @@ class AdminImportConfigAdapter(AdminApiAdapter):
provider_id=provider_id,
api_format=ep_data["api_format"],
base_url=ep_data["base_url"],
headers=ep_data.get("headers"),
header_rules=ep_data.get("header_rules"),
timeout=ep_data.get("timeout", 300),
max_retries=ep_data.get("max_retries", 2),
is_active=ep_data.get("is_active", True),

View File

@@ -489,6 +489,7 @@ class AdminGetUserKeysAdapter(AdminApiAdapter):
"name": key.name,
"key_display": key.get_display_key(),
"is_active": key.is_active,
"is_locked": key.is_locked,
"total_requests": key.total_requests,
"total_cost_usd": float(key.total_cost_usd or 0),
"rate_limit": key.rate_limit,

View File

@@ -140,16 +140,17 @@ class PassthroughRequestBuilder(RequestBuilder):
builder = HeaderBuilder()
# 2. 透传原始头部(排除敏感头部 - 黑名单模式
# 2. 透传原始头部(排除默认敏感头部)
if original_headers:
for name, value in original_headers.items():
if name.lower() in SENSITIVE_HEADERS:
continue
builder.add(name, value)
# 3. 添加 endpoint 配置的额外头部(不能覆盖认证头/Content-Type
if endpoint.headers:
builder.add_protected(endpoint.headers, protected_keys)
# 3. 应用 endpoint 的请求头规则
header_rules = getattr(endpoint, "header_rules", None)
if header_rules:
builder.apply_rules(header_rules, protected_keys)
# 4. 添加额外头部
if extra_headers:

View File

@@ -514,6 +514,7 @@ class ListMyApiKeysAdapter(AuthenticatedApiAdapter):
"name": key.name,
"key_display": key.get_display_key(),
"is_active": key.is_active,
"is_locked": key.is_locked,
"last_used_at": (
real_stats["last_used_at"].isoformat()
if real_stats["last_used_at"]
@@ -614,6 +615,7 @@ class GetMyApiKeyDetailAdapter(AuthenticatedApiAdapter):
"name": api_key.name,
"key_display": api_key.get_display_key(),
"is_active": api_key.is_active,
"is_locked": api_key.is_locked,
"allowed_providers": api_key.allowed_providers,
"force_capabilities": api_key.force_capabilities,
"rate_limit": api_key.rate_limit,
@@ -637,6 +639,8 @@ class DeleteMyApiKeyAdapter(AuthenticatedApiAdapter):
)
if not api_key:
raise NotFoundException("API密钥不存在", "api_key")
if api_key.is_locked:
raise ForbiddenException("该密钥已被管理员锁定,无法删除")
context.db.delete(api_key)
context.db.commit()
return {"message": "API密钥已删除"}
@@ -656,6 +660,8 @@ class ToggleMyApiKeyAdapter(AuthenticatedApiAdapter):
)
if not api_key:
raise NotFoundException("API密钥不存在", "api_key")
if api_key.is_locked:
raise ForbiddenException("该密钥已被管理员锁定,无法修改状态")
api_key.is_active = not api_key.is_active
context.db.commit()
context.db.refresh(api_key)
@@ -1055,6 +1061,8 @@ class UpdateApiKeyProvidersAdapter(AuthenticatedApiAdapter):
)
if not api_key:
raise NotFoundException("API密钥不存在")
if api_key.is_locked:
raise ForbiddenException("该密钥已被管理员锁定,无法修改")
if request.allowed_providers is not None and len(request.allowed_providers) > 0:
provider_ids = [cfg.provider_id for cfg in request.allowed_providers]
@@ -1101,6 +1109,8 @@ class UpdateApiKeyCapabilitiesAdapter(AuthenticatedApiAdapter):
)
if not api_key:
raise NotFoundException("API密钥不存在")
if api_key.is_locked:
raise ForbiddenException("该密钥已被管理员锁定,无法修改")
# 保存旧值用于审计
old_capabilities = api_key.force_capabilities