From 73dd96a178e4d5167b81fe7ef4b6074b19b40a10 Mon Sep 17 00:00:00 2001 From: xiong Date: Mon, 3 Aug 2026 10:01:38 +0800 Subject: [PATCH] fix(learning): validate tenant question grading rules --- .../Content/DirectContentService.cs | 25 ++++++++++++++ .../Api/DirectContentEndpointTests.cs | 34 ++++++++++++++++--- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/Tiku.Infrastructure/Content/DirectContentService.cs b/Tiku.Infrastructure/Content/DirectContentService.cs index a85f3e3..3e308f1 100644 --- a/Tiku.Infrastructure/Content/DirectContentService.cs +++ b/Tiku.Infrastructure/Content/DirectContentService.cs @@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore; using Tiku.Application.Assets; using Tiku.Application.Catalog; using Tiku.Application.Content; +using Tiku.Application.Learning; using Tiku.Application.QuestionBanks; using Tiku.Application.Security; using Tiku.Domain.Catalog; @@ -50,6 +51,7 @@ public sealed class DirectContentService( QuestionWriteCommand command, CancellationToken cancellationToken = default) { + ValidateQuestionForPublication(command); await AssertQuestionReferencesAsync(actor.TenantId, command, cancellationToken); await using var transaction = dbContext.Database.CurrentTransaction is null ? await dbContext.Database.BeginTransactionAsync(cancellationToken) @@ -94,6 +96,8 @@ public sealed class DirectContentService( throw new ContentManagementException("questionId is required.", "question_id_required"); } + ValidateQuestionForPublication(command); + var question = await dbContext.Questions.SingleOrDefaultAsync( item => item.TenantId == actor.TenantId && item.Id == command.QuestionId.Value, cancellationToken); @@ -1496,6 +1500,27 @@ public sealed class DirectContentService( question.Status = Parse(command.Status, QuestionStatus.Published, "question_status_invalid"); } + private static void ValidateQuestionForPublication(QuestionWriteCommand command) + { + var status = Parse(command.Status, QuestionStatus.Published, "question_status_invalid"); + if (status != QuestionStatus.Published) + { + return; + } + + var type = Normalize(command.Type) ?? "choice"; + if (!QuestionGrader.HasValidAuthoritativeAnswer( + type, + command.CorrectOptionIndex, + command.CorrectOptionIndices, + command.AnswerText)) + { + throw new ContentManagementException( + "Published questions require a valid authoritative answer.", + "question_grading_rule_invalid"); + } + } + private static QuestionVersion BuildQuestionVersion( DirectContentActor actor, Guid questionId, diff --git a/Tiku.IntegrationTests/Api/DirectContentEndpointTests.cs b/Tiku.IntegrationTests/Api/DirectContentEndpointTests.cs index 920f1d4..dec9344 100644 --- a/Tiku.IntegrationTests/Api/DirectContentEndpointTests.cs +++ b/Tiku.IntegrationTests/Api/DirectContentEndpointTests.cs @@ -64,6 +64,26 @@ public sealed class DirectContentEndpointTests Assert.Equal(1, dbContext.QuestionCollections.Single(item => item.Id == collectionId).QuestionCount); } + [Fact] + public async Task Published_objective_question_requires_authoritative_answer_but_draft_does_not() + { + await using var factory = new ApiTestFactory(); + var seed = await SeedAdminAsync(factory); + using var client = factory.CreateClient(); + await LoginAsync(client, seed); + + using var published = await client.PostAsJsonAsync( + "/api/tenant-content/questions", + new DirectQuestionWriteDto { Type = "choice", Content = "缺少答案", Status = "Published" }); + using var draft = await client.PostAsJsonAsync( + "/api/tenant-content/questions", + new DirectQuestionWriteDto { Type = "choice", Content = "草稿题", Status = "Draft" }); + + Assert.Equal(HttpStatusCode.BadRequest, published.StatusCode); + Assert.Equal("question_grading_rule_invalid", await ReadProblemCodeAsync(published)); + Assert.Equal(HttpStatusCode.OK, draft.StatusCode); + } + [Fact] public async Task Tenant_admin_can_upsert_vocabulary_handbook_video_and_operations_content() { @@ -128,7 +148,7 @@ public sealed class DirectContentEndpointTests var questionResponse = await client.PostAsJsonAsync( "/api/tenant-content/questions", - new DirectQuestionWriteDto { Type = "choice", Content = "题目" }); + new DirectQuestionWriteDto { Type = "choice", Content = "题目", CorrectOptionIndex = 0 }); var questionJson = await ReadJsonAsync(questionResponse); var questionId = questionJson.RootElement.GetProperty("item").GetProperty("id").GetGuid(); @@ -148,7 +168,7 @@ public sealed class DirectContentEndpointTests { Items = [ - JsonSerializer.SerializeToElement(new { type = "choice", content = "导入预览题" }) + JsonSerializer.SerializeToElement(new { type = "choice", content = "导入预览题", correctOptionIndex = 0 }) ] }); var previewJson = await ReadJsonAsync(previewResponse); @@ -160,7 +180,7 @@ public sealed class DirectContentEndpointTests { Items = [ - JsonSerializer.SerializeToElement(new { type = "choice", content = "导入执行题" }) + JsonSerializer.SerializeToElement(new { type = "choice", content = "导入执行题", correctOptionIndex = 0 }) ] }); var executeJson = await ReadJsonAsync(executeResponse); @@ -198,7 +218,7 @@ public sealed class DirectContentEndpointTests Async = true, Items = [ - JsonSerializer.SerializeToElement(new { type = "choice", content = "异步导入题" }) + JsonSerializer.SerializeToElement(new { type = "choice", content = "异步导入题", correctOptionIndex = 0 }) ] }); var queuedJob = await queueResponse.Content.ReadFromJsonAsync(JsonOptions); @@ -374,4 +394,10 @@ public sealed class DirectContentEndpointTests return await JsonDocument.ParseAsync(stream); } + private static async Task ReadProblemCodeAsync(HttpResponseMessage response) + { + using var payload = await ReadJsonAsync(response); + return payload.RootElement.TryGetProperty("code", out var code) ? code.GetString() : null; + } + }