// 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 生成 "年/月/日/." 形式的存储 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" }