diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 25456bb3..c91566c8 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -125,6 +125,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { OpsQueryModeDefault: settings.OpsQueryModeDefault, OpsMetricsIntervalSeconds: settings.OpsMetricsIntervalSeconds, MinClaudeCodeVersion: settings.MinClaudeCodeVersion, + MaxClaudeCodeVersion: settings.MaxClaudeCodeVersion, AllowUngroupedKeyScheduling: settings.AllowUngroupedKeyScheduling, BackendModeEnabled: settings.BackendModeEnabled, }) @@ -199,6 +200,7 @@ type UpdateSettingsRequest struct { OpsMetricsIntervalSeconds *int `json:"ops_metrics_interval_seconds"` MinClaudeCodeVersion string `json:"min_claude_code_version"` + MaxClaudeCodeVersion string `json:"max_claude_code_version"` // 分组隔离 AllowUngroupedKeyScheduling bool `json:"allow_ungrouped_key_scheduling"` @@ -442,6 +444,22 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } } + // 验证最高版本号格式(空字符串=禁用,或合法 semver) + if req.MaxClaudeCodeVersion != "" { + if !semverPattern.MatchString(req.MaxClaudeCodeVersion) { + response.Error(c, http.StatusBadRequest, "max_claude_code_version must be empty or a valid semver (e.g. 3.0.0)") + return + } + } + + // 交叉验证:如果同时设置了最低和最高版本号,最高版本号必须 >= 最低版本号 + if req.MinClaudeCodeVersion != "" && req.MaxClaudeCodeVersion != "" { + if service.CompareVersions(req.MaxClaudeCodeVersion, req.MinClaudeCodeVersion) < 0 { + response.Error(c, http.StatusBadRequest, "max_claude_code_version must be greater than or equal to min_claude_code_version") + return + } + } + settings := &service.SystemSettings{ RegistrationEnabled: req.RegistrationEnabled, EmailVerifyEnabled: req.EmailVerifyEnabled, @@ -488,6 +506,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { EnableIdentityPatch: req.EnableIdentityPatch, IdentityPatchPrompt: req.IdentityPatchPrompt, MinClaudeCodeVersion: req.MinClaudeCodeVersion, + MaxClaudeCodeVersion: req.MaxClaudeCodeVersion, AllowUngroupedKeyScheduling: req.AllowUngroupedKeyScheduling, BackendModeEnabled: req.BackendModeEnabled, OpsMonitoringEnabled: func() bool { @@ -588,6 +607,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { OpsQueryModeDefault: updatedSettings.OpsQueryModeDefault, OpsMetricsIntervalSeconds: updatedSettings.OpsMetricsIntervalSeconds, MinClaudeCodeVersion: updatedSettings.MinClaudeCodeVersion, + MaxClaudeCodeVersion: updatedSettings.MaxClaudeCodeVersion, AllowUngroupedKeyScheduling: updatedSettings.AllowUngroupedKeyScheduling, BackendModeEnabled: updatedSettings.BackendModeEnabled, }) @@ -744,6 +764,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.MinClaudeCodeVersion != after.MinClaudeCodeVersion { changed = append(changed, "min_claude_code_version") } + if before.MaxClaudeCodeVersion != after.MaxClaudeCodeVersion { + changed = append(changed, "max_claude_code_version") + } if before.AllowUngroupedKeyScheduling != after.AllowUngroupedKeyScheduling { changed = append(changed, "allow_ungrouped_key_scheduling") } diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index b953e336..0f4f8fdc 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -79,6 +79,7 @@ type SystemSettings struct { OpsMetricsIntervalSeconds int `json:"ops_metrics_interval_seconds"` MinClaudeCodeVersion string `json:"min_claude_code_version"` + MaxClaudeCodeVersion string `json:"max_claude_code_version"` // 分组隔离 AllowUngroupedKeyScheduling bool `json:"allow_ungrouped_key_scheduling"` diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index b5250aad..e1b1b9a8 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -1281,7 +1281,7 @@ func (h *GatewayHandler) ensureForwardErrorResponse(c *gin.Context, streamStarte return true } -// checkClaudeCodeVersion 检查 Claude Code 客户端版本是否满足最低要求 +// checkClaudeCodeVersion 检查 Claude Code 客户端版本是否满足版本要求 // 仅对已识别的 Claude Code 客户端执行,count_tokens 路径除外 func (h *GatewayHandler) checkClaudeCodeVersion(c *gin.Context) bool { ctx := c.Request.Context() @@ -1294,8 +1294,8 @@ func (h *GatewayHandler) checkClaudeCodeVersion(c *gin.Context) bool { return true } - minVersion := h.settingService.GetMinClaudeCodeVersion(ctx) - if minVersion == "" { + minVersion, maxVersion := h.settingService.GetClaudeCodeVersionBounds(ctx) + if minVersion == "" && maxVersion == "" { return true // 未设置,不检查 } @@ -1306,13 +1306,22 @@ func (h *GatewayHandler) checkClaudeCodeVersion(c *gin.Context) bool { return false } - if service.CompareVersions(clientVersion, minVersion) < 0 { + if minVersion != "" && service.CompareVersions(clientVersion, minVersion) < 0 { h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", fmt.Sprintf("Your Claude Code version (%s) is below the minimum required version (%s). Please update: npm update -g @anthropic-ai/claude-code", clientVersion, minVersion)) return false } + if maxVersion != "" && service.CompareVersions(clientVersion, maxVersion) > 0 { + h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", + fmt.Sprintf("Your Claude Code version (%s) exceeds the maximum allowed version (%s). "+ + "Please downgrade: npm install -g @anthropic-ai/claude-code@%s && "+ + "set CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 to prevent auto-upgrade", + clientVersion, maxVersion, maxVersion)) + return false + } + return true } diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 7b629e14..384d5159 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -226,6 +226,9 @@ const ( // SettingKeyMinClaudeCodeVersion 最低 Claude Code 版本号要求 (semver, 如 "2.1.0",空值=不检查) SettingKeyMinClaudeCodeVersion = "min_claude_code_version" + // SettingKeyMaxClaudeCodeVersion 最高 Claude Code 版本号限制 (semver, 如 "3.0.0",空值=不检查) + SettingKeyMaxClaudeCodeVersion = "max_claude_code_version" + // SettingKeyAllowUngroupedKeyScheduling 允许未分组 API Key 调度(默认 false:未分组 Key 返回 403) SettingKeyAllowUngroupedKeyScheduling = "allow_ungrouped_key_scheduling" diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index ece95c4e..f652839c 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -44,26 +44,27 @@ type SettingRepository interface { Delete(ctx context.Context, key string) error } -// cachedMinVersion 缓存最低 Claude Code 版本号(进程内缓存,60s TTL) -type cachedMinVersion struct { - value string // 空字符串 = 不检查 +// cachedVersionBounds 缓存 Claude Code 版本号上下限(进程内缓存,60s TTL) +type cachedVersionBounds struct { + min string // 空字符串 = 不检查 + max string // 空字符串 = 不检查 expiresAt int64 // unix nano } -// minVersionCache 最低版本号进程内缓存 -var minVersionCache atomic.Value // *cachedMinVersion +// versionBoundsCache 版本号上下限进程内缓存 +var versionBoundsCache atomic.Value // *cachedVersionBounds -// minVersionSF 防止缓存过期时 thundering herd -var minVersionSF singleflight.Group +// versionBoundsSF 防止缓存过期时 thundering herd +var versionBoundsSF singleflight.Group -// minVersionCacheTTL 缓存有效期 -const minVersionCacheTTL = 60 * time.Second +// versionBoundsCacheTTL 缓存有效期 +const versionBoundsCacheTTL = 60 * time.Second -// minVersionErrorTTL DB 错误时的短缓存,快速重试 -const minVersionErrorTTL = 5 * time.Second +// versionBoundsErrorTTL DB 错误时的短缓存,快速重试 +const versionBoundsErrorTTL = 5 * time.Second -// minVersionDBTimeout singleflight 内 DB 查询超时,独立于请求 context -const minVersionDBTimeout = 5 * time.Second +// versionBoundsDBTimeout singleflight 内 DB 查询超时,独立于请求 context +const versionBoundsDBTimeout = 5 * time.Second // cachedBackendMode Backend Mode cache (in-process, 60s TTL) type cachedBackendMode struct { @@ -484,6 +485,7 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet // Claude Code version check updates[SettingKeyMinClaudeCodeVersion] = settings.MinClaudeCodeVersion + updates[SettingKeyMaxClaudeCodeVersion] = settings.MaxClaudeCodeVersion // 分组隔离 updates[SettingKeyAllowUngroupedKeyScheduling] = strconv.FormatBool(settings.AllowUngroupedKeyScheduling) @@ -494,10 +496,11 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet err = s.settingRepo.SetMultiple(ctx, updates) if err == nil { // 先使 inflight singleflight 失效,再刷新缓存,缩小旧值覆盖新值的竞态窗口 - minVersionSF.Forget("min_version") - minVersionCache.Store(&cachedMinVersion{ - value: settings.MinClaudeCodeVersion, - expiresAt: time.Now().Add(minVersionCacheTTL).UnixNano(), + versionBoundsSF.Forget("version_bounds") + versionBoundsCache.Store(&cachedVersionBounds{ + min: settings.MinClaudeCodeVersion, + max: settings.MaxClaudeCodeVersion, + expiresAt: time.Now().Add(versionBoundsCacheTTL).UnixNano(), }) backendModeSF.Forget("backend_mode") backendModeCache.Store(&cachedBackendMode{ @@ -760,6 +763,7 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { // Claude Code version check (default: empty = disabled) SettingKeyMinClaudeCodeVersion: "", + SettingKeyMaxClaudeCodeVersion: "", // 分组隔离(默认不允许未分组 Key 调度) SettingKeyAllowUngroupedKeyScheduling: "false", @@ -895,6 +899,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin // Claude Code version check result.MinClaudeCodeVersion = settings[SettingKeyMinClaudeCodeVersion] + result.MaxClaudeCodeVersion = settings[SettingKeyMaxClaudeCodeVersion] // 分组隔离 result.AllowUngroupedKeyScheduling = settings[SettingKeyAllowUngroupedKeyScheduling] == "true" @@ -1281,51 +1286,61 @@ func (s *SettingService) IsUngroupedKeySchedulingAllowed(ctx context.Context) bo return value == "true" } -// GetMinClaudeCodeVersion 获取最低 Claude Code 版本号要求 +// GetClaudeCodeVersionBounds 获取 Claude Code 版本号上下限要求 // 使用进程内 atomic.Value 缓存,60 秒 TTL,热路径零锁开销 // singleflight 防止缓存过期时 thundering herd -// 返回空字符串表示不做版本检查 -func (s *SettingService) GetMinClaudeCodeVersion(ctx context.Context) string { - if cached, ok := minVersionCache.Load().(*cachedMinVersion); ok { +// 返回空字符串表示不做对应方向的版本检查 +func (s *SettingService) GetClaudeCodeVersionBounds(ctx context.Context) (min, max string) { + if cached, ok := versionBoundsCache.Load().(*cachedVersionBounds); ok { if time.Now().UnixNano() < cached.expiresAt { - return cached.value + return cached.min, cached.max } } // singleflight: 同一时刻只有一个 goroutine 查询 DB,其余复用结果 - result, err, _ := minVersionSF.Do("min_version", func() (any, error) { + type bounds struct{ min, max string } + result, err, _ := versionBoundsSF.Do("version_bounds", func() (any, error) { // 二次检查,避免排队的 goroutine 重复查询 - if cached, ok := minVersionCache.Load().(*cachedMinVersion); ok { + if cached, ok := versionBoundsCache.Load().(*cachedVersionBounds); ok { if time.Now().UnixNano() < cached.expiresAt { - return cached.value, nil + return bounds{cached.min, cached.max}, nil } } // 使用独立 context:断开请求取消链,避免客户端断连导致空值被长期缓存 - dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), minVersionDBTimeout) + dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), versionBoundsDBTimeout) defer cancel() - value, err := s.settingRepo.GetValue(dbCtx, SettingKeyMinClaudeCodeVersion) + values, err := s.settingRepo.GetMultiple(dbCtx, []string{ + SettingKeyMinClaudeCodeVersion, + SettingKeyMaxClaudeCodeVersion, + }) if err != nil { // fail-open: DB 错误时不阻塞请求,但记录日志并使用短 TTL 快速重试 - slog.Warn("failed to get min claude code version setting, skipping version check", "error", err) - minVersionCache.Store(&cachedMinVersion{ - value: "", - expiresAt: time.Now().Add(minVersionErrorTTL).UnixNano(), + slog.Warn("failed to get claude code version bounds setting, skipping version check", "error", err) + versionBoundsCache.Store(&cachedVersionBounds{ + min: "", + max: "", + expiresAt: time.Now().Add(versionBoundsErrorTTL).UnixNano(), }) - return "", nil + return bounds{"", ""}, nil } - minVersionCache.Store(&cachedMinVersion{ - value: value, - expiresAt: time.Now().Add(minVersionCacheTTL).UnixNano(), + b := bounds{ + min: values[SettingKeyMinClaudeCodeVersion], + max: values[SettingKeyMaxClaudeCodeVersion], + } + versionBoundsCache.Store(&cachedVersionBounds{ + min: b.min, + max: b.max, + expiresAt: time.Now().Add(versionBoundsCacheTTL).UnixNano(), }) - return value, nil + return b, nil }) if err != nil { - return "" + return "", "" } - ver, ok := result.(string) + b, ok := result.(bounds) if !ok { - return "" + return "", "" } - return ver + return b.min, b.max } // GetRectifierSettings 获取请求整流器配置 diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 23188a09..cd0bed0b 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -67,6 +67,7 @@ type SystemSettings struct { // Claude Code version check MinClaudeCodeVersion string + MaxClaudeCodeVersion string // 分组隔离:允许未分组 Key 调度(默认 false → 403) AllowUngroupedKeyScheduling bool diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index a2cd67f0..0519d2fc 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -81,6 +81,7 @@ export interface SystemSettings { // Claude Code version check min_claude_code_version: string + max_claude_code_version: string // 分组隔离 allow_ungrouped_key_scheduling: boolean @@ -137,6 +138,7 @@ export interface UpdateSettingsRequest { ops_query_mode_default?: 'auto' | 'raw' | 'preagg' | string ops_metrics_interval_seconds?: number min_claude_code_version?: string + max_claude_code_version?: string allow_ungrouped_key_scheduling?: boolean } diff --git a/frontend/src/components/keys/UseKeyModal.vue b/frontend/src/components/keys/UseKeyModal.vue index b478c50a..634db115 100644 --- a/frontend/src/components/keys/UseKeyModal.vue +++ b/frontend/src/components/keys/UseKeyModal.vue @@ -441,17 +441,20 @@ function generateAnthropicFiles(baseUrl: string, apiKey: string): FileConfig[] { case 'unix': path = 'Terminal' content = `export ANTHROPIC_BASE_URL="${baseUrl}" -export ANTHROPIC_AUTH_TOKEN="${apiKey}"` +export ANTHROPIC_AUTH_TOKEN="${apiKey}" +export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` break case 'cmd': path = 'Command Prompt' content = `set ANTHROPIC_BASE_URL=${baseUrl} -set ANTHROPIC_AUTH_TOKEN=${apiKey}` +set ANTHROPIC_AUTH_TOKEN=${apiKey} +set CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` break case 'powershell': path = 'PowerShell' content = `$env:ANTHROPIC_BASE_URL="${baseUrl}" -$env:ANTHROPIC_AUTH_TOKEN="${apiKey}"` +$env:ANTHROPIC_AUTH_TOKEN="${apiKey}" +$env:CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` break default: path = 'Terminal' @@ -466,6 +469,7 @@ $env:ANTHROPIC_AUTH_TOKEN="${apiKey}"` "env": { "ANTHROPIC_BASE_URL": "${baseUrl}", "ANTHROPIC_AUTH_TOKEN": "${apiKey}", + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1", "CLAUDE_CODE_ATTRIBUTION_HEADER": "0" } }` diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index ba90e11a..f04029e2 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -4119,7 +4119,11 @@ export default { minVersion: 'Minimum Version', minVersionPlaceholder: 'e.g. 2.1.63', minVersionHint: - 'Reject Claude Code clients below this version (semver format). Leave empty to disable version check.' + 'Reject Claude Code clients below this version (semver format). Leave empty to disable version check.', + maxVersion: 'Maximum Version', + maxVersionPlaceholder: 'e.g. 2.5.0', + maxVersionHint: + 'Reject Claude Code clients above this version (semver format). Leave empty to allow any version.' }, scheduling: { title: 'Gateway Scheduling Settings', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 8180c568..2c3eabeb 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -4283,7 +4283,10 @@ export default { description: '控制 Claude Code 客户端访问要求', minVersion: '最低版本号', minVersionPlaceholder: '例如 2.1.63', - minVersionHint: '拒绝低于此版本的 Claude Code 客户端请求(semver 格式)。留空则不检查版本。' + minVersionHint: '拒绝低于此版本的 Claude Code 客户端请求(semver 格式)。留空则不检查版本。', + maxVersion: '最高版本号', + maxVersionPlaceholder: '例如 2.5.0', + maxVersionHint: '拒绝高于此版本的 Claude Code 客户端请求(semver 格式)。留空则不限制最高版本。' }, scheduling: { title: '网关调度设置', diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index fda62b29..99cd247e 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -1127,6 +1127,20 @@ {{ t('admin.settings.claudeCode.minVersionHint') }}

+
+ + +

+ {{ t('admin.settings.claudeCode.maxVersionHint') }} +

+
@@ -1967,6 +1981,7 @@ const form = reactive({ ops_metrics_interval_seconds: 60, // Claude Code version check min_claude_code_version: '', + max_claude_code_version: '', // 分组隔离 allow_ungrouped_key_scheduling: false }) @@ -2232,6 +2247,7 @@ async function saveSettings() { enable_identity_patch: form.enable_identity_patch, identity_patch_prompt: form.identity_patch_prompt, min_claude_code_version: form.min_claude_code_version, + max_claude_code_version: form.max_claude_code_version, allow_ungrouped_key_scheduling: form.allow_ungrouped_key_scheduling } const updated = await adminAPI.settings.updateSettings(payload)