- 新增 $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 的相关注册留给主会话。
190 lines
7.9 KiB
Go
190 lines
7.9 KiB
Go
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 "探针失败,原因未分类(详见服务端日志)"
|
||
}
|
||
}
|