From 7a70de401c01ffdd8c58357451d772f757d4ec2b Mon Sep 17 00:00:00 2001 From: TT <92159997+ssharkkky@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:54:58 +0800 Subject: [PATCH] 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. --- .../internal/handler/admin/redeem_handler.go | 4 +- .../internal/service/payment_fulfillment.go | 56 +++- .../service/payment_fulfillment_test.go | 257 +++++++++++++++++- .../service/redeem_admin_fulfillment_test.go | 59 ++++ backend/internal/service/redeem_service.go | 46 +++- 5 files changed, 393 insertions(+), 29 deletions(-) create mode 100644 backend/internal/service/redeem_admin_fulfillment_test.go 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 }