503 lines
20 KiB
C#
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);
|
|
}
|
|
|
|
}
|