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