Files
tiku-backend.net/Tiku.Infrastructure/Assets/AssetManagementService.cs

808 lines
31 KiB
C#

using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Assets;
using Tiku.Application.Catalog;
using Tiku.Application.Content;
using Tiku.Application.Storage;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Assets;
public sealed class AssetManagementService(
TikuDbContext dbContext,
IObjectStorageService objectStorageService) : IAssetManagementService
{
private const int DefaultLimit = 100;
private const int MaxLimit = 500;
private static readonly TimeSpan DefaultUploadTtl = TimeSpan.FromMinutes(15);
private static readonly TimeSpan MaxUploadTtl = TimeSpan.FromHours(1);
public async Task<CatalogList<ContentAssetManagementItem>> GetAssetsAsync(
AssetManagementActor actor,
AssetManagementFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.ContentAssets
.AsNoTracking()
.Where(asset => asset.TenantId == actor.TenantId);
if (filter.RegionId.HasValue)
{
query = query.Where(asset => asset.RegionId == filter.RegionId.Value);
}
if (filter.SubjectId.HasValue)
{
query = query.Where(asset => asset.SubjectId == filter.SubjectId.Value);
}
if (filter.CategoryId.HasValue)
{
query = query.Where(asset => asset.CategoryId == filter.CategoryId.Value);
}
if (filter.ContentNodeId.HasValue)
{
query = query.Where(asset => asset.ContentNodeId == filter.ContentNodeId.Value);
}
if (!string.IsNullOrWhiteSpace(filter.AssetType) &&
Enum.TryParse<ContentAssetType>(filter.AssetType, ignoreCase: true, out var assetType))
{
query = query.Where(asset => asset.AssetType == assetType);
}
if (!string.IsNullOrWhiteSpace(filter.UploadStatus) &&
Enum.TryParse<AssetUploadStatus>(filter.UploadStatus, ignoreCase: true, out var uploadStatus))
{
query = query.Where(asset => asset.UploadStatus == uploadStatus);
}
if (!string.IsNullOrWhiteSpace(filter.SecurityScanStatus) &&
Enum.TryParse<AssetSecurityScanStatus>(filter.SecurityScanStatus, ignoreCase: true, out var securityScanStatus))
{
query = query.Where(asset => asset.SecurityScanStatus == securityScanStatus);
}
if (!string.IsNullOrWhiteSpace(filter.Category))
{
var category = filter.Category.Trim();
query = query.Where(asset => asset.Category == category);
}
if (!string.IsNullOrWhiteSpace(filter.Keyword))
{
var keyword = filter.Keyword.Trim();
query = query.Where(asset =>
(asset.Title != null && asset.Title.Contains(keyword)) ||
(asset.FileName != null && asset.FileName.Contains(keyword)) ||
(asset.Description != null && asset.Description.Contains(keyword)) ||
(asset.AssetKey != null && asset.AssetKey.Contains(keyword)));
}
var items = await query
.OrderBy(asset => asset.SortOrder)
.ThenByDescending(asset => asset.CreatedAt)
.Take(ResolveLimit(filter.Limit))
.Select(asset => ToItem(asset))
.ToArrayAsync(cancellationToken);
return new CatalogList<ContentAssetManagementItem>(items);
}
public async Task<ContentManagementResult<ContentAssetManagementItem>> UpsertAssetAsync(
AssetManagementActor actor,
UpsertAssetCommand command,
CancellationToken cancellationToken = default)
{
var asset = await ResolveManagementAssetAsync(actor, command, cancellationToken);
asset.RegionId = command.RegionId;
asset.SubjectId = command.SubjectId;
asset.CategoryId = command.CategoryId;
asset.ContentNodeId = command.ContentNodeId;
asset.LegacyId = NormalizeOptional(command.LegacyId);
asset.AssetKey = NormalizeOptional(command.AssetKey);
asset.Title = NormalizeOptional(command.Title) ?? NormalizeOptional(command.FileName) ?? asset.Title ?? "未命名资源";
asset.Category = NormalizeOptional(command.Category);
asset.Description = NormalizeOptional(command.Description);
asset.FileName = NormalizeOptional(command.FileName);
asset.CdnUrl = NormalizeOptional(command.CdnUrl);
asset.IsPublic = command.IsPublic ?? asset.IsPublic;
asset.AssetType = ParseEnum(command.AssetType, asset.AssetType);
asset.Visibility = ResolveVisibility(command.Visibility, asset.IsPublic);
asset.Status = ParseEnum(command.Status, asset.Status);
asset.StorageProvider = ToAssetStorageProvider(objectStorageService.NormalizeProvider(command.Provider, ToObjectStorageProvider(asset.StorageProvider)));
asset.Bucket = string.IsNullOrWhiteSpace(command.Bucket) ? asset.Bucket : command.Bucket.Trim();
asset.ObjectKey = string.IsNullOrWhiteSpace(command.ObjectKey)
? asset.ObjectKey
: objectStorageService.ValidateObjectKey(actor.TenantId, command.ObjectKey.Trim());
asset.MimeType = string.IsNullOrWhiteSpace(command.MimeType) ? asset.MimeType : objectStorageService.ValidateMimeType(command.MimeType.Trim());
asset.FileSizeBytes = objectStorageService.ValidateFileSize(command.FileSizeBytes ?? asset.FileSizeBytes);
asset.ChecksumSha256 = NormalizeChecksum(command.ChecksumSha256) ?? asset.ChecksumSha256;
asset.PreviewUrl = NormalizeOptional(command.PreviewUrl);
asset.PreviewObjectKey = NormalizeOptional(command.PreviewObjectKey) ?? asset.PreviewObjectKey;
asset.SortOrder = command.Order ?? asset.SortOrder;
asset.AccessRules = command.AccessRules.ValueKind == JsonValueKind.Undefined ? asset.AccessRules : command.AccessRules;
asset.Metadata = command.Metadata.ValueKind == JsonValueKind.Undefined ? asset.Metadata : command.Metadata;
asset.UpdatedBy = actor.UserId;
await dbContext.SaveChangesAsync(cancellationToken);
return new ContentManagementResult<ContentAssetManagementItem>(ToItem(asset));
}
public async Task<AssetUploadSignResult> SignUploadAsync(
AssetManagementActor actor,
AssetUploadSignCommand command,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(command.FileName);
ArgumentException.ThrowIfNullOrWhiteSpace(command.MimeType);
var provider = objectStorageService.NormalizeProvider(command.Provider, objectStorageService.ConfiguredDefaultProvider());
var bucket = string.IsNullOrWhiteSpace(command.Bucket)
? objectStorageService.ConfiguredDefaultBucket()
: command.Bucket.Trim();
var mimeType = objectStorageService.ValidateMimeType(command.MimeType.Trim());
var fileSizeBytes = objectStorageService.ValidateFileSize(command.FileSizeBytes);
var asset = await ResolveUploadAssetAsync(actor, command, provider, bucket, mimeType, fileSizeBytes, cancellationToken);
var objectKey = objectStorageService.ValidateObjectKey(
actor.TenantId,
string.IsNullOrWhiteSpace(command.ObjectKey)
? CreateObjectKey(actor.TenantId, asset.Id, command.FileName)
: command.ObjectKey.Trim());
objectStorageService.AssertUploadProvider(provider);
objectStorageService.AssertWritableLocation(new StorageAssetLocation(provider, bucket, objectKey));
asset.StorageProvider = ToAssetStorageProvider(provider);
asset.Bucket = bucket;
asset.ObjectKey = objectKey;
asset.FileName = command.FileName.Trim();
asset.MimeType = mimeType;
asset.FileSizeBytes = fileSizeBytes;
asset.ChecksumSha256 = NormalizeChecksum(command.ChecksumSha256);
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);
asset.UpdatedBy = actor.UserId;
var expiresIn = ResolveUploadTtl(command.ExpiresInSeconds);
var upload = await objectStorageService.SignUploadAsync(
new ObjectStorageUploadSignRequest(
actor.TenantId,
provider,
bucket,
objectKey,
asset.FileName,
mimeType,
fileSizeBytes,
expiresIn,
Upsert: true),
cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
return new AssetUploadSignResult(ToItem(asset), upload);
}
public async Task<AssetUploadConfirmResult> ConfirmUploadAsync(
AssetManagementActor actor,
AssetUploadConfirmCommand command,
CancellationToken cancellationToken = default)
{
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");
}
if (string.IsNullOrWhiteSpace(asset.ObjectKey) || string.IsNullOrWhiteSpace(asset.Bucket))
{
throw new AssetManagementException("Asset does not have a writable object location.", "asset_location_missing");
}
var provider = ToObjectStorageProvider(asset.StorageProvider);
var declaredMimeType = string.IsNullOrWhiteSpace(command.MimeType) ? asset.MimeType : command.MimeType.Trim();
var declaredSize = command.FileSizeBytes ?? asset.FileSizeBytes;
var declaredChecksum = NormalizeChecksum(command.ChecksumSha256) ?? asset.ChecksumSha256;
var metadata = await objectStorageService.HeadObjectAsync(
new ObjectStorageHeadRequest(
actor.TenantId,
provider,
asset.Bucket,
asset.ObjectKey,
declaredMimeType,
declaredSize,
declaredChecksum),
cancellationToken);
asset.VerificationDetails = JsonSerializer.SerializeToElement(new
{
metadata.Exists,
metadata.SizeBytes,
metadata.MimeType,
metadata.ChecksumSha256,
metadata.ETag,
metadata.LastModified,
metadata.VerificationSource,
declared = new
{
MimeType = declaredMimeType,
FileSizeBytes = declaredSize,
ChecksumSha256 = declaredChecksum
}
});
if (!metadata.Exists)
{
asset.UploadStatus = AssetUploadStatus.Failed;
asset.UpdatedBy = actor.UserId;
await dbContext.SaveChangesAsync(cancellationToken);
throw new AssetManagementException("Uploaded object was not found in object storage.", "asset_upload_missing");
}
asset.UploadStatus = AssetUploadStatus.Verified;
asset.VerifiedAt = DateTimeOffset.UtcNow;
asset.VerifiedBy = actor.UserId;
asset.VerifiedSizeBytes = metadata.SizeBytes ?? declaredSize;
asset.VerifiedChecksumSha256 = NormalizeChecksum(metadata.ChecksumSha256) ?? declaredChecksum;
asset.MimeType = metadata.MimeType ?? declaredMimeType;
asset.FileSizeBytes = metadata.SizeBytes ?? asset.FileSizeBytes;
asset.SecurityScanStatus = AssetSecurityScanStatus.Pending;
asset.UpdatedBy = actor.UserId;
await dbContext.SaveChangesAsync(cancellationToken);
return new AssetUploadConfirmResult(ToItem(asset), metadata);
}
public Task<AssetManagementSignedAccessResult> SignDownloadAsync(
AssetManagementActor actor,
AssetAccessSignCommand command,
CancellationToken cancellationToken = default)
{
return SignAssetAccessAsync(actor, command, AssetAccessType.AdminDownload, "attachment", cancellationToken);
}
public Task<AssetManagementSignedAccessResult> SignPreviewAsync(
AssetManagementActor actor,
AssetAccessSignCommand command,
CancellationToken cancellationToken = default)
{
return SignAssetAccessAsync(actor, command, AssetAccessType.AdminPreview, "inline", cancellationToken);
}
public async Task<CatalogList<ContentAssetAccessEventItem>> GetAccessEventsAsync(
AssetManagementActor actor,
AssetEventFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.ContentAssetAccessEvents.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId);
if (filter.AssetId.HasValue)
{
query = query.Where(item => item.AssetId == filter.AssetId.Value);
}
if (filter.UserId.HasValue)
{
query = query.Where(item => item.UserId == filter.UserId.Value);
}
var items = await query
.OrderByDescending(item => item.CreatedAt)
.Take(ResolveLimit(filter.Limit))
.Select(item => new ContentAssetAccessEventItem(
item.Id,
item.AssetId,
item.UserId,
item.ActorRole,
item.AccessType,
item.Visibility,
item.AssetType,
item.StorageProvider,
item.Disposition,
item.ExpiresInSeconds,
item.SignatureMode,
item.Result,
item.DenyCode,
item.IpAddress,
item.UserAgent,
item.Metadata,
item.CreatedAt))
.ToArrayAsync(cancellationToken);
return new CatalogList<ContentAssetAccessEventItem>(items);
}
public async Task<CatalogList<ContentAssetSecurityScanEventItem>> GetSecurityScanEventsAsync(
AssetManagementActor actor,
AssetEventFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.ContentAssetSecurityScanEvents.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId);
if (filter.AssetId.HasValue)
{
query = query.Where(item => item.AssetId == filter.AssetId.Value);
}
var items = await query
.OrderByDescending(item => item.CreatedAt)
.Take(ResolveLimit(filter.Limit))
.Select(item => new ContentAssetSecurityScanEventItem(
item.Id,
item.AssetId,
item.Provider,
item.ScanStatus,
item.RiskLevel,
item.IssueCodes,
item.Details,
item.CreatedAt))
.ToArrayAsync(cancellationToken);
return new CatalogList<ContentAssetSecurityScanEventItem>(items);
}
public async Task<CatalogList<ContentImportJobItem>> GetImportJobsAsync(
AssetManagementActor actor,
ImportJobFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.ContentImportJobs
.AsNoTracking()
.Where(job => job.TenantId == actor.TenantId);
if (!string.IsNullOrWhiteSpace(filter.Status) &&
Enum.TryParse<ContentImportStatus>(filter.Status, ignoreCase: true, out var status))
{
query = query.Where(job => job.Status == status);
}
if (!string.IsNullOrWhiteSpace(filter.ImportType) &&
Enum.TryParse<ContentImportType>(filter.ImportType, ignoreCase: true, out var importType))
{
query = query.Where(job => job.ImportType == importType);
}
if (!string.IsNullOrWhiteSpace(filter.SourceFormat) &&
Enum.TryParse<ImportSourceFormat>(filter.SourceFormat, ignoreCase: true, out var sourceFormat))
{
query = query.Where(job => job.SourceFormat == sourceFormat);
}
var items = await query
.OrderByDescending(job => job.CreatedAt)
.Take(ResolveLimit(filter.Limit))
.Select(job => ToJobItem(job))
.ToArrayAsync(cancellationToken);
return new CatalogList<ContentImportJobItem>(items);
}
public async Task<ContentImportJobDetail> GetImportJobAsync(
AssetManagementActor actor,
Guid jobId,
CancellationToken cancellationToken = default)
{
var job = await dbContext.ContentImportJobs
.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && item.Id == jobId)
.Select(item => ToJobItem(item))
.SingleOrDefaultAsync(cancellationToken);
if (job is null)
{
throw new AssetManagementException("Import job was not found.", "import_job_not_found");
}
var items = await dbContext.ContentImportItems
.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && item.JobId == jobId)
.OrderBy(item => item.RowNo)
.Take(MaxLimit)
.Select(item => new ContentImportItemModel(
item.Id,
item.JobId,
item.RowNo,
item.ExternalId,
item.Status,
item.TargetType,
item.TargetId,
item.SourcePayload,
item.NormalizedPayload,
item.ContentHash,
item.IssuesCount))
.ToArrayAsync(cancellationToken);
var issues = await dbContext.ContentImportIssues
.AsNoTracking()
.Where(issue => issue.TenantId == actor.TenantId && issue.JobId == jobId)
.OrderBy(issue => issue.RowNo)
.ThenBy(issue => issue.CreatedAt)
.Take(MaxLimit)
.Select(issue => new ContentImportIssueModel(
issue.Id,
issue.JobId,
issue.ItemId,
issue.RowNo,
issue.Severity,
issue.Code,
issue.FieldPath,
issue.Message,
issue.Details))
.ToArrayAsync(cancellationToken);
return new ContentImportJobDetail(job, items, issues);
}
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 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, ignoreCase: 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, ignoreCase: 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(), ignoreCase: 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.SupabaseStorage => AssetStorageProvider.SupabaseStorage,
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.SupabaseStorage => ObjectStorageProviders.SupabaseStorage,
AssetStorageProvider.AliyunOss => ObjectStorageProviders.AliyunOss,
AssetStorageProvider.TencentCos => ObjectStorageProviders.TencentCos,
AssetStorageProvider.QiniuKodo => ObjectStorageProviders.QiniuKodo,
AssetStorageProvider.LocalDev => ObjectStorageProviders.LocalDev,
_ => ObjectStorageProviders.ExternalUrl
};
}
}