Files
tiku-backend.net/Tiku.Infrastructure/Learning/Access/EffectiveLearningAccessService.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

312 lines
17 KiB
C#

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Application.Content;
using Tiku.Application.Learning;
using Tiku.Application.Security;
using Tiku.Domain.Content;
using Tiku.Domain.Learning;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Caching;
using Tiku.Infrastructure.Persistence;
using ZiggyCreatures.Caching.Fusion;
namespace Tiku.Infrastructure.Learning;
internal sealed class EffectiveLearningAccessService(
ILearningAccessPersistence access,
ITenantExecutionScope tenantExecutionScope,
[FromKeyedServices(BusinessCachingServiceCollectionExtensions.CacheName)]
IFusionCache cache) : IEffectiveLearningAccessService
{
private static readonly TimeSpan SnapshotLifetime = TimeSpan.FromSeconds(30);
public async Task<EffectiveLearningAccessSnapshot> GetSnapshotAsync(
LearningActor actor,
Guid businessLineId,
CancellationToken cancellationToken = default)
{
return await cache.GetOrSetAsync<EffectiveLearningAccessSnapshot>(
CacheKey(actor.TenantId, actor.UserId, businessLineId),
(_, token) => CompileAsync(actor, businessLineId, token),
options => options
.SetDuration(SnapshotLifetime)
.SetFailSafe(false),
token: cancellationToken);
}
public Task InvalidateAsync(
Guid tenantId,
Guid userId,
Guid businessLineId,
CancellationToken cancellationToken = default)
{
return cache.RemoveAsync(CacheKey(tenantId, userId, businessLineId), token: cancellationToken).AsTask();
}
private async Task<EffectiveLearningAccessSnapshot> CompileAsync(
LearningActor actor,
Guid businessLineId,
CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow;
if (!await access.Tenants.AsNoTracking().AnyAsync(
item => item.Id == actor.TenantId && item.Status == TenantStatus.Active,
cancellationToken))
throw Error("learning_tenant_inactive", "The tenant is not active.");
var license = await access.TenantBusinessLicenses.AsNoTracking().SingleOrDefaultAsync(
item => item.TenantId == actor.TenantId &&
item.BusinessLineId == businessLineId &&
item.Status == LicenseLifecycleStatus.Active &&
item.StartsAt <= now &&
(item.EndsAt == null || item.EndsAt > now),
cancellationToken) ?? throw Error("learning_license_required",
"The tenant has no active license for the requested business.");
var selections = await access.StudentTargetSelectionHistory.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.BusinessLineId == businessLineId &&
item.IsCurrent &&
item.EffectiveAt <= now &&
(item.EndedAt == null || item.EndedAt > now))
.ToArrayAsync(cancellationToken);
var primary = selections.SingleOrDefault(item => item.Role == TargetSelectionRole.Primary)
?? throw Error("student_primary_target_required",
"A primary exam target must be selected before starting practice.");
if (selections.Length > 16)
throw Error("student_target_limit_exceeded", "No more than 16 active targets are allowed per business.");
var selectedProfileIds = selections.Select(item => item.ExamTargetProfileVersionId).Distinct().ToArray();
if (selectedProfileIds.Length != selections.Length)
throw Error("student_target_selection_duplicate", "The active target selection contains duplicates.");
var licensedProfileIds = await access.TenantLicensedTargets.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId &&
item.TenantBusinessLicenseId == license.Id &&
item.StartsAt <= now &&
(item.EndsAt == null || item.EndsAt > now) &&
selectedProfileIds.Contains(item.ExamTargetProfileVersionId))
.Select(item => item.ExamTargetProfileVersionId)
.Distinct()
.ToArrayAsync(cancellationToken);
if (licensedProfileIds.Length != selectedProfileIds.Length)
throw Error("student_target_not_licensed", "One or more selected exam targets are not tenant licensed.");
var entitlementRows = await (
from entitlement in access.StudentEntitlementsV2.AsNoTracking()
join product in access.LearningProducts.AsNoTracking()
on new { entitlement.TenantId, Id = entitlement.LearningProductId }
equals new { product.TenantId, product.Id }
join manifest in access.ProductAccessManifestVersions.AsNoTracking()
on new { entitlement.TenantId, Id = entitlement.ProductAccessManifestVersionId }
equals new { manifest.TenantId, manifest.Id }
where entitlement.TenantId == actor.TenantId &&
entitlement.UserId == actor.UserId &&
entitlement.Status == StudentEntitlementStatus.Active &&
entitlement.StartsAt <= now &&
(entitlement.EndsAt == null || entitlement.EndsAt > now) &&
product.BusinessLineId == businessLineId &&
product.IsActive &&
manifest.Status == AccessManifestStatus.Published
select new ManifestGrant(manifest.Id, manifest.MaxActiveTargets, manifest.MaxAlternateTargets,
entitlement.EndsAt, null))
.ToArrayAsync(cancellationToken);
var classGrantRows = await (
from member in access.TenantClassMembers.AsNoTracking()
join grant in access.ClassAssignmentGrants.AsNoTracking()
on new { member.TenantId, member.ClassId } equals new { grant.TenantId, grant.ClassId }
join manifest in access.ProductAccessManifestVersions.AsNoTracking()
on new { grant.TenantId, Id = grant.ProductAccessManifestVersionId }
equals new { manifest.TenantId, manifest.Id }
join definition in access.ProductAccessManifests.AsNoTracking()
on new { manifest.TenantId, Id = manifest.ProductAccessManifestId }
equals new { definition.TenantId, definition.Id }
join product in access.LearningProducts.AsNoTracking()
on new { definition.TenantId, Id = definition.LearningProductId }
equals new { product.TenantId, product.Id }
where member.TenantId == actor.TenantId &&
member.UserId == actor.UserId &&
member.MemberType == TenantClassMemberType.Student &&
member.Status == TenantClassMemberStatus.Active &&
grant.Status == ClassAssignmentGrantStatus.Active &&
grant.StartsAt <= now &&
(grant.EndsAt == null || grant.EndsAt > now) &&
manifest.Status == AccessManifestStatus.Published &&
product.BusinessLineId == businessLineId &&
product.IsActive
select new ManifestGrant(manifest.Id, manifest.MaxActiveTargets, manifest.MaxAlternateTargets,
grant.EndsAt, grant.Id))
.ToArrayAsync(cancellationToken);
var grants = entitlementRows.Concat(classGrantRows).DistinctBy(item => item.ManifestVersionId).ToArray();
if (grants.Length == 0)
throw Error("student_entitlement_required", "No active product or class grant covers this business.");
var manifestIds = grants.Select(item => item.ManifestVersionId).ToArray();
await ValidateManifestTargetsAsync(actor.TenantId, selections, grants, cancellationToken);
var manifestReleases = await access.ProductManifestReleases.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && manifestIds.Contains(item.ProductAccessManifestVersionId))
.ToArrayAsync(cancellationToken);
if (manifestReleases.Length == 0)
throw Error("learning_manifest_empty", "The active access manifests contain no content release.");
var releaseIds = manifestReleases.Select(item => item.ContentReleaseId).Distinct().ToArray();
var releases = new List<ReleaseFact>();
foreach (var ownerTenantId in manifestReleases.Select(item => item.ContentOwnerTenantId).Distinct())
releases.AddRange(await ReadForOwnerAsync(
ownerTenantId,
"Validate explicitly licensed V2 content releases",
async (persistence, token) => (await persistence.ContentReleases.AsNoTracking()
.Where(item => item.TenantId == ownerTenantId &&
releaseIds.Contains(item.Id) &&
item.BusinessLineId == businessLineId &&
item.Status == ContentReleaseStatus.Published)
.Select(item => new { item.TenantId, item.Id, item.ReleaseNo })
.ToArrayAsync(token))
.Select(item => new ReleaseFact(item.TenantId, item.Id, item.ReleaseNo)).ToArray(),
cancellationToken));
var validReleaseIds = manifestReleases
.Where(link => releases.Any(release =>
release.TenantId == link.ContentOwnerTenantId && release.Id == link.ContentReleaseId))
.Select(link => link.ContentReleaseId)
.Distinct()
.ToArray();
if (validReleaseIds.Length != releaseIds.Length)
throw Error("learning_manifest_release_invalid",
"An access manifest references a missing, unpublished, or cross-business release.");
var segmentIds = new List<Guid>();
foreach (var ownerTenantId in manifestReleases.Select(item => item.ContentOwnerTenantId).Distinct())
segmentIds.AddRange(await ReadForOwnerAsync(
ownerTenantId,
"Compile V2 audience segments for explicitly licensed releases",
(persistence, token) => persistence.AudienceSegmentMembers.AsNoTracking()
.Where(item => item.TenantId == ownerTenantId &&
validReleaseIds.Contains(item.ContentReleaseId) &&
selectedProfileIds.Contains(item.ExamTargetProfileVersionId))
.Select(item => item.AudienceSegmentId)
.Distinct()
.ToArrayAsync(token),
cancellationToken));
if (segmentIds.Count == 0)
throw Error("learning_audience_empty", "No published content matches the selected exam targets.");
var version = await access.LearningAccessVersions.AsNoTracking().SingleOrDefaultAsync(
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
cancellationToken);
var validUntil = grants.Where(item => item.EndsAt.HasValue).Select(item => item.EndsAt!.Value)
.Append(license.EndsAt ?? now.AddHours(4))
.Min();
var alternateProfileIds = selections
.Where(item => item.Role == TargetSelectionRole.Alternate)
.Select(item => item.ExamTargetProfileVersionId).Order().ToArray();
var orderedReleaseIds = validReleaseIds.Order().ToArray();
var orderedSegmentIds = segmentIds.Distinct().Order().ToArray();
var orderedManifestIds = manifestIds.Order().ToArray();
var grantVersion = version?.GrantVersion ?? 1;
var contentVersion = Math.Max(version?.ContentVersion ?? 1, license.Version);
var strongRevocationVersion = version?.StrongRevocationVersion ?? 1;
var projectionId = Guid.NewGuid();
await access.Database.ExecuteSqlInterpolatedAsync($"""
INSERT INTO effective_access_projections
(id, tenant_id, user_id, business_line_id, primary_profile_version_id,
alternate_profile_version_ids, content_release_ids, audience_segment_ids,
manifest_version_ids, grant_version, content_version, strong_revocation_version,
valid_until, compiled_at)
VALUES
({projectionId}, {actor.TenantId}, {actor.UserId}, {businessLineId},
{primary.ExamTargetProfileVersionId}, {alternateProfileIds}, {orderedReleaseIds},
{orderedSegmentIds}, {orderedManifestIds}, {grantVersion}, {contentVersion},
{strongRevocationVersion}, {validUntil}, {now})
ON CONFLICT (tenant_id, user_id, business_line_id) DO UPDATE SET
primary_profile_version_id = EXCLUDED.primary_profile_version_id,
alternate_profile_version_ids = EXCLUDED.alternate_profile_version_ids,
content_release_ids = EXCLUDED.content_release_ids,
audience_segment_ids = EXCLUDED.audience_segment_ids,
manifest_version_ids = EXCLUDED.manifest_version_ids,
grant_version = EXCLUDED.grant_version,
content_version = EXCLUDED.content_version,
strong_revocation_version = EXCLUDED.strong_revocation_version,
valid_until = EXCLUDED.valid_until,
compiled_at = EXCLUDED.compiled_at
""", cancellationToken);
return new EffectiveLearningAccessSnapshot(
actor.TenantId,
actor.UserId,
businessLineId,
primary.ExamTargetProfileVersionId,
alternateProfileIds.ToHashSet(),
orderedReleaseIds.ToHashSet(),
orderedSegmentIds.ToHashSet(),
orderedManifestIds.ToHashSet(),
grantVersion,
contentVersion,
strongRevocationVersion,
validUntil,
now);
}
private async Task ValidateManifestTargetsAsync(
Guid tenantId,
IReadOnlyCollection<StudentTargetSelectionHistory> selections,
IReadOnlyCollection<ManifestGrant> grants,
CancellationToken cancellationToken)
{
var manifestIds = grants.Select(item => item.ManifestVersionId).ToArray();
var targetRows = await access.ProductManifestTargets.AsNoTracking()
.Where(item => item.TenantId == tenantId &&
manifestIds.Contains(item.ProductAccessManifestVersionId))
.ToArrayAsync(cancellationToken);
foreach (var selection in selections)
{
var permitted = targetRows.Any(target =>
target.ExamTargetProfileVersionId == selection.ExamTargetProfileVersionId &&
(selection.Role == TargetSelectionRole.Primary ? target.MayBePrimary : target.MayBeAlternate));
if (!permitted)
throw Error("student_target_not_entitled",
"One or more selected exam targets are not covered by an active manifest.");
}
var activeLimit = grants.Max(item => item.MaxActiveTargets);
var alternateLimit = grants.Max(item => item.MaxAlternateTargets);
if (selections.Count > activeLimit || selections.Count(item => item.Role == TargetSelectionRole.Alternate) > alternateLimit)
throw Error("student_target_product_limit_exceeded",
"The selected primary and alternate targets exceed the product limit.");
}
private static LearningAccessException 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(EffectiveLearningAccessService),
reason,
Guid.NewGuid().ToString("N")),
(provider, token) => read(provider.GetRequiredService<IQuestionBankPersistence>(), token),
cancellationToken);
}
internal static string CacheKey(Guid tenantId, Guid userId, Guid businessLineId) =>
$"learning-access:v2:{tenantId:N}:{userId:N}:{businessLineId:N}";
private sealed record ManifestGrant(
Guid ManifestVersionId,
int MaxActiveTargets,
int MaxAlternateTargets,
DateTimeOffset? EndsAt,
Guid? ClassAssignmentGrantId);
private sealed record ReleaseFact(Guid TenantId, Guid Id, int ReleaseNo);
}