feat: apikey支持5h/1d/7d速率控制

This commit is contained in:
shaw
2026-03-03 15:01:10 +08:00
parent b7df7ce5d5
commit a80ec5d8bb
33 changed files with 3715 additions and 83 deletions

View File

@@ -46,6 +46,7 @@ export async function getById(id: number): Promise<ApiKey> {
* @param ipBlacklist - Optional IP blacklist
* @param quota - Optional quota limit in USD (0 = unlimited)
* @param expiresInDays - Optional days until expiry (undefined = never expires)
* @param rateLimitData - Optional rate limit fields
* @returns Created API key
*/
export async function create(
@@ -55,7 +56,8 @@ export async function create(
ipWhitelist?: string[],
ipBlacklist?: string[],
quota?: number,
expiresInDays?: number
expiresInDays?: number,
rateLimitData?: { rate_limit_5h?: number; rate_limit_1d?: number; rate_limit_7d?: number }
): Promise<ApiKey> {
const payload: CreateApiKeyRequest = { name }
if (groupId !== undefined) {
@@ -76,6 +78,15 @@ export async function create(
if (expiresInDays !== undefined && expiresInDays > 0) {
payload.expires_in_days = expiresInDays
}
if (rateLimitData?.rate_limit_5h && rateLimitData.rate_limit_5h > 0) {
payload.rate_limit_5h = rateLimitData.rate_limit_5h
}
if (rateLimitData?.rate_limit_1d && rateLimitData.rate_limit_1d > 0) {
payload.rate_limit_1d = rateLimitData.rate_limit_1d
}
if (rateLimitData?.rate_limit_7d && rateLimitData.rate_limit_7d > 0) {
payload.rate_limit_7d = rateLimitData.rate_limit_7d
}
const { data } = await apiClient.post<ApiKey>('/keys', payload)
return data

View File

@@ -560,6 +560,19 @@ export default {
resetQuotaConfirmMessage: 'Are you sure you want to reset the used quota (${used}) for key "{name}" to 0? This action cannot be undone.',
quotaResetSuccess: 'Quota reset successfully',
failedToResetQuota: 'Failed to reset quota',
rateLimitColumn: 'Rate Limit',
rateLimitSection: 'Rate Limit',
resetUsage: 'Reset',
rateLimit5h: '5-Hour Limit (USD)',
rateLimit1d: 'Daily Limit (USD)',
rateLimit7d: '7-Day Limit (USD)',
rateLimitHint: 'Set the maximum spending for this key within each time window. 0 = unlimited.',
rateLimitUsage: 'Rate Limit Usage',
resetRateLimitUsage: 'Reset Rate Limit Usage',
resetRateLimitTitle: 'Confirm Reset Rate Limit',
resetRateLimitConfirmMessage: 'Are you sure you want to reset the rate limit usage for key "{name}"? All time window usage will be reset to zero. This action cannot be undone.',
rateLimitResetSuccess: 'Rate limit usage reset successfully',
failedToResetRateLimit: 'Failed to reset rate limit usage',
expiration: 'Expiration',
expiresInDays: '{days} days',
extendDays: '+{days} days',

View File

@@ -566,6 +566,19 @@ export default {
resetQuotaConfirmMessage: '确定要将密钥 "{name}" 的已用额度(${used})重置为 0 吗?此操作不可撤销。',
quotaResetSuccess: '额度重置成功',
failedToResetQuota: '重置额度失败',
rateLimitColumn: '速率限制',
rateLimitSection: '速率限制',
resetUsage: '重置',
rateLimit5h: '5小时限额 (USD)',
rateLimit1d: '日限额 (USD)',
rateLimit7d: '7天限额 (USD)',
rateLimitHint: '设置此密钥在指定时间窗口内的最大消费额。0 = 无限制。',
rateLimitUsage: '速率限制用量',
resetRateLimitUsage: '重置速率限制用量',
resetRateLimitTitle: '确认重置速率限制',
resetRateLimitConfirmMessage: '确定要重置密钥 "{name}" 的速率限制用量吗?所有时间窗口的已用额度将归零。此操作不可撤销。',
rateLimitResetSuccess: '速率限制已重置',
failedToResetRateLimit: '重置速率限制失败',
expiration: '密钥有效期',
expiresInDays: '{days} 天',
extendDays: '+{days} 天',

View File

@@ -421,6 +421,15 @@ export interface ApiKey {
created_at: string
updated_at: string
group?: Group
rate_limit_5h: number
rate_limit_1d: number
rate_limit_7d: number
usage_5h: number
usage_1d: number
usage_7d: number
window_5h_start: string | null
window_1d_start: string | null
window_7d_start: string | null
}
export interface CreateApiKeyRequest {
@@ -431,6 +440,9 @@ export interface CreateApiKeyRequest {
ip_blacklist?: string[]
quota?: number // Quota limit in USD (0 = unlimited)
expires_in_days?: number // Days until expiry (null = never expires)
rate_limit_5h?: number
rate_limit_1d?: number
rate_limit_7d?: number
}
export interface UpdateApiKeyRequest {
@@ -442,6 +454,10 @@ export interface UpdateApiKeyRequest {
quota?: number // Quota limit in USD (null = no change, 0 = unlimited)
expires_at?: string | null // Expiration time (null = no change)
reset_quota?: boolean // Reset quota_used to 0
rate_limit_5h?: number
rate_limit_1d?: number
rate_limit_7d?: number
reset_rate_limit_usage?: boolean
}
export interface CreateGroupRequest {

View File

@@ -137,6 +137,97 @@
</div>
</template>
<template #cell-rate_limit="{ row }">
<div v-if="row.rate_limit_5h > 0 || row.rate_limit_1d > 0 || row.rate_limit_7d > 0" class="space-y-1.5 min-w-[140px]">
<!-- 5h window -->
<div v-if="row.rate_limit_5h > 0">
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500 dark:text-gray-400">5h</span>
<span :class="[
'font-medium tabular-nums',
row.usage_5h >= row.rate_limit_5h ? 'text-red-500' :
row.usage_5h >= row.rate_limit_5h * 0.8 ? 'text-yellow-500' :
'text-gray-700 dark:text-gray-300'
]">
${{ row.usage_5h?.toFixed(2) || '0.00' }}/${{ row.rate_limit_5h?.toFixed(2) }}
</span>
</div>
<div class="h-1 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-dark-600">
<div
:class="[
'h-full rounded-full transition-all',
row.usage_5h >= row.rate_limit_5h ? 'bg-red-500' :
row.usage_5h >= row.rate_limit_5h * 0.8 ? 'bg-yellow-500' :
'bg-emerald-500'
]"
:style="{ width: Math.min((row.usage_5h / row.rate_limit_5h) * 100, 100) + '%' }"
/>
</div>
</div>
<!-- 1d window -->
<div v-if="row.rate_limit_1d > 0">
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500 dark:text-gray-400">1d</span>
<span :class="[
'font-medium tabular-nums',
row.usage_1d >= row.rate_limit_1d ? 'text-red-500' :
row.usage_1d >= row.rate_limit_1d * 0.8 ? 'text-yellow-500' :
'text-gray-700 dark:text-gray-300'
]">
${{ row.usage_1d?.toFixed(2) || '0.00' }}/${{ row.rate_limit_1d?.toFixed(2) }}
</span>
</div>
<div class="h-1 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-dark-600">
<div
:class="[
'h-full rounded-full transition-all',
row.usage_1d >= row.rate_limit_1d ? 'bg-red-500' :
row.usage_1d >= row.rate_limit_1d * 0.8 ? 'bg-yellow-500' :
'bg-emerald-500'
]"
:style="{ width: Math.min((row.usage_1d / row.rate_limit_1d) * 100, 100) + '%' }"
/>
</div>
</div>
<!-- 7d window -->
<div v-if="row.rate_limit_7d > 0">
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500 dark:text-gray-400">7d</span>
<span :class="[
'font-medium tabular-nums',
row.usage_7d >= row.rate_limit_7d ? 'text-red-500' :
row.usage_7d >= row.rate_limit_7d * 0.8 ? 'text-yellow-500' :
'text-gray-700 dark:text-gray-300'
]">
${{ row.usage_7d?.toFixed(2) || '0.00' }}/${{ row.rate_limit_7d?.toFixed(2) }}
</span>
</div>
<div class="h-1 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-dark-600">
<div
:class="[
'h-full rounded-full transition-all',
row.usage_7d >= row.rate_limit_7d ? 'bg-red-500' :
row.usage_7d >= row.rate_limit_7d * 0.8 ? 'bg-yellow-500' :
'bg-emerald-500'
]"
:style="{ width: Math.min((row.usage_7d / row.rate_limit_7d) * 100, 100) + '%' }"
/>
</div>
</div>
<!-- Reset button -->
<button
v-if="row.usage_5h > 0 || row.usage_1d > 0 || row.usage_7d > 0"
@click.stop="confirmResetRateLimitFromTable(row)"
class="mt-0.5 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
:title="t('keys.resetRateLimitUsage')"
>
<Icon name="refresh" size="xs" />
{{ t('keys.resetUsage') }}
</button>
</div>
<span v-else class="text-sm text-gray-400 dark:text-dark-500">-</span>
</template>
<template #cell-expires_at="{ value }">
<span v-if="value" :class="[
'text-sm',
@@ -452,6 +543,180 @@
</div>
</div>
<!-- Rate Limit Section -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<label class="input-label mb-0">{{ t('keys.rateLimitSection') }}</label>
<button
type="button"
@click="formData.enable_rate_limit = !formData.enable_rate_limit"
:class="[
'relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
formData.enable_rate_limit ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
>
<span
:class="[
'pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
formData.enable_rate_limit ? 'translate-x-4' : 'translate-x-0'
]"
/>
</button>
</div>
<div v-if="formData.enable_rate_limit" class="space-y-4 pt-2">
<p class="input-hint -mt-2">{{ t('keys.rateLimitHint') }}</p>
<!-- 5-Hour Limit -->
<div>
<label class="input-label">{{ t('keys.rateLimit5h') }}</label>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
v-model.number="formData.rate_limit_5h"
type="number"
step="0.01"
min="0"
class="input pl-7"
:placeholder="'0'"
/>
</div>
<!-- Usage info (edit mode only) -->
<div v-if="showEditModal && selectedKey && selectedKey.rate_limit_5h > 0" class="mt-2">
<div class="flex items-center gap-2">
<div class="flex-1 rounded-lg bg-gray-100 px-3 py-2 dark:bg-dark-700 text-sm">
<span :class="[
'font-medium',
selectedKey.usage_5h >= selectedKey.rate_limit_5h ? 'text-red-500' :
selectedKey.usage_5h >= selectedKey.rate_limit_5h * 0.8 ? 'text-yellow-500' :
'text-gray-900 dark:text-white'
]">
${{ selectedKey.usage_5h?.toFixed(4) || '0.0000' }}
</span>
<span class="mx-2 text-gray-400">/</span>
<span class="text-gray-500 dark:text-gray-400">
${{ selectedKey.rate_limit_5h?.toFixed(2) || '0.00' }}
</span>
</div>
</div>
<div class="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-dark-600">
<div
:class="[
'h-full rounded-full transition-all',
selectedKey.usage_5h >= selectedKey.rate_limit_5h ? 'bg-red-500' :
selectedKey.usage_5h >= selectedKey.rate_limit_5h * 0.8 ? 'bg-yellow-500' :
'bg-green-500'
]"
:style="{ width: Math.min((selectedKey.usage_5h / selectedKey.rate_limit_5h) * 100, 100) + '%' }"
/>
</div>
</div>
</div>
<!-- Daily Limit -->
<div>
<label class="input-label">{{ t('keys.rateLimit1d') }}</label>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
v-model.number="formData.rate_limit_1d"
type="number"
step="0.01"
min="0"
class="input pl-7"
:placeholder="'0'"
/>
</div>
<!-- Usage info (edit mode only) -->
<div v-if="showEditModal && selectedKey && selectedKey.rate_limit_1d > 0" class="mt-2">
<div class="flex items-center gap-2">
<div class="flex-1 rounded-lg bg-gray-100 px-3 py-2 dark:bg-dark-700 text-sm">
<span :class="[
'font-medium',
selectedKey.usage_1d >= selectedKey.rate_limit_1d ? 'text-red-500' :
selectedKey.usage_1d >= selectedKey.rate_limit_1d * 0.8 ? 'text-yellow-500' :
'text-gray-900 dark:text-white'
]">
${{ selectedKey.usage_1d?.toFixed(4) || '0.0000' }}
</span>
<span class="mx-2 text-gray-400">/</span>
<span class="text-gray-500 dark:text-gray-400">
${{ selectedKey.rate_limit_1d?.toFixed(2) || '0.00' }}
</span>
</div>
</div>
<div class="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-dark-600">
<div
:class="[
'h-full rounded-full transition-all',
selectedKey.usage_1d >= selectedKey.rate_limit_1d ? 'bg-red-500' :
selectedKey.usage_1d >= selectedKey.rate_limit_1d * 0.8 ? 'bg-yellow-500' :
'bg-green-500'
]"
:style="{ width: Math.min((selectedKey.usage_1d / selectedKey.rate_limit_1d) * 100, 100) + '%' }"
/>
</div>
</div>
</div>
<!-- 7-Day Limit -->
<div>
<label class="input-label">{{ t('keys.rateLimit7d') }}</label>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
v-model.number="formData.rate_limit_7d"
type="number"
step="0.01"
min="0"
class="input pl-7"
:placeholder="'0'"
/>
</div>
<!-- Usage info (edit mode only) -->
<div v-if="showEditModal && selectedKey && selectedKey.rate_limit_7d > 0" class="mt-2">
<div class="flex items-center gap-2">
<div class="flex-1 rounded-lg bg-gray-100 px-3 py-2 dark:bg-dark-700 text-sm">
<span :class="[
'font-medium',
selectedKey.usage_7d >= selectedKey.rate_limit_7d ? 'text-red-500' :
selectedKey.usage_7d >= selectedKey.rate_limit_7d * 0.8 ? 'text-yellow-500' :
'text-gray-900 dark:text-white'
]">
${{ selectedKey.usage_7d?.toFixed(4) || '0.0000' }}
</span>
<span class="mx-2 text-gray-400">/</span>
<span class="text-gray-500 dark:text-gray-400">
${{ selectedKey.rate_limit_7d?.toFixed(2) || '0.00' }}
</span>
</div>
</div>
<div class="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-dark-600">
<div
:class="[
'h-full rounded-full transition-all',
selectedKey.usage_7d >= selectedKey.rate_limit_7d ? 'bg-red-500' :
selectedKey.usage_7d >= selectedKey.rate_limit_7d * 0.8 ? 'bg-yellow-500' :
'bg-green-500'
]"
:style="{ width: Math.min((selectedKey.usage_7d / selectedKey.rate_limit_7d) * 100, 100) + '%' }"
/>
</div>
</div>
</div>
<!-- Reset Rate Limit button (edit mode only) -->
<div v-if="showEditModal && selectedKey && (selectedKey.rate_limit_5h > 0 || selectedKey.rate_limit_1d > 0 || selectedKey.rate_limit_7d > 0)">
<button
type="button"
@click="confirmResetRateLimit"
class="btn btn-secondary text-sm"
>
{{ t('keys.resetRateLimitUsage') }}
</button>
</div>
</div>
</div>
<!-- Expiration Section -->
<div class="space-y-3">
<div class="flex items-center justify-between">
@@ -593,6 +858,18 @@
@cancel="showResetQuotaDialog = false"
/>
<!-- Reset Rate Limit Confirmation Dialog -->
<ConfirmDialog
:show="showResetRateLimitDialog"
:title="t('keys.resetRateLimitTitle')"
:message="t('keys.resetRateLimitConfirmMessage', { name: selectedKey?.name })"
:confirm-text="t('keys.reset')"
:cancel-text="t('common.cancel')"
:danger="true"
@confirm="resetRateLimitUsage"
@cancel="showResetRateLimitDialog = false"
/>
<!-- Use Key Modal -->
<UseKeyModal
:show="showUseKeyModal"
@@ -743,6 +1020,7 @@ const columns = computed<Column[]>(() => [
{ key: 'key', label: t('keys.apiKey'), sortable: false },
{ key: 'group', label: t('keys.group'), sortable: false },
{ key: 'usage', label: t('keys.usage'), sortable: false },
{ key: 'rate_limit', label: t('keys.rateLimitColumn'), sortable: false },
{ key: 'expires_at', label: t('keys.expiresAt'), sortable: true },
{ key: 'status', label: t('common.status'), sortable: true },
{ key: 'last_used_at', label: t('keys.lastUsedAt'), sortable: true },
@@ -768,6 +1046,7 @@ const showCreateModal = ref(false)
const showEditModal = ref(false)
const showDeleteDialog = ref(false)
const showResetQuotaDialog = ref(false)
const showResetRateLimitDialog = ref(false)
const showUseKeyModal = ref(false)
const showCcsClientSelect = ref(false)
const pendingCcsRow = ref<ApiKey | null>(null)
@@ -806,6 +1085,11 @@ const formData = ref({
// Quota settings (empty = unlimited)
enable_quota: false,
quota: null as number | null,
// Rate limit settings
enable_rate_limit: false,
rate_limit_5h: null as number | null,
rate_limit_1d: null as number | null,
rate_limit_7d: null as number | null,
enable_expiration: false,
expiration_preset: '30' as '7' | '30' | '90' | 'custom',
expiration_date: ''
@@ -966,6 +1250,10 @@ const editKey = (key: ApiKey) => {
ip_blacklist: (key.ip_blacklist || []).join('\n'),
enable_quota: key.quota > 0,
quota: key.quota > 0 ? key.quota : null,
enable_rate_limit: (key.rate_limit_5h > 0) || (key.rate_limit_1d > 0) || (key.rate_limit_7d > 0),
rate_limit_5h: key.rate_limit_5h || null,
rate_limit_1d: key.rate_limit_1d || null,
rate_limit_7d: key.rate_limit_7d || null,
enable_expiration: hasExpiration,
expiration_preset: 'custom',
expiration_date: key.expires_at ? formatDateTimeLocal(key.expires_at) : ''
@@ -1078,6 +1366,13 @@ const handleSubmit = async () => {
expiresAt = ''
}
// Calculate rate limit values (send 0 when toggle is off)
const rateLimitData = formData.value.enable_rate_limit ? {
rate_limit_5h: formData.value.rate_limit_5h && formData.value.rate_limit_5h > 0 ? formData.value.rate_limit_5h : 0,
rate_limit_1d: formData.value.rate_limit_1d && formData.value.rate_limit_1d > 0 ? formData.value.rate_limit_1d : 0,
rate_limit_7d: formData.value.rate_limit_7d && formData.value.rate_limit_7d > 0 ? formData.value.rate_limit_7d : 0,
} : { rate_limit_5h: 0, rate_limit_1d: 0, rate_limit_7d: 0 }
submitting.value = true
try {
if (showEditModal.value && selectedKey.value) {
@@ -1088,7 +1383,10 @@ const handleSubmit = async () => {
ip_whitelist: ipWhitelist,
ip_blacklist: ipBlacklist,
quota: quota,
expires_at: expiresAt
expires_at: expiresAt,
rate_limit_5h: rateLimitData.rate_limit_5h,
rate_limit_1d: rateLimitData.rate_limit_1d,
rate_limit_7d: rateLimitData.rate_limit_7d,
})
appStore.showSuccess(t('keys.keyUpdatedSuccess'))
} else {
@@ -1100,7 +1398,8 @@ const handleSubmit = async () => {
ipWhitelist,
ipBlacklist,
quota,
expiresInDays
expiresInDays,
rateLimitData
)
appStore.showSuccess(t('keys.keyCreatedSuccess'))
// Only advance tour if active, on submit step, and creation succeeded
@@ -1154,6 +1453,10 @@ const closeModals = () => {
ip_blacklist: '',
enable_quota: false,
quota: null,
enable_rate_limit: false,
rate_limit_5h: null,
rate_limit_1d: null,
rate_limit_7d: null,
enable_expiration: false,
expiration_preset: '30',
expiration_date: ''
@@ -1190,6 +1493,37 @@ const resetQuotaUsed = async () => {
}
}
// Show reset rate limit confirmation dialog (from edit modal)
const confirmResetRateLimit = () => {
showResetRateLimitDialog.value = true
}
// Show reset rate limit confirmation dialog (from table row)
const confirmResetRateLimitFromTable = (row: ApiKey) => {
selectedKey.value = row
showResetRateLimitDialog.value = true
}
// Reset rate limit usage for an API key
const resetRateLimitUsage = async () => {
if (!selectedKey.value) return
showResetRateLimitDialog.value = false
try {
await keysAPI.update(selectedKey.value.id, { reset_rate_limit_usage: true })
appStore.showSuccess(t('keys.rateLimitResetSuccess'))
// Refresh key data
await loadApiKeys()
// Update the editing key with fresh data
const refreshedKey = apiKeys.value.find(k => k.id === selectedKey.value!.id)
if (refreshedKey) {
selectedKey.value = refreshedKey
}
} catch (error: any) {
const errorMsg = error.response?.data?.detail || t('keys.failedToResetRateLimit')
appStore.showError(errorMsg)
}
}
const importToCcswitch = (row: ApiKey) => {
const platform = row.group?.platform || 'anthropic'