feat(education): add practice blueprint lifecycle

This commit is contained in:
2026-07-31 12:47:00 +08:00
parent 5bc1e9e634
commit efaab4e03a
17 changed files with 775 additions and 8 deletions

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -32,4 +32,6 @@ public class PracticeBlueprintDO extends CatalogScopeDO {
private Integer maxQuestions;
private Integer suggestedCount;
private Boolean isActive;
private String publicationStatus;
private Integer authoringVersion;
}

View File

@@ -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;
}

View File

@@ -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> {
}

View File

@@ -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));
}
}

View File

@@ -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);
}

View File

@@ -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

View File

@@ -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, "练习蓝图可用题目不足");
}

View File

@@ -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) {
}

View File

@@ -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);
}

View File

@@ -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) {}
}

View File

@@ -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 $$;

View File

@@ -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; }
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}