forked from xiongyuxing/tiku-backend.net
feat: add tenant content navigation management endpoints
This commit is contained in:
363
Tiku.Api/Contracts/ContentManagementDtos.cs
Normal file
363
Tiku.Api/Contracts/ContentManagementDtos.cs
Normal 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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
238
Tiku.Application/Content/ContentManagementModels.cs
Normal file
238
Tiku.Application/Content/ContentManagementModels.cs
Normal 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;
|
||||
}
|
||||
55
Tiku.Application/Content/IContentManagementService.cs
Normal file
55
Tiku.Application/Content/IContentManagementService.cs
Normal 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);
|
||||
}
|
||||
982
Tiku.Infrastructure/Content/ContentManagementService.cs
Normal file
982
Tiku.Infrastructure/Content/ContentManagementService.cs
Normal 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" } } })
|
||||
};
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
258
Tiku.IntegrationTests/Api/ContentManagementEndpointTests.cs
Normal file
258
Tiku.IntegrationTests/Api/ContentManagementEndpointTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user