diff --git a/cmd/cyrene/websearch_action_data.go b/cmd/cyrene/websearch_action_data.go index abb73da..c5ea889 100644 --- a/cmd/cyrene/websearch_action_data.go +++ b/cmd/cyrene/websearch_action_data.go @@ -79,11 +79,20 @@ func (e webSearchActionEnv) saveProvider(_ context.Context, req pluginmgr.Action return nil, err } entry.Provider = name - next, err := e.applySelectionPatch(cur, values) + next, setPrimary, err := e.mergeSelectionPatch(cur, values) if err != nil { return nil, err } - if err := runtimeconfig.SaveWebSearch(e.path, runtimeconfig.UpsertProviderEntry(next, entry)); err != nil { + final := runtimeconfig.UpsertProviderEntry(next, entry) + // 🔴 校验必须在**并条之后**(顺序理由见 websearch_selection.go 的 mergeSelectionPatch): + // 主 provider 配没配齐,取决于**这次刚落进文件的凭据** —— 拿旧文件校验会把 + // 「第一次填 key + 同一次提交里选它」整笔回滚,连 key 一起丢。 + if setPrimary { + if err := e.checkPrimaryUsable(final, final.Provider); err != nil { + return nil, err + } + } + if err := runtimeconfig.SaveWebSearch(e.path, final); err != nil { e.log.Error("web-search panel config write failed", "path", e.path, "provider", name, "error", err) return nil, pluginmgr.NewActionError(actionCodeConfigWriteFailed, "面板配置写不进去(磁盘或权限问题),本次改动**没有**生效", http.StatusInternalServerError, err) @@ -97,8 +106,8 @@ func (e webSearchActionEnv) saveProvider(_ context.Context, req pluginmgr.Action "enabled": entry.IsEnabled(), // 文件级选择的**落盘后**取值(没给 primary/fallback 时也如实回显当前值): // 面板按它回显「谁先用」,也让「这次保存有没有动到选择」有可机读的答案。 - "primary": strings.ToLower(strings.TrimSpace(next.Provider)), - "fallback": selectionNames(next.Fallback), + "primary": strings.ToLower(strings.TrimSpace(final.Provider)), + "fallback": selectionNames(final.Fallback), }, nil } diff --git a/cmd/cyrene/websearch_selection.go b/cmd/cyrene/websearch_selection.go index d10fba1..0f256ea 100644 --- a/cmd/cyrene/websearch_selection.go +++ b/cmd/cyrene/websearch_selection.go @@ -44,24 +44,30 @@ const ( // fail-fast),所以在写盘之前就拒掉,而不是先写进盘里等着下次起不来。 const actionCodePrimaryNotConfigured = "primary_not_configured" -// applySelectionPatch 把表单里的主 provider / 降级链合并进文件(三态:**缺位 = 不改动**)。 +// mergeSelectionPatch 把表单里的主 provider / 降级链合并进文件(三态:**缺位 = 不改动**), +// 并回报「这次有没有给出 `primary`」—— **校验不在这里**。 // // 与其余字段同一套哨兵语义(websearch_action_data.go 的 saveProvider 注释): // `primary` 缺位 ⇒ 保留盘上的;给了值(含显式空串 / off / none)⇒ 采用 —— 空串与 // `off`/`none` 是本项目**唯一一套**关闭语义(config.IsWebSearchOffSentinel),面板不另造一套。 // -// 校验放在写盘之前(主 provider 必须已配齐),因为「先存下来、下次启动才发现起不来」 -// 是本项目明确要避免的失败形态(AGENTS §10 的「诚实性」与 design/27 §3 的 fail-fast)。 -func (e webSearchActionEnv) applySelectionPatch( +// 🔴 **顺序硬约束(缺陷修复 2026-10-07,真机报障)**:主 provider 的可用性校验必须由调用方 +// 在「**本次这条 provider 条目已经并进文件之后**」做(`checkPrimaryUsable(final, …)`)。 +// 本函数此前自己顺手校验,用的是**盘上的旧文件** ⇒ 面板上第一次给某家填 key、又在**同一次 +// 提交**里把它选为主 provider 时,校验看不到那个还没落盘的 key,于是整笔(**连同刚填的 key**) +// 被 400 `primary_not_configured` 回滚 —— 界面上表现为「怎么填都存不进去」,而盘上文件 +// mtime 一动不动(2026-10-07 09:50 日志连续两次 `plugin.saveProvider … code=primary_not_configured`)。 +// 调用点见 websearch_action_data.go 的 saveProvider:先 `UpsertProviderEntry`,再校验,最后写盘。 +func (e webSearchActionEnv) mergeSelectionPatch( f runtimeconfig.WebSearchFile, values map[string]any, -) (runtimeconfig.WebSearchFile, error) { +) (runtimeconfig.WebSearchFile, bool, error) { primary, setPrimary, err := selectionPrimary(values) if err != nil { - return f, err + return f, false, err } fallback, setFallback, err := selectionFallback(values) if err != nil { - return f, err + return f, false, err } if setPrimary { f.Provider = primary @@ -69,12 +75,7 @@ func (e webSearchActionEnv) applySelectionPatch( if setFallback { f.Fallback = fallback } - if setPrimary { - if err := e.checkPrimaryUsable(f, f.Provider); err != nil { - return f, err - } - } - return f, nil + return f, setPrimary, nil } // checkPrimaryUsable 判定一个主 provider 名在**落盘后的文件**里是否可用。 diff --git a/cmd/cyrene/websearch_selection_test.go b/cmd/cyrene/websearch_selection_test.go index 0a35117..5cbf0f4 100644 --- a/cmd/cyrene/websearch_selection_test.go +++ b/cmd/cyrene/websearch_selection_test.go @@ -280,3 +280,49 @@ func TestWebSearchProbeNotConfiguredIs400(t *testing.T) { t.Fatalf("err = %#v, want 400 %s", err, search.ProbeNotConfigured) } } + +// TestWebSearchSaveProviderAndPrimaryInOneSubmit 是 2026-10-07 真机报障的回归: +// 面板上第一次给某家填 key、又在**同一次提交**里把它选为主 provider,必须**一次成功**。 +// +// 缺陷原形:主 provider 的可用性校验拿的是**盘上的旧文件**(那条 provider 还没 key) +// ⇒ 整笔(**连同刚填的 key**)被 400 primary_not_configured 回滚,界面上表现为 +// 「怎么填都存不进去」,而盘上文件 mtime 一动不动。修法:校验移到「本次条目已并进 +// 文件之后」(websearch_action_data.go 的 saveProvider 先 UpsertProviderEntry 再 check)。 +func TestWebSearchSaveProviderAndPrimaryInOneSubmit(t *testing.T) { + env := newReloadTestEnv(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG) + + // 前置:盘上那条 zhipu 没有 key —— 面板第一次配置它时正是这个状态。 + if e, ok := findProviderEntry(env.loadFile(t), search.NameZhipu); !ok || strings.TrimSpace(e.APIKey) != "" { + t.Fatalf("前置不成立:盘上 zhipu 应存在且尚无 key(found=%v, key=%q)", ok, e.APIKey) + } + + if _, err := env.save(map[string]any{ + "provider": search.NameZhipu, "apiKey": "test-key-001", "enabled": true, + "primary": search.NameZhipu, + }); err != nil { + t.Fatalf("同一次提交里填 key + 选它当主家被拒(键与选择必须能一起落盘): %v", err) + } + + after := env.loadFile(t) + if got := strings.ToLower(strings.TrimSpace(after.Provider)); got != search.NameZhipu { + t.Fatalf("盘上 primary = %q, want %q", got, search.NameZhipu) + } + entry, ok := findProviderEntry(after, search.NameZhipu) + if !ok || entry.APIKey != "test-key-001" { + t.Fatalf("盘上 zhipu 条目 = %+v, want 带本次填的 key", entry) + } + + // 反向护栏:**真的没配齐**的主家仍必须被拒,且盘上一个字节都不动。 + // (本次修复只改「校验看哪一份文件」,不改判据。) + beforeReject := env.rawFile(t) + _, err := env.save(map[string]any{ + "provider": search.NameBocha, "enabled": true, "primary": search.NameBocha, + }) + ae, ok := pluginmgr.AsActionError(err) + if !ok || ae.Code != actionCodePrimaryNotConfigured { + t.Fatalf("err = %#v, want 400 %s(bocha 这次没填 key)", err, actionCodePrimaryNotConfigured) + } + if got := env.rawFile(t); !bytes.Equal(got, beforeReject) { + t.Fatalf("被拒的保存动了盘上的文件:\nbefore=%s\nafter =%s", beforeReject, got) + } +}