using Microsoft.EntityFrameworkCore; using Tiku.Application.Catalog; using Tiku.Domain.Catalog; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Catalog; public sealed class CatalogQueryService(TikuDbContext dbContext) : ICatalogQueryService { private const int DefaultLimit = 500; private const int MaxLimit = 2000; public async Task> GetRegionsAsync( CatalogFilter filter, CancellationToken cancellationToken = default) { var query = dbContext.Regions .AsNoTracking() .Where(region => region.TenantId == filter.TenantId && region.IsActive); query = ApplyKeyword(query, filter.Keyword); var items = await query .OrderBy(region => region.SortOrder) .ThenBy(region => region.Name) .ThenBy(region => region.CreatedAt) .Take(ResolveLimit(filter.Limit)) .Select(region => new RegionCatalogItem( region.Id, region.LegacyId, region.Name, region.Code, region.ShortName, region.FullName, region.Icon, region.Pinyin, region.SortOrder, region.IsHot)) .ToArrayAsync(cancellationToken); return new CatalogList(items); } public async Task> GetRegionModulesAsync( CatalogFilter filter, CancellationToken cancellationToken = default) { var query = dbContext.RegionModules .AsNoTracking() .Where(module => module.TenantId == filter.TenantId && module.IsActive); if (filter.RegionId.HasValue) { query = query.Where(module => module.RegionId == filter.RegionId.Value); } query = ApplyKeyword(query, filter.Keyword); var items = await query .OrderBy(module => module.SortOrder) .ThenBy(module => module.Name) .ThenBy(module => module.CreatedAt) .Take(ResolveLimit(filter.Limit)) .Select(module => new RegionModuleCatalogItem( module.Id, module.LegacyId, module.RegionId, module.Name, module.Type, module.Icon, module.Color, module.TextColor, module.Description, module.Route, module.SortOrder, module.IsPrimarySchoolModule)) .ToArrayAsync(cancellationToken); return new CatalogList(items); } public async Task> GetModuleNodesAsync( CatalogFilter filter, CancellationToken cancellationToken = default) { var query = dbContext.ModuleNodes .AsNoTracking() .Where(node => node.TenantId == filter.TenantId && node.IsActive); if (filter.RegionId.HasValue) { query = query.Where(node => node.RegionId == filter.RegionId.Value); } if (filter.ModuleId.HasValue) { query = query.Where(node => node.ModuleId == filter.ModuleId.Value); } if (filter.ParentIsRoot) { query = query.Where(node => node.ParentId == null); } else if (filter.ParentId.HasValue) { query = query.Where(node => node.ParentId == filter.ParentId.Value); } if (TryParseModuleNodeType(filter.Type, out var nodeType)) { query = query.Where(node => node.Type == nodeType); } query = ApplyKeyword(query, filter.Keyword); var items = await query .OrderBy(node => node.SortOrder) .ThenBy(node => node.Name) .ThenBy(node => node.CreatedAt) .Take(ResolveLimit(filter.Limit)) .Select(node => new ModuleNodeCatalogItem( node.Id, node.LegacyId, node.RegionId, node.ModuleId, node.ParentId, node.LegacyParentId, node.Type, node.Name, node.Path, node.SortOrder, node.Metadata)) .ToArrayAsync(cancellationToken); return new CatalogList(items); } public async Task> GetSchoolsAsync( CatalogFilter filter, CancellationToken cancellationToken = default) { var query = dbContext.Schools .AsNoTracking() .Where(school => school.TenantId == filter.TenantId); if (filter.RegionId.HasValue) { query = query.Where(school => school.RegionId == filter.RegionId.Value); } if (filter.ModuleId.HasValue) { query = query.Where(school => school.ModuleId == filter.ModuleId.Value); } query = ApplyKeyword(query, filter.Keyword); var items = await query .OrderBy(school => school.Name) .ThenBy(school => school.CreatedAt) .Take(ResolveLimit(filter.Limit)) .Select(school => new SchoolCatalogItem( school.Id, school.LegacyId, school.RegionId, school.ModuleId, school.Name, school.ProfessionalExamDate, school.Metadata)) .ToArrayAsync(cancellationToken); return new CatalogList(items); } public async Task> GetMajorsAsync( CatalogFilter filter, CancellationToken cancellationToken = default) { var query = dbContext.Majors .AsNoTracking() .Where(major => major.TenantId == filter.TenantId && major.IsActive); if (filter.RegionId.HasValue) { query = query.Where(major => major.RegionId == filter.RegionId.Value); } if (filter.SchoolId.HasValue) { query = query.Where(major => major.SchoolId == filter.SchoolId.Value); } query = ApplyKeyword(query, filter.Keyword); var items = await query .OrderBy(major => major.SortOrder) .ThenBy(major => major.Name) .ThenBy(major => major.CreatedAt) .Take(ResolveLimit(filter.Limit)) .Select(major => new MajorCatalogItem( major.Id, major.LegacyId, major.RegionId, major.SchoolId, major.Name, major.Description, major.StudyTips, major.SortOrder)) .ToArrayAsync(cancellationToken); return new CatalogList(items); } public async Task> GetSubjectsAsync( CatalogFilter filter, CancellationToken cancellationToken = default) { var query = dbContext.Subjects .AsNoTracking() .Where(subject => subject.TenantId == filter.TenantId && subject.IsActive); if (filter.RegionId.HasValue) { query = query.Where(subject => subject.RegionId == filter.RegionId.Value); } if (filter.SchoolId.HasValue) { query = query.Where(subject => subject.SchoolId == filter.SchoolId.Value); } if (filter.MajorId.HasValue) { query = query.Where(subject => subject.MajorId == filter.MajorId.Value); } if (filter.ModuleId.HasValue) { query = query.Where(subject => subject.ModuleId == filter.ModuleId.Value); } if (TryParseSubjectType(filter.Type, out var subjectType)) { query = query.Where(subject => subject.Type == subjectType); } query = ApplyKeyword(query, filter.Keyword); var items = await query .OrderBy(subject => subject.SortOrder) .ThenBy(subject => subject.Name) .ThenBy(subject => subject.CreatedAt) .Take(ResolveLimit(filter.Limit)) .Select(subject => new SubjectCatalogItem( subject.Id, subject.LegacyId, subject.RegionId, subject.ModuleId, subject.SchoolId, subject.MajorId, subject.NodeId, subject.Name, subject.Type, subject.Icon, subject.Description, subject.Stats, subject.SortOrder)) .ToArrayAsync(cancellationToken); return new CatalogList(items); } public async Task> GetCategoriesAsync( CatalogFilter filter, CancellationToken cancellationToken = default) { var query = dbContext.Categories .AsNoTracking() .Where(category => category.TenantId == filter.TenantId && category.IsActive); if (filter.SubjectId.HasValue) { query = query.Where(category => category.SubjectId == filter.SubjectId.Value); } if (filter.NodeId.HasValue) { query = query.Where(category => category.NodeId == filter.NodeId.Value); } if (TryParseCategoryType(filter.Type, out var categoryType)) { query = query.Where(category => category.CategoryType == categoryType); } query = ApplyKeyword(query, filter.Keyword); var items = await query .OrderBy(category => category.SortOrder) .ThenBy(category => category.Name) .ThenBy(category => category.CreatedAt) .Take(ResolveLimit(filter.Limit)) .Select(category => new CategoryCatalogItem( category.Id, category.LegacyId, category.SubjectId, category.NodeId, category.Name, category.CategoryType, category.SortOrder, category.SvipQuestionLimit)) .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(Region.Name)).Contains(trimmed)); } private static int ResolveLimit(int? limit) { return Math.Clamp(limit ?? DefaultLimit, 1, MaxLimit); } private static bool TryParseSubjectType(string? value, out SubjectType type) { return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out type); } private static bool TryParseModuleNodeType(string? value, out ModuleNodeType type) { return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out type); } private static bool TryParseCategoryType(string? value, out CategoryType 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); } }