feat(education): add category lifecycle authoring

This commit is contained in:
2026-07-31 12:45:58 +08:00
parent efaab4e03a
commit b8b06e0292
15 changed files with 514 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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.CategoryLifecycleAuditDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface CategoryLifecycleAuditMapper extends BaseMapperX<CategoryLifecycleAuditDO> {
}

View File

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

View File

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

View File

@@ -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,
"当前题库数据源模式不支持练习蓝图创作:{}");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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