feat(redeem): add redeem code batch update
This commit is contained in:
parent
7ec61eb2f5
commit
3263ca63c7
@ -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
|
// Expire handles expiring a redeem code
|
||||||
// POST /api/v1/admin/redeem-codes/:id/expire
|
// POST /api/v1/admin/redeem-codes/:id/expire
|
||||||
func (h *RedeemHandler) Expire(c *gin.Context) {
|
func (h *RedeemHandler) Expire(c *gin.Context) {
|
||||||
|
|||||||
@ -2492,6 +2492,10 @@ func (r *oauthPendingFlowRedeemCodeRepo) Update(ctx context.Context, code *servi
|
|||||||
return err
|
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 {
|
func (r *oauthPendingFlowRedeemCodeRepo) Delete(context.Context, int64) error {
|
||||||
panic("unexpected Delete call")
|
panic("unexpected Delete call")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||||
@ -359,6 +361,59 @@ type AdminRedeemCode struct {
|
|||||||
Notes string `json:"notes"`
|
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(不包含管理员字段)。
|
// UsageLog 是普通用户接口使用的 usage log DTO(不包含管理员字段)。
|
||||||
type UsageLog struct {
|
type UsageLog struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
|
|||||||
@ -236,6 +236,91 @@ func (r *redeemCodeRepository) Update(ctx context.Context, code *service.RedeemC
|
|||||||
return nil
|
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 {
|
func (r *redeemCodeRepository) Use(ctx context.Context, id, userID int64) error {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
client := clientFromContext(ctx, r.client)
|
client := clientFromContext(ctx, r.client)
|
||||||
|
|||||||
@ -4,6 +4,7 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -21,8 +22,8 @@ type RedeemCodeRepoSuite struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *RedeemCodeRepoSuite) SetupTest() {
|
func (s *RedeemCodeRepoSuite) SetupTest() {
|
||||||
s.ctx = context.Background()
|
|
||||||
tx := testEntTx(s.T())
|
tx := testEntTx(s.T())
|
||||||
|
s.ctx = dbent.NewTxContext(context.Background(), tx)
|
||||||
s.client = tx.Client()
|
s.client = tx.Client()
|
||||||
s.repo = NewRedeemCodeRepository(s.client).(*redeemCodeRepository)
|
s.repo = NewRedeemCodeRepository(s.client).(*redeemCodeRepository)
|
||||||
}
|
}
|
||||||
@ -237,6 +238,123 @@ func (s *RedeemCodeRepoSuite) TestUpdate() {
|
|||||||
s.Require().Equal(float64(50), got.Value)
|
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 ---
|
// --- Use ---
|
||||||
|
|
||||||
func (s *RedeemCodeRepoSuite) TestUse() {
|
func (s *RedeemCodeRepoSuite) TestUse() {
|
||||||
|
|||||||
@ -1849,6 +1849,10 @@ func (stubRedeemCodeRepo) Update(ctx context.Context, code *service.RedeemCode)
|
|||||||
return errors.New("not implemented")
|
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 {
|
func (stubRedeemCodeRepo) Delete(ctx context.Context, id int64) error {
|
||||||
return errors.New("not implemented")
|
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.POST("/generate", h.Admin.Redeem.Generate)
|
||||||
codes.DELETE("/:id", h.Admin.Redeem.Delete)
|
codes.DELETE("/:id", h.Admin.Redeem.Delete)
|
||||||
codes.POST("/batch-delete", h.Admin.Redeem.BatchDelete)
|
codes.POST("/batch-delete", h.Admin.Redeem.BatchDelete)
|
||||||
|
codes.POST("/batch-update", h.Admin.Redeem.BatchUpdate)
|
||||||
codes.POST("/:id/expire", h.Admin.Redeem.Expire)
|
codes.POST("/:id/expire", h.Admin.Redeem.Expire)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -244,6 +244,21 @@ func (s *groupRepoStub) UpdateSortOrders(ctx context.Context, updates []GroupSor
|
|||||||
return nil
|
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 {
|
type proxyRepoStub struct {
|
||||||
deleteErr error
|
deleteErr error
|
||||||
countErr error
|
countErr error
|
||||||
@ -310,6 +325,12 @@ func (s *proxyRepoStub) ListAccountSummariesByProxyID(ctx context.Context, proxy
|
|||||||
type redeemRepoStub struct {
|
type redeemRepoStub struct {
|
||||||
deleteErrByID map[int64]error
|
deleteErrByID map[int64]error
|
||||||
deletedIDs []int64
|
deletedIDs []int64
|
||||||
|
|
||||||
|
batchUpdateIDs []int64
|
||||||
|
batchUpdateFields RedeemCodeBatchUpdateFields
|
||||||
|
batchUpdateResult int64
|
||||||
|
batchUpdateErr error
|
||||||
|
batchUpdateCalled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *redeemRepoStub) Create(ctx context.Context, code *RedeemCode) error {
|
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")
|
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 {
|
func (s *redeemRepoStub) Delete(ctx context.Context, id int64) error {
|
||||||
s.deletedIDs = append(s.deletedIDs, id)
|
s.deletedIDs = append(s.deletedIDs, id)
|
||||||
if s.deleteErrByID != nil {
|
if s.deleteErrByID != nil {
|
||||||
@ -500,6 +534,23 @@ func TestAdminService_DeleteGroup_Success_WithCacheInvalidation(t *testing.T) {
|
|||||||
}, calls)
|
}, 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) {
|
func TestAdminService_DeleteGroup_NotFound(t *testing.T) {
|
||||||
repo := &groupRepoStub{deleteErr: ErrGroupNotFound}
|
repo := &groupRepoStub{deleteErr: ErrGroupNotFound}
|
||||||
svc := &adminServiceImpl{groupRepo: repo}
|
svc := &adminServiceImpl{groupRepo: repo}
|
||||||
|
|||||||
@ -59,6 +59,10 @@ func (s *redeemCodeRepoStub) Update(_ context.Context, code *RedeemCode) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *redeemCodeRepoStub) BatchUpdate(context.Context, []int64, RedeemCodeBatchUpdateFields) (int64, error) {
|
||||||
|
panic("unexpected BatchUpdate call")
|
||||||
|
}
|
||||||
|
|
||||||
func (s *redeemCodeRepoStub) Delete(context.Context, int64) error {
|
func (s *redeemCodeRepoStub) Delete(context.Context, int64) error {
|
||||||
panic("unexpected Delete call")
|
panic("unexpected Delete call")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -115,6 +115,10 @@ func (r *paymentOrderLifecycleRedeemRepo) Update(context.Context, *RedeemCode) e
|
|||||||
panic("unexpected call")
|
panic("unexpected call")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *paymentOrderLifecycleRedeemRepo) BatchUpdate(context.Context, []int64, RedeemCodeBatchUpdateFields) (int64, error) {
|
||||||
|
panic("unexpected call")
|
||||||
|
}
|
||||||
|
|
||||||
func (r *paymentOrderLifecycleRedeemRepo) Delete(context.Context, int64) error {
|
func (r *paymentOrderLifecycleRedeemRepo) Delete(context.Context, int64) error {
|
||||||
panic("unexpected call")
|
panic("unexpected call")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -54,6 +54,7 @@ type RedeemCodeRepository interface {
|
|||||||
GetByID(ctx context.Context, id int64) (*RedeemCode, error)
|
GetByID(ctx context.Context, id int64) (*RedeemCode, error)
|
||||||
GetByCode(ctx context.Context, code string) (*RedeemCode, error)
|
GetByCode(ctx context.Context, code string) (*RedeemCode, error)
|
||||||
Update(ctx context.Context, code *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
|
Delete(ctx context.Context, id int64) error
|
||||||
Use(ctx context.Context, id, userID int64) error
|
Use(ctx context.Context, id, userID int64) error
|
||||||
|
|
||||||
@ -82,6 +83,54 @@ type RedeemCodeResponse struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
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 兑换码服务
|
// RedeemService 兑换码服务
|
||||||
type RedeemService struct {
|
type RedeemService struct {
|
||||||
redeemRepo RedeemCodeRepository
|
redeemRepo RedeemCodeRepository
|
||||||
@ -218,6 +267,61 @@ func (s *RedeemService) CreateCode(ctx context.Context, code *RedeemCode) error
|
|||||||
return nil
|
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 检查用户兑换错误次数是否超限
|
// checkRedeemRateLimit 检查用户兑换错误次数是否超限
|
||||||
func (s *RedeemService) checkRedeemRateLimit(ctx context.Context, userID int64) error {
|
func (s *RedeemService) checkRedeemRateLimit(ctx context.Context, userID int64) error {
|
||||||
if s.cache == nil {
|
if s.cache == nil {
|
||||||
|
|||||||
75
backend/internal/service/redeem_service_batch_update_test.go
Normal file
75
backend/internal/service/redeem_service_batch_update_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ import { apiClient } from '../client'
|
|||||||
import type {
|
import type {
|
||||||
RedeemCode,
|
RedeemCode,
|
||||||
GenerateRedeemCodesRequest,
|
GenerateRedeemCodesRequest,
|
||||||
|
BatchUpdateRedeemCodeFields,
|
||||||
RedeemCodeType,
|
RedeemCodeType,
|
||||||
PaginatedResponse
|
PaginatedResponse
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
@ -23,7 +24,7 @@ export async function list(
|
|||||||
pageSize: number = 20,
|
pageSize: number = 20,
|
||||||
filters?: {
|
filters?: {
|
||||||
type?: RedeemCodeType
|
type?: RedeemCodeType
|
||||||
status?: 'active' | 'used' | 'expired' | 'unused'
|
status?: 'active' | 'used' | 'expired' | 'unused' | 'disabled'
|
||||||
search?: string
|
search?: string
|
||||||
sort_by?: string
|
sort_by?: string
|
||||||
sort_order?: 'asc' | 'desc'
|
sort_order?: 'asc' | 'desc'
|
||||||
@ -118,6 +119,26 @@ export async function batchDelete(ids: number[]): Promise<{
|
|||||||
return data
|
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
|
* Expire redeem code
|
||||||
* @param id - Redeem code ID
|
* @param id - Redeem code ID
|
||||||
@ -158,7 +179,7 @@ export async function getStats(): Promise<{
|
|||||||
*/
|
*/
|
||||||
export async function exportCodes(filters?: {
|
export async function exportCodes(filters?: {
|
||||||
type?: RedeemCodeType
|
type?: RedeemCodeType
|
||||||
status?: 'used' | 'expired' | 'unused'
|
status?: 'used' | 'expired' | 'unused' | 'disabled'
|
||||||
search?: string
|
search?: string
|
||||||
sort_by?: string
|
sort_by?: string
|
||||||
sort_order?: 'asc' | 'desc'
|
sort_order?: 'asc' | 'desc'
|
||||||
@ -176,6 +197,7 @@ export const redeemAPI = {
|
|||||||
generate,
|
generate,
|
||||||
delete: deleteCode,
|
delete: deleteCode,
|
||||||
batchDelete,
|
batchDelete,
|
||||||
|
batchUpdate,
|
||||||
expire,
|
expire,
|
||||||
getStats,
|
getStats,
|
||||||
exportCodes
|
exportCodes
|
||||||
|
|||||||
@ -4171,6 +4171,22 @@ export default {
|
|||||||
codeDeleted: 'Redeem code deleted successfully',
|
codeDeleted: 'Redeem code deleted successfully',
|
||||||
codesDeleted: 'Successfully deleted {count} unused code(s)',
|
codesDeleted: 'Successfully deleted {count} unused code(s)',
|
||||||
noUnusedCodes: 'No unused codes to delete',
|
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',
|
failedToLoad: 'Failed to load redeem codes',
|
||||||
failedToGenerate: 'Failed to generate codes',
|
failedToGenerate: 'Failed to generate codes',
|
||||||
failedToExport: 'Failed to export codes',
|
failedToExport: 'Failed to export codes',
|
||||||
|
|||||||
@ -4300,6 +4300,22 @@ export default {
|
|||||||
codeDeleted: '兑换码删除成功',
|
codeDeleted: '兑换码删除成功',
|
||||||
codesDeleted: '成功删除 {count} 个未使用的兑换码',
|
codesDeleted: '成功删除 {count} 个未使用的兑换码',
|
||||||
noUnusedCodes: '没有未使用的兑换码可删除',
|
noUnusedCodes: '没有未使用的兑换码可删除',
|
||||||
|
selectedCount: '已选 {count} 个',
|
||||||
|
clearSelection: '清空选择',
|
||||||
|
batchUpdate: '批量修改',
|
||||||
|
batchUpdateTitle: '批量修改兑换码',
|
||||||
|
batchUpdateSuccess: '成功修改 {count} 个兑换码',
|
||||||
|
failedToBatchUpdate: '批量修改兑换码失败',
|
||||||
|
selectCodesFirst: '请先选择兑换码',
|
||||||
|
noBatchFieldsSelected: '请选择至少一个要修改的字段',
|
||||||
|
batchFields: {
|
||||||
|
status: '状态',
|
||||||
|
expiresAt: '过期时间',
|
||||||
|
notes: '备注',
|
||||||
|
group: '分组'
|
||||||
|
},
|
||||||
|
batchNotesPlaceholder: '留空将清空备注',
|
||||||
|
clearGroup: '清除分组',
|
||||||
userPrefix: '用户 #{id}',
|
userPrefix: '用户 #{id}',
|
||||||
failedToExport: '导出兑换码失败',
|
failedToExport: '导出兑换码失败',
|
||||||
failedToDeleteUnused: '删除未使用的兑换码失败',
|
failedToDeleteUnused: '删除未使用的兑换码失败',
|
||||||
|
|||||||
@ -1289,12 +1289,13 @@ export interface RedeemCode {
|
|||||||
code: string
|
code: string
|
||||||
type: RedeemCodeType
|
type: RedeemCodeType
|
||||||
value: number
|
value: number
|
||||||
status: 'active' | 'used' | 'expired' | 'unused'
|
status: 'active' | 'used' | 'expired' | 'unused' | 'disabled'
|
||||||
used_by: number | null
|
used_by: number | null
|
||||||
used_at: string | null
|
used_at: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
expires_at?: string | null
|
expires_at?: string | null
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
|
notes?: string
|
||||||
group_id?: number | null // 订阅类型专用
|
group_id?: number | null // 订阅类型专用
|
||||||
validity_days?: number // 订阅类型专用
|
validity_days?: number // 订阅类型专用
|
||||||
user?: User
|
user?: User
|
||||||
@ -1311,6 +1312,18 @@ export interface GenerateRedeemCodesRequest {
|
|||||||
expires_in_days?: number
|
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 {
|
export interface RedeemCodeRequest {
|
||||||
code: string
|
code: string
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,6 +39,15 @@
|
|||||||
<button @click="handleExportCodes" class="btn btn-secondary">
|
<button @click="handleExportCodes" class="btn btn-secondary">
|
||||||
{{ t('admin.redeem.exportCsv') }}
|
{{ t('admin.redeem.exportCsv') }}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
data-test="batch-update-open"
|
||||||
|
@click="openBatchUpdateDialog"
|
||||||
|
:disabled="selectedCount === 0 || batchUpdating"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
>
|
||||||
|
<Icon name="edit" size="md" class="mr-2" />
|
||||||
|
{{ t('admin.redeem.batchUpdate') }}
|
||||||
|
</button>
|
||||||
<button @click="showGenerateDialog = true" class="btn btn-primary">
|
<button @click="showGenerateDialog = true" class="btn btn-primary">
|
||||||
{{ t('admin.redeem.generateCodes') }}
|
{{ t('admin.redeem.generateCodes') }}
|
||||||
</button>
|
</button>
|
||||||
@ -56,6 +65,28 @@
|
|||||||
default-sort-order="desc"
|
default-sort-order="desc"
|
||||||
@sort="handleSort"
|
@sort="handleSort"
|
||||||
>
|
>
|
||||||
|
<template #header-select>
|
||||||
|
<input
|
||||||
|
data-test="select-all-codes"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 cursor-pointer rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
:checked="allVisibleSelected"
|
||||||
|
@click.stop
|
||||||
|
@change="toggleSelectAllVisible($event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cell-select="{ row }">
|
||||||
|
<input
|
||||||
|
data-test="select-code"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 cursor-pointer rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
:checked="selectedCodeIds.has(row.id)"
|
||||||
|
@click.stop
|
||||||
|
@change="toggleSelectRow(row.id, $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template #cell-code="{ value }">
|
<template #cell-code="{ value }">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<code class="font-mono text-sm text-gray-900 dark:text-gray-100">{{ value }}</code>
|
<code class="font-mono text-sm text-gray-900 dark:text-gray-100">{{ value }}</code>
|
||||||
@ -174,6 +205,31 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #pagination>
|
<template #pagination>
|
||||||
|
<div
|
||||||
|
v-if="selectedCount > 0"
|
||||||
|
class="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-lg bg-primary-50 p-3 dark:bg-primary-900/20"
|
||||||
|
>
|
||||||
|
<span class="text-sm font-medium text-primary-900 dark:text-primary-100">
|
||||||
|
{{ t('admin.redeem.selectedCount', { count: selectedCount }) }}
|
||||||
|
</span>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-xs font-medium text-primary-700 hover:text-primary-800 dark:text-primary-300 dark:hover:text-primary-200"
|
||||||
|
@click="clearSelectedCodes"
|
||||||
|
>
|
||||||
|
{{ t('admin.redeem.clearSelection') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary btn-sm"
|
||||||
|
@click="openBatchUpdateDialog"
|
||||||
|
>
|
||||||
|
{{ t('admin.redeem.batchUpdate') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Pagination
|
<Pagination
|
||||||
v-if="pagination.total > 0"
|
v-if="pagination.total > 0"
|
||||||
:page="pagination.page"
|
:page="pagination.page"
|
||||||
@ -353,6 +409,117 @@
|
|||||||
</div>
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
|
|
||||||
|
<!-- Batch Update Dialog -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="showBatchUpdateDialog"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||||
|
>
|
||||||
|
<div class="fixed inset-0 bg-black/50" @click="closeBatchUpdateDialog"></div>
|
||||||
|
<div
|
||||||
|
class="relative z-10 w-full max-w-lg rounded-xl bg-white p-6 shadow-xl dark:bg-dark-800"
|
||||||
|
>
|
||||||
|
<h2 class="mb-1 text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{{ t('admin.redeem.batchUpdateTitle') }}
|
||||||
|
</h2>
|
||||||
|
<p class="mb-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('admin.redeem.selectedCount', { count: selectedCount }) }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form data-test="batch-update-form" class="space-y-4" @submit.prevent="handleBatchUpdate">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
<input
|
||||||
|
data-test="batch-field-status"
|
||||||
|
v-model="batchUpdateForm.update_status"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
{{ t('admin.redeem.batchFields.status') }}
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
v-if="batchUpdateForm.update_status"
|
||||||
|
v-model="batchUpdateForm.status"
|
||||||
|
data-test="batch-status-select"
|
||||||
|
:options="batchStatusOptions"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
<input
|
||||||
|
v-model="batchUpdateForm.update_expires_at"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
{{ t('admin.redeem.batchFields.expiresAt') }}
|
||||||
|
</label>
|
||||||
|
<template v-if="batchUpdateForm.update_expires_at">
|
||||||
|
<Select v-model="batchUpdateForm.expires_mode" :options="batchExpiryModeOptions" />
|
||||||
|
<input
|
||||||
|
v-if="batchUpdateForm.expires_mode === 'custom'"
|
||||||
|
v-model="batchUpdateForm.expires_at_local"
|
||||||
|
type="datetime-local"
|
||||||
|
class="input"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
<input
|
||||||
|
data-test="batch-field-notes"
|
||||||
|
v-model="batchUpdateForm.update_notes"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
{{ t('admin.redeem.batchFields.notes') }}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
v-if="batchUpdateForm.update_notes"
|
||||||
|
data-test="batch-notes-input"
|
||||||
|
v-model="batchUpdateForm.notes"
|
||||||
|
rows="3"
|
||||||
|
class="input"
|
||||||
|
:placeholder="t('admin.redeem.batchNotesPlaceholder')"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
<input
|
||||||
|
v-model="batchUpdateForm.update_group_id"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
{{ t('admin.redeem.batchFields.group') }}
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
v-if="batchUpdateForm.update_group_id"
|
||||||
|
v-model="batchUpdateForm.group_id"
|
||||||
|
:options="batchGroupOptions"
|
||||||
|
:placeholder="t('admin.redeem.selectGroupPlaceholder')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
|
<button type="button" @click="closeBatchUpdateDialog" class="btn btn-secondary">
|
||||||
|
{{ t('common.cancel') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-test="batch-update-submit"
|
||||||
|
type="submit"
|
||||||
|
:disabled="batchUpdating"
|
||||||
|
class="btn btn-primary"
|
||||||
|
>
|
||||||
|
{{ batchUpdating ? t('common.submitting') : t('admin.redeem.batchUpdate') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
<!-- Generated Codes Result Dialog -->
|
<!-- Generated Codes Result Dialog -->
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div v-if="showResultDialog" class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<div v-if="showResultDialog" class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
@ -445,10 +612,18 @@ import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import { useClipboard } from '@/composables/useClipboard'
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
|
import { useTableSelection } from '@/composables/useTableSelection'
|
||||||
import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
|
import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
|
||||||
import { adminAPI } from '@/api/admin'
|
import { adminAPI } from '@/api/admin'
|
||||||
import { formatDateTime } from '@/utils/format'
|
import { formatDateTime } from '@/utils/format'
|
||||||
import type { RedeemCode, RedeemCodeType, Group, GroupPlatform, SubscriptionType } from '@/types'
|
import type {
|
||||||
|
RedeemCode,
|
||||||
|
RedeemCodeType,
|
||||||
|
Group,
|
||||||
|
GroupPlatform,
|
||||||
|
SubscriptionType,
|
||||||
|
BatchUpdateRedeemCodeFields
|
||||||
|
} from '@/types'
|
||||||
import type { Column } from '@/components/common/types'
|
import type { Column } from '@/components/common/types'
|
||||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||||
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||||
@ -492,6 +667,11 @@ const subscriptionGroupOptions = computed(() => {
|
|||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const batchGroupOptions = computed(() => [
|
||||||
|
{ value: null, label: t('admin.redeem.clearGroup') },
|
||||||
|
...subscriptionGroupOptions.value
|
||||||
|
])
|
||||||
|
|
||||||
const generatedCodesText = computed(() => {
|
const generatedCodesText = computed(() => {
|
||||||
return generatedCodes.value.map((code) => code.code).join('\n')
|
return generatedCodes.value.map((code) => code.code).join('\n')
|
||||||
})
|
})
|
||||||
@ -540,6 +720,7 @@ const downloadGeneratedCodes = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const columns = computed<Column[]>(() => [
|
const columns = computed<Column[]>(() => [
|
||||||
|
{ key: 'select', label: '' },
|
||||||
{ key: 'code', label: t('admin.redeem.columns.code') },
|
{ key: 'code', label: t('admin.redeem.columns.code') },
|
||||||
{ key: 'type', label: t('admin.redeem.columns.type'), sortable: true },
|
{ key: 'type', label: t('admin.redeem.columns.type'), sortable: true },
|
||||||
{ key: 'value', label: t('admin.redeem.columns.value'), sortable: true },
|
{ key: 'value', label: t('admin.redeem.columns.value'), sortable: true },
|
||||||
@ -569,12 +750,24 @@ const filterStatusOptions = computed(() => [
|
|||||||
{ value: '', label: t('admin.redeem.allStatus') },
|
{ value: '', label: t('admin.redeem.allStatus') },
|
||||||
{ value: 'unused', label: t('admin.redeem.unused') },
|
{ value: 'unused', label: t('admin.redeem.unused') },
|
||||||
{ value: 'used', label: t('admin.redeem.used') },
|
{ value: 'used', label: t('admin.redeem.used') },
|
||||||
{ value: 'expired', label: t('admin.redeem.status.expired') }
|
{ value: 'expired', label: t('admin.redeem.status.expired') },
|
||||||
|
{ value: 'disabled', label: t('admin.redeem.status.disabled') }
|
||||||
|
])
|
||||||
|
|
||||||
|
const batchStatusOptions = computed(() => [
|
||||||
|
{ value: 'unused', label: t('admin.redeem.status.unused') },
|
||||||
|
{ value: 'disabled', label: t('admin.redeem.status.disabled') }
|
||||||
|
])
|
||||||
|
|
||||||
|
const batchExpiryModeOptions = computed(() => [
|
||||||
|
{ value: 'clear', label: t('admin.redeem.neverExpires') },
|
||||||
|
{ value: 'custom', label: t('admin.redeem.customExpiry') }
|
||||||
])
|
])
|
||||||
|
|
||||||
const codes = ref<RedeemCode[]>([])
|
const codes = ref<RedeemCode[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const generating = ref(false)
|
const generating = ref(false)
|
||||||
|
const batchUpdating = ref(false)
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const filters = reactive({
|
const filters = reactive({
|
||||||
type: '',
|
type: '',
|
||||||
@ -595,9 +788,35 @@ let abortController: AbortController | null = null
|
|||||||
|
|
||||||
const showDeleteDialog = ref(false)
|
const showDeleteDialog = ref(false)
|
||||||
const showDeleteUnusedDialog = ref(false)
|
const showDeleteUnusedDialog = ref(false)
|
||||||
|
const showBatchUpdateDialog = ref(false)
|
||||||
const deletingCode = ref<RedeemCode | null>(null)
|
const deletingCode = ref<RedeemCode | null>(null)
|
||||||
const copiedCode = ref<string | null>(null)
|
const copiedCode = ref<string | null>(null)
|
||||||
|
|
||||||
|
const {
|
||||||
|
selectedSet: selectedCodeIds,
|
||||||
|
selectedCount,
|
||||||
|
allVisibleSelected,
|
||||||
|
select,
|
||||||
|
deselect,
|
||||||
|
clear: clearSelectedCodes,
|
||||||
|
toggleVisible
|
||||||
|
} = useTableSelection<RedeemCode>({
|
||||||
|
rows: codes,
|
||||||
|
getId: (code) => code.id
|
||||||
|
})
|
||||||
|
|
||||||
|
const batchUpdateForm = reactive({
|
||||||
|
update_status: false,
|
||||||
|
status: 'disabled' as 'unused' | 'disabled',
|
||||||
|
update_expires_at: false,
|
||||||
|
expires_mode: 'clear' as 'clear' | 'custom',
|
||||||
|
expires_at_local: '',
|
||||||
|
update_notes: false,
|
||||||
|
notes: '',
|
||||||
|
update_group_id: false,
|
||||||
|
group_id: null as number | null
|
||||||
|
})
|
||||||
|
|
||||||
type RedeemCodeExpiryOption = 'never' | '1' | '3' | '7' | 'custom'
|
type RedeemCodeExpiryOption = 'never' | '1' | '3' | '7' | 'custom'
|
||||||
|
|
||||||
const redeemCodeExpiryOptions = computed<{ value: RedeemCodeExpiryOption; label: string }[]>(() => [
|
const redeemCodeExpiryOptions = computed<{ value: RedeemCodeExpiryOption; label: string }[]>(() => [
|
||||||
@ -632,7 +851,7 @@ watch(
|
|||||||
|
|
||||||
const buildRedeemQueryFilters = () => ({
|
const buildRedeemQueryFilters = () => ({
|
||||||
type: (filters.type || undefined) as RedeemCodeType | undefined,
|
type: (filters.type || undefined) as RedeemCodeType | undefined,
|
||||||
status: (filters.status || undefined) as 'used' | 'expired' | 'unused' | undefined,
|
status: (filters.status || undefined) as 'used' | 'expired' | 'unused' | 'disabled' | undefined,
|
||||||
search: searchQuery.value || undefined,
|
search: searchQuery.value || undefined,
|
||||||
sort_by: sortState.sort_by,
|
sort_by: sortState.sort_by,
|
||||||
sort_order: sortState.sort_order
|
sort_order: sortState.sort_order
|
||||||
@ -705,6 +924,20 @@ const handleSort = (key: string, order: 'asc' | 'desc') => {
|
|||||||
loadCodes()
|
loadCodes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleSelectRow = (id: number, event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
if (target.checked) {
|
||||||
|
select(id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deselect(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleSelectAllVisible = (event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
toggleVisible(target.checked)
|
||||||
|
}
|
||||||
|
|
||||||
const getRedeemCodeExpiresInDays = () => {
|
const getRedeemCodeExpiresInDays = () => {
|
||||||
if (generateForm.expiry_option === 'never') {
|
if (generateForm.expiry_option === 'never') {
|
||||||
return undefined
|
return undefined
|
||||||
@ -721,6 +954,69 @@ const getRedeemCodeExpiresInDays = () => {
|
|||||||
return Number(generateForm.expiry_option)
|
return Number(generateForm.expiry_option)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toDatetimeLocalInputValue = (date: Date) => {
|
||||||
|
const pad = (value: number) => String(value).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(
|
||||||
|
date.getHours()
|
||||||
|
)}:${pad(date.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetBatchUpdateForm = () => {
|
||||||
|
batchUpdateForm.update_status = false
|
||||||
|
batchUpdateForm.status = 'disabled'
|
||||||
|
batchUpdateForm.update_expires_at = false
|
||||||
|
batchUpdateForm.expires_mode = 'clear'
|
||||||
|
batchUpdateForm.expires_at_local = toDatetimeLocalInputValue(
|
||||||
|
new Date(Date.now() + 24 * 60 * 60 * 1000)
|
||||||
|
)
|
||||||
|
batchUpdateForm.update_notes = false
|
||||||
|
batchUpdateForm.notes = ''
|
||||||
|
batchUpdateForm.update_group_id = false
|
||||||
|
batchUpdateForm.group_id = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const openBatchUpdateDialog = () => {
|
||||||
|
if (selectedCount.value === 0) {
|
||||||
|
appStore.showInfo(t('admin.redeem.selectCodesFirst'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resetBatchUpdateForm()
|
||||||
|
showBatchUpdateDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeBatchUpdateDialog = () => {
|
||||||
|
showBatchUpdateDialog.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildBatchUpdateFields = (): BatchUpdateRedeemCodeFields | null => {
|
||||||
|
const fields: BatchUpdateRedeemCodeFields = {}
|
||||||
|
|
||||||
|
if (batchUpdateForm.update_status) {
|
||||||
|
fields.status = batchUpdateForm.status
|
||||||
|
}
|
||||||
|
if (batchUpdateForm.update_expires_at) {
|
||||||
|
if (batchUpdateForm.expires_mode === 'clear') {
|
||||||
|
fields.expires_at = null
|
||||||
|
} else {
|
||||||
|
const expiresAt = new Date(batchUpdateForm.expires_at_local)
|
||||||
|
if (!batchUpdateForm.expires_at_local || Number.isNaN(expiresAt.getTime())) {
|
||||||
|
appStore.showError(t('admin.redeem.expiryDaysRequired'))
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
fields.expires_at = expiresAt.toISOString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (batchUpdateForm.update_notes) {
|
||||||
|
fields.notes = batchUpdateForm.notes
|
||||||
|
}
|
||||||
|
if (batchUpdateForm.update_group_id) {
|
||||||
|
fields.group_id =
|
||||||
|
batchUpdateForm.group_id == null ? null : Number(batchUpdateForm.group_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(fields).length > 0 ? fields : null
|
||||||
|
}
|
||||||
|
|
||||||
const handleGenerateCodes = async () => {
|
const handleGenerateCodes = async () => {
|
||||||
// 订阅类型必须选择分组
|
// 订阅类型必须选择分组
|
||||||
if (generateForm.type === 'subscription' && !generateForm.group_id) {
|
if (generateForm.type === 'subscription' && !generateForm.group_id) {
|
||||||
@ -834,6 +1130,43 @@ const confirmDeleteUnused = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleBatchUpdate = async () => {
|
||||||
|
const ids = Array.from(selectedCodeIds.value)
|
||||||
|
if (ids.length === 0) {
|
||||||
|
appStore.showInfo(t('admin.redeem.selectCodesFirst'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasSelectedFields =
|
||||||
|
batchUpdateForm.update_status ||
|
||||||
|
batchUpdateForm.update_expires_at ||
|
||||||
|
batchUpdateForm.update_notes ||
|
||||||
|
batchUpdateForm.update_group_id
|
||||||
|
if (!hasSelectedFields) {
|
||||||
|
appStore.showError(t('admin.redeem.noBatchFieldsSelected'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = buildBatchUpdateFields()
|
||||||
|
if (!fields) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
batchUpdating.value = true
|
||||||
|
try {
|
||||||
|
const result = await adminAPI.redeem.batchUpdate(ids, fields)
|
||||||
|
appStore.showSuccess(t('admin.redeem.batchUpdateSuccess', { count: result.updated }))
|
||||||
|
showBatchUpdateDialog.value = false
|
||||||
|
clearSelectedCodes()
|
||||||
|
loadCodes()
|
||||||
|
} catch (error: any) {
|
||||||
|
appStore.showError(error.response?.data?.detail || t('admin.redeem.failedToBatchUpdate'))
|
||||||
|
console.error('Error batch updating codes:', error)
|
||||||
|
} finally {
|
||||||
|
batchUpdating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 加载订阅类型分组
|
// 加载订阅类型分组
|
||||||
const loadSubscriptionGroups = async () => {
|
const loadSubscriptionGroups = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -0,0 +1,187 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
|
||||||
|
import RedeemView from '../RedeemView.vue'
|
||||||
|
|
||||||
|
const { listRedeemCodes, batchUpdateRedeemCodes, getAllGroups, showSuccess, showError, showInfo } =
|
||||||
|
vi.hoisted(() => ({
|
||||||
|
listRedeemCodes: vi.fn(),
|
||||||
|
batchUpdateRedeemCodes: vi.fn(),
|
||||||
|
getAllGroups: vi.fn(),
|
||||||
|
showSuccess: vi.fn(),
|
||||||
|
showError: vi.fn(),
|
||||||
|
showInfo: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/admin', () => ({
|
||||||
|
adminAPI: {
|
||||||
|
redeem: {
|
||||||
|
list: listRedeemCodes,
|
||||||
|
generate: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
batchDelete: vi.fn(),
|
||||||
|
batchUpdate: batchUpdateRedeemCodes,
|
||||||
|
exportCodes: vi.fn()
|
||||||
|
},
|
||||||
|
groups: {
|
||||||
|
getAll: getAllGroups
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/app', () => ({
|
||||||
|
useAppStore: () => ({
|
||||||
|
showSuccess,
|
||||||
|
showError,
|
||||||
|
showInfo
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useClipboard', () => ({
|
||||||
|
useClipboard: () => ({
|
||||||
|
copyToClipboard: vi.fn()
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useI18n: () => ({
|
||||||
|
t: (key: string) => key
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const DataTableStub = {
|
||||||
|
props: ['columns', 'data'],
|
||||||
|
template: `
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th v-for="column in columns" :key="column.key">
|
||||||
|
<slot :name="'header-' + column.key" :column="column">{{ column.label }}</slot>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in data" :key="row.id">
|
||||||
|
<td v-for="column in columns" :key="column.key">
|
||||||
|
<slot :name="'cell-' + column.key" :row="row" :value="row[column.key]">
|
||||||
|
{{ row[column.key] }}
|
||||||
|
</slot>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
const SelectStub = {
|
||||||
|
props: ['modelValue', 'options'],
|
||||||
|
emits: ['update:modelValue', 'change'],
|
||||||
|
setup(props: { options: Array<{ value: unknown; label: string }> }, { emit }: { emit: (event: string, ...args: unknown[]) => void }) {
|
||||||
|
const onChange = (event: Event) => {
|
||||||
|
const raw = (event.target as HTMLSelectElement).value
|
||||||
|
const option = props.options.find((item) => String(item.value ?? '') === raw)
|
||||||
|
const value = option ? option.value : raw
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
emit('change', value, option ?? null)
|
||||||
|
}
|
||||||
|
return { onChange }
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<select v-bind="$attrs" :value="modelValue ?? ''" @change="onChange">
|
||||||
|
<option v-for="option in options" :key="String(option.value ?? '')" :value="option.value ?? ''">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('admin RedeemView batch update', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
|
||||||
|
listRedeemCodes.mockReset()
|
||||||
|
batchUpdateRedeemCodes.mockReset()
|
||||||
|
getAllGroups.mockReset()
|
||||||
|
showSuccess.mockReset()
|
||||||
|
showError.mockReset()
|
||||||
|
showInfo.mockReset()
|
||||||
|
|
||||||
|
listRedeemCodes.mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
code: 'CODE-1',
|
||||||
|
type: 'balance',
|
||||||
|
value: 10,
|
||||||
|
status: 'unused',
|
||||||
|
used_by: null,
|
||||||
|
used_at: null,
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
expires_at: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
code: 'CODE-2',
|
||||||
|
type: 'balance',
|
||||||
|
value: 20,
|
||||||
|
status: 'unused',
|
||||||
|
used_by: null,
|
||||||
|
used_at: null,
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
expires_at: null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
page: 1,
|
||||||
|
page_size: 20,
|
||||||
|
pages: 1
|
||||||
|
})
|
||||||
|
batchUpdateRedeemCodes.mockResolvedValue({ updated: 1, message: 'ok' })
|
||||||
|
getAllGroups.mockResolvedValue([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submits only checked fields for selected redeem codes', async () => {
|
||||||
|
const wrapper = mount(RedeemView, {
|
||||||
|
attachTo: document.body,
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
AppLayout: { template: '<div><slot /></div>' },
|
||||||
|
TablePageLayout: {
|
||||||
|
template: '<div><slot name="filters" /><slot name="table" /><slot name="pagination" /></div>'
|
||||||
|
},
|
||||||
|
DataTable: DataTableStub,
|
||||||
|
Pagination: true,
|
||||||
|
ConfirmDialog: true,
|
||||||
|
Select: SelectStub,
|
||||||
|
GroupBadge: true,
|
||||||
|
GroupOptionItem: true,
|
||||||
|
Icon: true,
|
||||||
|
Teleport: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.findAll('[data-test="select-code"]')[0].setValue(true)
|
||||||
|
await wrapper.get('[data-test="batch-update-open"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.get('[data-test="batch-field-status"]').setValue(true)
|
||||||
|
await wrapper.get('[data-test="batch-status-select"]').setValue('disabled')
|
||||||
|
await wrapper.get('[data-test="batch-field-notes"]').setValue(true)
|
||||||
|
await wrapper.get('[data-test="batch-notes-input"]').setValue('maintenance')
|
||||||
|
await wrapper.get('[data-test="batch-update-form"]').trigger('submit')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(batchUpdateRedeemCodes).toHaveBeenCalledWith([1], {
|
||||||
|
status: 'disabled',
|
||||||
|
notes: 'maintenance'
|
||||||
|
})
|
||||||
|
expect(showSuccess).toHaveBeenCalledWith('admin.redeem.batchUpdateSuccess')
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user