feat(learning): harden access and practice sessions

This commit is contained in:
2026-08-05 14:01:36 +08:00
parent 603bc24c26
commit df31c3a669
88 changed files with 72411 additions and 543 deletions

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View 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; }
}

View File

@@ -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);
}
}
}

View 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);
}

View File

@@ -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);
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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.")
{
}
}
}

View 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);
}
}

View 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);
}

View 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" }
});
}
}

View File

@@ -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;
}
}
}

View File

@@ -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);
}
}

View File

@@ -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));
}
}
}

View File

@@ -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)

View File

@@ -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; } = [];
}
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);
}

View 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);

View File

@@ -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);

View 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);
}

View File

@@ -16,7 +16,4 @@ public interface IQuestionBankQueryService
QuestionBankFilter filter,
CancellationToken cancellationToken = default);
Task<CatalogList<QuestionVersionCatalogItem>> GetQuestionVersionsAsync(
QuestionBankFilter filter,
CancellationToken cancellationToken = default);
}
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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
}
}

View 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
}

View File

@@ -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();
}
}

View File

@@ -237,5 +237,7 @@ public enum PracticeAccessEventMode
Free,
Svip,
Staff,
Package,
ClassAssignment,
Denied
}
}

View File

@@ -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; }
}
}

View File

@@ -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
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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)

View File

@@ -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();
}
}

View File

@@ -67,6 +67,7 @@ internal sealed partial class RefundAdministrationService
}
await unitOfWork.SaveChangesAsync(cancellationToken);
await FlushLearningAccessInvalidationsAsync(cancellationToken);
return refund;
}
}

View File

@@ -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;
}

View File

@@ -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);
}
}
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -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>());

View File

@@ -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);
}

View 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}";
}

View File

@@ -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);
}

View File

@@ -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)

View File

@@ -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);

View File

@@ -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()

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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>();

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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");
});
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

View File

@@ -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" });
}
}
}

View File

@@ -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; }

View File

@@ -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>();
}
}

View File

@@ -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))

View File

@@ -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,

View File

@@ -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

View File

@@ -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);

View File

@@ -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);
}
}
}

View File

@@ -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 =>
{

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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,

View File

@@ -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");
}
}
}
}

View File

@@ -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&regionId={regionId}&assetType=pdf");
$"/api/student/catalog/content-assets?tenantCode=master&regionId={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();
}
}
}

View File

@@ -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)

View File

@@ -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()

View File

@@ -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&regionId={regionId}&schoolId={schoolId}&majorId={majorId}&type=professional&keyword=理论");
$"/api/student/catalog/subjects?tenantCode=master&regionId={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&regionId={regionId}");
await client.GetAsync($"/api/student/catalog/banners?tenantCode=master&regionId={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&regionId={regionId}&schoolId={schoolId}");
$"/api/student/catalog/exam-dates?tenantCode=master&regionId={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&regionId={regionId}&type=material&keyword=联考&limit=1");
$"/api/student/catalog/products?tenantCode=master&regionId={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&regionId={regionId}");
await client.GetAsync($"/api/student/catalog/svip-plans?tenantCode=master&regionId={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();
}
}
}

View File

@@ -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&regionId={regionId}");
await client.GetAsync($"/api/student/catalog/content-entries?tenantCode=master&regionId={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();
}
}
}

View 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
};
}
}

View File

@@ -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;
}
}
}

View File

@@ -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]

View File

@@ -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();
}
}
}

View File

@@ -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}&regionId={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}&regionId={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}&regionId={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}&regionId={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);
}
}
}

View File

@@ -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&regionId={regionId}");
await client.GetAsync($"/api/student/catalog/vocabulary-units?tenantCode=master&regionId={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();
}
}
}

View File

@@ -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;