Commit 5e060b22 authored by erio's avatar erio
Browse files

Merge remote-tracking branch 'upstream/main' into feat/channel-insights

# Conflicts:
#	backend/cmd/server/wire_gen.go
parents 6f04c25e 0a80ec80
//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")
}
...@@ -7,6 +7,9 @@ import ( ...@@ -7,6 +7,9 @@ import (
"net/http" "net/http"
"testing" "testing"
"time" "time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/stretchr/testify/require"
) )
func TestCalculateOpenAI429ResetTime_7dExhausted(t *testing.T) { func TestCalculateOpenAI429ResetTime_7dExhausted(t *testing.T) {
...@@ -259,6 +262,53 @@ func TestNormalizedCodexLimits_OnlyPrimaryData(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) { func TestNormalizedCodexLimits_OnlySecondaryData(t *testing.T) {
// Test when only secondary has data, no window_minutes // Test when only secondary has data, no window_minutes
sUsed := 60.0 sUsed := 60.0
......
...@@ -1167,6 +1167,7 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting ...@@ -1167,6 +1167,7 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
// 默认配置 // 默认配置
updates[SettingKeyDefaultConcurrency] = strconv.Itoa(settings.DefaultConcurrency) updates[SettingKeyDefaultConcurrency] = strconv.Itoa(settings.DefaultConcurrency)
updates[SettingKeyDefaultBalance] = strconv.FormatFloat(settings.DefaultBalance, 'f', 8, 64) updates[SettingKeyDefaultBalance] = strconv.FormatFloat(settings.DefaultBalance, 'f', 8, 64)
updates[SettingKeyDefaultUserRPMLimit] = strconv.Itoa(settings.DefaultUserRPMLimit)
defaultSubsJSON, err := json.Marshal(settings.DefaultSubscriptions) defaultSubsJSON, err := json.Marshal(settings.DefaultSubscriptions)
if err != nil { if err != nil {
return nil, fmt.Errorf("marshal default subscriptions: %w", err) return nil, fmt.Errorf("marshal default subscriptions: %w", err)
...@@ -1538,6 +1539,18 @@ func (s *SettingService) GetDefaultBalance(ctx context.Context) float64 { ...@@ -1538,6 +1539,18 @@ func (s *SettingService) GetDefaultBalance(ctx context.Context) float64 {
return s.cfg.Default.UserBalance 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 获取新用户默认订阅配置列表。 // GetDefaultSubscriptions 获取新用户默认订阅配置列表。
func (s *SettingService) GetDefaultSubscriptions(ctx context.Context) []DefaultSubscriptionSetting { func (s *SettingService) GetDefaultSubscriptions(ctx context.Context) []DefaultSubscriptionSetting {
value, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultSubscriptions) value, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultSubscriptions)
...@@ -1706,6 +1719,7 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { ...@@ -1706,6 +1719,7 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
SettingKeyOIDCConnectUserInfoUsernamePath: "", SettingKeyOIDCConnectUserInfoUsernamePath: "",
SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency), SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency),
SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64), SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64),
SettingKeyDefaultUserRPMLimit: "0",
SettingKeyDefaultSubscriptions: "[]", SettingKeyDefaultSubscriptions: "[]",
SettingKeyAuthSourceDefaultEmailBalance: "0", SettingKeyAuthSourceDefaultEmailBalance: "0",
SettingKeyAuthSourceDefaultEmailConcurrency: "5", SettingKeyAuthSourceDefaultEmailConcurrency: "5",
...@@ -1822,6 +1836,10 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin ...@@ -1822,6 +1836,10 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
result.DefaultConcurrency = s.cfg.Default.UserConcurrency 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 { if balance, err := strconv.ParseFloat(settings[SettingKeyDefaultBalance], 64); err == nil {
result.DefaultBalance = balance result.DefaultBalance = balance
......
...@@ -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,
...@@ -210,11 +215,13 @@ func ProvideRateLimitService( ...@@ -210,11 +215,13 @@ func ProvideRateLimitService(
geminiQuotaService *GeminiQuotaService, geminiQuotaService *GeminiQuotaService,
tempUnschedCache TempUnschedCache, tempUnschedCache TempUnschedCache,
timeoutCounterCache TimeoutCounterCache, timeoutCounterCache TimeoutCounterCache,
openAI403CounterCache OpenAI403CounterCache,
settingService *SettingService, settingService *SettingService,
tokenCacheInvalidator TokenCacheInvalidator, tokenCacheInvalidator TokenCacheInvalidator,
) *RateLimitService { ) *RateLimitService {
svc := NewRateLimitService(accountRepo, usageRepo, cfg, geminiQuotaService, tempUnschedCache) svc := NewRateLimitService(accountRepo, usageRepo, cfg, geminiQuotaService, tempUnschedCache)
svc.SetTimeoutCounterCache(timeoutCounterCache) svc.SetTimeoutCounterCache(timeoutCounterCache)
svc.SetOpenAI403CounterCache(openAI403CounterCache)
svc.SetSettingService(settingService) svc.SetSettingService(settingService)
svc.SetTokenCacheInvalidator(tokenCacheInvalidator) svc.SetTokenCacheInvalidator(tokenCacheInvalidator)
return svc return svc
...@@ -384,6 +391,19 @@ func ProvideSettingService(settingRepo SettingRepository, groupRepo GroupReposit ...@@ -384,6 +391,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
...@@ -400,7 +420,7 @@ var ProviderSet = wire.NewSet( ...@@ -400,7 +420,7 @@ var ProviderSet = wire.NewSet(
NewDashboardService, NewDashboardService,
ProvidePricingService, ProvidePricingService,
NewBillingService, NewBillingService,
NewBillingCacheService, ProvideBillingCacheService,
NewAnnouncementService, NewAnnouncementService,
NewAdminService, NewAdminService,
NewGatewayService, NewGatewayService,
...@@ -412,7 +432,7 @@ var ProviderSet = wire.NewSet( ...@@ -412,7 +432,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;
...@@ -489,6 +490,7 @@ export interface UpdateSettingsRequest { ...@@ -489,6 +490,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)
......
...@@ -633,6 +633,22 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin ...@@ -633,6 +633,22 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
xhigh: {} xhigh: {}
} }
}, },
'gpt-5.5': {
name: 'GPT-5.5',
limit: {
context: 1050000,
output: 128000
},
options: {
store: false
},
variants: {
low: {},
medium: {},
high: {},
xhigh: {}
}
},
'gpt-5.4': { 'gpt-5.4': {
name: 'GPT-5.4', name: 'GPT-5.4',
limit: { limit: {
......
...@@ -6,6 +6,19 @@ vi.mock('@/stores/app', () => ({ ...@@ -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', () => ({ vi.mock('@/api/admin', () => ({
adminAPI: { adminAPI: {
accounts: { accounts: {
...@@ -17,6 +30,7 @@ vi.mock('@/api/admin', () => ({ ...@@ -17,6 +30,7 @@ vi.mock('@/api/admin', () => ({
})) }))
import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth' import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
import { adminAPI } from '@/api/admin'
describe('useOpenAIOAuth.buildCredentials', () => { describe('useOpenAIOAuth.buildCredentials', () => {
it('should keep client_id when token response contains it', () => { it('should keep client_id when token response contains it', () => {
...@@ -46,3 +60,21 @@ describe('useOpenAIOAuth.buildCredentials', () => { ...@@ -46,3 +60,21 @@ describe('useOpenAIOAuth.buildCredentials', () => {
expect(creds.refresh_token).toBe('rt') 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 的代理后重试;如果授权码已失效,请重新生成授权链接。'
)
})
})
...@@ -16,6 +16,8 @@ const openaiModels = [ ...@@ -16,6 +16,8 @@ const openaiModels = [
// GPT-5.2 系列 // GPT-5.2 系列
'gpt-5.2', 'gpt-5.2-2025-12-11', 'gpt-5.2-chat-latest', '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.2-pro', 'gpt-5.2-pro-2025-12-11',
// GPT-5.5 系列
'gpt-5.5',
// GPT-5.4 系列 // GPT-5.4 系列
'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-2026-03-05', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-2026-03-05',
// GPT-5.3 系列 // GPT-5.3 系列
...@@ -260,6 +262,7 @@ const openaiPresetMappings = [ ...@@ -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: '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.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.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: '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: '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' }, { 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' },
......
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