From 588dd826380919c9f2af60edb5052ea2fc3ba5b3 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Fri, 31 Jul 2026 07:54:39 +0800 Subject: [PATCH] fix(education): validate collection adoption history --- .../V4110__add_manual_question_collection.sql | 29 +++++++++++++++++++ ...ionAuthoringPostgreSqlIntegrationTest.java | 11 +++++++ ...ucationFlywayMigrationIntegrationTest.java | 19 ++++++++++++ 3 files changed, 59 insertions(+) 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 81633c2a..dec45541 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 @@ -6,6 +6,27 @@ ALTER TABLE education_question_collection ADD COLUMN authoring_version INTEGER NOT NULL DEFAULT 0, ADD COLUMN membership_version INTEGER NOT NULL DEFAULT 0; +DO $$ +DECLARE bad_collection BIGINT; bad_question BIGINT; +BEGIN + SELECT collection.id, membership.question_id INTO bad_collection,bad_question + FROM education_question_collection collection + LEFT JOIN education_content_node node ON node.id=collection.node_id + LEFT JOIN education_content_entry entry ON entry.id=node.entry_id + 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' + OR node.deleted OR NOT node.is_active OR node.is_hidden + OR entry.id IS NULL 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))) + 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'; + END IF; +END $$; + UPDATE education_question_collection SET publication_status = CASE WHEN is_active AND NOT is_hidden THEN 'ACTIVE' ELSE 'ARCHIVED' END, is_active = is_active AND NOT is_hidden, @@ -44,6 +65,12 @@ CREATE TABLE education_question_collection_membership_token ( PRIMARY KEY(transaction_id,tenant_id,collection_id,authoring_version)); REVOKE ALL ON TABLE education_question_collection_membership_token FROM PUBLIC; +CREATE FUNCTION education_prevent_question_collection_delete() RETURNS TRIGGER LANGUAGE plpgsql SET search_path=pg_catalog,pg_temp AS $$ +BEGIN + IF OLD.scope='TENANT_OWNED' THEN RAISE EXCEPTION 'tenant collection cannot be physically deleted' USING ERRCODE='23514'; END IF; + RETURN OLD; +END $$; + CREATE FUNCTION education_enforce_question_collection_authoring() RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog,pg_temp AS $$ DECLARE member RECORD; @@ -171,6 +198,7 @@ LANGUAGE plpgsql SET search_path=pg_catalog,pg_temp AS $$ DECLARE n INTEGER; BEG IF n<>1 THEN RAISE EXCEPTION 'collection lifecycle audit required' USING ERRCODE='23514'; END IF; RETURN NULL; END $$; CREATE FUNCTION education_prevent_question_collection_audit_mutation() RETURNS TRIGGER LANGUAGE plpgsql SET search_path=pg_catalog,pg_temp AS $$ BEGIN RAISE EXCEPTION 'collection audit is append-only' USING ERRCODE='23514'; END $$; +REVOKE ALL ON FUNCTION education_prevent_question_collection_delete() FROM PUBLIC; REVOKE ALL ON FUNCTION education_enforce_question_collection_authoring() FROM PUBLIC; REVOKE ALL ON FUNCTION education_enforce_collection_membership_mutation() FROM PUBLIC; REVOKE ALL ON FUNCTION education_block_active_collection_question_archive() FROM PUBLIC; @@ -178,6 +206,7 @@ REVOKE ALL ON FUNCTION education_validate_question_collection_audit() FROM PUBLI REVOKE ALL ON FUNCTION education_require_question_collection_membership_count() FROM PUBLIC; 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_delete BEFORE DELETE ON education_question_collection FOR EACH ROW EXECUTE FUNCTION education_prevent_question_collection_delete(); 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, deleted ON education_question FOR EACH ROW EXECUTE FUNCTION education_block_active_collection_question_archive(); 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 99ae384f..5a302e9b 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 @@ -117,6 +117,17 @@ class QuestionCollectionAuthoringPostgreSqlIntegrationTest extends PostgreSqlDbI assertEquals(QUESTION_COLLECTION_AUTHORING_NOT_FOUND.getCode(), notFound.getCode()); } + @Test void physicalCollectionDeleteIsRejectedEvenForEmptyDraft() { + Long id = service.createDraft(command(null)); + assertThrows(java.sql.SQLException.class, () -> { + try (var connection = dataSource.getConnection(); var statement = connection.prepareStatement( + "DELETE FROM education_question_collection WHERE id=?")) { + statement.setLong(1, id); statement.executeUpdate(); + } + }); + assertNotNull(collectionMapper.selectTenantOwnedById(10L, id)); + } + @Test void logicalQuestionDeleteIsRejected() { assertThrows(java.sql.SQLException.class, () -> { try (var connection = dataSource.getConnection(); var statement = connection.prepareStatement( diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/EducationFlywayMigrationIntegrationTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/EducationFlywayMigrationIntegrationTest.java index 331c5b9b..bdc2d406 100644 --- a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/EducationFlywayMigrationIntegrationTest.java +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/EducationFlywayMigrationIntegrationTest.java @@ -675,6 +675,25 @@ class EducationFlywayMigrationIntegrationTest { } } + @Test + void shouldFailV4110ForInvalidHistoricalActiveCollectionMembership() throws SQLException { + String schema = createSchema("invalid_collection_history"); + configureFlyway(schema, false).target("4100").load().migrate(); + execute(schema, """ + INSERT INTO education_content_entry(id,tenant_id,scope,entry_key,name,entry_type) VALUES(100,10,'TENANT_OWNED','questions','Questions','question'); + INSERT INTO education_content_node(id,tenant_id,scope,entry_id,name,node_type,publication_status,authoring_version,is_active,is_hidden) VALUES(200,10,'TENANT_OWNED',100,'Node','category','DRAFT',0,false,true); + UPDATE education_content_node SET publication_status='ACTIVE',authoring_version=1,is_active=true,is_hidden=false WHERE id=200; + INSERT INTO education_content_node_lifecycle_audit(tenant_id,node_id,authoring_version,actor_id,from_status,to_status) VALUES(10,200,1,7,'DRAFT','ACTIVE'); + INSERT INTO education_question(id,tenant_id,scope,stem,type,options,status,is_published) VALUES(300,0,'PUBLIC','Public','text','[]','DRAFT',false); + INSERT INTO education_question_version(tenant_id,scope,question_id,version_number,stem,type,options) VALUES(0,'PUBLIC',300,1,'Public','text','[]'); + INSERT INTO education_question_collection(id,tenant_id,scope,entry_id,node_id,name,collection_type,question_count,is_active,is_hidden) VALUES(400,10,'TENANT_OWNED',100,200,'Invalid','MANUAL',1,true,false); + INSERT INTO education_question_collection_question(tenant_id,scope,collection_id,question_id,sort_order) VALUES(10,'TENANT_OWNED',400,300,0); + """); + assertThatThrownBy(() -> configureFlyway(schema, false).load().migrate()) + .hasMessageContaining("invalid historical active collection 400 member 300 blocks V4110"); + assertThat(queryLong(schema,"SELECT count(*) FROM flyway_schema_history WHERE version='4110' AND success=true")).isZero(); + } + @Test void shouldHardenManualCollectionLifecycleAndRuntimePrivileges() throws SQLException { String schema = createSchema("collection_guards");