diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/category/CategoryAuthoringController.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/category/CategoryAuthoringController.java new file mode 100644 index 00000000..0423a7e7 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/category/CategoryAuthoringController.java @@ -0,0 +1,56 @@ +package cn.iocoder.yudao.module.education.controller.admin.category; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.education.controller.admin.category.vo.CategoryDraftReqVO; +import cn.iocoder.yudao.module.education.controller.admin.category.vo.CategoryReviseReqVO; +import cn.iocoder.yudao.module.education.service.category.authoring.CategoryAuthoringCommand; +import cn.iocoder.yudao.module.education.service.category.authoring.CategoryAuthoringService; +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/categories") +@Validated +@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true") +public class CategoryAuthoringController { + private final CategoryAuthoringService service; + + public CategoryAuthoringController(CategoryAuthoringService service) { this.service = service; } + + @PostMapping("/drafts") + @PreAuthorize("@ss.hasPermission('education:category:author')") + public CommonResult create(@Valid @RequestBody CategoryDraftReqVO request) { + return success(service.createDraft(toCommand(request, null))); + } + + @PutMapping("/{id}/draft") + @PreAuthorize("@ss.hasPermission('education:category:author')") + public CommonResult revise(@PathVariable("id") Long id, @Valid @RequestBody CategoryReviseReqVO request) { + return success(service.reviseDraft(id, toCommand(request, request.getExpectedAuthoringVersion()))); + } + + @PutMapping("/{id}/activate") + @PreAuthorize("@ss.hasPermission('education:category:publish')") + public CommonResult activate(@PathVariable("id") Long id, + @RequestParam("expectedAuthoringVersion") int expectedVersion) { + return success(service.activate(id, expectedVersion, getLoginUserId())); + } + + @PutMapping("/{id}/archive") + @PreAuthorize("@ss.hasPermission('education:category:archive')") + public CommonResult archive(@PathVariable("id") Long id, + @RequestParam("expectedAuthoringVersion") int expectedVersion) { + return success(service.archive(id, expectedVersion, getLoginUserId())); + } + + private CategoryAuthoringCommand toCommand(CategoryDraftReqVO request, Integer expectedVersion) { + return new CategoryAuthoringCommand(request.getSubjectId(), request.getLegacyNodeId(), request.getName(), + request.getSortOrder(), expectedVersion); + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/category/vo/CategoryDraftReqVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/category/vo/CategoryDraftReqVO.java new file mode 100644 index 00000000..f5ec9ab5 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/category/vo/CategoryDraftReqVO.java @@ -0,0 +1,14 @@ +package cn.iocoder.yudao.module.education.controller.admin.category.vo; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class CategoryDraftReqVO { + @NotNull private Long subjectId; + @Size(max = 100) private String legacyNodeId; + @NotBlank @Size(max = 200) private String name; + private Integer sortOrder; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/category/vo/CategoryReviseReqVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/category/vo/CategoryReviseReqVO.java new file mode 100644 index 00000000..70e67bc0 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/category/vo/CategoryReviseReqVO.java @@ -0,0 +1,9 @@ +package cn.iocoder.yudao.module.education.controller.admin.category.vo; + +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +public class CategoryReviseReqVO extends CategoryDraftReqVO { + @NotNull private Integer expectedAuthoringVersion; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/catalog/CategoryDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/catalog/CategoryDO.java index 5c3b62fd..5cb59657 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/catalog/CategoryDO.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/catalog/CategoryDO.java @@ -22,5 +22,7 @@ public class CategoryDO extends CatalogScopeDO { private String legacyNodeId; private String name; private Boolean isActive; + private String publicationStatus; + private Integer authoringVersion; private Integer sortOrder; } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/catalog/CategoryLifecycleAuditDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/catalog/CategoryLifecycleAuditDO.java new file mode 100644 index 00000000..684b589d --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/catalog/CategoryLifecycleAuditDO.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.education.dal.dataobject.catalog; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDateTime; + +@TableName("education_category_lifecycle_audit") +@KeySequence("education_category_lifecycle_audit_seq") +@Data +@EqualsAndHashCode(callSuper = true) +public class CategoryLifecycleAuditDO extends TenantBaseDO { + @TableId private Long id; + private Long categoryId; + 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/CategoryLifecycleAuditMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/CategoryLifecycleAuditMapper.java new file mode 100644 index 00000000..917b2e0f --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/CategoryLifecycleAuditMapper.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.CategoryLifecycleAuditDO; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface CategoryLifecycleAuditMapper extends BaseMapperX { +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/CategoryMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/CategoryMapper.java index 3d2ed92d..45f0fffe 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/CategoryMapper.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/CategoryMapper.java @@ -8,10 +8,38 @@ import java.util.List; @Mapper public interface CategoryMapper extends BaseMapperX { + default CategoryDO selectTenantOwnedById(Long tenantId, Long id) { + return selectOne(new LambdaQueryWrapperX().eq(CategoryDO::getId, id) + .eq(CategoryDO::getTenantId, tenantId).eq(CategoryDO::getScope, "TENANT_OWNED") + .eq(CategoryDO::getDeleted, false)); + } + + default int updateDraftCas(Long tenantId, Long id, CategoryDO values, int expectedVersion) { + return update(values, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper() + .eq(CategoryDO::getId, id).eq(CategoryDO::getTenantId, tenantId) + .eq(CategoryDO::getScope, "TENANT_OWNED").eq(CategoryDO::getDeleted, false) + .eq(CategoryDO::getPublicationStatus, "DRAFT") + .eq(CategoryDO::getAuthoringVersion, expectedVersion) + .set(CategoryDO::getAuthoringVersion, expectedVersion + 1)); + } + + default int updateLifecycleCas(Long tenantId, Long id, String expectedStatus, String targetStatus, + boolean active, int expectedVersion) { + return update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper() + .eq(CategoryDO::getId, id).eq(CategoryDO::getTenantId, tenantId) + .eq(CategoryDO::getScope, "TENANT_OWNED").eq(CategoryDO::getDeleted, false) + .eq(CategoryDO::getPublicationStatus, expectedStatus) + .eq(CategoryDO::getAuthoringVersion, expectedVersion) + .set(CategoryDO::getPublicationStatus, targetStatus) + .set(CategoryDO::getIsActive, active) + .set(CategoryDO::getAuthoringVersion, expectedVersion + 1)); + } + default List selectActiveList(Long tenantId, Long subjectId, String legacyNodeId) { LambdaQueryWrapperX w = new LambdaQueryWrapperX<>(); CatalogScopeQuery.apply(w, CategoryDO::getTenantId, CategoryDO::getScope, tenantId); - w.eq(CategoryDO::getIsActive, true).orderByAsc(CategoryDO::getSortOrder); + w.eq(CategoryDO::getPublicationStatus, "ACTIVE").eq(CategoryDO::getIsActive, true) + .orderByAsc(CategoryDO::getSortOrder); if (subjectId != null) w.eq(CategoryDO::getSubjectId, subjectId); if (legacyNodeId != null && !legacyNodeId.isBlank()) w.eq(CategoryDO::getLegacyNodeId, legacyNodeId); return selectList(w); diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/SubjectMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/SubjectMapper.java index 786df245..708a2e58 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/SubjectMapper.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/SubjectMapper.java @@ -4,10 +4,20 @@ 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.SubjectDO; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; import java.util.List; @Mapper public interface SubjectMapper extends BaseMapperX { + @Select(""" + SELECT * FROM education_subject + WHERE id = #{subjectId} AND deleted = false AND is_active = true + AND ((tenant_id = #{tenantId} AND scope = 'TENANT_OWNED') OR (tenant_id = 0 AND scope = 'PUBLIC')) + FOR SHARE + """) + SubjectDO selectAvailableReference(@Param("tenantId") Long tenantId, @Param("subjectId") Long subjectId); + default List selectActiveList(Long tenantId, Long regionId, Long schoolId, Long majorId, Long moduleId, String type) { LambdaQueryWrapperX w = new LambdaQueryWrapperX<>(); diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java index 7c131bb3..3e5830bb 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java @@ -133,6 +133,13 @@ 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-040 ~ 1-005-002-049 ========== + ErrorCode CATEGORY_AUTHORING_PROVIDER_UNSUPPORTED = new ErrorCode(1_005_002_040, + "当前题库数据源模式不支持分类创作:{}"); + ErrorCode CATEGORY_AUTHORING_NOT_FOUND = new ErrorCode(1_005_002_041, "分类不存在或无权管理"); + ErrorCode CATEGORY_AUTHORING_CONFLICT = new ErrorCode(1_005_002_042, "分类已变化,请刷新后重试"); + ErrorCode CATEGORY_SUBJECT_UNAVAILABLE = new ErrorCode(1_005_002_043, "分类学科不存在或不可用"); + // ========== 练习蓝图创作 1-005-002-050 ~ 1-005-002-059 ========== ErrorCode PRACTICE_BLUEPRINT_PROVIDER_UNSUPPORTED = new ErrorCode(1_005_002_050, "当前题库数据源模式不支持练习蓝图创作:{}"); diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/category/authoring/CategoryAuthoringCommand.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/category/authoring/CategoryAuthoringCommand.java new file mode 100644 index 00000000..6c5abeff --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/category/authoring/CategoryAuthoringCommand.java @@ -0,0 +1,5 @@ +package cn.iocoder.yudao.module.education.service.category.authoring; + +public record CategoryAuthoringCommand(Long subjectId, String legacyNodeId, String name, Integer sortOrder, + Integer expectedAuthoringVersion) { +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/category/authoring/CategoryAuthoringService.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/category/authoring/CategoryAuthoringService.java new file mode 100644 index 00000000..41c09235 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/category/authoring/CategoryAuthoringService.java @@ -0,0 +1,8 @@ +package cn.iocoder.yudao.module.education.service.category.authoring; + +public interface CategoryAuthoringService { + Long createDraft(CategoryAuthoringCommand command); + int reviseDraft(Long categoryId, CategoryAuthoringCommand command); + int activate(Long categoryId, int expectedAuthoringVersion, Long actorId); + int archive(Long categoryId, int expectedAuthoringVersion, Long actorId); +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/category/authoring/CategoryAuthoringServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/category/authoring/CategoryAuthoringServiceImpl.java new file mode 100644 index 00000000..ef3f4f5d --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/category/authoring/CategoryAuthoringServiceImpl.java @@ -0,0 +1,128 @@ +package cn.iocoder.yudao.module.education.service.category.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.CategoryDO; +import cn.iocoder.yudao.module.education.dal.dataobject.catalog.CategoryLifecycleAuditDO; +import cn.iocoder.yudao.module.education.dal.mysql.catalog.CategoryLifecycleAuditMapper; +import cn.iocoder.yudao.module.education.dal.mysql.catalog.CategoryMapper; +import cn.iocoder.yudao.module.education.dal.mysql.catalog.SubjectMapper; +import cn.iocoder.yudao.module.education.enums.CatalogProviderMode; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*; + +@Service +public class CategoryAuthoringServiceImpl implements CategoryAuthoringService { + private static final String TENANT_OWNED = "TENANT_OWNED"; + + private final EducationProperties properties; + private final CategoryMapper categoryMapper; + private final CategoryLifecycleAuditMapper auditMapper; + private final SubjectMapper subjectMapper; + + public CategoryAuthoringServiceImpl(EducationProperties properties, CategoryMapper categoryMapper, + CategoryLifecycleAuditMapper auditMapper, SubjectMapper subjectMapper) { + this.properties = properties; + this.categoryMapper = categoryMapper; + this.auditMapper = auditMapper; + this.subjectMapper = subjectMapper; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createDraft(CategoryAuthoringCommand command) { + assertMode(); + Long tenantId = TenantContextHolder.getRequiredTenantId(); + validateSubject(tenantId, command.subjectId()); + CategoryDO category = new CategoryDO(); + apply(category, command); + category.setTenantId(tenantId); + category.setScope(TENANT_OWNED); + category.setPublicationStatus("DRAFT"); + category.setAuthoringVersion(0); + category.setIsActive(false); + categoryMapper.insert(category); + return category.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int reviseDraft(Long categoryId, CategoryAuthoringCommand command) { + assertMode(); + Long tenantId = TenantContextHolder.getRequiredTenantId(); + CategoryDO current = requireOwned(tenantId, categoryId); + if (!"DRAFT".equals(current.getPublicationStatus()) + || !command.expectedAuthoringVersion().equals(current.getAuthoringVersion())) { + throw exception(CATEGORY_AUTHORING_CONFLICT); + } + validateSubject(tenantId, command.subjectId()); + CategoryDO update = new CategoryDO(); + apply(update, command); + if (categoryMapper.updateDraftCas(tenantId, categoryId, update, command.expectedAuthoringVersion()) != 1) { + throw exception(CATEGORY_AUTHORING_CONFLICT); + } + return command.expectedAuthoringVersion() + 1; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int activate(Long categoryId, int expectedVersion, Long actorId) { + return transition(categoryId, expectedVersion, actorId, "DRAFT", "ACTIVE", true); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int archive(Long categoryId, int expectedVersion, Long actorId) { + return transition(categoryId, expectedVersion, actorId, "ACTIVE", "ARCHIVED", false); + } + + private int transition(Long categoryId, int expectedVersion, Long actorId, String from, String to, boolean active) { + assertMode(); + Long tenantId = TenantContextHolder.getRequiredTenantId(); + CategoryDO current = requireOwned(tenantId, categoryId); + if (!from.equals(current.getPublicationStatus()) || current.getAuthoringVersion() == null + || current.getAuthoringVersion() != expectedVersion) { + throw exception(CATEGORY_AUTHORING_CONFLICT); + } + if ("ACTIVE".equals(to)) validateSubject(tenantId, current.getSubjectId()); + if (categoryMapper.updateLifecycleCas(tenantId, categoryId, from, to, active, expectedVersion) != 1) { + throw exception(CATEGORY_AUTHORING_CONFLICT); + } + CategoryLifecycleAuditDO audit = new CategoryLifecycleAuditDO(); + audit.setTenantId(tenantId); audit.setCategoryId(categoryId); audit.setAuthoringVersion(expectedVersion + 1); + audit.setActorId(actorId); audit.setFromStatus(from); audit.setToStatus(to); audit.setOccurredAt(LocalDateTime.now()); + auditMapper.insert(audit); + return expectedVersion + 1; + } + + private CategoryDO requireOwned(Long tenantId, Long categoryId) { + CategoryDO category = categoryMapper.selectTenantOwnedById(tenantId, categoryId); + if (category == null) throw exception(CATEGORY_AUTHORING_NOT_FOUND); + return category; + } + + private void validateSubject(Long tenantId, Long subjectId) { + if (subjectId == null || TenantUtils.executeIgnore(() -> subjectMapper.selectAvailableReference(tenantId, subjectId)) == null) { + throw exception(CATEGORY_SUBJECT_UNAVAILABLE); + } + } + + private void apply(CategoryDO category, CategoryAuthoringCommand command) { + category.setSubjectId(command.subjectId()); + category.setLegacyNodeId(command.legacyNodeId()); + category.setName(command.name()); + category.setSortOrder(command.sortOrder() != null ? command.sortOrder() : 0); + } + + private void assertMode() { + if (properties.getCatalogMode() != CatalogProviderMode.JAVA_READ) { + throw exception(CATEGORY_AUTHORING_PROVIDER_UNSUPPORTED, properties.getCatalogMode()); + } + } +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/category/CategoryAuthoringControllerContractTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/category/CategoryAuthoringControllerContractTest.java new file mode 100644 index 00000000..4cf8d231 --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/category/CategoryAuthoringControllerContractTest.java @@ -0,0 +1,83 @@ +package cn.iocoder.yudao.module.education.controller.admin.category; + +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.category.vo.CategoryDraftReqVO; +import cn.iocoder.yudao.module.education.controller.admin.category.vo.CategoryReviseReqVO; +import cn.iocoder.yudao.module.education.service.category.authoring.CategoryAuthoringCommand; +import cn.iocoder.yudao.module.education.service.category.authoring.CategoryAuthoringService; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.extension.ExtendWith; +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 java.util.*; + +import static org.junit.jupiter.api.Assertions.*; + +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = CategoryAuthoringControllerContractTest.Config.class) +class CategoryAuthoringControllerContractTest { + @jakarta.annotation.Resource CategoryAuthoringController 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:category:author"); + assertEquals(101L, controller.create(draft()).getData()); + assertEquals(3, controller.revise(101L, revise()).getData()); + assertThrows(AccessDeniedException.class, () -> controller.activate(101L, 3)); + assertThrows(AccessDeniedException.class, () -> controller.archive(101L, 3)); + + security.permissions.clear(); security.permissions.add("education:category:publish"); + assertThrows(AccessDeniedException.class, () -> controller.create(draft())); + assertEquals(4, controller.activate(101L, 3).getData()); + assertEquals(7L, service.actorId); + + security.permissions.clear(); security.permissions.add("education:category:archive"); + assertEquals(5, controller.archive(101L, 4).getData()); + } + + private CategoryDraftReqVO draft() { + CategoryDraftReqVO request = new CategoryDraftReqVO(); + request.setSubjectId(100L); request.setLegacyNodeId("legacy-node"); request.setName("Algebra"); + return request; + } + private CategoryReviseReqVO revise() { + CategoryReviseReqVO request = new CategoryReviseReqVO(); + request.setSubjectId(100L); request.setName("Advanced Algebra"); request.setExpectedAuthoringVersion(2); + return request; + } + + @Configuration(proxyBeanMethods = false) @EnableMethodSecurity static class Config { + @Bean("ss") MutableSecurity security() { return new MutableSecurity(); } + @Bean RecordingService service() { return new RecordingService(); } + @Bean CategoryAuthoringController controller(CategoryAuthoringService service) { return new CategoryAuthoringController(service); } + } + static class MutableSecurity implements SecurityFrameworkService { + final Set permissions = new HashSet<>(); + public boolean hasPermission(String p) { return permissions.contains(p); } + public boolean hasAnyPermissions(String... p) { return 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 CategoryAuthoringService { + Long actorId; + public Long createDraft(CategoryAuthoringCommand command) { return 101L; } + public int reviseDraft(Long id, CategoryAuthoringCommand command) { return command.expectedAuthoringVersion() + 1; } + public int activate(Long id, int version, Long actor) { actorId = actor; return version + 1; } + public int archive(Long id, int version, Long actor) { actorId = actor; return version + 1; } + } +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/CategoryMapperTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/CategoryMapperTest.java new file mode 100644 index 00000000..ea3ade1d --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/CategoryMapperTest.java @@ -0,0 +1,31 @@ +package cn.iocoder.yudao.module.education.dal.mysql.catalog; + +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.education.dal.dataobject.catalog.CategoryDO; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class CategoryMapperTest { + @Test void studentListingRequiresActiveLifecycleAndAvailabilityFlag() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), "test"), CategoryDO.class); + CategoryMapper mapper = mock(CategoryMapper.class, CALLS_REAL_METHODS); + when(mapper.selectList(any(LambdaQueryWrapperX.class))).thenReturn(java.util.List.of()); + + mapper.selectActiveList(10L, 100L, null); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(LambdaQueryWrapperX.class); + verify(mapper).selectList(captor.capture()); + String sql = captor.getValue().getSqlSegment(); + assertTrue(sql.toLowerCase().contains("publicationstatus")); + assertTrue(sql.toLowerCase().contains("isactive")); + assertEquals(2, captor.getValue().getParamNameValuePairs().values().stream() + .filter(value -> "ACTIVE".equals(value) || Boolean.TRUE.equals(value)).count()); + } +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/category/authoring/CategoryAuthoringServiceImplTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/category/authoring/CategoryAuthoringServiceImplTest.java new file mode 100644 index 00000000..4d509f99 --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/category/authoring/CategoryAuthoringServiceImplTest.java @@ -0,0 +1,99 @@ +package cn.iocoder.yudao.module.education.service.category.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.junit.jupiter.api.extension.ExtendWith; +import org.mockito.*; +import org.mockito.junit.jupiter.MockitoExtension; + +import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class CategoryAuthoringServiceImplTest { + @Mock CategoryMapper categoryMapper; + @Mock CategoryLifecycleAuditMapper auditMapper; + @Mock SubjectMapper subjectMapper; + EducationProperties properties; + CategoryAuthoringServiceImpl service; + + @BeforeEach void setUp() { + properties = new EducationProperties(); properties.setCatalogMode(CatalogProviderMode.JAVA_READ); + service = new CategoryAuthoringServiceImpl(properties, categoryMapper, auditMapper, subjectMapper); + TenantContextHolder.setTenantId(10L); + } + @AfterEach void clear() { TenantContextHolder.clear(); } + + @Test void unsupportedProviderFailsBeforeMapper() { + properties.setCatalogMode(CatalogProviderMode.SCALAR_READ); + ServiceException ex = assertThrows(ServiceException.class, () -> service.createDraft(command(null))); + assertEquals(CATEGORY_AUTHORING_PROVIDER_UNSUPPORTED.getCode(), ex.getCode()); + verifyNoInteractions(categoryMapper, auditMapper, subjectMapper); + } + + @Test void createDraftUsesSecureServerControlledDefaultsAndAvailableSubject() { + when(subjectMapper.selectAvailableReference(10L, 100L)).thenReturn(subject(100L, 0L, "PUBLIC", true)); + when(categoryMapper.insert(any(CategoryDO.class))).thenAnswer(invocation -> { invocation.getArgument(0).setId(101L); return 1; }); + + assertEquals(101L, service.createDraft(command(null))); + + ArgumentCaptor captor = ArgumentCaptor.forClass(CategoryDO.class); + verify(categoryMapper).insert(captor.capture()); + CategoryDO category = captor.getValue(); + assertEquals(10L, category.getTenantId()); assertEquals("TENANT_OWNED", category.getScope()); + assertEquals("DRAFT", category.getPublicationStatus()); assertEquals(0, category.getAuthoringVersion()); + assertFalse(category.getIsActive()); + } + + @Test void unavailableOrCrossTenantSubjectIsRejected() { + when(subjectMapper.selectAvailableReference(10L, 100L)).thenReturn(null); + ServiceException ex = assertThrows(ServiceException.class, () -> service.createDraft(command(null))); + assertEquals(CATEGORY_SUBJECT_UNAVAILABLE.getCode(), ex.getCode()); + verify(categoryMapper, never()).insert(any(CategoryDO.class)); + } + + @Test void staleDraftRevisionIsRejectedWithoutUpdate() { + when(categoryMapper.selectTenantOwnedById(10L, 101L)).thenReturn(category("DRAFT", 2)); + ServiceException ex = assertThrows(ServiceException.class, () -> service.reviseDraft(101L, command(1))); + assertEquals(CATEGORY_AUTHORING_CONFLICT.getCode(), ex.getCode()); + verify(categoryMapper, never()).updateDraftCas(anyLong(), anyLong(), any(), anyInt()); + } + + @Test void lifecycleCasAppendsActorAudit() { + when(categoryMapper.selectTenantOwnedById(10L, 101L)).thenReturn(category("DRAFT", 2)); + when(subjectMapper.selectAvailableReference(10L, 100L)).thenReturn(subject(100L, 10L, "TENANT_OWNED", true)); + when(categoryMapper.updateLifecycleCas(10L, 101L, "DRAFT", "ACTIVE", true, 2)).thenReturn(1); + + assertEquals(3, service.activate(101L, 2, 7L)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(CategoryLifecycleAuditDO.class); + verify(auditMapper).insert(captor.capture()); + assertEquals(10L, captor.getValue().getTenantId()); assertEquals(101L, captor.getValue().getCategoryId()); + assertEquals(3, captor.getValue().getAuthoringVersion()); assertEquals(7L, captor.getValue().getActorId()); + assertEquals("DRAFT", captor.getValue().getFromStatus()); assertEquals("ACTIVE", captor.getValue().getToStatus()); + } + + @Test void archiveRequiresActiveState() { + when(categoryMapper.selectTenantOwnedById(10L, 101L)).thenReturn(category("DRAFT", 2)); + ServiceException ex = assertThrows(ServiceException.class, () -> service.archive(101L, 2, 7L)); + assertEquals(CATEGORY_AUTHORING_CONFLICT.getCode(), ex.getCode()); + verify(auditMapper, never()).insert(any(CategoryLifecycleAuditDO.class)); + } + + private CategoryAuthoringCommand command(Integer expected) { + return new CategoryAuthoringCommand(100L, "legacy-node", "Algebra", 4, expected); + } + private CategoryDO category(String status, int version) { + CategoryDO category = new CategoryDO(); category.setId(101L); category.setTenantId(10L); category.setScope("TENANT_OWNED"); + category.setSubjectId(100L); category.setPublicationStatus(status); category.setAuthoringVersion(version); return category; + } + private SubjectDO subject(Long id, Long tenant, String scope, boolean active) { + SubjectDO subject = new SubjectDO(); subject.setId(id); subject.setTenantId(tenant); subject.setScope(scope); subject.setIsActive(active); return subject; + } +}