feat(subscription): 订阅过期状态自动更新与服务端排序
- 新增 SubscriptionExpiryService 定时任务,每分钟更新过期订阅状态 - 订阅列表支持服务端排序(按过期时间、状态、创建时间) - 实时显示正确的过期状态,无需等待定时任务 - 允许对已过期订阅进行续期操作 - DataTable 组件支持 serverSideSort 模式
This commit is contained in:
@@ -70,6 +70,7 @@ func provideCleanup(
|
|||||||
schedulerSnapshot *service.SchedulerSnapshotService,
|
schedulerSnapshot *service.SchedulerSnapshotService,
|
||||||
tokenRefresh *service.TokenRefreshService,
|
tokenRefresh *service.TokenRefreshService,
|
||||||
accountExpiry *service.AccountExpiryService,
|
accountExpiry *service.AccountExpiryService,
|
||||||
|
subscriptionExpiry *service.SubscriptionExpiryService,
|
||||||
usageCleanup *service.UsageCleanupService,
|
usageCleanup *service.UsageCleanupService,
|
||||||
pricing *service.PricingService,
|
pricing *service.PricingService,
|
||||||
emailQueue *service.EmailQueueService,
|
emailQueue *service.EmailQueueService,
|
||||||
@@ -138,6 +139,10 @@ func provideCleanup(
|
|||||||
accountExpiry.Stop()
|
accountExpiry.Stop()
|
||||||
return nil
|
return nil
|
||||||
}},
|
}},
|
||||||
|
{"SubscriptionExpiryService", func() error {
|
||||||
|
subscriptionExpiry.Stop()
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
{"PricingService", func() error {
|
{"PricingService", func() error {
|
||||||
pricing.Stop()
|
pricing.Stop()
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -178,7 +178,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
opsScheduledReportService := service.ProvideOpsScheduledReportService(opsService, userService, emailService, redisClient, configConfig)
|
opsScheduledReportService := service.ProvideOpsScheduledReportService(opsService, userService, emailService, redisClient, configConfig)
|
||||||
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, compositeTokenCacheInvalidator, configConfig)
|
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, compositeTokenCacheInvalidator, configConfig)
|
||||||
accountExpiryService := service.ProvideAccountExpiryService(accountRepository)
|
accountExpiryService := service.ProvideAccountExpiryService(accountRepository)
|
||||||
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, schedulerSnapshotService, tokenRefreshService, accountExpiryService, usageCleanupService, pricingService, emailQueueService, billingCacheService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService)
|
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository)
|
||||||
|
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, schedulerSnapshotService, tokenRefreshService, accountExpiryService, subscriptionExpiryService, usageCleanupService, pricingService, emailQueueService, billingCacheService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService)
|
||||||
application := &Application{
|
application := &Application{
|
||||||
Server: httpServer,
|
Server: httpServer,
|
||||||
Cleanup: v,
|
Cleanup: v,
|
||||||
@@ -211,6 +212,7 @@ func provideCleanup(
|
|||||||
schedulerSnapshot *service.SchedulerSnapshotService,
|
schedulerSnapshot *service.SchedulerSnapshotService,
|
||||||
tokenRefresh *service.TokenRefreshService,
|
tokenRefresh *service.TokenRefreshService,
|
||||||
accountExpiry *service.AccountExpiryService,
|
accountExpiry *service.AccountExpiryService,
|
||||||
|
subscriptionExpiry *service.SubscriptionExpiryService,
|
||||||
usageCleanup *service.UsageCleanupService,
|
usageCleanup *service.UsageCleanupService,
|
||||||
pricing *service.PricingService,
|
pricing *service.PricingService,
|
||||||
emailQueue *service.EmailQueueService,
|
emailQueue *service.EmailQueueService,
|
||||||
@@ -278,6 +280,10 @@ func provideCleanup(
|
|||||||
accountExpiry.Stop()
|
accountExpiry.Stop()
|
||||||
return nil
|
return nil
|
||||||
}},
|
}},
|
||||||
|
{"SubscriptionExpiryService", func() error {
|
||||||
|
subscriptionExpiry.Stop()
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
{"PricingService", func() error {
|
{"PricingService", func() error {
|
||||||
pricing.Stop()
|
pricing.Stop()
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -77,7 +77,11 @@ func (h *SubscriptionHandler) List(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
status := c.Query("status")
|
status := c.Query("status")
|
||||||
|
|
||||||
subscriptions, pagination, err := h.subscriptionService.List(c.Request.Context(), page, pageSize, userID, groupID, status)
|
// Parse sorting parameters
|
||||||
|
sortBy := c.DefaultQuery("sort_by", "created_at")
|
||||||
|
sortOrder := c.DefaultQuery("sort_order", "desc")
|
||||||
|
|
||||||
|
subscriptions, pagination, err := h.subscriptionService.List(c.Request.Context(), page, pageSize, userID, groupID, status, sortBy, sortOrder)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.ErrorFrom(c, err)
|
response.ErrorFrom(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ func (r *userSubscriptionRepository) ListByGroupID(ctx context.Context, groupID
|
|||||||
return userSubscriptionEntitiesToService(subs), paginationResultFromTotal(int64(total), params), nil
|
return userSubscriptionEntitiesToService(subs), paginationResultFromTotal(int64(total), params), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *userSubscriptionRepository) List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status string) ([]service.UserSubscription, *pagination.PaginationResult, error) {
|
func (r *userSubscriptionRepository) List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status, sortBy, sortOrder string) ([]service.UserSubscription, *pagination.PaginationResult, error) {
|
||||||
client := clientFromContext(ctx, r.client)
|
client := clientFromContext(ctx, r.client)
|
||||||
q := client.UserSubscription.Query()
|
q := client.UserSubscription.Query()
|
||||||
if userID != nil {
|
if userID != nil {
|
||||||
@@ -199,7 +199,31 @@ func (r *userSubscriptionRepository) List(ctx context.Context, params pagination
|
|||||||
if groupID != nil {
|
if groupID != nil {
|
||||||
q = q.Where(usersubscription.GroupIDEQ(*groupID))
|
q = q.Where(usersubscription.GroupIDEQ(*groupID))
|
||||||
}
|
}
|
||||||
if status != "" {
|
|
||||||
|
// Status filtering with real-time expiration check
|
||||||
|
now := time.Now()
|
||||||
|
switch status {
|
||||||
|
case service.SubscriptionStatusActive:
|
||||||
|
// Active: status is active AND not yet expired
|
||||||
|
q = q.Where(
|
||||||
|
usersubscription.StatusEQ(service.SubscriptionStatusActive),
|
||||||
|
usersubscription.ExpiresAtGT(now),
|
||||||
|
)
|
||||||
|
case service.SubscriptionStatusExpired:
|
||||||
|
// Expired: status is expired OR (status is active but already expired)
|
||||||
|
q = q.Where(
|
||||||
|
usersubscription.Or(
|
||||||
|
usersubscription.StatusEQ(service.SubscriptionStatusExpired),
|
||||||
|
usersubscription.And(
|
||||||
|
usersubscription.StatusEQ(service.SubscriptionStatusActive),
|
||||||
|
usersubscription.ExpiresAtLTE(now),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
case "":
|
||||||
|
// No filter
|
||||||
|
default:
|
||||||
|
// Other status (e.g., revoked)
|
||||||
q = q.Where(usersubscription.StatusEQ(status))
|
q = q.Where(usersubscription.StatusEQ(status))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,11 +232,28 @@ func (r *userSubscriptionRepository) List(ctx context.Context, params pagination
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply sorting
|
||||||
|
q = q.WithUser().WithGroup().WithAssignedByUser()
|
||||||
|
|
||||||
|
// Determine sort field
|
||||||
|
var field string
|
||||||
|
switch sortBy {
|
||||||
|
case "expires_at":
|
||||||
|
field = usersubscription.FieldExpiresAt
|
||||||
|
case "status":
|
||||||
|
field = usersubscription.FieldStatus
|
||||||
|
default:
|
||||||
|
field = usersubscription.FieldCreatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine sort order (default: desc)
|
||||||
|
if sortOrder == "asc" && sortBy != "" {
|
||||||
|
q = q.Order(dbent.Asc(field))
|
||||||
|
} else {
|
||||||
|
q = q.Order(dbent.Desc(field))
|
||||||
|
}
|
||||||
|
|
||||||
subs, err := q.
|
subs, err := q.
|
||||||
WithUser().
|
|
||||||
WithGroup().
|
|
||||||
WithAssignedByUser().
|
|
||||||
Order(dbent.Desc(usersubscription.FieldCreatedAt)).
|
|
||||||
Offset(params.Offset()).
|
Offset(params.Offset()).
|
||||||
Limit(params.Limit()).
|
Limit(params.Limit()).
|
||||||
All(ctx)
|
All(ctx)
|
||||||
|
|||||||
@@ -1176,7 +1176,7 @@ func (r *stubUserSubscriptionRepo) ListActiveByUserID(ctx context.Context, userI
|
|||||||
func (stubUserSubscriptionRepo) ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]service.UserSubscription, *pagination.PaginationResult, error) {
|
func (stubUserSubscriptionRepo) ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]service.UserSubscription, *pagination.PaginationResult, error) {
|
||||||
return nil, nil, errors.New("not implemented")
|
return nil, nil, errors.New("not implemented")
|
||||||
}
|
}
|
||||||
func (stubUserSubscriptionRepo) List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status string) ([]service.UserSubscription, *pagination.PaginationResult, error) {
|
func (stubUserSubscriptionRepo) List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status, sortBy, sortOrder string) ([]service.UserSubscription, *pagination.PaginationResult, error) {
|
||||||
return nil, nil, errors.New("not implemented")
|
return nil, nil, errors.New("not implemented")
|
||||||
}
|
}
|
||||||
func (stubUserSubscriptionRepo) ExistsByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (bool, error) {
|
func (stubUserSubscriptionRepo) ExistsByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (bool, error) {
|
||||||
|
|||||||
@@ -367,7 +367,7 @@ func (r *stubUserSubscriptionRepo) ListByGroupID(ctx context.Context, groupID in
|
|||||||
return nil, nil, errors.New("not implemented")
|
return nil, nil, errors.New("not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *stubUserSubscriptionRepo) List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status string) ([]service.UserSubscription, *pagination.PaginationResult, error) {
|
func (r *stubUserSubscriptionRepo) List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status, sortBy, sortOrder string) ([]service.UserSubscription, *pagination.PaginationResult, error) {
|
||||||
return nil, nil, errors.New("not implemented")
|
return nil, nil, errors.New("not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
71
backend/internal/service/subscription_expiry_service.go
Normal file
71
backend/internal/service/subscription_expiry_service.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SubscriptionExpiryService periodically updates expired subscription status.
|
||||||
|
type SubscriptionExpiryService struct {
|
||||||
|
userSubRepo UserSubscriptionRepository
|
||||||
|
interval time.Duration
|
||||||
|
stopCh chan struct{}
|
||||||
|
stopOnce sync.Once
|
||||||
|
wg sync.WaitGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSubscriptionExpiryService(userSubRepo UserSubscriptionRepository, interval time.Duration) *SubscriptionExpiryService {
|
||||||
|
return &SubscriptionExpiryService{
|
||||||
|
userSubRepo: userSubRepo,
|
||||||
|
interval: interval,
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SubscriptionExpiryService) Start() {
|
||||||
|
if s == nil || s.userSubRepo == nil || s.interval <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer s.wg.Done()
|
||||||
|
ticker := time.NewTicker(s.interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
s.runOnce()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
s.runOnce()
|
||||||
|
case <-s.stopCh:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SubscriptionExpiryService) Stop() {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.stopOnce.Do(func() {
|
||||||
|
close(s.stopCh)
|
||||||
|
})
|
||||||
|
s.wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SubscriptionExpiryService) runOnce() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
updated, err := s.userSubRepo.BatchUpdateExpiredStatus(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[SubscriptionExpiry] Update expired subscriptions failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if updated > 0 {
|
||||||
|
log.Printf("[SubscriptionExpiry] Updated %d expired subscriptions", updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -330,12 +330,10 @@ func (s *SubscriptionService) ExtendSubscription(ctx context.Context, subscripti
|
|||||||
newExpiresAt = MaxExpiresAt
|
newExpiresAt = MaxExpiresAt
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果是缩短(负数),检查新的过期时间必须大于当前时间
|
// 检查新的过期时间必须大于当前时间
|
||||||
if days < 0 {
|
now := time.Now()
|
||||||
now := time.Now()
|
if !newExpiresAt.After(now) {
|
||||||
if !newExpiresAt.After(now) {
|
return nil, ErrAdjustWouldExpire
|
||||||
return nil, ErrAdjustWouldExpire
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.userSubRepo.ExtendExpiry(ctx, subscriptionID, newExpiresAt); err != nil {
|
if err := s.userSubRepo.ExtendExpiry(ctx, subscriptionID, newExpiresAt); err != nil {
|
||||||
@@ -383,6 +381,7 @@ func (s *SubscriptionService) ListUserSubscriptions(ctx context.Context, userID
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
normalizeExpiredWindows(subs)
|
normalizeExpiredWindows(subs)
|
||||||
|
normalizeSubscriptionStatus(subs)
|
||||||
return subs, nil
|
return subs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,17 +403,19 @@ func (s *SubscriptionService) ListGroupSubscriptions(ctx context.Context, groupI
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
normalizeExpiredWindows(subs)
|
normalizeExpiredWindows(subs)
|
||||||
|
normalizeSubscriptionStatus(subs)
|
||||||
return subs, pag, nil
|
return subs, pag, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// List 获取所有订阅(分页,支持筛选)
|
// List 获取所有订阅(分页,支持筛选和排序)
|
||||||
func (s *SubscriptionService) List(ctx context.Context, page, pageSize int, userID, groupID *int64, status string) ([]UserSubscription, *pagination.PaginationResult, error) {
|
func (s *SubscriptionService) List(ctx context.Context, page, pageSize int, userID, groupID *int64, status, sortBy, sortOrder string) ([]UserSubscription, *pagination.PaginationResult, error) {
|
||||||
params := pagination.PaginationParams{Page: page, PageSize: pageSize}
|
params := pagination.PaginationParams{Page: page, PageSize: pageSize}
|
||||||
subs, pag, err := s.userSubRepo.List(ctx, params, userID, groupID, status)
|
subs, pag, err := s.userSubRepo.List(ctx, params, userID, groupID, status, sortBy, sortOrder)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
normalizeExpiredWindows(subs)
|
normalizeExpiredWindows(subs)
|
||||||
|
normalizeSubscriptionStatus(subs)
|
||||||
return subs, pag, nil
|
return subs, pag, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,6 +442,18 @@ func normalizeExpiredWindows(subs []UserSubscription) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizeSubscriptionStatus 根据实际过期时间修正状态(仅影响返回数据,不影响数据库)
|
||||||
|
// 这确保前端显示正确的状态,即使定时任务尚未更新数据库
|
||||||
|
func normalizeSubscriptionStatus(subs []UserSubscription) {
|
||||||
|
now := time.Now()
|
||||||
|
for i := range subs {
|
||||||
|
sub := &subs[i]
|
||||||
|
if sub.Status == SubscriptionStatusActive && !sub.ExpiresAt.After(now) {
|
||||||
|
sub.Status = SubscriptionStatusExpired
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// startOfDay 返回给定时间所在日期的零点(保持原时区)
|
// startOfDay 返回给定时间所在日期的零点(保持原时区)
|
||||||
func startOfDay(t time.Time) time.Time {
|
func startOfDay(t time.Time) time.Time {
|
||||||
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
||||||
@@ -659,11 +672,6 @@ func (s *SubscriptionService) GetUserSubscriptionsWithProgress(ctx context.Conte
|
|||||||
return progresses, nil
|
return progresses, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateExpiredSubscriptions 更新过期订阅状态(定时任务调用)
|
|
||||||
func (s *SubscriptionService) UpdateExpiredSubscriptions(ctx context.Context) (int64, error) {
|
|
||||||
return s.userSubRepo.BatchUpdateExpiredStatus(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateSubscription 验证订阅是否有效
|
// ValidateSubscription 验证订阅是否有效
|
||||||
func (s *SubscriptionService) ValidateSubscription(ctx context.Context, sub *UserSubscription) error {
|
func (s *SubscriptionService) ValidateSubscription(ctx context.Context, sub *UserSubscription) error {
|
||||||
if sub.Status == SubscriptionStatusExpired {
|
if sub.Status == SubscriptionStatusExpired {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ type UserSubscriptionRepository interface {
|
|||||||
ListByUserID(ctx context.Context, userID int64) ([]UserSubscription, error)
|
ListByUserID(ctx context.Context, userID int64) ([]UserSubscription, error)
|
||||||
ListActiveByUserID(ctx context.Context, userID int64) ([]UserSubscription, error)
|
ListActiveByUserID(ctx context.Context, userID int64) ([]UserSubscription, error)
|
||||||
ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]UserSubscription, *pagination.PaginationResult, error)
|
ListByGroupID(ctx context.Context, groupID int64, params pagination.PaginationParams) ([]UserSubscription, *pagination.PaginationResult, error)
|
||||||
List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status string) ([]UserSubscription, *pagination.PaginationResult, error)
|
List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status, sortBy, sortOrder string) ([]UserSubscription, *pagination.PaginationResult, error)
|
||||||
|
|
||||||
ExistsByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (bool, error)
|
ExistsByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (bool, error)
|
||||||
ExtendExpiry(ctx context.Context, subscriptionID int64, newExpiresAt time.Time) error
|
ExtendExpiry(ctx context.Context, subscriptionID int64, newExpiresAt time.Time) error
|
||||||
|
|||||||
@@ -72,6 +72,13 @@ func ProvideAccountExpiryService(accountRepo AccountRepository) *AccountExpirySe
|
|||||||
return svc
|
return svc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProvideSubscriptionExpiryService creates and starts SubscriptionExpiryService.
|
||||||
|
func ProvideSubscriptionExpiryService(userSubRepo UserSubscriptionRepository) *SubscriptionExpiryService {
|
||||||
|
svc := NewSubscriptionExpiryService(userSubRepo, time.Minute)
|
||||||
|
svc.Start()
|
||||||
|
return svc
|
||||||
|
}
|
||||||
|
|
||||||
// ProvideTimingWheelService creates and starts TimingWheelService
|
// ProvideTimingWheelService creates and starts TimingWheelService
|
||||||
func ProvideTimingWheelService() (*TimingWheelService, error) {
|
func ProvideTimingWheelService() (*TimingWheelService, error) {
|
||||||
svc, err := NewTimingWheelService()
|
svc, err := NewTimingWheelService()
|
||||||
@@ -256,6 +263,7 @@ var ProviderSet = wire.NewSet(
|
|||||||
ProvideUpdateService,
|
ProvideUpdateService,
|
||||||
ProvideTokenRefreshService,
|
ProvideTokenRefreshService,
|
||||||
ProvideAccountExpiryService,
|
ProvideAccountExpiryService,
|
||||||
|
ProvideSubscriptionExpiryService,
|
||||||
ProvideTimingWheelService,
|
ProvideTimingWheelService,
|
||||||
ProvideDashboardAggregationService,
|
ProvideDashboardAggregationService,
|
||||||
ProvideUsageCleanupService,
|
ProvideUsageCleanupService,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import type {
|
|||||||
* List all subscriptions with pagination
|
* List all subscriptions with pagination
|
||||||
* @param page - Page number (default: 1)
|
* @param page - Page number (default: 1)
|
||||||
* @param pageSize - Items per page (default: 20)
|
* @param pageSize - Items per page (default: 20)
|
||||||
* @param filters - Optional filters (status, user_id, group_id)
|
* @param filters - Optional filters (status, user_id, group_id, sort_by, sort_order)
|
||||||
* @returns Paginated list of subscriptions
|
* @returns Paginated list of subscriptions
|
||||||
*/
|
*/
|
||||||
export async function list(
|
export async function list(
|
||||||
@@ -27,6 +27,8 @@ export async function list(
|
|||||||
status?: 'active' | 'expired' | 'revoked'
|
status?: 'active' | 'expired' | 'revoked'
|
||||||
user_id?: number
|
user_id?: number
|
||||||
group_id?: number
|
group_id?: number
|
||||||
|
sort_by?: string
|
||||||
|
sort_order?: 'asc' | 'desc'
|
||||||
},
|
},
|
||||||
options?: {
|
options?: {
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
|
|||||||
@@ -181,6 +181,10 @@ import Icon from '@/components/icons/Icon.vue'
|
|||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
sort: [key: string, order: 'asc' | 'desc']
|
||||||
|
}>()
|
||||||
|
|
||||||
// 表格容器引用
|
// 表格容器引用
|
||||||
const tableWrapperRef = ref<HTMLElement | null>(null)
|
const tableWrapperRef = ref<HTMLElement | null>(null)
|
||||||
const isScrollable = ref(false)
|
const isScrollable = ref(false)
|
||||||
@@ -289,6 +293,11 @@ interface Props {
|
|||||||
* If provided, DataTable will load the stored sort state on mount.
|
* If provided, DataTable will load the stored sort state on mount.
|
||||||
*/
|
*/
|
||||||
sortStorageKey?: string
|
sortStorageKey?: string
|
||||||
|
/**
|
||||||
|
* Enable server-side sorting mode. When true, clicking sort headers
|
||||||
|
* will emit 'sort' events instead of performing client-side sorting.
|
||||||
|
*/
|
||||||
|
serverSideSort?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@@ -296,7 +305,8 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
stickyFirstColumn: true,
|
stickyFirstColumn: true,
|
||||||
stickyActionsColumn: true,
|
stickyActionsColumn: true,
|
||||||
expandableActions: true,
|
expandableActions: true,
|
||||||
defaultSortOrder: 'asc'
|
defaultSortOrder: 'asc',
|
||||||
|
serverSideSort: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const sortKey = ref<string>('')
|
const sortKey = ref<string>('')
|
||||||
@@ -448,16 +458,26 @@ watch(actionsExpanded, async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const handleSort = (key: string) => {
|
const handleSort = (key: string) => {
|
||||||
|
let newOrder: 'asc' | 'desc' = 'asc'
|
||||||
if (sortKey.value === key) {
|
if (sortKey.value === key) {
|
||||||
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
|
newOrder = sortOrder.value === 'asc' ? 'desc' : 'asc'
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
if (props.serverSideSort) {
|
||||||
|
// Server-side sort mode: emit event and update internal state for UI feedback
|
||||||
sortKey.value = key
|
sortKey.value = key
|
||||||
sortOrder.value = 'asc'
|
sortOrder.value = newOrder
|
||||||
|
emit('sort', key, newOrder)
|
||||||
|
} else {
|
||||||
|
// Client-side sort mode: just update internal state
|
||||||
|
sortKey.value = key
|
||||||
|
sortOrder.value = newOrder
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortedData = computed(() => {
|
const sortedData = computed(() => {
|
||||||
if (!sortKey.value || !props.data) return props.data
|
// Server-side sort mode: return data as-is (server handles sorting)
|
||||||
|
if (props.serverSideSort || !sortKey.value || !props.data) return props.data
|
||||||
|
|
||||||
const key = sortKey.value
|
const key = sortKey.value
|
||||||
const order = sortOrder.value
|
const order = sortOrder.value
|
||||||
|
|||||||
@@ -154,7 +154,13 @@
|
|||||||
|
|
||||||
<!-- Subscriptions Table -->
|
<!-- Subscriptions Table -->
|
||||||
<template #table>
|
<template #table>
|
||||||
<DataTable :columns="columns" :data="subscriptions" :loading="loading">
|
<DataTable
|
||||||
|
:columns="columns"
|
||||||
|
:data="subscriptions"
|
||||||
|
:loading="loading"
|
||||||
|
:server-side-sort="true"
|
||||||
|
@sort="handleSort"
|
||||||
|
>
|
||||||
<template #cell-user="{ row }">
|
<template #cell-user="{ row }">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
@@ -357,7 +363,7 @@
|
|||||||
<template #cell-actions="{ row }">
|
<template #cell-actions="{ row }">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
v-if="row.status === 'active'"
|
v-if="row.status === 'active' || row.status === 'expired'"
|
||||||
@click="handleExtend(row)"
|
@click="handleExtend(row)"
|
||||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
|
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"
|
||||||
>
|
>
|
||||||
@@ -683,9 +689,9 @@ const allColumns = computed<Column[]>(() => [
|
|||||||
label: userColumnMode.value === 'email'
|
label: userColumnMode.value === 'email'
|
||||||
? t('admin.subscriptions.columns.user')
|
? t('admin.subscriptions.columns.user')
|
||||||
: t('admin.users.columns.username'),
|
: t('admin.users.columns.username'),
|
||||||
sortable: true
|
sortable: false
|
||||||
},
|
},
|
||||||
{ key: 'group', label: t('admin.subscriptions.columns.group'), sortable: true },
|
{ key: 'group', label: t('admin.subscriptions.columns.group'), sortable: false },
|
||||||
{ key: 'usage', label: t('admin.subscriptions.columns.usage'), sortable: false },
|
{ key: 'usage', label: t('admin.subscriptions.columns.usage'), sortable: false },
|
||||||
{ key: 'expires_at', label: t('admin.subscriptions.columns.expires'), sortable: true },
|
{ key: 'expires_at', label: t('admin.subscriptions.columns.expires'), sortable: true },
|
||||||
{ key: 'status', label: t('admin.subscriptions.columns.status'), sortable: true },
|
{ key: 'status', label: t('admin.subscriptions.columns.status'), sortable: true },
|
||||||
@@ -785,10 +791,17 @@ const selectedUser = ref<SimpleUser | null>(null)
|
|||||||
let userSearchTimeout: ReturnType<typeof setTimeout> | null = null
|
let userSearchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
const filters = reactive({
|
const filters = reactive({
|
||||||
status: '',
|
status: 'active',
|
||||||
group_id: '',
|
group_id: '',
|
||||||
user_id: null as number | null
|
user_id: null as number | null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Sorting state
|
||||||
|
const sortState = reactive({
|
||||||
|
sort_by: 'created_at',
|
||||||
|
sort_order: 'desc' as 'asc' | 'desc'
|
||||||
|
})
|
||||||
|
|
||||||
const pagination = reactive({
|
const pagination = reactive({
|
||||||
page: 1,
|
page: 1,
|
||||||
page_size: 20,
|
page_size: 20,
|
||||||
@@ -854,7 +867,9 @@ const loadSubscriptions = async () => {
|
|||||||
{
|
{
|
||||||
status: (filters.status as any) || undefined,
|
status: (filters.status as any) || undefined,
|
||||||
group_id: filters.group_id ? parseInt(filters.group_id) : undefined,
|
group_id: filters.group_id ? parseInt(filters.group_id) : undefined,
|
||||||
user_id: filters.user_id || undefined
|
user_id: filters.user_id || undefined,
|
||||||
|
sort_by: sortState.sort_by,
|
||||||
|
sort_order: sortState.sort_order
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
signal
|
signal
|
||||||
@@ -995,6 +1010,13 @@ const handlePageSizeChange = (pageSize: number) => {
|
|||||||
loadSubscriptions()
|
loadSubscriptions()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSort = (key: string, order: 'asc' | 'desc') => {
|
||||||
|
sortState.sort_by = key
|
||||||
|
sortState.sort_order = order
|
||||||
|
pagination.page = 1
|
||||||
|
loadSubscriptions()
|
||||||
|
}
|
||||||
|
|
||||||
const closeAssignModal = () => {
|
const closeAssignModal = () => {
|
||||||
showAssignModal.value = false
|
showAssignModal.value = false
|
||||||
assignForm.user_id = null
|
assignForm.user_id = null
|
||||||
@@ -1053,11 +1075,11 @@ const closeExtendModal = () => {
|
|||||||
const handleExtendSubscription = async () => {
|
const handleExtendSubscription = async () => {
|
||||||
if (!extendingSubscription.value) return
|
if (!extendingSubscription.value) return
|
||||||
|
|
||||||
// 前端验证:调整后剩余天数必须 > 0
|
// 前端验证:调整后的过期时间必须在未来
|
||||||
if (extendingSubscription.value.expires_at) {
|
if (extendingSubscription.value.expires_at) {
|
||||||
const currentDaysRemaining = getDaysRemaining(extendingSubscription.value.expires_at) ?? 0
|
const expiresAt = new Date(extendingSubscription.value.expires_at)
|
||||||
const newDaysRemaining = currentDaysRemaining + extendForm.days
|
const newExpiresAt = new Date(expiresAt.getTime() + extendForm.days * 24 * 60 * 60 * 1000)
|
||||||
if (newDaysRemaining <= 0) {
|
if (newExpiresAt <= new Date()) {
|
||||||
appStore.showError(t('admin.subscriptions.adjustWouldExpire'))
|
appStore.showError(t('admin.subscriptions.adjustWouldExpire'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user