初始化2
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"seeyon-filesystem/config"
|
||||
"seeyon-filesystem/dto"
|
||||
"seeyon-filesystem/model"
|
||||
"seeyon-filesystem/repository"
|
||||
@@ -77,6 +78,12 @@ func (s *AdminService) CreateUser(req *dto.UserCreateRequest) error {
|
||||
// 分配角色
|
||||
if len(req.RoleIDs) > 0 {
|
||||
s.userRoleRepo.SetUserRoles(user.ID, req.RoleIDs)
|
||||
} else {
|
||||
// 未指定角色时, 自动分配默认角色(user)
|
||||
var defaultRole model.Role
|
||||
if config.DB.Where("name = ?", "user").First(&defaultRole).Error == nil {
|
||||
config.DB.Create(&model.UserRole{UserID: user.ID, RoleID: defaultRole.ID})
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -25,8 +25,7 @@ func NewAuthService() *AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
// Login 用户登录
|
||||
// 验证用户名密码, 生成JWT Token
|
||||
// Login 用户登录(用户名+密码)
|
||||
func (s *AuthService) Login(req *dto.LoginRequest) (*dto.LoginResponse, error) {
|
||||
// 查找用户
|
||||
user, err := s.userRepo.FindByUsername(req.Username)
|
||||
@@ -68,6 +67,64 @@ func (s *AuthService) Login(req *dto.LoginRequest) (*dto.LoginResponse, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// validateAppLogin 校验 appId 登录
|
||||
func (s *AuthService) validateAppLogin(appID string, userID uint64) error {
|
||||
// 1. appId 是否存在且启用
|
||||
var app model.App
|
||||
if err := config.DB.Where("app_id = ? AND status = 1", appID).First(&app).Error; err != nil {
|
||||
return errors.New("应用ID无效或已禁用")
|
||||
}
|
||||
// 2. 用户是否已绑定该应用
|
||||
var count int64
|
||||
config.DB.Model(&model.AppUser{}).Where("app_id = ? AND user_id = ?", app.ID, userID).Count(&count)
|
||||
if count == 0 {
|
||||
return errors.New("用户未授权此应用")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AppLogin 应用登录(用户名+appId)
|
||||
func (s *AuthService) AppLogin(req *dto.AppLoginRequest) (*dto.LoginResponse, error) {
|
||||
// 查找用户
|
||||
user, err := s.userRepo.FindByUsername(req.Username)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("用户名或密码错误")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 检查用户状态
|
||||
if user.Status != 1 {
|
||||
return nil, errors.New("账号已被禁用")
|
||||
}
|
||||
|
||||
// 校验 appId
|
||||
if err := s.validateAppLogin(req.AppID, user.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 生成JWT Token
|
||||
token, err := utils.GenerateToken(
|
||||
user.ID,
|
||||
user.Username,
|
||||
user.Role,
|
||||
config.AppConfig.JWT.Secret,
|
||||
config.AppConfig.JWT.Expiration,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, errors.New("生成Token失败")
|
||||
}
|
||||
|
||||
return &dto.LoginResponse{
|
||||
Token: token,
|
||||
Username: user.Username,
|
||||
Nickname: user.Nickname,
|
||||
Role: user.Role,
|
||||
MustChangePwd: user.MustChangePwd == 1,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Register 用户注册
|
||||
func (s *AuthService) Register(req *dto.RegisterRequest) error {
|
||||
existing, _ := s.userRepo.FindByUsername(req.Username)
|
||||
@@ -89,7 +146,17 @@ func (s *AuthService) Register(req *dto.RegisterRequest) error {
|
||||
Status: 1,
|
||||
}
|
||||
|
||||
return s.userRepo.Create(user)
|
||||
if err := s.userRepo.Create(user); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 分配默认角色(user), 使存储策略分配生效
|
||||
var role model.Role
|
||||
if config.DB.Where("name = ?", "user").First(&role).Error == nil {
|
||||
config.DB.Create(&model.UserRole{UserID: user.ID, RoleID: role.ID})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChangePassword 修改密码
|
||||
|
||||
@@ -197,23 +197,25 @@ func (s *BackupService) doBackup(policy *model.BackupPolicy, log *model.BackupLo
|
||||
s.cleanOldBackups(policy)
|
||||
}
|
||||
|
||||
// backupDatabase 备份数据库
|
||||
// backupDatabase 备份数据库(根据数据库类型选择工具)
|
||||
func (s *BackupService) backupDatabase(tarWriter *tar.Writer, timestamp string) (int64, error) {
|
||||
dbCfg := config.AppConfig.Database
|
||||
dumpFile := filepath.Join(os.TempDir(), fmt.Sprintf("db_dump_%s.sql", timestamp))
|
||||
|
||||
mysqldumpPath := s.findTool("mysqldump", config.AppConfig.Backup.MysqlDumpPath)
|
||||
var dumpFile string
|
||||
var cmd *exec.Cmd
|
||||
var err error
|
||||
|
||||
cmd := exec.Command(mysqldumpPath,
|
||||
"-h", dbCfg.Host,
|
||||
"-P", fmt.Sprintf("%d", dbCfg.Port),
|
||||
"-u", dbCfg.Username,
|
||||
"-p"+dbCfg.Password,
|
||||
"--single-transaction",
|
||||
"--routines",
|
||||
"--triggers",
|
||||
dbCfg.DBName,
|
||||
)
|
||||
switch dbCfg.Type {
|
||||
case "sqlserver":
|
||||
dumpFile, cmd, err = s.buildSQLServerDump(dbCfg, timestamp)
|
||||
case "postgres":
|
||||
dumpFile, cmd, err = s.buildPostgresDump(dbCfg, timestamp)
|
||||
default:
|
||||
dumpFile, cmd, err = s.buildMySQLDump(dbCfg, timestamp)
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
outFile, err := os.Create(dumpFile)
|
||||
if err != nil {
|
||||
@@ -225,18 +227,16 @@ func (s *BackupService) backupDatabase(tarWriter *tar.Writer, timestamp string)
|
||||
if err := cmd.Run(); err != nil {
|
||||
outFile.Close()
|
||||
os.Remove(dumpFile)
|
||||
return 0, fmt.Errorf("mysqldump执行失败: %w", err)
|
||||
return 0, fmt.Errorf("数据库备份命令执行失败: %w", err)
|
||||
}
|
||||
outFile.Close()
|
||||
|
||||
// 获取文件大小
|
||||
info, _ := os.Stat(dumpFile)
|
||||
dbSize := int64(0)
|
||||
if info != nil {
|
||||
dbSize = info.Size()
|
||||
}
|
||||
|
||||
// 写入tar
|
||||
file, err := os.Open(dumpFile)
|
||||
if err != nil {
|
||||
os.Remove(dumpFile)
|
||||
@@ -264,6 +264,51 @@ func (s *BackupService) backupDatabase(tarWriter *tar.Writer, timestamp string)
|
||||
return dbSize, nil
|
||||
}
|
||||
|
||||
// buildMySQLDump 构建 mysqldump 命令
|
||||
func (s *BackupService) buildMySQLDump(dbCfg config.DatabaseConfig, timestamp string) (string, *exec.Cmd, error) {
|
||||
dumpFile := filepath.Join(os.TempDir(), fmt.Sprintf("db_dump_%s.sql", timestamp))
|
||||
mysqldumpPath := s.findTool("mysqldump", config.AppConfig.Backup.MysqlDumpPath)
|
||||
cmd := exec.Command(mysqldumpPath,
|
||||
"-h", dbCfg.Host,
|
||||
"-P", fmt.Sprintf("%d", dbCfg.Port),
|
||||
"-u", dbCfg.Username,
|
||||
"-p"+dbCfg.Password,
|
||||
"--single-transaction", "--routines", "--triggers",
|
||||
dbCfg.DBName,
|
||||
)
|
||||
return dumpFile, cmd, nil
|
||||
}
|
||||
|
||||
// buildPostgresDump 构建 pg_dump 命令
|
||||
func (s *BackupService) buildPostgresDump(dbCfg config.DatabaseConfig, timestamp string) (string, *exec.Cmd, error) {
|
||||
dumpFile := filepath.Join(os.TempDir(), fmt.Sprintf("db_dump_%s.sql", timestamp))
|
||||
pgDumpPath := s.findTool("pg_dump", config.AppConfig.Backup.PgDumpPath)
|
||||
cmd := exec.Command(pgDumpPath,
|
||||
"-h", dbCfg.Host,
|
||||
"-p", fmt.Sprintf("%d", dbCfg.Port),
|
||||
"-U", dbCfg.Username,
|
||||
"-F", "p", // plain text SQL
|
||||
"--no-owner",
|
||||
dbCfg.DBName,
|
||||
)
|
||||
cmd.Env = append(os.Environ(), "PGPASSWORD="+dbCfg.Password)
|
||||
return dumpFile, cmd, nil
|
||||
}
|
||||
|
||||
// buildSQLServerDump 构建 sqlcmd 导出命令
|
||||
func (s *BackupService) buildSQLServerDump(dbCfg config.DatabaseConfig, timestamp string) (string, *exec.Cmd, error) {
|
||||
dumpFile := filepath.Join(os.TempDir(), fmt.Sprintf("db_dump_%s.sql", timestamp))
|
||||
sqlCmdPath := s.findTool("sqlcmd", config.AppConfig.Backup.SqlCmdPath)
|
||||
cmd := exec.Command(sqlCmdPath,
|
||||
"-S", fmt.Sprintf("%s,%d", dbCfg.Host, dbCfg.Port),
|
||||
"-U", dbCfg.Username,
|
||||
"-P", dbCfg.Password,
|
||||
"-d", dbCfg.DBName,
|
||||
"-Q", fmt.Sprintf("BACKUP DATABASE [%s] TO DISK='%s' WITH FORMAT", dbCfg.DBName, dumpFile),
|
||||
)
|
||||
return dumpFile, cmd, nil
|
||||
}
|
||||
|
||||
// backupFiles 备份文件
|
||||
func (s *BackupService) backupFiles(tarWriter *tar.Writer) (int64, int, error) {
|
||||
basePath := config.AppConfig.Storage.LocalBasePath
|
||||
@@ -525,12 +570,23 @@ func (s *BackupService) extractTarGz(tarGzPath, destDir string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// restoreDatabase 从SQL文件恢复数据库
|
||||
// restoreDatabase 从SQL文件恢复数据库(根据数据库类型选择工具)
|
||||
func (s *BackupService) restoreDatabase(sqlFilePath string) error {
|
||||
dbCfg := config.AppConfig.Database
|
||||
|
||||
mysqlPath := s.findTool("mysql", config.AppConfig.Backup.MysqlPath)
|
||||
switch dbCfg.Type {
|
||||
case "postgres":
|
||||
return s.restorePostgres(dbCfg, sqlFilePath)
|
||||
case "sqlserver":
|
||||
return s.restoreSQLServer(dbCfg, sqlFilePath)
|
||||
default:
|
||||
return s.restoreMySQL(dbCfg, sqlFilePath)
|
||||
}
|
||||
}
|
||||
|
||||
// restoreMySQL mysql恢复
|
||||
func (s *BackupService) restoreMySQL(dbCfg config.DatabaseConfig, sqlFilePath string) error {
|
||||
mysqlPath := s.findTool("mysql", config.AppConfig.Backup.MysqlPath)
|
||||
cmd := exec.Command(mysqlPath,
|
||||
"-h", dbCfg.Host,
|
||||
"-P", fmt.Sprintf("%d", dbCfg.Port),
|
||||
@@ -538,16 +594,42 @@ func (s *BackupService) restoreDatabase(sqlFilePath string) error {
|
||||
"-p"+dbCfg.Password,
|
||||
dbCfg.DBName,
|
||||
)
|
||||
|
||||
sqlFile, err := os.Open(sqlFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sqlFile.Close()
|
||||
|
||||
cmd.Stdin = sqlFile
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// restorePostgres psql恢复
|
||||
func (s *BackupService) restorePostgres(dbCfg config.DatabaseConfig, sqlFilePath string) error {
|
||||
psqlPath := s.findTool("psql", config.AppConfig.Backup.PsqlPath)
|
||||
cmd := exec.Command(psqlPath,
|
||||
"-h", dbCfg.Host,
|
||||
"-p", fmt.Sprintf("%d", dbCfg.Port),
|
||||
"-U", dbCfg.Username,
|
||||
"-d", dbCfg.DBName,
|
||||
"-f", sqlFilePath,
|
||||
)
|
||||
cmd.Env = append(os.Environ(), "PGPASSWORD="+dbCfg.Password)
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// restoreSQLServer sqlcmd恢复
|
||||
func (s *BackupService) restoreSQLServer(dbCfg config.DatabaseConfig, sqlFilePath string) error {
|
||||
sqlCmdPath := s.findTool("sqlcmd", config.AppConfig.Backup.SqlCmdPath)
|
||||
cmd := exec.Command(sqlCmdPath,
|
||||
"-S", fmt.Sprintf("%s,%d", dbCfg.Host, dbCfg.Port),
|
||||
"-U", dbCfg.Username,
|
||||
"-P", dbCfg.Password,
|
||||
"-d", dbCfg.DBName,
|
||||
"-i", sqlFilePath,
|
||||
)
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"seeyon-filesystem/config"
|
||||
"seeyon-filesystem/dto"
|
||||
@@ -34,6 +35,7 @@ func NewChunkUploadService() *ChunkUploadService {
|
||||
}
|
||||
|
||||
// InitUpload 初始化分片上传
|
||||
// 根据存储策略自动选择: 本地模式(服务端中转) 或 MinIO模式(客户端直传)
|
||||
func (s *ChunkUploadService) InitUpload(fileName string, fileSize int64, md5 string, folderID, ownerID, policyID uint64) (*dto.ChunkUploadInitVO, error) {
|
||||
// 秒传检查
|
||||
if md5 != "" {
|
||||
@@ -49,19 +51,28 @@ func (s *ChunkUploadService) InitUpload(fileName string, fileSize int64, md5 str
|
||||
}
|
||||
}
|
||||
|
||||
// 计算分片参数(5MB每片)
|
||||
// 计算分片参数
|
||||
chunkSize := int64(5 * 1024 * 1024)
|
||||
if fileSize < chunkSize {
|
||||
chunkSize = fileSize
|
||||
}
|
||||
totalChunks := int((fileSize + chunkSize - 1) / chunkSize)
|
||||
|
||||
// 创建临时目录
|
||||
uploadID := uuid.New().String()
|
||||
|
||||
// 生成存储路径
|
||||
ext := utils.GetExtension(fileName)
|
||||
var storageKey string
|
||||
if md5 != "" {
|
||||
storageKey = storage.GenerateStorageKey(md5, ext)
|
||||
} else {
|
||||
storageKey = storage.GenerateStorageKey(fmt.Sprintf("%d-%d", ownerID, time.Now().UnixNano()), ext)
|
||||
}
|
||||
|
||||
// 创建上传会话
|
||||
tempDir := filepath.Join(config.AppConfig.Storage.LocalBasePath, ".tmp", uploadID)
|
||||
os.MkdirAll(tempDir, 0755)
|
||||
|
||||
// 创建上传会话
|
||||
session := &model.UploadSession{
|
||||
ID: uploadID,
|
||||
FileName: fileName,
|
||||
@@ -73,22 +84,51 @@ func (s *ChunkUploadService) InitUpload(fileName string, fileSize int64, md5 str
|
||||
OwnerID: ownerID,
|
||||
PolicyID: policyID,
|
||||
Status: 1,
|
||||
TempDir: tempDir,
|
||||
}
|
||||
|
||||
if err := config.DB.Create(session).Error; err != nil {
|
||||
return nil, fmt.Errorf("创建上传会话失败: %w", err)
|
||||
TempDir: storageKey, // 复用字段存储目标storageKey
|
||||
}
|
||||
config.DB.Create(session)
|
||||
|
||||
// 检查是否为MinIO存储
|
||||
engine, err := storage.GetEngineByPolicyID(policyID)
|
||||
if err == nil {
|
||||
if minioEngine, ok := engine.(*storage.MinIOEngine); ok {
|
||||
return s.initMinIOUpload(minioEngine, session, storageKey)
|
||||
}
|
||||
}
|
||||
|
||||
// 本地存储模式
|
||||
return &dto.ChunkUploadInitVO{
|
||||
UploadID: uploadID,
|
||||
Instant: false,
|
||||
ChunkSize: chunkSize,
|
||||
Total: totalChunks,
|
||||
Mode: "local",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UploadChunk 上传单个分片
|
||||
// initMinIOUpload 初始化MinIO直传模式
|
||||
func (s *ChunkUploadService) initMinIOUpload(minioEngine *storage.MinIOEngine, session *model.UploadSession, storageKey string) (*dto.ChunkUploadInitVO, error) {
|
||||
// 为每个分片生成预签名上传URL
|
||||
parts := make([]dto.ChunkPresignURL, session.TotalChunks)
|
||||
for i := 0; i < session.TotalChunks; i++ {
|
||||
chunkKey := fmt.Sprintf("%s.chunk_%06d", storageKey, i)
|
||||
presignedURL, err := minioEngine.GetPresignedChunkURL(chunkKey, 24*time.Hour)
|
||||
if err != nil {
|
||||
presignedURL = ""
|
||||
}
|
||||
parts[i] = dto.ChunkPresignURL{Index: i, URL: presignedURL}
|
||||
}
|
||||
|
||||
return &dto.ChunkUploadInitVO{
|
||||
UploadID: session.ID,
|
||||
ChunkSize: session.ChunkSize,
|
||||
Total: session.TotalChunks,
|
||||
Mode: "minio",
|
||||
Parts: parts,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UploadChunk 上传单个分片(本地模式)
|
||||
func (s *ChunkUploadService) UploadChunk(uploadID string, index int, reader io.Reader, checksum string) (*dto.ChunkUploadChunkVO, error) {
|
||||
var session model.UploadSession
|
||||
if err := config.DB.First(&session, uploadID).Error; err != nil {
|
||||
@@ -101,14 +141,12 @@ func (s *ChunkUploadService) UploadChunk(uploadID string, index int, reader io.R
|
||||
return nil, errors.New("分片索引无效")
|
||||
}
|
||||
|
||||
// 检查是否已上传
|
||||
uploaded := parseChunkIndices(session.UploadedChunks)
|
||||
if uploaded[index] {
|
||||
return &dto.ChunkUploadChunkVO{Index: index, Status: "exists"}, nil
|
||||
}
|
||||
|
||||
// 写入临时文件
|
||||
chunkPath := filepath.Join(session.TempDir, fmt.Sprintf("chunk_%06d", index))
|
||||
chunkPath := filepath.Join(config.AppConfig.Storage.LocalBasePath, ".tmp", uploadID, fmt.Sprintf("chunk_%06d", index))
|
||||
dst, err := os.Create(chunkPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建分片文件失败: %w", err)
|
||||
@@ -123,14 +161,12 @@ func (s *ChunkUploadService) UploadChunk(uploadID string, index int, reader io.R
|
||||
return nil, fmt.Errorf("写入分片失败: %w", err)
|
||||
}
|
||||
|
||||
// checksum校验
|
||||
actualChecksum := hex.EncodeToString(hash.Sum(nil))
|
||||
if checksum != "" && checksum != actualChecksum {
|
||||
os.Remove(chunkPath)
|
||||
return nil, fmt.Errorf("分片校验失败: 期望 %s, 实际 %s", checksum, actualChecksum)
|
||||
}
|
||||
|
||||
// 更新进度
|
||||
uploaded[index] = true
|
||||
chunkStr := buildChunkIndices(uploaded)
|
||||
config.DB.Model(&session).Update("uploaded_chunks", chunkStr)
|
||||
@@ -138,6 +174,24 @@ func (s *ChunkUploadService) UploadChunk(uploadID string, index int, reader io.R
|
||||
return &dto.ChunkUploadChunkVO{Index: index, Size: size, Status: "ok"}, nil
|
||||
}
|
||||
|
||||
// UploadChunkMinIO 确认MinIO分片上传完成(客户端直传后调用)
|
||||
func (s *ChunkUploadService) UploadChunkMinIO(uploadID string, index int, etag string) (*dto.ChunkUploadChunkVO, error) {
|
||||
var session model.UploadSession
|
||||
if err := config.DB.First(&session, uploadID).Error; err != nil {
|
||||
return nil, errors.New("上传会话不存在")
|
||||
}
|
||||
if session.Status != 1 {
|
||||
return nil, errors.New("上传会话已结束")
|
||||
}
|
||||
|
||||
uploaded := parseChunkIndices(session.UploadedChunks)
|
||||
uploaded[index] = true
|
||||
chunkStr := buildChunkIndices(uploaded)
|
||||
config.DB.Model(&session).Update("uploaded_chunks", chunkStr)
|
||||
|
||||
return &dto.ChunkUploadChunkVO{Index: index, Status: "ok", ETag: etag}, nil
|
||||
}
|
||||
|
||||
// MergeChunks 合并分片完成上传
|
||||
func (s *ChunkUploadService) MergeChunks(uploadID string) (*dto.FileVO, error) {
|
||||
var session model.UploadSession
|
||||
@@ -148,7 +202,6 @@ func (s *ChunkUploadService) MergeChunks(uploadID string) (*dto.FileVO, error) {
|
||||
return nil, errors.New("上传会话已结束")
|
||||
}
|
||||
|
||||
// 检查完整性
|
||||
uploaded := parseChunkIndices(session.UploadedChunks)
|
||||
for i := 0; i < session.TotalChunks; i++ {
|
||||
if !uploaded[i] {
|
||||
@@ -156,14 +209,30 @@ func (s *ChunkUploadService) MergeChunks(uploadID string) (*dto.FileVO, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// 合并分片
|
||||
mergedPath := filepath.Join(session.TempDir, "merged")
|
||||
storageKey := session.TempDir // 目标storageKey
|
||||
|
||||
// 检测MinIO模式
|
||||
engine, _ := storage.GetEngineByPolicyID(session.PolicyID)
|
||||
if minioEngine, ok := engine.(*storage.MinIOEngine); ok {
|
||||
return s.mergeMinIO(minioEngine, &session, storageKey)
|
||||
}
|
||||
|
||||
// 本地模式: 合并分片文件
|
||||
return s.mergeLocal(&session, storageKey)
|
||||
}
|
||||
|
||||
// mergeLocal 本地模式合并
|
||||
func (s *ChunkUploadService) mergeLocal(session *model.UploadSession, storageKey string) (*dto.FileVO, error) {
|
||||
tmpDir := filepath.Join(config.AppConfig.Storage.LocalBasePath, ".tmp", session.ID)
|
||||
mergedPath := filepath.Join(tmpDir, "merged")
|
||||
|
||||
merged, err := os.Create(mergedPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建合并文件失败: %w", err)
|
||||
}
|
||||
|
||||
for i := 0; i < session.TotalChunks; i++ {
|
||||
chunkPath := filepath.Join(session.TempDir, fmt.Sprintf("chunk_%06d", i))
|
||||
chunkPath := filepath.Join(tmpDir, fmt.Sprintf("chunk_%06d", i))
|
||||
chunk, err := os.Open(chunkPath)
|
||||
if err != nil {
|
||||
merged.Close()
|
||||
@@ -174,22 +243,20 @@ func (s *ChunkUploadService) MergeChunks(uploadID string) (*dto.FileVO, error) {
|
||||
}
|
||||
merged.Close()
|
||||
|
||||
// 计算MD5
|
||||
fileMD5, _ := utils.FileMD5(mergedPath)
|
||||
fileSHA256, _ := utils.FileSHA256(mergedPath)
|
||||
|
||||
// 验证MD5
|
||||
if session.MD5 != "" && session.MD5 != fileMD5 {
|
||||
os.RemoveAll(session.TempDir)
|
||||
return nil, fmt.Errorf("文件MD5校验失败: 期望 %s, 实际 %s", session.MD5, fileMD5)
|
||||
os.RemoveAll(tmpDir)
|
||||
return nil, fmt.Errorf("MD5校验失败: 期望 %s, 实际 %s", session.MD5, fileMD5)
|
||||
}
|
||||
|
||||
// 秒传检查
|
||||
existing, _ := s.fileRepo.FindByMD5(fileMD5)
|
||||
if existing != nil {
|
||||
os.RemoveAll(session.TempDir)
|
||||
os.RemoveAll(tmpDir)
|
||||
newFile := &model.File{
|
||||
Name: session.FileName, Extension: utils.GetExtension(session.FileName),
|
||||
ID: utils.GenID(), Name: session.FileName, Extension: utils.GetExtension(session.FileName),
|
||||
FolderID: session.FolderID, OwnerID: session.OwnerID, Size: session.FileSize,
|
||||
MD5: fileMD5, StorageKey: existing.StorageKey, StoragePolicyID: existing.StoragePolicyID,
|
||||
MimeType: utils.GetMimeType(utils.GetExtension(session.FileName)), Status: 1,
|
||||
@@ -200,36 +267,60 @@ func (s *ChunkUploadService) MergeChunks(uploadID string) (*dto.FileVO, error) {
|
||||
|
||||
// 上传到存储引擎
|
||||
ext := utils.GetExtension(session.FileName)
|
||||
storageKey := storage.GenerateStorageKey(fileMD5, ext)
|
||||
engine, err := storage.GetEngineByPolicyID(session.PolicyID)
|
||||
if err != nil {
|
||||
engine = storage.GetDefaultEngine()
|
||||
}
|
||||
engine, _ := storage.GetEngineByPolicyID(session.PolicyID)
|
||||
reader, _ := os.Open(mergedPath)
|
||||
defer reader.Close()
|
||||
if err := engine.Upload(storageKey, reader, session.FileSize); err != nil {
|
||||
os.RemoveAll(session.TempDir)
|
||||
os.RemoveAll(tmpDir)
|
||||
return nil, fmt.Errorf("存储文件失败: %w", err)
|
||||
}
|
||||
|
||||
// 保存记录
|
||||
newFile := &model.File{
|
||||
Name: session.FileName, Extension: ext, FolderID: session.FolderID, OwnerID: session.OwnerID,
|
||||
ID: utils.GenID(), Name: session.FileName, Extension: ext, FolderID: session.FolderID, OwnerID: session.OwnerID,
|
||||
Size: session.FileSize, MD5: fileMD5, SHA256: fileSHA256, StorageKey: storageKey,
|
||||
StoragePolicyID: session.PolicyID, MimeType: utils.GetMimeType(ext), Status: 1,
|
||||
}
|
||||
if err := s.fileRepo.Create(newFile); err != nil {
|
||||
engine.Delete(storageKey)
|
||||
os.RemoveAll(session.TempDir)
|
||||
os.RemoveAll(tmpDir)
|
||||
return nil, fmt.Errorf("保存文件记录失败: %w", err)
|
||||
}
|
||||
|
||||
config.DB.Model(&session).Updates(map[string]interface{}{"status": 2, "file_id": newFile.ID, "md5": fileMD5, "sha256": fileSHA256})
|
||||
os.RemoveAll(session.TempDir)
|
||||
config.DB.Model(session).Updates(map[string]interface{}{"status": 2, "file_id": newFile.ID, "md5": fileMD5, "sha256": fileSHA256})
|
||||
os.RemoveAll(tmpDir)
|
||||
return fileToVO(newFile), nil
|
||||
}
|
||||
|
||||
// GetUploadProgress 查询上传进度(断点续传)
|
||||
// mergeMinIO MinIO模式合并
|
||||
func (s *ChunkUploadService) mergeMinIO(minioEngine *storage.MinIOEngine, session *model.UploadSession, storageKey string) (*dto.FileVO, error) {
|
||||
// 构建分片key列表
|
||||
chunkKeys := make([]string, session.TotalChunks)
|
||||
for i := 0; i < session.TotalChunks; i++ {
|
||||
chunkKeys[i] = fmt.Sprintf("%s.chunk_%06d", storageKey, i)
|
||||
}
|
||||
|
||||
// 合并分片
|
||||
if err := minioEngine.MergeChunks(chunkKeys, storageKey); err != nil {
|
||||
return nil, fmt.Errorf("MinIO合并失败: %w", err)
|
||||
}
|
||||
|
||||
ext := utils.GetExtension(session.FileName)
|
||||
fileMD5 := session.MD5 // 使用客户端提供的MD5
|
||||
|
||||
newFile := &model.File{
|
||||
ID: utils.GenID(), Name: session.FileName, Extension: ext, FolderID: session.FolderID, OwnerID: session.OwnerID,
|
||||
Size: session.FileSize, MD5: fileMD5, StorageKey: storageKey,
|
||||
StoragePolicyID: session.PolicyID, MimeType: utils.GetMimeType(ext), Status: 1,
|
||||
}
|
||||
if err := s.fileRepo.Create(newFile); err != nil {
|
||||
return nil, fmt.Errorf("保存文件记录失败: %w", err)
|
||||
}
|
||||
|
||||
config.DB.Model(session).Updates(map[string]interface{}{"status": 2, "file_id": newFile.ID, "md5": fileMD5})
|
||||
return fileToVO(newFile), nil
|
||||
}
|
||||
|
||||
// GetUploadProgress 查询上传进度
|
||||
func (s *ChunkUploadService) GetUploadProgress(uploadID string) (*dto.ChunkUploadProgressVO, error) {
|
||||
var session model.UploadSession
|
||||
if err := config.DB.First(&session, uploadID).Error; err != nil {
|
||||
@@ -255,7 +346,7 @@ func (s *ChunkUploadService) CancelUpload(uploadID string) error {
|
||||
if err := config.DB.First(&session, uploadID).Error; err != nil {
|
||||
return errors.New("上传会话不存在")
|
||||
}
|
||||
os.RemoveAll(session.TempDir)
|
||||
os.RemoveAll(filepath.Join(config.AppConfig.Storage.LocalBasePath, ".tmp", uploadID))
|
||||
config.DB.Model(&session).Update("status", 3)
|
||||
return nil
|
||||
}
|
||||
@@ -288,7 +379,8 @@ func buildChunkIndices(m map[int]bool) string {
|
||||
|
||||
func fileToVO(file *model.File) *dto.FileVO {
|
||||
vo := &dto.FileVO{
|
||||
ID: file.ID, Name: file.Name, Extension: file.Extension, FolderID: file.FolderID,
|
||||
ID: dto.StringID(file.ID), Name: file.Name, Extension: file.Extension, FolderID: dto.StringID(file.FolderID),
|
||||
OwnerID: dto.StringID(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,
|
||||
@@ -299,6 +391,8 @@ func fileToVO(file *model.File) *dto.FileVO {
|
||||
if localEngine, ok := engine.(*storage.LocalEngine); ok {
|
||||
vo.DownloadURL = localEngine.GetURL(file.StorageKey)
|
||||
vo.PreviewURL = localEngine.GetURL(file.StorageKey)
|
||||
} else if minioEngine, ok := engine.(*storage.MinIOEngine); ok {
|
||||
vo.PreviewURL = minioEngine.GetPreviewURL(file.StorageKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"seeyon-filesystem/config"
|
||||
@@ -36,9 +37,14 @@ func NewFileService() *FileService {
|
||||
}
|
||||
|
||||
// Upload 上传文件
|
||||
// 流程: 计算MD5 -> 检查秒传 -> 存储文件 -> 保存元数据
|
||||
// storagePolicyID: 存储策略ID, 0=使用默认策略
|
||||
// 流程: 校验大小 -> 计算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)
|
||||
@@ -99,6 +105,7 @@ func (s *FileService) Upload(fileHeader *multipart.FileHeader, folderID uint64,
|
||||
existing, _ := s.fileRepo.FindByMD5(md5)
|
||||
if existing != nil {
|
||||
newFile := &model.File{
|
||||
ID: utils.GenID(),
|
||||
Name: fileHeader.Filename,
|
||||
Extension: utils.GetExtension(fileHeader.Filename),
|
||||
FolderID: folderID,
|
||||
@@ -132,6 +139,7 @@ func (s *FileService) Upload(fileHeader *multipart.FileHeader, folderID uint64,
|
||||
}
|
||||
|
||||
newFile := &model.File{
|
||||
ID: utils.GenID(),
|
||||
Name: fileHeader.Filename,
|
||||
Extension: ext,
|
||||
FolderID: folderID,
|
||||
@@ -159,7 +167,7 @@ func (s *FileService) GetByID(id uint64, ownerID uint64) (*dto.FileVO, error) {
|
||||
if err != nil {
|
||||
return nil, errors.New("文件不存在")
|
||||
}
|
||||
if file.OwnerID != ownerID {
|
||||
if !s.canAccessFile(file.ID, file.OwnerID, ownerID) {
|
||||
return nil, errors.New("无权访问此文件")
|
||||
}
|
||||
return s.toFileVO(file), nil
|
||||
@@ -171,7 +179,7 @@ func (s *FileService) Download(id uint64, ownerID uint64) (io.ReadCloser, *model
|
||||
if err != nil {
|
||||
return nil, nil, errors.New("文件不存在")
|
||||
}
|
||||
if file.OwnerID != ownerID {
|
||||
if !s.canAccessFile(file.ID, file.OwnerID, ownerID) {
|
||||
return nil, nil, errors.New("无权下载此文件")
|
||||
}
|
||||
return s.downloadFile(file, id)
|
||||
@@ -210,7 +218,7 @@ func (s *FileService) Rename(id uint64, newName string, ownerID uint64, version
|
||||
if err != nil {
|
||||
return errors.New("文件不存在")
|
||||
}
|
||||
if file.OwnerID != ownerID {
|
||||
if !s.canAccessFile(file.ID, file.OwnerID, ownerID) {
|
||||
return errors.New("无权操作此文件")
|
||||
}
|
||||
|
||||
@@ -243,7 +251,7 @@ func (s *FileService) Move(id uint64, targetFolderID uint64, ownerID uint64) err
|
||||
if err != nil {
|
||||
return errors.New("文件不存在")
|
||||
}
|
||||
if file.OwnerID != ownerID {
|
||||
if !s.canAccessFile(file.ID, file.OwnerID, ownerID) {
|
||||
return errors.New("无权操作此文件")
|
||||
}
|
||||
|
||||
@@ -267,7 +275,7 @@ func (s *FileService) SoftDelete(id uint64, ownerID uint64) error {
|
||||
if err != nil {
|
||||
return errors.New("文件不存在")
|
||||
}
|
||||
if file.OwnerID != ownerID {
|
||||
if !s.canAccessFile(file.ID, file.OwnerID, ownerID) {
|
||||
return errors.New("无权操作此文件")
|
||||
}
|
||||
|
||||
@@ -280,7 +288,7 @@ func (s *FileService) Restore(id uint64, ownerID uint64) error {
|
||||
if err != nil {
|
||||
return errors.New("文件不存在")
|
||||
}
|
||||
if file.OwnerID != ownerID {
|
||||
if !s.canAccessFile(file.ID, file.OwnerID, ownerID) {
|
||||
return errors.New("无权操作此文件")
|
||||
}
|
||||
|
||||
@@ -293,7 +301,7 @@ func (s *FileService) HardDelete(id uint64, ownerID uint64) error {
|
||||
if err != nil {
|
||||
return errors.New("文件不存在")
|
||||
}
|
||||
if file.OwnerID != ownerID {
|
||||
if !s.canAccessFile(file.ID, file.OwnerID, ownerID) {
|
||||
return errors.New("无权操作此文件")
|
||||
}
|
||||
|
||||
@@ -308,53 +316,102 @@ func (s *FileService) HardDelete(id uint64, ownerID uint64) error {
|
||||
}
|
||||
|
||||
// GetDownloadURL 生成文件下载链接
|
||||
func (s *FileService) GetDownloadURL(id uint64) (string, error) {
|
||||
func (s *FileService) GetDownloadURL(id uint64, token ...string) (string, error) {
|
||||
file, err := s.fileRepo.FindByID(id)
|
||||
if err != nil {
|
||||
return "", errors.New("文件不存在")
|
||||
}
|
||||
return s.generateFileURL(file, "download")
|
||||
return GenerateFileURL(file, "download", token...)
|
||||
}
|
||||
|
||||
// GetPreviewURL 生成文件预览链接
|
||||
func (s *FileService) GetPreviewURL(id uint64) (string, error) {
|
||||
func (s *FileService) GetPreviewURL(id uint64, token ...string) (string, error) {
|
||||
file, err := s.fileRepo.FindByID(id)
|
||||
if err != nil {
|
||||
return "", errors.New("文件不存在")
|
||||
}
|
||||
return s.generateFileURL(file, "preview")
|
||||
return GenerateFileURL(file, "preview", token...)
|
||||
}
|
||||
|
||||
// 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
|
||||
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]
|
||||
}
|
||||
|
||||
// 本地存储: 使用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
|
||||
}
|
||||
// 根据存储策略的实际类型决定URL生成方式
|
||||
policyType := getStoragePolicyType(file.StoragePolicyID)
|
||||
|
||||
// MinIO存储: 生成预签名URL并替换为Nginx代理地址
|
||||
if minioEngine, ok := engine.(*storage.MinIOEngine); ok {
|
||||
presignedURL, err := minioEngine.GetPresignedURL(file.StorageKey, 24*time.Hour)
|
||||
switch policyType {
|
||||
case "minio":
|
||||
engine, err := storage.GetEngineByPolicyID(file.StoragePolicyID)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("/api/file/%d/%s", file.ID, urlType), 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
|
||||
}
|
||||
// 替换为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
|
||||
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 获取存储策略配置
|
||||
@@ -441,10 +498,12 @@ func (s *FileService) GetDownloadPath(storageKey string) string {
|
||||
// toFileVO 转换为视图对象
|
||||
func (s *FileService) toFileVO(file *model.File) *dto.FileVO {
|
||||
vo := &dto.FileVO{
|
||||
ID: file.ID,
|
||||
ID: dto.StringID(file.ID),
|
||||
Name: file.Name,
|
||||
Extension: file.Extension,
|
||||
FolderID: file.FolderID,
|
||||
FolderID: dto.StringID(file.FolderID),
|
||||
OwnerID: dto.StringID(file.OwnerID),
|
||||
OwnerName: getUserName(file.OwnerID),
|
||||
Size: file.Size,
|
||||
StorageKey: file.StorageKey,
|
||||
StoragePolicyID: file.StoragePolicyID,
|
||||
@@ -477,3 +536,227 @@ func (s *FileService) getUserRoleIDs(userID uint64) []uint64 {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"seeyon-filesystem/dto"
|
||||
"seeyon-filesystem/model"
|
||||
"seeyon-filesystem/repository"
|
||||
"seeyon-filesystem/utils"
|
||||
)
|
||||
|
||||
// FolderService 文件夹业务服务
|
||||
@@ -26,30 +27,35 @@ func NewFolderService() *FolderService {
|
||||
|
||||
// Create 创建文件夹
|
||||
func (s *FolderService) Create(req *dto.CreateFolderRequest, ownerID uint64) (*dto.FolderVO, error) {
|
||||
parentID := uint64(req.ParentID)
|
||||
|
||||
// 构建物化路径
|
||||
parentPath := "/"
|
||||
if req.ParentID > 0 {
|
||||
parent, err := s.folderRepo.FindByID(req.ParentID)
|
||||
if parentID > 0 {
|
||||
parent, err := s.folderRepo.FindByID(parentID)
|
||||
if err != nil {
|
||||
return nil, errors.New("父文件夹不存在")
|
||||
}
|
||||
if parent.OwnerID != ownerID {
|
||||
if !s.canAccessFolder(parent.OwnerID, ownerID) {
|
||||
return nil, errors.New("无权访问父文件夹")
|
||||
}
|
||||
parentPath = parent.Path
|
||||
}
|
||||
|
||||
// 检查同名文件夹
|
||||
existing, _ := s.folderRepo.FindByNameAndParent(req.Name, req.ParentID, ownerID)
|
||||
existing, _ := s.folderRepo.FindByNameAndParent(req.Name, parentID, ownerID)
|
||||
if existing != nil {
|
||||
return nil, errors.New("同名文件夹已存在")
|
||||
}
|
||||
|
||||
folderID := utils.GenID()
|
||||
folder := &model.Folder{
|
||||
ID: folderID,
|
||||
BizID: req.BizID,
|
||||
Name: req.Name,
|
||||
ParentID: req.ParentID,
|
||||
ParentID: parentID,
|
||||
OwnerID: ownerID,
|
||||
Path: parentPath, // 先创建, 获取ID后再更新路径
|
||||
Path: fmt.Sprintf("%s%d/", parentPath, folderID),
|
||||
Status: 1,
|
||||
}
|
||||
|
||||
@@ -57,12 +63,6 @@ func (s *FolderService) Create(req *dto.CreateFolderRequest, ownerID uint64) (*d
|
||||
return nil, fmt.Errorf("创建文件夹失败: %w", err)
|
||||
}
|
||||
|
||||
// 更新物化路径
|
||||
folder.Path = fmt.Sprintf("%s%d/", parentPath, folder.ID)
|
||||
if err := s.folderRepo.Update(folder); err != nil {
|
||||
return nil, fmt.Errorf("更新文件夹路径失败: %w", err)
|
||||
}
|
||||
|
||||
return s.toFolderVO(folder), nil
|
||||
}
|
||||
|
||||
@@ -72,16 +72,26 @@ func (s *FolderService) GetByID(id uint64, ownerID uint64) (*dto.FolderVO, error
|
||||
if err != nil {
|
||||
return nil, errors.New("文件夹不存在")
|
||||
}
|
||||
if folder.OwnerID != ownerID {
|
||||
if !s.canAccessFolder(folder.OwnerID, ownerID) {
|
||||
return nil, errors.New("无权访问此文件夹")
|
||||
}
|
||||
return s.toFolderVO(folder), nil
|
||||
}
|
||||
|
||||
// ListChildren 获取文件夹的子项(文件+文件夹, 基于权限)
|
||||
func (s *FolderService) ListChildren(folderID uint64, userID uint64) (*dto.FileListVO, error) {
|
||||
// 获取用户角色IDs
|
||||
// GetByBizID 根据业务ID获取文件夹
|
||||
func (s *FolderService) GetByBizID(bizID string, ownerID uint64) (*dto.FolderVO, error) {
|
||||
folder, err := s.folderRepo.FindByBizID(bizID, ownerID)
|
||||
if err != nil {
|
||||
return nil, errors.New("文件夹不存在")
|
||||
}
|
||||
return s.toFolderVO(folder), nil
|
||||
}
|
||||
|
||||
// ListChildren 获取文件夹的子项(文件+文件夹, 基于权限, 支持分页)
|
||||
// 管理员看全部, 非管理员只看自己创建的 + 被授权的
|
||||
func (s *FolderService) ListChildren(folderID uint64, userID uint64, page, pageSize int) (*dto.FileListVO, error) {
|
||||
roleIDs := s.getUserRoleIDs(userID)
|
||||
isAdmin := s.isAdmin(roleIDs)
|
||||
|
||||
// 如果指定了文件夹ID, 验证访问权限
|
||||
if folderID > 0 {
|
||||
@@ -89,19 +99,32 @@ func (s *FolderService) ListChildren(folderID uint64, userID uint64) (*dto.FileL
|
||||
if err != nil {
|
||||
return nil, errors.New("文件夹不存在")
|
||||
}
|
||||
if folder.OwnerID != userID {
|
||||
if !isAdmin && folder.OwnerID != userID {
|
||||
return nil, errors.New("无权访问此文件夹")
|
||||
}
|
||||
}
|
||||
|
||||
// 查询子文件夹(暂时只查自己创建的)
|
||||
folders, err := s.folderRepo.ListByParent(folderID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// 查询子文件夹
|
||||
var folders []model.Folder
|
||||
var totalFolders int64
|
||||
if isAdmin {
|
||||
folders, _ = s.folderRepo.ListByParentAll(folderID)
|
||||
totalFolders, _ = s.folderRepo.CountByParentAll(folderID)
|
||||
} else {
|
||||
folders, _ = s.folderRepo.ListByParent(folderID, userID)
|
||||
totalFolders, _ = s.folderRepo.CountByParent(folderID, userID)
|
||||
}
|
||||
|
||||
// 查询子文件(基于权限: 自己的 + 被授权的)
|
||||
files, err := s.fileRepo.ListByFolder(folderID, userID, roleIDs)
|
||||
// 分页查询子文件
|
||||
var files []model.File
|
||||
var totalFiles int64
|
||||
var err error
|
||||
if pageSize > 0 {
|
||||
files, totalFiles, err = s.fileRepo.ListByFolderPaginated(folderID, userID, roleIDs, page, pageSize)
|
||||
} else {
|
||||
files, err = s.fileRepo.ListByFolder(folderID, userID, roleIDs)
|
||||
totalFiles = int64(len(files))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -118,18 +141,40 @@ func (s *FolderService) ListChildren(folderID uint64, userID uint64) (*dto.FileL
|
||||
}
|
||||
|
||||
return &dto.FileListVO{
|
||||
Folders: folderVOs,
|
||||
Files: fileVOs,
|
||||
Folders: folderVOs,
|
||||
Files: fileVOs,
|
||||
TotalFiles: totalFiles,
|
||||
TotalFolders: totalFolders,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// canAccessFolder 检查用户是否有权操作该文件夹(owner / admin)
|
||||
func (s *FolderService) canAccessFolder(folderOwnerID, userID uint64) bool {
|
||||
if folderOwnerID == userID {
|
||||
return true
|
||||
}
|
||||
return s.isAdmin(s.getUserRoleIDs(userID))
|
||||
}
|
||||
|
||||
// isAdmin 检查用户是否为管理员
|
||||
func (s *FolderService) isAdmin(roleIDs []uint64) bool {
|
||||
if len(roleIDs) == 0 {
|
||||
return false
|
||||
}
|
||||
var count int64
|
||||
config.DB.Model(&model.Role{}).Where("id IN ? AND name = ?", roleIDs, "admin").Count(&count)
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// Rename 重命名文件夹(乐观锁)
|
||||
func (s *FolderService) Rename(id uint64, newName string, ownerID uint64, version int) error {
|
||||
folder, err := s.folderRepo.FindByID(id)
|
||||
if err != nil {
|
||||
return errors.New("文件夹不存在")
|
||||
}
|
||||
if folder.OwnerID != ownerID {
|
||||
if !s.canAccessFolder(folder.OwnerID, ownerID) {
|
||||
return errors.New("无权操作此文件夹")
|
||||
}
|
||||
|
||||
@@ -158,7 +203,7 @@ func (s *FolderService) Delete(id uint64, ownerID uint64) error {
|
||||
if err != nil {
|
||||
return errors.New("文件夹不存在")
|
||||
}
|
||||
if folder.OwnerID != ownerID {
|
||||
if !s.canAccessFolder(folder.OwnerID, ownerID) {
|
||||
return errors.New("无权操作此文件夹")
|
||||
}
|
||||
|
||||
@@ -168,9 +213,12 @@ func (s *FolderService) Delete(id uint64, ownerID uint64) error {
|
||||
// toFolderVO 转换为视图对象
|
||||
func (s *FolderService) toFolderVO(folder *model.Folder) *dto.FolderVO {
|
||||
return &dto.FolderVO{
|
||||
ID: folder.ID,
|
||||
ID: dto.StringID(folder.ID),
|
||||
BizID: folder.BizID,
|
||||
Name: folder.Name,
|
||||
ParentID: folder.ParentID,
|
||||
ParentID: dto.StringID(folder.ParentID),
|
||||
OwnerID: dto.StringID(folder.OwnerID),
|
||||
OwnerName: s.getUserName(folder.OwnerID),
|
||||
Path: folder.Path,
|
||||
Version: folder.Version,
|
||||
CreatedAt: folder.CreatedAt,
|
||||
@@ -178,6 +226,18 @@ func (s *FolderService) toFolderVO(folder *model.Folder) *dto.FolderVO {
|
||||
}
|
||||
}
|
||||
|
||||
// getUserName 根据用户ID获取显示名称
|
||||
func (s *FolderService) 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
|
||||
}
|
||||
|
||||
// getUserRoleIDs 获取用户的角色ID列表
|
||||
func (s *FolderService) getUserRoleIDs(userID uint64) []uint64 {
|
||||
var userRoles []model.UserRole
|
||||
@@ -191,16 +251,31 @@ func (s *FolderService) getUserRoleIDs(userID uint64) []uint64 {
|
||||
|
||||
// toFileVO 转换为视图对象
|
||||
func (s *FolderService) toFileVO(file *model.File) *dto.FileVO {
|
||||
return &dto.FileVO{
|
||||
ID: file.ID,
|
||||
Name: file.Name,
|
||||
Extension: file.Extension,
|
||||
FolderID: file.FolderID,
|
||||
Size: file.Size,
|
||||
MimeType: file.MimeType,
|
||||
IsFavorite: file.IsFavorite,
|
||||
DownloadCount: file.DownloadCount,
|
||||
CreatedAt: file.CreatedAt,
|
||||
UpdatedAt: file.UpdatedAt,
|
||||
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: s.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, _ := GenerateFileURL(file, "download")
|
||||
previewURL, _ := GenerateFileURL(file, "preview")
|
||||
vo.DownloadURL = downloadURL
|
||||
vo.PreviewURL = previewURL
|
||||
}
|
||||
|
||||
return vo
|
||||
}
|
||||
|
||||
@@ -32,7 +32,8 @@ func NewShareService() *ShareService {
|
||||
// Create 创建分享链接
|
||||
func (s *ShareService) Create(req *dto.ShareCreateRequest, ownerID uint64) (*dto.ShareVO, error) {
|
||||
// 验证文件存在
|
||||
file, err := s.fileRepo.FindByID(req.FileID)
|
||||
fileID := uint64(req.FileID)
|
||||
file, err := s.fileRepo.FindByID(fileID)
|
||||
if err != nil {
|
||||
return nil, errors.New("文件不存在")
|
||||
}
|
||||
@@ -47,7 +48,7 @@ func (s *ShareService) Create(req *dto.ShareCreateRequest, ownerID uint64) (*dto
|
||||
}
|
||||
|
||||
share := &model.Share{
|
||||
FileID: req.FileID,
|
||||
FileID: fileID,
|
||||
OwnerID: ownerID,
|
||||
ShareCode: shareCode,
|
||||
AllowPreview: 1,
|
||||
@@ -180,7 +181,7 @@ func (s *ShareService) toShareVO(share *model.Share) *dto.ShareVO {
|
||||
publicURL = strings.TrimRight(publicURL, "/")
|
||||
|
||||
return &dto.ShareVO{
|
||||
ID: share.ID,
|
||||
ID: dto.StringID(share.ID),
|
||||
ShareCode: share.ShareCode,
|
||||
ShareURL: publicURL + "/s/" + share.ShareCode,
|
||||
HasPassword: share.Password != "",
|
||||
|
||||
@@ -21,11 +21,13 @@ type UserStorageInfo struct {
|
||||
PolicyID uint64 // 当前使用的策略ID(最高优先级)
|
||||
PolicyName string
|
||||
PolicyType string
|
||||
TotalQuota int64 // 总配额(所有策略叠加)
|
||||
UsedStorage int64 // 已用空间
|
||||
UsedPercent float64
|
||||
AccessMode string
|
||||
Assignments []model.StorageAssignment // 所有关联的分配规则
|
||||
TotalQuota int64 // 总配额(所有策略叠加)
|
||||
UsedStorage int64 // 已用空间
|
||||
UsedPercent float64
|
||||
AccessMode string
|
||||
MaxFileSize int64 // 单文件大小上限(字节), 0=不限制
|
||||
ChunkThreshold int64 // 分片上传阈值(字节)
|
||||
Assignments []model.StorageAssignment // 所有关联的分配规则
|
||||
}
|
||||
|
||||
// ResolvePolicyID 确定用户上传时应使用的存储策略
|
||||
@@ -97,9 +99,47 @@ func (s *StorageAssignService) GetUserStorageInfo(userID uint64) (*UserStorageIn
|
||||
info.UsedPercent = float64(info.UsedStorage) / float64(info.TotalQuota) * 100
|
||||
}
|
||||
|
||||
// 根据角色确定文件大小限制
|
||||
info.MaxFileSize, info.ChunkThreshold = s.getFileSizeLimit(userID)
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// getFileSizeLimit 根据用户角色返回单文件大小限制和分片阈值
|
||||
// 返回: maxFileSize(字节), chunkThreshold(字节)
|
||||
func (s *StorageAssignService) getFileSizeLimit(userID uint64) (int64, 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, 100 * 1024 * 1024 // 不限大小, 100MB分片
|
||||
case "staff":
|
||||
return 500 * 1024 * 1024, 50 * 1024 * 1024 // 500MB上限, 50MB分片
|
||||
case "user":
|
||||
return 100 * 1024 * 1024, 10 * 1024 * 1024 // 100MB上限, 10MB分片
|
||||
case "guest":
|
||||
return 0, 0 // 不允许上传
|
||||
}
|
||||
}
|
||||
|
||||
// 默认
|
||||
return 100 * 1024 * 1024, 10 * 1024 * 1024
|
||||
}
|
||||
|
||||
// getUserAssignments 获取用户的所有存储分配(去重+排序)
|
||||
func (s *StorageAssignService) getUserAssignments(userID uint64) []model.StorageAssignment {
|
||||
var all []model.StorageAssignment
|
||||
|
||||
Reference in New Issue
Block a user