From 47613b51bddc2feb3966e5aa59d64527305f7fa2 Mon Sep 17 00:00:00 2001 From: xiong Date: Sun, 26 Jul 2026 14:21:42 +0800 Subject: [PATCH] feat: add study content readonly endpoints --- README.md | 7 +- Tiku.Api/Contracts/StudyContentDtos.cs | 45 +++ Tiku.Api/Controllers/CatalogController.cs | 79 +++++ .../StudyContent/IStudyContentQueryService.cs | 26 ++ .../StudyContent/StudyContentQueryModels.cs | 84 ++++++ Tiku.Infrastructure/DependencyInjection.cs | 3 + .../StudyContent/StudyContentQueryService.cs | 284 ++++++++++++++++++ .../Api/StudyContentEndpointTests.cs | 202 +++++++++++++ 8 files changed, 727 insertions(+), 3 deletions(-) create mode 100644 Tiku.Api/Contracts/StudyContentDtos.cs create mode 100644 Tiku.Application/StudyContent/IStudyContentQueryService.cs create mode 100644 Tiku.Application/StudyContent/StudyContentQueryModels.cs create mode 100644 Tiku.Infrastructure/StudyContent/StudyContentQueryService.cs create mode 100644 Tiku.IntegrationTests/Api/StudyContentEndpointTests.cs diff --git a/README.md b/README.md index adb4513..7022199 100644 --- a/README.md +++ b/README.md @@ -246,10 +246,10 @@ dotnet ef migrations script \ 建议下一批迁移顺序: ```text -Vocabulary / Handbook 只读 API - -> 词汇单元 / 单词 / 知识手册 Assets / Video 只读 API -> 内容资源 / 图片 / 视频解析 +Operations Content 只读 API + -> 横幅 / FAQ / 公告 ``` ## 当前状态 @@ -261,5 +261,6 @@ Assets / Video 只读 API - Catalog 基础只读 API 已建立:地区、地区模块、模块节点、院校、专业、科目、题目分类。 - Content Navigation 只读 API 已建立:内容入口、内容节点、题集、题集题目、练习蓝图。 - Question Bank 只读 API 已建立:题库列表、题目列表、题目详情、题目版本。 -- 当前模型测试、认证服务测试、API 认证/租户闭环测试、Catalog / Content Navigation / Question Bank 只读测试通过。 +- Vocabulary / Handbook 只读 API 已建立:词汇单元、词汇单词、知识手册科目、章节、条目。 +- 当前模型测试、认证服务测试、API 认证/租户闭环测试、Catalog / Content Navigation / Question Bank / Study Content 只读测试通过。 - 下一步重点是继续把题库、内容、导入、订单等业务 API 接入这套安全轨道,而不是重新散写权限判断。 diff --git a/Tiku.Api/Contracts/StudyContentDtos.cs b/Tiku.Api/Contracts/StudyContentDtos.cs new file mode 100644 index 0000000..6dfc483 --- /dev/null +++ b/Tiku.Api/Contracts/StudyContentDtos.cs @@ -0,0 +1,45 @@ +using System.ComponentModel.DataAnnotations; +using Tiku.Application.StudyContent; + +namespace Tiku.Api.Contracts; + +public sealed class StudyContentQueryDto +{ + [StringLength(100)] + public string? TenantCode { get; set; } + + public Guid? RegionId { get; set; } + + public Guid? UnitId { get; set; } + + public Guid? SubjectId { get; set; } + + public Guid? ChapterId { get; set; } + + public Guid? EntryId { get; set; } + + public Guid? ContentNodeId { get; set; } + + [StringLength(100)] + public string? Keyword { get; set; } + + public bool IncludeContent { get; set; } + + [Range(1, 2000)] + public int? Limit { get; set; } + + public StudyContentFilter ToFilter(Guid tenantId) + { + return new StudyContentFilter( + tenantId, + RegionId, + UnitId, + SubjectId, + ChapterId, + EntryId, + ContentNodeId, + Keyword, + IncludeContent, + Limit); + } +} diff --git a/Tiku.Api/Controllers/CatalogController.cs b/Tiku.Api/Controllers/CatalogController.cs index eda099c..26e7f69 100644 --- a/Tiku.Api/Controllers/CatalogController.cs +++ b/Tiku.Api/Controllers/CatalogController.cs @@ -6,6 +6,7 @@ using Tiku.Application.Catalog; using Tiku.Application.Content; using Tiku.Application.QuestionBanks; using Tiku.Application.Security; +using Tiku.Application.StudyContent; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; @@ -19,6 +20,7 @@ public sealed class CatalogController( ICatalogQueryService catalogQueryService, IContentNavigationQueryService contentNavigationQueryService, IQuestionBankQueryService questionBankQueryService, + IStudyContentQueryService studyContentQueryService, ICurrentTenant currentTenant, TikuDbContext dbContext) : ControllerBase { @@ -236,6 +238,71 @@ public sealed class CatalogController( cancellationToken)); } + [HttpGet("vocabulary-units")] + [EndpointSummary("查询词汇单元")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetVocabularyUnits( + [FromQuery] StudyContentQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await studyContentQueryService.GetVocabularyUnitsAsync( + query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)), + cancellationToken)); + } + + [HttpGet("vocabulary-words")] + [EndpointSummary("查询词汇单词")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetVocabularyWords( + [FromQuery] StudyContentQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await studyContentQueryService.GetVocabularyWordsAsync( + query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)), + cancellationToken)); + } + + [HttpGet("handbook-subjects")] + [EndpointSummary("查询知识手册科目")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetHandbookSubjects( + [FromQuery] StudyContentQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await studyContentQueryService.GetHandbookSubjectsAsync( + query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)), + cancellationToken)); + } + + [HttpGet("handbook-chapters")] + [EndpointSummary("查询知识手册章节")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetHandbookChapters( + [FromQuery] StudyContentQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await studyContentQueryService.GetHandbookChaptersAsync( + query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)), + cancellationToken)); + } + + [HttpGet("handbook-entries")] + [EndpointSummary("查询知识手册条目")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> GetHandbookEntries( + [FromQuery] StudyContentQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await studyContentQueryService.GetHandbookEntriesAsync( + query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)), + cancellationToken)); + } + private async Task ResolveTenantIdAsync( CatalogQueryDto query, CancellationToken cancellationToken) @@ -284,6 +351,18 @@ public sealed class CatalogController( }, cancellationToken); } + + private Task ResolveTenantIdAsync( + StudyContentQueryDto query, + CancellationToken cancellationToken) + { + return ResolveTenantIdAsync( + new CatalogQueryDto + { + TenantCode = query.TenantCode + }, + cancellationToken); + } } public sealed class TenantNotFoundException : Exception diff --git a/Tiku.Application/StudyContent/IStudyContentQueryService.cs b/Tiku.Application/StudyContent/IStudyContentQueryService.cs new file mode 100644 index 0000000..04265a9 --- /dev/null +++ b/Tiku.Application/StudyContent/IStudyContentQueryService.cs @@ -0,0 +1,26 @@ +using Tiku.Application.Catalog; + +namespace Tiku.Application.StudyContent; + +public interface IStudyContentQueryService +{ + Task> GetVocabularyUnitsAsync( + StudyContentFilter filter, + CancellationToken cancellationToken = default); + + Task> GetVocabularyWordsAsync( + StudyContentFilter filter, + CancellationToken cancellationToken = default); + + Task> GetHandbookSubjectsAsync( + StudyContentFilter filter, + CancellationToken cancellationToken = default); + + Task> GetHandbookChaptersAsync( + StudyContentFilter filter, + CancellationToken cancellationToken = default); + + Task> GetHandbookEntriesAsync( + StudyContentFilter filter, + CancellationToken cancellationToken = default); +} diff --git a/Tiku.Application/StudyContent/StudyContentQueryModels.cs b/Tiku.Application/StudyContent/StudyContentQueryModels.cs new file mode 100644 index 0000000..32e866c --- /dev/null +++ b/Tiku.Application/StudyContent/StudyContentQueryModels.cs @@ -0,0 +1,84 @@ +using System.Text.Json; +using Tiku.Domain.Content; + +namespace Tiku.Application.StudyContent; + +public sealed record StudyContentFilter( + Guid TenantId, + Guid? RegionId = null, + Guid? UnitId = null, + Guid? SubjectId = null, + Guid? ChapterId = null, + Guid? EntryId = null, + Guid? ContentNodeId = null, + string? Keyword = null, + bool IncludeContent = false, + int? Limit = null); + +public sealed record VocabularyUnitCatalogItem( + Guid Id, + string? LegacyId, + Guid? RegionId, + Guid? EntryId, + Guid? ContentNodeId, + string Name, + string? Description, + int? WordCount, + int Order, + JsonElement Metadata); + +public sealed record VocabularyWordCatalogItem( + Guid Id, + string? LegacyId, + Guid? UnitId, + Guid? EntryId, + Guid? ContentNodeId, + string Word, + string? Phonetic, + string? Meaning, + string? Example, + string? ExampleTranslation, + int? Difficulty, + JsonElement Tags, + int Order, + JsonElement Metadata); + +public sealed record HandbookSubjectCatalogItem( + Guid Id, + string? LegacyId, + Guid? RegionId, + Guid? SchoolId, + Guid? MajorId, + Guid? EntryId, + Guid? ContentNodeId, + string Name, + HandbookSubjectType? Type, + string? Icon, + string? Color, + string? Description, + int Order, + JsonElement Metadata); + +public sealed record HandbookChapterCatalogItem( + Guid Id, + string? LegacyId, + Guid? SubjectId, + Guid? EntryId, + Guid? ContentNodeId, + string Name, + string? Description, + int Order, + JsonElement Metadata); + +public sealed record HandbookEntryCatalogItem( + Guid Id, + string? LegacyId, + Guid? ChapterId, + Guid? EntryId, + Guid? ContentNodeId, + string Title, + string? Summary, + string? Content, + JsonElement Tags, + int Order, + JsonElement Metadata); diff --git a/Tiku.Infrastructure/DependencyInjection.cs b/Tiku.Infrastructure/DependencyInjection.cs index ce67f15..31f1e30 100644 --- a/Tiku.Infrastructure/DependencyInjection.cs +++ b/Tiku.Infrastructure/DependencyInjection.cs @@ -5,11 +5,13 @@ using Tiku.Application.Auth; using Tiku.Application.Catalog; using Tiku.Application.Content; using Tiku.Application.QuestionBanks; +using Tiku.Application.StudyContent; using Tiku.Infrastructure.Auth; using Tiku.Infrastructure.Catalog; using Tiku.Infrastructure.Content; using Tiku.Infrastructure.Persistence; using Tiku.Infrastructure.QuestionBanks; +using Tiku.Infrastructure.StudyContent; namespace Tiku.Infrastructure; @@ -37,6 +39,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/Tiku.Infrastructure/StudyContent/StudyContentQueryService.cs b/Tiku.Infrastructure/StudyContent/StudyContentQueryService.cs new file mode 100644 index 0000000..ef0b483 --- /dev/null +++ b/Tiku.Infrastructure/StudyContent/StudyContentQueryService.cs @@ -0,0 +1,284 @@ +using Microsoft.EntityFrameworkCore; +using Tiku.Application.Catalog; +using Tiku.Application.StudyContent; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.StudyContent; + +public sealed class StudyContentQueryService(TikuDbContext dbContext) : IStudyContentQueryService +{ + private const int DefaultLimit = 500; + private const int MaxLimit = 2000; + + public async Task> GetVocabularyUnitsAsync( + StudyContentFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.VocabularyUnits + .AsNoTracking() + .Where(unit => + unit.TenantId == filter.TenantId && + unit.IsActive); + + if (filter.RegionId.HasValue) + { + query = query.Where(unit => unit.RegionId == filter.RegionId.Value || unit.RegionId == null); + } + + if (filter.EntryId.HasValue) + { + query = query.Where(unit => unit.EntryId == filter.EntryId.Value); + } + + if (filter.ContentNodeId.HasValue) + { + query = query.Where(unit => unit.ContentNodeId == filter.ContentNodeId.Value); + } + + query = ApplyNameKeyword(query, filter.Keyword); + + var items = await query + .OrderBy(unit => unit.SortOrder) + .ThenBy(unit => unit.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .Select(unit => new VocabularyUnitCatalogItem( + unit.Id, + unit.LegacyId, + unit.RegionId, + unit.EntryId, + unit.ContentNodeId, + unit.Name, + unit.Description, + unit.WordCount, + unit.SortOrder, + unit.Metadata)) + .ToArrayAsync(cancellationToken); + + return new CatalogList(items); + } + + public async Task> GetVocabularyWordsAsync( + StudyContentFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.VocabularyWords + .AsNoTracking() + .Where(word => + word.TenantId == filter.TenantId && + word.IsActive); + + if (filter.UnitId.HasValue) + { + query = query.Where(word => word.UnitId == filter.UnitId.Value); + } + + if (filter.EntryId.HasValue) + { + query = query.Where(word => word.EntryId == filter.EntryId.Value); + } + + if (filter.ContentNodeId.HasValue) + { + query = query.Where(word => word.ContentNodeId == filter.ContentNodeId.Value); + } + + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(word => + word.Word.Contains(keyword) || + (word.Meaning != null && word.Meaning.Contains(keyword))); + } + + var items = await query + .OrderBy(word => word.SortOrder) + .ThenBy(word => word.Word) + .Take(ResolveLimit(filter.Limit)) + .Select(word => new VocabularyWordCatalogItem( + word.Id, + word.LegacyId, + word.UnitId, + word.EntryId, + word.ContentNodeId, + word.Word, + word.Phonetic, + word.Meaning, + word.Example, + word.ExampleTranslation, + word.Difficulty, + word.Tags, + word.SortOrder, + word.Metadata)) + .ToArrayAsync(cancellationToken); + + return new CatalogList(items); + } + + public async Task> GetHandbookSubjectsAsync( + StudyContentFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.HandbookSubjects + .AsNoTracking() + .Where(subject => + subject.TenantId == filter.TenantId && + subject.IsActive); + + if (filter.RegionId.HasValue) + { + query = query.Where(subject => subject.RegionId == filter.RegionId.Value || subject.RegionId == null); + } + + if (filter.EntryId.HasValue) + { + query = query.Where(subject => subject.EntryId == filter.EntryId.Value); + } + + if (filter.ContentNodeId.HasValue) + { + query = query.Where(subject => subject.ContentNodeId == filter.ContentNodeId.Value); + } + + query = ApplyNameKeyword(query, filter.Keyword); + + var items = await query + .OrderBy(subject => subject.SortOrder) + .ThenBy(subject => subject.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .Select(subject => new HandbookSubjectCatalogItem( + subject.Id, + subject.LegacyId, + subject.RegionId, + subject.SchoolId, + subject.MajorId, + subject.EntryId, + subject.ContentNodeId, + subject.Name, + subject.Type, + subject.Icon, + subject.Color, + subject.Description, + subject.SortOrder, + subject.Metadata)) + .ToArrayAsync(cancellationToken); + + return new CatalogList(items); + } + + public async Task> GetHandbookChaptersAsync( + StudyContentFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.HandbookChapters + .AsNoTracking() + .Where(chapter => + chapter.TenantId == filter.TenantId && + chapter.IsActive); + + if (filter.SubjectId.HasValue) + { + query = query.Where(chapter => chapter.SubjectId == filter.SubjectId.Value); + } + + if (filter.EntryId.HasValue) + { + query = query.Where(chapter => chapter.EntryId == filter.EntryId.Value); + } + + if (filter.ContentNodeId.HasValue) + { + query = query.Where(chapter => chapter.ContentNodeId == filter.ContentNodeId.Value); + } + + query = ApplyNameKeyword(query, filter.Keyword); + + var items = await query + .OrderBy(chapter => chapter.SortOrder) + .ThenBy(chapter => chapter.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .Select(chapter => new HandbookChapterCatalogItem( + chapter.Id, + chapter.LegacyId, + chapter.SubjectId, + chapter.EntryId, + chapter.ContentNodeId, + chapter.Name, + chapter.Description, + chapter.SortOrder, + chapter.Metadata)) + .ToArrayAsync(cancellationToken); + + return new CatalogList(items); + } + + public async Task> GetHandbookEntriesAsync( + StudyContentFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.HandbookEntries + .AsNoTracking() + .Where(entry => + entry.TenantId == filter.TenantId && + entry.IsActive); + + if (filter.ChapterId.HasValue) + { + query = query.Where(entry => entry.ChapterId == filter.ChapterId.Value); + } + + if (filter.EntryId.HasValue) + { + query = query.Where(entry => entry.EntryId == filter.EntryId.Value); + } + + if (filter.ContentNodeId.HasValue) + { + query = query.Where(entry => entry.ContentNodeId == filter.ContentNodeId.Value); + } + + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var keyword = filter.Keyword.Trim(); + query = query.Where(entry => + entry.Title.Contains(keyword) || + (entry.Summary != null && entry.Summary.Contains(keyword))); + } + + var items = await query + .OrderBy(entry => entry.SortOrder) + .ThenBy(entry => entry.CreatedAt) + .Take(ResolveLimit(filter.Limit)) + .Select(entry => new HandbookEntryCatalogItem( + entry.Id, + entry.LegacyId, + entry.ChapterId, + entry.EntryId, + entry.ContentNodeId, + entry.Title, + entry.Summary, + filter.IncludeContent ? entry.Content : null, + entry.Tags, + entry.SortOrder, + entry.Metadata)) + .ToArrayAsync(cancellationToken); + + return new CatalogList(items); + } + + private static IQueryable ApplyNameKeyword(IQueryable query, string? keyword) + where T : class + { + if (string.IsNullOrWhiteSpace(keyword)) + { + return query; + } + + var trimmed = keyword.Trim(); + return query.Where(entity => EF.Property(entity, "Name").Contains(trimmed)); + } + + private static int ResolveLimit(int? limit) + { + return Math.Clamp(limit ?? DefaultLimit, 1, MaxLimit); + } +} diff --git a/Tiku.IntegrationTests/Api/StudyContentEndpointTests.cs b/Tiku.IntegrationTests/Api/StudyContentEndpointTests.cs new file mode 100644 index 0000000..07a463e --- /dev/null +++ b/Tiku.IntegrationTests/Api/StudyContentEndpointTests.cs @@ -0,0 +1,202 @@ +using System.Net; +using System.Text.Json; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.Tenancy; + +namespace Tiku.IntegrationTests.Api; + +public sealed class StudyContentEndpointTests +{ + [Fact] + public async Task Vocabulary_units_are_active_sorted_and_region_scoped() + { + var tenantId = Guid.NewGuid(); + var regionId = Guid.NewGuid(); + await using var factory = new ApiTestFactory(); + await factory.SeedAsync( + Tenant(tenantId, "master"), + new VocabularyUnit + { + Id = Guid.NewGuid(), + TenantId = tenantId, + RegionId = regionId, + Name = "核心词汇", + SortOrder = 2, + IsActive = true + }, + new VocabularyUnit + { + Id = Guid.NewGuid(), + TenantId = tenantId, + Name = "全局词汇", + SortOrder = 1, + IsActive = true + }, + new VocabularyUnit + { + Id = Guid.NewGuid(), + TenantId = tenantId, + RegionId = regionId, + Name = "停用词汇", + SortOrder = 0, + IsActive = false + }); + using var client = factory.CreateClient(); + + using var response = await client.GetAsync($"/api/catalog/vocabulary-units?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 Vocabulary_words_support_unit_and_keyword_filters() + { + var tenantId = Guid.NewGuid(); + var unitId = Guid.NewGuid(); + await using var factory = new ApiTestFactory(); + await factory.SeedAsync( + Tenant(tenantId, "master"), + new VocabularyUnit { Id = unitId, TenantId = tenantId, Name = "Unit 1" }, + new VocabularyWord + { + Id = Guid.NewGuid(), + TenantId = tenantId, + UnitId = unitId, + Word = "abandon", + Meaning = "放弃", + SortOrder = 1, + IsActive = true + }, + new VocabularyWord + { + Id = Guid.NewGuid(), + TenantId = tenantId, + UnitId = unitId, + Word = "ability", + Meaning = "能力", + SortOrder = 2, + IsActive = true + }, + new VocabularyWord + { + Id = Guid.NewGuid(), + TenantId = tenantId, + Word = "other", + Meaning = "其他", + IsActive = true + }); + using var client = factory.CreateClient(); + + using var response = await client.GetAsync($"/api/catalog/vocabulary-words?tenantCode=master&unitId={unitId}&keyword=放弃"); + var items = await ReadItemsAsync(response); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var item = Assert.Single(items); + Assert.Equal("abandon", item.GetProperty("word").GetString()); + } + + [Fact] + public async Task Handbook_chapters_are_filtered_by_subject() + { + var tenantId = Guid.NewGuid(); + var subjectId = Guid.NewGuid(); + await using var factory = new ApiTestFactory(); + await factory.SeedAsync( + Tenant(tenantId, "master"), + new HandbookSubject + { + Id = subjectId, + TenantId = tenantId, + Name = "语文手册", + IsActive = true + }, + new HandbookChapter + { + Id = Guid.NewGuid(), + TenantId = tenantId, + SubjectId = subjectId, + Name = "古诗词", + SortOrder = 1, + IsActive = true + }, + new HandbookChapter + { + Id = Guid.NewGuid(), + TenantId = tenantId, + Name = "其他章节", + SortOrder = 2, + IsActive = true + }); + using var client = factory.CreateClient(); + + using var response = await client.GetAsync($"/api/catalog/handbook-chapters?tenantCode=master&subjectId={subjectId}"); + var items = await ReadItemsAsync(response); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("古诗词", Assert.Single(items).GetProperty("name").GetString()); + } + + [Fact] + public async Task Handbook_entries_hide_content_until_requested() + { + var tenantId = Guid.NewGuid(); + var chapterId = Guid.NewGuid(); + await using var factory = new ApiTestFactory(); + await factory.SeedAsync( + Tenant(tenantId, "master"), + new HandbookChapter + { + Id = chapterId, + TenantId = tenantId, + Name = "章节", + IsActive = true + }, + new HandbookEntry + { + Id = Guid.NewGuid(), + TenantId = tenantId, + ChapterId = chapterId, + Title = "知识点", + Summary = "摘要", + Content = "完整正文", + SortOrder = 1, + IsActive = true + }); + using var client = factory.CreateClient(); + + using var listResponse = await client.GetAsync($"/api/catalog/handbook-entries?tenantCode=master&chapterId={chapterId}"); + using var detailResponse = await client.GetAsync($"/api/catalog/handbook-entries?tenantCode=master&chapterId={chapterId}&includeContent=true"); + var listItem = Assert.Single(await ReadItemsAsync(listResponse)); + var detailItem = Assert.Single(await ReadItemsAsync(detailResponse)); + + Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode); + Assert.Equal(JsonValueKind.Null, listItem.GetProperty("content").ValueKind); + Assert.Equal("完整正文", detailItem.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(); + } +}