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

@@ -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 接入这套安全轨道,而不是重新散写权限判断。

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

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

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

View File

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

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

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