426 lines
15 KiB
C#
426 lines
15 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;
|
|
|
|
public sealed partial class AssetManagementService
|
|
{
|
|
private 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 dbContext.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
|
|
};
|
|
dbContext.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;
|
|
}
|
|
|
|
private async Task<ContentAsset> ResolveManagementAssetAsync(
|
|
AssetManagementActor actor,
|
|
UpsertAssetCommand command,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ContentAsset? asset = null;
|
|
if (command.AssetId.HasValue)
|
|
{
|
|
asset = await dbContext.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 dbContext.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
|
|
};
|
|
dbContext.ContentAssets.Add(asset);
|
|
return asset;
|
|
}
|
|
|
|
private async Task<AssetManagementSignedAccessResult> SignAssetAccessAsync(
|
|
AssetManagementActor actor,
|
|
AssetAccessSignCommand command,
|
|
AssetAccessType accessType,
|
|
string disposition,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var asset = await dbContext.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);
|
|
dbContext.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 dbContext.SaveChangesAsync(cancellationToken);
|
|
return new AssetManagementSignedAccessResult(ToItem(asset), url);
|
|
}
|
|
|
|
private 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);
|
|
}
|
|
|
|
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(
|
|
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);
|
|
}
|
|
|
|
private 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)}";
|
|
}
|
|
|
|
private static string SanitizeFileName(string fileName)
|
|
{
|
|
var trimmed = Path.GetFileName(fileName.Trim());
|
|
return string.Join(
|
|
"-",
|
|
trimmed.Split(Path.GetInvalidFileNameChars(),
|
|
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
|
|
}
|
|
|
|
private static string? NormalizeOptional(string? value)
|
|
{
|
|
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
|
}
|
|
|
|
private static string? NormalizeChecksum(string? checksum)
|
|
{
|
|
return string.IsNullOrWhiteSpace(checksum) ? null : checksum.Trim().ToLowerInvariant();
|
|
}
|
|
|
|
private static TimeSpan ResolveUploadTtl(int? expiresInSeconds)
|
|
{
|
|
if (!expiresInSeconds.HasValue || expiresInSeconds <= 0) return DefaultUploadTtl;
|
|
|
|
var requested = TimeSpan.FromSeconds(expiresInSeconds.Value);
|
|
return requested <= MaxUploadTtl ? requested : MaxUploadTtl;
|
|
}
|
|
|
|
private static int ResolveLimit(int? limit)
|
|
{
|
|
if (!limit.HasValue || limit <= 0) return DefaultLimit;
|
|
|
|
return Math.Min(limit.Value, MaxLimit);
|
|
}
|
|
|
|
private 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;
|
|
}
|
|
|
|
private 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;
|
|
}
|
|
|
|
private 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;
|
|
}
|
|
|
|
private 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;
|
|
}
|
|
|
|
private 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")
|
|
};
|
|
}
|
|
|
|
private 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
|
|
};
|
|
}
|
|
|
|
private 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());
|
|
}
|
|
}
|
|
|
|
private 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;
|
|
}
|
|
} |