From 372436323a70e75185e6c831b9df252bfdc4dd8d Mon Sep 17 00:00:00 2001 From: jianjian Date: Thu, 2 Jul 2026 16:38:42 +0000 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=82=80=E8=AF=B7=E7=A0=81?= =?UTF-8?q?=E6=99=AE=E9=80=9A=E5=85=91=E6=8D=A2=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/service/redeem_service.go | 21 +++- .../service/redeem_service_redeem_test.go | 102 ++++++++++++++++++ 2 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 backend/internal/service/redeem_service_redeem_test.go diff --git a/backend/internal/service/redeem_service.go b/backend/internal/service/redeem_service.go index 8db0d7daa6..2d1962dd3c 100644 --- a/backend/internal/service/redeem_service.go +++ b/backend/internal/service/redeem_service.go @@ -374,6 +374,13 @@ func (s *RedeemService) releaseRedeemLock(ctx context.Context, code string) { _ = s.cache.ReleaseRedeemLock(ctx, code) } +func unsupportedRedeemTypeError(codeType string) error { + if codeType == RedeemTypeInvitation { + return infraerrors.BadRequest("REDEEM_CODE_UNSUPPORTED_TYPE", "invitation codes can only be used during registration") + } + return infraerrors.BadRequest("REDEEM_CODE_UNSUPPORTED_TYPE", fmt.Sprintf("unsupported redeem type: %s", codeType)) +} + // Redeem 使用兑换码 func (s *RedeemService) Redeem(ctx context.Context, userID int64, code string) (*RedeemCode, error) { // 检查限流 @@ -407,9 +414,15 @@ func (s *RedeemService) Redeem(ctx context.Context, userID int64, code string) ( return nil, ErrRedeemCodeUsed } - // 验证兑换码类型的前置条件 - if redeemCode.Type == RedeemTypeSubscription && redeemCode.GroupID == nil { - return nil, infraerrors.BadRequest("REDEEM_CODE_INVALID", "invalid subscription redeem code: missing group_id") + // 验证兑换码类型的前置条件。邀请码属于注册流程,不能通过普通兑换接口使用。 + switch redeemCode.Type { + case RedeemTypeBalance, RedeemTypeConcurrency: + case RedeemTypeSubscription: + if redeemCode.GroupID == nil { + return nil, infraerrors.BadRequest("REDEEM_CODE_INVALID", "invalid subscription redeem code: missing group_id") + } + default: + return nil, unsupportedRedeemTypeError(redeemCode.Type) } // 获取用户信息 @@ -483,7 +496,7 @@ func (s *RedeemService) Redeem(ctx context.Context, userID int64, code string) ( } default: - return nil, fmt.Errorf("unsupported redeem type: %s", redeemCode.Type) + return nil, unsupportedRedeemTypeError(redeemCode.Type) } // 提交事务 diff --git a/backend/internal/service/redeem_service_redeem_test.go b/backend/internal/service/redeem_service_redeem_test.go new file mode 100644 index 0000000000..7cc4bbd130 --- /dev/null +++ b/backend/internal/service/redeem_service_redeem_test.go @@ -0,0 +1,102 @@ +package service + +import ( + "context" + "testing" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/stretchr/testify/require" +) + +type redeemRejectRepo struct { + code RedeemCode + useCalled bool +} + +func (r *redeemRejectRepo) Create(ctx context.Context, code *RedeemCode) error { + panic("unexpected Create call") +} + +func (r *redeemRejectRepo) CreateBatch(ctx context.Context, codes []RedeemCode) error { + panic("unexpected CreateBatch call") +} + +func (r *redeemRejectRepo) GetByID(ctx context.Context, id int64) (*RedeemCode, error) { + if r.code.ID != id { + return nil, ErrRedeemCodeNotFound + } + clone := r.code + return &clone, nil +} + +func (r *redeemRejectRepo) GetByCode(ctx context.Context, code string) (*RedeemCode, error) { + if r.code.Code != code { + return nil, ErrRedeemCodeNotFound + } + clone := r.code + return &clone, nil +} + +func (r *redeemRejectRepo) Update(ctx context.Context, code *RedeemCode) error { + panic("unexpected Update call") +} + +func (r *redeemRejectRepo) BatchUpdate(ctx context.Context, ids []int64, fields RedeemCodeBatchUpdateFields) (int64, error) { + panic("unexpected BatchUpdate call") +} + +func (r *redeemRejectRepo) Delete(ctx context.Context, id int64) error { + panic("unexpected Delete call") +} + +func (r *redeemRejectRepo) Use(ctx context.Context, id, userID int64) error { + r.useCalled = true + r.code.Status = StatusUsed + r.code.UsedBy = &userID + return nil +} + +func (r *redeemRejectRepo) List(ctx context.Context, params pagination.PaginationParams) ([]RedeemCode, *pagination.PaginationResult, error) { + panic("unexpected List call") +} + +func (r *redeemRejectRepo) ListWithFilters(ctx context.Context, params pagination.PaginationParams, codeType, status, search string) ([]RedeemCode, *pagination.PaginationResult, error) { + panic("unexpected ListWithFilters call") +} + +func (r *redeemRejectRepo) ListByUser(ctx context.Context, userID int64, limit int) ([]RedeemCode, error) { + panic("unexpected ListByUser call") +} + +func (r *redeemRejectRepo) ListByUserPaginated(ctx context.Context, userID int64, params pagination.PaginationParams, codeType string) ([]RedeemCode, *pagination.PaginationResult, error) { + panic("unexpected ListByUserPaginated call") +} + +func (r *redeemRejectRepo) SumPositiveBalanceByUser(ctx context.Context, userID int64) (float64, error) { + panic("unexpected SumPositiveBalanceByUser call") +} + +func TestRedeemRejectsInvitationCodeBeforeTransaction(t *testing.T) { + ctx := context.Background() + redeemRepo := &redeemRejectRepo{ + code: RedeemCode{ + ID: 1, + Code: "INVITE-001", + Type: RedeemTypeInvitation, + Status: StatusUnused, + }, + } + redeemService := NewRedeemService(redeemRepo, nil, nil, nil, nil, nil, nil, nil) + + got, err := redeemService.Redeem(ctx, 2, redeemRepo.code.Code) + + require.Nil(t, got) + require.Error(t, err) + require.True(t, infraerrors.IsBadRequest(err)) + require.Equal(t, "REDEEM_CODE_UNSUPPORTED_TYPE", infraerrors.Reason(err)) + require.Equal(t, "invitation codes can only be used during registration", infraerrors.Message(err)) + require.False(t, redeemRepo.useCalled) + require.Equal(t, StatusUnused, redeemRepo.code.Status) + require.Nil(t, redeemRepo.code.UsedBy) +}