604 lines
21 KiB
C#
604 lines
21 KiB
C#
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(TikuDbContext dbContext) : ICatalogQueryService
|
|
{
|
|
private const int DefaultLimit = 500;
|
|
private const int MaxLimit = 2000;
|
|
|
|
public async Task<CatalogList<RegionCatalogItem>> 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<RegionCatalogItem>(items);
|
|
}
|
|
|
|
public async Task<CatalogList<RegionModuleCatalogItem>> 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<RegionModuleCatalogItem>(items);
|
|
}
|
|
|
|
public async Task<CatalogList<ModuleNodeCatalogItem>> 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<ModuleNodeCatalogItem>(items);
|
|
}
|
|
|
|
public async Task<CatalogList<SchoolCatalogItem>> 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<SchoolCatalogItem>(items);
|
|
}
|
|
|
|
public async Task<CatalogList<MajorCatalogItem>> 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<MajorCatalogItem>(items);
|
|
}
|
|
|
|
public async Task<CatalogList<SubjectCatalogItem>> 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<SubjectCatalogItem>(items);
|
|
}
|
|
|
|
public async Task<CatalogList<CategoryCatalogItem>> 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<CategoryCatalogItem>(items);
|
|
}
|
|
|
|
public async Task<CatalogList<BannerCatalogItem>> GetBannersAsync(
|
|
CatalogFilter filter,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var query = dbContext.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<BannerCatalogItem>(items);
|
|
}
|
|
|
|
public async Task<CatalogList<FaqCatalogItem>> GetFaqsAsync(
|
|
CatalogFilter filter,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var query = dbContext.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<FaqCatalogItem>(items);
|
|
}
|
|
|
|
public async Task<CatalogList<AnnouncementCatalogItem>> GetAnnouncementsAsync(
|
|
CatalogFilter filter,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var query = dbContext.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<AnnouncementCatalogItem>(items);
|
|
}
|
|
|
|
public async Task<CatalogList<ExamDateCatalogItem>> GetExamDatesAsync(
|
|
CatalogFilter filter,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var query = dbContext.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<ExamDateCatalogItem>(items);
|
|
}
|
|
|
|
public async Task<CatalogList<ProductCatalogItem>> GetProductsAsync(
|
|
CatalogFilter filter,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var query = dbContext.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<ProductCatalogItem>(items);
|
|
}
|
|
|
|
public async Task<CatalogList<SvipPlanCatalogItem>> GetSvipPlansAsync(
|
|
CatalogFilter filter,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var query = dbContext.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<SvipPlanCatalogItem>(items);
|
|
}
|
|
|
|
private static IQueryable<T> ApplyKeyword<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, 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);
|
|
}
|
|
} |