package service import ( "errors" "fmt" "io" "mime/multipart" "os" "path/filepath" "strings" "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 -> 检查秒传 -> 存储文件 -> 保存元数据 func (s *FileService) Upload(fileHeader *multipart.FileHeader, folderID uint64, ownerID uint64, storagePolicyID uint64) (*dto.FileVO, error) { // 校验文件大小 maxSize := s.getMaxFileSize(ownerID) if maxSize > 0 && fileHeader.Size > maxSize { return nil, fmt.Errorf("文件大小 %s 超过限制 %s", formatBytes(fileHeader.Size), formatBytes(maxSize)) } // 验证文件夹 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{ ID: utils.GenID(), 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{ ID: utils.GenID(), 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 !s.canAccessFile(file.ID, 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 !s.canAccessFile(file.ID, 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 !s.canAccessFile(file.ID, 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 !s.canAccessFile(file.ID, 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 !s.canAccessFile(file.ID, 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 !s.canAccessFile(file.ID, 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 !s.canAccessFile(file.ID, 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, token ...string) (string, error) { file, err := s.fileRepo.FindByID(id) if err != nil { return "", errors.New("文件不存在") } return GenerateFileURL(file, "download", token...) } // GetPreviewURL 生成文件预览链接 func (s *FileService) GetPreviewURL(id uint64, token ...string) (string, error) { file, err := s.fileRepo.FindByID(id) if err != nil { return "", errors.New("文件不存在") } return GenerateFileURL(file, "preview", token...) } // generateFileURL 统一生成文件URL func (s *FileService) generateFileURL(file *model.File, urlType string) (string, error) { return GenerateFileURL(file, urlType) } // GenerateFileURL 统一生成文件URL(包级别, 供其他service调用) // download: 返回直接下载链接 // preview: 返回在线预览链接(Content-Disposition: inline) // token: 可选, 用于本地存储时拼接到URL中供浏览器直接访问 func GenerateFileURL(file *model.File, urlType string, token ...string) (string, error) { var tk string if len(token) > 0 { tk = token[0] } // 根据存储策略的实际类型决定URL生成方式 policyType := getStoragePolicyType(file.StoragePolicyID) switch policyType { case "minio": engine, err := storage.GetEngineByPolicyID(file.StoragePolicyID) if err != nil { // MinIO不可用, 回退到API代理 return buildAPIURL(file.ID, urlType, tk), nil } minioEngine := engine.(*storage.MinIOEngine) var presignedURL string var genErr error if urlType == "preview" { presignedURL, genErr = minioEngine.GetPresignedPreviewURL(file.StorageKey, file.MimeType, 24*time.Hour) } else { presignedURL, genErr = minioEngine.GetPresignedURL(file.StorageKey, 24*time.Hour) } if genErr != nil { return buildAPIURL(file.ID, urlType, tk), nil } policy := getStoragePolicyConfig(file.StoragePolicyID) if policy != nil && policy.NginxEndpoint != "" { presignedURL = replaceMinIOEndpoint(presignedURL, minioEngine.Endpoint, policy.NginxEndpoint) } return presignedURL, nil case "local": // 本地存储: 有CDN就用CDN, 否则用API路径 cfg := config.AppConfig.Storage if cfg.CDNHost != "" { prefix := strings.TrimRight(cfg.CDNPathPrefix, "/") key := strings.TrimLeft(file.StorageKey, "/") return fmt.Sprintf("%s%s/%s", cfg.CDNHost, prefix, key), nil } return buildAPIURL(file.ID, urlType, tk), nil default: // 未知策略或策略不存在, 回退到API代理 return buildAPIURL(file.ID, urlType, tk), nil } } // getStoragePolicyType 获取存储策略类型 func getStoragePolicyType(policyID uint64) string { var policy model.StoragePolicy if config.DB.Select("type").First(&policy, policyID).Error != nil { return "" } return policy.Type } // buildAPIURL 构建完整的API文件访问地址 func buildAPIURL(fileID uint64, urlType string, token ...string) string { publicURL := config.AppConfig.Server.PublicURL if publicURL == "" { publicURL = fmt.Sprintf("http://localhost:%d", config.AppConfig.Server.Port) } publicURL = strings.TrimRight(publicURL, "/") base := fmt.Sprintf("%s/api/file/%d/%s", publicURL, fileID, urlType) if len(token) > 0 && token[0] != "" { base += "?token=" + token[0] } return base } // 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: dto.StringID(file.ID), Name: file.Name, Extension: file.Extension, FolderID: dto.StringID(file.FolderID), OwnerID: dto.StringID(file.OwnerID), OwnerName: getUserName(file.OwnerID), 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 } // canAccessFile 检查用户是否有权操作该文件 // 规则: owner → 有权限 | admin → 有权限 | fs_file_permission中被授权 → 有权限 func (s *FileService) canAccessFile(fileID, fileOwnerID, userID uint64) bool { // 1. 文件所有者 if fileOwnerID == userID { return true } // 2. 管理员 if s.isAdmin(userID) { return true } // 3. 文件级授权 var count int64 config.DB.Model(&model.FilePermission{}). Where("file_id = ? AND (user_id = ? OR role_id IN (?))", fileID, userID, config.DB.Model(&model.UserRole{}).Select("role_id").Where("user_id = ?", userID), ).Count(&count) return count > 0 } // isAdmin 检查用户是否为管理员 func (s *FileService) isAdmin(userID uint64) bool { var userRoles []model.UserRole config.DB.Where("user_id = ?", userID).Find(&userRoles) for _, ur := range userRoles { var role model.Role if config.DB.First(&role, ur.RoleID).Error == nil && role.Name == "admin" { return true } } return false } // getMaxFileSize 根据用户角色返回最大文件大小(字节), 0=不限制 func (s *FileService) getMaxFileSize(userID uint64) int64 { 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) } var roles []model.Role if len(roleIDs) > 0 { config.DB.Where("id IN ?", roleIDs).Find(&roles) } for _, r := range roles { switch r.Name { case "admin": return 0 // 不限制 case "staff": return 500 * 1024 * 1024 // 500MB case "user": return 100 * 1024 * 1024 // 100MB case "guest": return 0 // 不允许上传 } } return 100 * 1024 * 1024 // 默认100MB } // formatBytes 格式化字节数 func formatBytes(bytes int64) string { const ( KB = 1024 MB = KB * 1024 GB = MB * 1024 ) switch { case bytes >= GB: return fmt.Sprintf("%.1fGB", float64(bytes)/float64(GB)) case bytes >= MB: return fmt.Sprintf("%.1fMB", float64(bytes)/float64(MB)) case bytes >= KB: return fmt.Sprintf("%.1fKB", float64(bytes)/float64(KB)) default: return fmt.Sprintf("%dB", bytes) } } // ========== 文件权限管理 ========== // GrantPermission 授权文件权限给用户或角色 func (s *FileService) GrantPermission(fileID uint64, grantBy uint64, req *dto.GrantPermissionRequest) error { // 验证文件存在且属于授权者 file, err := s.fileRepo.FindByID(fileID) if err != nil { return errors.New("文件不存在") } if !s.canAccessFile(file.ID, file.OwnerID, grantBy) { return errors.New("只有文件所有者或管理员才能授权") } // user_id 和 role_id 二选一 userID := uint64(req.UserID) roleID := uint64(req.RoleID) if userID == 0 && roleID == 0 { return errors.New("请指定 user_id 或 role_id") } if userID > 0 && roleID > 0 { return errors.New("user_id 和 role_id 只能选一个") } // 验证权限级别 switch req.PermLevel { case "read", "write", "admin": default: return errors.New("perm_level 必须是 read / write / admin") } // 去重: 同一文件+同一用户/角色只保留一条 var existing model.FilePermission query := config.DB.Where("file_id = ?", fileID) if userID > 0 { query = query.Where("user_id = ?", userID) } else { query = query.Where("role_id = ?", roleID) } if query.First(&existing).Error == nil { // 已存在则更新 existing.PermLevel = req.PermLevel existing.GrantBy = grantBy return config.DB.Save(&existing).Error } return config.DB.Create(&model.FilePermission{ FileID: fileID, UserID: userID, RoleID: roleID, PermLevel: req.PermLevel, GrantBy: grantBy, }).Error } // RevokePermission 撤销文件权限 func (s *FileService) RevokePermission(fileID uint64, permID uint64, userID uint64) error { // 验证文件归属 file, err := s.fileRepo.FindByID(fileID) if err != nil { return errors.New("文件不存在") } if !s.canAccessFile(file.ID, file.OwnerID, userID) { return errors.New("只有文件所有者或管理员才能撤销权限") } result := config.DB.Where("id = ? AND file_id = ?", permID, fileID).Delete(&model.FilePermission{}) if result.RowsAffected == 0 { return errors.New("权限记录不存在") } return result.Error } // ListPermissions 查看文件的授权列表 func (s *FileService) ListPermissions(fileID uint64, userID uint64) ([]dto.FilePermissionVO, error) { file, err := s.fileRepo.FindByID(fileID) if err != nil { return nil, errors.New("文件不存在") } if !s.canAccessFile(file.ID, file.OwnerID, userID) { return nil, errors.New("只有文件所有者或管理员才能查看权限列表") } var perms []model.FilePermission config.DB.Where("file_id = ?", fileID).Find(&perms) vos := make([]dto.FilePermissionVO, 0, len(perms)) for _, p := range perms { vo := dto.FilePermissionVO{ ID: p.ID, FileID: p.FileID, UserID: p.UserID, RoleID: p.RoleID, PermLevel: p.PermLevel, GrantBy: p.GrantBy, CreatedAt: p.CreatedAt.Format("2006-01-02 15:04:05"), } // 填充用户名 if p.UserID > 0 { var user model.User if config.DB.Select("nickname", "username").First(&user, p.UserID).Error == nil { vo.UserName = user.Nickname if vo.UserName == "" { vo.UserName = user.Username } } } // 填充角色名 if p.RoleID > 0 { var role model.Role if config.DB.Select("display_name", "name").First(&role, p.RoleID).Error == nil { vo.RoleName = role.DisplayName if vo.RoleName == "" { vo.RoleName = role.Name } } } // 填充授权人 var granter model.User if config.DB.Select("nickname", "username").First(&granter, p.GrantBy).Error == nil { vo.GrantByName = granter.Nickname if vo.GrantByName == "" { vo.GrantByName = granter.Username } } vos = append(vos, vo) } return vos, nil } // getUserName 根据用户ID获取显示名称 func getUserName(userID uint64) string { var user model.User if config.DB.Select("nickname", "username").First(&user, userID).Error != nil { return "" } if user.Nickname != "" { return user.Nickname } return user.Username }