package main // 本文件是 web-search「**各家 provider 的配置投影**」—— 组合根里唯一把配置翻译成各家接线 // 的地方。它存在的理由是一条硬约束:**`.env` 已冻结**(基础键不加/不改/不删)。 // // 旧形状是「一家一个 env 字段」:`config.WebSearchConfig` 有 `ZhipuAPIKey` / `BochaAPIKey` / …, // 组合根再逐家读这些字段(外加一个逐家写死的 switch 把面板文件的值搬回去)。那个形状的 // 后果是**加一家 provider 就要先加一个 env 键** —— 直接破 `.env` 冻结红线。 // // 新形状把**名字表收回到插件自己手里**(`plugins/tools/io/search` 的接入位表): // // - 各家叫什么、要不要 key、没给基地址时回落哪个默认地址 ⇒ 全在 `search.Descriptors()`; // - 组合根只做一件事:**按名字**在面板文件里找同名条目,有就取文件的值,没有就是没配; // - `.env` 退居**播种层**:只有「文件缺失/为空」时才由 `seedWebSearchFile` 落盘一次 // (既有 6 家的键与值逐字不变,主人已在用的配置不受影响)。 // // ⇒ 加一家 provider = `plugins/tools/io/search` 加一个接入位文件 + 表里一行,**本文件一行 // 都不用改**,`.env` 一个键都不用加。`tencent_wsa` / `metaso` 就是这么进来的(两家都 // **没有** env 字段:`.env` 已冻结,它们的 key / 基地址只从面板文件取)。 import ( "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io" "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search" ) // effectiveWebSearchConfig 是一次装配的**生效配置**,分两层: // // - Scalar:文件级标量(主 provider / 降级链 / 条数上限 / 超时)与 searxng 的端点。 // 它沿用 config.WebSearchConfig,因为那几个裁决函数都挂在它上面 // (ResolveProvider / ResolveEndpoint),而它们是 `.env` 与面板**共用**的一套语义。 // - Providers:各家(**不含 searxng**)的生效投影,按**接入位表**逐家给出。 // // 为什么各家的 key/基地址不再写回 Scalar 的逐家字段:那张字段表是 `.env` 的形状,碰它就要 // 动 env 键(见文件头)。逐家字段在这个结构里**不再承载生效值**(清零),免得被读成 // 「生效配置里的凭据」—— 那正是旧形状最容易被误用的地方。 type effectiveWebSearchConfig struct { // Scalar 是文件级标量与 searxng 端点(逐家字段一律清零,见上方说明)。 Scalar config.WebSearchConfig // Providers 是各家的生效投影(不含 searxng),顺序 = 接入位表的展示顺序。 Providers []searchProviderWire } // searchProviderWire 是「一家云 provider 的配置投影」:名字 + 凭据 + 基地址。 // searxng 不在这张表里(它的「基地址」就是端点,由 ResolveEndpoint 裁决整把工具的存亡)。 type searchProviderWire struct { Name string APIKey string BaseURL string } // searchProviderVector 是 **`.env` 播种层**给出的各家投影(既有 6 家的 env 字段表, // 云家部分)。它只服务两处:`.env` 缺失时的播种内容(seedWebSearchFile),以及 // 「没有可用文件」时的生效投影(envEffectiveWebSearch)。 // // ⚠️ 它**不是**「本期内置几家」的名单:登记表里还有两家**没有 env 字段**的家 // (tencent_wsa / metaso,`.env` 已冻结 ⇒ 不给它们加键),它们只从面板文件生效 // (`resolveSearchProviders` 按 `search.Descriptors()` 逐家投影,不经过本函数)。 // 它也**不是**逐条的兜底层:文件存在时,文件里没有的家就是没有(见 resolveSearchProviders)。 // 这条口径由 `wire_web_search_panel_test.go` 的 // `TestWireIOTools_PanelConfigClearedProviderDoesNotReviveFromEnv` 钉住。 func searchProviderVector(ws config.WebSearchConfig) []searchProviderWire { return []searchProviderWire{ {Name: search.NameZhipu, APIKey: ws.ZhipuAPIKey, BaseURL: ws.ZhipuBaseURL}, {Name: search.NameAliyunIQS, APIKey: ws.AliyunIQSAPIKey, BaseURL: ws.AliyunIQSBaseURL}, {Name: search.NameBocha, APIKey: ws.BochaAPIKey, BaseURL: ws.BochaBaseURL}, {Name: search.NameVolcengine, APIKey: ws.VolcengineAPIKey, BaseURL: ws.VolcengineBaseURL}, {Name: search.NameBaiduQianfan, APIKey: ws.BaiduQianfanAPIKey, BaseURL: ws.BaiduQianfanBaseURL}, } } // envSearchProviders 是 `.env` 播种层的**全量**投影:searxng(基地址 = WEB_SEARCH_ENDPOINT) // 打头,其余各家跟随 —— 顺序与既有播种逐字一致。 func envSearchProviders(ws config.WebSearchConfig) []searchProviderWire { out := make([]searchProviderWire, 0, len(searchProviderVector(ws))+1) out = append(out, searchProviderWire{Name: search.NameSearXNG, BaseURL: seedEndpoint(ws.Endpoint)}) return append(out, searchProviderVector(ws)...) } // effectiveSearchProviders 是各家(**不含 searxng**)在本次装配里的生效投影。 // // 生产路径只有薄薄这一层:接入位表从插件自己那里取(`search.Descriptors()`)。加一家 // provider 时这里**零改动** —— 这正是本片「去 env 化」的核心。 func effectiveSearchProviders(f runtimeconfig.WebSearchFile) []searchProviderWire { return resolveSearchProviders(search.Descriptors(), f) } // resolveSearchProviders 逐家算出生效投影。**判定顺序(文件权威)**: // // 1. 面板文件里有同名条目且**启用** ⇒ 取条目里的 apiKey / baseUrl(凭据以文件为准: // 条目里 key 为空 = 这家没配,绝不拿 `.env` 的旧 key 顶上,否则「我在面板里删了它」 // 会变成一句假话); // 2. 面板文件里有同名条目但**被显式停用** ⇒ 这家不生效(停用是显式选择,不回落别处); // 3. 面板文件里**没有**该条目 ⇒ 这家不生效(`.env` 只在**文件缺失/为空**时播种,文件一旦 // 存在就是权威;见 searchProviderVector 的注释与那条既有回归用例)。 // // ⚠️ **一处经核对的偏差(记在这里,免得后人踩)**:任务书里有一句「既有 6 家在文件里没有该 // 条目时仍按现状回落 `.env` 值」。本仓现状**没有**这层回落,且有两条既有约束钉着它: // ① `runtimeconfig.WebSearchFile.Providers` 的形状契约写明「允许为空数组(= 一家都没配 ⇒ // 组合根不注册 web-search)」;② 回归用例 `TestWireIOTools_PanelConfigClearedProviderDoesNot // ReviveFromEnv` 明确要求「文件里没有的家不得从 `.env` 复活」。本片按「**不改变既有行为**」 // 实现(既有 websearch 系列用例逐条未改语义、全绿),需要那层回落时改的就是下面这个循环。 // // 被跳过与未配置的家**仍带着基地址**(回落到接入位的默认值):接线表的 BaseURL 会被组合根 // 逐个加进 SSRF 白名单(io 包的 withSearchEndpointHost),这里悄悄少一家等于改了那份白名单。 // // defs 由调用方给(生产 = `search.Descriptors()`):测试可注入一个**假接入位**,从而驱动 // 「没有 env 字段的家也能只认文件生效」这条判据 —— 那是本片存在的理由,必须可证。 func resolveSearchProviders(defs []search.Descriptor, f runtimeconfig.WebSearchFile) []searchProviderWire { out := make([]searchProviderWire, 0, len(defs)) for _, d := range defs { // searxng 的「基地址」是端点(`WEB_SEARCH_ENDPOINT` / 文件里那条的 baseUrl), // 由 effectiveSearchConfig 单独裁决:空地址 = 关掉这家,语义与云家不同。 if strings.EqualFold(d.Name, search.NameSearXNG) { continue } entry, found := findProviderEntry(f, d.Name) if !found || !entry.IsEnabled() { out = append(out, searchProviderWire{Name: d.Name, BaseURL: baseURLChain("", d.DefaultBaseURL)}) continue } out = append(out, searchProviderWire{ Name: d.Name, APIKey: strings.TrimSpace(entry.APIKey), BaseURL: baseURLChain(entry.BaseURL, d.DefaultBaseURL), }) } return out } // baseURLChain 是「一家的基地址」的**唯一进位链**:条目里的值 → 该接入位的代码默认基地址 // → 空。 // // 空只可能出现在没有默认地址的家上(searxng):它的地址为空本身就是「关掉这家」的开关语义, // 回落一个本机地址会让「清空地址」变成假话。 func baseURLChain(fromEntry, accessSlotDefault string) string { if s := strings.TrimSpace(fromEntry); s != "" { return s } return accessSlotDefault } // resolveBaseURL 按**名字**走同一条进位链(动作面只拿得到名字与条目):默认基地址由接入位表 // 给出(`search.DefaultBaseURL`,未登记的名字 ⇒ 空)。 // // 装配期**不用**这个入口而是直接用 baseURLChain:那里手里已经有接入位描述,而「没有 env // 字段的家」在登记之前按名字查不到默认地址 —— 那正是本片要打通的那一类家。 func resolveBaseURL(name, fromEntry string) string { return baseURLChain(fromEntry, search.DefaultBaseURL(name)) } // searchProviderSpecs 把生效配置翻译成各家接线。 // // searxng 单独一行:它的基地址是端点、可用性由 ResolveEndpoint 裁决(不是「有没有 key」); // 其余各家按接入位表逐家给出,Configured 的口径仍由 `search.Configured` 统一判定 // (searxng 看端点、云家看 key;false ⇒ 该家不进注册表、不出现在可用清单里)。 // // **密钥只在这里流转,绝不写日志。** func searchProviderSpecs(ws config.WebSearchConfig, providers []searchProviderWire) []iotools.SearchProviderSpec { endpoint := ws.ResolveEndpoint() specs := make([]iotools.SearchProviderSpec, 0, len(providers)+1) specs = append(specs, iotools.SearchProviderSpec{ Name: search.NameSearXNG, BaseURL: endpoint.URL, Configured: endpoint.Enabled, }) for _, p := range providers { specs = append(specs, iotools.SearchProviderSpec{ Name: p.Name, APIKey: p.APIKey, BaseURL: p.BaseURL, Configured: search.Configured(p.Name, p.APIKey, p.BaseURL), }) } return specs } // availableProviderNames 返回已配置的家(即「可用清单」),供启动日志如实展示。 // **不含任何密钥** —— 只回名字。 func availableProviderNames(specs []iotools.SearchProviderSpec) []string { out := make([]string, 0, len(specs)) for _, s := range specs { if s.Configured { out = append(out, s.Name) } } return out } // seedEndpoint 把 `WEB_SEARCH_ENDPOINT` 的取值播进 searxng 条目:显式关闭的哨兵落成空串 // (两者语义相同:都表示不出这家,但面板里不该显示一个不是地址的 "off")。 func seedEndpoint(endpoint string) string { if config.IsWebSearchOffSentinel(endpoint) { return "" } return strings.TrimSpace(endpoint) }