From efaab4e03a649b83269fde3476aa57cb60e7899b Mon Sep 17 00:00:00 2001 From: wangziqi Date: Fri, 31 Jul 2026 12:47:00 +0800 Subject: [PATCH] feat(education): add practice blueprint lifecycle --- .../PracticeBlueprintAuthoringController.java | 43 +++++ .../vo/PracticeBlueprintDraftReqVO.java | 18 ++ .../vo/PracticeBlueprintReviseReqVO.java | 9 + .../catalog/PracticeBlueprintDO.java | 2 + .../PracticeBlueprintLifecycleAuditDO.java | 19 ++ ...PracticeBlueprintLifecycleAuditMapper.java | 9 + .../catalog/PracticeBlueprintMapper.java | 67 ++++++- .../catalog/QuestionCollectionMapper.java | 17 ++ .../dal/mysql/catalog/QuestionMapper.java | 7 + .../education/enums/ErrorCodeConstants.java | 8 + .../PracticeBlueprintAuthoringCommand.java | 9 + .../PracticeBlueprintAuthoringService.java | 8 + ...PracticeBlueprintAuthoringServiceImpl.java | 146 +++++++++++++++ ...egory_and_practice_blueprint_authoring.sql | 170 ++++++++++++++++++ ...eprintAuthoringControllerContractTest.java | 69 +++++++ ...intAuthoringPostgreSqlIntegrationTest.java | 89 +++++++++ ...ticeBlueprintAuthoringServiceImplTest.java | 93 ++++++++++ 17 files changed, 775 insertions(+), 8 deletions(-) create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/blueprint/PracticeBlueprintAuthoringController.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/blueprint/vo/PracticeBlueprintDraftReqVO.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/blueprint/vo/PracticeBlueprintReviseReqVO.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/catalog/PracticeBlueprintLifecycleAuditDO.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/PracticeBlueprintLifecycleAuditMapper.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/blueprint/authoring/PracticeBlueprintAuthoringCommand.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/blueprint/authoring/PracticeBlueprintAuthoringService.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/blueprint/authoring/PracticeBlueprintAuthoringServiceImpl.java create mode 100644 yudao-module-education/src/main/resources/db/migration/education/V4120__add_category_and_practice_blueprint_authoring.sql create mode 100644 yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/blueprint/PracticeBlueprintAuthoringControllerContractTest.java create mode 100644 yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/blueprint/authoring/PracticeBlueprintAuthoringPostgreSqlIntegrationTest.java create mode 100644 yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/blueprint/authoring/PracticeBlueprintAuthoringServiceImplTest.java diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/blueprint/PracticeBlueprintAuthoringController.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/blueprint/PracticeBlueprintAuthoringController.java new file mode 100644 index 00000000..c747a6aa --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/blueprint/PracticeBlueprintAuthoringController.java @@ -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 create(@Valid @RequestBody PracticeBlueprintDraftReqVO request) { + return success(service.createDraft(toCommand(request, null))); + } + @PutMapping("/{id}/draft") @PreAuthorize("@ss.hasPermission('education:practice-blueprint:author')") + public CommonResult 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 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 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); + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/blueprint/vo/PracticeBlueprintDraftReqVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/blueprint/vo/PracticeBlueprintDraftReqVO.java new file mode 100644 index 00000000..8f7fb406 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/blueprint/vo/PracticeBlueprintDraftReqVO.java @@ -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; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/blueprint/vo/PracticeBlueprintReviseReqVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/blueprint/vo/PracticeBlueprintReviseReqVO.java new file mode 100644 index 00000000..be71aa5f --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/blueprint/vo/PracticeBlueprintReviseReqVO.java @@ -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; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/catalog/PracticeBlueprintDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/catalog/PracticeBlueprintDO.java index d4c0d370..dfdbba79 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/catalog/PracticeBlueprintDO.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/catalog/PracticeBlueprintDO.java @@ -32,4 +32,6 @@ public class PracticeBlueprintDO extends CatalogScopeDO { private Integer maxQuestions; private Integer suggestedCount; private Boolean isActive; + private String publicationStatus; + private Integer authoringVersion; } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/catalog/PracticeBlueprintLifecycleAuditDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/catalog/PracticeBlueprintLifecycleAuditDO.java new file mode 100644 index 00000000..1763a8d1 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/catalog/PracticeBlueprintLifecycleAuditDO.java @@ -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; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/PracticeBlueprintLifecycleAuditMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/PracticeBlueprintLifecycleAuditMapper.java new file mode 100644 index 00000000..430ba86a --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/PracticeBlueprintLifecycleAuditMapper.java @@ -0,0 +1,9 @@ +package cn.iocoder.yudao.module.education.dal.mysql.catalog; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.module.education.dal.dataobject.catalog.PracticeBlueprintLifecycleAuditDO; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface PracticeBlueprintLifecycleAuditMapper extends BaseMapperX { +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/PracticeBlueprintMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/PracticeBlueprintMapper.java index 5986dec3..26755dde 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/PracticeBlueprintMapper.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/PracticeBlueprintMapper.java @@ -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 { + @Select(""" + + """) + 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 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().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().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().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)); } } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/QuestionCollectionMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/QuestionCollectionMapper.java index 2d78fe83..2ca32896 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/QuestionCollectionMapper.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/QuestionCollectionMapper.java @@ -44,6 +44,23 @@ public interface QuestionCollectionMapper extends BaseMapperX 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 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); } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/QuestionMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/QuestionMapper.java index 74680745..be2ca24f 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/QuestionMapper.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/QuestionMapper.java @@ -138,6 +138,13 @@ public interface QuestionMapper extends BaseMapperX { @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("""