Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
5e060b22
Commit
5e060b22
authored
Apr 23, 2026
by
erio
Browse files
Merge remote-tracking branch 'upstream/main' into feat/channel-insights
# Conflicts: # backend/cmd/server/wire_gen.go
parents
6f04c25e
0a80ec80
Changes
106
Show whitespace changes
Inline
Side-by-side
backend/internal/service/ratelimit_service_403_test.go
0 → 100644
View file @
5e060b22
//go:build unit
package
service
import
(
"context"
"net/http"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/stretchr/testify/require"
)
func
TestRateLimitService_HandleUpstreamError_OpenAI403FirstHitTempUnschedulable
(
t
*
testing
.
T
)
{
repo
:=
&
rateLimitAccountRepoStub
{}
counter
:=
&
openAI403CounterCacheStub
{
counts
:
[]
int64
{
1
}}
service
:=
NewRateLimitService
(
repo
,
nil
,
&
config
.
Config
{},
nil
,
nil
)
service
.
SetOpenAI403CounterCache
(
counter
)
account
:=
&
Account
{
ID
:
301
,
Platform
:
PlatformOpenAI
,
Type
:
AccountTypeOAuth
,
}
shouldDisable
:=
service
.
HandleUpstreamError
(
context
.
Background
(),
account
,
http
.
StatusForbidden
,
http
.
Header
{},
[]
byte
(
`{"error":{"message":"temporary edge rejection"}}`
),
)
require
.
True
(
t
,
shouldDisable
)
require
.
Equal
(
t
,
0
,
repo
.
setErrorCalls
)
require
.
Equal
(
t
,
1
,
repo
.
tempCalls
)
require
.
Contains
(
t
,
repo
.
lastTempReason
,
"temporary edge rejection"
)
require
.
Contains
(
t
,
repo
.
lastTempReason
,
"(1/3)"
)
}
func
TestRateLimitService_HandleUpstreamError_OpenAI403ThresholdDisables
(
t
*
testing
.
T
)
{
repo
:=
&
rateLimitAccountRepoStub
{}
counter
:=
&
openAI403CounterCacheStub
{
counts
:
[]
int64
{
3
}}
service
:=
NewRateLimitService
(
repo
,
nil
,
&
config
.
Config
{},
nil
,
nil
)
service
.
SetOpenAI403CounterCache
(
counter
)
account
:=
&
Account
{
ID
:
302
,
Platform
:
PlatformOpenAI
,
Type
:
AccountTypeOAuth
,
}
shouldDisable
:=
service
.
HandleUpstreamError
(
context
.
Background
(),
account
,
http
.
StatusForbidden
,
http
.
Header
{},
[]
byte
(
`{"error":{"message":"workspace forbidden by policy"}}`
),
)
require
.
True
(
t
,
shouldDisable
)
require
.
Equal
(
t
,
1
,
repo
.
setErrorCalls
)
require
.
Equal
(
t
,
0
,
repo
.
tempCalls
)
require
.
Contains
(
t
,
repo
.
lastErrorMsg
,
"workspace forbidden by policy"
)
require
.
Contains
(
t
,
repo
.
lastErrorMsg
,
"consecutive_403=3/3"
)
}
backend/internal/service/ratelimit_service_openai_test.go
View file @
5e060b22
...
...
@@ -7,6 +7,9 @@ import (
"net/http"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/stretchr/testify/require"
)
func
TestCalculateOpenAI429ResetTime_7dExhausted
(
t
*
testing
.
T
)
{
...
...
@@ -259,6 +262,53 @@ func TestNormalizedCodexLimits_OnlyPrimaryData(t *testing.T) {
}
}
func
TestRateLimitService_HandleUpstreamError_403PreservesOriginalUpstreamMessage
(
t
*
testing
.
T
)
{
repo
:=
&
rateLimitAccountRepoStub
{}
service
:=
NewRateLimitService
(
repo
,
nil
,
&
config
.
Config
{},
nil
,
nil
)
account
:=
&
Account
{
ID
:
201
,
Platform
:
PlatformOpenAI
,
Type
:
AccountTypeOAuth
,
}
shouldDisable
:=
service
.
HandleUpstreamError
(
context
.
Background
(),
account
,
403
,
http
.
Header
{},
[]
byte
(
`{"error":{"message":"workspace forbidden by policy","type":"invalid_request_error"}}`
),
)
require
.
True
(
t
,
shouldDisable
)
require
.
Equal
(
t
,
1
,
repo
.
setErrorCalls
)
require
.
Contains
(
t
,
repo
.
lastErrorMsg
,
"workspace forbidden by policy"
)
require
.
NotContains
(
t
,
repo
.
lastErrorMsg
,
"account may be suspended or lack permissions"
)
}
func
TestRateLimitService_HandleUpstreamError_403FallsBackToRawBody
(
t
*
testing
.
T
)
{
repo
:=
&
rateLimitAccountRepoStub
{}
service
:=
NewRateLimitService
(
repo
,
nil
,
&
config
.
Config
{},
nil
,
nil
)
account
:=
&
Account
{
ID
:
202
,
Platform
:
PlatformOpenAI
,
Type
:
AccountTypeOAuth
,
}
shouldDisable
:=
service
.
HandleUpstreamError
(
context
.
Background
(),
account
,
403
,
http
.
Header
{},
[]
byte
(
`{"error":{"type":"access_denied","details":{"reason":"ip_blocked"}}}`
),
)
require
.
True
(
t
,
shouldDisable
)
require
.
Equal
(
t
,
1
,
repo
.
setErrorCalls
)
require
.
Contains
(
t
,
repo
.
lastErrorMsg
,
`"access_denied"`
)
require
.
Contains
(
t
,
repo
.
lastErrorMsg
,
`"ip_blocked"`
)
require
.
NotContains
(
t
,
repo
.
lastErrorMsg
,
"account may be suspended or lack permissions"
)
}
func
TestNormalizedCodexLimits_OnlySecondaryData
(
t
*
testing
.
T
)
{
// Test when only secondary has data, no window_minutes
sUsed
:=
60.0
...
...
backend/internal/service/setting_service.go
View file @
5e060b22
...
...
@@ -1167,6 +1167,7 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
// 默认配置
updates
[
SettingKeyDefaultConcurrency
]
=
strconv
.
Itoa
(
settings
.
DefaultConcurrency
)
updates
[
SettingKeyDefaultBalance
]
=
strconv
.
FormatFloat
(
settings
.
DefaultBalance
,
'f'
,
8
,
64
)
updates
[
SettingKeyDefaultUserRPMLimit
]
=
strconv
.
Itoa
(
settings
.
DefaultUserRPMLimit
)
defaultSubsJSON
,
err
:=
json
.
Marshal
(
settings
.
DefaultSubscriptions
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"marshal default subscriptions: %w"
,
err
)
...
...
@@ -1538,6 +1539,18 @@ func (s *SettingService) GetDefaultBalance(ctx context.Context) float64 {
return
s
.
cfg
.
Default
.
UserBalance
}
// GetDefaultUserRPMLimit 获取新用户默认 RPM 限制(0 = 不限制)。未配置则返回 0。
func
(
s
*
SettingService
)
GetDefaultUserRPMLimit
(
ctx
context
.
Context
)
int
{
value
,
err
:=
s
.
settingRepo
.
GetValue
(
ctx
,
SettingKeyDefaultUserRPMLimit
)
if
err
!=
nil
||
value
==
""
{
return
0
}
if
v
,
err
:=
strconv
.
Atoi
(
value
);
err
==
nil
&&
v
>=
0
{
return
v
}
return
0
}
// GetDefaultSubscriptions 获取新用户默认订阅配置列表。
func
(
s
*
SettingService
)
GetDefaultSubscriptions
(
ctx
context
.
Context
)
[]
DefaultSubscriptionSetting
{
value
,
err
:=
s
.
settingRepo
.
GetValue
(
ctx
,
SettingKeyDefaultSubscriptions
)
...
...
@@ -1706,6 +1719,7 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
SettingKeyOIDCConnectUserInfoUsernamePath
:
""
,
SettingKeyDefaultConcurrency
:
strconv
.
Itoa
(
s
.
cfg
.
Default
.
UserConcurrency
),
SettingKeyDefaultBalance
:
strconv
.
FormatFloat
(
s
.
cfg
.
Default
.
UserBalance
,
'f'
,
8
,
64
),
SettingKeyDefaultUserRPMLimit
:
"0"
,
SettingKeyDefaultSubscriptions
:
"[]"
,
SettingKeyAuthSourceDefaultEmailBalance
:
"0"
,
SettingKeyAuthSourceDefaultEmailConcurrency
:
"5"
,
...
...
@@ -1822,6 +1836,10 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
result
.
DefaultConcurrency
=
s
.
cfg
.
Default
.
UserConcurrency
}
if
rpm
,
err
:=
strconv
.
Atoi
(
settings
[
SettingKeyDefaultUserRPMLimit
]);
err
==
nil
&&
rpm
>=
0
{
result
.
DefaultUserRPMLimit
=
rpm
}
// 解析浮点数类型
if
balance
,
err
:=
strconv
.
ParseFloat
(
settings
[
SettingKeyDefaultBalance
],
64
);
err
==
nil
{
result
.
DefaultBalance
=
balance
...
...
backend/internal/service/settings_view.go
View file @
5e060b22
...
...
@@ -106,6 +106,7 @@ type SystemSettings struct {
DefaultConcurrency
int
DefaultBalance
float64
DefaultUserRPMLimit
int
DefaultSubscriptions
[]
DefaultSubscriptionSetting
// Model fallback configuration
...
...
backend/internal/service/user.go
View file @
5e060b22
...
...
@@ -49,6 +49,15 @@ type User struct {
BalanceNotifyExtraEmails
[]
NotifyEmailEntry
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
Subscriptions
[]
UserSubscription
}
...
...
backend/internal/service/user_group_rate.go
View file @
5e060b22
...
...
@@ -2,14 +2,16 @@ package service
import
"context"
// UserGroupRateEntry 分组下用户专属倍率条目
// UserGroupRateEntry 分组下用户专属倍率/RPM 条目。
// RateMultiplier 与 RPMOverride 均为指针以支持"未设置"语义(NULL)。
type
UserGroupRateEntry
struct
{
UserID
int64
`json:"user_id"`
UserName
string
`json:"user_name"`
UserEmail
string
`json:"user_email"`
UserNotes
string
`json:"user_notes"`
UserStatus
string
`json:"user_status"`
RateMultiplier
float64
`json:"rate_multiplier"`
RateMultiplier
*
float64
`json:"rate_multiplier,omitempty"`
RPMOverride
*
int
`json:"rpm_override,omitempty"`
}
// GroupRateMultiplierInput 批量设置分组倍率的输入条目
...
...
@@ -18,30 +20,44 @@ type GroupRateMultiplierInput struct {
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
{
// GetByUserID 获取用户的所有专属分组倍率
// 返回 map[groupID]rateMultiplier
// GetByUserID 获取用户所有专属分组 rate_multiplier(仅返回非 NULL 的条目)
GetByUserID
(
ctx
context
.
Context
,
userID
int64
)
(
map
[
int64
]
float64
,
error
)
// GetByUserAndGroup 获取用户在特定分组的专属倍率
// 如果未设置专属倍率,返回 nil
// GetByUserAndGroup 获取用户在特定分组的专属 rate_multiplier(NULL 返回 nil)
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
)
// SyncUserGroupRates 同步用户的分组专属倍率
// rates: map[groupID]*rateMultiplier,nil 表示删除该分组的专属倍率
// SyncUserGroupRates 同步用户的分组专属倍率;nil 表示清空该分组的 rate_multiplier
SyncUserGroupRates
(
ctx
context
.
Context
,
userID
int64
,
rates
map
[
int64
]
*
float64
)
error
// SyncGroupRateMultipliers 批量同步分组的用户专属倍率(替换整组
数据
)
// SyncGroupRateMultipliers 批量同步分组的用户专属倍率(替换整组
rate 部分
)
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
// DeleteByUserID 删除指定用户的所有专属
倍率
(用户删除时调用)
// DeleteByUserID 删除指定用户的所有专属
条目
(用户删除时调用)
DeleteByUserID
(
ctx
context
.
Context
,
userID
int64
)
error
}
backend/internal/service/user_rpm_cache.go
0 → 100644
View file @
5e060b22
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
)
}
backend/internal/service/wire.go
View file @
5e060b22
...
...
@@ -39,6 +39,11 @@ func ProvideEmailQueueService(emailService *EmailService) *EmailQueueService {
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
func
ProvideTokenRefreshService
(
accountRepo
AccountRepository
,
...
...
@@ -210,11 +215,13 @@ func ProvideRateLimitService(
geminiQuotaService
*
GeminiQuotaService
,
tempUnschedCache
TempUnschedCache
,
timeoutCounterCache
TimeoutCounterCache
,
openAI403CounterCache
OpenAI403CounterCache
,
settingService
*
SettingService
,
tokenCacheInvalidator
TokenCacheInvalidator
,
)
*
RateLimitService
{
svc
:=
NewRateLimitService
(
accountRepo
,
usageRepo
,
cfg
,
geminiQuotaService
,
tempUnschedCache
)
svc
.
SetTimeoutCounterCache
(
timeoutCounterCache
)
svc
.
SetOpenAI403CounterCache
(
openAI403CounterCache
)
svc
.
SetSettingService
(
settingService
)
svc
.
SetTokenCacheInvalidator
(
tokenCacheInvalidator
)
return
svc
...
...
@@ -384,6 +391,19 @@ func ProvideSettingService(settingRepo SettingRepository, groupRepo GroupReposit
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
var
ProviderSet
=
wire
.
NewSet
(
// Core services
...
...
@@ -400,7 +420,7 @@ var ProviderSet = wire.NewSet(
NewDashboardService
,
ProvidePricingService
,
NewBillingService
,
New
BillingCacheService
,
Provide
BillingCacheService
,
NewAnnouncementService
,
NewAdminService
,
NewGatewayService
,
...
...
@@ -412,7 +432,7 @@ var ProviderSet = wire.NewSet(
NewCompositeTokenCacheInvalidator
,
wire
.
Bind
(
new
(
TokenCacheInvalidator
),
new
(
*
CompositeTokenCacheInvalidator
)),
NewAntigravityOAuthService
,
New
OAuthRefreshAPI
,
Provide
OAuthRefreshAPI
,
ProvideGeminiTokenProvider
,
NewGeminiMessagesCompatService
,
ProvideAntigravityTokenProvider
,
...
...
backend/migrations/125_add_group_rpm_limit.sql
0 → 100644
View file @
5e060b22
-- 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)。'
;
backend/migrations/126_add_user_rpm_limit.sql
0 → 100644
View file @
5e060b22
-- 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 时生效。'
;
backend/migrations/127_add_user_group_rpm_override.sql
0 → 100644
View file @
5e060b22
-- 在已有的"用户专属分组倍率表"上扩展 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 限制。'
;
frontend/src/api/admin/groups.ts
View file @
5e060b22
...
...
@@ -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
...
...
frontend/src/api/admin/settings.ts
View file @
5e060b22
...
...
@@ -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
;
...
...
@@ -489,6 +490,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
;
...
...
frontend/src/components/admin/group/GroupRPMOverridesModal.vue
0 → 100644
View file @
5e060b22
<
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
>
frontend/src/components/admin/group/GroupRateMultipliersModal.vue
View file @
5e060b22
...
...
@@ -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
}
}
// 本地删除
...
...
@@ -427,8 +432,10 @@ const removeLocal = (userId: number) => {
const
applyBatchFactor
=
()
=>
{
if
(
!
batchFactor
.
value
||
batchFactor
.
value
<=
0
)
return
for
(
const
entry
of
localEntries
.
value
)
{
if
(
entry
.
rate_multiplier
!=
null
)
{
entry
.
rate_multiplier
=
parseFloat
((
entry
.
rate_multiplier
*
batchFactor
.
value
).
toFixed
(
6
))
}
}
batchFactor
.
value
=
null
}
...
...
@@ -444,14 +451,16 @@ const handleCancel = () => {
adjustPage
()
}
// 保存:一次性提交所有数据
// 保存:一次性提交所有数据
(只提交 rate_multiplier;rpm_override 由独立弹窗管理)
const
handleSave
=
async
()
=>
{
if
(
!
props
.
group
)
return
saving
.
value
=
true
try
{
const
entries
=
localEntries
.
value
.
map
(
e
=>
({
const
entries
=
localEntries
.
value
.
filter
(
e
=>
e
.
rate_multiplier
!=
null
)
.
map
(
e
=>
({
user_id
:
e
.
user_id
,
rate_multiplier
:
e
.
rate_multiplier
rate_multiplier
:
e
.
rate_multiplier
as
number
}))
await
adminAPI
.
groups
.
batchSetGroupRateMultipliers
(
props
.
group
.
id
,
entries
)
appStore
.
showSuccess
(
t
(
'
admin.groups.rateSaved
'
))
...
...
frontend/src/components/admin/user/UserCreateModal.vue
View file @
5e060b22
...
...
@@ -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!@#$%^&*
'
...
...
frontend/src/components/admin/user/UserEditModal.vue
View file @
5e060b22
...
...
@@ -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
)
...
...
frontend/src/components/keys/UseKeyModal.vue
View file @
5e060b22
...
...
@@ -633,6 +633,22 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
xhigh
:
{}
}
},
'
gpt-5.5
'
:
{
name
:
'
GPT-5.5
'
,
limit
:
{
context
:
1050000
,
output
:
128000
},
options
:
{
store
:
false
},
variants
:
{
low
:
{},
medium
:
{},
high
:
{},
xhigh
:
{}
}
},
'
gpt-5.4
'
:
{
name
:
'
GPT-5.4
'
,
limit
:
{
...
...
frontend/src/composables/__tests__/useOpenAIOAuth.spec.ts
View file @
5e060b22
...
...
@@ -6,6 +6,19 @@ vi.mock('@/stores/app', () => ({
})
}))
vi
.
mock
(
'
vue-i18n
'
,
()
=>
({
useI18n
:
()
=>
({
t
:
(
key
:
string
)
=>
{
const
messages
:
Record
<
string
,
string
>
=
{
'
admin.accounts.oauth.openai.failedToExchangeCode
'
:
'
OpenAI 授权码兑换失败
'
,
'
admin.accounts.oauth.openai.errors.OPENAI_OAUTH_PROXY_REQUIRED
'
:
'
未设置代理,当前服务器无法直连 OpenAI,导致 OpenAI OAuth 请求失败。请先选择可访问 OpenAI 的代理后重试;如果授权码已失效,请重新生成授权链接。
'
}
return
messages
[
key
]
??
key
}
})
}))
vi
.
mock
(
'
@/api/admin
'
,
()
=>
({
adminAPI
:
{
accounts
:
{
...
...
@@ -17,6 +30,7 @@ vi.mock('@/api/admin', () => ({
}))
import
{
useOpenAIOAuth
}
from
'
@/composables/useOpenAIOAuth
'
import
{
adminAPI
}
from
'
@/api/admin
'
describe
(
'
useOpenAIOAuth.buildCredentials
'
,
()
=>
{
it
(
'
should keep client_id when token response contains it
'
,
()
=>
{
...
...
@@ -46,3 +60,21 @@ describe('useOpenAIOAuth.buildCredentials', () => {
expect
(
creds
.
refresh_token
).
toBe
(
'
rt
'
)
})
})
describe
(
'
useOpenAIOAuth.exchangeAuthCode
'
,
()
=>
{
it
(
'
shows a clear proxy hint when code exchange fails without a proxy
'
,
async
()
=>
{
vi
.
mocked
(
adminAPI
.
accounts
.
exchangeCode
).
mockRejectedValueOnce
({
status
:
502
,
reason
:
'
OPENAI_OAUTH_PROXY_REQUIRED
'
,
message
:
'
OpenAI OAuth token exchange failed: no proxy is configured.
'
})
const
oauth
=
useOpenAIOAuth
()
const
tokenInfo
=
await
oauth
.
exchangeAuthCode
(
'
code
'
,
'
session-id
'
,
'
state
'
)
expect
(
tokenInfo
).
toBeNull
()
expect
(
oauth
.
error
.
value
).
toBe
(
'
未设置代理,当前服务器无法直连 OpenAI,导致 OpenAI OAuth 请求失败。请先选择可访问 OpenAI 的代理后重试;如果授权码已失效,请重新生成授权链接。
'
)
})
})
frontend/src/composables/useModelWhitelist.ts
View file @
5e060b22
...
...
@@ -16,6 +16,8 @@ const openaiModels = [
// GPT-5.2 系列
'
gpt-5.2
'
,
'
gpt-5.2-2025-12-11
'
,
'
gpt-5.2-chat-latest
'
,
'
gpt-5.2-pro
'
,
'
gpt-5.2-pro-2025-12-11
'
,
// GPT-5.5 系列
'
gpt-5.5
'
,
// GPT-5.4 系列
'
gpt-5.4
'
,
'
gpt-5.4-mini
'
,
'
gpt-5.4-2026-03-05
'
,
// GPT-5.3 系列
...
...
@@ -260,6 +262,7 @@ const openaiPresetMappings = [
{
label
:
'
o3
'
,
from
:
'
o3
'
,
to
:
'
o3
'
,
color
:
'
bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400
'
},
{
label
:
'
GPT-5.3 Codex Spark
'
,
from
:
'
gpt-5.3-codex-spark
'
,
to
:
'
gpt-5.3-codex-spark
'
,
color
:
'
bg-teal-100 text-teal-700 hover:bg-teal-200 dark:bg-teal-900/30 dark:text-teal-400
'
},
{
label
:
'
GPT-5.2
'
,
from
:
'
gpt-5.2
'
,
to
:
'
gpt-5.2
'
,
color
:
'
bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400
'
},
{
label
:
'
GPT-5.5
'
,
from
:
'
gpt-5.5
'
,
to
:
'
gpt-5.5
'
,
color
:
'
bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400
'
},
{
label
:
'
GPT-5.4
'
,
from
:
'
gpt-5.4
'
,
to
:
'
gpt-5.4
'
,
color
:
'
bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-900/30 dark:text-rose-400
'
},
{
label
:
'
Haiku→5.4
'
,
from
:
'
claude-haiku-4-5-20251001
'
,
to
:
'
gpt-5.4
'
,
color
:
'
bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400
'
},
{
label
:
'
Opus→5.4
'
,
from
:
'
claude-opus-4-6
'
,
to
:
'
gpt-5.4
'
,
color
:
'
bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400
'
},
...
...
Prev
1
2
3
4
5
6
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment