feat: add content navigation readonly endpoints

This commit is contained in:
xiong
2026-07-26 14:08:23 +08:00
parent d5379affba
commit 3221b581c8
9 changed files with 982 additions and 3 deletions

View File

@@ -246,10 +246,10 @@ dotnet ef migrations script \
建议下一批迁移顺序:
```text
Content Navigation 只读 API
-> 内容入口 / 内容树 / 题集 / 练习蓝图
Question Bank 只读 API
-> 题库列表 / 题目详情 / 题目版本
Vocabulary / Handbook 只读 API
-> 词汇单元 / 单词 / 知识手册
```
## 当前状态
@@ -259,5 +259,6 @@ Question Bank 只读 API
- API 安全底座已建立JWT、Session、本地登录、当前用户、当前租户、基础授权策略。
- 租户公开入口已建立tenant resolve、public config、health。
- Catalog 基础只读 API 已建立:地区、地区模块、模块节点、院校、专业、科目、题目分类。
- 当前模型测试、认证服务测试、API 认证/租户闭环测试、Catalog 只读测试通过
- Content Navigation 只读 API 已建立:内容入口、内容节点、题集、题集题目、练习蓝图
- 当前模型测试、认证服务测试、API 认证/租户闭环测试、Catalog / Content Navigation 只读测试通过。
- 下一步重点是继续把题库、内容、导入、订单等业务 API 接入这套安全轨道,而不是重新散写权限判断。

View File

@@ -0,0 +1,71 @@
using System.ComponentModel.DataAnnotations;
using Tiku.Application.Content;
namespace Tiku.Api.Contracts;
public sealed class ContentNavigationQueryDto
{
[StringLength(100)]
public string? TenantCode { get; set; }
public Guid? RegionId { get; set; }
public Guid? EntryId { get; set; }
public Guid? NodeId { get; set; }
public Guid? CollectionId { get; set; }
[StringLength(64)]
[RegularExpression("^(root|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$")]
public string? ParentId { get; set; }
[StringLength(50)]
public string? EntryType { get; set; }
[StringLength(50)]
public string? CollectionType { get; set; }
[StringLength(50)]
public string? Mode { get; set; }
[StringLength(50)]
public string? MarkerType { get; set; }
[StringLength(100)]
public string? Keyword { get; set; }
public bool IncludeHidden { get; set; }
public bool IncludeInactive { get; set; }
[Range(1, 1000)]
public int? Limit { get; set; }
public ContentNavigationFilter ToFilter(Guid tenantId)
{
var parentWasSpecified = ParentId is not null;
var parentIsRoot = string.Equals(ParentId, "root", StringComparison.OrdinalIgnoreCase);
Guid? parentId = parentIsRoot || string.IsNullOrWhiteSpace(ParentId)
? null
: Guid.Parse(ParentId);
return new ContentNavigationFilter(
tenantId,
RegionId,
EntryId,
NodeId,
CollectionId,
parentId,
parentWasSpecified,
parentIsRoot,
EntryType,
CollectionType,
Mode,
MarkerType,
Keyword,
IncludeHidden,
IncludeInactive,
Limit);
}
}

View File

@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Tiku.Api.Contracts;
using Tiku.Application.Catalog;
using Tiku.Application.Content;
using Tiku.Application.Security;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
@@ -15,6 +16,7 @@ namespace Tiku.Api.Controllers;
[Route("api/catalog")]
public sealed class CatalogController(
ICatalogQueryService catalogQueryService,
IContentNavigationQueryService contentNavigationQueryService,
ICurrentTenant currentTenant,
TikuDbContext dbContext) : ControllerBase
{
@@ -110,6 +112,73 @@ public sealed class CatalogController(
cancellationToken));
}
[HttpGet("content-entries")]
[EndpointSummary("查询内容入口")]
[ProducesResponseType<CatalogList<ContentEntryCatalogItem>>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CatalogList<ContentEntryCatalogItem>>> GetContentEntries(
[FromQuery] ContentNavigationQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await contentNavigationQueryService.GetContentEntriesAsync(
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
cancellationToken));
}
[HttpGet("content-nodes")]
[EndpointSummary("查询内容导航节点")]
[ProducesResponseType<CatalogList<ContentNodeCatalogItem>>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CatalogList<ContentNodeCatalogItem>>> GetContentNodes(
[FromQuery] ContentNavigationQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await contentNavigationQueryService.GetContentNodesAsync(
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
cancellationToken));
}
[HttpGet("question-collections")]
[EndpointSummary("查询可用题集")]
[ProducesResponseType<CatalogList<QuestionCollectionCatalogItem>>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CatalogList<QuestionCollectionCatalogItem>>> GetQuestionCollections(
[FromQuery] ContentNavigationQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await contentNavigationQueryService.GetQuestionCollectionsAsync(
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
cancellationToken));
}
[HttpGet("question-collections/questions")]
[EndpointSummary("查询题集内题目")]
[ProducesResponseType<CatalogList<CollectionQuestionCatalogItem>>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CatalogList<CollectionQuestionCatalogItem>>> GetCollectionQuestions(
[FromQuery] ContentNavigationQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await contentNavigationQueryService.GetCollectionQuestionsAsync(
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
cancellationToken));
}
[HttpGet("practice-blueprints")]
[EndpointSummary("查询练习蓝图")]
[ProducesResponseType<CatalogList<PracticeBlueprintCatalogItem>>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<CatalogList<PracticeBlueprintCatalogItem>>> GetPracticeBlueprints(
[FromQuery] ContentNavigationQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await contentNavigationQueryService.GetPracticeBlueprintsAsync(
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
cancellationToken));
}
private async Task<Guid> ResolveTenantIdAsync(
CatalogQueryDto query,
CancellationToken cancellationToken)
@@ -134,6 +203,18 @@ public sealed class CatalogController(
return tenantId ?? throw new TenantNotFoundException();
}
private Task<Guid> ResolveTenantIdAsync(
ContentNavigationQueryDto query,
CancellationToken cancellationToken)
{
return ResolveTenantIdAsync(
new CatalogQueryDto
{
TenantCode = query.TenantCode
},
cancellationToken);
}
}
public sealed class TenantNotFoundException : Exception

View File

@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Mvc;
using Tiku.Api.Controllers;
using Tiku.Application.Auth;
using Tiku.Infrastructure.Content;
namespace Tiku.Api.Middleware;
@@ -33,6 +34,26 @@ public sealed class ExceptionHandlingMiddleware(
return;
}
if (exception is RequiredFieldException)
{
await WriteProblemAsync(
context,
exception.Message,
StatusCodes.Status400BadRequest,
"required_field");
return;
}
if (exception is ContentNavigationNotFoundException)
{
await WriteProblemAsync(
context,
exception.Message,
StatusCodes.Status404NotFound,
"content_navigation_not_found");
return;
}
logger.LogError(exception, "Unhandled API exception");
var problem = new ProblemDetails

View File

@@ -0,0 +1,127 @@
using System.Text.Json;
using Tiku.Domain.Content;
namespace Tiku.Application.Content;
public sealed record ContentNavigationFilter(
Guid TenantId,
Guid? RegionId = null,
Guid? EntryId = null,
Guid? NodeId = null,
Guid? CollectionId = null,
Guid? ParentId = null,
bool ParentWasSpecified = false,
bool ParentIsRoot = false,
string? EntryType = null,
string? CollectionType = null,
string? Mode = null,
string? MarkerType = null,
string? Keyword = null,
bool IncludeHidden = false,
bool IncludeInactive = false,
int? Limit = null);
public sealed record ContentEntryCatalogItem(
Guid Id,
Guid? RegionId,
string? LegacyId,
string EntryKey,
string Name,
ContentEntryType EntryType,
string? Icon,
string? Route,
string? Description,
ContentVisibility Visibility,
JsonElement AccessRules,
JsonElement LayoutConfig,
int Order);
public sealed record ContentNodeCatalogItem(
Guid Id,
Guid EntryId,
Guid? RegionId,
Guid? ParentId,
string? LegacyId,
string? NodeKey,
string Name,
ContentNodeType NodeType,
ContentMarkerType? MarkerType,
JsonElement MarkerConfig,
string? Path,
int Depth,
int Order,
bool IsSelectable,
bool IsLeaf,
JsonElement AccessRules,
JsonElement Metadata);
public sealed record QuestionCollectionCatalogItem(
Guid Id,
Guid? RegionId,
Guid? EntryId,
Guid? NodeId,
Guid? SubjectId,
Guid? CategoryId,
Guid? QuestionBankId,
string? LegacyId,
string Name,
QuestionCollectionType CollectionType,
QuestionCollectionSourceType SourceType,
JsonElement Filters,
int QuestionCount,
decimal? TotalScore,
int? DurationMinutes,
ContentStatus Status,
int Order,
JsonElement AccessRules,
JsonElement Metadata);
public sealed record PracticeBlueprintCatalogItem(
Guid Id,
Guid? RegionId,
Guid? EntryId,
Guid? NodeId,
Guid? CollectionId,
string? LegacyId,
string Name,
PracticeMode Mode,
PracticeAssemblyType AssemblyType,
int? QuestionLimit,
int? DurationMinutes,
decimal? TotalScore,
decimal? PassScore,
JsonElement Sections,
JsonElement Rules,
JsonElement AccessRules,
ContentStatus Status,
int Order);
public sealed record CollectionQuestionCatalogItem(
Guid Id,
string? LegacyId,
Guid? EntryId,
Guid? ContentNodeId,
Guid? PrimaryCollectionId,
Guid? SubjectId,
Guid? CategoryId,
Guid? NodeId,
string Type,
string? TypeLabel,
int? Difficulty,
JsonElement Tags,
JsonElement ExamMarkers,
string? MediaUrl,
bool HasVideoExplanation,
string? SectionKey,
decimal? Score,
int Order,
Guid? VersionId,
string? Content,
JsonElement Options,
int? CorrectOptionIndex,
JsonElement CorrectOptionIndices,
string? AnswerText,
string? Explanation,
JsonElement SubQuestions,
string? CodeLang,
string? CodeTemplate);

View File

@@ -0,0 +1,26 @@
using Tiku.Application.Catalog;
namespace Tiku.Application.Content;
public interface IContentNavigationQueryService
{
Task<CatalogList<ContentEntryCatalogItem>> GetContentEntriesAsync(
ContentNavigationFilter filter,
CancellationToken cancellationToken = default);
Task<CatalogList<ContentNodeCatalogItem>> GetContentNodesAsync(
ContentNavigationFilter filter,
CancellationToken cancellationToken = default);
Task<CatalogList<QuestionCollectionCatalogItem>> GetQuestionCollectionsAsync(
ContentNavigationFilter filter,
CancellationToken cancellationToken = default);
Task<CatalogList<PracticeBlueprintCatalogItem>> GetPracticeBlueprintsAsync(
ContentNavigationFilter filter,
CancellationToken cancellationToken = default);
Task<CatalogList<CollectionQuestionCatalogItem>> GetCollectionQuestionsAsync(
ContentNavigationFilter filter,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,380 @@
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Catalog;
using Tiku.Application.Content;
using Tiku.Domain.Content;
using Tiku.Domain.QuestionBanks;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Content;
public sealed class ContentNavigationQueryService(TikuDbContext dbContext) : IContentNavigationQueryService
{
private const int DefaultLimit = 100;
private const int MaxLimit = 1000;
public async Task<CatalogList<ContentEntryCatalogItem>> GetContentEntriesAsync(
ContentNavigationFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.ContentEntries
.AsNoTracking()
.Where(entry =>
entry.TenantId == filter.TenantId &&
entry.IsActive);
if (!filter.IncludeHidden)
{
query = query.Where(entry => entry.Visibility != ContentVisibility.Hidden);
}
if (filter.RegionId.HasValue)
{
query = query.Where(entry => entry.RegionId == filter.RegionId.Value || entry.RegionId == null);
}
if (TryParseEntryType(filter.EntryType, out var entryType))
{
query = query.Where(entry => entry.EntryType == entryType);
}
query = ApplyKeyword(query, filter.Keyword);
var items = await query
.OrderBy(entry => entry.SortOrder)
.ThenBy(entry => entry.CreatedAt)
.Take(ResolveLimit(filter.Limit, 500))
.Select(entry => new ContentEntryCatalogItem(
entry.Id,
entry.RegionId,
entry.LegacyId,
entry.EntryKey,
entry.Name,
entry.EntryType,
entry.Icon,
entry.Route,
entry.Description,
entry.Visibility,
entry.AccessRules,
entry.LayoutConfig,
entry.SortOrder))
.ToArrayAsync(cancellationToken);
return new CatalogList<ContentEntryCatalogItem>(items);
}
public async Task<CatalogList<ContentNodeCatalogItem>> GetContentNodesAsync(
ContentNavigationFilter filter,
CancellationToken cancellationToken = default)
{
if (!filter.EntryId.HasValue)
{
throw new RequiredFieldException("entryId is required.");
}
var query = dbContext.ContentNodes
.AsNoTracking()
.Where(node =>
node.TenantId == filter.TenantId &&
node.EntryId == filter.EntryId.Value);
if (!filter.IncludeInactive)
{
query = query.Where(node => node.IsActive);
}
if (filter.RegionId.HasValue)
{
query = query.Where(node => node.RegionId == filter.RegionId.Value || node.RegionId == null);
}
if (filter.ParentWasSpecified)
{
query = filter.ParentIsRoot
? query.Where(node => node.ParentId == null)
: query.Where(node => node.ParentId == filter.ParentId);
}
if (TryParseMarkerType(filter.MarkerType, out var markerType))
{
query = query.Where(node => node.MarkerType == markerType);
}
query = ApplyKeyword(query, filter.Keyword);
query = string.Equals(filter.Mode, "flat", StringComparison.OrdinalIgnoreCase)
? query.OrderBy(node => node.Path).ThenBy(node => node.SortOrder)
: query.OrderBy(node => node.SortOrder).ThenBy(node => node.CreatedAt);
var items = await query
.Take(ResolveLimit(filter.Limit, 1000))
.Select(node => new ContentNodeCatalogItem(
node.Id,
node.EntryId,
node.RegionId,
node.ParentId,
node.LegacyId,
node.NodeKey,
node.Name,
node.NodeType,
node.MarkerType,
node.MarkerConfig,
node.Path,
node.Depth,
node.SortOrder,
node.IsSelectable,
node.IsLeaf,
node.AccessRules,
node.Metadata))
.ToArrayAsync(cancellationToken);
return new CatalogList<ContentNodeCatalogItem>(items);
}
public async Task<CatalogList<QuestionCollectionCatalogItem>> GetQuestionCollectionsAsync(
ContentNavigationFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.QuestionCollections
.AsNoTracking()
.Where(collection =>
collection.TenantId == filter.TenantId &&
collection.Status == ContentStatus.Active);
if (filter.RegionId.HasValue)
{
query = query.Where(collection => collection.RegionId == filter.RegionId.Value || collection.RegionId == null);
}
if (filter.EntryId.HasValue)
{
query = query.Where(collection => collection.EntryId == filter.EntryId.Value);
}
if (filter.NodeId.HasValue)
{
query = query.Where(collection => collection.NodeId == filter.NodeId.Value);
}
if (TryParseCollectionType(filter.CollectionType, out var collectionType))
{
query = query.Where(collection => collection.CollectionType == collectionType);
}
query = ApplyKeyword(query, filter.Keyword);
var items = await query
.OrderBy(collection => collection.SortOrder)
.ThenBy(collection => collection.CreatedAt)
.Take(ResolveLimit(filter.Limit, 500))
.Select(collection => new QuestionCollectionCatalogItem(
collection.Id,
collection.RegionId,
collection.EntryId,
collection.NodeId,
collection.SubjectId,
collection.CategoryId,
collection.QuestionBankId,
collection.LegacyId,
collection.Name,
collection.CollectionType,
collection.SourceType,
collection.Filters,
collection.QuestionCount,
collection.TotalScore,
collection.DurationMinutes,
collection.Status,
collection.SortOrder,
collection.AccessRules,
collection.Metadata))
.ToArrayAsync(cancellationToken);
return new CatalogList<QuestionCollectionCatalogItem>(items);
}
public async Task<CatalogList<PracticeBlueprintCatalogItem>> GetPracticeBlueprintsAsync(
ContentNavigationFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.PracticeBlueprints
.AsNoTracking()
.Where(blueprint =>
blueprint.TenantId == filter.TenantId &&
blueprint.Status == ContentStatus.Active);
if (filter.RegionId.HasValue)
{
query = query.Where(blueprint => blueprint.RegionId == filter.RegionId.Value || blueprint.RegionId == null);
}
if (filter.EntryId.HasValue)
{
query = query.Where(blueprint => blueprint.EntryId == filter.EntryId.Value);
}
if (filter.NodeId.HasValue)
{
query = query.Where(blueprint => blueprint.NodeId == filter.NodeId.Value);
}
if (filter.CollectionId.HasValue)
{
query = query.Where(blueprint => blueprint.CollectionId == filter.CollectionId.Value);
}
if (TryParsePracticeMode(filter.Mode, out var mode))
{
query = query.Where(blueprint => blueprint.Mode == mode);
}
query = ApplyKeyword(query, filter.Keyword);
var items = await query
.OrderBy(blueprint => blueprint.SortOrder)
.ThenBy(blueprint => blueprint.CreatedAt)
.Take(ResolveLimit(filter.Limit, 500))
.Select(blueprint => new PracticeBlueprintCatalogItem(
blueprint.Id,
blueprint.RegionId,
blueprint.EntryId,
blueprint.NodeId,
blueprint.CollectionId,
blueprint.LegacyId,
blueprint.Name,
blueprint.Mode,
blueprint.AssemblyType,
blueprint.QuestionLimit,
blueprint.DurationMinutes,
blueprint.TotalScore,
blueprint.PassScore,
blueprint.Sections,
blueprint.Rules,
blueprint.AccessRules,
blueprint.Status,
blueprint.SortOrder))
.ToArrayAsync(cancellationToken);
return new CatalogList<PracticeBlueprintCatalogItem>(items);
}
public async Task<CatalogList<CollectionQuestionCatalogItem>> GetCollectionQuestionsAsync(
ContentNavigationFilter filter,
CancellationToken cancellationToken = default)
{
if (!filter.CollectionId.HasValue)
{
throw new RequiredFieldException("collectionId is required.");
}
var collectionExists = await dbContext.QuestionCollections
.AsNoTracking()
.AnyAsync(
collection =>
collection.TenantId == filter.TenantId &&
collection.Id == filter.CollectionId.Value &&
collection.Status == ContentStatus.Active,
cancellationToken);
if (!collectionExists)
{
throw new ContentNavigationNotFoundException("Question collection was not found.");
}
var query =
from item in dbContext.QuestionCollectionItems.AsNoTracking()
join question in dbContext.Questions.AsNoTracking()
on new { item.TenantId, QuestionId = item.QuestionId } equals new { question.TenantId, QuestionId = question.Id }
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()
where item.TenantId == filter.TenantId &&
item.CollectionId == filter.CollectionId.Value &&
question.Status == QuestionStatus.Published
orderby item.SectionKey, item.SortOrder, question.CreatedAt
select new CollectionQuestionCatalogItem(
question.Id,
question.LegacyId,
question.EntryId,
question.ContentNodeId,
question.PrimaryCollectionId,
question.SubjectId,
question.CategoryId,
question.NodeId,
question.Type,
question.TypeLabel,
question.Difficulty,
question.Tags,
question.ExamMarkers,
question.MediaUrl,
question.HasVideoExplanation,
item.SectionKey,
item.Score,
item.SortOrder,
version == null ? null : version.Id,
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);
var items = await query
.Take(ResolveLimit(filter.Limit, MaxLimit))
.ToArrayAsync(cancellationToken);
return new CatalogList<CollectionQuestionCatalogItem>(items);
}
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(ContentEntry.Name)).Contains(trimmed));
}
private static int ResolveLimit(int? limit, int defaultLimit)
{
return Math.Clamp(limit ?? defaultLimit, 1, MaxLimit);
}
private static bool TryParseEntryType(string? value, out ContentEntryType type)
{
return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out type);
}
private static bool TryParseCollectionType(string? value, out QuestionCollectionType type)
{
return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out type);
}
private static bool TryParsePracticeMode(string? value, out PracticeMode mode)
{
return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out mode);
}
private static bool TryParseMarkerType(string? value, out ContentMarkerType type)
{
return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out type);
}
private static string? NormalizeEnumValue(string? value)
{
return string.IsNullOrWhiteSpace(value)
? null
: value.Replace("_", string.Empty, StringComparison.Ordinal)
.Replace("-", string.Empty, StringComparison.Ordinal);
}
}
public sealed class RequiredFieldException(string message) : Exception(message);
public sealed class ContentNavigationNotFoundException(string message) : Exception(message);

View File

@@ -3,8 +3,10 @@ using Microsoft.Extensions.DependencyInjection;
using Npgsql;
using Tiku.Application.Auth;
using Tiku.Application.Catalog;
using Tiku.Application.Content;
using Tiku.Infrastructure.Auth;
using Tiku.Infrastructure.Catalog;
using Tiku.Infrastructure.Content;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure;
@@ -31,6 +33,7 @@ public static class DependencyInjection
services.AddHttpClient<IWechatOAuthClient, WechatOAuthClient>();
services.AddScoped<IAuthService, AuthService>();
services.AddScoped<ICatalogQueryService, CatalogQueryService>();
services.AddScoped<IContentNavigationQueryService, ContentNavigationQueryService>();
return services;
}

View File

@@ -0,0 +1,269 @@
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 ContentNavigationEndpointTests
{
[Fact]
public async Task Content_entries_hide_hidden_items_by_default()
{
var tenantId = Guid.NewGuid();
var regionId = Guid.NewGuid();
await using var factory = new ApiTestFactory();
await factory.SeedAsync(
Tenant(tenantId, "master"),
new ContentEntry
{
Id = Guid.NewGuid(),
TenantId = tenantId,
RegionId = regionId,
EntryKey = "practice",
Name = "公开练习",
EntryType = ContentEntryType.QuestionPractice,
Visibility = ContentVisibility.Public,
SortOrder = 2
},
new ContentEntry
{
Id = Guid.NewGuid(),
TenantId = tenantId,
EntryKey = "global",
Name = "全局入口",
EntryType = ContentEntryType.QuestionPractice,
Visibility = ContentVisibility.Public,
SortOrder = 1
},
new ContentEntry
{
Id = Guid.NewGuid(),
TenantId = tenantId,
EntryKey = "hidden",
Name = "隐藏入口",
EntryType = ContentEntryType.QuestionPractice,
Visibility = ContentVisibility.Hidden
});
using var client = factory.CreateClient();
using var response = await client.GetAsync($"/api/catalog/content-entries?tenantCode=master&regionId={regionId}");
var items = await ReadItemsAsync(response);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(["全局入口", "公开练习"], items.Select(item => item.GetProperty("name").GetString()!).ToArray());
}
[Fact]
public async Task Content_nodes_support_required_entry_and_root_filter()
{
var tenantId = Guid.NewGuid();
var entryId = Guid.NewGuid();
var rootNodeId = Guid.NewGuid();
await using var factory = new ApiTestFactory();
await factory.SeedAsync(
Tenant(tenantId, "master"),
new ContentEntry
{
Id = entryId,
TenantId = tenantId,
EntryKey = "practice",
Name = "练习入口"
},
new ContentNode
{
Id = rootNodeId,
TenantId = tenantId,
EntryId = entryId,
Name = "根内容节点",
NodeKey = "root",
SortOrder = 1,
IsLeaf = false
},
new ContentNode
{
Id = Guid.NewGuid(),
TenantId = tenantId,
EntryId = entryId,
ParentId = rootNodeId,
Name = "子内容节点",
NodeKey = "child",
SortOrder = 2,
IsLeaf = true
});
using var client = factory.CreateClient();
using var missingEntryResponse = await client.GetAsync("/api/catalog/content-nodes?tenantCode=master");
using var response = await client.GetAsync($"/api/catalog/content-nodes?tenantCode=master&entryId={entryId}&parentId=root");
var items = await ReadItemsAsync(response);
Assert.Equal(HttpStatusCode.BadRequest, missingEntryResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var item = Assert.Single(items);
Assert.Equal("根内容节点", item.GetProperty("name").GetString());
}
[Fact]
public async Task Collections_and_blueprints_return_active_items_only()
{
var tenantId = Guid.NewGuid();
var entryId = Guid.NewGuid();
var nodeId = Guid.NewGuid();
var collectionId = Guid.NewGuid();
await using var factory = new ApiTestFactory();
await factory.SeedAsync(
Tenant(tenantId, "master"),
new ContentEntry { Id = entryId, TenantId = tenantId, EntryKey = "practice", Name = "练习入口" },
new ContentNode { Id = nodeId, TenantId = tenantId, EntryId = entryId, Name = "章节" },
new QuestionCollection
{
Id = collectionId,
TenantId = tenantId,
EntryId = entryId,
NodeId = nodeId,
Name = "章节题集",
CollectionType = QuestionCollectionType.Chapter,
Status = ContentStatus.Active,
SortOrder = 1
},
new QuestionCollection
{
Id = Guid.NewGuid(),
TenantId = tenantId,
EntryId = entryId,
NodeId = nodeId,
Name = "草稿题集",
Status = ContentStatus.Draft
},
new PracticeBlueprint
{
Id = Guid.NewGuid(),
TenantId = tenantId,
EntryId = entryId,
NodeId = nodeId,
CollectionId = collectionId,
Name = "章节练习",
Mode = PracticeMode.Sequential,
Status = ContentStatus.Active
},
new PracticeBlueprint
{
Id = Guid.NewGuid(),
TenantId = tenantId,
EntryId = entryId,
NodeId = nodeId,
CollectionId = collectionId,
Name = "归档练习",
Mode = PracticeMode.Sequential,
Status = ContentStatus.Archived
});
using var client = factory.CreateClient();
using var collectionsResponse = await client.GetAsync(
$"/api/catalog/question-collections?tenantCode=master&entryId={entryId}&nodeId={nodeId}&collectionType=chapter");
using var blueprintsResponse = await client.GetAsync(
$"/api/catalog/practice-blueprints?tenantCode=master&entryId={entryId}&nodeId={nodeId}&collectionId={collectionId}&mode=sequential");
var collections = await ReadItemsAsync(collectionsResponse);
var blueprints = await ReadItemsAsync(blueprintsResponse);
Assert.Equal(HttpStatusCode.OK, collectionsResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, blueprintsResponse.StatusCode);
Assert.Equal("章节题集", Assert.Single(collections).GetProperty("name").GetString());
Assert.Equal("章节练习", Assert.Single(blueprints).GetProperty("name").GetString());
}
[Fact]
public async Task Collection_questions_return_published_questions_only()
{
var tenantId = Guid.NewGuid();
var collectionId = Guid.NewGuid();
var publishedQuestionId = Guid.NewGuid();
var archivedQuestionId = Guid.NewGuid();
var versionId = Guid.NewGuid();
await using var factory = new ApiTestFactory();
await factory.SeedAsync(
Tenant(tenantId, "master"),
new QuestionCollection
{
Id = collectionId,
TenantId = tenantId,
Name = "真题套卷",
Status = ContentStatus.Active
},
new Question
{
Id = publishedQuestionId,
TenantId = tenantId,
PrimaryCollectionId = collectionId,
Type = "choice",
TypeLabel = "单选题",
Status = QuestionStatus.Published,
CurrentVersionId = versionId
},
new QuestionVersion
{
Id = versionId,
TenantId = tenantId,
QuestionId = publishedQuestionId,
Content = "题干"
},
new Question
{
Id = archivedQuestionId,
TenantId = tenantId,
PrimaryCollectionId = collectionId,
Type = "choice",
Status = QuestionStatus.Archived
},
new QuestionCollectionItem
{
Id = Guid.NewGuid(),
TenantId = tenantId,
CollectionId = collectionId,
QuestionId = publishedQuestionId,
SortOrder = 1,
Score = 2
},
new QuestionCollectionItem
{
Id = Guid.NewGuid(),
TenantId = tenantId,
CollectionId = collectionId,
QuestionId = archivedQuestionId,
SortOrder = 2
});
using var client = factory.CreateClient();
using var response = await client.GetAsync($"/api/catalog/question-collections/questions?tenantCode=master&collectionId={collectionId}");
var items = await ReadItemsAsync(response);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var item = Assert.Single(items);
Assert.Equal(publishedQuestionId, item.GetProperty("id").GetGuid());
Assert.Equal("题干", item.GetProperty("content").GetString());
}
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();
}
}