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
b7e878de
Unverified
Commit
b7e878de
authored
Mar 13, 2026
by
Wesley Liddick
Committed by
GitHub
Mar 13, 2026
Browse files
Merge pull request #980 from touwaeriol/feat/redeem-subscription-support
feat(redeem): support subscription type in create-and-redeem API
parents
1ee98447
05edb551
Changes
2
Hide whitespace changes
Inline
Side-by-side
backend/internal/handler/admin/redeem_handler.go
View file @
b7e878de
...
@@ -41,12 +41,15 @@ type GenerateRedeemCodesRequest struct {
...
@@ -41,12 +41,15 @@ type GenerateRedeemCodesRequest struct {
}
}
// 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.
// Type 为 omitempty 而非 required 是为了向后兼容旧版调用方(不传 type 时默认 balance)。
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:"required,oneof=balance concurrency subscription invitation"`
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,gt=0"`
UserID
int64
`json:"user_id" binding:"required,gt=0"`
UserID
int64
`json:"user_id" binding:"required,gt=0"`
Notes
string
`json:"notes"`
GroupID
*
int64
`json:"group_id"`
// subscription 类型必填
ValidityDays
int
`json:"validity_days" binding:"omitempty,max=36500"`
// subscription 类型必填,>0
Notes
string
`json:"notes"`
}
}
// List handles listing all redeem codes with pagination
// List handles listing all redeem codes with pagination
...
@@ -136,6 +139,22 @@ func (h *RedeemHandler) CreateAndRedeem(c *gin.Context) {
...
@@ -136,6 +139,22 @@ func (h *RedeemHandler) CreateAndRedeem(c *gin.Context) {
return
return
}
}
req
.
Code
=
strings
.
TrimSpace
(
req
.
Code
)
req
.
Code
=
strings
.
TrimSpace
(
req
.
Code
)
// 向后兼容:旧版调用方(如 Sub2ApiPay)不传 type 字段,默认当作 balance 充值处理。
// 请勿删除此默认值逻辑,否则会导致旧版调用方 400 报错。
if
req
.
Type
==
""
{
req
.
Type
=
"balance"
}
if
req
.
Type
==
"subscription"
{
if
req
.
GroupID
==
nil
{
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"
)
return
}
}
executeAdminIdempotentJSON
(
c
,
"admin.redeem_codes.create_and_redeem"
,
req
,
service
.
DefaultWriteIdempotencyTTL
(),
func
(
ctx
context
.
Context
)
(
any
,
error
)
{
executeAdminIdempotentJSON
(
c
,
"admin.redeem_codes.create_and_redeem"
,
req
,
service
.
DefaultWriteIdempotencyTTL
(),
func
(
ctx
context
.
Context
)
(
any
,
error
)
{
existing
,
err
:=
h
.
redeemService
.
GetByCode
(
ctx
,
req
.
Code
)
existing
,
err
:=
h
.
redeemService
.
GetByCode
(
ctx
,
req
.
Code
)
...
@@ -147,11 +166,13 @@ func (h *RedeemHandler) CreateAndRedeem(c *gin.Context) {
...
@@ -147,11 +166,13 @@ func (h *RedeemHandler) CreateAndRedeem(c *gin.Context) {
}
}
createErr
:=
h
.
redeemService
.
CreateCode
(
ctx
,
&
service
.
RedeemCode
{
createErr
:=
h
.
redeemService
.
CreateCode
(
ctx
,
&
service
.
RedeemCode
{
Code
:
req
.
Code
,
Code
:
req
.
Code
,
Type
:
req
.
Type
,
Type
:
req
.
Type
,
Value
:
req
.
Value
,
Value
:
req
.
Value
,
Status
:
service
.
StatusUnused
,
Status
:
service
.
StatusUnused
,
Notes
:
req
.
Notes
,
Notes
:
req
.
Notes
,
GroupID
:
req
.
GroupID
,
ValidityDays
:
req
.
ValidityDays
,
})
})
if
createErr
!=
nil
{
if
createErr
!=
nil
{
// Unique code race: if code now exists, use idempotent semantics by used_by.
// Unique code race: if code now exists, use idempotent semantics by used_by.
...
...
backend/internal/handler/admin/redeem_handler_test.go
0 → 100644
View file @
b7e878de
package
admin
import
(
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// newCreateAndRedeemHandler creates a RedeemHandler with a non-nil (but minimal)
// RedeemService so that CreateAndRedeem's nil guard passes and we can test the
// parameter-validation layer that runs before any service call.
func
newCreateAndRedeemHandler
()
*
RedeemHandler
{
return
&
RedeemHandler
{
adminService
:
newStubAdminService
(),
redeemService
:
&
service
.
RedeemService
{},
// non-nil to pass nil guard
}
}
// postCreateAndRedeemValidation calls CreateAndRedeem and returns the response
// status code. For cases that pass validation and proceed into the service layer,
// a panic may occur (because RedeemService internals are nil); this is expected
// and treated as "validation passed" (returns 0 to indicate panic).
func
postCreateAndRedeemValidation
(
t
*
testing
.
T
,
handler
*
RedeemHandler
,
body
any
)
(
code
int
)
{
t
.
Helper
()
gin
.
SetMode
(
gin
.
TestMode
)
w
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
w
)
jsonBytes
,
err
:=
json
.
Marshal
(
body
)
require
.
NoError
(
t
,
err
)
c
.
Request
,
_
=
http
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/admin/redeem-codes/create-and-redeem"
,
bytes
.
NewReader
(
jsonBytes
))
c
.
Request
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
defer
func
()
{
if
r
:=
recover
();
r
!=
nil
{
// Panic means we passed validation and entered service layer (expected for minimal stub).
code
=
0
}
}()
handler
.
CreateAndRedeem
(
c
)
return
w
.
Code
}
func
TestCreateAndRedeem_TypeDefaultsToBalance
(
t
*
testing
.
T
)
{
// 不传 type 字段时应默认 balance,不触发 subscription 校验。
// 验证通过后进入 service 层会 panic(返回 0),说明默认值生效。
h
:=
newCreateAndRedeemHandler
()
code
:=
postCreateAndRedeemValidation
(
t
,
h
,
map
[
string
]
any
{
"code"
:
"test-balance-default"
,
"value"
:
10.0
,
"user_id"
:
1
,
})
assert
.
NotEqual
(
t
,
http
.
StatusBadRequest
,
code
,
"omitting type should default to balance and pass validation"
)
}
func
TestCreateAndRedeem_SubscriptionRequiresGroupID
(
t
*
testing
.
T
)
{
h
:=
newCreateAndRedeemHandler
()
code
:=
postCreateAndRedeemValidation
(
t
,
h
,
map
[
string
]
any
{
"code"
:
"test-sub-no-group"
,
"type"
:
"subscription"
,
"value"
:
29.9
,
"user_id"
:
1
,
"validity_days"
:
30
,
// group_id 缺失
})
assert
.
Equal
(
t
,
http
.
StatusBadRequest
,
code
)
}
func
TestCreateAndRedeem_SubscriptionRequiresPositiveValidityDays
(
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
)
})
}
}
func
TestCreateAndRedeem_SubscriptionValidParamsPassValidation
(
t
*
testing
.
T
)
{
groupID
:=
int64
(
5
)
h
:=
newCreateAndRedeemHandler
()
code
:=
postCreateAndRedeemValidation
(
t
,
h
,
map
[
string
]
any
{
"code"
:
"test-sub-valid"
,
"type"
:
"subscription"
,
"value"
:
29.9
,
"user_id"
:
1
,
"group_id"
:
groupID
,
"validity_days"
:
31
,
})
assert
.
NotEqual
(
t
,
http
.
StatusBadRequest
,
code
,
"valid subscription params should pass validation"
)
}
func
TestCreateAndRedeem_BalanceIgnoresSubscriptionFields
(
t
*
testing
.
T
)
{
h
:=
newCreateAndRedeemHandler
()
// balance 类型不传 group_id 和 validity_days,不应报 400
code
:=
postCreateAndRedeemValidation
(
t
,
h
,
map
[
string
]
any
{
"code"
:
"test-balance-no-extras"
,
"type"
:
"balance"
,
"value"
:
50.0
,
"user_id"
:
1
,
})
assert
.
NotEqual
(
t
,
http
.
StatusBadRequest
,
code
,
"balance type should not require group_id or validity_days"
)
}
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