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

494 lines
24 KiB
C#

using System.Data;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Learning;
using Tiku.Domain.Content;
using Tiku.Domain.Learning;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Learning;
internal sealed class StudentLearningTargetService(
ILearningAccessPersistence access,
IQuestionBankPersistence content,
IEffectiveLearningAccessService effectiveAccess) : IStudentLearningTargetService
{
public async Task<LearningContextItem> GetContextAsync(
LearningActor actor,
CancellationToken cancellationToken = default)
{
await EnsureTenantActiveAsync(actor.TenantId, cancellationToken);
var now = DateTimeOffset.UtcNow;
var licenses = await (
from license in access.TenantBusinessLicenses.AsNoTracking()
join business in access.BusinessLines.AsNoTracking() on license.BusinessLineId equals business.Id
where license.TenantId == actor.TenantId &&
license.Status == LicenseLifecycleStatus.Active &&
license.StartsAt <= now &&
(license.EndsAt == null || license.EndsAt > now) &&
business.IsActive
orderby business.Name, business.Code
select new { License = license, Business = business })
.ToArrayAsync(cancellationToken);
var items = new List<LearningBusinessContextItem>(licenses.Length);
foreach (var row in licenses)
{
var policy = await GetPolicyAsync(row.Business.Id, cancellationToken);
var selections = await CurrentSelectionsAsync(actor, row.Business.Id, cancellationToken);
items.Add(new LearningBusinessContextItem(
row.Business.Id,
row.Business.Code,
row.Business.Name,
row.License.Id,
row.License.EndsAt,
selections.SingleOrDefault(item => item.Role == TargetSelectionRole.Primary)
?.ExamTargetProfileVersionId,
selections.Where(item => item.Role == TargetSelectionRole.Alternate)
.Select(item => item.ExamTargetProfileVersionId).Order().ToArray(),
policy.MaxActiveTargets,
policy.MaxAlternateTargets,
policy.TargetChangeCooldownDays));
}
return new LearningContextItem(items);
}
public async Task<IReadOnlyCollection<LearningTargetItem>> GetTargetsAsync(
LearningActor actor,
Guid businessLineId,
CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var license = await RequireLicenseAsync(actor.TenantId, businessLineId, now, cancellationToken);
var selections = await CurrentSelectionsAsync(actor, businessLineId, cancellationToken);
var selectedIds = selections.Select(item => item.ExamTargetProfileVersionId).ToHashSet();
var targetAccess = await LoadTargetAccessAsync(actor, businessLineId, now, cancellationToken);
var manifestTargets = targetAccess.Targets;
var targetRoles = manifestTargets
.GroupBy(item => item.ExamTargetProfileVersionId)
.ToDictionary(group => group.Key, group => new
{
Primary = group.Any(item => item.MayBePrimary),
Alternate = group.Any(item => item.MayBeAlternate)
});
var licensed = await access.TenantLicensedTargets.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId &&
item.TenantBusinessLicenseId == license.Id &&
item.StartsAt <= now &&
(item.EndsAt == null || item.EndsAt > now))
.ToArrayAsync(cancellationToken);
var profileIds = licensed.Select(item => item.ExamTargetProfileVersionId).Distinct().ToArray();
var profiles = await (
from version in content.ExamTargetProfileVersions.AsNoTracking()
join profile in content.ExamTargetProfiles.AsNoTracking()
on version.ExamTargetProfileId equals profile.Id
where profileIds.Contains(version.Id) &&
profile.BusinessLineId == businessLineId &&
profile.IsActive &&
version.Status == ContentDefinitionStatus.Published
select new { Version = version, Profile = profile })
.ToArrayAsync(cancellationToken);
return profiles.Select(row =>
{
targetRoles.TryGetValue(row.Version.Id, out var role);
var selection = selections.SingleOrDefault(item => item.ExamTargetProfileVersionId == row.Version.Id);
return new LearningTargetItem(
row.Version.Id,
row.Profile.Code,
row.Version.DisplayName,
row.Version.ExamYear,
licensed.Single(item => item.ExamTargetProfileVersionId == row.Version.Id).IsBaseTarget,
role?.Primary == true,
role?.Alternate == true,
selection?.Role == TargetSelectionRole.Primary,
selection?.Role == TargetSelectionRole.Alternate);
}).Where(item => selectedIds.Contains(item.ExamTargetProfileVersionId) ||
item.MayBePrimary || item.MayBeAlternate)
.OrderByDescending(item => item.ExamYear)
.ThenBy(item => item.DisplayName)
.ToArray();
}
public async Task<LearningTargetSelectionItem> ChangeTargetsAsync(
LearningActor actor,
Guid businessLineId,
ChangeLearningTargetsCommand command,
CancellationToken cancellationToken = default)
{
return await ChangeTargetsCoreAsync(
actor,
businessLineId,
command,
actor.UserId,
"student_self_service",
true,
cancellationToken);
}
public async Task<LearningTargetSelectionItem> OverrideTargetsAsync(
LearningActor student,
Guid businessLineId,
ChangeLearningTargetsCommand command,
Guid changedBy,
string reason,
CancellationToken cancellationToken = default)
{
if (changedBy == Guid.Empty || string.IsNullOrWhiteSpace(reason))
throw Error("student_target_override_reason_required",
"An administrator and a non-empty reason are required for a target override.");
return await ChangeTargetsCoreAsync(
student,
businessLineId,
command,
changedBy,
reason.Trim(),
false,
cancellationToken);
}
private async Task<LearningTargetSelectionItem> ChangeTargetsCoreAsync(
LearningActor actor,
Guid businessLineId,
ChangeLearningTargetsCommand command,
Guid changedBy,
string reason,
bool enforceCooldown,
CancellationToken cancellationToken)
{
var alternateIds = command.AlternateProfileVersionIds.Distinct().ToArray();
if (alternateIds.Length != command.AlternateProfileVersionIds.Count ||
alternateIds.Contains(command.PrimaryProfileVersionId))
throw Error("student_target_selection_duplicate", "Primary and alternate targets must be unique.");
var now = DateTimeOffset.UtcNow;
await using var transaction = await access.Database.BeginTransactionAsync(
IsolationLevel.ReadCommitted,
cancellationToken);
var lockKey = $"student-target:{actor.TenantId:N}:{actor.UserId:N}:{businessLineId:N}";
await access.Database.ExecuteSqlInterpolatedAsync(
$"SELECT pg_advisory_xact_lock(hashtextextended({lockKey}, 0))",
cancellationToken);
var license = await RequireLicenseAsync(actor.TenantId, businessLineId, now, cancellationToken);
var policy = await GetPolicyAsync(businessLineId, cancellationToken);
if (policy.MaxPrimaryTargets < 1 ||
1 + alternateIds.Length > Math.Min(16, policy.MaxActiveTargets) ||
alternateIds.Length > policy.MaxAlternateTargets)
throw Error("student_target_policy_limit_exceeded",
"The requested primary and alternate targets exceed the business policy.");
var requestedIds = alternateIds.Append(command.PrimaryProfileVersionId).ToArray();
var licensedIds = await access.TenantLicensedTargets.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId &&
item.TenantBusinessLicenseId == license.Id &&
item.StartsAt <= now &&
(item.EndsAt == null || item.EndsAt > now) &&
requestedIds.Contains(item.ExamTargetProfileVersionId))
.Select(item => item.ExamTargetProfileVersionId)
.Distinct()
.ToArrayAsync(cancellationToken);
if (licensedIds.Length != requestedIds.Length)
throw Error("student_target_not_licensed", "One or more requested targets are not tenant licensed.");
var targetAccess = await LoadTargetAccessAsync(actor, businessLineId, now, cancellationToken);
var entitledTargets = targetAccess.Targets;
if (1 + alternateIds.Length > targetAccess.MaxActiveTargets ||
alternateIds.Length > targetAccess.MaxAlternateTargets)
throw Error("student_target_product_limit_exceeded",
"The requested primary and alternate targets exceed the active product limit.");
if (!entitledTargets.Any(item => item.ExamTargetProfileVersionId == command.PrimaryProfileVersionId &&
item.MayBePrimary) ||
alternateIds.Any(id => !entitledTargets.Any(item =>
item.ExamTargetProfileVersionId == id && item.MayBeAlternate)))
throw Error("student_target_not_entitled", "One or more requested targets are not product entitled.");
var current = await access.StudentTargetSelectionHistory
.Where(item => item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.BusinessLineId == businessLineId &&
item.IsCurrent)
.ToArrayAsync(cancellationToken);
if (SameSelection(current, command.PrimaryProfileVersionId, alternateIds))
{
await transaction.CommitAsync(cancellationToken);
var changedAt = current.Max(item => item.EffectiveAt);
return Result(businessLineId, command.PrimaryProfileVersionId, alternateIds, changedAt, policy);
}
var lastChangeAt = current.Length == 0
? await access.StudentTargetSelectionHistory.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.BusinessLineId == businessLineId)
.Select(item => (DateTimeOffset?)item.EffectiveAt)
.MaxAsync(cancellationToken)
: current.Max(item => item.EffectiveAt);
var nextAllowedAt = lastChangeAt?.AddDays(policy.TargetChangeCooldownDays);
if (enforceCooldown && nextAllowedAt > now)
throw Error("student_target_change_cooldown",
$"Targets cannot be changed before {nextAllowedAt:O}.");
foreach (var selection in current)
{
selection.IsCurrent = false;
selection.EndedAt = now;
selection.UpdatedAt = now;
}
var primarySource = ResolveSelectionSource(
targetAccess, command.PrimaryProfileVersionId, TargetSelectionRole.Primary);
access.StudentTargetSelectionHistory.Add(NewSelection(
actor, businessLineId, command.PrimaryProfileVersionId, TargetSelectionRole.Primary, primarySource,
changedBy, reason, now));
foreach (var alternateId in alternateIds)
{
var alternateSource = ResolveSelectionSource(
targetAccess, alternateId, TargetSelectionRole.Alternate);
access.StudentTargetSelectionHistory.Add(NewSelection(
actor, businessLineId, alternateId, TargetSelectionRole.Alternate, alternateSource,
changedBy, reason, now));
}
var version = await access.LearningAccessVersions.SingleOrDefaultAsync(
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
cancellationToken);
version ??= new LearningAccessVersion { TenantId = actor.TenantId, UserId = actor.UserId };
if (access.Entry(version).State == EntityState.Detached) access.LearningAccessVersions.Add(version);
version.GrantVersion++;
version.UpdatedAt = now;
await access.EffectiveAccessProjections
.Where(item => item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.BusinessLineId == businessLineId)
.ExecuteDeleteAsync(cancellationToken);
await access.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
await effectiveAccess.InvalidateAsync(actor.TenantId, actor.UserId, businessLineId, cancellationToken);
return Result(businessLineId, command.PrimaryProfileVersionId, alternateIds, now, policy);
}
private async Task<TargetAccess> LoadTargetAccessAsync(
LearningActor actor,
Guid businessLineId,
DateTimeOffset now,
CancellationToken cancellationToken)
{
var entitlementManifests = 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,
"student_entitlement",
entitlement.Id))
.ToArrayAsync(cancellationToken);
var classManifests = 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,
"class_assignment_grant",
grant.Id))
.ToArrayAsync(cancellationToken);
var manifests = entitlementManifests.Concat(classManifests)
.DistinctBy(item => new { item.ManifestVersionId, item.SourceType, item.SourceId })
.ToArray();
if (manifests.Length == 0) return new TargetAccess([], [], 0, 0);
var manifestIds = manifests.Select(item => item.ManifestVersionId).ToArray();
var targets = await access.ProductManifestTargets.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && manifestIds.Contains(item.ProductAccessManifestVersionId))
.ToArrayAsync(cancellationToken);
return new TargetAccess(
targets,
manifests,
manifests.Max(item => item.MaxActiveTargets),
manifests.Max(item => item.MaxAlternateTargets));
}
private async Task<TenantBusinessLicense> RequireLicenseAsync(
Guid tenantId,
Guid businessLineId,
DateTimeOffset now,
CancellationToken cancellationToken)
{
await EnsureTenantActiveAsync(tenantId, cancellationToken);
return await access.TenantBusinessLicenses.AsNoTracking().SingleOrDefaultAsync(
item => item.TenantId == 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.");
}
private async Task EnsureTenantActiveAsync(Guid tenantId, CancellationToken cancellationToken)
{
if (!await access.Tenants.AsNoTracking().AnyAsync(
item => item.Id == tenantId && item.Status == TenantStatus.Active,
cancellationToken))
throw Error("learning_tenant_inactive", "The tenant is not active.");
}
private async Task<BusinessTargetPolicyVersion> GetPolicyAsync(
Guid businessLineId,
CancellationToken cancellationToken)
{
return await (
from policy in content.BusinessTargetPolicies.AsNoTracking()
join version in content.BusinessTargetPolicyVersions.AsNoTracking()
on new { PolicyId = policy.Id, VersionId = policy.CurrentVersionId }
equals new { PolicyId = version.BusinessTargetPolicyId, VersionId = (Guid?)version.Id }
where policy.BusinessLineId == businessLineId &&
policy.IsActive &&
version.Status == ContentDefinitionStatus.Published
select version)
.SingleOrDefaultAsync(cancellationToken) ?? throw Error("business_target_policy_required",
"The business has no published target policy.");
}
private Task<StudentTargetSelectionHistory[]> CurrentSelectionsAsync(
LearningActor actor,
Guid businessLineId,
CancellationToken cancellationToken)
{
return access.StudentTargetSelectionHistory.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.BusinessLineId == businessLineId &&
item.IsCurrent)
.ToArrayAsync(cancellationToken);
}
private static bool SameSelection(
IReadOnlyCollection<StudentTargetSelectionHistory> current,
Guid primaryId,
IReadOnlyCollection<Guid> alternateIds)
{
return current.Count(item => item.Role == TargetSelectionRole.Primary) == 1 &&
current.Single(item => item.Role == TargetSelectionRole.Primary).ExamTargetProfileVersionId == primaryId &&
current.Where(item => item.Role == TargetSelectionRole.Alternate)
.Select(item => item.ExamTargetProfileVersionId).ToHashSet().SetEquals(alternateIds);
}
private static StudentTargetSelectionHistory NewSelection(
LearningActor actor,
Guid businessLineId,
Guid profileVersionId,
TargetSelectionRole role,
SelectionSource source,
Guid changedBy,
string reason,
DateTimeOffset now)
{
return new StudentTargetSelectionHistory
{
TenantId = actor.TenantId,
UserId = actor.UserId,
BusinessLineId = businessLineId,
ExamTargetProfileVersionId = profileVersionId,
Role = role,
EntitlementId = source.SourceType == "student_entitlement" ? source.SourceId : null,
SourceType = source.SourceType,
SourceId = source.SourceId,
EffectiveAt = now,
IsCurrent = true,
ChangedBy = changedBy,
Reason = reason
};
}
private static LearningTargetSelectionItem Result(
Guid businessLineId,
Guid primaryId,
IReadOnlyCollection<Guid> alternateIds,
DateTimeOffset changedAt,
BusinessTargetPolicyVersion policy)
{
return new LearningTargetSelectionItem(
businessLineId,
primaryId,
alternateIds.Order().ToArray(),
changedAt,
policy.TargetChangeCooldownDays == 0 ? changedAt : changedAt.AddDays(policy.TargetChangeCooldownDays));
}
private static LearningAccessException Error(string code, string message) => new(code, message);
private static SelectionSource ResolveSelectionSource(
TargetAccess access,
Guid profileVersionId,
TargetSelectionRole role)
{
var manifestIds = access.Targets
.Where(item => item.ExamTargetProfileVersionId == profileVersionId &&
(role == TargetSelectionRole.Primary ? item.MayBePrimary : item.MayBeAlternate))
.Select(item => item.ProductAccessManifestVersionId)
.ToHashSet();
var grant = access.Grants
.Where(item => manifestIds.Contains(item.ManifestVersionId))
.OrderBy(item => item.SourceType == "student_entitlement" ? 0 : 1)
.ThenBy(item => item.SourceId)
.FirstOrDefault() ?? throw Error(
"student_target_source_missing",
"The selected target has no auditable active entitlement source.");
return new SelectionSource(grant.SourceType, grant.SourceId);
}
private sealed record ManifestGrant(
Guid ManifestVersionId,
int MaxActiveTargets,
int MaxAlternateTargets,
string SourceType,
Guid SourceId);
private sealed record SelectionSource(string SourceType, Guid SourceId);
private sealed record TargetAccess(
ProductManifestTarget[] Targets,
ManifestGrant[] Grants,
int MaxActiveTargets,
int MaxAlternateTargets);
}