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:
122
internal/cache/idempotency.go
vendored
Normal file
122
internal/cache/idempotency.go
vendored
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
21
internal/cache/keys.go
vendored
21
internal/cache/keys.go
vendored
@@ -38,12 +38,15 @@ const (
|
|||||||
PrefixChannelsAllList = "channels:all_list"
|
PrefixChannelsAllList = "channels:all_list"
|
||||||
|
|
||||||
// 消息缓存相关
|
// 消息缓存相关
|
||||||
keyPrefixMsgHash = "msg_hash" // 消息详情 Hash
|
keyPrefixMsgHash = "msg_hash" // 消息详情 Hash
|
||||||
keyPrefixMsgIndex = "msg_index" // 消息索引 Sorted Set
|
keyPrefixMsgIndex = "msg_index" // 消息索引 Sorted Set
|
||||||
keyPrefixMsgCount = "msg_count" // 消息计数
|
keyPrefixMsgCount = "msg_count" // 消息计数
|
||||||
keyPrefixMsgSeq = "msg_seq" // Seq 计数器
|
keyPrefixMsgSeq = "msg_seq" // Seq 计数器
|
||||||
keyPrefixMsgPage = "msg_page" // 分页缓存
|
keyPrefixMsgPage = "msg_page" // 分页缓存
|
||||||
keyPrefixMsgIdempotent = "msg_idem" // 消息幂等键
|
keyPrefixMsgIdempotent = "msg_idem" // 消息幂等键
|
||||||
|
|
||||||
|
// 帖子创建幂等键前缀(clientRequestID -> 已创建的 postID)
|
||||||
|
keyPrefixPostIdempotent = "post_idem"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PostListKey 生成帖子列表缓存键
|
// PostListKey 生成帖子列表缓存键
|
||||||
@@ -197,6 +200,12 @@ func MessageIdempotentKey(senderID, clientMsgID string) string {
|
|||||||
return fmt.Sprintf("%s:%s:%s", keyPrefixMsgIdempotent, senderID, clientMsgID)
|
return fmt.Sprintf("%s:%s:%s", keyPrefixMsgIdempotent, senderID, clientMsgID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PostCreateIdempotentKey 帖子创建幂等键 (userID + clientRequestID -> postID)
|
||||||
|
// 用于在 SetNX 原子抢占期间识别同一客户端请求的重试,避免重复发帖。
|
||||||
|
func PostCreateIdempotentKey(userID, clientRequestID string) string {
|
||||||
|
return fmt.Sprintf("%s:%s:%s", keyPrefixPostIdempotent, userID, clientRequestID)
|
||||||
|
}
|
||||||
|
|
||||||
// UserReadSeqKey 用户已读位置缓存键(OpenIM 风格 SEQ_USER_READ:{convID}:{userID})
|
// UserReadSeqKey 用户已读位置缓存键(OpenIM 风格 SEQ_USER_READ:{convID}:{userID})
|
||||||
func UserReadSeqKey(convID, userID string) string {
|
func UserReadSeqKey(convID, userID string) string {
|
||||||
return fmt.Sprintf("%s:%s:%s", PrefixUserReadSeq, convID, userID)
|
return fmt.Sprintf("%s:%s:%s", PrefixUserReadSeq, convID, userID)
|
||||||
|
|||||||
@@ -54,20 +54,26 @@ type PostResponse struct {
|
|||||||
|
|
||||||
// CreateVotePostRequest 创建投票帖子请求
|
// CreateVotePostRequest 创建投票帖子请求
|
||||||
type CreateVotePostRequest struct {
|
type CreateVotePostRequest struct {
|
||||||
Title string `json:"title" binding:"required,max=200"`
|
Title string `json:"title" binding:"required,max=200"`
|
||||||
Content string `json:"content" binding:"max=2000"`
|
Content string `json:"content" binding:"max=2000"`
|
||||||
ChannelID *string `json:"channel_id,omitempty"`
|
ChannelID *string `json:"channel_id,omitempty"`
|
||||||
Images []string `json:"images"`
|
Images []string `json:"images"`
|
||||||
VoteOptions []string `json:"vote_options" binding:"required,min=2,max=10"`
|
VoteOptions []string `json:"vote_options" binding:"required,min=2,max=10"`
|
||||||
|
// ClientRequestID 客户端为本次发帖生成的幂等键(UUID)。
|
||||||
|
// 同一 userID + ClientRequestID 在 TTL 内重复提交时,后端直接返回首次创建的帖子,避免重复发帖。
|
||||||
|
ClientRequestID string `json:"client_request_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreatePostWithSegmentsRequest 创建帖子请求(含 segments)
|
// CreatePostWithSegmentsRequest 创建帖子请求(含 segments)
|
||||||
type CreatePostWithSegmentsRequest struct {
|
type CreatePostWithSegmentsRequest struct {
|
||||||
Title string `json:"title" binding:"required,max=200"`
|
Title string `json:"title" binding:"required,max=200"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Segments model.MessageSegments `json:"segments"`
|
Segments model.MessageSegments `json:"segments"`
|
||||||
Images []string `json:"images"`
|
Images []string `json:"images"`
|
||||||
ChannelID *string `json:"channel_id,omitempty"`
|
ChannelID *string `json:"channel_id,omitempty"`
|
||||||
|
// ClientRequestID 客户端为本次发帖生成的幂等键(UUID)。
|
||||||
|
// 同一 userID + ClientRequestID 在 TTL 内重复提交时,后端直接返回首次创建的帖子,避免重复发帖。
|
||||||
|
ClientRequestID string `json:"client_request_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// VoteOptionDTO 投票选项DTO
|
// VoteOptionDTO 投票选项DTO
|
||||||
|
|||||||
@@ -130,6 +130,9 @@ var (
|
|||||||
// 学习资料相关错误
|
// 学习资料相关错误
|
||||||
ErrSubjectHasMaterials = &AppError{Code: "SUBJECT_HAS_MATERIALS", Message: "学科下存在资料,无法删除"}
|
ErrSubjectHasMaterials = &AppError{Code: "SUBJECT_HAS_MATERIALS", Message: "学科下存在资料,无法删除"}
|
||||||
|
|
||||||
|
// 帖子创建幂等性:同一 client_request_id 的请求仍在处理中(占位未回填真实结果)
|
||||||
|
ErrDuplicatePostRequest = &AppError{Code: "DUPLICATE_POST_REQUEST", Message: "正在发布中,请稍候"}
|
||||||
|
|
||||||
// 身份认证相关错误
|
// 身份认证相关错误
|
||||||
ErrVerificationPending = &AppError{Code: "VERIFICATION_PENDING", Message: "已有待审核的认证申请,请等待审核"}
|
ErrVerificationPending = &AppError{Code: "VERIFICATION_PENDING", Message: "已有待审核的认证申请,请等待审核"}
|
||||||
ErrVerificationNotFound = &AppError{Code: "VERIFICATION_NOT_FOUND", Message: "认证记录不存在"}
|
ErrVerificationNotFound = &AppError{Code: "VERIFICATION_NOT_FOUND", Message: "认证记录不存在"}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -105,13 +106,18 @@ func (h *PostHandler) Create(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
post, err := h.postService.Create(c.Request.Context(), userID, req.Title, content, segments, req.Images, req.ChannelID)
|
post, err := h.postService.Create(c.Request.Context(), userID, req.Title, content, segments, req.Images, req.ChannelID, req.ClientRequestID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var moderationErr *service.PostModerationRejectedError
|
var moderationErr *service.PostModerationRejectedError
|
||||||
if errors.As(err, &moderationErr) {
|
if errors.As(err, &moderationErr) {
|
||||||
response.BadRequest(c, moderationErr.UserMessage())
|
response.BadRequest(c, moderationErr.UserMessage())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 幂等命中"进行中"占位:提示客户端稍候,避免重复提交
|
||||||
|
if errors.Is(err, service.ErrDuplicatePostRequest) {
|
||||||
|
response.ErrorWithStatusCode(c, http.StatusTooManyRequests, 429, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
response.InternalServerError(c, "failed to create post")
|
response.InternalServerError(c, "failed to create post")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ func (h *VoteHandler) CreateVotePost(c *gin.Context) {
|
|||||||
response.BadRequest(c, moderationErr.UserMessage())
|
response.BadRequest(c, moderationErr.UserMessage())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 幂等命中"进行中"占位:提示客户端稍候,避免重复提交
|
||||||
|
if errors.Is(err, service.ErrDuplicatePostRequest) {
|
||||||
|
response.ErrorWithStatusCode(c, http.StatusTooManyRequests, 429, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
response.Error(c, http.StatusBadRequest, err.Error())
|
response.Error(c, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,12 +22,17 @@ const (
|
|||||||
PostListTTL = 30 * time.Second // 帖子列表缓存30秒
|
PostListTTL = 30 * time.Second // 帖子列表缓存30秒
|
||||||
PostListNullTTL = 5 * time.Second
|
PostListNullTTL = 5 * time.Second
|
||||||
PostListJitterRatio = 0.15
|
PostListJitterRatio = 0.15
|
||||||
|
|
||||||
|
// PostCreateIdempotentTTL 帖子创建幂等键 TTL:24 小时内同一 client_request_id 不会重复发帖
|
||||||
|
PostCreateIdempotentTTL = 24 * time.Hour
|
||||||
)
|
)
|
||||||
|
|
||||||
// PostService 帖子服务接口
|
// PostService 帖子服务接口
|
||||||
type PostService interface {
|
type PostService interface {
|
||||||
// 帖子CRUD
|
// 帖子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)
|
GetByID(ctx context.Context, id string) (*model.Post, error)
|
||||||
Update(ctx context.Context, post *model.Post) error
|
Update(ctx context.Context, post *model.Post) error
|
||||||
UpdateWithImages(ctx context.Context, post *model.Post, images *[]string) error
|
UpdateWithImages(ctx context.Context, post *model.Post, images *[]string) error
|
||||||
@@ -99,8 +104,29 @@ type PostListResult struct {
|
|||||||
Total int64
|
Total int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrDuplicatePostRequest 表示检测到重复的发帖请求(幂等命中"进行中"占位)。
|
||||||
|
// 客户端遇到此错误时应提示"正在发布中,请稍候",而非重复提交。
|
||||||
|
var ErrDuplicatePostRequest = apperrors.ErrDuplicatePostRequest
|
||||||
|
|
||||||
// Create 创建帖子
|
// 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 提取文本摘要
|
// 如果没有提供 content 但有 segments,从 segments 提取文本摘要
|
||||||
if content == "" && len(segments) > 0 {
|
if content == "" && len(segments) > 0 {
|
||||||
content = dto.ExtractTextContent(segments)
|
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)
|
err := s.postRepo.Create(post, images)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// 创建失败:释放幂等占位,允许用户重试。
|
||||||
|
if clientRequestID != "" && s.cache != nil {
|
||||||
|
cache.ReleaseIdempotency(ctx, s.cache, cache.PostCreateIdempotentKey(userID, clientRequestID))
|
||||||
|
}
|
||||||
return nil, err
|
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 {
|
if s.logService != nil {
|
||||||
s.logService.OperationLog.RecordOperation(&model.OperationLog{
|
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个")
|
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
|
// 构建 vote segment
|
||||||
voteOptions := make([]dto.VoteOptionData, len(req.VoteOptions))
|
voteOptions := make([]dto.VoteOptionData, len(req.VoteOptions))
|
||||||
for i, opt := range 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)
|
err := s.postRepo.Create(post, req.Images)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// 创建失败:释放幂等占位,允许用户重试。
|
||||||
|
if req.ClientRequestID != "" && s.cache != nil {
|
||||||
|
cache.ReleaseIdempotency(ctx, s.cache, cache.PostCreateIdempotentKey(userID, req.ClientRequestID))
|
||||||
|
}
|
||||||
return nil, err
|
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)
|
go s.reviewVotePostAsync(post.ID, userID, req.Title, content, req.Images)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user