feat(learning): make practice scoring authoritative
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
@@ -16,17 +20,27 @@ public sealed class LearningActivityService(
|
||||
TikuDbContext dbContext,
|
||||
IQuestionReferenceService questionReferenceService,
|
||||
IPublicQuestionAccessPolicy publicQuestionAccessPolicy,
|
||||
ITenantExecutionScope tenantExecutionScope) : ILearningActivityService
|
||||
ITenantExecutionScope tenantExecutionScope,
|
||||
ILogger<LearningActivityService> logger) : ILearningActivityService
|
||||
{
|
||||
private const int DefaultLimit = 100;
|
||||
private const int MaxLimit = 500;
|
||||
private static readonly Meter LearningMeter = new("Tiku.Learning");
|
||||
private static readonly Counter<long> IdempotencyReplays = LearningMeter.CreateCounter<long>("tiku.learning.idempotency.replays");
|
||||
private static readonly Counter<long> AnswerConflicts = LearningMeter.CreateCounter<long>("tiku.learning.answer.conflicts");
|
||||
private static readonly Counter<long> SubmissionConflicts = LearningMeter.CreateCounter<long>("tiku.learning.submission.conflicts");
|
||||
private static readonly Counter<long> ScoringFailures = LearningMeter.CreateCounter<long>("tiku.learning.scoring.failures");
|
||||
|
||||
public async Task<LearningStatsItem> GetStatsAsync(
|
||||
LearningActor actor,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var answers = dbContext.AnswerRecords.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId);
|
||||
.Where(item => item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.IsCurrent &&
|
||||
item.GradingStatus != AnswerGradingStatus.PendingReview &&
|
||||
item.GradingStatus != AnswerGradingStatus.LegacyUnverified);
|
||||
return new LearningStatsItem(
|
||||
await answers.CountAsync(cancellationToken),
|
||||
await answers.CountAsync(item => item.IsCorrect == true, cancellationToken),
|
||||
@@ -46,7 +60,9 @@ public sealed class LearningActivityService(
|
||||
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
|
||||
cancellationToken),
|
||||
await dbContext.PracticeSessionReports.AsNoTracking().CountAsync(
|
||||
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
|
||||
item => item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.Status == PracticeReportStatus.Final,
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
@@ -60,6 +76,9 @@ public sealed class LearningActivityService(
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.IsCurrent &&
|
||||
item.GradingStatus != AnswerGradingStatus.PendingReview &&
|
||||
item.GradingStatus != AnswerGradingStatus.LegacyUnverified &&
|
||||
item.AnsweredAt >= since)
|
||||
.Select(item => new { item.AnsweredAt, item.IsCorrect })
|
||||
.ToArrayAsync(cancellationToken);
|
||||
@@ -81,7 +100,10 @@ public sealed class LearningActivityService(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var rows = await dbContext.AnswerRecords.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId)
|
||||
.Where(item => item.TenantId == actor.TenantId &&
|
||||
item.IsCurrent &&
|
||||
item.GradingStatus != AnswerGradingStatus.PendingReview &&
|
||||
item.GradingStatus != AnswerGradingStatus.LegacyUnverified)
|
||||
.GroupBy(item => item.UserId)
|
||||
.Select(group => new
|
||||
{
|
||||
@@ -120,22 +142,18 @@ public sealed class LearningActivityService(
|
||||
SubmitAnswerCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(command.IdempotencyKey))
|
||||
{
|
||||
throw new LearningValidationException("idempotency_key_required", "An idempotency key is required.");
|
||||
}
|
||||
if (command.SelectedOptionIndices?.Any(index => index < 0) == true)
|
||||
{
|
||||
throw new LearningValidationException("selected_option_index_invalid", "Selected option indices must be zero-based non-negative values.");
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var sessionQuestion = await dbContext.PracticeSessionQuestions
|
||||
.AsNoTracking()
|
||||
.Where(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.Id == command.SessionQuestionId)
|
||||
.Join(
|
||||
dbContext.PracticeSessions.AsNoTracking().Where(session =>
|
||||
session.TenantId == actor.TenantId &&
|
||||
session.UserId == actor.UserId &&
|
||||
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);
|
||||
var sessionQuestion = await dbContext.PracticeSessionQuestions.SingleOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId && item.Id == command.SessionQuestionId, cancellationToken);
|
||||
|
||||
if (sessionQuestion is null)
|
||||
{
|
||||
@@ -144,49 +162,143 @@ public sealed class LearningActivityService(
|
||||
"An active practice session question was not found.");
|
||||
}
|
||||
|
||||
var session = await dbContext.PracticeSessions.SingleOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.Id == sessionQuestion.PracticeSessionId, cancellationToken);
|
||||
if (session is null)
|
||||
{
|
||||
throw new LearningResourceNotFoundException("practice_session_not_found", "Practice session was not found.");
|
||||
}
|
||||
|
||||
var requestHash = HashAnswer(command);
|
||||
var existingOperation = await dbContext.LearningOperationIdempotencies.AsNoTracking().SingleOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.PracticeSessionId == session.Id &&
|
||||
item.OperationType == "answer" &&
|
||||
item.IdempotencyKey == command.IdempotencyKey, cancellationToken);
|
||||
if (existingOperation is not null)
|
||||
{
|
||||
if (!string.Equals(existingOperation.RequestHash, requestHash, StringComparison.Ordinal))
|
||||
{
|
||||
AnswerConflicts.Add(1);
|
||||
throw new LearningValidationException("idempotency_conflict", "The idempotency key was used with a different request.");
|
||||
}
|
||||
|
||||
IdempotencyReplays.Add(1);
|
||||
return existingOperation.ResponseSnapshot.Deserialize<AnswerRecordItem>()
|
||||
?? throw new InvalidOperationException("The stored answer response is invalid.");
|
||||
}
|
||||
|
||||
if (session.ExpiresAt.HasValue && session.ExpiresAt <= now)
|
||||
{
|
||||
session.Status = PracticeSessionStatus.Expired;
|
||||
session.Version++;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
throw new LearningValidationException("practice_session_expired", "The practice session has expired.");
|
||||
}
|
||||
EnsureAnswerSessionState(session, command);
|
||||
var current = await dbContext.AnswerRecords.SingleOrDefaultAsync(answer =>
|
||||
answer.TenantId == actor.TenantId &&
|
||||
answer.UserId == actor.UserId &&
|
||||
answer.PracticeSessionId == session.Id &&
|
||||
answer.SessionQuestionId == sessionQuestion.Id &&
|
||||
answer.IsCurrent, cancellationToken);
|
||||
if (current is not null)
|
||||
{
|
||||
current.IsCurrent = false;
|
||||
}
|
||||
|
||||
var selectedIndices = command.SelectedOptionIndices?.Distinct().Order().ToArray() ?? [];
|
||||
var score = sessionQuestion.Score ?? 1;
|
||||
QuestionGradingResult grading;
|
||||
try
|
||||
{
|
||||
grading = QuestionGrader.Grade(new QuestionGradingInput(
|
||||
sessionQuestion.QuestionType,
|
||||
sessionQuestion.CorrectOptionIndexSnapshot,
|
||||
sessionQuestion.CorrectOptionIndicesSnapshot,
|
||||
sessionQuestion.AnswerTextSnapshot,
|
||||
sessionQuestion.GradingRulesSnapshot,
|
||||
selectedIndices,
|
||||
command.AnswerText,
|
||||
score));
|
||||
}
|
||||
catch (InvalidOperationException exception)
|
||||
{
|
||||
ScoringFailures.Add(1);
|
||||
logger.LogWarning(exception,
|
||||
"Question scoring failed for tenant {TenantId}, session {PracticeSessionId}, question {SessionQuestionId}",
|
||||
actor.TenantId, session.Id, sessionQuestion.Id);
|
||||
throw new LearningValidationException("question_grading_rule_invalid", exception.Message);
|
||||
}
|
||||
|
||||
var record = new AnswerRecord
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
UserId = actor.UserId,
|
||||
PracticeSessionId = sessionQuestion.PracticeSessionId,
|
||||
SessionQuestionId = sessionQuestion.Id,
|
||||
SelectedOptions = JsonSerializer.SerializeToElement(command.SelectedOptions ?? []),
|
||||
SelectedOptions = JsonSerializer.SerializeToElement(selectedIndices),
|
||||
AnswerText = command.AnswerText,
|
||||
IsCorrect = command.SelfJudgedCorrect,
|
||||
IsCorrect = grading.IsCorrect,
|
||||
GradingStatus = grading.Status,
|
||||
AwardedScore = grading.AwardedScore,
|
||||
Revision = (current?.Revision ?? 0) + 1,
|
||||
ClientSequence = command.ClientSequence,
|
||||
IdempotencyKey = command.IdempotencyKey.Trim(),
|
||||
RequestHash = requestHash,
|
||||
IsCurrent = true,
|
||||
AnsweredAt = now,
|
||||
CreatedAt = now
|
||||
};
|
||||
dbContext.AnswerRecords.Add(record);
|
||||
|
||||
if (command.SelfJudgedCorrect == false)
|
||||
session.Version++;
|
||||
session.LastClientSequence = command.ClientSequence;
|
||||
var response = ToItem(record, session.Version);
|
||||
dbContext.LearningOperationIdempotencies.Add(new LearningOperationIdempotency
|
||||
{
|
||||
var wrongQuestion = await dbContext.WrongQuestions.FindAsync(
|
||||
[actor.TenantId, actor.UserId, sessionQuestion.QuestionReferenceId],
|
||||
cancellationToken);
|
||||
TenantId = actor.TenantId,
|
||||
UserId = actor.UserId,
|
||||
PracticeSessionId = session.Id,
|
||||
OperationType = "answer",
|
||||
IdempotencyKey = command.IdempotencyKey.Trim(),
|
||||
RequestHash = requestHash,
|
||||
ResponseSnapshot = JsonSerializer.SerializeToElement(response),
|
||||
CompletedAt = now
|
||||
});
|
||||
|
||||
if (wrongQuestion is null)
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
throw new LearningValidationException("practice_session_version_conflict", "The practice session changed. Reload it before answering.");
|
||||
}
|
||||
catch (DbUpdateException exception) when (
|
||||
exception.InnerException is Npgsql.PostgresException postgresException &&
|
||||
postgresException.SqlState == Npgsql.PostgresErrorCodes.UniqueViolation)
|
||||
{
|
||||
dbContext.ChangeTracker.Clear();
|
||||
var replay = await dbContext.LearningOperationIdempotencies.AsNoTracking().SingleOrDefaultAsync(item =>
|
||||
item.TenantId == actor.TenantId &&
|
||||
item.UserId == actor.UserId &&
|
||||
item.PracticeSessionId == session.Id &&
|
||||
item.OperationType == "answer" &&
|
||||
item.IdempotencyKey == command.IdempotencyKey, cancellationToken);
|
||||
if (replay is not null && string.Equals(replay.RequestHash, requestHash, StringComparison.Ordinal))
|
||||
{
|
||||
dbContext.WrongQuestions.Add(new WrongQuestion
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
UserId = actor.UserId,
|
||||
QuestionReferenceId = sessionQuestion.QuestionReferenceId,
|
||||
QuestionOwnerTenantId = sessionQuestion.QuestionOwnerTenantId,
|
||||
QuestionId = sessionQuestion.QuestionId,
|
||||
WrongCount = 1,
|
||||
LastWrongAt = now
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
wrongQuestion.WrongCount++;
|
||||
wrongQuestion.LastWrongAt = now;
|
||||
wrongQuestion.ResolvedAt = null;
|
||||
IdempotencyReplays.Add(1);
|
||||
return replay.ResponseSnapshot.Deserialize<AnswerRecordItem>()
|
||||
?? throw new InvalidOperationException("The stored answer response is invalid.");
|
||||
}
|
||||
AnswerConflicts.Add(1);
|
||||
throw new LearningValidationException("practice_answer_conflict", "The answer conflicted with another client operation.");
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToItem(record);
|
||||
return response;
|
||||
}
|
||||
|
||||
public async Task<LearningList<FavoriteQuestionItem>> GetFavoriteQuestionsAsync(
|
||||
@@ -664,11 +776,23 @@ public sealed class LearningActivityService(
|
||||
};
|
||||
dbContext.PracticeSessions.Add(session);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
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;
|
||||
@@ -682,7 +806,19 @@ public sealed class LearningActivityService(
|
||||
QuestionId = selection.QuestionId,
|
||||
QuestionVersionId = selection.QuestionVersionId,
|
||||
Position = index,
|
||||
Score = scorePerQuestion
|
||||
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
|
||||
}));
|
||||
dbContext.PracticeAccessEvents.Add(new PracticeAccessEvent
|
||||
{
|
||||
@@ -717,39 +853,104 @@ public sealed class LearningActivityService(
|
||||
.Where(answer =>
|
||||
answer.TenantId == actor.TenantId &&
|
||||
answer.UserId == actor.UserId &&
|
||||
answer.PracticeSessionId == session.Id)
|
||||
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.AnsweredAt).First()));
|
||||
group => ToItem(
|
||||
group.OrderByDescending(answer => answer.Revision).First(),
|
||||
session.Version));
|
||||
|
||||
return new PracticeSessionDetailItem(ToItem(session), orderedQuestions, answersByQuestion);
|
||||
}
|
||||
|
||||
public async Task<PracticeSessionReportItem> SubmitPracticeSessionAsync(
|
||||
LearningActor actor,
|
||||
PracticeSessionFilter filter,
|
||||
SubmitPracticeSessionCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var session = await GetPracticeSessionAsync(actor, filter.PracticeSessionId, cancellationToken);
|
||||
var existing = await dbContext.PracticeSessionReports
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(
|
||||
report =>
|
||||
report.TenantId == actor.TenantId &&
|
||||
report.PracticeSessionId == session.Id,
|
||||
cancellationToken);
|
||||
if (existing is not null)
|
||||
if (string.IsNullOrWhiteSpace(command.IdempotencyKey))
|
||||
{
|
||||
return ToItem(existing);
|
||||
throw new LearningValidationException("idempotency_key_required", "An idempotency key is required.");
|
||||
}
|
||||
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
var session = await GetPracticeSessionAsync(actor, command.PracticeSessionId, cancellationToken);
|
||||
var requestHash = HashSubmission(command);
|
||||
var existingOperation = await dbContext.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 dbContext.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.FinishedAt ??= report.SubmittedAt;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToItem(report);
|
||||
session.Status = report.IsFinal ? PracticeSessionStatus.Submitted : PracticeSessionStatus.PendingReview;
|
||||
session.FinishedAt = report.SubmittedAt;
|
||||
session.Version++;
|
||||
var response = ToItem(report);
|
||||
dbContext.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 dbContext.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 Npgsql.NpgsqlException)
|
||||
{
|
||||
SubmissionConflicts.Add(1);
|
||||
throw new LearningValidationException("practice_submission_conflict", "The practice session was already submitted by another request.");
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public async Task<PracticeSessionReportItem> GetPracticeSessionReportAsync(
|
||||
@@ -851,7 +1052,7 @@ public sealed class LearningActivityService(
|
||||
row.session.StartedAt,
|
||||
row.session.FinishedAt,
|
||||
row.session.ExpiresAt,
|
||||
PracticeSessionStatus(row.session, now),
|
||||
ResolvePracticeSessionHistoryStatus(row.session, now),
|
||||
row.report?.Id,
|
||||
row.report?.Score,
|
||||
row.report?.TotalScore,
|
||||
@@ -1055,7 +1256,17 @@ public sealed class LearningActivityService(
|
||||
reference.Id,
|
||||
reference.QuestionOwnerTenantId,
|
||||
reference.QuestionId,
|
||||
version.Id))
|
||||
version.Id,
|
||||
question.Type,
|
||||
question.TypeLabel,
|
||||
question.Difficulty,
|
||||
question.Tags,
|
||||
version.Content,
|
||||
version.Options,
|
||||
version.CorrectOptionIndex,
|
||||
version.CorrectOptionIndices,
|
||||
version.AnswerText,
|
||||
version.Explanation))
|
||||
.ToArrayAsync(token);
|
||||
},
|
||||
cancellationToken);
|
||||
@@ -1085,21 +1296,6 @@ public sealed class LearningActivityService(
|
||||
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
|
||||
@@ -1111,15 +1307,14 @@ public sealed class LearningActivityService(
|
||||
? QuestionSource.Tenant
|
||||
: QuestionSource.Platform,
|
||||
sessionQuestion.QuestionId),
|
||||
question.Id,
|
||||
question.Type,
|
||||
question.TypeLabel,
|
||||
question.Difficulty,
|
||||
question.Tags,
|
||||
version.Id,
|
||||
version.Content,
|
||||
version.Options,
|
||||
version.Explanation))
|
||||
sessionQuestion.QuestionId,
|
||||
sessionQuestion.QuestionType,
|
||||
sessionQuestion.TypeLabelSnapshot,
|
||||
sessionQuestion.DifficultySnapshot,
|
||||
sessionQuestion.TagsSnapshot,
|
||||
sessionQuestion.QuestionVersionId,
|
||||
sessionQuestion.ContentSnapshot,
|
||||
sessionQuestion.OptionsSnapshot))
|
||||
.ToArrayAsync(token);
|
||||
},
|
||||
cancellationToken);
|
||||
@@ -1172,32 +1367,35 @@ public sealed class LearningActivityService(
|
||||
.Where(answer =>
|
||||
answer.TenantId == actor.TenantId &&
|
||||
answer.UserId == actor.UserId &&
|
||||
answer.PracticeSessionId == session.Id)
|
||||
answer.PracticeSessionId == session.Id &&
|
||||
answer.IsCurrent)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var latestAnswers = answers
|
||||
.GroupBy(answer => answer.SessionQuestionId)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => group.OrderByDescending(answer => answer.AnsweredAt).First());
|
||||
var latestAnswers = answers.ToDictionary(answer => answer.SessionQuestionId);
|
||||
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);
|
||||
answer.GradingStatus == AnswerGradingStatus.Correct);
|
||||
var wrongCount = sessionQuestions.Count(question =>
|
||||
latestAnswers.TryGetValue(question.Id, out var answer) &&
|
||||
answer.IsCorrect != true);
|
||||
answer.GradingStatus == AnswerGradingStatus.Incorrect);
|
||||
var pendingReviewCount = sessionQuestions.Count(question =>
|
||||
latestAnswers.TryGetValue(question.Id, out var answer) &&
|
||||
answer.GradingStatus == AnswerGradingStatus.PendingReview);
|
||||
var unansweredCount = Math.Max(0, totalQuestions - answeredCount);
|
||||
var totalScore = session.TotalScore ?? totalQuestions;
|
||||
var scorePerQuestion = totalQuestions == 0 ? 0 : totalScore / totalQuestions;
|
||||
var score = Math.Round(correctCount * scorePerQuestion, 2);
|
||||
var accuracy = totalQuestions == 0 ? 0 : Math.Round((decimal)correctCount / totalQuestions, 4);
|
||||
var totalScore = sessionQuestions.Sum(question => question.Score ?? 1);
|
||||
var score = Math.Round(answers.Sum(answer => answer.AwardedScore ?? 0), 2);
|
||||
var objectivelyGradedCount = correctCount + wrongCount;
|
||||
var accuracy = objectivelyGradedCount == 0
|
||||
? 0
|
||||
: Math.Round((decimal)correctCount / objectivelyGradedCount, 4);
|
||||
var isFinal = pendingReviewCount == 0;
|
||||
var submittedAt = DateTimeOffset.UtcNow;
|
||||
var durationSeconds = Math.Max(0, (int)(submittedAt - session.StartedAt).TotalSeconds);
|
||||
var wrongQuestionIds = sessionQuestions
|
||||
.Where(question =>
|
||||
latestAnswers.TryGetValue(question.Id, out var answer) &&
|
||||
answer.IsCorrect != true)
|
||||
answer.GradingStatus == AnswerGradingStatus.Incorrect)
|
||||
.Select(question => question.QuestionReferenceId)
|
||||
.ToArray();
|
||||
var questionResults = sessionQuestions
|
||||
@@ -1211,10 +1409,15 @@ public sealed class LearningActivityService(
|
||||
questionId = question.QuestionId,
|
||||
source = question.QuestionOwnerTenantId == actor.TenantId ? "tenant" : "platform",
|
||||
answered = answer is not null,
|
||||
isCorrect = answer?.IsCorrect,
|
||||
score = answer?.IsCorrect == true ? scorePerQuestion : 0,
|
||||
totalScore = scorePerQuestion,
|
||||
answeredAt = answer?.AnsweredAt
|
||||
gradingStatus = answer?.GradingStatus.ToString(),
|
||||
isCorrect = isFinal ? answer?.IsCorrect : null,
|
||||
score = answer?.AwardedScore,
|
||||
totalScore = question.Score ?? 1,
|
||||
answeredAt = answer?.AnsweredAt,
|
||||
correctOptionIndex = isFinal ? question.CorrectOptionIndexSnapshot : null,
|
||||
correctOptionIndices = isFinal ? question.CorrectOptionIndicesSnapshot : JsonDefaults.Array(),
|
||||
answerText = isFinal ? question.AnswerTextSnapshot : null,
|
||||
explanation = isFinal ? question.ExplanationSnapshot : null
|
||||
};
|
||||
})
|
||||
.ToArray();
|
||||
@@ -1232,6 +1435,7 @@ public sealed class LearningActivityService(
|
||||
score,
|
||||
totalScore,
|
||||
accuracy,
|
||||
pendingReviewCount,
|
||||
sortOrder = 0
|
||||
}
|
||||
};
|
||||
@@ -1255,13 +1459,17 @@ public sealed class LearningActivityService(
|
||||
DurationSeconds = durationSeconds,
|
||||
StartedAt = session.StartedAt,
|
||||
SubmittedAt = submittedAt,
|
||||
Status = isFinal ? PracticeReportStatus.Final : PracticeReportStatus.PendingReview,
|
||||
Version = 1,
|
||||
IsFinal = isFinal,
|
||||
PendingReviewCount = pendingReviewCount,
|
||||
ScoringVersion = 1,
|
||||
SectionStats = JsonSerializer.SerializeToElement(sectionStats),
|
||||
QuestionResults = JsonSerializer.SerializeToElement(questionResults),
|
||||
WrongQuestionIds = JsonSerializer.SerializeToElement(wrongQuestionIds),
|
||||
Metadata = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
scoringVersion = 1,
|
||||
scorePerQuestion
|
||||
scoringVersion = 1
|
||||
})
|
||||
};
|
||||
dbContext.PracticeSessionReports.Add(report);
|
||||
@@ -1283,6 +1491,33 @@ public sealed class LearningActivityService(
|
||||
SortOrder = 0
|
||||
});
|
||||
|
||||
foreach (var question in sessionQuestions.Where(question =>
|
||||
latestAnswers.TryGetValue(question.Id, out var answer) &&
|
||||
answer.GradingStatus == AnswerGradingStatus.Incorrect))
|
||||
{
|
||||
var wrongQuestion = await dbContext.WrongQuestions.FindAsync(
|
||||
[actor.TenantId, actor.UserId, question.QuestionReferenceId], cancellationToken);
|
||||
if (wrongQuestion is null)
|
||||
{
|
||||
dbContext.WrongQuestions.Add(new WrongQuestion
|
||||
{
|
||||
TenantId = actor.TenantId,
|
||||
UserId = actor.UserId,
|
||||
QuestionReferenceId = question.QuestionReferenceId,
|
||||
QuestionOwnerTenantId = question.QuestionOwnerTenantId,
|
||||
QuestionId = question.QuestionId,
|
||||
WrongCount = 1,
|
||||
LastWrongAt = submittedAt
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
wrongQuestion.WrongCount++;
|
||||
wrongQuestion.LastWrongAt = submittedAt;
|
||||
wrongQuestion.ResolvedAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
@@ -1322,7 +1557,7 @@ public sealed class LearningActivityService(
|
||||
}
|
||||
}
|
||||
|
||||
private static AnswerRecordItem ToItem(AnswerRecord record)
|
||||
private static AnswerRecordItem ToItem(AnswerRecord record, long sessionVersion)
|
||||
{
|
||||
return new AnswerRecordItem(
|
||||
record.Id,
|
||||
@@ -1330,7 +1565,10 @@ public sealed class LearningActivityService(
|
||||
record.PracticeSessionId,
|
||||
record.SelectedOptions,
|
||||
record.AnswerText,
|
||||
record.IsCorrect,
|
||||
record.GradingStatus == AnswerGradingStatus.PendingReview ? "pending_review" : "accepted",
|
||||
record.Revision,
|
||||
record.ClientSequence,
|
||||
sessionVersion,
|
||||
record.AnsweredAt);
|
||||
}
|
||||
|
||||
@@ -1372,7 +1610,9 @@ public sealed class LearningActivityService(
|
||||
item.FinishedAt,
|
||||
item.ExpiresAt,
|
||||
item.Metadata,
|
||||
PracticeSessionStatus(item, DateTimeOffset.UtcNow));
|
||||
item.Status,
|
||||
item.Version,
|
||||
item.LastClientSequence);
|
||||
}
|
||||
|
||||
private static PracticeSessionReportItem ToItem(PracticeSessionReport item)
|
||||
@@ -1394,6 +1634,11 @@ public sealed class LearningActivityService(
|
||||
item.DurationSeconds,
|
||||
item.StartedAt,
|
||||
item.SubmittedAt,
|
||||
item.Status,
|
||||
item.Version,
|
||||
item.IsFinal,
|
||||
item.PendingReviewCount,
|
||||
item.ScoringVersion,
|
||||
item.SectionStats,
|
||||
item.QuestionResults,
|
||||
item.WrongQuestionIds,
|
||||
@@ -1416,14 +1661,60 @@ public sealed class LearningActivityService(
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string PracticeSessionStatus(PracticeSession session, DateTimeOffset now)
|
||||
private static void EnsureAnswerSessionState(
|
||||
PracticeSession session,
|
||||
SubmitAnswerCommand command)
|
||||
{
|
||||
if (session.FinishedAt.HasValue)
|
||||
if (session.Status != PracticeSessionStatus.Active)
|
||||
{
|
||||
throw new LearningValidationException("practice_session_not_active", "Only an active practice session accepts answers.");
|
||||
}
|
||||
if (session.Version != command.ExpectedSessionVersion)
|
||||
{
|
||||
throw new LearningValidationException("practice_session_version_conflict", "The practice session changed. Reload it before answering.");
|
||||
}
|
||||
if (command.ClientSequence <= session.LastClientSequence)
|
||||
{
|
||||
throw new LearningValidationException("practice_client_sequence_conflict", "Client sequence must increase within a practice session.");
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonElement BuildGradingRules(QuestionSelection selection) =>
|
||||
JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
version = 1,
|
||||
normalization = selection.QuestionType.Equals("fill_blank", StringComparison.OrdinalIgnoreCase)
|
||||
? "nfkc_trim_casefold_whitespace"
|
||||
: "exact"
|
||||
});
|
||||
|
||||
private static string HashAnswer(SubmitAnswerCommand command) => Hash(JsonSerializer.Serialize(new
|
||||
{
|
||||
command.SessionQuestionId,
|
||||
command.ExpectedSessionVersion,
|
||||
command.ClientSequence,
|
||||
selectedOptionIndices = command.SelectedOptionIndices?.Distinct().Order().ToArray() ?? [],
|
||||
answerText = command.AnswerText?.Trim()
|
||||
}));
|
||||
|
||||
private static string HashSubmission(SubmitPracticeSessionCommand command) => Hash(JsonSerializer.Serialize(new
|
||||
{
|
||||
command.PracticeSessionId,
|
||||
command.ExpectedSessionVersion
|
||||
}));
|
||||
|
||||
private static string Hash(string value) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
|
||||
|
||||
private static string ResolvePracticeSessionHistoryStatus(PracticeSession session, DateTimeOffset now)
|
||||
{
|
||||
if (session.Status is PracticeSessionStatus.Submitted or PracticeSessionStatus.PendingReview)
|
||||
{
|
||||
return "finished";
|
||||
}
|
||||
|
||||
if (session.ExpiresAt.HasValue && session.ExpiresAt.Value <= now)
|
||||
if (session.Status == PracticeSessionStatus.Expired ||
|
||||
session.ExpiresAt.HasValue && session.ExpiresAt.Value <= now)
|
||||
{
|
||||
return "expired";
|
||||
}
|
||||
@@ -1479,7 +1770,17 @@ public sealed class LearningActivityService(
|
||||
Guid QuestionReferenceId,
|
||||
Guid QuestionOwnerTenantId,
|
||||
Guid QuestionId,
|
||||
Guid QuestionVersionId);
|
||||
Guid QuestionVersionId,
|
||||
string QuestionType,
|
||||
string? TypeLabel,
|
||||
int? Difficulty,
|
||||
JsonElement Tags,
|
||||
string? Content,
|
||||
JsonElement Options,
|
||||
int? CorrectOptionIndex,
|
||||
JsonElement CorrectOptionIndices,
|
||||
string? AnswerText,
|
||||
string? Explanation);
|
||||
}
|
||||
|
||||
public class LearningException(string code, string message) : Exception(message)
|
||||
|
||||
@@ -18,12 +18,15 @@ internal sealed class PracticeSessionConfiguration : IEntityTypeConfiguration<Pr
|
||||
builder.Property(entity => entity.TargetType).HasMaxLength(50);
|
||||
builder.Property(entity => entity.StartedAt).HasDefaultValueSql("now()");
|
||||
builder.Property(entity => entity.TotalScore).HasPrecision(8, 2);
|
||||
builder.Property(entity => entity.Status).HasSnakeCaseEnum().HasDefaultValue(PracticeSessionStatus.Active);
|
||||
builder.Property(entity => entity.Version).IsConcurrencyToken().HasDefaultValue(1L);
|
||||
builder.Property(entity => entity.AccessMode)
|
||||
.HasSnakeCaseEnum()
|
||||
.HasDefaultValue(PracticeAccessMode.Free);
|
||||
builder.Property(entity => entity.AccessSnapshot).IsJson("{}");
|
||||
builder.Property(entity => entity.Metadata).IsJson("{}");
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.StartedAt });
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.Status, entity.StartedAt });
|
||||
|
||||
builder.ToTable(table =>
|
||||
{
|
||||
@@ -62,6 +65,13 @@ internal sealed class PracticeSessionQuestionConfiguration : IEntityTypeConfigur
|
||||
builder.HasAlternateKey(entity => new { entity.TenantId, entity.PracticeSessionId, entity.Id });
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.PracticeSessionId, entity.Position }).IsUnique();
|
||||
builder.Property(entity => entity.Score).HasPrecision(8, 2);
|
||||
builder.Property(entity => entity.QuestionType).HasMaxLength(50).HasDefaultValue("choice");
|
||||
builder.Property(entity => entity.TypeLabelSnapshot).HasMaxLength(100);
|
||||
builder.Property(entity => entity.TagsSnapshot).IsJson("[]");
|
||||
builder.Property(entity => entity.OptionsSnapshot).IsJson("[]");
|
||||
builder.Property(entity => entity.CorrectOptionIndicesSnapshot).IsJson("[]");
|
||||
builder.Property(entity => entity.GradingRulesSnapshot).IsJson("{}");
|
||||
builder.Property(entity => entity.SnapshotVersion).HasDefaultValue(1);
|
||||
|
||||
builder.HasOne<PracticeSession>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.PracticeSessionId })
|
||||
@@ -102,6 +112,12 @@ internal sealed class AnswerRecordConfiguration : IEntityTypeConfiguration<Answe
|
||||
builder.Property(entity => entity.LegacyQuestionId).HasMaxLength(64);
|
||||
builder.Property(entity => entity.LegacyCategoryId).HasMaxLength(64);
|
||||
builder.Property(entity => entity.SelectedOptions).IsJson("[]");
|
||||
builder.Property(entity => entity.GradingStatus).HasSnakeCaseEnum().HasDefaultValue(AnswerGradingStatus.PendingReview);
|
||||
builder.Property(entity => entity.AwardedScore).HasPrecision(8, 2);
|
||||
builder.Property(entity => entity.IdempotencyKey).HasMaxLength(200);
|
||||
builder.Property(entity => entity.RequestHash).HasMaxLength(64);
|
||||
builder.Property(entity => entity.Revision).HasDefaultValue(1);
|
||||
builder.Property(entity => entity.IsCurrent).HasDefaultValue(true);
|
||||
builder.Property(entity => entity.AnsweredAt).HasDefaultValueSql("now()");
|
||||
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
|
||||
@@ -111,6 +127,28 @@ internal sealed class AnswerRecordConfiguration : IEntityTypeConfiguration<Answe
|
||||
entity.UserId,
|
||||
entity.PracticeSessionId
|
||||
});
|
||||
builder.HasIndex(entity => new
|
||||
{
|
||||
entity.TenantId,
|
||||
entity.UserId,
|
||||
entity.PracticeSessionId,
|
||||
entity.ClientSequence
|
||||
}).IsUnique();
|
||||
builder.HasIndex(entity => new
|
||||
{
|
||||
entity.TenantId,
|
||||
entity.UserId,
|
||||
entity.PracticeSessionId,
|
||||
entity.SessionQuestionId,
|
||||
entity.Revision
|
||||
}).IsUnique().HasDatabaseName("ux_answer_records_session_question_revision");
|
||||
builder.HasIndex(entity => new
|
||||
{
|
||||
entity.TenantId,
|
||||
entity.UserId,
|
||||
entity.PracticeSessionId,
|
||||
entity.SessionQuestionId
|
||||
}).IsUnique().HasFilter("is_current").HasDatabaseName("ux_answer_records_current_session_question");
|
||||
|
||||
builder.HasOne<User>().WithMany()
|
||||
.HasForeignKey(entity => entity.UserId)
|
||||
@@ -146,6 +184,39 @@ internal sealed class AnswerRecordConfiguration : IEntityTypeConfiguration<Answe
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class LearningOperationIdempotencyConfiguration : IEntityTypeConfiguration<LearningOperationIdempotency>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<LearningOperationIdempotency> builder)
|
||||
{
|
||||
builder.ConfigureEntity("learning_operation_idempotencies");
|
||||
builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id });
|
||||
builder.Property(entity => entity.OperationType).HasMaxLength(50);
|
||||
builder.Property(entity => entity.IdempotencyKey).HasMaxLength(200);
|
||||
builder.Property(entity => entity.RequestHash).HasMaxLength(64);
|
||||
builder.Property(entity => entity.ResponseSnapshot).IsJson("{}");
|
||||
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
|
||||
builder.HasIndex(entity => new
|
||||
{
|
||||
entity.TenantId,
|
||||
entity.UserId,
|
||||
entity.PracticeSessionId,
|
||||
entity.OperationType,
|
||||
entity.IdempotencyKey
|
||||
}).IsUnique();
|
||||
|
||||
builder.HasOne<Tenant>().WithMany()
|
||||
.HasForeignKey(entity => entity.TenantId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<User>().WithMany()
|
||||
.HasForeignKey(entity => entity.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<PracticeSession>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.UserId, Id = entity.PracticeSessionId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.UserId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FavoriteQuestionConfiguration : IEntityTypeConfiguration<FavoriteQuestion>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<FavoriteQuestion> builder)
|
||||
|
||||
@@ -189,6 +189,10 @@ internal sealed class PracticeSessionReportConfiguration : IEntityTypeConfigurat
|
||||
builder.Property(entity => entity.TotalScore).HasPrecision(10, 2);
|
||||
builder.Property(entity => entity.Accuracy).HasPrecision(6, 4);
|
||||
builder.Property(entity => entity.SubmittedAt).HasDefaultValueSql("now()");
|
||||
builder.Property(entity => entity.Status).HasSnakeCaseEnum().HasDefaultValue(PracticeReportStatus.Final);
|
||||
builder.Property(entity => entity.Version).HasDefaultValue(1);
|
||||
builder.Property(entity => entity.IsFinal).HasDefaultValue(true);
|
||||
builder.Property(entity => entity.ScoringVersion).HasDefaultValue(1);
|
||||
builder.Property(entity => entity.SectionStats).IsJson("[]");
|
||||
builder.Property(entity => entity.QuestionResults).IsJson("[]");
|
||||
builder.Property(entity => entity.WrongQuestionIds).IsJson("[]");
|
||||
|
||||
20739
Tiku.Infrastructure/Persistence/Migrations/20260803013235_TrustedLearningCore.Designer.cs
generated
Normal file
20739
Tiku.Infrastructure/Persistence/Migrations/20260803013235_TrustedLearningCore.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,489 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class TrustedLearningCore : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<long>(
|
||||
name: "last_client_sequence",
|
||||
table: "practice_sessions",
|
||||
type: "bigint",
|
||||
nullable: false,
|
||||
defaultValue: 0L);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "status",
|
||||
table: "practice_sessions",
|
||||
type: "character varying(32)",
|
||||
maxLength: 32,
|
||||
nullable: false,
|
||||
defaultValue: "active");
|
||||
|
||||
migrationBuilder.AddColumn<long>(
|
||||
name: "version",
|
||||
table: "practice_sessions",
|
||||
type: "bigint",
|
||||
nullable: false,
|
||||
defaultValue: 1L);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "is_final",
|
||||
table: "practice_session_reports",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "pending_review_count",
|
||||
table: "practice_session_reports",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "scoring_version",
|
||||
table: "practice_session_reports",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 1);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "status",
|
||||
table: "practice_session_reports",
|
||||
type: "character varying(32)",
|
||||
maxLength: 32,
|
||||
nullable: false,
|
||||
defaultValue: "final");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "version",
|
||||
table: "practice_session_reports",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 1);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "answer_text_snapshot",
|
||||
table: "practice_session_questions",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "content_snapshot",
|
||||
table: "practice_session_questions",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "correct_option_index_snapshot",
|
||||
table: "practice_session_questions",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<JsonElement>(
|
||||
name: "correct_option_indices_snapshot",
|
||||
table: "practice_session_questions",
|
||||
type: "jsonb",
|
||||
nullable: false,
|
||||
defaultValueSql: "'[]'::jsonb");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "difficulty_snapshot",
|
||||
table: "practice_session_questions",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "explanation_snapshot",
|
||||
table: "practice_session_questions",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<JsonElement>(
|
||||
name: "grading_rules_snapshot",
|
||||
table: "practice_session_questions",
|
||||
type: "jsonb",
|
||||
nullable: false,
|
||||
defaultValueSql: "'{}'::jsonb");
|
||||
|
||||
migrationBuilder.AddColumn<JsonElement>(
|
||||
name: "options_snapshot",
|
||||
table: "practice_session_questions",
|
||||
type: "jsonb",
|
||||
nullable: false,
|
||||
defaultValueSql: "'[]'::jsonb");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "question_type",
|
||||
table: "practice_session_questions",
|
||||
type: "character varying(50)",
|
||||
maxLength: 50,
|
||||
nullable: false,
|
||||
defaultValue: "choice");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "snapshot_version",
|
||||
table: "practice_session_questions",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 1);
|
||||
|
||||
migrationBuilder.AddColumn<JsonElement>(
|
||||
name: "tags_snapshot",
|
||||
table: "practice_session_questions",
|
||||
type: "jsonb",
|
||||
nullable: false,
|
||||
defaultValueSql: "'[]'::jsonb");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "type_label_snapshot",
|
||||
table: "practice_session_questions",
|
||||
type: "character varying(100)",
|
||||
maxLength: 100,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "awarded_score",
|
||||
table: "answer_records",
|
||||
type: "numeric(8,2)",
|
||||
precision: 8,
|
||||
scale: 2,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<long>(
|
||||
name: "client_sequence",
|
||||
table: "answer_records",
|
||||
type: "bigint",
|
||||
nullable: false,
|
||||
defaultValue: 0L);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "grading_status",
|
||||
table: "answer_records",
|
||||
type: "character varying(32)",
|
||||
maxLength: 32,
|
||||
nullable: false,
|
||||
defaultValue: "pending_review");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "idempotency_key",
|
||||
table: "answer_records",
|
||||
type: "character varying(200)",
|
||||
maxLength: 200,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "is_current",
|
||||
table: "answer_records",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "request_hash",
|
||||
table: "answer_records",
|
||||
type: "character varying(64)",
|
||||
maxLength: 64,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "revision",
|
||||
table: "answer_records",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 1);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "learning_operation_idempotencies",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
user_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
practice_session_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
operation_type = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
idempotency_key = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
request_hash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
response_snapshot = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
completed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_learning_operation_idempotencies", x => x.id);
|
||||
table.UniqueConstraint("ak_learning_operation_idempotencies_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||
table.ForeignKey(
|
||||
name: "fk_learning_operation_idempotencies_practice_sessions_tenant_i~",
|
||||
columns: x => new { x.tenant_id, x.user_id, x.practice_session_id },
|
||||
principalTable: "practice_sessions",
|
||||
principalColumns: new[] { "tenant_id", "user_id", "id" },
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_learning_operation_idempotencies_tenants_tenant_id",
|
||||
column: x => x.tenant_id,
|
||||
principalTable: "tenants",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_learning_operation_idempotencies_users_user_id",
|
||||
column: x => x.user_id,
|
||||
principalTable: "users",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.Sql("""
|
||||
UPDATE practice_sessions
|
||||
SET status = CASE
|
||||
WHEN finished_at IS NOT NULL THEN 'submitted'
|
||||
WHEN expires_at IS NOT NULL AND expires_at <= now() THEN 'expired'
|
||||
ELSE 'active'
|
||||
END,
|
||||
version = 1,
|
||||
last_client_sequence = 0;
|
||||
|
||||
UPDATE practice_session_reports
|
||||
SET status = 'legacy_unverified',
|
||||
version = 1,
|
||||
is_final = false,
|
||||
pending_review_count = 0,
|
||||
scoring_version = 0;
|
||||
|
||||
UPDATE practice_session_questions AS session_question
|
||||
SET question_type = question.type,
|
||||
type_label_snapshot = question.type_label,
|
||||
difficulty_snapshot = question.difficulty,
|
||||
tags_snapshot = question.tags,
|
||||
content_snapshot = version.content,
|
||||
options_snapshot = version.options,
|
||||
correct_option_index_snapshot = version.correct_option_index,
|
||||
correct_option_indices_snapshot = version.correct_option_indices,
|
||||
answer_text_snapshot = version.answer_text,
|
||||
explanation_snapshot = version.explanation,
|
||||
grading_rules_snapshot = jsonb_build_object(
|
||||
'version', 1,
|
||||
'legacyBackfill', true),
|
||||
snapshot_version = 1
|
||||
FROM questions AS question
|
||||
JOIN question_versions AS version
|
||||
ON version.tenant_id = question.tenant_id
|
||||
AND version.question_id = question.id
|
||||
WHERE question.tenant_id = session_question.question_owner_tenant_id
|
||||
AND question.id = session_question.question_id
|
||||
AND version.id = session_question.question_version_id;
|
||||
|
||||
WITH ranked AS (
|
||||
SELECT id,
|
||||
row_number() OVER (
|
||||
PARTITION BY tenant_id, user_id, practice_session_id
|
||||
ORDER BY answered_at, created_at, id) AS client_sequence_value,
|
||||
row_number() OVER (
|
||||
PARTITION BY tenant_id, user_id, practice_session_id, session_question_id
|
||||
ORDER BY answered_at, created_at, id) AS revision_value,
|
||||
row_number() OVER (
|
||||
PARTITION BY tenant_id, user_id, practice_session_id, session_question_id
|
||||
ORDER BY answered_at DESC, created_at DESC, id DESC) AS current_rank
|
||||
FROM answer_records
|
||||
)
|
||||
UPDATE answer_records AS answer
|
||||
SET client_sequence = ranked.client_sequence_value,
|
||||
revision = ranked.revision_value,
|
||||
is_current = ranked.current_rank = 1,
|
||||
grading_status = 'legacy_unverified',
|
||||
is_correct = NULL,
|
||||
awarded_score = NULL,
|
||||
idempotency_key = 'legacy-' || answer.id::text,
|
||||
request_hash = repeat('0', 64)
|
||||
FROM ranked
|
||||
WHERE ranked.id = answer.id;
|
||||
|
||||
UPDATE practice_sessions AS session
|
||||
SET last_client_sequence = sequence.maximum
|
||||
FROM (
|
||||
SELECT tenant_id, user_id, practice_session_id, max(client_sequence) AS maximum
|
||||
FROM answer_records
|
||||
GROUP BY tenant_id, user_id, practice_session_id
|
||||
) AS sequence
|
||||
WHERE sequence.tenant_id = session.tenant_id
|
||||
AND sequence.user_id = session.user_id
|
||||
AND sequence.practice_session_id = session.id;
|
||||
""");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_practice_sessions_tenant_id_user_id_status_started_at",
|
||||
table: "practice_sessions",
|
||||
columns: new[] { "tenant_id", "user_id", "status", "started_at" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_answer_records_tenant_id_user_id_practice_session_id_client~",
|
||||
table: "answer_records",
|
||||
columns: new[] { "tenant_id", "user_id", "practice_session_id", "client_sequence" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ux_answer_records_current_session_question",
|
||||
table: "answer_records",
|
||||
columns: new[] { "tenant_id", "user_id", "practice_session_id", "session_question_id" },
|
||||
unique: true,
|
||||
filter: "is_current");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ux_answer_records_session_question_revision",
|
||||
table: "answer_records",
|
||||
columns: new[] { "tenant_id", "user_id", "practice_session_id", "session_question_id", "revision" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_learning_operation_idempotencies_tenant_id_user_id_practice~",
|
||||
table: "learning_operation_idempotencies",
|
||||
columns: new[] { "tenant_id", "user_id", "practice_session_id", "operation_type", "idempotency_key" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_learning_operation_idempotencies_user_id",
|
||||
table: "learning_operation_idempotencies",
|
||||
column: "user_id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "learning_operation_idempotencies");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ix_practice_sessions_tenant_id_user_id_status_started_at",
|
||||
table: "practice_sessions");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ix_answer_records_tenant_id_user_id_practice_session_id_client~",
|
||||
table: "answer_records");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ux_answer_records_current_session_question",
|
||||
table: "answer_records");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ux_answer_records_session_question_revision",
|
||||
table: "answer_records");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "last_client_sequence",
|
||||
table: "practice_sessions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "status",
|
||||
table: "practice_sessions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "version",
|
||||
table: "practice_sessions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "is_final",
|
||||
table: "practice_session_reports");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "pending_review_count",
|
||||
table: "practice_session_reports");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "scoring_version",
|
||||
table: "practice_session_reports");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "status",
|
||||
table: "practice_session_reports");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "version",
|
||||
table: "practice_session_reports");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "answer_text_snapshot",
|
||||
table: "practice_session_questions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "content_snapshot",
|
||||
table: "practice_session_questions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "correct_option_index_snapshot",
|
||||
table: "practice_session_questions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "correct_option_indices_snapshot",
|
||||
table: "practice_session_questions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "difficulty_snapshot",
|
||||
table: "practice_session_questions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "explanation_snapshot",
|
||||
table: "practice_session_questions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "grading_rules_snapshot",
|
||||
table: "practice_session_questions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "options_snapshot",
|
||||
table: "practice_session_questions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "question_type",
|
||||
table: "practice_session_questions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "snapshot_version",
|
||||
table: "practice_session_questions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "tags_snapshot",
|
||||
table: "practice_session_questions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "type_label_snapshot",
|
||||
table: "practice_session_questions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "awarded_score",
|
||||
table: "answer_records");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "client_sequence",
|
||||
table: "answer_records");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "grading_status",
|
||||
table: "answer_records");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "idempotency_key",
|
||||
table: "answer_records");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "is_current",
|
||||
table: "answer_records");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "request_hash",
|
||||
table: "answer_records");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "revision",
|
||||
table: "answer_records");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8650,16 +8650,45 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasColumnName("answered_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<decimal?>("AwardedScore")
|
||||
.HasPrecision(8, 2)
|
||||
.HasColumnType("numeric(8,2)")
|
||||
.HasColumnName("awarded_score");
|
||||
|
||||
b.Property<long>("ClientSequence")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("client_sequence");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("GradingStatus")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasDefaultValue("pending_review")
|
||||
.HasColumnName("grading_status");
|
||||
|
||||
b.Property<string>("IdempotencyKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("idempotency_key");
|
||||
|
||||
b.Property<bool?>("IsCorrect")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_correct");
|
||||
|
||||
b.Property<bool>("IsCurrent")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true)
|
||||
.HasColumnName("is_current");
|
||||
|
||||
b.Property<string>("LegacyCategoryId")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
@@ -8679,6 +8708,18 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("practice_session_id");
|
||||
|
||||
b.Property<string>("RequestHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasColumnName("request_hash");
|
||||
|
||||
b.Property<int>("Revision")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("revision");
|
||||
|
||||
b.Property<JsonElement>("SelectedOptions")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
@@ -8716,6 +8757,19 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
b.HasIndex("TenantId", "UserId", "PracticeSessionId")
|
||||
.HasDatabaseName("ix_answer_records_tenant_id_user_id_practice_session_id");
|
||||
|
||||
b.HasIndex("TenantId", "UserId", "PracticeSessionId", "ClientSequence")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_answer_records_tenant_id_user_id_practice_session_id_client~");
|
||||
|
||||
b.HasIndex("TenantId", "UserId", "PracticeSessionId", "SessionQuestionId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ux_answer_records_current_session_question")
|
||||
.HasFilter("is_current");
|
||||
|
||||
b.HasIndex("TenantId", "UserId", "PracticeSessionId", "SessionQuestionId", "Revision")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ux_answer_records_session_question_revision");
|
||||
|
||||
b.ToTable("answer_records", (string)null);
|
||||
});
|
||||
|
||||
@@ -8945,6 +8999,76 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("favorite_questions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Learning.LearningOperationIdempotency", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset?>("CompletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("completed_at");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("IdempotencyKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("idempotency_key");
|
||||
|
||||
b.Property<string>("OperationType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("operation_type");
|
||||
|
||||
b.Property<Guid>("PracticeSessionId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("practice_session_id");
|
||||
|
||||
b.Property<string>("RequestHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasColumnName("request_hash");
|
||||
|
||||
b.Property<JsonElement>("ResponseSnapshot")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("response_snapshot")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("tenant_id");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_learning_operation_idempotencies");
|
||||
|
||||
b.HasAlternateKey("TenantId", "Id")
|
||||
.HasName("ak_learning_operation_idempotencies_tenant_id_id");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.HasDatabaseName("ix_learning_operation_idempotencies_user_id");
|
||||
|
||||
b.HasIndex("TenantId", "UserId", "PracticeSessionId", "OperationType", "IdempotencyKey")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_learning_operation_idempotencies_tenant_id_user_id_practice~");
|
||||
|
||||
b.ToTable("learning_operation_idempotencies", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Learning.PracticeAccessEvent", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -9186,6 +9310,10 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("finished_at");
|
||||
|
||||
b.Property<long>("LastClientSequence")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("last_client_sequence");
|
||||
|
||||
b.Property<JsonElement>("Metadata")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
@@ -9208,6 +9336,14 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasColumnName("started_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasDefaultValue("active")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<Guid?>("TargetId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("target_id");
|
||||
@@ -9230,6 +9366,13 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.Property<long>("Version")
|
||||
.IsConcurrencyToken()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasDefaultValue(1L)
|
||||
.HasColumnName("version");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_practice_sessions");
|
||||
|
||||
@@ -9257,6 +9400,9 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
b.HasIndex("TenantId", "UserId", "StartedAt")
|
||||
.HasDatabaseName("ix_practice_sessions_tenant_id_user_id_started_at");
|
||||
|
||||
b.HasIndex("TenantId", "UserId", "Status", "StartedAt")
|
||||
.HasDatabaseName("ix_practice_sessions_tenant_id_user_id_status_started_at");
|
||||
|
||||
b.ToTable("practice_sessions", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_practice_sessions_consumed_free_quota", "consumed_free_quota >= 0");
|
||||
@@ -9271,6 +9417,44 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<string>("AnswerTextSnapshot")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("answer_text_snapshot");
|
||||
|
||||
b.Property<string>("ContentSnapshot")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("content_snapshot");
|
||||
|
||||
b.Property<int?>("CorrectOptionIndexSnapshot")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("correct_option_index_snapshot");
|
||||
|
||||
b.Property<JsonElement>("CorrectOptionIndicesSnapshot")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("correct_option_indices_snapshot")
|
||||
.HasDefaultValueSql("'[]'::jsonb");
|
||||
|
||||
b.Property<int?>("DifficultySnapshot")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("difficulty_snapshot");
|
||||
|
||||
b.Property<string>("ExplanationSnapshot")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("explanation_snapshot");
|
||||
|
||||
b.Property<JsonElement>("GradingRulesSnapshot")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("grading_rules_snapshot")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<JsonElement>("OptionsSnapshot")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("options_snapshot")
|
||||
.HasDefaultValueSql("'[]'::jsonb");
|
||||
|
||||
b.Property<int>("Position")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("position");
|
||||
@@ -9291,6 +9475,14 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("question_reference_id");
|
||||
|
||||
b.Property<string>("QuestionType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasDefaultValue("choice")
|
||||
.HasColumnName("question_type");
|
||||
|
||||
b.Property<Guid>("QuestionVersionId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("question_version_id");
|
||||
@@ -9300,10 +9492,27 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasColumnType("numeric(8,2)")
|
||||
.HasColumnName("score");
|
||||
|
||||
b.Property<int>("SnapshotVersion")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("snapshot_version");
|
||||
|
||||
b.Property<JsonElement>("TagsSnapshot")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("tags_snapshot")
|
||||
.HasDefaultValueSql("'[]'::jsonb");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("tenant_id");
|
||||
|
||||
b.Property<string>("TypeLabelSnapshot")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("type_label_snapshot");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_practice_session_questions");
|
||||
|
||||
@@ -9365,6 +9574,12 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("duration_seconds");
|
||||
|
||||
b.Property<bool>("IsFinal")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true)
|
||||
.HasColumnName("is_final");
|
||||
|
||||
b.Property<JsonElement>("Metadata")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
@@ -9377,6 +9592,10 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("mode");
|
||||
|
||||
b.Property<int>("PendingReviewCount")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("pending_review_count");
|
||||
|
||||
b.Property<Guid>("PracticeSessionId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("practice_session_id");
|
||||
@@ -9392,6 +9611,12 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasColumnType("numeric(10,2)")
|
||||
.HasColumnName("score");
|
||||
|
||||
b.Property<int>("ScoringVersion")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("scoring_version");
|
||||
|
||||
b.Property<JsonElement>("SectionStats")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
@@ -9402,6 +9627,14 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasDefaultValue("final")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<DateTimeOffset>("SubmittedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
@@ -9435,6 +9668,12 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.Property<int>("Version")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("version");
|
||||
|
||||
b.Property<int>("WrongCount")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("wrong_count");
|
||||
@@ -18845,6 +19084,31 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasConstraintName("fk_favorite_questions_tenant_question_references_tenant_id_que~");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Learning.LearningOperationIdempotency", b =>
|
||||
{
|
||||
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_learning_operation_idempotencies_tenants_tenant_id");
|
||||
|
||||
b.HasOne("Tiku.Domain.Identity.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_learning_operation_idempotencies_users_user_id");
|
||||
|
||||
b.HasOne("Tiku.Domain.Learning.PracticeSession", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "UserId", "PracticeSessionId")
|
||||
.HasPrincipalKey("TenantId", "UserId", "Id")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_learning_operation_idempotencies_practice_sessions_tenant_i~");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Learning.PracticeAccessEvent", b =>
|
||||
{
|
||||
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
|
||||
|
||||
@@ -110,6 +110,7 @@ public sealed class TikuDbContext(
|
||||
public DbSet<PracticeSession> PracticeSessions => Set<PracticeSession>();
|
||||
public DbSet<PracticeSessionQuestion> PracticeSessionQuestions => Set<PracticeSessionQuestion>();
|
||||
public DbSet<AnswerRecord> AnswerRecords => Set<AnswerRecord>();
|
||||
public DbSet<LearningOperationIdempotency> LearningOperationIdempotencies => Set<LearningOperationIdempotency>();
|
||||
public DbSet<FavoriteQuestion> FavoriteQuestions => Set<FavoriteQuestion>();
|
||||
public DbSet<WrongQuestion> WrongQuestions => Set<WrongQuestion>();
|
||||
public DbSet<RecentPractice> RecentPractices => Set<RecentPractice>();
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.PlatformAdmin;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Common;
|
||||
@@ -620,6 +621,15 @@ internal sealed class PlatformQuestionBankService(
|
||||
var type = string.IsNullOrWhiteSpace(command.Type) ? "choice" : command.Type.Trim();
|
||||
if (!SupportedQuestionTypes.Contains(type)) throw Error("题型不受支持。", "question_type_invalid");
|
||||
if (command.Difficulty is < 1 or > 5) throw Error("难度必须在 1 到 5 之间。", "question_difficulty_invalid");
|
||||
if (ParseQuestionStatus(command.Status) == QuestionStatus.Published &&
|
||||
!QuestionGrader.HasValidAuthoritativeAnswer(
|
||||
type,
|
||||
command.CorrectOptionIndex,
|
||||
command.CorrectOptionIndices,
|
||||
command.AnswerText))
|
||||
{
|
||||
throw Error("发布题目必须提供有效的标准答案。", "question_grading_rule_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyQuestion(Question question, UpsertPlatformQuestionCommand command, Guid entryId, Guid nodeId)
|
||||
|
||||
Reference in New Issue
Block a user