2026-04-15 17:00:09 +08:00
|
|
|
|
package minio
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
2026-04-22 08:45:45 +08:00
|
|
|
|
"bytes"
|
2026-04-15 17:00:09 +08:00
|
|
|
|
"context"
|
2026-04-22 08:45:45 +08:00
|
|
|
|
"encoding/base64"
|
2026-04-15 17:00:09 +08:00
|
|
|
|
"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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-22 08:45:45 +08:00
|
|
|
|
// ensureBucketAndObjectName 确保桶存在,并生成对象名
|
|
|
|
|
|
// 返回: bucketName, objectName, fileFormat, error
|
|
|
|
|
|
func ensureBucketAndObjectName(ctx context.Context, fileName string, fileStoreURL string) (string, string, string, error) {
|
2026-04-15 17:00:09 +08:00
|
|
|
|
bucketName, err := utils.GetBucketName(ctx)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
glog.Errorf(ctx, "获取桶名称失败: %v", err)
|
2026-04-22 08:45:45 +08:00
|
|
|
|
return "", "", "", err
|
2026-04-15 17:00:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
// 检查/创建桶
|
|
|
|
|
|
exists, err := minioClient.BucketExists(ctx, bucketName)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
glog.Errorf(ctx, "检查桶是否存在失败: %v", err)
|
2026-04-22 08:45:45 +08:00
|
|
|
|
return "", "", "", err
|
2026-04-15 17:00:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
if !exists {
|
|
|
|
|
|
if err = minioClient.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{Region: minioCfg.Region}); err != nil {
|
|
|
|
|
|
glog.Errorf(ctx, "创建桶失败: %v", err)
|
2026-04-22 08:45:45 +08:00
|
|
|
|
return "", "", "", err
|
2026-04-15 17:00:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
glog.Infof(ctx, "成功创建 MinIO 桶: %s", bucketName)
|
|
|
|
|
|
}
|
2026-04-22 08:45:45 +08:00
|
|
|
|
// 生成对象名
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-04-15 17:00:09 +08:00
|
|
|
|
// 打开文件,获取 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
|
|
|
|
|
|
}
|
2026-04-22 08:45:45 +08:00
|
|
|
|
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
|
2026-04-15 17:00:09 +08:00
|
|
|
|
}
|