forked from xiongyuxing/tiku-backend.net
381 lines
14 KiB
C#
381 lines
14 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Tiku.Application.Catalog;
|
|
using Tiku.Application.Content;
|
|
using Tiku.Domain.Content;
|
|
using Tiku.Domain.QuestionBanks;
|
|
using Tiku.Infrastructure.Persistence;
|
|
|
|
namespace Tiku.Infrastructure.Content;
|
|
|
|
public sealed class ContentNavigationQueryService(TikuDbContext 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 query =
|
|
from item in dbContext.QuestionCollectionItems.AsNoTracking()
|
|
join question in dbContext.Questions.AsNoTracking()
|
|
on new { item.TenantId, QuestionId = 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 ? default : version.Options,
|
|
version == null ? null : version.CorrectOptionIndex,
|
|
version == null ? default : version.CorrectOptionIndices,
|
|
version == null ? null : version.AnswerText,
|
|
version == null ? null : version.Explanation,
|
|
version == null ? default : 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), ignoreCase: true, out type);
|
|
}
|
|
|
|
private static bool TryParseCollectionType(string? value, out QuestionCollectionType type)
|
|
{
|
|
return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out type);
|
|
}
|
|
|
|
private static bool TryParsePracticeMode(string? value, out PracticeMode mode)
|
|
{
|
|
return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out mode);
|
|
}
|
|
|
|
private static bool TryParseMarkerType(string? value, out ContentMarkerType 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);
|
|
}
|
|
}
|
|
|
|
public sealed class RequiredFieldException(string message) : Exception(message);
|
|
|
|
public sealed class ContentNavigationNotFoundException(string message) : Exception(message);
|