Merge pull request #1439 from touwaeriol/feat/redeem-negative-value
feat(redeem): support negative values for refund/deduction
This commit is contained in:
14
.github/audit-exceptions.yml
vendored
14
.github/audit-exceptions.yml
vendored
@@ -14,3 +14,17 @@ exceptions:
|
|||||||
mitigation: "Load only on export; restrict export permissions and data scope"
|
mitigation: "Load only on export; restrict export permissions and data scope"
|
||||||
expires_on: "2026-04-05"
|
expires_on: "2026-04-05"
|
||||||
owner: "security@your-domain"
|
owner: "security@your-domain"
|
||||||
|
- package: lodash
|
||||||
|
advisory: "GHSA-r5fr-rjxr-66jc"
|
||||||
|
severity: high
|
||||||
|
reason: "lodash _.template not used with untrusted input; only internal admin UI templates"
|
||||||
|
mitigation: "No user-controlled template strings; plan to migrate to lodash-es tree-shaken imports"
|
||||||
|
expires_on: "2026-07-02"
|
||||||
|
owner: "security@your-domain"
|
||||||
|
- package: lodash-es
|
||||||
|
advisory: "GHSA-r5fr-rjxr-66jc"
|
||||||
|
severity: high
|
||||||
|
reason: "lodash-es _.template not used with untrusted input; only internal admin UI templates"
|
||||||
|
mitigation: "No user-controlled template strings; plan to migrate to native JS alternatives"
|
||||||
|
expires_on: "2026-07-02"
|
||||||
|
owner: "security@your-domain"
|
||||||
|
|||||||
@@ -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 {
|
|||||||
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) {
|
|||||||
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) {
|
|||||||
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,
|
||||||
for _, tc := range cases {
|
"validity_days": 0,
|
||||||
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)
|
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
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) {
|
func TestCreateAndRedeem_SubscriptionValidParamsPassValidation(t *testing.T) {
|
||||||
|
|||||||
@@ -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
|
|||||||
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) (
|
|||||||
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) (
|
|||||||
// 执行兑换逻辑(兑换码已被锁定,此时可安全操作)
|
// 执行兑换逻辑(兑换码已被锁定,此时可安全操作)
|
||||||
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
|
|||||||
}
|
}
|
||||||
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user