feat: add study content readonly endpoints

This commit is contained in:
xiong
2026-07-26 14:21:42 +08:00
parent 97e7819a34
commit 47613b51bd
8 changed files with 727 additions and 3 deletions

View File

@@ -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;
}

View 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);
}
}