From 2b192f7dcab70187999c0e04743ff33e024e37f7 Mon Sep 17 00:00:00 2001 From: shaw Date: Thu, 5 Feb 2026 16:00:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E4=B8=93=E5=B1=9E=E5=88=86=E7=BB=84=E5=80=8D=E7=8E=87=E9=85=8D?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/wire_gen.go | 7 +- backend/go.sum | 8 + .../internal/handler/admin/user_handler.go | 4 + backend/internal/handler/api_key_handler.go | 18 + backend/internal/handler/dto/mappers.go | 5 +- backend/internal/handler/dto/types.go | 3 + .../repository/user_group_rate_repo.go | 113 ++++++ backend/internal/repository/wire.go | 1 + backend/internal/server/api_contract_test.go | 4 +- .../middleware/api_key_auth_google_test.go | 2 + .../server/middleware/api_key_auth_test.go | 8 +- backend/internal/server/routes/user.go | 1 + backend/internal/service/admin_service.go | 41 ++- backend/internal/service/api_key_service.go | 46 ++- .../service/api_key_service_cache_test.go | 18 +- backend/internal/service/gateway_service.go | 21 +- backend/internal/service/user.go | 26 +- backend/internal/service/user_group_rate.go | 25 ++ .../047_add_user_group_rate_multipliers.sql | 19 + frontend/src/api/groups.ts | 12 +- .../admin/user/UserAllowedGroupsModal.vue | 337 ++++++++++++++++-- frontend/src/components/common/GroupBadge.vue | 29 +- .../src/components/common/GroupOptionItem.vue | 5 +- frontend/src/i18n/locales/en.ts | 10 + frontend/src/i18n/locales/zh.ts | 10 + frontend/src/types/index.ts | 5 + frontend/src/views/user/KeysView.vue | 16 + 27 files changed, 705 insertions(+), 89 deletions(-) create mode 100644 backend/internal/repository/user_group_rate_repo.go create mode 100644 backend/internal/service/user_group_rate.go create mode 100644 backend/migrations/047_add_user_group_rate_multipliers.sql diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 47b1e8ac..3ca86f91 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -59,8 +59,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { billingCacheService := service.NewBillingCacheService(billingCache, userRepository, userSubscriptionRepository, configConfig) apiKeyRepository := repository.NewAPIKeyRepository(client) groupRepository := repository.NewGroupRepository(client, db) + userGroupRateRepository := repository.NewUserGroupRateRepository(db) apiKeyCache := repository.NewAPIKeyCache(redisClient) - apiKeyService := service.NewAPIKeyService(apiKeyRepository, userRepository, groupRepository, userSubscriptionRepository, apiKeyCache, configConfig) + apiKeyService := service.NewAPIKeyService(apiKeyRepository, userRepository, groupRepository, userSubscriptionRepository, userGroupRateRepository, apiKeyCache, configConfig) apiKeyAuthCacheInvalidator := service.ProvideAPIKeyAuthCacheInvalidator(apiKeyService) promoService := service.NewPromoService(promoCodeRepository, userRepository, billingCacheService, client, apiKeyAuthCacheInvalidator) authService := service.NewAuthService(userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, emailQueueService, promoService) @@ -100,7 +101,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { proxyRepository := repository.NewProxyRepository(client, db) proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig) proxyLatencyCache := repository.NewProxyLatencyCache(redisClient) - adminService := service.NewAdminService(userRepository, groupRepository, accountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator) + adminService := service.NewAdminService(userRepository, groupRepository, accountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator) adminUserHandler := admin.NewUserHandler(adminService) groupHandler := admin.NewGroupHandler(adminService) claudeOAuthClient := repository.NewClaudeOAuthClient() @@ -153,7 +154,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { identityService := service.NewIdentityService(identityCache) deferredService := service.ProvideDeferredService(accountRepository, timingWheelService) claudeTokenProvider := service.NewClaudeTokenProvider(accountRepository, geminiTokenCache, oAuthService) - gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService, claudeTokenProvider, sessionLimitCache) + gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService, claudeTokenProvider, sessionLimitCache) openAITokenProvider := service.NewOpenAITokenProvider(accountRepository, geminiTokenCache, openAIOAuthService) openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider) geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, groupRepository, gatewayCache, schedulerSnapshotService, geminiTokenProvider, rateLimitService, httpUpstream, antigravityGatewayService, configConfig) diff --git a/backend/go.sum b/backend/go.sum index 171995c7..3000eb38 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -170,6 +170,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= @@ -203,6 +205,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -230,6 +234,8 @@ github.com/refraction-networking/utls v1.8.1 h1:yNY1kapmQU8JeM1sSw2H2asfTIwWxIkr github.com/refraction-networking/utls v1.8.1/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= @@ -252,6 +258,8 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= diff --git a/backend/internal/handler/admin/user_handler.go b/backend/internal/handler/admin/user_handler.go index ac76689d..1c772e7d 100644 --- a/backend/internal/handler/admin/user_handler.go +++ b/backend/internal/handler/admin/user_handler.go @@ -45,6 +45,9 @@ type UpdateUserRequest struct { Concurrency *int `json:"concurrency"` Status string `json:"status" binding:"omitempty,oneof=active disabled"` AllowedGroups *[]int64 `json:"allowed_groups"` + // GroupRates 用户专属分组倍率配置 + // map[groupID]*rate,nil 表示删除该分组的专属倍率 + GroupRates map[int64]*float64 `json:"group_rates"` } // UpdateBalanceRequest represents balance update request @@ -183,6 +186,7 @@ func (h *UserHandler) Update(c *gin.Context) { Concurrency: req.Concurrency, Status: req.Status, AllowedGroups: req.AllowedGroups, + GroupRates: req.GroupRates, }) if err != nil { response.ErrorFrom(c, err) diff --git a/backend/internal/handler/api_key_handler.go b/backend/internal/handler/api_key_handler.go index 9717194b..f1a18ad2 100644 --- a/backend/internal/handler/api_key_handler.go +++ b/backend/internal/handler/api_key_handler.go @@ -243,3 +243,21 @@ func (h *APIKeyHandler) GetAvailableGroups(c *gin.Context) { } response.Success(c, out) } + +// GetUserGroupRates 获取当前用户的专属分组倍率配置 +// GET /api/v1/groups/rates +func (h *APIKeyHandler) GetUserGroupRates(c *gin.Context) { + subject, ok := middleware2.GetAuthSubjectFromContext(c) + if !ok { + response.Unauthorized(c, "User not authenticated") + return + } + + rates, err := h.apiKeyService.GetUserGroupRates(c.Request.Context(), subject.UserID) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, rates) +} diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 4f8d1eeb..da0e9fc6 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -58,8 +58,9 @@ func UserFromServiceAdmin(u *service.User) *AdminUser { return nil } return &AdminUser{ - User: *base, - Notes: u.Notes, + User: *base, + Notes: u.Notes, + GroupRates: u.GroupRates, } } diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 8e6faf02..71bb1ed4 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -29,6 +29,9 @@ type AdminUser struct { User Notes string `json:"notes"` + // GroupRates 用户专属分组倍率配置 + // map[groupID]rateMultiplier + GroupRates map[int64]float64 `json:"group_rates,omitempty"` } type APIKey struct { diff --git a/backend/internal/repository/user_group_rate_repo.go b/backend/internal/repository/user_group_rate_repo.go new file mode 100644 index 00000000..eb65403b --- /dev/null +++ b/backend/internal/repository/user_group_rate_repo.go @@ -0,0 +1,113 @@ +package repository + +import ( + "context" + "database/sql" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" +) + +type userGroupRateRepository struct { + sql sqlExecutor +} + +// NewUserGroupRateRepository 创建用户专属分组倍率仓储 +func NewUserGroupRateRepository(sqlDB *sql.DB) service.UserGroupRateRepository { + return &userGroupRateRepository{sql: sqlDB} +} + +// GetByUserID 获取用户的所有专属分组倍率 +func (r *userGroupRateRepository) GetByUserID(ctx context.Context, userID int64) (map[int64]float64, error) { + query := `SELECT group_id, rate_multiplier FROM user_group_rate_multipliers WHERE user_id = $1` + rows, err := r.sql.QueryContext(ctx, query, userID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + result := make(map[int64]float64) + for rows.Next() { + var groupID int64 + var rate float64 + if err := rows.Scan(&groupID, &rate); err != nil { + return nil, err + } + result[groupID] = rate + } + if err := rows.Err(); err != nil { + return nil, err + } + return result, nil +} + +// GetByUserAndGroup 获取用户在特定分组的专属倍率 +func (r *userGroupRateRepository) GetByUserAndGroup(ctx context.Context, userID, groupID int64) (*float64, error) { + query := `SELECT rate_multiplier FROM user_group_rate_multipliers WHERE user_id = $1 AND group_id = $2` + var rate float64 + err := scanSingleRow(ctx, r.sql, query, []any{userID, groupID}, &rate) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &rate, nil +} + +// SyncUserGroupRates 同步用户的分组专属倍率 +func (r *userGroupRateRepository) SyncUserGroupRates(ctx context.Context, userID int64, rates map[int64]*float64) error { + if len(rates) == 0 { + // 如果传入空 map,删除该用户的所有专属倍率 + _, err := r.sql.ExecContext(ctx, `DELETE FROM user_group_rate_multipliers WHERE user_id = $1`, userID) + return err + } + + // 分离需要删除和需要 upsert 的记录 + var toDelete []int64 + toUpsert := make(map[int64]float64) + for groupID, rate := range rates { + if rate == nil { + toDelete = append(toDelete, groupID) + } else { + toUpsert[groupID] = *rate + } + } + + // 删除指定的记录 + for _, groupID := range toDelete { + _, err := r.sql.ExecContext(ctx, + `DELETE FROM user_group_rate_multipliers WHERE user_id = $1 AND group_id = $2`, + userID, groupID) + if err != nil { + return err + } + } + + // Upsert 记录 + now := time.Now() + for groupID, rate := range toUpsert { + _, err := r.sql.ExecContext(ctx, ` + INSERT INTO user_group_rate_multipliers (user_id, group_id, rate_multiplier, created_at, updated_at) + VALUES ($1, $2, $3, $4, $4) + ON CONFLICT (user_id, group_id) DO UPDATE SET rate_multiplier = $3, updated_at = $4 + `, userID, groupID, rate, now) + if err != nil { + return err + } + } + + return nil +} + +// DeleteByGroupID 删除指定分组的所有用户专属倍率 +func (r *userGroupRateRepository) DeleteByGroupID(ctx context.Context, groupID int64) error { + _, err := r.sql.ExecContext(ctx, `DELETE FROM user_group_rate_multipliers WHERE group_id = $1`, groupID) + return err +} + +// DeleteByUserID 删除指定用户的所有专属倍率 +func (r *userGroupRateRepository) DeleteByUserID(ctx context.Context, userID int64) error { + _, err := r.sql.ExecContext(ctx, `DELETE FROM user_group_rate_multipliers WHERE user_id = $1`, userID) + return err +} diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go index 857ce3e8..5437de35 100644 --- a/backend/internal/repository/wire.go +++ b/backend/internal/repository/wire.go @@ -66,6 +66,7 @@ var ProviderSet = wire.NewSet( NewUserSubscriptionRepository, NewUserAttributeDefinitionRepository, NewUserAttributeValueRepository, + NewUserGroupRateRepository, // Cache implementations NewGatewayCache, diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index e197b776..f5f8cda7 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -593,7 +593,7 @@ func newContractDeps(t *testing.T) *contractDeps { } userService := service.NewUserService(userRepo, nil) - apiKeyService := service.NewAPIKeyService(apiKeyRepo, userRepo, groupRepo, userSubRepo, apiKeyCache, cfg) + apiKeyService := service.NewAPIKeyService(apiKeyRepo, userRepo, groupRepo, userSubRepo, nil, apiKeyCache, cfg) usageRepo := newStubUsageLogRepo() usageService := service.NewUsageService(usageRepo, userRepo, nil, nil) @@ -607,7 +607,7 @@ func newContractDeps(t *testing.T) *contractDeps { settingRepo := newStubSettingRepo() settingService := service.NewSettingService(settingRepo, cfg) - adminService := service.NewAdminService(userRepo, groupRepo, &accountRepo, proxyRepo, apiKeyRepo, redeemRepo, nil, nil, nil, nil) + adminService := service.NewAdminService(userRepo, groupRepo, &accountRepo, proxyRepo, apiKeyRepo, redeemRepo, nil, nil, nil, nil, nil) authHandler := handler.NewAuthHandler(cfg, nil, userService, settingService, nil, redeemService, nil) apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService) usageHandler := handler.NewUsageHandler(usageService, apiKeyService) diff --git a/backend/internal/server/middleware/api_key_auth_google_test.go b/backend/internal/server/middleware/api_key_auth_google_test.go index c14582bd..38b93cb2 100644 --- a/backend/internal/server/middleware/api_key_auth_google_test.go +++ b/backend/internal/server/middleware/api_key_auth_google_test.go @@ -93,6 +93,7 @@ func newTestAPIKeyService(repo service.APIKeyRepository) *service.APIKeyService nil, // userRepo (unused in GetByKey) nil, // groupRepo nil, // userSubRepo + nil, // userGroupRateRepo nil, // cache &config.Config{}, ) @@ -187,6 +188,7 @@ func TestApiKeyAuthWithSubscriptionGoogleSetsGroupContext(t *testing.T) { nil, nil, nil, + nil, &config.Config{RunMode: config.RunModeSimple}, ) diff --git a/backend/internal/server/middleware/api_key_auth_test.go b/backend/internal/server/middleware/api_key_auth_test.go index a03f6168..9d514818 100644 --- a/backend/internal/server/middleware/api_key_auth_test.go +++ b/backend/internal/server/middleware/api_key_auth_test.go @@ -59,7 +59,7 @@ func TestSimpleModeBypassesQuotaCheck(t *testing.T) { t.Run("simple_mode_bypasses_quota_check", func(t *testing.T) { cfg := &config.Config{RunMode: config.RunModeSimple} - apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, cfg) + apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, nil, cfg) subscriptionService := service.NewSubscriptionService(nil, &stubUserSubscriptionRepo{}, nil) router := newAuthTestRouter(apiKeyService, subscriptionService, cfg) @@ -73,7 +73,7 @@ func TestSimpleModeBypassesQuotaCheck(t *testing.T) { t.Run("standard_mode_enforces_quota_check", func(t *testing.T) { cfg := &config.Config{RunMode: config.RunModeStandard} - apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, cfg) + apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, nil, cfg) now := time.Now() sub := &service.UserSubscription{ @@ -150,7 +150,7 @@ func TestAPIKeyAuthSetsGroupContext(t *testing.T) { } cfg := &config.Config{RunMode: config.RunModeSimple} - apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, cfg) + apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, nil, cfg) router := gin.New() router.Use(gin.HandlerFunc(NewAPIKeyAuthMiddleware(apiKeyService, nil, cfg))) router.GET("/t", func(c *gin.Context) { @@ -208,7 +208,7 @@ func TestAPIKeyAuthOverwritesInvalidContextGroup(t *testing.T) { } cfg := &config.Config{RunMode: config.RunModeSimple} - apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, cfg) + apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, nil, cfg) router := gin.New() router.Use(gin.HandlerFunc(NewAPIKeyAuthMiddleware(apiKeyService, nil, cfg))) diff --git a/backend/internal/server/routes/user.go b/backend/internal/server/routes/user.go index 5581e1e1..d0ed2489 100644 --- a/backend/internal/server/routes/user.go +++ b/backend/internal/server/routes/user.go @@ -49,6 +49,7 @@ func RegisterUserRoutes( groups := authenticated.Group("/groups") { groups.GET("/available", h.APIKey.GetAvailableGroups) + groups.GET("/rates", h.APIKey.GetUserGroupRates) } // 使用记录 diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index c512f235..f215f82e 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -93,6 +93,9 @@ type UpdateUserInput struct { Concurrency *int // 使用指针区分"未提供"和"设置为0" Status string AllowedGroups *[]int64 // 使用指针区分"未提供"和"设置为空数组" + // GroupRates 用户专属分组倍率配置 + // map[groupID]*rate,nil 表示删除该分组的专属倍率 + GroupRates map[int64]*float64 } type CreateGroupInput struct { @@ -293,6 +296,7 @@ type adminServiceImpl struct { proxyRepo ProxyRepository apiKeyRepo APIKeyRepository redeemCodeRepo RedeemCodeRepository + userGroupRateRepo UserGroupRateRepository billingCacheService *BillingCacheService proxyProber ProxyExitInfoProber proxyLatencyCache ProxyLatencyCache @@ -307,6 +311,7 @@ func NewAdminService( proxyRepo ProxyRepository, apiKeyRepo APIKeyRepository, redeemCodeRepo RedeemCodeRepository, + userGroupRateRepo UserGroupRateRepository, billingCacheService *BillingCacheService, proxyProber ProxyExitInfoProber, proxyLatencyCache ProxyLatencyCache, @@ -319,6 +324,7 @@ func NewAdminService( proxyRepo: proxyRepo, apiKeyRepo: apiKeyRepo, redeemCodeRepo: redeemCodeRepo, + userGroupRateRepo: userGroupRateRepo, billingCacheService: billingCacheService, proxyProber: proxyProber, proxyLatencyCache: proxyLatencyCache, @@ -333,11 +339,35 @@ func (s *adminServiceImpl) ListUsers(ctx context.Context, page, pageSize int, fi if err != nil { return nil, 0, err } + // 批量加载用户专属分组倍率 + if s.userGroupRateRepo != nil && len(users) > 0 { + for i := range users { + rates, err := s.userGroupRateRepo.GetByUserID(ctx, users[i].ID) + if err != nil { + log.Printf("failed to load user group rates: user_id=%d err=%v", users[i].ID, err) + continue + } + users[i].GroupRates = rates + } + } return users, result.Total, nil } func (s *adminServiceImpl) GetUser(ctx context.Context, id int64) (*User, error) { - return s.userRepo.GetByID(ctx, id) + user, err := s.userRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + // 加载用户专属分组倍率 + if s.userGroupRateRepo != nil { + rates, err := s.userGroupRateRepo.GetByUserID(ctx, id) + if err != nil { + log.Printf("failed to load user group rates: user_id=%d err=%v", id, err) + } else { + user.GroupRates = rates + } + } + return user, nil } func (s *adminServiceImpl) CreateUser(ctx context.Context, input *CreateUserInput) (*User, error) { @@ -406,6 +436,14 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda if err := s.userRepo.Update(ctx, user); err != nil { return nil, err } + + // 同步用户专属分组倍率 + if input.GroupRates != nil && s.userGroupRateRepo != nil { + if err := s.userGroupRateRepo.SyncUserGroupRates(ctx, user.ID, input.GroupRates); err != nil { + log.Printf("failed to sync user group rates: user_id=%d err=%v", user.ID, err) + } + } + if s.authCacheInvalidator != nil { if user.Concurrency != oldConcurrency || user.Status != oldStatus || user.Role != oldRole { s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, user.ID) @@ -941,6 +979,7 @@ func (s *adminServiceImpl) DeleteGroup(ctx context.Context, id int64) error { if err != nil { return err } + // 注意:user_group_rate_multipliers 表通过外键 ON DELETE CASCADE 自动清理 // 事务成功后,异步失效受影响用户的订阅缓存 if len(affectedUserIDs) > 0 && s.billingCacheService != nil { diff --git a/backend/internal/service/api_key_service.go b/backend/internal/service/api_key_service.go index b27682f3..cb1dd60a 100644 --- a/backend/internal/service/api_key_service.go +++ b/backend/internal/service/api_key_service.go @@ -115,15 +115,16 @@ type UpdateAPIKeyRequest struct { // APIKeyService API Key服务 type APIKeyService struct { - apiKeyRepo APIKeyRepository - userRepo UserRepository - groupRepo GroupRepository - userSubRepo UserSubscriptionRepository - cache APIKeyCache - cfg *config.Config - authCacheL1 *ristretto.Cache - authCfg apiKeyAuthCacheConfig - authGroup singleflight.Group + apiKeyRepo APIKeyRepository + userRepo UserRepository + groupRepo GroupRepository + userSubRepo UserSubscriptionRepository + userGroupRateRepo UserGroupRateRepository + cache APIKeyCache + cfg *config.Config + authCacheL1 *ristretto.Cache + authCfg apiKeyAuthCacheConfig + authGroup singleflight.Group } // NewAPIKeyService 创建API Key服务实例 @@ -132,16 +133,18 @@ func NewAPIKeyService( userRepo UserRepository, groupRepo GroupRepository, userSubRepo UserSubscriptionRepository, + userGroupRateRepo UserGroupRateRepository, cache APIKeyCache, cfg *config.Config, ) *APIKeyService { svc := &APIKeyService{ - apiKeyRepo: apiKeyRepo, - userRepo: userRepo, - groupRepo: groupRepo, - userSubRepo: userSubRepo, - cache: cache, - cfg: cfg, + apiKeyRepo: apiKeyRepo, + userRepo: userRepo, + groupRepo: groupRepo, + userSubRepo: userSubRepo, + userGroupRateRepo: userGroupRateRepo, + cache: cache, + cfg: cfg, } svc.initAuthCache(cfg) return svc @@ -627,6 +630,19 @@ func (s *APIKeyService) SearchAPIKeys(ctx context.Context, userID int64, keyword return keys, nil } +// GetUserGroupRates 获取用户的专属分组倍率配置 +// 返回 map[groupID]rateMultiplier +func (s *APIKeyService) GetUserGroupRates(ctx context.Context, userID int64) (map[int64]float64, error) { + if s.userGroupRateRepo == nil { + return nil, nil + } + rates, err := s.userGroupRateRepo.GetByUserID(ctx, userID) + if err != nil { + return nil, fmt.Errorf("get user group rates: %w", err) + } + return rates, nil +} + // CheckAPIKeyQuotaAndExpiry checks if the API key is valid for use (not expired, quota not exhausted) // Returns nil if valid, error if invalid func (s *APIKeyService) CheckAPIKeyQuotaAndExpiry(apiKey *APIKey) error { diff --git a/backend/internal/service/api_key_service_cache_test.go b/backend/internal/service/api_key_service_cache_test.go index 1099b1d2..14ecbf39 100644 --- a/backend/internal/service/api_key_service_cache_test.go +++ b/backend/internal/service/api_key_service_cache_test.go @@ -167,7 +167,7 @@ func TestAPIKeyService_GetByKey_UsesL2Cache(t *testing.T) { NegativeTTLSeconds: 30, }, } - svc := NewAPIKeyService(repo, nil, nil, nil, cache, cfg) + svc := NewAPIKeyService(repo, nil, nil, nil, nil, cache, cfg) groupID := int64(9) cacheEntry := &APIKeyAuthCacheEntry{ @@ -223,7 +223,7 @@ func TestAPIKeyService_GetByKey_NegativeCache(t *testing.T) { NegativeTTLSeconds: 30, }, } - svc := NewAPIKeyService(repo, nil, nil, nil, cache, cfg) + svc := NewAPIKeyService(repo, nil, nil, nil, nil, cache, cfg) cache.getAuthCache = func(ctx context.Context, key string) (*APIKeyAuthCacheEntry, error) { return &APIKeyAuthCacheEntry{NotFound: true}, nil } @@ -256,7 +256,7 @@ func TestAPIKeyService_GetByKey_CacheMissStoresL2(t *testing.T) { NegativeTTLSeconds: 30, }, } - svc := NewAPIKeyService(repo, nil, nil, nil, cache, cfg) + svc := NewAPIKeyService(repo, nil, nil, nil, nil, cache, cfg) cache.getAuthCache = func(ctx context.Context, key string) (*APIKeyAuthCacheEntry, error) { return nil, redis.Nil } @@ -293,7 +293,7 @@ func TestAPIKeyService_GetByKey_UsesL1Cache(t *testing.T) { L1TTLSeconds: 60, }, } - svc := NewAPIKeyService(repo, nil, nil, nil, cache, cfg) + svc := NewAPIKeyService(repo, nil, nil, nil, nil, cache, cfg) require.NotNil(t, svc.authCacheL1) _, err := svc.GetByKey(context.Background(), "k-l1") @@ -320,7 +320,7 @@ func TestAPIKeyService_InvalidateAuthCacheByUserID(t *testing.T) { NegativeTTLSeconds: 30, }, } - svc := NewAPIKeyService(repo, nil, nil, nil, cache, cfg) + svc := NewAPIKeyService(repo, nil, nil, nil, nil, cache, cfg) svc.InvalidateAuthCacheByUserID(context.Background(), 7) require.Len(t, cache.deleteAuthKeys, 2) @@ -338,7 +338,7 @@ func TestAPIKeyService_InvalidateAuthCacheByGroupID(t *testing.T) { L2TTLSeconds: 60, }, } - svc := NewAPIKeyService(repo, nil, nil, nil, cache, cfg) + svc := NewAPIKeyService(repo, nil, nil, nil, nil, cache, cfg) svc.InvalidateAuthCacheByGroupID(context.Background(), 9) require.Len(t, cache.deleteAuthKeys, 2) @@ -356,7 +356,7 @@ func TestAPIKeyService_InvalidateAuthCacheByKey(t *testing.T) { L2TTLSeconds: 60, }, } - svc := NewAPIKeyService(repo, nil, nil, nil, cache, cfg) + svc := NewAPIKeyService(repo, nil, nil, nil, nil, cache, cfg) svc.InvalidateAuthCacheByKey(context.Background(), "k1") require.Len(t, cache.deleteAuthKeys, 1) @@ -375,7 +375,7 @@ func TestAPIKeyService_GetByKey_CachesNegativeOnRepoMiss(t *testing.T) { NegativeTTLSeconds: 30, }, } - svc := NewAPIKeyService(repo, nil, nil, nil, cache, cfg) + svc := NewAPIKeyService(repo, nil, nil, nil, nil, cache, cfg) cache.getAuthCache = func(ctx context.Context, key string) (*APIKeyAuthCacheEntry, error) { return nil, redis.Nil } @@ -411,7 +411,7 @@ func TestAPIKeyService_GetByKey_SingleflightCollapses(t *testing.T) { Singleflight: true, }, } - svc := NewAPIKeyService(repo, nil, nil, nil, cache, cfg) + svc := NewAPIKeyService(repo, nil, nil, nil, nil, cache, cfg) start := make(chan struct{}) wg := sync.WaitGroup{} diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 8c88c0a9..9036955a 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -384,6 +384,7 @@ type GatewayService struct { usageLogRepo UsageLogRepository userRepo UserRepository userSubRepo UserSubscriptionRepository + userGroupRateRepo UserGroupRateRepository cache GatewayCache cfg *config.Config schedulerSnapshot *SchedulerSnapshotService @@ -405,6 +406,7 @@ func NewGatewayService( usageLogRepo UsageLogRepository, userRepo UserRepository, userSubRepo UserSubscriptionRepository, + userGroupRateRepo UserGroupRateRepository, cache GatewayCache, cfg *config.Config, schedulerSnapshot *SchedulerSnapshotService, @@ -424,6 +426,7 @@ func NewGatewayService( usageLogRepo: usageLogRepo, userRepo: userRepo, userSubRepo: userSubRepo, + userGroupRateRepo: userGroupRateRepo, cache: cache, cfg: cfg, schedulerSnapshot: schedulerSnapshot, @@ -4609,10 +4612,17 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu account := input.Account subscription := input.Subscription - // 获取费率倍数 + // 获取费率倍数(优先级:用户专属 > 分组默认 > 系统默认) multiplier := s.cfg.Default.RateMultiplier if apiKey.GroupID != nil && apiKey.Group != nil { multiplier = apiKey.Group.RateMultiplier + + // 检查用户专属倍率 + if s.userGroupRateRepo != nil { + if userRate, err := s.userGroupRateRepo.GetByUserAndGroup(ctx, user.ID, *apiKey.GroupID); err == nil && userRate != nil { + multiplier = *userRate + } + } } var cost *CostBreakdown @@ -4773,10 +4783,17 @@ func (s *GatewayService) RecordUsageWithLongContext(ctx context.Context, input * account := input.Account subscription := input.Subscription - // 获取费率倍数 + // 获取费率倍数(优先级:用户专属 > 分组默认 > 系统默认) multiplier := s.cfg.Default.RateMultiplier if apiKey.GroupID != nil && apiKey.Group != nil { multiplier = apiKey.Group.RateMultiplier + + // 检查用户专属倍率 + if s.userGroupRateRepo != nil { + if userRate, err := s.userGroupRateRepo.GetByUserAndGroup(ctx, user.ID, *apiKey.GroupID); err == nil && userRate != nil { + multiplier = *userRate + } + } } var cost *CostBreakdown diff --git a/backend/internal/service/user.go b/backend/internal/service/user.go index 0f589eb3..e56d83bf 100644 --- a/backend/internal/service/user.go +++ b/backend/internal/service/user.go @@ -21,6 +21,10 @@ type User struct { CreatedAt time.Time UpdatedAt time.Time + // GroupRates 用户专属分组倍率配置 + // map[groupID]rateMultiplier + GroupRates map[int64]float64 + // TOTP 双因素认证字段 TotpSecretEncrypted *string // AES-256-GCM 加密的 TOTP 密钥 TotpEnabled bool // 是否启用 TOTP @@ -40,18 +44,20 @@ func (u *User) IsActive() bool { // CanBindGroup checks whether a user can bind to a given group. // For standard groups: -// - If AllowedGroups is non-empty, only allow binding to IDs in that list. -// - If AllowedGroups is empty (nil or length 0), allow binding to any non-exclusive group. +// - Public groups (non-exclusive): all users can bind +// - Exclusive groups: only users with the group in AllowedGroups can bind func (u *User) CanBindGroup(groupID int64, isExclusive bool) bool { - if len(u.AllowedGroups) > 0 { - for _, id := range u.AllowedGroups { - if id == groupID { - return true - } - } - return false + // 公开分组(非专属):所有用户都可以绑定 + if !isExclusive { + return true } - return !isExclusive + // 专属分组:需要在 AllowedGroups 中 + for _, id := range u.AllowedGroups { + if id == groupID { + return true + } + } + return false } func (u *User) SetPassword(password string) error { diff --git a/backend/internal/service/user_group_rate.go b/backend/internal/service/user_group_rate.go new file mode 100644 index 00000000..9eb5f067 --- /dev/null +++ b/backend/internal/service/user_group_rate.go @@ -0,0 +1,25 @@ +package service + +import "context" + +// UserGroupRateRepository 用户专属分组倍率仓储接口 +// 允许管理员为特定用户设置分组的专属计费倍率,覆盖分组默认倍率 +type UserGroupRateRepository interface { + // GetByUserID 获取用户的所有专属分组倍率 + // 返回 map[groupID]rateMultiplier + GetByUserID(ctx context.Context, userID int64) (map[int64]float64, error) + + // GetByUserAndGroup 获取用户在特定分组的专属倍率 + // 如果未设置专属倍率,返回 nil + GetByUserAndGroup(ctx context.Context, userID, groupID int64) (*float64, error) + + // SyncUserGroupRates 同步用户的分组专属倍率 + // rates: map[groupID]*rateMultiplier,nil 表示删除该分组的专属倍率 + SyncUserGroupRates(ctx context.Context, userID int64, rates map[int64]*float64) error + + // DeleteByGroupID 删除指定分组的所有用户专属倍率(分组删除时调用) + DeleteByGroupID(ctx context.Context, groupID int64) error + + // DeleteByUserID 删除指定用户的所有专属倍率(用户删除时调用) + DeleteByUserID(ctx context.Context, userID int64) error +} diff --git a/backend/migrations/047_add_user_group_rate_multipliers.sql b/backend/migrations/047_add_user_group_rate_multipliers.sql new file mode 100644 index 00000000..a37d5bcd --- /dev/null +++ b/backend/migrations/047_add_user_group_rate_multipliers.sql @@ -0,0 +1,19 @@ +-- 用户专属分组倍率表 +-- 允许管理员为特定用户设置分组的专属计费倍率,覆盖分组默认倍率 +CREATE TABLE IF NOT EXISTS user_group_rate_multipliers ( + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + group_id BIGINT NOT NULL REFERENCES groups(id) ON DELETE CASCADE, + rate_multiplier DECIMAL(10,4) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, group_id) +); + +-- 按 group_id 查询索引(删除分组时清理关联记录) +CREATE INDEX IF NOT EXISTS idx_user_group_rate_multipliers_group_id + ON user_group_rate_multipliers(group_id); + +COMMENT ON TABLE user_group_rate_multipliers IS '用户专属分组倍率配置'; +COMMENT ON COLUMN user_group_rate_multipliers.user_id IS '用户ID'; +COMMENT ON COLUMN user_group_rate_multipliers.group_id IS '分组ID'; +COMMENT ON COLUMN user_group_rate_multipliers.rate_multiplier IS '专属计费倍率(覆盖分组默认倍率)'; diff --git a/frontend/src/api/groups.ts b/frontend/src/api/groups.ts index 0f366d51..0963a7a6 100644 --- a/frontend/src/api/groups.ts +++ b/frontend/src/api/groups.ts @@ -18,8 +18,18 @@ export async function getAvailable(): Promise { return data } +/** + * Get current user's custom group rate multipliers + * @returns Map of group_id to custom rate_multiplier + */ +export async function getUserGroupRates(): Promise> { + const { data } = await apiClient.get | null>('/groups/rates') + return data || {} +} + export const userGroupsAPI = { - getAvailable + getAvailable, + getUserGroupRates } export default userGroupsAPI diff --git a/frontend/src/components/admin/user/UserAllowedGroupsModal.vue b/frontend/src/components/admin/user/UserAllowedGroupsModal.vue index 825d2be5..bccc22c7 100644 --- a/frontend/src/components/admin/user/UserAllowedGroupsModal.vue +++ b/frontend/src/components/admin/user/UserAllowedGroupsModal.vue @@ -1,59 +1,328 @@ + + diff --git a/frontend/src/components/common/GroupBadge.vue b/frontend/src/components/common/GroupBadge.vue index 239d0452..83f4b8aa 100644 --- a/frontend/src/components/common/GroupBadge.vue +++ b/frontend/src/components/common/GroupBadge.vue @@ -11,7 +11,14 @@ {{ name }} - {{ labelText }} + + @@ -27,6 +34,7 @@ interface Props { platform?: GroupPlatform subscriptionType?: SubscriptionType rateMultiplier?: number + userRateMultiplier?: number | null // 用户专属倍率 showRate?: boolean daysRemaining?: number | null // 剩余天数(订阅类型时使用) } @@ -34,20 +42,31 @@ interface Props { const props = withDefaults(defineProps(), { subscriptionType: 'standard', showRate: true, - daysRemaining: null + daysRemaining: null, + userRateMultiplier: null }) const { t } = useI18n() const isSubscription = computed(() => props.subscriptionType === 'subscription') +// 是否有专属倍率(且与默认倍率不同) +const hasCustomRate = computed(() => { + return ( + props.userRateMultiplier !== null && + props.userRateMultiplier !== undefined && + props.rateMultiplier !== undefined && + props.userRateMultiplier !== props.rateMultiplier + ) +}) + // 是否显示右侧标签 const showLabel = computed(() => { if (!props.showRate) return false // 订阅类型:显示天数或"订阅" if (isSubscription.value) return true - // 标准类型:显示倍率 - return props.rateMultiplier !== undefined + // 标准类型:显示倍率(包括专属倍率) + return props.rateMultiplier !== undefined || hasCustomRate.value }) // Label text @@ -71,7 +90,7 @@ const labelClass = computed(() => { const base = 'px-1.5 py-0.5 rounded text-[10px] font-semibold' if (!isSubscription.value) { - // Standard: subtle background + // Standard: subtle background (不再为专属倍率使用不同的背景色) return `${base} bg-black/10 dark:bg-white/10` } diff --git a/frontend/src/components/common/GroupOptionItem.vue b/frontend/src/components/common/GroupOptionItem.vue index 3283c330..44750350 100644 --- a/frontend/src/components/common/GroupOptionItem.vue +++ b/frontend/src/components/common/GroupOptionItem.vue @@ -9,6 +9,7 @@ :platform="platform" :subscription-type="subscriptionType" :rate-multiplier="rateMultiplier" + :user-rate-multiplier="userRateMultiplier" /> (), { subscriptionType: 'standard', selected: false, - showCheckmark: true + showCheckmark: true, + userRateMultiplier: null }) diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index fb255c1a..a4571b10 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -849,6 +849,16 @@ export default { allowedGroupsUpdated: 'Allowed groups updated successfully', failedToLoadGroups: 'Failed to load groups', failedToUpdateAllowedGroups: 'Failed to update allowed groups', + // User Group Configuration + groupConfig: 'User Group Configuration', + groupConfigHint: 'Configure custom rate multipliers for user {email} (overrides group defaults)', + exclusiveGroups: 'Exclusive Groups', + publicGroups: 'Public Groups (Default Available)', + defaultRate: 'Default Rate', + customRate: 'Custom Rate', + useDefaultRate: 'Use Default', + customRatePlaceholder: 'Leave empty for default', + groupConfigUpdated: 'Group configuration updated successfully', deposit: 'Deposit', withdraw: 'Withdraw', depositAmount: 'Deposit Amount', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index e964aae2..8c6b1d91 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -910,6 +910,16 @@ export default { allowedGroupsUpdated: '允许分组更新成功', failedToLoadGroups: '加载分组列表失败', failedToUpdateAllowedGroups: '更新允许分组失败', + // 用户分组配置 + groupConfig: '用户分组配置', + groupConfigHint: '为用户 {email} 配置专属分组倍率(覆盖分组默认倍率)', + exclusiveGroups: '专属分组', + publicGroups: '公开分组(默认可用)', + defaultRate: '默认倍率', + customRate: '专属倍率', + useDefaultRate: '使用默认', + customRatePlaceholder: '留空使用默认', + groupConfigUpdated: '分组配置更新成功', deposit: '充值', withdraw: '退款', depositAmount: '充值金额', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index eb53de44..a87ae4ca 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -41,6 +41,8 @@ export interface User { export interface AdminUser extends User { // 管理员备注(普通用户接口不返回) notes: string + // 用户专属分组倍率配置 (group_id -> rate_multiplier) + group_rates?: Record } export interface LoginRequest { @@ -966,6 +968,9 @@ export interface UpdateUserRequest { concurrency?: number status?: 'active' | 'disabled' allowed_groups?: number[] | null + // 用户专属分组倍率配置 (group_id -> rate_multiplier | null) + // null 表示删除该分组的专属倍率 + group_rates?: Record } export interface ChangePasswordRequest { diff --git a/frontend/src/views/user/KeysView.vue b/frontend/src/views/user/KeysView.vue index 51b015fa..80a64f2e 100644 --- a/frontend/src/views/user/KeysView.vue +++ b/frontend/src/views/user/KeysView.vue @@ -73,6 +73,7 @@ :platform="row.group.platform" :subscription-type="row.group.subscription_type" :rate-multiplier="row.group.rate_multiplier" + :user-rate-multiplier="userGroupRates[row.group.id]" /> {{ t('keys.noGroup') @@ -272,6 +273,7 @@ :platform="(option as unknown as GroupOption).platform" :subscription-type="(option as unknown as GroupOption).subscriptionType" :rate-multiplier="(option as unknown as GroupOption).rate" + :user-rate-multiplier="(option as unknown as GroupOption).userRate" /> {{ t('keys.selectGroup') }} @@ -281,6 +283,7 @@ :platform="(option as unknown as GroupOption).platform" :subscription-type="(option as unknown as GroupOption).subscriptionType" :rate-multiplier="(option as unknown as GroupOption).rate" + :user-rate-multiplier="(option as unknown as GroupOption).userRate" :description="(option as unknown as GroupOption).description" :selected="selected" /> @@ -667,6 +670,7 @@ :platform="option.platform" :subscription-type="option.subscriptionType" :rate-multiplier="option.rate" + :user-rate-multiplier="option.userRate" :description="option.description" :selected=" selectedKeyForGroup?.group_id === option.value || @@ -718,6 +722,7 @@ interface GroupOption { label: string description: string | null rate: number + userRate: number | null subscriptionType: SubscriptionType platform: GroupPlatform } @@ -742,6 +747,7 @@ const groups = ref([]) const loading = ref(false) const submitting = ref(false) const usageStats = ref>({}) +const userGroupRates = ref>({}) const pagination = ref({ page: 1, @@ -825,6 +831,7 @@ const groupOptions = computed(() => label: group.name, description: group.description, rate: group.rate_multiplier, + userRate: userGroupRates.value[group.id] ?? null, subscriptionType: group.subscription_type, platform: group.platform })) @@ -899,6 +906,14 @@ const loadGroups = async () => { } } +const loadUserGroupRates = async () => { + try { + userGroupRates.value = await userGroupsAPI.getUserGroupRates() + } catch (error) { + console.error('Failed to load user group rates:', error) + } +} + const loadPublicSettings = async () => { try { publicSettings.value = await authAPI.getPublicSettings() @@ -1268,6 +1283,7 @@ const closeCcsClientSelect = () => { onMounted(() => { loadApiKeys() loadGroups() + loadUserGroupRates() loadPublicSettings() document.addEventListener('click', closeGroupSelector) })