using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Learning; using Tiku.Domain.Content; namespace Tiku.Infrastructure.Learning; public sealed partial class LearningActivityService { public async Task> 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(items); } public async Task 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 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 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 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> 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(items); } public async Task 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); } }