feat: 新增删除文件接口并支持多文件上传

- 新增 DeleteFile 接口,支持通过文件 URL 删除文件
- UploadFile 接口支持多文件上传,返回结果包含文件列表
- DownloadToBrowser 改为流式读取,避免大文件占用内存
- 移除 UploadFileBytes 字节流上传接口
- 修复租户存储容量校验顺序,先校验容量再写入 Redis
This commit is contained in:
2026-06-11 09:14:45 +08:00
parent 99cbdec579
commit 625ec05599
4 changed files with 172 additions and 191 deletions

View File

@@ -28,12 +28,13 @@ func (c *file) DownloadToBrowser(ctx context.Context, req *dto.DownloadToBrowser
return 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 上传文件 // UploadFile 上传文件
func (c *file) UploadFile(ctx context.Context, req *dto.UploadFileReq) (res *dto.UploadFileRes, err error) { func (c *file) UploadFile(ctx context.Context, req *dto.UploadFileReq) (res *dto.UploadFileRes, err error) {
return service.File.UploadFile(ctx, req) 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)
}

View File

@@ -1,9 +1,7 @@
package minio package minio
import ( import (
"bytes"
"context" "context"
"encoding/base64"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -138,65 +136,20 @@ func UploadFile(ctx context.Context, fileHeader *ghttp.UploadFile) (imagesUrl st
return objectName, fileHeader.Filename, fileFormat, nil return objectName, fileHeader.Filename, fileFormat, nil
} }
// UploadFileBytes 直接上传字节流到 MinIO // DeleteFile 删除单个文件
func UploadFileBytes(ctx context.Context, fileName string, fileBytes []byte, fileStoreURL string) (imagesUrl string, fileFormat string, err error) { func DeleteFile(ctx context.Context, objectName string) (err error) {
bucketName, objectName, fileFormat, err := ensureBucketAndObjectName(ctx, fileName, fileStoreURL) bucketName, err := utils.GetBucketName(ctx)
if err != nil { if err != nil {
glog.Errorf(ctx, "获取桶名称失败: %v", err)
return return
} }
// ============== 1. 强制从文件后缀获取 ContentType绝对准确 ============== // 执行删除
ext := strings.ToLower(filepath.Ext(fileName)) err = minioClient.RemoveObject(ctx, bucketName, objectName, minio.RemoveObjectOptions{})
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 { if err != nil {
glog.Errorf(ctx, "上传文件失败: %v", err) glog.Errorf(ctx, "删除失败: %v\n", err)
return return
} }
return objectName, fileFormat, nil return
}
// 常见图片/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) { 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 return
} }
// DownloadToBrowser 下载文件到浏览器(返回文件流,由 HTTP Response 直接写出 // DownloadToBrowser 下载文件到浏览器(返回 读取接口 + 文件名 + 类型 + 大小,不加载全量内存
// 参数 fileURL: MinIO 中的对象路径(即 objectName func DownloadToBrowser(ctx context.Context, fileURL string) (reader io.Reader, fileName string, contentType string, fileSize int64, err error) {
// 返回: 文件字节流、文件名、ContentType、错误
func DownloadToBrowser(ctx context.Context, fileURL string) (fileBytes []byte, fileName string, contentType string, err error) {
bucketName, err := utils.GetBucketName(ctx) bucketName, err := utils.GetBucketName(ctx)
if err != nil { if err != nil {
glog.Errorf(ctx, "获取桶名称失败: %v", err) glog.Errorf(ctx, "获取桶名称失败: %v", err)
@@ -241,25 +192,21 @@ func DownloadToBrowser(ctx context.Context, fileURL string) (fileBytes []byte, f
glog.Errorf(ctx, "获取文件流失败: %v", err) glog.Errorf(ctx, "获取文件流失败: %v", err)
return return
} }
defer object.Close()
// 获取对象元信息(用于读取 ContentType 和 Size // 获取对象元信息
info, err := object.Stat() info, err := object.Stat()
if err != nil { if err != nil {
defer object.Close() // 出错必须关闭
glog.Errorf(ctx, "获取文件信息失败: %v", err) glog.Errorf(ctx, "获取文件信息失败: %v", err)
return return
} }
// 读取全部内容 // 直接返回流,不读取到内存
fileBytes, err = io.ReadAll(object) reader = object
if err != nil {
glog.Errorf(ctx, "读取文件流失败: %v", err)
return
}
// 提取文件名
fileName = filepath.Base(fileURL) fileName = filepath.Base(fileURL)
contentType = info.ContentType contentType = info.ContentType
fileSize = info.Size
if contentType == "" { if contentType == "" {
contentType = "application/octet-stream" contentType = "application/octet-stream"
} }

View File

@@ -17,10 +17,16 @@ type DownloadToBrowserReq struct {
FileURL string `json:"fileURL" dc:"文件URL" in:"query"` 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 上传文件请求 // UploadFileReq 上传文件请求
type UploadFileReq struct { type UploadFileReq struct {
g.Meta `path:"/uploadFile" method:"post" tags:"存储管理" summary:"上传文件" dc:"上传文件"` 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 { type UploadFile struct {
@@ -31,17 +37,10 @@ type UploadFile struct {
// UploadFileRes 上传文件响应 // UploadFileRes 上传文件响应
type UploadFileRes struct { type UploadFileRes struct {
FileURL string `json:"fileURL" dc:"上传地址"` FileURL string `json:"fileURL" dc:"上传地址"`
FileSize int `json:"fileSize" dc:"文件大小"` FileSize int `json:"fileSize" dc:"文件大小"`
FileName string `json:"fileName" dc:"文件名称"` FileName string `json:"fileName" dc:"文件名称"`
FileFormat string `json:"fileFormat" dc:"文件格式"` FileFormat string `json:"fileFormat" dc:"文件格式"`
FileAddressPrefix string `json:"fileAddressPrefix"` FileAddressPrefix string `json:"fileAddressPrefix"`
} Items []*UploadFileRes `json:"files"`
// 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"`
} }

View File

@@ -3,6 +3,7 @@ package service
import ( import (
"context" "context"
"fmt" "fmt"
"io"
"net/url" "net/url"
"oss/consts" "oss/consts"
"oss/dao" "oss/dao"
@@ -31,42 +32,137 @@ func (f *file) DownloadToFile(ctx context.Context, req *dto.DownloadToFileReq) (
// DownloadToBrowser 下载文件到浏览器 // DownloadToBrowser 下载文件到浏览器
func (f *file) DownloadToBrowser(ctx context.Context, req *dto.DownloadToBrowserReq) (err error) { 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 { if err != nil {
return err return err
} }
// 重要:确保流最后关闭
if closer, ok := reader.(io.Closer); ok {
defer closer.Close()
}
r := ghttp.RequestFromCtx(ctx) r := ghttp.RequestFromCtx(ctx)
// 设置响应头 resp := r.Response
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))) resp.Header().Set("Content-Type", contentType)
r.Response.Header().Set("Content-Length", fmt.Sprintf("%d", len(fileBytes))) resp.Header().Set("Content-Disposition", fmt.Sprintf(
// 写出二进制流 `attachment; filename="%s"; filename*=UTF-8''%s`,
r.Response.Write(fileBytes) 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 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) { func (f *file) UploadFile(ctx context.Context, req *dto.UploadFileReq) (res *dto.UploadFileRes, err error) {
fileSize := gconv.Int(req.File.Size) fileReq := new(dto.UploadFileReq)
totalFileSize := 0 if !g.IsEmpty(req.File) {
// 获取租户id fileReq.Files = &ghttp.UploadFiles{req.File}
user, err := utils.GetUserInfo(ctx) 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 { 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) glog.Errorf(ctx, "获取用户信息失败: %v", err)
return return
} }
tenantId := user.TenantId tenantId = user.TenantId
// 获取redis-租户存储容量总数key // 获取redis-租户存储容量总数key
tenantOssTotalKey := fmt.Sprintf(consts.TenantOssTotalKey, gconv.String(user.TenantId)) tenantOssTotalKey := fmt.Sprintf(consts.TenantOssTotalKey, gconv.String(user.TenantId))
// 获取redis-租户存储-锁key // 获取redis-租户存储-锁key
fileLockKey := fmt.Sprintf(consts.FileLockKey, gconv.String(user.TenantId)) 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-租户存储容量总数 // 获取redis-租户存储容量总数
get, err := g.Redis().Get(ctx, tenantOssTotalKey) get, e := g.Redis().Get(ctx, tenantOssTotalKey)
if err != nil { if e != nil {
glog.Errorf(ctx, "获取redis-租户存储容量总数失败: %v", err) glog.Errorf(ctx, "获取redis-租户存储容量总数失败: %v", e)
return err return e
} }
tenantOssTotalEntity := &entity.TenantOssTotal{} tenantOssTotalEntity := &entity.TenantOssTotal{}
if g.IsEmpty(get) { if g.IsEmpty(get) {
@@ -74,10 +170,10 @@ func (f *file) UploadFile(ctx context.Context, req *dto.UploadFileReq) (res *dto
getByTenantIdReq := &dto.GetByTenantIdReq{ getByTenantIdReq := &dto.GetByTenantIdReq{
TenantId: user.TenantId, TenantId: user.TenantId,
} }
tenantOssTotalRes, err := dao.TenantOssTotal.GetOneByTenantId(ctx, getByTenantIdReq) tenantOssTotalRes, e := dao.TenantOssTotal.GetOneByTenantId(ctx, getByTenantIdReq)
if err != nil { if e != nil {
glog.Errorf(ctx, "查询数据库-获取租户存储容量总数失败: %v", err) glog.Errorf(ctx, "查询数据库-获取租户存储容量总数失败: %v", e)
return err return e
} }
if tenantOssTotalRes == nil || g.IsEmpty(tenantOssTotalRes.Id) { if tenantOssTotalRes == nil || g.IsEmpty(tenantOssTotalRes.Id) {
// 数据库中没有该租户的记录,创建默认配置 // 数据库中没有该租户的记录,创建默认配置
@@ -89,102 +185,40 @@ func (f *file) UploadFile(ctx context.Context, req *dto.UploadFileReq) (res *dto
} }
} else { } else {
// 反序列化-redis获取租户存储容量总数 // 反序列化-redis获取租户存储容量总数
if err = gconv.Struct(get, tenantOssTotalEntity); err != nil { if e = gconv.Struct(get, tenantOssTotalEntity); e != nil {
glog.Errorf(ctx, "反序列化-redis获取租户存储容量总数失败: %v", err) glog.Errorf(ctx, "反序列化-redis获取租户存储容量总数失败: %v", e)
return err return e
} }
} }
tenantId = tenantOssTotalEntity.TenantId tenantId = tenantOssTotalEntity.TenantId
fileSize = tenantOssTotalEntity.UsedOssSize + fileSize usedAfter := tenantOssTotalEntity.UsedOssSize + addSize
totalFileSize = tenantOssTotalEntity.TotalOssSize total := tenantOssTotalEntity.TotalOssSize
// 设置redis-租户存储容量总数
if usedAfter > total {
return gerror.New("存储服务内存不足")
}
// 设置redis-租户存储容量总数超时时间10分钟
tenantOssTotalKeyMap := dto.UpdateUsedOssReq{ tenantOssTotalKeyMap := dto.UpdateUsedOssReq{
TenantId: tenantId, TenantId: tenantId,
UsedOssSize: fileSize, UsedOssSize: usedAfter,
TotalOssSize: totalFileSize, TotalOssSize: total,
Creator: user.UserName, Creator: user.UserName,
Updater: user.UserName, Updater: user.UserName,
} }
// 修改redis-租户存储容量总数 超时时间10分钟 if e = g.Redis().SetEX(ctx, tenantOssTotalKey, tenantOssTotalKeyMap, gconv.Int64(time.Minute*10)); e != nil {
if err = g.Redis().SetEX(ctx, tenantOssTotalKey, tenantOssTotalKeyMap, gconv.Int64(time.Minute*10)); err != nil { glog.Errorf(ctx, "修改redis-租户存储容量总数失败: %v", e)
glog.Errorf(ctx, "修改redis-租户存储容量总数 超时时间10分钟失败: %v", err) return e
return err
}
if fileSize > totalFileSize {
return gerror.New("存储服务内存不足")
} }
return nil return nil
}) })
if err != nil { if e != nil {
return nil, err err = e
return
} }
if !success { if !success {
return nil, gerror.New("存储服务内存不足") err = 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 {
return 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 return
} }