using Microsoft.EntityFrameworkCore; using Tiku.Application.Catalog; using Tiku.Application.StudyContent; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.StudyContent; public sealed class StudyContentQueryService(IContentAssetPersistence 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); } }