提交
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -38,7 +39,7 @@ func NewFileService() *FileService {
|
||||
|
||||
// Upload 上传文件
|
||||
// 流程: 校验大小 -> 计算MD5 -> 检查秒传 -> 存储文件 -> 保存元数据
|
||||
func (s *FileService) Upload(fileHeader *multipart.FileHeader, folderID uint64, ownerID uint64, storagePolicyID uint64) (*dto.FileVO, error) {
|
||||
func (s *FileService) Upload(fileHeader *multipart.FileHeader, folderID uint64, ownerID uint64, storagePolicyID uint64, bizID string) (*dto.FileVO, error) {
|
||||
// 校验文件大小
|
||||
maxSize := s.getMaxFileSize(ownerID)
|
||||
if maxSize > 0 && fileHeader.Size > maxSize {
|
||||
@@ -51,11 +52,19 @@ func (s *FileService) Upload(fileHeader *multipart.FileHeader, folderID uint64,
|
||||
if err != nil {
|
||||
return nil, errors.New("目标文件夹不存在")
|
||||
}
|
||||
if folder.OwnerID != ownerID {
|
||||
if !s.canAccessFile(folder.ID, folder.OwnerID, ownerID) {
|
||||
return nil, errors.New("无权上传到此文件夹")
|
||||
}
|
||||
}
|
||||
|
||||
// 检查biz_id唯一性
|
||||
if bizID != "" {
|
||||
var existingBiz model.File
|
||||
if config.DB.Where("biz_id = ? AND status = 1", bizID).First(&existingBiz).Error == nil {
|
||||
return nil, fmt.Errorf("业务编号 %s 已存在", bizID)
|
||||
}
|
||||
}
|
||||
|
||||
// 确定存储策略: 优先使用指定策略, 否则根据用户分配自动确定
|
||||
var engine storage.Engine
|
||||
var policyID uint64
|
||||
@@ -68,7 +77,7 @@ func (s *FileService) Upload(fileHeader *multipart.FileHeader, folderID uint64,
|
||||
engine, engineErr := storage.GetEngineByPolicyID(policyID)
|
||||
if engineErr != nil {
|
||||
engine = storage.GetDefaultEngine()
|
||||
policyID = uint64(config.AppConfig.Storage.DefaultPolicyID)
|
||||
// policyID 保留原值, 不覆盖, 确保文件记录正确的存储策略
|
||||
}
|
||||
if engine == nil {
|
||||
return nil, errors.New("存储引擎未初始化")
|
||||
@@ -106,6 +115,7 @@ func (s *FileService) Upload(fileHeader *multipart.FileHeader, folderID uint64,
|
||||
if existing != nil {
|
||||
newFile := &model.File{
|
||||
ID: utils.GenID(),
|
||||
BizID: bizID,
|
||||
Name: fileHeader.Filename,
|
||||
Extension: utils.GetExtension(fileHeader.Filename),
|
||||
FolderID: folderID,
|
||||
@@ -140,6 +150,7 @@ func (s *FileService) Upload(fileHeader *multipart.FileHeader, folderID uint64,
|
||||
|
||||
newFile := &model.File{
|
||||
ID: utils.GenID(),
|
||||
BizID: bizID,
|
||||
Name: fileHeader.Filename,
|
||||
Extension: ext,
|
||||
FolderID: folderID,
|
||||
@@ -158,6 +169,12 @@ func (s *FileService) Upload(fileHeader *multipart.FileHeader, folderID uint64,
|
||||
}
|
||||
|
||||
s.userRepo.UpdateStorageUsed(ownerID, size)
|
||||
|
||||
// 异步检测视频编码, H.265自动转码为H.264
|
||||
if ext == "mp4" || ext == "mkv" || ext == "avi" || ext == "mov" {
|
||||
go s.asyncTranscode(engine, newFile)
|
||||
}
|
||||
|
||||
return s.toFileVO(newFile), nil
|
||||
}
|
||||
|
||||
@@ -350,36 +367,48 @@ func GenerateFileURL(file *model.File, urlType string, token ...string) (string,
|
||||
|
||||
// 根据存储策略的实际类型决定URL生成方式
|
||||
policyType := getStoragePolicyType(file.StoragePolicyID)
|
||||
log.Printf("[URL生成] 文件ID=%d, storage_policy_id=%d, 策略类型=%s", file.ID, file.StoragePolicyID, policyType)
|
||||
|
||||
switch policyType {
|
||||
case "minio":
|
||||
engine, err := storage.GetEngineByPolicyID(file.StoragePolicyID)
|
||||
if err != nil {
|
||||
// MinIO不可用, 回退到API代理
|
||||
log.Printf("[URL生成] MinIO引擎获取失败, 回退API代理: %v", err)
|
||||
return buildAPIURL(file.ID, urlType, tk), nil
|
||||
}
|
||||
minioEngine := engine.(*storage.MinIOEngine)
|
||||
var presignedURL string
|
||||
var fileURL string
|
||||
var genErr error
|
||||
if urlType == "preview" {
|
||||
presignedURL, genErr = minioEngine.GetPresignedPreviewURL(file.StorageKey, file.MimeType, 24*time.Hour)
|
||||
fileURL, genErr = minioEngine.GetPresignedPreviewURL(file.StorageKey, file.MimeType, 24*time.Hour)
|
||||
} else {
|
||||
presignedURL, genErr = minioEngine.GetPresignedURL(file.StorageKey, 24*time.Hour)
|
||||
fileURL, genErr = minioEngine.GetPresignedURL(file.StorageKey, 24*time.Hour)
|
||||
}
|
||||
if genErr != nil {
|
||||
return buildAPIURL(file.ID, urlType, tk), nil
|
||||
// 预签名失败(如匿名凭证/公开桶), 用直接URL
|
||||
log.Printf("[URL生成] 预签名失败, 使用直接URL: %v", genErr)
|
||||
fileURL = minioEngine.GetURL(file.StorageKey)
|
||||
// 预览模式追加inline头参数, 避免触发下载
|
||||
if urlType == "preview" {
|
||||
fileURL += "?response-content-disposition=inline"
|
||||
}
|
||||
}
|
||||
policy := getStoragePolicyConfig(file.StoragePolicyID)
|
||||
if policy != nil && policy.NginxEndpoint != "" {
|
||||
presignedURL = replaceMinIOEndpoint(presignedURL, minioEngine.Endpoint, policy.NginxEndpoint)
|
||||
fileURL = replaceMinIOEndpoint(fileURL, minioEngine.Endpoint, policy.NginxEndpoint)
|
||||
}
|
||||
return presignedURL, nil
|
||||
return fileURL, nil
|
||||
|
||||
case "local":
|
||||
// 本地存储: 有CDN就用CDN, 否则用API路径
|
||||
cfg := config.AppConfig.Storage
|
||||
if cfg.CDNHost != "" {
|
||||
prefix := strings.TrimRight(cfg.CDNPathPrefix, "/")
|
||||
// 策略分类子目录(hot/cold/public/slave)
|
||||
category := getStoragePolicyCategory(file.StoragePolicyID)
|
||||
if category != "" {
|
||||
prefix = prefix + "/" + category
|
||||
}
|
||||
key := strings.TrimLeft(file.StorageKey, "/")
|
||||
return fmt.Sprintf("%s%s/%s", cfg.CDNHost, prefix, key), nil
|
||||
}
|
||||
@@ -400,6 +429,15 @@ func getStoragePolicyType(policyID uint64) string {
|
||||
return policy.Type
|
||||
}
|
||||
|
||||
// getStoragePolicyCategory 获取存储策略分类(hot/cold/public/slave)
|
||||
func getStoragePolicyCategory(policyID uint64) string {
|
||||
var policy model.StoragePolicy
|
||||
if config.DB.Select("policy_type").First(&policy, policyID).Error != nil {
|
||||
return ""
|
||||
}
|
||||
return policy.PolicyType
|
||||
}
|
||||
|
||||
// buildAPIURL 构建完整的API文件访问地址
|
||||
func buildAPIURL(fileID uint64, urlType string, token ...string) string {
|
||||
publicURL := config.AppConfig.Server.PublicURL
|
||||
@@ -476,9 +514,97 @@ func (s *FileService) SearchByType(ownerID uint64, extensions []string) ([]dto.F
|
||||
return vos, nil
|
||||
}
|
||||
|
||||
// ListTrashed 查询回收站
|
||||
// asyncTranscode 异步转码视频(H.265→H.264), 支持本地存储和MinIO
|
||||
func (s *FileService) asyncTranscode(engine storage.Engine, file *model.File) {
|
||||
if !utils.FFmpegAvailable() {
|
||||
return
|
||||
}
|
||||
|
||||
// 根据存储引擎类型处理
|
||||
switch e := engine.(type) {
|
||||
case *storage.LocalEngine:
|
||||
s.transcodeLocal(e, file)
|
||||
case *storage.MinIOEngine:
|
||||
s.transcodeMinIO(e, file)
|
||||
}
|
||||
}
|
||||
|
||||
// transcodeLocal 本地存储转码
|
||||
func (s *FileService) transcodeLocal(localEngine *storage.LocalEngine, file *model.File) {
|
||||
filePath := localEngine.GetFullPath(file.StorageKey)
|
||||
if !utils.NeedsTranscode(filePath) {
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[转码] 检测到H.265视频, 开始转码: %s", file.Name)
|
||||
utils.TranscodeToH264(filePath)
|
||||
|
||||
// 更新文件大小(转码后大小可能变化)
|
||||
if info, err := os.Stat(filePath); err == nil {
|
||||
s.fileRepo.UpdateSize(file.ID, info.Size())
|
||||
}
|
||||
}
|
||||
|
||||
// transcodeMinIO MinIO存储转码: 下载→转码→重新上传
|
||||
func (s *FileService) transcodeMinIO(minioEngine *storage.MinIOEngine, file *model.File) {
|
||||
// 下载到临时文件
|
||||
tmpInput := os.TempDir() + "/transcode_in_" + file.MD5 + filepath.Ext(file.Name)
|
||||
reader, err := minioEngine.Download(file.StorageKey)
|
||||
if err != nil {
|
||||
log.Printf("[转码] MinIO下载失败: %v", err)
|
||||
return
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
outFile, err := os.Create(tmpInput)
|
||||
if err != nil {
|
||||
log.Printf("[转码] 创建临时文件失败: %v", err)
|
||||
return
|
||||
}
|
||||
io.Copy(outFile, reader)
|
||||
outFile.Close()
|
||||
defer os.Remove(tmpInput)
|
||||
|
||||
// 检测是否需要转码
|
||||
if !utils.NeedsTranscode(tmpInput) {
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[转码] 检测到H.265视频(MinIO), 开始转码: %s", file.Name)
|
||||
|
||||
// 转码
|
||||
tmpOutput := os.TempDir() + "/transcode_out_" + file.MD5 + ".mp4"
|
||||
utils.TranscodeToH264(tmpInput) // 会生成 tmpInput 的 _h264.mp4 版本并替换
|
||||
// TranscodeToH264 会替换原文件, 所以用 tmpInput 作为转码结果
|
||||
|
||||
// 重新上传到 MinIO
|
||||
uploadFile, err := os.Open(tmpInput)
|
||||
if err != nil {
|
||||
log.Printf("[转码] 打开转码文件失败: %v", err)
|
||||
return
|
||||
}
|
||||
defer uploadFile.Close()
|
||||
defer os.Remove(tmpInput)
|
||||
|
||||
newSize, _ := uploadFile.Seek(0, io.SeekEnd)
|
||||
uploadFile.Seek(0, io.SeekStart)
|
||||
|
||||
if err := minioEngine.Upload(file.StorageKey, uploadFile, newSize); err != nil {
|
||||
log.Printf("[转码] MinIO上传失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 更新文件大小
|
||||
s.fileRepo.UpdateSize(file.ID, newSize)
|
||||
log.Printf("[转码] MinIO转码完成: %s, 新大小: %d", file.Name, newSize)
|
||||
|
||||
os.Remove(tmpOutput)
|
||||
}
|
||||
|
||||
// ListTrashed 查询回收站(管理员看全部, 普通用户看自己的)
|
||||
func (s *FileService) ListTrashed(ownerID uint64) ([]dto.FileVO, error) {
|
||||
files, err := s.fileRepo.ListTrashed(ownerID)
|
||||
isAdmin := s.isAdmin(ownerID)
|
||||
files, err := s.fileRepo.ListTrashed(ownerID, isAdmin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -499,6 +625,7 @@ func (s *FileService) GetDownloadPath(storageKey string) string {
|
||||
func (s *FileService) toFileVO(file *model.File) *dto.FileVO {
|
||||
vo := &dto.FileVO{
|
||||
ID: dto.StringID(file.ID),
|
||||
BizID: file.BizID,
|
||||
Name: file.Name,
|
||||
Extension: file.Extension,
|
||||
FolderID: dto.StringID(file.FolderID),
|
||||
@@ -666,6 +793,7 @@ func (s *FileService) GrantPermission(fileID uint64, grantBy uint64, req *dto.Gr
|
||||
}
|
||||
|
||||
return config.DB.Create(&model.FilePermission{
|
||||
ID: utils.GenID(),
|
||||
FileID: fileID,
|
||||
UserID: userID,
|
||||
RoleID: roleID,
|
||||
|
||||
Reference in New Issue
Block a user