feat(learning): harden access and practice sessions
This commit is contained in:
@@ -84,6 +84,8 @@ public sealed class AssetQueryDto
|
||||
[Range(1, 500)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
internal IReadOnlyCollection<Guid>? AllowedRegionIds { get; set; }
|
||||
|
||||
public AssetFilter ToFilter(Guid tenantId)
|
||||
{
|
||||
return new AssetFilter(
|
||||
@@ -100,6 +102,7 @@ public sealed class AssetQueryDto
|
||||
Keyword,
|
||||
IncludeLocked,
|
||||
IncludeInactive,
|
||||
Limit);
|
||||
Limit,
|
||||
AllowedRegionIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,8 @@ public sealed class CatalogQueryDto
|
||||
[Range(1, 2000)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
internal IReadOnlyCollection<Guid>? AllowedRegionIds { get; set; }
|
||||
|
||||
public CatalogFilter ToFilter(Guid tenantId)
|
||||
{
|
||||
var parentIsRoot = string.Equals(ParentId, "root", StringComparison.OrdinalIgnoreCase);
|
||||
@@ -88,6 +90,7 @@ public sealed class CatalogQueryDto
|
||||
NodeId,
|
||||
Keyword,
|
||||
Type,
|
||||
Limit);
|
||||
Limit,
|
||||
AllowedRegionIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
44
Tiku.Api/Contracts/LearningAccessDtos.cs
Normal file
44
Tiku.Api/Contracts/LearningAccessDtos.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Domain.Learning;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
public sealed class ChangeStudentTargetRegionDto
|
||||
{
|
||||
public Guid MarketRegionId { get; set; }
|
||||
|
||||
public ChangeStudentTargetRegionCommand ToCommand() => new(MarketRegionId);
|
||||
}
|
||||
|
||||
public sealed class OverrideStudentTargetRegionDto
|
||||
{
|
||||
public Guid MarketRegionId { get; set; }
|
||||
|
||||
[Required]
|
||||
[StringLength(1000, MinimumLength = 1)]
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
|
||||
public OverrideStudentTargetRegionCommand ToCommand(Guid userId, Guid changedBy) =>
|
||||
new(userId, MarketRegionId, changedBy, Reason);
|
||||
}
|
||||
|
||||
public sealed class UpsertClassContentAssignmentDto
|
||||
{
|
||||
public Guid? Id { get; set; }
|
||||
public Guid ClassId { get; set; }
|
||||
public Guid ContentSliceId { get; set; }
|
||||
public LearningContentResourceType ResourceType { get; set; }
|
||||
public Guid ResourceId { get; set; }
|
||||
public DateTimeOffset? StartsAt { get; set; }
|
||||
public DateTimeOffset? EndsAt { get; set; }
|
||||
|
||||
public UpsertClassContentAssignmentCommand ToCommand() => new(
|
||||
Id, ClassId, ContentSliceId, ResourceType, ResourceId, StartsAt, EndsAt);
|
||||
}
|
||||
|
||||
public sealed class RevokeClassContentAssignmentDto
|
||||
{
|
||||
[StringLength(1000)]
|
||||
public string? Reason { get; set; }
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
@@ -195,13 +196,6 @@ public sealed class SubmitAnswerDto
|
||||
[Required]
|
||||
public Guid SessionQuestionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客户端读取会话时获得的版本。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Range(1, long.MaxValue)]
|
||||
public long ExpectedSessionVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客户端在本会话内单调递增的序列。
|
||||
/// </summary>
|
||||
@@ -231,7 +225,6 @@ public sealed class SubmitAnswerDto
|
||||
{
|
||||
return new SubmitAnswerCommand(
|
||||
SessionQuestionId,
|
||||
ExpectedSessionVersion,
|
||||
ClientSequence,
|
||||
IdempotencyKey,
|
||||
SelectedOptionIndices,
|
||||
@@ -366,4 +359,4 @@ public sealed class WordReviewDto
|
||||
{
|
||||
return new WordReviewCommand(WordId, Result, NextReviewAt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
71
Tiku.Api/Contracts/PlatformLearningAccessDtos.cs
Normal file
71
Tiku.Api/Contracts/PlatformLearningAccessDtos.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Domain.Learning;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
public sealed class UpsertBusinessLineDto
|
||||
{
|
||||
public Guid? Id { get; set; }
|
||||
[Required, StringLength(50)] public string Code { get; set; } = string.Empty;
|
||||
[Required, StringLength(100)] public string Name { get; set; } = string.Empty;
|
||||
public LearningRegionAccessStrategy RegionAccessStrategy { get; set; } =
|
||||
LearningRegionAccessStrategy.LicensedRegions;
|
||||
public bool RequiresBaseRegion { get; set; }
|
||||
[Range(0, 365)] public int TargetRegionCooldownDays { get; set; } = 30;
|
||||
public bool IsActive { get; set; } = true;
|
||||
public UpsertBusinessLineCommand ToCommand() => new(
|
||||
Id, Code, Name, RegionAccessStrategy, RequiresBaseRegion, TargetRegionCooldownDays, IsActive);
|
||||
}
|
||||
|
||||
public sealed class UpsertMarketRegionDto
|
||||
{
|
||||
public Guid? Id { get; set; }
|
||||
[Required, StringLength(50)] public string Code { get; set; } = string.Empty;
|
||||
[Required, StringLength(100)] public string Name { get; set; } = string.Empty;
|
||||
[StringLength(50)] public string? ParentCode { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
public UpsertMarketRegionCommand ToCommand() => new(Id, Code, Name, ParentCode, IsActive);
|
||||
}
|
||||
|
||||
public sealed class UpsertTenantLearningLicenseDto
|
||||
{
|
||||
public Guid? Id { get; set; }
|
||||
public Guid BusinessLineId { get; set; }
|
||||
public bool IsPrimary { get; set; } = true;
|
||||
public bool IncludesNational { get; set; }
|
||||
public bool AllowsAnyTargetRegion { get; set; }
|
||||
public LearningLicenseStatus Status { get; set; } = LearningLicenseStatus.Active;
|
||||
public DateTimeOffset StartsAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset? EndsAt { get; set; }
|
||||
public IReadOnlyCollection<Guid> MarketRegionIds { get; set; } = [];
|
||||
public Guid? BaseMarketRegionId { get; set; }
|
||||
public UpsertTenantLearningLicenseCommand ToCommand(Guid tenantId) => new(
|
||||
tenantId, Id, BusinessLineId, IsPrimary, IncludesNational, AllowsAnyTargetRegion,
|
||||
Status, StartsAt, EndsAt, MarketRegionIds, BaseMarketRegionId);
|
||||
}
|
||||
|
||||
public sealed class UpsertContentSliceDto
|
||||
{
|
||||
public Guid? Id { get; set; }
|
||||
public Guid BusinessLineId { get; set; }
|
||||
public Guid? MarketRegionId { get; set; }
|
||||
public LearningRegionScopeKind RegionScope { get; set; }
|
||||
public LearningContentResourceType ResourceType { get; set; }
|
||||
public Guid ResourceId { get; set; }
|
||||
public ContentSliceStatus Status { get; set; } = ContentSliceStatus.Active;
|
||||
public UpsertContentSliceCommand ToCommand(Guid tenantId) => new(
|
||||
tenantId, Id, BusinessLineId, MarketRegionId, RegionScope, ResourceType, ResourceId, Status);
|
||||
}
|
||||
|
||||
public sealed class UpsertLearningProductDto
|
||||
{
|
||||
public Guid? Id { get; set; }
|
||||
public Guid BusinessLineId { get; set; }
|
||||
[Required, StringLength(100)] public string Code { get; set; } = string.Empty;
|
||||
[Required, StringLength(200)] public string Name { get; set; } = string.Empty;
|
||||
public bool IsActive { get; set; } = true;
|
||||
public IReadOnlyCollection<Guid> ContentSliceIds { get; set; } = [];
|
||||
public UpsertLearningProductCommand ToCommand(Guid tenantId) => new(
|
||||
tenantId, Id, BusinessLineId, Code, Name, IsActive, ContentSliceIds);
|
||||
}
|
||||
@@ -61,6 +61,8 @@ public sealed class StudyContentQueryDto
|
||||
[Range(1, 2000)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
internal IReadOnlyCollection<Guid>? AllowedRegionIds { get; set; }
|
||||
|
||||
public StudyContentFilter ToFilter(Guid tenantId)
|
||||
{
|
||||
return new StudyContentFilter(
|
||||
@@ -73,6 +75,7 @@ public sealed class StudyContentQueryDto
|
||||
ContentNodeId,
|
||||
Keyword,
|
||||
IncludeContent,
|
||||
Limit);
|
||||
Limit,
|
||||
AllowedRegionIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Api.Security;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
@@ -9,7 +10,7 @@ namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("学生端-资源访问")]
|
||||
[AllowAnonymous]
|
||||
[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/student/assets")]
|
||||
public sealed class AssetsController(
|
||||
@@ -81,4 +82,4 @@ public sealed class AssetsController(
|
||||
var tenant = await tenantDirectory.FindByCodeAsync(resolvedTenantCode, cancellationToken);
|
||||
return tenant?.TenantId ?? throw new TenantNotFoundException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.OutputCaching;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Api.Security;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.StudyContent;
|
||||
using Tiku.Application.Tenancy;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("学生端-公开目录")]
|
||||
[AllowAnonymous]
|
||||
[Tags("学生端-认证目录")]
|
||||
[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/public/catalog")]
|
||||
[OutputCache(PolicyName = "TenantPublic")]
|
||||
[Route("api/student/catalog")]
|
||||
public sealed class CatalogController(
|
||||
ICatalogQueryService catalogQueryService,
|
||||
IContentNavigationQueryService contentNavigationQueryService,
|
||||
@@ -26,7 +24,8 @@ public sealed class CatalogController(
|
||||
IStudyContentQueryService studyContentQueryService,
|
||||
IAssetQueryService assetQueryService,
|
||||
ITenantContext currentTenant,
|
||||
ITenantDirectory tenantDirectory) : ControllerBase
|
||||
ILearningAccessService learningAccessService,
|
||||
LearningActorResolver learningActorResolver) : ControllerBase
|
||||
{
|
||||
[HttpGet("regions")]
|
||||
[EndpointSummary("查询可用地区")]
|
||||
@@ -129,7 +128,7 @@ public sealed class CatalogController(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await contentNavigationQueryService.GetContentEntriesAsync(
|
||||
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
|
||||
await ToAuthorizedFilterAsync(query, cancellationToken),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
@@ -143,7 +142,7 @@ public sealed class CatalogController(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await contentNavigationQueryService.GetContentNodesAsync(
|
||||
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
|
||||
await ToAuthorizedFilterAsync(query, cancellationToken),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
@@ -157,7 +156,7 @@ public sealed class CatalogController(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await contentNavigationQueryService.GetQuestionCollectionsAsync(
|
||||
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
|
||||
await ToAuthorizedFilterAsync(query, cancellationToken),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
@@ -172,7 +171,7 @@ public sealed class CatalogController(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await contentNavigationQueryService.GetCollectionQuestionsAsync(
|
||||
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
|
||||
await ToAuthorizedFilterAsync(query, cancellationToken),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
@@ -186,7 +185,7 @@ public sealed class CatalogController(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await contentNavigationQueryService.GetPracticeBlueprintsAsync(
|
||||
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
|
||||
await ToAuthorizedFilterAsync(query, cancellationToken),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
@@ -200,7 +199,7 @@ public sealed class CatalogController(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await questionBankQueryService.GetQuestionBanksAsync(
|
||||
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
|
||||
await ToAuthorizedFilterAsync(query, null, cancellationToken),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
@@ -215,7 +214,7 @@ public sealed class CatalogController(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await questionBankQueryService.GetQuestionsAsync(
|
||||
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
|
||||
await ToAuthorizedFilterAsync(query, null, cancellationToken),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
@@ -230,22 +229,7 @@ public sealed class CatalogController(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await questionBankQueryService.GetQuestionAsync(
|
||||
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken), questionId),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("questions/{questionId:guid}/versions")]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.Practice)]
|
||||
[EndpointSummary("查询题目版本")]
|
||||
[ProducesResponseType<CatalogList<QuestionVersionCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<CatalogList<QuestionVersionCatalogItem>>> GetQuestionVersions(
|
||||
Guid questionId,
|
||||
[FromQuery] QuestionBankQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await questionBankQueryService.GetQuestionVersionsAsync(
|
||||
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken), questionId),
|
||||
await ToAuthorizedFilterAsync(query, questionId, cancellationToken),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
@@ -474,13 +458,52 @@ public sealed class CatalogController(
|
||||
CatalogQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentTenant.TenantId.HasValue) return currentTenant.TenantId.Value;
|
||||
var actor = learningActorResolver.Resolve();
|
||||
var snapshot = await learningAccessService.GetSnapshotAsync(actor, cancellationToken);
|
||||
query.RegionId = ResolveAuthorizedRegion(query.RegionId, snapshot);
|
||||
query.AllowedRegionIds = snapshot.LicensedRegionIds.ToArray();
|
||||
return currentTenant.TenantId ?? throw new TenantNotFoundException();
|
||||
}
|
||||
|
||||
var tenantCode = query.TenantCode ?? Request.Headers["x-tenant-code"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(tenantCode)) throw new TenantNotFoundException();
|
||||
private async Task<ContentNavigationFilter> ToAuthorizedFilterAsync(
|
||||
ContentNavigationQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var actor = learningActorResolver.Resolve();
|
||||
var snapshot = await learningAccessService.GetSnapshotAsync(actor, cancellationToken);
|
||||
return query.ToFilter(actor.TenantId) with
|
||||
{
|
||||
RegionId = ResolveAuthorizedRegion(query.RegionId, snapshot),
|
||||
AllowedContentSliceIds = snapshot.ContentSliceIds.ToArray(),
|
||||
AllowedRegionIds = snapshot.LicensedRegionIds.ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
var tenant = await tenantDirectory.FindByCodeAsync(tenantCode, cancellationToken);
|
||||
return tenant?.TenantId ?? throw new TenantNotFoundException();
|
||||
private async Task<QuestionBankFilter> ToAuthorizedFilterAsync(
|
||||
QuestionBankQueryDto query,
|
||||
Guid? questionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var actor = learningActorResolver.Resolve();
|
||||
var snapshot = await learningAccessService.GetSnapshotAsync(actor, cancellationToken);
|
||||
return query.ToFilter(actor.TenantId, questionId) with
|
||||
{
|
||||
RegionId = ResolveAuthorizedRegion(query.RegionId, snapshot),
|
||||
AllowedContentSliceIds = snapshot.ContentSliceIds.ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
private static Guid? ResolveAuthorizedRegion(Guid? requestedRegionId, LearningAccessSnapshot snapshot)
|
||||
{
|
||||
if (requestedRegionId.HasValue &&
|
||||
requestedRegionId != snapshot.TargetRegionId &&
|
||||
!snapshot.LicensedRegionIds.Contains(requestedRegionId.Value))
|
||||
throw new LearningAccessException(
|
||||
"catalog_region_not_entitled",
|
||||
"The requested catalog region is outside the current learning grant.");
|
||||
|
||||
return requestedRegionId ?? snapshot.TargetRegionId ??
|
||||
(snapshot.LicensedRegionIds.Count == 1 ? snapshot.LicensedRegionIds.Single() : null);
|
||||
}
|
||||
|
||||
private Task<Guid> ResolveTenantIdAsync(
|
||||
@@ -507,28 +530,26 @@ public sealed class CatalogController(
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private Task<Guid> ResolveTenantIdAsync(
|
||||
private async Task<Guid> ResolveTenantIdAsync(
|
||||
StudyContentQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return ResolveTenantIdAsync(
|
||||
new CatalogQueryDto
|
||||
{
|
||||
TenantCode = query.TenantCode
|
||||
},
|
||||
cancellationToken);
|
||||
var actor = learningActorResolver.Resolve();
|
||||
var snapshot = await learningAccessService.GetSnapshotAsync(actor, cancellationToken);
|
||||
query.RegionId = ResolveAuthorizedRegion(query.RegionId, snapshot);
|
||||
query.AllowedRegionIds = snapshot.LicensedRegionIds.ToArray();
|
||||
return currentTenant.TenantId ?? throw new TenantNotFoundException();
|
||||
}
|
||||
|
||||
private Task<Guid> ResolveTenantIdAsync(
|
||||
private async Task<Guid> ResolveTenantIdAsync(
|
||||
AssetQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return ResolveTenantIdAsync(
|
||||
new CatalogQueryDto
|
||||
{
|
||||
TenantCode = query.TenantCode
|
||||
},
|
||||
cancellationToken);
|
||||
var actor = learningActorResolver.Resolve();
|
||||
var snapshot = await learningAccessService.GetSnapshotAsync(actor, cancellationToken);
|
||||
query.RegionId = ResolveAuthorizedRegion(query.RegionId, snapshot);
|
||||
query.AllowedRegionIds = snapshot.LicensedRegionIds.ToArray();
|
||||
return currentTenant.TenantId ?? throw new TenantNotFoundException();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -538,4 +559,4 @@ public sealed class TenantNotFoundException : Exception
|
||||
: base("Tenant was not found.")
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
44
Tiku.Api/Controllers/LearningAccessController.cs
Normal file
44
Tiku.Api/Controllers/LearningAccessController.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Api.Security;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.Security;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("学生端-学习授权")]
|
||||
[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.Practice)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/student/learning/access")]
|
||||
public sealed class LearningAccessController(
|
||||
ILearningAccessService accessService,
|
||||
ILearningAccessAdministrationService administrationService,
|
||||
LearningActorResolver actorResolver) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[EndpointSummary("查询当前学习授权快照")]
|
||||
public Task<LearningAccessSnapshot> Get(CancellationToken cancellationToken)
|
||||
{
|
||||
return accessService.GetSnapshotAsync(actorResolver.Resolve(), cancellationToken);
|
||||
}
|
||||
|
||||
[HttpGet("target-region")]
|
||||
[EndpointSummary("查询当前个人目标地区")]
|
||||
public Task<StudentTargetRegionItem?> GetTargetRegion(CancellationToken cancellationToken)
|
||||
{
|
||||
return administrationService.GetTargetRegionAsync(actorResolver.Resolve(), cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPut("target-region")]
|
||||
[EndpointSummary("变更个人目标地区")]
|
||||
public Task<StudentTargetRegionItem> ChangeTargetRegion(
|
||||
ChangeStudentTargetRegionDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return administrationService.ChangeTargetRegionAsync(
|
||||
actorResolver.Resolve(), request.ToCommand(), cancellationToken);
|
||||
}
|
||||
}
|
||||
75
Tiku.Api/Controllers/PlatformLearningAccessController.cs
Normal file
75
Tiku.Api/Controllers/PlatformLearningAccessController.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.Security;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("平台端-学习授权配置")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformConfigurationManage)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/platform/learning-access")]
|
||||
public sealed class PlatformLearningAccessController(
|
||||
IPlatformLearningAccessAdministrationService service) : ControllerBase
|
||||
{
|
||||
[HttpGet("business-lines")]
|
||||
public Task<IReadOnlyCollection<LearningDictionaryItem>> BusinessLines(CancellationToken cancellationToken) =>
|
||||
service.GetBusinessLinesAsync(cancellationToken);
|
||||
|
||||
[HttpPut("business-lines")]
|
||||
public Task<LearningDictionaryItem> UpsertBusinessLine(
|
||||
UpsertBusinessLineDto request,
|
||||
CancellationToken cancellationToken) =>
|
||||
service.UpsertBusinessLineAsync(request.ToCommand(), cancellationToken);
|
||||
|
||||
[HttpGet("market-regions")]
|
||||
public Task<IReadOnlyCollection<LearningDictionaryItem>> MarketRegions(CancellationToken cancellationToken) =>
|
||||
service.GetMarketRegionsAsync(cancellationToken);
|
||||
|
||||
[HttpPut("market-regions")]
|
||||
public Task<LearningDictionaryItem> UpsertMarketRegion(
|
||||
UpsertMarketRegionDto request,
|
||||
CancellationToken cancellationToken) =>
|
||||
service.UpsertMarketRegionAsync(request.ToCommand(), cancellationToken);
|
||||
|
||||
[HttpGet("tenants/{tenantId:guid}/license")]
|
||||
public Task<TenantLearningLicenseItem?> TenantLicense(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken) =>
|
||||
service.GetTenantLicenseAsync(tenantId, cancellationToken);
|
||||
|
||||
[HttpPut("tenants/{tenantId:guid}/license")]
|
||||
public Task<TenantLearningLicenseItem> UpsertTenantLicense(
|
||||
Guid tenantId,
|
||||
UpsertTenantLearningLicenseDto request,
|
||||
CancellationToken cancellationToken) =>
|
||||
service.UpsertTenantLicenseAsync(request.ToCommand(tenantId), cancellationToken);
|
||||
|
||||
[HttpGet("tenants/{tenantId:guid}/content-slices")]
|
||||
public Task<IReadOnlyCollection<ContentSliceItem>> ContentSlices(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken) =>
|
||||
service.GetContentSlicesAsync(tenantId, cancellationToken);
|
||||
|
||||
[HttpPut("tenants/{tenantId:guid}/content-slices")]
|
||||
public Task<ContentSliceItem> UpsertContentSlice(
|
||||
Guid tenantId,
|
||||
UpsertContentSliceDto request,
|
||||
CancellationToken cancellationToken) =>
|
||||
service.UpsertContentSliceAsync(request.ToCommand(tenantId), cancellationToken);
|
||||
|
||||
[HttpGet("tenants/{tenantId:guid}/products")]
|
||||
public Task<IReadOnlyCollection<LearningProductItem>> Products(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken) =>
|
||||
service.GetProductsAsync(tenantId, cancellationToken);
|
||||
|
||||
[HttpPut("tenants/{tenantId:guid}/products")]
|
||||
public Task<LearningProductItem> UpsertProduct(
|
||||
Guid tenantId,
|
||||
UpsertLearningProductDto request,
|
||||
CancellationToken cancellationToken) =>
|
||||
service.UpsertProductAsync(request.ToCommand(tenantId), cancellationToken);
|
||||
}
|
||||
24
Tiku.Api/Controllers/RetiredPublicCatalogController.cs
Normal file
24
Tiku.Api/Controllers/RetiredPublicCatalogController.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[AllowAnonymous]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
[Route("api/public/catalog")]
|
||||
public sealed class RetiredPublicCatalogController : ControllerBase
|
||||
{
|
||||
[AcceptVerbs("GET", "HEAD")]
|
||||
[Route("{**path}")]
|
||||
public IActionResult Retired()
|
||||
{
|
||||
return StatusCode(StatusCodes.Status410Gone, new ProblemDetails
|
||||
{
|
||||
Title = "Public catalog is retired.",
|
||||
Detail = "Authenticate and use /api/student/catalog instead.",
|
||||
Status = StatusCodes.Status410Gone,
|
||||
Extensions = { ["code"] = "public_catalog_retired" }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,28 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.OutputCaching;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Api.Security;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.Scoreline;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("学生端-分数线")]
|
||||
[AllowAnonymous]
|
||||
[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
|
||||
[RequireSaasFeature(SaasFeatureCatalog.Scoreline)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/public/scoreline")]
|
||||
[Route("api/student/scoreline")]
|
||||
public sealed class ScorelineController(
|
||||
IScorelineQueryService scorelineQueryService,
|
||||
ITenantContext currentTenant,
|
||||
ITenantDirectory tenantDirectory) : ControllerBase
|
||||
ILearningAccessService learningAccessService,
|
||||
LearningActorResolver learningActorResolver) : ControllerBase
|
||||
{
|
||||
private static readonly string[] DynamicPrefixes = ["field.", "min.", "max."];
|
||||
|
||||
[HttpGet("fields")]
|
||||
[OutputCache(PolicyName = "TenantPublic")]
|
||||
[EndpointSummary("查询分数线字段配置")]
|
||||
[ProducesResponseType<CatalogList<ScorelineFieldItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
@@ -78,7 +76,6 @@ public sealed class ScorelineController(
|
||||
}
|
||||
|
||||
[HttpGet("years")]
|
||||
[OutputCache(PolicyName = "TenantPublic")]
|
||||
[EndpointSummary("查询分数线可用年份")]
|
||||
[ProducesResponseType<CatalogList<int>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
@@ -115,12 +112,17 @@ public sealed class ScorelineController(
|
||||
ScorelineQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (currentTenant.TenantId.HasValue) return currentTenant.TenantId.Value;
|
||||
var actor = learningActorResolver.Resolve();
|
||||
var snapshot = await learningAccessService.GetSnapshotAsync(actor, cancellationToken);
|
||||
if (query.RegionId.HasValue &&
|
||||
query.RegionId != snapshot.TargetRegionId &&
|
||||
!snapshot.LicensedRegionIds.Contains(query.RegionId.Value))
|
||||
throw new LearningAccessException(
|
||||
"scoreline_region_not_entitled",
|
||||
"The requested scoreline region is outside the current learning grant.");
|
||||
|
||||
var tenantCode = query.TenantCode ?? Request.Headers["x-tenant-code"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(tenantCode)) throw new TenantNotFoundException();
|
||||
|
||||
var tenant = await tenantDirectory.FindByCodeAsync(tenantCode, cancellationToken);
|
||||
return tenant?.TenantId ?? throw new TenantNotFoundException();
|
||||
query.RegionId ??= snapshot.TargetRegionId ??
|
||||
(snapshot.LicensedRegionIds.Count == 1 ? snapshot.LicensedRegionIds.Single() : null);
|
||||
return actor.TenantId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.Security;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tags("租户端-班级学习授权")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/tenant/classes/learning-assignments")]
|
||||
public sealed class TenantClassLearningAssignmentController(
|
||||
ILearningAccessAdministrationService service,
|
||||
TenantAdminActorResolver actorResolver) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[EndpointSummary("查询班级题集和练习蓝图授权")]
|
||||
public Task<IReadOnlyCollection<ClassContentAssignmentItem>> Get(
|
||||
[FromQuery] Guid classId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var actor = actorResolver.Resolve();
|
||||
return service.GetClassAssignmentsAsync(actor.TenantId, classId, cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPut]
|
||||
[EndpointSummary("新增或更新班级题集和练习蓝图授权")]
|
||||
public Task<ClassContentAssignmentItem> Upsert(
|
||||
UpsertClassContentAssignmentDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var actor = actorResolver.Resolve();
|
||||
return service.UpsertClassAssignmentAsync(
|
||||
actor.TenantId, actor.UserId, request.ToCommand(), cancellationToken);
|
||||
}
|
||||
|
||||
[HttpPost("{assignmentId:guid}/revoke")]
|
||||
[EndpointSummary("撤回班级学习授权并强制撤销活动会话")]
|
||||
public Task<ClassContentAssignmentItem> Revoke(
|
||||
Guid assignmentId,
|
||||
RevokeClassContentAssignmentDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var actor = actorResolver.Resolve();
|
||||
return service.RevokeClassAssignmentAsync(
|
||||
actor.TenantId, actor.UserId, assignmentId, request.Reason, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
[ApiController]
|
||||
[Tags("租户端-学生学习授权")]
|
||||
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/tenant/students/{userId:guid}/learning/target-region")]
|
||||
public sealed class TenantStudentLearningAccessController(
|
||||
ILearningAccessAdministrationService service,
|
||||
TenantAdminActorResolver actorResolver) : ControllerBase
|
||||
{
|
||||
[HttpPut]
|
||||
[EndpointSummary("管理员强制调整学生目标地区")]
|
||||
public Task<StudentTargetRegionItem> Override(
|
||||
Guid userId,
|
||||
OverrideStudentTargetRegionDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var actor = actorResolver.Resolve();
|
||||
return service.OverrideTargetRegionAsync(
|
||||
actor.TenantId,
|
||||
request.ToCommand(userId, actor.UserId),
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -45,10 +45,24 @@ public sealed class TenantResolutionMiddleware(RequestDelegate next)
|
||||
}
|
||||
|
||||
if (tenant is not null)
|
||||
{
|
||||
if (!string.Equals(tenant.CellId, options.Value.CellId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status421MisdirectedRequest;
|
||||
await context.Response.WriteAsJsonAsync(new ProblemDetails
|
||||
{
|
||||
Title = "Tenant is assigned to another cell.",
|
||||
Status = StatusCodes.Status421MisdirectedRequest,
|
||||
Detail = "Route the request to the tenant's assigned learning cell."
|
||||
}, context.RequestAborted);
|
||||
return;
|
||||
}
|
||||
|
||||
tenantInitializer.Initialize(
|
||||
tenant.TenantId,
|
||||
tenant.TenantCode,
|
||||
tenant.Host is null ? TenantResolutionSource.TenantCode : TenantResolutionSource.Host);
|
||||
}
|
||||
|
||||
await next(context);
|
||||
}
|
||||
@@ -67,4 +81,4 @@ public sealed class TenantResolutionMiddleware(RequestDelegate next)
|
||||
{
|
||||
return prefixes.Any(prefix => path.StartsWithSegments(prefix, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,10 @@ namespace Tiku.Api.Modules.Student.Learning.Errors;
|
||||
internal sealed class LearningExceptionProblemDetailsMapper : ExceptionProblemDetailsMapper
|
||||
{
|
||||
public override IReadOnlyCollection<Type> HandledExceptionTypes { get; } =
|
||||
[typeof(LearningValidationException), typeof(LearningResourceNotFoundException), typeof(LearningAccessDeniedException)];
|
||||
[
|
||||
typeof(LearningValidationException), typeof(LearningResourceNotFoundException),
|
||||
typeof(LearningAccessException), typeof(LearningAccessDeniedException)
|
||||
];
|
||||
|
||||
public override bool TryMap(Exception exception, out ExceptionProblemDetailsMapping mapping)
|
||||
{
|
||||
@@ -17,6 +20,8 @@ internal sealed class LearningExceptionProblemDetailsMapper : ExceptionProblemDe
|
||||
validation.Code, out mapping),
|
||||
LearningResourceNotFoundException notFound => Mapped(notFound, StatusCodes.Status404NotFound,
|
||||
notFound.Code, out mapping),
|
||||
LearningAccessException access => Mapped(access, StatusCodes.Status403Forbidden,
|
||||
access.Code, out mapping),
|
||||
LearningAccessDeniedException => Mapped(exception, StatusCodes.Status403Forbidden,
|
||||
"learning_access_denied", out mapping),
|
||||
_ => NotMapped(out mapping)
|
||||
|
||||
@@ -4,6 +4,7 @@ public sealed class TenantResolutionOptions
|
||||
{
|
||||
public const string SectionName = "Tenancy:Resolution";
|
||||
|
||||
public string CellId { get; set; } = "cell-01";
|
||||
public string[] PlatformHosts { get; set; } = ["localhost", "127.0.0.1"];
|
||||
public string[] ExemptPathPrefixes { get; set; } = ["/health", "/openapi", "/scalar"];
|
||||
|
||||
@@ -14,4 +15,4 @@ public sealed class TenantResolutionOptions
|
||||
];
|
||||
|
||||
public string[] TrustedProxyAddresses { get; set; } = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ public sealed record AssetFilter(
|
||||
string? Keyword = null,
|
||||
bool IncludeLocked = false,
|
||||
bool IncludeInactive = false,
|
||||
int? Limit = null);
|
||||
int? Limit = null,
|
||||
IReadOnlyCollection<Guid>? AllowedRegionIds = null);
|
||||
|
||||
public sealed record ContentAssetCatalogItem(
|
||||
Guid Id,
|
||||
@@ -92,4 +93,4 @@ public sealed record QuestionVideoCatalogItem(
|
||||
QuestionVideoType VideoType,
|
||||
int Order,
|
||||
JsonElement Metadata,
|
||||
VideoExplanationCatalogItem? Video);
|
||||
VideoExplanationCatalogItem? Video);
|
||||
|
||||
@@ -18,7 +18,8 @@ public sealed record CatalogFilter(
|
||||
Guid? NodeId = null,
|
||||
string? Keyword = null,
|
||||
string? Type = null,
|
||||
int? Limit = null);
|
||||
int? Limit = null,
|
||||
IReadOnlyCollection<Guid>? AllowedRegionIds = null);
|
||||
|
||||
public sealed record RegionCatalogItem(
|
||||
Guid Id,
|
||||
@@ -183,4 +184,4 @@ public sealed record SvipPlanCatalogItem(
|
||||
string? VpProductId,
|
||||
bool VpEnabled,
|
||||
int Order,
|
||||
bool IsActive);
|
||||
bool IsActive);
|
||||
|
||||
@@ -19,7 +19,9 @@ public sealed record ContentNavigationFilter(
|
||||
string? Keyword = null,
|
||||
bool IncludeHidden = false,
|
||||
bool IncludeInactive = false,
|
||||
int? Limit = null);
|
||||
int? Limit = null,
|
||||
IReadOnlyCollection<Guid>? AllowedContentSliceIds = null,
|
||||
IReadOnlyCollection<Guid>? AllowedRegionIds = null);
|
||||
|
||||
public sealed record ContentEntryCatalogItem(
|
||||
Guid Id,
|
||||
@@ -118,10 +120,5 @@ public sealed record CollectionQuestionCatalogItem(
|
||||
Guid? VersionId,
|
||||
string? Content,
|
||||
JsonElement Options,
|
||||
int? CorrectOptionIndex,
|
||||
JsonElement CorrectOptionIndices,
|
||||
string? AnswerText,
|
||||
string? Explanation,
|
||||
JsonElement SubQuestions,
|
||||
string? CodeLang,
|
||||
string? CodeTemplate);
|
||||
string? CodeTemplate);
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using Tiku.Domain.Learning;
|
||||
|
||||
namespace Tiku.Application.Learning;
|
||||
|
||||
public sealed record StudentTargetRegionItem(
|
||||
Guid MarketRegionId,
|
||||
string RegionCode,
|
||||
string RegionName,
|
||||
DateTimeOffset EffectiveAt,
|
||||
DateTimeOffset? NextChangeAllowedAt);
|
||||
|
||||
public sealed record ChangeStudentTargetRegionCommand(Guid MarketRegionId);
|
||||
|
||||
public sealed record OverrideStudentTargetRegionCommand(
|
||||
Guid UserId,
|
||||
Guid MarketRegionId,
|
||||
Guid ChangedBy,
|
||||
string Reason);
|
||||
|
||||
public sealed record UpsertClassContentAssignmentCommand(
|
||||
Guid? Id,
|
||||
Guid ClassId,
|
||||
Guid ContentSliceId,
|
||||
LearningContentResourceType ResourceType,
|
||||
Guid ResourceId,
|
||||
DateTimeOffset? StartsAt,
|
||||
DateTimeOffset? EndsAt);
|
||||
|
||||
public sealed record ClassContentAssignmentItem(
|
||||
Guid Id,
|
||||
Guid ClassId,
|
||||
Guid ContentSliceId,
|
||||
LearningContentResourceType ResourceType,
|
||||
Guid ResourceId,
|
||||
DateTimeOffset StartsAt,
|
||||
DateTimeOffset? EndsAt,
|
||||
ClassContentAssignmentStatus Status);
|
||||
|
||||
public interface ILearningAccessAdministrationService
|
||||
{
|
||||
Task<StudentTargetRegionItem?> GetTargetRegionAsync(
|
||||
LearningActor actor,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<StudentTargetRegionItem> ChangeTargetRegionAsync(
|
||||
LearningActor actor,
|
||||
ChangeStudentTargetRegionCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<StudentTargetRegionItem> OverrideTargetRegionAsync(
|
||||
Guid tenantId,
|
||||
OverrideStudentTargetRegionCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyCollection<ClassContentAssignmentItem>> GetClassAssignmentsAsync(
|
||||
Guid tenantId,
|
||||
Guid classId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ClassContentAssignmentItem> UpsertClassAssignmentAsync(
|
||||
Guid tenantId,
|
||||
Guid actorUserId,
|
||||
UpsertClassContentAssignmentCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ClassContentAssignmentItem> RevokeClassAssignmentAsync(
|
||||
Guid tenantId,
|
||||
Guid actorUserId,
|
||||
Guid assignmentId,
|
||||
string? reason,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
47
Tiku.Application/Learning/LearningAccessModels.cs
Normal file
47
Tiku.Application/Learning/LearningAccessModels.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
using Tiku.Domain.Learning;
|
||||
|
||||
namespace Tiku.Application.Learning;
|
||||
|
||||
public sealed record LearningAccessSnapshot(
|
||||
Guid TenantId,
|
||||
Guid UserId,
|
||||
Guid BusinessLineId,
|
||||
LearningRegionAccessStrategy RegionAccessStrategy,
|
||||
IReadOnlySet<Guid> LicensedRegionIds,
|
||||
Guid? TargetRegionId,
|
||||
IReadOnlySet<Guid> ContentSliceIds,
|
||||
IReadOnlySet<Guid> ClassAssignmentIds,
|
||||
long GrantVersion,
|
||||
long ContentVersion,
|
||||
long StrongRevocationVersion,
|
||||
DateTimeOffset ValidUntil,
|
||||
DateTimeOffset GeneratedAt);
|
||||
|
||||
public sealed record LearningResourceAccessDecision(
|
||||
Guid ContentSliceId,
|
||||
PracticeAccessMode AccessMode,
|
||||
Guid? EntitlementId,
|
||||
Guid? ClassAssignmentId,
|
||||
LearningAccessSnapshot Snapshot);
|
||||
|
||||
public interface ILearningAccessService
|
||||
{
|
||||
Task<LearningAccessSnapshot> GetSnapshotAsync(
|
||||
LearningActor actor,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<LearningResourceAccessDecision> EnsureResourceAccessAsync(
|
||||
LearningActor actor,
|
||||
LearningContentResourceType resourceType,
|
||||
Guid resourceId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task EnsureStrongRevocationVersionAsync(
|
||||
LearningActor actor,
|
||||
long expectedVersion,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task InvalidateAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class LearningAccessException(string code, string message) : LearningException(code, message);
|
||||
@@ -11,7 +11,6 @@ public sealed record LearningList<TItem>(IReadOnlyCollection<TItem> Items);
|
||||
|
||||
public sealed record SubmitAnswerCommand(
|
||||
Guid SessionQuestionId,
|
||||
long ExpectedSessionVersion,
|
||||
long ClientSequence,
|
||||
string IdempotencyKey,
|
||||
IReadOnlyCollection<int>? SelectedOptionIndices,
|
||||
@@ -124,8 +123,14 @@ public sealed record AnswerRecordItem(
|
||||
string Status,
|
||||
int Revision,
|
||||
long ClientSequence,
|
||||
long SessionVersion,
|
||||
DateTimeOffset AcceptedAt);
|
||||
DateTimeOffset AcceptedAt,
|
||||
QuestionSolutionItem? Solution);
|
||||
|
||||
public sealed record QuestionSolutionItem(
|
||||
int? CorrectOptionIndex,
|
||||
JsonElement CorrectOptionIndices,
|
||||
string? AnswerText,
|
||||
string? Explanation);
|
||||
|
||||
public sealed record FavoriteQuestionItem(
|
||||
Guid QuestionReferenceId,
|
||||
@@ -173,7 +178,11 @@ public sealed record PracticeSessionItem(
|
||||
decimal? TotalScore,
|
||||
PracticeAccessMode AccessMode,
|
||||
Guid? AccessEntitlementId,
|
||||
Guid? AccessClassAssignmentId,
|
||||
int ConsumedFreeQuota,
|
||||
long AccessGrantVersion,
|
||||
long StrongRevocationVersion,
|
||||
DateTimeOffset? AuthorizationExpiresAt,
|
||||
JsonElement AccessSnapshot,
|
||||
DateTimeOffset StartedAt,
|
||||
DateTimeOffset? FinishedAt,
|
||||
@@ -186,7 +195,8 @@ public sealed record PracticeSessionItem(
|
||||
public sealed record PracticeSessionDetailItem(
|
||||
PracticeSessionItem Session,
|
||||
IReadOnlyCollection<PracticeSessionQuestionItem> Questions,
|
||||
IReadOnlyDictionary<Guid, AnswerRecordItem> AnswersBySessionQuestion);
|
||||
IReadOnlyDictionary<Guid, AnswerRecordItem> AnswersBySessionQuestion,
|
||||
IReadOnlyDictionary<Guid, QuestionSolutionItem> SolutionsBySessionQuestion);
|
||||
|
||||
public sealed record PracticeSessionQuestionItem(
|
||||
Guid SessionQuestionId,
|
||||
@@ -248,4 +258,4 @@ public sealed record PracticeHistoryItem(
|
||||
Guid? ReportId,
|
||||
decimal? Score,
|
||||
decimal? ReportTotalScore,
|
||||
decimal? Accuracy);
|
||||
decimal? Accuracy);
|
||||
|
||||
125
Tiku.Application/Learning/PlatformLearningAccessModels.cs
Normal file
125
Tiku.Application/Learning/PlatformLearningAccessModels.cs
Normal file
@@ -0,0 +1,125 @@
|
||||
using Tiku.Domain.Learning;
|
||||
|
||||
namespace Tiku.Application.Learning;
|
||||
|
||||
public sealed record LearningDictionaryItem(
|
||||
Guid Id,
|
||||
string Code,
|
||||
string Name,
|
||||
string? Kind,
|
||||
bool IsActive,
|
||||
bool? RequiresBaseRegion = null,
|
||||
int? TargetRegionCooldownDays = null);
|
||||
|
||||
public sealed record UpsertBusinessLineCommand(
|
||||
Guid? Id,
|
||||
string Code,
|
||||
string Name,
|
||||
LearningRegionAccessStrategy RegionAccessStrategy,
|
||||
bool RequiresBaseRegion,
|
||||
int TargetRegionCooldownDays,
|
||||
bool IsActive);
|
||||
|
||||
public sealed record UpsertMarketRegionCommand(
|
||||
Guid? Id,
|
||||
string Code,
|
||||
string Name,
|
||||
string? ParentCode,
|
||||
bool IsActive);
|
||||
|
||||
public sealed record TenantLearningLicenseItem(
|
||||
Guid Id,
|
||||
Guid TenantId,
|
||||
Guid BusinessLineId,
|
||||
bool IsPrimary,
|
||||
bool IncludesNational,
|
||||
bool AllowsAnyTargetRegion,
|
||||
LearningLicenseStatus Status,
|
||||
DateTimeOffset StartsAt,
|
||||
DateTimeOffset? EndsAt,
|
||||
long Version,
|
||||
IReadOnlyCollection<Guid> MarketRegionIds);
|
||||
|
||||
public sealed record UpsertTenantLearningLicenseCommand(
|
||||
Guid TenantId,
|
||||
Guid? Id,
|
||||
Guid BusinessLineId,
|
||||
bool IsPrimary,
|
||||
bool IncludesNational,
|
||||
bool AllowsAnyTargetRegion,
|
||||
LearningLicenseStatus Status,
|
||||
DateTimeOffset StartsAt,
|
||||
DateTimeOffset? EndsAt,
|
||||
IReadOnlyCollection<Guid> MarketRegionIds,
|
||||
Guid? BaseMarketRegionId);
|
||||
|
||||
public sealed record ContentSliceItem(
|
||||
Guid Id,
|
||||
Guid TenantId,
|
||||
Guid BusinessLineId,
|
||||
Guid? MarketRegionId,
|
||||
LearningRegionScopeKind RegionScope,
|
||||
LearningContentResourceType ResourceType,
|
||||
Guid ResourceId,
|
||||
long ContentVersion,
|
||||
ContentSliceStatus Status);
|
||||
|
||||
public sealed record UpsertContentSliceCommand(
|
||||
Guid TenantId,
|
||||
Guid? Id,
|
||||
Guid BusinessLineId,
|
||||
Guid? MarketRegionId,
|
||||
LearningRegionScopeKind RegionScope,
|
||||
LearningContentResourceType ResourceType,
|
||||
Guid ResourceId,
|
||||
ContentSliceStatus Status);
|
||||
|
||||
public sealed record LearningProductItem(
|
||||
Guid Id,
|
||||
Guid TenantId,
|
||||
Guid BusinessLineId,
|
||||
string Code,
|
||||
string Name,
|
||||
bool IsActive,
|
||||
IReadOnlyCollection<Guid> ContentSliceIds);
|
||||
|
||||
public sealed record UpsertLearningProductCommand(
|
||||
Guid TenantId,
|
||||
Guid? Id,
|
||||
Guid BusinessLineId,
|
||||
string Code,
|
||||
string Name,
|
||||
bool IsActive,
|
||||
IReadOnlyCollection<Guid> ContentSliceIds);
|
||||
|
||||
public interface IPlatformLearningAccessAdministrationService
|
||||
{
|
||||
Task<IReadOnlyCollection<LearningDictionaryItem>> GetBusinessLinesAsync(
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<LearningDictionaryItem> UpsertBusinessLineAsync(
|
||||
UpsertBusinessLineCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyCollection<LearningDictionaryItem>> GetMarketRegionsAsync(
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<LearningDictionaryItem> UpsertMarketRegionAsync(
|
||||
UpsertMarketRegionCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<TenantLearningLicenseItem?> GetTenantLicenseAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<TenantLearningLicenseItem> UpsertTenantLicenseAsync(
|
||||
UpsertTenantLearningLicenseCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyCollection<ContentSliceItem>> GetContentSlicesAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<ContentSliceItem> UpsertContentSliceAsync(
|
||||
UpsertContentSliceCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyCollection<LearningProductItem>> GetProductsAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<LearningProductItem> UpsertProductAsync(
|
||||
UpsertLearningProductCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -16,7 +16,4 @@ public interface IQuestionBankQueryService
|
||||
QuestionBankFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CatalogList<QuestionVersionCatalogItem>> GetQuestionVersionsAsync(
|
||||
QuestionBankFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,8 @@ public sealed record QuestionBankFilter(
|
||||
string? Type = null,
|
||||
string? Keyword = null,
|
||||
int? Limit = null,
|
||||
QuestionSource? Source = null);
|
||||
QuestionSource? Source = null,
|
||||
IReadOnlyCollection<Guid>? AllowedContentSliceIds = null);
|
||||
|
||||
public sealed record QuestionBankCatalogItem(
|
||||
Guid Id,
|
||||
@@ -54,28 +55,8 @@ public sealed record QuestionCatalogItem(
|
||||
int? VersionNo,
|
||||
string? Content,
|
||||
JsonElement Options,
|
||||
int? CorrectOptionIndex,
|
||||
JsonElement CorrectOptionIndices,
|
||||
string? AnswerText,
|
||||
string? Explanation,
|
||||
JsonElement SubQuestions,
|
||||
string? CodeLang,
|
||||
string? CodeTemplate,
|
||||
QuestionLocator Locator);
|
||||
|
||||
public sealed record QuestionLocator(QuestionSource Source, Guid QuestionId);
|
||||
|
||||
public sealed record QuestionVersionCatalogItem(
|
||||
Guid Id,
|
||||
Guid QuestionId,
|
||||
int VersionNo,
|
||||
string? Content,
|
||||
JsonElement Options,
|
||||
int? CorrectOptionIndex,
|
||||
JsonElement CorrectOptionIndices,
|
||||
string? AnswerText,
|
||||
string? Explanation,
|
||||
JsonElement SubQuestions,
|
||||
string? CodeLang,
|
||||
string? CodeTemplate,
|
||||
DateTimeOffset CreatedAt);
|
||||
@@ -13,7 +13,8 @@ public sealed record StudyContentFilter(
|
||||
Guid? ContentNodeId = null,
|
||||
string? Keyword = null,
|
||||
bool IncludeContent = false,
|
||||
int? Limit = null);
|
||||
int? Limit = null,
|
||||
IReadOnlyCollection<Guid>? AllowedRegionIds = null);
|
||||
|
||||
public sealed record VocabularyUnitCatalogItem(
|
||||
Guid Id,
|
||||
@@ -81,4 +82,4 @@ public sealed record HandbookEntryCatalogItem(
|
||||
string? Content,
|
||||
JsonElement Tags,
|
||||
int Order,
|
||||
JsonElement Metadata);
|
||||
JsonElement Metadata);
|
||||
|
||||
@@ -8,6 +8,7 @@ public sealed record TenantDirectoryEntry(
|
||||
string Name,
|
||||
TenantStatus Status,
|
||||
TenantMode Mode,
|
||||
string CellId,
|
||||
string? Host);
|
||||
|
||||
public interface ITenantDirectory
|
||||
@@ -19,4 +20,4 @@ public interface ITenantDirectory
|
||||
Task<TenantDirectoryEntry?> FindByCodeAsync(
|
||||
string tenantCode,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@ public sealed class PaymentEvent : Entity, ITenantOwned
|
||||
public sealed class Entitlement : Entity, ITenantOwned
|
||||
{
|
||||
public Guid UserId { get; set; }
|
||||
public Guid? LearningProductId { get; set; }
|
||||
public string EntitlementType { get; set; } = "svip";
|
||||
public EntitlementScopeType ScopeType { get; set; } = EntitlementScopeType.Tenant;
|
||||
public Guid? ScopeId { get; set; }
|
||||
@@ -532,4 +533,4 @@ public enum CommerceAdjustmentDirection
|
||||
IncreaseRefund,
|
||||
DecreaseRefund,
|
||||
WriteOff
|
||||
}
|
||||
}
|
||||
|
||||
150
Tiku.Domain/Learning/LearningAccessEntities.cs
Normal file
150
Tiku.Domain/Learning/LearningAccessEntities.cs
Normal file
@@ -0,0 +1,150 @@
|
||||
using Tiku.Domain.Common;
|
||||
|
||||
namespace Tiku.Domain.Learning;
|
||||
|
||||
public sealed class BusinessLine : AuditableEntity
|
||||
{
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public LearningRegionAccessStrategy RegionAccessStrategy { get; set; } =
|
||||
LearningRegionAccessStrategy.LicensedRegions;
|
||||
public bool RequiresBaseRegion { get; set; }
|
||||
public int TargetRegionCooldownDays { get; set; } = 30;
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
|
||||
public sealed class MarketRegion : AuditableEntity
|
||||
{
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? ParentCode { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
|
||||
public sealed class TenantLearningLicense : AuditableTenantEntity
|
||||
{
|
||||
public Guid BusinessLineId { get; set; }
|
||||
public bool IsPrimary { get; set; } = true;
|
||||
public bool IncludesNational { get; set; }
|
||||
public bool AllowsAnyTargetRegion { get; set; }
|
||||
public LearningLicenseStatus Status { get; set; } = LearningLicenseStatus.Active;
|
||||
public DateTimeOffset StartsAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset? EndsAt { get; set; }
|
||||
public long Version { get; set; } = 1;
|
||||
}
|
||||
|
||||
public sealed class TenantLearningLicenseRegion : Entity, ITenantOwned
|
||||
{
|
||||
public Guid LicenseId { get; set; }
|
||||
public Guid MarketRegionId { get; set; }
|
||||
public bool IsBaseRegion { get; set; }
|
||||
public Guid TenantId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class LearningProduct : AuditableTenantEntity
|
||||
{
|
||||
public Guid BusinessLineId { get; set; }
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
|
||||
public sealed class LearningProductScope : Entity, ITenantOwned
|
||||
{
|
||||
public Guid ProductId { get; set; }
|
||||
public Guid ContentSliceOwnerTenantId { get; set; }
|
||||
public Guid ContentSliceId { get; set; }
|
||||
public Guid TenantId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ContentSlice : AuditableTenantEntity
|
||||
{
|
||||
public Guid BusinessLineId { get; set; }
|
||||
public Guid? MarketRegionId { get; set; }
|
||||
public LearningRegionScopeKind RegionScope { get; set; } = LearningRegionScopeKind.Region;
|
||||
public LearningContentResourceType ResourceType { get; set; } = LearningContentResourceType.QuestionBank;
|
||||
public Guid ResourceId { get; set; }
|
||||
public long ContentVersion { get; set; } = 1;
|
||||
public ContentSliceStatus Status { get; set; } = ContentSliceStatus.Active;
|
||||
}
|
||||
|
||||
public sealed class StudentTargetRegionHistory : AuditableTenantEntity
|
||||
{
|
||||
public Guid UserId { get; set; }
|
||||
public Guid BusinessLineId { get; set; }
|
||||
public Guid MarketRegionId { get; set; }
|
||||
public DateTimeOffset EffectiveAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset? EndedAt { get; set; }
|
||||
public bool IsCurrent { get; set; } = true;
|
||||
public Guid? ChangedBy { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ClassContentAssignment : AuditableTenantEntity
|
||||
{
|
||||
public Guid ClassId { get; set; }
|
||||
public Guid ContentSliceOwnerTenantId { get; set; }
|
||||
public Guid ContentSliceId { get; set; }
|
||||
public LearningContentResourceType ResourceType { get; set; }
|
||||
public Guid ResourceId { get; set; }
|
||||
public DateTimeOffset StartsAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset? EndsAt { get; set; }
|
||||
public ClassContentAssignmentStatus Status { get; set; } = ClassContentAssignmentStatus.Active;
|
||||
public Guid? CreatedBy { get; set; }
|
||||
public Guid? RevokedBy { get; set; }
|
||||
public string? RevokedReason { get; set; }
|
||||
}
|
||||
|
||||
public sealed class LearningAccessVersion : TenantEntity
|
||||
{
|
||||
public Guid UserId { get; set; }
|
||||
public long GrantVersion { get; set; } = 1;
|
||||
public long ContentVersion { get; set; } = 1;
|
||||
public long StrongRevocationVersion { get; set; } = 1;
|
||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public enum LearningRegionAccessStrategy
|
||||
{
|
||||
NationalOnly,
|
||||
LicensedRegions,
|
||||
NationalWithStudentTargetRegion,
|
||||
NationalWithLicensedRegions
|
||||
}
|
||||
|
||||
public enum LearningLicenseStatus
|
||||
{
|
||||
Active,
|
||||
Suspended,
|
||||
Expired,
|
||||
Cancelled
|
||||
}
|
||||
|
||||
public enum LearningRegionScopeKind
|
||||
{
|
||||
National,
|
||||
Region
|
||||
}
|
||||
|
||||
public enum LearningContentResourceType
|
||||
{
|
||||
QuestionBank,
|
||||
Collection,
|
||||
Blueprint,
|
||||
ContentEntry,
|
||||
ContentNode
|
||||
}
|
||||
|
||||
public enum ContentSliceStatus
|
||||
{
|
||||
Draft,
|
||||
Active,
|
||||
Retired
|
||||
}
|
||||
|
||||
public enum ClassContentAssignmentStatus
|
||||
{
|
||||
Active,
|
||||
Revoked,
|
||||
Expired
|
||||
}
|
||||
@@ -24,7 +24,11 @@ public sealed class PracticeSession : TenantEntity
|
||||
public long LastClientSequence { get; set; }
|
||||
public PracticeAccessMode AccessMode { get; set; } = PracticeAccessMode.Free;
|
||||
public Guid? AccessEntitlementId { get; set; }
|
||||
public Guid? AccessClassAssignmentId { get; set; }
|
||||
public int ConsumedFreeQuota { get; set; }
|
||||
public long AccessGrantVersion { get; set; }
|
||||
public long StrongRevocationVersion { get; set; }
|
||||
public DateTimeOffset? AuthorizationExpiresAt { get; set; }
|
||||
public JsonElement AccessSnapshot { get; set; } = JsonDefaults.Object();
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
}
|
||||
@@ -42,12 +46,6 @@ public sealed class PracticeSessionQuestion : TenantEntity
|
||||
public string? TypeLabelSnapshot { get; set; }
|
||||
public int? DifficultySnapshot { get; set; }
|
||||
public JsonElement TagsSnapshot { get; set; } = JsonDefaults.Array();
|
||||
public string? ContentSnapshot { get; set; }
|
||||
public JsonElement OptionsSnapshot { get; set; } = JsonDefaults.Array();
|
||||
public int? CorrectOptionIndexSnapshot { get; set; }
|
||||
public JsonElement CorrectOptionIndicesSnapshot { get; set; } = JsonDefaults.Array();
|
||||
public string? AnswerTextSnapshot { get; set; }
|
||||
public string? ExplanationSnapshot { get; set; }
|
||||
public JsonElement GradingRulesSnapshot { get; set; } = JsonDefaults.Object();
|
||||
public int SnapshotVersion { get; set; } = 1;
|
||||
}
|
||||
@@ -66,7 +64,9 @@ public enum PracticeAccessMode
|
||||
{
|
||||
Free,
|
||||
Svip,
|
||||
Staff
|
||||
Staff,
|
||||
Package,
|
||||
ClassAssignment
|
||||
}
|
||||
|
||||
public sealed class AnswerRecord : TenantEntity
|
||||
@@ -86,11 +86,22 @@ public sealed class AnswerRecord : TenantEntity
|
||||
public long ClientSequence { get; set; }
|
||||
public string IdempotencyKey { get; set; } = string.Empty;
|
||||
public string RequestHash { get; set; } = string.Empty;
|
||||
public bool IsCurrent { get; set; } = true;
|
||||
public DateTimeOffset AnsweredAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class CurrentAnswer : TenantEntity
|
||||
{
|
||||
public Guid UserId { get; set; }
|
||||
public Guid PracticeSessionId { get; set; }
|
||||
public Guid SessionQuestionId { get; set; }
|
||||
public Guid AnswerRecordId { get; set; }
|
||||
public int Revision { get; set; }
|
||||
public long ClientSequence { get; set; }
|
||||
public long Version { get; set; } = 1;
|
||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public enum AnswerGradingStatus
|
||||
{
|
||||
PendingReview,
|
||||
@@ -147,4 +158,4 @@ public sealed class RecentPractice : AuditableTenantEntity
|
||||
public DateTimeOffset? LastAccessAt { get; set; }
|
||||
public DateTimeOffset? LastPracticeAt { get; set; }
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,5 +237,7 @@ public enum PracticeAccessEventMode
|
||||
Free,
|
||||
Svip,
|
||||
Staff,
|
||||
Package,
|
||||
ClassAssignment,
|
||||
Denied
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,10 @@ public sealed class QuestionVersion : Entity, ITenantOwned
|
||||
{
|
||||
public Guid QuestionId { get; set; }
|
||||
public int VersionNo { get; set; } = 1;
|
||||
public string QuestionType { get; set; } = "choice";
|
||||
public string? TypeLabel { get; set; }
|
||||
public int? Difficulty { get; set; }
|
||||
public JsonElement Tags { get; set; } = JsonDefaults.Array();
|
||||
public string? Content { get; set; }
|
||||
public JsonElement Options { get; set; } = JsonDefaults.Array();
|
||||
public int? CorrectOptionIndex { get; set; }
|
||||
@@ -66,4 +70,4 @@ public sealed class QuestionVersion : Entity, ITenantOwned
|
||||
public Guid? CreatedBy { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public Guid TenantId { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ public sealed class Tenant : AuditableEntity
|
||||
public string? LegalName { get; set; }
|
||||
public TenantStatus Status { get; set; } = TenantStatus.Active;
|
||||
public TenantMode Mode { get; set; } = TenantMode.Saas;
|
||||
public string CellId { get; set; } = "cell-01";
|
||||
public BillingStatus BillingStatus { get; set; } = BillingStatus.Trial;
|
||||
public Guid? OwnerUserId { get; set; }
|
||||
public string? LegacyId { get; set; }
|
||||
@@ -38,4 +39,4 @@ public enum BillingStatus
|
||||
PastDue,
|
||||
Suspended,
|
||||
Cancelled
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ public sealed class AssetQueryService(IContentAssetPersistence dbContext) : IAss
|
||||
var query = dbContext.ContentAssets
|
||||
.AsNoTracking()
|
||||
.Where(asset => asset.TenantId == filter.TenantId);
|
||||
if (filter.AllowedRegionIds is not null)
|
||||
query = query.Where(asset => asset.RegionId == null ||
|
||||
filter.AllowedRegionIds.Contains(asset.RegionId.Value));
|
||||
|
||||
if (!filter.IncludeInactive) query = query.Where(asset => asset.Status == ContentStatus.Active);
|
||||
|
||||
@@ -289,4 +292,4 @@ public sealed class AssetQueryService(IContentAssetPersistence dbContext) : IAss
|
||||
{
|
||||
return Math.Clamp(limit ?? DefaultLimit, 1, MaxLimit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ public sealed class CatalogQueryService(ICatalogPersistence catalogPersistence,
|
||||
.Where(region =>
|
||||
region.TenantId == filter.TenantId &&
|
||||
region.IsActive);
|
||||
if (filter.AllowedRegionIds is not null)
|
||||
query = query.Where(region => filter.AllowedRegionIds.Contains(region.Id));
|
||||
|
||||
query = ApplyKeyword(query, filter.Keyword);
|
||||
|
||||
@@ -56,6 +58,9 @@ public sealed class CatalogQueryService(ICatalogPersistence catalogPersistence,
|
||||
.Where(module =>
|
||||
module.TenantId == filter.TenantId &&
|
||||
module.IsActive);
|
||||
if (filter.AllowedRegionIds is not null)
|
||||
query = query.Where(module => module.RegionId.HasValue &&
|
||||
filter.AllowedRegionIds.Contains(module.RegionId.Value));
|
||||
|
||||
if (filter.RegionId.HasValue) query = query.Where(module => module.RegionId == filter.RegionId.Value);
|
||||
|
||||
@@ -93,6 +98,9 @@ public sealed class CatalogQueryService(ICatalogPersistence catalogPersistence,
|
||||
.Where(node =>
|
||||
node.TenantId == filter.TenantId &&
|
||||
node.IsActive);
|
||||
if (filter.AllowedRegionIds is not null)
|
||||
query = query.Where(node => node.RegionId == null ||
|
||||
filter.AllowedRegionIds.Contains(node.RegionId.Value));
|
||||
|
||||
if (filter.RegionId.HasValue) query = query.Where(node => node.RegionId == filter.RegionId.Value);
|
||||
|
||||
@@ -135,6 +143,9 @@ public sealed class CatalogQueryService(ICatalogPersistence catalogPersistence,
|
||||
var query = catalogPersistence.Schools
|
||||
.AsNoTracking()
|
||||
.Where(school => school.TenantId == filter.TenantId);
|
||||
if (filter.AllowedRegionIds is not null)
|
||||
query = query.Where(school => school.RegionId.HasValue &&
|
||||
filter.AllowedRegionIds.Contains(school.RegionId.Value));
|
||||
|
||||
if (filter.RegionId.HasValue) query = query.Where(school => school.RegionId == filter.RegionId.Value);
|
||||
|
||||
@@ -168,6 +179,9 @@ public sealed class CatalogQueryService(ICatalogPersistence catalogPersistence,
|
||||
.Where(major =>
|
||||
major.TenantId == filter.TenantId &&
|
||||
major.IsActive);
|
||||
if (filter.AllowedRegionIds is not null)
|
||||
query = query.Where(major => major.RegionId == null ||
|
||||
filter.AllowedRegionIds.Contains(major.RegionId.Value));
|
||||
|
||||
if (filter.RegionId.HasValue) query = query.Where(major => major.RegionId == filter.RegionId.Value);
|
||||
|
||||
@@ -203,6 +217,9 @@ public sealed class CatalogQueryService(ICatalogPersistence catalogPersistence,
|
||||
.Where(subject =>
|
||||
subject.TenantId == filter.TenantId &&
|
||||
subject.IsActive);
|
||||
if (filter.AllowedRegionIds is not null)
|
||||
query = query.Where(subject => subject.RegionId == null ||
|
||||
filter.AllowedRegionIds.Contains(subject.RegionId.Value));
|
||||
|
||||
if (filter.RegionId.HasValue) query = query.Where(subject => subject.RegionId == filter.RegionId.Value);
|
||||
|
||||
@@ -604,4 +621,4 @@ public sealed class CatalogQueryService(ICatalogPersistence catalogPersistence,
|
||||
: value.Replace("_", string.Empty, StringComparison.Ordinal)
|
||||
.Replace("-", string.Empty, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ using Tiku.Application.Commerce;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Learning;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using NotificationSeverity = Tiku.Domain.Commerce.NotificationSeverity;
|
||||
@@ -202,6 +203,41 @@ internal abstract partial class CommerceAdministrationServiceBase
|
||||
: PaymentStatus.PartiallyRefunded;
|
||||
}
|
||||
}
|
||||
|
||||
if (refund.EntitlementAction == RefundEntitlementAction.RevokeOnSuccess && order.UserId.HasValue)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var entitlements = await commercePersistence.Entitlements.Where(item =>
|
||||
item.TenantId == refund.TenantId &&
|
||||
item.UserId == order.UserId.Value &&
|
||||
item.SourceType == "order" &&
|
||||
item.SourceId == order.Id &&
|
||||
item.Status == EntitlementStatus.Active)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
foreach (var entitlement in entitlements)
|
||||
{
|
||||
entitlement.Status = EntitlementStatus.Revoked;
|
||||
entitlement.RevokedAt = now;
|
||||
entitlement.RevokedReason = $"refund:{refund.RefundNo}";
|
||||
}
|
||||
|
||||
var version = await learningAccessPersistence.LearningAccessVersions.SingleOrDefaultAsync(
|
||||
item => item.TenantId == refund.TenantId && item.UserId == order.UserId.Value,
|
||||
cancellationToken);
|
||||
if (version is null)
|
||||
{
|
||||
version = new LearningAccessVersion
|
||||
{
|
||||
TenantId = refund.TenantId,
|
||||
UserId = order.UserId.Value
|
||||
};
|
||||
learningAccessPersistence.LearningAccessVersions.Add(version);
|
||||
}
|
||||
version.GrantVersion++;
|
||||
version.StrongRevocationVersion++;
|
||||
version.UpdatedAt = now;
|
||||
learningAccessInvalidations.Add((refund.TenantId, order.UserId.Value));
|
||||
}
|
||||
}
|
||||
|
||||
protected static bool IsAllowedRefundTransition(CommerceRefundStatus from, CommerceRefundStatus to)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
@@ -13,6 +14,8 @@ internal sealed record CommerceAdministrationDependencies(
|
||||
IIdentityPersistence IdentityPersistence,
|
||||
ITenancyPersistence TenancyPersistence,
|
||||
IJobsOperationsPersistence JobsOperationsPersistence,
|
||||
ILearningAccessPersistence LearningAccessPersistence,
|
||||
ILearningAccessService LearningAccessService,
|
||||
ITenantSecretProtector TenantSecretProtector,
|
||||
ITenantExternalProviderConfigService ProviderConfigService,
|
||||
ICurrentAccessContext CurrentAccessContext,
|
||||
@@ -27,10 +30,20 @@ internal abstract partial class CommerceAdministrationServiceBase(CommerceAdmini
|
||||
protected IIdentityPersistence identityPersistence { get; } = dependencies.IdentityPersistence;
|
||||
protected ITenancyPersistence tenancyPersistence { get; } = dependencies.TenancyPersistence;
|
||||
protected IJobsOperationsPersistence jobsOperationsPersistence { get; } = dependencies.JobsOperationsPersistence;
|
||||
protected ILearningAccessPersistence learningAccessPersistence { get; } = dependencies.LearningAccessPersistence;
|
||||
protected ILearningAccessService learningAccessService { get; } = dependencies.LearningAccessService;
|
||||
protected IModulePersistence unitOfWork { get; } = dependencies.CommercePersistence;
|
||||
protected ITenantSecretProtector tenantSecretProtector { get; } = dependencies.TenantSecretProtector;
|
||||
protected ITenantExternalProviderConfigService providerConfigService { get; } = dependencies.ProviderConfigService;
|
||||
protected ICurrentAccessContext currentAccessContext { get; } = dependencies.CurrentAccessContext;
|
||||
protected IBackgroundJobQueue backgroundJobQueue { get; } = dependencies.BackgroundJobQueue;
|
||||
protected IBackgroundJobOperations backgroundJobOperations { get; } = dependencies.BackgroundJobOperations;
|
||||
private readonly HashSet<(Guid TenantId, Guid UserId)> learningAccessInvalidations = [];
|
||||
|
||||
protected async Task FlushLearningAccessInvalidationsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.WhenAll(learningAccessInvalidations.Select(item =>
|
||||
learningAccessService.InvalidateAsync(item.TenantId, item.UserId, cancellationToken)));
|
||||
learningAccessInvalidations.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ internal sealed partial class RefundAdministrationService
|
||||
}
|
||||
|
||||
await unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
await FlushLearningAccessInvalidationsAsync(cancellationToken);
|
||||
return refund;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,7 @@ internal sealed partial class RefundAdministrationService(CommerceAdministration
|
||||
await AddAuditAsync(actor, "commerce.refund.created", "commerce_refund_requests", refund.Id,
|
||||
new { refund.RefundNo, refund.AmountCents }, cancellationToken);
|
||||
await unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
await FlushLearningAccessInvalidationsAsync(cancellationToken);
|
||||
return refund;
|
||||
}
|
||||
|
||||
@@ -150,6 +151,7 @@ internal sealed partial class RefundAdministrationService(CommerceAdministration
|
||||
await AddAuditAsync(actor, "commerce.refund.status_changed", "commerce_refund_requests", refund.Id,
|
||||
new { refund.RefundNo, From = fromStatus, To = command.Status }, cancellationToken);
|
||||
await unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
await FlushLearningAccessInvalidationsAsync(cancellationToken);
|
||||
return refund;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,15 @@ using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Learning;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Content;
|
||||
|
||||
public sealed class ContentNavigationQueryService(IQuestionBankPersistence dbContext) : IContentNavigationQueryService
|
||||
public sealed class ContentNavigationQueryService(
|
||||
IQuestionBankPersistence dbContext,
|
||||
ILearningPersistence learningPersistence) : IContentNavigationQueryService
|
||||
{
|
||||
private const int DefaultLimit = 100;
|
||||
private const int MaxLimit = 1000;
|
||||
@@ -22,6 +25,9 @@ public sealed class ContentNavigationQueryService(IQuestionBankPersistence dbCon
|
||||
.Where(entry =>
|
||||
entry.TenantId == filter.TenantId &&
|
||||
entry.IsActive);
|
||||
if (filter.AllowedRegionIds is not null)
|
||||
query = query.Where(entry => entry.RegionId == null ||
|
||||
filter.AllowedRegionIds.Contains(entry.RegionId.Value));
|
||||
|
||||
if (!filter.IncludeHidden) query = query.Where(entry => entry.Visibility != ContentVisibility.Hidden);
|
||||
|
||||
@@ -67,6 +73,9 @@ public sealed class ContentNavigationQueryService(IQuestionBankPersistence dbCon
|
||||
.Where(node =>
|
||||
node.TenantId == filter.TenantId &&
|
||||
node.EntryId == filter.EntryId.Value);
|
||||
if (filter.AllowedRegionIds is not null)
|
||||
query = query.Where(node => node.RegionId == null ||
|
||||
filter.AllowedRegionIds.Contains(node.RegionId.Value));
|
||||
|
||||
if (!filter.IncludeInactive) query = query.Where(node => node.IsActive);
|
||||
|
||||
@@ -121,6 +130,13 @@ public sealed class ContentNavigationQueryService(IQuestionBankPersistence dbCon
|
||||
.Where(collection =>
|
||||
collection.TenantId == filter.TenantId &&
|
||||
collection.Status == ContentStatus.Active);
|
||||
var allowedSliceIds = filter.AllowedContentSliceIds?.ToArray() ?? [];
|
||||
query = query.Where(collection => learningPersistence.ContentSlices.Any(slice =>
|
||||
slice.TenantId == filter.TenantId &&
|
||||
allowedSliceIds.Contains(slice.Id) &&
|
||||
slice.Status == ContentSliceStatus.Active &&
|
||||
slice.ResourceType == LearningContentResourceType.Collection &&
|
||||
slice.ResourceId == collection.Id));
|
||||
|
||||
if (filter.RegionId.HasValue)
|
||||
query = query.Where(collection =>
|
||||
@@ -173,6 +189,13 @@ public sealed class ContentNavigationQueryService(IQuestionBankPersistence dbCon
|
||||
.Where(blueprint =>
|
||||
blueprint.TenantId == filter.TenantId &&
|
||||
blueprint.Status == ContentStatus.Active);
|
||||
var allowedSliceIds = filter.AllowedContentSliceIds?.ToArray() ?? [];
|
||||
query = query.Where(blueprint => learningPersistence.ContentSlices.Any(slice =>
|
||||
slice.TenantId == filter.TenantId &&
|
||||
allowedSliceIds.Contains(slice.Id) &&
|
||||
slice.Status == ContentSliceStatus.Active &&
|
||||
slice.ResourceType == LearningContentResourceType.Blueprint &&
|
||||
slice.ResourceId == blueprint.Id));
|
||||
|
||||
if (filter.RegionId.HasValue)
|
||||
query = query.Where(blueprint => blueprint.RegionId == filter.RegionId.Value || blueprint.RegionId == null);
|
||||
@@ -222,20 +245,25 @@ public sealed class ContentNavigationQueryService(IQuestionBankPersistence dbCon
|
||||
{
|
||||
if (!filter.CollectionId.HasValue) throw new RequiredFieldException("collectionId is required.");
|
||||
|
||||
var allowedSliceIds = filter.AllowedContentSliceIds?.ToArray() ?? [];
|
||||
var collectionExists = await dbContext.QuestionCollections
|
||||
.AsNoTracking()
|
||||
.AnyAsync(
|
||||
collection =>
|
||||
collection.TenantId == filter.TenantId &&
|
||||
collection.Id == filter.CollectionId.Value &&
|
||||
collection.Status == ContentStatus.Active,
|
||||
collection.Status == ContentStatus.Active &&
|
||||
learningPersistence.ContentSlices.Any(slice =>
|
||||
slice.TenantId == filter.TenantId &&
|
||||
allowedSliceIds.Contains(slice.Id) &&
|
||||
slice.Status == ContentSliceStatus.Active &&
|
||||
slice.ResourceType == LearningContentResourceType.Collection &&
|
||||
slice.ResourceId == collection.Id),
|
||||
cancellationToken);
|
||||
|
||||
if (!collectionExists) throw new ContentNavigationNotFoundException("Question collection was not found.");
|
||||
|
||||
var emptyOptions = JsonDefaults.Array();
|
||||
var emptyCorrectOptionIndices = JsonDefaults.Array();
|
||||
var emptySubQuestions = JsonDefaults.Array();
|
||||
var query =
|
||||
from item in dbContext.QuestionCollectionItems.AsNoTracking()
|
||||
join question in dbContext.Questions.AsNoTracking()
|
||||
@@ -271,11 +299,6 @@ public sealed class ContentNavigationQueryService(IQuestionBankPersistence dbCon
|
||||
version == null ? null : version.Id,
|
||||
version == null ? null : version.Content,
|
||||
version == null ? emptyOptions : version.Options,
|
||||
version == null ? null : version.CorrectOptionIndex,
|
||||
version == null ? emptyCorrectOptionIndices : version.CorrectOptionIndices,
|
||||
version == null ? null : version.AnswerText,
|
||||
version == null ? null : version.Explanation,
|
||||
version == null ? emptySubQuestions : version.SubQuestions,
|
||||
version == null ? null : version.CodeLang,
|
||||
version == null ? null : version.CodeTemplate);
|
||||
|
||||
@@ -327,4 +350,4 @@ public sealed class ContentNavigationQueryService(IQuestionBankPersistence dbCon
|
||||
: value.Replace("_", string.Empty, StringComparison.Ordinal)
|
||||
.Replace("-", string.Empty, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +158,10 @@ internal abstract partial class DirectContentServiceBase
|
||||
|
||||
protected static void ApplyQuestionVersion(QuestionVersion version, QuestionWriteCommand command)
|
||||
{
|
||||
version.QuestionType = Normalize(command.Type) ?? "choice";
|
||||
version.TypeLabel = Normalize(command.TypeLabel);
|
||||
version.Difficulty = command.Difficulty;
|
||||
version.Tags = JsonArrayOrDefault(command.Tags);
|
||||
version.Content = Normalize(command.Content);
|
||||
version.Options = JsonArrayOrDefault(command.Options);
|
||||
version.CorrectOptionIndex = command.CorrectOptionIndex;
|
||||
|
||||
@@ -70,36 +70,13 @@ internal sealed class QuestionManagementService(DirectContentServiceDependencies
|
||||
actor.TenantId,
|
||||
SaasQuotaMetricCatalog.PrivateQuestionCount,
|
||||
cancellationToken: cancellationToken);
|
||||
QuestionVersion? version;
|
||||
if (command.CreateVersion || !question.CurrentVersionId.HasValue)
|
||||
{
|
||||
var nextVersionNo = await questionBankPersistence.QuestionVersions
|
||||
.Where(item => item.TenantId == actor.TenantId && item.QuestionId == question.Id)
|
||||
.Select(item => (int?)item.VersionNo)
|
||||
.MaxAsync(cancellationToken) ?? 0;
|
||||
version = BuildQuestionVersion(actor, question.Id, nextVersionNo + 1, command);
|
||||
questionBankPersistence.QuestionVersions.Add(version);
|
||||
question.CurrentVersionId = version.Id;
|
||||
}
|
||||
else
|
||||
{
|
||||
version = await questionBankPersistence.QuestionVersions.SingleOrDefaultAsync(
|
||||
item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.QuestionId == question.Id &&
|
||||
item.Id == question.CurrentVersionId.Value,
|
||||
cancellationToken);
|
||||
if (version is null)
|
||||
{
|
||||
version = BuildQuestionVersion(actor, question.Id, 1, command);
|
||||
questionBankPersistence.QuestionVersions.Add(version);
|
||||
question.CurrentVersionId = version.Id;
|
||||
}
|
||||
else
|
||||
{
|
||||
ApplyQuestionVersion(version, command);
|
||||
}
|
||||
}
|
||||
var nextVersionNo = await questionBankPersistence.QuestionVersions
|
||||
.Where(item => item.TenantId == actor.TenantId && item.QuestionId == question.Id)
|
||||
.Select(item => (int?)item.VersionNo)
|
||||
.MaxAsync(cancellationToken) ?? 0;
|
||||
var version = BuildQuestionVersion(actor, question.Id, nextVersionNo + 1, command);
|
||||
questionBankPersistence.QuestionVersions.Add(version);
|
||||
question.CurrentVersionId = version.Id;
|
||||
|
||||
await SyncPrimaryCollectionItemAsync(actor, question, cancellationToken);
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<IQuestionBankPersistence>(provider => provider.GetRequiredService<TikuDbContext>());
|
||||
services.AddScoped<IContentAssetPersistence>(provider => provider.GetRequiredService<TikuDbContext>());
|
||||
services.AddScoped<ILearningPersistence>(provider => provider.GetRequiredService<TikuDbContext>());
|
||||
services.AddScoped<ILearningAccessPersistence>(provider => provider.GetRequiredService<TikuDbContext>());
|
||||
services.AddScoped<ICommercePersistence>(provider => provider.GetRequiredService<TikuDbContext>());
|
||||
services.AddScoped<IPointsPersistence>(provider => provider.GetRequiredService<TikuDbContext>());
|
||||
services.AddScoped<IGrowthPersistence>(provider => provider.GetRequiredService<TikuDbContext>());
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
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 LearningAccessAdministrationService(
|
||||
ILearningAccessPersistence persistence,
|
||||
ILearningAccessService accessService) : ILearningAccessAdministrationService
|
||||
{
|
||||
public async Task<StudentTargetRegionItem?> GetTargetRegionAsync(
|
||||
LearningActor actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var business = await (
|
||||
from license in persistence.TenantLearningLicenses.AsNoTracking()
|
||||
join item in persistence.BusinessLines.AsNoTracking()
|
||||
on license.BusinessLineId equals item.Id
|
||||
where license.TenantId == actor.TenantId && license.IsPrimary
|
||||
select new { BusinessLineId = item.Id, item.TargetRegionCooldownDays })
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (business is null) return null;
|
||||
return await (
|
||||
from history in persistence.StudentTargetRegionHistory.AsNoTracking()
|
||||
join region in persistence.MarketRegions.AsNoTracking()
|
||||
on history.MarketRegionId equals region.Id
|
||||
where history.TenantId == actor.TenantId &&
|
||||
history.UserId == actor.UserId &&
|
||||
history.BusinessLineId == business.BusinessLineId &&
|
||||
history.IsCurrent
|
||||
select new StudentTargetRegionItem(
|
||||
region.Id,
|
||||
region.Code,
|
||||
region.Name,
|
||||
history.EffectiveAt,
|
||||
history.EffectiveAt.AddDays(business.TargetRegionCooldownDays)))
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<StudentTargetRegionItem> ChangeTargetRegionAsync(
|
||||
LearningActor actor,
|
||||
ChangeStudentTargetRegionCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var license = await (
|
||||
from item in persistence.TenantLearningLicenses
|
||||
join business in persistence.BusinessLines on item.BusinessLineId equals business.Id
|
||||
where item.TenantId == actor.TenantId &&
|
||||
item.IsPrimary &&
|
||||
item.Status == LearningLicenseStatus.Active
|
||||
select new
|
||||
{
|
||||
License = item,
|
||||
business.RegionAccessStrategy,
|
||||
business.TargetRegionCooldownDays
|
||||
})
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
if (license is null || license.RegionAccessStrategy !=
|
||||
LearningRegionAccessStrategy.NationalWithStudentTargetRegion)
|
||||
throw new LearningAccessException(
|
||||
"student_target_region_not_supported",
|
||||
"The tenant business does not use personal target regions.");
|
||||
|
||||
var region = await persistence.MarketRegions.AsNoTracking()
|
||||
.SingleOrDefaultAsync(item => item.Id == command.MarketRegionId && item.IsActive, cancellationToken)
|
||||
?? throw new LearningAccessException("market_region_not_found", "The target region was not found.");
|
||||
var regionLicensed = license.License.AllowsAnyTargetRegion ||
|
||||
await persistence.TenantLearningLicenseRegions.AsNoTracking().AnyAsync(
|
||||
item => item.TenantId == actor.TenantId &&
|
||||
item.LicenseId == license.License.Id &&
|
||||
item.MarketRegionId == region.Id,
|
||||
cancellationToken);
|
||||
if (!regionLicensed)
|
||||
throw new LearningAccessException(
|
||||
"student_target_region_not_licensed",
|
||||
"The selected target region is not covered by the tenant license.");
|
||||
|
||||
await using var transaction = await persistence.Database.BeginTransactionAsync(cancellationToken);
|
||||
var current = await persistence.StudentTargetRegionHistory.SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.BusinessLineId == license.License.BusinessLineId &&
|
||||
item.IsCurrent,
|
||||
cancellationToken);
|
||||
if (current?.MarketRegionId == region.Id)
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return new StudentTargetRegionItem(
|
||||
region.Id, region.Code, region.Name, current.EffectiveAt,
|
||||
current.EffectiveAt.AddDays(license.TargetRegionCooldownDays));
|
||||
}
|
||||
|
||||
if (current is not null && current.EffectiveAt.AddDays(license.TargetRegionCooldownDays) > now)
|
||||
throw new LearningAccessException(
|
||||
"student_target_region_cooldown",
|
||||
$"The target region cannot be changed before {current.EffectiveAt.AddDays(license.TargetRegionCooldownDays):O}.");
|
||||
if (current is not null)
|
||||
{
|
||||
current.IsCurrent = false;
|
||||
current.EndedAt = now;
|
||||
}
|
||||
|
||||
persistence.StudentTargetRegionHistory.Add(new StudentTargetRegionHistory
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
UserId = actor.UserId,
|
||||
BusinessLineId = license.License.BusinessLineId,
|
||||
MarketRegionId = region.Id,
|
||||
EffectiveAt = now,
|
||||
IsCurrent = true,
|
||||
ChangedBy = actor.UserId,
|
||||
Reason = "student_self_service"
|
||||
});
|
||||
await BumpVersionAsync(actor.TenantId, actor.UserId, false, cancellationToken);
|
||||
await persistence.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
await accessService.InvalidateAsync(actor.TenantId, actor.UserId, cancellationToken);
|
||||
return new StudentTargetRegionItem(
|
||||
region.Id, region.Code, region.Name, now, now.AddDays(license.TargetRegionCooldownDays));
|
||||
}
|
||||
|
||||
public async Task<StudentTargetRegionItem> OverrideTargetRegionAsync(
|
||||
Guid tenantId,
|
||||
OverrideStudentTargetRegionCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(command.Reason))
|
||||
throw new LearningAccessException(
|
||||
"student_target_region_override_reason_required",
|
||||
"An override reason is required.");
|
||||
|
||||
var studentExists = await persistence.TenantMemberships.AsNoTracking().AnyAsync(
|
||||
item => item.TenantId == tenantId &&
|
||||
item.UserId == command.UserId &&
|
||||
item.Role == TenantRole.Student &&
|
||||
item.Status == MembershipStatus.Active,
|
||||
cancellationToken);
|
||||
if (!studentExists)
|
||||
throw new LearningAccessException("student_not_found", "The active tenant student was not found.");
|
||||
|
||||
var license = await (
|
||||
from item in persistence.TenantLearningLicenses
|
||||
join business in persistence.BusinessLines on item.BusinessLineId equals business.Id
|
||||
where item.TenantId == tenantId &&
|
||||
item.IsPrimary &&
|
||||
item.Status == LearningLicenseStatus.Active
|
||||
select new
|
||||
{
|
||||
License = item,
|
||||
business.RegionAccessStrategy,
|
||||
business.TargetRegionCooldownDays
|
||||
})
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
if (license is null || license.RegionAccessStrategy !=
|
||||
LearningRegionAccessStrategy.NationalWithStudentTargetRegion)
|
||||
throw new LearningAccessException(
|
||||
"student_target_region_not_supported",
|
||||
"The tenant business does not use personal target regions.");
|
||||
|
||||
var region = await persistence.MarketRegions.AsNoTracking()
|
||||
.SingleOrDefaultAsync(item => item.Id == command.MarketRegionId && item.IsActive, cancellationToken)
|
||||
?? throw new LearningAccessException("market_region_not_found", "The target region was not found.");
|
||||
var regionLicensed = license.License.AllowsAnyTargetRegion ||
|
||||
await persistence.TenantLearningLicenseRegions.AsNoTracking().AnyAsync(
|
||||
item => item.TenantId == tenantId &&
|
||||
item.LicenseId == license.License.Id &&
|
||||
item.MarketRegionId == region.Id,
|
||||
cancellationToken);
|
||||
if (!regionLicensed)
|
||||
throw new LearningAccessException(
|
||||
"student_target_region_not_licensed",
|
||||
"The selected target region is not covered by the tenant license.");
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
await using var transaction = await persistence.Database.BeginTransactionAsync(cancellationToken);
|
||||
var current = await persistence.StudentTargetRegionHistory.SingleOrDefaultAsync(
|
||||
item => item.TenantId == tenantId &&
|
||||
item.UserId == command.UserId &&
|
||||
item.BusinessLineId == license.License.BusinessLineId &&
|
||||
item.IsCurrent,
|
||||
cancellationToken);
|
||||
if (current?.MarketRegionId != region.Id)
|
||||
{
|
||||
if (current is not null)
|
||||
{
|
||||
current.IsCurrent = false;
|
||||
current.EndedAt = now;
|
||||
}
|
||||
|
||||
persistence.StudentTargetRegionHistory.Add(new StudentTargetRegionHistory
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = command.UserId,
|
||||
BusinessLineId = license.License.BusinessLineId,
|
||||
MarketRegionId = region.Id,
|
||||
EffectiveAt = now,
|
||||
IsCurrent = true,
|
||||
ChangedBy = command.ChangedBy,
|
||||
Reason = command.Reason.Trim()
|
||||
});
|
||||
await BumpVersionAsync(tenantId, command.UserId, false, cancellationToken);
|
||||
await persistence.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
await accessService.InvalidateAsync(tenantId, command.UserId, cancellationToken);
|
||||
var effectiveAt = current?.MarketRegionId == region.Id ? current.EffectiveAt : now;
|
||||
return new StudentTargetRegionItem(
|
||||
region.Id, region.Code, region.Name, effectiveAt,
|
||||
effectiveAt.AddDays(license.TargetRegionCooldownDays));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyCollection<ClassContentAssignmentItem>> GetClassAssignmentsAsync(
|
||||
Guid tenantId,
|
||||
Guid classId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await persistence.ClassContentAssignments.AsNoTracking()
|
||||
.Where(item => item.TenantId == tenantId && item.ClassId == classId)
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Select(item => ToItem(item))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<ClassContentAssignmentItem> UpsertClassAssignmentAsync(
|
||||
Guid tenantId,
|
||||
Guid actorUserId,
|
||||
UpsertClassContentAssignmentCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (command.EndsAt.HasValue && command.EndsAt <= (command.StartsAt ?? now))
|
||||
throw new LearningAccessException("class_assignment_period_invalid", "Assignment end must follow its start.");
|
||||
var tenantClass = await persistence.TenantClasses.AsNoTracking().AnyAsync(
|
||||
item => item.TenantId == tenantId &&
|
||||
item.Id == command.ClassId &&
|
||||
item.Status == TenantRecordStatus.Active,
|
||||
cancellationToken);
|
||||
if (!tenantClass) throw new LearningAccessException("class_not_found", "The class was not found.");
|
||||
|
||||
var slice = await persistence.ContentSlices.AsNoTracking().SingleOrDefaultAsync(
|
||||
item => item.TenantId == tenantId &&
|
||||
item.Id == command.ContentSliceId &&
|
||||
item.ResourceType == command.ResourceType &&
|
||||
item.ResourceId == command.ResourceId &&
|
||||
item.Status == ContentSliceStatus.Active,
|
||||
cancellationToken) ?? throw new LearningAccessException(
|
||||
"content_slice_not_found", "The active content slice was not found.");
|
||||
var license = await persistence.TenantLearningLicenses.AsNoTracking().SingleOrDefaultAsync(
|
||||
item => item.TenantId == tenantId &&
|
||||
item.IsPrimary &&
|
||||
item.Status == LearningLicenseStatus.Active &&
|
||||
item.BusinessLineId == slice.BusinessLineId,
|
||||
cancellationToken) ?? throw new LearningAccessException(
|
||||
"learning_license_scope_mismatch", "The content is outside the tenant learning license.");
|
||||
if (slice.RegionScope == LearningRegionScopeKind.National && !license.IncludesNational)
|
||||
throw new LearningAccessException(
|
||||
"learning_license_scope_mismatch", "The tenant license does not include national content.");
|
||||
if (slice.MarketRegionId.HasValue && !license.AllowsAnyTargetRegion &&
|
||||
!await persistence.TenantLearningLicenseRegions.AsNoTracking().AnyAsync(
|
||||
item => item.TenantId == tenantId &&
|
||||
item.LicenseId == license.Id &&
|
||||
item.MarketRegionId == slice.MarketRegionId,
|
||||
cancellationToken))
|
||||
throw new LearningAccessException(
|
||||
"learning_license_scope_mismatch", "The tenant license does not include the content region.");
|
||||
|
||||
var assignment = command.Id.HasValue
|
||||
? await persistence.ClassContentAssignments.SingleOrDefaultAsync(
|
||||
item => item.TenantId == tenantId && item.Id == command.Id.Value,
|
||||
cancellationToken)
|
||||
: null;
|
||||
assignment ??= new ClassContentAssignment
|
||||
{
|
||||
TenantId = tenantId,
|
||||
ClassId = command.ClassId,
|
||||
ContentSliceOwnerTenantId = tenantId,
|
||||
ContentSliceId = slice.Id,
|
||||
CreatedBy = actorUserId
|
||||
};
|
||||
var isNew = persistence.Entry(assignment).State == EntityState.Detached;
|
||||
assignment.ClassId = command.ClassId;
|
||||
assignment.ContentSliceOwnerTenantId = tenantId;
|
||||
assignment.ContentSliceId = slice.Id;
|
||||
assignment.ResourceType = command.ResourceType;
|
||||
assignment.ResourceId = command.ResourceId;
|
||||
assignment.StartsAt = command.StartsAt ?? now;
|
||||
assignment.EndsAt = command.EndsAt;
|
||||
assignment.Status = ClassContentAssignmentStatus.Active;
|
||||
assignment.RevokedBy = null;
|
||||
assignment.RevokedReason = null;
|
||||
if (isNew) persistence.ClassContentAssignments.Add(assignment);
|
||||
await persistence.SaveChangesAsync(cancellationToken);
|
||||
await InvalidateClassMembersAsync(tenantId, command.ClassId, false, cancellationToken);
|
||||
return ToItem(assignment);
|
||||
}
|
||||
|
||||
public async Task<ClassContentAssignmentItem> RevokeClassAssignmentAsync(
|
||||
Guid tenantId,
|
||||
Guid actorUserId,
|
||||
Guid assignmentId,
|
||||
string? reason,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var assignment = await persistence.ClassContentAssignments.SingleOrDefaultAsync(
|
||||
item => item.TenantId == tenantId && item.Id == assignmentId,
|
||||
cancellationToken) ?? throw new LearningAccessException("class_assignment_not_found", "Assignment was not found.");
|
||||
assignment.Status = ClassContentAssignmentStatus.Revoked;
|
||||
assignment.RevokedBy = actorUserId;
|
||||
assignment.RevokedReason = string.IsNullOrWhiteSpace(reason) ? "revoked_by_teacher" : reason.Trim();
|
||||
assignment.EndsAt = DateTimeOffset.UtcNow;
|
||||
await persistence.SaveChangesAsync(cancellationToken);
|
||||
await InvalidateClassMembersAsync(tenantId, assignment.ClassId, true, cancellationToken);
|
||||
return ToItem(assignment);
|
||||
}
|
||||
|
||||
private async Task InvalidateClassMembersAsync(
|
||||
Guid tenantId,
|
||||
Guid classId,
|
||||
bool strongRevocation,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var userIds = await persistence.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);
|
||||
var versions = await persistence.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))
|
||||
{
|
||||
persistence.LearningAccessVersions.Add(new LearningAccessVersion
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
StrongRevocationVersion = strongRevocation ? 2 : 1
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
version.GrantVersion++;
|
||||
if (strongRevocation) version.StrongRevocationVersion++;
|
||||
version.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
await persistence.SaveChangesAsync(cancellationToken);
|
||||
await Task.WhenAll(userIds.Select(userId =>
|
||||
accessService.InvalidateAsync(tenantId, userId, cancellationToken)));
|
||||
}
|
||||
|
||||
private async Task BumpVersionAsync(
|
||||
Guid tenantId,
|
||||
Guid userId,
|
||||
bool strongRevocation,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var version = await persistence.LearningAccessVersions.SingleOrDefaultAsync(
|
||||
item => item.TenantId == tenantId && item.UserId == userId,
|
||||
cancellationToken);
|
||||
if (version is null)
|
||||
{
|
||||
version = new LearningAccessVersion
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
StrongRevocationVersion = strongRevocation ? 2 : 1
|
||||
};
|
||||
persistence.LearningAccessVersions.Add(version);
|
||||
}
|
||||
else
|
||||
{
|
||||
version.GrantVersion++;
|
||||
if (strongRevocation) version.StrongRevocationVersion++;
|
||||
version.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
private static ClassContentAssignmentItem ToItem(ClassContentAssignment item) => new(
|
||||
item.Id,
|
||||
item.ClassId,
|
||||
item.ContentSliceId,
|
||||
item.ResourceType,
|
||||
item.ResourceId,
|
||||
item.StartsAt,
|
||||
item.EndsAt,
|
||||
item.Status);
|
||||
}
|
||||
266
Tiku.Infrastructure/Learning/Access/LearningAccessService.cs
Normal file
266
Tiku.Infrastructure/Learning/Access/LearningAccessService.cs
Normal file
@@ -0,0 +1,266 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Domain.Commerce;
|
||||
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 LearningAccessService(
|
||||
ILearningAccessPersistence persistence,
|
||||
[FromKeyedServices(BusinessCachingServiceCollectionExtensions.CacheName)]
|
||||
IFusionCache cache) : ILearningAccessService
|
||||
{
|
||||
private static readonly TimeSpan SnapshotLifetime = TimeSpan.FromSeconds(30);
|
||||
|
||||
public async Task<LearningAccessSnapshot> GetSnapshotAsync(
|
||||
LearningActor actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await cache.GetOrSetAsync<LearningAccessSnapshot>(
|
||||
CacheKey(actor.TenantId, actor.UserId),
|
||||
(_, token) => CompileAsync(actor, token),
|
||||
options =>
|
||||
{
|
||||
options.Duration = SnapshotLifetime;
|
||||
options.MemoryCacheDuration = TimeSpan.FromSeconds(5);
|
||||
},
|
||||
token: cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<LearningResourceAccessDecision> EnsureResourceAccessAsync(
|
||||
LearningActor actor,
|
||||
LearningContentResourceType resourceType,
|
||||
Guid resourceId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var snapshot = await GetSnapshotAsync(actor, cancellationToken);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var slice = await persistence.ContentSlices.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.ResourceType == resourceType &&
|
||||
item.ResourceId == resourceId &&
|
||||
item.Status == ContentSliceStatus.Active)
|
||||
.OrderByDescending(item => item.ContentVersion)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (slice is null)
|
||||
throw new LearningAccessException(
|
||||
"learning_content_unclassified",
|
||||
"The learning resource has no active content classification.");
|
||||
|
||||
if (!snapshot.ContentSliceIds.Contains(slice.Id))
|
||||
throw new LearningAccessException(
|
||||
"learning_content_not_entitled",
|
||||
"The current student is not entitled to this learning resource.");
|
||||
|
||||
var entitlementId = await (
|
||||
from entitlement in persistence.Entitlements.AsNoTracking()
|
||||
join scope in persistence.LearningProductScopes.AsNoTracking()
|
||||
on new { entitlement.TenantId, ProductId = entitlement.LearningProductId }
|
||||
equals new { scope.TenantId, ProductId = (Guid?)scope.ProductId }
|
||||
where entitlement.TenantId == actor.TenantId &&
|
||||
entitlement.UserId == actor.UserId &&
|
||||
entitlement.Status == EntitlementStatus.Active &&
|
||||
entitlement.StartsAt <= now &&
|
||||
(entitlement.ExpiresAt == null || entitlement.ExpiresAt > now) &&
|
||||
scope.ContentSliceId == slice.Id
|
||||
select (Guid?)entitlement.Id)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (entitlementId.HasValue)
|
||||
return new LearningResourceAccessDecision(
|
||||
slice.Id,
|
||||
PracticeAccessMode.Package,
|
||||
entitlementId,
|
||||
null,
|
||||
snapshot);
|
||||
|
||||
var assignmentId = await (
|
||||
from member in persistence.TenantClassMembers.AsNoTracking()
|
||||
join assignment in persistence.ClassContentAssignments.AsNoTracking()
|
||||
on new { member.TenantId, member.ClassId } equals new { assignment.TenantId, assignment.ClassId }
|
||||
where member.TenantId == actor.TenantId &&
|
||||
member.UserId == actor.UserId &&
|
||||
member.MemberType == TenantClassMemberType.Student &&
|
||||
member.Status == TenantClassMemberStatus.Active &&
|
||||
assignment.ContentSliceId == slice.Id &&
|
||||
assignment.Status == ClassContentAssignmentStatus.Active &&
|
||||
assignment.StartsAt <= now &&
|
||||
(assignment.EndsAt == null || assignment.EndsAt > now)
|
||||
select (Guid?)assignment.Id)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (assignmentId.HasValue)
|
||||
return new LearningResourceAccessDecision(
|
||||
slice.Id,
|
||||
PracticeAccessMode.ClassAssignment,
|
||||
null,
|
||||
assignmentId,
|
||||
snapshot);
|
||||
|
||||
throw new LearningAccessException(
|
||||
"learning_content_grant_expired",
|
||||
"The learning grant expired while access was being evaluated.");
|
||||
}
|
||||
|
||||
public async Task EnsureStrongRevocationVersionAsync(
|
||||
LearningActor actor,
|
||||
long expectedVersion,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var snapshot = await GetSnapshotAsync(actor, cancellationToken);
|
||||
if (snapshot.StrongRevocationVersion != expectedVersion)
|
||||
throw new LearningAccessException(
|
||||
"practice_access_revoked",
|
||||
"The practice session was revoked and can no longer accept answers.");
|
||||
}
|
||||
|
||||
public async Task InvalidateAsync(
|
||||
Guid tenantId,
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await cache.RemoveAsync(CacheKey(tenantId, userId), token: cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<LearningAccessSnapshot> CompileAsync(
|
||||
LearningActor actor,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var tenantIsActive = await persistence.Tenants.AsNoTracking().AnyAsync(
|
||||
tenant => tenant.Id == actor.TenantId && tenant.Status == TenantStatus.Active,
|
||||
cancellationToken);
|
||||
if (!tenantIsActive)
|
||||
throw new LearningAccessException("learning_tenant_inactive", "The tenant is not active.");
|
||||
|
||||
var license = await (
|
||||
from item in persistence.TenantLearningLicenses.AsNoTracking()
|
||||
join business in persistence.BusinessLines.AsNoTracking()
|
||||
on item.BusinessLineId equals business.Id
|
||||
where item.TenantId == actor.TenantId &&
|
||||
item.IsPrimary &&
|
||||
item.Status == LearningLicenseStatus.Active &&
|
||||
item.StartsAt <= now &&
|
||||
(item.EndsAt == null || item.EndsAt > now) &&
|
||||
business.IsActive
|
||||
select new
|
||||
{
|
||||
License = item,
|
||||
business.RegionAccessStrategy
|
||||
})
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
if (license is null)
|
||||
throw new LearningAccessException(
|
||||
"learning_license_required",
|
||||
"The tenant has no active primary learning license.");
|
||||
|
||||
var licensedRegions = await persistence.TenantLearningLicenseRegions.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.LicenseId == license.License.Id)
|
||||
.Select(item => item.MarketRegionId)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var usesStudentTarget = license.RegionAccessStrategy ==
|
||||
LearningRegionAccessStrategy.NationalWithStudentTargetRegion;
|
||||
var targetRegionId = usesStudentTarget
|
||||
? await persistence.StudentTargetRegionHistory.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.BusinessLineId == license.License.BusinessLineId &&
|
||||
item.IsCurrent)
|
||||
.Select(item => (Guid?)item.MarketRegionId)
|
||||
.SingleOrDefaultAsync(cancellationToken)
|
||||
: null;
|
||||
if (usesStudentTarget && !targetRegionId.HasValue)
|
||||
throw new LearningAccessException(
|
||||
"student_target_region_required",
|
||||
"A target region must be selected before starting practice.");
|
||||
if (targetRegionId.HasValue &&
|
||||
!license.License.AllowsAnyTargetRegion &&
|
||||
!licensedRegions.Contains(targetRegionId.Value))
|
||||
throw new LearningAccessException(
|
||||
"student_target_region_not_licensed",
|
||||
"The selected target region is not covered by the tenant license.");
|
||||
|
||||
var effectiveRegionIds = usesStudentTarget
|
||||
? targetRegionId.HasValue ? new[] { targetRegionId.Value } : []
|
||||
: license.RegionAccessStrategy == LearningRegionAccessStrategy.NationalOnly
|
||||
? []
|
||||
: licensedRegions;
|
||||
|
||||
var entitlementRows = await (
|
||||
from entitlement in persistence.Entitlements.AsNoTracking()
|
||||
join scope in persistence.LearningProductScopes.AsNoTracking()
|
||||
on new { entitlement.TenantId, ProductId = entitlement.LearningProductId }
|
||||
equals new { scope.TenantId, ProductId = (Guid?)scope.ProductId }
|
||||
join slice in persistence.ContentSlices.AsNoTracking()
|
||||
on new { TenantId = scope.ContentSliceOwnerTenantId, Id = scope.ContentSliceId }
|
||||
equals new { slice.TenantId, slice.Id }
|
||||
where entitlement.TenantId == actor.TenantId &&
|
||||
entitlement.UserId == actor.UserId &&
|
||||
entitlement.Status == EntitlementStatus.Active &&
|
||||
entitlement.StartsAt <= now &&
|
||||
(entitlement.ExpiresAt == null || entitlement.ExpiresAt > now) &&
|
||||
slice.Status == ContentSliceStatus.Active &&
|
||||
slice.BusinessLineId == license.License.BusinessLineId &&
|
||||
((slice.RegionScope == LearningRegionScopeKind.National && license.License.IncludesNational) ||
|
||||
(slice.MarketRegionId.HasValue &&
|
||||
effectiveRegionIds.Contains(slice.MarketRegionId.Value)))
|
||||
select new { entitlement.ExpiresAt, slice.Id })
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
var assignmentRows = await (
|
||||
from member in persistence.TenantClassMembers.AsNoTracking()
|
||||
join assignment in persistence.ClassContentAssignments.AsNoTracking()
|
||||
on new { member.TenantId, member.ClassId } equals new { assignment.TenantId, assignment.ClassId }
|
||||
join slice in persistence.ContentSlices.AsNoTracking()
|
||||
on new { TenantId = assignment.ContentSliceOwnerTenantId, Id = assignment.ContentSliceId }
|
||||
equals new { slice.TenantId, slice.Id }
|
||||
where member.TenantId == actor.TenantId &&
|
||||
member.UserId == actor.UserId &&
|
||||
member.MemberType == TenantClassMemberType.Student &&
|
||||
member.Status == TenantClassMemberStatus.Active &&
|
||||
assignment.Status == ClassContentAssignmentStatus.Active &&
|
||||
assignment.StartsAt <= now &&
|
||||
(assignment.EndsAt == null || assignment.EndsAt > now) &&
|
||||
slice.Status == ContentSliceStatus.Active &&
|
||||
slice.BusinessLineId == license.License.BusinessLineId &&
|
||||
((slice.RegionScope == LearningRegionScopeKind.National && license.License.IncludesNational) ||
|
||||
(slice.MarketRegionId.HasValue &&
|
||||
effectiveRegionIds.Contains(slice.MarketRegionId.Value)))
|
||||
select new { assignment.Id, assignment.EndsAt, SliceId = slice.Id })
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
var version = await persistence.LearningAccessVersions.AsNoTracking()
|
||||
.SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
|
||||
cancellationToken);
|
||||
var expiryCandidates = entitlementRows.Where(item => item.ExpiresAt.HasValue)
|
||||
.Select(item => item.ExpiresAt!.Value)
|
||||
.Concat(assignmentRows.Where(item => item.EndsAt.HasValue).Select(item => item.EndsAt!.Value))
|
||||
.Append(license.License.EndsAt ?? now.AddHours(4))
|
||||
.Append(now.AddMinutes(2));
|
||||
|
||||
return new LearningAccessSnapshot(
|
||||
actor.TenantId,
|
||||
actor.UserId,
|
||||
license.License.BusinessLineId,
|
||||
license.RegionAccessStrategy,
|
||||
effectiveRegionIds.ToHashSet(),
|
||||
targetRegionId,
|
||||
entitlementRows.Select(item => item.Id)
|
||||
.Concat(assignmentRows.Select(item => item.SliceId))
|
||||
.ToHashSet(),
|
||||
assignmentRows.Select(item => item.Id).ToHashSet(),
|
||||
version?.GrantVersion ?? 1,
|
||||
Math.Max(version?.ContentVersion ?? 1, license.License.Version),
|
||||
version?.StrongRevocationVersion ?? 1,
|
||||
expiryCandidates.Min(),
|
||||
now);
|
||||
}
|
||||
|
||||
internal static string CacheKey(Guid tenantId, Guid userId) =>
|
||||
$"learning-access:v1:{tenantId:N}:{userId:N}";
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Learning;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Learning;
|
||||
|
||||
internal sealed class PlatformLearningAccessAdministrationService(
|
||||
ITenantExecutionScope tenantExecutionScope) : IPlatformLearningAccessAdministrationService
|
||||
{
|
||||
public Task<IReadOnlyCollection<LearningDictionaryItem>> GetBusinessLinesAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
ExecuteGlobalAsync<IReadOnlyCollection<LearningDictionaryItem>>(
|
||||
"List learning business lines", async (db, _, token) =>
|
||||
await db.BusinessLines.AsNoTracking().OrderBy(item => item.Code)
|
||||
.Select(item => new LearningDictionaryItem(
|
||||
item.Id, item.Code, item.Name, item.RegionAccessStrategy.ToString(), item.IsActive,
|
||||
item.RequiresBaseRegion, item.TargetRegionCooldownDays))
|
||||
.ToArrayAsync(token), cancellationToken);
|
||||
|
||||
public Task<LearningDictionaryItem> UpsertBusinessLineAsync(
|
||||
UpsertBusinessLineCommand command,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
ExecuteGlobalAsync("Upsert learning business line", async (db, _, token) =>
|
||||
{
|
||||
ValidateCodeAndName(command.Code, command.Name);
|
||||
var entity = command.Id.HasValue
|
||||
? await db.BusinessLines.SingleOrDefaultAsync(item => item.Id == command.Id.Value, token)
|
||||
: null;
|
||||
entity ??= new BusinessLine();
|
||||
var isNew = db.Entry(entity).State == EntityState.Detached;
|
||||
entity.Code = command.Code.Trim().ToLowerInvariant();
|
||||
entity.Name = command.Name.Trim();
|
||||
if (command.TargetRegionCooldownDays is < 0 or > 365)
|
||||
throw Error("target_region_cooldown_invalid", "Target-region cooldown must be between 0 and 365 days.");
|
||||
entity.RegionAccessStrategy = command.RegionAccessStrategy;
|
||||
entity.RequiresBaseRegion = command.RequiresBaseRegion;
|
||||
entity.TargetRegionCooldownDays = command.TargetRegionCooldownDays;
|
||||
entity.IsActive = command.IsActive;
|
||||
if (isNew) db.BusinessLines.Add(entity);
|
||||
await db.SaveChangesAsync(token);
|
||||
return new LearningDictionaryItem(
|
||||
entity.Id, entity.Code, entity.Name, entity.RegionAccessStrategy.ToString(), entity.IsActive,
|
||||
entity.RequiresBaseRegion, entity.TargetRegionCooldownDays);
|
||||
}, cancellationToken);
|
||||
|
||||
public Task<IReadOnlyCollection<LearningDictionaryItem>> GetMarketRegionsAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
ExecuteGlobalAsync<IReadOnlyCollection<LearningDictionaryItem>>(
|
||||
"List learning market regions", async (db, _, token) =>
|
||||
await db.MarketRegions.AsNoTracking().OrderBy(item => item.Code)
|
||||
.Select(item => new LearningDictionaryItem(
|
||||
item.Id, item.Code, item.Name, item.ParentCode, item.IsActive))
|
||||
.ToArrayAsync(token), cancellationToken);
|
||||
|
||||
public Task<LearningDictionaryItem> UpsertMarketRegionAsync(
|
||||
UpsertMarketRegionCommand command,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
ExecuteGlobalAsync("Upsert learning market region", async (db, _, token) =>
|
||||
{
|
||||
ValidateCodeAndName(command.Code, command.Name);
|
||||
var entity = command.Id.HasValue
|
||||
? await db.MarketRegions.SingleOrDefaultAsync(item => item.Id == command.Id.Value, token)
|
||||
: null;
|
||||
entity ??= new MarketRegion();
|
||||
var isNew = db.Entry(entity).State == EntityState.Detached;
|
||||
entity.Code = command.Code.Trim().ToLowerInvariant();
|
||||
entity.Name = command.Name.Trim();
|
||||
entity.ParentCode = string.IsNullOrWhiteSpace(command.ParentCode)
|
||||
? null
|
||||
: command.ParentCode.Trim().ToLowerInvariant();
|
||||
entity.IsActive = command.IsActive;
|
||||
if (isNew) db.MarketRegions.Add(entity);
|
||||
await db.SaveChangesAsync(token);
|
||||
return new LearningDictionaryItem(
|
||||
entity.Id, entity.Code, entity.Name, entity.ParentCode, entity.IsActive);
|
||||
}, cancellationToken);
|
||||
|
||||
public Task<TenantLearningLicenseItem?> GetTenantLicenseAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
ExecuteTenantAsync(tenantId, "Read tenant learning license", async (db, _, token) =>
|
||||
{
|
||||
var license = await db.TenantLearningLicenses.AsNoTracking()
|
||||
.Where(item => item.TenantId == tenantId && item.IsPrimary)
|
||||
.OrderByDescending(item => item.UpdatedAt)
|
||||
.FirstOrDefaultAsync(token);
|
||||
return license is null ? null : await ToLicenseItemAsync(db, license, token);
|
||||
}, cancellationToken);
|
||||
|
||||
public Task<TenantLearningLicenseItem> UpsertTenantLicenseAsync(
|
||||
UpsertTenantLearningLicenseCommand command,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
ExecuteTenantAsync(command.TenantId, "Upsert tenant learning license", async (db, access, token) =>
|
||||
{
|
||||
await RequireTenantAndBusinessAsync(db, command.TenantId, command.BusinessLineId, token);
|
||||
if (command.EndsAt.HasValue && command.EndsAt <= command.StartsAt)
|
||||
throw Error("learning_license_period_invalid", "License end must follow its start.");
|
||||
var business = await db.BusinessLines.Where(item => item.Id == command.BusinessLineId)
|
||||
.Select(item => new
|
||||
{
|
||||
item.RegionAccessStrategy,
|
||||
item.RequiresBaseRegion
|
||||
}).SingleAsync(token);
|
||||
var regionIds = command.MarketRegionIds.Distinct().ToArray();
|
||||
if (regionIds.Length != await db.MarketRegions.CountAsync(
|
||||
item => regionIds.Contains(item.Id) && item.IsActive, token))
|
||||
throw Error("market_region_not_found", "One or more active market regions were not found.");
|
||||
if (business.RequiresBaseRegion &&
|
||||
(!command.BaseMarketRegionId.HasValue || !regionIds.Contains(command.BaseMarketRegionId.Value)))
|
||||
throw Error("learning_license_base_region_required",
|
||||
"This business line requires one base region included in its region set.");
|
||||
if (business.RegionAccessStrategy is (
|
||||
LearningRegionAccessStrategy.NationalOnly or
|
||||
LearningRegionAccessStrategy.NationalWithStudentTargetRegion or
|
||||
LearningRegionAccessStrategy.NationalWithLicensedRegions) &&
|
||||
!command.IncludesNational)
|
||||
throw Error("learning_license_national_required",
|
||||
"This business-line strategy requires national content.");
|
||||
|
||||
var entity = command.Id.HasValue
|
||||
? await db.TenantLearningLicenses.SingleOrDefaultAsync(
|
||||
item => item.TenantId == command.TenantId && item.Id == command.Id.Value, token)
|
||||
: null;
|
||||
entity ??= new TenantLearningLicense { TenantId = command.TenantId };
|
||||
var isNew = db.Entry(entity).State == EntityState.Detached;
|
||||
var previousStatus = entity.Status;
|
||||
entity.BusinessLineId = command.BusinessLineId;
|
||||
entity.IsPrimary = command.IsPrimary;
|
||||
entity.IncludesNational = command.IncludesNational;
|
||||
entity.AllowsAnyTargetRegion = command.AllowsAnyTargetRegion;
|
||||
entity.Status = command.Status;
|
||||
entity.StartsAt = command.StartsAt;
|
||||
entity.EndsAt = command.EndsAt;
|
||||
if (!isNew) entity.Version++;
|
||||
if (isNew) db.TenantLearningLicenses.Add(entity);
|
||||
else
|
||||
await db.TenantLearningLicenseRegions
|
||||
.Where(item => item.TenantId == command.TenantId && item.LicenseId == entity.Id)
|
||||
.ExecuteDeleteAsync(token);
|
||||
db.TenantLearningLicenseRegions.AddRange(regionIds.Select(regionId =>
|
||||
new TenantLearningLicenseRegion
|
||||
{
|
||||
TenantId = command.TenantId,
|
||||
LicenseId = entity.Id,
|
||||
MarketRegionId = regionId,
|
||||
IsBaseRegion = regionId == command.BaseMarketRegionId
|
||||
}));
|
||||
var strong = previousStatus == LearningLicenseStatus.Active &&
|
||||
command.Status != LearningLicenseStatus.Active;
|
||||
var userIds = await ActiveStudentIdsAsync(db, command.TenantId, token);
|
||||
await BumpVersionsAsync(db, command.TenantId, userIds, content: true, strong, token);
|
||||
await db.SaveChangesAsync(token);
|
||||
await InvalidateAsync(access, command.TenantId, userIds, token);
|
||||
return await ToLicenseItemAsync(db, entity, token);
|
||||
}, cancellationToken);
|
||||
|
||||
public Task<IReadOnlyCollection<ContentSliceItem>> GetContentSlicesAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
ExecuteTenantAsync<IReadOnlyCollection<ContentSliceItem>>(
|
||||
tenantId, "List tenant content slices", async (db, _, token) =>
|
||||
await db.ContentSlices.AsNoTracking().Where(item => item.TenantId == tenantId)
|
||||
.OrderBy(item => item.ResourceType).ThenBy(item => item.ResourceId)
|
||||
.Select(item => ToSliceItem(item)).ToArrayAsync(token), cancellationToken);
|
||||
|
||||
public Task<ContentSliceItem> UpsertContentSliceAsync(
|
||||
UpsertContentSliceCommand command,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
ExecuteTenantAsync(command.TenantId, "Upsert tenant content slice", async (db, access, token) =>
|
||||
{
|
||||
await RequireTenantAndBusinessAsync(db, command.TenantId, command.BusinessLineId, token);
|
||||
if ((command.RegionScope == LearningRegionScopeKind.National) != !command.MarketRegionId.HasValue)
|
||||
throw Error("content_slice_region_invalid",
|
||||
"National slices must omit a region and regional slices must include one.");
|
||||
if (command.MarketRegionId.HasValue && !await db.MarketRegions.AnyAsync(
|
||||
item => item.Id == command.MarketRegionId && item.IsActive, token))
|
||||
throw Error("market_region_not_found", "The active market region was not found.");
|
||||
var entity = command.Id.HasValue
|
||||
? await db.ContentSlices.SingleOrDefaultAsync(
|
||||
item => item.TenantId == command.TenantId && item.Id == command.Id.Value, token)
|
||||
: null;
|
||||
entity ??= new ContentSlice { TenantId = command.TenantId };
|
||||
var isNew = db.Entry(entity).State == EntityState.Detached;
|
||||
entity.BusinessLineId = command.BusinessLineId;
|
||||
entity.MarketRegionId = command.MarketRegionId;
|
||||
entity.RegionScope = command.RegionScope;
|
||||
entity.ResourceType = command.ResourceType;
|
||||
entity.ResourceId = command.ResourceId;
|
||||
entity.Status = command.Status;
|
||||
if (!isNew) entity.ContentVersion++;
|
||||
if (isNew) db.ContentSlices.Add(entity);
|
||||
await db.SaveChangesAsync(token);
|
||||
var userIds = await AffectedStudentIdsAsync(db, command.TenantId, entity.Id, token);
|
||||
await BumpVersionsAsync(db, command.TenantId, userIds, content: true, strong: false, token);
|
||||
await db.SaveChangesAsync(token);
|
||||
await InvalidateAsync(access, command.TenantId, userIds, token);
|
||||
return ToSliceItem(entity);
|
||||
}, cancellationToken);
|
||||
|
||||
public Task<IReadOnlyCollection<LearningProductItem>> GetProductsAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
ExecuteTenantAsync<IReadOnlyCollection<LearningProductItem>>(
|
||||
tenantId, "List tenant learning products", async (db, _, token) =>
|
||||
{
|
||||
var products = await db.LearningProducts.AsNoTracking()
|
||||
.Where(item => item.TenantId == tenantId).OrderBy(item => item.Code).ToArrayAsync(token);
|
||||
var scopes = await db.LearningProductScopes.AsNoTracking()
|
||||
.Where(item => item.TenantId == tenantId)
|
||||
.GroupBy(item => item.ProductId)
|
||||
.ToDictionaryAsync(group => group.Key, group => group.Select(item => item.ContentSliceId).ToArray(), token);
|
||||
return products.Select(item => ToProductItem(item, scopes.GetValueOrDefault(item.Id, []))).ToArray();
|
||||
}, cancellationToken);
|
||||
|
||||
public Task<LearningProductItem> UpsertProductAsync(
|
||||
UpsertLearningProductCommand command,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
ExecuteTenantAsync(command.TenantId, "Upsert tenant learning product", async (db, access, token) =>
|
||||
{
|
||||
ValidateCodeAndName(command.Code, command.Name);
|
||||
await RequireTenantAndBusinessAsync(db, command.TenantId, command.BusinessLineId, token);
|
||||
var sliceIds = command.ContentSliceIds.Distinct().ToArray();
|
||||
if (sliceIds.Length != await db.ContentSlices.CountAsync(item =>
|
||||
item.TenantId == command.TenantId &&
|
||||
sliceIds.Contains(item.Id) &&
|
||||
item.BusinessLineId == command.BusinessLineId, token))
|
||||
throw Error("content_slice_not_found", "One or more content slices are invalid for this product.");
|
||||
var entity = command.Id.HasValue
|
||||
? await db.LearningProducts.SingleOrDefaultAsync(
|
||||
item => item.TenantId == command.TenantId && item.Id == command.Id.Value, token)
|
||||
: null;
|
||||
entity ??= new LearningProduct { TenantId = command.TenantId };
|
||||
var isNew = db.Entry(entity).State == EntityState.Detached;
|
||||
entity.BusinessLineId = command.BusinessLineId;
|
||||
entity.Code = command.Code.Trim().ToLowerInvariant();
|
||||
entity.Name = command.Name.Trim();
|
||||
entity.IsActive = command.IsActive;
|
||||
if (isNew) db.LearningProducts.Add(entity);
|
||||
else
|
||||
await db.LearningProductScopes.Where(item =>
|
||||
item.TenantId == command.TenantId && item.ProductId == entity.Id).ExecuteDeleteAsync(token);
|
||||
db.LearningProductScopes.AddRange(sliceIds.Select(sliceId => new LearningProductScope
|
||||
{
|
||||
TenantId = command.TenantId,
|
||||
ProductId = entity.Id,
|
||||
ContentSliceOwnerTenantId = command.TenantId,
|
||||
ContentSliceId = sliceId
|
||||
}));
|
||||
await db.SaveChangesAsync(token);
|
||||
var userIds = await db.Entitlements.AsNoTracking().Where(item =>
|
||||
item.TenantId == command.TenantId && item.LearningProductId == entity.Id)
|
||||
.Select(item => item.UserId).Distinct().ToArrayAsync(token);
|
||||
await BumpVersionsAsync(db, command.TenantId, userIds, content: false, strong: false, token);
|
||||
await db.SaveChangesAsync(token);
|
||||
await InvalidateAsync(access, command.TenantId, userIds, token);
|
||||
return ToProductItem(entity, sliceIds);
|
||||
}, cancellationToken);
|
||||
|
||||
private Task<TResult> ExecuteGlobalAsync<TResult>(
|
||||
string reason,
|
||||
Func<ILearningAccessPersistence, ILearningAccessService, CancellationToken, Task<TResult>> operation,
|
||||
CancellationToken cancellationToken) =>
|
||||
ExecuteAsync(null, true, reason, operation, cancellationToken);
|
||||
|
||||
private Task<TResult> ExecuteTenantAsync<TResult>(
|
||||
Guid tenantId,
|
||||
string reason,
|
||||
Func<ILearningAccessPersistence, ILearningAccessService, CancellationToken, Task<TResult>> operation,
|
||||
CancellationToken cancellationToken) =>
|
||||
ExecuteAsync(tenantId, false, reason, operation, cancellationToken);
|
||||
|
||||
private Task<TResult> ExecuteAsync<TResult>(
|
||||
Guid? tenantId,
|
||||
bool global,
|
||||
string reason,
|
||||
Func<ILearningAccessPersistence, ILearningAccessService, CancellationToken, Task<TResult>> operation,
|
||||
CancellationToken cancellationToken) =>
|
||||
tenantExecutionScope.ExecuteAsync(
|
||||
new SystemScopeRequest(
|
||||
tenantId,
|
||||
SystemScopeCallerType.Platform,
|
||||
nameof(PlatformLearningAccessAdministrationService),
|
||||
reason,
|
||||
Guid.NewGuid().ToString("N"),
|
||||
global),
|
||||
async (provider, token) => await operation(
|
||||
provider.GetRequiredService<ILearningAccessPersistence>(),
|
||||
provider.GetRequiredService<ILearningAccessService>(), token),
|
||||
cancellationToken);
|
||||
|
||||
private static async Task RequireTenantAndBusinessAsync(
|
||||
ILearningAccessPersistence db,
|
||||
Guid tenantId,
|
||||
Guid businessLineId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await db.Tenants.AnyAsync(item =>
|
||||
item.Id == tenantId && item.Mode == TenantMode.Saas, cancellationToken))
|
||||
throw Error("tenant_not_found", "The SaaS tenant was not found.");
|
||||
if (!await db.BusinessLines.AnyAsync(item =>
|
||||
item.Id == businessLineId && item.IsActive, cancellationToken))
|
||||
throw Error("business_line_not_found", "The active business line was not found.");
|
||||
}
|
||||
|
||||
private static async Task<Guid[]> ActiveStudentIdsAsync(
|
||||
ILearningAccessPersistence db,
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken) =>
|
||||
await db.TenantMemberships.AsNoTracking().Where(item =>
|
||||
item.TenantId == tenantId &&
|
||||
item.Role == TenantRole.Student &&
|
||||
item.Status == MembershipStatus.Active)
|
||||
.Select(item => item.UserId).Distinct().ToArrayAsync(cancellationToken);
|
||||
|
||||
private static async Task<Guid[]> AffectedStudentIdsAsync(
|
||||
ILearningAccessPersistence db,
|
||||
Guid tenantId,
|
||||
Guid sliceId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var entitled = await (
|
||||
from scope in db.LearningProductScopes.AsNoTracking()
|
||||
join entitlement in db.Entitlements.AsNoTracking()
|
||||
on new { scope.TenantId, ProductId = (Guid?)scope.ProductId }
|
||||
equals new { entitlement.TenantId, ProductId = entitlement.LearningProductId }
|
||||
where scope.TenantId == tenantId && scope.ContentSliceId == sliceId
|
||||
select entitlement.UserId)
|
||||
.Distinct().ToArrayAsync(cancellationToken);
|
||||
var assigned = await (
|
||||
from assignment in db.ClassContentAssignments.AsNoTracking()
|
||||
join member in db.TenantClassMembers.AsNoTracking()
|
||||
on new { assignment.TenantId, assignment.ClassId }
|
||||
equals new { member.TenantId, member.ClassId }
|
||||
where assignment.TenantId == tenantId &&
|
||||
assignment.ContentSliceId == sliceId &&
|
||||
member.MemberType == TenantClassMemberType.Student &&
|
||||
member.Status == TenantClassMemberStatus.Active
|
||||
select member.UserId)
|
||||
.Distinct().ToArrayAsync(cancellationToken);
|
||||
return entitled.Concat(assigned).Distinct().ToArray();
|
||||
}
|
||||
|
||||
private static async Task BumpVersionsAsync(
|
||||
ILearningAccessPersistence db,
|
||||
Guid tenantId,
|
||||
IReadOnlyCollection<Guid> userIds,
|
||||
bool content,
|
||||
bool strong,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (userIds.Count == 0) return;
|
||||
var versions = await db.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 };
|
||||
db.LearningAccessVersions.Add(version);
|
||||
}
|
||||
version.GrantVersion++;
|
||||
if (content) version.ContentVersion++;
|
||||
if (strong) version.StrongRevocationVersion++;
|
||||
version.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task InvalidateAsync(
|
||||
ILearningAccessService access,
|
||||
Guid tenantId,
|
||||
IEnumerable<Guid> userIds,
|
||||
CancellationToken cancellationToken) =>
|
||||
await Task.WhenAll(userIds.Distinct().Select(userId =>
|
||||
access.InvalidateAsync(tenantId, userId, cancellationToken)));
|
||||
|
||||
private static async Task<TenantLearningLicenseItem> ToLicenseItemAsync(
|
||||
ILearningAccessPersistence db,
|
||||
TenantLearningLicense entity,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var regionIds = await db.TenantLearningLicenseRegions.AsNoTracking()
|
||||
.Where(item => item.TenantId == entity.TenantId && item.LicenseId == entity.Id)
|
||||
.Select(item => item.MarketRegionId).ToArrayAsync(cancellationToken);
|
||||
return new TenantLearningLicenseItem(
|
||||
entity.Id, entity.TenantId, entity.BusinessLineId, entity.IsPrimary,
|
||||
entity.IncludesNational, entity.AllowsAnyTargetRegion, entity.Status,
|
||||
entity.StartsAt, entity.EndsAt, entity.Version, regionIds);
|
||||
}
|
||||
|
||||
private static ContentSliceItem ToSliceItem(ContentSlice entity) => new(
|
||||
entity.Id, entity.TenantId, entity.BusinessLineId, entity.MarketRegionId,
|
||||
entity.RegionScope, entity.ResourceType, entity.ResourceId, entity.ContentVersion, entity.Status);
|
||||
|
||||
private static LearningProductItem ToProductItem(
|
||||
LearningProduct entity,
|
||||
IReadOnlyCollection<Guid> sliceIds) => new(
|
||||
entity.Id, entity.TenantId, entity.BusinessLineId, entity.Code, entity.Name, entity.IsActive, sliceIds);
|
||||
|
||||
private static void ValidateCodeAndName(string code, string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(name))
|
||||
throw Error("learning_access_name_required", "Code and name are required.");
|
||||
}
|
||||
|
||||
private static LearningAccessException Error(string code, string message) => new(code, message);
|
||||
}
|
||||
@@ -15,7 +15,8 @@ internal sealed class LearningAnalyticsService(LearningServiceDependencies depen
|
||||
var answers = learningPersistence.AnswerRecords.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.IsCurrent &&
|
||||
learningPersistence.CurrentAnswers.Any(current =>
|
||||
current.TenantId == item.TenantId && current.AnswerRecordId == item.Id) &&
|
||||
item.GradingStatus != AnswerGradingStatus.PendingReview &&
|
||||
item.GradingStatus != AnswerGradingStatus.LegacyUnverified);
|
||||
return new LearningStatsItem(
|
||||
@@ -53,7 +54,8 @@ internal sealed class LearningAnalyticsService(LearningServiceDependencies depen
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.IsCurrent &&
|
||||
learningPersistence.CurrentAnswers.Any(current =>
|
||||
current.TenantId == item.TenantId && current.AnswerRecordId == item.Id) &&
|
||||
item.GradingStatus != AnswerGradingStatus.PendingReview &&
|
||||
item.GradingStatus != AnswerGradingStatus.LegacyUnverified &&
|
||||
item.AnsweredAt >= since)
|
||||
@@ -79,7 +81,8 @@ internal sealed class LearningAnalyticsService(LearningServiceDependencies depen
|
||||
{
|
||||
var rows = await learningPersistence.AnswerRecords.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId &&
|
||||
item.IsCurrent &&
|
||||
learningPersistence.CurrentAnswers.Any(current =>
|
||||
current.TenantId == item.TenantId && current.AnswerRecordId == item.Id) &&
|
||||
item.GradingStatus != AnswerGradingStatus.PendingReview &&
|
||||
item.GradingStatus != AnswerGradingStatus.LegacyUnverified)
|
||||
.GroupBy(item => item.UserId)
|
||||
|
||||
@@ -37,18 +37,21 @@ internal sealed class AnsweringService(LearningServiceDependencies dependencies)
|
||||
if (session is null)
|
||||
throw new LearningResourceNotFoundException("practice_session_not_found",
|
||||
"Practice session was not found.");
|
||||
await learningAccessService.EnsureStrongRevocationVersionAsync(
|
||||
actor, session.StrongRevocationVersion, cancellationToken);
|
||||
var deliveryVersion = await LoadDeliveryVersionAsync(actor.TenantId, sessionQuestion, cancellationToken);
|
||||
|
||||
var requestHash = HashAnswer(command);
|
||||
var existingOperation = await learningPersistence.LearningOperationIdempotencies.AsNoTracking().SingleOrDefaultAsync(
|
||||
var existingAnswer = await learningPersistence.AnswerRecords.AsNoTracking().SingleOrDefaultAsync(
|
||||
item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.PracticeSessionId == session.Id &&
|
||||
item.OperationType == "answer" &&
|
||||
item.IdempotencyKey == command.IdempotencyKey, cancellationToken);
|
||||
if (existingOperation is not null)
|
||||
item.IdempotencyKey == command.IdempotencyKey,
|
||||
cancellationToken);
|
||||
if (existingAnswer is not null)
|
||||
{
|
||||
if (!string.Equals(existingOperation.RequestHash, requestHash, StringComparison.Ordinal))
|
||||
if (!string.Equals(existingAnswer.RequestHash, requestHash, StringComparison.Ordinal))
|
||||
{
|
||||
AnswerConflicts.Add(1);
|
||||
throw new LearningValidationException("idempotency_conflict",
|
||||
@@ -56,8 +59,9 @@ internal sealed class AnsweringService(LearningServiceDependencies dependencies)
|
||||
}
|
||||
|
||||
IdempotencyReplays.Add(1);
|
||||
return existingOperation.ResponseSnapshot.Deserialize<AnswerRecordItem>()
|
||||
?? throw new InvalidOperationException("The stored answer response is invalid.");
|
||||
return ToItem(
|
||||
existingAnswer,
|
||||
DefersSolutionUntilSubmission(session.Mode) ? null : ToSolutionItem(deliveryVersion));
|
||||
}
|
||||
|
||||
if (session.ExpiresAt.HasValue && session.ExpiresAt <= now)
|
||||
@@ -69,13 +73,15 @@ internal sealed class AnsweringService(LearningServiceDependencies dependencies)
|
||||
}
|
||||
|
||||
EnsureAnswerSessionState(session, command);
|
||||
var current = await learningPersistence.AnswerRecords.SingleOrDefaultAsync(answer =>
|
||||
var current = await learningPersistence.CurrentAnswers.SingleOrDefaultAsync(answer =>
|
||||
answer.TenantId == actor.TenantId &&
|
||||
answer.UserId == actor.UserId &&
|
||||
answer.PracticeSessionId == session.Id &&
|
||||
answer.SessionQuestionId == sessionQuestion.Id &&
|
||||
answer.IsCurrent, cancellationToken);
|
||||
if (current is not null) current.IsCurrent = false;
|
||||
answer.SessionQuestionId == sessionQuestion.Id, cancellationToken);
|
||||
if (current is not null && command.ClientSequence <= current.ClientSequence)
|
||||
throw new LearningValidationException(
|
||||
"practice_client_sequence_conflict",
|
||||
"Client sequence must increase for a revised answer.");
|
||||
|
||||
var selectedIndices = command.SelectedOptionIndices?.Distinct().Order().ToArray() ?? [];
|
||||
var score = sessionQuestion.Score ?? 1;
|
||||
@@ -83,10 +89,10 @@ internal sealed class AnsweringService(LearningServiceDependencies dependencies)
|
||||
try
|
||||
{
|
||||
grading = QuestionGrader.Grade(new QuestionGradingInput(
|
||||
sessionQuestion.QuestionType,
|
||||
sessionQuestion.CorrectOptionIndexSnapshot,
|
||||
sessionQuestion.CorrectOptionIndicesSnapshot,
|
||||
sessionQuestion.AnswerTextSnapshot,
|
||||
deliveryVersion.QuestionType,
|
||||
deliveryVersion.CorrectOptionIndex,
|
||||
deliveryVersion.CorrectOptionIndices,
|
||||
deliveryVersion.AnswerText,
|
||||
sessionQuestion.GradingRulesSnapshot,
|
||||
selectedIndices,
|
||||
command.AnswerText,
|
||||
@@ -116,26 +122,36 @@ internal sealed class AnsweringService(LearningServiceDependencies dependencies)
|
||||
ClientSequence = command.ClientSequence,
|
||||
IdempotencyKey = command.IdempotencyKey.Trim(),
|
||||
RequestHash = requestHash,
|
||||
IsCurrent = true,
|
||||
AnsweredAt = now,
|
||||
CreatedAt = now
|
||||
};
|
||||
learningPersistence.AnswerRecords.Add(record);
|
||||
session.Version++;
|
||||
session.LastClientSequence = command.ClientSequence;
|
||||
var response = ToItem(record, session.Version);
|
||||
learningPersistence.LearningOperationIdempotencies.Add(new LearningOperationIdempotency
|
||||
if (current is null)
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
UserId = actor.UserId,
|
||||
PracticeSessionId = session.Id,
|
||||
OperationType = "answer",
|
||||
IdempotencyKey = command.IdempotencyKey.Trim(),
|
||||
RequestHash = requestHash,
|
||||
ResponseSnapshot = JsonSerializer.SerializeToElement(response),
|
||||
CompletedAt = now
|
||||
});
|
||||
|
||||
current = new CurrentAnswer
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
UserId = actor.UserId,
|
||||
PracticeSessionId = session.Id,
|
||||
SessionQuestionId = sessionQuestion.Id,
|
||||
AnswerRecordId = record.Id,
|
||||
Revision = record.Revision,
|
||||
ClientSequence = record.ClientSequence,
|
||||
UpdatedAt = now
|
||||
};
|
||||
learningPersistence.CurrentAnswers.Add(current);
|
||||
}
|
||||
else
|
||||
{
|
||||
current.AnswerRecordId = record.Id;
|
||||
current.Revision = record.Revision;
|
||||
current.ClientSequence = record.ClientSequence;
|
||||
current.Version++;
|
||||
current.UpdatedAt = now;
|
||||
}
|
||||
var response = ToItem(
|
||||
record,
|
||||
DefersSolutionUntilSubmission(session.Mode) ? null : ToSolutionItem(deliveryVersion));
|
||||
try
|
||||
{
|
||||
await unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
@@ -150,17 +166,17 @@ internal sealed class AnsweringService(LearningServiceDependencies dependencies)
|
||||
postgresException.SqlState == PostgresErrorCodes.UniqueViolation)
|
||||
{
|
||||
unitOfWork.ChangeTracker.Clear();
|
||||
var replay = await learningPersistence.LearningOperationIdempotencies.AsNoTracking().SingleOrDefaultAsync(item =>
|
||||
var replay = await learningPersistence.AnswerRecords.AsNoTracking().SingleOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.PracticeSessionId == session.Id &&
|
||||
item.OperationType == "answer" &&
|
||||
item.IdempotencyKey == command.IdempotencyKey, cancellationToken);
|
||||
if (replay is not null && string.Equals(replay.RequestHash, requestHash, StringComparison.Ordinal))
|
||||
{
|
||||
IdempotencyReplays.Add(1);
|
||||
return replay.ResponseSnapshot.Deserialize<AnswerRecordItem>()
|
||||
?? throw new InvalidOperationException("The stored answer response is invalid.");
|
||||
return ToItem(
|
||||
replay,
|
||||
DefersSolutionUntilSubmission(session.Mode) ? null : ToSolutionItem(deliveryVersion));
|
||||
}
|
||||
|
||||
AnswerConflicts.Add(1);
|
||||
|
||||
@@ -123,14 +123,28 @@ internal abstract partial class LearningActivityServiceBase
|
||||
.Take(assembly.QuestionLimit)
|
||||
.Select(question => question.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
var existingReferences = await questionBankPersistence.TenantQuestionReferences
|
||||
.Where(reference =>
|
||||
reference.TenantId == actor.TenantId &&
|
||||
reference.QuestionOwnerTenantId == actor.TenantId &&
|
||||
questionIds.Contains(reference.QuestionId))
|
||||
.ToDictionaryAsync(reference => reference.QuestionId, cancellationToken);
|
||||
var referenceIds = new List<Guid>(questionIds.Count);
|
||||
foreach (var questionId in questionIds)
|
||||
{
|
||||
var reference = await questionReferenceService.ResolveAsync(
|
||||
actor.TenantId,
|
||||
actor.UserId,
|
||||
new QuestionLocator(QuestionSource.Tenant, questionId),
|
||||
cancellationToken);
|
||||
if (!existingReferences.TryGetValue(questionId, out var reference))
|
||||
{
|
||||
reference = new TenantQuestionReference
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
QuestionOwnerTenantId = actor.TenantId,
|
||||
QuestionId = questionId,
|
||||
Source = QuestionSource.Tenant,
|
||||
CreatedBy = actor.UserId
|
||||
};
|
||||
questionBankPersistence.TenantQuestionReferences.Add(reference);
|
||||
}
|
||||
|
||||
referenceIds.Add(reference.Id);
|
||||
}
|
||||
|
||||
@@ -193,10 +207,10 @@ internal abstract partial class LearningActivityServiceBase
|
||||
reference.QuestionOwnerTenantId,
|
||||
reference.QuestionId,
|
||||
version.Id,
|
||||
question.Type,
|
||||
question.TypeLabel,
|
||||
question.Difficulty,
|
||||
question.Tags,
|
||||
version.QuestionType,
|
||||
version.TypeLabel,
|
||||
version.Difficulty,
|
||||
version.Tags,
|
||||
version.Content,
|
||||
version.Options,
|
||||
version.CorrectOptionIndex,
|
||||
@@ -231,6 +245,13 @@ internal abstract partial class LearningActivityServiceBase
|
||||
var systemLearning = provider.GetRequiredService<ILearningPersistence>();
|
||||
return await (
|
||||
from sessionQuestion in systemLearning.PracticeSessionQuestions.AsNoTracking()
|
||||
join version in systemQuestionBank.QuestionVersions.AsNoTracking()
|
||||
on new
|
||||
{
|
||||
TenantId = sessionQuestion.QuestionOwnerTenantId,
|
||||
Id = sessionQuestion.QuestionVersionId
|
||||
}
|
||||
equals new { version.TenantId, version.Id }
|
||||
where sessionQuestion.TenantId == tenantId &&
|
||||
sessionQuestion.PracticeSessionId == practiceSessionId
|
||||
orderby sessionQuestion.Position
|
||||
@@ -243,13 +264,13 @@ internal abstract partial class LearningActivityServiceBase
|
||||
: QuestionSource.Platform,
|
||||
sessionQuestion.QuestionId),
|
||||
sessionQuestion.QuestionId,
|
||||
sessionQuestion.QuestionType,
|
||||
sessionQuestion.TypeLabelSnapshot,
|
||||
sessionQuestion.DifficultySnapshot,
|
||||
sessionQuestion.TagsSnapshot,
|
||||
version.QuestionType,
|
||||
version.TypeLabel,
|
||||
version.Difficulty,
|
||||
version.Tags,
|
||||
sessionQuestion.QuestionVersionId,
|
||||
sessionQuestion.ContentSnapshot,
|
||||
sessionQuestion.OptionsSnapshot))
|
||||
version.Content,
|
||||
version.Options))
|
||||
.ToArrayAsync(token);
|
||||
},
|
||||
cancellationToken);
|
||||
@@ -292,6 +313,8 @@ internal abstract partial class LearningActivityServiceBase
|
||||
if (sessionQuestions.Length == 0)
|
||||
throw new LearningValidationException("practice_session_empty",
|
||||
"Practice session has no question snapshot.");
|
||||
var deliveryVersions = await LoadDeliveryVersionsAsync(
|
||||
actor.TenantId, sessionQuestions, cancellationToken);
|
||||
|
||||
var answers = await learningPersistence.AnswerRecords
|
||||
.AsNoTracking()
|
||||
@@ -299,7 +322,8 @@ internal abstract partial class LearningActivityServiceBase
|
||||
answer.TenantId == actor.TenantId &&
|
||||
answer.UserId == actor.UserId &&
|
||||
answer.PracticeSessionId == session.Id &&
|
||||
answer.IsCurrent)
|
||||
learningPersistence.CurrentAnswers.Any(current =>
|
||||
current.TenantId == answer.TenantId && current.AnswerRecordId == answer.Id))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var latestAnswers = answers.ToDictionary(answer => answer.SessionQuestionId);
|
||||
var totalQuestions = sessionQuestions.Length;
|
||||
@@ -333,6 +357,7 @@ internal abstract partial class LearningActivityServiceBase
|
||||
.Select(question =>
|
||||
{
|
||||
latestAnswers.TryGetValue(question.Id, out var answer);
|
||||
var deliveryVersion = deliveryVersions[question.QuestionVersionId];
|
||||
return new
|
||||
{
|
||||
sessionQuestionId = question.Id,
|
||||
@@ -345,10 +370,10 @@ internal abstract partial class LearningActivityServiceBase
|
||||
score = answer?.AwardedScore,
|
||||
totalScore = question.Score ?? 1,
|
||||
answeredAt = answer?.AnsweredAt,
|
||||
correctOptionIndex = isFinal ? question.CorrectOptionIndexSnapshot : null,
|
||||
correctOptionIndices = isFinal ? question.CorrectOptionIndicesSnapshot : JsonDefaults.Array(),
|
||||
answerText = isFinal ? question.AnswerTextSnapshot : null,
|
||||
explanation = isFinal ? question.ExplanationSnapshot : null
|
||||
correctOptionIndex = isFinal ? deliveryVersion.CorrectOptionIndex : null,
|
||||
correctOptionIndices = isFinal ? deliveryVersion.CorrectOptionIndices : JsonDefaults.Array(),
|
||||
answerText = isFinal ? deliveryVersion.AnswerText : null,
|
||||
explanation = isFinal ? deliveryVersion.Explanation : null
|
||||
};
|
||||
})
|
||||
.ToArray();
|
||||
@@ -482,7 +507,7 @@ internal abstract partial class LearningActivityServiceBase
|
||||
if (!exists) throw new LearningResourceNotFoundException("word_not_found", "Word was not found.");
|
||||
}
|
||||
|
||||
protected static AnswerRecordItem ToItem(AnswerRecord record, long sessionVersion)
|
||||
protected static AnswerRecordItem ToItem(AnswerRecord record, QuestionSolutionItem? solution = null)
|
||||
{
|
||||
return new AnswerRecordItem(
|
||||
record.Id,
|
||||
@@ -493,8 +518,63 @@ internal abstract partial class LearningActivityServiceBase
|
||||
record.GradingStatus == AnswerGradingStatus.PendingReview ? "pending_review" : "accepted",
|
||||
record.Revision,
|
||||
record.ClientSequence,
|
||||
sessionVersion,
|
||||
record.AnsweredAt);
|
||||
record.AnsweredAt,
|
||||
solution);
|
||||
}
|
||||
|
||||
protected async Task<QuestionVersion> LoadDeliveryVersionAsync(
|
||||
Guid tenantId,
|
||||
PracticeSessionQuestion sessionQuestion,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await tenantExecutionScope.ExecuteAsync(
|
||||
new SystemScopeRequest(
|
||||
tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(LearningActivityServiceBase),
|
||||
"Read immutable question delivery version", Guid.NewGuid().ToString("N")),
|
||||
async (provider, token) =>
|
||||
{
|
||||
var questions = provider.GetRequiredService<IQuestionBankPersistence>();
|
||||
return await questions.QuestionVersions.AsNoTracking().SingleAsync(version =>
|
||||
version.TenantId == sessionQuestion.QuestionOwnerTenantId &&
|
||||
version.QuestionId == sessionQuestion.QuestionId &&
|
||||
version.Id == sessionQuestion.QuestionVersionId, token);
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
protected async Task<IReadOnlyDictionary<Guid, QuestionVersion>> LoadDeliveryVersionsAsync(
|
||||
Guid tenantId,
|
||||
IReadOnlyCollection<PracticeSessionQuestion> sessionQuestions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var versionIds = sessionQuestions.Select(item => item.QuestionVersionId).Distinct().ToArray();
|
||||
return await tenantExecutionScope.ExecuteAsync(
|
||||
new SystemScopeRequest(
|
||||
tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(LearningActivityServiceBase),
|
||||
"Read immutable question delivery versions", Guid.NewGuid().ToString("N")),
|
||||
async (provider, token) =>
|
||||
{
|
||||
var questions = provider.GetRequiredService<IQuestionBankPersistence>();
|
||||
return await questions.QuestionVersions.AsNoTracking()
|
||||
.Where(version => versionIds.Contains(version.Id))
|
||||
.ToDictionaryAsync(version => version.Id, token);
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
protected static QuestionSolutionItem ToSolutionItem(QuestionVersion version)
|
||||
{
|
||||
return new QuestionSolutionItem(
|
||||
version.CorrectOptionIndex,
|
||||
version.CorrectOptionIndices,
|
||||
version.AnswerText,
|
||||
version.Explanation);
|
||||
}
|
||||
|
||||
protected static bool DefersSolutionUntilSubmission(string mode)
|
||||
{
|
||||
var normalized = NormalizeEnumValue(mode);
|
||||
return normalized is "exam" or "mock" or "mockexam" or "paper" or "testpaper";
|
||||
}
|
||||
|
||||
protected static WordProgressItem ToItem(UserWordProgress item)
|
||||
@@ -529,7 +609,11 @@ internal abstract partial class LearningActivityServiceBase
|
||||
item.TotalScore,
|
||||
item.AccessMode,
|
||||
item.AccessEntitlementId,
|
||||
item.AccessClassAssignmentId,
|
||||
item.ConsumedFreeQuota,
|
||||
item.AccessGrantVersion,
|
||||
item.StrongRevocationVersion,
|
||||
item.AuthorizationExpiresAt,
|
||||
item.AccessSnapshot,
|
||||
item.StartedAt,
|
||||
item.FinishedAt,
|
||||
@@ -590,12 +674,6 @@ internal abstract partial class LearningActivityServiceBase
|
||||
if (session.Status != PracticeSessionStatus.Active)
|
||||
throw new LearningValidationException("practice_session_not_active",
|
||||
"Only an active practice session accepts answers.");
|
||||
if (session.Version != command.ExpectedSessionVersion)
|
||||
throw new LearningValidationException("practice_session_version_conflict",
|
||||
"The practice session changed. Reload it before answering.");
|
||||
if (command.ClientSequence <= session.LastClientSequence)
|
||||
throw new LearningValidationException("practice_client_sequence_conflict",
|
||||
"Client sequence must increase within a practice session.");
|
||||
}
|
||||
|
||||
protected static JsonElement BuildGradingRules(QuestionSelection selection)
|
||||
@@ -614,7 +692,6 @@ internal abstract partial class LearningActivityServiceBase
|
||||
return Hash(JsonSerializer.Serialize(new
|
||||
{
|
||||
command.SessionQuestionId,
|
||||
command.ExpectedSessionVersion,
|
||||
command.ClientSequence,
|
||||
selectedOptionIndices = command.SelectedOptionIndices?.Distinct().Order().ToArray() ?? [],
|
||||
answerText = command.AnswerText?.Trim()
|
||||
|
||||
@@ -14,6 +14,7 @@ internal sealed record LearningServiceDependencies(
|
||||
IIdentityPersistence IdentityPersistence,
|
||||
IQuestionReferenceService QuestionReferenceService,
|
||||
IPublicQuestionAccessPolicy PublicQuestionAccessPolicy,
|
||||
ILearningAccessService LearningAccessService,
|
||||
ITenantExecutionScope TenantExecutionScope,
|
||||
ILogger<LearningActivityServiceBase> Logger);
|
||||
|
||||
@@ -26,6 +27,7 @@ internal abstract partial class LearningActivityServiceBase(LearningServiceDepen
|
||||
protected IModulePersistence unitOfWork { get; } = dependencies.LearningPersistence;
|
||||
protected IQuestionReferenceService questionReferenceService { get; } = dependencies.QuestionReferenceService;
|
||||
protected IPublicQuestionAccessPolicy publicQuestionAccessPolicy { get; } = dependencies.PublicQuestionAccessPolicy;
|
||||
protected ILearningAccessService learningAccessService { get; } = dependencies.LearningAccessService;
|
||||
protected ITenantExecutionScope tenantExecutionScope { get; } = dependencies.TenantExecutionScope;
|
||||
protected ILogger<LearningActivityServiceBase> logger { get; } = dependencies.Logger;
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ internal sealed class PracticeSessionService(LearningServiceDependencies depende
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var assembly = await BuildPracticeAssemblyAsync(actor.TenantId, command, cancellationToken);
|
||||
var access = await ResolvePracticeAccessAsync(actor, assembly, cancellationToken);
|
||||
var questionReferenceIds = await CollectQuestionReferenceIdsAsync(actor, assembly, cancellationToken);
|
||||
if (questionReferenceIds.Count == 0)
|
||||
throw new LearningValidationException("no_practice_questions",
|
||||
@@ -33,6 +34,9 @@ internal sealed class PracticeSessionService(LearningServiceDependencies depende
|
||||
await publicQuestionAccessPolicy.EnsureCanStartAsync(actor.TenantId, cancellationToken);
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var sessionExpiry = assembly.DurationMinutes.HasValue
|
||||
? now.AddMinutes(Math.Min(240, assembly.DurationMinutes.Value + 15))
|
||||
: now.AddHours(2);
|
||||
var session = new PracticeSession
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
@@ -47,14 +51,24 @@ internal sealed class PracticeSessionService(LearningServiceDependencies depende
|
||||
QuestionCount = questionReferenceIds.Count,
|
||||
DurationMinutes = assembly.DurationMinutes,
|
||||
TotalScore = assembly.TotalScore,
|
||||
ExpiresAt = assembly.DurationMinutes.HasValue
|
||||
? now.AddMinutes(assembly.DurationMinutes.Value)
|
||||
: null,
|
||||
AccessMode = PracticeAccessMode.Free,
|
||||
ConsumedFreeQuota = questionReferenceIds.Count,
|
||||
ExpiresAt = sessionExpiry,
|
||||
AccessMode = access.AccessMode,
|
||||
AccessEntitlementId = access.EntitlementId,
|
||||
AccessClassAssignmentId = access.ClassAssignmentId,
|
||||
ConsumedFreeQuota = 0,
|
||||
AccessGrantVersion = access.Snapshot.GrantVersion,
|
||||
StrongRevocationVersion = access.Snapshot.StrongRevocationVersion,
|
||||
AuthorizationExpiresAt = sessionExpiry,
|
||||
AccessSnapshot = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
strategy = "v1_free",
|
||||
strategy = "compiled_access_v1",
|
||||
contentSliceId = access.ContentSliceId,
|
||||
businessLineId = access.Snapshot.BusinessLineId,
|
||||
regionAccessStrategy = access.Snapshot.RegionAccessStrategy,
|
||||
targetRegionId = access.Snapshot.TargetRegionId,
|
||||
grantVersion = access.Snapshot.GrantVersion,
|
||||
contentVersion = access.Snapshot.ContentVersion,
|
||||
strongRevocationVersion = access.Snapshot.StrongRevocationVersion,
|
||||
requestedCount = assembly.QuestionLimit,
|
||||
grantedCount = questionReferenceIds.Count
|
||||
}),
|
||||
@@ -96,12 +110,6 @@ internal sealed class PracticeSessionService(LearningServiceDependencies depende
|
||||
TypeLabelSnapshot = selection.TypeLabel,
|
||||
DifficultySnapshot = selection.Difficulty,
|
||||
TagsSnapshot = selection.Tags,
|
||||
ContentSnapshot = selection.Content,
|
||||
OptionsSnapshot = selection.Options,
|
||||
CorrectOptionIndexSnapshot = selection.CorrectOptionIndex,
|
||||
CorrectOptionIndicesSnapshot = selection.CorrectOptionIndices,
|
||||
AnswerTextSnapshot = selection.AnswerText,
|
||||
ExplanationSnapshot = selection.Explanation,
|
||||
GradingRulesSnapshot = BuildGradingRules(selection),
|
||||
SnapshotVersion = 1
|
||||
}));
|
||||
@@ -111,10 +119,18 @@ internal sealed class PracticeSessionService(LearningServiceDependencies depende
|
||||
UserId = actor.UserId,
|
||||
PracticeSessionId = session.Id,
|
||||
EventType = PracticeAccessEventType.SessionCreated,
|
||||
AccessMode = PracticeAccessEventMode.Free,
|
||||
AccessMode = access.AccessMode switch
|
||||
{
|
||||
PracticeAccessMode.Package => PracticeAccessEventMode.Package,
|
||||
PracticeAccessMode.ClassAssignment => PracticeAccessEventMode.ClassAssignment,
|
||||
PracticeAccessMode.Staff => PracticeAccessEventMode.Staff,
|
||||
PracticeAccessMode.Svip => PracticeAccessEventMode.Svip,
|
||||
_ => PracticeAccessEventMode.Free
|
||||
},
|
||||
RequestedCount = assembly.QuestionLimit,
|
||||
GrantedCount = selections.Count,
|
||||
ConsumedFreeQuota = selections.Count,
|
||||
ConsumedFreeQuota = 0,
|
||||
EntitlementId = access.EntitlementId,
|
||||
Metadata = session.AccessSnapshot
|
||||
});
|
||||
|
||||
@@ -128,6 +144,8 @@ internal sealed class PracticeSessionService(LearningServiceDependencies depende
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var session = await GetPracticeSessionAsync(actor, filter.PracticeSessionId, cancellationToken);
|
||||
await learningAccessService.EnsureStrongRevocationVersionAsync(
|
||||
actor, session.StrongRevocationVersion, cancellationToken);
|
||||
var orderedQuestions = await LoadSessionQuestionItemsAsync(
|
||||
actor.TenantId,
|
||||
session.Id,
|
||||
@@ -139,17 +157,43 @@ internal sealed class PracticeSessionService(LearningServiceDependencies depende
|
||||
answer.TenantId == actor.TenantId &&
|
||||
answer.UserId == actor.UserId &&
|
||||
answer.PracticeSessionId == session.Id &&
|
||||
answer.IsCurrent)
|
||||
learningPersistence.CurrentAnswers.Any(current =>
|
||||
current.TenantId == answer.TenantId && current.AnswerRecordId == answer.Id))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var answersByQuestion = answers
|
||||
.GroupBy(answer => answer.SessionQuestionId)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => ToItem(
|
||||
group.OrderByDescending(answer => answer.Revision).First(),
|
||||
session.Version));
|
||||
group.OrderByDescending(answer => answer.Revision).First()));
|
||||
|
||||
return new PracticeSessionDetailItem(ToItem(session), orderedQuestions, answersByQuestion);
|
||||
var revealAll = session.Status is PracticeSessionStatus.Submitted or PracticeSessionStatus.PendingReview;
|
||||
var revealAnswered = !DefersSolutionUntilSubmission(session.Mode);
|
||||
var revealQuestionIds = revealAll
|
||||
? orderedQuestions.Select(item => item.SessionQuestionId).ToArray()
|
||||
: revealAnswered ? answersByQuestion.Keys.ToArray() : [];
|
||||
var solutions = revealQuestionIds.Length == 0
|
||||
? new Dictionary<Guid, QuestionSolutionItem>()
|
||||
: await LoadSolutionsAsync(actor.TenantId, session.Id, revealQuestionIds, cancellationToken);
|
||||
|
||||
return new PracticeSessionDetailItem(ToItem(session), orderedQuestions, answersByQuestion, solutions);
|
||||
}
|
||||
|
||||
private async Task<Dictionary<Guid, QuestionSolutionItem>> LoadSolutionsAsync(
|
||||
Guid tenantId,
|
||||
Guid sessionId,
|
||||
IReadOnlyCollection<Guid> revealQuestionIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sessionQuestions = await learningPersistence.PracticeSessionQuestions.AsNoTracking()
|
||||
.Where(item => item.TenantId == tenantId &&
|
||||
item.PracticeSessionId == sessionId &&
|
||||
revealQuestionIds.Contains(item.Id))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var versions = await LoadDeliveryVersionsAsync(tenantId, sessionQuestions, cancellationToken);
|
||||
return sessionQuestions.ToDictionary(
|
||||
item => item.Id,
|
||||
item => ToSolutionItem(versions[item.QuestionVersionId]));
|
||||
}
|
||||
|
||||
public async Task<PracticeSessionReportItem> SubmitPracticeSessionAsync(
|
||||
@@ -162,6 +206,8 @@ internal sealed class PracticeSessionService(LearningServiceDependencies depende
|
||||
|
||||
await using var transaction = await unitOfWork.Database.BeginTransactionAsync(cancellationToken);
|
||||
var session = await GetPracticeSessionAsync(actor, command.PracticeSessionId, cancellationToken);
|
||||
await learningAccessService.EnsureStrongRevocationVersionAsync(
|
||||
actor, session.StrongRevocationVersion, cancellationToken);
|
||||
var requestHash = HashSubmission(command);
|
||||
var existingOperation = await learningPersistence.LearningOperationIdempotencies.AsNoTracking().SingleOrDefaultAsync(
|
||||
item =>
|
||||
@@ -238,4 +284,44 @@ internal sealed class PracticeSessionService(LearningServiceDependencies depende
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private async Task<LearningResourceAccessDecision> ResolvePracticeAccessAsync(
|
||||
LearningActor actor,
|
||||
PracticeAssembly assembly,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (assembly.BlueprintId.HasValue)
|
||||
return await learningAccessService.EnsureResourceAccessAsync(
|
||||
actor, LearningContentResourceType.Blueprint, assembly.BlueprintId.Value, cancellationToken);
|
||||
if (assembly.CollectionId.HasValue)
|
||||
return await learningAccessService.EnsureResourceAccessAsync(
|
||||
actor, LearningContentResourceType.Collection, assembly.CollectionId.Value, cancellationToken);
|
||||
if (assembly.ContentNodeId.HasValue)
|
||||
return await learningAccessService.EnsureResourceAccessAsync(
|
||||
actor, LearningContentResourceType.ContentNode, assembly.ContentNodeId.Value, cancellationToken);
|
||||
if (assembly.EntryId.HasValue)
|
||||
return await learningAccessService.EnsureResourceAccessAsync(
|
||||
actor, LearningContentResourceType.ContentEntry, assembly.EntryId.Value, cancellationToken);
|
||||
if (assembly.TargetId.HasValue &&
|
||||
string.Equals(assembly.TargetType?.Replace("_", string.Empty), "questionbank",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
return await learningAccessService.EnsureResourceAccessAsync(
|
||||
actor, LearningContentResourceType.QuestionBank, assembly.TargetId.Value, cancellationToken);
|
||||
|
||||
var snapshot = await learningAccessService.GetSnapshotAsync(actor, cancellationToken);
|
||||
if (snapshot.ContentSliceIds.Count == 0)
|
||||
throw new LearningAccessException(
|
||||
"learning_content_not_entitled",
|
||||
"The current student has no active learning content grant.");
|
||||
return new LearningResourceAccessDecision(
|
||||
snapshot.ContentSliceIds.First(),
|
||||
snapshot.ClassAssignmentIds.Count > 0
|
||||
? PracticeAccessMode.ClassAssignment
|
||||
: PracticeAccessMode.Package,
|
||||
null,
|
||||
snapshot.ClassAssignmentIds.FirstOrDefault() is { } assignmentId && assignmentId != Guid.Empty
|
||||
? assignmentId
|
||||
: null,
|
||||
snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ internal static class LearningModule
|
||||
internal static IServiceCollection AddLearningModule(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<LearningServiceDependencies>();
|
||||
services.AddScoped<ILearningAccessService, LearningAccessService>();
|
||||
services.AddScoped<ILearningAccessAdministrationService, LearningAccessAdministrationService>();
|
||||
services.AddScoped<IPlatformLearningAccessAdministrationService,
|
||||
PlatformLearningAccessAdministrationService>();
|
||||
services.AddScoped<ILearningAnalyticsService, LearningAnalyticsService>();
|
||||
services.AddScoped<IAnsweringService, AnsweringService>();
|
||||
services.AddScoped<IQuestionReviewService, QuestionReviewService>();
|
||||
|
||||
@@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Learning;
|
||||
using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence.Configurations;
|
||||
@@ -187,6 +188,10 @@ internal sealed class EntitlementConfiguration : IEntityTypeConfiguration<Entitl
|
||||
builder.Property(entity => entity.StartsAt).HasDefaultValueSql("now()");
|
||||
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.Status, entity.ExpiresAt });
|
||||
builder.HasOne<LearningProduct>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.LearningProductId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne<Tenant>().WithMany().HasForeignKey(entity => entity.TenantId).OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.UserId).OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.RevokedBy).OnDelete(DeleteBehavior.SetNull);
|
||||
@@ -576,4 +581,4 @@ internal sealed class
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.ActorUserId).OnDelete(DeleteBehavior.SetNull);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Learning;
|
||||
using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence.Configurations;
|
||||
|
||||
internal sealed class BusinessLineConfiguration : IEntityTypeConfiguration<BusinessLine>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<BusinessLine> builder)
|
||||
{
|
||||
builder.ConfigureEntity("business_lines");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(entity => entity.Code).HasMaxLength(50);
|
||||
builder.Property(entity => entity.Name).HasMaxLength(100);
|
||||
builder.Property(entity => entity.RegionAccessStrategy).HasSnakeCaseEnum().HasMaxLength(64);
|
||||
builder.Property(entity => entity.TargetRegionCooldownDays).HasDefaultValue(30);
|
||||
builder.ToTable(table => table.HasCheckConstraint(
|
||||
"ck_business_lines_target_region_cooldown_days",
|
||||
"target_region_cooldown_days between 0 and 365"));
|
||||
builder.HasIndex(entity => entity.Code).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class MarketRegionConfiguration : IEntityTypeConfiguration<MarketRegion>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<MarketRegion> builder)
|
||||
{
|
||||
builder.ConfigureEntity("market_regions");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(entity => entity.Code).HasMaxLength(50);
|
||||
builder.Property(entity => entity.Name).HasMaxLength(100);
|
||||
builder.Property(entity => entity.ParentCode).HasMaxLength(50);
|
||||
builder.HasIndex(entity => entity.Code).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TenantLearningLicenseConfiguration : IEntityTypeConfiguration<TenantLearningLicense>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TenantLearningLicense> builder)
|
||||
{
|
||||
builder.ConfigureTenantEntity("tenant_learning_licenses");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.Version).IsConcurrencyToken().HasDefaultValue(1L);
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.BusinessLineId, entity.Status });
|
||||
builder.HasIndex(entity => entity.TenantId).IsUnique().HasFilter("is_primary and status = 'active'");
|
||||
builder.HasOne<BusinessLine>().WithMany().HasForeignKey(entity => entity.BusinessLineId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TenantLearningLicenseRegionConfiguration : IEntityTypeConfiguration<TenantLearningLicenseRegion>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TenantLearningLicenseRegion> builder)
|
||||
{
|
||||
builder.ConfigureEntity("tenant_learning_license_regions");
|
||||
builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id });
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.LicenseId, entity.MarketRegionId }).IsUnique();
|
||||
builder.HasOne<TenantLearningLicense>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.LicenseId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<MarketRegion>().WithMany().HasForeignKey(entity => entity.MarketRegionId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class LearningProductConfiguration : IEntityTypeConfiguration<LearningProduct>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<LearningProduct> builder)
|
||||
{
|
||||
builder.ConfigureTenantEntity("learning_products");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(entity => entity.Code).HasMaxLength(100);
|
||||
builder.Property(entity => entity.Name).HasMaxLength(200);
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.Code }).IsUnique();
|
||||
builder.HasOne<BusinessLine>().WithMany().HasForeignKey(entity => entity.BusinessLineId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class LearningProductScopeConfiguration : IEntityTypeConfiguration<LearningProductScope>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<LearningProductScope> builder)
|
||||
{
|
||||
builder.ConfigureEntity("learning_product_scopes");
|
||||
builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id });
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.ProductId, entity.ContentSliceId }).IsUnique();
|
||||
builder.HasOne<LearningProduct>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.ProductId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<ContentSlice>().WithMany()
|
||||
.HasForeignKey(entity => new { TenantId = entity.ContentSliceOwnerTenantId, Id = entity.ContentSliceId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ContentSliceConfiguration : IEntityTypeConfiguration<ContentSlice>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ContentSlice> builder)
|
||||
{
|
||||
builder.ConfigureTenantEntity("content_slices");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(entity => entity.RegionScope).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.ResourceType).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.ContentVersion).IsConcurrencyToken().HasDefaultValue(1L);
|
||||
builder.HasIndex(entity => new
|
||||
{
|
||||
entity.TenantId,
|
||||
entity.ResourceType,
|
||||
entity.ResourceId,
|
||||
entity.Status
|
||||
});
|
||||
builder.HasIndex(entity => new { entity.BusinessLineId, entity.RegionScope, entity.MarketRegionId });
|
||||
builder.HasOne<BusinessLine>().WithMany().HasForeignKey(entity => entity.BusinessLineId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne<MarketRegion>().WithMany().HasForeignKey(entity => entity.MarketRegionId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
builder.ToTable(table => table.HasCheckConstraint(
|
||||
"ck_content_slices_region_scope",
|
||||
"(region_scope = 'national' and market_region_id is null) or (region_scope = 'region' and market_region_id is not null)"));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class StudentTargetRegionHistoryConfiguration : IEntityTypeConfiguration<StudentTargetRegionHistory>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<StudentTargetRegionHistory> builder)
|
||||
{
|
||||
builder.ConfigureTenantEntity("student_target_region_history");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(entity => entity.Reason).HasMaxLength(1000);
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.BusinessLineId }).IsUnique()
|
||||
.HasDatabaseName("ux_student_target_region_current")
|
||||
.HasFilter("is_current");
|
||||
builder.HasIndex(entity => new
|
||||
{
|
||||
entity.TenantId,
|
||||
entity.UserId,
|
||||
entity.BusinessLineId,
|
||||
entity.EffectiveAt
|
||||
}).HasDatabaseName("ix_student_target_region_history");
|
||||
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.UserId).OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<BusinessLine>().WithMany().HasForeignKey(entity => entity.BusinessLineId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.ChangedBy).OnDelete(DeleteBehavior.SetNull);
|
||||
builder.HasOne<MarketRegion>().WithMany().HasForeignKey(entity => entity.MarketRegionId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ClassContentAssignmentConfiguration : IEntityTypeConfiguration<ClassContentAssignment>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ClassContentAssignment> builder)
|
||||
{
|
||||
builder.ConfigureTenantEntity("class_content_assignments");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(entity => entity.ResourceType).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.RevokedReason).HasMaxLength(1000);
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.ClassId, entity.Status, entity.StartsAt });
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.ResourceType, entity.ResourceId, entity.Status });
|
||||
builder.HasOne<TenantClass>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.ClassId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<ContentSlice>().WithMany()
|
||||
.HasForeignKey(entity => new { TenantId = entity.ContentSliceOwnerTenantId, Id = entity.ContentSliceId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.CreatedBy).OnDelete(DeleteBehavior.SetNull);
|
||||
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.RevokedBy).OnDelete(DeleteBehavior.SetNull);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class LearningAccessVersionConfiguration : IEntityTypeConfiguration<LearningAccessVersion>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<LearningAccessVersion> builder)
|
||||
{
|
||||
builder.ConfigureTenantEntity("learning_access_versions");
|
||||
builder.Property(entity => entity.GrantVersion).IsConcurrencyToken().HasDefaultValue(1L);
|
||||
builder.Property(entity => entity.ContentVersion).HasDefaultValue(1L);
|
||||
builder.Property(entity => entity.StrongRevocationVersion).HasDefaultValue(1L);
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.UserId }).IsUnique();
|
||||
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.UserId).OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ internal sealed class PracticeSessionConfiguration : IEntityTypeConfiguration<Pr
|
||||
.HasSnakeCaseEnum()
|
||||
.HasDefaultValue(PracticeAccessMode.Free);
|
||||
builder.Property(entity => entity.AccessSnapshot).IsJson("{}");
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.StrongRevocationVersion });
|
||||
builder.Property(entity => entity.Metadata).IsJson("{}");
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.StartedAt });
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.Status, entity.StartedAt });
|
||||
@@ -68,8 +69,6 @@ internal sealed class PracticeSessionQuestionConfiguration : IEntityTypeConfigur
|
||||
builder.Property(entity => entity.QuestionType).HasMaxLength(50).HasDefaultValue("choice");
|
||||
builder.Property(entity => entity.TypeLabelSnapshot).HasMaxLength(100);
|
||||
builder.Property(entity => entity.TagsSnapshot).IsJson("[]");
|
||||
builder.Property(entity => entity.OptionsSnapshot).IsJson("[]");
|
||||
builder.Property(entity => entity.CorrectOptionIndicesSnapshot).IsJson("[]");
|
||||
builder.Property(entity => entity.GradingRulesSnapshot).IsJson("{}");
|
||||
builder.Property(entity => entity.SnapshotVersion).HasDefaultValue(1);
|
||||
|
||||
@@ -118,7 +117,6 @@ internal sealed class AnswerRecordConfiguration : IEntityTypeConfiguration<Answe
|
||||
builder.Property(entity => entity.IdempotencyKey).HasMaxLength(200);
|
||||
builder.Property(entity => entity.RequestHash).HasMaxLength(64);
|
||||
builder.Property(entity => entity.Revision).HasDefaultValue(1);
|
||||
builder.Property(entity => entity.IsCurrent).HasDefaultValue(true);
|
||||
builder.Property(entity => entity.AnsweredAt).HasDefaultValueSql("now()");
|
||||
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
|
||||
@@ -140,16 +138,16 @@ internal sealed class AnswerRecordConfiguration : IEntityTypeConfiguration<Answe
|
||||
entity.TenantId,
|
||||
entity.UserId,
|
||||
entity.PracticeSessionId,
|
||||
entity.SessionQuestionId,
|
||||
entity.Revision
|
||||
}).IsUnique().HasDatabaseName("ux_answer_records_session_question_revision");
|
||||
entity.IdempotencyKey
|
||||
}).IsUnique().HasDatabaseName("ux_answer_records_idempotency");
|
||||
builder.HasIndex(entity => new
|
||||
{
|
||||
entity.TenantId,
|
||||
entity.UserId,
|
||||
entity.PracticeSessionId,
|
||||
entity.SessionQuestionId
|
||||
}).IsUnique().HasFilter("is_current").HasDatabaseName("ux_answer_records_current_session_question");
|
||||
entity.SessionQuestionId,
|
||||
entity.Revision
|
||||
}).IsUnique().HasDatabaseName("ux_answer_records_session_question_revision");
|
||||
|
||||
builder.HasOne<User>().WithMany()
|
||||
.HasForeignKey(entity => entity.UserId)
|
||||
@@ -185,6 +183,42 @@ internal sealed class AnswerRecordConfiguration : IEntityTypeConfiguration<Answe
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class CurrentAnswerConfiguration : IEntityTypeConfiguration<CurrentAnswer>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<CurrentAnswer> builder)
|
||||
{
|
||||
builder.ConfigureTenantEntity("current_answers");
|
||||
builder.Property(entity => entity.Version).IsConcurrencyToken().HasDefaultValue(1L);
|
||||
builder.Property(entity => entity.UpdatedAt).HasDefaultValueSql("now()");
|
||||
builder.HasIndex(entity => new
|
||||
{
|
||||
entity.TenantId,
|
||||
entity.UserId,
|
||||
entity.PracticeSessionId,
|
||||
entity.SessionQuestionId
|
||||
}).IsUnique().HasDatabaseName("ux_current_answers_session_question");
|
||||
|
||||
builder.HasOne<AnswerRecord>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, Id = entity.AnswerRecordId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne<PracticeSessionQuestion>().WithMany()
|
||||
.HasForeignKey(entity => new
|
||||
{
|
||||
entity.TenantId,
|
||||
entity.PracticeSessionId,
|
||||
Id = entity.SessionQuestionId
|
||||
})
|
||||
.HasPrincipalKey(entity => new
|
||||
{
|
||||
entity.TenantId,
|
||||
entity.PracticeSessionId,
|
||||
entity.Id
|
||||
})
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class LearningOperationIdempotencyConfiguration : IEntityTypeConfiguration<LearningOperationIdempotency>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<LearningOperationIdempotency> builder)
|
||||
@@ -289,4 +323,4 @@ internal sealed class RecentPracticeConfiguration : IEntityTypeConfiguration<Rec
|
||||
.HasForeignKey(entity => entity.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,9 @@ internal sealed class QuestionVersionConfiguration : IEntityTypeConfiguration<Qu
|
||||
builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id });
|
||||
builder.HasAlternateKey(entity => new { entity.TenantId, entity.QuestionId, entity.Id });
|
||||
|
||||
builder.Property(entity => entity.QuestionType).HasMaxLength(50).HasDefaultValue("choice");
|
||||
builder.Property(entity => entity.TypeLabel).HasMaxLength(100);
|
||||
builder.Property(entity => entity.Tags).IsJson("[]");
|
||||
builder.Property(entity => entity.Options).IsJson("[]");
|
||||
builder.Property(entity => entity.CorrectOptionIndices).IsJson("[]");
|
||||
builder.Property(entity => entity.SubQuestions).IsJson("[]");
|
||||
@@ -106,4 +109,4 @@ internal sealed class QuestionVersionConfiguration : IEntityTypeConfiguration<Qu
|
||||
.HasForeignKey(entity => entity.CreatedBy)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ internal sealed class TenantConfiguration : IEntityTypeConfiguration<Tenant>
|
||||
builder.Property(entity => entity.LegalName).HasMaxLength(300);
|
||||
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.Mode).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.CellId).HasMaxLength(50).HasDefaultValue("cell-01");
|
||||
builder.Property(entity => entity.BillingStatus).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
|
||||
builder.Property(entity => entity.Metadata).IsJson("{}");
|
||||
@@ -26,6 +27,7 @@ internal sealed class TenantConfiguration : IEntityTypeConfiguration<Tenant>
|
||||
builder.HasIndex(entity => entity.Mode)
|
||||
.IsUnique()
|
||||
.HasFilter("mode = 'platform_owned'");
|
||||
builder.HasIndex(entity => new { entity.CellId, entity.Status });
|
||||
|
||||
builder.HasOne<User>()
|
||||
.WithMany()
|
||||
@@ -156,4 +158,4 @@ internal sealed class TenantFrontendConfigConfiguration : IEntityTypeConfigurati
|
||||
table.HasCheckConstraint("ck_tenant_frontend_configs_config_version", "config_version > 0");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
22260
Tiku.Infrastructure/Persistence/Migrations/20260805040749_HardenLearningAccess.Designer.cs
generated
Normal file
22260
Tiku.Infrastructure/Persistence/Migrations/20260805040749_HardenLearningAccess.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,768 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class HardenLearningAccess : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "answer_text_snapshot",
|
||||
table: "practice_session_questions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "content_snapshot",
|
||||
table: "practice_session_questions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "correct_option_index_snapshot",
|
||||
table: "practice_session_questions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "correct_option_indices_snapshot",
|
||||
table: "practice_session_questions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "explanation_snapshot",
|
||||
table: "practice_session_questions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "options_snapshot",
|
||||
table: "practice_session_questions");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "cell_id",
|
||||
table: "tenants",
|
||||
type: "character varying(50)",
|
||||
maxLength: 50,
|
||||
nullable: false,
|
||||
defaultValue: "cell-01");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "difficulty",
|
||||
table: "question_versions",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "question_type",
|
||||
table: "question_versions",
|
||||
type: "character varying(50)",
|
||||
maxLength: 50,
|
||||
nullable: false,
|
||||
defaultValue: "choice");
|
||||
|
||||
migrationBuilder.AddColumn<JsonElement>(
|
||||
name: "tags",
|
||||
table: "question_versions",
|
||||
type: "jsonb",
|
||||
nullable: false,
|
||||
defaultValueSql: "'[]'::jsonb");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "type_label",
|
||||
table: "question_versions",
|
||||
type: "character varying(100)",
|
||||
maxLength: 100,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "access_class_assignment_id",
|
||||
table: "practice_sessions",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<long>(
|
||||
name: "access_grant_version",
|
||||
table: "practice_sessions",
|
||||
type: "bigint",
|
||||
nullable: false,
|
||||
defaultValue: 0L);
|
||||
|
||||
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||
name: "authorization_expires_at",
|
||||
table: "practice_sessions",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<long>(
|
||||
name: "strong_revocation_version",
|
||||
table: "practice_sessions",
|
||||
type: "bigint",
|
||||
nullable: false,
|
||||
defaultValue: 0L);
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "learning_product_id",
|
||||
table: "entitlements",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
update question_versions as version
|
||||
set question_type = question.type,
|
||||
type_label = question.type_label,
|
||||
difficulty = question.difficulty,
|
||||
tags = question.tags
|
||||
from questions as question
|
||||
where question.tenant_id = version.tenant_id
|
||||
and question.id = version.question_id;
|
||||
|
||||
update practice_sessions
|
||||
set status = 'cancelled',
|
||||
finished_at = coalesce(finished_at, now()),
|
||||
expires_at = coalesce(expires_at, now()),
|
||||
authorization_expires_at = coalesce(expires_at, now()),
|
||||
access_grant_version = 1,
|
||||
strong_revocation_version = 1,
|
||||
version = version + 1
|
||||
where status in ('active', 'scoring');
|
||||
|
||||
update practice_sessions
|
||||
set access_grant_version = greatest(access_grant_version, 1),
|
||||
strong_revocation_version = greatest(strong_revocation_version, 1)
|
||||
where access_grant_version = 0 or strong_revocation_version = 0;
|
||||
""");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "business_lines",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
region_access_strategy = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
requires_base_region = table.Column<bool>(type: "boolean", nullable: false),
|
||||
target_region_cooldown_days = table.Column<int>(type: "integer", nullable: false, defaultValue: 30),
|
||||
is_active = table.Column<bool>(type: "boolean", nullable: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_business_lines", x => x.id);
|
||||
table.CheckConstraint("ck_business_lines_target_region_cooldown_days", "target_region_cooldown_days between 0 and 365");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "learning_access_versions",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
user_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
grant_version = table.Column<long>(type: "bigint", nullable: false, defaultValue: 1L),
|
||||
content_version = table.Column<long>(type: "bigint", nullable: false, defaultValue: 1L),
|
||||
strong_revocation_version = table.Column<long>(type: "bigint", nullable: false, defaultValue: 1L),
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_learning_access_versions", x => x.id);
|
||||
table.UniqueConstraint("ak_learning_access_versions_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||
table.ForeignKey(
|
||||
name: "fk_learning_access_versions_tenants_tenant_id",
|
||||
column: x => x.tenant_id,
|
||||
principalTable: "tenants",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_learning_access_versions_users_user_id",
|
||||
column: x => x.user_id,
|
||||
principalTable: "users",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "market_regions",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
parent_code = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||
is_active = table.Column<bool>(type: "boolean", nullable: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_market_regions", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "learning_products",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
business_line_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
code = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
is_active = table.Column<bool>(type: "boolean", nullable: false),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_learning_products", x => x.id);
|
||||
table.UniqueConstraint("ak_learning_products_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||
table.ForeignKey(
|
||||
name: "fk_learning_products_business_lines_business_line_id",
|
||||
column: x => x.business_line_id,
|
||||
principalTable: "business_lines",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "fk_learning_products_tenants_tenant_id",
|
||||
column: x => x.tenant_id,
|
||||
principalTable: "tenants",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "tenant_learning_licenses",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
business_line_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
is_primary = table.Column<bool>(type: "boolean", nullable: false),
|
||||
includes_national = table.Column<bool>(type: "boolean", nullable: false),
|
||||
allows_any_target_region = table.Column<bool>(type: "boolean", nullable: false),
|
||||
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
starts_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
ends_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
version = table.Column<long>(type: "bigint", nullable: false, defaultValue: 1L),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_tenant_learning_licenses", x => x.id);
|
||||
table.UniqueConstraint("ak_tenant_learning_licenses_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||
table.ForeignKey(
|
||||
name: "fk_tenant_learning_licenses_business_lines_business_line_id",
|
||||
column: x => x.business_line_id,
|
||||
principalTable: "business_lines",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "fk_tenant_learning_licenses_tenants_tenant_id",
|
||||
column: x => x.tenant_id,
|
||||
principalTable: "tenants",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "content_slices",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
business_line_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
market_region_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
region_scope = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
resource_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
resource_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
content_version = table.Column<long>(type: "bigint", nullable: false, defaultValue: 1L),
|
||||
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_content_slices", x => x.id);
|
||||
table.UniqueConstraint("ak_content_slices_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||
table.CheckConstraint("ck_content_slices_region_scope", "(region_scope = 'national' and market_region_id is null) or (region_scope = 'region' and market_region_id is not null)");
|
||||
table.ForeignKey(
|
||||
name: "fk_content_slices_business_lines_business_line_id",
|
||||
column: x => x.business_line_id,
|
||||
principalTable: "business_lines",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "fk_content_slices_market_regions_market_region_id",
|
||||
column: x => x.market_region_id,
|
||||
principalTable: "market_regions",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "fk_content_slices_tenants_tenant_id",
|
||||
column: x => x.tenant_id,
|
||||
principalTable: "tenants",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "student_target_region_history",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
user_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
market_region_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
effective_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
ended_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
is_current = table.Column<bool>(type: "boolean", nullable: false),
|
||||
changed_by = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
reason = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_student_target_region_history", x => x.id);
|
||||
table.UniqueConstraint("ak_student_target_region_history_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||
table.ForeignKey(
|
||||
name: "fk_student_target_region_history_market_regions_market_region_~",
|
||||
column: x => x.market_region_id,
|
||||
principalTable: "market_regions",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "fk_student_target_region_history_tenants_tenant_id",
|
||||
column: x => x.tenant_id,
|
||||
principalTable: "tenants",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_student_target_region_history_users_changed_by",
|
||||
column: x => x.changed_by,
|
||||
principalTable: "users",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "fk_student_target_region_history_users_user_id",
|
||||
column: x => x.user_id,
|
||||
principalTable: "users",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "tenant_learning_license_regions",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
license_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
market_region_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
is_base_region = table.Column<bool>(type: "boolean", nullable: false),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_tenant_learning_license_regions", x => x.id);
|
||||
table.UniqueConstraint("ak_tenant_learning_license_regions_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||
table.ForeignKey(
|
||||
name: "fk_tenant_learning_license_regions_market_regions_market_regio~",
|
||||
column: x => x.market_region_id,
|
||||
principalTable: "market_regions",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "fk_tenant_learning_license_regions_tenant_learning_licenses_te~",
|
||||
columns: x => new { x.tenant_id, x.license_id },
|
||||
principalTable: "tenant_learning_licenses",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "class_content_assignments",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
class_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
content_slice_owner_tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
content_slice_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
resource_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
resource_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
starts_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
ends_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
created_by = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
revoked_by = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
revoked_reason = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_class_content_assignments", x => x.id);
|
||||
table.UniqueConstraint("ak_class_content_assignments_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||
table.ForeignKey(
|
||||
name: "fk_class_content_assignments_content_slices_content_slice_owne~",
|
||||
columns: x => new { x.content_slice_owner_tenant_id, x.content_slice_id },
|
||||
principalTable: "content_slices",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "fk_class_content_assignments_tenant_classes_tenant_id_class_id",
|
||||
columns: x => new { x.tenant_id, x.class_id },
|
||||
principalTable: "tenant_classes",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_class_content_assignments_tenants_tenant_id",
|
||||
column: x => x.tenant_id,
|
||||
principalTable: "tenants",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_class_content_assignments_users_created_by",
|
||||
column: x => x.created_by,
|
||||
principalTable: "users",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "fk_class_content_assignments_users_revoked_by",
|
||||
column: x => x.revoked_by,
|
||||
principalTable: "users",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "learning_product_scopes",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
product_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
content_slice_owner_tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
content_slice_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_learning_product_scopes", x => x.id);
|
||||
table.UniqueConstraint("ak_learning_product_scopes_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||
table.ForeignKey(
|
||||
name: "fk_learning_product_scopes_content_slices_content_slice_owner_~",
|
||||
columns: x => new { x.content_slice_owner_tenant_id, x.content_slice_id },
|
||||
principalTable: "content_slices",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "fk_learning_product_scopes_learning_products_tenant_id_product~",
|
||||
columns: x => new { x.tenant_id, x.product_id },
|
||||
principalTable: "learning_products",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_tenants_cell_id_status",
|
||||
table: "tenants",
|
||||
columns: new[] { "cell_id", "status" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_practice_sessions_tenant_id_user_id_strong_revocation_versi~",
|
||||
table: "practice_sessions",
|
||||
columns: new[] { "tenant_id", "user_id", "strong_revocation_version" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_entitlements_tenant_id_learning_product_id",
|
||||
table: "entitlements",
|
||||
columns: new[] { "tenant_id", "learning_product_id" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ux_answer_records_idempotency",
|
||||
table: "answer_records",
|
||||
columns: new[] { "tenant_id", "user_id", "practice_session_id", "idempotency_key" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_business_lines_code",
|
||||
table: "business_lines",
|
||||
column: "code",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_class_content_assignments_content_slice_owner_tenant_id_con~",
|
||||
table: "class_content_assignments",
|
||||
columns: new[] { "content_slice_owner_tenant_id", "content_slice_id" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_class_content_assignments_created_by",
|
||||
table: "class_content_assignments",
|
||||
column: "created_by");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_class_content_assignments_revoked_by",
|
||||
table: "class_content_assignments",
|
||||
column: "revoked_by");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_class_content_assignments_tenant_id_class_id_status_starts_~",
|
||||
table: "class_content_assignments",
|
||||
columns: new[] { "tenant_id", "class_id", "status", "starts_at" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_class_content_assignments_tenant_id_resource_type_resource_~",
|
||||
table: "class_content_assignments",
|
||||
columns: new[] { "tenant_id", "resource_type", "resource_id", "status" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_content_slices_business_line_id_region_scope_market_region_~",
|
||||
table: "content_slices",
|
||||
columns: new[] { "business_line_id", "region_scope", "market_region_id" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_content_slices_market_region_id",
|
||||
table: "content_slices",
|
||||
column: "market_region_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_content_slices_tenant_id_resource_type_resource_id_status",
|
||||
table: "content_slices",
|
||||
columns: new[] { "tenant_id", "resource_type", "resource_id", "status" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_learning_access_versions_tenant_id_user_id",
|
||||
table: "learning_access_versions",
|
||||
columns: new[] { "tenant_id", "user_id" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_learning_access_versions_user_id",
|
||||
table: "learning_access_versions",
|
||||
column: "user_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_learning_product_scopes_content_slice_owner_tenant_id_conte~",
|
||||
table: "learning_product_scopes",
|
||||
columns: new[] { "content_slice_owner_tenant_id", "content_slice_id" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_learning_product_scopes_tenant_id_product_id_content_slice_~",
|
||||
table: "learning_product_scopes",
|
||||
columns: new[] { "tenant_id", "product_id", "content_slice_id" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_learning_products_business_line_id",
|
||||
table: "learning_products",
|
||||
column: "business_line_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_learning_products_tenant_id_code",
|
||||
table: "learning_products",
|
||||
columns: new[] { "tenant_id", "code" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_market_regions_code",
|
||||
table: "market_regions",
|
||||
column: "code",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_student_target_region_history_changed_by",
|
||||
table: "student_target_region_history",
|
||||
column: "changed_by");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_student_target_region_history_market_region_id",
|
||||
table: "student_target_region_history",
|
||||
column: "market_region_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_student_target_region_history_tenant_id_user_id",
|
||||
table: "student_target_region_history",
|
||||
columns: new[] { "tenant_id", "user_id" },
|
||||
unique: true,
|
||||
filter: "is_current");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_student_target_region_history_tenant_id_user_id_effective_at",
|
||||
table: "student_target_region_history",
|
||||
columns: new[] { "tenant_id", "user_id", "effective_at" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_student_target_region_history_user_id",
|
||||
table: "student_target_region_history",
|
||||
column: "user_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_tenant_learning_license_regions_market_region_id",
|
||||
table: "tenant_learning_license_regions",
|
||||
column: "market_region_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_tenant_learning_license_regions_tenant_id_license_id_market~",
|
||||
table: "tenant_learning_license_regions",
|
||||
columns: new[] { "tenant_id", "license_id", "market_region_id" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_tenant_learning_licenses_business_line_id",
|
||||
table: "tenant_learning_licenses",
|
||||
column: "business_line_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_tenant_learning_licenses_tenant_id",
|
||||
table: "tenant_learning_licenses",
|
||||
column: "tenant_id",
|
||||
unique: true,
|
||||
filter: "is_primary and status = 'active'");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_tenant_learning_licenses_tenant_id_business_line_id_status",
|
||||
table: "tenant_learning_licenses",
|
||||
columns: new[] { "tenant_id", "business_line_id", "status" });
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "fk_entitlements_learning_products_tenant_id_learning_product_id",
|
||||
table: "entitlements",
|
||||
columns: new[] { "tenant_id", "learning_product_id" },
|
||||
principalTable: "learning_products",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "fk_entitlements_learning_products_tenant_id_learning_product_id",
|
||||
table: "entitlements");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "class_content_assignments");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "learning_access_versions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "learning_product_scopes");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "student_target_region_history");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "tenant_learning_license_regions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "content_slices");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "learning_products");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "tenant_learning_licenses");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "market_regions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "business_lines");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ix_tenants_cell_id_status",
|
||||
table: "tenants");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ix_practice_sessions_tenant_id_user_id_strong_revocation_versi~",
|
||||
table: "practice_sessions");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ix_entitlements_tenant_id_learning_product_id",
|
||||
table: "entitlements");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ux_answer_records_idempotency",
|
||||
table: "answer_records");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "cell_id",
|
||||
table: "tenants");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "difficulty",
|
||||
table: "question_versions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "question_type",
|
||||
table: "question_versions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "tags",
|
||||
table: "question_versions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "type_label",
|
||||
table: "question_versions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "access_class_assignment_id",
|
||||
table: "practice_sessions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "access_grant_version",
|
||||
table: "practice_sessions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "authorization_expires_at",
|
||||
table: "practice_sessions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "strong_revocation_version",
|
||||
table: "practice_sessions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "learning_product_id",
|
||||
table: "entitlements");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "answer_text_snapshot",
|
||||
table: "practice_session_questions",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "content_snapshot",
|
||||
table: "practice_session_questions",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "correct_option_index_snapshot",
|
||||
table: "practice_session_questions",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<JsonElement>(
|
||||
name: "correct_option_indices_snapshot",
|
||||
table: "practice_session_questions",
|
||||
type: "jsonb",
|
||||
nullable: false,
|
||||
defaultValueSql: "'[]'::jsonb");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "explanation_snapshot",
|
||||
table: "practice_session_questions",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<JsonElement>(
|
||||
name: "options_snapshot",
|
||||
table: "practice_session_questions",
|
||||
type: "jsonb",
|
||||
nullable: false,
|
||||
defaultValueSql: "'[]'::jsonb");
|
||||
}
|
||||
}
|
||||
}
|
||||
22343
Tiku.Infrastructure/Persistence/Migrations/20260805044626_SplitCurrentAnswers.Designer.cs
generated
Normal file
22343
Tiku.Infrastructure/Persistence/Migrations/20260805044626_SplitCurrentAnswers.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,120 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SplitCurrentAnswers : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "current_answers",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
user_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
practice_session_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
session_question_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
answer_record_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
revision = table.Column<int>(type: "integer", nullable: false),
|
||||
client_sequence = table.Column<long>(type: "bigint", nullable: false),
|
||||
version = table.Column<long>(type: "bigint", nullable: false, defaultValue: 1L),
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_current_answers", x => x.id);
|
||||
table.UniqueConstraint("ak_current_answers_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||
table.ForeignKey(
|
||||
name: "fk_current_answers_answer_records_tenant_id_answer_record_id",
|
||||
columns: x => new { x.tenant_id, x.answer_record_id },
|
||||
principalTable: "answer_records",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "fk_current_answers_practice_session_questions_tenant_id_practi~",
|
||||
columns: x => new { x.tenant_id, x.practice_session_id, x.session_question_id },
|
||||
principalTable: "practice_session_questions",
|
||||
principalColumns: new[] { "tenant_id", "practice_session_id", "id" },
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_current_answers_tenants_tenant_id",
|
||||
column: x => x.tenant_id,
|
||||
principalTable: "tenants",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_current_answers_tenant_id_answer_record_id",
|
||||
table: "current_answers",
|
||||
columns: new[] { "tenant_id", "answer_record_id" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_current_answers_tenant_id_practice_session_id_session_quest~",
|
||||
table: "current_answers",
|
||||
columns: new[] { "tenant_id", "practice_session_id", "session_question_id" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ux_current_answers_session_question",
|
||||
table: "current_answers",
|
||||
columns: new[] { "tenant_id", "user_id", "practice_session_id", "session_question_id" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
insert into current_answers (
|
||||
id, user_id, practice_session_id, session_question_id, answer_record_id,
|
||||
revision, client_sequence, version, updated_at, tenant_id)
|
||||
select
|
||||
gen_random_uuid(), user_id, practice_session_id, session_question_id, id,
|
||||
revision, client_sequence, 1, answered_at, tenant_id
|
||||
from answer_records
|
||||
where is_current
|
||||
""");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ux_answer_records_current_session_question",
|
||||
table: "answer_records");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "is_current",
|
||||
table: "answer_records");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "is_current",
|
||||
table: "answer_records",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
update answer_records as answer
|
||||
set is_current = true
|
||||
from current_answers as current
|
||||
where answer.tenant_id = current.tenant_id
|
||||
and answer.id = current.answer_record_id
|
||||
""");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "current_answers");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ux_answer_records_current_session_question",
|
||||
table: "answer_records",
|
||||
columns: new[] { "tenant_id", "user_id", "practice_session_id", "session_question_id" },
|
||||
unique: true,
|
||||
filter: "is_current");
|
||||
}
|
||||
}
|
||||
}
|
||||
22357
Tiku.Infrastructure/Persistence/Migrations/20260805050046_ScopeStudentTargetRegionsByBusinessLine.Designer.cs
generated
Normal file
22357
Tiku.Infrastructure/Persistence/Migrations/20260805050046_ScopeStudentTargetRegionsByBusinessLine.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,112 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ScopeStudentTargetRegionsByBusinessLine : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ix_student_target_region_history_tenant_id_user_id",
|
||||
table: "student_target_region_history");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ix_student_target_region_history_tenant_id_user_id_effective_at",
|
||||
table: "student_target_region_history");
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "business_line_id",
|
||||
table: "student_target_region_history",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
update student_target_region_history as history
|
||||
set business_line_id = (
|
||||
select item.business_line_id
|
||||
from tenant_learning_licenses as item
|
||||
where item.tenant_id = history.tenant_id
|
||||
order by item.is_primary desc, item.created_at desc
|
||||
limit 1
|
||||
)
|
||||
where history.business_line_id is null;
|
||||
""");
|
||||
|
||||
migrationBuilder.AlterColumn<Guid>(
|
||||
name: "business_line_id",
|
||||
table: "student_target_region_history",
|
||||
type: "uuid",
|
||||
nullable: false,
|
||||
oldClrType: typeof(Guid),
|
||||
oldType: "uuid",
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_student_target_region_history",
|
||||
table: "student_target_region_history",
|
||||
columns: new[] { "tenant_id", "user_id", "business_line_id", "effective_at" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_student_target_region_history_business_line_id",
|
||||
table: "student_target_region_history",
|
||||
column: "business_line_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ux_student_target_region_current",
|
||||
table: "student_target_region_history",
|
||||
columns: new[] { "tenant_id", "user_id", "business_line_id" },
|
||||
unique: true,
|
||||
filter: "is_current");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "fk_student_target_region_history_business_lines_business_line_~",
|
||||
table: "student_target_region_history",
|
||||
column: "business_line_id",
|
||||
principalTable: "business_lines",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "fk_student_target_region_history_business_lines_business_line_~",
|
||||
table: "student_target_region_history");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ix_student_target_region_history",
|
||||
table: "student_target_region_history");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ix_student_target_region_history_business_line_id",
|
||||
table: "student_target_region_history");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ux_student_target_region_current",
|
||||
table: "student_target_region_history");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "business_line_id",
|
||||
table: "student_target_region_history");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_student_target_region_history_tenant_id_user_id",
|
||||
table: "student_target_region_history",
|
||||
columns: new[] { "tenant_id", "user_id" },
|
||||
unique: true,
|
||||
filter: "is_current");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_student_target_region_history_tenant_id_user_id_effective_at",
|
||||
table: "student_target_region_history",
|
||||
columns: new[] { "tenant_id", "user_id", "effective_at" });
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -128,12 +128,23 @@ public interface IContentAssetPersistence : IModulePersistence
|
||||
|
||||
public interface ILearningPersistence : IModulePersistence
|
||||
{
|
||||
DbSet<BusinessLine> BusinessLines { get; }
|
||||
DbSet<MarketRegion> MarketRegions { get; }
|
||||
DbSet<TenantLearningLicense> TenantLearningLicenses { get; }
|
||||
DbSet<TenantLearningLicenseRegion> TenantLearningLicenseRegions { get; }
|
||||
DbSet<LearningProduct> LearningProducts { get; }
|
||||
DbSet<LearningProductScope> LearningProductScopes { get; }
|
||||
DbSet<ContentSlice> ContentSlices { get; }
|
||||
DbSet<StudentTargetRegionHistory> StudentTargetRegionHistory { get; }
|
||||
DbSet<ClassContentAssignment> ClassContentAssignments { get; }
|
||||
DbSet<LearningAccessVersion> LearningAccessVersions { get; }
|
||||
DbSet<UserWordProgress> UserWordProgress { get; }
|
||||
DbSet<UserWordFavorite> UserWordFavorites { get; }
|
||||
DbSet<AiRecommendationReport> AiRecommendationReports { get; }
|
||||
DbSet<PracticeSession> PracticeSessions { get; }
|
||||
DbSet<PracticeSessionQuestion> PracticeSessionQuestions { get; }
|
||||
DbSet<AnswerRecord> AnswerRecords { get; }
|
||||
DbSet<CurrentAnswer> CurrentAnswers { get; }
|
||||
DbSet<LearningOperationIdempotency> LearningOperationIdempotencies { get; }
|
||||
DbSet<FavoriteQuestion> FavoriteQuestions { get; }
|
||||
DbSet<WrongQuestion> WrongQuestions { get; }
|
||||
@@ -150,6 +161,11 @@ public interface ILearningPersistence : IModulePersistence
|
||||
DbSet<RevenueDailyStat> RevenueDailyStats { get; }
|
||||
}
|
||||
|
||||
public interface ILearningAccessPersistence : ILearningPersistence, ICommercePersistence, IIdentityPersistence,
|
||||
ITenantAdministrationPersistence, ITenancyPersistence
|
||||
{
|
||||
}
|
||||
|
||||
public interface ICommercePersistence : IModulePersistence
|
||||
{
|
||||
DbSet<Product> Products { get; }
|
||||
|
||||
@@ -5,9 +5,20 @@ namespace Tiku.Infrastructure.Persistence;
|
||||
|
||||
public sealed partial class TikuDbContext
|
||||
{
|
||||
public DbSet<BusinessLine> BusinessLines => Set<BusinessLine>();
|
||||
public DbSet<MarketRegion> MarketRegions => Set<MarketRegion>();
|
||||
public DbSet<TenantLearningLicense> TenantLearningLicenses => Set<TenantLearningLicense>();
|
||||
public DbSet<TenantLearningLicenseRegion> TenantLearningLicenseRegions => Set<TenantLearningLicenseRegion>();
|
||||
public DbSet<LearningProduct> LearningProducts => Set<LearningProduct>();
|
||||
public DbSet<LearningProductScope> LearningProductScopes => Set<LearningProductScope>();
|
||||
public DbSet<ContentSlice> ContentSlices => Set<ContentSlice>();
|
||||
public DbSet<StudentTargetRegionHistory> StudentTargetRegionHistory => Set<StudentTargetRegionHistory>();
|
||||
public DbSet<ClassContentAssignment> ClassContentAssignments => Set<ClassContentAssignment>();
|
||||
public DbSet<LearningAccessVersion> LearningAccessVersions => Set<LearningAccessVersion>();
|
||||
public DbSet<PracticeSession> PracticeSessions => Set<PracticeSession>();
|
||||
public DbSet<PracticeSessionQuestion> PracticeSessionQuestions => Set<PracticeSessionQuestion>();
|
||||
public DbSet<AnswerRecord> AnswerRecords => Set<AnswerRecord>();
|
||||
public DbSet<CurrentAnswer> CurrentAnswers => Set<CurrentAnswer>();
|
||||
public DbSet<LearningOperationIdempotency> LearningOperationIdempotencies => Set<LearningOperationIdempotency>();
|
||||
public DbSet<FavoriteQuestion> FavoriteQuestions => Set<FavoriteQuestion>();
|
||||
public DbSet<WrongQuestion> WrongQuestions => Set<WrongQuestion>();
|
||||
@@ -22,4 +33,4 @@ public sealed partial class TikuDbContext
|
||||
public DbSet<PracticeSessionReportSection> PracticeSessionReportSections => Set<PracticeSessionReportSection>();
|
||||
public DbSet<DashboardDailyStat> DashboardDailyStats => Set<DashboardDailyStat>();
|
||||
public DbSet<RevenueDailyStat> RevenueDailyStats => Set<RevenueDailyStat>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence;
|
||||
|
||||
@@ -13,7 +14,8 @@ public sealed partial class TikuDbContext(
|
||||
DbContextOptions<TikuDbContext> options,
|
||||
ITenantContext tenantContext) : IdentityUserContext<User, Guid>(options), IDataProtectionKeyContext,
|
||||
IIdentityPersistence, ITenancyPersistence, ITenantAdministrationPersistence, ICatalogPersistence,
|
||||
IQuestionBankPersistence, IContentAssetPersistence, ILearningPersistence, ICommercePersistence,
|
||||
IQuestionBankPersistence, IContentAssetPersistence, ILearningPersistence, ILearningAccessPersistence,
|
||||
ICommercePersistence,
|
||||
IPointsPersistence, IGrowthPersistence, IJobsOperationsPersistence, IPlatformControlPlanePersistence,
|
||||
IPlatformAdministrationPersistence, IPlatformQuestionBankAdministrationPersistence,
|
||||
IPlatformTenantCapabilitiesPersistence, IPlatformBillingPersistence, IOwnerActivationPersistence,
|
||||
@@ -137,6 +139,18 @@ public sealed partial class TikuDbContext(
|
||||
|
||||
private void UpdateTimestamps()
|
||||
{
|
||||
var changedDeliveryVersion = ChangeTracker.Entries<QuestionVersion>()
|
||||
.FirstOrDefault(entry => entry.State is EntityState.Modified or EntityState.Deleted);
|
||||
if (changedDeliveryVersion is not null)
|
||||
throw new InvalidOperationException(
|
||||
"Published question delivery versions are immutable; create a new version instead.");
|
||||
|
||||
var changedAnswerAttempt = ChangeTracker.Entries<Tiku.Domain.Learning.AnswerRecord>()
|
||||
.FirstOrDefault(entry => entry.State is EntityState.Modified or EntityState.Deleted);
|
||||
if (changedAnswerAttempt is not null)
|
||||
throw new InvalidOperationException(
|
||||
"Answer attempts are immutable; update the current-answer pointer instead.");
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
foreach (var entry in ChangeTracker.Entries<User>().Where(entry => entry.State == EntityState.Modified))
|
||||
|
||||
@@ -169,6 +169,10 @@ internal abstract partial class PlatformQuestionBankServiceBase
|
||||
TenantId = tenantId,
|
||||
QuestionId = questionId,
|
||||
VersionNo = versionNo,
|
||||
QuestionType = Normalize(command.Type) ?? "choice",
|
||||
TypeLabel = Normalize(command.TypeLabel),
|
||||
Difficulty = command.Difficulty,
|
||||
Tags = ArrayOrDefault(command.Tags),
|
||||
Content = Normalize(command.Content),
|
||||
Options = ArrayOrDefault(command.Options),
|
||||
CorrectOptionIndex = command.CorrectOptionIndex,
|
||||
|
||||
@@ -3,12 +3,15 @@ using System.Text;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.PlatformAdmin;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Learning;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Tenancy;
|
||||
|
||||
namespace Tiku.Infrastructure.PlatformAdmin;
|
||||
@@ -478,6 +481,28 @@ internal sealed class TenantProvisioningAdministrationService(PlatformAdministra
|
||||
?? throw new PlatformAdminException("Tenant was not found.", "tenant_not_found");
|
||||
var fromStatus = tenant.Status;
|
||||
tenant.Status = command.Status;
|
||||
Guid[] stronglyRevokedUserIds = [];
|
||||
if (fromStatus == TenantStatus.Active && command.Status != TenantStatus.Active)
|
||||
{
|
||||
var learningDb = provider.GetRequiredService<ILearningAccessPersistence>();
|
||||
stronglyRevokedUserIds = await dbContext.TenantMemberships.AsNoTracking()
|
||||
.Where(item => item.TenantId == tenant.Id && item.Status == MembershipStatus.Active)
|
||||
.Select(item => item.UserId).Distinct().ToArrayAsync(cancellationToken);
|
||||
var versions = await learningDb.LearningAccessVersions
|
||||
.Where(item => item.TenantId == tenant.Id && stronglyRevokedUserIds.Contains(item.UserId))
|
||||
.ToDictionaryAsync(item => item.UserId, cancellationToken);
|
||||
foreach (var userId in stronglyRevokedUserIds)
|
||||
{
|
||||
if (!versions.TryGetValue(userId, out var version))
|
||||
{
|
||||
version = new LearningAccessVersion { TenantId = tenant.Id, UserId = userId };
|
||||
learningDb.LearningAccessVersions.Add(version);
|
||||
}
|
||||
version.GrantVersion++;
|
||||
version.StrongRevocationVersion++;
|
||||
version.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
}
|
||||
AddAudit(dbContext, actor, "platform.tenant.status_changed", tenant.Id, new
|
||||
{
|
||||
FromStatus = fromStatus,
|
||||
@@ -490,6 +515,9 @@ internal sealed class TenantProvisioningAdministrationService(PlatformAdministra
|
||||
.InvalidateAsync(tenant.Id, cancellationToken);
|
||||
await provider.GetRequiredService<IAuthorizationStateInvalidator>()
|
||||
.InvalidateTenantAsync(tenant.Id, cancellationToken);
|
||||
var learningAccess = provider.GetRequiredService<ILearningAccessService>();
|
||||
await Task.WhenAll(stronglyRevokedUserIds.Select(userId =>
|
||||
learningAccess.InvalidateAsync(tenant.Id, userId, cancellationToken)));
|
||||
var domainCount =
|
||||
await dbContext.TenantDomains.CountAsync(domain => domain.TenantId == tenant.Id, cancellationToken);
|
||||
var expiresAt = await dbContext.TenantSaasSubscriptions
|
||||
|
||||
@@ -5,6 +5,7 @@ using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Learning;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
@@ -13,6 +14,7 @@ namespace Tiku.Infrastructure.QuestionBanks;
|
||||
|
||||
public sealed class QuestionBankQueryService(
|
||||
IQuestionBankPersistence questionBankPersistence,
|
||||
ILearningPersistence learningPersistence,
|
||||
IPublicQuestionAccessPolicy accessPolicy,
|
||||
ITenantExecutionScope tenantExecutionScope) : IQuestionBankQueryService
|
||||
{
|
||||
@@ -26,6 +28,7 @@ public sealed class QuestionBankQueryService(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var limit = ResolveLimit(filter.Limit, DefaultBankLimit, MaxBankLimit);
|
||||
var allowedSliceIds = filter.AllowedContentSliceIds?.ToArray() ?? [];
|
||||
var tenantItems = filter.Source == QuestionSource.Platform
|
||||
? []
|
||||
: await questionBankPersistence.QuestionBanks
|
||||
@@ -33,6 +36,12 @@ public sealed class QuestionBankQueryService(
|
||||
.Where(bank =>
|
||||
bank.TenantId == filter.TenantId &&
|
||||
bank.Status == QuestionBankStatus.Active &&
|
||||
learningPersistence.ContentSlices.Any(slice =>
|
||||
slice.TenantId == filter.TenantId &&
|
||||
allowedSliceIds.Contains(slice.Id) &&
|
||||
slice.Status == ContentSliceStatus.Active &&
|
||||
slice.ResourceType == LearningContentResourceType.QuestionBank &&
|
||||
slice.ResourceId == bank.Id) &&
|
||||
(!filter.RegionId.HasValue || bank.RegionId == filter.RegionId.Value || bank.RegionId == null) &&
|
||||
(string.IsNullOrWhiteSpace(filter.Keyword) || bank.Name.Contains(filter.Keyword.Trim())))
|
||||
.OrderBy(bank => bank.Name)
|
||||
@@ -57,6 +66,7 @@ public sealed class QuestionBankQueryService(
|
||||
async (provider, token) =>
|
||||
{
|
||||
var systemQuestionBank = provider.GetRequiredService<IQuestionBankPersistence>();
|
||||
var systemLearning = provider.GetRequiredService<ILearningPersistence>();
|
||||
var systemTenancy = provider.GetRequiredService<ITenancyPersistence>();
|
||||
return await systemQuestionBank.QuestionBanks.AsNoTracking()
|
||||
.Join(
|
||||
@@ -67,6 +77,12 @@ public sealed class QuestionBankQueryService(
|
||||
(bank, tenant) => bank)
|
||||
.Where(bank =>
|
||||
bank.Status == QuestionBankStatus.Active &&
|
||||
systemLearning.ContentSlices.Any(slice =>
|
||||
slice.TenantId == filter.TenantId &&
|
||||
allowedSliceIds.Contains(slice.Id) &&
|
||||
slice.Status == ContentSliceStatus.Active &&
|
||||
slice.ResourceType == LearningContentResourceType.QuestionBank &&
|
||||
slice.ResourceId == bank.Id) &&
|
||||
(!filter.RegionId.HasValue || bank.RegionId == filter.RegionId.Value ||
|
||||
bank.RegionId == null) &&
|
||||
(string.IsNullOrWhiteSpace(filter.Keyword) || bank.Name.Contains(filter.Keyword.Trim())))
|
||||
@@ -122,56 +138,6 @@ public sealed class QuestionBankQueryService(
|
||||
?? throw new QuestionBankNotFoundException("Question was not found.");
|
||||
}
|
||||
|
||||
public async Task<CatalogList<QuestionVersionCatalogItem>> GetQuestionVersionsAsync(
|
||||
QuestionBankFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!filter.QuestionId.HasValue) throw new QuestionBankRequiredFieldException("questionId is required.");
|
||||
|
||||
if (filter.Source == QuestionSource.Platform)
|
||||
{
|
||||
await accessPolicy.EnsureCanStartAsync(filter.TenantId, cancellationToken);
|
||||
var platformItems =
|
||||
await GetPlatformVersionsAsync(filter.QuestionId.Value, filter.TenantId, cancellationToken);
|
||||
return new CatalogList<QuestionVersionCatalogItem>(platformItems);
|
||||
}
|
||||
|
||||
var questionExists = await questionBankPersistence.Questions
|
||||
.AsNoTracking()
|
||||
.AnyAsync(
|
||||
question =>
|
||||
question.TenantId == filter.TenantId &&
|
||||
question.Id == filter.QuestionId.Value &&
|
||||
question.Status == QuestionStatus.Published,
|
||||
cancellationToken);
|
||||
|
||||
if (!questionExists) throw new QuestionBankNotFoundException("Question was not found.");
|
||||
|
||||
var items = await questionBankPersistence.QuestionVersions
|
||||
.AsNoTracking()
|
||||
.Where(version =>
|
||||
version.TenantId == filter.TenantId &&
|
||||
version.QuestionId == filter.QuestionId.Value)
|
||||
.OrderByDescending(version => version.VersionNo)
|
||||
.Select(version => new QuestionVersionCatalogItem(
|
||||
version.Id,
|
||||
version.QuestionId,
|
||||
version.VersionNo,
|
||||
version.Content,
|
||||
version.Options,
|
||||
version.CorrectOptionIndex,
|
||||
version.CorrectOptionIndices,
|
||||
version.AnswerText,
|
||||
version.Explanation,
|
||||
version.SubQuestions,
|
||||
version.CodeLang,
|
||||
version.CodeTemplate,
|
||||
version.CreatedAt))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
return new CatalogList<QuestionVersionCatalogItem>(items);
|
||||
}
|
||||
|
||||
private IQueryable<Question> BaseQuestionQuery()
|
||||
{
|
||||
return questionBankPersistence.Questions
|
||||
@@ -184,6 +150,23 @@ public sealed class QuestionBankQueryService(
|
||||
QuestionBankFilter filter)
|
||||
{
|
||||
query = query.Where(question => question.TenantId == filter.TenantId);
|
||||
var allowedSliceIds = filter.AllowedContentSliceIds?.ToArray() ?? [];
|
||||
query = query.Where(question =>
|
||||
(question.QuestionBankId.HasValue && learningPersistence.ContentSlices.Any(slice =>
|
||||
slice.TenantId == filter.TenantId &&
|
||||
allowedSliceIds.Contains(slice.Id) &&
|
||||
slice.Status == ContentSliceStatus.Active &&
|
||||
slice.ResourceType == LearningContentResourceType.QuestionBank &&
|
||||
slice.ResourceId == question.QuestionBankId.Value)) ||
|
||||
questionBankPersistence.QuestionCollectionItems.Any(collectionItem =>
|
||||
collectionItem.TenantId == filter.TenantId &&
|
||||
collectionItem.QuestionId == question.Id &&
|
||||
learningPersistence.ContentSlices.Any(slice =>
|
||||
slice.TenantId == filter.TenantId &&
|
||||
allowedSliceIds.Contains(slice.Id) &&
|
||||
slice.Status == ContentSliceStatus.Active &&
|
||||
slice.ResourceType == LearningContentResourceType.Collection &&
|
||||
slice.ResourceId == collectionItem.CollectionId)));
|
||||
|
||||
if (filter.QuestionBankId.HasValue)
|
||||
query = query.Where(question => question.QuestionBankId == filter.QuestionBankId.Value);
|
||||
@@ -247,8 +230,6 @@ public sealed class QuestionBankQueryService(
|
||||
QuestionSource source)
|
||||
{
|
||||
var emptyOptions = JsonDefaults.Array();
|
||||
var emptyCorrectOptionIndices = JsonDefaults.Array();
|
||||
var emptySubQuestions = JsonDefaults.Array();
|
||||
return
|
||||
from question in questions
|
||||
join version in context.QuestionVersions.AsNoTracking()
|
||||
@@ -281,11 +262,6 @@ public sealed class QuestionBankQueryService(
|
||||
version == null ? null : version.VersionNo,
|
||||
version == null ? null : version.Content,
|
||||
version == null ? emptyOptions : version.Options,
|
||||
version == null ? null : version.CorrectOptionIndex,
|
||||
version == null ? emptyCorrectOptionIndices : version.CorrectOptionIndices,
|
||||
version == null ? null : version.AnswerText,
|
||||
version == null ? null : version.Explanation,
|
||||
version == null ? emptySubQuestions : version.SubQuestions,
|
||||
version == null ? null : version.CodeLang,
|
||||
version == null ? null : version.CodeTemplate,
|
||||
new QuestionLocator(source, question.Id));
|
||||
@@ -316,6 +292,7 @@ public sealed class QuestionBankQueryService(
|
||||
async (provider, token) =>
|
||||
{
|
||||
var systemQuestionBank = provider.GetRequiredService<IQuestionBankPersistence>();
|
||||
var systemLearning = provider.GetRequiredService<ILearningPersistence>();
|
||||
var systemTenancy = provider.GetRequiredService<ITenancyPersistence>();
|
||||
var platformTenantId = await systemTenancy.Tenants.AsNoTracking()
|
||||
.Where(tenant => tenant.Mode == TenantMode.PlatformOwned)
|
||||
@@ -324,6 +301,21 @@ public sealed class QuestionBankQueryService(
|
||||
var query = systemQuestionBank.Questions.AsNoTracking().Where(question =>
|
||||
question.TenantId == platformTenantId &&
|
||||
question.Status == QuestionStatus.Published &&
|
||||
((question.QuestionBankId.HasValue && systemLearning.ContentSlices.Any(slice =>
|
||||
slice.TenantId == filter.TenantId &&
|
||||
(filter.AllowedContentSliceIds ?? Array.Empty<Guid>()).Contains(slice.Id) &&
|
||||
slice.Status == ContentSliceStatus.Active &&
|
||||
slice.ResourceType == LearningContentResourceType.QuestionBank &&
|
||||
slice.ResourceId == question.QuestionBankId.Value)) ||
|
||||
systemQuestionBank.QuestionCollectionItems.Any(collectionItem =>
|
||||
collectionItem.TenantId == filter.TenantId &&
|
||||
collectionItem.QuestionId == question.Id &&
|
||||
systemLearning.ContentSlices.Any(slice =>
|
||||
slice.TenantId == filter.TenantId &&
|
||||
(filter.AllowedContentSliceIds ?? Array.Empty<Guid>()).Contains(slice.Id) &&
|
||||
slice.Status == ContentSliceStatus.Active &&
|
||||
slice.ResourceType == LearningContentResourceType.Collection &&
|
||||
slice.ResourceId == collectionItem.CollectionId))) &&
|
||||
(!filter.QuestionId.HasValue || question.Id == filter.QuestionId.Value) &&
|
||||
(!filter.QuestionBankId.HasValue || question.QuestionBankId == filter.QuestionBankId.Value) &&
|
||||
(!filter.SubjectId.HasValue || question.SubjectId == filter.SubjectId.Value) &&
|
||||
@@ -351,53 +343,6 @@ public sealed class QuestionBankQueryService(
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private Task<QuestionVersionCatalogItem[]> GetPlatformVersionsAsync(
|
||||
Guid questionId,
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return tenantExecutionScope.ExecuteAsync(
|
||||
new SystemScopeRequest(
|
||||
tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(QuestionBankQueryService),
|
||||
"Read platform question versions for an entitled tenant", Guid.NewGuid().ToString("N")),
|
||||
async (provider, token) =>
|
||||
{
|
||||
var systemQuestionBank = provider.GetRequiredService<IQuestionBankPersistence>();
|
||||
var systemTenancy = provider.GetRequiredService<ITenancyPersistence>();
|
||||
var platformQuestion = await systemQuestionBank.Questions.AsNoTracking()
|
||||
.Where(question => question.Id == questionId && question.Status == QuestionStatus.Published)
|
||||
.Join(
|
||||
systemTenancy.Tenants.AsNoTracking().Where(tenant => tenant.Mode == TenantMode.PlatformOwned),
|
||||
question => question.TenantId,
|
||||
tenant => tenant.Id,
|
||||
(question, tenant) => new { question.TenantId, question.Id })
|
||||
.SingleOrDefaultAsync(token);
|
||||
if (platformQuestion is null) throw new QuestionBankNotFoundException("Question was not found.");
|
||||
|
||||
return await systemQuestionBank.QuestionVersions.AsNoTracking()
|
||||
.Where(version =>
|
||||
version.TenantId == platformQuestion.TenantId &&
|
||||
version.QuestionId == platformQuestion.Id)
|
||||
.OrderByDescending(version => version.VersionNo)
|
||||
.Select(version => new QuestionVersionCatalogItem(
|
||||
version.Id,
|
||||
version.QuestionId,
|
||||
version.VersionNo,
|
||||
version.Content,
|
||||
version.Options,
|
||||
version.CorrectOptionIndex,
|
||||
version.CorrectOptionIndices,
|
||||
version.AnswerText,
|
||||
version.Explanation,
|
||||
version.SubQuestions,
|
||||
version.CodeLang,
|
||||
version.CodeTemplate,
|
||||
version.CreatedAt))
|
||||
.ToArrayAsync(token);
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static int ResolveLimit(int? limit, int defaultLimit, int maxLimit)
|
||||
{
|
||||
return Math.Clamp(limit ?? defaultLimit, 1, maxLimit);
|
||||
|
||||
@@ -19,6 +19,9 @@ public sealed class StudyContentQueryService(IContentAssetPersistence dbContext)
|
||||
.Where(unit =>
|
||||
unit.TenantId == filter.TenantId &&
|
||||
unit.IsActive);
|
||||
if (filter.AllowedRegionIds is not null)
|
||||
query = query.Where(unit => unit.RegionId == null ||
|
||||
filter.AllowedRegionIds.Contains(unit.RegionId.Value));
|
||||
|
||||
if (filter.RegionId.HasValue)
|
||||
query = query.Where(unit => unit.RegionId == filter.RegionId.Value || unit.RegionId == null);
|
||||
@@ -108,6 +111,9 @@ public sealed class StudyContentQueryService(IContentAssetPersistence dbContext)
|
||||
.Where(subject =>
|
||||
subject.TenantId == filter.TenantId &&
|
||||
subject.IsActive);
|
||||
if (filter.AllowedRegionIds is not null)
|
||||
query = query.Where(subject => subject.RegionId == null ||
|
||||
filter.AllowedRegionIds.Contains(subject.RegionId.Value));
|
||||
|
||||
if (filter.RegionId.HasValue)
|
||||
query = query.Where(subject => subject.RegionId == filter.RegionId.Value || subject.RegionId == null);
|
||||
@@ -240,4 +246,4 @@ public sealed class StudyContentQueryService(IContentAssetPersistence dbContext)
|
||||
{
|
||||
return Math.Clamp(limit ?? DefaultLimit, 1, MaxLimit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ public sealed class TenantDirectory(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
select t.id, t.slug, t.name, t.status, t.mode, d.host
|
||||
select t.id, t.slug, t.name, t.status, t.mode, t.cell_id, d.host
|
||||
from tenant_domains d
|
||||
join tenants t on t.id = d.tenant_id
|
||||
where d.host = @lookup
|
||||
@@ -35,7 +35,7 @@ public sealed class TenantDirectory(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
select t.id, t.slug, t.name, t.status, t.mode, null::text as host
|
||||
select t.id, t.slug, t.name, t.status, t.mode, t.cell_id, null::text as host
|
||||
from tenants t
|
||||
where t.slug = @lookup
|
||||
and t.status = 'active'
|
||||
@@ -71,7 +71,8 @@ public sealed class TenantDirectory(
|
||||
reader.GetString(2),
|
||||
ParseEnum<TenantStatus>(reader.GetString(3)),
|
||||
ParseEnum<TenantMode>(reader.GetString(4)),
|
||||
reader.IsDBNull(5) ? null : reader.GetString(5)));
|
||||
reader.GetString(5),
|
||||
reader.IsDBNull(6) ? null : reader.GetString(6)));
|
||||
},
|
||||
options =>
|
||||
{
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.TenantAdmin;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Learning;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Security;
|
||||
|
||||
@@ -221,15 +223,23 @@ internal sealed class TenantClassService(TenantAdminServiceDependencies dependen
|
||||
MemberType = memberType,
|
||||
CreatedBy = actor.UserId
|
||||
};
|
||||
var stronglyRevoke = !isNew &&
|
||||
item.MemberType == TenantClassMemberType.Student &&
|
||||
item.Status == TenantClassMemberStatus.Active &&
|
||||
status != TenantClassMemberStatus.Active;
|
||||
item.Status = status;
|
||||
item.LeftAt = status is TenantClassMemberStatus.Active ? null : DateTimeOffset.UtcNow;
|
||||
item.Metadata = JsonObjectOrDefault(command.Metadata);
|
||||
item.UpdatedBy = actor.UserId;
|
||||
if (isNew) tenantAdministrationPersistence.TenantClassMembers.Add(item);
|
||||
if (stronglyRevoke)
|
||||
await BumpStrongRevocationAsync(actor.TenantId, user.Id, cancellationToken);
|
||||
|
||||
await AddAuditAsync(actor, "tenant.class_member.upserted", "tenant_class_members", item.Id, cancellationToken);
|
||||
await unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
|
||||
if (stronglyRevoke)
|
||||
await learningAccessService.InvalidateAsync(actor.TenantId, user.Id, cancellationToken);
|
||||
return new ContentManagementResult<TenantAdminClassMemberItem>(ToClassMemberItem(item, ToUserSummary(user)));
|
||||
}
|
||||
|
||||
@@ -258,8 +268,30 @@ internal sealed class TenantClassService(TenantAdminServiceDependencies dependen
|
||||
item.Status = TenantClassMemberStatus.Removed;
|
||||
item.LeftAt = DateTimeOffset.UtcNow;
|
||||
item.UpdatedBy = actor.UserId;
|
||||
if (item.MemberType == TenantClassMemberType.Student)
|
||||
await BumpStrongRevocationAsync(actor.TenantId, item.UserId, cancellationToken);
|
||||
await AddAuditAsync(actor, "tenant.class_member.removed", "tenant_class_members", item.Id, cancellationToken);
|
||||
await unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
if (item.MemberType == TenantClassMemberType.Student)
|
||||
await learningAccessService.InvalidateAsync(actor.TenantId, item.UserId, cancellationToken);
|
||||
return new ContentManagementResult<TenantAdminClassMemberItem>(ToClassMemberItem(item, ToUserSummary(user)));
|
||||
}
|
||||
|
||||
private async Task BumpStrongRevocationAsync(
|
||||
Guid tenantId,
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var version = await learningAccessPersistence.LearningAccessVersions.SingleOrDefaultAsync(
|
||||
item => item.TenantId == tenantId && item.UserId == userId,
|
||||
cancellationToken);
|
||||
if (version is null)
|
||||
{
|
||||
version = new LearningAccessVersion { TenantId = tenantId, UserId = userId };
|
||||
learningAccessPersistence.LearningAccessVersions.Add(version);
|
||||
}
|
||||
version.GrantVersion++;
|
||||
version.StrongRevocationVersion++;
|
||||
version.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Notifications;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
@@ -12,6 +13,8 @@ internal sealed record TenantAdminServiceDependencies(
|
||||
ITenantAdministrationPersistence TenantAdministrationPersistence,
|
||||
ICatalogPersistence CatalogPersistence,
|
||||
ILearningPersistence LearningPersistence,
|
||||
ILearningAccessPersistence LearningAccessPersistence,
|
||||
ILearningAccessService LearningAccessService,
|
||||
ICommercePersistence CommercePersistence,
|
||||
IPointsPersistence PointsPersistence,
|
||||
IJobsOperationsPersistence JobsOperationsPersistence,
|
||||
@@ -32,6 +35,8 @@ internal abstract partial class TenantAdminServiceBase(TenantAdminServiceDepende
|
||||
|
||||
protected ICatalogPersistence catalogPersistence { get; } = dependencies.CatalogPersistence;
|
||||
protected ILearningPersistence learningPersistence { get; } = dependencies.LearningPersistence;
|
||||
protected ILearningAccessPersistence learningAccessPersistence { get; } = dependencies.LearningAccessPersistence;
|
||||
protected ILearningAccessService learningAccessService { get; } = dependencies.LearningAccessService;
|
||||
protected ICommercePersistence commercePersistence { get; } = dependencies.CommercePersistence;
|
||||
protected IPointsPersistence pointsPersistence { get; } = dependencies.PointsPersistence;
|
||||
protected IJobsOperationsPersistence jobsOperationsPersistence { get; } = dependencies.JobsOperationsPersistence;
|
||||
|
||||
@@ -17,7 +17,9 @@ using Tiku.Application.Security;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Learning;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
@@ -463,6 +465,123 @@ public sealed class ApiTestFactory(
|
||||
return reference;
|
||||
}
|
||||
|
||||
public async Task AuthorizeStudentCatalogAsync(HttpClient client, Guid tenantId)
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var businessLineId = Guid.NewGuid();
|
||||
var licenseId = Guid.NewGuid();
|
||||
var productId = Guid.NewGuid();
|
||||
var phone = $"137{Random.Shared.Next(10_000_000, 99_999_999)}";
|
||||
string tenantCode;
|
||||
|
||||
using (var scope = Services.CreateScope())
|
||||
{
|
||||
scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>()
|
||||
.InitializeSystem(tenantId, "Integration test explicit student catalog authorization");
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
tenantCode = await dbContext.Tenants
|
||||
.Where(item => item.Id == tenantId)
|
||||
.Select(item => item.Slug)
|
||||
.SingleAsync();
|
||||
var regionIds = await dbContext.Regions
|
||||
.Where(item => item.TenantId == tenantId)
|
||||
.Select(item => item.Id)
|
||||
.Distinct()
|
||||
.ToArrayAsync();
|
||||
var resources = (await dbContext.QuestionBanks
|
||||
.Where(item => item.TenantId == tenantId)
|
||||
.Select(item => new { Type = LearningContentResourceType.QuestionBank, item.Id })
|
||||
.ToArrayAsync())
|
||||
.Concat(await dbContext.QuestionCollections
|
||||
.Where(item => item.TenantId == tenantId)
|
||||
.Select(item => new { Type = LearningContentResourceType.Collection, item.Id })
|
||||
.ToArrayAsync())
|
||||
.Concat(await dbContext.PracticeBlueprints
|
||||
.Where(item => item.TenantId == tenantId)
|
||||
.Select(item => new { Type = LearningContentResourceType.Blueprint, item.Id })
|
||||
.ToArrayAsync())
|
||||
.ToArray();
|
||||
var slices = resources.Select(item => new ContentSlice
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
BusinessLineId = businessLineId,
|
||||
RegionScope = LearningRegionScopeKind.National,
|
||||
ResourceType = item.Type,
|
||||
ResourceId = item.Id,
|
||||
Status = ContentSliceStatus.Active
|
||||
}).ToArray();
|
||||
|
||||
dbContext.AddRange(
|
||||
new User { Id = userId, Phone = phone, Name = "Catalog Student" }.WithTestPassword(),
|
||||
new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Role = TenantRole.Student,
|
||||
Status = MembershipStatus.Active
|
||||
},
|
||||
new BusinessLine
|
||||
{
|
||||
Id = businessLineId,
|
||||
Code = $"catalog-{tenantId:N}",
|
||||
Name = "可扩展目录测试业务",
|
||||
RegionAccessStrategy = LearningRegionAccessStrategy.NationalWithLicensedRegions
|
||||
},
|
||||
new TenantLearningLicense
|
||||
{
|
||||
Id = licenseId,
|
||||
TenantId = tenantId,
|
||||
BusinessLineId = businessLineId,
|
||||
IncludesNational = true,
|
||||
StartsAt = DateTimeOffset.UtcNow.AddDays(-1)
|
||||
},
|
||||
new LearningProduct
|
||||
{
|
||||
Id = productId,
|
||||
TenantId = tenantId,
|
||||
BusinessLineId = businessLineId,
|
||||
Code = "catalog-full-access",
|
||||
Name = "目录测试显式授权"
|
||||
},
|
||||
new Entitlement
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
LearningProductId = productId,
|
||||
StartsAt = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(1),
|
||||
Status = EntitlementStatus.Active,
|
||||
SourceType = "integration_test"
|
||||
},
|
||||
new LearningAccessVersion { TenantId = tenantId, UserId = userId });
|
||||
dbContext.MarketRegions.AddRange(regionIds.Select(regionId => new MarketRegion
|
||||
{
|
||||
Id = regionId,
|
||||
Code = $"test-{regionId:N}",
|
||||
Name = $"测试地区 {regionId:N}"
|
||||
}));
|
||||
dbContext.TenantLearningLicenseRegions.AddRange(regionIds.Select(regionId =>
|
||||
new TenantLearningLicenseRegion
|
||||
{
|
||||
TenantId = tenantId,
|
||||
LicenseId = licenseId,
|
||||
MarketRegionId = regionId
|
||||
}));
|
||||
dbContext.ContentSlices.AddRange(slices);
|
||||
dbContext.LearningProductScopes.AddRange(slices.Select(slice => new LearningProductScope
|
||||
{
|
||||
TenantId = tenantId,
|
||||
ProductId = productId,
|
||||
ContentSliceOwnerTenantId = tenantId,
|
||||
ContentSliceId = slice.Id
|
||||
}));
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
client.UseAccessToken(await client.LoginAsTenantCodeAsync(tenantCode, phone));
|
||||
}
|
||||
|
||||
public async Task<Guid> SeedActiveSessionAsync(
|
||||
Guid userId,
|
||||
Guid? tenantId = null,
|
||||
|
||||
@@ -14,17 +14,17 @@ namespace Tiku.IntegrationTests.Api;
|
||||
public sealed class AssetAccessEndpointTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Public_asset_can_be_downloaded_anonymously_and_is_audited()
|
||||
public async Task Public_asset_still_requires_an_authenticated_tenant_member_and_is_audited()
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var assetId = Guid.NewGuid();
|
||||
await using var factory = new ApiTestFactory(objectStorageService: new FakeObjectStorageService());
|
||||
await factory.SeedAsync(
|
||||
Tenant(tenantId, "master"),
|
||||
PublicAsset(tenantId, assetId));
|
||||
var seed = await SeedLoginUserAsync(factory, tenantId);
|
||||
await factory.SeedAsync(PublicAsset(tenantId, assetId));
|
||||
using var client = factory.CreateClient();
|
||||
await LoginAsync(client, seed);
|
||||
|
||||
using var response = await client.GetAsync($"/api/student/assets/{assetId}/download?tenantCode=master");
|
||||
using var response = await client.GetAsync($"/api/student/assets/{assetId}/download");
|
||||
var body = await ReadJsonAsync(response);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
@@ -65,10 +65,7 @@ public sealed class AssetAccessEndpointTests
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var response = await client.GetAsync($"/api/student/assets/{assetId}/download?tenantCode=master");
|
||||
var body = await ReadJsonAsync(response);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
Assert.Equal("auth_required", body.RootElement.GetProperty("code").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -150,8 +147,8 @@ public sealed class AssetAccessEndpointTests
|
||||
var tenantId = Guid.NewGuid();
|
||||
var assetId = Guid.NewGuid();
|
||||
await using var factory = new ApiTestFactory(objectStorageService: new FakeObjectStorageService());
|
||||
var seed = await SeedLoginUserAsync(factory, tenantId);
|
||||
await factory.SeedAsync(
|
||||
Tenant(tenantId, "master"),
|
||||
new ContentAsset
|
||||
{
|
||||
Id = assetId,
|
||||
@@ -167,8 +164,9 @@ public sealed class AssetAccessEndpointTests
|
||||
SecurityScanStatus = AssetSecurityScanStatus.Passed
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
await LoginAsync(client, seed);
|
||||
|
||||
using var response = await client.GetAsync($"/api/student/assets/{assetId}/preview?tenantCode=master");
|
||||
using var response = await client.GetAsync($"/api/student/assets/{assetId}/preview");
|
||||
var body = await ReadJsonAsync(response);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
@@ -188,10 +186,12 @@ public sealed class AssetAccessEndpointTests
|
||||
await using var factory = new ApiTestFactory(objectStorageService: new FakeObjectStorageService());
|
||||
var asset = PublicAsset(tenantId, assetId);
|
||||
asset.SecurityScanStatus = scanStatus;
|
||||
await factory.SeedAsync(Tenant(tenantId, "scan-gate"), asset);
|
||||
var seed = await SeedLoginUserAsync(factory, tenantId);
|
||||
await factory.SeedAsync(asset);
|
||||
using var client = factory.CreateClient();
|
||||
await LoginAsync(client, seed);
|
||||
|
||||
using var response = await client.GetAsync($"/api/student/assets/{assetId}/download?tenantCode=scan-gate");
|
||||
using var response = await client.GetAsync($"/api/student/assets/{assetId}/download");
|
||||
var body = await ReadJsonAsync(response);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
|
||||
@@ -234,7 +234,7 @@ public sealed class AssetAccessEndpointTests
|
||||
Guid tenantId)
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var phone = "13800000000";
|
||||
var phone = $"138{Random.Shared.Next(10_000_000, 99_999_999)}";
|
||||
await factory.SeedAsync(
|
||||
Tenant(tenantId, tenantId.ToString("N")),
|
||||
new User
|
||||
@@ -374,4 +374,4 @@ public sealed class AssetAccessEndpointTests
|
||||
"fake-signed-url");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,9 +56,10 @@ public sealed class AssetEndpointTests
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
await factory.AuthorizeStudentCatalogAsync(client, tenantId);
|
||||
using var response =
|
||||
await client.GetAsync(
|
||||
$"/api/public/catalog/content-assets?tenantCode=master®ionId={regionId}&assetType=pdf");
|
||||
$"/api/student/catalog/content-assets?tenantCode=master®ionId={regionId}&assetType=pdf");
|
||||
var items = await ReadItemsAsync(response);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
@@ -84,8 +85,9 @@ public sealed class AssetEndpointTests
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
await factory.AuthorizeStudentCatalogAsync(client, tenantId);
|
||||
using var response =
|
||||
await client.GetAsync("/api/public/catalog/content-assets?tenantCode=master&includeLocked=true");
|
||||
await client.GetAsync("/api/student/catalog/content-assets?tenantCode=master&includeLocked=true");
|
||||
var items = await ReadItemsAsync(response);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
@@ -117,10 +119,11 @@ public sealed class AssetEndpointTests
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
await factory.AuthorizeStudentCatalogAsync(client, tenantId);
|
||||
using var publicResponse =
|
||||
await client.GetAsync("/api/public/catalog/images?tenantCode=master&category=banner");
|
||||
await client.GetAsync("/api/student/catalog/images?tenantCode=master&category=banner");
|
||||
using var lockedResponse =
|
||||
await client.GetAsync("/api/public/catalog/images?tenantCode=master&category=banner&includeLocked=true");
|
||||
await client.GetAsync("/api/student/catalog/images?tenantCode=master&category=banner&includeLocked=true");
|
||||
|
||||
Assert.Equal(["公开图"],
|
||||
(await ReadItemsAsync(publicResponse)).Select(item => item.GetProperty("title").GetString()!).ToArray());
|
||||
@@ -155,7 +158,8 @@ public sealed class AssetEndpointTests
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var response = await client.GetAsync("/api/public/catalog/app-assets?tenantCode=master&assetKey=logo");
|
||||
await factory.AuthorizeStudentCatalogAsync(client, tenantId);
|
||||
using var response = await client.GetAsync("/api/student/catalog/app-assets?tenantCode=master&assetKey=logo");
|
||||
var item = Assert.Single(await ReadItemsAsync(response));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
@@ -196,8 +200,9 @@ public sealed class AssetEndpointTests
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
await factory.AuthorizeStudentCatalogAsync(client, tenantId);
|
||||
using var response =
|
||||
await client.GetAsync($"/api/public/catalog/question-videos?tenantCode=master&questionId={questionId}");
|
||||
await client.GetAsync($"/api/student/catalog/question-videos?tenantCode=master&questionId={questionId}");
|
||||
var item = Assert.Single(await ReadItemsAsync(response));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
@@ -226,4 +231,4 @@ public sealed class AssetEndpointTests
|
||||
.Select(item => item.Clone())
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,18 +16,27 @@ internal static class AuthenticationTestClientExtensions
|
||||
string identifier,
|
||||
string password = PasswordTestUserExtensions.TestPassword)
|
||||
{
|
||||
SetTenantHeader(client, tenantId);
|
||||
return await client.LoginAsTenantCodeAsync(tenantId.ToString("N"), identifier, password);
|
||||
}
|
||||
|
||||
public static async Task<TestAuthenticationTokens> LoginAsTenantCodeAsync(
|
||||
this HttpClient client,
|
||||
string tenantCode,
|
||||
string identifier,
|
||||
string password = PasswordTestUserExtensions.TestPassword)
|
||||
{
|
||||
SetTenantHeader(client, tenantCode);
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/tenant/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
Realm = AuthRealm.Tenant,
|
||||
TenantCode = tenantId.ToString("N"),
|
||||
TenantCode = tenantCode,
|
||||
Identifier = identifier,
|
||||
Password = password
|
||||
});
|
||||
|
||||
return await client.CompleteTenantAuthenticationAsync(response, tenantId, identifier);
|
||||
return await client.CompleteTenantAuthenticationAsync(response, tenantCode, identifier);
|
||||
}
|
||||
|
||||
public static async Task<TestAuthenticationTokens> LoginAsPlatformAsync(
|
||||
@@ -54,7 +63,16 @@ internal static class AuthenticationTestClientExtensions
|
||||
Guid tenantId,
|
||||
string _)
|
||||
{
|
||||
SetTenantHeader(client, tenantId);
|
||||
return await client.CompleteTenantAuthenticationAsync(response, tenantId.ToString("N"), _);
|
||||
}
|
||||
|
||||
private static async Task<TestAuthenticationTokens> CompleteTenantAuthenticationAsync(
|
||||
this HttpClient client,
|
||||
HttpResponseMessage response,
|
||||
string tenantCode,
|
||||
string _)
|
||||
{
|
||||
SetTenantHeader(client, tenantCode);
|
||||
using var authentication = await ReadSuccessfulJsonAsync(response);
|
||||
var root = authentication.RootElement;
|
||||
var status = root.GetProperty("status").GetString();
|
||||
@@ -85,9 +103,14 @@ internal static class AuthenticationTestClientExtensions
|
||||
}
|
||||
|
||||
private static void SetTenantHeader(HttpClient client, Guid tenantId)
|
||||
{
|
||||
SetTenantHeader(client, tenantId.ToString("N"));
|
||||
}
|
||||
|
||||
private static void SetTenantHeader(HttpClient client, string tenantCode)
|
||||
{
|
||||
client.DefaultRequestHeaders.Remove("x-tenant-code");
|
||||
client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N"));
|
||||
client.DefaultRequestHeaders.Add("x-tenant-code", tenantCode);
|
||||
}
|
||||
|
||||
private static async Task<JsonDocument> ReadSuccessfulJsonAsync(HttpResponseMessage response)
|
||||
|
||||
@@ -16,8 +16,8 @@ namespace Tiku.IntegrationTests.Api;
|
||||
public sealed class AuthorizationManifestTests
|
||||
{
|
||||
// Controller-agnostic contract: HTTP method, route, action, anonymous access and policies.
|
||||
private const int ExpectedActionCount = 465;
|
||||
private const string ExpectedSha256 = "8dfb784d46fc916b59bd5e3896f7508d6e640d57bb52744354c0f6411948383e";
|
||||
private const int ExpectedActionCount = 481;
|
||||
private const string ExpectedSha256 = "0f5c7597431d23a50f57f57f5074b3d2ab551bcbc534dc85c98543842bfff723";
|
||||
|
||||
[Fact]
|
||||
public void Controller_authorization_surface_matches_reviewed_manifest()
|
||||
|
||||
@@ -52,7 +52,8 @@ public sealed class CatalogEndpointTests
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var response = await client.GetAsync("/api/public/catalog/regions?tenantCode=master");
|
||||
await factory.AuthorizeStudentCatalogAsync(client, tenantId);
|
||||
using var response = await client.GetAsync("/api/student/catalog/regions?tenantCode=master");
|
||||
var items = await ReadItemsAsync(response);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
@@ -107,8 +108,9 @@ public sealed class CatalogEndpointTests
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
await factory.AuthorizeStudentCatalogAsync(client, tenantId);
|
||||
using var response = await client.GetAsync(
|
||||
$"/api/public/catalog/subjects?tenantCode=master®ionId={regionId}&schoolId={schoolId}&majorId={majorId}&type=professional&keyword=理论");
|
||||
$"/api/student/catalog/subjects?tenantCode=master®ionId={regionId}&schoolId={schoolId}&majorId={majorId}&type=professional&keyword=理论");
|
||||
var items = await ReadItemsAsync(response);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
@@ -143,7 +145,8 @@ public sealed class CatalogEndpointTests
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var response = await client.GetAsync("/api/public/catalog/module-nodes?tenantCode=master&parentId=root");
|
||||
await factory.AuthorizeStudentCatalogAsync(client, tenantId);
|
||||
using var response = await client.GetAsync("/api/student/catalog/module-nodes?tenantCode=master&parentId=root");
|
||||
var items = await ReadItemsAsync(response);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
@@ -152,16 +155,13 @@ public sealed class CatalogEndpointTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Catalog_requires_known_tenant_for_anonymous_requests()
|
||||
public async Task Student_catalog_rejects_anonymous_requests()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var response = await client.GetAsync("/api/public/catalog/regions");
|
||||
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
Assert.Equal("tenant_not_found", body.RootElement.GetProperty("code").GetString());
|
||||
using var response = await client.GetAsync("/api/student/catalog/regions");
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -220,8 +220,9 @@ public sealed class CatalogEndpointTests
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
await factory.AuthorizeStudentCatalogAsync(client, tenantId);
|
||||
using var response =
|
||||
await client.GetAsync($"/api/public/catalog/banners?tenantCode=master®ionId={regionId}");
|
||||
await client.GetAsync($"/api/student/catalog/banners?tenantCode=master®ionId={regionId}");
|
||||
var items = await ReadItemsAsync(response);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
@@ -282,8 +283,9 @@ public sealed class CatalogEndpointTests
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var faqResponse = await client.GetAsync("/api/public/catalog/faqs?tenantCode=master");
|
||||
using var announcementResponse = await client.GetAsync("/api/public/catalog/announcements?tenantCode=master");
|
||||
await factory.AuthorizeStudentCatalogAsync(client, tenantId);
|
||||
using var faqResponse = await client.GetAsync("/api/student/catalog/faqs?tenantCode=master");
|
||||
using var announcementResponse = await client.GetAsync("/api/student/catalog/announcements?tenantCode=master");
|
||||
var faqs = await ReadItemsAsync(faqResponse);
|
||||
var announcements = await ReadItemsAsync(announcementResponse);
|
||||
|
||||
@@ -348,8 +350,9 @@ public sealed class CatalogEndpointTests
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
await factory.AuthorizeStudentCatalogAsync(client, tenantId);
|
||||
using var response = await client.GetAsync(
|
||||
$"/api/public/catalog/exam-dates?tenantCode=master®ionId={regionId}&schoolId={schoolId}");
|
||||
$"/api/student/catalog/exam-dates?tenantCode=master®ionId={regionId}&schoolId={schoolId}");
|
||||
var items = await ReadItemsAsync(response);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
@@ -408,8 +411,9 @@ public sealed class CatalogEndpointTests
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
await factory.AuthorizeStudentCatalogAsync(client, tenantId);
|
||||
using var response = await client.GetAsync(
|
||||
$"/api/public/catalog/products?tenantCode=master®ionId={regionId}&type=material&keyword=联考&limit=1");
|
||||
$"/api/student/catalog/products?tenantCode=master®ionId={regionId}&type=material&keyword=联考&limit=1");
|
||||
var items = await ReadItemsAsync(response);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
@@ -473,8 +477,9 @@ public sealed class CatalogEndpointTests
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
await factory.AuthorizeStudentCatalogAsync(client, tenantId);
|
||||
using var response =
|
||||
await client.GetAsync($"/api/public/catalog/svip-plans?tenantCode=master®ionId={regionId}");
|
||||
await client.GetAsync($"/api/student/catalog/svip-plans?tenantCode=master®ionId={regionId}");
|
||||
var items = await ReadItemsAsync(response);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
@@ -502,4 +507,4 @@ public sealed class CatalogEndpointTests
|
||||
.Select(item => item.Clone())
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,8 +51,9 @@ public sealed class ContentNavigationEndpointTests
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
await factory.AuthorizeStudentCatalogAsync(client, tenantId);
|
||||
using var response =
|
||||
await client.GetAsync($"/api/public/catalog/content-entries?tenantCode=master®ionId={regionId}");
|
||||
await client.GetAsync($"/api/student/catalog/content-entries?tenantCode=master®ionId={regionId}");
|
||||
var items = await ReadItemsAsync(response);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
@@ -98,10 +99,11 @@ public sealed class ContentNavigationEndpointTests
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var missingEntryResponse = await client.GetAsync("/api/public/catalog/content-nodes?tenantCode=master");
|
||||
await factory.AuthorizeStudentCatalogAsync(client, tenantId);
|
||||
using var missingEntryResponse = await client.GetAsync("/api/student/catalog/content-nodes?tenantCode=master");
|
||||
using var response =
|
||||
await client.GetAsync(
|
||||
$"/api/public/catalog/content-nodes?tenantCode=master&entryId={entryId}&parentId=root");
|
||||
$"/api/student/catalog/content-nodes?tenantCode=master&entryId={entryId}&parentId=root");
|
||||
var items = await ReadItemsAsync(response);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, missingEntryResponse.StatusCode);
|
||||
@@ -166,10 +168,11 @@ public sealed class ContentNavigationEndpointTests
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
await factory.AuthorizeStudentCatalogAsync(client, tenantId);
|
||||
using var collectionsResponse = await client.GetAsync(
|
||||
$"/api/public/catalog/question-collections?tenantCode=master&entryId={entryId}&nodeId={nodeId}&collectionType=chapter");
|
||||
$"/api/student/catalog/question-collections?tenantCode=master&entryId={entryId}&nodeId={nodeId}&collectionType=chapter");
|
||||
using var blueprintsResponse = await client.GetAsync(
|
||||
$"/api/public/catalog/practice-blueprints?tenantCode=master&entryId={entryId}&nodeId={nodeId}&collectionId={collectionId}&mode=sequential");
|
||||
$"/api/student/catalog/practice-blueprints?tenantCode=master&entryId={entryId}&nodeId={nodeId}&collectionId={collectionId}&mode=sequential");
|
||||
var collections = await ReadItemsAsync(collectionsResponse);
|
||||
var blueprints = await ReadItemsAsync(blueprintsResponse);
|
||||
|
||||
@@ -253,9 +256,10 @@ public sealed class ContentNavigationEndpointTests
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
await factory.AuthorizeStudentCatalogAsync(client, tenantId);
|
||||
using var response =
|
||||
await client.GetAsync(
|
||||
$"/api/public/catalog/question-collections/questions?tenantCode=master&collectionId={collectionId}");
|
||||
$"/api/student/catalog/question-collections/questions?tenantCode=master&collectionId={collectionId}");
|
||||
var items = await ReadItemsAsync(response);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
@@ -285,4 +289,4 @@ public sealed class ContentNavigationEndpointTests
|
||||
.Select(item => item.Clone())
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
225
Tiku.IntegrationTests/Api/LearningAccessPolicyTests.cs
Normal file
225
Tiku.IntegrationTests/Api/LearningAccessPolicyTests.cs
Normal file
@@ -0,0 +1,225 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Learning;
|
||||
using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class LearningAccessPolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Target_region_history_is_isolated_per_business_line()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
var primaryBusinessLineId = Guid.NewGuid();
|
||||
var futureBusinessLineId = Guid.NewGuid();
|
||||
var primaryRegionId = Guid.NewGuid();
|
||||
var futureRegionId = Guid.NewGuid();
|
||||
await factory.SeedAsync(
|
||||
new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Multi-business Tenant" },
|
||||
new User { Id = userId, Phone = $"137{Random.Shared.Next(10_000_000, 99_999_999)}" },
|
||||
new BusinessLine
|
||||
{
|
||||
Id = primaryBusinessLineId,
|
||||
Code = $"primary-{primaryBusinessLineId:N}",
|
||||
Name = "当前主业务",
|
||||
RegionAccessStrategy = LearningRegionAccessStrategy.NationalWithStudentTargetRegion
|
||||
},
|
||||
new BusinessLine
|
||||
{
|
||||
Id = futureBusinessLineId,
|
||||
Code = $"future-{futureBusinessLineId:N}",
|
||||
Name = "未来新增业务",
|
||||
RegionAccessStrategy = LearningRegionAccessStrategy.NationalWithStudentTargetRegion
|
||||
},
|
||||
new MarketRegion { Id = primaryRegionId, Code = $"p-{primaryRegionId:N}"[..18], Name = "主业务地区" },
|
||||
new MarketRegion { Id = futureRegionId, Code = $"f-{futureRegionId:N}"[..18], Name = "新增业务地区" },
|
||||
new TenantLearningLicense
|
||||
{
|
||||
TenantId = tenantId,
|
||||
BusinessLineId = primaryBusinessLineId,
|
||||
IsPrimary = true,
|
||||
IncludesNational = true
|
||||
},
|
||||
new TenantLearningLicense
|
||||
{
|
||||
TenantId = tenantId,
|
||||
BusinessLineId = futureBusinessLineId,
|
||||
IsPrimary = false,
|
||||
IncludesNational = true
|
||||
},
|
||||
new StudentTargetRegionHistory
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
BusinessLineId = primaryBusinessLineId,
|
||||
MarketRegionId = primaryRegionId
|
||||
},
|
||||
new StudentTargetRegionHistory
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
BusinessLineId = futureBusinessLineId,
|
||||
MarketRegionId = futureRegionId
|
||||
});
|
||||
|
||||
using var scope = factory.CreateTenantScope(tenantId);
|
||||
var target = await scope.ServiceProvider.GetRequiredService<ILearningAccessAdministrationService>()
|
||||
.GetTargetRegionAsync(new LearningActor(tenantId, userId));
|
||||
|
||||
Assert.NotNull(target);
|
||||
Assert.Equal(primaryRegionId, target.MarketRegionId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(LearningRegionAccessStrategy.NationalOnly, true, 1, 0)]
|
||||
[InlineData(LearningRegionAccessStrategy.LicensedRegions, false, 2, 2)]
|
||||
[InlineData(LearningRegionAccessStrategy.NationalWithStudentTargetRegion, true, 2, 1)]
|
||||
[InlineData(LearningRegionAccessStrategy.NationalWithLicensedRegions, true, 3, 2)]
|
||||
public async Task Configured_region_strategy_compiles_expected_slice_and_region_grants(
|
||||
LearningRegionAccessStrategy strategy,
|
||||
bool includesNational,
|
||||
int expectedSliceCount,
|
||||
int expectedRegionCount)
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
var businessLineId = Guid.NewGuid();
|
||||
var licenseId = Guid.NewGuid();
|
||||
var productId = Guid.NewGuid();
|
||||
var regionA = Guid.NewGuid();
|
||||
var regionB = Guid.NewGuid();
|
||||
var nationalSlice = Slice(tenantId, businessLineId, LearningRegionScopeKind.National, null);
|
||||
var regionASlice = Slice(tenantId, businessLineId, LearningRegionScopeKind.Region, regionA);
|
||||
var regionBSlice = Slice(tenantId, businessLineId, LearningRegionScopeKind.Region, regionB);
|
||||
var slices = new[] { nationalSlice, regionASlice, regionBSlice };
|
||||
|
||||
var entities = new List<object>
|
||||
{
|
||||
new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Policy Tenant" },
|
||||
new User { Id = userId, Phone = $"136{Random.Shared.Next(10_000_000, 99_999_999)}" },
|
||||
new BusinessLine
|
||||
{
|
||||
Id = businessLineId,
|
||||
Code = $"p-{(int)strategy}-{tenantId:N}",
|
||||
Name = "配置化业务线",
|
||||
RegionAccessStrategy = strategy
|
||||
},
|
||||
new MarketRegion { Id = regionA, Code = $"a-{regionA:N}"[..18], Name = "地区 A" },
|
||||
new MarketRegion { Id = regionB, Code = $"b-{regionB:N}"[..18], Name = "地区 B" },
|
||||
new TenantLearningLicense
|
||||
{
|
||||
Id = licenseId,
|
||||
TenantId = tenantId,
|
||||
BusinessLineId = businessLineId,
|
||||
IncludesNational = includesNational,
|
||||
StartsAt = DateTimeOffset.UtcNow.AddDays(-1)
|
||||
},
|
||||
new TenantLearningLicenseRegion
|
||||
{
|
||||
TenantId = tenantId,
|
||||
LicenseId = licenseId,
|
||||
MarketRegionId = regionA,
|
||||
IsBaseRegion = true
|
||||
},
|
||||
new TenantLearningLicenseRegion
|
||||
{
|
||||
TenantId = tenantId,
|
||||
LicenseId = licenseId,
|
||||
MarketRegionId = regionB
|
||||
},
|
||||
new LearningProduct
|
||||
{
|
||||
Id = productId,
|
||||
TenantId = tenantId,
|
||||
BusinessLineId = businessLineId,
|
||||
Code = "all-slices",
|
||||
Name = "全部测试切片"
|
||||
},
|
||||
new Entitlement
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
LearningProductId = productId,
|
||||
StartsAt = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(1),
|
||||
Status = EntitlementStatus.Active,
|
||||
SourceType = "integration_test"
|
||||
},
|
||||
new LearningAccessVersion { TenantId = tenantId, UserId = userId }
|
||||
};
|
||||
entities.AddRange(slices);
|
||||
entities.AddRange(slices.Select(slice => new LearningProductScope
|
||||
{
|
||||
TenantId = tenantId,
|
||||
ProductId = productId,
|
||||
ContentSliceOwnerTenantId = tenantId,
|
||||
ContentSliceId = slice.Id
|
||||
}));
|
||||
if (strategy == LearningRegionAccessStrategy.NationalWithStudentTargetRegion)
|
||||
entities.Add(new StudentTargetRegionHistory
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
BusinessLineId = businessLineId,
|
||||
MarketRegionId = regionA
|
||||
});
|
||||
await factory.SeedAsync([.. entities]);
|
||||
|
||||
using var scope = factory.CreateTenantScope(tenantId);
|
||||
var snapshot = await scope.ServiceProvider.GetRequiredService<ILearningAccessService>()
|
||||
.GetSnapshotAsync(new LearningActor(tenantId, userId));
|
||||
|
||||
Assert.Equal(expectedSliceCount, snapshot.ContentSliceIds.Count);
|
||||
Assert.Equal(expectedRegionCount, snapshot.LicensedRegionIds.Count);
|
||||
Assert.Equal(includesNational, snapshot.ContentSliceIds.Contains(nationalSlice.Id));
|
||||
Assert.Equal(
|
||||
strategy == LearningRegionAccessStrategy.NationalWithStudentTargetRegion ? regionA : (Guid?)null,
|
||||
snapshot.TargetRegionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Snapshot_fails_closed_without_an_active_primary_license()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
await factory.SeedAsync(new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Slug = tenantId.ToString("N"),
|
||||
Name = "Unlicensed Tenant"
|
||||
});
|
||||
|
||||
using var scope = factory.CreateTenantScope(tenantId);
|
||||
var exception = await Assert.ThrowsAsync<LearningAccessException>(() =>
|
||||
scope.ServiceProvider.GetRequiredService<ILearningAccessService>()
|
||||
.GetSnapshotAsync(new LearningActor(tenantId, userId)));
|
||||
|
||||
Assert.Equal("learning_license_required", exception.Code);
|
||||
}
|
||||
|
||||
private static ContentSlice Slice(
|
||||
Guid tenantId,
|
||||
Guid businessLineId,
|
||||
LearningRegionScopeKind regionScope,
|
||||
Guid? regionId)
|
||||
{
|
||||
return new ContentSlice
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
BusinessLineId = businessLineId,
|
||||
RegionScope = regionScope,
|
||||
MarketRegionId = regionId,
|
||||
ResourceType = LearningContentResourceType.Collection,
|
||||
ResourceId = Guid.NewGuid(),
|
||||
Status = ContentSliceStatus.Active
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Learning;
|
||||
@@ -41,7 +42,6 @@ public sealed class LearningEndpointTests
|
||||
new SubmitAnswerDto
|
||||
{
|
||||
SessionQuestionId = answerable.SessionQuestionId,
|
||||
ExpectedSessionVersion = 1,
|
||||
ClientSequence = 1,
|
||||
IdempotencyKey = "wrong-answer-1",
|
||||
SelectedOptionIndices = [1]
|
||||
@@ -51,14 +51,14 @@ public sealed class LearningEndpointTests
|
||||
new SubmitPracticeSessionDto
|
||||
{
|
||||
PracticeSessionId = answerable.SessionId,
|
||||
ExpectedSessionVersion = 2,
|
||||
ExpectedSessionVersion = 1,
|
||||
IdempotencyKey = "wrong-submit-1"
|
||||
});
|
||||
using var wrongResponse = await client.GetAsync("/api/student/learning/wrong-questions");
|
||||
var answer = await ReadJsonAsync(response);
|
||||
var wrongItems = await ReadItemsAsync(wrongResponse);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.True(response.IsSuccessStatusCode, await response.Content.ReadAsStringAsync());
|
||||
Assert.Equal(answerable.SessionQuestionId, answer.RootElement.GetProperty("sessionQuestionId").GetGuid());
|
||||
Assert.False(answer.RootElement.TryGetProperty("isCorrect", out _));
|
||||
Assert.Equal(HttpStatusCode.OK, submitResponse.StatusCode);
|
||||
@@ -157,6 +157,7 @@ public sealed class LearningEndpointTests
|
||||
QuestionId = secondQuestionId,
|
||||
SortOrder = 2
|
||||
});
|
||||
await SeedCollectionAccessAsync(factory, seed, collectionId);
|
||||
using var client = factory.CreateClient();
|
||||
await LoginAsync(client, seed);
|
||||
|
||||
@@ -186,7 +187,6 @@ public sealed class LearningEndpointTests
|
||||
new SubmitAnswerDto
|
||||
{
|
||||
SessionQuestionId = firstSessionQuestionId,
|
||||
ExpectedSessionVersion = 1,
|
||||
ClientSequence = 1,
|
||||
IdempotencyKey = "practice-first-answer",
|
||||
SelectedOptionIndices = [0]
|
||||
@@ -196,7 +196,6 @@ public sealed class LearningEndpointTests
|
||||
new SubmitAnswerDto
|
||||
{
|
||||
SessionQuestionId = secondSessionQuestionId,
|
||||
ExpectedSessionVersion = 2,
|
||||
ClientSequence = 2,
|
||||
IdempotencyKey = "practice-second-answer",
|
||||
SelectedOptionIndices = [1]
|
||||
@@ -206,7 +205,7 @@ public sealed class LearningEndpointTests
|
||||
new SubmitPracticeSessionDto
|
||||
{
|
||||
PracticeSessionId = practiceSessionId,
|
||||
ExpectedSessionVersion = 3,
|
||||
ExpectedSessionVersion = 1,
|
||||
IdempotencyKey = "practice-submit"
|
||||
});
|
||||
var report = await ReadJsonAsync(submitResponse);
|
||||
@@ -390,7 +389,6 @@ public sealed class LearningEndpointTests
|
||||
new SubmitAnswerDto
|
||||
{
|
||||
SessionQuestionId = answerable.SessionQuestionId,
|
||||
ExpectedSessionVersion = 1,
|
||||
ClientSequence = 1,
|
||||
IdempotencyKey = "stats-wrong-answer",
|
||||
SelectedOptionIndices = [1]
|
||||
@@ -400,7 +398,7 @@ public sealed class LearningEndpointTests
|
||||
new SubmitPracticeSessionDto
|
||||
{
|
||||
PracticeSessionId = answerable.SessionId,
|
||||
ExpectedSessionVersion = 2,
|
||||
ExpectedSessionVersion = 1,
|
||||
IdempotencyKey = "stats-wrong-submit"
|
||||
});
|
||||
await client.PostAsJsonAsync(
|
||||
@@ -455,7 +453,6 @@ public sealed class LearningEndpointTests
|
||||
var firstRequest = new SubmitAnswerDto
|
||||
{
|
||||
SessionQuestionId = answerable.SessionQuestionId,
|
||||
ExpectedSessionVersion = 1,
|
||||
ClientSequence = 1,
|
||||
IdempotencyKey = "revision-answer-1",
|
||||
SelectedOptionIndices = [1]
|
||||
@@ -468,7 +465,6 @@ public sealed class LearningEndpointTests
|
||||
new SubmitAnswerDto
|
||||
{
|
||||
SessionQuestionId = answerable.SessionQuestionId,
|
||||
ExpectedSessionVersion = 1,
|
||||
ClientSequence = 1,
|
||||
IdempotencyKey = "revision-answer-1",
|
||||
SelectedOptionIndices = [0]
|
||||
@@ -478,7 +474,6 @@ public sealed class LearningEndpointTests
|
||||
new SubmitAnswerDto
|
||||
{
|
||||
SessionQuestionId = answerable.SessionQuestionId,
|
||||
ExpectedSessionVersion = 2,
|
||||
ClientSequence = 2,
|
||||
IdempotencyKey = "revision-answer-2",
|
||||
SelectedOptionIndices = [0]
|
||||
@@ -488,8 +483,7 @@ public sealed class LearningEndpointTests
|
||||
new SubmitAnswerDto
|
||||
{
|
||||
SessionQuestionId = answerable.SessionQuestionId,
|
||||
ExpectedSessionVersion = 1,
|
||||
ClientSequence = 3,
|
||||
ClientSequence = 2,
|
||||
IdempotencyKey = "revision-answer-stale",
|
||||
SelectedOptionIndices = [0]
|
||||
});
|
||||
@@ -501,21 +495,25 @@ public sealed class LearningEndpointTests
|
||||
Assert.Equal("idempotency_conflict", await ReadProblemCodeAsync(conflict));
|
||||
Assert.Equal(HttpStatusCode.OK, revision.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Conflict, stale.StatusCode);
|
||||
Assert.Equal("practice_session_version_conflict", await ReadProblemCodeAsync(stale));
|
||||
Assert.Equal("practice_client_sequence_conflict", await ReadProblemCodeAsync(stale));
|
||||
|
||||
using var scope = factory.CreateSystemScope("Verify immutable answer revisions");
|
||||
var records = await scope.ServiceProvider.GetRequiredService<TikuDbContext>().AnswerRecords
|
||||
var db = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var records = await db.AnswerRecords
|
||||
.Where(item => item.PracticeSessionId == answerable.SessionId)
|
||||
.OrderBy(item => item.Revision)
|
||||
.ToArrayAsync();
|
||||
Assert.Equal(2, records.Length);
|
||||
Assert.False(records[0].IsCurrent);
|
||||
Assert.True(records[1].IsCurrent);
|
||||
var current = await db.CurrentAnswers.SingleAsync(item =>
|
||||
item.PracticeSessionId == answerable.SessionId &&
|
||||
item.SessionQuestionId == answerable.SessionQuestionId);
|
||||
Assert.Equal(records[1].Id, current.AnswerRecordId);
|
||||
Assert.Equal(2, current.Revision);
|
||||
Assert.Equal(AnswerGradingStatus.Correct, records[1].GradingStatus);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Session_detail_uses_immutable_safe_snapshot()
|
||||
public async Task Session_detail_uses_locked_immutable_delivery_version()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedLearningUserAsync(factory);
|
||||
@@ -526,13 +524,24 @@ public sealed class LearningEndpointTests
|
||||
var before =
|
||||
await client.GetAsync(
|
||||
$"/api/student/learning/practice-sessions/detail?practiceSessionId={answerable.SessionId}");
|
||||
using (var scope = factory.CreateSystemScope("Mutate source question version after session creation"))
|
||||
using (var scope = factory.CreateSystemScope("Publish a new question version after session creation"))
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var version = await db.QuestionVersions.SingleAsync(item => item.Id == answerable.VersionId);
|
||||
version.Content = "mutated source content";
|
||||
version.Explanation = "must remain hidden";
|
||||
version.CorrectOptionIndex = 1;
|
||||
var next = new QuestionVersion
|
||||
{
|
||||
TenantId = version.TenantId,
|
||||
QuestionId = version.QuestionId,
|
||||
VersionNo = version.VersionNo + 1,
|
||||
QuestionType = version.QuestionType,
|
||||
Content = "new delivery content",
|
||||
Explanation = "must remain hidden",
|
||||
CorrectOptionIndex = 1
|
||||
};
|
||||
db.QuestionVersions.Add(next);
|
||||
var question = await db.Questions.SingleAsync(item =>
|
||||
item.TenantId == version.TenantId && item.Id == version.QuestionId);
|
||||
question.CurrentVersionId = next.Id;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
@@ -561,7 +570,6 @@ public sealed class LearningEndpointTests
|
||||
new SubmitAnswerDto
|
||||
{
|
||||
SessionQuestionId = answerable.SessionQuestionId,
|
||||
ExpectedSessionVersion = 1,
|
||||
ClientSequence = 1,
|
||||
IdempotencyKey = "subjective-answer",
|
||||
AnswerText = "student response"
|
||||
@@ -571,7 +579,7 @@ public sealed class LearningEndpointTests
|
||||
new SubmitPracticeSessionDto
|
||||
{
|
||||
PracticeSessionId = answerable.SessionId,
|
||||
ExpectedSessionVersion = 2,
|
||||
ExpectedSessionVersion = 1,
|
||||
IdempotencyKey = "subjective-submit"
|
||||
});
|
||||
var answerBody = await ReadJsonAsync(answer);
|
||||
@@ -601,7 +609,6 @@ public sealed class LearningEndpointTests
|
||||
new SubmitAnswerDto
|
||||
{
|
||||
SessionQuestionId = answerable.SessionQuestionId,
|
||||
ExpectedSessionVersion = 1,
|
||||
ClientSequence = 1,
|
||||
IdempotencyKey = "concurrent-answer",
|
||||
SelectedOptionIndices = [0]
|
||||
@@ -613,7 +620,7 @@ public sealed class LearningEndpointTests
|
||||
new SubmitPracticeSessionDto
|
||||
{
|
||||
PracticeSessionId = answerable.SessionId,
|
||||
ExpectedSessionVersion = 2,
|
||||
ExpectedSessionVersion = 1,
|
||||
IdempotencyKey = "concurrent-submit-a"
|
||||
}),
|
||||
client.PostAsJsonAsync(
|
||||
@@ -621,7 +628,7 @@ public sealed class LearningEndpointTests
|
||||
new SubmitPracticeSessionDto
|
||||
{
|
||||
PracticeSessionId = answerable.SessionId,
|
||||
ExpectedSessionVersion = 2,
|
||||
ExpectedSessionVersion = 1,
|
||||
IdempotencyKey = "concurrent-submit-b"
|
||||
}));
|
||||
|
||||
@@ -647,7 +654,6 @@ public sealed class LearningEndpointTests
|
||||
new SubmitAnswerDto
|
||||
{
|
||||
SessionQuestionId = answerable.SessionQuestionId,
|
||||
ExpectedSessionVersion = 1,
|
||||
ClientSequence = 1,
|
||||
IdempotencyKey = "device-a-answer",
|
||||
SelectedOptionIndices = [0]
|
||||
@@ -657,7 +663,6 @@ public sealed class LearningEndpointTests
|
||||
new SubmitAnswerDto
|
||||
{
|
||||
SessionQuestionId = answerable.SessionQuestionId,
|
||||
ExpectedSessionVersion = 1,
|
||||
ClientSequence = 2,
|
||||
IdempotencyKey = "device-b-answer",
|
||||
SelectedOptionIndices = [1]
|
||||
@@ -670,15 +675,19 @@ public sealed class LearningEndpointTests
|
||||
.Where(item => item.PracticeSessionId == answerable.SessionId)
|
||||
.ToArrayAsync();
|
||||
Assert.Single(records);
|
||||
Assert.True(records[0].IsCurrent);
|
||||
var current = await scope.ServiceProvider.GetRequiredService<TikuDbContext>().CurrentAnswers
|
||||
.SingleAsync(item => item.PracticeSessionId == answerable.SessionId);
|
||||
Assert.Equal(records[0].Id, current.AnswerRecordId);
|
||||
}
|
||||
|
||||
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedLearningUserAsync(
|
||||
private static async Task<(Guid TenantId, Guid UserId, string Phone, Guid BusinessLineId)> SeedLearningUserAsync(
|
||||
ApiTestFactory factory)
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
var phone = "13900000000";
|
||||
var businessLineId = Guid.NewGuid();
|
||||
var licenseId = Guid.NewGuid();
|
||||
var phone = $"139{Random.Shared.Next(10_000_000, 99_999_999)}";
|
||||
await factory.SeedAsync(
|
||||
new Tenant
|
||||
{
|
||||
@@ -698,14 +707,34 @@ public sealed class LearningEndpointTests
|
||||
UserId = userId,
|
||||
Role = TenantRole.Student,
|
||||
Status = MembershipStatus.Active
|
||||
},
|
||||
new BusinessLine
|
||||
{
|
||||
Id = businessLineId,
|
||||
Code = $"national-{tenantId:N}",
|
||||
Name = "全国业务测试",
|
||||
RegionAccessStrategy = LearningRegionAccessStrategy.NationalOnly
|
||||
},
|
||||
new TenantLearningLicense
|
||||
{
|
||||
Id = licenseId,
|
||||
TenantId = tenantId,
|
||||
BusinessLineId = businessLineId,
|
||||
IncludesNational = true,
|
||||
StartsAt = DateTimeOffset.UtcNow.AddDays(-1)
|
||||
},
|
||||
new LearningAccessVersion
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId
|
||||
});
|
||||
|
||||
return (tenantId, userId, phone);
|
||||
return (tenantId, userId, phone, businessLineId);
|
||||
}
|
||||
|
||||
private static async Task<(Guid SessionQuestionId, Guid SessionId, Guid VersionId)> SeedAnswerableQuestionAsync(
|
||||
ApiTestFactory factory,
|
||||
(Guid TenantId, Guid UserId, string Phone) seed,
|
||||
(Guid TenantId, Guid UserId, string Phone, Guid BusinessLineId) seed,
|
||||
Guid questionId,
|
||||
string questionType = "choice")
|
||||
{
|
||||
@@ -724,6 +753,7 @@ public sealed class LearningEndpointTests
|
||||
TenantId = seed.TenantId,
|
||||
QuestionId = questionId,
|
||||
VersionNo = 1,
|
||||
QuestionType = questionType,
|
||||
Content = "answerable question",
|
||||
CorrectOptionIndex = questionType == "choice" ? 0 : null
|
||||
});
|
||||
@@ -747,6 +777,8 @@ public sealed class LearningEndpointTests
|
||||
UserId = seed.UserId,
|
||||
Mode = "single",
|
||||
QuestionCount = 1,
|
||||
AccessGrantVersion = 1,
|
||||
StrongRevocationVersion = 1,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30)
|
||||
},
|
||||
new PracticeSessionQuestion
|
||||
@@ -760,16 +792,59 @@ public sealed class LearningEndpointTests
|
||||
QuestionVersionId = versionId,
|
||||
Position = 0,
|
||||
QuestionType = questionType,
|
||||
ContentSnapshot = "answerable question",
|
||||
CorrectOptionIndexSnapshot = questionType == "choice" ? 0 : null,
|
||||
Score = 1
|
||||
});
|
||||
return (sessionQuestionId, sessionId, versionId);
|
||||
}
|
||||
|
||||
private static async Task SeedCollectionAccessAsync(
|
||||
ApiTestFactory factory,
|
||||
(Guid TenantId, Guid UserId, string Phone, Guid BusinessLineId) seed,
|
||||
Guid collectionId)
|
||||
{
|
||||
var productId = Guid.NewGuid();
|
||||
var sliceId = Guid.NewGuid();
|
||||
await factory.SeedAsync(
|
||||
new ContentSlice
|
||||
{
|
||||
Id = sliceId,
|
||||
TenantId = seed.TenantId,
|
||||
BusinessLineId = seed.BusinessLineId,
|
||||
RegionScope = LearningRegionScopeKind.National,
|
||||
ResourceType = LearningContentResourceType.Collection,
|
||||
ResourceId = collectionId,
|
||||
Status = ContentSliceStatus.Active
|
||||
},
|
||||
new LearningProduct
|
||||
{
|
||||
Id = productId,
|
||||
TenantId = seed.TenantId,
|
||||
BusinessLineId = seed.BusinessLineId,
|
||||
Code = "full-collection",
|
||||
Name = "完整题集"
|
||||
},
|
||||
new LearningProductScope
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
ProductId = productId,
|
||||
ContentSliceOwnerTenantId = seed.TenantId,
|
||||
ContentSliceId = sliceId
|
||||
},
|
||||
new Entitlement
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
UserId = seed.UserId,
|
||||
LearningProductId = productId,
|
||||
StartsAt = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
|
||||
Status = EntitlementStatus.Active,
|
||||
SourceType = "integration_test"
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task LoginAsync(
|
||||
HttpClient client,
|
||||
(Guid TenantId, Guid UserId, string Phone) seed)
|
||||
(Guid TenantId, Guid UserId, string Phone, Guid BusinessLineId) seed)
|
||||
{
|
||||
client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone));
|
||||
}
|
||||
@@ -795,4 +870,4 @@ public sealed class LearningEndpointTests
|
||||
using var body = await ReadJsonAsync(response);
|
||||
return body.RootElement.TryGetProperty("code", out var code) ? code.GetString() : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ public sealed class OpenApiDocumentationTests
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.True(document.RootElement.GetProperty("paths").TryGetProperty("/api/tenant/auth/login/password", out _));
|
||||
Assert.True(document.RootElement.GetProperty("paths").TryGetProperty("/api/public/catalog/regions", out _));
|
||||
Assert.True(document.RootElement.GetProperty("paths").TryGetProperty("/api/student/catalog/regions", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -43,7 +43,8 @@ public sealed class QuestionBankEndpointTests
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var response = await client.GetAsync("/api/public/catalog/question-banks?tenantCode=master");
|
||||
await factory.AuthorizeStudentCatalogAsync(client, tenantId);
|
||||
using var response = await client.GetAsync("/api/student/catalog/question-banks?tenantCode=master");
|
||||
var items = await ReadItemsAsync(response);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
@@ -115,8 +116,9 @@ public sealed class QuestionBankEndpointTests
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
await factory.AuthorizeStudentCatalogAsync(client, tenantId);
|
||||
using var response = await client.GetAsync(
|
||||
$"/api/public/catalog/questions?tenantCode=master&questionBankId={bankId}&subjectId={subjectId}&categoryId={categoryId}&collectionId={collectionId}&type=choice&keyword=关键词");
|
||||
$"/api/student/catalog/questions?tenantCode=master&questionBankId={bankId}&subjectId={subjectId}&categoryId={categoryId}&collectionId={collectionId}&type=choice&keyword=关键词");
|
||||
var items = await ReadItemsAsync(response);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
@@ -124,6 +126,11 @@ public sealed class QuestionBankEndpointTests
|
||||
Assert.Equal(includedQuestionId, item.GetProperty("id").GetGuid());
|
||||
Assert.Equal(versionId, item.GetProperty("versionId").GetGuid());
|
||||
Assert.Equal("题干关键词", item.GetProperty("content").GetString());
|
||||
Assert.False(item.TryGetProperty("correctOptionIndex", out _));
|
||||
Assert.False(item.TryGetProperty("correctOptionIndices", out _));
|
||||
Assert.False(item.TryGetProperty("answerText", out _));
|
||||
Assert.False(item.TryGetProperty("explanation", out _));
|
||||
Assert.False(item.TryGetProperty("subQuestions", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -144,7 +151,8 @@ public sealed class QuestionBankEndpointTests
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var response = await client.GetAsync($"/api/public/catalog/questions/{questionId}?tenantCode=master");
|
||||
await factory.AuthorizeStudentCatalogAsync(client, tenantId);
|
||||
using var response = await client.GetAsync($"/api/student/catalog/questions/{questionId}?tenantCode=master");
|
||||
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
@@ -152,7 +160,7 @@ public sealed class QuestionBankEndpointTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Question_versions_are_returned_newest_first()
|
||||
public async Task Public_question_version_history_is_retired()
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var questionId = Guid.NewGuid();
|
||||
@@ -187,10 +195,10 @@ public sealed class QuestionBankEndpointTests
|
||||
|
||||
using var response =
|
||||
await client.GetAsync($"/api/public/catalog/questions/{questionId}/versions?tenantCode=master");
|
||||
var items = await ReadItemsAsync(response);
|
||||
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(["新版本", "旧版本"], items.Select(item => item.GetProperty("content").GetString()!).ToArray());
|
||||
Assert.Equal(HttpStatusCode.Gone, response.StatusCode);
|
||||
Assert.Equal("public_catalog_retired", body.RootElement.GetProperty("code").GetString());
|
||||
}
|
||||
|
||||
private static Tenant Tenant(Guid id, string slug)
|
||||
@@ -227,4 +235,4 @@ public sealed class QuestionBankEndpointTests
|
||||
.Select(item => item.Clone())
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace Tiku.IntegrationTests.Api;
|
||||
public sealed class ScorelineEndpointTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Public_scoreline_records_support_dynamic_filters()
|
||||
public async Task Student_scoreline_records_support_dynamic_filters()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var tenantId = Guid.NewGuid();
|
||||
@@ -62,9 +62,10 @@ public sealed class ScorelineEndpointTests
|
||||
FieldValues = JsonSerializer.SerializeToElement(new { cultureScore = 360, scoreType = "统考" })
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
await factory.AuthorizeStudentCatalogAsync(client, tenantId);
|
||||
|
||||
using var response = await client.GetAsync(
|
||||
$"/api/public/scoreline/records?tenantCode={tenantCode}®ionId={regionId}&min.cultureScore=400");
|
||||
$"/api/student/scoreline/records?regionId={regionId}&min.cultureScore=400");
|
||||
var json = await ReadJsonAsync(response);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
@@ -74,7 +75,7 @@ public sealed class ScorelineEndpointTests
|
||||
Assert.Equal(420, item.GetProperty("fieldValues").GetProperty("cultureScore").GetInt32());
|
||||
|
||||
using var firstCursorResponse = await client.GetAsync(
|
||||
$"/api/public/scoreline/records/cursor?tenantCode={tenantCode}®ionId={regionId}&pageSize=1");
|
||||
$"/api/student/scoreline/records/cursor?regionId={regionId}&pageSize=1");
|
||||
var firstCursorJson = await ReadJsonAsync(firstCursorResponse);
|
||||
Assert.Equal(HttpStatusCode.OK, firstCursorResponse.StatusCode);
|
||||
Assert.True(firstCursorJson.RootElement.GetProperty("hasMore").GetBoolean());
|
||||
@@ -84,7 +85,7 @@ public sealed class ScorelineEndpointTests
|
||||
Assert.False(string.IsNullOrWhiteSpace(nextCursor));
|
||||
|
||||
using var secondCursorResponse = await client.GetAsync(
|
||||
$"/api/public/scoreline/records/cursor?tenantCode={tenantCode}®ionId={regionId}&pageSize=1&cursor={Uri.EscapeDataString(nextCursor!)}");
|
||||
$"/api/student/scoreline/records/cursor?regionId={regionId}&pageSize=1&cursor={Uri.EscapeDataString(nextCursor!)}");
|
||||
var secondCursorJson = await ReadJsonAsync(secondCursorResponse);
|
||||
Assert.Equal(HttpStatusCode.OK, secondCursorResponse.StatusCode);
|
||||
Assert.False(secondCursorJson.RootElement.GetProperty("hasMore").GetBoolean());
|
||||
@@ -93,23 +94,21 @@ public sealed class ScorelineEndpointTests
|
||||
Assert.Equal(2025, secondCursorItem.GetProperty("year").GetInt32());
|
||||
|
||||
using var invalidCursorResponse = await client.GetAsync(
|
||||
$"/api/public/scoreline/records/cursor?tenantCode={tenantCode}®ionId={regionId}&cursor=not-a-cursor");
|
||||
$"/api/student/scoreline/records/cursor?regionId={regionId}&cursor=not-a-cursor");
|
||||
var invalidCursorJson = await ReadJsonAsync(invalidCursorResponse);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, invalidCursorResponse.StatusCode);
|
||||
Assert.Equal("scoreline_cursor_invalid", invalidCursorJson.RootElement.GetProperty("code").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Public_scoreline_without_tenant_returns_not_found()
|
||||
public async Task Student_scoreline_rejects_anonymous_requests()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var response = await client.GetAsync("/api/public/scoreline/fields");
|
||||
var json = await ReadJsonAsync(response);
|
||||
using var response = await client.GetAsync("/api/student/scoreline/fields");
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
Assert.Equal("tenant_not_found", json.RootElement.GetProperty("code").GetString());
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
private static async Task<JsonDocument> ReadJsonAsync(HttpResponseMessage response)
|
||||
@@ -117,4 +116,4 @@ public sealed class ScorelineEndpointTests
|
||||
var stream = await response.Content.ReadAsStreamAsync();
|
||||
return await JsonDocument.ParseAsync(stream);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,8 +46,9 @@ public sealed class StudyContentEndpointTests
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
await factory.AuthorizeStudentCatalogAsync(client, tenantId);
|
||||
using var response =
|
||||
await client.GetAsync($"/api/public/catalog/vocabulary-units?tenantCode=master®ionId={regionId}");
|
||||
await client.GetAsync($"/api/student/catalog/vocabulary-units?tenantCode=master®ionId={regionId}");
|
||||
var items = await ReadItemsAsync(response);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
@@ -93,8 +94,9 @@ public sealed class StudyContentEndpointTests
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
await factory.AuthorizeStudentCatalogAsync(client, tenantId);
|
||||
using var response =
|
||||
await client.GetAsync($"/api/public/catalog/vocabulary-words?tenantCode=master&unitId={unitId}&keyword=放弃");
|
||||
await client.GetAsync($"/api/student/catalog/vocabulary-words?tenantCode=master&unitId={unitId}&keyword=放弃");
|
||||
var items = await ReadItemsAsync(response);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
@@ -136,8 +138,9 @@ public sealed class StudyContentEndpointTests
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
await factory.AuthorizeStudentCatalogAsync(client, tenantId);
|
||||
using var response =
|
||||
await client.GetAsync($"/api/public/catalog/handbook-chapters?tenantCode=master&subjectId={subjectId}");
|
||||
await client.GetAsync($"/api/student/catalog/handbook-chapters?tenantCode=master&subjectId={subjectId}");
|
||||
var items = await ReadItemsAsync(response);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
@@ -172,11 +175,12 @@ public sealed class StudyContentEndpointTests
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
await factory.AuthorizeStudentCatalogAsync(client, tenantId);
|
||||
using var listResponse =
|
||||
await client.GetAsync($"/api/public/catalog/handbook-entries?tenantCode=master&chapterId={chapterId}");
|
||||
await client.GetAsync($"/api/student/catalog/handbook-entries?tenantCode=master&chapterId={chapterId}");
|
||||
using var detailResponse =
|
||||
await client.GetAsync(
|
||||
$"/api/public/catalog/handbook-entries?tenantCode=master&chapterId={chapterId}&includeContent=true");
|
||||
$"/api/student/catalog/handbook-entries?tenantCode=master&chapterId={chapterId}&includeContent=true");
|
||||
var listItem = Assert.Single(await ReadItemsAsync(listResponse));
|
||||
var detailItem = Assert.Single(await ReadItemsAsync(detailResponse));
|
||||
|
||||
@@ -207,4 +211,4 @@ public sealed class StudyContentEndpointTests
|
||||
.Select(item => item.Clone())
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ using Tiku.Application.Learning;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Learning;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Domain.Tenancy;
|
||||
@@ -247,6 +249,10 @@ public sealed class PhaseThreeTenantIsolationTests
|
||||
var collectionId = Guid.NewGuid();
|
||||
var platformReferenceId = Guid.NewGuid();
|
||||
var privateReferenceId = Guid.NewGuid();
|
||||
var businessLineId = Guid.NewGuid();
|
||||
var licenseId = Guid.NewGuid();
|
||||
var productId = Guid.NewGuid();
|
||||
var contentSliceId = Guid.NewGuid();
|
||||
await factory.SeedAsync(
|
||||
Tenant(platformId, "platform", TenantMode.PlatformOwned),
|
||||
Tenant(tenantId, "tenant-a"),
|
||||
@@ -326,6 +332,56 @@ public sealed class PhaseThreeTenantIsolationTests
|
||||
QuestionOwnerTenantId = tenantId,
|
||||
QuestionId = privateQuestionId,
|
||||
SortOrder = 2
|
||||
},
|
||||
new BusinessLine
|
||||
{
|
||||
Id = businessLineId,
|
||||
Code = $"upgrade-{tenantId:N}",
|
||||
Name = "专升本",
|
||||
RegionAccessStrategy = LearningRegionAccessStrategy.NationalOnly
|
||||
},
|
||||
new TenantLearningLicense
|
||||
{
|
||||
Id = licenseId,
|
||||
TenantId = tenantId,
|
||||
BusinessLineId = businessLineId,
|
||||
IncludesNational = true,
|
||||
StartsAt = DateTimeOffset.UtcNow.AddDays(-1)
|
||||
},
|
||||
new ContentSlice
|
||||
{
|
||||
Id = contentSliceId,
|
||||
TenantId = tenantId,
|
||||
BusinessLineId = businessLineId,
|
||||
RegionScope = LearningRegionScopeKind.National,
|
||||
ResourceType = LearningContentResourceType.Collection,
|
||||
ResourceId = collectionId,
|
||||
Status = ContentSliceStatus.Active
|
||||
},
|
||||
new LearningProduct
|
||||
{
|
||||
Id = productId,
|
||||
TenantId = tenantId,
|
||||
BusinessLineId = businessLineId,
|
||||
Code = "mixed-collection",
|
||||
Name = "混合题集"
|
||||
},
|
||||
new LearningProductScope
|
||||
{
|
||||
TenantId = tenantId,
|
||||
ProductId = productId,
|
||||
ContentSliceOwnerTenantId = tenantId,
|
||||
ContentSliceId = contentSliceId
|
||||
},
|
||||
new Entitlement
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
LearningProductId = productId,
|
||||
StartsAt = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
|
||||
Status = EntitlementStatus.Active,
|
||||
SourceType = "integration_test"
|
||||
});
|
||||
|
||||
Guid firstSessionId;
|
||||
|
||||
Reference in New Issue
Block a user