Files
server/app/services/attachment_service.go
T
2026-01-16 15:49:34 +08:00

646 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package services
import (
"crypto/md5"
"encoding/hex"
"fmt"
"io"
"mime"
"os"
"path/filepath"
"strings"
"time"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
"goravel/app/models"
"goravel/app/utils"
"goravel/app/utils/errorlog"
)
type AttachmentService interface {
// GetByID 根据ID获取附件
GetByID(id uint) (*models.Attachment, error)
// GetByIDs 根据ID列表获取附件
GetByIDs(ids []uint) ([]models.Attachment, error)
// GetList 获取附件列表
GetList(filters AttachmentFilters, page, pageSize int) ([]models.Attachment, int64, error)
// InitChunkUpload 初始化分片上传(不再使用服务端缓存)
InitChunkUpload(filename string, totalSize int64, chunkSize int64, totalChunks int) (string, error)
// UploadChunk 上传分片(不再使用服务端缓存)
UploadChunk(chunkID string, chunkIndex int, chunkData []byte) error
// MergeChunks 合并分片(不再使用服务端缓存,需要传入 totalChunks
MergeChunks(chunkID string, filename string, mimeType string, totalChunks int) (*models.Attachment, error)
// GetChunkProgress 获取分片上传进度(不再使用服务端缓存,需要传入 totalChunks
GetChunkProgress(chunkID string, totalChunks int) (map[string]any, error)
// UploadFile 普通文件上传(小文件)
UploadFile(fileData []byte, filename string, mimeType string) (*models.Attachment, error)
// GetFileURL 获取文件访问URL
GetFileURL(attachment *models.Attachment) string
// GetFileType 根据MIME类型判断文件类型
GetFileType(mimeType string) string
// DeleteFile 删除文件
DeleteFile(attachment *models.Attachment) error
// UpdateDisplayName 更新显示名称
UpdateDisplayName(id uint, displayName string) error
}
// AttachmentFilters 附件查询过滤器
type AttachmentFilters struct {
AdminID string
Filename string
DisplayName string
FileType string
Extension string
StartTime string
EndTime string
OrderBy string
}
type AttachmentServiceImpl struct {
ctx http.Context
disk string
systemLogService SystemLogService
}
func NewAttachmentService(ctx http.Context) AttachmentService {
// 从数据库读取文件存储配置
// 优先使用 file_disk,如果没有则使用 storage_disk(向后兼容),最后使用默认值 local
disk := utils.GetConfigValue("storage", "file_disk", "")
if disk == "" {
disk = utils.GetConfigValue("storage", "storage_disk", "")
}
if disk == "" {
disk = "local"
}
return &AttachmentServiceImpl{
ctx: ctx,
disk: disk,
systemLogService: NewSystemLogService(),
}
}
// InitChunkUpload 初始化分片上传
// 注意:分片信息由客户端缓存,服务端只生成 chunkID
func (s *AttachmentServiceImpl) InitChunkUpload(filename string, totalSize int64, chunkSize int64, totalChunks int) (string, error) {
// 检查存储驱动:大文件分片上传仅支持本地存储
cloudStorageDrivers := []string{"s3", "oss", "cos", "minio", "qiniu"}
for _, driver := range cloudStorageDrivers {
if s.disk == driver {
return "", apperrors.ErrChunkUploadOnlyLocalStorage.WithMessage(fmt.Sprintf("大文件分片上传仅支持本地存储,当前存储驱动为: %s", driver))
}
}
// 生成唯一的分片ID
hash := md5.Sum(fmt.Appendf(nil, "%s_%d_%d", filename, totalSize, time.Now().UnixNano()))
chunkID := hex.EncodeToString(hash[:])
// 不再使用服务端缓存,分片信息由客户端管理
return chunkID, nil
}
// UploadChunk 上传分片
// 注意:不再使用服务端缓存,直接保存分片文件
func (s *AttachmentServiceImpl) UploadChunk(chunkID string, chunkIndex int, chunkData []byte) error {
if chunkIndex < 0 {
return apperrors.ErrInvalidChunkIndex
}
// 保存分片到临时目录
storage := facades.Storage().Disk(s.disk)
chunkPath := fmt.Sprintf("chunks/%s/%d", chunkID, chunkIndex)
if err := storage.Put(chunkPath, string(chunkData)); err != nil {
if s.ctx != nil {
errorlog.RecordHTTP(s.ctx, "attachment", "保存分片失败", map[string]any{
"chunk_id": chunkID,
"chunk_index": chunkIndex,
"error": err.Error(),
}, "保存分片失败: %w", err)
}
return apperrors.ErrSaveChunkFailed.WithError(err)
}
return nil
}
// MergeChunks 合并分片
// 注意:不再使用服务端缓存,通过检查实际文件系统来验证分片
func (s *AttachmentServiceImpl) MergeChunks(chunkID string, filename string, mimeType string, totalChunks int) (*models.Attachment, error) {
storage := facades.Storage().Disk(s.disk)
// 检查所有分片文件是否存在
indices := make([]int, totalChunks)
for i := range indices {
chunkPath := fmt.Sprintf("chunks/%s/%d", chunkID, i)
if !storage.Exists(chunkPath) {
return nil, apperrors.ErrChunkNotFound.WithParams(map[string]any{
"chunk_index": i,
})
}
}
// 生成最终文件路径
ext := filepath.Ext(filename)
if ext == "" {
exts, _ := mime.ExtensionsByType(mimeType)
if len(exts) > 0 {
ext = exts[0]
}
}
// 生成唯一文件名
hash := md5.Sum([]byte(fmt.Sprintf("%s_%d", filename, time.Now().UnixNano())))
uniqueName := hex.EncodeToString(hash[:]) + ext
datePath := time.Now().Format("2006/01/02")
finalPath := fmt.Sprintf("attachments/%s/%s", datePath, uniqueName)
// 合并分片(流式写入,避免大文件内存占用过高)
// 对于本地存储,直接使用文件系统操作以提高性能
var fileSize int64
var missingChunks []int // 记录缺失的分片索引
// 获取存储根目录
storageRoot := facades.Config().GetString("filesystems.disks." + s.disk + ".root")
if storageRoot == "" {
storageRoot = "storage/app"
}
// 构建目标文件的完整路径
finalFullPath := filepath.Join(storageRoot, finalPath)
// 确保目标目录存在
if err := os.MkdirAll(filepath.Dir(finalFullPath), 0755); err != nil {
if s.ctx != nil {
errorlog.RecordHTTP(s.ctx, "attachment", "创建目标目录失败", map[string]any{
"chunk_id": chunkID,
"directory": filepath.Dir(finalFullPath),
"error": err.Error(),
}, "创建目标目录失败: %w", err)
}
return nil, apperrors.ErrCreateDirectoryFailed.WithError(err)
}
// 创建目标文件(流式写入)
outFile, err := os.Create(finalFullPath)
if err != nil {
if s.ctx != nil {
errorlog.RecordHTTP(s.ctx, "attachment", "创建目标文件失败", map[string]any{
"chunk_id": chunkID,
"file_path": finalFullPath,
"error": err.Error(),
}, "创建目标文件失败: %w", err)
}
return nil, apperrors.ErrCreateFileFailed.WithError(err)
}
defer outFile.Close()
// 按顺序读取并写入每个分片
for i := range make([]int, totalChunks) {
chunkPath := fmt.Sprintf("chunks/%s/%d", chunkID, i)
chunkFullPath := filepath.Join(storageRoot, chunkPath)
// 检查分片文件是否存在
if _, err := os.Stat(chunkFullPath); os.IsNotExist(err) {
missingChunks = append(missingChunks, i)
continue
}
// 打开分片文件
chunkFile, err := os.Open(chunkFullPath)
if err != nil {
// 读取失败,记录到系统日志
if s.ctx != nil {
_ = s.systemLogService.RecordHTTP(s.ctx, "warning", "attachment", fmt.Sprintf("Failed to read chunk %d for chunkID %s", i, chunkID), map[string]any{
"chunk_index": i,
"chunk_id": chunkID,
"error": err.Error(),
})
}
missingChunks = append(missingChunks, i)
continue
}
// 流式复制分片内容到目标文件
written, err := io.Copy(outFile, chunkFile)
if err != nil {
chunkFile.Close()
// 如果写入失败,删除已创建的目标文件
_ = os.Remove(finalFullPath)
if s.ctx != nil {
errorlog.RecordHTTP(s.ctx, "attachment", "写入分片失败", map[string]any{
"chunk_id": chunkID,
"chunk_index": i,
"file_path": finalFullPath,
"error": err.Error(),
}, "写入分片 %d 失败: %w", i, err)
}
return nil, apperrors.ErrWriteChunkFailed.WithError(err).WithParams(map[string]any{
"chunk_index": i,
})
}
fileSize += written
chunkFile.Close()
}
// 关闭目标文件
if err := outFile.Close(); err != nil {
_ = os.Remove(finalFullPath)
if s.ctx != nil {
errorlog.RecordHTTP(s.ctx, "attachment", "关闭目标文件失败", map[string]any{
"chunk_id": chunkID,
"file_path": finalFullPath,
"error": err.Error(),
}, "关闭目标文件失败: %w", err)
}
return nil, apperrors.ErrCloseFileFailed.WithError(err)
}
// 检查是否有缺失的分片
if len(missingChunks) > 0 {
_ = os.Remove(finalFullPath)
return nil, apperrors.ErrChunkMissing.WithParams(map[string]any{
"missing_chunks": missingChunks,
"count": len(missingChunks),
})
}
// 检查是否有数据被合并
if fileSize == 0 {
_ = os.Remove(finalFullPath)
return nil, apperrors.ErrNoChunkDataToMerge
}
// 验证文件大小(从文件系统获取实际大小)
if fileInfo, err := os.Stat(finalFullPath); err == nil {
fileSize = fileInfo.Size()
}
// 创建附件记录
adminID := uint(0)
if s.ctx != nil {
if id, err := helpers.GetAdminIDFromContext(s.ctx); err == nil {
adminID = id
}
}
fileType := s.GetFileType(mimeType)
attachment := &models.Attachment{
AdminID: adminID,
Disk: s.disk,
Path: finalPath,
Filename: filename,
Extension: strings.TrimPrefix(ext, "."),
MimeType: mimeType,
Size: fileSize,
Status: 1,
FileType: fileType,
ChunkID: chunkID,
}
if err := facades.Orm().Query().Create(attachment); err != nil {
// 如果创建记录失败,删除已上传的文件
_ = storage.Delete(finalPath)
if s.ctx != nil {
errorlog.RecordHTTP(s.ctx, "attachment", "创建附件记录失败", map[string]any{
"chunk_id": chunkID,
"file_path": finalPath,
"filename": filename,
"error": err.Error(),
}, "创建附件记录失败: %w", err)
}
return nil, apperrors.ErrCreateFailed.WithError(err)
}
// 合并成功后才清理分片文件(确保数据已保存到数据库)
// 清理所有分片文件(包括可能缺失的)
cleanupSuccess := true
cleanupCount := 0
for i := range make([]int, totalChunks) {
chunkPath := fmt.Sprintf("chunks/%s/%d", chunkID, i)
if storage.Exists(chunkPath) {
if err := storage.Delete(chunkPath); err != nil {
// 记录删除失败到系统日志,但不影响整体流程
if s.ctx != nil {
_ = s.systemLogService.RecordHTTP(s.ctx, "warning", "attachment", fmt.Sprintf("Failed to delete chunk file %s", chunkPath), map[string]any{
"chunk_path": chunkPath,
"chunk_id": chunkID,
"error": err.Error(),
})
}
cleanupSuccess = false
} else {
cleanupCount++
}
}
}
if !cleanupSuccess && s.ctx != nil {
// 记录部分分片删除失败的警告到系统日志
_ = s.systemLogService.RecordHTTP(s.ctx, "warning", "attachment", fmt.Sprintf("Some chunk files failed to delete for chunkID %s", chunkID), map[string]any{
"chunk_id": chunkID,
"cleaned_count": cleanupCount,
"total_chunks": totalChunks,
})
}
return attachment, nil
}
// GetChunkProgress 获取分片上传进度
// 注意:不再使用服务端缓存,通过检查实际文件系统来获取进度
// 优化:如果分片数量很大,可以考虑限制返回的索引数量或使用并发检查
func (s *AttachmentServiceImpl) GetChunkProgress(chunkID string, totalChunks int) (map[string]any, error) {
storage := facades.Storage().Disk(s.disk)
uploadedCount := 0
uploadedIndices := []int{} // 已上传的分片索引数组
// 优化:如果分片数量超过1000,只返回前100个已上传的索引,减少响应大小
maxIndices := 100
if totalChunks > 1000 {
maxIndices = 100
}
// 检查每个分片文件是否存在
indices := make([]int, totalChunks)
for i := range indices {
chunkPath := fmt.Sprintf("chunks/%s/%d", chunkID, i)
if storage.Exists(chunkPath) {
uploadedCount++
// 只记录前 maxIndices 个索引,减少响应大小
if len(uploadedIndices) < maxIndices {
uploadedIndices = append(uploadedIndices, i)
}
}
}
progress := float64(uploadedCount) / float64(totalChunks) * 100
return map[string]any{
"chunk_id": chunkID,
"total_chunks": totalChunks,
"uploaded_count": uploadedCount,
"uploaded_chunks": uploadedIndices, // 返回已上传的分片索引数组(最多100个)
"progress": progress,
"completed": uploadedCount == totalChunks,
}, nil
}
// UploadFile 普通文件上传(小文件)
func (s *AttachmentServiceImpl) UploadFile(fileData []byte, filename string, mimeType string) (*models.Attachment, error) {
if mimeType == "" {
mimeType = "application/octet-stream"
}
// 生成文件路径
ext := filepath.Ext(filename)
hash := md5.Sum([]byte(fmt.Sprintf("%s_%d", filename, time.Now().UnixNano())))
uniqueName := hex.EncodeToString(hash[:]) + ext
datePath := time.Now().Format("2006/01/02")
finalPath := fmt.Sprintf("attachments/%s/%s", datePath, uniqueName)
// 保存文件
storage := facades.Storage().Disk(s.disk)
if err := storage.Put(finalPath, string(fileData)); err != nil {
if s.ctx != nil {
errorlog.RecordHTTP(s.ctx, "attachment", "保存文件失败", map[string]any{
"filename": filename,
"file_path": finalPath,
"error": err.Error(),
}, "保存文件失败: %w", err)
}
return nil, apperrors.ErrSaveFileFailed.WithError(err)
}
// 获取文件大小
fileSize := int64(len(fileData))
if size, err := storage.Size(finalPath); err == nil {
fileSize = size
}
// 创建附件记录
adminID := uint(0)
if s.ctx != nil {
if id, err := helpers.GetAdminIDFromContext(s.ctx); err == nil {
adminID = id
}
}
fileType := s.GetFileType(mimeType)
attachment := &models.Attachment{
AdminID: adminID,
Disk: s.disk,
Path: finalPath,
Filename: filename,
Extension: strings.TrimPrefix(ext, "."),
MimeType: mimeType,
Size: fileSize,
Status: 1,
FileType: fileType,
}
if err := facades.Orm().Query().Create(attachment); err != nil {
_ = storage.Delete(finalPath)
if s.ctx != nil {
errorlog.RecordHTTP(s.ctx, "attachment", "创建附件记录失败", map[string]any{
"filename": filename,
"file_path": finalPath,
"error": err.Error(),
}, "创建附件记录失败: %w", err)
}
return nil, apperrors.ErrCreateFailed.WithError(err)
}
return attachment, nil
}
// GetFileURL 获取文件访问URL
func (s *AttachmentServiceImpl) GetFileURL(attachment *models.Attachment) string {
// 对于本地存储,返回下载接口URL
if attachment.Disk == "local" || attachment.Disk == "public" {
return fmt.Sprintf("/api/admin/attachments/%d/preview", attachment.ID)
}
// 对于云存储,生成临时URL或直接URL
storage := facades.Storage().Disk(attachment.Disk)
// 尝试生成临时URL(24小时有效)
if url, err := storage.TemporaryUrl(attachment.Path, time.Now().Add(24*time.Hour)); err == nil {
return url
}
// 如果生成临时URL失败,尝试从配置获取基础URL
var configURL string
switch attachment.Disk {
case "s3":
configURL = utils.GetConfigValue("storage", "s3_url", "")
case "oss":
configURL = utils.GetConfigValue("storage", "oss_url", "")
case "cos":
configURL = utils.GetConfigValue("storage", "cos_url", "")
case "minio":
configURL = utils.GetConfigValue("storage", "minio_url", "")
}
if configURL != "" {
if !strings.HasSuffix(configURL, "/") {
configURL += "/"
}
return configURL + attachment.Path
}
// 默认返回下载接口URL
return fmt.Sprintf("/api/admin/attachments/%d/preview", attachment.ID)
}
// GetFileType 根据MIME类型判断文件类型
func (s *AttachmentServiceImpl) GetFileType(mimeType string) string {
if strings.HasPrefix(mimeType, "image/") {
return "image"
}
if strings.HasPrefix(mimeType, "video/") {
return "video"
}
if strings.HasPrefix(mimeType, "application/pdf") ||
strings.HasPrefix(mimeType, "application/msword") ||
strings.HasPrefix(mimeType, "application/vnd.openxmlformats-officedocument") ||
strings.HasPrefix(mimeType, "application/vnd.ms-excel") ||
strings.HasPrefix(mimeType, "text/") {
return "document"
}
return "other"
}
// GetByID 根据ID获取附件
func (s *AttachmentServiceImpl) GetByID(id uint) (*models.Attachment, error) {
var attachment models.Attachment
if err := facades.Orm().Query().Where("id", id).First(&attachment); err != nil {
return nil, apperrors.ErrAttachmentNotFound.WithError(err)
}
return &attachment, nil
}
// GetByIDs 根据ID列表获取附件
func (s *AttachmentServiceImpl) GetByIDs(ids []uint) ([]models.Attachment, error) {
if len(ids) == 0 {
return []models.Attachment{}, nil
}
idsAny := helpers.ConvertUintSliceToAny(ids)
var attachments []models.Attachment
if err := facades.Orm().Query().WhereIn("id", idsAny).Get(&attachments); err != nil {
return nil, apperrors.ErrQueryFailed.WithError(err)
}
return attachments, nil
}
// GetList 获取附件列表
func (s *AttachmentServiceImpl) GetList(filters AttachmentFilters, page, pageSize int) ([]models.Attachment, int64, error) {
query := facades.Orm().Query().Model(&models.Attachment{})
// 应用筛选条件
if filters.AdminID != "" {
query = query.Where("admin_id", filters.AdminID)
}
if filters.Filename != "" {
query = query.Where("filename LIKE ?", "%"+filters.Filename+"%")
}
if filters.DisplayName != "" {
query = query.Where("display_name LIKE ?", "%"+filters.DisplayName+"%")
}
if filters.FileType != "" {
query = query.Where("file_type = ?", filters.FileType)
}
if filters.Extension != "" {
query = query.Where("extension = ?", filters.Extension)
}
if filters.StartTime != "" {
query = query.Where("created_at >= ?", filters.StartTime)
}
if filters.EndTime != "" {
query = query.Where("created_at <= ?", filters.EndTime)
}
// 应用排序
orderBy := filters.OrderBy
if orderBy == "" {
orderBy = "id:desc"
}
query = helpers.ApplySort(query, orderBy, "id:desc")
// 分页查询
var attachments []models.Attachment
var total int64
if err := query.With("Admin").Paginate(page, pageSize, &attachments, &total); err != nil {
return nil, 0, err
}
return attachments, total, nil
}
// UpdateDisplayName 更新显示名称
func (s *AttachmentServiceImpl) UpdateDisplayName(id uint, displayName string) error {
var attachment models.Attachment
if err := facades.Orm().Query().Where("id", id).First(&attachment); err != nil {
return fmt.Errorf("附件不存在: %v", err)
}
attachment.DisplayName = displayName
if err := facades.Orm().Query().Save(&attachment); err != nil {
if s.ctx != nil {
errorlog.RecordHTTP(s.ctx, "attachment", "更新附件显示名称失败", map[string]any{
"attachment_id": id,
"display_name": displayName,
"error": err.Error(),
}, "更新附件显示名称失败: %v", err)
}
return apperrors.ErrUpdateFailed.WithError(err)
}
return nil
}
// DeleteFile 删除文件
func (s *AttachmentServiceImpl) DeleteFile(attachment *models.Attachment) error {
// 删除文件
if attachment.Path != "" && attachment.Disk != "" {
storage := facades.Storage().Disk(attachment.Disk)
if err := storage.Delete(attachment.Path); err != nil {
if s.ctx != nil {
errorlog.RecordHTTP(s.ctx, "attachment", "删除文件失败", map[string]any{
"attachment_id": attachment.ID,
"file_path": attachment.Path,
"disk": attachment.Disk,
"error": err.Error(),
}, "删除文件失败: %w", err)
}
return apperrors.ErrDeleteFileFailed.WithError(err)
}
}
// 删除数据库记录
if _, err := facades.Orm().Query().Delete(attachment); err != nil {
if s.ctx != nil {
errorlog.RecordHTTP(s.ctx, "attachment", "删除附件记录失败", map[string]any{
"attachment_id": attachment.ID,
"error": err.Error(),
}, "删除附件记录失败: %w", err)
}
return apperrors.ErrDeleteFailed.WithError(err)
}
return nil
}