EUForest 34372fe0b3 fix(redemption): enhance redemption code flow with transaction safety and idempotency
This commit addresses critical issues in the redemption code activation flow
to ensure data consistency, prevent duplicate redemptions, and improve user
experience.

Key improvements:

1. Transaction Safety (P0)
   - Wrap subscription creation, used count update, and record insertion in
     a single database transaction
   - Ensure atomicity: all operations succeed or all rollback
   - Prevent orphaned records and data inconsistencies

2. Idempotency Protection (P0)
   - Add redemption record check before processing to prevent duplicate
     operations on queue task retries
   - Maintain idempotency at multiple layers: interface, order, and record

3. Distributed Lock (P1)
   - Implement Redis-based distributed lock (10s timeout) to prevent
     concurrent duplicate redemptions
   - Lock key format: redemption_lock:{user_id}:{code}

4. IsNew Field Correction (P2)
   - Fix IsNew field to correctly determine first-time purchases using
     IsUserEligibleForNewOrder method
   - Ensure accurate statistics and future commission calculations

5. Quota Pre-check (P2)
   - Add quota validation at interface layer for immediate user feedback
   - Prevent "processing" status followed by eventual failure

6. Extended Cache TTL (P2)
   - Increase Redis cache expiration from 30 minutes to 2 hours
   - Ensure queue tasks can retrieve redemption data even with delays

7. Error Handling (P2)
   - Clean up Order records when Redis cache or queue enqueue fails
   - Prevent orphaned Order records in the database

8. Cache Clearing Optimization
   - Add user subscription cache clearing after activation
   - Ensure both node-side and user-side display latest subscription info

Technical details:
- Modified: internal/logic/public/redemption/redeemCodeLogic.go
- Modified: queue/logic/order/activateOrderLogic.go
- Modified: internal/model/redemption/default.go (transaction support)

Testing:
- All changes compiled successfully
- Comprehensive flow verification completed
- Ready for production deployment

BREAKING CHANGE: None
2026-02-09 01:07:39 +08:00

224 lines
8.4 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 redemption
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/hibiken/asynq"
"github.com/perfect-panel/server/internal/model/order"
"github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/pkg/constant"
"github.com/perfect-panel/server/pkg/tool"
"github.com/perfect-panel/server/pkg/xerr"
queue "github.com/perfect-panel/server/queue/types"
"github.com/pkg/errors"
"gorm.io/gorm"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger"
)
type RedeemCodeLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// Redeem code
func NewRedeemCodeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RedeemCodeLogic {
return &RedeemCodeLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *RedeemCodeLogic) RedeemCode(req *types.RedeemCodeRequest) (resp *types.RedeemCodeResponse, err error) {
// Get user from context
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
if !ok {
logger.Error("current user is not found in context")
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
}
// 使用Redis分布式锁防止并发重复兑换
lockKey := fmt.Sprintf("redemption_lock:%d:%s", u.Id, req.Code)
lockSuccess, err := l.svcCtx.Redis.SetNX(l.ctx, lockKey, "1", 10*time.Second).Result()
if err != nil {
l.Errorw("[RedeemCode] Acquire lock failed", logger.Field("error", err.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "system busy, please try again later")
}
if !lockSuccess {
l.Errorw("[RedeemCode] Redemption in progress",
logger.Field("user_id", u.Id),
logger.Field("code", req.Code))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "redemption in progress, please wait")
}
defer l.svcCtx.Redis.Del(l.ctx, lockKey)
// Find redemption code by code
redemptionCode, err := l.svcCtx.RedemptionCodeModel.FindOneByCode(l.ctx, req.Code)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
l.Errorw("[RedeemCode] Redemption code not found", logger.Field("code", req.Code))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "redemption code not found")
}
l.Errorw("[RedeemCode] Database Error", logger.Field("error", err.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find redemption code error: %v", err.Error())
}
// Check if redemption code is enabled
if redemptionCode.Status != 1 {
l.Errorw("[RedeemCode] Redemption code is disabled",
logger.Field("code", req.Code),
logger.Field("status", redemptionCode.Status))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "redemption code is disabled")
}
// Check if redemption code has remaining count
if redemptionCode.TotalCount > 0 && redemptionCode.UsedCount >= redemptionCode.TotalCount {
l.Errorw("[RedeemCode] Redemption code has been fully used",
logger.Field("code", req.Code),
logger.Field("total_count", redemptionCode.TotalCount),
logger.Field("used_count", redemptionCode.UsedCount))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "redemption code has been fully used")
}
// Check if user has already redeemed this code
userRecords, err := l.svcCtx.RedemptionRecordModel.FindByUserId(l.ctx, u.Id)
if err != nil {
l.Errorw("[RedeemCode] Database Error", logger.Field("error", err.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find redemption records error: %v", err.Error())
}
for _, record := range userRecords {
if record.RedemptionCodeId == redemptionCode.Id {
l.Errorw("[RedeemCode] User has already redeemed this code",
logger.Field("user_id", u.Id),
logger.Field("code", req.Code))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "you have already redeemed this code")
}
}
// Find subscribe plan from redemption code
subscribePlan, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, redemptionCode.SubscribePlan)
if err != nil {
l.Errorw("[RedeemCode] Subscribe plan not found",
logger.Field("subscribe_plan", redemptionCode.SubscribePlan),
logger.Field("error", err.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "subscribe plan not found")
}
// Check if subscribe plan is available
if !*subscribePlan.Sell {
l.Errorw("[RedeemCode] Subscribe plan is not available",
logger.Field("subscribe_plan", redemptionCode.SubscribePlan))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.SubscribeNotAvailable), "subscribe plan is not available")
}
// 检查配额限制(预检查,队列任务中会再次检查)
if subscribePlan.Quota > 0 {
var count int64
err = l.svcCtx.DB.Model(&user.Subscribe{}).
Where("user_id = ? AND subscribe_id = ?", u.Id, redemptionCode.SubscribePlan).
Count(&count).Error
if err != nil {
l.Errorw("[RedeemCode] Check quota failed", logger.Field("error", err.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "check quota failed")
}
if count >= subscribePlan.Quota {
l.Errorw("[RedeemCode] Subscribe quota limit exceeded",
logger.Field("user_id", u.Id),
logger.Field("subscribe_id", redemptionCode.SubscribePlan),
logger.Field("quota", subscribePlan.Quota),
logger.Field("current_count", count))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.SubscribeQuotaLimit), "subscribe quota limit exceeded")
}
}
// 判断是否首次购买
isNew, err := l.svcCtx.OrderModel.IsUserEligibleForNewOrder(l.ctx, u.Id)
if err != nil {
l.Errorw("[RedeemCode] Check user order failed", logger.Field("error", err.Error()))
// 可以继续默认为false
isNew = false
}
// 创建Order记录
orderInfo := &order.Order{
UserId: u.Id,
OrderNo: tool.GenerateTradeNo(),
Type: 5, // 兑换类型
Quantity: redemptionCode.Quantity,
Price: 0, // 兑换无价格
Amount: 0, // 兑换无金额
Discount: 0,
GiftAmount: 0,
Coupon: "",
CouponDiscount: 0,
PaymentId: 0,
Method: "redemption",
FeeAmount: 0,
Commission: 0,
Status: 2, // 直接设置为已支付
SubscribeId: redemptionCode.SubscribePlan,
IsNew: isNew,
}
// 保存Order到数据库
err = l.svcCtx.OrderModel.Insert(l.ctx, orderInfo)
if err != nil {
l.Errorw("[RedeemCode] Create order failed", logger.Field("error", err.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "create order failed")
}
// 缓存兑换码信息到Redis供队列任务使用
cacheKey := fmt.Sprintf("redemption_order:%s", orderInfo.OrderNo)
cacheData := map[string]interface{}{
"redemption_code_id": redemptionCode.Id,
"unit_time": redemptionCode.UnitTime,
"quantity": redemptionCode.Quantity,
}
jsonData, _ := json.Marshal(cacheData)
err = l.svcCtx.Redis.Set(l.ctx, cacheKey, jsonData, 2*time.Hour).Err()
if err != nil {
l.Errorw("[RedeemCode] Cache redemption data failed", logger.Field("error", err.Error()))
// 缓存失败删除已创建的Order避免孤儿记录
if delErr := l.svcCtx.OrderModel.Delete(l.ctx, orderInfo.Id); delErr != nil {
l.Errorw("[RedeemCode] Delete order failed after cache error",
logger.Field("order_id", orderInfo.Id),
logger.Field("error", delErr.Error()))
}
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "cache redemption data failed")
}
// 触发队列任务
payload := queue.ForthwithActivateOrderPayload{
OrderNo: orderInfo.OrderNo,
}
bytes, _ := json.Marshal(&payload)
task := asynq.NewTask(queue.ForthwithActivateOrder, bytes, asynq.MaxRetry(5))
_, err = l.svcCtx.Queue.EnqueueContext(l.ctx, task)
if err != nil {
l.Errorw("[RedeemCode] Enqueue task failed", logger.Field("error", err.Error()))
// 入队失败删除Order和Redis缓存
l.svcCtx.Redis.Del(l.ctx, cacheKey)
if delErr := l.svcCtx.OrderModel.Delete(l.ctx, orderInfo.Id); delErr != nil {
l.Errorw("[RedeemCode] Delete order failed after enqueue error",
logger.Field("order_id", orderInfo.Id),
logger.Field("error", delErr.Error()))
}
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "enqueue task failed")
}
l.Infow("[RedeemCode] Redemption order created successfully",
logger.Field("order_no", orderInfo.OrderNo),
logger.Field("user_id", u.Id),
)
return &types.RedeemCodeResponse{
Message: "Redemption successful, processing...",
}, nil
}