feat: add tenant content navigation management endpoints

This commit is contained in:
xiong
2026-07-26 17:25:08 +08:00
parent 3c0acebbbe
commit 1d7e09b1f0
8 changed files with 2066 additions and 0 deletions

View File

@@ -0,0 +1,982 @@
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Catalog;
using Tiku.Application.Content;
using Tiku.Domain.Catalog;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
using Tiku.Domain.QuestionBanks;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Content;
public sealed class ContentManagementService(TikuDbContext dbContext) : IContentManagementService
{
private const int DefaultLimit = 100;
private const int MaxLimit = 1000;
public async Task<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" } } })
};
}

View File

@@ -44,6 +44,7 @@ public static class DependencyInjection
services.AddScoped<IAuthService, AuthService>();
services.AddScoped<ICatalogQueryService, CatalogQueryService>();
services.AddScoped<IContentNavigationQueryService, ContentNavigationQueryService>();
services.AddScoped<IContentManagementService, ContentManagementService>();
services.AddScoped<IQuestionBankQueryService, QuestionBankQueryService>();
services.AddScoped<IStudyContentQueryService, StudyContentQueryService>();
services.AddScoped<IAssetQueryService, AssetQueryService>();