file
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user