114 lines
5.3 KiB
C#
114 lines
5.3 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Tiku.Application.Content;
|
|
using Tiku.Application.Security;
|
|
using Tiku.Domain.Content;
|
|
using Tiku.Infrastructure.Persistence;
|
|
|
|
namespace Tiku.Infrastructure.ContentV2;
|
|
|
|
internal sealed class PlatformContentPackageProjectionService(
|
|
IQuestionBankPersistence content,
|
|
ITenantExecutionScope tenantExecutionScope) :
|
|
IPlatformContentPackageProjectionService
|
|
{
|
|
public async Task<CellPackageApplyResult> ApplyAsync(
|
|
ApplyPlatformContentPackageCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var packageCode = Required(command.PackageCode, nameof(command.PackageCode), 100);
|
|
var cellId = Required(command.CellId, nameof(command.CellId), 100);
|
|
var contentHash = Required(command.ContentHash, nameof(command.ContentHash), 64);
|
|
if (command.EventSequence <= 0)
|
|
throw new ContentV2Exception("platform_package_sequence_invalid",
|
|
"The platform package event sequence must be positive.");
|
|
|
|
await using var transaction = await content.Database.BeginTransactionAsync(cancellationToken);
|
|
var lockKey = $"platform-package-cell:{command.PackageOwnerTenantId:N}:{packageCode}:{cellId}";
|
|
await content.Database.ExecuteSqlInterpolatedAsync(
|
|
$"SELECT pg_advisory_xact_lock(hashtextextended({lockKey}, 0))",
|
|
cancellationToken);
|
|
|
|
var package = await tenantExecutionScope.ExecuteAsync(
|
|
new SystemScopeRequest(
|
|
command.PackageOwnerTenantId,
|
|
SystemScopeCallerType.PublicQuestionBank,
|
|
nameof(PlatformContentPackageProjectionService),
|
|
"Validate an immutable platform package before applying it to a Cell",
|
|
Guid.NewGuid().ToString("N")),
|
|
(provider, token) => provider.GetRequiredService<IQuestionBankPersistence>()
|
|
.PlatformContentPackageVersions.AsNoTracking()
|
|
.SingleOrDefaultAsync(item =>
|
|
item.TenantId == command.PackageOwnerTenantId &&
|
|
item.Id == command.PlatformContentPackageVersionId &&
|
|
item.PackageCode == packageCode &&
|
|
item.ContentHash == contentHash &&
|
|
item.Status == PlatformContentPackageStatus.Published,
|
|
token),
|
|
cancellationToken) ?? throw new ContentV2Exception(
|
|
"platform_package_invalid",
|
|
"The package event does not match a published immutable platform package version.");
|
|
|
|
var projection = await content.PlatformContentPackageCellProjections
|
|
.SingleOrDefaultAsync(item =>
|
|
item.PackageOwnerTenantId == command.PackageOwnerTenantId &&
|
|
item.PackageCode == packageCode &&
|
|
item.CellId == cellId,
|
|
cancellationToken);
|
|
if (projection is not null && command.EventSequence < projection.EventSequence)
|
|
{
|
|
await transaction.CommitAsync(cancellationToken);
|
|
return Result(CellPackageApplyStatus.IgnoredOutOfOrder, projection);
|
|
}
|
|
|
|
if (projection is not null && command.EventSequence == projection.EventSequence)
|
|
{
|
|
if (projection.PlatformContentPackageVersionId != package.Id ||
|
|
!string.Equals(projection.ContentHash, contentHash, StringComparison.Ordinal))
|
|
throw new ContentV2Exception("platform_package_sequence_conflict",
|
|
"The same package event sequence was received with different immutable content.");
|
|
await transaction.CommitAsync(cancellationToken);
|
|
return Result(CellPackageApplyStatus.Duplicate, projection);
|
|
}
|
|
|
|
if (projection is null)
|
|
{
|
|
projection = new PlatformContentPackageCellProjection
|
|
{
|
|
PackageOwnerTenantId = command.PackageOwnerTenantId,
|
|
PackageCode = packageCode,
|
|
CellId = cellId
|
|
};
|
|
content.PlatformContentPackageCellProjections.Add(projection);
|
|
}
|
|
|
|
projection.PlatformContentPackageVersionId = package.Id;
|
|
projection.EventSequence = command.EventSequence;
|
|
projection.ContentHash = contentHash;
|
|
projection.Status = CellProjectionStatus.Ready;
|
|
projection.ProjectedAt = DateTimeOffset.UtcNow;
|
|
projection.LastError = null;
|
|
await content.SaveChangesAsync(cancellationToken);
|
|
await transaction.CommitAsync(cancellationToken);
|
|
return Result(CellPackageApplyStatus.Applied, projection);
|
|
}
|
|
|
|
private static CellPackageApplyResult Result(
|
|
CellPackageApplyStatus status,
|
|
PlatformContentPackageCellProjection projection) => new(
|
|
status,
|
|
projection.PlatformContentPackageVersionId,
|
|
projection.CellId,
|
|
projection.EventSequence,
|
|
projection.ContentHash);
|
|
|
|
private static string Required(string value, string parameterName, int maxLength)
|
|
{
|
|
var normalized = value.Trim();
|
|
if (normalized.Length == 0 || normalized.Length > maxLength)
|
|
throw new ContentV2Exception("platform_package_event_invalid",
|
|
$"{parameterName} is required and must not exceed {maxLength} characters.");
|
|
return normalized;
|
|
}
|
|
}
|