using System.Security.Cryptography; using System.Text; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; 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; internal abstract partial class LearningActivityServiceBase { protected 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 questionBankPersistence.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 }; } protected async Task> CollectQuestionReferenceIdsAsync( LearningActor actor, PracticeAssembly assembly, CancellationToken cancellationToken) { if (assembly.Mode == "wrong_review") return await learningPersistence.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 learningPersistence.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 questionBankPersistence.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 = questionBankPersistence.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 existingReferences = await questionBankPersistence.TenantQuestionReferences .Where(reference => reference.TenantId == actor.TenantId && reference.QuestionOwnerTenantId == actor.TenantId && questionIds.Contains(reference.QuestionId)) .ToDictionaryAsync(reference => reference.QuestionId, cancellationToken); var referenceIds = new List(questionIds.Count); foreach (var questionId in questionIds) { if (!existingReferences.TryGetValue(questionId, out var reference)) { reference = new TenantQuestionReference { TenantId = actor.TenantId, QuestionOwnerTenantId = actor.TenantId, QuestionId = questionId, Source = QuestionSource.Tenant, CreatedBy = actor.UserId }; questionBankPersistence.TenantQuestionReferences.Add(reference); } referenceIds.Add(reference.Id); } return referenceIds; } protected 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) }; } protected async Task> LoadQuestionSelectionsAsync( Guid tenantId, IReadOnlyCollection questionReferenceIds, CancellationToken cancellationToken) { var rows = await tenantExecutionScope.ExecuteAsync( new SystemScopeRequest( tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(LearningActivityServiceBase), "Lock published question versions for a new practice session", Guid.NewGuid().ToString("N")), async (provider, token) => { var systemQuestionBank = provider.GetRequiredService(); var systemLearning = provider.GetRequiredService(); return await ( from reference in systemQuestionBank.TenantQuestionReferences.AsNoTracking() join question in systemQuestionBank.Questions.AsNoTracking() on new { TenantId = reference.QuestionOwnerTenantId, Id = reference.QuestionId } equals new { question.TenantId, question.Id } join version in systemQuestionBank.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, version.QuestionType, version.TypeLabel, version.Difficulty, version.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(); } protected Task LoadSessionQuestionItemsAsync( Guid tenantId, Guid practiceSessionId, CancellationToken cancellationToken) { return tenantExecutionScope.ExecuteAsync( new SystemScopeRequest( tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(LearningActivityServiceBase), "Read locked question versions for a tenant practice session", Guid.NewGuid().ToString("N")), async (provider, token) => { var systemQuestionBank = provider.GetRequiredService(); var systemLearning = provider.GetRequiredService(); return await ( from sessionQuestion in systemLearning.PracticeSessionQuestions.AsNoTracking() join version in systemQuestionBank.QuestionVersions.AsNoTracking() on new { TenantId = sessionQuestion.QuestionOwnerTenantId, Id = sessionQuestion.QuestionVersionId } equals new { version.TenantId, 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), sessionQuestion.QuestionId, version.QuestionType, version.TypeLabel, version.Difficulty, version.Tags, sessionQuestion.QuestionVersionId, version.Content, version.Options)) .ToArrayAsync(token); }, cancellationToken); } protected 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 learningPersistence.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; } protected async Task BuildPracticeSessionReportAsync( LearningActor actor, PracticeSession session, CancellationToken cancellationToken) { var sessionQuestions = await learningPersistence.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 deliveryVersions = await LoadDeliveryVersionsAsync( actor.TenantId, sessionQuestions, cancellationToken); var answers = await learningPersistence.AnswerRecords .AsNoTracking() .Where(answer => answer.TenantId == actor.TenantId && answer.UserId == actor.UserId && answer.PracticeSessionId == session.Id && learningPersistence.CurrentAnswers.Any(current => current.TenantId == answer.TenantId && current.AnswerRecordId == answer.Id)) .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); var deliveryVersion = deliveryVersions[question.QuestionVersionId]; 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 ? deliveryVersion.CorrectOptionIndex : null, correctOptionIndices = isFinal ? deliveryVersion.CorrectOptionIndices : JsonDefaults.Array(), answerText = isFinal ? deliveryVersion.AnswerText : null, explanation = isFinal ? deliveryVersion.Explanation : 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 }) }; learningPersistence.PracticeSessionReports.Add(report); learningPersistence.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 }); learningPersistence.LearningOutboxMessages.Add(new LearningOutboxMessage { TenantId = actor.TenantId, EventType = LearningOutboxEventTypes.PracticeSessionSubmitted, SchemaVersion = 1, AggregateId = report.Id, Payload = JsonSerializer.SerializeToElement(new { reportId = report.Id, practiceSessionId = session.Id, userId = actor.UserId }), OccurredAt = submittedAt, AvailableAt = submittedAt }); return report; } protected async Task EnsureQuestionExistsAsync( Guid tenantId, Guid questionId, CancellationToken cancellationToken) { var exists = await questionBankPersistence.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."); } protected async Task EnsureWordExistsAsync( Guid tenantId, Guid wordId, CancellationToken cancellationToken) { var exists = await contentAssetPersistence.VocabularyWords.AnyAsync( word => word.TenantId == tenantId && word.Id == wordId && word.IsActive, cancellationToken); if (!exists) throw new LearningResourceNotFoundException("word_not_found", "Word was not found."); } protected static AnswerRecordItem ToItem(AnswerRecord record, QuestionSolutionItem? solution = null) { return new AnswerRecordItem( record.Id, record.SessionQuestionId, record.PracticeSessionId, record.SelectedOptions, record.AnswerText, record.GradingStatus == AnswerGradingStatus.PendingReview ? "pending_review" : "accepted", record.Revision, record.ClientSequence, record.AnsweredAt, solution); } protected async Task LoadDeliveryVersionAsync( Guid tenantId, PracticeSessionQuestion sessionQuestion, CancellationToken cancellationToken) { return await tenantExecutionScope.ExecuteAsync( new SystemScopeRequest( tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(LearningActivityServiceBase), "Read immutable question delivery version", Guid.NewGuid().ToString("N")), async (provider, token) => { var questions = provider.GetRequiredService(); return await questions.QuestionVersions.AsNoTracking().SingleAsync(version => version.TenantId == sessionQuestion.QuestionOwnerTenantId && version.QuestionId == sessionQuestion.QuestionId && version.Id == sessionQuestion.QuestionVersionId, token); }, cancellationToken); } protected async Task> LoadDeliveryVersionsAsync( Guid tenantId, IReadOnlyCollection sessionQuestions, CancellationToken cancellationToken) { var versionIds = sessionQuestions.Select(item => item.QuestionVersionId).Distinct().ToArray(); return await tenantExecutionScope.ExecuteAsync( new SystemScopeRequest( tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(LearningActivityServiceBase), "Read immutable question delivery versions", Guid.NewGuid().ToString("N")), async (provider, token) => { var questions = provider.GetRequiredService(); return await questions.QuestionVersions.AsNoTracking() .Where(version => versionIds.Contains(version.Id)) .ToDictionaryAsync(version => version.Id, token); }, cancellationToken); } protected static QuestionSolutionItem ToSolutionItem(QuestionVersion version) { return new QuestionSolutionItem( version.CorrectOptionIndex, version.CorrectOptionIndices, version.AnswerText, version.Explanation); } protected static bool DefersSolutionUntilSubmission(string mode) { var normalized = NormalizeEnumValue(mode); return normalized is "exam" or "mock" or "mockexam" or "paper" or "testpaper"; } protected 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); } protected 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.AccessClassAssignmentId, item.ConsumedFreeQuota, item.AccessGrantVersion, item.StrongRevocationVersion, item.AuthorizationExpiresAt, item.AccessSnapshot, item.StartedAt, item.FinishedAt, item.ExpiresAt, item.Metadata, item.Status, item.Version, item.LastClientSequence); } protected 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); } protected 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(); } protected 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."); } protected static JsonElement BuildGradingRules(QuestionSelection selection) { return JsonSerializer.SerializeToElement(new { version = 1, normalization = selection.QuestionType.Equals("fill_blank", StringComparison.OrdinalIgnoreCase) ? "nfkc_trim_casefold_whitespace" : "exact" }); } protected static string HashAnswer(SubmitAnswerCommand command) { return Hash(JsonSerializer.Serialize(new { command.SessionQuestionId, command.ClientSequence, selectedOptionIndices = command.SelectedOptionIndices?.Distinct().Order().ToArray() ?? [], answerText = command.AnswerText?.Trim() })); } protected static string HashSubmission(SubmitPracticeSessionCommand command) { return Hash(JsonSerializer.Serialize(new { command.PracticeSessionId, command.ExpectedSessionVersion })); } protected static string Hash(string value) { return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); } protected 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"; } protected 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" }; } protected static bool TryParseWordProgressStatus(string? value, out WordProgressStatus status) { return Enum.TryParse(NormalizeEnumValue(value), true, out status); } protected static int ResolveLimit(int? limit) { return Math.Clamp(limit ?? DefaultLimit, 1, MaxLimit); } protected static string? NormalizeEnumValue(string? value) { return string.IsNullOrWhiteSpace(value) ? null : value.Replace("_", string.Empty, StringComparison.Ordinal) .Replace("-", string.Empty, StringComparison.Ordinal); } protected sealed record PracticeAssembly( string Mode, string? TargetType, Guid? TargetId, Guid? BlueprintId, Guid? CollectionId, Guid? EntryId, Guid? ContentNodeId, int QuestionLimit, int? DurationMinutes, decimal? TotalScore); protected 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); }