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)