Files
starter/internal/handler/file_handler.go
limitcool b7628c770b Refactor user handler and middleware for improved error handling and logging
- Consolidated user ID retrieval and permission checks into helper functions.
- Updated UserHandler to utilize BaseHandler for common database and configuration access.
- Enhanced logging for user-related operations, including login, registration, and password changes.
- Removed redundant context handling in middleware and improved readability.
- Introduced FileUtil for file URL generation and management, encapsulating file-related logic.
- Refactored FileRepo and UserRepo to streamline database operations and error handling.
- Deleted unused request_id middleware and integrated its functionality into request_logger.
- Removed legacy test runner script to simplify testing process.
2025-06-17 23:09:02 +08:00

697 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 handler
import (
"fmt"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/limitcool/starter/configs"
"github.com/limitcool/starter/internal/api/response"
"github.com/limitcool/starter/internal/filestore"
"github.com/limitcool/starter/internal/model"
"github.com/limitcool/starter/internal/pkg/errorx"
"github.com/limitcool/starter/internal/pkg/logger"
"github.com/spf13/cast"
"gorm.io/gorm"
)
// FileHandler 文件处理器
type FileHandler struct {
*BaseHandler
storage *filestore.Storage
}
// NewFileHandler 创建文件处理器
func NewFileHandler(db *gorm.DB, config *configs.Config, storage *filestore.Storage) *FileHandler {
handler := &FileHandler{
BaseHandler: NewBaseHandler(db, config),
storage: storage,
}
handler.LogInit("FileHandler")
return handler
}
// GetUploadURL 获取文件上传预签名URL前端直接上传到MinIO
func (h *FileHandler) GetUploadURL(ctx *gin.Context) {
reqCtx := ctx.Request.Context()
// 获取当前用户信息
userID, exists := ctx.Get("user_id")
if !exists {
logger.WarnContext(reqCtx, "GetUploadURL 未找到用户ID")
response.Error(ctx, errorx.ErrUnauthorized.WithMsg("用户未登录"))
return
}
id := cast.ToInt64(userID)
// 获取用户类型
userType := uint8(1) // 默认为普通用户
if isAdmin, exists := ctx.Get("is_admin"); exists && cast.ToBool(isAdmin) {
userType = uint8(2) // 管理员
}
// 解析请求参数
var req struct {
Filename string `json:"filename" binding:"required"`
ContentType string `json:"content_type" binding:"required"`
FileType string `json:"file_type" binding:"required"` // image, document, video, audio
IsPublic bool `json:"is_public"`
Usage string `json:"usage"` // avatar, document, etc.
}
if err := ctx.ShouldBindJSON(&req); err != nil {
logger.WarnContext(reqCtx, "GetUploadURL 请求参数无效", "error", err)
response.Error(ctx, errorx.ErrInvalidParams.WithError(err))
return
}
// 验证文件类型
ext := strings.ToLower(filepath.Ext(req.Filename))
if !h.FileUtil.IsAllowedFileType(ext, req.FileType) {
logger.WarnContext(reqCtx, "GetUploadURL 不支持的文件类型",
"user_id", id,
"file_type", req.FileType,
"extension", ext)
response.Error(ctx, errorx.ErrInvalidParams.WithMsg("不支持的文件类型"))
return
}
// 生成唯一的文件名和存储路径
fileName := h.FileUtil.GenerateFileName(req.Filename)
storagePath := h.FileUtil.GetStoragePath(req.FileType, fileName, req.IsPublic)
// 生成上传预签名URL
uploadURL, err := h.storage.GetUploadPresignedURL(reqCtx, storagePath, req.ContentType, 15) // 15分钟有效期
if err != nil {
logger.ErrorContext(reqCtx, "GetUploadURL 生成上传预签名URL失败",
"error", err,
"user_id", id,
"storage_path", storagePath)
response.Error(ctx, errorx.WrapError(err, "生成上传链接失败"))
return
}
// 创建文件记录状态为pending等待上传完成确认
fileRepo := model.NewFileRepo(h.DB)
fileModel := &model.File{
Name: fileName,
OriginalName: req.Filename,
Path: storagePath,
URL: "", // 上传完成后再设置
Type: req.FileType,
Usage: req.Usage,
Size: 0, // 上传完成后再设置
MimeType: req.ContentType,
Extension: ext,
StorageType: string(h.storage.Config.Type),
UploadedBy: id,
UploadedByType: uint8(userType),
UploadedAt: time.Now(),
Status: 0, // 0=pending, 1=completed
IsPublic: req.IsPublic,
}
if err := fileRepo.Create(reqCtx, fileModel); err != nil {
logger.ErrorContext(reqCtx, "GetUploadURL 创建文件记录失败",
"error", err,
"user_id", id)
response.Error(ctx, err)
return
}
// 构建响应数据
result := map[string]interface{}{
"file_id": fileModel.ID,
"upload_url": uploadURL,
"storage_path": storagePath,
"expires_in": 15, // 分钟
"expires_at": time.Now().Add(15 * time.Minute).Unix(),
"method": "PUT", // 上传方法
"headers": map[string]string{
"Content-Type": req.ContentType,
},
}
logger.InfoContext(reqCtx, "GetUploadURL 生成上传URL成功",
"user_id", id,
"file_id", fileModel.ID,
"storage_path", storagePath,
"is_public", req.IsPublic)
response.Success(ctx, result)
}
// ConfirmUpload 确认文件上传完成
func (h *FileHandler) ConfirmUpload(ctx *gin.Context) {
reqCtx := ctx.Request.Context()
// 获取当前用户信息
userID, exists := ctx.Get("user_id")
if !exists {
logger.WarnContext(reqCtx, "ConfirmUpload 未找到用户ID")
response.Error(ctx, errorx.ErrUnauthorized.WithMsg("用户未登录"))
return
}
id := cast.ToInt64(userID)
// 解析请求参数
var req struct {
FileID uint `json:"file_id" binding:"required"`
Size int64 `json:"size" binding:"required"`
}
if err := ctx.ShouldBindJSON(&req); err != nil {
logger.WarnContext(reqCtx, "ConfirmUpload 请求参数无效", "error", err)
response.Error(ctx, errorx.ErrInvalidParams.WithError(err))
return
}
// 创建文件仓库
fileRepo := model.NewFileRepo(h.DB)
// 获取文件记录
fileModel, err := fileRepo.GetByID(reqCtx, req.FileID)
if err != nil {
logger.ErrorContext(reqCtx, "ConfirmUpload 获取文件记录失败",
"error", err,
"file_id", req.FileID)
response.Error(ctx, err)
return
}
// 检查文件所有权
if fileModel.UploadedBy != id {
// 检查是否是管理员
isAdmin, exists := ctx.Get("is_admin")
if !exists || !cast.ToBool(isAdmin) {
logger.WarnContext(reqCtx, "ConfirmUpload 无权限操作文件",
"file_id", req.FileID,
"user_id", id,
"uploaded_by", fileModel.UploadedBy)
response.Error(ctx, errorx.ErrForbidden.WithMsg("无权限操作此文件"))
return
}
}
// 检查文件状态
if fileModel.Status != 0 {
logger.WarnContext(reqCtx, "ConfirmUpload 文件已确认",
"file_id", req.FileID,
"status", fileModel.Status)
response.Error(ctx, errorx.ErrInvalidParams.WithMsg("文件已确认,无需重复操作"))
return
}
// 验证文件是否真的存在于存储中
exists, err = h.storage.Exists(reqCtx, fileModel.Path)
if err != nil {
logger.ErrorContext(reqCtx, "ConfirmUpload 检查文件存在性失败",
"error", err,
"file_id", req.FileID,
"path", fileModel.Path)
response.Error(ctx, errorx.WrapError(err, "验证文件失败"))
return
}
if !exists {
logger.WarnContext(reqCtx, "ConfirmUpload 文件不存在于存储中",
"file_id", req.FileID,
"path", fileModel.Path)
response.Error(ctx, errorx.ErrNotFound.WithMsg("文件上传未完成或已被删除"))
return
}
// 生成文件访问URL
fileURL, err := h.storage.GetURL(reqCtx, fileModel.Path)
if err != nil {
logger.ErrorContext(reqCtx, "ConfirmUpload 获取文件访问URL失败",
"error", err,
"file_id", req.FileID,
"path", fileModel.Path)
response.Error(ctx, errorx.WrapError(err, "生成文件访问URL失败"))
return
}
// 更新文件记录
fileModel.Size = req.Size
fileModel.URL = fileURL
fileModel.Status = 1 // 完成状态
fileModel.UploadedAt = time.Now()
if err := fileRepo.Update(reqCtx, fileModel); err != nil {
logger.ErrorContext(reqCtx, "ConfirmUpload 更新文件记录失败",
"error", err,
"file_id", req.FileID)
response.Error(ctx, err)
return
}
logger.InfoContext(reqCtx, "ConfirmUpload 确认上传成功",
"user_id", id,
"file_id", req.FileID,
"size", req.Size)
response.Success(ctx, fileModel)
}
// UploadFile 上传文件(保留旧接口,但标记为废弃)
func (h *FileHandler) UploadFile(ctx *gin.Context) {
// 获取请求上下文
reqCtx := ctx.Request.Context()
// 从上下文中获取用户ID
userID, exists := ctx.Get("user_id")
if !exists {
logger.WarnContext(reqCtx, "UploadFile 未找到用户ID")
response.Error(ctx, errorx.ErrUserNoLogin)
return
}
// 转换用户ID
id := cast.ToInt64(userID)
// 获取用户类型 - 根据is_admin字段推断
isAdminValue, exists := ctx.Get("is_admin")
if !exists {
logger.WarnContext(reqCtx, "UploadFile 未找到用户管理员标识")
response.Error(ctx, errorx.ErrUserNoLogin)
return
}
// 根据is_admin推断用户类型1=管理员2=普通用户)
var userType uint8 = 2 // 默认为普通用户
if isAdmin, ok := isAdminValue.(bool); ok && isAdmin {
userType = 1 // 管理员
}
// 获取文件
fileHeader, err := ctx.FormFile("file")
if err != nil {
logger.WarnContext(reqCtx, "UploadFile 获取文件失败",
"error", err,
"user_id", id)
response.Error(ctx, errorx.ErrInvalidParams.WithError(err))
return
}
// 获取文件类型
fileType := ctx.DefaultPostForm("type", model.FileTypeOther)
// 获取是否公开
isPublic := ctx.DefaultPostForm("is_public", "false") == "true"
// 获取文件基本信息
originalName := fileHeader.Filename
ext := strings.ToLower(filepath.Ext(originalName))
mimeType := filestore.GetMimeType(originalName)
size := fileHeader.Size
// 验证文件类型
if !h.FileUtil.IsAllowedFileType(ext, fileType) {
logger.WarnContext(reqCtx, "UploadFile 文件类型不允许",
"user_id", id,
"file_type", fileType,
"ext", ext)
response.Error(ctx, errorx.ErrInvalidParams.WithMsg("文件类型不允许"))
return
}
// 验证文件大小
if !h.FileUtil.IsAllowedFileSize(size, fileType) {
logger.WarnContext(reqCtx, "UploadFile 文件大小超出限制",
"user_id", id,
"file_type", fileType,
"size", size)
response.Error(ctx, errorx.ErrInvalidParams.WithMsg("文件大小超出限制"))
return
}
// 打开上传的文件
file, err := fileHeader.Open()
if err != nil {
logger.ErrorContext(reqCtx, "UploadFile 打开文件失败",
"error", err,
"user_id", id)
response.Error(ctx, errorx.ErrInvalidParams.WithError(err))
return
}
defer file.Close()
// 生成文件名和存储路径
fileName := h.FileUtil.GenerateFileName(originalName)
storagePath := h.FileUtil.GetStoragePath(fileType, fileName, isPublic)
// 上传文件到存储权限由路径和Bucket Policy控制
err = h.storage.Put(reqCtx, storagePath, file)
if err != nil {
logger.ErrorContext(reqCtx, "UploadFile 上传文件到存储失败",
"error", err,
"user_id", id,
"storage_path", storagePath)
response.Error(ctx, errorx.WrapError(err, fmt.Sprintf("上传文件到存储失败: %s", storagePath)))
return
}
// 获取文件访问URL
fileURL, err := h.storage.GetURL(reqCtx, storagePath)
if err != nil {
logger.ErrorContext(reqCtx, "UploadFile 获取文件访问URL失败",
"error", err,
"user_id", id,
"storage_path", storagePath)
response.Error(ctx, errorx.WrapError(err, fmt.Sprintf("获取文件访问URL失败: %s", storagePath)))
return
}
// 创建文件仓库
fileRepo := model.NewFileRepo(h.DB)
// 记录到数据库
fileModel := &model.File{
Name: fileName,
OriginalName: originalName,
Path: storagePath,
URL: fileURL,
Type: fileType,
Usage: model.FileUsageGeneral, // 默认用途为通用
Size: size,
MimeType: mimeType,
Extension: ext,
StorageType: string(h.storage.Config.Type),
UploadedBy: id,
UploadedByType: uint8(userType),
UploadedAt: time.Now(),
Status: 1, // 状态正常
IsPublic: isPublic, // 设置是否公开
}
if err := fileRepo.Create(reqCtx, fileModel); err != nil {
logger.ErrorContext(reqCtx, "UploadFile 保存文件记录失败",
"error", err,
"user_id", id)
response.Error(ctx, err)
return
}
logger.InfoContext(reqCtx, "UploadFile 上传文件成功",
"user_id", id,
"file_id", fileModel.ID,
"file_name", fileModel.Name)
response.Success(ctx, fileModel)
}
// GetFileURL 获取文件访问URL
func (h *FileHandler) GetFileURL(ctx *gin.Context) {
reqCtx := ctx.Request.Context()
// 获取文件ID
fileIDStr := ctx.Param("id")
fileID := cast.ToUint(fileIDStr)
if fileID == 0 {
logger.WarnContext(reqCtx, "GetFileURL 文件ID无效", "file_id", fileIDStr)
response.Error(ctx, errorx.ErrInvalidParams.WithMsg("文件ID无效"))
return
}
// 获取URL类型temporary临时默认或 permanent长期
urlType := ctx.DefaultQuery("type", "temporary")
if urlType != "temporary" && urlType != "permanent" {
logger.WarnContext(reqCtx, "GetFileURL URL类型无效", "type", urlType)
response.Error(ctx, errorx.ErrInvalidParams.WithMsg("URL类型无效只支持 temporary 或 permanent"))
return
}
// 获取过期时间分钟仅对临时URL有效
expiresIn := cast.ToInt(ctx.DefaultQuery("expires_in", "60")) // 默认1小时
if expiresIn < 1 || expiresIn > 10080 { // 最大7天
expiresIn = 60
}
// 创建文件仓库
fileRepo := model.NewFileRepo(h.DB)
// 获取文件信息
fileModel, err := fileRepo.GetByID(reqCtx, fileID)
if err != nil {
logger.ErrorContext(reqCtx, "GetFileURL 获取文件信息失败",
"error", err,
"file_id", fileID)
response.Error(ctx, err)
return
}
// 检查文件权限
if !fileModel.IsPublic {
// 获取当前用户信息
userID, exists := ctx.Get("user_id")
if !exists {
logger.WarnContext(reqCtx, "GetFileURL 未授权访问私有文件", "file_id", fileID)
response.Error(ctx, errorx.ErrUnauthorized.WithMsg("需要登录才能访问此文件"))
return
}
// 检查是否是文件上传者或管理员
if cast.ToInt64(userID) != fileModel.UploadedBy {
// 检查是否是管理员
isAdmin, exists := ctx.Get("is_admin")
if !exists || !cast.ToBool(isAdmin) {
logger.WarnContext(reqCtx, "GetFileURL 无权限访问文件",
"file_id", fileID,
"user_id", userID,
"uploaded_by", fileModel.UploadedBy)
response.Error(ctx, errorx.ErrForbidden.WithMsg("无权限访问此文件"))
return
}
}
}
var fileURL string
var expiresAt *int64
switch urlType {
case "permanent":
// 长期URL仅支持公开文件直接返回MinIO的公开URL
if !fileModel.IsPublic {
logger.WarnContext(reqCtx, "GetFileURL 私有文件不支持长期URL", "file_id", fileID)
response.Error(ctx, errorx.ErrInvalidParams.WithMsg("私有文件不支持长期URL"))
return
}
// 检查文件是否在public路径下
if !strings.HasPrefix(fileModel.Path, "public/") {
logger.WarnContext(reqCtx, "GetFileURL 文件路径不在public目录下", "file_id", fileID, "path", fileModel.Path)
response.Error(ctx, errorx.ErrInvalidParams.WithMsg("文件不在公开目录中"))
return
}
// 返回MinIO的直接访问URL无需签名长期有效
directURL, err := h.storage.GetDirectURL(reqCtx, fileModel.Path)
if err != nil {
logger.ErrorContext(reqCtx, "GetFileURL 获取直接URL失败",
"error", err,
"file_id", fileID,
"path", fileModel.Path)
response.Error(ctx, errorx.WrapError(err, "生成文件访问链接失败"))
return
}
fileURL = directURL
// 长期URL无过期时间
case "temporary":
// 临时URL使用预签名URL
presignedURL, err := h.storage.GetPresignedURL(reqCtx, fileModel.Path, expiresIn)
if err != nil {
logger.ErrorContext(reqCtx, "GetFileURL 生成预签名URL失败",
"error", err,
"file_id", fileID,
"path", fileModel.Path)
response.Error(ctx, errorx.WrapError(err, "生成文件访问链接失败"))
return
}
fileURL = presignedURL
expTime := time.Now().Add(time.Duration(expiresIn) * time.Minute).Unix()
expiresAt = &expTime
}
// 构建响应数据
result := map[string]interface{}{
"file_id": fileID,
"file_name": fileModel.Name,
"original_name": fileModel.OriginalName,
"url": fileURL,
"type": urlType,
"mime_type": fileModel.MimeType,
"size": fileModel.Size,
"is_public": fileModel.IsPublic,
}
// 添加过期信息临时和长期URL都有过期时间
if expiresAt != nil {
result["expires_at"] = *expiresAt
if urlType == "temporary" {
result["expires_in"] = expiresIn
} else {
result["expires_in"] = 7 * 24 * 60 // 7天
}
}
logger.InfoContext(reqCtx, "GetFileURL 获取文件URL成功",
"file_id", fileID,
"type", urlType,
"expires_in", expiresIn)
response.Success(ctx, result)
}
// getBaseURL 获取基础URL
func (h *FileHandler) getBaseURL(ctx *gin.Context) string {
scheme := "http"
if ctx.Request.TLS != nil {
scheme = "https"
}
return fmt.Sprintf("%s://%s", scheme, ctx.Request.Host)
}
// ServePublicFile 提供公开文件访问长期URL的实现
func (h *FileHandler) ServePublicFile(ctx *gin.Context) {
reqCtx := ctx.Request.Context()
// 获取文件ID
fileIDStr := ctx.Param("id")
fileID := cast.ToUint(fileIDStr)
if fileID == 0 {
logger.WarnContext(reqCtx, "ServePublicFile 文件ID无效", "file_id", fileIDStr)
ctx.JSON(404, gin.H{"error": "文件不存在"})
return
}
// 创建文件仓库
fileRepo := model.NewFileRepo(h.DB)
// 获取文件信息
fileModel, err := fileRepo.GetByID(reqCtx, fileID)
if err != nil {
logger.ErrorContext(reqCtx, "ServePublicFile 获取文件信息失败",
"error", err,
"file_id", fileID)
ctx.JSON(404, gin.H{"error": "文件不存在"})
return
}
// 检查是否是公开文件
if !fileModel.IsPublic {
logger.WarnContext(reqCtx, "ServePublicFile 尝试访问私有文件", "file_id", fileID)
ctx.JSON(403, gin.H{"error": "文件不可公开访问"})
return
}
// 检查文件是否在public路径下
if !strings.HasPrefix(fileModel.Path, "public/") {
logger.WarnContext(reqCtx, "ServePublicFile 文件不在public目录下", "file_id", fileID, "path", fileModel.Path)
ctx.JSON(403, gin.H{"error": "文件不在公开目录中"})
return
}
// 获取MinIO直接访问URL并重定向避免服务器代理
directURL, err := h.storage.GetDirectURL(reqCtx, fileModel.Path)
if err != nil {
logger.ErrorContext(reqCtx, "ServePublicFile 获取直接URL失败",
"error", err,
"file_id", fileID)
ctx.JSON(500, gin.H{"error": "文件访问失败"})
return
}
// 302重定向到MinIO直接URL
ctx.Redirect(302, directURL)
logger.InfoContext(reqCtx, "ServePublicFile 重定向到直接URL成功",
"file_id", fileID,
"direct_url", directURL)
}
// ListFiles 获取文件列表
func (h *FileHandler) ListFiles(ctx *gin.Context) {
reqCtx := ctx.Request.Context()
// 获取查询参数
page := cast.ToInt(ctx.DefaultQuery("page", "1"))
pageSize := cast.ToInt(ctx.DefaultQuery("page_size", "20"))
fileType := ctx.Query("type")
usage := ctx.Query("usage")
// 参数验证
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
// 创建文件仓库
fileRepo := model.NewFileRepo(h.DB)
// 获取文件列表
files, total, err := fileRepo.ListFiles(reqCtx, page, pageSize, fileType, usage, nil)
if err != nil {
logger.ErrorContext(reqCtx, "ListFiles 获取文件列表失败",
"error", err,
"page", page,
"page_size", pageSize)
response.Error(ctx, err)
return
}
// 构建响应数据
result := map[string]interface{}{
"files": files,
"pagination": map[string]interface{}{
"page": page,
"page_size": pageSize,
"total": total,
"total_page": (total + int64(pageSize) - 1) / int64(pageSize),
},
}
logger.InfoContext(reqCtx, "ListFiles 获取文件列表成功",
"page", page,
"page_size", pageSize,
"total", total)
response.Success(ctx, result)
}
// GetFileInfo 获取文件信息
func (h *FileHandler) GetFileInfo(ctx *gin.Context) {
reqCtx := ctx.Request.Context()
// 获取文件ID
fileIDStr := ctx.Param("id")
fileID := cast.ToUint(fileIDStr)
if fileID == 0 {
logger.WarnContext(reqCtx, "GetFileInfo 文件ID无效", "file_id", fileIDStr)
response.Error(ctx, errorx.ErrInvalidParams.WithMsg("文件ID无效"))
return
}
// 创建文件仓库
fileRepo := model.NewFileRepo(h.DB)
// 获取文件信息
fileModel, err := fileRepo.GetByID(reqCtx, fileID)
if err != nil {
logger.ErrorContext(reqCtx, "GetFileInfo 获取文件信息失败",
"error", err,
"file_id", fileID)
response.Error(ctx, err)
return
}
logger.InfoContext(reqCtx, "GetFileInfo 获取文件信息成功",
"file_id", fileID,
"file_name", fileModel.Name)
response.Success(ctx, fileModel)
}