forked from gongxuegit/tiku-backend.net
feat: add practice session workflow endpoints
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Text.Json;
|
||||||
using Tiku.Application.Learning;
|
using Tiku.Application.Learning;
|
||||||
|
using Tiku.Domain.Common;
|
||||||
|
|
||||||
namespace Tiku.Api.Contracts;
|
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
|
public sealed class SubmitAnswerDto
|
||||||
{
|
{
|
||||||
[Required]
|
[Required]
|
||||||
|
|||||||
@@ -15,6 +15,88 @@ public sealed class LearningController(
|
|||||||
ICurrentUser currentUser,
|
ICurrentUser currentUser,
|
||||||
ICurrentTenant currentTenant) : ControllerBase
|
ICurrentTenant currentTenant) : 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 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.ToFilter(),
|
||||||
|
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")]
|
[HttpPost("answers")]
|
||||||
[EndpointSummary("提交题目答案")]
|
[EndpointSummary("提交题目答案")]
|
||||||
[ProducesResponseType<AnswerRecordItem>(StatusCodes.Status200OK)]
|
[ProducesResponseType<AnswerRecordItem>(StatusCodes.Status200OK)]
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ public sealed class ExceptionHandlingMiddleware(
|
|||||||
await WriteProblemAsync(
|
await WriteProblemAsync(
|
||||||
context,
|
context,
|
||||||
learningValidationException.Message,
|
learningValidationException.Message,
|
||||||
StatusCodes.Status400BadRequest,
|
LearningValidationStatusCode(learningValidationException.Code),
|
||||||
learningValidationException.Code);
|
learningValidationException.Code);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -200,4 +200,13 @@ public sealed class ExceptionHandlingMiddleware(
|
|||||||
_ => StatusCodes.Status400BadRequest
|
_ => StatusCodes.Status400BadRequest
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static int LearningValidationStatusCode(string code)
|
||||||
|
{
|
||||||
|
return code switch
|
||||||
|
{
|
||||||
|
"no_practice_questions" or "practice_session_empty" => StatusCodes.Status409Conflict,
|
||||||
|
_ => StatusCodes.Status400BadRequest
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,4 +46,34 @@ public interface ILearningActivityService
|
|||||||
LearningActor actor,
|
LearningActor actor,
|
||||||
FavoriteWordCommand command,
|
FavoriteWordCommand command,
|
||||||
CancellationToken cancellationToken = default);
|
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,
|
||||||
|
PracticeSessionFilter filter,
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Tiku.Domain.Content;
|
using Tiku.Domain.Content;
|
||||||
|
using Tiku.Domain.Learning;
|
||||||
|
|
||||||
namespace Tiku.Application.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 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(
|
public sealed record AnswerRecordItem(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
Guid QuestionId,
|
Guid QuestionId,
|
||||||
@@ -67,3 +88,86 @@ public sealed record FavoriteWordItem(
|
|||||||
DateTimeOffset? FavoritedAt);
|
DateTimeOffset? FavoritedAt);
|
||||||
|
|
||||||
public sealed record LearningActionResult(bool Ok, bool? IsFavorite = null);
|
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<PracticeSessionQuestionItem> Questions,
|
||||||
|
IReadOnlyDictionary<Guid, AnswerRecordItem> 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);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Tiku.Application.Learning;
|
using Tiku.Application.Learning;
|
||||||
|
using Tiku.Domain.Common;
|
||||||
using Tiku.Domain.Content;
|
using Tiku.Domain.Content;
|
||||||
using Tiku.Domain.Learning;
|
using Tiku.Domain.Learning;
|
||||||
using Tiku.Domain.QuestionBanks;
|
using Tiku.Domain.QuestionBanks;
|
||||||
@@ -383,6 +384,568 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
|||||||
return new LearningActionResult(true, favorite);
|
return new LearningActionResult(true, favorite);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<PracticeSessionItem> 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<PracticeSessionDetailItem> 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<PracticeSessionReportItem> 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<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,
|
||||||
|
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<PracticeHistoryItem>(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<PracticeAssembly> 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<List<Guid>> 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<Question> ApplyLegacyTargetFilter(
|
||||||
|
IQueryable<Question> 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<PracticeSession> 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<PracticeSessionReport> 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(
|
private async Task EnsureQuestionExistsAsync(
|
||||||
Guid tenantId,
|
Guid tenantId,
|
||||||
Guid questionId,
|
Guid questionId,
|
||||||
@@ -448,6 +1011,102 @@ public sealed class LearningActivityService(TikuDbContext dbContext) : ILearning
|
|||||||
item.Metadata);
|
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<Guid> 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)
|
private static bool TryParseWordProgressStatus(string? value, out WordProgressStatus status)
|
||||||
{
|
{
|
||||||
return Enum.TryParse(NormalizeEnumValue(value), ignoreCase: true, out 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)
|
: value.Replace("_", string.Empty, StringComparison.Ordinal)
|
||||||
.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)
|
public class LearningException(string code, string message) : Exception(message)
|
||||||
|
|||||||
@@ -62,6 +62,114 @@ public sealed class LearningEndpointTests
|
|||||||
Assert.Equal(1, wrong.GetProperty("wrongCount").GetInt32());
|
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]
|
[Fact]
|
||||||
public async Task Favorite_question_can_be_added_listed_and_removed()
|
public async Task Favorite_question_can_be_added_listed_and_removed()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user