diff --git a/controller/file_controller.go b/controller/file_controller.go index bb7c881..683546a 100644 --- a/controller/file_controller.go +++ b/controller/file_controller.go @@ -4,6 +4,8 @@ import ( "context" "oss/model/dto" "oss/service" + + "gitea.com/red-future/common/beans" ) type file struct{} @@ -14,7 +16,18 @@ var File = new(file) func init() { } +// DownloadToFile 下载文件到本地 +func (c *file) DownloadToFile(ctx context.Context, req *dto.DownloadToFileReq) (res *beans.ResponseEmpty, err error) { + err = service.File.DownloadToFile(ctx, req) + return +} + // UploadFile 上传文件 func (c *file) UploadFile(ctx context.Context, req *dto.UploadFileReq) (res *dto.UploadFileRes, err error) { return service.File.UploadFile(ctx, req) } + +// UploadFileBytes 上传文件(字节流) +func (c *file) UploadFileBytes(ctx context.Context, req *dto.UploadFileBytesReq) (res *dto.UploadFileRes, err error) { + return service.File.UploadFileBytes(ctx, req) +} diff --git a/minio/minio.go b/minio/minio.go index c478e68..131d583 100644 --- a/minio/minio.go +++ b/minio/minio.go @@ -1,7 +1,9 @@ package minio import ( + "bytes" "context" + "encoding/base64" "fmt" "net/http" "path/filepath" @@ -54,29 +56,49 @@ func init() { } } -func UploadFile(ctx context.Context, fileHeader *ghttp.UploadFile) (imagesUrl string, fileName string, fileFormat string, err error) { - return uploadFile(ctx, fileHeader) -} - -func uploadFile(ctx context.Context, fileHeader *ghttp.UploadFile) (imagesUrl string, fileName string, fileFormat string, err error) { +// 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 + return "", "", "", err } // 检查/创建桶 exists, err := minioClient.BucketExists(ctx, bucketName) if err != nil { glog.Errorf(ctx, "检查桶是否存在失败: %v", err) - return + return "", "", "", err } if !exists { if err = minioClient.MakeBucket(ctx, bucketName, minio.MakeBucketOptions{Region: minioCfg.Region}); err != nil { glog.Errorf(ctx, "创建桶失败: %v", err) - return + 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 { @@ -97,17 +119,6 @@ func uploadFile(ctx context.Context, fileHeader *ghttp.UploadFile) (imagesUrl st glog.Errorf(ctx, "重置文件读取位置失败: %v", err) return } - // 生成唯一的 MinIO 对象名(避免覆盖) - fileExt := filepath.Ext(fileHeader.Filename) // 原文件后缀(如 .jpg) - uniqueID := uuid.New().String()[:32] // 32位随机UUID - timestamp := time.Now().Format("2006-01-02") // 日期目录(便于管理) - objectName := fmt.Sprintf("/%s/%s%s", timestamp, uniqueID, fileExt) // 存储路径:20251209/abc12345.jpg - // 设置存储桶公共读权限 - 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 = minioClient.PutObject( ctx, @@ -117,13 +128,98 @@ func uploadFile(ctx context.Context, fileHeader *ghttp.UploadFile) (imagesUrl st fileHeader.Size, minio.PutObjectOptions{ ContentType: contentType, // 关键:指定图片MIME类型,S3会根据此类型处理 - // 若需要图片可公开访问,添加如下配置(根据需求选择) - //ACL: minio.ACLPublicRead, }, ) if err != nil { glog.Errorf(ctx, "上传图片失败: %v", err) return } - return objectName, fileHeader.Filename, strings.ReplaceAll(fileExt, ".", ""), err + 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 } diff --git a/model/dto/file_dto.go b/model/dto/file_dto.go index 9a46121..4e5f695 100644 --- a/model/dto/file_dto.go +++ b/model/dto/file_dto.go @@ -5,6 +5,12 @@ import ( "github.com/gogf/gf/v2/net/ghttp" ) +type DownloadToFileReq struct { + g.Meta `path:"/downloadToFile" method:"post" tags:"存储管理" summary:"下载文件到本地" dc:"下载文件到本地"` + FileURL string `json:"fileURL" dc:"文件URL"` + LocalPath string `json:"localPath" dc:"本地路径"` +} + // UploadFileReq 上传文件请求 type UploadFileReq struct { g.Meta `path:"/uploadFile" method:"post" tags:"存储管理" summary:"上传文件" dc:"上传文件"` @@ -25,3 +31,11 @@ type UploadFileRes struct { FileFormat string `json:"fileFormat" dc:"文件格式"` FileAddressPrefix string `json:"fileAddressPrefix"` } + +// UploadFileBytesReq 上传文件请求(字节流) +type UploadFileBytesReq struct { + g.Meta `path:"/uploadFileBytes" method:"post" tags:"存储管理" summary:"上传文件(字节流)" dc:"上传文件(字节流)"` + FileName string `json:"fileName" dc:"文件名"` + FileBytes []byte `json:"fileBytes" dc:"文件字节流"` + FileStoreURL string `json:"fileStoreURL" dc:"文件存储的URL"` +} diff --git a/service/file_service.go b/service/file_service.go index c940878..6373506 100644 --- a/service/file_service.go +++ b/service/file_service.go @@ -23,6 +23,10 @@ type file struct{} // File 存储文件服务 var File = new(file) +func (f *file) DownloadToFile(ctx context.Context, req *dto.DownloadToFileReq) (err error) { + return minio.DownloadToFile(ctx, req.FileURL, req.LocalPath) +} + func (f *file) UploadFile(ctx context.Context, req *dto.UploadFileReq) (res *dto.UploadFileRes, err error) { fileSize := gconv.Int(req.File.Size) totalFileSize := 0 @@ -99,7 +103,8 @@ func (f *file) UploadFile(ctx context.Context, req *dto.UploadFileReq) (res *dto return nil, gerror.New("存储服务内存不足") } // 上传图片 - fileURL, fileName, fileFormat, err := minio.UploadFile(ctx, req.File) + var fileURL, fileName, fileFormat string + fileURL, fileName, fileFormat, err = minio.UploadFile(ctx, req.File) if err != nil { glog.Errorf(ctx, "上传图片失败: %v", err) return nil, err @@ -127,3 +132,40 @@ func (f *file) UploadFile(ctx context.Context, req *dto.UploadFileReq) (res *dto res.FileAddressPrefix = url return } + +// UploadFileBytes 上传文件(字节流) +func (f *file) UploadFileBytes(ctx context.Context, req *dto.UploadFileBytesReq) (res *dto.UploadFileRes, err error) { + // 获取用户信息 + user, err := utils.GetUserInfo(ctx) + if err != nil { + glog.Errorf(ctx, "获取用户信息失败: %v", err) + return + } + tenantId := user.TenantId + + // 上传到 MinIO + fileURL, fileFormat, err := minio.UploadFileBytes(ctx, req.FileName, req.FileBytes, req.FileStoreURL) + if err != nil { + glog.Errorf(ctx, "上传文件失败: %v", err) + return nil, err + } + + // 插入数据库记录 + ossEntity := &dto.UploadFile{ + TenantId: tenantId, + FileURL: fileURL, + FileSize: len(req.FileBytes), + } + if _, err = dao.File.Insert(ctx, ossEntity); err != nil { + return nil, err + } + + res = &dto.UploadFileRes{ + FileURL: fileURL, + FileSize: len(req.FileBytes), + FileName: req.FileName, + FileFormat: fileFormat, + } + res.FileAddressPrefix, _ = utils.GetFileAddressPrefix(ctx) + return +}