Files
tiku-backend.net/Tiku.Infrastructure/ContentV2/PlatformContentPackageProjectionService.cs
xiong 3f5957d744
Some checks failed
ci / release-gate (push) Has been cancelled
feat(content): establish v2 learning delivery foundation
2026-08-06 09:52:18 +08:00

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