Commit 66fde7a2 authored by erio's avatar erio
Browse files

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.
parent 055c48ab
...@@ -35,9 +35,9 @@ func NewRedeemHandler(adminService service.AdminService, redeemService *service. ...@@ -35,9 +35,9 @@ func NewRedeemHandler(adminService service.AdminService, redeemService *service.
type GenerateRedeemCodesRequest struct { type GenerateRedeemCodesRequest struct {
Count int `json:"count" binding:"required,min=1,max=100"` Count int `json:"count" binding:"required,min=1,max=100"`
Type string `json:"type" binding:"required,oneof=balance concurrency subscription invitation"` Type string `json:"type" binding:"required,oneof=balance concurrency subscription invitation"`
Value float64 `json:"value" binding:"min=0"` Value float64 `json:"value"`
GroupID *int64 `json:"group_id"` // 订阅类型必填 GroupID *int64 `json:"group_id"` // 订阅类型必填
ValidityDays int `json:"validity_days" binding:"omitempty,max=36500"` // 订阅类型使用,默认30天,最大100年 ValidityDays int `json:"validity_days"` // 订阅类型使用,正数增加/负数退款扣减
} }
// CreateAndRedeemCodeRequest represents creating a fixed code and redeeming it for a target user. // CreateAndRedeemCodeRequest represents creating a fixed code and redeeming it for a target user.
...@@ -45,10 +45,10 @@ type GenerateRedeemCodesRequest struct { ...@@ -45,10 +45,10 @@ type GenerateRedeemCodesRequest struct {
type CreateAndRedeemCodeRequest struct { type CreateAndRedeemCodeRequest struct {
Code string `json:"code" binding:"required,min=3,max=128"` Code string `json:"code" binding:"required,min=3,max=128"`
Type string `json:"type" binding:"omitempty,oneof=balance concurrency subscription invitation"` // 不传时默认 balance(向后兼容) 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"` UserID int64 `json:"user_id" binding:"required,gt=0"`
GroupID *int64 `json:"group_id"` // subscription 类型必填 GroupID *int64 `json:"group_id"` // subscription 类型必填
ValidityDays int `json:"validity_days" binding:"omitempty,max=36500"` // subscription 类型必填,>0 ValidityDays int `json:"validity_days"` // subscription 类型:正数增加,负数退款扣减
Notes string `json:"notes"` Notes string `json:"notes"`
} }
...@@ -150,8 +150,8 @@ func (h *RedeemHandler) CreateAndRedeem(c *gin.Context) { ...@@ -150,8 +150,8 @@ func (h *RedeemHandler) CreateAndRedeem(c *gin.Context) {
response.BadRequest(c, "group_id is required for subscription type") response.BadRequest(c, "group_id is required for subscription type")
return return
} }
if req.ValidityDays <= 0 { if req.ValidityDays == 0 {
response.BadRequest(c, "validity_days must be greater than 0 for subscription type") response.BadRequest(c, "validity_days must not be zero for subscription type")
return return
} }
} }
......
...@@ -76,32 +76,38 @@ func TestCreateAndRedeem_SubscriptionRequiresGroupID(t *testing.T) { ...@@ -76,32 +76,38 @@ func TestCreateAndRedeem_SubscriptionRequiresGroupID(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, code) assert.Equal(t, http.StatusBadRequest, code)
} }
func TestCreateAndRedeem_SubscriptionRequiresPositiveValidityDays(t *testing.T) { func TestCreateAndRedeem_SubscriptionRequiresNonZeroValidityDays(t *testing.T) {
groupID := int64(5) groupID := int64(5)
h := newCreateAndRedeemHandler() h := newCreateAndRedeemHandler()
cases := []struct { // zero should be rejected
name string t.Run("zero", func(t *testing.T) {
validityDays int code := postCreateAndRedeemValidation(t, h, map[string]any{
}{ "code": "test-sub-bad-days-zero",
{"zero", 0}, "type": "subscription",
{"negative", -1}, "value": 29.9,
} "user_id": 1,
"group_id": groupID,
"validity_days": 0,
})
assert.Equal(t, http.StatusBadRequest, code)
})
for _, tc := range cases { // negative should pass validation (used for refund/reduction)
t.Run(tc.name, func(t *testing.T) { t.Run("negative_passes_validation", func(t *testing.T) {
code := postCreateAndRedeemValidation(t, h, map[string]any{ code := postCreateAndRedeemValidation(t, h, map[string]any{
"code": "test-sub-bad-days-" + tc.name, "code": "test-sub-negative-days",
"type": "subscription", "type": "subscription",
"value": 29.9, "value": 29.9,
"user_id": 1, "user_id": 1,
"group_id": groupID, "group_id": groupID,
"validity_days": tc.validityDays, "validity_days": -7,
})
assert.Equal(t, http.StatusBadRequest, code)
}) })
}
assert.NotEqual(t, http.StatusBadRequest, code,
"negative validity_days should pass validation for refund")
})
} }
func TestCreateAndRedeem_SubscriptionValidParamsPassValidation(t *testing.T) { func TestCreateAndRedeem_SubscriptionValidParamsPassValidation(t *testing.T) {
......
...@@ -131,9 +131,9 @@ func (s *RedeemService) GenerateCodes(ctx context.Context, req GenerateCodesRequ ...@@ -131,9 +131,9 @@ func (s *RedeemService) GenerateCodes(ctx context.Context, req GenerateCodesRequ
return nil, errors.New("count must be greater than 0") return nil, errors.New("count must be greater than 0")
} }
// 邀请码类型不需要数值,其他类型需要 // 邀请码类型不需要数值,其他类型需要非零值(支持负数用于退款)
if req.Type != RedeemTypeInvitation && req.Value <= 0 { if req.Type != RedeemTypeInvitation && req.Value == 0 {
return nil, errors.New("value must be greater than 0") return nil, errors.New("value must not be zero")
} }
if req.Count > 1000 { if req.Count > 1000 {
...@@ -188,8 +188,8 @@ func (s *RedeemService) CreateCode(ctx context.Context, code *RedeemCode) error ...@@ -188,8 +188,8 @@ func (s *RedeemService) CreateCode(ctx context.Context, code *RedeemCode) error
if code.Type == "" { if code.Type == "" {
code.Type = RedeemTypeBalance code.Type = RedeemTypeBalance
} }
if code.Type != RedeemTypeInvitation && code.Value <= 0 { if code.Type != RedeemTypeInvitation && code.Value == 0 {
return errors.New("value must be greater than 0") return errors.New("value must not be zero")
} }
if code.Status == "" { if code.Status == "" {
code.Status = StatusUnused code.Status = StatusUnused
...@@ -292,7 +292,6 @@ func (s *RedeemService) Redeem(ctx context.Context, userID int64, code string) ( ...@@ -292,7 +292,6 @@ func (s *RedeemService) Redeem(ctx context.Context, userID int64, code string) (
if err != nil { if err != nil {
return nil, fmt.Errorf("get user: %w", err) return nil, fmt.Errorf("get user: %w", err)
} }
_ = user // 使用变量避免未使用错误
// 使用数据库事务保证兑换码标记与权益发放的原子性 // 使用数据库事务保证兑换码标记与权益发放的原子性
tx, err := s.entClient.Tx(ctx) tx, err := s.entClient.Tx(ctx)
...@@ -316,31 +315,46 @@ func (s *RedeemService) Redeem(ctx context.Context, userID int64, code string) ( ...@@ -316,31 +315,46 @@ func (s *RedeemService) Redeem(ctx context.Context, userID int64, code string) (
// 执行兑换逻辑(兑换码已被锁定,此时可安全操作) // 执行兑换逻辑(兑换码已被锁定,此时可安全操作)
switch redeemCode.Type { switch redeemCode.Type {
case RedeemTypeBalance: case RedeemTypeBalance:
// 增加用户余额 amount := redeemCode.Value
if err := s.userRepo.UpdateBalance(txCtx, userID, redeemCode.Value); err != nil { // 负数为退款扣减,余额最低为 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) return nil, fmt.Errorf("update user balance: %w", err)
} }
case RedeemTypeConcurrency: case RedeemTypeConcurrency:
// 增加用户并发数 delta := int(redeemCode.Value)
if err := s.userRepo.UpdateConcurrency(txCtx, userID, int(redeemCode.Value)); err != nil { // 负数为退款扣减,并发数最低为 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) return nil, fmt.Errorf("update user concurrency: %w", err)
} }
case RedeemTypeSubscription: case RedeemTypeSubscription:
validityDays := redeemCode.ValidityDays validityDays := redeemCode.ValidityDays
if validityDays <= 0 { if validityDays < 0 {
validityDays = 30 // 负数天数:缩短订阅,减到 0 则取消订阅
} if err := s.reduceOrCancelSubscription(txCtx, userID, *redeemCode.GroupID, -validityDays, redeemCode.Code); err != nil {
_, _, err := s.subscriptionService.AssignOrExtendSubscription(txCtx, &AssignSubscriptionInput{ return nil, fmt.Errorf("reduce or cancel subscription: %w", err)
UserID: userID, }
GroupID: *redeemCode.GroupID, } else {
ValidityDays: validityDays, if validityDays == 0 {
AssignedBy: 0, // 系统分配 validityDays = 30
Notes: fmt.Sprintf("通过兑换码 %s 兑换", redeemCode.Code), }
}) _, _, err := s.subscriptionService.AssignOrExtendSubscription(txCtx, &AssignSubscriptionInput{
if err != nil { UserID: userID,
return nil, fmt.Errorf("assign or extend subscription: %w", err) 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: default:
...@@ -475,3 +489,51 @@ func (s *RedeemService) GetUserHistory(ctx context.Context, userID int64, limit ...@@ -475,3 +489,51 @@ func (s *RedeemService) GetUserHistory(ctx context.Context, userID int64, limit
} }
return codes, nil 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
}
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment