fix(learning): validate tenant question grading rules

This commit is contained in:
2026-08-03 10:01:38 +08:00
parent 684f4c2f81
commit 73dd96a178
2 changed files with 55 additions and 4 deletions

View File

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

View File

@@ -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<BackgroundJobItem>(JsonOptions);
@@ -374,4 +394,10 @@ public sealed class DirectContentEndpointTests
return await JsonDocument.ParseAsync(stream);
}
private static async Task<string?> ReadProblemCodeAsync(HttpResponseMessage response)
{
using var payload = await ReadJsonAsync(response);
return payload.RootElement.TryGetProperty("code", out var code) ? code.GetString() : null;
}
}