feat: add practice session workflow endpoints

This commit is contained in:
xiong
2026-07-26 15:25:33 +08:00
parent db319ef5a2
commit e505384ecf
7 changed files with 1090 additions and 1 deletions

View File

@@ -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]

View File

@@ -15,6 +15,88 @@ public sealed class LearningController(
ICurrentUser currentUser,
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")]
[EndpointSummary("提交题目答案")]
[ProducesResponseType<AnswerRecordItem>(StatusCodes.Status200OK)]

View File

@@ -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
};
}
}

View File

@@ -46,4 +46,34 @@ public interface ILearningActivityService
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,
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);
}

View File

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

View File

@@ -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<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(
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<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)
{
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)

View File

@@ -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()
{