diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/QuestionCatalogServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/QuestionCatalogServiceImpl.java index c3b1bc82..767c72e5 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/QuestionCatalogServiceImpl.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/QuestionCatalogServiceImpl.java @@ -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( diff --git a/yudao-module-education/src/main/resources/db/migration/education/V4110__add_manual_question_collection.sql b/yudao-module-education/src/main/resources/db/migration/education/V4110__add_manual_question_collection.sql index 2b8b4caf..81633c2a 100644 --- a/yudao-module-education/src/main/resources/db/migration/education/V4110__add_manual_question_collection.sql +++ b/yudao-module-education/src/main/resources/db/migration/education/V4110__add_manual_question_collection.sql @@ -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(); diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/app/question/QuestionControllerHttpTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/app/question/QuestionControllerHttpTest.java index 3fd0a410..0eaeb735 100644 --- a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/app/question/QuestionControllerHttpTest.java +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/app/question/QuestionControllerHttpTest.java @@ -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); diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/collection/authoring/QuestionCollectionAuthoringPostgreSqlIntegrationTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/collection/authoring/QuestionCollectionAuthoringPostgreSqlIntegrationTest.java index a7c2ac3a..99ae384f 100644 --- a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/collection/authoring/QuestionCollectionAuthoringPostgreSqlIntegrationTest.java +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/collection/authoring/QuestionCollectionAuthoringPostgreSqlIntegrationTest.java @@ -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 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); diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/question/QuestionCatalogServiceImplTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/question/QuestionCatalogServiceImplTest.java index b7ef3df2..f6e6fd16 100644 --- a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/question/QuestionCatalogServiceImplTest.java +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/question/QuestionCatalogServiceImplTest.java @@ -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);