Merge pull request #7091 from SavitarC/feat/subscription-bulk-actions

feat: 支持订阅管理批量操作
This commit is contained in:
Wesley Liddick
2026-09-15 08:57:27 +08:00
committed by GitHub
22 changed files with 2093 additions and 72 deletions
@@ -27,10 +27,27 @@ func executeAdminIdempotent(
payload any,
ttl time.Duration,
execute func(context.Context) (any, error),
) (*service.IdempotencyExecuteResult, error) {
return executeAdminIdempotentWithTimeout(c, scope, payload, ttl, 0, execute)
}
func executeAdminIdempotentWithTimeout(
c *gin.Context,
scope string,
payload any,
ttl time.Duration,
executionTimeout time.Duration,
execute func(context.Context) (any, error),
) (*service.IdempotencyExecuteResult, error) {
coordinator := service.DefaultIdempotencyCoordinator()
if coordinator == nil {
data, err := execute(c.Request.Context())
ctx := c.Request.Context()
if executionTimeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(context.WithoutCancel(ctx), executionTimeout)
defer cancel()
}
data, err := execute(ctx)
if err != nil {
return nil, err
}
@@ -38,14 +55,15 @@ func executeAdminIdempotent(
}
return coordinator.Execute(c.Request.Context(), service.IdempotencyExecuteOptions{
Scope: scope,
ActorScope: adminActorScope(c),
Method: c.Request.Method,
Route: c.FullPath(),
IdempotencyKey: c.GetHeader("Idempotency-Key"),
Payload: payload,
RequireKey: true,
TTL: ttl,
Scope: scope,
ActorScope: adminActorScope(c),
Method: c.Request.Method,
Route: c.FullPath(),
IdempotencyKey: c.GetHeader("Idempotency-Key"),
Payload: payload,
RequireKey: true,
TTL: ttl,
ExecutionTimeout: executionTimeout,
}, execute)
}
@@ -64,7 +82,18 @@ func executeAdminIdempotentJSON(
ttl time.Duration,
execute func(context.Context) (any, error),
) {
executeAdminIdempotentJSONWithMode(c, scope, payload, ttl, idempotencyStoreUnavailableFailClose, execute)
executeAdminIdempotentJSONWithMode(c, scope, payload, ttl, 0, idempotencyStoreUnavailableFailClose, execute)
}
func executeAdminIdempotentJSONWithTimeout(
c *gin.Context,
scope string,
payload any,
ttl time.Duration,
executionTimeout time.Duration,
execute func(context.Context) (any, error),
) {
executeAdminIdempotentJSONWithMode(c, scope, payload, ttl, executionTimeout, idempotencyStoreUnavailableFailClose, execute)
}
func executeAdminIdempotentJSONFailOpenOnStoreUnavailable(
@@ -74,7 +103,7 @@ func executeAdminIdempotentJSONFailOpenOnStoreUnavailable(
ttl time.Duration,
execute func(context.Context) (any, error),
) {
executeAdminIdempotentJSONWithMode(c, scope, payload, ttl, idempotencyStoreUnavailableFailOpen, execute)
executeAdminIdempotentJSONWithMode(c, scope, payload, ttl, 0, idempotencyStoreUnavailableFailOpen, execute)
}
func executeAdminIdempotentJSONWithMode(
@@ -82,10 +111,11 @@ func executeAdminIdempotentJSONWithMode(
scope string,
payload any,
ttl time.Duration,
executionTimeout time.Duration,
mode idempotencyStoreUnavailableMode,
execute func(context.Context) (any, error),
) {
result, err := executeAdminIdempotent(c, scope, payload, ttl, execute)
result, err := executeAdminIdempotentWithTimeout(c, scope, payload, ttl, executionTimeout, execute)
if err != nil {
if infraerrors.Code(err) == infraerrors.Code(service.ErrIdempotencyStoreUnavail) {
strategy := "fail_close"
@@ -0,0 +1,212 @@
package admin
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type bulkActionHandlerSubscriptionRepo struct {
service.UserSubscriptionRepository
sub *service.UserSubscription
extendCalls int
}
type cancellationAwareBulkActionRepo struct {
*bulkActionHandlerSubscriptionRepo
cancelRequest context.CancelFunc
}
func (r *cancellationAwareBulkActionRepo) GetByID(ctx context.Context, id int64) (*service.UserSubscription, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
return r.bulkActionHandlerSubscriptionRepo.GetByID(ctx, id)
}
func (r *cancellationAwareBulkActionRepo) ExtendExpiry(ctx context.Context, id int64, expiry time.Time) error {
if err := ctx.Err(); err != nil {
return err
}
err := r.bulkActionHandlerSubscriptionRepo.ExtendExpiry(ctx, id, expiry)
r.cancelRequest()
return err
}
type cancellationAwareAdminIdempotencyRepo struct {
*memoryIdempotencyRepoStub
saves int
}
func (r *cancellationAwareAdminIdempotencyRepo) MarkSucceeded(ctx context.Context, id int64, status int, body string, expiresAt time.Time) error {
if err := ctx.Err(); err != nil {
return err
}
r.saves++
return r.memoryIdempotencyRepoStub.MarkSucceeded(ctx, id, status, body, expiresAt)
}
func (r *bulkActionHandlerSubscriptionRepo) GetByID(_ context.Context, id int64) (*service.UserSubscription, error) {
if r.sub == nil || r.sub.ID != id {
return nil, service.ErrSubscriptionNotFound
}
copy := *r.sub
return &copy, nil
}
func (r *bulkActionHandlerSubscriptionRepo) GetByIDForUpdate(ctx context.Context, id int64) (*service.UserSubscription, error) {
return r.GetByID(ctx, id)
}
func (r *bulkActionHandlerSubscriptionRepo) ExtendExpiry(_ context.Context, _ int64, expiry time.Time) error {
r.extendCalls++
r.sub.ExpiresAt = expiry
return nil
}
func bulkActionHandlerRequest(router *gin.Engine, path, body, key string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
if key != "" {
req.Header.Set("Idempotency-Key", key)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func TestSubscriptionBulkAction_ReplaysPartialResultWithoutRepeatingExtension(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := service.DefaultIdempotencyConfig()
cfg.ObserveOnly = false
service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(newMemoryIdempotencyRepoStub(), cfg))
t.Cleanup(func() { service.SetDefaultIdempotencyCoordinator(nil) })
expiresAt := time.Now().AddDate(0, 0, 30)
repo := &bulkActionHandlerSubscriptionRepo{sub: &service.UserSubscription{ID: 1, UserID: 1, GroupID: 10, ExpiresAt: expiresAt}}
svc := service.NewSubscriptionService(nil, repo, nil, nil, nil)
t.Cleanup(svc.Stop)
h := NewSubscriptionHandler(svc)
router := gin.New()
path := "/api/v1/admin/subscriptions/bulk-action"
router.POST(path, h.BulkAction)
body := `{"subscription_ids":[1,404,1],"action":"extend","days":7}`
missingKey := bulkActionHandlerRequest(router, path, body, "")
require.Equal(t, http.StatusBadRequest, missingKey.Code)
require.Zero(t, repo.extendCalls)
first := bulkActionHandlerRequest(router, path, body, "bulk-extend-once")
require.Equal(t, http.StatusOK, first.Code, first.Body.String())
var result struct {
Data service.BulkSubscriptionActionResult `json:"data"`
}
require.NoError(t, json.Unmarshal(first.Body.Bytes(), &result))
require.Equal(t, 1, result.Data.SuccessCount)
require.Equal(t, 1, result.Data.FailedCount)
require.Len(t, result.Data.Results, 2)
replayed := bulkActionHandlerRequest(router, path, body, "bulk-extend-once")
require.Equal(t, http.StatusOK, replayed.Code, replayed.Body.String())
require.Equal(t, "true", replayed.Header().Get("X-Idempotency-Replayed"))
require.JSONEq(t, first.Body.String(), replayed.Body.String())
require.Equal(t, 1, repo.extendCalls)
require.Equal(t, expiresAt.AddDate(0, 0, 7), repo.sub.ExpiresAt)
conflict := bulkActionHandlerRequest(router, path, `{"subscription_ids":[1,404,1],"action":"extend","days":14}`, "bulk-extend-once")
require.Equal(t, http.StatusConflict, conflict.Code)
require.Equal(t, 1, repo.extendCalls)
}
func TestSubscriptionBulkAction_ClientCancellationStillPersistsReplay(t *testing.T) {
gin.SetMode(gin.TestMode)
idempotencyRepo := &cancellationAwareAdminIdempotencyRepo{memoryIdempotencyRepoStub: newMemoryIdempotencyRepoStub()}
service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(idempotencyRepo, service.DefaultIdempotencyConfig()))
t.Cleanup(func() { service.SetDefaultIdempotencyCoordinator(nil) })
requestCtx, cancel := context.WithCancel(context.Background())
defer cancel()
expiresAt := time.Now().AddDate(0, 0, 30)
repo := &cancellationAwareBulkActionRepo{
bulkActionHandlerSubscriptionRepo: &bulkActionHandlerSubscriptionRepo{sub: &service.UserSubscription{ID: 1, UserID: 1, GroupID: 10, ExpiresAt: expiresAt}},
cancelRequest: cancel,
}
svc := service.NewSubscriptionService(nil, repo, nil, nil, nil)
t.Cleanup(svc.Stop)
router := gin.New()
path := "/api/v1/admin/subscriptions/bulk-action"
router.POST(path, NewSubscriptionHandler(svc).BulkAction)
body := `{"subscription_ids":[1],"action":"extend","days":7}`
request := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(body)).WithContext(requestCtx)
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Idempotency-Key", "canceled-request")
first := httptest.NewRecorder()
router.ServeHTTP(first, request)
require.ErrorIs(t, requestCtx.Err(), context.Canceled)
require.Equal(t, http.StatusOK, first.Code, first.Body.String())
require.Equal(t, 1, idempotencyRepo.saves)
replayed := bulkActionHandlerRequest(router, path, body, "canceled-request")
require.Equal(t, http.StatusOK, replayed.Code, replayed.Body.String())
require.Equal(t, "true", replayed.Header().Get("X-Idempotency-Replayed"))
require.JSONEq(t, first.Body.String(), replayed.Body.String())
require.Equal(t, 1, repo.extendCalls)
require.Equal(t, expiresAt.AddDate(0, 0, 7), repo.sub.ExpiresAt)
}
func TestSubscriptionBulkAction_ValidatesBeforeIdempotencyAndExecution(t *testing.T) {
gin.SetMode(gin.TestMode)
service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(storeUnavailableRepoStub{}, service.DefaultIdempotencyConfig()))
t.Cleanup(func() { service.SetDefaultIdempotencyCoordinator(nil) })
router := gin.New()
path := "/api/v1/admin/subscriptions/bulk-action"
router.POST(path, NewSubscriptionHandler(nil).BulkAction)
tooManyIDs := make([]int64, 101)
for i := range tooManyIDs {
tooManyIDs[i] = 1
}
tooManyBody, err := json.Marshal(service.BulkSubscriptionActionInput{SubscriptionIDs: tooManyIDs, Action: "revoke"})
require.NoError(t, err)
for _, body := range []string{
`{"subscription_ids":[1],"action":"delete"}`,
`{"subscription_ids":[1,0],"action":"revoke"}`,
`{"subscription_ids":[],"action":"restore"}`,
`{"subscription_ids":[1],"action":"extend","days":0}`,
`{"subscription_ids":[1],"action":"extend","days":36501}`,
`{"subscription_ids":[1],"action":"reset_quota"}`,
`{"subscription_ids":[1],"action":"reset_quota","daily":"yes"}`,
`{"subscription_ids":[1],"action":"revoke"`,
string(tooManyBody),
} {
t.Run(body, func(t *testing.T) {
response := bulkActionHandlerRequest(router, path, body, "bulk-invalid")
require.Equal(t, http.StatusBadRequest, response.Code, response.Body.String())
})
}
// A valid request must still fail closed when replay protection is unavailable.
response := bulkActionHandlerRequest(router, path, `{"subscription_ids":[1],"action":"revoke"}`, "bulk-valid")
require.Equal(t, http.StatusServiceUnavailable, response.Code)
}
func TestSubscriptionBulkAssign_RejectsInvalidUserIDsBeforeExecution(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
path := "/api/v1/admin/subscriptions/bulk-assign"
router.POST(path, NewSubscriptionHandler(nil).BulkAssign)
for _, ids := range [][]int64{{}, {1, 0}, {1, -1}, make([]int64, 101)} {
t.Run(fmt.Sprint(len(ids), ids), func(t *testing.T) {
body, err := json.Marshal(BulkAssignSubscriptionRequest{UserIDs: ids, GroupID: 1, ValidityDays: 30})
require.NoError(t, err)
response := bulkActionHandlerRequest(router, path, string(body), "")
require.Equal(t, http.StatusBadRequest, response.Code, response.Body.String())
})
}
}
@@ -3,6 +3,7 @@ package admin
import (
"context"
"strconv"
"time"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
@@ -48,7 +49,7 @@ type AssignSubscriptionRequest struct {
// BulkAssignSubscriptionRequest represents bulk assign subscription request
type BulkAssignSubscriptionRequest struct {
UserIDs []int64 `json:"user_ids" binding:"required,min=1"`
UserIDs []int64 `json:"user_ids" binding:"required,min=1,max=100,dive,gt=0"`
GroupID int64 `json:"group_id" binding:"required"`
ValidityDays int `json:"validity_days" binding:"omitempty,max=36500"` // max 100 years
Notes string `json:"notes"`
@@ -186,6 +187,23 @@ func (h *SubscriptionHandler) BulkAssign(c *gin.Context) {
response.Success(c, dto.BulkAssignResultFromService(result))
}
// BulkAction applies one operation to selected subscriptions, returning each outcome.
// POST /api/v1/admin/subscriptions/bulk-action
func (h *SubscriptionHandler) BulkAction(c *gin.Context) {
var req service.BulkSubscriptionActionInput
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
if err := req.Validate(); err != nil {
response.ErrorFrom(c, err)
return
}
executeAdminIdempotentJSONWithTimeout(c, "admin.subscriptions.bulk-action", req, service.DefaultWriteIdempotencyTTL(), 2*time.Minute, func(ctx context.Context) (any, error) {
return h.subscriptionService.BulkSubscriptionAction(ctx, &req)
})
}
// Extend handles adjusting a subscription (extend or shorten)
// POST /api/v1/admin/subscriptions/:id/extend
func (h *SubscriptionHandler) Extend(c *gin.Context) {
+1
View File
@@ -675,6 +675,7 @@ func registerSubscriptionRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
subscriptions.GET("/:id/progress", h.Admin.Subscription.GetProgress)
subscriptions.POST("/assign", h.Admin.Subscription.Assign)
subscriptions.POST("/bulk-assign", h.Admin.Subscription.BulkAssign)
subscriptions.POST("/bulk-action", h.Admin.Subscription.BulkAction)
subscriptions.POST("/:id/extend", h.Admin.Subscription.Extend)
subscriptions.POST("/:id/reset-quota", h.Admin.Subscription.ResetQuota)
subscriptions.POST("/:id/revoke", h.Admin.Subscription.Revoke)
@@ -0,0 +1,50 @@
package routes
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/handler"
adminhandler "github.com/Wei-Shaw/sub2api/internal/handler/admin"
servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestSubscriptionBulkActionRoutesRequireAdminAuthentication(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
handlers := &handler.Handlers{Admin: &handler.AdminHandlers{Subscription: adminhandler.NewSubscriptionHandler(nil)}}
adminAuth := servermiddleware.AdminAuthMiddleware(func(c *gin.Context) {
if c.GetHeader("Authorization") == "" {
servermiddleware.AbortWithError(c, http.StatusUnauthorized, "UNAUTHORIZED", "Authorization required")
return
}
servermiddleware.AbortWithError(c, http.StatusForbidden, "FORBIDDEN", "Admin access required")
})
auditLog := servermiddleware.AuditLogMiddleware(func(c *gin.Context) { c.Next() })
stepUp := servermiddleware.StepUpAuthMiddleware(func(c *gin.Context) { c.Next() })
RegisterAdminRoutes(router.Group("/api/v1"), handlers, adminAuth, auditLog, stepUp, nil, nil)
for _, path := range []string{"/api/v1/admin/subscriptions/bulk-action", "/api/v1/admin/subscriptions/bulk-assign"} {
for _, tc := range []struct {
name string
auth string
wantStatus int
}{
{name: "unauthenticated", wantStatus: http.StatusUnauthorized},
{name: "non-admin", auth: "Bearer user-token", wantStatus: http.StatusForbidden},
} {
t.Run(path+"/"+tc.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, path, nil)
if tc.auth != "" {
request.Header.Set("Authorization", tc.auth)
}
router.ServeHTTP(recorder, request)
require.Equal(t, tc.wantStatus, recorder.Code)
})
}
}
}
+66 -2
View File
@@ -87,6 +87,9 @@ type IdempotencyExecuteOptions struct {
Payload any
TTL time.Duration
RequireKey bool
// ExecutionTimeout opts into bounded execution independent of client cancellation,
// with lease renewal and a separate short window to persist the final result.
ExecutionTimeout time.Duration
}
type IdempotencyExecuteResult struct {
@@ -202,6 +205,11 @@ func (c *IdempotencyCoordinator) Execute(
opts IdempotencyExecuteOptions,
execute func(context.Context) (any, error),
) (*IdempotencyExecuteResult, error) {
if opts.ExecutionTimeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(context.WithoutCancel(ctx), opts.ExecutionTimeout)
defer cancel()
}
if execute == nil {
return nil, infraerrors.InternalServer("IDEMPOTENCY_EXECUTOR_NIL", "idempotency executor is nil")
}
@@ -392,12 +400,26 @@ func (c *IdempotencyCoordinator) Execute(
return nil, ErrIdempotencyStoreUnavail
}
if opts.ExecutionTimeout > 0 {
stopRenewal := c.renewProcessingLease(ctx, record, ttl)
defer stopRenewal()
}
execStart := time.Now()
defer func() {
recordIdempotencyProcessingDuration(opts.Route, opts.Scope, time.Since(execStart), nil)
}()
data, execErr := execute(ctx)
persistCtx := ctx
if opts.ExecutionTimeout > 0 {
// The execution deadline can expire after some items have completed.
// Store those outcomes even then, using a fresh but bounded context.
var cancel context.CancelFunc
persistCtx, cancel = context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
expiresAt = time.Now().Add(ttl)
}
if execErr != nil {
backoffUntil := time.Now().Add(c.cfg.FailedRetryBackoff)
reason := infraerrors.Reason(execErr)
@@ -408,7 +430,7 @@ func (c *IdempotencyCoordinator) Execute(
logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "processing->failed_retryable", false, map[string]string{
"reason": reason,
})
if markErr := c.repo.MarkFailedRetryable(ctx, record.ID, reason, backoffUntil, expiresAt); markErr != nil {
if markErr := c.repo.MarkFailedRetryable(persistCtx, record.ID, reason, backoffUntil, expiresAt); markErr != nil {
RecordIdempotencyStoreUnavailable(opts.Route, opts.Scope, "mark_failed_retryable_error")
logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "processing->store_unavailable", false, map[string]string{
"operation": "mark_failed_retryable",
@@ -425,7 +447,7 @@ func (c *IdempotencyCoordinator) Execute(
})
return nil, ErrIdempotencyStoreUnavail.WithCause(marshalErr)
}
if markErr := c.repo.MarkSucceeded(ctx, record.ID, 200, storedBody, expiresAt); markErr != nil {
if markErr := c.repo.MarkSucceeded(persistCtx, record.ID, 200, storedBody, expiresAt); markErr != nil {
RecordIdempotencyStoreUnavailable(opts.Route, opts.Scope, "mark_succeeded_error")
logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "processing->store_unavailable", false, map[string]string{
"operation": "mark_succeeded",
@@ -437,6 +459,48 @@ func (c *IdempotencyCoordinator) Execute(
return &IdempotencyExecuteResult{Data: data}, nil
}
// renewProcessingLease uses the same repository lease primitive as system
// operations. Renewal continues through slow batches and stops before Execute exits.
func (c *IdempotencyCoordinator) renewProcessingLease(ctx context.Context, record *IdempotencyRecord, ttl time.Duration) func() {
lease := c.cfg.ProcessingTimeout
if lease <= 0 {
lease = DefaultIdempotencyConfig().ProcessingTimeout
}
interval := min(lease/3, ttl/3)
if interval < time.Millisecond {
interval = time.Millisecond
}
renewCtx, cancel := context.WithCancel(ctx)
done := make(chan struct{})
go func() {
defer close(done)
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-renewCtx.Done():
return
case <-ticker.C:
now := time.Now()
callCtx, callCancel := context.WithTimeout(renewCtx, min(2*time.Second, interval))
ok, err := c.repo.ExtendProcessingLock(callCtx, record.ID, record.RequestFingerprint, now.Add(lease), now.Add(ttl))
callCancel()
if err != nil {
RecordIdempotencyStoreUnavailable("", record.Scope, "renew_processing_lock_error")
continue
}
if !ok {
return
}
}
}
}()
return func() {
cancel()
<-done
}
}
func (c *IdempotencyCoordinator) conflictWithRetryAfter(base *infraerrors.ApplicationError, lockedUntil *time.Time, now time.Time) error {
if lockedUntil == nil {
return base
@@ -0,0 +1,119 @@
package service
import (
"context"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
)
type detachedIdempotencyRepo struct {
*inMemoryIdempotencyRepo
renewals atomic.Int32
savedWithBound bool
}
func (r *detachedIdempotencyRepo) MarkSucceeded(ctx context.Context, id int64, status int, body string, expiresAt time.Time) error {
if err := ctx.Err(); err != nil {
return err
}
deadline, bounded := ctx.Deadline()
r.savedWithBound = bounded && time.Until(deadline) <= 5*time.Second
return r.inMemoryIdempotencyRepo.MarkSucceeded(ctx, id, status, body, expiresAt)
}
func (r *detachedIdempotencyRepo) ExtendProcessingLock(ctx context.Context, id int64, fingerprint string, lockedUntil, expiresAt time.Time) (bool, error) {
ok, err := r.inMemoryIdempotencyRepo.ExtendProcessingLock(ctx, id, fingerprint, lockedUntil, expiresAt)
if ok && err == nil {
r.renewals.Add(1)
}
return ok, err
}
func TestIdempotencyCoordinator_DetachedExecutionPersistsAfterCancellation(t *testing.T) {
for _, deadlineExpires := range []bool{false, true} {
name := "client cancellation"
if deadlineExpires {
name = "execution deadline"
}
t.Run(name, func(t *testing.T) {
repo := &detachedIdempotencyRepo{inMemoryIdempotencyRepo: newInMemoryIdempotencyRepo()}
coordinator := NewIdempotencyCoordinator(repo, DefaultIdempotencyConfig())
requestCtx, cancel := context.WithCancel(context.Background())
defer cancel()
opts := IdempotencyExecuteOptions{
Scope: "admin.bulk", Method: "POST", Route: "/bulk", IdempotencyKey: "cancel-test", RequireKey: true,
Payload: map[string]int{"days": 7}, ExecutionTimeout: time.Second,
}
if deadlineExpires {
opts.ExecutionTimeout = 10 * time.Millisecond
}
executions := 0
execute := func(ctx context.Context) (any, error) {
executions++
if deadlineExpires {
<-ctx.Done()
} else {
cancel()
require.NoError(t, ctx.Err(), "client cancellation must not interrupt the accepted mutation")
}
return map[string]int{"success_count": 1, "failed_count": 1}, nil
}
result, err := coordinator.Execute(requestCtx, opts, execute)
require.NoError(t, err)
require.NotNil(t, result.Data)
require.True(t, repo.savedWithBound, "result persistence must use a live bounded context")
replay, err := coordinator.Execute(context.Background(), opts, execute)
require.NoError(t, err)
require.True(t, replay.Replayed)
require.Equal(t, 1, executions)
})
}
}
func TestIdempotencyCoordinator_DetachedExecutionRenewsLongBatchLease(t *testing.T) {
repo := &detachedIdempotencyRepo{inMemoryIdempotencyRepo: newInMemoryIdempotencyRepo()}
cfg := DefaultIdempotencyConfig()
cfg.ProcessingTimeout = 30 * time.Millisecond
cfg.DefaultTTL = 50 * time.Millisecond
coordinator := NewIdempotencyCoordinator(repo, cfg)
opts := IdempotencyExecuteOptions{
Scope: "admin.bulk", Method: "POST", Route: "/bulk", IdempotencyKey: "long-batch", RequireKey: true,
Payload: map[string]int{"days": 7}, ExecutionTimeout: 2 * time.Second,
}
var executions atomic.Int32
release := make(chan struct{})
t.Cleanup(func() {
select {
case <-release:
default:
close(release)
}
})
execute := func(ctx context.Context) (any, error) {
executions.Add(1)
select {
case <-release:
case <-ctx.Done():
}
return map[string]int{"success_count": 1}, nil
}
completed := make(chan error, 1)
go func() {
_, err := coordinator.Execute(context.Background(), opts, execute)
completed <- err
}()
// Ten renewals exceed both the original processing lease and record TTL.
require.Eventually(t, func() bool { return repo.renewals.Load() >= 10 }, time.Second, 5*time.Millisecond)
_, err := coordinator.Execute(context.Background(), opts, execute)
require.ErrorIs(t, err, ErrIdempotencyInProgress)
require.Equal(t, int32(1), executions.Load())
close(release)
require.NoError(t, <-completed)
replay, err := coordinator.Execute(context.Background(), opts, execute)
require.NoError(t, err)
require.True(t, replay.Replayed)
require.Equal(t, int32(1), executions.Load())
}
@@ -0,0 +1,129 @@
package service
import (
"context"
"errors"
"log"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/util/logredact"
)
const MaxBulkSubscriptionActions = 100
// BulkSubscriptionActionInput applies one operation to a bounded set of subscriptions.
type BulkSubscriptionActionInput struct {
SubscriptionIDs []int64 `json:"subscription_ids"`
Action string `json:"action"`
Days int `json:"days,omitempty"`
Daily bool `json:"daily,omitempty"`
Weekly bool `json:"weekly,omitempty"`
Monthly bool `json:"monthly,omitempty"`
}
// Validate checks the entire request before any subscription is changed.
func (input *BulkSubscriptionActionInput) Validate() error {
if input == nil {
return ErrSubscriptionNilInput
}
if len(input.SubscriptionIDs) == 0 || len(input.SubscriptionIDs) > MaxBulkSubscriptionActions {
return infraerrors.BadRequest("INVALID_BULK_SUBSCRIPTIONS", "subscription_ids must contain between 1 and 100 IDs")
}
for _, id := range input.SubscriptionIDs {
if id <= 0 {
return infraerrors.BadRequest("INVALID_SUBSCRIPTION_ID", "subscription IDs must be positive")
}
}
switch input.Action {
case "extend":
if input.Days == 0 || input.Days < -MaxValidityDays || input.Days > MaxValidityDays {
return infraerrors.BadRequest("INVALID_ADJUSTMENT_DAYS", "days must be nonzero and between -36500 and 36500")
}
case "reset_quota":
if !input.Daily && !input.Weekly && !input.Monthly {
return ErrInvalidInput
}
case "revoke", "restore":
default:
return infraerrors.BadRequest("INVALID_SUBSCRIPTION_ACTION", "action must be extend, reset_quota, revoke, or restore")
}
return nil
}
type BulkSubscriptionActionItemResult struct {
SubscriptionID int64 `json:"subscription_id"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
type BulkSubscriptionActionResult struct {
SuccessCount int `json:"success_count"`
FailedCount int `json:"failed_count"`
Results []BulkSubscriptionActionItemResult `json:"results"`
}
// BulkSubscriptionAction preserves input order and executes duplicate IDs only once.
// Individual failures remain in the result instead of returning a top-level error,
// allowing callers to retain the completed portion of a partially successful batch.
func (s *SubscriptionService) BulkSubscriptionAction(ctx context.Context, input *BulkSubscriptionActionInput) (*BulkSubscriptionActionResult, error) {
if err := input.Validate(); err != nil {
return nil, err
}
result := &BulkSubscriptionActionResult{
Results: make([]BulkSubscriptionActionItemResult, 0, len(input.SubscriptionIDs)),
}
seen := make(map[int64]struct{}, len(input.SubscriptionIDs))
for _, id := range input.SubscriptionIDs {
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
err := ctx.Err()
if err == nil {
var changed *UserSubscription
// Single-item services may read again or update status after their first
// write. Keep all those steps in one transaction, so a failed item can
// be retried without repeating a previously committed extension/reset.
err = s.withSubscriptionUpdateTx(ctx, func(txCtx context.Context) error {
var mutationErr error
switch input.Action {
case "extend":
changed, mutationErr = s.ExtendSubscription(txCtx, id, input.Days)
case "reset_quota":
changed, mutationErr = s.AdminResetQuota(txCtx, id, input.Daily, input.Weekly, input.Monthly)
case "revoke":
changed, mutationErr = s.userSubRepo.GetByID(txCtx, id)
if mutationErr == nil {
mutationErr = s.RevokeSubscription(txCtx, id)
}
case "restore":
changed, mutationErr = s.RestoreSubscription(txCtx, id)
}
return mutationErr
})
if err == nil && changed != nil {
// Invalidate again after commit: concurrent readers could have filled
// a cache with the old row while the transaction was still open.
if cacheErr := s.invalidateSubscriptionCaches(changed.UserID, changed.GroupID); cacheErr != nil {
log.Printf("[SubscriptionBulkAction] committed action=%s subscription_id=%d cache_error=%s", input.Action, id, logredact.RedactText(cacheErr.Error()))
}
}
}
item := BulkSubscriptionActionItemResult{SubscriptionID: id, Success: err == nil}
if err != nil {
result.FailedCount++
item.Error = infraerrors.Message(err)
if errors.Is(err, context.Canceled) {
item.Error = context.Canceled.Error()
} else if errors.Is(err, context.DeadlineExceeded) {
item.Error = context.DeadlineExceeded.Error()
} else if infraerrors.Code(err) >= 500 {
log.Printf("[SubscriptionBulkAction] action=%s subscription_id=%d error=%s", input.Action, id, logredact.RedactText(err.Error()))
}
} else {
result.SuccessCount++
}
result.Results = append(result.Results, item)
}
return result, nil
}
@@ -0,0 +1,230 @@
package service
import (
"context"
"errors"
"fmt"
"testing"
"time"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/stretchr/testify/require"
)
type bulkActionSubscriptionRepo struct {
UserSubscriptionRepository
subscriptions map[int64]*UserSubscription
mutations []int64
afterMutation func()
}
func (r *bulkActionSubscriptionRepo) GetByIDIncludeDeleted(_ context.Context, id int64) (*UserSubscription, error) {
sub := r.subscriptions[id]
if sub == nil {
return nil, ErrSubscriptionNotFound
}
copy := *sub
return &copy, nil
}
func (r *bulkActionSubscriptionRepo) GetByID(ctx context.Context, id int64) (*UserSubscription, error) {
sub, err := r.GetByIDIncludeDeleted(ctx, id)
if err != nil {
return nil, err
}
if sub.DeletedAt != nil {
return nil, ErrSubscriptionNotFound
}
return sub, nil
}
func (r *bulkActionSubscriptionRepo) GetByIDForUpdate(ctx context.Context, id int64) (*UserSubscription, error) {
return r.GetByID(ctx, id)
}
func (r *bulkActionSubscriptionRepo) mutated(id int64) {
r.mutations = append(r.mutations, id)
if r.afterMutation != nil {
r.afterMutation()
}
}
func (r *bulkActionSubscriptionRepo) ExtendExpiry(_ context.Context, id int64, expiresAt time.Time) error {
r.subscriptions[id].ExpiresAt = expiresAt
r.mutated(id)
return nil
}
func (r *bulkActionSubscriptionRepo) ResetUsageWindows(_ context.Context, id int64, daily, weekly, monthly bool, dailyStart, periodicStart time.Time) error {
sub := r.subscriptions[id]
if daily {
sub.DailyUsageUSD, sub.DailyWindowStart = 0, &dailyStart
}
if weekly {
sub.WeeklyUsageUSD, sub.WeeklyWindowStart = 0, &periodicStart
}
if monthly {
sub.MonthlyUsageUSD, sub.MonthlyWindowStart = 0, &periodicStart
}
r.mutated(id)
return nil
}
func (r *bulkActionSubscriptionRepo) Delete(_ context.Context, id int64) error {
now := time.Now()
r.subscriptions[id].DeletedAt = &now
r.mutated(id)
return nil
}
func (r *bulkActionSubscriptionRepo) ExistsActiveByUserIDAndGroupID(_ context.Context, userID, groupID int64) (bool, error) {
for _, sub := range r.subscriptions {
if sub.UserID == userID && sub.GroupID == groupID && sub.DeletedAt == nil {
return true, nil
}
}
return false, nil
}
func (r *bulkActionSubscriptionRepo) Restore(_ context.Context, id int64, status string) (*UserSubscription, error) {
sub := r.subscriptions[id]
sub.DeletedAt, sub.Status = nil, status
r.mutated(id)
copy := *sub
return &copy, nil
}
func TestBulkSubscriptionAction_PartialSuccessAndDeduplication(t *testing.T) {
for _, action := range []string{"extend", "reset_quota", "revoke", "restore"} {
t.Run(action, func(t *testing.T) {
expiresAt := time.Now().AddDate(0, 0, 30)
repo := &bulkActionSubscriptionRepo{subscriptions: map[int64]*UserSubscription{}}
for _, id := range []int64{1, 2} {
sub := &UserSubscription{ID: id, UserID: id, GroupID: 10, Status: SubscriptionStatusActive, ExpiresAt: expiresAt, DailyUsageUSD: 2, WeeklyUsageUSD: 5, MonthlyUsageUSD: 8}
if action == "restore" {
deletedAt := time.Now().Add(-time.Hour)
sub.DeletedAt = &deletedAt
}
repo.subscriptions[id] = sub
}
svc := NewSubscriptionService(nil, repo, nil, nil, nil)
t.Cleanup(svc.Stop)
result, err := svc.BulkSubscriptionAction(context.Background(), &BulkSubscriptionActionInput{
SubscriptionIDs: []int64{2, 404, 1, 2, 404}, Action: action, Days: 7, Daily: true, Weekly: true,
})
require.NoError(t, err)
require.Equal(t, 2, result.SuccessCount)
require.Equal(t, 1, result.FailedCount)
require.Equal(t, []BulkSubscriptionActionItemResult{
{SubscriptionID: 2, Success: true},
{SubscriptionID: 404, Error: "subscription not found"},
{SubscriptionID: 1, Success: true},
}, result.Results)
require.Equal(t, []int64{2, 1}, repo.mutations)
for _, sub := range repo.subscriptions {
switch action {
case "extend":
require.Equal(t, expiresAt.AddDate(0, 0, 7), sub.ExpiresAt)
case "reset_quota":
require.Zero(t, sub.DailyUsageUSD)
require.Zero(t, sub.WeeklyUsageUSD)
require.Equal(t, float64(8), sub.MonthlyUsageUSD)
case "revoke":
require.NotNil(t, sub.DeletedAt)
case "restore":
require.Nil(t, sub.DeletedAt)
require.Equal(t, SubscriptionStatusActive, sub.Status)
}
}
})
}
}
func TestBulkSubscriptionAction_ValidatesBeforeAnyRepositoryAccess(t *testing.T) {
tooMany := make([]int64, MaxBulkSubscriptionActions+1)
for i := range tooMany {
tooMany[i] = 1
}
for name, input := range map[string]*BulkSubscriptionActionInput{
"nil": nil,
"empty IDs": {Action: "revoke"},
"too many IDs": {SubscriptionIDs: tooMany, Action: "revoke"},
"invalid later ID": {SubscriptionIDs: []int64{1, 0}, Action: "revoke"},
"negative ID": {SubscriptionIDs: []int64{1, -1}, Action: "revoke"},
"unknown action": {SubscriptionIDs: []int64{1}, Action: "delete"},
"missing action": {SubscriptionIDs: []int64{1}},
"zero adjustment": {SubscriptionIDs: []int64{1}, Action: "extend"},
"large adjustment": {SubscriptionIDs: []int64{1}, Action: "extend", Days: MaxValidityDays + 1},
"small adjustment": {SubscriptionIDs: []int64{1}, Action: "extend", Days: -MaxValidityDays - 1},
"no reset windows": {SubscriptionIDs: []int64{1}, Action: "reset_quota"},
} {
t.Run(name, func(t *testing.T) {
// A nil repository would panic if validation allowed any execution.
svc := &SubscriptionService{}
result, err := svc.BulkSubscriptionAction(context.Background(), input)
require.Error(t, err)
require.Equal(t, 400, infraerrors.Code(err))
require.Nil(t, result)
})
}
for _, days := range []int{-MaxValidityDays, -1, 1, MaxValidityDays} {
input := BulkSubscriptionActionInput{SubscriptionIDs: []int64{1}, Action: "extend", Days: days}
require.NoError(t, input.Validate())
}
}
func TestBulkSubscriptionAction_CancellationPreservesCompletedResults(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
repo := &bulkActionSubscriptionRepo{
subscriptions: map[int64]*UserSubscription{1: {ID: 1, UserID: 1, GroupID: 10}},
afterMutation: cancel,
}
svc := NewSubscriptionService(nil, repo, nil, nil, nil)
t.Cleanup(svc.Stop)
result, err := svc.BulkSubscriptionAction(ctx, &BulkSubscriptionActionInput{SubscriptionIDs: []int64{1, 2, 3}, Action: "revoke"})
require.NoError(t, err)
require.Equal(t, 1, result.SuccessCount)
require.Equal(t, 2, result.FailedCount)
require.Equal(t, []int64{1}, repo.mutations)
require.True(t, result.Results[0].Success)
for _, item := range result.Results[1:] {
require.False(t, item.Success)
require.Equal(t, context.Canceled.Error(), item.Error)
}
}
type failingBulkActionSubscriptionRepo struct {
*bulkActionSubscriptionRepo
err error
}
func (r failingBulkActionSubscriptionRepo) Delete(context.Context, int64) error {
return r.err
}
func TestBulkSubscriptionAction_DoesNotExposeInternalErrors(t *testing.T) {
for _, tc := range []struct {
name string
err error
want string
}{
{name: "internal failure", err: errors.New("postgres: internal connection details"), want: "internal error"},
{name: "wrapped cancellation", err: fmt.Errorf("postgres: internal connection details: %w", context.Canceled), want: context.Canceled.Error()},
{name: "wrapped deadline", err: fmt.Errorf("postgres: internal connection details: %w", context.DeadlineExceeded), want: context.DeadlineExceeded.Error()},
} {
t.Run(tc.name, func(t *testing.T) {
repo := failingBulkActionSubscriptionRepo{
bulkActionSubscriptionRepo: &bulkActionSubscriptionRepo{subscriptions: map[int64]*UserSubscription{1: {ID: 1}}},
err: tc.err,
}
svc := NewSubscriptionService(nil, repo, nil, nil, nil)
t.Cleanup(svc.Stop)
result, err := svc.BulkSubscriptionAction(context.Background(), &BulkSubscriptionActionInput{SubscriptionIDs: []int64{1}, Action: "revoke"})
require.NoError(t, err)
require.Equal(t, 1, result.FailedCount)
require.Equal(t, tc.want, result.Results[0].Error)
})
}
}
@@ -0,0 +1,140 @@
package service
import (
"context"
"errors"
"testing"
"time"
"entgo.io/ent/dialect"
entsql "entgo.io/ent/dialect/sql"
"github.com/DATA-DOG/go-sqlmock"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/stretchr/testify/require"
)
// This repository stages mutations against the Ent transaction from context and
// makes them visible only when the real Ent/sql transaction commits.
type transactionalBulkSubscriptionRepo struct {
UserSubscriptionRepository
committed UserSubscription
pending map[*dbent.Tx]*UserSubscription
reads map[*dbent.Tx]int
postReadFailure bool
statusFailure bool
}
func (r *transactionalBulkSubscriptionRepo) GetByID(ctx context.Context, _ int64) (*UserSubscription, error) {
tx := dbent.TxFromContext(ctx)
if tx == nil {
return nil, errors.New("bulk mutation must use a transaction")
}
if r.pending[tx] == nil {
copy := r.committed
r.pending[tx] = &copy
tx.OnCommit(func(next dbent.Committer) dbent.Committer {
return dbent.CommitFunc(func(ctx context.Context, tx *dbent.Tx) error {
if err := next.Commit(ctx, tx); err != nil {
return err
}
r.committed = *r.pending[tx]
return nil
})
})
}
r.reads[tx]++
if r.reads[tx] > 1 && r.postReadFailure {
return nil, errors.New("refresh failed after write")
}
copy := *r.pending[tx]
return &copy, nil
}
func (r *transactionalBulkSubscriptionRepo) GetByIDForUpdate(ctx context.Context, id int64) (*UserSubscription, error) {
return r.GetByID(ctx, id)
}
func (r *transactionalBulkSubscriptionRepo) ExtendExpiry(ctx context.Context, _ int64, expiry time.Time) error {
r.pending[dbent.TxFromContext(ctx)].ExpiresAt = expiry
return nil
}
func (r *transactionalBulkSubscriptionRepo) UpdateStatus(ctx context.Context, _ int64, status string) error {
if r.statusFailure {
return errors.New("status update failed after expiry write")
}
r.pending[dbent.TxFromContext(ctx)].Status = status
return nil
}
func (r *transactionalBulkSubscriptionRepo) ResetUsageWindows(ctx context.Context, _ int64, daily, weekly, monthly bool, dailyStart, periodicStart time.Time) error {
sub := r.pending[dbent.TxFromContext(ctx)]
if daily {
sub.DailyUsageUSD, sub.DailyWindowStart = 0, &dailyStart
}
if weekly {
sub.WeeklyUsageUSD, sub.WeeklyWindowStart = 0, &periodicStart
}
if monthly {
sub.MonthlyUsageUSD, sub.MonthlyWindowStart = 0, &periodicStart
}
return nil
}
func TestBulkSubscriptionAction_RollsBackPostWriteFailureBeforeRetry(t *testing.T) {
for _, tc := range []struct {
name string
action string
postReadFailure bool
statusFailure bool
}{
{name: "extend refresh failure", action: "extend", postReadFailure: true},
{name: "extend status failure", action: "extend", statusFailure: true},
{name: "quota refresh failure", action: "reset_quota", postReadFailure: true},
} {
t.Run(tc.name, func(t *testing.T) {
sqlDB, mock, err := sqlmock.New()
require.NoError(t, err)
client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, sqlDB)))
t.Cleanup(func() { _ = client.Close() })
expiresAt := time.Now().AddDate(0, 0, 30)
status := SubscriptionStatusActive
if tc.statusFailure {
status = SubscriptionStatusExpired
}
repo := &transactionalBulkSubscriptionRepo{
committed: UserSubscription{ID: 1, UserID: 10, GroupID: 20, Status: status, ExpiresAt: expiresAt, DailyUsageUSD: 7},
pending: make(map[*dbent.Tx]*UserSubscription),
reads: make(map[*dbent.Tx]int),
postReadFailure: tc.postReadFailure,
statusFailure: tc.statusFailure,
}
svc := NewSubscriptionService(nil, repo, nil, client, nil)
t.Cleanup(svc.Stop)
input := &BulkSubscriptionActionInput{SubscriptionIDs: []int64{1}, Action: tc.action, Days: 7, Daily: true}
mock.ExpectBegin()
mock.ExpectRollback()
result, err := svc.BulkSubscriptionAction(context.Background(), input)
require.NoError(t, err)
require.Equal(t, 1, result.FailedCount)
require.Equal(t, expiresAt, repo.committed.ExpiresAt, "failed operation must not commit its earlier expiry write")
require.Equal(t, status, repo.committed.Status)
require.Equal(t, float64(7), repo.committed.DailyUsageUSD)
repo.postReadFailure, repo.statusFailure = false, false
mock.ExpectBegin()
mock.ExpectCommit()
result, err = svc.BulkSubscriptionAction(context.Background(), input)
require.NoError(t, err)
require.Equal(t, 1, result.SuccessCount)
if tc.action == "extend" {
require.Equal(t, expiresAt.AddDate(0, 0, 7), repo.committed.ExpiresAt, "retry must extend only once")
require.Equal(t, SubscriptionStatusActive, repo.committed.Status)
} else {
require.Zero(t, repo.committed.DailyUsageUSD)
}
require.NoError(t, mock.ExpectationsWereMet())
})
}
}
@@ -115,6 +115,22 @@ func TestAssignOrExtendSubscriptionSerializedRenewalsAccumulateDays(t *testing.T
require.Equal(t, initialExpiry.AddDate(0, 0, 14), second.ExpiresAt)
}
func TestExtendSubscriptionUsesLockedCurrentRow(t *testing.T) {
now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
initialExpiry := now.AddDate(0, 0, 10)
repo := &lockingRenewalRepo{current: UserSubscription{
ID: 37, UserID: 41, GroupID: 43, ExpiresAt: initialExpiry, Status: SubscriptionStatusActive,
}}
svc := NewSubscriptionService(nil, repo, nil, nil, nil)
svc.now = func() time.Time { return now }
updated, err := svc.ExtendSubscription(context.Background(), 7, 5)
require.NoError(t, err)
require.Equal(t, 1, repo.lockReads)
require.Equal(t, initialExpiry.AddDate(0, 0, 5), updated.ExpiresAt)
}
func TestAssignSubscriptionDoesNotReactivateRowSuspendedAfterStaleRead(t *testing.T) {
now := time.Date(2026, 8, 2, 12, 0, 0, 0, time.UTC)
windowStart := now.Add(-24 * time.Hour)
@@ -652,55 +652,72 @@ func (s *SubscriptionService) RestoreSubscription(ctx context.Context, subscript
// ExtendSubscription 调整订阅时长(正数延长,负数缩短)
func (s *SubscriptionService) ExtendSubscription(ctx context.Context, subscriptionID int64, days int) (*UserSubscription, error) {
sub, err := s.userSubRepo.GetByID(ctx, subscriptionID)
err := s.withSubscriptionUpdateTx(ctx, func(txCtx context.Context) error {
// Lock the row before reading its expiry. Without this lock, concurrent
// adjustments can both calculate from the same stale expiry and lose one
// of the updates when they write the absolute timestamp.
sub, err := s.userSubRepo.GetByIDForUpdate(txCtx, subscriptionID)
if err != nil {
return ErrSubscriptionNotFound
}
// 限制调整天数范围
if days > MaxValidityDays {
days = MaxValidityDays
}
if days < -MaxValidityDays {
days = -MaxValidityDays
}
now := time.Now()
if s.now != nil {
now = s.now()
}
isExpired := !sub.ExpiresAt.After(now)
// 如果订阅已过期,不允许负向调整
if isExpired && days < 0 {
return infraerrors.BadRequest("CANNOT_SHORTEN_EXPIRED", "cannot shorten an expired subscription")
}
// 计算新的过期时间
var newExpiresAt time.Time
if isExpired {
// 已过期:从当前时间开始增加天数
newExpiresAt = now.AddDate(0, 0, days)
} else {
// 未过期:从原过期时间增加/减少天数
newExpiresAt = sub.ExpiresAt.AddDate(0, 0, days)
}
if newExpiresAt.After(MaxExpiresAt) {
newExpiresAt = MaxExpiresAt
}
// 检查新的过期时间必须大于当前时间
if !newExpiresAt.After(now) {
return ErrAdjustWouldExpire
}
if err := s.userSubRepo.ExtendExpiry(txCtx, subscriptionID, newExpiresAt); err != nil {
return err
}
// 如果订阅已过期,恢复为active状态
if sub.Status == SubscriptionStatusExpired {
if err := s.userSubRepo.UpdateStatus(txCtx, subscriptionID, SubscriptionStatusActive); err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, ErrSubscriptionNotFound
}
// 限制调整天数范围
if days > MaxValidityDays {
days = MaxValidityDays
}
if days < -MaxValidityDays {
days = -MaxValidityDays
}
now := time.Now()
isExpired := !sub.ExpiresAt.After(now)
// 如果订阅已过期,不允许负向调整
if isExpired && days < 0 {
return nil, infraerrors.BadRequest("CANNOT_SHORTEN_EXPIRED", "cannot shorten an expired subscription")
}
// 计算新的过期时间
var newExpiresAt time.Time
if isExpired {
// 已过期:从当前时间开始增加天数
newExpiresAt = now.AddDate(0, 0, days)
} else {
// 未过期:从原过期时间增加/减少天数
newExpiresAt = sub.ExpiresAt.AddDate(0, 0, days)
}
if newExpiresAt.After(MaxExpiresAt) {
newExpiresAt = MaxExpiresAt
}
// 检查新的过期时间必须大于当前时间
if !newExpiresAt.After(now) {
return nil, ErrAdjustWouldExpire
}
if err := s.userSubRepo.ExtendExpiry(ctx, subscriptionID, newExpiresAt); err != nil {
return nil, err
}
// 如果订阅已过期,恢复为active状态
if sub.Status == SubscriptionStatusExpired {
if err := s.userSubRepo.UpdateStatus(ctx, subscriptionID, SubscriptionStatusActive); err != nil {
return nil, err
}
sub, err := s.userSubRepo.GetByID(ctx, subscriptionID)
if err != nil {
return nil, err
}
// 失效订阅缓存
@@ -714,7 +731,7 @@ func (s *SubscriptionService) ExtendSubscription(ctx context.Context, subscripti
}()
}
return s.userSubRepo.GetByID(ctx, subscriptionID)
return sub, nil
}
// GetByID 根据ID获取订阅
@@ -0,0 +1,29 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { subscriptionsAPI } from '../admin/subscriptions'
const { post } = vi.hoisted(() => ({ post: vi.fn() }))
vi.mock('../client', () => ({ apiClient: { post } }))
describe('admin subscription batch APIs', () => {
beforeEach(() => vi.clearAllMocks())
it('sends the explicit operation key and returns partial results', async () => {
const request = { subscription_ids: [3, 5], action: 'extend' as const, days: 7 }
const result = { success_count: 1, failed_count: 1, results: [
{ subscription_id: 3, success: true }, { subscription_id: 5, success: false, error: 'Not found' }
] }
post.mockResolvedValue({ data: result })
expect(await subscriptionsAPI.bulkAction(request, 'operation-123')).toEqual(result)
expect(post).toHaveBeenCalledWith('/admin/subscriptions/bulk-action', request, {
headers: { 'Idempotency-Key': 'operation-123' }
})
})
it('returns the assignment summary and per-user failures', async () => {
const request = { user_ids: [11, 12], group_id: 3, validity_days: 30 }
const result = { success_count: 1, failed_count: 1, subscriptions: [], errors: ['User 12: conflict'], statuses: { 11: 'created', 12: 'failed' } }
post.mockResolvedValue({ data: result })
expect(await subscriptionsAPI.bulkAssign(request)).toEqual(result)
expect(post).toHaveBeenCalledWith('/admin/subscriptions/bulk-assign', request)
})
})
+43 -3
View File
@@ -13,6 +13,45 @@ import type {
PaginatedResponse
} from '@/types'
export type SubscriptionBulkAction = 'extend' | 'reset_quota' | 'revoke' | 'restore'
export interface SubscriptionBulkActionRequest {
subscription_ids: number[]
action: SubscriptionBulkAction
days?: number
daily?: boolean
weekly?: boolean
monthly?: boolean
}
export interface SubscriptionBulkActionResult {
success_count: number
failed_count: number
results: Array<{ subscription_id: number; success: boolean; error?: string }>
}
export interface BulkAssignSubscriptionResult {
success_count: number
created_count: number
reused_count: number
failed_count: number
subscriptions: UserSubscription[]
errors: string[]
statuses?: Record<string, 'created' | 'reused' | 'failed'>
}
export async function bulkAction(
request: SubscriptionBulkActionRequest,
idempotencyKey: string
): Promise<SubscriptionBulkActionResult> {
const { data } = await apiClient.post<SubscriptionBulkActionResult>(
'/admin/subscriptions/bulk-action',
request,
{ headers: { 'Idempotency-Key': idempotencyKey } }
)
return data
}
/**
* List all subscriptions with pagination
* @param page - Page number (default: 1)
@@ -82,12 +121,12 @@ export async function assign(request: AssignSubscriptionRequest): Promise<UserSu
/**
* Bulk assign subscriptions to multiple users
* @param request - Bulk assignment request
* @returns Created subscriptions
* @returns Per-user assignment outcomes and created or reused subscriptions
*/
export async function bulkAssign(
request: BulkAssignSubscriptionRequest
): Promise<UserSubscription[]> {
const { data } = await apiClient.post<UserSubscription[]>(
): Promise<BulkAssignSubscriptionResult> {
const { data } = await apiClient.post<BulkAssignSubscriptionResult>(
'/admin/subscriptions/bulk-assign',
request
)
@@ -196,6 +235,7 @@ export const subscriptionsAPI = {
getProgress,
assign,
bulkAssign,
bulkAction,
extend,
revoke,
restore,
@@ -0,0 +1,225 @@
<template>
<BaseDialog
:show="show"
:title="t(`admin.subscriptions.bulk.${currentAction}`)"
width="normal"
:close-on-escape="canClose"
:show-close-button="canClose"
@close="handleClose"
>
<form id="bulk-subscription-action-form" class="space-y-4" novalidate @submit.prevent="submit">
<div>
<p class="mb-2 text-sm font-medium text-gray-900 dark:text-gray-100">
{{ t('admin.subscriptions.bulk.confirmTargets', { count: targets.length }) }}
</p>
<ul class="max-h-48 divide-y divide-gray-100 overflow-y-auto rounded-lg border border-gray-200 dark:divide-dark-700 dark:border-dark-600">
<li v-for="subscription in targets" :key="subscription.id" class="px-3 py-2 text-sm">
<div class="break-all text-gray-900 dark:text-gray-100">
{{ subscription.email || `#${subscription.id}` }}
</div>
<div class="break-words text-xs text-gray-500 dark:text-gray-400">
{{ subscription.group || t('admin.subscriptions.bulk.groupFallback', { id: subscription.groupId }) }}
<span v-if="subscription.email" class="ml-2">#{{ subscription.id }}</span>
</div>
</li>
</ul>
</div>
<fieldset :disabled="parametersLocked" class="space-y-3 disabled:opacity-70">
<div v-if="currentAction === 'extend'">
<label for="bulk-subscription-days" class="input-label">
{{ t('admin.subscriptions.form.adjustDays') }}
</label>
<input
id="bulk-subscription-days"
v-model.number="days"
type="number"
min="-36500"
max="36500"
step="1"
class="input"
:disabled="parametersLocked"
:placeholder="t('admin.subscriptions.adjustDaysPlaceholder')"
aria-describedby="bulk-subscription-days-hint"
/>
<p id="bulk-subscription-days-hint" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.subscriptions.bulk.extendHint') }}
</p>
</div>
<template v-else-if="currentAction === 'reset_quota'">
<legend class="text-sm font-medium text-gray-700 dark:text-gray-200">
{{ t('admin.subscriptions.bulk.resetWindows') }}
</legend>
<div class="flex flex-wrap gap-5">
<label v-for="window in quotaWindows" :key="window" class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
v-model="windows[window]"
:name="window"
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
:disabled="parametersLocked"
/>
{{ t(`admin.subscriptions.${window}`) }}
</label>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.subscriptions.bulk.resetHint') }}
</p>
</template>
<p v-else class="rounded-lg bg-amber-50 p-3 text-sm text-amber-800 dark:bg-amber-900/20 dark:text-amber-200">
{{ t(`admin.subscriptions.bulk.${currentAction}Hint`) }}
</p>
</fieldset>
<p v-if="validationError && !parametersLocked" role="alert" class="text-sm text-red-600 dark:text-red-400">
{{ validationError }}
</p>
<div v-if="requestError" role="alert" class="space-y-2 rounded-lg bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-300">
<p>{{ requestError }}</p>
<p v-if="pendingOperation">{{ t('admin.subscriptions.bulk.retryHint') }}</p>
</div>
<div v-if="result" aria-live="polite" class="space-y-3">
<p class="rounded-lg bg-gray-50 p-3 text-sm font-medium text-gray-900 dark:bg-dark-700 dark:text-gray-100">
{{ t('admin.subscriptions.bulk.result', { success: result.success_count, failed: result.failed_count }) }}
</p>
<ul v-if="failedResults.length" class="max-h-48 space-y-2 overflow-y-auto">
<li v-for="item in failedResults" :key="item.subscription_id" class="rounded-lg bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-300">
<p class="break-all font-medium">{{ failedTargetLabel(item.subscription_id) }}</p>
<p class="mt-1 break-words">{{ item.error || t('admin.subscriptions.bulk.itemFailed') }}</p>
</li>
</ul>
</div>
</form>
<template #footer>
<div class="flex justify-end gap-3">
<button type="button" class="btn btn-secondary" :disabled="!canClose" @click="handleClose">
{{ result ? t('common.close') : t('common.cancel') }}
</button>
<button
v-if="!result"
type="submit"
form="bulk-subscription-action-form"
:class="['btn', currentAction === 'revoke' ? 'btn-danger' : 'btn-primary']"
:disabled="submitting || (!pendingOperation && !!validationError)"
>
{{ submitting ? t('common.processing') : pendingOperation ? t('admin.subscriptions.bulk.retry') : t('admin.subscriptions.bulk.confirm') }}
</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin'
import type { SubscriptionBulkAction, SubscriptionBulkActionRequest, SubscriptionBulkActionResult } from '@/api/admin/subscriptions'
import BaseDialog from '@/components/common/BaseDialog.vue'
import type { UserSubscription } from '@/types'
import { completeBulkSubscriptionOperation, prepareBulkSubscriptionOperation, type BulkSubscriptionOperation } from './bulkSubscriptionOperation'
const props = defineProps<{
show: boolean
action: SubscriptionBulkAction
subscriptions: UserSubscription[]
}>()
const emit = defineEmits<{
close: []
completed: [result: SubscriptionBulkActionResult]
}>()
const { t } = useI18n()
const quotaWindows = ['daily', 'weekly', 'monthly'] as const
const days = ref<number | string>(30)
const windows = reactive({ daily: true, weekly: true, monthly: true })
const submitting = ref(false)
const requestError = ref('')
const result = shallowRef<SubscriptionBulkActionResult | null>(null)
const pendingOperation = shallowRef<BulkSubscriptionOperation | null>(null)
const targets = ref<{ id: number; email?: string; group?: string; groupId: number }[]>([])
const submittedAction = ref<SubscriptionBulkAction | null>(null)
const currentAction = computed(() => submittedAction.value ?? props.action)
const parametersLocked = computed(() => submitting.value || !!pendingOperation.value || !!result.value)
const canClose = computed(() => !submitting.value)
const failedResults = computed(() => result.value?.results.filter(item => !item.success) ?? [])
const validationError = computed(() => {
if (targets.value.length === 0) return t('admin.subscriptions.bulk.selectionRequired')
if (targets.value.length > 100) return t('admin.subscriptions.bulk.selectionLimit')
if (currentAction.value === 'extend') {
const value = Number(days.value)
if (!Number.isInteger(value) || value === 0 || Math.abs(value) > 36500) {
return t('admin.subscriptions.bulk.invalidDays')
}
}
if (currentAction.value === 'reset_quota' && !quotaWindows.some(window => windows[window])) {
return t('admin.subscriptions.bulk.selectWindow')
}
return ''
})
watch(() => props.subscriptions, subscriptions => {
if (parametersLocked.value) return
targets.value = subscriptions.map(subscription => ({
id: subscription.id,
email: subscription.user?.email,
group: subscription.group?.name,
groupId: subscription.group_id
}))
}, { immediate: true })
function handleClose() {
if (canClose.value) emit('close')
}
function failedTargetLabel(id: number) {
const target = targets.value.find(item => item.id === id)
return target?.email ? `${target.email} · #${id}` : `#${id}`
}
async function submit() {
if (submitting.value || result.value) return
if (!pendingOperation.value) {
if (validationError.value) return
const request: SubscriptionBulkActionRequest = {
subscription_ids: targets.value.map(subscription => subscription.id),
action: props.action
}
if (request.action === 'extend') request.days = Number(days.value)
if (request.action === 'reset_quota') Object.assign(request, { ...windows })
pendingOperation.value = prepareBulkSubscriptionOperation(request)
submittedAction.value = request.action
}
const operation = pendingOperation.value
submitting.value = true
requestError.value = ''
try {
result.value = await adminAPI.subscriptions.bulkAction(operation.request, operation.key)
completeBulkSubscriptionOperation(operation)
pendingOperation.value = null
emit('completed', result.value)
} catch (error: unknown) {
const failure = error as { status?: number; message?: string; response?: { status?: number; data?: { message?: string } } }
requestError.value = failure?.message || failure?.response?.data?.message || t('admin.subscriptions.bulk.requestFailed')
const status = failure?.status ?? failure?.response?.status
// A timeout, server error or in-progress conflict may arrive after writes.
// Retry the frozen payload and key until a definitive result is available.
if (!operation.outcomeUncertain && status && status >= 400 && status < 500 && status !== 408 && status !== 409) {
completeBulkSubscriptionOperation(operation)
pendingOperation.value = null
submittedAction.value = null
} else {
operation.outcomeUncertain = true
}
} finally {
submitting.value = false
}
}
</script>
@@ -0,0 +1,222 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import type { SubscriptionBulkAction, SubscriptionBulkActionResult } from '@/api/admin/subscriptions'
import type { UserSubscription } from '@/types'
import BulkSubscriptionActionDialog from '../BulkSubscriptionActionDialog.vue'
const bulkAction = vi.hoisted(() => vi.fn())
vi.mock('@/api/admin', () => ({ adminAPI: { subscriptions: { bulkAction } } }))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string, params?: Record<string, unknown>) => params ? `${key} ${JSON.stringify(params)}` : key
})
}))
function subscription(id: number): UserSubscription {
return {
id,
user_id: id,
group_id: 8,
status: 'active',
user: { email: `user${id}@example.com` },
group: { name: 'Subscription group' }
} as UserSubscription
}
const successResult: SubscriptionBulkActionResult = {
success_count: 2,
failed_count: 0,
results: [
{ subscription_id: 1, success: true },
{ subscription_id: 2, success: true }
]
}
function mountDialog(action: SubscriptionBulkAction = 'extend', subscriptions = [subscription(1), subscription(2)]) {
const wrapper = mount(BulkSubscriptionActionDialog, {
props: { show: true, action, subscriptions },
global: {
stubs: {
BaseDialog: {
name: 'BaseDialog',
props: ['show', 'title', 'closeOnEscape', 'showCloseButton'],
emits: ['close'],
template: '<section><h2>{{ title }}</h2><slot /><footer><slot name="footer" /></footer></section>'
}
}
}
})
wrappers.push(wrapper)
return wrapper
}
const wrappers: ReturnType<typeof mountDialog>[] = []
let adminId = 100
beforeEach(() => {
bulkAction.mockReset()
bulkAction.mockResolvedValue(successResult)
localStorage.setItem('auth_user', JSON.stringify({ id: ++adminId }))
sessionStorage.clear()
})
afterEach(() => {
wrappers.splice(0).forEach(wrapper => wrapper.unmount())
})
describe('BulkSubscriptionActionDialog', () => {
it.each([30, -7, 36500, -36500])('submits whole-day adjustments of %s and shows target identities', async days => {
const wrapper = mountDialog()
expect(wrapper.text()).toContain('user1@example.com')
expect(wrapper.text()).toContain('Subscription group')
await wrapper.get('input[type="number"]').setValue(days)
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(bulkAction).toHaveBeenCalledWith({ subscription_ids: [1, 2], action: 'extend', days }, expect.any(String))
expect(wrapper.emitted('completed')).toEqual([[successResult]])
expect(wrapper.find('button[type="submit"]').exists()).toBe(false)
})
it.each([0, 1.5, 36501, -36501, ''])('rejects invalid day value %s', async days => {
const wrapper = mountDialog()
await wrapper.get('input[type="number"]').setValue(days)
await wrapper.get('form').trigger('submit')
expect(wrapper.text()).toContain('admin.subscriptions.bulk.invalidDays')
expect(bulkAction).not.toHaveBeenCalled()
})
it('defaults to all quota windows and validates at least one selected window', async () => {
const wrapper = mountDialog('reset_quota')
for (const checkbox of wrapper.findAll('input[type="checkbox"]')) {
expect((checkbox.element as HTMLInputElement).checked).toBe(true)
await checkbox.setValue(false)
}
await wrapper.get('form').trigger('submit')
expect(bulkAction).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('admin.subscriptions.bulk.selectWindow')
await wrapper.get('input[name="weekly"]').setValue(true)
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(bulkAction).toHaveBeenCalledWith({
subscription_ids: [1, 2], action: 'reset_quota', daily: false, weekly: true, monthly: false
}, expect.any(String))
})
it.each(['revoke', 'restore'] as const)('submits %s without unrelated parameters', async action => {
const wrapper = mountDialog(action)
expect(wrapper.text()).toContain(`admin.subscriptions.bulk.${action}Hint`)
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(bulkAction).toHaveBeenCalledWith({ subscription_ids: [1, 2], action }, expect.any(String))
})
it.each([0, 101])('rejects a selection of %s subscriptions', async count => {
const wrapper = mountDialog('revoke', Array.from({ length: count }, (_, index) => subscription(index + 1)))
await wrapper.get('form').trigger('submit')
expect(bulkAction).not.toHaveBeenCalled()
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
})
it('prevents duplicate submits, parameter edits, and closing while submitting', async () => {
let resolve!: (value: SubscriptionBulkActionResult) => void
bulkAction.mockReturnValue(new Promise<SubscriptionBulkActionResult>(done => { resolve = done }))
const wrapper = mountDialog()
await wrapper.get('form').trigger('submit')
await wrapper.get('form').trigger('submit')
const dialog = wrapper.findComponent({ name: 'BaseDialog' })
dialog.vm.$emit('close')
expect(bulkAction).toHaveBeenCalledTimes(1)
expect(wrapper.get('input[type="number"]').attributes('disabled')).toBeDefined()
expect(dialog.props('closeOnEscape')).toBe(false)
expect(dialog.props('showCloseButton')).toBe(false)
expect(wrapper.emitted('close')).toBeUndefined()
resolve(successResult)
await flushPromises()
expect(dialog.props('closeOnEscape')).toBe(true)
})
it('shows partial failures and keeps the original target list after the parent refreshes', async () => {
const partial: SubscriptionBulkActionResult = {
success_count: 1, failed_count: 1,
results: [{ subscription_id: 1, success: true }, { subscription_id: 2, success: false, error: 'Subscription is revoked' }]
}
bulkAction.mockResolvedValue(partial)
const wrapper = mountDialog('reset_quota')
await wrapper.get('form').trigger('submit')
await flushPromises()
await wrapper.setProps({ subscriptions: [subscription(2)] })
expect(wrapper.text()).toContain('user1@example.com')
expect(wrapper.text()).toContain('user2@example.com')
expect(wrapper.text()).toContain('Subscription is revoked')
expect(wrapper.emitted('completed')).toEqual([[partial]])
expect(wrapper.findAll('footer button')).toHaveLength(1)
await wrapper.get('form').trigger('submit')
expect(bulkAction).toHaveBeenCalledTimes(1)
await wrapper.get('footer button').trigger('click')
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('retries an unknown outcome with the original payload and key, even when props change', async () => {
bulkAction.mockRejectedValueOnce({ status: 0, message: 'Connection lost' })
const wrapper = mountDialog()
await wrapper.get('input[type="number"]').setValue(-7)
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain('Connection lost')
expect(wrapper.text()).toContain('admin.subscriptions.bulk.retryHint')
expect(wrapper.get('input[type="number"]').attributes('disabled')).toBeDefined()
expect(wrapper.findComponent({ name: 'BaseDialog' }).props('closeOnEscape')).toBe(true)
await wrapper.setProps({ action: 'revoke', subscriptions: [subscription(3)] })
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(bulkAction.mock.calls[1]).toEqual(bulkAction.mock.calls[0])
expect(bulkAction.mock.calls[1]![0]).toEqual({ subscription_ids: [1, 2], action: 'extend', days: -7 })
expect(wrapper.emitted('completed')).toEqual([[successResult]])
})
it('retains a pending key across closing and reopening, then releases it on completion', async () => {
bulkAction.mockRejectedValueOnce({ status: 503, message: 'Unavailable' })
const first = mountDialog()
await first.get('form').trigger('submit')
await flushPromises()
await first.get('footer button[type="button"]').trigger('click')
expect(first.emitted('close')).toHaveLength(1)
first.unmount()
const retry = mountDialog('extend', [subscription(2), subscription(1)])
await retry.get('form').trigger('submit')
await flushPromises()
expect(bulkAction.mock.calls[1]).toEqual(bulkAction.mock.calls[0])
retry.unmount()
const next = mountDialog()
await next.get('form').trigger('submit')
await flushPromises()
expect(bulkAction.mock.calls[2]![1]).not.toBe(bulkAction.mock.calls[0]![1])
})
it('uses a new key after a definitive initial validation failure and allows correcting parameters', async () => {
bulkAction.mockRejectedValueOnce({ status: 400, message: 'Invalid adjustment' })
const wrapper = mountDialog()
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(wrapper.get('input[type="number"]').attributes('disabled')).toBeUndefined()
await wrapper.get('input[type="number"]').setValue(7)
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(bulkAction.mock.calls[1]![0].days).toBe(7)
expect(bulkAction.mock.calls[1]![1]).not.toBe(bulkAction.mock.calls[0]![1])
})
it('keeps the pending key when a retry is rate limited after an unknown outcome', async () => {
bulkAction.mockRejectedValueOnce({ status: 0 }).mockRejectedValueOnce({ status: 429 })
const wrapper = mountDialog()
for (let attempt = 0; attempt < 3; attempt++) {
await wrapper.get('form').trigger('submit')
await flushPromises()
}
expect(bulkAction.mock.calls[1]).toEqual(bulkAction.mock.calls[0])
expect(bulkAction.mock.calls[2]).toEqual(bulkAction.mock.calls[0])
})
})
@@ -0,0 +1,41 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { completeBulkSubscriptionOperation, prepareBulkSubscriptionOperation } from '../bulkSubscriptionOperation'
let adminId = 1000
beforeEach(() => {
localStorage.setItem('auth_user', JSON.stringify({ id: ++adminId }))
sessionStorage.clear()
})
describe('bulk subscription operation identity', () => {
it('canonicalizes duplicate and reordered IDs for storage and the sent request', () => {
const first = prepareBulkSubscriptionOperation({ action: 'extend', subscription_ids: [3, 1, 3], days: 7 })
const retry = prepareBulkSubscriptionOperation({ subscription_ids: [1, 3], days: 7, action: 'extend' })
expect(retry.request).toEqual({ action: 'extend', subscription_ids: [1, 3], days: 7 })
expect(retry.key).toBe(first.key)
expect(retry.outcomeUncertain).toBe(true)
})
it('isolates different parameters, actions, targets, and administrators', () => {
const request = { action: 'extend' as const, subscription_ids: [1], days: 7 }
const first = prepareBulkSubscriptionOperation(request)
const differentDays = prepareBulkSubscriptionOperation({ ...request, days: 14 })
const differentAction = prepareBulkSubscriptionOperation({ action: 'revoke', subscription_ids: [1] })
const differentTargets = prepareBulkSubscriptionOperation({ ...request, subscription_ids: [2] })
localStorage.setItem('auth_user', JSON.stringify({ id: ++adminId }))
const differentAdmin = prepareBulkSubscriptionOperation(request)
expect(new Set([first, differentDays, differentAction, differentTargets, differentAdmin].map(operation => operation.key)).size).toBe(5)
})
it('reuses a stored pending key and clears both storage and memory after completion', () => {
const request = { action: 'reset_quota' as const, subscription_ids: [1], daily: true, weekly: false, monthly: false }
const scope = `sub2api:admin:subscription-bulk:${adminId}:${JSON.stringify({ subscription_ids: [1], action: 'reset_quota', daily: true, weekly: false, monthly: false })}`
sessionStorage.setItem(scope, 'saved-operation-key')
const resumed = prepareBulkSubscriptionOperation(request)
expect(resumed.key).toBe('saved-operation-key')
completeBulkSubscriptionOperation(resumed)
expect(sessionStorage.getItem(scope)).toBeNull()
expect(prepareBulkSubscriptionOperation(request).key).not.toBe(resumed.key)
})
})
@@ -0,0 +1,71 @@
import type { SubscriptionBulkActionRequest } from '@/api/admin/subscriptions'
export interface BulkSubscriptionOperation {
request: SubscriptionBulkActionRequest
key: string
storageKey: string | null
outcomeUncertain: boolean
}
const pendingKeys = new Map<string, string>()
function currentAdminId(): number | null {
try {
const user = JSON.parse(globalThis.localStorage?.getItem('auth_user') ?? 'null') as { id?: unknown } | null
const id = user?.id
return typeof id === 'number' && Number.isSafeInteger(id) && id > 0 ? id : null
} catch {
return null
}
}
function readStoredKey(storageKey: string): string | null {
try {
return globalThis.sessionStorage?.getItem(storageKey) ?? null
} catch {
return null
}
}
function storeKey(storageKey: string, key: string | null) {
try {
if (key) globalThis.sessionStorage?.setItem(storageKey, key)
else globalThis.sessionStorage?.removeItem(storageKey)
} catch {
// Keep same-session retries safe in memory when browser storage is unavailable.
}
}
export function prepareBulkSubscriptionOperation(input: SubscriptionBulkActionRequest): BulkSubscriptionOperation {
// Send the same canonical payload used for storage, including ID order, so a
// retry from a differently sorted table still matches the backend fingerprint.
const request: SubscriptionBulkActionRequest = {
subscription_ids: [...new Set(input.subscription_ids)].sort((a, b) => a - b),
action: input.action
}
if (input.action === 'extend') request.days = input.days
if (input.action === 'reset_quota') {
request.daily = !!input.daily
request.weekly = !!input.weekly
request.monthly = !!input.monthly
}
const adminId = currentAdminId()
const storageKey = adminId ? `sub2api:admin:subscription-bulk:${adminId}:${JSON.stringify(request)}` : null
let key = storageKey ? pendingKeys.get(storageKey) ?? readStoredKey(storageKey) : null
const outcomeUncertain = !!key
if (!key) {
const requestId = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`
key = `subscription-bulk-${adminId ?? 'unknown'}-${requestId}`
}
if (storageKey) {
pendingKeys.set(storageKey, key)
storeKey(storageKey, key)
}
return { request, key, storageKey, outcomeUncertain }
}
export function completeBulkSubscriptionOperation(operation: BulkSubscriptionOperation) {
if (!operation.storageKey) return
pendingKeys.delete(operation.storageKey)
storeKey(operation.storageKey, null)
}
@@ -658,6 +658,41 @@ export default {
// Subscriptions
subscriptions: {
batchAssign: {
enable: 'Assign to multiple users',
hint: 'Search and add up to 100 users to assign the same group and validity period.',
selected: '{count} users added',
removeUser: 'Remove {email}',
result: 'Assignment complete: {success} succeeded, {failed} failed',
retryHint: 'Successful users have been removed. Resolve any errors and submit the remaining users again.'
},
bulk: {
extend: 'Bulk Adjust Expiration',
reset_quota: 'Bulk Reset Quota',
revoke: 'Bulk Revoke',
restore: 'Bulk Restore',
selected: '{count} subscriptions selected',
selectSubscription: 'Select subscription #{id}',
clearSelection: 'Clear Selection',
selectionHint: 'Select up to 100 subscriptions on this page. Changing pages or filters clears the selection. Each action only processes subscriptions with an applicable status.',
selectionLimit: 'You can process up to 100 subscriptions at a time',
selectionRequired: 'Select at least one subscription',
confirmTargets: 'This action will process the following {count} subscriptions',
groupFallback: 'Group #{id}',
extendHint: 'Enter a positive whole number to extend or a negative one to shorten, up to 36500 days. Expired subscriptions are extended from now and cannot be shortened. The new expiration must be in the future.',
invalidDays: 'Enter a nonzero whole number of days between -36500 and 36500',
resetWindows: 'Select quota windows to reset',
resetHint: 'Usage in the selected windows will be zeroed and restarted from today.',
selectWindow: 'Select at least one quota window',
revokeHint: 'These subscriptions will no longer be usable. You can restore them later from the revoked list.',
restoreHint: 'These subscriptions will be enabled again. Subscriptions whose original validity has ended will be restored as expired.',
confirm: 'Confirm Action',
retry: 'Retry Original Action',
retryHint: 'The outcome is not yet confirmed. Retrying continues the original operation to avoid duplicate changes. You can also close this dialog, then select the same subscriptions and settings to retry.',
requestFailed: 'The bulk request failed. Please retry.',
result: 'Completed: {success} succeeded, {failed} failed',
itemFailed: 'Action failed'
},
title: 'Subscription Management',
description: 'Manage user subscriptions and quota limits',
assignSubscription: 'Assign Subscription',
@@ -658,6 +658,41 @@ export default {
// Subscriptions Management
subscriptions: {
batchAssign: {
enable: '批量分配订阅',
hint: '搜索并添加多个用户,统一分配所选分组和有效期。每次最多 100 人。',
selected: '已添加 {count} 位用户',
removeUser: '移除 {email}',
result: '分配完成:成功 {success} 人,失败 {failed} 人',
retryHint: '已移除成功项,可检查失败原因后重新提交剩余用户。'
},
bulk: {
extend: '批量调整有效期',
reset_quota: '批量重置配额',
revoke: '批量撤销',
restore: '批量恢复',
selected: '已选择 {count} 条订阅',
selectSubscription: '选择订阅 #{id}',
clearSelection: '清空选择',
selectionHint: '当前页选择,翻页或筛选会清空,最多 100 条。各操作仅处理适用状态的订阅。',
selectionLimit: '每次最多操作 100 条订阅',
selectionRequired: '请至少选择一条订阅',
confirmTargets: '本次将处理以下 {count} 条订阅',
groupFallback: '分组 #{id}',
extendHint: '正整数延长,负整数缩短,最多调整 36500 天。已过期订阅从当前时间起延长,不能缩短;缩短后的到期时间必须在未来。',
invalidDays: '请输入 -36500 到 36500 之间的非零整数天数',
resetWindows: '选择要重置的配额窗口',
resetHint: '所选窗口的用量将归零,并从今天开始重新计算。',
selectWindow: '请至少选择一个配额窗口',
revokeHint: '撤销后这些订阅将无法继续使用,可稍后在已撤销列表中恢复。',
restoreHint: '恢复后将重新启用这些订阅。原有效期已结束的订阅将显示为已过期。',
confirm: '确认执行',
retry: '重试原操作',
retryHint: '暂未确认操作结果。重试会继续原操作,避免重复处理。也可关闭后重新选择相同订阅和参数重试。',
requestFailed: '批量操作请求失败,请重试',
result: '处理完成:成功 {success} 条,失败 {failed} 条',
itemFailed: '操作失败'
},
title: '订阅管理',
description: '管理用户订阅和配额限制',
assignSubscription: '分配订阅',
+162 -8
View File
@@ -165,6 +165,32 @@
</button>
</div>
</div>
<div
v-if="selectedCount > 0"
class="mt-3 space-y-2 rounded-xl border border-primary-200 bg-primary-50 p-3 dark:border-primary-800 dark:bg-primary-900/20"
data-test="subscription-bulk-actions"
>
<div class="flex flex-wrap items-center gap-2">
<span class="mr-2 text-sm font-medium text-primary-800 dark:text-primary-200">
{{ t('admin.subscriptions.bulk.selected', { count: selectedCount }) }}
</span>
<button
v-for="action in bulkActions"
:key="action"
type="button"
:class="action === 'revoke' ? 'btn btn-danger btn-sm' : 'btn btn-secondary btn-sm'"
:data-test="`bulk-${action}`"
:disabled="loading || bulkTargets[action].length === 0"
@click="openBulkAction(action)"
>
{{ t(`admin.subscriptions.bulk.${action}`) }} ({{ bulkTargets[action].length }})
</button>
<button type="button" class="btn btn-secondary btn-sm" @click="clearSelection">
{{ t('admin.subscriptions.bulk.clearSelection') }}
</button>
</div>
<p class="text-xs text-gray-600 dark:text-gray-400">{{ t('admin.subscriptions.bulk.selectionHint') }}</p>
</div>
</template>
<!-- Subscriptions Table -->
@@ -173,10 +199,15 @@
:columns="columns"
:data="subscriptions"
:loading="loading"
row-key="id"
selectable
:selected-keys="selectedIds"
:selection-label="getSubscriptionSelectionLabel"
:server-side-sort="true"
default-sort-key="created_at"
default-sort-order="desc"
@sort="handleSort"
@update:selected-keys="handleSelectedKeysUpdate"
>
<template #cell-user="{ row }">
<div class="flex items-center gap-2">
@@ -447,11 +478,22 @@
</template>
</TablePageLayout>
<BulkSubscriptionActionDialog
v-if="bulkAction !== null"
:show="true"
:action="bulkAction"
:subscriptions="bulkSubscriptions"
@close="bulkAction = null"
@completed="handleBulkCompleted"
/>
<!-- Assign Subscription Modal -->
<BaseDialog
:show="showAssignModal"
:title="t('admin.subscriptions.assignSubscription')"
width="normal"
:show-close-button="!submitting"
:close-on-escape="!submitting"
@close="closeAssignModal"
>
<form
@@ -459,12 +501,18 @@
@submit.prevent="handleAssignSubscription"
class="space-y-5"
>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input v-model="batchAssignEnabled" type="checkbox" :disabled="submitting" @change="resetAssignUsers" />
{{ t('admin.subscriptions.batchAssign.enable') }}
</label>
<p v-if="batchAssignEnabled" class="input-hint">{{ t('admin.subscriptions.batchAssign.hint') }}</p>
<div>
<label class="input-label">{{ t('admin.subscriptions.form.user') }}</label>
<div class="relative" data-assign-user-search>
<input
v-model="userSearchKeyword"
type="text"
:disabled="submitting || (batchAssignEnabled && assignUsers.length >= 100)"
class="input pr-8"
:placeholder="t('admin.usage.searchUserPlaceholder')"
@input="debounceSearchUsers"
@@ -499,19 +547,39 @@
v-for="user in userSearchResults"
:key="user.id"
type="button"
:disabled="submitting || (batchAssignEnabled && assignUsers.some((selected) => selected.id === user.id))"
@click="selectUser(user)"
class="w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-dark-700"
class="w-full px-4 py-2 text-left text-sm hover:bg-gray-100 disabled:opacity-50 dark:hover:bg-dark-700"
>
<span class="font-medium text-gray-900 dark:text-white">{{ user.email }}</span>
<span class="ml-2 text-gray-500 dark:text-gray-400">#{{ user.id }}</span>
</button>
</div>
</div>
<div v-if="batchAssignEnabled && assignUsers.length > 0" class="mt-2 space-y-2" data-test="assign-users">
<p class="text-sm text-gray-600 dark:text-gray-400">
{{ t('admin.subscriptions.batchAssign.selected', { count: assignUsers.length }) }}
</p>
<ul class="max-h-40 space-y-1 overflow-y-auto">
<li v-for="user in assignUsers" :key="user.id" class="flex items-center justify-between gap-2 rounded-lg bg-gray-50 px-3 py-1 text-sm dark:bg-dark-700">
<span class="truncate">{{ user.email }} <span class="text-gray-500">#{{ user.id }}</span></span>
<button
type="button"
:disabled="submitting"
:aria-label="t('admin.subscriptions.batchAssign.removeUser', { email: user.email })"
@click="assignUsers = assignUsers.filter((selected) => selected.id !== user.id)"
>
<Icon name="x" size="sm" />
</button>
</li>
</ul>
</div>
</div>
<div>
<label class="input-label">{{ t('admin.subscriptions.form.group') }}</label>
<Select
v-model="assignForm.group_id"
:disabled="submitting"
:options="subscriptionGroupOptions"
:placeholder="t('admin.subscriptions.selectGroup')"
>
@@ -540,19 +608,26 @@
</div>
<div>
<label class="input-label">{{ t('admin.subscriptions.form.validityDays') }}</label>
<input v-model.number="assignForm.validity_days" type="number" min="1" class="input" />
<input v-model.number="assignForm.validity_days" type="number" min="1" max="36500" step="1" :disabled="submitting" class="input" />
<p class="input-hint">{{ t('admin.subscriptions.validityHint') }}</p>
</div>
<div v-if="batchAssignResult" class="space-y-2 text-sm" role="status" data-test="batch-assign-result">
<p>{{ t('admin.subscriptions.batchAssign.result', { success: batchAssignResult.success_count, failed: batchAssignResult.failed_count }) }}</p>
<ul v-if="batchAssignResult.errors.length" class="max-h-40 space-y-1 overflow-y-auto text-red-600 dark:text-red-400">
<li v-for="(error, index) in batchAssignResult.errors" :key="index">{{ error }}</li>
</ul>
<p v-if="batchAssignResult.failed_count > 0" class="input-hint">{{ t('admin.subscriptions.batchAssign.retryHint') }}</p>
</div>
</form>
<template #footer>
<div class="flex justify-end gap-3">
<button @click="closeAssignModal" type="button" class="btn btn-secondary">
{{ t('common.cancel') }}
<button @click="closeAssignModal" type="button" :disabled="submitting" class="btn btn-secondary">
{{ batchAssignResult ? t('common.close') : t('common.cancel') }}
</button>
<button
type="submit"
form="assign-subscription-form"
:disabled="submitting"
:disabled="submitting || (batchAssignEnabled && assignUsers.length === 0)"
class="btn btn-primary"
>
<svg
@@ -771,6 +846,9 @@ import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import type { AdminUser, UserSubscription, Group, GroupPlatform, SubscriptionType } from '@/types'
import type { SimpleUser } from '@/api/admin/usage'
import type { SubscriptionBulkAction, SubscriptionBulkActionResult, BulkAssignSubscriptionResult } from '@/api/admin/subscriptions'
import { useTableSelection } from '@/composables/useTableSelection'
import BulkSubscriptionActionDialog from '@/components/admin/subscription/BulkSubscriptionActionDialog.vue'
import type { Column } from '@/components/common/types'
import { formatDateTimeToMinute } from '@/utils/format'
import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
@@ -934,6 +1012,36 @@ const groups = ref<Group[]>([])
const loading = ref(false)
let abortController: AbortController | null = null
const { selectedIds, selectedCount, setSelectedIds, clear: clearSelection, removeMany: removeSelectedIds } =
useTableSelection<UserSubscription>({ rows: subscriptions, getId: (subscription) => subscription.id })
const bulkActions: SubscriptionBulkAction[] = ['extend', 'reset_quota', 'revoke', 'restore']
const bulkAction = ref<SubscriptionBulkAction | null>(null)
const bulkSubscriptions = ref<UserSubscription[]>([])
const bulkTargets = computed(() => {
const selected = subscriptions.value.filter((subscription) => selectedIds.value.includes(subscription.id))
return {
extend: selected.filter((subscription) => ['active', 'expired'].includes(subscription.status)),
reset_quota: selected.filter((subscription) => subscription.status === 'active'),
revoke: selected.filter((subscription) => subscription.status === 'active'),
restore: selected.filter((subscription) => subscription.status === 'revoked')
}
})
const getSubscriptionSelectionLabel = (subscription: UserSubscription) =>
t('admin.subscriptions.bulk.selectSubscription', { id: subscription.id })
const handleSelectedKeysUpdate = (keys: Array<string | number>) => {
const visibleIds = new Set(subscriptions.value.map((subscription) => subscription.id))
setSelectedIds(keys.filter((key): key is number => typeof key === 'number' && visibleIds.has(key)))
}
const openBulkAction = (action: SubscriptionBulkAction) => {
if (loading.value || bulkTargets.value[action].length === 0) return
bulkSubscriptions.value = [...bulkTargets.value[action]]
bulkAction.value = action
}
const handleBulkCompleted = async (result: SubscriptionBulkActionResult) => {
removeSelectedIds(result.results.filter((item) => item.success).map((item) => item.subscription_id))
await loadSubscriptions()
}
// Toolbar user filter (fuzzy search -> select user_id)
const filterUserKeyword = ref('')
const filterUserResults = ref<SimpleUser[]>([])
@@ -948,6 +1056,9 @@ const userSearchResults = ref<AdminUser[]>([])
const userSearchLoading = ref(false)
const showUserDropdown = ref(false)
const selectedUser = ref<AdminUser | null>(null)
const batchAssignEnabled = ref(false)
const assignUsers = ref<AdminUser[]>([])
const batchAssignResult = ref<BulkAssignSubscriptionResult | null>(null)
let userSearchTimeout: ReturnType<typeof setTimeout> | null = null
const filters = reactive({
@@ -1018,6 +1129,7 @@ const subscriptionGroupOptions = computed(() =>
)
const applyFilters = () => {
clearSelection()
pagination.page = 1
loadSubscriptions()
}
@@ -1049,6 +1161,8 @@ const loadSubscriptions = async () => {
)
if (signal.aborted || abortController !== requestController) return
subscriptions.value = response.items
const visibleIds = new Set(response.items.map((subscription) => subscription.id))
setSelectedIds(selectedIds.value.filter((id) => visibleIds.has(id)))
pagination.total = response.total
pagination.pages = response.pages
} catch (error: any) {
@@ -1160,6 +1274,16 @@ const searchUsers = async () => {
}
const selectUser = (user: AdminUser) => {
if (submitting.value) return
if (batchAssignEnabled.value) {
if (assignUsers.value.length < 100 && !assignUsers.value.some((selected) => selected.id === user.id)) {
assignUsers.value = [...assignUsers.value, user]
}
userSearchKeyword.value = ''
userSearchResults.value = []
showUserDropdown.value = false
return
}
selectedUser.value = user
userSearchKeyword.value = user.email
showUserDropdown.value = false
@@ -1173,18 +1297,27 @@ const clearUserSelection = () => {
assignForm.user_id = null
}
const resetAssignUsers = () => {
clearUserSelection()
assignUsers.value = []
batchAssignResult.value = null
}
const handlePageChange = (page: number) => {
clearSelection()
pagination.page = page
loadSubscriptions()
}
const handlePageSizeChange = (pageSize: number) => {
clearSelection()
pagination.page_size = pageSize
pagination.page = 1
loadSubscriptions()
}
const handleSort = (key: string, order: 'asc' | 'desc') => {
clearSelection()
sortState.sort_by = key
sortState.sort_order = order
pagination.page = 1
@@ -1192,7 +1325,11 @@ const handleSort = (key: string, order: 'asc' | 'desc') => {
}
const closeAssignModal = () => {
if (submitting.value) return
showAssignModal.value = false
batchAssignEnabled.value = false
assignUsers.value = []
batchAssignResult.value = null
assignForm.user_id = null
assignForm.group_id = null
assignForm.validity_days = 30
@@ -1204,7 +1341,8 @@ const closeAssignModal = () => {
}
const handleAssignSubscription = async () => {
if (!assignForm.user_id) {
if (submitting.value) return
if (batchAssignEnabled.value ? assignUsers.value.length === 0 : !assignForm.user_id) {
appStore.showError(t('admin.subscriptions.pleaseSelectUser'))
return
}
@@ -1212,19 +1350,35 @@ const handleAssignSubscription = async () => {
appStore.showError(t('admin.subscriptions.pleaseSelectGroup'))
return
}
if (!assignForm.validity_days || assignForm.validity_days < 1) {
if (!Number.isInteger(assignForm.validity_days) || assignForm.validity_days < 1 || assignForm.validity_days > 36500) {
appStore.showError(t('admin.subscriptions.validityDaysRequired'))
return
}
submitting.value = true
try {
if (batchAssignEnabled.value) {
batchAssignResult.value = await adminAPI.subscriptions.bulkAssign({
user_ids: assignUsers.value.map((user) => user.id),
group_id: assignForm.group_id,
validity_days: assignForm.validity_days
})
const result = batchAssignResult.value
const successIds = new Set(result.subscriptions.map((subscription) => subscription.user_id))
assignUsers.value = assignUsers.value.filter((user) => !successIds.has(user.id))
if (result.success_count > 0) {
appStore.showSuccess(t('admin.subscriptions.batchAssign.result', { success: result.success_count, failed: result.failed_count }))
await loadSubscriptions()
}
return
}
await adminAPI.subscriptions.assign({
user_id: assignForm.user_id,
user_id: assignForm.user_id!,
group_id: assignForm.group_id,
validity_days: assignForm.validity_days
})
appStore.showSuccess(t('admin.subscriptions.subscriptionAssigned'))
submitting.value = false
closeAssignModal()
loadSubscriptions()
} catch (error: any) {
@@ -0,0 +1,143 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import SubscriptionsView from '../SubscriptionsView.vue'
const { list, bulkAction, bulkAssign, listUsers, showError } = vi.hoisted(() => ({
list: vi.fn(), bulkAction: vi.fn(), bulkAssign: vi.fn(), listUsers: vi.fn(), showError: vi.fn()
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
subscriptions: { list, bulkAction, bulkAssign },
groups: { getAll: vi.fn().mockResolvedValue([]) },
users: { list: listUsers }
}
}))
vi.mock('@/stores/app', () => ({ useAppStore: () => ({ showError, showSuccess: vi.fn() }) }))
vi.mock('vue-i18n', async () => ({
...await vi.importActual<typeof import('vue-i18n')>('vue-i18n'),
useI18n: () => ({ t: (key: string) => key })
}))
const rows = [
{ id: 1, user_id: 11, group_id: 1, status: 'active', user: { email: 'active@example.com' } },
{ id: 2, user_id: 22, group_id: 1, status: 'expired', user: { email: 'expired@example.com' } },
{ id: 3, user_id: 33, group_id: 1, status: 'revoked', user: { email: 'revoked@example.com' } }
]
function mountView() {
return mount(SubscriptionsView, {
global: {
stubs: {
AppLayout: { template: '<div><slot /></div>' },
TablePageLayout: { template: '<div><slot name="filters" /><slot name="table" /><slot name="pagination" /></div>' },
DataTable: { name: 'DataTable', props: ['data', 'selectedKeys'], emits: ['update:selectedKeys', 'sort'], template: '<div />' },
BaseDialog: { props: ['show'], template: '<div v-if="show"><slot /><slot name="footer" /></div>' },
Pagination: true, Select: true, ConfirmDialog: true, Icon: true,
GroupBadge: true, GroupOptionItem: true, EmptyState: true, Teleport: true, RouterLink: true
}
}
})
}
let wrapper: ReturnType<typeof mountView>
beforeEach(async () => {
vi.clearAllMocks()
localStorage.clear()
sessionStorage.clear()
localStorage.setItem('auth_user', JSON.stringify({ id: 777 }))
list.mockResolvedValue({ items: rows, total: 60, pages: 3 })
wrapper = mountView()
await flushPromises()
})
afterEach(() => {
wrapper.unmount()
vi.useRealTimers()
})
async function select(ids: number[]) {
wrapper.getComponent({ name: 'DataTable' }).vm.$emit('update:selectedKeys', ids)
await flushPromises()
}
describe('subscription bulk operations', () => {
it('uses eligible selected rows and retains failures and untouched selections after a partial result', async () => {
bulkAction.mockResolvedValue({ success_count: 1, failed_count: 1, results: [
{ subscription_id: 1, success: true },
{ subscription_id: 2, success: false, error: 'Cannot adjust this subscription' }
] })
await select([1, 2, 3])
await wrapper.get('[data-test="bulk-extend"]').trigger('click')
const form = wrapper.get('#bulk-subscription-action-form')
expect(form.text()).toContain('active@example.com')
expect(form.text()).toContain('expired@example.com')
expect(form.text()).not.toContain('revoked@example.com')
await form.trigger('submit')
await flushPromises()
expect(bulkAction).toHaveBeenCalledWith({ subscription_ids: [1, 2], action: 'extend', days: 30 }, expect.any(String))
expect(wrapper.getComponent({ name: 'DataTable' }).props('selectedKeys')).toEqual([2, 3])
expect(form.text()).toContain('Cannot adjust this subscription')
expect(list).toHaveBeenCalledTimes(2)
})
it('disables operations that do not apply to the selected status', async () => {
await select([3])
for (const action of ['extend', 'reset_quota', 'revoke']) {
expect(wrapper.get(`[data-test="bulk-${action}"]`).attributes('disabled')).toBeDefined()
}
expect(wrapper.get('[data-test="bulk-restore"]').attributes('disabled')).toBeUndefined()
})
it('clears selection on pagination, sorting, and filters and rejects IDs outside the visible page', async () => {
await select([1, 999])
expect(wrapper.getComponent({ name: 'DataTable' }).props('selectedKeys')).toEqual([1])
wrapper.getComponent({ name: 'Pagination' }).vm.$emit('update:page', 2)
await flushPromises()
expect(wrapper.getComponent({ name: 'DataTable' }).props('selectedKeys')).toEqual([])
await select([2])
wrapper.getComponent({ name: 'DataTable' }).vm.$emit('sort', 'status', 'asc')
await flushPromises()
expect(wrapper.getComponent({ name: 'DataTable' }).props('selectedKeys')).toEqual([])
await select([3])
wrapper.findAllComponents({ name: 'Select' })[0]!.vm.$emit('change')
await flushPromises()
expect(wrapper.getComponent({ name: 'DataTable' }).props('selectedKeys')).toEqual([])
})
it('assigns multiple users once and retries only failed users', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
await wrapper.findAll('button').find(button => button.text() === 'admin.subscriptions.assignSubscription')!.trigger('click')
const form = wrapper.get('#assign-subscription-form')
await form.get('input[type="checkbox"]').setValue(true)
form.getComponent({ name: 'Select' }).vm.$emit('update:modelValue', 7)
const search = form.get('[data-assign-user-search] input')
for (const id of [11, 22]) {
listUsers.mockResolvedValue({ items: [{ id, email: `user${id}@example.com` }] })
await search.trigger('focus')
await search.setValue(`user${id}`)
await vi.advanceTimersByTimeAsync(300)
await flushPromises()
await form.get('[data-assign-user-search] button').trigger('click')
}
expect(form.get('[data-test="assign-users"]').text()).toContain('user11@example.com')
expect(form.get('[data-test="assign-users"]').text()).toContain('user22@example.com')
let resolveAssign!: (result: unknown) => void
bulkAssign.mockReturnValueOnce(new Promise(resolve => { resolveAssign = resolve }))
await form.trigger('submit')
await form.trigger('submit')
expect(bulkAssign).toHaveBeenCalledTimes(1)
expect(bulkAssign).toHaveBeenCalledWith({ user_ids: [11, 22], group_id: 7, validity_days: 30 })
resolveAssign({ success_count: 1, failed_count: 1, subscriptions: [{ user_id: 11 }], errors: ['User 22: conflict'] })
await flushPromises()
expect(form.get('[data-test="assign-users"]').text()).not.toContain('user11@example.com')
expect(form.get('[data-test="assign-users"]').text()).toContain('user22@example.com')
expect(form.get('[data-test="batch-assign-result"]').text()).toContain('User 22: conflict')
bulkAssign.mockResolvedValueOnce({ success_count: 1, failed_count: 0, subscriptions: [{ user_id: 22 }], errors: [] })
await form.trigger('submit')
await flushPromises()
expect(bulkAssign).toHaveBeenLastCalledWith({ user_ids: [22], group_id: 7, validity_days: 30 })
expect(form.find('[data-test="assign-users"]').exists()).toBe(false)
})
})