forked from gongxuegit/tiku-backend.net
feat: enforce tenant isolation and shared question bank
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System.Text.Json;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Learning;
|
||||
@@ -9,7 +12,11 @@ using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Learning;
|
||||
|
||||
public sealed class LearningActivityService(TikuDbContext dbContext) : ILearningActivityService
|
||||
public sealed class LearningActivityService(
|
||||
TikuDbContext dbContext,
|
||||
IQuestionReferenceService questionReferenceService,
|
||||
IPublicQuestionAccessPolicy publicQuestionAccessPolicy,
|
||||
ITenantExecutionScope tenantExecutionScope) : ILearningActivityService
|
||||
{
|
||||
private const int DefaultLimit = 100;
|
||||
private const int MaxLimit = 500;
|
||||
@@ -113,47 +120,36 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
SubmitAnswerCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var question = await dbContext.Questions
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var sessionQuestion = await dbContext.PracticeSessionQuestions
|
||||
.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.Id == command.QuestionId &&
|
||||
item.Status == QuestionStatus.Published)
|
||||
.Select(item => new
|
||||
{
|
||||
item.Id,
|
||||
item.CurrentVersionId
|
||||
})
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (question is null)
|
||||
{
|
||||
throw new LearningResourceNotFoundException("question_not_found", "Question was not found.");
|
||||
}
|
||||
|
||||
if (command.PracticeSessionId.HasValue)
|
||||
{
|
||||
var sessionExists = await dbContext.PracticeSessions.AnyAsync(
|
||||
session =>
|
||||
item.Id == command.SessionQuestionId)
|
||||
.Join(
|
||||
dbContext.PracticeSessions.AsNoTracking().Where(session =>
|
||||
session.TenantId == actor.TenantId &&
|
||||
session.UserId == actor.UserId &&
|
||||
session.Id == command.PracticeSessionId.Value,
|
||||
cancellationToken);
|
||||
session.FinishedAt == null &&
|
||||
(!session.ExpiresAt.HasValue || session.ExpiresAt > now)),
|
||||
item => new { item.TenantId, Id = item.PracticeSessionId },
|
||||
session => new { session.TenantId, session.Id },
|
||||
(item, session) => item)
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (!sessionExists)
|
||||
{
|
||||
throw new LearningResourceNotFoundException("practice_session_not_found", "Practice session was not found.");
|
||||
}
|
||||
if (sessionQuestion is null)
|
||||
{
|
||||
throw new LearningResourceNotFoundException(
|
||||
"session_question_not_found",
|
||||
"An active practice session question was not found.");
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var record = new AnswerRecord
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
UserId = actor.UserId,
|
||||
QuestionId = question.Id,
|
||||
QuestionVersionId = question.CurrentVersionId,
|
||||
PracticeSessionId = command.PracticeSessionId,
|
||||
PracticeSessionId = sessionQuestion.PracticeSessionId,
|
||||
SessionQuestionId = sessionQuestion.Id,
|
||||
SelectedOptions = JsonSerializer.SerializeToElement(command.SelectedOptions ?? []),
|
||||
AnswerText = command.AnswerText,
|
||||
IsCorrect = command.SelfJudgedCorrect,
|
||||
@@ -165,7 +161,7 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
if (command.SelfJudgedCorrect == false)
|
||||
{
|
||||
var wrongQuestion = await dbContext.WrongQuestions.FindAsync(
|
||||
[actor.TenantId, actor.UserId, question.Id],
|
||||
[actor.TenantId, actor.UserId, sessionQuestion.QuestionReferenceId],
|
||||
cancellationToken);
|
||||
|
||||
if (wrongQuestion is null)
|
||||
@@ -174,7 +170,9 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
UserId = actor.UserId,
|
||||
QuestionId = question.Id,
|
||||
QuestionReferenceId = sessionQuestion.QuestionReferenceId,
|
||||
QuestionOwnerTenantId = sessionQuestion.QuestionOwnerTenantId,
|
||||
QuestionId = sessionQuestion.QuestionId,
|
||||
WrongCount = 1,
|
||||
LastWrongAt = now
|
||||
});
|
||||
@@ -204,8 +202,10 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.Select(item => new FavoriteQuestionItem(
|
||||
item.QuestionId,
|
||||
item.Source,
|
||||
item.QuestionReferenceId,
|
||||
new QuestionLocator(
|
||||
item.QuestionOwnerTenantId == item.TenantId ? QuestionSource.Tenant : QuestionSource.Platform,
|
||||
item.QuestionId),
|
||||
item.CreatedAt))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
@@ -217,11 +217,15 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
QuestionActionCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureQuestionExistsAsync(actor.TenantId, command.QuestionId, cancellationToken);
|
||||
var reference = await questionReferenceService.ResolveAsync(
|
||||
actor.TenantId,
|
||||
actor.UserId,
|
||||
command.Locator,
|
||||
cancellationToken);
|
||||
|
||||
var favorite = command.Favorite ?? true;
|
||||
var item = await dbContext.FavoriteQuestions.FindAsync(
|
||||
[actor.TenantId, actor.UserId, command.QuestionId],
|
||||
[actor.TenantId, actor.UserId, reference.Id],
|
||||
cancellationToken);
|
||||
|
||||
if (favorite)
|
||||
@@ -232,8 +236,10 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
UserId = actor.UserId,
|
||||
QuestionId = command.QuestionId,
|
||||
Source = "api",
|
||||
QuestionReferenceId = reference.Id,
|
||||
QuestionOwnerTenantId = reference.QuestionOwnerTenantId,
|
||||
QuestionId = reference.QuestionId,
|
||||
Source = reference.Source.ToString().ToLowerInvariant(),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
@@ -267,7 +273,10 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
.OrderByDescending(item => item.LastWrongAt)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.Select(item => new WrongQuestionItem(
|
||||
item.QuestionId,
|
||||
item.QuestionReferenceId,
|
||||
new QuestionLocator(
|
||||
item.QuestionOwnerTenantId == item.TenantId ? QuestionSource.Tenant : QuestionSource.Platform,
|
||||
item.QuestionId),
|
||||
item.WrongCount,
|
||||
item.LastWrongAt,
|
||||
item.ResolvedAt))
|
||||
@@ -281,8 +290,13 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
QuestionActionCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var reference = await questionReferenceService.ResolveAsync(
|
||||
actor.TenantId,
|
||||
actor.UserId,
|
||||
command.Locator,
|
||||
cancellationToken);
|
||||
var item = await dbContext.WrongQuestions.FindAsync(
|
||||
[actor.TenantId, actor.UserId, command.QuestionId],
|
||||
[actor.TenantId, actor.UserId, reference.Id],
|
||||
cancellationToken);
|
||||
|
||||
if (item is null)
|
||||
@@ -308,7 +322,13 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
.OrderByDescending(item => item.WrongCount)
|
||||
.ThenBy(item => item.LastWrongAt)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.Select(item => new WrongQuestionReviewPlanItem(item.QuestionId, item.WrongCount, item.LastWrongAt))
|
||||
.Select(item => new WrongQuestionReviewPlanItem(
|
||||
item.QuestionReferenceId,
|
||||
new QuestionLocator(
|
||||
item.QuestionOwnerTenantId == item.TenantId ? QuestionSource.Tenant : QuestionSource.Platform,
|
||||
item.QuestionId),
|
||||
item.WrongCount,
|
||||
item.LastWrongAt))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
return new WrongQuestionReviewPlan(
|
||||
items,
|
||||
@@ -594,12 +614,24 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var assembly = await BuildPracticeAssemblyAsync(actor.TenantId, command, cancellationToken);
|
||||
var questionIds = await CollectQuestionIdsAsync(actor, assembly, cancellationToken);
|
||||
if (questionIds.Count == 0)
|
||||
var questionReferenceIds = await CollectQuestionReferenceIdsAsync(actor, assembly, cancellationToken);
|
||||
if (questionReferenceIds.Count == 0)
|
||||
{
|
||||
throw new LearningValidationException("no_practice_questions", "No published questions are available for this practice target.");
|
||||
}
|
||||
|
||||
|
||||
var containsPlatformQuestion = await dbContext.TenantQuestionReferences.AsNoTracking().AnyAsync(
|
||||
reference =>
|
||||
reference.TenantId == actor.TenantId &&
|
||||
questionReferenceIds.Contains(reference.Id) &&
|
||||
reference.Source == QuestionSource.Platform,
|
||||
cancellationToken);
|
||||
if (containsPlatformQuestion)
|
||||
{
|
||||
await publicQuestionAccessPolicy.EnsureCanStartAsync(actor.TenantId, cancellationToken);
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var session = new PracticeSession
|
||||
{
|
||||
@@ -612,26 +644,46 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
CollectionId = assembly.CollectionId,
|
||||
EntryId = assembly.EntryId,
|
||||
ContentNodeId = assembly.ContentNodeId,
|
||||
QuestionIds = JsonSerializer.SerializeToElement(questionIds),
|
||||
QuestionCount = questionIds.Count,
|
||||
QuestionCount = questionReferenceIds.Count,
|
||||
DurationMinutes = assembly.DurationMinutes,
|
||||
TotalScore = assembly.TotalScore,
|
||||
ExpiresAt = assembly.DurationMinutes.HasValue
|
||||
? now.AddMinutes(assembly.DurationMinutes.Value)
|
||||
: null,
|
||||
AccessMode = PracticeAccessMode.Free,
|
||||
ConsumedFreeQuota = questionIds.Count,
|
||||
ConsumedFreeQuota = questionReferenceIds.Count,
|
||||
AccessSnapshot = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
strategy = "v1_free",
|
||||
requestedCount = assembly.QuestionLimit,
|
||||
grantedCount = questionIds.Count
|
||||
grantedCount = questionReferenceIds.Count
|
||||
}),
|
||||
Metadata = command.Metadata.ValueKind is JsonValueKind.Undefined
|
||||
? JsonDefaults.Object()
|
||||
: command.Metadata
|
||||
};
|
||||
dbContext.PracticeSessions.Add(session);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
var selections = await LoadQuestionSelectionsAsync(
|
||||
actor.TenantId,
|
||||
questionReferenceIds,
|
||||
cancellationToken);
|
||||
var scorePerQuestion = session.TotalScore.HasValue && selections.Count > 0
|
||||
? session.TotalScore.Value / selections.Count
|
||||
: (decimal?)null;
|
||||
dbContext.PracticeSessionQuestions.AddRange(selections.Select((selection, index) =>
|
||||
new PracticeSessionQuestion
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
PracticeSessionId = session.Id,
|
||||
QuestionReferenceId = selection.QuestionReferenceId,
|
||||
QuestionOwnerTenantId = selection.QuestionOwnerTenantId,
|
||||
QuestionId = selection.QuestionId,
|
||||
QuestionVersionId = selection.QuestionVersionId,
|
||||
Position = index,
|
||||
Score = scorePerQuestion
|
||||
}));
|
||||
dbContext.PracticeAccessEvents.Add(new PracticeAccessEvent
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
@@ -640,8 +692,8 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
EventType = PracticeAccessEventType.SessionCreated,
|
||||
AccessMode = PracticeAccessEventMode.Free,
|
||||
RequestedCount = assembly.QuestionLimit,
|
||||
GrantedCount = questionIds.Count,
|
||||
ConsumedFreeQuota = questionIds.Count,
|
||||
GrantedCount = selections.Count,
|
||||
ConsumedFreeQuota = selections.Count,
|
||||
Metadata = session.AccessSnapshot
|
||||
});
|
||||
|
||||
@@ -655,44 +707,20 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var session = await GetPracticeSessionAsync(actor, filter.PracticeSessionId, cancellationToken);
|
||||
var questionIds = ReadGuidArray(session.QuestionIds);
|
||||
var questions = await dbContext.Questions
|
||||
.AsNoTracking()
|
||||
.Where(question =>
|
||||
question.TenantId == actor.TenantId &&
|
||||
questionIds.Contains(question.Id))
|
||||
.GroupJoin(
|
||||
dbContext.QuestionVersions.AsNoTracking(),
|
||||
question => new { question.TenantId, QuestionId = question.Id, VersionId = question.CurrentVersionId },
|
||||
version => new { version.TenantId, version.QuestionId, VersionId = (Guid?)version.Id },
|
||||
(question, versions) => new { question, version = versions.FirstOrDefault() })
|
||||
.Select(row => new PracticeSessionQuestionItem(
|
||||
row.question.Id,
|
||||
row.question.Type,
|
||||
row.question.TypeLabel,
|
||||
row.question.Difficulty,
|
||||
row.question.Tags,
|
||||
row.version == null ? null : row.version.Id,
|
||||
row.version == null ? null : row.version.Content,
|
||||
row.version == null ? JsonDefaults.Array() : row.version.Options,
|
||||
row.version == null ? null : row.version.Explanation))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var questionById = questions.ToDictionary(question => question.Id);
|
||||
var orderedQuestions = questionIds
|
||||
.Where(questionById.ContainsKey)
|
||||
.Select(questionId => questionById[questionId])
|
||||
.ToArray();
|
||||
var orderedQuestions = await LoadSessionQuestionItemsAsync(
|
||||
actor.TenantId,
|
||||
session.Id,
|
||||
cancellationToken);
|
||||
|
||||
var answers = await dbContext.AnswerRecords
|
||||
.AsNoTracking()
|
||||
.Where(answer =>
|
||||
answer.TenantId == actor.TenantId &&
|
||||
answer.UserId == actor.UserId &&
|
||||
answer.PracticeSessionId == session.Id &&
|
||||
answer.QuestionId != null)
|
||||
answer.PracticeSessionId == session.Id)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var answersByQuestion = answers
|
||||
.GroupBy(answer => answer.QuestionId!.Value)
|
||||
.GroupBy(answer => answer.SessionQuestionId)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => ToItem(group.OrderByDescending(answer => answer.AnsweredAt).First()));
|
||||
@@ -885,7 +913,7 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<List<Guid>> CollectQuestionIdsAsync(
|
||||
private async Task<List<Guid>> CollectQuestionReferenceIdsAsync(
|
||||
LearningActor actor,
|
||||
PracticeAssembly assembly,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -898,16 +926,10 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.ResolvedAt == null)
|
||||
.Join(
|
||||
dbContext.Questions.AsNoTracking(),
|
||||
item => new { item.TenantId, item.QuestionId },
|
||||
question => new { question.TenantId, QuestionId = question.Id },
|
||||
(item, question) => new { item, question })
|
||||
.Where(row => row.question.Status == QuestionStatus.Published)
|
||||
.OrderByDescending(row => row.item.WrongCount)
|
||||
.ThenBy(row => row.item.LastWrongAt)
|
||||
.OrderByDescending(item => item.WrongCount)
|
||||
.ThenBy(item => item.LastWrongAt)
|
||||
.Take(assembly.QuestionLimit)
|
||||
.Select(row => row.question.Id)
|
||||
.Select(item => item.QuestionReferenceId)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@@ -918,15 +940,9 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId)
|
||||
.Join(
|
||||
dbContext.Questions.AsNoTracking(),
|
||||
item => new { item.TenantId, item.QuestionId },
|
||||
question => new { question.TenantId, QuestionId = question.Id },
|
||||
(item, question) => new { item, question })
|
||||
.Where(row => row.question.Status == QuestionStatus.Published)
|
||||
.OrderByDescending(row => row.item.CreatedAt)
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Take(assembly.QuestionLimit)
|
||||
.Select(row => row.question.Id)
|
||||
.Select(item => item.QuestionReferenceId)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@@ -937,16 +953,9 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.CollectionId == assembly.CollectionId.Value)
|
||||
.Join(
|
||||
dbContext.Questions.AsNoTracking(),
|
||||
item => new { item.TenantId, item.QuestionId },
|
||||
question => new { question.TenantId, QuestionId = question.Id },
|
||||
(item, question) => new { item, question })
|
||||
.Where(row => row.question.Status == QuestionStatus.Published)
|
||||
.OrderBy(row => row.item.SortOrder)
|
||||
.ThenBy(row => row.question.CreatedAt)
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.Take(assembly.QuestionLimit)
|
||||
.Select(row => row.question.Id)
|
||||
.Select(item => item.QuestionReferenceId)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@@ -973,11 +982,23 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
throw new LearningValidationException("practice_target_required", "Practice target is required.");
|
||||
}
|
||||
|
||||
return await query
|
||||
var questionIds = await query
|
||||
.OrderBy(question => question.CreatedAt)
|
||||
.Take(assembly.QuestionLimit)
|
||||
.Select(question => question.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
var referenceIds = new List<Guid>(questionIds.Count);
|
||||
foreach (var questionId in questionIds)
|
||||
{
|
||||
var reference = await questionReferenceService.ResolveAsync(
|
||||
actor.TenantId,
|
||||
actor.UserId,
|
||||
new QuestionLocator(QuestionSource.Tenant, questionId),
|
||||
cancellationToken);
|
||||
referenceIds.Add(reference.Id);
|
||||
}
|
||||
|
||||
return referenceIds;
|
||||
}
|
||||
|
||||
private static IQueryable<Question> ApplyLegacyTargetFilter(
|
||||
@@ -997,6 +1018,111 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<QuestionSelection>> LoadQuestionSelectionsAsync(
|
||||
Guid tenantId,
|
||||
IReadOnlyCollection<Guid> questionReferenceIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var rows = await tenantExecutionScope.ExecuteAsync(
|
||||
tenantId,
|
||||
"Lock published question versions for a new practice session",
|
||||
async (provider, token) =>
|
||||
{
|
||||
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
||||
return await (
|
||||
from reference in systemDbContext.TenantQuestionReferences.AsNoTracking()
|
||||
join question in systemDbContext.Questions.AsNoTracking()
|
||||
on new { TenantId = reference.QuestionOwnerTenantId, Id = reference.QuestionId }
|
||||
equals new { question.TenantId, question.Id }
|
||||
join version in systemDbContext.QuestionVersions.AsNoTracking()
|
||||
on new
|
||||
{
|
||||
TenantId = reference.QuestionOwnerTenantId,
|
||||
reference.QuestionId,
|
||||
Id = question.CurrentVersionId
|
||||
}
|
||||
equals new
|
||||
{
|
||||
version.TenantId,
|
||||
version.QuestionId,
|
||||
Id = (Guid?)version.Id
|
||||
}
|
||||
where reference.TenantId == tenantId &&
|
||||
questionReferenceIds.Contains(reference.Id) &&
|
||||
question.Status == QuestionStatus.Published
|
||||
select new QuestionSelection(
|
||||
reference.Id,
|
||||
reference.QuestionOwnerTenantId,
|
||||
reference.QuestionId,
|
||||
version.Id))
|
||||
.ToArrayAsync(token);
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
var byReference = rows.ToDictionary(row => row.QuestionReferenceId);
|
||||
if (byReference.Count != questionReferenceIds.Distinct().Count())
|
||||
{
|
||||
throw new LearningValidationException(
|
||||
"practice_question_unavailable",
|
||||
"One or more practice questions have no published version.");
|
||||
}
|
||||
|
||||
return questionReferenceIds.Select(referenceId => byReference[referenceId]).ToArray();
|
||||
}
|
||||
|
||||
private Task<PracticeSessionQuestionItem[]> LoadSessionQuestionItemsAsync(
|
||||
Guid tenantId,
|
||||
Guid practiceSessionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return tenantExecutionScope.ExecuteAsync(
|
||||
tenantId,
|
||||
"Read locked question versions for a tenant practice session",
|
||||
async (provider, token) =>
|
||||
{
|
||||
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
|
||||
return await (
|
||||
from sessionQuestion in systemDbContext.PracticeSessionQuestions.AsNoTracking()
|
||||
join question in systemDbContext.Questions.AsNoTracking()
|
||||
on new
|
||||
{
|
||||
TenantId = sessionQuestion.QuestionOwnerTenantId,
|
||||
Id = sessionQuestion.QuestionId
|
||||
}
|
||||
equals new { question.TenantId, question.Id }
|
||||
join version in systemDbContext.QuestionVersions.AsNoTracking()
|
||||
on new
|
||||
{
|
||||
TenantId = sessionQuestion.QuestionOwnerTenantId,
|
||||
sessionQuestion.QuestionId,
|
||||
Id = sessionQuestion.QuestionVersionId
|
||||
}
|
||||
equals new { version.TenantId, version.QuestionId, version.Id }
|
||||
where sessionQuestion.TenantId == tenantId &&
|
||||
sessionQuestion.PracticeSessionId == practiceSessionId
|
||||
orderby sessionQuestion.Position
|
||||
select new PracticeSessionQuestionItem(
|
||||
sessionQuestion.Id,
|
||||
sessionQuestion.QuestionReferenceId,
|
||||
new QuestionLocator(
|
||||
sessionQuestion.QuestionOwnerTenantId == tenantId
|
||||
? QuestionSource.Tenant
|
||||
: QuestionSource.Platform,
|
||||
sessionQuestion.QuestionId),
|
||||
question.Id,
|
||||
question.Type,
|
||||
question.TypeLabel,
|
||||
question.Difficulty,
|
||||
question.Tags,
|
||||
version.Id,
|
||||
version.Content,
|
||||
version.Options,
|
||||
version.Explanation))
|
||||
.ToArrayAsync(token);
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<PracticeSession> GetPracticeSessionAsync(
|
||||
LearningActor actor,
|
||||
Guid? practiceSessionId,
|
||||
@@ -1028,8 +1154,13 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
PracticeSession session,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var questionIds = ReadGuidArray(session.QuestionIds);
|
||||
if (questionIds.Count == 0)
|
||||
var sessionQuestions = await dbContext.PracticeSessionQuestions.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.PracticeSessionId == session.Id)
|
||||
.OrderBy(item => item.Position)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
if (sessionQuestions.Length == 0)
|
||||
{
|
||||
throw new LearningValidationException("practice_session_empty", "Practice session has no question snapshot.");
|
||||
}
|
||||
@@ -1039,21 +1170,20 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
.Where(answer =>
|
||||
answer.TenantId == actor.TenantId &&
|
||||
answer.UserId == actor.UserId &&
|
||||
answer.PracticeSessionId == session.Id &&
|
||||
answer.QuestionId != null)
|
||||
answer.PracticeSessionId == session.Id)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var latestAnswers = answers
|
||||
.GroupBy(answer => answer.QuestionId!.Value)
|
||||
.GroupBy(answer => answer.SessionQuestionId)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => group.OrderByDescending(answer => answer.AnsweredAt).First());
|
||||
var totalQuestions = questionIds.Count;
|
||||
var answeredCount = questionIds.Count(latestAnswers.ContainsKey);
|
||||
var correctCount = questionIds.Count(questionId =>
|
||||
latestAnswers.TryGetValue(questionId, out var answer) &&
|
||||
var totalQuestions = sessionQuestions.Length;
|
||||
var answeredCount = sessionQuestions.Count(question => latestAnswers.ContainsKey(question.Id));
|
||||
var correctCount = sessionQuestions.Count(question =>
|
||||
latestAnswers.TryGetValue(question.Id, out var answer) &&
|
||||
answer.IsCorrect == true);
|
||||
var wrongCount = questionIds.Count(questionId =>
|
||||
latestAnswers.TryGetValue(questionId, out var answer) &&
|
||||
var wrongCount = sessionQuestions.Count(question =>
|
||||
latestAnswers.TryGetValue(question.Id, out var answer) &&
|
||||
answer.IsCorrect != true);
|
||||
var unansweredCount = Math.Max(0, totalQuestions - answeredCount);
|
||||
var totalScore = session.TotalScore ?? totalQuestions;
|
||||
@@ -1062,18 +1192,22 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
var accuracy = totalQuestions == 0 ? 0 : Math.Round((decimal)correctCount / totalQuestions, 4);
|
||||
var submittedAt = DateTimeOffset.UtcNow;
|
||||
var durationSeconds = Math.Max(0, (int)(submittedAt - session.StartedAt).TotalSeconds);
|
||||
var wrongQuestionIds = questionIds
|
||||
.Where(questionId =>
|
||||
latestAnswers.TryGetValue(questionId, out var answer) &&
|
||||
var wrongQuestionIds = sessionQuestions
|
||||
.Where(question =>
|
||||
latestAnswers.TryGetValue(question.Id, out var answer) &&
|
||||
answer.IsCorrect != true)
|
||||
.Select(question => question.QuestionReferenceId)
|
||||
.ToArray();
|
||||
var questionResults = questionIds
|
||||
.Select(questionId =>
|
||||
var questionResults = sessionQuestions
|
||||
.Select(question =>
|
||||
{
|
||||
latestAnswers.TryGetValue(questionId, out var answer);
|
||||
latestAnswers.TryGetValue(question.Id, out var answer);
|
||||
return new
|
||||
{
|
||||
questionId,
|
||||
sessionQuestionId = question.Id,
|
||||
questionReferenceId = question.QuestionReferenceId,
|
||||
questionId = question.QuestionId,
|
||||
source = question.QuestionOwnerTenantId == actor.TenantId ? "tenant" : "platform",
|
||||
answered = answer is not null,
|
||||
isCorrect = answer?.IsCorrect,
|
||||
score = answer?.IsCorrect == true ? scorePerQuestion : 0,
|
||||
@@ -1190,8 +1324,7 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
{
|
||||
return new AnswerRecordItem(
|
||||
record.Id,
|
||||
record.QuestionId!.Value,
|
||||
record.QuestionVersionId,
|
||||
record.SessionQuestionId,
|
||||
record.PracticeSessionId,
|
||||
record.SelectedOptions,
|
||||
record.AnswerText,
|
||||
@@ -1226,7 +1359,6 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
item.CollectionId,
|
||||
item.EntryId,
|
||||
item.ContentNodeId,
|
||||
item.QuestionIds,
|
||||
item.QuestionCount,
|
||||
item.DurationMinutes,
|
||||
item.TotalScore,
|
||||
@@ -1340,6 +1472,12 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
||||
int QuestionLimit,
|
||||
int? DurationMinutes,
|
||||
decimal? TotalScore);
|
||||
|
||||
private sealed record QuestionSelection(
|
||||
Guid QuestionReferenceId,
|
||||
Guid QuestionOwnerTenantId,
|
||||
Guid QuestionId,
|
||||
Guid QuestionVersionId);
|
||||
}
|
||||
|
||||
public class LearningException(string code, string message) : Exception(message)
|
||||
|
||||
Reference in New Issue
Block a user