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 "正在发布中,请稍候".
This commit is contained in:
@@ -22,12 +22,17 @@ const (
|
||||
PostListTTL = 30 * time.Second // 帖子列表缓存30秒
|
||||
PostListNullTTL = 5 * time.Second
|
||||
PostListJitterRatio = 0.15
|
||||
|
||||
// PostCreateIdempotentTTL 帖子创建幂等键 TTL:24 小时内同一 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{
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user