using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Auth; using Tiku.Application.Jobs; using Tiku.Application.Security; using Tiku.Application.Storage; using Tiku.Application.Tenancy; using Tiku.Domain.Content; using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Tenancy; internal sealed class TenantLifecycleService( ITenancyPersistence tenancyPersistence, IIdentityPersistence identityPersistence, IContentAssetPersistence contentAssetPersistence, IJobsOperationsPersistence jobsOperationsPersistence, IBackgroundJobQueue backgroundJobService, IAuthSessionStore sessionStore, ITenantRuntimeCacheInvalidator runtimeCacheInvalidator, ITenantPublicCacheInvalidator publicCacheInvalidator, ITenantFeatureCacheInvalidator featureCacheInvalidator, IAuthorizationStateInvalidator authorizationStateInvalidator, IObjectStorageService objectStorageService) : ITenantLifecycleService { private static readonly TimeSpan RecentExportWindow = TimeSpan.FromHours(24); public async Task PreviewArchiveAsync( Guid tenantId, CancellationToken cancellationToken = default) { var tenant = await tenancyPersistence.Tenants.AsNoTracking() .SingleOrDefaultAsync(item => item.Id == tenantId, cancellationToken) ?? throw new TenantLifecycleException("tenant_not_found", "Tenant was not found."); var blockers = new List(); if (tenant.Status == TenantStatus.Archived) blockers.Add("tenant_already_archived"); if (await jobsOperationsPersistence.BackgroundJobs.AnyAsync(item => item.TenantId == tenantId && item.Status == BackgroundJobStatus.Processing, cancellationToken)) blockers.Add("processing_background_jobs"); var recentExport = await HasRecentExportAsync(tenantId, cancellationToken); if (!recentExport) blockers.Add("recent_successful_export_required"); return new TenantArchivePreview(tenantId, blockers.Count == 0, recentExport, blockers); } public async Task CreateExportAsync( Guid tenantId, Guid actorUserId, CancellationToken cancellationToken = default) { await RequireTenantAsync(tenantId, cancellationToken); var operation = CreateOperation(tenantId, actorUserId, TenantLifecycleOperationType.Export, null); jobsOperationsPersistence.TenantLifecycleOperations.Add(operation); await tenancyPersistence.SaveChangesAsync(cancellationToken); await backgroundJobService.EnqueueAsync( new CreateBackgroundJobCommand( tenantId, "tenant_export", JsonSerializer.SerializeToElement(new { operationId = operation.Id }), MaxRetries: 3, IdempotencyKey: $"tenant-export:{operation.Id:N}", IsSystemJob: true), cancellationToken); return ToItem(operation); } public async Task GetOperationAsync( Guid tenantId, Guid operationId, CancellationToken cancellationToken = default) { var operation = await jobsOperationsPersistence.TenantLifecycleOperations.AsNoTracking().SingleOrDefaultAsync( item => item.TenantId == tenantId && item.Id == operationId, cancellationToken); return operation is null ? null : ToItem(operation); } public async Task SignExportDownloadAsync( Guid tenantId, Guid operationId, CancellationToken cancellationToken = default) { var operation = await jobsOperationsPersistence.TenantLifecycleOperations.AsNoTracking().SingleOrDefaultAsync( item => item.TenantId == tenantId && item.Id == operationId && item.OperationType == TenantLifecycleOperationType.Export && item.Status == TenantLifecycleOperationStatus.Succeeded, cancellationToken) ?? throw new TenantLifecycleException("tenant_export_not_ready", "Tenant export is not ready."); var asset = operation.ExportAssetId.HasValue ? await contentAssetPersistence.ContentAssets.AsNoTracking().SingleOrDefaultAsync( item => item.TenantId == tenantId && item.Id == operation.ExportAssetId.Value, cancellationToken) : null; if (asset is null || string.IsNullOrWhiteSpace(asset.ObjectKey)) throw new TenantLifecycleException("tenant_export_not_ready", "Tenant export asset was not found."); return await objectStorageService.SignDownloadAsync( new ObjectStorageDownloadSignRequest( tenantId, ToProvider(asset.StorageProvider), asset.Bucket, asset.ObjectKey, TimeSpan.FromMinutes(15), asset.CdnUrl, asset.FileName), cancellationToken); } public async Task ArchiveAsync( Guid tenantId, Guid actorUserId, string reason, CancellationToken cancellationToken = default) { var preview = await PreviewArchiveAsync(tenantId, cancellationToken); if (!preview.CanArchive) throw new TenantLifecycleException("tenant_archive_blocked", string.Join(',', preview.Blockers)); var tenant = await RequireTenantAsync(tenantId, cancellationToken); var operation = CreateOperation(tenantId, actorUserId, TenantLifecycleOperationType.Archive, reason); operation.Status = TenantLifecycleOperationStatus.Succeeded; operation.StartedAt = operation.CompletedAt = DateTimeOffset.UtcNow; tenant.Status = TenantStatus.Archived; await tenancyPersistence.TenantDomains.Where(item => item.TenantId == tenantId) .ExecuteUpdateAsync(setters => setters.SetProperty(item => item.Status, TenantDomainStatus.Disabled), cancellationToken); jobsOperationsPersistence.TenantLifecycleOperations.Add(operation); AddAudit(tenantId, actorUserId, "tenant.archived", tenantId, reason); await tenancyPersistence.SaveChangesAsync(cancellationToken); await RevokeTenantSessionsAsync(tenantId, cancellationToken); await InvalidateAsync(tenantId, cancellationToken); return ToItem(operation); } public async Task RestoreAsync( Guid tenantId, Guid actorUserId, string reason, CancellationToken cancellationToken = default) { var tenant = await RequireTenantAsync(tenantId, cancellationToken); if (tenant.Status != TenantStatus.Archived) throw new TenantLifecycleException("tenant_not_archived", "Only archived tenants can be restored."); var operation = CreateOperation(tenantId, actorUserId, TenantLifecycleOperationType.Restore, reason); operation.Status = TenantLifecycleOperationStatus.Succeeded; operation.StartedAt = operation.CompletedAt = DateTimeOffset.UtcNow; tenant.Status = TenantStatus.Suspended; await tenancyPersistence.TenantDomains.Where(item => item.TenantId == tenantId) .ExecuteUpdateAsync(setters => setters.SetProperty(item => item.Status, TenantDomainStatus.Pending), cancellationToken); jobsOperationsPersistence.TenantLifecycleOperations.Add(operation); AddAudit(tenantId, actorUserId, "tenant.restored_suspended", tenantId, reason); await tenancyPersistence.SaveChangesAsync(cancellationToken); await InvalidateAsync(tenantId, cancellationToken); return ToItem(operation); } public async Task TransferOwnerAsync( Guid tenantId, Guid actorUserId, Guid targetUserId, string reason, CancellationToken cancellationToken = default) { await using var transaction = await tenancyPersistence.Database.BeginTransactionAsync(cancellationToken); var tenant = await RequireTenantAsync(tenantId, cancellationToken); var target = await identityPersistence.TenantMemberships.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.UserId == targetUserId && item.Status == MembershipStatus.Active, cancellationToken) ?? throw new TenantLifecycleException( "tenant_owner_target_not_active_member", "New owner must be an existing active tenant member."); var previousOwnerId = tenant.OwnerUserId; if (previousOwnerId == targetUserId) throw new TenantLifecycleException("tenant_owner_unchanged", "Target user is already the tenant owner."); var previous = previousOwnerId.HasValue ? await identityPersistence.TenantMemberships.SingleOrDefaultAsync(item => item.TenantId == tenantId && item.UserId == previousOwnerId.Value, cancellationToken) : null; if (previous is not null) previous.Role = TenantRole.TenantAdmin; target.Role = TenantRole.TenantOwner; tenant.OwnerUserId = targetUserId; var ownerRoleId = await jobsOperationsPersistence.TenantBackendRoles .Where(item => item.TenantId == tenantId && item.Code == "tenant_owner") .Select(item => (Guid?)item.Id) .SingleOrDefaultAsync(cancellationToken); if (ownerRoleId.HasValue) { if (previousOwnerId.HasValue) await jobsOperationsPersistence.TenantBackendUserRoles .Where(item => item.TenantId == tenantId && item.UserId == previousOwnerId.Value && item.RoleId == ownerRoleId.Value) .ExecuteDeleteAsync(cancellationToken); if (!await jobsOperationsPersistence.TenantBackendUserRoles.AnyAsync(item => item.TenantId == tenantId && item.UserId == targetUserId && item.RoleId == ownerRoleId.Value, cancellationToken)) jobsOperationsPersistence.TenantBackendUserRoles.Add(new TenantBackendUserRole { TenantId = tenantId, UserId = targetUserId, RoleId = ownerRoleId.Value }); } var operation = CreateOperation(tenantId, actorUserId, TenantLifecycleOperationType.OwnerTransfer, reason); operation.TargetUserId = targetUserId; operation.Status = TenantLifecycleOperationStatus.Succeeded; operation.StartedAt = operation.CompletedAt = DateTimeOffset.UtcNow; operation.Result = JsonSerializer.SerializeToElement(new { previousOwnerId, newOwnerId = targetUserId }); jobsOperationsPersistence.TenantLifecycleOperations.Add(operation); AddAudit(tenantId, actorUserId, "tenant.owner_transferred", targetUserId, reason); await tenancyPersistence.SaveChangesAsync(cancellationToken); await transaction.CommitAsync(cancellationToken); await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Tenant, tenantId, cancellationToken); if (previousOwnerId.HasValue) await authorizationStateInvalidator.InvalidateMembershipAsync(tenantId, previousOwnerId.Value, cancellationToken); await authorizationStateInvalidator.InvalidateMembershipAsync(tenantId, targetUserId, cancellationToken); await InvalidateAsync(tenantId, cancellationToken); return ToItem(operation); } private Task HasRecentExportAsync(Guid tenantId, CancellationToken cancellationToken) { var cutoff = DateTimeOffset.UtcNow - RecentExportWindow; return jobsOperationsPersistence.TenantLifecycleOperations.AnyAsync(item => item.TenantId == tenantId && item.OperationType == TenantLifecycleOperationType.Export && item.Status == TenantLifecycleOperationStatus.Succeeded && item.CompletedAt >= cutoff, cancellationToken); } private async Task RequireTenantAsync(Guid tenantId, CancellationToken cancellationToken) { return await tenancyPersistence.Tenants.SingleOrDefaultAsync(item => item.Id == tenantId, cancellationToken) ?? throw new TenantLifecycleException("tenant_not_found", "Tenant was not found."); } private async Task RevokeTenantSessionsAsync(Guid tenantId, CancellationToken cancellationToken) { var userIds = await identityPersistence.TenantMemberships.AsNoTracking() .Where(item => item.TenantId == tenantId) .Select(item => item.UserId) .Distinct() .ToArrayAsync(cancellationToken); foreach (var userId in userIds) await sessionStore.RevokeRealmAsync(userId, AuthRealm.Tenant, tenantId, "tenant_archived", cancellationToken); } private async Task InvalidateAsync(Guid tenantId, CancellationToken cancellationToken) { await runtimeCacheInvalidator.InvalidateAsync(tenantId, cancellationToken); await publicCacheInvalidator.InvalidateAsync(tenantId, cancellationToken); await featureCacheInvalidator.InvalidateAsync(tenantId, cancellationToken); await authorizationStateInvalidator.InvalidateTenantAsync(tenantId, cancellationToken); } private static TenantLifecycleOperation CreateOperation( Guid tenantId, Guid actorUserId, TenantLifecycleOperationType type, string? reason) { return new TenantLifecycleOperation { TenantId = tenantId, RequestedBy = actorUserId, OperationType = type, Reason = reason?.Trim() }; } private void AddAudit(Guid tenantId, Guid actorUserId, string action, Guid targetId, string reason) { jobsOperationsPersistence.AuditLogs.Add(new AuditLog { TenantId = tenantId, ActorUserId = actorUserId, Action = action, TargetType = "tenant", TargetId = targetId.ToString(), Details = JsonSerializer.SerializeToElement(new { reason }) }); } private static string ToProvider(AssetStorageProvider provider) { return provider switch { AssetStorageProvider.AliyunOss => ObjectStorageProviders.AliyunOss, AssetStorageProvider.LocalDev => ObjectStorageProviders.LocalDev, _ => ObjectStorageProviders.ExternalUrl }; } private static TenantLifecycleOperationItem ToItem(TenantLifecycleOperation operation) { return new TenantLifecycleOperationItem( operation.Id, operation.TenantId, operation.OperationType, operation.Status, operation.RequestedBy, operation.TargetUserId, operation.ExportAssetId, operation.Reason, operation.LastError, operation.CreatedAt, operation.CompletedAt); } }