fix(opencode): preserve managed usage state on account updates

This commit is contained in:
alfadb
2026-09-23 15:26:35 +08:00
parent f7f0a56f35
commit 0a43efb9a4
9 changed files with 947 additions and 27 deletions
+121 -17
View File
@@ -648,7 +648,19 @@ func lockAndMergeAccountProbeExtra(
extra -> 'upstream_billing_probe',
extra -> 'ollama_cloud_usage_session',
extra -> 'ollama_cloud_usage_auto_refresh',
extra -> 'ollama_cloud_usage_snapshot'
extra -> 'ollama_cloud_usage_snapshot',
COALESCE(
platform = 'openai'
AND $2 = 'openai'
AND type = 'apikey'
AND $3 = 'apikey'
AND credentials -> 'api_key' IS NOT DISTINCT FROM $4::jsonb -> 'api_key'
AND `+opencodeGoBaseURLMatchSQLPrefix+`credentials ->> 'base_url'`+opencodeGoBaseURLMatchSQLSuffix+`
AND `+opencodeGoBaseURLMatchSQLPrefix+`$4::jsonb ->> 'base_url'`+opencodeGoBaseURLMatchSQLSuffix+`,
false
),
extra -> 'opencode_go_usage_auto_refresh',
extra -> 'opencode_go_usage_snapshot'
FROM accounts
WHERE id = $1 AND deleted_at IS NULL
FOR NO KEY UPDATE
@@ -665,15 +677,18 @@ func lockAndMergeAccountProbeExtra(
}
var (
identityUnchanged bool
ollamaGroupIdentityUnchanged bool
ollamaProxyIdentityUnchanged bool
currentEnabled []byte
currentRateSyncEnabled []byte
currentSnapshot []byte
currentOllamaSession []byte
currentOllamaAutoRefresh []byte
currentOllamaSnapshot []byte
identityUnchanged bool
ollamaGroupIdentityUnchanged bool
ollamaProxyIdentityUnchanged bool
opencodeGroupIdentityUnchanged bool
currentEnabled []byte
currentRateSyncEnabled []byte
currentSnapshot []byte
currentOllamaSession []byte
currentOllamaAutoRefresh []byte
currentOllamaSnapshot []byte
currentOpenCodeAutoRefresh []byte
currentOpenCodeSnapshot []byte
)
if err := rows.Scan(
&identityUnchanged,
@@ -685,6 +700,9 @@ func lockAndMergeAccountProbeExtra(
&currentOllamaSession,
&currentOllamaAutoRefresh,
&currentOllamaSnapshot,
&opencodeGroupIdentityUnchanged,
&currentOpenCodeAutoRefresh,
&currentOpenCodeSnapshot,
); err != nil {
return nil, err
}
@@ -700,6 +718,8 @@ func lockAndMergeAccountProbeExtra(
service.OllamaCloudUsageSessionExtraKey,
service.OllamaCloudUsageAutoRefreshExtraKey,
service.OllamaCloudUsageSnapshotExtraKey,
service.OpenCodeGoUsageAutoRefreshExtraKey,
service.OpenCodeGoUsageSnapshotExtraKey,
} {
delete(extra, key)
}
@@ -777,6 +797,24 @@ func lockAndMergeAccountProbeExtra(
}
}
}
// OpenCode Go 受管状态与 Ollama 同范式:组身份(normalized opencode base URL +
// api_key)未变时从锁定的 DB 行回填,客户端 DTO 脱敏或并发 refresh 写入都无法
// 覆盖/丢失;身份改变或不再 eligible 时既不回填,写出的 extra 即清除旧状态。
// 代理不属于组身份,但 snapshot 外呼与 CAS 经代理,故代理变化时快照失效而开关保留。
if service.IsOpenCodeGoUsageAccount(account) && opencodeGroupIdentityUnchanged {
if value, ok, err := decodeAccountExtraJSON(currentOpenCodeAutoRefresh); err != nil {
return nil, err
} else if ok {
extra[service.OpenCodeGoUsageAutoRefreshExtraKey] = value
}
if ollamaProxyIdentityUnchanged {
if snapshot, ok, err := decodeAccountExtraJSON(currentOpenCodeSnapshot); err != nil {
return nil, err
} else if ok {
extra[service.OpenCodeGoUsageSnapshotExtraKey] = snapshot
}
}
}
return extra, nil
}
@@ -819,6 +857,25 @@ func (r *accountRepository) UpdateCredentials(ctx context.Context, id int64, cre
SET
credentials = $1::jsonb,
extra = CASE
-- OpenCode Go 分支必须先于 Ollama 分支求值:Ollama 守卫对任意
-- openai/anthropic apikey 行在 api_key/base_url 变化时都会命中,
-- 若排在前面会遮蔽 opencode 行的清理。opencode 与 ollama base URL
-- 正则互斥,旧行只会命中其中一个分支。
WHEN platform = 'openai'
AND type = 'apikey'
AND credentials IS DISTINCT FROM $1::jsonb
AND `+opencodeGoBaseURLMatchSQLPrefix+`credentials ->> 'base_url'`+opencodeGoBaseURLMatchSQLSuffix+`
AND (
credentials -> 'api_key' IS DISTINCT FROM $1::jsonb -> 'api_key'
OR (`+opencodeGoBaseURLMatchSQLPrefix+`$1::jsonb ->> 'base_url'`+opencodeGoBaseURLMatchSQLSuffix+`) IS NOT TRUE
)
THEN COALESCE(extra, '{}'::jsonb)
- 'upstream_billing_probe'
- 'opencode_go_usage_auto_refresh'
- 'opencode_go_usage_snapshot'
- 'ollama_cloud_usage_session'
- 'ollama_cloud_usage_auto_refresh'
- 'ollama_cloud_usage_snapshot'
-- 凭证整体未变化 ⇒ Ollama 组身份必然未变化;顶层 DISTINCT 守卫防止
-- 非 Ollama 账号的无变化持久化误清探测快照或重写 NULL extra。
WHEN platform IN (`+ollamaCloudUsagePlatformsSQL+`)
@@ -3009,7 +3066,23 @@ func (r *accountRepository) BulkUpdate(ctx context.Context, ids []int64, updates
" AND "+ollamaCloudBaseURLMatchesSQL(credentialPlaceholder+"::jsonb ->> 'base_url'")+")")
}
if len(updates.Extra) > 0 || len(ollamaGroupIdentityChanges) > 0 || ollamaProxyIdentityChanged != "" || updates.EnsureCodexFingerprintSeed {
// OpenCode Go 身份清理与 Ollama 同范式但更精确:仅当旧行是 opencode base URL
// 身份时才可能持有受管键,且只在该组身份(api_key / normalized base URL)真正
// 变化或不再 eligible 时清除,避免与 Ollama 分支交叉误清。
opencodeGroupIdentityChanges := make([]string, 0, 2)
opencodeOldBaseURL := opencodeGoBaseURLMatchSQLPrefix + "credentials ->> 'base_url'" + opencodeGoBaseURLMatchSQLSuffix
if _, ok := updates.Credentials["api_key"]; ok {
opencodeGroupIdentityChanges = append(opencodeGroupIdentityChanges,
opencodeOldBaseURL+" AND credentials -> 'api_key' IS DISTINCT FROM "+credentialPlaceholder+"::jsonb -> 'api_key'")
}
if _, ok := updates.Credentials["base_url"]; ok {
// NULL-safe:新 base_url 缺失/为 null 时 regex(NULL) 为 NULL,NOT NULL 仍为
// NULL 会令 WHEN 不命中而残留 OpenCode 状态;IS NOT TRUE 把 NULL 视为不匹配。
opencodeGroupIdentityChanges = append(opencodeGroupIdentityChanges,
opencodeOldBaseURL+" AND ("+opencodeGoBaseURLMatchSQLPrefix+credentialPlaceholder+"::jsonb ->> 'base_url'"+opencodeGoBaseURLMatchSQLSuffix+") IS NOT TRUE")
}
if len(updates.Extra) > 0 || len(ollamaGroupIdentityChanges) > 0 || len(opencodeGroupIdentityChanges) > 0 || ollamaProxyIdentityChanged != "" || updates.EnsureCodexFingerprintSeed {
extraExpression := "COALESCE(extra, '{}'::jsonb)"
if len(updates.Extra) > 0 {
payload, err := json.Marshal(updates.Extra)
@@ -3040,13 +3113,44 @@ func (r *accountRepository) BulkUpdate(ctx context.Context, ids []int64, updates
snapshotIdentityChanged = "(" + snapshotIdentityChanged + " OR " + proxyChanged + ")"
}
}
// 代理不属于 OpenCode 组身份,但 snapshot 外呼与 CAS 经代理:
// 组身份变化清除开关+快照,仅代理变化清除快照而保留开关。
// eligible 判定必须包含旧行 opencode base URL:否则 OpenAI+Ollama 行在
// 代理变化时会先命中 OpenCode 分支(CASE 按序求值)而遮蔽 Ollama 分支的
// 快照清理。opencode 与 ollama 正则互斥,带上 base URL 后两套分支互斥。
opencodeEligibleAccount := "platform = 'openai' AND type = 'apikey' AND " + opencodeOldBaseURL
opencodeGroupIdentityChanged := ""
if len(opencodeGroupIdentityChanges) > 0 {
opencodeGroupIdentityChanged = "(" + opencodeEligibleAccount + " AND (" + joinClauses(opencodeGroupIdentityChanges, " OR ") + "))"
}
opencodeSnapshotIdentityChanged := opencodeGroupIdentityChanged
if ollamaProxyIdentityChanged != "" {
opencodeProxyChanged := "(" + opencodeEligibleAccount + " AND " + ollamaProxyIdentityChanged + ")"
if opencodeSnapshotIdentityChanged == "" {
opencodeSnapshotIdentityChanged = opencodeProxyChanged
} else {
opencodeSnapshotIdentityChanged = "(" + opencodeSnapshotIdentityChanged + " OR " + opencodeProxyChanged + ")"
}
}
caseBranches := make([]string, 0, 4)
if opencodeGroupIdentityChanged != "" {
caseBranches = append(caseBranches,
" WHEN "+opencodeGroupIdentityChanged+" THEN ("+extraExpression+") - 'opencode_go_usage_auto_refresh' - 'opencode_go_usage_snapshot'")
}
if opencodeSnapshotIdentityChanged != "" {
caseBranches = append(caseBranches,
" WHEN "+opencodeSnapshotIdentityChanged+" THEN ("+extraExpression+") - 'opencode_go_usage_snapshot'")
}
if groupIdentityChanged != "" {
extraExpression = "CASE" +
" WHEN " + groupIdentityChanged + " THEN (" + extraExpression + ") - 'ollama_cloud_usage_session' - 'ollama_cloud_usage_auto_refresh' - 'ollama_cloud_usage_snapshot'" +
" WHEN " + snapshotIdentityChanged + " THEN (" + extraExpression + ") - 'ollama_cloud_usage_snapshot'" +
" ELSE " + extraExpression + " END"
} else if snapshotIdentityChanged != "" {
extraExpression = "CASE WHEN " + snapshotIdentityChanged + " THEN (" + extraExpression + ") - 'ollama_cloud_usage_snapshot' ELSE " + extraExpression + " END"
caseBranches = append(caseBranches,
" WHEN "+groupIdentityChanged+" THEN ("+extraExpression+") - 'ollama_cloud_usage_session' - 'ollama_cloud_usage_auto_refresh' - 'ollama_cloud_usage_snapshot'")
}
if snapshotIdentityChanged != "" {
caseBranches = append(caseBranches,
" WHEN "+snapshotIdentityChanged+" THEN ("+extraExpression+") - 'ollama_cloud_usage_snapshot'")
}
if len(caseBranches) > 0 {
extraExpression = "CASE" + strings.Join(caseBranches, "") + " ELSE " + extraExpression + " END"
}
if updates.EnsureCodexFingerprintSeed {
extraExpression = ensureCodexFingerprintSeedSQL(extraExpression)
@@ -12,7 +12,10 @@ import (
)
const (
opencodeGoBaseURLRegexSQL = `^[hH][tT][tT][pP][sS]://[oO][pP][eE][nN][cC][oO][dD][eE]\.[aA][iI]/[zZ][eE][nN]/[gG][oO]/[vV]1/?$`
// 与 service.isOpenCodeGoBaseURL 对齐:Go 侧接受显式默认端口 :443(parsed.Host ==
// hostname+":443"),SQL 正则必须同样接受可选 :443,否则同一 base_url 在 Go 判定
// eligible 而 SQL 判定不 eligible,身份清理与组查询会漏行。只做 parity,不扩域名/路径。
opencodeGoBaseURLRegexSQL = `^[hH][tT][tT][pP][sS]://[oO][pP][eE][nN][cC][oO][dD][eE]\.[aA][iI](:[4][4][3])?/[zZ][eE][nN]/[gG][oO]/[vV]1/?$`
opencodeGoBaseURLMatchSQLPrefix = "btrim("
opencodeGoBaseURLMatchSQLSuffix = ") ~ '" + opencodeGoBaseURLRegexSQL + "'"
opencodeGoUsageEligibleSQL = `
@@ -0,0 +1,286 @@
//go:build integration
package repository
import (
"context"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
// openCodeGoManagedExtra 构造同时携带探测 / Ollama / OpenCode 受管键的 extra,
// 用于验证各身份清理分支只清自己该清的键、不跨身份误清。
func openCodeGoManagedExtra(now time.Time) map[string]any {
return map[string]any{
service.UpstreamBillingProbeExtraKey: map[string]any{"status": "ok"},
service.OllamaCloudUsageSessionExtraKey: "cipher:wos-session=fixture",
service.OllamaCloudUsageAutoRefreshExtraKey: true,
service.OllamaCloudUsageSnapshotExtraKey: map[string]any{
"status": service.OllamaCloudUsageStatusOK, "last_attempt_at": now, "next_refresh_at": now.Add(time.Hour),
},
service.OpenCodeGoUsageAutoRefreshExtraKey: true,
service.OpenCodeGoUsageSnapshotExtraKey: map[string]any{
"status": service.OpenCodeGoUsageStatusOK, "last_attempt_at": now, "next_refresh_at": now.Add(time.Hour),
},
}
}
// F1:混合 OpenCode + openai Ollama + anthropic Ollama 行的批量代理变化与
// 代理+key 变化,验证 OpenCode/Ollama CASE 分支互斥、各自只清自己该清的键。
// 回归点:OpenCode eligible 判定若不含旧行 opencode base URL,OpenAI+Ollama 行
// 在代理变化时会先命中 OpenCode 分支而遮蔽 Ollama 快照清理。
func TestBulkUpdateOpenCodeGoMixedPlatformIdentityCleanup(t *testing.T) {
ctx := context.Background()
tx := testEntTx(t)
repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil)
now := time.Now().UTC()
create := func(name, platform, baseURL string, extra map[string]any) *service.Account {
t.Helper()
return mustCreateAccount(t, tx.Client(), &service.Account{
Name: name, Platform: platform, Type: service.AccountTypeAPIKey,
Credentials: map[string]any{"api_key": "shared-key", "base_url": baseURL},
Extra: extra,
})
}
ollamaExtra := func() map[string]any {
return map[string]any{
service.OllamaCloudUsageSessionExtraKey: "cipher:wos-session=fixture",
service.OllamaCloudUsageAutoRefreshExtraKey: true,
service.OllamaCloudUsageSnapshotExtraKey: map[string]any{
"status": service.OllamaCloudUsageStatusOK, "last_attempt_at": now, "next_refresh_at": now.Add(time.Hour),
},
}
}
opencode := create("mixed-opencode", service.PlatformOpenAI, "https://opencode.ai/zen/go/v1", map[string]any{
service.OpenCodeGoUsageAutoRefreshExtraKey: true,
service.OpenCodeGoUsageSnapshotExtraKey: map[string]any{
"status": service.OpenCodeGoUsageStatusOK, "last_attempt_at": now, "next_refresh_at": now.Add(time.Hour),
},
})
openaiOllama := create("mixed-openai-ollama", service.PlatformOpenAI, "https://ollama.com", ollamaExtra())
anthropicOllama := create("mixed-anthropic-ollama", service.PlatformAnthropic, "https://ollama.com", ollamaExtra())
proxy := mustCreateProxy(t, tx.Client(), &service.Proxy{
Name: "mixed-proxy", Protocol: "http", Host: "127.0.0.1", Port: 3128,
Username: "user", Password: "pass", Status: service.StatusActive,
})
proxy2 := mustCreateProxy(t, tx.Client(), &service.Proxy{
Name: "mixed-proxy-2", Protocol: "http", Host: "127.0.0.2", Port: 3128,
Username: "user", Password: "pass", Status: service.StatusActive,
})
// 只改 proxy:OpenCode 行清快照留开关;Ollama 行清快照留 session/开关。
rows, err := repo.BulkUpdate(ctx, []int64{opencode.ID, openaiOllama.ID, anthropicOllama.ID}, service.AccountBulkUpdate{
ProxyID: &proxy.ID,
})
require.NoError(t, err)
require.Equal(t, int64(3), rows)
opencodeLoaded, err := repo.GetByID(ctx, opencode.ID)
require.NoError(t, err)
require.Equal(t, true, opencodeLoaded.Extra[service.OpenCodeGoUsageAutoRefreshExtraKey])
require.NotContains(t, opencodeLoaded.Extra, service.OpenCodeGoUsageSnapshotExtraKey)
for _, id := range []int64{openaiOllama.ID, anthropicOllama.ID} {
loaded, err := repo.GetByID(ctx, id)
require.NoError(t, err)
require.Equal(t, "cipher:wos-session=fixture", loaded.Extra[service.OllamaCloudUsageSessionExtraKey])
require.Equal(t, true, loaded.Extra[service.OllamaCloudUsageAutoRefreshExtraKey])
require.NotContains(t, loaded.Extra, service.OllamaCloudUsageSnapshotExtraKey,
"OpenAI+Ollama 行的快照必须由 Ollama 分支清理,不能被 OpenCode 分支遮蔽")
}
// 同时改 proxy + api_key:OpenCode 行清开关+快照;Ollama 行清 session/开关/快照。
rows, err = repo.BulkUpdate(ctx, []int64{opencode.ID, openaiOllama.ID, anthropicOllama.ID}, service.AccountBulkUpdate{
ProxyID: &proxy2.ID,
Credentials: map[string]any{"api_key": "rotated-key"},
})
require.NoError(t, err)
require.Equal(t, int64(3), rows)
opencodeLoaded, err = repo.GetByID(ctx, opencode.ID)
require.NoError(t, err)
require.NotContains(t, opencodeLoaded.Extra, service.OpenCodeGoUsageAutoRefreshExtraKey)
require.NotContains(t, opencodeLoaded.Extra, service.OpenCodeGoUsageSnapshotExtraKey)
for _, id := range []int64{openaiOllama.ID, anthropicOllama.ID} {
loaded, err := repo.GetByID(ctx, id)
require.NoError(t, err)
require.NotContains(t, loaded.Extra, service.OllamaCloudUsageSessionExtraKey)
require.NotContains(t, loaded.Extra, service.OllamaCloudUsageAutoRefreshExtraKey)
require.NotContains(t, loaded.Extra, service.OllamaCloudUsageSnapshotExtraKey)
}
}
// F2:UpdateCredentials 的 OpenCode 分支必须与通用身份变化不变量一致——
// 清 probe、OpenCode 两键以及不应跨身份残留的 Ollama session/auto/snapshot。
func TestUpdateCredentialsOpenCodeGoClearsAllManagedStateOnIdentityChange(t *testing.T) {
ctx := context.Background()
tx := testEntTx(t)
repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil)
now := time.Now().UTC()
account := mustCreateAccount(t, tx.Client(), &service.Account{
Name: "opencode-credentials", Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey,
Credentials: map[string]any{"api_key": "old-key", "base_url": "https://opencode.ai/zen/go/v1"},
Extra: openCodeGoManagedExtra(now),
})
require.NoError(t, repo.UpdateCredentials(ctx, account.ID, map[string]any{
"api_key": "new-key", "base_url": "https://opencode.ai/zen/go/v1",
}))
loaded, err := repo.GetByID(ctx, account.ID)
require.NoError(t, err)
require.NotContains(t, loaded.Extra, service.UpstreamBillingProbeExtraKey)
require.NotContains(t, loaded.Extra, service.OpenCodeGoUsageAutoRefreshExtraKey)
require.NotContains(t, loaded.Extra, service.OpenCodeGoUsageSnapshotExtraKey)
require.NotContains(t, loaded.Extra, service.OllamaCloudUsageSessionExtraKey)
require.NotContains(t, loaded.Extra, service.OllamaCloudUsageAutoRefreshExtraKey)
require.NotContains(t, loaded.Extra, service.OllamaCloudUsageSnapshotExtraKey)
}
// F3:新 credentials 缺 base_url(NULL)时 OpenCode 分支必须仍命中(NULL-safe),
// 否则 OpenCode 受管状态会残留。api_key 保持不变,专门压 NOT regex(NULL) 路径。
func TestUpdateCredentialsOpenCodeGoMissingBaseURLClearsManagedState(t *testing.T) {
ctx := context.Background()
tx := testEntTx(t)
repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil)
now := time.Now().UTC()
account := mustCreateAccount(t, tx.Client(), &service.Account{
Name: "opencode-missing-base-url", Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey,
Credentials: map[string]any{"api_key": "old-key", "base_url": "https://opencode.ai/zen/go/v1"},
Extra: map[string]any{
service.OpenCodeGoUsageAutoRefreshExtraKey: true,
service.OpenCodeGoUsageSnapshotExtraKey: map[string]any{
"status": service.OpenCodeGoUsageStatusOK, "last_attempt_at": now, "next_refresh_at": now.Add(time.Hour),
},
},
})
require.NoError(t, repo.UpdateCredentials(ctx, account.ID, map[string]any{
"api_key": "old-key",
}))
loaded, err := repo.GetByID(ctx, account.ID)
require.NoError(t, err)
require.NotContains(t, loaded.Extra, service.OpenCodeGoUsageAutoRefreshExtraKey)
require.NotContains(t, loaded.Extra, service.OpenCodeGoUsageSnapshotExtraKey)
}
// F3 延伸:BulkUpdate 移除 base_url(null)时 opencode 受管键必须清除,
// 否则 NOT regex(NULL) 为 NULL 会令 WHEN 不命中而残留。
func TestBulkUpdateOpenCodeGoRemovedBaseURLClearsManagedState(t *testing.T) {
ctx := context.Background()
tx := testEntTx(t)
repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil)
now := time.Now().UTC()
account := mustCreateAccount(t, tx.Client(), &service.Account{
Name: "opencode-bulk-remove-base-url", Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey,
Credentials: map[string]any{"api_key": "old-key", "base_url": "https://opencode.ai/zen/go/v1"},
Extra: map[string]any{
service.OpenCodeGoUsageAutoRefreshExtraKey: true,
service.OpenCodeGoUsageSnapshotExtraKey: map[string]any{
"status": service.OpenCodeGoUsageStatusOK, "last_attempt_at": now, "next_refresh_at": now.Add(time.Hour),
},
},
})
rows, err := repo.BulkUpdate(ctx, []int64{account.ID}, service.AccountBulkUpdate{
Credentials: map[string]any{"base_url": nil},
})
require.NoError(t, err)
require.Equal(t, int64(1), rows)
loaded, err := repo.GetByID(ctx, account.ID)
require.NoError(t, err)
require.NotContains(t, loaded.Extra, service.OpenCodeGoUsageAutoRefreshExtraKey)
require.NotContains(t, loaded.Extra, service.OpenCodeGoUsageSnapshotExtraKey)
}
// F6:SQL eligible 判定与 Go 侧对齐,接受显式默认端口 :443。
func TestOpenCodeGoUsageEligibleSQLAcceptsDefaultPort443(t *testing.T) {
ctx := context.Background()
tx := testEntTx(t)
repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil)
account := mustCreateAccount(t, tx.Client(), &service.Account{
Name: "opencode-port-443", Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey,
Credentials: map[string]any{"api_key": "port-key", "base_url": "https://opencode.ai:443/zen/go/v1"},
Extra: map[string]any{},
})
// ListOpenCodeGoUsageGroupAccounts 走 opencodeGoUsageEligibleSQL,能解析到
// :443 行即证明 SQL 正则与 Go 判定一致。
groups, err := repo.ListOpenCodeGoUsageGroupAccounts(ctx, []*service.Account{account})
require.NoError(t, err)
require.Len(t, groups, 1)
require.Equal(t, account.ID, groups[0].ID)
}
// F7:生产故障路径——前端脱敏普通编辑(incoming Extra 不含 OpenCode 受管键)经真实
// repo.Update 走 lockAndMergeAccountProbeExtra 的 SELECT/Scan/UPDATE 后,custom 生效
// 且两个受管键从锁定 DB 行回填;incoming Extra 伪造受管键时以锁定 DB 值为准。
// 旧代码(无 opencode 三列与回填分支)下:脱敏编辑会直接抹掉 DB 里的开关与快照,
// 伪造编辑会把伪造值原样写入,两条断言都会失败。
func TestUpdateOpenCodeGoManagedStateSurvivesDesensitizedEdit(t *testing.T) {
ctx := context.Background()
tx := testEntTx(t)
repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil)
now := time.Now().UTC()
proxy := mustCreateProxy(t, tx.Client(), &service.Proxy{
Name: "opencode-edit-proxy", Protocol: "http", Host: "127.0.0.1", Port: 3128,
Username: "user", Password: "pass", Status: service.StatusActive,
})
account := mustCreateAccount(t, tx.Client(), &service.Account{
Name: "opencode-desensitized-edit", Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey,
Credentials: map[string]any{"api_key": "edit-key", "base_url": "https://opencode.ai/zen/go/v1"},
ProxyID: &proxy.ID,
Extra: map[string]any{
service.OpenCodeGoUsageAutoRefreshExtraKey: true,
service.OpenCodeGoUsageSnapshotExtraKey: map[string]any{
"status": service.OpenCodeGoUsageStatusOK, "last_attempt_at": now, "next_refresh_at": now.Add(time.Hour),
},
"custom": "original",
},
})
// 前端脱敏普通编辑:incoming Extra 不含两个受管键,只改 custom;base_url 用
// :443 + 尾斜杠变体证明 normalized 身份判定(同 ID/platform/type/api_key/proxy)。
edit, err := repo.GetByID(ctx, account.ID)
require.NoError(t, err)
edit.Credentials = map[string]any{"api_key": "edit-key", "base_url": "https://opencode.ai:443/zen/go/v1/"}
edit.Extra = map[string]any{"custom": "edited"}
require.NoError(t, repo.Update(ctx, edit))
loaded, err := repo.GetByID(ctx, account.ID)
require.NoError(t, err)
require.Equal(t, "edited", loaded.Extra["custom"])
require.Equal(t, true, loaded.Extra[service.OpenCodeGoUsageAutoRefreshExtraKey],
"脱敏编辑不得抹掉 auto_refresh")
snapshot, ok := loaded.Extra[service.OpenCodeGoUsageSnapshotExtraKey].(map[string]any)
require.True(t, ok)
require.Equal(t, service.OpenCodeGoUsageStatusOK, snapshot["status"], "脱敏编辑不得抹掉快照")
// incoming Extra 伪造受管键:锁定 DB 值必须胜出。
forged, err := repo.GetByID(ctx, account.ID)
require.NoError(t, err)
forged.Extra = map[string]any{
service.OpenCodeGoUsageAutoRefreshExtraKey: false,
service.OpenCodeGoUsageSnapshotExtraKey: map[string]any{"status": "forged"},
"custom": "edited-again",
}
require.NoError(t, repo.Update(ctx, forged))
loaded, err = repo.GetByID(ctx, account.ID)
require.NoError(t, err)
require.Equal(t, "edited-again", loaded.Extra["custom"])
require.Equal(t, true, loaded.Extra[service.OpenCodeGoUsageAutoRefreshExtraKey],
"伪造 auto_refresh 必须以锁定 DB 值为准")
snapshot, ok = loaded.Extra[service.OpenCodeGoUsageSnapshotExtraKey].(map[string]any)
require.True(t, ok)
require.Equal(t, service.OpenCodeGoUsageStatusOK, snapshot["status"], "伪造快照必须以锁定 DB 值为准")
}
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"regexp"
"strings"
"testing"
"time"
@@ -66,3 +67,329 @@ func TestUpdateOpenCodeGoUsageSnapshotWritesSnapshotOnly(t *testing.T) {
require.NoError(t, err)
require.NoError(t, mock.ExpectationsWereMet())
}
// lockAndMergeAccountProbeExtra 的 SELECT 现在多出三列(opencode 组身份 / 开关 / 快照),
// 供通用 Update 路径做 OpenCode 受管键的原子回填。
func openCodeGoMergeMockColumns() []string {
return []string{
"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged",
"enabled", "rate_sync_enabled", "snapshot",
"ollama_session", "ollama_auto", "ollama_snapshot",
"opencode_group_unchanged", "opencode_auto", "opencode_snapshot",
}
}
func openCodeGoSnapshotJSON() string {
previousAttempt := time.Date(2026, time.August, 15, 8, 0, 0, 0, time.UTC)
raw, err := json.Marshal(&service.OpenCodeGoUsageSnapshot{
Status: service.OpenCodeGoUsageStatusOK,
LastAttemptAt: previousAttempt,
NextRefreshAt: previousAttempt.Add(time.Hour),
})
if err != nil {
panic(err)
}
return string(raw)
}
func TestLockAndMergeAccountProbeExtraPreservesOpenCodeGoManagedState(t *testing.T) {
tests := []struct {
name string
account *service.Account
groupUnchanged bool
proxyUnchanged bool
databaseAuto string
databaseSnapshot string
inputExtra map[string]any
wantAuto any // nil 表示键不存在
wantSnapshot any // nil 表示键不存在
wantSnapshotCheck bool
}{
{
name: "identity unchanged restores both managed keys over forged input",
account: openCodeGoUsageRepositoryAccount(),
groupUnchanged: true,
proxyUnchanged: true,
databaseAuto: "true",
databaseSnapshot: openCodeGoSnapshotJSON(),
inputExtra: map[string]any{
service.OpenCodeGoUsageAutoRefreshExtraKey: false,
service.OpenCodeGoUsageSnapshotExtraKey: map[string]any{"status": "forged"},
},
wantAuto: true,
wantSnapshotCheck: true,
},
{
name: "identity unchanged keeps disabled switch from database",
account: openCodeGoUsageRepositoryAccount(),
groupUnchanged: true,
proxyUnchanged: true,
databaseAuto: "false",
databaseSnapshot: openCodeGoSnapshotJSON(),
inputExtra: map[string]any{
service.OpenCodeGoUsageAutoRefreshExtraKey: true,
},
wantAuto: false,
wantSnapshotCheck: true,
},
{
name: "api key changed clears both managed keys",
account: openCodeGoUsageRepositoryAccount(),
groupUnchanged: false,
proxyUnchanged: true,
databaseAuto: "true",
databaseSnapshot: openCodeGoSnapshotJSON(),
inputExtra: map[string]any{
service.OpenCodeGoUsageAutoRefreshExtraKey: true,
service.OpenCodeGoUsageSnapshotExtraKey: map[string]any{"status": "stale"},
},
},
{
name: "base url no longer eligible clears both managed keys",
account: openCodeGoUsageRepositoryAccount(),
groupUnchanged: false,
proxyUnchanged: true,
databaseAuto: "true",
databaseSnapshot: openCodeGoSnapshotJSON(),
inputExtra: map[string]any{"custom": "value"},
},
{
name: "proxy changed invalidates snapshot but keeps auto-refresh switch",
account: openCodeGoUsageRepositoryAccount(),
groupUnchanged: true,
proxyUnchanged: false,
databaseAuto: "true",
databaseSnapshot: openCodeGoSnapshotJSON(),
inputExtra: map[string]any{"custom": "value"},
wantAuto: true,
},
{
name: "no longer eligible account never inherits managed keys",
account: func() *service.Account {
account := openCodeGoUsageRepositoryAccount()
account.Type = service.AccountTypeOAuth
return account
}(),
groupUnchanged: false,
proxyUnchanged: true,
databaseAuto: "true",
databaseSnapshot: openCodeGoSnapshotJSON(),
inputExtra: map[string]any{
service.OpenCodeGoUsageAutoRefreshExtraKey: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client, mock := newOllamaCloudUsageRepositoryTestClient(t)
credentials, err := json.Marshal(normalizeJSONMap(tt.account.Credentials))
require.NoError(t, err)
mock.ExpectQuery(`(?s)`+regexp.QuoteMeta("SELECT")+`.*`+regexp.QuoteMeta("FOR NO KEY UPDATE")).
WithArgs(tt.account.ID, tt.account.Platform, tt.account.Type, string(credentials), nil).
WillReturnRows(sqlmock.NewRows(openCodeGoMergeMockColumns()).
AddRow(false, false, tt.proxyUnchanged, nil, nil, nil, nil, nil, nil, tt.groupUnchanged, tt.databaseAuto, tt.databaseSnapshot))
got, err := lockAndMergeAccountProbeExtra(context.Background(), client, tt.account, nil, nil)
require.NoError(t, err)
if tt.wantAuto == nil {
require.NotContains(t, got, service.OpenCodeGoUsageAutoRefreshExtraKey)
} else {
require.Equal(t, tt.wantAuto, got[service.OpenCodeGoUsageAutoRefreshExtraKey])
}
if tt.wantSnapshotCheck {
snapshot, ok := got[service.OpenCodeGoUsageSnapshotExtraKey].(map[string]any)
require.True(t, ok)
require.Equal(t, service.OpenCodeGoUsageStatusOK, snapshot["status"])
} else if tt.wantSnapshot == nil {
require.NotContains(t, got, service.OpenCodeGoUsageSnapshotExtraKey)
} else {
require.Equal(t, tt.wantSnapshot, got[service.OpenCodeGoUsageSnapshotExtraKey])
}
require.NoError(t, mock.ExpectationsWereMet())
})
}
}
func TestUpdateCredentialsOpenCodeGoIdentityChangeClearsManagedExtra(t *testing.T) {
client, mock := newOllamaCloudUsageRepositoryTestClient(t)
mock.ExpectBegin()
// opencode 清理分支必须文本上先于 ollama 分支出现,否则 opencode 行的
// api_key/base_url 变化会被先求值的 Ollama 分支遮蔽。
mock.ExpectExec(`(?s)UPDATE accounts.*- 'opencode_go_usage_auto_refresh'.*- 'opencode_go_usage_snapshot'.*- 'ollama_cloud_usage_session'`).
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())
}
// opencode → ollama 的 base_url 跨域变化同样必须清除 opencode 受管键(不再 eligible)。
func TestUpdateCredentialsOpenCodeGoToOllamaCrossOverClearsManagedExtra(t *testing.T) {
client, mock := newOllamaCloudUsageRepositoryTestClient(t)
mock.ExpectBegin()
mock.ExpectExec(`(?s)UPDATE accounts.*opencode_go_usage_auto_refresh.*opencode_go_usage_snapshot`).
WithArgs(`{"api_key":"same-key","base_url":"https://ollama.com/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": "same-key", "base_url": "https://ollama.com/v1",
})
require.NoError(t, err)
require.NoError(t, mock.ExpectationsWereMet())
}
// OpenCode 清理分支必须带顶层 credentials DISTINCT 守卫:凭证未变化时不能误清受管键。
func TestUpdateCredentialsOpenCodeGoCleanupRequiresChangedCredentials(t *testing.T) {
client, mock := newOllamaCloudUsageRepositoryTestClient(t)
mock.ExpectBegin()
mock.ExpectExec(`(?s)UPDATE accounts.*CASE.*AND credentials IS DISTINCT FROM \$1::jsonb`).
WithArgs(`{"api_key":"same-key","base_url":"https://relay.example.com/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": "same-key", "base_url": "https://relay.example.com/v1",
})
require.NoError(t, err)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestBulkUpdateOpenCodeGoIdentityCleanupIsValueConditional(t *testing.T) {
exec := &recordingSQLExecutor{result: rowsAffectedResult(1)}
repo := newAccountRepositoryWithSQL(nil, exec, nil)
_, err := repo.BulkUpdate(context.Background(), []int64{17}, service.AccountBulkUpdate{
Credentials: map[string]any{"api_key": "new-key"},
})
require.NoError(t, err)
require.NotEmpty(t, exec.execQueries)
query := normalizeSQLWhitespace(exec.execQueries[0])
// OpenCode 分支必须出现在 Ollama 分支之前,且只在旧行是 opencode base URL 时触发。
require.Contains(t, query, "platform = 'openai' AND type = 'apikey'")
require.Contains(t, query, "- 'opencode_go_usage_auto_refresh' - 'opencode_go_usage_snapshot'")
opencodeBranch := strings.Index(query, "opencode_go_usage_auto_refresh")
ollamaBranch := strings.Index(query, "ollama_cloud_usage_auto_refresh")
require.NotEqual(t, -1, opencodeBranch)
require.NotEqual(t, -1, ollamaBranch)
require.Less(t, opencodeBranch, ollamaBranch)
}
// F1 回归:OpenCode eligible 判定必须包含旧行 opencode base URL,否则 OpenAI+Ollama
// 行在代理变化时会先命中 OpenCode 分支而遮蔽 Ollama 快照清理。这里断言 OpenCode 分支
// 的 WHEN 携带 opencode 正则(与 Ollama 正则互斥),真实行为由 integration 测试覆盖。
func TestBulkUpdateOpenCodeGoEligiblePredicateIncludesBaseURL(t *testing.T) {
exec := &recordingSQLExecutor{result: rowsAffectedResult(1)}
repo := newAccountRepositoryWithSQL(nil, exec, nil)
proxyID := int64(9)
_, err := repo.BulkUpdate(context.Background(), []int64{17}, service.AccountBulkUpdate{
ProxyID: &proxyID,
})
require.NoError(t, err)
require.NotEmpty(t, exec.execQueries)
query := normalizeSQLWhitespace(exec.execQueries[0])
// 第一个 WHEN 分支是 OpenCode 快照失效分支(代理变化),其 eligible 判定必须
// 包含 opencode base URL 正则,使 OpenAI+Ollama 行无法命中该分支。
caseStart := strings.Index(query, "CASE")
firstThen := strings.Index(query, "THEN")
require.NotEqual(t, -1, caseStart)
require.NotEqual(t, -1, firstThen)
require.Less(t, caseStart, firstThen)
firstWhen := query[caseStart:firstThen]
require.Contains(t, firstWhen, "[oO][pP][eE][nN][cC][oO][dD][eE]")
require.Contains(t, firstWhen, "credentials ->> 'base_url'")
require.NotContains(t, firstWhen, "[oO][lL][lL][aA][mM][aA]")
}
// F3 延伸:BulkUpdate 的 opencode base_url 变化子句同样必须 NULL-safe——新 base_url
// 缺失/为 null 时 regex(NULL) 为 NULL,NOT NULL 仍为 NULL 会令 WHEN 不命中而残留
// OpenCode 状态;IS NOT TRUE 把 NULL 视为不匹配。
func TestBulkUpdateOpenCodeGoBaseURLClauseIsNullSafe(t *testing.T) {
exec := &recordingSQLExecutor{result: rowsAffectedResult(1)}
repo := newAccountRepositoryWithSQL(nil, exec, nil)
_, err := repo.BulkUpdate(context.Background(), []int64{17}, service.AccountBulkUpdate{
Credentials: map[string]any{"base_url": nil},
})
require.NoError(t, err)
require.NotEmpty(t, exec.execQueries)
query := normalizeSQLWhitespace(exec.execQueries[0])
require.Contains(t, query, "IS NOT TRUE")
require.NotContains(t, query, "AND NOT btrim(credentials ->> 'base_url')")
}
// F6 回归:SQL 正则与 service.isOpenCodeGoBaseURL 对齐,接受显式默认端口 :443。
// 该正则同时用于 eligible 判定与身份清理,Go 侧接受而 SQL 侧拒绝会导致漏清/漏组。
func TestOpenCodeGoBaseURLRegexSQLAcceptsDefaultPort443(t *testing.T) {
re := regexp.MustCompile(opencodeGoBaseURLRegexSQL)
for _, url := range []string{
"https://opencode.ai/zen/go/v1",
"https://opencode.ai/zen/go/v1/",
"https://opencode.ai:443/zen/go/v1",
"https://opencode.ai:443/zen/go/v1/",
"HTTPS://OPENCODE.AI:443/ZEN/GO/V1",
} {
require.True(t, re.MatchString(url), "SQL regex must accept %s", url)
}
for _, url := range []string{
"https://opencode.ai:444/zen/go/v1",
"https://opencode.ai/v1",
"https://ollama.com/zen/go/v1",
"https://opencode.ai/zen/go/v1?x=1",
} {
require.False(t, re.MatchString(url), "SQL regex must reject %s", url)
}
}
func TestBulkUpdateOpenCodeGoProxyChangeClearsSnapshotOnly(t *testing.T) {
exec := &recordingSQLExecutor{result: rowsAffectedResult(1)}
repo := newAccountRepositoryWithSQL(nil, exec, nil)
proxyID := int64(9)
_, err := repo.BulkUpdate(context.Background(), []int64{17}, service.AccountBulkUpdate{
ProxyID: &proxyID,
})
require.NoError(t, err)
require.NotEmpty(t, exec.execQueries)
query := normalizeSQLWhitespace(exec.execQueries[0])
require.Contains(t, query, "- 'opencode_go_usage_snapshot'")
require.NotContains(t, query, "- 'opencode_go_usage_auto_refresh'")
}
func TestInvalidateProxyProbeSnapshotsClearsOpenCodeGoSnapshot(t *testing.T) {
client, mock := newOllamaCloudUsageRepositoryTestClient(t)
mock.ExpectQuery(`(?s)UPDATE accounts.*opencode_go_usage_snapshot.*RETURNING id`).
WithArgs(int64(9)).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(int64(17)))
ids, err := invalidateProxyProbeSnapshots(context.Background(), client, 9)
require.NoError(t, err)
require.Equal(t, []int64{17}, ids)
require.NoError(t, mock.ExpectationsWereMet())
}
@@ -81,8 +81,8 @@ func TestLockAndMergeAccountProbeExtraUsesCurrentDatabaseSnapshot(t *testing.T)
mock.ExpectQuery(`(?s)`+regexp.QuoteMeta("SELECT")+`.*`+regexp.QuoteMeta("FOR NO KEY UPDATE")).
WithArgs(int64(27), service.PlatformOpenAI, service.AccountTypeAPIKey, `{"api_key":"sk-test"}`, nil).
WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot"}).
AddRow(tt.identityUnchanged, false, true, tt.databaseEnabled, nil, tt.databaseSnapshot, nil, nil, nil))
WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot", "opencode_group_unchanged", "opencode_auto", "opencode_snapshot"}).
AddRow(tt.identityUnchanged, false, true, tt.databaseEnabled, nil, tt.databaseSnapshot, nil, nil, nil, false, nil, nil))
account := &service.Account{
ID: 27,
@@ -172,8 +172,8 @@ func TestLockAndMergeAccountProbeExtraNeverInfersProbeFromRateSync(t *testing.T)
mock.ExpectQuery(`(?s)`+regexp.QuoteMeta("SELECT")+`.*`+regexp.QuoteMeta("FOR NO KEY UPDATE")).
WithArgs(int64(31), service.PlatformOpenAI, service.AccountTypeAPIKey, `{"api_key":"sk-test"}`, nil).
WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot"}).
AddRow(true, false, true, tt.databaseEnabled, tt.databaseRateSync, nil, nil, nil, nil))
WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot", "opencode_group_unchanged", "opencode_auto", "opencode_snapshot"}).
AddRow(true, false, true, tt.databaseEnabled, tt.databaseRateSync, nil, nil, nil, nil, false, nil, nil))
account := &service.Account{
ID: 31,
@@ -211,8 +211,8 @@ func TestLockAndMergeAccountProbeExtraProtectsOllamaManagedFields(t *testing.T)
mock.ExpectQuery(`(?s)`+regexp.QuoteMeta("SELECT")+`.*`+regexp.QuoteMeta("FOR NO KEY UPDATE")).
WithArgs(int64(29), service.PlatformAnthropic, service.AccountTypeAPIKey, `{"api_key":"key","base_url":"https://ollama.com"}`, nil).
WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot"}).
AddRow(identityUnchanged, identityUnchanged, true, nil, nil, nil, []byte(`"local-ciphertext"`), []byte(`true`), []byte(`{"status":"ok"}`)))
WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot", "opencode_group_unchanged", "opencode_auto", "opencode_snapshot"}).
AddRow(identityUnchanged, identityUnchanged, true, nil, nil, nil, []byte(`"local-ciphertext"`), []byte(`true`), []byte(`{"status":"ok"}`), false, nil, nil))
account := &service.Account{
ID: 29, Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey,
@@ -371,8 +371,8 @@ func TestUpdateWithAccountBillingSettingsRollsBackWhenOutboxFails(t *testing.T)
mock.ExpectBegin()
mock.ExpectQuery(`(?s)`+regexp.QuoteMeta("SELECT")+`.*`+regexp.QuoteMeta("FOR NO KEY UPDATE")).
WithArgs(int64(27), service.PlatformOpenAI, service.AccountTypeAPIKey, `{"api_key":"sk-test"}`, nil).
WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot"}).
AddRow(true, false, true, []byte(`true`), []byte(`true`), []byte(`{"status":"ok"}`), nil, nil, nil))
WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot", "opencode_group_unchanged", "opencode_auto", "opencode_snapshot"}).
AddRow(true, false, true, []byte(`true`), []byte(`true`), []byte(`{"status":"ok"}`), nil, nil, nil, false, nil, nil))
mock.ExpectExec(`(?s)UPDATE .*accounts.*SET.*WHERE .*id.*`).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectQuery(`(?s)SELECT .* FROM "accounts" WHERE "id" = \$1`).
+5 -1
View File
@@ -226,7 +226,8 @@ func invalidateProxyProbeSnapshots(ctx context.Context, exec sqlExecutor, proxyI
UPDATE accounts
SET extra = COALESCE(extra, '{}'::jsonb)
- 'upstream_billing_probe'
- 'ollama_cloud_usage_snapshot',
- 'ollama_cloud_usage_snapshot'
- 'opencode_go_usage_snapshot',
updated_at = NOW()
WHERE proxy_id = $1
AND type = 'apikey'
@@ -236,6 +237,9 @@ func invalidateProxyProbeSnapshots(ctx context.Context, exec sqlExecutor, proxyI
OR (platform IN (`+ollamaCloudUsagePlatformsSQL+`)
AND extra ? 'ollama_cloud_usage_snapshot'
AND extra -> 'ollama_cloud_usage_snapshot' <> 'null'::jsonb)
OR (platform = 'openai'
AND extra ? 'opencode_go_usage_snapshot'
AND extra -> 'opencode_go_usage_snapshot' <> 'null'::jsonb)
)
AND deleted_at IS NULL
RETURNING id
+22
View File
@@ -418,6 +418,8 @@ func buildAccountForCreate(input *CreateAccountInput, accountExtra map[string]an
delete(accountExtra, OllamaCloudUsageSessionExtraKey)
delete(accountExtra, OllamaCloudUsageAutoRefreshExtraKey)
delete(accountExtra, OllamaCloudUsageSnapshotExtraKey)
delete(accountExtra, OpenCodeGoUsageAutoRefreshExtraKey)
delete(accountExtra, OpenCodeGoUsageSnapshotExtraKey)
accountExtra = prepareCodexFingerprintExtraForCreate(input.Platform, input.Type, accountExtra)
account := &Account{
Name: input.Name,
@@ -598,6 +600,7 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
}
previousProbeIdentity := upstreamBillingProbeIdentity(account)
previousOllamaUsageIdentity := ollamaCloudUsageIdentity(account)
previousOpenCodeUsageIdentity := openCodeGoUsageIdentity(account)
// 安全/身份不变量(影子账号):通用更新路径被 edit/re-auth/refresh/batch 共用,
// 必须在此守住,否则仅在创建时的保证可被这些路径绕过。
if account.IsCredentialShadow() {
@@ -674,6 +677,8 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
delete(normalizedExtra, OllamaCloudUsageSessionExtraKey)
delete(normalizedExtra, OllamaCloudUsageAutoRefreshExtraKey)
delete(normalizedExtra, OllamaCloudUsageSnapshotExtraKey)
delete(normalizedExtra, OpenCodeGoUsageAutoRefreshExtraKey)
delete(normalizedExtra, OpenCodeGoUsageSnapshotExtraKey)
// 保留配额用量和专用服务受管字段,防止普通账号编辑意外覆盖。
for _, key := range []string{
"quota_used",
@@ -689,6 +694,8 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
OllamaCloudUsageAutoRefreshExtraKey,
OllamaCloudUsageSnapshotExtraKey,
OpenAIAutoResetCreditStateExtraKey,
OpenCodeGoUsageAutoRefreshExtraKey,
OpenCodeGoUsageSnapshotExtraKey,
} {
if v, ok := account.Extra[key]; ok {
normalizedExtra[key] = v
@@ -775,6 +782,17 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
delete(account.Extra, OllamaCloudUsageSnapshotExtraKey)
}
}
// OpenCode Go 受管键:身份改变或不再 eligible 时随本次写入清除,防止跨组污染。
// (代理变化只失效快照而保留开关,由 repository 合并层在锁定的 DB 行上裁决。)
if account.Extra != nil {
if !IsOpenCodeGoUsageAccount(account) {
delete(account.Extra, OpenCodeGoUsageAutoRefreshExtraKey)
delete(account.Extra, OpenCodeGoUsageSnapshotExtraKey)
} else if !reflect.DeepEqual(previousOpenCodeUsageIdentity, openCodeGoUsageIdentity(account)) {
delete(account.Extra, OpenCodeGoUsageAutoRefreshExtraKey)
delete(account.Extra, OpenCodeGoUsageSnapshotExtraKey)
}
}
// 只在指针非 nil 时更新 Concurrency(支持设置为 0)
if input.Concurrency != nil {
account.Concurrency = normalizeAccountConcurrency(account.Platform, account.Type, *input.Concurrency)
@@ -910,6 +928,8 @@ func (s *adminServiceImpl) UpdateAccountExtra(ctx context.Context, id int64, upd
delete(updates, OllamaCloudUsageSessionExtraKey)
delete(updates, OllamaCloudUsageAutoRefreshExtraKey)
delete(updates, OllamaCloudUsageSnapshotExtraKey)
delete(updates, OpenCodeGoUsageAutoRefreshExtraKey)
delete(updates, OpenCodeGoUsageSnapshotExtraKey)
if _, exists := updates[openAILongContextBillingEnabledKey]; exists {
account, err := s.accountRepo.GetByID(ctx, id)
if err != nil {
@@ -937,6 +957,8 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp
delete(input.Extra, OllamaCloudUsageSessionExtraKey)
delete(input.Extra, OllamaCloudUsageAutoRefreshExtraKey)
delete(input.Extra, OllamaCloudUsageSnapshotExtraKey)
delete(input.Extra, OpenCodeGoUsageAutoRefreshExtraKey)
delete(input.Extra, OpenCodeGoUsageSnapshotExtraKey)
if len(input.AccountIDs) == 0 && input.Filters != nil {
accountIDs, err := s.resolveBulkUpdateTargetIDs(ctx, input.Filters)
@@ -4,6 +4,7 @@ import (
"context"
"net/http"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
"github.com/stretchr/testify/require"
@@ -749,3 +750,166 @@ func TestBulkUpdateAccountsKeepsProbeSnapshotForUnrelatedCredentials(t *testing.
require.Len(t, repo.bulkUpdates, 1)
require.NotContains(t, repo.bulkUpdates[0].Extra, UpstreamBillingProbeExtraKey)
}
func openCodeGoAdminUsageAccount(id int64, apiKey string) *Account {
previousAttempt := time.Date(2026, time.August, 15, 8, 0, 0, 0, time.UTC)
return &Account{
ID: id,
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Status: StatusActive,
Credentials: map[string]any{
"api_key": apiKey,
"base_url": "https://opencode.ai/zen/go/v1",
},
Extra: map[string]any{
OpenCodeGoUsageAutoRefreshExtraKey: true,
OpenCodeGoUsageSnapshotExtraKey: &OpenCodeGoUsageSnapshot{
Status: OpenCodeGoUsageStatusOK,
LastAttemptAt: previousAttempt,
NextRefreshAt: previousAttempt.Add(time.Hour),
},
},
}
}
// 普通账号编辑(脱敏 extra 不带受管键)不得丢 OpenCode Go 受管状态。
func TestUpdateAccountPreservesOpenCodeGoManagedStateForUnrelatedEdit(t *testing.T) {
accountID := int64(210)
repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
accountID: openCodeGoAdminUsageAccount(accountID, "sk-1"),
}}
svc := &adminServiceImpl{accountRepo: repo}
updated, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
Extra: map[string]any{"custom": "value"},
})
require.NoError(t, err)
require.Equal(t, true, updated.Extra[OpenCodeGoUsageAutoRefreshExtraKey])
snapshot, ok := updated.Extra[OpenCodeGoUsageSnapshotExtraKey].(*OpenCodeGoUsageSnapshot)
require.True(t, ok)
require.Equal(t, OpenCodeGoUsageStatusOK, snapshot.Status)
require.Equal(t, "value", updated.Extra["custom"])
}
// 客户端在 extra 里伪造受管键必须被丢弃,以数据库中的受管状态为准。
func TestUpdateAccountRejectsInjectedOpenCodeGoManagedKeys(t *testing.T) {
accountID := int64(211)
repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
accountID: openCodeGoAdminUsageAccount(accountID, "sk-1"),
}}
svc := &adminServiceImpl{accountRepo: repo}
updated, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
Extra: map[string]any{
OpenCodeGoUsageAutoRefreshExtraKey: false,
OpenCodeGoUsageSnapshotExtraKey: map[string]any{"status": "forged"},
"custom": "value",
},
})
require.NoError(t, err)
require.Equal(t, true, updated.Extra[OpenCodeGoUsageAutoRefreshExtraKey])
snapshot, ok := updated.Extra[OpenCodeGoUsageSnapshotExtraKey].(*OpenCodeGoUsageSnapshot)
require.True(t, ok)
require.Equal(t, OpenCodeGoUsageStatusOK, snapshot.Status)
require.Equal(t, "value", updated.Extra["custom"])
}
// OpenCode 身份改变(api_key / base_url / type)必须清除旧受管状态,防止跨组污染。
func TestUpdateAccountClearsOpenCodeGoManagedStateWhenIdentityChanges(t *testing.T) {
tests := []struct {
name string
input *UpdateAccountInput
}{
{name: "api key", input: &UpdateAccountInput{Credentials: map[string]any{"api_key": "sk-new"}}},
{name: "base url", input: &UpdateAccountInput{Credentials: map[string]any{"base_url": "https://api.openai.com/v1"}}},
{name: "type", input: &UpdateAccountInput{Type: AccountTypeOAuth}},
}
for i, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
accountID := int64(220 + i)
repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
accountID: openCodeGoAdminUsageAccount(accountID, "sk-1"),
}}
updated, err := (&adminServiceImpl{accountRepo: repo}).UpdateAccount(context.Background(), accountID, tt.input)
require.NoError(t, err)
require.NotContains(t, updated.Extra, OpenCodeGoUsageAutoRefreshExtraKey)
require.NotContains(t, updated.Extra, OpenCodeGoUsageSnapshotExtraKey)
})
}
}
// UpdateAccountExtra 是 key 级合并入口,伪造的受管键必须被剥离。
func TestUpdateAccountExtraDropsOpenCodeGoManagedKeys(t *testing.T) {
accountID := int64(230)
repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
accountID: openCodeGoAdminUsageAccount(accountID, "sk-1"),
}}
svc := &adminServiceImpl{accountRepo: repo}
err := svc.UpdateAccountExtra(context.Background(), accountID, map[string]any{
OpenCodeGoUsageAutoRefreshExtraKey: false,
OpenCodeGoUsageSnapshotExtraKey: map[string]any{"status": "forged"},
"custom": "value",
})
require.NoError(t, err)
require.Len(t, repo.updates[accountID], 1)
persisted := repo.updates[accountID][0]
require.NotContains(t, persisted, OpenCodeGoUsageAutoRefreshExtraKey)
require.NotContains(t, persisted, OpenCodeGoUsageSnapshotExtraKey)
require.Equal(t, "value", persisted["custom"])
}
// 批量更新同样不得接受伪造的受管键。
func TestBulkUpdateAccountsDropsOpenCodeGoManagedKeys(t *testing.T) {
accountID := int64(240)
repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
accountID: openCodeGoAdminUsageAccount(accountID, "sk-1"),
}}
svc := &adminServiceImpl{accountRepo: repo}
_, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{
AccountIDs: []int64{accountID},
Extra: map[string]any{
OpenCodeGoUsageAutoRefreshExtraKey: false,
OpenCodeGoUsageSnapshotExtraKey: map[string]any{"status": "forged"},
"custom": "value",
},
})
require.NoError(t, err)
require.Len(t, repo.bulkUpdates, 1)
require.NotContains(t, repo.bulkUpdates[0].Extra, OpenCodeGoUsageAutoRefreshExtraKey)
require.NotContains(t, repo.bulkUpdates[0].Extra, OpenCodeGoUsageSnapshotExtraKey)
require.Equal(t, "value", repo.bulkUpdates[0].Extra["custom"])
}
// 创建入口不接受伪造的受管键。
func TestCreateAccountDropsOpenCodeGoManagedKeys(t *testing.T) {
repo := &upstreamBillingProbeAccountRepo{}
svc := &adminServiceImpl{accountRepo: repo}
created, err := svc.CreateAccount(context.Background(), &CreateAccountInput{
Name: "opencode",
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Credentials: map[string]any{"api_key": "sk-1", "base_url": "https://opencode.ai/zen/go/v1"},
SkipDefaultGroupBind: true,
Extra: map[string]any{
OpenCodeGoUsageAutoRefreshExtraKey: true,
OpenCodeGoUsageSnapshotExtraKey: map[string]any{"status": "forged"},
"custom": "value",
},
})
require.NoError(t, err)
require.NotContains(t, created.Extra, OpenCodeGoUsageAutoRefreshExtraKey)
require.NotContains(t, created.Extra, OpenCodeGoUsageSnapshotExtraKey)
require.Equal(t, "value", created.Extra["custom"])
}
@@ -742,6 +742,16 @@ func TestIsOpenCodeGoUsageAccount(t *testing.T) {
account.Credentials["base_url"] = "https://opencode.ai/zen/go/v1/"
require.True(t, IsOpenCodeGoUsageAccount(account))
// explicit default port :443 is allowed (parsed.Host == hostname+":443")
account = base()
account.Credentials["base_url"] = "https://opencode.ai:443/zen/go/v1"
require.True(t, IsOpenCodeGoUsageAccount(account))
// non-default port is rejected
account = base()
account.Credentials["base_url"] = "https://opencode.ai:444/zen/go/v1"
require.False(t, IsOpenCodeGoUsageAccount(account))
// scheme/host/path are case-insensitive
account = base()
account.Credentials["base_url"] = "HTTPS://OPENCODE.AI/ZEN/GO/V1"