mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-31 00:24:38 +00:00
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:
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user