Skip to content

Commit 0c7cbe3

Browse files
author
QTom
committed
feat(gateway): 系统设置控制未分组 Key 调度 — Handler 层中间件拦截
新增系统设置 allow_ungrouped_key_scheduling(默认关闭), 未分组的 API Key 在网关请求时直接返回 403, 由 RequireGroupAssignment 中间件统一拦截, 支持 Anthropic / Google 两种错误格式响应。 全栈实现:常量 → 结构体 → 解析/更新/初始化 → DTO → 管理接口 → 中间件 → 路由注册 → 前端设置界面 + i18n。
1 parent ccf6a92 commit 0c7cbe3

13 files changed

Lines changed: 150 additions & 7 deletions

File tree

backend/internal/handler/admin/setting_handler.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
123123
OpsQueryModeDefault: settings.OpsQueryModeDefault,
124124
OpsMetricsIntervalSeconds: settings.OpsMetricsIntervalSeconds,
125125
MinClaudeCodeVersion: settings.MinClaudeCodeVersion,
126+
AllowUngroupedKeyScheduling: settings.AllowUngroupedKeyScheduling,
126127
})
127128
}
128129

@@ -193,6 +194,9 @@ type UpdateSettingsRequest struct {
193194
OpsMetricsIntervalSeconds *int `json:"ops_metrics_interval_seconds"`
194195

195196
MinClaudeCodeVersion string `json:"min_claude_code_version"`
197+
198+
// 分组隔离
199+
AllowUngroupedKeyScheduling bool `json:"allow_ungrouped_key_scheduling"`
196200
}
197201

198202
// UpdateSettings 更新系统设置
@@ -465,6 +469,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
465469
EnableIdentityPatch: req.EnableIdentityPatch,
466470
IdentityPatchPrompt: req.IdentityPatchPrompt,
467471
MinClaudeCodeVersion: req.MinClaudeCodeVersion,
472+
AllowUngroupedKeyScheduling: req.AllowUngroupedKeyScheduling,
468473
OpsMonitoringEnabled: func() bool {
469474
if req.OpsMonitoringEnabled != nil {
470475
return *req.OpsMonitoringEnabled
@@ -561,6 +566,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
561566
OpsQueryModeDefault: updatedSettings.OpsQueryModeDefault,
562567
OpsMetricsIntervalSeconds: updatedSettings.OpsMetricsIntervalSeconds,
563568
MinClaudeCodeVersion: updatedSettings.MinClaudeCodeVersion,
569+
AllowUngroupedKeyScheduling: updatedSettings.AllowUngroupedKeyScheduling,
564570
})
565571
}
566572

@@ -709,6 +715,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
709715
if before.MinClaudeCodeVersion != after.MinClaudeCodeVersion {
710716
changed = append(changed, "min_claude_code_version")
711717
}
718+
if before.AllowUngroupedKeyScheduling != after.AllowUngroupedKeyScheduling {
719+
changed = append(changed, "allow_ungrouped_key_scheduling")
720+
}
712721
if before.PurchaseSubscriptionEnabled != after.PurchaseSubscriptionEnabled {
713722
changed = append(changed, "purchase_subscription_enabled")
714723
}

backend/internal/handler/dto/settings.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ type SystemSettings struct {
7777
OpsMetricsIntervalSeconds int `json:"ops_metrics_interval_seconds"`
7878

7979
MinClaudeCodeVersion string `json:"min_claude_code_version"`
80+
81+
// 分组隔离
82+
AllowUngroupedKeyScheduling bool `json:"allow_ungrouped_key_scheduling"`
8083
}
8184

8285
type DefaultSubscriptionSetting struct {

backend/internal/server/api_contract_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,7 @@ func TestAPIContracts(t *testing.T) {
532532
"purchase_subscription_enabled": false,
533533
"purchase_subscription_url": "",
534534
"min_claude_code_version": "",
535+
"allow_ungrouped_key_scheduling": false,
535536
"custom_menu_items": []
536537
}
537538
}`,

backend/internal/server/middleware/middleware.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ package middleware
22

33
import (
44
"context"
5+
"net/http"
56

67
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
8+
"github.com/Wei-Shaw/sub2api/internal/pkg/googleapi"
9+
"github.com/Wei-Shaw/sub2api/internal/service"
710
"github.com/gin-gonic/gin"
811
)
912

@@ -71,3 +74,48 @@ func AbortWithError(c *gin.Context, statusCode int, code, message string) {
7174
c.JSON(statusCode, NewErrorResponse(code, message))
7275
c.Abort()
7376
}
77+
78+
// ──────────────────────────────────────────────────────────
79+
// RequireGroupAssignment — 未分组 Key 拦截中间件
80+
// ──────────────────────────────────────────────────────────
81+
82+
// GatewayErrorWriter 定义网关错误响应格式(不同协议使用不同格式)
83+
type GatewayErrorWriter func(c *gin.Context, status int, message string)
84+
85+
// AnthropicErrorWriter 按 Anthropic API 规范输出错误
86+
func AnthropicErrorWriter(c *gin.Context, status int, message string) {
87+
c.JSON(status, gin.H{
88+
"type": "error",
89+
"error": gin.H{"type": "permission_error", "message": message},
90+
})
91+
}
92+
93+
// GoogleErrorWriter 按 Google API 规范输出错误
94+
func GoogleErrorWriter(c *gin.Context, status int, message string) {
95+
c.JSON(status, gin.H{
96+
"error": gin.H{
97+
"code": status,
98+
"message": message,
99+
"status": googleapi.HTTPStatusToGoogleStatus(status),
100+
},
101+
})
102+
}
103+
104+
// RequireGroupAssignment 检查 API Key 是否已分配到分组,
105+
// 如果未分组且系统设置不允许未分组 Key 调度则返回 403。
106+
func RequireGroupAssignment(settingService *service.SettingService, writeError GatewayErrorWriter) gin.HandlerFunc {
107+
return func(c *gin.Context) {
108+
apiKey, ok := GetAPIKeyFromContext(c)
109+
if !ok || apiKey.GroupID != nil {
110+
c.Next()
111+
return
112+
}
113+
// 未分组 Key — 检查系统设置
114+
if settingService.IsUngroupedKeySchedulingAllowed(c.Request.Context()) {
115+
c.Next()
116+
return
117+
}
118+
writeError(c, http.StatusForbidden, "API Key is not assigned to any group and cannot be used. Please contact the administrator to assign it to a group.")
119+
c.Abort()
120+
}
121+
}

backend/internal/server/router.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ func SetupRouter(
8181
}
8282

8383
// 注册路由
84-
registerRoutes(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService, opsService, cfg, redisClient)
84+
registerRoutes(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, cfg, redisClient)
8585

8686
return r
8787
}
@@ -96,6 +96,7 @@ func registerRoutes(
9696
apiKeyService *service.APIKeyService,
9797
subscriptionService *service.SubscriptionService,
9898
opsService *service.OpsService,
99+
settingService *service.SettingService,
99100
cfg *config.Config,
100101
redisClient *redis.Client,
101102
) {
@@ -110,5 +111,5 @@ func registerRoutes(
110111
routes.RegisterUserRoutes(v1, h, jwtAuth)
111112
routes.RegisterSoraClientRoutes(v1, h, jwtAuth)
112113
routes.RegisterAdminRoutes(v1, h, adminAuth)
113-
routes.RegisterGatewayRoutes(r, h, apiKeyAuth, apiKeyService, subscriptionService, opsService, cfg)
114+
routes.RegisterGatewayRoutes(r, h, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, cfg)
114115
}

backend/internal/server/routes/gateway.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ func RegisterGatewayRoutes(
1919
apiKeyService *service.APIKeyService,
2020
subscriptionService *service.SubscriptionService,
2121
opsService *service.OpsService,
22+
settingService *service.SettingService,
2223
cfg *config.Config,
2324
) {
2425
bodyLimit := middleware.RequestBodyLimit(cfg.Gateway.MaxBodySize)
@@ -30,12 +31,17 @@ func RegisterGatewayRoutes(
3031
clientRequestID := middleware.ClientRequestID()
3132
opsErrorLogger := handler.OpsErrorLoggerMiddleware(opsService)
3233

34+
// 未分组 Key 拦截中间件(按协议格式区分错误响应)
35+
requireGroupAnthropic := middleware.RequireGroupAssignment(settingService, middleware.AnthropicErrorWriter)
36+
requireGroupGoogle := middleware.RequireGroupAssignment(settingService, middleware.GoogleErrorWriter)
37+
3338
// API网关(Claude API兼容)
3439
gateway := r.Group("/v1")
3540
gateway.Use(bodyLimit)
3641
gateway.Use(clientRequestID)
3742
gateway.Use(opsErrorLogger)
3843
gateway.Use(gin.HandlerFunc(apiKeyAuth))
44+
gateway.Use(requireGroupAnthropic)
3945
{
4046
gateway.POST("/messages", h.Gateway.Messages)
4147
gateway.POST("/messages/count_tokens", h.Gateway.CountTokens)
@@ -61,6 +67,7 @@ func RegisterGatewayRoutes(
6167
gemini.Use(clientRequestID)
6268
gemini.Use(opsErrorLogger)
6369
gemini.Use(middleware.APIKeyAuthWithSubscriptionGoogle(apiKeyService, subscriptionService, cfg))
70+
gemini.Use(requireGroupGoogle)
6471
{
6572
gemini.GET("/models", h.Gateway.GeminiV1BetaListModels)
6673
gemini.GET("/models/:model", h.Gateway.GeminiV1BetaGetModel)
@@ -69,11 +76,11 @@ func RegisterGatewayRoutes(
6976
}
7077

7178
// OpenAI Responses API(不带v1前缀的别名)
72-
r.POST("/responses", bodyLimit, clientRequestID, opsErrorLogger, gin.HandlerFunc(apiKeyAuth), h.OpenAIGateway.Responses)
73-
r.GET("/responses", bodyLimit, clientRequestID, opsErrorLogger, gin.HandlerFunc(apiKeyAuth), h.OpenAIGateway.ResponsesWebSocket)
79+
r.POST("/responses", bodyLimit, clientRequestID, opsErrorLogger, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.OpenAIGateway.Responses)
80+
r.GET("/responses", bodyLimit, clientRequestID, opsErrorLogger, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.OpenAIGateway.ResponsesWebSocket)
7481

7582
// Antigravity 模型列表
76-
r.GET("/antigravity/models", gin.HandlerFunc(apiKeyAuth), h.Gateway.AntigravityModels)
83+
r.GET("/antigravity/models", gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.Gateway.AntigravityModels)
7784

7885
// Antigravity 专用路由(仅使用 antigravity 账户,不混合调度)
7986
antigravityV1 := r.Group("/antigravity/v1")
@@ -82,6 +89,7 @@ func RegisterGatewayRoutes(
8289
antigravityV1.Use(opsErrorLogger)
8390
antigravityV1.Use(middleware.ForcePlatform(service.PlatformAntigravity))
8491
antigravityV1.Use(gin.HandlerFunc(apiKeyAuth))
92+
antigravityV1.Use(requireGroupAnthropic)
8593
{
8694
antigravityV1.POST("/messages", h.Gateway.Messages)
8795
antigravityV1.POST("/messages/count_tokens", h.Gateway.CountTokens)
@@ -95,6 +103,7 @@ func RegisterGatewayRoutes(
95103
antigravityV1Beta.Use(opsErrorLogger)
96104
antigravityV1Beta.Use(middleware.ForcePlatform(service.PlatformAntigravity))
97105
antigravityV1Beta.Use(middleware.APIKeyAuthWithSubscriptionGoogle(apiKeyService, subscriptionService, cfg))
106+
antigravityV1Beta.Use(requireGroupGoogle)
98107
{
99108
antigravityV1Beta.GET("/models", h.Gateway.GeminiV1BetaListModels)
100109
antigravityV1Beta.GET("/models/:model", h.Gateway.GeminiV1BetaGetModel)
@@ -108,6 +117,7 @@ func RegisterGatewayRoutes(
108117
soraV1.Use(opsErrorLogger)
109118
soraV1.Use(middleware.ForcePlatform(service.PlatformSora))
110119
soraV1.Use(gin.HandlerFunc(apiKeyAuth))
120+
soraV1.Use(requireGroupAnthropic)
111121
{
112122
soraV1.POST("/chat/completions", h.SoraGateway.ChatCompletions)
113123
soraV1.GET("/models", h.Gateway.Models)

backend/internal/service/domain_constants.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,9 @@ const (
201201

202202
// SettingKeyMinClaudeCodeVersion 最低 Claude Code 版本号要求 (semver, 如 "2.1.0",空值=不检查)
203203
SettingKeyMinClaudeCodeVersion = "min_claude_code_version"
204+
205+
// SettingKeyAllowUngroupedKeyScheduling 允许未分组 API Key 调度(默认 false:未分组 Key 返回 403)
206+
SettingKeyAllowUngroupedKeyScheduling = "allow_ungrouped_key_scheduling"
204207
)
205208

206209
// AdminAPIKeyPrefix is the prefix for admin API keys (distinct from user "sk-" keys).

backend/internal/service/setting_service.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,9 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
438438
// Claude Code version check
439439
updates[SettingKeyMinClaudeCodeVersion] = settings.MinClaudeCodeVersion
440440

441+
// 分组隔离
442+
updates[SettingKeyAllowUngroupedKeyScheduling] = strconv.FormatBool(settings.AllowUngroupedKeyScheduling)
443+
441444
err = s.settingRepo.SetMultiple(ctx, updates)
442445
if err == nil {
443446
// 先使 inflight singleflight 失效,再刷新缓存,缩小旧值覆盖新值的竞态窗口
@@ -646,6 +649,9 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
646649

647650
// Claude Code version check (default: empty = disabled)
648651
SettingKeyMinClaudeCodeVersion: "",
652+
653+
// 分组隔离(默认不允许未分组 Key 调度)
654+
SettingKeyAllowUngroupedKeyScheduling: "false",
649655
}
650656

651657
return s.settingRepo.SetMultiple(ctx, defaults)
@@ -776,6 +782,9 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
776782
// Claude Code version check
777783
result.MinClaudeCodeVersion = settings[SettingKeyMinClaudeCodeVersion]
778784

785+
// 分组隔离
786+
result.AllowUngroupedKeyScheduling = settings[SettingKeyAllowUngroupedKeyScheduling] == "true"
787+
779788
return result
780789
}
781790

@@ -1098,6 +1107,15 @@ func (s *SettingService) GetStreamTimeoutSettings(ctx context.Context) (*StreamT
10981107
return &settings, nil
10991108
}
11001109

1110+
// IsUngroupedKeySchedulingAllowed 查询是否允许未分组 Key 调度
1111+
func (s *SettingService) IsUngroupedKeySchedulingAllowed(ctx context.Context) bool {
1112+
value, err := s.settingRepo.GetValue(ctx, SettingKeyAllowUngroupedKeyScheduling)
1113+
if err != nil {
1114+
return false // fail-closed: 查询失败时默认不允许
1115+
}
1116+
return value == "true"
1117+
}
1118+
11011119
// GetMinClaudeCodeVersion 获取最低 Claude Code 版本号要求
11021120
// 使用进程内 atomic.Value 缓存,60 秒 TTL,热路径零锁开销
11031121
// singleflight 防止缓存过期时 thundering herd

backend/internal/service/settings_view.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ type SystemSettings struct {
6565

6666
// Claude Code version check
6767
MinClaudeCodeVersion string
68+
69+
// 分组隔离:允许未分组 Key 调度(默认 false → 403)
70+
AllowUngroupedKeyScheduling bool
6871
}
6972

7073
type DefaultSubscriptionSetting struct {

frontend/src/api/admin/settings.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ export interface SystemSettings {
7878

7979
// Claude Code version check
8080
min_claude_code_version: string
81+
82+
// 分组隔离
83+
allow_ungrouped_key_scheduling: boolean
8184
}
8285

8386
export interface UpdateSettingsRequest {
@@ -128,6 +131,7 @@ export interface UpdateSettingsRequest {
128131
ops_query_mode_default?: 'auto' | 'raw' | 'preagg' | string
129132
ops_metrics_interval_seconds?: number
130133
min_claude_code_version?: string
134+
allow_ungrouped_key_scheduling?: boolean
131135
}
132136

133137
/**

0 commit comments

Comments
 (0)