6595e94677
经 deepseek/GLM/Claude 三轮对抗性评审 + 交叉核验 + 终审(见 last_report.md、cross_review_assessment.md),逐条回读源码核实并修复。 go build / vet / test -race ./... 全绿。 安全(P0): - JWT alg confusion:WithValidMethods 固定 HMAC + keyfunc 方法断言 - JWT 空密钥 / 不支持算法 fail-closed(ErrEmptySecret / ErrUnsupportedAlgorithm) - 上传大小实测封顶(enforceUploadSize),不信任客户端 file.Size - HTTP headers/cookies map 竞态(写锁+快照)+ SSRF 防护(opt-in dialer.Control) 并发/资源(主线A 闭合): - 包级可变全局一律 atomic.Pointer:DefaultRedis / DefaultStorage / DefaultManager / Validator / DefaultCache 等,消除裸指针数据竞争 - trace.Close 去 sync.Once(防 exporter 泄漏);app OnReady 失败走 Shutdown - ws.Hub.Stop stopOnce 防 double-close;cron 接入 App 生命周期(WithCron) - ratelimit failClosed atomic.Bool;Recover Written() 守卫 - cron checkAndRun 锁内收集锁外 spawn(wg.Add 在锁内防 Stop 竞态) 终审剩余项收口(H-A~M-H + L 系列): - 副本连接池 MaxOpenConns/2 截断修复(replicaMaxOpenConns) - JWT 黑名单 1s 超时 + 显式错误;ratelimit GetRedis 取一次复用 - GetPage 深分页上限;HashFile 流式;ReadFile 去 TOCTOU - config Clone() 深拷贝;cache 锁操作返 ErrRedisNotReady - compress 解压残留清理;正则预编译;EqualsIgnoreCase→EqualFold - 删 redisLimiters 死代码;CLI 输入校验 + 回滚 文档: - README/GUIDE/CHANGELOG 对齐当前 API(含破坏性变更迁移说明) - docs/README 索引修正;config 注释修正(RS256 已不支持) 破坏性变更详见 CHANGELOG.md [Unreleased]。项目处于研发初期、无下游用户。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
537 lines
16 KiB
Go
537 lines
16 KiB
Go
package utils
|
||
|
||
import (
|
||
"bytes"
|
||
"crypto/tls"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"mime/multipart"
|
||
"net"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"syscall"
|
||
"time"
|
||
)
|
||
|
||
// HTTPClient HTTP 客户端封装
|
||
type HTTPClient struct {
|
||
// mu 保护 client/transport/cfg/timeout/skipTLS 的并发读写。SetSkipTLS/SetTimeout
|
||
// 在写锁下重建 client/transport;do/DoWithResponse/Close 在读锁下快照后无锁调用
|
||
// http.Client.Do(Do 自身并发安全),不持有锁发请求以避免序列化。
|
||
mu sync.RWMutex
|
||
client *http.Client
|
||
transport *http.Transport
|
||
cfg HTTPClientConfig // 保留配置以便 SetSkipTLS 重建 transport
|
||
timeout time.Duration
|
||
headers map[string]string
|
||
cookies map[string]string
|
||
skipTLS bool
|
||
// maxRespBodySize 响应体读取上限(字节),0 表示用默认 32MB,-1 表示不限。
|
||
// 防止恶意/异常服务端返回超大响应打爆内存(C5/N5)。
|
||
maxRespBodySize int64
|
||
}
|
||
|
||
// UploadFile 上传文件信息
|
||
type UploadFile struct {
|
||
FieldName string // 表单字段名
|
||
FilePath string // 文件路径
|
||
}
|
||
|
||
// HTTPClientConfig HTTP 客户端配置
|
||
type HTTPClientConfig struct {
|
||
Timeout time.Duration // 请求超时时间
|
||
MaxIdleConns int // 最大空闲连接数
|
||
IdleConnTimeout time.Duration // 空闲连接超时时间
|
||
MaxConnsPerHost int // 每个主机最大连接数
|
||
MaxIdleConnsPerHost int // 每个主机最大空闲连接数
|
||
SkipTLSVerify bool // 是否跳过 TLS 验证(默认 false 校验 TLS;自签证书场景需显式设 true)
|
||
// MaxResponseBodySize 响应体读取上限(字节)。0 = 默认 32MB,-1 = 不限制。
|
||
// 防止异常服务端返回超大响应打爆内存(C5/N5)。
|
||
MaxResponseBodySize int64
|
||
// BlockPrivateNetworks 启用 SSRF 防护(P0,默认 false 保持兼容)。启用后,连接建立时
|
||
// 校验解析出的目标 IP,拒绝回环/私有(RFC1918+ULA)/链路本地/元数据(169.254.169.254)/
|
||
// 未指定/多播等内网地址。校验在 DialContext.Control 中进行,对重定向的每一跳同样生效。
|
||
// 当 URL 可能来自用户输入(webhook、头像抓取等)时应开启。
|
||
BlockPrivateNetworks bool
|
||
}
|
||
|
||
// ErrSSRFBlocked 目标 IP 落在被拦截网段(SSRF 防护,P0)。
|
||
var ErrSSRFBlocked = errors.New("ssrf guard: 目标 IP 属被拦截网段(回环/私有/链路本地/元数据)")
|
||
|
||
// isBlockedIP 判断 IP 是否应被 SSRF 防护拦截。
|
||
// 覆盖:回环、私有(RFC1918 + ULA fc00::/7)、链路本地(含 169.254.169.254 云元数据 / fe80::)、
|
||
// 未指定(0.0.0.0/::)、多播。
|
||
func isBlockedIP(ip net.IP) bool {
|
||
if ip == nil {
|
||
return true
|
||
}
|
||
return ip.IsLoopback() || ip.IsPrivate() ||
|
||
ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
|
||
ip.IsUnspecified() || ip.IsMulticast() || ip.IsInterfaceLocalMulticast()
|
||
}
|
||
|
||
// ssrfControl 是 net.Dialer.Control 回调:在 DNS 解析后、真正 dial 前校验目标 IP,
|
||
// 命中内网段即拒绝。放在 Control 层可覆盖 DNS 重绑定与重定向的每一跳(P0)。
|
||
func ssrfControl(network, address string, _ syscall.RawConn) error {
|
||
host, _, err := net.SplitHostPort(address)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
ip := net.ParseIP(host)
|
||
if ip == nil {
|
||
return fmt.Errorf("%w: 无法解析地址 %q", ErrSSRFBlocked, address)
|
||
}
|
||
if isBlockedIP(ip) {
|
||
return fmt.Errorf("%w: %s", ErrSSRFBlocked, ip)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// DefaultHTTPClientConfig 默认配置
|
||
var DefaultHTTPClientConfig = HTTPClientConfig{
|
||
Timeout: 30 * time.Second,
|
||
MaxIdleConns: 100,
|
||
IdleConnTimeout: 90 * time.Second,
|
||
MaxConnsPerHost: 10,
|
||
MaxIdleConnsPerHost: 10,
|
||
SkipTLSVerify: false, // H2 修复:默认校验 TLS,防 MITM;自签证书需显式 SetSkipTLS(true)
|
||
MaxResponseBodySize: 32 * 1024 * 1024,
|
||
}
|
||
|
||
// NewHTTPClient 创建 HTTP 客户端
|
||
func NewHTTPClient() *HTTPClient {
|
||
cfg := DefaultHTTPClientConfig
|
||
return NewHTTPClientWithConfig(cfg)
|
||
}
|
||
|
||
// NewSSRFSafeHTTPClient 创建启用 SSRF 防护的 HTTP 客户端(P0):拒绝连接内网目标 IP。
|
||
// 适用于目标 URL 可能来自用户输入的场景(webhook 回调、远程图片抓取等)。
|
||
func NewSSRFSafeHTTPClient() *HTTPClient {
|
||
cfg := DefaultHTTPClientConfig
|
||
cfg.BlockPrivateNetworks = true
|
||
return NewHTTPClientWithConfig(cfg)
|
||
}
|
||
|
||
// NewHTTPClientWithConfig 使用自定义配置创建 HTTP 客户端
|
||
func NewHTTPClientWithConfig(cfg HTTPClientConfig) *HTTPClient {
|
||
transport, client := buildHTTPClientPair(cfg)
|
||
return &HTTPClient{
|
||
client: client,
|
||
transport: transport,
|
||
cfg: cfg,
|
||
timeout: cfg.Timeout,
|
||
headers: make(map[string]string),
|
||
cookies: make(map[string]string),
|
||
skipTLS: cfg.SkipTLSVerify,
|
||
maxRespBodySize: cfg.MaxResponseBodySize,
|
||
}
|
||
}
|
||
|
||
// buildHTTPClientPair 按 cfg 构造 (transport, client) 对。SetSkipTLS 重建时复用。
|
||
func buildHTTPClientPair(cfg HTTPClientConfig) (*http.Transport, *http.Client) {
|
||
// Transport 在初始化时创建,连接池可复用
|
||
transport := &http.Transport{
|
||
// #nosec G402 -- InsecureSkipVerify 仅在调用方显式设 cfg.SkipTLSVerify=true 时启用,
|
||
// 默认 false 校验 TLS(H2 修复)。自签证书场景需 opt-in。
|
||
TLSClientConfig: &tls.Config{
|
||
InsecureSkipVerify: cfg.SkipTLSVerify,
|
||
},
|
||
MaxIdleConns: cfg.MaxIdleConns,
|
||
IdleConnTimeout: cfg.IdleConnTimeout,
|
||
MaxConnsPerHost: cfg.MaxConnsPerHost,
|
||
MaxIdleConnsPerHost: cfg.MaxIdleConnsPerHost,
|
||
DisableCompression: false,
|
||
}
|
||
// SSRF 防护(P0):启用后为拨号器装 Control 回调,连接建立时拦截内网目标 IP。
|
||
if cfg.BlockPrivateNetworks {
|
||
dialer := &net.Dialer{
|
||
Timeout: 30 * time.Second,
|
||
KeepAlive: 30 * time.Second,
|
||
Control: ssrfControl,
|
||
}
|
||
transport.DialContext = dialer.DialContext
|
||
}
|
||
client := &http.Client{
|
||
Transport: transport,
|
||
Timeout: cfg.Timeout,
|
||
}
|
||
return transport, client
|
||
}
|
||
|
||
// currentClient 在读锁下快照当前 *http.Client(H-12:与 SetSkipTLS/SetTimeout 重建无竞态)。
|
||
func (c *HTTPClient) currentClient() *http.Client {
|
||
c.mu.RLock()
|
||
defer c.mu.RUnlock()
|
||
return c.client
|
||
}
|
||
|
||
// currentTransport 在读锁下快照当前 *http.Transport。
|
||
func (c *HTTPClient) currentTransport() *http.Transport {
|
||
c.mu.RLock()
|
||
defer c.mu.RUnlock()
|
||
return c.transport
|
||
}
|
||
|
||
// SetTimeout 设置超时时间。
|
||
// H-12 修复:在写锁下重建 *http.Client(复用 transport 保留连接池),避免与并发 Do 对
|
||
// client.Timeout 字段的数据竞争。
|
||
func (c *HTTPClient) SetTimeout(timeout time.Duration) *HTTPClient {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
c.timeout = timeout
|
||
c.cfg.Timeout = timeout
|
||
_, client := buildHTTPClientPair(c.cfg)
|
||
// 复用旧 transport 保留连接池:新 client 用旧 transport + 新 timeout。
|
||
client.Transport = c.transport
|
||
c.client = client
|
||
return c
|
||
}
|
||
|
||
// SetHeader 设置请求头(P0:写锁保护,与并发 do/DoWithResponse 读 map 无竞态)。
|
||
func (c *HTTPClient) SetHeader(key, value string) *HTTPClient {
|
||
c.mu.Lock()
|
||
c.headers[key] = value
|
||
c.mu.Unlock()
|
||
return c
|
||
}
|
||
|
||
// SetHeaders 批量设置请求头(P0:写锁保护)。
|
||
func (c *HTTPClient) SetHeaders(headers map[string]string) *HTTPClient {
|
||
c.mu.Lock()
|
||
for k, v := range headers {
|
||
c.headers[k] = v
|
||
}
|
||
c.mu.Unlock()
|
||
return c
|
||
}
|
||
|
||
// SetCookie 设置 Cookie(P0:写锁保护)。
|
||
func (c *HTTPClient) SetCookie(key, value string) *HTTPClient {
|
||
c.mu.Lock()
|
||
c.cookies[key] = value
|
||
c.mu.Unlock()
|
||
return c
|
||
}
|
||
|
||
// snapshotHeadersCookies 在读锁下拷贝 headers/cookies,供 do/DoWithResponse 在锁外应用到请求,
|
||
// 消除与 SetHeader/SetHeaders/SetCookie 的 map 读写竞态(P0)。
|
||
func (c *HTTPClient) snapshotHeadersCookies() (headers, cookies map[string]string) {
|
||
c.mu.RLock()
|
||
defer c.mu.RUnlock()
|
||
headers = make(map[string]string, len(c.headers))
|
||
for k, v := range c.headers {
|
||
headers[k] = v
|
||
}
|
||
cookies = make(map[string]string, len(c.cookies))
|
||
for k, v := range c.cookies {
|
||
cookies[k] = v
|
||
}
|
||
return headers, cookies
|
||
}
|
||
|
||
// SetSkipTLS 设置是否跳过 TLS 验证。
|
||
// 跳过 TLS 校验会暴露于 MITM 攻击,仅在受控环境(如自签证书的内网服务)且明确风险时启用,
|
||
// 生产环境应保持 false。
|
||
//
|
||
// H-12 修复:原实现直接覆盖 c.transport.TLSClientConfig 指针,与并发 Do 读取该字段
|
||
// 存在数据竞争(-race 必采),且注释自承"需重建 Transport"却未重建。改为在写锁下用
|
||
// 新配置重建 transport+client 并原子替换,旧 transport 释放空闲连接(保留旧 TLS 配置的
|
||
// idle 连接不再被复用)。支持运行期并发调用与并发请求无竞态。
|
||
func (c *HTTPClient) SetSkipTLS(skip bool) *HTTPClient {
|
||
c.mu.Lock()
|
||
c.skipTLS = skip
|
||
c.cfg.SkipTLSVerify = skip
|
||
transport, client := buildHTTPClientPair(c.cfg)
|
||
oldTransport := c.transport
|
||
c.transport = transport
|
||
c.client = client
|
||
c.mu.Unlock()
|
||
// 锁外释放旧 transport 的空闲连接(CloseIdleConnections 仅关 idle 连接,不影响在途请求)。
|
||
if oldTransport != nil {
|
||
oldTransport.CloseIdleConnections()
|
||
}
|
||
return c
|
||
}
|
||
|
||
// SetBlockPrivateNetworks 运行期开关 SSRF 防护(P0)。开启后连接内网 IP(回环/私有/链路本地/
|
||
// 元数据等)会被拒绝并返回 ErrSSRFBlocked。写锁下重建 transport+client 并原子替换,
|
||
// 与并发请求无竞态;旧 transport 的空闲连接在锁外释放。
|
||
func (c *HTTPClient) SetBlockPrivateNetworks(block bool) *HTTPClient {
|
||
c.mu.Lock()
|
||
c.cfg.BlockPrivateNetworks = block
|
||
transport, client := buildHTTPClientPair(c.cfg)
|
||
oldTransport := c.transport
|
||
c.transport = transport
|
||
c.client = client
|
||
c.mu.Unlock()
|
||
if oldTransport != nil {
|
||
oldTransport.CloseIdleConnections()
|
||
}
|
||
return c
|
||
}
|
||
|
||
// Get 发送 GET 请求
|
||
func (c *HTTPClient) Get(urlStr string, params map[string]string) ([]byte, error) {
|
||
if len(params) > 0 {
|
||
u, err := url.Parse(urlStr)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
q := u.Query()
|
||
for k, v := range params {
|
||
q.Set(k, v)
|
||
}
|
||
u.RawQuery = q.Encode()
|
||
urlStr = u.String()
|
||
}
|
||
|
||
req, err := http.NewRequest("GET", urlStr, nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return c.do(req)
|
||
}
|
||
|
||
// Post 发送 POST 请求(form 表单格式)
|
||
func (c *HTTPClient) Post(urlStr string, params map[string]string) ([]byte, error) {
|
||
data := url.Values{}
|
||
for k, v := range params {
|
||
data.Set(k, v)
|
||
}
|
||
|
||
req, err := http.NewRequest("POST", urlStr, strings.NewReader(data.Encode()))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
return c.do(req)
|
||
}
|
||
|
||
// PostJSON 发送 POST 请求(JSON 格式)
|
||
func (c *HTTPClient) PostJSON(urlStr string, data any) ([]byte, error) {
|
||
jsonData, err := json.Marshal(data)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
req, err := http.NewRequest("POST", urlStr, bytes.NewReader(jsonData))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
return c.do(req)
|
||
}
|
||
|
||
// Put 发送 PUT 请求(JSON 格式)
|
||
func (c *HTTPClient) Put(urlStr string, data any) ([]byte, error) {
|
||
jsonData, err := json.Marshal(data)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
req, err := http.NewRequest("PUT", urlStr, bytes.NewReader(jsonData))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
return c.do(req)
|
||
}
|
||
|
||
// Delete 发送 DELETE 请求
|
||
func (c *HTTPClient) Delete(urlStr string) ([]byte, error) {
|
||
req, err := http.NewRequest("DELETE", urlStr, nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return c.do(req)
|
||
}
|
||
|
||
// Upload 上传文件
|
||
func (c *HTTPClient) Upload(urlStr string, files []UploadFile, params map[string]string) ([]byte, error) {
|
||
var buf bytes.Buffer
|
||
writer := multipart.NewWriter(&buf)
|
||
|
||
for _, f := range files {
|
||
file, err := os.Open(f.FilePath)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
part, err := writer.CreateFormFile(f.FieldName, filepath.Base(f.FilePath))
|
||
if err != nil {
|
||
file.Close()
|
||
return nil, err
|
||
}
|
||
if _, err = io.Copy(part, file); err != nil {
|
||
file.Close()
|
||
return nil, err
|
||
}
|
||
// 显式关闭,避免循环内 defer 累积 FD(N5/C5)。
|
||
file.Close()
|
||
}
|
||
|
||
for k, v := range params {
|
||
if err := writer.WriteField(k, v); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
|
||
if err := writer.Close(); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
req, err := http.NewRequest("POST", urlStr, &buf)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||
return c.do(req)
|
||
}
|
||
|
||
// UploadFromBytes 从字节数据上传文件
|
||
func (c *HTTPClient) UploadFromBytes(urlStr string, fieldName string, filename string, data []byte, params map[string]string) ([]byte, error) {
|
||
var buf bytes.Buffer
|
||
writer := multipart.NewWriter(&buf)
|
||
|
||
part, err := writer.CreateFormFile(fieldName, filename)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if _, err = io.Copy(part, bytes.NewReader(data)); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
for k, v := range params {
|
||
if err := writer.WriteField(k, v); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
|
||
if err := writer.Close(); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
req, err := http.NewRequest("POST", urlStr, &buf)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||
return c.do(req)
|
||
}
|
||
|
||
// Request 发送自定义请求
|
||
func (c *HTTPClient) Request(method, urlStr string, body []byte) ([]byte, error) {
|
||
req, err := http.NewRequest(method, urlStr, bytes.NewReader(body))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if body != nil {
|
||
req.Header.Set("Content-Type", "application/json")
|
||
}
|
||
return c.do(req)
|
||
}
|
||
|
||
// do 执行请求(使用共享的 client 和 transport)
|
||
func (c *HTTPClient) do(req *http.Request) ([]byte, error) {
|
||
// P0:读锁快照 headers/cookies 后在锁外应用,与并发 Set* 无竞态。
|
||
headers, cookies := c.snapshotHeadersCookies()
|
||
for k, v := range headers {
|
||
req.Header.Set(k, v)
|
||
}
|
||
for k, v := range cookies {
|
||
req.AddCookie(&http.Cookie{Name: k, Value: v})
|
||
}
|
||
|
||
// 发送请求(H-12:快照 client,与 SetSkipTLS/SetTimeout 重建无竞态)
|
||
resp, err := c.currentClient().Do(req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
// 检查状态码
|
||
if resp.StatusCode >= 400 {
|
||
return nil, fmt.Errorf("http error: %d %s", resp.StatusCode, resp.Status)
|
||
}
|
||
|
||
// 响应体读取封顶,防异常服务端返回超大响应打爆内存(C5/N5)。
|
||
// maxRespBodySize: 0=默认 32MB,-1=不限。
|
||
limit := c.maxRespBodySize
|
||
if limit == 0 {
|
||
limit = 32 * 1024 * 1024
|
||
}
|
||
var reader io.Reader = resp.Body
|
||
if limit > 0 {
|
||
// 多读 1 字节用于判断是否超限。
|
||
reader = io.LimitReader(resp.Body, limit+1)
|
||
}
|
||
data, err := io.ReadAll(reader)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if limit > 0 && int64(len(data)) > limit {
|
||
return nil, fmt.Errorf("response body exceeds limit %d bytes", limit)
|
||
}
|
||
return data, nil
|
||
}
|
||
|
||
// DoWithResponse 执行请求并返回完整响应。
|
||
//
|
||
// 注意:调用方负责关闭返回的 resp.Body;且此方法不套用 maxRespBodySize 读封顶(由调用方掌控)。
|
||
func (c *HTTPClient) DoWithResponse(req *http.Request) (*http.Response, error) {
|
||
// P0:读锁快照 headers/cookies 后在锁外应用,与并发 Set* 无竞态。
|
||
headers, cookies := c.snapshotHeadersCookies()
|
||
for k, v := range headers {
|
||
req.Header.Set(k, v)
|
||
}
|
||
for k, v := range cookies {
|
||
req.AddCookie(&http.Cookie{Name: k, Value: v})
|
||
}
|
||
|
||
return c.currentClient().Do(req)
|
||
}
|
||
|
||
// Close 关闭客户端(释放连接池资源)
|
||
func (c *HTTPClient) Close() {
|
||
if t := c.currentTransport(); t != nil {
|
||
t.CloseIdleConnections()
|
||
}
|
||
}
|
||
|
||
// JSONMarshal 内部 JSON 序列化函数
|
||
func JSONMarshal(v any) ([]byte, error) {
|
||
return json.Marshal(v)
|
||
}
|
||
|
||
// 全局默认 HTTP 客户端
|
||
var defaultClient *HTTPClient
|
||
var defaultClientOnce sync.Once
|
||
|
||
// DefaultHTTPClient 获取全局默认 HTTP 客户端
|
||
func DefaultHTTPClient() *HTTPClient {
|
||
defaultClientOnce.Do(func() {
|
||
defaultClient = NewHTTPClient()
|
||
})
|
||
return defaultClient
|
||
}
|
||
|
||
// HTTPGet 使用默认客户端发送 GET 请求
|
||
func HTTPGet(url string, params map[string]string) ([]byte, error) {
|
||
return DefaultHTTPClient().Get(url, params)
|
||
}
|
||
|
||
// HTTPPost 使用默认客户端发送 POST 请求
|
||
func HTTPPost(url string, params map[string]string) ([]byte, error) {
|
||
return DefaultHTTPClient().Post(url, params)
|
||
}
|
||
|
||
// HTTPPostJSON 使用默认客户端发送 JSON POST 请求
|
||
func HTTPPostJSON(url string, data any) ([]byte, error) {
|
||
return DefaultHTTPClient().PostJSON(url, data)
|
||
} |