Files
SeeyonFileSystem/server/service/file_service.go

891 lines
26 KiB
Go
Raw Normal View History

2026-07-03 15:58:29 +08:00
package service
import (
"errors"
"fmt"
"io"
2026-07-18 10:52:32 +08:00
"log"
2026-07-03 15:58:29 +08:00
"mime/multipart"
"os"
"path/filepath"
2026-07-10 17:33:33 +08:00
"strings"
2026-07-03 15:58:29 +08:00
"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 上传文件
2026-07-10 17:33:33 +08:00
// 流程: 校验大小 -> 计算MD5 -> 检查秒传 -> 存储文件 -> 保存元数据
2026-07-18 10:52:32 +08:00
func (s *FileService) Upload(fileHeader *multipart.FileHeader, folderID uint64, ownerID uint64, storagePolicyID uint64, bizID string) (*dto.FileVO, error) {
2026-07-10 17:33:33 +08:00
// 校验文件大小
maxSize := s.getMaxFileSize(ownerID)
if maxSize > 0 && fileHeader.Size > maxSize {
return nil, fmt.Errorf("文件大小 %s 超过限制 %s", formatBytes(fileHeader.Size), formatBytes(maxSize))
}
2026-07-03 15:58:29 +08:00
// 验证文件夹
if folderID > 0 {
folder, err := s.folderRepo.FindByID(folderID)
if err != nil {
return nil, errors.New("目标文件夹不存在")
}
2026-07-18 10:52:32 +08:00
if !s.canAccessFile(folder.ID, folder.OwnerID, ownerID) {
2026-07-03 15:58:29 +08:00
return nil, errors.New("无权上传到此文件夹")
}
}
2026-07-18 10:52:32 +08:00
// 检查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)
}
}
2026-07-03 15:58:29 +08:00
// 确定存储策略: 优先使用指定策略, 否则根据用户分配自动确定
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()
2026-07-18 10:52:32 +08:00
// policyID 保留原值, 不覆盖, 确保文件记录正确的存储策略
2026-07-03 15:58:29 +08:00
}
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{
2026-07-10 17:33:33 +08:00
ID: utils.GenID(),
2026-07-18 10:52:32 +08:00
BizID: bizID,
2026-07-03 15:58:29 +08:00
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{
2026-07-10 17:33:33 +08:00
ID: utils.GenID(),
2026-07-18 10:52:32 +08:00
BizID: bizID,
2026-07-03 15:58:29 +08:00
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)
2026-07-18 10:52:32 +08:00
// 异步检测视频编码, H.265自动转码为H.264
if ext == "mp4" || ext == "mkv" || ext == "avi" || ext == "mov" {
go s.asyncTranscode(engine, newFile)
}
2026-07-03 15:58:29 +08:00
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("文件不存在")
}
2026-07-10 17:33:33 +08:00
if !s.canAccessFile(file.ID, file.OwnerID, ownerID) {
2026-07-03 15:58:29 +08:00
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("文件不存在")
}
2026-07-10 17:33:33 +08:00
if !s.canAccessFile(file.ID, file.OwnerID, ownerID) {
2026-07-03 15:58:29 +08:00
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("文件不存在")
}
2026-07-10 17:33:33 +08:00
if !s.canAccessFile(file.ID, file.OwnerID, ownerID) {
2026-07-03 15:58:29 +08:00
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("文件不存在")
}
2026-07-10 17:33:33 +08:00
if !s.canAccessFile(file.ID, file.OwnerID, ownerID) {
2026-07-03 15:58:29 +08:00
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("文件不存在")
}
2026-07-10 17:33:33 +08:00
if !s.canAccessFile(file.ID, file.OwnerID, ownerID) {
2026-07-03 15:58:29 +08:00
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("文件不存在")
}
2026-07-10 17:33:33 +08:00
if !s.canAccessFile(file.ID, file.OwnerID, ownerID) {
2026-07-03 15:58:29 +08:00
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("文件不存在")
}
2026-07-10 17:33:33 +08:00
if !s.canAccessFile(file.ID, file.OwnerID, ownerID) {
2026-07-03 15:58:29 +08:00
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 生成文件下载链接
2026-07-10 17:33:33 +08:00
func (s *FileService) GetDownloadURL(id uint64, token ...string) (string, error) {
2026-07-03 15:58:29 +08:00
file, err := s.fileRepo.FindByID(id)
if err != nil {
return "", errors.New("文件不存在")
}
2026-07-10 17:33:33 +08:00
return GenerateFileURL(file, "download", token...)
2026-07-03 15:58:29 +08:00
}
// GetPreviewURL 生成文件预览链接
2026-07-10 17:33:33 +08:00
func (s *FileService) GetPreviewURL(id uint64, token ...string) (string, error) {
2026-07-03 15:58:29 +08:00
file, err := s.fileRepo.FindByID(id)
if err != nil {
return "", errors.New("文件不存在")
}
2026-07-10 17:33:33 +08:00
return GenerateFileURL(file, "preview", token...)
2026-07-03 15:58:29 +08:00
}
// generateFileURL 统一生成文件URL
func (s *FileService) generateFileURL(file *model.File, urlType string) (string, error) {
2026-07-10 17:33:33 +08:00
return GenerateFileURL(file, urlType)
}
2026-07-03 15:58:29 +08:00
2026-07-10 17:33:33 +08:00
// 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]
2026-07-03 15:58:29 +08:00
}
2026-07-10 17:33:33 +08:00
// 根据存储策略的实际类型决定URL生成方式
policyType := getStoragePolicyType(file.StoragePolicyID)
2026-07-18 10:52:32 +08:00
log.Printf("[URL生成] 文件ID=%d, storage_policy_id=%d, 策略类型=%s", file.ID, file.StoragePolicyID, policyType)
2026-07-10 17:33:33 +08:00
switch policyType {
case "minio":
engine, err := storage.GetEngineByPolicyID(file.StoragePolicyID)
2026-07-03 15:58:29 +08:00
if err != nil {
2026-07-18 10:52:32 +08:00
log.Printf("[URL生成] MinIO引擎获取失败, 回退API代理: %v", err)
2026-07-10 17:33:33 +08:00
return buildAPIURL(file.ID, urlType, tk), nil
}
minioEngine := engine.(*storage.MinIOEngine)
2026-07-18 10:52:32 +08:00
var fileURL string
2026-07-10 17:33:33 +08:00
var genErr error
if urlType == "preview" {
2026-07-18 10:52:32 +08:00
fileURL, genErr = minioEngine.GetPresignedPreviewURL(file.StorageKey, file.MimeType, 24*time.Hour)
2026-07-10 17:33:33 +08:00
} else {
2026-07-18 10:52:32 +08:00
fileURL, genErr = minioEngine.GetPresignedURL(file.StorageKey, 24*time.Hour)
2026-07-10 17:33:33 +08:00
}
if genErr != nil {
2026-07-18 10:52:32 +08:00
// 预签名失败(如匿名凭证/公开桶), 用直接URL
log.Printf("[URL生成] 预签名失败, 使用直接URL: %v", genErr)
fileURL = minioEngine.GetURL(file.StorageKey)
// 预览模式追加inline头参数, 避免触发下载
if urlType == "preview" {
fileURL += "?response-content-disposition=inline"
}
2026-07-03 15:58:29 +08:00
}
policy := getStoragePolicyConfig(file.StoragePolicyID)
if policy != nil && policy.NginxEndpoint != "" {
2026-07-18 10:52:32 +08:00
fileURL = replaceMinIOEndpoint(fileURL, minioEngine.Endpoint, policy.NginxEndpoint)
2026-07-03 15:58:29 +08:00
}
2026-07-18 10:52:32 +08:00
return fileURL, nil
2026-07-10 17:33:33 +08:00
case "local":
// 本地存储: 有CDN就用CDN, 否则用API路径
cfg := config.AppConfig.Storage
if cfg.CDNHost != "" {
prefix := strings.TrimRight(cfg.CDNPathPrefix, "/")
2026-07-18 10:52:32 +08:00
// 策略分类子目录(hot/cold/public/slave)
category := getStoragePolicyCategory(file.StoragePolicyID)
if category != "" {
prefix = prefix + "/" + category
}
2026-07-10 17:33:33 +08:00
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
2026-07-03 15:58:29 +08:00
}
2026-07-10 17:33:33 +08:00
}
2026-07-03 15:58:29 +08:00
2026-07-10 17:33:33 +08:00
// getStoragePolicyType 获取存储策略类型
func getStoragePolicyType(policyID uint64) string {
var policy model.StoragePolicy
if config.DB.Select("type").First(&policy, policyID).Error != nil {
return ""
}
return policy.Type
}
2026-07-18 10:52:32 +08:00
// 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
}
2026-07-10 17:33:33 +08:00
// 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
2026-07-03 15:58:29 +08:00
}
// 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
}
2026-07-18 10:52:32 +08:00
// 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 查询回收站(管理员看全部, 普通用户看自己的)
2026-07-03 15:58:29 +08:00
func (s *FileService) ListTrashed(ownerID uint64) ([]dto.FileVO, error) {
2026-07-18 10:52:32 +08:00
isAdmin := s.isAdmin(ownerID)
files, err := s.fileRepo.ListTrashed(ownerID, isAdmin)
2026-07-03 15:58:29 +08:00
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{
2026-07-10 17:33:33 +08:00
ID: dto.StringID(file.ID),
2026-07-18 10:52:32 +08:00
BizID: file.BizID,
2026-07-03 15:58:29 +08:00
Name: file.Name,
Extension: file.Extension,
2026-07-10 17:33:33 +08:00
FolderID: dto.StringID(file.FolderID),
OwnerID: dto.StringID(file.OwnerID),
OwnerName: getUserName(file.OwnerID),
2026-07-03 15:58:29 +08:00
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
}
2026-07-10 17:33:33 +08:00
// 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{
2026-07-18 10:52:32 +08:00
ID: utils.GenID(),
2026-07-10 17:33:33 +08:00
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
}