feat(search): 智谱 provider 按官方 schema 加固(请求补确证字段 + 错误码分档)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

官方文档逐页确证后落地的四件(分册 11 的「官方 schema 确证(2026-10-07)」):
- 请求补 search_intent:false(官方标 required,缺发可能触发 1213)与
  content_size:medium;count 仅 1–50 透传,越界不发该字段;
  search_query >70 rune 直接报错且**不发请求**;
- 响应按官方收窄:摘要只认 snippet/summary/abstract 候选(官方响应**没有**
  snippet),publish_date 入 Published;
- 错误码分档:1000/1001/1003⇒unauthorized、1113⇒quota_exceeded、
  1302/1305⇒rate_limited。🔴 1113 与 1302/1305 **同为 HTTP 429** ⇒ 只看状态码
  分不开「该充值」与「该退避」,必须读 error.code(码同时进日志文案);
- media/icon/refer 当前一律不投影(Result 没有「来源站点」位;要投影应给
  Result 加字段 + 同步契约,不许塞进 Snippet —— 否则摘要位会被站点名恒填满)。

另修两处过期注释(providers_test.go 仍写「只发 search_engine/search_query」)。

验收(主会话复跑 + 亲自变异):go vet 绿;search 包与调用方 io 包全绿;
我用 -overlay 删掉 1113⇒quota_exceeded 映射 ⇒ TestZhipuErrorCodeMapping 变红
(欠费被误报成限流、探针码 rate_limited)。

⚠️ 候选键容错暂未删除:官方已排除的键保留到**真机实测通过后**再收窄(现无 key)。
⚠️ 真机未跑:仍只打 httptest,官方真实响应形态未实测。
This commit is contained in:
AskaEth committed 2026-10-07 09:59:24 +08:00
1 parent c512609c63
commit 25aa50e532
4 files changed
+452 -16

No files matched your search

+52 -2
View File
@@ -3,6 +3,7 @@ package search
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
)
@@ -63,7 +64,24 @@ func Unavailable(err error) bool {
// classifyStatus 把上游的失败状态码归成本仓的哨兵错误。
// 先看业务错误码(各家官方文档确证的那几个,见 upstreamCodeClass),再看 HTTP 状态。
//
// 业务错误码非空时,返回值会带上 `(code=…)`:**只看 HTTP 状态分不开同一状态下的不同含义**
// —— 智谱把「欠费」与「限流」都放在 HTTP 429(1113 vs 1302/1305),码是唯一判据;这段文案
// 会被工具层记进日志,运维据此知道该充值还是该退避(分册 11 错误码节)。`errors.Is` 仍能
// 命中对应哨兵(用 %w 包装)。
//
// ⚠️ 只带 **code**、不带上游 message:message 是任意上游文本(可能回显请求信息),而 `do` 的
// 契约是「错误体不入错误文案」(见 http.go),凭据更不许出现在错误/日志里。
func classifyStatus(status int, code string) error {
sentinel := classifyStatusSentinel(status, code)
if c := strings.TrimSpace(code); c != "" {
return fmt.Errorf("%w (code=%s)", sentinel, c)
}
return sentinel
}
// classifyStatusSentinel 是「业务码优先、再看状态码」的原判定(不带码回显)。
func classifyStatusSentinel(status int, code string) error {
if e, ok := upstreamCodeClass(code); ok {
return e
}
@@ -74,6 +92,7 @@ func classifyStatus(status int, code string) error {
case status == http.StatusPaymentRequired:
return ErrQuotaExceeded
case status == http.StatusTooManyRequests:
// 429 有两种含义(欠费 / 限流):表里没收录的码按**保守档**当限流处理。
return ErrRateLimited
default:
// 5xx 与其余 4xx 一律归 ErrUpstream(工具侧只关心「不可用 ⇒ 前进一格」)。
@@ -83,10 +102,14 @@ func classifyStatus(status int, code string) error {
// upstreamCodeClass 把各家**官方文档确证**的业务错误码归类到哨兵错误(大小写不敏感)。
//
// 来源:阿里云 IQS 错误码表(help.aliyun.com/document_detail/2857020.html;
// DevDocs/reference/search-providers/15-阿里云-IQS.md 的「错误码」节)。
// 来源:① 阿里云 IQS 错误码表(help.aliyun.com/document_detail/2857020.html;
// DevDocs/reference/search-providers/15-阿里云-IQS.md 的「错误码」节);② 智谱 BigModel
// 平台通用错误码表(见 zhipuCodeClass)。
// 只收录确证过的条目 —— 未确证的错误码一律不猜。
func upstreamCodeClass(code string) (error, bool) {
if e, ok := zhipuCodeClass(code); ok {
return e, true
}
switch strings.ToLower(strings.TrimSpace(code)) {
case "retrieval.throttling.user", "retrieval.testuserqueryperdayexceeded":
return ErrRateLimited, true
@@ -99,6 +122,33 @@ func upstreamCodeClass(code string) (error, bool) {
}
}
// zhipuCodeClass 是智谱 BigModel 的**平台通用**错误码表(分册 11「官方 schema 确证」③
// 逐码对应官方原文,适用 `open.bigmodel.cn/api/paas/v4/*`):
//
// 1000 / 1001 / 1003(HTTP 401)⇒ 身份验证失败 / 未收到凭据 / 凭据过期 ⇒ unauthorized
// 1113(HTTP 429) ⇒ 账户欠费 ⇒ quota_exceeded
// 1302 / 1305(HTTP 429) ⇒ 账户限流 / 平台过载 ⇒ rate_limited
//
// 🔴 1113 与 1302/1305 **同为 HTTP 429** ⇒ 不读 code 就分不开「该充值」与「该退避」。
// 搜索专用码 1701(并发达上限)/1702/1703 只登记、不在此映射:官方未给它们的 HTTP 状态码,
// 保守交给状态码兜底(429 未收录码 ⇒ rate_limited,其余 4xx/5xx ⇒ upstream)。
//
// ⚠️ 作用域:这些是**裸数字码**,而本函数被所有 provider 共用。当前六家里只有智谱用数字业务码
// (阿里云是 `Retrieval.*` 点分码)⇒ 暂按码值归类。**若将来出现第二家用数字码的家,必须改成
// 「按 provider 分表」**,不能靠码值碰运气。
func zhipuCodeClass(code string) (error, bool) {
switch strings.TrimSpace(code) {
case "1000", "1001", "1003":
return ErrUnauthorized, true
case "1113":
return ErrQuotaExceeded, true
case "1302", "1305":
return ErrRateLimited, true
default:
return nil, false
}
}
// codePaths 是上游错误体里「业务错误码」的候选位置(点分路径,按序取第一个非空字符串)。
// 各家形状不一,这里给的是容错候选;取不到就当没有错误码(按状态码分类)。
func codePaths() []string {
+9 -2
View File
@@ -101,7 +101,7 @@ func searxngCase() providerCase {
}
}
// zhipuCase 是智谱的夹具(POST + Bearer;请求字段 search_engine/search_query)。
// zhipuCase 是智谱的夹具(POST + Bearer;请求字段 search_engine/search_query/search_intent/content_size)。
func zhipuCase() providerCase {
return providerCase{
name: "zhipu", newP: newZhipu,
@@ -112,7 +112,8 @@ func zhipuCase() providerCase {
wantContent: "智谱正文",
checkRequest: func(t *testing.T, c *captured) {
t.Helper()
// 只发官方文档确证过的字段(search_engine / search_query)。
// 只发官方文档确证过的字段:search_engine / search_query / search_intent / content_size,
// 外加**条件透传**的 count(仅当 Count 落在官方区间 1–50;本夹具 Query 未给 Count ⇒ 不发)。
var body map[string]any
mustUnmarshal(t, c.body, &body)
if body["search_query"] != "昔涟" {
@@ -121,6 +122,12 @@ func zhipuCase() providerCase {
if body["search_engine"] != zhipuEngineStd {
t.Errorf("zhipu body search_engine = %v, want %s", body["search_engine"], zhipuEngineStd)
}
if body["search_intent"] != false {
t.Errorf("zhipu body search_intent = %v, want false(官方标 required)", body["search_intent"])
}
if body["content_size"] != zhipuContentSize {
t.Errorf("zhipu body content_size = %v, want %s", body["content_size"], zhipuContentSize)
}
},
}
}
+83 -12
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"unicode/utf8"
)
// zhipuProfile 是智谱 BigModel 联网搜索(design/27 §2.1 国内首选;分册 11)。
@@ -13,20 +14,50 @@ import (
// 官方**定价已确证**(分册 11 的官方定价页逐字):Search-Std 0.01 元/次、
// Search-Pro 0.03、Search-Pro-Sogou 0.05、Search-Pro-Quark 0.05。
//
// ⚠️ 诚实边界(分册 11「未确证」):**完整请求/响应 JSON schema 未取到**,只确证了
// 字段名(请求侧 search_engine/search_query/…,响应侧 title/link/content)。
// 因此这里:① 请求只发确证过的两个字段;② 响应用共享提取器按**多组候选键**容错,
// 不把任何猜测的字段当事实。拿到真实 key 后应按官方 API Reference 复核并收窄候选键。
// ✅ 官方 schema 已确证(2026-10-07,分册 11「官方 schema 确证」一节,逐条对应官方原文):
// - 请求 9 字段:`search_query`(≤ **70** 字)/ `search_engine`(四档 enum,必填)/
// `search_intent`(boolean,页面标 required)/ `count`(**1–50**,默认 10)/
// `search_domain_filter` / `search_recency_filter` / `content_size` / `request_id` / `user_id`;
// - 响应:顶层 `id`/`created`/`request_id`/`search_intent`(object[])/`search_result`(object[]),
// `search_result[]` 每条 7 键 = `title`/`content`/`link`/`media`/`icon`/`refer`/`publish_date`;
// - 🔴 同名字段语义反转:请求侧 `search_intent` 是 **boolean**,响应侧是 **object[]**。
//
// ⚠️ 诚实边界:**没有 API key ⇒ 真机未实测**(分册 11「本轮未能确证」逐条)。因此:
//
// ① 请求只发**确证过的字段**;② 响应用共享提取器按**多组候选键**容错 —— 官方 schema
// 已排除的候选键**暂不删**(见 zhipuSpec),待真机实测通过后再收窄 —— 拿未实测的推断
// 去砍掉容错路径,风险大于收益;③ `content_size` 的取值属**半确证**(见常量注释)。
type zhipu struct{ cfg Config }
// zhipuPath 是联网搜索端点路径(分册 11 官方原文:POST https://open.bigmodel.cn/api/paas/v4/web_search)。
const zhipuPath = "/api/paas/v4/web_search"
// zhipuEngineStd 是默认搜索型号。取 `search_std`(自研基础搜索,官方定价页确证
// 0.01 元/次,四档里最便宜)。⚠️ `search_engine` 是否可选官方未明说(分册 11),
// 显式写死一档以固定成本;要换档请改这里或后续开放为配置项。
// 0.01 元/次,四档里最便宜)。官方 API Reference 把 `search_engine` 列为**必填 enum**
// (`search_std`/`search_pro`/`search_pro_sogou`/`search_pro_quark`),这里显式写死一档
// 以固定成本;要换档请改这里,或后续开放为配置项。
const zhipuEngineStd = "search_std"
// zhipuMaxQueryRunes 是 `search_query` 的**官方硬上限**(API Reference:Maximum string
// length 70)。超限就**如实报错、一个请求都不发** —— 静默截断会让她以为搜的是别的东西。
//
// 按**字符(rune)**计:官方只写 "string length",未说明是字符还是字节;中文查询词按字符
// 计最宽(一个中文 = 3 字节),拿不准时宁可少发几个字,也不误把合法查询词拒掉。
const zhipuMaxQueryRunes = 70
// zhipuCount 的官方合法区间(API Reference:1–50,默认 10)。
const (
zhipuMinCount = 1
zhipuMaxCount = 50
)
// zhipuContentSize 压短上游返回的正文(`medium` = 摘要),降低撞本仓 1 MiB 读取上限
// (plugins/tools/io 的 http 响应上限)的风险。
//
// ⚠️ **半确证**:API Reference 的字段表**没标 default**;「默认 medium」只来自 guide 页
// 示例旁的注释(分册 11「本轮未能确证」第 5 条)。这里按 `medium` 发,属保守取值。
const zhipuContentSize = "medium"
// newZhipu 造一家智谱 provider。
func newZhipu(cfg Config) Provider { return &zhipu{cfg: cfg} }
@@ -35,12 +66,25 @@ func (p *zhipu) Name() string { return NameZhipu }
// Search 实现 Provider。
func (p *zhipu) Search(ctx context.Context, q Query) ([]Result, error) {
// TODO(未确证):响应可能很大(自带网页正文,分册 11 提示注意本仓 1 MiB 上限)。
// 官方另有 `content_size` 可限正文长度,但其取值域未确证 ⇒ 不写死,留待拿到
// 真实 key 后按官方文档补上(当前超限会如实返回 ErrResponseTooLarge)。
if n := utf8.RuneCountInString(q.Text); n > zhipuMaxQueryRunes {
// 官方硬上限:超限**如实报错、不发请求**(截断 = 悄悄把她的查询词换成另一个)。
return nil, fmt.Errorf("search: zhipu: query is %d characters, upstream limit is %d",
n, zhipuMaxQueryRunes)
}
payload := map[string]any{
"search_engine": zhipuEngineStd,
"search_query": q.Text,
// 官方把 search_intent 标为 required(默认 false):缺发可能触发 1213(未正常接收到
// 该参数)。发 false = 直接搜、不做意图识别。
// 🔴 响应侧同名字段是 object[](意图结果),两侧语义相反 —— 别混用。
"search_intent": false,
// content_size 是**半确证**取值(见常量注释):压短正文,降低撞读取上限的风险。
"content_size": zhipuContentSize,
}
// count 落在官方区间才透传;越界就**不发这个字段**,让上游用默认 10 ——
// 把非法值发上去换一个 1210 参数错误没有意义。
if c, ok := zhipuCount(q.Count); ok {
payload["count"] = c
}
body, err := json.Marshal(payload)
if err != nil {
@@ -58,16 +102,43 @@ func (p *zhipu) Search(ctx context.Context, q Query) ([]Result, error) {
return extractResults(data, zhipuSpec())
}
// zhipuSpec 是智谱响应的候选形状。官方示例以「工具块」给出 WebBrowserOutput{title,link,content}
// (分册 11),故 title/link/content 是确证键;外层数组键名未确证 ⇒ 给多组候选。
// zhipuCount 把工具侧的 count 收敛到官方区间 [1,50];越界返回 ok=false(不发该字段)。
func zhipuCount(count int) (int, bool) {
if count < zhipuMinCount || count > zhipuMaxCount {
return 0, false
}
return count, true
}
// zhipuSpec 是智谱响应的候选形状。
//
// ✅ 2026-10-07 官方 schema 已确证(分册 11):数组键是**顶层 `search_result`**,每条 7 键 =
// title/content/link/media/icon/refer/publish_date;官方**没有** snippet/summary/abstract/
// url/score/rank(「摘要」就在 `content` 里)。据此:
// - `publish_date` 是确证的发布时间 ⇒ 已映射到 Published;
// - 🔴 **`media` / `icon` / `refer` 当前一律不投影**(主人 2026-10-07 裁定,撤销先前把
// `media` 当 Snippet 候选的写法)。`media` 是**网站名称**(如「搜狐」),不是正文摘录:
// 官方响应**根本没有 `snippet`** ⇒ 若把它列为候选,`Snippet` 会被**恒由 `media` 填满**
// (不是偶尔兜底),等于每条结果的「摘要位」都变成站点名 —— 往语义位塞了另一类事实;
// 更危险的是共享提取器有一条**单向**回落(`extractResults`:`Content` 空 ⇒ 回落
// `Snippet`)⇒ 站点名有可能被当成正文递出去。
// `Result` 没有「来源站点」位(本片不许改 `extract.go` / `search.Result`)⇒ **将来要投影
// `media`,正确做法是给 `Result` 加一个字段并同步契约**(不是塞进 `Snippet`)。
//
// ⚠️ **候选键暂不删**:主人还没给 key,容错路径**未经真机验证**;官方 schema 只覆盖
// `search_std` 的文档口径(分册 11「各档引擎返回字段是否真同构」未确证)。**待真机实测
// 通过后**再把这些已排除的候选键收窄成单键。
func zhipuSpec() spec {
return spec{
Arrays: []string{"search_result", "results", "data", "data.results", "web_search_result"},
Title: []string{"title", "name"},
Link: []string{"link", "url"},
// 🔴 **不含 `media`**:它是网站名称、不是正文摘录,且官方响应没有 `snippet`
// ⇒ 列进来会让 Snippet **恒**被站点名填满,并被共享提取器的单向回落当成正文
// 递出去(理由见上方注释;`media`/`icon`/`refer` 当前一律不投影)。
Snippet: []string{"snippet", "summary", "abstract"},
Content: []string{"content", "text"},
// 官方示例未见发布时间/评分字段 ⇒ 只给通用候选,取不到就是空。
// 官方确证:publish_date(如 "2025-05-23")。
Published: []string{"publish_date", "published", "date"},
Score: []string{"score", "rank"},
}
+308
View File
@@ -0,0 +1,308 @@
package search
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
)
// 本文件钉住智谱 provider 的**请求面**与**错误映射**(依据:分册 11「官方 schema 确证(2026-10-07)」):
// 官方请求 9 字段(`search_query` ≤70 字 / `search_intent` boolean 必填 / `count` 1–50 / `content_size`)、
// 响应 `search_result[]` 七键、错误码 `error.code`(🔴 1113 欠费与 1302/1305 限流**同为 HTTP 429**)。
// 全部打 httptest,**不打真实网络**(无 key,真机未实测)。
// zhipuOK 是官方文档原文形状的应答(分册 11 响应示例逐字段照抄),含语义易混的
// 响应侧 `search_intent`(**object[]**,与请求侧 boolean 反向)。
const zhipuOK = `{
"created": 1748261757,
"id": "20250526201557dda85ca6801b467b",
"request_id": "20250526201557dda85ca6801b467b",
"search_intent": [
{"intent": "SEARCH_ALL", "keywords": "2025年4月 财经新闻", "query": "搜索2025年4月的财经新闻"}
],
"search_result": [
{
"content": "一、1-4月我国对外直接投资575.4亿美元,同比增长7.5%。",
"icon": "https://sfile.chatglm.cn/searchImage/sohu_icon_new.jpg",
"link": "https://www.sohu.com/a/897879632_121123890",
"media": "搜狐",
"publish_date": "2025-05-23",
"refer": "ref_1",
"title": "2025年5月23日财经早资讯"
}
]
}`
// zhipuStub 是一个假智谱上游:回固定状态码 + 固定报文,并数着被打了多少次
// (用来证明「超长查询词根本不发请求」)。
type zhipuStub struct {
srv *httptest.Server
hits int32
body chan string
}
func newZhipuStub(t *testing.T, status int, fixture string) *zhipuStub {
t.Helper()
st := &zhipuStub{body: make(chan string, 8)}
st.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&st.hits, 1)
raw, _ := io.ReadAll(r.Body)
select {
case st.body <- string(raw):
default:
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write([]byte(fixture))
}))
t.Cleanup(st.srv.Close)
return st
}
func (st *zhipuStub) provider() Provider {
return newZhipu(Config{APIKey: testKey, BaseURL: st.srv.URL, Client: st.srv.Client(), MaxBytes: 1 << 20})
}
func (st *zhipuStub) hitCount() int { return int(atomic.LoadInt32(&st.hits)) }
// lastBody 取最近一次请求体(一次都没发过时返回 "")。
func (st *zhipuStub) lastBody() string {
select {
case b := <-st.body:
return b
default:
return ""
}
}
// decodedBody 把最近一次请求体解成 map;没发过请求即 Fatal。
func (st *zhipuStub) decodedBody(t *testing.T) map[string]any {
t.Helper()
raw := st.lastBody()
if raw == "" {
t.Fatal("没收到任何请求体 —— 请求根本没发出去")
}
var body map[string]any
mustUnmarshal(t, raw, &body)
return body
}
// 判据 ①:请求体补发官方标 required / 半确证的字段(`search_intent:false` + `content_size:"medium"`),
// 合法 `count` 透传。
func TestZhipuRequestCarriesConfirmedFields(t *testing.T) {
t.Parallel()
st := newZhipuStub(t, http.StatusOK, zhipuOK)
got, err := st.provider().Search(context.Background(), Query{Text: "昔涟", Count: 5})
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(got) != 1 {
t.Fatalf("结果条数 = %d, want 1", len(got))
}
body := st.decodedBody(t)
if body["search_intent"] != false {
t.Errorf("search_intent = %v, want false(官方标 required,缺发可能触发 1213)", body["search_intent"])
}
if body["content_size"] != zhipuContentSize {
t.Errorf("content_size = %v, want %q", body["content_size"], zhipuContentSize)
}
if body["count"] != float64(5) {
t.Errorf("count = %v, want 5(合法区间内应透传)", body["count"])
}
if body["search_engine"] != zhipuEngineStd {
t.Errorf("search_engine = %v, want %q", body["search_engine"], zhipuEngineStd)
}
if body["search_query"] != "昔涟" {
t.Errorf("search_query = %v, want 昔涟", body["search_query"])
}
}
// 判据 ②:`count` 落在官方区间 [1,50] 才透传;越界(0 / 负数 / >50)**不发这个字段**,
// 让上游用默认 10(不许把非法值发上去)。
func TestZhipuCountOnlySentWithinOfficialRange(t *testing.T) {
t.Parallel()
cases := []struct {
count int
want bool
}{
{0, false}, {1, true}, {10, true}, {50, true}, {51, false}, {-3, false},
}
for _, tc := range cases {
tc := tc
t.Run(fmt.Sprintf("count=%d", tc.count), func(t *testing.T) {
t.Parallel()
st := newZhipuStub(t, http.StatusOK, zhipuOK)
if _, err := st.provider().Search(context.Background(), Query{Text: "昔涟", Count: tc.count}); err != nil {
t.Fatalf("Search: %v", err)
}
raw, present := st.decodedBody(t)["count"]
if !tc.want {
if present {
t.Fatalf("count=%d 越界却把非法值发了上去: %v", tc.count, raw)
}
return
}
if !present || raw != float64(tc.count) {
t.Fatalf("count = %v (present=%v), want %d", raw, present, tc.count)
}
})
}
}
// 判据 ③:`search_query` 超过官方 70 字硬上限 ⇒ **如实报错**(不截断),且**一个请求都不发**
// (服务器计数为 0 是证据)。边界:正好 70 字放行。
func TestZhipuQueryOverLimitFailsWithoutRequest(t *testing.T) {
t.Parallel()
t.Run("71 字必须报错且不发请求", func(t *testing.T) {
t.Parallel()
st := newZhipuStub(t, http.StatusOK, zhipuOK)
// 用中文:证明按**字符**(rune)而不是字节计数(71 字 = 213 字节)。
got, err := st.provider().Search(context.Background(),
Query{Text: strings.Repeat("昔", zhipuMaxQueryRunes+1), Count: 3})
if err == nil {
t.Fatalf("超长查询词必须如实报错,却拿到了 %d 条结果", len(got))
}
if !strings.Contains(err.Error(), "70") {
t.Errorf("错误文案应点明官方上限 70:%v", err)
}
if n := st.hitCount(); n != 0 {
t.Fatalf("超长查询词不该发出请求,服务器却收到 %d 次", n)
}
})
t.Run("70 字是上限内,应放行", func(t *testing.T) {
t.Parallel()
st := newZhipuStub(t, http.StatusOK, zhipuOK)
if _, err := st.provider().Search(context.Background(),
Query{Text: strings.Repeat("昔", zhipuMaxQueryRunes), Count: 1}); err != nil {
t.Fatalf("正好 %d 字在官方上限内,不该报错: %v", zhipuMaxQueryRunes, err)
}
if n := st.hitCount(); n != 1 {
t.Fatalf("正好 %d 字应发出 1 次请求,实际 %d 次", zhipuMaxQueryRunes, n)
}
})
}
// 判据 ④:错误映射 —— 🔴 429 上的「欠费(1113)」与「限流(1302/1305)」必须靠 `error.code` 分开,
// 不许只看 HTTP 状态;分类要一路走到探针词汇(面板「测试连通」据此说话)。
func TestZhipuErrorCodeMapping(t *testing.T) {
t.Parallel()
cases := []struct {
name string
status int
body string
wantSent error
wantCode string // 探针错误码(probe.go 的词汇)
wantIn string // 错误文案里必须带上的原始业务码(供日志定位)
}{
{
name: "凭据无效 1001", status: http.StatusUnauthorized,
body: `{"error":{"code":"1001","message":"Header 中未收到 Authentication 参数,无法进行身份验证"}}`,
wantSent: ErrUnauthorized, wantCode: ProbeUnauthorized, wantIn: "1001",
},
{
name: "凭据无效 1000", status: http.StatusUnauthorized,
body: `{"error":{"code":"1000","message":"身份验证失败"}}`,
wantSent: ErrUnauthorized, wantCode: ProbeUnauthorized, wantIn: "1000",
},
{
name: "凭据过期 1003(数字码也要认)", status: http.StatusUnauthorized,
body: `{"error":{"code":1003,"message":"Authentication Token 已过期,请重新生成"}}`,
wantSent: ErrUnauthorized, wantCode: ProbeUnauthorized, wantIn: "1003",
},
{
name: "欠费 1113(同为 429,必须与限流分开)", status: http.StatusTooManyRequests,
body: `{"error":{"code":"1113","message":"您的账户已欠费,请充值后重试"}}`,
wantSent: ErrQuotaExceeded, wantCode: ProbeQuotaExceeded, wantIn: "1113",
},
{
name: "账户限流 1302", status: http.StatusTooManyRequests,
body: `{"error":{"code":"1302","message":"您的账户已达到速率限制,请您控制请求频率"}}`,
wantSent: ErrRateLimited, wantCode: ProbeRateLimited, wantIn: "1302",
},
{
name: "平台过载 1305", status: http.StatusTooManyRequests,
body: `{"error":{"code":"1305","message":"该模型当前访问量过大,请您稍后再试"}}`,
wantSent: ErrRateLimited, wantCode: ProbeRateLimited, wantIn: "1305",
},
{
name: "429 + 表里没有的码 ⇒ 保守当限流,但码要进文案", status: http.StatusTooManyRequests,
body: `{"error":{"code":"1701","message":"并发已达上限"}}`,
wantSent: ErrRateLimited, wantCode: ProbeRateLimited, wantIn: "1701",
},
{
name: "429 + 没有码 ⇒ 保守当限流", status: http.StatusTooManyRequests,
body: `{}`,
wantSent: ErrRateLimited, wantCode: ProbeRateLimited,
},
{
name: "5xx ⇒ upstream", status: http.StatusBadGateway,
body: `<html>bad gateway</html>`,
wantSent: ErrUpstream, wantCode: ProbeUpstream,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
st := newZhipuStub(t, tc.status, tc.body)
got, err := st.provider().Search(context.Background(), Query{Text: "昔涟", Count: 1})
if err == nil {
t.Fatalf("非 2xx 必须如实报错,却拿到 %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 tc.wantIn != "" && !strings.Contains(err.Error(), tc.wantIn) {
t.Errorf("错误文案没带上原始业务码 %q(运维没法从日志区分欠费/限流):%v", tc.wantIn, err)
}
if strings.Contains(err.Error(), testKey) {
t.Fatalf("错误文案泄漏了 API key: %v", err)
}
})
}
}
// 判据 ⑤:官方确证的键能被归一化 —— 响应侧 `search_intent` 是 **object[]**(与请求侧 boolean 反向),
// 不许被误当成结果数组;`publish_date` 真的被吃进来,而 `media`(网站名称)**不许**被投影到 Snippet。
func TestZhipuResponseNormalizesConfirmedKeys(t *testing.T) {
t.Parallel()
st := newZhipuStub(t, http.StatusOK, zhipuOK)
got, err := st.provider().Search(context.Background(), Query{Text: "昔涟", Count: 1})
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(got) != 1 {
t.Fatalf("结果条数 = %d, want 1(search_intent 是意图数组,不该被当成结果数组)", len(got))
}
r := got[0]
if r.Title != "2025年5月23日财经早资讯" {
t.Errorf("Title = %q", r.Title)
}
if r.Link != "https://www.sohu.com/a/897879632_121123890" {
t.Errorf("Link = %q", r.Link)
}
if !strings.HasPrefix(r.Content, "一、1-4月") {
t.Errorf("Content = %q(官方「摘要」就在 content 里)", r.Content)
}
if r.Published != "2025-05-23" {
t.Errorf("Published = %q, want 2025-05-23(官方 publish_date)", r.Published)
}
// 🔴 官方响应**没有 `snippet`** ⇒ 若把 `media`(网站名称,如「搜狐」)当候选塞进 `Snippet`,
// 它会被**恒定填满**(不是偶尔兜底),而 Snippet 的语义是**正文摘录**;更糟的是共享提取器
// 有一条单向回落(`Content` 空 ⇒ 回落 `Snippet`)⇒ 站点名可能被当成正文递出去。
if strings.Contains(r.Snippet, "搜狐") {
t.Errorf("Snippet = %q 里出现了站点名「搜狐」(官方 media=网站名称)—— media/icon/refer 当前一律不投影", r.Snippet)
}
if r.Snippet != "" {
t.Errorf("Snippet = %q, want 空(官方响应没有 snippet,Snippet 不许被别的字段填充)", r.Snippet)
}
}