From b3887f2f6e2baefc0fd6c67b7639835e6cdee3de Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Wed, 1 Apr 2026 00:21:00 +0800 Subject: [PATCH 1/2] feat(identity): realistic fingerprint management and device ID persistence Replace the single hardcoded fingerprint (Linux arm64, cli/2.1.22) with a pool of 5 realistic profiles (macOS arm64/x64, Linux x64/arm64, Windows x64) selected deterministically per account via SHA256(accountID). Device IDs are now persisted to Account.Extra in the database instead of relying solely on Redis 7-day TTL, preventing identity changes on cache flush. Admins can also import real device_id values from actual Claude Code installs. On first device_id generation, an async startup probe (max_tokens=1 + haiku) is sent to Anthropic to simulate Claude Code's connectivity check, associating the device_id with the OAuth token on the server side. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/cmd/server/wire_gen.go | 2 +- backend/internal/pkg/claude/constants.go | 114 +++++++++++++- backend/internal/pkg/claude/constants_test.go | 78 ++++++++++ backend/internal/service/gateway_service.go | 77 +++++++++- backend/internal/service/identity_service.go | 141 ++++++++++++------ .../service/identity_service_order_test.go | 4 +- backend/internal/service/wire.go | 8 +- 7 files changed, 366 insertions(+), 58 deletions(-) create mode 100644 backend/internal/pkg/claude/constants_test.go diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index ce898a4a90..5a8cb7e3e4 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -171,7 +171,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { return nil, err } billingService := service.NewBillingService(configConfig, pricingService) - identityService := service.NewIdentityService(identityCache) + identityService := service.ProvideIdentityService(identityCache, accountRepository) deferredService := service.ProvideDeferredService(accountRepository, timingWheelService) claudeTokenProvider := service.ProvideClaudeTokenProvider(accountRepository, geminiTokenCache, oAuthService, oauthRefreshAPI) digestSessionStore := service.NewDigestSessionStore() diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go index dfca252f48..d4fd49b4c9 100644 --- a/backend/internal/pkg/claude/constants.go +++ b/backend/internal/pkg/claude/constants.go @@ -1,6 +1,12 @@ // Package claude provides constants and helpers for Claude API integration. package claude +import ( + "crypto/sha256" + "encoding/binary" + "fmt" +) + // Claude Code 客户端相关常量 // Beta header 常量 @@ -44,23 +50,123 @@ const APIKeyBetaHeader = BetaClaudeCode + "," + BetaInterleavedThinking + "," + // APIKeyHaikuBetaHeader Haiku 模型在 API-key 账号下使用的 anthropic-beta header(不包含 oauth / claude-code) const APIKeyHaikuBetaHeader = BetaInterleavedThinking +// DefaultCLIVersion 与 header_util.go 抓包来源(claude-cli/2.1.81)保持一致 +const DefaultCLIVersion = "2.1.81" + // DefaultHeaders 是 Claude Code 客户端默认请求头。 +// 注意:当启用了指纹系统时,这些值会被 ApplyFingerprint 覆盖。 +// 此处作为最后的 fallback,使用最新版本。 var DefaultHeaders = map[string]string{ // Keep these in sync with recent Claude CLI traffic to reduce the chance // that Claude Code-scoped OAuth credentials are rejected as "non-CLI" usage. - "User-Agent": "claude-cli/2.1.22 (external, cli)", + "User-Agent": "claude-cli/" + DefaultCLIVersion + " (external, cli)", "X-Stainless-Lang": "js", - "X-Stainless-Package-Version": "0.70.0", - "X-Stainless-OS": "Linux", + "X-Stainless-Package-Version": "0.72.1", + "X-Stainless-OS": "MacOS", "X-Stainless-Arch": "arm64", "X-Stainless-Runtime": "node", - "X-Stainless-Runtime-Version": "v24.13.0", + "X-Stainless-Runtime-Version": "v22.16.0", "X-Stainless-Retry-Count": "0", "X-Stainless-Timeout": "600", "X-App": "cli", "Anthropic-Dangerous-Direct-Browser-Access": "true", } +// FingerprintProfile 真实 Claude Code 客户端的指纹模板。 +// 基于对真实 Claude CLI 流量的观察,覆盖常见的 OS/Arch/Runtime 组合。 +type FingerprintProfile struct { + OS string // X-Stainless-OS: "MacOS", "Linux", "Windows_NT" + Arch string // X-Stainless-Arch: "arm64", "x64" + Runtime string // X-Stainless-Runtime: "node" + RuntimeVersions []string // 该平台常见的 node 版本 + PackageVersions []string // @anthropic-ai/sdk 版本 + CLIVersions []string // claude-cli 版本 +} + +// RealisticProfiles 基于真实 Claude Code 用户环境的指纹模板池。 +// 权重按真实用户分布:macOS arm64 最多,其次 Linux x64,再次 macOS x64 等。 +var RealisticProfiles = []FingerprintProfile{ + { + // macOS Apple Silicon — 最常见的 Claude Code 用户环境 + OS: "MacOS", Arch: "arm64", Runtime: "node", + RuntimeVersions: []string{"v22.11.0", "v22.16.0", "v24.13.0"}, + PackageVersions: []string{"0.70.0", "0.72.1", "0.68.2"}, + CLIVersions: []string{"2.1.81", "2.1.78", "2.2.0"}, + }, + { + // macOS Intel — 老款 Mac 用户 + OS: "MacOS", Arch: "x64", Runtime: "node", + RuntimeVersions: []string{"v22.11.0", "v22.16.0"}, + PackageVersions: []string{"0.70.0", "0.72.1"}, + CLIVersions: []string{"2.1.81", "2.1.78"}, + }, + { + // Linux x64 — 服务器/WSL/开发者 + OS: "Linux", Arch: "x64", Runtime: "node", + RuntimeVersions: []string{"v22.11.0", "v22.16.0", "v24.13.0"}, + PackageVersions: []string{"0.70.0", "0.72.1", "0.68.2"}, + CLIVersions: []string{"2.1.81", "2.1.78", "2.2.0"}, + }, + { + // Linux arm64 — ARM 服务器/Raspberry Pi/Codespaces + OS: "Linux", Arch: "arm64", Runtime: "node", + RuntimeVersions: []string{"v22.16.0", "v24.13.0"}, + PackageVersions: []string{"0.70.0", "0.72.1"}, + CLIVersions: []string{"2.1.81", "2.1.78"}, + }, + { + // Windows x64 — Windows 用户 + OS: "Windows_NT", Arch: "x64", Runtime: "node", + RuntimeVersions: []string{"v22.11.0", "v22.16.0"}, + PackageVersions: []string{"0.70.0", "0.72.1"}, + CLIVersions: []string{"2.1.81", "2.1.78"}, + }, +} + +// SelectedFingerprint 从 SelectProfileForAccount 返回的完整指纹选择结果。 +type SelectedFingerprint struct { + ProfileIndex int // 在 RealisticProfiles 中的索引,用于持久化 + Profile FingerprintProfile + CLIVersion string + PackageVersion string + RuntimeVersion string + UserAgent string // 完整的 User-Agent 字符串 +} + +// SelectProfileForAccount 基于 accountID 确定性地选择一个完整的指纹组合。 +// 同一个 accountID 永远返回相同的结果。如果提供了 lockedIndex >= 0, +// 则使用该索引选择 profile(用于已持久化的账号)。 +func SelectProfileForAccount(accountID int64, lockedIndex int) SelectedFingerprint { + h := sha256.Sum256([]byte(fmt.Sprintf("fingerprint:%d", accountID))) + seed := binary.BigEndian.Uint64(h[:8]) + + // 选择 profile + profileIdx := int(seed % uint64(len(RealisticProfiles))) + if lockedIndex >= 0 && lockedIndex < len(RealisticProfiles) { + profileIdx = lockedIndex + } + profile := RealisticProfiles[profileIdx] + + // 确定性选择版本组合(使用不同的 hash 位段避免关联) + seed2 := binary.BigEndian.Uint64(h[8:16]) + cliVersion := profile.CLIVersions[int(seed2%uint64(len(profile.CLIVersions)))] + + seed3 := binary.BigEndian.Uint64(h[16:24]) + pkgVersion := profile.PackageVersions[int(seed3%uint64(len(profile.PackageVersions)))] + + seed4 := binary.BigEndian.Uint64(h[24:32]) + rtVersion := profile.RuntimeVersions[int(seed4%uint64(len(profile.RuntimeVersions)))] + + return SelectedFingerprint{ + ProfileIndex: profileIdx, + Profile: profile, + CLIVersion: cliVersion, + PackageVersion: pkgVersion, + RuntimeVersion: rtVersion, + UserAgent: fmt.Sprintf("claude-cli/%s (external, cli)", cliVersion), + } +} + // Model 表示一个 Claude 模型 type Model struct { ID string `json:"id"` diff --git a/backend/internal/pkg/claude/constants_test.go b/backend/internal/pkg/claude/constants_test.go new file mode 100644 index 0000000000..d0757c8218 --- /dev/null +++ b/backend/internal/pkg/claude/constants_test.go @@ -0,0 +1,78 @@ +package claude + +import ( + "testing" +) + +func TestSelectProfileForAccount_Deterministic(t *testing.T) { + // Same accountID always produces same result + r1 := SelectProfileForAccount(42, -1) + r2 := SelectProfileForAccount(42, -1) + + if r1.UserAgent != r2.UserAgent { + t.Errorf("UserAgent not deterministic: %q != %q", r1.UserAgent, r2.UserAgent) + } + if r1.Profile.OS != r2.Profile.OS { + t.Errorf("OS not deterministic: %q != %q", r1.Profile.OS, r2.Profile.OS) + } + if r1.CLIVersion != r2.CLIVersion { + t.Errorf("CLIVersion not deterministic: %q != %q", r1.CLIVersion, r2.CLIVersion) + } + if r1.PackageVersion != r2.PackageVersion { + t.Errorf("PackageVersion not deterministic: %q != %q", r1.PackageVersion, r2.PackageVersion) + } + if r1.RuntimeVersion != r2.RuntimeVersion { + t.Errorf("RuntimeVersion not deterministic: %q != %q", r1.RuntimeVersion, r2.RuntimeVersion) + } +} + +func TestSelectProfileForAccount_Diverse(t *testing.T) { + // Different accountIDs should produce at least 2 different OS values across 100 accounts + osSet := make(map[string]struct{}) + for i := int64(1); i <= 100; i++ { + sel := SelectProfileForAccount(i, -1) + osSet[sel.Profile.OS] = struct{}{} + } + + if len(osSet) < 2 { + t.Errorf("expected at least 2 different OS values across 100 accounts, got %d: %v", len(osSet), osSet) + } +} + +func TestSelectProfileForAccount_LockedIndex(t *testing.T) { + // When lockedIndex is provided, profile selection should use that index + sel0 := SelectProfileForAccount(999, 0) + sel4 := SelectProfileForAccount(999, 4) + + if sel0.Profile.OS != RealisticProfiles[0].OS { + t.Errorf("lockedIndex=0 should use profile[0], got OS=%q, want %q", sel0.Profile.OS, RealisticProfiles[0].OS) + } + if sel4.Profile.OS != RealisticProfiles[4].OS { + t.Errorf("lockedIndex=4 should use profile[4], got OS=%q, want %q", sel4.Profile.OS, RealisticProfiles[4].OS) + } +} + +func TestSelectProfileForAccount_ValidOutput(t *testing.T) { + for i := int64(0); i < 50; i++ { + sel := SelectProfileForAccount(i, -1) + + if sel.UserAgent == "" { + t.Errorf("account %d: empty UserAgent", i) + } + if sel.CLIVersion == "" { + t.Errorf("account %d: empty CLIVersion", i) + } + if sel.PackageVersion == "" { + t.Errorf("account %d: empty PackageVersion", i) + } + if sel.RuntimeVersion == "" { + t.Errorf("account %d: empty RuntimeVersion", i) + } + if sel.Profile.OS == "" { + t.Errorf("account %d: empty OS", i) + } + if sel.Profile.Arch == "" { + t.Errorf("account %d: empty Arch", i) + } + } +} diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 94e04d286d..039aafcba8 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -4166,7 +4166,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: true} if s.identityService != nil { - fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header) + fp, _, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header, account) if err == nil && fp != nil { // metadata 透传开启时跳过 metadata 注入 _, mimicMPT := s.settingService.GetGatewayForwardingSettings(ctx) @@ -5720,8 +5720,8 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex enableFP, enableMPT = s.settingService.GetGatewayForwardingSettings(ctx) } if account.IsOAuth() && s.identityService != nil { - // 1. 获取或创建指纹(包含随机生成的ClientID) - fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders) + // 1. 获取或创建指纹(包含持久化的 device_id 和多样化 profile) + fp, isNewDevice, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders, account) if err != nil { logger.LegacyPrintf("service.gateway", "Warning: failed to get fingerprint for account %d: %v", account.ID, err) // 失败时降级为透传原始headers @@ -5741,6 +5741,11 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex } } } + + // 3. 首次 device_id 生成时,异步发送 Claude Code 启动探测 + if isNewDevice { + go s.simulateStartupProbe(context.Background(), account, fp) + } } } @@ -6244,6 +6249,70 @@ func applyClaudeCodeMimicHeaders(req *http.Request, isStream bool) { } } +// simulateStartupProbe 模拟 Claude Code 启动时的连通性探测请求。 +// 真实 Claude Code 在启动时发送 max_tokens=1 + haiku 的非流式请求来验证 API 连接。 +// 此方法在新 device_id 首次生成后异步调用,让 Anthropic 服务端关联 device_id 与 OAuth token。 +func (s *GatewayService) simulateStartupProbe(ctx context.Context, account *Account, fp *Fingerprint) { + if account == nil || fp == nil { + return + } + + token, tokenType, err := s.GetAccessToken(ctx, account) + if err != nil { + logger.LegacyPrintf("service.gateway", "Startup probe: failed to get token for account %d: %v", account.ID, err) + return + } + + accountUUID := account.GetExtraString("account_uuid") + sessionID := generateRandomUUID() + version := ExtractCLIVersion(fp.UserAgent) + metadataUserID := FormatMetadataUserID(fp.ClientID, accountUUID, sessionID, version) + + probeBody := fmt.Sprintf(`{"model":"claude-haiku-4-5-20251001","max_tokens":1,"messages":[{"role":"user","content":"hi"}],"metadata":{"user_id":%q}}`, metadataUserID) + + probeURL := "https://api.anthropic.com/v1/messages?beta=true" + req, err := http.NewRequestWithContext(ctx, "POST", probeURL, strings.NewReader(probeBody)) + if err != nil { + logger.LegacyPrintf("service.gateway", "Startup probe: failed to create request for account %d: %v", account.ID, err) + return + } + + // 认证头 + if tokenType == "oauth" { + setHeaderRaw(req.Header, "authorization", "Bearer "+token) + } else { + setHeaderRaw(req.Header, "x-api-key", token) + } + + // 基础头 + setHeaderRaw(req.Header, "content-type", "application/json") + setHeaderRaw(req.Header, "anthropic-version", "2023-06-01") + setHeaderRaw(req.Header, "anthropic-beta", claude.HaikuBetaHeader) + setHeaderRaw(req.Header, "Accept", "application/json") + setHeaderRaw(req.Header, "X-Claude-Code-Session-Id", sessionID) + + // 应用指纹头(User-Agent, X-Stainless-*) + s.identityService.ApplyFingerprint(req, fp) + + // 补充 DefaultHeaders 中 ApplyFingerprint 未覆盖的静态头 + for key, value := range claude.DefaultHeaders { + if getHeaderRaw(req.Header, key) == "" && value != "" { + setHeaderRaw(req.Header, resolveWireCasing(key), value) + } + } + + resp, err := s.httpUpstream.Do(req, "", account.ID, account.Concurrency) + if err != nil { + logger.LegacyPrintf("service.gateway", "Startup probe: request failed for account %d: %v", account.ID, err) + return + } + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + + logger.LegacyPrintf("service.gateway", "Startup probe: completed for account %d, status=%d, device_id=%s", + account.ID, resp.StatusCode, fp.ClientID[:min(16, len(fp.ClientID))]+"...") +} + func truncateForLog(b []byte, maxBytes int) string { if maxBytes <= 0 { maxBytes = 2048 @@ -8459,7 +8528,7 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con } var ctFingerprint *Fingerprint if account.IsOAuth() && s.identityService != nil { - fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders) + fp, _, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders, account) if err == nil { ctFingerprint = fp if !ctEnableMPT { diff --git a/backend/internal/service/identity_service.go b/backend/internal/service/identity_service.go index 3d7065083e..c398133c35 100644 --- a/backend/internal/service/identity_service.go +++ b/backend/internal/service/identity_service.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -24,15 +25,10 @@ var ( userAgentVersionRegex = regexp.MustCompile(`/(\d+)\.(\d+)\.(\d+)`) ) -// 默认指纹值(当客户端未提供时使用) -var defaultFingerprint = Fingerprint{ - UserAgent: "claude-cli/2.1.22 (external, cli)", - StainlessLang: "js", - StainlessPackageVersion: "0.70.0", - StainlessOS: "Linux", - StainlessArch: "arm64", - StainlessRuntime: "node", - StainlessRuntimeVersion: "v24.13.0", +// AccountExtraUpdater 用于将 device_id 和指纹 profile 持久化到 Account.Extra。 +// 由 AccountRepository 实现。 +type AccountExtraUpdater interface { + UpdateExtra(ctx context.Context, accountID int64, updates map[string]any) error } // Fingerprint represents account fingerprint data @@ -63,33 +59,38 @@ type IdentityCache interface { // IdentityService 管理OAuth账号的请求身份指纹 type IdentityService struct { - cache IdentityCache + cache IdentityCache + extraUpdater AccountExtraUpdater } // NewIdentityService 创建新的IdentityService -func NewIdentityService(cache IdentityCache) *IdentityService { - return &IdentityService{cache: cache} +func NewIdentityService(cache IdentityCache, extraUpdater AccountExtraUpdater) *IdentityService { + return &IdentityService{cache: cache, extraUpdater: extraUpdater} } -// GetOrCreateFingerprint 获取或创建账号的指纹 -// 如果缓存存在,检测user-agent版本,新版本则更新 -// 如果缓存不存在,生成随机ClientID并从请求头创建指纹,然后缓存 -func (s *IdentityService) GetOrCreateFingerprint(ctx context.Context, accountID int64, headers http.Header) (*Fingerprint, error) { - // 尝试从缓存获取指纹 - cached, err := s.cache.GetFingerprint(ctx, accountID) - if err == nil && cached != nil { +// GetOrCreateFingerprint 获取或创建账号的指纹。 +// +// 优先级: +// 1. Redis 缓存(热路径) +// 2. Account.Extra["device_id"](持久化的 device_id) +// 3. 全新生成(并持久化到 DB + Redis) +// +// account 参数用于读取 Extra 中持久化的 device_id 和 fingerprint_profile_index。 +// 如果 account 为 nil,行为与旧版本一致(仅依赖 Redis 缓存)。 +// +// 返回值 isNewDevice 为 true 表示 device_id 是首次生成的,调用方应考虑执行启动探测。 +func (s *IdentityService) GetOrCreateFingerprint(ctx context.Context, accountID int64, headers http.Header, account *Account) (fp *Fingerprint, isNewDevice bool, err error) { + // 1. 尝试从 Redis 缓存获取 + cached, cacheErr := s.cache.GetFingerprint(ctx, accountID) + if cacheErr == nil && cached != nil { needWrite := false - // 检查客户端的user-agent是否是更新版本 clientUA := headers.Get("User-Agent") if clientUA != "" && isNewerVersion(clientUA, cached.UserAgent) { - // 版本升级:merge 语义 — 仅更新请求中实际携带的字段,保留缓存值 - // 避免缺失的头被硬编码默认值覆盖(如新 CLI 版本 + 旧 SDK 默认值的不一致) mergeHeadersIntoFingerprint(cached, headers) needWrite = true logger.LegacyPrintf("service.identity", "Updated fingerprint for account %d: %s (merge update)", accountID, clientUA) } else if time.Since(time.Unix(cached.UpdatedAt, 0)) > 24*time.Hour { - // 距上次写入超过24小时,续期TTL needWrite = true } @@ -99,45 +100,94 @@ func (s *IdentityService) GetOrCreateFingerprint(ctx context.Context, accountID logger.LegacyPrintf("service.identity", "Warning: failed to refresh fingerprint for account %d: %v", accountID, err) } } - return cached, nil + return cached, false, nil + } + + // 2. Redis 缓存 miss — 尝试从 Account.Extra 恢复持久化的 device_id + var persistedDeviceID string + var profileIndex int = -1 + if account != nil { + persistedDeviceID = account.GetExtraString("device_id") + if v, ok := account.Extra["fingerprint_profile_index"]; ok { + switch n := v.(type) { + case float64: + profileIndex = int(n) + case int: + profileIndex = n + case int64: + profileIndex = int(n) + } + } } - // 缓存不存在或解析失败,创建新指纹 - fp := s.createFingerprintFromHeaders(headers) + // 3. 创建指纹 + newFP, selectedProfileIndex := s.createFingerprintFromHeaders(headers, accountID, profileIndex) - // 生成随机ClientID - fp.ClientID = generateClientID() - fp.UpdatedAt = time.Now().Unix() + if persistedDeviceID != "" { + newFP.ClientID = persistedDeviceID + isNewDevice = false + logger.LegacyPrintf("service.identity", "Restored device_id from DB for account %d: %s", accountID, persistedDeviceID[:min(16, len(persistedDeviceID))]+"...") + } else { + newFP.ClientID = generateClientID() + isNewDevice = true + logger.LegacyPrintf("service.identity", "Generated new device_id for account %d: %s", accountID, newFP.ClientID[:min(16, len(newFP.ClientID))]+"...") - // 保存到缓存(7天TTL,每24小时自动续期) - if err := s.cache.SetFingerprint(ctx, accountID, fp); err != nil { + s.persistDeviceID(ctx, accountID, newFP.ClientID, selectedProfileIndex, account) + } + + newFP.UpdatedAt = time.Now().Unix() + + // 写回 Redis 缓存 + if err := s.cache.SetFingerprint(ctx, accountID, newFP); err != nil { logger.LegacyPrintf("service.identity", "Warning: failed to cache fingerprint for account %d: %v", accountID, err) } - logger.LegacyPrintf("service.identity", "Created new fingerprint for account %d with client_id: %s", accountID, fp.ClientID) - return fp, nil + return newFP, isNewDevice, nil } -// createFingerprintFromHeaders 从请求头创建指纹 -func (s *IdentityService) createFingerprintFromHeaders(headers http.Header) *Fingerprint { +// persistDeviceID 将 device_id 和 fingerprint_profile_index 持久化到 Account.Extra。 +// profileIndex 来自 SelectProfileForAccount 的结果,用于锁定 profile 选择以防模板池变化。 +func (s *IdentityService) persistDeviceID(ctx context.Context, accountID int64, deviceID string, profileIndex int, account *Account) { + if s.extraUpdater == nil { + return + } + + updates := map[string]any{ + "device_id": deviceID, + } + + if account == nil || account.Extra == nil || account.Extra["fingerprint_profile_index"] == nil { + updates["fingerprint_profile_index"] = profileIndex + } + + if err := s.extraUpdater.UpdateExtra(ctx, accountID, updates); err != nil { + logger.LegacyPrintf("service.identity", "Warning: failed to persist device_id for account %d: %v", accountID, err) + } +} + +// createFingerprintFromHeaders 从请求头创建指纹。 +// 当客户端 headers 缺失时(mimic 场景),使用基于 accountID 选择的多样化 profile。 +// profileIndex >= 0 时使用持久化的 profile 索引,否则由 hash(accountID) 确定。 +// createFingerprintFromHeaders 从请求头创建指纹,返回指纹和选中的 profile index。 +func (s *IdentityService) createFingerprintFromHeaders(headers http.Header, accountID int64, profileIndex int) (*Fingerprint, int) { + sel := claude.SelectProfileForAccount(accountID, profileIndex) + fp := &Fingerprint{} - // 获取User-Agent if ua := headers.Get("User-Agent"); ua != "" { fp.UserAgent = ua } else { - fp.UserAgent = defaultFingerprint.UserAgent + fp.UserAgent = sel.UserAgent } - // 获取x-stainless-*头,如果没有则使用默认值 - fp.StainlessLang = getHeaderOrDefault(headers, "X-Stainless-Lang", defaultFingerprint.StainlessLang) - fp.StainlessPackageVersion = getHeaderOrDefault(headers, "X-Stainless-Package-Version", defaultFingerprint.StainlessPackageVersion) - fp.StainlessOS = getHeaderOrDefault(headers, "X-Stainless-OS", defaultFingerprint.StainlessOS) - fp.StainlessArch = getHeaderOrDefault(headers, "X-Stainless-Arch", defaultFingerprint.StainlessArch) - fp.StainlessRuntime = getHeaderOrDefault(headers, "X-Stainless-Runtime", defaultFingerprint.StainlessRuntime) - fp.StainlessRuntimeVersion = getHeaderOrDefault(headers, "X-Stainless-Runtime-Version", defaultFingerprint.StainlessRuntimeVersion) + fp.StainlessLang = getHeaderOrDefault(headers, "X-Stainless-Lang", "js") + fp.StainlessPackageVersion = getHeaderOrDefault(headers, "X-Stainless-Package-Version", sel.PackageVersion) + fp.StainlessOS = getHeaderOrDefault(headers, "X-Stainless-OS", sel.Profile.OS) + fp.StainlessArch = getHeaderOrDefault(headers, "X-Stainless-Arch", sel.Profile.Arch) + fp.StainlessRuntime = getHeaderOrDefault(headers, "X-Stainless-Runtime", sel.Profile.Runtime) + fp.StainlessRuntimeVersion = getHeaderOrDefault(headers, "X-Stainless-Runtime-Version", sel.RuntimeVersion) - return fp + return fp, sel.ProfileIndex } // mergeHeadersIntoFingerprint 将请求头中实际存在的字段合并到现有指纹中(用于版本升级场景) @@ -440,3 +490,4 @@ func isNewerVersion(newUA, cachedUA string) bool { return newPatch > cachedPatch } + diff --git a/backend/internal/service/identity_service_order_test.go b/backend/internal/service/identity_service_order_test.go index d1e1227412..f4047395cf 100644 --- a/backend/internal/service/identity_service_order_test.go +++ b/backend/internal/service/identity_service_order_test.go @@ -28,7 +28,7 @@ func (s *identityCacheStub) SetMaskedSessionID(_ context.Context, _ int64, sessi func TestIdentityService_RewriteUserID_PreservesTopLevelFieldOrder(t *testing.T) { cache := &identityCacheStub{} - svc := NewIdentityService(cache) + svc := NewIdentityService(cache, nil) originalUserID := FormatMetadataUserID( "d61f76d0730d2b920763648949bad5c79742155c27037fc77ac3f9805cb90169", @@ -49,7 +49,7 @@ func TestIdentityService_RewriteUserID_PreservesTopLevelFieldOrder(t *testing.T) func TestIdentityService_RewriteUserIDWithMasking_PreservesTopLevelFieldOrder(t *testing.T) { cache := &identityCacheStub{maskedSessionID: "11111111-2222-4333-8444-555555555555"} - svc := NewIdentityService(cache) + svc := NewIdentityService(cache, nil) originalUserID := FormatMetadataUserID( "d61f76d0730d2b920763648949bad5c79742155c27037fc77ac3f9805cb90169", diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index d79a353124..d43dd44ed5 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -162,7 +162,11 @@ func ProvideTimingWheelService() (*TimingWheelService, error) { return svc, nil } -// ProvideDeferredService creates and starts DeferredService +// ProvideIdentityService creates an IdentityService with AccountRepository as the extra updater. +func ProvideIdentityService(cache IdentityCache, accountRepo AccountRepository) *IdentityService { + return NewIdentityService(cache, accountRepo) +} + func ProvideDeferredService(accountRepo AccountRepository, timingWheel *TimingWheelService) *DeferredService { svc := NewDeferredService(accountRepo, timingWheel, 10*time.Second) svc.Start() @@ -467,7 +471,7 @@ var ProviderSet = wire.NewSet( ProvideUserMessageQueueService, NewUsageRecordWorkerPool, ProvideSchedulerSnapshotService, - NewIdentityService, + ProvideIdentityService, NewCRSSyncService, ProvideUpdateService, ProvideTokenRefreshService, From f92ca8ca3f5fa97e1fe0c24dc2b4dfa64b4ea3da Mon Sep 17 00:00:00 2001 From: haruka <1628615876@qq.com> Date: Wed, 1 Apr 2026 00:21:00 +0800 Subject: [PATCH 2/2] feat(identity): realistic fingerprint management and device ID persistence Replace the single hardcoded fingerprint (Linux arm64, cli/2.1.22) with a pool of 5 realistic profiles (macOS arm64/x64, Linux x64/arm64, Windows x64) selected deterministically per account via SHA256(accountID). Device IDs are now persisted to Account.Extra in the database instead of relying solely on Redis 7-day TTL, preventing identity changes on cache flush. Admins can also import real device_id values from actual Claude Code installs. On first device_id generation, an async startup probe (max_tokens=1 + haiku) is sent to Anthropic to simulate Claude Code's connectivity check, associating the device_id with the OAuth token on the server side. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/cmd/server/wire_gen.go | 2 +- backend/internal/pkg/claude/constants.go | 114 +++++++++++++- backend/internal/pkg/claude/constants_test.go | 78 ++++++++++ backend/internal/service/gateway_service.go | 77 +++++++++- backend/internal/service/identity_service.go | 140 ++++++++++++------ .../service/identity_service_order_test.go | 4 +- backend/internal/service/wire.go | 8 +- 7 files changed, 365 insertions(+), 58 deletions(-) create mode 100644 backend/internal/pkg/claude/constants_test.go diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index ce898a4a90..5a8cb7e3e4 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -171,7 +171,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { return nil, err } billingService := service.NewBillingService(configConfig, pricingService) - identityService := service.NewIdentityService(identityCache) + identityService := service.ProvideIdentityService(identityCache, accountRepository) deferredService := service.ProvideDeferredService(accountRepository, timingWheelService) claudeTokenProvider := service.ProvideClaudeTokenProvider(accountRepository, geminiTokenCache, oAuthService, oauthRefreshAPI) digestSessionStore := service.NewDigestSessionStore() diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go index dfca252f48..d4fd49b4c9 100644 --- a/backend/internal/pkg/claude/constants.go +++ b/backend/internal/pkg/claude/constants.go @@ -1,6 +1,12 @@ // Package claude provides constants and helpers for Claude API integration. package claude +import ( + "crypto/sha256" + "encoding/binary" + "fmt" +) + // Claude Code 客户端相关常量 // Beta header 常量 @@ -44,23 +50,123 @@ const APIKeyBetaHeader = BetaClaudeCode + "," + BetaInterleavedThinking + "," + // APIKeyHaikuBetaHeader Haiku 模型在 API-key 账号下使用的 anthropic-beta header(不包含 oauth / claude-code) const APIKeyHaikuBetaHeader = BetaInterleavedThinking +// DefaultCLIVersion 与 header_util.go 抓包来源(claude-cli/2.1.81)保持一致 +const DefaultCLIVersion = "2.1.81" + // DefaultHeaders 是 Claude Code 客户端默认请求头。 +// 注意:当启用了指纹系统时,这些值会被 ApplyFingerprint 覆盖。 +// 此处作为最后的 fallback,使用最新版本。 var DefaultHeaders = map[string]string{ // Keep these in sync with recent Claude CLI traffic to reduce the chance // that Claude Code-scoped OAuth credentials are rejected as "non-CLI" usage. - "User-Agent": "claude-cli/2.1.22 (external, cli)", + "User-Agent": "claude-cli/" + DefaultCLIVersion + " (external, cli)", "X-Stainless-Lang": "js", - "X-Stainless-Package-Version": "0.70.0", - "X-Stainless-OS": "Linux", + "X-Stainless-Package-Version": "0.72.1", + "X-Stainless-OS": "MacOS", "X-Stainless-Arch": "arm64", "X-Stainless-Runtime": "node", - "X-Stainless-Runtime-Version": "v24.13.0", + "X-Stainless-Runtime-Version": "v22.16.0", "X-Stainless-Retry-Count": "0", "X-Stainless-Timeout": "600", "X-App": "cli", "Anthropic-Dangerous-Direct-Browser-Access": "true", } +// FingerprintProfile 真实 Claude Code 客户端的指纹模板。 +// 基于对真实 Claude CLI 流量的观察,覆盖常见的 OS/Arch/Runtime 组合。 +type FingerprintProfile struct { + OS string // X-Stainless-OS: "MacOS", "Linux", "Windows_NT" + Arch string // X-Stainless-Arch: "arm64", "x64" + Runtime string // X-Stainless-Runtime: "node" + RuntimeVersions []string // 该平台常见的 node 版本 + PackageVersions []string // @anthropic-ai/sdk 版本 + CLIVersions []string // claude-cli 版本 +} + +// RealisticProfiles 基于真实 Claude Code 用户环境的指纹模板池。 +// 权重按真实用户分布:macOS arm64 最多,其次 Linux x64,再次 macOS x64 等。 +var RealisticProfiles = []FingerprintProfile{ + { + // macOS Apple Silicon — 最常见的 Claude Code 用户环境 + OS: "MacOS", Arch: "arm64", Runtime: "node", + RuntimeVersions: []string{"v22.11.0", "v22.16.0", "v24.13.0"}, + PackageVersions: []string{"0.70.0", "0.72.1", "0.68.2"}, + CLIVersions: []string{"2.1.81", "2.1.78", "2.2.0"}, + }, + { + // macOS Intel — 老款 Mac 用户 + OS: "MacOS", Arch: "x64", Runtime: "node", + RuntimeVersions: []string{"v22.11.0", "v22.16.0"}, + PackageVersions: []string{"0.70.0", "0.72.1"}, + CLIVersions: []string{"2.1.81", "2.1.78"}, + }, + { + // Linux x64 — 服务器/WSL/开发者 + OS: "Linux", Arch: "x64", Runtime: "node", + RuntimeVersions: []string{"v22.11.0", "v22.16.0", "v24.13.0"}, + PackageVersions: []string{"0.70.0", "0.72.1", "0.68.2"}, + CLIVersions: []string{"2.1.81", "2.1.78", "2.2.0"}, + }, + { + // Linux arm64 — ARM 服务器/Raspberry Pi/Codespaces + OS: "Linux", Arch: "arm64", Runtime: "node", + RuntimeVersions: []string{"v22.16.0", "v24.13.0"}, + PackageVersions: []string{"0.70.0", "0.72.1"}, + CLIVersions: []string{"2.1.81", "2.1.78"}, + }, + { + // Windows x64 — Windows 用户 + OS: "Windows_NT", Arch: "x64", Runtime: "node", + RuntimeVersions: []string{"v22.11.0", "v22.16.0"}, + PackageVersions: []string{"0.70.0", "0.72.1"}, + CLIVersions: []string{"2.1.81", "2.1.78"}, + }, +} + +// SelectedFingerprint 从 SelectProfileForAccount 返回的完整指纹选择结果。 +type SelectedFingerprint struct { + ProfileIndex int // 在 RealisticProfiles 中的索引,用于持久化 + Profile FingerprintProfile + CLIVersion string + PackageVersion string + RuntimeVersion string + UserAgent string // 完整的 User-Agent 字符串 +} + +// SelectProfileForAccount 基于 accountID 确定性地选择一个完整的指纹组合。 +// 同一个 accountID 永远返回相同的结果。如果提供了 lockedIndex >= 0, +// 则使用该索引选择 profile(用于已持久化的账号)。 +func SelectProfileForAccount(accountID int64, lockedIndex int) SelectedFingerprint { + h := sha256.Sum256([]byte(fmt.Sprintf("fingerprint:%d", accountID))) + seed := binary.BigEndian.Uint64(h[:8]) + + // 选择 profile + profileIdx := int(seed % uint64(len(RealisticProfiles))) + if lockedIndex >= 0 && lockedIndex < len(RealisticProfiles) { + profileIdx = lockedIndex + } + profile := RealisticProfiles[profileIdx] + + // 确定性选择版本组合(使用不同的 hash 位段避免关联) + seed2 := binary.BigEndian.Uint64(h[8:16]) + cliVersion := profile.CLIVersions[int(seed2%uint64(len(profile.CLIVersions)))] + + seed3 := binary.BigEndian.Uint64(h[16:24]) + pkgVersion := profile.PackageVersions[int(seed3%uint64(len(profile.PackageVersions)))] + + seed4 := binary.BigEndian.Uint64(h[24:32]) + rtVersion := profile.RuntimeVersions[int(seed4%uint64(len(profile.RuntimeVersions)))] + + return SelectedFingerprint{ + ProfileIndex: profileIdx, + Profile: profile, + CLIVersion: cliVersion, + PackageVersion: pkgVersion, + RuntimeVersion: rtVersion, + UserAgent: fmt.Sprintf("claude-cli/%s (external, cli)", cliVersion), + } +} + // Model 表示一个 Claude 模型 type Model struct { ID string `json:"id"` diff --git a/backend/internal/pkg/claude/constants_test.go b/backend/internal/pkg/claude/constants_test.go new file mode 100644 index 0000000000..d0757c8218 --- /dev/null +++ b/backend/internal/pkg/claude/constants_test.go @@ -0,0 +1,78 @@ +package claude + +import ( + "testing" +) + +func TestSelectProfileForAccount_Deterministic(t *testing.T) { + // Same accountID always produces same result + r1 := SelectProfileForAccount(42, -1) + r2 := SelectProfileForAccount(42, -1) + + if r1.UserAgent != r2.UserAgent { + t.Errorf("UserAgent not deterministic: %q != %q", r1.UserAgent, r2.UserAgent) + } + if r1.Profile.OS != r2.Profile.OS { + t.Errorf("OS not deterministic: %q != %q", r1.Profile.OS, r2.Profile.OS) + } + if r1.CLIVersion != r2.CLIVersion { + t.Errorf("CLIVersion not deterministic: %q != %q", r1.CLIVersion, r2.CLIVersion) + } + if r1.PackageVersion != r2.PackageVersion { + t.Errorf("PackageVersion not deterministic: %q != %q", r1.PackageVersion, r2.PackageVersion) + } + if r1.RuntimeVersion != r2.RuntimeVersion { + t.Errorf("RuntimeVersion not deterministic: %q != %q", r1.RuntimeVersion, r2.RuntimeVersion) + } +} + +func TestSelectProfileForAccount_Diverse(t *testing.T) { + // Different accountIDs should produce at least 2 different OS values across 100 accounts + osSet := make(map[string]struct{}) + for i := int64(1); i <= 100; i++ { + sel := SelectProfileForAccount(i, -1) + osSet[sel.Profile.OS] = struct{}{} + } + + if len(osSet) < 2 { + t.Errorf("expected at least 2 different OS values across 100 accounts, got %d: %v", len(osSet), osSet) + } +} + +func TestSelectProfileForAccount_LockedIndex(t *testing.T) { + // When lockedIndex is provided, profile selection should use that index + sel0 := SelectProfileForAccount(999, 0) + sel4 := SelectProfileForAccount(999, 4) + + if sel0.Profile.OS != RealisticProfiles[0].OS { + t.Errorf("lockedIndex=0 should use profile[0], got OS=%q, want %q", sel0.Profile.OS, RealisticProfiles[0].OS) + } + if sel4.Profile.OS != RealisticProfiles[4].OS { + t.Errorf("lockedIndex=4 should use profile[4], got OS=%q, want %q", sel4.Profile.OS, RealisticProfiles[4].OS) + } +} + +func TestSelectProfileForAccount_ValidOutput(t *testing.T) { + for i := int64(0); i < 50; i++ { + sel := SelectProfileForAccount(i, -1) + + if sel.UserAgent == "" { + t.Errorf("account %d: empty UserAgent", i) + } + if sel.CLIVersion == "" { + t.Errorf("account %d: empty CLIVersion", i) + } + if sel.PackageVersion == "" { + t.Errorf("account %d: empty PackageVersion", i) + } + if sel.RuntimeVersion == "" { + t.Errorf("account %d: empty RuntimeVersion", i) + } + if sel.Profile.OS == "" { + t.Errorf("account %d: empty OS", i) + } + if sel.Profile.Arch == "" { + t.Errorf("account %d: empty Arch", i) + } + } +} diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 94e04d286d..017a923b64 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -4166,7 +4166,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: true} if s.identityService != nil { - fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header) + fp, _, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header, account) if err == nil && fp != nil { // metadata 透传开启时跳过 metadata 注入 _, mimicMPT := s.settingService.GetGatewayForwardingSettings(ctx) @@ -5720,8 +5720,8 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex enableFP, enableMPT = s.settingService.GetGatewayForwardingSettings(ctx) } if account.IsOAuth() && s.identityService != nil { - // 1. 获取或创建指纹(包含随机生成的ClientID) - fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders) + // 1. 获取或创建指纹(包含持久化的 device_id 和多样化 profile) + fp, isNewDevice, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders, account) if err != nil { logger.LegacyPrintf("service.gateway", "Warning: failed to get fingerprint for account %d: %v", account.ID, err) // 失败时降级为透传原始headers @@ -5741,6 +5741,11 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex } } } + + // 3. 首次 device_id 生成时,异步发送 Claude Code 启动探测 + if isNewDevice { + go s.simulateStartupProbe(context.Background(), account, fp) + } } } @@ -6244,6 +6249,70 @@ func applyClaudeCodeMimicHeaders(req *http.Request, isStream bool) { } } +// simulateStartupProbe 模拟 Claude Code 启动时的连通性探测请求。 +// 真实 Claude Code 在启动时发送 max_tokens=1 + haiku 的非流式请求来验证 API 连接。 +// 此方法在新 device_id 首次生成后异步调用,让 Anthropic 服务端关联 device_id 与 OAuth token。 +func (s *GatewayService) simulateStartupProbe(ctx context.Context, account *Account, fp *Fingerprint) { + if account == nil || fp == nil { + return + } + + token, tokenType, err := s.GetAccessToken(ctx, account) + if err != nil { + logger.LegacyPrintf("service.gateway", "Startup probe: failed to get token for account %d: %v", account.ID, err) + return + } + + accountUUID := account.GetExtraString("account_uuid") + sessionID := generateRandomUUID() + version := ExtractCLIVersion(fp.UserAgent) + metadataUserID := FormatMetadataUserID(fp.ClientID, accountUUID, sessionID, version) + + probeBody := fmt.Sprintf(`{"model":"claude-haiku-4-5-20251001","max_tokens":1,"messages":[{"role":"user","content":"hi"}],"metadata":{"user_id":%q}}`, metadataUserID) + + probeURL := "https://api.anthropic.com/v1/messages?beta=true" + req, err := http.NewRequestWithContext(ctx, "POST", probeURL, strings.NewReader(probeBody)) + if err != nil { + logger.LegacyPrintf("service.gateway", "Startup probe: failed to create request for account %d: %v", account.ID, err) + return + } + + // 认证头 + if tokenType == "oauth" { + setHeaderRaw(req.Header, "authorization", "Bearer "+token) + } else { + setHeaderRaw(req.Header, "x-api-key", token) + } + + // 基础头 + setHeaderRaw(req.Header, "content-type", "application/json") + setHeaderRaw(req.Header, "anthropic-version", "2023-06-01") + setHeaderRaw(req.Header, "anthropic-beta", claude.HaikuBetaHeader) + setHeaderRaw(req.Header, "Accept", "application/json") + setHeaderRaw(req.Header, "X-Claude-Code-Session-Id", sessionID) + + // 应用指纹头(User-Agent, X-Stainless-*) + s.identityService.ApplyFingerprint(req, fp) + + // 补充 DefaultHeaders 中 ApplyFingerprint 未覆盖的静态头 + for key, value := range claude.DefaultHeaders { + if getHeaderRaw(req.Header, key) == "" && value != "" { + setHeaderRaw(req.Header, resolveWireCasing(key), value) + } + } + + resp, err := s.httpUpstream.Do(req, "", account.ID, account.Concurrency) + if err != nil { + logger.LegacyPrintf("service.gateway", "Startup probe: request failed for account %d: %v", account.ID, err) + return + } + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + + logger.LegacyPrintf("service.gateway", "Startup probe: completed for account %d, status=%d, device_id=%s", + account.ID, resp.StatusCode, fp.ClientID[:min(16, len(fp.ClientID))]+"...") +} + func truncateForLog(b []byte, maxBytes int) string { if maxBytes <= 0 { maxBytes = 2048 @@ -8459,7 +8528,7 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con } var ctFingerprint *Fingerprint if account.IsOAuth() && s.identityService != nil { - fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders) + fp, _, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders, account) if err == nil { ctFingerprint = fp if !ctEnableMPT { diff --git a/backend/internal/service/identity_service.go b/backend/internal/service/identity_service.go index 3d7065083e..71811925eb 100644 --- a/backend/internal/service/identity_service.go +++ b/backend/internal/service/identity_service.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -24,15 +25,10 @@ var ( userAgentVersionRegex = regexp.MustCompile(`/(\d+)\.(\d+)\.(\d+)`) ) -// 默认指纹值(当客户端未提供时使用) -var defaultFingerprint = Fingerprint{ - UserAgent: "claude-cli/2.1.22 (external, cli)", - StainlessLang: "js", - StainlessPackageVersion: "0.70.0", - StainlessOS: "Linux", - StainlessArch: "arm64", - StainlessRuntime: "node", - StainlessRuntimeVersion: "v24.13.0", +// AccountExtraUpdater 用于将 device_id 和指纹 profile 持久化到 Account.Extra。 +// 由 AccountRepository 实现。 +type AccountExtraUpdater interface { + UpdateExtra(ctx context.Context, accountID int64, updates map[string]any) error } // Fingerprint represents account fingerprint data @@ -63,33 +59,38 @@ type IdentityCache interface { // IdentityService 管理OAuth账号的请求身份指纹 type IdentityService struct { - cache IdentityCache + cache IdentityCache + extraUpdater AccountExtraUpdater } // NewIdentityService 创建新的IdentityService -func NewIdentityService(cache IdentityCache) *IdentityService { - return &IdentityService{cache: cache} +func NewIdentityService(cache IdentityCache, extraUpdater AccountExtraUpdater) *IdentityService { + return &IdentityService{cache: cache, extraUpdater: extraUpdater} } -// GetOrCreateFingerprint 获取或创建账号的指纹 -// 如果缓存存在,检测user-agent版本,新版本则更新 -// 如果缓存不存在,生成随机ClientID并从请求头创建指纹,然后缓存 -func (s *IdentityService) GetOrCreateFingerprint(ctx context.Context, accountID int64, headers http.Header) (*Fingerprint, error) { - // 尝试从缓存获取指纹 - cached, err := s.cache.GetFingerprint(ctx, accountID) - if err == nil && cached != nil { +// GetOrCreateFingerprint 获取或创建账号的指纹。 +// +// 优先级: +// 1. Redis 缓存(热路径) +// 2. Account.Extra["device_id"](持久化的 device_id) +// 3. 全新生成(并持久化到 DB + Redis) +// +// account 参数用于读取 Extra 中持久化的 device_id 和 fingerprint_profile_index。 +// 如果 account 为 nil,行为与旧版本一致(仅依赖 Redis 缓存)。 +// +// 返回值 isNewDevice 为 true 表示 device_id 是首次生成的,调用方应考虑执行启动探测。 +func (s *IdentityService) GetOrCreateFingerprint(ctx context.Context, accountID int64, headers http.Header, account *Account) (fp *Fingerprint, isNewDevice bool, err error) { + // 1. 尝试从 Redis 缓存获取 + cached, cacheErr := s.cache.GetFingerprint(ctx, accountID) + if cacheErr == nil && cached != nil { needWrite := false - // 检查客户端的user-agent是否是更新版本 clientUA := headers.Get("User-Agent") if clientUA != "" && isNewerVersion(clientUA, cached.UserAgent) { - // 版本升级:merge 语义 — 仅更新请求中实际携带的字段,保留缓存值 - // 避免缺失的头被硬编码默认值覆盖(如新 CLI 版本 + 旧 SDK 默认值的不一致) mergeHeadersIntoFingerprint(cached, headers) needWrite = true logger.LegacyPrintf("service.identity", "Updated fingerprint for account %d: %s (merge update)", accountID, clientUA) } else if time.Since(time.Unix(cached.UpdatedAt, 0)) > 24*time.Hour { - // 距上次写入超过24小时,续期TTL needWrite = true } @@ -99,45 +100,94 @@ func (s *IdentityService) GetOrCreateFingerprint(ctx context.Context, accountID logger.LegacyPrintf("service.identity", "Warning: failed to refresh fingerprint for account %d: %v", accountID, err) } } - return cached, nil + return cached, false, nil + } + + // 2. Redis 缓存 miss — 尝试从 Account.Extra 恢复持久化的 device_id + var persistedDeviceID string + profileIndex := -1 + if account != nil { + persistedDeviceID = account.GetExtraString("device_id") + if v, ok := account.Extra["fingerprint_profile_index"]; ok { + switch n := v.(type) { + case float64: + profileIndex = int(n) + case int: + profileIndex = n + case int64: + profileIndex = int(n) + } + } } - // 缓存不存在或解析失败,创建新指纹 - fp := s.createFingerprintFromHeaders(headers) + // 3. 创建指纹 + newFP, selectedProfileIndex := s.createFingerprintFromHeaders(headers, accountID, profileIndex) - // 生成随机ClientID - fp.ClientID = generateClientID() - fp.UpdatedAt = time.Now().Unix() + if persistedDeviceID != "" { + newFP.ClientID = persistedDeviceID + isNewDevice = false + logger.LegacyPrintf("service.identity", "Restored device_id from DB for account %d: %s", accountID, persistedDeviceID[:min(16, len(persistedDeviceID))]+"...") + } else { + newFP.ClientID = generateClientID() + isNewDevice = true + logger.LegacyPrintf("service.identity", "Generated new device_id for account %d: %s", accountID, newFP.ClientID[:min(16, len(newFP.ClientID))]+"...") + + s.persistDeviceID(ctx, accountID, newFP.ClientID, selectedProfileIndex, account) + } - // 保存到缓存(7天TTL,每24小时自动续期) - if err := s.cache.SetFingerprint(ctx, accountID, fp); err != nil { + newFP.UpdatedAt = time.Now().Unix() + + // 写回 Redis 缓存 + if err := s.cache.SetFingerprint(ctx, accountID, newFP); err != nil { logger.LegacyPrintf("service.identity", "Warning: failed to cache fingerprint for account %d: %v", accountID, err) } - logger.LegacyPrintf("service.identity", "Created new fingerprint for account %d with client_id: %s", accountID, fp.ClientID) - return fp, nil + return newFP, isNewDevice, nil +} + +// persistDeviceID 将 device_id 和 fingerprint_profile_index 持久化到 Account.Extra。 +// profileIndex 来自 SelectProfileForAccount 的结果,用于锁定 profile 选择以防模板池变化。 +func (s *IdentityService) persistDeviceID(ctx context.Context, accountID int64, deviceID string, profileIndex int, account *Account) { + if s.extraUpdater == nil { + return + } + + updates := map[string]any{ + "device_id": deviceID, + } + + if account == nil || account.Extra == nil || account.Extra["fingerprint_profile_index"] == nil { + updates["fingerprint_profile_index"] = profileIndex + } + + if err := s.extraUpdater.UpdateExtra(ctx, accountID, updates); err != nil { + logger.LegacyPrintf("service.identity", "Warning: failed to persist device_id for account %d: %v", accountID, err) + } } -// createFingerprintFromHeaders 从请求头创建指纹 -func (s *IdentityService) createFingerprintFromHeaders(headers http.Header) *Fingerprint { +// createFingerprintFromHeaders 从请求头创建指纹。 +// 当客户端 headers 缺失时(mimic 场景),使用基于 accountID 选择的多样化 profile。 +// profileIndex >= 0 时使用持久化的 profile 索引,否则由 hash(accountID) 确定。 +// createFingerprintFromHeaders 从请求头创建指纹,返回指纹和选中的 profile index。 +func (s *IdentityService) createFingerprintFromHeaders(headers http.Header, accountID int64, profileIndex int) (*Fingerprint, int) { + sel := claude.SelectProfileForAccount(accountID, profileIndex) + fp := &Fingerprint{} - // 获取User-Agent if ua := headers.Get("User-Agent"); ua != "" { fp.UserAgent = ua } else { - fp.UserAgent = defaultFingerprint.UserAgent + fp.UserAgent = sel.UserAgent } - // 获取x-stainless-*头,如果没有则使用默认值 - fp.StainlessLang = getHeaderOrDefault(headers, "X-Stainless-Lang", defaultFingerprint.StainlessLang) - fp.StainlessPackageVersion = getHeaderOrDefault(headers, "X-Stainless-Package-Version", defaultFingerprint.StainlessPackageVersion) - fp.StainlessOS = getHeaderOrDefault(headers, "X-Stainless-OS", defaultFingerprint.StainlessOS) - fp.StainlessArch = getHeaderOrDefault(headers, "X-Stainless-Arch", defaultFingerprint.StainlessArch) - fp.StainlessRuntime = getHeaderOrDefault(headers, "X-Stainless-Runtime", defaultFingerprint.StainlessRuntime) - fp.StainlessRuntimeVersion = getHeaderOrDefault(headers, "X-Stainless-Runtime-Version", defaultFingerprint.StainlessRuntimeVersion) + fp.StainlessLang = getHeaderOrDefault(headers, "X-Stainless-Lang", "js") + fp.StainlessPackageVersion = getHeaderOrDefault(headers, "X-Stainless-Package-Version", sel.PackageVersion) + fp.StainlessOS = getHeaderOrDefault(headers, "X-Stainless-OS", sel.Profile.OS) + fp.StainlessArch = getHeaderOrDefault(headers, "X-Stainless-Arch", sel.Profile.Arch) + fp.StainlessRuntime = getHeaderOrDefault(headers, "X-Stainless-Runtime", sel.Profile.Runtime) + fp.StainlessRuntimeVersion = getHeaderOrDefault(headers, "X-Stainless-Runtime-Version", sel.RuntimeVersion) - return fp + return fp, sel.ProfileIndex } // mergeHeadersIntoFingerprint 将请求头中实际存在的字段合并到现有指纹中(用于版本升级场景) diff --git a/backend/internal/service/identity_service_order_test.go b/backend/internal/service/identity_service_order_test.go index d1e1227412..f4047395cf 100644 --- a/backend/internal/service/identity_service_order_test.go +++ b/backend/internal/service/identity_service_order_test.go @@ -28,7 +28,7 @@ func (s *identityCacheStub) SetMaskedSessionID(_ context.Context, _ int64, sessi func TestIdentityService_RewriteUserID_PreservesTopLevelFieldOrder(t *testing.T) { cache := &identityCacheStub{} - svc := NewIdentityService(cache) + svc := NewIdentityService(cache, nil) originalUserID := FormatMetadataUserID( "d61f76d0730d2b920763648949bad5c79742155c27037fc77ac3f9805cb90169", @@ -49,7 +49,7 @@ func TestIdentityService_RewriteUserID_PreservesTopLevelFieldOrder(t *testing.T) func TestIdentityService_RewriteUserIDWithMasking_PreservesTopLevelFieldOrder(t *testing.T) { cache := &identityCacheStub{maskedSessionID: "11111111-2222-4333-8444-555555555555"} - svc := NewIdentityService(cache) + svc := NewIdentityService(cache, nil) originalUserID := FormatMetadataUserID( "d61f76d0730d2b920763648949bad5c79742155c27037fc77ac3f9805cb90169", diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index d79a353124..d43dd44ed5 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -162,7 +162,11 @@ func ProvideTimingWheelService() (*TimingWheelService, error) { return svc, nil } -// ProvideDeferredService creates and starts DeferredService +// ProvideIdentityService creates an IdentityService with AccountRepository as the extra updater. +func ProvideIdentityService(cache IdentityCache, accountRepo AccountRepository) *IdentityService { + return NewIdentityService(cache, accountRepo) +} + func ProvideDeferredService(accountRepo AccountRepository, timingWheel *TimingWheelService) *DeferredService { svc := NewDeferredService(accountRepo, timingWheel, 10*time.Second) svc.Start() @@ -467,7 +471,7 @@ var ProviderSet = wire.NewSet( ProvideUserMessageQueueService, NewUsageRecordWorkerPool, ProvideSchedulerSnapshotService, - NewIdentityService, + ProvideIdentityService, NewCRSSyncService, ProvideUpdateService, ProvideTokenRefreshService,