using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; 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; namespace Tiku.Infrastructure.Learning; public sealed class LearningActivityService( TikuDbContext dbContext, IQuestionReferenceService questionReferenceService, IPublicQuestionAccessPolicy publicQuestionAccessPolicy, ITenantExecutionScope tenantExecutionScope) : ILearningActivityService { private const int DefaultLimit = 100; private const int MaxLimit = 500; public async Task GetStatsAsync( LearningActor actor, CancellationToken cancellationToken = default) { var answers = dbContext.AnswerRecords.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId); 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, cancellationToken)); } public async Task> 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.AnsweredAt >= since) .Select(item => new { item.AnsweredAt, item.IsCorrect }) .ToArrayAsync(cancellationToken); var items = rows .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(items); } public async Task GetLeaderboardAsync( LearningActor actor, LearningLimitFilter filter, CancellationToken cancellationToken = default) { var rows = await dbContext.AnswerRecords.AsNoTracking() .Where(item => item.TenantId == actor.TenantId) .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 SubmitAnswerAsync( LearningActor actor, SubmitAnswerCommand command, CancellationToken cancellationToken = default) { 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); if (sessionQuestion is null) { throw new LearningResourceNotFoundException( "session_question_not_found", "An active practice session question was not found."); } var record = new AnswerRecord { TenantId = actor.TenantId, UserId = actor.UserId, PracticeSessionId = sessionQuestion.PracticeSessionId, SessionQuestionId = sessionQuestion.Id, 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, sessionQuestion.QuestionReferenceId], cancellationToken); if (wrongQuestion is null) { 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; } } 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.QuestionReferenceId, new QuestionLocator( item.QuestionOwnerTenantId == item.TenantId ? QuestionSource.Tenant : QuestionSource.Platform, item.QuestionId), item.CreatedAt)) .ToArrayAsync(cancellationToken); return new LearningList(items); } public async Task 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> 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(items); } public async Task 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 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> 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 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 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 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> 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 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); await dbContext.SaveChangesAsync(cancellationToken); var selections = await LoadQuestionSelectionsAsync( actor.TenantId, questionReferenceIds, cancellationToken); 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 })); 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 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) .ToArrayAsync(cancellationToken); var answersByQuestion = answers .GroupBy(answer => answer.SessionQuestionId) .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> 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(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 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> LoadQuestionSelectionsAsync( Guid tenantId, IReadOnlyCollection 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(); 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)) .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 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(); 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 select new PracticeSessionQuestionItem( sessionQuestion.Id, sessionQuestion.QuestionReferenceId, new QuestionLocator( sessionQuestion.QuestionOwnerTenantId == tenantId ? QuestionSource.Tenant : QuestionSource.Platform, sessionQuestion.QuestionId), question.Id, question.Type, question.TypeLabel, question.Difficulty, question.Tags, version.Id, version.Content, version.Options, version.Explanation)) .ToArrayAsync(token); }, cancellationToken); } 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 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) .ToArrayAsync(cancellationToken); var latestAnswers = answers .GroupBy(answer => answer.SessionQuestionId) .ToDictionary( group => group.Key, group => group.OrderByDescending(answer => answer.AnsweredAt).First()); 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); var wrongCount = sessionQuestions.Count(question => latestAnswers.TryGetValue(question.Id, 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 = sessionQuestions .Where(question => latestAnswers.TryGetValue(question.Id, out var answer) && answer.IsCorrect != true) .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, 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.SessionQuestionId, 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.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); private sealed record QuestionSelection( Guid QuestionReferenceId, Guid QuestionOwnerTenantId, Guid QuestionId, Guid QuestionVersionId); } 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);