206 lines
5.7 KiB
Go
206 lines
5.7 KiB
Go
// 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"
|
|
"portal-mini-go/internal/repository/file"
|
|
)
|
|
|
|
// 文件相关业务错误(业务码沿用现有 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
|
|
List(ctx context.Context, filter file.ListFilter) ([]model.File, int64, 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)
|
|
}
|
|
|
|
// ListReq 文件列表查询条件。
|
|
type ListReq struct {
|
|
UploaderType string // 上传者类型(admin/ma),空表示不限
|
|
UploaderID uint64 // 上传者主键,0 表示不限
|
|
Keyword string // 模糊匹配 origin_name 或 filename
|
|
Page int // 页码,从 1 开始
|
|
Size int // 每页条数
|
|
}
|
|
|
|
// List 分页查询文件,缺省第 1 页、每页 20 条。
|
|
func (s *Service) List(ctx context.Context, req ListReq) ([]model.File, int64, error) {
|
|
page, size := req.Page, req.Size
|
|
if page <= 0 {
|
|
page = 1
|
|
}
|
|
if size <= 0 {
|
|
size = 20
|
|
}
|
|
return s.repo.List(ctx, file.ListFilter{
|
|
UploaderType: req.UploaderType,
|
|
UploaderID: req.UploaderID,
|
|
Keyword: req.Keyword,
|
|
Page: page,
|
|
Size: size,
|
|
})
|
|
}
|
|
|
|
// 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"
|
|
}
|