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

263 lines
12 KiB
C#

using System.Data;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Learning;
using Tiku.Domain.Learning;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Learning;
internal sealed class V2LearningAccessAdministrationService(
ILearningAccessPersistence access,
IEffectiveLearningAccessService effectiveAccess) : IV2LearningAccessAdministrationService
{
public async Task<IReadOnlyCollection<ClassAssignmentGrantItem>> GetClassGrantsAsync(
Guid tenantId,
Guid classId,
CancellationToken cancellationToken = default)
{
await RequireClassAsync(tenantId, classId, cancellationToken);
var items = await access.ClassAssignmentGrants.AsNoTracking()
.Where(item => item.TenantId == tenantId && item.ClassId == classId)
.OrderByDescending(item => item.CreatedAt)
.ToArrayAsync(cancellationToken);
return items.Select(ToItem).ToArray();
}
public async Task<ClassAssignmentGrantItem> UpsertClassGrantAsync(
Guid tenantId,
Guid actorUserId,
UpsertClassAssignmentGrantCommand command,
CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var startsAt = command.StartsAt ?? now;
if (command.EndsAt <= startsAt)
throw Error("class_assignment_period_invalid", "The assignment end must be after its start.");
await using var transaction = await access.Database.BeginTransactionAsync(
IsolationLevel.ReadCommitted,
cancellationToken);
await RequireClassAsync(tenantId, command.ClassId, cancellationToken);
var businessLineId = await EnsureManifestResourceLicensedAsync(tenantId, command, now, cancellationToken);
ClassAssignmentGrant item;
if (command.Id.HasValue)
{
item = await access.ClassAssignmentGrants.SingleOrDefaultAsync(value =>
value.TenantId == tenantId && value.Id == command.Id.Value,
cancellationToken)
?? throw Error("class_assignment_not_found", "The class assignment was not found.");
if (item.Status != ClassAssignmentGrantStatus.Active)
throw Error("class_assignment_not_active", "Only an active class assignment can be changed.");
if (item.ClassId != command.ClassId)
throw Error("class_assignment_class_immutable", "The assignment class cannot be changed.");
}
else
{
item = new ClassAssignmentGrant
{
TenantId = tenantId,
ClassId = command.ClassId,
CreatedBy = actorUserId
};
access.ClassAssignmentGrants.Add(item);
}
item.ProductAccessManifestVersionId = command.ProductAccessManifestVersionId;
item.ResourceType = command.ResourceType;
item.ResourceId = command.ResourceId;
item.StartsAt = startsAt;
item.EndsAt = command.EndsAt;
item.UpdatedAt = now;
await access.SaveChangesAsync(cancellationToken);
var affectedUsers = await BumpAffectedStudentsAsync(tenantId, command.ClassId, now, cancellationToken);
await transaction.CommitAsync(cancellationToken);
await InvalidateAsync(tenantId, affectedUsers, businessLineId, cancellationToken);
return ToItem(item);
}
public async Task<ClassAssignmentGrantItem> RevokeClassGrantAsync(
Guid tenantId,
Guid actorUserId,
Guid classId,
Guid grantId,
string reason,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(reason))
throw Error("class_assignment_revoke_reason_required", "A revoke reason is required.");
var now = DateTimeOffset.UtcNow;
await using var transaction = await access.Database.BeginTransactionAsync(cancellationToken);
var item = await access.ClassAssignmentGrants.SingleOrDefaultAsync(value =>
value.TenantId == tenantId && value.ClassId == classId && value.Id == grantId,
cancellationToken)
?? throw Error("class_assignment_not_found", "The class assignment was not found.");
var businessLineId = await ResolveManifestBusinessLineAsync(
tenantId, item.ProductAccessManifestVersionId, cancellationToken);
if (item.Status == ClassAssignmentGrantStatus.Revoked)
{
await transaction.CommitAsync(cancellationToken);
return ToItem(item);
}
item.Status = ClassAssignmentGrantStatus.Revoked;
item.RevokedBy = actorUserId;
item.RevokedReason = reason.Trim();
item.UpdatedAt = now;
await access.SaveChangesAsync(cancellationToken);
var affectedUsers = await BumpAffectedStudentsAsync(tenantId, item.ClassId, now, cancellationToken);
await transaction.CommitAsync(cancellationToken);
await InvalidateAsync(tenantId, affectedUsers, businessLineId, cancellationToken);
return ToItem(item);
}
private async Task<Guid> EnsureManifestResourceLicensedAsync(
Guid tenantId,
UpsertClassAssignmentGrantCommand command,
DateTimeOffset now,
CancellationToken cancellationToken)
{
var manifest = await (
from version in access.ProductAccessManifestVersions.AsNoTracking()
join definition in access.ProductAccessManifests.AsNoTracking()
on new { version.TenantId, Id = version.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 }
join resource in access.ProductManifestResources.AsNoTracking()
on new { version.TenantId, VersionId = version.Id }
equals new { resource.TenantId, VersionId = resource.ProductAccessManifestVersionId }
where version.TenantId == tenantId &&
version.Id == command.ProductAccessManifestVersionId &&
version.Status == AccessManifestStatus.Published &&
definition.IsActive && product.IsActive &&
resource.ResourceType == command.ResourceType &&
resource.ResourceId == command.ResourceId
select new { product.BusinessLineId })
.SingleOrDefaultAsync(cancellationToken)
?? throw Error("class_assignment_manifest_resource_invalid",
"The resource is not present in an active published manifest.");
var license = await access.TenantBusinessLicenses.AsNoTracking().SingleOrDefaultAsync(item =>
item.TenantId == tenantId &&
item.BusinessLineId == manifest.BusinessLineId &&
item.Status == LicenseLifecycleStatus.Active &&
item.StartsAt <= now &&
(item.EndsAt == null || item.EndsAt > now), cancellationToken)
?? throw Error("class_assignment_tenant_license_required",
"The tenant is not licensed for the manifest business.");
var manifestTargetIds = await access.ProductManifestTargets.AsNoTracking()
.Where(item => item.TenantId == tenantId &&
item.ProductAccessManifestVersionId == command.ProductAccessManifestVersionId)
.Select(item => item.ExamTargetProfileVersionId)
.Distinct()
.ToArrayAsync(cancellationToken);
var licensedTargetCount = await access.TenantLicensedTargets.AsNoTracking()
.Where(item => item.TenantId == tenantId &&
item.TenantBusinessLicenseId == license.Id &&
item.StartsAt <= now &&
(item.EndsAt == null || item.EndsAt > now) &&
manifestTargetIds.Contains(item.ExamTargetProfileVersionId))
.Select(item => item.ExamTargetProfileVersionId)
.Distinct()
.CountAsync(cancellationToken);
if (licensedTargetCount != manifestTargetIds.Length)
throw Error("class_assignment_target_not_licensed",
"The class assignment manifest exceeds the tenant licensed targets.");
return manifest.BusinessLineId;
}
private async Task<Guid[]> BumpAffectedStudentsAsync(
Guid tenantId,
Guid classId,
DateTimeOffset now,
CancellationToken cancellationToken)
{
var userIds = await access.TenantClassMembers.AsNoTracking()
.Where(item => item.TenantId == tenantId &&
item.ClassId == classId &&
item.MemberType == TenantClassMemberType.Student &&
item.Status == TenantClassMemberStatus.Active)
.Select(item => item.UserId)
.Distinct()
.ToArrayAsync(cancellationToken);
if (userIds.Length == 0) return userIds;
var versions = await access.LearningAccessVersions
.Where(item => item.TenantId == tenantId && userIds.Contains(item.UserId))
.ToDictionaryAsync(item => item.UserId, cancellationToken);
foreach (var userId in userIds)
{
if (!versions.TryGetValue(userId, out var version))
{
version = new LearningAccessVersion { TenantId = tenantId, UserId = userId };
access.LearningAccessVersions.Add(version);
}
version.GrantVersion++;
version.UpdatedAt = now;
}
await access.EffectiveAccessProjections
.Where(item => item.TenantId == tenantId && userIds.Contains(item.UserId))
.ExecuteDeleteAsync(cancellationToken);
await access.SaveChangesAsync(cancellationToken);
return userIds;
}
private async Task InvalidateAsync(
Guid tenantId,
IEnumerable<Guid> userIds,
Guid businessLineId,
CancellationToken cancellationToken)
{
foreach (var userId in userIds)
await effectiveAccess.InvalidateAsync(tenantId, userId, businessLineId, cancellationToken);
}
private async Task<Guid> ResolveManifestBusinessLineAsync(
Guid tenantId,
Guid manifestVersionId,
CancellationToken cancellationToken)
{
return await (
from version in access.ProductAccessManifestVersions.AsNoTracking()
join definition in access.ProductAccessManifests.AsNoTracking()
on new { version.TenantId, Id = version.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 version.TenantId == tenantId && version.Id == manifestVersionId
select product.BusinessLineId)
.SingleAsync(cancellationToken);
}
private async Task RequireClassAsync(Guid tenantId, Guid classId, CancellationToken cancellationToken)
{
if (!await access.TenantClasses.AsNoTracking().AnyAsync(
item => item.TenantId == tenantId && item.Id == classId,
cancellationToken))
throw Error("class_not_found", "The class was not found.");
}
private static ClassAssignmentGrantItem ToItem(ClassAssignmentGrant item) => new(
item.Id,
item.ClassId,
item.ProductAccessManifestVersionId,
item.ResourceType,
item.ResourceId,
item.StartsAt,
item.EndsAt,
item.Status,
item.CreatedBy,
item.RevokedBy,
item.RevokedReason);
private static LearningAccessException Error(string code, string message) => new(code, message);
}