feat: add question bank readonly endpoints

This commit is contained in:
xiong
2026-07-26 14:17:30 +08:00
parent 3221b581c8
commit 97e7819a34
9 changed files with 747 additions and 3 deletions

View File

@@ -0,0 +1,76 @@
using System.ComponentModel.DataAnnotations;
using Tiku.Application.QuestionBanks;
namespace Tiku.Api.Contracts;
public sealed class QuestionBankQueryDto
{
[StringLength(100)]
public string? TenantCode { get; set; }
public Guid? RegionId { get; set; }
public Guid? QuestionBankId { get; set; }
public Guid? SubjectId { get; set; }
public Guid? CategoryId { get; set; }
public Guid? NodeId { get; set; }
public Guid? EntryId { get; set; }
public Guid? ContentNodeId { get; set; }
public Guid? CollectionId { get; set; }
[StringLength(50)]
public string? Type { get; set; }
[StringLength(100)]
public string? Keyword { get; set; }
/// <summary>
/// 题目 ID 列表,支持逗号分隔;最多取前 300 个。
/// </summary>
public string? QuestionIds { get; set; }
[Range(1, 500)]
public int? Limit { get; set; }
public QuestionBankFilter ToFilter(Guid tenantId, Guid? questionId = null)
{
return new QuestionBankFilter(
tenantId,
RegionId,
QuestionBankId,
SubjectId,
CategoryId,
NodeId,
EntryId,
ContentNodeId,
CollectionId,
questionId,
ParseQuestionIds(),
Type,
Keyword,
Limit);
}
private Guid[] ParseQuestionIds()
{
if (string.IsNullOrWhiteSpace(QuestionIds))
{
return [];
}
return QuestionIds
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(value => Guid.TryParse(value, out var id) ? id : (Guid?)null)
.Where(id => id.HasValue)
.Select(id => id!.Value)
.Distinct()
.Take(300)
.ToArray();
}
}

View File

@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
using Tiku.Api.Contracts;
using Tiku.Application.Catalog;
using Tiku.Application.Content;
using Tiku.Application.QuestionBanks;
using Tiku.Application.Security;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
@@ -17,6 +18,7 @@ namespace Tiku.Api.Controllers;
public sealed class CatalogController(
ICatalogQueryService catalogQueryService,
IContentNavigationQueryService contentNavigationQueryService,
IQuestionBankQueryService questionBankQueryService,
ICurrentTenant currentTenant,
TikuDbContext dbContext) : ControllerBase
{
@@ -179,6 +181,61 @@ public sealed class CatalogController(
cancellationToken));
}
[HttpGet("question-banks")]
[EndpointSummary("查询题库列表")]
[ProducesResponseType<CatalogList<QuestionBankCatalogItem>>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CatalogList<QuestionBankCatalogItem>>> GetQuestionBanks(
[FromQuery] QuestionBankQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await questionBankQueryService.GetQuestionBanksAsync(
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
cancellationToken));
}
[HttpGet("questions")]
[EndpointSummary("查询已发布题目")]
[EndpointDescription("支持按题库、科目、分类、模块节点、内容入口、内容节点、题集或题目 ID 列表筛选。")]
[ProducesResponseType<CatalogList<QuestionCatalogItem>>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CatalogList<QuestionCatalogItem>>> GetQuestions(
[FromQuery] QuestionBankQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await questionBankQueryService.GetQuestionsAsync(
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
cancellationToken));
}
[HttpGet("questions/{questionId:guid}")]
[EndpointSummary("查询题目详情")]
[ProducesResponseType<QuestionCatalogItem>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<QuestionCatalogItem>> GetQuestion(
Guid questionId,
[FromQuery] QuestionBankQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await questionBankQueryService.GetQuestionAsync(
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken), questionId),
cancellationToken));
}
[HttpGet("questions/{questionId:guid}/versions")]
[EndpointSummary("查询题目版本")]
[ProducesResponseType<CatalogList<QuestionVersionCatalogItem>>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CatalogList<QuestionVersionCatalogItem>>> GetQuestionVersions(
Guid questionId,
[FromQuery] QuestionBankQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await questionBankQueryService.GetQuestionVersionsAsync(
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken), questionId),
cancellationToken));
}
private async Task<Guid> ResolveTenantIdAsync(
CatalogQueryDto query,
CancellationToken cancellationToken)
@@ -215,6 +272,18 @@ public sealed class CatalogController(
},
cancellationToken);
}
private Task<Guid> ResolveTenantIdAsync(
QuestionBankQueryDto query,
CancellationToken cancellationToken)
{
return ResolveTenantIdAsync(
new CatalogQueryDto
{
TenantCode = query.TenantCode
},
cancellationToken);
}
}
public sealed class TenantNotFoundException : Exception

View File

@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc;
using Tiku.Api.Controllers;
using Tiku.Application.Auth;
using Tiku.Infrastructure.Content;
using Tiku.Infrastructure.QuestionBanks;
namespace Tiku.Api.Middleware;
@@ -54,6 +55,26 @@ public sealed class ExceptionHandlingMiddleware(
return;
}
if (exception is QuestionBankRequiredFieldException)
{
await WriteProblemAsync(
context,
exception.Message,
StatusCodes.Status400BadRequest,
"required_field");
return;
}
if (exception is QuestionBankNotFoundException)
{
await WriteProblemAsync(
context,
exception.Message,
StatusCodes.Status404NotFound,
"question_not_found");
return;
}
logger.LogError(exception, "Unhandled API exception");
var problem = new ProblemDetails