292 lines
10 KiB
C#
292 lines
10 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using System.Diagnostics.Metrics;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
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;
|
|
using ZLinq;
|
|
|
|
namespace Tiku.Infrastructure.Learning;
|
|
|
|
public sealed partial class LearningActivityService
|
|
{
|
|
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);
|
|
}
|
|
|
|
|
|
}
|