package minio import ( "bytes" "context" "encoding/base64" "fmt" "net/http" "path/filepath" "strings" "time" "gitea.com/red-future/common/utils" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/net/ghttp" "github.com/gogf/gf/v2/os/glog" "github.com/google/uuid" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" ) // IoConfig 映射 YAML 中的 minio 配置节点 type IoConfig struct { Endpoint string `yaml:"endpoint"` // MinIO API 地址 AccessKey string `yaml:"accessKey"` // AK SecretKey string `yaml:"secretKey"` // SK Secure bool `yaml:"secure"` // 是否启用 SSL Region string `yaml:"region"` // 区域 } // 全局 MinIO 客户端(初始化一次,避免重复创建) var minioClient *minio.Client var minioCfg IoConfig // initMinIO 初始化 MinIO 客户端。 func init() { ctx := context.Background() if !g.Cfg().MustGet(ctx, "minio").IsEmpty() { // 加载 MinIO 配置(可从配置文件/环境变量读取,这里硬编码示例) minioCfg = IoConfig{ Endpoint: g.Cfg().MustGet(ctx, "minio.endpoint").String(), AccessKey: g.Cfg().MustGet(ctx, "minio.accessKey").String(), SecretKey: g.Cfg().MustGet(ctx, "minio.secretKey").String(), Secure: g.Cfg().MustGet(ctx, "minio.secure").Bool(), Region: g.Cfg().MustGet(ctx, "minio.region").String(), } // 创建 MinIO 客户端 var err error if minioClient, err = minio.New(minioCfg.Endpoint, &minio.Options{ Creds: credentials.NewStaticV4(minioCfg.AccessKey, minioCfg.SecretKey, ""), Secure: minioCfg.Secure, Region: minioCfg.Region, }); err != nil { glog.Errorf(ctx, "初始化 MinIO 客户端失败: %v", err) } } } // ensureBucketAndObjectName 确保桶存在,并生成对象名 // 返回: bucketName, objectName, fileFormat, error func ensureBucketAndObjectName(ctx context.Context, fileName string, fileStoreURL string) (string, string, string, error) { bucketName, err := utils.GetBucketName(ctx) if err != nil { glog.Errorf(ctx, "获取桶名称失败: %v", err) return "", "", "", err } // 检查/创建桶 exists, err := minioClient.BucketExists(ctx, bucketName) if err != nil { glog.Errorf(ctx, "检查桶是否存在失败: %v", err) return "", "", "", err } if !exists { if err = minioClient.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{Region: minioCfg.Region}); err != nil { glog.Errorf(ctx, "创建桶失败: %v", err) return "", "", "", err } glog.Infof(ctx, "成功创建 MinIO 桶: %s", bucketName) } // 生成对象名 fileExt := filepath.Ext(fileName) uniqueID := uuid.New().String()[:32] timestamp := time.Now().Format("2006-01-02") objectName := fmt.Sprintf("/%s/%s%s", timestamp, uniqueID, fileExt) if fileStoreURL != "" { objectName = fmt.Sprintf("/%s/%s/%s%s", timestamp, fileStoreURL, uniqueID, fileExt) } // 设置存储桶公共读权限 policy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":["*"]},"Action":["s3:GetObject"],"Resource":["arn:aws:s3:::` + bucketName + `/*"]}]}` if err = minioClient.SetBucketPolicy(ctx, bucketName, policy); err != nil { glog.Errorf(ctx, "设置存储桶权限失败: %v", err) return "", "", "", err } return bucketName, objectName, strings.TrimPrefix(fileExt, "."), nil } func UploadFile(ctx context.Context, fileHeader *ghttp.UploadFile) (imagesUrl string, fileName string, fileFormat string, err error) { bucketName, objectName, fileFormat, err := ensureBucketAndObjectName(ctx, fileHeader.Filename, "") if err != nil { return } // 打开文件,获取 io.Reader(*os.File 实现了 io.Reader) file, err := fileHeader.Open() if err != nil { glog.Errorf(ctx, "打开文件失败: %v", err) return } defer file.Close() // 必须关闭,避免文件句柄泄露 // 获取文件类型 buffer := make([]byte, 512) _, err = file.Read(buffer) if err != nil { glog.Errorf(ctx, "读取文件头失败: %v", err) return } contentType := http.DetectContentType(buffer) // 重置文件读取位置,否则后续 PutObject 会从第512字节开始上传 if _, err = file.Seek(0, 0); err != nil { glog.Errorf(ctx, "重置文件读取位置失败: %v", err) return } // 执行图片上传 _, err = minioClient.PutObject( ctx, bucketName, objectName, file, fileHeader.Size, minio.PutObjectOptions{ ContentType: contentType, // 关键:指定图片MIME类型,S3会根据此类型处理 }, ) if err != nil { glog.Errorf(ctx, "上传图片失败: %v", err) return } return objectName, fileHeader.Filename, fileFormat, nil } // UploadFileBytes 直接上传字节流到 MinIO func UploadFileBytes(ctx context.Context, fileName string, fileBytes []byte, fileStoreURL string) (imagesUrl string, fileFormat string, err error) { bucketName, objectName, fileFormat, err := ensureBucketAndObjectName(ctx, fileName, fileStoreURL) if err != nil { return } // ============== 1. 强制从文件后缀获取 ContentType(绝对准确) ============== ext := strings.ToLower(filepath.Ext(fileName)) contentType := "application/octet-stream" // 默认二进制流 if t, ok := contentTypeMap[ext]; ok { contentType = t } // ============== 2. 强制指定编码,解决 HTML 乱码 ============== putOpts := minio.PutObjectOptions{ ContentType: contentType, // 强制存储为 utf-8,解决网页/文本乱码 UserMetadata: map[string]string{ "Charset": "UTF-8", }, } // ====================== 核心修复 ====================== // 1. 尝试解码 Base64(因为 JSON 传 []byte 会自动编码) var rawBytes []byte decodedBytes, decodeErr := base64.StdEncoding.DecodeString(string(fileBytes)) if decodeErr == nil { // 解码成功 → 使用原始二进制(图片/HTML) rawBytes = decodedBytes } else { // 解码失败 → 直接使用原字节 rawBytes = fileBytes } // 上传 _, err = minioClient.PutObject( ctx, bucketName, objectName, bytes.NewReader(rawBytes), int64(len(rawBytes)), putOpts, ) if err != nil { glog.Errorf(ctx, "上传文件失败: %v", err) return } return objectName, fileFormat, nil } // 常见图片/HTML 类型映射 var contentTypeMap = map[string]string{ ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif", ".bmp": "image/bmp", ".webp": "image/webp", ".svg": "image/svg+xml", ".html": "text/html", ".htm": "text/html", } func DownloadToFile(ctx context.Context, fileURL string, localPath string) (err error) { bucketName, err := utils.GetBucketName(ctx) // 桶名 if err != nil { glog.Errorf(ctx, "获取桶名称失败: %v", err) return } fileName := filepath.Base(fileURL) localPath = filepath.Join(localPath, fileName) // 执行下载 err = minioClient.FGetObject( ctx, bucketName, fileURL, localPath, minio.GetObjectOptions{}, // 可设置签名、加密等参数 ) if err != nil { fmt.Println("下载失败:", err) return } return }