package pkg import ( "bytes" "encoding/json" "fmt" "io" "mime/multipart" "net/http" "os" "path/filepath" "time" ) // PostMultipart 发送 multipart/form-data 请求 // url: 请求地址 // data: map[string]interface{} 类型的数据,支持以下类型: // - 普通字段:string, int, float64, bool 等 // - 文件字段:*os.File, []byte, 或实现了 io.Reader 接口的类型,需配合文件名使用 // - 文件路径:使用 "file_path" 字段标记,如:map[string]interface{}{"field_name": map[string]interface{}{"file_path": "/path/to/file"}} // - 简化文件:map[string]interface{}{"field_name": map[string]interface{}{"file": fileObj, "filename": "custom.png"}} // // result: 响应 JSON 将解析到此指针对象 func PostMultipart(url string, data map[string]interface{}, result interface{}) error { // 创建缓冲区和 multipart writer body := &bytes.Buffer{} writer := multipart.NewWriter(body) // 遍历所有字段 for fieldName, fieldValue := range data { switch v := fieldValue.(type) { case string: // 普通字符串字段 if err := writer.WriteField(fieldName, v); err != nil { writer.Close() return fmt.Errorf("写入字段 %s 失败: %w", fieldName, err) } case int, int32, int64, float32, float64, bool: // 基本类型转字符串 if err := writer.WriteField(fieldName, fmt.Sprintf("%v", v)); err != nil { writer.Close() return fmt.Errorf("写入字段 %s 失败: %w", fieldName, err) } case *os.File: // *os.File 类型,自动获取文件名 if err := addFilePart(writer, fieldName, v.Name(), v); err != nil { writer.Close() return err } case []byte: // 字节数组,需要提供文件名 return fmt.Errorf("字段 %s 为 []byte 类型,请使用 map[string]interface{} 格式提供文件名: {\"data\": 字节内容, \"filename\": \"文件名\"}", fieldName) case map[string]interface{}: // 复杂文件描述 if err := handleFileMap(writer, fieldName, v); err != nil { writer.Close() return err } default: // 其他类型转为字符串 if err := writer.WriteField(fieldName, fmt.Sprintf("%v", v)); err != nil { writer.Close() return fmt.Errorf("写入字段 %s 失败: %w", fieldName, err) } } } // 关闭 writer 以写入结束边界 if err := writer.Close(); err != nil { return fmt.Errorf("关闭 multipart writer 失败: %w", err) } // 创建请求 req, err := http.NewRequest("POST", url, body) if err != nil { return fmt.Errorf("创建请求失败: %w", err) } // 设置 Content-Type,包含边界分隔符 req.Header.Set("Content-Type", writer.FormDataContentType()) // 发送请求 client := &http.Client{ Timeout: 30 * time.Second, } resp, err := client.Do(req) if err != nil { return fmt.Errorf("请求失败: %w", err) } defer resp.Body.Close() // 读取响应体 respBody, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("读取响应失败: %w", err) } // 检查 HTTP 状态码 if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("HTTP 错误: %d, 响应: %s", resp.StatusCode, string(respBody)) } // 解析 JSON 到 result 指针 if err := json.Unmarshal(respBody, result); err != nil { return fmt.Errorf("JSON 解析失败: %w, 原始响应: %s", err, string(respBody)) } return nil } // addFilePart 添加文件部分 func addFilePart(writer *multipart.Writer, fieldName, filename string, reader io.Reader) error { part, err := writer.CreateFormFile(fieldName, filename) if err != nil { return fmt.Errorf("创建文件字段 %s 失败: %w", fieldName, err) } if _, err := io.Copy(part, reader); err != nil { return fmt.Errorf("复制文件 %s 内容失败: %w", fieldName, err) } return nil } // handleFileMap 处理文件映射 func handleFileMap(writer *multipart.Writer, fieldName string, fileMap map[string]interface{}) error { // 支持两种格式: // 1. {"file_path": "/path/to/file"} // 2. {"file": io.Reader, "filename": "custom_name.ext"} // 格式1: 文件路径 if filePath, ok := fileMap["file_path"].(string); ok { file, err := os.Open(filePath) if err != nil { return fmt.Errorf("打开文件 %s 失败: %w", filePath, err) } defer file.Close() return addFilePart(writer, fieldName, filepath.Base(filePath), file) } // 格式2: 直接提供文件和文件名 if fileReader, ok := fileMap["file"]; ok { // 获取文件名 filename := "file" if fn, ok := fileMap["filename"].(string); ok && fn != "" { filename = fn } var reader io.Reader switch r := fileReader.(type) { case io.Reader: reader = r case []byte: reader = bytes.NewReader(r) case *os.File: reader = r if filename == "file" { filename = filepath.Base(r.Name()) } default: return fmt.Errorf("文件字段 %s 不支持的类型: %T", fieldName, fileReader) } return addFilePart(writer, fieldName, filename, reader) } return fmt.Errorf("文件字段 %s 格式错误,需要 file_path 或 file+filename", fieldName) }