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.Infrastructure.Persistence; namespace Tiku.Infrastructure.Learning; internal abstract partial class LearningActivityServiceBase { protected async Task LoadSessionQuestionItemsAsync( Guid tenantId, Guid practiceSessionId, CancellationToken cancellationToken) { var sessionQuestions = await learningPersistence.PracticeSessionQuestions.AsNoTracking() .Where(item => item.TenantId == tenantId && item.PracticeSessionId == practiceSessionId) .OrderBy(item => item.Position) .ToArrayAsync(cancellationToken); var revisions = new List(sessionQuestions.Length); foreach (var ownerGroup in sessionQuestions.GroupBy(item => item.QuestionOwnerTenantId)) { var revisionIds = ownerGroup.Select(item => item.QuestionRevisionId).Distinct().ToArray(); revisions.AddRange(await tenantExecutionScope.ExecuteAsync( new SystemScopeRequest( ownerGroup.Key, SystemScopeCallerType.PublicQuestionBank, nameof(LearningActivityServiceBase), "Read explicitly locked V2 prompts for a practice session", Guid.NewGuid().ToString("N")), async (provider, token) => { var persistence = provider.GetRequiredService(); return await persistence.QuestionRevisions.AsNoTracking() .Where(item => item.TenantId == ownerGroup.Key && revisionIds.Contains(item.Id)) .ToArrayAsync(token); }, cancellationToken)); } return sessionQuestions.Select(item => { var revision = revisions.Single(version => version.TenantId == item.QuestionOwnerTenantId && version.Id == item.QuestionRevisionId); return new PracticeSessionQuestionItem( item.Id, item.QuestionAssetId, revision.Id, item.QuestionPlacementId, item.AssessmentPolicyVersionId, revision.QuestionType, revision.TypeLabel, item.DifficultySnapshot, item.TagsSnapshot, revision.Id, revision.Content, revision.Options); }).ToArray(); } 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> LoadV2ReportSolutionsAsync( IReadOnlyCollection sessionQuestions, CancellationToken cancellationToken) { if (sessionQuestions.Count == 0) return []; var revisions = new List(); foreach (var ownerGroup in sessionQuestions.GroupBy(item => item.QuestionOwnerTenantId)) { var ownerTenantId = ownerGroup.Key; var revisionIds = ownerGroup.Select(item => item.QuestionRevisionId).Distinct().ToArray(); revisions.AddRange(await tenantExecutionScope.ExecuteAsync( new SystemScopeRequest( ownerTenantId, SystemScopeCallerType.PublicQuestionBank, nameof(LearningActivityServiceBase), "Read explicitly locked V2 solutions for final reporting", Guid.NewGuid().ToString("N")), async (provider, token) => { var persistence = provider.GetRequiredService(); return await persistence.QuestionRevisions.AsNoTracking() .Where(item => item.TenantId == ownerTenantId && revisionIds.Contains(item.Id)) .ToArrayAsync(token); }, cancellationToken)); } return sessionQuestions.ToDictionary( item => item.Id, item => { var revision = revisions.Single(version => version.TenantId == item.QuestionOwnerTenantId && version.Id == item.QuestionRevisionId); return new QuestionSolutionItem( revision.CorrectOptionIndex, revision.CorrectOptionIndices, revision.AnswerText, revision.Explanation); }); } 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 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.Id) .ToArray(); var v2Solutions = isFinal ? await LoadV2ReportSolutionsAsync( sessionQuestions, cancellationToken) : new Dictionary(); var questionResults = sessionQuestions .Select(question => { latestAnswers.TryGetValue(question.Id, out var answer); v2Solutions.TryGetValue(question.Id, out var v2Solution); return new { sessionQuestionId = question.Id, questionAssetId = question.QuestionAssetId, questionRevisionId = question.QuestionRevisionId, questionPlacementId = question.QuestionPlacementId, assessmentPolicyVersionId = question.AssessmentPolicyVersionId, 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 ? v2Solution?.CorrectOptionIndex : null, correctOptionIndices = isFinal ? v2Solution?.CorrectOptionIndices ?? JsonDefaults.Array() : JsonDefaults.Array(), answerText = isFinal ? v2Solution?.AnswerText : null, explanation = isFinal ? v2Solution?.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 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 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 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 })); } 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); } }