Files
tiku-backend.net/Tiku.Infrastructure/Content/ContentManagementService.cs

1153 lines
46 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<CatalogList<ContentEntryManagementItem>> 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<ContentEntryManagementItem>(items);
}
public async Task<ContentManagementResult<ContentEntryManagementItem>> 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<ContentEntryManagementItem>(ToEntryItem(entry));
}
public async Task<CatalogList<ContentNodeManagementItem>> 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<ContentNodeManagementItem>(items);
}
public async Task<ContentManagementResult<ContentNodeManagementItem>> 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<ContentMarkerType>(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<ContentNodeManagementItem>(ToNodeItem(node));
}
public async Task<CatalogList<QuestionCollectionManagementItem>> 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<QuestionCollectionManagementItem>(items);
}
public async Task<ContentManagementResult<QuestionCollectionManagementItem>> 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<Subject>(actor.TenantId, command.SubjectId, "subject_not_found", cancellationToken);
await AssertReferenceAsync<Category>(actor.TenantId, command.CategoryId, "category_not_found", cancellationToken);
await AssertReferenceAsync<QuestionBank>(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<QuestionCollectionManagementItem>(ToCollectionItem(collection));
}
public async Task<CollectionItemsReplaceResult> 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<CatalogList<PracticeBlueprintManagementItem>> 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<PracticeBlueprintManagementItem>(items);
}
public async Task<ContentManagementResult<PracticeBlueprintManagementItem>> 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<QuestionCollection>(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<PracticeBlueprintManagementItem>(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<Region>(tenantId, regionId, "region_not_found", cancellationToken);
}
private async Task AssertEntryAsync(Guid tenantId, Guid? entryId, CancellationToken cancellationToken)
{
await AssertReferenceAsync<ContentEntry>(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<ContentNode>(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<CurrentDataScope> 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<TEntity>(
Guid tenantId,
Guid? id,
string code,
CancellationToken cancellationToken)
where TEntity : class
{
if (!id.HasValue)
{
return;
}
var exists = await dbContext.Set<TEntity>()
.AnyAsync(entity =>
EF.Property<Guid>(entity, nameof(ContentEntry.TenantId)) == tenantId &&
EF.Property<Guid>(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<TEntity?> ResolveEntityAsync<TEntity>(
DbSet<TEntity> set,
Guid tenantId,
Guid? id,
System.Linq.Expressions.Expression<Func<TEntity, bool>> alternatePredicate,
CancellationToken cancellationToken)
where TEntity : class
{
if (id.HasValue)
{
var byId = await set.SingleOrDefaultAsync(entity =>
EF.Property<Guid>(entity, nameof(ContentEntry.TenantId)) == tenantId &&
EF.Property<Guid>(entity, nameof(ContentEntry.Id)) == id.Value,
cancellationToken);
if (byId is not null)
{
return byId;
}
}
return await set
.Where(entity => EF.Property<Guid>(entity, nameof(ContentEntry.TenantId)) == tenantId)
.SingleOrDefaultAsync(alternatePredicate, cancellationToken);
}
private static async Task<TEntity?> ResolveEntityByIdOrLegacyAsync<TEntity>(
DbSet<TEntity> set,
Guid tenantId,
Guid? id,
string? legacyId,
CancellationToken cancellationToken)
where TEntity : class
{
if (id.HasValue)
{
var byId = await set.SingleOrDefaultAsync(entity =>
EF.Property<Guid>(entity, nameof(ContentEntry.TenantId)) == tenantId &&
EF.Property<Guid>(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<Guid>(entity, nameof(ContentEntry.TenantId)) == tenantId &&
EF.Property<string?>(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<TEnum>(string? value, TEnum fallback, string code)
where TEnum : struct
{
if (string.IsNullOrWhiteSpace(value))
{
return fallback;
}
if (Enum.TryParse<TEnum>(value, ignoreCase: true, out var parsed))
{
return parsed;
}
throw new ContentManagementException("Invalid enum value.", code);
}
private static TEnum? ParseNullable<TEnum>(string? value, string code)
where TEnum : struct
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
if (Enum.TryParse<TEnum>(value, ignoreCase: true, out var parsed))
{
return parsed;
}
throw new ContentManagementException("Invalid enum value.", code);
}
private static bool TryParse<TEnum>(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<ImportFieldSpec> 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<string, ImportSpec> Specs =
new Dictionary<string, ImportSpec>(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" } } })
};
}