diff --git a/Tiku.Api/Configuration/ApiPresentationExtensions.cs b/Tiku.Api/Configuration/ApiPresentationExtensions.cs index 2507a9b..1631c50 100644 --- a/Tiku.Api/Configuration/ApiPresentationExtensions.cs +++ b/Tiku.Api/Configuration/ApiPresentationExtensions.cs @@ -25,6 +25,7 @@ internal static class ApiPresentationExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/Tiku.Api/Controllers/AnsweringController.cs b/Tiku.Api/Controllers/AnsweringController.cs new file mode 100644 index 0000000..d1d825e --- /dev/null +++ b/Tiku.Api/Controllers/AnsweringController.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Tiku.Api.Contracts; +using Tiku.Api.Security; +using Tiku.Application.Learning; +using Tiku.Application.Security; + +namespace Tiku.Api.Controllers; + +[ApiController] +[Tags("学生端-学习")] +[Authorize(Policy = TikuPolicies.CurrentTenantMember)] +[RequireSaasFeature(SaasFeatureCatalog.Practice)] +[Produces("application/json")] +[Route("api/student/learning")] +public sealed class AnsweringController( + IAnsweringService service, + LearningActorResolver actorResolver) : 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 service.SubmitAnswerAsync( + actorResolver.Resolve(), + request.ToCommand(), + cancellationToken)); + } +} diff --git a/Tiku.Api/Controllers/LearningActorResolver.cs b/Tiku.Api/Controllers/LearningActorResolver.cs new file mode 100644 index 0000000..4eec37b --- /dev/null +++ b/Tiku.Api/Controllers/LearningActorResolver.cs @@ -0,0 +1,19 @@ +using Tiku.Application.Learning; +using Tiku.Application.Security; + +namespace Tiku.Api.Controllers; + +public sealed class LearningActorResolver( + ICurrentUser currentUser, + ITenantContext currentTenant) +{ + internal LearningActor Resolve() + { + 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/Controllers/LearningAnalyticsController.cs b/Tiku.Api/Controllers/LearningAnalyticsController.cs new file mode 100644 index 0000000..efb932b --- /dev/null +++ b/Tiku.Api/Controllers/LearningAnalyticsController.cs @@ -0,0 +1,48 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Tiku.Api.Contracts; +using Tiku.Api.Security; +using Tiku.Application.Learning; +using Tiku.Application.Security; + +namespace Tiku.Api.Controllers; + +[ApiController] +[Tags("学生端-学习")] +[Authorize(Policy = TikuPolicies.CurrentTenantMember)] +[RequireSaasFeature(SaasFeatureCatalog.Practice)] +[Produces("application/json")] +[Route("api/student/learning")] +public sealed class LearningAnalyticsController( + ILearningAnalyticsService service, + LearningActorResolver actorResolver) : ControllerBase +{ + [HttpGet("stats")] + [EndpointSummary("查询学习统计")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetStats(CancellationToken cancellationToken) + { + return Ok(await service.GetStatsAsync(actorResolver.Resolve(), cancellationToken)); + } + + [HttpGet("trend")] + [EndpointSummary("查询学习趋势")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetTrend( + [FromQuery] LearningLimitQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await service.GetTrendAsync(actorResolver.Resolve(), query.ToFilter(), cancellationToken)); + } + + [HttpGet("leaderboard")] + [EndpointSummary("查询学习排行榜")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetLeaderboard( + [FromQuery] LearningLimitQueryDto query, + CancellationToken cancellationToken) + { + return Ok( + await service.GetLeaderboardAsync(actorResolver.Resolve(), query.ToFilter(), cancellationToken)); + } +} diff --git a/Tiku.Api/Controllers/LearningController.cs b/Tiku.Api/Controllers/LearningController.cs deleted file mode 100644 index a9e1edb..0000000 --- a/Tiku.Api/Controllers/LearningController.cs +++ /dev/null @@ -1,318 +0,0 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Tiku.Api.Contracts; -using Tiku.Api.Security; -using Tiku.Application.Learning; -using Tiku.Application.Security; - -namespace Tiku.Api.Controllers; - -[ApiController] -[Tags("学生端-学习")] -[Authorize(Policy = TikuPolicies.CurrentTenantMember)] -[RequireSaasFeature(SaasFeatureCatalog.Practice)] -[Produces("application/json")] -[Route("api/student/learning")] -public sealed class LearningController( - ILearningActivityService learningActivityService, - ICurrentUser currentUser, - ITenantContext currentTenant) : ControllerBase -{ - [HttpGet("stats")] - [EndpointSummary("查询学习统计")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> GetStats(CancellationToken cancellationToken) - { - return Ok(await learningActivityService.GetStatsAsync(ResolveActor(), cancellationToken)); - } - - [HttpGet("trend")] - [EndpointSummary("查询学习趋势")] - [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task>> GetTrend( - [FromQuery] LearningLimitQueryDto query, - CancellationToken cancellationToken) - { - return Ok(await learningActivityService.GetTrendAsync(ResolveActor(), query.ToFilter(), cancellationToken)); - } - - [HttpGet("leaderboard")] - [EndpointSummary("查询学习排行榜")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> GetLeaderboard( - [FromQuery] LearningLimitQueryDto query, - CancellationToken cancellationToken) - { - return Ok( - await learningActivityService.GetLeaderboardAsync(ResolveActor(), query.ToFilter(), cancellationToken)); - } - - [HttpPost("practice-sessions")] - [EndpointSummary("创建练习会话")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status409Conflict)] - public async Task> CreatePracticeSession( - CreatePracticeSessionDto request, - CancellationToken cancellationToken) - { - return Ok(await learningActivityService.CreatePracticeSessionAsync( - ResolveActor(), - request.ToCommand(), - cancellationToken)); - } - - [HttpGet("practice-sessions/detail")] - [EndpointSummary("获取练习会话详情")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> GetPracticeSessionDetail( - [FromQuery] PracticeSessionQueryDto query, - CancellationToken cancellationToken) - { - return Ok(await learningActivityService.GetPracticeSessionDetailAsync( - ResolveActor(), - query.ToFilter(), - cancellationToken)); - } - - [HttpPost("practice-sessions/submit")] - [EndpointSummary("提交练习会话并生成报告")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> SubmitPracticeSession( - SubmitPracticeSessionDto request, - CancellationToken cancellationToken) - { - return Ok(await learningActivityService.SubmitPracticeSessionAsync( - ResolveActor(), - request.ToCommand(), - cancellationToken)); - } - - [HttpGet("practice-sessions/report")] - [EndpointSummary("获取练习会话报告")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> GetPracticeSessionReport( - [FromQuery] PracticeSessionQueryDto query, - CancellationToken cancellationToken) - { - return Ok(await learningActivityService.GetPracticeSessionReportAsync( - ResolveActor(), - query.ToFilter(), - cancellationToken)); - } - - [HttpGet("practice-reports")] - [EndpointSummary("查询练习报告列表")] - [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task>> GetPracticeReports( - [FromQuery] PracticeSessionQueryDto query, - CancellationToken cancellationToken) - { - return Ok(await learningActivityService.GetPracticeReportsAsync( - ResolveActor(), - query.ToFilter(), - cancellationToken)); - } - - [HttpGet("practice-sessions/history")] - [EndpointSummary("查询练习历史")] - [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task>> GetPracticeHistory( - [FromQuery] PracticeSessionQueryDto query, - CancellationToken cancellationToken) - { - return Ok(await learningActivityService.GetPracticeHistoryAsync( - ResolveActor(), - query.ToFilter(), - cancellationToken)); - } - - [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)); - } - - [HttpGet("wrong-questions/review-plan")] - [EndpointSummary("生成错题复习计划")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> GetWrongQuestionReviewPlan( - [FromQuery] LearningLimitQueryDto query, - CancellationToken cancellationToken) - { - return Ok(await learningActivityService.GetWrongQuestionReviewPlanAsync( - 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)); - } - - [HttpGet("vocabulary/review-plan")] - [EndpointSummary("生成单词复习计划")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> GetWordReviewPlan( - [FromQuery] LearningLimitQueryDto query, - CancellationToken cancellationToken) - { - return Ok(await learningActivityService.GetWordReviewPlanAsync( - 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)); - } - - [HttpPost("vocabulary/review")] - [EndpointSummary("提交单词复习结果")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task> ReviewWord( - WordReviewDto request, - CancellationToken cancellationToken) - { - return Ok(await learningActivityService.ReviewWordAsync( - ResolveActor(), - request.ToCommand(), - cancellationToken)); - } - - [HttpGet("vocabulary/stats")] - [EndpointSummary("查询单词学习统计")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task> GetWordStats( - [FromQuery] LearningLimitQueryDto query, - CancellationToken cancellationToken) - { - return Ok(await learningActivityService.GetWordStatsAsync( - ResolveActor(), - query.ToFilter(), - 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."); \ No newline at end of file diff --git a/Tiku.Api/Controllers/PracticeReportController.cs b/Tiku.Api/Controllers/PracticeReportController.cs new file mode 100644 index 0000000..69def06 --- /dev/null +++ b/Tiku.Api/Controllers/PracticeReportController.cs @@ -0,0 +1,59 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Tiku.Api.Contracts; +using Tiku.Api.Security; +using Tiku.Application.Learning; +using Tiku.Application.Security; + +namespace Tiku.Api.Controllers; + +[ApiController] +[Tags("学生端-学习")] +[Authorize(Policy = TikuPolicies.CurrentTenantMember)] +[RequireSaasFeature(SaasFeatureCatalog.Practice)] +[Produces("application/json")] +[Route("api/student/learning")] +public sealed class PracticeReportController( + IPracticeReportService service, + LearningActorResolver actorResolver) : ControllerBase +{ + [HttpGet("practice-sessions/report")] + [EndpointSummary("获取练习会话报告")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetPracticeSessionReport( + [FromQuery] PracticeSessionQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await service.GetPracticeSessionReportAsync( + actorResolver.Resolve(), + query.ToFilter(), + cancellationToken)); + } + + [HttpGet("practice-reports")] + [EndpointSummary("查询练习报告列表")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetPracticeReports( + [FromQuery] PracticeSessionQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await service.GetPracticeReportsAsync( + actorResolver.Resolve(), + query.ToFilter(), + cancellationToken)); + } + + [HttpGet("practice-sessions/history")] + [EndpointSummary("查询练习历史")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetPracticeHistory( + [FromQuery] PracticeSessionQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await service.GetPracticeHistoryAsync( + actorResolver.Resolve(), + query.ToFilter(), + cancellationToken)); + } +} diff --git a/Tiku.Api/Controllers/PracticeSessionController.cs b/Tiku.Api/Controllers/PracticeSessionController.cs new file mode 100644 index 0000000..1cb3aae --- /dev/null +++ b/Tiku.Api/Controllers/PracticeSessionController.cs @@ -0,0 +1,61 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Tiku.Api.Contracts; +using Tiku.Api.Security; +using Tiku.Application.Learning; +using Tiku.Application.Security; + +namespace Tiku.Api.Controllers; + +[ApiController] +[Tags("学生端-学习")] +[Authorize(Policy = TikuPolicies.CurrentTenantMember)] +[RequireSaasFeature(SaasFeatureCatalog.Practice)] +[Produces("application/json")] +[Route("api/student/learning")] +public sealed class PracticeSessionController( + IPracticeSessionService service, + LearningActorResolver actorResolver) : ControllerBase +{ + [HttpPost("practice-sessions")] + [EndpointSummary("创建练习会话")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> CreatePracticeSession( + CreatePracticeSessionDto request, + CancellationToken cancellationToken) + { + return Ok(await service.CreatePracticeSessionAsync( + actorResolver.Resolve(), + request.ToCommand(), + cancellationToken)); + } + + [HttpGet("practice-sessions/detail")] + [EndpointSummary("获取练习会话详情")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetPracticeSessionDetail( + [FromQuery] PracticeSessionQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await service.GetPracticeSessionDetailAsync( + actorResolver.Resolve(), + query.ToFilter(), + cancellationToken)); + } + + [HttpPost("practice-sessions/submit")] + [EndpointSummary("提交练习会话并生成报告")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> SubmitPracticeSession( + SubmitPracticeSessionDto request, + CancellationToken cancellationToken) + { + return Ok(await service.SubmitPracticeSessionAsync( + actorResolver.Resolve(), + request.ToCommand(), + cancellationToken)); + } +} diff --git a/Tiku.Api/Controllers/QuestionReviewController.cs b/Tiku.Api/Controllers/QuestionReviewController.cs new file mode 100644 index 0000000..44d51b2 --- /dev/null +++ b/Tiku.Api/Controllers/QuestionReviewController.cs @@ -0,0 +1,86 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Tiku.Api.Contracts; +using Tiku.Api.Security; +using Tiku.Application.Learning; +using Tiku.Application.Security; + +namespace Tiku.Api.Controllers; + +[ApiController] +[Tags("学生端-学习")] +[Authorize(Policy = TikuPolicies.CurrentTenantMember)] +[RequireSaasFeature(SaasFeatureCatalog.Practice)] +[Produces("application/json")] +[Route("api/student/learning")] +public sealed class QuestionReviewController( + IQuestionReviewService service, + LearningActorResolver actorResolver) : ControllerBase +{ + [HttpGet("favorites/questions")] + [EndpointSummary("查询收藏题目")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetFavoriteQuestions( + [FromQuery] LearningLimitQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await service.GetFavoriteQuestionsAsync( + actorResolver.Resolve(), + query.ToFilter(), + cancellationToken)); + } + + [HttpPost("favorites/questions")] + [EndpointSummary("收藏或取消收藏题目")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> ToggleFavoriteQuestion( + QuestionActionDto request, + CancellationToken cancellationToken) + { + return Ok(await service.ToggleFavoriteQuestionAsync( + actorResolver.Resolve(), + request.ToCommand(), + cancellationToken)); + } + + [HttpGet("wrong-questions")] + [EndpointSummary("查询错题列表")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetWrongQuestions( + [FromQuery] LearningLimitQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await service.GetWrongQuestionsAsync( + actorResolver.Resolve(), + query.ToFilter(), + cancellationToken)); + } + + [HttpGet("wrong-questions/review-plan")] + [EndpointSummary("生成错题复习计划")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetWrongQuestionReviewPlan( + [FromQuery] LearningLimitQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await service.GetWrongQuestionReviewPlanAsync( + actorResolver.Resolve(), + 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 service.ResolveWrongQuestionAsync( + actorResolver.Resolve(), + request.ToCommand(), + cancellationToken)); + } +} diff --git a/Tiku.Api/Controllers/WordLearningController.cs b/Tiku.Api/Controllers/WordLearningController.cs new file mode 100644 index 0000000..6fb404c --- /dev/null +++ b/Tiku.Api/Controllers/WordLearningController.cs @@ -0,0 +1,113 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Tiku.Api.Contracts; +using Tiku.Api.Security; +using Tiku.Application.Learning; +using Tiku.Application.Security; + +namespace Tiku.Api.Controllers; + +[ApiController] +[Tags("学生端-学习")] +[Authorize(Policy = TikuPolicies.CurrentTenantMember)] +[RequireSaasFeature(SaasFeatureCatalog.Practice)] +[Produces("application/json")] +[Route("api/student/learning")] +public sealed class WordLearningController( + IWordLearningService service, + LearningActorResolver actorResolver) : ControllerBase +{ + [HttpGet("vocabulary/progress")] + [EndpointSummary("查询单词学习进度")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetWordProgress( + [FromQuery] LearningLimitQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await service.GetWordProgressAsync( + actorResolver.Resolve(), + query.ToFilter(), + cancellationToken)); + } + + [HttpGet("vocabulary/review-plan")] + [EndpointSummary("生成单词复习计划")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetWordReviewPlan( + [FromQuery] LearningLimitQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await service.GetWordReviewPlanAsync( + actorResolver.Resolve(), + query.ToFilter(), + cancellationToken)); + } + + [HttpPost("vocabulary/progress")] + [EndpointSummary("更新单词学习进度")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> UpdateWordProgress( + WordProgressDto request, + CancellationToken cancellationToken) + { + return Ok(await service.UpdateWordProgressAsync( + actorResolver.Resolve(), + request.ToCommand(), + cancellationToken)); + } + + [HttpPost("vocabulary/review")] + [EndpointSummary("提交单词复习结果")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> ReviewWord( + WordReviewDto request, + CancellationToken cancellationToken) + { + return Ok(await service.ReviewWordAsync( + actorResolver.Resolve(), + request.ToCommand(), + cancellationToken)); + } + + [HttpGet("vocabulary/stats")] + [EndpointSummary("查询单词学习统计")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> GetWordStats( + [FromQuery] LearningLimitQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await service.GetWordStatsAsync( + actorResolver.Resolve(), + query.ToFilter(), + cancellationToken)); + } + + [HttpGet("vocabulary/favorites")] + [EndpointSummary("查询收藏单词")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task>> GetFavoriteWords( + [FromQuery] LearningLimitQueryDto query, + CancellationToken cancellationToken) + { + return Ok(await service.GetFavoriteWordsAsync( + actorResolver.Resolve(), + query.ToFilter(), + cancellationToken)); + } + + [HttpPost("vocabulary/favorites")] + [EndpointSummary("收藏或取消收藏单词")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> ToggleFavoriteWord( + FavoriteWordDto request, + CancellationToken cancellationToken) + { + return Ok(await service.ToggleFavoriteWordAsync( + actorResolver.Resolve(), + request.ToCommand(), + cancellationToken)); + } +} diff --git a/Tiku.Application/Learning/ILearningActivityService.cs b/Tiku.Application/Learning/ILearningActivityService.cs deleted file mode 100644 index ed6c980..0000000 --- a/Tiku.Application/Learning/ILearningActivityService.cs +++ /dev/null @@ -1,113 +0,0 @@ -namespace Tiku.Application.Learning; - -public interface ILearningActivityService -{ - Task GetStatsAsync( - LearningActor actor, - CancellationToken cancellationToken = default); - - Task> GetTrendAsync( - LearningActor actor, - LearningLimitFilter filter, - CancellationToken cancellationToken = default); - - Task GetLeaderboardAsync( - LearningActor actor, - LearningLimitFilter filter, - CancellationToken cancellationToken = default); - - 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 GetWrongQuestionReviewPlanAsync( - 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 GetWordReviewPlanAsync( - LearningActor actor, - LearningLimitFilter filter, - CancellationToken cancellationToken = default); - - Task UpdateWordProgressAsync( - LearningActor actor, - WordProgressCommand command, - CancellationToken cancellationToken = default); - - Task ReviewWordAsync( - LearningActor actor, - WordReviewCommand command, - CancellationToken cancellationToken = default); - - Task GetWordStatsAsync( - LearningActor actor, - LearningLimitFilter filter, - CancellationToken cancellationToken = default); - - Task> GetFavoriteWordsAsync( - LearningActor actor, - LearningLimitFilter filter, - CancellationToken cancellationToken = default); - - Task ToggleFavoriteWordAsync( - LearningActor actor, - FavoriteWordCommand command, - CancellationToken cancellationToken = default); - - Task CreatePracticeSessionAsync( - LearningActor actor, - PracticeSessionCommand command, - CancellationToken cancellationToken = default); - - Task GetPracticeSessionDetailAsync( - LearningActor actor, - PracticeSessionFilter filter, - CancellationToken cancellationToken = default); - - Task SubmitPracticeSessionAsync( - LearningActor actor, - SubmitPracticeSessionCommand command, - CancellationToken cancellationToken = default); - - Task GetPracticeSessionReportAsync( - LearningActor actor, - PracticeSessionFilter filter, - CancellationToken cancellationToken = default); - - Task> GetPracticeReportsAsync( - LearningActor actor, - PracticeSessionFilter filter, - CancellationToken cancellationToken = default); - - Task> GetPracticeHistoryAsync( - LearningActor actor, - PracticeSessionFilter filter, - CancellationToken cancellationToken = default); -} \ No newline at end of file diff --git a/Tiku.Application/Learning/LearningServices.cs b/Tiku.Application/Learning/LearningServices.cs new file mode 100644 index 0000000..415e36c --- /dev/null +++ b/Tiku.Application/Learning/LearningServices.cs @@ -0,0 +1,68 @@ +namespace Tiku.Application.Learning; + +public interface ILearningAnalyticsService +{ + Task GetStatsAsync(LearningActor actor, CancellationToken cancellationToken = default); + Task> GetTrendAsync(LearningActor actor, LearningLimitFilter filter, + CancellationToken cancellationToken = default); + Task GetLeaderboardAsync(LearningActor actor, LearningLimitFilter filter, + CancellationToken cancellationToken = default); +} + +public interface IAnsweringService +{ + Task SubmitAnswerAsync(LearningActor actor, SubmitAnswerCommand command, + CancellationToken cancellationToken = default); +} + +public interface IQuestionReviewService +{ + 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 GetWrongQuestionReviewPlanAsync(LearningActor actor, LearningLimitFilter filter, + CancellationToken cancellationToken = default); + Task ResolveWrongQuestionAsync(LearningActor actor, QuestionActionCommand command, + CancellationToken cancellationToken = default); +} + +public interface IWordLearningService +{ + Task> GetWordProgressAsync(LearningActor actor, LearningLimitFilter filter, + CancellationToken cancellationToken = default); + Task GetWordReviewPlanAsync(LearningActor actor, LearningLimitFilter filter, + CancellationToken cancellationToken = default); + Task UpdateWordProgressAsync(LearningActor actor, WordProgressCommand command, + CancellationToken cancellationToken = default); + Task ReviewWordAsync(LearningActor actor, WordReviewCommand command, + CancellationToken cancellationToken = default); + Task GetWordStatsAsync(LearningActor actor, LearningLimitFilter filter, + CancellationToken cancellationToken = default); + Task> GetFavoriteWordsAsync(LearningActor actor, LearningLimitFilter filter, + CancellationToken cancellationToken = default); + Task ToggleFavoriteWordAsync(LearningActor actor, FavoriteWordCommand command, + CancellationToken cancellationToken = default); +} + +public interface IPracticeSessionService +{ + Task CreatePracticeSessionAsync(LearningActor actor, PracticeSessionCommand command, + CancellationToken cancellationToken = default); + Task GetPracticeSessionDetailAsync(LearningActor actor, PracticeSessionFilter filter, + CancellationToken cancellationToken = default); + Task SubmitPracticeSessionAsync(LearningActor actor, + SubmitPracticeSessionCommand command, CancellationToken cancellationToken = default); +} + +public interface IPracticeReportService +{ + Task GetPracticeSessionReportAsync(LearningActor actor, PracticeSessionFilter filter, + CancellationToken cancellationToken = default); + Task> GetPracticeReportsAsync(LearningActor actor, + PracticeSessionFilter filter, CancellationToken cancellationToken = default); + Task> GetPracticeHistoryAsync(LearningActor actor, + PracticeSessionFilter filter, CancellationToken cancellationToken = default); +} diff --git a/Tiku.Infrastructure/Learning/Analytics/LearningActivityService.Analytics.cs b/Tiku.Infrastructure/Learning/Analytics/LearningAnalyticsService.cs similarity index 97% rename from Tiku.Infrastructure/Learning/Analytics/LearningActivityService.Analytics.cs rename to Tiku.Infrastructure/Learning/Analytics/LearningAnalyticsService.cs index 47c147a..4a5f7bc 100644 --- a/Tiku.Infrastructure/Learning/Analytics/LearningActivityService.Analytics.cs +++ b/Tiku.Infrastructure/Learning/Analytics/LearningAnalyticsService.cs @@ -5,7 +5,8 @@ using ZLinq; namespace Tiku.Infrastructure.Learning; -public sealed partial class LearningActivityService +internal sealed class LearningAnalyticsService(LearningServiceDependencies dependencies) + : LearningActivityServiceBase(dependencies), ILearningAnalyticsService { public async Task GetStatsAsync( LearningActor actor, @@ -113,4 +114,4 @@ public sealed partial class LearningActivityService items.FirstOrDefault(item => item.UserId == actor.UserId), DateTimeOffset.UtcNow); } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Learning/Answering/LearningActivityService.Answering.cs b/Tiku.Infrastructure/Learning/Answering/AnsweringService.cs similarity index 98% rename from Tiku.Infrastructure/Learning/Answering/LearningActivityService.Answering.cs rename to Tiku.Infrastructure/Learning/Answering/AnsweringService.cs index f54b080..e6f1d96 100644 --- a/Tiku.Infrastructure/Learning/Answering/LearningActivityService.Answering.cs +++ b/Tiku.Infrastructure/Learning/Answering/AnsweringService.cs @@ -7,7 +7,8 @@ using Tiku.Domain.Learning; namespace Tiku.Infrastructure.Learning; -public sealed partial class LearningActivityService +internal sealed class AnsweringService(LearningServiceDependencies dependencies) + : LearningActivityServiceBase(dependencies), IAnsweringService { public async Task SubmitAnswerAsync( LearningActor actor, @@ -169,4 +170,4 @@ public sealed partial class LearningActivityService return response; } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Learning/Foundation/LearningActivityService.Foundation.cs b/Tiku.Infrastructure/Learning/Foundation/LearningFoundation.Helpers.cs similarity index 93% rename from Tiku.Infrastructure/Learning/Foundation/LearningActivityService.Foundation.cs rename to Tiku.Infrastructure/Learning/Foundation/LearningFoundation.Helpers.cs index b10e56f..34531a5 100644 --- a/Tiku.Infrastructure/Learning/Foundation/LearningActivityService.Foundation.cs +++ b/Tiku.Infrastructure/Learning/Foundation/LearningFoundation.Helpers.cs @@ -14,9 +14,9 @@ using Tiku.Infrastructure.Persistence; namespace Tiku.Infrastructure.Learning; -public sealed partial class LearningActivityService +internal abstract partial class LearningActivityServiceBase { - private async Task BuildPracticeAssemblyAsync( + protected async Task BuildPracticeAssemblyAsync( Guid tenantId, PracticeSessionCommand command, CancellationToken cancellationToken) @@ -63,7 +63,7 @@ public sealed partial class LearningActivityService }; } - private async Task> CollectQuestionReferenceIdsAsync( + protected async Task> CollectQuestionReferenceIdsAsync( LearningActor actor, PracticeAssembly assembly, CancellationToken cancellationToken) @@ -137,7 +137,7 @@ public sealed partial class LearningActivityService return referenceIds; } - private static IQueryable ApplyLegacyTargetFilter( + protected static IQueryable ApplyLegacyTargetFilter( IQueryable query, string? targetType, Guid targetId) @@ -154,14 +154,14 @@ public sealed partial class LearningActivityService }; } - private async Task> LoadQuestionSelectionsAsync( + protected async Task> LoadQuestionSelectionsAsync( Guid tenantId, IReadOnlyCollection questionReferenceIds, CancellationToken cancellationToken) { var rows = await tenantExecutionScope.ExecuteAsync( new SystemScopeRequest( - tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(LearningActivityService), + tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(LearningActivityServiceBase), "Lock published question versions for a new practice session", Guid.NewGuid().ToString("N")), async (provider, token) => { @@ -215,14 +215,14 @@ public sealed partial class LearningActivityService return questionReferenceIds.Select(referenceId => byReference[referenceId]).ToArray(); } - private Task LoadSessionQuestionItemsAsync( + protected Task LoadSessionQuestionItemsAsync( Guid tenantId, Guid practiceSessionId, CancellationToken cancellationToken) { return tenantExecutionScope.ExecuteAsync( new SystemScopeRequest( - tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(LearningActivityService), + tenantId, SystemScopeCallerType.PublicQuestionBank, nameof(LearningActivityServiceBase), "Read locked question versions for a tenant practice session", Guid.NewGuid().ToString("N")), async (provider, token) => { @@ -253,7 +253,7 @@ public sealed partial class LearningActivityService cancellationToken); } - private async Task GetPracticeSessionAsync( + protected async Task GetPracticeSessionAsync( LearningActor actor, Guid? practiceSessionId, CancellationToken cancellationToken) @@ -276,7 +276,7 @@ public sealed partial class LearningActivityService return session; } - private async Task BuildPracticeSessionReportAsync( + protected async Task BuildPracticeSessionReportAsync( LearningActor actor, PracticeSession session, CancellationToken cancellationToken) @@ -450,7 +450,7 @@ public sealed partial class LearningActivityService return report; } - private async Task EnsureQuestionExistsAsync( + protected async Task EnsureQuestionExistsAsync( Guid tenantId, Guid questionId, CancellationToken cancellationToken) @@ -465,7 +465,7 @@ public sealed partial class LearningActivityService if (!exists) throw new LearningResourceNotFoundException("question_not_found", "Question was not found."); } - private async Task EnsureWordExistsAsync( + protected async Task EnsureWordExistsAsync( Guid tenantId, Guid wordId, CancellationToken cancellationToken) @@ -480,7 +480,7 @@ public sealed partial class LearningActivityService if (!exists) throw new LearningResourceNotFoundException("word_not_found", "Word was not found."); } - private static AnswerRecordItem ToItem(AnswerRecord record, long sessionVersion) + protected static AnswerRecordItem ToItem(AnswerRecord record, long sessionVersion) { return new AnswerRecordItem( record.Id, @@ -495,7 +495,7 @@ public sealed partial class LearningActivityService record.AnsweredAt); } - private static WordProgressItem ToItem(UserWordProgress item) + protected static WordProgressItem ToItem(UserWordProgress item) { return new WordProgressItem( item.WordId, @@ -511,7 +511,7 @@ public sealed partial class LearningActivityService item.Metadata); } - private static PracticeSessionItem ToItem(PracticeSession item) + protected static PracticeSessionItem ToItem(PracticeSession item) { return new PracticeSessionItem( item.Id, @@ -538,7 +538,7 @@ public sealed partial class LearningActivityService item.LastClientSequence); } - private static PracticeSessionReportItem ToItem(PracticeSessionReport item) + protected static PracticeSessionReportItem ToItem(PracticeSessionReport item) { return new PracticeSessionReportItem( item.Id, @@ -568,7 +568,7 @@ public sealed partial class LearningActivityService item.Metadata); } - private static List ReadGuidArray(JsonElement value) + protected static List ReadGuidArray(JsonElement value) { if (value.ValueKind is not JsonValueKind.Array) return []; @@ -581,7 +581,7 @@ public sealed partial class LearningActivityService .ToList(); } - private static void EnsureAnswerSessionState( + protected static void EnsureAnswerSessionState( PracticeSession session, SubmitAnswerCommand command) { @@ -596,7 +596,7 @@ public sealed partial class LearningActivityService "Client sequence must increase within a practice session."); } - private static JsonElement BuildGradingRules(QuestionSelection selection) + protected static JsonElement BuildGradingRules(QuestionSelection selection) { return JsonSerializer.SerializeToElement(new { @@ -607,7 +607,7 @@ public sealed partial class LearningActivityService }); } - private static string HashAnswer(SubmitAnswerCommand command) + protected static string HashAnswer(SubmitAnswerCommand command) { return Hash(JsonSerializer.Serialize(new { @@ -619,7 +619,7 @@ public sealed partial class LearningActivityService })); } - private static string HashSubmission(SubmitPracticeSessionCommand command) + protected static string HashSubmission(SubmitPracticeSessionCommand command) { return Hash(JsonSerializer.Serialize(new { @@ -628,12 +628,12 @@ public sealed partial class LearningActivityService })); } - private static string Hash(string value) + protected static string Hash(string value) { return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); } - private static string ResolvePracticeSessionHistoryStatus(PracticeSession session, DateTimeOffset now) + protected static string ResolvePracticeSessionHistoryStatus(PracticeSession session, DateTimeOffset now) { if (session.Status is PracticeSessionStatus.Submitted or PracticeSessionStatus.PendingReview) return "finished"; @@ -644,7 +644,7 @@ public sealed partial class LearningActivityService return "active"; } - private static string NormalizeMode(string? mode) + protected static string NormalizeMode(string? mode) { return NormalizeEnumValue(mode) switch { @@ -658,17 +658,17 @@ public sealed partial class LearningActivityService }; } - private static bool TryParseWordProgressStatus(string? value, out WordProgressStatus status) + protected static bool TryParseWordProgressStatus(string? value, out WordProgressStatus status) { return Enum.TryParse(NormalizeEnumValue(value), true, out status); } - private static int ResolveLimit(int? limit) + protected static int ResolveLimit(int? limit) { return Math.Clamp(limit ?? DefaultLimit, 1, MaxLimit); } - private static string? NormalizeEnumValue(string? value) + protected static string? NormalizeEnumValue(string? value) { return string.IsNullOrWhiteSpace(value) ? null @@ -676,7 +676,7 @@ public sealed partial class LearningActivityService .Replace("-", string.Empty, StringComparison.Ordinal); } - private sealed record PracticeAssembly( + protected sealed record PracticeAssembly( string Mode, string? TargetType, Guid? TargetId, @@ -688,7 +688,7 @@ public sealed partial class LearningActivityService int? DurationMinutes, decimal? TotalScore); - private sealed record QuestionSelection( + protected sealed record QuestionSelection( Guid QuestionReferenceId, Guid QuestionOwnerTenantId, Guid QuestionId, @@ -703,4 +703,4 @@ public sealed partial class LearningActivityService JsonElement CorrectOptionIndices, string? AnswerText, string? Explanation); -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Learning/Foundation/LearningFoundation.cs b/Tiku.Infrastructure/Learning/Foundation/LearningFoundation.cs new file mode 100644 index 0000000..c569c27 --- /dev/null +++ b/Tiku.Infrastructure/Learning/Foundation/LearningFoundation.cs @@ -0,0 +1,40 @@ +using System.Diagnostics.Metrics; +using Microsoft.Extensions.Logging; +using Tiku.Application.Learning; +using Tiku.Application.QuestionBanks; +using Tiku.Application.Security; +using Tiku.Infrastructure.Persistence; + +namespace Tiku.Infrastructure.Learning; + +internal sealed record LearningServiceDependencies( + TikuDbContext DbContext, + IQuestionReferenceService QuestionReferenceService, + IPublicQuestionAccessPolicy PublicQuestionAccessPolicy, + ITenantExecutionScope TenantExecutionScope, + ILogger Logger); + +internal abstract partial class LearningActivityServiceBase(LearningServiceDependencies dependencies) +{ + protected TikuDbContext dbContext { get; } = dependencies.DbContext; + protected IQuestionReferenceService questionReferenceService { get; } = dependencies.QuestionReferenceService; + protected IPublicQuestionAccessPolicy publicQuestionAccessPolicy { get; } = dependencies.PublicQuestionAccessPolicy; + protected ITenantExecutionScope tenantExecutionScope { get; } = dependencies.TenantExecutionScope; + protected ILogger logger { get; } = dependencies.Logger; + + protected const int DefaultLimit = 100; + protected const int MaxLimit = 500; + protected static readonly Meter LearningMeter = new("Tiku.Learning"); + + protected static readonly Counter IdempotencyReplays = + LearningMeter.CreateCounter("tiku.learning.idempotency.replays"); + + protected static readonly Counter AnswerConflicts = + LearningMeter.CreateCounter("tiku.learning.answer.conflicts"); + + protected static readonly Counter SubmissionConflicts = + LearningMeter.CreateCounter("tiku.learning.submission.conflicts"); + + protected static readonly Counter ScoringFailures = + LearningMeter.CreateCounter("tiku.learning.scoring.failures"); +} diff --git a/Tiku.Infrastructure/Learning/LearningActivityService.cs b/Tiku.Infrastructure/Learning/LearningActivityService.cs deleted file mode 100644 index 36c3d4d..0000000 --- a/Tiku.Infrastructure/Learning/LearningActivityService.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Diagnostics.Metrics; -using Microsoft.Extensions.Logging; -using Tiku.Application.Learning; -using Tiku.Application.QuestionBanks; -using Tiku.Application.Security; -using Tiku.Infrastructure.Persistence; - -namespace Tiku.Infrastructure.Learning; - -public sealed partial class LearningActivityService( - TikuDbContext dbContext, - IQuestionReferenceService questionReferenceService, - IPublicQuestionAccessPolicy publicQuestionAccessPolicy, - ITenantExecutionScope tenantExecutionScope, - ILogger logger) : ILearningActivityService -{ - private const int DefaultLimit = 100; - private const int MaxLimit = 500; - private static readonly Meter LearningMeter = new("Tiku.Learning"); - - private static readonly Counter IdempotencyReplays = - LearningMeter.CreateCounter("tiku.learning.idempotency.replays"); - - private static readonly Counter AnswerConflicts = - LearningMeter.CreateCounter("tiku.learning.answer.conflicts"); - - private static readonly Counter SubmissionConflicts = - LearningMeter.CreateCounter("tiku.learning.submission.conflicts"); - - private static readonly Counter ScoringFailures = - LearningMeter.CreateCounter("tiku.learning.scoring.failures"); -} \ No newline at end of file diff --git a/Tiku.Infrastructure/Learning/PracticeReports/PracticeReportService.cs b/Tiku.Infrastructure/Learning/PracticeReports/PracticeReportService.cs new file mode 100644 index 0000000..383acf3 --- /dev/null +++ b/Tiku.Infrastructure/Learning/PracticeReports/PracticeReportService.cs @@ -0,0 +1,111 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using Tiku.Application.Learning; +using Tiku.Domain.Common; +using Tiku.Domain.Content; +using Tiku.Domain.Learning; + +namespace Tiku.Infrastructure.Learning; + +internal sealed class PracticeReportService(LearningServiceDependencies dependencies) + : LearningActivityServiceBase(dependencies), IPracticeReportService +{ + public async Task GetPracticeSessionReportAsync( + LearningActor actor, + PracticeSessionFilter filter, + CancellationToken cancellationToken = default) + { + if (!filter.PracticeSessionId.HasValue) + throw new LearningValidationException("practice_session_id_required", "Practice session id is required."); + + var report = await dbContext.PracticeSessionReports + .AsNoTracking() + .SingleOrDefaultAsync( + item => + item.TenantId == actor.TenantId && + item.UserId == actor.UserId && + item.PracticeSessionId == filter.PracticeSessionId.Value, + cancellationToken); + + if (report is null) + throw new LearningResourceNotFoundException("practice_report_not_found", + "Practice session report was not found."); + + return ToItem(report); + } + + public async Task> GetPracticeReportsAsync( + LearningActor actor, + PracticeSessionFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.PracticeSessionReports + .AsNoTracking() + .Where(report => + report.TenantId == actor.TenantId && + report.UserId == actor.UserId); + + if (filter.BlueprintId.HasValue) query = query.Where(report => report.BlueprintId == filter.BlueprintId.Value); + + if (!string.IsNullOrWhiteSpace(filter.Mode)) query = query.Where(report => report.Mode == filter.Mode.Trim()); + + var items = await query + .OrderByDescending(report => report.SubmittedAt) + .Take(ResolveLimit(filter.Limit)) + .ToArrayAsync(cancellationToken); + + return new LearningList(items.Select(ToItem).ToArray()); + } + + public async Task> GetPracticeHistoryAsync( + LearningActor actor, + PracticeSessionFilter filter, + CancellationToken cancellationToken = default) + { + var query = dbContext.PracticeSessions + .AsNoTracking() + .Where(session => + session.TenantId == actor.TenantId && + session.UserId == actor.UserId); + + if (!string.IsNullOrWhiteSpace(filter.Mode)) query = query.Where(session => session.Mode == filter.Mode.Trim()); + + var rows = await query + .GroupJoin( + dbContext.PracticeSessionReports.AsNoTracking(), + session => new { session.TenantId, PracticeSessionId = session.Id }, + report => new { report.TenantId, report.PracticeSessionId }, + (session, reports) => new { session, report = reports.FirstOrDefault() }) + .OrderByDescending(row => row.session.FinishedAt ?? row.session.StartedAt) + .Take(ResolveLimit(filter.Limit)) + .ToArrayAsync(cancellationToken); + var now = DateTimeOffset.UtcNow; + var items = rows + .Select(row => new PracticeHistoryItem( + row.session.Id, + row.session.Mode, + row.session.TargetType, + row.session.TargetId, + row.session.BlueprintId, + row.session.CollectionId, + row.session.EntryId, + row.session.ContentNodeId, + row.session.QuestionCount, + row.report?.AnsweredCount ?? 0, + row.report?.CorrectCount ?? 0, + row.report?.WrongCount ?? 0, + row.session.StartedAt, + row.session.FinishedAt, + row.session.ExpiresAt, + ResolvePracticeSessionHistoryStatus(row.session, now), + row.report?.Id, + row.report?.Score, + row.report?.TotalScore, + row.report?.Accuracy)) + .Where(item => string.IsNullOrWhiteSpace(filter.Status) || item.Status == filter.Status.Trim()) + .ToArray(); + + return new LearningList(items); + } +} diff --git a/Tiku.Infrastructure/Learning/PracticeSessions/LearningActivityService.PracticeSessions.cs b/Tiku.Infrastructure/Learning/PracticeSessions/PracticeSessionService.cs similarity index 72% rename from Tiku.Infrastructure/Learning/PracticeSessions/LearningActivityService.PracticeSessions.cs rename to Tiku.Infrastructure/Learning/PracticeSessions/PracticeSessionService.cs index 7644fbc..9b83de4 100644 --- a/Tiku.Infrastructure/Learning/PracticeSessions/LearningActivityService.PracticeSessions.cs +++ b/Tiku.Infrastructure/Learning/PracticeSessions/PracticeSessionService.cs @@ -8,7 +8,8 @@ using Tiku.Domain.Learning; namespace Tiku.Infrastructure.Learning; -public sealed partial class LearningActivityService +internal sealed class PracticeSessionService(LearningServiceDependencies dependencies) + : LearningActivityServiceBase(dependencies), IPracticeSessionService { public async Task CreatePracticeSessionAsync( LearningActor actor, @@ -237,102 +238,4 @@ public sealed partial class LearningActivityService return response; } - - public async Task GetPracticeSessionReportAsync( - LearningActor actor, - PracticeSessionFilter filter, - CancellationToken cancellationToken = default) - { - if (!filter.PracticeSessionId.HasValue) - throw new LearningValidationException("practice_session_id_required", "Practice session id is required."); - - var report = await dbContext.PracticeSessionReports - .AsNoTracking() - .SingleOrDefaultAsync( - item => - item.TenantId == actor.TenantId && - item.UserId == actor.UserId && - item.PracticeSessionId == filter.PracticeSessionId.Value, - cancellationToken); - - if (report is null) - throw new LearningResourceNotFoundException("practice_report_not_found", - "Practice session report was not found."); - - return ToItem(report); - } - - public async Task> GetPracticeReportsAsync( - LearningActor actor, - PracticeSessionFilter filter, - CancellationToken cancellationToken = default) - { - var query = dbContext.PracticeSessionReports - .AsNoTracking() - .Where(report => - report.TenantId == actor.TenantId && - report.UserId == actor.UserId); - - if (filter.BlueprintId.HasValue) query = query.Where(report => report.BlueprintId == filter.BlueprintId.Value); - - if (!string.IsNullOrWhiteSpace(filter.Mode)) query = query.Where(report => report.Mode == filter.Mode.Trim()); - - var items = await query - .OrderByDescending(report => report.SubmittedAt) - .Take(ResolveLimit(filter.Limit)) - .ToArrayAsync(cancellationToken); - - return new LearningList(items.Select(ToItem).ToArray()); - } - - public async Task> GetPracticeHistoryAsync( - LearningActor actor, - PracticeSessionFilter filter, - CancellationToken cancellationToken = default) - { - var query = dbContext.PracticeSessions - .AsNoTracking() - .Where(session => - session.TenantId == actor.TenantId && - session.UserId == actor.UserId); - - if (!string.IsNullOrWhiteSpace(filter.Mode)) query = query.Where(session => session.Mode == filter.Mode.Trim()); - - var rows = await query - .GroupJoin( - dbContext.PracticeSessionReports.AsNoTracking(), - session => new { session.TenantId, PracticeSessionId = session.Id }, - report => new { report.TenantId, report.PracticeSessionId }, - (session, reports) => new { session, report = reports.FirstOrDefault() }) - .OrderByDescending(row => row.session.FinishedAt ?? row.session.StartedAt) - .Take(ResolveLimit(filter.Limit)) - .ToArrayAsync(cancellationToken); - var now = DateTimeOffset.UtcNow; - var items = rows - .Select(row => new PracticeHistoryItem( - row.session.Id, - row.session.Mode, - row.session.TargetType, - row.session.TargetId, - row.session.BlueprintId, - row.session.CollectionId, - row.session.EntryId, - row.session.ContentNodeId, - row.session.QuestionCount, - row.report?.AnsweredCount ?? 0, - row.report?.CorrectCount ?? 0, - row.report?.WrongCount ?? 0, - row.session.StartedAt, - row.session.FinishedAt, - row.session.ExpiresAt, - ResolvePracticeSessionHistoryStatus(row.session, now), - row.report?.Id, - row.report?.Score, - row.report?.TotalScore, - row.report?.Accuracy)) - .Where(item => string.IsNullOrWhiteSpace(filter.Status) || item.Status == filter.Status.Trim()) - .ToArray(); - - return new LearningList(items); - } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Learning/QuestionReview/LearningActivityService.QuestionReview.cs b/Tiku.Infrastructure/Learning/QuestionReview/QuestionReviewService.cs similarity index 97% rename from Tiku.Infrastructure/Learning/QuestionReview/LearningActivityService.QuestionReview.cs rename to Tiku.Infrastructure/Learning/QuestionReview/QuestionReviewService.cs index 22aef04..01554e3 100644 --- a/Tiku.Infrastructure/Learning/QuestionReview/LearningActivityService.QuestionReview.cs +++ b/Tiku.Infrastructure/Learning/QuestionReview/QuestionReviewService.cs @@ -7,7 +7,8 @@ using Tiku.Domain.Learning; namespace Tiku.Infrastructure.Learning; -public sealed partial class LearningActivityService +internal sealed class QuestionReviewService(LearningServiceDependencies dependencies) + : LearningActivityServiceBase(dependencies), IQuestionReviewService { public async Task> GetFavoriteQuestionsAsync( LearningActor actor, @@ -153,4 +154,4 @@ public sealed partial class LearningActivityService recommendedEndpoint = "/api/student/learning/practice-sessions" })); } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Learning/WordLearning/LearningActivityService.WordLearning.cs b/Tiku.Infrastructure/Learning/WordLearning/WordLearningService.cs similarity index 98% rename from Tiku.Infrastructure/Learning/WordLearning/LearningActivityService.WordLearning.cs rename to Tiku.Infrastructure/Learning/WordLearning/WordLearningService.cs index 489d849..7dcf958 100644 --- a/Tiku.Infrastructure/Learning/WordLearning/LearningActivityService.WordLearning.cs +++ b/Tiku.Infrastructure/Learning/WordLearning/WordLearningService.cs @@ -5,7 +5,8 @@ using Tiku.Domain.Content; namespace Tiku.Infrastructure.Learning; -public sealed partial class LearningActivityService +internal sealed class WordLearningService(LearningServiceDependencies dependencies) + : LearningActivityServiceBase(dependencies), IWordLearningService { public async Task> GetWordProgressAsync( LearningActor actor, @@ -258,4 +259,4 @@ public sealed partial class LearningActivityService await dbContext.SaveChangesAsync(cancellationToken); return new LearningActionResult(true, favorite); } -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Modules/LearningModule.cs b/Tiku.Infrastructure/Modules/LearningModule.cs index a224a01..7c6e927 100644 --- a/Tiku.Infrastructure/Modules/LearningModule.cs +++ b/Tiku.Infrastructure/Modules/LearningModule.cs @@ -8,7 +8,13 @@ internal static class LearningModule { internal static IServiceCollection AddLearningModule(this IServiceCollection services) { - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); return services; } -} \ No newline at end of file +} diff --git a/Tiku.IntegrationTests/ArchitectureBoundaryTests.cs b/Tiku.IntegrationTests/ArchitectureBoundaryTests.cs index 0f6a5f9..c081139 100644 --- a/Tiku.IntegrationTests/ArchitectureBoundaryTests.cs +++ b/Tiku.IntegrationTests/ArchitectureBoundaryTests.cs @@ -35,7 +35,6 @@ public sealed class ArchitectureBoundaryTests var root = FindRepositoryRoot(); var legacyLineBudgets = new Dictionary(StringComparer.Ordinal) { - ["LearningActivityService"] = 1781, ["PlatformAdminService"] = 1719, ["ContentManagementService"] = 1096, ["PlatformQuestionBankService"] = 1033, diff --git a/Tiku.IntegrationTests/PhaseThreeTenantIsolationTests.cs b/Tiku.IntegrationTests/PhaseThreeTenantIsolationTests.cs index 9c532d8..cd88758 100644 --- a/Tiku.IntegrationTests/PhaseThreeTenantIsolationTests.cs +++ b/Tiku.IntegrationTests/PhaseThreeTenantIsolationTests.cs @@ -328,7 +328,7 @@ public sealed class PhaseThreeTenantIsolationTests Guid firstSessionId; using (var scope = factory.CreateTenantScope(tenantId, "tenant-a")) { - var created = await scope.ServiceProvider.GetRequiredService() + var created = await scope.ServiceProvider.GetRequiredService() .CreatePracticeSessionAsync( new LearningActor(tenantId, userId), new PracticeSessionCommand( @@ -358,7 +358,7 @@ public sealed class PhaseThreeTenantIsolationTests Guid secondSessionId; using (var scope = factory.CreateTenantScope(tenantId, "tenant-a")) { - var created = await scope.ServiceProvider.GetRequiredService() + var created = await scope.ServiceProvider.GetRequiredService() .CreatePracticeSessionAsync( new LearningActor(tenantId, userId), new PracticeSessionCommand( @@ -395,4 +395,4 @@ public sealed class PhaseThreeTenantIsolationTests Mode = mode }; } -} \ No newline at end of file +}