fix(education): control content node visibility

This commit is contained in:
2026-07-30 23:50:50 +08:00
parent e0a695978c
commit f84aecf8f5
9 changed files with 36 additions and 20 deletions

View File

@@ -51,7 +51,7 @@ public class ContentNodeAuthoringController {
private ContentNodeAuthoringCommand toCommand(ContentNodeDraftReqVO request, Integer expectedVersion) { private ContentNodeAuthoringCommand toCommand(ContentNodeDraftReqVO request, Integer expectedVersion) {
return new ContentNodeAuthoringCommand(request.getEntryId(), request.getParentId(), request.getName(), return new ContentNodeAuthoringCommand(request.getEntryId(), request.getParentId(), request.getName(),
request.getTitle(), request.getNodeType(), request.getMarkerType(), request.getLeaf(), request.getTitle(), request.getNodeType(), request.getMarkerType(), request.getSelectable(),
request.getSelectable(), request.getHidden(), request.getSortOrder(), request.getMetadata(), expectedVersion); request.getSortOrder(), request.getMetadata(), expectedVersion);
} }
} }

View File

@@ -15,9 +15,7 @@ public class ContentNodeDraftReqVO {
@Size(max = 200) private String title; @Size(max = 200) private String title;
@NotBlank @Size(max = 50) private String nodeType; @NotBlank @Size(max = 50) private String nodeType;
@Size(max = 50) private String markerType; @Size(max = 50) private String markerType;
@NotNull private Boolean leaf;
@NotNull private Boolean selectable; @NotNull private Boolean selectable;
@NotNull private Boolean hidden;
private Integer sortOrder; private Integer sortOrder;
private String metadata; private String metadata;
} }

View File

@@ -65,6 +65,7 @@ public interface ContentNodeMapper extends BaseMapperX<ContentNodeDO> {
.eq(ContentNodeDO::getAuthoringVersion, expectedVersion) .eq(ContentNodeDO::getAuthoringVersion, expectedVersion)
.set(ContentNodeDO::getPublicationStatus, targetStatus) .set(ContentNodeDO::getPublicationStatus, targetStatus)
.set(ContentNodeDO::getIsActive, active) .set(ContentNodeDO::getIsActive, active)
.set(ContentNodeDO::getIsHidden, !active)
.set(ContentNodeDO::getAuthoringVersion, expectedVersion + 1)); .set(ContentNodeDO::getAuthoringVersion, expectedVersion + 1));
} }

View File

@@ -1,7 +1,7 @@
package cn.iocoder.yudao.module.education.service.contentnode.authoring; package cn.iocoder.yudao.module.education.service.contentnode.authoring;
public record ContentNodeAuthoringCommand(Long entryId, Long parentId, String name, String title, public record ContentNodeAuthoringCommand(Long entryId, Long parentId, String name, String title,
String nodeType, String markerType, Boolean leaf, String nodeType, String markerType, Boolean selectable,
Boolean selectable, Boolean hidden, Integer sortOrder, Integer sortOrder, String metadata,
String metadata, Integer expectedAuthoringVersion) { Integer expectedAuthoringVersion) {
} }

View File

@@ -38,7 +38,8 @@ public class ContentNodeAuthoringServiceImpl implements ContentNodeAuthoringServ
ContentNodeDO node = new ContentNodeDO(); ContentNodeDO node = new ContentNodeDO();
apply(node, command); apply(node, command);
node.setTenantId(tenantId); node.setScope(TENANT_OWNED); node.setTenantId(tenantId); node.setScope(TENANT_OWNED);
node.setPublicationStatus("DRAFT"); node.setAuthoringVersion(0); node.setIsActive(false); node.setPublicationStatus("DRAFT"); node.setAuthoringVersion(0);
node.setIsActive(false); node.setIsHidden(true); node.setIsLeaf(true);
nodeMapper.insert(node); nodeMapper.insert(node);
return node.getId(); return node.getId();
} }
@@ -105,7 +106,7 @@ public class ContentNodeAuthoringServiceImpl implements ContentNodeAuthoringServ
private void apply(ContentNodeDO node, ContentNodeAuthoringCommand command) { private void apply(ContentNodeDO node, ContentNodeAuthoringCommand command) {
node.setEntryId(command.entryId()); node.setParentId(command.parentId()); node.setName(command.name()); node.setEntryId(command.entryId()); node.setParentId(command.parentId()); node.setName(command.name());
node.setTitle(command.title()); node.setNodeType(command.nodeType()); node.setMarkerType(command.markerType()); node.setTitle(command.title()); node.setNodeType(command.nodeType()); node.setMarkerType(command.markerType());
node.setIsLeaf(command.leaf()); node.setIsSelectable(command.selectable()); node.setIsHidden(command.hidden()); node.setIsSelectable(command.selectable());
node.setSortOrder(command.sortOrder() != null ? command.sortOrder() : 0); node.setMetadata(command.metadata()); node.setSortOrder(command.sortOrder() != null ? command.sortOrder() : 0); node.setMetadata(command.metadata());
} }

View File

@@ -6,14 +6,18 @@ ALTER TABLE education_content_node
ADD COLUMN authoring_version INTEGER NOT NULL DEFAULT 0; ADD COLUMN authoring_version INTEGER NOT NULL DEFAULT 0;
UPDATE education_content_node UPDATE education_content_node
SET publication_status = CASE WHEN is_active THEN 'ACTIVE' ELSE 'ARCHIVED' END; SET publication_status = CASE WHEN is_active AND NOT is_hidden THEN 'ACTIVE' ELSE 'ARCHIVED' END,
is_active = is_active AND NOT is_hidden,
is_hidden = NOT (is_active AND NOT is_hidden);
ALTER TABLE education_content_node ALTER TABLE education_content_node
ADD CONSTRAINT ck_education_content_node_publication_status ADD CONSTRAINT ck_education_content_node_publication_status
CHECK (publication_status IN ('DRAFT', 'ACTIVE', 'ARCHIVED')), CHECK (publication_status IN ('DRAFT', 'ACTIVE', 'ARCHIVED')),
ADD CONSTRAINT ck_education_content_node_authoring_version CHECK (authoring_version >= 0), ADD CONSTRAINT ck_education_content_node_authoring_version CHECK (authoring_version >= 0),
ADD CONSTRAINT ck_education_content_node_lifecycle_consistent ADD CONSTRAINT ck_education_content_node_lifecycle_consistent CHECK (
CHECK (is_active = (publication_status = 'ACTIVE')); (publication_status = 'ACTIVE' AND is_active AND NOT is_hidden) OR
(publication_status IN ('DRAFT', 'ARCHIVED') AND NOT is_active AND is_hidden)
);
COMMENT ON COLUMN education_content_node.publication_status IS '内容节点发布状态DRAFT、ACTIVE、ARCHIVED'; COMMENT ON COLUMN education_content_node.publication_status IS '内容节点发布状态DRAFT、ACTIVE、ARCHIVED';
COMMENT ON COLUMN education_content_node.authoring_version IS '内容节点创作和生命周期共用的乐观锁版本'; COMMENT ON COLUMN education_content_node.authoring_version IS '内容节点创作和生命周期共用的乐观锁版本';
@@ -47,11 +51,13 @@ RETURNS TRIGGER LANGUAGE plpgsql SET search_path = pg_catalog, pg_temp AS $$
BEGIN BEGIN
IF TG_OP = 'INSERT' THEN IF TG_OP = 'INSERT' THEN
IF NEW.scope = 'PUBLIC' THEN IF NEW.scope = 'PUBLIC' THEN
IF NEW.publication_status <> 'ACTIVE' OR NOT NEW.is_active OR NEW.authoring_version <> 0 THEN IF NEW.publication_status <> 'ACTIVE' OR NOT NEW.is_active OR NEW.is_hidden
OR NEW.authoring_version <> 0 THEN
RAISE EXCEPTION 'PUBLIC content node writes are not managed by tenant authoring' USING ERRCODE = '23514'; RAISE EXCEPTION 'PUBLIC content node writes are not managed by tenant authoring' USING ERRCODE = '23514';
END IF; END IF;
ELSIF NEW.scope = 'TENANT_OWNED' AND ELSIF NEW.scope = 'TENANT_OWNED' AND
(NEW.publication_status <> 'DRAFT' OR NEW.is_active OR NEW.authoring_version <> 0) THEN (NEW.publication_status <> 'DRAFT' OR NEW.is_active OR NOT NEW.is_hidden
OR NEW.authoring_version <> 0) THEN
RAISE EXCEPTION 'tenant content nodes must start in DRAFT' USING ERRCODE = '23514'; RAISE EXCEPTION 'tenant content nodes must start in DRAFT' USING ERRCODE = '23514';
END IF; END IF;
RETURN NEW; RETURN NEW;

View File

@@ -57,7 +57,7 @@ class ContentNodeAuthoringControllerContractTest {
security.permissions.add("education:content-node:author"); security.permissions.add("education:content-node:author");
ContentNodeReviseReqVO request = new ContentNodeReviseReqVO(); ContentNodeReviseReqVO request = new ContentNodeReviseReqVO();
request.setExpectedAuthoringVersion(2); request.setName("Revised"); request.setNodeType("category"); request.setExpectedAuthoringVersion(2); request.setName("Revised"); request.setNodeType("category");
request.setEntryId(100L); request.setSelectable(true); request.setHidden(false); request.setLeaf(true); request.setEntryId(100L); request.setSelectable(true);
assertEquals(3, controller.revise(101L, request).getData()); assertEquals(3, controller.revise(101L, request).getData());
assertEquals(2, service.command.expectedAuthoringVersion()); assertEquals(2, service.command.expectedAuthoringVersion());
} }
@@ -65,7 +65,7 @@ class ContentNodeAuthoringControllerContractTest {
private ContentNodeDraftReqVO request() { private ContentNodeDraftReqVO request() {
ContentNodeDraftReqVO request = new ContentNodeDraftReqVO(); ContentNodeDraftReqVO request = new ContentNodeDraftReqVO();
request.setEntryId(100L); request.setName("Algebra"); request.setNodeType("category"); request.setEntryId(100L); request.setName("Algebra"); request.setNodeType("category");
request.setSelectable(true); request.setHidden(false); request.setLeaf(true); request.setSelectable(true);
return request; return request;
} }

View File

@@ -38,15 +38,25 @@ class ContentNodeAuthoringPostgreSqlIntegrationTest extends PostgreSqlDbIntegrat
@Test void draftActivateArchiveControlsStudentDiscoveryAndPlacement() { @Test void draftActivateArchiveControlsStudentDiscoveryAndPlacement() {
Long id = service.createDraft(command(null, null, "Algebra", null)); Long id = service.createDraft(command(null, null, "Algebra", null));
ContentNodeDO draft = nodeMapper.selectTenantOwnedById(10L, id);
assertFalse(draft.getIsActive());
assertTrue(draft.getIsHidden());
assertTrue(draft.getIsLeaf());
assertEquals(0, nodeMapper.selectAllByEntryId(10L, 100L, false, null).size()); assertEquals(0, nodeMapper.selectAllByEntryId(10L, 100L, false, null).size());
assertNull(nodeMapper.selectAvailablePlacementTarget(10L, id)); assertNull(nodeMapper.selectAvailablePlacementTarget(10L, id));
assertEquals(1, service.reviseDraft(id, command(null, null, "Algebra revised", 0))); assertEquals(1, service.reviseDraft(id, command(null, null, "Algebra revised", 0)));
assertEquals(2, service.activate(id, 1, 7L)); assertEquals(2, service.activate(id, 1, 7L));
ContentNodeDO active = nodeMapper.selectTenantOwnedById(10L, id);
assertTrue(active.getIsActive());
assertFalse(active.getIsHidden());
assertEquals(1, nodeMapper.selectAllByEntryId(10L, 100L, false, null).size()); assertEquals(1, nodeMapper.selectAllByEntryId(10L, 100L, false, null).size());
assertNotNull(nodeMapper.selectAvailablePlacementTarget(10L, id)); assertNotNull(nodeMapper.selectAvailablePlacementTarget(10L, id));
assertEquals(3, service.archive(id, 2, 7L)); assertEquals(3, service.archive(id, 2, 7L));
ContentNodeDO archived = nodeMapper.selectTenantOwnedById(10L, id);
assertFalse(archived.getIsActive());
assertTrue(archived.getIsHidden());
assertEquals(0, nodeMapper.selectAllByEntryId(10L, 100L, false, null).size()); assertEquals(0, nodeMapper.selectAllByEntryId(10L, 100L, false, null).size());
assertNull(nodeMapper.selectAvailablePlacementTarget(10L, id)); assertNull(nodeMapper.selectAvailablePlacementTarget(10L, id));
assertEquals(2L, auditMapper.selectCount()); assertEquals(2L, auditMapper.selectCount());
@@ -74,6 +84,6 @@ class ContentNodeAuthoringPostgreSqlIntegrationTest extends PostgreSqlDbIntegrat
private ContentNodeAuthoringCommand command(Long entryId, Long parentId, String name, Integer version) { private ContentNodeAuthoringCommand command(Long entryId, Long parentId, String name, Integer version) {
return new ContentNodeAuthoringCommand(entryId != null ? entryId : 100L, parentId, name, null, return new ContentNodeAuthoringCommand(entryId != null ? entryId : 100L, parentId, name, null,
"category", null, true, true, false, 0, null, version); "category", null, true, 0, null, version);
} }
} }

View File

@@ -484,12 +484,12 @@ class EducationFlywayMigrationIntegrationTest {
(id, tenant_id, scope, entry_key, name, entry_type) (id, tenant_id, scope, entry_key, name, entry_type)
VALUES (100, 10, 'TENANT_OWNED', 'questions', 'Questions', 'question'); VALUES (100, 10, 'TENANT_OWNED', 'questions', 'Questions', 'question');
INSERT INTO education_content_node INSERT INTO education_content_node
(id, tenant_id, scope, entry_id, name, node_type, publication_status, is_active) (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); VALUES (200, 10, 'TENANT_OWNED', 100, 'Draft', 'category', 'DRAFT', false, true);
"""); """);
assertThatThrownBy(() -> execute(schema, """ assertThatThrownBy(() -> execute(schema, """
UPDATE education_content_node UPDATE education_content_node
SET publication_status = 'ACTIVE', is_active = true, authoring_version = 1 SET publication_status = 'ACTIVE', is_active = true, is_hidden = false, authoring_version = 1
WHERE id = 200; WHERE id = 200;
""")).hasMessageContaining("content node lifecycle audit must accompany its transition"); """)).hasMessageContaining("content node lifecycle audit must accompany its transition");
assertThatThrownBy(() -> execute(schema, """ assertThatThrownBy(() -> execute(schema, """