diff --git a/backend/internal/handler/admin/redeem_handler.go b/backend/internal/handler/admin/redeem_handler.go index 5dca01d5ec..fcf0b6dcdf 100644 --- a/backend/internal/handler/admin/redeem_handler.go +++ b/backend/internal/handler/admin/redeem_handler.go @@ -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 } diff --git a/backend/internal/service/payment_fulfillment.go b/backend/internal/service/payment_fulfillment.go index 4d442f3d1e..2b70a90ef6 100644 --- a/backend/internal/service/payment_fulfillment.go +++ b/backend/internal/service/payment_fulfillment.go @@ -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 { diff --git a/backend/internal/service/payment_fulfillment_test.go b/backend/internal/service/payment_fulfillment_test.go index 50ccd485ed..3f1fdb1cf6 100644 --- a/backend/internal/service/payment_fulfillment_test.go +++ b/backend/internal/service/payment_fulfillment_test.go @@ -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) diff --git a/backend/internal/service/redeem_admin_fulfillment_test.go b/backend/internal/service/redeem_admin_fulfillment_test.go new file mode 100644 index 0000000000..76e4ff4832 --- /dev/null +++ b/backend/internal/service/redeem_admin_fulfillment_test.go @@ -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) +} diff --git a/backend/internal/service/redeem_service.go b/backend/internal/service/redeem_service.go index 8794872e3d..7be6871f07 100644 --- a/backend/internal/service/redeem_service.go +++ b/backend/internal/service/redeem_service.go @@ -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 }