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

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);
}
}