Files
tiku-backend.net/Tiku.Infrastructure/Learning/Foundation/LearningFoundation.Helpers.cs

503 lines
20 KiB
C#

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<PracticeSessionQuestionItem[]> 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<QuestionRevision>(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<IQuestionBankPersistence>();
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<PracticeSession> GetPracticeSessionAsync(
LearningActor actor,
Guid? practiceSessionId,
CancellationToken cancellationToken)
{
if (!practiceSessionId.HasValue)
throw new LearningValidationException("practice_session_id_required", "Practice session id is required.");
var session = await 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<Dictionary<Guid, QuestionSolutionItem>> LoadV2ReportSolutionsAsync(
IReadOnlyCollection<PracticeSessionQuestion> sessionQuestions,
CancellationToken cancellationToken)
{
if (sessionQuestions.Count == 0) return [];
var revisions = new List<QuestionRevision>();
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<IQuestionBankPersistence>();
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<PracticeSessionReport> 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<Guid, QuestionSolutionItem>();
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,
ResourceType = session.ResourceType,
ResourceId = session.ResourceId,
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.ResourceType,
item.ResourceId,
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.ResourceType,
item.ResourceId,
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<Guid> ReadGuidArray(JsonElement value)
{
if (value.ValueKind is not JsonValueKind.Array) return [];
return value.EnumerateArray()
.Select(item => item.ValueKind == JsonValueKind.String && Guid.TryParse(item.GetString(), out var id)
? (Guid?)id
: null)
.Where(id => id.HasValue)
.Select(id => id!.Value)
.ToList();
}
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);
}
}