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,363 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using Tiku.Application.Content;
using Tiku.Domain.Common;
namespace Tiku.Api.Contracts;
public sealed class ContentManagementQueryDto
{
public Guid? RegionId { get; set; }
public Guid? EntryId { get; set; }
public Guid? NodeId { get; set; }
public Guid? CollectionId { get; set; }
[StringLength(64)]
[RegularExpression("^(root|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$")]
public string? ParentId { get; set; }
[StringLength(50)]
public string? EntryType { get; set; }
[StringLength(50)]
public string? CollectionType { get; set; }
[StringLength(50)]
public string? Mode { get; set; }
[StringLength(50)]
public string? MarkerType { get; set; }
[StringLength(100)]
public string? Keyword { get; set; }
public bool IncludeInactive { get; set; }
[Range(1, 1000)]
public int? Limit { get; set; }
public ContentManagementFilter ToFilter()
{
return new ContentManagementFilter(
RegionId,
EntryId,
NodeId,
CollectionId,
ParentId,
EntryType,
CollectionType,
Mode,
MarkerType,
Keyword,
IncludeInactive,
Limit);
}
}
public sealed class UpsertContentEntryDto
{
public Guid? Id { get; set; }
public Guid? RegionId { get; set; }
[StringLength(64)]
public string? LegacyId { get; set; }
[StringLength(100)]
public string? EntryKey { get; set; }
[Required]
[StringLength(300)]
public string Name { get; set; } = string.Empty;
[StringLength(50)]
public string? EntryType { get; set; }
[StringLength(100)]
public string? Icon { get; set; }
[StringLength(500)]
public string? Route { get; set; }
[StringLength(2000)]
public string? Description { get; set; }
[StringLength(50)]
public string? Visibility { get; set; }
public JsonElement AccessRules { get; set; } = JsonDefaults.Object();
public JsonElement LayoutConfig { get; set; } = JsonDefaults.Object();
public int? Order { get; set; }
public bool? IsActive { get; set; }
public UpsertContentEntryCommand ToCommand()
{
return new UpsertContentEntryCommand(
Id,
RegionId,
LegacyId,
EntryKey,
Name,
EntryType,
Icon,
Route,
Description,
Visibility,
AccessRules,
LayoutConfig,
Order,
IsActive);
}
}
public sealed class UpsertContentNodeDto
{
public Guid? Id { get; set; }
[Required]
public Guid EntryId { get; set; }
public Guid? RegionId { get; set; }
public Guid? ParentId { get; set; }
[StringLength(64)]
public string? LegacyId { get; set; }
[StringLength(100)]
public string? NodeKey { get; set; }
[Required]
[StringLength(300)]
public string Name { get; set; } = string.Empty;
[StringLength(50)]
public string? NodeType { get; set; }
[StringLength(50)]
public string? MarkerType { get; set; }
public JsonElement MarkerConfig { get; set; } = JsonDefaults.Object();
public int? Order { get; set; }
public bool? IsActive { get; set; }
public bool? IsSelectable { get; set; }
public bool? IsLeaf { get; set; }
public JsonElement AccessRules { get; set; } = JsonDefaults.Object();
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
public UpsertContentNodeCommand ToCommand()
{
return new UpsertContentNodeCommand(
Id,
EntryId,
RegionId,
ParentId,
LegacyId,
NodeKey,
Name,
NodeType,
MarkerType,
MarkerConfig,
Order,
IsActive,
IsSelectable,
IsLeaf,
AccessRules,
Metadata);
}
}
public sealed class UpsertQuestionCollectionDto
{
public Guid? Id { get; set; }
public Guid? RegionId { get; set; }
public Guid? EntryId { get; set; }
public Guid? NodeId { get; set; }
public Guid? SubjectId { get; set; }
public Guid? CategoryId { get; set; }
public Guid? QuestionBankId { get; set; }
[StringLength(64)]
public string? LegacyId { get; set; }
[Required]
[StringLength(300)]
public string Name { get; set; } = string.Empty;
[StringLength(50)]
public string? CollectionType { get; set; }
[StringLength(50)]
public string? SourceType { get; set; }
public JsonElement Filters { get; set; } = JsonDefaults.Object();
public decimal? TotalScore { get; set; }
public int? DurationMinutes { get; set; }
[StringLength(50)]
public string? Status { get; set; }
public int? Order { get; set; }
public JsonElement AccessRules { get; set; } = JsonDefaults.Object();
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
public UpsertQuestionCollectionCommand ToCommand()
{
return new UpsertQuestionCollectionCommand(
Id,
RegionId,
EntryId,
NodeId,
SubjectId,
CategoryId,
QuestionBankId,
LegacyId,
Name,
CollectionType,
SourceType,
Filters,
TotalScore,
DurationMinutes,
Status,
Order,
AccessRules,
Metadata);
}
}
public sealed class CollectionQuestionDto
{
[Required]
public Guid QuestionId { get; set; }
[StringLength(100)]
public string? SectionKey { get; set; }
public int? Order { get; set; }
public decimal? Score { get; set; }
public bool? Required { get; set; }
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
public CollectionQuestionCommand ToCommand()
{
return new CollectionQuestionCommand(QuestionId, SectionKey, Order, Score, Required, Metadata);
}
}
public sealed class ReplaceCollectionItemsDto
{
[Required]
public Guid CollectionId { get; set; }
public IReadOnlyCollection<CollectionQuestionDto> Questions { get; set; } = [];
public ReplaceCollectionItemsCommand ToCommand()
{
return new ReplaceCollectionItemsCommand(
CollectionId,
Questions.Select(question => question.ToCommand()).ToArray());
}
}
public sealed class UpsertPracticeBlueprintDto
{
public Guid? Id { get; set; }
public Guid? RegionId { get; set; }
public Guid? EntryId { get; set; }
public Guid? NodeId { get; set; }
public Guid? CollectionId { get; set; }
[StringLength(64)]
public string? LegacyId { get; set; }
[Required]
[StringLength(300)]
public string Name { get; set; } = string.Empty;
[StringLength(50)]
public string? Mode { get; set; }
[StringLength(50)]
public string? AssemblyType { get; set; }
public int? QuestionLimit { get; set; }
public int? DurationMinutes { get; set; }
public decimal? TotalScore { get; set; }
public decimal? PassScore { get; set; }
public JsonElement Sections { get; set; } = JsonDefaults.Array();
public JsonElement Rules { get; set; } = JsonDefaults.Object();
public JsonElement AccessRules { get; set; } = JsonDefaults.Object();
[StringLength(50)]
public string? Status { get; set; }
public int? Order { get; set; }
public UpsertPracticeBlueprintCommand ToCommand()
{
return new UpsertPracticeBlueprintCommand(
Id,
RegionId,
EntryId,
NodeId,
CollectionId,
LegacyId,
Name,
Mode,
AssemblyType,
QuestionLimit,
DurationMinutes,
TotalScore,
PassScore,
Sections,
Rules,
AccessRules,
Status,
Order);
}
}
public sealed class ImportTemplateQueryDto
{
[Required]
[StringLength(50)]
public string ImportType { get; set; } = string.Empty;
[StringLength(10)]
public string? Format { get; set; }
}

View File

@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using Tiku.Api.Contracts;
using Tiku.Application.Assets;
using Tiku.Application.Catalog;
using Tiku.Application.Content;
using Tiku.Application.Security;
namespace Tiku.Api.Controllers;
@@ -13,9 +14,145 @@ namespace Tiku.Api.Controllers;
[Route("api/tenant-content")]
public sealed class TenantContentController(
IAssetManagementService assetManagementService,
IContentManagementService contentManagementService,
ICurrentUser currentUser,
ICurrentTenant currentTenant) : ControllerBase
{
[HttpGet("entries")]
[EndpointSummary("查询租户内容入口")]
[ProducesResponseType<CatalogList<ContentEntryManagementItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<ContentEntryManagementItem>>> GetEntries(
[FromQuery] ContentManagementQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await contentManagementService.GetEntriesAsync(
ResolveContentActor(),
query.ToFilter(),
cancellationToken));
}
[HttpPost("entries")]
[EndpointSummary("创建或更新内容入口")]
[ProducesResponseType<ContentManagementResult<ContentEntryManagementItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<ContentEntryManagementItem>>> UpsertEntry(
UpsertContentEntryDto request,
CancellationToken cancellationToken)
{
return Ok(await contentManagementService.UpsertEntryAsync(
ResolveContentActor(),
request.ToCommand(),
cancellationToken));
}
[HttpGet("nodes")]
[EndpointSummary("查询租户内容节点")]
[ProducesResponseType<CatalogList<ContentNodeManagementItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<ContentNodeManagementItem>>> GetNodes(
[FromQuery] ContentManagementQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await contentManagementService.GetNodesAsync(
ResolveContentActor(),
query.ToFilter(),
cancellationToken));
}
[HttpPost("nodes")]
[EndpointSummary("创建或更新内容节点")]
[ProducesResponseType<ContentManagementResult<ContentNodeManagementItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<ContentNodeManagementItem>>> UpsertNode(
UpsertContentNodeDto request,
CancellationToken cancellationToken)
{
return Ok(await contentManagementService.UpsertNodeAsync(
ResolveContentActor(),
request.ToCommand(),
cancellationToken));
}
[HttpGet("question-collections")]
[EndpointSummary("查询租户题集")]
[ProducesResponseType<CatalogList<QuestionCollectionManagementItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<QuestionCollectionManagementItem>>> GetQuestionCollections(
[FromQuery] ContentManagementQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await contentManagementService.GetCollectionsAsync(
ResolveContentActor(),
query.ToFilter(),
cancellationToken));
}
[HttpPost("question-collections")]
[EndpointSummary("创建或更新题集")]
[ProducesResponseType<ContentManagementResult<QuestionCollectionManagementItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<QuestionCollectionManagementItem>>> UpsertQuestionCollection(
UpsertQuestionCollectionDto request,
CancellationToken cancellationToken)
{
return Ok(await contentManagementService.UpsertCollectionAsync(
ResolveContentActor(),
request.ToCommand(),
cancellationToken));
}
[HttpPost("question-collections/items/replace")]
[EndpointSummary("替换题集题目")]
[ProducesResponseType<CollectionItemsReplaceResult>(StatusCodes.Status200OK)]
public async Task<ActionResult<CollectionItemsReplaceResult>> ReplaceQuestionCollectionItems(
ReplaceCollectionItemsDto request,
CancellationToken cancellationToken)
{
return Ok(await contentManagementService.ReplaceCollectionItemsAsync(
ResolveContentActor(),
request.ToCommand(),
cancellationToken));
}
[HttpGet("practice-blueprints")]
[EndpointSummary("查询练习蓝图")]
[ProducesResponseType<CatalogList<PracticeBlueprintManagementItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<PracticeBlueprintManagementItem>>> GetPracticeBlueprints(
[FromQuery] ContentManagementQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await contentManagementService.GetPracticeBlueprintsAsync(
ResolveContentActor(),
query.ToFilter(),
cancellationToken));
}
[HttpPost("practice-blueprints")]
[EndpointSummary("创建或更新练习蓝图")]
[ProducesResponseType<ContentManagementResult<PracticeBlueprintManagementItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentManagementResult<PracticeBlueprintManagementItem>>> UpsertPracticeBlueprint(
UpsertPracticeBlueprintDto request,
CancellationToken cancellationToken)
{
return Ok(await contentManagementService.UpsertPracticeBlueprintAsync(
ResolveContentActor(),
request.ToCommand(),
cancellationToken));
}
[HttpGet("imports/field-mapping")]
[EndpointSummary("查询导入字段映射")]
[ProducesResponseType<ImportFieldMappingItem>(StatusCodes.Status200OK)]
public ActionResult<ImportFieldMappingItem> GetImportFieldMapping([FromQuery] ImportTemplateQueryDto query)
{
_ = ResolveContentActor();
return Ok(contentManagementService.GetImportFieldMapping(query.ImportType));
}
[HttpGet("imports/templates")]
[EndpointSummary("获取内容导入模板")]
[ProducesResponseType<ImportTemplateItem>(StatusCodes.Status200OK)]
public ActionResult<ImportTemplateItem> GetImportTemplate([FromQuery] ImportTemplateQueryDto query)
{
_ = ResolveContentActor();
return Ok(contentManagementService.GetImportTemplate(query.ImportType, query.Format));
}
[HttpGet("assets")]
[EndpointSummary("查询租户内容资产")]
[ProducesResponseType<CatalogList<ContentAssetManagementItem>>(StatusCodes.Status200OK)]
@@ -95,4 +232,14 @@ public sealed class TenantContentController(
return new AssetManagementActor(currentTenant.TenantId.Value, currentUser.UserId.Value);
}
private ContentManagementActor ResolveContentActor()
{
if (currentTenant.TenantId is null || currentUser.UserId is null)
{
throw new ContentManagementException("Tenant content actor was not resolved.", "tenant_content_access_denied");
}
return new ContentManagementActor(currentTenant.TenantId.Value, currentUser.UserId.Value);
}
}

View File

@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc;
using Tiku.Api.Controllers;
using Tiku.Application.Assets;
using Tiku.Application.Auth;
using Tiku.Application.Content;
using Tiku.Application.Storage;
using Tiku.Infrastructure.Content;
using Tiku.Infrastructure.Learning;
@@ -98,6 +99,16 @@ public sealed class ExceptionHandlingMiddleware(
return;
}
if (exception is ContentManagementException contentManagementException)
{
await WriteProblemAsync(
context,
contentManagementException.Message,
ContentManagementStatusCode(contentManagementException.Code),
contentManagementException.Code);
return;
}
if (exception is LearningValidationException learningValidationException)
{
await WriteProblemAsync(
@@ -230,4 +241,15 @@ public sealed class ExceptionHandlingMiddleware(
_ => StatusCodes.Status400BadRequest
};
}
private static int ContentManagementStatusCode(string code)
{
return code switch
{
"entry_not_found" or "node_not_found" or "collection_not_found" or "question_not_found" or
"import_type_invalid" => StatusCodes.Status404NotFound,
"tenant_content_access_denied" => StatusCodes.Status403Forbidden,
_ => StatusCodes.Status400BadRequest
};
}
}

View File

@@ -0,0 +1,238 @@
using System.Text.Json;
using Tiku.Application.Catalog;
using Tiku.Domain.Content;
namespace Tiku.Application.Content;
public sealed record ContentManagementActor(Guid TenantId, Guid UserId);
public sealed record ContentManagementFilter(
Guid? RegionId = null,
Guid? EntryId = null,
Guid? NodeId = null,
Guid? CollectionId = null,
string? ParentId = null,
string? EntryType = null,
string? CollectionType = null,
string? Mode = null,
string? MarkerType = null,
string? Keyword = null,
bool IncludeInactive = false,
int? Limit = null);
public sealed record UpsertContentEntryCommand(
Guid? Id,
Guid? RegionId,
string? LegacyId,
string? EntryKey,
string Name,
string? EntryType,
string? Icon,
string? Route,
string? Description,
string? Visibility,
JsonElement AccessRules,
JsonElement LayoutConfig,
int? Order,
bool? IsActive);
public sealed record UpsertContentNodeCommand(
Guid? Id,
Guid EntryId,
Guid? RegionId,
Guid? ParentId,
string? LegacyId,
string? NodeKey,
string Name,
string? NodeType,
string? MarkerType,
JsonElement MarkerConfig,
int? Order,
bool? IsActive,
bool? IsSelectable,
bool? IsLeaf,
JsonElement AccessRules,
JsonElement Metadata);
public sealed record UpsertQuestionCollectionCommand(
Guid? Id,
Guid? RegionId,
Guid? EntryId,
Guid? NodeId,
Guid? SubjectId,
Guid? CategoryId,
Guid? QuestionBankId,
string? LegacyId,
string Name,
string? CollectionType,
string? SourceType,
JsonElement Filters,
decimal? TotalScore,
int? DurationMinutes,
string? Status,
int? Order,
JsonElement AccessRules,
JsonElement Metadata);
public sealed record ReplaceCollectionItemsCommand(
Guid CollectionId,
IReadOnlyCollection<CollectionQuestionCommand> Questions);
public sealed record CollectionQuestionCommand(
Guid QuestionId,
string? SectionKey,
int? Order,
decimal? Score,
bool? Required,
JsonElement Metadata);
public sealed record UpsertPracticeBlueprintCommand(
Guid? Id,
Guid? RegionId,
Guid? EntryId,
Guid? NodeId,
Guid? CollectionId,
string? LegacyId,
string Name,
string? Mode,
string? AssemblyType,
int? QuestionLimit,
int? DurationMinutes,
decimal? TotalScore,
decimal? PassScore,
JsonElement Sections,
JsonElement Rules,
JsonElement AccessRules,
string? Status,
int? Order);
public sealed record ContentEntryManagementItem(
Guid Id,
Guid? RegionId,
string? LegacyId,
string EntryKey,
string Name,
ContentEntryType EntryType,
string? Icon,
string? Route,
string? Description,
ContentVisibility Visibility,
JsonElement AccessRules,
JsonElement LayoutConfig,
int Order,
bool IsActive,
DateTimeOffset CreatedAt,
DateTimeOffset? UpdatedAt);
public sealed record ContentNodeManagementItem(
Guid Id,
Guid EntryId,
Guid? RegionId,
Guid? ParentId,
string? LegacyId,
string? NodeKey,
string Name,
ContentNodeType NodeType,
ContentMarkerType? MarkerType,
JsonElement MarkerConfig,
string? Path,
int Depth,
int Order,
bool IsActive,
bool IsSelectable,
bool IsLeaf,
JsonElement AccessRules,
JsonElement Metadata,
DateTimeOffset CreatedAt,
DateTimeOffset? UpdatedAt);
public sealed record QuestionCollectionManagementItem(
Guid Id,
Guid? RegionId,
Guid? EntryId,
Guid? NodeId,
Guid? SubjectId,
Guid? CategoryId,
Guid? QuestionBankId,
string? LegacyId,
string Name,
QuestionCollectionType CollectionType,
QuestionCollectionSourceType SourceType,
JsonElement Filters,
int QuestionCount,
decimal? TotalScore,
int? DurationMinutes,
ContentStatus Status,
int Order,
JsonElement AccessRules,
JsonElement Metadata,
DateTimeOffset CreatedAt,
DateTimeOffset? UpdatedAt);
public sealed record QuestionCollectionItemManagementItem(
Guid Id,
Guid CollectionId,
Guid QuestionId,
string? SectionKey,
int Order,
decimal? Score,
bool Required,
JsonElement Metadata);
public sealed record PracticeBlueprintManagementItem(
Guid Id,
Guid? RegionId,
Guid? EntryId,
Guid? NodeId,
Guid? CollectionId,
string? LegacyId,
string Name,
PracticeMode Mode,
PracticeAssemblyType AssemblyType,
int? QuestionLimit,
int? DurationMinutes,
decimal? TotalScore,
decimal? PassScore,
JsonElement Sections,
JsonElement Rules,
JsonElement AccessRules,
ContentStatus Status,
int Order,
DateTimeOffset CreatedAt,
DateTimeOffset? UpdatedAt);
public sealed record ContentManagementResult<TItem>(TItem Item);
public sealed record CollectionItemsReplaceResult(
Guid CollectionId,
int QuestionCount,
IReadOnlyCollection<QuestionCollectionItemManagementItem> Items);
public sealed record ImportFieldSpec(
string Field,
string Label,
bool Required,
IReadOnlyCollection<string> Aliases,
string Description,
JsonElement Example);
public sealed record ImportFieldMappingItem(
string ImportType,
string Title,
string Description,
IReadOnlyCollection<ImportFieldSpec> Fields,
IReadOnlyCollection<string> RequiredFields);
public sealed record ImportTemplateItem(
string ImportType,
string Format,
string FileName,
string MimeType,
string ContentBase64,
string ContentPreview,
IReadOnlyCollection<ImportFieldSpec> Fields);
public sealed class ContentManagementException(string message, string code) : Exception(message)
{
public string Code { get; } = code;
}

View File

@@ -0,0 +1,55 @@
using Tiku.Application.Catalog;
namespace Tiku.Application.Content;
public interface IContentManagementService
{
Task<CatalogList<ContentEntryManagementItem>> GetEntriesAsync(
ContentManagementActor actor,
ContentManagementFilter filter,
CancellationToken cancellationToken = default);
Task<ContentManagementResult<ContentEntryManagementItem>> UpsertEntryAsync(
ContentManagementActor actor,
UpsertContentEntryCommand command,
CancellationToken cancellationToken = default);
Task<CatalogList<ContentNodeManagementItem>> GetNodesAsync(
ContentManagementActor actor,
ContentManagementFilter filter,
CancellationToken cancellationToken = default);
Task<ContentManagementResult<ContentNodeManagementItem>> UpsertNodeAsync(
ContentManagementActor actor,
UpsertContentNodeCommand command,
CancellationToken cancellationToken = default);
Task<CatalogList<QuestionCollectionManagementItem>> GetCollectionsAsync(
ContentManagementActor actor,
ContentManagementFilter filter,
CancellationToken cancellationToken = default);
Task<ContentManagementResult<QuestionCollectionManagementItem>> UpsertCollectionAsync(
ContentManagementActor actor,
UpsertQuestionCollectionCommand command,
CancellationToken cancellationToken = default);
Task<CollectionItemsReplaceResult> ReplaceCollectionItemsAsync(
ContentManagementActor actor,
ReplaceCollectionItemsCommand command,
CancellationToken cancellationToken = default);
Task<CatalogList<PracticeBlueprintManagementItem>> GetPracticeBlueprintsAsync(
ContentManagementActor actor,
ContentManagementFilter filter,
CancellationToken cancellationToken = default);
Task<ContentManagementResult<PracticeBlueprintManagementItem>> UpsertPracticeBlueprintAsync(
ContentManagementActor actor,
UpsertPracticeBlueprintCommand command,
CancellationToken cancellationToken = default);
ImportFieldMappingItem GetImportFieldMapping(string importType);
ImportTemplateItem GetImportTemplate(string importType, string? format);
}

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>();

View File

@@ -0,0 +1,258 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Api.Contracts;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
using Tiku.Domain.Identity;
using Tiku.Domain.QuestionBanks;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Auth;
using Tiku.Infrastructure.Persistence;
namespace Tiku.IntegrationTests.Api;
public sealed class ContentManagementEndpointTests
{
[Fact]
public async Task Tenant_content_management_requires_admin_authentication()
{
await using var factory = new ApiTestFactory();
using var client = factory.CreateClient();
using var response = await client.GetAsync("/api/tenant-content/entries");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Tenant_admin_can_upsert_entries_and_nodes()
{
await using var factory = new ApiTestFactory();
var seed = await SeedAdminAsync(factory);
using var client = factory.CreateClient();
await LoginAsync(client, seed);
using var entryResponse = await client.PostAsJsonAsync(
"/api/tenant-content/entries",
new UpsertContentEntryDto
{
EntryKey = "exam-practice",
Name = "刷题入口",
EntryType = "questionPractice",
Visibility = "members",
Order = 10
});
var entryJson = await ReadJsonAsync(entryResponse);
var entryId = entryJson.RootElement.GetProperty("item").GetProperty("id").GetGuid();
using var nodeResponse = await client.PostAsJsonAsync(
"/api/tenant-content/nodes",
new UpsertContentNodeDto
{
EntryId = entryId,
NodeKey = "chapter-1",
Name = "第一章",
NodeType = "chapter",
IsLeaf = true
});
var nodeJson = await ReadJsonAsync(nodeResponse);
using var listResponse = await client.GetAsync($"/api/tenant-content/nodes?entryId={entryId}&parentId=root");
var listJson = await ReadJsonAsync(listResponse);
Assert.Equal(HttpStatusCode.OK, entryResponse.StatusCode);
Assert.Equal("exam-practice", entryJson.RootElement.GetProperty("item").GetProperty("entryKey").GetString());
Assert.Equal(HttpStatusCode.OK, nodeResponse.StatusCode);
var node = nodeJson.RootElement.GetProperty("item");
Assert.Equal(0, node.GetProperty("depth").GetInt32());
Assert.StartsWith("n_", node.GetProperty("path").GetString(), StringComparison.Ordinal);
Assert.Single(listJson.RootElement.GetProperty("items").EnumerateArray());
}
[Fact]
public async Task Tenant_admin_can_upsert_collection_replace_items_and_create_blueprint()
{
await using var factory = new ApiTestFactory();
var seed = await SeedAdminAsync(factory);
var questionId = Guid.NewGuid();
await factory.SeedAsync(new Question
{
Id = questionId,
TenantId = seed.TenantId,
Type = "choice",
Status = QuestionStatus.Published
});
using var client = factory.CreateClient();
await LoginAsync(client, seed);
var entryId = await CreateEntryAsync(client);
var collectionResponse = await client.PostAsJsonAsync(
"/api/tenant-content/question-collections",
new UpsertQuestionCollectionDto
{
EntryId = entryId,
Name = "基础题集",
CollectionType = "manual",
SourceType = "manualQuestions",
TotalScore = 100,
DurationMinutes = 45
});
var collectionJson = await ReadJsonAsync(collectionResponse);
var collectionId = collectionJson.RootElement.GetProperty("item").GetProperty("id").GetGuid();
var replaceResponse = await client.PostAsJsonAsync(
"/api/tenant-content/question-collections/items/replace",
new ReplaceCollectionItemsDto
{
CollectionId = collectionId,
Questions =
[
new CollectionQuestionDto
{
QuestionId = questionId,
SectionKey = "choice",
Order = 1,
Score = 5,
Required = true
}
]
});
var replaceJson = await ReadJsonAsync(replaceResponse);
var blueprintResponse = await client.PostAsJsonAsync(
"/api/tenant-content/practice-blueprints",
new UpsertPracticeBlueprintDto
{
EntryId = entryId,
CollectionId = collectionId,
Name = "章节练习",
Mode = "sequential",
AssemblyType = "collection",
QuestionLimit = 10,
TotalScore = 100
});
var blueprintJson = await ReadJsonAsync(blueprintResponse);
Assert.Equal(HttpStatusCode.OK, collectionResponse.StatusCode);
Assert.Equal("Manual", collectionJson.RootElement.GetProperty("item").GetProperty("collectionType").GetString());
Assert.Equal(HttpStatusCode.OK, replaceResponse.StatusCode);
Assert.Equal(1, replaceJson.RootElement.GetProperty("questionCount").GetInt32());
Assert.Equal(HttpStatusCode.OK, blueprintResponse.StatusCode);
Assert.Equal(collectionId, blueprintJson.RootElement.GetProperty("item").GetProperty("collectionId").GetGuid());
using var scope = factory.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
Assert.Equal(1, dbContext.QuestionCollections.Single(item => item.Id == collectionId).QuestionCount);
Assert.Contains(dbContext.QuestionCollectionItems, item => item.QuestionId == questionId);
}
[Fact]
public async Task Tenant_admin_can_get_import_field_mapping_and_templates()
{
await using var factory = new ApiTestFactory();
var seed = await SeedAdminAsync(factory);
using var client = factory.CreateClient();
await LoginAsync(client, seed);
using var mappingResponse = await client.GetAsync("/api/tenant-content/imports/field-mapping?importType=questions");
using var templateResponse = await client.GetAsync("/api/tenant-content/imports/templates?importType=questions&format=csv");
var mapping = await ReadJsonAsync(mappingResponse);
var template = await ReadJsonAsync(templateResponse);
Assert.Equal(HttpStatusCode.OK, mappingResponse.StatusCode);
Assert.Equal("questions", mapping.RootElement.GetProperty("importType").GetString());
Assert.Contains(mapping.RootElement.GetProperty("requiredFields").EnumerateArray(), item => item.GetString() == "type");
Assert.Equal(HttpStatusCode.OK, templateResponse.StatusCode);
Assert.Equal("csv", template.RootElement.GetProperty("format").GetString());
Assert.NotEmpty(template.RootElement.GetProperty("contentBase64").GetString() ?? string.Empty);
}
private static async Task<Guid> CreateEntryAsync(HttpClient client)
{
using var response = await client.PostAsJsonAsync(
"/api/tenant-content/entries",
new UpsertContentEntryDto
{
EntryKey = Guid.NewGuid().ToString("N"),
Name = "刷题入口",
EntryType = "questionPractice"
});
var json = await ReadJsonAsync(response);
return json.RootElement.GetProperty("item").GetProperty("id").GetGuid();
}
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedAdminAsync(ApiTestFactory factory)
{
var tenantId = Guid.NewGuid();
var userId = Guid.NewGuid();
var phone = "13700000000";
var passwordHash = new PasswordHasher().Hash("passw0rd!");
await factory.SeedAsync(
new Tenant
{
Id = tenantId,
Slug = tenantId.ToString("N"),
Name = "Test Tenant",
Status = TenantStatus.Active,
Metadata = JsonDefaults.Object()
},
new User
{
Id = userId,
Phone = phone,
Name = "Tenant Admin"
},
new TenantMembership
{
TenantId = tenantId,
UserId = userId,
Role = TenantRole.TenantAdmin,
Status = MembershipStatus.Active
},
new UserIdentity
{
UserId = userId,
Provider = "password",
ProviderSubject = phone,
Phone = phone,
SecretPayload = CreateSecretPayload(passwordHash)
});
return (tenantId, userId, phone);
}
private static async Task LoginAsync(
HttpClient client,
(Guid TenantId, Guid UserId, string Phone) seed)
{
var loginResponse = await client.PostAsJsonAsync(
"/api/auth/login/password",
new PasswordLoginDto
{
TenantId = seed.TenantId,
Phone = seed.Phone,
Password = "passw0rd!"
});
var loginJson = await ReadJsonAsync(loginResponse);
var accessToken = loginJson.RootElement
.GetProperty("tokens")
.GetProperty("accessToken")
.GetString();
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
}
private static async Task<JsonDocument> ReadJsonAsync(HttpResponseMessage response)
{
var stream = await response.Content.ReadAsStreamAsync();
return await JsonDocument.ParseAsync(stream);
}
private static JsonElement CreateSecretPayload(string passwordHash)
{
using var document = JsonDocument.Parse(
$$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}""");
return document.RootElement.Clone();
}
}