fix(payment): isolate fulfillment from redeem limits

Payment and admin fulfillment shared the public Redeem entry point, so
fulfillment retries were blocked by, and contributed to, the user's
public redeem failure counter, and lookup errors were silently treated as
"code does not exist" on a partial re-run.

- resolveRedeemAction now distinguishes a genuine not-found lookup from
  other lookup errors (which now fail the fulfillment instead of being
  swallowed and re-created).
- Add validatePaymentRedeemCode: before reusing an existing code during
  fulfillment, verify it belongs to the order (code id, balance type,
  amount, user, status), so a stale or foreign code can never be
  credited against the wrong order/user.
- Split Redeem into a policy-based core: public redemption enforces the
  failure counter; payment fulfillment (redeemForPaymentFulfillment) and
  admin fulfillment (RedeemForAdminFulfillment) bypass it, while all code
  validation and transactional updates remain identical. Admin
  fulfillment keeps the normal redeem-level affiliate rebate.
- The admin CreateAndRedeem endpoint now uses RedeemForAdminFulfillment.

Tests: updated resolveRedeemAction cases, new TestValidatePaymentRedeemCode,
code-used-by-another-user rejection, payment/admin rate-limit bypass
coverage, and public-path regression coverage.
This commit is contained in:
TT
2026-09-05 23:56:59 +08:00
parent ab99d56e96
commit 7a70de401c
5 changed files with 393 additions and 29 deletions
@@ -228,7 +228,7 @@ func (h *RedeemHandler) CreateAndRedeem(c *gin.Context) {
return nil, createErr
}
redeemed, redeemErr := h.redeemService.Redeem(ctx, req.UserID, req.Code)
redeemed, redeemErr := h.redeemService.RedeemForAdminFulfillment(ctx, req.UserID, req.Code)
if redeemErr != nil {
return nil, redeemErr
}
@@ -246,7 +246,7 @@ func (h *RedeemHandler) resolveCreateAndRedeemExisting(ctx context.Context, exis
return nil, service.ErrRedeemCodeExpired
}
if existing.CanUse() {
redeemed, err := h.redeemService.Redeem(ctx, userID, existing.Code)
redeemed, err := h.redeemService.RedeemForAdminFulfillment(ctx, userID, existing.Code)
if err == nil {
return gin.H{"redeem_code": dto.RedeemCodeFromServiceAdmin(redeemed)}, nil
}
@@ -317,20 +317,62 @@ const (
// resolveRedeemAction decides the idempotency action based on an existing redeem code lookup.
// existing is the result of GetByCode; lookupErr is the error from that call.
func resolveRedeemAction(existing *RedeemCode, lookupErr error) redeemAction {
if existing == nil || lookupErr != nil {
return redeemActionCreate
func resolveRedeemAction(existing *RedeemCode, lookupErr error) (redeemAction, error) {
if lookupErr != nil {
if errors.Is(lookupErr, ErrRedeemCodeNotFound) {
return redeemActionCreate, nil
}
return redeemActionCreate, fmt.Errorf("lookup payment redeem code: %w", lookupErr)
}
if existing == nil {
return redeemActionCreate, nil
}
if existing.IsUsed() {
return redeemActionSkipCompleted
return redeemActionSkipCompleted, nil
}
return redeemActionRedeem
return redeemActionRedeem, nil
}
func validatePaymentRedeemCode(o *dbent.PaymentOrder, code *RedeemCode) error {
if o == nil || code == nil {
return errors.New("payment redeem code validation requires an order and code")
}
if code.Code != o.RechargeCode {
return fmt.Errorf("payment redeem code mismatch for order %d", o.ID)
}
if code.Type != RedeemTypeBalance {
return fmt.Errorf("payment redeem code type mismatch for order %d: got %s", o.ID, code.Type)
}
if math.IsNaN(code.Value) || math.IsInf(code.Value, 0) || math.Abs(code.Value-o.Amount) > 1e-8 {
return fmt.Errorf("payment redeem code amount mismatch for order %d: expected %.8f, got %.8f", o.ID, o.Amount, code.Value)
}
switch code.Status {
case StatusUnused:
if code.UsedBy != nil {
return fmt.Errorf("unused payment redeem code has a user for order %d", o.ID)
}
case StatusUsed:
if code.UsedBy == nil || *code.UsedBy != o.UserID {
return fmt.Errorf("payment redeem code user mismatch for order %d", o.ID)
}
default:
return fmt.Errorf("payment redeem code has invalid status for order %d: %s", o.ID, code.Status)
}
return nil
}
func (s *PaymentService) doBalance(ctx context.Context, o *dbent.PaymentOrder, lease *paymentFulfillmentLease) error {
// Idempotency: check if redeem code already exists (from a previous partial run)
existing, lookupErr := s.redeemService.GetByCode(ctx, o.RechargeCode)
action := resolveRedeemAction(existing, lookupErr)
action, err := resolveRedeemAction(existing, lookupErr)
if err != nil {
return err
}
if existing != nil {
if err := validatePaymentRedeemCode(o, existing); err != nil {
return err
}
}
switch action {
case redeemActionSkipCompleted:
@@ -347,7 +389,7 @@ func (s *PaymentService) doBalance(ctx context.Context, o *dbent.PaymentOrder, l
case redeemActionRedeem:
// Code exists but unused — skip creation, proceed to redeem
}
if _, err := s.redeemService.Redeem(ContextSkipRedeemAffiliate(ctx), o.UserID, o.RechargeCode); err != nil {
if _, err := s.redeemService.redeemForPaymentFulfillment(ctx, o.UserID, o.RechargeCode); err != nil {
return fmt.Errorf("redeem balance: %w", err)
}
if err := s.applyAffiliateRebateForOrder(ctx, o); err != nil {
@@ -23,6 +23,52 @@ type paymentFulfillmentTestProvider struct {
supportedTypes []payment.PaymentType
}
type paymentFulfillmentRedeemCacheStub struct {
count int
getCalls int
incrementCalls int
acquireCalls int
releaseCalls int
}
type paymentFulfillmentRedeemRepo struct {
paymentOrderLifecycleRedeemRepo
createCalls int
}
func (r *paymentFulfillmentRedeemRepo) Create(_ context.Context, code *RedeemCode) error {
r.createCalls++
if r.codesByCode == nil {
r.codesByCode = make(map[string]*RedeemCode)
}
cloned := *code
cloned.ID = int64(100 + r.createCalls)
code.ID = cloned.ID
r.codesByCode[cloned.Code] = &cloned
return nil
}
func (c *paymentFulfillmentRedeemCacheStub) GetRedeemAttemptCount(context.Context, int64) (int, error) {
c.getCalls++
return c.count, nil
}
func (c *paymentFulfillmentRedeemCacheStub) IncrementRedeemAttemptCount(context.Context, int64) error {
c.incrementCalls++
c.count++
return nil
}
func (c *paymentFulfillmentRedeemCacheStub) AcquireRedeemLock(context.Context, string, time.Duration) (bool, error) {
c.acquireCalls++
return true, nil
}
func (c *paymentFulfillmentRedeemCacheStub) ReleaseRedeemLock(context.Context, string) error {
c.releaseCalls++
return nil
}
func (p paymentFulfillmentTestProvider) Name() string { return p.key }
func (p paymentFulfillmentTestProvider) ProviderKey() string { return p.key }
func (p paymentFulfillmentTestProvider) SupportedTypes() []payment.PaymentType {
@@ -210,14 +256,15 @@ func ensurePaymentAuditOrderActionUniqueIndex(t *testing.T, ctx context.Context,
func TestResolveRedeemAction_CodeNotFound(t *testing.T) {
t.Parallel()
action := resolveRedeemAction(nil, nil)
assert.Equal(t, redeemActionCreate, action, "nil code with nil error should create")
action, err := resolveRedeemAction(nil, ErrRedeemCodeNotFound)
require.NoError(t, err)
assert.Equal(t, redeemActionCreate, action, "a missing code should be created")
}
func TestResolveRedeemAction_LookupError(t *testing.T) {
t.Parallel()
action := resolveRedeemAction(nil, errors.New("db connection lost"))
assert.Equal(t, redeemActionCreate, action, "lookup error should fall back to create")
_, err := resolveRedeemAction(nil, errors.New("db connection lost"))
require.ErrorContains(t, err, "lookup payment redeem code")
}
func TestResolveRedeemAction_LookupErrorWithNonNilCode(t *testing.T) {
@@ -225,8 +272,8 @@ func TestResolveRedeemAction_LookupErrorWithNonNilCode(t *testing.T) {
// Edge case: both code and error are non-nil (shouldn't happen in practice,
// but the function should still treat error as authoritative)
code := &RedeemCode{Status: StatusUnused}
action := resolveRedeemAction(code, errors.New("partial error"))
assert.Equal(t, redeemActionCreate, action, "non-nil error should always result in create regardless of code")
_, err := resolveRedeemAction(code, errors.New("partial error"))
require.ErrorContains(t, err, "lookup payment redeem code")
}
func TestResolveRedeemAction_CodeExistsAndUsed(t *testing.T) {
@@ -237,7 +284,8 @@ func TestResolveRedeemAction_CodeExistsAndUsed(t *testing.T) {
Type: RedeemTypeBalance,
Value: 10.0,
}
action := resolveRedeemAction(code, nil)
action, err := resolveRedeemAction(code, nil)
require.NoError(t, err)
assert.Equal(t, redeemActionSkipCompleted, action, "used code should skip to completed")
}
@@ -249,7 +297,8 @@ func TestResolveRedeemAction_CodeExistsAndUnused(t *testing.T) {
Type: RedeemTypeBalance,
Value: 25.0,
}
action := resolveRedeemAction(code, nil)
action, err := resolveRedeemAction(code, nil)
require.NoError(t, err)
assert.Equal(t, redeemActionRedeem, action, "unused code should skip creation and proceed to redeem")
}
@@ -261,7 +310,8 @@ func TestResolveRedeemAction_CodeExistsWithExpiredStatus(t *testing.T) {
Code: "expired-code",
Status: StatusExpired,
}
action := resolveRedeemAction(code, nil)
action, err := resolveRedeemAction(code, nil)
require.NoError(t, err)
assert.Equal(t, redeemActionRedeem, action, "expired-status code is not IsUsed(), should redeem")
}
@@ -277,6 +327,7 @@ func TestResolveRedeemAction_Table(t *testing.T) {
code *RedeemCode
err error
expected redeemAction
wantErr bool
}{
{
name: "nil code, nil error — first run",
@@ -291,10 +342,11 @@ func TestResolveRedeemAction_Table(t *testing.T) {
expected: redeemActionCreate,
},
{
name: "nil code, generic DB error — treat as not found",
name: "nil code, generic DB error — fail closed",
code: nil,
err: errors.New("connection refused"),
expected: redeemActionCreate,
wantErr: true,
},
{
name: "code exists, used — previous run completed redeem",
@@ -309,17 +361,23 @@ func TestResolveRedeemAction_Table(t *testing.T) {
expected: redeemActionRedeem,
},
{
name: "code exists but error also set — error takes precedence",
name: "code exists but error also set — fail closed",
code: &RedeemCode{Status: StatusUsed},
err: errors.New("unexpected"),
expected: redeemActionCreate,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := resolveRedeemAction(tt.code, tt.err)
got, err := resolveRedeemAction(tt.code, tt.err)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.expected, got)
})
}
@@ -350,11 +408,48 @@ func TestResolveRedeemAction_IsUsedCanUseConsistency(t *testing.T) {
// Verify our decision function is consistent with the domain model methods
assert.True(t, usedCode.IsUsed())
assert.False(t, usedCode.CanUse())
assert.Equal(t, redeemActionSkipCompleted, resolveRedeemAction(usedCode, nil))
usedAction, err := resolveRedeemAction(usedCode, nil)
require.NoError(t, err)
assert.Equal(t, redeemActionSkipCompleted, usedAction)
assert.False(t, unusedCode.IsUsed())
assert.True(t, unusedCode.CanUse())
assert.Equal(t, redeemActionRedeem, resolveRedeemAction(unusedCode, nil))
unusedAction, err := resolveRedeemAction(unusedCode, nil)
require.NoError(t, err)
assert.Equal(t, redeemActionRedeem, unusedAction)
}
func TestValidatePaymentRedeemCode(t *testing.T) {
t.Parallel()
userID := int64(42)
otherUserID := int64(43)
order := &dbent.PaymentOrder{ID: 7, UserID: userID, RechargeCode: "PAY-7-12345", Amount: 80}
tests := []struct {
name string
code *RedeemCode
wantErr string
}{
{name: "unused code", code: &RedeemCode{Code: order.RechargeCode, Type: RedeemTypeBalance, Value: 80, Status: StatusUnused}},
{name: "used by order user", code: &RedeemCode{Code: order.RechargeCode, Type: RedeemTypeBalance, Value: 80, Status: StatusUsed, UsedBy: &userID}},
{name: "used without user", code: &RedeemCode{Code: order.RechargeCode, Type: RedeemTypeBalance, Value: 80, Status: StatusUsed}, wantErr: "user mismatch"},
{name: "used by another user", code: &RedeemCode{Code: order.RechargeCode, Type: RedeemTypeBalance, Value: 80, Status: StatusUsed, UsedBy: &otherUserID}, wantErr: "user mismatch"},
{name: "wrong code", code: &RedeemCode{Code: "OTHER", Type: RedeemTypeBalance, Value: 80, Status: StatusUnused}, wantErr: "code mismatch"},
{name: "wrong type", code: &RedeemCode{Code: order.RechargeCode, Type: RedeemTypeConcurrency, Value: 80, Status: StatusUnused}, wantErr: "type mismatch"},
{name: "wrong amount", code: &RedeemCode{Code: order.RechargeCode, Type: RedeemTypeBalance, Value: 79, Status: StatusUnused}, wantErr: "amount mismatch"},
{name: "invalid status", code: &RedeemCode{Code: order.RechargeCode, Type: RedeemTypeBalance, Value: 80, Status: StatusDisabled}, wantErr: "invalid status"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validatePaymentRedeemCode(order, tt.code)
if tt.wantErr == "" {
require.NoError(t, err)
return
}
require.ErrorContains(t, err, tt.wantErr)
})
}
}
func TestExpectedNotificationProviderKeyPrefersOrderInstanceProvider(t *testing.T) {
@@ -668,6 +763,100 @@ func TestFulfillmentLeaseVersionRejectsStaleWorker(t *testing.T) {
require.NoError(t, svc.markCompleted(ctx, order, secondLease, "SUBSCRIPTION_SUCCESS"))
}
func TestPublicRedeemStillEnforcesFailureLimit(t *testing.T) {
cache := &paymentFulfillmentRedeemCacheStub{count: redeemMaxErrorsPerHour}
svc := &RedeemService{cache: cache}
result, err := svc.Redeem(context.Background(), 42, "PUBLIC-CODE")
require.Nil(t, result)
require.ErrorIs(t, err, ErrRedeemRateLimited)
require.Equal(t, 1, cache.getCalls)
require.Zero(t, cache.incrementCalls)
require.Zero(t, cache.acquireCalls)
}
func TestPublicRedeemStillIncrementsInvalidCodeFailures(t *testing.T) {
cache := &paymentFulfillmentRedeemCacheStub{}
redeemRepo := &redeemRejectRepo{code: RedeemCode{Code: "OTHER"}}
svc := &RedeemService{redeemRepo: redeemRepo, cache: cache}
result, err := svc.Redeem(context.Background(), 42, "MISSING")
require.Nil(t, result)
require.ErrorIs(t, err, ErrRedeemCodeNotFound)
require.Equal(t, 1, cache.getCalls)
require.Equal(t, 1, cache.incrementCalls)
require.Equal(t, 1, cache.count)
require.Equal(t, 1, cache.acquireCalls)
require.Equal(t, 1, cache.releaseCalls)
}
func TestPaymentRedeemDoesNotIncrementFailureLimit(t *testing.T) {
ctx := context.Background()
cache := &paymentFulfillmentRedeemCacheStub{count: redeemMaxErrorsPerHour}
redeemRepo := &redeemRejectRepo{code: RedeemCode{
ID: 1,
Code: "PAY-EXPIRED",
Type: RedeemTypeBalance,
Value: 10,
Status: StatusExpired,
}}
svc := &RedeemService{redeemRepo: redeemRepo, cache: cache}
result, err := svc.redeemForPaymentFulfillment(ctx, 42, redeemRepo.code.Code)
require.Nil(t, result)
require.ErrorIs(t, err, ErrRedeemCodeExpired)
require.Zero(t, cache.getCalls)
require.Zero(t, cache.incrementCalls)
require.Equal(t, 1, cache.acquireCalls)
require.Equal(t, 1, cache.releaseCalls)
}
func TestExecuteBalanceFulfillmentBypassesUserRedeemRateLimit(t *testing.T) {
ctx := context.Background()
client := newPaymentConfigServiceTestClient(t)
ensurePaymentAuditOrderActionUniqueIndex(t, ctx, client)
order := createPaymentFulfillmentSubscriptionOrder(t, ctx, client, OrderStatusPaid, time.Now())
order, err := client.PaymentOrder.UpdateOneID(order.ID).
SetOrderType(payment.OrderTypeBalance).
ClearPlanID().
ClearSubscriptionGroupID().
ClearSubscriptionDays().
Save(ctx)
require.NoError(t, err)
redeemRepo := &paymentFulfillmentRedeemRepo{}
credited := 0.0
userRepo := &mockUserRepo{getByIDUser: &User{ID: order.UserID, Balance: 0}}
userRepo.updateBalanceFn = func(_ context.Context, id int64, amount float64) error {
require.Equal(t, order.UserID, id)
credited += amount
return nil
}
cache := &paymentFulfillmentRedeemCacheStub{count: redeemMaxErrorsPerHour}
redeemService := NewRedeemService(redeemRepo, userRepo, nil, cache, nil, client, nil, nil)
svc := &PaymentService{entClient: client, redeemService: redeemService, userRepo: userRepo}
require.NoError(t, svc.ExecuteBalanceFulfillment(ctx, order.ID))
require.InDelta(t, order.Amount, credited, 1e-8)
require.Zero(t, cache.getCalls, "trusted payment fulfillment must not read the public failure counter")
require.Zero(t, cache.incrementCalls, "trusted payment fulfillment must not mutate the public failure counter")
require.Equal(t, 1, cache.acquireCalls)
require.Equal(t, 1, cache.releaseCalls)
require.Equal(t, 1, redeemRepo.createCalls)
require.Len(t, redeemRepo.useCalls, 1)
usedCode := redeemRepo.codesByCode[order.RechargeCode]
require.Equal(t, StatusUsed, usedCode.Status)
require.NotNil(t, usedCode.UsedBy)
require.Equal(t, order.UserID, *usedCode.UsedBy)
reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
require.NoError(t, err)
require.Equal(t, OrderStatusCompleted, reloaded.Status)
}
func TestExecuteBalanceFulfillmentRecoversAfterRedeemWithoutCreditingAgain(t *testing.T) {
ctx := context.Background()
client := newPaymentConfigServiceTestClient(t)
@@ -683,6 +872,7 @@ func TestExecuteBalanceFulfillmentRecoversAfterRedeemWithoutCreditingAgain(t *te
Save(ctx)
require.NoError(t, err)
usedBy := order.UserID
redeemRepo := &redeemCodeRepoStub{codesByCode: map[string]*RedeemCode{
order.RechargeCode: {
ID: 101,
@@ -690,6 +880,7 @@ func TestExecuteBalanceFulfillmentRecoversAfterRedeemWithoutCreditingAgain(t *te
Type: RedeemTypeBalance,
Value: order.Amount,
Status: StatusUsed,
UsedBy: &usedBy,
},
}}
svc := &PaymentService{
@@ -770,6 +961,44 @@ func TestPaymentNotificationRejectsAmountMismatchBeforeFulfillment(t *testing.T)
require.Equal(t, OrderStatusPending, reloaded.Status)
}
func TestExecuteBalanceFulfillmentRejectsCodeUsedByAnotherUser(t *testing.T) {
ctx := context.Background()
client := newPaymentConfigServiceTestClient(t)
ensurePaymentAuditOrderActionUniqueIndex(t, ctx, client)
order := createPaymentFulfillmentSubscriptionOrder(t, ctx, client, OrderStatusPaid, time.Now())
order, err := client.PaymentOrder.UpdateOneID(order.ID).
SetOrderType(payment.OrderTypeBalance).
ClearPlanID().
ClearSubscriptionGroupID().
ClearSubscriptionDays().
Save(ctx)
require.NoError(t, err)
otherUserID := order.UserID + 1
redeemRepo := &redeemCodeRepoStub{codesByCode: map[string]*RedeemCode{
order.RechargeCode: {
ID: 101,
Code: order.RechargeCode,
Type: RedeemTypeBalance,
Value: order.Amount,
Status: StatusUsed,
UsedBy: &otherUserID,
},
}}
svc := &PaymentService{
entClient: client,
redeemService: &RedeemService{redeemRepo: redeemRepo},
}
err = svc.ExecuteBalanceFulfillment(ctx, order.ID)
require.ErrorContains(t, err, "payment redeem code user mismatch")
require.Empty(t, redeemRepo.useCalls)
reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
require.NoError(t, err)
require.Equal(t, OrderStatusFailed, reloaded.Status)
require.Nil(t, reloaded.CompletedAt)
}
func TestExecuteSubscriptionFulfillmentRecoversCommittedAssignmentWithoutExtendingAgain(t *testing.T) {
ctx := context.Background()
client := newPaymentConfigServiceTestClient(t)
@@ -0,0 +1,59 @@
//go:build unit
package service
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestAdminFulfillmentBypassesLimitAndKeepsRedeemAffiliate(t *testing.T) {
ctx := context.Background()
client := newPaymentConfigServiceTestClient(t)
userID := int64(42)
inviterID := int64(9001)
code := &RedeemCode{
ID: 102, Code: "ADMIN-SUCCESS", Type: RedeemTypeBalance, Value: 20, Status: StatusUnused,
}
redeemRepo := &paymentFulfillmentRedeemRepo{
paymentOrderLifecycleRedeemRepo: paymentOrderLifecycleRedeemRepo{
codesByCode: map[string]*RedeemCode{code.Code: code},
},
}
userRepo := &mockUserRepo{getByIDUser: &User{ID: userID}}
userRepo.updateBalanceFn = func(context.Context, int64, float64) error { return nil }
cache := &paymentFulfillmentRedeemCacheStub{count: redeemMaxErrorsPerHour}
affiliateRepo := &paymentFulfillmentAffiliateRepoStub{
inviteeSummary: &AffiliateSummary{
UserID: userID, AffCode: "INVITEE", InviterID: &inviterID, CreatedAt: time.Now().Add(-time.Hour),
},
inviterSummary: &AffiliateSummary{
UserID: inviterID, AffCode: "INVITER", CreatedAt: time.Now().Add(-2 * time.Hour),
},
}
settingSvc := NewSettingService(&paymentFulfillmentSettingRepoStub{values: map[string]string{
SettingKeyAffiliateEnabled: "true",
SettingKeyAffiliateRebateRate: "10",
SettingKeyAffiliateRebateFreezeHours: "0",
}}, nil)
affiliateSvc := NewAffiliateService(affiliateRepo, settingSvc, nil, nil)
svc := NewRedeemService(redeemRepo, userRepo, nil, cache, nil, client, nil, affiliateSvc)
result, err := svc.RedeemForAdminFulfillment(ctx, userID, code.Code)
require.NoError(t, err)
require.NotNil(t, result)
require.Zero(t, cache.getCalls)
require.Zero(t, cache.incrementCalls)
require.Equal(t, 1, cache.acquireCalls)
require.Equal(t, 1, cache.releaseCalls)
require.Len(t, redeemRepo.useCalls, 1)
require.Len(t, affiliateRepo.accrueCalls, 1)
require.Equal(t, inviterID, affiliateRepo.accrueCalls[0].inviterID)
require.Equal(t, userID, affiliateRepo.accrueCalls[0].inviteeUserID)
require.InDelta(t, 2, affiliateRepo.accrueCalls[0].amount, 1e-8)
require.Nil(t, affiliateRepo.accrueCalls[0].sourceOrderID)
}
+40 -6
View File
@@ -30,6 +30,13 @@ const (
redeemLockDuration = 10 * time.Second // 锁超时时间,防止死锁
)
type redeemRateLimitPolicy uint8
const (
enforceRedeemRateLimit redeemRateLimitPolicy = iota
bypassRedeemRateLimit
)
type ctxKeySkipRedeemAffiliate struct{}
// ContextSkipRedeemAffiliate returns a context that suppresses the redeem-level
@@ -386,9 +393,30 @@ func unsupportedRedeemTypeError(codeType string) error {
// Redeem 使用兑换码
func (s *RedeemService) Redeem(ctx context.Context, userID int64, code string) (*RedeemCode, error) {
// 检查限流
if err := s.checkRedeemRateLimit(ctx, userID); err != nil {
return nil, err
return s.redeem(ctx, userID, code, enforceRedeemRateLimit)
}
// redeemForPaymentFulfillment is restricted to trusted payment fulfillment.
// Payment retries must not be blocked by, or contribute to, a user's public
// redeem failure counter. All code validation and transactional updates remain
// identical to the public redemption path.
func (s *RedeemService) redeemForPaymentFulfillment(ctx context.Context, userID int64, code string) (*RedeemCode, error) {
return s.redeem(ContextSkipRedeemAffiliate(ctx), userID, code, bypassRedeemRateLimit)
}
// RedeemForAdminFulfillment is restricted to trusted admin fulfillment.
// Admin retries must not be blocked by, or contribute to, a user's public
// redeem failure counter. Unlike payment fulfillment, the normal redeem-level
// affiliate rebate remains enabled.
func (s *RedeemService) RedeemForAdminFulfillment(ctx context.Context, userID int64, code string) (*RedeemCode, error) {
return s.redeem(ctx, userID, code, bypassRedeemRateLimit)
}
func (s *RedeemService) redeem(ctx context.Context, userID int64, code string, rateLimitPolicy redeemRateLimitPolicy) (*RedeemCode, error) {
if rateLimitPolicy == enforceRedeemRateLimit {
if err := s.checkRedeemRateLimit(ctx, userID); err != nil {
return nil, err
}
}
// 获取分布式锁,防止同一兑换码并发使用
@@ -401,7 +429,9 @@ func (s *RedeemService) Redeem(ctx context.Context, userID int64, code string) (
redeemCode, err := s.redeemRepo.GetByCode(ctx, code)
if err != nil {
if errors.Is(err, ErrRedeemCodeNotFound) {
s.incrementRedeemErrorCount(ctx, userID)
if rateLimitPolicy == enforceRedeemRateLimit {
s.incrementRedeemErrorCount(ctx, userID)
}
return nil, ErrRedeemCodeNotFound
}
return nil, fmt.Errorf("get redeem code: %w", err)
@@ -409,11 +439,15 @@ func (s *RedeemService) Redeem(ctx context.Context, userID int64, code string) (
// 检查兑换码状态和码本身的过期时间
if redeemCode.IsExpired() {
s.incrementRedeemErrorCount(ctx, userID)
if rateLimitPolicy == enforceRedeemRateLimit {
s.incrementRedeemErrorCount(ctx, userID)
}
return nil, ErrRedeemCodeExpired
}
if !redeemCode.CanUse() {
s.incrementRedeemErrorCount(ctx, userID)
if rateLimitPolicy == enforceRedeemRateLimit {
s.incrementRedeemErrorCount(ctx, userID)
}
return nil, ErrRedeemCodeUsed
}