This commit is contained in:
2026-07-18 10:52:32 +08:00
parent 2dcafae465
commit b5ef58f434
61 changed files with 1274 additions and 2142 deletions

View File

@@ -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,