Files
tiku-backend.net/Tiku.Infrastructure/Assets/Foundation/AssetManagementService.Foundation.cs
xiong c497a3ca8d
Some checks failed
ci / release-gate (push) Has been cancelled
清理代码
2026-08-03 12:31:39 +08:00

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;
}
}