refactor(learning): split learning capability services

This commit is contained in:
2026-08-04 09:33:55 +08:00
parent 0bd075e20d
commit 087ca78af5
23 changed files with 697 additions and 607 deletions

View File

@@ -25,6 +25,7 @@ internal static class ApiPresentationExtensions
services.AddScoped<TenantAdminActorResolver>();
services.AddScoped<DirectContentActorResolver>();
services.AddScoped<CommerceAdminActorResolver>();
services.AddScoped<LearningActorResolver>();
return services;
}

View File

@@ -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<AnswerRecordItem>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status403Forbidden)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<AnswerRecordItem>> SubmitAnswer(
SubmitAnswerDto request,
CancellationToken cancellationToken)
{
return Ok(await service.SubmitAnswerAsync(
actorResolver.Resolve(),
request.ToCommand(),
cancellationToken));
}
}

View File

@@ -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.");

View File

@@ -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<LearningStatsItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<LearningStatsItem>> GetStats(CancellationToken cancellationToken)
{
return Ok(await service.GetStatsAsync(actorResolver.Resolve(), cancellationToken));
}
[HttpGet("trend")]
[EndpointSummary("查询学习趋势")]
[ProducesResponseType<LearningList<LearningTrendItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<LearningList<LearningTrendItem>>> GetTrend(
[FromQuery] LearningLimitQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await service.GetTrendAsync(actorResolver.Resolve(), query.ToFilter(), cancellationToken));
}
[HttpGet("leaderboard")]
[EndpointSummary("查询学习排行榜")]
[ProducesResponseType<LearningLeaderboardResult>(StatusCodes.Status200OK)]
public async Task<ActionResult<LearningLeaderboardResult>> GetLeaderboard(
[FromQuery] LearningLimitQueryDto query,
CancellationToken cancellationToken)
{
return Ok(
await service.GetLeaderboardAsync(actorResolver.Resolve(), query.ToFilter(), cancellationToken));
}
}

View File

@@ -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<LearningStatsItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<LearningStatsItem>> GetStats(CancellationToken cancellationToken)
{
return Ok(await learningActivityService.GetStatsAsync(ResolveActor(), cancellationToken));
}
[HttpGet("trend")]
[EndpointSummary("查询学习趋势")]
[ProducesResponseType<LearningList<LearningTrendItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<LearningList<LearningTrendItem>>> GetTrend(
[FromQuery] LearningLimitQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await learningActivityService.GetTrendAsync(ResolveActor(), query.ToFilter(), cancellationToken));
}
[HttpGet("leaderboard")]
[EndpointSummary("查询学习排行榜")]
[ProducesResponseType<LearningLeaderboardResult>(StatusCodes.Status200OK)]
public async Task<ActionResult<LearningLeaderboardResult>> GetLeaderboard(
[FromQuery] LearningLimitQueryDto query,
CancellationToken cancellationToken)
{
return Ok(
await learningActivityService.GetLeaderboardAsync(ResolveActor(), query.ToFilter(), cancellationToken));
}
[HttpPost("practice-sessions")]
[EndpointSummary("创建练习会话")]
[ProducesResponseType<PracticeSessionItem>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PracticeSessionItem>> CreatePracticeSession(
CreatePracticeSessionDto request,
CancellationToken cancellationToken)
{
return Ok(await learningActivityService.CreatePracticeSessionAsync(
ResolveActor(),
request.ToCommand(),
cancellationToken));
}
[HttpGet("practice-sessions/detail")]
[EndpointSummary("获取练习会话详情")]
[ProducesResponseType<PracticeSessionDetailItem>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PracticeSessionDetailItem>> GetPracticeSessionDetail(
[FromQuery] PracticeSessionQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await learningActivityService.GetPracticeSessionDetailAsync(
ResolveActor(),
query.ToFilter(),
cancellationToken));
}
[HttpPost("practice-sessions/submit")]
[EndpointSummary("提交练习会话并生成报告")]
[ProducesResponseType<PracticeSessionReportItem>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PracticeSessionReportItem>> SubmitPracticeSession(
SubmitPracticeSessionDto request,
CancellationToken cancellationToken)
{
return Ok(await learningActivityService.SubmitPracticeSessionAsync(
ResolveActor(),
request.ToCommand(),
cancellationToken));
}
[HttpGet("practice-sessions/report")]
[EndpointSummary("获取练习会话报告")]
[ProducesResponseType<PracticeSessionReportItem>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PracticeSessionReportItem>> GetPracticeSessionReport(
[FromQuery] PracticeSessionQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await learningActivityService.GetPracticeSessionReportAsync(
ResolveActor(),
query.ToFilter(),
cancellationToken));
}
[HttpGet("practice-reports")]
[EndpointSummary("查询练习报告列表")]
[ProducesResponseType<LearningList<PracticeSessionReportItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<LearningList<PracticeSessionReportItem>>> GetPracticeReports(
[FromQuery] PracticeSessionQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await learningActivityService.GetPracticeReportsAsync(
ResolveActor(),
query.ToFilter(),
cancellationToken));
}
[HttpGet("practice-sessions/history")]
[EndpointSummary("查询练习历史")]
[ProducesResponseType<LearningList<PracticeHistoryItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<LearningList<PracticeHistoryItem>>> GetPracticeHistory(
[FromQuery] PracticeSessionQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await learningActivityService.GetPracticeHistoryAsync(
ResolveActor(),
query.ToFilter(),
cancellationToken));
}
[HttpPost("answers")]
[EndpointSummary("提交题目答案")]
[ProducesResponseType<AnswerRecordItem>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status401Unauthorized)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status403Forbidden)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<AnswerRecordItem>> SubmitAnswer(
SubmitAnswerDto request,
CancellationToken cancellationToken)
{
return Ok(await learningActivityService.SubmitAnswerAsync(
ResolveActor(),
request.ToCommand(),
cancellationToken));
}
[HttpGet("favorites/questions")]
[EndpointSummary("查询收藏题目")]
[ProducesResponseType<LearningList<FavoriteQuestionItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<LearningList<FavoriteQuestionItem>>> GetFavoriteQuestions(
[FromQuery] LearningLimitQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await learningActivityService.GetFavoriteQuestionsAsync(
ResolveActor(),
query.ToFilter(),
cancellationToken));
}
[HttpPost("favorites/questions")]
[EndpointSummary("收藏或取消收藏题目")]
[ProducesResponseType<LearningActionResult>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<LearningActionResult>> ToggleFavoriteQuestion(
QuestionActionDto request,
CancellationToken cancellationToken)
{
return Ok(await learningActivityService.ToggleFavoriteQuestionAsync(
ResolveActor(),
request.ToCommand(),
cancellationToken));
}
[HttpGet("wrong-questions")]
[EndpointSummary("查询错题列表")]
[ProducesResponseType<LearningList<WrongQuestionItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<LearningList<WrongQuestionItem>>> GetWrongQuestions(
[FromQuery] LearningLimitQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await learningActivityService.GetWrongQuestionsAsync(
ResolveActor(),
query.ToFilter(),
cancellationToken));
}
[HttpGet("wrong-questions/review-plan")]
[EndpointSummary("生成错题复习计划")]
[ProducesResponseType<WrongQuestionReviewPlan>(StatusCodes.Status200OK)]
public async Task<ActionResult<WrongQuestionReviewPlan>> GetWrongQuestionReviewPlan(
[FromQuery] LearningLimitQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await learningActivityService.GetWrongQuestionReviewPlanAsync(
ResolveActor(),
query.ToFilter(),
cancellationToken));
}
[HttpPost("wrong-questions/resolve")]
[EndpointSummary("将错题标记为已解决")]
[ProducesResponseType<LearningActionResult>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<LearningActionResult>> ResolveWrongQuestion(
QuestionActionDto request,
CancellationToken cancellationToken)
{
return Ok(await learningActivityService.ResolveWrongQuestionAsync(
ResolveActor(),
request.ToCommand(),
cancellationToken));
}
[HttpGet("vocabulary/progress")]
[EndpointSummary("查询单词学习进度")]
[ProducesResponseType<LearningList<WordProgressItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<LearningList<WordProgressItem>>> GetWordProgress(
[FromQuery] LearningLimitQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await learningActivityService.GetWordProgressAsync(
ResolveActor(),
query.ToFilter(),
cancellationToken));
}
[HttpGet("vocabulary/review-plan")]
[EndpointSummary("生成单词复习计划")]
[ProducesResponseType<WordReviewPlan>(StatusCodes.Status200OK)]
public async Task<ActionResult<WordReviewPlan>> GetWordReviewPlan(
[FromQuery] LearningLimitQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await learningActivityService.GetWordReviewPlanAsync(
ResolveActor(),
query.ToFilter(),
cancellationToken));
}
[HttpPost("vocabulary/progress")]
[EndpointSummary("更新单词学习进度")]
[ProducesResponseType<WordProgressItem>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<WordProgressItem>> UpdateWordProgress(
WordProgressDto request,
CancellationToken cancellationToken)
{
return Ok(await learningActivityService.UpdateWordProgressAsync(
ResolveActor(),
request.ToCommand(),
cancellationToken));
}
[HttpPost("vocabulary/review")]
[EndpointSummary("提交单词复习结果")]
[ProducesResponseType<WordProgressItem>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<WordProgressItem>> ReviewWord(
WordReviewDto request,
CancellationToken cancellationToken)
{
return Ok(await learningActivityService.ReviewWordAsync(
ResolveActor(),
request.ToCommand(),
cancellationToken));
}
[HttpGet("vocabulary/stats")]
[EndpointSummary("查询单词学习统计")]
[ProducesResponseType<WordStatsItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<WordStatsItem>> GetWordStats(
[FromQuery] LearningLimitQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await learningActivityService.GetWordStatsAsync(
ResolveActor(),
query.ToFilter(),
cancellationToken));
}
[HttpGet("vocabulary/favorites")]
[EndpointSummary("查询收藏单词")]
[ProducesResponseType<LearningList<FavoriteWordItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<LearningList<FavoriteWordItem>>> GetFavoriteWords(
[FromQuery] LearningLimitQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await learningActivityService.GetFavoriteWordsAsync(
ResolveActor(),
query.ToFilter(),
cancellationToken));
}
[HttpPost("vocabulary/favorites")]
[EndpointSummary("收藏或取消收藏单词")]
[ProducesResponseType<LearningActionResult>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<LearningActionResult>> 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.");

View File

@@ -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<PracticeSessionReportItem>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PracticeSessionReportItem>> GetPracticeSessionReport(
[FromQuery] PracticeSessionQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await service.GetPracticeSessionReportAsync(
actorResolver.Resolve(),
query.ToFilter(),
cancellationToken));
}
[HttpGet("practice-reports")]
[EndpointSummary("查询练习报告列表")]
[ProducesResponseType<LearningList<PracticeSessionReportItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<LearningList<PracticeSessionReportItem>>> GetPracticeReports(
[FromQuery] PracticeSessionQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await service.GetPracticeReportsAsync(
actorResolver.Resolve(),
query.ToFilter(),
cancellationToken));
}
[HttpGet("practice-sessions/history")]
[EndpointSummary("查询练习历史")]
[ProducesResponseType<LearningList<PracticeHistoryItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<LearningList<PracticeHistoryItem>>> GetPracticeHistory(
[FromQuery] PracticeSessionQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await service.GetPracticeHistoryAsync(
actorResolver.Resolve(),
query.ToFilter(),
cancellationToken));
}
}

View File

@@ -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<PracticeSessionItem>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
public async Task<ActionResult<PracticeSessionItem>> CreatePracticeSession(
CreatePracticeSessionDto request,
CancellationToken cancellationToken)
{
return Ok(await service.CreatePracticeSessionAsync(
actorResolver.Resolve(),
request.ToCommand(),
cancellationToken));
}
[HttpGet("practice-sessions/detail")]
[EndpointSummary("获取练习会话详情")]
[ProducesResponseType<PracticeSessionDetailItem>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PracticeSessionDetailItem>> GetPracticeSessionDetail(
[FromQuery] PracticeSessionQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await service.GetPracticeSessionDetailAsync(
actorResolver.Resolve(),
query.ToFilter(),
cancellationToken));
}
[HttpPost("practice-sessions/submit")]
[EndpointSummary("提交练习会话并生成报告")]
[ProducesResponseType<PracticeSessionReportItem>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PracticeSessionReportItem>> SubmitPracticeSession(
SubmitPracticeSessionDto request,
CancellationToken cancellationToken)
{
return Ok(await service.SubmitPracticeSessionAsync(
actorResolver.Resolve(),
request.ToCommand(),
cancellationToken));
}
}

View File

@@ -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<LearningList<FavoriteQuestionItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<LearningList<FavoriteQuestionItem>>> GetFavoriteQuestions(
[FromQuery] LearningLimitQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await service.GetFavoriteQuestionsAsync(
actorResolver.Resolve(),
query.ToFilter(),
cancellationToken));
}
[HttpPost("favorites/questions")]
[EndpointSummary("收藏或取消收藏题目")]
[ProducesResponseType<LearningActionResult>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<LearningActionResult>> ToggleFavoriteQuestion(
QuestionActionDto request,
CancellationToken cancellationToken)
{
return Ok(await service.ToggleFavoriteQuestionAsync(
actorResolver.Resolve(),
request.ToCommand(),
cancellationToken));
}
[HttpGet("wrong-questions")]
[EndpointSummary("查询错题列表")]
[ProducesResponseType<LearningList<WrongQuestionItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<LearningList<WrongQuestionItem>>> GetWrongQuestions(
[FromQuery] LearningLimitQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await service.GetWrongQuestionsAsync(
actorResolver.Resolve(),
query.ToFilter(),
cancellationToken));
}
[HttpGet("wrong-questions/review-plan")]
[EndpointSummary("生成错题复习计划")]
[ProducesResponseType<WrongQuestionReviewPlan>(StatusCodes.Status200OK)]
public async Task<ActionResult<WrongQuestionReviewPlan>> GetWrongQuestionReviewPlan(
[FromQuery] LearningLimitQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await service.GetWrongQuestionReviewPlanAsync(
actorResolver.Resolve(),
query.ToFilter(),
cancellationToken));
}
[HttpPost("wrong-questions/resolve")]
[EndpointSummary("将错题标记为已解决")]
[ProducesResponseType<LearningActionResult>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<LearningActionResult>> ResolveWrongQuestion(
QuestionActionDto request,
CancellationToken cancellationToken)
{
return Ok(await service.ResolveWrongQuestionAsync(
actorResolver.Resolve(),
request.ToCommand(),
cancellationToken));
}
}

View File

@@ -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<LearningList<WordProgressItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<LearningList<WordProgressItem>>> GetWordProgress(
[FromQuery] LearningLimitQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await service.GetWordProgressAsync(
actorResolver.Resolve(),
query.ToFilter(),
cancellationToken));
}
[HttpGet("vocabulary/review-plan")]
[EndpointSummary("生成单词复习计划")]
[ProducesResponseType<WordReviewPlan>(StatusCodes.Status200OK)]
public async Task<ActionResult<WordReviewPlan>> GetWordReviewPlan(
[FromQuery] LearningLimitQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await service.GetWordReviewPlanAsync(
actorResolver.Resolve(),
query.ToFilter(),
cancellationToken));
}
[HttpPost("vocabulary/progress")]
[EndpointSummary("更新单词学习进度")]
[ProducesResponseType<WordProgressItem>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<WordProgressItem>> UpdateWordProgress(
WordProgressDto request,
CancellationToken cancellationToken)
{
return Ok(await service.UpdateWordProgressAsync(
actorResolver.Resolve(),
request.ToCommand(),
cancellationToken));
}
[HttpPost("vocabulary/review")]
[EndpointSummary("提交单词复习结果")]
[ProducesResponseType<WordProgressItem>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<WordProgressItem>> ReviewWord(
WordReviewDto request,
CancellationToken cancellationToken)
{
return Ok(await service.ReviewWordAsync(
actorResolver.Resolve(),
request.ToCommand(),
cancellationToken));
}
[HttpGet("vocabulary/stats")]
[EndpointSummary("查询单词学习统计")]
[ProducesResponseType<WordStatsItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<WordStatsItem>> GetWordStats(
[FromQuery] LearningLimitQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await service.GetWordStatsAsync(
actorResolver.Resolve(),
query.ToFilter(),
cancellationToken));
}
[HttpGet("vocabulary/favorites")]
[EndpointSummary("查询收藏单词")]
[ProducesResponseType<LearningList<FavoriteWordItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<LearningList<FavoriteWordItem>>> GetFavoriteWords(
[FromQuery] LearningLimitQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await service.GetFavoriteWordsAsync(
actorResolver.Resolve(),
query.ToFilter(),
cancellationToken));
}
[HttpPost("vocabulary/favorites")]
[EndpointSummary("收藏或取消收藏单词")]
[ProducesResponseType<LearningActionResult>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public async Task<ActionResult<LearningActionResult>> ToggleFavoriteWord(
FavoriteWordDto request,
CancellationToken cancellationToken)
{
return Ok(await service.ToggleFavoriteWordAsync(
actorResolver.Resolve(),
request.ToCommand(),
cancellationToken));
}
}

View File

@@ -1,113 +0,0 @@
namespace Tiku.Application.Learning;
public interface ILearningActivityService
{
Task<LearningStatsItem> GetStatsAsync(
LearningActor actor,
CancellationToken cancellationToken = default);
Task<LearningList<LearningTrendItem>> GetTrendAsync(
LearningActor actor,
LearningLimitFilter filter,
CancellationToken cancellationToken = default);
Task<LearningLeaderboardResult> GetLeaderboardAsync(
LearningActor actor,
LearningLimitFilter filter,
CancellationToken cancellationToken = default);
Task<AnswerRecordItem> SubmitAnswerAsync(
LearningActor actor,
SubmitAnswerCommand command,
CancellationToken cancellationToken = default);
Task<LearningList<FavoriteQuestionItem>> GetFavoriteQuestionsAsync(
LearningActor actor,
LearningLimitFilter filter,
CancellationToken cancellationToken = default);
Task<LearningActionResult> ToggleFavoriteQuestionAsync(
LearningActor actor,
QuestionActionCommand command,
CancellationToken cancellationToken = default);
Task<LearningList<WrongQuestionItem>> GetWrongQuestionsAsync(
LearningActor actor,
LearningLimitFilter filter,
CancellationToken cancellationToken = default);
Task<WrongQuestionReviewPlan> GetWrongQuestionReviewPlanAsync(
LearningActor actor,
LearningLimitFilter filter,
CancellationToken cancellationToken = default);
Task<LearningActionResult> ResolveWrongQuestionAsync(
LearningActor actor,
QuestionActionCommand command,
CancellationToken cancellationToken = default);
Task<LearningList<WordProgressItem>> GetWordProgressAsync(
LearningActor actor,
LearningLimitFilter filter,
CancellationToken cancellationToken = default);
Task<WordReviewPlan> GetWordReviewPlanAsync(
LearningActor actor,
LearningLimitFilter filter,
CancellationToken cancellationToken = default);
Task<WordProgressItem> UpdateWordProgressAsync(
LearningActor actor,
WordProgressCommand command,
CancellationToken cancellationToken = default);
Task<WordProgressItem> ReviewWordAsync(
LearningActor actor,
WordReviewCommand command,
CancellationToken cancellationToken = default);
Task<WordStatsItem> GetWordStatsAsync(
LearningActor actor,
LearningLimitFilter filter,
CancellationToken cancellationToken = default);
Task<LearningList<FavoriteWordItem>> GetFavoriteWordsAsync(
LearningActor actor,
LearningLimitFilter filter,
CancellationToken cancellationToken = default);
Task<LearningActionResult> ToggleFavoriteWordAsync(
LearningActor actor,
FavoriteWordCommand command,
CancellationToken cancellationToken = default);
Task<PracticeSessionItem> CreatePracticeSessionAsync(
LearningActor actor,
PracticeSessionCommand command,
CancellationToken cancellationToken = default);
Task<PracticeSessionDetailItem> GetPracticeSessionDetailAsync(
LearningActor actor,
PracticeSessionFilter filter,
CancellationToken cancellationToken = default);
Task<PracticeSessionReportItem> SubmitPracticeSessionAsync(
LearningActor actor,
SubmitPracticeSessionCommand command,
CancellationToken cancellationToken = default);
Task<PracticeSessionReportItem> GetPracticeSessionReportAsync(
LearningActor actor,
PracticeSessionFilter filter,
CancellationToken cancellationToken = default);
Task<LearningList<PracticeSessionReportItem>> GetPracticeReportsAsync(
LearningActor actor,
PracticeSessionFilter filter,
CancellationToken cancellationToken = default);
Task<LearningList<PracticeHistoryItem>> GetPracticeHistoryAsync(
LearningActor actor,
PracticeSessionFilter filter,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,68 @@
namespace Tiku.Application.Learning;
public interface ILearningAnalyticsService
{
Task<LearningStatsItem> GetStatsAsync(LearningActor actor, CancellationToken cancellationToken = default);
Task<LearningList<LearningTrendItem>> GetTrendAsync(LearningActor actor, LearningLimitFilter filter,
CancellationToken cancellationToken = default);
Task<LearningLeaderboardResult> GetLeaderboardAsync(LearningActor actor, LearningLimitFilter filter,
CancellationToken cancellationToken = default);
}
public interface IAnsweringService
{
Task<AnswerRecordItem> SubmitAnswerAsync(LearningActor actor, SubmitAnswerCommand command,
CancellationToken cancellationToken = default);
}
public interface IQuestionReviewService
{
Task<LearningList<FavoriteQuestionItem>> GetFavoriteQuestionsAsync(LearningActor actor,
LearningLimitFilter filter, CancellationToken cancellationToken = default);
Task<LearningActionResult> ToggleFavoriteQuestionAsync(LearningActor actor, QuestionActionCommand command,
CancellationToken cancellationToken = default);
Task<LearningList<WrongQuestionItem>> GetWrongQuestionsAsync(LearningActor actor, LearningLimitFilter filter,
CancellationToken cancellationToken = default);
Task<WrongQuestionReviewPlan> GetWrongQuestionReviewPlanAsync(LearningActor actor, LearningLimitFilter filter,
CancellationToken cancellationToken = default);
Task<LearningActionResult> ResolveWrongQuestionAsync(LearningActor actor, QuestionActionCommand command,
CancellationToken cancellationToken = default);
}
public interface IWordLearningService
{
Task<LearningList<WordProgressItem>> GetWordProgressAsync(LearningActor actor, LearningLimitFilter filter,
CancellationToken cancellationToken = default);
Task<WordReviewPlan> GetWordReviewPlanAsync(LearningActor actor, LearningLimitFilter filter,
CancellationToken cancellationToken = default);
Task<WordProgressItem> UpdateWordProgressAsync(LearningActor actor, WordProgressCommand command,
CancellationToken cancellationToken = default);
Task<WordProgressItem> ReviewWordAsync(LearningActor actor, WordReviewCommand command,
CancellationToken cancellationToken = default);
Task<WordStatsItem> GetWordStatsAsync(LearningActor actor, LearningLimitFilter filter,
CancellationToken cancellationToken = default);
Task<LearningList<FavoriteWordItem>> GetFavoriteWordsAsync(LearningActor actor, LearningLimitFilter filter,
CancellationToken cancellationToken = default);
Task<LearningActionResult> ToggleFavoriteWordAsync(LearningActor actor, FavoriteWordCommand command,
CancellationToken cancellationToken = default);
}
public interface IPracticeSessionService
{
Task<PracticeSessionItem> CreatePracticeSessionAsync(LearningActor actor, PracticeSessionCommand command,
CancellationToken cancellationToken = default);
Task<PracticeSessionDetailItem> GetPracticeSessionDetailAsync(LearningActor actor, PracticeSessionFilter filter,
CancellationToken cancellationToken = default);
Task<PracticeSessionReportItem> SubmitPracticeSessionAsync(LearningActor actor,
SubmitPracticeSessionCommand command, CancellationToken cancellationToken = default);
}
public interface IPracticeReportService
{
Task<PracticeSessionReportItem> GetPracticeSessionReportAsync(LearningActor actor, PracticeSessionFilter filter,
CancellationToken cancellationToken = default);
Task<LearningList<PracticeSessionReportItem>> GetPracticeReportsAsync(LearningActor actor,
PracticeSessionFilter filter, CancellationToken cancellationToken = default);
Task<LearningList<PracticeHistoryItem>> GetPracticeHistoryAsync(LearningActor actor,
PracticeSessionFilter filter, CancellationToken cancellationToken = default);
}

View File

@@ -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<LearningStatsItem> GetStatsAsync(
LearningActor actor,
@@ -113,4 +114,4 @@ public sealed partial class LearningActivityService
items.FirstOrDefault(item => item.UserId == actor.UserId),
DateTimeOffset.UtcNow);
}
}
}

View File

@@ -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<AnswerRecordItem> SubmitAnswerAsync(
LearningActor actor,
@@ -169,4 +170,4 @@ public sealed partial class LearningActivityService
return response;
}
}
}

View File

@@ -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<PracticeAssembly> BuildPracticeAssemblyAsync(
protected async Task<PracticeAssembly> BuildPracticeAssemblyAsync(
Guid tenantId,
PracticeSessionCommand command,
CancellationToken cancellationToken)
@@ -63,7 +63,7 @@ public sealed partial class LearningActivityService
};
}
private async Task<List<Guid>> CollectQuestionReferenceIdsAsync(
protected async Task<List<Guid>> CollectQuestionReferenceIdsAsync(
LearningActor actor,
PracticeAssembly assembly,
CancellationToken cancellationToken)
@@ -137,7 +137,7 @@ public sealed partial class LearningActivityService
return referenceIds;
}
private static IQueryable<Question> ApplyLegacyTargetFilter(
protected static IQueryable<Question> ApplyLegacyTargetFilter(
IQueryable<Question> query,
string? targetType,
Guid targetId)
@@ -154,14 +154,14 @@ public sealed partial class LearningActivityService
};
}
private async Task<IReadOnlyList<QuestionSelection>> LoadQuestionSelectionsAsync(
protected async Task<IReadOnlyList<QuestionSelection>> LoadQuestionSelectionsAsync(
Guid tenantId,
IReadOnlyCollection<Guid> 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<PracticeSessionQuestionItem[]> LoadSessionQuestionItemsAsync(
protected Task<PracticeSessionQuestionItem[]> 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<PracticeSession> GetPracticeSessionAsync(
protected async Task<PracticeSession> GetPracticeSessionAsync(
LearningActor actor,
Guid? practiceSessionId,
CancellationToken cancellationToken)
@@ -276,7 +276,7 @@ public sealed partial class LearningActivityService
return session;
}
private async Task<PracticeSessionReport> BuildPracticeSessionReportAsync(
protected async Task<PracticeSessionReport> 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<Guid> ReadGuidArray(JsonElement value)
protected static List<Guid> 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);
}
}

View File

@@ -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<LearningActivityServiceBase> 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<LearningActivityServiceBase> 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<long> IdempotencyReplays =
LearningMeter.CreateCounter<long>("tiku.learning.idempotency.replays");
protected static readonly Counter<long> AnswerConflicts =
LearningMeter.CreateCounter<long>("tiku.learning.answer.conflicts");
protected static readonly Counter<long> SubmissionConflicts =
LearningMeter.CreateCounter<long>("tiku.learning.submission.conflicts");
protected static readonly Counter<long> ScoringFailures =
LearningMeter.CreateCounter<long>("tiku.learning.scoring.failures");
}

View File

@@ -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<LearningActivityService> logger) : ILearningActivityService
{
private const int DefaultLimit = 100;
private const int MaxLimit = 500;
private static readonly Meter LearningMeter = new("Tiku.Learning");
private static readonly Counter<long> IdempotencyReplays =
LearningMeter.CreateCounter<long>("tiku.learning.idempotency.replays");
private static readonly Counter<long> AnswerConflicts =
LearningMeter.CreateCounter<long>("tiku.learning.answer.conflicts");
private static readonly Counter<long> SubmissionConflicts =
LearningMeter.CreateCounter<long>("tiku.learning.submission.conflicts");
private static readonly Counter<long> ScoringFailures =
LearningMeter.CreateCounter<long>("tiku.learning.scoring.failures");
}

View File

@@ -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<PracticeSessionReportItem> 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<LearningList<PracticeSessionReportItem>> 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<PracticeSessionReportItem>(items.Select(ToItem).ToArray());
}
public async Task<LearningList<PracticeHistoryItem>> 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<PracticeHistoryItem>(items);
}
}

View File

@@ -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<PracticeSessionItem> CreatePracticeSessionAsync(
LearningActor actor,
@@ -237,102 +238,4 @@ public sealed partial class LearningActivityService
return response;
}
public async Task<PracticeSessionReportItem> 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<LearningList<PracticeSessionReportItem>> 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<PracticeSessionReportItem>(items.Select(ToItem).ToArray());
}
public async Task<LearningList<PracticeHistoryItem>> 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<PracticeHistoryItem>(items);
}
}
}

View File

@@ -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<LearningList<FavoriteQuestionItem>> GetFavoriteQuestionsAsync(
LearningActor actor,
@@ -153,4 +154,4 @@ public sealed partial class LearningActivityService
recommendedEndpoint = "/api/student/learning/practice-sessions"
}));
}
}
}

View File

@@ -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<LearningList<WordProgressItem>> GetWordProgressAsync(
LearningActor actor,
@@ -258,4 +259,4 @@ public sealed partial class LearningActivityService
await dbContext.SaveChangesAsync(cancellationToken);
return new LearningActionResult(true, favorite);
}
}
}

View File

@@ -8,7 +8,13 @@ internal static class LearningModule
{
internal static IServiceCollection AddLearningModule(this IServiceCollection services)
{
services.AddScoped<ILearningActivityService, LearningActivityService>();
services.AddScoped<LearningServiceDependencies>();
services.AddScoped<ILearningAnalyticsService, LearningAnalyticsService>();
services.AddScoped<IAnsweringService, AnsweringService>();
services.AddScoped<IQuestionReviewService, QuestionReviewService>();
services.AddScoped<IWordLearningService, WordLearningService>();
services.AddScoped<IPracticeSessionService, PracticeSessionService>();
services.AddScoped<IPracticeReportService, PracticeReportService>();
return services;
}
}
}

View File

@@ -35,7 +35,6 @@ public sealed class ArchitectureBoundaryTests
var root = FindRepositoryRoot();
var legacyLineBudgets = new Dictionary<string, int>(StringComparer.Ordinal)
{
["LearningActivityService"] = 1781,
["PlatformAdminService"] = 1719,
["ContentManagementService"] = 1096,
["PlatformQuestionBankService"] = 1033,

View File

@@ -328,7 +328,7 @@ public sealed class PhaseThreeTenantIsolationTests
Guid firstSessionId;
using (var scope = factory.CreateTenantScope(tenantId, "tenant-a"))
{
var created = await scope.ServiceProvider.GetRequiredService<ILearningActivityService>()
var created = await scope.ServiceProvider.GetRequiredService<IPracticeSessionService>()
.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<ILearningActivityService>()
var created = await scope.ServiceProvider.GetRequiredService<IPracticeSessionService>()
.CreatePracticeSessionAsync(
new LearningActor(tenantId, userId),
new PracticeSessionCommand(
@@ -395,4 +395,4 @@ public sealed class PhaseThreeTenantIsolationTests
Mode = mode
};
}
}
}