feat: 网关请求头 wire casing 保持、转发行为开关、调试日志增强及 accept-encoding 恢复

- 新增 header_util.go,通过 setHeaderRaw/getHeaderRaw/addHeaderRaw 绕过
  Go 的 canonical-case 规范化,保持真实 Claude CLI 抓包的请求头大小写
  (如 "x-app" 而非 "X-App","X-Stainless-OS" 而非 "X-Stainless-Os")
- 新增管理后台开关:指纹统一化(默认开启)和 metadata 透传(默认关闭),
  使用 atomic.Value + singleflight 缓存模式,60s TTL
- 调试日志从控制台 body 打印升级为文件级完整快照
  (按真实 wire 顺序输出 headers + 格式化 JSON body + 上下文元数据)
- 恢复 accept-encoding 到白名单,在 http_upstream.go 新增 decompressResponseBody
  处理 gzip/brotli/deflate 解压(Go 显式设置 Accept-Encoding 时不会自动解压)
- OAuth 服务 axios UA 从 1.8.4 更新至 1.13.6
- 测试断言改用 getHeaderRaw 适配 raw header 存储方式
This commit is contained in:
shaw
2026-03-26 10:22:03 +08:00
parent 0f03393010
commit b20e142249
17 changed files with 655 additions and 154 deletions

View File

@@ -212,7 +212,7 @@ func (s *claudeOAuthService) ExchangeCodeForToken(ctx context.Context, code, cod
SetContext(ctx).
SetHeader("Accept", "application/json, text/plain, */*").
SetHeader("Content-Type", "application/json").
SetHeader("User-Agent", "axios/1.8.4").
SetHeader("User-Agent", "axios/1.13.6").
SetBody(reqBody).
SetSuccessResult(&tokenResp).
Post(s.tokenURL)
@@ -250,7 +250,7 @@ func (s *claudeOAuthService) RefreshToken(ctx context.Context, refreshToken, pro
SetContext(ctx).
SetHeader("Accept", "application/json, text/plain, */*").
SetHeader("Content-Type", "application/json").
SetHeader("User-Agent", "axios/1.8.4").
SetHeader("User-Agent", "axios/1.13.6").
SetBody(reqBody).
SetSuccessResult(&tokenResp).
Post(s.tokenURL)

View File

@@ -1,6 +1,8 @@
package repository
import (
"compress/flate"
"compress/gzip"
"errors"
"fmt"
"io"
@@ -13,6 +15,8 @@ import (
"sync/atomic"
"time"
"github.com/andybalholm/brotli"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/proxyurl"
"github.com/Wei-Shaw/sub2api/internal/pkg/proxyutil"
@@ -143,6 +147,9 @@ func (s *httpUpstreamService) Do(req *http.Request, proxyURL string, accountID i
return nil, err
}
// 如果上游返回了压缩内容,解压后再交给业务层
decompressResponseBody(resp)
// 包装响应体,在关闭时自动减少计数并更新时间戳
// 这确保了流式响应(如 SSE在完全读取前不会被淘汰
resp.Body = wrapTrackedBody(resp.Body, func() {
@@ -218,6 +225,9 @@ func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, acco
slog.Debug("tls_fingerprint_request_success", "account_id", accountID, "status", resp.StatusCode)
// 如果上游返回了压缩内容,解压后再交给业务层
decompressResponseBody(resp)
// 包装响应体,在关闭时自动减少计数并更新时间戳
resp.Body = wrapTrackedBody(resp.Body, func() {
atomic.AddInt64(&entry.inFlight, -1)
@@ -884,3 +894,56 @@ func wrapTrackedBody(body io.ReadCloser, onClose func()) io.ReadCloser {
}
return &trackedBody{ReadCloser: body, onClose: onClose}
}
// decompressResponseBody 根据 Content-Encoding 解压响应体。
// 当请求显式设置了 accept-encoding 时Go 的 Transport 不会自动解压,需要手动处理。
// 解压成功后会删除 Content-Encoding 和 Content-Length header长度已不准确
func decompressResponseBody(resp *http.Response) {
if resp == nil || resp.Body == nil {
return
}
ce := strings.ToLower(strings.TrimSpace(resp.Header.Get("Content-Encoding")))
if ce == "" {
return
}
var reader io.Reader
switch ce {
case "gzip":
gr, err := gzip.NewReader(resp.Body)
if err != nil {
return // 解压失败,保持原样
}
reader = gr
case "br":
reader = brotli.NewReader(resp.Body)
case "deflate":
reader = flate.NewReader(resp.Body)
default:
return
}
originalBody := resp.Body
resp.Body = &decompressedBody{reader: reader, closer: originalBody}
resp.Header.Del("Content-Encoding")
resp.Header.Del("Content-Length") // 解压后长度不确定
resp.ContentLength = -1
}
// decompressedBody 组合解压 reader 和原始 body 的 close。
type decompressedBody struct {
reader io.Reader
closer io.Closer
}
func (d *decompressedBody) Read(p []byte) (int, error) {
return d.reader.Read(p)
}
func (d *decompressedBody) Close() error {
// 如果 reader 本身也是 Closer如 gzip.Reader先关闭它
if rc, ok := d.reader.(io.Closer); ok {
_ = rc.Close()
}
return d.closer.Close()
}