refactor: API Key 过期时间改用日期选择器,rate_limit 支持无限制

- 前端:将过期时间设置从"天数输入"改为"日期选择器",更直观
- 后端:新增 expires_at 字段(ISO 日期格式),兼容旧版 expire_days
- rate_limit 字段现在支持 null 表示无限制,移除默认值 100
- 解析逻辑:过期时间设为当天 UTC 23:59:59.999999
This commit is contained in:
fawney19
2026-01-05 02:16:16 +08:00
parent 689339117a
commit e7db76e581
9 changed files with 140 additions and 84 deletions

View File

@@ -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 // 过期后是否自动删除
}

View File

@@ -79,45 +79,45 @@
<div class="space-y-2">
<Label
for="form-expire-days"
for="form-expires-at"
class="text-sm font-medium"
>有效期设置</Label>
<div class="flex items-center gap-2">
<div class="relative flex-1">
<Input
id="form-expire-days"
:model-value="form.expire_days ?? ''"
type="number"
min="1"
max="3650"
placeholder="天数"
:class="form.never_expire ? 'flex-1 h-9 opacity-50' : 'flex-1 h-9'"
:disabled="form.never_expire"
@update:model-value="(v) => form.expire_days = parseNumberInput(v, { min: 1, max: 3650 })"
id="form-expires-at"
:model-value="form.expires_at || ''"
type="date"
:min="minExpiryDate"
class="h-9 pr-8"
:placeholder="form.expires_at ? '' : '永不过期'"
@update:model-value="(v) => form.expires_at = v || undefined"
/>
<label class="flex items-center gap-1.5 border rounded-md px-2 py-1.5 bg-muted/50 cursor-pointer text-xs whitespace-nowrap">
<input
v-model="form.never_expire"
type="checkbox"
class="h-3.5 w-3.5 rounded border-gray-300 cursor-pointer"
@change="onNeverExpireChange"
<button
v-if="form.expires_at"
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
title="清空永不过期"
@click="clearExpiryDate"
>
永不过期
</label>
<X class="h-4 w-4" />
</button>
</div>
<label
class="flex items-center gap-1.5 border rounded-md px-2 py-1.5 bg-muted/50 cursor-pointer text-xs whitespace-nowrap"
:class="form.never_expire ? 'opacity-50' : ''"
:class="!form.expires_at ? 'opacity-50 cursor-not-allowed' : ''"
>
<input
v-model="form.auto_delete_on_expiry"
type="checkbox"
class="h-3.5 w-3.5 rounded border-gray-300 cursor-pointer"
:disabled="form.never_expire"
:disabled="!form.expires_at"
>
到期删除
</label>
</div>
<p class="text-xs text-muted-foreground">
不勾选"到期删除"则仅禁用
{{ form.expires_at ? '到期后' + (form.auto_delete_on_expiry ? '自动删除' : '仅禁用') + '(当天 UTC 23:59 失效)' : '留空表示永不过期' }}
</p>
</div>
@@ -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<string[]>([])
const form = ref<StandaloneKeyFormData>({
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<StandaloneKeyFormData>({
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
// 清空过期日期(同时清空到期删除选项)
function clearExpiryDate() {
form.value.expires_at = undefined
form.value.auto_delete_on_expiry = false
}
}
// 提交表单

View File

@@ -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<CreateStandaloneApiKeyRequest> = {
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,

View File

@@ -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,18 +328,20 @@ 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 或负数表示永不过期
update_data["expires_at"] = None
elif hasattr(self.key_data, "expire_days") and self.key_data.expire_days is None:
# 明确传递 None设为永不过期
# expire_days = None/0/负数 表示永不过期
update_data["expires_at"] = None
# 使用 ApiKeyService 更新

View File

@@ -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),

View File

@@ -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

View File

@@ -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仅用于独立KeyNone = 无限制"
)

View File

@@ -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 能力配置

View File

@@ -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仅用于独立KeyNone = 无限制
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)