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

+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)
}
}