fix(education): guard collection question deletion
This commit is contained in:
@@ -103,6 +103,9 @@ public class QuestionCatalogServiceImpl implements QuestionCatalogService {
|
|||||||
public PracticeConfigPreviewRespVO previewPracticeConfig(PracticeConfigPreviewReqVO reqVO) {
|
public PracticeConfigPreviewRespVO previewPracticeConfig(PracticeConfigPreviewReqVO reqVO) {
|
||||||
assertEnabled();
|
assertEnabled();
|
||||||
|
|
||||||
|
if (reqVO.getCollectionId() != null && reqVO.getNodeId() != null && !reqVO.getNodeId().isBlank()) {
|
||||||
|
throw exception(INVALID_PRACTICE_CONFIG, "题集与目录节点不能同时指定");
|
||||||
|
}
|
||||||
int requestedCount = reqVO.getQuestionCount() != null ? reqVO.getQuestionCount() : 10;
|
int requestedCount = reqVO.getQuestionCount() != null ? reqVO.getQuestionCount() : 10;
|
||||||
|
|
||||||
CatalogPracticeBlueprintDTO blueprint = provider.getPracticeBlueprint(
|
CatalogPracticeBlueprintDTO blueprint = provider.getPracticeBlueprint(
|
||||||
|
|||||||
@@ -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 $$
|
LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog,pg_temp AS $$
|
||||||
DECLARE active_collection BIGINT;
|
DECLARE active_collection BIGINT;
|
||||||
BEGIN
|
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
|
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)
|
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;
|
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;
|
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_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_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_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 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();
|
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();
|
||||||
|
|||||||
@@ -296,6 +296,17 @@ class QuestionControllerHttpTest {
|
|||||||
|
|
||||||
// ========== Error scenarios ==========
|
// ========== 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
|
@Test
|
||||||
void shouldReturnQuestionNotFoundForMissingQuestion() throws Exception {
|
void shouldReturnQuestionNotFoundForMissingQuestion() throws Exception {
|
||||||
setLoginUser(100L);
|
setLoginUser(100L);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class QuestionCollectionAuthoringPostgreSqlIntegrationTest extends PostgreSqlDbI
|
|||||||
@Resource ContentNodeMapper nodeMapper;
|
@Resource ContentNodeMapper nodeMapper;
|
||||||
@Resource cn.iocoder.yudao.module.education.service.question.authoring.TenantQuestionLifecycleService questionService;
|
@Resource cn.iocoder.yudao.module.education.service.question.authoring.TenantQuestionLifecycleService questionService;
|
||||||
@Resource QuestionMapper questionMapper;
|
@Resource QuestionMapper questionMapper;
|
||||||
|
@Resource javax.sql.DataSource dataSource;
|
||||||
@Resource cn.iocoder.yudao.module.education.service.catalog.provider.JavaCatalogProvider catalogProvider;
|
@Resource cn.iocoder.yudao.module.education.service.catalog.provider.JavaCatalogProvider catalogProvider;
|
||||||
private Long activeNodeId;
|
private Long activeNodeId;
|
||||||
private List<Long> publishedQuestionIds;
|
private List<Long> publishedQuestionIds;
|
||||||
@@ -116,6 +117,16 @@ class QuestionCollectionAuthoringPostgreSqlIntegrationTest extends PostgreSqlDbI
|
|||||||
assertEquals(QUESTION_COLLECTION_AUTHORING_NOT_FOUND.getCode(), notFound.getCode());
|
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() {
|
@Test void activeCollectionBlocksQuestionArchiveUntilCollectionArchive() {
|
||||||
Long id = service.createDraft(command(null));
|
Long id = service.createDraft(command(null));
|
||||||
service.replaceMembership(id, List.of(publishedQuestionIds.get(0)), 0);
|
service.replaceMembership(id, List.of(publishedQuestionIds.get(0)), 0);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import java.util.List;
|
|||||||
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
|
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
import static org.mockito.ArgumentMatchers.*;
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* QuestionCatalogServiceImpl 单元测试 — 测试安全字段剥离、fail-closed 可见性验证、真实总数。
|
* 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
|
@Test
|
||||||
void shouldReturnNormalizedPracticePreview() {
|
void shouldReturnNormalizedPracticePreview() {
|
||||||
CatalogPracticeBlueprintDTO bp = CatalogPracticeBlueprintDTO.builder()
|
CatalogPracticeBlueprintDTO bp = CatalogPracticeBlueprintDTO.builder()
|
||||||
@@ -357,12 +367,11 @@ class QuestionCatalogServiceImplTest {
|
|||||||
.suggestedCount(20)
|
.suggestedCount(20)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
when(provider.getPracticeBlueprint(eq("col1"), eq("node1"), eq("choice"), eq("easy")))
|
when(provider.getPracticeBlueprint(eq("col1"), isNull(), eq("choice"), eq("easy")))
|
||||||
.thenReturn(bp);
|
.thenReturn(bp);
|
||||||
|
|
||||||
PracticeConfigPreviewReqVO req = new PracticeConfigPreviewReqVO();
|
PracticeConfigPreviewReqVO req = new PracticeConfigPreviewReqVO();
|
||||||
req.setCollectionId("col1");
|
req.setCollectionId("col1");
|
||||||
req.setNodeId("node1");
|
|
||||||
req.setType("choice");
|
req.setType("choice");
|
||||||
req.setDifficulty("easy");
|
req.setDifficulty("easy");
|
||||||
req.setQuestionCount(10);
|
req.setQuestionCount(10);
|
||||||
|
|||||||
Reference in New Issue
Block a user