feat: migrate direct tenant content and learning endpoints

This commit is contained in:
xiong
2026-07-26 17:57:03 +08:00
parent 1d7e09b1f0
commit 91a4162908
18 changed files with 4079 additions and 0 deletions

View File

@@ -2,6 +2,7 @@ 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;
@@ -91,6 +92,46 @@ public sealed class AssetManagementService(
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,
@@ -227,6 +268,92 @@ public sealed class AssetManagementService(
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,
@@ -372,6 +499,107 @@ public sealed class AssetManagementService(
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(
@@ -527,6 +755,19 @@ public sealed class AssetManagementService(
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 ||