Files
AskaEth 4c187ac070 refactor(trace): 把剥 url.Error 壳的实现收敛成 trace.URLCause
五处逐字同形的三行(mediafetch.sanitizeErr / httpclient.transportCause /
vision.fetchCause / telegram.callCause / weixin_ilink.urlFreeCause)收敛为
trace 包里的唯一实现,13 个调用点改为直接调用;5 份 body 只剩 1 份,5 个
未导出符号与其文件内注释整份删除(不留薄封装)。

行为等价:只剥 *url.Error、不新增包装,内层哨兵链(context 取消/超时、
*net.DNSError 等)原样透传,因此重试/降级/熔断的分类判据不变。两处刻意的
收紧:
- 逐层剥:内层若还是 *url.Error 也一起剥(只剥一层时内层仍带同一段地址);
- *url.Error 的 Err 为 nil(或链自引用)时不再回落到原串,退化成只含 Op 的
  替身 —— 那种错误无法在不带地址的前提下原样返回,而地址里就是凭据。

顺带把 httpclient 的 user:pass@ 用例改成变异可打红:net/http 自己在
*url.Error 里就把密码打成 ***,「密码不出现」在剥壳与不剥壳下都成立(空跑),
用户名才是分水岭。

验收:go build ./... + go vet ./... + 指定包 go test -count=1 全绿;
变异验证(把 URLCause 改成透传)⇒ 五处「不含 URL」断言全红。
2026-10-26 18:45:34 +08:00

165 lines
5.8 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 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"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/mediafetch"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// ilinkAuthType 是 iLink 请求头 `AuthorizationType` 的协议定值,仅此一处定义。
const ilinkAuthType = "ilink_bot_token"
// maxErrorBodyBytes 是 iLink 非 2xx 响应里被读取的字节上限:只用来数体积与取
// sha256 前缀(trace.UpstreamError 的口径),正文不进错误。
const maxErrorBodyBytes = 4096
// authorizationHeader 为 req 装上 iLink 通用请求头:JSON 内容类型、bearer
// token、认证类型、每次新生成的随机 X-WECHAT-UIN 防重放值,以及 App 标识头。
func (a *Adapter) authorizationHeader(req *http.Request) {
for k, vs := range a.inboundHeaders() {
for _, v := range vs {
req.Header.Set(k, v)
}
}
}
// inboundHeaders 构造 iLink 通用请求头(JSON 内容类型、bearer token、认证类型、随机
// 防重放值、App 标识头)。API 调用与**入站附件取回**共用同一份口径(切片 IM-1)——
// CDN 与 API 同属 iLink 平台,凭据与标识没有任何区别。
func (a *Adapter) inboundHeaders() http.Header {
h := http.Header{}
h.Set("Content-Type", "application/json")
h.Set("AuthorizationType", ilinkAuthType)
h.Set("X-WECHAT-UIN", randomUIN())
h.Set("iLink-App-Id", ilinkAppID)
h.Set("iLink-App-ClientVersion", buildClientVersion(ilinkVersion))
a.mu.RLock()
token := a.cfg.BotToken
a.mu.RUnlock()
if token != "" {
h.Set("Authorization", "Bearer "+token)
}
return h
}
// 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)。
//
// 地址只以来源形态出镜(mediafetch.HostOf):rawURL 由 base+path 拼成,而 pollBase
// 可能是对端重定向给的主机,完整串没有进日志的必要。非 2xx 的响应体同样不拼 ——
// iLink 会在 errmsg 里回显请求内容,这里只经 trace.UpstreamError 留下
// 状态码/体积/sha256 前缀(同 M40 口径)。
func (a *Adapter) doJSON(ctx context.Context, method, rawURL string, body io.Reader, out any) error {
host := mediafetch.HostOf(rawURL)
req, err := http.NewRequestWithContext(ctx, method, rawURL, body)
if err != nil {
return fmt.Errorf("weixin_ilink: build %s %s: %w", method, host, trace.URLCause(err))
}
a.authorizationHeader(req)
resp, err := a.httpClient.Do(req)
if err != nil {
return fmt.Errorf("weixin_ilink: %s %s: %w", method, host, trace.URLCause(err))
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
msg, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes))
return fmt.Errorf("weixin_ilink: %s %s: %w", method, host,
trace.UpstreamError(host, resp.StatusCode, resp.Header.Get("Content-Type"), msg))
}
if out == nil {
if _, err := io.Copy(io.Discard, resp.Body); err != nil {
return fmt.Errorf("weixin_ilink: drain %s %s: %w", method, host, trace.URLCause(err))
}
return nil
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return fmt.Errorf("weixin_ilink: decode %s %s: %w", method, host, 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://<redirect_host>)。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, "/")
}