diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 70ff03597c..781928aea2 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -868,10 +868,16 @@ func (r *accountRepository) UpdateCredentials(ctx context.Context, id int64, cre SET credentials = $1::jsonb, extra = CASE - -- OpenCode 分支必须先于 Ollama 分支求值:Ollama 守卫对任意白名单平台的 - -- apikey 行在 api_key/base_url 变化时都会命中,若排在前面会遮蔽 opencode - -- 行的清理。两分支互斥:opencode_go 不在 Ollama 白名单内,挂载行的 - -- opencode.ai 基址与 ollama.com 基址正则互斥,旧行只会命中其中一个分支。 + -- 正确性依赖(非防御):OpenCode 分支必须先于 Ollama 分支求值。两分支 + -- 的 WHEN 并不互斥:Ollama 分支的守卫是宽谓词——NOT(ollamaMatch(old) + -- AND ollamaMatch(new)) 在旧行不匹配 ollama.com 基址时恒真,且两侧 + -- 平台白名单完全相同,因此挂载行(白名单平台 + 官方 OpenCode Go 基址) + -- 会同时满足两分支的 WHEN。若把 Ollama 分支前移,挂载行的 api_key 变化 + -- 会先命中 Ollama 分支,opencode_go_usage_snapshot / + -- opencode_go_usage_auto_refresh 残留,陈旧快照跟着新 api_key 走, + -- 造成跨 key 组污染。互斥的只是两侧身份谓词(opencode.ai 基址正则 vs + -- ollama.com 基址正则):Ollama 行不会被 OpenCode 分支遮蔽; + -- opencode_go 平台行不在 Ollama 白名单内,与 Ollama 分支真互斥。 WHEN type = 'apikey' AND credentials IS DISTINCT FROM $1::jsonb AND ( @@ -3158,9 +3164,16 @@ func (r *accountRepository) BulkUpdate(ctx context.Context, ids []int64, updates // eligible 判定必须包含旧行 OpenCode 身份(opencode_go Go 订阅,或挂载 // 白名单平台 + opencode 基址):否则白名单平台的 Ollama 行在代理变化时会 // 先命中 OpenCode 分支(CASE 按序求值)而遮蔽 Ollama 分支的快照清理。 - // OpenCode 与 Ollama 两套分支互斥:opencode_go 不在 Ollama 白名单内;挂载行 - // 的 opencode.ai 基址与 ollama.com 基址正则互斥(host 不同),同一旧行不可能 - // 同时命中两分支,CASE 求值顺序因此只是防御而非正确性依赖。 + // 注意 OpenCode 与 Ollama 两套分支的 WHEN 并不互斥:eligibleAccount 只看 + // platform 白名单 + type='apikey',根本不含 base_url,且与 OpenCode 挂载 + // 白名单完全相同,因此挂载行(白名单平台 + 官方 OpenCode Go 基址)会同时 + // 满足两套分支的 WHEN;互斥的只是两侧身份谓词(opencode.ai 基址正则 vs + // ollama.com 基址正则,host 不同)。因此下方 caseBranches 必须先拼 OpenCode + // 分支再拼 Ollama 分支——这是正确性要求而非防御:若 Ollama 分支在前, + // 挂载行的 api_key 变化会先命中 Ollama 分支,opencode_go_usage_snapshot / + // opencode_go_usage_auto_refresh 残留,陈旧快照跟着新 api_key 走,跨 key + // 组污染。反方向安全:Ollama 行(ollama.com 基址)不满足 OpenCode 身份 + // 谓词;opencode_go 平台行不在 Ollama 白名单内,与 Ollama 分支真互斥。 opencodeEligibleAccount := "type = 'apikey' AND " + opencodeOldUsageIdentity opencodeGroupIdentityChanged := "" if len(opencodeGroupIdentityChanges) > 0 { diff --git a/backend/internal/repository/account_repo_opencode_go_usage.go b/backend/internal/repository/account_repo_opencode_go_usage.go index 1720745adf..1568e70695 100644 --- a/backend/internal/repository/account_repo_opencode_go_usage.go +++ b/backend/internal/repository/account_repo_opencode_go_usage.go @@ -32,6 +32,11 @@ const ( // 默认兼容逻辑一致;COALESCE 把 <> 对 NULL 的结果归一为 true。不校验 // base_url(平台字段已是权威来源)。 // - 挂载白名单平台:base_url 必须匹配官方 OpenCode Go 基址正则。 + // + // 已知且可接受的差异:btrim 只去空格,Go 侧 strings.TrimSpace 还会去 + // \t\n\v\f\r 等空白;account_mode 等凭证字段实际不会出现这类空白,且改用 + // btrim(x, E' \t\n\r\f\v') 会在 parity 关键的 SQL 字符串里引入转义脆弱性, + // 故保持现状不改行为。 opencodeGoUsageEligibleSQL = ` ( (platform = 'opencode_go' diff --git a/backend/internal/repository/account_repo_opencode_go_usage_test.go b/backend/internal/repository/account_repo_opencode_go_usage_test.go index a6dbebbf6f..8a5add8ffd 100644 --- a/backend/internal/repository/account_repo_opencode_go_usage_test.go +++ b/backend/internal/repository/account_repo_opencode_go_usage_test.go @@ -445,3 +445,82 @@ func TestInvalidateProxyProbeSnapshotsClearsOpenCodeGoSnapshot(t *testing.T) { require.Equal(t, []int64{17}, ids) require.NoError(t, mock.ExpectationsWereMet()) } + +// UpdateCredentials 的 CASE 求值顺序是正确性依赖而非防御:Ollama 分支的 WHEN 是 +// 宽守卫(NOT(ollamaMatch(old) AND ollamaMatch(new)),旧行不匹配 ollama.com 基址 +// 时恒真,且不含 opencode.ai 正则),而两侧平台白名单完全相同,因此挂载行 +// (白名单平台 + 官方 OpenCode Go 基址)会同时满足两分支的 WHEN。若把 Ollama +// 分支前移,挂载行的 api_key 变化会先命中 Ollama 分支,OpenCode THEN 才清除的 +// opencode_go_usage_snapshot / opencode_go_usage_auto_refresh 残留,陈旧快照跟着 +// 新 api_key 走,跨 key 组污染。本测试用正则钉死 OpenCode 分支的 WHEN 标记 +// (platform = 'opencode_go',只出现在 OpenCode 分支)文本上先于 Ollama 分支的 +// WHEN 标记(ollama.com 基址正则片段)。 +func TestUpdateCredentialsOpenCodeBranchPrecedesOllamaBranch(t *testing.T) { + client, mock := newOllamaCloudUsageRepositoryTestClient(t) + mock.ExpectBegin() + mock.ExpectExec(`(?s)UPDATE accounts.*platform = 'opencode_go'.*\[oO\]\[lL\]\[lL\]\[aA\]\[mM\]\[aA\]`). + WithArgs(`{"api_key":"new-key","base_url":"https://opencode.ai/zen/go/v1"}`, int64(17)). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec(regexp.QuoteMeta("INSERT INTO scheduler_outbox")). + WithArgs(service.SchedulerOutboxEventAccountChanged, int64(17), nil, nil, sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + repo := newAccountRepositoryWithSQL(client, nil, nil) + + err := repo.UpdateCredentials(context.Background(), 17, map[string]any{ + "api_key": "new-key", "base_url": "https://opencode.ai/zen/go/v1", + }) + + require.NoError(t, err) + require.NoError(t, mock.ExpectationsWereMet()) +} + +// SQL 侧「默认 Go」语义的常量级护栏:opencode_go 平台行 account_mode 缺失/为 +// JSON null 时,COALESCE 兜底必须落 true(视为 Go 订阅,与 GetOpenCodeAccountMode +// 的默认兼容逻辑一致)。若误改成 false,存量 opencode_go 账号在 SQL 侧集体失去 +// 资格(组查询漏行、身份清理漏清、RunDue 自动刷新停摆),而 Go 侧仍判合格, +// 两侧不一致;唯一的行为级覆盖在需要 Docker 的 integration 测试,无 Docker 的 +// unit 通道此前完全拦不住,这里以文本形态钉死该 COALESCE 表达式。 +func TestOpenCodeGoUsageEligibleSQLDefaultsMissingAccountModeToGo(t *testing.T) { + require.Contains(t, opencodeGoUsageEligibleSQL, + "COALESCE(btrim(credentials ->> 'account_mode') <> 'zen', true)") +} + +// Go 侧「默认 Go」契约的表驱动钉死:account_mode 只有 trim 后恰好等于 "zen" 才是 +// Zen,其余取值一律判 Go。SQL 侧(opencodeGoUsageEligibleSQL)以 +// COALESCE(btrim(...) <> 'zen', true) 声明同一语义:btrim 只去空格,与本表全部 +// 取值在 Go 侧 strings.TrimSpace 下的结论一致(空白字符集差异见常量注释)。 +func TestOpenCodeAccountModeOnlyTrimmedZenIsZen(t *testing.T) { + tests := []struct { + name string + accountMode func(map[string]any) + wantMode string + wantGoPlan bool + }{ + {name: "键缺失", accountMode: func(map[string]any) {}, wantMode: service.AccountModeGo, wantGoPlan: true}, + {name: "JSON null", accountMode: func(c map[string]any) { c["account_mode"] = nil }, wantMode: service.AccountModeGo, wantGoPlan: true}, + {name: "空串", accountMode: func(c map[string]any) { c["account_mode"] = "" }, wantMode: service.AccountModeGo, wantGoPlan: true}, + {name: "go", accountMode: func(c map[string]any) { c["account_mode"] = service.AccountModeGo }, wantMode: service.AccountModeGo, wantGoPlan: true}, + {name: "zen", accountMode: func(c map[string]any) { c["account_mode"] = service.AccountModeZen }, wantMode: service.AccountModeZen, wantGoPlan: false}, + {name: "前后空格的 zen", accountMode: func(c map[string]any) { c["account_mode"] = " zen " }, wantMode: service.AccountModeZen, wantGoPlan: false}, + {name: "大小写不同的 ZEN", accountMode: func(c map[string]any) { c["account_mode"] = "ZEN" }, wantMode: service.AccountModeGo, wantGoPlan: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + account := &service.Account{ + ID: 17, + Platform: service.PlatformOpenCodeGo, + Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "key"}, + } + tt.accountMode(account.Credentials) + require.Equal(t, tt.wantMode, account.GetOpenCodeAccountMode()) + require.Equal(t, tt.wantGoPlan, account.IsOpenCodeGoPlan()) + require.Equal(t, !tt.wantGoPlan, account.IsOpenCodeZen()) + // opencode_go 平台行的用量资格即 Go 订阅判定,与 SQL 侧 + // opencodeGoUsageEligibleSQL 的「默认 Go、仅 trim 后等于 zen 排除」 + // 互为镜像。 + require.Equal(t, tt.wantGoPlan, service.IsOpenCodeGoUsageAccount(account)) + }) + } +} diff --git a/backend/internal/service/opencode_go_usage.go b/backend/internal/service/opencode_go_usage.go index 583b06c4c0..f0408a0f30 100644 --- a/backend/internal/service/opencode_go_usage.go +++ b/backend/internal/service/opencode_go_usage.go @@ -887,7 +887,10 @@ func isOpenCodeGoUsageMountPlatform(platform string) bool { // - platform == opencode_go:平台字段是权威来源,以 IsOpenCodeGoPlan 为准(必须是 // Go 订阅;Zen 按量付费无订阅配额窗口,上游 CN 配额链路同样排除)。此分支不要求 // base_url 匹配——opencode_go 账号的 base_url 可能是 CC/Responses 基址 -// (/zen/go/v1)或 Anthropic 基址(/zen/go),甚至为空。 +// (/zen/go/v1)或 Anthropic 基址(/zen/go),甚至为空。这是有意取舍:平台 + +// 模式已是权威来源,且账号指向自建代理/中转时 key 仍是官方 OpenCode key,从 +// 官方端点取用量恰恰是正确数据源,强制要求官方 host 会破坏这类合法用法; +// 前提假设是该账号的 api_key 为官方 OpenCode Go 订阅 key。 // - 其它平台:仅限挂载白名单(isOpenCodeGoUsageMountPlatform)内的 API Key 账号, // 以 base_url 严格指向官方 OpenCode Go 基址判定(见 isOpenCodeGoBaseURL)。 // diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue index 8ab2d6691d..36e708e08a 100644 --- a/frontend/src/components/account/AccountUsageCell.vue +++ b/frontend/src/components/account/AccountUsageCell.vue @@ -442,10 +442,14 @@ @updated="handleOllamaCloudUsageUpdated" /> + 刷新由本分支的 OpenCode 用量窗口独占。对 platform=opencode_go:上游 CN + 配额链路原生支持该平台(cnQuotaCellVisible 对它返回 true),但其探测 + 端点(base_url + "/usage",默认 base 即官方 Go 基址)与本 cell 刷新的 + 是同一端点、同一份数据,抑制 CN 子单元格是为了避免同源双份探测与重复 + 展示。对挂载在 kimi/zhipu/deepseek/minimax 下的账号:CN 的额度/余额 + 探测端点由 base_url 衍生,对 opencode.ai 会被后端出站 URL 白名单拒绝, + 渲染出来只会给用户一行探测报错。两种情况都不再渲染 CN 子单元格与 + 占位符(调度停调仍由上游 CN 触发各自驱动)。 --> +