mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
Merge pull request #3622 from deqiying/feat/subscription-revoke-restore
支持恢复已撤销订阅
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -2004,6 +2004,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")
|
||||
}
|
||||
@@ -2016,6 +2019,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
|
||||
@@ -2037,6 +2043,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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<UserSubscription> {
|
||||
const { data } = await apiClient.post<UserSubscription>(`/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
|
||||
|
||||
@@ -3002,6 +3002,7 @@ export default {
|
||||
assignSubscription: 'Assign Subscription',
|
||||
adjustSubscription: 'Adjust Subscription',
|
||||
revokeSubscription: 'Revoke Subscription',
|
||||
restoreSubscription: 'Restore Subscription',
|
||||
allStatus: 'All Status',
|
||||
allGroups: 'All Groups',
|
||||
allPlatforms: 'All Platforms',
|
||||
@@ -3024,7 +3025,8 @@ export default {
|
||||
status: {
|
||||
active: 'Active',
|
||||
expired: 'Expired',
|
||||
revoked: 'Revoked'
|
||||
revoked: 'Revoked',
|
||||
suspended: 'Suspended'
|
||||
},
|
||||
columns: {
|
||||
user: 'User',
|
||||
@@ -3053,6 +3055,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.",
|
||||
@@ -3063,17 +3066,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.',
|
||||
|
||||
@@ -3079,6 +3079,7 @@ export default {
|
||||
assignSubscription: '分配订阅',
|
||||
adjustSubscription: '调整订阅',
|
||||
revokeSubscription: '撤销订阅',
|
||||
restoreSubscription: '恢复订阅',
|
||||
allStatus: '全部状态',
|
||||
allGroups: '全部分组',
|
||||
allPlatforms: '全部平台',
|
||||
@@ -3101,7 +3102,8 @@ export default {
|
||||
status: {
|
||||
active: '生效中',
|
||||
expired: '已过期',
|
||||
revoked: '已撤销'
|
||||
revoked: '已撤销',
|
||||
suspended: '已暂停'
|
||||
},
|
||||
columns: {
|
||||
user: '用户',
|
||||
@@ -3130,6 +3132,7 @@ export default {
|
||||
adjust: '调整',
|
||||
adjusting: '调整中...',
|
||||
revoke: '撤销',
|
||||
restore: '恢复',
|
||||
resetQuota: '重置配额',
|
||||
resetQuotaTitle: '重置用量配额',
|
||||
resetQuotaConfirm: "确定要重置 '{user}' 的每日、每周和每月用量配额吗?用量将归零并从今天开始重新计算。",
|
||||
@@ -3140,16 +3143,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: '订阅模式允许你按时间周期为用户分配使用额度,支持日/周/月配额限制。按照以下步骤即可完成配置。',
|
||||
|
||||
@@ -1619,7 +1619,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
|
||||
|
||||
@@ -404,6 +404,14 @@
|
||||
<Icon name="ban" size="sm" />
|
||||
<span class="text-xs">{{ t('admin.subscriptions.revoke') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="row.status === 'revoked'"
|
||||
@click="handleRestore(row)"
|
||||
class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400"
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
<span class="text-xs">{{ t('admin.subscriptions.restore') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -645,6 +653,17 @@
|
||||
@cancel="showRevokeDialog = false"
|
||||
/>
|
||||
|
||||
<!-- Restore Confirmation Dialog -->
|
||||
<ConfirmDialog
|
||||
:show="showRestoreDialog"
|
||||
:title="t('admin.subscriptions.restoreSubscription')"
|
||||
:message="t('admin.subscriptions.restoreConfirm', { user: restoringSubscription?.user?.email })"
|
||||
:confirm-text="t('admin.subscriptions.restore')"
|
||||
:cancel-text="t('common.cancel')"
|
||||
@confirm="confirmRestore"
|
||||
@cancel="showRestoreDialog = false"
|
||||
/>
|
||||
|
||||
<!-- Reset Quota Confirmation Dialog -->
|
||||
<ConfirmDialog
|
||||
:show="showResetQuotaConfirm"
|
||||
@@ -940,12 +959,14 @@ const pagination = reactive({
|
||||
const showAssignModal = ref(false)
|
||||
const showExtendModal = ref(false)
|
||||
const showRevokeDialog = ref(false)
|
||||
const showRestoreDialog = ref(false)
|
||||
const showResetQuotaConfirm = ref(false)
|
||||
const submitting = ref(false)
|
||||
const resettingSubscription = ref<UserSubscription | null>(null)
|
||||
const resettingQuota = ref(false)
|
||||
const extendingSubscription = ref<UserSubscription | null>(null)
|
||||
const revokingSubscription = ref<UserSubscription | null>(null)
|
||||
const restoringSubscription = ref<UserSubscription | null>(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
|
||||
|
||||
Reference in New Issue
Block a user