feat(search): 面板上终于能改「结果条数上限」(原来改了会被静默丢弃)
真缺陷(上一片登记):`maxResults` 在面板上**根本改不了** —— ① 字段表里没有这个
字段(UI 不显示输入框);② `saveProvider` 只并 provider/apiKey/baseUrl/enabled;
③ `mergeSelectionPatch` 只处理 primary/fallback ⇒ **手工塞进 values 也会被静默丢弃**
(文件不变、也不报错)。「我改了它却没变」正是要消灭的那种假话。
- `panel.go`:表单加 `{Key: FieldMaxResults, Type: sdk.FieldNumber, Min: 1}`(**SDK 本就有
数字类型**,未自造;前端本就渲染 number ⇒ **不需要改前端**)。刻意不设 `Default`
(设了会让一次「只改 key」的保存把默认值固化进文件、还可能盖掉 `.env`)。
- `websearch_selection.go`:`mergeSelectionPatch` 里按**三态**落 `maxResults` ——
缺位/null ⇒ **不改动**;正整数(含整数字符串)⇒ 采用;0/负数/小数/空串/非数/
布尔/数组 ⇒ **400 `invalid_payload`** 且**盘上一字节不动**(绝不静默忽略)。
- `reauth` 回填该值(否则一保存就把上限清掉)。
- `.env.example`:该键的示例值与「默认 5」注释同步为 **10**(代码默认已改;⚠️ `.env` 未动)。
验收(主会话复跑 + 亲自变异):**整树 `go build ./...` 通过**;三包 vet/测试绿;
端到端「面板存 20 ⇒ 模型要 50 也只给 20、要 3 给 3」(走真 action hub → 落盘 →
热重载 → WithSearchMaxResults → 真 dispatcher,未重启后端);
我用 -overlay 把 `f.MaxResults = maxResults` 摘掉 ⇒ 两条用例变红
(「面板给的值必须真的落盘」/「回填的 maxResults = 5, want 20」)。
⚠️ 已知取舍(如实登记):**清空输入框 = 400**(后端按「非数 ⇒ 报错」拒),因此面板上
没有「去掉上限」的手势 —— 要回到 `.env` 兜底得改文件;要支持「清空 = 去掉覆盖」
需另定哨兵语义,未自行发挥。
⚠️ 契约面已变更(面板声明多一个 number 字段 + saveProvider 的 values 多一个可写键 +
响应多一个回显键)⇒ 主会话登记。
This commit is contained in:
1 parent
8325d1bb8c
commit
c68387a69a
8 files changed
+387
-31
No files matched your search
@@ -52,9 +52,11 @@ func (e webSearchActionEnv) listProviderOptions(_ context.Context, _ pluginmgr.A
|
||||
// - apiKey **空串** ⇒ 显式清空(前端只有勾了「清除」才会送出空串);
|
||||
// - baseUrl / enabled 缺位或空串 ⇒ 保留;给出(含 false / 空地址)⇒ 采用。
|
||||
//
|
||||
// 同一次提交里还可能带两个**文件级**字段(`primary` / `fallback`,切片 W3):它们不是这一条
|
||||
// provider 的属性,而是「谁先用 / 失败后按什么顺序降级」,因此合并到文件级而不是条目上
|
||||
// (解析与校验在 websearch_selection.go)。缺位 = 不改动,与其余字段同一套哨兵语义。
|
||||
// 同一次提交里还可能带三个**文件级**字段(`primary` / `fallback` / `maxResults`):它们不是
|
||||
// 这一条 provider 的属性,而是「谁先用 / 失败后按什么顺序降级 / 单次最多几条」,因此合并到
|
||||
// 文件级而不是条目上(解析与校验在 websearch_selection.go)。缺位 = 不改动;但给出了**不合法**
|
||||
// 的值(如 `maxResults` ≤0 / 非整数)⇒ 整笔 400,**绝不静默丢弃** —— 静默丢弃会让用户以为
|
||||
// 上限已经改了,而跑的还是旧的那个(那正是这一片要消灭的那条「我改了它却没变」)。
|
||||
//
|
||||
// 落盘之后由组合根的热重载事务接管(`websearch_reload.go`:重建工具 + 换注册表条目,
|
||||
// 失败则连同本次落盘一起回滚)—— 本函数只负责「把改动存进去」这一件事。
|
||||
@@ -108,10 +110,12 @@ func (e webSearchActionEnv) saveProvider(_ context.Context, req pluginmgr.Action
|
||||
"provider": name,
|
||||
"created": !existed,
|
||||
"enabled": entry.IsEnabled(),
|
||||
// 文件级选择的**落盘后**取值(没给 primary/fallback 时也如实回显当前值):
|
||||
// 面板按它回显「谁先用」,也让「这次保存有没有动到选择」有可机读的答案。
|
||||
// 文件级取值的**落盘后**样子(没给这几个键时也如实回显当前值):面板按它回显
|
||||
// 「谁先用」,也让「这次保存有没有动到选择 / 上限」有可机读的答案。
|
||||
"primary": strings.ToLower(strings.TrimSpace(final.Provider)),
|
||||
"fallback": selectionNames(final.Fallback),
|
||||
// maxResults 的 0 = 文件里未声明(由 `.env` / 代码默认兜底),原样回显,不编一个假值。
|
||||
"maxResults": final.MaxResults,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -167,6 +171,10 @@ func (e webSearchActionEnv) testProvider(ctx context.Context, req pluginmgr.Acti
|
||||
// 只把它算作「盘上已有值」⇒ 留空 = 不改动、要清空必须显式勾选;
|
||||
// - 其余字段回**盘上那条的原样**,不做默认值回填:回填代码默认地址会让一次
|
||||
// 「只改 key」的保存把默认地址固化进文件(面板显示的是「该条」,不是「生效值」)。
|
||||
//
|
||||
// 文件级字段(`maxResults` 在内)也按同一条口径回填:**盘上有值才回**。上限不回填的话表单
|
||||
// 里那个框是空的,用户既看不见当前上限、也无从改起;而盘上没写(0 = 未声明)时**不回** 0 ——
|
||||
// 0 会被前端当成一个值送回来,写侧又拒 ≤0,那会让「只改 key」的保存整笔进不去。
|
||||
func (e webSearchActionEnv) reauthProvider(_ context.Context, req pluginmgr.ActionRequest) (any, error) {
|
||||
name, err := providerNameOf(req.Payload)
|
||||
if err != nil {
|
||||
@@ -185,8 +193,9 @@ func (e webSearchActionEnv) reauthProvider(_ context.Context, req pluginmgr.Acti
|
||||
"baseUrl": entry.BaseURL,
|
||||
"enabled": !exists || entry.IsEnabled(),
|
||||
}
|
||||
// 两个文件级字段一并回填:不然用户「清空降级链」在前端的三态写回里会落成缺位
|
||||
// (= 不改动),「我清掉了它」就成了一句假话。
|
||||
// 三个文件级字段一并回填(主 provider / 降级链 / 条数上限):不然用户「清空降级链」在前端
|
||||
// 的三态写回里会落成缺位(= 不改动),「我清掉了它」就成了一句假话;上限同理 ——
|
||||
// 不回填就看不见盘上的值,改上限也就无从下手。
|
||||
addSelectionValues(values, f)
|
||||
if masked, ok := maskedProviderEntry(f, name); ok && strings.TrimSpace(masked.APIKey) != "" {
|
||||
values["apiKey"] = masked.APIKey
|
||||
|
||||
@@ -4,16 +4,22 @@ package main
|
||||
// (主人 2026-10-07 裁定;口径见 design/27 §3 与 `cmd/cyrene/wire_io_tools.go` 的
|
||||
// `effectiveSearchConfig` —— 面板文件是可变源,`.env` 只播种)。
|
||||
//
|
||||
// 上半部分直接写盘(测投影:文件里的 `maxResults` 就是真上限);下半部分走**面板动作**
|
||||
// (`plugin.saveProvider` 带上 `maxResults` ⇒ 热重载 ⇒ 下一次搜索真的最多那么多条),
|
||||
// 并钉住三态:缺位 = 不改动、正整数 = 采用、其余 = 400 invalid_payload(绝不静默丢弃)。
|
||||
//
|
||||
// 夹具一律写进**本用例自己的** `$DATA_DIR`(`wsLoadConfig` 的 `t.TempDir()`):
|
||||
// 生产文件 `data/config/plugins/web-search.json` 不在测试的触碰范围内。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||||
@@ -118,3 +124,163 @@ func TestWebSearchDefaultsToTenWithoutPanelOrEnvGivesTen(t *testing.T) {
|
||||
t.Fatalf("count = %d, want 10(代码默认值)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 面板动作这一侧:存得进去、真的生效、非法值如实报错 -------------------------
|
||||
|
||||
// wsMaxResultsEnv 是一份「`.env` 给 envMaxResults、假上游答 30 条」的面板动作夹具
|
||||
// (热重载面 + 分派器都在:`newReloadTestEnv`)。`.env` 那个值是留给「面板压过 `.env`」
|
||||
// 这条判据当对照用的。
|
||||
func wsMaxResultsEnv(t *testing.T, envMaxResults int) (*reloadTestEnv, *wsFakeUpstream) {
|
||||
t.Helper()
|
||||
up := newWSFakeUpstream(t, http.StatusOK, wsManyResultsBody(30))
|
||||
env := newReloadTestEnv(t,
|
||||
"WEB_SEARCH_PROVIDER="+search.NameSearXNG,
|
||||
"WEB_SEARCH_ENDPOINT="+up.url,
|
||||
fmt.Sprintf("WEB_SEARCH_MAX_RESULTS=%d", envMaxResults),
|
||||
)
|
||||
return env, up
|
||||
}
|
||||
|
||||
// TestWebSearchPanelSaveAppliesMaxResults 是本片的主判据(端到端):面板提交里带上
|
||||
// `maxResults: 20` ⇒ 落盘 20 ⇒ **热重载**后下一次搜索真的最多 20 条 —— `.env` 的 5 不作数,
|
||||
// 也没有重启后端。
|
||||
func TestWebSearchPanelSaveAppliesMaxResults(t *testing.T) {
|
||||
env, up := wsMaxResultsEnv(t, 5)
|
||||
outcome := env.mustSave(t, map[string]any{
|
||||
"provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true,
|
||||
"maxResults": 20,
|
||||
})
|
||||
if !outcome.Applied || !outcome.Registered {
|
||||
t.Fatalf("hotReload = %+v, want applied 且 registered", outcome)
|
||||
}
|
||||
if got := env.loadFile(t).MaxResults; got != 20 {
|
||||
t.Fatalf("盘上 maxResults = %d, want 20(面板给的值必须真的落盘)", got)
|
||||
}
|
||||
if got := wsSearchCount(t, env.app, map[string]any{"query": "cyrene", "count": 50}); got != 20 {
|
||||
t.Fatalf("count = %d, want 20(模型要 50,上限是面板刚存下的 20 —— 且没重启)", got)
|
||||
}
|
||||
if got := wsSearchCount(t, env.app, map[string]any{"query": "cyrene", "count": 3}); got != 3 {
|
||||
t.Fatalf("count = %d, want 3(模型要得更少时尊重模型)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSearchPanelSaveWithoutMaxResultsKeepsStoredValue 钉住三态的第一态:提交里**缺位**
|
||||
// ⇒ 盘上那个上限一个字节都不动(「我没改它」必须真的是没改),运行中的生效值也照旧。
|
||||
//
|
||||
// 前置的 20 是**直接写盘**的(不经过写侧):这条判据只关心「缺位的那次保存有没有动它」,
|
||||
// 不该依赖另一条用例的能力。
|
||||
func TestWebSearchPanelSaveWithoutMaxResultsKeepsStoredValue(t *testing.T) {
|
||||
env, up := wsMaxResultsEnv(t, 5)
|
||||
if err := runtimeconfig.SaveWebSearch(env.path, runtimeconfig.WebSearchFile{
|
||||
Version: runtimeconfig.WebSearchVersion,
|
||||
Provider: search.NameSearXNG,
|
||||
MaxResults: 20,
|
||||
Providers: []runtimeconfig.ProviderEntry{
|
||||
{Provider: search.NameSearXNG, BaseURL: up.url, Enabled: runtimeconfig.EnableFlag(true)},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("写面板配置: %v", err)
|
||||
}
|
||||
env.mustSave(t, map[string]any{
|
||||
"provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true,
|
||||
})
|
||||
if got := env.loadFile(t).MaxResults; got != 20 {
|
||||
t.Fatalf("缺位的一次保存把上限改成了 %d, want 20(缺位 = 不改动)", got)
|
||||
}
|
||||
if got := wsSearchCount(t, env.app, map[string]any{"query": "cyrene", "count": 50}); got != 20 {
|
||||
t.Fatalf("count = %d, want 20(上限没被动过,热重载后的生效值也该照旧)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSearchPanelSaveRejectsInvalidMaxResults 钉住三态的第三态:给了值却用不了 ⇒
|
||||
// **400 invalid_payload**,盘上一个字节都不动 —— 绝不静默忽略。静默忽略正是这一片要消灭的
|
||||
// 那条「我改了它却没变」(旧行为就是:手塞进去的值被写侧无声丢掉,文件不变、也不报错)。
|
||||
func TestWebSearchPanelSaveRejectsInvalidMaxResults(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
value any
|
||||
}{
|
||||
{"零", 0},
|
||||
{"负数", -3},
|
||||
{"小数", 2.5},
|
||||
{"空串(前端清空输入框送的就是它)", ""},
|
||||
{"非数字字符串", "twenty"},
|
||||
{"布尔", true},
|
||||
{"数组", []any{20}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
env, up := wsMaxResultsEnv(t, 5)
|
||||
before := env.rawFile(t)
|
||||
_, err := env.save(map[string]any{
|
||||
"provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true,
|
||||
"maxResults": tc.value,
|
||||
})
|
||||
ae, ok := pluginmgr.AsActionError(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %v, want 结构化 ActionError(非法上限绝不能被静默忽略)", err)
|
||||
}
|
||||
if ae.Code != pluginmgr.ActionCodeInvalidPayload || ae.Status != http.StatusBadRequest {
|
||||
t.Fatalf("失败 = %d/%s, want 400/%s", ae.Status, ae.Code, pluginmgr.ActionCodeInvalidPayload)
|
||||
}
|
||||
if got := env.rawFile(t); !bytes.Equal(got, before) {
|
||||
t.Fatalf("被拒的保存动了盘上的文件:\nbefore=%s\nafter =%s", before, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSearchReauthEchoesMaxResults:回填必须带上盘上的上限 —— 不回填的话表单里那个框是空的,
|
||||
// 用户既看不见当前上限,也无从改起。
|
||||
func TestWebSearchReauthEchoesMaxResults(t *testing.T) {
|
||||
env, up := wsMaxResultsEnv(t, 5)
|
||||
env.mustSave(t, map[string]any{
|
||||
"provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true, "maxResults": 20,
|
||||
})
|
||||
values := wsReauthValues(t, env, search.NameSearXNG)
|
||||
if got := values[search.FieldMaxResults]; got != 20 {
|
||||
t.Fatalf("回填的 %s = %#v, want 20", search.FieldMaxResults, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebSearchReauthOmitsUnsetMaxResults:盘上**没给**上限(`maxResults` 缺位 ⇒ Go 侧 0 =
|
||||
// 未声明,由 `.env` 兜底)时不得回填一个 0 —— 0 会被前端当成一个值送回来,而写侧拒 ≤0,
|
||||
// 那会让「只改 key」的一次保存整笔进不去。
|
||||
func TestWebSearchReauthOmitsUnsetMaxResults(t *testing.T) {
|
||||
env, up := wsMaxResultsEnv(t, 5)
|
||||
if err := runtimeconfig.SaveWebSearch(env.path, runtimeconfig.WebSearchFile{
|
||||
Version: runtimeconfig.WebSearchVersion,
|
||||
Provider: search.NameSearXNG,
|
||||
// 刻意不写 maxResults(= 未声明)。
|
||||
Providers: []runtimeconfig.ProviderEntry{
|
||||
{Provider: search.NameSearXNG, BaseURL: up.url, Enabled: runtimeconfig.EnableFlag(true)},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("写面板配置: %v", err)
|
||||
}
|
||||
if got := env.loadFile(t).MaxResults; got != 0 {
|
||||
t.Fatalf("前置不成立:盘上 maxResults = %d, want 0(未声明)", got)
|
||||
}
|
||||
values := wsReauthValues(t, env, search.NameSearXNG)
|
||||
if raw, ok := values[search.FieldMaxResults]; ok {
|
||||
t.Fatalf("未声明时回填了 %#v(前端会把它当值送回,写侧又拒 ≤0)", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// wsReauthValues 走 reauth 动作,取出回填的 values。
|
||||
func wsReauthValues(t *testing.T, env *reloadTestEnv, provider string) map[string]any {
|
||||
t.Helper()
|
||||
out, err := env.hub.Dispatch(context.Background(), pluginmgr.ActionRequest{
|
||||
PluginID: search.PanelPluginID, Action: search.ActionReauthProvider,
|
||||
Payload: map[string]any{"row": map[string]any{"provider": provider}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("reauth: %v", err)
|
||||
}
|
||||
values, _ := out.(map[string]any)["values"].(map[string]any)
|
||||
if values == nil {
|
||||
t.Fatalf("reauth 结果缺 values: %#v", out)
|
||||
}
|
||||
return values
|
||||
}
|
||||
@@ -1,17 +1,18 @@
|
||||
package main
|
||||
|
||||
// 本文件是「搜索提供商」页表单里**两个文件级字段**的解析与落盘(切片 W3;
|
||||
// `DECISIONS-2026-10-06` 裁决 #2:声明面必须能配「谁先用」——`primary` + `fallback[]`)。
|
||||
// 本文件是「搜索提供商」页表单里**文件级字段**的解析与落盘(切片 W3;
|
||||
// `DECISIONS-2026-10-06` 裁决 #2:声明面必须能配「谁先用」——`primary` + `fallback[]`;
|
||||
// 后续切片又加了 `maxResults`:结果条数上限也得能在面板上改)。
|
||||
//
|
||||
// 它们与其余字段(provider / apiKey / baseUrl / enabled)的区别:
|
||||
//
|
||||
// - 其余字段描述**一条 provider 条目**(providers[] 里的一个元素);
|
||||
// - `primary` / `fallback` 描述**整份配置的选择**(文件级的 `provider` / `fallback`,
|
||||
// 即 design/27 §3 的「主后端 + 降级链」)。
|
||||
// - 这三个描述**整份配置**:`primary` / `fallback` 是 design/27 §3 的「主后端 + 降级链」,
|
||||
// `maxResults` 是单次检索的条数上限 —— 三者都是可变源顶层的同名键。
|
||||
//
|
||||
// 为什么挂在**同一个** form 区块里,而不是另开一个:前端的「带回表单」(reauth)只在面板
|
||||
// **恰好一个** form 区块时转交(`frontend/web/src/admin/plugin/PluginPanel.tsx` 的
|
||||
// `singleForm`),加第二个 form 会让既有的「重配凭据」回填整条失灵。因此这两项作为文件级
|
||||
// `singleForm`),加第二个 form 会让既有的「重配凭据」回填整条失灵。因此这些项作为文件级
|
||||
// 字段进既有表单,回填值由 `reauthProvider` 一并给出(见 addSelectionValues 的调用点)。
|
||||
//
|
||||
// 字段 key 刻意不叫 `provider`:表单里的 `provider` 已经是「这一条 provider 的名字」,
|
||||
@@ -19,7 +20,9 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
||||
@@ -28,15 +31,17 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||||
)
|
||||
|
||||
// 表单里两个文件级字段的 key 由**声明侧**给出(search.FieldPrimary / search.FieldFallback):
|
||||
// 它们必须与 panel.go 里写下的字段声明逐字一致(本文件读的正是这些 key),而
|
||||
// 「声明里写一个 key、处理器里读另一个」这类漂移只有共用一个常量才拦得住 ——
|
||||
// 表单里三个文件级字段的 key 由**声明侧**给出(search.FieldPrimary / FieldFallback /
|
||||
// FieldMaxResults):它们必须与 panel.go 里写下的字段声明逐字一致(本文件读的正是这些 key),
|
||||
// 而「声明里写一个 key、处理器里读另一个」这类漂移只有共用一个常量才拦得住 ——
|
||||
// 在 cmd/cyrene 里再写一遍字面量就等于第二份真源。
|
||||
const (
|
||||
// selectionPrimaryKey 是主 provider 的字段 key(落到文件级 `provider`)。
|
||||
selectionPrimaryKey = search.FieldPrimary
|
||||
// selectionFallbackKey 是降级链的字段 key(落到文件级 `fallback`)。
|
||||
selectionFallbackKey = search.FieldFallback
|
||||
// selectionMaxResultsKey 是结果条数上限的字段 key(落到文件级 `maxResults`)。
|
||||
selectionMaxResultsKey = search.FieldMaxResults
|
||||
)
|
||||
|
||||
// actionCodePrimaryNotConfigured 报告「选中的主 provider 在本文件里还没配齐」——
|
||||
@@ -44,13 +49,17 @@ const (
|
||||
// fail-fast),所以在写盘之前就拒掉,而不是先写进盘里等着下次起不来。
|
||||
const actionCodePrimaryNotConfigured = "primary_not_configured"
|
||||
|
||||
// mergeSelectionPatch 把表单里的主 provider / 降级链合并进文件(三态:**缺位 = 不改动**),
|
||||
// 并回报「这次有没有给出 `primary`」—— **校验不在这里**。
|
||||
// mergeSelectionPatch 把表单里的三个**文件级**字段(主 provider / 降级链 / 结果条数上限)
|
||||
// 合并进文件(三态:**缺位 = 不改动**),并回报「这次有没有给出 `primary`」
|
||||
// —— **校验不在这里**。
|
||||
//
|
||||
// 与其余字段同一套哨兵语义(websearch_action_data.go 的 saveProvider 注释):
|
||||
// `primary` 缺位 ⇒ 保留盘上的;给了值(含显式空串 / off / none)⇒ 采用 —— 空串与
|
||||
// `off`/`none` 是本项目**唯一一套**关闭语义(config.IsWebSearchOffSentinel),面板不另造一套。
|
||||
//
|
||||
// `maxResults` 的第三态**比另外两个严**:它没有「空串 = 关闭」这种解释,因此 ≤0 与非整数
|
||||
// 一律 400(口径与理由见 selectionMaxResults)—— 那正是这一片要消灭的「我改了它却没变」。
|
||||
//
|
||||
// 🔴 **顺序硬约束(缺陷修复 2026-10-07,真机报障)**:主 provider 的可用性校验必须由调用方
|
||||
// 在「**本次这条 provider 条目已经并进文件之后**」做(`checkPrimaryUsable(final, …)`)。
|
||||
// 本函数此前自己顺手校验,用的是**盘上的旧文件** ⇒ 面板上第一次给某家填 key、又在**同一次
|
||||
@@ -69,12 +78,19 @@ func (e webSearchActionEnv) mergeSelectionPatch(
|
||||
if err != nil {
|
||||
return f, false, err
|
||||
}
|
||||
maxResults, setMaxResults, err := selectionMaxResults(values)
|
||||
if err != nil {
|
||||
return f, false, err
|
||||
}
|
||||
if setPrimary {
|
||||
f.Provider = primary
|
||||
}
|
||||
if setFallback {
|
||||
f.Fallback = fallback
|
||||
}
|
||||
if setMaxResults {
|
||||
f.MaxResults = maxResults
|
||||
}
|
||||
return f, setPrimary, nil
|
||||
}
|
||||
|
||||
@@ -123,10 +139,11 @@ func selectionNames(names []string) []string {
|
||||
return names
|
||||
}
|
||||
|
||||
// addSelectionValues 把盘上的文件级选择回填进 `reauth` 的 values。
|
||||
// addSelectionValues 把盘上的**文件级取值**回填进 `reauth` 的 values。
|
||||
//
|
||||
// 回填的必要性在前端的三态写回里:只有 `initial` 有值,用户「清空降级链」才会被送成
|
||||
// 显式空(`planSubmit`),否则会落成「缺位 = 不改动」——「我清掉了它」就成了一句假话。
|
||||
// `maxResults` 同理:不回填的话表单里那个框是空的,用户看不见当前上限、也就无从改起。
|
||||
func addSelectionValues(values map[string]any, f runtimeconfig.WebSearchFile) {
|
||||
values[selectionPrimaryKey] = f.Provider
|
||||
fb := f.Fallback
|
||||
@@ -134,6 +151,60 @@ func addSelectionValues(values map[string]any, f runtimeconfig.WebSearchFile) {
|
||||
fb = []string{}
|
||||
}
|
||||
values[selectionFallbackKey] = fb
|
||||
// 上限只在盘上**真的给过**时才回填(`maxResults` 的 0 = 未声明):回填一个 0 会被前端
|
||||
// 当成一个值送回来,而写侧拒 ≤0 —— 用户「什么都没改」的一次保存就整笔进不去了。
|
||||
if f.MaxResults > 0 {
|
||||
values[selectionMaxResultsKey] = f.MaxResults
|
||||
}
|
||||
}
|
||||
|
||||
// selectionMaxResults 解析结果条数上限字段(落点:文件级 `maxResults`)。**三态**:
|
||||
//
|
||||
// - 缺位 / JSON null ⇒ 不改动;
|
||||
// - 正整数 ⇒ 采用(落盘后由热重载事务接上工具,下一次搜索就用它);
|
||||
// - 其余(0、负数、小数、空串、非数字字符串、布尔、数组…)⇒ **400 invalid_payload**。
|
||||
//
|
||||
// 为什么第三态必须报错,而不是像 `enabled` 那样「空串 = 未改动」:这一片的来由正是
|
||||
// 「我改了它却没变」—— 手工塞进提交体的上限被写侧静默丢弃(文件不变、也不报错)。既然
|
||||
// 面板上给了这个输入框,任何**给了值却没被采用**的提交都必须说得出原因;否则用户以为上限
|
||||
// 是 20,跑的还是 10。0 / 负数在这里也没有第二种解释:文件形状里 `maxResults` 的 0 =
|
||||
// 「未声明」(`omitempty` 不落盘),把 0 当「去掉上限」会让「去掉」与「从没配过」长得一模一样。
|
||||
func selectionMaxResults(values map[string]any) (int, bool, error) {
|
||||
raw, ok := values[selectionMaxResultsKey]
|
||||
if !ok || raw == nil {
|
||||
return 0, false, nil
|
||||
}
|
||||
n, ok := wholeNumber(raw)
|
||||
if !ok || n < 1 {
|
||||
// %q + Sprint:字符串会带引号(空串才看得见),数字/布尔照原样。
|
||||
return 0, false, invalidPayloadActionError(fmt.Sprintf(
|
||||
"%s 必须是正整数(≥1),收到 %q:这个框不能靠留空或 0 去掉上限,请填一个具体条数",
|
||||
selectionMaxResultsKey, fmt.Sprint(raw)))
|
||||
}
|
||||
return n, true, nil
|
||||
}
|
||||
|
||||
// wholeNumber 把一个补丁值还原成整数:JSON 数字(float64,必须**没有小数部分**)、Go 侧构造的
|
||||
// int、以及整数字符串(与 `wire_browser_panel_data.go` 的 patchInt 同一取舍:某些控件会把值以
|
||||
// 字符串送出)。其余类型一律拒 —— 不做静默转换,那正是本片要消灭的那类假成功。
|
||||
func wholeNumber(raw any) (int, bool) {
|
||||
switch v := raw.(type) {
|
||||
case float64:
|
||||
if v != math.Trunc(v) {
|
||||
return 0, false
|
||||
}
|
||||
return int(v), true
|
||||
case int:
|
||||
return v, true
|
||||
case string:
|
||||
n, err := strconv.Atoi(strings.TrimSpace(v))
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// selectionPrimary 解析主 provider 字段:缺位 ⇒ (…, false, nil);非字符串 ⇒ 400。
|
||||
|
||||
@@ -11,7 +11,7 @@ package main
|
||||
// - `websearch_action_data.go`:动作本体的业务数据面(可变源的读写与翻译、载荷解析);
|
||||
// - `websearch_probe.go`:探针的传输层绑定(HTTP 客户端 + 同主机守卫 + 状态映射);
|
||||
// - `websearch_reload.go`:保存后的**热重载**事务(重建工具 + 换注册表条目 + 失败回滚);
|
||||
// - `websearch_selection.go`:表单里两个文件级字段(主 provider / 降级链)的读写。
|
||||
// - `websearch_selection.go`:表单里文件级字段(主 provider / 降级链 / 条数上限)的读写。
|
||||
//
|
||||
// 三条边界:
|
||||
//
|
||||
|
||||
Reference in new issue
Block a user