Files
tiku-backend.net/Tiku.Infrastructure/ContentV2/ContentReleaseCompiler.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

370 lines
18 KiB
C#

using System.Data;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Application.Content;
using Tiku.Application.Security;
using Tiku.Domain.Content;
using Tiku.Domain.Learning;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.ContentV2;
internal sealed class ContentReleaseCompiler(
IQuestionBankPersistence content,
ILearningPersistence learning,
ITenantExecutionScope tenantExecutionScope) : IContentReleaseCompiler
{
internal const string ReleasePublishedEvent = "content_release_published";
public async Task<ContentReleaseCompilationResult> PublishAsync(
PublishContentReleaseCommand command,
CancellationToken cancellationToken = default)
{
await using var transaction = await content.Database.BeginTransactionAsync(
IsolationLevel.RepeatableRead,
cancellationToken);
var publicationLockKey = $"content-release:{command.TenantId:N}:{command.CurriculumVersionId:N}";
await content.Database.ExecuteSqlInterpolatedAsync(
$"SELECT pg_advisory_xact_lock(hashtextextended({publicationLockKey}, 0))",
cancellationToken);
var curriculumVersion = await content.CurriculumVersions.AsNoTracking().SingleOrDefaultAsync(
item => item.TenantId == command.TenantId &&
item.Id == command.CurriculumVersionId &&
item.Status == ContentDefinitionStatus.Published,
cancellationToken) ?? throw Error("curriculum_version_not_publishable",
"A published curriculum version is required.");
var curriculum = await content.Curricula.AsNoTracking().SingleAsync(
item => item.TenantId == command.TenantId && item.Id == curriculumVersion.CurriculumId,
cancellationToken);
var placements = await content.QuestionPlacements.AsNoTracking()
.Where(item => item.TenantId == command.TenantId &&
item.CurriculumVersionId == curriculumVersion.Id &&
item.Status == QuestionPlacementStatus.Active &&
(item.ValidFrom == null || item.ValidFrom <= DateTimeOffset.UtcNow) &&
(item.ValidTo == null || item.ValidTo > DateTimeOffset.UtcNow))
.OrderBy(item => item.Id)
.ToArrayAsync(cancellationToken);
if (placements.Length == 0)
throw Error("content_release_empty", "The curriculum has no active question placements.");
var platformOwnerIds = await tenantExecutionScope.ExecuteAsync(
new SystemScopeRequest(
null,
SystemScopeCallerType.Platform,
nameof(ContentReleaseCompiler),
"Resolve the unique platform V2 content owner",
Guid.NewGuid().ToString("N"),
true),
(provider, token) => provider.GetRequiredService<ITenancyPersistence>().Tenants.AsNoTracking()
.Where(item => item.Mode == TenantMode.PlatformOwned)
.Select(item => item.Id)
.ToArrayAsync(token),
cancellationToken);
if (platformOwnerIds.Length != 1)
throw Error("platform_content_owner_invalid",
"Exactly one platform-owned content tenant is required before publishing content.");
var permittedOwnerIds = platformOwnerIds.Append(command.TenantId).Distinct().ToArray();
if (placements.Any(item => !permittedOwnerIds.Contains(item.QuestionAssetOwnerTenantId)))
throw Error("question_asset_owner_forbidden",
"A placement may reference only this tenant's private question or the platform public owner.");
var assetIds = placements.Select(item => item.QuestionAssetId).Distinct().ToArray();
var assets = new List<QuestionAsset>();
foreach (var ownerTenantId in permittedOwnerIds)
assets.AddRange(await ReadForOwnerAsync(
ownerTenantId,
"Load explicitly placed published V2 question assets",
(persistence, token) => persistence.QuestionAssets.AsNoTracking()
.Where(item => item.TenantId == ownerTenantId &&
assetIds.Contains(item.Id) &&
item.Status == QuestionAssetStatus.Published &&
item.CurrentRevisionId != null)
.ToArrayAsync(token),
cancellationToken));
var assetsByOwner = assets.ToDictionary(item => (item.TenantId, item.Id));
var requestedAssets = placements
.Select(item => (item.QuestionAssetOwnerTenantId, item.QuestionAssetId))
.Distinct()
.ToArray();
if (requestedAssets.Any(key => !assetsByOwner.ContainsKey(key)))
throw Error("content_release_question_unpublished",
"Every placement must reference a published question asset with a current revision.");
var revisionIds = assets.Select(item => item.CurrentRevisionId!.Value).Distinct().ToArray();
var revisions = new List<QuestionRevision>();
foreach (var ownerTenantId in permittedOwnerIds)
revisions.AddRange(await ReadForOwnerAsync(
ownerTenantId,
"Load immutable V2 question revisions for release compilation",
(persistence, token) => persistence.QuestionRevisions.AsNoTracking()
.Where(item => item.TenantId == ownerTenantId && revisionIds.Contains(item.Id))
.ToArrayAsync(token),
cancellationToken));
var revisionsByOwner = revisions.ToDictionary(item => (item.TenantId, item.Id));
if (requestedAssets.Any(key =>
!revisionsByOwner.ContainsKey((key.QuestionAssetOwnerTenantId,
assetsByOwner[key].CurrentRevisionId!.Value))))
throw Error("content_release_revision_missing", "One or more immutable question revisions are missing.");
var policyVersionIds = placements.Select(item => item.AssessmentPolicyVersionId).Distinct().ToArray();
var publishedPolicyIds = await content.AssessmentPolicyVersions.AsNoTracking()
.Where(item => item.TenantId == command.TenantId &&
policyVersionIds.Contains(item.Id) &&
item.Status == ContentDefinitionStatus.Published)
.Select(item => item.Id)
.ToArrayAsync(cancellationToken);
if (publishedPolicyIds.Length != policyVersionIds.Length)
throw Error("content_release_assessment_unpublished",
"Every placement must reference a published assessment policy version.");
var profileVersions = await (
from profile in content.ExamTargetProfiles.AsNoTracking()
join version in content.ExamTargetProfileVersions.AsNoTracking()
on profile.Id equals version.ExamTargetProfileId
where profile.BusinessLineId == curriculum.BusinessLineId &&
profile.IsActive &&
version.Status == ContentDefinitionStatus.Published
select version)
.ToArrayAsync(cancellationToken);
if (profileVersions.Length == 0)
throw Error("content_release_target_profiles_missing",
"At least one published exam target profile is required.");
var profileFacts = await LoadProfileFactsAsync(profileVersions, cancellationToken);
var placementRules = await LoadPlacementRulesAsync(placements, cancellationToken);
var placementMatches = placements.Select(placement =>
{
var groups = placementRules.GetValueOrDefault(placement.Id) ?? [];
var matchingProfiles = profileFacts
.Where(pair => QuestionApplicabilityEvaluator.Matches(pair.Value, groups))
.Select(pair => pair.Key)
.Order()
.ToArray();
if (matchingProfiles.Length == 0)
throw Error("content_release_rule_matches_nothing",
$"Placement '{placement.Id}' does not match any published target profile.");
return new PlacementMatch(placement, matchingProfiles, HashIds(matchingProfiles));
}).ToArray();
var now = DateTimeOffset.UtcNow;
var nextReleaseNo = await content.ContentReleases
.Where(item => item.TenantId == command.TenantId &&
item.CurriculumVersionId == curriculumVersion.Id)
.Select(item => (int?)item.ReleaseNo)
.MaxAsync(cancellationToken) ?? 0;
var sourceFingerprint = ComputeSourceFingerprint(placementMatches, assetsByOwner, revisionsByOwner);
var release = new ContentRelease
{
TenantId = command.TenantId,
BusinessLineId = curriculum.BusinessLineId,
CurriculumVersionId = curriculumVersion.Id,
ReleaseNo = nextReleaseNo + 1,
Name = command.Name.Trim(),
Status = ContentReleaseStatus.Compiling,
SourceFingerprint = sourceFingerprint,
PublishedAt = now,
CreatedBy = command.ActorUserId
};
content.ContentReleases.Add(release);
await content.SaveChangesAsync(cancellationToken);
var segmentsByHash = new Dictionary<string, AudienceSegment>(StringComparer.Ordinal);
foreach (var match in placementMatches)
{
if (segmentsByHash.ContainsKey(match.AudienceHash)) continue;
var segment = new AudienceSegment
{
TenantId = command.TenantId,
ContentReleaseId = release.Id,
RuleHash = match.AudienceHash,
SegmentOrdinal = segmentsByHash.Count
};
segmentsByHash.Add(match.AudienceHash, segment);
content.AudienceSegments.Add(segment);
foreach (var profileVersionId in match.ProfileVersionIds)
content.AudienceSegmentMembers.Add(new AudienceSegmentMember
{
TenantId = command.TenantId,
ContentReleaseId = release.Id,
AudienceSegmentId = segment.Id,
ExamTargetProfileVersionId = profileVersionId
});
}
foreach (var group in placementMatches
.OrderBy(item => item.Placement.CurriculumNodeId)
.ThenBy(item => assetsByOwner[(item.Placement.QuestionAssetOwnerTenantId,
item.Placement.QuestionAssetId)].DeliveryFingerprint)
.GroupBy(item => new { item.AudienceHash, item.Placement.CurriculumNodeId }))
{
var ordinal = 0;
foreach (var match in group)
{
var asset = assetsByOwner[(match.Placement.QuestionAssetOwnerTenantId,
match.Placement.QuestionAssetId)];
var revision = revisionsByOwner[(asset.TenantId, asset.CurrentRevisionId!.Value)];
content.ContentReleaseQuestions.Add(new ContentReleaseQuestion
{
TenantId = command.TenantId,
ContentReleaseId = release.Id,
AudienceSegmentId = segmentsByHash[match.AudienceHash].Id,
CurriculumNodeId = match.Placement.CurriculumNodeId,
QuestionPlacementId = match.Placement.Id,
QuestionAssetOwnerTenantId = asset.TenantId,
QuestionAssetId = asset.Id,
QuestionRevisionId = revision.Id,
AssessmentPolicyVersionId = match.Placement.AssessmentPolicyVersionId,
Ordinal = ordinal++,
Difficulty = match.Placement.DifficultyOverride ?? revision.Difficulty,
QuestionType = revision.QuestionType,
Status = ContentReleaseQuestionStatus.Active
});
}
}
await content.SaveChangesAsync(cancellationToken);
release.Status = ContentReleaseStatus.Published;
learning.LearningOutboxMessages.Add(new LearningOutboxMessage
{
TenantId = command.TenantId,
EventType = ReleasePublishedEvent,
SchemaVersion = 1,
AggregateId = release.Id,
Payload = JsonSerializer.SerializeToElement(new
{
releaseId = release.Id,
curriculumVersionId = curriculumVersion.Id,
businessLineId = curriculum.BusinessLineId,
releaseNo = release.ReleaseNo,
sourceFingerprint
}),
OccurredAt = now,
AvailableAt = now
});
await content.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return new ContentReleaseCompilationResult(
release.Id,
release.ReleaseNo,
placements.Length,
placements.Length,
segmentsByHash.Count,
profileVersions.Length,
sourceFingerprint,
now);
}
private async Task<Dictionary<Guid, IReadOnlyCollection<TargetProfileFact>>> LoadProfileFactsAsync(
IReadOnlyCollection<ExamTargetProfileVersion> versions,
CancellationToken cancellationToken)
{
var versionIds = versions.Select(item => item.Id).ToArray();
var values = await content.ExamTargetProfileValues.AsNoTracking()
.Where(item => versionIds.Contains(item.ExamTargetProfileVersionId))
.ToArrayAsync(cancellationToken);
var nodeIds = values.Select(item => item.TargetNodeId).Distinct().ToArray();
var allNodes = await content.TargetNodes.AsNoTracking()
.Select(item => new NodeParent(item.Id, item.ParentId))
.ToDictionaryAsync(item => item.Id, cancellationToken);
var ancestors = nodeIds.ToDictionary(nodeId => nodeId, nodeId => ResolveAncestors(nodeId, allNodes));
return versions.ToDictionary(
version => version.Id,
version => (IReadOnlyCollection<TargetProfileFact>)values
.Where(value => value.ExamTargetProfileVersionId == version.Id)
.Select(value => new TargetProfileFact(
value.TargetDimensionDefinitionId,
value.TargetNodeId,
ancestors[value.TargetNodeId]))
.ToArray());
}
private async Task<Dictionary<Guid, IReadOnlyCollection<PlacementRuleGroupFact>>> LoadPlacementRulesAsync(
IReadOnlyCollection<QuestionPlacement> placements,
CancellationToken cancellationToken)
{
var placementIds = placements.Select(item => item.Id).ToArray();
var groups = await content.QuestionPlacementRuleGroups.AsNoTracking()
.Where(item => item.TenantId == placements.First().TenantId && placementIds.Contains(item.PlacementId))
.ToArrayAsync(cancellationToken);
var groupIds = groups.Select(item => item.Id).ToArray();
var conditions = await content.QuestionPlacementRuleConditions.AsNoTracking()
.Where(item => item.TenantId == placements.First().TenantId && groupIds.Contains(item.RuleGroupId))
.ToArrayAsync(cancellationToken);
return groups.GroupBy(item => item.PlacementId).ToDictionary(
group => group.Key,
group => (IReadOnlyCollection<PlacementRuleGroupFact>)group.OrderBy(item => item.GroupOrder)
.Select(item => new PlacementRuleGroupFact(
item.GroupOrder,
conditions.Where(condition => condition.RuleGroupId == item.Id)
.Select(condition => new PlacementRuleConditionFact(
condition.TargetDimensionDefinitionId,
condition.TargetNodeId,
condition.Operator))
.ToArray()))
.ToArray());
}
private static IReadOnlySet<Guid> ResolveAncestors(
Guid nodeId,
IReadOnlyDictionary<Guid, NodeParent> nodes)
{
var result = new HashSet<Guid>();
var current = nodeId;
while (nodes.TryGetValue(current, out var node) && node.ParentId is Guid parentId)
{
if (!result.Add(parentId)) throw Error("target_node_cycle", "A target-node hierarchy contains a cycle.");
current = parentId;
}
return result;
}
private static string ComputeSourceFingerprint(
IEnumerable<PlacementMatch> matches,
IReadOnlyDictionary<(Guid OwnerTenantId, Guid AssetId), QuestionAsset> assets,
IReadOnlyDictionary<(Guid OwnerTenantId, Guid RevisionId), QuestionRevision> revisions)
{
var values = matches.OrderBy(item => item.Placement.Id).Select(item =>
{
var asset = assets[(item.Placement.QuestionAssetOwnerTenantId, item.Placement.QuestionAssetId)];
var revision = revisions[(asset.TenantId, asset.CurrentRevisionId!.Value)];
return $"{item.Placement.Id:N}:{asset.TenantId:N}:{revision.Id:N}:{item.Placement.AssessmentPolicyVersionId:N}:{item.AudienceHash}";
});
return HashText(string.Join('|', values));
}
private static string HashIds(IEnumerable<Guid> ids) => HashText(string.Join(',', ids.Select(id => id.ToString("N"))));
private static string HashText(string value) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
private static ContentV2Exception Error(string code, string message) => new(code, message);
private Task<T[]> ReadForOwnerAsync<T>(
Guid ownerTenantId,
string reason,
Func<IQuestionBankPersistence, CancellationToken, Task<T[]>> read,
CancellationToken cancellationToken)
{
return tenantExecutionScope.ExecuteAsync(
new SystemScopeRequest(
ownerTenantId,
SystemScopeCallerType.PublicQuestionBank,
nameof(ContentReleaseCompiler),
reason,
Guid.NewGuid().ToString("N")),
(provider, token) => read(provider.GetRequiredService<IQuestionBankPersistence>(), token),
cancellationToken);
}
private sealed record PlacementMatch(
QuestionPlacement Placement,
Guid[] ProfileVersionIds,
string AudienceHash);
private sealed record NodeParent(Guid Id, Guid? ParentId);
}