Skip to content

Commit 4280aca

Browse files
author
QTom
committed
feat(gateway): 添加 Claude Code 客户端最低版本检查功能
- 通过 User-Agent 识别 Claude Code 客户端并提取版本号 - 在网关层验证客户端版本是否满足管理员配置的最低要求 - 在管理后台提供版本要求配置选项(英文/中文双语) - 实现原子缓存 + singleflight 防止并发问题和 thundering herd - 使用 context.WithoutCancel 隔离 DB 查询,避免客户端断连影响缓存 - 双 TTL 策略:60s 正常、5s 错误恢复,保证性能与可用性 - 仅检查 Claude Code 客户端,其他客户端不受影响 - 添加完整单元测试覆盖版本提取、比对、上下文操作
1 parent f7fa71b commit 4280aca

15 files changed

Lines changed: 331 additions & 6 deletions

File tree

backend/cmd/server/wire_gen.go

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/internal/handler/admin/setting_handler.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package admin
33
import (
44
"fmt"
55
"log"
6+
"net/http"
7+
"regexp"
68
"strings"
79
"time"
810

@@ -15,6 +17,9 @@ import (
1517
"github.com/gin-gonic/gin"
1618
)
1719

20+
// semverPattern 预编译 semver 格式校验正则
21+
var semverPattern = regexp.MustCompile(`^\d+\.\d+\.\d+$`)
22+
1823
// SettingHandler 系统设置处理器
1924
type SettingHandler struct {
2025
settingService *service.SettingService
@@ -93,6 +98,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
9398
OpsRealtimeMonitoringEnabled: settings.OpsRealtimeMonitoringEnabled,
9499
OpsQueryModeDefault: settings.OpsQueryModeDefault,
95100
OpsMetricsIntervalSeconds: settings.OpsMetricsIntervalSeconds,
101+
MinClaudeCodeVersion: settings.MinClaudeCodeVersion,
96102
})
97103
}
98104

@@ -159,6 +165,8 @@ type UpdateSettingsRequest struct {
159165
OpsRealtimeMonitoringEnabled *bool `json:"ops_realtime_monitoring_enabled"`
160166
OpsQueryModeDefault *string `json:"ops_query_mode_default"`
161167
OpsMetricsIntervalSeconds *int `json:"ops_metrics_interval_seconds"`
168+
169+
MinClaudeCodeVersion string `json:"min_claude_code_version"`
162170
}
163171

164172
// UpdateSettings 更新系统设置
@@ -293,6 +301,14 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
293301
req.OpsMetricsIntervalSeconds = &v
294302
}
295303

304+
// 验证最低版本号格式(空字符串=禁用,或合法 semver)
305+
if req.MinClaudeCodeVersion != "" {
306+
if !semverPattern.MatchString(req.MinClaudeCodeVersion) {
307+
response.Error(c, http.StatusBadRequest, "min_claude_code_version must be empty or a valid semver (e.g. 2.1.63)")
308+
return
309+
}
310+
}
311+
296312
settings := &service.SystemSettings{
297313
RegistrationEnabled: req.RegistrationEnabled,
298314
EmailVerifyEnabled: req.EmailVerifyEnabled,
@@ -334,6 +350,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
334350
FallbackModelAntigravity: req.FallbackModelAntigravity,
335351
EnableIdentityPatch: req.EnableIdentityPatch,
336352
IdentityPatchPrompt: req.IdentityPatchPrompt,
353+
MinClaudeCodeVersion: req.MinClaudeCodeVersion,
337354
OpsMonitoringEnabled: func() bool {
338355
if req.OpsMonitoringEnabled != nil {
339356
return *req.OpsMonitoringEnabled
@@ -420,6 +437,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
420437
OpsRealtimeMonitoringEnabled: updatedSettings.OpsRealtimeMonitoringEnabled,
421438
OpsQueryModeDefault: updatedSettings.OpsQueryModeDefault,
422439
OpsMetricsIntervalSeconds: updatedSettings.OpsMetricsIntervalSeconds,
440+
MinClaudeCodeVersion: updatedSettings.MinClaudeCodeVersion,
423441
})
424442
}
425443

@@ -562,6 +580,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
562580
if before.OpsMetricsIntervalSeconds != after.OpsMetricsIntervalSeconds {
563581
changed = append(changed, "ops_metrics_interval_seconds")
564582
}
583+
if before.MinClaudeCodeVersion != after.MinClaudeCodeVersion {
584+
changed = append(changed, "min_claude_code_version")
585+
}
565586
return changed
566587
}
567588

backend/internal/handler/dto/settings.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ type SystemSettings struct {
5858
OpsRealtimeMonitoringEnabled bool `json:"ops_realtime_monitoring_enabled"`
5959
OpsQueryModeDefault string `json:"ops_query_mode_default"`
6060
OpsMetricsIntervalSeconds int `json:"ops_metrics_interval_seconds"`
61+
62+
MinClaudeCodeVersion string `json:"min_claude_code_version"`
6163
}
6264

6365
type PublicSettings struct {

backend/internal/handler/gateway_handler.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ type GatewayHandler struct {
4848
maxAccountSwitches int
4949
maxAccountSwitchesGemini int
5050
cfg *config.Config
51+
settingService *service.SettingService
5152
}
5253

5354
// NewGatewayHandler creates a new GatewayHandler
@@ -63,6 +64,7 @@ func NewGatewayHandler(
6364
usageRecordWorkerPool *service.UsageRecordWorkerPool,
6465
errorPassthroughService *service.ErrorPassthroughService,
6566
cfg *config.Config,
67+
settingService *service.SettingService,
6668
) *GatewayHandler {
6769
pingInterval := time.Duration(0)
6870
maxAccountSwitches := 10
@@ -90,6 +92,7 @@ func NewGatewayHandler(
9092
maxAccountSwitches: maxAccountSwitches,
9193
maxAccountSwitchesGemini: maxAccountSwitchesGemini,
9294
cfg: cfg,
95+
settingService: settingService,
9396
}
9497
}
9598

@@ -155,6 +158,11 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
155158
SetClaudeCodeClientContext(c, body, parsedReq)
156159
isClaudeCodeClient := service.IsClaudeCodeClient(c.Request.Context())
157160

161+
// 版本检查:仅对 Claude Code 客户端,拒绝低于最低版本的请求
162+
if !h.checkClaudeCodeVersion(c) {
163+
return
164+
}
165+
158166
// 在请求上下文中记录 thinking 状态,供 Antigravity 最终模型 key 推导/模型维度限流使用
159167
c.Request = c.Request.WithContext(service.WithThinkingEnabled(c.Request.Context(), parsedReq.ThinkingEnabled, h.metadataBridgeEnabled()))
160168

@@ -1003,6 +1011,41 @@ func (h *GatewayHandler) ensureForwardErrorResponse(c *gin.Context, streamStarte
10031011
return true
10041012
}
10051013

1014+
// checkClaudeCodeVersion 检查 Claude Code 客户端版本是否满足最低要求
1015+
// 仅对已识别的 Claude Code 客户端执行,count_tokens 路径除外
1016+
func (h *GatewayHandler) checkClaudeCodeVersion(c *gin.Context) bool {
1017+
ctx := c.Request.Context()
1018+
if !service.IsClaudeCodeClient(ctx) {
1019+
return true
1020+
}
1021+
1022+
// 排除 count_tokens 子路径
1023+
if strings.HasSuffix(c.Request.URL.Path, "/count_tokens") {
1024+
return true
1025+
}
1026+
1027+
minVersion := h.settingService.GetMinClaudeCodeVersion(ctx)
1028+
if minVersion == "" {
1029+
return true // 未设置,不检查
1030+
}
1031+
1032+
clientVersion := service.GetClaudeCodeVersion(ctx)
1033+
if clientVersion == "" {
1034+
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error",
1035+
"Unable to determine Claude Code version. Please update Claude Code: npm update -g @anthropic-ai/claude-code")
1036+
return false
1037+
}
1038+
1039+
if service.CompareVersions(clientVersion, minVersion) < 0 {
1040+
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error",
1041+
fmt.Sprintf("Your Claude Code version (%s) is below the minimum required version (%s). Please update: npm update -g @anthropic-ai/claude-code",
1042+
clientVersion, minVersion))
1043+
return false
1044+
}
1045+
1046+
return true
1047+
}
1048+
10061049
// errorResponse 返回Claude API格式的错误响应
10071050
func (h *GatewayHandler) errorResponse(c *gin.Context, status int, errType, message string) {
10081051
c.JSON(status, gin.H{

backend/internal/handler/gateway_helper.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,10 @@ func SetClaudeCodeClientContext(c *gin.Context, body []byte, parsedReq *service.
2929
if parsedReq != nil {
3030
c.Set(claudeCodeParsedRequestContextKey, parsedReq)
3131
}
32+
33+
ua := c.GetHeader("User-Agent")
3234
// Fast path:非 Claude CLI UA 直接判定 false,避免热路径二次 JSON 反序列化。
33-
if !claudeCodeValidator.ValidateUserAgent(c.GetHeader("User-Agent")) {
35+
if !claudeCodeValidator.ValidateUserAgent(ua) {
3436
ctx := service.SetClaudeCodeClient(c.Request.Context(), false)
3537
c.Request = c.Request.WithContext(ctx)
3638
return
@@ -54,6 +56,14 @@ func SetClaudeCodeClientContext(c *gin.Context, body []byte, parsedReq *service.
5456

5557
// 更新 request context
5658
ctx := service.SetClaudeCodeClient(c.Request.Context(), isClaudeCode)
59+
60+
// 仅在确认为 Claude Code 客户端时提取版本号写入 context
61+
if isClaudeCode {
62+
if version := claudeCodeValidator.ExtractVersion(ua); version != "" {
63+
ctx = service.SetClaudeCodeVersion(ctx, version)
64+
}
65+
}
66+
5767
c.Request = c.Request.WithContext(ctx)
5868
}
5969

backend/internal/pkg/ctxkey/ctxkey.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,7 @@ const (
5252
// PrefetchedStickyGroupID 标识上游预取 sticky session 时所使用的分组 ID。
5353
// Service 层仅在分组匹配时复用 PrefetchedStickyAccountID,避免分组切换重试误用旧 sticky。
5454
PrefetchedStickyGroupID Key = "ctx_prefetched_sticky_group_id"
55+
56+
// ClaudeCodeVersion stores the extracted Claude Code version from User-Agent (e.g. "2.1.22")
57+
ClaudeCodeVersion Key = "ctx_claude_code_version"
5558
)

backend/internal/service/claude_code_validator.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"net/http"
66
"regexp"
7+
"strconv"
78
"strings"
89

910
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
@@ -17,6 +18,9 @@ var (
1718
// User-Agent 匹配: claude-cli/x.x.x (仅支持官方 CLI,大小写不敏感)
1819
claudeCodeUAPattern = regexp.MustCompile(`(?i)^claude-cli/\d+\.\d+\.\d+`)
1920

21+
// 带捕获组的版本提取正则
22+
claudeCodeUAVersionPattern = regexp.MustCompile(`(?i)^claude-cli/(\d+\.\d+\.\d+)`)
23+
2024
// metadata.user_id 格式: user_{64位hex}_account__session_{uuid}
2125
userIDPattern = regexp.MustCompile(`^user_[a-fA-F0-9]{64}_account__session_[\w-]+$`)
2226

@@ -270,3 +274,55 @@ func IsClaudeCodeClient(ctx context.Context) bool {
270274
func SetClaudeCodeClient(ctx context.Context, isClaudeCode bool) context.Context {
271275
return context.WithValue(ctx, ctxkey.IsClaudeCodeClient, isClaudeCode)
272276
}
277+
278+
// ExtractVersion 从 User-Agent 中提取 Claude Code 版本号
279+
// 返回 "2.1.22" 形式的版本号,如果不匹配返回空字符串
280+
func (v *ClaudeCodeValidator) ExtractVersion(ua string) string {
281+
matches := claudeCodeUAVersionPattern.FindStringSubmatch(ua)
282+
if len(matches) >= 2 {
283+
return matches[1]
284+
}
285+
return ""
286+
}
287+
288+
// SetClaudeCodeVersion 将 Claude Code 版本号设置到 context 中
289+
func SetClaudeCodeVersion(ctx context.Context, version string) context.Context {
290+
return context.WithValue(ctx, ctxkey.ClaudeCodeVersion, version)
291+
}
292+
293+
// GetClaudeCodeVersion 从 context 中获取 Claude Code 版本号
294+
func GetClaudeCodeVersion(ctx context.Context) string {
295+
if v, ok := ctx.Value(ctxkey.ClaudeCodeVersion).(string); ok {
296+
return v
297+
}
298+
return ""
299+
}
300+
301+
// CompareVersions 比较两个 semver 版本号
302+
// 返回: -1 (a < b), 0 (a == b), 1 (a > b)
303+
func CompareVersions(a, b string) int {
304+
aParts := parseSemver(a)
305+
bParts := parseSemver(b)
306+
for i := 0; i < 3; i++ {
307+
if aParts[i] < bParts[i] {
308+
return -1
309+
}
310+
if aParts[i] > bParts[i] {
311+
return 1
312+
}
313+
}
314+
return 0
315+
}
316+
317+
// parseSemver 解析 semver 版本号为 [major, minor, patch]
318+
func parseSemver(v string) [3]int {
319+
v = strings.TrimPrefix(v, "v")
320+
parts := strings.Split(v, ".")
321+
result := [3]int{0, 0, 0}
322+
for i := 0; i < len(parts) && i < 3; i++ {
323+
if parsed, err := strconv.Atoi(parts[i]); err == nil {
324+
result[i] = parsed
325+
}
326+
}
327+
return result
328+
}

backend/internal/service/claude_code_validator_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,51 @@ func TestClaudeCodeValidator_NonMessagesPathUAOnly(t *testing.T) {
5656
ok := validator.Validate(req, nil)
5757
require.True(t, ok)
5858
}
59+
60+
func TestExtractVersion(t *testing.T) {
61+
v := NewClaudeCodeValidator()
62+
tests := []struct {
63+
ua string
64+
want string
65+
}{
66+
{"claude-cli/2.1.22 (darwin; arm64)", "2.1.22"},
67+
{"claude-cli/1.0.0", "1.0.0"},
68+
{"Claude-CLI/3.10.5 (linux; x86_64)", "3.10.5"}, // 大小写不敏感
69+
{"curl/8.0.0", ""}, // 非 Claude CLI
70+
{"", ""}, // 空字符串
71+
{"claude-cli/", ""}, // 无版本号
72+
{"claude-cli/2.1.22-beta", "2.1.22"}, // 带后缀仍提取主版本号
73+
}
74+
for _, tt := range tests {
75+
got := v.ExtractVersion(tt.ua)
76+
require.Equal(t, tt.want, got, "ExtractVersion(%q)", tt.ua)
77+
}
78+
}
79+
80+
func TestCompareVersions(t *testing.T) {
81+
tests := []struct {
82+
a, b string
83+
want int
84+
}{
85+
{"2.1.0", "2.1.0", 0}, // 相等
86+
{"2.1.1", "2.1.0", 1}, // patch 更大
87+
{"2.0.0", "2.1.0", -1}, // minor 更小
88+
{"3.0.0", "2.99.99", 1}, // major 更大
89+
{"1.0.0", "2.0.0", -1}, // major 更小
90+
{"0.0.1", "0.0.0", 1}, // patch 差异
91+
{"", "1.0.0", -1}, // 空字符串 vs 正常版本
92+
{"v2.1.0", "2.1.0", 0}, // v 前缀处理
93+
}
94+
for _, tt := range tests {
95+
got := CompareVersions(tt.a, tt.b)
96+
require.Equal(t, tt.want, got, "CompareVersions(%q, %q)", tt.a, tt.b)
97+
}
98+
}
99+
100+
func TestSetGetClaudeCodeVersion(t *testing.T) {
101+
ctx := context.Background()
102+
require.Equal(t, "", GetClaudeCodeVersion(ctx), "empty context should return empty string")
103+
104+
ctx = SetClaudeCodeVersion(ctx, "2.1.63")
105+
require.Equal(t, "2.1.63", GetClaudeCodeVersion(ctx))
106+
}

backend/internal/service/domain_constants.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,13 @@ const (
192192
// =========================
193193

194194
SettingKeySoraDefaultStorageQuotaBytes = "sora_default_storage_quota_bytes" // 新用户默认 Sora 存储配额(字节)
195+
196+
// =========================
197+
// Claude Code Version Check
198+
// =========================
199+
200+
// SettingKeyMinClaudeCodeVersion 最低 Claude Code 版本号要求 (semver, 如 "2.1.0",空值=不检查)
201+
SettingKeyMinClaudeCodeVersion = "min_claude_code_version"
195202
)
196203

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

0 commit comments

Comments
 (0)