mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-05 16:47:26 +00:00
Merge pull request #6670 from ssharkkky/fix/payment-fulfillment-isolation
fix(payment): isolate fulfillment from redeem limits
This commit is contained in:
5 files changed
+393
-29
No files matched your search
@@ -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