mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
Merge pull request #2573 from wucm667/feat/redeem-code-expiry
feat(redeem): 兑换码支持设置使用有效期
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user