Files
tiku-backend.net/Tiku.Infrastructure/Assets/Uploads/AssetUploadManagementService.cs
xiong 33375a38d7
Some checks failed
ci / release-gate (push) Has been cancelled
refactor(architecture): harden module boundaries
2026-08-04 12:10:36 +08:00

171 lines
7.2 KiB
C#

using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Assets;
using Tiku.Application.Jobs;
using Tiku.Application.Storage;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
namespace Tiku.Infrastructure.Assets;
internal sealed class AssetUploadManagementService(AssetManagementDependencies dependencies)
: AssetManagementServiceBase(dependencies), IAssetUploadManagementService
{
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,
true),
cancellationToken);
await contentAssetPersistence.SaveChangesAsync(cancellationToken);
return new AssetUploadSignResult(ToItem(asset), upload);
}
public async Task<AssetUploadConfirmResult> ConfirmUploadAsync(
AssetManagementActor actor,
AssetUploadConfirmCommand command,
CancellationToken cancellationToken = default)
{
var asset = await contentAssetPersistence.ContentAssets
.SingleOrDefaultAsync(
item =>
item.TenantId == actor.TenantId &&
item.Id == command.AssetId &&
item.Status == ContentStatus.Active,
cancellationToken);
if (asset is null) throw new AssetManagementException("Asset was not found.", "asset_not_found");
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 contentAssetPersistence.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 contentAssetPersistence.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);
}
}