feat(gateway): 添加 Claude Code 客户端最低版本检查功能
- 通过 User-Agent 识别 Claude Code 客户端并提取版本号 - 在网关层验证客户端版本是否满足管理员配置的最低要求 - 在管理后台提供版本要求配置选项(英文/中文双语) - 实现原子缓存 + singleflight 防止并发问题和 thundering herd - 使用 context.WithoutCancel 隔离 DB 查询,避免客户端断连影响缓存 - 双 TTL 策略:60s 正常、5s 错误恢复,保证性能与可用性 - 仅检查 Claude Code 客户端,其他客户端不受影响 - 添加完整单元测试覆盖版本提取、比对、上下文操作
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||
@@ -17,6 +18,9 @@ var (
|
||||
// User-Agent 匹配: claude-cli/x.x.x (仅支持官方 CLI,大小写不敏感)
|
||||
claudeCodeUAPattern = regexp.MustCompile(`(?i)^claude-cli/\d+\.\d+\.\d+`)
|
||||
|
||||
// 带捕获组的版本提取正则
|
||||
claudeCodeUAVersionPattern = regexp.MustCompile(`(?i)^claude-cli/(\d+\.\d+\.\d+)`)
|
||||
|
||||
// metadata.user_id 格式: user_{64位hex}_account__session_{uuid}
|
||||
userIDPattern = regexp.MustCompile(`^user_[a-fA-F0-9]{64}_account__session_[\w-]+$`)
|
||||
|
||||
@@ -270,3 +274,55 @@ func IsClaudeCodeClient(ctx context.Context) bool {
|
||||
func SetClaudeCodeClient(ctx context.Context, isClaudeCode bool) context.Context {
|
||||
return context.WithValue(ctx, ctxkey.IsClaudeCodeClient, isClaudeCode)
|
||||
}
|
||||
|
||||
// ExtractVersion 从 User-Agent 中提取 Claude Code 版本号
|
||||
// 返回 "2.1.22" 形式的版本号,如果不匹配返回空字符串
|
||||
func (v *ClaudeCodeValidator) ExtractVersion(ua string) string {
|
||||
matches := claudeCodeUAVersionPattern.FindStringSubmatch(ua)
|
||||
if len(matches) >= 2 {
|
||||
return matches[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SetClaudeCodeVersion 将 Claude Code 版本号设置到 context 中
|
||||
func SetClaudeCodeVersion(ctx context.Context, version string) context.Context {
|
||||
return context.WithValue(ctx, ctxkey.ClaudeCodeVersion, version)
|
||||
}
|
||||
|
||||
// GetClaudeCodeVersion 从 context 中获取 Claude Code 版本号
|
||||
func GetClaudeCodeVersion(ctx context.Context) string {
|
||||
if v, ok := ctx.Value(ctxkey.ClaudeCodeVersion).(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// CompareVersions 比较两个 semver 版本号
|
||||
// 返回: -1 (a < b), 0 (a == b), 1 (a > b)
|
||||
func CompareVersions(a, b string) int {
|
||||
aParts := parseSemver(a)
|
||||
bParts := parseSemver(b)
|
||||
for i := 0; i < 3; i++ {
|
||||
if aParts[i] < bParts[i] {
|
||||
return -1
|
||||
}
|
||||
if aParts[i] > bParts[i] {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// parseSemver 解析 semver 版本号为 [major, minor, patch]
|
||||
func parseSemver(v string) [3]int {
|
||||
v = strings.TrimPrefix(v, "v")
|
||||
parts := strings.Split(v, ".")
|
||||
result := [3]int{0, 0, 0}
|
||||
for i := 0; i < len(parts) && i < 3; i++ {
|
||||
if parsed, err := strconv.Atoi(parts[i]); err == nil {
|
||||
result[i] = parsed
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -56,3 +56,51 @@ func TestClaudeCodeValidator_NonMessagesPathUAOnly(t *testing.T) {
|
||||
ok := validator.Validate(req, nil)
|
||||
require.True(t, ok)
|
||||
}
|
||||
|
||||
func TestExtractVersion(t *testing.T) {
|
||||
v := NewClaudeCodeValidator()
|
||||
tests := []struct {
|
||||
ua string
|
||||
want string
|
||||
}{
|
||||
{"claude-cli/2.1.22 (darwin; arm64)", "2.1.22"},
|
||||
{"claude-cli/1.0.0", "1.0.0"},
|
||||
{"Claude-CLI/3.10.5 (linux; x86_64)", "3.10.5"}, // 大小写不敏感
|
||||
{"curl/8.0.0", ""}, // 非 Claude CLI
|
||||
{"", ""}, // 空字符串
|
||||
{"claude-cli/", ""}, // 无版本号
|
||||
{"claude-cli/2.1.22-beta", "2.1.22"}, // 带后缀仍提取主版本号
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := v.ExtractVersion(tt.ua)
|
||||
require.Equal(t, tt.want, got, "ExtractVersion(%q)", tt.ua)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareVersions(t *testing.T) {
|
||||
tests := []struct {
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"2.1.0", "2.1.0", 0}, // 相等
|
||||
{"2.1.1", "2.1.0", 1}, // patch 更大
|
||||
{"2.0.0", "2.1.0", -1}, // minor 更小
|
||||
{"3.0.0", "2.99.99", 1}, // major 更大
|
||||
{"1.0.0", "2.0.0", -1}, // major 更小
|
||||
{"0.0.1", "0.0.0", 1}, // patch 差异
|
||||
{"", "1.0.0", -1}, // 空字符串 vs 正常版本
|
||||
{"v2.1.0", "2.1.0", 0}, // v 前缀处理
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := CompareVersions(tt.a, tt.b)
|
||||
require.Equal(t, tt.want, got, "CompareVersions(%q, %q)", tt.a, tt.b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetGetClaudeCodeVersion(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
require.Equal(t, "", GetClaudeCodeVersion(ctx), "empty context should return empty string")
|
||||
|
||||
ctx = SetClaudeCodeVersion(ctx, "2.1.63")
|
||||
require.Equal(t, "2.1.63", GetClaudeCodeVersion(ctx))
|
||||
}
|
||||
|
||||
@@ -192,6 +192,13 @@ const (
|
||||
// =========================
|
||||
|
||||
SettingKeySoraDefaultStorageQuotaBytes = "sora_default_storage_quota_bytes" // 新用户默认 Sora 存储配额(字节)
|
||||
|
||||
// =========================
|
||||
// Claude Code Version Check
|
||||
// =========================
|
||||
|
||||
// SettingKeyMinClaudeCodeVersion 最低 Claude Code 版本号要求 (semver, 如 "2.1.0",空值=不检查)
|
||||
SettingKeyMinClaudeCodeVersion = "min_claude_code_version"
|
||||
)
|
||||
|
||||
// AdminAPIKeyPrefix is the prefix for admin API keys (distinct from user "sk-" keys).
|
||||
|
||||
@@ -7,12 +7,15 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -32,6 +35,27 @@ type SettingRepository interface {
|
||||
Delete(ctx context.Context, key string) error
|
||||
}
|
||||
|
||||
// cachedMinVersion 缓存最低 Claude Code 版本号(进程内缓存,60s TTL)
|
||||
type cachedMinVersion struct {
|
||||
value string // 空字符串 = 不检查
|
||||
expiresAt int64 // unix nano
|
||||
}
|
||||
|
||||
// minVersionCache 最低版本号进程内缓存
|
||||
var minVersionCache atomic.Value // *cachedMinVersion
|
||||
|
||||
// minVersionSF 防止缓存过期时 thundering herd
|
||||
var minVersionSF singleflight.Group
|
||||
|
||||
// minVersionCacheTTL 缓存有效期
|
||||
const minVersionCacheTTL = 60 * time.Second
|
||||
|
||||
// minVersionErrorTTL DB 错误时的短缓存,快速重试
|
||||
const minVersionErrorTTL = 5 * time.Second
|
||||
|
||||
// minVersionDBTimeout singleflight 内 DB 查询超时,独立于请求 context
|
||||
const minVersionDBTimeout = 5 * time.Second
|
||||
|
||||
// SettingService 系统设置服务
|
||||
type SettingService struct {
|
||||
settingRepo SettingRepository
|
||||
@@ -270,9 +294,20 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
|
||||
updates[SettingKeyOpsMetricsIntervalSeconds] = strconv.Itoa(settings.OpsMetricsIntervalSeconds)
|
||||
}
|
||||
|
||||
// Claude Code version check
|
||||
updates[SettingKeyMinClaudeCodeVersion] = settings.MinClaudeCodeVersion
|
||||
|
||||
err := s.settingRepo.SetMultiple(ctx, updates)
|
||||
if err == nil && s.onUpdate != nil {
|
||||
s.onUpdate() // Invalidate cache after settings update
|
||||
if err == nil {
|
||||
// 先使 inflight singleflight 失效,再刷新缓存,缩小旧值覆盖新值的竞态窗口
|
||||
minVersionSF.Forget("min_version")
|
||||
minVersionCache.Store(&cachedMinVersion{
|
||||
value: settings.MinClaudeCodeVersion,
|
||||
expiresAt: time.Now().Add(minVersionCacheTTL).UnixNano(),
|
||||
})
|
||||
if s.onUpdate != nil {
|
||||
s.onUpdate() // Invalidate cache after settings update
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -417,6 +452,9 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
|
||||
SettingKeyOpsRealtimeMonitoringEnabled: "true",
|
||||
SettingKeyOpsQueryModeDefault: "auto",
|
||||
SettingKeyOpsMetricsIntervalSeconds: "60",
|
||||
|
||||
// Claude Code version check (default: empty = disabled)
|
||||
SettingKeyMinClaudeCodeVersion: "",
|
||||
}
|
||||
|
||||
return s.settingRepo.SetMultiple(ctx, defaults)
|
||||
@@ -542,6 +580,9 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
||||
}
|
||||
}
|
||||
|
||||
// Claude Code version check
|
||||
result.MinClaudeCodeVersion = settings[SettingKeyMinClaudeCodeVersion]
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -839,6 +880,46 @@ func (s *SettingService) GetStreamTimeoutSettings(ctx context.Context) (*StreamT
|
||||
return &settings, nil
|
||||
}
|
||||
|
||||
// GetMinClaudeCodeVersion 获取最低 Claude Code 版本号要求
|
||||
// 使用进程内 atomic.Value 缓存,60 秒 TTL,热路径零锁开销
|
||||
// singleflight 防止缓存过期时 thundering herd
|
||||
// 返回空字符串表示不做版本检查
|
||||
func (s *SettingService) GetMinClaudeCodeVersion(ctx context.Context) string {
|
||||
if cached, ok := minVersionCache.Load().(*cachedMinVersion); ok {
|
||||
if time.Now().UnixNano() < cached.expiresAt {
|
||||
return cached.value
|
||||
}
|
||||
}
|
||||
// singleflight: 同一时刻只有一个 goroutine 查询 DB,其余复用结果
|
||||
result, _, _ := minVersionSF.Do("min_version", func() (interface{}, error) {
|
||||
// 二次检查,避免排队的 goroutine 重复查询
|
||||
if cached, ok := minVersionCache.Load().(*cachedMinVersion); ok {
|
||||
if time.Now().UnixNano() < cached.expiresAt {
|
||||
return cached.value, nil
|
||||
}
|
||||
}
|
||||
// 使用独立 context:断开请求取消链,避免客户端断连导致空值被长期缓存
|
||||
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), minVersionDBTimeout)
|
||||
defer cancel()
|
||||
value, err := s.settingRepo.GetValue(dbCtx, SettingKeyMinClaudeCodeVersion)
|
||||
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(),
|
||||
})
|
||||
return "", nil
|
||||
}
|
||||
minVersionCache.Store(&cachedMinVersion{
|
||||
value: value,
|
||||
expiresAt: time.Now().Add(minVersionCacheTTL).UnixNano(),
|
||||
})
|
||||
return value, nil
|
||||
})
|
||||
return result.(string)
|
||||
}
|
||||
|
||||
// SetStreamTimeoutSettings 设置流超时处理配置
|
||||
func (s *SettingService) SetStreamTimeoutSettings(ctx context.Context, settings *StreamTimeoutSettings) error {
|
||||
if settings == nil {
|
||||
|
||||
@@ -60,6 +60,9 @@ type SystemSettings struct {
|
||||
OpsRealtimeMonitoringEnabled bool
|
||||
OpsQueryModeDefault string
|
||||
OpsMetricsIntervalSeconds int
|
||||
|
||||
// Claude Code version check
|
||||
MinClaudeCodeVersion string
|
||||
}
|
||||
|
||||
type PublicSettings struct {
|
||||
|
||||
Reference in New Issue
Block a user