feat(education): add native question publication lifecycle

This commit is contained in:
2026-07-30 21:31:08 +08:00
parent 4db4a7d371
commit 34bc1fe41e
22 changed files with 2044 additions and 20 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
package cn.iocoder.yudao.module.education.dal.mysql.catalog;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionLifecycleAuditDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface QuestionLifecycleAuditMapper extends BaseMapperX<QuestionLifecycleAuditDO> {
}

View File

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

View File

@@ -0,0 +1,9 @@
package cn.iocoder.yudao.module.education.dal.mysql.catalog;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionVersionDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface QuestionVersionMapper extends BaseMapperX<QuestionVersionDO> {
}

View File

@@ -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, "题目内容不完整或不安全,无法发布");
}

View File

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

View File

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

View File

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

View File

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