Files
AskaEth 25aa50e532
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(search): 智谱 provider 按官方 schema 加固(请求补确证字段 + 错误码分档)
官方文档逐页确证后落地的四件(分册 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,官方真实响应形态未实测。
2026-10-07 09:59:24 +08:00

309 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}