Files
tiku-backend.net/Tiku.Infrastructure/Assets/Foundation/AssetManagementFoundation.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

427 lines
16 KiB
C#

using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Assets;
using Tiku.Application.Security;
using Tiku.Application.Storage;
using Tiku.Application.Tenancy;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
using Tiku.Domain.Tenancy;
namespace Tiku.Infrastructure.Assets;
internal abstract partial class AssetManagementServiceBase
{
protected async Task<ContentAsset> ResolveUploadAssetAsync(
AssetManagementActor actor,
AssetUploadSignCommand command,
string provider,
string bucket,
string mimeType,
long? fileSizeBytes,
CancellationToken cancellationToken)
{
ContentAsset? asset = null;
if (command.AssetId.HasValue)
{
asset = await contentAssetPersistence.ContentAssets.SingleOrDefaultAsync(
item => item.TenantId == actor.TenantId && item.Id == command.AssetId.Value,
cancellationToken);
if (asset is null) throw new AssetManagementException("Asset was not found.", "asset_not_found");
}
if (asset is null)
{
asset = new ContentAsset
{
Id = command.AssetId ?? Guid.NewGuid(),
TenantId = actor.TenantId,
CreatedBy = actor.UserId,
Source = "manual",
Status = ContentStatus.Active
};
contentAssetPersistence.ContentAssets.Add(asset);
}
asset.RegionId = command.RegionId;
asset.SubjectId = command.SubjectId;
asset.CategoryId = command.CategoryId;
asset.ContentNodeId = command.ContentNodeId;
asset.AssetKey = NormalizeOptional(command.AssetKey);
asset.Title = NormalizeOptional(command.Title) ?? command.FileName.Trim();
asset.Category = NormalizeOptional(command.Category);
asset.Description = NormalizeOptional(command.Description);
asset.IsPublic = command.IsPublic ?? false;
asset.AssetType = ResolveAssetType(command.AssetType, mimeType);
asset.Visibility = ResolveVisibility(command.Visibility, asset.IsPublic);
asset.StorageProvider = ToAssetStorageProvider(provider);
asset.Bucket = bucket;
asset.FileSizeBytes = fileSizeBytes;
asset.Metadata = command.Metadata.ValueKind == JsonValueKind.Undefined
? JsonDefaults.Object()
: command.Metadata;
return asset;
}
protected async Task<ContentAsset> ResolveManagementAssetAsync(
AssetManagementActor actor,
UpsertAssetCommand command,
CancellationToken cancellationToken)
{
ContentAsset? asset = null;
if (command.AssetId.HasValue)
{
asset = await contentAssetPersistence.ContentAssets.SingleOrDefaultAsync(
item => item.TenantId == actor.TenantId && item.Id == command.AssetId.Value,
cancellationToken);
if (asset is null) throw new AssetManagementException("Asset was not found.", "asset_not_found");
}
else if (!string.IsNullOrWhiteSpace(command.LegacyId))
{
var legacyId = command.LegacyId.Trim();
asset = await contentAssetPersistence.ContentAssets.SingleOrDefaultAsync(
item => item.TenantId == actor.TenantId && item.LegacyId == legacyId,
cancellationToken);
}
if (asset is not null) return asset;
asset = new ContentAsset
{
Id = command.AssetId ?? Guid.NewGuid(),
TenantId = actor.TenantId,
CreatedBy = actor.UserId,
UpdatedBy = actor.UserId,
Source = "manual",
Status = ContentStatus.Active
};
contentAssetPersistence.ContentAssets.Add(asset);
return asset;
}
protected async Task<AssetManagementSignedAccessResult> SignAssetAccessAsync(
AssetManagementActor actor,
AssetAccessSignCommand command,
AssetAccessType accessType,
string disposition,
CancellationToken cancellationToken)
{
var asset = await contentAssetPersistence.ContentAssets.SingleOrDefaultAsync(
item => item.TenantId == actor.TenantId && item.Id == command.AssetId &&
item.Status == ContentStatus.Active,
cancellationToken);
if (asset is null) throw new AssetManagementException("Asset was not found.", "asset_not_found");
var provider = ToObjectStorageProvider(asset.StorageProvider);
var objectKey = accessType == AssetAccessType.AdminPreview
? asset.PreviewObjectKey ?? asset.ObjectKey
: asset.ObjectKey;
var cdnUrl = accessType == AssetAccessType.AdminPreview
? asset.PreviewUrl ?? asset.CdnUrl
: asset.CdnUrl;
var expiresIn = TimeSpan.FromSeconds(Math.Clamp(command.ExpiresInSeconds ?? 900, 60, 3600));
var url = await objectStorageService.SignDownloadAsync(
new ObjectStorageDownloadSignRequest(
actor.TenantId,
provider,
asset.Bucket,
objectKey,
expiresIn,
cdnUrl,
asset.FileName,
disposition),
cancellationToken);
contentAssetPersistence.ContentAssetAccessEvents.Add(new ContentAssetAccessEvent
{
TenantId = actor.TenantId,
AssetId = asset.Id,
UserId = actor.UserId,
ActorRole = AssetAccessActorRole.TenantAdmin,
AccessType = accessType,
Visibility = asset.Visibility.ToString(),
AssetType = asset.AssetType.ToString(),
StorageProvider = asset.StorageProvider.ToString(),
Disposition = disposition == "inline" ? AssetAccessDisposition.Inline : AssetAccessDisposition.Attachment,
ExpiresInSeconds = (int)expiresIn.TotalSeconds,
SignatureMode = url.SignatureMode,
Result = AssetAccessResult.Granted,
Metadata = JsonSerializer.SerializeToElement(new
{
url.Provider,
url.Bucket,
url.ObjectKey
})
});
await contentAssetPersistence.SaveChangesAsync(cancellationToken);
return new AssetManagementSignedAccessResult(ToItem(asset), url);
}
protected static ContentAssetManagementItem ToItem(ContentAsset asset)
{
return new ContentAssetManagementItem(
asset.Id,
asset.LegacyId,
asset.RegionId,
asset.SubjectId,
asset.CategoryId,
asset.ContentNodeId,
asset.AssetKey,
asset.Title,
asset.Category,
asset.Description,
asset.FileName,
asset.CdnUrl,
asset.IsPublic,
asset.AssetType,
asset.StorageProvider,
asset.Bucket,
asset.ObjectKey,
asset.MimeType,
asset.FileSizeBytes,
asset.ChecksumSha256,
asset.Visibility,
asset.Status,
asset.SortOrder,
asset.UploadStatus,
asset.VerifiedAt,
asset.VerifiedSizeBytes,
asset.VerifiedChecksumSha256,
asset.PreviewStatus,
asset.SecurityScanStatus,
asset.Metadata,
asset.CreatedAt,
asset.UpdatedAt);
}
protected 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 contentAssetPersistence.SaveChangesAsync(cancellationToken);
}
catch
{
await featureAccessService.ReleaseQuotaAsync(
tenantId,
SaasQuotaMetricCatalog.StorageBytes,
byteDelta,
CancellationToken.None);
throw;
}
return;
}
await contentAssetPersistence.SaveChangesAsync(cancellationToken);
if (byteDelta < 0)
await featureAccessService.ReleaseQuotaAsync(
tenantId,
SaasQuotaMetricCatalog.StorageBytes,
-byteDelta,
CancellationToken.None);
}
protected static long AccountedStorageBytes(ContentAsset asset)
{
return asset.Status == ContentStatus.Active && asset.VerifiedSizeBytes is > 0
? asset.VerifiedSizeBytes.Value
: 0;
}
protected static ContentImportJobItem ToJobItem(ContentImportJob job)
{
return new ContentImportJobItem(
job.Id,
job.TargetRegionId,
job.TargetSubjectId,
job.TargetCategoryId,
job.TargetContentNodeId,
job.TargetQuestionBankId,
job.ImportType,
job.SourceFormat,
job.Status,
job.SourceName,
job.SourceHash,
job.DryRun,
job.TotalCount,
job.ValidCount,
job.ErrorCount,
job.WarningCount,
job.InsertedCount,
job.UpdatedCount,
job.SkippedCount,
job.Summary,
job.ErrorMessage,
job.StartedAt,
job.FinishedAt,
job.CreatedAt,
job.UpdatedAt);
}
protected static string CreateObjectKey(Guid tenantId, Guid assetId, string fileName)
{
var now = DateTimeOffset.UtcNow;
return $"{tenantId:N}/assets/{now:yyyy}/{now:MM}/{assetId:N}/{SanitizeFileName(fileName)}";
}
protected static string SanitizeFileName(string fileName)
{
var trimmed = Path.GetFileName(fileName.Trim());
return string.Join(
"-",
trimmed.Split(Path.GetInvalidFileNameChars(),
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
}
protected static string? NormalizeOptional(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
protected static string? NormalizeChecksum(string? checksum)
{
return string.IsNullOrWhiteSpace(checksum) ? null : checksum.Trim().ToLowerInvariant();
}
protected static TimeSpan ResolveUploadTtl(int? expiresInSeconds)
{
if (!expiresInSeconds.HasValue || expiresInSeconds <= 0) return DefaultUploadTtl;
var requested = TimeSpan.FromSeconds(expiresInSeconds.Value);
return requested <= MaxUploadTtl ? requested : MaxUploadTtl;
}
protected static int ResolveLimit(int? limit)
{
if (!limit.HasValue || limit <= 0) return DefaultLimit;
return Math.Min(limit.Value, MaxLimit);
}
protected static ContentAssetType ResolveAssetType(string? value, string mimeType)
{
if (!string.IsNullOrWhiteSpace(value) &&
Enum.TryParse<ContentAssetType>(value, true, out var parsed))
return parsed;
var normalizedMimeType = mimeType.ToLowerInvariant();
if (normalizedMimeType == "application/pdf") return ContentAssetType.Pdf;
if (normalizedMimeType.StartsWith("image/", StringComparison.Ordinal)) return ContentAssetType.Image;
if (normalizedMimeType.StartsWith("video/", StringComparison.Ordinal)) return ContentAssetType.Video;
if (normalizedMimeType.StartsWith("audio/", StringComparison.Ordinal)) return ContentAssetType.Audio;
return ContentAssetType.Document;
}
protected static ContentVisibility ResolveVisibility(string? value, bool isPublic)
{
if (!string.IsNullOrWhiteSpace(value) &&
Enum.TryParse<ContentVisibility>(value, true, out var parsed))
return parsed;
return isPublic ? ContentVisibility.Public : ContentVisibility.Members;
}
protected static TEnum ParseEnum<TEnum>(string? value, TEnum fallback)
where TEnum : struct
{
if (string.IsNullOrWhiteSpace(value)) return fallback;
return Enum.TryParse<TEnum>(value.Trim(), true, out var parsed)
? parsed
: fallback;
}
protected static AssetPreviewStatus ResolveInitialPreviewStatus(ContentAssetType assetType, string mimeType)
{
return assetType is ContentAssetType.Pdf or ContentAssetType.Image ||
mimeType.Equals("application/pdf", StringComparison.OrdinalIgnoreCase) ||
mimeType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)
? AssetPreviewStatus.Pending
: AssetPreviewStatus.None;
}
protected static AssetStorageProvider ToAssetStorageProvider(string provider)
{
return provider switch
{
ObjectStorageProviders.ExternalUrl => AssetStorageProvider.ExternalUrl,
ObjectStorageProviders.AliyunOss => AssetStorageProvider.AliyunOss,
ObjectStorageProviders.TencentCos => AssetStorageProvider.TencentCos,
ObjectStorageProviders.QiniuKodo => AssetStorageProvider.QiniuKodo,
ObjectStorageProviders.LocalDev => AssetStorageProvider.LocalDev,
_ => throw new AssetManagementException("Storage provider is not supported.",
"storage_provider_not_supported")
};
}
protected static string ToObjectStorageProvider(AssetStorageProvider provider)
{
return provider switch
{
AssetStorageProvider.ExternalUrl => ObjectStorageProviders.ExternalUrl,
AssetStorageProvider.AliyunOss => ObjectStorageProviders.AliyunOss,
AssetStorageProvider.TencentCos => ObjectStorageProviders.TencentCos,
AssetStorageProvider.QiniuKodo => ObjectStorageProviders.QiniuKodo,
AssetStorageProvider.LocalDev => ObjectStorageProviders.LocalDev,
_ => ObjectStorageProviders.ExternalUrl
};
}
protected async Task<(string Provider, string Bucket)> ResolveObjectStorageConfigAsync(
Guid tenantId,
CancellationToken cancellationToken)
{
try
{
var account = await providerConfigService.GetActiveProviderAsync(
tenantId,
TenantExternalProviderCapability.ObjectStorage,
cancellationToken: cancellationToken);
var bucket = GetJsonString(account.ConfigPublic, "bucket", "defaultBucket", "default_bucket");
if (string.IsNullOrWhiteSpace(bucket))
throw new ObjectStorageException(
"Object storage provider bucket is not configured.",
"STORAGE_BUCKET_NOT_CONFIGURED");
return (objectStorageService.NormalizeProvider(account.Provider), bucket.Trim());
}
catch (TenantExternalProviderException)
{
return (
objectStorageService.ConfiguredDefaultProvider(),
objectStorageService.ConfiguredDefaultBucket());
}
}
protected static string? GetJsonString(JsonElement element, params string[] keys)
{
if (element.ValueKind != JsonValueKind.Object) return null;
foreach (var key in keys)
if (element.TryGetProperty(key, out var value) && value.ValueKind == JsonValueKind.String)
return value.GetString();
return null;
}
}