Files
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

242 lines
11 KiB
C#

using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Npgsql;
using Tiku.Application.Learning;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
using Tiku.Domain.Learning;
namespace Tiku.Infrastructure.Learning;
internal sealed class PracticeSessionService(LearningServiceDependencies dependencies)
: LearningActivityServiceBase(dependencies), IPracticeSessionService
{
public async Task<PracticeSessionItem> CreatePracticeSessionAsync(
LearningActor actor,
PracticeSessionCommand command,
CancellationToken cancellationToken = default)
{
var assembly = await BuildPracticeAssemblyAsync(actor.TenantId, command, cancellationToken);
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 questionBankPersistence.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
{
TenantId = actor.TenantId,
UserId = actor.UserId,
Mode = assembly.Mode,
TargetType = assembly.TargetType,
TargetId = assembly.TargetId,
BlueprintId = assembly.BlueprintId,
CollectionId = assembly.CollectionId,
EntryId = assembly.EntryId,
ContentNodeId = assembly.ContentNodeId,
QuestionCount = questionReferenceIds.Count,
DurationMinutes = assembly.DurationMinutes,
TotalScore = assembly.TotalScore,
ExpiresAt = assembly.DurationMinutes.HasValue
? now.AddMinutes(assembly.DurationMinutes.Value)
: null,
AccessMode = PracticeAccessMode.Free,
ConsumedFreeQuota = questionReferenceIds.Count,
AccessSnapshot = JsonSerializer.SerializeToElement(new
{
strategy = "v1_free",
requestedCount = assembly.QuestionLimit,
grantedCount = questionReferenceIds.Count
}),
Metadata = command.Metadata.ValueKind is JsonValueKind.Undefined
? JsonDefaults.Object()
: command.Metadata
};
learningPersistence.PracticeSessions.Add(session);
var selections = await LoadQuestionSelectionsAsync(
actor.TenantId,
questionReferenceIds,
cancellationToken);
foreach (var selection in selections)
if (!QuestionGrader.HasValidAuthoritativeAnswer(
selection.QuestionType,
selection.CorrectOptionIndex,
selection.CorrectOptionIndices,
selection.AnswerText))
throw new LearningValidationException(
"practice_question_grading_rule_invalid",
$"Question '{selection.QuestionId}' has no valid authoritative grading rule.");
var scorePerQuestion = session.TotalScore.HasValue && selections.Count > 0
? session.TotalScore.Value / selections.Count
: (decimal?)null;
learningPersistence.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,
QuestionType = selection.QuestionType,
TypeLabelSnapshot = selection.TypeLabel,
DifficultySnapshot = selection.Difficulty,
TagsSnapshot = selection.Tags,
ContentSnapshot = selection.Content,
OptionsSnapshot = selection.Options,
CorrectOptionIndexSnapshot = selection.CorrectOptionIndex,
CorrectOptionIndicesSnapshot = selection.CorrectOptionIndices,
AnswerTextSnapshot = selection.AnswerText,
ExplanationSnapshot = selection.Explanation,
GradingRulesSnapshot = BuildGradingRules(selection),
SnapshotVersion = 1
}));
learningPersistence.PracticeAccessEvents.Add(new PracticeAccessEvent
{
TenantId = actor.TenantId,
UserId = actor.UserId,
PracticeSessionId = session.Id,
EventType = PracticeAccessEventType.SessionCreated,
AccessMode = PracticeAccessEventMode.Free,
RequestedCount = assembly.QuestionLimit,
GrantedCount = selections.Count,
ConsumedFreeQuota = selections.Count,
Metadata = session.AccessSnapshot
});
await unitOfWork.SaveChangesAsync(cancellationToken);
return ToItem(session);
}
public async Task<PracticeSessionDetailItem> GetPracticeSessionDetailAsync(
LearningActor actor,
PracticeSessionFilter filter,
CancellationToken cancellationToken = default)
{
var session = await GetPracticeSessionAsync(actor, filter.PracticeSessionId, 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 &&
answer.IsCurrent)
.ToArrayAsync(cancellationToken);
var answersByQuestion = answers
.GroupBy(answer => answer.SessionQuestionId)
.ToDictionary(
group => group.Key,
group => ToItem(
group.OrderByDescending(answer => answer.Revision).First(),
session.Version));
return new PracticeSessionDetailItem(ToItem(session), orderedQuestions, answersByQuestion);
}
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 session = await GetPracticeSessionAsync(actor, command.PracticeSessionId, 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.");
}
if (session.Version != command.ExpectedSessionVersion)
throw new LearningValidationException("practice_session_version_conflict",
"The practice session changed. Reload it before submitting.");
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;
}
}