Files
tiku-backend.net/Tiku.Infrastructure/Content/ContentNavigationQueryService.cs
xiong 33375a38d7
Some checks failed
ci / release-gate (push) Has been cancelled
refactor(architecture): harden module boundaries
2026-08-04 12:10:36 +08:00

330 lines
13 KiB
C#

using Microsoft.EntityFrameworkCore;
using Tiku.Application.Catalog;
using Tiku.Application.Content;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
using Tiku.Domain.QuestionBanks;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Content;
public sealed class ContentNavigationQueryService(IQuestionBankPersistence dbContext) : IContentNavigationQueryService
{
private const int DefaultLimit = 100;
private const int MaxLimit = 1000;
public async Task<CatalogList<ContentEntryCatalogItem>> GetContentEntriesAsync(
ContentNavigationFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.ContentEntries
.AsNoTracking()
.Where(entry =>
entry.TenantId == filter.TenantId &&
entry.IsActive);
if (!filter.IncludeHidden) query = query.Where(entry => entry.Visibility != ContentVisibility.Hidden);
if (filter.RegionId.HasValue)
query = query.Where(entry => entry.RegionId == filter.RegionId.Value || entry.RegionId == null);
if (TryParseEntryType(filter.EntryType, out var entryType))
query = query.Where(entry => entry.EntryType == entryType);
query = ApplyKeyword(query, filter.Keyword);
var items = await query
.OrderBy(entry => entry.SortOrder)
.ThenBy(entry => entry.CreatedAt)
.Take(ResolveLimit(filter.Limit, 500))
.Select(entry => new ContentEntryCatalogItem(
entry.Id,
entry.RegionId,
entry.LegacyId,
entry.EntryKey,
entry.Name,
entry.EntryType,
entry.Icon,
entry.Route,
entry.Description,
entry.Visibility,
entry.AccessRules,
entry.LayoutConfig,
entry.SortOrder))
.ToArrayAsync(cancellationToken);
return new CatalogList<ContentEntryCatalogItem>(items);
}
public async Task<CatalogList<ContentNodeCatalogItem>> GetContentNodesAsync(
ContentNavigationFilter filter,
CancellationToken cancellationToken = default)
{
if (!filter.EntryId.HasValue) throw new RequiredFieldException("entryId is required.");
var query = dbContext.ContentNodes
.AsNoTracking()
.Where(node =>
node.TenantId == filter.TenantId &&
node.EntryId == filter.EntryId.Value);
if (!filter.IncludeInactive) query = query.Where(node => node.IsActive);
if (filter.RegionId.HasValue)
query = query.Where(node => node.RegionId == filter.RegionId.Value || node.RegionId == null);
if (filter.ParentWasSpecified)
query = filter.ParentIsRoot
? query.Where(node => node.ParentId == null)
: query.Where(node => node.ParentId == filter.ParentId);
if (TryParseMarkerType(filter.MarkerType, out var markerType))
query = query.Where(node => node.MarkerType == markerType);
query = ApplyKeyword(query, filter.Keyword);
query = string.Equals(filter.Mode, "flat", StringComparison.OrdinalIgnoreCase)
? query.OrderBy(node => node.Path).ThenBy(node => node.SortOrder)
: query.OrderBy(node => node.SortOrder).ThenBy(node => node.CreatedAt);
var items = await query
.Take(ResolveLimit(filter.Limit, 1000))
.Select(node => new ContentNodeCatalogItem(
node.Id,
node.EntryId,
node.RegionId,
node.ParentId,
node.LegacyId,
node.NodeKey,
node.Name,
node.NodeType,
node.MarkerType,
node.MarkerConfig,
node.Path,
node.Depth,
node.SortOrder,
node.IsSelectable,
node.IsLeaf,
node.AccessRules,
node.Metadata))
.ToArrayAsync(cancellationToken);
return new CatalogList<ContentNodeCatalogItem>(items);
}
public async Task<CatalogList<QuestionCollectionCatalogItem>> GetQuestionCollectionsAsync(
ContentNavigationFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.QuestionCollections
.AsNoTracking()
.Where(collection =>
collection.TenantId == filter.TenantId &&
collection.Status == ContentStatus.Active);
if (filter.RegionId.HasValue)
query = query.Where(collection =>
collection.RegionId == filter.RegionId.Value || collection.RegionId == null);
if (filter.EntryId.HasValue) query = query.Where(collection => collection.EntryId == filter.EntryId.Value);
if (filter.NodeId.HasValue) query = query.Where(collection => collection.NodeId == filter.NodeId.Value);
if (TryParseCollectionType(filter.CollectionType, out var collectionType))
query = query.Where(collection => collection.CollectionType == collectionType);
query = ApplyKeyword(query, filter.Keyword);
var items = await query
.OrderBy(collection => collection.SortOrder)
.ThenBy(collection => collection.CreatedAt)
.Take(ResolveLimit(filter.Limit, 500))
.Select(collection => new QuestionCollectionCatalogItem(
collection.Id,
collection.RegionId,
collection.EntryId,
collection.NodeId,
collection.SubjectId,
collection.CategoryId,
collection.QuestionBankId,
collection.LegacyId,
collection.Name,
collection.CollectionType,
collection.SourceType,
collection.Filters,
collection.QuestionCount,
collection.TotalScore,
collection.DurationMinutes,
collection.Status,
collection.SortOrder,
collection.AccessRules,
collection.Metadata))
.ToArrayAsync(cancellationToken);
return new CatalogList<QuestionCollectionCatalogItem>(items);
}
public async Task<CatalogList<PracticeBlueprintCatalogItem>> GetPracticeBlueprintsAsync(
ContentNavigationFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.PracticeBlueprints
.AsNoTracking()
.Where(blueprint =>
blueprint.TenantId == filter.TenantId &&
blueprint.Status == ContentStatus.Active);
if (filter.RegionId.HasValue)
query = query.Where(blueprint => blueprint.RegionId == filter.RegionId.Value || blueprint.RegionId == null);
if (filter.EntryId.HasValue) query = query.Where(blueprint => blueprint.EntryId == filter.EntryId.Value);
if (filter.NodeId.HasValue) query = query.Where(blueprint => blueprint.NodeId == filter.NodeId.Value);
if (filter.CollectionId.HasValue)
query = query.Where(blueprint => blueprint.CollectionId == filter.CollectionId.Value);
if (TryParsePracticeMode(filter.Mode, out var mode)) query = query.Where(blueprint => blueprint.Mode == mode);
query = ApplyKeyword(query, filter.Keyword);
var items = await query
.OrderBy(blueprint => blueprint.SortOrder)
.ThenBy(blueprint => blueprint.CreatedAt)
.Take(ResolveLimit(filter.Limit, 500))
.Select(blueprint => new PracticeBlueprintCatalogItem(
blueprint.Id,
blueprint.RegionId,
blueprint.EntryId,
blueprint.NodeId,
blueprint.CollectionId,
blueprint.LegacyId,
blueprint.Name,
blueprint.Mode,
blueprint.AssemblyType,
blueprint.QuestionLimit,
blueprint.DurationMinutes,
blueprint.TotalScore,
blueprint.PassScore,
blueprint.Sections,
blueprint.Rules,
blueprint.AccessRules,
blueprint.Status,
blueprint.SortOrder))
.ToArrayAsync(cancellationToken);
return new CatalogList<PracticeBlueprintCatalogItem>(items);
}
public async Task<CatalogList<CollectionQuestionCatalogItem>> GetCollectionQuestionsAsync(
ContentNavigationFilter filter,
CancellationToken cancellationToken = default)
{
if (!filter.CollectionId.HasValue) throw new RequiredFieldException("collectionId is required.");
var collectionExists = await dbContext.QuestionCollections
.AsNoTracking()
.AnyAsync(
collection =>
collection.TenantId == filter.TenantId &&
collection.Id == filter.CollectionId.Value &&
collection.Status == ContentStatus.Active,
cancellationToken);
if (!collectionExists) throw new ContentNavigationNotFoundException("Question collection was not found.");
var emptyOptions = JsonDefaults.Array();
var emptyCorrectOptionIndices = JsonDefaults.Array();
var emptySubQuestions = JsonDefaults.Array();
var query =
from item in dbContext.QuestionCollectionItems.AsNoTracking()
join question in dbContext.Questions.AsNoTracking()
on new { item.TenantId, item.QuestionId } equals new { question.TenantId, QuestionId = question.Id }
join version in dbContext.QuestionVersions.AsNoTracking()
on new { question.TenantId, QuestionId = question.Id, VersionId = question.CurrentVersionId }
equals new { version.TenantId, version.QuestionId, VersionId = (Guid?)version.Id }
into versions
from version in versions.DefaultIfEmpty()
where item.TenantId == filter.TenantId &&
item.CollectionId == filter.CollectionId.Value &&
question.Status == QuestionStatus.Published
orderby item.SectionKey, item.SortOrder, question.CreatedAt
select new CollectionQuestionCatalogItem(
question.Id,
question.LegacyId,
question.EntryId,
question.ContentNodeId,
question.PrimaryCollectionId,
question.SubjectId,
question.CategoryId,
question.NodeId,
question.Type,
question.TypeLabel,
question.Difficulty,
question.Tags,
question.ExamMarkers,
question.MediaUrl,
question.HasVideoExplanation,
item.SectionKey,
item.Score,
item.SortOrder,
version == null ? null : version.Id,
version == null ? null : version.Content,
version == null ? emptyOptions : version.Options,
version == null ? null : version.CorrectOptionIndex,
version == null ? emptyCorrectOptionIndices : version.CorrectOptionIndices,
version == null ? null : version.AnswerText,
version == null ? null : version.Explanation,
version == null ? emptySubQuestions : version.SubQuestions,
version == null ? null : version.CodeLang,
version == null ? null : version.CodeTemplate);
var items = await query
.Take(ResolveLimit(filter.Limit, MaxLimit))
.ToArrayAsync(cancellationToken);
return new CatalogList<CollectionQuestionCatalogItem>(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(ContentEntry.Name)).Contains(trimmed));
}
private static int ResolveLimit(int? limit, int defaultLimit)
{
return Math.Clamp(limit ?? defaultLimit, 1, MaxLimit);
}
private static bool TryParseEntryType(string? value, out ContentEntryType type)
{
return Enum.TryParse(NormalizeEnumValue(value), true, out type);
}
private static bool TryParseCollectionType(string? value, out QuestionCollectionType type)
{
return Enum.TryParse(NormalizeEnumValue(value), true, out type);
}
private static bool TryParsePracticeMode(string? value, out PracticeMode mode)
{
return Enum.TryParse(NormalizeEnumValue(value), true, out mode);
}
private static bool TryParseMarkerType(string? value, out ContentMarkerType 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);
}
}