using System.Text; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Catalog; using Tiku.Application.Content; using Tiku.Application.QuestionBanks; using Tiku.Application.Security; using Tiku.Domain.Catalog; using Tiku.Domain.Common; using Tiku.Domain.Content; using Tiku.Domain.QuestionBanks; using Tiku.Infrastructure.Persistence; using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Content; public sealed class ContentManagementService( TikuDbContext dbContext, IQuestionReferenceService questionReferenceService, ICurrentAccessContext currentAccessContext) : IContentManagementService { private const int DefaultLimit = 100; private const int MaxLimit = 1000; public async Task> GetEntriesAsync( ContentManagementActor actor, ContentManagementFilter filter, CancellationToken cancellationToken = default) { var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); var query = dbContext.ContentEntries .AsNoTracking() .Where(entry => entry.TenantId == actor.TenantId) .ApplyDataScope( scope, entry => entry.CreatedBy == actor.UserId, entry => entry.RegionId.HasValue && regionIds.Contains(entry.RegionId.Value)); 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) { var scope = await RequireDataScopeAsync(actor, cancellationToken); 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; if (command.Id.HasValue && (entry is null || entry.Id != command.Id.Value)) { throw new ContentManagementException("Content entry was not found.", "entry_not_found"); } if (entry is not null && !scope.AllowsResource(actor.UserId, entry.CreatedBy, entry.RegionId)) { throw new ContentManagementException("Content entry was not found.", "entry_not_found"); } if (entry is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId)) { throw new ContentManagementException("Content entry was not found.", "entry_not_found"); } 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) { var scope = await RequireDataScopeAsync(actor, cancellationToken); if (!filter.EntryId.HasValue) { throw new ContentManagementException("entryId is required.", "entry_id_required"); } await AssertEntryAsync(actor, scope, filter.EntryId, cancellationToken); var regionIds = scope.RegionIds.ToArray(); var query = dbContext.ContentNodes .AsNoTracking() .Where(node => node.TenantId == actor.TenantId && node.EntryId == filter.EntryId.Value) .ApplyDataScope( scope, node => node.CreatedBy == actor.UserId, node => node.RegionId.HasValue && regionIds.Contains(node.RegionId.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) { var scope = await RequireDataScopeAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); await AssertEntryAsync(actor, scope, command.EntryId, cancellationToken); await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken); await AssertNodeAsync(actor, scope, command.ParentId, 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; if (command.Id.HasValue && (node is null || node.Id != command.Id.Value)) { throw new ContentManagementException("Content node was not found.", "node_not_found"); } if (node is not null && !scope.AllowsResource(actor.UserId, node.CreatedBy, node.RegionId)) { throw new ContentManagementException("Content node was not found.", "node_not_found"); } if (node is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId)) { throw new ContentManagementException("Content node was not found.", "node_not_found"); } 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 scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); var query = dbContext.QuestionCollections .AsNoTracking() .Where(collection => collection.TenantId == actor.TenantId) .ApplyDataScope( scope, collection => collection.CreatedBy == actor.UserId, collection => collection.RegionId.HasValue && regionIds.Contains(collection.RegionId.Value)); 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) { var scope = await RequireDataScopeAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken); await AssertEntryAsync(actor, scope, command.EntryId, cancellationToken); await AssertNodeAsync(actor, scope, 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; if (command.Id.HasValue && (collection is null || collection.Id != command.Id.Value)) { throw new ContentManagementException("Collection was not found.", "collection_not_found"); } if (collection is not null && !scope.AllowsResource(actor.UserId, collection.CreatedBy, collection.RegionId)) { throw new ContentManagementException("Collection was not found.", "collection_not_found"); } if (collection is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId)) { throw new ContentManagementException("Collection was not found.", "collection_not_found"); } 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 scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); var collection = await dbContext.QuestionCollections .Where(item => item.TenantId == actor.TenantId && item.Id == command.CollectionId) .ApplyDataScope( scope, item => item.CreatedBy == actor.UserId, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)) .SingleOrDefaultAsync(cancellationToken); if (collection is null) { throw new ContentManagementException("Collection was not found.", "collection_not_found"); } var resolvedQuestions = new List<(CollectionQuestionCommand Command, TenantQuestionReference Reference)>(); foreach (var question in command.Questions) { var reference = await questionReferenceService.ResolveAsync( actor.TenantId, actor.UserId, question.Locator, cancellationToken); resolvedQuestions.Add((question, reference)); } var oldItems = await dbContext.QuestionCollectionItems .Where(item => item.TenantId == actor.TenantId && item.CollectionId == command.CollectionId) .ToArrayAsync(cancellationToken); dbContext.QuestionCollectionItems.RemoveRange(oldItems); var items = resolvedQuestions .Select((resolved, index) => new QuestionCollectionItem { TenantId = actor.TenantId, CollectionId = command.CollectionId, QuestionReferenceId = resolved.Reference.Id, QuestionOwnerTenantId = resolved.Reference.QuestionOwnerTenantId, QuestionId = resolved.Reference.QuestionId, SectionKey = Normalize(resolved.Command.SectionKey), SortOrder = resolved.Command.Order ?? index, Score = resolved.Command.Score, Required = resolved.Command.Required ?? true, Metadata = JsonObjectOrDefault(resolved.Command.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 scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); var query = dbContext.PracticeBlueprints .AsNoTracking() .Where(blueprint => blueprint.TenantId == actor.TenantId) .ApplyDataScope( scope, blueprint => blueprint.CreatedBy == actor.UserId, blueprint => blueprint.RegionId.HasValue && regionIds.Contains(blueprint.RegionId.Value)); 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) { var scope = await RequireDataScopeAsync(actor, cancellationToken); ArgumentException.ThrowIfNullOrWhiteSpace(command.Name); await AssertRegionAsync(actor.TenantId, command.RegionId, cancellationToken); await AssertEntryAsync(actor, scope, command.EntryId, cancellationToken); await AssertNodeAsync(actor, scope, 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; if (command.Id.HasValue && (blueprint is null || blueprint.Id != command.Id.Value)) { throw new ContentManagementException("Practice blueprint was not found.", "practice_blueprint_not_found"); } if (blueprint is not null && !scope.AllowsResource(actor.UserId, blueprint.CreatedBy, blueprint.RegionId)) { throw new ContentManagementException("Practice blueprint was not found.", "practice_blueprint_not_found"); } if (blueprint is null && !scope.AllowsResource(actor.UserId, actor.UserId, command.RegionId)) { throw new ContentManagementException("Practice blueprint was not found.", "practice_blueprint_not_found"); } 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 AssertEntryAsync( ContentManagementActor actor, CurrentDataScope scope, Guid? entryId, CancellationToken cancellationToken) { if (!entryId.HasValue) { return; } var regionIds = scope.RegionIds.ToArray(); var exists = await dbContext.ContentEntries .Where(entry => entry.TenantId == actor.TenantId && entry.Id == entryId.Value) .ApplyDataScope( scope, entry => entry.CreatedBy == actor.UserId, entry => entry.RegionId.HasValue && regionIds.Contains(entry.RegionId.Value)) .AnyAsync(cancellationToken); if (!exists) { throw new ContentManagementException("Content entry was not found.", "entry_not_found"); } } private async Task AssertNodeAsync(Guid tenantId, Guid? nodeId, CancellationToken cancellationToken) { await AssertReferenceAsync(tenantId, nodeId, "node_not_found", cancellationToken); } private async Task AssertNodeAsync( ContentManagementActor actor, CurrentDataScope scope, Guid? nodeId, CancellationToken cancellationToken) { if (!nodeId.HasValue) { return; } var regionIds = scope.RegionIds.ToArray(); var exists = await dbContext.ContentNodes .Where(node => node.TenantId == actor.TenantId && node.Id == nodeId.Value) .ApplyDataScope( scope, node => node.CreatedBy == actor.UserId, node => node.RegionId.HasValue && regionIds.Contains(node.RegionId.Value)) .AnyAsync(cancellationToken); if (!exists) { throw new ContentManagementException("Content node was not found.", "node_not_found"); } } private async Task RequireDataScopeAsync( ContentManagementActor actor, CancellationToken cancellationToken) { var access = await currentAccessContext.GetAsync(cancellationToken); if (!access.IsCurrentTenantMember || access.UserId != actor.UserId || access.TenantId != actor.TenantId) { throw new ContentManagementException("Content resource was not found.", "content_not_found"); } return access.DataScope; } 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, new QuestionLocator( item.QuestionOwnerTenantId == item.TenantId ? QuestionSource.Tenant : QuestionSource.Platform, 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" } } }) }; }