feat: add catalog readonly endpoints

This commit is contained in:
xiong
2026-07-26 14:02:04 +08:00
parent 41a8a5ff81
commit d5379affba
10 changed files with 962 additions and 53 deletions

View File

@@ -0,0 +1,368 @@
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<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);
}
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), 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);
}
}