Add client_request_id field to post creation endpoints (CreateVotePost and CreatePostWithSegments). When provided, the server uses Redis SetNX-based idempotency to prevent duplicate posts from double-clicks or network retries within a 24-hour TTL. Duplicate requests return the originally created post; concurrent in-flight requests return 429 "正在发布中,请稍候".
123 lines
4.2 KiB
Go
123 lines
4.2 KiB
Go
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
|
||
}
|