package search import ( "bytes" "context" "fmt" "io" "net/http" ) // defaultMaxBytes 是单次响应体的兜底读取上限(1 MiB,与 io 包的 http 响应上限同口径)。 // 组合根总会传入显式值;这里只保证 Config.MaxBytes 为零值时不是「无限读」。 const defaultMaxBytes = 1 << 20 // request 描述一次上游调用。provider 只填这些,其余(读取上限、错误分类)走共享通路 // —— 各家绝不能各写一套「发请求 + 判状态 + 读 body」的样板(AGENTS §10 三次复制铁律)。 type request struct { // Method 是 HTTP 方法(http.MethodGet / http.MethodPost)。 Method string // URL 是完整目标地址(provider 自己拼 base + path + query)。 URL string // Headers 是附加请求头(含认证头)。**绝不写进日志**。 Headers map[string]string // Body 是请求体(非空时默认 Content-Type: application/json)。 Body []byte } // do 发一次请求并返回读取上限内的响应体: // // - SSRF 守卫(组合根注入)先判最终地址 ⇒ 被拦时如实报错(不静默放行); // - 网络层失败 ⇒ ErrNetwork(含 context 超时/取消,调用方可用 errors.Is 区分); // - 非 2xx ⇒ 按业务错误码 + 状态码分类(classifyStatus); // - 超过 MaxBytes ⇒ ErrResponseTooLarge(不静默截断成半截 JSON)。 // // 报文正文与认证头**都不进日志**(本函数不记日志,日志由调用方在工具层记)。 func do(ctx context.Context, cfg Config, r request) ([]byte, error) { if cfg.Client == nil { return nil, fmt.Errorf("search: no http client: %w", ErrNotConfigured) } if cfg.Guard != nil { if err := cfg.Guard(ctx, r.URL); err != nil { return nil, fmt.Errorf("search: blocked upstream url: %w", err) } } var body io.Reader if len(r.Body) > 0 { body = bytes.NewReader(r.Body) } req, err := http.NewRequestWithContext(ctx, r.Method, r.URL, body) if err != nil { return nil, fmt.Errorf("search: build request: %w", err) } for k, v := range r.Headers { req.Header.Set(k, v) } if len(r.Body) > 0 && req.Header.Get("Content-Type") == "" { req.Header.Set("Content-Type", "application/json") } resp, err := cfg.Client.Do(req) if err != nil { return nil, fmt.Errorf("%w: %w", ErrNetwork, err) } defer resp.Body.Close() data, err := readCapped(resp.Body, cfg.MaxBytes) if err != nil { return nil, err } if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { // 错误体可能带业务错误码(如阿里云 Retrieval.Arrears);错误体本身不入错误文案 // 以外的去处,也不会被当成结果。 return nil, fmt.Errorf("search: upstream status %d: %w", resp.StatusCode, classifyStatus(resp.StatusCode, upstreamCode(data))) } return data, nil } // readCapped 读回最多 max 字节;超限返回 ErrResponseTooLarge。 func readCapped(r io.Reader, max int64) ([]byte, error) { if max <= 0 { max = defaultMaxBytes } data, err := io.ReadAll(io.LimitReader(r, max+1)) if err != nil { return nil, fmt.Errorf("search: read response: %w", err) } if int64(len(data)) > max { return nil, fmt.Errorf("%w: exceeds the %d-byte limit", ErrResponseTooLarge, max) } return data, nil } // baseURLJoin 把基地址与各家固定路径拼起来(容忍基地址尾部的 "/")。 func baseURLJoin(baseURL, path string) string { trimmed := baseURL for len(trimmed) > 0 && trimmed[len(trimmed)-1] == '/' { trimmed = trimmed[:len(trimmed)-1] } return trimmed + path }