From 3cb2381bdc9fafcc895f331212fd25fb74847983 Mon Sep 17 00:00:00 2001 From: alfadb Date: Wed, 2 Sep 2026 10:25:23 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(claude):=20=E8=AE=A9=E4=BC=AA=E8=A3=85?= =?UTF-8?q?=E7=9A=84=20Claude=20Code=20=E7=89=88=E6=9C=AC=E7=9C=9F?= =?UTF-8?q?=E6=AD=A3=E7=94=9F=E6=95=88=EF=BC=8C=E4=BF=AE=E5=A4=8D=20Fable?= =?UTF-8?q?=205.1=20=E4=BB=8D=E7=84=B6=20400?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claude-fable-5-1 已登记进 DefaultModels,但 OAuth 账号实际调用仍然全部失败: 400 claude_code_version_too_old "Claude Code 2.1.220 does not support this model; version 2.1.251 or newer is required." 两个独立的原因,缺一不可: 1. CLICurrentVersion 停留在 2.1.220,低于上游对该模型的客户端版本闸门。 升到 2.1.258(当前官方 CLI 最新版,npm @anthropic-ai/claude-code)。 2. 只升常量对所有已有账号无效。上游请求实际发送的 User-Agent 来自账号级 持久指纹(IdentityService,Redis fingerprint:),不是 DefaultHeaders;defaultFingerprint 只在首次创建指纹时使用。 GetOrCreateFingerprint 的缓存命中路径只有两条改写分支:UA 畸形时自愈, 或客户端送来更新版本时升级。指纹 7 天 TTL 但活跃账号每 24h 续期,实际 近乎永不过期,系统内也没有重置入口——客户端版本比缓存旧时(例如缓存 2.1.220、客户端 2.1.75),存量账号会永远对上游声称 2.1.220。 生产实测:升级 CLICurrentVersion 并部署后仍报同样的 400,读 Redis 确认 出问题账号的缓存值为 "claude-cli/2.1.220 (external, cli)"。 修复:新增 floorClaudeCLIUserAgentVersion,把 CLICurrentVersion 作为缓存 指纹里 claude-cli 版本的下限,缓存命中与首次创建两条路径共用。 - 只对 claude-cli 产品生效,其它客户端一律不动 - 只升不降:版本等于或高于下限(含客户端上报的更新版本)不做任何改动 - 只替换版本号段,UA 其余部分原样保留,例如 "(external, claude-desktop-3p, agent-sdk/0.3.100)" 不受影响 - 与既有的"客户端更新版本"升级分支相互独立、二者取更新者 - X-Stainless-* 维持既有 merge 语义,不被下限逻辑覆盖 cc_version 无需单独处理:billing attribution block 的版本已由 syncBillingHeaderVersion 跟随实际发送的 User-Agent 同步。 测试:identity_service_version_floor_test.go 覆盖 helper 的 8 种 UA 形态与 5 个 GetOrCreateFingerprint 场景,其中一个直接复现线上故障(缓存 2.1.220 + 客户端 2.1.75 → 写回 2.1.258,Stainless 字段保持不变)。 constants_cli_version_test.go 锁定常量与 DefaultHeaders 的 UA 同源,并断言 不低于 Fable 5.1 的闸门下限。回退实现后对应用例确认转红。 identity_service_user_agent_validation_test.go 中 3 个把版本钉死在旧字面量 的既有用例改为钉 CLICurrentVersion 或更高版本——它们断言的正是本次有意改变 的行为,各自的原始意图(哨兵拒绝、合法升级生效、健康缓存不重写)保持不变。 --- backend/internal/pkg/claude/constants.go | 2 +- .../pkg/claude/constants_cli_version_test.go | 70 +++++++++ backend/internal/service/identity_service.go | 65 +++++++- ...tity_service_user_agent_validation_test.go | 14 +- .../identity_service_version_floor_test.go | 146 ++++++++++++++++++ 5 files changed, 284 insertions(+), 13 deletions(-) create mode 100644 backend/internal/pkg/claude/constants_cli_version_test.go create mode 100644 backend/internal/service/identity_service_version_floor_test.go diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go index df9b7613b2..319eb84dd3 100644 --- a/backend/internal/pkg/claude/constants.go +++ b/backend/internal/pkg/claude/constants.go @@ -80,7 +80,7 @@ const DefaultCacheControlTTL = "5m" // // ⚠️ 读取实际生效的版本号请用 CLIVersion(),它会叠加 SUB2API_CLAUDE_CLI_VERSION 覆盖。 // 直接引用本常量只在"表达内置基线"时才正确(例如覆盖值的下限校验)。 -const CLICurrentVersion = "2.1.220" +const CLICurrentVersion = "2.1.258" // FullClaudeCodeMimicryBetas 返回最"像"真实 Claude Code CLI 的完整 beta 列表, // 用于 OAuth 账号伪装成 Claude Code 时使用。 diff --git a/backend/internal/pkg/claude/constants_cli_version_test.go b/backend/internal/pkg/claude/constants_cli_version_test.go new file mode 100644 index 0000000000..91bd12833f --- /dev/null +++ b/backend/internal/pkg/claude/constants_cli_version_test.go @@ -0,0 +1,70 @@ +package claude + +import ( + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// fable51MinCLIVersion 是上游对 claude-fable-5-1 的客户端版本闸门下限。 +// 低于该版本时上游直接 400: +// +// claude_code_version_too_old +// "Claude Code does not support this model; version 2.1.251 or newer is required." +const fable51MinCLIVersion = "2.1.251" + +// TestCLICurrentVersionMatchesDefaultUserAgent 锁定"常量与 UA 同源"这条约束。 +// 两者不一致会被 Anthropic 判为第三方客户端,且只改一处是历史上的常见疏漏, +// 所以这里从 UA 里反解版本号来比对,而不是并列两个字面量。 +func TestCLICurrentVersionMatchesDefaultUserAgent(t *testing.T) { + t.Parallel() + + ua := DefaultHeaders["User-Agent"] + const prefix = "claude-cli/" + require.True(t, strings.HasPrefix(ua, prefix), "unexpected User-Agent: %q", ua) + + rest := strings.TrimPrefix(ua, prefix) + version := rest + if idx := strings.IndexByte(rest, ' '); idx >= 0 { + version = rest[:idx] + } + require.Equal(t, CLICurrentVersion, version, + "DefaultHeaders User-Agent version must match CLICurrentVersion") +} + +// TestCLICurrentVersionSatisfiesFable51Gate 锁定伪装版本不低于 claude-fable-5-1 +// 的闸门下限。DefaultModels 里登记了该模型却发着更旧的版本号,等于模型清单可见 +// 但一调就 400。 +func TestCLICurrentVersionSatisfiesFable51Gate(t *testing.T) { + t.Parallel() + + require.GreaterOrEqual(t, compareSemver(t, CLICurrentVersion, fable51MinCLIVersion), 0, + "CLICurrentVersion %s is below the claude-fable-5-1 gate %s", + CLICurrentVersion, fable51MinCLIVersion) +} + +// compareSemver 比较两个三段 semver,返回 -1 / 0 / 1。 +func compareSemver(t *testing.T, a, b string) int { + t.Helper() + + as, bs := strings.Split(a, "."), strings.Split(b, ".") + require.Len(t, as, 3, "not a three-part semver: %q", a) + require.Len(t, bs, 3, "not a three-part semver: %q", b) + + for i := range as { + ai, err := strconv.Atoi(as[i]) + require.NoError(t, err, "non-numeric segment in %q", a) + bi, err := strconv.Atoi(bs[i]) + require.NoError(t, err, "non-numeric segment in %q", b) + + switch { + case ai > bi: + return 1 + case ai < bi: + return -1 + } + } + return 0 +} diff --git a/backend/internal/service/identity_service.go b/backend/internal/service/identity_service.go index 2f9e9d0b3f..bab982ff4b 100644 --- a/backend/internal/service/identity_service.go +++ b/backend/internal/service/identity_service.go @@ -28,6 +28,10 @@ var ( // /.. 之后必须紧跟空白或字符串结束。 // 版本号带 -local / -dev / +build 等后缀的本地构建一律不接受。 fingerprintUserAgentPattern = regexp.MustCompile(`^[A-Za-z0-9._-]+/\d+\.\d+\.\d+(\s|$)`) + + // claudeCLIUAVersionPrefixRegex 匹配 claude-cli UA 开头的 "claude-cli/x.y.z" 版本号段, + // 供版本下限抬升时就地替换版本号使用;UA 其余部分(括号内的真实客户端形态等)原样保留。 + claudeCLIUAVersionPrefixRegex = regexp.MustCompile(`(?i)^(claude-cli)/\d+\.\d+\.\d+`) ) const ( @@ -75,6 +79,38 @@ func isAcceptableFingerprintUserAgent(ua string) bool { return major <= currentMajor+maxClaudeCLIMajorVersionSkew } +// floorClaudeCLIUserAgentVersion 把 claude-cli UA 中的版本号抬升到 claude.CLICurrentVersion +// 下限:低于下限时就地升到下限并返回 changed=true,否则原样返回。 +// +// 为什么需要这条:账号级指纹"只升不降"、活跃账号懒续期后近乎永不过期,且系统内没有重置 +// 入口,defaultFingerprint 只在首次创建指纹时使用。存量账号缓存里的 claude-cli 版本停留在 +// 历史值(如 claude-cli/2.1.220),客户端送来更旧的版本时 isNewerVersion 不会触发升级—— +// 仅升 CLICurrentVersion 常量对所有已有账号完全无效,上游按指纹 UA 做客户端版本闸门 +// (如 Fable 5.1 要求 >= 2.1.251)时旧指纹永远过不去。 +// +// 约束: +// - 只对 claude-cli 产品生效,其它产品一律不动,避免误伤别的合法客户端; +// - 只升不降:版本等于或高于 CLICurrentVersion(含客户端上报的更新版本)时不做任何改动; +// - 只替换 claude-cli/ 后的版本号段,UA 其余部分(如 "(external, claude-desktop-3p, +// agent-sdk/0.3.100)")原样保留,不重建整个字符串、不退化为 defaultFingerprint.UserAgent; +// - X-Stainless-* 字段不在此处理,维持调用方的既有 merge 语义。 +func floorClaudeCLIUserAgentVersion(ua string) (string, bool) { + if extractProduct(ua) != claudeCLIUserAgentProduct { + return ua, false + } + floorUA := claudeCLIUserAgentProduct + "/" + claude.CLICurrentVersion + // isNewerVersion(floor, ua) 为 true 当且仅当下限版本严格高于 ua: + // ua 等于或高于下限、产品名不一致、或版本无法解析时都不做改动。 + if !isNewerVersion(floorUA, ua) { + return ua, false + } + floored := claudeCLIUAVersionPrefixRegex.ReplaceAllString(ua, "${1}/"+claude.CLICurrentVersion) + if floored == ua { + return ua, false + } + return floored, true +} + // 默认指纹值(当客户端未提供时使用) var defaultFingerprint = Fingerprint{ UserAgent: "claude-cli/" + claude.CLIVersion() + " (external, cli)", @@ -157,12 +193,25 @@ func (s *IdentityService) GetOrCreateFingerprint(ctx context.Context, accountID logger.LegacyPrintf("service.identity", "Replaced malformed cached fingerprint for account %d: %q -> %q", accountID, poisoned, cached.UserAgent) - } else if uaAcceptable && 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 { + // 客户端送来更新版本时的常规升级:merge 语义 — 仅更新请求中实际携带的字段, + // 保留缓存值,避免缺失的头被硬编码默认值覆盖(如新 CLI 版本 + 旧 SDK 默认值的不一致) + if uaAcceptable && isNewerVersion(clientUA, cached.UserAgent) { + mergeHeadersIntoFingerprint(cached, headers) + needWrite = true + logger.LegacyPrintf("service.identity", "Updated fingerprint for account %d: %s (merge update)", accountID, clientUA) + } + + // 版本下限抬升(floor):与上面的客户端升级相互独立、二者取更新者。客户端送来 + // 更旧版本时 isNewerVersion 不触发,此处仍能把低于 CLICurrentVersion 的存量指纹 + // 就地抬到下限并持久化,否则升 CLICurrentVersion 对所有已有账号无效,新模型的 + // 客户端版本闸门(如 Fable 5.1 要求 >= 2.1.251)永远过不去。 + if flooredUA, changed := floorClaudeCLIUserAgentVersion(cached.UserAgent); changed { + cached.UserAgent = flooredUA + needWrite = true + logger.LegacyPrintf("service.identity", + "Floored cached fingerprint claude-cli version for account %d: %s", accountID, flooredUA) + } } if !needWrite && time.Since(time.Unix(cached.UpdatedAt, 0)) > 24*time.Hour { @@ -208,7 +257,9 @@ func (s *IdentityService) createFingerprintFromHeaders(headers http.Header) *Fin // 获取User-Agent:只接受形态合法且版本合理的值,否则回退默认指纹。 // 首次创建同样是持久化写入,必须与升级路径共用同一套校验。 if ua := strings.TrimSpace(headers.Get("User-Agent")); isAcceptableFingerprintUserAgent(ua) { - fp.UserAgent = ua + // 首次创建与缓存命中路径共用同一个版本下限:合法但过旧的 claude-cli UA + // 落库时同样不能低于 CLICurrentVersion,否则新账号一开始就带着过旧的持久身份。 + fp.UserAgent, _ = floorClaudeCLIUserAgentVersion(ua) } else { fp.UserAgent = defaultFingerprint.UserAgent } diff --git a/backend/internal/service/identity_service_user_agent_validation_test.go b/backend/internal/service/identity_service_user_agent_validation_test.go index 5206eb04f0..30ef87b779 100644 --- a/backend/internal/service/identity_service_user_agent_validation_test.go +++ b/backend/internal/service/identity_service_user_agent_validation_test.go @@ -105,8 +105,9 @@ func TestGetOrCreateFingerprintRejectsMalformedUserAgentOnCreate(t *testing.T) { // 升级路径:哨兵版本号不得覆盖已缓存的真实指纹。 // isNewerVersion 是纯数值比较,999.0.0 恒大于任何真实版本,一旦写入永远无法夺回。 func TestGetOrCreateFingerprintRejectsSentinelVersionOnUpgrade(t *testing.T) { + // 缓存版本取 CLICurrentVersion(>= 下限),使本用例只验证哨兵拒绝,不与版本下限抬升耦合。 cached := &Fingerprint{ - UserAgent: "claude-cli/2.1.22 (external, cli)", + UserAgent: "claude-cli/" + claude.CLICurrentVersion + " (external, cli)", ClientID: "cid-1", UpdatedAt: time.Now().Unix(), } @@ -119,7 +120,7 @@ func TestGetOrCreateFingerprintRejectsSentinelVersionOnUpgrade(t *testing.T) { ) require.NoError(t, err) - require.Equal(t, "claude-cli/2.1.22 (external, cli)", fp.UserAgent, + require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, cli)", fp.UserAgent, "真实指纹不得被哨兵版本覆盖") require.Zero(t, cache.setCalls, "被拒的 UA 不应触发任何写入") } @@ -133,7 +134,9 @@ func TestGetOrCreateFingerprintStillUpgradesOnValidNewerVersion(t *testing.T) { }} svc := NewIdentityService(cache) - newUA := "claude-cli/2.1.223 (external, cli)" + // 客户端版本取高于 CLICurrentVersion 的合法值,使本用例只验证常规升级, + // 不与版本下限抬升耦合(低于下限的客户端升级由 version_floor 测试覆盖)。 + newUA := "claude-cli/2.9.0 (external, cli)" fp, err := svc.GetOrCreateFingerprint(context.Background(), 1, headersWithUA(newUA)) require.NoError(t, err) @@ -203,9 +206,10 @@ func TestGetOrCreateFingerprintHealsPoisonedCacheWithoutValidClientUA(t *testing } // 自愈只针对畸形缓存:合法缓存 + 非更新版本的合法 UA 不得触发额外写入。 +// 缓存版本取 CLICurrentVersion(>= 下限),低于下限的缓存抬升由 version_floor 测试覆盖。 func TestGetOrCreateFingerprintDoesNotRewriteHealthyCache(t *testing.T) { cache := &stubIdentityCache{fingerprint: &Fingerprint{ - UserAgent: "claude-cli/2.1.220 (external, cli)", + UserAgent: "claude-cli/" + claude.CLICurrentVersion + " (external, cli)", ClientID: "cid-1", UpdatedAt: time.Now().Unix(), }} @@ -217,7 +221,7 @@ func TestGetOrCreateFingerprintDoesNotRewriteHealthyCache(t *testing.T) { ) require.NoError(t, err) - require.Equal(t, "claude-cli/2.1.220 (external, cli)", fp.UserAgent) + require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, cli)", fp.UserAgent) require.Zero(t, cache.setCalls) } diff --git a/backend/internal/service/identity_service_version_floor_test.go b/backend/internal/service/identity_service_version_floor_test.go new file mode 100644 index 0000000000..78a15627c4 --- /dev/null +++ b/backend/internal/service/identity_service_version_floor_test.go @@ -0,0 +1,146 @@ +package service + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" +) + +// floorClaudeCLIUserAgentVersion 单元测试:版本下限抬升的各种形态。 +func TestFloorClaudeCLIUserAgentVersion(t *testing.T) { + floorUA := "claude-cli/" + claude.CLICurrentVersion + cases := []struct { + name string + ua string + want string + wantChanged bool + }{ + // 线上故障形态:历史版本低于 CLICurrentVersion,就地抬到下限。 + {"old_version_upgraded", "claude-cli/2.1.220 (external, cli)", floorUA + " (external, cli)", true}, + // 只替换版本号段,括号内的真实客户端形态原样保留。 + {"old_version_with_desktop_3p_suffix", + "claude-cli/2.1.100 (external, claude-desktop-3p, agent-sdk/0.3.100)", + floorUA + " (external, claude-desktop-3p, agent-sdk/0.3.100)", true}, + // 等于下限:不得改动。 + {"equal_to_floor", floorUA + " (external, cli)", floorUA + " (external, cli)", false}, + // 高于下限:只升不降,不得把客户端上报的更新版本压回去。 + {"newer_than_floor_not_downgraded", "claude-cli/2.9.0 (external, cli)", "claude-cli/2.9.0 (external, cli)", false}, + // 非 claude-cli 产品:一律不动。 + {"other_product_untouched", "opencode/1.2.3 (external, cli)", "opencode/1.2.3 (external, cli)", false}, + // 空串 / 畸形:一律不动。 + {"empty", "", "", false}, + {"no_version", "claude-cli (external, cli)", "claude-cli (external, cli)", false}, + {"unparseable_version", "claude-cli/abc (external, cli)", "claude-cli/abc (external, cli)", false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, changed := floorClaudeCLIUserAgentVersion(tc.ua) + require.Equal(t, tc.want, got) + require.Equal(t, tc.wantChanged, changed) + }) + } +} + +// GetOrCreateFingerprint 集成行为,直接对应线上故障:缓存指纹停留在历史版本 +// 2.1.220(生产 Redis 中账号 147 的实际值),客户端送来更旧的 2.1.75。 +// 修复前:isNewerVersion 不触发、UA 形态合法不触发自愈,旧指纹被原样返回并 +// 近乎永不过期——上游按指纹 UA 做客户端版本闸门(Fable 5.1 要求 >= 2.1.251), +// 仅升 CLICurrentVersion 对存量账号完全无效。 +func TestGetOrCreateFingerprintFloorsStaleCachedUserAgent(t *testing.T) { + cache := &stubIdentityCache{fingerprint: &Fingerprint{ + UserAgent: "claude-cli/2.1.220 (external, cli)", + ClientID: "cid-1", + StainlessPackageVersion: "0.91.1", + UpdatedAt: time.Now().Unix(), + }} + svc := NewIdentityService(cache) + + fp, err := svc.GetOrCreateFingerprint( + context.Background(), 147, + headersWithUA("claude-cli/2.1.75 (external, cli)"), + ) + + require.NoError(t, err) + require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, cli)", fp.UserAgent) + require.Equal(t, 1, cache.setCalls, "下限抬升必须持久化写回缓存") + require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, cli)", cache.lastSet.UserAgent) + // X-Stainless-* 维持既有 merge 语义:客户端未携带时保留缓存中的真实值,不被下限逻辑覆盖。 + require.Equal(t, "0.91.1", fp.StainlessPackageVersion) +} + +// 缓存版本高于下限:不得被降级,也不得触发多余写入。 +func TestGetOrCreateFingerprintDoesNotTouchCacheAboveFloor(t *testing.T) { + ua := "claude-cli/2.9.0 (external, cli)" + cache := &stubIdentityCache{fingerprint: &Fingerprint{ + UserAgent: ua, + ClientID: "cid-1", + StainlessPackageVersion: "0.91.1", + UpdatedAt: time.Now().Unix(), + }} + svc := NewIdentityService(cache) + + fp, err := svc.GetOrCreateFingerprint( + context.Background(), 1, + headersWithUA("claude-cli/2.1.75 (external, cli)"), + ) + + require.NoError(t, err) + require.Equal(t, ua, fp.UserAgent, "高于下限的缓存版本不得被降级") + require.Zero(t, cache.setCalls) +} + +// 客户端送来高于下限的更新版本:常规升级照常生效,且不被下限压回。 +func TestGetOrCreateFingerprintClientNewerThanFloorStillWins(t *testing.T) { + newUA := "claude-cli/2.9.0 (external, cli)" + cache := &stubIdentityCache{fingerprint: &Fingerprint{ + UserAgent: "claude-cli/2.1.220 (external, cli)", + ClientID: "cid-1", + UpdatedAt: time.Now().Unix(), + }} + svc := NewIdentityService(cache) + + fp, err := svc.GetOrCreateFingerprint(context.Background(), 1, headersWithUA(newUA)) + + require.NoError(t, err) + require.Equal(t, newUA, fp.UserAgent, "客户端更新版本优先于下限,不得被压回") + require.Equal(t, 1, cache.setCalls) +} + +// 客户端版本高于缓存但低于下限:merge 升级后仍被抬到下限(二者取更新者)。 +func TestGetOrCreateFingerprintFloorWinsOverStaleClientUpgrade(t *testing.T) { + cache := &stubIdentityCache{fingerprint: &Fingerprint{ + UserAgent: "claude-cli/2.1.22 (external, cli)", + ClientID: "cid-1", + UpdatedAt: time.Now().Unix(), + }} + svc := NewIdentityService(cache) + + fp, err := svc.GetOrCreateFingerprint( + context.Background(), 1, + headersWithUA("claude-cli/2.1.223 (external, cli)"), + ) + + require.NoError(t, err) + require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, cli)", fp.UserAgent) + require.Equal(t, 1, cache.setCalls) +} + +// 首次创建路径同样受下限约束:合法但过旧的 claude-cli UA 落库时必须抬到 CLICurrentVersion。 +func TestCreateFingerprintFromHeadersFloorsOldClientUserAgent(t *testing.T) { + cache := &stubIdentityCache{} + svc := NewIdentityService(cache) + + fp, err := svc.GetOrCreateFingerprint( + context.Background(), 1, + headersWithUA("claude-cli/2.1.75 (external, claude-desktop-3p)"), + ) + + require.NoError(t, err) + require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, claude-desktop-3p)", fp.UserAgent) + require.Equal(t, 1, cache.setCalls) +} From ce157b32ed584bd91b1edafcf305ca5fb84c52ed Mon Sep 17 00:00:00 2001 From: alfadb Date: Sat, 5 Sep 2026 17:08:11 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(claude):=20=E6=8C=87=E7=BA=B9=E8=87=AA?= =?UTF-8?q?=E6=84=88=E8=B7=AF=E5=BE=84=E5=90=8C=E6=A0=B7=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=20claude-cli=20=E7=89=88=E6=9C=AC=E4=B8=8B=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetOrCreateFingerprint 的 heal 分支(畸形缓存自愈)此前在版本下限逻辑之外: 畸形缓存 + 合法但过旧的客户端 UA 会在首次读取时把过旧 UA 原样落库并返回, 下一次读取才被抬到下限——该窗口内账号仍以过旧版本撞上游客户端版本闸门 (PR #6481 review 指出的边界 case)。 修复:把 floorClaudeCLIUserAgentVersion 后置块从 healthy else 内移到 heal/healthy if-else 之后、TTL 续期检查之前,作为两条缓存命中路径的共同 后置条件;heal 分支 needWrite 本已为 true,单次落库语义不变。 测试:TestGetOrCreateFingerprintHealsPoisonedCacheUsingValidClientUA 期望改为首次返回/落库即 claude-cli/CLICurrentVersion(保留 ClientID 不变、 恰好一次落库断言);floor/UA 全部既有用例保持绿。 --- backend/internal/service/identity_service.go | 24 +++++++++++-------- ...tity_service_user_agent_validation_test.go | 6 +++-- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/backend/internal/service/identity_service.go b/backend/internal/service/identity_service.go index bab982ff4b..d117f720cc 100644 --- a/backend/internal/service/identity_service.go +++ b/backend/internal/service/identity_service.go @@ -202,16 +202,20 @@ func (s *IdentityService) GetOrCreateFingerprint(ctx context.Context, accountID logger.LegacyPrintf("service.identity", "Updated fingerprint for account %d: %s (merge update)", accountID, clientUA) } - // 版本下限抬升(floor):与上面的客户端升级相互独立、二者取更新者。客户端送来 - // 更旧版本时 isNewerVersion 不触发,此处仍能把低于 CLICurrentVersion 的存量指纹 - // 就地抬到下限并持久化,否则升 CLICurrentVersion 对所有已有账号无效,新模型的 - // 客户端版本闸门(如 Fable 5.1 要求 >= 2.1.251)永远过不去。 - if flooredUA, changed := floorClaudeCLIUserAgentVersion(cached.UserAgent); changed { - cached.UserAgent = flooredUA - needWrite = true - logger.LegacyPrintf("service.identity", - "Floored cached fingerprint claude-cli version for account %d: %s", accountID, flooredUA) - } + } + + // 版本下限抬升(floor):heal 与 healthy 两条缓存命中路径共同的后置条件,与 + // 上面的客户端常规升级相互独立、二者取更新者。客户端送来更旧版本时 + // isNewerVersion 不触发,此处仍能把低于 CLICurrentVersion 的存量指纹就地抬到 + // 下限并持久化,否则升 CLICurrentVersion 对所有已有账号无效,新模型的客户端 + // 版本闸门(如 Fable 5.1 要求 >= 2.1.251)永远过不去。自愈路径同样必须经过: + // 畸形缓存 + 合法但过旧的客户端 UA 首次读取就要落到下限,不能先落库一个 + // 过旧版本、等下一次读取才纠正。 + if flooredUA, changed := floorClaudeCLIUserAgentVersion(cached.UserAgent); changed { + cached.UserAgent = flooredUA + needWrite = true + logger.LegacyPrintf("service.identity", + "Floored cached fingerprint claude-cli version for account %d: %s", accountID, flooredUA) } if !needWrite && time.Since(time.Unix(cached.UpdatedAt, 0)) > 24*time.Hour { diff --git a/backend/internal/service/identity_service_user_agent_validation_test.go b/backend/internal/service/identity_service_user_agent_validation_test.go index 30ef87b779..1a52d12554 100644 --- a/backend/internal/service/identity_service_user_agent_validation_test.go +++ b/backend/internal/service/identity_service_user_agent_validation_test.go @@ -179,8 +179,10 @@ func TestGetOrCreateFingerprintHealsPoisonedCacheUsingValidClientUA(t *testing.T fp, err := svc.GetOrCreateFingerprint(context.Background(), 1, headersWithUA(realUA)) require.NoError(t, err) - require.Equal(t, realUA, fp.UserAgent, - "真实客户端必须能从被毒化的指纹手中夺回账号身份") + // 真实客户端从被毒化的指纹手中夺回账号身份,但版本下限在自愈的同一读取内生效: + // 合法而过旧的 UA 不能先以过旧版本落库、等下次读取才被抬升。 + require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, cli)", fp.UserAgent) + require.Equal(t, fp.UserAgent, cache.lastSet.UserAgent, "自愈与下限抬升应合并为同一次落库") require.Equal(t, 1, cache.setCalls) require.NotContains(t, cache.lastSet.UserAgent, "999.0.0") require.Equal(t, "cid-1", fp.ClientID, "自愈不应重置 ClientID") From 3efeba33a47a9cfc51faac6057806a50cabee12d Mon Sep 17 00:00:00 2001 From: alfadb Date: Sat, 5 Sep 2026 17:43:13 +0800 Subject: [PATCH 3/3] =?UTF-8?q?test(claude):=20=E7=89=88=E6=9C=AC=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E7=94=A8=E4=BE=8B=E5=AD=97=E9=9D=A2=E9=87=8F=E9=9A=8F?= =?UTF-8?q?=E5=86=85=E7=BD=AE=E5=9F=BA=E7=BA=BF=E5=8D=87=E8=87=B3=202.1.25?= =?UTF-8?q?9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 这三个用例的字面量 2.1.251 写于内置基线为 2.1.220 的时期,本意是取一个 高于基线的补丁位验证"覆盖不得低于 pin"规则。基线升至 2.1.258 后,2.1.251 落在 pin 之下:IsSupportedCLIVersion 应判 false、resolveCLIVersion 应回落 基线,与用例原意相悖,CI 随之失败。 改为真正高于当前 pin 的最小补丁位 2.1.259,用例语义不变;实现未放宽, constants_cli_version_test.go 中独立的 Fable 5.1 闸门下限 2.1.251 保持不变。 --- backend/internal/pkg/claude/cli_version_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/internal/pkg/claude/cli_version_test.go b/backend/internal/pkg/claude/cli_version_test.go index 043df2b554..89f8db33a8 100644 --- a/backend/internal/pkg/claude/cli_version_test.go +++ b/backend/internal/pkg/claude/cli_version_test.go @@ -9,7 +9,7 @@ func TestIsSupportedCLIVersion(t *testing.T) { want bool }{ {"内置基线本身", CLICurrentVersion, true}, - {"高于基线的补丁位", "2.1.251", true}, + {"高于基线的补丁位", "2.1.259", true}, {"高于基线的次版本", "2.2.0", true}, {"高于基线的主版本", "3.0.0", true}, {"低于基线", "2.1.219", false}, @@ -41,8 +41,8 @@ func TestResolveCLIVersion(t *testing.T) { }{ {"未配置时用内置基线", "", CLICurrentVersion}, {"只有空白等同未配置", " ", CLICurrentVersion}, - {"合法覆盖生效", "2.1.251", "2.1.251"}, - {"两侧空白被裁掉", " 2.1.251 ", "2.1.251"}, + {"合法覆盖生效", "2.1.259", "2.1.259"}, + {"两侧空白被裁掉", " 2.1.259 ", "2.1.259"}, {"非法值回落基线", "not-a-version", CLICurrentVersion}, {"向下覆盖被拒", "2.0.0", CLICurrentVersion}, {"预发布后缀被拒(会毒化账号指纹)", "2.2.0-local", CLICurrentVersion},