diff --git a/.gitignore b/.gitignore index aa39edf..b3237e0 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -.cursor \ No newline at end of file +.cursor +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md index a24a94c..3cbec00 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ 提供从外部文件加载配置的功能,支持数据库、OSS、Redis、CORS、MinIO等配置。 ### 6. 存储工具 (storage) -提供文件上传和查看功能,支持OSS和MinIO两种存储方式,并提供HTTP处理器。 +提供文件上传和查看功能,支持本地文件夹(Local)、OSS 和 MinIO 三种存储方式,并提供HTTP处理器。 ### 7. 邮件工具 (email) 提供SMTP邮件发送功能,支持纯文本和HTML邮件,使用Go标准库实现。 @@ -105,6 +105,7 @@ | 数据库 | `GetDatabase()` | 返回GORM对象,用于复杂查询 | ⭐⭐ | | Redis高级 | `GetRedisClient()` | 返回Redis客户端,用于Hash/List/Set等 | ⭐ | | Logger高级 | `GetLogger()` | 返回Logger对象,用于Close等 | ⭐ | +| 存储高级 | `GetStorage()` | 返回Storage对象,用于Delete/Exists/GetObject等 | ⭐ | ### 使用示例 diff --git a/VERSION.md b/VERSION.md index 43fc2b9..0ea50e6 100644 --- a/VERSION.md +++ b/VERSION.md @@ -129,3 +129,8 @@ go get -u=minor git.toowon.com/jimmy/go-common - 初始版本 - 包含所有基础工具类:migration、datetime、http、middleware、config、storage、email、sms、factory、logger +- **v1.1.0** (未发布) + - storage:新增本地文件夹存储(LocalStorage),支持将文件/图片上传到本地目录 + - config:新增 `localStorage` 配置段(`baseDir` / `publicURL`) + - factory:新增 `GetStorage()`,并支持 Local/MinIO/OSS 自动选择(优先级:Local > MinIO > OSS) + diff --git a/config/config.go b/config/config.go index 8ab4597..e27bef2 100644 --- a/config/config.go +++ b/config/config.go @@ -9,15 +9,31 @@ import ( // Config 应用配置 type Config struct { - Database *DatabaseConfig `json:"database"` - OSS *OSSConfig `json:"oss"` - Redis *RedisConfig `json:"redis"` - CORS *CORSConfig `json:"cors"` - MinIO *MinIOConfig `json:"minio"` - Email *EmailConfig `json:"email"` - SMS *SMSConfig `json:"sms"` - Logger *LoggerConfig `json:"logger"` - RateLimit *RateLimitConfig `json:"rateLimit"` + Database *DatabaseConfig `json:"database"` + OSS *OSSConfig `json:"oss"` + Redis *RedisConfig `json:"redis"` + CORS *CORSConfig `json:"cors"` + MinIO *MinIOConfig `json:"minio"` + Local *LocalStorageConfig `json:"localStorage"` + Email *EmailConfig `json:"email"` + SMS *SMSConfig `json:"sms"` + Logger *LoggerConfig `json:"logger"` + RateLimit *RateLimitConfig `json:"rateLimit"` +} + +// LocalStorageConfig 本地存储配置 +// 用于将文件保存到本地文件夹(适合开发环境、单机部署等场景) +type LocalStorageConfig struct { + // BaseDir 本地文件保存根目录(必填) + // 示例: "./uploads" 或 "/var/app/uploads" + BaseDir string `json:"baseDir"` + + // PublicURL 对外访问URL(可选) + // 1) 若包含 "{objectKey}" 占位符,则会替换为 url.QueryEscape(objectKey) + // 示例: "http://localhost:8080/file?key={objectKey}" (配合 ProxyHandler 使用) + // 2) 若不包含占位符,则作为URL前缀,自动拼接 objectKey + // 示例: "http://localhost:8080/static/" => "http://localhost:8080/static/" + PublicURL string `json:"publicURL"` } // DatabaseConfig 数据库配置 @@ -439,6 +455,11 @@ func (c *Config) GetMinIO() *MinIOConfig { return c.MinIO } +// GetLocalStorage 获取本地存储配置 +func (c *Config) GetLocalStorage() *LocalStorageConfig { + return c.Local +} + // GetEmail 获取邮件配置 func (c *Config) GetEmail() *EmailConfig { return c.Email diff --git a/config/example.json b/config/example.json index a7af323..d26990a 100644 --- a/config/example.json +++ b/config/example.json @@ -50,6 +50,10 @@ "region": "us-east-1", "domain": "http://localhost:9000" }, + "localStorage": { + "baseDir": "./uploads", + "publicURL": "http://localhost:8080/file?key={objectKey}" + }, "email": { "host": "smtp.example.com", "port": 587, diff --git a/docs/config.md b/docs/config.md index a382deb..56c1abc 100644 --- a/docs/config.md +++ b/docs/config.md @@ -2,12 +2,13 @@ ## 概述 -配置工具提供了从外部文件加载和管理应用配置的功能,支持数据库、OSS、Redis、CORS、MinIO、邮件、短信等常用服务的配置。 +配置工具提供了从外部文件加载和管理应用配置的功能,支持数据库、LocalStorage、OSS、Redis、CORS、MinIO、邮件、短信等常用服务的配置。 ## 功能特性 - 支持从外部JSON文件加载配置 - 支持数据库配置(MySQL、PostgreSQL、SQLite) +- 支持本地存储配置(LocalStorage,文件上传保存到本地文件夹) - 支持OSS对象存储配置(阿里云、腾讯云、AWS、七牛云等) - 支持Redis配置 - 支持CORS配置(与middleware包集成) @@ -75,6 +76,10 @@ "region": "us-east-1", "domain": "http://localhost:9000" }, + "localStorage": { + "baseDir": "./uploads", + "publicURL": "http://localhost:8080/file?key={objectKey}" + }, "email": { "host": "smtp.example.com", "port": 587, @@ -196,6 +201,16 @@ if minioConfig != nil { } ``` +### 6.1 获取本地存储配置(LocalStorage) + +```go +localCfg := config.GetLocalStorage() +if localCfg != nil { + fmt.Printf("Local baseDir: %s\n", localCfg.BaseDir) + fmt.Printf("Local publicURL: %s\n", localCfg.PublicURL) +} +``` + ## 配置项说明 ### DatabaseConfig 数据库配置 @@ -266,6 +281,13 @@ if minioConfig != nil { | Region | string | 区域 | | Domain | string | 自定义域名 | +### LocalStorageConfig 本地存储配置 + +| 字段 | 类型 | 说明 | +|------|------|------| +| BaseDir | string | 本地文件保存根目录(必填) | +| PublicURL | string | 对外访问 URL(可选)。包含 `{objectKey}` 占位符时会替换为 `url.QueryEscape(objectKey)`;不包含时作为 URL 前缀拼接 | + ### EmailConfig 邮件配置 | 字段 | 类型 | 说明 | 默认值 | diff --git a/docs/storage.md b/docs/storage.md index 25ac3d1..7fa7252 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -2,10 +2,11 @@ ## 概述 -存储工具提供了文件上传和查看功能,支持OSS和MinIO两种存储方式,并提供HTTP处理器用于文件上传和代理查看。 +存储工具提供了文件上传和查看功能,支持 **本地文件夹(Local)**、OSS 和 MinIO 三种存储方式,并提供HTTP处理器用于文件上传和代理查看。 ## 功能特性 +- 支持本地文件夹存储(Local) - 支持OSS对象存储(阿里云、腾讯云、AWS、七牛云等) - 支持MinIO对象存储 - 提供统一的存储接口 @@ -17,6 +18,32 @@ ## 使用方法 +### 0. 工厂调用方式(推荐) + +当你使用 `factory` 黑盒模式时,外部项目无需关心底层是 Local/MinIO/OSS: + +```go +import ( + "context" + "os" + + "git.toowon.com/jimmy/go-common/factory" + "git.toowon.com/jimmy/go-common/storage" +) + +fac, _ := factory.NewFactoryFromFile("./config.json") + +f, _ := os.Open("test.jpg") +defer f.Close() + +objectKey := storage.GenerateObjectKeyWithDate("uploads/images", "test.jpg") +url, err := fac.UploadFile(context.Background(), objectKey, f, "image/jpeg") +if err != nil { + panic(err) +} +_ = url +``` + ### 1. 创建存储实例 ```go @@ -42,6 +69,12 @@ minioStorage, err := storage.NewStorage(storage.StorageTypeMinIO, cfg) if err != nil { log.Fatal(err) } + +// 创建本地存储实例 +localStorage, err := storage.NewStorage(storage.StorageTypeLocal, cfg) +if err != nil { + log.Fatal(err) +} ``` ### 2. 上传文件 @@ -76,6 +109,25 @@ if err != nil { fmt.Printf("File URL: %s\n", url) ``` +### 2.1 本地存储配置示例 + +`config.json` 增加 `localStorage` 配置段: + +```json +{ + "localStorage": { + "baseDir": "./uploads", + "publicURL": "http://localhost:8080/file?key={objectKey}" + } +} +``` + +说明: +- **baseDir**:文件保存根目录 +- **publicURL**:用于 `GetURL()` 返回对外可访问的 URL + - 推荐配合本文的 `ProxyHandler`,示例 `http://localhost:8080/file?key={objectKey}` + - `{objectKey}` 会自动做 `url.QueryEscape` 处理 + ### 3. 使用HTTP处理器上传文件 ```go @@ -136,6 +188,10 @@ http.Handle("/file", proxyHandler) http.ListenAndServe(":8080", nil) ``` +**本地存储建议搭配:** +- `POST /upload` 上传文件(返回 `url`) +- `GET /file?key=...` 通过代理读取本地文件并返回二进制内容 + **查看请求示例:** ``` GET /file?key=images/test.jpg diff --git a/examples/config_example.go b/examples/config_example.go index 4b6b531..514b686 100644 --- a/examples/config_example.go +++ b/examples/config_example.go @@ -1,3 +1,6 @@ +//go:build example +// +build example + package main import ( diff --git a/examples/doc.go b/examples/doc.go new file mode 100644 index 0000000..eaec1cf --- /dev/null +++ b/examples/doc.go @@ -0,0 +1,8 @@ +// Package examples contains build-tagged example programs. +// +// 所有示例程序默认不参与 `go test ./...` 编译,避免多个 main 冲突。 +// +// 运行示例: +// go run -tags example ./examples/storage_example.go +package examples + diff --git a/examples/email_example.go b/examples/email_example.go index 0aeeb03..82134af 100644 --- a/examples/email_example.go +++ b/examples/email_example.go @@ -1,3 +1,6 @@ +//go:build example +// +build example + package main import ( diff --git a/examples/excel_example.go b/examples/excel_example.go index c8f7857..6b46e41 100644 --- a/examples/excel_example.go +++ b/examples/excel_example.go @@ -1,3 +1,6 @@ +//go:build example +// +build example + package main import ( diff --git a/examples/storage_example.go b/examples/storage_example.go index 4858846..4f69796 100644 --- a/examples/storage_example.go +++ b/examples/storage_example.go @@ -1,3 +1,6 @@ +//go:build example +// +build example + package main import ( @@ -16,52 +19,51 @@ func main() { log.Fatal("Failed to load config:", err) } - // 创建存储实例(使用OSS) - // 注意:需要先实现OSS SDK集成 - ossStorage, err := storage.NewStorage(storage.StorageTypeOSS, cfg) + // 优先演示本地存储(可直接运行) + localStorage, err := storage.NewStorage(storage.StorageTypeLocal, cfg) if err != nil { - log.Printf("Failed to create OSS storage: %v", err) - log.Println("Note: OSS SDK integration is required") - // 继续演示其他功能 - } else { - // 创建上传处理器 - uploadHandler := storage.NewUploadHandler(storage.UploadHandlerConfig{ - Storage: ossStorage, - MaxFileSize: 10 * 1024 * 1024, // 10MB - AllowedExts: []string{".jpg", ".jpeg", ".png", ".gif", ".pdf"}, - ObjectPrefix: "uploads/", - }) + log.Fatal("Failed to create Local storage:", err) + } - // 创建代理查看处理器 - proxyHandler := storage.NewProxyHandler(ossStorage) + uploadHandler := storage.NewUploadHandler(storage.UploadHandlerConfig{ + Storage: localStorage, + MaxFileSize: 10 * 1024 * 1024, // 10MB + AllowedExts: []string{".jpg", ".jpeg", ".png", ".gif", ".pdf"}, + ObjectPrefix: "uploads/", + }) - // 创建中间件链 - var corsConfig *middleware.CORSConfig - if cfg.GetCORS() != nil { - c := cfg.GetCORS() - corsConfig = middleware.NewCORSConfig( - c.AllowedOrigins, - c.AllowedMethods, - c.AllowedHeaders, - c.ExposedHeaders, - c.AllowCredentials, - c.MaxAge, - ) - } - chain := middleware.NewChain( - middleware.CORS(corsConfig), - middleware.Timezone, + proxyHandler := storage.NewProxyHandler(localStorage) + + // 创建中间件链 + var corsConfig *middleware.CORSConfig + if cfg.GetCORS() != nil { + c := cfg.GetCORS() + corsConfig = middleware.NewCORSConfig( + c.AllowedOrigins, + c.AllowedMethods, + c.AllowedHeaders, + c.ExposedHeaders, + c.AllowCredentials, + c.MaxAge, ) + } + chain := middleware.NewChain( + middleware.CORS(corsConfig), + middleware.Timezone, + ) - // 注册路由 - mux := http.NewServeMux() - mux.Handle("/upload", chain.Then(uploadHandler)) - mux.Handle("/file", chain.Then(proxyHandler)) + // 注册路由 + mux := http.NewServeMux() + mux.Handle("/upload", chain.Then(uploadHandler)) + mux.Handle("/file", chain.Then(proxyHandler)) - log.Println("Storage server started on :8080") - log.Println("Upload: POST /upload") - log.Println("View: GET /file?key=images/test.jpg") - log.Fatal(http.ListenAndServe(":8080", mux)) + log.Println("Local storage server started on :8080") + log.Println("Upload: POST /upload") + log.Println("View: GET /file?key=uploads/xxx.jpg") + + // 提示:OSS 需要你自行集成对应 SDK(当前 go-common 中仅提供接口框架) + if _, err := storage.NewStorage(storage.StorageTypeOSS, cfg); err != nil { + log.Printf("OSS storage not ready: %v", err) } // 演示MinIO存储 @@ -79,5 +81,6 @@ func main() { objectKey2 := storage.GenerateObjectKeyWithDate("images", "test.jpg") log.Printf("Object key 2: %s", objectKey2) -} + log.Fatal(http.ListenAndServe(":8080", mux)) +} diff --git a/factory/factory.go b/factory/factory.go index 39716fe..5d5d107 100644 --- a/factory/factory.go +++ b/factory/factory.go @@ -653,14 +653,16 @@ func (f *Factory) getStorage() (storage.Storage, error) { } // 根据配置自动选择存储类型 - // 优先级:MinIO > OSS + // 优先级:Local > MinIO > OSS var storageType storage.StorageType - if f.cfg.MinIO != nil { + if f.cfg.GetLocalStorage() != nil { + storageType = storage.StorageTypeLocal + } else if f.cfg.MinIO != nil { storageType = storage.StorageTypeMinIO } else if f.cfg.OSS != nil { storageType = storage.StorageTypeOSS } else { - return nil, fmt.Errorf("no storage config found (OSS or MinIO)") + return nil, fmt.Errorf("no storage config found (LocalStorage, OSS or MinIO)") } // 创建存储实例 @@ -673,6 +675,16 @@ func (f *Factory) getStorage() (storage.Storage, error) { return s, nil } +// GetStorage 获取存储实例对象(高级功能时使用) +// 通常推荐使用黑盒方法: +// - UploadFile() +// - GetFileURL() +// +// 如需自定义上传/查看行为(例如 Delete/Exists/GetObject),可使用此方法获取底层存储对象。 +func (f *Factory) GetStorage() (storage.Storage, error) { + return f.getStorage() +} + // UploadFile 上传文件(黑盒模式,推荐使用) // 自动根据配置选择存储类型(OSS 或 MinIO),无需关心内部实现 // ctx: 上下文 diff --git a/storage/local.go b/storage/local.go new file mode 100644 index 0000000..a0c1a8e --- /dev/null +++ b/storage/local.go @@ -0,0 +1,222 @@ +package storage + +import ( + "context" + "fmt" + "io" + "net/url" + "os" + "path" + "path/filepath" + "strings" + + "git.toowon.com/jimmy/go-common/config" +) + +// LocalStorage 本地存储实现 +// 将对象写入本地文件夹(BaseDir),对象键 objectKey 作为相对路径使用。 +// +// 典型用法: +// - 上传:Upload(ctx, "uploads/2026/01/01/a.png", reader) +// - 查看:配合 ProxyHandler 或 http.FileServer 对外提供访问 +type LocalStorage struct { + baseDir string + publicURL string +} + +// NewLocalStorage 创建本地存储实例 +func NewLocalStorage(cfg *config.LocalStorageConfig) (*LocalStorage, error) { + if cfg == nil { + return nil, fmt.Errorf("LocalStorage config is nil") + } + if strings.TrimSpace(cfg.BaseDir) == "" { + return nil, fmt.Errorf("LocalStorage baseDir is empty") + } + + absBase, err := filepath.Abs(cfg.BaseDir) + if err != nil { + return nil, fmt.Errorf("failed to get absolute baseDir: %w", err) + } + + // 确保根目录存在 + if err := os.MkdirAll(absBase, 0o755); err != nil { + return nil, fmt.Errorf("failed to create baseDir: %w", err) + } + + return &LocalStorage{ + baseDir: absBase, + publicURL: strings.TrimSpace(cfg.PublicURL), + }, nil +} + +// Upload 上传文件到本地文件夹 +func (s *LocalStorage) Upload(ctx context.Context, objectKey string, reader io.Reader, contentType ...string) error { + _ = ctx + _ = contentType // 本地写文件不依赖 contentType;可由上层自行记录 + + dstPath, err := s.resolvePath(objectKey) + if err != nil { + return err + } + + // 确保目录存在 + if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + // 原子写入:先写临时文件,再 rename + tmp, err := os.CreateTemp(filepath.Dir(dstPath), ".upload-*") + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + + tmpName := tmp.Name() + defer func() { + _ = tmp.Close() + _ = os.Remove(tmpName) + }() + + if _, err := io.Copy(tmp, reader); err != nil { + return fmt.Errorf("failed to write temp file: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("failed to close temp file: %w", err) + } + + // 如果目标文件已存在,先删除(保证跨平台兼容 rename 行为) + _ = os.Remove(dstPath) + + if err := os.Rename(tmpName, dstPath); err != nil { + return fmt.Errorf("failed to move temp file to destination: %w", err) + } + + return nil +} + +// GetURL 获取本地文件访问URL +// - 若配置了 publicURL: +// - 包含 "{objectKey}" 占位符:替换为 url.QueryEscape(objectKey) +// - 否则认为是 URL 前缀:自动拼接 objectKey(用 path.Join 处理斜杠) +// +// - 未配置 publicURL:返回 objectKey(相对路径) +func (s *LocalStorage) GetURL(objectKey string, expires int64) (string, error) { + _ = expires // 本地存储不提供签名URL,忽略 expires + + cleanKey, err := normalizeObjectKey(objectKey) + if err != nil { + return "", err + } + + if s.publicURL == "" { + return cleanKey, nil + } + + if strings.Contains(s.publicURL, "{objectKey}") { + return strings.ReplaceAll(s.publicURL, "{objectKey}", url.QueryEscape(cleanKey)), nil + } + + // 作为前缀拼接 + trimmed := strings.TrimRight(s.publicURL, "/") + return trimmed + "/" + path.Clean("/" + cleanKey)[1:], nil +} + +// Delete 删除本地文件 +func (s *LocalStorage) Delete(ctx context.Context, objectKey string) error { + _ = ctx + + dstPath, err := s.resolvePath(objectKey) + if err != nil { + return err + } + + if err := os.Remove(dstPath); err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("failed to delete file: %w", err) + } + return nil +} + +// Exists 检查本地文件是否存在 +func (s *LocalStorage) Exists(ctx context.Context, objectKey string) (bool, error) { + _ = ctx + + dstPath, err := s.resolvePath(objectKey) + if err != nil { + return false, err + } + + info, err := os.Stat(dstPath) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, fmt.Errorf("failed to stat file: %w", err) + } + if info.IsDir() { + return false, nil + } + return true, nil +} + +// GetObject 获取本地文件内容 +func (s *LocalStorage) GetObject(ctx context.Context, objectKey string) (io.ReadCloser, error) { + _ = ctx + + dstPath, err := s.resolvePath(objectKey) + if err != nil { + return nil, err + } + + f, err := os.Open(dstPath) + if err != nil { + return nil, fmt.Errorf("failed to open file: %w", err) + } + return f, nil +} + +func (s *LocalStorage) resolvePath(objectKey string) (string, error) { + cleanKey, err := normalizeObjectKey(objectKey) + if err != nil { + return "", err + } + // 将 URL 风格路径转换为 OS 路径 + full := filepath.Join(s.baseDir, filepath.FromSlash(cleanKey)) + + // 防御:确保仍在 baseDir 下 + rel, err := filepath.Rel(s.baseDir, full) + if err != nil { + return "", fmt.Errorf("failed to resolve path: %w", err) + } + if rel == "." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || rel == ".." { + return "", fmt.Errorf("invalid objectKey: %s", objectKey) + } + + return full, nil +} + +func normalizeObjectKey(objectKey string) (string, error) { + key := strings.TrimSpace(objectKey) + if key == "" { + return "", fmt.Errorf("objectKey is empty") + } + + // 兼容 Windows 风格路径,统一为 URL 风格 + key = strings.ReplaceAll(key, "\\", "/") + + // 清洗路径,去除多余的 . / .. + // 加前缀 "/" 让 Clean 以绝对路径方式处理,避免出现空结果 + clean := path.Clean("/" + key) + clean = strings.TrimPrefix(clean, "/") + if clean == "" || clean == "." { + return "", fmt.Errorf("invalid objectKey: %s", objectKey) + } + + // 不允许以 "/" 结尾(必须指向文件) + if strings.HasSuffix(clean, "/") { + return "", fmt.Errorf("objectKey cannot be a directory: %s", objectKey) + } + + return clean, nil +} diff --git a/storage/local_test.go b/storage/local_test.go new file mode 100644 index 0000000..692044b --- /dev/null +++ b/storage/local_test.go @@ -0,0 +1,102 @@ +package storage + +import ( + "bytes" + "context" + "io" + "testing" + + "git.toowon.com/jimmy/go-common/config" +) + +func TestLocalStorage_UploadGetExistsDelete(t *testing.T) { + t.Parallel() + + cfg := &config.LocalStorageConfig{ + BaseDir: t.TempDir(), + PublicURL: "http://localhost:8080/file?key={objectKey}", + } + s, err := NewLocalStorage(cfg) + if err != nil { + t.Fatalf("NewLocalStorage error: %v", err) + } + + ctx := context.Background() + objectKey := "uploads/2026/01/30/hello.txt" + body := []byte("hello local storage") + + if err := s.Upload(ctx, objectKey, bytes.NewReader(body), "text/plain"); err != nil { + t.Fatalf("Upload error: %v", err) + } + + exists, err := s.Exists(ctx, objectKey) + if err != nil { + t.Fatalf("Exists error: %v", err) + } + if !exists { + t.Fatalf("expected exists=true") + } + + rc, err := s.GetObject(ctx, objectKey) + if err != nil { + t.Fatalf("GetObject error: %v", err) + } + defer rc.Close() + + got, err := io.ReadAll(rc) + if err != nil { + t.Fatalf("ReadAll error: %v", err) + } + if !bytes.Equal(got, body) { + t.Fatalf("content mismatch: got=%q want=%q", string(got), string(body)) + } + + u, err := s.GetURL(objectKey, 0) + if err != nil { + t.Fatalf("GetURL error: %v", err) + } + if u == "" { + t.Fatalf("expected non-empty url") + } + + if err := s.Delete(ctx, objectKey); err != nil { + t.Fatalf("Delete error: %v", err) + } + + exists, err = s.Exists(ctx, objectKey) + if err != nil { + t.Fatalf("Exists error: %v", err) + } + if exists { + t.Fatalf("expected exists=false after delete") + } +} + +func TestNormalizeObjectKey(t *testing.T) { + t.Parallel() + + if _, err := normalizeObjectKey(""); err == nil { + t.Fatalf("expected error for empty objectKey") + } + if _, err := normalizeObjectKey(" "); err == nil { + t.Fatalf("expected error for blank objectKey") + } + if _, err := normalizeObjectKey("."); err == nil { + t.Fatalf("expected error for '.'") + } + clean1, err := normalizeObjectKey("a/b/") + if err != nil { + t.Fatalf("normalizeObjectKey error: %v", err) + } + if clean1 != "a/b" { + t.Fatalf("unexpected clean key: %q", clean1) + } + + clean, err := normalizeObjectKey(`\a\..\b\c.txt`) + if err != nil { + t.Fatalf("normalizeObjectKey error: %v", err) + } + if clean != "b/c.txt" { + t.Fatalf("unexpected clean key: %q", clean) + } +} diff --git a/storage/storage.go b/storage/storage.go index 6f7ea5f..581a418 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -39,10 +39,11 @@ type StorageType string const ( StorageTypeOSS StorageType = "oss" StorageTypeMinIO StorageType = "minio" + StorageTypeLocal StorageType = "local" ) // NewStorage 创建存储实例 -// storageType: 存储类型(oss或minio) +// storageType: 存储类型(oss/minio/local) // cfg: 配置对象 func NewStorage(storageType StorageType, cfg *config.Config) (Storage, error) { switch storageType { @@ -58,6 +59,12 @@ func NewStorage(storageType StorageType, cfg *config.Config) (Storage, error) { return nil, fmt.Errorf("MinIO config is nil") } return NewMinIOStorage(minioConfig) + case StorageTypeLocal: + localCfg := cfg.GetLocalStorage() + if localCfg == nil { + return nil, fmt.Errorf("LocalStorage config is nil") + } + return NewLocalStorage(localCfg) default: return nil, fmt.Errorf("unsupported storage type: %s", storageType) }