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
|
||||
Question Bank 只读 API
|
||||
-> 题库列表 / 题目详情 / 题目版本
|
||||
Vocabulary / Handbook 只读 API
|
||||
-> 词汇单元 / 单词 / 知识手册
|
||||
Assets / Video 只读 API
|
||||
-> 内容资源 / 图片 / 视频解析
|
||||
```
|
||||
|
||||
## 当前状态
|
||||
@@ -260,5 +260,6 @@ Vocabulary / Handbook 只读 API
|
||||
- 租户公开入口已建立:tenant resolve、public config、health。
|
||||
- Catalog 基础只读 API 已建立:地区、地区模块、模块节点、院校、专业、科目、题目分类。
|
||||
- Content Navigation 只读 API 已建立:内容入口、内容节点、题集、题集题目、练习蓝图。
|
||||
- 当前模型测试、认证服务测试、API 认证/租户闭环测试、Catalog / Content Navigation 只读测试通过。
|
||||
- Question Bank 只读 API 已建立:题库列表、题目列表、题目详情、题目版本。
|
||||
- 当前模型测试、认证服务测试、API 认证/租户闭环测试、Catalog / Content Navigation / Question Bank 只读测试通过。
|
||||
- 下一步重点是继续把题库、内容、导入、订单等业务 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.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
|
||||
|
||||
@@ -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
|
||||
|
||||
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.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Infrastructure.Auth;
|
||||
using Tiku.Infrastructure.Catalog;
|
||||
using Tiku.Infrastructure.Content;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.QuestionBanks;
|
||||
|
||||
namespace Tiku.Infrastructure;
|
||||
|
||||
@@ -34,6 +36,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<IAuthService, AuthService>();
|
||||
services.AddScoped<ICatalogQueryService, CatalogQueryService>();
|
||||
services.AddScoped<IContentNavigationQueryService, ContentNavigationQueryService>();
|
||||
services.AddScoped<IQuestionBankQueryService, QuestionBankQueryService>();
|
||||
|
||||
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