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

983 lines
39 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.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<CatalogList<ContentEntryManagementItem>> 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<ContentEntryManagementItem>(items);
}
public async Task<ContentManagementResult<ContentEntryManagementItem>> 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<ContentEntryManagementItem>(ToEntryItem(entry));
}
public async Task<CatalogList<ContentNodeManagementItem>> 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<ContentNodeManagementItem>(items);
}
public async Task<ContentManagementResult<ContentNodeManagementItem>> 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<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 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<QuestionCollectionManagementItem>(items);
}
public async Task<ContentManagementResult<QuestionCollectionManagementItem>> 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<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;
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 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<CatalogList<PracticeBlueprintManagementItem>> 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<PracticeBlueprintManagementItem>(items);
}
public async Task<ContentManagementResult<PracticeBlueprintManagementItem>> 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<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;
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 AssertNodeAsync(Guid tenantId, Guid? nodeId, CancellationToken cancellationToken)
{
await AssertReferenceAsync<ContentNode>(tenantId, nodeId, "node_not_found", cancellationToken);
}
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,
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" } } })
};
}