388 lines
9.2 KiB
Go
388 lines
9.2 KiB
Go
package client
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"mime"
|
||
"mime/multipart"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
type HTTPClient struct {
|
||
Client *http.Client
|
||
BaseURL string
|
||
Headers map[string]string
|
||
mu sync.RWMutex // 保护 Headers 的并发访问
|
||
}
|
||
|
||
// NewHTTPClient 创建新的HTTP客户端实例
|
||
func NewHTTPClient(baseURL string, timeout time.Duration) *HTTPClient {
|
||
return &HTTPClient{
|
||
Client: &http.Client{
|
||
Timeout: timeout,
|
||
},
|
||
BaseURL: baseURL,
|
||
Headers: make(map[string]string),
|
||
}
|
||
}
|
||
|
||
// SetHeader 设置请求头(线程安全)
|
||
func (c *HTTPClient) SetHeader(key, value string) {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
c.Headers[key] = value
|
||
}
|
||
|
||
// GetHeader 获取请求头(线程安全)
|
||
func (c *HTTPClient) GetHeader(key string) string {
|
||
c.mu.RLock()
|
||
defer c.mu.RUnlock()
|
||
return c.Headers[key]
|
||
}
|
||
|
||
// GetHeaders 获取所有请求头的副本(线程安全)
|
||
func (c *HTTPClient) GetHeaders() 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
|
||
}
|
||
return headers
|
||
}
|
||
|
||
// Get 发送GET请求,支持查询参数
|
||
func (c *HTTPClient) Get(path string, queryParams ...map[string]string) ([]byte, error) {
|
||
// 构建完整URL
|
||
fullURL := c.BaseURL + path
|
||
|
||
// 处理查询参数
|
||
if len(queryParams) > 0 {
|
||
// 创建URL对象
|
||
u, err := url.Parse(fullURL)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("解析URL失败: %w", err)
|
||
}
|
||
|
||
// 添加查询参数
|
||
q := u.Query()
|
||
for key, value := range queryParams[0] {
|
||
q.Add(key, value)
|
||
}
|
||
u.RawQuery = q.Encode()
|
||
|
||
fullURL = u.String()
|
||
}
|
||
|
||
fmt.Println("request url:", fullURL)
|
||
// 创建请求
|
||
req, err := http.NewRequest("GET", fullURL, nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 设置请求头(使用线程安全的方法)
|
||
headers := c.GetHeaders()
|
||
for k, v := range headers {
|
||
req.Header.Set(k, v)
|
||
}
|
||
|
||
// 发送请求
|
||
resp, err := c.Client.Do(req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
// 检查状态码
|
||
if resp.StatusCode != http.StatusOK {
|
||
// 尝试读取响应体以获取更多错误信息
|
||
return nil, &HTTPError{
|
||
StatusCode: resp.StatusCode,
|
||
Message: resp.Status,
|
||
}
|
||
}
|
||
|
||
return io.ReadAll(resp.Body)
|
||
}
|
||
|
||
// PostJSON 发送JSON格式POST请求
|
||
func (c *HTTPClient) PostJSON(path string, data interface{}) ([]byte, error) {
|
||
// 拼接URL
|
||
fullURL := c.BaseURL + path
|
||
jsonData, err := json.Marshal(data)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
fmt.Println("request url, body is :", fullURL, string(jsonData))
|
||
req, err := http.NewRequest("POST", fullURL, bytes.NewBuffer(jsonData))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 设置请求头
|
||
req.Header.Set("Content-Type", "application/json")
|
||
headers := c.GetHeaders()
|
||
for k, v := range headers {
|
||
req.Header.Set(k, v)
|
||
}
|
||
|
||
resp, err := c.Client.Do(req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
return nil, &HTTPError{
|
||
StatusCode: resp.StatusCode,
|
||
Message: resp.Status,
|
||
}
|
||
}
|
||
|
||
return io.ReadAll(resp.Body)
|
||
}
|
||
|
||
// UploadFiles 上传多个文件并支持额外表单字段
|
||
// files 参数现在支持每个字段名对应多个文件路径
|
||
func (c *HTTPClient) UploadFiles(
|
||
path string,
|
||
filesPrefix string,
|
||
files map[string][]string, // 字段名 -> 多个文件路径
|
||
formFields map[string]string, // 额外表单字段
|
||
) ([]byte, error) {
|
||
fullURL := path
|
||
|
||
// 创建multipart writer
|
||
body := &bytes.Buffer{}
|
||
writer := multipart.NewWriter(body)
|
||
|
||
// 添加文件部分 - 支持每个字段名多个文件
|
||
for fieldName, filePaths := range files {
|
||
for _, filePath := range filePaths {
|
||
file, err := os.Open(filePath)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("打开文件 %s 失败: %w", filePath, err)
|
||
}
|
||
defer file.Close()
|
||
|
||
relativePath, err := removePathPrefix(filePath, filesPrefix)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
part, err := writer.CreateFormFile(fieldName, url.PathEscape(relativePath))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("为文件 %s 创建表单字段失败: %w", filePath, err)
|
||
}
|
||
|
||
_, err = io.Copy(part, file)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("复制文件 %s 内容失败: %w", filePath, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
//// 添加额外表单字段
|
||
//for key, value := range formFields {
|
||
// err := writer.WriteField(key, value)
|
||
// if err != nil {
|
||
// return nil, fmt.Errorf("添加表单字段 %s 失败: %w", key, err)
|
||
// }
|
||
//}
|
||
|
||
// 关闭writer以完成multipart消息
|
||
err := writer.Close()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("关闭multipart writer失败: %w", err)
|
||
}
|
||
|
||
// 创建请求
|
||
req, err := http.NewRequest("POST", fullURL, body)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 设置内容类型(必须包含boundary)
|
||
contentType := writer.FormDataContentType()
|
||
req.Header.Set("Content-Type", contentType)
|
||
|
||
// 添加自定义头(使用线程安全的方法)
|
||
headers := c.GetHeaders()
|
||
for k, v := range headers {
|
||
req.Header.Set(k, v)
|
||
}
|
||
|
||
// 发送请求
|
||
resp, err := c.Client.Do(req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
// 检查状态码
|
||
if resp.StatusCode != http.StatusOK {
|
||
// 尝试读取错误响应体
|
||
errorBody, _ := io.ReadAll(resp.Body)
|
||
errorMsg := string(errorBody)
|
||
if errorMsg == "" {
|
||
errorMsg = resp.Status
|
||
}
|
||
|
||
return nil, &HTTPError{
|
||
StatusCode: resp.StatusCode,
|
||
Message: errorMsg,
|
||
}
|
||
}
|
||
|
||
return io.ReadAll(resp.Body)
|
||
}
|
||
|
||
func removePathPrefix(fullPath, prefix string) (string, error) {
|
||
// 确保两个路径都是绝对路径并标准化
|
||
absFullPath, err := filepath.Abs(fullPath)
|
||
if err != nil {
|
||
return "", fmt.Errorf("获取绝对路径失败 [%s]: %w", fullPath, err)
|
||
}
|
||
absPrefix, err := filepath.Abs(prefix)
|
||
if err != nil {
|
||
return "", fmt.Errorf("获取绝对路径失败 [%s]: %w", prefix, err)
|
||
}
|
||
|
||
// 标准化路径(处理 .. 和 . 等)
|
||
absFullPath = filepath.Clean(absFullPath)
|
||
absPrefix = filepath.Clean(absPrefix)
|
||
|
||
// 转换为 Linux 风格的路径分隔符(统一使用 /)
|
||
unixFullPath := filepath.ToSlash(absFullPath)
|
||
unixPrefix := filepath.ToSlash(absPrefix)
|
||
|
||
// 确保前缀以 / 结尾
|
||
if !strings.HasSuffix(unixPrefix, "/") {
|
||
unixPrefix += "/"
|
||
}
|
||
|
||
// 检查路径是否有指定的前缀
|
||
if strings.HasPrefix(unixFullPath, unixPrefix) {
|
||
// 返回去除前缀的路径(已经是 Linux 风格)
|
||
return unixFullPath[len(unixPrefix):], nil
|
||
}
|
||
|
||
// 如果不匹配,返回详细错误信息
|
||
return "", fmt.Errorf("路径前缀不匹配 [文件路径: %s, 前缀: %s]", unixFullPath, unixPrefix)
|
||
}
|
||
|
||
func (c *HTTPClient) encodeFilepath(paths []string) []string {
|
||
var result []string
|
||
for _, path := range paths {
|
||
// 编码文件路径,使用urlencoded编码
|
||
encodedPath := url.PathEscape(path)
|
||
result = append(result, encodedPath)
|
||
}
|
||
fmt.Println("encodeFilepath result: ", result)
|
||
return result
|
||
}
|
||
|
||
// DownloadFile 下载文件到本地路径(支持大文件流式处理)
|
||
// localPath:保存文件的本地路径(包含文件名)
|
||
// queryParams:可选查询参数
|
||
func (c *HTTPClient) DownloadFile(path, localPath string, queryParams ...map[string]string) (string, error) {
|
||
// 构建完整URL
|
||
fullURL := path
|
||
|
||
//// 处理查询参数
|
||
//if queryParams != nil {
|
||
// u, err := url.Parse(fullURL)
|
||
// if err != nil {
|
||
// return "", fmt.Errorf("解析URL失败: %w", err)
|
||
// }
|
||
//
|
||
// q := u.Query()
|
||
// for key, value := range queryParams[0] {
|
||
// q.Add(key, value)
|
||
// }
|
||
// u.RawQuery = q.Encode()
|
||
// fullURL = u.String()
|
||
//}
|
||
|
||
// 创建请求
|
||
req, err := http.NewRequest("GET", fullURL, nil)
|
||
if err != nil {
|
||
return "", fmt.Errorf("创建请求失败: %w", err)
|
||
}
|
||
|
||
// 设置请求头(使用线程安全的方法)
|
||
headers := c.GetHeaders()
|
||
for k, v := range headers {
|
||
req.Header.Set(k, v)
|
||
}
|
||
|
||
// 发送请求
|
||
resp, err := c.Client.Do(req)
|
||
if err != nil {
|
||
return "", fmt.Errorf("请求失败: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
// 检查状态码 (接受2xx状态码)
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
// 尝试读取部分错误信息(限制长度防止内存溢出)
|
||
return "", &HTTPError{
|
||
StatusCode: resp.StatusCode,
|
||
Message: resp.Status,
|
||
}
|
||
}
|
||
|
||
os.MkdirAll(localPath, 0755)
|
||
filename := extractFilenameFromDisposition(resp.Header.Get("Content-Disposition"))
|
||
if filename == "" {
|
||
filename = filepath.Join(localPath, "file.zip")
|
||
} else {
|
||
filename = filepath.Join(localPath, filename)
|
||
}
|
||
// 创建本地文件
|
||
outFile, err := os.Create(filename)
|
||
if err != nil {
|
||
return "", fmt.Errorf("创建本地文件失败: %w", err)
|
||
}
|
||
defer outFile.Close()
|
||
|
||
// 流式拷贝(避免大文件内存溢出)
|
||
_, err = io.Copy(outFile, resp.Body)
|
||
if err != nil {
|
||
// 删除可能不完整的文件
|
||
_ = os.Remove(localPath)
|
||
return "", fmt.Errorf("下载文件写入失败: %w", err)
|
||
}
|
||
|
||
return filename, nil
|
||
}
|
||
|
||
func extractFilenameFromDisposition(contentDisposition string) string {
|
||
// 尝试使用标准库的 mime.ParseMediaType
|
||
if _, params, err := mime.ParseMediaType(contentDisposition); err == nil {
|
||
if filename, exists := params["filename"]; exists && filename != "" {
|
||
return filename
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// HTTPError 自定义HTTP错误
|
||
type HTTPError struct {
|
||
StatusCode int
|
||
Message string
|
||
}
|
||
|
||
func (e *HTTPError) Error() string {
|
||
return e.Message
|
||
}
|