Merge pull request #2573 from wucm667/feat/redeem-code-expiry

feat(redeem): 兑换码支持设置使用有效期
This commit is contained in:
Wesley Liddick
2026-05-19 16:25:12 +08:00
committed by GitHub
27 changed files with 676 additions and 38 deletions
@@ -8,6 +8,7 @@ import (
"fmt"
"strconv"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
@@ -33,23 +34,51 @@ func NewRedeemHandler(adminService service.AdminService, redeemService *service.
// GenerateRedeemCodesRequest represents generate redeem codes request
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"`
GroupID *int64 `json:"group_id"` // 订阅类型必填
ValidityDays int `json:"validity_days"` // 订阅类型使用,正数增加/负数退款扣减
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"`
GroupID *int64 `json:"group_id"` // 订阅类型必填
ValidityDays int `json:"validity_days"` // 订阅类型使用,正数增加/负数退款扣减
ExpiresAt *time.Time `json:"expires_at"`
ExpiresInDays *int `json:"expires_in_days" binding:"omitempty,min=1,max=3650"`
}
// CreateAndRedeemCodeRequest represents creating a fixed code and redeeming it for a target user.
// Type 为 omitempty 而非 required 是为了向后兼容旧版调用方(不传 type 时默认 balance)。
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"`
UserID int64 `json:"user_id" binding:"required,gt=0"`
GroupID *int64 `json:"group_id"` // subscription 类型必填
ValidityDays int `json:"validity_days"` // subscription 类型:正数增加,负数退款扣减
Notes string `json:"notes"`
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"`
UserID int64 `json:"user_id" binding:"required,gt=0"`
GroupID *int64 `json:"group_id"` // subscription 类型必填
ValidityDays int `json:"validity_days"` // subscription 类型:正数增加,负数退款扣减
Notes string `json:"notes"`
ExpiresAt *time.Time `json:"expires_at"`
ExpiresInDays *int `json:"expires_in_days" binding:"omitempty,min=1,max=3650"`
}
func resolveRedeemCodeExpiresAt(expiresAt *time.Time, expiresInDays *int) (*time.Time, error) {
if expiresAt != nil && expiresInDays != nil {
return nil, infraerrors.BadRequest("REDEEM_CODE_EXPIRY_CONFLICT", "expires_at and expires_in_days cannot both be set")
}
now := time.Now().UTC()
if expiresInDays != nil {
if *expiresInDays <= 0 {
return nil, infraerrors.BadRequest("REDEEM_CODE_EXPIRES_IN_DAYS_INVALID", "expires_in_days must be greater than zero")
}
expires := now.AddDate(0, 0, *expiresInDays)
return &expires, nil
}
if expiresAt == nil {
return nil, nil
}
expires := expiresAt.UTC()
if !expires.After(now) {
return nil, infraerrors.BadRequest("REDEEM_CODE_EXPIRES_AT_INVALID", "expires_at must be in the future")
}
return &expires, nil
}
// List handles listing all redeem codes with pagination
@@ -107,6 +136,12 @@ func (h *RedeemHandler) Generate(c *gin.Context) {
return
}
expiresAt, err := resolveRedeemCodeExpiresAt(req.ExpiresAt, req.ExpiresInDays)
if err != nil {
response.ErrorFrom(c, err)
return
}
executeAdminIdempotentJSON(c, "admin.redeem_codes.generate", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) {
codes, execErr := h.adminService.GenerateRedeemCodes(ctx, &service.GenerateRedeemCodesInput{
Count: req.Count,
@@ -114,6 +149,7 @@ func (h *RedeemHandler) Generate(c *gin.Context) {
Value: req.Value,
GroupID: req.GroupID,
ValidityDays: req.ValidityDays,
ExpiresAt: expiresAt,
})
if execErr != nil {
return nil, execErr
@@ -158,6 +194,12 @@ func (h *RedeemHandler) CreateAndRedeem(c *gin.Context) {
}
}
expiresAt, err := resolveRedeemCodeExpiresAt(req.ExpiresAt, req.ExpiresInDays)
if err != nil {
response.ErrorFrom(c, err)
return
}
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)
if err == nil {
@@ -175,6 +217,7 @@ func (h *RedeemHandler) CreateAndRedeem(c *gin.Context) {
Notes: req.Notes,
GroupID: req.GroupID,
ValidityDays: req.ValidityDays,
ExpiresAt: expiresAt,
})
if createErr != nil {
// Unique code race: if code now exists, use idempotent semantics by used_by.
@@ -199,6 +242,9 @@ func (h *RedeemHandler) resolveCreateAndRedeemExisting(ctx context.Context, exis
}
// If previous run created the code but crashed before redeem, redeem it now.
if existing.IsExpired() {
return nil, service.ErrRedeemCodeExpired
}
if existing.CanUse() {
redeemed, err := h.redeemService.Redeem(ctx, userID, existing.Code)
if err == nil {
@@ -321,7 +367,7 @@ func (h *RedeemHandler) Export(c *gin.Context) {
writer := csv.NewWriter(&buf)
// Write header
if err := writer.Write([]string{"id", "code", "type", "value", "status", "used_by", "used_by_email", "used_at", "created_at"}); err != nil {
if err := writer.Write([]string{"id", "code", "type", "value", "status", "used_by", "used_by_email", "used_at", "expires_at", "created_at"}); err != nil {
response.InternalError(c, "Failed to export redeem codes: "+err.Error())
return
}
@@ -340,6 +386,10 @@ func (h *RedeemHandler) Export(c *gin.Context) {
if code.UsedAt != nil {
usedAt = code.UsedAt.Format("2006-01-02 15:04:05")
}
expiresAt := ""
if code.ExpiresAt != nil {
expiresAt = code.ExpiresAt.Format("2006-01-02 15:04:05")
}
if err := writer.Write([]string{
fmt.Sprintf("%d", code.ID),
code.Code,
@@ -349,6 +399,7 @@ func (h *RedeemHandler) Export(c *gin.Context) {
usedBy,
usedByEmail,
usedAt,
expiresAt,
code.CreatedAt.Format("2006-01-02 15:04:05"),
}); err != nil {
response.InternalError(c, "Failed to export redeem codes: "+err.Error())
@@ -6,6 +6,7 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
@@ -139,3 +140,33 @@ func TestCreateAndRedeem_BalanceIgnoresSubscriptionFields(t *testing.T) {
assert.NotEqual(t, http.StatusBadRequest, code,
"balance type should not require group_id or validity_days")
}
func TestResolveRedeemCodeExpiresAt_FromDays(t *testing.T) {
days := 3
expiresAt, err := resolveRedeemCodeExpiresAt(nil, &days)
require.NoError(t, err)
require.NotNil(t, expiresAt)
require.WithinDuration(t, time.Now().UTC().AddDate(0, 0, days), *expiresAt, 2*time.Second)
}
func TestResolveRedeemCodeExpiresAt_RejectsPastAbsoluteTime(t *testing.T) {
past := time.Now().UTC().Add(-time.Minute)
expiresAt, err := resolveRedeemCodeExpiresAt(&past, nil)
require.Error(t, err)
require.Nil(t, expiresAt)
}
func TestResolveRedeemCodeExpiresAt_RejectsNonPositiveDays(t *testing.T) {
days := 0
expiresAt, err := resolveRedeemCodeExpiresAt(nil, &days)
require.Error(t, err)
require.Nil(t, expiresAt)
}
func TestResolveRedeemCodeExpiresAt_RejectsConflictingInputs(t *testing.T) {
future := time.Now().UTC().Add(time.Hour)
days := 3
expiresAt, err := resolveRedeemCodeExpiresAt(&future, &days)
require.Error(t, err)
require.Nil(t, expiresAt)
}
+4
View File
@@ -533,11 +533,15 @@ func redeemCodeFromServiceBase(rc *service.RedeemCode) RedeemCode {
UsedBy: rc.UsedBy,
UsedAt: rc.UsedAt,
CreatedAt: rc.CreatedAt,
ExpiresAt: rc.ExpiresAt,
GroupID: rc.GroupID,
ValidityDays: rc.ValidityDays,
User: UserFromServiceShallow(rc.User),
Group: GroupFromServiceShallow(rc.Group),
}
if rc.IsExpired() {
out.Status = service.StatusExpired
}
// For admin_balance/admin_concurrency types, include notes so users can see
// why they were charged or credited by admin
+1
View File
@@ -338,6 +338,7 @@ type RedeemCode struct {
UsedBy *int64 `json:"used_by"`
UsedAt *time.Time `json:"used_at"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
GroupID *int64 `json:"group_id"`
ValidityDays int `json:"validity_days"`
@@ -30,6 +30,7 @@ func (r *redeemCodeRepository) Create(ctx context.Context, code *service.RedeemC
SetStatus(code.Status).
SetNotes(code.Notes).
SetValidityDays(code.ValidityDays).
SetNillableExpiresAt(code.ExpiresAt).
SetNillableUsedBy(code.UsedBy).
SetNillableUsedAt(code.UsedAt).
SetNillableGroupID(code.GroupID).
@@ -56,6 +57,7 @@ func (r *redeemCodeRepository) CreateBatch(ctx context.Context, codes []service.
SetStatus(c.Status).
SetNotes(c.Notes).
SetValidityDays(c.ValidityDays).
SetNillableExpiresAt(c.ExpiresAt).
SetNillableUsedBy(c.UsedBy).
SetNillableUsedAt(c.UsedAt).
SetNillableGroupID(c.GroupID)
@@ -107,7 +109,28 @@ func (r *redeemCodeRepository) ListWithFilters(ctx context.Context, params pagin
q = q.Where(redeemcode.TypeEQ(codeType))
}
if status != "" {
q = q.Where(redeemcode.StatusEQ(status))
now := time.Now()
switch status {
case service.StatusExpired:
q = q.Where(redeemcode.Or(
redeemcode.StatusEQ(service.StatusExpired),
redeemcode.And(
redeemcode.StatusEQ(service.StatusUnused),
redeemcode.ExpiresAtNotNil(),
redeemcode.ExpiresAtLTE(now),
),
))
case service.StatusUnused:
q = q.Where(
redeemcode.StatusEQ(service.StatusUnused),
redeemcode.Or(
redeemcode.ExpiresAtIsNil(),
redeemcode.ExpiresAtGT(now),
),
)
default:
q = q.Where(redeemcode.StatusEQ(status))
}
}
if search != "" {
q = q.Where(
@@ -158,6 +181,8 @@ func redeemCodeListOrder(params pagination.PaginationParams) []func(*entsql.Sele
field = redeemcode.FieldUsedAt
case "created_at":
field = redeemcode.FieldCreatedAt
case "expires_at":
field = redeemcode.FieldExpiresAt
case "code":
field = redeemcode.FieldCode
default:
@@ -194,6 +219,11 @@ func (r *redeemCodeRepository) Update(ctx context.Context, code *service.RedeemC
} else {
up.ClearGroupID()
}
if code.ExpiresAt != nil {
up.SetExpiresAt(*code.ExpiresAt)
} else {
up.ClearExpiresAt()
}
updated, err := up.Save(ctx)
if err != nil {
@@ -307,6 +337,7 @@ func redeemCodeEntityToService(m *dbent.RedeemCode) *service.RedeemCode {
UsedAt: m.UsedAt,
Notes: derefString(m.Notes),
CreatedAt: m.CreatedAt,
ExpiresAt: m.ExpiresAt,
GroupID: m.GroupID,
ValidityDays: m.ValidityDays,
}
@@ -51,11 +51,13 @@ func (s *RedeemCodeRepoSuite) createGroup(name string) *dbent.Group {
// --- Create / CreateBatch / GetByID / GetByCode ---
func (s *RedeemCodeRepoSuite) TestCreate() {
expiresAt := time.Now().UTC().Add(2 * time.Hour)
code := &service.RedeemCode{
Code: "TEST-CREATE",
Type: service.RedeemTypeBalance,
Value: 100,
Status: service.StatusUnused,
Code: "TEST-CREATE",
Type: service.RedeemTypeBalance,
Value: 100,
Status: service.StatusUnused,
ExpiresAt: &expiresAt,
}
err := s.repo.Create(s.ctx, code)
@@ -65,6 +67,8 @@ func (s *RedeemCodeRepoSuite) TestCreate() {
got, err := s.repo.GetByID(s.ctx, code.ID)
s.Require().NoError(err, "GetByID")
s.Require().Equal("TEST-CREATE", got.Code)
s.Require().NotNil(got.ExpiresAt)
s.Require().WithinDuration(expiresAt, *got.ExpiresAt, time.Second)
}
func (s *RedeemCodeRepoSuite) TestCreateBatch() {
@@ -166,6 +170,23 @@ func (s *RedeemCodeRepoSuite) TestListWithFilters_Status() {
s.Require().Equal(service.StatusUsed, codes[0].Status)
}
func (s *RedeemCodeRepoSuite) TestListWithFilters_StatusExpiredByExpiresAt() {
past := time.Now().UTC().Add(-time.Hour)
future := time.Now().UTC().Add(time.Hour)
s.Require().NoError(s.repo.Create(s.ctx, &service.RedeemCode{Code: "STAT-EXPIRED-BY-TIME", Type: service.RedeemTypeBalance, Value: 0, Status: service.StatusUnused, ExpiresAt: &past}))
s.Require().NoError(s.repo.Create(s.ctx, &service.RedeemCode{Code: "STAT-UNUSED-FUTURE", Type: service.RedeemTypeBalance, Value: 0, Status: service.StatusUnused, ExpiresAt: &future}))
expired, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, "", service.StatusExpired, "")
s.Require().NoError(err)
s.Require().Len(expired, 1)
s.Require().Equal("STAT-EXPIRED-BY-TIME", expired[0].Code)
unused, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, "", service.StatusUnused, "")
s.Require().NoError(err)
s.Require().Len(unused, 1)
s.Require().Equal("STAT-UNUSED-FUTURE", unused[0].Code)
}
func (s *RedeemCodeRepoSuite) TestListWithFilters_Search() {
s.Require().NoError(s.repo.Create(s.ctx, &service.RedeemCode{Code: "ALPHA-CODE", Type: service.RedeemTypeBalance, Value: 0, Status: service.StatusUnused}))
s.Require().NoError(s.repo.Create(s.ctx, &service.RedeemCode{Code: "BETA-CODE", Type: service.RedeemTypeBalance, Value: 0, Status: service.StatusUnused}))
+10 -4
View File
@@ -397,6 +397,7 @@ type GenerateRedeemCodesInput struct {
Value float64
GroupID *int64 // 订阅类型专用:关联的分组ID
ValidityDays int // 订阅类型专用:有效天数
ExpiresAt *time.Time
}
type ProxyBatchDeleteResult struct {
@@ -2970,6 +2971,10 @@ func (s *adminServiceImpl) GetRedeemCode(ctx context.Context, id int64) (*Redeem
}
func (s *adminServiceImpl) GenerateRedeemCodes(ctx context.Context, input *GenerateRedeemCodesInput) ([]RedeemCode, error) {
if input.ExpiresAt != nil && !input.ExpiresAt.After(time.Now()) {
return nil, ErrRedeemCodeExpired
}
// 如果是订阅类型,验证必须有 GroupID
if input.Type == RedeemTypeSubscription {
if input.GroupID == nil {
@@ -2992,10 +2997,11 @@ func (s *adminServiceImpl) GenerateRedeemCodes(ctx context.Context, input *Gener
return nil, err
}
code := RedeemCode{
Code: codeValue,
Type: input.Type,
Value: input.Value,
Status: StatusUnused,
Code: codeValue,
Type: input.Type,
Value: input.Value,
Status: StatusUnused,
ExpiresAt: input.ExpiresAt,
}
// 订阅类型专用字段
if input.Type == RedeemTypeSubscription {
@@ -72,7 +72,7 @@ func (s *AuthService) validateOAuthRegistrationInvitation(ctx context.Context, i
if err != nil {
return nil, ErrInvitationCodeInvalid
}
if redeemCode.Type != RedeemTypeInvitation || redeemCode.Status != StatusUnused {
if redeemCode.Type != RedeemTypeInvitation || !redeemCode.CanUse() {
return nil, ErrInvitationCodeInvalid
}
return redeemCode, nil
@@ -364,6 +364,7 @@ func (s *AuthService) loadOAuthRegistrationInvitation(ctx context.Context, invit
UsedAt: entity.UsedAt,
Notes: oauthEmailFlowStringValue(entity.Notes),
CreatedAt: entity.CreatedAt,
ExpiresAt: entity.ExpiresAt,
GroupID: entity.GroupID,
ValidityDays: entity.ValidityDays,
}, nil
@@ -374,7 +375,11 @@ func (s *AuthService) loadOAuthRegistrationInvitation(ctx context.Context, invit
func (s *AuthService) useOAuthRegistrationInvitation(ctx context.Context, invitationID, userID int64) error {
if client := s.oauthEmailFlowClient(ctx); client != nil {
affected, err := client.RedeemCode.Update().
Where(redeemcode.IDEQ(invitationID), redeemcode.StatusEQ(StatusUnused)).
Where(
redeemcode.IDEQ(invitationID),
redeemcode.StatusEQ(StatusUnused),
redeemcode.Or(redeemcode.ExpiresAtIsNil(), redeemcode.ExpiresAtGT(time.Now().UTC())),
).
SetStatus(StatusUsed).
SetUsedBy(userID).
SetUsedAt(time.Now().UTC()).
@@ -402,6 +407,11 @@ func (s *AuthService) updateOAuthRegistrationInvitation(ctx context.Context, cod
SetStatus(code.Status).
SetNotes(code.Notes).
SetValidityDays(code.ValidityDays)
if code.ExpiresAt != nil {
update = update.SetExpiresAt(*code.ExpiresAt)
} else {
update = update.ClearExpiresAt()
}
if code.UsedBy != nil {
update = update.SetUsedBy(*code.UsedBy)
} else {
+2 -2
View File
@@ -157,7 +157,7 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw
return "", nil, ErrInvitationCodeInvalid
}
// 检查类型和状态
if redeemCode.Type != RedeemTypeInvitation || redeemCode.Status != StatusUnused {
if redeemCode.Type != RedeemTypeInvitation || !redeemCode.CanUse() {
logger.LegacyPrintf("service.auth", "[Auth] Invitation code invalid: type=%s, status=%s", redeemCode.Type, redeemCode.Status)
return "", nil, ErrInvitationCodeInvalid
}
@@ -615,7 +615,7 @@ func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, ema
if err != nil {
return nil, nil, ErrInvitationCodeInvalid
}
if redeemCode.Type != RedeemTypeInvitation || redeemCode.Status != StatusUnused {
if redeemCode.Type != RedeemTypeInvitation || !redeemCode.CanUse() {
return nil, nil, ErrInvitationCodeInvalid
}
invitationRedeemCode = redeemCode
+16 -1
View File
@@ -16,6 +16,7 @@ type RedeemCode struct {
UsedAt *time.Time
Notes string
CreatedAt time.Time
ExpiresAt *time.Time
GroupID *int64
ValidityDays int
@@ -28,8 +29,22 @@ func (r *RedeemCode) IsUsed() bool {
return r.Status == StatusUsed
}
func (r *RedeemCode) IsExpired() bool {
return r.IsExpiredAt(time.Now())
}
func (r *RedeemCode) IsExpiredAt(now time.Time) bool {
if r == nil {
return false
}
if r.Status == StatusExpired {
return true
}
return r.Status == StatusUnused && r.ExpiresAt != nil && !r.ExpiresAt.After(now)
}
func (r *RedeemCode) CanUse() bool {
return r.Status == StatusUnused
return r.Status == StatusUnused && !r.IsExpired()
}
func GenerateRedeemCode() (string, error) {
@@ -0,0 +1,59 @@
package service
import (
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestRedeemCodeExpiry(t *testing.T) {
now := time.Now().UTC()
past := now.Add(-time.Hour)
future := now.Add(time.Hour)
tests := []struct {
name string
code RedeemCode
wantExpired bool
wantCanUse bool
}{
{
name: "unused without expiry can be used",
code: RedeemCode{Status: StatusUnused},
wantExpired: false,
wantCanUse: true,
},
{
name: "unused before expiry can be used",
code: RedeemCode{Status: StatusUnused, ExpiresAt: &future},
wantExpired: false,
wantCanUse: true,
},
{
name: "unused after expiry cannot be used",
code: RedeemCode{Status: StatusUnused, ExpiresAt: &past},
wantExpired: true,
wantCanUse: false,
},
{
name: "explicit expired status is expired",
code: RedeemCode{Status: StatusExpired},
wantExpired: true,
wantCanUse: false,
},
{
name: "used code remains used even after expiry time",
code: RedeemCode{Status: StatusUsed, ExpiresAt: &past},
wantExpired: false,
wantCanUse: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.wantExpired, tt.code.IsExpiredAt(now))
require.Equal(t, tt.wantCanUse, tt.code.CanUse())
})
}
}
+9 -1
View File
@@ -18,6 +18,7 @@ import (
var (
ErrRedeemCodeNotFound = infraerrors.NotFound("REDEEM_CODE_NOT_FOUND", "redeem code not found")
ErrRedeemCodeUsed = infraerrors.Conflict("REDEEM_CODE_USED", "redeem code already used")
ErrRedeemCodeExpired = infraerrors.Conflict("REDEEM_CODE_EXPIRED", "redeem code expired")
ErrInsufficientBalance = infraerrors.BadRequest("INSUFFICIENT_BALANCE", "insufficient balance")
ErrRedeemRateLimited = infraerrors.TooManyRequests("REDEEM_RATE_LIMITED", "too many failed attempts, please try again later")
ErrRedeemCodeLocked = infraerrors.Conflict("REDEEM_CODE_LOCKED", "redeem code is being processed, please try again")
@@ -207,6 +208,9 @@ func (s *RedeemService) CreateCode(ctx context.Context, code *RedeemCode) error
if code.Status == "" {
code.Status = StatusUnused
}
if code.IsExpired() {
return ErrRedeemCodeExpired
}
if err := s.redeemRepo.Create(ctx, code); err != nil {
return fmt.Errorf("create redeem code: %w", err)
@@ -289,7 +293,11 @@ func (s *RedeemService) Redeem(ctx context.Context, userID int64, code string) (
return nil, fmt.Errorf("get redeem code: %w", err)
}
// 检查兑换码状态
// 检查兑换码状态和码本身的过期时间
if redeemCode.IsExpired() {
s.incrementRedeemErrorCount(ctx, userID)
return nil, ErrRedeemCodeExpired
}
if !redeemCode.CanUse() {
s.incrementRedeemErrorCount(ctx, userID)
return nil, ErrRedeemCodeUsed