feat(rpm): RPM 限流模块优化

P0:
- rpm_override 嵌入 Auth Cache Snapshot,消除每请求 DB 查询 (snapshot v6→v7)
- 429 RPM 响应返回 Retry-After 头(当前分钟剩余秒数)

P1:
- ClearAll 按钮直连 DELETE API,带 loading 防重复
- 新增 GET /admin/users/:id/rpm-status 管理员 RPM 用量查询端点

优化:
- checkRPM 从级联互斥改为并行取最严,user.rpm_limit 作为全局硬上限始终生效
- Override/Group 变更后自动失效 auth cache
- fail-open 语义不变,Redis 故障不阻塞业务
This commit is contained in:
james-6-23
2026-04-23 03:33:52 +08:00
parent ef967d8f8a
commit dc5d42addc
79 changed files with 2831 additions and 140 deletions

View File

@@ -164,7 +164,8 @@ export interface GroupRateMultiplierEntry {
user_email: string
user_notes: string
user_status: string
rate_multiplier: number
rate_multiplier?: number | null
rpm_override?: number | null
}
/**
@@ -205,9 +206,7 @@ export async function clearGroupRateMultipliers(id: number): Promise<{ message:
/**
* Batch set rate multipliers for users in a group
* @param id - Group ID
* @param entries - Array of { user_id, rate_multiplier }
* @returns Success confirmation
* Only touches rate_multiplier column; preserves rpm_override on existing rows.
*/
export async function batchSetGroupRateMultipliers(
id: number,
@@ -220,6 +219,60 @@ export async function batchSetGroupRateMultipliers(
return data
}
/**
* RPM override entry for a user in a group
*/
export interface GroupRPMOverrideEntry {
user_id: number
user_name: string
user_email: string
user_notes: string
user_status: string
rpm_override: number
}
/**
* Get RPM overrides for users in a group (subset of rate-multipliers endpoint).
*/
export async function getGroupRPMOverrides(id: number): Promise<GroupRPMOverrideEntry[]> {
const { data } = await apiClient.get<GroupRateMultiplierEntry[]>(
`/admin/groups/${id}/rate-multipliers`
)
return data
.filter(e => e.rpm_override != null)
.map(e => ({
user_id: e.user_id,
user_name: e.user_name,
user_email: e.user_email,
user_notes: e.user_notes,
user_status: e.user_status,
rpm_override: e.rpm_override as number
}))
}
/**
* Batch set RPM overrides for users in a group.
* Only touches rpm_override column; preserves rate_multiplier on existing rows.
*/
export async function batchSetGroupRPMOverrides(
id: number,
entries: Array<{ user_id: number; rpm_override: number }>
): Promise<{ message: string }> {
const { data } = await apiClient.put<{ message: string }>(
`/admin/groups/${id}/rpm-overrides`,
{ entries }
)
return data
}
/**
* Clear all RPM overrides for a group (preserves rate_multiplier).
*/
export async function clearGroupRPMOverrides(id: number): Promise<{ message: string }> {
const { data } = await apiClient.delete<{ message: string }>(`/admin/groups/${id}/rpm-overrides`)
return data
}
/**
* Get usage summary (today + cumulative cost) for all groups
* @param timezone - IANA timezone string (e.g. "Asia/Shanghai")
@@ -262,6 +315,9 @@ export const groupsAPI = {
getGroupRateMultipliers,
clearGroupRateMultipliers,
batchSetGroupRateMultipliers,
getGroupRPMOverrides,
clearGroupRPMOverrides,
batchSetGroupRPMOverrides,
updateSortOrder,
getUsageSummary,
getCapacitySummary

View File

@@ -309,6 +309,7 @@ export interface SystemSettings {
// Default settings
default_balance: number;
default_concurrency: number;
default_user_rpm_limit: number;
default_subscriptions: DefaultSubscriptionSetting[];
auth_source_default_email_balance?: number;
auth_source_default_email_concurrency?: number;
@@ -482,6 +483,7 @@ export interface UpdateSettingsRequest {
totp_enabled?: boolean; // TOTP 双因素认证
default_balance?: number;
default_concurrency?: number;
default_user_rpm_limit?: number;
default_subscriptions?: DefaultSubscriptionSetting[];
auth_source_default_email_balance?: number;
auth_source_default_email_concurrency?: number;

View File

@@ -0,0 +1,434 @@
<template>
<BaseDialog :show="show" :title="t('admin.groups.rpmOverridesTitle')" width="wide" @close="handleClose">
<div v-if="group" class="space-y-4">
<!-- 分组信息 -->
<div class="flex flex-wrap items-center gap-3 rounded-lg bg-gray-50 px-4 py-2.5 text-sm dark:bg-dark-700">
<span class="inline-flex items-center gap-1.5" :class="platformColorClass">
<PlatformIcon :platform="group.platform" size="sm" />
{{ t('admin.groups.platforms.' + group.platform) }}
</span>
<span class="text-gray-400">|</span>
<span class="font-medium text-gray-900 dark:text-white">{{ group.name }}</span>
<span class="text-gray-400">|</span>
<span class="text-gray-600 dark:text-gray-400">
{{ t('admin.groups.groupRpmDefault') }}: {{ group.rpm_limit || 0 }}
</span>
</div>
<!-- 操作区添加用户 -->
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-600">
<h4 class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.addUserRpm') }}
</h4>
<div class="flex items-end gap-2">
<div class="relative flex-1">
<input
v-model="searchQuery"
type="text"
autocomplete="off"
class="input w-full"
:placeholder="t('admin.groups.searchUserPlaceholder')"
@input="handleSearchUsers"
@focus="showDropdown = true"
/>
<div
v-if="showDropdown && searchResults.length > 0"
class="absolute left-0 right-0 top-full z-10 mt-1 max-h-48 overflow-y-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-dark-500 dark:bg-dark-700"
>
<button
v-for="user in searchResults"
:key="user.id"
type="button"
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-gray-50 dark:hover:bg-dark-600"
@click="selectUser(user)"
>
<span class="text-gray-400">#{{ user.id }}</span>
<span class="text-gray-900 dark:text-white">{{ user.username || user.email }}</span>
<span v-if="user.username" class="text-xs text-gray-400">{{ user.email }}</span>
</button>
</div>
</div>
<div class="w-24">
<input
v-model.number="newRpm"
type="number"
step="1"
min="0"
autocomplete="off"
class="hide-spinner input w-full"
placeholder="100"
/>
</div>
<button
type="button"
class="btn btn-primary shrink-0"
:disabled="!selectedUser || newRpm == null || newRpm < 0"
@click="handleAddLocal"
>
{{ t('common.add') }}
</button>
</div>
<div v-if="localEntries.length > 0" class="mt-3 flex items-center justify-end border-t border-gray-100 pt-3 dark:border-dark-600">
<button
type="button"
:disabled="clearing"
class="rounded-lg border border-red-200 bg-red-50 px-3 py-1.5 text-sm font-medium text-red-600 transition-colors hover:bg-red-100 disabled:opacity-50 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400 dark:hover:bg-red-900/40"
@click="clearAllLocal"
>
<Icon v-if="clearing" name="refresh" size="sm" class="mr-1 inline animate-spin" />
{{ t('admin.groups.clearAll') }}
</button>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="flex justify-center py-6">
<svg class="h-6 w-6 animate-spin text-primary-500" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
<!-- 列表 -->
<div v-else>
<h4 class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.rpmOverrides') }} ({{ localEntries.length }})
</h4>
<div v-if="localEntries.length === 0" class="py-6 text-center text-sm text-gray-400 dark:text-gray-500">
{{ t('admin.groups.noRpmOverrides') }}
</div>
<div v-else>
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-dark-600">
<div class="max-h-[420px] overflow-y-auto">
<table class="w-full text-sm">
<thead class="sticky top-0 z-[1]">
<tr class="border-b border-gray-200 bg-gray-50 dark:border-dark-600 dark:bg-dark-700">
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.groups.columns.userEmail') }}</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">ID</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.groups.columns.userName') }}</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.groups.columns.userNotes') }}</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.groups.columns.userStatus') }}</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400" :title="t('admin.groups.columns.rpmOverrideHint')">{{ t('admin.groups.columns.rpmOverride') }}</th>
<th class="w-10 px-2 py-2"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-dark-600">
<tr
v-for="entry in paginatedLocalEntries"
:key="entry.user_id"
class="hover:bg-gray-50 dark:hover:bg-dark-700/50"
>
<td class="px-3 py-2 text-gray-600 dark:text-gray-400">{{ entry.user_email }}</td>
<td class="whitespace-nowrap px-3 py-2 text-gray-400 dark:text-gray-500">{{ entry.user_id }}</td>
<td class="whitespace-nowrap px-3 py-2 text-gray-900 dark:text-white">{{ entry.user_name || '-' }}</td>
<td class="max-w-[160px] truncate px-3 py-2 text-gray-500 dark:text-gray-400" :title="entry.user_notes">{{ entry.user_notes || '-' }}</td>
<td class="whitespace-nowrap px-3 py-2">
<span
:class="[
'inline-flex rounded-full px-2 py-0.5 text-xs font-medium',
entry.user_status === 'active'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-600 dark:bg-dark-600 dark:text-gray-400'
]"
>
{{ entry.user_status }}
</span>
</td>
<td class="whitespace-nowrap px-3 py-2">
<input
type="number"
step="1"
min="0"
autocomplete="off"
:value="entry.rpm_override"
class="hide-spinner w-20 rounded border border-gray-200 bg-white px-2 py-1 text-center text-sm font-medium transition-colors focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500/20 dark:border-dark-500 dark:bg-dark-700 dark:focus:border-primary-500"
@change="updateLocalRpm(entry.user_id, ($event.target as HTMLInputElement).value)"
/>
</td>
<td class="px-2 py-2">
<button
type="button"
class="rounded p-1 text-gray-400 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
@click="removeLocal(entry.user_id)"
>
<Icon name="trash" size="sm" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<Pagination
:total="localEntries.length"
:page="currentPage"
:page-size="pageSize"
@update:page="currentPage = $event"
@update:pageSize="handlePageSizeChange"
/>
</div>
</div>
<!-- 底部 -->
<div class="flex items-center gap-3 border-t border-gray-200 pt-4 dark:border-dark-600">
<template v-if="isDirty">
<span class="text-xs text-amber-600 dark:text-amber-400">{{ t('admin.groups.unsavedChanges') }}</span>
<button
type="button"
class="text-xs font-medium text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
@click="handleCancel"
>
{{ t('admin.groups.revertChanges') }}
</button>
</template>
<div class="ml-auto flex items-center gap-3">
<button type="button" class="btn btn-sm px-4 py-1.5" @click="handleClose">
{{ t('common.close') }}
</button>
<button
v-if="isDirty"
type="button"
class="btn btn-primary btn-sm px-4 py-1.5"
:disabled="saving"
@click="handleSave"
>
<Icon v-if="saving" name="refresh" size="sm" class="mr-1 animate-spin" />
{{ t('common.save') }}
</button>
</div>
</div>
</div>
</BaseDialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import type { GroupRPMOverrideEntry } from '@/api/admin/groups'
import type { AdminGroup, AdminUser } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Pagination from '@/components/common/Pagination.vue'
import Icon from '@/components/icons/Icon.vue'
import PlatformIcon from '@/components/common/PlatformIcon.vue'
interface LocalEntry extends GroupRPMOverrideEntry {}
const props = defineProps<{
show: boolean
group: AdminGroup | null
}>()
const emit = defineEmits<{
close: []
success: []
}>()
const { t } = useI18n()
const appStore = useAppStore()
const loading = ref(false)
const saving = ref(false)
const serverEntries = ref<GroupRPMOverrideEntry[]>([])
const localEntries = ref<LocalEntry[]>([])
const searchQuery = ref('')
const searchResults = ref<AdminUser[]>([])
const showDropdown = ref(false)
const selectedUser = ref<AdminUser | null>(null)
const newRpm = ref<number | null>(null)
const currentPage = ref(1)
const pageSize = ref(10)
let searchTimeout: ReturnType<typeof setTimeout>
const platformColorClass = computed(() => {
switch (props.group?.platform) {
case 'anthropic': return 'text-orange-700 dark:text-orange-400'
case 'openai': return 'text-emerald-700 dark:text-emerald-400'
case 'antigravity': return 'text-purple-700 dark:text-purple-400'
default: return 'text-blue-700 dark:text-blue-400'
}
})
const isDirty = computed(() => {
if (localEntries.value.length !== serverEntries.value.length) return true
const serverMap = new Map(serverEntries.value.map(e => [e.user_id, e.rpm_override]))
return localEntries.value.some(e => serverMap.get(e.user_id) !== e.rpm_override)
})
const paginatedLocalEntries = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return localEntries.value.slice(start, start + pageSize.value)
})
const cloneEntries = (entries: GroupRPMOverrideEntry[]): LocalEntry[] => {
return entries.map(e => ({ ...e }))
}
const loadEntries = async () => {
if (!props.group) return
loading.value = true
try {
serverEntries.value = await adminAPI.groups.getGroupRPMOverrides(props.group.id)
localEntries.value = cloneEntries(serverEntries.value)
adjustPage()
} catch (error) {
appStore.showError(t('admin.groups.failedToLoad'))
console.error('Error loading RPM overrides:', error)
} finally {
loading.value = false
}
}
const adjustPage = () => {
const totalPages = Math.max(1, Math.ceil(localEntries.value.length / pageSize.value))
if (currentPage.value > totalPages) currentPage.value = totalPages
}
watch(() => props.show, (val) => {
if (val && props.group) {
currentPage.value = 1
searchQuery.value = ''
searchResults.value = []
selectedUser.value = null
newRpm.value = null
loadEntries()
}
})
const handlePageSizeChange = (newSize: number) => {
pageSize.value = newSize
currentPage.value = 1
}
const handleSearchUsers = () => {
clearTimeout(searchTimeout)
selectedUser.value = null
if (!searchQuery.value.trim()) {
searchResults.value = []
showDropdown.value = false
return
}
searchTimeout = setTimeout(async () => {
try {
const res = await adminAPI.users.list(1, 10, { search: searchQuery.value.trim() })
searchResults.value = res.items
showDropdown.value = true
} catch {
searchResults.value = []
}
}, 300)
}
const selectUser = (user: AdminUser) => {
selectedUser.value = user
searchQuery.value = user.email
showDropdown.value = false
searchResults.value = []
}
const handleAddLocal = () => {
if (!selectedUser.value || newRpm.value == null || newRpm.value < 0) return
const user = selectedUser.value
const idx = localEntries.value.findIndex(e => e.user_id === user.id)
const entry: LocalEntry = {
user_id: user.id,
user_name: user.username || '',
user_email: user.email,
user_notes: user.notes || '',
user_status: user.status || 'active',
rpm_override: newRpm.value
}
if (idx >= 0) {
localEntries.value[idx] = entry
} else {
localEntries.value.push(entry)
}
searchQuery.value = ''
selectedUser.value = null
newRpm.value = null
adjustPage()
}
const updateLocalRpm = (userId: number, value: string) => {
const num = parseInt(value, 10)
if (isNaN(num) || num < 0) return
const entry = localEntries.value.find(e => e.user_id === userId)
if (entry) entry.rpm_override = num
}
const removeLocal = (userId: number) => {
localEntries.value = localEntries.value.filter(e => e.user_id !== userId)
adjustPage()
}
const clearing = ref(false)
const clearAllLocal = async () => {
if (!props.group || clearing.value) return
clearing.value = true
try {
await adminAPI.groups.clearGroupRPMOverrides(props.group.id)
localEntries.value = []
serverEntries.value = []
appStore.showSuccess(t('admin.groups.rpmSaved'))
} catch (error) {
appStore.showError(t('admin.groups.failedToSave'))
console.error('Error clearing RPM overrides:', error)
} finally {
clearing.value = false
}
}
const handleCancel = () => {
localEntries.value = cloneEntries(serverEntries.value)
adjustPage()
}
const handleSave = async () => {
if (!props.group) return
saving.value = true
try {
const entries = localEntries.value.map(e => ({
user_id: e.user_id,
rpm_override: e.rpm_override
}))
await adminAPI.groups.batchSetGroupRPMOverrides(props.group.id, entries)
appStore.showSuccess(t('admin.groups.rpmSaved'))
emit('success')
emit('close')
} catch (error) {
appStore.showError(t('admin.groups.failedToSave'))
console.error('Error saving RPM overrides:', error)
} finally {
saving.value = false
}
}
const handleClose = () => {
if (isDirty.value) {
localEntries.value = cloneEntries(serverEntries.value)
}
emit('close')
}
const handleClickOutside = () => { showDropdown.value = false }
if (typeof document !== 'undefined') {
document.addEventListener('click', handleClickOutside)
}
</script>
<style scoped>
.hide-spinner::-webkit-outer-spin-button,
.hide-spinner::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.hide-spinner {
-moz-appearance: textfield;
}
</style>

View File

@@ -168,7 +168,8 @@
step="0.001"
min="0.001"
autocomplete="off"
:value="entry.rate_multiplier"
:value="entry.rate_multiplier ?? ''"
:placeholder="String(props.group?.rate_multiplier ?? 1)"
class="hide-spinner w-20 rounded border border-gray-200 bg-white px-2 py-1 text-center text-sm font-medium transition-colors focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500/20 dark:border-dark-500 dark:bg-dark-700 dark:focus:border-primary-500"
@change="updateLocalRate(entry.user_id, ($event.target as HTMLInputElement).value)"
/>
@@ -294,19 +295,17 @@ const showFinalRate = computed(() => {
})
// 计算最终倍率预览
const computeFinalRate = (rate: number) => {
if (!batchFactor.value) return rate
return parseFloat((rate * batchFactor.value).toFixed(6))
const computeFinalRate = (rate: number | null | undefined) => {
const base = rate ?? props.group?.rate_multiplier ?? 1
if (!batchFactor.value) return base
return parseFloat((base * batchFactor.value).toFixed(6))
}
// 检测是否有未保存的修改
const isDirty = computed(() => {
if (localEntries.value.length !== serverEntries.value.length) return true
const serverMap = new Map(serverEntries.value.map(e => [e.user_id, e.rate_multiplier]))
return localEntries.value.some(e => {
const serverRate = serverMap.get(e.user_id)
return serverRate === undefined || serverRate !== e.rate_multiplier
})
const serverMap = new Map(serverEntries.value.map(e => [e.user_id, e.rate_multiplier ?? null]))
return localEntries.value.some(e => serverMap.get(e.user_id) !== (e.rate_multiplier ?? null))
})
const paginatedLocalEntries = computed(() => {
@@ -322,7 +321,9 @@ const loadEntries = async () => {
if (!props.group) return
loading.value = true
try {
serverEntries.value = await adminAPI.groups.getGroupRateMultipliers(props.group.id)
const raw = await adminAPI.groups.getGroupRateMultipliers(props.group.id)
// 仅显示已设置 rate_multiplier 的条目rpm_override 在另一个弹窗管理,保留不动
serverEntries.value = raw.filter(e => e.rate_multiplier != null)
localEntries.value = cloneEntries(serverEntries.value)
adjustPage()
} catch (error) {
@@ -394,7 +395,8 @@ const handleAddLocal = () => {
user_email: user.email,
user_notes: user.notes || '',
user_status: user.status || 'active',
rate_multiplier: newRate.value
rate_multiplier: newRate.value,
rpm_override: null
}
if (idx >= 0) {
localEntries.value[idx] = entry
@@ -409,12 +411,15 @@ const handleAddLocal = () => {
// 本地修改倍率
const updateLocalRate = (userId: number, value: string) => {
const entry = localEntries.value.find(e => e.user_id === userId)
if (!entry) return
if (value.trim() === '') {
entry.rate_multiplier = null
return
}
const num = parseFloat(value)
if (isNaN(num)) return
const entry = localEntries.value.find(e => e.user_id === userId)
if (entry) {
entry.rate_multiplier = num
}
entry.rate_multiplier = num
}
// 本地删除
@@ -427,7 +432,9 @@ const removeLocal = (userId: number) => {
const applyBatchFactor = () => {
if (!batchFactor.value || batchFactor.value <= 0) return
for (const entry of localEntries.value) {
entry.rate_multiplier = parseFloat((entry.rate_multiplier * batchFactor.value).toFixed(6))
if (entry.rate_multiplier != null) {
entry.rate_multiplier = parseFloat((entry.rate_multiplier * batchFactor.value).toFixed(6))
}
}
batchFactor.value = null
}
@@ -444,15 +451,17 @@ const handleCancel = () => {
adjustPage()
}
// 保存:一次性提交所有数据
// 保存:一次性提交所有数据(只提交 rate_multiplierrpm_override 由独立弹窗管理)
const handleSave = async () => {
if (!props.group) return
saving.value = true
try {
const entries = localEntries.value.map(e => ({
user_id: e.user_id,
rate_multiplier: e.rate_multiplier
}))
const entries = localEntries.value
.filter(e => e.rate_multiplier != null)
.map(e => ({
user_id: e.user_id,
rate_multiplier: e.rate_multiplier as number
}))
await adminAPI.groups.batchSetGroupRateMultipliers(props.group.id, entries)
appStore.showSuccess(t('admin.groups.rateSaved'))
emit('success')

View File

@@ -35,6 +35,18 @@
<input v-model.number="form.concurrency" type="number" class="input" />
</div>
</div>
<div>
<label class="input-label">{{ t('admin.users.form.rpmLimit') }}</label>
<input
v-model.number="form.rpm_limit"
type="number"
min="0"
step="1"
class="input"
:placeholder="t('admin.users.form.rpmLimitPlaceholder')"
/>
<p class="input-hint">{{ t('admin.users.form.rpmLimitHint') }}</p>
</div>
</form>
<template #footer>
<div class="flex justify-end gap-3">
@@ -57,7 +69,7 @@ import Icon from '@/components/icons/Icon.vue'
const props = defineProps<{ show: boolean }>()
const emit = defineEmits(['close', 'success']); const { t } = useI18n()
const form = reactive({ email: '', password: '', username: '', notes: '', balance: 0, concurrency: 1 })
const form = reactive({ email: '', password: '', username: '', notes: '', balance: 0, concurrency: 1, rpm_limit: 0 })
const { loading, submit } = useForm({
form,
@@ -68,7 +80,7 @@ const { loading, submit } = useForm({
successMsg: t('admin.users.userCreated')
})
watch(() => props.show, (v) => { if(v) Object.assign(form, { email: '', password: '', username: '', notes: '', balance: 0, concurrency: 1 }) })
watch(() => props.show, (v) => { if(v) Object.assign(form, { email: '', password: '', username: '', notes: '', balance: 0, concurrency: 1, rpm_limit: 0 }) })
const generateRandomPassword = () => {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%^&*'

View File

@@ -37,6 +37,18 @@
<label class="input-label">{{ t('admin.users.columns.concurrency') }}</label>
<input v-model.number="form.concurrency" type="number" class="input" />
</div>
<div>
<label class="input-label">{{ t('admin.users.form.rpmLimit') }}</label>
<input
v-model.number="form.rpm_limit"
type="number"
min="0"
step="1"
class="input"
:placeholder="t('admin.users.form.rpmLimitPlaceholder')"
/>
<p class="input-hint">{{ t('admin.users.form.rpmLimitHint') }}</p>
</div>
<UserAttributeForm v-model="form.customAttributes" :user-id="user?.id" />
</form>
<template #footer>
@@ -66,11 +78,11 @@ const emit = defineEmits(['close', 'success'])
const { t } = useI18n(); const appStore = useAppStore(); const { copyToClipboard } = useClipboard()
const submitting = ref(false); const passwordCopied = ref(false)
const form = reactive({ email: '', password: '', username: '', notes: '', concurrency: 1, customAttributes: {} as UserAttributeValuesMap })
const form = reactive({ email: '', password: '', username: '', notes: '', concurrency: 1, rpm_limit: 0, customAttributes: {} as UserAttributeValuesMap })
watch(() => props.user, (u) => {
if (u) {
Object.assign(form, { email: u.email, password: '', username: u.username || '', notes: u.notes || '', concurrency: u.concurrency, customAttributes: {} })
Object.assign(form, { email: u.email, password: '', username: u.username || '', notes: u.notes || '', concurrency: u.concurrency, rpm_limit: u.rpm_limit ?? 0, customAttributes: {} })
passwordCopied.value = false
}
}, { immediate: true })
@@ -97,7 +109,7 @@ const handleUpdateUser = async () => {
}
submitting.value = true
try {
const data: any = { email: form.email, username: form.username, notes: form.notes, concurrency: form.concurrency }
const data: any = { email: form.email, username: form.username, notes: form.notes, concurrency: form.concurrency, rpm_limit: form.rpm_limit }
if (form.password.trim()) data.password = form.password.trim()
await adminAPI.users.update(props.user.id, data)
if (Object.keys(form.customAttributes).length > 0) await adminAPI.userAttributes.updateUserAttributeValues(props.user.id, form.customAttributes)

View File

@@ -894,6 +894,8 @@ export default {
description: 'Manage your account information and settings',
accountBalance: 'Account Balance',
concurrencyLimit: 'Concurrency Limit',
rpmLimit: 'RPM Limit',
rpmUnlimited: 'Unlimited',
memberSince: 'Member Since',
overviewTitle: 'Account Overview',
overviewDescription: 'Check account status, profile sources, and common actions at a glance.',
@@ -1490,6 +1492,11 @@ export default {
copyPassword: 'Copy password',
creating: 'Creating...',
updating: 'Updating...',
form: {
rpmLimit: 'Requests Per Minute (RPM)',
rpmLimitPlaceholder: '0 = unlimited',
rpmLimitHint: 'Max requests per minute for this user; 0 = unlimited. Acts as a fallback only when the group has no rpm_limit set.'
},
columns: {
user: 'User',
id: 'ID',
@@ -1704,6 +1711,10 @@ export default {
name: 'Name',
platform: 'Platform',
rateMultiplier: 'Rate Multiplier',
rpmOverride: 'RPM Override',
rpmOverrideHint: 'Per-user RPM cap in this group; empty = group default; 0 = unlimited',
rateDefault: 'default',
rpmDefault: 'default',
type: 'Type',
accounts: 'Accounts',
capacity: 'Capacity',
@@ -1730,7 +1741,10 @@ export default {
platform: 'Platform',
rateMultiplier: 'Rate Multiplier',
status: 'Status',
exclusive: 'Exclusive Group'
exclusive: 'Exclusive Group',
rpmLimit: 'Requests Per Minute (RPM)',
rpmLimitPlaceholder: '0 = unlimited',
rpmLimitHint: 'Max requests per minute for each user in this group; 0 = unlimited. Once set, it takes over per-user rate limiting in this group (overrides the user-level rpm_limit fallback).'
},
enterGroupName: 'Enter group name',
optionalDescription: 'Optional description',
@@ -1762,6 +1776,12 @@ export default {
rateMultipliers: 'Rate Multipliers',
rateMultipliersTitle: 'Group Rate Multipliers',
addUserRate: 'Add User Rate Multiplier',
rpmOverrides: 'RPM Overrides',
rpmOverridesTitle: 'Group RPM Overrides',
addUserRpm: 'Add User RPM Override',
noRpmOverrides: 'No users have an RPM override yet',
rpmSaved: 'RPM overrides saved',
groupRpmDefault: 'Group default RPM',
searchUserPlaceholder: 'Search user email...',
noRateMultipliers: 'No user rate multipliers configured',
rateUpdated: 'Rate multiplier updated',
@@ -4503,6 +4523,8 @@ export default {
defaultBalanceHint: 'Initial balance for new users',
defaultConcurrency: 'Default Concurrency',
defaultConcurrencyHint: 'Maximum concurrent requests for new users',
defaultUserRpmLimit: 'Default User RPM Limit',
defaultUserRpmLimitHint: 'Default max requests per minute for new users; 0 = unlimited. Only applied at new user creation.',
defaultSubscriptions: 'Default Subscriptions',
defaultSubscriptionsHint: 'Auto-assign these subscriptions when a new user is created or registered',
addDefaultSubscription: 'Add Default Subscription',

View File

@@ -898,6 +898,8 @@ export default {
description: '管理您的账户信息和设置',
accountBalance: '账户余额',
concurrencyLimit: '并发限制',
rpmLimit: 'RPM 限制',
rpmUnlimited: '不限制',
memberSince: '注册时间',
overviewTitle: '账户总览',
overviewDescription: '快速查看账号状态、资料来源与常用设置。',
@@ -1589,7 +1591,10 @@ export default {
balanceLabel: '余额',
concurrencyLabel: '并发数',
statusLabel: '状态',
selectStatus: '选择状态'
selectStatus: '选择状态',
rpmLimit: '每分钟请求数 (RPM)',
rpmLimitPlaceholder: '0 表示不限制',
rpmLimitHint: '该用户每分钟最大请求数0 = 不限制;仅在所用分组未设置 rpm_limit 时作为兜底生效'
},
adjustBalance: '调整余额',
adjustConcurrency: '调整并发数',
@@ -1756,6 +1761,10 @@ export default {
name: '名称',
platform: '平台',
rateMultiplier: '费率倍数',
rpmOverride: 'RPM 覆盖',
rpmOverrideHint: '该用户在此分组的 RPM 上限;留空 = 使用分组默认0 = 不限制',
rateDefault: '默认',
rpmDefault: '默认',
exclusive: '独占',
type: '类型',
priority: '优先级',
@@ -1790,6 +1799,9 @@ export default {
descriptionPlaceholder: '请输入描述(可选)',
rateMultiplierLabel: '费率倍数',
rateMultiplierHint: '1.0 = 标准费率0.5 = 半价2.0 = 双倍',
rpmLimit: '每分钟请求数 (RPM)',
rpmLimitPlaceholder: '0 表示不限制',
rpmLimitHint: '每用户在本分组每分钟最大请求数0 = 不限制;一旦设置即接管该用户的限流(覆盖用户级 rpm_limit',
exclusiveLabel: '专属分组',
exclusiveHint: '专属分组,可以手动指定给用户',
platformLabel: '平台限制',
@@ -1859,6 +1871,12 @@ export default {
rateMultipliers: '专属倍率',
rateMultipliersTitle: '分组专属倍率管理',
addUserRate: '添加用户专属倍率',
rpmOverrides: '专属 RPM',
rpmOverridesTitle: '分组专属 RPM 管理',
addUserRpm: '添加用户专属 RPM',
noRpmOverrides: '暂无用户设置了专属 RPM',
rpmSaved: '专属 RPM 已保存',
groupRpmDefault: '分组默认 RPM',
searchUserPlaceholder: '搜索用户邮箱...',
noRateMultipliers: '暂无用户设置了专属倍率',
rateUpdated: '专属倍率已更新',
@@ -4668,6 +4686,8 @@ export default {
defaultBalanceHint: '新用户的初始余额',
defaultConcurrency: '默认并发数',
defaultConcurrencyHint: '新用户的最大并发请求数',
defaultUserRpmLimit: '默认用户 RPM 限制',
defaultUserRpmLimitHint: '新用户默认每分钟最大请求数0 = 不限制;仅作用于新用户创建时初始化',
defaultSubscriptions: '默认订阅列表',
defaultSubscriptionsHint: '新用户创建或注册时自动分配这些订阅',
addDefaultSubscription: '添加默认订阅',

View File

@@ -87,6 +87,7 @@ export interface User {
role: 'admin' | 'user' // User role for authorization
balance: number // User balance for API usage
concurrency: number // Allowed concurrent requests
rpm_limit?: number // User-level RPM cap (0 = unlimited); effective as fallback when group has no rpm_limit
status: 'active' | 'disabled' // Account status
allowed_groups: number[] | null // Allowed group IDs (null = all non-exclusive groups)
balance_notify_enabled: boolean
@@ -453,6 +454,7 @@ export interface Group {
description: string | null
platform: GroupPlatform
rate_multiplier: number
rpm_limit?: number // Group-level RPM cap (0 = unlimited); overrides user-level rpm_limit when set
is_exclusive: boolean
status: 'active' | 'inactive'
subscription_type: SubscriptionType

View File

@@ -308,6 +308,15 @@
t("admin.groups.rateMultipliers")
}}</span>
</button>
<button
@click="handleRPMOverrides(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-orange-600 dark:hover:bg-dark-700 dark:hover:text-orange-400"
>
<Icon name="bolt" size="sm" />
<span class="text-xs">{{
t("admin.groups.rpmOverrides")
}}</span>
</button>
<button
@click="handleDelete(row)"
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
@@ -491,6 +500,18 @@
/>
<p class="input-hint">{{ t("admin.groups.rateMultiplierHint") }}</p>
</div>
<div>
<label class="input-label">{{ t("admin.groups.form.rpmLimit") }}</label>
<input
v-model.number="createForm.rpm_limit"
type="number"
min="0"
step="1"
class="input"
:placeholder="t('admin.groups.form.rpmLimitPlaceholder')"
/>
<p class="input-hint">{{ t("admin.groups.form.rpmLimitHint") }}</p>
</div>
<div
v-if="createForm.subscription_type !== 'subscription'"
data-tour="group-form-exclusive"
@@ -1612,6 +1633,18 @@
data-tour="group-form-multiplier"
/>
</div>
<div>
<label class="input-label">{{ t("admin.groups.form.rpmLimit") }}</label>
<input
v-model.number="editForm.rpm_limit"
type="number"
min="0"
step="1"
class="input"
:placeholder="t('admin.groups.form.rpmLimitPlaceholder')"
/>
<p class="input-hint">{{ t("admin.groups.form.rpmLimitHint") }}</p>
</div>
<div v-if="editForm.subscription_type !== 'subscription'">
<div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
@@ -2689,6 +2722,14 @@
@close="showRateMultipliersModal = false"
@success="loadGroups"
/>
<!-- Group RPM Overrides Modal -->
<GroupRPMOverridesModal
:show="showRPMOverridesModal"
:group="rpmOverridesGroup"
@close="showRPMOverridesModal = false"
@success="loadGroups"
/>
</AppLayout>
</template>
@@ -2711,6 +2752,7 @@ import Select from "@/components/common/Select.vue";
import PlatformIcon from "@/components/common/PlatformIcon.vue";
import Icon from "@/components/icons/Icon.vue";
import GroupRateMultipliersModal from "@/components/admin/group/GroupRateMultipliersModal.vue";
import GroupRPMOverridesModal from "@/components/admin/group/GroupRPMOverridesModal.vue";
import GroupCapacityBadge from "@/components/common/GroupCapacityBadge.vue";
import { VueDraggable } from "vue-draggable-plus";
import { createStableObjectKeyResolver } from "@/utils/stableObjectKey";
@@ -2951,6 +2993,8 @@ const editingGroup = ref<AdminGroup | null>(null);
const deletingGroup = ref<AdminGroup | null>(null);
const showRateMultipliersModal = ref(false);
const rateMultipliersGroup = ref<AdminGroup | null>(null);
const showRPMOverridesModal = ref(false);
const rpmOverridesGroup = ref<AdminGroup | null>(null);
const sortableGroups = ref<AdminGroup[]>([]);
const createMessagesDispatchDefaults = createDefaultMessagesDispatchFormState();
const editMessagesDispatchDefaults = createDefaultMessagesDispatchFormState();
@@ -2990,6 +3034,8 @@ const createForm = reactive({
mcp_xml_inject: true,
// 从分组复制账号
copy_accounts_from_group_ids: [] as number[],
// 分组级 RPM 限制每用户每分钟最大请求数0 = 不限制)
rpm_limit: 0 as number,
});
// 简单账号类型(用于模型路由选择)
@@ -3271,6 +3317,8 @@ const editForm = reactive({
mcp_xml_inject: true,
// 从分组复制账号
copy_accounts_from_group_ids: [] as number[],
// 分组级 RPM 限制每用户每分钟最大请求数0 = 不限制)
rpm_limit: 0 as number,
});
// 根据分组类型返回不同的删除确认消息
@@ -3562,6 +3610,7 @@ const handleEdit = async (group: AdminGroup) => {
];
editForm.mcp_xml_inject = group.mcp_xml_inject ?? true;
editForm.copy_accounts_from_group_ids = []; // 复制账号字段每次编辑时重置为空
editForm.rpm_limit = group.rpm_limit ?? 0;
// 加载模型路由规则(异步加载账号名称)
editModelRoutingRules.value = await convertApiFormatToRoutingRules(
group.model_routing,
@@ -3670,6 +3719,11 @@ const handleRateMultipliers = (group: AdminGroup) => {
showRateMultipliersModal.value = true;
};
const handleRPMOverrides = (group: AdminGroup) => {
rpmOverridesGroup.value = group;
showRPMOverridesModal.value = true;
};
const handleDelete = (group: AdminGroup) => {
deletingGroup.value = group;
showDeleteDialog.value = true;

View File

@@ -2170,6 +2170,24 @@
{{ t("admin.settings.defaults.defaultConcurrencyHint") }}
</p>
</div>
<div>
<label
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ t("admin.settings.defaults.defaultUserRpmLimit") }}
</label>
<input
v-model.number="form.default_user_rpm_limit"
type="number"
min="0"
step="1"
class="input"
placeholder="0"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t("admin.settings.defaults.defaultUserRpmLimitHint") }}
</p>
</div>
</div>
<div class="border-t border-gray-100 pt-4 dark:border-dark-700">
@@ -4867,6 +4885,7 @@ const form = reactive<SettingsForm>({
default_concurrency: 1,
default_subscriptions: [],
force_email_on_third_party_signup: false,
default_user_rpm_limit: 0,
site_name: "Sub2API",
site_logo: "",
site_subtitle: "Subscription to API Conversion Platform",
@@ -5783,6 +5802,7 @@ async function saveSettings() {
default_concurrency: form.default_concurrency,
default_subscriptions: normalizedDefaultSubscriptions,
force_email_on_third_party_signup: form.force_email_on_third_party_signup,
default_user_rpm_limit: form.default_user_rpm_limit,
site_name: form.site_name,
site_logo: form.site_logo,
site_subtitle: form.site_subtitle,