Files
backend/internal/cache/idempotency.go
lafay d8e56e60dc
All checks were successful
Build Backend / build (push) Successful in 3m42s
Build Backend / build-docker (push) Successful in 1m7s
feat(post): add idempotency support for post creation with client request ID
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 "正在发布中,请稍候".
2026-06-26 17:00:38 +08:00

123 lines
4.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}