forked from wangziqi/ruoyi-vue-pro
feat(education): add native question publication lifecycle
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
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.service.question.authoring.QuestionDraftCommand;
|
||||
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;
|
||||
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 java.util.List;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
|
||||
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
|
||||
|
||||
@Tag(name = "管理后台 - 题目创作与发布")
|
||||
@RestController
|
||||
@RequestMapping("/education/questions")
|
||||
@Validated
|
||||
@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true")
|
||||
public class QuestionAuthoringController {
|
||||
|
||||
private final TenantQuestionLifecycleService lifecycleService;
|
||||
|
||||
public QuestionAuthoringController(TenantQuestionLifecycleService lifecycleService) {
|
||||
this.lifecycleService = lifecycleService;
|
||||
}
|
||||
|
||||
@PostMapping("/drafts")
|
||||
@Operation(summary = "创建租户题目草稿")
|
||||
@PreAuthorize("@ss.hasPermission('education:question:author')")
|
||||
public CommonResult<Long> createDraft(@Valid @RequestBody QuestionDraftCreateReqVO reqVO) {
|
||||
List<QuestionDraftCommand.QuestionDraftOption> options = reqVO.getOptions().stream()
|
||||
.map(option -> new QuestionDraftCommand.QuestionDraftOption(
|
||||
option.getLabel(), option.getContent(), option.getOrder()))
|
||||
.toList();
|
||||
QuestionDraftCommand command = new QuestionDraftCommand(
|
||||
reqVO.getStem(), reqVO.getType(), reqVO.getDifficulty(), options,
|
||||
reqVO.getCorrectAnswer(), reqVO.getExplanation(), reqVO.getAnalysis());
|
||||
return success(lifecycleService.createDraft(command));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/publish")
|
||||
@Operation(summary = "发布租户题目")
|
||||
@PreAuthorize("@ss.hasPermission('education:question:publish')")
|
||||
public CommonResult<Boolean> publish(@PathVariable("id") Long id) {
|
||||
lifecycleService.publish(id, getLoginUserId());
|
||||
return success(true);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/archive")
|
||||
@Operation(summary = "归档租户题目")
|
||||
@PreAuthorize("@ss.hasPermission('education:question:archive')")
|
||||
public CommonResult<Boolean> archive(@PathVariable("id") Long id) {
|
||||
lifecycleService.archive(id, getLoginUserId());
|
||||
return success(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package cn.iocoder.yudao.module.education.controller.admin.question.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Schema(description = "管理后台 - 创建题目草稿 Request VO")
|
||||
@Data
|
||||
public class QuestionDraftCreateReqVO {
|
||||
|
||||
@Schema(description = "题干", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank
|
||||
@Size(max = 20000)
|
||||
private String stem;
|
||||
|
||||
@Schema(description = "题型", requiredMode = Schema.RequiredMode.REQUIRED, example = "choice")
|
||||
@NotBlank
|
||||
@Size(max = 32)
|
||||
private String type;
|
||||
|
||||
@Schema(description = "难度", example = "easy")
|
||||
@Size(max = 32)
|
||||
private String difficulty;
|
||||
|
||||
@Schema(description = "学生可见选项;不得包含正确性标记", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotNull
|
||||
@Valid
|
||||
private List<@NotNull Option> options;
|
||||
|
||||
@Schema(description = "正确答案;仅服务端使用")
|
||||
@Size(max = 500)
|
||||
private String correctAnswer;
|
||||
|
||||
@Schema(description = "答案解析;仅服务端使用")
|
||||
private String explanation;
|
||||
|
||||
@Schema(description = "深度分析;仅服务端使用")
|
||||
private String analysis;
|
||||
|
||||
@Data
|
||||
public static class Option {
|
||||
|
||||
@NotBlank
|
||||
@Size(max = 32)
|
||||
private String label;
|
||||
|
||||
@NotBlank
|
||||
@Size(max = 10000)
|
||||
private String content;
|
||||
|
||||
private Double order;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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_question_lifecycle_audit")
|
||||
@KeySequence("education_question_lifecycle_audit_seq")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class QuestionLifecycleAuditDO extends TenantBaseDO {
|
||||
|
||||
@TableId
|
||||
private Long id;
|
||||
private Long questionId;
|
||||
private Integer contentVersion;
|
||||
private Long actorId;
|
||||
private String fromStatus;
|
||||
private String toStatus;
|
||||
private LocalDateTime occurredAt;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package cn.iocoder.yudao.module.education.dal.dataobject.catalog;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/** 题目不可变内容版本。 */
|
||||
@TableName(value = "education_question_version", autoResultMap = true)
|
||||
@KeySequence("education_question_version_seq")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class QuestionVersionDO extends CatalogScopeDO {
|
||||
|
||||
@TableId
|
||||
private Long id;
|
||||
private Long questionId;
|
||||
private Integer versionNumber;
|
||||
private String stem;
|
||||
private String type;
|
||||
private String typeLabel;
|
||||
private String difficulty;
|
||||
private String questionContent;
|
||||
private String options;
|
||||
private String correctAnswer;
|
||||
private String explanation;
|
||||
private String analysis;
|
||||
}
|
||||
@@ -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.QuestionLifecycleAuditDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface QuestionLifecycleAuditMapper extends BaseMapperX<QuestionLifecycleAuditDO> {
|
||||
}
|
||||
@@ -4,6 +4,7 @@ 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.QuestionDO;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
@@ -72,6 +73,25 @@ public interface QuestionMapper extends BaseMapperX<QuestionDO> {
|
||||
default QuestionDO selectPublishedById(Long tenantId, Long id) {
|
||||
return selectOne(visible(tenantId).eq(QuestionDO::getId, id));
|
||||
}
|
||||
|
||||
default QuestionDO selectTenantOwnedById(Long tenantId, Long id) {
|
||||
return selectOne(new LambdaQueryWrapperX<QuestionDO>()
|
||||
.eq(QuestionDO::getId, id)
|
||||
.eq(QuestionDO::getTenantId, tenantId)
|
||||
.eq(QuestionDO::getScope, "TENANT_OWNED"));
|
||||
}
|
||||
|
||||
default int updateLifecycle(Long tenantId, Long id, String expectedStatus,
|
||||
String targetStatus, boolean published) {
|
||||
return update(null, new LambdaUpdateWrapper<QuestionDO>()
|
||||
.eq(QuestionDO::getId, id)
|
||||
.eq(QuestionDO::getTenantId, tenantId)
|
||||
.eq(QuestionDO::getScope, "TENANT_OWNED")
|
||||
.eq(QuestionDO::getStatus, expectedStatus)
|
||||
.set(QuestionDO::getStatus, targetStatus)
|
||||
.set(QuestionDO::getIsPublished, published));
|
||||
}
|
||||
|
||||
private LambdaQueryWrapperX<QuestionDO> visible(Long tenantId) {
|
||||
LambdaQueryWrapperX<QuestionDO> w = new LambdaQueryWrapperX<>();
|
||||
CatalogScopeQuery.apply(w, QuestionDO::getTenantId, QuestionDO::getScope, tenantId);
|
||||
|
||||
@@ -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.QuestionVersionDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface QuestionVersionMapper extends BaseMapperX<QuestionVersionDO> {
|
||||
}
|
||||
@@ -97,4 +97,11 @@ public interface ErrorCodeConstants {
|
||||
ErrorCode FAVORITE_TARGET_TYPE_INVALID = new ErrorCode(1_005_003_061, "不支持的收藏目标类型:{}");
|
||||
ErrorCode FAVORITE_ALREADY_EXISTS = new ErrorCode(1_005_003_062, "已收藏,无需重复操作");
|
||||
ErrorCode FAVORITE_NOT_FOUND = new ErrorCode(1_005_003_063, "收藏记录不存在");
|
||||
|
||||
// ========== 题目创作与发布 1-005-003-070 ~ 1-005-003-079 ==========
|
||||
ErrorCode QUESTION_AUTHORING_PROVIDER_UNSUPPORTED = new ErrorCode(1_005_003_070,
|
||||
"当前题库数据源模式不支持本地内容创作:{}");
|
||||
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, "题目内容不完整或不安全,无法发布");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package cn.iocoder.yudao.module.education.service.question.authoring;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 创建租户题目草稿的领域命令;归属和状态由服务端决定。 */
|
||||
public record QuestionDraftCommand(
|
||||
String stem,
|
||||
String type,
|
||||
String difficulty,
|
||||
List<QuestionDraftOption> options,
|
||||
String correctAnswer,
|
||||
String explanation,
|
||||
String analysis) {
|
||||
|
||||
public record QuestionDraftOption(String label, String content, Double order) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package cn.iocoder.yudao.module.education.service.question.authoring;
|
||||
|
||||
/** 租户自有题目的显式发布生命周期。 */
|
||||
public interface TenantQuestionLifecycleService {
|
||||
|
||||
Long createDraft(QuestionDraftCommand command);
|
||||
|
||||
void publish(Long questionId, Long actorId);
|
||||
|
||||
void archive(Long questionId, Long actorId);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
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.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.QuestionLifecycleAuditMapper;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionMapper;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionVersionMapper;
|
||||
import cn.iocoder.yudao.module.education.enums.CatalogProviderMode;
|
||||
import cn.iocoder.yudao.module.education.service.question.QuestionContentSafety;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
|
||||
|
||||
/**
|
||||
* JAVA_READ 本地题目的创作与发布深模块。
|
||||
*
|
||||
* <p>当前只开放单向 DRAFT → PUBLISHED → ARCHIVED,不提供通用状态更新。</p>
|
||||
*/
|
||||
@Service
|
||||
public class TenantQuestionLifecycleServiceImpl implements TenantQuestionLifecycleService {
|
||||
|
||||
private static final String TENANT_OWNED = "TENANT_OWNED";
|
||||
private static final String DRAFT = "DRAFT";
|
||||
private static final String PUBLISHED = "PUBLISHED";
|
||||
private static final String ARCHIVED = "ARCHIVED";
|
||||
|
||||
private final EducationProperties properties;
|
||||
private final QuestionMapper questionMapper;
|
||||
private final QuestionVersionMapper versionMapper;
|
||||
private final QuestionLifecycleAuditMapper auditMapper;
|
||||
|
||||
public TenantQuestionLifecycleServiceImpl(EducationProperties properties,
|
||||
QuestionMapper questionMapper,
|
||||
QuestionVersionMapper versionMapper,
|
||||
QuestionLifecycleAuditMapper auditMapper) {
|
||||
this.properties = properties;
|
||||
this.questionMapper = questionMapper;
|
||||
this.versionMapper = versionMapper;
|
||||
this.auditMapper = auditMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long createDraft(QuestionDraftCommand command) {
|
||||
assertAuthoringMode();
|
||||
Long tenantId = TenantContextHolder.getRequiredTenantId();
|
||||
List<QuestionDraftCommand.QuestionDraftOption> suppliedOptions = command.options() != null
|
||||
? command.options() : List.of();
|
||||
List<QuestionDraftCommand.QuestionDraftOption> options = IntStream.range(0, suppliedOptions.size())
|
||||
.mapToObj(index -> {
|
||||
QuestionDraftCommand.QuestionDraftOption option = suppliedOptions.get(index);
|
||||
return new QuestionDraftCommand.QuestionDraftOption(
|
||||
option.label(), option.content(),
|
||||
option.order() != null ? option.order() : index + 1D);
|
||||
})
|
||||
.toList();
|
||||
|
||||
QuestionDO question = new QuestionDO();
|
||||
question.setTenantId(tenantId);
|
||||
question.setScope(TENANT_OWNED);
|
||||
question.setContentVersion(1);
|
||||
question.setStem(command.stem());
|
||||
question.setType(command.type());
|
||||
question.setDifficulty(command.difficulty());
|
||||
question.setOptions(JsonUtils.toJsonString(options));
|
||||
question.setCorrectAnswer(command.correctAnswer());
|
||||
question.setExplanation(command.explanation());
|
||||
question.setAnalysis(command.analysis());
|
||||
question.setStatus(DRAFT);
|
||||
question.setIsPublished(false);
|
||||
question.setSortOrder(0);
|
||||
questionMapper.insert(question);
|
||||
|
||||
QuestionVersionDO version = new QuestionVersionDO();
|
||||
version.setTenantId(tenantId);
|
||||
version.setScope(TENANT_OWNED);
|
||||
version.setQuestionId(question.getId());
|
||||
version.setVersionNumber(1);
|
||||
version.setStem(question.getStem());
|
||||
version.setType(question.getType());
|
||||
version.setDifficulty(question.getDifficulty());
|
||||
version.setOptions(question.getOptions());
|
||||
version.setCorrectAnswer(question.getCorrectAnswer());
|
||||
version.setExplanation(question.getExplanation());
|
||||
version.setAnalysis(question.getAnalysis());
|
||||
versionMapper.insert(version);
|
||||
return question.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void publish(Long questionId, Long actorId) {
|
||||
transition(questionId, actorId, DRAFT, PUBLISHED, true, "发布");
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void archive(Long questionId, Long actorId) {
|
||||
transition(questionId, actorId, PUBLISHED, ARCHIVED, false, "归档");
|
||||
}
|
||||
|
||||
private void transition(Long questionId, Long actorId, String fromStatus, String toStatus,
|
||||
boolean published, String operation) {
|
||||
assertAuthoringMode();
|
||||
Long tenantId = TenantContextHolder.getRequiredTenantId();
|
||||
QuestionDO question = questionMapper.selectTenantOwnedById(tenantId, questionId);
|
||||
if (question == null) {
|
||||
throw exception(QUESTION_AUTHORING_NOT_FOUND);
|
||||
}
|
||||
if (!fromStatus.equals(question.getStatus())) {
|
||||
throw exception(QUESTION_LIFECYCLE_CONFLICT, operation);
|
||||
}
|
||||
if (PUBLISHED.equals(toStatus)) {
|
||||
validatePublishable(question);
|
||||
}
|
||||
if (questionMapper.updateLifecycle(tenantId, questionId, fromStatus, toStatus, published) != 1) {
|
||||
throw exception(QUESTION_LIFECYCLE_CONFLICT, operation);
|
||||
}
|
||||
|
||||
QuestionLifecycleAuditDO audit = new QuestionLifecycleAuditDO();
|
||||
audit.setTenantId(tenantId);
|
||||
audit.setQuestionId(questionId);
|
||||
audit.setContentVersion(question.getContentVersion());
|
||||
audit.setActorId(actorId);
|
||||
audit.setFromStatus(fromStatus);
|
||||
audit.setToStatus(toStatus);
|
||||
audit.setOccurredAt(LocalDateTime.now());
|
||||
auditMapper.insert(audit);
|
||||
}
|
||||
|
||||
private void validatePublishable(QuestionDO question) {
|
||||
if (question.getStem() == null || question.getStem().isBlank()
|
||||
|| question.getCorrectAnswer() == null || question.getCorrectAnswer().isBlank()) {
|
||||
throw exception(QUESTION_CONTENT_NOT_PUBLISHABLE);
|
||||
}
|
||||
try {
|
||||
List<QuestionContentSafety.SafeOption> options =
|
||||
QuestionContentSafety.restoreSnapshotOptions(question.getType(), question.getOptions());
|
||||
validateAnswerKey(question.getType(), question.getCorrectAnswer(), options);
|
||||
} catch (ServiceException ex) {
|
||||
throw exception(QUESTION_CONTENT_NOT_PUBLISHABLE);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateAnswerKey(String rawType, String rawAnswer,
|
||||
List<QuestionContentSafety.SafeOption> options) {
|
||||
if (!QuestionContentSafety.isOptionBackedType(rawType)) {
|
||||
return;
|
||||
}
|
||||
Set<String> labels = options.stream()
|
||||
.map(QuestionContentSafety.SafeOption::label)
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
String type = rawType.trim().toLowerCase(Locale.ROOT);
|
||||
if ("multi".equals(type) || "multi_choice".equals(type)) {
|
||||
String[] parsedAnswers = JsonUtils.parseObjectQuietly(rawAnswer, String[].class);
|
||||
List<String> answers = parsedAnswers != null ? Arrays.asList(parsedAnswers) : null;
|
||||
if (answers == null || answers.isEmpty() || answers.stream().anyMatch(answer ->
|
||||
answer == null || answer.isBlank() || !labels.contains(answer.trim()))) {
|
||||
throw exception(QUESTION_CONTENT_NOT_PUBLISHABLE);
|
||||
}
|
||||
Set<String> distinct = new HashSet<>();
|
||||
if (answers.stream().map(String::trim).anyMatch(answer -> !distinct.add(answer))) {
|
||||
throw exception(QUESTION_CONTENT_NOT_PUBLISHABLE);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!labels.contains(rawAnswer.trim())) {
|
||||
throw exception(QUESTION_CONTENT_NOT_PUBLISHABLE);
|
||||
}
|
||||
}
|
||||
|
||||
private void assertAuthoringMode() {
|
||||
if (properties.getCatalogMode() != CatalogProviderMode.JAVA_READ) {
|
||||
throw exception(QUESTION_AUTHORING_PROVIDER_UNSUPPORTED, properties.getCatalogMode());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
-- EDU-010: make native question authoring draft-first and keep lifecycle audit
|
||||
-- in the same PostgreSQL transaction as publication state changes.
|
||||
|
||||
CREATE FUNCTION education_question_answer_key_is_valid(
|
||||
question_type VARCHAR,
|
||||
answer_key VARCHAR,
|
||||
question_options JSONB
|
||||
)
|
||||
RETURNS BOOLEAN
|
||||
LANGUAGE plpgsql
|
||||
IMMUTABLE
|
||||
SET search_path = pg_catalog, pg_temp
|
||||
AS $$
|
||||
DECLARE
|
||||
normalized_type VARCHAR;
|
||||
answer_labels JSONB;
|
||||
option_count INTEGER;
|
||||
distinct_label_count INTEGER;
|
||||
ordered_option_count INTEGER;
|
||||
distinct_order_count INTEGER;
|
||||
BEGIN
|
||||
normalized_type := lower(trim(COALESCE(question_type, '')));
|
||||
IF answer_key IS NULL OR btrim(answer_key) = ''
|
||||
OR jsonb_typeof(question_options) <> 'array' THEN
|
||||
RETURN false;
|
||||
END IF;
|
||||
|
||||
IF normalized_type IN (
|
||||
'fill', 'text', 'terms', 'short_answer', 'composition', 'discuss',
|
||||
'translation', 'case_analysis', 'brief_analysis', 'calculation',
|
||||
'analysis_design', 'combination', 'solution'
|
||||
) THEN
|
||||
RETURN jsonb_array_length(question_options) = 0;
|
||||
END IF;
|
||||
IF normalized_type NOT IN ('choice', 'multi', 'multi_choice', 'judge', 'image')
|
||||
OR jsonb_array_length(question_options) < 2 THEN
|
||||
RETURN false;
|
||||
END IF;
|
||||
|
||||
SELECT count(*),
|
||||
count(DISTINCT btrim(item ->> 'label')),
|
||||
count(*) FILTER (WHERE jsonb_typeof(item -> 'order') = 'number'),
|
||||
count(DISTINCT (item ->> 'order')::NUMERIC)
|
||||
FILTER (WHERE jsonb_typeof(item -> 'order') = 'number')
|
||||
INTO option_count, distinct_label_count, ordered_option_count, distinct_order_count
|
||||
FROM jsonb_array_elements(question_options) item
|
||||
WHERE jsonb_typeof(item) = 'object'
|
||||
AND (SELECT count(*) FROM jsonb_object_keys(item)) = 3
|
||||
AND item ?& ARRAY['label', 'content', 'order']
|
||||
AND jsonb_typeof(item -> 'label') = 'string'
|
||||
AND btrim(item ->> 'label') <> ''
|
||||
AND jsonb_typeof(item -> 'content') = 'string'
|
||||
AND btrim(item ->> 'content') <> ''
|
||||
AND jsonb_typeof(item -> 'order') IN ('number', 'null');
|
||||
IF option_count <> jsonb_array_length(question_options)
|
||||
OR distinct_label_count <> option_count
|
||||
OR distinct_order_count <> ordered_option_count THEN
|
||||
RETURN false;
|
||||
END IF;
|
||||
|
||||
IF normalized_type IN ('multi', 'multi_choice') THEN
|
||||
BEGIN
|
||||
answer_labels := answer_key::JSONB;
|
||||
EXCEPTION WHEN others THEN
|
||||
RETURN false;
|
||||
END;
|
||||
IF jsonb_typeof(answer_labels) <> 'array'
|
||||
OR jsonb_array_length(answer_labels) = 0
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM jsonb_array_elements(answer_labels) answer
|
||||
WHERE jsonb_typeof(answer) <> 'string'
|
||||
OR btrim(answer #>> '{}') = ''
|
||||
OR NOT EXISTS (
|
||||
SELECT 1 FROM jsonb_array_elements(question_options) item
|
||||
WHERE btrim(item ->> 'label') = btrim(answer #>> '{}')
|
||||
)
|
||||
)
|
||||
OR (SELECT count(*) FROM jsonb_array_elements_text(answer_labels)) <>
|
||||
(SELECT count(DISTINCT btrim(value))
|
||||
FROM jsonb_array_elements_text(answer_labels) value) THEN
|
||||
RETURN false;
|
||||
END IF;
|
||||
RETURN true;
|
||||
END IF;
|
||||
|
||||
RETURN EXISTS (
|
||||
SELECT 1 FROM jsonb_array_elements(question_options) item
|
||||
WHERE btrim(item ->> 'label') = btrim(answer_key)
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION education_question_answer_key_is_valid(VARCHAR, VARCHAR, JSONB) FROM PUBLIC;
|
||||
|
||||
-- Hold back concurrent inserts and updates until the historical safety check,
|
||||
-- normalization, and lifecycle write guards all commit together.
|
||||
LOCK TABLE education_question IN SHARE ROW EXCLUSIVE MODE;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
invalid_question_id BIGINT;
|
||||
BEGIN
|
||||
SELECT id
|
||||
INTO invalid_question_id
|
||||
FROM education_question
|
||||
WHERE status = 'PUBLISHED'
|
||||
AND is_published = true
|
||||
AND (
|
||||
stem IS NULL OR btrim(stem) = '' OR
|
||||
NOT education_question_answer_key_is_valid(type, correct_answer, options)
|
||||
)
|
||||
ORDER BY id
|
||||
LIMIT 1;
|
||||
IF invalid_question_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'unsafe historical published question % blocks V4080', invalid_question_id
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
ALTER TABLE education_question DROP CONSTRAINT ck_education_question_status;
|
||||
|
||||
UPDATE education_question
|
||||
SET status = CASE
|
||||
WHEN status = 'DRAFT' THEN 'DRAFT'
|
||||
WHEN status = 'PUBLISHED' AND is_published = true THEN 'PUBLISHED'
|
||||
ELSE 'ARCHIVED'
|
||||
END,
|
||||
is_published = CASE
|
||||
WHEN status = 'PUBLISHED' AND is_published = true THEN true
|
||||
ELSE false
|
||||
END;
|
||||
|
||||
ALTER TABLE education_question ALTER COLUMN status SET DEFAULT 'DRAFT';
|
||||
ALTER TABLE education_question ALTER COLUMN is_published SET DEFAULT false;
|
||||
ALTER TABLE education_question
|
||||
ADD CONSTRAINT ck_education_question_status
|
||||
CHECK (status IN ('DRAFT', 'PUBLISHED', 'ARCHIVED')),
|
||||
ADD CONSTRAINT ck_education_question_publication_consistent
|
||||
CHECK (is_published = (status = 'PUBLISHED'));
|
||||
|
||||
CREATE TABLE education_question_version (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
scope VARCHAR(20) NOT NULL,
|
||||
question_id BIGINT NOT NULL,
|
||||
version_number INTEGER NOT NULL,
|
||||
stem TEXT NOT NULL,
|
||||
type VARCHAR(32) NOT NULL,
|
||||
type_label VARCHAR(50),
|
||||
difficulty VARCHAR(32),
|
||||
question_content JSONB,
|
||||
options JSONB NOT NULL,
|
||||
correct_answer VARCHAR(500),
|
||||
explanation TEXT,
|
||||
analysis TEXT,
|
||||
creator VARCHAR(64) DEFAULT '',
|
||||
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updater VARCHAR(64) DEFAULT '',
|
||||
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted BOOLEAN NOT NULL DEFAULT false,
|
||||
CONSTRAINT ck_education_question_version_scope CHECK (
|
||||
(scope = 'PUBLIC' AND tenant_id = 0) OR
|
||||
(scope = 'TENANT_OWNED' AND tenant_id > 0)
|
||||
),
|
||||
CONSTRAINT ck_education_question_version_number CHECK (version_number > 0),
|
||||
CONSTRAINT fk_education_question_version_question
|
||||
FOREIGN KEY (question_id) REFERENCES education_question (id),
|
||||
CONSTRAINT uk_education_question_version_question_number
|
||||
UNIQUE (question_id, version_number),
|
||||
CONSTRAINT uk_education_question_version_tenant_question_number
|
||||
UNIQUE (tenant_id, question_id, version_number)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE education_question_version IS
|
||||
'教育-题目不可变内容版本;当前版本号由 education_question.content_version 指向';
|
||||
COMMENT ON COLUMN education_question_version.correct_answer IS
|
||||
'服务端答案;不得进入学生端安全投影';
|
||||
|
||||
INSERT INTO education_question_version (
|
||||
tenant_id, scope, question_id, version_number, stem, type, type_label,
|
||||
difficulty, question_content, options, correct_answer, explanation, analysis,
|
||||
creator, create_time, updater, update_time, deleted
|
||||
)
|
||||
SELECT tenant_id, scope, id, content_version, stem, type, type_label,
|
||||
difficulty, question_content, options, correct_answer, explanation, analysis,
|
||||
creator, create_time, updater, update_time, false
|
||||
FROM education_question;
|
||||
|
||||
ALTER TABLE education_question
|
||||
ADD CONSTRAINT fk_education_question_current_version
|
||||
FOREIGN KEY (id, content_version)
|
||||
REFERENCES education_question_version (question_id, version_number)
|
||||
DEFERRABLE INITIALLY DEFERRED;
|
||||
|
||||
CREATE TABLE education_question_lifecycle_audit (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
question_id BIGINT NOT NULL,
|
||||
content_version INTEGER NOT NULL,
|
||||
actor_id BIGINT NOT NULL,
|
||||
from_status VARCHAR(20) NOT NULL,
|
||||
to_status VARCHAR(20) NOT NULL,
|
||||
occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
creator VARCHAR(64) DEFAULT '',
|
||||
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updater VARCHAR(64) DEFAULT '',
|
||||
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted BOOLEAN NOT NULL DEFAULT false,
|
||||
CONSTRAINT ck_education_question_lifecycle_audit_transition CHECK (
|
||||
(from_status = 'DRAFT' AND to_status = 'PUBLISHED') OR
|
||||
(from_status = 'PUBLISHED' AND to_status = 'ARCHIVED')
|
||||
),
|
||||
CONSTRAINT fk_education_question_lifecycle_audit_version
|
||||
FOREIGN KEY (tenant_id, question_id, content_version)
|
||||
REFERENCES education_question_version (tenant_id, question_id, version_number),
|
||||
CONSTRAINT uk_education_question_lifecycle_audit_transition
|
||||
UNIQUE (tenant_id, question_id, from_status, to_status)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE education_question_lifecycle_audit IS
|
||||
'教育-题目发布生命周期追加式领域审计,与状态转换同事务提交';
|
||||
CREATE INDEX idx_education_question_lifecycle_audit_tenant_question
|
||||
ON education_question_lifecycle_audit (tenant_id, question_id, occurred_at, id);
|
||||
|
||||
CREATE TABLE education_question_lifecycle_transition_token (
|
||||
transaction_id BIGINT NOT NULL,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
question_id BIGINT NOT NULL,
|
||||
content_version INTEGER NOT NULL,
|
||||
from_status VARCHAR(20) NOT NULL,
|
||||
to_status VARCHAR(20) NOT NULL,
|
||||
PRIMARY KEY (transaction_id, tenant_id, question_id, from_status, to_status)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE education_question_lifecycle_transition_token IS
|
||||
'题目状态触发器与审计触发器之间的事务内临时凭据;成功审计后即消费';
|
||||
REVOKE ALL ON TABLE education_question_lifecycle_transition_token FROM PUBLIC;
|
||||
|
||||
CREATE FUNCTION education_prevent_question_version_mutation()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = pg_catalog, pg_temp
|
||||
AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'question versions are immutable'
|
||||
USING ERRCODE = '23514';
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION education_enforce_question_lifecycle_transition()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog, pg_temp
|
||||
AS $$
|
||||
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 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;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION education_prevent_question_lifecycle_audit_mutation()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = pg_catalog, pg_temp
|
||||
AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'question lifecycle audit is append-only'
|
||||
USING ERRCODE = '23514';
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION education_prevent_question_content_projection_mutation()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = pg_catalog, pg_temp
|
||||
AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'question content must change through immutable versions'
|
||||
USING ERRCODE = '23514';
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION education_require_question_lifecycle_audit()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = pg_catalog, pg_temp
|
||||
AS $$
|
||||
DECLARE
|
||||
audit_exists BOOLEAN;
|
||||
BEGIN
|
||||
IF NEW.status IS NOT DISTINCT FROM OLD.status THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
EXECUTE format(
|
||||
'SELECT EXISTS (SELECT 1 FROM %I.education_question_lifecycle_audit' ||
|
||||
' WHERE tenant_id = $1 AND question_id = $2 AND content_version = $3' ||
|
||||
' AND from_status = $4 AND to_status = $5 AND deleted = false)',
|
||||
TG_TABLE_SCHEMA)
|
||||
INTO audit_exists
|
||||
USING NEW.tenant_id, NEW.id, NEW.content_version, OLD.status, NEW.status;
|
||||
IF NOT audit_exists THEN
|
||||
RAISE EXCEPTION 'question lifecycle transition requires transactional audit'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION education_check_question_version_ownership()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog, pg_temp
|
||||
AS $$
|
||||
DECLARE
|
||||
question_tenant_id BIGINT;
|
||||
question_scope VARCHAR(20);
|
||||
BEGIN
|
||||
EXECUTE format(
|
||||
'SELECT tenant_id, scope FROM %I.education_question WHERE id = $1 FOR SHARE',
|
||||
TG_TABLE_SCHEMA)
|
||||
INTO question_tenant_id, question_scope
|
||||
USING NEW.question_id;
|
||||
IF question_tenant_id IS NULL OR question_scope IS NULL THEN
|
||||
RAISE EXCEPTION 'question version points to missing question %', NEW.question_id
|
||||
USING ERRCODE = '23503';
|
||||
END IF;
|
||||
IF NEW.tenant_id IS DISTINCT FROM question_tenant_id
|
||||
OR NEW.scope IS DISTINCT FROM question_scope THEN
|
||||
RAISE EXCEPTION 'question version ownership must equal question ownership'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE FUNCTION education_validate_question_lifecycle_audit_insert()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog, pg_temp
|
||||
AS $$
|
||||
DECLARE
|
||||
question_status VARCHAR(20);
|
||||
question_version INTEGER;
|
||||
transition_token_count INTEGER;
|
||||
BEGIN
|
||||
EXECUTE format(
|
||||
'SELECT status, content_version FROM %I.education_question' ||
|
||||
' WHERE tenant_id = $1 AND id = $2 FOR SHARE',
|
||||
TG_TABLE_SCHEMA)
|
||||
INTO question_status, question_version
|
||||
USING NEW.tenant_id, NEW.question_id;
|
||||
IF question_status IS NULL OR question_version IS NULL
|
||||
OR question_status IS DISTINCT FROM NEW.to_status
|
||||
OR question_version IS DISTINCT FROM NEW.content_version THEN
|
||||
RAISE EXCEPTION 'question lifecycle audit must accompany its state transition'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
EXECUTE format(
|
||||
'DELETE FROM %I.education_question_lifecycle_transition_token' ||
|
||||
' WHERE transaction_id = $1 AND tenant_id = $2 AND question_id = $3' ||
|
||||
' AND content_version = $4 AND from_status = $5 AND to_status = $6',
|
||||
TG_TABLE_SCHEMA)
|
||||
USING pg_current_xact_id()::text::BIGINT, NEW.tenant_id, NEW.question_id,
|
||||
NEW.content_version, NEW.from_status, NEW.to_status;
|
||||
GET DIAGNOSTICS transition_token_count = ROW_COUNT;
|
||||
IF transition_token_count <> 1 THEN
|
||||
RAISE EXCEPTION 'question lifecycle audit must accompany its state transition'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION education_prevent_question_version_mutation() FROM PUBLIC;
|
||||
REVOKE ALL ON FUNCTION education_enforce_question_lifecycle_transition() FROM PUBLIC;
|
||||
REVOKE ALL ON FUNCTION education_prevent_question_lifecycle_audit_mutation() FROM PUBLIC;
|
||||
REVOKE ALL ON FUNCTION education_prevent_question_content_projection_mutation() FROM PUBLIC;
|
||||
REVOKE ALL ON FUNCTION education_require_question_lifecycle_audit() FROM PUBLIC;
|
||||
REVOKE ALL ON FUNCTION education_check_question_version_ownership() FROM PUBLIC;
|
||||
REVOKE ALL ON FUNCTION education_validate_question_lifecycle_audit_insert() FROM PUBLIC;
|
||||
|
||||
CREATE TRIGGER trg_education_question_version_reference_scope
|
||||
BEFORE INSERT OR UPDATE OF tenant_id, scope, question_id ON education_question_version
|
||||
FOR EACH ROW EXECUTE FUNCTION education_check_reference_scope('question_id', 'education_question');
|
||||
CREATE TRIGGER trg_education_question_version_ownership_equal
|
||||
BEFORE INSERT OR UPDATE OF tenant_id, scope, question_id ON education_question_version
|
||||
FOR EACH ROW EXECUTE FUNCTION education_check_question_version_ownership();
|
||||
CREATE TRIGGER trg_education_question_version_scope_immutable
|
||||
BEFORE UPDATE OF tenant_id, scope ON education_question_version
|
||||
FOR EACH ROW EXECUTE FUNCTION education_prevent_catalog_scope_change();
|
||||
CREATE TRIGGER trg_education_question_version_immutable
|
||||
BEFORE UPDATE OR DELETE ON education_question_version
|
||||
FOR EACH ROW EXECUTE FUNCTION education_prevent_question_version_mutation();
|
||||
CREATE TRIGGER trg_education_question_lifecycle_transition
|
||||
BEFORE INSERT OR UPDATE OF status ON education_question
|
||||
FOR EACH ROW EXECUTE FUNCTION education_enforce_question_lifecycle_transition();
|
||||
CREATE TRIGGER trg_education_question_lifecycle_audit_immutable
|
||||
BEFORE UPDATE OR DELETE ON education_question_lifecycle_audit
|
||||
FOR EACH ROW EXECUTE FUNCTION education_prevent_question_lifecycle_audit_mutation();
|
||||
CREATE TRIGGER trg_education_question_lifecycle_audit_insert_valid
|
||||
BEFORE INSERT ON education_question_lifecycle_audit
|
||||
FOR EACH ROW EXECUTE FUNCTION education_validate_question_lifecycle_audit_insert();
|
||||
CREATE TRIGGER trg_education_question_content_projection_immutable
|
||||
BEFORE UPDATE OF content_version, stem, type, type_label, difficulty,
|
||||
question_content, options, correct_answer, explanation, analysis
|
||||
ON education_question
|
||||
FOR EACH ROW EXECUTE FUNCTION education_prevent_question_content_projection_mutation();
|
||||
CREATE CONSTRAINT TRIGGER trg_education_question_lifecycle_audit_required
|
||||
AFTER UPDATE ON education_question
|
||||
DEFERRABLE INITIALLY DEFERRED
|
||||
FOR EACH ROW EXECUTE FUNCTION education_require_question_lifecycle_audit();
|
||||
|
||||
-- The platform schema is adopted before Education Flyway in deployed environments.
|
||||
-- Isolated Education migration tests intentionally have no System tables.
|
||||
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
|
||||
(6800, '教育管理', '', 1, 50, 0, '/education', 'ep:school', NULL,
|
||||
NULL, 0, false, true, true, 'education-flyway', 'education-flyway'),
|
||||
(6801, '能力查询', 'education:capability', 3, 1, 6800, '', '', NULL,
|
||||
NULL, 0, false, true, true, 'education-flyway', 'education-flyway'),
|
||||
(6802, '题目创作', 'education:question:author', 3, 2, 6800, '', '', NULL,
|
||||
NULL, 0, false, true, true, 'education-flyway', 'education-flyway'),
|
||||
(6803, '题目发布', 'education:question:publish', 3, 3, 6800, '', '', NULL,
|
||||
NULL, 0, false, true, true, 'education-flyway', 'education-flyway'),
|
||||
(6804, '题目归档', 'education:question:archive', 3, 4, 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 status = 0
|
||||
AND deleted = 0
|
||||
AND (
|
||||
(id = 6800 AND permission = '' AND type = 1 AND parent_id = 0 AND path = '/education')
|
||||
OR (id = 6801 AND permission = 'education:capability' AND type = 3 AND parent_id = 6800)
|
||||
OR (id = 6802 AND permission = 'education:question:author' AND type = 3 AND parent_id = 6800)
|
||||
OR (id = 6803 AND permission = 'education:question:publish' AND type = 3 AND parent_id = 6800)
|
||||
OR (id = 6804 AND permission = 'education:question:archive' AND type = 3 AND parent_id = 6800)
|
||||
);
|
||||
IF installed_permission_count <> 5 THEN
|
||||
RAISE EXCEPTION 'Education System RBAC seed IDs conflict with existing system_menu rows'
|
||||
USING ERRCODE = '23505';
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
Reference in New Issue
Block a user