mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-16 07:33:06 +08:00
feat(redeem): add redeem code batch update
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user