Files
tiku-backend.net/Tiku.Infrastructure/Tenancy/TenantLifecycleService.cs
xiong 33375a38d7
Some checks failed
ci / release-gate (push) Has been cancelled
refactor(architecture): harden module boundaries
2026-08-04 12:10:36 +08:00

317 lines
15 KiB
C#

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<TenantArchivePreview> 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<string>();
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<TenantLifecycleOperationItem> 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<TenantLifecycleOperationItem?> 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<ObjectStorageSignedUrl> 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<TenantLifecycleOperationItem> 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<TenantLifecycleOperationItem> 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<TenantLifecycleOperationItem> 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<bool> 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<Tenant> 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);
}
}