file
This commit is contained in:
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package filesvc_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"portal-mini-go/internal/model"
|
||||
"portal-mini-go/internal/pkg/config"
|
||||
"portal-mini-go/internal/pkg/storage"
|
||||
filesvc "portal-mini-go/internal/service/file"
|
||||
)
|
||||
|
||||
// --- 测试替身 ---
|
||||
|
||||
type fakeStorage struct {
|
||||
putErr error
|
||||
gotKey string
|
||||
gotSize int64
|
||||
data map[string][]byte
|
||||
}
|
||||
|
||||
func newFakeStorage() *fakeStorage { return &fakeStorage{data: map[string][]byte{}} }
|
||||
|
||||
func (s *fakeStorage) Put(_ context.Context, key string, r io.Reader, size int64) error {
|
||||
if s.putErr != nil {
|
||||
return s.putErr
|
||||
}
|
||||
b, _ := io.ReadAll(r)
|
||||
s.data[key] = b
|
||||
s.gotKey = key
|
||||
s.gotSize = size
|
||||
return nil
|
||||
}
|
||||
func (s *fakeStorage) Get(_ context.Context, key string) (io.ReadCloser, error) {
|
||||
b, ok := s.data[key]
|
||||
if !ok {
|
||||
return nil, errors.New("not found")
|
||||
}
|
||||
return io.NopCloser(bytes.NewReader(b)), nil
|
||||
}
|
||||
func (s *fakeStorage) Delete(_ context.Context, key string) error {
|
||||
delete(s.data, key)
|
||||
return nil
|
||||
}
|
||||
func (s *fakeStorage) URL(key string) string { return "/files/static/" + key }
|
||||
|
||||
type fakeRepo struct {
|
||||
created *model.File
|
||||
byID *model.File
|
||||
getErr error
|
||||
createErr error
|
||||
delErr error
|
||||
deletedID uint64
|
||||
}
|
||||
|
||||
func (r *fakeRepo) Create(_ context.Context, f *model.File) error { r.created = f; return r.createErr }
|
||||
func (r *fakeRepo) GetByID(_ context.Context, id uint64) (*model.File, error) {
|
||||
if r.getErr != nil {
|
||||
return nil, r.getErr
|
||||
}
|
||||
return r.byID, nil
|
||||
}
|
||||
func (r *fakeRepo) Delete(_ context.Context, id uint64) error { r.deletedID = id; return r.delErr }
|
||||
|
||||
// 编译期接口断言。
|
||||
var (
|
||||
_ filesvc.FileRepository = (*fakeRepo)(nil)
|
||||
_ storage.Storage = (*fakeStorage)(nil)
|
||||
)
|
||||
|
||||
func newSvc(cfg config.UploadConfig, store *fakeStorage, repo *fakeRepo) *filesvc.Service {
|
||||
return filesvc.New(repo, store, cfg)
|
||||
}
|
||||
|
||||
// --- Upload ---
|
||||
|
||||
func TestUpload_Success(t *testing.T) {
|
||||
store := newFakeStorage()
|
||||
repo := &fakeRepo{}
|
||||
svc := newSvc(config.UploadConfig{MaxSize: 1 << 20}, store, repo)
|
||||
|
||||
res, err := svc.Upload(context.Background(), filesvc.UploadInput{
|
||||
Reader: bytes.NewReader([]byte("hello")),
|
||||
Size: 5,
|
||||
Filename: "我的图片.png",
|
||||
UploaderType: "admin",
|
||||
UploaderID: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, res)
|
||||
require.Equal(t, uint64(1), repo.created.UploaderID)
|
||||
require.Equal(t, "admin", repo.created.UploaderType)
|
||||
require.Equal(t, "png", repo.created.Ext)
|
||||
require.Equal(t, "image/png", repo.created.MimeType)
|
||||
require.NotEmpty(t, repo.created.StorageKey)
|
||||
require.Equal(t, store.URL(repo.created.StorageKey), res.URL)
|
||||
require.Contains(t, store.gotKey, ".png")
|
||||
}
|
||||
|
||||
func TestUpload_TooLarge(t *testing.T) {
|
||||
svc := newSvc(config.UploadConfig{MaxSize: 10}, newFakeStorage(), &fakeRepo{})
|
||||
_, err := svc.Upload(context.Background(), filesvc.UploadInput{
|
||||
Reader: bytes.NewReader(make([]byte, 11)),
|
||||
Size: 11,
|
||||
Filename: "a.png",
|
||||
})
|
||||
require.ErrorIs(t, err, filesvc.ErrFileTooLarge)
|
||||
}
|
||||
|
||||
func TestUpload_TypeForbidden(t *testing.T) {
|
||||
svc := newSvc(config.UploadConfig{MaxSize: 1 << 20, AllowedExt: []string{"jpg"}}, newFakeStorage(), &fakeRepo{})
|
||||
_, err := svc.Upload(context.Background(), filesvc.UploadInput{
|
||||
Reader: bytes.NewReader([]byte("x")),
|
||||
Size: 1,
|
||||
Filename: "a.png",
|
||||
})
|
||||
require.ErrorIs(t, err, filesvc.ErrFileTypeForbid)
|
||||
}
|
||||
|
||||
func TestUpload_EmptyWhitelistAllowsAll(t *testing.T) {
|
||||
store := newFakeStorage()
|
||||
repo := &fakeRepo{}
|
||||
svc := newSvc(config.UploadConfig{MaxSize: 1 << 20}, store, repo)
|
||||
_, err := svc.Upload(context.Background(), filesvc.UploadInput{
|
||||
Reader: bytes.NewReader([]byte("x")),
|
||||
Size: 1,
|
||||
Filename: "a.unknown-ext",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "unknown-ext", repo.created.Ext)
|
||||
}
|
||||
|
||||
func TestUpload_StoreErrorNoCreate(t *testing.T) {
|
||||
store := newFakeStorage()
|
||||
store.putErr = errors.New("disk full")
|
||||
repo := &fakeRepo{}
|
||||
svc := newSvc(config.UploadConfig{}, store, repo)
|
||||
_, err := svc.Upload(context.Background(), filesvc.UploadInput{
|
||||
Reader: bytes.NewReader([]byte("x")), Size: 1, Filename: "a.png",
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Nil(t, repo.created, "存储失败不应写入元数据")
|
||||
}
|
||||
|
||||
func TestUpload_RollbackOnRepoError(t *testing.T) {
|
||||
store := newFakeStorage()
|
||||
repo := &fakeRepo{createErr: errors.New("db down")}
|
||||
svc := newSvc(config.UploadConfig{}, store, repo)
|
||||
_, err := svc.Upload(context.Background(), filesvc.UploadInput{
|
||||
Reader: bytes.NewReader([]byte("x")), Size: 1, Filename: "a.png",
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Empty(t, store.data, "元数据落库失败应回滚已落盘文件")
|
||||
}
|
||||
|
||||
// --- Get / Download / Delete ---
|
||||
|
||||
func TestGet(t *testing.T) {
|
||||
want := &model.File{ID: 7, OriginName: "doc.pdf"}
|
||||
svc := newSvc(config.UploadConfig{}, newFakeStorage(), &fakeRepo{byID: want})
|
||||
got, err := svc.Get(context.Background(), 7)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, want, got)
|
||||
}
|
||||
|
||||
func TestDownload(t *testing.T) {
|
||||
store := newFakeStorage()
|
||||
store.data["2006/01/01/abc.png"] = []byte("binary")
|
||||
repo := &fakeRepo{byID: &model.File{ID: 3, StorageKey: "2006/01/01/abc.png", MimeType: "image/png", Size: 6}}
|
||||
svc := newSvc(config.UploadConfig{}, store, repo)
|
||||
|
||||
rc, f, err := svc.Download(context.Background(), 3)
|
||||
require.NoError(t, err)
|
||||
defer rc.Close()
|
||||
b, _ := io.ReadAll(rc)
|
||||
require.Equal(t, []byte("binary"), b)
|
||||
require.Equal(t, "image/png", f.MimeType)
|
||||
}
|
||||
|
||||
func TestDelete(t *testing.T) {
|
||||
store := newFakeStorage()
|
||||
store.data["k"] = []byte("x")
|
||||
repo := &fakeRepo{byID: &model.File{ID: 9, StorageKey: "k"}}
|
||||
svc := newSvc(config.UploadConfig{}, store, repo)
|
||||
|
||||
require.NoError(t, svc.Delete(context.Background(), 9))
|
||||
require.Equal(t, uint64(9), repo.deletedID)
|
||||
require.Empty(t, store.data)
|
||||
}
|
||||
Reference in New Issue
Block a user