diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionServiceImpl.java index 59adcb87..fbfedc53 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionServiceImpl.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionServiceImpl.java @@ -66,6 +66,10 @@ public class PracticeSessionServiceImpl implements PracticeSessionService { @Override @Transactional(rollbackFor = Exception.class) public PracticeSessionRespVO createPracticeSession(PracticeSessionCreateReqVO reqVO, Long userId, Long tenantId) { + if (reqVO.getCollectionId() != null && !reqVO.getCollectionId().isBlank() + && reqVO.getNodeId() != null && !reqVO.getNodeId().isBlank()) { + throw exception(INVALID_PRACTICE_CONFIG, "题集与目录节点不能同时指定"); + } // 1. Idempotent check: same tenant + clientSessionId exists → verify ownership before returning PracticeSessionDO existing = sessionMapper.selectByTenantAndClientSessionId(tenantId, reqVO.getClientSessionId()); if (existing != null) { 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 767c72e5..58ad2660 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 @@ -35,6 +35,10 @@ public class QuestionCatalogServiceImpl implements QuestionCatalogService { @Override public PageResult pageQuestions(QuestionPageReqVO reqVO) { assertEnabled(); + if (reqVO.getCollectionId() != null && !reqVO.getCollectionId().isBlank() + && reqVO.getNodeId() != null && !reqVO.getNodeId().isBlank()) { + throw exception(INVALID_PRACTICE_CONFIG, "题集与目录节点不能同时指定"); + } int pageNo = reqVO.getPageNo() != null ? reqVO.getPageNo() : 1; int pageSize = reqVO.getPageSize() != null ? reqVO.getPageSize() : 20; 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 99334365..3b4b9a4f 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 @@ -16,14 +16,20 @@ BEGIN LEFT JOIN education_question_collection_question membership ON membership.collection_id=collection.id AND NOT membership.deleted LEFT JOIN education_question question ON question.id=membership.question_id WHERE collection.scope='TENANT_OWNED' AND collection.is_active AND NOT collection.is_hidden AND NOT collection.deleted - AND (node.id IS NULL OR node.tenant_id IS DISTINCT FROM collection.tenant_id OR node.scope<>'TENANT_OWNED' + AND (collection.collection_type<>'MANUAL' + OR node.id IS NULL OR node.tenant_id IS DISTINCT FROM collection.tenant_id OR node.scope<>'TENANT_OWNED' OR node.deleted OR NOT node.is_active OR node.is_hidden OR collection.entry_id IS DISTINCT FROM node.entry_id OR entry.id IS NULL OR entry.scope NOT IN ('PUBLIC','TENANT_OWNED') OR (entry.scope='TENANT_OWNED' AND entry.tenant_id IS DISTINCT FROM collection.tenant_id) OR entry.deleted OR NOT entry.is_active OR entry.is_hidden - OR (membership.id IS NOT NULL AND (question.id IS NULL OR question.tenant_id IS DISTINCT FROM collection.tenant_id - OR question.scope<>'TENANT_OWNED' OR question.deleted OR question.status<>'PUBLISHED' OR NOT question.is_published))) + OR (membership.id IS NOT NULL AND (membership.sort_order<0 + OR question.id IS NULL OR question.tenant_id IS DISTINCT FROM collection.tenant_id + OR question.scope<>'TENANT_OWNED' OR question.deleted OR question.status<>'PUBLISHED' OR NOT question.is_published)) + OR EXISTS (SELECT 1 FROM education_question_collection_question ordered_membership + WHERE ordered_membership.collection_id=collection.id AND NOT ordered_membership.deleted + GROUP BY ordered_membership.collection_id + HAVING count(*)<>count(DISTINCT ordered_membership.sort_order) OR min(ordered_membership.sort_order)<>0 OR max(ordered_membership.sort_order)<>count(*)-1)) ORDER BY collection.id,membership.question_id NULLS FIRST LIMIT 1; IF bad_collection IS NOT NULL THEN RAISE EXCEPTION 'invalid historical active collection % member % blocks V4110',bad_collection,COALESCE(bad_question,0) USING ERRCODE='23514'; 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 0eaeb735..ec96c04d 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 @@ -168,14 +168,8 @@ class QuestionControllerHttpTest { } @Test - void shouldForwardQueryParamsForPage() throws Exception { + void shouldRejectContradictoryQueryParamsForPage() throws Exception { setLoginUser(100L); - when(provider.listQuestions("col1", "node1", "choice", "easy", 2, 10)) - .thenReturn(CatalogQuestionPageResult.builder() - .items(Collections.emptyList()) - .total(0L) - .build()); - mockMvc.perform(get("/education/questions/page") .param("collectionId", "col1") .param("nodeId", "node1") @@ -183,8 +177,9 @@ class QuestionControllerHttpTest { .param("difficulty", "easy") .param("pageNo", "2") .param("pageSize", "10")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(0)); + .andExpect(status().is(HttpStatus.INTERNAL_SERVER_ERROR.value())) + .andExpect(jsonPath("$.code").value(INVALID_PRACTICE_CONFIG.getCode())); + verify(provider, never()).listQuestions(any(), any(), any(), any(), anyInt(), anyInt()); } // ========== Anonymous → 401 ========== diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionServiceImplTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionServiceImplTest.java index 352f2abe..b84f09f9 100644 --- a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionServiceImplTest.java +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionServiceImplTest.java @@ -27,7 +27,7 @@ import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServic 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.*; /** * PracticeSessionService test — real Mapper + Mock Provider. @@ -57,6 +57,16 @@ public class PracticeSessionServiceImplTest extends PostgreSqlDbIntegrationTest // ========== Create: basic ========== + @Test + void shouldRejectCreateWithCollectionAndNodeBeforePersistence() { + PracticeSessionCreateReqVO req = createReq("uuid-conflicting-parent", "col-001", 1); + req.setNodeId("node-001"); + ServiceException ex = assertThrows(ServiceException.class, () -> service.createPracticeSession(req, 100L, 1L)); + assertEquals(INVALID_PRACTICE_CONFIG.getCode(), ex.getCode()); + verify(provider, never()).listQuestions(any(), any(), any(), any(), anyInt(), anyInt()); + assertEquals(0L, sessionMapper.selectCount()); + } + @Test void shouldCreateSessionAndReturnQuestions() { when(provider.isEnabled()).thenReturn(true); @@ -183,16 +193,14 @@ public class PracticeSessionServiceImplTest extends PostgreSqlDbIntegrationTest // ========== Create: nodeId forwarding (MEDIUM #3) ========== @Test - void shouldForwardNodeIdToProvider() { + void shouldRejectNodeIdAlongsideRequiredCollectionId() { when(provider.isEnabled()).thenReturn(true); - when(provider.listQuestions(eq("col-001"), eq("node-042"), isNull(), isNull(), eq(1), eq(1))) - .thenReturn(pageResult(List.of(questionDTO("q-001", "v1", "stem")))); - PracticeSessionCreateReqVO req = createReq("uuid-node", "col-001", 1); req.setNodeId("node-042"); - - PracticeSessionRespVO resp = service.createPracticeSession(req, 100L, 1L); - assertNotNull(resp.getSessionId()); + ServiceException ex = assertThrows(ServiceException.class, + () -> service.createPracticeSession(req, 100L, 1L)); + assertEquals(INVALID_PRACTICE_CONFIG.getCode(), ex.getCode()); + verify(provider, never()).listQuestions(any(), any(), any(), any(), anyInt(), anyInt()); } @Test 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 f6e6fd16..5811ec5f 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 @@ -96,6 +96,14 @@ class QuestionCatalogServiceImplTest { assertFalse(json.contains("isCorrect"), "JSON must not contain isCorrect"); } + @Test + void shouldRejectQuestionPageWithCollectionAndNode() { + QuestionPageReqVO req = new QuestionPageReqVO(); req.setCollectionId("col1"); req.setNodeId("node1"); + ServiceException ex = assertThrows(ServiceException.class, () -> service.pageQuestions(req)); + assertEquals(INVALID_PRACTICE_CONFIG.getCode(), ex.getCode()); + verify(provider, never()).listQuestions(any(), any(), any(), any(), anyInt(), anyInt()); + } + @Test void shouldStripAnswerFieldsInPageResponse() { CatalogQuestionDTO dto = questionDto("q1", "Q1", "choice", "easy", true,