forked from wangziqi/ruoyi-vue-pro
feat(education): add practice blueprint lifecycle
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
package cn.iocoder.yudao.module.education.controller.admin.blueprint;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.module.education.controller.admin.blueprint.vo.*;
|
||||
import cn.iocoder.yudao.module.education.service.blueprint.authoring.*;
|
||||
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/practice-blueprints")
|
||||
@Validated
|
||||
@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true")
|
||||
public class PracticeBlueprintAuthoringController {
|
||||
private final PracticeBlueprintAuthoringService service;
|
||||
public PracticeBlueprintAuthoringController(PracticeBlueprintAuthoringService service) { this.service = service; }
|
||||
|
||||
@PostMapping("/drafts") @PreAuthorize("@ss.hasPermission('education:practice-blueprint:author')")
|
||||
public CommonResult<Long> create(@Valid @RequestBody PracticeBlueprintDraftReqVO request) {
|
||||
return success(service.createDraft(toCommand(request, null)));
|
||||
}
|
||||
@PutMapping("/{id}/draft") @PreAuthorize("@ss.hasPermission('education:practice-blueprint:author')")
|
||||
public CommonResult<Integer> revise(@PathVariable Long id, @Valid @RequestBody PracticeBlueprintReviseReqVO request) {
|
||||
return success(service.reviseDraft(id, toCommand(request, request.getExpectedAuthoringVersion())));
|
||||
}
|
||||
@PutMapping("/{id}/activate") @PreAuthorize("@ss.hasPermission('education:practice-blueprint:publish')")
|
||||
public CommonResult<Integer> activate(@PathVariable Long id, @RequestParam int expectedAuthoringVersion) {
|
||||
return success(service.activate(id, expectedAuthoringVersion, getLoginUserId()));
|
||||
}
|
||||
@PutMapping("/{id}/archive") @PreAuthorize("@ss.hasPermission('education:practice-blueprint:archive')")
|
||||
public CommonResult<Integer> archive(@PathVariable Long id, @RequestParam int expectedAuthoringVersion) {
|
||||
return success(service.archive(id, expectedAuthoringVersion, getLoginUserId()));
|
||||
}
|
||||
private PracticeBlueprintAuthoringCommand toCommand(PracticeBlueprintDraftReqVO r, Integer version) {
|
||||
return new PracticeBlueprintAuthoringCommand(r.getMode(), r.getNodeId(), r.getCollectionId(),
|
||||
r.getQuestionLimit(), r.getDurationMinutes(), r.getAvailableTypes(), r.getMinQuestions(),
|
||||
r.getMaxQuestions(), r.getSuggestedCount(), version);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package cn.iocoder.yudao.module.education.controller.admin.blueprint.vo;
|
||||
|
||||
import jakarta.validation.constraints.*;
|
||||
import lombok.Data;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class PracticeBlueprintDraftReqVO {
|
||||
@NotBlank @Pattern(regexp = "NODE|COLLECTION") private String mode;
|
||||
private Long nodeId;
|
||||
private Long collectionId;
|
||||
@Positive private Integer questionLimit;
|
||||
@Positive private Integer durationMinutes;
|
||||
private List<@NotBlank @Size(max = 32) String> availableTypes;
|
||||
@NotNull @Positive private Integer minQuestions;
|
||||
@NotNull @Positive private Integer maxQuestions;
|
||||
@NotNull @Positive private Integer suggestedCount;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package cn.iocoder.yudao.module.education.controller.admin.blueprint.vo;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class PracticeBlueprintReviseReqVO extends PracticeBlueprintDraftReqVO {
|
||||
@NotNull private Integer expectedAuthoringVersion;
|
||||
}
|
||||
@@ -32,4 +32,6 @@ public class PracticeBlueprintDO extends CatalogScopeDO {
|
||||
private Integer maxQuestions;
|
||||
private Integer suggestedCount;
|
||||
private Boolean isActive;
|
||||
private String publicationStatus;
|
||||
private Integer authoringVersion;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package cn.iocoder.yudao.module.education.dal.dataobject.catalog;
|
||||
|
||||
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@TableName("education_practice_blueprint_lifecycle_audit")
|
||||
@KeySequence("education_practice_blueprint_lifecycle_audit_seq")
|
||||
@Data @EqualsAndHashCode(callSuper = true)
|
||||
public class PracticeBlueprintLifecycleAuditDO extends TenantBaseDO {
|
||||
@TableId private Long id;
|
||||
private Long blueprintId;
|
||||
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.PracticeBlueprintLifecycleAuditDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface PracticeBlueprintLifecycleAuditMapper extends BaseMapperX<PracticeBlueprintLifecycleAuditDO> {
|
||||
}
|
||||
@@ -3,19 +3,70 @@ package cn.iocoder.yudao.module.education.dal.mysql.catalog;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.PracticeBlueprintDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
|
||||
@Mapper
|
||||
public interface PracticeBlueprintMapper extends BaseMapperX<PracticeBlueprintDO> {
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT blueprint.* FROM education_practice_blueprint blueprint
|
||||
JOIN education_content_node node ON node.id = blueprint.node_id
|
||||
JOIN education_content_entry entry ON entry.id = blueprint.entry_id
|
||||
LEFT JOIN education_question_collection collection ON collection.id = blueprint.collection_id
|
||||
WHERE blueprint.deleted = false AND blueprint.publication_status = 'ACTIVE' AND blueprint.is_active = true
|
||||
AND ((blueprint.tenant_id = #{tenantId} AND blueprint.scope = 'TENANT_OWNED') OR (blueprint.tenant_id = 0 AND blueprint.scope = 'PUBLIC'))
|
||||
AND node.deleted = false AND node.publication_status = 'ACTIVE' AND node.is_active = true AND node.is_hidden = false
|
||||
AND ((node.tenant_id = #{tenantId} AND node.scope = 'TENANT_OWNED') OR (node.tenant_id = 0 AND node.scope = 'PUBLIC'))
|
||||
AND entry.deleted = false AND entry.is_active = true AND entry.is_hidden = false
|
||||
AND ((entry.tenant_id = #{tenantId} AND entry.scope = 'TENANT_OWNED') OR (entry.tenant_id = 0 AND entry.scope = 'PUBLIC'))
|
||||
<choose>
|
||||
<when test="collectionId != null">
|
||||
AND blueprint.mode = 'COLLECTION' AND blueprint.collection_id = #{collectionId}
|
||||
AND collection.deleted = false AND collection.publication_status = 'ACTIVE'
|
||||
AND collection.is_active = true AND collection.is_hidden = false
|
||||
</when>
|
||||
<otherwise>
|
||||
AND blueprint.mode = 'NODE' AND blueprint.collection_id IS NULL AND blueprint.node_id = #{nodeId}
|
||||
</otherwise>
|
||||
</choose>
|
||||
ORDER BY blueprint.id LIMIT 1
|
||||
</script>
|
||||
""")
|
||||
PracticeBlueprintDO selectAvailableForStudent(@Param("tenantId") Long tenantId,
|
||||
@Param("collectionId") Long collectionId, @Param("nodeId") Long nodeId);
|
||||
|
||||
default PracticeBlueprintDO selectByCollectionOrNode(Long tenantId, Long collectionId, Long nodeId,
|
||||
String mode, String type, String difficulty) {
|
||||
if (collectionId == null && nodeId == null) return null;
|
||||
LambdaQueryWrapperX<PracticeBlueprintDO> w = new LambdaQueryWrapperX<>();
|
||||
CatalogScopeQuery.apply(w, PracticeBlueprintDO::getTenantId, PracticeBlueprintDO::getScope, tenantId);
|
||||
w.eq(PracticeBlueprintDO::getIsActive, true).and(x -> {
|
||||
if (collectionId != null) x.eq(PracticeBlueprintDO::getCollectionId, collectionId);
|
||||
if (nodeId != null) x.or().eq(PracticeBlueprintDO::getNodeId, nodeId);
|
||||
}).orderByAsc(PracticeBlueprintDO::getId).last("LIMIT 1");
|
||||
return selectOne(w);
|
||||
return selectAvailableForStudent(tenantId, collectionId, nodeId);
|
||||
}
|
||||
default PracticeBlueprintDO selectTenantOwnedById(Long tenantId, Long id) {
|
||||
return selectOne(new LambdaQueryWrapperX<PracticeBlueprintDO>().eq(PracticeBlueprintDO::getId, id)
|
||||
.eq(PracticeBlueprintDO::getTenantId, tenantId).eq(PracticeBlueprintDO::getScope, "TENANT_OWNED")
|
||||
.eq(PracticeBlueprintDO::getDeleted, false));
|
||||
}
|
||||
default int updateDraftCas(Long tenantId, Long id, PracticeBlueprintDO values, int expectedVersion) {
|
||||
return update(null, new LambdaUpdateWrapper<PracticeBlueprintDO>().eq(PracticeBlueprintDO::getId, id)
|
||||
.eq(PracticeBlueprintDO::getTenantId, tenantId).eq(PracticeBlueprintDO::getScope, "TENANT_OWNED")
|
||||
.eq(PracticeBlueprintDO::getDeleted, false).eq(PracticeBlueprintDO::getPublicationStatus, "DRAFT")
|
||||
.eq(PracticeBlueprintDO::getAuthoringVersion, expectedVersion)
|
||||
.set(PracticeBlueprintDO::getMode, values.getMode()).set(PracticeBlueprintDO::getEntryId, values.getEntryId())
|
||||
.set(PracticeBlueprintDO::getNodeId, values.getNodeId()).set(PracticeBlueprintDO::getCollectionId, values.getCollectionId())
|
||||
.set(PracticeBlueprintDO::getQuestionLimit, values.getQuestionLimit()).set(PracticeBlueprintDO::getDurationMinutes, values.getDurationMinutes())
|
||||
.set(PracticeBlueprintDO::getEligibleCount, values.getEligibleCount()).set(PracticeBlueprintDO::getTotalCount, values.getTotalCount())
|
||||
.set(PracticeBlueprintDO::getAvailableTypes, values.getAvailableTypes()).set(PracticeBlueprintDO::getAvailableDifficulties, values.getAvailableDifficulties())
|
||||
.set(PracticeBlueprintDO::getMinQuestions, values.getMinQuestions()).set(PracticeBlueprintDO::getMaxQuestions, values.getMaxQuestions())
|
||||
.set(PracticeBlueprintDO::getSuggestedCount, values.getSuggestedCount()).set(PracticeBlueprintDO::getAuthoringVersion, expectedVersion + 1));
|
||||
}
|
||||
default int updateLifecycleCas(Long tenantId, Long id, String from, String to, boolean active,
|
||||
int expectedVersion, int eligibleCount) {
|
||||
return update(null, new LambdaUpdateWrapper<PracticeBlueprintDO>().eq(PracticeBlueprintDO::getId, id)
|
||||
.eq(PracticeBlueprintDO::getTenantId, tenantId).eq(PracticeBlueprintDO::getScope, "TENANT_OWNED")
|
||||
.eq(PracticeBlueprintDO::getDeleted, false).eq(PracticeBlueprintDO::getPublicationStatus, from)
|
||||
.eq(PracticeBlueprintDO::getAuthoringVersion, expectedVersion)
|
||||
.set(PracticeBlueprintDO::getEligibleCount, eligibleCount).set(PracticeBlueprintDO::getTotalCount, eligibleCount)
|
||||
.set(PracticeBlueprintDO::getPublicationStatus, to).set(PracticeBlueprintDO::getIsActive, active)
|
||||
.set(PracticeBlueprintDO::getAuthoringVersion, expectedVersion + 1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,23 @@ public interface QuestionCollectionMapper extends BaseMapperX<QuestionCollection
|
||||
List<QuestionCollectionDO> selectAvailableList(@Param("tenantId") Long tenantId, @Param("entryId") Long entryId,
|
||||
@Param("nodeId") Long nodeId, @Param("collectionType") String collectionType, @Param("limit") Integer limit);
|
||||
|
||||
@Select("""
|
||||
SELECT collection.* FROM education_question_collection collection
|
||||
JOIN education_content_node node ON node.id = collection.node_id
|
||||
JOIN education_content_entry entry ON entry.id = node.entry_id
|
||||
WHERE collection.id = #{collectionId} AND collection.tenant_id = #{tenantId}
|
||||
AND collection.scope = 'TENANT_OWNED' AND collection.deleted = false
|
||||
AND collection.collection_type = 'MANUAL' AND collection.publication_status = 'ACTIVE'
|
||||
AND collection.is_active = true AND collection.is_hidden = false
|
||||
AND node.tenant_id = #{tenantId} AND node.scope = 'TENANT_OWNED'
|
||||
AND node.deleted = false AND node.publication_status = 'ACTIVE'
|
||||
AND node.is_active = true AND node.is_hidden = false
|
||||
AND entry.deleted = false AND entry.is_active = true AND entry.is_hidden = false
|
||||
AND ((entry.tenant_id = #{tenantId} AND entry.scope = 'TENANT_OWNED') OR (entry.tenant_id = 0 AND entry.scope = 'PUBLIC'))
|
||||
""")
|
||||
QuestionCollectionDO selectTenantOwnedActiveForAuthoring(@Param("tenantId") Long tenantId,
|
||||
@Param("collectionId") Long collectionId);
|
||||
|
||||
default List<QuestionCollectionDO> selectActiveList(Long tenantId, Long entryId, Long nodeId, String collectionType, Integer limit) {
|
||||
return selectAvailableList(tenantId, entryId, nodeId, collectionType, limit != null && limit > 0 ? Math.min(limit, 200) : 100);
|
||||
}
|
||||
|
||||
@@ -138,6 +138,13 @@ public interface QuestionMapper extends BaseMapperX<QuestionDO> {
|
||||
@Param("nodeId") Long nodeId, @Param("type") String type,
|
||||
@Param("difficulty") String difficulty);
|
||||
|
||||
@Select("""
|
||||
SELECT count(*) FROM education_question
|
||||
WHERE deleted = false AND tenant_id = #{tenantId} AND scope = 'TENANT_OWNED'
|
||||
AND status = 'PUBLISHED' AND is_published = true AND node_id = #{nodeId}
|
||||
""")
|
||||
long countTenantOwnedPublishedByNode(@Param("tenantId") Long tenantId, @Param("nodeId") Long nodeId);
|
||||
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT count(*) FROM education_question
|
||||
|
||||
@@ -133,4 +133,12 @@ public interface ErrorCodeConstants {
|
||||
ErrorCode ENTITLEMENT_RESOURCE_NOT_FOUND = new ErrorCode(1_005_004_003, "权益资源不存在或不可发放");
|
||||
ErrorCode PRODUCT_BINDING_CONFLICT = new ErrorCode(1_005_004_004, "资源商品绑定冲突");
|
||||
ErrorCode PRODUCT_NOT_AVAILABLE = new ErrorCode(1_005_004_005, "Mall 商品不存在或不可用");
|
||||
// ========== 练习蓝图创作 1-005-002-050 ~ 1-005-002-059 ==========
|
||||
ErrorCode PRACTICE_BLUEPRINT_PROVIDER_UNSUPPORTED = new ErrorCode(1_005_002_050,
|
||||
"当前题库数据源模式不支持练习蓝图创作:{}");
|
||||
ErrorCode PRACTICE_BLUEPRINT_AUTHORING_NOT_FOUND = new ErrorCode(1_005_002_051, "练习蓝图不存在或无权管理");
|
||||
ErrorCode PRACTICE_BLUEPRINT_AUTHORING_CONFLICT = new ErrorCode(1_005_002_052, "练习蓝图已变化,请刷新后重试");
|
||||
ErrorCode PRACTICE_BLUEPRINT_TARGET_UNAVAILABLE = new ErrorCode(1_005_002_053, "练习蓝图目标不存在或不可用");
|
||||
ErrorCode PRACTICE_BLUEPRINT_CONFIG_INVALID = new ErrorCode(1_005_002_054, "练习蓝图配置无效");
|
||||
ErrorCode PRACTICE_BLUEPRINT_ELIGIBLE_INSUFFICIENT = new ErrorCode(1_005_002_055, "练习蓝图可用题目不足");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package cn.iocoder.yudao.module.education.service.blueprint.authoring;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record PracticeBlueprintAuthoringCommand(String mode, Long nodeId, Long collectionId,
|
||||
Integer questionLimit, Integer durationMinutes, List<String> availableTypes,
|
||||
Integer minQuestions, Integer maxQuestions, Integer suggestedCount,
|
||||
Integer expectedAuthoringVersion) {
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package cn.iocoder.yudao.module.education.service.blueprint.authoring;
|
||||
|
||||
public interface PracticeBlueprintAuthoringService {
|
||||
Long createDraft(PracticeBlueprintAuthoringCommand command);
|
||||
int reviseDraft(Long id, PracticeBlueprintAuthoringCommand command);
|
||||
int activate(Long id, int expectedVersion, Long actorId);
|
||||
int archive(Long id, int expectedVersion, Long actorId);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package cn.iocoder.yudao.module.education.service.blueprint.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.*;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.catalog.*;
|
||||
import cn.iocoder.yudao.module.education.enums.CatalogProviderMode;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
|
||||
|
||||
@Service
|
||||
public class PracticeBlueprintAuthoringServiceImpl implements PracticeBlueprintAuthoringService {
|
||||
private final EducationProperties properties;
|
||||
private final PracticeBlueprintMapper blueprintMapper;
|
||||
private final PracticeBlueprintLifecycleAuditMapper auditMapper;
|
||||
private final ContentNodeMapper nodeMapper;
|
||||
private final QuestionCollectionMapper collectionMapper;
|
||||
private final QuestionMapper questionMapper;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
public PracticeBlueprintAuthoringServiceImpl(EducationProperties properties, PracticeBlueprintMapper blueprintMapper,
|
||||
PracticeBlueprintLifecycleAuditMapper auditMapper, ContentNodeMapper nodeMapper,
|
||||
QuestionCollectionMapper collectionMapper, QuestionMapper questionMapper) {
|
||||
this.properties = properties; this.blueprintMapper = blueprintMapper; this.auditMapper = auditMapper;
|
||||
this.nodeMapper = nodeMapper; this.collectionMapper = collectionMapper; this.questionMapper = questionMapper;
|
||||
}
|
||||
|
||||
@Override @Transactional(rollbackFor = Exception.class)
|
||||
public Long createDraft(PracticeBlueprintAuthoringCommand command) {
|
||||
assertMode(); Long tenantId = TenantContextHolder.getRequiredTenantId(); Target target = validate(tenantId, command);
|
||||
PracticeBlueprintDO row = new PracticeBlueprintDO(); apply(row, command, target);
|
||||
row.setTenantId(tenantId); row.setScope("TENANT_OWNED"); row.setPublicationStatus("DRAFT");
|
||||
row.setAuthoringVersion(0); row.setIsActive(false); blueprintMapper.insert(row); return row.getId();
|
||||
}
|
||||
|
||||
@Override @Transactional(rollbackFor = Exception.class)
|
||||
public int reviseDraft(Long id, PracticeBlueprintAuthoringCommand command) {
|
||||
assertMode(); Long tenantId = TenantContextHolder.getRequiredTenantId(); PracticeBlueprintDO current = requireOwned(tenantId, id);
|
||||
if (!"DRAFT".equals(current.getPublicationStatus()) || !Objects.equals(current.getAuthoringVersion(), command.expectedAuthoringVersion())) {
|
||||
throw exception(PRACTICE_BLUEPRINT_AUTHORING_CONFLICT);
|
||||
}
|
||||
Target target = validate(tenantId, command); PracticeBlueprintDO row = new PracticeBlueprintDO(); apply(row, command, target);
|
||||
if (blueprintMapper.updateDraftCas(tenantId, id, row, command.expectedAuthoringVersion()) != 1) {
|
||||
throw exception(PRACTICE_BLUEPRINT_AUTHORING_CONFLICT);
|
||||
}
|
||||
return command.expectedAuthoringVersion() + 1;
|
||||
}
|
||||
|
||||
@Override @Transactional(rollbackFor = Exception.class)
|
||||
public int activate(Long id, int expectedVersion, Long actorId) { return transition(id, expectedVersion, actorId, "DRAFT", "ACTIVE", true); }
|
||||
@Override @Transactional(rollbackFor = Exception.class)
|
||||
public int archive(Long id, int expectedVersion, Long actorId) { return transition(id, expectedVersion, actorId, "ACTIVE", "ARCHIVED", false); }
|
||||
|
||||
private int transition(Long id, int expectedVersion, Long actorId, String from, String to, boolean active) {
|
||||
assertMode(); Long tenantId = TenantContextHolder.getRequiredTenantId(); PracticeBlueprintDO current = requireOwned(tenantId, id);
|
||||
if (!from.equals(current.getPublicationStatus()) || !Objects.equals(current.getAuthoringVersion(), expectedVersion)) {
|
||||
throw exception(PRACTICE_BLUEPRINT_AUTHORING_CONFLICT);
|
||||
}
|
||||
int eligible = current.getEligibleCount() != null ? current.getEligibleCount() : 0;
|
||||
if (active) {
|
||||
Target target = target(tenantId, current.getMode(), current.getNodeId(), current.getCollectionId());
|
||||
eligible = target.eligibleCount();
|
||||
validateBounds(current.getMinQuestions(), current.getMaxQuestions(), current.getSuggestedCount(), current.getQuestionLimit());
|
||||
validateEligible(current.getMinQuestions(), current.getQuestionLimit(), eligible);
|
||||
}
|
||||
if (blueprintMapper.updateLifecycleCas(tenantId, id, from, to, active, expectedVersion, eligible) != 1) {
|
||||
throw exception(PRACTICE_BLUEPRINT_AUTHORING_CONFLICT);
|
||||
}
|
||||
PracticeBlueprintLifecycleAuditDO audit = new PracticeBlueprintLifecycleAuditDO(); audit.setTenantId(tenantId);
|
||||
audit.setBlueprintId(id); audit.setAuthoringVersion(expectedVersion + 1); audit.setActorId(actorId);
|
||||
audit.setFromStatus(from); audit.setToStatus(to); audit.setOccurredAt(LocalDateTime.now()); auditMapper.insert(audit);
|
||||
return expectedVersion + 1;
|
||||
}
|
||||
|
||||
private Target validate(Long tenantId, PracticeBlueprintAuthoringCommand command) {
|
||||
if (command == null || command.mode() == null || command.minQuestions() == null || command.maxQuestions() == null
|
||||
|| command.suggestedCount() == null || !("NODE".equals(command.mode()) ^ "COLLECTION".equals(command.mode()))
|
||||
|| ("NODE".equals(command.mode()) && (command.nodeId() == null || command.collectionId() != null))
|
||||
|| ("COLLECTION".equals(command.mode()) && (command.collectionId() == null || command.nodeId() != null))
|
||||
|| (command.durationMinutes() != null && command.durationMinutes() <= 0)) {
|
||||
throw exception(PRACTICE_BLUEPRINT_CONFIG_INVALID);
|
||||
}
|
||||
validateBounds(command.minQuestions(), command.maxQuestions(), command.suggestedCount(), command.questionLimit());
|
||||
Target target = target(tenantId, command.mode(), command.nodeId(), command.collectionId());
|
||||
validateEligible(command.minQuestions(), command.questionLimit(), target.eligibleCount());
|
||||
return target;
|
||||
}
|
||||
|
||||
private Target target(Long tenantId, String mode, Long nodeId, Long collectionId) {
|
||||
if ("NODE".equals(mode)) {
|
||||
ContentNodeDO node = TenantUtils.executeIgnore(() -> nodeMapper.selectAvailableCollectionTarget(tenantId, nodeId));
|
||||
if (node == null || !Objects.equals(node.getTenantId(), tenantId) || !"TENANT_OWNED".equals(node.getScope())) {
|
||||
throw exception(PRACTICE_BLUEPRINT_TARGET_UNAVAILABLE);
|
||||
}
|
||||
long count = TenantUtils.executeIgnore(() -> questionMapper.countTenantOwnedPublishedByNode(tenantId, nodeId));
|
||||
return new Target(node.getEntryId(), nodeId, null, bounded(count));
|
||||
}
|
||||
QuestionCollectionDO collection = TenantUtils.executeIgnore(() -> collectionMapper.selectTenantOwnedActiveForAuthoring(tenantId, collectionId));
|
||||
if (collection == null) throw exception(PRACTICE_BLUEPRINT_TARGET_UNAVAILABLE);
|
||||
return new Target(collection.getEntryId(), collection.getNodeId(), collectionId,
|
||||
collection.getQuestionCount() != null ? collection.getQuestionCount() : 0);
|
||||
}
|
||||
|
||||
private void validateBounds(int min, int max, int suggested, Integer questionLimit) {
|
||||
if (min < 1 || max < min || max > 1000 || suggested < min || suggested > max
|
||||
|| (questionLimit != null && (questionLimit < min || questionLimit > max))) {
|
||||
throw exception(PRACTICE_BLUEPRINT_CONFIG_INVALID);
|
||||
}
|
||||
}
|
||||
private void validateEligible(int min, Integer questionLimit, int eligible) {
|
||||
int required = questionLimit != null ? questionLimit : min;
|
||||
if (eligible < required) throw exception(PRACTICE_BLUEPRINT_ELIGIBLE_INSUFFICIENT);
|
||||
}
|
||||
|
||||
private PracticeBlueprintDO requireOwned(Long tenantId, Long id) {
|
||||
PracticeBlueprintDO row = blueprintMapper.selectTenantOwnedById(tenantId, id);
|
||||
if (row == null) throw exception(PRACTICE_BLUEPRINT_AUTHORING_NOT_FOUND); return row;
|
||||
}
|
||||
private void apply(PracticeBlueprintDO row, PracticeBlueprintAuthoringCommand command, Target target) {
|
||||
row.setMode(command.mode()); row.setEntryId(target.entryId()); row.setNodeId(target.nodeId()); row.setCollectionId(target.collectionId());
|
||||
row.setQuestionLimit(command.questionLimit()); row.setDurationMinutes(command.durationMinutes());
|
||||
row.setEligibleCount(target.eligibleCount()); row.setTotalCount(target.eligibleCount()); row.setAvailableTypes(json(command.availableTypes()));
|
||||
row.setAvailableDifficulties(null); row.setMinQuestions(command.minQuestions()); row.setMaxQuestions(command.maxQuestions());
|
||||
row.setSuggestedCount(command.suggestedCount());
|
||||
}
|
||||
private String json(List<String> values) {
|
||||
if (values == null) return null;
|
||||
List<String> normalized = values.stream().map(String::trim).filter(v -> !v.isEmpty()).distinct().toList();
|
||||
try { return objectMapper.writeValueAsString(normalized); }
|
||||
catch (JsonProcessingException ex) { throw exception(PRACTICE_BLUEPRINT_CONFIG_INVALID); }
|
||||
}
|
||||
private int bounded(long value) { return Math.toIntExact(Math.min(value, Integer.MAX_VALUE)); }
|
||||
private void assertMode() {
|
||||
if (properties.getCatalogMode() != CatalogProviderMode.JAVA_READ) {
|
||||
throw exception(PRACTICE_BLUEPRINT_PROVIDER_UNSUPPORTED, properties.getCatalogMode());
|
||||
}
|
||||
}
|
||||
private record Target(Long entryId, Long nodeId, Long collectionId, int eligibleCount) {}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
-- EDU-010: bounded tenant Category and Practice Blueprint authoring lifecycles.
|
||||
-- JAVA_READ authority remains an application guard; database rules protect direct writes.
|
||||
|
||||
LOCK TABLE education_category, education_practice_blueprint IN SHARE ROW EXCLUSIVE MODE;
|
||||
|
||||
ALTER TABLE education_category
|
||||
ADD COLUMN publication_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
|
||||
ADD COLUMN authoring_version INTEGER NOT NULL DEFAULT 0;
|
||||
UPDATE education_category SET publication_status = CASE WHEN is_active THEN 'ACTIVE' ELSE 'ARCHIVED' END;
|
||||
ALTER TABLE education_category
|
||||
ADD CONSTRAINT ck_education_category_publication_status CHECK (publication_status IN ('DRAFT','ACTIVE','ARCHIVED')),
|
||||
ADD CONSTRAINT ck_education_category_authoring_version CHECK (authoring_version >= 0),
|
||||
ADD CONSTRAINT ck_education_category_lifecycle_consistent CHECK (
|
||||
(publication_status='ACTIVE' AND is_active) OR (publication_status IN ('DRAFT','ARCHIVED') AND NOT is_active));
|
||||
|
||||
ALTER TABLE education_practice_blueprint
|
||||
ADD COLUMN publication_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
|
||||
ADD COLUMN authoring_version INTEGER NOT NULL DEFAULT 0;
|
||||
UPDATE education_practice_blueprint SET publication_status = CASE WHEN is_active THEN 'ACTIVE' ELSE 'ARCHIVED' END;
|
||||
ALTER TABLE education_practice_blueprint
|
||||
ADD CONSTRAINT ck_education_practice_blueprint_publication_status CHECK (publication_status IN ('DRAFT','ACTIVE','ARCHIVED')),
|
||||
ADD CONSTRAINT ck_education_practice_blueprint_authoring_version CHECK (authoring_version >= 0),
|
||||
ADD CONSTRAINT ck_education_practice_blueprint_lifecycle_consistent CHECK (
|
||||
(publication_status='ACTIVE' AND is_active) OR (publication_status IN ('DRAFT','ARCHIVED') AND NOT is_active)),
|
||||
ADD CONSTRAINT ck_education_practice_blueprint_mode_target CHECK (
|
||||
(mode='NODE' AND node_id IS NOT NULL AND collection_id IS NULL) OR
|
||||
(mode='COLLECTION' AND node_id IS NOT NULL AND collection_id IS NOT NULL)),
|
||||
ADD CONSTRAINT ck_education_practice_blueprint_nonnegative_counts CHECK (
|
||||
eligible_count >= 0 AND total_count >= 0 AND eligible_count = total_count),
|
||||
ADD CONSTRAINT ck_education_practice_blueprint_question_limit CHECK (
|
||||
question_limit IS NULL OR question_limit BETWEEN min_questions AND max_questions),
|
||||
ADD CONSTRAINT ck_education_practice_blueprint_duration CHECK (duration_minutes IS NULL OR duration_minutes > 0);
|
||||
|
||||
CREATE UNIQUE INDEX uk_education_practice_blueprint_tenant_active_node
|
||||
ON education_practice_blueprint(tenant_id,node_id)
|
||||
WHERE deleted=false AND scope='TENANT_OWNED' AND mode='NODE' AND publication_status='ACTIVE';
|
||||
CREATE UNIQUE INDEX uk_education_practice_blueprint_tenant_active_collection
|
||||
ON education_practice_blueprint(tenant_id,collection_id)
|
||||
WHERE deleted=false AND scope='TENANT_OWNED' AND mode='COLLECTION' AND publication_status='ACTIVE';
|
||||
|
||||
CREATE TABLE education_category_lifecycle_audit (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL, category_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,
|
||||
FOREIGN KEY(category_id) REFERENCES education_category(id),
|
||||
CHECK ((from_status='DRAFT' AND to_status='ACTIVE') OR (from_status='ACTIVE' AND to_status='ARCHIVED')),
|
||||
UNIQUE(tenant_id,category_id,authoring_version));
|
||||
COMMENT ON TABLE education_category_lifecycle_audit IS '教育-分类生命周期审计';
|
||||
|
||||
CREATE TABLE education_practice_blueprint_lifecycle_audit (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL, blueprint_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,
|
||||
FOREIGN KEY(blueprint_id) REFERENCES education_practice_blueprint(id),
|
||||
CHECK ((from_status='DRAFT' AND to_status='ACTIVE') OR (from_status='ACTIVE' AND to_status='ARCHIVED')),
|
||||
UNIQUE(tenant_id,blueprint_id,authoring_version));
|
||||
COMMENT ON TABLE education_practice_blueprint_lifecycle_audit IS '教育-练习蓝图生命周期审计';
|
||||
|
||||
CREATE TABLE education_category_lifecycle_transition_token (
|
||||
transaction_id BIGINT NOT NULL, tenant_id BIGINT NOT NULL, category_id BIGINT NOT NULL,
|
||||
authoring_version INTEGER NOT NULL, from_status VARCHAR(16) NOT NULL, to_status VARCHAR(16) NOT NULL,
|
||||
PRIMARY KEY(transaction_id,tenant_id,category_id,authoring_version,from_status,to_status));
|
||||
CREATE TABLE education_practice_blueprint_lifecycle_transition_token (
|
||||
transaction_id BIGINT NOT NULL, tenant_id BIGINT NOT NULL, blueprint_id BIGINT NOT NULL,
|
||||
authoring_version INTEGER NOT NULL, from_status VARCHAR(16) NOT NULL, to_status VARCHAR(16) NOT NULL,
|
||||
PRIMARY KEY(transaction_id,tenant_id,blueprint_id,authoring_version,from_status,to_status));
|
||||
REVOKE ALL ON education_category_lifecycle_transition_token, education_practice_blueprint_lifecycle_transition_token FROM PUBLIC;
|
||||
|
||||
CREATE FUNCTION education_enforce_category_authoring() RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog,pg_temp AS $$
|
||||
DECLARE s_tenant BIGINT; s_scope VARCHAR(20); s_active BOOLEAN; s_deleted BOOLEAN;
|
||||
BEGIN
|
||||
IF TG_OP='DELETE' THEN IF OLD.scope='TENANT_OWNED' THEN RAISE EXCEPTION 'tenant category cannot be deleted' USING ERRCODE='23514'; END IF; RETURN OLD; END IF;
|
||||
IF TG_OP='INSERT' THEN
|
||||
IF NEW.scope='PUBLIC' THEN RAISE EXCEPTION 'PUBLIC category writes are not tenant authoring' USING ERRCODE='23514'; END IF;
|
||||
IF NEW.scope='TENANT_OWNED' AND (NEW.deleted OR NEW.publication_status<>'DRAFT' OR NEW.is_active OR NEW.authoring_version<>0) THEN RAISE EXCEPTION 'tenant category must start DRAFT' USING ERRCODE='23514'; END IF;
|
||||
ELSE
|
||||
IF OLD.scope='TENANT_OWNED' AND NEW.deleted IS DISTINCT FROM OLD.deleted THEN RAISE EXCEPTION 'tenant category cannot be logically deleted' USING ERRCODE='23514'; END IF;
|
||||
IF OLD.publication_status='ARCHIVED' AND NEW IS DISTINCT FROM OLD THEN RAISE EXCEPTION 'archived category is immutable' USING ERRCODE='23514'; END IF;
|
||||
IF NEW.authoring_version<>OLD.authoring_version+1 THEN RAISE EXCEPTION 'category version must advance once' USING ERRCODE='23514'; END IF;
|
||||
IF OLD.publication_status<>'DRAFT' AND (NEW.subject_id IS DISTINCT FROM OLD.subject_id OR NEW.name IS DISTINCT FROM OLD.name OR NEW.sort_order IS DISTINCT FROM OLD.sort_order) THEN RAISE EXCEPTION 'active category is immutable' USING ERRCODE='23514'; END IF;
|
||||
IF NEW.publication_status IS DISTINCT FROM OLD.publication_status THEN
|
||||
IF NOT ((OLD.publication_status='DRAFT' AND NEW.publication_status='ACTIVE') OR (OLD.publication_status='ACTIVE' AND NEW.publication_status='ARCHIVED')) THEN RAISE EXCEPTION 'invalid category lifecycle' USING ERRCODE='23514'; END IF;
|
||||
EXECUTE format('INSERT INTO %I.education_category_lifecycle_transition_token VALUES($1,$2,$3,$4,$5,$6)',TG_TABLE_SCHEMA) USING pg_current_xact_id()::text::BIGINT,NEW.tenant_id,NEW.id,NEW.authoring_version,OLD.publication_status,NEW.publication_status;
|
||||
END IF;
|
||||
END IF;
|
||||
IF NEW.scope='TENANT_OWNED' THEN
|
||||
IF NEW.subject_id IS NULL THEN RAISE EXCEPTION 'category subject required' USING ERRCODE='23514'; END IF;
|
||||
EXECUTE format('SELECT tenant_id,scope,is_active,deleted FROM %I.education_subject WHERE id=$1 FOR SHARE',TG_TABLE_SCHEMA) INTO s_tenant,s_scope,s_active,s_deleted USING NEW.subject_id;
|
||||
IF s_scope IS NULL OR s_deleted OR NOT s_active OR (s_scope='TENANT_OWNED' AND s_tenant IS DISTINCT FROM NEW.tenant_id) OR s_scope NOT IN('PUBLIC','TENANT_OWNED') THEN RAISE EXCEPTION 'category subject unavailable' USING ERRCODE='23514'; END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE FUNCTION education_enforce_practice_blueprint_authoring() RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog,pg_temp AS $$
|
||||
DECLARE n_tenant BIGINT; n_scope VARCHAR(20); n_entry BIGINT; n_active BOOLEAN; n_hidden BOOLEAN; n_deleted BOOLEAN; n_status VARCHAR(16);
|
||||
c_tenant BIGINT; c_scope VARCHAR(20); c_entry BIGINT; c_node BIGINT; c_active BOOLEAN; c_hidden BOOLEAN; c_deleted BOOLEAN; c_status VARCHAR(16); c_type VARCHAR(50); c_count INTEGER;
|
||||
BEGIN
|
||||
IF TG_OP='DELETE' THEN IF OLD.scope='TENANT_OWNED' THEN RAISE EXCEPTION 'tenant blueprint cannot be deleted' USING ERRCODE='23514'; END IF; RETURN OLD; END IF;
|
||||
IF TG_OP='INSERT' THEN
|
||||
IF NEW.scope='PUBLIC' THEN RAISE EXCEPTION 'PUBLIC blueprint writes are not tenant authoring' USING ERRCODE='23514'; END IF;
|
||||
IF NEW.scope='TENANT_OWNED' AND (NEW.deleted OR NEW.publication_status<>'DRAFT' OR NEW.is_active OR NEW.authoring_version<>0) THEN RAISE EXCEPTION 'tenant blueprint must start DRAFT' USING ERRCODE='23514'; END IF;
|
||||
ELSE
|
||||
IF OLD.scope='TENANT_OWNED' AND NEW.deleted IS DISTINCT FROM OLD.deleted THEN RAISE EXCEPTION 'tenant blueprint cannot be logically deleted' USING ERRCODE='23514'; END IF;
|
||||
IF OLD.publication_status='ARCHIVED' AND NEW IS DISTINCT FROM OLD THEN RAISE EXCEPTION 'archived blueprint is immutable' USING ERRCODE='23514'; END IF;
|
||||
IF NEW.authoring_version<>OLD.authoring_version+1 THEN RAISE EXCEPTION 'blueprint version must advance once' USING ERRCODE='23514'; END IF;
|
||||
IF OLD.publication_status<>'DRAFT' AND (NEW.mode IS DISTINCT FROM OLD.mode OR NEW.entry_id IS DISTINCT FROM OLD.entry_id OR NEW.node_id IS DISTINCT FROM OLD.node_id OR NEW.collection_id IS DISTINCT FROM OLD.collection_id OR NEW.question_limit IS DISTINCT FROM OLD.question_limit OR NEW.duration_minutes IS DISTINCT FROM OLD.duration_minutes OR NEW.available_types IS DISTINCT FROM OLD.available_types OR NEW.available_difficulties IS DISTINCT FROM OLD.available_difficulties OR NEW.min_questions IS DISTINCT FROM OLD.min_questions OR NEW.max_questions IS DISTINCT FROM OLD.max_questions OR NEW.suggested_count IS DISTINCT FROM OLD.suggested_count) THEN RAISE EXCEPTION 'active blueprint is immutable' USING ERRCODE='23514'; END IF;
|
||||
IF NEW.publication_status IS DISTINCT FROM OLD.publication_status THEN
|
||||
IF NOT ((OLD.publication_status='DRAFT' AND NEW.publication_status='ACTIVE') OR (OLD.publication_status='ACTIVE' AND NEW.publication_status='ARCHIVED')) THEN RAISE EXCEPTION 'invalid blueprint lifecycle' USING ERRCODE='23514'; END IF;
|
||||
EXECUTE format('INSERT INTO %I.education_practice_blueprint_lifecycle_transition_token VALUES($1,$2,$3,$4,$5,$6)',TG_TABLE_SCHEMA) USING pg_current_xact_id()::text::BIGINT,NEW.tenant_id,NEW.id,NEW.authoring_version,OLD.publication_status,NEW.publication_status;
|
||||
END IF;
|
||||
END IF;
|
||||
IF NEW.scope='TENANT_OWNED' THEN
|
||||
EXECUTE format('SELECT tenant_id,scope,entry_id,is_active,is_hidden,deleted,publication_status FROM %I.education_content_node WHERE id=$1 FOR SHARE',TG_TABLE_SCHEMA) INTO n_tenant,n_scope,n_entry,n_active,n_hidden,n_deleted,n_status USING NEW.node_id;
|
||||
IF n_tenant IS DISTINCT FROM NEW.tenant_id OR n_scope<>'TENANT_OWNED' OR n_entry IS DISTINCT FROM NEW.entry_id OR n_deleted OR NOT n_active OR n_hidden OR n_status<>'ACTIVE' THEN RAISE EXCEPTION 'blueprint node unavailable' USING ERRCODE='23514'; END IF;
|
||||
IF NEW.mode='COLLECTION' THEN
|
||||
EXECUTE format('SELECT tenant_id,scope,entry_id,node_id,is_active,is_hidden,deleted,publication_status,collection_type,question_count FROM %I.education_question_collection WHERE id=$1 FOR SHARE',TG_TABLE_SCHEMA) INTO c_tenant,c_scope,c_entry,c_node,c_active,c_hidden,c_deleted,c_status,c_type,c_count USING NEW.collection_id;
|
||||
IF c_tenant IS DISTINCT FROM NEW.tenant_id OR c_scope<>'TENANT_OWNED' OR c_entry IS DISTINCT FROM NEW.entry_id OR c_node IS DISTINCT FROM NEW.node_id OR c_deleted OR NOT c_active OR c_hidden OR c_status<>'ACTIVE' OR c_type<>'MANUAL' OR NEW.eligible_count IS DISTINCT FROM c_count OR NEW.total_count IS DISTINCT FROM c_count THEN RAISE EXCEPTION 'blueprint collection unavailable' USING ERRCODE='23514'; END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE FUNCTION education_validate_category_audit() RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog,pg_temp AS $$ DECLARE consumed INTEGER; BEGIN
|
||||
IF NEW.deleted THEN RAISE EXCEPTION 'category audit cannot be deleted' USING ERRCODE='23514'; END IF;
|
||||
EXECUTE format('DELETE FROM %I.education_category_lifecycle_transition_token WHERE transaction_id=$1 AND tenant_id=$2 AND category_id=$3 AND authoring_version=$4 AND from_status=$5 AND to_status=$6',TG_TABLE_SCHEMA) USING pg_current_xact_id()::text::BIGINT,NEW.tenant_id,NEW.category_id,NEW.authoring_version,NEW.from_status,NEW.to_status;
|
||||
GET DIAGNOSTICS consumed=ROW_COUNT; IF consumed<>1 THEN RAISE EXCEPTION 'category audit must accompany transition' USING ERRCODE='23514'; END IF; RETURN NEW; END $$;
|
||||
CREATE FUNCTION education_validate_practice_blueprint_audit() RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog,pg_temp AS $$ DECLARE consumed INTEGER; BEGIN
|
||||
IF NEW.deleted THEN RAISE EXCEPTION 'blueprint audit cannot be deleted' USING ERRCODE='23514'; END IF;
|
||||
EXECUTE format('DELETE FROM %I.education_practice_blueprint_lifecycle_transition_token WHERE transaction_id=$1 AND tenant_id=$2 AND blueprint_id=$3 AND authoring_version=$4 AND from_status=$5 AND to_status=$6',TG_TABLE_SCHEMA) USING pg_current_xact_id()::text::BIGINT,NEW.tenant_id,NEW.blueprint_id,NEW.authoring_version,NEW.from_status,NEW.to_status;
|
||||
GET DIAGNOSTICS consumed=ROW_COUNT; IF consumed<>1 THEN RAISE EXCEPTION 'blueprint audit must accompany transition' USING ERRCODE='23514'; END IF; RETURN NEW; END $$;
|
||||
CREATE FUNCTION education_prevent_publication_audit_mutation() RETURNS TRIGGER LANGUAGE plpgsql SET search_path=pg_catalog,pg_temp AS $$ BEGIN RAISE EXCEPTION 'lifecycle audit is append-only' USING ERRCODE='23514'; END $$;
|
||||
CREATE FUNCTION education_require_category_audit() RETURNS TRIGGER LANGUAGE plpgsql SET search_path=pg_catalog,pg_temp AS $$ DECLARE n 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_category_lifecycle_audit WHERE tenant_id=$1 AND category_id=$2 AND authoring_version=$3 AND from_status=$4 AND to_status=$5 AND deleted=false',TG_TABLE_SCHEMA) INTO n USING NEW.tenant_id,NEW.id,NEW.authoring_version,OLD.publication_status,NEW.publication_status; IF n<>1 THEN RAISE EXCEPTION 'category lifecycle audit required' USING ERRCODE='23514'; END IF; RETURN NULL; END $$;
|
||||
CREATE FUNCTION education_require_practice_blueprint_audit() RETURNS TRIGGER LANGUAGE plpgsql SET search_path=pg_catalog,pg_temp AS $$ DECLARE n 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_practice_blueprint_lifecycle_audit WHERE tenant_id=$1 AND blueprint_id=$2 AND authoring_version=$3 AND from_status=$4 AND to_status=$5 AND deleted=false',TG_TABLE_SCHEMA) INTO n USING NEW.tenant_id,NEW.id,NEW.authoring_version,OLD.publication_status,NEW.publication_status; IF n<>1 THEN RAISE EXCEPTION 'blueprint lifecycle audit required' USING ERRCODE='23514'; END IF; RETURN NULL; END $$;
|
||||
|
||||
REVOKE ALL ON FUNCTION education_enforce_category_authoring() FROM PUBLIC;
|
||||
REVOKE ALL ON FUNCTION education_enforce_practice_blueprint_authoring() FROM PUBLIC;
|
||||
REVOKE ALL ON FUNCTION education_validate_category_audit() FROM PUBLIC;
|
||||
REVOKE ALL ON FUNCTION education_validate_practice_blueprint_audit() FROM PUBLIC;
|
||||
REVOKE ALL ON FUNCTION education_prevent_publication_audit_mutation() FROM PUBLIC;
|
||||
REVOKE ALL ON FUNCTION education_require_category_audit() FROM PUBLIC;
|
||||
REVOKE ALL ON FUNCTION education_require_practice_blueprint_audit() FROM PUBLIC;
|
||||
CREATE TRIGGER trg_education_category_authoring BEFORE INSERT OR UPDATE OR DELETE ON education_category FOR EACH ROW EXECUTE FUNCTION education_enforce_category_authoring();
|
||||
CREATE TRIGGER trg_education_practice_blueprint_authoring BEFORE INSERT OR UPDATE OR DELETE ON education_practice_blueprint FOR EACH ROW EXECUTE FUNCTION education_enforce_practice_blueprint_authoring();
|
||||
CREATE TRIGGER trg_education_category_audit_validate BEFORE INSERT ON education_category_lifecycle_audit FOR EACH ROW EXECUTE FUNCTION education_validate_category_audit();
|
||||
CREATE TRIGGER trg_education_blueprint_audit_validate BEFORE INSERT ON education_practice_blueprint_lifecycle_audit FOR EACH ROW EXECUTE FUNCTION education_validate_practice_blueprint_audit();
|
||||
CREATE TRIGGER trg_education_category_audit_immutable BEFORE UPDATE OR DELETE ON education_category_lifecycle_audit FOR EACH ROW EXECUTE FUNCTION education_prevent_publication_audit_mutation();
|
||||
CREATE TRIGGER trg_education_blueprint_audit_immutable BEFORE UPDATE OR DELETE ON education_practice_blueprint_lifecycle_audit FOR EACH ROW EXECUTE FUNCTION education_prevent_publication_audit_mutation();
|
||||
CREATE CONSTRAINT TRIGGER trg_education_category_audit_required AFTER UPDATE OF publication_status ON education_category DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION education_require_category_audit();
|
||||
CREATE CONSTRAINT TRIGGER trg_education_blueprint_audit_required AFTER UPDATE OF publication_status ON education_practice_blueprint DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION education_require_practice_blueprint_audit();
|
||||
|
||||
DO $$ DECLARE installed INTEGER; BEGIN
|
||||
IF to_regclass('system_menu') IS NULL THEN RETURN; END IF;
|
||||
INSERT INTO system_menu(id,name,permission,type,sort,parent_id,path,icon,component,component_name,status,visible,keep_alive,always_show,creator,updater) VALUES
|
||||
(6809,'分类创作','education:category:author',3,9,6800,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'),
|
||||
(6810,'分类发布','education:category:publish',3,10,6800,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'),
|
||||
(6811,'分类归档','education:category:archive',3,11,6800,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'),
|
||||
(6812,'蓝图创作','education:practice-blueprint:author',3,12,6800,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'),
|
||||
(6813,'蓝图发布','education:practice-blueprint:publish',3,13,6800,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway'),
|
||||
(6814,'蓝图归档','education:practice-blueprint:archive',3,14,6800,'','',NULL,NULL,0,false,true,true,'education-flyway','education-flyway') ON CONFLICT(id) DO NOTHING;
|
||||
SELECT count(*) INTO installed FROM system_menu WHERE deleted=0 AND status=0 AND ((id=6809 AND permission='education:category:author') OR (id=6810 AND permission='education:category:publish') OR (id=6811 AND permission='education:category:archive') OR (id=6812 AND permission='education:practice-blueprint:author') OR (id=6813 AND permission='education:practice-blueprint:publish') OR (id=6814 AND permission='education:practice-blueprint:archive'));
|
||||
IF installed<>6 THEN RAISE EXCEPTION 'Education category/blueprint RBAC seed IDs conflict' USING ERRCODE='23505'; END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,69 @@
|
||||
package cn.iocoder.yudao.module.education.controller.admin.blueprint;
|
||||
|
||||
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.blueprint.vo.PracticeBlueprintDraftReqVO;
|
||||
import cn.iocoder.yudao.module.education.service.blueprint.authoring.PracticeBlueprintAuthoringCommand;
|
||||
import cn.iocoder.yudao.module.education.service.blueprint.authoring.PracticeBlueprintAuthoringService;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.springframework.context.annotation.*;
|
||||
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 org.junit.jupiter.api.extension.ExtendWith;
|
||||
import java.util.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@ExtendWith(SpringExtension.class)
|
||||
@ContextConfiguration(classes = PracticeBlueprintAuthoringControllerContractTest.Config.class)
|
||||
class PracticeBlueprintAuthoringControllerContractTest {
|
||||
@jakarta.annotation.Resource PracticeBlueprintAuthoringController controller;
|
||||
@jakarta.annotation.Resource RecordingService service;
|
||||
@jakarta.annotation.Resource MutableSecurity security;
|
||||
@BeforeEach void setUp() {
|
||||
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 permissionsAreIndependentAndActorIsServerDerived() {
|
||||
security.permissions.add("education:practice-blueprint:author");
|
||||
assertEquals(101L, controller.create(draft()).getData());
|
||||
assertThrows(AccessDeniedException.class, () -> controller.activate(101L, 0));
|
||||
security.permissions.clear(); security.permissions.add("education:practice-blueprint:publish");
|
||||
assertThrows(AccessDeniedException.class, () -> controller.create(draft()));
|
||||
assertEquals(1, controller.activate(101L, 0).getData());
|
||||
assertEquals(7L, service.actorId);
|
||||
security.permissions.clear(); security.permissions.add("education:practice-blueprint:archive");
|
||||
assertEquals(2, controller.archive(101L, 1).getData());
|
||||
}
|
||||
|
||||
private PracticeBlueprintDraftReqVO draft() {
|
||||
PracticeBlueprintDraftReqVO r = new PracticeBlueprintDraftReqVO();
|
||||
r.setMode("NODE"); r.setNodeId(100L); r.setMinQuestions(1); r.setMaxQuestions(20); r.setSuggestedCount(10);
|
||||
return r;
|
||||
}
|
||||
@Configuration(proxyBeanMethods = false) @EnableMethodSecurity static class Config {
|
||||
@Bean("ss") MutableSecurity security() { return new MutableSecurity(); }
|
||||
@Bean RecordingService service() { return new RecordingService(); }
|
||||
@Bean PracticeBlueprintAuthoringController controller(PracticeBlueprintAuthoringService s) { return new PracticeBlueprintAuthoringController(s); }
|
||||
}
|
||||
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 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 PracticeBlueprintAuthoringService {
|
||||
Long actorId;
|
||||
public Long createDraft(PracticeBlueprintAuthoringCommand c) { return 101L; }
|
||||
public int reviseDraft(Long id, PracticeBlueprintAuthoringCommand c) { return c.expectedAuthoringVersion() + 1; }
|
||||
public int activate(Long id, int v, Long actor) { actorId = actor; return v + 1; }
|
||||
public int archive(Long id, int v, Long actor) { actorId = actor; return v + 1; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package cn.iocoder.yudao.module.education.service.blueprint.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.*;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.catalog.*;
|
||||
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.*;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import java.util.List;
|
||||
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@Import({PracticeBlueprintAuthoringServiceImpl.class,
|
||||
cn.iocoder.yudao.module.education.service.contentnode.authoring.ContentNodeAuthoringServiceImpl.class,
|
||||
cn.iocoder.yudao.module.education.service.question.authoring.TenantQuestionLifecycleServiceImpl.class,
|
||||
cn.iocoder.yudao.module.education.service.catalog.provider.JavaCatalogProvider.class, EducationProperties.class})
|
||||
@TestPropertySource(properties = {"yudao.education.enabled=true", "yudao.education.catalog-mode=JAVA_READ"})
|
||||
class PracticeBlueprintAuthoringPostgreSqlIntegrationTest extends PostgreSqlDbIntegrationTest {
|
||||
@Resource EducationProperties properties;
|
||||
@Resource PracticeBlueprintAuthoringService service;
|
||||
@Resource PracticeBlueprintMapper blueprintMapper;
|
||||
@Resource PracticeBlueprintLifecycleAuditMapper auditMapper;
|
||||
@Resource ContentEntryMapper entryMapper;
|
||||
@Resource cn.iocoder.yudao.module.education.service.contentnode.authoring.ContentNodeAuthoringService nodeService;
|
||||
@Resource cn.iocoder.yudao.module.education.service.question.authoring.TenantQuestionLifecycleService questionService;
|
||||
@Resource cn.iocoder.yudao.module.education.service.catalog.provider.JavaCatalogProvider catalogProvider;
|
||||
private Long nodeId;
|
||||
|
||||
@BeforeEach void setUp() {
|
||||
properties.setCatalogMode(CatalogProviderMode.JAVA_READ); TenantContextHolder.setTenantId(10L);
|
||||
ContentEntryDO entry = new ContentEntryDO(); entry.setId(100L); entry.setTenantId(10L); entry.setScope("TENANT_OWNED");
|
||||
entry.setEntryKey("blueprints"); entry.setName("Blueprints"); entry.setEntryType("question"); entryMapper.insert(entry);
|
||||
nodeId = nodeService.createDraft(new cn.iocoder.yudao.module.education.service.contentnode.authoring.ContentNodeAuthoringCommand(
|
||||
100L, null, "Node", null, "category", null, true, 0, null, null));
|
||||
nodeService.activate(nodeId, 0, 7L);
|
||||
createPublishedQuestion(); createPublishedQuestion();
|
||||
}
|
||||
@AfterEach void clear() { TenantContextHolder.clear(); }
|
||||
|
||||
@Test void draftActiveArchiveControlsStudentVisibilityAndAudit() {
|
||||
Long id = service.createDraft(command(null));
|
||||
assertNull(catalogProvider.getPracticeBlueprint(null, String.valueOf(nodeId), null, null));
|
||||
assertEquals(1, service.activate(id, 0, 7L));
|
||||
assertNotNull(catalogProvider.getPracticeBlueprint(null, String.valueOf(nodeId), null, null));
|
||||
assertEquals(2, blueprintMapper.selectTenantOwnedById(10L, id).getEligibleCount());
|
||||
assertEquals(2, service.archive(id, 1, 7L));
|
||||
assertNull(catalogProvider.getPracticeBlueprint(null, String.valueOf(nodeId), null, null));
|
||||
assertEquals(2L, auditMapper.selectCount());
|
||||
}
|
||||
|
||||
@Test void unsupportedModeCrossTenantAndStaleVersionFailClosed() {
|
||||
properties.setCatalogMode(CatalogProviderMode.SCALAR_READ);
|
||||
ServiceException unsupported = assertThrows(ServiceException.class, () -> service.createDraft(command(null)));
|
||||
assertEquals(PRACTICE_BLUEPRINT_PROVIDER_UNSUPPORTED.getCode(), unsupported.getCode());
|
||||
properties.setCatalogMode(CatalogProviderMode.JAVA_READ); Long id = service.createDraft(command(null));
|
||||
TenantContextHolder.setTenantId(20L);
|
||||
ServiceException notFound = assertThrows(ServiceException.class, () -> service.activate(id, 0, 8L));
|
||||
assertEquals(PRACTICE_BLUEPRINT_AUTHORING_NOT_FOUND.getCode(), notFound.getCode());
|
||||
TenantContextHolder.setTenantId(10L); service.activate(id, 0, 7L);
|
||||
ServiceException stale = assertThrows(ServiceException.class, () -> service.archive(id, 0, 7L));
|
||||
assertEquals(PRACTICE_BLUEPRINT_AUTHORING_CONFLICT.getCode(), stale.getCode());
|
||||
}
|
||||
|
||||
@Test void activeBlueprintContentAndAuditAreDatabaseImmutable() throws Exception {
|
||||
Long id = service.createDraft(command(null)); service.activate(id, 0, 7L);
|
||||
assertThrows(Exception.class, () -> blueprintMapper.updateDraftCas(10L, id,
|
||||
blueprintMapper.selectTenantOwnedById(10L, id), 1));
|
||||
PracticeBlueprintLifecycleAuditDO audit = auditMapper.selectOne();
|
||||
audit.setActorId(8L); assertThrows(Exception.class, () -> auditMapper.updateById(audit));
|
||||
}
|
||||
|
||||
private PracticeBlueprintAuthoringCommand command(Integer version) {
|
||||
return new PracticeBlueprintAuthoringCommand("NODE", nodeId, null, 2, 30, List.of("choice"), 1, 20, 2, version);
|
||||
}
|
||||
private void createPublishedQuestion() {
|
||||
Long id = questionService.createDraft(new cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand(
|
||||
"Question", "choice", "easy", List.of(
|
||||
new cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand.QuestionDraftOption("A", "Yes", 0.0),
|
||||
new cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand.QuestionDraftOption("B", "No", 1.0)),
|
||||
"A", "Explanation", null));
|
||||
questionService.place(id, new cn.iocoder.yudao.module.education.service.question.authoring.QuestionPlacementCommand(nodeId, 0));
|
||||
questionService.publish(id, 7L);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package cn.iocoder.yudao.module.education.service.blueprint.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.*;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.catalog.*;
|
||||
import cn.iocoder.yudao.module.education.enums.CatalogProviderMode;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.mockito.*;
|
||||
import java.util.List;
|
||||
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class PracticeBlueprintAuthoringServiceImplTest {
|
||||
@Mock PracticeBlueprintMapper blueprintMapper;
|
||||
@Mock PracticeBlueprintLifecycleAuditMapper auditMapper;
|
||||
@Mock ContentNodeMapper nodeMapper;
|
||||
@Mock QuestionCollectionMapper collectionMapper;
|
||||
@Mock QuestionMapper questionMapper;
|
||||
EducationProperties properties;
|
||||
PracticeBlueprintAuthoringServiceImpl service;
|
||||
|
||||
@BeforeEach void setUp() {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
properties = new EducationProperties(); properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
|
||||
service = new PracticeBlueprintAuthoringServiceImpl(properties, blueprintMapper, auditMapper, nodeMapper, collectionMapper, questionMapper);
|
||||
TenantContextHolder.setTenantId(10L);
|
||||
}
|
||||
@AfterEach void clear() { TenantContextHolder.clear(); }
|
||||
|
||||
@Test void unsupportedModeFailsBeforeMapper() {
|
||||
properties.setCatalogMode(CatalogProviderMode.SCALAR_READ);
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> service.createDraft(nodeCommand(null)));
|
||||
assertEquals(PRACTICE_BLUEPRINT_PROVIDER_UNSUPPORTED.getCode(), ex.getCode());
|
||||
verifyNoInteractions(blueprintMapper, auditMapper, nodeMapper, collectionMapper, questionMapper);
|
||||
}
|
||||
|
||||
@Test void nodeDraftDerivesOwnershipAndCounts() {
|
||||
ContentNodeDO node = new ContentNodeDO(); node.setId(100L); node.setEntryId(50L); node.setTenantId(10L); node.setScope("TENANT_OWNED");
|
||||
when(nodeMapper.selectAvailableCollectionTarget(10L, 100L)).thenReturn(node);
|
||||
when(questionMapper.countTenantOwnedPublishedByNode(10L, 100L)).thenReturn(12L);
|
||||
when(blueprintMapper.insert(any(PracticeBlueprintDO.class))).thenAnswer(invocation -> { PracticeBlueprintDO row = invocation.getArgument(0); row.setId(9L); return 1; });
|
||||
assertEquals(9L, service.createDraft(nodeCommand(null)));
|
||||
ArgumentCaptor<PracticeBlueprintDO> row = ArgumentCaptor.forClass(PracticeBlueprintDO.class);
|
||||
verify(blueprintMapper).insert(row.capture());
|
||||
assertEquals(10L, row.getValue().getTenantId()); assertEquals("TENANT_OWNED", row.getValue().getScope());
|
||||
assertEquals(50L, row.getValue().getEntryId()); assertEquals(100L, row.getValue().getNodeId()); assertNull(row.getValue().getCollectionId());
|
||||
assertEquals(12, row.getValue().getEligibleCount()); assertEquals("DRAFT", row.getValue().getPublicationStatus());
|
||||
assertFalse(row.getValue().getIsActive()); assertEquals(0, row.getValue().getAuthoringVersion());
|
||||
}
|
||||
|
||||
@Test void invalidBindingAndBoundsFailClosed() {
|
||||
ServiceException binding = assertThrows(ServiceException.class, () -> service.createDraft(
|
||||
new PracticeBlueprintAuthoringCommand("NODE", 100L, 200L, null, null, null, 1, 20, 10, null)));
|
||||
assertEquals(PRACTICE_BLUEPRINT_CONFIG_INVALID.getCode(), binding.getCode());
|
||||
ServiceException bounds = assertThrows(ServiceException.class, () -> service.createDraft(
|
||||
new PracticeBlueprintAuthoringCommand("NODE", 100L, null, 30, null, null, 1, 20, 10, null)));
|
||||
assertEquals(PRACTICE_BLUEPRINT_CONFIG_INVALID.getCode(), bounds.getCode());
|
||||
verifyNoInteractions(blueprintMapper, auditMapper, nodeMapper, collectionMapper, questionMapper);
|
||||
}
|
||||
|
||||
@Test void collectionMustBeActiveTenantOwnedAndCountsAreDerived() {
|
||||
QuestionCollectionDO c = new QuestionCollectionDO(); c.setId(200L); c.setEntryId(50L); c.setNodeId(100L); c.setTenantId(10L); c.setScope("TENANT_OWNED"); c.setQuestionCount(7);
|
||||
when(collectionMapper.selectTenantOwnedActiveForAuthoring(10L, 200L)).thenReturn(c);
|
||||
when(blueprintMapper.insert(any(PracticeBlueprintDO.class))).thenAnswer(invocation -> { PracticeBlueprintDO row = invocation.getArgument(0); row.setId(8L); return 1; });
|
||||
assertEquals(8L, service.createDraft(collectionCommand(null)));
|
||||
ArgumentCaptor<PracticeBlueprintDO> row = ArgumentCaptor.forClass(PracticeBlueprintDO.class); verify(blueprintMapper).insert(row.capture());
|
||||
assertEquals(7, row.getValue().getEligibleCount()); assertEquals(200L, row.getValue().getCollectionId()); assertEquals(100L, row.getValue().getNodeId());
|
||||
}
|
||||
|
||||
@Test void lifecycleUsesCasAndAudit() {
|
||||
PracticeBlueprintDO current = new PracticeBlueprintDO(); current.setId(1L); current.setTenantId(10L); current.setScope("TENANT_OWNED");
|
||||
current.setPublicationStatus("DRAFT"); current.setAuthoringVersion(0); current.setMode("NODE"); current.setNodeId(100L);
|
||||
current.setMinQuestions(1); current.setMaxQuestions(20); current.setSuggestedCount(10); current.setQuestionLimit(10);
|
||||
when(blueprintMapper.selectTenantOwnedById(10L, 1L)).thenReturn(current);
|
||||
ContentNodeDO node = new ContentNodeDO(); node.setEntryId(50L); node.setTenantId(10L); node.setScope("TENANT_OWNED");
|
||||
when(nodeMapper.selectAvailableCollectionTarget(10L, 100L)).thenReturn(node);
|
||||
when(questionMapper.countTenantOwnedPublishedByNode(10L, 100L)).thenReturn(12L);
|
||||
when(blueprintMapper.updateLifecycleCas(10L, 1L, "DRAFT", "ACTIVE", true, 0, 12)).thenReturn(1);
|
||||
assertEquals(1, service.activate(1L, 0, 7L));
|
||||
ArgumentCaptor<PracticeBlueprintLifecycleAuditDO> audit = ArgumentCaptor.forClass(PracticeBlueprintLifecycleAuditDO.class);
|
||||
verify(auditMapper).insert(audit.capture()); assertEquals(7L, audit.getValue().getActorId()); assertEquals("ACTIVE", audit.getValue().getToStatus());
|
||||
}
|
||||
|
||||
private PracticeBlueprintAuthoringCommand nodeCommand(Integer version) {
|
||||
return new PracticeBlueprintAuthoringCommand("NODE", 100L, null, 10, 30, List.of("choice"), 1, 20, 10, version);
|
||||
}
|
||||
private PracticeBlueprintAuthoringCommand collectionCommand(Integer version) {
|
||||
return new PracticeBlueprintAuthoringCommand("COLLECTION", null, 200L, 5, 30, null, 1, 20, 5, version);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user