177 lines
8.3 KiB
C#
177 lines
8.3 KiB
C#
using System.Text.Json;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Npgsql;
|
|
using Tiku.Application.Learning;
|
|
using Tiku.Domain.Learning;
|
|
|
|
namespace Tiku.Infrastructure.Learning;
|
|
|
|
internal sealed class PracticeSessionService(
|
|
LearningServiceDependencies dependencies,
|
|
IV2PracticeSessionService v2PracticeSessionService)
|
|
: LearningActivityServiceBase(dependencies), IPracticeSessionService
|
|
{
|
|
public async Task<PracticeSessionItem> CreatePracticeSessionAsync(
|
|
LearningActor actor,
|
|
PracticeSessionCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return await v2PracticeSessionService.CreateAsync(actor, command, cancellationToken);
|
|
}
|
|
|
|
public async Task<PracticeSessionDetailItem> GetPracticeSessionDetailAsync(
|
|
LearningActor actor,
|
|
PracticeSessionFilter filter,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var session = await GetPracticeSessionAsync(actor, filter.PracticeSessionId, cancellationToken);
|
|
await strongRevocationService.EnsureVersionAsync(
|
|
actor, session.StrongRevocationVersion, cancellationToken);
|
|
var orderedQuestions = await LoadSessionQuestionItemsAsync(
|
|
actor.TenantId,
|
|
session.Id,
|
|
cancellationToken);
|
|
|
|
var answers = await learningPersistence.AnswerRecords
|
|
.AsNoTracking()
|
|
.Where(answer =>
|
|
answer.TenantId == actor.TenantId &&
|
|
answer.UserId == actor.UserId &&
|
|
answer.PracticeSessionId == session.Id &&
|
|
learningPersistence.CurrentAnswers.Any(current =>
|
|
current.TenantId == answer.TenantId && current.AnswerRecordId == answer.Id))
|
|
.ToArrayAsync(cancellationToken);
|
|
var answersByQuestion = answers
|
|
.GroupBy(answer => answer.SessionQuestionId)
|
|
.ToDictionary(
|
|
group => group.Key,
|
|
group => ToItem(
|
|
group.OrderByDescending(answer => answer.Revision).First()));
|
|
|
|
var revealAll = session.Status is PracticeSessionStatus.Submitted or PracticeSessionStatus.PendingReview;
|
|
var revealAnswered = !DefersSolutionUntilSubmission(session.Mode);
|
|
var revealQuestionIds = revealAll
|
|
? orderedQuestions.Select(item => item.SessionQuestionId).ToArray()
|
|
: revealAnswered ? answersByQuestion.Keys.ToArray() : [];
|
|
var solutions = revealQuestionIds.Length == 0
|
|
? new Dictionary<Guid, QuestionSolutionItem>()
|
|
: await LoadSolutionsAsync(actor.TenantId, session.Id, revealQuestionIds, cancellationToken);
|
|
|
|
return new PracticeSessionDetailItem(ToItem(session), orderedQuestions, answersByQuestion, solutions);
|
|
}
|
|
|
|
private async Task<Dictionary<Guid, QuestionSolutionItem>> LoadSolutionsAsync(
|
|
Guid tenantId,
|
|
Guid sessionId,
|
|
IReadOnlyCollection<Guid> revealQuestionIds,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var sessionQuestions = await learningPersistence.PracticeSessionQuestions.AsNoTracking()
|
|
.Where(item => item.TenantId == tenantId &&
|
|
item.PracticeSessionId == sessionId &&
|
|
revealQuestionIds.Contains(item.Id))
|
|
.ToArrayAsync(cancellationToken);
|
|
if (sessionQuestions.Any(item => !item.QuestionRevisionId.HasValue))
|
|
throw new LearningValidationException(
|
|
"practice_session_delivery_unsupported",
|
|
"The practice session uses a retired delivery model.");
|
|
|
|
return await v2PracticeSessionService.LoadSolutionsAsync(sessionQuestions, cancellationToken);
|
|
}
|
|
|
|
public async Task<PracticeSessionReportItem> SubmitPracticeSessionAsync(
|
|
LearningActor actor,
|
|
SubmitPracticeSessionCommand command,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(command.IdempotencyKey))
|
|
throw new LearningValidationException("idempotency_key_required", "An idempotency key is required.");
|
|
|
|
await using var transaction = await unitOfWork.Database.BeginTransactionAsync(cancellationToken);
|
|
var submissionLockKey = $"practice-submit:{actor.TenantId:N}:{actor.UserId:N}:{command.PracticeSessionId:N}";
|
|
await unitOfWork.Database.ExecuteSqlInterpolatedAsync(
|
|
$"SELECT pg_advisory_xact_lock(hashtextextended({submissionLockKey}, 0))",
|
|
cancellationToken);
|
|
var session = await learningPersistence.PracticeSessions.SingleOrDefaultAsync(item =>
|
|
item.TenantId == actor.TenantId &&
|
|
item.UserId == actor.UserId &&
|
|
item.Id == command.PracticeSessionId,
|
|
cancellationToken) ?? throw new LearningResourceNotFoundException(
|
|
"practice_session_not_found", "Practice session was not found.");
|
|
await strongRevocationService.EnsureVersionAsync(
|
|
actor, session.StrongRevocationVersion, cancellationToken);
|
|
var requestHash = HashSubmission(command);
|
|
var existingOperation = await learningPersistence.LearningOperationIdempotencies.AsNoTracking().SingleOrDefaultAsync(
|
|
item =>
|
|
item.TenantId == actor.TenantId &&
|
|
item.UserId == actor.UserId &&
|
|
item.PracticeSessionId == session.Id &&
|
|
item.OperationType == "submit" &&
|
|
item.IdempotencyKey == command.IdempotencyKey, cancellationToken);
|
|
if (existingOperation is not null)
|
|
{
|
|
if (!string.Equals(existingOperation.RequestHash, requestHash, StringComparison.Ordinal))
|
|
{
|
|
SubmissionConflicts.Add(1);
|
|
throw new LearningValidationException("idempotency_conflict",
|
|
"The idempotency key was used with a different request.");
|
|
}
|
|
|
|
IdempotencyReplays.Add(1);
|
|
return existingOperation.ResponseSnapshot.Deserialize<PracticeSessionReportItem>()
|
|
?? throw new InvalidOperationException("The stored report response is invalid.");
|
|
}
|
|
|
|
if (session.Status != PracticeSessionStatus.Active)
|
|
throw new LearningValidationException("practice_session_not_active",
|
|
"Only an active practice session can be submitted.");
|
|
if (session.ExpiresAt.HasValue && session.ExpiresAt <= DateTimeOffset.UtcNow)
|
|
{
|
|
session.Status = PracticeSessionStatus.Expired;
|
|
session.Version++;
|
|
await unitOfWork.SaveChangesAsync(cancellationToken);
|
|
await transaction.CommitAsync(cancellationToken);
|
|
throw new LearningValidationException("practice_session_expired", "The practice session has expired.");
|
|
}
|
|
|
|
session.Status = PracticeSessionStatus.Scoring;
|
|
session.Version++;
|
|
var report = await BuildPracticeSessionReportAsync(actor, session, cancellationToken);
|
|
session.Status = report.IsFinal ? PracticeSessionStatus.Submitted : PracticeSessionStatus.PendingReview;
|
|
session.FinishedAt = report.SubmittedAt;
|
|
session.Version++;
|
|
var response = ToItem(report);
|
|
learningPersistence.LearningOperationIdempotencies.Add(new LearningOperationIdempotency
|
|
{
|
|
TenantId = actor.TenantId,
|
|
UserId = actor.UserId,
|
|
PracticeSessionId = session.Id,
|
|
OperationType = "submit",
|
|
IdempotencyKey = command.IdempotencyKey.Trim(),
|
|
RequestHash = requestHash,
|
|
ResponseSnapshot = JsonSerializer.SerializeToElement(response),
|
|
CompletedAt = report.SubmittedAt
|
|
});
|
|
try
|
|
{
|
|
await unitOfWork.SaveChangesAsync(cancellationToken);
|
|
await transaction.CommitAsync(cancellationToken);
|
|
}
|
|
catch (DbUpdateConcurrencyException)
|
|
{
|
|
SubmissionConflicts.Add(1);
|
|
throw new LearningValidationException("practice_session_version_conflict",
|
|
"The practice session changed during submission.");
|
|
}
|
|
catch (DbUpdateException exception) when (exception.InnerException is NpgsqlException)
|
|
{
|
|
SubmissionConflicts.Add(1);
|
|
throw new LearningValidationException("practice_submission_conflict",
|
|
"The practice session was already submitted by another request.");
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
}
|