fix(opencode): 纠正 CASE 求值顺序注释并补齐 mode 语义护栏

This commit is contained in:
alfadb
2026-09-23 15:26:44 +08:00
parent 31635849e0
commit a2e1618700
5 changed files with 119 additions and 13 deletions
+20 -7
View File
@@ -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 {
@@ -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'
@@ -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))
})
}
}
@@ -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)。
//
@@ -442,10 +442,14 @@
@updated="handleOllamaCloudUsageUpdated"
/>
<!-- 挂在 CN 平台下的 OpenCode Go 账号(资格由后端下发 eligible):用量展示与
刷新由本分支的 OpenCode 用量窗口独占。与 ollama 同理,这类账号不是国产
厂商订阅,CN 的额度/余额探测端点由 base_url 衍生,对 opencode.ai 会被
后端出站 URL 白名单拒绝,渲染出来只会给用户一行探测报错,因此不再渲染
CN 子单元格与占位符(调度停调仍由上游 CN 触发各自驱动)。 -->
刷新由本分支的 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 触发各自驱动)。 -->
<OpenCodeGoUsageCell
v-else-if="account.opencode_go_usage?.eligible"
:account="account"
@@ -591,8 +595,10 @@
:account="account"
@updated="handleOllamaCloudUsageUpdated"
/>
<!-- 与上方 CN 分支结构对齐:Ollama 与 OpenCode 用量身份互斥(基址 host 不同),
v-else-if 提供结构性互斥保证,不改变实际渲染结果。 -->
<OpenCodeGoUsageCell
v-if="account.opencode_go_usage?.eligible"
v-else-if="account.opencode_go_usage?.eligible"
:account="account"
@updated="handleOpenCodeGoUsageUpdated"
/>