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
+146
View File
@@ -0,0 +1,146 @@
// Package filehdl 是文件上传服务的 HTTP 接入层,分别提供后台 admin 与小程序 ma 两套入口。
package filehdl
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"portal-mini-go/internal/middleware"
"portal-mini-go/internal/pkg/errors"
"portal-mini-go/internal/pkg/response"
filesvc "portal-mini-go/internal/service/file"
)
// Handler 文件上传请求处理。
type Handler struct {
svc *filesvc.Service
}
func NewHandler(svc *filesvc.Service) *Handler { return &Handler{svc: svc} }
// 表单字段名。
const formFieldFile = "file"
// Upload 接收 multipart 单文件上传。上传者身份由路由分组决定(admin 或 ma)。
func (h *Handler) Upload(c *gin.Context) {
fh, err := c.FormFile(formFieldFile)
if err != nil {
c.Error(errors.ErrBadRequest)
return
}
f, err := fh.Open()
if err != nil {
c.Error(errors.Wrap(50000, "open uploaded file", err))
return
}
defer f.Close()
typ, id := resolveUploader(c)
if id == 0 {
c.Error(errors.ErrUnauthorized)
return
}
res, err := h.svc.Upload(c.Request.Context(), filesvc.UploadInput{
Reader: f,
Size: fh.Size,
Filename: fh.Filename,
UploaderType: typ,
UploaderID: id,
})
if err != nil {
c.Error(err)
return
}
response.OK(c, res)
}
// Info 返回文件元数据。
func (h *Handler) Info(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
f, err := h.svc.Get(c.Request.Context(), id)
if err != nil {
c.Error(err)
return
}
response.OK(c, f)
}
// Raw 以流形式返回文件内容,按 MIME 设置 Content-Type,供预览/下载。
func (h *Handler) Raw(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
rc, f, err := h.svc.Download(c.Request.Context(), id)
if err != nil {
c.Error(err)
return
}
defer rc.Close()
extra := map[string]string{
"Content-Disposition": `inline; filename="` + f.Filename + `"`,
}
c.DataFromReader(http.StatusOK, f.Size, f.MimeType, rc, extra)
}
// Delete 删除文件(存储内容 + 元数据)。
func (h *Handler) Delete(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
if err := h.svc.Delete(c.Request.Context(), id); err != nil {
c.Error(err)
return
}
response.OK(c, gin.H{"id": id})
}
// resolveUploader 从 context 解析上传者身份:后台走 admin_id,小程序走 user_id。
// 两者均不存在(未认证)返回 ("", 0),由调用方拒绝。
func resolveUploader(c *gin.Context) (string, uint64) {
if aid, ok := middleware.AdminIDUint(c); ok && aid != 0 {
return "admin", aid
}
if uid := middleware.UserID(c); uid != 0 {
return "ma", uid
}
return "", 0
}
func parseID(c *gin.Context) (uint64, bool) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.Error(errors.ErrBadRequest)
return 0, false
}
return id, true
}
// RegisterAdminUpload 在已鉴权(AdminAuth + PermissionLoader)的后台分组下挂载上传接口。
func RegisterAdminUpload(g *gin.RouterGroup, h *Handler) {
rg := g.Group("/files")
{
rg.POST("/upload", h.Upload)
rg.GET("/:id", h.Info)
rg.GET("/:id/raw", h.Raw)
rg.DELETE("/:id", h.Delete)
}
}
// RegisterMiniAppUpload 在已鉴权(MiniAppAuth + MiniUserAuth)的小程序分组下挂载上传接口。
func RegisterMiniAppUpload(g *gin.RouterGroup, h *Handler) {
rg := g.Group("/files")
{
rg.POST("/upload", h.Upload)
rg.GET("/:id", h.Info)
rg.GET("/:id/raw", h.Raw)
rg.DELETE("/:id", h.Delete)
}
}
+20
View File
@@ -0,0 +1,20 @@
package model
import "portal-mini-go/internal/pkg/types"
// File 记录一次上传文件的元数据;实际内容由 StorageKey 指向具体存储实现(本地/对象存储)。
type File struct {
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
StorageKey string `gorm:"type:varchar(255);index" json:"storage_key"` // 如 2006/01/02/<rand>.ext
Filename string `gorm:"type:varchar(255)" json:"filename"` // 落盘文件名(含扩展名)
OriginName string `gorm:"type:varchar(255)" json:"origin_name"` // 用户上传时的原始文件名
MimeType string `gorm:"type:varchar(128)" json:"mime_type"`
Ext string `gorm:"type:varchar(32)" json:"ext"` // 小写扩展名,不含点
Size int64 `gorm:"type:bigint" json:"size"`
UploaderType string `gorm:"type:varchar(16)" json:"uploader_type"` // admin | ma
UploaderID uint64 `gorm:"type:bigint;index" json:"uploader_id"`
URL string `gorm:"type:varchar(512)" json:"url"`
CreatedAt types.Time `json:"created_at"`
}
func (File) TableName() string { return "files" }
+28
View File
@@ -16,6 +16,16 @@ type Config struct {
MySQL MySQLConfig `mapstructure:"mysql"`
Logging LoggingConfig `mapstructure:"logging"`
Admin AdminConfig `mapstructure:"admin"`
Upload UploadConfig `mapstructure:"upload"`
}
// UploadConfig 文件上传相关配置。
type UploadConfig struct {
StorageType string `mapstructure:"storage_type"` // local | minio(minio 预留)
LocalPath string `mapstructure:"local_path"` // 本地存储根目录(storage_type=local)
BaseURL string `mapstructure:"base_url"` // 文件可访问的基础路径/域名
MaxSize int64 `mapstructure:"max_size"` // 单文件大小上限(字节),0 表示不限制
AllowedExt []string `mapstructure:"allowed_ext"` // 扩展名白名单;为空表示不限制类型
}
type ServerConfig struct {
@@ -97,5 +107,23 @@ func Load(configPath string) (*Config, error) {
if err := v.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("unmarshal config: %w", err)
}
applyUploadDefaults(&cfg.Upload)
return &cfg, nil
}
// applyUploadDefaults 为上传配置补齐合理默认值。
func applyUploadDefaults(u *UploadConfig) {
if u.StorageType == "" {
u.StorageType = "local"
}
if u.LocalPath == "" {
u.LocalPath = "./uploads"
}
if u.BaseURL == "" {
u.BaseURL = "/files/static"
}
// MaxSize=0 视为不限制;仅当完全未配置(<0)时兜底为 10MB,避免误配导致无限大。
if u.MaxSize < 0 {
u.MaxSize = 10 << 20
}
}
+90
View File
@@ -0,0 +1,90 @@
// Package local 提供基于本地文件系统的 Storage 实现。
package local
import (
"context"
"io"
"os"
"path/filepath"
"strings"
"portal-mini-go/internal/pkg/errors"
)
// Local 是基于本地磁盘的存储实现。
type Local struct {
root string
baseURL string
}
// New 构造本地存储,root 为落盘根目录,baseURL 为对外访问基础路径(不含末尾斜杠)。
func New(root, baseURL string) *Local {
return &Local{root: root, baseURL: strings.TrimRight(baseURL, "/")}
}
// Put 将内容写入 root/key,自动创建父目录。
func (l *Local) Put(_ context.Context, key string, r io.Reader, _ int64) error {
p, err := l.safePath(key)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
return errors.Wrap(50000, "mkdir upload dir", err)
}
f, err := os.Create(p)
if err != nil {
return errors.Wrap(50000, "create upload file", err)
}
defer f.Close()
if _, err := io.Copy(f, r); err != nil {
return errors.Wrap(50000, "write upload file", err)
}
return nil
}
// Get 打开文件并返回读取流。
func (l *Local) Get(_ context.Context, key string) (io.ReadCloser, error) {
p, err := l.safePath(key)
if err != nil {
return nil, err
}
f, err := os.Open(p)
if err != nil {
if os.IsNotExist(err) {
return nil, errors.ErrNotFound
}
return nil, errors.Wrap(50000, "open upload file", err)
}
return f, nil
}
// Delete 删除文件;不存在视为成功。
func (l *Local) Delete(_ context.Context, key string) error {
p, err := l.safePath(key)
if err != nil {
return err
}
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
return errors.Wrap(50000, "delete upload file", err)
}
return nil
}
// URL 返回 /baseURL/key 形式的访问地址。
func (l *Local) URL(key string) string {
return l.baseURL + "/" + strings.TrimLeft(key, "/")
}
// safePath 将 key 解析为 root 内的绝对路径,并禁止任何目录穿越。
func (l *Local) safePath(key string) (string, error) {
clean := filepath.Clean("/" + key) // 以 / 开头,消除 . 与 ..
if strings.Contains(clean, "..") {
return "", errors.New(40000, "非法的文件 key")
}
p := filepath.Join(l.root, clean)
// 二次校验,确保最终路径没有逃出 root。
if rel, err := filepath.Rel(l.root, p); err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", errors.New(40000, "非法的文件 key")
}
return p, nil
}
+36
View File
@@ -0,0 +1,36 @@
// Package storage 定义文件存储抽象,屏蔽本地磁盘与对象存储(minIO/OSS 等)的差异。
// 业务层(service)仅依赖 Storage 接口,便于替换存储后端与单测 mock。
package storage
import (
"context"
"fmt"
"io"
"portal-mini-go/internal/pkg/config"
"portal-mini-go/internal/pkg/storage/local"
)
// Storage 是文件存储抽象。
// key 的约定形如 "2006/01/02/<rand>.ext",由 service 层生成,按日期分片避免单目录文件过多。
type Storage interface {
// Put 写入文件内容;size 为文件字节数(<=0 表示未知,由实现自行处理)。
Put(ctx context.Context, key string, r io.Reader, size int64) error
// Get 返回文件读取流,调用方负责关闭;不存在返回 ErrNotFound。
Get(ctx context.Context, key string) (io.ReadCloser, error)
// Delete 删除文件;不存在不报错。
Delete(ctx context.Context, key string) error
// URL 返回客户端可访问的地址(本地为 baseURL 拼接;对象存储可为签名/公开 URL)。
URL(key string) string
}
// New 依据配置构造存储实现。
// 当前支持 local;minio 等对象存储预留(后续在此补充 case 并接入 SDK)。
func New(cfg config.UploadConfig) (Storage, error) {
switch cfg.StorageType {
case "", "local":
return local.New(cfg.LocalPath, cfg.BaseURL), nil
default:
return nil, fmt.Errorf("storage: 不支持的存储类型 %q", cfg.StorageType)
}
}
+51
View File
@@ -0,0 +1,51 @@
// Package file 提供文件元数据(files 表)的数据访问。
// 所有查询均参数化,杜绝 SQL 注入;仅负责持久化,不涉及存储后端 IO。
package file
import (
"context"
"gorm.io/gorm"
"portal-mini-go/internal/model"
"portal-mini-go/internal/pkg/errors"
)
// Repository 文件元数据仓储。
type Repository struct {
db *gorm.DB
}
func New(db *gorm.DB) *Repository { return &Repository{db: db} }
// Create 写入文件记录。
func (r *Repository) Create(ctx context.Context, f *model.File) error {
if err := r.db.WithContext(ctx).Create(f).Error; err != nil {
return errors.Wrap(50000, "create file record", err)
}
return nil
}
// GetByID 按 ID 查询文件元数据,不存在返回 ErrNotFound。
func (r *Repository) GetByID(ctx context.Context, id uint64) (*model.File, error) {
var f model.File
if err := r.db.WithContext(ctx).Where("id = ?", id).First(&f).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.ErrNotFound
}
return nil, errors.Wrap(50000, "query file", err)
}
return &f, nil
}
// Delete 按 ID 删除文件元数据,不存在返回 ErrNotFound。
func (r *Repository) Delete(ctx context.Context, id uint64) error {
res := r.db.WithContext(ctx).Where("id = ?", id).Delete(&model.File{})
if res.Error != nil {
return errors.Wrap(50000, "delete file", res.Error)
}
if res.RowsAffected == 0 {
return errors.ErrNotFound
}
return nil
}
+25
View File
@@ -16,19 +16,23 @@ import (
rbachdl "portal-mini-go/internal/handler/admin/rbac"
userhdl "portal-mini-go/internal/handler/admin/user"
app1hdl "portal-mini-go/internal/handler/miniapp/app1"
filehdl "portal-mini-go/internal/handler/file"
"portal-mini-go/internal/middleware"
"portal-mini-go/internal/pkg/config"
"portal-mini-go/internal/pkg/response"
"portal-mini-go/internal/pkg/session"
"portal-mini-go/internal/pkg/storage"
"portal-mini-go/internal/pkg/wechat"
adminrepo "portal-mini-go/internal/repository/admin"
miniapprepo "portal-mini-go/internal/repository/miniapp"
"portal-mini-go/internal/repository/user"
filerepo "portal-mini-go/internal/repository/file"
authsvc "portal-mini-go/internal/service/admin/auth"
miniappsvc "portal-mini-go/internal/service/admin/miniapp"
adminrbac "portal-mini-go/internal/service/admin/rbac"
usersvc "portal-mini-go/internal/service/admin/user"
app1svc "portal-mini-go/internal/service/miniapp/app1"
filesvc "portal-mini-go/internal/service/file"
)
// New 构造并装配整个 HTTP 服务。
@@ -44,6 +48,19 @@ func New(cfg *config.Config, db *gorm.DB, store session.Store) *gin.Engine {
response.OK(c, gin.H{"status": "ok"})
})
// 文件存储:依据配置构造(当前支持本地磁盘,minIO 等预留)。
fileStore, err := storage.New(cfg.Upload)
if err != nil {
slog.Error("init storage", slog.Any("err", err))
os.Exit(1)
}
// 本地存储静态目录对外可访问(与 file.URL 拼接的 baseURL 对齐)。
r.Static(cfg.Upload.BaseURL, cfg.Upload.LocalPath)
// 共享的文件服务实例,admin 与小程序端复用。
fileRepo := filerepo.New(db)
fileSvc := filesvc.New(fileRepo, fileStore, cfg.Upload)
fileHdl := filehdl.NewHandler(fileSvc)
// 共享基础设施:微信客户端、用户仓储、小程序仓储。
wx := wechat.NewClient()
userRepo := user.New(db)
@@ -55,6 +72,11 @@ func New(cfg *config.Config, db *gorm.DB, store session.Store) *gin.Engine {
{
app1Svc := app1svc.New(userRepo, miniRepo, wx, store)
app1hdl.Register(ma, app1hdl.NewHandler(app1Svc), middleware.MiniUserAuth(store))
// 文件上传服务:小程序端已登录用户可上传/管理文件。
maAuth := ma.Group("")
maAuth.Use(middleware.MiniUserAuth(store))
filehdl.RegisterMiniAppUpload(maAuth, fileHdl)
}
// 后台 RBAC 基础设施:仓储、缓存、权限 Provider、种子初始化。
@@ -100,6 +122,9 @@ func New(cfg *config.Config, db *gorm.DB, store session.Store) *gin.Engine {
// 用户管理:各小程序端用户(列表、详情、编辑、禁用/启用)。
userSvc := usersvc.New(userRepo)
userhdl.Register(authAd, userhdl.NewHandler(userSvc))
// 文件上传服务:后台已认证管理员可上传/管理文件(admin 与 ma 双入口复用同一 Service)。
filehdl.RegisterAdminUpload(authAd, fileHdl)
}
}
+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"
}
+194
View File
@@ -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)
}