Commit dc5d42ad authored by james-6-23's avatar james-6-23
Browse files

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 故障不阻塞业务
parent ef967d8f
...@@ -106,6 +106,7 @@ type SystemSettings struct { ...@@ -106,6 +106,7 @@ type SystemSettings struct {
DefaultConcurrency int DefaultConcurrency int
DefaultBalance float64 DefaultBalance float64
DefaultUserRPMLimit int
DefaultSubscriptions []DefaultSubscriptionSetting DefaultSubscriptions []DefaultSubscriptionSetting
// Model fallback configuration // Model fallback configuration
......
...@@ -49,6 +49,15 @@ type User struct { ...@@ -49,6 +49,15 @@ type User struct {
BalanceNotifyExtraEmails []NotifyEmailEntry BalanceNotifyExtraEmails []NotifyEmailEntry
TotalRecharged float64 TotalRecharged float64
// RPMLimit 用户级每分钟请求数上限(0 = 不限制)。仅在所用分组未设置 rpm_limit
// 且该 (用户, 分组) 无 rpm_override 时作为全局兜底生效,计数键 rpm:u:{userID}:{min}。
RPMLimit int
// UserGroupRPMOverride 来自 auth cache snapshot 的 (user, group) RPM 覆盖值。
// nil = 该 API Key 对应的 (user, group) 无 override;非 nil 时 checkRPM 直接使用,
// 避免每请求查 DB。字段不持久化到数据库。
UserGroupRPMOverride *int
APIKeys []APIKey APIKeys []APIKey
Subscriptions []UserSubscription Subscriptions []UserSubscription
} }
......
...@@ -2,14 +2,16 @@ package service ...@@ -2,14 +2,16 @@ package service
import "context" import "context"
// UserGroupRateEntry 分组下用户专属倍率条目 // UserGroupRateEntry 分组下用户专属倍率/RPM 条目。
// RateMultiplier 与 RPMOverride 均为指针以支持"未设置"语义(NULL)。
type UserGroupRateEntry struct { type UserGroupRateEntry struct {
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
UserName string `json:"user_name"` UserName string `json:"user_name"`
UserEmail string `json:"user_email"` UserEmail string `json:"user_email"`
UserNotes string `json:"user_notes"` UserNotes string `json:"user_notes"`
UserStatus string `json:"user_status"` UserStatus string `json:"user_status"`
RateMultiplier float64 `json:"rate_multiplier"` RateMultiplier *float64 `json:"rate_multiplier,omitempty"`
RPMOverride *int `json:"rpm_override,omitempty"`
} }
// GroupRateMultiplierInput 批量设置分组倍率的输入条目 // GroupRateMultiplierInput 批量设置分组倍率的输入条目
...@@ -18,30 +20,44 @@ type GroupRateMultiplierInput struct { ...@@ -18,30 +20,44 @@ type GroupRateMultiplierInput struct {
RateMultiplier float64 `json:"rate_multiplier"` RateMultiplier float64 `json:"rate_multiplier"`
} }
// UserGroupRateRepository 用户专属分组倍率仓储接口 // GroupRPMOverrideInput 批量设置分组 RPM override 的输入条目。
// 允许管理员为特定用户设置分组的专属计费倍率,覆盖分组默认倍率 // RPMOverride 为 *int 以支持清除(nil)语义。
type GroupRPMOverrideInput struct {
UserID int64 `json:"user_id"`
RPMOverride *int `json:"rpm_override"`
}
// UserGroupRateRepository 用户专属分组倍率/RPM 仓储接口。
// 允许管理员为特定用户设置分组的专属计费倍率与 RPM 上限,覆盖分组默认值。
type UserGroupRateRepository interface { type UserGroupRateRepository interface {
// GetByUserID 获取用户的所有专属分组倍率 // GetByUserID 获取用户所有专属分组 rate_multiplier(仅返回非 NULL 的条目)
// 返回 map[groupID]rateMultiplier
GetByUserID(ctx context.Context, userID int64) (map[int64]float64, error) GetByUserID(ctx context.Context, userID int64) (map[int64]float64, error)
// GetByUserAndGroup 获取用户在特定分组的专属倍率 // GetByUserAndGroup 获取用户在特定分组的专属 rate_multiplier(NULL 返回 nil)
// 如果未设置专属倍率,返回 nil
GetByUserAndGroup(ctx context.Context, userID, groupID int64) (*float64, error) GetByUserAndGroup(ctx context.Context, userID, groupID int64) (*float64, error)
// GetByGroupID 获取指定分组下所有用户的专属倍率 // GetRPMOverrideByUserAndGroup 获取用户在特定分组的 rpm_override(NULL 返回 nil)
GetRPMOverrideByUserAndGroup(ctx context.Context, userID, groupID int64) (*int, error)
// GetByGroupID 获取指定分组下所有用户的专属配置(rate 与 rpm_override 任一非 NULL 即返回)
GetByGroupID(ctx context.Context, groupID int64) ([]UserGroupRateEntry, error) GetByGroupID(ctx context.Context, groupID int64) ([]UserGroupRateEntry, error)
// SyncUserGroupRates 同步用户的分组专属倍率 // SyncUserGroupRates 同步用户的分组专属倍率;nil 表示清空该分组的 rate_multiplier
// rates: map[groupID]*rateMultiplier,nil 表示删除该分组的专属倍率
SyncUserGroupRates(ctx context.Context, userID int64, rates map[int64]*float64) error SyncUserGroupRates(ctx context.Context, userID int64, rates map[int64]*float64) error
// SyncGroupRateMultipliers 批量同步分组的用户专属倍率(替换整组数据 // SyncGroupRateMultipliers 批量同步分组的用户专属倍率(替换整组 rate 部分
SyncGroupRateMultipliers(ctx context.Context, groupID int64, entries []GroupRateMultiplierInput) error SyncGroupRateMultipliers(ctx context.Context, groupID int64, entries []GroupRateMultiplierInput) error
// DeleteByGroupID 删除指定分组的所有用户专属倍率(分组删除时调用) // SyncGroupRPMOverrides 批量同步分组的用户专属 RPM(替换整组 rpm_override 部分)。
// 条目中 RPMOverride 为 nil 时清空对应行的 rpm_override;非 nil 时 upsert。
SyncGroupRPMOverrides(ctx context.Context, groupID int64, entries []GroupRPMOverrideInput) error
// ClearGroupRPMOverrides 清空指定分组的所有 rpm_override(整组 rpm 部分归 NULL)
ClearGroupRPMOverrides(ctx context.Context, groupID int64) error
// DeleteByGroupID 删除指定分组的所有用户专属条目(分组删除时调用)
DeleteByGroupID(ctx context.Context, groupID int64) error DeleteByGroupID(ctx context.Context, groupID int64) error
// DeleteByUserID 删除指定用户的所有专属倍率(用户删除时调用) // DeleteByUserID 删除指定用户的所有专属条目(用户删除时调用)
DeleteByUserID(ctx context.Context, userID int64) error DeleteByUserID(ctx context.Context, userID int64) error
} }
package service
import "context"
// UserRPMCache 用户/分组级 RPM 计数器接口。
//
// 与账号级 RPMCache 的区别:
// - RPMCache —— 按外部 AI provider 账号聚合(key: rpm:{accountID}:{min})。
// - UserRPMCache —— 按用户或 (用户, 分组) 聚合,杜绝"同一用户创建多个 API Key 绕过 RPM"的路径。
// key 形如 rpm:ug:{userID}:{groupID}:{min} 或 rpm:u:{userID}:{min}。
type UserRPMCache interface {
// IncrementUserGroupRPM 原子递增 (user, group) 级分钟计数并返回最新值。
// 用于分组 rpm_limit 与 user-group rpm_override 两种命中分支。
IncrementUserGroupRPM(ctx context.Context, userID, groupID int64) (count int, err error)
// IncrementUserRPM 原子递增用户级分钟计数并返回最新值。
// 用于用户全局 rpm_limit 兜底分支(分组未设且无 override 时)。
IncrementUserRPM(ctx context.Context, userID int64) (count int, err error)
// GetUserGroupRPM 获取 (user, group) 当前分钟已用 RPM(只读,不递增)。
GetUserGroupRPM(ctx context.Context, userID, groupID int64) (count int, err error)
// GetUserRPM 获取用户当前分钟已用 RPM(只读,不递增)。
GetUserRPM(ctx context.Context, userID int64) (count int, err error)
}
...@@ -39,6 +39,11 @@ func ProvideEmailQueueService(emailService *EmailService) *EmailQueueService { ...@@ -39,6 +39,11 @@ func ProvideEmailQueueService(emailService *EmailService) *EmailQueueService {
return NewEmailQueueService(emailService, 3) return NewEmailQueueService(emailService, 3)
} }
// ProvideOAuthRefreshAPI creates OAuthRefreshAPI with the default lock TTL.
func ProvideOAuthRefreshAPI(accountRepo AccountRepository, tokenCache GeminiTokenCache) *OAuthRefreshAPI {
return NewOAuthRefreshAPI(accountRepo, tokenCache)
}
// ProvideTokenRefreshService creates and starts TokenRefreshService // ProvideTokenRefreshService creates and starts TokenRefreshService
func ProvideTokenRefreshService( func ProvideTokenRefreshService(
accountRepo AccountRepository, accountRepo AccountRepository,
...@@ -383,6 +388,19 @@ func ProvideSettingService(settingRepo SettingRepository, groupRepo GroupReposit ...@@ -383,6 +388,19 @@ func ProvideSettingService(settingRepo SettingRepository, groupRepo GroupReposit
return svc return svc
} }
// ProvideBillingCacheService wires BillingCacheService with its RPM dependencies.
func ProvideBillingCacheService(
cache BillingCache,
userRepo UserRepository,
subRepo UserSubscriptionRepository,
apiKeyRepo APIKeyRepository,
rpmCache UserRPMCache,
rateRepo UserGroupRateRepository,
cfg *config.Config,
) *BillingCacheService {
return NewBillingCacheService(cache, userRepo, subRepo, apiKeyRepo, rpmCache, rateRepo, cfg)
}
// ProviderSet is the Wire provider set for all services // ProviderSet is the Wire provider set for all services
var ProviderSet = wire.NewSet( var ProviderSet = wire.NewSet(
// Core services // Core services
...@@ -399,7 +417,7 @@ var ProviderSet = wire.NewSet( ...@@ -399,7 +417,7 @@ var ProviderSet = wire.NewSet(
NewDashboardService, NewDashboardService,
ProvidePricingService, ProvidePricingService,
NewBillingService, NewBillingService,
NewBillingCacheService, ProvideBillingCacheService,
NewAnnouncementService, NewAnnouncementService,
NewAdminService, NewAdminService,
NewGatewayService, NewGatewayService,
...@@ -411,7 +429,7 @@ var ProviderSet = wire.NewSet( ...@@ -411,7 +429,7 @@ var ProviderSet = wire.NewSet(
NewCompositeTokenCacheInvalidator, NewCompositeTokenCacheInvalidator,
wire.Bind(new(TokenCacheInvalidator), new(*CompositeTokenCacheInvalidator)), wire.Bind(new(TokenCacheInvalidator), new(*CompositeTokenCacheInvalidator)),
NewAntigravityOAuthService, NewAntigravityOAuthService,
NewOAuthRefreshAPI, ProvideOAuthRefreshAPI,
ProvideGeminiTokenProvider, ProvideGeminiTokenProvider,
NewGeminiMessagesCompatService, NewGeminiMessagesCompatService,
ProvideAntigravityTokenProvider, ProvideAntigravityTokenProvider,
......
-- Add per-group Requests-Per-Minute limit.
-- rpm_limit: 分组统一 RPM 上限(0 = 不限制)。
-- 一旦配置即接管该用户在该分组的限流,覆盖用户级 users.rpm_limit。
-- 计数键:rpm:ug:{user_id}:{group_id}:{minute}。
ALTER TABLE groups ADD COLUMN IF NOT EXISTS rpm_limit integer NOT NULL DEFAULT 0;
COMMENT ON COLUMN groups.rpm_limit IS '分组 RPM 上限;0 表示不限制;设置后接管该分组用户的限流(覆盖用户级 rpm_limit)。';
-- Add per-user Requests-Per-Minute cap.
-- rpm_limit: 用户全局 RPM 兜底(0 = 不限制)。
-- 仅当所访问分组未设置 rpm_limit 且无 user-group rpm_override 时作为兜底生效。
-- 计数键:rpm:u:{user_id}:{minute}。
ALTER TABLE users ADD COLUMN IF NOT EXISTS rpm_limit integer NOT NULL DEFAULT 0;
COMMENT ON COLUMN users.rpm_limit IS '用户级 RPM 兜底上限;0 表示不限制;仅当分组未设置 rpm_limit 时生效。';
-- 在已有的"用户专属分组倍率表"上扩展 rpm_override 列;同时放宽 rate_multiplier 为可空,
-- 使一行记录可以只覆盖 rate、只覆盖 rpm,或同时覆盖两者。
-- 语义:
-- - rate_multiplier NULL → 该用户在此分组使用 groups.rate_multiplier 默认值
-- - rate_multiplier 非 NULL → 覆盖分组默认计费倍率
-- - rpm_override NULL → 该用户在此分组使用 groups.rpm_limit 默认值
-- - rpm_override 非 NULL → 覆盖分组默认 RPM(0 = 不限制)
-- 用户级 users.rpm_limit 仍独立生效(跨分组总配额)。
ALTER TABLE user_group_rate_multipliers
ADD COLUMN IF NOT EXISTS rpm_override integer NULL;
ALTER TABLE user_group_rate_multipliers
ALTER COLUMN rate_multiplier DROP NOT NULL;
COMMENT ON COLUMN user_group_rate_multipliers.rate_multiplier IS '专属计费倍率;NULL 表示沿用分组默认倍率。';
COMMENT ON COLUMN user_group_rate_multipliers.rpm_override IS '专属 RPM 上限;NULL 表示沿用分组默认;0 表示该用户在此分组不受 RPM 限制。';
...@@ -164,7 +164,8 @@ export interface GroupRateMultiplierEntry { ...@@ -164,7 +164,8 @@ export interface GroupRateMultiplierEntry {
user_email: string user_email: string
user_notes: string user_notes: string
user_status: 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: ...@@ -205,9 +206,7 @@ export async function clearGroupRateMultipliers(id: number): Promise<{ message:
/** /**
* Batch set rate multipliers for users in a group * Batch set rate multipliers for users in a group
* @param id - Group ID * Only touches rate_multiplier column; preserves rpm_override on existing rows.
* @param entries - Array of { user_id, rate_multiplier }
* @returns Success confirmation
*/ */
export async function batchSetGroupRateMultipliers( export async function batchSetGroupRateMultipliers(
id: number, id: number,
...@@ -220,6 +219,60 @@ export async function batchSetGroupRateMultipliers( ...@@ -220,6 +219,60 @@ export async function batchSetGroupRateMultipliers(
return data 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 * Get usage summary (today + cumulative cost) for all groups
* @param timezone - IANA timezone string (e.g. "Asia/Shanghai") * @param timezone - IANA timezone string (e.g. "Asia/Shanghai")
...@@ -262,6 +315,9 @@ export const groupsAPI = { ...@@ -262,6 +315,9 @@ export const groupsAPI = {
getGroupRateMultipliers, getGroupRateMultipliers,
clearGroupRateMultipliers, clearGroupRateMultipliers,
batchSetGroupRateMultipliers, batchSetGroupRateMultipliers,
getGroupRPMOverrides,
clearGroupRPMOverrides,
batchSetGroupRPMOverrides,
updateSortOrder, updateSortOrder,
getUsageSummary, getUsageSummary,
getCapacitySummary getCapacitySummary
......
...@@ -309,6 +309,7 @@ export interface SystemSettings { ...@@ -309,6 +309,7 @@ export interface SystemSettings {
// Default settings // Default settings
default_balance: number; default_balance: number;
default_concurrency: number; default_concurrency: number;
default_user_rpm_limit: number;
default_subscriptions: DefaultSubscriptionSetting[]; default_subscriptions: DefaultSubscriptionSetting[];
auth_source_default_email_balance?: number; auth_source_default_email_balance?: number;
auth_source_default_email_concurrency?: number; auth_source_default_email_concurrency?: number;
...@@ -482,6 +483,7 @@ export interface UpdateSettingsRequest { ...@@ -482,6 +483,7 @@ export interface UpdateSettingsRequest {
totp_enabled?: boolean; // TOTP 双因素认证 totp_enabled?: boolean; // TOTP 双因素认证
default_balance?: number; default_balance?: number;
default_concurrency?: number; default_concurrency?: number;
default_user_rpm_limit?: number;
default_subscriptions?: DefaultSubscriptionSetting[]; default_subscriptions?: DefaultSubscriptionSetting[];
auth_source_default_email_balance?: number; auth_source_default_email_balance?: number;
auth_source_default_email_concurrency?: number; auth_source_default_email_concurrency?: number;
......
<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>
...@@ -168,7 +168,8 @@ ...@@ -168,7 +168,8 @@
step="0.001" step="0.001"
min="0.001" min="0.001"
autocomplete="off" 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" 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)" @change="updateLocalRate(entry.user_id, ($event.target as HTMLInputElement).value)"
/> />
...@@ -294,19 +295,17 @@ const showFinalRate = computed(() => { ...@@ -294,19 +295,17 @@ const showFinalRate = computed(() => {
}) })
// 计算最终倍率预览 // 计算最终倍率预览
const computeFinalRate = (rate: number) => { const computeFinalRate = (rate: number | null | undefined) => {
if (!batchFactor.value) return rate const base = rate ?? props.group?.rate_multiplier ?? 1
return parseFloat((rate * batchFactor.value).toFixed(6)) if (!batchFactor.value) return base
return parseFloat((base * batchFactor.value).toFixed(6))
} }
// 检测是否有未保存的修改 // 检测是否有未保存的修改
const isDirty = computed(() => { const isDirty = computed(() => {
if (localEntries.value.length !== serverEntries.value.length) return true if (localEntries.value.length !== serverEntries.value.length) return true
const serverMap = new Map(serverEntries.value.map(e => [e.user_id, e.rate_multiplier])) const serverMap = new Map(serverEntries.value.map(e => [e.user_id, e.rate_multiplier ?? null]))
return localEntries.value.some(e => { return localEntries.value.some(e => serverMap.get(e.user_id) !== (e.rate_multiplier ?? null))
const serverRate = serverMap.get(e.user_id)
return serverRate === undefined || serverRate !== e.rate_multiplier
})
}) })
const paginatedLocalEntries = computed(() => { const paginatedLocalEntries = computed(() => {
...@@ -322,7 +321,9 @@ const loadEntries = async () => { ...@@ -322,7 +321,9 @@ const loadEntries = async () => {
if (!props.group) return if (!props.group) return
loading.value = true loading.value = true
try { 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) localEntries.value = cloneEntries(serverEntries.value)
adjustPage() adjustPage()
} catch (error) { } catch (error) {
...@@ -394,7 +395,8 @@ const handleAddLocal = () => { ...@@ -394,7 +395,8 @@ const handleAddLocal = () => {
user_email: user.email, user_email: user.email,
user_notes: user.notes || '', user_notes: user.notes || '',
user_status: user.status || 'active', user_status: user.status || 'active',
rate_multiplier: newRate.value rate_multiplier: newRate.value,
rpm_override: null
} }
if (idx >= 0) { if (idx >= 0) {
localEntries.value[idx] = entry localEntries.value[idx] = entry
...@@ -409,12 +411,15 @@ const handleAddLocal = () => { ...@@ -409,12 +411,15 @@ const handleAddLocal = () => {
// 本地修改倍率 // 本地修改倍率
const updateLocalRate = (userId: number, value: string) => { const updateLocalRate = (userId: number, value: string) => {
const num = parseFloat(value)
if (isNaN(num)) return
const entry = localEntries.value.find(e => e.user_id === userId) const entry = localEntries.value.find(e => e.user_id === userId)
if (entry) { if (!entry) return
entry.rate_multiplier = num if (value.trim() === '') {
entry.rate_multiplier = null
return
} }
const num = parseFloat(value)
if (isNaN(num)) return
entry.rate_multiplier = num
} }
// 本地删除 // 本地删除
...@@ -427,7 +432,9 @@ const removeLocal = (userId: number) => { ...@@ -427,7 +432,9 @@ const removeLocal = (userId: number) => {
const applyBatchFactor = () => { const applyBatchFactor = () => {
if (!batchFactor.value || batchFactor.value <= 0) return if (!batchFactor.value || batchFactor.value <= 0) return
for (const entry of localEntries.value) { 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 batchFactor.value = null
} }
...@@ -444,15 +451,17 @@ const handleCancel = () => { ...@@ -444,15 +451,17 @@ const handleCancel = () => {
adjustPage() adjustPage()
} }
// 保存:一次性提交所有数据 // 保存:一次性提交所有数据(只提交 rate_multiplier;rpm_override 由独立弹窗管理)
const handleSave = async () => { const handleSave = async () => {
if (!props.group) return if (!props.group) return
saving.value = true saving.value = true
try { try {
const entries = localEntries.value.map(e => ({ const entries = localEntries.value
user_id: e.user_id, .filter(e => e.rate_multiplier != null)
rate_multiplier: e.rate_multiplier .map(e => ({
})) user_id: e.user_id,
rate_multiplier: e.rate_multiplier as number
}))
await adminAPI.groups.batchSetGroupRateMultipliers(props.group.id, entries) await adminAPI.groups.batchSetGroupRateMultipliers(props.group.id, entries)
appStore.showSuccess(t('admin.groups.rateSaved')) appStore.showSuccess(t('admin.groups.rateSaved'))
emit('success') emit('success')
......
...@@ -35,6 +35,18 @@ ...@@ -35,6 +35,18 @@
<input v-model.number="form.concurrency" type="number" class="input" /> <input v-model.number="form.concurrency" type="number" class="input" />
</div> </div>
</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> </form>
<template #footer> <template #footer>
<div class="flex justify-end gap-3"> <div class="flex justify-end gap-3">
...@@ -57,7 +69,7 @@ import Icon from '@/components/icons/Icon.vue' ...@@ -57,7 +69,7 @@ import Icon from '@/components/icons/Icon.vue'
const props = defineProps<{ show: boolean }>() const props = defineProps<{ show: boolean }>()
const emit = defineEmits(['close', 'success']); const { t } = useI18n() 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({ const { loading, submit } = useForm({
form, form,
...@@ -68,7 +80,7 @@ const { loading, submit } = useForm({ ...@@ -68,7 +80,7 @@ const { loading, submit } = useForm({
successMsg: t('admin.users.userCreated') 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 generateRandomPassword = () => {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%^&*' const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%^&*'
......
...@@ -37,6 +37,18 @@ ...@@ -37,6 +37,18 @@
<label class="input-label">{{ t('admin.users.columns.concurrency') }}</label> <label class="input-label">{{ t('admin.users.columns.concurrency') }}</label>
<input v-model.number="form.concurrency" type="number" class="input" /> <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>
<UserAttributeForm v-model="form.customAttributes" :user-id="user?.id" /> <UserAttributeForm v-model="form.customAttributes" :user-id="user?.id" />
</form> </form>
<template #footer> <template #footer>
...@@ -66,11 +78,11 @@ const emit = defineEmits(['close', 'success']) ...@@ -66,11 +78,11 @@ const emit = defineEmits(['close', 'success'])
const { t } = useI18n(); const appStore = useAppStore(); const { copyToClipboard } = useClipboard() const { t } = useI18n(); const appStore = useAppStore(); const { copyToClipboard } = useClipboard()
const submitting = ref(false); const passwordCopied = ref(false) 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) => { watch(() => props.user, (u) => {
if (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 passwordCopied.value = false
} }
}, { immediate: true }) }, { immediate: true })
...@@ -97,7 +109,7 @@ const handleUpdateUser = async () => { ...@@ -97,7 +109,7 @@ const handleUpdateUser = async () => {
} }
submitting.value = true submitting.value = true
try { 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() if (form.password.trim()) data.password = form.password.trim()
await adminAPI.users.update(props.user.id, data) await adminAPI.users.update(props.user.id, data)
if (Object.keys(form.customAttributes).length > 0) await adminAPI.userAttributes.updateUserAttributeValues(props.user.id, form.customAttributes) if (Object.keys(form.customAttributes).length > 0) await adminAPI.userAttributes.updateUserAttributeValues(props.user.id, form.customAttributes)
......
...@@ -894,6 +894,8 @@ export default { ...@@ -894,6 +894,8 @@ export default {
description: 'Manage your account information and settings', description: 'Manage your account information and settings',
accountBalance: 'Account Balance', accountBalance: 'Account Balance',
concurrencyLimit: 'Concurrency Limit', concurrencyLimit: 'Concurrency Limit',
rpmLimit: 'RPM Limit',
rpmUnlimited: 'Unlimited',
memberSince: 'Member Since', memberSince: 'Member Since',
overviewTitle: 'Account Overview', overviewTitle: 'Account Overview',
overviewDescription: 'Check account status, profile sources, and common actions at a glance.', overviewDescription: 'Check account status, profile sources, and common actions at a glance.',
...@@ -1490,6 +1492,11 @@ export default { ...@@ -1490,6 +1492,11 @@ export default {
copyPassword: 'Copy password', copyPassword: 'Copy password',
creating: 'Creating...', creating: 'Creating...',
updating: 'Updating...', 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: { columns: {
user: 'User', user: 'User',
id: 'ID', id: 'ID',
...@@ -1704,6 +1711,10 @@ export default { ...@@ -1704,6 +1711,10 @@ export default {
name: 'Name', name: 'Name',
platform: 'Platform', platform: 'Platform',
rateMultiplier: 'Rate Multiplier', 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', type: 'Type',
accounts: 'Accounts', accounts: 'Accounts',
capacity: 'Capacity', capacity: 'Capacity',
...@@ -1730,7 +1741,10 @@ export default { ...@@ -1730,7 +1741,10 @@ export default {
platform: 'Platform', platform: 'Platform',
rateMultiplier: 'Rate Multiplier', rateMultiplier: 'Rate Multiplier',
status: 'Status', 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', enterGroupName: 'Enter group name',
optionalDescription: 'Optional description', optionalDescription: 'Optional description',
...@@ -1762,6 +1776,12 @@ export default { ...@@ -1762,6 +1776,12 @@ export default {
rateMultipliers: 'Rate Multipliers', rateMultipliers: 'Rate Multipliers',
rateMultipliersTitle: 'Group Rate Multipliers', rateMultipliersTitle: 'Group Rate Multipliers',
addUserRate: 'Add User Rate Multiplier', 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...', searchUserPlaceholder: 'Search user email...',
noRateMultipliers: 'No user rate multipliers configured', noRateMultipliers: 'No user rate multipliers configured',
rateUpdated: 'Rate multiplier updated', rateUpdated: 'Rate multiplier updated',
...@@ -4503,6 +4523,8 @@ export default { ...@@ -4503,6 +4523,8 @@ export default {
defaultBalanceHint: 'Initial balance for new users', defaultBalanceHint: 'Initial balance for new users',
defaultConcurrency: 'Default Concurrency', defaultConcurrency: 'Default Concurrency',
defaultConcurrencyHint: 'Maximum concurrent requests for new users', 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', defaultSubscriptions: 'Default Subscriptions',
defaultSubscriptionsHint: 'Auto-assign these subscriptions when a new user is created or registered', defaultSubscriptionsHint: 'Auto-assign these subscriptions when a new user is created or registered',
addDefaultSubscription: 'Add Default Subscription', addDefaultSubscription: 'Add Default Subscription',
......
...@@ -898,6 +898,8 @@ export default { ...@@ -898,6 +898,8 @@ export default {
description: '管理您的账户信息和设置', description: '管理您的账户信息和设置',
accountBalance: '账户余额', accountBalance: '账户余额',
concurrencyLimit: '并发限制', concurrencyLimit: '并发限制',
rpmLimit: 'RPM 限制',
rpmUnlimited: '不限制',
memberSince: '注册时间', memberSince: '注册时间',
overviewTitle: '账户总览', overviewTitle: '账户总览',
overviewDescription: '快速查看账号状态、资料来源与常用设置。', overviewDescription: '快速查看账号状态、资料来源与常用设置。',
...@@ -1589,7 +1591,10 @@ export default { ...@@ -1589,7 +1591,10 @@ export default {
balanceLabel: '余额', balanceLabel: '余额',
concurrencyLabel: '并发数', concurrencyLabel: '并发数',
statusLabel: '状态', statusLabel: '状态',
selectStatus: '选择状态' selectStatus: '选择状态',
rpmLimit: '每分钟请求数 (RPM)',
rpmLimitPlaceholder: '0 表示不限制',
rpmLimitHint: '该用户每分钟最大请求数,0 = 不限制;仅在所用分组未设置 rpm_limit 时作为兜底生效'
}, },
adjustBalance: '调整余额', adjustBalance: '调整余额',
adjustConcurrency: '调整并发数', adjustConcurrency: '调整并发数',
...@@ -1756,6 +1761,10 @@ export default { ...@@ -1756,6 +1761,10 @@ export default {
name: '名称', name: '名称',
platform: '平台', platform: '平台',
rateMultiplier: '费率倍数', rateMultiplier: '费率倍数',
rpmOverride: 'RPM 覆盖',
rpmOverrideHint: '该用户在此分组的 RPM 上限;留空 = 使用分组默认;0 = 不限制',
rateDefault: '默认',
rpmDefault: '默认',
exclusive: '独占', exclusive: '独占',
type: '类型', type: '类型',
priority: '优先级', priority: '优先级',
...@@ -1790,6 +1799,9 @@ export default { ...@@ -1790,6 +1799,9 @@ export default {
descriptionPlaceholder: '请输入描述(可选)', descriptionPlaceholder: '请输入描述(可选)',
rateMultiplierLabel: '费率倍数', rateMultiplierLabel: '费率倍数',
rateMultiplierHint: '1.0 = 标准费率,0.5 = 半价,2.0 = 双倍', rateMultiplierHint: '1.0 = 标准费率,0.5 = 半价,2.0 = 双倍',
rpmLimit: '每分钟请求数 (RPM)',
rpmLimitPlaceholder: '0 表示不限制',
rpmLimitHint: '每用户在本分组每分钟最大请求数,0 = 不限制;一旦设置即接管该用户的限流(覆盖用户级 rpm_limit)',
exclusiveLabel: '专属分组', exclusiveLabel: '专属分组',
exclusiveHint: '专属分组,可以手动指定给用户', exclusiveHint: '专属分组,可以手动指定给用户',
platformLabel: '平台限制', platformLabel: '平台限制',
...@@ -1859,6 +1871,12 @@ export default { ...@@ -1859,6 +1871,12 @@ export default {
rateMultipliers: '专属倍率', rateMultipliers: '专属倍率',
rateMultipliersTitle: '分组专属倍率管理', rateMultipliersTitle: '分组专属倍率管理',
addUserRate: '添加用户专属倍率', addUserRate: '添加用户专属倍率',
rpmOverrides: '专属 RPM',
rpmOverridesTitle: '分组专属 RPM 管理',
addUserRpm: '添加用户专属 RPM',
noRpmOverrides: '暂无用户设置了专属 RPM',
rpmSaved: '专属 RPM 已保存',
groupRpmDefault: '分组默认 RPM',
searchUserPlaceholder: '搜索用户邮箱...', searchUserPlaceholder: '搜索用户邮箱...',
noRateMultipliers: '暂无用户设置了专属倍率', noRateMultipliers: '暂无用户设置了专属倍率',
rateUpdated: '专属倍率已更新', rateUpdated: '专属倍率已更新',
...@@ -4668,6 +4686,8 @@ export default { ...@@ -4668,6 +4686,8 @@ export default {
defaultBalanceHint: '新用户的初始余额', defaultBalanceHint: '新用户的初始余额',
defaultConcurrency: '默认并发数', defaultConcurrency: '默认并发数',
defaultConcurrencyHint: '新用户的最大并发请求数', defaultConcurrencyHint: '新用户的最大并发请求数',
defaultUserRpmLimit: '默认用户 RPM 限制',
defaultUserRpmLimitHint: '新用户默认每分钟最大请求数,0 = 不限制;仅作用于新用户创建时初始化',
defaultSubscriptions: '默认订阅列表', defaultSubscriptions: '默认订阅列表',
defaultSubscriptionsHint: '新用户创建或注册时自动分配这些订阅', defaultSubscriptionsHint: '新用户创建或注册时自动分配这些订阅',
addDefaultSubscription: '添加默认订阅', addDefaultSubscription: '添加默认订阅',
......
...@@ -87,6 +87,7 @@ export interface User { ...@@ -87,6 +87,7 @@ export interface User {
role: 'admin' | 'user' // User role for authorization role: 'admin' | 'user' // User role for authorization
balance: number // User balance for API usage balance: number // User balance for API usage
concurrency: number // Allowed concurrent requests 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 status: 'active' | 'disabled' // Account status
allowed_groups: number[] | null // Allowed group IDs (null = all non-exclusive groups) allowed_groups: number[] | null // Allowed group IDs (null = all non-exclusive groups)
balance_notify_enabled: boolean balance_notify_enabled: boolean
...@@ -453,6 +454,7 @@ export interface Group { ...@@ -453,6 +454,7 @@ export interface Group {
description: string | null description: string | null
platform: GroupPlatform platform: GroupPlatform
rate_multiplier: number rate_multiplier: number
rpm_limit?: number // Group-level RPM cap (0 = unlimited); overrides user-level rpm_limit when set
is_exclusive: boolean is_exclusive: boolean
status: 'active' | 'inactive' status: 'active' | 'inactive'
subscription_type: SubscriptionType subscription_type: SubscriptionType
......
...@@ -308,6 +308,15 @@ ...@@ -308,6 +308,15 @@
t("admin.groups.rateMultipliers") t("admin.groups.rateMultipliers")
}}</span> }}</span>
</button> </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 <button
@click="handleDelete(row)" @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" 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 @@ ...@@ -491,6 +500,18 @@
/> />
<p class="input-hint">{{ t("admin.groups.rateMultiplierHint") }}</p> <p class="input-hint">{{ t("admin.groups.rateMultiplierHint") }}</p>
</div> </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 <div
v-if="createForm.subscription_type !== 'subscription'" v-if="createForm.subscription_type !== 'subscription'"
data-tour="group-form-exclusive" data-tour="group-form-exclusive"
...@@ -1612,6 +1633,18 @@ ...@@ -1612,6 +1633,18 @@
data-tour="group-form-multiplier" data-tour="group-form-multiplier"
/> />
</div> </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 v-if="editForm.subscription_type !== 'subscription'">
<div class="mb-1.5 flex items-center gap-1"> <div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300"> <label class="text-sm font-medium text-gray-700 dark:text-gray-300">
...@@ -2689,6 +2722,14 @@ ...@@ -2689,6 +2722,14 @@
@close="showRateMultipliersModal = false" @close="showRateMultipliersModal = false"
@success="loadGroups" @success="loadGroups"
/> />
<!-- Group RPM Overrides Modal -->
<GroupRPMOverridesModal
:show="showRPMOverridesModal"
:group="rpmOverridesGroup"
@close="showRPMOverridesModal = false"
@success="loadGroups"
/>
</AppLayout> </AppLayout>
</template> </template>
...@@ -2711,6 +2752,7 @@ import Select from "@/components/common/Select.vue"; ...@@ -2711,6 +2752,7 @@ import Select from "@/components/common/Select.vue";
import PlatformIcon from "@/components/common/PlatformIcon.vue"; import PlatformIcon from "@/components/common/PlatformIcon.vue";
import Icon from "@/components/icons/Icon.vue"; import Icon from "@/components/icons/Icon.vue";
import GroupRateMultipliersModal from "@/components/admin/group/GroupRateMultipliersModal.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 GroupCapacityBadge from "@/components/common/GroupCapacityBadge.vue";
import { VueDraggable } from "vue-draggable-plus"; import { VueDraggable } from "vue-draggable-plus";
import { createStableObjectKeyResolver } from "@/utils/stableObjectKey"; import { createStableObjectKeyResolver } from "@/utils/stableObjectKey";
...@@ -2951,6 +2993,8 @@ const editingGroup = ref<AdminGroup | null>(null); ...@@ -2951,6 +2993,8 @@ const editingGroup = ref<AdminGroup | null>(null);
const deletingGroup = ref<AdminGroup | null>(null); const deletingGroup = ref<AdminGroup | null>(null);
const showRateMultipliersModal = ref(false); const showRateMultipliersModal = ref(false);
const rateMultipliersGroup = ref<AdminGroup | null>(null); const rateMultipliersGroup = ref<AdminGroup | null>(null);
const showRPMOverridesModal = ref(false);
const rpmOverridesGroup = ref<AdminGroup | null>(null);
const sortableGroups = ref<AdminGroup[]>([]); const sortableGroups = ref<AdminGroup[]>([]);
const createMessagesDispatchDefaults = createDefaultMessagesDispatchFormState(); const createMessagesDispatchDefaults = createDefaultMessagesDispatchFormState();
const editMessagesDispatchDefaults = createDefaultMessagesDispatchFormState(); const editMessagesDispatchDefaults = createDefaultMessagesDispatchFormState();
...@@ -2990,6 +3034,8 @@ const createForm = reactive({ ...@@ -2990,6 +3034,8 @@ const createForm = reactive({
mcp_xml_inject: true, mcp_xml_inject: true,
// 从分组复制账号 // 从分组复制账号
copy_accounts_from_group_ids: [] as number[], copy_accounts_from_group_ids: [] as number[],
// 分组级 RPM 限制(每用户每分钟最大请求数;0 = 不限制)
rpm_limit: 0 as number,
}); });
// 简单账号类型(用于模型路由选择) // 简单账号类型(用于模型路由选择)
...@@ -3271,6 +3317,8 @@ const editForm = reactive({ ...@@ -3271,6 +3317,8 @@ const editForm = reactive({
mcp_xml_inject: true, mcp_xml_inject: true,
// 从分组复制账号 // 从分组复制账号
copy_accounts_from_group_ids: [] as number[], copy_accounts_from_group_ids: [] as number[],
// 分组级 RPM 限制(每用户每分钟最大请求数;0 = 不限制)
rpm_limit: 0 as number,
}); });
// 根据分组类型返回不同的删除确认消息 // 根据分组类型返回不同的删除确认消息
...@@ -3562,6 +3610,7 @@ const handleEdit = async (group: AdminGroup) => { ...@@ -3562,6 +3610,7 @@ const handleEdit = async (group: AdminGroup) => {
]; ];
editForm.mcp_xml_inject = group.mcp_xml_inject ?? true; editForm.mcp_xml_inject = group.mcp_xml_inject ?? true;
editForm.copy_accounts_from_group_ids = []; // 复制账号字段每次编辑时重置为空 editForm.copy_accounts_from_group_ids = []; // 复制账号字段每次编辑时重置为空
editForm.rpm_limit = group.rpm_limit ?? 0;
// 加载模型路由规则(异步加载账号名称) // 加载模型路由规则(异步加载账号名称)
editModelRoutingRules.value = await convertApiFormatToRoutingRules( editModelRoutingRules.value = await convertApiFormatToRoutingRules(
group.model_routing, group.model_routing,
...@@ -3670,6 +3719,11 @@ const handleRateMultipliers = (group: AdminGroup) => { ...@@ -3670,6 +3719,11 @@ const handleRateMultipliers = (group: AdminGroup) => {
showRateMultipliersModal.value = true; showRateMultipliersModal.value = true;
}; };
const handleRPMOverrides = (group: AdminGroup) => {
rpmOverridesGroup.value = group;
showRPMOverridesModal.value = true;
};
const handleDelete = (group: AdminGroup) => { const handleDelete = (group: AdminGroup) => {
deletingGroup.value = group; deletingGroup.value = group;
showDeleteDialog.value = true; showDeleteDialog.value = true;
......
...@@ -2170,6 +2170,24 @@ ...@@ -2170,6 +2170,24 @@
{{ t("admin.settings.defaults.defaultConcurrencyHint") }} {{ t("admin.settings.defaults.defaultConcurrencyHint") }}
</p> </p>
</div> </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>
<div class="border-t border-gray-100 pt-4 dark:border-dark-700"> <div class="border-t border-gray-100 pt-4 dark:border-dark-700">
...@@ -4867,6 +4885,7 @@ const form = reactive<SettingsForm>({ ...@@ -4867,6 +4885,7 @@ const form = reactive<SettingsForm>({
default_concurrency: 1, default_concurrency: 1,
default_subscriptions: [], default_subscriptions: [],
force_email_on_third_party_signup: false, force_email_on_third_party_signup: false,
default_user_rpm_limit: 0,
site_name: "Sub2API", site_name: "Sub2API",
site_logo: "", site_logo: "",
site_subtitle: "Subscription to API Conversion Platform", site_subtitle: "Subscription to API Conversion Platform",
...@@ -5783,6 +5802,7 @@ async function saveSettings() { ...@@ -5783,6 +5802,7 @@ async function saveSettings() {
default_concurrency: form.default_concurrency, default_concurrency: form.default_concurrency,
default_subscriptions: normalizedDefaultSubscriptions, default_subscriptions: normalizedDefaultSubscriptions,
force_email_on_third_party_signup: form.force_email_on_third_party_signup, 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_name: form.site_name,
site_logo: form.site_logo, site_logo: form.site_logo,
site_subtitle: form.site_subtitle, site_subtitle: form.site_subtitle,
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment