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

289 lines
9.0 KiB
Go

package redemption
import (
"context"
"errors"
"fmt"
"github.com/perfect-panel/server/pkg/cache"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
var _ RedemptionCodeModel = (*customRedemptionCodeModel)(nil)
var _ RedemptionRecordModel = (*customRedemptionRecordModel)(nil)
var (
cacheRedemptionCodeIdPrefix = "cache:redemption_code:id:"
cacheRedemptionCodeCodePrefix = "cache:redemption_code:code:"
cacheRedemptionRecordIdPrefix = "cache:redemption_record:id:"
)
type (
RedemptionCodeModel interface {
Insert(ctx context.Context, data *RedemptionCode) error
FindOne(ctx context.Context, id int64) (*RedemptionCode, error)
FindOneByCode(ctx context.Context, code string) (*RedemptionCode, error)
Update(ctx context.Context, data *RedemptionCode, tx ...*gorm.DB) error
Delete(ctx context.Context, id int64) error
Transaction(ctx context.Context, fn func(db *gorm.DB) error) error
customRedemptionCodeLogicModel
}
RedemptionRecordModel interface {
Insert(ctx context.Context, data *RedemptionRecord, tx ...*gorm.DB) error
FindOne(ctx context.Context, id int64) (*RedemptionRecord, error)
Update(ctx context.Context, data *RedemptionRecord) error
Delete(ctx context.Context, id int64) error
customRedemptionRecordLogicModel
}
customRedemptionCodeLogicModel interface {
QueryRedemptionCodeListByPage(ctx context.Context, page, size int, subscribePlan int64, unitTime string, code string) (total int64, list []*RedemptionCode, err error)
BatchDelete(ctx context.Context, ids []int64) error
IncrementUsedCount(ctx context.Context, id int64, tx ...*gorm.DB) error
}
customRedemptionRecordLogicModel interface {
QueryRedemptionRecordListByPage(ctx context.Context, page, size int, userId int64, codeId int64) (total int64, list []*RedemptionRecord, err error)
FindByUserId(ctx context.Context, userId int64) ([]*RedemptionRecord, error)
FindByCodeId(ctx context.Context, codeId int64) ([]*RedemptionRecord, error)
}
customRedemptionCodeModel struct {
*defaultRedemptionCodeModel
}
defaultRedemptionCodeModel struct {
cache.CachedConn
table string
}
customRedemptionRecordModel struct {
*defaultRedemptionRecordModel
}
defaultRedemptionRecordModel struct {
cache.CachedConn
table string
}
)
func newRedemptionCodeModel(db *gorm.DB, c *redis.Client) *defaultRedemptionCodeModel {
return &defaultRedemptionCodeModel{
CachedConn: cache.NewConn(db, c),
table: "`redemption_code`",
}
}
func newRedemptionRecordModel(db *gorm.DB, c *redis.Client) *defaultRedemptionRecordModel {
return &defaultRedemptionRecordModel{
CachedConn: cache.NewConn(db, c),
table: "`redemption_record`",
}
}
// RedemptionCode cache methods
func (m *defaultRedemptionCodeModel) getCacheKeys(data *RedemptionCode) []string {
if data == nil {
return []string{}
}
codeIdKey := fmt.Sprintf("%s%v", cacheRedemptionCodeIdPrefix, data.Id)
codeCodeKey := fmt.Sprintf("%s%v", cacheRedemptionCodeCodePrefix, data.Code)
cacheKeys := []string{
codeIdKey,
codeCodeKey,
}
return cacheKeys
}
func (m *defaultRedemptionCodeModel) Insert(ctx context.Context, data *RedemptionCode) error {
err := m.ExecCtx(ctx, func(conn *gorm.DB) error {
return conn.Create(data).Error
}, m.getCacheKeys(data)...)
return err
}
func (m *defaultRedemptionCodeModel) FindOne(ctx context.Context, id int64) (*RedemptionCode, error) {
codeIdKey := fmt.Sprintf("%s%v", cacheRedemptionCodeIdPrefix, id)
var resp RedemptionCode
err := m.QueryCtx(ctx, &resp, codeIdKey, func(conn *gorm.DB, v interface{}) error {
return conn.Model(&RedemptionCode{}).Where("`id` = ?", id).First(&resp).Error
})
switch {
case err == nil:
return &resp, nil
default:
return nil, err
}
}
func (m *defaultRedemptionCodeModel) FindOneByCode(ctx context.Context, code string) (*RedemptionCode, error) {
codeCodeKey := fmt.Sprintf("%s%v", cacheRedemptionCodeCodePrefix, code)
var resp RedemptionCode
err := m.QueryCtx(ctx, &resp, codeCodeKey, func(conn *gorm.DB, v interface{}) error {
return conn.Model(&RedemptionCode{}).Where("`code` = ?", code).First(&resp).Error
})
switch {
case err == nil:
return &resp, nil
default:
return nil, err
}
}
func (m *defaultRedemptionCodeModel) Update(ctx context.Context, data *RedemptionCode, tx ...*gorm.DB) error {
old, err := m.FindOne(ctx, data.Id)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
err = m.ExecCtx(ctx, func(conn *gorm.DB) error {
db := conn
if len(tx) > 0 {
db = tx[0]
}
return db.Save(data).Error
}, m.getCacheKeys(old)...)
return err
}
func (m *defaultRedemptionCodeModel) Delete(ctx context.Context, id int64) error {
data, err := m.FindOne(ctx, id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
err = m.ExecCtx(ctx, func(conn *gorm.DB) error {
db := conn
return db.Delete(&RedemptionCode{}, id).Error
}, m.getCacheKeys(data)...)
return err
}
func (m *defaultRedemptionCodeModel) Transaction(ctx context.Context, fn func(db *gorm.DB) error) error {
return m.TransactCtx(ctx, fn)
}
// RedemptionCode custom logic methods
func (m *customRedemptionCodeModel) QueryRedemptionCodeListByPage(ctx context.Context, page, size int, subscribePlan int64, unitTime string, code string) (total int64, list []*RedemptionCode, err error) {
err = m.QueryNoCacheCtx(ctx, &list, func(conn *gorm.DB, v interface{}) error {
db := conn.Model(&RedemptionCode{})
if subscribePlan != 0 {
db = db.Where("subscribe_plan = ?", subscribePlan)
}
if unitTime != "" {
db = db.Where("unit_time = ?", unitTime)
}
if code != "" {
db = db.Where("code like ?", "%"+code+"%")
}
return db.Count(&total).Limit(size).Offset((page - 1) * size).Order("created_at DESC").Find(v).Error
})
return total, list, err
}
func (m *customRedemptionCodeModel) BatchDelete(ctx context.Context, ids []int64) error {
var err error
for _, id := range ids {
if err = m.Delete(ctx, id); err != nil {
return err
}
}
return nil
}
func (m *customRedemptionCodeModel) IncrementUsedCount(ctx context.Context, id int64, tx ...*gorm.DB) error {
data, err := m.FindOne(ctx, id)
if err != nil {
return err
}
data.UsedCount++
if len(tx) > 0 {
return m.Update(ctx, data, tx[0])
}
return m.Update(ctx, data)
}
// RedemptionRecord cache methods
func (m *defaultRedemptionRecordModel) getCacheKeys(data *RedemptionRecord) []string {
if data == nil {
return []string{}
}
recordIdKey := fmt.Sprintf("%s%v", cacheRedemptionRecordIdPrefix, data.Id)
cacheKeys := []string{
recordIdKey,
}
return cacheKeys
}
func (m *defaultRedemptionRecordModel) Insert(ctx context.Context, data *RedemptionRecord, tx ...*gorm.DB) error {
err := m.ExecCtx(ctx, func(conn *gorm.DB) error {
if len(tx) > 0 {
conn = tx[0]
}
return conn.Create(data).Error
}, m.getCacheKeys(data)...)
return err
}
func (m *defaultRedemptionRecordModel) FindOne(ctx context.Context, id int64) (*RedemptionRecord, error) {
recordIdKey := fmt.Sprintf("%s%v", cacheRedemptionRecordIdPrefix, id)
var resp RedemptionRecord
err := m.QueryCtx(ctx, &resp, recordIdKey, func(conn *gorm.DB, v interface{}) error {
return conn.Model(&RedemptionRecord{}).Where("`id` = ?", id).First(&resp).Error
})
switch {
case err == nil:
return &resp, nil
default:
return nil, err
}
}
func (m *defaultRedemptionRecordModel) Update(ctx context.Context, data *RedemptionRecord) error {
err := m.ExecCtx(ctx, func(conn *gorm.DB) error {
db := conn
return db.Save(data).Error
}, m.getCacheKeys(data)...)
return err
}
func (m *defaultRedemptionRecordModel) Delete(ctx context.Context, id int64) error {
err := m.ExecCtx(ctx, func(conn *gorm.DB) error {
db := conn
return db.Delete(&RedemptionRecord{}, id).Error
}, m.getCacheKeys(nil)...)
return err
}
// RedemptionRecord custom logic methods
func (m *customRedemptionRecordModel) QueryRedemptionRecordListByPage(ctx context.Context, page, size int, userId int64, codeId int64) (total int64, list []*RedemptionRecord, err error) {
err = m.QueryNoCacheCtx(ctx, &list, func(conn *gorm.DB, v interface{}) error {
db := conn.Model(&RedemptionRecord{})
if userId != 0 {
db = db.Where("user_id = ?", userId)
}
if codeId != 0 {
db = db.Where("redemption_code_id = ?", codeId)
}
return db.Count(&total).Limit(size).Offset((page - 1) * size).Order("created_at DESC").Find(v).Error
})
return total, list, err
}
func (m *customRedemptionRecordModel) FindByUserId(ctx context.Context, userId int64) ([]*RedemptionRecord, error) {
var list []*RedemptionRecord
err := m.QueryNoCacheCtx(ctx, &list, func(conn *gorm.DB, v interface{}) error {
return conn.Model(&RedemptionRecord{}).Where("user_id = ?", userId).Order("created_at DESC").Find(v).Error
})
return list, err
}
func (m *customRedemptionRecordModel) FindByCodeId(ctx context.Context, codeId int64) ([]*RedemptionRecord, error) {
var list []*RedemptionRecord
err := m.QueryNoCacheCtx(ctx, &list, func(conn *gorm.DB, v interface{}) error {
return conn.Model(&RedemptionRecord{}).Where("redemption_code_id = ?", codeId).Order("created_at DESC").Find(v).Error
})
return list, err
}