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:
AskaEth committed 2026-10-06 09:04:04 +08:00
1 parent 2d21aa553e
commit 120d926cf8
11 files changed
+1811 -41

No files matched your search

+109
View File
@@ -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 }
+218
View File
@@ -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
}
+87
View File
@@ -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() }
+189
View File
@@ -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 "探针失败,原因未分类(详见服务端日志)"
}
}
+189
View File
@@ -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)
}
}