feat: add file upload workflow

This commit is contained in:
yml
2026-05-22 18:50:32 +08:00
parent 6f915b37d3
commit 236518ade4
22 changed files with 819 additions and 117 deletions
+9
View File
@@ -0,0 +1,9 @@
package file
type UploadDTO struct {
ObjectKey string `json:"object_key"`
URL string `json:"url"`
Filename string `json:"filename"`
ContentType string `json:"content_type"`
Size int64 `json:"size"`
}
+86
View File
@@ -0,0 +1,86 @@
package file
import (
"errors"
"net/http"
"strings"
"hfb_sys/backend/pkg/response"
"github.com/gin-gonic/gin"
)
type Handler struct {
service *Service
storage *Storage
}
func NewHandler(service *Service, storage *Storage) *Handler {
return &Handler{service: service, storage: storage}
}
func (h *Handler) Upload(c *gin.Context) {
header, err := c.FormFile("file")
if err != nil {
response.BadRequest(c, "请选择要上传的文件")
return
}
reader, err := header.Open()
if err != nil {
response.BadRequest(c, "文件读取失败")
return
}
defer func() {
_ = reader.Close()
}()
item, err := h.service.Upload(uploadRequest{
Context: c.Request.Context(),
Scene: c.PostForm("scene"),
Header: header,
Reader: reader,
ContentType: header.Header.Get("Content-Type"),
})
if err != nil {
writeFileError(c, err)
return
}
response.Created(c, item)
}
func (h *Handler) Object(c *gin.Context) {
if h.storage == nil {
response.ServiceUnavailable(c, "文件存储未连接")
return
}
key := strings.TrimSpace(c.Query("key"))
if key == "" || strings.Contains(key, "..") {
response.BadRequest(c, "文件 key 不正确")
return
}
object, err := h.storage.Get(c.Request.Context(), key)
if err != nil {
response.Error(c, http.StatusNotFound, "not_found", "文件不存在或暂不可访问")
return
}
defer func() {
_ = object.Reader.Close()
}()
contentType := object.ContentType
if contentType == "" {
contentType = "application/octet-stream"
}
c.Header("Content-Type", contentType)
c.Header("Cache-Control", "private, max-age=300")
c.DataFromReader(http.StatusOK, object.Size, contentType, object.Reader, nil)
}
func writeFileError(c *gin.Context, err error) {
switch {
case errors.Is(err, ErrDependencyUnavailable):
response.ServiceUnavailable(c, "文件存储未连接")
case errors.Is(err, ErrInvalidFile):
response.BadRequest(c, "文件不符合规则,仅支持 10MB 内的 JPG、PNG、WebP 或 PDF")
default:
response.Error(c, http.StatusInternalServerError, "internal_error", "文件服务暂时不可用")
}
}
+76
View File
@@ -0,0 +1,76 @@
package file
import (
"context"
"errors"
"mime/multipart"
"strings"
)
var (
ErrDependencyUnavailable = errors.New("dependency unavailable")
ErrInvalidFile = errors.New("invalid file")
)
const maxUploadSize = 10 * 1024 * 1024
var allowedContentTypes = map[string]bool{
"image/jpeg": true,
"image/png": true,
"image/webp": true,
"application/pdf": true,
}
type Service struct {
storage *Storage
}
func NewService(storage *Storage) *Service {
return &Service{storage: storage}
}
func (s *Service) Upload(req uploadRequest) (*UploadDTO, error) {
if s.storage == nil {
return nil, ErrDependencyUnavailable
}
if req.Header == nil || req.Reader == nil || req.Header.Size <= 0 || req.Header.Size > maxUploadSize {
return nil, ErrInvalidFile
}
contentType := req.ContentType
if contentType == "" {
contentType = req.Header.Header.Get("Content-Type")
}
if !allowedContentTypes[contentType] {
return nil, ErrInvalidFile
}
scene := normalizeScene(req.Scene)
key, err := s.storage.Put(req.Context, scene, req.Header, req.Reader, contentType)
if err != nil {
return nil, err
}
return &UploadDTO{
ObjectKey: key,
URL: "/api/files/object?key=" + key,
Filename: req.Header.Filename,
ContentType: contentType,
Size: req.Header.Size,
}, nil
}
func normalizeScene(scene string) string {
scene = strings.TrimSpace(strings.ToLower(scene))
switch scene {
case "listing", "handoff", "dispute", "realname", "avatar":
return scene
default:
return "misc"
}
}
type uploadRequest struct {
Context context.Context
Scene string
Header *multipart.FileHeader
Reader multipart.File
ContentType string
}
+122
View File
@@ -0,0 +1,122 @@
package file
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"mime/multipart"
"net/url"
"path"
"strings"
"time"
"hfb_sys/backend/internal/config"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
type Storage struct {
client *minio.Client
bucket string
}
type Object struct {
Reader io.ReadCloser
ContentType string
Size int64
}
func NewStorage(cfg config.StorageConfig) (*Storage, error) {
endpoint, secure, err := normalizeEndpoint(cfg.Endpoint)
if err != nil {
return nil, err
}
client, err := minio.New(endpoint, &minio.Options{
Creds: credentials.NewStaticV4(cfg.AccessKeyID, cfg.SecretAccessKey, ""),
Secure: secure,
})
if err != nil {
return nil, err
}
storage := &Storage{client: client, bucket: cfg.Bucket}
if err := storage.ensureBucket(context.Background()); err != nil {
return nil, err
}
return storage, nil
}
func (s *Storage) Put(ctx context.Context, scene string, header *multipart.FileHeader, reader io.Reader, contentType string) (string, error) {
key, err := newObjectKey(scene, header.Filename)
if err != nil {
return "", err
}
_, err = s.client.PutObject(ctx, s.bucket, key, reader, header.Size, minio.PutObjectOptions{
ContentType: contentType,
UserMetadata: map[string]string{
"original-filename": header.Filename,
},
})
if err != nil {
return "", err
}
return key, nil
}
func (s *Storage) Get(ctx context.Context, key string) (*Object, error) {
object, err := s.client.GetObject(ctx, s.bucket, key, minio.GetObjectOptions{})
if err != nil {
return nil, err
}
info, err := object.Stat()
if err != nil {
_ = object.Close()
return nil, err
}
return &Object{
Reader: object,
ContentType: info.ContentType,
Size: info.Size,
}, nil
}
func (s *Storage) ensureBucket(ctx context.Context) error {
exists, err := s.client.BucketExists(ctx, s.bucket)
if err != nil {
errResp := minio.ToErrorResponse(err)
if errResp.Code != "NoSuchBucket" && !strings.Contains(strings.ToLower(err.Error()), "bucket does not exist") {
return err
}
}
if exists {
return nil
}
return s.client.MakeBucket(ctx, s.bucket, minio.MakeBucketOptions{})
}
func normalizeEndpoint(raw string) (string, bool, error) {
parsed, err := url.Parse(raw)
if err != nil {
return "", false, err
}
if parsed.Scheme == "" {
return raw, false, nil
}
return parsed.Host, parsed.Scheme == "https", nil
}
func newObjectKey(scene string, filename string) (string, error) {
if scene == "" {
scene = "misc"
}
scene = strings.ToLower(scene)
now := time.Now()
token := make([]byte, 12)
if _, err := rand.Read(token); err != nil {
return "", err
}
ext := strings.ToLower(path.Ext(filename))
return fmt.Sprintf("%s/%04d/%02d/%02d/%s%s", scene, now.Year(), now.Month(), now.Day(), hex.EncodeToString(token), ext), nil
}