fix(education): harden content node lifecycle

This commit is contained in:
2026-07-31 01:36:04 +08:00
parent 62bbdd4b87
commit f445c61f92
5 changed files with 236 additions and 17 deletions

View File

@@ -36,7 +36,7 @@ EDU-000 Phase 0 artifacts done
└── EDU-005 PostgreSQL/Flyway takeover decision done
EDU-009 + provider/content decisions
└── EDU-010 Tenant content publication in progress (graph integrity delivered; lifecycle remains)
└── EDU-010 Tenant content publication in progress (graph integrity and Content Node lifecycle delivered)
└── EDU-011 Import/export/assets/scanning blocked
EDU-004

View File

@@ -2,6 +2,7 @@ package cn.iocoder.yudao.module.education.dal.mysql.catalog;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.ContentEntryDO;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.ContentNodeDO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@@ -27,30 +28,34 @@ public interface ContentNodeMapper extends BaseMapperX<ContentNodeDO> {
@Param("nodeId") Long nodeId);
@Select("""
SELECT COUNT(*) FROM education_content_entry
SELECT * FROM education_content_entry
WHERE id = #{entryId} AND deleted = false AND is_active = true AND is_hidden = false
AND ((tenant_id = #{tenantId} AND scope = 'TENANT_OWNED') OR (tenant_id = 0 AND scope = 'PUBLIC'))
FOR SHARE
""")
int countAvailableEntry(@Param("tenantId") Long tenantId, @Param("entryId") Long entryId);
ContentEntryDO selectAvailableEntryForShare(@Param("tenantId") Long tenantId, @Param("entryId") Long entryId);
@Select("""
SELECT COUNT(*) FROM education_content_node
SELECT * FROM education_content_node
WHERE id = #{parentId} AND entry_id = #{entryId} AND deleted = false
AND publication_status = 'ACTIVE' AND is_active = true AND is_hidden = false
AND ((tenant_id = #{tenantId} AND scope = 'TENANT_OWNED') OR (tenant_id = 0 AND scope = 'PUBLIC'))
FOR SHARE
""")
int countAvailableParent(@Param("tenantId") Long tenantId, @Param("entryId") Long entryId,
@Param("parentId") Long parentId);
ContentNodeDO selectAvailableParentForShare(@Param("tenantId") Long tenantId, @Param("entryId") Long entryId,
@Param("parentId") Long parentId);
default ContentNodeDO selectTenantOwnedById(Long tenantId, Long id) {
return selectOne(new LambdaQueryWrapperX<ContentNodeDO>().eq(ContentNodeDO::getId, id)
.eq(ContentNodeDO::getTenantId, tenantId).eq(ContentNodeDO::getScope, "TENANT_OWNED"));
.eq(ContentNodeDO::getTenantId, tenantId).eq(ContentNodeDO::getScope, "TENANT_OWNED")
.eq(ContentNodeDO::getDeleted, false));
}
default int updateDraftCas(Long tenantId, Long id, ContentNodeDO values, int expectedVersion) {
return update(values, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<ContentNodeDO>()
.eq(ContentNodeDO::getId, id).eq(ContentNodeDO::getTenantId, tenantId)
.eq(ContentNodeDO::getScope, "TENANT_OWNED")
.eq(ContentNodeDO::getDeleted, false)
.eq(ContentNodeDO::getPublicationStatus, "DRAFT")
.eq(ContentNodeDO::getAuthoringVersion, expectedVersion)
.set(ContentNodeDO::getAuthoringVersion, expectedVersion + 1));
@@ -61,6 +66,7 @@ public interface ContentNodeMapper extends BaseMapperX<ContentNodeDO> {
return update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<ContentNodeDO>()
.eq(ContentNodeDO::getId, id).eq(ContentNodeDO::getTenantId, tenantId)
.eq(ContentNodeDO::getScope, "TENANT_OWNED")
.eq(ContentNodeDO::getDeleted, false)
.eq(ContentNodeDO::getPublicationStatus, expectedStatus)
.eq(ContentNodeDO::getAuthoringVersion, expectedVersion)
.set(ContentNodeDO::getPublicationStatus, targetStatus)

View File

@@ -96,9 +96,9 @@ public class ContentNodeAuthoringServiceImpl implements ContentNodeAuthoringServ
}
private void validateStructure(Long tenantId, Long nodeId, Long entryId, Long parentId) {
if (entryId == null || TenantUtils.executeIgnore(() -> nodeMapper.countAvailableEntry(tenantId, entryId)) != 1
if (entryId == null || TenantUtils.executeIgnore(() -> nodeMapper.selectAvailableEntryForShare(tenantId, entryId)) == null
|| (parentId != null && (parentId.equals(nodeId)
|| TenantUtils.executeIgnore(() -> nodeMapper.countAvailableParent(tenantId, entryId, parentId)) != 1))) {
|| TenantUtils.executeIgnore(() -> nodeMapper.selectAvailableParentForShare(tenantId, entryId, parentId)) == null))) {
throw exception(CONTENT_NODE_STRUCTURE_INVALID);
}
}

View File

@@ -40,16 +40,34 @@ CREATE TABLE education_content_node_lifecycle_audit (
FOREIGN KEY (node_id) REFERENCES education_content_node(id),
CONSTRAINT ck_education_content_node_lifecycle_audit_transition CHECK (
(from_status = 'DRAFT' AND to_status = 'ACTIVE') OR
(from_status = 'ACTIVE' AND to_status = 'ARCHIVED'))
(from_status = 'ACTIVE' AND to_status = 'ARCHIVED')),
CONSTRAINT uk_education_content_node_lifecycle_audit_version_transition
UNIQUE (tenant_id, node_id, authoring_version)
);
COMMENT ON TABLE education_content_node_lifecycle_audit IS '教育-内容节点追加式生命周期审计';
CREATE INDEX idx_education_content_node_lifecycle_audit_node
ON education_content_node_lifecycle_audit (tenant_id, node_id, id);
CREATE TABLE education_content_node_lifecycle_transition_token (
transaction_id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL,
node_id BIGINT NOT NULL,
authoring_version INTEGER NOT NULL,
from_status VARCHAR(16) NOT NULL,
to_status VARCHAR(16) NOT NULL,
PRIMARY KEY (transaction_id, tenant_id, node_id, authoring_version, from_status, to_status)
);
COMMENT ON TABLE education_content_node_lifecycle_transition_token IS
'内容节点状态触发器与审计触发器之间的事务内临时凭据;成功审计后即消费';
REVOKE ALL ON TABLE education_content_node_lifecycle_transition_token FROM PUBLIC;
CREATE FUNCTION education_enforce_content_node_authoring()
RETURNS TRIGGER LANGUAGE plpgsql SET search_path = pg_catalog, pg_temp AS $$
RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, pg_temp AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
IF NEW.deleted THEN
RAISE EXCEPTION 'content node cannot start logically deleted' USING ERRCODE = '23514';
END IF;
IF NEW.scope = 'PUBLIC' THEN
IF NEW.publication_status <> 'ACTIVE' OR NOT NEW.is_active OR NEW.is_hidden
OR NEW.authoring_version <> 0 THEN
@@ -65,6 +83,9 @@ BEGIN
IF NEW.scope = 'PUBLIC' AND (NEW IS DISTINCT FROM OLD) THEN
RAISE EXCEPTION 'PUBLIC content node writes are not managed by tenant authoring' USING ERRCODE = '23514';
END IF;
IF OLD.scope = 'TENANT_OWNED' AND NEW.deleted IS DISTINCT FROM OLD.deleted THEN
RAISE EXCEPTION 'content node cannot be logically deleted' USING ERRCODE = '23514';
END IF;
IF OLD.publication_status = 'ARCHIVED' AND NEW IS DISTINCT FROM OLD THEN
RAISE EXCEPTION 'archived content node is immutable' USING ERRCODE = '23514';
END IF;
@@ -76,12 +97,22 @@ BEGIN
(OLD.publication_status = 'ACTIVE' AND NEW.publication_status = 'ARCHIVED')) THEN
RAISE EXCEPTION 'invalid content node lifecycle transition' USING ERRCODE = '23514';
END IF;
IF NEW.publication_status IS DISTINCT FROM OLD.publication_status THEN
EXECUTE format(
'INSERT INTO %I.education_content_node_lifecycle_transition_token' ||
' (transaction_id, tenant_id, node_id, authoring_version, from_status, to_status)' ||
' VALUES ($1, $2, $3, $4, $5, $6)',
TG_TABLE_SCHEMA)
USING pg_current_xact_id()::text::BIGINT, NEW.tenant_id, NEW.id,
NEW.authoring_version, OLD.publication_status, NEW.publication_status;
END IF;
IF OLD.publication_status <> 'DRAFT' AND (
NEW.entry_id IS DISTINCT FROM OLD.entry_id OR NEW.parent_id IS DISTINCT FROM OLD.parent_id OR
NEW.name IS DISTINCT FROM OLD.name OR NEW.title IS DISTINCT FROM OLD.title OR
NEW.node_type IS DISTINCT FROM OLD.node_type OR NEW.marker_type IS DISTINCT FROM OLD.marker_type OR
NEW.is_leaf IS DISTINCT FROM OLD.is_leaf OR NEW.is_selectable IS DISTINCT FROM OLD.is_selectable OR
NEW.sort_order IS DISTINCT FROM OLD.sort_order OR NEW.metadata IS DISTINCT FROM OLD.metadata) THEN
NEW.sort_order IS DISTINCT FROM OLD.sort_order OR NEW.metadata IS DISTINCT FROM OLD.metadata OR
NEW.deleted IS DISTINCT FROM OLD.deleted) THEN
RAISE EXCEPTION 'active content node content is immutable' USING ERRCODE = '23514';
END IF;
RETURN NEW;
@@ -98,7 +129,7 @@ RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog,
DECLARE matching_count INTEGER;
BEGIN
IF NEW.publication_status IS NOT DISTINCT FROM OLD.publication_status THEN RETURN NULL; END IF;
EXECUTE format('SELECT count(*) FROM %I.education_content_node_lifecycle_audit WHERE tenant_id=$1 AND node_id=$2 AND authoring_version=$3 AND from_status=$4 AND to_status=$5', TG_TABLE_SCHEMA)
EXECUTE format('SELECT count(*) FROM %I.education_content_node_lifecycle_audit WHERE tenant_id=$1 AND node_id=$2 AND authoring_version=$3 AND from_status=$4 AND to_status=$5 AND deleted=false', TG_TABLE_SCHEMA)
INTO matching_count USING NEW.tenant_id, NEW.id, NEW.authoring_version, OLD.publication_status, NEW.publication_status;
IF matching_count <> 1 THEN RAISE EXCEPTION 'content node lifecycle audit must accompany its transition' USING ERRCODE = '23514'; END IF;
RETURN NULL;
@@ -106,13 +137,27 @@ END $$;
CREATE FUNCTION education_validate_content_node_lifecycle_audit()
RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, pg_temp AS $$
DECLARE node_tenant BIGINT; node_version INTEGER; node_status VARCHAR(16);
DECLARE node_tenant BIGINT; node_version INTEGER; node_status VARCHAR(16); transition_token_count INTEGER;
BEGIN
EXECUTE format('SELECT tenant_id, authoring_version, publication_status FROM %I.education_content_node WHERE id=$1', TG_TABLE_SCHEMA)
IF NEW.deleted THEN
RAISE EXCEPTION 'content node lifecycle audit cannot be logically deleted' USING ERRCODE = '23514';
END IF;
EXECUTE format('SELECT tenant_id, authoring_version, publication_status FROM %I.education_content_node WHERE id=$1 FOR SHARE', TG_TABLE_SCHEMA)
INTO node_tenant, node_version, node_status USING NEW.node_id;
IF node_tenant IS DISTINCT FROM NEW.tenant_id OR node_version IS DISTINCT FROM NEW.authoring_version
OR node_status IS DISTINCT FROM NEW.to_status THEN
RAISE EXCEPTION 'content node lifecycle audit must match current transition' USING ERRCODE = '23514';
RAISE EXCEPTION 'content node lifecycle audit must accompany its transition' USING ERRCODE = '23514';
END IF;
EXECUTE format(
'DELETE FROM %I.education_content_node_lifecycle_transition_token' ||
' WHERE transaction_id=$1 AND tenant_id=$2 AND node_id=$3 AND authoring_version=$4' ||
' AND from_status=$5 AND to_status=$6',
TG_TABLE_SCHEMA)
USING pg_current_xact_id()::text::BIGINT, NEW.tenant_id, NEW.node_id,
NEW.authoring_version, NEW.from_status, NEW.to_status;
GET DIAGNOSTICS transition_token_count = ROW_COUNT;
IF transition_token_count <> 1 THEN
RAISE EXCEPTION 'content node lifecycle audit must accompany its transition' USING ERRCODE = '23514';
END IF;
RETURN NEW;
END $$;

View File

@@ -495,15 +495,129 @@ class EducationFlywayMigrationIntegrationTest {
INSERT INTO education_content_entry
(id, tenant_id, scope, entry_key, name, entry_type)
VALUES (100, 10, 'TENANT_OWNED', 'questions', 'Questions', 'question');
""");
assertThatThrownBy(() -> execute(schema, """
INSERT INTO education_content_node
(id, tenant_id, scope, entry_id, name, node_type,
publication_status, is_active, is_hidden, deleted)
VALUES (199, 10, 'TENANT_OWNED', 100, 'Deleted draft', 'category',
'DRAFT', false, true, true);
""")).hasMessageContaining("content node cannot start logically deleted");
execute(schema, """
INSERT INTO education_content_node
(id, tenant_id, scope, entry_id, name, node_type, publication_status, is_active, is_hidden)
VALUES (200, 10, 'TENANT_OWNED', 100, 'Draft', 'category', 'DRAFT', false, true);
""");
execute(schema, """
DO $activate$
BEGIN
UPDATE education_content_node
SET publication_status = 'ACTIVE', is_active = true, is_hidden = false,
authoring_version = 1
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');
END
$activate$;
""");
assertThat(queryLong(schema, """
SELECT COUNT(*)
FROM pg_constraint constraint_definition
JOIN pg_class audit_table ON audit_table.oid = constraint_definition.conrelid
JOIN pg_namespace audit_schema ON audit_schema.oid = audit_table.relnamespace
WHERE audit_schema.nspname = current_schema()
AND audit_table.relname = 'education_content_node_lifecycle_audit'
AND constraint_definition.conname =
'uk_education_content_node_lifecycle_audit_version_transition'
AND constraint_definition.contype = 'u'
""")).isEqualTo(1L);
assertThatThrownBy(() -> execute(schema, """
INSERT INTO education_content_node_lifecycle_audit
(tenant_id, node_id, authoring_version, actor_id, from_status, to_status)
VALUES (10, 200, 1, 8, 'DRAFT', 'ACTIVE');
""")).hasMessageContaining("content node lifecycle audit must accompany its transition");
execute(schema, """
INSERT INTO education_content_node
(id, tenant_id, scope, entry_id, name, node_type, publication_status, is_active, is_hidden)
VALUES (201, 10, 'TENANT_OWNED', 100, 'Deleted audit draft', 'category', 'DRAFT', false, true);
""");
assertThatThrownBy(() -> execute(schema, """
DO $activate_deleted$
BEGIN
UPDATE education_content_node
SET publication_status = 'ACTIVE', is_active = true, is_hidden = false,
deleted = true, authoring_version = 1
WHERE id = 201;
INSERT INTO education_content_node_lifecycle_audit
(tenant_id, node_id, authoring_version, actor_id, from_status, to_status)
VALUES (10, 201, 1, 7, 'DRAFT', 'ACTIVE');
END
$activate_deleted$;
""")).hasMessageContaining("content node cannot be logically deleted");
assertThatThrownBy(() -> execute(schema, """
UPDATE education_content_node
SET publication_status = 'ACTIVE', is_active = true, is_hidden = false, authoring_version = 1
SET deleted = true, authoring_version = 1
WHERE id = 201;
""")).hasMessageContaining("content node cannot be logically deleted");
assertThatThrownBy(() -> execute(schema, """
DO $deleted_audit$
BEGIN
UPDATE education_content_node
SET publication_status = 'ACTIVE', is_active = true, is_hidden = false,
authoring_version = 1
WHERE id = 201;
INSERT INTO education_content_node_lifecycle_audit
(tenant_id, node_id, authoring_version, actor_id, from_status, to_status, deleted)
VALUES (10, 201, 1, 7, 'DRAFT', 'ACTIVE', true);
END
$deleted_audit$;
""")).hasMessageContaining("content node lifecycle audit cannot be logically deleted");
assertThatThrownBy(() -> execute(schema, """
UPDATE education_content_node
SET publication_status = 'ARCHIVED', is_active = false, is_hidden = true,
authoring_version = 2
WHERE id = 200;
""")).hasMessageContaining("content node lifecycle audit must accompany its transition");
assertThatThrownBy(() -> execute(schema, """
INSERT INTO education_content_node_lifecycle_audit
(tenant_id, node_id, authoring_version, actor_id, from_status, to_status)
VALUES (10, 200, 2, 8, 'ACTIVE', 'ARCHIVED');
""")).hasMessageContaining("content node lifecycle audit must accompany its transition");
assertThatThrownBy(() -> execute(schema, """
UPDATE education_content_node
SET deleted = true, authoring_version = 2
WHERE id = 200;
""")).hasMessageContaining("content node cannot be logically deleted");
assertThatThrownBy(() -> execute(schema, """
UPDATE education_content_node
SET publication_status = 'ARCHIVED', is_active = false, is_hidden = true,
authoring_version = 2
WHERE id = 200;
""")).hasMessageContaining("content node lifecycle audit must accompany its transition");
execute(schema, """
DO $archive$
BEGIN
UPDATE education_content_node
SET publication_status = 'ARCHIVED', is_active = false, is_hidden = true,
authoring_version = 2
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, 2, 7, 'ACTIVE', 'ARCHIVED');
END
$archive$;
""");
assertThatThrownBy(() -> execute(schema, """
UPDATE education_content_node
SET deleted = true, authoring_version = 3
WHERE id = 200;
""")).hasMessageContaining("content node cannot be logically deleted");
assertThatThrownBy(() -> execute(schema, """
INSERT INTO education_content_node_lifecycle_audit
(tenant_id, node_id, authoring_version, actor_id, from_status, to_status)
VALUES (10, 200, 3, 8, 'ACTIVE', 'ARCHIVED');
""")).hasMessageContaining("content node lifecycle audit must accompany its transition");
assertThatThrownBy(() -> execute(schema, """
INSERT INTO education_content_node
(tenant_id, scope, entry_id, name, node_type, publication_status, is_active)
@@ -511,6 +625,56 @@ class EducationFlywayMigrationIntegrationTest {
""")).hasMessageContaining("PUBLIC content node writes are not managed by tenant authoring");
}
@Test
void shouldAllowRuntimeRoleTransitionsWithoutTokenTablePrivileges() throws SQLException {
String schema = createSchema("content_node_runtime_role");
configureFlyway(schema, false).load().migrate();
String runtimeRole = "edu_runtime_" + UUID.randomUUID().toString().replace("-", "");
try {
execute(schema, "CREATE ROLE " + runtimeRole + " LOGIN PASSWORD 'runtime_test'");
execute(schema, "GRANT USAGE ON SCHEMA " + schema + " TO " + runtimeRole);
execute(schema, "GRANT SELECT, INSERT, UPDATE ON ALL TABLES IN SCHEMA " + schema + " TO " + runtimeRole);
execute(schema, "GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA " + schema + " TO " + runtimeRole);
execute(schema, "REVOKE ALL ON " + schema
+ ".education_content_node_lifecycle_transition_token FROM " + runtimeRole);
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, is_active, is_hidden)
VALUES (200, 10, 'TENANT_OWNED', 100, 'Draft', 'category',
'DRAFT', false, true);
""");
try (Connection connection = DriverManager.getConnection(
jdbcUrl(schema), runtimeRole, "runtime_test");
var statement = connection.createStatement()) {
statement.execute("""
DO $activate$
BEGIN
UPDATE education_content_node
SET publication_status = 'ACTIVE', is_active = true, is_hidden = false,
authoring_version = 1
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');
END
$activate$;
""");
assertThatThrownBy(() -> statement.execute("""
INSERT INTO education_content_node_lifecycle_transition_token
(transaction_id, tenant_id, node_id, authoring_version, from_status, to_status)
VALUES (1, 10, 200, 2, 'ACTIVE', 'ARCHIVED');
""")).hasMessageContaining("permission denied");
}
} finally {
execute(schema, "DROP OWNED BY " + runtimeRole + "; DROP ROLE " + runtimeRole);
}
}
@Test
void shouldNormalizeHistoricalQuestionVisibilityIntoLifecycleStates() throws SQLException {
String schema = createSchema("question_history");
@@ -749,6 +913,10 @@ class EducationFlywayMigrationIntegrationTest {
'education_require_question_lifecycle_audit',
'education_check_question_version_ownership',
'education_validate_question_lifecycle_audit_insert',
'education_enforce_content_node_authoring',
'education_prevent_content_node_audit_mutation',
'education_require_content_node_lifecycle_audit',
'education_validate_content_node_lifecycle_audit',
'education_enforce_question_placement_mutation',
'education_question_answer_key_is_valid')
AND grantee = 'PUBLIC'