feat(education): add tenant question placement

This commit is contained in:
2026-07-30 22:28:08 +08:00
parent 34bc1fe41e
commit 2a40cbd69e
21 changed files with 813 additions and 44 deletions

View File

@@ -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')")

View File

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

View File

@@ -67,6 +67,9 @@ public class QuestionDO extends CatalogScopeDO {
/** 所属内容节点 ID */
private Long nodeId;
/** 目录放置的乐观锁版本;不属于题目内容版本 */
private Integer placementVersion;
/** 标签 JSON 数组 */
private String tags;

View File

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

View File

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

View File

@@ -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, "题目尚未归类,无法发布");
}

View File

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

View File

@@ -0,0 +1,5 @@
package cn.iocoder.yudao.module.education.service.question.authoring;
/** 将不可见的租户题目草稿放置到学生目录节点;版本用于乐观并发控制。 */
public record QuestionPlacementCommand(Long nodeId, Integer expectedPlacementVersion) {
}

View File

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

View File

@@ -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,

View File

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