feat(post): add idempotency support for post creation with client request ID
All checks were successful
Build Backend / build (push) Successful in 3m42s
Build Backend / build-docker (push) Successful in 1m7s

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 "正在发布中,请稍候".
This commit is contained in:
lafay
2026-06-26 17:00:38 +08:00
parent 28cfcbf9a8
commit d8e56e60dc
8 changed files with 231 additions and 19 deletions

View File

@@ -22,12 +22,17 @@ const (
PostListTTL = 30 * time.Second // 帖子列表缓存30秒
PostListNullTTL = 5 * time.Second
PostListJitterRatio = 0.15
// PostCreateIdempotentTTL 帖子创建幂等键 TTL24 小时内同一 client_request_id 不会重复发帖
PostCreateIdempotentTTL = 24 * time.Hour
)
// PostService 帖子服务接口
type PostService interface {
// 帖子CRUD
Create(ctx context.Context, userID, title, content string, segments model.MessageSegments, images []string, channelID *string) (*model.Post, error)
// Create 创建帖子。clientRequestID 为客户端幂等键(可空):
// 非空时,同一 userID+clientRequestID 在 TTL 内重复提交将返回首次创建的帖子。
Create(ctx context.Context, userID, title, content string, segments model.MessageSegments, images []string, channelID *string, clientRequestID string) (*model.Post, error)
GetByID(ctx context.Context, id string) (*model.Post, error)
Update(ctx context.Context, post *model.Post) error
UpdateWithImages(ctx context.Context, post *model.Post, images *[]string) error
@@ -99,8 +104,29 @@ type PostListResult struct {
Total int64
}
// ErrDuplicatePostRequest 表示检测到重复的发帖请求(幂等命中"进行中"占位)。
// 客户端遇到此错误时应提示"正在发布中,请稍候",而非重复提交。
var ErrDuplicatePostRequest = apperrors.ErrDuplicatePostRequest
// Create 创建帖子
func (s *postServiceImpl) Create(ctx context.Context, userID, title, content string, segments model.MessageSegments, images []string, channelID *string) (*model.Post, error) {
func (s *postServiceImpl) Create(ctx context.Context, userID, title, content string, segments model.MessageSegments, images []string, channelID *string, clientRequestID string) (*model.Post, error) {
// 幂等检查:同一 userID + clientRequestID 在 TTL 内只创建一次。
// 重复请求(多次点击、网络重试)会命中已回填的 postID直接返回首次创建的帖子。
if clientRequestID != "" && s.cache != nil {
idemKey := cache.PostCreateIdempotentKey(userID, clientRequestID)
result := cache.TryAcquireIdempotency(ctx, s.cache, idemKey, PostCreateIdempotentTTL)
if !result.Acquired {
// 命中已完成的请求:返回首次创建的帖子。
if result.ExistingValue != "" {
if existing, err := s.postRepo.GetByID(result.ExistingValue); err == nil && existing != nil {
return existing, nil
}
}
// 命中"进行中"占位:前一个请求还在处理,拒绝重复提交。
return nil, ErrDuplicatePostRequest
}
}
// 如果没有提供 content 但有 segments从 segments 提取文本摘要
if content == "" && len(segments) > 0 {
content = dto.ExtractTextContent(segments)
@@ -125,9 +151,18 @@ func (s *postServiceImpl) Create(ctx context.Context, userID, title, content str
err := s.postRepo.Create(post, images)
if err != nil {
// 创建失败:释放幂等占位,允许用户重试。
if clientRequestID != "" && s.cache != nil {
cache.ReleaseIdempotency(ctx, s.cache, cache.PostCreateIdempotentKey(userID, clientRequestID))
}
return nil, err
}
// 创建成功:回填幂等键为 postID使后续重复请求返回同一帖子。
if clientRequestID != "" && s.cache != nil {
cache.CompleteIdempotency(ctx, s.cache, cache.PostCreateIdempotentKey(userID, clientRequestID), post.ID, PostCreateIdempotentTTL)
}
// 记录操作日志
if s.logService != nil {
s.logService.OperationLog.RecordOperation(&model.OperationLog{

View File

@@ -66,6 +66,23 @@ func (s *voteService) CreateVotePost(ctx context.Context, userID string, req *dt
return nil, errors.New("投票选项最多10个")
}
// 幂等检查:同一 userID + clientRequestID 在 TTL 内只创建一次。
// 重复请求(多次点击、网络重试)会命中已回填的 postID直接返回首次创建的帖子。
if req.ClientRequestID != "" && s.cache != nil {
idemKey := cache.PostCreateIdempotentKey(userID, req.ClientRequestID)
result := cache.TryAcquireIdempotency(ctx, s.cache, idemKey, PostCreateIdempotentTTL)
if !result.Acquired {
// 命中已完成的请求:返回首次创建的帖子。
if result.ExistingValue != "" {
if existing, err := s.postRepo.GetByID(result.ExistingValue); err == nil && existing != nil {
return s.convertToPostResponse(existing, userID), nil
}
}
// 命中"进行中"占位:前一个请求还在处理,拒绝重复提交。
return nil, ErrDuplicatePostRequest
}
}
// 构建 vote segment
voteOptions := make([]dto.VoteOptionData, len(req.VoteOptions))
for i, opt := range req.VoteOptions {
@@ -103,9 +120,18 @@ func (s *voteService) CreateVotePost(ctx context.Context, userID string, req *dt
err := s.postRepo.Create(post, req.Images)
if err != nil {
// 创建失败:释放幂等占位,允许用户重试。
if req.ClientRequestID != "" && s.cache != nil {
cache.ReleaseIdempotency(ctx, s.cache, cache.PostCreateIdempotentKey(userID, req.ClientRequestID))
}
return nil, err
}
// 创建成功:回填幂等键为 postID使后续重复请求返回同一帖子。
if req.ClientRequestID != "" && s.cache != nil {
cache.CompleteIdempotency(ctx, s.cache, cache.PostCreateIdempotentKey(userID, req.ClientRequestID), post.ID, PostCreateIdempotentTTL)
}
// 异步审核
go s.reviewVotePostAsync(post.ID, userID, req.Title, content, req.Images)