From 625ec05599ae349e7301fbfb2d9c1e3e6fc59d7f Mon Sep 17 00:00:00 2001 From: qhd <1766646056@qq.com> Date: Thu, 11 Jun 2026 09:14:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E6=8E=A5=E5=8F=A3=E5=B9=B6=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=A4=9A=E6=96=87=E4=BB=B6=E4=B8=8A=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 DeleteFile 接口,支持通过文件 URL 删除文件 - UploadFile 接口支持多文件上传,返回结果包含文件列表 - DownloadToBrowser 改为流式读取,避免大文件占用内存 - 移除 UploadFileBytes 字节流上传接口 - 修复租户存储容量校验顺序,先校验容量再写入 Redis --- controller/file_controller.go | 11 +- minio/minio.go | 85 +++--------- model/dto/file_dto.go | 27 ++-- service/file_service.go | 240 +++++++++++++++++++--------------- 4 files changed, 172 insertions(+), 191 deletions(-) diff --git a/controller/file_controller.go b/controller/file_controller.go index 9f3718e..cbdbead 100644 --- a/controller/file_controller.go +++ b/controller/file_controller.go @@ -28,12 +28,13 @@ func (c *file) DownloadToBrowser(ctx context.Context, req *dto.DownloadToBrowser return } +// DeleteFile 删除文件 +func (c *file) DeleteFile(ctx context.Context, req *dto.DeleteFileReq) (res *beans.ResponseEmpty, err error) { + err = service.File.DeleteFile(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 5085295..73a36de 100644 --- a/minio/minio.go +++ b/minio/minio.go @@ -1,9 +1,7 @@ package minio import ( - "bytes" "context" - "encoding/base64" "fmt" "io" "net/http" @@ -138,65 +136,20 @@ func UploadFile(ctx context.Context, fileHeader *ghttp.UploadFile) (imagesUrl st 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) +// DeleteFile 删除单个文件 +func DeleteFile(ctx context.Context, objectName string) (err error) { + bucketName, err := utils.GetBucketName(ctx) if err != nil { + glog.Errorf(ctx, "获取桶名称失败: %v", err) 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, - ) + // 执行删除 + err = minioClient.RemoveObject(ctx, bucketName, objectName, minio.RemoveObjectOptions{}) if err != nil { - glog.Errorf(ctx, "上传文件失败: %v", err) + glog.Errorf(ctx, "删除失败: %v\n", 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", + return } func DownloadToFile(ctx context.Context, fileURL string, localPath string) (err error) { @@ -225,10 +178,8 @@ func DownloadToFile(ctx context.Context, fileURL string, localPath string) (err return } -// DownloadToBrowser 下载文件到浏览器(返回文件流,由 HTTP Response 直接写出) -// 参数 fileURL: MinIO 中的对象路径(即 objectName) -// 返回: 文件字节流、文件名、ContentType、错误 -func DownloadToBrowser(ctx context.Context, fileURL string) (fileBytes []byte, fileName string, contentType string, err error) { +// DownloadToBrowser 下载文件到浏览器(返回 读取接口 + 文件名 + 类型 + 大小,不加载全量内存) +func DownloadToBrowser(ctx context.Context, fileURL string) (reader io.Reader, fileName string, contentType string, fileSize int64, err error) { bucketName, err := utils.GetBucketName(ctx) if err != nil { glog.Errorf(ctx, "获取桶名称失败: %v", err) @@ -241,25 +192,21 @@ func DownloadToBrowser(ctx context.Context, fileURL string) (fileBytes []byte, f glog.Errorf(ctx, "获取文件流失败: %v", err) return } - defer object.Close() - // 获取对象元信息(用于读取 ContentType 和 Size) + // 获取对象元信息 info, err := object.Stat() if err != nil { + defer object.Close() // 出错必须关闭 glog.Errorf(ctx, "获取文件信息失败: %v", err) return } - // 读取全部内容 - fileBytes, err = io.ReadAll(object) - if err != nil { - glog.Errorf(ctx, "读取文件流失败: %v", err) - return - } - - // 提取文件名 + // 直接返回流,不读取到内存 + reader = object fileName = filepath.Base(fileURL) contentType = info.ContentType + fileSize = info.Size + if contentType == "" { contentType = "application/octet-stream" } diff --git a/model/dto/file_dto.go b/model/dto/file_dto.go index 89233c8..5fcf51a 100644 --- a/model/dto/file_dto.go +++ b/model/dto/file_dto.go @@ -17,10 +17,16 @@ type DownloadToBrowserReq struct { FileURL string `json:"fileURL" dc:"文件URL" in:"query"` } +type DeleteFileReq struct { + g.Meta `path:"/deleteFile" method:"post" tags:"存储管理" summary:"删除文件" dc:"删除文件"` + FileURL string `json:"fileUrl" dc:"文件URL"` // 文件URL +} + // UploadFileReq 上传文件请求 type UploadFileReq struct { g.Meta `path:"/uploadFile" method:"post" tags:"存储管理" summary:"上传文件" dc:"上传文件"` - File *ghttp.UploadFile `json:"file" type:"file"` // 文件URL + File *ghttp.UploadFile `json:"file" type:"file"` // 文件URL + Files *ghttp.UploadFiles `json:"files" type:"files"` // 文件URL } type UploadFile struct { @@ -31,17 +37,10 @@ type UploadFile struct { // UploadFileRes 上传文件响应 type UploadFileRes struct { - FileURL string `json:"fileURL" dc:"上传地址"` - FileSize int `json:"fileSize" dc:"文件大小"` - FileName string `json:"fileName" dc:"文件名称"` - 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"` + FileURL string `json:"fileURL" dc:"上传地址"` + FileSize int `json:"fileSize" dc:"文件大小"` + FileName string `json:"fileName" dc:"文件名称"` + FileFormat string `json:"fileFormat" dc:"文件格式"` + FileAddressPrefix string `json:"fileAddressPrefix"` + Items []*UploadFileRes `json:"files"` } diff --git a/service/file_service.go b/service/file_service.go index ac40b48..96c9eb4 100644 --- a/service/file_service.go +++ b/service/file_service.go @@ -3,6 +3,7 @@ package service import ( "context" "fmt" + "io" "net/url" "oss/consts" "oss/dao" @@ -31,42 +32,137 @@ func (f *file) DownloadToFile(ctx context.Context, req *dto.DownloadToFileReq) ( // DownloadToBrowser 下载文件到浏览器 func (f *file) DownloadToBrowser(ctx context.Context, req *dto.DownloadToBrowserReq) (err error) { - fileBytes, fileName, contentType, err := minio.DownloadToBrowser(ctx, req.FileURL) + // 拿到流,而不是全量字节数组 + reader, fileName, contentType, fileSize, err := minio.DownloadToBrowser(ctx, req.FileURL) if err != nil { return err } + // 重要:确保流最后关闭 + if closer, ok := reader.(io.Closer); ok { + defer closer.Close() + } + r := ghttp.RequestFromCtx(ctx) - // 设置响应头 - r.Response.Header().Set("Content-Type", contentType) - r.Response.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"; filename*=UTF-8''%s`, fileName, url.PathEscape(fileName))) - r.Response.Header().Set("Content-Length", fmt.Sprintf("%d", len(fileBytes))) - // 写出二进制流 - r.Response.Write(fileBytes) + resp := r.Response + + resp.Header().Set("Content-Type", contentType) + resp.Header().Set("Content-Disposition", fmt.Sprintf( + `attachment; filename="%s"; filename*=UTF-8''%s`, + fileName, url.PathEscape(fileName), + )) + resp.Header().Set("Content-Length", fmt.Sprintf("%d", fileSize)) + resp.Header().Del("Content-Encoding") + resp.Header().Del("Transfer-Encoding") + data, err := io.ReadAll(reader) + if err != nil { + return err + } + resp.WriteExit(data) return } +func (f *file) DeleteFile(ctx context.Context, req *dto.DeleteFileReq) (err error) { + return minio.DeleteFile(ctx, req.FileURL) +} + func (f *file) UploadFile(ctx context.Context, req *dto.UploadFileReq) (res *dto.UploadFileRes, err error) { - fileSize := gconv.Int(req.File.Size) - totalFileSize := 0 - // 获取租户id - user, err := utils.GetUserInfo(ctx) + fileReq := new(dto.UploadFileReq) + if !g.IsEmpty(req.File) { + fileReq.Files = &ghttp.UploadFiles{req.File} + files, err := f.UploadFiles(ctx, fileReq) + if err != nil { + return nil, err + } + res = files.Items[0] + } else if !g.IsEmpty(req.Files) { + fileReq.Files = req.Files + res, err = f.UploadFiles(ctx, fileReq) + } else { + return nil, gerror.New("上传内容不能为空") + } + return +} + +// UploadFiles 上传多个文件(新接口) +func (f *file) UploadFiles(ctx context.Context, req *dto.UploadFileReq) (res *dto.UploadFileRes, err error) { + var files = *req.Files + + totalAdd := 0 + for _, file := range files { + if file == nil { + continue + } + totalAdd += gconv.Int(file.Size) + } + tenantId, err := f.reserveTenantOssSize(ctx, totalAdd) if err != nil { + return nil, err + } + + prefix, _ := utils.GetFileAddressPrefix(ctx) + items := make([]*dto.UploadFileRes, 0, len(files)) + + for _, file := range files { + if file == nil { + continue + } + + fileURL, fileName, fileFormat, e := minio.UploadFile(ctx, file) + if e != nil { + glog.Errorf(ctx, "上传文件失败: %v", e) + return nil, e + } + + ossEntity := &dto.UploadFile{ + TenantId: tenantId, + FileURL: fileURL, + FileSize: gconv.Int(file.Size), + } + if _, e = dao.File.Insert(ctx, ossEntity); e != nil { + return nil, e + } + + items = append(items, &dto.UploadFileRes{ + FileURL: fileURL, + FileSize: gconv.Int(file.Size), + FileName: fileName, + FileFormat: fileFormat, + FileAddressPrefix: prefix, + }) + } + fileRes := &dto.UploadFileRes{ + FileAddressPrefix: prefix, + Items: items, + } + return fileRes, nil +} + +func (f *file) reserveTenantOssSize(ctx context.Context, addSize int) (tenantId uint64, err error) { + if addSize < 0 { + err = gerror.New("addSize 不能为负数") + return + } + // 获取租户id + user, e := utils.GetUserInfo(ctx) + if e != nil { + err = e glog.Errorf(ctx, "获取用户信息失败: %v", err) return } - tenantId := user.TenantId + tenantId = user.TenantId + // 获取redis-租户存储容量总数key tenantOssTotalKey := fmt.Sprintf(consts.TenantOssTotalKey, gconv.String(user.TenantId)) // 获取redis-租户存储-锁key fileLockKey := fmt.Sprintf(consts.FileLockKey, gconv.String(user.TenantId)) - success, err := utils.Lock(ctx, fileLockKey, gconv.Int64(time.Minute*1), func(ctx context.Context) error { + success, e := utils.Lock(ctx, fileLockKey, gconv.Int64(time.Minute*1), func(ctx context.Context) error { // 获取redis-租户存储容量总数 - get, err := g.Redis().Get(ctx, tenantOssTotalKey) - if err != nil { - glog.Errorf(ctx, "获取redis-租户存储容量总数失败: %v", err) - return err + get, e := g.Redis().Get(ctx, tenantOssTotalKey) + if e != nil { + glog.Errorf(ctx, "获取redis-租户存储容量总数失败: %v", e) + return e } tenantOssTotalEntity := &entity.TenantOssTotal{} if g.IsEmpty(get) { @@ -74,10 +170,10 @@ func (f *file) UploadFile(ctx context.Context, req *dto.UploadFileReq) (res *dto getByTenantIdReq := &dto.GetByTenantIdReq{ TenantId: user.TenantId, } - tenantOssTotalRes, err := dao.TenantOssTotal.GetOneByTenantId(ctx, getByTenantIdReq) - if err != nil { - glog.Errorf(ctx, "查询数据库-获取租户存储容量总数失败: %v", err) - return err + tenantOssTotalRes, e := dao.TenantOssTotal.GetOneByTenantId(ctx, getByTenantIdReq) + if e != nil { + glog.Errorf(ctx, "查询数据库-获取租户存储容量总数失败: %v", e) + return e } if tenantOssTotalRes == nil || g.IsEmpty(tenantOssTotalRes.Id) { // 数据库中没有该租户的记录,创建默认配置 @@ -89,102 +185,40 @@ func (f *file) UploadFile(ctx context.Context, req *dto.UploadFileReq) (res *dto } } else { // 反序列化-redis获取租户存储容量总数 - if err = gconv.Struct(get, tenantOssTotalEntity); err != nil { - glog.Errorf(ctx, "反序列化-redis获取租户存储容量总数失败: %v", err) - return err + if e = gconv.Struct(get, tenantOssTotalEntity); e != nil { + glog.Errorf(ctx, "反序列化-redis获取租户存储容量总数失败: %v", e) + return e } } tenantId = tenantOssTotalEntity.TenantId - fileSize = tenantOssTotalEntity.UsedOssSize + fileSize - totalFileSize = tenantOssTotalEntity.TotalOssSize - // 设置redis-租户存储容量总数 + usedAfter := tenantOssTotalEntity.UsedOssSize + addSize + total := tenantOssTotalEntity.TotalOssSize + + if usedAfter > total { + return gerror.New("存储服务内存不足") + } + + // 设置redis-租户存储容量总数(超时时间10分钟) tenantOssTotalKeyMap := dto.UpdateUsedOssReq{ TenantId: tenantId, - UsedOssSize: fileSize, - TotalOssSize: totalFileSize, + UsedOssSize: usedAfter, + TotalOssSize: total, Creator: user.UserName, Updater: user.UserName, } - // 修改redis-租户存储容量总数 超时时间10分钟 - if err = g.Redis().SetEX(ctx, tenantOssTotalKey, tenantOssTotalKeyMap, gconv.Int64(time.Minute*10)); err != nil { - glog.Errorf(ctx, "修改redis-租户存储容量总数 超时时间10分钟失败: %v", err) - return err - } - if fileSize > totalFileSize { - return gerror.New("存储服务内存不足") + if e = g.Redis().SetEX(ctx, tenantOssTotalKey, tenantOssTotalKeyMap, gconv.Int64(time.Minute*10)); e != nil { + glog.Errorf(ctx, "修改redis-租户存储容量总数失败: %v", e) + return e } return nil }) - if err != nil { - return nil, err + if e != nil { + err = e + return } if !success { - return nil, gerror.New("存储服务内存不足") - } - // 上传图片 - 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 - } - // 插入数据库 - ossEntity := &dto.UploadFile{ - TenantId: tenantId, - FileURL: fileURL, - FileSize: fileSize, - } - if _, err = dao.File.Insert(ctx, ossEntity); err != nil { - return nil, err - } - // 返回图片url - res = &dto.UploadFileRes{ - FileURL: fileURL, - FileSize: fileSize, - FileName: fileName, - FileFormat: fileFormat, - } - url, err := utils.GetFileAddressPrefix(ctx) - if err != nil { + err = gerror.New("存储服务内存不足") return } - 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 }