forked from xiongyuxing/tiku-backend.net
feat: add tenant content asset management endpoints
This commit is contained in:
177
Tiku.Api/Contracts/AssetManagementDtos.cs
Normal file
177
Tiku.Api/Contracts/AssetManagementDtos.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
98
Tiku.Api/Controllers/TenantContentController.cs
Normal file
98
Tiku.Api/Controllers/TenantContentController.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,6 +88,16 @@ public sealed class ExceptionHandlingMiddleware(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (exception is AssetManagementException assetManagementException)
|
||||||
|
{
|
||||||
|
await WriteProblemAsync(
|
||||||
|
context,
|
||||||
|
assetManagementException.Message,
|
||||||
|
AssetManagementStatusCode(assetManagementException.Code),
|
||||||
|
assetManagementException.Code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (exception is LearningValidationException learningValidationException)
|
if (exception is LearningValidationException learningValidationException)
|
||||||
{
|
{
|
||||||
await WriteProblemAsync(
|
await WriteProblemAsync(
|
||||||
@@ -209,4 +219,15 @@ public sealed class ExceptionHandlingMiddleware(
|
|||||||
_ => StatusCodes.Status400BadRequest
|
_ => 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
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
158
Tiku.Application/Assets/AssetManagementModels.cs
Normal file
158
Tiku.Application/Assets/AssetManagementModels.cs
Normal 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;
|
||||||
|
}
|
||||||
31
Tiku.Application/Assets/IAssetManagementService.cs
Normal file
31
Tiku.Application/Assets/IAssetManagementService.cs
Normal 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);
|
||||||
|
}
|
||||||
566
Tiku.Infrastructure/Assets/AssetManagementService.cs
Normal file
566
Tiku.Infrastructure/Assets/AssetManagementService.cs
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IStudyContentQueryService, StudyContentQueryService>();
|
services.AddScoped<IStudyContentQueryService, StudyContentQueryService>();
|
||||||
services.AddScoped<IAssetQueryService, AssetQueryService>();
|
services.AddScoped<IAssetQueryService, AssetQueryService>();
|
||||||
services.AddScoped<IAssetAccessService, AssetAccessService>();
|
services.AddScoped<IAssetAccessService, AssetAccessService>();
|
||||||
|
services.AddScoped<IAssetManagementService, AssetManagementService>();
|
||||||
services.AddScoped<ILearningActivityService, LearningActivityService>();
|
services.AddScoped<ILearningActivityService, LearningActivityService>();
|
||||||
services.AddOptions<AliyunOssOptions>()
|
services.AddOptions<AliyunOssOptions>()
|
||||||
.Validate(
|
.Validate(
|
||||||
|
|||||||
350
Tiku.IntegrationTests/Api/AssetManagementEndpointTests.cs
Normal file
350
Tiku.IntegrationTests/Api/AssetManagementEndpointTests.cs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user