Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
b384570d
Unverified
Commit
b384570d
authored
Apr 03, 2026
by
Wesley Liddick
Committed by
GitHub
Apr 03, 2026
Browse files
Merge pull request #1439 from touwaeriol/feat/redeem-negative-value
feat(redeem): support negative values for refund/deduction
parents
0507852a
bf24de88
Changes
4
Show whitespace changes
Inline
Side-by-side
.github/audit-exceptions.yml
View file @
b384570d
...
@@ -14,3 +14,17 @@ exceptions:
...
@@ -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"
backend/internal/handler/admin/redeem_handler.go
View file @
b384570d
...
@@ -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
}
}
}
}
...
...
backend/internal/handler/admin/redeem_handler_test.go
View file @
b384570d
...
@@ -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_SubscriptionRequires
Positive
ValidityDays
(
t
*
testing
.
T
)
{
func
TestCreateAndRedeem_SubscriptionRequires
NonZero
ValidityDays
(
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
}{
{
"zero"
,
0
},
{
"negative"
,
-
1
},
}
for
_
,
tc
:=
range
cases
{
t
.
Run
(
tc
.
name
,
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-bad-days-
zero"
,
"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"
:
0
,
})
})
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
)
{
...
...
backend/internal/service/redeem_service.go
View file @
b384570d
...
@@ -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,20 +315,34 @@ func (s *RedeemService) Redeem(ctx context.Context, userID int64, code string) (
...
@@ -316,20 +315,34 @@ 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
{
// 负数天数:缩短订阅,减到 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
validityDays
=
30
}
}
_
,
_
,
err
:=
s
.
subscriptionService
.
AssignOrExtendSubscription
(
txCtx
,
&
AssignSubscriptionInput
{
_
,
_
,
err
:=
s
.
subscriptionService
.
AssignOrExtendSubscription
(
txCtx
,
&
AssignSubscriptionInput
{
...
@@ -342,6 +355,7 @@ func (s *RedeemService) Redeem(ctx context.Context, userID int64, code string) (
...
@@ -342,6 +355,7 @@ func (s *RedeemService) Redeem(ctx context.Context, userID int64, code string) (
if
err
!=
nil
{
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"assign or extend subscription: %w"
,
err
)
return
nil
,
fmt
.
Errorf
(
"assign or extend subscription: %w"
,
err
)
}
}
}
default
:
default
:
return
nil
,
fmt
.
Errorf
(
"unsupported redeem type: %s"
,
redeemCode
.
Type
)
return
nil
,
fmt
.
Errorf
(
"unsupported redeem type: %s"
,
redeemCode
.
Type
)
...
@@ -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
}
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment