From f2033fd4b9dc761a4ce81537c3986c3104631ef7 Mon Sep 17 00:00:00 2001 From: shanshanzhong Date: Thu, 14 May 2026 23:33:50 -0700 Subject: [PATCH] feat: add rustfs direct upload endpoint --- apis/public/file.api | 18 ++++ .../handler/public/file/fileUploadHandler.go | 43 +++++++++ internal/handler/routes.go | 3 + internal/logic/public/file/fileuploadlogic.go | 88 +++++++++++++++++++ internal/types/types.go | 14 +++ pkg/storage/s3.go | 23 ++++- 6 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 internal/handler/public/file/fileUploadHandler.go create mode 100644 internal/logic/public/file/fileuploadlogic.go diff --git a/apis/public/file.api b/apis/public/file.api index e05b40c..a9b79ba 100644 --- a/apis/public/file.api +++ b/apis/public/file.api @@ -11,6 +11,20 @@ info ( import "../types.api" type ( + FileUploadRequest { + BizType string `form:"biz_type" validate:"required"` + } + + FileUploadResponse { + FileId string `json:"file_id"` + FileName string `json:"file_name"` + ObjectKey string `json:"object_key"` + Size int64 `json:"size"` + ContentType string `json:"content_type"` + Etag string `json:"etag"` + Status string `json:"status"` + } + FileUploadInitRequest { BizType string `json:"biz_type" validate:"required"` FileName string `json:"file_name" validate:"required"` @@ -48,6 +62,10 @@ type ( middleware: AuthMiddleware,DeviceMiddleware ) service ppanel { + @doc "Upload file to RustFS" + @handler FileUpload + post /upload (FileUploadRequest) returns (FileUploadResponse) + @doc "Init file upload" @handler FileUploadInit post /upload/init (FileUploadInitRequest) returns (FileUploadInitResponse) diff --git a/internal/handler/public/file/fileUploadHandler.go b/internal/handler/public/file/fileUploadHandler.go new file mode 100644 index 0000000..1108571 --- /dev/null +++ b/internal/handler/public/file/fileUploadHandler.go @@ -0,0 +1,43 @@ +package file + +import ( + "mime/multipart" + + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/logic/public/file" + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/result" +) + +// Upload file to RustFS +func FileUploadHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return func(c *gin.Context) { + var req types.FileUploadRequest + _ = c.ShouldBind(&req) + validateErr := svcCtx.Validate(&req) + if validateErr != nil { + result.ParamErrorResult(c, validateErr) + return + } + + fileHeader, err := c.FormFile("file") + if err != nil { + result.ParamErrorResult(c, err) + return + } + + fileReader, err := fileHeader.Open() + if err != nil { + result.HttpResult(c, nil, err) + return + } + defer func(file multipart.File) { + _ = file.Close() + }(fileReader) + + l := file.NewFileUploadLogic(c.Request.Context(), svcCtx) + resp, err := l.FileUpload(&req, fileHeader, fileReader) + result.HttpResult(c, resp, err) + } +} diff --git a/internal/handler/routes.go b/internal/handler/routes.go index 4475d8a..0b35e68 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -870,6 +870,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { publicFileGroupRouter.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx)) { + // Upload file to RustFS + publicFileGroupRouter.POST("/upload", publicFile.FileUploadHandler(serverCtx)) + // Init file upload publicFileGroupRouter.POST("/upload/init", publicFile.FileUploadInitHandler(serverCtx)) diff --git a/internal/logic/public/file/fileuploadlogic.go b/internal/logic/public/file/fileuploadlogic.go new file mode 100644 index 0000000..b9f2a7b --- /dev/null +++ b/internal/logic/public/file/fileuploadlogic.go @@ -0,0 +1,88 @@ +package file + +import ( + "bytes" + "context" + "io" + "mime/multipart" + "net/http" + "time" + + "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/logger" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" +) + +type FileUploadLogic struct { + logger.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +// Upload file to RustFS +func NewFileUploadLogic(ctx context.Context, svcCtx *svc.ServiceContext) *FileUploadLogic { + return &FileUploadLogic{ + Logger: logger.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *FileUploadLogic) FileUpload(req *types.FileUploadRequest, fileHeader *multipart.FileHeader, file multipart.File) (resp *types.FileUploadResponse, err error) { + u, err := currentUserFromContext(l.ctx) + if err != nil { + return nil, err + } + + if fileHeader == nil { + return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "file is required") + } + + contentType, err := sniffContentType(fileHeader, file) + if err != nil { + return nil, err + } + if err := validateInitRequest(l.svcCtx, req.BizType, fileHeader.Filename, contentType, fileHeader.Size); err != nil { + return nil, err + } + + now := time.Now() + fileID := buildFileID(u.Id, req.BizType, fileHeader.Filename) + objectKey := buildObjectKey(l.svcCtx.Config.S3.Prefix, u.Id, req.BizType, fileID, fileHeader.Filename, now) + + putResult, err := l.svcCtx.S3Store.PutObject(l.ctx, objectKey, file, fileHeader.Size, contentType) + if err != nil { + l.Errorw("put object failed", logger.Field("error", err.Error()), logger.Field("user_id", u.Id), logger.Field("file_id", fileID)) + return nil, err + } + + return &types.FileUploadResponse{ + FileId: fileID, + FileName: fileHeader.Filename, + ObjectKey: objectKey, + Size: fileHeader.Size, + ContentType: contentType, + Etag: putResult.ETag, + Status: fileUploadCompleteStatus, + }, nil +} + +func sniffContentType(fileHeader *multipart.FileHeader, file multipart.File) (string, error) { + if headerType := fileHeader.Header.Get("Content-Type"); headerType != "" { + return headerType, nil + } + if seeker, ok := file.(io.ReadSeeker); ok { + buf := make([]byte, 512) + n, readErr := seeker.Read(buf) + if readErr != nil && readErr != io.EOF { + return "", readErr + } + if _, err := seeker.Seek(0, io.SeekStart); err != nil { + return "", err + } + return http.DetectContentType(bytes.TrimRight(buf[:n], "\x00")), nil + } + return "application/octet-stream", nil +} diff --git a/internal/types/types.go b/internal/types/types.go index 0251a24..663ceeb 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -762,6 +762,20 @@ type FamilySummary struct { UpdatedAt int64 `json:"updated_at"` } +type FileUploadRequest struct { + BizType string `form:"biz_type" validate:"required"` +} + +type FileUploadResponse struct { + FileId string `json:"file_id"` + FileName string `json:"file_name"` + ObjectKey string `json:"object_key"` + Size int64 `json:"size"` + ContentType string `json:"content_type"` + Etag string `json:"etag"` + Status string `json:"status"` +} + type FileUploadCompleteRequest struct { FileId string `json:"file_id" validate:"required"` } diff --git a/pkg/storage/s3.go b/pkg/storage/s3.go index 3306496..c9d035d 100644 --- a/pkg/storage/s3.go +++ b/pkg/storage/s3.go @@ -3,6 +3,7 @@ package storage import ( "context" "fmt" + "io" "net/url" "strings" "time" @@ -22,11 +23,15 @@ type PresignUploadResult struct { } type ObjectMeta struct { - ETag string - ContentType string + ETag string + ContentType string ContentLength int64 } +type PutObjectResult struct { + ETag string +} + type S3Store struct { cfg config.S3Config client *s3.Client @@ -96,6 +101,20 @@ func (s *S3Store) HeadObject(ctx context.Context, objectKey string) (*ObjectMeta }, nil } +func (s *S3Store) PutObject(ctx context.Context, objectKey string, body io.Reader, size int64, contentType string) (*PutObjectResult, error) { + resp, err := s.client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(s.cfg.Bucket), + Key: aws.String(objectKey), + Body: body, + ContentLength: aws.Int64(size), + ContentType: aws.String(contentType), + }) + if err != nil { + return nil, err + } + return &PutObjectResult{ETag: strings.Trim(aws.ToString(resp.ETag), "\"")}, nil +} + func (s *S3Store) BuildObjectURL(objectKey string) string { if s.cfg.PublicBaseURL != "" { return strings.TrimRight(s.cfg.PublicBaseURL, "/") + "/" + strings.TrimLeft(objectKey, "/")