From e505384ecf9b6553153f43d015b316992e684a6a Mon Sep 17 00:00:00 2001 From: xiong Date: Sun, 26 Jul 2026 15:25:33 +0800 Subject: [PATCH] feat: add practice session workflow endpoints --- Tiku.Api/Contracts/LearningDtos.cs | 85 +++ Tiku.Api/Controllers/LearningController.cs | 82 +++ .../Middleware/ExceptionHandlingMiddleware.cs | 11 +- .../Learning/ILearningActivityService.cs | 30 + .../Learning/LearningActivityModels.cs | 104 +++ .../Learning/LearningActivityService.cs | 671 ++++++++++++++++++ .../Api/LearningEndpointTests.cs | 108 +++ 7 files changed, 1090 insertions(+), 1 deletion(-) diff --git a/Tiku.Api/Contracts/LearningDtos.cs b/Tiku.Api/Contracts/LearningDtos.cs index e7fc93d..c8d566e 100644 --- a/Tiku.Api/Contracts/LearningDtos.cs +++ b/Tiku.Api/Contracts/LearningDtos.cs @@ -1,5 +1,7 @@ using System.ComponentModel.DataAnnotations; +using System.Text.Json; using Tiku.Application.Learning; +using Tiku.Domain.Common; namespace Tiku.Api.Contracts; @@ -19,6 +21,89 @@ public sealed class LearningLimitQueryDto } } +public sealed class PracticeSessionQueryDto +{ + public Guid? PracticeSessionId { get; set; } + + public Guid? BlueprintId { get; set; } + + [StringLength(50)] + public string? Mode { get; set; } + + [StringLength(50)] + public string? Status { get; set; } + + [Range(1, 200)] + public int? Limit { get; set; } + + public PracticeSessionFilter ToFilter(Guid? routePracticeSessionId = null) + { + return new PracticeSessionFilter( + routePracticeSessionId ?? PracticeSessionId, + BlueprintId, + Mode, + Status, + Limit); + } +} + +public sealed class CreatePracticeSessionDto +{ + [StringLength(50)] + public string? Mode { get; set; } + + [StringLength(50)] + public string? TargetType { get; set; } + + public Guid? TargetId { get; set; } + + public Guid? BlueprintId { get; set; } + + public Guid? CollectionId { get; set; } + + public Guid? EntryId { get; set; } + + public Guid? ContentNodeId { get; set; } + + [Range(1, 500)] + public int? QuestionLimit { get; set; } + + [Range(1, 1440)] + public int? DurationMinutes { get; set; } + + [Range(typeof(decimal), "0", "99999")] + public decimal? TotalScore { get; set; } + + public JsonElement Metadata { get; set; } = JsonDefaults.Object(); + + public PracticeSessionCommand ToCommand() + { + return new PracticeSessionCommand( + Mode, + TargetType, + TargetId, + BlueprintId, + CollectionId, + EntryId, + ContentNodeId, + QuestionLimit, + DurationMinutes, + TotalScore, + Metadata); + } +} + +public sealed class SubmitPracticeSessionDto +{ + [Required] + public Guid PracticeSessionId { get; set; } + + public PracticeSessionFilter ToFilter() + { + return new PracticeSessionFilter(PracticeSessionId); + } +} + public sealed class SubmitAnswerDto { [Required] diff --git a/Tiku.Api/Controllers/LearningController.cs b/Tiku.Api/Controllers/LearningController.cs index d1f0159..6032f2f 100644 --- a/Tiku.Api/Controllers/LearningController.cs +++ b/Tiku.Api/Controllers/LearningController.cs @@ -15,6 +15,88 @@ public sealed class LearningController( ICurrentUser currentUser, ICurrentTenant currentTenant) : ControllerBase { + [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.ToFilter(), + 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)] diff --git a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs index dfa8be9..7df5bbc 100644 --- a/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs +++ b/Tiku.Api/Middleware/ExceptionHandlingMiddleware.cs @@ -93,7 +93,7 @@ public sealed class ExceptionHandlingMiddleware( await WriteProblemAsync( context, learningValidationException.Message, - StatusCodes.Status400BadRequest, + LearningValidationStatusCode(learningValidationException.Code), learningValidationException.Code); return; } @@ -200,4 +200,13 @@ public sealed class ExceptionHandlingMiddleware( _ => StatusCodes.Status400BadRequest }; } + + private static int LearningValidationStatusCode(string code) + { + return code switch + { + "no_practice_questions" or "practice_session_empty" => StatusCodes.Status409Conflict, + _ => StatusCodes.Status400BadRequest + }; + } } diff --git a/Tiku.Application/Learning/ILearningActivityService.cs b/Tiku.Application/Learning/ILearningActivityService.cs index 79c3c9f..553c13b 100644 --- a/Tiku.Application/Learning/ILearningActivityService.cs +++ b/Tiku.Application/Learning/ILearningActivityService.cs @@ -46,4 +46,34 @@ public interface ILearningActivityService 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, + PracticeSessionFilter filter, + 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); } diff --git a/Tiku.Application/Learning/LearningActivityModels.cs b/Tiku.Application/Learning/LearningActivityModels.cs index 583e4f1..0a9fe0d 100644 --- a/Tiku.Application/Learning/LearningActivityModels.cs +++ b/Tiku.Application/Learning/LearningActivityModels.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Tiku.Domain.Content; +using Tiku.Domain.Learning; namespace Tiku.Application.Learning; @@ -27,6 +28,26 @@ public sealed record FavoriteWordCommand(Guid WordId, bool? Favorite, string? No public sealed record LearningLimitFilter(int? Limit = null, string? Status = null, Guid? UnitId = null); +public sealed record PracticeSessionCommand( + string? Mode, + string? TargetType, + Guid? TargetId, + Guid? BlueprintId, + Guid? CollectionId, + Guid? EntryId, + Guid? ContentNodeId, + int? QuestionLimit, + int? DurationMinutes, + decimal? TotalScore, + JsonElement Metadata); + +public sealed record PracticeSessionFilter( + Guid? PracticeSessionId = null, + Guid? BlueprintId = null, + string? Mode = null, + string? Status = null, + int? Limit = null); + public sealed record AnswerRecordItem( Guid Id, Guid QuestionId, @@ -67,3 +88,86 @@ public sealed record FavoriteWordItem( DateTimeOffset? FavoritedAt); public sealed record LearningActionResult(bool Ok, bool? IsFavorite = null); + +public sealed record PracticeSessionItem( + Guid Id, + string Mode, + string? TargetType, + Guid? TargetId, + Guid? BlueprintId, + Guid? CollectionId, + Guid? EntryId, + Guid? ContentNodeId, + JsonElement QuestionIds, + int QuestionCount, + int? DurationMinutes, + decimal? TotalScore, + PracticeAccessMode AccessMode, + Guid? AccessEntitlementId, + int ConsumedFreeQuota, + JsonElement AccessSnapshot, + DateTimeOffset StartedAt, + DateTimeOffset? FinishedAt, + DateTimeOffset? ExpiresAt, + JsonElement Metadata, + string Status); + +public sealed record PracticeSessionDetailItem( + PracticeSessionItem Session, + IReadOnlyCollection Questions, + IReadOnlyDictionary AnswersByQuestion); + +public sealed record PracticeSessionQuestionItem( + Guid Id, + string Type, + string? TypeLabel, + int? Difficulty, + JsonElement Tags, + Guid? VersionId, + string? Content, + JsonElement Options, + string? Explanation); + +public sealed record PracticeSessionReportItem( + Guid Id, + Guid PracticeSessionId, + Guid? BlueprintId, + Guid? CollectionId, + string Mode, + int TotalQuestions, + int AnsweredCount, + int CorrectCount, + int WrongCount, + int UnansweredCount, + decimal Score, + decimal TotalScore, + decimal Accuracy, + int DurationSeconds, + DateTimeOffset? StartedAt, + DateTimeOffset SubmittedAt, + JsonElement SectionStats, + JsonElement QuestionResults, + JsonElement WrongQuestionIds, + JsonElement Metadata); + +public sealed record PracticeHistoryItem( + Guid Id, + string Mode, + string? TargetType, + Guid? TargetId, + Guid? BlueprintId, + Guid? CollectionId, + Guid? EntryId, + Guid? ContentNodeId, + int QuestionCount, + int AnsweredCount, + int CorrectCount, + int WrongCount, + DateTimeOffset StartedAt, + DateTimeOffset? FinishedAt, + DateTimeOffset? ExpiresAt, + string Status, + Guid? ReportId, + decimal? Score, + decimal? ReportTotalScore, + decimal? Accuracy); diff --git a/Tiku.Infrastructure/Learning/LearningActivityService.cs b/Tiku.Infrastructure/Learning/LearningActivityService.cs index ad5bc36..b59e29a 100644 --- a/Tiku.Infrastructure/Learning/LearningActivityService.cs +++ b/Tiku.Infrastructure/Learning/LearningActivityService.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using System.Text.Json; using Tiku.Application.Learning; +using Tiku.Domain.Common; using Tiku.Domain.Content; using Tiku.Domain.Learning; using Tiku.Domain.QuestionBanks; @@ -383,6 +384,568 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning return new LearningActionResult(true, favorite); } + public async Task CreatePracticeSessionAsync( + LearningActor actor, + PracticeSessionCommand command, + CancellationToken cancellationToken = default) + { + var assembly = await BuildPracticeAssemblyAsync(actor.TenantId, command, cancellationToken); + var questionIds = await CollectQuestionIdsAsync(actor, assembly, cancellationToken); + if (questionIds.Count == 0) + { + throw new LearningValidationException("no_practice_questions", "No published questions are available for this practice target."); + } + + var now = DateTimeOffset.UtcNow; + var session = new PracticeSession + { + TenantId = actor.TenantId, + UserId = actor.UserId, + Mode = assembly.Mode, + TargetType = assembly.TargetType, + TargetId = assembly.TargetId, + BlueprintId = assembly.BlueprintId, + CollectionId = assembly.CollectionId, + EntryId = assembly.EntryId, + ContentNodeId = assembly.ContentNodeId, + QuestionIds = JsonSerializer.SerializeToElement(questionIds), + QuestionCount = questionIds.Count, + DurationMinutes = assembly.DurationMinutes, + TotalScore = assembly.TotalScore, + ExpiresAt = assembly.DurationMinutes.HasValue + ? now.AddMinutes(assembly.DurationMinutes.Value) + : null, + AccessMode = PracticeAccessMode.Free, + ConsumedFreeQuota = questionIds.Count, + AccessSnapshot = JsonSerializer.SerializeToElement(new + { + strategy = "v1_free", + requestedCount = assembly.QuestionLimit, + grantedCount = questionIds.Count + }), + Metadata = command.Metadata.ValueKind is JsonValueKind.Undefined + ? JsonDefaults.Object() + : command.Metadata + }; + dbContext.PracticeSessions.Add(session); + dbContext.PracticeAccessEvents.Add(new PracticeAccessEvent + { + TenantId = actor.TenantId, + UserId = actor.UserId, + PracticeSessionId = session.Id, + EventType = PracticeAccessEventType.SessionCreated, + AccessMode = PracticeAccessEventMode.Free, + RequestedCount = assembly.QuestionLimit, + GrantedCount = questionIds.Count, + ConsumedFreeQuota = questionIds.Count, + Metadata = session.AccessSnapshot + }); + + await dbContext.SaveChangesAsync(cancellationToken); + return ToItem(session); + } + + public async Task GetPracticeSessionDetailAsync( + LearningActor actor, + PracticeSessionFilter filter, + CancellationToken cancellationToken = default) + { + var session = await GetPracticeSessionAsync(actor, filter.PracticeSessionId, cancellationToken); + var questionIds = ReadGuidArray(session.QuestionIds); + var questions = await dbContext.Questions + .AsNoTracking() + .Where(question => + question.TenantId == actor.TenantId && + questionIds.Contains(question.Id)) + .GroupJoin( + dbContext.QuestionVersions.AsNoTracking(), + question => new { question.TenantId, QuestionId = question.Id, VersionId = question.CurrentVersionId }, + version => new { version.TenantId, version.QuestionId, VersionId = (Guid?)version.Id }, + (question, versions) => new { question, version = versions.FirstOrDefault() }) + .Select(row => new PracticeSessionQuestionItem( + row.question.Id, + row.question.Type, + row.question.TypeLabel, + row.question.Difficulty, + row.question.Tags, + row.version == null ? null : row.version.Id, + row.version == null ? null : row.version.Content, + row.version == null ? JsonDefaults.Array() : row.version.Options, + row.version == null ? null : row.version.Explanation)) + .ToArrayAsync(cancellationToken); + var questionById = questions.ToDictionary(question => question.Id); + var orderedQuestions = questionIds + .Where(questionById.ContainsKey) + .Select(questionId => questionById[questionId]) + .ToArray(); + + var answers = await dbContext.AnswerRecords + .AsNoTracking() + .Where(answer => + answer.TenantId == actor.TenantId && + answer.UserId == actor.UserId && + answer.PracticeSessionId == session.Id && + answer.QuestionId != null) + .ToArrayAsync(cancellationToken); + var answersByQuestion = answers + .GroupBy(answer => answer.QuestionId!.Value) + .ToDictionary( + group => group.Key, + group => ToItem(group.OrderByDescending(answer => answer.AnsweredAt).First())); + + return new PracticeSessionDetailItem(ToItem(session), orderedQuestions, answersByQuestion); + } + + public async Task SubmitPracticeSessionAsync( + LearningActor actor, + PracticeSessionFilter filter, + CancellationToken cancellationToken = default) + { + var session = await GetPracticeSessionAsync(actor, filter.PracticeSessionId, cancellationToken); + var existing = await dbContext.PracticeSessionReports + .AsNoTracking() + .SingleOrDefaultAsync( + report => + report.TenantId == actor.TenantId && + report.PracticeSessionId == session.Id, + cancellationToken); + if (existing is not null) + { + return ToItem(existing); + } + + var report = await BuildPracticeSessionReportAsync(actor, session, cancellationToken); + session.FinishedAt ??= report.SubmittedAt; + await dbContext.SaveChangesAsync(cancellationToken); + return ToItem(report); + } + + 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, + PracticeSessionStatus(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); + } + + private async Task BuildPracticeAssemblyAsync( + Guid tenantId, + PracticeSessionCommand command, + CancellationToken cancellationToken) + { + var mode = NormalizeMode(command.Mode); + var assembly = new PracticeAssembly( + mode, + command.TargetType, + command.TargetId, + command.BlueprintId, + command.CollectionId, + command.EntryId, + command.ContentNodeId, + Math.Clamp(command.QuestionLimit ?? 100, 1, MaxLimit), + command.DurationMinutes, + command.TotalScore); + + if (!command.BlueprintId.HasValue) + { + return assembly; + } + + var blueprint = await dbContext.PracticeBlueprints + .AsNoTracking() + .SingleOrDefaultAsync( + item => + item.TenantId == tenantId && + item.Id == command.BlueprintId.Value && + item.Status == ContentStatus.Active, + cancellationToken); + + if (blueprint is null) + { + throw new LearningResourceNotFoundException("practice_blueprint_not_found", "Practice blueprint was not found."); + } + + return assembly with + { + Mode = NormalizeMode(blueprint.Mode.ToString()), + TargetType = command.TargetType ?? "blueprint", + TargetId = command.TargetId ?? blueprint.Id, + CollectionId = command.CollectionId ?? blueprint.CollectionId, + EntryId = command.EntryId ?? blueprint.EntryId, + ContentNodeId = command.ContentNodeId ?? blueprint.NodeId, + QuestionLimit = Math.Clamp(command.QuestionLimit ?? blueprint.QuestionLimit ?? 100, 1, MaxLimit), + DurationMinutes = command.DurationMinutes ?? blueprint.DurationMinutes, + TotalScore = command.TotalScore ?? blueprint.TotalScore + }; + } + + private async Task> CollectQuestionIdsAsync( + LearningActor actor, + PracticeAssembly assembly, + CancellationToken cancellationToken) + { + if (assembly.Mode == "wrong_review") + { + return await dbContext.WrongQuestions + .AsNoTracking() + .Where(item => + item.TenantId == actor.TenantId && + item.UserId == actor.UserId && + item.ResolvedAt == null) + .Join( + dbContext.Questions.AsNoTracking(), + item => new { item.TenantId, item.QuestionId }, + question => new { question.TenantId, QuestionId = question.Id }, + (item, question) => new { item, question }) + .Where(row => row.question.Status == QuestionStatus.Published) + .OrderByDescending(row => row.item.WrongCount) + .ThenBy(row => row.item.LastWrongAt) + .Take(assembly.QuestionLimit) + .Select(row => row.question.Id) + .ToListAsync(cancellationToken); + } + + if (assembly.Mode == "favorite_review") + { + return await dbContext.FavoriteQuestions + .AsNoTracking() + .Where(item => + item.TenantId == actor.TenantId && + item.UserId == actor.UserId) + .Join( + dbContext.Questions.AsNoTracking(), + item => new { item.TenantId, item.QuestionId }, + question => new { question.TenantId, QuestionId = question.Id }, + (item, question) => new { item, question }) + .Where(row => row.question.Status == QuestionStatus.Published) + .OrderByDescending(row => row.item.CreatedAt) + .Take(assembly.QuestionLimit) + .Select(row => row.question.Id) + .ToListAsync(cancellationToken); + } + + if (assembly.CollectionId.HasValue) + { + return await dbContext.QuestionCollectionItems + .AsNoTracking() + .Where(item => + item.TenantId == actor.TenantId && + item.CollectionId == assembly.CollectionId.Value) + .Join( + dbContext.Questions.AsNoTracking(), + item => new { item.TenantId, item.QuestionId }, + question => new { question.TenantId, QuestionId = question.Id }, + (item, question) => new { item, question }) + .Where(row => row.question.Status == QuestionStatus.Published) + .OrderBy(row => row.item.SortOrder) + .ThenBy(row => row.question.CreatedAt) + .Take(assembly.QuestionLimit) + .Select(row => row.question.Id) + .ToListAsync(cancellationToken); + } + + var query = dbContext.Questions + .AsNoTracking() + .Where(question => + question.TenantId == actor.TenantId && + question.Status == QuestionStatus.Published); + + if (assembly.ContentNodeId.HasValue) + { + query = query.Where(question => question.ContentNodeId == assembly.ContentNodeId.Value); + } + else if (assembly.EntryId.HasValue) + { + query = query.Where(question => question.EntryId == assembly.EntryId.Value); + } + else if (assembly.TargetId.HasValue && !string.IsNullOrWhiteSpace(assembly.TargetType)) + { + query = ApplyLegacyTargetFilter(query, assembly.TargetType, assembly.TargetId.Value); + } + else + { + throw new LearningValidationException("practice_target_required", "Practice target is required."); + } + + return await query + .OrderBy(question => question.CreatedAt) + .Take(assembly.QuestionLimit) + .Select(question => question.Id) + .ToListAsync(cancellationToken); + } + + private static IQueryable ApplyLegacyTargetFilter( + IQueryable query, + string? targetType, + Guid targetId) + { + return NormalizeEnumValue(targetType) switch + { + "subject" => query.Where(question => question.SubjectId == targetId), + "category" => query.Where(question => question.CategoryId == targetId), + "node" => query.Where(question => question.NodeId == targetId), + "questionbank" => query.Where(question => question.QuestionBankId == targetId), + "contentnode" => query.Where(question => question.ContentNodeId == targetId), + "entry" => query.Where(question => question.EntryId == targetId), + _ => query.Where(_ => false) + }; + } + + private async Task GetPracticeSessionAsync( + LearningActor actor, + Guid? practiceSessionId, + CancellationToken cancellationToken) + { + if (!practiceSessionId.HasValue) + { + throw new LearningValidationException("practice_session_id_required", "Practice session id is required."); + } + + var session = await dbContext.PracticeSessions + .SingleOrDefaultAsync( + item => + item.TenantId == actor.TenantId && + item.UserId == actor.UserId && + item.Id == practiceSessionId.Value, + cancellationToken); + + if (session is null) + { + throw new LearningResourceNotFoundException("practice_session_not_found", "Practice session was not found."); + } + + return session; + } + + private async Task BuildPracticeSessionReportAsync( + LearningActor actor, + PracticeSession session, + CancellationToken cancellationToken) + { + var questionIds = ReadGuidArray(session.QuestionIds); + if (questionIds.Count == 0) + { + throw new LearningValidationException("practice_session_empty", "Practice session has no question snapshot."); + } + + var answers = await dbContext.AnswerRecords + .AsNoTracking() + .Where(answer => + answer.TenantId == actor.TenantId && + answer.UserId == actor.UserId && + answer.PracticeSessionId == session.Id && + answer.QuestionId != null) + .ToArrayAsync(cancellationToken); + var latestAnswers = answers + .GroupBy(answer => answer.QuestionId!.Value) + .ToDictionary( + group => group.Key, + group => group.OrderByDescending(answer => answer.AnsweredAt).First()); + var totalQuestions = questionIds.Count; + var answeredCount = questionIds.Count(latestAnswers.ContainsKey); + var correctCount = questionIds.Count(questionId => + latestAnswers.TryGetValue(questionId, out var answer) && + answer.IsCorrect == true); + var wrongCount = questionIds.Count(questionId => + latestAnswers.TryGetValue(questionId, out var answer) && + answer.IsCorrect != true); + var unansweredCount = Math.Max(0, totalQuestions - answeredCount); + var totalScore = session.TotalScore ?? totalQuestions; + var scorePerQuestion = totalQuestions == 0 ? 0 : totalScore / totalQuestions; + var score = Math.Round(correctCount * scorePerQuestion, 2); + var accuracy = totalQuestions == 0 ? 0 : Math.Round((decimal)correctCount / totalQuestions, 4); + var submittedAt = DateTimeOffset.UtcNow; + var durationSeconds = Math.Max(0, (int)(submittedAt - session.StartedAt).TotalSeconds); + var wrongQuestionIds = questionIds + .Where(questionId => + latestAnswers.TryGetValue(questionId, out var answer) && + answer.IsCorrect != true) + .ToArray(); + var questionResults = questionIds + .Select(questionId => + { + latestAnswers.TryGetValue(questionId, out var answer); + return new + { + questionId, + answered = answer is not null, + isCorrect = answer?.IsCorrect, + score = answer?.IsCorrect == true ? scorePerQuestion : 0, + totalScore = scorePerQuestion, + answeredAt = answer?.AnsweredAt + }; + }) + .ToArray(); + var sectionStats = new[] + { + new + { + key = "default", + title = "默认", + questionCount = totalQuestions, + answeredCount, + correctCount, + wrongCount, + unansweredCount, + score, + totalScore, + accuracy, + sortOrder = 0 + } + }; + + var report = new PracticeSessionReport + { + TenantId = actor.TenantId, + UserId = actor.UserId, + PracticeSessionId = session.Id, + BlueprintId = session.BlueprintId, + CollectionId = session.CollectionId, + Mode = session.Mode, + TotalQuestions = totalQuestions, + AnsweredCount = answeredCount, + CorrectCount = correctCount, + WrongCount = wrongCount, + UnansweredCount = unansweredCount, + Score = score, + TotalScore = totalScore, + Accuracy = accuracy, + DurationSeconds = durationSeconds, + StartedAt = session.StartedAt, + SubmittedAt = submittedAt, + SectionStats = JsonSerializer.SerializeToElement(sectionStats), + QuestionResults = JsonSerializer.SerializeToElement(questionResults), + WrongQuestionIds = JsonSerializer.SerializeToElement(wrongQuestionIds), + Metadata = JsonSerializer.SerializeToElement(new + { + scoringVersion = 1, + scorePerQuestion + }) + }; + dbContext.PracticeSessionReports.Add(report); + dbContext.PracticeSessionReportSections.Add(new PracticeSessionReportSection + { + TenantId = actor.TenantId, + ReportId = report.Id, + PracticeSessionId = session.Id, + SectionKey = "default", + SectionName = "默认", + QuestionCount = totalQuestions, + AnsweredCount = answeredCount, + CorrectCount = correctCount, + WrongCount = wrongCount, + UnansweredCount = unansweredCount, + Score = score, + TotalScore = totalScore, + Accuracy = accuracy, + SortOrder = 0 + }); + + return report; + } + private async Task EnsureQuestionExistsAsync( Guid tenantId, Guid questionId, @@ -448,6 +1011,102 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning item.Metadata); } + private static PracticeSessionItem ToItem(PracticeSession item) + { + return new PracticeSessionItem( + item.Id, + item.Mode, + item.TargetType, + item.TargetId, + item.BlueprintId, + item.CollectionId, + item.EntryId, + item.ContentNodeId, + item.QuestionIds, + item.QuestionCount, + item.DurationMinutes, + item.TotalScore, + item.AccessMode, + item.AccessEntitlementId, + item.ConsumedFreeQuota, + item.AccessSnapshot, + item.StartedAt, + item.FinishedAt, + item.ExpiresAt, + item.Metadata, + PracticeSessionStatus(item, DateTimeOffset.UtcNow)); + } + + private static PracticeSessionReportItem ToItem(PracticeSessionReport item) + { + return new PracticeSessionReportItem( + item.Id, + item.PracticeSessionId, + item.BlueprintId, + item.CollectionId, + item.Mode, + item.TotalQuestions, + item.AnsweredCount, + item.CorrectCount, + item.WrongCount, + item.UnansweredCount, + item.Score, + item.TotalScore, + item.Accuracy, + item.DurationSeconds, + item.StartedAt, + item.SubmittedAt, + item.SectionStats, + item.QuestionResults, + item.WrongQuestionIds, + item.Metadata); + } + + private static List ReadGuidArray(JsonElement value) + { + if (value.ValueKind is not JsonValueKind.Array) + { + return []; + } + + return value.EnumerateArray() + .Select(item => item.ValueKind == JsonValueKind.String && Guid.TryParse(item.GetString(), out var id) + ? (Guid?)id + : null) + .Where(id => id.HasValue) + .Select(id => id!.Value) + .ToList(); + } + + private static string PracticeSessionStatus(PracticeSession session, DateTimeOffset now) + { + if (session.FinishedAt.HasValue) + { + return "finished"; + } + + if (session.ExpiresAt.HasValue && session.ExpiresAt.Value <= now) + { + return "expired"; + } + + return "active"; + } + + private static string NormalizeMode(string? mode) + { + return NormalizeEnumValue(mode) switch + { + "sequential" => "sequential", + "random" => "random", + "mockexam" => "mock_exam", + "paper" => "paper", + "wrongreview" => "wrong_review", + "favoritereview" => "favorite_review", + _ => "chapter" + }; + } + private static bool TryParseWordProgressStatus(string? value, out WordProgressStatus status) { return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out status); @@ -465,6 +1124,18 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning : value.Replace("_", string.Empty, StringComparison.Ordinal) .Replace("-", string.Empty, StringComparison.Ordinal); } + + private sealed record PracticeAssembly( + string Mode, + string? TargetType, + Guid? TargetId, + Guid? BlueprintId, + Guid? CollectionId, + Guid? EntryId, + Guid? ContentNodeId, + int QuestionLimit, + int? DurationMinutes, + decimal? TotalScore); } public class LearningException(string code, string message) : Exception(message) diff --git a/Tiku.IntegrationTests/Api/LearningEndpointTests.cs b/Tiku.IntegrationTests/Api/LearningEndpointTests.cs index 6f96d5f..f5652b9 100644 --- a/Tiku.IntegrationTests/Api/LearningEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/LearningEndpointTests.cs @@ -62,6 +62,114 @@ public sealed class LearningEndpointTests Assert.Equal(1, wrong.GetProperty("wrongCount").GetInt32()); } + [Fact] + public async Task Practice_session_can_be_created_detailed_submitted_and_listed() + { + await using var factory = new ApiTestFactory(); + var seed = await SeedLearningUserAsync(factory); + var collectionId = Guid.NewGuid(); + var firstQuestionId = Guid.NewGuid(); + var secondQuestionId = Guid.NewGuid(); + await factory.SeedAsync( + new QuestionCollection + { + Id = collectionId, + TenantId = seed.TenantId, + Name = "基础练习", + Status = ContentStatus.Active + }, + new Question + { + Id = firstQuestionId, + TenantId = seed.TenantId, + Type = "choice", + Status = QuestionStatus.Published + }, + new Question + { + Id = secondQuestionId, + TenantId = seed.TenantId, + Type = "choice", + Status = QuestionStatus.Published + }, + new QuestionCollectionItem + { + Id = Guid.NewGuid(), + TenantId = seed.TenantId, + CollectionId = collectionId, + QuestionId = firstQuestionId, + SortOrder = 1 + }, + new QuestionCollectionItem + { + Id = Guid.NewGuid(), + TenantId = seed.TenantId, + CollectionId = collectionId, + QuestionId = secondQuestionId, + SortOrder = 2 + }); + using var client = factory.CreateClient(); + await LoginAsync(client, seed); + + var createResponse = await client.PostAsJsonAsync( + "/api/learning/practice-sessions", + new CreatePracticeSessionDto + { + Mode = "chapter", + CollectionId = collectionId, + QuestionLimit = 2, + TotalScore = 100 + }); + var created = await ReadJsonAsync(createResponse); + var practiceSessionId = created.RootElement.GetProperty("id").GetGuid(); + var detailResponse = await client.GetAsync($"/api/learning/practice-sessions/detail?practiceSessionId={practiceSessionId}"); + var detail = await ReadJsonAsync(detailResponse); + + await client.PostAsJsonAsync( + "/api/learning/answers", + new SubmitAnswerDto + { + QuestionId = firstQuestionId, + PracticeSessionId = practiceSessionId, + SelectedOptions = ["A"], + SelfJudgedCorrect = true + }); + await client.PostAsJsonAsync( + "/api/learning/answers", + new SubmitAnswerDto + { + QuestionId = secondQuestionId, + PracticeSessionId = practiceSessionId, + SelectedOptions = ["B"], + SelfJudgedCorrect = false + }); + var submitResponse = await client.PostAsJsonAsync( + "/api/learning/practice-sessions/submit", + new SubmitPracticeSessionDto { PracticeSessionId = practiceSessionId }); + var report = await ReadJsonAsync(submitResponse); + var reportResponse = await client.GetAsync($"/api/learning/practice-sessions/report?practiceSessionId={practiceSessionId}"); + var reportsResponse = await client.GetAsync("/api/learning/practice-reports"); + var historyResponse = await client.GetAsync("/api/learning/practice-sessions/history?status=finished"); + var reports = await ReadItemsAsync(reportsResponse); + var history = await ReadItemsAsync(historyResponse); + + Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode); + Assert.Equal(2, created.RootElement.GetProperty("questionCount").GetInt32()); + Assert.Equal("active", created.RootElement.GetProperty("status").GetString()); + Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode); + Assert.Equal(2, detail.RootElement.GetProperty("questions").GetArrayLength()); + Assert.Equal(HttpStatusCode.OK, submitResponse.StatusCode); + Assert.Equal(2, report.RootElement.GetProperty("totalQuestions").GetInt32()); + Assert.Equal(1, report.RootElement.GetProperty("correctCount").GetInt32()); + Assert.Equal(1, report.RootElement.GetProperty("wrongCount").GetInt32()); + Assert.Equal(50m, report.RootElement.GetProperty("score").GetDecimal()); + Assert.Equal(HttpStatusCode.OK, reportResponse.StatusCode); + Assert.Single(reports); + var historyItem = Assert.Single(history); + Assert.Equal(practiceSessionId, historyItem.GetProperty("id").GetGuid()); + Assert.Equal("finished", historyItem.GetProperty("status").GetString()); + } + [Fact] public async Task Favorite_question_can_be_added_listed_and_removed() {