feat(education): add tenant question placement
This commit is contained in:
@@ -2,7 +2,9 @@ package cn.iocoder.yudao.module.education.controller.admin.question;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.module.education.controller.admin.question.vo.QuestionDraftCreateReqVO;
|
||||
import cn.iocoder.yudao.module.education.controller.admin.question.vo.QuestionPlacementReqVO;
|
||||
import cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand;
|
||||
import cn.iocoder.yudao.module.education.service.question.authoring.QuestionPlacementCommand;
|
||||
import cn.iocoder.yudao.module.education.service.question.authoring.TenantQuestionLifecycleService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
@@ -44,6 +46,15 @@ public class QuestionAuthoringController {
|
||||
return success(lifecycleService.createDraft(command));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/placement")
|
||||
@Operation(summary = "将租户题目草稿放置到内容节点")
|
||||
@PreAuthorize("@ss.hasPermission('education:question:classify')")
|
||||
public CommonResult<Integer> place(@PathVariable("id") Long id,
|
||||
@Valid @RequestBody QuestionPlacementReqVO reqVO) {
|
||||
return success(lifecycleService.place(id, new QuestionPlacementCommand(
|
||||
reqVO.getNodeId(), reqVO.getExpectedPlacementVersion())));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/publish")
|
||||
@Operation(summary = "发布租户题目")
|
||||
@PreAuthorize("@ss.hasPermission('education:question:publish')")
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package cn.iocoder.yudao.module.education.controller.admin.question.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import lombok.Data;
|
||||
|
||||
@Schema(description = "管理后台 - 题目目录放置 Request VO")
|
||||
@Data
|
||||
public class QuestionPlacementReqVO {
|
||||
|
||||
@Schema(description = "目标内容节点 ID", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotNull
|
||||
@Positive
|
||||
private Long nodeId;
|
||||
|
||||
@Schema(description = "期望放置版本", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
|
||||
@NotNull
|
||||
@Min(0)
|
||||
@Max(Integer.MAX_VALUE - 1L)
|
||||
private Integer expectedPlacementVersion;
|
||||
}
|
||||
@@ -67,6 +67,9 @@ public class QuestionDO extends CatalogScopeDO {
|
||||
/** 所属内容节点 ID */
|
||||
private Long nodeId;
|
||||
|
||||
/** 目录放置的乐观锁版本;不属于题目内容版本 */
|
||||
private Integer placementVersion;
|
||||
|
||||
/** 标签 JSON 数组 */
|
||||
private String tags;
|
||||
|
||||
|
||||
@@ -4,10 +4,28 @@ 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.ContentNodeDO;
|
||||
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 ContentNodeMapper extends BaseMapperX<ContentNodeDO> {
|
||||
@Select("""
|
||||
SELECT *
|
||||
FROM education_content_node
|
||||
WHERE id = #{nodeId}
|
||||
AND deleted = false
|
||||
AND is_active = true
|
||||
AND is_hidden = false
|
||||
AND is_selectable = true
|
||||
AND ((tenant_id = #{tenantId} AND scope = 'TENANT_OWNED')
|
||||
OR (tenant_id = 0 AND scope = 'PUBLIC'))
|
||||
FOR SHARE
|
||||
""")
|
||||
ContentNodeDO selectAvailablePlacementTarget(@Param("tenantId") Long tenantId,
|
||||
@Param("nodeId") Long nodeId);
|
||||
|
||||
default List<ContentNodeDO> selectChildren(Long tenantId, Long entryId, Long parentId, boolean includeInactive,
|
||||
String markerType) {
|
||||
LambdaQueryWrapperX<ContentNodeDO> w = base(tenantId, entryId, includeInactive, markerType);
|
||||
|
||||
@@ -13,15 +13,40 @@ import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface QuestionMapper extends BaseMapperX<QuestionDO> {
|
||||
default IPage<QuestionDO> selectPublishedPage(IPage<QuestionDO> page, Long tenantId, Long nodeId,
|
||||
default IPage<QuestionDO> selectPublishedPage(IPage<QuestionDO> page, Long tenantId,
|
||||
String type, String difficulty) {
|
||||
LambdaQueryWrapperX<QuestionDO> w = visible(tenantId);
|
||||
if (nodeId != null) w.eq(QuestionDO::getNodeId, nodeId);
|
||||
if (type != null && !type.isBlank()) w.eq(QuestionDO::getType, type);
|
||||
if (difficulty != null && !difficulty.isBlank()) w.eq(QuestionDO::getDifficulty, difficulty);
|
||||
return selectPage(page, w.orderByAsc(QuestionDO::getSortOrder).orderByAsc(QuestionDO::getId));
|
||||
}
|
||||
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT q.*
|
||||
FROM education_question q
|
||||
JOIN education_content_node node ON node.id = q.node_id
|
||||
WHERE q.deleted = false
|
||||
AND q.is_published = true
|
||||
AND q.status = 'PUBLISHED'
|
||||
AND q.node_id = #{nodeId}
|
||||
AND ((q.tenant_id = #{tenantId} AND q.scope = 'TENANT_OWNED')
|
||||
OR (q.tenant_id = 0 AND q.scope = 'PUBLIC'))
|
||||
AND node.deleted = false
|
||||
AND node.is_active = true
|
||||
AND node.is_hidden = false
|
||||
AND node.is_selectable = true
|
||||
AND ((node.tenant_id = #{tenantId} AND node.scope = 'TENANT_OWNED')
|
||||
OR (node.tenant_id = 0 AND node.scope = 'PUBLIC'))
|
||||
<if test="type != null and type != ''">AND q.type = #{type}</if>
|
||||
<if test="difficulty != null and difficulty != ''">AND q.difficulty = #{difficulty}</if>
|
||||
ORDER BY q.sort_order, q.id
|
||||
</script>
|
||||
""")
|
||||
IPage<QuestionDO> selectPublishedPageByAvailableNode(IPage<QuestionDO> page,
|
||||
@Param("tenantId") Long tenantId, @Param("nodeId") Long nodeId,
|
||||
@Param("type") String type, @Param("difficulty") String difficulty);
|
||||
|
||||
default IPage<QuestionDO> selectPublishedPageByIds(IPage<QuestionDO> page, Long tenantId, List<Long> ids,
|
||||
String type, String difficulty) {
|
||||
if (ids == null || ids.isEmpty()) {
|
||||
@@ -62,13 +87,30 @@ public interface QuestionMapper extends BaseMapperX<QuestionDO> {
|
||||
return selectCount(w);
|
||||
}
|
||||
|
||||
default long countPublished(Long tenantId, Long nodeId, String type, String difficulty) {
|
||||
LambdaQueryWrapperX<QuestionDO> w = visible(tenantId);
|
||||
if (nodeId != null) w.eq(QuestionDO::getNodeId, nodeId);
|
||||
if (type != null && !type.isBlank()) w.eq(QuestionDO::getType, type);
|
||||
if (difficulty != null && !difficulty.isBlank()) w.eq(QuestionDO::getDifficulty, difficulty);
|
||||
return selectCount(w);
|
||||
}
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT count(*)
|
||||
FROM education_question q
|
||||
JOIN education_content_node node ON node.id = q.node_id
|
||||
WHERE q.deleted = false
|
||||
AND q.is_published = true
|
||||
AND q.status = 'PUBLISHED'
|
||||
AND q.node_id = #{nodeId}
|
||||
AND ((q.tenant_id = #{tenantId} AND q.scope = 'TENANT_OWNED')
|
||||
OR (q.tenant_id = 0 AND q.scope = 'PUBLIC'))
|
||||
AND node.deleted = false
|
||||
AND node.is_active = true
|
||||
AND node.is_hidden = false
|
||||
AND node.is_selectable = true
|
||||
AND ((node.tenant_id = #{tenantId} AND node.scope = 'TENANT_OWNED')
|
||||
OR (node.tenant_id = 0 AND node.scope = 'PUBLIC'))
|
||||
<if test="type != null and type != ''">AND q.type = #{type}</if>
|
||||
<if test="difficulty != null and difficulty != ''">AND q.difficulty = #{difficulty}</if>
|
||||
</script>
|
||||
""")
|
||||
long countPublishedByAvailableNode(@Param("tenantId") Long tenantId,
|
||||
@Param("nodeId") Long nodeId, @Param("type") String type,
|
||||
@Param("difficulty") String difficulty);
|
||||
|
||||
default QuestionDO selectPublishedById(Long tenantId, Long id) {
|
||||
return selectOne(visible(tenantId).eq(QuestionDO::getId, id));
|
||||
@@ -82,16 +124,28 @@ public interface QuestionMapper extends BaseMapperX<QuestionDO> {
|
||||
}
|
||||
|
||||
default int updateLifecycle(Long tenantId, Long id, String expectedStatus,
|
||||
String targetStatus, boolean published) {
|
||||
String targetStatus, boolean published, Integer expectedPlacementVersion) {
|
||||
return update(null, new LambdaUpdateWrapper<QuestionDO>()
|
||||
.eq(QuestionDO::getId, id)
|
||||
.eq(QuestionDO::getTenantId, tenantId)
|
||||
.eq(QuestionDO::getScope, "TENANT_OWNED")
|
||||
.eq(QuestionDO::getStatus, expectedStatus)
|
||||
.eq(QuestionDO::getPlacementVersion, expectedPlacementVersion)
|
||||
.set(QuestionDO::getStatus, targetStatus)
|
||||
.set(QuestionDO::getIsPublished, published));
|
||||
}
|
||||
|
||||
default int updatePlacement(Long tenantId, Long id, Long nodeId, int expectedPlacementVersion) {
|
||||
return update(null, new LambdaUpdateWrapper<QuestionDO>()
|
||||
.eq(QuestionDO::getId, id)
|
||||
.eq(QuestionDO::getTenantId, tenantId)
|
||||
.eq(QuestionDO::getScope, "TENANT_OWNED")
|
||||
.eq(QuestionDO::getStatus, "DRAFT")
|
||||
.eq(QuestionDO::getPlacementVersion, expectedPlacementVersion)
|
||||
.set(QuestionDO::getNodeId, nodeId)
|
||||
.set(QuestionDO::getPlacementVersion, expectedPlacementVersion + 1));
|
||||
}
|
||||
|
||||
private LambdaQueryWrapperX<QuestionDO> visible(Long tenantId) {
|
||||
LambdaQueryWrapperX<QuestionDO> w = new LambdaQueryWrapperX<>();
|
||||
CatalogScopeQuery.apply(w, QuestionDO::getTenantId, QuestionDO::getScope, tenantId);
|
||||
|
||||
@@ -104,4 +104,7 @@ public interface ErrorCodeConstants {
|
||||
ErrorCode QUESTION_AUTHORING_NOT_FOUND = new ErrorCode(1_005_003_071, "题目不存在或无权管理");
|
||||
ErrorCode QUESTION_LIFECYCLE_CONFLICT = new ErrorCode(1_005_003_072, "题目状态已变化,无法执行 {} 操作");
|
||||
ErrorCode QUESTION_CONTENT_NOT_PUBLISHABLE = new ErrorCode(1_005_003_073, "题目内容不完整或不安全,无法发布");
|
||||
ErrorCode QUESTION_PLACEMENT_TARGET_UNAVAILABLE = new ErrorCode(1_005_003_074, "题目归类目标不存在或不可用");
|
||||
ErrorCode QUESTION_PLACEMENT_CONFLICT = new ErrorCode(1_005_003_075, "题目归类已变化,请刷新后重试");
|
||||
ErrorCode QUESTION_PLACEMENT_REQUIRED = new ErrorCode(1_005_003_076, "题目尚未归类,无法发布");
|
||||
}
|
||||
|
||||
@@ -161,7 +161,11 @@ public class JavaCatalogProvider implements CatalogProvider, QuestionCatalogProv
|
||||
.map(QuestionCollectionQuestionDO::getQuestionId).toList();
|
||||
IPage<QuestionDO> result = ids != null
|
||||
? readWithExplicitCatalogScope(() -> questionMapper.selectPublishedPageByIds(new Page<>(page, bounded), tenantId(), ids, type, difficulty))
|
||||
: readWithExplicitCatalogScope(() -> questionMapper.selectPublishedPage(new Page<>(page, bounded), tenantId(), nid, type, difficulty));
|
||||
: nid != null
|
||||
? readWithExplicitCatalogScope(() -> questionMapper.selectPublishedPageByAvailableNode(
|
||||
new Page<>(page, bounded), tenantId(), nid, type, difficulty))
|
||||
: readWithExplicitCatalogScope(() -> questionMapper.selectPublishedPage(
|
||||
new Page<>(page, bounded), tenantId(), type, difficulty));
|
||||
return buildPageResult(result);
|
||||
}
|
||||
|
||||
@@ -234,7 +238,8 @@ public class JavaCatalogProvider implements CatalogProvider, QuestionCatalogProv
|
||||
}
|
||||
|
||||
private long countVisibleNodeQuestions(Long nodeId, String type, String difficulty) {
|
||||
return readWithExplicitCatalogScope(() -> questionMapper.countPublished(tenantId(), nodeId, type, difficulty));
|
||||
return readWithExplicitCatalogScope(() -> questionMapper.countPublishedByAvailableNode(
|
||||
tenantId(), nodeId, type, difficulty));
|
||||
}
|
||||
|
||||
private CatalogPracticeBlueprintDTO toPracticeBlueprintDTO(PracticeBlueprintDO bp, long eligible) {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package cn.iocoder.yudao.module.education.service.question.authoring;
|
||||
|
||||
/** 将不可见的租户题目草稿放置到学生目录节点;版本用于乐观并发控制。 */
|
||||
public record QuestionPlacementCommand(Long nodeId, Integer expectedPlacementVersion) {
|
||||
}
|
||||
@@ -5,6 +5,8 @@ public interface TenantQuestionLifecycleService {
|
||||
|
||||
Long createDraft(QuestionDraftCommand command);
|
||||
|
||||
int place(Long questionId, QuestionPlacementCommand command);
|
||||
|
||||
void publish(Long questionId, Long actorId);
|
||||
|
||||
void archive(Long questionId, Long actorId);
|
||||
|
||||
@@ -3,10 +3,12 @@ package cn.iocoder.yudao.module.education.service.question.authoring;
|
||||
import cn.iocoder.yudao.framework.common.exception.ServiceException;
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
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.QuestionDO;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionLifecycleAuditDO;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionVersionDO;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.catalog.ContentNodeMapper;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionLifecycleAuditMapper;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionMapper;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionVersionMapper;
|
||||
@@ -41,19 +43,46 @@ public class TenantQuestionLifecycleServiceImpl implements TenantQuestionLifecyc
|
||||
|
||||
private final EducationProperties properties;
|
||||
private final QuestionMapper questionMapper;
|
||||
private final ContentNodeMapper contentNodeMapper;
|
||||
private final QuestionVersionMapper versionMapper;
|
||||
private final QuestionLifecycleAuditMapper auditMapper;
|
||||
|
||||
public TenantQuestionLifecycleServiceImpl(EducationProperties properties,
|
||||
QuestionMapper questionMapper,
|
||||
ContentNodeMapper contentNodeMapper,
|
||||
QuestionVersionMapper versionMapper,
|
||||
QuestionLifecycleAuditMapper auditMapper) {
|
||||
this.properties = properties;
|
||||
this.questionMapper = questionMapper;
|
||||
this.contentNodeMapper = contentNodeMapper;
|
||||
this.versionMapper = versionMapper;
|
||||
this.auditMapper = auditMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public int place(Long questionId, QuestionPlacementCommand command) {
|
||||
assertAuthoringMode();
|
||||
Long tenantId = TenantContextHolder.getRequiredTenantId();
|
||||
QuestionDO question = questionMapper.selectTenantOwnedById(tenantId, questionId);
|
||||
if (question == null) {
|
||||
throw exception(QUESTION_AUTHORING_NOT_FOUND);
|
||||
}
|
||||
if (!DRAFT.equals(question.getStatus())
|
||||
|| !command.expectedPlacementVersion().equals(question.getPlacementVersion())) {
|
||||
throw exception(QUESTION_PLACEMENT_CONFLICT);
|
||||
}
|
||||
requireAvailablePlacementTarget(tenantId, command.nodeId());
|
||||
if (command.nodeId().equals(question.getNodeId())) {
|
||||
throw exception(QUESTION_PLACEMENT_CONFLICT);
|
||||
}
|
||||
if (questionMapper.updatePlacement(tenantId, questionId, command.nodeId(),
|
||||
command.expectedPlacementVersion()) != 1) {
|
||||
throw exception(QUESTION_PLACEMENT_CONFLICT);
|
||||
}
|
||||
return command.expectedPlacementVersion() + 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long createDraft(QuestionDraftCommand command) {
|
||||
@@ -128,7 +157,8 @@ public class TenantQuestionLifecycleServiceImpl implements TenantQuestionLifecyc
|
||||
if (PUBLISHED.equals(toStatus)) {
|
||||
validatePublishable(question);
|
||||
}
|
||||
if (questionMapper.updateLifecycle(tenantId, questionId, fromStatus, toStatus, published) != 1) {
|
||||
if (questionMapper.updateLifecycle(tenantId, questionId, fromStatus, toStatus, published,
|
||||
question.getPlacementVersion()) != 1) {
|
||||
throw exception(QUESTION_LIFECYCLE_CONFLICT, operation);
|
||||
}
|
||||
|
||||
@@ -155,6 +185,18 @@ public class TenantQuestionLifecycleServiceImpl implements TenantQuestionLifecyc
|
||||
} catch (ServiceException ex) {
|
||||
throw exception(QUESTION_CONTENT_NOT_PUBLISHABLE);
|
||||
}
|
||||
if (question.getNodeId() == null) {
|
||||
throw exception(QUESTION_PLACEMENT_REQUIRED);
|
||||
}
|
||||
Long tenantId = TenantContextHolder.getRequiredTenantId();
|
||||
requireAvailablePlacementTarget(tenantId, question.getNodeId());
|
||||
}
|
||||
|
||||
private void requireAvailablePlacementTarget(Long tenantId, Long nodeId) {
|
||||
if (TenantUtils.executeIgnore(() -> contentNodeMapper.selectAvailablePlacementTarget(
|
||||
tenantId, nodeId)) == null) {
|
||||
throw exception(QUESTION_PLACEMENT_TARGET_UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateAnswerKey(String rawType, String rawAnswer,
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
-- EDU-010: make tenant Question Placement an explicit optimistic command and
|
||||
-- require a stable, available Content Node before native publication.
|
||||
|
||||
ALTER TABLE education_question
|
||||
ADD COLUMN placement_version INTEGER NOT NULL DEFAULT 0,
|
||||
ADD CONSTRAINT ck_education_question_placement_version
|
||||
CHECK (placement_version >= 0);
|
||||
|
||||
COMMENT ON COLUMN education_question.placement_version IS
|
||||
'题目目录放置的乐观锁版本;不属于不可变题目内容版本';
|
||||
|
||||
CREATE FUNCTION education_enforce_question_placement_mutation()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = pg_catalog, pg_temp
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.node_id IS NOT DISTINCT FROM OLD.node_id
|
||||
AND NEW.placement_version IS NOT DISTINCT FROM OLD.placement_version THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
IF NEW.scope <> 'TENANT_OWNED' OR NEW.tenant_id <= 0 THEN
|
||||
RAISE EXCEPTION 'PUBLIC question placement is not managed by tenant authoring'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF OLD.status <> 'DRAFT' OR NEW.status <> 'DRAFT' THEN
|
||||
RAISE EXCEPTION 'published or archived question placement is immutable'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF NEW.node_id IS NOT DISTINCT FROM OLD.node_id THEN
|
||||
RAISE EXCEPTION 'question placement version cannot change without placement'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF NEW.placement_version IS NULL
|
||||
OR NEW.placement_version <> OLD.placement_version + 1 THEN
|
||||
RAISE EXCEPTION 'question placement version must advance exactly once'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION education_enforce_question_lifecycle_transition()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog, pg_temp
|
||||
AS $$
|
||||
DECLARE
|
||||
placement_node_id BIGINT;
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
IF NEW.status <> 'DRAFT' OR NEW.is_published THEN
|
||||
RAISE EXCEPTION 'new questions must start in DRAFT'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
IF NEW.status IS DISTINCT FROM OLD.status
|
||||
AND (NEW.scope <> 'TENANT_OWNED' OR NEW.tenant_id <= 0) THEN
|
||||
RAISE EXCEPTION 'PUBLIC question lifecycle is not managed by tenant authoring'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF NEW.status IS DISTINCT FROM OLD.status AND NOT (
|
||||
(OLD.status = 'DRAFT' AND NEW.status = 'PUBLISHED') OR
|
||||
(OLD.status = 'PUBLISHED' AND NEW.status = 'ARCHIVED')
|
||||
) THEN
|
||||
RAISE EXCEPTION 'invalid question lifecycle transition from % to %', OLD.status, NEW.status
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF OLD.status = 'DRAFT' AND NEW.status = 'PUBLISHED' THEN
|
||||
IF NEW.node_id IS NULL THEN
|
||||
RAISE EXCEPTION 'question must be placed before publication'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
EXECUTE format(
|
||||
'SELECT id FROM %I.education_content_node' ||
|
||||
' WHERE id = $1 AND deleted = false AND is_active = true' ||
|
||||
' AND is_hidden = false AND is_selectable = true FOR SHARE',
|
||||
TG_TABLE_SCHEMA)
|
||||
INTO placement_node_id
|
||||
USING NEW.node_id;
|
||||
IF placement_node_id IS NULL THEN
|
||||
RAISE EXCEPTION 'question placement target is unavailable'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
END IF;
|
||||
IF NEW.status IS DISTINCT FROM OLD.status THEN
|
||||
EXECUTE format(
|
||||
'INSERT INTO %I.education_question_lifecycle_transition_token' ||
|
||||
' (transaction_id, tenant_id, question_id, content_version, from_status, to_status)' ||
|
||||
' VALUES ($1, $2, $3, $4, $5, $6)',
|
||||
TG_TABLE_SCHEMA)
|
||||
USING pg_current_xact_id()::text::BIGINT, NEW.tenant_id, NEW.id,
|
||||
NEW.content_version, OLD.status, NEW.status;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION education_enforce_question_placement_mutation() FROM PUBLIC;
|
||||
REVOKE ALL ON FUNCTION education_enforce_question_lifecycle_transition() FROM PUBLIC;
|
||||
|
||||
CREATE TRIGGER trg_education_question_placement_mutation
|
||||
BEFORE UPDATE OF node_id, placement_version ON education_question
|
||||
FOR EACH ROW EXECUTE FUNCTION education_enforce_question_placement_mutation();
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
installed_permission_count INTEGER;
|
||||
BEGIN
|
||||
IF to_regclass('system_menu') IS NULL THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
INSERT INTO system_menu (
|
||||
id, name, permission, type, sort, parent_id, path, icon, component,
|
||||
component_name, status, visible, keep_alive, always_show, creator, updater
|
||||
) VALUES
|
||||
(6805, '题目归类', 'education:question:classify', 3, 5, 6800, '', '', NULL,
|
||||
NULL, 0, false, true, true, 'education-flyway', 'education-flyway')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
SELECT count(*)
|
||||
INTO installed_permission_count
|
||||
FROM system_menu
|
||||
WHERE id = 6805
|
||||
AND permission = 'education:question:classify'
|
||||
AND type = 3
|
||||
AND parent_id = 6800
|
||||
AND status = 0
|
||||
AND deleted = 0;
|
||||
IF installed_permission_count <> 1 THEN
|
||||
RAISE EXCEPTION 'Education Question Placement RBAC seed ID conflicts with existing system_menu row'
|
||||
USING ERRCODE = '23505';
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
@@ -3,7 +3,9 @@ package cn.iocoder.yudao.module.education.controller.admin.question;
|
||||
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.question.vo.QuestionDraftCreateReqVO;
|
||||
import cn.iocoder.yudao.module.education.controller.admin.question.vo.QuestionPlacementReqVO;
|
||||
import cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand;
|
||||
import cn.iocoder.yudao.module.education.service.question.authoring.QuestionPlacementCommand;
|
||||
import cn.iocoder.yudao.module.education.service.question.authoring.TenantQuestionLifecycleService;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -32,6 +34,7 @@ class QuestionAuthoringControllerContractTest {
|
||||
|
||||
private static final Long ACTOR_ID = 7L;
|
||||
private static final String AUTHOR_PERMISSION = "education:question:author";
|
||||
private static final String CLASSIFY_PERMISSION = "education:question:classify";
|
||||
private static final String PUBLISH_PERMISSION = "education:question:publish";
|
||||
private static final String ARCHIVE_PERMISSION = "education:question:archive";
|
||||
|
||||
@@ -67,6 +70,14 @@ class QuestionAuthoringControllerContractTest {
|
||||
assertEquals(0, lifecycleService.totalCalls());
|
||||
}
|
||||
|
||||
@Test
|
||||
void authorPermissionDoesNotAllowPlacement() {
|
||||
grant(AUTHOR_PERMISSION);
|
||||
|
||||
assertThrows(AccessDeniedException.class, () -> controller.place(101L, null));
|
||||
assertEquals(0, lifecycleService.totalCalls());
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishPermissionDoesNotAllowArchiving() {
|
||||
grant(PUBLISH_PERMISSION);
|
||||
@@ -75,6 +86,14 @@ class QuestionAuthoringControllerContractTest {
|
||||
assertEquals(0, lifecycleService.totalCalls());
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyPermissionDoesNotAllowPublishing() {
|
||||
grant(CLASSIFY_PERMISSION);
|
||||
|
||||
assertThrows(AccessDeniedException.class, () -> controller.publish(101L));
|
||||
assertEquals(0, lifecycleService.totalCalls());
|
||||
}
|
||||
|
||||
@Test
|
||||
void archivePermissionDoesNotAllowDraftCreation() {
|
||||
grant(ARCHIVE_PERMISSION);
|
||||
@@ -92,6 +111,19 @@ class QuestionAuthoringControllerContractTest {
|
||||
assertEquals(1, lifecycleService.totalCalls());
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyPermissionAllowsPlacement() {
|
||||
grant(CLASSIFY_PERMISSION);
|
||||
QuestionPlacementReqVO request = new QuestionPlacementReqVO();
|
||||
request.setNodeId(201L);
|
||||
request.setExpectedPlacementVersion(0);
|
||||
|
||||
assertEquals(1, controller.place(101L, request).getData());
|
||||
assertEquals(101L, lifecycleService.placedQuestionId);
|
||||
assertEquals(new QuestionPlacementCommand(201L, 0), lifecycleService.placementCommand);
|
||||
assertEquals(1, lifecycleService.totalCalls());
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishPermissionAllowsPublishing() {
|
||||
grant(PUBLISH_PERMISSION);
|
||||
@@ -208,6 +240,8 @@ class QuestionAuthoringControllerContractTest {
|
||||
private Long publishedByActorId;
|
||||
private Long archivedQuestionId;
|
||||
private Long archivedByActorId;
|
||||
private Long placedQuestionId;
|
||||
private QuestionPlacementCommand placementCommand;
|
||||
|
||||
void reset() {
|
||||
createdCommand = null;
|
||||
@@ -215,12 +249,15 @@ class QuestionAuthoringControllerContractTest {
|
||||
publishedByActorId = null;
|
||||
archivedQuestionId = null;
|
||||
archivedByActorId = null;
|
||||
placedQuestionId = null;
|
||||
placementCommand = null;
|
||||
}
|
||||
|
||||
int totalCalls() {
|
||||
int count = createdCommand != null ? 1 : 0;
|
||||
count += publishedQuestionId != null ? 1 : 0;
|
||||
count += archivedQuestionId != null ? 1 : 0;
|
||||
count += placedQuestionId != null ? 1 : 0;
|
||||
return count;
|
||||
}
|
||||
|
||||
@@ -230,6 +267,13 @@ class QuestionAuthoringControllerContractTest {
|
||||
return 101L;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int place(Long questionId, QuestionPlacementCommand command) {
|
||||
placedQuestionId = questionId;
|
||||
placementCommand = command;
|
||||
return command.expectedPlacementVersion() + 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void publish(Long questionId, Long actorId) {
|
||||
publishedQuestionId = questionId;
|
||||
|
||||
@@ -5,8 +5,13 @@ 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.controller.app.question.vo.SafeQuestionRespVO;
|
||||
import cn.iocoder.yudao.module.education.controller.app.question.vo.QuestionPageReqVO;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionDO;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionVersionDO;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.ContentEntryDO;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.ContentNodeDO;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.catalog.ContentEntryMapper;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.catalog.ContentNodeMapper;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionLifecycleAuditMapper;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionMapper;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionVersionMapper;
|
||||
@@ -46,10 +51,19 @@ class TenantQuestionLifecyclePostgreSqlIntegrationTest extends PostgreSqlDbInteg
|
||||
@Resource private QuestionMapper questionMapper;
|
||||
@Resource private QuestionVersionMapper versionMapper;
|
||||
@Resource private PlatformTransactionManager transactionManager;
|
||||
@Resource private ContentEntryMapper contentEntryMapper;
|
||||
@Resource private ContentNodeMapper contentNodeMapper;
|
||||
|
||||
@BeforeEach
|
||||
void setUpTenant() {
|
||||
TenantContextHolder.setTenantId(10L);
|
||||
TenantUtils.executeIgnore(() -> {
|
||||
insertEntry(100L, 10L, "TENANT_OWNED", "questions", "Questions");
|
||||
insertEntry(110L, 0L, "PUBLIC", "public-questions", "Public Questions");
|
||||
insertNode(200L, 10L, "TENANT_OWNED", 100L, "Algebra");
|
||||
insertNode(201L, 10L, "TENANT_OWNED", 100L, "Geometry");
|
||||
insertNode(210L, 0L, "PUBLIC", 110L, "Public Algebra");
|
||||
});
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
@@ -66,6 +80,8 @@ class TenantQuestionLifecyclePostgreSqlIntegrationTest extends PostgreSqlDbInteg
|
||||
"A", "secret explanation", "secret analysis"));
|
||||
|
||||
assertQuestionNotVisible(id);
|
||||
assertEquals(1, lifecycleService.place(id, new QuestionPlacementCommand(200L, 0)));
|
||||
assertNodeQuestionCount(200L, 0L);
|
||||
lifecycleService.publish(id, 7L);
|
||||
|
||||
SafeQuestionRespVO published = questionCatalogService.getQuestion(String.valueOf(id));
|
||||
@@ -73,6 +89,7 @@ class TenantQuestionLifecyclePostgreSqlIntegrationTest extends PostgreSqlDbInteg
|
||||
assertEquals(2, published.getOptions().size());
|
||||
assertFalse(cn.iocoder.yudao.framework.common.util.json.JsonUtils.toJsonString(published)
|
||||
.matches(".*(correctAnswer|explanation|analysis|isCorrect).*"));
|
||||
assertNodeQuestionCount(200L, 1L);
|
||||
|
||||
lifecycleService.archive(id, 7L);
|
||||
assertQuestionNotVisible(id);
|
||||
@@ -84,6 +101,7 @@ class TenantQuestionLifecyclePostgreSqlIntegrationTest extends PostgreSqlDbInteg
|
||||
Long id = lifecycleService.createDraft(new QuestionDraftCommand(
|
||||
"Capital of France?", "text", "easy", List.of(),
|
||||
"Paris", null, null));
|
||||
lifecycleService.place(id, new QuestionPlacementCommand(200L, 0));
|
||||
|
||||
assertThrows(RuntimeException.class, () -> lifecycleService.publish(id, null));
|
||||
|
||||
@@ -95,6 +113,7 @@ class TenantQuestionLifecyclePostgreSqlIntegrationTest extends PostgreSqlDbInteg
|
||||
@Test
|
||||
void shouldAllowOnlyOneConcurrentPublishAndAppendOneAudit() throws InterruptedException {
|
||||
Long id = lifecycleService.createDraft(questionCommand("Concurrent publish"));
|
||||
lifecycleService.place(id, new QuestionPlacementCommand(200L, 0));
|
||||
CountDownLatch ready = new CountDownLatch(2);
|
||||
CountDownLatch go = new CountDownLatch(1);
|
||||
AtomicInteger successCount = new AtomicInteger();
|
||||
@@ -120,9 +139,41 @@ class TenantQuestionLifecyclePostgreSqlIntegrationTest extends PostgreSqlDbInteg
|
||||
assertEquals(1L, auditMapper.selectCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAllowOnlyOneConcurrentPlacementForExpectedVersion() throws InterruptedException {
|
||||
Long id = lifecycleService.createDraft(questionCommand("Concurrent placement"));
|
||||
CountDownLatch ready = new CountDownLatch(2);
|
||||
CountDownLatch go = new CountDownLatch(1);
|
||||
AtomicInteger successCount = new AtomicInteger();
|
||||
AtomicReference<Throwable> firstError = new AtomicReference<>();
|
||||
AtomicReference<Throwable> secondError = new AtomicReference<>();
|
||||
|
||||
Thread first = placementThread(id, 200L, ready, go, successCount, firstError);
|
||||
Thread second = placementThread(id, 201L, ready, go, successCount, secondError);
|
||||
first.start();
|
||||
second.start();
|
||||
assertTrue(ready.await(5, TimeUnit.SECONDS));
|
||||
go.countDown();
|
||||
first.join(10000);
|
||||
second.join(10000);
|
||||
|
||||
assertFalse(first.isAlive());
|
||||
assertFalse(second.isAlive());
|
||||
assertEquals(1, successCount.get());
|
||||
Throwable loserError = firstError.get() != null ? firstError.get() : secondError.get();
|
||||
ServiceException conflict = assertInstanceOf(ServiceException.class, loserError);
|
||||
assertEquals(QUESTION_PLACEMENT_CONFLICT.getCode(), conflict.getCode());
|
||||
QuestionDO stored = questionMapper.selectTenantOwnedById(10L, id);
|
||||
assertTrue(stored.getNodeId().equals(200L) || stored.getNodeId().equals(201L));
|
||||
assertEquals(1, stored.getPlacementVersion());
|
||||
assertEquals("DRAFT", stored.getStatus());
|
||||
assertEquals(0L, auditMapper.selectCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectCrossTenantPublishAndArchive() {
|
||||
Long id = lifecycleService.createDraft(questionCommand("Tenant isolated"));
|
||||
lifecycleService.place(id, new QuestionPlacementCommand(200L, 0));
|
||||
|
||||
TenantContextHolder.setTenantId(20L);
|
||||
assertAuthoringNotFound(() -> lifecycleService.publish(id, 8L));
|
||||
@@ -147,12 +198,67 @@ class TenantQuestionLifecyclePostgreSqlIntegrationTest extends PostgreSqlDbInteg
|
||||
assertEquals(0L, auditMapper.selectCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAllowTenantDraftPlacementOnPublicNode() {
|
||||
Long id = lifecycleService.createDraft(questionCommand("Public classification"));
|
||||
|
||||
assertEquals(1, lifecycleService.place(id, new QuestionPlacementCommand(210L, 0)));
|
||||
lifecycleService.publish(id, 7L);
|
||||
|
||||
assertNodeQuestionCount(210L, 1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHidePublishedQuestionsWhenPlacementNodeBecomesUnavailable() {
|
||||
Long id = lifecycleService.createDraft(questionCommand("Hidden route"));
|
||||
lifecycleService.place(id, new QuestionPlacementCommand(200L, 0));
|
||||
lifecycleService.publish(id, 7L);
|
||||
assertNodeQuestionCount(200L, 1L);
|
||||
|
||||
ContentNodeDO update = new ContentNodeDO();
|
||||
update.setId(200L);
|
||||
update.setIsActive(false);
|
||||
contentNodeMapper.updateById(update);
|
||||
|
||||
assertNodeQuestionCount(200L, 0L);
|
||||
assertNotNull(questionCatalogService.getQuestion(String.valueOf(id)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectCrossTenantPlacementWithoutPartialWrite() {
|
||||
Long id = lifecycleService.createDraft(questionCommand("Tenant placement"));
|
||||
TenantUtils.executeIgnore(() -> {
|
||||
insertEntry(120L, 20L, "TENANT_OWNED", "other-questions", "Other Questions");
|
||||
insertNode(220L, 20L, "TENANT_OWNED", 120L, "Other Algebra");
|
||||
});
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> lifecycleService.place(id, new QuestionPlacementCommand(220L, 0)));
|
||||
|
||||
assertEquals(QUESTION_PLACEMENT_TARGET_UNAVAILABLE.getCode(), ex.getCode());
|
||||
QuestionDO stored = questionMapper.selectTenantOwnedById(10L, id);
|
||||
assertNull(stored.getNodeId());
|
||||
assertEquals(0, stored.getPlacementVersion());
|
||||
assertEquals(1L, versionMapper.selectCount());
|
||||
assertEquals(0L, auditMapper.selectCount());
|
||||
}
|
||||
|
||||
private void assertQuestionNotVisible(Long id) {
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> questionCatalogService.getQuestion(String.valueOf(id)));
|
||||
assertEquals(QUESTION_NOT_FOUND.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
private void assertNodeQuestionCount(Long nodeId, long expected) {
|
||||
QuestionPageReqVO request = new QuestionPageReqVO();
|
||||
request.setNodeId(String.valueOf(nodeId));
|
||||
var page = questionCatalogService.pageQuestions(request);
|
||||
assertEquals(expected, page.getTotal());
|
||||
assertEquals(expected, page.getList().size());
|
||||
assertFalse(cn.iocoder.yudao.framework.common.util.json.JsonUtils.toJsonString(page.getList())
|
||||
.matches(".*(correctAnswer|explanation|analysis|isCorrect).*"));
|
||||
}
|
||||
|
||||
private void assertAuthoringNotFound(org.junit.jupiter.api.function.Executable executable) {
|
||||
ServiceException ex = assertThrows(ServiceException.class, executable);
|
||||
assertEquals(QUESTION_AUTHORING_NOT_FOUND.getCode(), ex.getCode());
|
||||
@@ -175,6 +281,23 @@ class TenantQuestionLifecyclePostgreSqlIntegrationTest extends PostgreSqlDbInteg
|
||||
});
|
||||
}
|
||||
|
||||
private Thread placementThread(Long questionId, Long nodeId, CountDownLatch ready, CountDownLatch go,
|
||||
AtomicInteger successCount, AtomicReference<Throwable> error) {
|
||||
return new Thread(() -> {
|
||||
TenantContextHolder.setTenantId(10L);
|
||||
ready.countDown();
|
||||
try {
|
||||
assertTrue(go.await(5, TimeUnit.SECONDS));
|
||||
lifecycleService.place(questionId, new QuestionPlacementCommand(nodeId, 0));
|
||||
successCount.incrementAndGet();
|
||||
} catch (Throwable throwable) {
|
||||
error.set(throwable);
|
||||
} finally {
|
||||
TenantContextHolder.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private Long createPublicDraft() {
|
||||
return TenantUtils.executeIgnore(() -> new TransactionTemplate(transactionManager).execute(status -> {
|
||||
QuestionDO question = new QuestionDO();
|
||||
@@ -207,4 +330,26 @@ class TenantQuestionLifecyclePostgreSqlIntegrationTest extends PostgreSqlDbInteg
|
||||
private QuestionDraftCommand questionCommand(String stem) {
|
||||
return new QuestionDraftCommand(stem, "text", "easy", List.of(), "answer", null, null);
|
||||
}
|
||||
|
||||
private void insertEntry(Long id, Long tenantId, String scope, String key, String name) {
|
||||
ContentEntryDO entry = new ContentEntryDO();
|
||||
entry.setId(id);
|
||||
entry.setTenantId(tenantId);
|
||||
entry.setScope(scope);
|
||||
entry.setEntryKey(key);
|
||||
entry.setName(name);
|
||||
entry.setEntryType("question");
|
||||
contentEntryMapper.insert(entry);
|
||||
}
|
||||
|
||||
private void insertNode(Long id, Long tenantId, String scope, Long entryId, String name) {
|
||||
ContentNodeDO node = new ContentNodeDO();
|
||||
node.setId(id);
|
||||
node.setTenantId(tenantId);
|
||||
node.setScope(scope);
|
||||
node.setEntryId(entryId);
|
||||
node.setName(name);
|
||||
node.setNodeType("category");
|
||||
contentNodeMapper.insert(node);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ package cn.iocoder.yudao.module.education.service.question.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.ContentNodeDO;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionDO;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.catalog.ContentNodeMapper;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionLifecycleAuditMapper;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionMapper;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionVersionMapper;
|
||||
@@ -31,6 +33,7 @@ import static org.mockito.Mockito.*;
|
||||
class TenantQuestionLifecycleServiceImplTest {
|
||||
|
||||
@Mock private QuestionMapper questionMapper;
|
||||
@Mock private ContentNodeMapper contentNodeMapper;
|
||||
@Mock private QuestionVersionMapper versionMapper;
|
||||
@Mock private QuestionLifecycleAuditMapper auditMapper;
|
||||
|
||||
@@ -40,7 +43,8 @@ class TenantQuestionLifecycleServiceImplTest {
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
properties = new EducationProperties();
|
||||
service = new TenantQuestionLifecycleServiceImpl(properties, questionMapper, versionMapper, auditMapper);
|
||||
service = new TenantQuestionLifecycleServiceImpl(properties, questionMapper, contentNodeMapper,
|
||||
versionMapper, auditMapper);
|
||||
TenantContextHolder.setTenantId(10L);
|
||||
}
|
||||
|
||||
@@ -56,7 +60,18 @@ class TenantQuestionLifecycleServiceImplTest {
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> service.publish(1L, 7L));
|
||||
|
||||
assertEquals(QUESTION_AUTHORING_PROVIDER_UNSUPPORTED.getCode(), ex.getCode());
|
||||
verifyNoInteractions(questionMapper, versionMapper, auditMapper);
|
||||
verifyNoInteractions(questionMapper, contentNodeMapper, versionMapper, auditMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailPlacementBeforeDatabaseAccessInScalarMode() {
|
||||
properties.setCatalogMode(CatalogProviderMode.SCALAR_READ);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> service.place(101L, new QuestionPlacementCommand(201L, 0)));
|
||||
|
||||
assertEquals(QUESTION_AUTHORING_PROVIDER_UNSUPPORTED.getCode(), ex.getCode());
|
||||
verifyNoInteractions(questionMapper, contentNodeMapper, versionMapper, auditMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -95,7 +110,8 @@ class TenantQuestionLifecycleServiceImplTest {
|
||||
void shouldPublishWithCasAndAppendAudit() {
|
||||
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
|
||||
when(questionMapper.selectTenantOwnedById(10L, 101L)).thenReturn(draft());
|
||||
when(questionMapper.updateLifecycle(10L, 101L, "DRAFT", "PUBLISHED", true)).thenReturn(1);
|
||||
when(contentNodeMapper.selectAvailablePlacementTarget(10L, 201L)).thenReturn(availableNode());
|
||||
when(questionMapper.updateLifecycle(10L, 101L, "DRAFT", "PUBLISHED", true, 1)).thenReturn(1);
|
||||
|
||||
service.publish(101L, 7L);
|
||||
|
||||
@@ -110,6 +126,82 @@ class TenantQuestionLifecycleServiceImplTest {
|
||||
assertEquals("PUBLISHED", auditCaptor.getValue().getToStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPlaceDraftOnAvailableNodeWithCas() {
|
||||
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
|
||||
ContentNodeDO node = new ContentNodeDO();
|
||||
node.setId(201L);
|
||||
node.setTenantId(10L);
|
||||
node.setScope("TENANT_OWNED");
|
||||
node.setIsActive(true);
|
||||
node.setIsHidden(false);
|
||||
node.setIsSelectable(true);
|
||||
QuestionDO draft = draft();
|
||||
draft.setNodeId(null);
|
||||
draft.setPlacementVersion(0);
|
||||
when(questionMapper.selectTenantOwnedById(10L, 101L)).thenReturn(draft);
|
||||
when(contentNodeMapper.selectAvailablePlacementTarget(10L, 201L)).thenReturn(node);
|
||||
when(questionMapper.updatePlacement(10L, 101L, 201L, 0)).thenReturn(1);
|
||||
|
||||
int placementVersion = service.place(101L, new QuestionPlacementCommand(201L, 0));
|
||||
|
||||
assertEquals(1, placementVersion);
|
||||
verify(questionMapper).updatePlacement(10L, 101L, 201L, 0);
|
||||
verifyNoInteractions(versionMapper, auditMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectUnavailablePlacementTarget() {
|
||||
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
|
||||
QuestionDO draft = draft();
|
||||
when(questionMapper.selectTenantOwnedById(10L, 101L)).thenReturn(draft);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> service.place(101L, new QuestionPlacementCommand(999L, 1)));
|
||||
|
||||
assertEquals(QUESTION_PLACEMENT_TARGET_UNAVAILABLE.getCode(), ex.getCode());
|
||||
verify(questionMapper, never()).updatePlacement(any(), any(), any(), anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectStalePlacementVersionBeforeTargetLookup() {
|
||||
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
|
||||
when(questionMapper.selectTenantOwnedById(10L, 101L)).thenReturn(draft());
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> service.place(101L, new QuestionPlacementCommand(202L, 0)));
|
||||
|
||||
assertEquals(QUESTION_PLACEMENT_CONFLICT.getCode(), ex.getCode());
|
||||
verifyNoInteractions(contentNodeMapper);
|
||||
verify(questionMapper, never()).updatePlacement(any(), any(), any(), anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectPlacementWhenCasLoses() {
|
||||
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
|
||||
when(questionMapper.selectTenantOwnedById(10L, 101L)).thenReturn(draft());
|
||||
when(contentNodeMapper.selectAvailablePlacementTarget(10L, 202L)).thenReturn(availableNode());
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> service.place(101L, new QuestionPlacementCommand(202L, 1)));
|
||||
|
||||
assertEquals(QUESTION_PLACEMENT_CONFLICT.getCode(), ex.getCode());
|
||||
verify(questionMapper).updatePlacement(10L, 101L, 202L, 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectPlacementOnCurrentNodeWithoutVersionChange() {
|
||||
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
|
||||
when(questionMapper.selectTenantOwnedById(10L, 101L)).thenReturn(draft());
|
||||
when(contentNodeMapper.selectAvailablePlacementTarget(10L, 201L)).thenReturn(availableNode());
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> service.place(101L, new QuestionPlacementCommand(201L, 1)));
|
||||
|
||||
assertEquals(QUESTION_PLACEMENT_CONFLICT.getCode(), ex.getCode());
|
||||
verify(questionMapper, never()).updatePlacement(any(), any(), any(), anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPreserveRequestOrderWhenOptionOrderIsOmitted() {
|
||||
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
|
||||
@@ -141,7 +233,7 @@ class TenantQuestionLifecycleServiceImplTest {
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> service.publish(101L, 7L));
|
||||
|
||||
assertEquals(QUESTION_CONTENT_NOT_PUBLISHABLE.getCode(), ex.getCode());
|
||||
verify(questionMapper, never()).updateLifecycle(any(), any(), any(), any(), anyBoolean());
|
||||
verify(questionMapper, never()).updateLifecycle(any(), any(), any(), any(), anyBoolean(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -154,7 +246,34 @@ class TenantQuestionLifecycleServiceImplTest {
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> service.publish(101L, 7L));
|
||||
|
||||
assertEquals(QUESTION_CONTENT_NOT_PUBLISHABLE.getCode(), ex.getCode());
|
||||
verify(questionMapper, never()).updateLifecycle(any(), any(), any(), any(), anyBoolean());
|
||||
verify(questionMapper, never()).updateLifecycle(any(), any(), any(), any(), anyBoolean(), any());
|
||||
verifyNoInteractions(auditMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectPublishingUnplacedDraft() {
|
||||
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
|
||||
QuestionDO draft = draft();
|
||||
draft.setPlacementVersion(0);
|
||||
draft.setNodeId(null);
|
||||
when(questionMapper.selectTenantOwnedById(10L, 101L)).thenReturn(draft);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> service.publish(101L, 7L));
|
||||
|
||||
assertEquals(QUESTION_PLACEMENT_REQUIRED.getCode(), ex.getCode());
|
||||
verify(questionMapper, never()).updateLifecycle(any(), any(), any(), any(), anyBoolean(), any());
|
||||
verifyNoInteractions(contentNodeMapper, auditMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectPublishingWhenPlacementTargetBecameUnavailable() {
|
||||
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
|
||||
when(questionMapper.selectTenantOwnedById(10L, 101L)).thenReturn(draft());
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> service.publish(101L, 7L));
|
||||
|
||||
assertEquals(QUESTION_PLACEMENT_TARGET_UNAVAILABLE.getCode(), ex.getCode());
|
||||
verify(questionMapper, never()).updateLifecycle(any(), any(), any(), any(), anyBoolean(), any());
|
||||
verifyNoInteractions(auditMapper);
|
||||
}
|
||||
|
||||
@@ -176,7 +295,7 @@ class TenantQuestionLifecycleServiceImplTest {
|
||||
});
|
||||
|
||||
assertEquals(QUESTION_LIFECYCLE_CONFLICT.getCode(), ex.getCode());
|
||||
verify(questionMapper, never()).updateLifecycle(any(), any(), any(), any(), anyBoolean());
|
||||
verify(questionMapper, never()).updateLifecycle(any(), any(), any(), any(), anyBoolean(), any());
|
||||
verifyNoInteractions(auditMapper);
|
||||
}
|
||||
|
||||
@@ -206,8 +325,21 @@ class TenantQuestionLifecycleServiceImplTest {
|
||||
question.setOptions("[{\"label\":\"A\",\"content\":\"4\",\"order\":1}," +
|
||||
"{\"label\":\"B\",\"content\":\"5\",\"order\":2}]");
|
||||
question.setCorrectAnswer("A");
|
||||
question.setNodeId(201L);
|
||||
question.setPlacementVersion(1);
|
||||
question.setStatus("DRAFT");
|
||||
question.setIsPublished(false);
|
||||
return question;
|
||||
}
|
||||
|
||||
private ContentNodeDO availableNode() {
|
||||
ContentNodeDO node = new ContentNodeDO();
|
||||
node.setId(201L);
|
||||
node.setTenantId(10L);
|
||||
node.setScope("TENANT_OWNED");
|
||||
node.setIsActive(true);
|
||||
node.setIsHidden(false);
|
||||
node.setIsSelectable(true);
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ class EducationFlywayMigrationIntegrationTest {
|
||||
|
||||
assertThat(queryStrings(schema,
|
||||
"SELECT COALESCE(version, 'BASELINE') FROM flyway_schema_history ORDER BY installed_rank"))
|
||||
.containsExactly("4009", "4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080");
|
||||
.containsExactly("4009", "4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080", "4090");
|
||||
assertThat(queryLong(schema,
|
||||
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = current_schema() " +
|
||||
"AND table_name = 'education_idempotency'"))
|
||||
@@ -181,7 +181,7 @@ class EducationFlywayMigrationIntegrationTest {
|
||||
|
||||
assertThat(queryStrings(schema,
|
||||
"SELECT version FROM flyway_schema_history WHERE success = TRUE ORDER BY installed_rank"))
|
||||
.containsExactly("4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080");
|
||||
.containsExactly("4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080", "4090");
|
||||
assertThat(queryStrings(schema,
|
||||
"SELECT table_name FROM information_schema.tables " +
|
||||
"WHERE table_schema = current_schema() AND table_name IN (" +
|
||||
@@ -264,7 +264,7 @@ class EducationFlywayMigrationIntegrationTest {
|
||||
@Test
|
||||
void shouldCreateDraftFirstQuestionLifecycleSchema() throws SQLException {
|
||||
String schema = createSchema("question_lifecycle");
|
||||
configureFlyway(schema, false).load().migrate();
|
||||
configureFlyway(schema, false).target("4080").load().migrate();
|
||||
|
||||
assertThat(queryStrings(schema, """
|
||||
SELECT column_default
|
||||
@@ -386,6 +386,85 @@ class EducationFlywayMigrationIntegrationTest {
|
||||
.hasMessageContaining("question version ownership must equal question ownership");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCreateQuestionPlacementSchemaAndFreezePublishedPlacement() throws SQLException {
|
||||
String schema = createSchema("question_placement");
|
||||
configureFlyway(schema, false).load().migrate();
|
||||
|
||||
assertThat(queryStrings(schema, """
|
||||
SELECT column_default || ':' || is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'education_question'
|
||||
AND column_name = 'placement_version'
|
||||
"""))
|
||||
.containsExactly("0:NO");
|
||||
|
||||
execute(schema, """
|
||||
INSERT INTO education_content_entry
|
||||
(id, tenant_id, scope, entry_key, name, entry_type)
|
||||
VALUES (100, 10, 'TENANT_OWNED', 'questions', 'Questions', 'question');
|
||||
INSERT INTO education_content_node
|
||||
(id, tenant_id, scope, entry_id, name, node_type)
|
||||
VALUES (200, 10, 'TENANT_OWNED', 100, 'Algebra', 'category'),
|
||||
(201, 10, 'TENANT_OWNED', 100, 'Geometry', 'category');
|
||||
INSERT INTO education_question
|
||||
(id, tenant_id, scope, stem, type, options, correct_answer)
|
||||
VALUES (300, 10, 'TENANT_OWNED', 'Placed draft', 'text', '[]', 'answer');
|
||||
INSERT INTO education_question_version
|
||||
(tenant_id, scope, question_id, version_number, stem, type, options, correct_answer)
|
||||
VALUES (10, 'TENANT_OWNED', 300, 1, 'Placed draft', 'text', '[]', 'answer');
|
||||
INSERT INTO education_content_entry
|
||||
(id, tenant_id, scope, entry_key, name, entry_type)
|
||||
VALUES (110, 0, 'PUBLIC', 'public-questions', 'Public Questions', 'question');
|
||||
INSERT INTO education_content_node
|
||||
(id, tenant_id, scope, entry_id, name, node_type)
|
||||
VALUES (210, 0, 'PUBLIC', 110, 'Public Algebra', 'category'),
|
||||
(211, 0, 'PUBLIC', 110, 'Public Geometry', 'category');
|
||||
INSERT INTO education_question
|
||||
(id, tenant_id, scope, stem, type, options, correct_answer, node_id)
|
||||
VALUES (310, 0, 'PUBLIC', 'Public draft', 'text', '[]', 'answer', 210);
|
||||
INSERT INTO education_question_version
|
||||
(tenant_id, scope, question_id, version_number, stem, type, options, correct_answer)
|
||||
VALUES (0, 'PUBLIC', 310, 1, 'Public draft', 'text', '[]', 'answer');
|
||||
""");
|
||||
|
||||
assertThatThrownBy(() -> execute(schema, """
|
||||
UPDATE education_question
|
||||
SET node_id = 211, placement_version = 1
|
||||
WHERE id = 310;
|
||||
"""))
|
||||
.hasMessageContaining("PUBLIC question placement is not managed by tenant authoring");
|
||||
|
||||
assertThatThrownBy(() -> execute(schema, """
|
||||
UPDATE education_question SET node_id = 200 WHERE id = 300;
|
||||
"""))
|
||||
.hasMessageContaining("question placement version must advance exactly once");
|
||||
|
||||
execute(schema, """
|
||||
UPDATE education_question
|
||||
SET node_id = 200, placement_version = 1
|
||||
WHERE id = 300;
|
||||
DO $publish$
|
||||
BEGIN
|
||||
UPDATE education_question
|
||||
SET status = 'PUBLISHED', is_published = true
|
||||
WHERE id = 300;
|
||||
INSERT INTO education_question_lifecycle_audit
|
||||
(tenant_id, question_id, content_version, actor_id, from_status, to_status)
|
||||
VALUES (10, 300, 1, 7, 'DRAFT', 'PUBLISHED');
|
||||
END
|
||||
$publish$;
|
||||
""");
|
||||
|
||||
assertThatThrownBy(() -> execute(schema, """
|
||||
UPDATE education_question
|
||||
SET node_id = 201, placement_version = 2
|
||||
WHERE id = 300;
|
||||
"""))
|
||||
.hasMessageContaining("published or archived question placement is immutable");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNormalizeHistoricalQuestionVisibilityIntoLifecycleStates() throws SQLException {
|
||||
String schema = createSchema("question_history");
|
||||
@@ -490,14 +569,15 @@ class EducationFlywayMigrationIntegrationTest {
|
||||
assertThat(queryStrings(schema, """
|
||||
SELECT permission
|
||||
FROM system_menu
|
||||
WHERE id BETWEEN 6801 AND 6804
|
||||
WHERE id BETWEEN 6801 AND 6805
|
||||
ORDER BY id
|
||||
"""))
|
||||
.containsExactly(
|
||||
"education:capability",
|
||||
"education:question:author",
|
||||
"education:question:publish",
|
||||
"education:question:archive");
|
||||
"education:question:archive",
|
||||
"education:question:classify");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -516,6 +596,23 @@ class EducationFlywayMigrationIntegrationTest {
|
||||
.hasMessageContaining("Education System RBAC seed IDs conflict");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailClosedWhenQuestionPlacementPermissionSeedConflicts() throws SQLException {
|
||||
String schema = createSchema("question_placement_permission_conflict");
|
||||
configureFlyway(schema, false).target("4070").load().migrate();
|
||||
createSystemMenuFixture(schema);
|
||||
configureFlyway(schema, false).target("4080").load().migrate();
|
||||
execute(schema, """
|
||||
INSERT INTO system_menu
|
||||
(id, name, permission, type, sort, parent_id, status, deleted)
|
||||
VALUES (6805, 'Conflicting placement permission', 'education:question:other',
|
||||
3, 5, 6800, 0, 0);
|
||||
""");
|
||||
|
||||
assertThatThrownBy(() -> configureFlyway(schema, false).load().migrate())
|
||||
.hasMessageContaining("Education Question Placement RBAC seed ID conflicts");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAttachGraphGuardToEveryCatalogReference() throws SQLException {
|
||||
String schema = createSchema("catalog_triggers");
|
||||
@@ -606,6 +703,7 @@ class EducationFlywayMigrationIntegrationTest {
|
||||
'education_require_question_lifecycle_audit',
|
||||
'education_check_question_version_ownership',
|
||||
'education_validate_question_lifecycle_audit_insert',
|
||||
'education_enforce_question_placement_mutation',
|
||||
'education_question_answer_key_is_valid')
|
||||
AND grantee = 'PUBLIC'
|
||||
AND privilege_type = 'EXECUTE'
|
||||
|
||||
@@ -12,5 +12,7 @@ TRUNCATE TABLE
|
||||
education_practice_report_detail,
|
||||
education_practice_report,
|
||||
education_practice_question,
|
||||
education_practice_session
|
||||
education_practice_session,
|
||||
education_content_node,
|
||||
education_content_entry
|
||||
RESTART IDENTITY CASCADE;
|
||||
|
||||
Reference in New Issue
Block a user