forked from wangziqi/ruoyi-vue-pro
feat(education): add tenant content node lifecycle
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/` 是过时归档;两者都不是运行时交付入口。
|
||||
|
||||
@@ -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<Long> create(@Valid @RequestBody ContentNodeDraftReqVO request) {
|
||||
return success(service.createDraft(toCommand(request, null)));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/draft")
|
||||
@PreAuthorize("@ss.hasPermission('education:content-node:author')")
|
||||
public CommonResult<Integer> 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<Integer> 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<Integer> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<ContentNodeLifecycleAuditDO> {
|
||||
}
|
||||
@@ -26,6 +26,48 @@ public interface ContentNodeMapper extends BaseMapperX<ContentNodeDO> {
|
||||
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<ContentNodeDO>().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<ContentNodeDO>()
|
||||
.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<ContentNodeDO>()
|
||||
.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<ContentNodeDO> selectChildren(Long tenantId, Long entryId, Long parentId, boolean includeInactive,
|
||||
String markerType) {
|
||||
LambdaQueryWrapperX<ContentNodeDO> w = base(tenantId, entryId, includeInactive, markerType);
|
||||
|
||||
@@ -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, "内容节点入口或父节点无效");
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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<String> 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; }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user