diff --git a/Tiku.Api/Contracts/ContentManagementDtos.cs b/Tiku.Api/Contracts/ContentManagementDtos.cs
deleted file mode 100644
index 73bbc35..0000000
--- a/Tiku.Api/Contracts/ContentManagementDtos.cs
+++ /dev/null
@@ -1,665 +0,0 @@
-using System.ComponentModel.DataAnnotations;
-using System.Text.Json;
-using Tiku.Application.Content;
-using Tiku.Application.QuestionBanks;
-using Tiku.Domain.Common;
-using Tiku.Domain.Content;
-
-namespace Tiku.Api.Contracts;
-
-///
-/// 内容管理查询参数。
-///
-public sealed class ContentManagementQueryDto
-{
- ///
- /// 地区 ID。
- ///
- public Guid? RegionId { get; set; }
-
- ///
- /// 内容入口 ID。
- ///
- public Guid? EntryId { get; set; }
-
- ///
- /// 节点 ID。
- ///
- public Guid? NodeId { get; set; }
-
- ///
- /// 题集 ID。
- ///
- public Guid? CollectionId { get; set; }
-
- ///
- /// 父节点 ID;传 root 表示根节点。
- ///
- [StringLength(64)]
- [RegularExpression("^(root|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$")]
- public string? ParentId { get; set; }
-
- ///
- /// 入口类型。
- ///
- [StringLength(50)]
- public string? EntryType { get; set; }
-
- ///
- /// 题集类型。
- ///
- [StringLength(50)]
- public string? CollectionType { get; set; }
-
- ///
- /// 模式。
- ///
- [StringLength(50)]
- public string? Mode { get; set; }
-
- ///
- /// 标记类型。
- ///
- [StringLength(50)]
- public string? MarkerType { get; set; }
-
- ///
- /// 关键字。
- ///
- [StringLength(100)]
- public string? Keyword { get; set; }
-
- ///
- /// 是否包含停用数据。
- ///
- public bool IncludeInactive { get; set; }
-
- ///
- /// 返回数量上限。
- ///
- [Range(1, 1000)]
- public int? Limit { get; set; }
-
- public ContentManagementFilter ToFilter()
- {
- return new ContentManagementFilter(
- RegionId,
- EntryId,
- NodeId,
- CollectionId,
- ParentId,
- EntryType,
- CollectionType,
- Mode,
- MarkerType,
- Keyword,
- IncludeInactive,
- Limit);
- }
-}
-
-///
-/// 新增或更新内容条目请求 DTO。
-///
-public sealed class UpsertContentEntryDto
-{
- ///
- /// ID。
- ///
- public Guid? Id { get; set; }
-
- ///
- /// 地区 ID。
- ///
- public Guid? RegionId { get; set; }
-
- ///
- /// 历史系统 ID。
- ///
- [StringLength(64)]
- public string? LegacyId { get; set; }
-
- ///
- /// 入口键。
- ///
- [StringLength(100)]
- public string? EntryKey { get; set; }
-
- ///
- /// 名称。
- ///
- [Required]
- [StringLength(300)]
- public string Name { get; set; } = string.Empty;
-
- ///
- /// 入口类型。
- ///
- [StringLength(50)]
- public string? EntryType { get; set; }
-
- ///
- /// 图标。
- ///
- [StringLength(100)]
- public string? Icon { get; set; }
-
- ///
- /// 路由地址。
- ///
- [StringLength(500)]
- public string? Route { get; set; }
-
- ///
- /// 说明。
- ///
- [StringLength(2000)]
- public string? Description { get; set; }
-
- ///
- /// 可见性。
- ///
- [StringLength(50)]
- public string? Visibility { get; set; }
-
- ///
- /// 访问规则。
- ///
- public JsonElement AccessRules { get; set; } = JsonDefaults.Object();
-
- ///
- /// 布局配置。
- ///
- public JsonElement LayoutConfig { get; set; } = JsonDefaults.Object();
-
- ///
- /// 显示顺序。
- ///
- public int? Order { get; set; }
-
- ///
- /// 是否启用。
- ///
- public bool? IsActive { get; set; }
-
- public UpsertContentEntryCommand ToCommand()
- {
- return new UpsertContentEntryCommand(
- Id,
- RegionId,
- LegacyId,
- EntryKey,
- Name,
- EntryType,
- Icon,
- Route,
- Description,
- Visibility,
- AccessRules,
- LayoutConfig,
- Order,
- IsActive);
- }
-}
-
-///
-/// 新增或更新内容节点请求 DTO。
-///
-public sealed class UpsertContentNodeDto
-{
- ///
- /// ID。
- ///
- public Guid? Id { get; set; }
-
- ///
- /// 内容入口 ID。
- ///
- [Required]
- public Guid EntryId { get; set; }
-
- ///
- /// 地区 ID。
- ///
- public Guid? RegionId { get; set; }
-
- ///
- /// 父节点 ID。
- ///
- public Guid? ParentId { get; set; }
-
- ///
- /// 历史系统 ID。
- ///
- [StringLength(64)]
- public string? LegacyId { get; set; }
-
- ///
- /// 节点键。
- ///
- [StringLength(100)]
- public string? NodeKey { get; set; }
-
- ///
- /// 名称。
- ///
- [Required]
- [StringLength(300)]
- public string Name { get; set; } = string.Empty;
-
- ///
- /// 节点Type。
- ///
- [StringLength(50)]
- public string? NodeType { get; set; }
-
- ///
- /// 标记类型。
- ///
- [StringLength(50)]
- public string? MarkerType { get; set; }
-
- ///
- /// 标记配置。
- ///
- public JsonElement MarkerConfig { get; set; } = JsonDefaults.Object();
-
- ///
- /// 显示顺序。
- ///
- public int? Order { get; set; }
-
- ///
- /// 是否启用。
- ///
- public bool? IsActive { get; set; }
-
- ///
- /// 是否可选择。
- ///
- public bool? IsSelectable { get; set; }
-
- ///
- /// 是否叶子节点。
- ///
- public bool? IsLeaf { get; set; }
-
- ///
- /// 访问规则。
- ///
- public JsonElement AccessRules { get; set; } = JsonDefaults.Object();
-
- ///
- /// 扩展元数据。
- ///
- public JsonElement Metadata { get; set; } = JsonDefaults.Object();
-
- public UpsertContentNodeCommand ToCommand()
- {
- return new UpsertContentNodeCommand(
- Id,
- EntryId,
- RegionId,
- ParentId,
- LegacyId,
- NodeKey,
- Name,
- NodeType,
- MarkerType,
- MarkerConfig,
- Order,
- IsActive,
- IsSelectable,
- IsLeaf,
- AccessRules,
- Metadata);
- }
-}
-
-///
-/// 新增或更新题目题集请求 DTO。
-///
-public sealed class UpsertQuestionCollectionDto
-{
- ///
- /// ID。
- ///
- public Guid? Id { get; set; }
-
- ///
- /// 地区 ID。
- ///
- public Guid? RegionId { get; set; }
-
- ///
- /// 内容入口 ID。
- ///
- public Guid? EntryId { get; set; }
-
- ///
- /// 节点 ID。
- ///
- public Guid? NodeId { get; set; }
-
- ///
- /// 科目 ID。
- ///
- public Guid? SubjectId { get; set; }
-
- ///
- /// 分类 ID。
- ///
- public Guid? CategoryId { get; set; }
-
- ///
- /// 题库 ID。
- ///
- public Guid? QuestionBankId { get; set; }
-
- ///
- /// 历史系统 ID。
- ///
- [StringLength(64)]
- public string? LegacyId { get; set; }
-
- ///
- /// 名称。
- ///
- [Required]
- [StringLength(300)]
- public string Name { get; set; } = string.Empty;
-
- ///
- /// 题集类型。
- ///
- [StringLength(50)]
- public string? CollectionType { get; set; }
-
- ///
- /// 来源类型。
- ///
- [StringLength(50)]
- public string? SourceType { get; set; }
-
- ///
- /// 筛选条件。
- ///
- public JsonElement Filters { get; set; } = JsonDefaults.Object();
-
- ///
- /// 总分。
- ///
- public decimal? TotalScore { get; set; }
-
- ///
- /// 时长,单位为分钟。
- ///
- public int? DurationMinutes { get; set; }
-
- ///
- /// 状态。
- ///
- [StringLength(50)]
- public string? Status { get; set; }
-
- ///
- /// 显示顺序。
- ///
- public int? Order { get; set; }
-
- ///
- /// 访问规则。
- ///
- public JsonElement AccessRules { get; set; } = JsonDefaults.Object();
-
- ///
- /// 扩展元数据。
- ///
- public JsonElement Metadata { get; set; } = JsonDefaults.Object();
-
- public UpsertQuestionCollectionCommand ToCommand()
- {
- return new UpsertQuestionCollectionCommand(
- Id,
- RegionId,
- EntryId,
- NodeId,
- SubjectId,
- CategoryId,
- QuestionBankId,
- LegacyId,
- Name,
- CollectionType,
- SourceType,
- Filters,
- TotalScore,
- DurationMinutes,
- Status,
- Order,
- AccessRules,
- Metadata);
- }
-}
-
-///
-/// 题集题目请求 DTO。
-///
-public sealed class CollectionQuestionDto
-{
- ///
- /// 题目 ID。
- ///
- [Required]
- public Guid QuestionId { get; set; }
-
- ///
- /// 来源。
- ///
- [Required]
- public QuestionSource Source { get; set; } = QuestionSource.Tenant;
-
- ///
- /// 分段键。
- ///
- [StringLength(100)]
- public string? SectionKey { get; set; }
-
- ///
- /// 显示顺序。
- ///
- public int? Order { get; set; }
-
- ///
- /// 分数。
- ///
- public decimal? Score { get; set; }
-
- ///
- /// 是否必填。
- ///
- public bool? Required { get; set; }
-
- ///
- /// 扩展元数据。
- ///
- public JsonElement Metadata { get; set; } = JsonDefaults.Object();
-
- public CollectionQuestionCommand ToCommand()
- {
- return new CollectionQuestionCommand(
- new QuestionLocator(Source, QuestionId),
- SectionKey,
- Order,
- Score,
- Required,
- Metadata);
- }
-}
-
-///
-/// 替换题集Items请求 DTO。
-///
-public sealed class ReplaceCollectionItemsDto
-{
- ///
- /// 题集 ID。
- ///
- [Required]
- public Guid CollectionId { get; set; }
-
- ///
- /// 题目列表。
- ///
- public IReadOnlyCollection Questions { get; set; } = [];
-
- public ReplaceCollectionItemsCommand ToCommand()
- {
- return new ReplaceCollectionItemsCommand(
- CollectionId,
- Questions.Select(question => question.ToCommand()).ToArray());
- }
-}
-
-///
-/// 新增或更新练习Blueprint请求 DTO。
-///
-public sealed class UpsertPracticeBlueprintDto
-{
- ///
- /// ID。
- ///
- public Guid? Id { get; set; }
-
- ///
- /// 地区 ID。
- ///
- public Guid? RegionId { get; set; }
-
- ///
- /// 内容入口 ID。
- ///
- public Guid? EntryId { get; set; }
-
- ///
- /// 节点 ID。
- ///
- public Guid? NodeId { get; set; }
-
- ///
- /// 题集 ID。
- ///
- public Guid? CollectionId { get; set; }
-
- ///
- /// 历史系统 ID。
- ///
- [StringLength(64)]
- public string? LegacyId { get; set; }
-
- ///
- /// 名称。
- ///
- [Required]
- [StringLength(300)]
- public string Name { get; set; } = string.Empty;
-
- ///
- /// 模式。
- ///
- [StringLength(50)]
- public string? Mode { get; set; }
-
- ///
- /// 组卷方式。
- ///
- [StringLength(50)]
- public string? AssemblyType { get; set; }
-
- ///
- /// 题目数量上限。
- ///
- public int? QuestionLimit { get; set; }
-
- ///
- /// 时长,单位为分钟。
- ///
- public int? DurationMinutes { get; set; }
-
- ///
- /// 总分。
- ///
- public decimal? TotalScore { get; set; }
-
- ///
- /// 及格分。
- ///
- public decimal? PassScore { get; set; }
-
- ///
- /// 分段配置。
- ///
- public JsonElement Sections { get; set; } = JsonDefaults.Array();
-
- ///
- /// 规则配置。
- ///
- public JsonElement Rules { get; set; } = JsonDefaults.Object();
-
- ///
- /// 访问规则。
- ///
- public JsonElement AccessRules { get; set; } = JsonDefaults.Object();
-
- ///
- /// 状态。
- ///
- [StringLength(50)]
- public string? Status { get; set; }
-
- ///
- /// 显示顺序。
- ///
- public int? Order { get; set; }
-
- public UpsertPracticeBlueprintCommand ToCommand()
- {
- return new UpsertPracticeBlueprintCommand(
- Id,
- RegionId,
- EntryId,
- NodeId,
- CollectionId,
- LegacyId,
- Name,
- Mode,
- AssemblyType,
- QuestionLimit,
- DurationMinutes,
- TotalScore,
- PassScore,
- Sections,
- Rules,
- AccessRules,
- Status,
- Order);
- }
-}
-
-///
-/// 导入Template查询参数。
-///
-public sealed class ImportTemplateQueryDto
-{
- ///
- /// 导入Type。
- ///
- [Required]
- [StringLength(50)]
- public string ImportType { get; set; } = string.Empty;
-
- ///
- /// 导出格式。
- ///
- [StringLength(10)]
- public string? Format { get; set; }
-}
\ No newline at end of file
diff --git a/Tiku.Api/Contracts/ContentNavigationDtos.cs b/Tiku.Api/Contracts/ContentNavigationDtos.cs
deleted file mode 100644
index e7aa9f2..0000000
--- a/Tiku.Api/Contracts/ContentNavigationDtos.cs
+++ /dev/null
@@ -1,116 +0,0 @@
-using System.ComponentModel.DataAnnotations;
-using Tiku.Application.Content;
-
-namespace Tiku.Api.Contracts;
-
-///
-/// 内容Navigation查询参数。
-///
-public sealed class ContentNavigationQueryDto
-{
- ///
- /// 租户编码。
- ///
- [StringLength(100)]
- public string? TenantCode { get; set; }
-
- ///
- /// 地区 ID。
- ///
- public Guid? RegionId { get; set; }
-
- ///
- /// 内容入口 ID。
- ///
- public Guid? EntryId { get; set; }
-
- ///
- /// 节点 ID。
- ///
- public Guid? NodeId { get; set; }
-
- ///
- /// 题集 ID。
- ///
- public Guid? CollectionId { get; set; }
-
- ///
- /// 父节点 ID;传 root 表示根节点。
- ///
- [StringLength(64)]
- [RegularExpression("^(root|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$")]
- public string? ParentId { get; set; }
-
- ///
- /// 入口类型。
- ///
- [StringLength(50)]
- public string? EntryType { get; set; }
-
- ///
- /// 题集类型。
- ///
- [StringLength(50)]
- public string? CollectionType { get; set; }
-
- ///
- /// 模式。
- ///
- [StringLength(50)]
- public string? Mode { get; set; }
-
- ///
- /// 标记类型。
- ///
- [StringLength(50)]
- public string? MarkerType { get; set; }
-
- ///
- /// 关键字。
- ///
- [StringLength(100)]
- public string? Keyword { get; set; }
-
- ///
- /// 是否包含隐藏数据。
- ///
- public bool IncludeHidden { get; set; }
-
- ///
- /// 是否包含停用数据。
- ///
- public bool IncludeInactive { get; set; }
-
- ///
- /// 返回数量上限。
- ///
- [Range(1, 1000)]
- public int? Limit { get; set; }
-
- public ContentNavigationFilter ToFilter(Guid tenantId)
- {
- var parentWasSpecified = ParentId is not null;
- var parentIsRoot = string.Equals(ParentId, "root", StringComparison.OrdinalIgnoreCase);
- Guid? parentId = parentIsRoot || string.IsNullOrWhiteSpace(ParentId)
- ? null
- : Guid.Parse(ParentId);
-
- return new ContentNavigationFilter(
- tenantId,
- RegionId,
- EntryId,
- NodeId,
- CollectionId,
- parentId,
- parentWasSpecified,
- parentIsRoot,
- EntryType,
- CollectionType,
- Mode,
- MarkerType,
- Keyword,
- IncludeHidden,
- IncludeInactive,
- Limit);
- }
-}
\ No newline at end of file
diff --git a/Tiku.Api/Contracts/ContentV2Dtos.cs b/Tiku.Api/Contracts/ContentV2Dtos.cs
new file mode 100644
index 0000000..ad5afd4
--- /dev/null
+++ b/Tiku.Api/Contracts/ContentV2Dtos.cs
@@ -0,0 +1,138 @@
+using System.ComponentModel.DataAnnotations;
+using System.Text.Json;
+using Tiku.Application.Content;
+using Tiku.Domain.Common;
+using Tiku.Domain.Content;
+
+namespace Tiku.Api.Contracts;
+
+public sealed class QuestionRevisionDraftDto
+{
+ [Required][StringLength(50)] public string QuestionType { get; set; } = "choice";
+ [StringLength(100)] public string? TypeLabel { get; set; }
+ [Range(1, 5)] public int? Difficulty { get; set; }
+ public string? Content { get; set; }
+ public JsonElement Options { get; set; } = JsonDefaults.Array();
+ public int? CorrectOptionIndex { get; set; }
+ public JsonElement CorrectOptionIndices { get; set; } = JsonDefaults.Array();
+ public string? AnswerText { get; set; }
+ public string? Explanation { get; set; }
+ public JsonElement SubQuestions { get; set; } = JsonDefaults.Array();
+ [StringLength(50)] public string? CodeLang { get; set; }
+ public string? CodeTemplate { get; set; }
+
+ public QuestionRevisionDraft ToCommand() => new(
+ QuestionType,
+ TypeLabel,
+ Difficulty,
+ Content,
+ Options,
+ CorrectOptionIndex,
+ CorrectOptionIndices,
+ AnswerText,
+ Explanation,
+ SubQuestions,
+ CodeLang,
+ CodeTemplate);
+}
+
+public sealed class QuestionDuplicateSearchDto
+{
+ [Required] public QuestionRevisionDraftDto Draft { get; set; } = new();
+ [Range(1, 100)] public int Limit { get; set; } = 20;
+}
+
+public sealed class ExecuteQuestionAuthoringDto
+{
+ [Required] public QuestionImportDecision Decision { get; set; }
+ [Required] public QuestionRevisionDraftDto Draft { get; set; } = new();
+ public Guid? ReuseQuestionAssetId { get; set; }
+ public Guid? FamilyId { get; set; }
+ public bool PublishAsset { get; set; }
+
+ public ExecuteQuestionAuthoringCommand ToCommand() => new(
+ Decision,
+ Draft.ToCommand(),
+ ReuseQuestionAssetId,
+ FamilyId,
+ PublishAsset);
+}
+
+public sealed class CreateQuestionRevisionV2Dto
+{
+ [Required] public QuestionRevisionDraftDto Draft { get; set; } = new();
+ public bool PublishAsset { get; set; }
+}
+
+public sealed class PlacementConditionDto
+{
+ [Required] public Guid TargetDimensionDefinitionId { get; set; }
+ [Required] public Guid TargetNodeId { get; set; }
+ [Required] public TargetRuleOperator Operator { get; set; }
+
+ public PlacementConditionCommand ToCommand() =>
+ new(TargetDimensionDefinitionId, TargetNodeId, Operator);
+}
+
+public sealed class PlacementRuleGroupDto
+{
+ [Range(0, 1000)] public int GroupOrder { get; set; }
+ [MinLength(1)] public IReadOnlyCollection Conditions { get; set; } = [];
+
+ public PlacementRuleGroupCommand ToCommand() =>
+ new(GroupOrder, Conditions.Select(item => item.ToCommand()).ToArray());
+}
+
+public sealed class CreateQuestionPlacementDto
+{
+ [Required] public Guid QuestionAssetOwnerTenantId { get; set; }
+ [Required] public Guid QuestionAssetId { get; set; }
+ [Required] public Guid CurriculumVersionId { get; set; }
+ [Required] public Guid CurriculumNodeId { get; set; }
+ [Required] public Guid AssessmentPolicyVersionId { get; set; }
+ public IReadOnlyCollection KnowledgeConceptIds { get; set; } = [];
+ public IReadOnlyCollection RuleGroups { get; set; } = [];
+ [Range(1, 5)] public int? DifficultyOverride { get; set; }
+ public bool Activate { get; set; }
+
+ public CreateQuestionPlacementCommand ToCommand() => new(
+ QuestionAssetOwnerTenantId,
+ QuestionAssetId,
+ CurriculumVersionId,
+ CurriculumNodeId,
+ AssessmentPolicyVersionId,
+ KnowledgeConceptIds,
+ RuleGroups.Select(item => item.ToCommand()).ToArray(),
+ DifficultyOverride,
+ Activate);
+}
+
+public sealed class PublishContentReleaseDto
+{
+ [Required] public Guid CurriculumVersionId { get; set; }
+ [Required][StringLength(300, MinimumLength = 1)] public string Name { get; set; } = string.Empty;
+}
+
+public sealed class QuestionImportPreviewRowDto
+{
+ [Required][StringLength(100, MinimumLength = 1)] public string RowKey { get; set; } = string.Empty;
+ [Required] public QuestionRevisionDraftDto Draft { get; set; } = new();
+}
+
+public sealed class PreviewQuestionImportV2Dto
+{
+ [Required][MinLength(1)][MaxLength(2000)]
+ public IReadOnlyCollection Rows { get; set; } = [];
+}
+
+public sealed class ExecuteQuestionImportRowDto
+{
+ [Required][StringLength(100, MinimumLength = 1)] public string RowKey { get; set; } = string.Empty;
+ [Required] public ExecuteQuestionAuthoringDto Command { get; set; } = new();
+}
+
+public sealed class ExecuteQuestionImportV2Dto
+{
+ [Required][MinLength(1)][MaxLength(2000)]
+ public IReadOnlyCollection Rows { get; set; } = [];
+}
diff --git a/Tiku.Api/Contracts/LearningAccessDtos.cs b/Tiku.Api/Contracts/LearningAccessDtos.cs
deleted file mode 100644
index f8850ab..0000000
--- a/Tiku.Api/Contracts/LearningAccessDtos.cs
+++ /dev/null
@@ -1,44 +0,0 @@
-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; }
-}
diff --git a/Tiku.Api/Contracts/LearningAccessV2Dtos.cs b/Tiku.Api/Contracts/LearningAccessV2Dtos.cs
new file mode 100644
index 0000000..dd1adce
--- /dev/null
+++ b/Tiku.Api/Contracts/LearningAccessV2Dtos.cs
@@ -0,0 +1,45 @@
+using System.ComponentModel.DataAnnotations;
+using Tiku.Application.Learning;
+using Tiku.Domain.Learning;
+
+namespace Tiku.Api.Contracts;
+
+public sealed class UpsertClassAssignmentGrantDto
+{
+ public Guid? Id { get; set; }
+ public Guid ProductAccessManifestVersionId { get; set; }
+ public AccessResourceType ResourceType { get; set; }
+ public Guid ResourceId { get; set; }
+ public DateTimeOffset? StartsAt { get; set; }
+ public DateTimeOffset? EndsAt { get; set; }
+
+ public UpsertClassAssignmentGrantCommand ToCommand(Guid classId) => new(
+ Id,
+ classId,
+ ProductAccessManifestVersionId,
+ ResourceType,
+ ResourceId,
+ StartsAt,
+ EndsAt);
+}
+
+public sealed class RevokeClassAssignmentGrantDto
+{
+ [Required]
+ [StringLength(1000, MinimumLength = 1)]
+ public string Reason { get; set; } = string.Empty;
+}
+
+public sealed class OverrideStudentLearningTargetsDto
+{
+ public Guid PrimaryProfileVersionId { get; set; }
+ public IReadOnlyCollection AlternateProfileVersionIds { get; set; } = [];
+
+ [Required]
+ [StringLength(1000, MinimumLength = 1)]
+ public string Reason { get; set; } = string.Empty;
+
+ public ChangeLearningTargetsCommand ToCommand() => new(
+ PrimaryProfileVersionId,
+ AlternateProfileVersionIds);
+}
diff --git a/Tiku.Api/Contracts/LearningDtos.cs b/Tiku.Api/Contracts/LearningDtos.cs
index 75923a9..466275e 100644
--- a/Tiku.Api/Contracts/LearningDtos.cs
+++ b/Tiku.Api/Contracts/LearningDtos.cs
@@ -5,6 +5,7 @@ using Tiku.Application.Learning;
using Tiku.Application.QuestionBanks;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
+using Tiku.Domain.Learning;
namespace Tiku.Api.Contracts;
@@ -92,73 +93,20 @@ public sealed class CreatePracticeSessionDto
public string? Mode { get; set; }
///
- /// 目标类型。
+ /// 目标资源类型。租户、Release、Segment 和业务由服务端推导。
///
- [StringLength(50)]
- public string? TargetType { get; set; }
+ [Required]
+ public AccessResourceType? ResourceType { get; set; }
///
- /// 目标 ID。
+ /// 目标资源 ID。
///
- public Guid? TargetId { get; set; }
-
- ///
- /// 练习蓝图 ID。
- ///
- public Guid? BlueprintId { get; set; }
-
- ///
- /// 题集 ID。
- ///
- public Guid? CollectionId { get; set; }
-
- ///
- /// 内容入口 ID。
- ///
- public Guid? EntryId { get; set; }
-
- ///
- /// 内容节点 ID。
- ///
- public Guid? ContentNodeId { get; set; }
-
- ///
- /// 题目数量上限。
- ///
- [Range(1, 500)]
- public int? QuestionLimit { get; set; }
-
- ///
- /// 时长,单位为分钟。
- ///
- [Range(1, 1440)]
- public int? DurationMinutes { get; set; }
-
- ///
- /// 总分。
- ///
- [Range(typeof(decimal), "0", "99999")]
- public decimal? TotalScore { get; set; }
-
- ///
- /// 扩展元数据。
- ///
- public JsonElement Metadata { get; set; } = JsonDefaults.Object();
+ [Required]
+ public Guid? ResourceId { get; set; }
public PracticeSessionCommand ToCommand()
{
- return new PracticeSessionCommand(
- Mode,
- TargetType,
- TargetId,
- BlueprintId,
- CollectionId,
- EntryId,
- ContentNodeId,
- QuestionLimit,
- DurationMinutes,
- TotalScore,
- Metadata);
+ return new PracticeSessionCommand(Mode, ResourceType!.Value, ResourceId!.Value);
}
}
@@ -173,15 +121,30 @@ public sealed class SubmitPracticeSessionDto
[Required]
public Guid PracticeSessionId { get; set; }
- [Required][Range(1, long.MaxValue)] public long ExpectedSessionVersion { get; set; }
-
[Required]
[StringLength(200, MinimumLength = 1)]
public string IdempotencyKey { get; set; } = string.Empty;
public SubmitPracticeSessionCommand ToCommand()
{
- return new SubmitPracticeSessionCommand(PracticeSessionId, ExpectedSessionVersion, IdempotencyKey);
+ return new SubmitPracticeSessionCommand(PracticeSessionId, IdempotencyKey);
+ }
+}
+
+///
+/// 学生变更当前业务的主目标和备选目标。租户、业务许可和产品范围均由服务端校验。
+///
+public sealed class ChangeLearningTargetsDto
+{
+ [Required]
+ public Guid PrimaryProfileVersionId { get; set; }
+
+ [MaxLength(15)]
+ public IReadOnlyCollection AlternateProfileVersionIds { get; set; } = [];
+
+ public ChangeLearningTargetsCommand ToCommand()
+ {
+ return new ChangeLearningTargetsCommand(PrimaryProfileVersionId, AlternateProfileVersionIds);
}
}
diff --git a/Tiku.Api/Contracts/TaxonomyDtos.cs b/Tiku.Api/Contracts/TaxonomyDtos.cs
deleted file mode 100644
index 76053ad..0000000
--- a/Tiku.Api/Contracts/TaxonomyDtos.cs
+++ /dev/null
@@ -1,66 +0,0 @@
-using System.ComponentModel.DataAnnotations;
-using System.Text.Json;
-using Tiku.Application.Catalog;
-using Tiku.Domain.Catalog;
-using Tiku.Domain.Common;
-using Tiku.Domain.Content;
-
-namespace Tiku.Api.Contracts;
-
-///
-/// 创建分类节点请求 DTO。
-///
-public sealed class CreateTaxonomyNodeDto
-{
- ///
- /// 父节点 ID。
- ///
- public Guid? ParentId { get; set; }
-
- ///
- /// 父级来源。
- ///
- public QuestionSource? ParentSource { get; set; }
-
- ///
- /// 节点Type。
- ///
- [Required]
- public TaxonomyNodeType NodeType { get; set; }
-
- ///
- /// 编码。
- ///
- [Required]
- [StringLength(100)]
- public string Code { get; set; } = string.Empty;
-
- ///
- /// 名称。
- ///
- [Required]
- [StringLength(300)]
- public string Name { get; set; } = string.Empty;
-
- ///
- /// 排序值。
- ///
- public int SortOrder { get; set; }
-
- ///
- /// 扩展元数据。
- ///
- public JsonElement Metadata { get; set; } = JsonDefaults.Object();
-
- public CreateTaxonomyNodeCommand ToCommand()
- {
- return new CreateTaxonomyNodeCommand(
- ParentId,
- ParentSource,
- NodeType,
- Code,
- Name,
- SortOrder,
- Metadata);
- }
-}
\ No newline at end of file
diff --git a/Tiku.Api/Controllers/CatalogController.cs b/Tiku.Api/Controllers/CatalogController.cs
deleted file mode 100644
index 3728c80..0000000
--- a/Tiku.Api/Controllers/CatalogController.cs
+++ /dev/null
@@ -1,562 +0,0 @@
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Mvc;
-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;
-
-namespace Tiku.Api.Controllers;
-
-[ApiController]
-[Tags("学生端-认证目录")]
-[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
-[Produces("application/json")]
-[Route("api/student/catalog")]
-public sealed class CatalogController(
- ICatalogQueryService catalogQueryService,
- IContentNavigationQueryService contentNavigationQueryService,
- IQuestionBankQueryService questionBankQueryService,
- IStudyContentQueryService studyContentQueryService,
- IAssetQueryService assetQueryService,
- ITenantContext currentTenant,
- ILearningAccessService learningAccessService,
- LearningActorResolver learningActorResolver) : ControllerBase
-{
- [HttpGet("regions")]
- [EndpointSummary("查询可用地区")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task>> GetRegions(
- [FromQuery] CatalogQueryDto query,
- CancellationToken cancellationToken)
- {
- return Ok(await catalogQueryService.GetRegionsAsync(
- query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
- cancellationToken));
- }
-
- [HttpGet("region-modules")]
- [EndpointSummary("查询地区功能模块")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task>> GetRegionModules(
- [FromQuery] CatalogQueryDto query,
- CancellationToken cancellationToken)
- {
- return Ok(await catalogQueryService.GetRegionModulesAsync(
- query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
- cancellationToken));
- }
-
- [HttpGet("module-nodes")]
- [EndpointSummary("查询模块导航节点")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task>> GetModuleNodes(
- [FromQuery] CatalogQueryDto query,
- CancellationToken cancellationToken)
- {
- return Ok(await catalogQueryService.GetModuleNodesAsync(
- query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
- cancellationToken));
- }
-
- [HttpGet("schools")]
- [EndpointSummary("查询院校目录")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task>> GetSchools(
- [FromQuery] CatalogQueryDto query,
- CancellationToken cancellationToken)
- {
- return Ok(await catalogQueryService.GetSchoolsAsync(
- query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
- cancellationToken));
- }
-
- [HttpGet("majors")]
- [EndpointSummary("查询专业目录")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task>> GetMajors(
- [FromQuery] CatalogQueryDto query,
- CancellationToken cancellationToken)
- {
- return Ok(await catalogQueryService.GetMajorsAsync(
- query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
- cancellationToken));
- }
-
- [HttpGet("subjects")]
- [EndpointSummary("查询科目目录")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task>> GetSubjects(
- [FromQuery] CatalogQueryDto query,
- CancellationToken cancellationToken)
- {
- return Ok(await catalogQueryService.GetSubjectsAsync(
- query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
- cancellationToken));
- }
-
- [HttpGet("categories")]
- [HttpGet("question-categories")]
- [EndpointSummary("查询题目分类")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task>> GetCategories(
- [FromQuery] CatalogQueryDto query,
- CancellationToken cancellationToken)
- {
- return Ok(await catalogQueryService.GetCategoriesAsync(
- query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
- cancellationToken));
- }
-
- [HttpGet("content-entries")]
- [EndpointSummary("查询内容入口")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task>> GetContentEntries(
- [FromQuery] ContentNavigationQueryDto query,
- CancellationToken cancellationToken)
- {
- return Ok(await contentNavigationQueryService.GetContentEntriesAsync(
- await ToAuthorizedFilterAsync(query, cancellationToken),
- cancellationToken));
- }
-
- [HttpGet("content-nodes")]
- [EndpointSummary("查询内容导航节点")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- [ProducesResponseType(StatusCodes.Status400BadRequest)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task>> GetContentNodes(
- [FromQuery] ContentNavigationQueryDto query,
- CancellationToken cancellationToken)
- {
- return Ok(await contentNavigationQueryService.GetContentNodesAsync(
- await ToAuthorizedFilterAsync(query, cancellationToken),
- cancellationToken));
- }
-
- [HttpGet("question-collections")]
- [RequireSaasFeature(SaasFeatureCatalog.Practice)]
- [EndpointSummary("查询可用题集")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task>> GetQuestionCollections(
- [FromQuery] ContentNavigationQueryDto query,
- CancellationToken cancellationToken)
- {
- return Ok(await contentNavigationQueryService.GetQuestionCollectionsAsync(
- await ToAuthorizedFilterAsync(query, cancellationToken),
- cancellationToken));
- }
-
- [HttpGet("question-collections/questions")]
- [RequireSaasFeature(SaasFeatureCatalog.Practice)]
- [EndpointSummary("查询题集内题目")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- [ProducesResponseType(StatusCodes.Status400BadRequest)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task>> GetCollectionQuestions(
- [FromQuery] ContentNavigationQueryDto query,
- CancellationToken cancellationToken)
- {
- return Ok(await contentNavigationQueryService.GetCollectionQuestionsAsync(
- await ToAuthorizedFilterAsync(query, cancellationToken),
- cancellationToken));
- }
-
- [HttpGet("practice-blueprints")]
- [RequireSaasFeature(SaasFeatureCatalog.Practice)]
- [EndpointSummary("查询练习蓝图")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task>> GetPracticeBlueprints(
- [FromQuery] ContentNavigationQueryDto query,
- CancellationToken cancellationToken)
- {
- return Ok(await contentNavigationQueryService.GetPracticeBlueprintsAsync(
- await ToAuthorizedFilterAsync(query, cancellationToken),
- cancellationToken));
- }
-
- [HttpGet("question-banks")]
- [RequireSaasFeature(SaasFeatureCatalog.Practice)]
- [EndpointSummary("查询题库列表")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task>> GetQuestionBanks(
- [FromQuery] QuestionBankQueryDto query,
- CancellationToken cancellationToken)
- {
- return Ok(await questionBankQueryService.GetQuestionBanksAsync(
- await ToAuthorizedFilterAsync(query, null, cancellationToken),
- cancellationToken));
- }
-
- [HttpGet("questions")]
- [RequireSaasFeature(SaasFeatureCatalog.Practice)]
- [EndpointSummary("查询已发布题目")]
- [EndpointDescription("支持按题库、科目、分类、模块节点、内容入口、内容节点、题集或题目 ID 列表筛选。")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task>> GetQuestions(
- [FromQuery] QuestionBankQueryDto query,
- CancellationToken cancellationToken)
- {
- return Ok(await questionBankQueryService.GetQuestionsAsync(
- await ToAuthorizedFilterAsync(query, null, cancellationToken),
- cancellationToken));
- }
-
- [HttpGet("questions/{questionId:guid}")]
- [RequireSaasFeature(SaasFeatureCatalog.Practice)]
- [EndpointSummary("查询题目详情")]
- [ProducesResponseType(StatusCodes.Status200OK)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task> GetQuestion(
- Guid questionId,
- [FromQuery] QuestionBankQueryDto query,
- CancellationToken cancellationToken)
- {
- return Ok(await questionBankQueryService.GetQuestionAsync(
- await ToAuthorizedFilterAsync(query, questionId, cancellationToken),
- cancellationToken));
- }
-
- [HttpGet("vocabulary-units")]
- [RequireSaasFeature(SaasFeatureCatalog.Vocabulary)]
- [EndpointSummary("查询词汇单元")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task>> GetVocabularyUnits(
- [FromQuery] StudyContentQueryDto query,
- CancellationToken cancellationToken)
- {
- return Ok(await studyContentQueryService.GetVocabularyUnitsAsync(
- query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
- cancellationToken));
- }
-
- [HttpGet("vocabulary-words")]
- [RequireSaasFeature(SaasFeatureCatalog.Vocabulary)]
- [EndpointSummary("查询词汇单词")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task>> GetVocabularyWords(
- [FromQuery] StudyContentQueryDto query,
- CancellationToken cancellationToken)
- {
- return Ok(await studyContentQueryService.GetVocabularyWordsAsync(
- query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
- cancellationToken));
- }
-
- [HttpGet("handbook-subjects")]
- [RequireSaasFeature(SaasFeatureCatalog.Handbook)]
- [EndpointSummary("查询知识手册科目")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task>> GetHandbookSubjects(
- [FromQuery] StudyContentQueryDto query,
- CancellationToken cancellationToken)
- {
- return Ok(await studyContentQueryService.GetHandbookSubjectsAsync(
- query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
- cancellationToken));
- }
-
- [HttpGet("handbook-chapters")]
- [RequireSaasFeature(SaasFeatureCatalog.Handbook)]
- [EndpointSummary("查询知识手册章节")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task>> GetHandbookChapters(
- [FromQuery] StudyContentQueryDto query,
- CancellationToken cancellationToken)
- {
- return Ok(await studyContentQueryService.GetHandbookChaptersAsync(
- query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
- cancellationToken));
- }
-
- [HttpGet("handbook-entries")]
- [RequireSaasFeature(SaasFeatureCatalog.Handbook)]
- [EndpointSummary("查询知识手册条目")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task>> GetHandbookEntries(
- [FromQuery] StudyContentQueryDto query,
- CancellationToken cancellationToken)
- {
- return Ok(await studyContentQueryService.GetHandbookEntriesAsync(
- query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
- cancellationToken));
- }
-
- [HttpGet("content-assets")]
- [EndpointSummary("查询内容资源")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task>> GetContentAssets(
- [FromQuery] AssetQueryDto query,
- CancellationToken cancellationToken)
- {
- return Ok(await assetQueryService.GetContentAssetsAsync(
- query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
- cancellationToken));
- }
-
- [HttpGet("images")]
- [EndpointSummary("查询图片资源")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task>> GetImages(
- [FromQuery] AssetQueryDto query,
- CancellationToken cancellationToken)
- {
- return Ok(await assetQueryService.GetImagesAsync(
- query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
- cancellationToken));
- }
-
- [HttpGet("app-assets")]
- [EndpointSummary("查询应用资源")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task>> GetAppAssets(
- [FromQuery] AssetQueryDto query,
- CancellationToken cancellationToken)
- {
- return Ok(await assetQueryService.GetAppAssetsAsync(
- query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
- cancellationToken));
- }
-
- [HttpGet("video-explanations")]
- [RequireSaasFeature(SaasFeatureCatalog.Video)]
- [EndpointSummary("查询视频讲解")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task>> GetVideoExplanations(
- [FromQuery] AssetQueryDto query,
- CancellationToken cancellationToken)
- {
- return Ok(await assetQueryService.GetVideoExplanationsAsync(
- query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
- cancellationToken));
- }
-
- [HttpGet("question-videos")]
- [RequireSaasFeature(SaasFeatureCatalog.Video)]
- [EndpointSummary("查询题目关联视频")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task>> GetQuestionVideos(
- [FromQuery] AssetQueryDto query,
- CancellationToken cancellationToken)
- {
- return Ok(await assetQueryService.GetQuestionVideosAsync(
- query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
- cancellationToken));
- }
-
- [HttpGet("banners")]
- [RequireSaasFeature(SaasFeatureCatalog.SiteContent)]
- [EndpointSummary("查询首页横幅")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task>> GetBanners(
- [FromQuery] CatalogQueryDto query,
- CancellationToken cancellationToken)
- {
- return Ok(await catalogQueryService.GetBannersAsync(
- query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
- cancellationToken));
- }
-
- [HttpGet("faqs")]
- [RequireSaasFeature(SaasFeatureCatalog.SiteContent)]
- [EndpointSummary("查询常见问题")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task>> GetFaqs(
- [FromQuery] CatalogQueryDto query,
- CancellationToken cancellationToken)
- {
- return Ok(await catalogQueryService.GetFaqsAsync(
- query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
- cancellationToken));
- }
-
- [HttpGet("announcements")]
- [RequireSaasFeature(SaasFeatureCatalog.SiteContent)]
- [EndpointSummary("查询公告")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task>> GetAnnouncements(
- [FromQuery] CatalogQueryDto query,
- CancellationToken cancellationToken)
- {
- return Ok(await catalogQueryService.GetAnnouncementsAsync(
- query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
- cancellationToken));
- }
-
- [HttpGet("exam-dates")]
- [RequireSaasFeature(SaasFeatureCatalog.SiteContent)]
- [EndpointSummary("查询考试日期")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task>> GetExamDates(
- [FromQuery] CatalogQueryDto query,
- CancellationToken cancellationToken)
- {
- return Ok(await catalogQueryService.GetExamDatesAsync(
- query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
- cancellationToken));
- }
-
- [HttpGet("products")]
- [RequireSaasFeature(SaasFeatureCatalog.StudentStore)]
- [EndpointSummary("查询可购买产品")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task>> GetProducts(
- [FromQuery] CatalogQueryDto query,
- CancellationToken cancellationToken)
- {
- return Ok(await catalogQueryService.GetProductsAsync(
- query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
- cancellationToken));
- }
-
- [HttpGet("svip-plans")]
- [RequireSaasFeature(SaasFeatureCatalog.StudentStore)]
- [EndpointSummary("查询 SVIP 套餐")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- public async Task>> GetSvipPlans(
- [FromQuery] CatalogQueryDto query,
- CancellationToken cancellationToken)
- {
- return Ok(await catalogQueryService.GetSvipPlansAsync(
- query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
- cancellationToken));
- }
-
- private async Task ResolveTenantIdAsync(
- CatalogQueryDto query,
- CancellationToken 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 async Task 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()
- };
- }
-
- private async Task 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 ResolveTenantIdAsync(
- ContentNavigationQueryDto query,
- CancellationToken cancellationToken)
- {
- return ResolveTenantIdAsync(
- new CatalogQueryDto
- {
- TenantCode = query.TenantCode
- },
- cancellationToken);
- }
-
- private Task ResolveTenantIdAsync(
- QuestionBankQueryDto query,
- CancellationToken cancellationToken)
- {
- return ResolveTenantIdAsync(
- new CatalogQueryDto
- {
- TenantCode = query.TenantCode
- },
- cancellationToken);
- }
-
- private async Task ResolveTenantIdAsync(
- StudyContentQueryDto query,
- CancellationToken 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 async Task ResolveTenantIdAsync(
- AssetQueryDto query,
- CancellationToken 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();
- }
-}
-
-public sealed class TenantNotFoundException : Exception
-{
- public TenantNotFoundException()
- : base("Tenant was not found.")
- {
- }
-}
diff --git a/Tiku.Api/Controllers/ContentV2AuthoringController.cs b/Tiku.Api/Controllers/ContentV2AuthoringController.cs
new file mode 100644
index 0000000..2f1edcc
--- /dev/null
+++ b/Tiku.Api/Controllers/ContentV2AuthoringController.cs
@@ -0,0 +1,107 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Tiku.Api.Contracts;
+using Tiku.Api.Security;
+using Tiku.Application.Content;
+using Tiku.Application.Security;
+
+namespace Tiku.Api.Controllers;
+
+[ApiController]
+[Tags("租户端-内容 V2 录题")]
+[Authorize(Policy = BackendPermissions.TenantContentManage)]
+[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
+[RequireSaasFeature(SaasFeatureCatalog.PrivateQuestionBank)]
+[Produces("application/json")]
+[Route("api/tenant/content-v2")]
+public sealed class ContentV2AuthoringController(
+ IContentV2AuthoringService service,
+ IContentReleaseCompiler releaseCompiler,
+ DirectContentActorResolver actorResolver) : ControllerBase
+{
+ [HttpPost("questions/duplicate-search")]
+ [EndpointSummary("查询当前租户和平台公共范围内的精确/相似题")]
+ public Task SearchDuplicates(
+ QuestionDuplicateSearchDto request,
+ CancellationToken cancellationToken)
+ {
+ return service.SearchDuplicatesAsync(Actor(), request.Draft.ToCommand(), request.Limit, cancellationToken);
+ }
+
+ [HttpPost("questions/execute")]
+ [EndpointSummary("按复用、新建、变体、跳过或人工复核决策执行录题")]
+ public Task Execute(
+ ExecuteQuestionAuthoringDto request,
+ CancellationToken cancellationToken)
+ {
+ return service.ExecuteAsync(Actor(), request.ToCommand(), cancellationToken);
+ }
+
+ [HttpPost("questions/{questionAssetId:guid}/revisions")]
+ [EndpointSummary("为租户题目资产创建不可变 Revision")]
+ public Task CreateRevision(
+ Guid questionAssetId,
+ CreateQuestionRevisionV2Dto request,
+ CancellationToken cancellationToken)
+ {
+ return service.CreateRevisionAsync(
+ Actor(), questionAssetId, request.Draft.ToCommand(), request.PublishAsset, cancellationToken);
+ }
+
+ [HttpPost("questions/placements")]
+ [EndpointSummary("把租户私题或平台公共题放置到大纲并配置适用规则")]
+ public Task CreatePlacement(
+ CreateQuestionPlacementDto request,
+ CancellationToken cancellationToken)
+ {
+ return service.CreatePlacementAsync(Actor(), request.ToCommand(), cancellationToken);
+ }
+
+ [HttpPost("imports/questions/preview")]
+ [EndpointSummary("预览 Excel/Word 解析后的题目行并给出精确复用或人工复核建议")]
+ public Task PreviewImport(
+ PreviewQuestionImportV2Dto request,
+ CancellationToken cancellationToken)
+ {
+ return service.PreviewImportAsync(
+ Actor(),
+ request.Rows.Select(row => (row.RowKey, row.Draft.ToCommand())).ToArray(),
+ cancellationToken);
+ }
+
+ [HttpPost("imports/questions/execute")]
+ [EndpointSummary("按逐行复用、新建、变体、跳过或人工复核决策执行导入")]
+ public Task ExecuteImport(
+ ExecuteQuestionImportV2Dto request,
+ CancellationToken cancellationToken)
+ {
+ return service.ExecuteImportAsync(
+ Actor(),
+ request.Rows.Select(row => new ExecuteQuestionImportRow(
+ row.RowKey,
+ row.Command.ToCommand())).ToArray(),
+ cancellationToken);
+ }
+
+ [HttpPost("releases/publish")]
+ [EndpointSummary("校验并发布不可变内容 Release")]
+ public Task PublishRelease(
+ PublishContentReleaseDto request,
+ CancellationToken cancellationToken)
+ {
+ var actor = Actor();
+ return releaseCompiler.PublishAsync(
+ new PublishContentReleaseCommand(
+ actor.TenantId,
+ actor.UserId,
+ request.CurriculumVersionId,
+ request.Name),
+ cancellationToken);
+ }
+
+ private ContentAuthorActor Actor()
+ {
+ var actor = actorResolver.Resolve();
+ return new ContentAuthorActor(actor.TenantId, actor.UserId);
+ }
+}
diff --git a/Tiku.Api/Controllers/LearningAccessController.cs b/Tiku.Api/Controllers/LearningAccessController.cs
deleted file mode 100644
index 946983c..0000000
--- a/Tiku.Api/Controllers/LearningAccessController.cs
+++ /dev/null
@@ -1,44 +0,0 @@
-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 Get(CancellationToken cancellationToken)
- {
- return accessService.GetSnapshotAsync(actorResolver.Resolve(), cancellationToken);
- }
-
- [HttpGet("target-region")]
- [EndpointSummary("查询当前个人目标地区")]
- public Task GetTargetRegion(CancellationToken cancellationToken)
- {
- return administrationService.GetTargetRegionAsync(actorResolver.Resolve(), cancellationToken);
- }
-
- [HttpPut("target-region")]
- [EndpointSummary("变更个人目标地区")]
- public Task ChangeTargetRegion(
- ChangeStudentTargetRegionDto request,
- CancellationToken cancellationToken)
- {
- return administrationService.ChangeTargetRegionAsync(
- actorResolver.Resolve(), request.ToCommand(), cancellationToken);
- }
-}
diff --git a/Tiku.Api/Controllers/PlatformContentV2AuthoringController.cs b/Tiku.Api/Controllers/PlatformContentV2AuthoringController.cs
new file mode 100644
index 0000000..51cb094
--- /dev/null
+++ b/Tiku.Api/Controllers/PlatformContentV2AuthoringController.cs
@@ -0,0 +1,112 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Tiku.Api.Contracts;
+using Tiku.Api.Security;
+using Tiku.Application.Content;
+using Tiku.Application.Security;
+
+namespace Tiku.Api.Controllers;
+
+[ApiController]
+[Tags("平台端-公共内容 V2")]
+[Authorize(Policy = TikuPolicies.PlatformBackofficeBootstrap)]
+[Authorize(Policy = BackendPermissions.PlatformQuestionBankManage)]
+[Produces("application/json")]
+[Route("api/platform/content-v2")]
+public sealed class PlatformContentV2AuthoringController(
+ IContentV2AuthoringService service,
+ IContentReleaseCompiler releaseCompiler,
+ IPlatformContentActorResolver actorResolver,
+ ICurrentUser currentUser) : ControllerBase
+{
+ [HttpPost("questions/duplicate-search")]
+ [EndpointSummary("查询平台公共题范围内的精确和相似题")]
+ public async Task SearchDuplicates(
+ QuestionDuplicateSearchDto request,
+ CancellationToken cancellationToken)
+ {
+ return await service.SearchDuplicatesAsync(
+ await ActorAsync(cancellationToken),
+ request.Draft.ToCommand(),
+ request.Limit,
+ cancellationToken);
+ }
+
+ [HttpPost("questions/execute")]
+ [EndpointSummary("按审核决策录入平台公共题")]
+ public async Task Execute(
+ ExecuteQuestionAuthoringDto request,
+ CancellationToken cancellationToken)
+ {
+ return await service.ExecuteAsync(
+ await ActorAsync(cancellationToken), request.ToCommand(), cancellationToken);
+ }
+
+ [HttpPost("questions/{questionAssetId:guid}/revisions")]
+ [EndpointSummary("为平台公共题创建不可变 Revision")]
+ public async Task CreateRevision(
+ Guid questionAssetId,
+ CreateQuestionRevisionV2Dto request,
+ CancellationToken cancellationToken)
+ {
+ return await service.CreateRevisionAsync(
+ await ActorAsync(cancellationToken),
+ questionAssetId,
+ request.Draft.ToCommand(),
+ request.PublishAsset,
+ cancellationToken);
+ }
+
+ [HttpPost("questions/placements")]
+ [EndpointSummary("把平台公共题放置到大纲并配置目标规则")]
+ public async Task CreatePlacement(
+ CreateQuestionPlacementDto request,
+ CancellationToken cancellationToken)
+ {
+ return await service.CreatePlacementAsync(
+ await ActorAsync(cancellationToken), request.ToCommand(), cancellationToken);
+ }
+
+ [HttpPost("imports/questions/preview")]
+ [EndpointSummary("预览平台公共题批量导入")]
+ public async Task PreviewImport(
+ PreviewQuestionImportV2Dto request,
+ CancellationToken cancellationToken)
+ {
+ return await service.PreviewImportAsync(
+ await ActorAsync(cancellationToken),
+ request.Rows.Select(row => (row.RowKey, row.Draft.ToCommand())).ToArray(),
+ cancellationToken);
+ }
+
+ [HttpPost("imports/questions/execute")]
+ [EndpointSummary("执行平台公共题批量导入决策")]
+ public async Task ExecuteImport(
+ ExecuteQuestionImportV2Dto request,
+ CancellationToken cancellationToken)
+ {
+ return await service.ExecuteImportAsync(
+ await ActorAsync(cancellationToken),
+ request.Rows.Select(row => new ExecuteQuestionImportRow(row.RowKey, row.Command.ToCommand())).ToArray(),
+ cancellationToken);
+ }
+
+ [HttpPost("releases/publish")]
+ [EndpointSummary("校验并发布平台公共不可变 Release")]
+ public async Task PublishRelease(
+ PublishContentReleaseDto request,
+ CancellationToken cancellationToken)
+ {
+ var actor = await ActorAsync(cancellationToken);
+ return await releaseCompiler.PublishAsync(
+ new PublishContentReleaseCommand(actor.TenantId, actor.UserId, request.CurriculumVersionId, request.Name),
+ cancellationToken);
+ }
+
+ private Task ActorAsync(CancellationToken cancellationToken)
+ {
+ return currentUser.UserId is { } userId
+ ? actorResolver.ResolveAsync(userId, cancellationToken)
+ : throw new ContentV2Exception("platform_access_denied", "The platform actor was not resolved.");
+ }
+}
diff --git a/Tiku.Api/Controllers/PlatformQuestionBanksController.cs b/Tiku.Api/Controllers/PlatformQuestionBanksController.cs
deleted file mode 100644
index 8acf2d1..0000000
--- a/Tiku.Api/Controllers/PlatformQuestionBanksController.cs
+++ /dev/null
@@ -1,170 +0,0 @@
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Mvc;
-using Tiku.Api.Contracts;
-using Tiku.Application.Assets;
-using Tiku.Application.PlatformAdmin;
-using Tiku.Application.Security;
-
-namespace Tiku.Api.Controllers;
-
-[ApiController]
-[Tags("平台端-公共题库")]
-[Authorize(Policy = BackendPermissions.PlatformQuestionBankManage)]
-[Produces("application/json")]
-[Route("api/platform/question-banks")]
-public sealed class PlatformQuestionBanksController(
- IPlatformQuestionBankCatalogService catalogService,
- IPlatformQuestionBankNodeService nodeService,
- IPlatformQuestionAdministrationService questionService,
- IPlatformQuestionImportService importService,
- IPlatformQuestionAssetService assetService,
- ICurrentUser currentUser) : ControllerBase
-{
- [HttpGet]
- [EndpointSummary("查询平台公共题库")]
- public async Task>> GetBanks(
- [FromQuery] string? keyword,
- [FromQuery] string? status,
- CancellationToken cancellationToken)
- {
- return Ok(await catalogService.GetBanksAsync(ResolveActor(),
- new PlatformQuestionBankFilter(Keyword: keyword, Status: status), cancellationToken));
- }
-
- [HttpPut]
- [EndpointSummary("新增或更新平台公共题库")]
- public async Task> UpsertBank(
- UpsertPlatformQuestionBankCommand request,
- CancellationToken cancellationToken)
- {
- return Ok(await catalogService.UpsertBankAsync(ResolveActor(), request, cancellationToken));
- }
-
- [HttpPost("{bankId:guid}/archive")]
- [EndpointSummary("归档平台公共题库")]
- public async Task> ArchiveBank(Guid bankId,
- CancellationToken cancellationToken)
- {
- return Ok(await catalogService.ArchiveBankAsync(ResolveActor(), bankId, cancellationToken));
- }
-
- [HttpGet("{bankId:guid}/nodes")]
- [EndpointSummary("查询公共题库内容结构")]
- public async Task>> GetNodes(Guid bankId,
- CancellationToken cancellationToken)
- {
- return Ok(await nodeService.GetNodesAsync(ResolveActor(), bankId, cancellationToken));
- }
-
- [HttpPut("nodes")]
- [EndpointSummary("新增或更新公共题库内容节点")]
- public async Task> UpsertNode(
- UpsertPlatformQuestionBankNodeCommand request,
- CancellationToken cancellationToken)
- {
- return Ok(await nodeService.UpsertNodeAsync(ResolveActor(), request, cancellationToken));
- }
-
- [HttpPost("nodes/batch")]
- [EndpointSummary("批量创建公共题库章节或试卷")]
- public async Task>> BatchCreateNodes(
- BatchCreatePlatformQuestionBankNodesCommand request,
- CancellationToken cancellationToken)
- {
- return Ok(await nodeService.BatchCreateNodesAsync(ResolveActor(), request, cancellationToken));
- }
-
- [HttpPost("nodes/{nodeId:guid}/archive")]
- [EndpointSummary("归档公共题库内容节点")]
- public async Task> ArchiveNode(Guid nodeId,
- CancellationToken cancellationToken)
- {
- return Ok(await nodeService.ArchiveNodeAsync(ResolveActor(), nodeId, cancellationToken));
- }
-
- [HttpGet("questions")]
- [EndpointSummary("分页查询公共题库题目")]
- public async Task> GetQuestions(
- [FromQuery] Guid questionBankId,
- [FromQuery] Guid? contentNodeId,
- [FromQuery] string? keyword,
- [FromQuery] string? type,
- [FromQuery] int? difficulty,
- [FromQuery] string? status,
- [FromQuery] int page = 1,
- [FromQuery] int pageSize = 20,
- CancellationToken cancellationToken = default)
- {
- return Ok(await questionService.GetQuestionsAsync(ResolveActor(), new PlatformQuestionBankFilter(
- questionBankId, contentNodeId, keyword, type, difficulty, status, page, pageSize), cancellationToken));
- }
-
- [HttpPut("questions")]
- [EndpointSummary("新增或更新公共题库题目并保留版本")]
- public async Task> UpsertQuestion(
- UpsertPlatformQuestionCommand request,
- CancellationToken cancellationToken)
- {
- return Ok(await questionService.UpsertQuestionAsync(ResolveActor(), request, cancellationToken));
- }
-
- [HttpPost("questions/archive")]
- [EndpointSummary("批量归档公共题库题目")]
- public async Task> ArchiveQuestions(
- ArchivePlatformQuestionsCommand request,
- CancellationToken cancellationToken)
- {
- return Ok(await questionService.ArchiveQuestionsAsync(ResolveActor(), request, cancellationToken));
- }
-
- [HttpPost("imports/preview")]
- [EndpointSummary("预检公共题库 JSON 导入")]
- public async Task> PreviewImport(
- PlatformQuestionImportCommand request,
- CancellationToken cancellationToken)
- {
- return Ok(await importService.PreviewImportAsync(ResolveActor(), request, cancellationToken));
- }
-
- [HttpPost("imports")]
- [EndpointSummary("执行公共题库 JSON 导入")]
- public async Task> ExecuteImport(
- PlatformQuestionImportCommand request,
- CancellationToken cancellationToken)
- {
- return Ok(await importService.ExecuteImportAsync(ResolveActor(), request, cancellationToken));
- }
-
- [HttpGet("imports/{jobId:guid}")]
- [EndpointSummary("查询公共题库导入结果")]
- public async Task> GetImport(Guid jobId, CancellationToken cancellationToken)
- {
- return Ok(await importService.GetImportAsync(ResolveActor(), jobId, cancellationToken));
- }
-
- [HttpPost("assets/upload-sign")]
- [EndpointSummary("签发公共题库图片上传地址")]
- public async Task> SignAssetUpload(
- AssetUploadSignDto request,
- CancellationToken cancellationToken)
- {
- return Ok(await assetService.SignQuestionAssetUploadAsync(ResolveActor(), request.ToCommand(), cancellationToken));
- }
-
- [HttpPost("assets/upload-confirm")]
- [EndpointSummary("确认公共题库图片上传结果")]
- public async Task> ConfirmAssetUpload(
- AssetUploadConfirmDto request,
- CancellationToken cancellationToken)
- {
- return Ok(await assetService.ConfirmQuestionAssetUploadAsync(ResolveActor(), request.ToCommand(),
- cancellationToken));
- }
-
- private PlatformAdminActor ResolveActor()
- {
- return currentUser.UserId is { } userId
- ? new PlatformAdminActor(userId)
- : throw new PlatformAdminException("无法识别当前平台员工。", "platform_access_denied");
- }
-}
diff --git a/Tiku.Api/Controllers/QuestionManagementController.cs b/Tiku.Api/Controllers/QuestionManagementController.cs
deleted file mode 100644
index 072ef24..0000000
--- a/Tiku.Api/Controllers/QuestionManagementController.cs
+++ /dev/null
@@ -1,51 +0,0 @@
-using System.Text.Json;
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Mvc;
-using Tiku.Api.Contracts;
-using Tiku.Api.Security;
-using Tiku.Application.Assets;
-using Tiku.Application.Catalog;
-using Tiku.Application.Content;
-using Tiku.Application.Jobs;
-using Tiku.Application.Security;
-using Tiku.Domain.Catalog;
-using Tiku.Domain.Content;
-
-namespace Tiku.Api.Controllers;
-
-[ApiController]
-[Tags("租户端-内容直接管理")]
-[Produces("application/json")]
-[Route("api/tenant/content")]
-public sealed class QuestionManagementController(
- IQuestionManagementService service,
- DirectContentActorResolver actorResolver) : ControllerBase
-{
- [HttpPost("questions")]
- [Authorize(Policy = BackendPermissions.TenantContentManage)]
- [Authorize(Policy = TikuPolicies.TenantAllDataScope)]
- [RequireSaasFeature(SaasFeatureCatalog.PrivateQuestionBank)]
- [EndpointSummary("创建题目及首个版本")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- public async Task>> CreateQuestion(
- DirectQuestionWriteDto request,
- CancellationToken cancellationToken)
- {
- return Ok(await service.CreateQuestionAsync(actorResolver.Resolve(), request.ToCommand(true),
- cancellationToken));
- }
-
- [HttpPatch("questions")]
- [Authorize(Policy = BackendPermissions.TenantContentManage)]
- [Authorize(Policy = TikuPolicies.TenantAllDataScope)]
- [RequireSaasFeature(SaasFeatureCatalog.PrivateQuestionBank)]
- [EndpointSummary("更新题目并可选择创建新版本")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- public async Task>> UpdateQuestion(
- DirectQuestionWriteDto request,
- CancellationToken cancellationToken)
- {
- return Ok(await service.UpdateQuestionAsync(actorResolver.Resolve(), request.ToCommand(false),
- cancellationToken));
- }
-}
diff --git a/Tiku.Api/Controllers/StudentLearningContextController.cs b/Tiku.Api/Controllers/StudentLearningContextController.cs
new file mode 100644
index 0000000..6a70fe7
--- /dev/null
+++ b/Tiku.Api/Controllers/StudentLearningContextController.cs
@@ -0,0 +1,64 @@
+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")]
+public sealed class StudentLearningContextController(
+ IStudentLearningTargetService service,
+ IStudentLearningCatalogService catalog,
+ LearningActorResolver actorResolver) : ControllerBase
+{
+ [HttpGet("learning-context")]
+ [EndpointSummary("查询学生可用业务与当前学习目标")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public Task GetContext(CancellationToken cancellationToken)
+ {
+ return service.GetContextAsync(actorResolver.Resolve(), cancellationToken);
+ }
+
+ [HttpGet("learning-targets")]
+ [EndpointSummary("查询当前业务可选目标")]
+ [ProducesResponseType>(StatusCodes.Status200OK)]
+ public Task> GetTargets(
+ [FromQuery] Guid businessLineId,
+ CancellationToken cancellationToken)
+ {
+ return service.GetTargetsAsync(actorResolver.Resolve(), businessLineId, cancellationToken);
+ }
+
+ [HttpGet("catalog/resources")]
+ [EndpointSummary("查询由当前套餐、目标和班级授权编译出的认证学习目录")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public Task GetCatalog(
+ [FromQuery] Guid businessLineId,
+ CancellationToken cancellationToken)
+ {
+ return catalog.GetAsync(actorResolver.Resolve(), businessLineId, cancellationToken);
+ }
+
+ [HttpPut("learning-targets/{businessLineId:guid}")]
+ [EndpointSummary("变更当前业务的主目标和备选目标")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public Task ChangeTargets(
+ Guid businessLineId,
+ ChangeLearningTargetsDto request,
+ CancellationToken cancellationToken)
+ {
+ return service.ChangeTargetsAsync(
+ actorResolver.Resolve(),
+ businessLineId,
+ request.ToCommand(),
+ cancellationToken);
+ }
+}
diff --git a/Tiku.Api/Controllers/TaxonomyController.cs b/Tiku.Api/Controllers/TaxonomyController.cs
deleted file mode 100644
index 0befad4..0000000
--- a/Tiku.Api/Controllers/TaxonomyController.cs
+++ /dev/null
@@ -1,42 +0,0 @@
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Mvc;
-using Tiku.Api.Contracts;
-using Tiku.Api.Security;
-using Tiku.Application.Catalog;
-using Tiku.Application.Security;
-
-namespace Tiku.Api.Controllers;
-
-[ApiController]
-[Tags("租户端-分类管理")]
-[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
-[Produces("application/json")]
-[Route("api/tenant/taxonomy/nodes")]
-public sealed class TaxonomyController(
- ITenantContext tenantContext,
- ITaxonomyService taxonomyService) : ControllerBase
-{
- [HttpGet]
- [RequireSaasFeature(SaasFeatureCatalog.Practice)]
- [EndpointSummary("查询租户分类节点")]
- public Task> List(CancellationToken cancellationToken)
- {
- return taxonomyService.ListAsync(RequireTenantId(), cancellationToken);
- }
-
- [HttpPost]
- [RequireSaasFeature(SaasFeatureCatalog.PrivateQuestionBank)]
- [Authorize(Policy = BackendPermissions.TenantContentManage)]
- [EndpointSummary("创建租户分类节点")]
- public Task Create(
- CreateTaxonomyNodeDto request,
- CancellationToken cancellationToken)
- {
- return taxonomyService.CreateAsync(RequireTenantId(), request.ToCommand(), cancellationToken);
- }
-
- private Guid RequireTenantId()
- {
- return tenantContext.TenantId ?? throw new InvalidOperationException("Tenant was not resolved.");
- }
-}
\ No newline at end of file
diff --git a/Tiku.Api/Controllers/TenantClassLearningAssignmentController.cs b/Tiku.Api/Controllers/TenantClassLearningAssignmentController.cs
index 0b203aa..2c2bcc3 100644
--- a/Tiku.Api/Controllers/TenantClassLearningAssignmentController.cs
+++ b/Tiku.Api/Controllers/TenantClassLearningAssignmentController.cs
@@ -7,68 +7,82 @@ using Tiku.Application.Security;
namespace Tiku.Api.Controllers;
[ApiController]
-[Tags("租户端-班级学习授权")]
+[Tags("租户端-班级学习授权 V2")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[Produces("application/json")]
-[Route("api/tenant/classes/learning-assignments")]
+[Route("api/tenant/classes/{classId:guid}/learning-assignments")]
public sealed class TenantClassLearningAssignmentController(
- ILearningAccessAdministrationService service,
+ IV2LearningAccessAdministrationService service,
TenantAdminActorResolver actorResolver) : ControllerBase
{
[HttpGet]
- [EndpointSummary("查询班级题集和练习蓝图授权")]
- public Task> Get(
- [FromQuery] Guid classId,
+ [EndpointSummary("查询班级的 V2 题集和蓝图授权")]
+ public Task> Get(
+ Guid classId,
CancellationToken cancellationToken)
{
var actor = actorResolver.Resolve();
- return service.GetClassAssignmentsAsync(actor.TenantId, classId, cancellationToken);
+ return service.GetClassGrantsAsync(actor.TenantId, classId, cancellationToken);
}
[HttpPut]
- [EndpointSummary("新增或更新班级题集和练习蓝图授权")]
- public Task Upsert(
- UpsertClassContentAssignmentDto request,
+ [EndpointSummary("新增或更新班级的 V2 题集和蓝图授权")]
+ public Task Upsert(
+ Guid classId,
+ UpsertClassAssignmentGrantDto request,
CancellationToken cancellationToken)
{
var actor = actorResolver.Resolve();
- return service.UpsertClassAssignmentAsync(
- actor.TenantId, actor.UserId, request.ToCommand(), cancellationToken);
+ return service.UpsertClassGrantAsync(
+ actor.TenantId,
+ actor.UserId,
+ request.ToCommand(classId),
+ cancellationToken);
}
- [HttpPost("{assignmentId:guid}/revoke")]
- [EndpointSummary("撤回班级学习授权并强制撤销活动会话")]
- public Task Revoke(
- Guid assignmentId,
- RevokeClassContentAssignmentDto request,
+ [HttpPost("{grantId:guid}/revoke")]
+ [EndpointSummary("撤回班级 V2 学习授权;已开始会话继续使用锁定快照")]
+ public Task Revoke(
+ Guid classId,
+ Guid grantId,
+ RevokeClassAssignmentGrantDto request,
CancellationToken cancellationToken)
{
var actor = actorResolver.Resolve();
- return service.RevokeClassAssignmentAsync(
- actor.TenantId, actor.UserId, assignmentId, request.Reason, cancellationToken);
+ return service.RevokeClassGrantAsync(
+ actor.TenantId,
+ actor.UserId,
+ classId,
+ grantId,
+ request.Reason,
+ cancellationToken);
}
}
[ApiController]
-[Tags("租户端-学生学习授权")]
+[Tags("租户端-学生学习目标 V2")]
[Authorize(Policy = BackendPermissions.TenantStudentManage)]
[Produces("application/json")]
-[Route("api/tenant/students/{userId:guid}/learning/target-region")]
+[Route("api/tenant/students/{userId:guid}/learning-targets/{businessLineId:guid}")]
public sealed class TenantStudentLearningAccessController(
- ILearningAccessAdministrationService service,
+ IStudentLearningTargetService service,
TenantAdminActorResolver actorResolver) : ControllerBase
{
[HttpPut]
- [EndpointSummary("管理员强制调整学生目标地区")]
- public Task Override(
+ [EndpointSummary("管理员带原因强制调整学生复合考试目标")]
+ public Task Override(
Guid userId,
- OverrideStudentTargetRegionDto request,
+ Guid businessLineId,
+ OverrideStudentLearningTargetsDto request,
CancellationToken cancellationToken)
{
var actor = actorResolver.Resolve();
- return service.OverrideTargetRegionAsync(
- actor.TenantId,
- request.ToCommand(userId, actor.UserId),
+ return service.OverrideTargetsAsync(
+ new LearningActor(actor.TenantId, userId),
+ businessLineId,
+ request.ToCommand(),
+ actor.UserId,
+ request.Reason,
cancellationToken);
}
}
diff --git a/Tiku.Api/Controllers/TenantContentCapabilitySet.cs b/Tiku.Api/Controllers/TenantContentCapabilitySet.cs
index 36989c7..15c4a00 100644
--- a/Tiku.Api/Controllers/TenantContentCapabilitySet.cs
+++ b/Tiku.Api/Controllers/TenantContentCapabilitySet.cs
@@ -4,20 +4,12 @@ using Tiku.Application.Content;
namespace Tiku.Api.Controllers;
public sealed class TenantContentCapabilitySet(
- IContentEntryManagementService contentEntries,
- IContentNodeManagementService contentNodes,
- IQuestionCollectionManagementService questionCollections,
- IPracticeBlueprintManagementService practiceBlueprints,
IAssetCatalogManagementService assetCatalog,
IAssetUploadManagementService assetUploads,
IAssetLifecycleManagementService assetLifecycle,
IAssetAuditQueryService assetAudit,
IAssetImportJobQueryService assetImports)
{
- internal IContentEntryManagementService ContentEntries { get; } = contentEntries;
- internal IContentNodeManagementService ContentNodes { get; } = contentNodes;
- internal IQuestionCollectionManagementService QuestionCollections { get; } = questionCollections;
- internal IPracticeBlueprintManagementService PracticeBlueprints { get; } = practiceBlueprints;
internal IAssetCatalogManagementService AssetCatalog { get; } = assetCatalog;
internal IAssetUploadManagementService AssetUploads { get; } = assetUploads;
internal IAssetLifecycleManagementService AssetLifecycle { get; } = assetLifecycle;
diff --git a/Tiku.Api/Controllers/TenantContentController.cs b/Tiku.Api/Controllers/TenantContentController.cs
index 3deadfb..3098c51 100644
--- a/Tiku.Api/Controllers/TenantContentController.cs
+++ b/Tiku.Api/Controllers/TenantContentController.cs
@@ -18,141 +18,6 @@ public sealed class TenantContentController(
ICurrentUser currentUser,
ITenantContext currentTenant) : ControllerBase
{
- [HttpGet("entries")]
- [EndpointSummary("查询租户内容入口")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- public async Task>> GetEntries(
- [FromQuery] ContentManagementQueryDto query,
- CancellationToken cancellationToken)
- {
- return Ok(await capabilities.ContentEntries.GetEntriesAsync(
- ResolveContentActor(),
- query.ToFilter(),
- cancellationToken));
- }
-
- [HttpPost("entries")]
- [EndpointSummary("创建或更新内容入口")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- public async Task>> UpsertEntry(
- UpsertContentEntryDto request,
- CancellationToken cancellationToken)
- {
- return Ok(await capabilities.ContentEntries.UpsertEntryAsync(
- ResolveContentActor(),
- request.ToCommand(),
- cancellationToken));
- }
-
- [HttpGet("nodes")]
- [EndpointSummary("查询租户内容节点")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- public async Task>> GetNodes(
- [FromQuery] ContentManagementQueryDto query,
- CancellationToken cancellationToken)
- {
- return Ok(await capabilities.ContentNodes.GetNodesAsync(
- ResolveContentActor(),
- query.ToFilter(),
- cancellationToken));
- }
-
- [HttpPost("nodes")]
- [EndpointSummary("创建或更新内容节点")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- public async Task>> UpsertNode(
- UpsertContentNodeDto request,
- CancellationToken cancellationToken)
- {
- return Ok(await capabilities.ContentNodes.UpsertNodeAsync(
- ResolveContentActor(),
- request.ToCommand(),
- cancellationToken));
- }
-
- [HttpGet("question-collections")]
- [EndpointSummary("查询租户题集")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- public async Task>> GetQuestionCollections(
- [FromQuery] ContentManagementQueryDto query,
- CancellationToken cancellationToken)
- {
- return Ok(await capabilities.QuestionCollections.GetCollectionsAsync(
- ResolveContentActor(),
- query.ToFilter(),
- cancellationToken));
- }
-
- [HttpPost("question-collections")]
- [EndpointSummary("创建或更新题集")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- public async Task>> UpsertQuestionCollection(
- UpsertQuestionCollectionDto request,
- CancellationToken cancellationToken)
- {
- return Ok(await capabilities.QuestionCollections.UpsertCollectionAsync(
- ResolveContentActor(),
- request.ToCommand(),
- cancellationToken));
- }
-
- [HttpPost("question-collections/items/replace")]
- [EndpointSummary("替换题集题目")]
- [ProducesResponseType(StatusCodes.Status200OK)]
- public async Task> ReplaceQuestionCollectionItems(
- ReplaceCollectionItemsDto request,
- CancellationToken cancellationToken)
- {
- return Ok(await capabilities.QuestionCollections.ReplaceCollectionItemsAsync(
- ResolveContentActor(),
- request.ToCommand(),
- cancellationToken));
- }
-
- [HttpGet("practice-blueprints")]
- [EndpointSummary("查询练习蓝图")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- public async Task>> GetPracticeBlueprints(
- [FromQuery] ContentManagementQueryDto query,
- CancellationToken cancellationToken)
- {
- return Ok(await capabilities.PracticeBlueprints.GetPracticeBlueprintsAsync(
- ResolveContentActor(),
- query.ToFilter(),
- cancellationToken));
- }
-
- [HttpPost("practice-blueprints")]
- [EndpointSummary("创建或更新练习蓝图")]
- [ProducesResponseType>(StatusCodes.Status200OK)]
- public async Task>> UpsertPracticeBlueprint(
- UpsertPracticeBlueprintDto request,
- CancellationToken cancellationToken)
- {
- return Ok(await capabilities.PracticeBlueprints.UpsertPracticeBlueprintAsync(
- ResolveContentActor(),
- request.ToCommand(),
- cancellationToken));
- }
-
- [HttpGet("imports/field-mapping")]
- [EndpointSummary("查询导入字段映射")]
- [ProducesResponseType(StatusCodes.Status200OK)]
- public ActionResult GetImportFieldMapping([FromQuery] ImportTemplateQueryDto query)
- {
- _ = ResolveContentActor();
- return Ok(capabilities.PracticeBlueprints.GetImportFieldMapping(query.ImportType));
- }
-
- [HttpGet("imports/templates")]
- [EndpointSummary("获取内容导入模板")]
- [ProducesResponseType(StatusCodes.Status200OK)]
- public ActionResult GetImportTemplate([FromQuery] ImportTemplateQueryDto query)
- {
- _ = ResolveContentActor();
- return Ok(capabilities.PracticeBlueprints.GetImportTemplate(query.ImportType, query.Format));
- }
-
[HttpGet("assets")]
[EndpointSummary("查询租户内容资产")]
[ProducesResponseType>(StatusCodes.Status200OK)]
@@ -311,12 +176,4 @@ public sealed class TenantContentController(
return new AssetManagementActor(currentTenant.TenantId.Value, currentUser.UserId.Value);
}
- private ContentManagementActor ResolveContentActor()
- {
- if (currentTenant.TenantId is null || currentUser.UserId is null)
- throw new ContentManagementException("Tenant content actor was not resolved.",
- "tenant_content_access_denied");
-
- return new ContentManagementActor(currentTenant.TenantId.Value, currentUser.UserId.Value);
- }
}
diff --git a/Tiku.Api/Controllers/TenantNotFoundException.cs b/Tiku.Api/Controllers/TenantNotFoundException.cs
new file mode 100644
index 0000000..5716349
--- /dev/null
+++ b/Tiku.Api/Controllers/TenantNotFoundException.cs
@@ -0,0 +1,8 @@
+namespace Tiku.Api.Controllers;
+
+public sealed class TenantNotFoundException : Exception
+{
+ public TenantNotFoundException() : base("Tenant not found.")
+ {
+ }
+}
diff --git a/Tiku.Api/Modules/Student/Content/Errors/ContentExceptionProblemDetailsMapper.cs b/Tiku.Api/Modules/Student/Content/Errors/ContentExceptionProblemDetailsMapper.cs
index d8073b3..5e9eb6b 100644
--- a/Tiku.Api/Modules/Student/Content/Errors/ContentExceptionProblemDetailsMapper.cs
+++ b/Tiku.Api/Modules/Student/Content/Errors/ContentExceptionProblemDetailsMapper.cs
@@ -15,7 +15,7 @@ internal sealed class ContentExceptionProblemDetailsMapper : ExceptionProblemDet
typeof(ContentNavigationNotFoundException), typeof(QuestionBankRequiredFieldException),
typeof(QuestionBankNotFoundException), typeof(AssetAccessException), typeof(AssetManagementException),
typeof(VideoPlaybackException), typeof(ContentManagementException), typeof(ScorelineQueryException),
- typeof(ProfileException)
+ typeof(ProfileException), typeof(ContentV2Exception)
];
public override bool TryMap(Exception exception, out ExceptionProblemDetailsMapping mapping)
@@ -38,6 +38,7 @@ internal sealed class ContentExceptionProblemDetailsMapper : ExceptionProblemDet
AssetManagementException asset => Mapped(asset, AssetManagementStatus(asset.Code), asset.Code, out mapping),
VideoPlaybackException video => Mapped(video, VideoStatus(video.Code), video.Code, out mapping),
ContentManagementException content => Mapped(content, ContentStatus(content.Code), content.Code, out mapping),
+ ContentV2Exception content => Mapped(content, ContentV2Status(content.Code), content.Code, out mapping),
ScorelineQueryException scoreline => Mapped(scoreline, StatusCodes.Status400BadRequest, scoreline.Code,
out mapping),
ProfileException profile => Mapped(profile, ProfileStatus(profile.Code), profile.Code, out mapping),
@@ -85,4 +86,13 @@ internal sealed class ContentExceptionProblemDetailsMapper : ExceptionProblemDet
"major_not_found" => StatusCodes.Status404NotFound,
_ => StatusCodes.Status400BadRequest
};
+
+ private static int ContentV2Status(string code) => code switch
+ {
+ "question_asset_owner_forbidden" => StatusCodes.Status403Forbidden,
+ "question_exact_duplicate_exists" => StatusCodes.Status409Conflict,
+ _ when code.EndsWith("_not_found", StringComparison.Ordinal) ||
+ code.EndsWith("_missing", StringComparison.Ordinal) => StatusCodes.Status404NotFound,
+ _ => StatusCodes.Status400BadRequest
+ };
}
diff --git a/Tiku.Api/Modules/Student/Learning/Errors/LearningExceptionProblemDetailsMapper.cs b/Tiku.Api/Modules/Student/Learning/Errors/LearningExceptionProblemDetailsMapper.cs
index a0f6722..1555339 100644
--- a/Tiku.Api/Modules/Student/Learning/Errors/LearningExceptionProblemDetailsMapper.cs
+++ b/Tiku.Api/Modules/Student/Learning/Errors/LearningExceptionProblemDetailsMapper.cs
@@ -20,7 +20,7 @@ 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,
+ LearningAccessException access => Mapped(access, AccessStatus(access.Code),
access.Code, out mapping),
LearningAccessDeniedException => Mapped(exception, StatusCodes.Status403Forbidden,
"learning_access_denied", out mapping),
@@ -36,4 +36,11 @@ internal sealed class LearningExceptionProblemDetailsMapper : ExceptionProblemDe
"practice_session_expired" => StatusCodes.Status409Conflict,
_ => StatusCodes.Status400BadRequest
};
+
+ private static int AccessStatus(string code) => code switch
+ {
+ "student_target_change_cooldown" or "student_target_policy_limit_exceeded" or
+ "student_target_product_limit_exceeded" => StatusCodes.Status409Conflict,
+ _ => StatusCodes.Status403Forbidden
+ };
}
diff --git a/Tiku.Application/Catalog/TaxonomyModels.cs b/Tiku.Application/Catalog/TaxonomyModels.cs
deleted file mode 100644
index 0274014..0000000
--- a/Tiku.Application/Catalog/TaxonomyModels.cs
+++ /dev/null
@@ -1,37 +0,0 @@
-using System.Text.Json;
-using Tiku.Domain.Catalog;
-using Tiku.Domain.Content;
-
-namespace Tiku.Application.Catalog;
-
-public sealed record TaxonomyNodeItem(
- Guid Id,
- QuestionSource Source,
- Guid? ParentId,
- QuestionSource? ParentSource,
- TaxonomyNodeType NodeType,
- string Code,
- string Name,
- string? Path,
- int Depth,
- int SortOrder,
- JsonElement Metadata);
-
-public sealed record CreateTaxonomyNodeCommand(
- Guid? ParentId,
- QuestionSource? ParentSource,
- TaxonomyNodeType NodeType,
- string Code,
- string Name,
- int SortOrder,
- JsonElement Metadata);
-
-public interface ITaxonomyService
-{
- Task> ListAsync(Guid tenantId, CancellationToken cancellationToken = default);
-
- Task CreateAsync(
- Guid tenantId,
- CreateTaxonomyNodeCommand command,
- CancellationToken cancellationToken = default);
-}
\ No newline at end of file
diff --git a/Tiku.Application/Content/ContentManagementException.cs b/Tiku.Application/Content/ContentManagementException.cs
new file mode 100644
index 0000000..b8ce3dc
--- /dev/null
+++ b/Tiku.Application/Content/ContentManagementException.cs
@@ -0,0 +1,8 @@
+namespace Tiku.Application.Content;
+
+public sealed record ContentManagementResult(TItem Item);
+
+public sealed class ContentManagementException(string message, string code) : Exception(message)
+{
+ public string Code { get; } = code;
+}
diff --git a/Tiku.Application/Content/ContentManagementModels.cs b/Tiku.Application/Content/ContentManagementModels.cs
deleted file mode 100644
index 11b99c2..0000000
--- a/Tiku.Application/Content/ContentManagementModels.cs
+++ /dev/null
@@ -1,239 +0,0 @@
-using System.Text.Json;
-using Tiku.Application.QuestionBanks;
-using Tiku.Domain.Content;
-
-namespace Tiku.Application.Content;
-
-public sealed record ContentManagementActor(Guid TenantId, Guid UserId);
-
-public sealed record ContentManagementFilter(
- Guid? RegionId = null,
- Guid? EntryId = null,
- Guid? NodeId = null,
- Guid? CollectionId = null,
- string? ParentId = null,
- string? EntryType = null,
- string? CollectionType = null,
- string? Mode = null,
- string? MarkerType = null,
- string? Keyword = null,
- bool IncludeInactive = false,
- int? Limit = null);
-
-public sealed record UpsertContentEntryCommand(
- Guid? Id,
- Guid? RegionId,
- string? LegacyId,
- string? EntryKey,
- string Name,
- string? EntryType,
- string? Icon,
- string? Route,
- string? Description,
- string? Visibility,
- JsonElement AccessRules,
- JsonElement LayoutConfig,
- int? Order,
- bool? IsActive);
-
-public sealed record UpsertContentNodeCommand(
- Guid? Id,
- Guid EntryId,
- Guid? RegionId,
- Guid? ParentId,
- string? LegacyId,
- string? NodeKey,
- string Name,
- string? NodeType,
- string? MarkerType,
- JsonElement MarkerConfig,
- int? Order,
- bool? IsActive,
- bool? IsSelectable,
- bool? IsLeaf,
- JsonElement AccessRules,
- JsonElement Metadata);
-
-public sealed record UpsertQuestionCollectionCommand(
- Guid? Id,
- Guid? RegionId,
- Guid? EntryId,
- Guid? NodeId,
- Guid? SubjectId,
- Guid? CategoryId,
- Guid? QuestionBankId,
- string? LegacyId,
- string Name,
- string? CollectionType,
- string? SourceType,
- JsonElement Filters,
- decimal? TotalScore,
- int? DurationMinutes,
- string? Status,
- int? Order,
- JsonElement AccessRules,
- JsonElement Metadata);
-
-public sealed record ReplaceCollectionItemsCommand(
- Guid CollectionId,
- IReadOnlyCollection Questions);
-
-public sealed record CollectionQuestionCommand(
- QuestionLocator Locator,
- string? SectionKey,
- int? Order,
- decimal? Score,
- bool? Required,
- JsonElement Metadata);
-
-public sealed record UpsertPracticeBlueprintCommand(
- Guid? Id,
- Guid? RegionId,
- Guid? EntryId,
- Guid? NodeId,
- Guid? CollectionId,
- string? LegacyId,
- string Name,
- string? Mode,
- string? AssemblyType,
- int? QuestionLimit,
- int? DurationMinutes,
- decimal? TotalScore,
- decimal? PassScore,
- JsonElement Sections,
- JsonElement Rules,
- JsonElement AccessRules,
- string? Status,
- int? Order);
-
-public sealed record ContentEntryManagementItem(
- Guid Id,
- Guid? RegionId,
- string? LegacyId,
- string EntryKey,
- string Name,
- ContentEntryType EntryType,
- string? Icon,
- string? Route,
- string? Description,
- ContentVisibility Visibility,
- JsonElement AccessRules,
- JsonElement LayoutConfig,
- int Order,
- bool IsActive,
- DateTimeOffset CreatedAt,
- DateTimeOffset? UpdatedAt);
-
-public sealed record ContentNodeManagementItem(
- Guid Id,
- Guid EntryId,
- Guid? RegionId,
- Guid? ParentId,
- string? LegacyId,
- string? NodeKey,
- string Name,
- ContentNodeType NodeType,
- ContentMarkerType? MarkerType,
- JsonElement MarkerConfig,
- string? Path,
- int Depth,
- int Order,
- bool IsActive,
- bool IsSelectable,
- bool IsLeaf,
- JsonElement AccessRules,
- JsonElement Metadata,
- DateTimeOffset CreatedAt,
- DateTimeOffset? UpdatedAt);
-
-public sealed record QuestionCollectionManagementItem(
- Guid Id,
- Guid? RegionId,
- Guid? EntryId,
- Guid? NodeId,
- Guid? SubjectId,
- Guid? CategoryId,
- Guid? QuestionBankId,
- string? LegacyId,
- string Name,
- QuestionCollectionType CollectionType,
- QuestionCollectionSourceType SourceType,
- JsonElement Filters,
- int QuestionCount,
- decimal? TotalScore,
- int? DurationMinutes,
- ContentStatus Status,
- int Order,
- JsonElement AccessRules,
- JsonElement Metadata,
- DateTimeOffset CreatedAt,
- DateTimeOffset? UpdatedAt);
-
-public sealed record QuestionCollectionItemManagementItem(
- Guid Id,
- Guid CollectionId,
- Guid QuestionId,
- QuestionLocator Locator,
- string? SectionKey,
- int Order,
- decimal? Score,
- bool Required,
- JsonElement Metadata);
-
-public sealed record PracticeBlueprintManagementItem(
- Guid Id,
- Guid? RegionId,
- Guid? EntryId,
- Guid? NodeId,
- Guid? CollectionId,
- string? LegacyId,
- string Name,
- PracticeMode Mode,
- PracticeAssemblyType AssemblyType,
- int? QuestionLimit,
- int? DurationMinutes,
- decimal? TotalScore,
- decimal? PassScore,
- JsonElement Sections,
- JsonElement Rules,
- JsonElement AccessRules,
- ContentStatus Status,
- int Order,
- DateTimeOffset CreatedAt,
- DateTimeOffset? UpdatedAt);
-
-public sealed record ContentManagementResult(TItem Item);
-
-public sealed record CollectionItemsReplaceResult(
- Guid CollectionId,
- int QuestionCount,
- IReadOnlyCollection Items);
-
-public sealed record ImportFieldSpec(
- string Field,
- string Label,
- bool Required,
- IReadOnlyCollection Aliases,
- string Description,
- JsonElement Example);
-
-public sealed record ImportFieldMappingItem(
- string ImportType,
- string Title,
- string Description,
- IReadOnlyCollection Fields,
- IReadOnlyCollection RequiredFields);
-
-public sealed record ImportTemplateItem(
- string ImportType,
- string Format,
- string FileName,
- string MimeType,
- string ContentBase64,
- string ContentPreview,
- IReadOnlyCollection Fields);
-
-public sealed class ContentManagementException(string message, string code) : Exception(message)
-{
- public string Code { get; } = code;
-}
\ No newline at end of file
diff --git a/Tiku.Application/Content/ContentNavigationQueryModels.cs b/Tiku.Application/Content/ContentNavigationQueryModels.cs
deleted file mode 100644
index 2a8c44d..0000000
--- a/Tiku.Application/Content/ContentNavigationQueryModels.cs
+++ /dev/null
@@ -1,124 +0,0 @@
-using System.Text.Json;
-using Tiku.Domain.Content;
-
-namespace Tiku.Application.Content;
-
-public sealed record ContentNavigationFilter(
- Guid TenantId,
- Guid? RegionId = null,
- Guid? EntryId = null,
- Guid? NodeId = null,
- Guid? CollectionId = null,
- Guid? ParentId = null,
- bool ParentWasSpecified = false,
- bool ParentIsRoot = false,
- string? EntryType = null,
- string? CollectionType = null,
- string? Mode = null,
- string? MarkerType = null,
- string? Keyword = null,
- bool IncludeHidden = false,
- bool IncludeInactive = false,
- int? Limit = null,
- IReadOnlyCollection? AllowedContentSliceIds = null,
- IReadOnlyCollection? AllowedRegionIds = null);
-
-public sealed record ContentEntryCatalogItem(
- Guid Id,
- Guid? RegionId,
- string? LegacyId,
- string EntryKey,
- string Name,
- ContentEntryType EntryType,
- string? Icon,
- string? Route,
- string? Description,
- ContentVisibility Visibility,
- JsonElement AccessRules,
- JsonElement LayoutConfig,
- int Order);
-
-public sealed record ContentNodeCatalogItem(
- Guid Id,
- Guid EntryId,
- Guid? RegionId,
- Guid? ParentId,
- string? LegacyId,
- string? NodeKey,
- string Name,
- ContentNodeType NodeType,
- ContentMarkerType? MarkerType,
- JsonElement MarkerConfig,
- string? Path,
- int Depth,
- int Order,
- bool IsSelectable,
- bool IsLeaf,
- JsonElement AccessRules,
- JsonElement Metadata);
-
-public sealed record QuestionCollectionCatalogItem(
- Guid Id,
- Guid? RegionId,
- Guid? EntryId,
- Guid? NodeId,
- Guid? SubjectId,
- Guid? CategoryId,
- Guid? QuestionBankId,
- string? LegacyId,
- string Name,
- QuestionCollectionType CollectionType,
- QuestionCollectionSourceType SourceType,
- JsonElement Filters,
- int QuestionCount,
- decimal? TotalScore,
- int? DurationMinutes,
- ContentStatus Status,
- int Order,
- JsonElement AccessRules,
- JsonElement Metadata);
-
-public sealed record PracticeBlueprintCatalogItem(
- Guid Id,
- Guid? RegionId,
- Guid? EntryId,
- Guid? NodeId,
- Guid? CollectionId,
- string? LegacyId,
- string Name,
- PracticeMode Mode,
- PracticeAssemblyType AssemblyType,
- int? QuestionLimit,
- int? DurationMinutes,
- decimal? TotalScore,
- decimal? PassScore,
- JsonElement Sections,
- JsonElement Rules,
- JsonElement AccessRules,
- ContentStatus Status,
- int Order);
-
-public sealed record CollectionQuestionCatalogItem(
- Guid Id,
- string? LegacyId,
- Guid? EntryId,
- Guid? ContentNodeId,
- Guid? PrimaryCollectionId,
- Guid? SubjectId,
- Guid? CategoryId,
- Guid? NodeId,
- string Type,
- string? TypeLabel,
- int? Difficulty,
- JsonElement Tags,
- JsonElement ExamMarkers,
- string? MediaUrl,
- bool HasVideoExplanation,
- string? SectionKey,
- decimal? Score,
- int Order,
- Guid? VersionId,
- string? Content,
- JsonElement Options,
- string? CodeLang,
- string? CodeTemplate);
diff --git a/Tiku.Application/Content/ContentV2AuthoringModels.cs b/Tiku.Application/Content/ContentV2AuthoringModels.cs
new file mode 100644
index 0000000..d5185f1
--- /dev/null
+++ b/Tiku.Application/Content/ContentV2AuthoringModels.cs
@@ -0,0 +1,152 @@
+using System.Text.Json;
+using Tiku.Domain.Content;
+
+namespace Tiku.Application.Content;
+
+public sealed record ContentAuthorActor(Guid TenantId, Guid UserId);
+
+public interface IPlatformContentActorResolver
+{
+ Task ResolveAsync(Guid userId, CancellationToken cancellationToken = default);
+}
+
+public sealed record QuestionRevisionDraft(
+ string QuestionType,
+ string? TypeLabel,
+ int? Difficulty,
+ string? Content,
+ JsonElement Options,
+ int? CorrectOptionIndex,
+ JsonElement CorrectOptionIndices,
+ string? AnswerText,
+ string? Explanation,
+ JsonElement SubQuestions,
+ string? CodeLang,
+ string? CodeTemplate);
+
+public sealed record QuestionDuplicateCandidate(
+ Guid QuestionAssetOwnerTenantId,
+ Guid QuestionAssetId,
+ Guid QuestionRevisionId,
+ string Source,
+ bool ExactDeliveryMatch,
+ double PromptSimilarity,
+ string QuestionType,
+ string? Content,
+ int? Difficulty);
+
+public sealed record QuestionDuplicateSearchResult(
+ string DeliveryFingerprint,
+ IReadOnlyCollection Candidates);
+
+public enum QuestionImportDecision
+{
+ ReusePlatform,
+ ReuseTenant,
+ CreateNew,
+ CreateVariant,
+ Skip,
+ ManualReview
+}
+
+public sealed record ExecuteQuestionAuthoringCommand(
+ QuestionImportDecision Decision,
+ QuestionRevisionDraft Draft,
+ Guid? ReuseQuestionAssetId = null,
+ Guid? FamilyId = null,
+ bool PublishAsset = false);
+
+public sealed record QuestionAuthoringResult(
+ QuestionImportDecision Decision,
+ Guid? QuestionAssetOwnerTenantId,
+ Guid? QuestionAssetId,
+ Guid? QuestionRevisionId,
+ string DeliveryFingerprint,
+ string Status);
+
+public sealed record QuestionImportPreviewRow(
+ string RowKey,
+ string DeliveryFingerprint,
+ QuestionImportDecision SuggestedDecision,
+ IReadOnlyCollection Candidates);
+
+public sealed record QuestionImportPreviewResult(
+ int RowCount,
+ IReadOnlyCollection Rows);
+
+public sealed record ExecuteQuestionImportRow(
+ string RowKey,
+ ExecuteQuestionAuthoringCommand Command);
+
+public sealed record QuestionImportExecutionRow(
+ string RowKey,
+ QuestionAuthoringResult Result);
+
+public sealed record QuestionImportExecutionResult(
+ int RowCount,
+ IReadOnlyCollection Rows);
+
+public sealed record PlacementConditionCommand(
+ Guid TargetDimensionDefinitionId,
+ Guid TargetNodeId,
+ TargetRuleOperator Operator);
+
+public sealed record PlacementRuleGroupCommand(
+ int GroupOrder,
+ IReadOnlyCollection Conditions);
+
+public sealed record CreateQuestionPlacementCommand(
+ Guid QuestionAssetOwnerTenantId,
+ Guid QuestionAssetId,
+ Guid CurriculumVersionId,
+ Guid CurriculumNodeId,
+ Guid AssessmentPolicyVersionId,
+ IReadOnlyCollection KnowledgeConceptIds,
+ IReadOnlyCollection RuleGroups,
+ int? DifficultyOverride = null,
+ bool Activate = false);
+
+public sealed record QuestionPlacementItem(
+ Guid Id,
+ Guid QuestionAssetOwnerTenantId,
+ Guid QuestionAssetId,
+ Guid CurriculumVersionId,
+ Guid CurriculumNodeId,
+ Guid AssessmentPolicyVersionId,
+ string Status);
+
+public interface IContentV2AuthoringService
+{
+ Task SearchDuplicatesAsync(
+ ContentAuthorActor actor,
+ QuestionRevisionDraft draft,
+ int limit = 20,
+ CancellationToken cancellationToken = default);
+
+ Task ExecuteAsync(
+ ContentAuthorActor actor,
+ ExecuteQuestionAuthoringCommand command,
+ CancellationToken cancellationToken = default);
+
+ Task CreateRevisionAsync(
+ ContentAuthorActor actor,
+ Guid questionAssetId,
+ QuestionRevisionDraft draft,
+ bool publishAsset,
+ CancellationToken cancellationToken = default);
+
+ Task CreatePlacementAsync(
+ ContentAuthorActor actor,
+ CreateQuestionPlacementCommand command,
+ CancellationToken cancellationToken = default);
+
+ Task PreviewImportAsync(
+ ContentAuthorActor actor,
+ IReadOnlyCollection<(string RowKey, QuestionRevisionDraft Draft)> rows,
+ CancellationToken cancellationToken = default);
+
+ Task ExecuteImportAsync(
+ ContentAuthorActor actor,
+ IReadOnlyCollection rows,
+ CancellationToken cancellationToken = default);
+}
diff --git a/Tiku.Application/Content/ContentV2Models.cs b/Tiku.Application/Content/ContentV2Models.cs
new file mode 100644
index 0000000..0cd44ca
--- /dev/null
+++ b/Tiku.Application/Content/ContentV2Models.cs
@@ -0,0 +1,93 @@
+using Tiku.Domain.Content;
+
+namespace Tiku.Application.Content;
+
+public sealed record PublishContentReleaseCommand(
+ Guid TenantId,
+ Guid ActorUserId,
+ Guid CurriculumVersionId,
+ string Name);
+
+public sealed record ContentReleaseCompilationResult(
+ Guid ContentReleaseId,
+ int ReleaseNo,
+ int PlacementCount,
+ int QuestionCount,
+ int AudienceSegmentCount,
+ int TargetProfileCount,
+ string SourceFingerprint,
+ DateTimeOffset PublishedAt);
+
+public interface IContentReleaseCompiler
+{
+ Task PublishAsync(
+ PublishContentReleaseCommand command,
+ CancellationToken cancellationToken = default);
+}
+
+public sealed record ContentCandidateQuery(
+ Guid ContentOwnerTenantId,
+ IReadOnlyCollection ContentReleaseIds,
+ IReadOnlyCollection AudienceSegmentIds,
+ Guid? CurriculumNodeId,
+ IReadOnlyCollection? CollectionReleaseIds,
+ int Limit,
+ int Seed,
+ string? QuestionType = null,
+ int? Difficulty = null);
+
+public sealed record ContentCandidateItem(
+ Guid ContentReleaseQuestionId,
+ Guid ContentOwnerTenantId,
+ Guid ContentReleaseId,
+ Guid AudienceSegmentId,
+ Guid CurriculumNodeId,
+ Guid QuestionPlacementId,
+ Guid QuestionAssetOwnerTenantId,
+ Guid QuestionAssetId,
+ Guid QuestionRevisionId,
+ Guid AssessmentPolicyVersionId,
+ int Ordinal,
+ int? Difficulty,
+ string QuestionType);
+
+public interface IContentCandidateReader
+{
+ Task> GetCandidatesAsync(
+ ContentCandidateQuery query,
+ CancellationToken cancellationToken = default);
+}
+
+public sealed record ApplyPlatformContentPackageCommand(
+ Guid PackageOwnerTenantId,
+ Guid PlatformContentPackageVersionId,
+ string PackageCode,
+ string CellId,
+ long EventSequence,
+ string ContentHash);
+
+public enum CellPackageApplyStatus
+{
+ Applied,
+ Duplicate,
+ IgnoredOutOfOrder
+}
+
+public sealed record CellPackageApplyResult(
+ CellPackageApplyStatus Status,
+ Guid PlatformContentPackageVersionId,
+ string CellId,
+ long EventSequence,
+ string ContentHash);
+
+public interface IPlatformContentPackageProjectionService
+{
+ Task ApplyAsync(
+ ApplyPlatformContentPackageCommand command,
+ CancellationToken cancellationToken = default);
+}
+
+public sealed class ContentV2Exception(string code, string message) : Exception(message)
+{
+ public string Code { get; } = code;
+}
diff --git a/Tiku.Application/Content/ContentV2Rules.cs b/Tiku.Application/Content/ContentV2Rules.cs
new file mode 100644
index 0000000..a7680c0
--- /dev/null
+++ b/Tiku.Application/Content/ContentV2Rules.cs
@@ -0,0 +1,157 @@
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using Tiku.Domain.Content;
+
+namespace Tiku.Application.Content;
+
+public sealed record TargetProfileFact(
+ Guid TargetDimensionDefinitionId,
+ Guid TargetNodeId,
+ IReadOnlySet AncestorNodeIds);
+
+public sealed record PlacementRuleConditionFact(
+ Guid TargetDimensionDefinitionId,
+ Guid TargetNodeId,
+ TargetRuleOperator Operator);
+
+public sealed record PlacementRuleGroupFact(
+ int GroupOrder,
+ IReadOnlyCollection Conditions);
+
+public static class QuestionApplicabilityEvaluator
+{
+ public static bool Matches(
+ IReadOnlyCollection profile,
+ IReadOnlyCollection groups)
+ {
+ if (groups.Count == 0) return true;
+ var valuesByDimension = profile
+ .GroupBy(value => value.TargetDimensionDefinitionId)
+ .ToDictionary(group => group.Key, group => group.ToArray());
+ return groups.Any(group => group.Conditions.Count > 0 &&
+ group.Conditions.All(condition => Matches(valuesByDimension, condition)));
+ }
+
+ private static bool Matches(
+ IReadOnlyDictionary valuesByDimension,
+ PlacementRuleConditionFact condition)
+ {
+ valuesByDimension.TryGetValue(condition.TargetDimensionDefinitionId, out var values);
+ values ??= [];
+ var positiveMatch = condition.Operator switch
+ {
+ TargetRuleOperator.Exact or TargetRuleOperator.NotExact =>
+ values.Any(value => value.TargetNodeId == condition.TargetNodeId),
+ TargetRuleOperator.DescendantOf or TargetRuleOperator.NotDescendantOf =>
+ values.Any(value => value.TargetNodeId == condition.TargetNodeId ||
+ value.AncestorNodeIds.Contains(condition.TargetNodeId)),
+ _ => false
+ };
+ return condition.Operator is TargetRuleOperator.NotExact or TargetRuleOperator.NotDescendantOf
+ ? !positiveMatch
+ : positiveMatch;
+ }
+}
+
+public sealed record QuestionDeliveryFingerprintInput(
+ string QuestionType,
+ string? Content,
+ JsonElement Options,
+ int? CorrectOptionIndex,
+ JsonElement CorrectOptionIndices,
+ string? AnswerText,
+ JsonElement SubQuestions,
+ string? CodeLang,
+ string? CodeTemplate);
+
+public static class QuestionDeliveryFingerprint
+{
+ public static string Compute(QuestionDeliveryFingerprintInput input)
+ {
+ using var stream = new MemoryStream();
+ using (var writer = new Utf8JsonWriter(stream))
+ {
+ writer.WriteStartObject();
+ writer.WriteString("questionType", NormalizeText(input.QuestionType));
+ writer.WriteString("content", NormalizeText(input.Content));
+ writer.WritePropertyName("options");
+ WriteCanonical(writer, input.Options);
+ if (input.CorrectOptionIndex.HasValue)
+ writer.WriteNumber("correctOptionIndex", input.CorrectOptionIndex.Value);
+ else
+ writer.WriteNull("correctOptionIndex");
+ writer.WritePropertyName("correctOptionIndices");
+ WriteCanonical(writer, input.CorrectOptionIndices);
+ writer.WriteString("answerText", NormalizeText(input.AnswerText));
+ writer.WritePropertyName("subQuestions");
+ WriteCanonical(writer, input.SubQuestions);
+ writer.WriteString("codeLang", NormalizeText(input.CodeLang));
+ writer.WriteString("codeTemplate", NormalizeText(input.CodeTemplate));
+ writer.WriteEndObject();
+ }
+
+ return Convert.ToHexString(SHA256.HashData(stream.ToArray())).ToLowerInvariant();
+ }
+
+ public static string NormalizePrompt(string? value) => NormalizeText(value);
+
+ private static string NormalizeText(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value)) return string.Empty;
+ var normalized = value.Normalize(NormalizationForm.FormKC).Trim();
+ var builder = new StringBuilder(normalized.Length);
+ var previousWhitespace = false;
+ foreach (var character in normalized)
+ {
+ if (char.IsWhiteSpace(character))
+ {
+ if (!previousWhitespace) builder.Append(' ');
+ previousWhitespace = true;
+ }
+ else
+ {
+ builder.Append(character);
+ previousWhitespace = false;
+ }
+ }
+
+ return builder.ToString();
+ }
+
+ private static void WriteCanonical(Utf8JsonWriter writer, JsonElement value)
+ {
+ switch (value.ValueKind)
+ {
+ case JsonValueKind.Object:
+ writer.WriteStartObject();
+ foreach (var property in value.EnumerateObject().OrderBy(property => property.Name, StringComparer.Ordinal))
+ {
+ writer.WritePropertyName(property.Name);
+ WriteCanonical(writer, property.Value);
+ }
+ writer.WriteEndObject();
+ break;
+ case JsonValueKind.Array:
+ writer.WriteStartArray();
+ foreach (var item in value.EnumerateArray()) WriteCanonical(writer, item);
+ writer.WriteEndArray();
+ break;
+ case JsonValueKind.String:
+ writer.WriteStringValue(NormalizeText(value.GetString()));
+ break;
+ case JsonValueKind.Number:
+ value.WriteTo(writer);
+ break;
+ case JsonValueKind.True:
+ writer.WriteBooleanValue(true);
+ break;
+ case JsonValueKind.False:
+ writer.WriteBooleanValue(false);
+ break;
+ default:
+ writer.WriteNullValue();
+ break;
+ }
+ }
+}
diff --git a/Tiku.Application/Content/IContentManagementService.cs b/Tiku.Application/Content/IContentManagementService.cs
deleted file mode 100644
index 545fc0d..0000000
--- a/Tiku.Application/Content/IContentManagementService.cs
+++ /dev/null
@@ -1,64 +0,0 @@
-using Tiku.Application.Catalog;
-
-namespace Tiku.Application.Content;
-
-public interface IContentEntryManagementService
-{
- Task> GetEntriesAsync(
- ContentManagementActor actor,
- ContentManagementFilter filter,
- CancellationToken cancellationToken = default);
-
- Task> UpsertEntryAsync(
- ContentManagementActor actor,
- UpsertContentEntryCommand command,
- CancellationToken cancellationToken = default);
-}
-
-public interface IContentNodeManagementService
-{
- Task> GetNodesAsync(
- ContentManagementActor actor,
- ContentManagementFilter filter,
- CancellationToken cancellationToken = default);
-
- Task> UpsertNodeAsync(
- ContentManagementActor actor,
- UpsertContentNodeCommand command,
- CancellationToken cancellationToken = default);
-}
-
-public interface IQuestionCollectionManagementService
-{
- Task> GetCollectionsAsync(
- ContentManagementActor actor,
- ContentManagementFilter filter,
- CancellationToken cancellationToken = default);
-
- Task> UpsertCollectionAsync(
- ContentManagementActor actor,
- UpsertQuestionCollectionCommand command,
- CancellationToken cancellationToken = default);
-
- Task ReplaceCollectionItemsAsync(
- ContentManagementActor actor,
- ReplaceCollectionItemsCommand command,
- CancellationToken cancellationToken = default);
-}
-
-public interface IPracticeBlueprintManagementService
-{
- Task> GetPracticeBlueprintsAsync(
- ContentManagementActor actor,
- ContentManagementFilter filter,
- CancellationToken cancellationToken = default);
-
- Task> UpsertPracticeBlueprintAsync(
- ContentManagementActor actor,
- UpsertPracticeBlueprintCommand command,
- CancellationToken cancellationToken = default);
-
- ImportFieldMappingItem GetImportFieldMapping(string importType);
-
- ImportTemplateItem GetImportTemplate(string importType, string? format);
-}
diff --git a/Tiku.Application/Content/IContentNavigationQueryService.cs b/Tiku.Application/Content/IContentNavigationQueryService.cs
deleted file mode 100644
index fd193e5..0000000
--- a/Tiku.Application/Content/IContentNavigationQueryService.cs
+++ /dev/null
@@ -1,26 +0,0 @@
-using Tiku.Application.Catalog;
-
-namespace Tiku.Application.Content;
-
-public interface IContentNavigationQueryService
-{
- Task> GetContentEntriesAsync(
- ContentNavigationFilter filter,
- CancellationToken cancellationToken = default);
-
- Task> GetContentNodesAsync(
- ContentNavigationFilter filter,
- CancellationToken cancellationToken = default);
-
- Task> GetQuestionCollectionsAsync(
- ContentNavigationFilter filter,
- CancellationToken cancellationToken = default);
-
- Task> GetPracticeBlueprintsAsync(
- ContentNavigationFilter filter,
- CancellationToken cancellationToken = default);
-
- Task> GetCollectionQuestionsAsync(
- ContentNavigationFilter filter,
- CancellationToken cancellationToken = default);
-}
\ No newline at end of file
diff --git a/Tiku.Application/Learning/LearningAccessAdministrationModels.cs b/Tiku.Application/Learning/LearningAccessAdministrationModels.cs
deleted file mode 100644
index 62ad579..0000000
--- a/Tiku.Application/Learning/LearningAccessAdministrationModels.cs
+++ /dev/null
@@ -1,72 +0,0 @@
-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 GetTargetRegionAsync(
- LearningActor actor,
- CancellationToken cancellationToken = default);
-
- Task ChangeTargetRegionAsync(
- LearningActor actor,
- ChangeStudentTargetRegionCommand command,
- CancellationToken cancellationToken = default);
-
- Task OverrideTargetRegionAsync(
- Guid tenantId,
- OverrideStudentTargetRegionCommand command,
- CancellationToken cancellationToken = default);
-
- Task> GetClassAssignmentsAsync(
- Guid tenantId,
- Guid classId,
- CancellationToken cancellationToken = default);
-
- Task UpsertClassAssignmentAsync(
- Guid tenantId,
- Guid actorUserId,
- UpsertClassContentAssignmentCommand command,
- CancellationToken cancellationToken = default);
-
- Task RevokeClassAssignmentAsync(
- Guid tenantId,
- Guid actorUserId,
- Guid assignmentId,
- string? reason,
- CancellationToken cancellationToken = default);
-}
diff --git a/Tiku.Application/Learning/LearningAccessV2Models.cs b/Tiku.Application/Learning/LearningAccessV2Models.cs
new file mode 100644
index 0000000..caf6a8b
--- /dev/null
+++ b/Tiku.Application/Learning/LearningAccessV2Models.cs
@@ -0,0 +1,43 @@
+namespace Tiku.Application.Learning;
+
+public sealed record EffectiveLearningAccessSnapshot(
+ Guid TenantId,
+ Guid UserId,
+ Guid BusinessLineId,
+ Guid PrimaryProfileVersionId,
+ IReadOnlySet AlternateProfileVersionIds,
+ IReadOnlySet ContentReleaseIds,
+ IReadOnlySet AudienceSegmentIds,
+ IReadOnlySet ManifestVersionIds,
+ long GrantVersion,
+ long ContentVersion,
+ long StrongRevocationVersion,
+ DateTimeOffset ValidUntil,
+ DateTimeOffset CompiledAt);
+
+public interface IEffectiveLearningAccessService
+{
+ Task GetSnapshotAsync(
+ LearningActor actor,
+ Guid businessLineId,
+ CancellationToken cancellationToken = default);
+
+ Task InvalidateAsync(
+ Guid tenantId,
+ Guid userId,
+ Guid businessLineId,
+ CancellationToken cancellationToken = default);
+}
+
+public interface ILearningStrongRevocationService
+{
+ Task EnsureVersionAsync(
+ LearningActor actor,
+ long expectedVersion,
+ CancellationToken cancellationToken = default);
+
+ Task InvalidateAsync(
+ Guid tenantId,
+ Guid userId,
+ CancellationToken cancellationToken = default);
+}
diff --git a/Tiku.Application/Learning/LearningActivityModels.cs b/Tiku.Application/Learning/LearningActivityModels.cs
index 3114256..39e432f 100644
--- a/Tiku.Application/Learning/LearningActivityModels.cs
+++ b/Tiku.Application/Learning/LearningActivityModels.cs
@@ -18,7 +18,6 @@ public sealed record SubmitAnswerCommand(
public sealed record SubmitPracticeSessionCommand(
Guid PracticeSessionId,
- long ExpectedSessionVersion,
string IdempotencyKey);
public sealed record QuestionActionCommand(QuestionLocator Locator, bool? Favorite);
@@ -96,16 +95,8 @@ public sealed record WordStatsItem(
public sealed record PracticeSessionCommand(
string? Mode,
- string? TargetType,
- Guid? TargetId,
- Guid? BlueprintId,
- Guid? CollectionId,
- Guid? EntryId,
- Guid? ContentNodeId,
- int? QuestionLimit,
- int? DurationMinutes,
- decimal? TotalScore,
- JsonElement Metadata);
+ AccessResourceType ResourceType,
+ Guid ResourceId);
public sealed record PracticeSessionFilter(
Guid? PracticeSessionId = null,
@@ -200,9 +191,13 @@ public sealed record PracticeSessionDetailItem(
public sealed record PracticeSessionQuestionItem(
Guid SessionQuestionId,
- Guid QuestionReferenceId,
- QuestionLocator Locator,
- Guid QuestionId,
+ Guid? QuestionReferenceId,
+ QuestionLocator? Locator,
+ Guid? QuestionId,
+ Guid? QuestionAssetId,
+ Guid? QuestionRevisionId,
+ Guid? QuestionPlacementId,
+ Guid? AssessmentPolicyVersionId,
string Type,
string? TypeLabel,
int? Difficulty,
diff --git a/Tiku.Application/Learning/LearningTargetModels.cs b/Tiku.Application/Learning/LearningTargetModels.cs
new file mode 100644
index 0000000..905698b
--- /dev/null
+++ b/Tiku.Application/Learning/LearningTargetModels.cs
@@ -0,0 +1,109 @@
+using Tiku.Domain.Learning;
+
+namespace Tiku.Application.Learning;
+
+public sealed record LearningBusinessContextItem(
+ Guid BusinessLineId,
+ string BusinessCode,
+ string BusinessName,
+ Guid TenantBusinessLicenseId,
+ DateTimeOffset? LicenseEndsAt,
+ Guid? PrimaryProfileVersionId,
+ IReadOnlyCollection AlternateProfileVersionIds,
+ int MaxActiveTargets,
+ int MaxAlternateTargets,
+ int TargetChangeCooldownDays);
+
+public sealed record LearningContextItem(IReadOnlyCollection Businesses);
+
+public sealed record LearningTargetItem(
+ Guid ExamTargetProfileVersionId,
+ string ProfileCode,
+ string DisplayName,
+ int? ExamYear,
+ bool IsBaseTarget,
+ bool MayBePrimary,
+ bool MayBeAlternate,
+ bool IsPrimary,
+ bool IsAlternate);
+
+public sealed record LearningTargetSelectionItem(
+ Guid BusinessLineId,
+ Guid PrimaryProfileVersionId,
+ IReadOnlyCollection