package weixin_ilink // 本文件承载 iLink 适配器的 HTTP 管道(请求构造与响应解码)。它从 adapter.go // 拆出,以便那个文件保持在项目 ≤500 行的单文件上限之内。这里不放任何「包内其余 // 代码或其测试依赖其位于某个特定文件」的导出符号。 import ( "context" "crypto/rand" "encoding/base64" "encoding/binary" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" ) // authorizationHeader 为 req 装上 iLink 通用请求头:JSON 内容类型、bearer // token、认证类型、每次新生成的随机 X-WECHAT-UIN 防重放值,以及 App 标识头。 func (a *Adapter) authorizationHeader(req *http.Request) { req.Header.Set("Content-Type", "application/json") req.Header.Set("AuthorizationType", "ilink_bot_token") req.Header.Set("X-WECHAT-UIN", randomUIN()) req.Header.Set("iLink-App-Id", ilinkAppID) req.Header.Set("iLink-App-ClientVersion", buildClientVersion(ilinkVersion)) a.mu.RLock() token := a.cfg.BotToken a.mu.RUnlock() if token != "" { req.Header.Set("Authorization", "Bearer "+token) } } // randomUIN 返回 base64 编码的十进制 uint32,每次请求都新生成,作为 iLink 的 // 防重放值。 func randomUIN() string { var b [4]byte _, _ = rand.Read(b[:]) n := binary.BigEndian.Uint32(b[:]) return base64.StdEncoding.EncodeToString([]byte(fmt.Sprint(n))) } // doJSON 发起一次 HTTP 请求并把 JSON 响应解码进 out。它关闭响应体,并用 %w // 包裹网络/解码错误。out 为 nil 时跳过解码(用于响应体是不透明字节的 PUT)。 func (a *Adapter) doJSON(ctx context.Context, method, rawURL string, body io.Reader, out any) error { req, err := http.NewRequestWithContext(ctx, method, rawURL, body) if err != nil { return fmt.Errorf("weixin_ilink: build %s %s: %w", method, rawURL, err) } a.authorizationHeader(req) resp, err := a.httpClient.Do(req) if err != nil { return fmt.Errorf("weixin_ilink: %s %s: %w", method, rawURL, err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) return fmt.Errorf("weixin_ilink: %s %s: status %d: %s", method, rawURL, resp.StatusCode, strings.TrimSpace(string(msg))) } if out == nil { if _, err := io.Copy(io.Discard, resp.Body); err != nil { return fmt.Errorf("weixin_ilink: drain %s %s: %w", method, rawURL, err) } return nil } if err := json.NewDecoder(resp.Body).Decode(out); err != nil { return fmt.Errorf("weixin_ilink: decode %s %s: %w", method, rawURL, err) } return nil } // apiURL 把路径与已配置的基础 URL 拼接起来。 func (a *Adapter) apiURL(path string) string { return joinURL(a.cfg.BaseURL, path) } // joinURL 把基础 URL 与以斜杠开头的路径拼接起来,并容忍基础 URL 末尾带斜杠。 func joinURL(base, path string) string { return strings.TrimRight(base, "/") + path } // pollBase 返回 get_qrcode_status 使用的基础 URL:已记录 IDC 重定向主机 // (scaned_but_redirect)时用它,否则用已配置的基础 URL。 func (a *Adapter) pollBase() string { a.mu.RLock() defer a.mu.RUnlock() if a.pollBaseURL != "" { return a.pollBaseURL } return a.cfg.BaseURL } // setPollBaseURL 按官方 currentApiBaseUrl 的逻辑,为后续的 get_qrcode_status // 轮询记录一个 IDC 重定向主机(https://)。host 为空则清除该覆盖。 func (a *Adapter) setPollBaseURL(host string) { a.mu.Lock() defer a.mu.Unlock() if host == "" { a.pollBaseURL = "" return } a.pollBaseURL = "https://" + host } // buildClientVersion 按官方 api.ts 的做法,把语义化版本编码成 iLink // App-ClientVersion 的线上值 0x00MMNNPP = (major&0xff)<<16 | (minor&0xff)<<8 | // (patch&0xff)。例如 "2.4.8" -> "132104"(十进制字符串,而不是 "1")。 func buildClientVersion(version string) string { parts := strings.Split(version, ".") at := func(i int) int { if i >= len(parts) { return 0 } n, err := strconv.Atoi(parts[i]) if err != nil { return 0 } return n } v := ((at(0) & 0xff) << 16) | ((at(1) & 0xff) << 8) | (at(2) & 0xff) return strconv.Itoa(v) } // cdnURL 把路径与已配置的 CDN 基础 URL 拼接起来,并转义部分 CDN 引用携带的原始 // 查询串。 func (a *Adapter) cdnURL(raw string) string { if u, err := url.Parse(raw); err == nil && u.IsAbs() { return raw } return strings.TrimRight(a.cfg.CDNBaseURL, "/") + "/" + strings.TrimLeft(raw, "/") }