Files
tiku-backend.net/Tiku.Infrastructure/Learning/LearningActivityService.cs

1353 lines
51 KiB
C#

using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using Tiku.Application.Learning;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
using Tiku.Domain.Learning;
using Tiku.Domain.QuestionBanks;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Learning;
public sealed class LearningActivityService(TikuDbContext dbContext) : ILearningActivityService
{
private const int DefaultLimit = 100;
private const int MaxLimit = 500;
public async Task<LearningStatsItem> GetStatsAsync(
LearningActor actor,
CancellationToken cancellationToken = default)
{
var answers = dbContext.AnswerRecords.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId);
return new LearningStatsItem(
await answers.CountAsync(cancellationToken),
await answers.CountAsync(item => item.IsCorrect == true, cancellationToken),
await dbContext.WrongQuestions.AsNoTracking().CountAsync(
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId && item.ResolvedAt == null,
cancellationToken),
await dbContext.FavoriteQuestions.AsNoTracking().CountAsync(
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
cancellationToken),
await dbContext.UserWordFavorites.AsNoTracking().CountAsync(
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
cancellationToken),
await dbContext.UserWordProgress.AsNoTracking().CountAsync(
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
cancellationToken),
await dbContext.PracticeSessions.AsNoTracking().CountAsync(
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
cancellationToken),
await dbContext.PracticeSessionReports.AsNoTracking().CountAsync(
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
cancellationToken));
}
public async Task<LearningList<LearningTrendItem>> GetTrendAsync(
LearningActor actor,
LearningLimitFilter filter,
CancellationToken cancellationToken = default)
{
var since = DateTimeOffset.UtcNow.AddDays(-ResolveLimit(filter.Limit));
var rows = await dbContext.AnswerRecords.AsNoTracking()
.Where(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.AnsweredAt >= since)
.Select(item => new { item.AnsweredAt, item.IsCorrect })
.ToArrayAsync(cancellationToken);
var items = rows
.GroupBy(item => DateOnly.FromDateTime(item.AnsweredAt.UtcDateTime))
.OrderBy(group => group.Key)
.Select(group => new LearningTrendItem(
group.Key,
group.Count(),
group.Count(item => item.IsCorrect == true),
group.Count(item => item.IsCorrect == false)))
.ToArray();
return new LearningList<LearningTrendItem>(items);
}
public async Task<LearningLeaderboardResult> GetLeaderboardAsync(
LearningActor actor,
LearningLimitFilter filter,
CancellationToken cancellationToken = default)
{
var rows = await dbContext.AnswerRecords.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId)
.GroupBy(item => item.UserId)
.Select(group => new
{
UserId = group.Key,
AnswerCount = group.Count(),
CorrectCount = group.Count(item => item.IsCorrect == true),
WrongCount = group.Count(item => item.IsCorrect == false)
})
.OrderByDescending(item => item.CorrectCount)
.ThenByDescending(item => item.AnswerCount)
.Take(ResolveLimit(filter.Limit))
.ToArrayAsync(cancellationToken);
var userIds = rows.Select(item => item.UserId).ToArray();
var names = await dbContext.Users.AsNoTracking()
.Where(item => userIds.Contains(item.Id))
.ToDictionaryAsync(item => item.Id, item => item.Name ?? item.Phone, cancellationToken);
var items = rows
.Select(item => new LearningLeaderboardItem(
item.UserId,
names.GetValueOrDefault(item.UserId),
item.AnswerCount,
item.CorrectCount,
item.WrongCount,
item.AnswerCount == 0 ? 0 : decimal.Round((decimal)item.CorrectCount / item.AnswerCount, 4)))
.ToArray();
return new LearningLeaderboardResult(
"correct_count",
"all",
items,
items.FirstOrDefault(item => item.UserId == actor.UserId),
DateTimeOffset.UtcNow);
}
public async Task<AnswerRecordItem> SubmitAnswerAsync(
LearningActor actor,
SubmitAnswerCommand command,
CancellationToken cancellationToken = default)
{
var question = await dbContext.Questions
.AsNoTracking()
.Where(item =>
item.TenantId == actor.TenantId &&
item.Id == command.QuestionId &&
item.Status == QuestionStatus.Published)
.Select(item => new
{
item.Id,
item.CurrentVersionId
})
.SingleOrDefaultAsync(cancellationToken);
if (question is null)
{
throw new LearningResourceNotFoundException("question_not_found", "Question was not found.");
}
if (command.PracticeSessionId.HasValue)
{
var sessionExists = await dbContext.PracticeSessions.AnyAsync(
session =>
session.TenantId == actor.TenantId &&
session.UserId == actor.UserId &&
session.Id == command.PracticeSessionId.Value,
cancellationToken);
if (!sessionExists)
{
throw new LearningResourceNotFoundException("practice_session_not_found", "Practice session was not found.");
}
}
var now = DateTimeOffset.UtcNow;
var record = new AnswerRecord
{
TenantId = actor.TenantId,
UserId = actor.UserId,
QuestionId = question.Id,
QuestionVersionId = question.CurrentVersionId,
PracticeSessionId = command.PracticeSessionId,
SelectedOptions = JsonSerializer.SerializeToElement(command.SelectedOptions ?? []),
AnswerText = command.AnswerText,
IsCorrect = command.SelfJudgedCorrect,
AnsweredAt = now,
CreatedAt = now
};
dbContext.AnswerRecords.Add(record);
if (command.SelfJudgedCorrect == false)
{
var wrongQuestion = await dbContext.WrongQuestions.FindAsync(
[actor.TenantId, actor.UserId, question.Id],
cancellationToken);
if (wrongQuestion is null)
{
dbContext.WrongQuestions.Add(new WrongQuestion
{
TenantId = actor.TenantId,
UserId = actor.UserId,
QuestionId = question.Id,
WrongCount = 1,
LastWrongAt = now
});
}
else
{
wrongQuestion.WrongCount++;
wrongQuestion.LastWrongAt = now;
wrongQuestion.ResolvedAt = null;
}
}
await dbContext.SaveChangesAsync(cancellationToken);
return ToItem(record);
}
public async Task<LearningList<FavoriteQuestionItem>> GetFavoriteQuestionsAsync(
LearningActor actor,
LearningLimitFilter filter,
CancellationToken cancellationToken = default)
{
var items = await dbContext.FavoriteQuestions
.AsNoTracking()
.Where(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId)
.OrderByDescending(item => item.CreatedAt)
.Take(ResolveLimit(filter.Limit))
.Select(item => new FavoriteQuestionItem(
item.QuestionId,
item.Source,
item.CreatedAt))
.ToArrayAsync(cancellationToken);
return new LearningList<FavoriteQuestionItem>(items);
}
public async Task<LearningActionResult> ToggleFavoriteQuestionAsync(
LearningActor actor,
QuestionActionCommand command,
CancellationToken cancellationToken = default)
{
await EnsureQuestionExistsAsync(actor.TenantId, command.QuestionId, cancellationToken);
var favorite = command.Favorite ?? true;
var item = await dbContext.FavoriteQuestions.FindAsync(
[actor.TenantId, actor.UserId, command.QuestionId],
cancellationToken);
if (favorite)
{
if (item is null)
{
dbContext.FavoriteQuestions.Add(new FavoriteQuestion
{
TenantId = actor.TenantId,
UserId = actor.UserId,
QuestionId = command.QuestionId,
Source = "api",
CreatedAt = DateTimeOffset.UtcNow
});
}
}
else if (item is not null)
{
dbContext.FavoriteQuestions.Remove(item);
}
await dbContext.SaveChangesAsync(cancellationToken);
return new LearningActionResult(true, favorite);
}
public async Task<LearningList<WrongQuestionItem>> GetWrongQuestionsAsync(
LearningActor actor,
LearningLimitFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.WrongQuestions
.AsNoTracking()
.Where(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId);
if (!string.Equals(filter.Status, "all", StringComparison.OrdinalIgnoreCase))
{
query = query.Where(item => item.ResolvedAt == null);
}
var items = await query
.OrderByDescending(item => item.LastWrongAt)
.Take(ResolveLimit(filter.Limit))
.Select(item => new WrongQuestionItem(
item.QuestionId,
item.WrongCount,
item.LastWrongAt,
item.ResolvedAt))
.ToArrayAsync(cancellationToken);
return new LearningList<WrongQuestionItem>(items);
}
public async Task<LearningActionResult> ResolveWrongQuestionAsync(
LearningActor actor,
QuestionActionCommand command,
CancellationToken cancellationToken = default)
{
var item = await dbContext.WrongQuestions.FindAsync(
[actor.TenantId, actor.UserId, command.QuestionId],
cancellationToken);
if (item is null)
{
throw new LearningResourceNotFoundException("wrong_question_not_found", "Wrong question was not found.");
}
item.ResolvedAt = DateTimeOffset.UtcNow;
await dbContext.SaveChangesAsync(cancellationToken);
return new LearningActionResult(true);
}
public async Task<WrongQuestionReviewPlan> GetWrongQuestionReviewPlanAsync(
LearningActor actor,
LearningLimitFilter filter,
CancellationToken cancellationToken = default)
{
var items = await dbContext.WrongQuestions.AsNoTracking()
.Where(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.ResolvedAt == null)
.OrderByDescending(item => item.WrongCount)
.ThenBy(item => item.LastWrongAt)
.Take(ResolveLimit(filter.Limit))
.Select(item => new WrongQuestionReviewPlanItem(item.QuestionId, item.WrongCount, item.LastWrongAt))
.ToArrayAsync(cancellationToken);
return new WrongQuestionReviewPlan(
items,
JsonSerializer.SerializeToElement(new
{
mode = "wrong_review",
questionCount = items.Length,
recommendedEndpoint = "/api/learning/practice-sessions"
}));
}
public async Task<LearningList<WordProgressItem>> GetWordProgressAsync(
LearningActor actor,
LearningLimitFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.UserWordProgress
.AsNoTracking()
.Where(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId);
if (filter.UnitId.HasValue)
{
query = query.Where(item => dbContext.VocabularyWords.Any(word =>
word.TenantId == actor.TenantId &&
word.Id == item.WordId &&
word.UnitId == filter.UnitId.Value));
}
if (TryParseWordProgressStatus(filter.Status, out var status))
{
query = query.Where(item => item.Status == status);
}
var items = await query
.OrderBy(item => item.NextReviewAt == null)
.ThenBy(item => item.NextReviewAt)
.ThenByDescending(item => item.UpdatedAt)
.Take(ResolveLimit(filter.Limit))
.Select(item => new WordProgressItem(
item.WordId,
item.Status,
item.CorrectCount,
item.WrongCount,
item.LastReviewAt,
item.NextReviewAt,
item.ReviewCount,
item.CorrectStreak,
item.LastResult,
item.DueLevel,
item.Metadata))
.ToArrayAsync(cancellationToken);
return new LearningList<WordProgressItem>(items);
}
public async Task<WordProgressItem> UpdateWordProgressAsync(
LearningActor actor,
WordProgressCommand command,
CancellationToken cancellationToken = default)
{
await EnsureWordExistsAsync(actor.TenantId, command.WordId, cancellationToken);
var now = DateTimeOffset.UtcNow;
var item = await dbContext.UserWordProgress
.SingleOrDefaultAsync(
progress =>
progress.TenantId == actor.TenantId &&
progress.UserId == actor.UserId &&
progress.WordId == command.WordId,
cancellationToken);
if (item is null)
{
item = new UserWordProgress
{
TenantId = actor.TenantId,
UserId = actor.UserId,
WordId = command.WordId
};
dbContext.UserWordProgress.Add(item);
}
if (TryParseWordProgressStatus(command.Status, out var status))
{
item.Status = status;
}
else if (string.IsNullOrWhiteSpace(command.Status))
{
item.Status = WordProgressStatus.Learning;
}
else
{
throw new LearningValidationException("invalid_word_status", "Word progress status is invalid.");
}
var correctDelta = command.CorrectDelta ?? 0;
var wrongDelta = command.WrongDelta ?? 0;
item.CorrectCount += correctDelta;
item.WrongCount += wrongDelta;
item.ReviewCount += correctDelta + wrongDelta;
item.CorrectStreak = wrongDelta > 0
? 0
: item.CorrectStreak + correctDelta;
item.LastResult = wrongDelta > 0
? WordReviewResult.Wrong
: correctDelta > 0
? WordReviewResult.Correct
: item.LastResult;
item.LastReviewAt = correctDelta + wrongDelta > 0 ? now : item.LastReviewAt;
item.NextReviewAt = command.NextReviewAt ?? item.NextReviewAt;
item.DueLevel = item.Status == WordProgressStatus.Mastered
? WordDueLevel.Mastered
: item.WrongCount > 0 && item.CorrectStreak == 0
? WordDueLevel.Again
: item.DueLevel;
await dbContext.SaveChangesAsync(cancellationToken);
return ToItem(item);
}
public async Task<WordReviewPlan> GetWordReviewPlanAsync(
LearningActor actor,
LearningLimitFilter filter,
CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var query = dbContext.UserWordProgress.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId);
if (filter.UnitId.HasValue)
{
query = query.Where(item => dbContext.VocabularyWords.Any(word =>
word.TenantId == actor.TenantId &&
word.Id == item.WordId &&
word.UnitId == filter.UnitId.Value));
}
var items = await query
.Where(item => item.NextReviewAt == null || item.NextReviewAt <= now)
.OrderBy(item => item.NextReviewAt == null)
.ThenBy(item => item.NextReviewAt)
.ThenByDescending(item => item.WrongCount)
.Take(ResolveLimit(filter.Limit))
.Select(item => new WordReviewPlanItem(
item.WordId,
item.Status,
item.NextReviewAt,
item.CorrectCount,
item.WrongCount,
item.DueLevel))
.ToArrayAsync(cancellationToken);
return new WordReviewPlan(
items,
JsonSerializer.SerializeToElement(new
{
mode = "word_review",
wordCount = items.Length
}));
}
public Task<WordProgressItem> ReviewWordAsync(
LearningActor actor,
WordReviewCommand command,
CancellationToken cancellationToken = default)
{
var correct = string.Equals(command.Result, "correct", StringComparison.OrdinalIgnoreCase) ||
string.Equals(command.Result, "known", StringComparison.OrdinalIgnoreCase);
return UpdateWordProgressAsync(
actor,
new WordProgressCommand(
command.WordId,
correct ? "Reviewing" : "Learning",
correct ? 1 : 0,
correct ? 0 : 1,
command.NextReviewAt ?? DateTimeOffset.UtcNow.AddDays(correct ? 2 : 1)),
cancellationToken);
}
public async Task<WordStatsItem> GetWordStatsAsync(
LearningActor actor,
LearningLimitFilter filter,
CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var query = dbContext.UserWordProgress.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId);
if (filter.UnitId.HasValue)
{
query = query.Where(item => dbContext.VocabularyWords.Any(word =>
word.TenantId == actor.TenantId &&
word.Id == item.WordId &&
word.UnitId == filter.UnitId.Value));
}
return new WordStatsItem(
await query.CountAsync(cancellationToken),
await query.CountAsync(item => item.Status == WordProgressStatus.New, cancellationToken),
await query.CountAsync(item => item.Status == WordProgressStatus.Learning, cancellationToken),
await query.CountAsync(item => item.Status == WordProgressStatus.Reviewing, cancellationToken),
await query.CountAsync(item => item.Status == WordProgressStatus.Mastered, cancellationToken),
await query.CountAsync(item => item.NextReviewAt == null || item.NextReviewAt <= now, cancellationToken),
await dbContext.UserWordFavorites.AsNoTracking().CountAsync(
item => item.TenantId == actor.TenantId && item.UserId == actor.UserId,
cancellationToken));
}
public async Task<LearningList<FavoriteWordItem>> GetFavoriteWordsAsync(
LearningActor actor,
LearningLimitFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.UserWordFavorites
.AsNoTracking()
.Where(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId);
if (filter.UnitId.HasValue)
{
query = query.Where(item => dbContext.VocabularyWords.Any(word =>
word.TenantId == actor.TenantId &&
word.Id == item.WordId &&
word.UnitId == filter.UnitId.Value));
}
var items = await query
.OrderByDescending(item => item.FavoritedAt ?? item.CreatedAt)
.Take(ResolveLimit(filter.Limit))
.Select(item => new FavoriteWordItem(
item.WordId,
item.Note,
item.FavoritedAt))
.ToArrayAsync(cancellationToken);
return new LearningList<FavoriteWordItem>(items);
}
public async Task<LearningActionResult> ToggleFavoriteWordAsync(
LearningActor actor,
FavoriteWordCommand command,
CancellationToken cancellationToken = default)
{
await EnsureWordExistsAsync(actor.TenantId, command.WordId, cancellationToken);
var favorite = command.Favorite ?? true;
var item = await dbContext.UserWordFavorites
.SingleOrDefaultAsync(
favoriteWord =>
favoriteWord.TenantId == actor.TenantId &&
favoriteWord.UserId == actor.UserId &&
favoriteWord.WordId == command.WordId,
cancellationToken);
if (favorite)
{
if (item is null)
{
item = new UserWordFavorite
{
TenantId = actor.TenantId,
UserId = actor.UserId,
WordId = command.WordId
};
dbContext.UserWordFavorites.Add(item);
}
item.Note = command.Note ?? item.Note;
item.FavoritedAt ??= DateTimeOffset.UtcNow;
}
else if (item is not null)
{
dbContext.UserWordFavorites.Remove(item);
}
await dbContext.SaveChangesAsync(cancellationToken);
return new LearningActionResult(true, favorite);
}
public async Task<PracticeSessionItem> CreatePracticeSessionAsync(
LearningActor actor,
PracticeSessionCommand command,
CancellationToken cancellationToken = default)
{
var assembly = await BuildPracticeAssemblyAsync(actor.TenantId, command, cancellationToken);
var questionIds = await CollectQuestionIdsAsync(actor, assembly, cancellationToken);
if (questionIds.Count == 0)
{
throw new LearningValidationException("no_practice_questions", "No published questions are available for this practice target.");
}
var now = DateTimeOffset.UtcNow;
var session = new PracticeSession
{
TenantId = actor.TenantId,
UserId = actor.UserId,
Mode = assembly.Mode,
TargetType = assembly.TargetType,
TargetId = assembly.TargetId,
BlueprintId = assembly.BlueprintId,
CollectionId = assembly.CollectionId,
EntryId = assembly.EntryId,
ContentNodeId = assembly.ContentNodeId,
QuestionIds = JsonSerializer.SerializeToElement(questionIds),
QuestionCount = questionIds.Count,
DurationMinutes = assembly.DurationMinutes,
TotalScore = assembly.TotalScore,
ExpiresAt = assembly.DurationMinutes.HasValue
? now.AddMinutes(assembly.DurationMinutes.Value)
: null,
AccessMode = PracticeAccessMode.Free,
ConsumedFreeQuota = questionIds.Count,
AccessSnapshot = JsonSerializer.SerializeToElement(new
{
strategy = "v1_free",
requestedCount = assembly.QuestionLimit,
grantedCount = questionIds.Count
}),
Metadata = command.Metadata.ValueKind is JsonValueKind.Undefined
? JsonDefaults.Object()
: command.Metadata
};
dbContext.PracticeSessions.Add(session);
dbContext.PracticeAccessEvents.Add(new PracticeAccessEvent
{
TenantId = actor.TenantId,
UserId = actor.UserId,
PracticeSessionId = session.Id,
EventType = PracticeAccessEventType.SessionCreated,
AccessMode = PracticeAccessEventMode.Free,
RequestedCount = assembly.QuestionLimit,
GrantedCount = questionIds.Count,
ConsumedFreeQuota = questionIds.Count,
Metadata = session.AccessSnapshot
});
await dbContext.SaveChangesAsync(cancellationToken);
return ToItem(session);
}
public async Task<PracticeSessionDetailItem> GetPracticeSessionDetailAsync(
LearningActor actor,
PracticeSessionFilter filter,
CancellationToken cancellationToken = default)
{
var session = await GetPracticeSessionAsync(actor, filter.PracticeSessionId, cancellationToken);
var questionIds = ReadGuidArray(session.QuestionIds);
var questions = await dbContext.Questions
.AsNoTracking()
.Where(question =>
question.TenantId == actor.TenantId &&
questionIds.Contains(question.Id))
.GroupJoin(
dbContext.QuestionVersions.AsNoTracking(),
question => new { question.TenantId, QuestionId = question.Id, VersionId = question.CurrentVersionId },
version => new { version.TenantId, version.QuestionId, VersionId = (Guid?)version.Id },
(question, versions) => new { question, version = versions.FirstOrDefault() })
.Select(row => new PracticeSessionQuestionItem(
row.question.Id,
row.question.Type,
row.question.TypeLabel,
row.question.Difficulty,
row.question.Tags,
row.version == null ? null : row.version.Id,
row.version == null ? null : row.version.Content,
row.version == null ? JsonDefaults.Array() : row.version.Options,
row.version == null ? null : row.version.Explanation))
.ToArrayAsync(cancellationToken);
var questionById = questions.ToDictionary(question => question.Id);
var orderedQuestions = questionIds
.Where(questionById.ContainsKey)
.Select(questionId => questionById[questionId])
.ToArray();
var answers = await dbContext.AnswerRecords
.AsNoTracking()
.Where(answer =>
answer.TenantId == actor.TenantId &&
answer.UserId == actor.UserId &&
answer.PracticeSessionId == session.Id &&
answer.QuestionId != null)
.ToArrayAsync(cancellationToken);
var answersByQuestion = answers
.GroupBy(answer => answer.QuestionId!.Value)
.ToDictionary(
group => group.Key,
group => ToItem(group.OrderByDescending(answer => answer.AnsweredAt).First()));
return new PracticeSessionDetailItem(ToItem(session), orderedQuestions, answersByQuestion);
}
public async Task<PracticeSessionReportItem> SubmitPracticeSessionAsync(
LearningActor actor,
PracticeSessionFilter filter,
CancellationToken cancellationToken = default)
{
var session = await GetPracticeSessionAsync(actor, filter.PracticeSessionId, cancellationToken);
var existing = await dbContext.PracticeSessionReports
.AsNoTracking()
.SingleOrDefaultAsync(
report =>
report.TenantId == actor.TenantId &&
report.PracticeSessionId == session.Id,
cancellationToken);
if (existing is not null)
{
return ToItem(existing);
}
var report = await BuildPracticeSessionReportAsync(actor, session, cancellationToken);
session.FinishedAt ??= report.SubmittedAt;
await dbContext.SaveChangesAsync(cancellationToken);
return ToItem(report);
}
public async Task<PracticeSessionReportItem> GetPracticeSessionReportAsync(
LearningActor actor,
PracticeSessionFilter filter,
CancellationToken cancellationToken = default)
{
if (!filter.PracticeSessionId.HasValue)
{
throw new LearningValidationException("practice_session_id_required", "Practice session id is required.");
}
var report = await dbContext.PracticeSessionReports
.AsNoTracking()
.SingleOrDefaultAsync(
item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.PracticeSessionId == filter.PracticeSessionId.Value,
cancellationToken);
if (report is null)
{
throw new LearningResourceNotFoundException("practice_report_not_found", "Practice session report was not found.");
}
return ToItem(report);
}
public async Task<LearningList<PracticeSessionReportItem>> GetPracticeReportsAsync(
LearningActor actor,
PracticeSessionFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.PracticeSessionReports
.AsNoTracking()
.Where(report =>
report.TenantId == actor.TenantId &&
report.UserId == actor.UserId);
if (filter.BlueprintId.HasValue)
{
query = query.Where(report => report.BlueprintId == filter.BlueprintId.Value);
}
if (!string.IsNullOrWhiteSpace(filter.Mode))
{
query = query.Where(report => report.Mode == filter.Mode.Trim());
}
var items = await query
.OrderByDescending(report => report.SubmittedAt)
.Take(ResolveLimit(filter.Limit))
.ToArrayAsync(cancellationToken);
return new LearningList<PracticeSessionReportItem>(items.Select(ToItem).ToArray());
}
public async Task<LearningList<PracticeHistoryItem>> GetPracticeHistoryAsync(
LearningActor actor,
PracticeSessionFilter filter,
CancellationToken cancellationToken = default)
{
var query = dbContext.PracticeSessions
.AsNoTracking()
.Where(session =>
session.TenantId == actor.TenantId &&
session.UserId == actor.UserId);
if (!string.IsNullOrWhiteSpace(filter.Mode))
{
query = query.Where(session => session.Mode == filter.Mode.Trim());
}
var rows = await query
.GroupJoin(
dbContext.PracticeSessionReports.AsNoTracking(),
session => new { session.TenantId, PracticeSessionId = session.Id },
report => new { report.TenantId, report.PracticeSessionId },
(session, reports) => new { session, report = reports.FirstOrDefault() })
.OrderByDescending(row => row.session.FinishedAt ?? row.session.StartedAt)
.Take(ResolveLimit(filter.Limit))
.ToArrayAsync(cancellationToken);
var now = DateTimeOffset.UtcNow;
var items = rows
.Select(row => new PracticeHistoryItem(
row.session.Id,
row.session.Mode,
row.session.TargetType,
row.session.TargetId,
row.session.BlueprintId,
row.session.CollectionId,
row.session.EntryId,
row.session.ContentNodeId,
row.session.QuestionCount,
row.report?.AnsweredCount ?? 0,
row.report?.CorrectCount ?? 0,
row.report?.WrongCount ?? 0,
row.session.StartedAt,
row.session.FinishedAt,
row.session.ExpiresAt,
PracticeSessionStatus(row.session, now),
row.report?.Id,
row.report?.Score,
row.report?.TotalScore,
row.report?.Accuracy))
.Where(item => string.IsNullOrWhiteSpace(filter.Status) || item.Status == filter.Status.Trim())
.ToArray();
return new LearningList<PracticeHistoryItem>(items);
}
private 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 dbContext.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
};
}
private async Task<List<Guid>> CollectQuestionIdsAsync(
LearningActor actor,
PracticeAssembly assembly,
CancellationToken cancellationToken)
{
if (assembly.Mode == "wrong_review")
{
return await dbContext.WrongQuestions
.AsNoTracking()
.Where(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.ResolvedAt == null)
.Join(
dbContext.Questions.AsNoTracking(),
item => new { item.TenantId, item.QuestionId },
question => new { question.TenantId, QuestionId = question.Id },
(item, question) => new { item, question })
.Where(row => row.question.Status == QuestionStatus.Published)
.OrderByDescending(row => row.item.WrongCount)
.ThenBy(row => row.item.LastWrongAt)
.Take(assembly.QuestionLimit)
.Select(row => row.question.Id)
.ToListAsync(cancellationToken);
}
if (assembly.Mode == "favorite_review")
{
return await dbContext.FavoriteQuestions
.AsNoTracking()
.Where(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId)
.Join(
dbContext.Questions.AsNoTracking(),
item => new { item.TenantId, item.QuestionId },
question => new { question.TenantId, QuestionId = question.Id },
(item, question) => new { item, question })
.Where(row => row.question.Status == QuestionStatus.Published)
.OrderByDescending(row => row.item.CreatedAt)
.Take(assembly.QuestionLimit)
.Select(row => row.question.Id)
.ToListAsync(cancellationToken);
}
if (assembly.CollectionId.HasValue)
{
return await dbContext.QuestionCollectionItems
.AsNoTracking()
.Where(item =>
item.TenantId == actor.TenantId &&
item.CollectionId == assembly.CollectionId.Value)
.Join(
dbContext.Questions.AsNoTracking(),
item => new { item.TenantId, item.QuestionId },
question => new { question.TenantId, QuestionId = question.Id },
(item, question) => new { item, question })
.Where(row => row.question.Status == QuestionStatus.Published)
.OrderBy(row => row.item.SortOrder)
.ThenBy(row => row.question.CreatedAt)
.Take(assembly.QuestionLimit)
.Select(row => row.question.Id)
.ToListAsync(cancellationToken);
}
var query = dbContext.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.");
}
return await query
.OrderBy(question => question.CreatedAt)
.Take(assembly.QuestionLimit)
.Select(question => question.Id)
.ToListAsync(cancellationToken);
}
private 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)
};
}
private 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 dbContext.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;
}
private async Task<PracticeSessionReport> BuildPracticeSessionReportAsync(
LearningActor actor,
PracticeSession session,
CancellationToken cancellationToken)
{
var questionIds = ReadGuidArray(session.QuestionIds);
if (questionIds.Count == 0)
{
throw new LearningValidationException("practice_session_empty", "Practice session has no question snapshot.");
}
var answers = await dbContext.AnswerRecords
.AsNoTracking()
.Where(answer =>
answer.TenantId == actor.TenantId &&
answer.UserId == actor.UserId &&
answer.PracticeSessionId == session.Id &&
answer.QuestionId != null)
.ToArrayAsync(cancellationToken);
var latestAnswers = answers
.GroupBy(answer => answer.QuestionId!.Value)
.ToDictionary(
group => group.Key,
group => group.OrderByDescending(answer => answer.AnsweredAt).First());
var totalQuestions = questionIds.Count;
var answeredCount = questionIds.Count(latestAnswers.ContainsKey);
var correctCount = questionIds.Count(questionId =>
latestAnswers.TryGetValue(questionId, out var answer) &&
answer.IsCorrect == true);
var wrongCount = questionIds.Count(questionId =>
latestAnswers.TryGetValue(questionId, out var answer) &&
answer.IsCorrect != true);
var unansweredCount = Math.Max(0, totalQuestions - answeredCount);
var totalScore = session.TotalScore ?? totalQuestions;
var scorePerQuestion = totalQuestions == 0 ? 0 : totalScore / totalQuestions;
var score = Math.Round(correctCount * scorePerQuestion, 2);
var accuracy = totalQuestions == 0 ? 0 : Math.Round((decimal)correctCount / totalQuestions, 4);
var submittedAt = DateTimeOffset.UtcNow;
var durationSeconds = Math.Max(0, (int)(submittedAt - session.StartedAt).TotalSeconds);
var wrongQuestionIds = questionIds
.Where(questionId =>
latestAnswers.TryGetValue(questionId, out var answer) &&
answer.IsCorrect != true)
.ToArray();
var questionResults = questionIds
.Select(questionId =>
{
latestAnswers.TryGetValue(questionId, out var answer);
return new
{
questionId,
answered = answer is not null,
isCorrect = answer?.IsCorrect,
score = answer?.IsCorrect == true ? scorePerQuestion : 0,
totalScore = scorePerQuestion,
answeredAt = answer?.AnsweredAt
};
})
.ToArray();
var sectionStats = new[]
{
new
{
key = "default",
title = "默认",
questionCount = totalQuestions,
answeredCount,
correctCount,
wrongCount,
unansweredCount,
score,
totalScore,
accuracy,
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,
SectionStats = JsonSerializer.SerializeToElement(sectionStats),
QuestionResults = JsonSerializer.SerializeToElement(questionResults),
WrongQuestionIds = JsonSerializer.SerializeToElement(wrongQuestionIds),
Metadata = JsonSerializer.SerializeToElement(new
{
scoringVersion = 1,
scorePerQuestion
})
};
dbContext.PracticeSessionReports.Add(report);
dbContext.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
});
return report;
}
private async Task EnsureQuestionExistsAsync(
Guid tenantId,
Guid questionId,
CancellationToken cancellationToken)
{
var exists = await dbContext.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.");
}
}
private async Task EnsureWordExistsAsync(
Guid tenantId,
Guid wordId,
CancellationToken cancellationToken)
{
var exists = await dbContext.VocabularyWords.AnyAsync(
word =>
word.TenantId == tenantId &&
word.Id == wordId &&
word.IsActive,
cancellationToken);
if (!exists)
{
throw new LearningResourceNotFoundException("word_not_found", "Word was not found.");
}
}
private static AnswerRecordItem ToItem(AnswerRecord record)
{
return new AnswerRecordItem(
record.Id,
record.QuestionId!.Value,
record.QuestionVersionId,
record.PracticeSessionId,
record.SelectedOptions,
record.AnswerText,
record.IsCorrect,
record.AnsweredAt);
}
private 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);
}
private 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.QuestionIds,
item.QuestionCount,
item.DurationMinutes,
item.TotalScore,
item.AccessMode,
item.AccessEntitlementId,
item.ConsumedFreeQuota,
item.AccessSnapshot,
item.StartedAt,
item.FinishedAt,
item.ExpiresAt,
item.Metadata,
PracticeSessionStatus(item, DateTimeOffset.UtcNow));
}
private 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.SectionStats,
item.QuestionResults,
item.WrongQuestionIds,
item.Metadata);
}
private 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();
}
private static string PracticeSessionStatus(PracticeSession session, DateTimeOffset now)
{
if (session.FinishedAt.HasValue)
{
return "finished";
}
if (session.ExpiresAt.HasValue && session.ExpiresAt.Value <= now)
{
return "expired";
}
return "active";
}
private 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"
};
}
private static bool TryParseWordProgressStatus(string? value, out WordProgressStatus status)
{
return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out status);
}
private static int ResolveLimit(int? limit)
{
return Math.Clamp(limit ?? DefaultLimit, 1, MaxLimit);
}
private static string? NormalizeEnumValue(string? value)
{
return string.IsNullOrWhiteSpace(value)
? null
: value.Replace("_", string.Empty, StringComparison.Ordinal)
.Replace("-", string.Empty, StringComparison.Ordinal);
}
private sealed record PracticeAssembly(
string Mode,
string? TargetType,
Guid? TargetId,
Guid? BlueprintId,
Guid? CollectionId,
Guid? EntryId,
Guid? ContentNodeId,
int QuestionLimit,
int? DurationMinutes,
decimal? TotalScore);
}
public class LearningException(string code, string message) : Exception(message)
{
public string Code { get; } = code;
}
public sealed class LearningResourceNotFoundException(string code, string message) : LearningException(code, message);
public sealed class LearningValidationException(string code, string message) : LearningException(code, message);