feat: add tenant content asset management endpoints

This commit is contained in:
xiong
2026-07-26 15:34:59 +08:00
parent e505384ecf
commit 3c0acebbbe
8 changed files with 1402 additions and 0 deletions

View File

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

View File

@@ -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<CatalogList<ContentAssetManagementItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<ContentAssetManagementItem>>> GetAssets(
[FromQuery] AssetManagementQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await assetManagementService.GetAssetsAsync(
ResolveActor(),
query.ToFilter(),
cancellationToken));
}
[HttpPost("assets/uploads/sign")]
[EndpointSummary("创建资产上传签名")]
[ProducesResponseType<AssetUploadSignResult>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<AssetUploadSignResult>> SignAssetUpload(
AssetUploadSignDto request,
CancellationToken cancellationToken)
{
return Ok(await assetManagementService.SignUploadAsync(
ResolveActor(),
request.ToCommand(),
cancellationToken));
}
[HttpPost("assets/uploads/confirm")]
[EndpointSummary("确认资产上传完成")]
[ProducesResponseType<AssetUploadConfirmResult>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
public async Task<ActionResult<AssetUploadConfirmResult>> ConfirmAssetUpload(
AssetUploadConfirmDto request,
CancellationToken cancellationToken)
{
return Ok(await assetManagementService.ConfirmUploadAsync(
ResolveActor(),
request.ToCommand(),
cancellationToken));
}
[HttpGet("import-jobs")]
[EndpointSummary("查询内容导入任务")]
[ProducesResponseType<CatalogList<ContentImportJobItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<ContentImportJobItem>>> GetImportJobs(
[FromQuery] ImportJobQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await assetManagementService.GetImportJobsAsync(
ResolveActor(),
query.ToFilter(),
cancellationToken));
}
[HttpGet("import-jobs/{jobId:guid}")]
[EndpointSummary("查询内容导入任务详情")]
[ProducesResponseType<ContentImportJobDetail>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ContentImportJobDetail>> 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);
}
}

View File

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

View File

@@ -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<ContentImportItemModel> Items,
IReadOnlyCollection<ContentImportIssueModel> Issues);
public sealed class AssetManagementException(string message, string code) : Exception(message)
{
public string Code { get; } = code;
}

View File

@@ -0,0 +1,31 @@
using Tiku.Application.Catalog;
namespace Tiku.Application.Assets;
public interface IAssetManagementService
{
Task<CatalogList<ContentAssetManagementItem>> GetAssetsAsync(
AssetManagementActor actor,
AssetManagementFilter filter,
CancellationToken cancellationToken = default);
Task<AssetUploadSignResult> SignUploadAsync(
AssetManagementActor actor,
AssetUploadSignCommand command,
CancellationToken cancellationToken = default);
Task<AssetUploadConfirmResult> ConfirmUploadAsync(
AssetManagementActor actor,
AssetUploadConfirmCommand command,
CancellationToken cancellationToken = default);
Task<CatalogList<ContentImportJobItem>> GetImportJobsAsync(
AssetManagementActor actor,
ImportJobFilter filter,
CancellationToken cancellationToken = default);
Task<ContentImportJobDetail> GetImportJobAsync(
AssetManagementActor actor,
Guid jobId,
CancellationToken cancellationToken = default);
}

View File

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

View File

@@ -48,6 +48,7 @@ public static class DependencyInjection
services.AddScoped<IStudyContentQueryService, StudyContentQueryService>();
services.AddScoped<IAssetQueryService, AssetQueryService>();
services.AddScoped<IAssetAccessService, AssetAccessService>();
services.AddScoped<IAssetManagementService, AssetManagementService>();
services.AddScoped<ILearningActivityService, LearningActivityService>();
services.AddOptions<AliyunOssOptions>()
.Validate(

View File

@@ -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<TikuDbContext>();
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<TikuDbContext>();
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<JsonDocument> 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<ObjectStorageSignedUrl> 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<string, string> { ["content-type"] = request.MimeType },
DateTimeOffset.UtcNow.Add(request.ExpiresIn),
request.ExpiresIn,
"fake-signed-url"));
}
public Task<ObjectStorageSignedUrl> 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<string, string>(),
DateTimeOffset.UtcNow.Add(request.ExpiresIn),
request.ExpiresIn,
"fake-signed-url"));
}
public Task<ObjectStorageMetadata> 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<string, string>(),
"fake-head"));
}
}
}