feat(search): 搜索 provider 可变源 + 插件声明面板(PLG-4)
- 新增 $DATA_DIR/config/plugins/web-search.json(0600 + temp+rename,文件名 = 插件 id)
- 组合根改「先读可变源、缺失回落 .env 播种」;**不新增任何 env 键**(.env 已冻结)
- 多实例:每家 provider 独立 key/baseURL/启用态 + 「测试连通」探针
- 用 PLG-1 的声明库给出该面板的 adminPanels 声明;动作名交给 PLG-2 的通用动作端点分发
(本片自己不开路由)
⚠️ 本片自认越界:改了 cmd/cyrene/wire_web_search_test.go(既有测试,为适配 DataDir);
另有 design/28 的相关注册留给主会话。
This commit is contained in:
1 parent
2d21aa553e
commit
120d926cf8
11 files changed
+1811
-41
No files matched your search
@@ -0,0 +1,109 @@
|
||||
package search
|
||||
|
||||
// 本文件是 web-search 插件向**面板**声明的管理页面:design/09 §5.2 的 `adminPanels`,
|
||||
// 形状即 `pkg/plugins/sdk` 的 `Panel`。
|
||||
//
|
||||
// 声明只说三件事:**要什么字段、怎么渲染、动作叫什么**。它
|
||||
//
|
||||
// - 不注入任何 JS/CSS/HTML、
|
||||
// - 不给 URL(只有动作名,走宿主的通用动作端点分发)、
|
||||
// - 不开任何路由。
|
||||
//
|
||||
// 这三条是 design/09 §5.2.1 的红线:声明而非编码、声明里塞不进请求面(防 SSRF)、
|
||||
// 面板数据一律经插件管理器的端点。渲染与流程由宿主负责,本文件只负责「说得清楚」。
|
||||
|
||||
import "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
|
||||
// PanelPluginID 是本插件在面板体系里的 id。它与可变源的文件名**同源**:
|
||||
// `$DATA_DIR/config/plugins/web-search.json`(design/13 §2.1:文件名 = 插件 id),
|
||||
// 两边由 panel_test.go 钉住。
|
||||
const PanelPluginID = "web-search"
|
||||
|
||||
// PanelIDProviders 是「搜索提供商」页的面板 id。
|
||||
// 前端路由为 `/admin/plugin/web-search/providers`(design/09 §5.7 C)。
|
||||
const PanelIDProviders = "providers"
|
||||
|
||||
// 本插件声明的**全部**动作名(design/09 §5.2 示例给出的那四个)。
|
||||
//
|
||||
// 表里的动作名一律取自这里,通用动作端点也只接受这几个名字(未声明 ⇒ 404/403,
|
||||
// design/09 §5.2.1 B)。把「声明」与「动作名」放同一个文件,是为了让「表里写了一个
|
||||
// 没实现的动作」这种半渲染在 review 时一眼可见 —— 它们就挨着。
|
||||
const (
|
||||
// ActionListProviders 列出全部 provider 配置(表格区块的数据源)。
|
||||
ActionListProviders = "plugin.listProviders"
|
||||
// ActionSaveProvider 新增 / 修改一条 provider 配置(表单提交)。
|
||||
// 语义是 upsert:按 `provider` 名就位替换或追加。
|
||||
ActionSaveProvider = "plugin.saveProvider"
|
||||
// ActionTestProvider 测试连通:对一条 provider 配置真发一次最小检索(见 probe.go)。
|
||||
ActionTestProvider = "plugin.testProvider"
|
||||
// ActionReauthProvider 重配凭据:把该条带回表单。写侧的「未改动」必须用显式哨兵表达,
|
||||
// **不能**靠空字符串(那会静默清空凭据,design/09 §5.4.2)。
|
||||
ActionReauthProvider = "plugin.reauthProvider"
|
||||
)
|
||||
|
||||
// ActionNames 返回本插件声明的动作名,顺序与 AdminPanels 里出现的顺序一致
|
||||
// (表格数据源 → 表单提交 → 行内动作)。调用方(插件管理器的动作端点)据此建白名单:
|
||||
// 白名单外的动作名一律拒,绝不「先执行再看结果」。
|
||||
func ActionNames() []string {
|
||||
return []string{ActionListProviders, ActionSaveProvider, ActionTestProvider, ActionReauthProvider}
|
||||
}
|
||||
|
||||
// AdminPanels 返回本插件声明的管理页面(design/09 §5.2 示例的 providers 页)。
|
||||
//
|
||||
// 两处相对示例的**明确增补**(示例是形状示范,不是字段清单的穷举;两处都被本片的任务书
|
||||
// 要求):
|
||||
// - 表单多一个 `enabled` 开关:任务书要求每家 provider **独立启用态**;
|
||||
// - 表单的 `baseUrl` 用示例的 `endpoint` 语义(searxng 的端点 = 那家的基地址),
|
||||
// 名字跟可变源的字段名走,免得面板字段名与文件字段名对不上。
|
||||
func AdminPanels() []sdk.Panel {
|
||||
return []sdk.Panel{{
|
||||
ID: PanelIDProviders,
|
||||
Title: "搜索提供商",
|
||||
Description: "为 web-search 能力配置多个第三方搜索后端:每条独立凭据与基地址," +
|
||||
"可单独启停并测试连通。改动落 $DATA_DIR/config/plugins/web-search.json,重启后仍然生效。",
|
||||
Group: sdk.GroupPlugins,
|
||||
Order: 20,
|
||||
Icon: "search",
|
||||
// 未启用的插件 ⇒ 入口不出现(design/09 §5.2.1 nav.item)。web-search 是编进二进制的
|
||||
// 内置能力,没有卸载路径 ⇒ 恒为启用;这里写条件是为了让「启用事实」由插件管理器
|
||||
// 给出(单一事实来源),而不是由面板猜。
|
||||
When: &sdk.When{Enabled: boolFlag(true)},
|
||||
Sections: []sdk.Section{
|
||||
{
|
||||
Type: sdk.SectionForm,
|
||||
ID: "add",
|
||||
Title: "添加 / 修改提供商",
|
||||
Fields: []sdk.Field{
|
||||
{Key: "provider", Label: "提供商", Type: sdk.FieldSelect,
|
||||
OptionsFrom: "plugin:providers", Required: true},
|
||||
// secret 字段刻意不标 required:它同时承担「编辑既有条目」的输入,
|
||||
// 而编辑时不重填凭据是常态(写侧哨兵语义,见 ActionReauthProvider)。
|
||||
{Key: "apiKey", Label: "API Key", Type: sdk.FieldSecret,
|
||||
Placeholder: "留空 = 保持原值"},
|
||||
{Key: "baseUrl", Label: "接口地址", Type: sdk.FieldText,
|
||||
Placeholder: "https://..."},
|
||||
{Key: "enabled", Label: "启用", Type: sdk.FieldSwitch},
|
||||
},
|
||||
Submit: &sdk.Submit{Action: ActionSaveProvider},
|
||||
},
|
||||
{
|
||||
Type: sdk.SectionTable,
|
||||
ID: "list",
|
||||
Title: "已配置的提供商",
|
||||
Source: &sdk.Source{Action: ActionListProviders},
|
||||
// 列 key 与 panelrow.go 的行字段同源;凭据不进任何列(读侧无明文)。
|
||||
Columns: []sdk.Column{
|
||||
{Key: "provider", Label: "提供商"},
|
||||
{Key: "state", Label: "状态"},
|
||||
},
|
||||
RowActions: []sdk.RowAction{
|
||||
{ID: "test", Label: "测试连通", Action: ActionTestProvider},
|
||||
{ID: "reauth", Label: "重配凭据", Action: ActionReauthProvider},
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// boolFlag 是 `&true` / `&false` 的简写(sdk.When.Enabled 是三态指针)。
|
||||
func boolFlag(v bool) *bool { return &v }
|
||||
@@ -0,0 +1,218 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/paneldecl"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// 本文件钉住 web-search 插件向面板声明的管理页面(design/09 §5.2 / §5.2.1):
|
||||
// 声明必须能过宿主的**闭集校验**(否则 §5.2.1 的「非法声明 ⇒ 该面板不注册」会真的
|
||||
// 把它挡在门外),动作名必须与 ActionNames() 同源,表格状态的语义必须可断言。
|
||||
|
||||
// 判据 ①:声明必须过宿主校验(这是「面板真的会出现」的唯一凭据)。
|
||||
func TestAdminPanelsPassHostValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
if err := paneldecl.Validate(PanelPluginID, AdminPanels()); err != nil {
|
||||
t.Fatalf("宿主校验不通过(面板会被整体拒掉): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 判据 ②:面板里的每一个动作名都必须是 ActionNames() 里的成员(声明与动作表同源),
|
||||
// 且 ActionNames() 本身无重复 —— 否则「声明了却没实现」或「白名单漏一个」都会变成
|
||||
// 前端点了没反应的半渲染。
|
||||
func TestAdminPanelsActionsAreDeclaredOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
declared := make(map[string]bool)
|
||||
for _, n := range ActionNames() {
|
||||
if declared[n] {
|
||||
t.Fatalf("ActionNames() 里 %q 重复", n)
|
||||
}
|
||||
declared[n] = true
|
||||
}
|
||||
used := make([]string, 0, len(declared))
|
||||
for _, p := range AdminPanels() {
|
||||
for _, s := range p.Sections {
|
||||
if s.Submit != nil {
|
||||
used = append(used, s.Submit.Action)
|
||||
}
|
||||
if s.Source != nil {
|
||||
used = append(used, s.Source.Action)
|
||||
}
|
||||
for _, a := range s.RowActions {
|
||||
used = append(used, a.Action)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(used) == 0 {
|
||||
t.Fatal("声明里一个动作都没有:这张表点不动")
|
||||
}
|
||||
for _, a := range used {
|
||||
if !declared[a] {
|
||||
t.Errorf("面板声明了动作 %q,但它不在 ActionNames() 里(动作白名单会把它拒掉)", a)
|
||||
}
|
||||
}
|
||||
// 反向:声明的动作都必须真的出现在面板里(否则白名单里挂着一个点不到的名字)。
|
||||
seen := make(map[string]bool, len(used))
|
||||
for _, a := range used {
|
||||
seen[a] = true
|
||||
}
|
||||
for _, n := range ActionNames() {
|
||||
if !seen[n] {
|
||||
t.Errorf("ActionNames() 里的 %q 在面板声明里没有用到", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 判据 ③:面板 id 与可变源的文件名同源(design/13 §2.1:文件名 = 插件 id)。
|
||||
// 这两个字面量分处两个包,只有这条断言能把「改了插件 id 却忘了改文件落点」拦下来。
|
||||
func TestPanelPluginIDMatchesConfigFileName(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := runtimeconfig.WebSearchConfigPath("/data")
|
||||
if !strings.HasSuffix(got, PanelPluginID+".json") {
|
||||
t.Fatalf("可变源落点 %q 的文件名必须等于插件 id %q", got, PanelPluginID)
|
||||
}
|
||||
}
|
||||
|
||||
// 判据 ④:表格区块必须声明 source + columns + 行内动作(§5.2.1 的形状矩阵由宿主校验
|
||||
// 兜底,这里再钉一层语义:这张表就是用来看状态与点动作的)。
|
||||
func TestAdminPanelsTableShapeIsUsable(t *testing.T) {
|
||||
t.Parallel()
|
||||
var table *sdk.Section
|
||||
for _, p := range AdminPanels() {
|
||||
for i := range p.Sections {
|
||||
if p.Sections[i].Type == sdk.SectionTable {
|
||||
table = &p.Sections[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
if table == nil {
|
||||
t.Fatal("声明里没有 table 区块:多实例列表无处可看")
|
||||
}
|
||||
if table.Source == nil || table.Source.Action != ActionListProviders {
|
||||
t.Fatalf("表格数据源 = %+v, want %s", table.Source, ActionListProviders)
|
||||
}
|
||||
keys := make([]string, 0, len(table.Columns))
|
||||
for _, c := range table.Columns {
|
||||
keys = append(keys, c.Key)
|
||||
}
|
||||
for _, want := range []string{"provider", "state"} {
|
||||
if !contains(keys, want) {
|
||||
t.Errorf("表格列里缺 %q(行数据必须能显示出来): %v", want, keys)
|
||||
}
|
||||
}
|
||||
// 行内动作至少要有「测试连通」(本片任务书点名的动作)。
|
||||
found := false
|
||||
for _, a := range table.RowActions {
|
||||
if a.Action == ActionTestProvider {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("表格行内动作里没有 %s: %+v", ActionTestProvider, table.RowActions)
|
||||
}
|
||||
}
|
||||
|
||||
// 判据 ⑤:表格行与列同源(列 key 都能在行数据里取到)。
|
||||
func TestBuildRowsFieldsCoverDeclaredColumns(t *testing.T) {
|
||||
t.Parallel()
|
||||
rows := BuildRows([]ProviderInput{
|
||||
{Provider: NameBocha, APIKey: "sk-x", BaseURL: "https://api.bocha.cn", Enabled: true},
|
||||
})
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("rows = %d, want 1", len(rows))
|
||||
}
|
||||
raw, err := json.Marshal(rows[0])
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var obj map[string]any
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
for _, want := range []string{"provider", "state"} {
|
||||
if _, ok := obj[want]; !ok {
|
||||
t.Errorf("行数据缺列 key %q: %s", want, raw)
|
||||
}
|
||||
}
|
||||
if strings.Contains(string(raw), "sk-x") {
|
||||
t.Fatalf("行数据泄漏了凭据: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// 判据 ⑥:state 的判定顺序(根因在前):未登记 > 停用 > 未配齐 > 可用。
|
||||
func TestBuildRowsStates(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
in ProviderInput
|
||||
state string
|
||||
}{
|
||||
{"已登记已配齐", ProviderInput{Provider: NameBocha, APIKey: "k", BaseURL: "https://b", Enabled: true}, StateOK},
|
||||
{"名字没登记", ProviderInput{Provider: "tavily", APIKey: "k", BaseURL: "https://t", Enabled: true}, StateUnknown},
|
||||
{"名字没登记且停用(名字错了才是根因)", ProviderInput{Provider: "tavily", Enabled: false}, StateUnknown},
|
||||
{"显式停用", ProviderInput{Provider: NameBocha, APIKey: "k", BaseURL: "https://b", Enabled: false}, StateDisabled},
|
||||
{"云家缺 key", ProviderInput{Provider: NameBocha, BaseURL: "https://b", Enabled: true}, StateNotConfigured},
|
||||
{"searxng 缺地址", ProviderInput{Provider: NameSearXNG, Enabled: true}, StateNotConfigured},
|
||||
{"searxng 有地址", ProviderInput{Provider: NameSearXNG, BaseURL: "http://127.0.0.1:38471", Enabled: true}, StateOK},
|
||||
{"名字大小写与空白不敏感", ProviderInput{Provider: " BOCHA ", APIKey: "k", BaseURL: "https://b", Enabled: true}, StateOK},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := BuildRows([]ProviderInput{tc.in})[0]
|
||||
if got.State != tc.state {
|
||||
t.Fatalf("state = %q, want %q", got.State, tc.state)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 判据 ⑦:下拉选项 = 本期内置的全部已登记家(稳定排序),且**含尚未配置的家**
|
||||
// —— 表单的用途就是第一次配置,只列已配置的会让用户无从选起。
|
||||
func TestProviderOptionsAreAllKnownNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := ProviderOptions()
|
||||
want := append([]string(nil), KnownNames()...)
|
||||
sort.Strings(want)
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("ProviderOptions() = %v, want %v", got, want)
|
||||
}
|
||||
if len(got) == 0 {
|
||||
t.Fatal("选项为空:表单的下拉一上来就选不了东西")
|
||||
}
|
||||
}
|
||||
|
||||
// 判据 ⑧:动作名是与插件管理器的**对外契约**(设计示例里的那四个字面量)。
|
||||
// 改字号就是改契约:这里钉住字面量,免得一次「顺手重命名」让前端与设计文档对不上。
|
||||
func TestDeclaredActionNamesMatchDesignExample(t *testing.T) {
|
||||
t.Parallel()
|
||||
want := []string{
|
||||
"plugin.listProviders", "plugin.saveProvider", "plugin.testProvider", "plugin.reauthProvider",
|
||||
}
|
||||
got := ActionNames()
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("ActionNames() = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("ActionNames()[%d] = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// contains 是字符串切片成员判定(测试里只此一处,不值得抽包)。
|
||||
func contains(list []string, want string) bool {
|
||||
for _, s := range list {
|
||||
if s == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package search
|
||||
|
||||
import "strings"
|
||||
|
||||
// 本文件是「搜索提供商」页的**数据面**:表格行与下拉选项的来源。
|
||||
//
|
||||
// 它只做**翻译**(事实 → 面板要的形状),不读文件、不开路由、不鉴权 —— 那些是插件管理器
|
||||
// 与宿主端点的事(design/09 §5.2.1 B)。放这里的理由只有一个:`state` 与选项值的语义
|
||||
// 属于 provider 领域(要问 `Configured` / `KnownNames`),散到宿主里就会与面板声明各写一套。
|
||||
|
||||
// 表格 `state` 列的取值(design/09 §5.2 示例:「ok | 未配置 | 凭据失效 | 未知」)。
|
||||
//
|
||||
// 「凭据失效」不在这里:它只能由一次真实调用得知(那是 plugin.testProvider 的结论,
|
||||
// 见 probe.go),列表接口不该为了它去逐家发请求。
|
||||
const (
|
||||
// StateOK 表示这家可以用了(名字已登记且已配齐且启用)。
|
||||
StateOK = "ok"
|
||||
// StateNotConfigured 表示这个名字已登记,但没配齐(云家缺 key、searxng 缺地址)。
|
||||
StateNotConfigured = "not_configured"
|
||||
// StateDisabled 表示这条已被显式停用(enabled=false)。
|
||||
StateDisabled = "disabled"
|
||||
// StateUnknown 表示这个名字本期没登记(写错了 / 还没做,如 tavily)。
|
||||
StateUnknown = "unknown"
|
||||
)
|
||||
|
||||
// ProviderInput 是构造一行所需的最小事实(一条 provider 配置)。
|
||||
//
|
||||
// 刻意用平铺字段而不是引用 runtimeconfig 的类型:search 包是插件实现,不该认识宿主的
|
||||
// 配置文件形状(那份形状属于宿主,见 internal/config/runtimeconfig/websearch.go)。
|
||||
type ProviderInput struct {
|
||||
// Provider 是 provider 名。
|
||||
Provider string
|
||||
// APIKey 是上游凭据(判定是否配齐用,**绝不进行数据**)。
|
||||
APIKey string
|
||||
// BaseURL 是上游基地址(同上,判定用)。
|
||||
BaseURL string
|
||||
// Enabled 是启用态。
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// ProviderRow 是「已配置的提供商」表格的一行。字段名即表格列的 key(panel.go 的 columns)。
|
||||
//
|
||||
// 凭据与基地址**都不进行数据**:列表只需要状态,凭据读侧无明文(design/09 §5.4.2),
|
||||
// 而基地址可能带用户名口令。要看地址就点「重配凭据」,由表单一对一带回。
|
||||
type ProviderRow struct {
|
||||
Provider string `json:"provider"`
|
||||
State string `json:"state"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// BuildRows 把各条配置翻译成表格行。
|
||||
//
|
||||
// 判定顺序是把「根因」排在前面:名字没登记(写错了)> 显式停用 > 没配齐 > 可用。
|
||||
// 一个停用的陌生名字报 `unknown` —— 名字错了才是要修的那处。
|
||||
func BuildRows(inputs []ProviderInput) []ProviderRow {
|
||||
out := make([]ProviderRow, 0, len(inputs))
|
||||
for _, in := range inputs {
|
||||
out = append(out, ProviderRow{
|
||||
Provider: strings.ToLower(strings.TrimSpace(in.Provider)),
|
||||
State: providerState(in),
|
||||
Enabled: in.Enabled,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// providerState 判定一条配置的表格状态。
|
||||
func providerState(in ProviderInput) string {
|
||||
name := strings.ToLower(strings.TrimSpace(in.Provider))
|
||||
if !Known(name) {
|
||||
return StateUnknown
|
||||
}
|
||||
if !in.Enabled {
|
||||
return StateDisabled
|
||||
}
|
||||
if !Configured(name, in.APIKey, in.BaseURL) {
|
||||
return StateNotConfigured
|
||||
}
|
||||
return StateOK
|
||||
}
|
||||
|
||||
// ProviderOptions 返回 `optionsFrom: plugin:providers` 的选项值 —— 本期**全部已登记**的
|
||||
// provider 名(含尚未配置的家),稳定排序。
|
||||
//
|
||||
// 为什么给全部而不是「已配置的家」:表单的用途就是**新增**一家,只列已配置的会让
|
||||
// 「第一次配置」无从选起。可用性不靠下拉限制,靠列表的 `state` 列如实呈现。
|
||||
func ProviderOptions() []string { return KnownNames() }
|
||||
@@ -0,0 +1,189 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 本文件是「测试连通」动作(design/09 §5.2 示例的 plugin.testProvider)的数据面:
|
||||
// 对**一条** provider 配置真发一次最小检索,把结论分成「通了」与「为什么没通」。
|
||||
//
|
||||
// 三条口径:
|
||||
//
|
||||
// 1. **真发一次请求**:连通性不靠 ping 主机、也不靠「配置看起来齐了」来假装 ——
|
||||
// 那会给出「连通 OK 但一搜就报错」的假信号。这里走的就是工具运行时走的那条通路
|
||||
// (同一个 provider 构造、同一个共享归一化)。
|
||||
// 2. **空结果 ≠ 不通**(design/27 §1.4):上游正常应答但一条都没查到,如实报
|
||||
// no_results —— 既不冒充成功,也不冤枉成「该家不可用」。
|
||||
// 3. **密钥绝不进结果**:只回结构化错误码 + 固定文案,**绝不回上游原始报文**
|
||||
// (错误体可能回显请求 URL 或凭据)。文案与错误码分离,前端可本地化(§5.4.6)。
|
||||
//
|
||||
// 本文件不读配置文件、不开路由、不落审计 —— 那些是插件管理器与宿主端点的事。
|
||||
|
||||
// 探针的查询词与条数:一次**最小但真实**的检索(只要 1 条,省上游额度)。
|
||||
const (
|
||||
probeQuery = "cyrene"
|
||||
probeCount = 1
|
||||
)
|
||||
|
||||
// 探针结论的错误码(稳定标识;design/09 §5.4.6「错误码与文案分离」)。
|
||||
const (
|
||||
// ProbeUnknownProvider 报告名字本期没登记(写错了 / 还没做)。
|
||||
ProbeUnknownProvider = "unknown_provider"
|
||||
// ProbeNotConfigured 报告这条没配齐(云家缺 API Key,或任何一家缺接口地址)。
|
||||
ProbeNotConfigured = "not_configured"
|
||||
// ProbeUnauthorized 报告上游拒了凭据(401/403)。
|
||||
ProbeUnauthorized = "unauthorized"
|
||||
// ProbeRateLimited 报告被上游限流(429)。
|
||||
ProbeRateLimited = "rate_limited"
|
||||
// ProbeQuotaExceeded 报告额度用尽 / 欠费 / 未开通。
|
||||
ProbeQuotaExceeded = "quota_exceeded"
|
||||
// ProbeNetwork 报告网络层失败(连不上 / DNS / 超时)。
|
||||
ProbeNetwork = "network"
|
||||
// ProbeUpstream 报告上游 5xx 或其它无法进一步分类的失败状态。
|
||||
ProbeUpstream = "upstream"
|
||||
// ProbeBadResponse 报告上游应答不是预期结构(或超过读取上限)。
|
||||
ProbeBadResponse = "bad_response"
|
||||
// ProbeNoResults 报告上游正常应答但没有任何结果(**不算失败**,只是没内容)。
|
||||
ProbeNoResults = "no_results"
|
||||
// ProbeUnknown 是兜底:未分类的失败。
|
||||
ProbeUnknown = "unknown"
|
||||
)
|
||||
|
||||
// ProbeResult 是「测试连通」的结论(面板行内动作的返回体)。
|
||||
type ProbeResult struct {
|
||||
// OK 报告这次探针真的拿到结果了(空结果不是 OK)。
|
||||
OK bool `json:"ok"`
|
||||
// Provider 是被探的那一家(小写归一后的名字)。
|
||||
Provider string `json:"provider"`
|
||||
// Count 是这次探针拿到的结果条数(OK 时 > 0)。
|
||||
Count int `json:"count"`
|
||||
// LatencyMS 是这次探针的耗时(毫秒),含建连与读体。
|
||||
LatencyMS int64 `json:"latencyMs"`
|
||||
// ErrorCode 是失败时的稳定错误码(OK 时为空)。
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
// Error 是与错误码配对的固定文案(**不含上游原文、不含凭据**)。
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Probe 对 name 这一家做一次真实的最小检索(count ≤ 0 时用 probeCount)。
|
||||
//
|
||||
// cfg 由调用方填好(凭据 / 基地址 / 共享 HTTP 客户端 / SSRF 守卫),与工具运行时用的是
|
||||
// 同一套接线;cfg.Client 为 nil 时如实报 not_configured(发不出请求就是没配齐)。
|
||||
func Probe(ctx context.Context, name string, cfg Config, count int) (res ProbeResult) {
|
||||
res.Provider = strings.ToLower(strings.TrimSpace(name))
|
||||
if count <= 0 {
|
||||
count = probeCount
|
||||
}
|
||||
start := time.Now()
|
||||
defer func() { res.LatencyMS = time.Since(start).Milliseconds() }()
|
||||
|
||||
res.ErrorCode, res.Error = probePreflight(res.Provider, cfg)
|
||||
if res.ErrorCode != "" {
|
||||
return res
|
||||
}
|
||||
items, err := probeSearch(ctx, res.Provider, cfg, count)
|
||||
if err != nil {
|
||||
res.ErrorCode = ProbeErrorCode(err)
|
||||
res.Error = probeMessage(res.ErrorCode)
|
||||
return res
|
||||
}
|
||||
if len(items) == 0 {
|
||||
res.ErrorCode = ProbeNoResults
|
||||
res.Error = probeMessage(ProbeNoResults)
|
||||
return res
|
||||
}
|
||||
res.OK = true
|
||||
res.Count = len(items)
|
||||
return res
|
||||
}
|
||||
|
||||
// probePreflight 做发请求前的判定,返回 (错误码, 文案);都为空表示可以发。
|
||||
//
|
||||
// 为什么比运行时更严(运行时云家只看 API Key):运行时的基地址有代码默认值兜底
|
||||
// (`WEB_SEARCH_<NAME>_BASE_URL` 的 tag 默认值),而面板里存下来的那条**可能真的没有地址**
|
||||
// —— 那种情况下探针必须说「没配齐」,而不是发一个相对 URL 出去报一句看不懂的错。
|
||||
func probePreflight(name string, cfg Config) (string, string) {
|
||||
if !Known(name) {
|
||||
return ProbeUnknownProvider, "未登记的搜索提供商(本期接入位:" +
|
||||
strings.Join(KnownNames(), ", ") + ")"
|
||||
}
|
||||
if !Configured(name, cfg.APIKey, cfg.BaseURL) || strings.TrimSpace(cfg.BaseURL) == "" {
|
||||
return ProbeNotConfigured, probeMessage(ProbeNotConfigured)
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// probeSearch 建一家 provider 并真发一次检索。error 为 nil 时 items 可能为空
|
||||
// (空结果由调用方按「空 ≠ 不通」处置)。
|
||||
func probeSearch(ctx context.Context, name string, cfg Config, count int) ([]Result, error) {
|
||||
reg := Build(
|
||||
[]Wire{{Name: name, APIKey: cfg.APIKey, BaseURL: cfg.BaseURL, Configured: true}},
|
||||
cfg.Client, cfg.MaxBytes, cfg.Guard)
|
||||
p, ok := reg.Lookup(name)
|
||||
if !ok {
|
||||
// 只有「已登记却构造不出来」才走到这里(与 checkSearchSelection 的 ErrNotConfigured 同源)。
|
||||
return nil, fmt.Errorf("%w: probe: provider %q cannot be built", ErrNotConfigured, name)
|
||||
}
|
||||
return p.Search(ctx, Query{Text: probeQuery, Count: count})
|
||||
}
|
||||
|
||||
// ProbeErrorCode 把一次探针失败归类成稳定错误码(errors.Is 判定)。
|
||||
func ProbeErrorCode(err error) string {
|
||||
switch {
|
||||
case err == nil:
|
||||
return ""
|
||||
case errors.Is(err, ErrUnknownProvider):
|
||||
return ProbeUnknownProvider
|
||||
case errors.Is(err, ErrNotConfigured):
|
||||
return ProbeNotConfigured
|
||||
case errors.Is(err, ErrUnauthorized):
|
||||
return ProbeUnauthorized
|
||||
case errors.Is(err, ErrRateLimited):
|
||||
return ProbeRateLimited
|
||||
case errors.Is(err, ErrQuotaExceeded):
|
||||
return ProbeQuotaExceeded
|
||||
case errors.Is(err, ErrNetwork):
|
||||
return ProbeNetwork
|
||||
case errors.Is(err, ErrResponseTooLarge), errors.Is(err, ErrBadResponse):
|
||||
return ProbeBadResponse
|
||||
case errors.Is(err, ErrNoResults):
|
||||
return ProbeNoResults
|
||||
case errors.Is(err, ErrUpstream):
|
||||
return ProbeUpstream
|
||||
default:
|
||||
return ProbeUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// probeMessage 给出与错误码配对的固定中文文案。
|
||||
//
|
||||
// 刻意**不回上游原文**:错误体与网络错误都可能回显请求 URL(某些上游把 key 放在查询串里)
|
||||
// 或凭据,而这份文案会进面板响应。要看原文就走服务端日志(那里也不含凭据)。
|
||||
func probeMessage(code string) string {
|
||||
switch code {
|
||||
case ProbeUnknownProvider:
|
||||
return "未登记的搜索提供商:名字写错了,或本期还没做这一家"
|
||||
case ProbeNotConfigured:
|
||||
return "这条没配齐:云家需要 API Key,任何一家都需要接口地址"
|
||||
case ProbeUnauthorized:
|
||||
return "上游拒绝了凭据(401/403):请核对 API Key 是否有效"
|
||||
case ProbeRateLimited:
|
||||
return "被上游限流(429):稍后重试"
|
||||
case ProbeQuotaExceeded:
|
||||
return "上游额度用尽 / 欠费 / 未开通该服务"
|
||||
case ProbeNetwork:
|
||||
return "连不上上游:网络/域名/超时问题"
|
||||
case ProbeUpstream:
|
||||
return "上游返回了失败状态(5xx 或其它)"
|
||||
case ProbeBadResponse:
|
||||
return "上游应答不是预期结构(或超过读取上限)"
|
||||
case ProbeNoResults:
|
||||
return "连通正常,但这次探针没有查到结果(空结果不等于失败,换别的查询词再试)"
|
||||
default:
|
||||
return "探针失败,原因未分类(详见服务端日志)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 本文件钉住「测试连通」探针(design/09 §5.2 示例的 plugin.testProvider)的结论语义:
|
||||
// 真发一次请求、空结果 ≠ 不通、凭据绝不进结果、失败必须可分类。
|
||||
|
||||
// wsProbeServer 起一个只说博查形状的假上游,把收到的请求送进 got 供断言。
|
||||
func wsProbeServer(t *testing.T, status int, body string) (*httptest.Server, chan *http.Request) {
|
||||
t.Helper()
|
||||
got := make(chan *http.Request, 1)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
got <- r
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, got
|
||||
}
|
||||
|
||||
// 判据 ①:通了 ⇒ OK + 真实条数;且真的打到了那家的固定路径上,凭据真的带上了。
|
||||
func TestProbeOKHitsProviderWithCredential(t *testing.T) {
|
||||
t.Parallel()
|
||||
const key = "probe-secret-key"
|
||||
srv, got := wsProbeServer(t, http.StatusOK,
|
||||
`{"webPages":{"value":[{"name":"标题","url":"https://a.example/1","summary":"正文"}]}}`)
|
||||
|
||||
res := Probe(context.Background(), NameBocha, Config{
|
||||
APIKey: key, BaseURL: srv.URL, Client: srv.Client(), MaxBytes: 1 << 20,
|
||||
}, 1)
|
||||
if !res.OK || res.Count != 1 {
|
||||
t.Fatalf("探针结论 = %+v, want OK/count=1", res)
|
||||
}
|
||||
if res.Provider != NameBocha {
|
||||
t.Fatalf("provider = %q, want %q", res.Provider, NameBocha)
|
||||
}
|
||||
if res.ErrorCode != "" || res.Error != "" {
|
||||
t.Fatalf("成功时不该带错误码/文案:%+v", res)
|
||||
}
|
||||
select {
|
||||
case r := <-got:
|
||||
if r.URL.Path != "/v1/web-search" {
|
||||
t.Errorf("请求路径 = %q, want /v1/web-search(各家固定路径)", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer "+key {
|
||||
t.Errorf("Authorization = %q(凭据必须真的被用上)", r.Header.Get("Authorization"))
|
||||
}
|
||||
default:
|
||||
t.Fatal("探针一个请求都没发 —— 连通性是发出来的,不是算出来的")
|
||||
}
|
||||
wsAssertNoSecret(t, res, key, srv.URL)
|
||||
}
|
||||
|
||||
// 判据 ②:空结果 ≠ 不通 —— 报 no_results,既不算成功、也不算「这家不可用」。
|
||||
func TestProbeEmptyResultsIsNoResultsNotFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv, _ := wsProbeServer(t, http.StatusOK, `{"webPages":{"value":[]}}`)
|
||||
res := Probe(context.Background(), NameBocha, Config{
|
||||
APIKey: "k", BaseURL: srv.URL, Client: srv.Client(),
|
||||
}, 1)
|
||||
if res.OK {
|
||||
t.Fatalf("空结果不得算成功:%+v", res)
|
||||
}
|
||||
if res.ErrorCode != ProbeNoResults {
|
||||
t.Fatalf("ErrorCode = %q, want %q", res.ErrorCode, ProbeNoResults)
|
||||
}
|
||||
}
|
||||
|
||||
// 判据 ③:失败必须可分类,且**文案里不许出现上游 URL/凭据**。
|
||||
func TestProbeFailureClassesAndNoLeak(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
status int
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{"凭据被拒", http.StatusUnauthorized, `{"error":{"code":"unauthorized"}}`, ProbeUnauthorized},
|
||||
{"限流", http.StatusTooManyRequests, `{}`, ProbeRateLimited},
|
||||
{"上游 5xx", http.StatusBadGateway, `{}`, ProbeUpstream},
|
||||
{"应答不是预期结构", http.StatusOK, `{"unexpected":true}`, ProbeBadResponse},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
const key = "probe-secret-key"
|
||||
srv, _ := wsProbeServer(t, tc.status, tc.body)
|
||||
res := Probe(context.Background(), NameBocha, Config{
|
||||
APIKey: key, BaseURL: srv.URL, Client: srv.Client(),
|
||||
}, 1)
|
||||
if res.OK {
|
||||
t.Fatalf("失败不得报 OK:%+v", res)
|
||||
}
|
||||
if res.ErrorCode != tc.want {
|
||||
t.Fatalf("ErrorCode = %q, want %q(文案=%q)", res.ErrorCode, tc.want, res.Error)
|
||||
}
|
||||
if res.Error == "" {
|
||||
t.Fatal("失败必须带一句可读文案(错误码与文案分离,前端可本地化)")
|
||||
}
|
||||
wsAssertNoSecret(t, res, key, srv.URL)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 判据 ④:名字没登记 / 没配齐 / 没客户端,三类都别去发请求,且分类准确。
|
||||
func TestProbePreflightRejectsBeforeSending(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
prov string
|
||||
cfg Config
|
||||
want string
|
||||
}{
|
||||
{"没登记的名字", "tavily", Config{APIKey: "k", BaseURL: "https://t.example"}, ProbeUnknownProvider},
|
||||
{"云家缺 key", NameBocha, Config{BaseURL: "https://b.example"}, ProbeNotConfigured},
|
||||
{"缺接口地址", NameBocha, Config{APIKey: "k"}, ProbeNotConfigured},
|
||||
{"没有 http 客户端", NameBocha, Config{APIKey: "k", BaseURL: "https://b.example"}, ProbeNotConfigured},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
res := Probe(context.Background(), tc.prov, tc.cfg, 1)
|
||||
if res.ErrorCode != tc.want {
|
||||
t.Fatalf("ErrorCode = %q, want %q(文案=%q)", res.ErrorCode, tc.want, res.Error)
|
||||
}
|
||||
if res.OK || res.Count != 0 {
|
||||
t.Fatalf("发不出去的探针不得报成功:%+v", res)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 判据 ⑤:网络层失败归 network(连不上也有稳定错误码,不是一团未知)。
|
||||
func TestProbeNetworkFailureIsClassified(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv, _ := wsProbeServer(t, http.StatusOK, `{}`)
|
||||
url := srv.URL
|
||||
srv.Close() // 关掉:后续请求必然连不上
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
res := Probe(ctx, NameBocha, Config{APIKey: "k", BaseURL: url, Client: &http.Client{}}, 1)
|
||||
if res.ErrorCode != ProbeNetwork {
|
||||
t.Fatalf("ErrorCode = %q, want %q", res.ErrorCode, ProbeNetwork)
|
||||
}
|
||||
}
|
||||
|
||||
// 判据 ⑥:错误码 ↔ 文案一一对应,且未分类有兜底(不许出现「空文案的失败」)。
|
||||
func TestProbeMessagesCoverEveryCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, code := range []string{
|
||||
ProbeUnknownProvider, ProbeNotConfigured, ProbeUnauthorized, ProbeRateLimited,
|
||||
ProbeQuotaExceeded, ProbeNetwork, ProbeUpstream, ProbeBadResponse,
|
||||
ProbeNoResults, ProbeUnknown,
|
||||
} {
|
||||
if msg := probeMessage(code); strings.TrimSpace(msg) == "" {
|
||||
t.Errorf("错误码 %q 没有文案", code)
|
||||
}
|
||||
}
|
||||
if ProbeErrorCode(nil) != "" {
|
||||
t.Fatal("nil 错误没有错误码")
|
||||
}
|
||||
}
|
||||
|
||||
// wsAssertNoSecret 断言探针结论里既没有凭据、也没有上游 URL(两者都可能出现在上游
|
||||
// 错误体或网络错误里,而这份结构会进面板响应)。
|
||||
func wsAssertNoSecret(t *testing.T, res ProbeResult, secret, upstreamURL string) {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(res)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if strings.Contains(string(raw), secret) {
|
||||
t.Fatalf("探针结论泄漏了凭据: %s", raw)
|
||||
}
|
||||
if strings.Contains(string(raw), upstreamURL) {
|
||||
t.Fatalf("探针结论泄漏了上游地址: %s", raw)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user