From 97e7819a34de712bc9111cf8dfceb3c3f92cf079 Mon Sep 17 00:00:00 2001 From: xiong Date: Sun, 26 Jul 2026 14:17:30 +0800 Subject: [PATCH] feat: add question bank readonly endpoints --- README.md | 7 +- Tiku.Api/Contracts/QuestionBankDtos.cs | 76 +++++ Tiku.Api/Controllers/CatalogController.cs | 69 +++++ .../Middleware/ExceptionHandlingMiddleware.cs | 21 ++ .../IQuestionBankQueryService.cs | 22 ++ .../QuestionBanks/QuestionBankQueryModels.cs | 76 +++++ Tiku.Infrastructure/DependencyInjection.cs | 3 + .../QuestionBanks/QuestionBankQueryService.cs | 274 ++++++++++++++++++ .../Api/QuestionBankEndpointTests.cs | 202 +++++++++++++ 9 files changed, 747 insertions(+), 3 deletions(-) create mode 100644 Tiku.Api/Contracts/QuestionBankDtos.cs create mode 100644 Tiku.Application/QuestionBanks/IQuestionBankQueryService.cs create mode 100644 Tiku.Application/QuestionBanks/QuestionBankQueryModels.cs create mode 100644 Tiku.Infrastructure/QuestionBanks/QuestionBankQueryService.cs create mode 100644 Tiku.IntegrationTests/Api/QuestionBankEndpointTests.cs diff --git a/README.md b/README.md index 903ec2e..adb4513 100644 --- a/README.md +++ b/README.md @@ -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 接入这套安全轨道,而不是重新散写权限判断。 diff --git a/Tiku.Api/Contracts/QuestionBankDtos.cs b/Tiku.Api/Contracts/QuestionBankDtos.cs new file mode 100644 index 0000000..a319a51 --- /dev/null +++ b/Tiku.Api/Contracts/QuestionBankDtos.cs @@ -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; } + + /// + /// 题目 ID 列表,支持逗号分隔;最多取前 300 个。 + /// + 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(); + } +} diff --git a/Tiku.Api/Controllers/CatalogController.cs b/Tiku.Api/Controllers/CatalogController.cs index 9f04e63..eda099c 100644 --- a/Tiku.Api/Controllers/CatalogController.cs +++ b/Tiku.Api/Controllers/CatalogController.cs @@ -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>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetQuestionBanks( + [FromQuery] QuestionBankQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await questionBankQueryService.GetQuestionBanksAsync( + query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)), + cancellationToken)); + } + + [HttpGet("questions")] + [EndpointSummary("查询已发布题目")] + [EndpointDescription("支持按题库、科目、分类、模块节点、内容入口、内容节点、题集或题目 ID 列表筛选。")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetQuestions( + [FromQuery] QuestionBankQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await questionBankQueryService.GetQuestionsAsync( + query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)), + cancellationToken)); + } + + [HttpGet("questions/{questionId:guid}")] + [EndpointSummary("查询题目详情")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> 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>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetQuestionVersions( + Guid questionId, + [FromQuery] QuestionBankQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await questionBankQueryService.GetQuestionVersionsAsync( + query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken), questionId), + cancellationToken)); + } + private async Task ResolveTenantIdAsync( CatalogQueryDto query, CancellationToken cancellationToken) @@ -215,6 +272,18 @@ public sealed class CatalogController( }, cancellationToken); } + + private Task ResolveTenantIdAsync( + QuestionBankQueryDto query, + CancellationToken cancellationToken) + { + return ResolveTenantIdAsync( + new CatalogQueryDto + { + TenantCode = query.TenantCode + }, + cancellationToken); + } } public sealed class TenantNotFoundException : Exception diff --git a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs index 7512ef6..dfac6ae 100644 --- a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs +++ b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs @@ -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 diff --git a/Tiku.Application/QuestionBanks/IQuestionBankQueryService.cs b/Tiku.Application/QuestionBanks/IQuestionBankQueryService.cs new file mode 100644 index 0000000..c744dc3 --- /dev/null +++ b/Tiku.Application/QuestionBanks/IQuestionBankQueryService.cs @@ -0,0 +1,22 @@ +using Tiku.Application.Catalog; + +namespace Tiku.Application.QuestionBanks; + +public interface IQuestionBankQueryService +{ + Task> GetQuestionBanksAsync( + QuestionBankFilter filter, + CancellationToken cancellationToken = default); + + Task> GetQuestionsAsync( + QuestionBankFilter filter, + CancellationToken cancellationToken = default); + + Task GetQuestionAsync( + QuestionBankFilter filter, + CancellationToken cancellationToken = default); + + Task> GetQuestionVersionsAsync( + QuestionBankFilter filter, + CancellationToken cancellationToken = default); +} diff --git a/Tiku.Application/QuestionBanks/QuestionBankQueryModels.cs b/Tiku.Application/QuestionBanks/QuestionBankQueryModels.cs new file mode 100644 index 0000000..b21ce6b --- /dev/null +++ b/Tiku.Application/QuestionBanks/QuestionBankQueryModels.cs @@ -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? 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); diff --git a/Tiku.Infrastructure/DependencyInjection.cs b/Tiku.Infrastructure/DependencyInjection.cs index e7595b6..ce67f15 100644 --- a/Tiku.Infrastructure/DependencyInjection.cs +++ b/Tiku.Infrastructure/DependencyInjection.cs @@ -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(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/Tiku.Infrastructure/QuestionBanks/QuestionBankQueryService.cs b/Tiku.Infrastructure/QuestionBanks/QuestionBankQueryService.cs new file mode 100644 index 0000000..d7f2c2d --- /dev/null +++ b/Tiku.Infrastructure/QuestionBanks/QuestionBankQueryService.cs @@ -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> 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(items); + } + + public async Task> 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(items); + } + + public async Task 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> 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(items); + } + + private IQueryable BaseQuestionQuery() + { + return dbContext.Questions + .AsNoTracking() + .Where(question => question.Status == QuestionStatus.Published); + } + + private IQueryable ApplyQuestionFilters( + IQueryable 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 ApplyKeyword(IQueryable query, string? keyword) + where T : class + { + if (string.IsNullOrWhiteSpace(keyword)) + { + return query; + } + + var trimmed = keyword.Trim(); + return query.Where(entity => EF.Property(entity, nameof(QuestionBank.Name)).Contains(trimmed)); + } + + private IQueryable ProjectQuestions(IQueryable 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); diff --git a/Tiku.IntegrationTests/Api/QuestionBankEndpointTests.cs b/Tiku.IntegrationTests/Api/QuestionBankEndpointTests.cs new file mode 100644 index 0000000..1ca75fa --- /dev/null +++ b/Tiku.IntegrationTests/Api/QuestionBankEndpointTests.cs @@ -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 ReadItemsAsync(HttpResponseMessage response) + { + var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + return body.RootElement + .GetProperty("items") + .EnumerateArray() + .Select(item => item.Clone()) + .ToArray(); + } +}