480 lines
14 KiB
Go
480 lines
14 KiB
Go
|
|
package service
|
||
|
|
|
||
|
|
import (
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"mime/multipart"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"seeyon-filesystem/config"
|
||
|
|
"seeyon-filesystem/dto"
|
||
|
|
"seeyon-filesystem/model"
|
||
|
|
"seeyon-filesystem/repository"
|
||
|
|
"seeyon-filesystem/storage"
|
||
|
|
"seeyon-filesystem/utils"
|
||
|
|
)
|
||
|
|
|
||
|
|
// FileService 文件业务服务
|
||
|
|
type FileService struct {
|
||
|
|
fileRepo *repository.FileRepository
|
||
|
|
folderRepo *repository.FolderRepository
|
||
|
|
userRepo *repository.UserRepository
|
||
|
|
assignService *StorageAssignService
|
||
|
|
}
|
||
|
|
|
||
|
|
// NewFileService 创建文件服务实例
|
||
|
|
func NewFileService() *FileService {
|
||
|
|
return &FileService{
|
||
|
|
fileRepo: repository.NewFileRepository(),
|
||
|
|
folderRepo: repository.NewFolderRepository(),
|
||
|
|
userRepo: repository.NewUserRepository(),
|
||
|
|
assignService: NewStorageAssignService(),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Upload 上传文件
|
||
|
|
// 流程: 计算MD5 -> 检查秒传 -> 存储文件 -> 保存元数据
|
||
|
|
// storagePolicyID: 存储策略ID, 0=使用默认策略
|
||
|
|
func (s *FileService) Upload(fileHeader *multipart.FileHeader, folderID uint64, ownerID uint64, storagePolicyID uint64) (*dto.FileVO, error) {
|
||
|
|
// 验证文件夹
|
||
|
|
if folderID > 0 {
|
||
|
|
folder, err := s.folderRepo.FindByID(folderID)
|
||
|
|
if err != nil {
|
||
|
|
return nil, errors.New("目标文件夹不存在")
|
||
|
|
}
|
||
|
|
if folder.OwnerID != ownerID {
|
||
|
|
return nil, errors.New("无权上传到此文件夹")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 确定存储策略: 优先使用指定策略, 否则根据用户分配自动确定
|
||
|
|
var engine storage.Engine
|
||
|
|
var policyID uint64
|
||
|
|
if storagePolicyID > 0 {
|
||
|
|
policyID = storagePolicyID
|
||
|
|
} else {
|
||
|
|
policyID = s.assignService.ResolvePolicyID(ownerID)
|
||
|
|
}
|
||
|
|
|
||
|
|
engine, engineErr := storage.GetEngineByPolicyID(policyID)
|
||
|
|
if engineErr != nil {
|
||
|
|
engine = storage.GetDefaultEngine()
|
||
|
|
policyID = uint64(config.AppConfig.Storage.DefaultPolicyID)
|
||
|
|
}
|
||
|
|
if engine == nil {
|
||
|
|
return nil, errors.New("存储引擎未初始化")
|
||
|
|
}
|
||
|
|
|
||
|
|
// 打开上传文件
|
||
|
|
src, err := fileHeader.Open()
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("打开上传文件失败: %w", err)
|
||
|
|
}
|
||
|
|
defer src.Close()
|
||
|
|
|
||
|
|
// 读取文件内容到临时文件(用于计算MD5)
|
||
|
|
tempFile, err := os.CreateTemp("", "upload_*")
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("创建临时文件失败: %w", err)
|
||
|
|
}
|
||
|
|
defer os.Remove(tempFile.Name())
|
||
|
|
|
||
|
|
size, err := io.Copy(tempFile, src)
|
||
|
|
if err != nil {
|
||
|
|
tempFile.Close()
|
||
|
|
return nil, fmt.Errorf("保存临时文件失败: %w", err)
|
||
|
|
}
|
||
|
|
tempFile.Close()
|
||
|
|
|
||
|
|
// 计算文件MD5
|
||
|
|
md5, err := utils.FileMD5(tempFile.Name())
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("计算MD5失败: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// 秒传检查: 如果MD5相同的文件已存在, 直接创建引用
|
||
|
|
existing, _ := s.fileRepo.FindByMD5(md5)
|
||
|
|
if existing != nil {
|
||
|
|
newFile := &model.File{
|
||
|
|
Name: fileHeader.Filename,
|
||
|
|
Extension: utils.GetExtension(fileHeader.Filename),
|
||
|
|
FolderID: folderID,
|
||
|
|
OwnerID: ownerID,
|
||
|
|
Size: size,
|
||
|
|
MD5: md5,
|
||
|
|
StorageKey: existing.StorageKey,
|
||
|
|
StoragePolicyID: existing.StoragePolicyID,
|
||
|
|
MimeType: utils.GetMimeType(utils.GetExtension(fileHeader.Filename)),
|
||
|
|
Status: 1,
|
||
|
|
}
|
||
|
|
if err := s.fileRepo.Create(newFile); err != nil {
|
||
|
|
return nil, fmt.Errorf("创建文件记录失败: %w", err)
|
||
|
|
}
|
||
|
|
s.userRepo.UpdateStorageUsed(ownerID, size)
|
||
|
|
return s.toFileVO(newFile), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// 正常上传: 生成存储路径 -> 上传到存储引擎 -> 保存元数据
|
||
|
|
ext := utils.GetExtension(fileHeader.Filename)
|
||
|
|
storageKey := storage.GenerateStorageKey(md5, ext)
|
||
|
|
|
||
|
|
reader, err := os.Open(tempFile.Name())
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("打开临时文件失败: %w", err)
|
||
|
|
}
|
||
|
|
defer reader.Close()
|
||
|
|
|
||
|
|
if err := engine.Upload(storageKey, reader, size); err != nil {
|
||
|
|
return nil, fmt.Errorf("存储文件失败: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
newFile := &model.File{
|
||
|
|
Name: fileHeader.Filename,
|
||
|
|
Extension: ext,
|
||
|
|
FolderID: folderID,
|
||
|
|
OwnerID: ownerID,
|
||
|
|
Size: size,
|
||
|
|
MD5: md5,
|
||
|
|
StorageKey: storageKey,
|
||
|
|
StoragePolicyID: policyID,
|
||
|
|
MimeType: utils.GetMimeType(ext),
|
||
|
|
Status: 1,
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := s.fileRepo.Create(newFile); err != nil {
|
||
|
|
engine.Delete(storageKey)
|
||
|
|
return nil, fmt.Errorf("创建文件记录失败: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
s.userRepo.UpdateStorageUsed(ownerID, size)
|
||
|
|
return s.toFileVO(newFile), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetByID 根据ID获取文件
|
||
|
|
func (s *FileService) GetByID(id uint64, ownerID uint64) (*dto.FileVO, error) {
|
||
|
|
file, err := s.fileRepo.FindByID(id)
|
||
|
|
if err != nil {
|
||
|
|
return nil, errors.New("文件不存在")
|
||
|
|
}
|
||
|
|
if file.OwnerID != ownerID {
|
||
|
|
return nil, errors.New("无权访问此文件")
|
||
|
|
}
|
||
|
|
return s.toFileVO(file), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Download 下载文件(检查归属)
|
||
|
|
func (s *FileService) Download(id uint64, ownerID uint64) (io.ReadCloser, *model.File, error) {
|
||
|
|
file, err := s.fileRepo.FindByID(id)
|
||
|
|
if err != nil {
|
||
|
|
return nil, nil, errors.New("文件不存在")
|
||
|
|
}
|
||
|
|
if file.OwnerID != ownerID {
|
||
|
|
return nil, nil, errors.New("无权下载此文件")
|
||
|
|
}
|
||
|
|
return s.downloadFile(file, id)
|
||
|
|
}
|
||
|
|
|
||
|
|
// DownloadByFileID 下载文件(不检查归属, 用于分享下载)
|
||
|
|
func (s *FileService) DownloadByFileID(fileID uint64) (io.ReadCloser, *model.File, error) {
|
||
|
|
file, err := s.fileRepo.FindByID(fileID)
|
||
|
|
if err != nil {
|
||
|
|
return nil, nil, errors.New("文件不存在")
|
||
|
|
}
|
||
|
|
return s.downloadFile(file, fileID)
|
||
|
|
}
|
||
|
|
|
||
|
|
// downloadFile 内部下载实现
|
||
|
|
func (s *FileService) downloadFile(file *model.File, id uint64) (io.ReadCloser, *model.File, error) {
|
||
|
|
engine, err := storage.GetEngineByPolicyID(file.StoragePolicyID)
|
||
|
|
if err != nil {
|
||
|
|
return nil, nil, fmt.Errorf("获取存储引擎失败: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
reader, err := engine.Download(file.StorageKey)
|
||
|
|
if err != nil {
|
||
|
|
return nil, nil, fmt.Errorf("读取文件失败: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
s.fileRepo.IncrementDownloadCount(id)
|
||
|
|
return reader, file, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Rename 重命名文件
|
||
|
|
// Rename 重命名文件
|
||
|
|
// version: 乐观锁版本号, 0=不检查版本
|
||
|
|
func (s *FileService) Rename(id uint64, newName string, ownerID uint64, version int) error {
|
||
|
|
file, err := s.fileRepo.FindByID(id)
|
||
|
|
if err != nil {
|
||
|
|
return errors.New("文件不存在")
|
||
|
|
}
|
||
|
|
if file.OwnerID != ownerID {
|
||
|
|
return errors.New("无权操作此文件")
|
||
|
|
}
|
||
|
|
|
||
|
|
// 乐观锁检查
|
||
|
|
if version > 0 && file.Version != version {
|
||
|
|
return fmt.Errorf("文件已被其他人修改, 当前版本: %d, 请刷新后重试", file.Version)
|
||
|
|
}
|
||
|
|
|
||
|
|
file.Name = newName
|
||
|
|
file.Extension = utils.GetExtension(newName)
|
||
|
|
file.Version++
|
||
|
|
|
||
|
|
result := config.DB.Model(&model.File{}).
|
||
|
|
Where("id = ? AND version = ?", id, file.Version-1).
|
||
|
|
Updates(map[string]interface{}{
|
||
|
|
"name": file.Name,
|
||
|
|
"extension": file.Extension,
|
||
|
|
"version": file.Version,
|
||
|
|
})
|
||
|
|
|
||
|
|
if result.RowsAffected == 0 {
|
||
|
|
return fmt.Errorf("文件已被其他人修改, 请刷新后重试")
|
||
|
|
}
|
||
|
|
return result.Error
|
||
|
|
}
|
||
|
|
|
||
|
|
// Move 移动文件到目标文件夹
|
||
|
|
func (s *FileService) Move(id uint64, targetFolderID uint64, ownerID uint64) error {
|
||
|
|
file, err := s.fileRepo.FindByID(id)
|
||
|
|
if err != nil {
|
||
|
|
return errors.New("文件不存在")
|
||
|
|
}
|
||
|
|
if file.OwnerID != ownerID {
|
||
|
|
return errors.New("无权操作此文件")
|
||
|
|
}
|
||
|
|
|
||
|
|
if targetFolderID > 0 {
|
||
|
|
folder, err := s.folderRepo.FindByID(targetFolderID)
|
||
|
|
if err != nil {
|
||
|
|
return errors.New("目标文件夹不存在")
|
||
|
|
}
|
||
|
|
if folder.OwnerID != ownerID {
|
||
|
|
return errors.New("无权移动到此文件夹")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
file.FolderID = targetFolderID
|
||
|
|
return s.fileRepo.Update(file)
|
||
|
|
}
|
||
|
|
|
||
|
|
// SoftDelete 移入回收站
|
||
|
|
func (s *FileService) SoftDelete(id uint64, ownerID uint64) error {
|
||
|
|
file, err := s.fileRepo.FindByID(id)
|
||
|
|
if err != nil {
|
||
|
|
return errors.New("文件不存在")
|
||
|
|
}
|
||
|
|
if file.OwnerID != ownerID {
|
||
|
|
return errors.New("无权操作此文件")
|
||
|
|
}
|
||
|
|
|
||
|
|
return s.fileRepo.SoftDelete(id)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Restore 从回收站恢复
|
||
|
|
func (s *FileService) Restore(id uint64, ownerID uint64) error {
|
||
|
|
file, err := s.fileRepo.FindByID(id)
|
||
|
|
if err != nil {
|
||
|
|
return errors.New("文件不存在")
|
||
|
|
}
|
||
|
|
if file.OwnerID != ownerID {
|
||
|
|
return errors.New("无权操作此文件")
|
||
|
|
}
|
||
|
|
|
||
|
|
return s.fileRepo.Restore(id)
|
||
|
|
}
|
||
|
|
|
||
|
|
// HardDelete 永久删除(同时删除存储文件)
|
||
|
|
func (s *FileService) HardDelete(id uint64, ownerID uint64) error {
|
||
|
|
file, err := s.fileRepo.FindByID(id)
|
||
|
|
if err != nil {
|
||
|
|
return errors.New("文件不存在")
|
||
|
|
}
|
||
|
|
if file.OwnerID != ownerID {
|
||
|
|
return errors.New("无权操作此文件")
|
||
|
|
}
|
||
|
|
|
||
|
|
// 根据文件的存储策略获取引擎并删除
|
||
|
|
engine, err := storage.GetEngineByPolicyID(file.StoragePolicyID)
|
||
|
|
if err == nil && engine != nil {
|
||
|
|
engine.Delete(file.StorageKey)
|
||
|
|
}
|
||
|
|
|
||
|
|
s.userRepo.UpdateStorageUsed(ownerID, -file.Size)
|
||
|
|
return s.fileRepo.HardDelete(id)
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetDownloadURL 生成文件下载链接
|
||
|
|
func (s *FileService) GetDownloadURL(id uint64) (string, error) {
|
||
|
|
file, err := s.fileRepo.FindByID(id)
|
||
|
|
if err != nil {
|
||
|
|
return "", errors.New("文件不存在")
|
||
|
|
}
|
||
|
|
return s.generateFileURL(file, "download")
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetPreviewURL 生成文件预览链接
|
||
|
|
func (s *FileService) GetPreviewURL(id uint64) (string, error) {
|
||
|
|
file, err := s.fileRepo.FindByID(id)
|
||
|
|
if err != nil {
|
||
|
|
return "", errors.New("文件不存在")
|
||
|
|
}
|
||
|
|
return s.generateFileURL(file, "preview")
|
||
|
|
}
|
||
|
|
|
||
|
|
// generateFileURL 统一生成文件URL
|
||
|
|
func (s *FileService) generateFileURL(file *model.File, urlType string) (string, error) {
|
||
|
|
engine, err := storage.GetEngineByPolicyID(file.StoragePolicyID)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Sprintf("/api/file/%d/%s", file.ID, urlType), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// 本地存储: 使用CDN地址
|
||
|
|
if localEngine, ok := engine.(*storage.LocalEngine); ok {
|
||
|
|
if localEngine.CDNHost != "" {
|
||
|
|
return localEngine.GetURL(file.StorageKey), nil
|
||
|
|
}
|
||
|
|
return fmt.Sprintf("/api/file/%d/%s", file.ID, urlType), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// MinIO存储: 生成预签名URL并替换为Nginx代理地址
|
||
|
|
if minioEngine, ok := engine.(*storage.MinIOEngine); ok {
|
||
|
|
presignedURL, err := minioEngine.GetPresignedURL(file.StorageKey, 24*time.Hour)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Sprintf("/api/file/%d/%s", file.ID, urlType), nil
|
||
|
|
}
|
||
|
|
// 替换为Nginx代理地址
|
||
|
|
policy := getStoragePolicyConfig(file.StoragePolicyID)
|
||
|
|
if policy != nil && policy.NginxEndpoint != "" {
|
||
|
|
presignedURL = replaceMinIOEndpoint(presignedURL, minioEngine.Endpoint, policy.NginxEndpoint)
|
||
|
|
}
|
||
|
|
return presignedURL, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
return fmt.Sprintf("/api/file/%d/%s", file.ID, urlType), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// getStoragePolicyConfig 获取存储策略配置
|
||
|
|
func getStoragePolicyConfig(policyID uint64) *model.PolicyConfig {
|
||
|
|
var policy model.StoragePolicy
|
||
|
|
if err := config.DB.First(&policy, policyID).Error; err != nil {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
return &policy.Config
|
||
|
|
}
|
||
|
|
|
||
|
|
// replaceMinIOEndpoint 替换URL中的MinIO地址为Nginx代理地址
|
||
|
|
func replaceMinIOEndpoint(url, minioEndpoint, nginxEndpoint string) string {
|
||
|
|
oldHTTP := "http://" + minioEndpoint
|
||
|
|
if len(url) > len(oldHTTP) && url[:len(oldHTTP)] == oldHTTP {
|
||
|
|
return nginxEndpoint + url[len(oldHTTP):]
|
||
|
|
}
|
||
|
|
oldHTTPS := "https://" + minioEndpoint
|
||
|
|
if len(url) > len(oldHTTPS) && url[:len(oldHTTPS)] == oldHTTPS {
|
||
|
|
return nginxEndpoint + url[len(oldHTTPS):]
|
||
|
|
}
|
||
|
|
return url
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetByKey 根据fileKey(storageKey)查询文件信息
|
||
|
|
func (s *FileService) GetByKey(storageKey string) (*dto.FileVO, error) {
|
||
|
|
var file model.File
|
||
|
|
err := config.DB.Where("storage_key = ?", storageKey).First(&file).Error
|
||
|
|
if err != nil {
|
||
|
|
return nil, errors.New("文件不存在")
|
||
|
|
}
|
||
|
|
return s.toFileVO(&file), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Search 搜索文件(基于权限: 自己的 + 被授权的)
|
||
|
|
func (s *FileService) Search(userID uint64, keyword string) ([]dto.FileVO, error) {
|
||
|
|
roleIDs := s.getUserRoleIDs(userID)
|
||
|
|
files, err := s.fileRepo.SearchByName(userID, roleIDs, keyword)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
vos := make([]dto.FileVO, 0, len(files))
|
||
|
|
for _, f := range files {
|
||
|
|
vo := s.toFileVO(&f)
|
||
|
|
vos = append(vos, *vo)
|
||
|
|
}
|
||
|
|
return vos, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// SearchByType 按文件类型搜索
|
||
|
|
func (s *FileService) SearchByType(ownerID uint64, extensions []string) ([]dto.FileVO, error) {
|
||
|
|
files, err := s.fileRepo.SearchByType(ownerID, extensions)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
vos := make([]dto.FileVO, 0, len(files))
|
||
|
|
for _, f := range files {
|
||
|
|
vos = append(vos, *s.toFileVO(&f))
|
||
|
|
}
|
||
|
|
return vos, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// ListTrashed 查询回收站
|
||
|
|
func (s *FileService) ListTrashed(ownerID uint64) ([]dto.FileVO, error) {
|
||
|
|
files, err := s.fileRepo.ListTrashed(ownerID)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
vos := make([]dto.FileVO, 0, len(files))
|
||
|
|
for _, f := range files {
|
||
|
|
vos = append(vos, *s.toFileVO(&f))
|
||
|
|
}
|
||
|
|
return vos, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetDownloadPath 获取文件的本地存储完整路径(用于静态文件服务)
|
||
|
|
func (s *FileService) GetDownloadPath(storageKey string) string {
|
||
|
|
return filepath.Join(config.AppConfig.Storage.LocalBasePath, storageKey)
|
||
|
|
}
|
||
|
|
|
||
|
|
// toFileVO 转换为视图对象
|
||
|
|
func (s *FileService) toFileVO(file *model.File) *dto.FileVO {
|
||
|
|
vo := &dto.FileVO{
|
||
|
|
ID: file.ID,
|
||
|
|
Name: file.Name,
|
||
|
|
Extension: file.Extension,
|
||
|
|
FolderID: file.FolderID,
|
||
|
|
Size: file.Size,
|
||
|
|
StorageKey: file.StorageKey,
|
||
|
|
StoragePolicyID: file.StoragePolicyID,
|
||
|
|
MimeType: file.MimeType,
|
||
|
|
IsFavorite: file.IsFavorite,
|
||
|
|
DownloadCount: file.DownloadCount,
|
||
|
|
Version: file.Version,
|
||
|
|
CreatedAt: file.CreatedAt,
|
||
|
|
UpdatedAt: file.UpdatedAt,
|
||
|
|
}
|
||
|
|
|
||
|
|
// 生成下载/预览链接
|
||
|
|
if file.StorageKey != "" {
|
||
|
|
downloadURL, _ := s.generateFileURL(file, "download")
|
||
|
|
previewURL, _ := s.generateFileURL(file, "preview")
|
||
|
|
vo.DownloadURL = downloadURL
|
||
|
|
vo.PreviewURL = previewURL
|
||
|
|
}
|
||
|
|
|
||
|
|
return vo
|
||
|
|
}
|
||
|
|
|
||
|
|
// getUserRoleIDs 获取用户的角色ID列表
|
||
|
|
func (s *FileService) getUserRoleIDs(userID uint64) []uint64 {
|
||
|
|
var userRoles []model.UserRole
|
||
|
|
config.DB.Where("user_id = ?", userID).Find(&userRoles)
|
||
|
|
roleIDs := make([]uint64, 0, len(userRoles))
|
||
|
|
for _, ur := range userRoles {
|
||
|
|
roleIDs = append(roleIDs, ur.RoleID)
|
||
|
|
}
|
||
|
|
return roleIDs
|
||
|
|
}
|