forked from xiongyuxing/tiku-backend.net
808 lines
31 KiB
C#
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
|
|
};
|
|
}
|
|
}
|