forked from xiongyuxing/tiku-backend.net
feat(saas): implement marketplace and tenant onboarding
This commit is contained in:
@@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Common;
|
||||
@@ -15,7 +16,8 @@ namespace Tiku.Infrastructure.Assets;
|
||||
public sealed class AssetManagementService(
|
||||
TikuDbContext dbContext,
|
||||
IObjectStorageService objectStorageService,
|
||||
ITenantExternalProviderConfigService providerConfigService) : IAssetManagementService
|
||||
ITenantExternalProviderConfigService providerConfigService,
|
||||
IFeatureAccessService featureAccessService) : IAssetManagementService
|
||||
{
|
||||
private const int DefaultLimit = 100;
|
||||
private const int MaxLimit = 500;
|
||||
@@ -101,6 +103,7 @@ public sealed class AssetManagementService(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var asset = await ResolveManagementAssetAsync(actor, command, cancellationToken);
|
||||
var accountedBytesBefore = AccountedStorageBytes(asset);
|
||||
asset.RegionId = command.RegionId;
|
||||
asset.SubjectId = command.SubjectId;
|
||||
asset.CategoryId = command.CategoryId;
|
||||
@@ -131,7 +134,11 @@ public sealed class AssetManagementService(
|
||||
asset.Metadata = command.Metadata.ValueKind == JsonValueKind.Undefined ? asset.Metadata : command.Metadata;
|
||||
asset.UpdatedBy = actor.UserId;
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
var accountedBytesAfter = AccountedStorageBytes(asset);
|
||||
await SaveWithStorageQuotaAdjustmentAsync(
|
||||
actor.TenantId,
|
||||
accountedBytesAfter - accountedBytesBefore,
|
||||
cancellationToken);
|
||||
return new ContentManagementResult<ContentAssetManagementItem>(ToItem(asset));
|
||||
}
|
||||
|
||||
@@ -168,8 +175,6 @@ public sealed class AssetManagementService(
|
||||
asset.UploadStatus = AssetUploadStatus.Pending;
|
||||
asset.VerifiedAt = null;
|
||||
asset.VerifiedBy = null;
|
||||
asset.VerifiedSizeBytes = null;
|
||||
asset.VerifiedChecksumSha256 = null;
|
||||
asset.VerificationDetails = JsonDefaults.Object();
|
||||
asset.SecurityScanStatus = AssetSecurityScanStatus.Pending;
|
||||
asset.PreviewStatus = ResolveInitialPreviewStatus(asset.AssetType, mimeType);
|
||||
@@ -216,6 +221,7 @@ public sealed class AssetManagementService(
|
||||
throw new AssetManagementException("Asset does not have a writable object location.", "asset_location_missing");
|
||||
}
|
||||
|
||||
var accountedBytesBefore = AccountedStorageBytes(asset);
|
||||
var provider = ToObjectStorageProvider(asset.StorageProvider);
|
||||
var declaredMimeType = string.IsNullOrWhiteSpace(command.MimeType) ? asset.MimeType : command.MimeType.Trim();
|
||||
var declaredSize = command.FileSizeBytes ?? asset.FileSizeBytes;
|
||||
@@ -255,21 +261,68 @@ public sealed class AssetManagementService(
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
throw new AssetManagementException("Uploaded object was not found in object storage.", "asset_upload_missing");
|
||||
}
|
||||
if (metadata.SizeBytes is not { } verifiedSizeBytes)
|
||||
{
|
||||
asset.UploadStatus = AssetUploadStatus.Failed;
|
||||
asset.UpdatedBy = actor.UserId;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
throw new AssetManagementException(
|
||||
"Object storage did not return a verified asset size.",
|
||||
"asset_upload_size_unverified");
|
||||
}
|
||||
|
||||
asset.UploadStatus = AssetUploadStatus.Verified;
|
||||
asset.VerifiedAt = DateTimeOffset.UtcNow;
|
||||
asset.VerifiedBy = actor.UserId;
|
||||
asset.VerifiedSizeBytes = metadata.SizeBytes ?? declaredSize;
|
||||
asset.VerifiedSizeBytes = verifiedSizeBytes;
|
||||
asset.VerifiedChecksumSha256 = NormalizeChecksum(metadata.ChecksumSha256) ?? declaredChecksum;
|
||||
asset.MimeType = metadata.MimeType ?? declaredMimeType;
|
||||
asset.FileSizeBytes = metadata.SizeBytes ?? asset.FileSizeBytes;
|
||||
asset.FileSizeBytes = verifiedSizeBytes;
|
||||
asset.SecurityScanStatus = AssetSecurityScanStatus.Pending;
|
||||
asset.UpdatedBy = actor.UserId;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
var accountedBytesAfter = AccountedStorageBytes(asset);
|
||||
await SaveWithStorageQuotaAdjustmentAsync(
|
||||
actor.TenantId,
|
||||
accountedBytesAfter - accountedBytesBefore,
|
||||
cancellationToken);
|
||||
|
||||
return new AssetUploadConfirmResult(ToItem(asset), metadata);
|
||||
}
|
||||
|
||||
public async Task<ContentManagementResult<ContentAssetManagementItem>> ArchiveAssetAsync(
|
||||
AssetManagementActor actor,
|
||||
Guid assetId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var asset = await dbContext.ContentAssets.SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == assetId,
|
||||
cancellationToken);
|
||||
if (asset is null)
|
||||
{
|
||||
throw new AssetManagementException("Asset was not found.", "asset_not_found");
|
||||
}
|
||||
|
||||
if (asset.Status == ContentStatus.Archived)
|
||||
{
|
||||
return new ContentManagementResult<ContentAssetManagementItem>(ToItem(asset));
|
||||
}
|
||||
|
||||
var accountedBytes = AccountedStorageBytes(asset);
|
||||
asset.Status = ContentStatus.Archived;
|
||||
asset.UpdatedBy = actor.UserId;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
if (accountedBytes > 0)
|
||||
{
|
||||
await featureAccessService.ReleaseQuotaAsync(
|
||||
actor.TenantId,
|
||||
SaasQuotaMetricCatalog.StorageBytes,
|
||||
accountedBytes,
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
return new ContentManagementResult<ContentAssetManagementItem>(ToItem(asset));
|
||||
}
|
||||
|
||||
public Task<AssetManagementSignedAccessResult> SignDownloadAsync(
|
||||
AssetManagementActor actor,
|
||||
AssetAccessSignCommand command,
|
||||
@@ -639,6 +692,59 @@ public sealed class AssetManagementService(
|
||||
asset.UpdatedAt);
|
||||
}
|
||||
|
||||
private async Task SaveWithStorageQuotaAdjustmentAsync(
|
||||
Guid tenantId,
|
||||
long byteDelta,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (byteDelta > 0)
|
||||
{
|
||||
var reserved = await featureAccessService.TryConsumeQuotaAsync(
|
||||
tenantId,
|
||||
SaasQuotaMetricCatalog.StorageBytes,
|
||||
byteDelta,
|
||||
cancellationToken);
|
||||
if (!reserved)
|
||||
{
|
||||
throw new FeatureAccessException(
|
||||
"Tenant storage quota is exhausted.",
|
||||
"feature_quota_exhausted");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await featureAccessService.ReleaseQuotaAsync(
|
||||
tenantId,
|
||||
SaasQuotaMetricCatalog.StorageBytes,
|
||||
byteDelta,
|
||||
CancellationToken.None);
|
||||
throw;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
if (byteDelta < 0)
|
||||
{
|
||||
await featureAccessService.ReleaseQuotaAsync(
|
||||
tenantId,
|
||||
SaasQuotaMetricCatalog.StorageBytes,
|
||||
-byteDelta,
|
||||
CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
private static long AccountedStorageBytes(ContentAsset asset)
|
||||
{
|
||||
return asset.Status == ContentStatus.Active && asset.VerifiedSizeBytes is > 0
|
||||
? asset.VerifiedSizeBytes.Value
|
||||
: 0;
|
||||
}
|
||||
|
||||
private static ContentImportJobItem ToJobItem(ContentImportJob job)
|
||||
{
|
||||
return new ContentImportJobItem(
|
||||
|
||||
Reference in New Issue
Block a user