package cache import ( "context" "time" redislib "github.com/redis/go-redis/v9" "go.uber.org/zap" ) // IdempotencyResult 描述幂等检查的结果。 type IdempotencyResult struct { // Acquired 为 true 表示当前请求成功抢占到幂等键,调用方应执行业务逻辑。 // 为 false 表示检测到重复请求:ExistingValue 为先前写入的值(可能为空, // 表示前一个请求仍在进行中——即"进行中"占位)。 Acquired bool // ExistingValue 命中已有幂等记录时的值(如已创建的 postID)。 // Acquired=true 时为空字符串。 ExistingValue string } // TryAcquireIdempotency 原子抢占幂等键。 // // 流程: // 1. 读取幂等键。若已有值,视为重复请求,返回 (Acquired=false, ExistingValue=...)。 // 2. 若无值,用 SetNX 写入空占位(标记"进行中")。成功则获得执行权 (Acquired=true)。 // // 降级:当 Redis 不可用(cache 非 *LayeredCache/*RedisCache,或底层 client 为 nil)时, // 返回 (Acquired=true),即不阻断业务(与现有 call_service 的降级策略一致), // 此时幂等性退化为依赖前端防抖。 // // 调用方在业务成功后必须调用 CompleteIdempotency 写入真实结果,以便后续重试可返回同一资源。 func TryAcquireIdempotency(ctx context.Context, c Cache, key string, ttl time.Duration) IdempotencyResult { if c == nil { return IdempotencyResult{Acquired: true} } // 1) 先查命中:若已有"完成值",直接复用,避免重复创建。 if existing, ok := GetTyped[string](c, key); ok && existing != "" { return IdempotencyResult{Acquired: false, ExistingValue: existing} } // 2) 用 SetNX 写入空占位,原子抢占执行权。 // 占位为空字符串:表示"进行中"。后续命中时若值为空,视为并发请求进行中, // 直接拒绝(Acquired=false 且 ExistingValue="")。 rdb := resolveRedisClient(c) if rdb == nil { // Redis 不可用:降级放行,依赖前端防抖。 return IdempotencyResult{Acquired: true} } ok, err := rdb.SetNX(ctx, normalizeKey(key), "", ttl).Result() if err != nil { zap.L().Warn("idempotency SetNX failed, falling back to non-idempotent create", zap.String("key", key), zap.Error(err), ) return IdempotencyResult{Acquired: true} } if !ok { // 已被其他请求抢占(进行中或已完成但值尚未回填)。 // 读取实际值以区分"进行中"与"已完成"。 if existing, ok := GetTyped[string](c, key); ok && existing != "" { return IdempotencyResult{Acquired: false, ExistingValue: existing} } return IdempotencyResult{Acquired: false} } return IdempotencyResult{Acquired: true} } // CompleteIdempotency 在业务成功后回填幂等键的真实结果值(如 postID), // 使后续重复请求能返回同一资源。失败时仅记录日志,不影响业务结果。 func CompleteIdempotency(ctx context.Context, c Cache, key, value string, ttl time.Duration) { if c == nil || value == "" { return } // 直接覆盖占位为真实值,保留原 TTL。 rdb := resolveRedisClient(c) if rdb == nil { // 降级:用通用 Set 写入(LayeredCache/RedisCache 都支持)。 c.Set(key, value, ttl) return } if err := rdb.Set(ctx, normalizeKey(key), value, ttl).Err(); err != nil { zap.L().Warn("idempotency complete Set failed", zap.String("key", key), zap.Error(err), ) } } // ReleaseIdempotency 在业务失败时清除占位,允许重试。 func ReleaseIdempotency(ctx context.Context, c Cache, key string) { if c == nil { return } // 仅当当前值仍为空占位时才删除,避免误删已回填的真实值。 if existing, ok := GetTyped[string](c, key); ok && existing != "" { return } c.Delete(key) } // resolveRedisClient 从 Cache 实现中解析出底层 *redis.Client,用于 SetNX 原子操作。 // 返回 nil 表示 Redis 不可用(如纯内存 LRU 模式)。 func resolveRedisClient(c Cache) *redislib.Client { switch v := c.(type) { case *LayeredCache: client := v.GetRedisClient() if client == nil { return nil } return client.GetClient() case *RedisCache: client := v.GetRedisClient() if client == nil { return nil } return client.GetClient() } return nil }