diff --git a/Tiku.Api/Contracts/ContentManagementDtos.cs b/Tiku.Api/Contracts/ContentManagementDtos.cs new file mode 100644 index 0000000..4e3be02 --- /dev/null +++ b/Tiku.Api/Contracts/ContentManagementDtos.cs @@ -0,0 +1,363 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using Tiku.Application.Content; +using Tiku.Domain.Common; + +namespace Tiku.Api.Contracts; + +public sealed class ContentManagementQueryDto +{ + public Guid? RegionId { get; set; } + + public Guid? EntryId { get; set; } + + public Guid? NodeId { get; set; } + + public Guid? CollectionId { get; set; } + + [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); + } +} + +public sealed class UpsertContentEntryDto +{ + public Guid? Id { get; set; } + + public Guid? RegionId { get; set; } + + [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); + } +} + +public sealed class UpsertContentNodeDto +{ + public Guid? Id { get; set; } + + [Required] + public Guid EntryId { get; set; } + + public Guid? RegionId { get; set; } + + public Guid? ParentId { get; set; } + + [StringLength(64)] + public string? LegacyId { get; set; } + + [StringLength(100)] + public string? NodeKey { get; set; } + + [Required] + [StringLength(300)] + public string Name { get; set; } = string.Empty; + + [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); + } +} + +public sealed class UpsertQuestionCollectionDto +{ + public Guid? Id { get; set; } + + public Guid? RegionId { get; set; } + + public Guid? EntryId { get; set; } + + public Guid? NodeId { get; set; } + + public Guid? SubjectId { get; set; } + + public Guid? CategoryId { get; set; } + + public Guid? QuestionBankId { get; set; } + + [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); + } +} + +public sealed class CollectionQuestionDto +{ + [Required] + public Guid QuestionId { get; set; } + + [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(QuestionId, SectionKey, Order, Score, Required, Metadata); + } +} + +public sealed class ReplaceCollectionItemsDto +{ + [Required] + public Guid CollectionId { get; set; } + + public IReadOnlyCollection Questions { get; set; } = []; + + public ReplaceCollectionItemsCommand ToCommand() + { + return new ReplaceCollectionItemsCommand( + CollectionId, + Questions.Select(question => question.ToCommand()).ToArray()); + } +} + +public sealed class UpsertPracticeBlueprintDto +{ + public Guid? Id { get; set; } + + public Guid? RegionId { get; set; } + + public Guid? EntryId { get; set; } + + public Guid? NodeId { get; set; } + + public Guid? CollectionId { get; set; } + + [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); + } +} + +public sealed class ImportTemplateQueryDto +{ + [Required] + [StringLength(50)] + public string ImportType { get; set; } = string.Empty; + + [StringLength(10)] + public string? Format { get; set; } +} diff --git a/Tiku.Api/Controllers/TenantContentController.cs b/Tiku.Api/Controllers/TenantContentController.cs index 1b055d5..d4cf11c 100644 --- a/Tiku.Api/Controllers/TenantContentController.cs +++ b/Tiku.Api/Controllers/TenantContentController.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc; using Tiku.Api.Contracts; using Tiku.Application.Assets; using Tiku.Application.Catalog; +using Tiku.Application.Content; using Tiku.Application.Security; namespace Tiku.Api.Controllers; @@ -13,9 +14,145 @@ namespace Tiku.Api.Controllers; [Route("api/tenant-content")] public sealed class TenantContentController( IAssetManagementService assetManagementService, + IContentManagementService contentManagementService, ICurrentUser currentUser, ICurrentTenant currentTenant) : ControllerBase { + [HttpGet("entries")] + [EndpointSummary("查询租户内容入口")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetEntries( + [FromQuery] ContentManagementQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await contentManagementService.GetEntriesAsync( + ResolveContentActor(), + query.ToFilter(), + cancellationToken)); + } + + [HttpPost("entries")] + [EndpointSummary("创建或更新内容入口")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> UpsertEntry( + UpsertContentEntryDto request, + CancellationToken cancellationToken) + { + return Ok(await contentManagementService.UpsertEntryAsync( + ResolveContentActor(), + request.ToCommand(), + cancellationToken)); + } + + [HttpGet("nodes")] + [EndpointSummary("查询租户内容节点")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetNodes( + [FromQuery] ContentManagementQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await contentManagementService.GetNodesAsync( + ResolveContentActor(), + query.ToFilter(), + cancellationToken)); + } + + [HttpPost("nodes")] + [EndpointSummary("创建或更新内容节点")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> UpsertNode( + UpsertContentNodeDto request, + CancellationToken cancellationToken) + { + return Ok(await contentManagementService.UpsertNodeAsync( + ResolveContentActor(), + request.ToCommand(), + cancellationToken)); + } + + [HttpGet("question-collections")] + [EndpointSummary("查询租户题集")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetQuestionCollections( + [FromQuery] ContentManagementQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await contentManagementService.GetCollectionsAsync( + ResolveContentActor(), + query.ToFilter(), + cancellationToken)); + } + + [HttpPost("question-collections")] + [EndpointSummary("创建或更新题集")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> UpsertQuestionCollection( + UpsertQuestionCollectionDto request, + CancellationToken cancellationToken) + { + return Ok(await contentManagementService.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 contentManagementService.ReplaceCollectionItemsAsync( + ResolveContentActor(), + request.ToCommand(), + cancellationToken)); + } + + [HttpGet("practice-blueprints")] + [EndpointSummary("查询练习蓝图")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetPracticeBlueprints( + [FromQuery] ContentManagementQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await contentManagementService.GetPracticeBlueprintsAsync( + ResolveContentActor(), + query.ToFilter(), + cancellationToken)); + } + + [HttpPost("practice-blueprints")] + [EndpointSummary("创建或更新练习蓝图")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> UpsertPracticeBlueprint( + UpsertPracticeBlueprintDto request, + CancellationToken cancellationToken) + { + return Ok(await contentManagementService.UpsertPracticeBlueprintAsync( + ResolveContentActor(), + request.ToCommand(), + cancellationToken)); + } + + [HttpGet("imports/field-mapping")] + [EndpointSummary("查询导入字段映射")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult GetImportFieldMapping([FromQuery] ImportTemplateQueryDto query) + { + _ = ResolveContentActor(); + return Ok(contentManagementService.GetImportFieldMapping(query.ImportType)); + } + + [HttpGet("imports/templates")] + [EndpointSummary("获取内容导入模板")] + [ProducesResponseType(StatusCodes.Status200OK)] + public ActionResult GetImportTemplate([FromQuery] ImportTemplateQueryDto query) + { + _ = ResolveContentActor(); + return Ok(contentManagementService.GetImportTemplate(query.ImportType, query.Format)); + } + [HttpGet("assets")] [EndpointSummary("查询租户内容资产")] [ProducesResponseType>(StatusCodes.Status200OK)] @@ -95,4 +232,14 @@ 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/Middleware/ExceptionHandlingMiddleware.cs b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs index aece8f7..a3e7d5e 100644 --- a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs +++ b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc; using Tiku.Api.Controllers; using Tiku.Application.Assets; using Tiku.Application.Auth; +using Tiku.Application.Content; using Tiku.Application.Storage; using Tiku.Infrastructure.Content; using Tiku.Infrastructure.Learning; @@ -98,6 +99,16 @@ public sealed class ExceptionHandlingMiddleware( return; } + if (exception is ContentManagementException contentManagementException) + { + await WriteProblemAsync( + context, + contentManagementException.Message, + ContentManagementStatusCode(contentManagementException.Code), + contentManagementException.Code); + return; + } + if (exception is LearningValidationException learningValidationException) { await WriteProblemAsync( @@ -230,4 +241,15 @@ public sealed class ExceptionHandlingMiddleware( _ => StatusCodes.Status400BadRequest }; } + + private static int ContentManagementStatusCode(string code) + { + return code switch + { + "entry_not_found" or "node_not_found" or "collection_not_found" or "question_not_found" or + "import_type_invalid" => StatusCodes.Status404NotFound, + "tenant_content_access_denied" => StatusCodes.Status403Forbidden, + _ => StatusCodes.Status400BadRequest + }; + } } diff --git a/Tiku.Application/Content/ContentManagementModels.cs b/Tiku.Application/Content/ContentManagementModels.cs new file mode 100644 index 0000000..717d16f --- /dev/null +++ b/Tiku.Application/Content/ContentManagementModels.cs @@ -0,0 +1,238 @@ +using System.Text.Json; +using Tiku.Application.Catalog; +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( + Guid QuestionId, + 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, + 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; +} diff --git a/Tiku.Application/Content/IContentManagementService.cs b/Tiku.Application/Content/IContentManagementService.cs new file mode 100644 index 0000000..6080d44 --- /dev/null +++ b/Tiku.Application/Content/IContentManagementService.cs @@ -0,0 +1,55 @@ +using Tiku.Application.Catalog; + +namespace Tiku.Application.Content; + +public interface IContentManagementService +{ + Task> GetEntriesAsync( + ContentManagementActor actor, + ContentManagementFilter filter, + CancellationToken cancellationToken = default); + + Task> UpsertEntryAsync( + ContentManagementActor actor, + UpsertContentEntryCommand command, + CancellationToken cancellationToken = default); + + Task> GetNodesAsync( + ContentManagementActor actor, + ContentManagementFilter filter, + CancellationToken cancellationToken = default); + + Task> UpsertNodeAsync( + ContentManagementActor actor, + UpsertContentNodeCommand command, + CancellationToken cancellationToken = default); + + 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); + + 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.Infrastructure/Content/ContentManagementService.cs b/Tiku.Infrastructure/Content/ContentManagementService.cs new file mode 100644 index 0000000..c2078d8 --- /dev/null +++ b/Tiku.Infrastructure/Content/ContentManagementService.cs @@ -0,0 +1,982 @@ +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Catalog; +using Tiku.Application.Content; +using Tiku.Domain.Catalog; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.QuestionBanks; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Content; + +public sealed class ContentManagementService(TikuDbContext dbContext) : IContentManagementService +{ + private const int DefaultLimit = 100; + private const int MaxLimit = 1000; + + public async Task> GetEntriesAsync( + ContentManagementActor actor, + ContentManagementFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.ContentEntries + .AsNoTracking() + .Where(entry => entry.TenantId == actor.TenantId); + + if (!filter.IncludeInactive) + { + query = query.Where(entry => entry.IsActive); + } + + if (filter.RegionId.HasValue) + { + query = query.Where(entry => entry.RegionId == filter.RegionId.Value); + } + + if (TryParse(filter.EntryType, out ContentEntryType entryType)) + { + query = query.Where(entry => entry.EntryType == entryType); + } + + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(entry => + entry.Name.Contains(keyword) || + entry.EntryKey.Contains(keyword) || + (entry.Description != null && entry.Description.Contains(keyword))); + } + + var items = await query + .OrderBy(entry => entry.SortOrder) + .ThenBy(entry => entry.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .Select(entry => ToEntryItem(entry)) + .ToArrayAsync(cancellationToken); + + return new CatalogList(items); + } + + public async Task> UpsertEntryAsync( + ContentManagementActor actor, + UpsertContentEntryCommand command, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); + await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken); + + var entryKey = Normalize(command.EntryKey) ?? + Normalize(command.Id?.ToString("N")) ?? + Guid.NewGuid().ToString("N"); + var entry = await ResolveEntityAsync( + dbContext.ContentEntries, + actor.TenantId, + command.Id, + item => item.EntryKey == entryKey, + cancellationToken); + + var isNew = entry is null; + entry ??= new ContentEntry + { + Id = command.Id ?? Guid.NewGuid(), + TenantId = actor.TenantId, + EntryKey = entryKey, + CreatedBy = actor.UserId + }; + + entry.RegionId = command.RegionId; + entry.LegacyId = Normalize(command.LegacyId); + entry.Name = command.Name.Trim(); + entry.EntryType = Parse(command.EntryType, ContentEntryType.QuestionPractice, "entry_type_invalid"); + entry.Icon = Normalize(command.Icon); + entry.Route = Normalize(command.Route); + entry.Description = Normalize(command.Description); + entry.Visibility = Parse(command.Visibility, ContentVisibility.Public, "visibility_invalid"); + entry.AccessRules = JsonObjectOrDefault(command.AccessRules); + entry.LayoutConfig = JsonObjectOrDefault(command.LayoutConfig); + entry.SortOrder = command.Order ?? 0; + entry.IsActive = command.IsActive ?? true; + entry.UpdatedBy = actor.UserId; + + if (isNew) + { + dbContext.ContentEntries.Add(entry); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(ToEntryItem(entry)); + } + + public async Task> GetNodesAsync( + ContentManagementActor actor, + ContentManagementFilter filter, + CancellationToken cancellationToken = default) + { + if (!filter.EntryId.HasValue) + { + throw new ContentManagementException("entryId is required.", "entry_id_required"); + } + + var query = dbContext.ContentNodes + .AsNoTracking() + .Where(node => node.TenantId == actor.TenantId && node.EntryId == filter.EntryId.Value); + + if (!filter.IncludeInactive) + { + query = query.Where(node => node.IsActive); + } + + if (filter.RegionId.HasValue) + { + query = query.Where(node => node.RegionId == filter.RegionId.Value); + } + + if (filter.ParentId is not null) + { + if (string.Equals(filter.ParentId, "root", StringComparison.OrdinalIgnoreCase)) + { + query = query.Where(node => node.ParentId == null); + } + else if (Guid.TryParse(filter.ParentId, out var parentId)) + { + query = query.Where(node => node.ParentId == parentId); + } + } + + if (TryParse(filter.MarkerType, out ContentMarkerType markerType)) + { + query = query.Where(node => node.MarkerType == markerType); + } + + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(node => + node.Name.Contains(keyword) || + (node.NodeKey != null && node.NodeKey.Contains(keyword))); + } + + query = string.Equals(filter.Mode, "flat", StringComparison.OrdinalIgnoreCase) + ? query.OrderBy(node => node.Path).ThenBy(node => node.SortOrder) + : query.OrderBy(node => node.SortOrder).ThenBy(node => node.CreatedAt); + + var items = await query + .Take(ResolveLimit(filter.Limit)) + .Select(node => ToNodeItem(node)) + .ToArrayAsync(cancellationToken); + + return new CatalogList(items); + } + + public async Task> UpsertNodeAsync( + ContentManagementActor actor, + UpsertContentNodeCommand command, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); + await AssertEntryAsync(actor.TenantId, command.EntryId, cancellationToken); + await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken); + + var nodeKey = Normalize(command.NodeKey) ?? + Normalize(command.Id?.ToString("N")) ?? + Guid.NewGuid().ToString("N"); + var node = await ResolveEntityAsync( + dbContext.ContentNodes, + actor.TenantId, + command.Id, + item => item.EntryId == command.EntryId && item.NodeKey == nodeKey, + cancellationToken); + + var isNew = node is null; + node ??= new ContentNode + { + Id = command.Id ?? Guid.NewGuid(), + TenantId = actor.TenantId, + EntryId = command.EntryId, + NodeKey = nodeKey, + CreatedBy = actor.UserId + }; + + var path = await BuildNodePathAsync(actor.TenantId, command.EntryId, node.Id, command.ParentId, cancellationToken); + node.EntryId = command.EntryId; + node.RegionId = command.RegionId; + node.ParentId = command.ParentId; + node.LegacyId = Normalize(command.LegacyId); + node.Name = command.Name.Trim(); + node.NodeType = Parse(command.NodeType, ContentNodeType.Category, "node_type_invalid"); + node.MarkerType = ParseNullable(command.MarkerType, "marker_type_invalid"); + node.MarkerConfig = JsonObjectOrDefault(command.MarkerConfig); + node.Path = path.Path; + node.Depth = path.Depth; + node.SortOrder = command.Order ?? 0; + node.IsActive = command.IsActive ?? true; + node.IsSelectable = command.IsSelectable ?? true; + node.IsLeaf = command.IsLeaf ?? false; + node.AccessRules = JsonObjectOrDefault(command.AccessRules); + node.Metadata = JsonObjectOrDefault(command.Metadata); + node.UpdatedBy = actor.UserId; + + if (isNew) + { + dbContext.ContentNodes.Add(node); + } + + if (command.ParentId.HasValue) + { + var parent = await dbContext.ContentNodes.SingleOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.Id == command.ParentId.Value, + cancellationToken); + if (parent is not null) + { + parent.IsLeaf = false; + } + } + + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(ToNodeItem(node)); + } + + public async Task> GetCollectionsAsync( + ContentManagementActor actor, + ContentManagementFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.QuestionCollections + .AsNoTracking() + .Where(collection => collection.TenantId == actor.TenantId); + + if (!filter.IncludeInactive) + { + query = query.Where(collection => collection.Status == ContentStatus.Active); + } + + if (filter.RegionId.HasValue) + { + query = query.Where(collection => collection.RegionId == filter.RegionId.Value); + } + + if (filter.EntryId.HasValue) + { + query = query.Where(collection => collection.EntryId == filter.EntryId.Value); + } + + if (filter.NodeId.HasValue) + { + query = query.Where(collection => collection.NodeId == filter.NodeId.Value); + } + + if (TryParse(filter.CollectionType, out QuestionCollectionType collectionType)) + { + query = query.Where(collection => collection.CollectionType == collectionType); + } + + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(collection => collection.Name.Contains(keyword)); + } + + var items = await query + .OrderBy(collection => collection.SortOrder) + .ThenBy(collection => collection.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .Select(collection => ToCollectionItem(collection)) + .ToArrayAsync(cancellationToken); + + return new CatalogList(items); + } + + public async Task> UpsertCollectionAsync( + ContentManagementActor actor, + UpsertQuestionCollectionCommand command, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); + await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken); + await AssertEntryAsync(actor.TenantId, command.EntryId, cancellationToken); + await AssertNodeAsync(actor.TenantId, command.NodeId, cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.SubjectId, "subject_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.CategoryId, "category_not_found", cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.QuestionBankId, "question_bank_not_found", cancellationToken); + + var collection = await ResolveEntityByIdOrLegacyAsync( + dbContext.QuestionCollections, + actor.TenantId, + command.Id, + command.LegacyId, + cancellationToken); + + var isNew = collection is null; + collection ??= new QuestionCollection + { + Id = command.Id ?? Guid.NewGuid(), + TenantId = actor.TenantId, + CreatedBy = actor.UserId + }; + + collection.RegionId = command.RegionId; + collection.EntryId = command.EntryId; + collection.NodeId = command.NodeId; + collection.SubjectId = command.SubjectId; + collection.CategoryId = command.CategoryId; + collection.QuestionBankId = command.QuestionBankId; + collection.LegacyId = Normalize(command.LegacyId); + collection.Name = command.Name.Trim(); + collection.CollectionType = Parse(command.CollectionType, QuestionCollectionType.Dynamic, "collection_type_invalid"); + collection.SourceType = Parse(command.SourceType, QuestionCollectionSourceType.Filters, "collection_source_type_invalid"); + collection.Filters = JsonObjectOrDefault(command.Filters); + collection.TotalScore = command.TotalScore; + collection.DurationMinutes = command.DurationMinutes; + collection.Status = Parse(command.Status, ContentStatus.Active, "content_status_invalid"); + collection.SortOrder = command.Order ?? 0; + collection.AccessRules = JsonObjectOrDefault(command.AccessRules); + collection.Metadata = JsonObjectOrDefault(command.Metadata); + collection.UpdatedBy = actor.UserId; + + if (isNew) + { + dbContext.QuestionCollections.Add(collection); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(ToCollectionItem(collection)); + } + + public async Task ReplaceCollectionItemsAsync( + ContentManagementActor actor, + ReplaceCollectionItemsCommand command, + CancellationToken cancellationToken = default) + { + var collection = await dbContext.QuestionCollections.SingleOrDefaultAsync( + item => item.TenantId == actor.TenantId && item.Id == command.CollectionId, + cancellationToken); + + if (collection is null) + { + throw new ContentManagementException("Collection was not found.", "collection_not_found"); + } + + var questionIds = command.Questions.Select(item => item.QuestionId).Distinct().ToArray(); + var existingQuestions = await dbContext.Questions + .Where(question => question.TenantId == actor.TenantId && questionIds.Contains(question.Id)) + .Select(question => question.Id) + .ToArrayAsync(cancellationToken); + + if (existingQuestions.Length != questionIds.Length) + { + throw new ContentManagementException("One or more questions are not in this tenant.", "question_not_found"); + } + + var oldItems = await dbContext.QuestionCollectionItems + .Where(item => item.TenantId == actor.TenantId && item.CollectionId == command.CollectionId) + .ToArrayAsync(cancellationToken); + dbContext.QuestionCollectionItems.RemoveRange(oldItems); + + var items = command.Questions + .Select((question, index) => new QuestionCollectionItem + { + TenantId = actor.TenantId, + CollectionId = command.CollectionId, + QuestionId = question.QuestionId, + SectionKey = Normalize(question.SectionKey), + SortOrder = question.Order ?? index, + Score = question.Score, + Required = question.Required ?? true, + Metadata = JsonObjectOrDefault(question.Metadata) + }) + .ToArray(); + + dbContext.QuestionCollectionItems.AddRange(items); + collection.QuestionCount = items.Length; + collection.UpdatedBy = actor.UserId; + await dbContext.SaveChangesAsync(cancellationToken); + + return new CollectionItemsReplaceResult( + command.CollectionId, + collection.QuestionCount, + items.Select(ToCollectionItemItem).ToArray()); + } + + public async Task> GetPracticeBlueprintsAsync( + ContentManagementActor actor, + ContentManagementFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.PracticeBlueprints + .AsNoTracking() + .Where(blueprint => blueprint.TenantId == actor.TenantId); + + if (!filter.IncludeInactive) + { + query = query.Where(blueprint => blueprint.Status == ContentStatus.Active); + } + + if (filter.RegionId.HasValue) + { + query = query.Where(blueprint => blueprint.RegionId == filter.RegionId.Value); + } + + if (filter.EntryId.HasValue) + { + query = query.Where(blueprint => blueprint.EntryId == filter.EntryId.Value); + } + + if (filter.NodeId.HasValue) + { + query = query.Where(blueprint => blueprint.NodeId == filter.NodeId.Value); + } + + if (filter.CollectionId.HasValue) + { + query = query.Where(blueprint => blueprint.CollectionId == filter.CollectionId.Value); + } + + if (TryParse(filter.Mode, out PracticeMode mode)) + { + query = query.Where(blueprint => blueprint.Mode == mode); + } + + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(blueprint => blueprint.Name.Contains(keyword)); + } + + var items = await query + .OrderBy(blueprint => blueprint.SortOrder) + .ThenBy(blueprint => blueprint.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .Select(blueprint => ToBlueprintItem(blueprint)) + .ToArrayAsync(cancellationToken); + + return new CatalogList(items); + } + + public async Task> UpsertPracticeBlueprintAsync( + ContentManagementActor actor, + UpsertPracticeBlueprintCommand command, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); + await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken); + await AssertEntryAsync(actor.TenantId, command.EntryId, cancellationToken); + await AssertNodeAsync(actor.TenantId, command.NodeId, cancellationToken); + await AssertReferenceAsync(actor.TenantId, command.CollectionId, "collection_not_found", cancellationToken); + + var blueprint = await ResolveEntityByIdOrLegacyAsync( + dbContext.PracticeBlueprints, + actor.TenantId, + command.Id, + command.LegacyId, + cancellationToken); + + var isNew = blueprint is null; + blueprint ??= new PracticeBlueprint + { + Id = command.Id ?? Guid.NewGuid(), + TenantId = actor.TenantId, + CreatedBy = actor.UserId + }; + + blueprint.RegionId = command.RegionId; + blueprint.EntryId = command.EntryId; + blueprint.NodeId = command.NodeId; + blueprint.CollectionId = command.CollectionId; + blueprint.LegacyId = Normalize(command.LegacyId); + blueprint.Name = command.Name.Trim(); + blueprint.Mode = Parse(command.Mode, PracticeMode.Sequential, "practice_mode_invalid"); + blueprint.AssemblyType = Parse(command.AssemblyType, PracticeAssemblyType.Collection, "practice_assembly_type_invalid"); + blueprint.QuestionLimit = command.QuestionLimit; + blueprint.DurationMinutes = command.DurationMinutes; + blueprint.TotalScore = command.TotalScore; + blueprint.PassScore = command.PassScore; + blueprint.Sections = JsonArrayOrDefault(command.Sections); + blueprint.Rules = JsonObjectOrDefault(command.Rules); + blueprint.AccessRules = JsonObjectOrDefault(command.AccessRules); + blueprint.Status = Parse(command.Status, ContentStatus.Active, "content_status_invalid"); + blueprint.SortOrder = command.Order ?? 0; + blueprint.UpdatedBy = actor.UserId; + + if (isNew) + { + dbContext.PracticeBlueprints.Add(blueprint); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return new ContentManagementResult(ToBlueprintItem(blueprint)); + } + + public ImportFieldMappingItem GetImportFieldMapping(string importType) + { + var spec = ResolveImportSpec(importType); + return new ImportFieldMappingItem( + spec.ImportType, + spec.Title, + spec.Description, + spec.Fields, + spec.Fields.Where(field => field.Required).Select(field => field.Field).ToArray()); + } + + public ImportTemplateItem GetImportTemplate(string importType, string? format) + { + var spec = ResolveImportSpec(importType); + var normalizedFormat = string.IsNullOrWhiteSpace(format) ? "json" : format.Trim().ToLowerInvariant(); + var content = normalizedFormat switch + { + "json" => JsonSerializer.Serialize(spec.JsonExample, new JsonSerializerOptions { WriteIndented = true }), + "csv" => string.Join( + "\n", + spec.CsvRows.Select(row => string.Join(",", row.Select(EscapeCsv)))), + _ => throw new ContentManagementException("Import template format is not supported.", "import_template_format_invalid") + }; + + return new ImportTemplateItem( + spec.ImportType, + normalizedFormat, + $"{spec.ImportType}-import-template.{normalizedFormat}", + normalizedFormat == "csv" ? "text/csv" : "application/json", + Convert.ToBase64String(Encoding.UTF8.GetBytes(content)), + content, + spec.Fields); + } + + private async Task<(string Path, int Depth)> BuildNodePathAsync( + Guid tenantId, + Guid entryId, + Guid nodeId, + Guid? parentId, + CancellationToken cancellationToken) + { + var label = $"n_{nodeId:N}"; + if (!parentId.HasValue) + { + return (label, 0); + } + + var parent = await dbContext.ContentNodes + .AsNoTracking() + .Where(node => node.TenantId == tenantId && node.EntryId == entryId && node.Id == parentId.Value) + .Select(node => new { node.Path, node.Depth }) + .SingleOrDefaultAsync(cancellationToken); + + if (parent is null) + { + throw new ContentManagementException("Parent node was not found in this entry.", "parent_node_not_found"); + } + + return ($"{parent.Path}.{label}", parent.Depth + 1); + } + + private async Task AssertRegionAsync(Guid tenantId, Guid? regionId, CancellationToken cancellationToken) + { + await AssertReferenceAsync(tenantId, regionId, "region_not_found", cancellationToken); + } + + private async Task AssertEntryAsync(Guid tenantId, Guid? entryId, CancellationToken cancellationToken) + { + await AssertReferenceAsync(tenantId, entryId, "entry_not_found", cancellationToken); + } + + private async Task AssertNodeAsync(Guid tenantId, Guid? nodeId, CancellationToken cancellationToken) + { + await AssertReferenceAsync(tenantId, nodeId, "node_not_found", cancellationToken); + } + + private async Task AssertReferenceAsync( + Guid tenantId, + Guid? id, + string code, + CancellationToken cancellationToken) + where TEntity : class + { + if (!id.HasValue) + { + return; + } + + var exists = await dbContext.Set() + .AnyAsync(entity => + EF.Property(entity, nameof(ContentEntry.TenantId)) == tenantId && + EF.Property(entity, nameof(ContentEntry.Id)) == id.Value, + cancellationToken); + + if (!exists) + { + throw new ContentManagementException("Referenced entity was not found in this tenant.", code); + } + } + + private static async Task ResolveEntityAsync( + DbSet set, + Guid tenantId, + Guid? id, + System.Linq.Expressions.Expression> alternatePredicate, + CancellationToken cancellationToken) + where TEntity : class + { + if (id.HasValue) + { + var byId = await set.SingleOrDefaultAsync(entity => + EF.Property(entity, nameof(ContentEntry.TenantId)) == tenantId && + EF.Property(entity, nameof(ContentEntry.Id)) == id.Value, + cancellationToken); + if (byId is not null) + { + return byId; + } + } + + return await set + .Where(entity => EF.Property(entity, nameof(ContentEntry.TenantId)) == tenantId) + .SingleOrDefaultAsync(alternatePredicate, cancellationToken); + } + + private static async Task ResolveEntityByIdOrLegacyAsync( + DbSet set, + Guid tenantId, + Guid? id, + string? legacyId, + CancellationToken cancellationToken) + where TEntity : class + { + if (id.HasValue) + { + var byId = await set.SingleOrDefaultAsync(entity => + EF.Property(entity, nameof(ContentEntry.TenantId)) == tenantId && + EF.Property(entity, nameof(ContentEntry.Id)) == id.Value, + cancellationToken); + if (byId is not null) + { + return byId; + } + } + + var normalizedLegacyId = Normalize(legacyId); + if (normalizedLegacyId is null) + { + return null; + } + + return await set.SingleOrDefaultAsync(entity => + EF.Property(entity, nameof(ContentEntry.TenantId)) == tenantId && + EF.Property(entity, nameof(ContentEntry.LegacyId)) == normalizedLegacyId, + cancellationToken); + } + + private static ContentEntryManagementItem ToEntryItem(ContentEntry entry) + { + return new ContentEntryManagementItem( + entry.Id, + entry.RegionId, + entry.LegacyId, + entry.EntryKey, + entry.Name, + entry.EntryType, + entry.Icon, + entry.Route, + entry.Description, + entry.Visibility, + entry.AccessRules, + entry.LayoutConfig, + entry.SortOrder, + entry.IsActive, + entry.CreatedAt, + entry.UpdatedAt); + } + + private static ContentNodeManagementItem ToNodeItem(ContentNode node) + { + return new ContentNodeManagementItem( + node.Id, + node.EntryId, + node.RegionId, + node.ParentId, + node.LegacyId, + node.NodeKey, + node.Name, + node.NodeType, + node.MarkerType, + node.MarkerConfig, + node.Path, + node.Depth, + node.SortOrder, + node.IsActive, + node.IsSelectable, + node.IsLeaf, + node.AccessRules, + node.Metadata, + node.CreatedAt, + node.UpdatedAt); + } + + private static QuestionCollectionManagementItem ToCollectionItem(QuestionCollection collection) + { + return new QuestionCollectionManagementItem( + collection.Id, + collection.RegionId, + collection.EntryId, + collection.NodeId, + collection.SubjectId, + collection.CategoryId, + collection.QuestionBankId, + collection.LegacyId, + collection.Name, + collection.CollectionType, + collection.SourceType, + collection.Filters, + collection.QuestionCount, + collection.TotalScore, + collection.DurationMinutes, + collection.Status, + collection.SortOrder, + collection.AccessRules, + collection.Metadata, + collection.CreatedAt, + collection.UpdatedAt); + } + + private static QuestionCollectionItemManagementItem ToCollectionItemItem(QuestionCollectionItem item) + { + return new QuestionCollectionItemManagementItem( + item.Id, + item.CollectionId, + item.QuestionId, + item.SectionKey, + item.SortOrder, + item.Score, + item.Required, + item.Metadata); + } + + private static PracticeBlueprintManagementItem ToBlueprintItem(PracticeBlueprint blueprint) + { + return new PracticeBlueprintManagementItem( + blueprint.Id, + blueprint.RegionId, + blueprint.EntryId, + blueprint.NodeId, + blueprint.CollectionId, + blueprint.LegacyId, + blueprint.Name, + blueprint.Mode, + blueprint.AssemblyType, + blueprint.QuestionLimit, + blueprint.DurationMinutes, + blueprint.TotalScore, + blueprint.PassScore, + blueprint.Sections, + blueprint.Rules, + blueprint.AccessRules, + blueprint.Status, + blueprint.SortOrder, + blueprint.CreatedAt, + blueprint.UpdatedAt); + } + + private static int ResolveLimit(int? limit) + { + return limit is > 0 ? Math.Min(limit.Value, MaxLimit) : DefaultLimit; + } + + private static string? Normalize(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } + + private static JsonElement JsonObjectOrDefault(JsonElement value) + { + return value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null + ? JsonDefaults.Object() + : value; + } + + private static JsonElement JsonArrayOrDefault(JsonElement value) + { + return value.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null + ? JsonDefaults.Array() + : value; + } + + private static TEnum Parse(string? value, TEnum fallback, string code) + where TEnum : struct + { + if (string.IsNullOrWhiteSpace(value)) + { + return fallback; + } + + if (Enum.TryParse(value, ignoreCase: true, out var parsed)) + { + return parsed; + } + + throw new ContentManagementException("Invalid enum value.", code); + } + + private static TEnum? ParseNullable(string? value, string code) + where TEnum : struct + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + if (Enum.TryParse(value, ignoreCase: true, out var parsed)) + { + return parsed; + } + + throw new ContentManagementException("Invalid enum value.", code); + } + + private static bool TryParse(string? value, out TEnum parsed) + where TEnum : struct + { + return Enum.TryParse(value, ignoreCase: true, out parsed); + } + + private static string EscapeCsv(string value) + { + return value.Contains(',') || value.Contains('"') || value.Contains('\n') + ? $"\"{value.Replace("\"", "\"\"", StringComparison.Ordinal)}\"" + : value; + } + + private static ImportSpec ResolveImportSpec(string importType) + { + var normalized = importType.Trim().ToLowerInvariant(); + return Specs.TryGetValue(normalized, out var spec) + ? spec + : throw new ContentManagementException("Import type is not supported.", "import_type_invalid"); + } + + private sealed record ImportSpec( + string ImportType, + string Title, + string Description, + IReadOnlyCollection Fields, + string[][] CsvRows, + object JsonExample); + + private static ImportFieldSpec Field( + string field, + string label, + bool required, + string[] aliases, + string description, + object example) + { + return new ImportFieldSpec( + field, + label, + required, + aliases, + description, + JsonSerializer.SerializeToElement(example)); + } + + private static readonly IReadOnlyDictionary Specs = + new Dictionary(StringComparer.Ordinal) + { + ["questions"] = new( + "questions", + "题目导入模板", + "用于导入刷题题库,后端会校验题型、答案、目标科目、分类、题集和租户隔离。", + [ + Field("legacyId", "旧系统 ID", false, ["legacy_id", "externalId", "id"], "用于幂等更新。", "tj-english-2026-001"), + Field("type", "题型", true, ["题型", "questionType"], "choice、multi、judge、reading、short_answer 等。", "choice"), + Field("content", "题干", true, ["题干", "stem", "question"], "支持 Markdown、图片 URL 和公式。", "多租户 SaaS 最重要的安全边界是什么?"), + Field("options", "选项", false, ["选项", "choices"], "客观题选项。", new[] { "前端隐藏", "后端权限" }), + Field("correctOptionIndices", "正确选项索引", false, ["答案", "answer"], "从 0 开始;CSV 可用 A/B/C/D。", new[] { 1 }), + Field("answerText", "文字答案", false, ["主观题答案"], "主观题答案。", "以后端权限和数据库约束为准。"), + Field("explanation", "解析", false, ["解析", "analysis"], "题目解析内容。", "最终权限以后端强制为准。"), + Field("difficulty", "难度", false, ["难度"], "建议 1-5。", 2), + Field("tags", "标签", false, ["标签", "tag"], "JSON 数组或 CSV 中用 | 分隔。", new[] { "安全", "多租户" }) + ], + [ + ["legacyId", "type", "content", "选项A", "选项B", "答案", "explanation", "difficulty", "tags"], + ["tj-english-2026-001", "choice", "多租户 SaaS 最重要的安全边界是什么?", "前端隐藏", "后端权限", "B", "最终权限以后端强制为准。", "2", "安全|多租户"] + ], + new + { + items = new[] + { + new + { + legacyId = "tj-english-2026-001", + type = "choice", + content = "多租户 SaaS 最重要的安全边界是什么?", + options = new[] { "前端隐藏", "后端权限" }, + correctOptionIndices = new[] { 1 }, + explanation = "最终权限以后端强制为准。", + difficulty = 2, + tags = new[] { "安全", "多租户" } + } + } + }), + ["vocabulary"] = new( + "vocabulary", + "单词导入模板", + "用于导入词汇单元和单词,后端会按单元归组并幂等写入。", + [ + Field("unitName", "单元名称", true, ["unit", "单元"], "单词所属单元。", "核心词汇 Unit 1"), + Field("word", "单词", true, ["单词"], "英文单词或词组。", "scale"), + Field("meaning", "释义", true, ["释义", "中文"], "中文释义。", "n. 规模;等级"), + Field("phonetic", "音标", false, ["音标"], "音标展示文本。", "/skeil/"), + Field("example", "例句", false, ["例句"], "英文例句。", "The platform must scale safely.") + ], + [ + ["unitName", "word", "phonetic", "meaning", "example", "difficulty", "tags"], + ["核心词汇 Unit 1", "scale", "/skeil/", "n. 规模;等级", "The platform must scale safely.", "2", "高频|SaaS"] + ], + new { units = new[] { new { name = "核心词汇 Unit 1", words = new[] { new { word = "scale", meaning = "n. 规模;等级" } } } } }), + ["handbook"] = new( + "handbook", + "知识手册导入模板", + "用于导入手册科目、章节、小节和知识点。", + [ + Field("subjectName", "手册科目", true, ["subject", "手册"], "知识手册顶层名称。", "专升本英语知识手册"), + Field("chapterName", "章节", true, ["chapter", "章节"], "章节名称。", "第一章 语法基础"), + Field("title", "知识点标题", true, ["entryTitle", "标题"], "知识点条目标题。", "that 引导的主语从句"), + Field("content", "正文", true, ["正文", "markdown"], "Markdown 正文。", "主语从句可放在句首。") + ], + [ + ["subjectName", "chapterName", "title", "content", "tags"], + ["专升本英语知识手册", "第一章 语法基础", "that 引导的主语从句", "主语从句可放在句首。", "语法"] + ], + new { subjects = new[] { new { name = "专升本英语知识手册", chapters = new[] { new { name = "第一章 语法基础" } } } } }), + ["scoreline"] = new( + "scoreline", + "分数线导入模板", + "用于导入动态字段、院校、专业和年份分数线记录。", + [ + Field("kind", "数据类型", true, ["type", "类型"], "field、school、major、record。", "record"), + Field("schoolName", "院校名称", false, ["school", "院校"], "院校名称。", "天津职业技术师范大学"), + Field("majorName", "专业名称", false, ["major", "专业"], "专业名称。", "软件工程"), + Field("year", "年份", false, ["年份"], "record 常用。", 2026), + Field("fieldValues", "字段值", false, ["values", "分数字段"], "record 的动态字段 JSON。", new { minScore = 188 }) + ], + [ + ["kind", "schoolName", "majorName", "year", "minScore"], + ["record", "天津职业技术师范大学", "软件工程", "2026", "188"] + ], + new { records = new[] { new { schoolName = "天津职业技术师范大学", majorName = "软件工程", year = 2026, fieldValues = new { minScore = 188 } } } }), + ["videos"] = new( + "videos", + "视频解析导入模板", + "用于导入视频解析元数据并绑定到题目。", + [ + Field("title", "标题", true, ["视频标题", "name"], "视频标题。", "多租户隔离题解析"), + Field("videoUrl", "视频 URL", false, ["video_url", "url"], "外部视频 URL。", "https://cdn.example.test/video.mp4"), + Field("assetId", "资源 ID", false, ["asset_id"], "对象存储资源台账 ID。", "00000000-0000-0000-0000-000000000000"), + Field("legacyQuestionId", "题目外部 ID", false, ["legacy_question_id"], "按旧题目 ID 绑定。", "tj-english-2026-001") + ], + [ + ["title", "videoUrl", "legacyQuestionId", "videoType"], + ["多租户隔离题解析", "https://cdn.example.test/video.mp4", "tj-english-2026-001", "specific"] + ], + new { videos = new[] { new { title = "多租户隔离题解析", videoUrl = "https://cdn.example.test/video.mp4" } } }) + }; +} diff --git a/Tiku.Infrastructure/DependencyInjection.cs b/Tiku.Infrastructure/DependencyInjection.cs index 6d73e00..f9c4e2e 100644 --- a/Tiku.Infrastructure/DependencyInjection.cs +++ b/Tiku.Infrastructure/DependencyInjection.cs @@ -44,6 +44,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/Tiku.IntegrationTests/Api/ContentManagementEndpointTests.cs b/Tiku.IntegrationTests/Api/ContentManagementEndpointTests.cs new file mode 100644 index 0000000..620d485 --- /dev/null +++ b/Tiku.IntegrationTests/Api/ContentManagementEndpointTests.cs @@ -0,0 +1,258 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Tiku.Api.Contracts; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.Identity; +using Tiku.Domain.QuestionBanks; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Auth; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.IntegrationTests.Api; + +public sealed class ContentManagementEndpointTests +{ + [Fact] + public async Task Tenant_content_management_requires_admin_authentication() + { + await using var factory = new ApiTestFactory(); + using var client = factory.CreateClient(); + + using var response = await client.GetAsync("/api/tenant-content/entries"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Tenant_admin_can_upsert_entries_and_nodes() + { + await using var factory = new ApiTestFactory(); + var seed = await SeedAdminAsync(factory); + using var client = factory.CreateClient(); + await LoginAsync(client, seed); + + using var entryResponse = await client.PostAsJsonAsync( + "/api/tenant-content/entries", + new UpsertContentEntryDto + { + EntryKey = "exam-practice", + Name = "刷题入口", + EntryType = "questionPractice", + Visibility = "members", + Order = 10 + }); + var entryJson = await ReadJsonAsync(entryResponse); + var entryId = entryJson.RootElement.GetProperty("item").GetProperty("id").GetGuid(); + + using var nodeResponse = await client.PostAsJsonAsync( + "/api/tenant-content/nodes", + new UpsertContentNodeDto + { + EntryId = entryId, + NodeKey = "chapter-1", + Name = "第一章", + NodeType = "chapter", + IsLeaf = true + }); + var nodeJson = await ReadJsonAsync(nodeResponse); + using var listResponse = await client.GetAsync($"/api/tenant-content/nodes?entryId={entryId}&parentId=root"); + var listJson = await ReadJsonAsync(listResponse); + + Assert.Equal(HttpStatusCode.OK, entryResponse.StatusCode); + Assert.Equal("exam-practice", entryJson.RootElement.GetProperty("item").GetProperty("entryKey").GetString()); + Assert.Equal(HttpStatusCode.OK, nodeResponse.StatusCode); + var node = nodeJson.RootElement.GetProperty("item"); + Assert.Equal(0, node.GetProperty("depth").GetInt32()); + Assert.StartsWith("n_", node.GetProperty("path").GetString(), StringComparison.Ordinal); + Assert.Single(listJson.RootElement.GetProperty("items").EnumerateArray()); + } + + [Fact] + public async Task Tenant_admin_can_upsert_collection_replace_items_and_create_blueprint() + { + await using var factory = new ApiTestFactory(); + var seed = await SeedAdminAsync(factory); + var questionId = Guid.NewGuid(); + await factory.SeedAsync(new Question + { + Id = questionId, + TenantId = seed.TenantId, + Type = "choice", + Status = QuestionStatus.Published + }); + using var client = factory.CreateClient(); + await LoginAsync(client, seed); + + var entryId = await CreateEntryAsync(client); + var collectionResponse = await client.PostAsJsonAsync( + "/api/tenant-content/question-collections", + new UpsertQuestionCollectionDto + { + EntryId = entryId, + Name = "基础题集", + CollectionType = "manual", + SourceType = "manualQuestions", + TotalScore = 100, + DurationMinutes = 45 + }); + var collectionJson = await ReadJsonAsync(collectionResponse); + var collectionId = collectionJson.RootElement.GetProperty("item").GetProperty("id").GetGuid(); + + var replaceResponse = await client.PostAsJsonAsync( + "/api/tenant-content/question-collections/items/replace", + new ReplaceCollectionItemsDto + { + CollectionId = collectionId, + Questions = + [ + new CollectionQuestionDto + { + QuestionId = questionId, + SectionKey = "choice", + Order = 1, + Score = 5, + Required = true + } + ] + }); + var replaceJson = await ReadJsonAsync(replaceResponse); + + var blueprintResponse = await client.PostAsJsonAsync( + "/api/tenant-content/practice-blueprints", + new UpsertPracticeBlueprintDto + { + EntryId = entryId, + CollectionId = collectionId, + Name = "章节练习", + Mode = "sequential", + AssemblyType = "collection", + QuestionLimit = 10, + TotalScore = 100 + }); + var blueprintJson = await ReadJsonAsync(blueprintResponse); + + Assert.Equal(HttpStatusCode.OK, collectionResponse.StatusCode); + Assert.Equal("Manual", collectionJson.RootElement.GetProperty("item").GetProperty("collectionType").GetString()); + Assert.Equal(HttpStatusCode.OK, replaceResponse.StatusCode); + Assert.Equal(1, replaceJson.RootElement.GetProperty("questionCount").GetInt32()); + Assert.Equal(HttpStatusCode.OK, blueprintResponse.StatusCode); + Assert.Equal(collectionId, blueprintJson.RootElement.GetProperty("item").GetProperty("collectionId").GetGuid()); + + using var scope = factory.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + Assert.Equal(1, dbContext.QuestionCollections.Single(item => item.Id == collectionId).QuestionCount); + Assert.Contains(dbContext.QuestionCollectionItems, item => item.QuestionId == questionId); + } + + [Fact] + public async Task Tenant_admin_can_get_import_field_mapping_and_templates() + { + await using var factory = new ApiTestFactory(); + var seed = await SeedAdminAsync(factory); + using var client = factory.CreateClient(); + await LoginAsync(client, seed); + + using var mappingResponse = await client.GetAsync("/api/tenant-content/imports/field-mapping?importType=questions"); + using var templateResponse = await client.GetAsync("/api/tenant-content/imports/templates?importType=questions&format=csv"); + var mapping = await ReadJsonAsync(mappingResponse); + var template = await ReadJsonAsync(templateResponse); + + Assert.Equal(HttpStatusCode.OK, mappingResponse.StatusCode); + Assert.Equal("questions", mapping.RootElement.GetProperty("importType").GetString()); + Assert.Contains(mapping.RootElement.GetProperty("requiredFields").EnumerateArray(), item => item.GetString() == "type"); + Assert.Equal(HttpStatusCode.OK, templateResponse.StatusCode); + Assert.Equal("csv", template.RootElement.GetProperty("format").GetString()); + Assert.NotEmpty(template.RootElement.GetProperty("contentBase64").GetString() ?? string.Empty); + } + + private static async Task CreateEntryAsync(HttpClient client) + { + using var response = await client.PostAsJsonAsync( + "/api/tenant-content/entries", + new UpsertContentEntryDto + { + EntryKey = Guid.NewGuid().ToString("N"), + Name = "刷题入口", + EntryType = "questionPractice" + }); + var json = await ReadJsonAsync(response); + return json.RootElement.GetProperty("item").GetProperty("id").GetGuid(); + } + + private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedAdminAsync(ApiTestFactory factory) + { + var tenantId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var phone = "13700000000"; + var passwordHash = new PasswordHasher().Hash("passw0rd!"); + + await factory.SeedAsync( + new Tenant + { + Id = tenantId, + Slug = tenantId.ToString("N"), + Name = "Test Tenant", + Status = TenantStatus.Active, + Metadata = JsonDefaults.Object() + }, + new User + { + Id = userId, + Phone = phone, + Name = "Tenant Admin" + }, + new TenantMembership + { + TenantId = tenantId, + UserId = userId, + Role = TenantRole.TenantAdmin, + Status = MembershipStatus.Active + }, + new UserIdentity + { + UserId = userId, + Provider = "password", + ProviderSubject = phone, + Phone = phone, + SecretPayload = CreateSecretPayload(passwordHash) + }); + + return (tenantId, userId, phone); + } + + private static async Task LoginAsync( + HttpClient client, + (Guid TenantId, Guid UserId, string Phone) seed) + { + var loginResponse = await client.PostAsJsonAsync( + "/api/auth/login/password", + new PasswordLoginDto + { + TenantId = seed.TenantId, + Phone = seed.Phone, + Password = "passw0rd!" + }); + var loginJson = await ReadJsonAsync(loginResponse); + var accessToken = loginJson.RootElement + .GetProperty("tokens") + .GetProperty("accessToken") + .GetString(); + client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); + } + + private static async Task ReadJsonAsync(HttpResponseMessage response) + { + var stream = await response.Content.ReadAsStreamAsync(); + return await JsonDocument.ParseAsync(stream); + } + + private static JsonElement CreateSecretPayload(string passwordHash) + { + using var document = JsonDocument.Parse( + $$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}"""); + return document.RootElement.Clone(); + } +}