Files
server/app/http/controllers/admin/attachment_controller.go
T
2026-01-16 15:49:34 +08:00

601 lines
19 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 admin
import (
"fmt"
"mime"
"path/filepath"
"strconv"
"time"
apperrors "goravel/app/errors"
"goravel/app/http/helpers"
"goravel/app/http/response"
"goravel/app/models"
"goravel/app/services"
"goravel/app/utils/errorlog"
"github.com/goravel/framework/contracts/http"
"github.com/goravel/framework/facades"
)
type AttachmentController struct {
attachmentService services.AttachmentService
}
func NewAttachmentController() *AttachmentController {
return &AttachmentController{}
}
// Index 附件列表
func (r *AttachmentController) Index(ctx http.Context) http.Response {
page := helpers.GetIntQuery(ctx, "page", 1)
pageSize := helpers.GetIntQuery(ctx, "page_size", 10)
filters := r.buildFilters(ctx)
attachmentService := services.NewAttachmentService(ctx)
attachments, total, err := attachmentService.GetList(filters, page, pageSize)
if err != nil {
return response.ErrorWithLog(ctx, "attachment", err)
}
type AttachmentWithURL struct {
models.Attachment
FileURL string `json:"file_url"`
}
var resultWithURL []AttachmentWithURL
for _, a := range attachments {
fileURL := attachmentService.GetFileURL(&a)
resultWithURL = append(resultWithURL, AttachmentWithURL{
Attachment: a,
FileURL: fileURL,
})
}
return response.Success(ctx, http.Json{
"list": resultWithURL,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// buildFilters 构建查询过滤器
func (r *AttachmentController) buildFilters(ctx http.Context) services.AttachmentFilters {
adminID := ctx.Request().Query("admin_id", "")
filename := ctx.Request().Query("filename", "")
displayName := ctx.Request().Query("display_name", "")
fileType := ctx.Request().Query("file_type", "")
extension := ctx.Request().Query("extension", "")
startTime := helpers.GetTimeQueryParam(ctx, "start_time")
endTime := helpers.GetTimeQueryParam(ctx, "end_time")
orderBy := ctx.Request().Query("order_by", "")
return services.AttachmentFilters{
AdminID: adminID,
Filename: filename,
DisplayName: displayName,
FileType: fileType,
Extension: extension,
StartTime: startTime,
EndTime: endTime,
OrderBy: orderBy,
}
}
// Upload 普通文件上传(小文件)
func (r *AttachmentController) Upload(ctx http.Context) http.Response {
file, err := ctx.Request().File("file")
if err != nil {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrFileRequired.Code)
}
filename := file.GetClientOriginalName()
if filename == "" {
filename = "uploaded_file"
}
// 读取文件内容:先将文件保存到临时位置,然后读取
storage := facades.Storage().Disk("local")
// 保存文件到临时位置,PutFile 返回保存后的路径
savedPath, err := storage.PutFile("", file)
if err != nil {
return response.ErrorWithLog(ctx, "attachment", err, map[string]any{
"filename": filename,
})
}
// 读取文件内容
fileDataStr, err := storage.Get(savedPath)
if err != nil {
// 清理临时文件
_ = storage.Delete(savedPath)
return response.ErrorWithLog(ctx, "attachment", err, map[string]any{
"filename": filename,
})
}
// 清理临时文件
_ = storage.Delete(savedPath)
// 转换为字节数组
fileData := []byte(fileDataStr)
// 获取MIME类型:直接根据文件扩展名推断(multipart/form-data 的 Content-Type 不是文件本身的 MIME 类型)
ext := filepath.Ext(filename)
mimeType := mime.TypeByExtension(ext)
if mimeType == "" {
mimeType = "application/octet-stream"
}
attachmentService := services.NewAttachmentService(ctx)
attachment, err := attachmentService.UploadFile(fileData, filename, mimeType)
if err != nil {
return response.ErrorWithLog(ctx, "attachment", err, map[string]any{
"filename": filename,
})
}
fileURL := attachmentService.GetFileURL(attachment)
return response.Success(ctx, "upload_success", http.Json{
"id": attachment.ID,
"filename": attachment.Filename,
"size": attachment.Size,
"mime_type": attachment.MimeType,
"file_type": attachment.FileType,
"file_url": fileURL,
})
}
// ChunkUpload 大文件分片上传统一接口
// 通过 action 参数区分不同操作:init(初始化)、upload(上传分片)、merge(合并分片)、progress(获取进度)
func (r *AttachmentController) ChunkUpload(ctx http.Context) http.Response {
action := ctx.Request().Input("action", "")
if action == "" {
// 兼容 GET 请求获取进度
action = ctx.Request().Query("action", "progress")
}
attachmentService := services.NewAttachmentService(ctx)
switch action {
case "init":
// 初始化分片上传
filename := ctx.Request().Input("filename", "")
if filename == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrFilenameRequired.Code)
}
totalSizeStr := ctx.Request().Input("total_size", "0")
totalSize, err := strconv.ParseInt(totalSizeStr, 10, 64)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidTotalSize.Code)
}
if totalSize <= 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidTotalSize.Code)
}
chunkSizeStr := ctx.Request().Input("chunk_size", "0")
chunkSize, err := strconv.ParseInt(chunkSizeStr, 10, 64)
if err != nil {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidChunkSize.Code)
}
if chunkSize <= 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidChunkSize.Code)
}
totalChunksStr := ctx.Request().Input("total_chunks", "0")
totalChunks, err := strconv.Atoi(totalChunksStr)
if err != nil {
// 尝试作为浮点数解析(以防传入 "5.0" 格式)
if floatVal, floatErr := strconv.ParseFloat(totalChunksStr, 64); floatErr == nil {
totalChunks = int(floatVal)
} else {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidTotalChunks.Code)
}
}
if totalChunks <= 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidTotalChunks.Code)
}
// 验证分片数量计算的合理性
expectedChunks := int((totalSize + chunkSize - 1) / chunkSize) // 向上取整
if totalChunks != expectedChunks {
// 不返回错误,使用客户端提供的值(可能是由于浮点数计算差异)
}
chunkID, err := attachmentService.InitChunkUpload(filename, totalSize, chunkSize, totalChunks)
if err != nil {
// 使用业务错误类型,直接提取错误码
if businessErr, ok := apperrors.GetBusinessError(err); ok {
return response.Error(ctx, http.StatusBadRequest, businessErr.Code)
}
// 返回详细的错误信息
return response.ErrorWithLog(ctx, "attachment", err, map[string]any{
"filename": filename,
"total_size": totalSize,
"chunk_size": chunkSize,
"total_chunks": totalChunks,
})
}
return response.Success(ctx, "init_chunk_upload_success", http.Json{
"chunk_id": chunkID,
})
case "upload":
// 上传分片
chunkID := ctx.Request().Input("chunk_id", "")
if chunkID == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrChunkIDRequired.Code)
}
chunkIndex, err := strconv.Atoi(ctx.Request().Input("chunk_index", "-1"))
if err != nil || chunkIndex < 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidChunkIndex.Code)
}
file, err := ctx.Request().File("chunk")
if err != nil {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrChunkFileRequired.Code)
}
// 读取分片数据:先将文件保存到临时位置,然后读取
storage := facades.Storage().Disk("local")
// 保存文件到临时位置
savedPath, err := storage.PutFile("", file)
if err != nil {
return response.ErrorWithLog(ctx, "attachment", err, map[string]any{
"chunk_id": chunkID,
"chunk_index": chunkIndex,
})
}
// 读取文件内容
chunkDataStr, err := storage.Get(savedPath)
if err != nil {
// 清理临时文件
_ = storage.Delete(savedPath)
return response.ErrorWithLog(ctx, "attachment", err, map[string]any{
"chunk_id": chunkID,
"chunk_index": chunkIndex,
})
}
// 清理临时文件
_ = storage.Delete(savedPath)
// 转换为字节数组
chunkData := []byte(chunkDataStr)
if err := attachmentService.UploadChunk(chunkID, chunkIndex, chunkData); err != nil {
return response.ErrorWithLog(ctx, "attachment", err, map[string]any{
"chunk_id": chunkID,
"chunk_index": chunkIndex,
})
}
return response.Success(ctx, "upload_chunk_success")
case "merge":
// 合并分片
chunkID := ctx.Request().Input("chunk_id", "")
if chunkID == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrChunkIDRequired.Code)
}
filename := ctx.Request().Input("filename", "")
if filename == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrFilenameRequired.Code)
}
totalChunksStr := ctx.Request().Input("total_chunks", "0")
totalChunks, err := strconv.Atoi(totalChunksStr)
if err != nil {
// 尝试作为浮点数解析(以防传入 "5.0" 格式)
if floatVal, floatErr := strconv.ParseFloat(totalChunksStr, 64); floatErr == nil {
totalChunks = int(floatVal)
} else {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidTotalChunks.Code)
}
}
if totalChunks <= 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidTotalChunks.Code)
}
// 获取MIME类型:直接根据文件扩展名推断(前端传递的 mime_type 可能不准确)
ext := filepath.Ext(filename)
mimeType := mime.TypeByExtension(ext)
if mimeType == "" {
mimeType = "application/octet-stream"
}
attachment, err := attachmentService.MergeChunks(chunkID, filename, mimeType, totalChunks)
if err != nil {
return response.ErrorWithLog(ctx, "attachment", err, map[string]any{
"chunk_id": chunkID,
"filename": filename,
"total_chunks": totalChunks,
})
}
// 合并成功后,记录日志(仅 Debug 模式)
facades.Log().Debugf("Successfully merged chunks for chunkID %s, filename: %s, total_chunks: %d", chunkID, filename, totalChunks)
fileURL := attachmentService.GetFileURL(attachment)
return response.Success(ctx, "merge_chunks_success", http.Json{
"id": attachment.ID,
"filename": attachment.Filename,
"size": attachment.Size,
"mime_type": attachment.MimeType,
"file_type": attachment.FileType,
"file_url": fileURL,
})
case "progress":
// 获取分片上传进度
chunkID := ctx.Request().Query("chunk_id", "")
if chunkID == "" {
chunkID = ctx.Request().Input("chunk_id", "")
}
if chunkID == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrChunkIDRequired.Code)
}
totalChunks, err := strconv.Atoi(ctx.Request().Query("total_chunks", "0"))
if totalChunks == 0 {
totalChunks, err = strconv.Atoi(ctx.Request().Input("total_chunks", "0"))
}
if err != nil || totalChunks <= 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidTotalChunks.Code)
}
progress, err := attachmentService.GetChunkProgress(chunkID, totalChunks)
if err != nil {
return response.ErrorWithLog(ctx, "attachment", err, map[string]any{
"chunk_id": chunkID,
})
}
return response.Success(ctx, progress)
default:
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrInvalidAction.Code)
}
}
// Download 下载附件文件
func (r *AttachmentController) Download(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
if id == 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrIDRequired.Code)
}
attachmentService := services.NewAttachmentService(ctx)
attachment, err := attachmentService.GetByID(id)
if err != nil {
return response.Error(ctx, http.StatusNotFound, apperrors.ErrRecordNotFound.Code)
}
if attachment.Path == "" || attachment.Disk == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrFilePathRequired.Code)
}
// 对于云存储,尝试生成临时URL并重定向,避免通过服务器中转
if attachment.Disk != "local" && attachment.Disk != "public" {
storage := facades.Storage().Disk(attachment.Disk)
// 尝试生成临时URL(24小时有效)
if url, err := storage.TemporaryUrl(attachment.Path, time.Now().Add(24*time.Hour)); err == nil {
return ctx.Response().Redirect(http.StatusFound, url)
}
// 如果生成临时URL失败,尝试从配置获取基础URL
attachmentService := services.NewAttachmentService(ctx)
directURL := attachmentService.GetFileURL(attachment)
if directURL != "" && directURL != fmt.Sprintf("/api/admin/attachments/%d/preview", attachment.ID) {
return ctx.Response().Redirect(http.StatusFound, directURL)
}
// 如果都失败,继续使用服务器中转方式
}
// 对于本地存储或临时URL生成失败的情况,使用服务器中转
storage := facades.Storage().Disk(attachment.Disk)
// 读取文件内容
content, err := storage.Get(attachment.Path)
if err != nil {
return response.ErrorWithLog(ctx, "attachment", err, map[string]any{
"disk": attachment.Disk,
"path": attachment.Path,
})
}
// 设置响应头
filename := attachment.Filename
if filename == "" {
filename = attachment.Path
}
// 根据MIME类型设置 Content-Type
contentType := attachment.MimeType
if contentType == "" {
contentType = "application/octet-stream"
}
// 设置响应头,使用链式调用确保顺序正确
response := ctx.Response().
Header("Content-Type", contentType).
Header("Content-Length", fmt.Sprintf("%d", len(content))).
Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)).
Header("Cache-Control", "no-cache, no-store, must-revalidate").
Header("Pragma", "no-cache").
Header("Expires", "0")
return response.String(http.StatusOK, content)
}
// Preview 预览文件(图片、视频、文档)
func (r *AttachmentController) Preview(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
if id == 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrIDRequired.Code)
}
attachmentService := services.NewAttachmentService(ctx)
attachment, err := attachmentService.GetByID(id)
if err != nil {
return response.Error(ctx, http.StatusNotFound, apperrors.ErrRecordNotFound.Code)
}
if attachment.Path == "" || attachment.Disk == "" {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrFilePathRequired.Code)
}
// 对于云存储,尝试生成临时URL并重定向,避免通过服务器中转
// 这样可以减少服务器带宽和内存占用,提高性能
if attachment.Disk != "local" && attachment.Disk != "public" {
storage := facades.Storage().Disk(attachment.Disk)
// 尝试生成临时URL(24小时有效)
if url, err := storage.TemporaryUrl(attachment.Path, time.Now().Add(24*time.Hour)); err == nil {
return ctx.Response().Redirect(http.StatusFound, url)
}
// 如果生成临时URL失败,尝试从配置获取基础URL
attachmentService := services.NewAttachmentService(ctx)
directURL := attachmentService.GetFileURL(attachment)
if directURL != "" && directURL != fmt.Sprintf("/api/admin/attachments/%d/preview", attachment.ID) {
return ctx.Response().Redirect(http.StatusFound, directURL)
}
// 如果都失败,继续使用服务器中转方式
}
// 对于本地存储或临时URL生成失败的情况,使用服务器中转
storage := facades.Storage().Disk(attachment.Disk)
// 读取文件内容
content, err := storage.Get(attachment.Path)
if err != nil {
return response.ErrorWithLog(ctx, "attachment", err, map[string]any{
"disk": attachment.Disk,
"path": attachment.Path,
})
}
// 设置响应头
mimeType := attachment.MimeType
if mimeType == "" {
mimeType = "application/octet-stream"
}
// 设置响应头
response := ctx.Response().
Header("Content-Type", mimeType).
Header("Content-Length", fmt.Sprintf("%d", len(content))).
Header("Cache-Control", "public, max-age=3600")
// 对于图片和视频,支持范围请求(Range request
if attachment.FileType == "image" || attachment.FileType == "video" {
response = response.Header("Accept-Ranges", "bytes")
}
return response.String(http.StatusOK, content)
}
// Destroy 删除附件
func (r *AttachmentController) Destroy(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
if id == 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrIDRequired.Code)
}
attachmentService := services.NewAttachmentService(ctx)
attachment, err := attachmentService.GetByID(id)
if err != nil {
return response.Error(ctx, http.StatusNotFound, apperrors.ErrRecordNotFound.Code)
}
if err := attachmentService.DeleteFile(attachment); err != nil {
return response.ErrorWithLog(ctx, "attachment", err, map[string]any{
"attachId": attachment.ID,
})
}
return response.Success(ctx)
}
type AttachmentBatchDestroyRequest struct {
IDs []uint `json:"ids"`
}
// BatchDestroy 批量删除附件
func (r *AttachmentController) BatchDestroy(ctx http.Context) http.Response {
var req AttachmentBatchDestroyRequest
if err := ctx.Request().Bind(&req); err != nil {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrParamsError.Code)
}
if len(req.IDs) == 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrIDsRequired.Code)
}
ids := req.IDs
// 查询要删除的附件
attachmentService := services.NewAttachmentService(ctx)
attachments, err := attachmentService.GetByIDs(ids)
if err != nil {
return response.ErrorWithLog(ctx, "attachment", err, map[string]any{
"ids": ids,
})
}
// 删除文件和记录
for _, attachment := range attachments {
if err := attachmentService.DeleteFile(&attachment); err != nil {
// 批量删除中单个文件删除失败只记录日志,不影响主流程
errorlog.RecordHTTP(ctx, "attachment", "Failed to delete attachment in batch delete", map[string]any{
"error": err.Error(),
"attachId": attachment.ID,
}, "Delete attachment in batch delete error: %v", err)
}
}
return response.Success(ctx)
}
// UpdateDisplayName 更新显示名称
func (r *AttachmentController) UpdateDisplayName(ctx http.Context) http.Response {
id := helpers.GetUintRoute(ctx, "id")
if id == 0 {
return response.Error(ctx, http.StatusBadRequest, apperrors.ErrIDRequired.Code)
}
attachmentService := services.NewAttachmentService(ctx)
displayName := ctx.Request().Input("display_name", "")
if err := attachmentService.UpdateDisplayName(id, displayName); err != nil {
return response.ErrorWithLog(ctx, "attachment", err, map[string]any{
"attachId": id,
})
}
// 重新获取更新后的附件
attachment, err := attachmentService.GetByID(id)
if err != nil {
return response.Error(ctx, http.StatusNotFound, apperrors.ErrRecordNotFound.Code)
}
return response.Success(ctx, http.Json{
"attachment": attachment,
})
}