fix cache error semantics

This commit is contained in:
杭州明婳科技
2026-07-08 15:02:52 +08:00
parent 9b9c5df676
commit 8ccfb162b8
7 changed files with 201 additions and 32 deletions
+2
View File
@@ -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。
+42 -9
View File
@@ -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.ClientM12 修复:原 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
}
+113
View File
@@ -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)
}
}
+12
View File
@@ -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
+12 -1
View File
@@ -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
}
}
+7 -7
View File
@@ -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()
}
+13 -15
View File
@@ -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")