feat: add content navigation readonly endpoints

This commit is contained in:
xiong
2026-07-26 14:08:23 +08:00
parent d5379affba
commit 3221b581c8
9 changed files with 982 additions and 3 deletions

View 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);
}
}

View File

@@ -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

View File

@@ -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