From 3221b581c8411702205f6ffd0929c109643137e7 Mon Sep 17 00:00:00 2001 From: xiong Date: Sun, 26 Jul 2026 14:08:23 +0800 Subject: [PATCH] feat: add content navigation readonly endpoints --- README.md | 7 +- Tiku.Api/Contracts/ContentNavigationDtos.cs | 71 ++++ Tiku.Api/Controllers/CatalogController.cs | 81 ++++ .../Middleware/ExceptionHandlingMiddleware.cs | 21 + .../Content/ContentNavigationQueryModels.cs | 127 ++++++ .../Content/IContentNavigationQueryService.cs | 26 ++ .../Content/ContentNavigationQueryService.cs | 380 ++++++++++++++++++ Tiku.Infrastructure/DependencyInjection.cs | 3 + .../Api/ContentNavigationEndpointTests.cs | 269 +++++++++++++ 9 files changed, 982 insertions(+), 3 deletions(-) create mode 100644 Tiku.Api/Contracts/ContentNavigationDtos.cs create mode 100644 Tiku.Application/Content/ContentNavigationQueryModels.cs create mode 100644 Tiku.Application/Content/IContentNavigationQueryService.cs create mode 100644 Tiku.Infrastructure/Content/ContentNavigationQueryService.cs create mode 100644 Tiku.IntegrationTests/Api/ContentNavigationEndpointTests.cs diff --git a/README.md b/README.md index a754c22..903ec2e 100644 --- a/README.md +++ b/README.md @@ -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 接入这套安全轨道,而不是重新散写权限判断。 diff --git a/Tiku.Api/Contracts/ContentNavigationDtos.cs b/Tiku.Api/Contracts/ContentNavigationDtos.cs new file mode 100644 index 0000000..7f05db0 --- /dev/null +++ b/Tiku.Api/Contracts/ContentNavigationDtos.cs @@ -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); + } +} diff --git a/Tiku.Api/Controllers/CatalogController.cs b/Tiku.Api/Controllers/CatalogController.cs index 4cc578c..9f04e63 100644 --- a/Tiku.Api/Controllers/CatalogController.cs +++ b/Tiku.Api/Controllers/CatalogController.cs @@ -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>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetContentEntries( + [FromQuery] ContentNavigationQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await contentNavigationQueryService.GetContentEntriesAsync( + query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)), + cancellationToken)); + } + + [HttpGet("content-nodes")] + [EndpointSummary("查询内容导航节点")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetContentNodes( + [FromQuery] ContentNavigationQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await contentNavigationQueryService.GetContentNodesAsync( + query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)), + cancellationToken)); + } + + [HttpGet("question-collections")] + [EndpointSummary("查询可用题集")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetQuestionCollections( + [FromQuery] ContentNavigationQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await contentNavigationQueryService.GetQuestionCollectionsAsync( + query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)), + cancellationToken)); + } + + [HttpGet("question-collections/questions")] + [EndpointSummary("查询题集内题目")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetCollectionQuestions( + [FromQuery] ContentNavigationQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await contentNavigationQueryService.GetCollectionQuestionsAsync( + query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)), + cancellationToken)); + } + + [HttpGet("practice-blueprints")] + [EndpointSummary("查询练习蓝图")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetPracticeBlueprints( + [FromQuery] ContentNavigationQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await contentNavigationQueryService.GetPracticeBlueprintsAsync( + query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)), + cancellationToken)); + } + private async Task ResolveTenantIdAsync( CatalogQueryDto query, CancellationToken cancellationToken) @@ -134,6 +203,18 @@ public sealed class CatalogController( return tenantId ?? throw new TenantNotFoundException(); } + + private Task ResolveTenantIdAsync( + ContentNavigationQueryDto 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 c6b7af6..7512ef6 100644 --- a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs +++ b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs @@ -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 diff --git a/Tiku.Application/Content/ContentNavigationQueryModels.cs b/Tiku.Application/Content/ContentNavigationQueryModels.cs new file mode 100644 index 0000000..c436aa0 --- /dev/null +++ b/Tiku.Application/Content/ContentNavigationQueryModels.cs @@ -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); diff --git a/Tiku.Application/Content/IContentNavigationQueryService.cs b/Tiku.Application/Content/IContentNavigationQueryService.cs new file mode 100644 index 0000000..c3446c7 --- /dev/null +++ b/Tiku.Application/Content/IContentNavigationQueryService.cs @@ -0,0 +1,26 @@ +using Tiku.Application.Catalog; + +namespace Tiku.Application.Content; + +public interface IContentNavigationQueryService +{ + Task> GetContentEntriesAsync( + ContentNavigationFilter filter, + CancellationToken cancellationToken = default); + + Task> GetContentNodesAsync( + ContentNavigationFilter filter, + CancellationToken cancellationToken = default); + + Task> GetQuestionCollectionsAsync( + ContentNavigationFilter filter, + CancellationToken cancellationToken = default); + + Task> GetPracticeBlueprintsAsync( + ContentNavigationFilter filter, + CancellationToken cancellationToken = default); + + Task> GetCollectionQuestionsAsync( + ContentNavigationFilter filter, + CancellationToken cancellationToken = default); +} diff --git a/Tiku.Infrastructure/Content/ContentNavigationQueryService.cs b/Tiku.Infrastructure/Content/ContentNavigationQueryService.cs new file mode 100644 index 0000000..37d44e4 --- /dev/null +++ b/Tiku.Infrastructure/Content/ContentNavigationQueryService.cs @@ -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> 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(items); + } + + public async Task> 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(items); + } + + public async Task> 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(items); + } + + public async Task> 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(items); + } + + public async Task> 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(items); + } + + 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(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); diff --git a/Tiku.Infrastructure/DependencyInjection.cs b/Tiku.Infrastructure/DependencyInjection.cs index 06605c8..e7595b6 100644 --- a/Tiku.Infrastructure/DependencyInjection.cs +++ b/Tiku.Infrastructure/DependencyInjection.cs @@ -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(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/Tiku.IntegrationTests/Api/ContentNavigationEndpointTests.cs b/Tiku.IntegrationTests/Api/ContentNavigationEndpointTests.cs new file mode 100644 index 0000000..93d696b --- /dev/null +++ b/Tiku.IntegrationTests/Api/ContentNavigationEndpointTests.cs @@ -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®ionId={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 ReadItemsAsync(HttpResponseMessage response) + { + var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + return body.RootElement + .GetProperty("items") + .EnumerateArray() + .Select(item => item.Clone()) + .ToArray(); + } +}