Merge pull request #3622 from deqiying/feat/subscription-revoke-restore

支持恢复已撤销订阅
This commit is contained in:
Wesley Liddick
2026-07-02 17:34:20 +08:00
committed by GitHub
17 changed files with 382 additions and 19 deletions
@@ -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")
}
+1
View File
@@ -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
+12 -1
View File
@@ -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
+9 -2
View File
@@ -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.',
+8 -2
View File
@@ -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: '订阅模式允许你按时间周期为用户分配使用额度,支持日/周/月配额限制。按照以下步骤即可完成配置。',
+1 -1
View File
@@ -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