diff --git a/Tiku.Api/Contracts/LearningDtos.cs b/Tiku.Api/Contracts/LearningDtos.cs new file mode 100644 index 0000000..e7fc93d --- /dev/null +++ b/Tiku.Api/Contracts/LearningDtos.cs @@ -0,0 +1,101 @@ +using System.ComponentModel.DataAnnotations; +using Tiku.Application.Learning; + +namespace Tiku.Api.Contracts; + +public sealed class LearningLimitQueryDto +{ + [Range(1, 500)] + public int? Limit { get; set; } + + [StringLength(50)] + public string? Status { get; set; } + + public Guid? UnitId { get; set; } + + public LearningLimitFilter ToFilter() + { + return new LearningLimitFilter(Limit, Status, UnitId); + } +} + +public sealed class SubmitAnswerDto +{ + [Required] + public Guid QuestionId { get; set; } + + public Guid? PracticeSessionId { get; set; } + + public IReadOnlyCollection? SelectedOptions { get; set; } + + [StringLength(10000)] + public string? AnswerText { get; set; } + + public bool? SelfJudgedCorrect { get; set; } + + public SubmitAnswerCommand ToCommand() + { + return new SubmitAnswerCommand( + QuestionId, + PracticeSessionId, + SelectedOptions, + AnswerText, + SelfJudgedCorrect); + } +} + +public sealed class QuestionActionDto +{ + [Required] + public Guid QuestionId { get; set; } + + public bool? Favorite { get; set; } + + public QuestionActionCommand ToCommand() + { + return new QuestionActionCommand(QuestionId, Favorite); + } +} + +public sealed class WordProgressDto +{ + [Required] + public Guid WordId { get; set; } + + [StringLength(50)] + public string? Status { get; set; } + + [Range(0, 100)] + public int? CorrectDelta { get; set; } + + [Range(0, 100)] + public int? WrongDelta { get; set; } + + public DateTimeOffset? NextReviewAt { get; set; } + + public WordProgressCommand ToCommand() + { + return new WordProgressCommand( + WordId, + Status, + CorrectDelta, + WrongDelta, + NextReviewAt); + } +} + +public sealed class FavoriteWordDto +{ + [Required] + public Guid WordId { get; set; } + + public bool? Favorite { get; set; } + + [StringLength(1000)] + public string? Note { get; set; } + + public FavoriteWordCommand ToCommand() + { + return new FavoriteWordCommand(WordId, Favorite, Note); + } +} diff --git a/Tiku.Api/Controllers/LearningController.cs b/Tiku.Api/Controllers/LearningController.cs new file mode 100644 index 0000000..d1f0159 --- /dev/null +++ b/Tiku.Api/Controllers/LearningController.cs @@ -0,0 +1,154 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Tiku.Api.Contracts; +using Tiku.Application.Learning; +using Tiku.Application.Security; + +namespace Tiku.Api.Controllers; + +[ApiController] +[Authorize(Policy = TikuPolicies.CurrentTenantMember)] +[Produces("application/json")] +[Route("api/learning")] +public sealed class LearningController( + ILearningActivityService learningActivityService, + ICurrentUser currentUser, + ICurrentTenant currentTenant) : ControllerBase +{ + [HttpPost("answers")] + [EndpointSummary("提交题目答案")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> SubmitAnswer( + SubmitAnswerDto request, + CancellationToken cancellationToken) + { + return Ok(await learningActivityService.SubmitAnswerAsync( + ResolveActor(), + request.ToCommand(), + cancellationToken)); + } + + [HttpGet("favorites/questions")] + [EndpointSummary("查询收藏题目")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetFavoriteQuestions( + [FromQuery] LearningLimitQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await learningActivityService.GetFavoriteQuestionsAsync( + ResolveActor(), + query.ToFilter(), + cancellationToken)); + } + + [HttpPost("favorites/questions")] + [EndpointSummary("收藏或取消收藏题目")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> ToggleFavoriteQuestion( + QuestionActionDto request, + CancellationToken cancellationToken) + { + return Ok(await learningActivityService.ToggleFavoriteQuestionAsync( + ResolveActor(), + request.ToCommand(), + cancellationToken)); + } + + [HttpGet("wrong-questions")] + [EndpointSummary("查询错题列表")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetWrongQuestions( + [FromQuery] LearningLimitQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await learningActivityService.GetWrongQuestionsAsync( + ResolveActor(), + query.ToFilter(), + cancellationToken)); + } + + [HttpPost("wrong-questions/resolve")] + [EndpointSummary("将错题标记为已解决")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> ResolveWrongQuestion( + QuestionActionDto request, + CancellationToken cancellationToken) + { + return Ok(await learningActivityService.ResolveWrongQuestionAsync( + ResolveActor(), + request.ToCommand(), + cancellationToken)); + } + + [HttpGet("vocabulary/progress")] + [EndpointSummary("查询单词学习进度")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetWordProgress( + [FromQuery] LearningLimitQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await learningActivityService.GetWordProgressAsync( + ResolveActor(), + query.ToFilter(), + cancellationToken)); + } + + [HttpPost("vocabulary/progress")] + [EndpointSummary("更新单词学习进度")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> UpdateWordProgress( + WordProgressDto request, + CancellationToken cancellationToken) + { + return Ok(await learningActivityService.UpdateWordProgressAsync( + ResolveActor(), + request.ToCommand(), + cancellationToken)); + } + + [HttpGet("vocabulary/favorites")] + [EndpointSummary("查询收藏单词")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetFavoriteWords( + [FromQuery] LearningLimitQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await learningActivityService.GetFavoriteWordsAsync( + ResolveActor(), + query.ToFilter(), + cancellationToken)); + } + + [HttpPost("vocabulary/favorites")] + [EndpointSummary("收藏或取消收藏单词")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> ToggleFavoriteWord( + FavoriteWordDto request, + CancellationToken cancellationToken) + { + return Ok(await learningActivityService.ToggleFavoriteWordAsync( + ResolveActor(), + request.ToCommand(), + cancellationToken)); + } + + private LearningActor ResolveActor() + { + if (currentTenant.TenantId is null || currentUser.UserId is null) + { + throw new LearningAccessDeniedException(); + } + + return new LearningActor(currentTenant.TenantId.Value, currentUser.UserId.Value); + } +} + +public sealed class LearningAccessDeniedException() + : Exception("Learning actor was not resolved."); diff --git a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs index 2df902d..dfa8be9 100644 --- a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs +++ b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs @@ -4,6 +4,7 @@ using Tiku.Application.Assets; using Tiku.Application.Auth; using Tiku.Application.Storage; using Tiku.Infrastructure.Content; +using Tiku.Infrastructure.Learning; using Tiku.Infrastructure.QuestionBanks; namespace Tiku.Api.Middleware; @@ -87,6 +88,36 @@ public sealed class ExceptionHandlingMiddleware( return; } + if (exception is LearningValidationException learningValidationException) + { + await WriteProblemAsync( + context, + learningValidationException.Message, + StatusCodes.Status400BadRequest, + learningValidationException.Code); + return; + } + + if (exception is LearningResourceNotFoundException learningResourceNotFoundException) + { + await WriteProblemAsync( + context, + learningResourceNotFoundException.Message, + StatusCodes.Status404NotFound, + learningResourceNotFoundException.Code); + return; + } + + if (exception is LearningAccessDeniedException) + { + await WriteProblemAsync( + context, + exception.Message, + StatusCodes.Status403Forbidden, + "learning_access_denied"); + return; + } + if (exception is ObjectStorageException storageException) { await WriteProblemAsync( diff --git a/Tiku.Application/Learning/ILearningActivityService.cs b/Tiku.Application/Learning/ILearningActivityService.cs new file mode 100644 index 0000000..79c3c9f --- /dev/null +++ b/Tiku.Application/Learning/ILearningActivityService.cs @@ -0,0 +1,49 @@ +namespace Tiku.Application.Learning; + +public interface ILearningActivityService +{ + Task SubmitAnswerAsync( + LearningActor actor, + SubmitAnswerCommand command, + CancellationToken cancellationToken = default); + + Task> GetFavoriteQuestionsAsync( + LearningActor actor, + LearningLimitFilter filter, + CancellationToken cancellationToken = default); + + Task ToggleFavoriteQuestionAsync( + LearningActor actor, + QuestionActionCommand command, + CancellationToken cancellationToken = default); + + Task> GetWrongQuestionsAsync( + LearningActor actor, + LearningLimitFilter filter, + CancellationToken cancellationToken = default); + + Task ResolveWrongQuestionAsync( + LearningActor actor, + QuestionActionCommand command, + CancellationToken cancellationToken = default); + + Task> GetWordProgressAsync( + LearningActor actor, + LearningLimitFilter filter, + CancellationToken cancellationToken = default); + + Task UpdateWordProgressAsync( + LearningActor actor, + WordProgressCommand command, + CancellationToken cancellationToken = default); + + Task> GetFavoriteWordsAsync( + LearningActor actor, + LearningLimitFilter filter, + CancellationToken cancellationToken = default); + + Task ToggleFavoriteWordAsync( + LearningActor actor, + FavoriteWordCommand command, + CancellationToken cancellationToken = default); +} diff --git a/Tiku.Application/Learning/LearningActivityModels.cs b/Tiku.Application/Learning/LearningActivityModels.cs new file mode 100644 index 0000000..583e4f1 --- /dev/null +++ b/Tiku.Application/Learning/LearningActivityModels.cs @@ -0,0 +1,69 @@ +using System.Text.Json; +using Tiku.Domain.Content; + +namespace Tiku.Application.Learning; + +public sealed record LearningActor(Guid TenantId, Guid UserId); + +public sealed record LearningList(IReadOnlyCollection Items); + +public sealed record SubmitAnswerCommand( + Guid QuestionId, + Guid? PracticeSessionId, + IReadOnlyCollection? SelectedOptions, + string? AnswerText, + bool? SelfJudgedCorrect); + +public sealed record QuestionActionCommand(Guid QuestionId, bool? Favorite); + +public sealed record WordProgressCommand( + Guid WordId, + string? Status, + int? CorrectDelta, + int? WrongDelta, + DateTimeOffset? NextReviewAt); + +public sealed record FavoriteWordCommand(Guid WordId, bool? Favorite, string? Note); + +public sealed record LearningLimitFilter(int? Limit = null, string? Status = null, Guid? UnitId = null); + +public sealed record AnswerRecordItem( + Guid Id, + Guid QuestionId, + Guid? QuestionVersionId, + Guid? PracticeSessionId, + JsonElement SelectedOptions, + string? AnswerText, + bool? IsCorrect, + DateTimeOffset AnsweredAt); + +public sealed record FavoriteQuestionItem( + Guid QuestionId, + string Source, + DateTimeOffset CreatedAt); + +public sealed record WrongQuestionItem( + Guid QuestionId, + int WrongCount, + DateTimeOffset LastWrongAt, + DateTimeOffset? ResolvedAt); + +public sealed record WordProgressItem( + Guid WordId, + WordProgressStatus Status, + int CorrectCount, + int WrongCount, + DateTimeOffset? LastReviewAt, + DateTimeOffset? NextReviewAt, + int ReviewCount, + int CorrectStreak, + WordReviewResult? LastResult, + WordDueLevel DueLevel, + JsonElement Metadata); + +public sealed record FavoriteWordItem( + Guid WordId, + string? Note, + DateTimeOffset? FavoritedAt); + +public sealed record LearningActionResult(bool Ok, bool? IsFavorite = null); diff --git a/Tiku.Infrastructure/DependencyInjection.cs b/Tiku.Infrastructure/DependencyInjection.cs index d70edeb..029829f 100644 --- a/Tiku.Infrastructure/DependencyInjection.cs +++ b/Tiku.Infrastructure/DependencyInjection.cs @@ -5,6 +5,7 @@ using Tiku.Application.Assets; using Tiku.Application.Auth; using Tiku.Application.Catalog; using Tiku.Application.Content; +using Tiku.Application.Learning; using Tiku.Application.QuestionBanks; using Tiku.Application.Storage; using Tiku.Application.StudyContent; @@ -12,6 +13,7 @@ using Tiku.Infrastructure.Assets; using Tiku.Infrastructure.Auth; using Tiku.Infrastructure.Catalog; using Tiku.Infrastructure.Content; +using Tiku.Infrastructure.Learning; using Tiku.Infrastructure.Persistence; using Tiku.Infrastructure.QuestionBanks; using Tiku.Infrastructure.Storage; @@ -46,6 +48,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddOptions() .Validate( AliyunOssOptions.BeValid, diff --git a/Tiku.Infrastructure/Learning/LearningActivityService.cs b/Tiku.Infrastructure/Learning/LearningActivityService.cs new file mode 100644 index 0000000..ad5bc36 --- /dev/null +++ b/Tiku.Infrastructure/Learning/LearningActivityService.cs @@ -0,0 +1,477 @@ +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 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> 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(items); + } + + public async Task 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> 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(items); + } + + public async Task 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> 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> 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); + } + + 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); diff --git a/Tiku.IntegrationTests/Api/LearningEndpointTests.cs b/Tiku.IntegrationTests/Api/LearningEndpointTests.cs new file mode 100644 index 0000000..6f96d5f --- /dev/null +++ b/Tiku.IntegrationTests/Api/LearningEndpointTests.cs @@ -0,0 +1,274 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Tiku.Api.Contracts; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.Identity; +using Tiku.Domain.QuestionBanks; +using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Auth; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.IntegrationTests.Api; + +public sealed class LearningEndpointTests +{ + [Fact] + public async Task Learning_endpoints_require_current_tenant_member() + { + await using var factory = new ApiTestFactory(); + using var client = factory.CreateClient(); + + using var response = await client.GetAsync("/api/learning/favorites/questions"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Submit_answer_records_wrong_question_when_self_judged_wrong() + { + await using var factory = new ApiTestFactory(); + var seed = await SeedLearningUserAsync(factory); + var questionId = Guid.NewGuid(); + await factory.SeedAsync(new Question + { + Id = questionId, + TenantId = seed.TenantId, + Type = "choice", + Status = QuestionStatus.Published + }); + using var client = factory.CreateClient(); + await LoginAsync(client, seed); + + using var response = await client.PostAsJsonAsync( + "/api/learning/answers", + new SubmitAnswerDto + { + QuestionId = questionId, + SelectedOptions = ["A"], + SelfJudgedCorrect = false + }); + using var wrongResponse = await client.GetAsync("/api/learning/wrong-questions"); + var answer = await ReadJsonAsync(response); + var wrongItems = await ReadItemsAsync(wrongResponse); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(questionId, answer.RootElement.GetProperty("questionId").GetGuid()); + Assert.False(answer.RootElement.GetProperty("isCorrect").GetBoolean()); + var wrong = Assert.Single(wrongItems); + Assert.Equal(questionId, wrong.GetProperty("questionId").GetGuid()); + Assert.Equal(1, wrong.GetProperty("wrongCount").GetInt32()); + } + + [Fact] + public async Task Favorite_question_can_be_added_listed_and_removed() + { + await using var factory = new ApiTestFactory(); + var seed = await SeedLearningUserAsync(factory); + var questionId = Guid.NewGuid(); + await factory.SeedAsync(new Question + { + Id = questionId, + TenantId = seed.TenantId, + Type = "choice", + Status = QuestionStatus.Published + }); + using var client = factory.CreateClient(); + await LoginAsync(client, seed); + + var addResponse = await client.PostAsJsonAsync( + "/api/learning/favorites/questions", + new QuestionActionDto { QuestionId = questionId }); + var listResponse = await client.GetAsync("/api/learning/favorites/questions"); + var itemsAfterAdd = await ReadItemsAsync(listResponse); + var removeResponse = await client.PostAsJsonAsync( + "/api/learning/favorites/questions", + new QuestionActionDto { QuestionId = questionId, Favorite = false }); + var emptyResponse = await client.GetAsync("/api/learning/favorites/questions"); + var itemsAfterRemove = await ReadItemsAsync(emptyResponse); + + Assert.Equal(HttpStatusCode.OK, addResponse.StatusCode); + Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode); + Assert.Equal(questionId, Assert.Single(itemsAfterAdd).GetProperty("questionId").GetGuid()); + Assert.Equal(HttpStatusCode.OK, removeResponse.StatusCode); + Assert.Empty(itemsAfterRemove); + } + + [Fact] + public async Task Word_progress_upserts_and_filters_by_status() + { + await using var factory = new ApiTestFactory(); + var seed = await SeedLearningUserAsync(factory); + var wordId = Guid.NewGuid(); + await factory.SeedAsync(new VocabularyWord + { + Id = wordId, + TenantId = seed.TenantId, + Word = "composition", + IsActive = true + }); + using var client = factory.CreateClient(); + await LoginAsync(client, seed); + + using var updateResponse = await client.PostAsJsonAsync( + "/api/learning/vocabulary/progress", + new WordProgressDto + { + WordId = wordId, + Status = "learning", + CorrectDelta = 2 + }); + using var listResponse = await client.GetAsync("/api/learning/vocabulary/progress?status=learning"); + var progress = await ReadJsonAsync(updateResponse); + var items = await ReadItemsAsync(listResponse); + + Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode); + Assert.Equal(2, progress.RootElement.GetProperty("correctCount").GetInt32()); + var item = Assert.Single(items); + Assert.Equal(wordId, item.GetProperty("wordId").GetGuid()); + Assert.Equal("Learning", item.GetProperty("status").GetString()); + } + + [Fact] + public async Task Favorite_words_can_be_filtered_by_unit() + { + await using var factory = new ApiTestFactory(); + var seed = await SeedLearningUserAsync(factory); + var unitId = Guid.NewGuid(); + var otherUnitId = Guid.NewGuid(); + var wordId = Guid.NewGuid(); + var otherWordId = Guid.NewGuid(); + await factory.SeedAsync( + new VocabularyUnit + { + Id = unitId, + TenantId = seed.TenantId, + Name = "Unit 1", + IsActive = true + }, + new VocabularyUnit + { + Id = otherUnitId, + TenantId = seed.TenantId, + Name = "Unit 2", + IsActive = true + }, + new VocabularyWord + { + Id = wordId, + TenantId = seed.TenantId, + UnitId = unitId, + Word = "design", + IsActive = true + }, + new VocabularyWord + { + Id = otherWordId, + TenantId = seed.TenantId, + UnitId = otherUnitId, + Word = "sketch", + IsActive = true + }); + using var client = factory.CreateClient(); + await LoginAsync(client, seed); + + await client.PostAsJsonAsync( + "/api/learning/vocabulary/favorites", + new FavoriteWordDto { WordId = wordId, Note = "重点" }); + await client.PostAsJsonAsync( + "/api/learning/vocabulary/favorites", + new FavoriteWordDto { WordId = otherWordId }); + using var response = await client.GetAsync($"/api/learning/vocabulary/favorites?unitId={unitId}"); + var items = await ReadItemsAsync(response); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var item = Assert.Single(items); + Assert.Equal(wordId, item.GetProperty("wordId").GetGuid()); + Assert.Equal("重点", item.GetProperty("note").GetString()); + } + + private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedLearningUserAsync( + ApiTestFactory factory) + { + var tenantId = Guid.NewGuid(); + var userId = Guid.NewGuid(); + var phone = "13900000000"; + var passwordHash = new PasswordHasher().Hash("passw0rd!"); + + await factory.SeedAsync( + new Tenant + { + Id = tenantId, + Slug = tenantId.ToString("N"), + Name = "Learning Tenant" + }, + new User + { + Id = userId, + Phone = phone, + Name = "Learning User" + }, + new TenantMembership + { + TenantId = tenantId, + UserId = userId, + Role = TenantRole.Student, + Status = MembershipStatus.Active + }, + new UserIdentity + { + UserId = userId, + Provider = "password", + ProviderSubject = phone, + Phone = phone, + SecretPayload = CreateSecretPayload(passwordHash) + }); + + return (tenantId, userId, phone); + } + + private static async Task LoginAsync( + HttpClient client, + (Guid TenantId, Guid UserId, string Phone) seed) + { + var loginResponse = await client.PostAsJsonAsync( + "/api/auth/login/password", + new PasswordLoginDto + { + TenantId = seed.TenantId, + Phone = seed.Phone, + Password = "passw0rd!" + }); + var loginJson = await ReadJsonAsync(loginResponse); + var accessToken = loginJson.RootElement + .GetProperty("tokens") + .GetProperty("accessToken") + .GetString(); + client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken); + } + + private static async Task ReadJsonAsync(HttpResponseMessage response) + { + var stream = await response.Content.ReadAsStreamAsync(); + return await JsonDocument.ParseAsync(stream); + } + + private static async Task ReadItemsAsync(HttpResponseMessage response) + { + var body = await ReadJsonAsync(response); + return body.RootElement + .GetProperty("items") + .EnumerateArray() + .Select(item => item.Clone()) + .ToArray(); + } + + private static JsonElement CreateSecretPayload(string passwordHash) + { + using var document = JsonDocument.Parse( + $$"""{"passwordHash":{{JsonSerializer.Serialize(passwordHash)}}}"""); + return document.RootElement.Clone(); + } +}