feat: add study content readonly endpoints
This commit is contained in:
@@ -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 接入这套安全轨道,而不是重新散写权限判断。
|
||||
|
||||
45
Tiku.Api/Contracts/StudyContentDtos.cs
Normal file
45
Tiku.Api/Contracts/StudyContentDtos.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<CatalogList<VocabularyUnitCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<CatalogList<VocabularyUnitCatalogItem>>> GetVocabularyUnits(
|
||||
[FromQuery] StudyContentQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await studyContentQueryService.GetVocabularyUnitsAsync(
|
||||
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("vocabulary-words")]
|
||||
[EndpointSummary("查询词汇单词")]
|
||||
[ProducesResponseType<CatalogList<VocabularyWordCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<CatalogList<VocabularyWordCatalogItem>>> GetVocabularyWords(
|
||||
[FromQuery] StudyContentQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await studyContentQueryService.GetVocabularyWordsAsync(
|
||||
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("handbook-subjects")]
|
||||
[EndpointSummary("查询知识手册科目")]
|
||||
[ProducesResponseType<CatalogList<HandbookSubjectCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<CatalogList<HandbookSubjectCatalogItem>>> GetHandbookSubjects(
|
||||
[FromQuery] StudyContentQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await studyContentQueryService.GetHandbookSubjectsAsync(
|
||||
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("handbook-chapters")]
|
||||
[EndpointSummary("查询知识手册章节")]
|
||||
[ProducesResponseType<CatalogList<HandbookChapterCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<CatalogList<HandbookChapterCatalogItem>>> GetHandbookChapters(
|
||||
[FromQuery] StudyContentQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await studyContentQueryService.GetHandbookChaptersAsync(
|
||||
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("handbook-entries")]
|
||||
[EndpointSummary("查询知识手册条目")]
|
||||
[ProducesResponseType<CatalogList<HandbookEntryCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<CatalogList<HandbookEntryCatalogItem>>> GetHandbookEntries(
|
||||
[FromQuery] StudyContentQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await studyContentQueryService.GetHandbookEntriesAsync(
|
||||
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken)),
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<Guid> ResolveTenantIdAsync(
|
||||
CatalogQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -284,6 +351,18 @@ public sealed class CatalogController(
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private Task<Guid> ResolveTenantIdAsync(
|
||||
StudyContentQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return ResolveTenantIdAsync(
|
||||
new CatalogQueryDto
|
||||
{
|
||||
TenantCode = query.TenantCode
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TenantNotFoundException : Exception
|
||||
|
||||
26
Tiku.Application/StudyContent/IStudyContentQueryService.cs
Normal file
26
Tiku.Application/StudyContent/IStudyContentQueryService.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using Tiku.Application.Catalog;
|
||||
|
||||
namespace Tiku.Application.StudyContent;
|
||||
|
||||
public interface IStudyContentQueryService
|
||||
{
|
||||
Task<CatalogList<VocabularyUnitCatalogItem>> GetVocabularyUnitsAsync(
|
||||
StudyContentFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CatalogList<VocabularyWordCatalogItem>> GetVocabularyWordsAsync(
|
||||
StudyContentFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CatalogList<HandbookSubjectCatalogItem>> GetHandbookSubjectsAsync(
|
||||
StudyContentFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CatalogList<HandbookChapterCatalogItem>> GetHandbookChaptersAsync(
|
||||
StudyContentFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<CatalogList<HandbookEntryCatalogItem>> GetHandbookEntriesAsync(
|
||||
StudyContentFilter filter,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
84
Tiku.Application/StudyContent/StudyContentQueryModels.cs
Normal file
84
Tiku.Application/StudyContent/StudyContentQueryModels.cs
Normal file
@@ -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);
|
||||
@@ -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<ICatalogQueryService, CatalogQueryService>();
|
||||
services.AddScoped<IContentNavigationQueryService, ContentNavigationQueryService>();
|
||||
services.AddScoped<IQuestionBankQueryService, QuestionBankQueryService>();
|
||||
services.AddScoped<IStudyContentQueryService, StudyContentQueryService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
284
Tiku.Infrastructure/StudyContent/StudyContentQueryService.cs
Normal file
284
Tiku.Infrastructure/StudyContent/StudyContentQueryService.cs
Normal file
@@ -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<CatalogList<VocabularyUnitCatalogItem>> 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<VocabularyUnitCatalogItem>(items);
|
||||
}
|
||||
|
||||
public async Task<CatalogList<VocabularyWordCatalogItem>> 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<VocabularyWordCatalogItem>(items);
|
||||
}
|
||||
|
||||
public async Task<CatalogList<HandbookSubjectCatalogItem>> 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<HandbookSubjectCatalogItem>(items);
|
||||
}
|
||||
|
||||
public async Task<CatalogList<HandbookChapterCatalogItem>> 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<HandbookChapterCatalogItem>(items);
|
||||
}
|
||||
|
||||
public async Task<CatalogList<HandbookEntryCatalogItem>> 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<HandbookEntryCatalogItem>(items);
|
||||
}
|
||||
|
||||
private static IQueryable<T> ApplyNameKeyword<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, "Name").Contains(trimmed));
|
||||
}
|
||||
|
||||
private static int ResolveLimit(int? limit)
|
||||
{
|
||||
return Math.Clamp(limit ?? DefaultLimit, 1, MaxLimit);
|
||||
}
|
||||
}
|
||||
202
Tiku.IntegrationTests/Api/StudyContentEndpointTests.cs
Normal file
202
Tiku.IntegrationTests/Api/StudyContentEndpointTests.cs
Normal file
@@ -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<JsonElement[]> ReadItemsAsync(HttpResponseMessage response)
|
||||
{
|
||||
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
return body.RootElement
|
||||
.GetProperty("items")
|
||||
.EnumerateArray()
|
||||
.Select(item => item.Clone())
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user