diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index d19a5fb..9c89212 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -42,7 +42,7 @@ export interface UserApiKeyExport { allowed_endpoints?: string[] | null allowed_api_formats?: string[] | null allowed_models?: string[] | null - rate_limit?: number + rate_limit?: number | null // null = 无限制 concurrent_limit?: number | null force_capabilities?: any is_active: boolean @@ -220,7 +220,7 @@ export interface AdminApiKey { total_requests?: number total_tokens?: number total_cost_usd?: number - rate_limit?: number + rate_limit?: number | null // null = 无限制 allowed_providers?: string[] | null // 允许的提供商列表 allowed_api_formats?: string[] | null // 允许的 API 格式列表 allowed_models?: string[] | null // 允许的模型列表 @@ -236,8 +236,8 @@ export interface CreateStandaloneApiKeyRequest { allowed_providers?: string[] | null allowed_api_formats?: string[] | null allowed_models?: string[] | null - rate_limit?: number - expire_days?: number | null // null = 永不过期 + rate_limit?: number | null // null = 无限制 + expires_at?: string | null // ISO 日期字符串,如 "2025-12-31",null = 永不过期 initial_balance_usd: number // 初始余额,必须设置 auto_delete_on_expiry?: boolean // 过期后是否自动删除 } diff --git a/frontend/src/features/api-keys/components/StandaloneKeyFormDialog.vue b/frontend/src/features/api-keys/components/StandaloneKeyFormDialog.vue index a92a06a..bdc65b0 100644 --- a/frontend/src/features/api-keys/components/StandaloneKeyFormDialog.vue +++ b/frontend/src/features/api-keys/components/StandaloneKeyFormDialog.vue @@ -79,45 +79,45 @@
- -

- 不勾选"到期删除"则仅禁用 + {{ form.expires_at ? '到期后' + (form.auto_delete_on_expiry ? '自动删除' : '仅禁用') + '(当天 UTC 23:59 失效)' : '留空表示永不过期' }}

@@ -280,7 +280,7 @@ import { Input, Label, } from '@/components/ui' -import { Plus, SquarePen, Key, Shield, ChevronDown } from 'lucide-vue-next' +import { Plus, SquarePen, Key, Shield, ChevronDown, X } from 'lucide-vue-next' import { useFormDialog } from '@/composables/useFormDialog' import { ModelMultiSelect } from '@/components/common' import { getProvidersSummary } from '@/api/endpoints/providers' @@ -294,8 +294,7 @@ export interface StandaloneKeyFormData { id?: string name: string initial_balance_usd?: number - expire_days?: number - never_expire: boolean + expires_at?: string // ISO 日期字符串,如 "2025-12-31",undefined = 永不过期 rate_limit?: number auto_delete_on_expiry: boolean allowed_providers: string[] @@ -329,8 +328,7 @@ const allApiFormats = ref([]) const form = ref({ name: '', initial_balance_usd: 10, - expire_days: undefined, - never_expire: true, + expires_at: undefined, rate_limit: undefined, auto_delete_on_expiry: false, allowed_providers: [], @@ -338,12 +336,18 @@ const form = ref({ allowed_models: [] }) +// 计算最小可选日期(明天) +const minExpiryDate = computed(() => { + const tomorrow = new Date() + tomorrow.setDate(tomorrow.getDate() + 1) + return tomorrow.toISOString().split('T')[0] +}) + function resetForm() { form.value = { name: '', initial_balance_usd: 10, - expire_days: undefined, - never_expire: true, + expires_at: undefined, rate_limit: undefined, auto_delete_on_expiry: false, allowed_providers: [], @@ -360,8 +364,7 @@ function loadKeyData() { id: props.apiKey.id, name: props.apiKey.name || '', initial_balance_usd: props.apiKey.initial_balance_usd, - expire_days: props.apiKey.expire_days, - never_expire: props.apiKey.never_expire, + expires_at: props.apiKey.expires_at, rate_limit: props.apiKey.rate_limit, auto_delete_on_expiry: props.apiKey.auto_delete_on_expiry, allowed_providers: props.apiKey.allowed_providers || [], @@ -406,12 +409,10 @@ function toggleSelection(field: 'allowed_providers' | 'allowed_api_formats' | 'a } } -// 永不过期切换 -function onNeverExpireChange() { - if (form.value.never_expire) { - form.value.expire_days = undefined - form.value.auto_delete_on_expiry = false - } +// 清空过期日期(同时清空到期删除选项) +function clearExpiryDate() { + form.value.expires_at = undefined + form.value.auto_delete_on_expiry = false } // 提交表单 diff --git a/frontend/src/views/admin/ApiKeys.vue b/frontend/src/views/admin/ApiKeys.vue index 8b85d5d..4293d15 100644 --- a/frontend/src/views/admin/ApiKeys.vue +++ b/frontend/src/views/admin/ApiKeys.vue @@ -850,28 +850,20 @@ async function deleteApiKey(apiKey: AdminApiKey) { } function editApiKey(apiKey: AdminApiKey) { - // 计算过期天数 - let expireDays: number | undefined = undefined - let neverExpire = true + // 解析过期日期为 YYYY-MM-DD 格式 + // 保留原始日期,不做时间过滤(避免编辑当天过期的 Key 时意外清空) + let expiresAt: string | undefined = undefined if (apiKey.expires_at) { const expiresDate = new Date(apiKey.expires_at) - const now = new Date() - const diffMs = expiresDate.getTime() - now.getTime() - const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24)) - - if (diffDays > 0) { - expireDays = diffDays - neverExpire = false - } + expiresAt = expiresDate.toISOString().split('T')[0] } editingKeyData.value = { id: apiKey.id, name: apiKey.name || '', - expire_days: expireDays, - never_expire: neverExpire, - rate_limit: apiKey.rate_limit || 100, + expires_at: expiresAt, + rate_limit: apiKey.rate_limit ?? undefined, auto_delete_on_expiry: apiKey.auto_delete_on_expiry || false, allowed_providers: apiKey.allowed_providers || [], allowed_api_formats: apiKey.allowed_api_formats || [], @@ -1033,14 +1025,25 @@ function closeKeyFormDialog() { // 统一处理表单提交 async function handleKeyFormSubmit(data: StandaloneKeyFormData) { + // 验证过期日期(如果设置了,必须晚于今天) + if (data.expires_at) { + const selectedDate = new Date(data.expires_at) + const today = new Date() + today.setHours(0, 0, 0, 0) + if (selectedDate <= today) { + error('过期日期必须晚于今天') + return + } + } + keyFormDialogRef.value?.setSaving(true) try { if (data.id) { // 更新 const updateData: Partial = { name: data.name || undefined, - rate_limit: data.rate_limit, - expire_days: data.never_expire ? null : (data.expire_days || null), + rate_limit: data.rate_limit ?? null, // undefined = 无限制,显式传 null + expires_at: data.expires_at || null, // undefined/空 = 永不过期 auto_delete_on_expiry: data.auto_delete_on_expiry, // 空数组表示清除限制(允许全部),后端会将空数组存为 NULL allowed_providers: data.allowed_providers, @@ -1058,8 +1061,8 @@ async function handleKeyFormSubmit(data: StandaloneKeyFormData) { const createData: CreateStandaloneApiKeyRequest = { name: data.name || undefined, initial_balance_usd: data.initial_balance_usd, - rate_limit: data.rate_limit, - expire_days: data.never_expire ? null : (data.expire_days || null), + rate_limit: data.rate_limit ?? null, // undefined = 无限制,显式传 null + expires_at: data.expires_at || null, // undefined/空 = 永不过期 auto_delete_on_expiry: data.auto_delete_on_expiry, // 空数组表示不设置限制(允许全部),后端会将空数组存为 NULL allowed_providers: data.allowed_providers, diff --git a/src/api/admin/api_keys/routes.py b/src/api/admin/api_keys/routes.py index 133c7d7..46983ba 100644 --- a/src/api/admin/api_keys/routes.py +++ b/src/api/admin/api_keys/routes.py @@ -3,7 +3,7 @@ 独立余额Key:不关联用户配额,有独立余额限制,用于给非注册用户使用。 """ -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query, Request @@ -11,14 +11,50 @@ from sqlalchemy.orm import Session from src.api.base.admin_adapter import AdminApiAdapter from src.api.base.pipeline import ApiRequestPipeline -from src.core.exceptions import NotFoundException +from src.core.exceptions import InvalidRequestException, NotFoundException from src.core.logger import logger from src.database import get_db from src.models.api import CreateApiKeyRequest -from src.models.database import ApiKey, User +from src.models.database import ApiKey from src.services.user.apikey import ApiKeyService +def parse_expiry_date(date_str: Optional[str]) -> Optional[datetime]: + """解析过期日期字符串为 datetime 对象(UTC 时区)。 + + Args: + date_str: 日期字符串,支持 "YYYY-MM-DD" 或 ISO 格式 + + Returns: + datetime 对象(当天 23:59:59.999999 UTC),或 None 如果输入为空 + + Raises: + BadRequestException: 日期格式无效 + """ + if not date_str or not date_str.strip(): + return None + + date_str = date_str.strip() + + # 尝试 YYYY-MM-DD 格式 + try: + parsed_date = datetime.strptime(date_str, "%Y-%m-%d") + # 设置为当天结束时间 (23:59:59.999999 UTC) + return parsed_date.replace( + hour=23, minute=59, second=59, microsecond=999999, tzinfo=timezone.utc + ) + except ValueError: + pass + + # 尝试完整 ISO 格式 + try: + return datetime.fromisoformat(date_str.replace("Z", "+00:00")) + except ValueError: + pass + + raise InvalidRequestException(f"无效的日期格式: {date_str},请使用 YYYY-MM-DD 格式") + + router = APIRouter(prefix="/api/admin/api-keys", tags=["Admin - API Keys (Standalone)"]) pipeline = ApiRequestPipeline() @@ -215,6 +251,9 @@ class AdminCreateStandaloneKeyAdapter(AdminApiAdapter): # 独立Key需要关联到管理员用户(从context获取) admin_user_id = context.user.id + # 解析过期时间(优先使用 expires_at,其次使用 expire_days) + expires_at_dt = parse_expiry_date(self.key_data.expires_at) + # 创建独立Key api_key, plain_key = ApiKeyService.create_api_key( db=db, @@ -224,7 +263,8 @@ class AdminCreateStandaloneKeyAdapter(AdminApiAdapter): allowed_api_formats=self.key_data.allowed_api_formats, allowed_models=self.key_data.allowed_models, rate_limit=self.key_data.rate_limit, # None 表示不限制 - expire_days=self.key_data.expire_days, + expire_days=self.key_data.expire_days, # 兼容旧版 + expires_at=expires_at_dt, # 优先使用 initial_balance_usd=self.key_data.initial_balance_usd, is_standalone=True, # 标记为独立Key auto_delete_on_expiry=self.key_data.auto_delete_on_expiry, @@ -270,7 +310,8 @@ class AdminUpdateApiKeyAdapter(AdminApiAdapter): update_data = {} if self.key_data.name is not None: update_data["name"] = self.key_data.name - if self.key_data.rate_limit is not None: + # rate_limit: 显式传递时更新(包括 null 表示无限制) + if "rate_limit" in self.key_data.model_fields_set: update_data["rate_limit"] = self.key_data.rate_limit if ( hasattr(self.key_data, "auto_delete_on_expiry") @@ -287,19 +328,21 @@ class AdminUpdateApiKeyAdapter(AdminApiAdapter): update_data["allowed_models"] = self.key_data.allowed_models # 处理过期时间 - if self.key_data.expire_days is not None: - if self.key_data.expire_days > 0: - from datetime import timedelta - + # 优先使用 expires_at(如果显式传递且有值) + if self.key_data.expires_at and self.key_data.expires_at.strip(): + update_data["expires_at"] = parse_expiry_date(self.key_data.expires_at) + elif "expires_at" in self.key_data.model_fields_set: + # expires_at 明确传递为 null 或空字符串,设为永不过期 + update_data["expires_at"] = None + # 兼容旧版 expire_days + elif "expire_days" in self.key_data.model_fields_set: + if self.key_data.expire_days is not None and self.key_data.expire_days > 0: update_data["expires_at"] = datetime.now(timezone.utc) + timedelta( days=self.key_data.expire_days ) else: - # expire_days = 0 或负数表示永不过期 + # expire_days = None/0/负数 表示永不过期 update_data["expires_at"] = None - elif hasattr(self.key_data, "expire_days") and self.key_data.expire_days is None: - # 明确传递 None,设为永不过期 - update_data["expires_at"] = None # 使用 ApiKeyService 更新 updated_key = ApiKeyService.update_api_key(db, self.key_id, **update_data) diff --git a/src/api/admin/system.py b/src/api/admin/system.py index ede736e..b233cfa 100644 --- a/src/api/admin/system.py +++ b/src/api/admin/system.py @@ -1133,7 +1133,7 @@ class AdminImportUsersAdapter(AdminApiAdapter): allowed_endpoints=key_data.get("allowed_endpoints"), allowed_api_formats=key_data.get("allowed_api_formats"), allowed_models=key_data.get("allowed_models"), - rate_limit=key_data.get("rate_limit", 100), + rate_limit=key_data.get("rate_limit"), # None = 无限制 concurrent_limit=key_data.get("concurrent_limit", 5), force_capabilities=key_data.get("force_capabilities"), is_active=key_data.get("is_active", True), diff --git a/src/api/admin/users/routes.py b/src/api/admin/users/routes.py index ca7ae97..03560b3 100644 --- a/src/api/admin/users/routes.py +++ b/src/api/admin/users/routes.py @@ -431,7 +431,7 @@ class AdminCreateUserKeyAdapter(AdminApiAdapter): name=key_data.name, allowed_providers=key_data.allowed_providers, allowed_models=key_data.allowed_models, - rate_limit=key_data.rate_limit or 100, + rate_limit=key_data.rate_limit, # None = 无限制 expire_days=key_data.expire_days, initial_balance_usd=None, # 普通Key不设置余额限制 is_standalone=False, # 不是独立Key diff --git a/src/models/api.py b/src/models/api.py index 202d814..1640cd0 100644 --- a/src/models/api.py +++ b/src/models/api.py @@ -309,8 +309,9 @@ class CreateApiKeyRequest(BaseModel): allowed_endpoints: Optional[List[str]] = None # 允许使用的端点 ID 列表 allowed_api_formats: Optional[List[str]] = None # 允许使用的 API 格式列表 allowed_models: Optional[List[str]] = None # 允许使用的模型名称列表 - rate_limit: Optional[int] = 100 - expire_days: Optional[int] = None # None = 永不过期,数字 = 多少天后过期 + rate_limit: Optional[int] = None # None = 无限制 + expire_days: Optional[int] = None # None = 永不过期,数字 = 多少天后过期(兼容旧版) + expires_at: Optional[str] = None # ISO 日期字符串,如 "2025-12-31",优先于 expire_days initial_balance_usd: Optional[float] = Field( None, description="初始余额(USD),仅用于独立Key,None = 无限制" ) diff --git a/src/models/database.py b/src/models/database.py index 85e4a0b..608a37f 100644 --- a/src/models/database.py +++ b/src/models/database.py @@ -150,7 +150,7 @@ class ApiKey(Base): allowed_endpoints = Column(JSON, nullable=True) # 允许使用的端点 ID 列表 allowed_api_formats = Column(JSON, nullable=True) # 允许使用的 API 格式列表 allowed_models = Column(JSON, nullable=True) # 允许使用的模型名称列表 - rate_limit = Column(Integer, default=100) # 每分钟请求限制 + rate_limit = Column(Integer, default=None, nullable=True) # 每分钟请求限制,None = 无限制 concurrent_limit = Column(Integer, default=5, nullable=True) # 并发请求限制 # Key 能力配置 diff --git a/src/services/user/apikey.py b/src/services/user/apikey.py index b03e453..51919fc 100644 --- a/src/services/user/apikey.py +++ b/src/services/user/apikey.py @@ -25,9 +25,10 @@ class ApiKeyService: allowed_providers: Optional[List[str]] = None, allowed_api_formats: Optional[List[str]] = None, allowed_models: Optional[List[str]] = None, - rate_limit: int = 100, + rate_limit: Optional[int] = None, concurrent_limit: int = 5, expire_days: Optional[int] = None, + expires_at: Optional[datetime] = None, # 直接传入过期时间,优先于 expire_days initial_balance_usd: Optional[float] = None, is_standalone: bool = False, auto_delete_on_expiry: bool = False, @@ -44,6 +45,7 @@ class ApiKeyService: rate_limit: 速率限制 concurrent_limit: 并发限制 expire_days: 过期天数,None = 永不过期 + expires_at: 直接指定过期时间,优先于 expire_days initial_balance_usd: 初始余额(USD),仅用于独立Key,None = 无限制 is_standalone: 是否为独立余额Key(仅管理员可创建) auto_delete_on_expiry: 过期后是否自动删除(True=物理删除,False=仅禁用) @@ -54,10 +56,10 @@ class ApiKeyService: key_hash = ApiKey.hash_key(key) key_encrypted = crypto_service.encrypt(key) # 加密存储密钥 - # 计算过期时间 - expires_at = None - if expire_days: - expires_at = datetime.now(timezone.utc) + timedelta(days=expire_days) + # 计算过期时间:优先使用 expires_at,其次使用 expire_days + final_expires_at = expires_at + if final_expires_at is None and expire_days: + final_expires_at = datetime.now(timezone.utc) + timedelta(days=expire_days) # 空数组转为 None(表示不限制) api_key = ApiKey( @@ -70,7 +72,7 @@ class ApiKeyService: allowed_models=allowed_models or None, rate_limit=rate_limit, concurrent_limit=concurrent_limit, - expires_at=expires_at, + expires_at=final_expires_at, balance_used_usd=0.0, current_balance_usd=initial_balance_usd, # 直接使用初始余额,None = 无限制 is_standalone=is_standalone, @@ -145,6 +147,9 @@ class ApiKeyService: # 允许显式设置为空数组/None 的字段(空数组会转为 None,表示"全部") nullable_list_fields = {"allowed_providers", "allowed_api_formats", "allowed_models"} + # 允许显式设置为 None 的字段(如 expires_at=None 表示永不过期,rate_limit=None 表示无限制) + nullable_fields = {"expires_at", "rate_limit"} + for field, value in kwargs.items(): if field not in updatable_fields: continue @@ -153,6 +158,9 @@ class ApiKeyService: if value is not None: # 空数组转为 None(表示允许全部) setattr(api_key, field, value if value else None) + elif field in nullable_fields: + # 这些字段允许显式设置为 None + setattr(api_key, field, value) elif value is not None: setattr(api_key, field, value)