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

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