feat(gateway): 添加 Claude Code 客户端最低版本检查功能

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

View File

@@ -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 {