Files
tiku-backend.net/Tiku.Infrastructure/QuestionBanks/QuestionBankQueryService.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

406 lines
19 KiB
C#

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Application.Catalog;
using Tiku.Application.QuestionBanks;
using Tiku.Application.Security;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
using Tiku.Domain.QuestionBanks;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.QuestionBanks;
public sealed class QuestionBankQueryService(
IQuestionBankPersistence questionBankPersistence,
IPublicQuestionAccessPolicy accessPolicy,
ITenantExecutionScope tenantExecutionScope) : IQuestionBankQueryService
{
private const int DefaultQuestionLimit = 200;
private const int MaxQuestionLimit = 500;
private const int DefaultBankLimit = 100;
private const int MaxBankLimit = 500;
public async Task<CatalogList<QuestionBankCatalogItem>> GetQuestionBanksAsync(
QuestionBankFilter filter,
CancellationToken cancellationToken = default)
{
var limit = ResolveLimit(filter.Limit, DefaultBankLimit, MaxBankLimit);
var tenantItems = filter.Source == QuestionSource.Platform
? []
: await questionBankPersistence.QuestionBanks
.AsNoTracking()
.Where(bank =>
bank.TenantId == filter.TenantId &&
bank.Status == QuestionBankStatus.Active &&
(!filter.RegionId.HasValue || bank.RegionId == filter.RegionId.Value || bank.RegionId == null) &&
(string.IsNullOrWhiteSpace(filter.Keyword) || bank.Name.Contains(filter.Keyword.Trim())))
.OrderBy(bank => bank.Name)
.ThenBy(bank => bank.CreatedAt)
.Take(limit)
.Select(bank => new QuestionBankCatalogItem(
bank.Id,
bank.RegionId,
bank.Name,
QuestionSource.Tenant,
bank.Status,
bank.Metadata))
.ToArrayAsync(cancellationToken);
var platformItems = filter.Source == QuestionSource.Tenant ||
!await CanAccessPlatformAsync(filter.TenantId, cancellationToken)
? []
: await tenantExecutionScope.ExecuteAsync(
new SystemScopeRequest(
filter.TenantId, SystemScopeCallerType.PublicQuestionBank, nameof(QuestionBankQueryService),
"List platform question banks for an entitled tenant", Guid.NewGuid().ToString("N")),
async (provider, token) =>
{
var systemQuestionBank = provider.GetRequiredService<IQuestionBankPersistence>();
var systemTenancy = provider.GetRequiredService<ITenancyPersistence>();
return await systemQuestionBank.QuestionBanks.AsNoTracking()
.Join(
systemTenancy.Tenants.AsNoTracking()
.Where(tenant => tenant.Mode == TenantMode.PlatformOwned),
bank => bank.TenantId,
tenant => tenant.Id,
(bank, tenant) => bank)
.Where(bank =>
bank.Status == QuestionBankStatus.Active &&
(!filter.RegionId.HasValue || bank.RegionId == filter.RegionId.Value ||
bank.RegionId == null) &&
(string.IsNullOrWhiteSpace(filter.Keyword) || bank.Name.Contains(filter.Keyword.Trim())))
.OrderBy(bank => bank.Name)
.Take(limit)
.Select(bank => new QuestionBankCatalogItem(
bank.Id,
bank.RegionId,
bank.Name,
QuestionSource.Platform,
bank.Status,
bank.Metadata))
.ToArrayAsync(token);
},
cancellationToken);
return new CatalogList<QuestionBankCatalogItem>(tenantItems
.Concat(platformItems)
.OrderBy(item => item.Name)
.Take(limit)
.ToArray());
}
public async Task<CatalogList<QuestionCatalogItem>> GetQuestionsAsync(
QuestionBankFilter filter,
CancellationToken cancellationToken = default)
{
var limit = ResolveLimit(filter.Limit, DefaultQuestionLimit, MaxQuestionLimit);
var tenantItems = filter.Source == QuestionSource.Platform
? []
: await ProjectQuestions(
questionBankPersistence,
ApplyQuestionFilters(BaseQuestionQuery(), filter)
.OrderByDescending(question => question.CreatedAt)
.Take(limit),
QuestionSource.Tenant)
.ToArrayAsync(cancellationToken);
var platformItems = filter.Source == QuestionSource.Tenant ||
!await CanAccessPlatformAsync(filter.TenantId, cancellationToken)
? []
: await GetPlatformQuestionsAsync(filter, limit, cancellationToken);
return new CatalogList<QuestionCatalogItem>(tenantItems.Concat(platformItems).Take(limit).ToArray());
}
public async Task<QuestionCatalogItem> GetQuestionAsync(
QuestionBankFilter filter,
CancellationToken cancellationToken = default)
{
if (!filter.QuestionId.HasValue) throw new QuestionBankRequiredFieldException("questionId is required.");
var result = await GetQuestionsAsync(filter with { Limit = 2 }, cancellationToken);
return result.Items.SingleOrDefault()
?? throw new QuestionBankNotFoundException("Question was not found.");
}
public async Task<CatalogList<QuestionVersionCatalogItem>> GetQuestionVersionsAsync(
QuestionBankFilter filter,
CancellationToken cancellationToken = default)
{
if (!filter.QuestionId.HasValue) throw new QuestionBankRequiredFieldException("questionId is required.");
if (filter.Source == QuestionSource.Platform)
{
await accessPolicy.EnsureCanStartAsync(filter.TenantId, cancellationToken);
var platformItems =
await GetPlatformVersionsAsync(filter.QuestionId.Value, filter.TenantId, cancellationToken);
return new CatalogList<QuestionVersionCatalogItem>(platformItems);
}
var questionExists = await questionBankPersistence.Questions
.AsNoTracking()
.AnyAsync(
question =>
question.TenantId == filter.TenantId &&
question.Id == filter.QuestionId.Value &&
question.Status == QuestionStatus.Published,
cancellationToken);
if (!questionExists) throw new QuestionBankNotFoundException("Question was not found.");
var items = await questionBankPersistence.QuestionVersions
.AsNoTracking()
.Where(version =>
version.TenantId == filter.TenantId &&
version.QuestionId == filter.QuestionId.Value)
.OrderByDescending(version => version.VersionNo)
.Select(version => new QuestionVersionCatalogItem(
version.Id,
version.QuestionId,
version.VersionNo,
version.Content,
version.Options,
version.CorrectOptionIndex,
version.CorrectOptionIndices,
version.AnswerText,
version.Explanation,
version.SubQuestions,
version.CodeLang,
version.CodeTemplate,
version.CreatedAt))
.ToArrayAsync(cancellationToken);
return new CatalogList<QuestionVersionCatalogItem>(items);
}
private IQueryable<Question> BaseQuestionQuery()
{
return questionBankPersistence.Questions
.AsNoTracking()
.Where(question => question.Status == QuestionStatus.Published);
}
private IQueryable<Question> ApplyQuestionFilters(
IQueryable<Question> query,
QuestionBankFilter filter)
{
query = query.Where(question => question.TenantId == filter.TenantId);
if (filter.QuestionBankId.HasValue)
query = query.Where(question => question.QuestionBankId == filter.QuestionBankId.Value);
if (filter.SubjectId.HasValue) query = query.Where(question => question.SubjectId == filter.SubjectId.Value);
if (filter.CategoryId.HasValue) query = query.Where(question => question.CategoryId == filter.CategoryId.Value);
if (filter.NodeId.HasValue) query = query.Where(question => question.NodeId == filter.NodeId.Value);
if (filter.EntryId.HasValue) query = query.Where(question => question.EntryId == filter.EntryId.Value);
if (filter.ContentNodeId.HasValue)
query = query.Where(question => question.ContentNodeId == filter.ContentNodeId.Value);
if (filter.CollectionId.HasValue)
query = query.Where(question =>
question.PrimaryCollectionId == filter.CollectionId.Value ||
questionBankPersistence.QuestionCollectionItems.Any(item =>
item.TenantId == question.TenantId &&
item.QuestionId == question.Id &&
item.CollectionId == filter.CollectionId.Value));
if (filter.QuestionIds?.Count > 0) query = query.Where(question => filter.QuestionIds.Contains(question.Id));
if (!string.IsNullOrWhiteSpace(filter.Type))
{
var type = filter.Type.Trim();
query = query.Where(question => question.Type == type);
}
if (!string.IsNullOrWhiteSpace(filter.Keyword))
{
var keyword = filter.Keyword.Trim();
query = query.Where(question =>
question.Type.Contains(keyword) ||
(question.TypeLabel != null && question.TypeLabel.Contains(keyword)) ||
questionBankPersistence.QuestionVersions.Any(version =>
version.TenantId == question.TenantId &&
version.QuestionId == question.Id &&
version.Id == question.CurrentVersionId &&
version.Content != null &&
version.Content.Contains(keyword)));
}
return query;
}
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(QuestionBank.Name)).Contains(trimmed));
}
private static IQueryable<QuestionCatalogItem> ProjectQuestions(
IQuestionBankPersistence context,
IQueryable<Question> questions,
QuestionSource source)
{
var emptyOptions = JsonDefaults.Array();
var emptyCorrectOptionIndices = JsonDefaults.Array();
var emptySubQuestions = JsonDefaults.Array();
return
from question in questions
join version in context.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()
select new QuestionCatalogItem(
question.Id,
question.QuestionBankId,
question.SubjectId,
question.CategoryId,
question.NodeId,
question.EntryId,
question.ContentNodeId,
question.PrimaryCollectionId,
question.LegacyId,
question.LegacySubjectId,
question.LegacyCategoryId,
question.LegacyNodeId,
question.Type,
question.TypeLabel,
question.Difficulty,
question.Tags,
question.ExamMarkers,
question.MediaUrl,
question.HasVideoExplanation,
question.Status,
version == null ? null : version.Id,
version == null ? null : version.VersionNo,
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,
new QuestionLocator(source, question.Id));
}
private async Task<bool> CanAccessPlatformAsync(Guid tenantId, CancellationToken cancellationToken)
{
try
{
await accessPolicy.EnsureCanStartAsync(tenantId, cancellationToken);
return true;
}
catch (PublicQuestionAccessDeniedException)
{
return false;
}
}
private Task<QuestionCatalogItem[]> GetPlatformQuestionsAsync(
QuestionBankFilter filter,
int limit,
CancellationToken cancellationToken)
{
return tenantExecutionScope.ExecuteAsync(
new SystemScopeRequest(
filter.TenantId, SystemScopeCallerType.PublicQuestionBank, nameof(QuestionBankQueryService),
"List platform questions for an entitled tenant", Guid.NewGuid().ToString("N")),
async (provider, token) =>
{
var systemQuestionBank = provider.GetRequiredService<IQuestionBankPersistence>();
var systemTenancy = provider.GetRequiredService<ITenancyPersistence>();
var platformTenantId = await systemTenancy.Tenants.AsNoTracking()
.Where(tenant => tenant.Mode == TenantMode.PlatformOwned)
.Select(tenant => tenant.Id)
.SingleAsync(token);
var query = systemQuestionBank.Questions.AsNoTracking().Where(question =>
question.TenantId == platformTenantId &&
question.Status == QuestionStatus.Published &&
(!filter.QuestionId.HasValue || question.Id == filter.QuestionId.Value) &&
(!filter.QuestionBankId.HasValue || question.QuestionBankId == filter.QuestionBankId.Value) &&
(!filter.SubjectId.HasValue || question.SubjectId == filter.SubjectId.Value) &&
(!filter.CategoryId.HasValue || question.CategoryId == filter.CategoryId.Value) &&
(!filter.NodeId.HasValue || question.NodeId == filter.NodeId.Value) &&
(!filter.EntryId.HasValue || question.EntryId == filter.EntryId.Value) &&
(!filter.ContentNodeId.HasValue || question.ContentNodeId == filter.ContentNodeId.Value) &&
(filter.QuestionIds == null || filter.QuestionIds.Count == 0 ||
filter.QuestionIds.Contains(question.Id)) &&
(string.IsNullOrWhiteSpace(filter.Type) || question.Type == filter.Type.Trim()));
if (!string.IsNullOrWhiteSpace(filter.Keyword))
{
var keyword = filter.Keyword.Trim();
query = query.Where(question =>
question.Type.Contains(keyword) ||
(question.TypeLabel != null && question.TypeLabel.Contains(keyword)));
}
return await ProjectQuestions(
systemQuestionBank,
query.OrderByDescending(question => question.CreatedAt).Take(limit),
QuestionSource.Platform)
.ToArrayAsync(token);
},
cancellationToken);
}
private Task<QuestionVersionCatalogItem[]> GetPlatformVersionsAsync(
Guid questionId,
Guid tenantId,
CancellationToken cancellationToken)
{
return tenantExecutionScope.ExecuteAsync(
new SystemScopeRequest(
tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(QuestionBankQueryService),
"Read platform question versions for an entitled tenant", Guid.NewGuid().ToString("N")),
async (provider, token) =>
{
var systemQuestionBank = provider.GetRequiredService<IQuestionBankPersistence>();
var systemTenancy = provider.GetRequiredService<ITenancyPersistence>();
var platformQuestion = await systemQuestionBank.Questions.AsNoTracking()
.Where(question => question.Id == questionId && question.Status == QuestionStatus.Published)
.Join(
systemTenancy.Tenants.AsNoTracking().Where(tenant => tenant.Mode == TenantMode.PlatformOwned),
question => question.TenantId,
tenant => tenant.Id,
(question, tenant) => new { question.TenantId, question.Id })
.SingleOrDefaultAsync(token);
if (platformQuestion is null) throw new QuestionBankNotFoundException("Question was not found.");
return await systemQuestionBank.QuestionVersions.AsNoTracking()
.Where(version =>
version.TenantId == platformQuestion.TenantId &&
version.QuestionId == platformQuestion.Id)
.OrderByDescending(version => version.VersionNo)
.Select(version => new QuestionVersionCatalogItem(
version.Id,
version.QuestionId,
version.VersionNo,
version.Content,
version.Options,
version.CorrectOptionIndex,
version.CorrectOptionIndices,
version.AnswerText,
version.Explanation,
version.SubQuestions,
version.CodeLang,
version.CodeTemplate,
version.CreatedAt))
.ToArrayAsync(token);
},
cancellationToken);
}
private static int ResolveLimit(int? limit, int defaultLimit, int maxLimit)
{
return Math.Clamp(limit ?? defaultLimit, 1, maxLimit);
}
}