fix(education): guard collection question deletion

This commit is contained in:
2026-07-31 07:12:36 +08:00
parent c6b482fa2b
commit 5bd2f6758f
5 changed files with 41 additions and 4 deletions

View File

@@ -103,6 +103,9 @@ public class QuestionCatalogServiceImpl implements QuestionCatalogService {
public PracticeConfigPreviewRespVO previewPracticeConfig(PracticeConfigPreviewReqVO reqVO) {
assertEnabled();
if (reqVO.getCollectionId() != null && reqVO.getNodeId() != null && !reqVO.getNodeId().isBlank()) {
throw exception(INVALID_PRACTICE_CONFIG, "题集与目录节点不能同时指定");
}
int requestedCount = reqVO.getQuestionCount() != null ? reqVO.getQuestionCount() : 10;
CatalogPracticeBlueprintDTO blueprint = provider.getPracticeBlueprint(

View File

@@ -117,6 +117,9 @@ CREATE FUNCTION education_block_active_collection_question_archive() RETURNS TRI
LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog,pg_temp AS $$
DECLARE active_collection BIGINT;
BEGIN
IF NOT OLD.deleted AND NEW.deleted AND OLD.scope='TENANT_OWNED' THEN
RAISE EXCEPTION 'tenant question cannot be logically deleted' USING ERRCODE='23514';
END IF;
IF OLD.status='PUBLISHED' AND NEW.status='ARCHIVED' THEN
EXECUTE format('SELECT min(collection.id) FROM %I.education_question_collection_question membership JOIN %I.education_question_collection collection ON collection.id=membership.collection_id WHERE membership.question_id=$1 AND NOT membership.deleted AND collection.publication_status=''ACTIVE'' AND NOT collection.deleted',TG_TABLE_SCHEMA,TG_TABLE_SCHEMA)
INTO active_collection USING NEW.id;
@@ -177,7 +180,7 @@ REVOKE ALL ON FUNCTION education_require_question_collection_audit() FROM PUBLIC
REVOKE ALL ON FUNCTION education_prevent_question_collection_audit_mutation() FROM PUBLIC;
CREATE TRIGGER trg_education_question_collection_authoring BEFORE INSERT OR UPDATE ON education_question_collection FOR EACH ROW EXECUTE FUNCTION education_enforce_question_collection_authoring();
CREATE TRIGGER trg_education_collection_membership_mutation BEFORE INSERT OR UPDATE OR DELETE ON education_question_collection_question FOR EACH ROW EXECUTE FUNCTION education_enforce_collection_membership_mutation();
CREATE TRIGGER trg_education_question_active_collection_archive BEFORE UPDATE OF status ON education_question FOR EACH ROW EXECUTE FUNCTION education_block_active_collection_question_archive();
CREATE TRIGGER trg_education_question_active_collection_archive BEFORE UPDATE OF status, deleted ON education_question FOR EACH ROW EXECUTE FUNCTION education_block_active_collection_question_archive();
CREATE TRIGGER trg_education_question_collection_audit_validate BEFORE INSERT ON education_question_collection_lifecycle_audit FOR EACH ROW EXECUTE FUNCTION education_validate_question_collection_audit();
CREATE TRIGGER trg_education_question_collection_audit_immutable BEFORE UPDATE OR DELETE ON education_question_collection_lifecycle_audit FOR EACH ROW EXECUTE FUNCTION education_prevent_question_collection_audit_mutation();
CREATE CONSTRAINT TRIGGER trg_education_question_collection_membership_count AFTER UPDATE OF membership_version ON education_question_collection DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION education_require_question_collection_membership_count();

View File

@@ -296,6 +296,17 @@ class QuestionControllerHttpTest {
// ========== Error scenarios ==========
@Test
void shouldRejectPreviewWithCollectionAndNode() throws Exception {
setLoginUser(100L);
mockMvc.perform(get("/education/practice-config/preview")
.param("collectionId", "col1")
.param("nodeId", "node1"))
.andExpect(status().is(HttpStatus.INTERNAL_SERVER_ERROR.value()))
.andExpect(jsonPath("$.code").value(INVALID_PRACTICE_CONFIG.getCode()));
verify(provider, never()).getPracticeBlueprint(any(), any(), any(), any());
}
@Test
void shouldReturnQuestionNotFoundForMissingQuestion() throws Exception {
setLoginUser(100L);

View File

@@ -29,6 +29,7 @@ class QuestionCollectionAuthoringPostgreSqlIntegrationTest extends PostgreSqlDbI
@Resource ContentNodeMapper nodeMapper;
@Resource cn.iocoder.yudao.module.education.service.question.authoring.TenantQuestionLifecycleService questionService;
@Resource QuestionMapper questionMapper;
@Resource javax.sql.DataSource dataSource;
@Resource cn.iocoder.yudao.module.education.service.catalog.provider.JavaCatalogProvider catalogProvider;
private Long activeNodeId;
private List<Long> publishedQuestionIds;
@@ -116,6 +117,16 @@ class QuestionCollectionAuthoringPostgreSqlIntegrationTest extends PostgreSqlDbI
assertEquals(QUESTION_COLLECTION_AUTHORING_NOT_FOUND.getCode(), notFound.getCode());
}
@Test void logicalQuestionDeleteIsRejected() {
assertThrows(java.sql.SQLException.class, () -> {
try (var connection = dataSource.getConnection(); var statement = connection.prepareStatement(
"UPDATE education_question SET deleted=true WHERE id=?")) {
statement.setLong(1, publishedQuestionIds.get(0)); statement.executeUpdate();
}
});
assertNotNull(questionMapper.selectPublishedById(10L, publishedQuestionIds.get(0)));
}
@Test void activeCollectionBlocksQuestionArchiveUntilCollectionArchive() {
Long id = service.createDraft(command(null));
service.replaceMembership(id, List.of(publishedQuestionIds.get(0)), 0);

View File

@@ -18,7 +18,7 @@ import java.util.List;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.*;
/**
* QuestionCatalogServiceImpl 单元测试 — 测试安全字段剥离、fail-closed 可见性验证、真实总数。
@@ -345,6 +345,16 @@ class QuestionCatalogServiceImplTest {
}
@Test
void shouldRejectCollectionAndNodeTogether() {
PracticeConfigPreviewReqVO req = new PracticeConfigPreviewReqVO();
req.setCollectionId("col1");
req.setNodeId("node1");
ServiceException ex = assertThrows(ServiceException.class, () -> service.previewPracticeConfig(req));
assertEquals(INVALID_PRACTICE_CONFIG.getCode(), ex.getCode());
verify(provider, never()).getPracticeBlueprint(any(), any(), any(), any());
}
@Test
void shouldReturnNormalizedPracticePreview() {
CatalogPracticeBlueprintDTO bp = CatalogPracticeBlueprintDTO.builder()
@@ -357,12 +367,11 @@ class QuestionCatalogServiceImplTest {
.suggestedCount(20)
.build();
when(provider.getPracticeBlueprint(eq("col1"), eq("node1"), eq("choice"), eq("easy")))
when(provider.getPracticeBlueprint(eq("col1"), isNull(), eq("choice"), eq("easy")))
.thenReturn(bp);
PracticeConfigPreviewReqVO req = new PracticeConfigPreviewReqVO();
req.setCollectionId("col1");
req.setNodeId("node1");
req.setType("choice");
req.setDifficulty("easy");
req.setQuestionCount(10);