From 66fde7a2e609b13adcdeeaac3d4ac5b1296edc29 Mon Sep 17 00:00:00 2001 From: erio Date: Fri, 3 Apr 2026 01:50:26 +0800 Subject: [PATCH] feat(redeem): support negative values for refund/deduction Allow redeem codes with negative values to enable refund scenarios: - Balance: negative value deducts balance (clamped to 0, never negative) - Concurrency: negative value reduces concurrency (clamped to 0) - Subscription: negative validity_days reduces remaining days; if remaining days <= 0, the subscription is canceled (set to expired) All deductions generate standard redeem code records for audit trail. --- .../internal/handler/admin/redeem_handler.go | 16 +-- .../handler/admin/redeem_handler_test.go | 50 +++++---- backend/internal/service/redeem_service.go | 106 ++++++++++++++---- 3 files changed, 120 insertions(+), 52 deletions(-) diff --git a/backend/internal/handler/admin/redeem_handler.go b/backend/internal/handler/admin/redeem_handler.go index 13ea88d9..c31f847d 100644 --- a/backend/internal/handler/admin/redeem_handler.go +++ b/backend/internal/handler/admin/redeem_handler.go @@ -35,9 +35,9 @@ func NewRedeemHandler(adminService service.AdminService, redeemService *service. type GenerateRedeemCodesRequest struct { Count int `json:"count" binding:"required,min=1,max=100"` Type string `json:"type" binding:"required,oneof=balance concurrency subscription invitation"` - Value float64 `json:"value" binding:"min=0"` - GroupID *int64 `json:"group_id"` // 订阅类型必填 - ValidityDays int `json:"validity_days" binding:"omitempty,max=36500"` // 订阅类型使用,默认30天,最大100年 + Value float64 `json:"value"` + GroupID *int64 `json:"group_id"` // 订阅类型必填 + ValidityDays int `json:"validity_days"` // 订阅类型使用,正数增加/负数退款扣减 } // CreateAndRedeemCodeRequest represents creating a fixed code and redeeming it for a target user. @@ -45,10 +45,10 @@ type GenerateRedeemCodesRequest struct { type CreateAndRedeemCodeRequest struct { Code string `json:"code" binding:"required,min=3,max=128"` Type string `json:"type" binding:"omitempty,oneof=balance concurrency subscription invitation"` // 不传时默认 balance(向后兼容) - Value float64 `json:"value" binding:"required,gt=0"` + Value float64 `json:"value" binding:"required"` UserID int64 `json:"user_id" binding:"required,gt=0"` - GroupID *int64 `json:"group_id"` // subscription 类型必填 - ValidityDays int `json:"validity_days" binding:"omitempty,max=36500"` // subscription 类型必填,>0 + GroupID *int64 `json:"group_id"` // subscription 类型必填 + ValidityDays int `json:"validity_days"` // subscription 类型:正数增加,负数退款扣减 Notes string `json:"notes"` } @@ -150,8 +150,8 @@ func (h *RedeemHandler) CreateAndRedeem(c *gin.Context) { response.BadRequest(c, "group_id is required for subscription type") return } - if req.ValidityDays <= 0 { - response.BadRequest(c, "validity_days must be greater than 0 for subscription type") + if req.ValidityDays == 0 { + response.BadRequest(c, "validity_days must not be zero for subscription type") return } } diff --git a/backend/internal/handler/admin/redeem_handler_test.go b/backend/internal/handler/admin/redeem_handler_test.go index 0d42f64f..f1f7778f 100644 --- a/backend/internal/handler/admin/redeem_handler_test.go +++ b/backend/internal/handler/admin/redeem_handler_test.go @@ -76,32 +76,38 @@ func TestCreateAndRedeem_SubscriptionRequiresGroupID(t *testing.T) { assert.Equal(t, http.StatusBadRequest, code) } -func TestCreateAndRedeem_SubscriptionRequiresPositiveValidityDays(t *testing.T) { +func TestCreateAndRedeem_SubscriptionRequiresNonZeroValidityDays(t *testing.T) { groupID := int64(5) h := newCreateAndRedeemHandler() - cases := []struct { - name string - validityDays int - }{ - {"zero", 0}, - {"negative", -1}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - code := postCreateAndRedeemValidation(t, h, map[string]any{ - "code": "test-sub-bad-days-" + tc.name, - "type": "subscription", - "value": 29.9, - "user_id": 1, - "group_id": groupID, - "validity_days": tc.validityDays, - }) - - assert.Equal(t, http.StatusBadRequest, code) + // zero should be rejected + t.Run("zero", func(t *testing.T) { + code := postCreateAndRedeemValidation(t, h, map[string]any{ + "code": "test-sub-bad-days-zero", + "type": "subscription", + "value": 29.9, + "user_id": 1, + "group_id": groupID, + "validity_days": 0, }) - } + + assert.Equal(t, http.StatusBadRequest, code) + }) + + // negative should pass validation (used for refund/reduction) + t.Run("negative_passes_validation", func(t *testing.T) { + code := postCreateAndRedeemValidation(t, h, map[string]any{ + "code": "test-sub-negative-days", + "type": "subscription", + "value": 29.9, + "user_id": 1, + "group_id": groupID, + "validity_days": -7, + }) + + assert.NotEqual(t, http.StatusBadRequest, code, + "negative validity_days should pass validation for refund") + }) } func TestCreateAndRedeem_SubscriptionValidParamsPassValidation(t *testing.T) { diff --git a/backend/internal/service/redeem_service.go b/backend/internal/service/redeem_service.go index b22da752..9ced6201 100644 --- a/backend/internal/service/redeem_service.go +++ b/backend/internal/service/redeem_service.go @@ -131,9 +131,9 @@ func (s *RedeemService) GenerateCodes(ctx context.Context, req GenerateCodesRequ return nil, errors.New("count must be greater than 0") } - // 邀请码类型不需要数值,其他类型需要 - if req.Type != RedeemTypeInvitation && req.Value <= 0 { - return nil, errors.New("value must be greater than 0") + // 邀请码类型不需要数值,其他类型需要非零值(支持负数用于退款) + if req.Type != RedeemTypeInvitation && req.Value == 0 { + return nil, errors.New("value must not be zero") } if req.Count > 1000 { @@ -188,8 +188,8 @@ func (s *RedeemService) CreateCode(ctx context.Context, code *RedeemCode) error if code.Type == "" { code.Type = RedeemTypeBalance } - if code.Type != RedeemTypeInvitation && code.Value <= 0 { - return errors.New("value must be greater than 0") + if code.Type != RedeemTypeInvitation && code.Value == 0 { + return errors.New("value must not be zero") } if code.Status == "" { code.Status = StatusUnused @@ -292,7 +292,6 @@ func (s *RedeemService) Redeem(ctx context.Context, userID int64, code string) ( if err != nil { return nil, fmt.Errorf("get user: %w", err) } - _ = user // 使用变量避免未使用错误 // 使用数据库事务保证兑换码标记与权益发放的原子性 tx, err := s.entClient.Tx(ctx) @@ -316,31 +315,46 @@ func (s *RedeemService) Redeem(ctx context.Context, userID int64, code string) ( // 执行兑换逻辑(兑换码已被锁定,此时可安全操作) switch redeemCode.Type { case RedeemTypeBalance: - // 增加用户余额 - if err := s.userRepo.UpdateBalance(txCtx, userID, redeemCode.Value); err != nil { + amount := redeemCode.Value + // 负数为退款扣减,余额最低为 0 + if amount < 0 && user.Balance+amount < 0 { + amount = -user.Balance + } + if err := s.userRepo.UpdateBalance(txCtx, userID, amount); err != nil { return nil, fmt.Errorf("update user balance: %w", err) } case RedeemTypeConcurrency: - // 增加用户并发数 - if err := s.userRepo.UpdateConcurrency(txCtx, userID, int(redeemCode.Value)); err != nil { + delta := int(redeemCode.Value) + // 负数为退款扣减,并发数最低为 0 + if delta < 0 && user.Concurrency+delta < 0 { + delta = -user.Concurrency + } + if err := s.userRepo.UpdateConcurrency(txCtx, userID, delta); err != nil { return nil, fmt.Errorf("update user concurrency: %w", err) } case RedeemTypeSubscription: validityDays := redeemCode.ValidityDays - if validityDays <= 0 { - validityDays = 30 - } - _, _, err := s.subscriptionService.AssignOrExtendSubscription(txCtx, &AssignSubscriptionInput{ - UserID: userID, - GroupID: *redeemCode.GroupID, - ValidityDays: validityDays, - AssignedBy: 0, // 系统分配 - Notes: fmt.Sprintf("通过兑换码 %s 兑换", redeemCode.Code), - }) - if err != nil { - return nil, fmt.Errorf("assign or extend subscription: %w", err) + if validityDays < 0 { + // 负数天数:缩短订阅,减到 0 则取消订阅 + if err := s.reduceOrCancelSubscription(txCtx, userID, *redeemCode.GroupID, -validityDays, redeemCode.Code); err != nil { + return nil, fmt.Errorf("reduce or cancel subscription: %w", err) + } + } else { + if validityDays == 0 { + validityDays = 30 + } + _, _, err := s.subscriptionService.AssignOrExtendSubscription(txCtx, &AssignSubscriptionInput{ + UserID: userID, + GroupID: *redeemCode.GroupID, + ValidityDays: validityDays, + AssignedBy: 0, // 系统分配 + Notes: fmt.Sprintf("通过兑换码 %s 兑换", redeemCode.Code), + }) + if err != nil { + return nil, fmt.Errorf("assign or extend subscription: %w", err) + } } default: @@ -475,3 +489,51 @@ func (s *RedeemService) GetUserHistory(ctx context.Context, userID int64, limit } return codes, nil } + +// reduceOrCancelSubscription 缩短订阅天数,剩余天数 <= 0 时取消订阅 +func (s *RedeemService) reduceOrCancelSubscription(ctx context.Context, userID, groupID int64, reduceDays int, code string) error { + sub, err := s.subscriptionService.userSubRepo.GetByUserIDAndGroupID(ctx, userID, groupID) + if err != nil { + return ErrSubscriptionNotFound + } + + now := time.Now() + remaining := int(sub.ExpiresAt.Sub(now).Hours() / 24) + if remaining < 0 { + remaining = 0 + } + + notes := fmt.Sprintf("通过兑换码 %s 退款扣减 %d 天", code, reduceDays) + + if remaining <= reduceDays { + // 剩余天数不足,直接取消订阅 + if err := s.subscriptionService.userSubRepo.UpdateStatus(ctx, sub.ID, SubscriptionStatusExpired); err != nil { + return fmt.Errorf("cancel subscription: %w", err) + } + // 设置过期时间为当前时间 + if err := s.subscriptionService.userSubRepo.ExtendExpiry(ctx, sub.ID, now); err != nil { + return fmt.Errorf("set subscription expiry: %w", err) + } + } else { + // 缩短天数 + newExpiresAt := sub.ExpiresAt.AddDate(0, 0, -reduceDays) + if err := s.subscriptionService.userSubRepo.ExtendExpiry(ctx, sub.ID, newExpiresAt); err != nil { + return fmt.Errorf("reduce subscription: %w", err) + } + } + + // 追加备注 + newNotes := sub.Notes + if newNotes != "" { + newNotes += "\n" + } + newNotes += notes + if err := s.subscriptionService.userSubRepo.UpdateNotes(ctx, sub.ID, newNotes); err != nil { + return fmt.Errorf("update subscription notes: %w", err) + } + + // 失效缓存 + s.subscriptionService.InvalidateSubCache(userID, groupID) + + return nil +}