feat(learning): make practice scoring authoritative

This commit is contained in:
2026-08-03 09:44:57 +08:00
parent a9a8f0ab9f
commit 6bdfee6807
19 changed files with 22684 additions and 168 deletions

View File

@@ -30,7 +30,7 @@ internal static class ObservabilityExtensions
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddMeter(DatabasePerformanceTelemetry.MeterName, WorkerTelemetry.MeterName,
AuthorizationCacheTelemetry.MeterName, "Tiku.Security.Redis", "Npgsql")
AuthorizationCacheTelemetry.MeterName, "Tiku.Security.Redis", "Tiku.Learning", "Npgsql")
.ApplyIf(hasOtlpEndpoint, builder => builder.AddOtlpExporter(options => options.Endpoint = endpointUri!)));
return services;

View File

@@ -172,10 +172,14 @@ public sealed class SubmitPracticeSessionDto
[Required]
public Guid PracticeSessionId { get; set; }
public PracticeSessionFilter ToFilter()
{
return new PracticeSessionFilter(PracticeSessionId);
}
[Range(1, long.MaxValue)]
public long ExpectedSessionVersion { get; set; }
[Required, StringLength(200, MinimumLength = 1)]
public string IdempotencyKey { get; set; } = string.Empty;
public SubmitPracticeSessionCommand ToCommand() =>
new(PracticeSessionId, ExpectedSessionVersion, IdempotencyKey);
}
/// <summary>
@@ -190,9 +194,27 @@ public sealed class SubmitAnswerDto
public Guid SessionQuestionId { get; set; }
/// <summary>
/// 已选选项
/// 客户端读取会话时获得的版本
/// </summary>
public IReadOnlyCollection<string>? SelectedOptions { get; set; }
[Range(1, long.MaxValue)]
public long ExpectedSessionVersion { get; set; }
/// <summary>
/// 客户端在本会话内单调递增的序列。
/// </summary>
[Range(1, long.MaxValue)]
public long ClientSequence { get; set; }
/// <summary>
/// 网络重试幂等键。
/// </summary>
[Required, StringLength(200, MinimumLength = 1)]
public string IdempotencyKey { get; set; } = string.Empty;
/// <summary>
/// 已选选项的零基索引。
/// </summary>
public IReadOnlyCollection<int>? SelectedOptionIndices { get; set; }
/// <summary>
/// 文字答案。
@@ -200,18 +222,15 @@ public sealed class SubmitAnswerDto
[StringLength(10000)]
public string? AnswerText { get; set; }
/// <summary>
/// 主观题自评是否正确。
/// </summary>
public bool? SelfJudgedCorrect { get; set; }
public SubmitAnswerCommand ToCommand()
{
return new SubmitAnswerCommand(
SessionQuestionId,
SelectedOptions,
AnswerText,
SelfJudgedCorrect);
ExpectedSessionVersion,
ClientSequence,
IdempotencyKey,
SelectedOptionIndices,
AnswerText);
}
}

View File

@@ -83,7 +83,7 @@ public sealed class LearningController(
{
return Ok(await learningActivityService.SubmitPracticeSessionAsync(
ResolveActor(),
request.ToFilter(),
request.ToCommand(),
cancellationToken));
}

View File

@@ -505,7 +505,11 @@ public sealed class ExceptionHandlingMiddleware(
{
return code switch
{
"no_practice_questions" or "practice_session_empty" => StatusCodes.Status409Conflict,
"no_practice_questions" or "practice_session_empty" or
"idempotency_conflict" or "practice_session_version_conflict" or
"practice_client_sequence_conflict" or "practice_answer_conflict" or
"practice_submission_conflict" or "practice_session_not_active" or
"practice_session_expired" => StatusCodes.Status409Conflict,
_ => StatusCodes.Status400BadRequest
};
}

View File

@@ -93,7 +93,7 @@ public interface ILearningActivityService
Task<PracticeSessionReportItem> SubmitPracticeSessionAsync(
LearningActor actor,
PracticeSessionFilter filter,
SubmitPracticeSessionCommand command,
CancellationToken cancellationToken = default);
Task<PracticeSessionReportItem> GetPracticeSessionReportAsync(

View File

@@ -11,9 +11,16 @@ public sealed record LearningList<TItem>(IReadOnlyCollection<TItem> Items);
public sealed record SubmitAnswerCommand(
Guid SessionQuestionId,
IReadOnlyCollection<string>? SelectedOptions,
string? AnswerText,
bool? SelfJudgedCorrect);
long ExpectedSessionVersion,
long ClientSequence,
string IdempotencyKey,
IReadOnlyCollection<int>? SelectedOptionIndices,
string? AnswerText);
public sealed record SubmitPracticeSessionCommand(
Guid PracticeSessionId,
long ExpectedSessionVersion,
string IdempotencyKey);
public sealed record QuestionActionCommand(QuestionLocator Locator, bool? Favorite);
@@ -112,10 +119,13 @@ public sealed record AnswerRecordItem(
Guid Id,
Guid SessionQuestionId,
Guid PracticeSessionId,
JsonElement SelectedOptions,
JsonElement SelectedOptionIndices,
string? AnswerText,
bool? IsCorrect,
DateTimeOffset AnsweredAt);
string Status,
int Revision,
long ClientSequence,
long SessionVersion,
DateTimeOffset AcceptedAt);
public sealed record FavoriteQuestionItem(
Guid QuestionReferenceId,
@@ -169,7 +179,9 @@ public sealed record PracticeSessionItem(
DateTimeOffset? FinishedAt,
DateTimeOffset? ExpiresAt,
JsonElement Metadata,
string Status);
PracticeSessionStatus Status,
long Version,
long LastClientSequence);
public sealed record PracticeSessionDetailItem(
PracticeSessionItem Session,
@@ -187,8 +199,7 @@ public sealed record PracticeSessionQuestionItem(
JsonElement Tags,
Guid? VersionId,
string? Content,
JsonElement Options,
string? Explanation);
JsonElement Options);
public sealed record PracticeSessionReportItem(
Guid Id,
@@ -207,6 +218,11 @@ public sealed record PracticeSessionReportItem(
int DurationSeconds,
DateTimeOffset? StartedAt,
DateTimeOffset SubmittedAt,
PracticeReportStatus Status,
int Version,
bool IsFinal,
int PendingReviewCount,
int ScoringVersion,
JsonElement SectionStats,
JsonElement QuestionResults,
JsonElement WrongQuestionIds,

View File

@@ -0,0 +1,180 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
using Tiku.Domain.Learning;
namespace Tiku.Application.Learning;
public sealed record QuestionGradingInput(
string QuestionType,
int? CorrectOptionIndex,
JsonElement CorrectOptionIndices,
string? CorrectAnswerText,
JsonElement GradingRules,
IReadOnlyCollection<int>? SelectedOptionIndices,
string? AnswerText,
decimal Score);
public sealed record QuestionGradingResult(
AnswerGradingStatus Status,
bool? IsCorrect,
decimal? AwardedScore);
public static class QuestionGrader
{
private static readonly IReadOnlySet<string> SubjectiveTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"short_answer", "reading", "programming"
};
public static QuestionGradingResult Grade(QuestionGradingInput input)
{
var type = input.QuestionType.Trim().ToLowerInvariant();
if (SubjectiveTypes.Contains(type))
{
return new QuestionGradingResult(AnswerGradingStatus.PendingReview, null, null);
}
var correct = type switch
{
"choice" => GradeChoice(input),
"multiple_choice" => GradeMultipleChoice(input),
"true_false" => GradeTrueFalse(input),
"fill_blank" => GradeFillBlank(input),
_ => throw new InvalidOperationException($"Unsupported question type '{input.QuestionType}'.")
};
return new QuestionGradingResult(
correct ? AnswerGradingStatus.Correct : AnswerGradingStatus.Incorrect,
correct,
correct ? input.Score : 0);
}
public static bool HasValidAuthoritativeAnswer(
string questionType,
int? correctOptionIndex,
JsonElement correctOptionIndices,
string? answerText)
{
return questionType.Trim().ToLowerInvariant() switch
{
"choice" => correctOptionIndex is >= 0,
"multiple_choice" => ReadIndices(correctOptionIndices).Count > 0,
"true_false" => TryNormalizeBoolean(answerText, out _),
"fill_blank" => !string.IsNullOrWhiteSpace(answerText),
"short_answer" or "reading" or "programming" => true,
_ => false
};
}
private static bool GradeChoice(QuestionGradingInput input)
{
var selected = input.SelectedOptionIndices?.Distinct().ToArray() ?? [];
return input.CorrectOptionIndex.HasValue &&
selected.Length == 1 &&
selected[0] == input.CorrectOptionIndex.Value;
}
private static bool GradeMultipleChoice(QuestionGradingInput input)
{
var expected = ReadIndices(input.CorrectOptionIndices);
var selected = (input.SelectedOptionIndices ?? []).Distinct().Order().ToArray();
return expected.Count > 0 && expected.SequenceEqual(selected);
}
private static bool GradeTrueFalse(QuestionGradingInput input)
{
return TryNormalizeBoolean(input.CorrectAnswerText, out var expected) &&
TryNormalizeBoolean(input.AnswerText, out var actual) &&
expected == actual;
}
private static bool GradeFillBlank(QuestionGradingInput input)
{
var accepted = new HashSet<string>(StringComparer.Ordinal);
AddNormalized(accepted, input.CorrectAnswerText);
if (input.GradingRules.ValueKind == JsonValueKind.Object &&
input.GradingRules.TryGetProperty("acceptedAnswers", out var alternatives) &&
alternatives.ValueKind == JsonValueKind.Array)
{
foreach (var alternative in alternatives.EnumerateArray())
{
if (alternative.ValueKind == JsonValueKind.String)
{
AddNormalized(accepted, alternative.GetString());
}
}
}
return accepted.Count > 0 && accepted.Contains(NormalizeText(input.AnswerText));
}
private static IReadOnlyList<int> ReadIndices(JsonElement value)
{
if (value.ValueKind != JsonValueKind.Array)
{
return [];
}
return value.EnumerateArray()
.Where(item => item.TryGetInt32(out _))
.Select(item => item.GetInt32())
.Distinct()
.Order()
.ToArray();
}
private static bool TryNormalizeBoolean(string? value, out bool result)
{
switch (NormalizeText(value))
{
case "true" or "1" or "yes" or "正确" or "对":
result = true;
return true;
case "false" or "0" or "no" or "错误" or "错":
result = false;
return true;
default:
result = false;
return false;
}
}
private static void AddNormalized(ISet<string> values, string? value)
{
var normalized = NormalizeText(value);
if (normalized.Length > 0)
{
values.Add(normalized);
}
}
private static string NormalizeText(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var normalized = value.Normalize(NormalizationForm.FormKC).Trim().ToLower(CultureInfo.InvariantCulture);
var builder = new StringBuilder(normalized.Length);
var previousWhitespace = false;
foreach (var character in normalized)
{
if (char.IsWhiteSpace(character))
{
if (!previousWhitespace)
{
builder.Append(' ');
previousWhitespace = true;
}
}
else
{
builder.Append(character);
previousWhitespace = false;
}
}
return builder.ToString();
}
}

View File

@@ -19,6 +19,9 @@ public sealed class PracticeSession : TenantEntity
public int? DurationMinutes { get; set; }
public decimal? TotalScore { get; set; }
public DateTimeOffset? ExpiresAt { get; set; }
public PracticeSessionStatus Status { get; set; } = PracticeSessionStatus.Active;
public long Version { get; set; } = 1;
public long LastClientSequence { get; set; }
public PracticeAccessMode AccessMode { get; set; } = PracticeAccessMode.Free;
public Guid? AccessEntitlementId { get; set; }
public int ConsumedFreeQuota { get; set; }
@@ -35,6 +38,28 @@ public sealed class PracticeSessionQuestion : TenantEntity
public Guid QuestionVersionId { get; set; }
public int Position { get; set; }
public decimal? Score { get; set; }
public string QuestionType { get; set; } = "choice";
public string? TypeLabelSnapshot { get; set; }
public int? DifficultySnapshot { get; set; }
public JsonElement TagsSnapshot { get; set; } = JsonDefaults.Array();
public string? ContentSnapshot { get; set; }
public JsonElement OptionsSnapshot { get; set; } = JsonDefaults.Array();
public int? CorrectOptionIndexSnapshot { get; set; }
public JsonElement CorrectOptionIndicesSnapshot { get; set; } = JsonDefaults.Array();
public string? AnswerTextSnapshot { get; set; }
public string? ExplanationSnapshot { get; set; }
public JsonElement GradingRulesSnapshot { get; set; } = JsonDefaults.Object();
public int SnapshotVersion { get; set; } = 1;
}
public enum PracticeSessionStatus
{
Active,
Scoring,
PendingReview,
Submitted,
Expired,
Cancelled
}
public enum PracticeAccessMode
@@ -55,10 +80,38 @@ public sealed class AnswerRecord : TenantEntity
public JsonElement SelectedOptions { get; set; } = JsonDefaults.Array();
public string? AnswerText { get; set; }
public bool? IsCorrect { get; set; }
public AnswerGradingStatus GradingStatus { get; set; } = AnswerGradingStatus.PendingReview;
public decimal? AwardedScore { get; set; }
public int Revision { get; set; } = 1;
public long ClientSequence { get; set; }
public string IdempotencyKey { get; set; } = string.Empty;
public string RequestHash { get; set; } = string.Empty;
public bool IsCurrent { get; set; } = true;
public DateTimeOffset AnsweredAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public enum AnswerGradingStatus
{
PendingReview,
Correct,
Incorrect,
LegacyUnverified
}
public sealed class LearningOperationIdempotency : Entity, ITenantOwned
{
public Guid TenantId { get; set; }
public Guid UserId { get; set; }
public Guid PracticeSessionId { get; set; }
public string OperationType { get; set; } = string.Empty;
public string IdempotencyKey { get; set; } = string.Empty;
public string RequestHash { get; set; } = string.Empty;
public JsonElement ResponseSnapshot { get; set; } = JsonDefaults.Object();
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? CompletedAt { get; set; }
}
public sealed class FavoriteQuestion : ITenantOwned
{
public Guid TenantId { get; set; }

View File

@@ -109,12 +109,24 @@ public sealed class PracticeSessionReport : AuditableTenantEntity
public int DurationSeconds { get; set; }
public DateTimeOffset? StartedAt { get; set; }
public DateTimeOffset SubmittedAt { get; set; } = DateTimeOffset.UtcNow;
public PracticeReportStatus Status { get; set; } = PracticeReportStatus.Final;
public int Version { get; set; } = 1;
public bool IsFinal { get; set; } = true;
public int PendingReviewCount { get; set; }
public int ScoringVersion { get; set; } = 1;
public JsonElement SectionStats { get; set; } = JsonDefaults.Array();
public JsonElement QuestionResults { get; set; } = JsonDefaults.Array();
public JsonElement WrongQuestionIds { get; set; } = JsonDefaults.Array();
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
}
public enum PracticeReportStatus
{
PendingReview,
Final,
LegacyUnverified
}
public sealed class PracticeSessionReportSection : AuditableTenantEntity
{
public Guid ReportId { get; set; }

View File

@@ -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)

View File

@@ -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)

View File

@@ -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("[]");

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

View File

@@ -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)

View File

@@ -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>();

View File

@@ -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)

View File

@@ -1,6 +1,7 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Api.Contracts;
using Tiku.Domain.Common;
@@ -33,7 +34,7 @@ public sealed class LearningEndpointTests
await using var factory = new ApiTestFactory();
var seed = await SeedLearningUserAsync(factory);
var questionId = Guid.NewGuid();
var sessionQuestionId = await SeedAnswerableQuestionAsync(factory, seed, questionId);
var answerable = await SeedAnswerableQuestionAsync(factory, seed, questionId);
using var client = factory.CreateClient();
await LoginAsync(client, seed);
@@ -41,17 +42,28 @@ public sealed class LearningEndpointTests
"/api/learning/answers",
new SubmitAnswerDto
{
SessionQuestionId = sessionQuestionId,
SelectedOptions = ["A"],
SelfJudgedCorrect = false
SessionQuestionId = answerable.SessionQuestionId,
ExpectedSessionVersion = 1,
ClientSequence = 1,
IdempotencyKey = "wrong-answer-1",
SelectedOptionIndices = [1]
});
using var submitResponse = await client.PostAsJsonAsync(
"/api/learning/practice-sessions/submit",
new SubmitPracticeSessionDto
{
PracticeSessionId = answerable.SessionId,
ExpectedSessionVersion = 2,
IdempotencyKey = "wrong-submit-1"
});
using var wrongResponse = await client.GetAsync("/api/learning/wrong-questions");
var answer = await ReadJsonAsync(response);
var wrongItems = await ReadItemsAsync(wrongResponse);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(sessionQuestionId, answer.RootElement.GetProperty("sessionQuestionId").GetGuid());
Assert.False(answer.RootElement.GetProperty("isCorrect").GetBoolean());
Assert.Equal(answerable.SessionQuestionId, answer.RootElement.GetProperty("sessionQuestionId").GetGuid());
Assert.False(answer.RootElement.TryGetProperty("isCorrect", out _));
Assert.Equal(HttpStatusCode.OK, submitResponse.StatusCode);
var wrong = Assert.Single(wrongItems);
Assert.Equal(questionId, wrong.GetProperty("locator").GetProperty("questionId").GetGuid());
Assert.Equal(1, wrong.GetProperty("wrongCount").GetInt32());
@@ -81,7 +93,8 @@ public sealed class LearningEndpointTests
TenantId = seed.TenantId,
QuestionId = firstQuestionId,
VersionNo = 1,
Content = "first"
Content = "first",
CorrectOptionIndex = 0
});
await factory.SeedQuestionWithVersionAsync(
new Question
@@ -97,7 +110,8 @@ public sealed class LearningEndpointTests
TenantId = seed.TenantId,
QuestionId = secondQuestionId,
VersionNo = 1,
Content = "second"
Content = "second",
CorrectOptionIndex = 0
});
var firstReferenceId = Guid.NewGuid();
var secondReferenceId = Guid.NewGuid();
@@ -167,25 +181,34 @@ public sealed class LearningEndpointTests
var secondSessionQuestionId = detailQuestions.Single(item =>
item.GetProperty("questionId").GetGuid() == secondQuestionId).GetProperty("sessionQuestionId").GetGuid();
await client.PostAsJsonAsync(
var firstAnswerResponse = await client.PostAsJsonAsync(
"/api/learning/answers",
new SubmitAnswerDto
{
SessionQuestionId = firstSessionQuestionId,
SelectedOptions = ["A"],
SelfJudgedCorrect = true
ExpectedSessionVersion = 1,
ClientSequence = 1,
IdempotencyKey = "practice-first-answer",
SelectedOptionIndices = [0]
});
await client.PostAsJsonAsync(
var secondAnswerResponse = await client.PostAsJsonAsync(
"/api/learning/answers",
new SubmitAnswerDto
{
SessionQuestionId = secondSessionQuestionId,
SelectedOptions = ["B"],
SelfJudgedCorrect = false
ExpectedSessionVersion = 2,
ClientSequence = 2,
IdempotencyKey = "practice-second-answer",
SelectedOptionIndices = [1]
});
var submitResponse = await client.PostAsJsonAsync(
"/api/learning/practice-sessions/submit",
new SubmitPracticeSessionDto { PracticeSessionId = practiceSessionId });
new SubmitPracticeSessionDto
{
PracticeSessionId = practiceSessionId,
ExpectedSessionVersion = 3,
IdempotencyKey = "practice-submit"
});
var report = await ReadJsonAsync(submitResponse);
var reportResponse = await client.GetAsync($"/api/learning/practice-sessions/report?practiceSessionId={practiceSessionId}");
var reportsResponse = await client.GetAsync("/api/learning/practice-reports");
@@ -195,9 +218,11 @@ public sealed class LearningEndpointTests
Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode);
Assert.Equal(2, created.RootElement.GetProperty("questionCount").GetInt32());
Assert.Equal("active", created.RootElement.GetProperty("status").GetString());
Assert.Equal("Active", created.RootElement.GetProperty("status").GetString());
Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode);
Assert.Equal(2, detail.RootElement.GetProperty("questions").GetArrayLength());
Assert.True(firstAnswerResponse.IsSuccessStatusCode, await firstAnswerResponse.Content.ReadAsStringAsync());
Assert.True(secondAnswerResponse.IsSuccessStatusCode, await secondAnswerResponse.Content.ReadAsStringAsync());
Assert.Equal(HttpStatusCode.OK, submitResponse.StatusCode);
Assert.Equal(2, report.RootElement.GetProperty("totalQuestions").GetInt32());
Assert.Equal(1, report.RootElement.GetProperty("correctCount").GetInt32());
@@ -346,7 +371,7 @@ public sealed class LearningEndpointTests
var seed = await SeedLearningUserAsync(factory);
var questionId = Guid.NewGuid();
var wordId = Guid.NewGuid();
var sessionQuestionId = await SeedAnswerableQuestionAsync(factory, seed, questionId);
var answerable = await SeedAnswerableQuestionAsync(factory, seed, questionId);
await factory.SeedAsync(
new VocabularyWord
{
@@ -362,9 +387,19 @@ public sealed class LearningEndpointTests
"/api/learning/answers",
new SubmitAnswerDto
{
SessionQuestionId = sessionQuestionId,
SelectedOptions = ["A"],
SelfJudgedCorrect = false
SessionQuestionId = answerable.SessionQuestionId,
ExpectedSessionVersion = 1,
ClientSequence = 1,
IdempotencyKey = "stats-wrong-answer",
SelectedOptionIndices = [1]
});
await client.PostAsJsonAsync(
"/api/learning/practice-sessions/submit",
new SubmitPracticeSessionDto
{
PracticeSessionId = answerable.SessionId,
ExpectedSessionVersion = 2,
IdempotencyKey = "stats-wrong-submit"
});
await client.PostAsJsonAsync(
"/api/learning/vocabulary/progress",
@@ -405,6 +440,231 @@ public sealed class LearningEndpointTests
Assert.Equal(1, wordStats.RootElement.GetProperty("total").GetInt32());
}
[Fact]
public async Task Answers_are_idempotent_versioned_and_keep_revision_history()
{
await using var factory = new ApiTestFactory();
var seed = await SeedLearningUserAsync(factory);
var answerable = await SeedAnswerableQuestionAsync(factory, seed, Guid.NewGuid());
using var client = factory.CreateClient();
await LoginAsync(client, seed);
var firstRequest = new SubmitAnswerDto
{
SessionQuestionId = answerable.SessionQuestionId,
ExpectedSessionVersion = 1,
ClientSequence = 1,
IdempotencyKey = "revision-answer-1",
SelectedOptionIndices = [1]
};
var first = await client.PostAsJsonAsync("/api/learning/answers", firstRequest);
var replay = await client.PostAsJsonAsync("/api/learning/answers", firstRequest);
var conflict = await client.PostAsJsonAsync(
"/api/learning/answers",
new SubmitAnswerDto
{
SessionQuestionId = answerable.SessionQuestionId,
ExpectedSessionVersion = 1,
ClientSequence = 1,
IdempotencyKey = "revision-answer-1",
SelectedOptionIndices = [0]
});
var revision = await client.PostAsJsonAsync(
"/api/learning/answers",
new SubmitAnswerDto
{
SessionQuestionId = answerable.SessionQuestionId,
ExpectedSessionVersion = 2,
ClientSequence = 2,
IdempotencyKey = "revision-answer-2",
SelectedOptionIndices = [0]
});
var stale = await client.PostAsJsonAsync(
"/api/learning/answers",
new SubmitAnswerDto
{
SessionQuestionId = answerable.SessionQuestionId,
ExpectedSessionVersion = 1,
ClientSequence = 3,
IdempotencyKey = "revision-answer-stale",
SelectedOptionIndices = [0]
});
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
Assert.Equal(HttpStatusCode.OK, replay.StatusCode);
Assert.Equal(await first.Content.ReadAsStringAsync(), await replay.Content.ReadAsStringAsync());
Assert.Equal(HttpStatusCode.Conflict, conflict.StatusCode);
Assert.Equal("idempotency_conflict", await ReadProblemCodeAsync(conflict));
Assert.Equal(HttpStatusCode.OK, revision.StatusCode);
Assert.Equal(HttpStatusCode.Conflict, stale.StatusCode);
Assert.Equal("practice_session_version_conflict", await ReadProblemCodeAsync(stale));
using var scope = factory.CreateSystemScope("Verify immutable answer revisions");
var records = await scope.ServiceProvider.GetRequiredService<TikuDbContext>().AnswerRecords
.Where(item => item.PracticeSessionId == answerable.SessionId)
.OrderBy(item => item.Revision)
.ToArrayAsync();
Assert.Equal(2, records.Length);
Assert.False(records[0].IsCurrent);
Assert.True(records[1].IsCurrent);
Assert.Equal(AnswerGradingStatus.Correct, records[1].GradingStatus);
}
[Fact]
public async Task Session_detail_uses_immutable_safe_snapshot()
{
await using var factory = new ApiTestFactory();
var seed = await SeedLearningUserAsync(factory);
var answerable = await SeedAnswerableQuestionAsync(factory, seed, Guid.NewGuid());
using var client = factory.CreateClient();
await LoginAsync(client, seed);
var before = await client.GetAsync($"/api/learning/practice-sessions/detail?practiceSessionId={answerable.SessionId}");
using (var scope = factory.CreateSystemScope("Mutate source question version after session creation"))
{
var db = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var version = await db.QuestionVersions.SingleAsync(item => item.Id == answerable.VersionId);
version.Content = "mutated source content";
version.Explanation = "must remain hidden";
version.CorrectOptionIndex = 1;
await db.SaveChangesAsync();
}
var after = await client.GetAsync($"/api/learning/practice-sessions/detail?practiceSessionId={answerable.SessionId}");
var beforeQuestion = (await ReadJsonAsync(before)).RootElement.GetProperty("questions")[0];
var afterQuestion = (await ReadJsonAsync(after)).RootElement.GetProperty("questions")[0];
Assert.Equal("answerable question", beforeQuestion.GetProperty("content").GetString());
Assert.Equal("answerable question", afterQuestion.GetProperty("content").GetString());
Assert.False(beforeQuestion.TryGetProperty("explanation", out _));
Assert.False(afterQuestion.TryGetProperty("correctOptionIndex", out _));
}
[Fact]
public async Task Subjective_answer_produces_non_final_pending_review_report()
{
await using var factory = new ApiTestFactory();
var seed = await SeedLearningUserAsync(factory);
var answerable = await SeedAnswerableQuestionAsync(factory, seed, Guid.NewGuid(), "short_answer");
using var client = factory.CreateClient();
await LoginAsync(client, seed);
var answer = await client.PostAsJsonAsync(
"/api/learning/answers",
new SubmitAnswerDto
{
SessionQuestionId = answerable.SessionQuestionId,
ExpectedSessionVersion = 1,
ClientSequence = 1,
IdempotencyKey = "subjective-answer",
AnswerText = "student response"
});
var submit = await client.PostAsJsonAsync(
"/api/learning/practice-sessions/submit",
new SubmitPracticeSessionDto
{
PracticeSessionId = answerable.SessionId,
ExpectedSessionVersion = 2,
IdempotencyKey = "subjective-submit"
});
var answerBody = await ReadJsonAsync(answer);
var report = await ReadJsonAsync(submit);
var result = report.RootElement.GetProperty("questionResults")[0];
Assert.Equal(HttpStatusCode.OK, answer.StatusCode);
Assert.Equal("pending_review", answerBody.RootElement.GetProperty("status").GetString());
Assert.Equal(HttpStatusCode.OK, submit.StatusCode);
Assert.Equal("PendingReview", report.RootElement.GetProperty("status").GetString());
Assert.False(report.RootElement.GetProperty("isFinal").GetBoolean());
Assert.Equal(1, report.RootElement.GetProperty("pendingReviewCount").GetInt32());
Assert.Equal(JsonValueKind.Null, result.GetProperty("isCorrect").ValueKind);
Assert.Equal(JsonValueKind.Null, result.GetProperty("explanation").ValueKind);
}
[Fact]
public async Task Concurrent_submission_creates_only_one_authoritative_report()
{
await using var factory = new ApiTestFactory();
var seed = await SeedLearningUserAsync(factory);
var answerable = await SeedAnswerableQuestionAsync(factory, seed, Guid.NewGuid());
using var client = factory.CreateClient();
await LoginAsync(client, seed);
await client.PostAsJsonAsync(
"/api/learning/answers",
new SubmitAnswerDto
{
SessionQuestionId = answerable.SessionQuestionId,
ExpectedSessionVersion = 1,
ClientSequence = 1,
IdempotencyKey = "concurrent-answer",
SelectedOptionIndices = [0]
});
var submissions = await Task.WhenAll(
client.PostAsJsonAsync(
"/api/learning/practice-sessions/submit",
new SubmitPracticeSessionDto
{
PracticeSessionId = answerable.SessionId,
ExpectedSessionVersion = 2,
IdempotencyKey = "concurrent-submit-a"
}),
client.PostAsJsonAsync(
"/api/learning/practice-sessions/submit",
new SubmitPracticeSessionDto
{
PracticeSessionId = answerable.SessionId,
ExpectedSessionVersion = 2,
IdempotencyKey = "concurrent-submit-b"
}));
Assert.Single(submissions, response => response.StatusCode == HttpStatusCode.OK);
Assert.Single(submissions, response => response.StatusCode == HttpStatusCode.Conflict);
using var scope = factory.CreateSystemScope("Verify unique authoritative report");
Assert.Equal(1, await scope.ServiceProvider.GetRequiredService<TikuDbContext>().PracticeSessionReports
.CountAsync(item => item.PracticeSessionId == answerable.SessionId));
}
[Fact]
public async Task Concurrent_devices_cannot_silently_overwrite_an_answer()
{
await using var factory = new ApiTestFactory();
var seed = await SeedLearningUserAsync(factory);
var answerable = await SeedAnswerableQuestionAsync(factory, seed, Guid.NewGuid());
using var client = factory.CreateClient();
await LoginAsync(client, seed);
var responses = await Task.WhenAll(
client.PostAsJsonAsync(
"/api/learning/answers",
new SubmitAnswerDto
{
SessionQuestionId = answerable.SessionQuestionId,
ExpectedSessionVersion = 1,
ClientSequence = 1,
IdempotencyKey = "device-a-answer",
SelectedOptionIndices = [0]
}),
client.PostAsJsonAsync(
"/api/learning/answers",
new SubmitAnswerDto
{
SessionQuestionId = answerable.SessionQuestionId,
ExpectedSessionVersion = 1,
ClientSequence = 2,
IdempotencyKey = "device-b-answer",
SelectedOptionIndices = [1]
}));
Assert.Single(responses, response => response.StatusCode == HttpStatusCode.OK);
Assert.Single(responses, response => response.StatusCode == HttpStatusCode.Conflict);
using var scope = factory.CreateSystemScope("Verify concurrent answer winner");
var records = await scope.ServiceProvider.GetRequiredService<TikuDbContext>().AnswerRecords
.Where(item => item.PracticeSessionId == answerable.SessionId)
.ToArrayAsync();
Assert.Single(records);
Assert.True(records[0].IsCurrent);
}
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedLearningUserAsync(
ApiTestFactory factory)
{
@@ -435,10 +695,11 @@ public sealed class LearningEndpointTests
return (tenantId, userId, phone);
}
private static async Task<Guid> SeedAnswerableQuestionAsync(
private static async Task<(Guid SessionQuestionId, Guid SessionId, Guid VersionId)> SeedAnswerableQuestionAsync(
ApiTestFactory factory,
(Guid TenantId, Guid UserId, string Phone) seed,
Guid questionId)
Guid questionId,
string questionType = "choice")
{
var versionId = Guid.NewGuid();
await factory.SeedQuestionWithVersionAsync(
@@ -446,7 +707,7 @@ public sealed class LearningEndpointTests
{
Id = questionId,
TenantId = seed.TenantId,
Type = "choice",
Type = questionType,
Status = QuestionStatus.Published
},
new QuestionVersion
@@ -455,7 +716,8 @@ public sealed class LearningEndpointTests
TenantId = seed.TenantId,
QuestionId = questionId,
VersionNo = 1,
Content = "answerable question"
Content = "answerable question",
CorrectOptionIndex = questionType == "choice" ? 0 : null
});
var referenceId = Guid.NewGuid();
@@ -488,9 +750,13 @@ public sealed class LearningEndpointTests
QuestionOwnerTenantId = seed.TenantId,
QuestionId = questionId,
QuestionVersionId = versionId,
Position = 0
Position = 0,
QuestionType = questionType,
ContentSnapshot = "answerable question",
CorrectOptionIndexSnapshot = questionType == "choice" ? 0 : null,
Score = 1
});
return sessionQuestionId;
return (sessionQuestionId, sessionId, versionId);
}
private static async Task LoginAsync(
@@ -516,4 +782,10 @@ public sealed class LearningEndpointTests
.ToArray();
}
private static async Task<string?> ReadProblemCodeAsync(HttpResponseMessage response)
{
using var body = await ReadJsonAsync(response);
return body.RootElement.TryGetProperty("code", out var code) ? code.GetString() : null;
}
}

View File

@@ -0,0 +1,81 @@
using System.Text.Json;
using Tiku.Application.Learning;
using Tiku.Domain.Common;
using Tiku.Domain.Learning;
namespace Tiku.UnitTests.Learning;
public sealed class QuestionGraderTests
{
[Fact]
public void Choice_requires_exactly_one_matching_index()
{
var result = Grade("choice", correctIndex: 1, selected: [1]);
Assert.Equal(AnswerGradingStatus.Correct, result.Status);
Assert.True(result.IsCorrect);
Assert.Equal(5, result.AwardedScore);
}
[Fact]
public void Multiple_choice_compares_deduplicated_unordered_sets()
{
var result = Grade(
"multiple_choice",
correctIndices: JsonSerializer.SerializeToElement(new[] { 2, 0 }),
selected: [0, 2, 2]);
Assert.Equal(AnswerGradingStatus.Correct, result.Status);
}
[Theory]
[InlineData("对", "true")]
[InlineData("false", "错")]
public void True_false_normalizes_supported_boolean_values(string expected, string actual)
{
var result = Grade("true_false", correctText: expected, answerText: actual);
Assert.Equal(AnswerGradingStatus.Correct, result.Status);
}
[Fact]
public void Fill_blank_normalizes_unicode_case_and_whitespace()
{
var rules = JsonSerializer.SerializeToElement(new { acceptedAnswers = new[] { "Entity Framework" } });
var result = Grade("fill_blank", correctText: "EF Core", answerText: " ENTITY FRAMEWORK ", rules: rules);
Assert.Equal(AnswerGradingStatus.Correct, result.Status);
}
[Theory]
[InlineData("short_answer")]
[InlineData("reading")]
[InlineData("programming")]
public void Subjective_questions_remain_pending_review(string type)
{
var result = Grade(type, answerText: "student answer");
Assert.Equal(AnswerGradingStatus.PendingReview, result.Status);
Assert.Null(result.IsCorrect);
Assert.Null(result.AwardedScore);
}
private static QuestionGradingResult Grade(
string type,
int? correctIndex = null,
JsonElement? correctIndices = null,
string? correctText = null,
IReadOnlyCollection<int>? selected = null,
string? answerText = null,
JsonElement? rules = null) =>
QuestionGrader.Grade(new QuestionGradingInput(
type,
correctIndex,
correctIndices ?? JsonDefaults.Array(),
correctText,
rules ?? JsonDefaults.Object(),
selected,
answerText,
5));
}