forked from xiongyuxing/tiku-backend.net
feat: add question bank readonly endpoints
This commit is contained in:
@@ -246,10 +246,10 @@ dotnet ef migrations script \
|
|||||||
建议下一批迁移顺序:
|
建议下一批迁移顺序:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Question Bank 只读 API
|
|
||||||
-> 题库列表 / 题目详情 / 题目版本
|
|
||||||
Vocabulary / Handbook 只读 API
|
Vocabulary / Handbook 只读 API
|
||||||
-> 词汇单元 / 单词 / 知识手册
|
-> 词汇单元 / 单词 / 知识手册
|
||||||
|
Assets / Video 只读 API
|
||||||
|
-> 内容资源 / 图片 / 视频解析
|
||||||
```
|
```
|
||||||
|
|
||||||
## 当前状态
|
## 当前状态
|
||||||
@@ -260,5 +260,6 @@ Vocabulary / Handbook 只读 API
|
|||||||
- 租户公开入口已建立:tenant resolve、public config、health。
|
- 租户公开入口已建立:tenant resolve、public config、health。
|
||||||
- Catalog 基础只读 API 已建立:地区、地区模块、模块节点、院校、专业、科目、题目分类。
|
- Catalog 基础只读 API 已建立:地区、地区模块、模块节点、院校、专业、科目、题目分类。
|
||||||
- Content Navigation 只读 API 已建立:内容入口、内容节点、题集、题集题目、练习蓝图。
|
- Content Navigation 只读 API 已建立:内容入口、内容节点、题集、题集题目、练习蓝图。
|
||||||
- 当前模型测试、认证服务测试、API 认证/租户闭环测试、Catalog / Content Navigation 只读测试通过。
|
- Question Bank 只读 API 已建立:题库列表、题目列表、题目详情、题目版本。
|
||||||
|
- 当前模型测试、认证服务测试、API 认证/租户闭环测试、Catalog / Content Navigation / Question Bank 只读测试通过。
|
||||||
- 下一步重点是继续把题库、内容、导入、订单等业务 API 接入这套安全轨道,而不是重新散写权限判断。
|
- 下一步重点是继续把题库、内容、导入、订单等业务 API 接入这套安全轨道,而不是重新散写权限判断。
|
||||||
|
|||||||
76
Tiku.Api/Contracts/QuestionBankDtos.cs
Normal file
76
Tiku.Api/Contracts/QuestionBankDtos.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Tiku.Api.Contracts;
|
using Tiku.Api.Contracts;
|
||||||
using Tiku.Application.Catalog;
|
using Tiku.Application.Catalog;
|
||||||
using Tiku.Application.Content;
|
using Tiku.Application.Content;
|
||||||
|
using Tiku.Application.QuestionBanks;
|
||||||
using Tiku.Application.Security;
|
using Tiku.Application.Security;
|
||||||
using Tiku.Domain.Tenancy;
|
using Tiku.Domain.Tenancy;
|
||||||
using Tiku.Infrastructure.Persistence;
|
using Tiku.Infrastructure.Persistence;
|
||||||
@@ -17,6 +18,7 @@ namespace Tiku.Api.Controllers;
|
|||||||
public sealed class CatalogController(
|
public sealed class CatalogController(
|
||||||
ICatalogQueryService catalogQueryService,
|
ICatalogQueryService catalogQueryService,
|
||||||
IContentNavigationQueryService contentNavigationQueryService,
|
IContentNavigationQueryService contentNavigationQueryService,
|
||||||
|
IQuestionBankQueryService questionBankQueryService,
|
||||||
ICurrentTenant currentTenant,
|
ICurrentTenant currentTenant,
|
||||||
TikuDbContext dbContext) : ControllerBase
|
TikuDbContext dbContext) : ControllerBase
|
||||||
{
|
{
|
||||||
@@ -179,6 +181,61 @@ public sealed class CatalogController(
|
|||||||
cancellationToken));
|
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(
|
private async Task<Guid> ResolveTenantIdAsync(
|
||||||
CatalogQueryDto query,
|
CatalogQueryDto query,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
@@ -215,6 +272,18 @@ public sealed class CatalogController(
|
|||||||
},
|
},
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Task<Guid> ResolveTenantIdAsync(
|
||||||
|
QuestionBankQueryDto query,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return ResolveTenantIdAsync(
|
||||||
|
new CatalogQueryDto
|
||||||
|
{
|
||||||
|
TenantCode = query.TenantCode
|
||||||
|
},
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class TenantNotFoundException : Exception
|
public sealed class TenantNotFoundException : Exception
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using Tiku.Api.Controllers;
|
using Tiku.Api.Controllers;
|
||||||
using Tiku.Application.Auth;
|
using Tiku.Application.Auth;
|
||||||
using Tiku.Infrastructure.Content;
|
using Tiku.Infrastructure.Content;
|
||||||
|
using Tiku.Infrastructure.QuestionBanks;
|
||||||
|
|
||||||
namespace Tiku.Api.Middleware;
|
namespace Tiku.Api.Middleware;
|
||||||
|
|
||||||
@@ -54,6 +55,26 @@ public sealed class ExceptionHandlingMiddleware(
|
|||||||
return;
|
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");
|
logger.LogError(exception, "Unhandled API exception");
|
||||||
|
|
||||||
var problem = new ProblemDetails
|
var problem = new ProblemDetails
|
||||||
|
|||||||
22
Tiku.Application/QuestionBanks/IQuestionBankQueryService.cs
Normal file
22
Tiku.Application/QuestionBanks/IQuestionBankQueryService.cs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
using Tiku.Application.Catalog;
|
||||||
|
|
||||||
|
namespace Tiku.Application.QuestionBanks;
|
||||||
|
|
||||||
|
public interface IQuestionBankQueryService
|
||||||
|
{
|
||||||
|
Task<CatalogList<QuestionBankCatalogItem>> GetQuestionBanksAsync(
|
||||||
|
QuestionBankFilter filter,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<CatalogList<QuestionCatalogItem>> GetQuestionsAsync(
|
||||||
|
QuestionBankFilter filter,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<QuestionCatalogItem> GetQuestionAsync(
|
||||||
|
QuestionBankFilter filter,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<CatalogList<QuestionVersionCatalogItem>> GetQuestionVersionsAsync(
|
||||||
|
QuestionBankFilter filter,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
76
Tiku.Application/QuestionBanks/QuestionBankQueryModels.cs
Normal file
76
Tiku.Application/QuestionBanks/QuestionBankQueryModels.cs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Tiku.Domain.QuestionBanks;
|
||||||
|
|
||||||
|
namespace Tiku.Application.QuestionBanks;
|
||||||
|
|
||||||
|
public sealed record QuestionBankFilter(
|
||||||
|
Guid TenantId,
|
||||||
|
Guid? RegionId = null,
|
||||||
|
Guid? QuestionBankId = null,
|
||||||
|
Guid? SubjectId = null,
|
||||||
|
Guid? CategoryId = null,
|
||||||
|
Guid? NodeId = null,
|
||||||
|
Guid? EntryId = null,
|
||||||
|
Guid? ContentNodeId = null,
|
||||||
|
Guid? CollectionId = null,
|
||||||
|
Guid? QuestionId = null,
|
||||||
|
IReadOnlyCollection<Guid>? QuestionIds = null,
|
||||||
|
string? Type = null,
|
||||||
|
string? Keyword = null,
|
||||||
|
int? Limit = null);
|
||||||
|
|
||||||
|
public sealed record QuestionBankCatalogItem(
|
||||||
|
Guid Id,
|
||||||
|
Guid? RegionId,
|
||||||
|
string Name,
|
||||||
|
QuestionBankScope SourceScope,
|
||||||
|
QuestionBankStatus Status,
|
||||||
|
JsonElement Metadata);
|
||||||
|
|
||||||
|
public sealed record QuestionCatalogItem(
|
||||||
|
Guid Id,
|
||||||
|
Guid? QuestionBankId,
|
||||||
|
Guid? SubjectId,
|
||||||
|
Guid? CategoryId,
|
||||||
|
Guid? NodeId,
|
||||||
|
Guid? EntryId,
|
||||||
|
Guid? ContentNodeId,
|
||||||
|
Guid? PrimaryCollectionId,
|
||||||
|
string? LegacyId,
|
||||||
|
string? LegacySubjectId,
|
||||||
|
string? LegacyCategoryId,
|
||||||
|
string? LegacyNodeId,
|
||||||
|
string Type,
|
||||||
|
string? TypeLabel,
|
||||||
|
int? Difficulty,
|
||||||
|
JsonElement Tags,
|
||||||
|
JsonElement ExamMarkers,
|
||||||
|
string? MediaUrl,
|
||||||
|
bool HasVideoExplanation,
|
||||||
|
QuestionStatus Status,
|
||||||
|
Guid? VersionId,
|
||||||
|
int? VersionNo,
|
||||||
|
string? Content,
|
||||||
|
JsonElement Options,
|
||||||
|
int? CorrectOptionIndex,
|
||||||
|
JsonElement CorrectOptionIndices,
|
||||||
|
string? AnswerText,
|
||||||
|
string? Explanation,
|
||||||
|
JsonElement SubQuestions,
|
||||||
|
string? CodeLang,
|
||||||
|
string? CodeTemplate);
|
||||||
|
|
||||||
|
public sealed record QuestionVersionCatalogItem(
|
||||||
|
Guid Id,
|
||||||
|
Guid QuestionId,
|
||||||
|
int VersionNo,
|
||||||
|
string? Content,
|
||||||
|
JsonElement Options,
|
||||||
|
int? CorrectOptionIndex,
|
||||||
|
JsonElement CorrectOptionIndices,
|
||||||
|
string? AnswerText,
|
||||||
|
string? Explanation,
|
||||||
|
JsonElement SubQuestions,
|
||||||
|
string? CodeLang,
|
||||||
|
string? CodeTemplate,
|
||||||
|
DateTimeOffset CreatedAt);
|
||||||
@@ -4,10 +4,12 @@ using Npgsql;
|
|||||||
using Tiku.Application.Auth;
|
using Tiku.Application.Auth;
|
||||||
using Tiku.Application.Catalog;
|
using Tiku.Application.Catalog;
|
||||||
using Tiku.Application.Content;
|
using Tiku.Application.Content;
|
||||||
|
using Tiku.Application.QuestionBanks;
|
||||||
using Tiku.Infrastructure.Auth;
|
using Tiku.Infrastructure.Auth;
|
||||||
using Tiku.Infrastructure.Catalog;
|
using Tiku.Infrastructure.Catalog;
|
||||||
using Tiku.Infrastructure.Content;
|
using Tiku.Infrastructure.Content;
|
||||||
using Tiku.Infrastructure.Persistence;
|
using Tiku.Infrastructure.Persistence;
|
||||||
|
using Tiku.Infrastructure.QuestionBanks;
|
||||||
|
|
||||||
namespace Tiku.Infrastructure;
|
namespace Tiku.Infrastructure;
|
||||||
|
|
||||||
@@ -34,6 +36,7 @@ public static class DependencyInjection
|
|||||||
services.AddScoped<IAuthService, AuthService>();
|
services.AddScoped<IAuthService, AuthService>();
|
||||||
services.AddScoped<ICatalogQueryService, CatalogQueryService>();
|
services.AddScoped<ICatalogQueryService, CatalogQueryService>();
|
||||||
services.AddScoped<IContentNavigationQueryService, ContentNavigationQueryService>();
|
services.AddScoped<IContentNavigationQueryService, ContentNavigationQueryService>();
|
||||||
|
services.AddScoped<IQuestionBankQueryService, QuestionBankQueryService>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|||||||
274
Tiku.Infrastructure/QuestionBanks/QuestionBankQueryService.cs
Normal file
274
Tiku.Infrastructure/QuestionBanks/QuestionBankQueryService.cs
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Tiku.Application.Catalog;
|
||||||
|
using Tiku.Application.QuestionBanks;
|
||||||
|
using Tiku.Domain.Content;
|
||||||
|
using Tiku.Domain.QuestionBanks;
|
||||||
|
using Tiku.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace Tiku.Infrastructure.QuestionBanks;
|
||||||
|
|
||||||
|
public sealed class QuestionBankQueryService(TikuDbContext dbContext) : IQuestionBankQueryService
|
||||||
|
{
|
||||||
|
private const int DefaultQuestionLimit = 200;
|
||||||
|
private const int MaxQuestionLimit = 500;
|
||||||
|
private const int DefaultBankLimit = 100;
|
||||||
|
private const int MaxBankLimit = 500;
|
||||||
|
|
||||||
|
public async Task<CatalogList<QuestionBankCatalogItem>> GetQuestionBanksAsync(
|
||||||
|
QuestionBankFilter filter,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var query = dbContext.QuestionBanks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(bank =>
|
||||||
|
bank.TenantId == filter.TenantId &&
|
||||||
|
bank.Status == QuestionBankStatus.Active);
|
||||||
|
|
||||||
|
if (filter.RegionId.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(bank => bank.RegionId == filter.RegionId.Value || bank.RegionId == null);
|
||||||
|
}
|
||||||
|
|
||||||
|
query = ApplyKeyword(query, filter.Keyword);
|
||||||
|
|
||||||
|
var items = await query
|
||||||
|
.OrderBy(bank => bank.Name)
|
||||||
|
.ThenBy(bank => bank.CreatedAt)
|
||||||
|
.Take(ResolveLimit(filter.Limit, DefaultBankLimit, MaxBankLimit))
|
||||||
|
.Select(bank => new QuestionBankCatalogItem(
|
||||||
|
bank.Id,
|
||||||
|
bank.RegionId,
|
||||||
|
bank.Name,
|
||||||
|
bank.SourceScope,
|
||||||
|
bank.Status,
|
||||||
|
bank.Metadata))
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new CatalogList<QuestionBankCatalogItem>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CatalogList<QuestionCatalogItem>> GetQuestionsAsync(
|
||||||
|
QuestionBankFilter filter,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var questions = ApplyQuestionFilters(BaseQuestionQuery(), filter)
|
||||||
|
.OrderByDescending(question => question.CreatedAt)
|
||||||
|
.Take(ResolveLimit(filter.Limit, DefaultQuestionLimit, MaxQuestionLimit));
|
||||||
|
|
||||||
|
var items = await ProjectQuestions(questions)
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new CatalogList<QuestionCatalogItem>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<QuestionCatalogItem> GetQuestionAsync(
|
||||||
|
QuestionBankFilter filter,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!filter.QuestionId.HasValue)
|
||||||
|
{
|
||||||
|
throw new QuestionBankRequiredFieldException("questionId is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var questions = ApplyQuestionFilters(BaseQuestionQuery(), filter)
|
||||||
|
.Where(question => question.Id == filter.QuestionId.Value);
|
||||||
|
|
||||||
|
var question = await ProjectQuestions(questions)
|
||||||
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
return question ?? throw new QuestionBankNotFoundException("Question was not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CatalogList<QuestionVersionCatalogItem>> GetQuestionVersionsAsync(
|
||||||
|
QuestionBankFilter filter,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!filter.QuestionId.HasValue)
|
||||||
|
{
|
||||||
|
throw new QuestionBankRequiredFieldException("questionId is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var questionExists = await dbContext.Questions
|
||||||
|
.AsNoTracking()
|
||||||
|
.AnyAsync(
|
||||||
|
question =>
|
||||||
|
question.TenantId == filter.TenantId &&
|
||||||
|
question.Id == filter.QuestionId.Value &&
|
||||||
|
question.Status == QuestionStatus.Published,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (!questionExists)
|
||||||
|
{
|
||||||
|
throw new QuestionBankNotFoundException("Question was not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = await dbContext.QuestionVersions
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(version =>
|
||||||
|
version.TenantId == filter.TenantId &&
|
||||||
|
version.QuestionId == filter.QuestionId.Value)
|
||||||
|
.OrderByDescending(version => version.VersionNo)
|
||||||
|
.Select(version => new QuestionVersionCatalogItem(
|
||||||
|
version.Id,
|
||||||
|
version.QuestionId,
|
||||||
|
version.VersionNo,
|
||||||
|
version.Content,
|
||||||
|
version.Options,
|
||||||
|
version.CorrectOptionIndex,
|
||||||
|
version.CorrectOptionIndices,
|
||||||
|
version.AnswerText,
|
||||||
|
version.Explanation,
|
||||||
|
version.SubQuestions,
|
||||||
|
version.CodeLang,
|
||||||
|
version.CodeTemplate,
|
||||||
|
version.CreatedAt))
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new CatalogList<QuestionVersionCatalogItem>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IQueryable<Question> BaseQuestionQuery()
|
||||||
|
{
|
||||||
|
return dbContext.Questions
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(question => question.Status == QuestionStatus.Published);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IQueryable<Question> ApplyQuestionFilters(
|
||||||
|
IQueryable<Question> query,
|
||||||
|
QuestionBankFilter filter)
|
||||||
|
{
|
||||||
|
query = query.Where(question => question.TenantId == filter.TenantId);
|
||||||
|
|
||||||
|
if (filter.QuestionBankId.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(question => question.QuestionBankId == filter.QuestionBankId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.SubjectId.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(question => question.SubjectId == filter.SubjectId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.CategoryId.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(question => question.CategoryId == filter.CategoryId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.NodeId.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(question => question.NodeId == filter.NodeId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.EntryId.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(question => question.EntryId == filter.EntryId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.ContentNodeId.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(question => question.ContentNodeId == filter.ContentNodeId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.CollectionId.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(question =>
|
||||||
|
question.PrimaryCollectionId == filter.CollectionId.Value ||
|
||||||
|
dbContext.QuestionCollectionItems.Any(item =>
|
||||||
|
item.TenantId == question.TenantId &&
|
||||||
|
item.QuestionId == question.Id &&
|
||||||
|
item.CollectionId == filter.CollectionId.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.QuestionIds?.Count > 0)
|
||||||
|
{
|
||||||
|
query = query.Where(question => filter.QuestionIds.Contains(question.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(filter.Type))
|
||||||
|
{
|
||||||
|
var type = filter.Type.Trim();
|
||||||
|
query = query.Where(question => question.Type == type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||||
|
{
|
||||||
|
var keyword = filter.Keyword.Trim();
|
||||||
|
query = query.Where(question =>
|
||||||
|
question.Type.Contains(keyword) ||
|
||||||
|
(question.TypeLabel != null && question.TypeLabel.Contains(keyword)) ||
|
||||||
|
dbContext.QuestionVersions.Any(version =>
|
||||||
|
version.TenantId == question.TenantId &&
|
||||||
|
version.QuestionId == question.Id &&
|
||||||
|
version.Id == question.CurrentVersionId &&
|
||||||
|
version.Content != null &&
|
||||||
|
version.Content.Contains(keyword)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(QuestionBank.Name)).Contains(trimmed));
|
||||||
|
}
|
||||||
|
|
||||||
|
private IQueryable<QuestionCatalogItem> ProjectQuestions(IQueryable<Question> questions)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
from question in questions
|
||||||
|
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()
|
||||||
|
select new QuestionCatalogItem(
|
||||||
|
question.Id,
|
||||||
|
question.QuestionBankId,
|
||||||
|
question.SubjectId,
|
||||||
|
question.CategoryId,
|
||||||
|
question.NodeId,
|
||||||
|
question.EntryId,
|
||||||
|
question.ContentNodeId,
|
||||||
|
question.PrimaryCollectionId,
|
||||||
|
question.LegacyId,
|
||||||
|
question.LegacySubjectId,
|
||||||
|
question.LegacyCategoryId,
|
||||||
|
question.LegacyNodeId,
|
||||||
|
question.Type,
|
||||||
|
question.TypeLabel,
|
||||||
|
question.Difficulty,
|
||||||
|
question.Tags,
|
||||||
|
question.ExamMarkers,
|
||||||
|
question.MediaUrl,
|
||||||
|
question.HasVideoExplanation,
|
||||||
|
question.Status,
|
||||||
|
version == null ? null : version.Id,
|
||||||
|
version == null ? null : version.VersionNo,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ResolveLimit(int? limit, int defaultLimit, int maxLimit)
|
||||||
|
{
|
||||||
|
return Math.Clamp(limit ?? defaultLimit, 1, maxLimit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class QuestionBankRequiredFieldException(string message) : Exception(message);
|
||||||
|
|
||||||
|
public sealed class QuestionBankNotFoundException(string message) : Exception(message);
|
||||||
202
Tiku.IntegrationTests/Api/QuestionBankEndpointTests.cs
Normal file
202
Tiku.IntegrationTests/Api/QuestionBankEndpointTests.cs
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
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 QuestionBankEndpointTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Question_banks_return_active_tenant_banks_only()
|
||||||
|
{
|
||||||
|
var tenantId = Guid.NewGuid();
|
||||||
|
var otherTenantId = Guid.NewGuid();
|
||||||
|
await using var factory = new ApiTestFactory();
|
||||||
|
await factory.SeedAsync(
|
||||||
|
Tenant(tenantId, "master"),
|
||||||
|
Tenant(otherTenantId, "other"),
|
||||||
|
new QuestionBank
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
TenantId = tenantId,
|
||||||
|
Name = "A 题库",
|
||||||
|
Status = QuestionBankStatus.Active
|
||||||
|
},
|
||||||
|
new QuestionBank
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
TenantId = tenantId,
|
||||||
|
Name = "归档题库",
|
||||||
|
Status = QuestionBankStatus.Archived
|
||||||
|
},
|
||||||
|
new QuestionBank
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
TenantId = otherTenantId,
|
||||||
|
Name = "其他租户题库",
|
||||||
|
Status = QuestionBankStatus.Active
|
||||||
|
});
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
|
||||||
|
using var response = await client.GetAsync("/api/catalog/question-banks?tenantCode=master");
|
||||||
|
var items = await ReadItemsAsync(response);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
Assert.Equal("A 题库", Assert.Single(items).GetProperty("name").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Questions_support_filters_and_current_version_projection()
|
||||||
|
{
|
||||||
|
var tenantId = Guid.NewGuid();
|
||||||
|
var bankId = Guid.NewGuid();
|
||||||
|
var subjectId = Guid.NewGuid();
|
||||||
|
var categoryId = Guid.NewGuid();
|
||||||
|
var collectionId = Guid.NewGuid();
|
||||||
|
var includedQuestionId = Guid.NewGuid();
|
||||||
|
var excludedQuestionId = Guid.NewGuid();
|
||||||
|
var versionId = Guid.NewGuid();
|
||||||
|
await using var factory = new ApiTestFactory();
|
||||||
|
await factory.SeedAsync(
|
||||||
|
Tenant(tenantId, "master"),
|
||||||
|
new QuestionBank { Id = bankId, TenantId = tenantId, Name = "题库" },
|
||||||
|
new QuestionCollection { Id = collectionId, TenantId = tenantId, Name = "题集", Status = ContentStatus.Active },
|
||||||
|
new Question
|
||||||
|
{
|
||||||
|
Id = includedQuestionId,
|
||||||
|
TenantId = tenantId,
|
||||||
|
QuestionBankId = bankId,
|
||||||
|
SubjectId = subjectId,
|
||||||
|
CategoryId = categoryId,
|
||||||
|
Type = "choice",
|
||||||
|
TypeLabel = "单选题",
|
||||||
|
Difficulty = 2,
|
||||||
|
Status = QuestionStatus.Published,
|
||||||
|
CurrentVersionId = versionId
|
||||||
|
},
|
||||||
|
new QuestionVersion
|
||||||
|
{
|
||||||
|
Id = versionId,
|
||||||
|
TenantId = tenantId,
|
||||||
|
QuestionId = includedQuestionId,
|
||||||
|
VersionNo = 2,
|
||||||
|
Content = "题干关键词"
|
||||||
|
},
|
||||||
|
new QuestionCollectionItem
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
TenantId = tenantId,
|
||||||
|
CollectionId = collectionId,
|
||||||
|
QuestionId = includedQuestionId
|
||||||
|
},
|
||||||
|
new Question
|
||||||
|
{
|
||||||
|
Id = excludedQuestionId,
|
||||||
|
TenantId = tenantId,
|
||||||
|
QuestionBankId = bankId,
|
||||||
|
SubjectId = subjectId,
|
||||||
|
Type = "choice",
|
||||||
|
Status = QuestionStatus.Archived
|
||||||
|
});
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
|
||||||
|
using var response = await client.GetAsync(
|
||||||
|
$"/api/catalog/questions?tenantCode=master&questionBankId={bankId}&subjectId={subjectId}&categoryId={categoryId}&collectionId={collectionId}&type=choice&keyword=关键词");
|
||||||
|
var items = await ReadItemsAsync(response);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var item = Assert.Single(items);
|
||||||
|
Assert.Equal(includedQuestionId, item.GetProperty("id").GetGuid());
|
||||||
|
Assert.Equal(versionId, item.GetProperty("versionId").GetGuid());
|
||||||
|
Assert.Equal("题干关键词", item.GetProperty("content").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Question_detail_returns_not_found_for_archived_question()
|
||||||
|
{
|
||||||
|
var tenantId = Guid.NewGuid();
|
||||||
|
var questionId = Guid.NewGuid();
|
||||||
|
await using var factory = new ApiTestFactory();
|
||||||
|
await factory.SeedAsync(
|
||||||
|
Tenant(tenantId, "master"),
|
||||||
|
new Question
|
||||||
|
{
|
||||||
|
Id = questionId,
|
||||||
|
TenantId = tenantId,
|
||||||
|
Type = "choice",
|
||||||
|
Status = QuestionStatus.Archived
|
||||||
|
});
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
|
||||||
|
using var response = await client.GetAsync($"/api/catalog/questions/{questionId}?tenantCode=master");
|
||||||
|
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||||
|
Assert.Equal("question_not_found", body.RootElement.GetProperty("code").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Question_versions_are_returned_newest_first()
|
||||||
|
{
|
||||||
|
var tenantId = Guid.NewGuid();
|
||||||
|
var questionId = Guid.NewGuid();
|
||||||
|
await using var factory = new ApiTestFactory();
|
||||||
|
await factory.SeedAsync(
|
||||||
|
Tenant(tenantId, "master"),
|
||||||
|
new Question
|
||||||
|
{
|
||||||
|
Id = questionId,
|
||||||
|
TenantId = tenantId,
|
||||||
|
Type = "choice",
|
||||||
|
Status = QuestionStatus.Published
|
||||||
|
},
|
||||||
|
new QuestionVersion
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
TenantId = tenantId,
|
||||||
|
QuestionId = questionId,
|
||||||
|
VersionNo = 1,
|
||||||
|
Content = "旧版本"
|
||||||
|
},
|
||||||
|
new QuestionVersion
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
TenantId = tenantId,
|
||||||
|
QuestionId = questionId,
|
||||||
|
VersionNo = 2,
|
||||||
|
Content = "新版本"
|
||||||
|
});
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
|
||||||
|
using var response = await client.GetAsync($"/api/catalog/questions/{questionId}/versions?tenantCode=master");
|
||||||
|
var items = await ReadItemsAsync(response);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
Assert.Equal(["新版本", "旧版本"], items.Select(item => item.GetProperty("content").GetString()!).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
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