diff --git a/docs/education/migration/07-decisions.md b/docs/education/migration/07-decisions.md index 9fd6cc2b..658b44bd 100644 --- a/docs/education/migration/07-decisions.md +++ b/docs/education/migration/07-decisions.md @@ -117,6 +117,9 @@ The durable artifact classification, adoption matrix, version allocation, backfi 8. Question Placement means assigning a DRAFT question's `node_id`; it does not mean Category CRUD. The `education_category` table has no Question relationship in the current target model. 9. Placement targets must be visible, active, selectable PUBLIC or same-tenant Content Nodes. Placement has an independent optimistic version, becomes immutable after publication, and publication CAS includes that version. 10. Node-route student visibility is evaluated atomically in the Question/Content Node query. Node availability gates that route but does not change direct question visibility or historical snapshots. +11. Tenant Content Node authoring is JAVA_READ-only, current-tenant TENANT_OWNED-only, and uses `DRAFT → ACTIVE → ARCHIVED` with one CAS `authoring_version` shared by revisions and transitions. +12. Content Node activation requires a structurally available entry and parent. Student discovery and Question Placement treat only ACTIVE nodes as available. Content Node lifecycle audit is transactional and append-only. +13. Content Node permissions are `education:content-node:author`, `education:content-node:publish`, and `education:content-node:archive`; V4100 seeds no permission, menu, role, or role grant. **Permission and data-scope matrix:** @@ -126,7 +129,10 @@ The durable artifact classification, adoption matrix, version allocation, backfi | Classifier | `education:question:classify` | Place or re-place current-tenant `TENANT_OWNED` drafts on allowed Content Nodes | Tenant-wide shared catalog asset; PUBLIC/same-tenant targets only | | Publisher | `education:question:publish` | Current-tenant `TENANT_OWNED` drafts | Tenant-wide shared catalog asset; department/self DataPermission does not expand access | | Archiver | `education:question:archive` | Current-tenant `TENANT_OWNED` published questions | Tenant-wide shared catalog asset; department/self DataPermission does not expand access | -| Platform curator | none in this slice | No PUBLIC or tenant-owned writes through these endpoints | Fail closed | +| Content Node author | `education:content-node:author` | Create/revise current-tenant TENANT_OWNED drafts | Current tenant; structural entry/parent validation | +| Content Node publisher | `education:content-node:publish` | Activate current-tenant TENANT_OWNED drafts | Tenant-wide shared catalog asset; authoring-version CAS | +| Content Node archiver | `education:content-node:archive` | Archive current-tenant TENANT_OWNED active nodes | Tenant-wide shared catalog asset; authoring-version CAS | +| Platform curator | none in this slice | No PUBLIC writes through these endpoints | Fail closed | V4080/V4090 conditionally seed the five Education permissions (`education:capability` plus author/classify/publish/archive) when the adopted platform schema contains `system_menu`. They fail when a fixed ID is already occupied by a different permission and deliberately do not assign the permissions to any role. diff --git a/docs/education/migration/issues/EDU-010-content-publication.md b/docs/education/migration/issues/EDU-010-content-publication.md index 6e3e0266..a005dcc1 100644 --- a/docs/education/migration/issues/EDU-010-content-publication.md +++ b/docs/education/migration/issues/EDU-010-content-publication.md @@ -1,6 +1,6 @@ # EDU-010 — Tenant content publication and graph integrity -- **Status:** in progress — catalog graph-integrity, the verified JAVA_READ tenant-question lifecycle, and Question Placement slices are delivered; category, collection, and blueprint authoring remain +- **Status:** in progress — catalog graph-integrity, JAVA_READ tenant-question lifecycle, Question Placement, and tenant Content Node lifecycle slices are delivered; category, collection, and blueprint authoring remain - **Type:** implementation program - **Phase:** 3 - **Blockers:** EDU-004 ✓ (done), EDU-009 ✓ (done), provider-authority decision ✓ (resolved 2026-07-30, see decisions.md), PUBLIC graph-semantics decision ✓ (resolved 2026-07-30, see decisions.md) @@ -85,7 +85,18 @@ Delivered: - Student node-route reads join Question and Content Node in one PostgreSQL statement. A hidden, inactive, non-selectable, cross-scope, or deleted node cannot expose questions through that route; direct question visibility still follows the question Publication State. - V4090 conditionally seeds `education:question:classify` at fixed ID 6805, fails closed on conflicting rows, and grants no role. -Category CRUD, Content Node authoring, Collection/Blueprint authoring, PUBLIC curator workflows, and a distinct Retire state remain outside this slice. In the target domain, `education_category` is not connected to Question; the delivered classification seam is explicitly Question Placement through `question.node_id`. +Category CRUD, Collection/Blueprint authoring, PUBLIC curator workflows, and a distinct Retire state remain outside the delivered slices. In the target domain, `education_category` is not connected to Question; classification remains Question Placement through `question.node_id`. + +## Delivery progress — JAVA_READ tenant Content Node lifecycle slice (2026-07-30) + +Delivered: + +- Current-tenant `TENANT_OWNED` Content Nodes expose draft create/revise, activate, and archive commands only in `JAVA_READ`. PUBLIC writes and Category CRUD fail closed/outside the surface. +- The command surface is strictly `DRAFT → ACTIVE → ARCHIVED`. One `authoring_version` CAS advances on every draft revision and lifecycle transition; ACTIVE content and ARCHIVED rows are immutable. +- Entry and parent validation accepts only active, visible PUBLIC or same-tenant graph parents, requires the parent to belong to the same entry, and rejects self-parenting. +- `education:content-node:author`, `education:content-node:publish`, and `education:content-node:archive` are independent controller permissions. V4100 deliberately seeds neither permissions nor roles. +- Activation/archive append an actor/version/status audit in the same transaction. V4100 makes that audit append-only and uses a deferred constraint trigger to reject lifecycle changes without the matching audit. +- Student catalog discovery and Question Placement continue to require `is_active=true`; V4100 constrains that flag to `publication_status='ACTIVE'`, so drafts/archives cannot appear or accept placement. ## Verification evidence (2026-07-30) diff --git a/yudao-module-education/CONTEXT.md b/yudao-module-education/CONTEXT.md index 4f2d0f74..731897ad 100644 --- a/yudao-module-education/CONTEXT.md +++ b/yudao-module-education/CONTEXT.md @@ -68,6 +68,18 @@ _Avoid_: Category assignment, editable published classification A non-negative optimistic concurrency version for Question Placement. It advances exactly once when the node changes, is independent from the immutable Question Content Version, and participates in publication CAS. _Avoid_: Content version, lifecycle version +**Content Node Publication State**: +The explicit state of a tenant-owned Content Node: Draft is author-editable and student-invisible, Active is immutable and available for student discovery/Question Placement, and Archived is terminal and unavailable. +_Avoid_: `is_active` as an independent lifecycle, Category lifecycle + +**Content Node Authoring Version**: +The single non-negative optimistic concurrency version advanced exactly once by each Content Node draft revision or lifecycle transition. +_Avoid_: Question Placement Version, Question Content Version + +**Content Node Lifecycle Audit**: +An append-only Education domain fact recording actor, authoring version, and a valid Content Node Publication State transition in the same transaction. +_Avoid_: Operation log as activation proof + **Protected Answer Key**: Server-only correctness and explanation data captured for stable scoring of a practice session. It is never included in a Safe Question, Question Snapshot JSON, or pre-submit response. _Avoid_: Question Snapshot, frontend answer diff --git a/yudao-module-education/README.md b/yudao-module-education/README.md index 47797f3c..9141f1ca 100644 --- a/yudao-module-education/README.md +++ b/yudao-module-education/README.md @@ -238,6 +238,7 @@ yudao-module-education/src/main/resources/db/migration/education/ - `V4070` 对 19 条目录引用边执行历史预校验并安装写入守卫,同时将 11 张目录表的 `tenant_id/scope` 设为插入后不可变,阻止 PUBLIC→租户、跨租户以及父节点归属变更造成的非法关系。 - `V4080` 增加第 20 条目录引用边 `education_question_version.question_id → education_question.id`,并为版本表增加第 12 个 ownership/scope 守卫;版本归属必须与题目完全一致。该迁移还建立 draft-first 生命周期、不可变题目版本、同事务追加式审计及历史已发布内容的 fail-closed 预检。 - `V4090` 增加 Question Placement 乐观版本、PUBLIC 管理拒绝、发布后放置冻结、发布前可用节点约束,以及 `education:question:classify` 权限种子。 +- `V4100` 增加租户 Content Node 的 `DRAFT → ACTIVE → ARCHIVED` 生命周期、统一 `authoring_version` CAS、同事务追加式审计和 entry/parent 结构约束;不写入权限或角色种子。 - V4080/V4090 在平台 `system_menu` 已存在时条件写入 Education 权限种子;固定 ID 已被不同 permission 占用时迁移失败,且迁移不会向角色写入授权关系。 - 旧 `education_answer_idempotency`、`education_submit_idempotency` 在首次接管时保留,后续清理必须使用更高版本的独立向前 migration。 - `sql/postgresql/education/` 是手工初始化/设计历史,`sql/mysql/education/` 是过时归档;两者都不是运行时交付入口。 diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/contentnode/ContentNodeAuthoringController.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/contentnode/ContentNodeAuthoringController.java new file mode 100644 index 00000000..52e0a880 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/contentnode/ContentNodeAuthoringController.java @@ -0,0 +1,57 @@ +package cn.iocoder.yudao.module.education.controller.admin.contentnode; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.education.controller.admin.contentnode.vo.ContentNodeDraftReqVO; +import cn.iocoder.yudao.module.education.controller.admin.contentnode.vo.ContentNodeReviseReqVO; +import cn.iocoder.yudao.module.education.service.contentnode.authoring.ContentNodeAuthoringCommand; +import cn.iocoder.yudao.module.education.service.contentnode.authoring.ContentNodeAuthoringService; +import jakarta.validation.Valid; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@RestController +@RequestMapping("/education/content-nodes") +@Validated +@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true") +public class ContentNodeAuthoringController { + private final ContentNodeAuthoringService service; + public ContentNodeAuthoringController(ContentNodeAuthoringService service) { this.service = service; } + + @PostMapping("/drafts") + @PreAuthorize("@ss.hasPermission('education:content-node:author')") + public CommonResult create(@Valid @RequestBody ContentNodeDraftReqVO request) { + return success(service.createDraft(toCommand(request, null))); + } + + @PutMapping("/{id}/draft") + @PreAuthorize("@ss.hasPermission('education:content-node:author')") + public CommonResult revise(@PathVariable("id") Long id, + @Valid @RequestBody ContentNodeReviseReqVO request) { + return success(service.reviseDraft(id, toCommand(request, request.getExpectedAuthoringVersion()))); + } + + @PutMapping("/{id}/activate") + @PreAuthorize("@ss.hasPermission('education:content-node:publish')") + public CommonResult activate(@PathVariable("id") Long id, + @RequestParam("expectedAuthoringVersion") int expectedVersion) { + return success(service.activate(id, expectedVersion, getLoginUserId())); + } + + @PutMapping("/{id}/archive") + @PreAuthorize("@ss.hasPermission('education:content-node:archive')") + public CommonResult archive(@PathVariable("id") Long id, + @RequestParam("expectedAuthoringVersion") int expectedVersion) { + return success(service.archive(id, expectedVersion, getLoginUserId())); + } + + private ContentNodeAuthoringCommand toCommand(ContentNodeDraftReqVO request, Integer expectedVersion) { + return new ContentNodeAuthoringCommand(request.getEntryId(), request.getParentId(), request.getName(), + request.getTitle(), request.getNodeType(), request.getMarkerType(), request.getLeaf(), + request.getSelectable(), request.getHidden(), request.getSortOrder(), request.getMetadata(), expectedVersion); + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/contentnode/vo/ContentNodeDraftReqVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/contentnode/vo/ContentNodeDraftReqVO.java new file mode 100644 index 00000000..697b8105 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/contentnode/vo/ContentNodeDraftReqVO.java @@ -0,0 +1,23 @@ +package cn.iocoder.yudao.module.education.controller.admin.contentnode.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - 创建内容节点草稿 Request VO") +@Data +public class ContentNodeDraftReqVO { + @NotNull private Long entryId; + private Long parentId; + @NotBlank @Size(max = 200) private String name; + @Size(max = 200) private String title; + @NotBlank @Size(max = 50) private String nodeType; + @Size(max = 50) private String markerType; + @NotNull private Boolean leaf; + @NotNull private Boolean selectable; + @NotNull private Boolean hidden; + private Integer sortOrder; + private String metadata; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/contentnode/vo/ContentNodeReviseReqVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/contentnode/vo/ContentNodeReviseReqVO.java new file mode 100644 index 00000000..6f36d702 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/contentnode/vo/ContentNodeReviseReqVO.java @@ -0,0 +1,9 @@ +package cn.iocoder.yudao.module.education.controller.admin.contentnode.vo; + +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +public class ContentNodeReviseReqVO extends ContentNodeDraftReqVO { + @NotNull private Integer expectedAuthoringVersion; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/catalog/ContentNodeDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/catalog/ContentNodeDO.java index ed7f9801..75eee39f 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/catalog/ContentNodeDO.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/catalog/ContentNodeDO.java @@ -29,6 +29,8 @@ public class ContentNodeDO extends CatalogScopeDO { private Boolean isSelectable; private Boolean isHidden; private Boolean isActive; + private String publicationStatus; + private Integer authoringVersion; private Integer sortOrder; private String metadata; } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/catalog/ContentNodeLifecycleAuditDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/catalog/ContentNodeLifecycleAuditDO.java new file mode 100644 index 00000000..21f64395 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/catalog/ContentNodeLifecycleAuditDO.java @@ -0,0 +1,23 @@ +package cn.iocoder.yudao.module.education.dal.dataobject.catalog; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; +import java.time.LocalDateTime; + +@TableName("education_content_node_lifecycle_audit") +@KeySequence("education_content_node_lifecycle_audit_seq") +@Data +@EqualsAndHashCode(callSuper = true) +public class ContentNodeLifecycleAuditDO extends TenantBaseDO { + @TableId private Long id; + private Long nodeId; + private Integer authoringVersion; + private Long actorId; + private String fromStatus; + private String toStatus; + private LocalDateTime occurredAt; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/ContentNodeLifecycleAuditMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/ContentNodeLifecycleAuditMapper.java new file mode 100644 index 00000000..e206ff70 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/ContentNodeLifecycleAuditMapper.java @@ -0,0 +1,9 @@ +package cn.iocoder.yudao.module.education.dal.mysql.catalog; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.module.education.dal.dataobject.catalog.ContentNodeLifecycleAuditDO; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ContentNodeLifecycleAuditMapper extends BaseMapperX { +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/ContentNodeMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/ContentNodeMapper.java index f3beedde..cd38a04c 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/ContentNodeMapper.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/ContentNodeMapper.java @@ -26,6 +26,48 @@ public interface ContentNodeMapper extends BaseMapperX { ContentNodeDO selectAvailablePlacementTarget(@Param("tenantId") Long tenantId, @Param("nodeId") Long nodeId); + @Select(""" + SELECT COUNT(*) 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')) + """) + int countAvailableEntry(@Param("tenantId") Long tenantId, @Param("entryId") Long entryId); + + @Select(""" + SELECT COUNT(*) 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')) + """) + int countAvailableParent(@Param("tenantId") Long tenantId, @Param("entryId") Long entryId, + @Param("parentId") Long parentId); + + default ContentNodeDO selectTenantOwnedById(Long tenantId, Long id) { + return selectOne(new LambdaQueryWrapperX().eq(ContentNodeDO::getId, id) + .eq(ContentNodeDO::getTenantId, tenantId).eq(ContentNodeDO::getScope, "TENANT_OWNED")); + } + + default int updateDraftCas(Long tenantId, Long id, ContentNodeDO values, int expectedVersion) { + return update(values, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper() + .eq(ContentNodeDO::getId, id).eq(ContentNodeDO::getTenantId, tenantId) + .eq(ContentNodeDO::getScope, "TENANT_OWNED") + .eq(ContentNodeDO::getPublicationStatus, "DRAFT") + .eq(ContentNodeDO::getAuthoringVersion, expectedVersion) + .set(ContentNodeDO::getAuthoringVersion, expectedVersion + 1)); + } + + default int updateLifecycleCas(Long tenantId, Long id, String expectedStatus, String targetStatus, + boolean active, int expectedVersion) { + return update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper() + .eq(ContentNodeDO::getId, id).eq(ContentNodeDO::getTenantId, tenantId) + .eq(ContentNodeDO::getScope, "TENANT_OWNED") + .eq(ContentNodeDO::getPublicationStatus, expectedStatus) + .eq(ContentNodeDO::getAuthoringVersion, expectedVersion) + .set(ContentNodeDO::getPublicationStatus, targetStatus) + .set(ContentNodeDO::getIsActive, active) + .set(ContentNodeDO::getAuthoringVersion, expectedVersion + 1)); + } + default List selectChildren(Long tenantId, Long entryId, Long parentId, boolean includeInactive, String markerType) { LambdaQueryWrapperX w = base(tenantId, entryId, includeInactive, markerType); diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java index f114a247..080a3d70 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java @@ -107,4 +107,11 @@ public interface ErrorCodeConstants { ErrorCode QUESTION_PLACEMENT_TARGET_UNAVAILABLE = new ErrorCode(1_005_003_074, "题目归类目标不存在或不可用"); ErrorCode QUESTION_PLACEMENT_CONFLICT = new ErrorCode(1_005_003_075, "题目归类已变化,请刷新后重试"); ErrorCode QUESTION_PLACEMENT_REQUIRED = new ErrorCode(1_005_003_076, "题目尚未归类,无法发布"); + + // ========== 内容节点创作 1-005-002-020 ~ 1-005-002-029 ========== + ErrorCode CONTENT_NODE_AUTHORING_PROVIDER_UNSUPPORTED = new ErrorCode(1_005_002_020, + "当前题库数据源模式不支持内容节点创作:{}"); + ErrorCode CONTENT_NODE_AUTHORING_NOT_FOUND = new ErrorCode(1_005_002_021, "内容节点不存在或无权管理"); + ErrorCode CONTENT_NODE_AUTHORING_CONFLICT = new ErrorCode(1_005_002_022, "内容节点已变化,请刷新后重试"); + ErrorCode CONTENT_NODE_STRUCTURE_INVALID = new ErrorCode(1_005_002_023, "内容节点入口或父节点无效"); } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/contentnode/authoring/ContentNodeAuthoringCommand.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/contentnode/authoring/ContentNodeAuthoringCommand.java new file mode 100644 index 00000000..8b83f31a --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/contentnode/authoring/ContentNodeAuthoringCommand.java @@ -0,0 +1,7 @@ +package cn.iocoder.yudao.module.education.service.contentnode.authoring; + +public record ContentNodeAuthoringCommand(Long entryId, Long parentId, String name, String title, + String nodeType, String markerType, Boolean leaf, + Boolean selectable, Boolean hidden, Integer sortOrder, + String metadata, Integer expectedAuthoringVersion) { +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/contentnode/authoring/ContentNodeAuthoringService.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/contentnode/authoring/ContentNodeAuthoringService.java new file mode 100644 index 00000000..8f14a991 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/contentnode/authoring/ContentNodeAuthoringService.java @@ -0,0 +1,8 @@ +package cn.iocoder.yudao.module.education.service.contentnode.authoring; + +public interface ContentNodeAuthoringService { + Long createDraft(ContentNodeAuthoringCommand command); + int reviseDraft(Long nodeId, ContentNodeAuthoringCommand command); + int activate(Long nodeId, int expectedAuthoringVersion, Long actorId); + int archive(Long nodeId, int expectedAuthoringVersion, Long actorId); +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/contentnode/authoring/ContentNodeAuthoringServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/contentnode/authoring/ContentNodeAuthoringServiceImpl.java new file mode 100644 index 00000000..8adefdbe --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/contentnode/authoring/ContentNodeAuthoringServiceImpl.java @@ -0,0 +1,117 @@ +package cn.iocoder.yudao.module.education.service.contentnode.authoring; + +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; +import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils; +import cn.iocoder.yudao.module.education.config.EducationProperties; +import cn.iocoder.yudao.module.education.dal.dataobject.catalog.ContentNodeDO; +import cn.iocoder.yudao.module.education.dal.dataobject.catalog.ContentNodeLifecycleAuditDO; +import cn.iocoder.yudao.module.education.dal.mysql.catalog.ContentNodeLifecycleAuditMapper; +import cn.iocoder.yudao.module.education.dal.mysql.catalog.ContentNodeMapper; +import cn.iocoder.yudao.module.education.enums.CatalogProviderMode; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*; + +@Service +public class ContentNodeAuthoringServiceImpl implements ContentNodeAuthoringService { + private static final String TENANT_OWNED = "TENANT_OWNED"; + private final EducationProperties properties; + private final ContentNodeMapper nodeMapper; + private final ContentNodeLifecycleAuditMapper auditMapper; + + public ContentNodeAuthoringServiceImpl(EducationProperties properties, ContentNodeMapper nodeMapper, + ContentNodeLifecycleAuditMapper auditMapper) { + this.properties = properties; + this.nodeMapper = nodeMapper; + this.auditMapper = auditMapper; + } + + @Override @Transactional(rollbackFor = Exception.class) + public Long createDraft(ContentNodeAuthoringCommand command) { + assertMode(); + Long tenantId = TenantContextHolder.getRequiredTenantId(); + validateStructure(tenantId, null, command.entryId(), command.parentId()); + ContentNodeDO node = new ContentNodeDO(); + apply(node, command); + node.setTenantId(tenantId); node.setScope(TENANT_OWNED); + node.setPublicationStatus("DRAFT"); node.setAuthoringVersion(0); node.setIsActive(false); + nodeMapper.insert(node); + return node.getId(); + } + + @Override @Transactional(rollbackFor = Exception.class) + public int reviseDraft(Long nodeId, ContentNodeAuthoringCommand command) { + assertMode(); + Long tenantId = TenantContextHolder.getRequiredTenantId(); + ContentNodeDO current = requireOwned(tenantId, nodeId); + if (!"DRAFT".equals(current.getPublicationStatus()) + || !command.expectedAuthoringVersion().equals(current.getAuthoringVersion())) { + throw exception(CONTENT_NODE_AUTHORING_CONFLICT); + } + validateStructure(tenantId, nodeId, command.entryId(), command.parentId()); + ContentNodeDO update = new ContentNodeDO(); apply(update, command); + if (nodeMapper.updateDraftCas(tenantId, nodeId, update, command.expectedAuthoringVersion()) != 1) { + throw exception(CONTENT_NODE_AUTHORING_CONFLICT); + } + return command.expectedAuthoringVersion() + 1; + } + + @Override @Transactional(rollbackFor = Exception.class) + public int activate(Long nodeId, int expectedVersion, Long actorId) { + return transition(nodeId, expectedVersion, actorId, "DRAFT", "ACTIVE", true); + } + + @Override @Transactional(rollbackFor = Exception.class) + public int archive(Long nodeId, int expectedVersion, Long actorId) { + return transition(nodeId, expectedVersion, actorId, "ACTIVE", "ARCHIVED", false); + } + + private int transition(Long nodeId, int expectedVersion, Long actorId, String from, String to, boolean active) { + assertMode(); Long tenantId = TenantContextHolder.getRequiredTenantId(); + ContentNodeDO current = requireOwned(tenantId, nodeId); + if (!from.equals(current.getPublicationStatus()) + || current.getAuthoringVersion() == null || current.getAuthoringVersion() != expectedVersion) { + throw exception(CONTENT_NODE_AUTHORING_CONFLICT); + } + if ("ACTIVE".equals(to)) validateStructure(tenantId, nodeId, current.getEntryId(), current.getParentId()); + if (nodeMapper.updateLifecycleCas(tenantId, nodeId, from, to, active, expectedVersion) != 1) { + throw exception(CONTENT_NODE_AUTHORING_CONFLICT); + } + ContentNodeLifecycleAuditDO audit = new ContentNodeLifecycleAuditDO(); + audit.setTenantId(tenantId); audit.setNodeId(nodeId); audit.setAuthoringVersion(expectedVersion + 1); + audit.setActorId(actorId); audit.setFromStatus(from); audit.setToStatus(to); audit.setOccurredAt(LocalDateTime.now()); + auditMapper.insert(audit); + return expectedVersion + 1; + } + + private ContentNodeDO requireOwned(Long tenantId, Long nodeId) { + ContentNodeDO node = nodeMapper.selectTenantOwnedById(tenantId, nodeId); + if (node == null) throw exception(CONTENT_NODE_AUTHORING_NOT_FOUND); + return node; + } + + private void validateStructure(Long tenantId, Long nodeId, Long entryId, Long parentId) { + if (entryId == null || TenantUtils.executeIgnore(() -> nodeMapper.countAvailableEntry(tenantId, entryId)) != 1 + || (parentId != null && (parentId.equals(nodeId) + || TenantUtils.executeIgnore(() -> nodeMapper.countAvailableParent(tenantId, entryId, parentId)) != 1))) { + throw exception(CONTENT_NODE_STRUCTURE_INVALID); + } + } + + private void apply(ContentNodeDO node, ContentNodeAuthoringCommand command) { + 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.setIsLeaf(command.leaf()); node.setIsSelectable(command.selectable()); node.setIsHidden(command.hidden()); + node.setSortOrder(command.sortOrder() != null ? command.sortOrder() : 0); node.setMetadata(command.metadata()); + } + + private void assertMode() { + if (properties.getCatalogMode() != CatalogProviderMode.JAVA_READ) { + throw exception(CONTENT_NODE_AUTHORING_PROVIDER_UNSUPPORTED, properties.getCatalogMode()); + } + } +} diff --git a/yudao-module-education/src/main/resources/db/migration/education/V4100__add_content_node_authoring_lifecycle.sql b/yudao-module-education/src/main/resources/db/migration/education/V4100__add_content_node_authoring_lifecycle.sql new file mode 100644 index 00000000..cc4a1a74 --- /dev/null +++ b/yudao-module-education/src/main/resources/db/migration/education/V4100__add_content_node_authoring_lifecycle.sql @@ -0,0 +1,130 @@ +-- EDU-010: tenant-owned Content Node authoring lifecycle. +-- No PUBLIC writes, permission seed, Category CRUD, or role grants are introduced. + +ALTER TABLE education_content_node + ADD COLUMN publication_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + ADD COLUMN authoring_version INTEGER NOT NULL DEFAULT 0; + +UPDATE education_content_node + SET publication_status = CASE WHEN is_active THEN 'ACTIVE' ELSE 'ARCHIVED' END; + +ALTER TABLE education_content_node + ADD CONSTRAINT ck_education_content_node_publication_status + 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_lifecycle_consistent + CHECK (is_active = (publication_status = 'ACTIVE')); + +COMMENT ON COLUMN education_content_node.publication_status IS '内容节点发布状态:DRAFT、ACTIVE、ARCHIVED'; +COMMENT ON COLUMN education_content_node.authoring_version IS '内容节点创作和生命周期共用的乐观锁版本'; + +CREATE TABLE education_content_node_lifecycle_audit ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + tenant_id BIGINT NOT NULL, + node_id BIGINT NOT NULL, + authoring_version INTEGER NOT NULL, + actor_id BIGINT NOT NULL, + from_status VARCHAR(16) NOT NULL, + to_status VARCHAR(16) NOT NULL, + occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + creator VARCHAR(64) DEFAULT '', + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updater VARCHAR(64) DEFAULT '', + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT false, + CONSTRAINT fk_education_content_node_lifecycle_audit_node + 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')) +); +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 FUNCTION education_enforce_content_node_authoring() +RETURNS TRIGGER LANGUAGE plpgsql SET search_path = pg_catalog, pg_temp AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + IF NEW.scope = 'PUBLIC' THEN + IF NEW.publication_status <> 'ACTIVE' OR NOT NEW.is_active OR NEW.authoring_version <> 0 THEN + RAISE EXCEPTION 'PUBLIC content node writes are not managed by tenant authoring' USING ERRCODE = '23514'; + END IF; + ELSIF NEW.scope = 'TENANT_OWNED' AND + (NEW.publication_status <> 'DRAFT' OR NEW.is_active OR NEW.authoring_version <> 0) THEN + RAISE EXCEPTION 'tenant content nodes must start in DRAFT' USING ERRCODE = '23514'; + END IF; + RETURN NEW; + END IF; + 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.publication_status = 'ARCHIVED' AND NEW IS DISTINCT FROM OLD THEN + RAISE EXCEPTION 'archived content node is immutable' USING ERRCODE = '23514'; + END IF; + IF NEW.authoring_version <> OLD.authoring_version + 1 THEN + RAISE EXCEPTION 'content node authoring version must advance exactly once' USING ERRCODE = '23514'; + END IF; + IF NEW.publication_status IS DISTINCT FROM OLD.publication_status AND NOT ( + (OLD.publication_status = 'DRAFT' AND NEW.publication_status = 'ACTIVE') OR + (OLD.publication_status = 'ACTIVE' AND NEW.publication_status = 'ARCHIVED')) THEN + RAISE EXCEPTION 'invalid content node lifecycle transition' USING ERRCODE = '23514'; + 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.is_hidden IS DISTINCT FROM OLD.is_hidden OR NEW.sort_order IS DISTINCT FROM OLD.sort_order OR + NEW.metadata IS DISTINCT FROM OLD.metadata) THEN + RAISE EXCEPTION 'active content node content is immutable' USING ERRCODE = '23514'; + END IF; + RETURN NEW; +END $$; + +CREATE FUNCTION education_prevent_content_node_audit_mutation() +RETURNS TRIGGER LANGUAGE plpgsql SET search_path = pg_catalog, pg_temp AS $$ +BEGIN + RAISE EXCEPTION 'content node lifecycle audit is append-only' USING ERRCODE = '23514'; +END $$; + +CREATE FUNCTION education_require_content_node_lifecycle_audit() +RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, pg_temp AS $$ +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) + 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; +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); +BEGIN + EXECUTE format('SELECT tenant_id, authoring_version, publication_status FROM %I.education_content_node WHERE id=$1', 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'; + END IF; + RETURN NEW; +END $$; + +REVOKE ALL ON FUNCTION education_enforce_content_node_authoring() FROM PUBLIC; +REVOKE ALL ON FUNCTION education_prevent_content_node_audit_mutation() FROM PUBLIC; +REVOKE ALL ON FUNCTION education_require_content_node_lifecycle_audit() FROM PUBLIC; +REVOKE ALL ON FUNCTION education_validate_content_node_lifecycle_audit() FROM PUBLIC; +CREATE TRIGGER trg_education_content_node_authoring BEFORE INSERT OR UPDATE ON education_content_node + FOR EACH ROW EXECUTE FUNCTION education_enforce_content_node_authoring(); +CREATE TRIGGER trg_education_content_node_audit_validate BEFORE INSERT ON education_content_node_lifecycle_audit + FOR EACH ROW EXECUTE FUNCTION education_validate_content_node_lifecycle_audit(); +CREATE TRIGGER trg_education_content_node_audit_immutable BEFORE UPDATE OR DELETE ON education_content_node_lifecycle_audit + FOR EACH ROW EXECUTE FUNCTION education_prevent_content_node_audit_mutation(); +CREATE CONSTRAINT TRIGGER trg_education_content_node_lifecycle_audit_required + AFTER UPDATE OF publication_status ON education_content_node DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION education_require_content_node_lifecycle_audit(); + +-- Student discovery and placement must only use ACTIVE nodes. Existing Mapper queries +-- already require is_active=true; the consistency constraint makes that equivalent. diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/contentnode/ContentNodeAuthoringControllerContractTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/contentnode/ContentNodeAuthoringControllerContractTest.java new file mode 100644 index 00000000..1d585d59 --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/contentnode/ContentNodeAuthoringControllerContractTest.java @@ -0,0 +1,95 @@ +package cn.iocoder.yudao.module.education.controller.admin.contentnode; + +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.service.SecurityFrameworkService; +import cn.iocoder.yudao.module.education.controller.admin.contentnode.vo.ContentNodeDraftReqVO; +import cn.iocoder.yudao.module.education.controller.admin.contentnode.vo.ContentNodeReviseReqVO; +import cn.iocoder.yudao.module.education.service.contentnode.authoring.ContentNodeAuthoringCommand; +import cn.iocoder.yudao.module.education.service.contentnode.authoring.ContentNodeAuthoringService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = ContentNodeAuthoringControllerContractTest.Config.class) +class ContentNodeAuthoringControllerContractTest { + @Autowired ContentNodeAuthoringController controller; + @Autowired RecordingService service; + @Autowired MutableSecurity security; + + @BeforeEach void setUp() { + service.reset(); security.permissions.clear(); + LoginUser user = new LoginUser(); user.setId(7L); user.setTenantId(10L); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null, List.of())); + } + @AfterEach void clear() { SecurityContextHolder.clearContext(); } + + @Test void permissionsAreIndependent() { + security.permissions.add("education:content-node:author"); + assertEquals(101L, controller.create(request()).getData()); + assertThrows(AccessDeniedException.class, () -> controller.activate(101L, 0)); + assertThrows(AccessDeniedException.class, () -> controller.archive(101L, 0)); + security.permissions.clear(); security.permissions.add("education:content-node:publish"); + assertThrows(AccessDeniedException.class, () -> controller.create(request())); + controller.activate(101L, 0); + security.permissions.clear(); security.permissions.add("education:content-node:archive"); + controller.archive(101L, 1); + } + + @Test void authorCanReviseWithCas() { + security.permissions.add("education:content-node:author"); + ContentNodeReviseReqVO request = new ContentNodeReviseReqVO(); + request.setExpectedAuthoringVersion(2); request.setName("Revised"); request.setNodeType("category"); + request.setEntryId(100L); request.setSelectable(true); request.setHidden(false); request.setLeaf(true); + assertEquals(3, controller.revise(101L, request).getData()); + assertEquals(2, service.command.expectedAuthoringVersion()); + } + + private ContentNodeDraftReqVO request() { + ContentNodeDraftReqVO request = new ContentNodeDraftReqVO(); + request.setEntryId(100L); request.setName("Algebra"); request.setNodeType("category"); + request.setSelectable(true); request.setHidden(false); request.setLeaf(true); + return request; + } + + @Configuration(proxyBeanMethods = false) @EnableMethodSecurity + static class Config { + @Bean("ss") MutableSecurity security() { return new MutableSecurity(); } + @Bean RecordingService service() { return new RecordingService(); } + @Bean ContentNodeAuthoringController controller(ContentNodeAuthoringService service) { + return new ContentNodeAuthoringController(service); + } + } + static class MutableSecurity implements SecurityFrameworkService { + final Set permissions = new HashSet<>(); + public boolean hasPermission(String p) { return permissions.contains(p); } + public boolean hasAnyPermissions(String... p) { return java.util.Arrays.stream(p).anyMatch(this::hasPermission); } + public boolean hasRole(String r) { return false; } public boolean hasAnyRoles(String... r) { return false; } + public boolean hasScope(String s) { return false; } public boolean hasAnyScopes(String... s) { return false; } + } + static class RecordingService implements ContentNodeAuthoringService { + ContentNodeAuthoringCommand command; + void reset() { command = null; } + public Long createDraft(ContentNodeAuthoringCommand c) { command = c; return 101L; } + public int reviseDraft(Long id, ContentNodeAuthoringCommand c) { command = c; return c.expectedAuthoringVersion() + 1; } + public int activate(Long id, int expected, Long actor) { return expected + 1; } + public int archive(Long id, int expected, Long actor) { return expected + 1; } + } +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/contentnode/authoring/ContentNodeAuthoringPostgreSqlIntegrationTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/contentnode/authoring/ContentNodeAuthoringPostgreSqlIntegrationTest.java new file mode 100644 index 00000000..1aadfe87 --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/contentnode/authoring/ContentNodeAuthoringPostgreSqlIntegrationTest.java @@ -0,0 +1,79 @@ +package cn.iocoder.yudao.module.education.service.contentnode.authoring; + +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; +import cn.iocoder.yudao.module.education.config.EducationProperties; +import cn.iocoder.yudao.module.education.dal.dataobject.catalog.ContentEntryDO; +import cn.iocoder.yudao.module.education.dal.dataobject.catalog.ContentNodeDO; +import cn.iocoder.yudao.module.education.dal.mysql.catalog.ContentEntryMapper; +import cn.iocoder.yudao.module.education.dal.mysql.catalog.ContentNodeLifecycleAuditMapper; +import cn.iocoder.yudao.module.education.dal.mysql.catalog.ContentNodeMapper; +import cn.iocoder.yudao.module.education.enums.CatalogProviderMode; +import cn.iocoder.yudao.module.education.test.PostgreSqlDbIntegrationTest; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.TestPropertySource; + +import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; + +@Import({ContentNodeAuthoringServiceImpl.class, EducationProperties.class}) +@TestPropertySource(properties = {"yudao.education.enabled=true", "yudao.education.catalog-mode=JAVA_READ"}) +class ContentNodeAuthoringPostgreSqlIntegrationTest extends PostgreSqlDbIntegrationTest { + @Resource ContentNodeAuthoringService service; + @Resource ContentNodeMapper nodeMapper; + @Resource ContentEntryMapper entryMapper; + @Resource ContentNodeLifecycleAuditMapper auditMapper; + + @BeforeEach void setUp() { + TenantContextHolder.setTenantId(10L); + ContentEntryDO entry = new ContentEntryDO(); entry.setId(100L); entry.setTenantId(10L); + entry.setScope("TENANT_OWNED"); entry.setEntryKey("questions"); entry.setName("Questions"); + entry.setEntryType("question"); entryMapper.insert(entry); + } + @AfterEach void clear() { TenantContextHolder.clear(); } + + @Test void draftActivateArchiveControlsStudentDiscoveryAndPlacement() { + Long id = service.createDraft(command(null, null, "Algebra", null)); + assertEquals(0, nodeMapper.selectAllByEntryId(10L, 100L, false, null).size()); + assertNull(nodeMapper.selectAvailablePlacementTarget(10L, id)); + + assertEquals(1, service.reviseDraft(id, command(null, null, "Algebra revised", 0))); + assertEquals(2, service.activate(id, 1, 7L)); + assertEquals(1, nodeMapper.selectAllByEntryId(10L, 100L, false, null).size()); + assertNotNull(nodeMapper.selectAvailablePlacementTarget(10L, id)); + + assertEquals(3, service.archive(id, 2, 7L)); + assertEquals(0, nodeMapper.selectAllByEntryId(10L, 100L, false, null).size()); + assertNull(nodeMapper.selectAvailablePlacementTarget(10L, id)); + assertEquals(2L, auditMapper.selectCount()); + } + + @Test void structuralValidationAndCasFailClosed() { + ServiceException badEntry = assertThrows(ServiceException.class, + () -> service.createDraft(command(999L, null, "Bad", null))); + assertEquals(CONTENT_NODE_STRUCTURE_INVALID.getCode(), badEntry.getCode()); + + Long parent = service.createDraft(command(null, null, "Parent", null)); + service.activate(parent, 0, 7L); + Long child = service.createDraft(command(null, parent, "Child", null)); + ServiceException stale = assertThrows(ServiceException.class, + () -> service.reviseDraft(child, command(null, parent, "Stale", 1))); + assertEquals(CONTENT_NODE_AUTHORING_CONFLICT.getCode(), stale.getCode()); + } + + @Test void currentTenantAndPublicWritesAreRejected() { + Long id = service.createDraft(command(null, null, "Tenant node", null)); + TenantContextHolder.setTenantId(20L); + ServiceException otherTenant = assertThrows(ServiceException.class, () -> service.activate(id, 0, 8L)); + assertEquals(CONTENT_NODE_AUTHORING_NOT_FOUND.getCode(), otherTenant.getCode()); + } + + private ContentNodeAuthoringCommand command(Long entryId, Long parentId, String name, Integer version) { + return new ContentNodeAuthoringCommand(entryId != null ? entryId : 100L, parentId, name, null, + "category", null, true, true, false, 0, null, version); + } +} 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 a89a9595..4420a5e0 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 @@ -465,6 +465,40 @@ class EducationFlywayMigrationIntegrationTest { .hasMessageContaining("published or archived question placement is immutable"); } + @Test + void shouldCreateContentNodeLifecycleAndRequireTransactionalAudit() throws SQLException { + String schema = createSchema("content_node_lifecycle"); + configureFlyway(schema, false).load().migrate(); + + assertThat(queryLong(schema, """ + SELECT count(*) + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'education_content_node' + AND column_name IN ('publication_status', 'authoring_version') + AND is_nullable = 'NO' + """)).isEqualTo(2L); + + 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) + VALUES (200, 10, 'TENANT_OWNED', 100, 'Draft', 'category', 'DRAFT', false); + """); + assertThatThrownBy(() -> execute(schema, """ + UPDATE education_content_node + SET publication_status = 'ACTIVE', is_active = true, authoring_version = 1 + WHERE id = 200; + """)).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) + VALUES (0, 'PUBLIC', 100, 'Public draft', 'category', 'DRAFT', false); + """)).hasMessageContaining("PUBLIC content node writes are not managed by tenant authoring"); + } + @Test void shouldNormalizeHistoricalQuestionVisibilityIntoLifecycleStates() throws SQLException { String schema = createSchema("question_history"); diff --git a/yudao-module-education/src/test/resources/sql/postgresql/clean.sql b/yudao-module-education/src/test/resources/sql/postgresql/clean.sql index fae019e1..17372559 100644 --- a/yudao-module-education/src/test/resources/sql/postgresql/clean.sql +++ b/yudao-module-education/src/test/resources/sql/postgresql/clean.sql @@ -1,6 +1,7 @@ -- PostgreSQL persistence test cleanup. -- Module-owned Flyway creates the disposable schema; this only isolates test data. TRUNCATE TABLE + education_content_node_lifecycle_audit, education_question_lifecycle_transition_token, education_question_lifecycle_audit, education_question_version,