feat(rate-limit): 实现分层 RPM 限速,支持系统默认/用户/独立Key三级配置

- 新增用户级 rate_limit 字段,支持系统默认/用户自定义/不限制三种模式
- 独立 Key 的 rate_limit 语义调整:null=跟随系统默认,0=不限制,>0=自定义
- 实现 UserRpmLimiter 基于 Redis sliding window 的 RPM 限速引擎
- Pipeline 请求流程集成用户级 RPM 检查
- 管理后台和用户面板新增 RPM 限速配置与实时状态查看
- 系统设置新增全局默认 RPM 配置项
- 迁移脚本回填现有 API Key 的 rate_limit 默认值
- 新增用户/Key RPM 状态监控 API 和前端展示

Closes #231

Co-authored-by: LewisPen <LewisPen@nyadoo.com>
This commit is contained in:
fawney19
2026-03-15 14:22:59 +08:00
parent 920a383136
commit f92b0943b5
35 changed files with 2051 additions and 238 deletions

View File

@@ -20,7 +20,7 @@
size="icon"
class="h-8 w-8"
title="创建新 API Key"
@click="showCreateDialog = true"
@click="openCreateApiKeyDialog"
>
<Plus class="w-3.5 h-3.5" />
</Button>
@@ -56,7 +56,7 @@
<Button
size="lg"
class="shadow-lg shadow-primary/20"
@click="showCreateDialog = true"
@click="openCreateApiKeyDialog"
>
<Plus class="mr-2 h-4 w-4" />
创建新 API Key
@@ -157,17 +157,23 @@
<div class="flex flex-col items-center gap-1">
<Badge
:variant="apiKey.is_active ? 'success' : 'secondary'"
class="font-medium px-3 py-1"
class="h-5 px-2 py-0 text-[10px] font-medium"
>
{{ apiKey.is_active ? '活跃' : '禁用' }}
</Badge>
<Badge
v-if="apiKey.is_locked"
variant="warning"
class="font-medium text-[10px]"
class="h-5 px-2 py-0 text-[10px] font-medium"
>
已锁定
</Badge>
<Badge
variant="secondary"
class="h-5 px-2 py-0 text-[10px] font-medium"
>
{{ formatRateLimitSimple(apiKey.rate_limit) }}
</Badge>
</div>
</TableCell>
@@ -179,6 +185,16 @@
<!-- 操作按钮 -->
<TableCell class="py-4">
<div class="flex justify-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:title="apiKey.is_locked ? '已锁定' : '编辑'"
:disabled="apiKey.is_locked"
@click="openEditApiKeyDialog(apiKey)"
>
<SquarePen class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
@@ -237,8 +253,24 @@
>
已锁定
</Badge>
<Badge
variant="secondary"
class="text-[10px] px-1.5 py-0"
>
{{ formatRateLimitSimple(apiKey.rate_limit) }}
</Badge>
</div>
<div class="flex items-center gap-0.5 flex-shrink-0">
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:title="apiKey.is_locked ? '已锁定' : '编辑'"
:disabled="apiKey.is_locked"
@click="openEditApiKeyDialog(apiKey)"
>
<SquarePen class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
@@ -288,6 +320,10 @@
<span class="text-foreground font-medium">
{{ formatNumber(apiKey.total_requests || 0) }}
</span>
<span class="text-muted-foreground"></span>
<span class="text-muted-foreground">
{{ formatRateLimitSimple(apiKey.rate_limit) }}
</span>
</div>
</div>
</div>
@@ -316,10 +352,10 @@
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-foreground leading-tight">
创建 API 密钥
{{ editingApiKey ? '编辑 API 密钥' : '创建 API 密钥' }}
</h3>
<p class="text-xs text-muted-foreground">
创建一个新的密钥用于访问 API 服务
{{ editingApiKey ? '更新密钥名称和速率限制' : '创建一个新的密钥用于访问 API 服务' }}
</p>
</div>
</div>
@@ -344,26 +380,46 @@
给密钥起一个有意义的名称方便识别
</p>
</div>
<div class="space-y-2">
<Label
for="key-rate-limit"
class="text-sm font-semibold"
>速率限制 (请求/分钟)</Label>
<Input
id="key-rate-limit"
:model-value="newKeyRateLimit ?? ''"
type="number"
min="0"
max="10000"
placeholder="留空不限"
class="h-11 border-border/60"
@update:model-value="(v) => newKeyRateLimit = parseNumberInput(v, { min: 0, max: 10000 })"
/>
<p class="text-xs text-muted-foreground">
留空不限
</p>
</div>
</div>
<template #footer>
<Button
variant="outline"
class="h-11 px-6"
@click="showCreateDialog = false"
@click="closeApiKeyDialog"
>
取消
</Button>
<Button
class="h-11 px-6 shadow-lg shadow-primary/20"
:disabled="creating"
@click="createApiKey"
@click="saveApiKey"
>
<Loader2
v-if="creating"
class="animate-spin h-4 w-4 mr-2"
/>
{{ creating ? '创建中...' : '创建' }}
{{ creating ? (editingApiKey ? '保存中...' : '创建中...') : (editingApiKey ? '保存' : '创建') }}
</Button>
</template>
</Dialog>
@@ -455,10 +511,12 @@ import {
TableRow
} from '@/components/ui'
import RefreshButton from '@/components/ui/refresh-button.vue'
import { Plus, Key, Copy, Trash2, Loader2, Activity, CheckCircle, Power } from 'lucide-vue-next'
import { Plus, Key, Copy, Trash2, Loader2, Activity, CheckCircle, Power, SquarePen } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { log } from '@/utils/logger'
import { parseApiError } from '@/utils/errorParser'
import { formatRateLimitSimple } from '@/utils/format'
import { parseNumberInput } from '@/utils/form'
import { getErrorStatus } from '@/types/api-error'
import { computed } from 'vue'
@@ -483,8 +541,10 @@ const showKeyDialog = ref(false)
const showDeleteDialog = ref(false)
const newKeyName = ref('')
const newKeyRateLimit = ref<number | undefined>(undefined)
const newKeyValue = ref('')
const keyToDelete = ref<ApiKey | null>(null)
const editingApiKey = ref<ApiKey | null>(null)
onMounted(() => {
loadApiKeys()
@@ -509,7 +569,28 @@ async function loadApiKeys() {
}
}
async function createApiKey() {
function openEditApiKeyDialog(apiKey: ApiKey) {
editingApiKey.value = apiKey
newKeyName.value = apiKey.name || ''
newKeyRateLimit.value = apiKey.rate_limit ?? undefined
showCreateDialog.value = true
}
function openCreateApiKeyDialog() {
editingApiKey.value = null
newKeyName.value = ''
newKeyRateLimit.value = undefined
showCreateDialog.value = true
}
function closeApiKeyDialog() {
showCreateDialog.value = false
editingApiKey.value = null
newKeyName.value = ''
newKeyRateLimit.value = undefined
}
async function saveApiKey() {
if (!newKeyName.value.trim()) {
showError('请输入密钥名称')
return
@@ -517,16 +598,26 @@ async function createApiKey() {
creating.value = true
try {
const newKey = await meApi.createApiKey(newKeyName.value)
newKeyValue.value = newKey.key || ''
showCreateDialog.value = false
showKeyDialog.value = true
newKeyName.value = ''
if (editingApiKey.value) {
await meApi.updateApiKey(editingApiKey.value.id, {
name: newKeyName.value,
rate_limit: newKeyRateLimit.value ?? 0,
})
success('API 密钥更新成功')
} else {
const newKey = await meApi.createApiKey({
name: newKeyName.value,
rate_limit: newKeyRateLimit.value ?? 0,
})
newKeyValue.value = newKey.key || ''
showKeyDialog.value = true
success('API 密钥创建成功')
}
closeApiKeyDialog()
await loadApiKeys()
success('API 密钥创建成功')
} catch (error) {
log.error('创建 API 密钥失败:', error)
showError('创建 API 密钥失败')
log.error(editingApiKey.value ? '更新 API 密钥失败:' : '创建 API 密钥失败:', error)
showError(editingApiKey.value ? '更新 API 密钥失败' : '创建 API 密钥失败')
} finally {
creating.value = false
}