forked from gongxuegit/tiku-backend.net
feat: add content navigation readonly endpoints
This commit is contained in:
@@ -246,10 +246,10 @@ dotnet ef migrations script \
|
||||
建议下一批迁移顺序:
|
||||
|
||||
```text
|
||||
Content Navigation 只读 API
|
||||
-> 内容入口 / 内容树 / 题集 / 练习蓝图
|
||||
Question Bank 只读 API
|
||||
-> 题库列表 / 题目详情 / 题目版本
|
||||
Vocabulary / Handbook 只读 API
|
||||
-> 词汇单元 / 单词 / 知识手册
|
||||
```
|
||||
|
||||
## 当前状态
|
||||
@@ -259,5 +259,6 @@ Question Bank 只读 API
|
||||
- API 安全底座已建立:JWT、Session、本地登录、当前用户、当前租户、基础授权策略。
|
||||
- 租户公开入口已建立:tenant resolve、public config、health。
|
||||
- Catalog 基础只读 API 已建立:地区、地区模块、模块节点、院校、专业、科目、题目分类。
|
||||
- 当前模型测试、认证服务测试、API 认证/租户闭环测试、Catalog 只读测试通过。
|
||||
- Content Navigation 只读 API 已建立:内容入口、内容节点、题集、题集题目、练习蓝图。
|
||||
- 当前模型测试、认证服务测试、API 认证/租户闭环测试、Catalog / Content Navigation 只读测试通过。
|
||||
- 下一步重点是继续把题库、内容、导入、订单等业务 API 接入这套安全轨道,而不是重新散写权限判断。
|
||||
|
||||
71
Tiku.Api/Contracts/ContentNavigationDtos.cs
Normal file
71
Tiku.Api/Contracts/ContentNavigationDtos.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Tiku.Application.Content;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
public sealed class ContentNavigationQueryDto
|
||||
{
|
||||
[StringLength(100)]
|
||||
public string? TenantCode { get; set; }
|
||||
|
||||
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 IncludeHidden { get; set; }
|
||||
|
||||
public bool IncludeInactive { get; set; }
|
||||
|
||||
[Range(1, 1000)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
public ContentNavigationFilter ToFilter(Guid tenantId)
|
||||
{
|
||||
var parentWasSpecified = ParentId is not null;
|
||||
var parentIsRoot = string.Equals(ParentId, "root", StringComparison.OrdinalIgnoreCase);
|
||||
Guid? parentId = parentIsRoot || string.IsNullOrWhiteSpace(ParentId)
|
||||
? null
|
||||
: Guid.Parse(ParentId);
|
||||
|
||||
return new ContentNavigationFilter(
|
||||
tenantId,
|
||||
RegionId,
|
||||
EntryId,
|
||||
NodeId,
|
||||
CollectionId,
|
||||
parentId,
|
||||
parentWasSpecified,
|
||||
parentIsRoot,
|
||||
EntryType,
|
||||
CollectionType,
|
||||
Mode,
|
||||
MarkerType,
|
||||
Keyword,
|
||||
IncludeHidden,
|
||||
IncludeInactive,
|
||||
Limit);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
@@ -15,6 +16,7 @@ namespace Tiku.Api.Controllers;
|
||||
[Route("api/catalog")]
|
||||
public sealed class CatalogController(
|
||||
ICatalogQueryService catalogQueryService,
|
||||
IContentNavigationQueryService contentNavigationQueryService,
|
||||
ICurrentTenant currentTenant,
|
||||
TikuDbContext dbContext) : ControllerBase
|
||||
{
|
||||
@@ -110,6 +112,73 @@ public sealed class CatalogController(
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("content-entries")]
|
||||
[EndpointSummary("查询内容入口")]
|
||||
[ProducesResponseType<CatalogList<ContentEntryCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<CatalogList<ContentEntryCatalogItem>>> GetContentEntries(
|
||||
[FromQuery] ContentNavigationQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await contentNavigationQueryService.GetContentEntriesAsync(
|
||||
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("content-nodes")]
|
||||
[EndpointSummary("查询内容导航节点")]
|
||||
[ProducesResponseType<CatalogList<ContentNodeCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<CatalogList<ContentNodeCatalogItem>>> GetContentNodes(
|
||||
[FromQuery] ContentNavigationQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await contentNavigationQueryService.GetContentNodesAsync(
|
||||
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("question-collections")]
|
||||
[EndpointSummary("查询可用题集")]
|
||||
[ProducesResponseType<CatalogList<QuestionCollectionCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<CatalogList<QuestionCollectionCatalogItem>>> GetQuestionCollections(
|
||||
[FromQuery] ContentNavigationQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await contentNavigationQueryService.GetQuestionCollectionsAsync(
|
||||
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("question-collections/questions")]
|
||||
[EndpointSummary("查询题集内题目")]
|
||||
[ProducesResponseType<CatalogList<CollectionQuestionCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<CatalogList<CollectionQuestionCatalogItem>>> GetCollectionQuestions(
|
||||
[FromQuery] ContentNavigationQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await contentNavigationQueryService.GetCollectionQuestionsAsync(
|
||||
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("practice-blueprints")]
|
||||
[EndpointSummary("查询练习蓝图")]
|
||||
[ProducesResponseType<CatalogList<PracticeBlueprintCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<CatalogList<PracticeBlueprintCatalogItem>>> GetPracticeBlueprints(
|
||||
[FromQuery] ContentNavigationQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await contentNavigationQueryService.GetPracticeBlueprintsAsync(
|
||||
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<Guid> ResolveTenantIdAsync(
|
||||
CatalogQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -134,6 +203,18 @@ public sealed class CatalogController(
|
||||
|
||||
return tenantId ?? throw new TenantNotFoundException();
|
||||
}
|
||||
|
||||
private Task<Guid> ResolveTenantIdAsync(
|
||||
ContentNavigationQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return ResolveTenantIdAsync(
|
||||
new CatalogQueryDto
|
||||
{
|
||||
TenantCode = query.TenantCode
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TenantNotFoundException : Exception
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Controllers;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Infrastructure.Content;
|
||||
|
||||
namespace Tiku.Api.Middleware;
|
||||
|
||||
@@ -33,6 +34,26 @@ public sealed class ExceptionHandlingMiddleware(
|
||||
return;
|
||||
}
|
||||
|
||||
if (exception is RequiredFieldException)
|
||||
{
|
||||
await WriteProblemAsync(
|
||||
context,
|
||||
exception.Message,
|
||||
StatusCodes.Status400BadRequest,
|
||||
"required_field");
|
||||
return;
|
||||
}
|
||||
|
||||
if (exception is ContentNavigationNotFoundException)
|
||||
{
|
||||
await WriteProblemAsync(
|
||||
context,
|
||||
exception.Message,
|
||||
StatusCodes.Status404NotFound,
|
||||
"content_navigation_not_found");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogError(exception, "Unhandled API exception");
|
||||
|
||||
var problem = new ProblemDetails
|
||||
|
||||
127
Tiku.Application/Content/ContentNavigationQueryModels.cs
Normal file
127
Tiku.Application/Content/ContentNavigationQueryModels.cs
Normal file
@@ -0,0 +1,127 @@
|
||||
using System.Text.Json;
|
||||
using Tiku.Domain.Content;
|
||||
|
||||
namespace Tiku.Application.Content;
|
||||
|
||||
public sealed record ContentNavigationFilter(
|
||||
Guid TenantId,
|
||||
Guid? RegionId = null,
|
||||
Guid? EntryId = null,
|
||||
Guid? NodeId = null,
|
||||
Guid? CollectionId = null,
|
||||
Guid? ParentId = null,
|
||||
bool ParentWasSpecified = false,
|
||||
bool ParentIsRoot = false,
|
||||
string? EntryType = null,
|
||||
string? CollectionType = null,
|
||||
string? Mode = null,
|
||||
string? MarkerType = null,
|
||||
string? Keyword = null,
|
||||
bool IncludeHidden = false,
|
||||
bool IncludeInactive = false,
|
||||
int? Limit = null);
|
||||
|
||||
public sealed record ContentEntryCatalogItem(
|
||||
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);
|
||||
|
||||
public sealed record ContentNodeCatalogItem(
|
||||
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 IsSelectable,
|
||||
bool IsLeaf,
|
||||
JsonElement AccessRules,
|
||||
JsonElement Metadata);
|
||||
|
||||
public sealed record QuestionCollectionCatalogItem(
|
||||
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);
|
||||
|
||||
public sealed record PracticeBlueprintCatalogItem(
|
||||
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);
|
||||
|
||||
public sealed record CollectionQuestionCatalogItem(
|
||||
Guid Id,
|
||||
string? LegacyId,
|
||||
Guid? EntryId,
|
||||
Guid? ContentNodeId,
|
||||
Guid? PrimaryCollectionId,
|
||||
Guid? SubjectId,
|
||||
Guid? CategoryId,
|
||||
Guid? NodeId,
|
||||
string Type,
|
||||
string? TypeLabel,
|
||||
int? Difficulty,
|
||||
JsonElement Tags,
|
||||
JsonElement ExamMarkers,
|
||||
string? MediaUrl,
|
||||
bool HasVideoExplanation,
|
||||
string? SectionKey,
|
||||
decimal? Score,
|
||||
int Order,
|
||||
Guid? VersionId,
|
||||
string? Content,
|
||||
JsonElement Options,
|
||||
int? CorrectOptionIndex,
|
||||
JsonElement CorrectOptionIndices,
|
||||
string? AnswerText,
|
||||
string? Explanation,
|
||||
JsonElement SubQuestions,
|
||||
string? CodeLang,
|
||||
string? CodeTemplate);
|
||||
26
Tiku.Application/Content/IContentNavigationQueryService.cs
Normal file
26
Tiku.Application/Content/IContentNavigationQueryService.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using Tiku.Application.Catalog;
|
||||
|
||||
namespace Tiku.Application.Content;
|
||||
|
||||
public interface IContentNavigationQueryService
|
||||
{
|
||||
Task<CatalogList<ContentEntryCatalogItem>> GetContentEntriesAsync(
|
||||
ContentNavigationFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CatalogList<ContentNodeCatalogItem>> GetContentNodesAsync(
|
||||
ContentNavigationFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CatalogList<QuestionCollectionCatalogItem>> GetQuestionCollectionsAsync(
|
||||
ContentNavigationFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CatalogList<PracticeBlueprintCatalogItem>> GetPracticeBlueprintsAsync(
|
||||
ContentNavigationFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CatalogList<CollectionQuestionCatalogItem>> GetCollectionQuestionsAsync(
|
||||
ContentNavigationFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
380
Tiku.Infrastructure/Content/ContentNavigationQueryService.cs
Normal file
380
Tiku.Infrastructure/Content/ContentNavigationQueryService.cs
Normal file
@@ -0,0 +1,380 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Content;
|
||||
|
||||
public sealed class ContentNavigationQueryService(TikuDbContext dbContext) : IContentNavigationQueryService
|
||||
{
|
||||
private const int DefaultLimit = 100;
|
||||
private const int MaxLimit = 1000;
|
||||
|
||||
public async Task<CatalogList<ContentEntryCatalogItem>> GetContentEntriesAsync(
|
||||
ContentNavigationFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = dbContext.ContentEntries
|
||||
.AsNoTracking()
|
||||
.Where(entry =>
|
||||
entry.TenantId == filter.TenantId &&
|
||||
entry.IsActive);
|
||||
|
||||
if (!filter.IncludeHidden)
|
||||
{
|
||||
query = query.Where(entry => entry.Visibility != ContentVisibility.Hidden);
|
||||
}
|
||||
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(entry => entry.RegionId == filter.RegionId.Value || entry.RegionId == null);
|
||||
}
|
||||
|
||||
if (TryParseEntryType(filter.EntryType, out var entryType))
|
||||
{
|
||||
query = query.Where(entry => entry.EntryType == entryType);
|
||||
}
|
||||
|
||||
query = ApplyKeyword(query, filter.Keyword);
|
||||
|
||||
var items = await query
|
||||
.OrderBy(entry => entry.SortOrder)
|
||||
.ThenBy(entry => entry.CreatedAt)
|
||||
.Take(ResolveLimit(filter.Limit, 500))
|
||||
.Select(entry => new ContentEntryCatalogItem(
|
||||
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))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
return new CatalogList<ContentEntryCatalogItem>(items);
|
||||
}
|
||||
|
||||
public async Task<CatalogList<ContentNodeCatalogItem>> GetContentNodesAsync(
|
||||
ContentNavigationFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!filter.EntryId.HasValue)
|
||||
{
|
||||
throw new RequiredFieldException("entryId is required.");
|
||||
}
|
||||
|
||||
var query = dbContext.ContentNodes
|
||||
.AsNoTracking()
|
||||
.Where(node =>
|
||||
node.TenantId == filter.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 || node.RegionId == null);
|
||||
}
|
||||
|
||||
if (filter.ParentWasSpecified)
|
||||
{
|
||||
query = filter.ParentIsRoot
|
||||
? query.Where(node => node.ParentId == null)
|
||||
: query.Where(node => node.ParentId == filter.ParentId);
|
||||
}
|
||||
|
||||
if (TryParseMarkerType(filter.MarkerType, out var markerType))
|
||||
{
|
||||
query = query.Where(node => node.MarkerType == markerType);
|
||||
}
|
||||
|
||||
query = ApplyKeyword(query, filter.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, 1000))
|
||||
.Select(node => new ContentNodeCatalogItem(
|
||||
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.IsSelectable,
|
||||
node.IsLeaf,
|
||||
node.AccessRules,
|
||||
node.Metadata))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
return new CatalogList<ContentNodeCatalogItem>(items);
|
||||
}
|
||||
|
||||
public async Task<CatalogList<QuestionCollectionCatalogItem>> GetQuestionCollectionsAsync(
|
||||
ContentNavigationFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = dbContext.QuestionCollections
|
||||
.AsNoTracking()
|
||||
.Where(collection =>
|
||||
collection.TenantId == filter.TenantId &&
|
||||
collection.Status == ContentStatus.Active);
|
||||
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(collection => collection.RegionId == filter.RegionId.Value || collection.RegionId == null);
|
||||
}
|
||||
|
||||
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 (TryParseCollectionType(filter.CollectionType, out var collectionType))
|
||||
{
|
||||
query = query.Where(collection => collection.CollectionType == collectionType);
|
||||
}
|
||||
|
||||
query = ApplyKeyword(query, filter.Keyword);
|
||||
|
||||
var items = await query
|
||||
.OrderBy(collection => collection.SortOrder)
|
||||
.ThenBy(collection => collection.CreatedAt)
|
||||
.Take(ResolveLimit(filter.Limit, 500))
|
||||
.Select(collection => new QuestionCollectionCatalogItem(
|
||||
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))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
return new CatalogList<QuestionCollectionCatalogItem>(items);
|
||||
}
|
||||
|
||||
public async Task<CatalogList<PracticeBlueprintCatalogItem>> GetPracticeBlueprintsAsync(
|
||||
ContentNavigationFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = dbContext.PracticeBlueprints
|
||||
.AsNoTracking()
|
||||
.Where(blueprint =>
|
||||
blueprint.TenantId == filter.TenantId &&
|
||||
blueprint.Status == ContentStatus.Active);
|
||||
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(blueprint => blueprint.RegionId == filter.RegionId.Value || blueprint.RegionId == null);
|
||||
}
|
||||
|
||||
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 (TryParsePracticeMode(filter.Mode, out var mode))
|
||||
{
|
||||
query = query.Where(blueprint => blueprint.Mode == mode);
|
||||
}
|
||||
|
||||
query = ApplyKeyword(query, filter.Keyword);
|
||||
|
||||
var items = await query
|
||||
.OrderBy(blueprint => blueprint.SortOrder)
|
||||
.ThenBy(blueprint => blueprint.CreatedAt)
|
||||
.Take(ResolveLimit(filter.Limit, 500))
|
||||
.Select(blueprint => new PracticeBlueprintCatalogItem(
|
||||
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))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
return new CatalogList<PracticeBlueprintCatalogItem>(items);
|
||||
}
|
||||
|
||||
public async Task<CatalogList<CollectionQuestionCatalogItem>> GetCollectionQuestionsAsync(
|
||||
ContentNavigationFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!filter.CollectionId.HasValue)
|
||||
{
|
||||
throw new RequiredFieldException("collectionId is required.");
|
||||
}
|
||||
|
||||
var collectionExists = await dbContext.QuestionCollections
|
||||
.AsNoTracking()
|
||||
.AnyAsync(
|
||||
collection =>
|
||||
collection.TenantId == filter.TenantId &&
|
||||
collection.Id == filter.CollectionId.Value &&
|
||||
collection.Status == ContentStatus.Active,
|
||||
cancellationToken);
|
||||
|
||||
if (!collectionExists)
|
||||
{
|
||||
throw new ContentNavigationNotFoundException("Question collection was not found.");
|
||||
}
|
||||
|
||||
var query =
|
||||
from item in dbContext.QuestionCollectionItems.AsNoTracking()
|
||||
join question in dbContext.Questions.AsNoTracking()
|
||||
on new { item.TenantId, QuestionId = item.QuestionId } equals new { question.TenantId, QuestionId = question.Id }
|
||||
join version in dbContext.QuestionVersions.AsNoTracking()
|
||||
on new { question.TenantId, QuestionId = question.Id, VersionId = question.CurrentVersionId }
|
||||
equals new { version.TenantId, version.QuestionId, VersionId = (Guid?)version.Id }
|
||||
into versions
|
||||
from version in versions.DefaultIfEmpty()
|
||||
where item.TenantId == filter.TenantId &&
|
||||
item.CollectionId == filter.CollectionId.Value &&
|
||||
question.Status == QuestionStatus.Published
|
||||
orderby item.SectionKey, item.SortOrder, question.CreatedAt
|
||||
select new CollectionQuestionCatalogItem(
|
||||
question.Id,
|
||||
question.LegacyId,
|
||||
question.EntryId,
|
||||
question.ContentNodeId,
|
||||
question.PrimaryCollectionId,
|
||||
question.SubjectId,
|
||||
question.CategoryId,
|
||||
question.NodeId,
|
||||
question.Type,
|
||||
question.TypeLabel,
|
||||
question.Difficulty,
|
||||
question.Tags,
|
||||
question.ExamMarkers,
|
||||
question.MediaUrl,
|
||||
question.HasVideoExplanation,
|
||||
item.SectionKey,
|
||||
item.Score,
|
||||
item.SortOrder,
|
||||
version == null ? null : version.Id,
|
||||
version == null ? null : version.Content,
|
||||
version == null ? default : version.Options,
|
||||
version == null ? null : version.CorrectOptionIndex,
|
||||
version == null ? default : version.CorrectOptionIndices,
|
||||
version == null ? null : version.AnswerText,
|
||||
version == null ? null : version.Explanation,
|
||||
version == null ? default : version.SubQuestions,
|
||||
version == null ? null : version.CodeLang,
|
||||
version == null ? null : version.CodeTemplate);
|
||||
|
||||
var items = await query
|
||||
.Take(ResolveLimit(filter.Limit, MaxLimit))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
return new CatalogList<CollectionQuestionCatalogItem>(items);
|
||||
}
|
||||
|
||||
private static IQueryable<T> ApplyKeyword<T>(IQueryable<T> query, string? keyword)
|
||||
where T : class
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
return query;
|
||||
}
|
||||
|
||||
var trimmed = keyword.Trim();
|
||||
return query.Where(entity => EF.Property<string>(entity, nameof(ContentEntry.Name)).Contains(trimmed));
|
||||
}
|
||||
|
||||
private static int ResolveLimit(int? limit, int defaultLimit)
|
||||
{
|
||||
return Math.Clamp(limit ?? defaultLimit, 1, MaxLimit);
|
||||
}
|
||||
|
||||
private static bool TryParseEntryType(string? value, out ContentEntryType type)
|
||||
{
|
||||
return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out type);
|
||||
}
|
||||
|
||||
private static bool TryParseCollectionType(string? value, out QuestionCollectionType type)
|
||||
{
|
||||
return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out type);
|
||||
}
|
||||
|
||||
private static bool TryParsePracticeMode(string? value, out PracticeMode mode)
|
||||
{
|
||||
return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out mode);
|
||||
}
|
||||
|
||||
private static bool TryParseMarkerType(string? value, out ContentMarkerType type)
|
||||
{
|
||||
return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out type);
|
||||
}
|
||||
|
||||
private static string? NormalizeEnumValue(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value)
|
||||
? null
|
||||
: value.Replace("_", string.Empty, StringComparison.Ordinal)
|
||||
.Replace("-", string.Empty, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RequiredFieldException(string message) : Exception(message);
|
||||
|
||||
public sealed class ContentNavigationNotFoundException(string message) : Exception(message);
|
||||
@@ -3,8 +3,10 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Npgsql;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Infrastructure.Auth;
|
||||
using Tiku.Infrastructure.Catalog;
|
||||
using Tiku.Infrastructure.Content;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure;
|
||||
@@ -31,6 +33,7 @@ public static class DependencyInjection
|
||||
services.AddHttpClient<IWechatOAuthClient, WechatOAuthClient>();
|
||||
services.AddScoped<IAuthService, AuthService>();
|
||||
services.AddScoped<ICatalogQueryService, CatalogQueryService>();
|
||||
services.AddScoped<IContentNavigationQueryService, ContentNavigationQueryService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
269
Tiku.IntegrationTests/Api/ContentNavigationEndpointTests.cs
Normal file
269
Tiku.IntegrationTests/Api/ContentNavigationEndpointTests.cs
Normal file
@@ -0,0 +1,269 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class ContentNavigationEndpointTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Content_entries_hide_hidden_items_by_default()
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var regionId = Guid.NewGuid();
|
||||
await using var factory = new ApiTestFactory();
|
||||
await factory.SeedAsync(
|
||||
Tenant(tenantId, "master"),
|
||||
new ContentEntry
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
RegionId = regionId,
|
||||
EntryKey = "practice",
|
||||
Name = "公开练习",
|
||||
EntryType = ContentEntryType.QuestionPractice,
|
||||
Visibility = ContentVisibility.Public,
|
||||
SortOrder = 2
|
||||
},
|
||||
new ContentEntry
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
EntryKey = "global",
|
||||
Name = "全局入口",
|
||||
EntryType = ContentEntryType.QuestionPractice,
|
||||
Visibility = ContentVisibility.Public,
|
||||
SortOrder = 1
|
||||
},
|
||||
new ContentEntry
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
EntryKey = "hidden",
|
||||
Name = "隐藏入口",
|
||||
EntryType = ContentEntryType.QuestionPractice,
|
||||
Visibility = ContentVisibility.Hidden
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var response = await client.GetAsync($"/api/catalog/content-entries?tenantCode=master®ionId={regionId}");
|
||||
var items = await ReadItemsAsync(response);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(["全局入口", "公开练习"], items.Select(item => item.GetProperty("name").GetString()!).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_nodes_support_required_entry_and_root_filter()
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var entryId = Guid.NewGuid();
|
||||
var rootNodeId = Guid.NewGuid();
|
||||
await using var factory = new ApiTestFactory();
|
||||
await factory.SeedAsync(
|
||||
Tenant(tenantId, "master"),
|
||||
new ContentEntry
|
||||
{
|
||||
Id = entryId,
|
||||
TenantId = tenantId,
|
||||
EntryKey = "practice",
|
||||
Name = "练习入口"
|
||||
},
|
||||
new ContentNode
|
||||
{
|
||||
Id = rootNodeId,
|
||||
TenantId = tenantId,
|
||||
EntryId = entryId,
|
||||
Name = "根内容节点",
|
||||
NodeKey = "root",
|
||||
SortOrder = 1,
|
||||
IsLeaf = false
|
||||
},
|
||||
new ContentNode
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
EntryId = entryId,
|
||||
ParentId = rootNodeId,
|
||||
Name = "子内容节点",
|
||||
NodeKey = "child",
|
||||
SortOrder = 2,
|
||||
IsLeaf = true
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var missingEntryResponse = await client.GetAsync("/api/catalog/content-nodes?tenantCode=master");
|
||||
using var response = await client.GetAsync($"/api/catalog/content-nodes?tenantCode=master&entryId={entryId}&parentId=root");
|
||||
var items = await ReadItemsAsync(response);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, missingEntryResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var item = Assert.Single(items);
|
||||
Assert.Equal("根内容节点", item.GetProperty("name").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Collections_and_blueprints_return_active_items_only()
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var entryId = Guid.NewGuid();
|
||||
var nodeId = Guid.NewGuid();
|
||||
var collectionId = Guid.NewGuid();
|
||||
await using var factory = new ApiTestFactory();
|
||||
await factory.SeedAsync(
|
||||
Tenant(tenantId, "master"),
|
||||
new ContentEntry { Id = entryId, TenantId = tenantId, EntryKey = "practice", Name = "练习入口" },
|
||||
new ContentNode { Id = nodeId, TenantId = tenantId, EntryId = entryId, Name = "章节" },
|
||||
new QuestionCollection
|
||||
{
|
||||
Id = collectionId,
|
||||
TenantId = tenantId,
|
||||
EntryId = entryId,
|
||||
NodeId = nodeId,
|
||||
Name = "章节题集",
|
||||
CollectionType = QuestionCollectionType.Chapter,
|
||||
Status = ContentStatus.Active,
|
||||
SortOrder = 1
|
||||
},
|
||||
new QuestionCollection
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
EntryId = entryId,
|
||||
NodeId = nodeId,
|
||||
Name = "草稿题集",
|
||||
Status = ContentStatus.Draft
|
||||
},
|
||||
new PracticeBlueprint
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
EntryId = entryId,
|
||||
NodeId = nodeId,
|
||||
CollectionId = collectionId,
|
||||
Name = "章节练习",
|
||||
Mode = PracticeMode.Sequential,
|
||||
Status = ContentStatus.Active
|
||||
},
|
||||
new PracticeBlueprint
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
EntryId = entryId,
|
||||
NodeId = nodeId,
|
||||
CollectionId = collectionId,
|
||||
Name = "归档练习",
|
||||
Mode = PracticeMode.Sequential,
|
||||
Status = ContentStatus.Archived
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var collectionsResponse = await client.GetAsync(
|
||||
$"/api/catalog/question-collections?tenantCode=master&entryId={entryId}&nodeId={nodeId}&collectionType=chapter");
|
||||
using var blueprintsResponse = await client.GetAsync(
|
||||
$"/api/catalog/practice-blueprints?tenantCode=master&entryId={entryId}&nodeId={nodeId}&collectionId={collectionId}&mode=sequential");
|
||||
var collections = await ReadItemsAsync(collectionsResponse);
|
||||
var blueprints = await ReadItemsAsync(blueprintsResponse);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, collectionsResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, blueprintsResponse.StatusCode);
|
||||
Assert.Equal("章节题集", Assert.Single(collections).GetProperty("name").GetString());
|
||||
Assert.Equal("章节练习", Assert.Single(blueprints).GetProperty("name").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Collection_questions_return_published_questions_only()
|
||||
{
|
||||
var tenantId = Guid.NewGuid();
|
||||
var collectionId = Guid.NewGuid();
|
||||
var publishedQuestionId = Guid.NewGuid();
|
||||
var archivedQuestionId = Guid.NewGuid();
|
||||
var versionId = Guid.NewGuid();
|
||||
await using var factory = new ApiTestFactory();
|
||||
await factory.SeedAsync(
|
||||
Tenant(tenantId, "master"),
|
||||
new QuestionCollection
|
||||
{
|
||||
Id = collectionId,
|
||||
TenantId = tenantId,
|
||||
Name = "真题套卷",
|
||||
Status = ContentStatus.Active
|
||||
},
|
||||
new Question
|
||||
{
|
||||
Id = publishedQuestionId,
|
||||
TenantId = tenantId,
|
||||
PrimaryCollectionId = collectionId,
|
||||
Type = "choice",
|
||||
TypeLabel = "单选题",
|
||||
Status = QuestionStatus.Published,
|
||||
CurrentVersionId = versionId
|
||||
},
|
||||
new QuestionVersion
|
||||
{
|
||||
Id = versionId,
|
||||
TenantId = tenantId,
|
||||
QuestionId = publishedQuestionId,
|
||||
Content = "题干"
|
||||
},
|
||||
new Question
|
||||
{
|
||||
Id = archivedQuestionId,
|
||||
TenantId = tenantId,
|
||||
PrimaryCollectionId = collectionId,
|
||||
Type = "choice",
|
||||
Status = QuestionStatus.Archived
|
||||
},
|
||||
new QuestionCollectionItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
CollectionId = collectionId,
|
||||
QuestionId = publishedQuestionId,
|
||||
SortOrder = 1,
|
||||
Score = 2
|
||||
},
|
||||
new QuestionCollectionItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
CollectionId = collectionId,
|
||||
QuestionId = archivedQuestionId,
|
||||
SortOrder = 2
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var response = await client.GetAsync($"/api/catalog/question-collections/questions?tenantCode=master&collectionId={collectionId}");
|
||||
var items = await ReadItemsAsync(response);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var item = Assert.Single(items);
|
||||
Assert.Equal(publishedQuestionId, item.GetProperty("id").GetGuid());
|
||||
Assert.Equal("题干", item.GetProperty("content").GetString());
|
||||
}
|
||||
|
||||
private static Tenant Tenant(Guid id, string slug)
|
||||
{
|
||||
return new Tenant
|
||||
{
|
||||
Id = id,
|
||||
Slug = slug,
|
||||
Name = slug,
|
||||
Status = TenantStatus.Active,
|
||||
Metadata = JsonDefaults.Object()
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<JsonElement[]> ReadItemsAsync(HttpResponseMessage response)
|
||||
{
|
||||
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
return body.RootElement
|
||||
.GetProperty("items")
|
||||
.EnumerateArray()
|
||||
.Select(item => item.Clone())
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user