diff --git a/backend/internal/handler/admin/subscription_handler.go b/backend/internal/handler/admin/subscription_handler.go index 990b19e72a..d370dad886 100644 --- a/backend/internal/handler/admin/subscription_handler.go +++ b/backend/internal/handler/admin/subscription_handler.go @@ -268,6 +268,24 @@ func (h *SubscriptionHandler) Revoke(c *gin.Context) { response.Success(c, gin.H{"message": "Subscription revoked successfully"}) } +// Restore handles restoring a revoked subscription. +// POST /api/v1/admin/subscriptions/:id/restore +func (h *SubscriptionHandler) Restore(c *gin.Context) { + subscriptionID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "Invalid subscription ID") + return + } + + subscription, err := h.subscriptionService.RestoreSubscription(c.Request.Context(), subscriptionID) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, dto.UserSubscriptionFromServiceAdmin(subscription)) +} + // ListByGroup handles listing subscriptions for a specific group // GET /api/v1/admin/groups/:id/subscriptions func (h *SubscriptionHandler) ListByGroup(c *gin.Context) { diff --git a/backend/internal/repository/user_subscription_repo.go b/backend/internal/repository/user_subscription_repo.go index 97a6bd08dc..6326c9711f 100644 --- a/backend/internal/repository/user_subscription_repo.go +++ b/backend/internal/repository/user_subscription_repo.go @@ -75,6 +75,21 @@ func (r *userSubscriptionRepository) GetByID(ctx context.Context, id int64) (*se return userSubscriptionEntityToService(m), nil } +func (r *userSubscriptionRepository) GetByIDIncludeDeleted(ctx context.Context, id int64) (*service.UserSubscription, error) { + client := clientFromContext(ctx, r.client) + queryCtx := mixins.SkipSoftDelete(ctx) + m, err := client.UserSubscription.Query(). + Where(usersubscription.IDEQ(id)). + WithUser(). + WithGroup(). + WithAssignedByUser(). + Only(queryCtx) + if err != nil { + return nil, translatePersistenceError(err, service.ErrSubscriptionNotFound, nil) + } + return userSubscriptionEntityToServicePreserveStatus(m), nil +} + func (r *userSubscriptionRepository) GetByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error) { client := clientFromContext(ctx, r.client) m, err := client.UserSubscription.Query(). @@ -141,6 +156,20 @@ func (r *userSubscriptionRepository) Delete(ctx context.Context, id int64) error return err } +func (r *userSubscriptionRepository) Restore(ctx context.Context, subscriptionID int64, restoredStatus string) (*service.UserSubscription, error) { + client := clientFromContext(ctx, r.client) + queryCtx := mixins.SkipSoftDelete(ctx) + _, err := client.UserSubscription.UpdateOneID(subscriptionID). + SetStatus(restoredStatus). + ClearDeletedAt(). + SetUpdatedAt(time.Now()). + Save(queryCtx) + if err != nil { + return nil, translatePersistenceError(err, service.ErrSubscriptionNotFound, service.ErrSubscriptionRestoreConflict) + } + return r.GetByID(ctx, subscriptionID) +} + func (r *userSubscriptionRepository) ListByUserID(ctx context.Context, userID int64) ([]service.UserSubscription, error) { client := clientFromContext(ctx, r.client) subs, err := client.UserSubscription.Query(). @@ -299,6 +328,10 @@ func (r *userSubscriptionRepository) ExistsByUserIDAndGroupID(ctx context.Contex Exist(ctx) } +func (r *userSubscriptionRepository) ExistsActiveByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (bool, error) { + return r.ExistsByUserIDAndGroupID(ctx, userID, groupID) +} + func (r *userSubscriptionRepository) ExtendExpiry(ctx context.Context, subscriptionID int64, newExpiresAt time.Time) error { client := clientFromContext(ctx, r.client) _, err := client.UserSubscription.UpdateOneID(subscriptionID). @@ -520,11 +553,19 @@ func uniqueInt64s(values []int64) []int64 { } func userSubscriptionEntityToService(m *dbent.UserSubscription) *service.UserSubscription { + return userSubscriptionEntityToServiceWithStatusMapping(m, true) +} + +func userSubscriptionEntityToServicePreserveStatus(m *dbent.UserSubscription) *service.UserSubscription { + return userSubscriptionEntityToServiceWithStatusMapping(m, false) +} + +func userSubscriptionEntityToServiceWithStatusMapping(m *dbent.UserSubscription, mapDeletedToRevoked bool) *service.UserSubscription { if m == nil { return nil } status := m.Status - if m.DeletedAt != nil { + if mapDeletedToRevoked && m.DeletedAt != nil { status = service.SubscriptionStatusRevoked } out := &service.UserSubscription{ diff --git a/backend/internal/repository/user_subscription_repo_integration_test.go b/backend/internal/repository/user_subscription_repo_integration_test.go index c800bc99fa..caa88cc640 100644 --- a/backend/internal/repository/user_subscription_repo_integration_test.go +++ b/backend/internal/repository/user_subscription_repo_integration_test.go @@ -157,6 +157,41 @@ func (s *UserSubscriptionRepoSuite) TestDelete() { s.Require().Error(err, "expected error after delete") } +func (s *UserSubscriptionRepoSuite) TestGetByIDIncludeDeleted_PreservesPersistedStatus() { + user := s.mustCreateUser("include-deleted@test.com", service.RoleUser) + group := s.mustCreateGroup("g-include-deleted") + sub := s.mustCreateSubscription(user.ID, group.ID, func(c *dbent.UserSubscriptionCreate) { + c.SetStatus(service.SubscriptionStatusActive) + }) + + s.Require().NoError(s.repo.Delete(s.ctx, sub.ID), "Delete") + + got, err := s.repo.GetByIDIncludeDeleted(s.ctx, sub.ID) + s.Require().NoError(err, "GetByIDIncludeDeleted") + s.Require().Equal(service.SubscriptionStatusActive, got.Status) + s.Require().NotNil(got.DeletedAt) + s.Require().NotNil(got.User) + s.Require().NotNil(got.Group) +} + +func (s *UserSubscriptionRepoSuite) TestRestore() { + user := s.mustCreateUser("restore@test.com", service.RoleUser) + group := s.mustCreateGroup("g-restore") + sub := s.mustCreateSubscription(user.ID, group.ID, nil) + + s.Require().NoError(s.repo.Delete(s.ctx, sub.ID), "Delete") + + restored, err := s.repo.Restore(s.ctx, sub.ID, service.SubscriptionStatusExpired) + s.Require().NoError(err, "Restore") + s.Require().Equal(service.SubscriptionStatusExpired, restored.Status) + s.Require().Nil(restored.DeletedAt) + + got, err := s.repo.GetByID(s.ctx, sub.ID) + s.Require().NoError(err, "GetByID after restore") + s.Require().Nil(got.DeletedAt) + s.Require().Equal(service.SubscriptionStatusExpired, got.Status) +} + func (s *UserSubscriptionRepoSuite) TestDelete_Idempotent() { s.Require().NoError(s.repo.Delete(s.ctx, 42424242), "Delete should be idempotent") } @@ -587,6 +622,22 @@ func (s *UserSubscriptionRepoSuite) TestExistsByUserIDAndGroupID() { s.Require().False(notExists) } +func (s *UserSubscriptionRepoSuite) TestExistsActiveByUserIDAndGroupID_IgnoresSoftDeletedRows() { + user := s.mustCreateUser("exists-active@test.com", service.RoleUser) + group := s.mustCreateGroup("g-exists-active") + sub := s.mustCreateSubscription(user.ID, group.ID, nil) + + exists, err := s.repo.ExistsActiveByUserIDAndGroupID(s.ctx, user.ID, group.ID) + s.Require().NoError(err, "ExistsActiveByUserIDAndGroupID") + s.Require().True(exists) + + s.Require().NoError(s.repo.Delete(s.ctx, sub.ID), "Delete") + + exists, err = s.repo.ExistsActiveByUserIDAndGroupID(s.ctx, user.ID, group.ID) + s.Require().NoError(err, "ExistsActiveByUserIDAndGroupID after delete") + s.Require().False(exists) +} + // --- CountByGroupID / CountActiveByGroupID --- func (s *UserSubscriptionRepoSuite) TestCountByGroupID() { diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 533e0b404a..acc9f0cac1 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -1999,6 +1999,9 @@ func (stubUserSubscriptionRepo) Create(ctx context.Context, sub *service.UserSub func (stubUserSubscriptionRepo) GetByID(ctx context.Context, id int64) (*service.UserSubscription, error) { return nil, errors.New("not implemented") } +func (stubUserSubscriptionRepo) GetByIDIncludeDeleted(ctx context.Context, id int64) (*service.UserSubscription, error) { + return nil, errors.New("not implemented") +} func (stubUserSubscriptionRepo) GetByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error) { return nil, errors.New("not implemented") } @@ -2011,6 +2014,9 @@ func (stubUserSubscriptionRepo) Update(ctx context.Context, sub *service.UserSub func (stubUserSubscriptionRepo) Delete(ctx context.Context, id int64) error { return errors.New("not implemented") } +func (stubUserSubscriptionRepo) Restore(ctx context.Context, subscriptionID int64, restoredStatus string) (*service.UserSubscription, error) { + return nil, errors.New("not implemented") +} func (r *stubUserSubscriptionRepo) ListByUserID(ctx context.Context, userID int64) ([]service.UserSubscription, error) { if r.byUser == nil { return nil, nil @@ -2032,6 +2038,9 @@ func (stubUserSubscriptionRepo) List(ctx context.Context, params pagination.Pagi func (stubUserSubscriptionRepo) ExistsByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (bool, error) { return false, errors.New("not implemented") } +func (stubUserSubscriptionRepo) ExistsActiveByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (bool, error) { + return false, errors.New("not implemented") +} func (stubUserSubscriptionRepo) ExtendExpiry(ctx context.Context, subscriptionID int64, newExpiresAt time.Time) error { return errors.New("not implemented") } diff --git a/backend/internal/server/middleware/api_key_auth_google_test.go b/backend/internal/server/middleware/api_key_auth_google_test.go index 32e7e70fd0..bf3909fcd4 100644 --- a/backend/internal/server/middleware/api_key_auth_google_test.go +++ b/backend/internal/server/middleware/api_key_auth_google_test.go @@ -117,6 +117,9 @@ func (f fakeGoogleSubscriptionRepo) Create(ctx context.Context, sub *service.Use func (f fakeGoogleSubscriptionRepo) GetByID(ctx context.Context, id int64) (*service.UserSubscription, error) { return nil, errors.New("not implemented") } +func (f fakeGoogleSubscriptionRepo) GetByIDIncludeDeleted(ctx context.Context, id int64) (*service.UserSubscription, error) { + return nil, errors.New("not implemented") +} func (f fakeGoogleSubscriptionRepo) GetByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error) { return nil, errors.New("not implemented") } @@ -132,6 +135,9 @@ func (f fakeGoogleSubscriptionRepo) Update(ctx context.Context, sub *service.Use func (f fakeGoogleSubscriptionRepo) Delete(ctx context.Context, id int64) error { return errors.New("not implemented") } +func (f fakeGoogleSubscriptionRepo) Restore(ctx context.Context, subscriptionID int64, restoredStatus string) (*service.UserSubscription, error) { + return nil, errors.New("not implemented") +} func (f fakeGoogleSubscriptionRepo) ListByUserID(ctx context.Context, userID int64) ([]service.UserSubscription, error) { return nil, errors.New("not implemented") } @@ -147,6 +153,9 @@ func (f fakeGoogleSubscriptionRepo) List(ctx context.Context, params pagination. func (f fakeGoogleSubscriptionRepo) ExistsByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (bool, error) { return false, errors.New("not implemented") } +func (f fakeGoogleSubscriptionRepo) ExistsActiveByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (bool, error) { + return false, errors.New("not implemented") +} func (f fakeGoogleSubscriptionRepo) ExtendExpiry(ctx context.Context, subscriptionID int64, newExpiresAt time.Time) error { return errors.New("not implemented") } diff --git a/backend/internal/server/middleware/api_key_auth_test.go b/backend/internal/server/middleware/api_key_auth_test.go index d2234e8370..25c7db0aac 100644 --- a/backend/internal/server/middleware/api_key_auth_test.go +++ b/backend/internal/server/middleware/api_key_auth_test.go @@ -1175,6 +1175,10 @@ func (r *stubUserSubscriptionRepo) GetByID(ctx context.Context, id int64) (*serv return nil, errors.New("not implemented") } +func (r *stubUserSubscriptionRepo) GetByIDIncludeDeleted(ctx context.Context, id int64) (*service.UserSubscription, error) { + return nil, errors.New("not implemented") +} + func (r *stubUserSubscriptionRepo) GetByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error) { return nil, errors.New("not implemented") } @@ -1194,6 +1198,10 @@ func (r *stubUserSubscriptionRepo) Delete(ctx context.Context, id int64) error { return errors.New("not implemented") } +func (r *stubUserSubscriptionRepo) Restore(ctx context.Context, subscriptionID int64, restoredStatus string) (*service.UserSubscription, error) { + return nil, errors.New("not implemented") +} + func (r *stubUserSubscriptionRepo) ListByUserID(ctx context.Context, userID int64) ([]service.UserSubscription, error) { return nil, errors.New("not implemented") } @@ -1214,6 +1222,10 @@ func (r *stubUserSubscriptionRepo) ExistsByUserIDAndGroupID(ctx context.Context, return false, errors.New("not implemented") } +func (r *stubUserSubscriptionRepo) ExistsActiveByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (bool, error) { + return false, errors.New("not implemented") +} + func (r *stubUserSubscriptionRepo) ExtendExpiry(ctx context.Context, subscriptionID int64, newExpiresAt time.Time) error { return errors.New("not implemented") } diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 662c107e23..a456c8c370 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -562,6 +562,7 @@ func registerSubscriptionRoutes(admin *gin.RouterGroup, h *handler.Handlers) { 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) + subscriptions.POST("/:id/restore", h.Admin.Subscription.Restore) subscriptions.DELETE("/:id", h.Admin.Subscription.Revoke) } diff --git a/backend/internal/service/subscription_assign_idempotency_test.go b/backend/internal/service/subscription_assign_idempotency_test.go index c8ace61330..8e249af52b 100644 --- a/backend/internal/service/subscription_assign_idempotency_test.go +++ b/backend/internal/service/subscription_assign_idempotency_test.go @@ -73,6 +73,9 @@ func (userSubRepoNoop) Create(context.Context, *UserSubscription) error { func (userSubRepoNoop) GetByID(context.Context, int64) (*UserSubscription, error) { panic("unexpected GetByID call") } +func (userSubRepoNoop) GetByIDIncludeDeleted(context.Context, int64) (*UserSubscription, error) { + panic("unexpected GetByIDIncludeDeleted call") +} func (userSubRepoNoop) GetByUserIDAndGroupID(context.Context, int64, int64) (*UserSubscription, error) { panic("unexpected GetByUserIDAndGroupID call") } @@ -83,6 +86,9 @@ func (userSubRepoNoop) Update(context.Context, *UserSubscription) error { panic("unexpected Update call") } func (userSubRepoNoop) Delete(context.Context, int64) error { panic("unexpected Delete call") } +func (userSubRepoNoop) Restore(context.Context, int64, string) (*UserSubscription, error) { + panic("unexpected Restore call") +} func (userSubRepoNoop) ListByUserID(context.Context, int64) ([]UserSubscription, error) { panic("unexpected ListByUserID call") } @@ -98,6 +104,9 @@ func (userSubRepoNoop) List(context.Context, pagination.PaginationParams, *int64 func (userSubRepoNoop) ExistsByUserIDAndGroupID(context.Context, int64, int64) (bool, error) { panic("unexpected ExistsByUserIDAndGroupID call") } +func (userSubRepoNoop) ExistsActiveByUserIDAndGroupID(context.Context, int64, int64) (bool, error) { + panic("unexpected ExistsActiveByUserIDAndGroupID call") +} func (userSubRepoNoop) ExtendExpiry(context.Context, int64, time.Time) error { panic("unexpected ExtendExpiry call") } diff --git a/backend/internal/service/subscription_expiry_service_test.go b/backend/internal/service/subscription_expiry_service_test.go index 00ae3eb386..056315a289 100644 --- a/backend/internal/service/subscription_expiry_service_test.go +++ b/backend/internal/service/subscription_expiry_service_test.go @@ -22,6 +22,10 @@ func (r *subscriptionExpiryRepoStub) GetByID(context.Context, int64) (*UserSubsc return nil, ErrSubscriptionNotFound } +func (r *subscriptionExpiryRepoStub) GetByIDIncludeDeleted(context.Context, int64) (*UserSubscription, error) { + return nil, ErrSubscriptionNotFound +} + func (r *subscriptionExpiryRepoStub) GetByUserIDAndGroupID(context.Context, int64, int64) (*UserSubscription, error) { return nil, ErrSubscriptionNotFound } @@ -38,6 +42,10 @@ func (r *subscriptionExpiryRepoStub) Delete(context.Context, int64) error { return nil } +func (r *subscriptionExpiryRepoStub) Restore(context.Context, int64, string) (*UserSubscription, error) { + return nil, ErrSubscriptionNotFound +} + func (r *subscriptionExpiryRepoStub) ListByUserID(context.Context, int64) ([]UserSubscription, error) { return nil, nil } @@ -59,6 +67,10 @@ func (r *subscriptionExpiryRepoStub) ExistsByUserIDAndGroupID(context.Context, i return false, nil } +func (r *subscriptionExpiryRepoStub) ExistsActiveByUserIDAndGroupID(context.Context, int64, int64) (bool, error) { + return false, nil +} + func (r *subscriptionExpiryRepoStub) ExtendExpiry(context.Context, int64, time.Time) error { return nil } diff --git a/backend/internal/service/subscription_revoke_cache_test.go b/backend/internal/service/subscription_revoke_cache_test.go index f1ba8e3893..b781824b80 100644 --- a/backend/internal/service/subscription_revoke_cache_test.go +++ b/backend/internal/service/subscription_revoke_cache_test.go @@ -74,3 +74,99 @@ func TestRevokeSubscription_InvalidatesL1CacheSynchronously(t *testing.T) { require.ErrorIs(t, err, ErrSubscriptionNotFound) require.Equal(t, 2, repo.getActiveCalls, "撤销后应回源确认订阅已不存在,不能命中旧 L1") } + +type restoreUserSubRepoStub struct { + userSubRepoNoop + + sub *UserSubscription + existsActive bool + restoreCalls int + restoredStatus string +} + +func (r *restoreUserSubRepoStub) GetByIDIncludeDeleted(_ context.Context, id int64) (*UserSubscription, error) { + if r.sub == nil || r.sub.ID != id { + return nil, ErrSubscriptionNotFound + } + cp := *r.sub + return &cp, nil +} + +func (r *restoreUserSubRepoStub) ExistsActiveByUserIDAndGroupID(context.Context, int64, int64) (bool, error) { + return r.existsActive, nil +} + +func (r *restoreUserSubRepoStub) Restore(_ context.Context, id int64, restoredStatus string) (*UserSubscription, error) { + if r.sub == nil || r.sub.ID != id { + return nil, ErrSubscriptionNotFound + } + r.restoreCalls++ + r.restoredStatus = restoredStatus + cp := *r.sub + cp.Status = restoredStatus + cp.DeletedAt = nil + r.sub = &cp + return &cp, nil +} + +func TestRestoreSubscription_ExpiredActiveRestoresAsExpired(t *testing.T) { + deletedAt := time.Now().Add(-time.Hour) + repo := &restoreUserSubRepoStub{ + sub: &UserSubscription{ + ID: 1, + UserID: 10, + GroupID: 20, + Status: SubscriptionStatusActive, + ExpiresAt: time.Now().Add(-time.Minute), + DeletedAt: &deletedAt, + }, + } + svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil) + t.Cleanup(svc.Stop) + + restored, err := svc.RestoreSubscription(context.Background(), 1) + require.NoError(t, err) + require.Equal(t, 1, repo.restoreCalls) + require.Equal(t, SubscriptionStatusExpired, repo.restoredStatus) + require.Equal(t, SubscriptionStatusExpired, restored.Status) + require.Nil(t, restored.DeletedAt) +} + +func TestRestoreSubscription_NotRevokedReturnsConflict(t *testing.T) { + repo := &restoreUserSubRepoStub{ + sub: &UserSubscription{ + ID: 1, + UserID: 10, + GroupID: 20, + Status: SubscriptionStatusActive, + ExpiresAt: time.Now().Add(time.Hour), + }, + } + svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil) + t.Cleanup(svc.Stop) + + _, err := svc.RestoreSubscription(context.Background(), 1) + require.ErrorIs(t, err, ErrSubscriptionNotRevoked) + require.Zero(t, repo.restoreCalls) +} + +func TestRestoreSubscription_LiveSubscriptionConflict(t *testing.T) { + deletedAt := time.Now().Add(-time.Hour) + repo := &restoreUserSubRepoStub{ + existsActive: true, + sub: &UserSubscription{ + ID: 1, + UserID: 10, + GroupID: 20, + Status: SubscriptionStatusExpired, + ExpiresAt: time.Now().Add(-time.Hour), + DeletedAt: &deletedAt, + }, + } + svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil) + t.Cleanup(svc.Stop) + + _, err := svc.RestoreSubscription(context.Background(), 1) + require.ErrorIs(t, err, ErrSubscriptionRestoreConflict) + require.Zero(t, repo.restoreCalls) +} diff --git a/backend/internal/service/subscription_service.go b/backend/internal/service/subscription_service.go index 0d9043c0e5..0a4fc7b757 100644 --- a/backend/internal/service/subscription_service.go +++ b/backend/internal/service/subscription_service.go @@ -25,18 +25,20 @@ var MaxExpiresAt = time.Date(2099, 12, 31, 23, 59, 59, 0, time.UTC) const MaxValidityDays = 36500 var ( - ErrSubscriptionNotFound = infraerrors.NotFound("SUBSCRIPTION_NOT_FOUND", "subscription not found") - ErrSubscriptionExpired = infraerrors.Forbidden("SUBSCRIPTION_EXPIRED", "subscription has expired") - ErrSubscriptionSuspended = infraerrors.Forbidden("SUBSCRIPTION_SUSPENDED", "subscription is suspended") - ErrSubscriptionAlreadyExists = infraerrors.Conflict("SUBSCRIPTION_ALREADY_EXISTS", "subscription already exists for this user and group") - ErrSubscriptionAssignConflict = infraerrors.Conflict("SUBSCRIPTION_ASSIGN_CONFLICT", "subscription exists but request conflicts with existing assignment semantics") - ErrGroupNotSubscriptionType = infraerrors.BadRequest("GROUP_NOT_SUBSCRIPTION_TYPE", "group is not a subscription type") - ErrInvalidInput = infraerrors.BadRequest("INVALID_INPUT", "at least one of resetDaily, resetWeekly, or resetMonthly must be true") - ErrDailyLimitExceeded = infraerrors.TooManyRequests("DAILY_LIMIT_EXCEEDED", "daily usage limit exceeded") - ErrWeeklyLimitExceeded = infraerrors.TooManyRequests("WEEKLY_LIMIT_EXCEEDED", "weekly usage limit exceeded") - ErrMonthlyLimitExceeded = infraerrors.TooManyRequests("MONTHLY_LIMIT_EXCEEDED", "monthly usage limit exceeded") - ErrSubscriptionNilInput = infraerrors.BadRequest("SUBSCRIPTION_NIL_INPUT", "subscription input cannot be nil") - ErrAdjustWouldExpire = infraerrors.BadRequest("ADJUST_WOULD_EXPIRE", "adjustment would result in expired subscription (remaining days must be > 0)") + ErrSubscriptionNotFound = infraerrors.NotFound("SUBSCRIPTION_NOT_FOUND", "subscription not found") + ErrSubscriptionExpired = infraerrors.Forbidden("SUBSCRIPTION_EXPIRED", "subscription has expired") + ErrSubscriptionSuspended = infraerrors.Forbidden("SUBSCRIPTION_SUSPENDED", "subscription is suspended") + ErrSubscriptionAlreadyExists = infraerrors.Conflict("SUBSCRIPTION_ALREADY_EXISTS", "subscription already exists for this user and group") + ErrSubscriptionAssignConflict = infraerrors.Conflict("SUBSCRIPTION_ASSIGN_CONFLICT", "subscription exists but request conflicts with existing assignment semantics") + ErrSubscriptionNotRevoked = infraerrors.Conflict("SUBSCRIPTION_NOT_REVOKED", "subscription is not revoked") + ErrSubscriptionRestoreConflict = infraerrors.Conflict("SUBSCRIPTION_RESTORE_CONFLICT", "subscription already exists for this user and group") + ErrGroupNotSubscriptionType = infraerrors.BadRequest("GROUP_NOT_SUBSCRIPTION_TYPE", "group is not a subscription type") + ErrInvalidInput = infraerrors.BadRequest("INVALID_INPUT", "at least one of resetDaily, resetWeekly, or resetMonthly must be true") + ErrDailyLimitExceeded = infraerrors.TooManyRequests("DAILY_LIMIT_EXCEEDED", "daily usage limit exceeded") + ErrWeeklyLimitExceeded = infraerrors.TooManyRequests("WEEKLY_LIMIT_EXCEEDED", "weekly usage limit exceeded") + ErrMonthlyLimitExceeded = infraerrors.TooManyRequests("MONTHLY_LIMIT_EXCEEDED", "monthly usage limit exceeded") + ErrSubscriptionNilInput = infraerrors.BadRequest("SUBSCRIPTION_NIL_INPUT", "subscription input cannot be nil") + ErrAdjustWouldExpire = infraerrors.BadRequest("ADJUST_WOULD_EXPIRE", "adjustment would result in expired subscription (remaining days must be > 0)") ) // SubscriptionService 订阅服务 @@ -578,6 +580,41 @@ func (s *SubscriptionService) RevokeSubscription(ctx context.Context, subscripti return nil } +// RestoreSubscription 恢复已撤销订阅 +func (s *SubscriptionService) RestoreSubscription(ctx context.Context, subscriptionID int64) (*UserSubscription, error) { + sub, err := s.userSubRepo.GetByIDIncludeDeleted(ctx, subscriptionID) + if err != nil { + return nil, err + } + if sub.DeletedAt == nil { + return nil, ErrSubscriptionNotRevoked + } + + exists, err := s.userSubRepo.ExistsActiveByUserIDAndGroupID(ctx, sub.UserID, sub.GroupID) + if err != nil { + return nil, err + } + if exists { + return nil, ErrSubscriptionRestoreConflict + } + + restoredStatus := sub.Status + now := time.Now() + if restoredStatus == SubscriptionStatusActive && !sub.ExpiresAt.After(now) { + restoredStatus = SubscriptionStatusExpired + } + + restored, err := s.userSubRepo.Restore(ctx, subscriptionID, restoredStatus) + if err != nil { + return nil, err + } + + if err := s.invalidateSubscriptionCaches(restored.UserID, restored.GroupID); err != nil { + return nil, err + } + return restored, nil +} + // ExtendSubscription 调整订阅时长(正数延长,负数缩短) func (s *SubscriptionService) ExtendSubscription(ctx context.Context, subscriptionID int64, days int) (*UserSubscription, error) { sub, err := s.userSubRepo.GetByID(ctx, subscriptionID) diff --git a/backend/internal/service/user_subscription_port.go b/backend/internal/service/user_subscription_port.go index 4484fae8f4..43d41d6dd7 100644 --- a/backend/internal/service/user_subscription_port.go +++ b/backend/internal/service/user_subscription_port.go @@ -10,10 +10,12 @@ import ( type UserSubscriptionRepository interface { Create(ctx context.Context, sub *UserSubscription) error GetByID(ctx context.Context, id int64) (*UserSubscription, error) + GetByIDIncludeDeleted(ctx context.Context, id int64) (*UserSubscription, error) GetByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (*UserSubscription, error) GetActiveByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (*UserSubscription, error) Update(ctx context.Context, sub *UserSubscription) error Delete(ctx context.Context, id int64) error + Restore(ctx context.Context, subscriptionID int64, restoredStatus string) (*UserSubscription, error) ListByUserID(ctx context.Context, userID int64) ([]UserSubscription, error) ListActiveByUserID(ctx context.Context, userID int64) ([]UserSubscription, error) @@ -21,6 +23,7 @@ type UserSubscriptionRepository interface { List(ctx context.Context, params pagination.PaginationParams, userID, groupID *int64, status, platform, sortBy, sortOrder string) ([]UserSubscription, *pagination.PaginationResult, error) ExistsByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (bool, error) + ExistsActiveByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (bool, error) ExtendExpiry(ctx context.Context, subscriptionID int64, newExpiresAt time.Time) error UpdateStatus(ctx context.Context, subscriptionID int64, status string) error UpdateNotes(ctx context.Context, subscriptionID int64, notes string) error diff --git a/frontend/src/api/admin/subscriptions.ts b/frontend/src/api/admin/subscriptions.ts index 63d92812ca..893dc13ab8 100644 --- a/frontend/src/api/admin/subscriptions.ts +++ b/frontend/src/api/admin/subscriptions.ts @@ -24,7 +24,7 @@ export async function list( page: number = 1, pageSize: number = 20, filters?: { - status?: 'active' | 'expired' | 'revoked' + status?: 'active' | 'expired' | 'revoked' | 'suspended' user_id?: number group_id?: number platform?: string @@ -121,6 +121,16 @@ export async function revoke(id: number): Promise<{ message: string }> { return data } +/** + * Restore revoked subscription + * @param id - Subscription ID + * @returns Restored subscription + */ +export async function restore(id: number): Promise { + const { data } = await apiClient.post(`/admin/subscriptions/${id}/restore`) + return data +} + /** * Reset daily, weekly, and/or monthly usage quota for a subscription * @param id - Subscription ID @@ -188,6 +198,7 @@ export const subscriptionsAPI = { bulkAssign, extend, revoke, + restore, resetQuota, listByGroup, listByUser diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 3469d3fff2..b41bb4e55d 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -2977,6 +2977,7 @@ export default { assignSubscription: 'Assign Subscription', adjustSubscription: 'Adjust Subscription', revokeSubscription: 'Revoke Subscription', + restoreSubscription: 'Restore Subscription', allStatus: 'All Status', allGroups: 'All Groups', allPlatforms: 'All Platforms', @@ -2999,7 +3000,8 @@ export default { status: { active: 'Active', expired: 'Expired', - revoked: 'Revoked' + revoked: 'Revoked', + suspended: 'Suspended' }, columns: { user: 'User', @@ -3028,6 +3030,7 @@ export default { adjust: 'Adjust', adjusting: 'Adjusting...', revoke: 'Revoke', + restore: 'Restore', resetQuota: 'Reset Quota', resetQuotaTitle: 'Reset Usage Quota', resetQuotaConfirm: "Reset the daily, weekly, and monthly usage quota for '{user}'? Usage will be zeroed and windows restarted from today.", @@ -3038,17 +3041,21 @@ export default { subscriptionAssigned: 'Subscription assigned successfully', subscriptionAdjusted: 'Subscription adjusted successfully', subscriptionRevoked: 'Subscription revoked successfully', + subscriptionRestored: 'Subscription restored successfully', failedToLoad: 'Failed to load subscriptions', failedToAssign: 'Failed to assign subscription', failedToAdjust: 'Failed to adjust subscription', failedToRevoke: 'Failed to revoke subscription', + failedToRestore: 'Failed to restore subscription', adjustWouldExpire: 'Remaining days after adjustment must be greater than 0', adjustOutOfRange: 'Adjustment days must be between -36500 and 36500', pleaseSelectUser: 'Please select a user', pleaseSelectGroup: 'Please select a group', validityDaysRequired: 'Please enter a valid number of days (at least 1)', revokeConfirm: - "Are you sure you want to revoke the subscription for '{user}'? This action cannot be undone.", + "Are you sure you want to revoke the subscription for '{user}'? You can restore it later from the revoked list.", + restoreConfirm: + "Restore the subscription for '{user}'? If the original subscription has expired, it will be restored as expired.", guide: { title: 'Subscription Management Guide', subtitle: 'Subscription mode lets you assign time-based usage quotas to users, with daily/weekly/monthly limits. Follow these steps to get started.', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 3fa1e1371d..b344f01a09 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -3054,6 +3054,7 @@ export default { assignSubscription: '分配订阅', adjustSubscription: '调整订阅', revokeSubscription: '撤销订阅', + restoreSubscription: '恢复订阅', allStatus: '全部状态', allGroups: '全部分组', allPlatforms: '全部平台', @@ -3076,7 +3077,8 @@ export default { status: { active: '生效中', expired: '已过期', - revoked: '已撤销' + revoked: '已撤销', + suspended: '已暂停' }, columns: { user: '用户', @@ -3105,6 +3107,7 @@ export default { adjust: '调整', adjusting: '调整中...', revoke: '撤销', + restore: '恢复', resetQuota: '重置配额', resetQuotaTitle: '重置用量配额', resetQuotaConfirm: "确定要重置 '{user}' 的每日、每周和每月用量配额吗?用量将归零并从今天开始重新计算。", @@ -3115,16 +3118,19 @@ export default { subscriptionAssigned: '订阅分配成功', subscriptionAdjusted: '订阅调整成功', subscriptionRevoked: '订阅撤销成功', + subscriptionRestored: '订阅已恢复', failedToLoad: '加载订阅列表失败', failedToAssign: '分配订阅失败', failedToAdjust: '调整订阅失败', failedToRevoke: '撤销订阅失败', + failedToRestore: '恢复订阅失败', adjustWouldExpire: '调整后剩余天数必须大于0', adjustOutOfRange: '调整天数必须在 -36500 到 36500 之间', pleaseSelectUser: '请选择用户', pleaseSelectGroup: '请选择分组', validityDaysRequired: '请输入有效的天数(至少1天)', - revokeConfirm: "确定要撤销 '{user}' 的订阅吗?此操作无法撤销。", + revokeConfirm: "确定要撤销 '{user}' 的订阅吗?可稍后在已撤销列表中恢复。", + restoreConfirm: "确定要恢复 '{user}' 的订阅吗?如果原订阅已过期,恢复后将显示为已过期。", guide: { title: '订阅管理教程', subtitle: '订阅模式允许你按时间周期为用户分配使用额度,支持日/周/月配额限制。按照以下步骤即可完成配置。', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 9e185b0fc2..50252afe28 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1602,7 +1602,7 @@ export interface UserSubscription { id: number user_id: number group_id: number - status: 'active' | 'expired' | 'revoked' + status: 'active' | 'expired' | 'revoked' | 'suspended' starts_at: string daily_usage_usd: number weekly_usage_usd: number diff --git a/frontend/src/views/admin/SubscriptionsView.vue b/frontend/src/views/admin/SubscriptionsView.vue index 6c53064d96..3f6e73a8cd 100644 --- a/frontend/src/views/admin/SubscriptionsView.vue +++ b/frontend/src/views/admin/SubscriptionsView.vue @@ -404,6 +404,14 @@ {{ t('admin.subscriptions.revoke') }} + @@ -645,6 +653,17 @@ @cancel="showRevokeDialog = false" /> + + + (null) const resettingQuota = ref(false) const extendingSubscription = ref(null) const revokingSubscription = ref(null) +const restoringSubscription = ref(null) const assignForm = reactive({ user_id: null as number | null, @@ -1261,6 +1282,26 @@ const confirmRevoke = async () => { } } +const handleRestore = (subscription: UserSubscription) => { + restoringSubscription.value = subscription + showRestoreDialog.value = true +} + +const confirmRestore = async () => { + if (!restoringSubscription.value) return + + try { + await adminAPI.subscriptions.restore(restoringSubscription.value.id) + appStore.showSuccess(t('admin.subscriptions.subscriptionRestored')) + showRestoreDialog.value = false + restoringSubscription.value = null + loadSubscriptions() + } catch (error: any) { + appStore.showError(error.response?.data?.detail || t('admin.subscriptions.failedToRestore')) + console.error('Error restoring subscription:', error) + } +} + const handleResetQuota = (subscription: UserSubscription) => { resettingSubscription.value = subscription showResetQuotaConfirm.value = true