Files
tiku-backend.net/Tiku.Infrastructure/Learning/Foundation/LearningFoundation.Helpers.cs
xiong 33375a38d7
Some checks failed
ci / release-gate (push) Has been cancelled
refactor(architecture): harden module boundaries
2026-08-04 12:10:36 +08:00

709 lines
28 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.Domain.QuestionBanks;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Learning;
internal abstract partial class LearningActivityServiceBase
{
protected async Task<PracticeAssembly> 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<List<Guid>> 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 referenceIds = new List<Guid>(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;
}
protected static IQueryable<Question> ApplyLegacyTargetFilter(
IQueryable<Question> 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<IReadOnlyList<QuestionSelection>> LoadQuestionSelectionsAsync(
Guid tenantId,
IReadOnlyCollection<Guid> 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<IQuestionBankPersistence>();
var systemLearning = provider.GetRequiredService<ILearningPersistence>();
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,
question.Type,
question.TypeLabel,
question.Difficulty,
question.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<PracticeSessionQuestionItem[]> 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<IQuestionBankPersistence>();
var systemLearning = provider.GetRequiredService<ILearningPersistence>();
return await (
from sessionQuestion in systemLearning.PracticeSessionQuestions.AsNoTracking()
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,
sessionQuestion.QuestionType,
sessionQuestion.TypeLabelSnapshot,
sessionQuestion.DifficultySnapshot,
sessionQuestion.TagsSnapshot,
sessionQuestion.QuestionVersionId,
sessionQuestion.ContentSnapshot,
sessionQuestion.OptionsSnapshot))
.ToArrayAsync(token);
},
cancellationToken);
}
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<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 &&
answer.IsCurrent)
.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);
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 ? question.CorrectOptionIndexSnapshot : null,
correctOptionIndices = isFinal ? question.CorrectOptionIndicesSnapshot : JsonDefaults.Array(),
answerText = isFinal ? question.AnswerTextSnapshot : null,
explanation = isFinal ? question.ExplanationSnapshot : 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
});
foreach (var question in sessionQuestions.Where(question =>
latestAnswers.TryGetValue(question.Id, out var answer) &&
answer.GradingStatus == AnswerGradingStatus.Incorrect))
{
var wrongQuestion = await learningPersistence.WrongQuestions.FindAsync(
[actor.TenantId, actor.UserId, question.QuestionReferenceId], cancellationToken);
if (wrongQuestion is null)
{
learningPersistence.WrongQuestions.Add(new WrongQuestion
{
TenantId = actor.TenantId,
UserId = actor.UserId,
QuestionReferenceId = question.QuestionReferenceId,
QuestionOwnerTenantId = question.QuestionOwnerTenantId,
QuestionId = question.QuestionId,
WrongCount = 1,
LastWrongAt = submittedAt
});
}
else
{
wrongQuestion.WrongCount++;
wrongQuestion.LastWrongAt = submittedAt;
wrongQuestion.ResolvedAt = null;
}
}
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, long sessionVersion)
{
return new AnswerRecordItem(
record.Id,
record.SessionQuestionId,
record.PracticeSessionId,
record.SelectedOptions,
record.AnswerText,
record.GradingStatus == AnswerGradingStatus.PendingReview ? "pending_review" : "accepted",
record.Revision,
record.ClientSequence,
sessionVersion,
record.AnsweredAt);
}
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.ConsumedFreeQuota,
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<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.");
if (session.Version != command.ExpectedSessionVersion)
throw new LearningValidationException("practice_session_version_conflict",
"The practice session changed. Reload it before answering.");
if (command.ClientSequence <= session.LastClientSequence)
throw new LearningValidationException("practice_client_sequence_conflict",
"Client sequence must increase within a practice session.");
}
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.ExpectedSessionVersion,
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);
}