diff --git a/Docs/api-client.md b/Docs/api-client.md index 5e6ac8b..83d305e 100644 --- a/Docs/api-client.md +++ b/Docs/api-client.md @@ -1,6 +1,6 @@ # Cyrene v2 客户端 API 契约 -> **契约版本:`v0.86`**(上一版 `v0.85`,其条目见 §6;`v0.84` / `v0.83` / `v0.82` / `v0.81` / `v0.80` / `v0.79` / `v0.78` / `v0.77` / `v0.76` / `v0.75` / `v0.74` / `v0.73` / `v0.72` / `v0.71` 及更早见本文件 §6,`v0.67` 及更早的基线已归档、只读:`Docs/archive/api-client.v0.65.md`)。 +> **契约版本:`v0.87`**(上一版 `v0.86`,其条目见 §6;`v0.85` / `v0.84` / `v0.83` / `v0.82` / `v0.81` / `v0.80` / `v0.79` / `v0.78` / `v0.77` / `v0.76` / `v0.75` / `v0.74` / `v0.73` / `v0.72` / `v0.71` 及更早见本文件 §6,`v0.67` 及更早的基线已归档、只读:`Docs/archive/api-client.v0.65.md`)。 > **读者**:Web 前端 / 移动端原生客户端(含 Android)/ IM 桥接等**跨平台客户端开发者**。 > **唯一真源 = 当前代码**(`internal/services/gateway/*` 等)。本文档由代码逐条核对得出;与代码不符时**以代码为准**,并回改本文档。 > **旧的超详细版已归档、只读**:`Docs/archive/api-client.v0.65.md`(412 KB,含 v0.28→v0.65 全部历史条目)与 `Docs/archive/api-reference.v0.65.md`(170 KB,后端全量字段级文档)。归档的原因是**结构性臃肿 + 历史条目并列 + 长期漂移**;本文档**不复制**那段历史,需要考古时去归档。 @@ -23,6 +23,7 @@ > **v0.77 是链路图按路分桶 + 记忆整理护栏/异步落地版(已实现;纯增量)**:① **`graph_update` 按路分桶**(切片 FV2)—— 帧 `data` 新增可选 `turnId`(该图的**路**的权威回合标识)与 `round`(会话轮次号);真并行(`parallel:true`)时同一会话**每一路各发自己的帧**(`data.id` / `data.turnId` 各不相同),两路不再互相顶掉;`GET /api/v1/admin/flow/graph` 响应新增 `lanes[]`(**最近一轮每一路**的图,恒为非 null 数组),`graph` 语义不变(最新一路 / 指定轮 / `null` 诚实空态)⇒ **老的读取方零改动**。**无破坏性变更**(两个新键可选;单路时 `turnId` 缺席 ⇒ 客户端行为与 v0.75 逐字相同)。详见 **§2.2 B13 / §3.4.5 / §4.11 / §7.4-19**。② **记忆整理护栏抬高 + 诚实截断 + 试算异步落地**(切片 MT-A)—— 每分区候选上限 `MEMORY_TIDY_CANDIDATE_LIMIT` 默认值 `20 → 200`、两两比对上限改为 env 键新键 `MEMORY_TIDY_COMPARE_LIMIT`(默认 `2000`,原先**写死** `400`);配置事实面 **12 → 13 条**;`truncated` 新增第 4 种标签 **`(budget)`**(墙钟时间预算截断);**v0.76 ③ 的加性异步已落地**(`?async=1` + B15 轮询可用;新错误码 **`tidy_run_not_found`**)。详见 **§2.2 B14/B15 / §4.10(a) / §4.12 / §4.12.1 / §5**。③ **「入库前审查」的 4 个 env 键登记**(切片 MT-B,纯增量)—— `MEMORY_REVIEW_ENABLED`(`true`)/ `MEMORY_REVIEW_QUEUE_SIZE`(`256`)/ `MEMORY_REVIEW_SCAN_LIMIT`(`200`)/ `MEMORY_REVIEW_TIMEOUT_SEC`(`20`);⚠️ 该切片的**面板读侧 / 回滚端点目前没有 HTTP 入口**(见 §7.2)。详见 **§5**。 > **v0.86 是「搜索结果条数上限可配」版(已实现并推库 `bff57bd` + `c68387a`;**纯增量:面板多一个 number 字段 + 两个动作各多一个键;无破坏性变更**)**:`web-search` 的「结果条数上限」从**改不了**变成**面板上真能改**(此前三处断点:字段表里没这个字段 / `saveProvider` 只并四个键 / `mergeSelectionPatch` 只处理 `primary`+`fallback` ⇒ **手工塞进 `values` 也会被静默丢弃**,「我改了它却没变」成立)。① **面板声明**新增字段:`pluginId=web-search` / `panel=providers` / form `add` 多一个 **`maxResults`**(`type:"number"`, `min:1`, 标签「结果条数上限(默认 10)」)—— 前端本就渲染 `number` ⇒ **客户端零改动**。② **`plugin.saveProvider` 的 `values` 新增可写键 `maxResults`**:**三态** —— 缺位/null ⇒ 不改动;**正整数**(含整数字符串)⇒ 采用;`0` / 负数 / 小数 / 空串 / 非数 / 布尔 / 数组 ⇒ **`400 invalid_payload`**,且**盘上一字节不动**(**绝不静默忽略**)。③ 响应新增回显:`plugin.saveProvider` 的 `data.result.maxResults`(**0 = 文件里未声明**)、`plugin.reauthProvider` 的 `values.maxResults`(盘上有值才出现 ⇒ 用于「重配凭据」回填,否则一保存就把上限清掉)。④ **语义**:面板文件的 `maxResults` **压过** `.env` 的 `WEB_SEARCH_MAX_RESULTS`,是**运行期真上限**(模型的 `count` 只能更小),**保存即生效**(走既有热重载,响应里的 `hotReload`);代码默认值 **5 → 10**(三处口径已一致:env 默认 / io 包 `defaultMaxResults` / 面板文件)。⑤ **计数不变**(无新端点 ⇒ §2.0 维持 `14 + 25 + 40 = 79`)。⚠️ **已知取舍(如实)**:**清空输入框 = `400`**(后端按「非数 ⇒ 报错」拒)⇒ 面板上**没有「去掉上限」的手势**(要回到 `.env` 兜底得改文件);**小数/负数一律拒**;文件里没写该键时 `reauth` **不回填**(避免回填 0 让保存整笔进不去),因此那个框是空的、看不出当前生效值。 +> **v0.87 是「搜索 provider 扩到八家」版(已实现,纯增量;⚠️ 两家的成功响应都未真机实测)**:新增两家已登记接入位 —— **`tencent_wsa`**(腾讯云·元宝搜索,`POST {base}/SearchPro` + `Authorization: Bearer`,**零签名**;🔴 **HTTP 状态码恒 200** ⇒ 判失败**只能**读响应体 `Response.Error.Code`;`Pages` 是**JSON 字符串数组** ⇒ 需二次解析)与 **`metaso`**(秘塔搜索,`POST {base}/api/v1/search`;⚠️ 响应 schema 全部来自第三方来源且互相打架 ⇒ 实现走**宽松解析、未实测**)。对客户端的**唯一影响是两处名单**:`plugin.saveProvider` 的 `values.provider` 合法闭集(**六家 → 八家**)与 `plugin.providers` 的候选(多两条 `{value,label}`,中文名见 §6 本版条目)—— **端点形状 / 错误码 / 三态写回语义 / §2.0 计数(`14 + 25 + 40 = 79`)全部一字未改**,且前端候选本就是 `optionsFrom` 数据驱动 ⇒ **客户端零改动**。**零新配置键**:两家只走 `$DATA_DIR/config/plugins/web-search.json`,`.env` / `.env.example` **未动**。 > **v0.85 是「整理不再只产建议」版(已实现并推库 `6d92536`;**新增 1 条 writeAuth 端点 + 一处行为变更(整理默认自动落地)+ `link` 候选停产;无破坏性变更**)**:① **新增端点 `POST /api/v1/admin/memories/candidates/reject?action=&limit=`(B25)** —— 按**动作**批量把 `pending` 候选标成 `rejected`(**幂等、只改候选状态、绝不碰记忆行**)。`action` **必填**且是**闭集**(`merge|link|archive|reclassify|keep`,未知值 → `400 bad_request`);`limit` 缺省 **1000**、硬顶 **10000**、非整数 → `400`。`200 {data:{action,limit,total,rejected,truncated}}`(`total` 是命中筛选的**真**条数,`rejected` 只覆盖 `limit` 之内 ⇒ 不等时 `truncated:true`)。审计 `memory.tidy.reject`(只带计数)。语义见 **§4.15(e)**。② 🔴 **行为变更(默认生效):整理在轮末会「自动落地」两类高置信动作**,不再只产建议(主人 2026-10-07 口径「该删就删」)——**可自动的只有**:`merge`(**字面重复** ∨「事实集合等价 ∧ 相似度 ≥ 0.95」)、`archive`(理由 = `one_off_event` **且不像习惯**)。**不自动**:`reclassify`、低置信 merge、跨分区/跨角色、任何改正文、**受限来源(群聊/陌生人)**。六条护栏:软动作(正文不改、行不删)/ **每轮上限 50**(硬顶 500,超限如实 `truncated`)/ 审计 `memory.tidy.apply`(`auto=true`)/ **可 revert**(回滚路径与人工应用同一条)/ 幂等 / **一键关回只产建议**(`$DATA_DIR/config/memory-tidy.json` 的 `{"autoApply": false}`,**零新 env 键**)。⚠️ **面板侧的候选列表仍可能显示历史 `link` 行**,直到用 B25 清掉。③ **`link` 候选停产**:`link` 在 `design/23 §14` 里被列为**未细化**(既无落点也无数据模型),且 `applyable()` 只认 merge/archive/reclassify ⇒ **应用 link 恒 `skipped`,产出它只是往待办里灌噪音**。现在规则层不再产出(判 `keep`);`TidyLink` 常量保留**只为读历史行**。存量用 B25 清(主会话已执行)。④ **计数**:`writeAuth` 段 24 → **25**、管理路由总数 78 → **79**(`14 + 25 + 40`;`routes.go` 核:经 `writeAuth` 挂载的 admin 路由实有 **27**,按历史口径减 §2.4(a) 的 `channels/events` 与 `.../counters` ⇒ **25**)。⑤ ⚠️ **诚实边界**:**未部署**(要一次 `build` + `restart`);**自动档的真机行为未验**(不能重启后端);`reclassify` 仍全部人工;`TidyRuleVersion` **刻意未升版**(本次不改变任何判定结论,升版会让既有候选按幂等键复制出重复行)。 @@ -1254,7 +1255,7 @@ | `plugin.testProvider` | 测试连通(**真发一次最小检索**) | write(table `rowActions`) | `{"row":{"provider":"bocha"}}`(亦可给 `{"values":{…}}` 或顶层 `provider`) | `{"ok":true,"provider":"bocha","count":1,"latencyMs":123}`;**没通 = 非 2xx**(见错误表),**不在 200 里回 `ok:false`** | | `plugin.reauthProvider` | 重配凭据:把该条带回表单 | write(table `rowActions`) | `{"row":{"provider":"bocha"}}` | `{"values":{"provider":"bocha","apiKey":"****cdef","baseUrl":"https://api.bocha.cn","enabled":true,"primary":"bocha","fallback":["searxng"]}}`(**`apiKey` 只回掩码**;前端对 secret 字段一律不回填输入框,**空 = 不改动**)。⚠️ **v0.80 起 `values` 另带两个文件级字段** `primary`(字符串)/ `fallback`(字符串数组)—— **不带它们**的话,面板「清空降级链」在客户端三态写回里会落成「缺位 = 不改动」(**清不掉**) | -`plugin.saveProvider` 的**三态写回哨兵**(写侧语义,前端须照此送值):`values.apiKey` **缺位** ⇒ 保留盘上原值;**空串** ⇒ 显式清空;非空 ⇒ 覆盖。`values.baseUrl` / `values.enabled` **缺位** ⇒ 保留;给出(含 `""` / `false`)⇒ 采用(`enabled` 给非布尔值 ⇒ `400 invalid_payload`,**不静默保留**)。`values.provider` **必填**,且必须是本期已登记的接入位(`searxng` / `zhipu` / `aliyun_iqs` / `bocha` / `volcengine` / `baidu_qianfan`),否则 `400 unknown_provider`。 +`plugin.saveProvider` 的**三态写回哨兵**(写侧语义,前端须照此送值):`values.apiKey` **缺位** ⇒ 保留盘上原值;**空串** ⇒ 显式清空;非空 ⇒ 覆盖。`values.baseUrl` / `values.enabled` **缺位** ⇒ 保留;给出(含 `""` / `false`)⇒ 采用(`enabled` 给非布尔值 ⇒ `400 invalid_payload`,**不静默保留**)。`values.provider` **必填**,且必须是本期已登记的接入位(`searxng` / `zhipu` / `aliyun_iqs` / `bocha` / `volcengine` / `baidu_qianfan` / **`tencent_wsa`** / **`metaso`**),否则 `400 unknown_provider`。🆕 **v0.87:名单由六家扩到八家**(新增腾讯云·元宝搜索与秘塔搜索),**端点形状与错误码一字未改**。 **(v0.80 新增)两个文件级字段的三态写回**(与条目字段同一套哨兵语义):`values.primary` **缺位** ⇒ 保留盘上值;给字符串(含空串 / `off` / `none`)⇒ 采用 —— **空串与 `off` / `none` 都是「关掉这把工具」的哨兵**(`off` / `none` 大小写与空白不敏感),`WEB_SEARCH_PROVIDER` 与面板**共用同一套关闭语义**(不另造第二套)。`values.primary` 指名一家**已登记但未配齐**的家 ⇒ **`400 primary_not_configured`**(**写盘之前**就拒,因为那份配置会让后端下次起不来)。`values.fallback` **缺位** ⇒ 保留;给数组(或逗号 / 顿号 / 分号 / 换行分隔的字符串)⇒ 采用(去空白、去重、丢空项);元素**未登记** ⇒ `400 unknown_provider`;给非数组 / 非字符串 ⇒ `400 invalid_payload`(**不静默当空数组** —— 那等于一次保存悄悄清空降级链)。降级链**允许**放本期已登记但**尚未配齐**的家(运行期会跳过它)。 @@ -1486,7 +1487,17 @@ curl -s -X POST -H "X-API-Key: $KEY" \ > 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。 > 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。** -### v0.86(当前契约版本) +### v0.87(当前契约版本) + +**「搜索 provider 扩到八家」版 —— 名单扩充,端点形状与错误码一字未改**(切片 `agent-389`,本次提交;依据:主人「我看之前不是走有头CDP扒了好几家搜索提供商的接口方式在本地参考文档吗?为什么不都做一下适配?」+「优先只做国内能用到的几家」。) + +- **新增两家已登记接入位**:`tencent_wsa`(腾讯云·元宝搜索,`POST {base}/SearchPro` + `Authorization: Bearer`,**零签名**)与 `metaso`(秘塔搜索,`POST {base}/api/v1/search`)。 +- **契约面影响(只有两处,且都是名单扩充)**:① `plugin.saveProvider` 的 `values.provider` 合法名单 **六家 → 八家**(见 §4.14 的三态写回那段);② `plugin.providers` 候选新增两条 `{value,label}`(中文名见 §6 本版上方那条的逐字表)。**§2.0 计数不变**(无新端点,维持 `14 + 25 + 40 = 79`)。 +- **前端零改动**:候选是数据驱动的(`optionsFrom`),中文名只是 `label`。 +- ⚠️ **诚实边界(如实,别当已验)**:① **腾讯成功响应未真机实测**(无 key)—— 依据是官方文档 + 本机无 key 探测(缺 `Authorization` 也回 **HTTP 200** + `Response.Error`);② **秘塔响应 schema 全部来自第三方来源且互相打架** ⇒ 代码走**宽松解析**(多字段名回退),**未真机验证**,拿到真 key 后必须按真实报文收窄;③ 两家的请求体都**只发官方确证为必选的字段**(腾讯只发 `Query`),条数靠本地截断。 +- **零新配置键**:两家只走 `$DATA_DIR/config/plugins/web-search.json`;`.env` / `.env.example` **未动**(`.env` 冻结口径不变)。 + +### v0.86 **「搜索结果条数上限可配」版 —— 面板多一个 number 字段 + 两个动作各多一个键**(切片 `bff57bd` + `c68387a`;依据:主人 2026-10-07 口径「返回五条结果会不会太少了?」+ 上一片登记的「`maxResults` 在面板上根本改不了」那条真缺陷。) @@ -1510,7 +1521,7 @@ curl -s -X POST -H "X-API-Key: $KEY" \ **「显示名 + 段落标签硬约束」版 —— 一处形状变更(插件动作响应)+ 一处新增产出侧硬约束 + 一条模型面登记**(依据:主人报障「下拉里全给英文名,你甚至不愿意带个括号把中文名写上去」;安卓端登记 `

正文

` 畸形;`design/28 §1 M5 / §8 B5` 的浏览器降级档。切片:显示名 `c46053e`、段落修复 `8051906`、provider 配置面去 env 化 `bc1dee3`、搜索 linkless 修复 `2c56bc9`。) -- **① 形状变更:`plugin.providers` 候选从裸字符串数组 → 对象数组 `{value,label}`**。见 **§4.14(c)** 与 §2.1 A14。根因**不是**漏写文案,而是**整条链路没有「显示名」的位置**(字段声明只有 `optionsFrom`,动作回的就是 `KnownNames()` 的纯 id 数组)。中文名逐字:`SearXNG 本地兜底(searxng)` / `智谱 BigModel(zhipu)` / `阿里云 IQS 信息查询(aliyun_iqs)` / `博查 Bocha(bocha)` / `火山引擎·豆包搜索(volcengine)` / `百度千帆 AI 搜索(baidu_qianfan)`。🔴 **`label` 仅供显示、`value` 才是落盘值**;前端解码器兼容三种形状 ⇒ 其他插件零影响;表格按 `value→label` 索引显示、查不到回落原值。 +- **① 形状变更:`plugin.providers` 候选从裸字符串数组 → 对象数组 `{value,label}`**。见 **§4.14(c)** 与 §2.1 A14。根因**不是**漏写文案,而是**整条链路没有「显示名」的位置**(字段声明只有 `optionsFrom`,动作回的就是 `KnownNames()` 的纯 id 数组)。中文名逐字:`SearXNG 本地兜底(searxng)` / `智谱 BigModel(zhipu)` / `阿里云 IQS 信息查询(aliyun_iqs)` / `博查 Bocha(bocha)` / `火山引擎·豆包搜索(volcengine)` / `百度千帆 AI 搜索(baidu_qianfan)` / **`腾讯云·元宝搜索(tencent_wsa)`** / **`秘塔搜索 Metaso(metaso)`**(🆕 **v0.87 起八家**)。🔴 **`label` 仅供显示、`value` 才是落盘值**;前端解码器兼容三种形状 ⇒ 其他插件零影响;表格按 `value→label` 索引显示、查不到回落原值。 - **② 新增产出侧硬约束:助手文本不得出现未配对的段落标签**(落单 `

` / `

`)。见 **§4.16**。修的是安卓登记的 `

正文

`;**两半根因都在产出侧**(规整器原样放过 + 在「末段漏 `

`」时亲手制造),另修一个幂等漏洞。**历史行不回填**、消费方**仍应保留**容错。 - **③ 模型面登记(非客户端契约面)**:`web-fetch` 的浏览器渲染降级档已接线,返回体用 `fetchedBy`(`direct` / `browser`)**如实标注**正文实际来自哪条路;两条档的失败各自留痕,**绝不静默替换**,被 SSRF 拒绝的目标**不换路**。 - **④ provider 配置面「去 env 化」(对客户端零形状影响,登记供跨端对照)**:新增 provider 不再需要新的 `.env` 键 —— 面板文件 `$DATA_DIR/config/plugins/web-search.json` **是权威**,`.env` 只在文件缺失/为空时**播种**(文件一旦存在,「文件里没有的家」**不从 env 复活** —— 这是既有回归用例钉住的口径,本次未改);各家「没配 baseUrl 就回落代码默认值」的默认值表移进接入位表(`search.Descriptor.DefaultBaseURL`),**逐字未变**。 diff --git a/cmd/cyrene/websearch_new_providers_test.go b/cmd/cyrene/websearch_new_providers_test.go new file mode 100644 index 0000000..9f14841 --- /dev/null +++ b/cmd/cyrene/websearch_new_providers_test.go @@ -0,0 +1,91 @@ +package main + +// 本文件钉住本片新增的两家(`tencent_wsa` / `metaso`)在**组合根里真的接通了**: +// 它们**没有 `.env` 字段**(`.env` 已冻结),因此凭据与地址只能来自面板文件 +// `$DATA_DIR/config/plugins/web-search.json`,而组合根**零改动**地按接入位表把它们投影出来 +// (`resolveSearchProviders` 逐家走 `search.Descriptors()`)。 +// +// 为什么值得端到端一条:`cmd/cyrene/websearch_providers.go` 里**一行都没改**——这正是 +// 「按接入位表投影」的设计意图;没有这条用例,「零改动」就只是一句没有证据的话。 +// +// 夹具(假上游、`wsSurfaceApp`、`wsExecuteSearch`)在 websearch_testenv_test.go / +// websearch_projection_test.go;`.env` 字段表的完整性由 wire_web_search_*_test.go 钉住。 + +import ( + "net/http" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" + "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search" +) + +// TestWebSearchPanelOnlyProvidersWiredEndToEnd 是判据:面板文件里配好一家**没有 env 字段** +// 的家 ⇒ 工具进注册面 ⇒ 真的把请求打到面板里那个地址、带上面板里那把 key,且结果里如实 +// 标注 provider。 +func TestWebSearchPanelOnlyProvidersWiredEndToEnd(t *testing.T) { + t.Parallel() + cases := []struct { + name string + path string + fixture string + }{ + { + name: search.NameTencentWSA, + path: "/SearchPro", + // Pages 的元素是 JSON **字符串**(官方 Array of String 形状)。 + fixture: `{"Response":{"Pages":["{\"title\":\"元宝结果\",\"url\":\"https://t.example/1\",` + + `\"passage\":\"元宝摘要\"}"],"Query":"昔涟","RequestId":"r"}}`, + }, + { + name: search.NameMetaso, + path: "/api/v1/search", + fixture: `{"webpages":[{"title":"秘塔结果","link":"https://m.example/1","snippet":"秘塔摘要"}]}`, + }, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + const key = "test-panel-only-key" + up := newWSFakeUpstream(t, http.StatusOK, tc.fixture) + // `.env` 只给「谁当主家」:这两家在 `.env` 里没有字段,凭据与地址只能来自面板文件。 + cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+tc.name) + // 前提:它确实不在 `.env` 字段表里(否则这条用例会退化成既有 6 家的回归)。 + for _, p := range searchProviderVector(cfg.WebSearch) { + if strings.EqualFold(p.Name, tc.name) { + t.Fatalf("前提被破坏:%s 不该出现在 .env 的字段表里", tc.name) + } + } + // 前提:接入位表里确有它(否则组合根根本投影不出来)。 + if !search.Known(tc.name) { + t.Fatalf("前提被破坏:%s 应已在接入位表里登记", tc.name) + } + wsSavePanel(t, cfg, runtimeconfig.WebSearchFile{ + Provider: tc.name, + Providers: []runtimeconfig.ProviderEntry{ + {Provider: tc.name, APIKey: key, BaseURL: up.url, Enabled: runtimeconfig.EnableFlag(true)}, + }, + }) + + a := wsSurfaceApp(t, cfg, nil) + if !wsHasTool(a, webSearchToolName) { + t.Fatalf("面板文件里配好的 %s 应让工具进注册面,实际工具面 = %v", tc.name, toolNames(a)) + } + if got := wsExecuteSearch(t, a, "昔涟"); got["provider"] != tc.name { + t.Fatalf("provider = %v, want %q(如实标注实际应答的家)", got["provider"], tc.name) + } + select { + case r := <-up.hits: + if r.URL.Path != tc.path { + t.Errorf("请求路径 = %q, want %q(各家固定路径)", r.URL.Path, tc.path) + } + if got := r.Header.Get("Authorization"); got != "Bearer "+key { + t.Errorf("Authorization = %q,want 面板文件里那把 key(凭据必须以文件为准)", got) + } + default: + t.Fatalf("%s 一个请求都没收到 —— 面板里的配置没接到 provider 上", tc.name) + } + }) + } +} diff --git a/cmd/cyrene/websearch_projection_test.go b/cmd/cyrene/websearch_projection_test.go index 75fba0f..a76f51f 100644 --- a/cmd/cyrene/websearch_projection_test.go +++ b/cmd/cyrene/websearch_projection_test.go @@ -35,11 +35,15 @@ import ( // // 它在 search 包里**没有**登记(`search.Known` 为 false),也没有对应的 `.env` 字段 —— // 于是它正好是判据 ③ 的载体:「没有 env 字段的家,只认文件也能被投影出生效配置」。 +// +// ⚠️ 名字取 `fake_next_provider` 而不是某个真接入位(早先这里写的是 `tencent_wsa`): +// `tencent_wsa` 已经**真的登记**(本片新增),沿用旧名会让这条用例的前提断言直接失败 —— +// 假接入位必须是一个**永远不会被登记**的名字,用例才钉得住「没 env 字段的家」这件事。 func wsFakeNewProviderDef() search.Descriptor { return search.Descriptor{ - Name: "tencent_wsa", + Name: "fake_next_provider", NeedKey: true, - DefaultBaseURL: "https://wsa.tencentcloudapi.com", + DefaultBaseURL: "https://fake-next.example", } } @@ -233,11 +237,12 @@ func TestWebSearchProviderFromEnvSeedLayer(t *testing.T) { // --- ③ 没有 env 字段的家:只认文件(本片的**核心判据**) ---------------------- // TestWebSearchProviderWithoutEnvFieldIsProjected 是判据 ③ 的投影层:**没有 env 字段的家** -// (测试专用的假接入位 `tencent_wsa`)在文件里有 key ⇒ 照样被投影出生效配置。 +// (测试专用的假接入位 `fake_next_provider`)在文件里有 key ⇒ 照样被投影出生效配置。 // // 这条判据就是本片存在的理由:旧形状的逐家 switch 只认 `.env` 字段表里的 6 家,第 7 家 // 想进来只能先加 env 键(破红线)。新形状按**接入位表**逐家投影,因此第 7 家只需在 -// `plugins/tools/io/search` 登记(加一个接入位文件 + 表里一行),组合根零改动。 +// `plugins/tools/io/search` 登记(加一个接入位文件 + 表里一行),组合根零改动 +// —— 本片新接的 `tencent_wsa` / `metaso` 走的正是这条路(它们没有 env 键)。 // // 最后一跳(进注册表)由同文件的 TestWebSearchFileOnlyProviderEntersRegistry 用真实接入位 // 走完:注册表按名字查的正是同一张接入位表。 @@ -252,7 +257,7 @@ func TestWebSearchProviderWithoutEnvFieldIsProjected(t *testing.T) { t.Fatalf("前提被破坏:%s 不该出现在 .env 的字段表里", fake.Name) } } - // 前提二:它确实还没登记(登记工作是下一片的:加接入位文件 + 表里一行)。 + // 前提二:它确实还没登记(假接入位的全部意义就在于此)。 if search.Known(fake.Name) { t.Fatalf("前提被破坏:假接入位 %s 不该已经登记", fake.Name) } diff --git a/cmd/cyrene/websearch_providers.go b/cmd/cyrene/websearch_providers.go index babdb6b..57ce1cb 100644 --- a/cmd/cyrene/websearch_providers.go +++ b/cmd/cyrene/websearch_providers.go @@ -15,7 +15,8 @@ package main // (既有 6 家的键与值逐字不变,主人已在用的配置不受影响)。 // // ⇒ 加一家 provider = `plugins/tools/io/search` 加一个接入位文件 + 表里一行,**本文件一行 -// 都不用改**,`.env` 一个键都不用加。 +// 都不用改**,`.env` 一个键都不用加。`tencent_wsa` / `metaso` 就是这么进来的(两家都 +// **没有** env 字段:`.env` 已冻结,它们的 key / 基地址只从面板文件取)。 import ( "strings" @@ -55,7 +56,10 @@ type searchProviderWire struct { // 云家部分)。它只服务两处:`.env` 缺失时的播种内容(seedWebSearchFile),以及 // 「没有可用文件」时的生效投影(envEffectiveWebSearch)。 // -// ⚠️ 它**不是**逐条的兜底层:文件存在时,文件里没有的家就是没有(见 resolveSearchProviders)。 +// ⚠️ 它**不是**「本期内置几家」的名单:登记表里还有两家**没有 env 字段**的家 +// (tencent_wsa / metaso,`.env` 已冻结 ⇒ 不给它们加键),它们只从面板文件生效 +// (`resolveSearchProviders` 按 `search.Descriptors()` 逐家投影,不经过本函数)。 +// 它也**不是**逐条的兜底层:文件存在时,文件里没有的家就是没有(见 resolveSearchProviders)。 // 这条口径由 `wire_web_search_panel_test.go` 的 // `TestWireIOTools_PanelConfigClearedProviderDoesNotReviveFromEnv` 钉住。 func searchProviderVector(ws config.WebSearchConfig) []searchProviderWire { diff --git a/cmd/cyrene/wire_web_search_panel_test.go b/cmd/cyrene/wire_web_search_panel_test.go index ff8042c..fef2db3 100644 --- a/cmd/cyrene/wire_web_search_panel_test.go +++ b/cmd/cyrene/wire_web_search_panel_test.go @@ -73,8 +73,18 @@ func TestWireIOTools_PanelConfigSeedsFromEnvThenIsAuthoritative(t *testing.T) { if f.Provider != search.NameBocha { t.Fatalf("播种的主 provider = %q, want %q", f.Provider, search.NameBocha) } - if len(f.Providers) != len(search.KnownNames()) { - t.Fatalf("播种必须列全本期内置的家:%d 条, want %d", len(f.Providers), len(search.KnownNames())) + // 播种内容 = `.env` 字段表那张表(searxng + 5 家云家)。**没有 env 字段的家** + // (tencent_wsa / metaso)不进播种:`.env` 已冻结(不加键),它们只从面板文件配。 + seededNames := envSearchProviders(first.WebSearch) + if len(f.Providers) != len(seededNames) { + t.Fatalf("播种必须列全 `.env` 字段表里的家:%d 条, want %d", len(f.Providers), len(seededNames)) + } + for _, absent := range []string{search.NameTencentWSA, search.NameMetaso} { + for _, e := range f.Providers { + if strings.EqualFold(e.Provider, absent) { + t.Fatalf("没有 env 字段的家 %s 不得被播种(它只从面板文件配)", absent) + } + } } seeded := false for _, e := range f.Providers { diff --git a/cmd/cyrene/wire_web_search_providers_test.go b/cmd/cyrene/wire_web_search_providers_test.go index 30bebe1..c6ad4b3 100644 --- a/cmd/cyrene/wire_web_search_providers_test.go +++ b/cmd/cyrene/wire_web_search_providers_test.go @@ -112,7 +112,10 @@ func TestWireIOTools_AvailableListExcludesUnconfigured(t *testing.T) { if !strings.Contains(out, search.NameBocha) { t.Fatalf("可用清单里应有 bocha: %q", out) } - for _, absent := range []string{search.NameZhipu, search.NameAliyunIQS, search.NameVolcengine, search.NameBaiduQianfan} { + for _, absent := range []string{ + search.NameZhipu, search.NameAliyunIQS, search.NameVolcengine, search.NameBaiduQianfan, + search.NameTencentWSA, search.NameMetaso, + } { if strings.Contains(out, absent) { t.Fatalf("缺 key 的 %s 不得出现在可用清单里: %q", absent, out) } @@ -170,16 +173,17 @@ func TestWireIOTools_SelectedCloudProviderIsUsed(t *testing.T) { } // TestWireIOTools_ProviderVectorSummary 是一句「本期内置哪几家」的防回归: -// 六家的名字都必须被 search 包登记(新增一家别忘了加文件 + 注册表一行)。 +// 八家的名字都必须被 search 包登记(新增一家别忘了加文件 + 注册表一行)。 func TestWireIOTools_ProviderVectorSummary(t *testing.T) { t.Parallel() want := map[string]bool{ search.NameSearXNG: true, search.NameZhipu: true, search.NameAliyunIQS: true, search.NameBocha: true, search.NameVolcengine: true, search.NameBaiduQianfan: true, + search.NameTencentWSA: true, search.NameMetaso: true, } got := search.KnownNames() if len(got) != len(want) { - t.Fatalf("KnownNames() = %v, want %d 家(本期内置:searxng/zhipu/aliyun_iqs/bocha/volcengine/baidu_qianfan)", + t.Fatalf("KnownNames() = %v, want %d 家(本期内置:searxng/zhipu/aliyun_iqs/bocha/volcengine/baidu_qianfan/tencent_wsa/metaso)", got, len(want)) } for _, n := range got { diff --git a/internal/config/runtimeconfig/websearch.go b/internal/config/runtimeconfig/websearch.go index 289d42e..72ce21b 100644 --- a/internal/config/runtimeconfig/websearch.go +++ b/internal/config/runtimeconfig/websearch.go @@ -49,8 +49,8 @@ const ( ) // webSearchProviderRe 是 provider 名的字符集。本仓内置名全是小写字母 + 下划线 -// (searxng / zhipu / aliyun_iqs / bocha / volcengine / baidu_qianfan),限定字符集是为了 -// 让名字能直接当端点段、文件名与日志字段用。 +// (searxng / zhipu / aliyun_iqs / bocha / volcengine / baidu_qianfan / tencent_wsa / +// metaso),限定字符集是为了让名字能直接当端点段、文件名与日志字段用。 // // 它**不**判定「这个名字是否已登记」—— 那是 `plugins/tools/io/search` 的事实,本包不去 // 反向依赖插件包(组合根负责核对并记 WARN)。 diff --git a/internal/config/websearch.go b/internal/config/websearch.go index ac6f158..5421005 100644 --- a/internal/config/websearch.go +++ b/internal/config/websearch.go @@ -10,7 +10,12 @@ import ( // 写法照既有 VoiceConfig 惯例:一组 provider 键 + 未配置则如实降级,绝不硬编码。 type WebSearchConfig struct { // Provider 选谁当后端(design/27 §3):`searxng`(默认)/ `zhipu` / `aliyun_iqs` / - // `bocha` / `volcengine` / `baidu_qianfan`。 + // `bocha` / `volcengine` / `baidu_qianfan` / `tencent_wsa` / `metaso`。 + // + // ⚠️ 后两家(`tencent_wsa` / `metaso`)**没有对应的 env 字段**(`.env` 已冻结:不加键) + // —— 它们只在 `$DATA_DIR/config/plugins/web-search.json` 里配(组合根按插件侧的接入位表 + // 逐家投影,见 cmd/cyrene/websearch_providers.go)。本结构里的逐家字段仍然**只**服务 + // 已有的 6 家播种/覆盖。 // // **语义硬约束**(design/27 §3,由组合根的 io 工具集在启动期执行): // - 指向**未登记**(如本期未做的 tavily)或**未配置**(缺 key/端点)的名字 diff --git a/plugins/tools/io/doc.go b/plugins/tools/io/doc.go index daa6c67..32e9087 100644 --- a/plugins/tools/io/doc.go +++ b/plugins/tools/io/doc.go @@ -22,7 +22,8 @@ // the host is on an explicit allowlist). // - web-search: queries the configured search **provider** through the registry in // plugins/tools/io/search (searxng / zhipu / aliyun_iqs / bocha / volcengine / -// baidu_qianfan; one provider per file, design/27 §4). The model sees exactly one +// baidu_qianfan / tencent_wsa / metaso; one provider per file, design/27 §4). The +// model sees exactly one // tool whose schema never changes with the backend. A configured fallback chain is // tried in order, and only an *unavailable* provider (missing key, auth / rate // limit / quota / network failure) advances the chain — an empty result set does diff --git a/plugins/tools/io/search/errors.go b/plugins/tools/io/search/errors.go index 5c21286..61ffd07 100644 --- a/plugins/tools/io/search/errors.go +++ b/plugins/tools/io/search/errors.go @@ -133,9 +133,14 @@ func upstreamCodeClass(code string) (error, bool) { // 搜索专用码 1701(并发达上限)/1702/1703 只登记、不在此映射:官方未给它们的 HTTP 状态码, // 保守交给状态码兜底(429 未收录码 ⇒ rate_limited,其余 4xx/5xx ⇒ upstream)。 // -// ⚠️ 作用域:这些是**裸数字码**,而本函数被所有 provider 共用。当前六家里只有智谱用数字业务码 -// (阿里云是 `Retrieval.*` 点分码)⇒ 暂按码值归类。**若将来出现第二家用数字码的家,必须改成 -// 「按 provider 分表」**,不能靠码值碰运气。 +// ⚠️ 作用域:这些是**裸数字码**,而本函数被所有 provider 共用。走本函数的家里只有智谱用数字 +// 业务码(阿里云是 `Retrieval.*` 点分码)⇒ 暂按码值归类。**若将来有第二家走本函数的家也用 +// 数字码,必须改成「按 provider 分表」**,不能靠码值碰运气。 +// +// 已经有第二家用数字码的家族:秘塔(`errCode=2005` 等)与腾讯云(大写点分码)。它们**不经过 +// 本函数**——那两家的错误随 **HTTP 200** 返回,classifyStatus(只处理非 2xx)根本看不到, +// 因此各自在 provider 文件里维护自己的码表(`metasoCodeClass` / `tencentCodeClass`)。 +// 这正是「按 provider 分表」的落地形态:**共用表保持最小、各家码表就近**。 func zhipuCodeClass(code string) (error, bool) { switch strings.TrimSpace(code) { case "1000", "1001", "1003": diff --git a/plugins/tools/io/search/extract.go b/plugins/tools/io/search/extract.go index 44f359e..0d147a6 100644 --- a/plugins/tools/io/search/extract.go +++ b/plugins/tools/io/search/extract.go @@ -12,8 +12,10 @@ import ( // spec 描述「怎么把一家 provider 的 JSON 归一到 []Result」:每个字段给一串**候选键名** // (按序取第一个非空值),结果数组给一串**候选路径**(点分,按序取第一个是数组的)。 // -// 六家 provider **共用这一个提取器** —— 不许各写一套 JSON 解析样板(AGENTS §10)。 -// 候选键的意义:分册里「完整响应结构未确证」的家(智谱/火山/百度)用多组候选键容错, +// 各家 provider **共用这一个提取器** —— 不许各写一套 JSON 解析样板(AGENTS §10)。 +// 特例:腾讯云元宝搜索的 `Pages` 是「JSON 字符串数组」,本提取器吃不下(它只认对象数组), +// 那一家自带一次**二次解析**后仍复用本文件的字段挑选器(见 tencent_wsa.go 的 tencentParse)。 +// 候选键的意义:分册里「完整响应结构未确证」的家(智谱/火山/百度/秘塔)用多组候选键容错, // 而不是把某个猜测的字段当事实写死(design/27 §7 的诚实边界)。 type spec struct { // Arrays 是结果数组的候选路径(点分)。例:{"webPages.value", "results"}。 diff --git a/plugins/tools/io/search/http.go b/plugins/tools/io/search/http.go index 3424c63..924a951 100644 --- a/plugins/tools/io/search/http.go +++ b/plugins/tools/io/search/http.go @@ -13,7 +13,7 @@ import ( const defaultMaxBytes = 1 << 20 // request 描述一次上游调用。provider 只填这些,其余(读取上限、错误分类)走共享通路 -// —— 六家绝不能各写一套「发请求 + 判状态 + 读 body」的样板(AGENTS §10 三次复制铁律)。 +// —— 各家绝不能各写一套「发请求 + 判状态 + 读 body」的样板(AGENTS §10 三次复制铁律)。 type request struct { // Method 是 HTTP 方法(http.MethodGet / http.MethodPost)。 Method string diff --git a/plugins/tools/io/search/metaso.go b/plugins/tools/io/search/metaso.go new file mode 100644 index 0000000..5189054 --- /dev/null +++ b/plugins/tools/io/search/metaso.go @@ -0,0 +1,185 @@ +package search + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" +) + +// metasoProfile 是秘塔搜索 Metaso(design/27 §2.1;官方材料见 +// DevDocs/reference/search-providers/metaso/)。 +// +// 走开放平台的搜索端点(**不是**站点自己的 `/search/v2`,那是另一套字段): +// +// POST https://metaso.cn/api/v1/search +// Authorization: Bearer +// +// 请求字段取**官方前端产物**里确证的那几个(`client-bundle-evidence.md` §4 的组装函数原文): +// +// q 查询词(必填) +// scope "webpage"(官方枚举 webpage/document/scholar/image/video/podcast) +// size 结果数量,官方前端发的是**字符串**(默认 "10") +// +// 刻意**不发 `mode`**:官方前端会发 `mode:"search"`,而第三方誊抄的官方 cURL 样例里没有它 +// ⇒「是否必需」**未确证**,拿不准的字段不发(响应侧的 `errCode` 也读不出「缺 mode」)。 +// +// 🔴 **错误也走 HTTP 200**:`{"errCode":2005,"errMsg":"API密钥无效"}`(本机实测;缺 key 与 +// 错 key **同码**)⇒ 必须读响应体的 `errCode` 才能判失败(见 metasoResponseError)。 +// +// 🔴 **响应 schema 只有第三方来源、且互相打架**(条目级字段 `date` vs `displayDate`;顶层 +// `webpages` 三方一致但**无官方来源**)⇒ 本家只能**宽松解析**(多字段名回退,见 metasoSpec), +// 候选键**未经实测**。拿到真 key 后必须按真实报文核对并收窄候选键。 +type metaso struct{ cfg Config } + +// metasoPath 是开放平台的搜索端点路径(官方 JS 产物:POST /api/v1/search)。 +const metasoPath = "/api/v1/search" + +// metasoScopeWebpage 是固定的搜索范围「网页」。 +// +// 固定它的理由:响应侧的结果数组键**随 scope 变**(webpage→webpages),本工具是网页搜索, +// 只有固定 scope=webpage 才能让请求与解析两侧对上(第三方的 SCOPE_RESULT_MAPPING 原文如此)。 +const metasoScopeWebpage = "webpage" + +// metasoDefaultSize 是 `size` 的官方前端默认值(**字符串** "10",见文件头)。 +const metasoDefaultSize = "10" + +// metasoMaxSize 是 `size` 的钳制上限。 +// +// ⚠️ **未确证**:官方页面/JS 产物**没写**上限,1–100 这个区间来自第三方实现 +// (`metaso-search-mcp`:`Math.min(Math.max(size,1),100)`)。这里按第三方的保守值钳制 +// ——宁可少要几条(本地截断照常生效),也不把越界值发上去换一个参数错。 +const metasoMaxSize = 100 + +// metasoUnauthorizedCode 是凭据问题的业务码(本机实测:无 key 与错 key 都是 2005)。 +const metasoUnauthorizedCode = "2005" + +// metasoAcceptHeader 是官方 cURL 模板四段头之一(搜索端点固定 `application/json`)。 +const metasoAcceptHeader = "application/json" + +// newMetaso 造一家秘塔搜索 provider。 +func newMetaso(cfg Config) Provider { return &metaso{cfg: cfg} } + +// Name 实现 Provider。 +func (p *metaso) Name() string { return NameMetaso } + +// Search 实现 Provider。 +func (p *metaso) Search(ctx context.Context, q Query) ([]Result, error) { + payload := map[string]any{ + "q": q.Text, + "scope": metasoScopeWebpage, + "size": metasoSize(q.Count), + } + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("search: metaso: encode request: %w", err) + } + data, err := do(ctx, p.cfg, request{ + Method: http.MethodPost, + URL: baseURLJoin(p.cfg.BaseURL, metasoPath), + Headers: map[string]string{"Authorization": "Bearer " + p.cfg.APIKey, "Accept": metasoAcceptHeader}, + Body: body, + }) + if err != nil { + return nil, err + } + return metasoParse(data) +} + +// metasoSize 把调用方要的条数收进 [1,100] 并**转成字符串**(官方前端发的是字符串)。 +// 未给条数(≤0)⇒ 官方默认值 "10"。 +func metasoSize(n int) string { + if n <= 0 { + return metasoDefaultSize + } + if n > metasoMaxSize { + n = metasoMaxSize + } + return strconv.Itoa(n) +} + +// metasoParse 先判错、再把报文交给共享提取器归一化。 +// +// 判错必须在提取之前:失败报文里**没有**结果数组,直接用共享提取器会把它读成 +// 「结构不对」(ErrBadResponse),而真相是「凭据无效」——那正是降级链该前进一格的那种失败。 +func metasoParse(data []byte) ([]Result, error) { + var root any + if err := json.Unmarshal(data, &root); err != nil { + return nil, fmt.Errorf("%w: %w", ErrBadResponse, err) + } + if err := metasoResponseError(root); err != nil { + return nil, err + } + // 判错之后复用共享提取器:同一套多候选键容错 + 「Content 空 ⇒ 回落 Snippet」的口径。 + return extractResults(data, metasoSpec()) +} + +// metasoResponseError 读顶层 `errCode`(HTTP 200 上判失败的**唯一**途径)。 +// 缺位或 0 ⇒ 这次调用成功(成功响应里 errCode 长什么样**未实测**,因此对 0/缺位都放行)。 +func metasoResponseError(root any) error { + v, ok := resolvePath(root, "errCode") + if !ok { + return nil + } + code := scalarString(v) + if code == "" || code == "0" { + return nil + } + sentinel := metasoCodeClass(code) + return fmt.Errorf("%w (code=%s)", sentinel, code) +} + +// metasoCodeClass 把秘塔的错误码归成本仓哨兵。 +// +// 已确证的只有一条:**2005 = 「API密钥无效」**(本机实测,缺 key 与错 key 同码)。官方 +// 错误码表未公开,**欠费/限流/未开通的码一个都没取到** ⇒ 一律不猜,落到 ErrUpstream +// (仍然是「不可用」,降级链照常前进一格,不会把失败说成空结果)。 +// +// `401`/`403` 是站点自身接口的码(`/api/my-info` 的「需要登录」实测为 401),这里一并 +// 认作凭据问题:开放 API 若沿用同一套码,归类也是对的。 +func metasoCodeClass(code string) error { + switch strings.TrimSpace(code) { + case metasoUnauthorizedCode, "401", "403": + return ErrUnauthorized + default: + return ErrUpstream + } +} + +// metasoSpec 是秘塔搜索响应的**候选**形状 —— ⚠️ **全部未经实测**(无官方 schema)。 +// +// 已知事实只有三条:① 顶层**没有 `data` 信封**(第三方三个实现一致);② 结果数组的键随 +// scope 变(本地固定 scope=webpage ⇒ 主候选 `webpages`);③ 条目级字段名**三方不一致** +// (`date` vs `displayDate`)。因此候选键给多组回退,且**不把任何一个猜测当事实**: +// +// Arrays: webpages(主候选)→ data.webpages / documents / data.documents / +// results / data.results / data(逐级容错) +// Title: title / name +// Link: link / url +// Snippet: snippet / summary / passage / abstract +// Content: rawContent(`includeRawContent=true` 时的正文;本工具不请求该开关 ⇒ 通常为空) +// → content / markdown / text +// Published: date / displayDate / published / publishDate +// Score: score(第三方实测是**字符串** ⇒ pickFloat 认得数字字符串) +// +// 刻意**不把 `position` / `rank` 当 Score**:它们是**排序位次**(1,2,3…),而 Finalize 按 +// Score **降序**排 —— 拿位次当分数会把最相关的一条排到最后。宁可没有分数(保持上游原始 +// 顺序,SliceStable)。 +func metasoSpec() spec { + return spec{ + Arrays: []string{ + "webpages", "data.webpages", + "documents", "data.documents", + "results", "data.results", + "data", + }, + Title: []string{"title", "name"}, + Link: []string{"link", "url"}, + Snippet: []string{"snippet", "summary", "passage", "abstract"}, + Content: []string{"rawContent", "content", "markdown", "text"}, + Published: []string{"date", "displayDate", "published", "publishDate"}, + Score: []string{"score"}, + } +} diff --git a/plugins/tools/io/search/metaso_test.go b/plugins/tools/io/search/metaso_test.go new file mode 100644 index 0000000..eb7eb54 --- /dev/null +++ b/plugins/tools/io/search/metaso_test.go @@ -0,0 +1,311 @@ +package search + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" +) + +// 本文件钉住秘塔搜索 provider 的**请求面**、**宽松解析**与**错误映射** +// (依据:DevDocs/reference/search-providers/metaso 的官方 JS 产物 + 本机无 key 实测)。 +// +// ⚠️ 与其它家不同的一条前提:**响应 schema 只有第三方来源、且三方互相打架** +// (条目级 `date` vs `displayDate`;顶层 `webpages` 无官方来源)⇒ 本家的解析是**宽松解析** +// (多字段名回退),用例也只能钉住「这些候选键都能出结果」与「字段全缺时不 panic」, +// **不能**钉住「官方就是这些字段」。拿到真 key 后必须按真实报文收窄。 +// +// 全部打 httptest,**不打真实网络**(无 API KEY ⇒ 成功响应真机未实测;错误码只确证了 2005)。 + +// metasoOK 是第三方实测形状的应答(`third-party-response-schema.md` 的 zod schema 逐字段照抄: +// 顶层**没有 data 信封**、`score` 是字符串、`rawContent` 是开启抓原文后的正文)。 +const metasoOK = `{ + "credits": 3, + "searchParameters": {"q": "昔涟", "size": 10, "includeSummary": false}, + "webpages": [ + {"title": "昔涟是什么", "link": "https://m.example/1", "score": "0.93", + "snippet": "秘塔摘要", "position": 1, "rawContent": "秘塔正文原文", "date": "2025-06-01"}, + {"title": "第二条", "link": "https://m.example/2", "position": 2, "snippet": "第二摘要"} + ], + "total": 2 +}` + +// TestMetasoRequestShape 钉住请求面:POST `/api/v1/search` + Bearer + Accept,请求体是 +// 官方前端那三个字段(`q` / `scope=webpage` / `size` **字符串**),且 `mode` 不发 +// (是否必需未确证)。 +func TestMetasoRequestShape(t *testing.T) { + t.Parallel() + cases := []struct { + name string + count int + wantSize string + wantQuery string + }{ + {"未给条数 ⇒ 官方默认 \"10\"", 0, metasoDefaultSize, "昔涟"}, + {"给了条数 ⇒ 原样成字符串", 5, "5", "昔涟"}, + {"越界条数 ⇒ 钳到未确证的上限 100", 1000, "100", "昔涟"}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + srv, cap := fixtureServer(t, metasoOK) + if _, err := newMetaso(Config{ + APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(), + }).Search(context.Background(), Query{Text: tc.wantQuery, Count: tc.count}); err != nil { + t.Fatalf("Search: %v", err) + } + if cap.req == nil { + t.Fatal("没发出任何请求") + } + if cap.req.Method != http.MethodPost { + t.Errorf("method = %q, want POST", cap.req.Method) + } + if cap.req.URL.Path != metasoPath { + t.Errorf("path = %q, want %q", cap.req.URL.Path, metasoPath) + } + if got := cap.req.Header.Get("Authorization"); got != "Bearer "+testKey { + t.Errorf("Authorization = %q, want Bearer ", got) + } + if got := cap.req.Header.Get("Accept"); got != metasoAcceptHeader { + t.Errorf("Accept = %q, want %q", got, metasoAcceptHeader) + } + var body map[string]any + mustUnmarshal(t, cap.body, &body) + if body["q"] != tc.wantQuery { + t.Errorf("body q = %v, want %q", body["q"], tc.wantQuery) + } + if body["scope"] != metasoScopeWebpage { + t.Errorf("body scope = %v, want %q", body["scope"], metasoScopeWebpage) + } + if body["size"] != tc.wantSize { + t.Errorf("body size = %v (%T), want 字符串 %q", body["size"], body["size"], tc.wantSize) + } + if _, ok := body["mode"]; ok { + t.Error("body 不该带 mode:官方前端发、第三方 cURL 不发,是否必需未确证 ⇒ 不发") + } + }) + } +} + +// TestMetasoResponseNormalizesLooseFields 覆盖「宽松解析」的主路径:第三方实测字段 +// (title/link/snippet/rawContent/date/score 字符串)都能出结果。 +func TestMetasoResponseNormalizesLooseFields(t *testing.T) { + t.Parallel() + srv, _ := fixtureServer(t, metasoOK) + got, err := newMetaso(Config{ + APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(), + }).Search(context.Background(), Query{Text: "昔涟"}) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(got) != 2 { + t.Fatalf("条数 = %d, want 2: %+v", len(got), got) + } + first := got[0] + if first.Title != "昔涟是什么" || first.Link != "https://m.example/1" { + t.Errorf("Title/Link = %q/%q", first.Title, first.Link) + } + if first.Snippet != "秘塔摘要" { + t.Errorf("Snippet = %q, want 第三方实测的 snippet", first.Snippet) + } + if first.Content != "秘塔正文原文" { + t.Errorf("Content = %q, want rawContent(抓原文开关打开时的正文)", first.Content) + } + if first.Published != "2025-06-01" { + t.Errorf("Published = %q, want 第三方实测的 date", first.Published) + } + if first.Score != 0.93 { + t.Errorf("Score = %v, want 0.93(第三方实测 score 是字符串,pickFloat 应认)", first.Score) + } + // position=1 是**排序位次**,不是相关性分数 ⇒ 不许被当成 Score(否则降序排会把首条排到末位)。 + if got[1].Score != 0 { + t.Errorf("第二条 Score = %v, want 0(position 不是分数,不许投影)", got[1].Score) + } +} + +// TestMetasoResponseLooseArrayAndKeyFallbacks 是验收「宽松解析:多种字段名都能出结果」: +// 结果数组与条目字段名都按多组候选回退(因为 response schema 无官方来源)。 +func TestMetasoResponseLooseArrayAndKeyFallbacks(t *testing.T) { + t.Parallel() + cases := []struct { + name string + fixture string + }{ + {"顶层 webpages(主候选)", `{"webpages":[{"title":"T","link":"https://x/1","snippet":"S"}]}`}, + {"data 信封里的 webpages", `{"data":{"webpages":[{"title":"T","link":"https://x/1","snippet":"S"}]}}`}, + {"documents(scope 变体的容错)", `{"documents":[{"title":"T","link":"https://x/1","snippet":"S"}]}`}, + {"results 通用键", `{"results":[{"title":"T","link":"https://x/1","snippet":"S"}]}`}, + {"字段名变体:name/url/summary", `{"webpages":[{"name":"T","url":"https://x/1","summary":"S"}]}`}, + {"字段名变体:passage/content", `{"webpages":[{"title":"T","link":"https://x/1","passage":"S","content":"C"}]}`}, + {"字段名变体:displayDate", `{"webpages":[{"title":"T","link":"https://x/1","displayDate":"2025-01-02"}]}`}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + srv, _ := fixtureServer(t, tc.fixture) + got, err := newMetaso(Config{ + APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(), + }).Search(context.Background(), Query{Text: "q"}) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(got) != 1 { + t.Fatalf("条数 = %d, want 1(候选键应能命中): %+v", len(got), got) + } + if got[0].Title != "T" || got[0].Link != "https://x/1" { + t.Errorf("归一化结果 = %+v, want title=T link=https://x/1", got[0]) + } + }) + } +} + +// TestMetasoAllFieldsMissingDoesNotPanic 是验收「字段全缺时不 panic」:条目是空对象 ⇒ +// 归一化成一条**四字段全空**的行,由共享后处理(Finalize)丢弃 —— 不 panic、不报错。 +func TestMetasoAllFieldsMissingDoesNotPanic(t *testing.T) { + t.Parallel() + srv, _ := fixtureServer(t, `{"webpages":[{}, {"unknown": 1}]}`) + got, err := newMetaso(Config{ + APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(), + }).Search(context.Background(), Query{Text: "q"}) + if err != nil { + t.Fatalf("字段全缺不该报错(那会让上游的脏数据变成该家不可用): %v", err) + } + if len(got) != 2 { + t.Fatalf("条数 = %d, want 2", len(got)) + } + for i, r := range got { + if r.Link != "" || r.Title != "" || r.Snippet != "" || r.Content != "" { + t.Errorf("[%d] 空条目应四字段全空: %+v", i, r) + } + } + if final := Finalize(got, Options{Limit: 5}); len(final) != 0 { + t.Fatalf("Finalize 后条数 = %d, want 0(空壳行不算结果)", len(final)) + } +} + +// TestMetasoEmptyIsNotAnError 钉住「空结果不是错误」。 +func TestMetasoEmptyIsNotAnError(t *testing.T) { + t.Parallel() + srv, _ := fixtureServer(t, `{"credits":0,"webpages":[]}`) + got, err := newMetaso(Config{ + APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(), + }).Search(context.Background(), Query{Text: "q"}) + if err != nil { + t.Fatalf("空 webpages 不是错误: %v", err) + } + if len(got) != 0 { + t.Fatalf("条数 = %d, want 0", len(got)) + } +} + +// TestMetasoErrorsOnHTTP200 钉住错误路径:**HTTP 200 + 顶层 `errCode`** +// (本机实测:缺 key 与错 key 都是 2005)。只确证了 2005 一条 ⇒ 其它码一律归 upstream, +// 但**都必须报失败**(绝不能把失败读成「零结果」)。 +func TestMetasoErrorsOnHTTP200(t *testing.T) { + t.Parallel() + cases := []struct { + name string + body string + wantSent error + wantCode string + }{ + {"凭据无效 2005(字符串)", `{"errCode":2005,"errMsg":"API密钥无效"}`, ErrUnauthorized, ProbeUnauthorized}, + {"凭据无效 2005(数字也要认)", `{"errCode":"2005","errMsg":"API密钥无效"}`, ErrUnauthorized, ProbeUnauthorized}, + {"站点登录码 401", `{"errCode":401,"errMsg":"需要登录"}`, ErrUnauthorized, ProbeUnauthorized}, + {"未确证的码 ⇒ upstream(不猜欠费/限流)", `{"errCode":9999,"errMsg":"unknown"}`, ErrUpstream, ProbeUpstream}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + srv, _ := fixtureServer(t, tc.body) + got, err := newMetaso(Config{ + APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(), + }).Search(context.Background(), Query{Text: "q"}) + if err == nil { + t.Fatalf("HTTP 200 带 errCode 必须报失败,却拿到 %d 条结果", len(got)) + } + if !errors.Is(err, tc.wantSent) { + t.Errorf("errors.Is(err, %v) = false: %v", tc.wantSent, err) + } + if code := ProbeErrorCode(err); code != tc.wantCode { + t.Errorf("探针错误码 = %q, want %q", code, tc.wantCode) + } + // errMsg 是任意上游文本 ⇒ 不许进错误文案(它会进日志与面板响应)。 + if strings.Contains(err.Error(), "API密钥无效") { + t.Errorf("错误文案带上了上游 errMsg: %v", err) + } + if strings.Contains(err.Error(), testKey) { + t.Fatalf("错误文案泄漏了凭据: %v", err) + } + }) + } +} + +// TestMetasoZeroErrCodeIsNotAnError 钉住成功侧不误杀:`errCode` 缺位或为 0 时按成功处理 +// (成功响应里 errCode 长什么样**未实测** ⇒ 两种形态都放行)。 +func TestMetasoZeroErrCodeIsNotAnError(t *testing.T) { + t.Parallel() + for _, fixture := range []string{ + `{"errCode":0,"webpages":[{"title":"T","link":"https://x/1"}]}`, + `{"errCode":"0","webpages":[{"title":"T","link":"https://x/1"}]}`, + `{"webpages":[{"title":"T","link":"https://x/1"}]}`, + } { + srv, _ := fixtureServer(t, fixture) + got, err := newMetaso(Config{ + APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(), + }).Search(context.Background(), Query{Text: "q"}) + if err != nil { + t.Fatalf("errCode=%q 的应答不该当失败: %v", fixture, err) + } + if len(got) != 1 { + t.Fatalf("条数 = %d, want 1(输入 %s)", len(got), fixture) + } + } +} + +// TestMetasoMissingArrayIsBadResponse 钉住「空」与「解析不了」分开:一个候选数组都没有 +// (响应形状变了 / 是别的 scope 的报文)⇒ ErrBadResponse,不是静默 0 条。 +func TestMetasoMissingArrayIsBadResponse(t *testing.T) { + t.Parallel() + for _, body := range []string{`not json`, `{}`, `[]`, `{"credits":1}`} { + srv, _ := fixtureServer(t, body) + _, err := newMetaso(Config{ + APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(), + }).Search(context.Background(), Query{Text: "q"}) + if !errors.Is(err, ErrBadResponse) { + t.Errorf("输入 %s 应报 ErrBadResponse, got %v", body, err) + } + } +} + +// TestMetasoOverLimitResponseRejected 是验收 ④:响应体超过本仓读取上限 ⇒ +// ErrResponseTooLarge(**不静默截断成半截 JSON**)。 +func TestMetasoOverLimitResponseRejected(t *testing.T) { + t.Parallel() + srv, _ := fixtureServer(t, metasoOK) + _, err := newMetaso(Config{ + APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(), MaxBytes: 16, + }).Search(context.Background(), Query{Text: "q"}) + if !errors.Is(err, ErrResponseTooLarge) { + t.Fatalf("err = %v, want ErrResponseTooLarge", err) + } +} + +// TestMetasoSpecArraysStayOrdered 钉住候选数组的**顺序**(主候选必须是 webpages), +// 并钉住 Score 候选里**没有** position/rank(它们是排序位次,降序排会把最相关的一条排到最后)。 +func TestMetasoSpecArraysStayOrdered(t *testing.T) { + t.Parallel() + s := metasoSpec() + if len(s.Arrays) == 0 || s.Arrays[0] != "webpages" { + t.Fatalf("Arrays[0] = %v, want webpages(本地固定 scope=webpage,主候选必须与它对上)", s.Arrays) + } + for _, key := range s.Score { + if key == "position" || key == "rank" { + t.Errorf("Score 候选里有 %q:那是排序位次、不是相关性分数", key) + } + } +} diff --git a/plugins/tools/io/search/paneldata.go b/plugins/tools/io/search/paneldata.go index 481db1c..c29ffe4 100644 --- a/plugins/tools/io/search/paneldata.go +++ b/plugins/tools/io/search/paneldata.go @@ -108,6 +108,8 @@ func providerLabels() map[string]string { NameBocha: "博查 Bocha(bocha)", NameVolcengine: "火山引擎·豆包搜索(volcengine)", NameBaiduQianfan: "百度千帆 AI 搜索(baidu_qianfan)", + NameTencentWSA: "腾讯云·元宝搜索(tencent_wsa)", + NameMetaso: "秘塔搜索 Metaso(metaso)", } } diff --git a/plugins/tools/io/search/provider.go b/plugins/tools/io/search/provider.go index 644734c..0b2ce3d 100644 --- a/plugins/tools/io/search/provider.go +++ b/plugins/tools/io/search/provider.go @@ -53,6 +53,10 @@ const ( NameVolcengine = "volcengine" // NameBaiduQianfan 是百度千帆 AI 搜索。 NameBaiduQianfan = "baidu_qianfan" + // NameTencentWSA 是腾讯云·元宝搜索(联网搜索 API / WSA,服务 API KEY 方式)。 + NameTencentWSA = "tencent_wsa" + // NameMetaso 是秘塔搜索 Metaso(开放平台 /api/v1/search)。 + NameMetaso = "metaso" ) // Config 是构造一家 provider 所需的一切接线(密钥/地址/客户端/响应上限/URL 守卫)。 diff --git a/plugins/tools/io/search/providers_test.go b/plugins/tools/io/search/providers_test.go index bb3b87d..edcd945 100644 --- a/plugins/tools/io/search/providers_test.go +++ b/plugins/tools/io/search/providers_test.go @@ -68,10 +68,11 @@ type providerCase struct { checkRequest func(t *testing.T, c *captured) } -// searchProviderCaseTable 是六家的夹具表(**禁止真调外部 API** —— 全部打 httptest)。 +// searchProviderCaseTable 是八家的夹具表(**禁止真调外部 API** —— 全部打 httptest)。 func searchProviderCaseTable() []providerCase { return []providerCase{ searxngCase(), zhipuCase(), aliyunCase(), bochaCase(), volcengineCase(), baiduCase(), + tencentWSACase(), metasoCase(), } } @@ -231,7 +232,69 @@ func baiduCase() providerCase { } } -// TestProviders_FixturePerProvider 是验收 ①:六家各一条 httptest 夹具(不打真上游)。 +// tencentWSACase 是腾讯云·元宝搜索的夹具(POST + Bearer;`Pages` 是 **JSON 字符串**数组)。 +func tencentWSACase() providerCase { + return providerCase{ + name: NameTencentWSA, newP: newTencentWSA, + method: http.MethodPost, path: tencentWSAPath, authHeader: "Authorization", authValue: "Bearer " + testKey, + // Pages 的元素是一段 JSON 文本(官方 Array of String 形状)⇒ 二次解析才能取到字段。 + fixture: `{"Response":{"Pages":["{\"title\":\"元宝结果\",\"url\":\"https://t.example/1\",` + + `\"passage\":\"元宝摘要\",\"content\":\"元宝正文\",\"date\":\"2025-06-01\",\"score\":0.9}"],` + + `"Query":"昔涟","Version":"standard","RequestId":"r-1"}}`, + wantTitle: "元宝结果", + wantLink: "https://t.example/1", + wantContent: "元宝正文", + checkRequest: func(t *testing.T, c *captured) { + t.Helper() + var body map[string]any + mustUnmarshal(t, c.body, &body) + if body["Query"] != "昔涟" { + t.Errorf("tencent_wsa body Query = %v", body["Query"]) + } + // 只发官方确证为必选的 Query:Cnt/Industry 是尊享版门槛、Site 是域名过滤 + // (design/27 §4:过滤统一在 Finalize 做)、Mode/Freshness 版本口径未确证。 + for _, absent := range []string{"Cnt", "Industry", "Site", "Mode", "Freshness"} { + if _, ok := body[absent]; ok { + t.Errorf("tencent_wsa 不该发 %s(版本门槛/过滤参数,见 provider 注释)", absent) + } + } + }, + } +} + +// metasoCase 是秘塔搜索的夹具(POST + Bearer + Accept;`size` 是**字符串**)。 +func metasoCase() providerCase { + return providerCase{ + name: NameMetaso, newP: newMetaso, + method: http.MethodPost, path: metasoPath, authHeader: "Authorization", authValue: "Bearer " + testKey, + fixture: `{"credits":1,"webpages":[{"title":"秘塔结果","link":"https://m.example/1",` + + `"snippet":"秘塔摘要","rawContent":"秘塔正文","date":"2025-06-01","score":"0.9"}]}`, + wantTitle: "秘塔结果", + wantLink: "https://m.example/1", + wantContent: "秘塔正文", + checkRequest: func(t *testing.T, c *captured) { + t.Helper() + var body map[string]any + mustUnmarshal(t, c.body, &body) + if body["q"] != "昔涟" { + t.Errorf("metaso body q = %v", body["q"]) + } + if body["scope"] != metasoScopeWebpage { + t.Errorf("metaso body scope = %v, want %s", body["scope"], metasoScopeWebpage) + } + // 官方前端发的是**字符串**("10")⇒ 这里也必须是字符串,便于与 Playground 对拍。 + if body["size"] != metasoDefaultSize { + t.Errorf("metaso body size = %v (%T), want 字符串 %q", + body["size"], body["size"], metasoDefaultSize) + } + if got := c.req.Header.Get("Accept"); got != metasoAcceptHeader { + t.Errorf("metaso Accept = %q, want %q", got, metasoAcceptHeader) + } + }, + } +} + +// TestProviders_FixturePerProvider 是验收 ①:八家各一条 httptest 夹具(不打真上游)。 func TestProviders_FixturePerProvider(t *testing.T) { t.Parallel() for _, tc := range searchProviderCaseTable() { @@ -273,7 +336,7 @@ func TestProviders_FixturePerProvider(t *testing.T) { } } -// TestProviders_AllNormalizeToSameResult 是验收 ②:六家输出**归一化成同一 Result** +// TestProviders_AllNormalizeToSameResult 是验收 ②:八家输出**归一化成同一 Result** // (同一结构体、同一语义),且共用同一套后处理(Finalize)。 func TestProviders_AllNormalizeToSameResult(t *testing.T) { t.Parallel() @@ -289,7 +352,7 @@ func TestProviders_AllNormalizeToSameResult(t *testing.T) { } for i, r := range got { if r.Link == "" { - // 六家的夹具**都给得出**链接(真机智谱有时给不出,但那是上游的形态、 + // 各家夹具**都给得出**链接(真机智谱有时给不出,但那是上游的形态、 // 不是本判据的对象):夹具里链接丢了就说明归一化把 link 认错了。 t.Errorf("[%d] Link 为空(夹具里给了链接): %+v", i, r) } @@ -304,7 +367,7 @@ func TestProviders_AllNormalizeToSameResult(t *testing.T) { } } -// TestProviders_EmptyBodyIsEmptyNotError 钉住六家对「200 + 空数组」的处置一致: +// TestProviders_EmptyBodyIsEmptyNotError 钉住各家对「200 + 空数组」的处置一致: // 不是错误,而是 0 条(由上层按「空结果 ≠ 成功」处置)。 func TestProviders_EmptyBodyIsEmptyNotError(t *testing.T) { t.Parallel() @@ -338,6 +401,10 @@ func (tc providerCase) emptyFixture() string { return `{"webPages":{"value":[]}}` case "volcengine": return `{"Result":{"WebResults":[]}}` + case NameTencentWSA: + return `{"Response":{"Pages":[],"Query":"q","RequestId":"r"}}` + case NameMetaso: + return `{"webpages":[],"credits":0}` default: return `{"references":[]}` } diff --git a/plugins/tools/io/search/registry.go b/plugins/tools/io/search/registry.go index c015701..32ace26 100644 --- a/plugins/tools/io/search/registry.go +++ b/plugins/tools/io/search/registry.go @@ -85,6 +85,11 @@ func definitions() []definition { {name: NameBocha, needKey: true, defaultBaseURL: "https://api.bocha.cn", new: newBocha}, {name: NameVolcengine, needKey: true, defaultBaseURL: "https://open.feedcoopapi.com", new: newVolcengine}, {name: NameBaiduQianfan, needKey: true, defaultBaseURL: "https://qianfan.baidubce.com", new: newBaiduQianfan}, + // 下面两家**没有对应的 `.env` 字段**(`.env` 已冻结):它们的 key / 基地址只从 + // `$DATA_DIR/config/plugins/web-search.json` 取(组合根按本表逐家投影,故组合根零改动)。 + // 默认基地址取官方文档给出的服务域名。 + {name: NameTencentWSA, needKey: true, defaultBaseURL: "https://api.wsa.cloud.tencent.com", new: newTencentWSA}, + {name: NameMetaso, needKey: true, defaultBaseURL: "https://metaso.cn", new: newMetaso}, } } diff --git a/plugins/tools/io/search/registry_test.go b/plugins/tools/io/search/registry_test.go index 6b14577..62a7b21 100644 --- a/plugins/tools/io/search/registry_test.go +++ b/plugins/tools/io/search/registry_test.go @@ -14,6 +14,7 @@ func TestRegistry_KnownAndKnownNames(t *testing.T) { t.Parallel() for _, name := range []string{ NameSearXNG, NameZhipu, NameAliyunIQS, NameBocha, NameVolcengine, NameBaiduQianfan, + NameTencentWSA, NameMetaso, "Zhipu", " bocha ", } { if !Known(name) { @@ -26,8 +27,8 @@ func TestRegistry_KnownAndKnownNames(t *testing.T) { } } names := KnownNames() - if len(names) != 6 { - t.Fatalf("KnownNames() = %v, want 六家", names) + if len(names) != 8 { + t.Fatalf("KnownNames() = %v, want 八家", names) } } @@ -103,9 +104,10 @@ func TestRegistry_EmptyAndNilSafe(t *testing.T) { } } -// TestRegistry_DefaultBaseURLMatchesEnvDefaults 钉住接入位表里的**默认基地址**逐字等于 -// `.env` 的标签默认值(`internal/config/websearch.go` 的 `*BaseURL` 那一组,`.env` 冻结的 -// 基础键)。 +// TestRegistry_DefaultBaseURLMatchesEnvDefaults 钉住接入位表里的**默认基地址**: +// 「有 `.env` 字段的家」那几个值**逐字等于** `.env` 的标签默认值 +// (`internal/config/websearch.go` 的 `*BaseURL` 那一组,`.env` 冻结的基础键); +// 没有 `.env` 字段的家(tencent_wsa / metaso)只在这里给出唯一出处。 // // 为什么值得一条逐字表:接入位表是本片新增的「这家没配 baseUrl ⇒ 回落哪个地址」的 // **唯一出处**,而 `.env` 侧那几个键仍然存在、仍然参与播种。两处一旦漂移,同一家 provider @@ -118,6 +120,9 @@ func TestRegistry_DefaultBaseURLMatchesEnvDefaults(t *testing.T) { NameBocha: "https://api.bocha.cn", NameVolcengine: "https://open.feedcoopapi.com", NameBaiduQianfan: "https://qianfan.baidubce.com", + // 没有 `.env` 字段的两家:默认地址只来自本表(登记在 registry.go 的接入位表里)。 + NameTencentWSA: "https://api.wsa.cloud.tencent.com", + NameMetaso: "https://metaso.cn", } for name, base := range want { if got := DefaultBaseURL(name); got != base { diff --git a/plugins/tools/io/search/tencent_wsa.go b/plugins/tools/io/search/tencent_wsa.go new file mode 100644 index 0000000..aec118d --- /dev/null +++ b/plugins/tools/io/search/tencent_wsa.go @@ -0,0 +1,219 @@ +package search + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" +) + +// tencentWSAProfile 是腾讯云·元宝搜索(联网搜索 API / WSA,design/27 §2.1;官方原文见 +// DevDocs/reference/search-providers/tencent-wsa/)。 +// +// 走的是**服务 API KEY 方式**(不是 TC3 签名那条路,那条需要 AK/SK 派生签名): +// +// POST https://api.wsa.cloud.tencent.com/SearchPro +// Authorization: Bearer ← 零签名,标准库即可 ⇒ 不破「零新依赖」 +// +// 🔴 两条必须照抄的官方口径(都对本实现有决定性影响): +// +// - **HTTP 状态码恒为 200**:官方 `api-response-format.md` 原文「目前只要请求被服务端 +// 正常处理了,响应的 HTTP 状态码均为200」;本机无 key 实测逐字复现(分册 +// `live-probe-2026-10-07.md`:缺 Authorization 也是 200 + `Response.Error`)。 +// ⇒ **不能靠状态码判成败**,必须读响应体的 `Response.Error.Code`(见 tencentResponseError)。 +// - **`Pages` 是「JSON 字符串」数组**(官方类型:`Array of String`,每个元素是一段 JSON +// 文本)⇒ 必须**二次解析**才能拿到 title/url/passage/…。共享提取器 `extractResults` 只能 +// 吃「对象数组」,因此本家自带解析(见 tencentParse),但字段挑选与「Content 空 ⇒ 回落 +// Snippet」的既有口径仍复用包内同一套函数。 +// +// ⚠️ 诚实边界(**没有 API KEY ⇒ 成功响应未实测**,只有官方示例可依): +// +// - 请求只发官方确证为「必选」的 `Query`。其余字段各有明确理由不发: +// `Site` 是域名过滤(design/27 §4:过滤统一在 Finalize 做,provider 不许自己过滤); +// `Cnt`/`Industry` 官方标「仅限尊享版使用」(发上去会让轻量/标准版直接报参数错); +// `Mode` 与 `Freshness`/`FromTime`/`ToTime` 的版本门槛在两页官方文档里**口径不一致** +// (README「已知未确证」)⇒ 一律不发,条数交给本地截断。 +// - `content`(动态摘要)是**尊享版字段**(官方原文:「极速版不返回该字段」);拿不到时 +// 由包内既有单向回落把 `passage`(标准摘要)填进 Content。 +type tencentWSA struct{ cfg Config } + +// tencentWSAPath 是服务 API KEY 方式的端点路径(官方原文:接口路径(URI):/SearchPro)。 +const tencentWSAPath = "/SearchPro" + +// tencentResponseErrorPath 是上游错误信封的位置(官方原文:Error 字段连同其内部的 Code 与 +// Message 在调用失败时**必定**返回)。 +const tencentResponseErrorPath = "Response.Error" + +// tencentPagesPath 是结果数组的路径(官方原文:`Pages`,Array of String)。 +const tencentPagesPath = "Response.Pages" + +// newTencentWSA 造一家腾讯云·元宝搜索 provider。 +func newTencentWSA(cfg Config) Provider { return &tencentWSA{cfg: cfg} } + +// Name 实现 Provider。 +func (p *tencentWSA) Name() string { return NameTencentWSA } + +// Search 实现 Provider。 +func (p *tencentWSA) Search(ctx context.Context, q Query) ([]Result, error) { + body, err := json.Marshal(map[string]any{"Query": q.Text}) + if err != nil { + return nil, fmt.Errorf("search: tencent_wsa: encode request: %w", err) + } + data, err := do(ctx, p.cfg, request{ + Method: http.MethodPost, + URL: baseURLJoin(p.cfg.BaseURL, tencentWSAPath), + Headers: map[string]string{"Authorization": "Bearer " + p.cfg.APIKey}, + Body: body, + }) + if err != nil { + return nil, err + } + return tencentParse(data) +} + +// tencentParse 把上游报文归一化成 []Result。 +// +// 顺序是「先判错、再取值」:这一家**错误也走 HTTP 200**,先解 `Response.Error` 才不会把 +// 一次失败读成「上游正常应答但零结果」(那会是一句谎报)。 +func tencentParse(data []byte) ([]Result, error) { + var root any + if err := json.Unmarshal(data, &root); err != nil { + return nil, fmt.Errorf("%w: %w", ErrBadResponse, err) + } + if err := tencentResponseError(root); err != nil { + return nil, err + } + raw, ok := resolvePath(root, tencentPagesPath) + if !ok { + return nil, fmt.Errorf("%w: tencent_wsa: no %s", ErrBadResponse, tencentPagesPath) + } + items, ok := raw.([]any) + if !ok { + return nil, fmt.Errorf("%w: tencent_wsa: %s is not an array", ErrBadResponse, tencentPagesPath) + } + s := tencentPageSpec() + out := make([]Result, 0, len(items)) + for _, item := range items { + obj, ok := tencentPageObject(item) + if !ok { + // 单条脏数据(解析不出来的页元素)跳过:不毁掉整次检索。 + continue + } + out = append(out, tencentResult(obj, s)) + } + for i := range out { + // 包内既有口径(与 extractResults 同一条):正文缺了就回落摘要,别让 Content 空着。 + if out[i].Content == "" { + out[i].Content = out[i].Snippet + } + } + return out, nil +} + +// tencentPageObject 把一个 `Pages` 元素还原成对象。 +// +// 官方形状是**一段 JSON 文本**(`Array of String`)⇒ 这里做二次解析;也容忍它已经是对象 +// 的实现(网关/上游版本差异),两种都认不出来就跳过这一条。 +func tencentPageObject(raw any) (map[string]any, bool) { + switch v := raw.(type) { + case map[string]any: + return v, true + case string: + var obj map[string]any + if err := json.Unmarshal([]byte(v), &obj); err != nil { + return nil, false + } + return obj, true + default: + return nil, false + } +} + +// tencentPageSpec 是 `Pages` 元素内层对象的字段(官方原文逐字确证,两条接入路线同构): +// +// title(结果标题)/ url(源 URL)/ passage(标准摘要)/ content(动态摘要,尊享版字段)/ +// date(内容发布时间)/ score(相关性得分 0~1,越接近 1 越相关) +// +// 刻意**不投影** `site`(网站名称)/ `favicon` / `pics` / `deeplinks` / `authority_level`: +// `Result` 没有对应的位,而把 `site` 塞进 `Snippet`(正文摘录位)会让「摘要」变成站点名 +// —— 与 zhipuSpec 的同一条理由(往语义位塞另一类事实)。 +func tencentPageSpec() spec { + return spec{ + Title: []string{"title"}, + Link: []string{"url"}, + Snippet: []string{"passage"}, + Content: []string{"content"}, + Published: []string{"date"}, + Score: []string{"score"}, + } +} + +// tencentResult 按 tencentPageSpec 从一条 Pages 对象里取值(复用包内统一的挑选器)。 +func tencentResult(obj map[string]any, s spec) Result { + return Result{ + Title: pickString(obj, s.Title), + Link: pickString(obj, s.Link), + Snippet: pickString(obj, s.Snippet), + Content: pickString(obj, s.Content), + Published: pickString(obj, s.Published), + Score: pickFloat(obj, s.Score), + } +} + +// tencentResponseError 读 `Response.Error`:返回 nil 表示这次调用成功。 +// +// 它是这一家判失败的**唯一**途径(HTTP 恒 200,见文件头「两条必须照抄的官方口径」)。 +// 官方原文:Error 字段一出现就代表调用失败,且调用失败时 Code 与 Message 必定返回。 +func tencentResponseError(root any) error { + if _, ok := resolvePath(root, tencentResponseErrorPath); !ok { + return nil + } + code := "" + if v, ok := resolvePath(root, tencentResponseErrorPath+".Code"); ok { + code = scalarString(v) + } + return tencentStatusError(code) +} + +// tencentStatusError 把上游错误码包成本仓哨兵错误。 +// +// ⚠️ 只带 **code**、**绝不带上游 Message**:Message 是任意上游文本,某些上游会把请求 +// URL/凭据回显进去,而这段文案会被工具层记进日志(与 errors.go 的 classifyStatus 同口径)。 +// 取不到码(Error 在但 Code 缺)时按 upstream 归类,仍然**不会当成空结果**。 +func tencentStatusError(code string) error { + sentinel := tencentCodeClass(code) + if c := strings.TrimSpace(code); c != "" { + return fmt.Errorf("%w (code=%s)", sentinel, c) + } + return sentinel +} + +// tencentCodeClass 把腾讯云侧的错误码归成本仓哨兵(大小写不敏感,前缀匹配点分码)。 +// +// 依据官方错误码表(`api-error-codes.md` 公共码 + `api-searchpro-apikey.md` 产品码): +// +// UnauthorizedOperation / AuthFailure.* ⇒ 凭据问题。官方表把 UnauthorizedOperation 解释为 +// 「请检查服务密钥 API KEY 是否正确」;**无 key 与 +// key 无效同码**(本机实测),因此只能归成一类。 +// ResourceUnavailable ⇒ 欠费(官方表:「请检查账号是否欠费」)。 +// ResourceNotFound ⇒ 用户资源未开通(官方表:「请联系主账号开通服务」)。 +// RequestLimitExceeded[.*] ⇒ 频率超限。 +// LimitExceeded ⇒ 超过配额限制(与「频率」不同,归额度类)。 +// 其余(含取不到码) ⇒ upstream(不可进一步分类,降级链照常前进一格)。 +// +// 这张表**只在本家使用**:腾讯云的错误码随 HTTP 200 返回,errors.go 的 classifyStatus +// (只处理非 2xx)根本看不到它们,把点分大写码混进那张所有家共用的数字码表只会制造碰撞。 +func tencentCodeClass(code string) error { + c := strings.ToLower(strings.TrimSpace(code)) + switch { + case strings.HasPrefix(c, "unauthorizedoperation"), strings.HasPrefix(c, "authfailure"): + return ErrUnauthorized + case c == "resourceunavailable", c == "resourcenotfound", c == "limitexceeded": + return ErrQuotaExceeded + case strings.HasPrefix(c, "requestlimitexceeded"): + return ErrRateLimited + default: + return ErrUpstream + } +} diff --git a/plugins/tools/io/search/tencent_wsa_test.go b/plugins/tools/io/search/tencent_wsa_test.go new file mode 100644 index 0000000..1625a9d --- /dev/null +++ b/plugins/tools/io/search/tencent_wsa_test.go @@ -0,0 +1,247 @@ +package search + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" +) + +// 本文件钉住腾讯云·元宝搜索 provider 的**请求面**、**Pages 二次解析**与**错误映射** +// (依据:DevDocs/reference/search-providers/tencent-wsa 的官方原文 + 无 key 本机实测 +// `live-probe-2026-10-07.md`)。 +// +// 两条本家特有的口径,各有用例守着: +// +// - `Pages` 是 **JSON 字符串数组**(官方类型 `Array of String`)⇒ 必须二次解析; +// - **HTTP 状态码恒为 200**,失败信息在 `Response.Error.Code` ⇒ 判错只能读响应体 +// (用例里那个假上游**确实回的是 200**,这就是「读响应体」这条路径的证据)。 +// +// 全部打 httptest,**不打真实网络**(无 API KEY ⇒ 成功响应真机未实测)。 + +// tencentWSAOK 是官方示例形状的应答(`api-searchpro-apikey.md` 输出示例逐字段照抄): +// `Pages` 的元素是**一段 JSON 文本**,不是对象。 +const tencentWSAOK = `{ + "Response": { + "Pages": [ + "{\"passage\":\"三星堆名字的由来 三星堆遗址位于四川省广汉市西北部\",\"score\":0.897802,\"date\":\"2024-06-07 19:00:51\",\"title\":\"三星堆名字的由来\",\"url\":\"https://www.163.com/dy/article/J43Q2STU0553FV3Z.html\",\"site\":\"网易\",\"images\":[],\"favicon\":\"http://img01.sogoucdn.com/app/a/200913/x.png\"}", + "{\"passage\":\"一、地理环境因素\",\"score\":0.8887853,\"date\":\"2024-09-03 12:59:00\",\"title\":\"三星堆名字的由来_腾讯新闻\",\"url\":\"https://news.qq.com/rain/a/20240903A048N700\",\"site\":\"腾讯网\"}" + ], + "Query": "三星堆的由来", + "Version": "standard", + "RequestId": "6f8df221-9a68-4ea2-90d6-a9590cae244c" + } +}` + +// TestTencentWSARequestShape 钉住请求面:POST `/SearchPro` + Bearer,请求体只带官方必选的 +// `Query`(版本门槛参数与域名过滤参数一律不发 —— 理由见 provider 文件头)。 +func TestTencentWSARequestShape(t *testing.T) { + t.Parallel() + srv, cap := fixtureServer(t, tencentWSAOK) + if _, err := newTencentWSA(Config{ + APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(), + }).Search(context.Background(), Query{Text: "昔涟", Count: 20}); err != nil { + t.Fatalf("Search: %v", err) + } + if cap.req == nil { + t.Fatal("没发出任何请求") + } + if cap.req.Method != http.MethodPost { + t.Errorf("method = %q, want POST", cap.req.Method) + } + if cap.req.URL.Path != tencentWSAPath { + t.Errorf("path = %q, want %q", cap.req.URL.Path, tencentWSAPath) + } + if got := cap.req.Header.Get("Authorization"); got != "Bearer "+testKey { + t.Errorf("Authorization = %q, want Bearer ", got) + } + var body map[string]any + mustUnmarshal(t, cap.body, &body) + if body["Query"] != "昔涟" { + t.Errorf("body Query = %v, want 昔涟", body["Query"]) + } + if len(body) != 1 { + t.Errorf("body = %v,只许发官方必选的 Query(Cnt 是尊享版门槛、Site 是域名过滤)", body) + } +} + +// TestTencentWSAPagesAreJSONStrings 是验收 ①:`Pages` 的**二次解析**。官方把每条结果编码成 +// 一段 JSON 字符串 ⇒ 拿对象数组直接喂共享提取器会得到 ErrBadResponse;本家的解析能吃下它, +// 并把 title/url/passage/date/score 归一化到位。 +func TestTencentWSAPagesAreJSONStrings(t *testing.T) { + t.Parallel() + srv, _ := fixtureServer(t, tencentWSAOK) + got, err := newTencentWSA(Config{ + APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(), + }).Search(context.Background(), Query{Text: "三星堆的由来"}) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(got) != 2 { + t.Fatalf("结果条数 = %d, want 2(Pages 里两条 JSON 文本): %+v", len(got), got) + } + first := got[0] + if first.Title != "三星堆名字的由来" { + t.Errorf("Title = %q", first.Title) + } + if first.Link != "https://www.163.com/dy/article/J43Q2STU0553FV3Z.html" { + t.Errorf("Link = %q", first.Link) + } + if !strings.HasPrefix(first.Snippet, "三星堆名字的由来 三星堆遗址") { + t.Errorf("Snippet = %q(官方 passage = 标准摘要)", first.Snippet) + } + if first.Content == "" { + t.Error("Content 为空:官方这条没有 content(动态摘要)⇒ 包内既有口径应回落 passage") + } + if first.Published != "2024-06-07 19:00:51" { + t.Errorf("Published = %q, want 官方 date 原样", first.Published) + } + if first.Score <= 0 { + t.Errorf("Score = %v, want 0.897802(官方 score 0~1)", first.Score) + } + // site(网站名称)不许被投影进 Snippet:那是「正文摘录」的语义位(同 zhipuSpec 的理由)。 + if strings.Contains(first.Snippet, "网易") { + t.Errorf("Snippet = %q 里出现了站点名「网易」(官方 site 字段)—— 站点名不进 Snippet", first.Snippet) + } +} + +// TestTencentWSAContentFallsBackToPassage 钉住「尊享版的 content 拿不到时不空着」: +// 官方 `content` 是尊享版字段(极速版不返回)⇒ 缺失时 Content 回落标准摘要 passage。 +func TestTencentWSAContentFallsBackToPassage(t *testing.T) { + t.Parallel() + srv, _ := fixtureServer(t, `{"Response":{"Pages":["{\"title\":\"t\",\"url\":\"https://x/1\",\"passage\":\"标准摘要\"}"]}}`) + got, err := newTencentWSA(Config{ + APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(), + }).Search(context.Background(), Query{Text: "q"}) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(got) != 1 { + t.Fatalf("条数 = %d, want 1", len(got)) + } + if got[0].Content != "标准摘要" { + t.Errorf("Content = %q, want 回落 passage(官方 content 是尊享版字段,可能缺)", got[0].Content) + } +} + +// TestTencentWSADirtyPageElementIsSkipped 钉住容错:解析不出来的页元素(不是 JSON、也不是 +// 对象)跳过,不毁掉整次检索。 +func TestTencentWSADirtyPageElementIsSkipped(t *testing.T) { + t.Parallel() + srv, _ := fixtureServer(t, `{"Response":{"Pages":["not-json",{"title":"对象形态也认"},"{\"title\":\"正常\"}"]}}`) + got, err := newTencentWSA(Config{ + APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(), + }).Search(context.Background(), Query{Text: "q"}) + if err != nil { + t.Fatalf("单条脏数据不该让整次检索报错: %v", err) + } + if len(got) != 2 { + t.Fatalf("条数 = %d, want 2(坏的那条应被跳过): %+v", len(got), got) + } +} + +// TestTencentWSAErrorsOnHTTP200 是验收 ②:**HTTP 200 + `Response.Error`** 这条路径 +// (假上游回的确实就是 200 —— 它的 handler 从不写状态码)。三个官方码各一条, +// 且分类要一路走到探针词汇(面板「测试连通」据此说话)。 +func TestTencentWSAErrorsOnHTTP200(t *testing.T) { + t.Parallel() + cases := []struct { + name string + code string + wantSent error + wantCode string + }{ + {"key 无效 / 无 key(同码)", "UnauthorizedOperation", ErrUnauthorized, ProbeUnauthorized}, + {"欠费", "ResourceUnavailable", ErrQuotaExceeded, ProbeQuotaExceeded}, + {"频率超限", "RequestLimitExceeded", ErrRateLimited, ProbeRateLimited}, + {"点分限流码(前缀匹配)", "RequestLimitExceeded.UinLimitExceeded", ErrRateLimited, ProbeRateLimited}, + {"未开通服务", "ResourceNotFound", ErrQuotaExceeded, ProbeQuotaExceeded}, + {"超出配额", "LimitExceeded", ErrQuotaExceeded, ProbeQuotaExceeded}, + {"鉴权前缀码", "AuthFailure.SignatureFailure", ErrUnauthorized, ProbeUnauthorized}, + {"表里没有的码 ⇒ upstream", "InternalError", ErrUpstream, ProbeUpstream}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + srv, _ := fixtureServer(t, + `{"Response":{"Error":{"Code":"`+tc.code+`","Message":"`+testKey+` 不应进文案"},"RequestId":"r"}}`) + got, err := newTencentWSA(Config{ + APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(), + }).Search(context.Background(), Query{Text: "q"}) + if err == nil { + t.Fatalf("HTTP 200 带 Response.Error 必须报失败,却拿到 %d 条结果", len(got)) + } + if !errors.Is(err, tc.wantSent) { + t.Errorf("errors.Is(err, %v) = false: %v", tc.wantSent, err) + } + if code := ProbeErrorCode(err); code != tc.wantCode { + t.Errorf("探针错误码 = %q, want %q", code, tc.wantCode) + } + if !strings.Contains(err.Error(), tc.code) { + t.Errorf("错误文案没带上原始业务码 %q(运维没法从日志定位): %v", tc.code, err) + } + // 上游 Message 是任意文本(可能回显凭据)⇒ 不许进错误文案。 + if strings.Contains(err.Error(), testKey) { + t.Fatalf("错误文案带上了上游 Message / 凭据: %v", err) + } + }) + } +} + +// TestTencentWSAEmptyPagesIsNotAnError 是验收 ③:`Pages: []` 是「上游正常应答但零结果」, +// **不是错误**(由上层按「空结果 ≠ 成功」处置),也不是结构错。 +func TestTencentWSAEmptyPagesIsNotAnError(t *testing.T) { + t.Parallel() + srv, _ := fixtureServer(t, `{"Response":{"Pages":[],"Query":"q","RequestId":"r"}}`) + got, err := newTencentWSA(Config{ + APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(), + }).Search(context.Background(), Query{Text: "q"}) + if err != nil { + t.Fatalf("空 Pages 不是错误: %v", err) + } + if len(got) != 0 { + t.Fatalf("条数 = %d, want 0", len(got)) + } +} + +// TestTencentWSAMissingPagesIsBadResponse 钉住「空」与「解析不了」分开:连 `Pages` 都没有 +// (响应形状变了)⇒ ErrBadResponse,而不是静默当成 0 条。 +func TestTencentWSAMissingPagesIsBadResponse(t *testing.T) { + t.Parallel() + for _, body := range []string{`{"Response":{"Query":"q"}}`, `{"Response":{"Pages":"不是数组"}}`, `not json`, `{}`} { + srv, _ := fixtureServer(t, body) + _, err := newTencentWSA(Config{ + APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(), + }).Search(context.Background(), Query{Text: "q"}) + if !errors.Is(err, ErrBadResponse) { + t.Errorf("输入 %q 应报 ErrBadResponse, got %v", body, err) + } + } +} + +// TestTencentWSAOverLimitResponseRejected 是验收 ④:响应体超过本仓读取上限 ⇒ +// ErrResponseTooLarge(**不静默截断成半截 JSON**)。 +func TestTencentWSAOverLimitResponseRejected(t *testing.T) { + t.Parallel() + srv, _ := fixtureServer(t, tencentWSAOK) + _, err := newTencentWSA(Config{ + APIKey: testKey, BaseURL: srv.URL, Client: srv.Client(), MaxBytes: 16, + }).Search(context.Background(), Query{Text: "q"}) + if !errors.Is(err, ErrResponseTooLarge) { + t.Fatalf("err = %v, want ErrResponseTooLarge", err) + } +} + +// TestTencentWSACodeClassIsLocal 钉住这张码表**只服务本家**:既有的公共码表 +// (errors.go 的 upstreamCodeClass,被所有家共用)不许认腾讯云的大写点分码 —— 认了会让 +// 「按码值碰运气」的隐患跨家扩散。 +func TestTencentWSACodeClassIsLocal(t *testing.T) { + t.Parallel() + for _, code := range []string{"UnauthorizedOperation", "ResourceUnavailable", "RequestLimitExceeded"} { + if _, ok := upstreamCodeClass(code); ok { + t.Errorf("upstreamCodeClass(%q) 不该认识腾讯云的业务码(它随 HTTP 200 返回,只在本家映射)", code) + } + } +}