478 lines
16 KiB
C#
478 lines
16 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using System.Text.Json;
|
|
using Tiku.Application.Learning;
|
|
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<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<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<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);
|
|
}
|
|
|
|
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 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);
|
|
}
|
|
}
|
|
|
|
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);
|