This commit is contained in:
Joe
2026-01-16 15:49:34 +08:00
commit 550d3e1f42
380 changed files with 62024 additions and 0 deletions
@@ -0,0 +1,600 @@
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,
})
}