diff --git a/CHANGELOG.md b/CHANGELOG.md index 864842f..7571193 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange ### Breaking ⚠️ +- **cache 写操作与计数器/原始 Redis helper 在 Redis 未初始化时返回 `ErrRedisNotReady`**:`Set` / `Delete` / `DeleteByPattern` / `Incr` / `IncrBy` / `Decr` / `GetTTL` / `SetExpire` / `GetRaw` / `SetRaw` 旧行为会静默返回成功或零值,调用方容易误判缓存写入、计数器更新或过期时间设置已经生效;现在统一显式返回错误。公共接口签名不变,但依赖“未启用 Redis 时当作成功”的下游需要改为忽略 `errors.Is(err, cache.ErrRedisNotReady)` 或显式启用 Redis。 - **`cache.WithLock` / `cache.WithLockAutoExtend` 未获取到锁时返回 `ErrLockNotAcquired`**:旧行为返回 `nil` 并跳过业务函数,调用方无法区分“业务执行成功”和“根本没有执行”。同时锁 TTL 小于 1ms、续期/重试间隔非正会返回显式错误,避免 Redis PX=0 或 `time.NewTicker(0)` 崩溃。 - **`jwt.ParseToken` 开始校验 issuer,`RefreshToken` 使用 `jwt.refresh_expire`**:签发者与当前配置不一致的 token 会被拒绝;刷新后的 token 过期时间优先使用 `refresh_expire`,未配置时回退 `expire`。`GenerateTokenWithCustomExpiry` 现在拒绝非正过期时间,`InvalidateTokenByID("")` 返回 `ErrEmptyJTI`。 - **`App.Init()` 由 `sync.Once` 改为生命周期状态机**(app.go,M1):5 态 `stateCreated/Initializing/Initialized/Stopping/Stopped` + `lifecycleMu`(RWMutex) + `initMu`(Mutex)。`Shutdown` 后或 `Init` 失败后再调 `Init()` 返回新增导出错误 **`xlgo.ErrAppClosed`**(原 `sync.Once` "多次调用返回首次结果"语义不再适用——已关闭的 App 不可再 Init,需新建 App)。 @@ -36,6 +37,7 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange - **M9 JWT issuer / refresh expiry 契约修复**:`ParseToken`、`InvalidateToken`、`GetClaimsFromToken` 统一按当前配置校验 issuer;`RefreshToken` 不再忽略 `refresh_expire`;空 JTI 不再写入永不命中的 `jwt_bl:` 黑名单键。 - **M10 分布式锁参数与未获锁语义修复**:锁 TTL 统一校验到 Redis 毫秒粒度;`TryLock` 的非正 retry interval 不再 busy-loop;`WithLockAutoExtend` 的非正 extend interval 不再触发 goroutine panic;`UnlockByKey` 在 Redis 未初始化时与 `ForceUnlock` 一样返回 `ErrRedisNotReady`。 +- **M10 cache 剩余错误语义收口**:新增 `cache.ExistsE` 与可选 `CacheExistChecker`,让调用方能区分 key 不存在和 Redis/backend 故障;保留旧 `Exists` bool-only 兼容方法但记录后端错误;`KeyBuilder` 现在忽略 nil option,`WithPrefix` / `WithSeparator` / `WithCacheType` 直接作用于 nil builder 时 no-op,避免扩展配置路径 panic。 - **M11 SSE 换行注入修复**:`WriteEvent` 拒绝带 CR/LF 的 event 名,`WriteMessage` / `WriteEvent` 的 data 按 SSE 多行格式逐行输出,避免用户数据伪造额外 `event:`/`id:` 字段。 - **M12 storage/compress 安全边界修复**:本地上传写侧 `Close` 错误会通过返回值暴露并清理残片;OSS `GetSignedURL` 统一经过 object key 净化;`UnzipWithOptions` 解析目标绝对路径失败时 fail-closed。 diff --git a/cache/cache.go b/cache/cache.go index 97fe775..9fea8b7 100644 --- a/cache/cache.go +++ b/cache/cache.go @@ -7,10 +7,8 @@ import ( "sync/atomic" "time" - "github.com/EthanCodeCraft/xlgo-core/logger" - "github.com/EthanCodeCraft/xlgo-core/database" - + "github.com/EthanCodeCraft/xlgo-core/logger" "github.com/redis/go-redis/v9" "go.uber.org/zap" ) @@ -29,6 +27,12 @@ type CacheService interface { 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) +} + // redisCache Redis 缓存实现。 // // 不在构造时快照 redis.Client(M12 修复:原 NewRedisCache 构造时取 database.GetRedis(), @@ -73,7 +77,7 @@ func (c *redisCache) Get(ctx context.Context, key string, dest any) bool { func (c *redisCache) Set(ctx context.Context, key string, value any, ttl time.Duration) error { cli := c.client() if cli == nil { - return nil // Redis 未启用,跳过缓存 + return ErrRedisNotReady } data, err := json.Marshal(value) @@ -94,7 +98,7 @@ func (c *redisCache) Set(ctx context.Context, key string, value any, ttl time.Du func (c *redisCache) Delete(ctx context.Context, key string) error { cli := c.client() if cli == nil { - return nil + return ErrRedisNotReady } if err := cli.Del(ctx, key).Err(); err != nil { @@ -109,7 +113,7 @@ func (c *redisCache) Delete(ctx context.Context, key string) error { func (c *redisCache) DeleteByPattern(ctx context.Context, pattern string) error { cli := c.client() if cli == nil { - return nil + return ErrRedisNotReady } var cursor uint64 @@ -150,12 +154,27 @@ func (c *redisCache) DeleteByPattern(ctx context.Context, pattern string) error // Exists 检查缓存是否存在 func (c *redisCache) Exists(ctx context.Context, key string) bool { - cli := c.client() - if cli == nil { + ok, err := c.ExistsE(ctx, key) + if err != nil { + logger.Warn("缓存存在性检查失败", zap.String("key", key), zap.Error(err)) return false } + return ok +} - return cli.Exists(ctx, key).Val() > 0 +// 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) { + cli := c.client() + if cli == nil { + return false, ErrRedisNotReady + } + + n, err := cli.Exists(ctx, key).Result() + if err != nil { + return false, err + } + return n > 0, nil } // CacheManager 缓存管理器(#10)。照 database.Manager 模式: @@ -222,3 +241,17 @@ func Init() { 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) { + 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 +} diff --git a/cache/cache_m10_internal_test.go b/cache/cache_m10_internal_test.go new file mode 100644 index 0000000..f0d58c7 --- /dev/null +++ b/cache/cache_m10_internal_test.go @@ -0,0 +1,113 @@ +package cache + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/EthanCodeCraft/xlgo-core/database" + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" +) + +func setupM10MiniRedis(t *testing.T) *miniredis.Miniredis { + t.Helper() + mr := miniredis.RunT(t) + client := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = client.Close() }) + database.SetTestRedisClient(client) + t.Cleanup(func() { database.SetTestRedisClient(nil) }) + return mr +} + +func TestM10CacheWritesReturnRedisNotReady(t *testing.T) { + database.SetTestRedisClient(nil) + t.Cleanup(func() { database.SetTestRedisClient(nil) }) + + c := &redisCache{} + ctx := context.Background() + + if err := c.Set(ctx, "k", "v", time.Minute); !errors.Is(err, ErrRedisNotReady) { + t.Fatalf("Set without Redis err = %v, want ErrRedisNotReady", err) + } + if err := c.Delete(ctx, "k"); !errors.Is(err, ErrRedisNotReady) { + t.Fatalf("Delete without Redis err = %v, want ErrRedisNotReady", err) + } + if err := c.DeleteByPattern(ctx, "k:*"); !errors.Is(err, ErrRedisNotReady) { + t.Fatalf("DeleteByPattern without Redis err = %v, want ErrRedisNotReady", err) + } +} + +func TestM10RawHelpersReturnRedisNotReady(t *testing.T) { + database.SetTestRedisClient(nil) + t.Cleanup(func() { database.SetTestRedisClient(nil) }) + ctx := context.Background() + + if _, err := Incr(ctx, "k"); !errors.Is(err, ErrRedisNotReady) { + t.Fatalf("Incr without Redis err = %v, want ErrRedisNotReady", err) + } + if _, err := IncrBy(ctx, "k", 2); !errors.Is(err, ErrRedisNotReady) { + t.Fatalf("IncrBy without Redis err = %v, want ErrRedisNotReady", err) + } + if _, err := Decr(ctx, "k"); !errors.Is(err, ErrRedisNotReady) { + t.Fatalf("Decr without Redis err = %v, want ErrRedisNotReady", err) + } + if _, err := GetTTL(ctx, "k"); !errors.Is(err, ErrRedisNotReady) { + t.Fatalf("GetTTL without Redis err = %v, want ErrRedisNotReady", err) + } + if _, err := SetExpire(ctx, "k", time.Minute); !errors.Is(err, ErrRedisNotReady) { + t.Fatalf("SetExpire without Redis err = %v, want ErrRedisNotReady", err) + } + if _, err := GetRaw(ctx, "k"); !errors.Is(err, ErrRedisNotReady) { + t.Fatalf("GetRaw without Redis err = %v, want ErrRedisNotReady", err) + } + if err := SetRaw(ctx, "k", "v", time.Minute); !errors.Is(err, ErrRedisNotReady) { + t.Fatalf("SetRaw without Redis err = %v, want ErrRedisNotReady", err) + } +} + +func TestM10ExistsEReturnsBackendErrors(t *testing.T) { + setupM10MiniRedis(t) + c := &redisCache{} + ctx := context.Background() + + exists, err := c.ExistsE(ctx, "missing") + if err != nil { + t.Fatalf("ExistsE missing err = %v", err) + } + if exists { + t.Fatal("ExistsE 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") + if err != nil || !exists { + t.Fatalf("ExistsE 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) + } +} + +func TestM10PackageExistsEUsesOptionalInterface(t *testing.T) { + setupM10MiniRedis(t) + orig := GetDefaultCache() + t.Cleanup(func() { SetDefaultCacheManager(orig) }) + SetDefaultCacheManager(NewCacheManager()) + Init() + + ctx := context.Background() + if err := GetCache().Set(ctx, "present", "value", time.Minute); err != nil { + t.Fatalf("Set through facade: %v", err) + } + exists, err := ExistsE(ctx, "present") + if err != nil || !exists { + t.Fatalf("facade ExistsE = %v, err=%v; want true,nil", exists, err) + } +} diff --git a/cache/keybuilder.go b/cache/keybuilder.go index b265452..4f3d74e 100644 --- a/cache/keybuilder.go +++ b/cache/keybuilder.go @@ -25,6 +25,9 @@ type KeyBuilderOption func(*KeyBuilder) // 示例: WithPrefix("site_a") -> 所有 key 自动添加 "site_a" 前缀 func WithPrefix(prefix string) KeyBuilderOption { return func(kb *KeyBuilder) { + if kb == nil { + return + } kb.prefix = prefix } } @@ -33,6 +36,9 @@ func WithPrefix(prefix string) KeyBuilderOption { // 示例: WithSeparator(":") -> "site_a:user:1" func WithSeparator(separator string) KeyBuilderOption { return func(kb *KeyBuilder) { + if kb == nil { + return + } kb._separator = separator } } @@ -41,6 +47,9 @@ func WithSeparator(separator string) KeyBuilderOption { // 示例: WithCacheType("session") -> "session_site_a_user:1" func WithCacheType(cacheType string) KeyBuilderOption { return func(kb *KeyBuilder) { + if kb == nil { + return + } kb._cacheType = cacheType } } @@ -53,6 +62,9 @@ func NewKeyBuilder(opts ...KeyBuilderOption) *KeyBuilder { _cacheType: "cache", } for _, opt := range opts { + if opt == nil { + continue + } opt(kb) } return kb diff --git a/cache/keybuilder_test.go b/cache/keybuilder_test.go index 4bc4e94..0ec6eb8 100644 --- a/cache/keybuilder_test.go +++ b/cache/keybuilder_test.go @@ -62,6 +62,17 @@ func TestKeyBuilderNoPrefix(t *testing.T) { } } +func TestKeyBuilderNilOptionIgnored(t *testing.T) { + kb := cache.NewKeyBuilder(nil, cache.WithPrefix("site")) + if got := kb.Build("user:1"); got != "cache:site:user:1" { + t.Fatalf("Build with nil option = %q, want cache:site:user:1", got) + } + + cache.WithPrefix("ignored")(nil) + cache.WithSeparator("_")(nil) + cache.WithCacheType("ignored")(nil) +} + func TestKeyBuilderSetPrefix(t *testing.T) { kb := cache.NewKeyBuilder(cache.WithPrefix("site_a")) @@ -211,4 +222,4 @@ func contains(s, sub string) bool { } } return false -} \ No newline at end of file +} diff --git a/cache/lock.go b/cache/lock.go index 03e4cba..2c89228 100644 --- a/cache/lock.go +++ b/cache/lock.go @@ -360,7 +360,7 @@ func ForceUnlock(ctx context.Context, key string) error { func Incr(ctx context.Context, key string) (int64, error) { rdb := database.GetRedis() if rdb == nil { - return 0, nil + return 0, ErrRedisNotReady } return rdb.Incr(ctx, key).Result() } @@ -369,7 +369,7 @@ func Incr(ctx context.Context, key string) (int64, error) { func IncrBy(ctx context.Context, key string, value int64) (int64, error) { rdb := database.GetRedis() if rdb == nil { - return 0, nil + return 0, ErrRedisNotReady } return rdb.IncrBy(ctx, key, value).Result() } @@ -378,7 +378,7 @@ func IncrBy(ctx context.Context, key string, value int64) (int64, error) { func Decr(ctx context.Context, key string) (int64, error) { rdb := database.GetRedis() if rdb == nil { - return 0, nil + return 0, ErrRedisNotReady } return rdb.Decr(ctx, key).Result() } @@ -387,7 +387,7 @@ func Decr(ctx context.Context, key string) (int64, error) { func GetTTL(ctx context.Context, key string) (time.Duration, error) { rdb := database.GetRedis() if rdb == nil { - return 0, nil + return 0, ErrRedisNotReady } return rdb.TTL(ctx, key).Result() } @@ -396,7 +396,7 @@ func GetTTL(ctx context.Context, key string) (time.Duration, error) { func SetExpire(ctx context.Context, key string, ttl time.Duration) (bool, error) { rdb := database.GetRedis() if rdb == nil { - return false, nil + return false, ErrRedisNotReady } return rdb.Expire(ctx, key, ttl).Result() } @@ -405,7 +405,7 @@ func SetExpire(ctx context.Context, key string, ttl time.Duration) (bool, error) func GetRaw(ctx context.Context, key string) (string, error) { rdb := database.GetRedis() if rdb == nil { - return "", nil + return "", ErrRedisNotReady } return rdb.Get(ctx, key).Result() } @@ -414,7 +414,7 @@ func GetRaw(ctx context.Context, key string) (string, error) { func SetRaw(ctx context.Context, key string, value string, ttl time.Duration) error { rdb := database.GetRedis() if rdb == nil { - return nil + return ErrRedisNotReady } return rdb.Set(ctx, key, value, ttl).Err() } diff --git a/cache/lock_test.go b/cache/lock_test.go index c4c1411..821b15d 100644 --- a/cache/lock_test.go +++ b/cache/lock_test.go @@ -60,28 +60,26 @@ func TestLockErrors(t *testing.T) { func TestIncrDecr(t *testing.T) { ctx := context.Background() - // Test Incr without Redis + // Redis 未初始化时应显式返回 ErrRedisNotReady,避免调用方误判为计数器值为 0。 n, err := cache.Incr(ctx, "counter") - if err != nil { - t.Errorf("Incr error: %v", err) + if !errors.Is(err, cache.ErrRedisNotReady) { + t.Errorf("Incr without Redis should return ErrRedisNotReady, got %v", err) } if n != 0 { t.Error("Incr should return 0 without Redis") } - // Test IncrBy n, err = cache.IncrBy(ctx, "counter", 10) - if err != nil { - t.Errorf("IncrBy error: %v", err) + if !errors.Is(err, cache.ErrRedisNotReady) { + t.Errorf("IncrBy without Redis should return ErrRedisNotReady, got %v", err) } if n != 0 { t.Error("IncrBy should return 0 without Redis") } - // Test Decr n, err = cache.Decr(ctx, "counter") - if err != nil { - t.Errorf("Decr error: %v", err) + if !errors.Is(err, cache.ErrRedisNotReady) { + t.Errorf("Decr without Redis should return ErrRedisNotReady, got %v", err) } if n != 0 { t.Error("Decr should return 0 without Redis") @@ -92,8 +90,8 @@ func TestSetExpire(t *testing.T) { ctx := context.Background() ok, err := cache.SetExpire(ctx, "test_key", time.Minute) - if err != nil { - t.Errorf("SetExpire error: %v", err) + if !errors.Is(err, cache.ErrRedisNotReady) { + t.Errorf("SetExpire without Redis should return ErrRedisNotReady, got %v", err) } if ok { t.Error("SetExpire should return false without Redis") @@ -105,14 +103,14 @@ func TestGetRawSetRaw(t *testing.T) { // Test SetRaw err := cache.SetRaw(ctx, "test_key", "test_value", time.Minute) - if err != nil { - t.Errorf("SetRaw error: %v", err) + if !errors.Is(err, cache.ErrRedisNotReady) { + t.Errorf("SetRaw without Redis should return ErrRedisNotReady, got %v", err) } // Test GetRaw val, err := cache.GetRaw(ctx, "test_key") - if err != nil { - t.Errorf("GetRaw error: %v", err) + if !errors.Is(err, cache.ErrRedisNotReady) { + t.Errorf("GetRaw without Redis should return ErrRedisNotReady, got %v", err) } if val != "" { t.Error("GetRaw should return empty string without Redis")