feat(education): add category lifecycle authoring
This commit is contained in:
@@ -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<Long> create(@Valid @RequestBody CategoryDraftReqVO request) {
|
||||
return success(service.createDraft(toCommand(request, null)));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/draft")
|
||||
@PreAuthorize("@ss.hasPermission('education:category:author')")
|
||||
public CommonResult<Integer> 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<Integer> 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<Integer> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<CategoryLifecycleAuditDO> {
|
||||
}
|
||||
@@ -8,10 +8,38 @@ import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface CategoryMapper extends BaseMapperX<CategoryDO> {
|
||||
default CategoryDO selectTenantOwnedById(Long tenantId, Long id) {
|
||||
return selectOne(new LambdaQueryWrapperX<CategoryDO>().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<CategoryDO>()
|
||||
.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<CategoryDO>()
|
||||
.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<CategoryDO> selectActiveList(Long tenantId, Long subjectId, String legacyNodeId) {
|
||||
LambdaQueryWrapperX<CategoryDO> 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);
|
||||
|
||||
@@ -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<SubjectDO> {
|
||||
@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<SubjectDO> selectActiveList(Long tenantId, Long regionId, Long schoolId, Long majorId,
|
||||
Long moduleId, String type) {
|
||||
LambdaQueryWrapperX<SubjectDO> w = new LambdaQueryWrapperX<>();
|
||||
|
||||
@@ -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,
|
||||
"当前题库数据源模式不支持练习蓝图创作:{}");
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<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 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; }
|
||||
}
|
||||
}
|
||||
@@ -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<LambdaQueryWrapperX<CategoryDO>> 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());
|
||||
}
|
||||
}
|
||||
@@ -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.<CategoryDO>getArgument(0).setId(101L); return 1; });
|
||||
|
||||
assertEquals(101L, service.createDraft(command(null)));
|
||||
|
||||
ArgumentCaptor<CategoryDO> 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<CategoryLifecycleAuditDO> 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user