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

972 lines
37 KiB
C#

using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Assets;
using Tiku.Application.Catalog;
using Tiku.Application.Content;
using Tiku.Application.Security;
using Tiku.Application.Jobs;
using Tiku.Application.Storage;
using Tiku.Application.Tenancy;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Assets;
public sealed class AssetManagementService(
TikuDbContext dbContext,
IObjectStorageService objectStorageService,
ITenantExternalProviderConfigService providerConfigService,
IFeatureAccessService featureAccessService,
IBackgroundJobService backgroundJobService) : 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);
var accountedBytesBefore = AccountedStorageBytes(asset);
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;
var accountedBytesAfter = AccountedStorageBytes(asset);
await SaveWithStorageQuotaAdjustmentAsync(
actor.TenantId,
accountedBytesAfter - accountedBytesBefore,
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 storageConfig = await ResolveObjectStorageConfigAsync(actor.TenantId, cancellationToken);
var provider = storageConfig.Provider;
var bucket = storageConfig.Bucket;
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.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 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;
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");
}
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 = verifiedSizeBytes;
asset.VerifiedChecksumSha256 = NormalizeChecksum(metadata.ChecksumSha256) ?? declaredChecksum;
asset.MimeType = metadata.MimeType ?? declaredMimeType;
asset.FileSizeBytes = verifiedSizeBytes;
asset.SecurityScanStatus = AssetSecurityScanStatus.Pending;
asset.UpdatedBy = actor.UserId;
var accountedBytesAfter = AccountedStorageBytes(asset);
await SaveWithStorageQuotaAdjustmentAsync(
actor.TenantId,
accountedBytesAfter - accountedBytesBefore,
cancellationToken);
await backgroundJobService.EnqueueAsync(
new CreateBackgroundJobCommand(
actor.TenantId,
"asset_security_scan",
JsonSerializer.SerializeToElement(new { assetId = asset.Id }),
MaxRetries: 5,
IdempotencyKey: $"asset:{asset.Id:N}:{asset.VerifiedChecksumSha256 ?? asset.VerifiedAt?.UtcTicks.ToString()}",
IsSystemJob: true),
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,
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 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, 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.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;
}
}