This commit is contained in:
2026-10-27 22:48:36 +08:00
parent cf832f0962
commit 1231251ea4
13 changed files with 946 additions and 0 deletions
+176
View File
@@ -0,0 +1,176 @@
// Package filesvc 是文件上传/下载/删除的业务逻辑层。
// 负责大小/类型校验、生成存储 key、调用存储后端并持久化元数据。
package filesvc
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"mime"
"path"
"strings"
"time"
"portal-mini-go/internal/model"
"portal-mini-go/internal/pkg/config"
"portal-mini-go/internal/pkg/errors"
"portal-mini-go/internal/pkg/storage"
"portal-mini-go/internal/pkg/types"
)
// 文件相关业务错误(业务码沿用现有 40xxx/41xxx/50xxx 段)。
var (
ErrFileTooLarge = errors.New(41300, "文件大小超过限制")
ErrFileTypeForbid = errors.New(41500, "不支持的文件类型")
)
// FileRepository 是文件元数据仓储接口,便于在单测中以 mock 替换真实 DB。
type FileRepository interface {
Create(ctx context.Context, f *model.File) error
GetByID(ctx context.Context, id uint64) (*model.File, error)
Delete(ctx context.Context, id uint64) error
}
// Service 文件上传业务服务。
type Service struct {
repo FileRepository
store storage.Storage
cfg config.UploadConfig
}
func New(repo FileRepository, store storage.Storage, cfg config.UploadConfig) *Service {
return &Service{repo: repo, store: store, cfg: cfg}
}
// UploadInput 上传入参。
type UploadInput struct {
Reader io.Reader
Size int64 // 字节;<=0 视为非法
Filename string // 原始文件名,用于取扩展名与展示
UploaderType string // admin | ma
UploaderID uint64
}
// UploadResult 上传成功后的返回摘要。
type UploadResult struct {
ID uint64 `json:"id"`
URL string `json:"url"`
Filename string `json:"filename"`
OriginName string `json:"origin_name"`
MimeType string `json:"mime_type"`
Size int64 `json:"size"`
}
// Upload 校验后落盘并返回元数据。
func (s *Service) Upload(ctx context.Context, in UploadInput) (*UploadResult, error) {
if in.Size <= 0 {
return nil, errors.ErrBadRequest
}
if s.cfg.MaxSize > 0 && in.Size > s.cfg.MaxSize {
return nil, ErrFileTooLarge
}
ext := strings.ToLower(strings.TrimPrefix(path.Ext(in.Filename), "."))
if !s.allowedExt(ext) {
return nil, ErrFileTypeForbid
}
key := s.genKey(ext)
if err := s.store.Put(ctx, key, in.Reader, in.Size); err != nil {
return nil, errors.Wrap(50000, "save file to storage", err)
}
rec := &model.File{
StorageKey: key,
Filename: path.Base(key),
OriginName: in.Filename,
MimeType: detectMime(ext),
Ext: ext,
Size: in.Size,
UploaderType: in.UploaderType,
UploaderID: in.UploaderID,
URL: s.store.URL(key),
CreatedAt: types.Time{Time: time.Now()},
}
if err := s.repo.Create(ctx, rec); err != nil {
// 回滚:存储已写入但元数据落库失败,删除孤儿文件。
_ = s.store.Delete(ctx, key)
return nil, err
}
return &UploadResult{
ID: rec.ID,
URL: rec.URL,
Filename: rec.Filename,
OriginName: rec.OriginName,
MimeType: rec.MimeType,
Size: rec.Size,
}, nil
}
// Get 查询文件元数据。
func (s *Service) Get(ctx context.Context, id uint64) (*model.File, error) {
return s.repo.GetByID(ctx, id)
}
// Download 返回文件读取流与元数据;调用方负责关闭流。
func (s *Service) Download(ctx context.Context, id uint64) (io.ReadCloser, *model.File, error) {
f, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, nil, err
}
rc, err := s.store.Get(ctx, f.StorageKey)
if err != nil {
return nil, nil, errors.Wrap(50000, "read file from storage", err)
}
return rc, f, nil
}
// Delete 同时删除存储内容与元数据。
func (s *Service) Delete(ctx context.Context, id uint64) error {
f, err := s.repo.GetByID(ctx, id)
if err != nil {
return err
}
if err := s.store.Delete(ctx, f.StorageKey); err != nil {
return errors.Wrap(50000, "delete file from storage", err)
}
return s.repo.Delete(ctx, id)
}
// allowedExt 校验扩展名是否在白名单内;白名单为空时放行(由 MaxSize 兜底)。
func (s *Service) allowedExt(ext string) bool {
if len(s.cfg.AllowedExt) == 0 {
return true
}
for _, e := range s.cfg.AllowedExt {
if strings.EqualFold(ext, strings.TrimPrefix(e, ".")) {
return true
}
}
return false
}
// genKey 生成 "年/月/日/<rand>.<ext>" 形式的存储 key。
func (s *Service) genKey(ext string) string {
b := make([]byte, 16)
_, _ = rand.Read(b)
name := hex.EncodeToString(b)
if ext != "" {
name += "." + ext
}
now := time.Now()
return fmt.Sprintf("%04d/%02d/%02d/%s", now.Year(), now.Month(), now.Day(), name)
}
// detectMime 依据扩展名推断 MIME,未知回退为 application/octet-stream。
func detectMime(ext string) string {
if ext == "" {
return "application/octet-stream"
}
if t := mime.TypeByExtension("." + ext); t != "" {
return t
}
return "application/octet-stream"
}