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