feat: enforce tenant isolation and shared question bank

This commit is contained in:
2026-07-27 16:59:12 +08:00
parent 28e9a9fa41
commit db4c7b4496
137 changed files with 6402 additions and 112274 deletions

View File

@@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore;
using Tiku.Application.QuestionBanks;
using Tiku.Domain.Commerce;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.QuestionBanks;
public sealed class PublicQuestionAccessPolicy(TikuDbContext dbContext) : IPublicQuestionAccessPolicy
{
public async Task EnsureCanStartAsync(Guid tenantId, CancellationToken cancellationToken = default)
{
var tenantIsActive = await dbContext.Tenants.AsNoTracking().AnyAsync(
tenant => tenant.Id == tenantId && tenant.Status == TenantStatus.Active,
cancellationToken);
var now = DateTimeOffset.UtcNow;
var subscriptionIsActive = await dbContext.TenantSubscriptions.AsNoTracking().AnyAsync(
subscription =>
subscription.TenantId == tenantId &&
(subscription.Status == TenantSubscriptionStatus.Trial ||
subscription.Status == TenantSubscriptionStatus.Active) &&
(!subscription.StartsAt.HasValue || subscription.StartsAt <= now) &&
(!subscription.ExpiresAt.HasValue || subscription.ExpiresAt > now),
cancellationToken);
if (!tenantIsActive || !subscriptionIsActive)
{
throw new PublicQuestionAccessDeniedException(
"public_question_subscription_required",
"An active trial or subscription is required to start public question practice.");
}
}
}

View File

@@ -1,14 +1,20 @@
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(TikuDbContext dbContext) : IQuestionBankQueryService
public sealed class QuestionBankQueryService(
TikuDbContext dbContext,
IPublicQuestionAccessPolicy accessPolicy,
ITenantExecutionScope tenantExecutionScope) : IQuestionBankQueryService
{
private const int DefaultQuestionLimit = 200;
private const int MaxQuestionLimit = 500;
@@ -19,47 +25,84 @@ public sealed class QuestionBankQueryService(TikuDbContext dbContext) : IQuestio
QuestionBankFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.QuestionBanks
var limit = ResolveLimit(filter.Limit, DefaultBankLimit, MaxBankLimit);
var tenantItems = filter.Source == QuestionSource.Platform
? []
: await dbContext.QuestionBanks
.AsNoTracking()
.Where(bank =>
bank.TenantId == filter.TenantId &&
bank.Status == QuestionBankStatus.Active);
if (filter.RegionId.HasValue)
{
query = query.Where(bank => bank.RegionId == filter.RegionId.Value || bank.RegionId == null);
}
query = ApplyKeyword(query, filter.Keyword);
var items = await query
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(ResolveLimit(filter.Limit, DefaultBankLimit, MaxBankLimit))
.Take(limit)
.Select(bank => new QuestionBankCatalogItem(
bank.Id,
bank.RegionId,
bank.Name,
bank.SourceScope,
QuestionSource.Tenant,
bank.Status,
bank.Metadata))
.ToArrayAsync(cancellationToken);
return new CatalogList<QuestionBankCatalogItem>(items);
var platformItems = filter.Source == QuestionSource.Tenant || !await CanAccessPlatformAsync(filter.TenantId, cancellationToken)
? []
: await tenantExecutionScope.ExecuteAsync(
filter.TenantId,
"List platform question banks for an entitled tenant",
async (provider, token) =>
{
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
return await systemDbContext.QuestionBanks.AsNoTracking()
.Join(
systemDbContext.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 questions = ApplyQuestionFilters(BaseQuestionQuery(), filter)
.OrderByDescending(question => question.CreatedAt)
.Take(ResolveLimit(filter.Limit, DefaultQuestionLimit, MaxQuestionLimit));
var items = await ProjectQuestions(questions)
.ToArrayAsync(cancellationToken);
return new CatalogList<QuestionCatalogItem>(items);
var limit = ResolveLimit(filter.Limit, DefaultQuestionLimit, MaxQuestionLimit);
var tenantItems = filter.Source == QuestionSource.Platform
? []
: await ProjectQuestions(
dbContext,
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(
@@ -71,13 +114,9 @@ public sealed class QuestionBankQueryService(TikuDbContext dbContext) : IQuestio
throw new QuestionBankRequiredFieldException("questionId is required.");
}
var questions = ApplyQuestionFilters(BaseQuestionQuery(), filter)
.Where(question => question.Id == filter.QuestionId.Value);
var question = await ProjectQuestions(questions)
.SingleOrDefaultAsync(cancellationToken);
return question ?? throw new QuestionBankNotFoundException("Question was not found.");
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(
@@ -89,6 +128,13 @@ public sealed class QuestionBankQueryService(TikuDbContext dbContext) : IQuestio
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 dbContext.Questions
.AsNoTracking()
.AnyAsync(
@@ -221,14 +267,17 @@ public sealed class QuestionBankQueryService(TikuDbContext dbContext) : IQuestio
return query.Where(entity => EF.Property<string>(entity, nameof(QuestionBank.Name)).Contains(trimmed));
}
private IQueryable<QuestionCatalogItem> ProjectQuestions(IQueryable<Question> questions)
private static IQueryable<QuestionCatalogItem> ProjectQuestions(
TikuDbContext 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 dbContext.QuestionVersions.AsNoTracking()
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
@@ -264,7 +313,113 @@ public sealed class QuestionBankQueryService(TikuDbContext dbContext) : IQuestio
version == null ? null : version.Explanation,
version == null ? emptySubQuestions : version.SubQuestions,
version == null ? null : version.CodeLang,
version == null ? null : version.CodeTemplate);
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(
filter.TenantId,
"List platform questions for an entitled tenant",
async (provider, token) =>
{
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
var platformTenantId = await systemDbContext.Tenants.AsNoTracking()
.Where(tenant => tenant.Mode == TenantMode.PlatformOwned)
.Select(tenant => tenant.Id)
.SingleAsync(token);
var query = systemDbContext.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(
systemDbContext,
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(
tenantId,
"Read platform question versions for an entitled tenant",
async (provider, token) =>
{
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
var platformQuestion = await systemDbContext.Questions.AsNoTracking()
.Where(question => question.Id == questionId && question.Status == QuestionStatus.Published)
.Join(
systemDbContext.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 systemDbContext.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)

View File

@@ -0,0 +1,98 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Application.QuestionBanks;
using Tiku.Application.Security;
using Tiku.Domain.Content;
using Tiku.Domain.QuestionBanks;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.QuestionBanks;
public sealed class QuestionReferenceService(
TikuDbContext dbContext,
IPublicQuestionAccessPolicy accessPolicy,
ITenantExecutionScope tenantExecutionScope) : IQuestionReferenceService
{
public async Task<TenantQuestionReference> ResolveAsync(
Guid tenantId,
Guid? userId,
QuestionLocator locator,
CancellationToken cancellationToken = default)
{
var ownerTenantId = locator.Source switch
{
QuestionSource.Tenant => await ResolveTenantQuestionAsync(tenantId, locator.QuestionId, cancellationToken),
QuestionSource.Platform => await ResolvePlatformQuestionAsync(tenantId, locator.QuestionId, cancellationToken),
_ => throw new QuestionLocatorException("question_source_invalid", "Question source is invalid.")
};
var existing = await dbContext.TenantQuestionReferences.SingleOrDefaultAsync(
reference =>
reference.TenantId == tenantId &&
reference.QuestionOwnerTenantId == ownerTenantId &&
reference.QuestionId == locator.QuestionId,
cancellationToken);
if (existing is not null)
{
return existing;
}
var reference = new TenantQuestionReference
{
TenantId = tenantId,
QuestionOwnerTenantId = ownerTenantId,
QuestionId = locator.QuestionId,
Source = locator.Source,
CreatedBy = userId
};
dbContext.TenantQuestionReferences.Add(reference);
return reference;
}
private async Task<Guid> ResolveTenantQuestionAsync(
Guid tenantId,
Guid questionId,
CancellationToken cancellationToken)
{
var exists = await dbContext.Questions.AsNoTracking().AnyAsync(
question =>
question.TenantId == tenantId &&
question.Id == questionId &&
question.Status == QuestionStatus.Published,
cancellationToken);
return exists
? tenantId
: throw new QuestionLocatorException("question_not_found", "Tenant question was not found.");
}
private async Task<Guid> ResolvePlatformQuestionAsync(
Guid tenantId,
Guid questionId,
CancellationToken cancellationToken)
{
await accessPolicy.EnsureCanStartAsync(tenantId, cancellationToken);
var ownerTenantId = await tenantExecutionScope.ExecuteAsync(
tenantId,
"Resolve a platform question for an entitled tenant",
async (provider, token) =>
{
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
return await systemDbContext.Questions.AsNoTracking()
.Where(question =>
question.Id == questionId &&
question.Status == QuestionStatus.Published)
.Join(
systemDbContext.Tenants.AsNoTracking().Where(tenant => tenant.Mode == TenantMode.PlatformOwned),
question => question.TenantId,
tenant => tenant.Id,
(question, tenant) => (Guid?)tenant.Id)
.SingleOrDefaultAsync(token);
},
cancellationToken);
return ownerTenantId ?? throw new QuestionLocatorException(
"question_not_found",
"Platform question was not found.");
}
}