diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ced541..c5f93c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,16 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange - **`cron.ParseCron` 非法表达式改为 fail-fast panic**(cron/cron.go,M13):旧行为会把非法表达式静默回退为每分钟执行,容易让拼写错误变成高频任务。动态输入请用 `ParseCronStrict` 处理 error;确实需要旧回退语义时改用新增 `ParseCronOrDefault`。 - **repository 查询保护默认开启**(repository/repository.go,M5/N5):`FindAll` 默认最多返回 `DefaultFindAllLimit=1000` 条;明确需要全表扫描时改用 `FindAllUnbounded`。`FindPage*` / `QueryBuilder.Page` 会归一化 `page/pageSize` 并限制 `MaxPageSize=100`、`MaxPage=10000`。`Find*Ordered` / `QueryBuilder.Order` 只接受简单字段排序(如 `created_at DESC, id ASC`),复杂表达式/raw SQL 会返回 `ErrUnsafeOrder`;`UpdateBatch` 字段名不合法返回 `ErrUnsafeField`。 +- **同义可失败 API 收敛:错误不再吞并**(cache/jwt/storage/ratelimit):按"同一能力只保留一个主入口、框架不替上层吞错、安全路径默认 fail-closed"原则,收敛历史双轨 API。详见下述分项;自定义 `CacheService`/`Storage` 实现需同步更新签名(编译失败即迁移信号)。 + + - **cache**:`CacheService.Get` / `Exists` 改为 `(bool, error)`--命中 `(true,nil)`、未命中 `(false,nil)`、Redis 未就绪/命令错误/反序列化失败返回 `(false,err)`。删除 `cache.GetE` / `cache.ExistsE` / `CacheGetter` / `CacheExistChecker` / `redisCache.GetE` / `redisCache.ExistsE`(不保留 deprecated wrapper)。新增包级 `cache.Get(ctx,key,dest) (bool,error)` / `cache.Exists(ctx,key) (bool,error)`。`GetWithPrefix` 改为 `(bool,error)`。迁移:`hit, err := cache.Get(ctx, key, &v); if err != nil { return err }; if !hit { /* miss */ }`。 + + - **jwt**:`TokenBlacklist.IsBlacklisted` 改为 `(bool, error)`,删除 `IsBlacklistedE`。`IsTokenRevoked` 改为 `(bool, error)`。`ParseToken` 默认从 **fail-open 改为 fail-closed**--黑名单后端不可检查时返回 `ErrBlacklistUnavailable` 拒绝该 Token(无 Redis 部署不再支持可靠撤销)。删除 `ParseTokenFailClosed`(主 API 已 fail-closed)。保留 `ParseTokenWithBlacklistPolicy(token, policy)` + `BlacklistPolicy`/`BlacklistFailOpen`/`BlacklistFailClosed` 供显式 fail-open(仅无 Redis 或低安全场景)。迁移:无 Redis 部署需启用 Redis,或显式 `jwt.ParseTokenWithBlacklistPolicy(token, jwt.BlacklistFailOpen)`。 + + - **storage**:`Storage.Exists` 改为 `(bool, error)`--存在 `(true,nil)`、不存在 `(false,nil)`、未初始化/路径非法/穿越/后端错误返回 `(false,err)`。`LocalStorage.Exists` 区分 `os.IsNotExist`(not found)与其他 `os.Stat` 错误;`OSSStorage.Exists` 区分 OSS 404/NoSuchKey(not found)与鉴权/网络错误。包级 `storage.Exists` 同步签名,未初始化返回 `ErrStorageNotInitialized`。新增 `ErrReadTooLarge`:`LocalStorage.Get` / `OSSStorage.Get` 读取超 `maxReadBytes` 上限原误用 `ErrInvalidPath`(路径无效语义不贴切),改为 `ErrReadTooLarge`。迁移:`ok, err := storage.Exists(p); if err != nil { return err }; if !ok { /* not found */ }`。 + + - **ratelimit**:Redis 限流器策略收敛为配置型 API。`NewRedisRateLimiter(keyPrefix, rate, window, opts ...RedisRateLimiterOption)` 新增可变参数,`WithFailClosed(true)` 替代原 `NewRedisRateLimiterFailClosed`。`RedisRateLimit` / `CustomRedisRateLimit` / `RedisRateLimitWithIdentifier` 同步加 `opts` 参数。删除 `NewRedisRateLimiterFailClosed` / `RedisRateLimitFailClosed` / `CustomRedisRateLimitFailClosed`。`UploadRedisRateLimit` 由 fail-open 改为 **fail-closed**(上传属资源敏感操作,Redis 故障时拒绝以防限流静默失效)。迁移:`middleware.RedisRateLimit("k", 100, middleware.WithFailClosed(true))` 替代原 `RedisRateLimitFailClosed("k", 100)`。 + ### Security 🔒 - **MySQL 连接支持 TLS**(M-config-2):`DatabaseConfig.TLS` 为 true 时 `MySQLDSN` 追加 `tls=true`(go-sql-driver/mysql v1.7.0 内置安全语义:系统根 CA + ServerName 自动取自 Host + 证书校验,无需注册)。配合 `DatabaseConfig.TLSRootCA`(PEM 路径)可指定私有 CA/自签证书,由 `database` 包在 `InitDB` 时 `RegisterTLSConfig` 注册命名配置(`config.MySQLTLSConfigName`);CA 不可读或非 PEM 时 fail-fast,不静默回退明文。 diff --git a/GUIDE.md b/GUIDE.md index fceac1c..55dce62 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -529,9 +529,11 @@ c := cache.GetCache() // 设置缓存 c.Set(ctx, "user:1", userData, 30*time.Minute) -// 获取缓存 +// 获取缓存(命中返回 true,未命中返回 false,nil,后端错误返回 err) var user User -if c.Get(ctx, "user:1", &user) { +if hit, err := c.Get(ctx, "user:1", &user); err != nil { + return err +} else if hit { // 缓存命中 } @@ -541,8 +543,11 @@ c.Delete(ctx, "user:1") // 批量删除(按模式) c.DeleteByPattern(ctx, "user:*") -// 检查是否存在 -exists := c.Exists(ctx, "user:1") +// 检查是否存在(未命中 false,nil;后端错误返回 err) +exists, err := c.Exists(ctx, "user:1") +if err != nil { + return err +} ``` ### 5.3 键名前缀管理(多站点共用 Redis) @@ -562,7 +567,10 @@ cache.KSession("sid") // → "session:my_app:sid" // 使用带前缀的缓存 c.Set(ctx, cache.K("user:1"), userData, ttl) -c.Get(ctx, cache.K("user:1"), &user) +hit, err := c.Get(ctx, cache.K("user:1"), &user) +if err != nil { + return err +} ``` ### 5.4 分布式锁(安全增强版) @@ -920,13 +928,13 @@ r.Use(middleware.CustomRateLimit(50, time.Minute)) // 每分钟50次 r.Use(middleware.RedisRateLimit("api_limit", 100)) // 每分钟100次(fail-open:Redis 故障时放行) r.Use(middleware.LoginRedisRateLimit()) // 登录限流(fail-closed:Redis 故障时拒绝,防爆破) r.Use(middleware.APIRedisRateLimit()) // API限流(fail-open) -r.Use(middleware.UploadRedisRateLimit()) // 上传限流(fail-open) +r.Use(middleware.UploadRedisRateLimit()) // 上传限流(fail-closed:资源敏感,Redis 故障时拒绝) -// 自定义 Redis 限流 -r.Use(middleware.CustomRedisRateLimit("custom", 50, time.Minute)) // fail-open -r.Use(middleware.CustomRedisRateLimitFailClosed("sensitive", 50, time.Minute)) // fail-closed(安全场景) +// 自定义 Redis 限流(默认 fail-open,传 WithFailClosed(true) 切换为 fail-closed) +r.Use(middleware.CustomRedisRateLimit("custom", 50, time.Minute)) // fail-open +r.Use(middleware.CustomRedisRateLimit("sensitive", 50, time.Minute, middleware.WithFailClosed(true))) // fail-closed(安全场景) -// 自定义标识限流(如按用户ID) +// 自定义标识限流(如按用户ID,同样支持 WithFailClosed) r.Use(middleware.RedisRateLimitWithIdentifier("user_limit", 100, func(c *gin.Context) string { return fmt.Sprintf("user:%d", middleware.GetUserID(c)) })) @@ -936,9 +944,9 @@ defer middleware.StopRateLimiters() ``` > **fail-open vs fail-closed(H4c)**:Redis 限流器在 Redis 故障时有两种策略—— -> - **fail-open**(`RedisRateLimit`/`APIRedisRateLimit`/`UploadRedisRateLimit`/`CustomRedisRateLimit`,默认):Redis 故障时放行,避免影响业务,但限流静默失效。 -> - **fail-closed**(`RedisRateLimitFailClosed`/`LoginRedisRateLimit`/`CustomRedisRateLimitFailClosed`):Redis 故障时拒绝(HTTP 503),防限流静默失效。**安全敏感场景(登录防爆破、敏感操作)必须用 fail-closed**。 -> `RedisRateLimiter` 可经 `NewRedisRateLimiterFailClosed` 构造或 `SetFailClosed(true)` 切换策略。 +> - **fail-open**(默认):Redis 故障时放行,避免影响业务,但限流静默失效。 +> - **fail-closed**(`WithFailClosed(true)`):Redis 故障时拒绝(HTTP 503),防限流静默失效。**安全敏感场景(登录防爆破、上传、敏感操作)必须用 fail-closed**。 +> `RedisRateLimiter` 可经 `NewRedisRateLimiter(..., WithFailClosed(true))` 构造或 `SetFailClosed(true)` 切换策略。 **内存限流 vs Redis 限流:** - 内存限流:单实例使用,简单高效 @@ -1115,7 +1123,7 @@ import "github.com/EthanCodeCraft/xlgo-core/jwt" // 生成Token(自动包含唯一 JTI) token, err := jwt.GenerateToken(userID, username, "admin", "admin") -// 解析Token +// 解析Token(默认 fail-closed:黑名单后端不可检查时拒绝 Token,需 Redis) claims, err := jwt.ParseToken(tokenString) // 使Token失效(使用 JTI,内存占用约 30 字节) @@ -1229,8 +1237,12 @@ err := storage.Delete(path) // 获取文件内容 data, err := storage.Get(path) -// 检查文件是否存在 -if storage.Exists(path) { +// 检查文件是否存在(不存在 false,nil;后端错误返回 err) +ok, err := storage.Exists(path) +if err != nil { + return err +} +if ok { // 文件存在 } ``` diff --git a/README.md b/README.md index f9ecbf2..b3b3553 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,7 @@ db := database.GetDB() myDB := database.NewManager(cfg) myDB.Open(ctx) // 独立实例,不受全局影响 database.SetDefaultManager(myDB) // 提升为全局默认,并关闭旧默认 manager -mockCache := &fakeCacheSvc{} -cache.SetDefaultCacheManager(&cache.CacheManager{}) // 测试注入 +cache.SetDefaultCacheManager(&cache.CacheManager{}) // 测试注入自定义 CacheManager ``` ### 区别于一般 Gin 脚手架的几点 @@ -351,19 +350,14 @@ cacheService := cache.GetCache() // 设置缓存 cacheService.Set(ctx, "user:1", user, 10*time.Minute) -// 获取缓存 +// 获取缓存(命中返回 true,未命中返回 false,nil,后端错误返回 err) var user User -if cacheService.Get(ctx, "user:1", &user) { - // 缓存命中 -} - -// 严格区分缓存未命中与 Redis/反序列化错误 -if hit, err := cache.GetE(ctx, "user:1", &user); err != nil { +if hit, err := cacheService.Get(ctx, "user:1", &user); err != nil { return err } else if hit { // 缓存命中 } -if exists, err := cache.ExistsE(ctx, "user:1"); err != nil { +if exists, err := cacheService.Exists(ctx, "user:1"); err != nil { return err } else if exists { // key 存在 @@ -383,10 +377,11 @@ cacheService.DeleteByPattern(ctx, "user:*") token, err := jwt.GenerateToken(userID, username, "admin", "admin") // 解析 Token +// ParseToken 默认 fail-closed:黑名单后端不可检查时拒绝 Token(需 Redis) claims, err := jwt.ParseToken(tokenString) -// 安全敏感路由可要求黑名单检查 fail-closed -claims, err = jwt.ParseTokenFailClosed(tokenString) +// 需显式 fail-open(仅无 Redis 或低安全场景)用: +claims, err = jwt.ParseTokenWithBlacklistPolicy(tokenString, jwt.BlacklistFailOpen) // 使 Token 失效(使用 JTI,高效) jwt.InvalidateToken(tokenString) @@ -491,8 +486,11 @@ err := storage.Delete(path) // 获取文件内容 data, err := storage.Get(path) -// 检查文件是否存在 -exists := storage.Exists(path) +// 检查文件是否存在(不存在 false,nil;后端错误返回 err) +ok, err := storage.Exists(path) +if err != nil { + return err +} ``` ### SSE 流式响应 diff --git a/cache/cache.go b/cache/cache.go index c8e8ab8..30b152e 100644 --- a/cache/cache.go +++ b/cache/cache.go @@ -15,28 +15,17 @@ import ( // CacheService 缓存服务接口 type CacheService interface { - // Get 获取缓存值,如果存在则反序列化到 dest 并返回 true - Get(ctx context.Context, key string, dest any) bool + // Get 获取缓存值,命中则反序列化到 dest 并返回 (true, nil);未命中返回 (false, nil); + // Redis 未就绪、命令错误或反序列化失败返回 (false, err)。调用方须显式处理错误。 + Get(ctx context.Context, key string, dest any) (bool, error) // Set 设置缓存值 Set(ctx context.Context, key string, value any, ttl time.Duration) error // Delete 删除缓存 Delete(ctx context.Context, key string) error // DeleteByPattern 按模式删除缓存 DeleteByPattern(ctx context.Context, pattern string) error - // Exists 检查缓存是否存在 - Exists(ctx context.Context, key string) bool -} - -// CacheExistChecker is implemented by cache backends that can distinguish a -// missing key from a backend failure. -type CacheExistChecker interface { - ExistsE(ctx context.Context, key string) (bool, error) -} - -// CacheGetter is implemented by cache backends that can distinguish a cache -// miss from a backend failure while loading a value. -type CacheGetter interface { - GetE(ctx context.Context, key string, dest any) (bool, error) + // Exists 检查缓存是否存在;未命中返回 (false, nil),后端错误返回 (false, err)。 + Exists(ctx context.Context, key string) (bool, error) } // redisCache Redis 缓存实现。 @@ -56,32 +45,9 @@ func NewRedisCache() CacheService { return &redisCache{} } -// Get 获取缓存值 -func (c *redisCache) Get(ctx context.Context, key string, dest any) bool { - cli := c.client() - if cli == nil { - return false - } - - val, err := cli.Get(ctx, key).Result() - if err != nil { - if err != redis.Nil { - logger.Warn("缓存获取失败", zap.String("key", key), zap.Error(err)) - } - return false - } - - if err := json.Unmarshal([]byte(val), dest); err != nil { - logger.Warn("缓存反序列化失败", zap.String("key", key), zap.Error(err)) - return false - } - - return true -} - -// GetE loads a cached JSON value and returns backend/deserialize errors to -// callers that need to distinguish a cache miss from Redis or data failures. -func (c *redisCache) GetE(ctx context.Context, key string, dest any) (bool, error) { +// Get 获取缓存值。命中返回 (true, nil);未命中返回 (false, nil); +// Redis 未就绪返回 (false, ErrRedisNotReady);Redis 命令错误或反序列化失败返回 (false, err)。 +func (c *redisCache) Get(ctx context.Context, key string, dest any) (bool, error) { cli := c.client() if cli == nil { return false, ErrRedisNotReady @@ -181,19 +147,9 @@ func (c *redisCache) DeleteByPattern(ctx context.Context, pattern string) error return nil } -// Exists 检查缓存是否存在 -func (c *redisCache) Exists(ctx context.Context, key string) bool { - ok, err := c.ExistsE(ctx, key) - if err != nil { - logger.Warn("缓存存在性检查失败", zap.String("key", key), zap.Error(err)) - return false - } - return ok -} - -// ExistsE checks whether key exists and returns Redis/backend errors to callers -// that need to distinguish a missing key from a cache outage. -func (c *redisCache) ExistsE(ctx context.Context, key string) (bool, error) { +// Exists 检查缓存是否存在。命中返回 (true, nil);未命中返回 (false, nil); +// Redis 未就绪返回 (false, ErrRedisNotReady);命令错误返回 (false, err)。 +func (c *redisCache) Exists(ctx context.Context, key string) (bool, error) { cli := c.client() if cli == nil { return false, ErrRedisNotReady @@ -271,30 +227,22 @@ func GetCache() CacheService { return GetDefaultCache().Get() } -// ExistsE checks whether key exists and returns backend errors. It complements -// the legacy bool-only CacheService.Exists method without changing that public -// interface for downstream custom cache implementations. -func ExistsE(ctx context.Context, key string) (bool, error) { +// Get 获取全局缓存值。命中返回 (true, nil);未命中返回 (false, nil); +// 缓存未初始化或 Redis 错误返回 (false, err)。 +func Get(ctx context.Context, key string, dest any) (bool, error) { svc := GetCache() if svc == nil { return false, ErrRedisNotReady } - if checker, ok := svc.(CacheExistChecker); ok { - return checker.ExistsE(ctx, key) - } - return svc.Exists(ctx, key), nil + return svc.Get(ctx, key, dest) } -// GetE loads a cached JSON value and returns backend/deserialize errors. It -// complements the legacy bool-only CacheService.Get method without changing -// that public interface for downstream custom cache implementations. -func GetE(ctx context.Context, key string, dest any) (bool, error) { +// Exists 检查全局缓存是否存在。未命中返回 (false, nil);缓存未初始化或 +// Redis 错误返回 (false, err)。 +func Exists(ctx context.Context, key string) (bool, error) { svc := GetCache() if svc == nil { return false, ErrRedisNotReady } - if getter, ok := svc.(CacheGetter); ok { - return getter.GetE(ctx, key, dest) - } - return svc.Get(ctx, key, dest), nil + return svc.Exists(ctx, key) } diff --git a/cache/cache_m10_internal_test.go b/cache/cache_m10_internal_test.go index a8b8a8b..0f94ec4 100644 --- a/cache/cache_m10_internal_test.go +++ b/cache/cache_m10_internal_test.go @@ -67,70 +67,70 @@ func TestM10RawHelpersReturnRedisNotReady(t *testing.T) { } } -func TestM10ExistsEReturnsBackendErrors(t *testing.T) { +func TestM10ExistsReturnsBackendErrors(t *testing.T) { setupM10MiniRedis(t) c := &redisCache{} ctx := context.Background() - exists, err := c.ExistsE(ctx, "missing") + exists, err := c.Exists(ctx, "missing") if err != nil { - t.Fatalf("ExistsE missing err = %v", err) + t.Fatalf("Exists missing err = %v", err) } if exists { - t.Fatal("ExistsE missing = true, want false") + t.Fatal("Exists missing = true, want false") } if err := c.Set(ctx, "present", "value", time.Minute); err != nil { t.Fatalf("Set present: %v", err) } - exists, err = c.ExistsE(ctx, "present") + exists, err = c.Exists(ctx, "present") if err != nil || !exists { - t.Fatalf("ExistsE present = %v, err=%v; want true,nil", exists, err) + t.Fatalf("Exists present = %v, err=%v; want true,nil", exists, err) } canceled, cancel := context.WithCancel(ctx) cancel() - if exists, err = c.ExistsE(canceled, "present"); err == nil || exists { - t.Fatalf("ExistsE canceled = %v, err=%v; want false,error", exists, err) + if exists, err = c.Exists(canceled, "present"); err == nil || exists { + t.Fatalf("Exists canceled = %v, err=%v; want false,error", exists, err) } } -func TestM10GetEReturnsBackendAndDecodeErrors(t *testing.T) { +func TestM10GetReturnsBackendAndDecodeErrors(t *testing.T) { setupM10MiniRedis(t) c := &redisCache{} ctx := context.Background() var got string - ok, err := c.GetE(ctx, "missing", &got) + ok, err := c.Get(ctx, "missing", &got) if err != nil || ok { - t.Fatalf("GetE missing = %v, err=%v; want false,nil", ok, err) + t.Fatalf("Get missing = %v, err=%v; want false,nil", ok, err) } if err := c.Set(ctx, "present", "value", time.Minute); err != nil { t.Fatalf("Set present: %v", err) } - ok, err = c.GetE(ctx, "present", &got) + ok, err = c.Get(ctx, "present", &got) if err != nil || !ok || got != "value" { - t.Fatalf("GetE present = %v, %q, err=%v; want true,value,nil", ok, got, err) + t.Fatalf("Get present = %v, %q, err=%v; want true,value,nil", ok, got, err) } if err := c.client().Set(ctx, "bad-json", "{", time.Minute).Err(); err != nil { t.Fatalf("Set bad-json: %v", err) } - ok, err = c.GetE(ctx, "bad-json", &got) + ok, err = c.Get(ctx, "bad-json", &got) if err == nil || ok { - t.Fatalf("GetE bad-json = %v, err=%v; want false,error", ok, err) + t.Fatalf("Get bad-json = %v, err=%v; want false,error", ok, err) } canceled, cancel := context.WithCancel(ctx) cancel() - ok, err = c.GetE(canceled, "present", &got) + ok, err = c.Get(canceled, "present", &got) if err == nil || ok { - t.Fatalf("GetE canceled = %v, err=%v; want false,error", ok, err) + t.Fatalf("Get canceled = %v, err=%v; want false,error", ok, err) } } -func TestM10PackageExistsEUsesOptionalInterface(t *testing.T) { +func TestM10PackageExistsReturnsBackendErrors(t *testing.T) { setupM10MiniRedis(t) orig := GetDefaultCache() t.Cleanup(func() { SetDefaultCacheManager(orig) }) @@ -141,13 +141,13 @@ func TestM10PackageExistsEUsesOptionalInterface(t *testing.T) { if err := GetCache().Set(ctx, "present", "value", time.Minute); err != nil { t.Fatalf("Set through facade: %v", err) } - exists, err := ExistsE(ctx, "present") + exists, err := Exists(ctx, "present") if err != nil || !exists { - t.Fatalf("facade ExistsE = %v, err=%v; want true,nil", exists, err) + t.Fatalf("facade Exists = %v, err=%v; want true,nil", exists, err) } } -func TestM10PackageGetEUsesOptionalInterface(t *testing.T) { +func TestM10PackageGetReturnsBackendErrors(t *testing.T) { setupM10MiniRedis(t) orig := GetDefaultCache() t.Cleanup(func() { SetDefaultCacheManager(orig) }) @@ -159,8 +159,41 @@ func TestM10PackageGetEUsesOptionalInterface(t *testing.T) { t.Fatalf("Set through facade: %v", err) } var got string - ok, err := GetE(ctx, "present", &got) + ok, err := Get(ctx, "present", &got) if err != nil || !ok || got != "value" { - t.Fatalf("facade GetE = %v, %q, err=%v; want true,value,nil", ok, got, err) + t.Fatalf("facade Get = %v, %q, err=%v; want true,value,nil", ok, got, err) + } +} + +// TestM10CustomCacheServiceCompile asserts a user-provided CacheService +// implementation compiles against the (bool, error) contract. This is a +// compile-time guard: if the interface changes again, this stops building. +type customCacheSvc struct{} + +func (customCacheSvc) Get(ctx context.Context, key string, dest any) (bool, error) { return false, nil } +func (customCacheSvc) Set(ctx context.Context, key string, value any, ttl time.Duration) error { + return nil +} +func (customCacheSvc) Delete(ctx context.Context, key string) error { return nil } +func (customCacheSvc) DeleteByPattern(ctx context.Context, p string) error { return nil } +func (customCacheSvc) Exists(ctx context.Context, key string) (bool, error) { return false, nil } + +func TestM10CustomCacheServiceCompiles(t *testing.T) { + var _ CacheService = customCacheSvc{} +} + +// TestM10RedisNotReadyOnGetExists asserts the Redis-not-ready error is +// surfaced (not swallowed) when no backend is configured. +func TestM10RedisNotReadyOnGetExists(t *testing.T) { + database.SetTestRedisClient(nil) + t.Cleanup(func() { database.SetTestRedisClient(nil) }) + c := &redisCache{} + ctx := context.Background() + + if _, err := c.Get(ctx, "k", new(string)); !errors.Is(err, ErrRedisNotReady) { + t.Fatalf("Get without Redis err = %v, want ErrRedisNotReady", err) + } + if _, err := c.Exists(ctx, "k"); !errors.Is(err, ErrRedisNotReady) { + t.Fatalf("Exists without Redis err = %v, want ErrRedisNotReady", err) } } diff --git a/cache/keybuilder.go b/cache/keybuilder.go index 4abe8f6..969bdf8 100644 --- a/cache/keybuilder.go +++ b/cache/keybuilder.go @@ -270,8 +270,9 @@ func SetWithPrefix(ctx context.Context, key string, value any, ttl time.Duration return GetCache().Set(ctx, kb.Build(key), value, ttl) } -// GetWithPrefix 带前缀的缓存获取 -func GetWithPrefix(ctx context.Context, key string, dest any, prefix string) bool { +// GetWithPrefix 带前缀的缓存获取。命中返回 (true, nil);未命中返回 (false, nil); +// Redis 错误或反序列化失败返回 (false, err)。 +func GetWithPrefix(ctx context.Context, key string, dest any, prefix string) (bool, error) { kb := NewKeyBuilder(WithPrefix(prefix)) return GetCache().Get(ctx, kb.Build(key), dest) } diff --git a/examples/full/main_test.go b/examples/full/main_test.go index a5d7f38..145eaee 100644 --- a/examples/full/main_test.go +++ b/examples/full/main_test.go @@ -13,9 +13,12 @@ import ( "github.com/EthanCodeCraft/xlgo-core/config" "github.com/EthanCodeCraft/xlgo-core/database" + "github.com/EthanCodeCraft/xlgo-core/jwt" "github.com/EthanCodeCraft/xlgo-core/validation" + "github.com/alicebob/miniredis/v2" "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" + "github.com/redis/go-redis/v9" "gorm.io/gorm" ) @@ -63,6 +66,15 @@ func setupExampleRouter(t *testing.T) (*gin.Engine, *gorm.DB) { } }) + // JWT 黑名单走 Redis(ParseToken 默认 fail-closed,无 Redis 会拒绝所有 token)。 + // 测试用 miniredis 注入,避免依赖外部 Redis。 + mr := miniredis.RunT(t) + redisClient := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = redisClient.Close() }) + prevJWTMgr := jwt.GetDefaultJWT() + jwt.SetDefaultJWTManager(jwt.NewJWTManagerWithRedis(redisClient)) + t.Cleanup(func() { jwt.SetDefaultJWTManager(prevJWTMgr) }) + gin.SetMode(gin.TestMode) r := gin.New() registerRoutes(r.Group("")) diff --git a/jwt/jwt.go b/jwt/jwt.go index 4237ca1..82aca6c 100644 --- a/jwt/jwt.go +++ b/jwt/jwt.go @@ -119,8 +119,8 @@ func (tb *TokenBlacklist) redisClient() *redis.Client { const blacklistOpTimeout = 1 * time.Second // BlacklistPolicy 控制解析 Token 时遇到黑名单查询错误的处理策略。 -// ParseToken 为兼容未启用 Redis 的存量部署默认 fail-open;安全敏感路由应使用 -// ParseTokenFailClosed 或 ParseTokenWithBlacklistPolicy(..., BlacklistFailClosed)。 +// ParseToken 默认 BlacklistFailClosed(黑名单不可检查即拒绝); +// 需显式 fail-open(仅无 Redis 或低安全场景)用 ParseTokenWithBlacklistPolicy(..., BlacklistFailOpen)。 type BlacklistPolicy int const ( @@ -164,31 +164,10 @@ func (tb *TokenBlacklist) Add(jti string, expiry time.Time) error { return client.Set(ctx, key, "1", ttl).Err() } -// IsBlacklisted 检查 JTI 是否在黑名单中 -func (tb *TokenBlacklist) IsBlacklisted(jti string) bool { - client := tb.redisClient() - if client == nil { - // Redis 未启用,不检查黑名单 - return false - } - - ctx, cancel := blacklistCtx() - defer cancel() - key := fmt.Sprintf("jwt_bl:%s", jti) - // M-A 修复:显式处理 Redis 错误(原 .Val() 吞错致故障被静默当"未拉黑")。 - // 错误时保持 fail-open(返 false),与"无 Redis 部署可用"的固有局限一致,但记录告警 - // 便于运维感知 Redis 故障。安全敏感场景必须启用 Redis(见 ErrBlacklistUnavailable 注释)。 - n, err := client.Exists(ctx, key).Result() - if err != nil { - logger.Warn("jwt 黑名单检查失败,fail-open 放行", zap.String("jti", jti), zap.Error(err)) - return false - } - return n > 0 -} - -// IsBlacklistedE 检查 JTI 是否在黑名单中,并返回 Redis/后端错误。 -// 当黑名单可用性属于路由安全契约时使用它;IsBlacklisted 保留旧版 fail-open bool API。 -func (tb *TokenBlacklist) IsBlacklistedE(jti string) (bool, error) { +// IsBlacklisted 检查 JTI 是否在黑名单中。 +// 命中返回 (true, nil);未命中返回 (false, nil); +// Redis 未启用或不可达返回 (false, ErrBlacklistUnavailable),由调用方决定 fail-open/fail-closed。 +func (tb *TokenBlacklist) IsBlacklisted(jti string) (bool, error) { client := tb.redisClient() if client == nil { return false, ErrBlacklistUnavailable @@ -393,7 +372,7 @@ func checkTokenBlacklist(claims *Claims, policy BlacklistPolicy) error { if claims == nil || claims.JTI == "" { return nil } - revoked, err := currentBlacklist().IsBlacklistedE(claims.JTI) + revoked, err := currentBlacklist().IsBlacklisted(claims.JTI) if err != nil { if policy == BlacklistFailClosed { return err @@ -407,13 +386,10 @@ func checkTokenBlacklist(claims *Claims, policy BlacklistPolicy) error { return nil } -// ParseToken 解析 JWT Token +// ParseToken 解析 JWT Token。默认 fail-closed:黑名单后端不可检查时返回 +// ErrBlacklistUnavailable,拒绝该 Token。需要显式 fail-open(仅无 Redis 或低安全场景) +// 请用 ParseTokenWithBlacklistPolicy(token, BlacklistFailOpen)。 func ParseToken(tokenString string) (*Claims, error) { - return ParseTokenWithBlacklistPolicy(tokenString, BlacklistFailOpen) -} - -// ParseTokenFailClosed 解析 JWT Token;若黑名单后端不可检查,则拒绝该 Token。 -func ParseTokenFailClosed(tokenString string) (*Claims, error) { return ParseTokenWithBlacklistPolicy(tokenString, BlacklistFailClosed) } @@ -521,8 +497,9 @@ func GetJTI(tokenString string) (string, error) { return "", ErrTokenInvalid } -// IsTokenRevoked 检查 Token 是否被撤销(通过 JTI) -func IsTokenRevoked(jti string) bool { +// IsTokenRevoked 检查 Token 是否被撤销(通过 JTI)。 +// 返回 (是否撤销, 错误);黑名单后端不可用时返回 (false, ErrBlacklistUnavailable)。 +func IsTokenRevoked(jti string) (bool, error) { return currentBlacklist().IsBlacklisted(jti) } diff --git a/jwt/jwt_c9c_internal_test.go b/jwt/jwt_c9c_internal_test.go index 7536f2b..e53e812 100644 --- a/jwt/jwt_c9c_internal_test.go +++ b/jwt/jwt_c9c_internal_test.go @@ -74,7 +74,7 @@ func TestC9cConcurrentSetDefaultAndRead(t *testing.T) { default: } _, _ = ParseToken(token) - _ = IsTokenRevoked("some-jti") + _, _ = IsTokenRevoked("some-jti") _ = InvalidateTokenByID("some-jti", time.Now().Add(time.Hour)) _ = currentManager() } diff --git a/jwt/jwt_test.go b/jwt/jwt_test.go index 77f4106..5832f8f 100644 --- a/jwt/jwt_test.go +++ b/jwt/jwt_test.go @@ -44,6 +44,7 @@ func TestGenerateToken(t *testing.T) { func TestParseToken(t *testing.T) { setupTestConfig() + setupMiniRedis(t) // 先生成 token token, _ := jwt.GenerateToken(1, "testuser", "admin", "super_admin") @@ -183,9 +184,10 @@ func TestTokenBlacklist(t *testing.T) { t.Errorf("TokenBlacklist.Add without Redis should return ErrBlacklistUnavailable, got %v", err) } - // 无 Redis 时,IsBlacklisted 仍返回 false(验证侧 fail-open 是无 Redis 部署的固有局限) - if tb.IsBlacklisted("test-token") { - t.Error("TokenBlacklist.IsBlacklisted without Redis should return false") + // 无 Redis 时,IsBlacklisted 返回 (false, ErrBlacklistUnavailable)(fail-closed:错误上抛) + revoked, err := tb.IsBlacklisted("test-token") + if revoked || !errors.Is(err, jwt.ErrBlacklistUnavailable) { + t.Errorf("TokenBlacklist.IsBlacklisted without Redis = (%v, %v), want (false, ErrBlacklistUnavailable)", revoked, err) } } @@ -376,6 +378,7 @@ func TestSupportedAlgorithmHS384(t *testing.T) { Expire: time.Hour, }}) t.Cleanup(setupTestConfig) + setupMiniRedis(t) token, err := jwt.GenerateToken(1, "u", "admin", "admin") if err != nil { @@ -529,11 +532,11 @@ func TestParseTokenBlacklistPolicy(t *testing.T) { if err != nil { t.Fatalf("GenerateToken: %v", err) } - if _, err := jwt.ParseToken(token); err != nil { - t.Fatalf("ParseToken default fail-open should pass without Redis: %v", err) + if _, err := jwt.ParseToken(token); !errors.Is(err, jwt.ErrBlacklistUnavailable) { + t.Fatalf("ParseToken default fail-closed should reject without Redis, err = %v, want ErrBlacklistUnavailable", err) } - if _, err := jwt.ParseTokenFailClosed(token); !errors.Is(err, jwt.ErrBlacklistUnavailable) { - t.Fatalf("ParseTokenFailClosed err = %v, want ErrBlacklistUnavailable", err) + if _, err := jwt.ParseTokenWithBlacklistPolicy(token, jwt.BlacklistFailOpen); err != nil { + t.Fatalf("ParseTokenWithBlacklistPolicy fail-open should pass without Redis: %v", err) } } diff --git a/middleware/auth_test.go b/middleware/auth_test.go index 41aae18..4b4ec59 100644 --- a/middleware/auth_test.go +++ b/middleware/auth_test.go @@ -42,6 +42,7 @@ func setAuthUser(userType, role string) func(*gin.Context) { } func TestAuthRequiredAcceptsCaseInsensitiveBearer_M8(t *testing.T) { + setupMiddlewareMiniRedis(t) if err := config.Set(&config.Config{ JWT: config.JWTConfig{ Secret: "test-secret-key-1234567890123456789012", diff --git a/middleware/middleware_test.go b/middleware/middleware_test.go index 03e1a49..e127585 100644 --- a/middleware/middleware_test.go +++ b/middleware/middleware_test.go @@ -1115,7 +1115,7 @@ func TestRateLimiterRejectsInvalidConfig(t *testing.T) { _ = middleware.NewRedisRateLimiter("bad", 0, time.Minute) }) assertPanic(t, "redis fail-closed limiter zero window", func() { - _ = middleware.NewRedisRateLimiterFailClosed("bad", 1, 0) + _ = middleware.NewRedisRateLimiter("bad", 1, 0, middleware.WithFailClosed(true)) }) } @@ -1371,7 +1371,7 @@ func TestRedisRateLimiterFailClosedNoRedis(t *testing.T) { prev := database.SetTestRedisClient(nil) defer func() { database.SetTestRedisClient(prev) }() - limiter := middleware.NewRedisRateLimiterFailClosed("test", 10, time.Minute) + limiter := middleware.NewRedisRateLimiter("test", 10, time.Minute, middleware.WithFailClosed(true)) allowed, err := limiter.Allow(context.Background(), "1.2.3.4") if allowed { t.Error("fail-closed no-redis should deny") @@ -1397,7 +1397,7 @@ func TestRedisRateLimiterFailClosedOnRedisError(t *testing.T) { } // fail-closed:正常时放行。 - closedLimiter := middleware.NewRedisRateLimiterFailClosed("test_closed", 10, time.Minute) + closedLimiter := middleware.NewRedisRateLimiter("test_closed", 10, time.Minute, middleware.WithFailClosed(true)) allowed, err = closedLimiter.Allow(context.Background(), "1.2.3.4") if err != nil { t.Fatalf("fail-closed normal err = %v", err) @@ -1431,7 +1431,7 @@ func TestRedisRateLimitFailClosedMiddlewareReturns503(t *testing.T) { defer func() { database.SetTestRedisClient(prev) }() r := setupTestRouter() - r.Use(middleware.RedisRateLimitFailClosed("login_limit", 10)) + r.Use(middleware.RedisRateLimit("login_limit", 10, middleware.WithFailClosed(true))) r.GET("/login", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }) w := httptest.NewRecorder() diff --git a/middleware/ratelimit.go b/middleware/ratelimit.go index d2a143e..d324d4b 100644 --- a/middleware/ratelimit.go +++ b/middleware/ratelimit.go @@ -193,28 +193,32 @@ else end ` -// NewRedisRateLimiter 创建 Redis 分布式限流器(默认 fail-open:Redis 错误时放行,避免影响业务)。 -// 安全敏感场景(如登录防爆破)应使用 NewRedisRateLimiterFailClosed,Redis 故障时拒绝以防限流失效。 -func NewRedisRateLimiter(keyPrefix string, rate int, window time.Duration) *RedisRateLimiter { - mustValidRateLimit(rate, window) - return &RedisRateLimiter{ - keyPrefix: keyPrefix, - rate: rate, - window: window, - // failClosed 零值 false(fail-open,兼容默认) +// RedisRateLimiterOption 配置 RedisRateLimiter 的可选策略。 +type RedisRateLimiterOption func(*RedisRateLimiter) + +// WithFailClosed 设置 Redis 故障时 fail-closed(拒绝请求)。 +// 不传或传 false 为 fail-open(放行,兼容默认)。安全敏感场景(登录防爆破等)应传 true。 +func WithFailClosed(failClosed bool) RedisRateLimiterOption { + return func(rl *RedisRateLimiter) { + rl.failClosed.Store(failClosed) } } -// NewRedisRateLimiterFailClosed 创建安全型 Redis 分布式限流器(fail-closed): -// Redis 不可用/错误/返回非预期类型时拒绝请求,避免限流静默失效(防爆破场景必备)。 -func NewRedisRateLimiterFailClosed(keyPrefix string, rate int, window time.Duration) *RedisRateLimiter { +// NewRedisRateLimiter 创建 Redis 分布式限流器。 +// 默认 fail-open(Redis 故障时放行,避免影响业务);安全敏感场景传 WithFailClosed(true)。 +func NewRedisRateLimiter(keyPrefix string, rate int, window time.Duration, opts ...RedisRateLimiterOption) *RedisRateLimiter { mustValidRateLimit(rate, window) rl := &RedisRateLimiter{ keyPrefix: keyPrefix, rate: rate, window: window, + // failClosed 零值 false(fail-open,兼容默认) + } + for _, opt := range opts { + if opt != nil { + opt(rl) + } } - rl.failClosed.Store(true) return rl } @@ -458,11 +462,11 @@ func redisLimitDecision(c *gin.Context, allowed bool, err error) { c.Next() } -// RedisRateLimit Redis 分布式限流中间件(fail-open:Redis 故障时放行,避免影响业务)。 -// 参数: keyPrefix 键名前缀(如 "login_limit"),rate 每分钟请求数 -// 安全敏感场景(登录防爆破等)应使用 RedisRateLimitFailClosed,Redis 故障时拒绝以防限流失效。 -func RedisRateLimit(keyPrefix string, rate int) gin.HandlerFunc { - limiter := NewRedisRateLimiter(keyPrefix, rate, time.Minute) +// RedisRateLimit Redis 分布式限流中间件。 +// 默认 fail-open(Redis 故障时放行,避免影响业务);安全敏感场景(登录防爆破等) +// 传 WithFailClosed(true),Redis 故障时拒绝以防限流失效。 +func RedisRateLimit(keyPrefix string, rate int, opts ...RedisRateLimiterOption) gin.HandlerFunc { + limiter := NewRedisRateLimiter(keyPrefix, rate, time.Minute, opts...) return func(c *gin.Context) { identifier := c.ClientIP() @@ -478,22 +482,11 @@ func RedisRateLimit(keyPrefix string, rate int) gin.HandlerFunc { } } -// RedisRateLimitFailClosed 安全型 Redis 分布式限流中间件(fail-closed): -// Redis 故障时拒绝(503),避免限流静默失效。用于登录防爆破等安全场景。 -func RedisRateLimitFailClosed(keyPrefix string, rate int) gin.HandlerFunc { - limiter := NewRedisRateLimiterFailClosed(keyPrefix, rate, time.Minute) - - return func(c *gin.Context) { - identifier := c.ClientIP() - allowed, err := limiter.Allow(c.Request.Context(), identifier) - redisLimitDecision(c, allowed, err) - } -} - -// RedisRateLimitWithIdentifier 自定义标识的 Redis 分布式限流(fail-open)。 -// 参数: keyPrefix 键名前缀,rate 每分钟请求数,identifierFunc 标识获取函数 -func RedisRateLimitWithIdentifier(keyPrefix string, rate int, identifierFunc func(c *gin.Context) string) gin.HandlerFunc { - limiter := NewRedisRateLimiter(keyPrefix, rate, time.Minute) +// RedisRateLimitWithIdentifier 自定义标识的 Redis 分布式限流。 +// 参数: keyPrefix 键名前缀,rate 1 分钟窗口内允许的请求数,identifierFunc 标识获取函数。 +// identifierFunc 为 nil 或返回空串时回退到 c.ClientIP()。默认 fail-open,传 WithFailClosed(true) 切换。 +func RedisRateLimitWithIdentifier(keyPrefix string, rate int, identifierFunc func(c *gin.Context) string, opts ...RedisRateLimiterOption) gin.HandlerFunc { + limiter := NewRedisRateLimiter(keyPrefix, rate, time.Minute, opts...) return func(c *gin.Context) { identifier := "" @@ -511,10 +504,10 @@ func RedisRateLimitWithIdentifier(keyPrefix string, rate int, identifierFunc fun // LoginRedisRateLimit 登录接口 Redis 分布式限流(fail-closed)。 // -// H4c: 登录防爆破场景必须 fail-closed——Redis 故障时若 fail-open 则限流失效、 +// H4c: 登录防爆破场景必须 fail-closed--Redis 故障时若 fail-open 则限流失效、 // 攻击者可借 Redis 抖动窗口无限爆破。改为 fail-closed:Redis 故障时返 503 拒绝。 func LoginRedisRateLimit() gin.HandlerFunc { - return RedisRateLimitFailClosed("login_limit", 10) + return RedisRateLimit("login_limit", 10, WithFailClosed(true)) } // APIRedisRateLimit API Redis 分布式限流(fail-open,避免影响业务)。 @@ -522,25 +515,16 @@ func APIRedisRateLimit() gin.HandlerFunc { return RedisRateLimit("api_limit", 100) } -// UploadRedisRateLimit 上传接口 Redis 分布式限流(fail-open)。 +// UploadRedisRateLimit 上传接口 Redis 分布式限流(fail-closed)。 +// 上传属资源敏感操作,Redis 故障时拒绝以防限流静默失效。 func UploadRedisRateLimit() gin.HandlerFunc { - return RedisRateLimit("upload_limit", 20) + return RedisRateLimit("upload_limit", 20, WithFailClosed(true)) } -// CustomRedisRateLimit 自定义 Redis 分布式限流(fail-open)。 -func CustomRedisRateLimit(keyPrefix string, rate int, window time.Duration) gin.HandlerFunc { - limiter := NewRedisRateLimiter(keyPrefix, rate, window) - - return func(c *gin.Context) { - identifier := c.ClientIP() - allowed, err := limiter.Allow(c.Request.Context(), identifier) - redisLimitDecision(c, allowed, err) - } -} - -// CustomRedisRateLimitFailClosed 自定义安全型 Redis 分布式限流(fail-closed)。 -func CustomRedisRateLimitFailClosed(keyPrefix string, rate int, window time.Duration) gin.HandlerFunc { - limiter := NewRedisRateLimiterFailClosed(keyPrefix, rate, window) +// CustomRedisRateLimit 自定义 Redis 分布式限流。 +// 默认 fail-open,传 WithFailClosed(true) 切换为 fail-closed。 +func CustomRedisRateLimit(keyPrefix string, rate int, window time.Duration, opts ...RedisRateLimiterOption) gin.HandlerFunc { + limiter := NewRedisRateLimiter(keyPrefix, rate, window, opts...) return func(c *gin.Context) { identifier := c.ClientIP() diff --git a/storage/storage.go b/storage/storage.go index a6b9d1f..3e4cb0c 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -30,7 +30,9 @@ type Storage interface { GetURL(path string) string Delete(path string) error Get(path string) ([]byte, error) - Exists(path string) bool + // Exists 检查文件是否存在。存在返回 (true, nil);不存在返回 (false, nil); + // 未初始化、路径非法/穿越、后端错误返回 (false, err)。 + Exists(path string) (bool, error) } var ( @@ -46,6 +48,9 @@ var ( // ErrUploadTooLarge 上传声明大小或实际字节数超过 MaxSizeBytes(P0)。客户端声明的 file.Size // 不可信,故除前置校验外,拷贝阶段也按实际字节封顶,防止声明小体积却流式发送大 body 撑爆磁盘/OSS。 ErrUploadTooLarge = errors.New("upload exceeds max size") + // ErrReadTooLarge Get 读取内容超过 maxReadBytes 上限(C4c)。与 ErrUploadTooLarge 区分: + // 前者是读操作的超限,后者是上传操作的超限。 + ErrReadTooLarge = errors.New("read exceeds max size") ) const ( @@ -458,19 +463,25 @@ func (s *LocalStorage) Get(p string) ([]byte, error) { return nil, fmt.Errorf("读取文件内容失败: %w", err) } if s.maxReadBytes > 0 && int64(len(data)) > s.maxReadBytes { - return nil, fmt.Errorf("文件超过最大读取限制 %d 字节: %w", s.maxReadBytes, ErrInvalidPath) + return nil, fmt.Errorf("文件超过最大读取限制 %d 字节: %w", s.maxReadBytes, ErrReadTooLarge) } return data, nil } -// Exists 检查文件是否存在 -func (s *LocalStorage) Exists(p string) bool { +// Exists 检查文件是否存在。存在返回 (true, nil);不存在返回 (false, nil); +// 路径非法/穿越返回 (false, err);其他 Stat 错误返回 (false, err)。 +func (s *LocalStorage) Exists(p string) (bool, error) { fullPath, err := s.safeJoin(p) if err != nil { - return false + return false, err } - _, err = os.Stat(fullPath) - return err == nil + if _, err := os.Stat(fullPath); err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + return true, nil } // OSSStorage OSS 存储 @@ -661,19 +672,27 @@ func (s *OSSStorage) Get(p string) ([]byte, error) { return nil, fmt.Errorf("读取 OSS 文件内容失败: %w", err) } if s.maxReadBytes > 0 && int64(len(data)) > s.maxReadBytes { - return nil, fmt.Errorf("文件超过最大读取限制 %d 字节: %w", s.maxReadBytes, ErrInvalidPath) + return nil, fmt.Errorf("文件超过最大读取限制 %d 字节: %w", s.maxReadBytes, ErrReadTooLarge) } return data, nil } -// Exists 检查 OSS 文件是否存在 -func (s *OSSStorage) Exists(p string) bool { +// Exists 检查 OSS 文件是否存在。存在返回 (true, nil);object 不存在返回 (false, nil); +// key 非法/穿越返回 (false, err);鉴权/网络/服务端错误返回 (false, err)。 +func (s *OSSStorage) Exists(p string) (bool, error) { key, err := sanitizeObjectKey(p) if err != nil { - return false + return false, err } - _, err = s.bucket.GetObjectMeta(key) - return err == nil + if _, err := s.bucket.GetObjectMeta(key); err != nil { + // OSS object 不存在(404 / NoSuchKey)不是错误,返回 (false, nil)。 + var se *oss.ServiceError + if errors.As(err, &se) && (se.StatusCode == http.StatusNotFound || se.Code == "NoSuchKey") { + return false, nil + } + return false, err + } + return true, nil } // StorageManager 存储管理器(#10)。照 database.Manager 模式: @@ -811,11 +830,12 @@ func Get(path string) ([]byte, error) { return s.Get(path) } -// Exists 检查文件是否存在 -func Exists(path string) bool { +// Exists 检查文件是否存在。未初始化返回 (false, ErrStorageNotInitialized); +// 其余语义同 Storage.Exists。 +func Exists(path string) (bool, error) { s := GetStorage() if s == nil { - return false + return false, ErrStorageNotInitialized } return s.Exists(path) } diff --git a/storage/storage_security_test.go b/storage/storage_security_test.go index 737bd11..3aa515b 100644 --- a/storage/storage_security_test.go +++ b/storage/storage_security_test.go @@ -84,9 +84,9 @@ func TestLocalStoragePathTraversal(t *testing.T) { t.Errorf("Get(%q) err = %v, want ErrPathTraversal", escapeRel, err) } - // Exists 必须返回 false(而非穿越探测到 canary) - if s.Exists(escapeRel) { - t.Errorf("Exists(%q) = true, want false (traversal must not probe outside root)", escapeRel) + // Exists 必须返回 (false, ErrPathTraversal)(而非穿越探测到 canary) + if ok, err := s.Exists(escapeRel); ok || !errors.Is(err, storage.ErrPathTraversal) { + t.Errorf("Exists(%q) = (%v, %v), want (false, ErrPathTraversal)", escapeRel, ok, err) } // 绝对路径也必须拒绝 @@ -111,8 +111,8 @@ func TestLocalStorageNormalPathStillWorks(t *testing.T) { } rel := filepath.ToSlash(filepath.Join("sub", "ok.txt")) - if !s.Exists(rel) { - t.Error("Exists(normal) = false, want true") + if ok, err := s.Exists(rel); err != nil || !ok { + t.Errorf("Exists(normal) = (%v, %v), want (true, nil)", ok, err) } data, err := s.Get(rel) if err != nil { @@ -170,8 +170,8 @@ func TestLocalStorageGetReadLimit(t *testing.T) { if err != nil { t.Fatalf("Upload big file: %v", err) } - if _, err := s.Get(rel); !errors.Is(err, storage.ErrInvalidPath) { - t.Errorf("Get over-limit err = %v, want ErrInvalidPath", err) + if _, err := s.Get(rel); !errors.Is(err, storage.ErrReadTooLarge) { + t.Errorf("Get over-limit err = %v, want ErrReadTooLarge", err) } // 小文件应正常读取。 diff --git a/storage/storage_test.go b/storage/storage_test.go index 558d2c1..d0dc370 100644 --- a/storage/storage_test.go +++ b/storage/storage_test.go @@ -40,8 +40,8 @@ func TestStorageNotInitialized(t *testing.T) { if url := storage.GetURL("missing"); url != "" { t.Fatalf("expected empty URL, got %q", url) } - if storage.Exists("missing") { - t.Fatal("expected Exists false without storage") + if ok, err := storage.Exists("missing"); ok || !errors.Is(err, storage.ErrStorageNotInitialized) { + t.Fatalf("expected (false, ErrStorageNotInitialized) without storage, got (%v, %v)", ok, err) } } @@ -107,8 +107,9 @@ func TestLocalStorageDeleteGetExists(t *testing.T) { } // 测试 Exists - if !local.Exists("test.txt") { - t.Error("Exists should return true for existing file") + ok, err := local.Exists("test.txt") + if err != nil || !ok { + t.Errorf("Exists should return (true, nil) for existing file, got (%v, %v)", ok, err) } // 测试 Get @@ -127,8 +128,9 @@ func TestLocalStorageDeleteGetExists(t *testing.T) { } // 验证删除后不存在 - if local.Exists("test.txt") { - t.Error("Exists should return false after delete") + ok, err = local.Exists("test.txt") + if err != nil || ok { + t.Errorf("Exists should return (false, nil) after delete, got (%v, %v)", ok, err) } // 删除不存在的文件应该失败