From 3c0acebbbe8e61e3076a913c2cb3a002a98265cd Mon Sep 17 00:00:00 2001 From: xiong Date: Sun, 26 Jul 2026 15:34:59 +0800 Subject: [PATCH] feat: add tenant content asset management endpoints --- Tiku.Api/Contracts/AssetManagementDtos.cs | 177 ++++++ .../Controllers/TenantContentController.cs | 98 +++ .../Middleware/ExceptionHandlingMiddleware.cs | 21 + .../Assets/AssetManagementModels.cs | 158 +++++ .../Assets/IAssetManagementService.cs | 31 + .../Assets/AssetManagementService.cs | 566 ++++++++++++++++++ Tiku.Infrastructure/DependencyInjection.cs | 1 + .../Api/AssetManagementEndpointTests.cs | 350 +++++++++++ 8 files changed, 1402 insertions(+) create mode 100644 Tiku.Api/Contracts/AssetManagementDtos.cs create mode 100644 Tiku.Api/Controllers/TenantContentController.cs create mode 100644 Tiku.Application/Assets/AssetManagementModels.cs create mode 100644 Tiku.Application/Assets/IAssetManagementService.cs create mode 100644 Tiku.Infrastructure/Assets/AssetManagementService.cs create mode 100644 Tiku.IntegrationTests/Api/AssetManagementEndpointTests.cs diff --git a/Tiku.Api/Contracts/AssetManagementDtos.cs b/Tiku.Api/Contracts/AssetManagementDtos.cs new file mode 100644 index 0000000..19b569d --- /dev/null +++ b/Tiku.Api/Contracts/AssetManagementDtos.cs @@ -0,0 +1,177 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using Tiku.Application.Assets; +using Tiku.Domain.Common; + +namespace Tiku.Api.Contracts; + +public sealed class AssetUploadSignDto +{ + public Guid? AssetId { get; set; } + + public Guid? RegionId { get; set; } + + public Guid? SubjectId { get; set; } + + public Guid? CategoryId { get; set; } + + public Guid? ContentNodeId { get; set; } + + [StringLength(200)] + public string? AssetKey { get; set; } + + [StringLength(300)] + public string? Title { get; set; } + + [StringLength(100)] + public string? Category { get; set; } + + [StringLength(1000)] + public string? Description { get; set; } + + [Required] + [StringLength(500)] + public string FileName { get; set; } = string.Empty; + + [Required] + [StringLength(200)] + public string MimeType { get; set; } = string.Empty; + + [Range(0, long.MaxValue)] + public long? FileSizeBytes { get; set; } + + [RegularExpression("^[A-Fa-f0-9]{64}$")] + public string? ChecksumSha256 { get; set; } + + [StringLength(50)] + public string? AssetType { get; set; } + + [StringLength(50)] + public string? Visibility { get; set; } + + public bool? IsPublic { get; set; } + + [StringLength(50)] + public string? Provider { get; set; } + + [StringLength(200)] + public string? Bucket { get; set; } + + [StringLength(1000)] + public string? ObjectKey { get; set; } + + [Range(1, 3600)] + public int? ExpiresInSeconds { get; set; } + + public JsonElement Metadata { get; set; } = JsonDefaults.Object(); + + public AssetUploadSignCommand ToCommand() + { + return new AssetUploadSignCommand( + AssetId, + RegionId, + SubjectId, + CategoryId, + ContentNodeId, + AssetKey, + Title, + Category, + Description, + FileName, + MimeType, + FileSizeBytes, + ChecksumSha256, + AssetType, + Visibility, + IsPublic, + Provider, + Bucket, + ObjectKey, + ExpiresInSeconds, + Metadata); + } +} + +public sealed class AssetUploadConfirmDto +{ + [Required] + public Guid AssetId { get; set; } + + [StringLength(200)] + public string? MimeType { get; set; } + + [Range(0, long.MaxValue)] + public long? FileSizeBytes { get; set; } + + [RegularExpression("^[A-Fa-f0-9]{64}$")] + public string? ChecksumSha256 { get; set; } + + public AssetUploadConfirmCommand ToCommand() + { + return new AssetUploadConfirmCommand(AssetId, MimeType, FileSizeBytes, ChecksumSha256); + } +} + +public sealed class AssetManagementQueryDto +{ + public Guid? RegionId { get; set; } + + public Guid? SubjectId { get; set; } + + public Guid? CategoryId { get; set; } + + public Guid? ContentNodeId { get; set; } + + [StringLength(50)] + public string? AssetType { get; set; } + + [StringLength(100)] + public string? Category { get; set; } + + [StringLength(50)] + public string? UploadStatus { get; set; } + + [StringLength(50)] + public string? SecurityScanStatus { get; set; } + + [StringLength(100)] + public string? Keyword { get; set; } + + [Range(1, 500)] + public int? Limit { get; set; } + + public AssetManagementFilter ToFilter() + { + return new AssetManagementFilter( + RegionId, + SubjectId, + CategoryId, + ContentNodeId, + AssetType, + Category, + UploadStatus, + SecurityScanStatus, + Keyword, + Limit); + } +} + +public sealed class ImportJobQueryDto +{ + [StringLength(50)] + public string? Status { get; set; } + + [StringLength(50)] + public string? ImportType { get; set; } + + [StringLength(50)] + public string? SourceFormat { get; set; } + + [Range(1, 500)] + public int? Limit { get; set; } + + public ImportJobFilter ToFilter() + { + return new ImportJobFilter(Status, ImportType, SourceFormat, Limit); + } +} diff --git a/Tiku.Api/Controllers/TenantContentController.cs b/Tiku.Api/Controllers/TenantContentController.cs new file mode 100644 index 0000000..1b055d5 --- /dev/null +++ b/Tiku.Api/Controllers/TenantContentController.cs @@ -0,0 +1,98 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Tiku.Api.Contracts; +using Tiku.Application.Assets; +using Tiku.Application.Catalog; +using Tiku.Application.Security; + +namespace Tiku.Api.Controllers; + +[ApiController] +[Authorize(Policy = TikuPolicies.TenantAdmin)] +[Produces("application/json")] +[Route("api/tenant-content")] +public sealed class TenantContentController( + IAssetManagementService assetManagementService, + ICurrentUser currentUser, + ICurrentTenant currentTenant) : ControllerBase +{ + [HttpGet("assets")] + [EndpointSummary("查询租户内容资产")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetAssets( + [FromQuery] AssetManagementQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await assetManagementService.GetAssetsAsync( + ResolveActor(), + query.ToFilter(), + cancellationToken)); + } + + [HttpPost("assets/uploads/sign")] + [EndpointSummary("创建资产上传签名")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> SignAssetUpload( + AssetUploadSignDto request, + CancellationToken cancellationToken) + { + return Ok(await assetManagementService.SignUploadAsync( + ResolveActor(), + request.ToCommand(), + cancellationToken)); + } + + [HttpPost("assets/uploads/confirm")] + [EndpointSummary("确认资产上传完成")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> ConfirmAssetUpload( + AssetUploadConfirmDto request, + CancellationToken cancellationToken) + { + return Ok(await assetManagementService.ConfirmUploadAsync( + ResolveActor(), + request.ToCommand(), + cancellationToken)); + } + + [HttpGet("import-jobs")] + [EndpointSummary("查询内容导入任务")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetImportJobs( + [FromQuery] ImportJobQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await assetManagementService.GetImportJobsAsync( + ResolveActor(), + query.ToFilter(), + cancellationToken)); + } + + [HttpGet("import-jobs/{jobId:guid}")] + [EndpointSummary("查询内容导入任务详情")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetImportJob( + Guid jobId, + CancellationToken cancellationToken) + { + return Ok(await assetManagementService.GetImportJobAsync( + ResolveActor(), + jobId, + cancellationToken)); + } + + private AssetManagementActor ResolveActor() + { + if (currentTenant.TenantId is null || currentUser.UserId is null) + { + throw new AssetManagementException("Tenant content actor was not resolved.", "tenant_content_access_denied"); + } + + return new AssetManagementActor(currentTenant.TenantId.Value, currentUser.UserId.Value); + } +} diff --git a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs index 7df5bbc..aece8f7 100644 --- a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs +++ b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs @@ -88,6 +88,16 @@ public sealed class ExceptionHandlingMiddleware( return; } + if (exception is AssetManagementException assetManagementException) + { + await WriteProblemAsync( + context, + assetManagementException.Message, + AssetManagementStatusCode(assetManagementException.Code), + assetManagementException.Code); + return; + } + if (exception is LearningValidationException learningValidationException) { await WriteProblemAsync( @@ -209,4 +219,15 @@ public sealed class ExceptionHandlingMiddleware( _ => StatusCodes.Status400BadRequest }; } + + private static int AssetManagementStatusCode(string code) + { + return code switch + { + "asset_not_found" or "import_job_not_found" => StatusCodes.Status404NotFound, + "asset_upload_missing" => StatusCodes.Status409Conflict, + "tenant_content_access_denied" => StatusCodes.Status403Forbidden, + _ => StatusCodes.Status400BadRequest + }; + } } diff --git a/Tiku.Application/Assets/AssetManagementModels.cs b/Tiku.Application/Assets/AssetManagementModels.cs new file mode 100644 index 0000000..cfd7075 --- /dev/null +++ b/Tiku.Application/Assets/AssetManagementModels.cs @@ -0,0 +1,158 @@ +using System.Text.Json; +using Tiku.Application.Catalog; +using Tiku.Application.Storage; +using Tiku.Domain.Content; + +namespace Tiku.Application.Assets; + +public sealed record AssetManagementActor(Guid TenantId, Guid UserId); + +public sealed record AssetUploadSignCommand( + Guid? AssetId, + Guid? RegionId, + Guid? SubjectId, + Guid? CategoryId, + Guid? ContentNodeId, + string? AssetKey, + string? Title, + string? Category, + string? Description, + string FileName, + string MimeType, + long? FileSizeBytes, + string? ChecksumSha256, + string? AssetType, + string? Visibility, + bool? IsPublic, + string? Provider, + string? Bucket, + string? ObjectKey, + int? ExpiresInSeconds, + JsonElement Metadata); + +public sealed record AssetUploadConfirmCommand( + Guid AssetId, + string? MimeType, + long? FileSizeBytes, + string? ChecksumSha256); + +public sealed record AssetManagementFilter( + Guid? RegionId = null, + Guid? SubjectId = null, + Guid? CategoryId = null, + Guid? ContentNodeId = null, + string? AssetType = null, + string? Category = null, + string? UploadStatus = null, + string? SecurityScanStatus = null, + string? Keyword = null, + int? Limit = null); + +public sealed record ImportJobFilter( + string? Status = null, + string? ImportType = null, + string? SourceFormat = null, + int? Limit = null); + +public sealed record ContentAssetManagementItem( + Guid Id, + string? LegacyId, + Guid? RegionId, + Guid? SubjectId, + Guid? CategoryId, + Guid? ContentNodeId, + string? AssetKey, + string? Title, + string? Category, + string? Description, + string? FileName, + string? CdnUrl, + bool IsPublic, + ContentAssetType AssetType, + AssetStorageProvider StorageProvider, + string? Bucket, + string? ObjectKey, + string? MimeType, + long? FileSizeBytes, + string? ChecksumSha256, + ContentVisibility Visibility, + ContentStatus Status, + int Order, + AssetUploadStatus UploadStatus, + DateTimeOffset? VerifiedAt, + long? VerifiedSizeBytes, + string? VerifiedChecksumSha256, + AssetPreviewStatus PreviewStatus, + AssetSecurityScanStatus SecurityScanStatus, + JsonElement Metadata, + DateTimeOffset CreatedAt, + DateTimeOffset? UpdatedAt); + +public sealed record AssetUploadSignResult( + ContentAssetManagementItem Item, + ObjectStorageSignedUrl Upload); + +public sealed record AssetUploadConfirmResult( + ContentAssetManagementItem Item, + ObjectStorageMetadata Metadata); + +public sealed record ContentImportJobItem( + Guid Id, + Guid? TargetRegionId, + Guid? TargetSubjectId, + Guid? TargetCategoryId, + Guid? TargetContentNodeId, + Guid? TargetQuestionBankId, + ContentImportType ImportType, + ImportSourceFormat SourceFormat, + ContentImportStatus Status, + string? SourceName, + string? SourceHash, + bool DryRun, + int TotalCount, + int ValidCount, + int ErrorCount, + int WarningCount, + int InsertedCount, + int UpdatedCount, + int SkippedCount, + JsonElement Summary, + string? ErrorMessage, + DateTimeOffset? StartedAt, + DateTimeOffset? FinishedAt, + DateTimeOffset CreatedAt, + DateTimeOffset? UpdatedAt); + +public sealed record ContentImportItemModel( + Guid Id, + Guid JobId, + int RowNo, + string? ExternalId, + ContentImportItemStatus Status, + string? TargetType, + Guid? TargetId, + JsonElement SourcePayload, + JsonElement NormalizedPayload, + string? ContentHash, + int IssuesCount); + +public sealed record ContentImportIssueModel( + Guid Id, + Guid JobId, + Guid? ItemId, + int? RowNo, + ImportIssueSeverity Severity, + string Code, + string? FieldPath, + string Message, + JsonElement Details); + +public sealed record ContentImportJobDetail( + ContentImportJobItem Job, + IReadOnlyCollection Items, + IReadOnlyCollection Issues); + +public sealed class AssetManagementException(string message, string code) : Exception(message) +{ + public string Code { get; } = code; +} diff --git a/Tiku.Application/Assets/IAssetManagementService.cs b/Tiku.Application/Assets/IAssetManagementService.cs new file mode 100644 index 0000000..4315f12 --- /dev/null +++ b/Tiku.Application/Assets/IAssetManagementService.cs @@ -0,0 +1,31 @@ +using Tiku.Application.Catalog; + +namespace Tiku.Application.Assets; + +public interface IAssetManagementService +{ + Task> GetAssetsAsync( + AssetManagementActor actor, + AssetManagementFilter filter, + CancellationToken cancellationToken = default); + + Task SignUploadAsync( + AssetManagementActor actor, + AssetUploadSignCommand command, + CancellationToken cancellationToken = default); + + Task ConfirmUploadAsync( + AssetManagementActor actor, + AssetUploadConfirmCommand command, + CancellationToken cancellationToken = default); + + Task> GetImportJobsAsync( + AssetManagementActor actor, + ImportJobFilter filter, + CancellationToken cancellationToken = default); + + Task GetImportJobAsync( + AssetManagementActor actor, + Guid jobId, + CancellationToken cancellationToken = default); +} diff --git a/Tiku.Infrastructure/Assets/AssetManagementService.cs b/Tiku.Infrastructure/Assets/AssetManagementService.cs new file mode 100644 index 0000000..fd3cb54 --- /dev/null +++ b/Tiku.Infrastructure/Assets/AssetManagementService.cs @@ -0,0 +1,566 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Assets; +using Tiku.Application.Catalog; +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> 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(filter.AssetType, ignoreCase: true, out var assetType)) + { + query = query.Where(asset => asset.AssetType == assetType); + } + + if (!string.IsNullOrWhiteSpace(filter.UploadStatus) && + Enum.TryParse(filter.UploadStatus, ignoreCase: true, out var uploadStatus)) + { + query = query.Where(asset => asset.UploadStatus == uploadStatus); + } + + if (!string.IsNullOrWhiteSpace(filter.SecurityScanStatus) && + Enum.TryParse(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(items); + } + + public async Task 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 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 async Task> 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(filter.Status, ignoreCase: true, out var status)) + { + query = query.Where(job => job.Status == status); + } + + if (!string.IsNullOrWhiteSpace(filter.ImportType) && + Enum.TryParse(filter.ImportType, ignoreCase: true, out var importType)) + { + query = query.Where(job => job.ImportType == importType); + } + + if (!string.IsNullOrWhiteSpace(filter.SourceFormat) && + Enum.TryParse(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(items); + } + + public async Task 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 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 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(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(value, ignoreCase: true, out var parsed)) + { + return parsed; + } + + return isPublic ? ContentVisibility.Public : ContentVisibility.Members; + } + + 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 + }; + } +} diff --git a/Tiku.Infrastructure/DependencyInjection.cs b/Tiku.Infrastructure/DependencyInjection.cs index 029829f..6d73e00 100644 --- a/Tiku.Infrastructure/DependencyInjection.cs +++ b/Tiku.Infrastructure/DependencyInjection.cs @@ -48,6 +48,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddOptions() .Validate( diff --git a/Tiku.IntegrationTests/Api/AssetManagementEndpointTests.cs b/Tiku.IntegrationTests/Api/AssetManagementEndpointTests.cs new file mode 100644 index 0000000..be7ca2d --- /dev/null +++ b/Tiku.IntegrationTests/Api/AssetManagementEndpointTests.cs @@ -0,0 +1,350 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Tiku.Api.Contracts; +using Tiku.Application.Storage; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.Identity; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Auth; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.IntegrationTests.Api; + +public sealed class AssetManagementEndpointTests +{ + [Fact] + public async Task Tenant_content_upload_sign_requires_authentication() + { + await using var factory = new ApiTestFactory(objectStorageService: new FakeObjectStorageService()); + using var client = factory.CreateClient(); + + using var response = await client.PostAsJsonAsync( + "/api/tenant-content/assets/uploads/sign", + new AssetUploadSignDto + { + FileName = "lesson.pdf", + MimeType = "application/pdf" + }); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Tenant_admin_can_sign_upload_and_create_pending_asset() + { + await using var factory = new ApiTestFactory(objectStorageService: new FakeObjectStorageService()); + var seed = await SeedAdminAsync(factory); + using var client = factory.CreateClient(); + await LoginAsync(client, seed); + + using var response = await client.PostAsJsonAsync( + "/api/tenant-content/assets/uploads/sign", + new AssetUploadSignDto + { + FileName = "lesson.pdf", + MimeType = "application/pdf", + FileSizeBytes = 1024, + Title = "课程讲义", + AssetType = "pdf", + Visibility = "members" + }); + var body = await ReadJsonAsync(response); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("PUT", body.RootElement.GetProperty("upload").GetProperty("method").GetString()); + var item = body.RootElement.GetProperty("item"); + var assetId = item.GetProperty("id").GetGuid(); + Assert.Equal("Pending", item.GetProperty("uploadStatus").GetString()); + Assert.Equal("AliyunOss", item.GetProperty("storageProvider").GetString()); + Assert.StartsWith($"{seed.TenantId:N}/assets/", item.GetProperty("objectKey").GetString(), StringComparison.Ordinal); + + using var listResponse = await client.GetAsync("/api/tenant-content/assets?uploadStatus=pending"); + var list = await ReadJsonAsync(listResponse); + var listItem = Assert.Single(list.RootElement.GetProperty("items").EnumerateArray()); + Assert.Equal(assetId, listItem.GetProperty("id").GetGuid()); + + using var scope = factory.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var asset = dbContext.ContentAssets.Single(asset => asset.Id == assetId); + Assert.Equal(seed.TenantId, asset.TenantId); + Assert.Equal(seed.UserId, asset.CreatedBy); + Assert.Equal(AssetUploadStatus.Pending, asset.UploadStatus); + Assert.Equal(AssetSecurityScanStatus.Pending, asset.SecurityScanStatus); + } + + [Fact] + public async Task Tenant_admin_can_confirm_upload_and_mark_asset_verified() + { + var storage = new FakeObjectStorageService + { + MetadataSizeBytes = 2048, + MetadataChecksumSha256 = new string('a', 64) + }; + await using var factory = new ApiTestFactory(objectStorageService: storage); + var seed = await SeedAdminAsync(factory); + var assetId = Guid.NewGuid(); + await factory.SeedAsync(new ContentAsset + { + Id = assetId, + TenantId = seed.TenantId, + Title = "课程讲义", + FileName = "lesson.pdf", + StorageProvider = AssetStorageProvider.AliyunOss, + Bucket = "tenant-assets", + ObjectKey = $"{seed.TenantId:N}/assets/lesson.pdf", + MimeType = "application/pdf", + AssetType = ContentAssetType.Pdf, + UploadStatus = AssetUploadStatus.Pending, + SecurityScanStatus = AssetSecurityScanStatus.Pending + }); + using var client = factory.CreateClient(); + await LoginAsync(client, seed); + + using var response = await client.PostAsJsonAsync( + "/api/tenant-content/assets/uploads/confirm", + new AssetUploadConfirmDto + { + AssetId = assetId, + MimeType = "application/pdf", + FileSizeBytes = 2048, + ChecksumSha256 = new string('a', 64) + }); + var body = await ReadJsonAsync(response); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("Verified", body.RootElement.GetProperty("item").GetProperty("uploadStatus").GetString()); + Assert.Equal(2048, body.RootElement.GetProperty("metadata").GetProperty("sizeBytes").GetInt64()); + + using var scope = factory.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var asset = dbContext.ContentAssets.Single(asset => asset.Id == assetId); + Assert.Equal(AssetUploadStatus.Verified, asset.UploadStatus); + Assert.Equal(seed.UserId, asset.VerifiedBy); + Assert.Equal(2048, asset.VerifiedSizeBytes); + Assert.Equal(new string('a', 64), asset.VerifiedChecksumSha256); + } + + [Fact] + public async Task Import_jobs_are_scoped_to_current_tenant_and_include_detail() + { + await using var factory = new ApiTestFactory(objectStorageService: new FakeObjectStorageService()); + var seed = await SeedAdminAsync(factory); + var otherTenantId = Guid.NewGuid(); + var jobId = Guid.NewGuid(); + var itemId = Guid.NewGuid(); + await factory.SeedAsync( + new Tenant + { + Id = otherTenantId, + Slug = otherTenantId.ToString("N"), + Name = "Other Tenant", + Status = TenantStatus.Active, + Metadata = JsonDefaults.Object() + }, + new ContentImportJob + { + Id = jobId, + TenantId = seed.TenantId, + SourceName = "questions.xlsx", + ImportType = ContentImportType.Questions, + SourceFormat = ImportSourceFormat.Excel, + Status = ContentImportStatus.CompletedWithErrors, + TotalCount = 1, + ErrorCount = 1 + }, + new ContentImportItem + { + Id = itemId, + TenantId = seed.TenantId, + JobId = jobId, + RowNo = 1, + Status = ContentImportItemStatus.Invalid, + IssuesCount = 1 + }, + new ContentImportIssue + { + Id = Guid.NewGuid(), + TenantId = seed.TenantId, + JobId = jobId, + ItemId = itemId, + RowNo = 1, + Severity = ImportIssueSeverity.Error, + Code = "missing_answer", + Message = "答案不能为空" + }, + new ContentImportJob + { + Id = Guid.NewGuid(), + TenantId = otherTenantId, + SourceName = "other.xlsx" + }); + using var client = factory.CreateClient(); + await LoginAsync(client, seed); + + using var listResponse = await client.GetAsync("/api/tenant-content/import-jobs?status=completedWithErrors"); + using var detailResponse = await client.GetAsync($"/api/tenant-content/import-jobs/{jobId}"); + var list = await ReadJsonAsync(listResponse); + var detail = await ReadJsonAsync(detailResponse); + + Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode); + var listItem = Assert.Single(list.RootElement.GetProperty("items").EnumerateArray()); + Assert.Equal(jobId, listItem.GetProperty("id").GetGuid()); + Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode); + Assert.Equal(jobId, detail.RootElement.GetProperty("job").GetProperty("id").GetGuid()); + Assert.Single(detail.RootElement.GetProperty("items").EnumerateArray()); + Assert.Single(detail.RootElement.GetProperty("issues").EnumerateArray()); + } + + private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedAdminAsync(ApiTestFactory factory) + { + var tenantId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var phone = "13900000000"; + var passwordHash = new PasswordHasher().Hash("passw0rd!"); + + await factory.SeedAsync( + new Tenant + { + Id = tenantId, + Slug = tenantId.ToString("N"), + Name = "Test Tenant", + Status = TenantStatus.Active, + Metadata = JsonDefaults.Object() + }, + new User + { + Id = userId, + Phone = phone, + Name = "Tenant Admin" + }, + new TenantMembership + { + TenantId = tenantId, + UserId = userId, + Role = TenantRole.TenantAdmin, + Status = MembershipStatus.Active + }, + new UserIdentity + { + UserId = userId, + Provider = "password", + ProviderSubject = phone, + Phone = phone, + SecretPayload = CreateSecretPayload(passwordHash) + }); + + return (tenantId, userId, phone); + } + + private static async Task LoginAsync( + HttpClient client, + (Guid TenantId, Guid UserId, string Phone) seed) + { + var loginResponse = await client.PostAsJsonAsync( + "/api/auth/login/password", + new PasswordLoginDto + { + TenantId = seed.TenantId, + Phone = seed.Phone, + Password = "passw0rd!" + }); + var loginJson = await ReadJsonAsync(loginResponse); + var accessToken = loginJson.RootElement + .GetProperty("tokens") + .GetProperty("accessToken") + .GetString(); + client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); + } + + private static async Task ReadJsonAsync(HttpResponseMessage response) + { + var stream = await response.Content.ReadAsStreamAsync(); + return await JsonDocument.ParseAsync(stream); + } + + private static JsonElement CreateSecretPayload(string passwordHash) + { + using var document = JsonDocument.Parse( + $$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}"""); + return document.RootElement.Clone(); + } + + private sealed class FakeObjectStorageService : IObjectStorageService + { + public long? MetadataSizeBytes { get; init; } + + public string? MetadataChecksumSha256 { get; init; } + + public string ConfiguredDefaultProvider() => ObjectStorageProviders.AliyunOss; + + public string ConfiguredDefaultBucket() => "tenant-assets"; + + public string NormalizeProvider(string? value, string? fallback = null) + { + return value ?? fallback ?? ObjectStorageProviders.AliyunOss; + } + + public string ValidateObjectKey(Guid tenantId, string objectKey) => objectKey; + + public string ValidateMimeType(string mimeType) => mimeType; + + public long? ValidateFileSize(long? fileSizeBytes) => fileSizeBytes; + + public void AssertUploadProvider(string provider) { } + + public void AssertWritableLocation(StorageAssetLocation location) { } + + public Task SignUploadAsync( + ObjectStorageUploadSignRequest request, + CancellationToken cancellationToken = default) + { + return Task.FromResult(new ObjectStorageSignedUrl( + request.Provider, + request.Bucket, + request.ObjectKey, + "PUT", + new Uri($"https://storage.example.test/{request.ObjectKey}"), + new Dictionary { ["content-type"] = request.MimeType }, + DateTimeOffset.UtcNow.Add(request.ExpiresIn), + request.ExpiresIn, + "fake-signed-url")); + } + + public Task SignDownloadAsync( + ObjectStorageDownloadSignRequest request, + CancellationToken cancellationToken = default) + { + return Task.FromResult(new ObjectStorageSignedUrl( + request.Provider, + request.Bucket, + request.ObjectKey, + "GET", + new Uri($"https://storage.example.test/{request.ObjectKey}"), + new Dictionary(), + DateTimeOffset.UtcNow.Add(request.ExpiresIn), + request.ExpiresIn, + "fake-signed-url")); + } + + public Task HeadObjectAsync( + ObjectStorageHeadRequest request, + CancellationToken cancellationToken = default) + { + return Task.FromResult(new ObjectStorageMetadata( + request.Provider, + request.Bucket, + request.ObjectKey, + Exists: true, + MetadataSizeBytes ?? request.DeclaredFileSizeBytes, + request.DeclaredMimeType, + MetadataChecksumSha256 ?? request.DeclaredChecksumSha256, + "etag", + DateTimeOffset.UtcNow.ToString("O"), + new Dictionary(), + "fake-head")); + } + } +}