官方文档逐页确证后落地的四件(分册 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,官方真实响应形态未实测。
309 lines
12 KiB
Go
309 lines
12 KiB
Go
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)
|
||
}
|
||
}
|