using Microsoft.EntityFrameworkCore; using System.Text.Json; using Tiku.Application.Learning; using Tiku.Domain.Common; using Tiku.Domain.Content; using Tiku.Domain.Learning; using Tiku.Domain.QuestionBanks; using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Learning; public sealed class LearningActivityService(TikuDbContext dbContext) : ILearningActivityService { private const int DefaultLimit = 100; private const int MaxLimit = 500; public async Task SubmitAnswerAsync( LearningActor actor, SubmitAnswerCommand command, CancellationToken cancellationToken = default) { var question = await dbContext.Questions .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.Id == command.QuestionId && item.Status == QuestionStatus.Published) .Select(item => new { item.Id, item.CurrentVersionId }) .SingleOrDefaultAsync(cancellationToken); if (question is null) { throw new LearningResourceNotFoundException("question_not_found", "Question was not found."); } if (command.PracticeSessionId.HasValue) { var sessionExists = await dbContext.PracticeSessions.AnyAsync( session => session.TenantId == actor.TenantId && session.UserId == actor.UserId && session.Id == command.PracticeSessionId.Value, cancellationToken); if (!sessionExists) { throw new LearningResourceNotFoundException("practice_session_not_found", "Practice session was not found."); } } var now = DateTimeOffset.UtcNow; var record = new AnswerRecord { TenantId = actor.TenantId, UserId = actor.UserId, QuestionId = question.Id, QuestionVersionId = question.CurrentVersionId, PracticeSessionId = command.PracticeSessionId, SelectedOptions = JsonSerializer.SerializeToElement(command.SelectedOptions ?? []), AnswerText = command.AnswerText, IsCorrect = command.SelfJudgedCorrect, AnsweredAt = now, CreatedAt = now }; dbContext.AnswerRecords.Add(record); if (command.SelfJudgedCorrect == false) { var wrongQuestion = await dbContext.WrongQuestions.FindAsync( [actor.TenantId, actor.UserId, question.Id], cancellationToken); if (wrongQuestion is null) { dbContext.WrongQuestions.Add(new WrongQuestion { TenantId = actor.TenantId, UserId = actor.UserId, QuestionId = question.Id, WrongCount = 1, LastWrongAt = now }); } else { wrongQuestion.WrongCount++; wrongQuestion.LastWrongAt = now; wrongQuestion.ResolvedAt = null; } } await dbContext.SaveChangesAsync(cancellationToken); return ToItem(record); } public async Task> 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.QuestionId, item.Source, item.CreatedAt)) .ToArrayAsync(cancellationToken); return new LearningList(items); } public async Task ToggleFavoriteQuestionAsync( LearningActor actor, QuestionActionCommand command, CancellationToken cancellationToken = default) { await EnsureQuestionExistsAsync(actor.TenantId, command.QuestionId, cancellationToken); var favorite = command.Favorite ?? true; var item = await dbContext.FavoriteQuestions.FindAsync( [actor.TenantId, actor.UserId, command.QuestionId], cancellationToken); if (favorite) { if (item is null) { dbContext.FavoriteQuestions.Add(new FavoriteQuestion { TenantId = actor.TenantId, UserId = actor.UserId, QuestionId = command.QuestionId, Source = "api", 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> 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.QuestionId, item.WrongCount, item.LastWrongAt, item.ResolvedAt)) .ToArrayAsync(cancellationToken); return new LearningList(items); } public async Task ResolveWrongQuestionAsync( LearningActor actor, QuestionActionCommand command, CancellationToken cancellationToken = default) { var item = await dbContext.WrongQuestions.FindAsync( [actor.TenantId, actor.UserId, command.QuestionId], 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> 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(items); } public async Task 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> 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(items); } public async Task 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 CreatePracticeSessionAsync( LearningActor actor, PracticeSessionCommand command, CancellationToken cancellationToken = default) { var assembly = await BuildPracticeAssemblyAsync(actor.TenantId, command, cancellationToken); var questionIds = await CollectQuestionIdsAsync(actor, assembly, cancellationToken); if (questionIds.Count == 0) { throw new LearningValidationException("no_practice_questions", "No published questions are available for this practice target."); } 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, QuestionIds = JsonSerializer.SerializeToElement(questionIds), QuestionCount = questionIds.Count, DurationMinutes = assembly.DurationMinutes, TotalScore = assembly.TotalScore, ExpiresAt = assembly.DurationMinutes.HasValue ? now.AddMinutes(assembly.DurationMinutes.Value) : null, AccessMode = PracticeAccessMode.Free, ConsumedFreeQuota = questionIds.Count, AccessSnapshot = JsonSerializer.SerializeToElement(new { strategy = "v1_free", requestedCount = assembly.QuestionLimit, grantedCount = questionIds.Count }), Metadata = command.Metadata.ValueKind is JsonValueKind.Undefined ? JsonDefaults.Object() : command.Metadata }; dbContext.PracticeSessions.Add(session); dbContext.PracticeAccessEvents.Add(new PracticeAccessEvent { TenantId = actor.TenantId, UserId = actor.UserId, PracticeSessionId = session.Id, EventType = PracticeAccessEventType.SessionCreated, AccessMode = PracticeAccessEventMode.Free, RequestedCount = assembly.QuestionLimit, GrantedCount = questionIds.Count, ConsumedFreeQuota = questionIds.Count, Metadata = session.AccessSnapshot }); await dbContext.SaveChangesAsync(cancellationToken); return ToItem(session); } public async Task GetPracticeSessionDetailAsync( LearningActor actor, PracticeSessionFilter filter, CancellationToken cancellationToken = default) { var session = await GetPracticeSessionAsync(actor, filter.PracticeSessionId, cancellationToken); var questionIds = ReadGuidArray(session.QuestionIds); var questions = await dbContext.Questions .AsNoTracking() .Where(question => question.TenantId == actor.TenantId && questionIds.Contains(question.Id)) .GroupJoin( dbContext.QuestionVersions.AsNoTracking(), question => new { question.TenantId, QuestionId = question.Id, VersionId = question.CurrentVersionId }, version => new { version.TenantId, version.QuestionId, VersionId = (Guid?)version.Id }, (question, versions) => new { question, version = versions.FirstOrDefault() }) .Select(row => new PracticeSessionQuestionItem( row.question.Id, row.question.Type, row.question.TypeLabel, row.question.Difficulty, row.question.Tags, row.version == null ? null : row.version.Id, row.version == null ? null : row.version.Content, row.version == null ? JsonDefaults.Array() : row.version.Options, row.version == null ? null : row.version.Explanation)) .ToArrayAsync(cancellationToken); var questionById = questions.ToDictionary(question => question.Id); var orderedQuestions = questionIds .Where(questionById.ContainsKey) .Select(questionId => questionById[questionId]) .ToArray(); var answers = await dbContext.AnswerRecords .AsNoTracking() .Where(answer => answer.TenantId == actor.TenantId && answer.UserId == actor.UserId && answer.PracticeSessionId == session.Id && answer.QuestionId != null) .ToArrayAsync(cancellationToken); var answersByQuestion = answers .GroupBy(answer => answer.QuestionId!.Value) .ToDictionary( group => group.Key, group => ToItem(group.OrderByDescending(answer => answer.AnsweredAt).First())); return new PracticeSessionDetailItem(ToItem(session), orderedQuestions, answersByQuestion); } public async Task SubmitPracticeSessionAsync( LearningActor actor, PracticeSessionFilter filter, 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) { return ToItem(existing); } var report = await BuildPracticeSessionReportAsync(actor, session, cancellationToken); session.FinishedAt ??= report.SubmittedAt; await dbContext.SaveChangesAsync(cancellationToken); return ToItem(report); } public async Task 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> 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(items.Select(ToItem).ToArray()); } public async Task> 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, PracticeSessionStatus(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(items); } private async Task 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> CollectQuestionIdsAsync( 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) .Join( dbContext.Questions.AsNoTracking(), item => new { item.TenantId, item.QuestionId }, question => new { question.TenantId, QuestionId = question.Id }, (item, question) => new { item, question }) .Where(row => row.question.Status == QuestionStatus.Published) .OrderByDescending(row => row.item.WrongCount) .ThenBy(row => row.item.LastWrongAt) .Take(assembly.QuestionLimit) .Select(row => row.question.Id) .ToListAsync(cancellationToken); } if (assembly.Mode == "favorite_review") { return await dbContext.FavoriteQuestions .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId) .Join( dbContext.Questions.AsNoTracking(), item => new { item.TenantId, item.QuestionId }, question => new { question.TenantId, QuestionId = question.Id }, (item, question) => new { item, question }) .Where(row => row.question.Status == QuestionStatus.Published) .OrderByDescending(row => row.item.CreatedAt) .Take(assembly.QuestionLimit) .Select(row => row.question.Id) .ToListAsync(cancellationToken); } if (assembly.CollectionId.HasValue) { return await dbContext.QuestionCollectionItems .AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.CollectionId == assembly.CollectionId.Value) .Join( dbContext.Questions.AsNoTracking(), item => new { item.TenantId, item.QuestionId }, question => new { question.TenantId, QuestionId = question.Id }, (item, question) => new { item, question }) .Where(row => row.question.Status == QuestionStatus.Published) .OrderBy(row => row.item.SortOrder) .ThenBy(row => row.question.CreatedAt) .Take(assembly.QuestionLimit) .Select(row => row.question.Id) .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."); } return await query .OrderBy(question => question.CreatedAt) .Take(assembly.QuestionLimit) .Select(question => question.Id) .ToListAsync(cancellationToken); } private static IQueryable ApplyLegacyTargetFilter( IQueryable 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 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 BuildPracticeSessionReportAsync( LearningActor actor, PracticeSession session, CancellationToken cancellationToken) { var questionIds = ReadGuidArray(session.QuestionIds); if (questionIds.Count == 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.QuestionId != null) .ToArrayAsync(cancellationToken); var latestAnswers = answers .GroupBy(answer => answer.QuestionId!.Value) .ToDictionary( group => group.Key, group => group.OrderByDescending(answer => answer.AnsweredAt).First()); var totalQuestions = questionIds.Count; var answeredCount = questionIds.Count(latestAnswers.ContainsKey); var correctCount = questionIds.Count(questionId => latestAnswers.TryGetValue(questionId, out var answer) && answer.IsCorrect == true); var wrongCount = questionIds.Count(questionId => latestAnswers.TryGetValue(questionId, out var answer) && answer.IsCorrect != true); 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 submittedAt = DateTimeOffset.UtcNow; var durationSeconds = Math.Max(0, (int)(submittedAt - session.StartedAt).TotalSeconds); var wrongQuestionIds = questionIds .Where(questionId => latestAnswers.TryGetValue(questionId, out var answer) && answer.IsCorrect != true) .ToArray(); var questionResults = questionIds .Select(questionId => { latestAnswers.TryGetValue(questionId, out var answer); return new { questionId, answered = answer is not null, isCorrect = answer?.IsCorrect, score = answer?.IsCorrect == true ? scorePerQuestion : 0, totalScore = scorePerQuestion, answeredAt = answer?.AnsweredAt }; }) .ToArray(); var sectionStats = new[] { new { key = "default", title = "默认", questionCount = totalQuestions, answeredCount, correctCount, wrongCount, unansweredCount, score, totalScore, accuracy, 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, SectionStats = JsonSerializer.SerializeToElement(sectionStats), QuestionResults = JsonSerializer.SerializeToElement(questionResults), WrongQuestionIds = JsonSerializer.SerializeToElement(wrongQuestionIds), Metadata = JsonSerializer.SerializeToElement(new { scoringVersion = 1, scorePerQuestion }) }; 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 }); 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) { return new AnswerRecordItem( record.Id, record.QuestionId!.Value, record.QuestionVersionId, record.PracticeSessionId, record.SelectedOptions, record.AnswerText, record.IsCorrect, 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.QuestionIds, item.QuestionCount, item.DurationMinutes, item.TotalScore, item.AccessMode, item.AccessEntitlementId, item.ConsumedFreeQuota, item.AccessSnapshot, item.StartedAt, item.FinishedAt, item.ExpiresAt, item.Metadata, PracticeSessionStatus(item, DateTimeOffset.UtcNow)); } 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.SectionStats, item.QuestionResults, item.WrongQuestionIds, item.Metadata); } private static List 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 string PracticeSessionStatus(PracticeSession session, DateTimeOffset now) { if (session.FinishedAt.HasValue) { return "finished"; } if (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); } 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);