diff --git a/backend/internal/handler/admin/redeem_handler.go b/backend/internal/handler/admin/redeem_handler.go index 7b4300b1..5dca01d5 100644 --- a/backend/internal/handler/admin/redeem_handler.go +++ b/backend/internal/handler/admin/redeem_handler.go @@ -307,6 +307,51 @@ func (h *RedeemHandler) BatchDelete(c *gin.Context) { }) } +// BatchUpdate handles batch updating redeem codes +// POST /api/v1/admin/redeem-codes/batch-update +func (h *RedeemHandler) BatchUpdate(c *gin.Context) { + if h.redeemService == nil { + response.InternalError(c, "redeem service not configured") + return + } + + var req dto.BatchUpdateRedeemCodesRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + result, err := h.redeemService.BatchUpdate(c.Request.Context(), &service.RedeemCodeBatchUpdateInput{ + IDs: req.IDs, + Fields: redeemBatchUpdateFieldsFromDTO(req.Fields), + }) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, gin.H{ + "updated": result.Updated, + "message": "Redeem codes updated successfully", + }) +} + +func redeemBatchUpdateFieldsFromDTO(in dto.BatchUpdateRedeemCodeFields) service.RedeemCodeBatchUpdateFields { + out := service.RedeemCodeBatchUpdateFields{ + Status: in.Status, + Notes: in.Notes, + Type: in.Type, + Value: in.Value, + } + if in.ExpiresAt.Set { + out.ExpiresAt = service.NullableTimeUpdate{Set: true, Value: in.ExpiresAt.Value} + } + if in.GroupID.Set { + out.GroupID = service.NullableInt64Update{Set: true, Value: in.GroupID.Value} + } + return out +} + // Expire handles expiring a redeem code // POST /api/v1/admin/redeem-codes/:id/expire func (h *RedeemHandler) Expire(c *gin.Context) { diff --git a/backend/internal/handler/auth_oauth_pending_flow_test.go b/backend/internal/handler/auth_oauth_pending_flow_test.go index 584e5751..4081b9e4 100644 --- a/backend/internal/handler/auth_oauth_pending_flow_test.go +++ b/backend/internal/handler/auth_oauth_pending_flow_test.go @@ -2492,6 +2492,10 @@ func (r *oauthPendingFlowRedeemCodeRepo) Update(ctx context.Context, code *servi return err } +func (r *oauthPendingFlowRedeemCodeRepo) BatchUpdate(context.Context, []int64, service.RedeemCodeBatchUpdateFields) (int64, error) { + panic("unexpected BatchUpdate call") +} + func (r *oauthPendingFlowRedeemCodeRepo) Delete(context.Context, int64) error { panic("unexpected Delete call") } diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index cc360f78..31828375 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -1,6 +1,8 @@ package dto import ( + "bytes" + "encoding/json" "time" "github.com/Wei-Shaw/sub2api/internal/domain" @@ -359,6 +361,59 @@ type AdminRedeemCode struct { Notes string `json:"notes"` } +type NullableTimeField struct { + Set bool + Value *time.Time +} + +func (f *NullableTimeField) UnmarshalJSON(data []byte) error { + f.Set = true + if bytes.Equal(data, []byte("null")) { + f.Value = nil + return nil + } + var value time.Time + if err := json.Unmarshal(data, &value); err != nil { + return err + } + f.Value = &value + return nil +} + +type NullableInt64Field struct { + Set bool + Value *int64 +} + +func (f *NullableInt64Field) UnmarshalJSON(data []byte) error { + f.Set = true + if bytes.Equal(data, []byte("null")) { + f.Value = nil + return nil + } + var value int64 + if err := json.Unmarshal(data, &value); err != nil { + return err + } + f.Value = &value + return nil +} + +type BatchUpdateRedeemCodeFields struct { + Status *string `json:"status,omitempty"` + ExpiresAt NullableTimeField `json:"expires_at,omitempty"` + Notes *string `json:"notes,omitempty"` + GroupID NullableInt64Field `json:"group_id,omitempty"` + + Type *string `json:"type,omitempty"` + Value *float64 `json:"value,omitempty"` +} + +type BatchUpdateRedeemCodesRequest struct { + IDs []int64 `json:"ids" binding:"required,min=1"` + Fields BatchUpdateRedeemCodeFields `json:"fields" binding:"required"` +} + // UsageLog 是普通用户接口使用的 usage log DTO(不包含管理员字段)。 type UsageLog struct { ID int64 `json:"id"` diff --git a/backend/internal/repository/redeem_code_repo.go b/backend/internal/repository/redeem_code_repo.go index 47c38d3e..2bdb34b4 100644 --- a/backend/internal/repository/redeem_code_repo.go +++ b/backend/internal/repository/redeem_code_repo.go @@ -236,6 +236,91 @@ func (r *redeemCodeRepository) Update(ctx context.Context, code *service.RedeemC return nil } +func (r *redeemCodeRepository) BatchUpdate(ctx context.Context, ids []int64, fields service.RedeemCodeBatchUpdateFields) (int64, error) { + uniqueIDs := make([]int64, 0, len(ids)) + seen := make(map[int64]struct{}, len(ids)) + for _, id := range ids { + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + uniqueIDs = append(uniqueIDs, id) + } + if len(uniqueIDs) == 0 { + return 0, nil + } + + if tx := dbent.TxFromContext(ctx); tx != nil { + return r.batchUpdate(ctx, tx.Client(), uniqueIDs, fields) + } + + tx, err := r.client.Tx(ctx) + if err != nil { + return 0, err + } + txCtx := dbent.NewTxContext(ctx, tx) + defer func() { _ = tx.Rollback() }() + + updated, err := r.batchUpdate(txCtx, tx.Client(), uniqueIDs, fields) + if err != nil { + return 0, err + } + if err := tx.Commit(); err != nil { + return 0, err + } + return updated, nil +} + +func (r *redeemCodeRepository) batchUpdate(ctx context.Context, client *dbent.Client, ids []int64, fields service.RedeemCodeBatchUpdateFields) (int64, error) { + existing, err := client.RedeemCode.Query(). + Where(redeemcode.IDIn(ids...)). + All(ctx) + if err != nil { + return 0, err + } + if len(existing) != len(ids) { + return 0, service.ErrRedeemCodeNotFound + } + if fields.TouchesUsedSensitiveFields() { + for _, code := range existing { + if code.Status == service.StatusUsed { + return 0, service.ErrRedeemCodeUsed + } + } + } + + up := client.RedeemCode.Update().Where(redeemcode.IDIn(ids...)) + if fields.Status != nil { + up.SetStatus(*fields.Status) + } + if fields.Notes != nil { + up.SetNotes(*fields.Notes) + } + if fields.ExpiresAt.Set { + if fields.ExpiresAt.Value != nil { + up.SetExpiresAt(*fields.ExpiresAt.Value) + } else { + up.ClearExpiresAt() + } + } + if fields.GroupID.Set { + if fields.GroupID.Value != nil { + up.SetGroupID(*fields.GroupID.Value) + } else { + up.ClearGroupID() + } + } + + affected, err := up.Save(ctx) + if err != nil { + return 0, err + } + if affected != len(ids) { + return 0, service.ErrRedeemCodeNotFound + } + return int64(affected), nil +} + func (r *redeemCodeRepository) Use(ctx context.Context, id, userID int64) error { now := time.Now() client := clientFromContext(ctx, r.client) diff --git a/backend/internal/repository/redeem_code_repo_integration_test.go b/backend/internal/repository/redeem_code_repo_integration_test.go index 24e5910e..99dc609e 100644 --- a/backend/internal/repository/redeem_code_repo_integration_test.go +++ b/backend/internal/repository/redeem_code_repo_integration_test.go @@ -4,6 +4,7 @@ package repository import ( "context" + "errors" "testing" "time" @@ -21,8 +22,8 @@ type RedeemCodeRepoSuite struct { } func (s *RedeemCodeRepoSuite) SetupTest() { - s.ctx = context.Background() tx := testEntTx(s.T()) + s.ctx = dbent.NewTxContext(context.Background(), tx) s.client = tx.Client() s.repo = NewRedeemCodeRepository(s.client).(*redeemCodeRepository) } @@ -237,6 +238,123 @@ func (s *RedeemCodeRepoSuite) TestUpdate() { s.Require().Equal(float64(50), got.Value) } +func (s *RedeemCodeRepoSuite) TestBatchUpdate_PartialFieldsAndClear() { + group := s.createGroup(uniqueTestValue(s.T(), "batch-update-group")) + groupID := group.ID + expiresAt := time.Now().UTC().Add(2 * time.Hour) + status := service.StatusDisabled + notes := "batch note" + + codeA := &service.RedeemCode{ + Code: "BATCH-UP-A", + Type: service.RedeemTypeBalance, + Value: 10, + Status: service.StatusUnused, + Notes: "old", + } + codeB := &service.RedeemCode{ + Code: "BATCH-UP-B", + Type: service.RedeemTypeBalance, + Value: 20, + Status: service.StatusUnused, + Notes: "old", + } + untouched := &service.RedeemCode{ + Code: "BATCH-UP-C", + Type: service.RedeemTypeBalance, + Value: 30, + Status: service.StatusUnused, + Notes: "keep", + } + s.Require().NoError(s.repo.Create(s.ctx, codeA)) + s.Require().NoError(s.repo.Create(s.ctx, codeB)) + s.Require().NoError(s.repo.Create(s.ctx, untouched)) + + updated, err := s.repo.BatchUpdate(s.ctx, []int64{codeA.ID, codeB.ID}, service.RedeemCodeBatchUpdateFields{ + Status: &status, + ExpiresAt: service.NullableTimeUpdate{Set: true, Value: &expiresAt}, + Notes: ¬es, + GroupID: service.NullableInt64Update{Set: true, Value: &groupID}, + }) + s.Require().NoError(err) + s.Require().Equal(int64(2), updated) + + gotA, err := s.repo.GetByID(s.ctx, codeA.ID) + s.Require().NoError(err) + s.Require().Equal("BATCH-UP-A", gotA.Code) + s.Require().Equal(service.RedeemTypeBalance, gotA.Type) + s.Require().Equal(float64(10), gotA.Value) + s.Require().Equal(service.StatusDisabled, gotA.Status) + s.Require().Equal(notes, gotA.Notes) + s.Require().NotNil(gotA.ExpiresAt) + s.Require().WithinDuration(expiresAt, *gotA.ExpiresAt, time.Second) + s.Require().NotNil(gotA.GroupID) + s.Require().Equal(groupID, *gotA.GroupID) + + gotB, err := s.repo.GetByID(s.ctx, codeB.ID) + s.Require().NoError(err) + s.Require().Equal(service.StatusDisabled, gotB.Status) + s.Require().Equal(notes, gotB.Notes) + + gotUntouched, err := s.repo.GetByID(s.ctx, untouched.ID) + s.Require().NoError(err) + s.Require().Equal(service.StatusUnused, gotUntouched.Status) + s.Require().Equal("keep", gotUntouched.Notes) + s.Require().Nil(gotUntouched.ExpiresAt) + s.Require().Nil(gotUntouched.GroupID) + + updated, err = s.repo.BatchUpdate(s.ctx, []int64{codeA.ID}, service.RedeemCodeBatchUpdateFields{ + ExpiresAt: service.NullableTimeUpdate{Set: true}, + GroupID: service.NullableInt64Update{Set: true}, + }) + s.Require().NoError(err) + s.Require().Equal(int64(1), updated) + + gotA, err = s.repo.GetByID(s.ctx, codeA.ID) + s.Require().NoError(err) + s.Require().Nil(gotA.ExpiresAt) + s.Require().Nil(gotA.GroupID) +} + +func (s *RedeemCodeRepoSuite) TestBatchUpdate_InvalidIDRollsBack() { + code := &service.RedeemCode{ + Code: "BATCH-UP-ROLLBACK", + Type: service.RedeemTypeBalance, + Value: 10, + Status: service.StatusUnused, + Notes: "keep", + } + s.Require().NoError(s.repo.Create(s.ctx, code)) + notes := "changed" + + _, err := s.repo.BatchUpdate(s.ctx, []int64{code.ID, 999999}, service.RedeemCodeBatchUpdateFields{Notes: ¬es}) + s.Require().Error(err) + s.Require().True(errors.Is(err, service.ErrRedeemCodeNotFound)) + + got, getErr := s.repo.GetByID(s.ctx, code.ID) + s.Require().NoError(getErr) + s.Require().Equal("keep", got.Notes) +} + +func (s *RedeemCodeRepoSuite) TestBatchUpdate_UsedCodeRejectsSensitiveFields() { + code := &service.RedeemCode{ + Code: "BATCH-UP-USED", + Type: service.RedeemTypeBalance, + Value: 10, + Status: service.StatusUsed, + } + s.Require().NoError(s.repo.Create(s.ctx, code)) + status := service.StatusDisabled + + _, err := s.repo.BatchUpdate(s.ctx, []int64{code.ID}, service.RedeemCodeBatchUpdateFields{Status: &status}) + s.Require().Error(err) + s.Require().True(errors.Is(err, service.ErrRedeemCodeUsed)) + + got, getErr := s.repo.GetByID(s.ctx, code.ID) + s.Require().NoError(getErr) + s.Require().Equal(service.StatusUsed, got.Status) +} + // --- Use --- func (s *RedeemCodeRepoSuite) TestUse() { diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 10404276..4948d2f1 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -1849,6 +1849,10 @@ func (stubRedeemCodeRepo) Update(ctx context.Context, code *service.RedeemCode) return errors.New("not implemented") } +func (stubRedeemCodeRepo) BatchUpdate(ctx context.Context, ids []int64, fields service.RedeemCodeBatchUpdateFields) (int64, error) { + return int64(len(ids)), nil +} + func (stubRedeemCodeRepo) Delete(ctx context.Context, id int64) error { return errors.New("not implemented") } diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index f7215414..e036cf32 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -393,6 +393,7 @@ func registerRedeemCodeRoutes(admin *gin.RouterGroup, h *handler.Handlers) { codes.POST("/generate", h.Admin.Redeem.Generate) codes.DELETE("/:id", h.Admin.Redeem.Delete) codes.POST("/batch-delete", h.Admin.Redeem.BatchDelete) + codes.POST("/batch-update", h.Admin.Redeem.BatchUpdate) codes.POST("/:id/expire", h.Admin.Redeem.Expire) } } diff --git a/backend/internal/service/admin_service_delete_test.go b/backend/internal/service/admin_service_delete_test.go index a9492a1d..2f764d67 100644 --- a/backend/internal/service/admin_service_delete_test.go +++ b/backend/internal/service/admin_service_delete_test.go @@ -244,6 +244,21 @@ func (s *groupRepoStub) UpdateSortOrders(ctx context.Context, updates []GroupSor return nil } +type deleteGroupAPIKeyRepoStub struct { + apiKeyRepoStubForGroupUpdate + keys []string + listErr error + listGroupIDs []int64 +} + +func (s *deleteGroupAPIKeyRepoStub) ListKeysByGroupID(ctx context.Context, groupID int64) ([]string, error) { + s.listGroupIDs = append(s.listGroupIDs, groupID) + if s.listErr != nil { + return nil, s.listErr + } + return s.keys, nil +} + type proxyRepoStub struct { deleteErr error countErr error @@ -310,6 +325,12 @@ func (s *proxyRepoStub) ListAccountSummariesByProxyID(ctx context.Context, proxy type redeemRepoStub struct { deleteErrByID map[int64]error deletedIDs []int64 + + batchUpdateIDs []int64 + batchUpdateFields RedeemCodeBatchUpdateFields + batchUpdateResult int64 + batchUpdateErr error + batchUpdateCalled bool } func (s *redeemRepoStub) Create(ctx context.Context, code *RedeemCode) error { @@ -332,6 +353,19 @@ func (s *redeemRepoStub) Update(ctx context.Context, code *RedeemCode) error { panic("unexpected Update call") } +func (s *redeemRepoStub) BatchUpdate(ctx context.Context, ids []int64, fields RedeemCodeBatchUpdateFields) (int64, error) { + s.batchUpdateCalled = true + s.batchUpdateIDs = append([]int64(nil), ids...) + s.batchUpdateFields = fields + if s.batchUpdateErr != nil { + return 0, s.batchUpdateErr + } + if s.batchUpdateResult != 0 { + return s.batchUpdateResult, nil + } + return int64(len(ids)), nil +} + func (s *redeemRepoStub) Delete(ctx context.Context, id int64) error { s.deletedIDs = append(s.deletedIDs, id) if s.deleteErrByID != nil { @@ -500,6 +534,23 @@ func TestAdminService_DeleteGroup_Success_WithCacheInvalidation(t *testing.T) { }, calls) } +func TestAdminService_DeleteGroup_InvalidatesAuthCacheForBoundKeys(t *testing.T) { + repo := &groupRepoStub{} + apiKeyRepo := &deleteGroupAPIKeyRepoStub{keys: []string{"k1", "k2"}} + invalidator := &authCacheInvalidatorStub{} + svc := &adminServiceImpl{ + groupRepo: repo, + apiKeyRepo: apiKeyRepo, + authCacheInvalidator: invalidator, + } + + err := svc.DeleteGroup(context.Background(), 5) + require.NoError(t, err) + require.Equal(t, []int64{5}, repo.deleteCalls) + require.Equal(t, []int64{5}, apiKeyRepo.listGroupIDs) + require.Equal(t, []string{"k1", "k2"}, invalidator.keys) +} + func TestAdminService_DeleteGroup_NotFound(t *testing.T) { repo := &groupRepoStub{deleteErr: ErrGroupNotFound} svc := &adminServiceImpl{groupRepo: repo} diff --git a/backend/internal/service/auth_oauth_email_flow_test.go b/backend/internal/service/auth_oauth_email_flow_test.go index cd76c6b7..3c02587b 100644 --- a/backend/internal/service/auth_oauth_email_flow_test.go +++ b/backend/internal/service/auth_oauth_email_flow_test.go @@ -59,6 +59,10 @@ func (s *redeemCodeRepoStub) Update(_ context.Context, code *RedeemCode) error { return nil } +func (s *redeemCodeRepoStub) BatchUpdate(context.Context, []int64, RedeemCodeBatchUpdateFields) (int64, error) { + panic("unexpected BatchUpdate call") +} + func (s *redeemCodeRepoStub) Delete(context.Context, int64) error { panic("unexpected Delete call") } diff --git a/backend/internal/service/payment_order_lifecycle_test.go b/backend/internal/service/payment_order_lifecycle_test.go index 1964cdf6..658a1806 100644 --- a/backend/internal/service/payment_order_lifecycle_test.go +++ b/backend/internal/service/payment_order_lifecycle_test.go @@ -115,6 +115,10 @@ func (r *paymentOrderLifecycleRedeemRepo) Update(context.Context, *RedeemCode) e panic("unexpected call") } +func (r *paymentOrderLifecycleRedeemRepo) BatchUpdate(context.Context, []int64, RedeemCodeBatchUpdateFields) (int64, error) { + panic("unexpected call") +} + func (r *paymentOrderLifecycleRedeemRepo) Delete(context.Context, int64) error { panic("unexpected call") } diff --git a/backend/internal/service/redeem_service.go b/backend/internal/service/redeem_service.go index 73aa02b1..8db0d7da 100644 --- a/backend/internal/service/redeem_service.go +++ b/backend/internal/service/redeem_service.go @@ -54,6 +54,7 @@ type RedeemCodeRepository interface { GetByID(ctx context.Context, id int64) (*RedeemCode, error) GetByCode(ctx context.Context, code string) (*RedeemCode, error) Update(ctx context.Context, code *RedeemCode) error + BatchUpdate(ctx context.Context, ids []int64, fields RedeemCodeBatchUpdateFields) (int64, error) Delete(ctx context.Context, id int64) error Use(ctx context.Context, id, userID int64) error @@ -82,6 +83,54 @@ type RedeemCodeResponse struct { CreatedAt time.Time `json:"created_at"` } +type NullableTimeUpdate struct { + Set bool + Value *time.Time +} + +type NullableInt64Update struct { + Set bool + Value *int64 +} + +type RedeemCodeBatchUpdateFields struct { + Status *string + ExpiresAt NullableTimeUpdate + Notes *string + GroupID NullableInt64Update + + // Core fields are intentionally modeled only so service validation can + // reject payloads that try to mutate redemption value semantics in bulk. + Type *string + Value *float64 +} + +func (f RedeemCodeBatchUpdateFields) HasChanges() bool { + return f.Status != nil || + f.ExpiresAt.Set || + f.Notes != nil || + f.GroupID.Set || + f.Type != nil || + f.Value != nil +} + +func (f RedeemCodeBatchUpdateFields) HasCoreFieldChanges() bool { + return f.Type != nil || f.Value != nil +} + +func (f RedeemCodeBatchUpdateFields) TouchesUsedSensitiveFields() bool { + return f.Status != nil || f.ExpiresAt.Set || f.GroupID.Set +} + +type RedeemCodeBatchUpdateInput struct { + IDs []int64 + Fields RedeemCodeBatchUpdateFields +} + +type RedeemCodeBatchUpdateResult struct { + Updated int64 `json:"updated"` +} + // RedeemService 兑换码服务 type RedeemService struct { redeemRepo RedeemCodeRepository @@ -218,6 +267,61 @@ func (s *RedeemService) CreateCode(ctx context.Context, code *RedeemCode) error return nil } +func (s *RedeemService) BatchUpdate(ctx context.Context, input *RedeemCodeBatchUpdateInput) (*RedeemCodeBatchUpdateResult, error) { + if input == nil { + return nil, infraerrors.BadRequest("REDEEM_CODE_BATCH_UPDATE_INVALID", "batch update input is required") + } + if len(input.IDs) == 0 { + return nil, infraerrors.BadRequest("REDEEM_CODE_BATCH_UPDATE_IDS_REQUIRED", "ids are required") + } + if !input.Fields.HasChanges() { + return nil, infraerrors.BadRequest("REDEEM_CODE_BATCH_UPDATE_EMPTY", "at least one field must be selected") + } + if input.Fields.HasCoreFieldChanges() { + return nil, infraerrors.BadRequest("REDEEM_CODE_CORE_FIELDS_IMMUTABLE", "type and value cannot be batch updated") + } + + ids := make([]int64, 0, len(input.IDs)) + seen := make(map[int64]struct{}, len(input.IDs)) + for _, id := range input.IDs { + if id <= 0 { + return nil, infraerrors.BadRequest("REDEEM_CODE_BATCH_UPDATE_INVALID_ID", "ids must be positive") + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + if len(ids) == 0 { + return nil, infraerrors.BadRequest("REDEEM_CODE_BATCH_UPDATE_IDS_REQUIRED", "ids are required") + } + + if input.Fields.Status != nil { + switch *input.Fields.Status { + case StatusUnused, StatusDisabled: + default: + return nil, infraerrors.BadRequest("REDEEM_CODE_STATUS_INVALID", "status must be unused or disabled") + } + } + if input.Fields.ExpiresAt.Set && input.Fields.ExpiresAt.Value != nil { + expiresAt := input.Fields.ExpiresAt.Value.UTC() + if !expiresAt.After(time.Now().UTC()) { + return nil, infraerrors.BadRequest("REDEEM_CODE_EXPIRES_AT_INVALID", "expires_at must be in the future") + } + input.Fields.ExpiresAt.Value = &expiresAt + } + if input.Fields.GroupID.Set && input.Fields.GroupID.Value != nil && *input.Fields.GroupID.Value <= 0 { + return nil, infraerrors.BadRequest("REDEEM_CODE_GROUP_ID_INVALID", "group_id must be positive") + } + + updated, err := s.redeemRepo.BatchUpdate(ctx, ids, input.Fields) + if err != nil { + return nil, err + } + return &RedeemCodeBatchUpdateResult{Updated: updated}, nil +} + // checkRedeemRateLimit 检查用户兑换错误次数是否超限 func (s *RedeemService) checkRedeemRateLimit(ctx context.Context, userID int64) error { if s.cache == nil { diff --git a/backend/internal/service/redeem_service_batch_update_test.go b/backend/internal/service/redeem_service_batch_update_test.go new file mode 100644 index 00000000..e54019cc --- /dev/null +++ b/backend/internal/service/redeem_service_batch_update_test.go @@ -0,0 +1,75 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/stretchr/testify/require" +) + +func TestRedeemService_BatchUpdate_PartialFields(t *testing.T) { + status := StatusDisabled + notes := "maintenance window" + expiresAt := time.Now().UTC().Add(24 * time.Hour) + repo := &redeemRepoStub{} + svc := &RedeemService{redeemRepo: repo} + + result, err := svc.BatchUpdate(context.Background(), &RedeemCodeBatchUpdateInput{ + IDs: []int64{1, 2, 2}, + Fields: RedeemCodeBatchUpdateFields{ + Status: &status, + ExpiresAt: NullableTimeUpdate{Set: true, Value: &expiresAt}, + Notes: ¬es, + }, + }) + + require.NoError(t, err) + require.Equal(t, int64(2), result.Updated) + require.True(t, repo.batchUpdateCalled) + require.Equal(t, []int64{1, 2}, repo.batchUpdateIDs) + require.Equal(t, &status, repo.batchUpdateFields.Status) + require.True(t, repo.batchUpdateFields.ExpiresAt.Set) + require.WithinDuration(t, expiresAt, *repo.batchUpdateFields.ExpiresAt.Value, time.Second) + require.Equal(t, ¬es, repo.batchUpdateFields.Notes) + require.False(t, repo.batchUpdateFields.GroupID.Set) + require.Nil(t, repo.batchUpdateFields.Type) + require.Nil(t, repo.batchUpdateFields.Value) +} + +func TestRedeemService_BatchUpdate_RejectsInvalidID(t *testing.T) { + repo := &redeemRepoStub{} + svc := &RedeemService{redeemRepo: repo} + notes := "bad id" + + result, err := svc.BatchUpdate(context.Background(), &RedeemCodeBatchUpdateInput{ + IDs: []int64{1, 0}, + Fields: RedeemCodeBatchUpdateFields{Notes: ¬es}, + }) + + require.Nil(t, result) + require.Error(t, err) + require.True(t, infraerrors.IsBadRequest(err)) + require.False(t, repo.batchUpdateCalled) +} + +func TestRedeemService_BatchUpdate_RejectsCoreFieldsForUsedCodes(t *testing.T) { + repo := &redeemRepoStub{} + svc := &RedeemService{redeemRepo: repo} + newValue := 100.0 + + result, err := svc.BatchUpdate(context.Background(), &RedeemCodeBatchUpdateInput{ + IDs: []int64{42}, + Fields: RedeemCodeBatchUpdateFields{ + Value: &newValue, + }, + }) + + require.Nil(t, result) + require.Error(t, err) + require.True(t, infraerrors.IsBadRequest(err)) + require.False(t, repo.batchUpdateCalled) +} diff --git a/frontend/src/api/admin/redeem.ts b/frontend/src/api/admin/redeem.ts index 398d68a4..777bb110 100644 --- a/frontend/src/api/admin/redeem.ts +++ b/frontend/src/api/admin/redeem.ts @@ -7,6 +7,7 @@ import { apiClient } from '../client' import type { RedeemCode, GenerateRedeemCodesRequest, + BatchUpdateRedeemCodeFields, RedeemCodeType, PaginatedResponse } from '@/types' @@ -23,7 +24,7 @@ export async function list( pageSize: number = 20, filters?: { type?: RedeemCodeType - status?: 'active' | 'used' | 'expired' | 'unused' + status?: 'active' | 'used' | 'expired' | 'unused' | 'disabled' search?: string sort_by?: string sort_order?: 'asc' | 'desc' @@ -118,6 +119,26 @@ export async function batchDelete(ids: number[]): Promise<{ return data } +/** + * Batch update selected redeem code fields + * @param ids - Array of redeem code IDs + * @param fields - Field collection to update + * @returns Updated count + */ +export async function batchUpdate( + ids: number[], + fields: BatchUpdateRedeemCodeFields +): Promise<{ + updated: number + message: string +}> { + const { data } = await apiClient.post<{ + updated: number + message: string + }>('/admin/redeem-codes/batch-update', { ids, fields }) + return data +} + /** * Expire redeem code * @param id - Redeem code ID @@ -158,7 +179,7 @@ export async function getStats(): Promise<{ */ export async function exportCodes(filters?: { type?: RedeemCodeType - status?: 'used' | 'expired' | 'unused' + status?: 'used' | 'expired' | 'unused' | 'disabled' search?: string sort_by?: string sort_order?: 'asc' | 'desc' @@ -176,6 +197,7 @@ export const redeemAPI = { generate, delete: deleteCode, batchDelete, + batchUpdate, expire, getStats, exportCodes diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 5f0fe6f5..324d7b75 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -4171,6 +4171,22 @@ export default { codeDeleted: 'Redeem code deleted successfully', codesDeleted: 'Successfully deleted {count} unused code(s)', noUnusedCodes: 'No unused codes to delete', + selectedCount: '{count} selected', + clearSelection: 'Clear selection', + batchUpdate: 'Batch update', + batchUpdateTitle: 'Batch update redeem codes', + batchUpdateSuccess: 'Successfully updated {count} redeem code(s)', + failedToBatchUpdate: 'Failed to batch update redeem codes', + selectCodesFirst: 'Please select redeem codes first', + noBatchFieldsSelected: 'Select at least one field to update', + batchFields: { + status: 'Status', + expiresAt: 'Expires At', + notes: 'Notes', + group: 'Group' + }, + batchNotesPlaceholder: 'Leave empty to clear notes', + clearGroup: 'Clear group', failedToLoad: 'Failed to load redeem codes', failedToGenerate: 'Failed to generate codes', failedToExport: 'Failed to export codes', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index a03435db..4c1019aa 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -4300,6 +4300,22 @@ export default { codeDeleted: '兑换码删除成功', codesDeleted: '成功删除 {count} 个未使用的兑换码', noUnusedCodes: '没有未使用的兑换码可删除', + selectedCount: '已选 {count} 个', + clearSelection: '清空选择', + batchUpdate: '批量修改', + batchUpdateTitle: '批量修改兑换码', + batchUpdateSuccess: '成功修改 {count} 个兑换码', + failedToBatchUpdate: '批量修改兑换码失败', + selectCodesFirst: '请先选择兑换码', + noBatchFieldsSelected: '请选择至少一个要修改的字段', + batchFields: { + status: '状态', + expiresAt: '过期时间', + notes: '备注', + group: '分组' + }, + batchNotesPlaceholder: '留空将清空备注', + clearGroup: '清除分组', userPrefix: '用户 #{id}', failedToExport: '导出兑换码失败', failedToDeleteUnused: '删除未使用的兑换码失败', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 82b6ed29..63b9b14f 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1289,12 +1289,13 @@ export interface RedeemCode { code: string type: RedeemCodeType value: number - status: 'active' | 'used' | 'expired' | 'unused' + status: 'active' | 'used' | 'expired' | 'unused' | 'disabled' used_by: number | null used_at: string | null created_at: string expires_at?: string | null updated_at?: string + notes?: string group_id?: number | null // 订阅类型专用 validity_days?: number // 订阅类型专用 user?: User @@ -1311,6 +1312,18 @@ export interface GenerateRedeemCodesRequest { expires_in_days?: number } +export interface BatchUpdateRedeemCodeFields { + status?: 'unused' | 'disabled' + expires_at?: string | null + notes?: string + group_id?: number | null +} + +export interface BatchUpdateRedeemCodesRequest { + ids: number[] + fields: BatchUpdateRedeemCodeFields +} + export interface RedeemCodeRequest { code: string } diff --git a/frontend/src/views/admin/RedeemView.vue b/frontend/src/views/admin/RedeemView.vue index b8e0e936..faae7439 100644 --- a/frontend/src/views/admin/RedeemView.vue +++ b/frontend/src/views/admin/RedeemView.vue @@ -39,6 +39,15 @@ + @@ -56,6 +65,28 @@ default-sort-order="desc" @sort="handleSort" > + + + +