Files
tiku-backend.net/Tiku.Infrastructure/Learning/LearningActivityService.cs
xiong 290a0c7bd7
Some checks failed
ci / release-gate (push) Has been cancelled
feat(platform): harden governance and approval workflows
2026-08-03 11:43:18 +08:00

1796 lines
72 KiB
C#

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;
using Tiku.Application.Security;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
using Tiku.Domain.Learning;
using Tiku.Domain.QuestionBanks;
using Tiku.Infrastructure.Persistence;
using ZLinq;
namespace Tiku.Infrastructure.Learning;
public sealed class LearningActivityService(
TikuDbContext dbContext,
IQuestionReferenceService questionReferenceService,
IPublicQuestionAccessPolicy publicQuestionAccessPolicy,
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 &&
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),
await dbContext.WrongQuestions.AsNoTracking().CountAsync(
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.ResolvedAt == null,
cancellationToken),
await dbContext.FavoriteQuestions.AsNoTracking().CountAsync(
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
cancellationToken),
await dbContext.UserWordFavorites.AsNoTracking().CountAsync(
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
cancellationToken),
await dbContext.UserWordProgress.AsNoTracking().CountAsync(
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
cancellationToken),
await dbContext.PracticeSessions.AsNoTracking().CountAsync(
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.Status == PracticeReportStatus.Final,
cancellationToken));
}
public async Task<LearningList<LearningTrendItem>> GetTrendAsync(
LearningActor actor,
LearningLimitFilter filter,
CancellationToken cancellationToken = default)
{
var since = DateTimeOffset.UtcNow.AddDays(-ResolveLimit(filter.Limit));
var rows = await dbContext.AnswerRecords.AsNoTracking()
.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);
var items = rows
.AsValueEnumerable()
.GroupBy(item => DateOnly.FromDateTime(item.AnsweredAt.UtcDateTime))
.OrderBy(group => group.Key)
.Select(group => new LearningTrendItem(
group.Key,
group.Count(),
group.Count(item => item.IsCorrect == true),
group.Count(item => item.IsCorrect == false)))
.ToArray();
return new LearningList<LearningTrendItem>(items);
}
public async Task<LearningLeaderboardResult> GetLeaderboardAsync(
LearningActor actor,
LearningLimitFilter filter,
CancellationToken cancellationToken = default)
{
var rows = await dbContext.AnswerRecords.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId &&
item.IsCurrent &&
item.GradingStatus != AnswerGradingStatus.PendingReview &&
item.GradingStatus != AnswerGradingStatus.LegacyUnverified)
.GroupBy(item => item.UserId)
.Select(group => new
{
UserId = group.Key,
AnswerCount = group.Count(),
CorrectCount = group.Count(item => item.IsCorrect == true),
WrongCount = group.Count(item => item.IsCorrect == false)
})
.OrderByDescending(item => item.CorrectCount)
.ThenByDescending(item => item.AnswerCount)
.Take(ResolveLimit(filter.Limit))
.ToArrayAsync(cancellationToken);
var userIds = rows.Select(item => item.UserId).ToArray();
var names = await dbContext.Users.AsNoTracking()
.Where(item => userIds.Contains(item.Id))
.ToDictionaryAsync(item => item.Id, item => item.Name ?? item.Phone, cancellationToken);
var items = rows
.Select(item => new LearningLeaderboardItem(
item.UserId,
names.GetValueOrDefault(item.UserId),
item.AnswerCount,
item.CorrectCount,
item.WrongCount,
item.AnswerCount == 0 ? 0 : decimal.Round((decimal)item.CorrectCount / item.AnswerCount, 4)))
.ToArray();
return new LearningLeaderboardResult(
"correct_count",
"all",
items,
items.FirstOrDefault(item => item.UserId == actor.UserId),
DateTimeOffset.UtcNow);
}
public async Task<AnswerRecordItem> SubmitAnswerAsync(
LearningActor actor,
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.SingleOrDefaultAsync(item =>
item.TenantId == actor.TenantId && item.Id == command.SessionQuestionId, cancellationToken);
if (sessionQuestion is null)
{
throw new LearningResourceNotFoundException(
"session_question_not_found",
"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(selectedIndices),
AnswerText = command.AnswerText,
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);
session.Version++;
session.LastClientSequence = command.ClientSequence;
var response = ToItem(record, session.Version);
dbContext.LearningOperationIdempotencies.Add(new LearningOperationIdempotency
{
TenantId = actor.TenantId,
UserId = actor.UserId,
PracticeSessionId = session.Id,
OperationType = "answer",
IdempotencyKey = command.IdempotencyKey.Trim(),
RequestHash = requestHash,
ResponseSnapshot = JsonSerializer.SerializeToElement(response),
CompletedAt = now
});
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))
{
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.");
}
return response;
}
public async Task<LearningList<FavoriteQuestionItem>> GetFavoriteQuestionsAsync(
LearningActor actor,
LearningLimitFilter filter,
CancellationToken cancellationToken = default)
{
var items = await dbContext.FavoriteQuestions
.AsNoTracking()
.Where(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId)
.OrderByDescending(item => item.CreatedAt)
.Take(ResolveLimit(filter.Limit))
.Select(item => new FavoriteQuestionItem(
item.QuestionReferenceId,
new QuestionLocator(
item.QuestionOwnerTenantId == item.TenantId ? QuestionSource.Tenant : QuestionSource.Platform,
item.QuestionId),
item.CreatedAt))
.ToArrayAsync(cancellationToken);
return new LearningList<FavoriteQuestionItem>(items);
}
public async Task<LearningActionResult> ToggleFavoriteQuestionAsync(
LearningActor actor,
QuestionActionCommand command,
CancellationToken cancellationToken = default)
{
var reference = await questionReferenceService.ResolveAsync(
actor.TenantId,
actor.UserId,
command.Locator,
cancellationToken);
var favorite = command.Favorite ?? true;
var item = await dbContext.FavoriteQuestions.FindAsync(
[actor.TenantId, actor.UserId, reference.Id],
cancellationToken);
if (favorite)
{
if (item is null)
{
dbContext.FavoriteQuestions.Add(new FavoriteQuestion
{
TenantId = actor.TenantId,
UserId = actor.UserId,
QuestionReferenceId = reference.Id,
QuestionOwnerTenantId = reference.QuestionOwnerTenantId,
QuestionId = reference.QuestionId,
Source = reference.Source.ToString().ToLowerInvariant(),
CreatedAt = DateTimeOffset.UtcNow
});
}
}
else if (item is not null)
{
dbContext.FavoriteQuestions.Remove(item);
}
await dbContext.SaveChangesAsync(cancellationToken);
return new LearningActionResult(true, favorite);
}
public async Task<LearningList<WrongQuestionItem>> GetWrongQuestionsAsync(
LearningActor actor,
LearningLimitFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.WrongQuestions
.AsNoTracking()
.Where(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId);
if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase))
{
query = query.Where(item => item.ResolvedAt == null);
}
var items = await query
.OrderByDescending(item => item.LastWrongAt)
.Take(ResolveLimit(filter.Limit))
.Select(item => new WrongQuestionItem(
item.QuestionReferenceId,
new QuestionLocator(
item.QuestionOwnerTenantId == item.TenantId ? QuestionSource.Tenant : QuestionSource.Platform,
item.QuestionId),
item.WrongCount,
item.LastWrongAt,
item.ResolvedAt))
.ToArrayAsync(cancellationToken);
return new LearningList<WrongQuestionItem>(items);
}
public async Task<LearningActionResult> ResolveWrongQuestionAsync(
LearningActor actor,
QuestionActionCommand command,
CancellationToken cancellationToken = default)
{
var reference = await questionReferenceService.ResolveAsync(
actor.TenantId,
actor.UserId,
command.Locator,
cancellationToken);
var item = await dbContext.WrongQuestions.FindAsync(
[actor.TenantId, actor.UserId, reference.Id],
cancellationToken);
if (item is null)
{
throw new LearningResourceNotFoundException("wrong_question_not_found", "Wrong question was not found.");
}
item.ResolvedAt = DateTimeOffset.UtcNow;
await dbContext.SaveChangesAsync(cancellationToken);
return new LearningActionResult(true);
}
public async Task<WrongQuestionReviewPlan> GetWrongQuestionReviewPlanAsync(
LearningActor actor,
LearningLimitFilter filter,
CancellationToken cancellationToken = default)
{
var items = await dbContext.WrongQuestions.AsNoTracking()
.Where(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.ResolvedAt == null)
.OrderByDescending(item => item.WrongCount)
.ThenBy(item => item.LastWrongAt)
.Take(ResolveLimit(filter.Limit))
.Select(item => new WrongQuestionReviewPlanItem(
item.QuestionReferenceId,
new QuestionLocator(
item.QuestionOwnerTenantId == item.TenantId ? QuestionSource.Tenant : QuestionSource.Platform,
item.QuestionId),
item.WrongCount,
item.LastWrongAt))
.ToArrayAsync(cancellationToken);
return new WrongQuestionReviewPlan(
items,
JsonSerializer.SerializeToElement(new
{
mode = "wrong_review",
questionCount = items.Length,
recommendedEndpoint = "/api/learning/practice-sessions"
}));
}
public async Task<LearningList<WordProgressItem>> GetWordProgressAsync(
LearningActor actor,
LearningLimitFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.UserWordProgress
.AsNoTracking()
.Where(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId);
if (filter.UnitId.HasValue)
{
query = query.Where(item => dbContext.VocabularyWords.Any(word =>
word.TenantId == actor.TenantId &&
word.Id == item.WordId &&
word.UnitId == filter.UnitId.Value));
}
if (TryParseWordProgressStatus(filter.Status, out var status))
{
query = query.Where(item => item.Status == status);
}
var items = await query
.OrderBy(item => item.NextReviewAt == null)
.ThenBy(item => item.NextReviewAt)
.ThenByDescending(item => item.UpdatedAt)
.Take(ResolveLimit(filter.Limit))
.Select(item => new WordProgressItem(
item.WordId,
item.Status,
item.CorrectCount,
item.WrongCount,
item.LastReviewAt,
item.NextReviewAt,
item.ReviewCount,
item.CorrectStreak,
item.LastResult,
item.DueLevel,
item.Metadata))
.ToArrayAsync(cancellationToken);
return new LearningList<WordProgressItem>(items);
}
public async Task<WordProgressItem> UpdateWordProgressAsync(
LearningActor actor,
WordProgressCommand command,
CancellationToken cancellationToken = default)
{
await EnsureWordExistsAsync(actor.TenantId, command.WordId, cancellationToken);
var now = DateTimeOffset.UtcNow;
var item = await dbContext.UserWordProgress
.SingleOrDefaultAsync(
progress =>
progress.TenantId == actor.TenantId &&
progress.UserId == actor.UserId &&
progress.WordId == command.WordId,
cancellationToken);
if (item is null)
{
item = new UserWordProgress
{
TenantId = actor.TenantId,
UserId = actor.UserId,
WordId = command.WordId
};
dbContext.UserWordProgress.Add(item);
}
if (TryParseWordProgressStatus(command.Status, out var status))
{
item.Status = status;
}
else if (string.IsNullOrWhiteSpace(command.Status))
{
item.Status = WordProgressStatus.Learning;
}
else
{
throw new LearningValidationException("invalid_word_status", "Word progress status is invalid.");
}
var correctDelta = command.CorrectDelta ?? 0;
var wrongDelta = command.WrongDelta ?? 0;
item.CorrectCount += correctDelta;
item.WrongCount += wrongDelta;
item.ReviewCount += correctDelta + wrongDelta;
item.CorrectStreak = wrongDelta > 0
? 0
: item.CorrectStreak + correctDelta;
item.LastResult = wrongDelta > 0
? WordReviewResult.Wrong
: correctDelta > 0
? WordReviewResult.Correct
: item.LastResult;
item.LastReviewAt = correctDelta + wrongDelta > 0 ? now : item.LastReviewAt;
item.NextReviewAt = command.NextReviewAt ?? item.NextReviewAt;
item.DueLevel = item.Status == WordProgressStatus.Mastered
? WordDueLevel.Mastered
: item.WrongCount > 0 && item.CorrectStreak == 0
? WordDueLevel.Again
: item.DueLevel;
await dbContext.SaveChangesAsync(cancellationToken);
return ToItem(item);
}
public async Task<WordReviewPlan> GetWordReviewPlanAsync(
LearningActor actor,
LearningLimitFilter filter,
CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var query = dbContext.UserWordProgress.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId);
if (filter.UnitId.HasValue)
{
query = query.Where(item => dbContext.VocabularyWords.Any(word =>
word.TenantId == actor.TenantId &&
word.Id == item.WordId &&
word.UnitId == filter.UnitId.Value));
}
var items = await query
.Where(item => item.NextReviewAt == null || item.NextReviewAt <= now)
.OrderBy(item => item.NextReviewAt == null)
.ThenBy(item => item.NextReviewAt)
.ThenByDescending(item => item.WrongCount)
.Take(ResolveLimit(filter.Limit))
.Select(item => new WordReviewPlanItem(
item.WordId,
item.Status,
item.NextReviewAt,
item.CorrectCount,
item.WrongCount,
item.DueLevel))
.ToArrayAsync(cancellationToken);
return new WordReviewPlan(
items,
JsonSerializer.SerializeToElement(new
{
mode = "word_review",
wordCount = items.Length
}));
}
public Task<WordProgressItem> ReviewWordAsync(
LearningActor actor,
WordReviewCommand command,
CancellationToken cancellationToken = default)
{
var correct = string.Equals(command.Result, "correct", StringComparison.OrdinalIgnoreCase) ||
string.Equals(command.Result, "known", StringComparison.OrdinalIgnoreCase);
return UpdateWordProgressAsync(
actor,
new WordProgressCommand(
command.WordId,
correct ? "Reviewing" : "Learning",
correct ? 1 : 0,
correct ? 0 : 1,
command.NextReviewAt ?? DateTimeOffset.UtcNow.AddDays(correct ? 2 : 1)),
cancellationToken);
}
public async Task<WordStatsItem> GetWordStatsAsync(
LearningActor actor,
LearningLimitFilter filter,
CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var query = dbContext.UserWordProgress.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId);
if (filter.UnitId.HasValue)
{
query = query.Where(item => dbContext.VocabularyWords.Any(word =>
word.TenantId == actor.TenantId &&
word.Id == item.WordId &&
word.UnitId == filter.UnitId.Value));
}
return new WordStatsItem(
await query.CountAsync(cancellationToken),
await query.CountAsync(item => item.Status == WordProgressStatus.New, cancellationToken),
await query.CountAsync(item => item.Status == WordProgressStatus.Learning, cancellationToken),
await query.CountAsync(item => item.Status == WordProgressStatus.Reviewing, cancellationToken),
await query.CountAsync(item => item.Status == WordProgressStatus.Mastered, cancellationToken),
await query.CountAsync(item => item.NextReviewAt == null || item.NextReviewAt <= now, cancellationToken),
await dbContext.UserWordFavorites.AsNoTracking().CountAsync(
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
cancellationToken));
}
public async Task<LearningList<FavoriteWordItem>> GetFavoriteWordsAsync(
LearningActor actor,
LearningLimitFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.UserWordFavorites
.AsNoTracking()
.Where(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId);
if (filter.UnitId.HasValue)
{
query = query.Where(item => dbContext.VocabularyWords.Any(word =>
word.TenantId == actor.TenantId &&
word.Id == item.WordId &&
word.UnitId == filter.UnitId.Value));
}
var items = await query
.OrderByDescending(item => item.FavoritedAt ?? item.CreatedAt)
.Take(ResolveLimit(filter.Limit))
.Select(item => new FavoriteWordItem(
item.WordId,
item.Note,
item.FavoritedAt))
.ToArrayAsync(cancellationToken);
return new LearningList<FavoriteWordItem>(items);
}
public async Task<LearningActionResult> ToggleFavoriteWordAsync(
LearningActor actor,
FavoriteWordCommand command,
CancellationToken cancellationToken = default)
{
await EnsureWordExistsAsync(actor.TenantId, command.WordId, cancellationToken);
var favorite = command.Favorite ?? true;
var item = await dbContext.UserWordFavorites
.SingleOrDefaultAsync(
favoriteWord =>
favoriteWord.TenantId == actor.TenantId &&
favoriteWord.UserId == actor.UserId &&
favoriteWord.WordId == command.WordId,
cancellationToken);
if (favorite)
{
if (item is null)
{
item = new UserWordFavorite
{
TenantId = actor.TenantId,
UserId = actor.UserId,
WordId = command.WordId
};
dbContext.UserWordFavorites.Add(item);
}
item.Note = command.Note ?? item.Note;
item.FavoritedAt ??= DateTimeOffset.UtcNow;
}
else if (item is not null)
{
dbContext.UserWordFavorites.Remove(item);
}
await dbContext.SaveChangesAsync(cancellationToken);
return new LearningActionResult(true, favorite);
}
public async Task<PracticeSessionItem> CreatePracticeSessionAsync(
LearningActor actor,
PracticeSessionCommand command,
CancellationToken cancellationToken = default)
{
var assembly = await BuildPracticeAssemblyAsync(actor.TenantId, command, cancellationToken);
var questionReferenceIds = await CollectQuestionReferenceIdsAsync(actor, assembly, cancellationToken);
if (questionReferenceIds.Count == 0)
{
throw new LearningValidationException("no_practice_questions", "No published questions are available for this practice target.");
}
var containsPlatformQuestion = await dbContext.TenantQuestionReferences.AsNoTracking().AnyAsync(
reference =>
reference.TenantId == actor.TenantId &&
questionReferenceIds.Contains(reference.Id) &&
reference.Source == QuestionSource.Platform,
cancellationToken);
if (containsPlatformQuestion)
{
await publicQuestionAccessPolicy.EnsureCanStartAsync(actor.TenantId, cancellationToken);
}
var now = DateTimeOffset.UtcNow;
var session = new PracticeSession
{
TenantId = actor.TenantId,
UserId = actor.UserId,
Mode = assembly.Mode,
TargetType = assembly.TargetType,
TargetId = assembly.TargetId,
BlueprintId = assembly.BlueprintId,
CollectionId = assembly.CollectionId,
EntryId = assembly.EntryId,
ContentNodeId = assembly.ContentNodeId,
QuestionCount = questionReferenceIds.Count,
DurationMinutes = assembly.DurationMinutes,
TotalScore = assembly.TotalScore,
ExpiresAt = assembly.DurationMinutes.HasValue
? now.AddMinutes(assembly.DurationMinutes.Value)
: null,
AccessMode = PracticeAccessMode.Free,
ConsumedFreeQuota = questionReferenceIds.Count,
AccessSnapshot = JsonSerializer.SerializeToElement(new
{
strategy = "v1_free",
requestedCount = assembly.QuestionLimit,
grantedCount = questionReferenceIds.Count
}),
Metadata = command.Metadata.ValueKind is JsonValueKind.Undefined
? JsonDefaults.Object()
: command.Metadata
};
dbContext.PracticeSessions.Add(session);
var selections = await LoadQuestionSelectionsAsync(
actor.TenantId,
questionReferenceIds,
cancellationToken);
foreach (var selection in selections)
{
if (!QuestionGrader.HasValidAuthoritativeAnswer(
selection.QuestionType,
selection.CorrectOptionIndex,
selection.CorrectOptionIndices,
selection.AnswerText))
{
throw new LearningValidationException(
"practice_question_grading_rule_invalid",
$"Question '{selection.QuestionId}' has no valid authoritative grading rule.");
}
}
var scorePerQuestion = session.TotalScore.HasValue && selections.Count > 0
? session.TotalScore.Value / selections.Count
: (decimal?)null;
dbContext.PracticeSessionQuestions.AddRange(selections.Select((selection, index) =>
new PracticeSessionQuestion
{
TenantId = actor.TenantId,
PracticeSessionId = session.Id,
QuestionReferenceId = selection.QuestionReferenceId,
QuestionOwnerTenantId = selection.QuestionOwnerTenantId,
QuestionId = selection.QuestionId,
QuestionVersionId = selection.QuestionVersionId,
Position = index,
Score = scorePerQuestion,
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
{
TenantId = actor.TenantId,
UserId = actor.UserId,
PracticeSessionId = session.Id,
EventType = PracticeAccessEventType.SessionCreated,
AccessMode = PracticeAccessEventMode.Free,
RequestedCount = assembly.QuestionLimit,
GrantedCount = selections.Count,
ConsumedFreeQuota = selections.Count,
Metadata = session.AccessSnapshot
});
await dbContext.SaveChangesAsync(cancellationToken);
return ToItem(session);
}
public async Task<PracticeSessionDetailItem> GetPracticeSessionDetailAsync(
LearningActor actor,
PracticeSessionFilter filter,
CancellationToken cancellationToken = default)
{
var session = await GetPracticeSessionAsync(actor, filter.PracticeSessionId, cancellationToken);
var orderedQuestions = await LoadSessionQuestionItemsAsync(
actor.TenantId,
session.Id,
cancellationToken);
var answers = await dbContext.AnswerRecords
.AsNoTracking()
.Where(answer =>
answer.TenantId == actor.TenantId &&
answer.UserId == actor.UserId &&
answer.PracticeSessionId == session.Id &&
answer.IsCurrent)
.ToArrayAsync(cancellationToken);
var answersByQuestion = answers
.GroupBy(answer => answer.SessionQuestionId)
.ToDictionary(
group => group.Key,
group => ToItem(
group.OrderByDescending(answer => answer.Revision).First(),
session.Version));
return new PracticeSessionDetailItem(ToItem(session), orderedQuestions, answersByQuestion);
}
public async Task<PracticeSessionReportItem> SubmitPracticeSessionAsync(
LearningActor actor,
SubmitPracticeSessionCommand command,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(command.IdempotencyKey))
{
throw new LearningValidationException("idempotency_key_required", "An idempotency key is required.");
}
await using var transaction = await 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.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(
LearningActor actor,
PracticeSessionFilter filter,
CancellationToken cancellationToken = default)
{
if (!filter.PracticeSessionId.HasValue)
{
throw new LearningValidationException("practice_session_id_required", "Practice session id is required.");
}
var report = await dbContext.PracticeSessionReports
.AsNoTracking()
.SingleOrDefaultAsync(
item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.PracticeSessionId == filter.PracticeSessionId.Value,
cancellationToken);
if (report is null)
{
throw new LearningResourceNotFoundException("practice_report_not_found", "Practice session report was not found.");
}
return ToItem(report);
}
public async Task<LearningList<PracticeSessionReportItem>> GetPracticeReportsAsync(
LearningActor actor,
PracticeSessionFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.PracticeSessionReports
.AsNoTracking()
.Where(report =>
report.TenantId == actor.TenantId &&
report.UserId == actor.UserId);
if (filter.BlueprintId.HasValue)
{
query = query.Where(report => report.BlueprintId == filter.BlueprintId.Value);
}
if (!string.IsNullOrWhiteSpace(filter.Mode))
{
query = query.Where(report => report.Mode == filter.Mode.Trim());
}
var items = await query
.OrderByDescending(report => report.SubmittedAt)
.Take(ResolveLimit(filter.Limit))
.ToArrayAsync(cancellationToken);
return new LearningList<PracticeSessionReportItem>(items.Select(ToItem).ToArray());
}
public async Task<LearningList<PracticeHistoryItem>> GetPracticeHistoryAsync(
LearningActor actor,
PracticeSessionFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.PracticeSessions
.AsNoTracking()
.Where(session =>
session.TenantId == actor.TenantId &&
session.UserId == actor.UserId);
if (!string.IsNullOrWhiteSpace(filter.Mode))
{
query = query.Where(session => session.Mode == filter.Mode.Trim());
}
var rows = await query
.GroupJoin(
dbContext.PracticeSessionReports.AsNoTracking(),
session => new { session.TenantId, PracticeSessionId = session.Id },
report => new { report.TenantId, report.PracticeSessionId },
(session, reports) => new { session, report = reports.FirstOrDefault() })
.OrderByDescending(row => row.session.FinishedAt ?? row.session.StartedAt)
.Take(ResolveLimit(filter.Limit))
.ToArrayAsync(cancellationToken);
var now = DateTimeOffset.UtcNow;
var items = rows
.Select(row => new PracticeHistoryItem(
row.session.Id,
row.session.Mode,
row.session.TargetType,
row.session.TargetId,
row.session.BlueprintId,
row.session.CollectionId,
row.session.EntryId,
row.session.ContentNodeId,
row.session.QuestionCount,
row.report?.AnsweredCount ?? 0,
row.report?.CorrectCount ?? 0,
row.report?.WrongCount ?? 0,
row.session.StartedAt,
row.session.FinishedAt,
row.session.ExpiresAt,
ResolvePracticeSessionHistoryStatus(row.session, now),
row.report?.Id,
row.report?.Score,
row.report?.TotalScore,
row.report?.Accuracy))
.Where(item => string.IsNullOrWhiteSpace(filter.Status) || item.Status == filter.Status.Trim())
.ToArray();
return new LearningList<PracticeHistoryItem>(items);
}
private async Task<PracticeAssembly> BuildPracticeAssemblyAsync(
Guid tenantId,
PracticeSessionCommand command,
CancellationToken cancellationToken)
{
var mode = NormalizeMode(command.Mode);
var assembly = new PracticeAssembly(
mode,
command.TargetType,
command.TargetId,
command.BlueprintId,
command.CollectionId,
command.EntryId,
command.ContentNodeId,
Math.Clamp(command.QuestionLimit ?? 100, 1, MaxLimit),
command.DurationMinutes,
command.TotalScore);
if (!command.BlueprintId.HasValue)
{
return assembly;
}
var blueprint = await dbContext.PracticeBlueprints
.AsNoTracking()
.SingleOrDefaultAsync(
item =>
item.TenantId == tenantId &&
item.Id == command.BlueprintId.Value &&
item.Status == ContentStatus.Active,
cancellationToken);
if (blueprint is null)
{
throw new LearningResourceNotFoundException("practice_blueprint_not_found", "Practice blueprint was not found.");
}
return assembly with
{
Mode = NormalizeMode(blueprint.Mode.ToString()),
TargetType = command.TargetType ?? "blueprint",
TargetId = command.TargetId ?? blueprint.Id,
CollectionId = command.CollectionId ?? blueprint.CollectionId,
EntryId = command.EntryId ?? blueprint.EntryId,
ContentNodeId = command.ContentNodeId ?? blueprint.NodeId,
QuestionLimit = Math.Clamp(command.QuestionLimit ?? blueprint.QuestionLimit ?? 100, 1, MaxLimit),
DurationMinutes = command.DurationMinutes ?? blueprint.DurationMinutes,
TotalScore = command.TotalScore ?? blueprint.TotalScore
};
}
private async Task<List<Guid>> CollectQuestionReferenceIdsAsync(
LearningActor actor,
PracticeAssembly assembly,
CancellationToken cancellationToken)
{
if (assembly.Mode == "wrong_review")
{
return await dbContext.WrongQuestions
.AsNoTracking()
.Where(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.ResolvedAt == null)
.OrderByDescending(item => item.WrongCount)
.ThenBy(item => item.LastWrongAt)
.Take(assembly.QuestionLimit)
.Select(item => item.QuestionReferenceId)
.ToListAsync(cancellationToken);
}
if (assembly.Mode == "favorite_review")
{
return await dbContext.FavoriteQuestions
.AsNoTracking()
.Where(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId)
.OrderByDescending(item => item.CreatedAt)
.Take(assembly.QuestionLimit)
.Select(item => item.QuestionReferenceId)
.ToListAsync(cancellationToken);
}
if (assembly.CollectionId.HasValue)
{
return await dbContext.QuestionCollectionItems
.AsNoTracking()
.Where(item =>
item.TenantId == actor.TenantId &&
item.CollectionId == assembly.CollectionId.Value)
.OrderBy(item => item.SortOrder)
.Take(assembly.QuestionLimit)
.Select(item => item.QuestionReferenceId)
.ToListAsync(cancellationToken);
}
var query = dbContext.Questions
.AsNoTracking()
.Where(question =>
question.TenantId == actor.TenantId &&
question.Status == QuestionStatus.Published);
if (assembly.ContentNodeId.HasValue)
{
query = query.Where(question => question.ContentNodeId == assembly.ContentNodeId.Value);
}
else if (assembly.EntryId.HasValue)
{
query = query.Where(question => question.EntryId == assembly.EntryId.Value);
}
else if (assembly.TargetId.HasValue && !string.IsNullOrWhiteSpace(assembly.TargetType))
{
query = ApplyLegacyTargetFilter(query, assembly.TargetType, assembly.TargetId.Value);
}
else
{
throw new LearningValidationException("practice_target_required", "Practice target is required.");
}
var questionIds = await query
.OrderBy(question => question.CreatedAt)
.Take(assembly.QuestionLimit)
.Select(question => question.Id)
.ToListAsync(cancellationToken);
var referenceIds = new List<Guid>(questionIds.Count);
foreach (var questionId in questionIds)
{
var reference = await questionReferenceService.ResolveAsync(
actor.TenantId,
actor.UserId,
new QuestionLocator(QuestionSource.Tenant, questionId),
cancellationToken);
referenceIds.Add(reference.Id);
}
return referenceIds;
}
private static IQueryable<Question> ApplyLegacyTargetFilter(
IQueryable<Question> query,
string? targetType,
Guid targetId)
{
return NormalizeEnumValue(targetType) switch
{
"subject" => query.Where(question => question.SubjectId == targetId),
"category" => query.Where(question => question.CategoryId == targetId),
"node" => query.Where(question => question.NodeId == targetId),
"questionbank" => query.Where(question => question.QuestionBankId == targetId),
"contentnode" => query.Where(question => question.ContentNodeId == targetId),
"entry" => query.Where(question => question.EntryId == targetId),
_ => query.Where(_ => false)
};
}
private async Task<IReadOnlyList<QuestionSelection>> LoadQuestionSelectionsAsync(
Guid tenantId,
IReadOnlyCollection<Guid> questionReferenceIds,
CancellationToken cancellationToken)
{
var rows = await tenantExecutionScope.ExecuteAsync(
new SystemScopeRequest(
tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(LearningActivityService),
"Lock published question versions for a new practice session", Guid.NewGuid().ToString("N")),
async (provider, token) =>
{
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
return await (
from reference in systemDbContext.TenantQuestionReferences.AsNoTracking()
join question in systemDbContext.Questions.AsNoTracking()
on new { TenantId = reference.QuestionOwnerTenantId, Id = reference.QuestionId }
equals new { question.TenantId, question.Id }
join version in systemDbContext.QuestionVersions.AsNoTracking()
on new
{
TenantId = reference.QuestionOwnerTenantId,
reference.QuestionId,
Id = question.CurrentVersionId
}
equals new
{
version.TenantId,
version.QuestionId,
Id = (Guid?)version.Id
}
where reference.TenantId == tenantId &&
questionReferenceIds.Contains(reference.Id) &&
question.Status == QuestionStatus.Published
select new QuestionSelection(
reference.Id,
reference.QuestionOwnerTenantId,
reference.QuestionId,
version.Id,
question.Type,
question.TypeLabel,
question.Difficulty,
question.Tags,
version.Content,
version.Options,
version.CorrectOptionIndex,
version.CorrectOptionIndices,
version.AnswerText,
version.Explanation))
.ToArrayAsync(token);
},
cancellationToken);
var byReference = rows.ToDictionary(row => row.QuestionReferenceId);
if (byReference.Count != questionReferenceIds.Distinct().Count())
{
throw new LearningValidationException(
"practice_question_unavailable",
"One or more practice questions have no published version.");
}
return questionReferenceIds.Select(referenceId => byReference[referenceId]).ToArray();
}
private Task<PracticeSessionQuestionItem[]> LoadSessionQuestionItemsAsync(
Guid tenantId,
Guid practiceSessionId,
CancellationToken cancellationToken)
{
return tenantExecutionScope.ExecuteAsync(
new SystemScopeRequest(
tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(LearningActivityService),
"Read locked question versions for a tenant practice session", Guid.NewGuid().ToString("N")),
async (provider, token) =>
{
var systemDbContext = provider.GetRequiredService<TikuDbContext>();
return await (
from sessionQuestion in systemDbContext.PracticeSessionQuestions.AsNoTracking()
where sessionQuestion.TenantId == tenantId &&
sessionQuestion.PracticeSessionId == practiceSessionId
orderby sessionQuestion.Position
select new PracticeSessionQuestionItem(
sessionQuestion.Id,
sessionQuestion.QuestionReferenceId,
new QuestionLocator(
sessionQuestion.QuestionOwnerTenantId == tenantId
? QuestionSource.Tenant
: QuestionSource.Platform,
sessionQuestion.QuestionId),
sessionQuestion.QuestionId,
sessionQuestion.QuestionType,
sessionQuestion.TypeLabelSnapshot,
sessionQuestion.DifficultySnapshot,
sessionQuestion.TagsSnapshot,
sessionQuestion.QuestionVersionId,
sessionQuestion.ContentSnapshot,
sessionQuestion.OptionsSnapshot))
.ToArrayAsync(token);
},
cancellationToken);
}
private async Task<PracticeSession> GetPracticeSessionAsync(
LearningActor actor,
Guid? practiceSessionId,
CancellationToken cancellationToken)
{
if (!practiceSessionId.HasValue)
{
throw new LearningValidationException("practice_session_id_required", "Practice session id is required.");
}
var session = await dbContext.PracticeSessions
.SingleOrDefaultAsync(
item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.Id == practiceSessionId.Value,
cancellationToken);
if (session is null)
{
throw new LearningResourceNotFoundException("practice_session_not_found", "Practice session was not found.");
}
return session;
}
private async Task<PracticeSessionReport> BuildPracticeSessionReportAsync(
LearningActor actor,
PracticeSession session,
CancellationToken cancellationToken)
{
var sessionQuestions = await dbContext.PracticeSessionQuestions.AsNoTracking()
.Where(item =>
item.TenantId == actor.TenantId &&
item.PracticeSessionId == session.Id)
.OrderBy(item => item.Position)
.ToArrayAsync(cancellationToken);
if (sessionQuestions.Length == 0)
{
throw new LearningValidationException("practice_session_empty", "Practice session has no question snapshot.");
}
var answers = await dbContext.AnswerRecords
.AsNoTracking()
.Where(answer =>
answer.TenantId == actor.TenantId &&
answer.UserId == actor.UserId &&
answer.PracticeSessionId == session.Id &&
answer.IsCurrent)
.ToArrayAsync(cancellationToken);
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.GradingStatus == AnswerGradingStatus.Correct);
var wrongCount = sessionQuestions.Count(question =>
latestAnswers.TryGetValue(question.Id, out var answer) &&
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 = 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.GradingStatus == AnswerGradingStatus.Incorrect)
.Select(question => question.QuestionReferenceId)
.ToArray();
var questionResults = sessionQuestions
.Select(question =>
{
latestAnswers.TryGetValue(question.Id, out var answer);
return new
{
sessionQuestionId = question.Id,
questionReferenceId = question.QuestionReferenceId,
questionId = question.QuestionId,
source = question.QuestionOwnerTenantId == actor.TenantId ? "tenant" : "platform",
answered = answer is not null,
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();
var sectionStats = new[]
{
new
{
key = "default",
title = "默认",
questionCount = totalQuestions,
answeredCount,
correctCount,
wrongCount,
unansweredCount,
score,
totalScore,
accuracy,
pendingReviewCount,
sortOrder = 0
}
};
var report = new PracticeSessionReport
{
TenantId = actor.TenantId,
UserId = actor.UserId,
PracticeSessionId = session.Id,
BlueprintId = session.BlueprintId,
CollectionId = session.CollectionId,
Mode = session.Mode,
TotalQuestions = totalQuestions,
AnsweredCount = answeredCount,
CorrectCount = correctCount,
WrongCount = wrongCount,
UnansweredCount = unansweredCount,
Score = score,
TotalScore = totalScore,
Accuracy = accuracy,
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
})
};
dbContext.PracticeSessionReports.Add(report);
dbContext.PracticeSessionReportSections.Add(new PracticeSessionReportSection
{
TenantId = actor.TenantId,
ReportId = report.Id,
PracticeSessionId = session.Id,
SectionKey = "default",
SectionName = "默认",
QuestionCount = totalQuestions,
AnsweredCount = answeredCount,
CorrectCount = correctCount,
WrongCount = wrongCount,
UnansweredCount = unansweredCount,
Score = score,
TotalScore = totalScore,
Accuracy = accuracy,
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;
}
private async Task EnsureQuestionExistsAsync(
Guid tenantId,
Guid questionId,
CancellationToken cancellationToken)
{
var exists = await dbContext.Questions.AnyAsync(
question =>
question.TenantId == tenantId &&
question.Id == questionId &&
question.Status == QuestionStatus.Published,
cancellationToken);
if (!exists)
{
throw new LearningResourceNotFoundException("question_not_found", "Question was not found.");
}
}
private async Task EnsureWordExistsAsync(
Guid tenantId,
Guid wordId,
CancellationToken cancellationToken)
{
var exists = await dbContext.VocabularyWords.AnyAsync(
word =>
word.TenantId == tenantId &&
word.Id == wordId &&
word.IsActive,
cancellationToken);
if (!exists)
{
throw new LearningResourceNotFoundException("word_not_found", "Word was not found.");
}
}
private static AnswerRecordItem ToItem(AnswerRecord record, long sessionVersion)
{
return new AnswerRecordItem(
record.Id,
record.SessionQuestionId,
record.PracticeSessionId,
record.SelectedOptions,
record.AnswerText,
record.GradingStatus == AnswerGradingStatus.PendingReview ? "pending_review" : "accepted",
record.Revision,
record.ClientSequence,
sessionVersion,
record.AnsweredAt);
}
private static WordProgressItem ToItem(UserWordProgress item)
{
return new WordProgressItem(
item.WordId,
item.Status,
item.CorrectCount,
item.WrongCount,
item.LastReviewAt,
item.NextReviewAt,
item.ReviewCount,
item.CorrectStreak,
item.LastResult,
item.DueLevel,
item.Metadata);
}
private static PracticeSessionItem ToItem(PracticeSession item)
{
return new PracticeSessionItem(
item.Id,
item.Mode,
item.TargetType,
item.TargetId,
item.BlueprintId,
item.CollectionId,
item.EntryId,
item.ContentNodeId,
item.QuestionCount,
item.DurationMinutes,
item.TotalScore,
item.AccessMode,
item.AccessEntitlementId,
item.ConsumedFreeQuota,
item.AccessSnapshot,
item.StartedAt,
item.FinishedAt,
item.ExpiresAt,
item.Metadata,
item.Status,
item.Version,
item.LastClientSequence);
}
private static PracticeSessionReportItem ToItem(PracticeSessionReport item)
{
return new PracticeSessionReportItem(
item.Id,
item.PracticeSessionId,
item.BlueprintId,
item.CollectionId,
item.Mode,
item.TotalQuestions,
item.AnsweredCount,
item.CorrectCount,
item.WrongCount,
item.UnansweredCount,
item.Score,
item.TotalScore,
item.Accuracy,
item.DurationSeconds,
item.StartedAt,
item.SubmittedAt,
item.Status,
item.Version,
item.IsFinal,
item.PendingReviewCount,
item.ScoringVersion,
item.SectionStats,
item.QuestionResults,
item.WrongQuestionIds,
item.Metadata);
}
private static List<Guid> ReadGuidArray(JsonElement value)
{
if (value.ValueKind is not JsonValueKind.Array)
{
return [];
}
return value.EnumerateArray()
.Select(item => item.ValueKind == JsonValueKind.String && Guid.TryParse(item.GetString(), out var id)
? (Guid?)id
: null)
.Where(id => id.HasValue)
.Select(id => id!.Value)
.ToList();
}
private static void EnsureAnswerSessionState(
PracticeSession session,
SubmitAnswerCommand command)
{
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.Status == PracticeSessionStatus.Expired ||
session.ExpiresAt.HasValue && session.ExpiresAt.Value <= now)
{
return "expired";
}
return "active";
}
private static string NormalizeMode(string? mode)
{
return NormalizeEnumValue(mode) switch
{
"sequential" => "sequential",
"random" => "random",
"mockexam" => "mock_exam",
"paper" => "paper",
"wrongreview" => "wrong_review",
"favoritereview" => "favorite_review",
_ => "chapter"
};
}
private static bool TryParseWordProgressStatus(string? value, out WordProgressStatus status)
{
return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out status);
}
private static int ResolveLimit(int? limit)
{
return Math.Clamp(limit ?? DefaultLimit, 1, MaxLimit);
}
private static string? NormalizeEnumValue(string? value)
{
return string.IsNullOrWhiteSpace(value)
? null
: value.Replace("_", string.Empty, StringComparison.Ordinal)
.Replace("-", string.Empty, StringComparison.Ordinal);
}
private sealed record PracticeAssembly(
string Mode,
string? TargetType,
Guid? TargetId,
Guid? BlueprintId,
Guid? CollectionId,
Guid? EntryId,
Guid? ContentNodeId,
int QuestionLimit,
int? DurationMinutes,
decimal? TotalScore);
private sealed record QuestionSelection(
Guid QuestionReferenceId,
Guid QuestionOwnerTenantId,
Guid QuestionId,
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)
{
public string Code { get; } = code;
}
public sealed class LearningResourceNotFoundException(string code, string message) : LearningException(code, message);
public sealed class LearningValidationException(string code, string message) : LearningException(code, message);