feat(saas): implement marketplace and tenant onboarding

This commit is contained in:
2026-07-29 13:58:59 +08:00
parent 76606029e2
commit 6db200a2fc
145 changed files with 16862 additions and 94529 deletions

View File

@@ -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(