307 lines
8.8 KiB
Go
307 lines
8.8 KiB
Go
|
|
package service
|
||
|
|
|
||
|
|
import (
|
||
|
|
"crypto/sha256"
|
||
|
|
"encoding/hex"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"sort"
|
||
|
|
"strconv"
|
||
|
|
"strings"
|
||
|
|
|
||
|
|
"seeyon-filesystem/config"
|
||
|
|
"seeyon-filesystem/dto"
|
||
|
|
"seeyon-filesystem/model"
|
||
|
|
"seeyon-filesystem/repository"
|
||
|
|
"seeyon-filesystem/storage"
|
||
|
|
"seeyon-filesystem/utils"
|
||
|
|
|
||
|
|
"github.com/google/uuid"
|
||
|
|
)
|
||
|
|
|
||
|
|
// ChunkUploadService 分片上传服务
|
||
|
|
type ChunkUploadService struct {
|
||
|
|
fileRepo *repository.FileRepository
|
||
|
|
}
|
||
|
|
|
||
|
|
func NewChunkUploadService() *ChunkUploadService {
|
||
|
|
return &ChunkUploadService{
|
||
|
|
fileRepo: repository.NewFileRepository(),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// InitUpload 初始化分片上传
|
||
|
|
func (s *ChunkUploadService) InitUpload(fileName string, fileSize int64, md5 string, folderID, ownerID, policyID uint64) (*dto.ChunkUploadInitVO, error) {
|
||
|
|
// 秒传检查
|
||
|
|
if md5 != "" {
|
||
|
|
existing, _ := s.fileRepo.FindByMD5(md5)
|
||
|
|
if existing != nil {
|
||
|
|
return &dto.ChunkUploadInitVO{
|
||
|
|
UploadID: "",
|
||
|
|
Instant: true,
|
||
|
|
FileID: existing.ID,
|
||
|
|
ChunkSize: 0,
|
||
|
|
Total: 0,
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 计算分片参数(5MB每片)
|
||
|
|
chunkSize := int64(5 * 1024 * 1024)
|
||
|
|
if fileSize < chunkSize {
|
||
|
|
chunkSize = fileSize
|
||
|
|
}
|
||
|
|
totalChunks := int((fileSize + chunkSize - 1) / chunkSize)
|
||
|
|
|
||
|
|
// 创建临时目录
|
||
|
|
uploadID := uuid.New().String()
|
||
|
|
tempDir := filepath.Join(config.AppConfig.Storage.LocalBasePath, ".tmp", uploadID)
|
||
|
|
os.MkdirAll(tempDir, 0755)
|
||
|
|
|
||
|
|
// 创建上传会话
|
||
|
|
session := &model.UploadSession{
|
||
|
|
ID: uploadID,
|
||
|
|
FileName: fileName,
|
||
|
|
FileSize: fileSize,
|
||
|
|
ChunkSize: chunkSize,
|
||
|
|
TotalChunks: totalChunks,
|
||
|
|
MD5: md5,
|
||
|
|
FolderID: folderID,
|
||
|
|
OwnerID: ownerID,
|
||
|
|
PolicyID: policyID,
|
||
|
|
Status: 1,
|
||
|
|
TempDir: tempDir,
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := config.DB.Create(session).Error; err != nil {
|
||
|
|
return nil, fmt.Errorf("创建上传会话失败: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
return &dto.ChunkUploadInitVO{
|
||
|
|
UploadID: uploadID,
|
||
|
|
Instant: false,
|
||
|
|
ChunkSize: chunkSize,
|
||
|
|
Total: totalChunks,
|
||
|
|
}, 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 {
|
||
|
|
return nil, errors.New("上传会话不存在")
|
||
|
|
}
|
||
|
|
if session.Status != 1 {
|
||
|
|
return nil, errors.New("上传会话已结束")
|
||
|
|
}
|
||
|
|
if index < 0 || index >= session.TotalChunks {
|
||
|
|
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))
|
||
|
|
dst, err := os.Create(chunkPath)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("创建分片文件失败: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
hash := sha256.New()
|
||
|
|
writer := io.MultiWriter(dst, hash)
|
||
|
|
size, err := io.Copy(writer, reader)
|
||
|
|
dst.Close()
|
||
|
|
if err != nil {
|
||
|
|
os.Remove(chunkPath)
|
||
|
|
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)
|
||
|
|
|
||
|
|
return &dto.ChunkUploadChunkVO{Index: index, Size: size, Status: "ok"}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// MergeChunks 合并分片完成上传
|
||
|
|
func (s *ChunkUploadService) MergeChunks(uploadID string) (*dto.FileVO, 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)
|
||
|
|
for i := 0; i < session.TotalChunks; i++ {
|
||
|
|
if !uploaded[i] {
|
||
|
|
return nil, fmt.Errorf("分片 %d 未上传", i)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 合并分片
|
||
|
|
mergedPath := filepath.Join(session.TempDir, "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))
|
||
|
|
chunk, err := os.Open(chunkPath)
|
||
|
|
if err != nil {
|
||
|
|
merged.Close()
|
||
|
|
return nil, fmt.Errorf("读取分片 %d 失败: %w", i, err)
|
||
|
|
}
|
||
|
|
io.Copy(merged, chunk)
|
||
|
|
chunk.Close()
|
||
|
|
}
|
||
|
|
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)
|
||
|
|
}
|
||
|
|
|
||
|
|
// 秒传检查
|
||
|
|
existing, _ := s.fileRepo.FindByMD5(fileMD5)
|
||
|
|
if existing != nil {
|
||
|
|
os.RemoveAll(session.TempDir)
|
||
|
|
newFile := &model.File{
|
||
|
|
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,
|
||
|
|
}
|
||
|
|
s.fileRepo.Create(newFile)
|
||
|
|
return fileToVO(newFile), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// 上传到存储引擎
|
||
|
|
ext := utils.GetExtension(session.FileName)
|
||
|
|
storageKey := storage.GenerateStorageKey(fileMD5, ext)
|
||
|
|
engine, err := storage.GetEngineByPolicyID(session.PolicyID)
|
||
|
|
if err != nil {
|
||
|
|
engine = storage.GetDefaultEngine()
|
||
|
|
}
|
||
|
|
reader, _ := os.Open(mergedPath)
|
||
|
|
defer reader.Close()
|
||
|
|
if err := engine.Upload(storageKey, reader, session.FileSize); err != nil {
|
||
|
|
os.RemoveAll(session.TempDir)
|
||
|
|
return nil, fmt.Errorf("存储文件失败: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// 保存记录
|
||
|
|
newFile := &model.File{
|
||
|
|
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)
|
||
|
|
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)
|
||
|
|
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 {
|
||
|
|
return nil, errors.New("上传会话不存在")
|
||
|
|
}
|
||
|
|
uploaded := parseChunkIndices(session.UploadedChunks)
|
||
|
|
missing := []int{}
|
||
|
|
for i := 0; i < session.TotalChunks; i++ {
|
||
|
|
if !uploaded[i] {
|
||
|
|
missing = append(missing, i)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return &dto.ChunkUploadProgressVO{
|
||
|
|
UploadID: session.ID, FileName: session.FileName, FileSize: session.FileSize,
|
||
|
|
ChunkSize: session.ChunkSize, TotalChunks: session.TotalChunks,
|
||
|
|
Uploaded: len(uploaded), Missing: missing, Status: session.Status,
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// CancelUpload 取消上传
|
||
|
|
func (s *ChunkUploadService) CancelUpload(uploadID string) error {
|
||
|
|
var session model.UploadSession
|
||
|
|
if err := config.DB.First(&session, uploadID).Error; err != nil {
|
||
|
|
return errors.New("上传会话不存在")
|
||
|
|
}
|
||
|
|
os.RemoveAll(session.TempDir)
|
||
|
|
config.DB.Model(&session).Update("status", 3)
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func parseChunkIndices(s string) map[int]bool {
|
||
|
|
result := make(map[int]bool)
|
||
|
|
if s == "" {
|
||
|
|
return result
|
||
|
|
}
|
||
|
|
for _, part := range strings.Split(s, ",") {
|
||
|
|
if idx, err := strconv.Atoi(strings.TrimSpace(part)); err == nil {
|
||
|
|
result[idx] = true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return result
|
||
|
|
}
|
||
|
|
|
||
|
|
func buildChunkIndices(m map[int]bool) string {
|
||
|
|
indices := []int{}
|
||
|
|
for idx := range m {
|
||
|
|
indices = append(indices, idx)
|
||
|
|
}
|
||
|
|
sort.Ints(indices)
|
||
|
|
strs := make([]string, len(indices))
|
||
|
|
for i, idx := range indices {
|
||
|
|
strs[i] = strconv.Itoa(idx)
|
||
|
|
}
|
||
|
|
return strings.Join(strs, ",")
|
||
|
|
}
|
||
|
|
|
||
|
|
func fileToVO(file *model.File) *dto.FileVO {
|
||
|
|
vo := &dto.FileVO{
|
||
|
|
ID: file.ID, Name: file.Name, Extension: file.Extension, FolderID: file.FolderID,
|
||
|
|
Size: file.Size, StorageKey: file.StorageKey, StoragePolicyID: file.StoragePolicyID,
|
||
|
|
MimeType: file.MimeType, IsFavorite: file.IsFavorite, DownloadCount: file.DownloadCount,
|
||
|
|
Version: file.Version, CreatedAt: file.CreatedAt, UpdatedAt: file.UpdatedAt,
|
||
|
|
}
|
||
|
|
if file.StorageKey != "" {
|
||
|
|
engine, err := storage.GetEngineByPolicyID(file.StoragePolicyID)
|
||
|
|
if err == nil {
|
||
|
|
if localEngine, ok := engine.(*storage.LocalEngine); ok {
|
||
|
|
vo.DownloadURL = localEngine.GetURL(file.StorageKey)
|
||
|
|
vo.PreviewURL = localEngine.GetURL(file.StorageKey)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return vo
|
||
|
|
}
|