using Microsoft.EntityFrameworkCore; using Tiku.Application.Catalog; using Tiku.Domain.Catalog; using Tiku.Domain.Commerce; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Catalog; public sealed class CatalogQueryService(ICatalogPersistence catalogPersistence, IJobsOperationsPersistence jobsOperationsPersistence, ILearningPersistence learningPersistence, ICommercePersistence commercePersistence) : ICatalogQueryService { private const int DefaultLimit = 500; private const int MaxLimit = 2000; public async Task> GetRegionsAsync( CatalogFilter filter, CancellationToken cancellationToken = default) { var query = catalogPersistence.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 = catalogPersistence.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 = catalogPersistence.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 = catalogPersistence.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 = catalogPersistence.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 = catalogPersistence.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 = catalogPersistence.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); } public async Task> GetBannersAsync( CatalogFilter filter, CancellationToken cancellationToken = default) { var query = jobsOperationsPersistence.Banners .AsNoTracking() .Where(banner => banner.TenantId == filter.TenantId && banner.IsActive); if (filter.RegionId.HasValue) query = query.Where(banner => banner.RegionId == filter.RegionId.Value); if (!string.IsNullOrWhiteSpace(filter.Keyword)) { var keyword = filter.Keyword.Trim(); query = query.Where(banner => (banner.Title != null && banner.Title.Contains(keyword)) || (banner.Subtitle != null && banner.Subtitle.Contains(keyword)) || (banner.Content != null && banner.Content.Contains(keyword))); } var items = await query .OrderBy(banner => banner.SortOrder) .ThenBy(banner => banner.CreatedAt) .Take(ResolveLimit(filter.Limit)) .Select(banner => new BannerCatalogItem( banner.Id, banner.LegacyId, banner.RegionId, banner.Title, banner.Subtitle, banner.Content, banner.ButtonText, banner.ButtonLink, banner.BackgroundColor, banner.BorderColor, banner.SortOrder, banner.IsActive)) .ToArrayAsync(cancellationToken); return new CatalogList(items); } public async Task> GetFaqsAsync( CatalogFilter filter, CancellationToken cancellationToken = default) { var query = jobsOperationsPersistence.Faqs .AsNoTracking() .Where(faq => faq.TenantId == filter.TenantId && faq.IsActive); if (filter.RegionId.HasValue) query = query.Where(faq => faq.RegionId == filter.RegionId.Value); if (!string.IsNullOrWhiteSpace(filter.Keyword)) { var keyword = filter.Keyword.Trim(); query = query.Where(faq => (faq.Question != null && faq.Question.Contains(keyword)) || (faq.Answer != null && faq.Answer.Contains(keyword))); } var items = await query .OrderBy(faq => faq.SortOrder) .ThenBy(faq => faq.CreatedAt) .Take(ResolveLimit(filter.Limit)) .Select(faq => new FaqCatalogItem( faq.Id, faq.LegacyId, faq.RegionId, faq.Question, faq.Answer, faq.SortOrder, faq.IsActive)) .ToArrayAsync(cancellationToken); return new CatalogList(items); } public async Task> GetAnnouncementsAsync( CatalogFilter filter, CancellationToken cancellationToken = default) { var query = jobsOperationsPersistence.Announcements .AsNoTracking() .Where(announcement => announcement.TenantId == filter.TenantId && announcement.IsActive); if (!string.IsNullOrWhiteSpace(filter.Keyword)) { var keyword = filter.Keyword.Trim(); query = query.Where(announcement => announcement.Content != null && announcement.Content.Contains(keyword)); } var items = await query .OrderBy(announcement => announcement.SortOrder) .ThenBy(announcement => announcement.CreatedAt) .Take(ResolveLimit(filter.Limit)) .Select(announcement => new AnnouncementCatalogItem( announcement.Id, announcement.LegacyId, announcement.Content, announcement.Link, announcement.BackgroundColor, announcement.SortOrder, announcement.IsActive)) .ToArrayAsync(cancellationToken); return new CatalogList(items); } public async Task> GetExamDatesAsync( CatalogFilter filter, CancellationToken cancellationToken = default) { var query = learningPersistence.ExamDates .AsNoTracking() .Where(examDate => examDate.TenantId == filter.TenantId && examDate.IsActive); if (filter.RegionId.HasValue) query = query.Where(examDate => examDate.RegionId == filter.RegionId.Value || examDate.RegionId == null); if (filter.SchoolId.HasValue) query = query.Where(examDate => examDate.SchoolId == filter.SchoolId.Value || examDate.SchoolId == null); if (!string.IsNullOrWhiteSpace(filter.Type)) query = query.Where(examDate => examDate.ExamType == filter.Type.Trim()); if (!string.IsNullOrWhiteSpace(filter.Keyword)) { var keyword = filter.Keyword.Trim(); query = query.Where(examDate => examDate.ExamName.Contains(keyword) || (examDate.Description != null && examDate.Description.Contains(keyword))); } var today = DateTimeOffset.UtcNow.Date; var rows = await query .OrderBy(examDate => examDate.ExamAt == null) .ThenBy(examDate => examDate.ExamAt) .ThenBy(examDate => examDate.SortOrder) .Take(ResolveLimit(filter.Limit)) .Select(examDate => new { examDate.Id, examDate.LegacyId, examDate.RegionId, examDate.SchoolId, examDate.ExamName, examDate.ExamAt, examDate.ExamType, examDate.Description, examDate.Metadata, examDate.SortOrder, examDate.IsActive }) .ToArrayAsync(cancellationToken); var items = rows .Select(examDate => new ExamDateCatalogItem( examDate.Id, examDate.LegacyId, examDate.RegionId, examDate.SchoolId, examDate.ExamName, examDate.ExamAt, examDate.ExamType, examDate.Description, examDate.Metadata, examDate.SortOrder, examDate.IsActive, examDate.ExamAt.HasValue ? (examDate.ExamAt.Value.Date - today).Days : null)) .ToArray(); return new CatalogList(items); } public async Task> GetProductsAsync( CatalogFilter filter, CancellationToken cancellationToken = default) { var query = commercePersistence.Products .AsNoTracking() .Where(product => product.TenantId == filter.TenantId && product.IsActive); if (filter.RegionId.HasValue) query = query.Where(product => product.RegionId == filter.RegionId.Value); if (TryParseProductType(filter.Type, out var productType)) query = query.Where(product => product.Type == productType); if (!string.IsNullOrWhiteSpace(filter.Keyword)) { var keyword = filter.Keyword.Trim(); query = query.Where(product => product.Title.Contains(keyword)); } var items = await query .OrderBy(product => product.SortOrder) .ThenByDescending(product => product.CreatedAt) .Take(ResolveLimit(filter.Limit)) .Select(product => new ProductCatalogItem( product.Id, product.LegacyId, product.RegionId, product.Title, null, product.LegacyPriceText, product.Link, product.Type, product.Tags, product.Cover, product.PreviewIframe, product.DetailImages, product.SortOrder, product.IsActive)) .ToArrayAsync(cancellationToken); return new CatalogList(items); } public async Task> GetSvipPlansAsync( CatalogFilter filter, CancellationToken cancellationToken = default) { var query = commercePersistence.SvipPlans .AsNoTracking() .Where(plan => plan.TenantId == filter.TenantId && plan.IsActive); if (filter.RegionId.HasValue) query = query.Where(plan => plan.RegionId == filter.RegionId.Value || plan.RegionId == null); if (!string.IsNullOrWhiteSpace(filter.Keyword)) { var keyword = filter.Keyword.Trim(); query = query.Where(plan => plan.Name.Contains(keyword) || (plan.Description != null && plan.Description.Contains(keyword))); } var items = await query .OrderBy(plan => plan.SortOrder) .ThenBy(plan => plan.PriceCents) .Take(ResolveLimit(filter.Limit)) .Select(plan => new SvipPlanCatalogItem( plan.Id, plan.LegacyId, plan.RegionId, plan.Name, plan.PriceCents, plan.PriceCents / 100m, plan.OriginalPriceCents, plan.OriginalPriceCents.HasValue ? plan.OriginalPriceCents.Value / 100m : null, plan.Days, plan.Description, plan.PerDayLabel, plan.Badge, plan.Recommended, plan.CouponOnly, plan.VpProductId, plan.VpEnabled, plan.SortOrder, plan.IsActive)) .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), true, out type); } private static bool TryParseModuleNodeType(string? value, out ModuleNodeType type) { return Enum.TryParse(NormalizeEnumValue(value), true, out type); } private static bool TryParseCategoryType(string? value, out CategoryType type) { return Enum.TryParse(NormalizeEnumValue(value), true, out type); } private static bool TryParseProductType(string? value, out ProductType type) { return Enum.TryParse(NormalizeEnumValue(value), 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); } }