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

@@ -32,6 +32,34 @@ _Avoid_: Answer, choice answer
The immutable, student-visible Question Content captured when a practice session is created so that later content edits do not change that session. It excludes answers and explanations; protected scoring data is not part of this projection even when stored beside it.
_Avoid_: Question cache
**Question Content Version**:
An immutable revision of Question Content identified by its question and positive version number. Publication selects lifecycle state for the current version; it does not rewrite an existing version or change content ownership.
_Avoid_: Editable version row, question backup
**Publication State**:
The server-controlled lifecycle of tenant Question Content: `DRAFT → PUBLISHED → ARCHIVED`. The current command surface has no reverse transition, and `RETIRED` is not yet a defined state.
_Avoid_: Arbitrary status field, published boolean as an independent state
**Draft Question Content**:
Tenant-owned Question Content that may still be completed by an author and is never available to fresh student reads.
_Avoid_: Unpublished live question
**Published Question Content**:
Question Content whose current immutable version passed publication safety checks and may be returned as a Safe Question to fresh student reads.
_Avoid_: Public content (publication does not mean `scope=PUBLIC`)
**Archived Question Content**:
Previously Published Question Content withdrawn from fresh student reads without changing ownership or previously captured Question Snapshots. It is terminal in the current command surface.
_Avoid_: Deleted question, retired question
**Content Write Authority**:
The catalog source allowed to accept authoring commands and produce student-visible read-after-write behavior. Native PostgreSQL authoring is authoritative only in `JAVA_READ`; other provider modes fail before persistence access.
_Avoid_: Read provider switch as implicit write permission
**Question Lifecycle Audit**:
An append-only Education domain fact recording actor, content version, and a valid Publication State transition in the same transaction as that transition.
_Avoid_: Asynchronous operation log as publication proof
**Protected Answer Key**:
Server-only correctness and explanation data captured for stable scoring of a practice session. It is never included in a Safe Question, Question Snapshot JSON, or pre-submit response.
_Avoid_: Question Snapshot, frontend answer

View File

@@ -4,7 +4,7 @@
## 当前状态
此模块提供教育业务功能骨架、题库目录浏览 tracer bullet,以及题目预览与练习配置预览
此模块提供教育业务功能骨架、题库目录浏览、题目预览与练习闭环,以及 `JAVA_READ` 下的租户题目草稿、发布和归档 tracer bullet。
**已实现**
- 模块骨架与包结构
@@ -16,9 +16,10 @@
- 练习配置预览端点 (见下方 Practice API) — 学生端已认证
- 答案保存端点 (见下方 Answer API) — 幂等保存,安全重试
- 题目安全过滤(答案/解析绝不暴露到前端)
- 管理端题目创作、发布和归档端点 — 仅在 `JAVA_READ` 下接受写入
- 独立的功能开关配置 + Scalar 数据源配置
- 错误码常量(通用 + 租户 + Catalog/Scalar + 题目/练习)
- 权限注解(`education:capability`);菜单种子尚未纳入正式 Flyway管理员授权需在后续产品决策后交付
- System RBAC 权限注解及 V4080 条件种子(`education:capability``education:question:author``education:question:publish``education:question:archive`);迁移不自动向任何角色授权
## 功能配置
@@ -34,9 +35,12 @@ yudao:
practice-write-enabled: true
# Pilot 灰度租户;空列表表示不限制,生产 Pilot 应显式配置目标租户 ID
pilot-tenant-ids: [1024]
# SCALAR_READ 是默认读取权威;只有 JAVA_READ 允许本地题目创作
catalog-mode: SCALAR_READ
```
`catalog-mode=SCALAR_READ` 时管理端题目写命令会在访问 Mapper 前失败关闭;要使用下述创作 API必须显式配置 `catalog-mode=JAVA_READ`,确保写入 PostgreSQL 的题目也是学生读取的数据源。
灰度与回滚约束:
- `enabled=false`:移除 Education HTTP 能力,不执行任何数据删除。
@@ -69,6 +73,42 @@ GET /admin-api/education/capability
}
```
### 管理后台 - 题目创作与发布
这些端点只管理当前框架租户的 `TENANT_OWNED` 题目,不提供 PUBLIC/platform-curator 写入口。客户端不能提交 `tenantId``scope`、生命周期状态或 actor新题固定以 `DRAFT/false` 创建,并且只允许 `DRAFT → PUBLISHED → ARCHIVED`
| 端点 | 权限 | 说明 |
|------|------|------|
| `POST /admin-api/education/questions/drafts` | `education:question:author` | 创建当前租户草稿,返回题目 ID |
| `PUT /admin-api/education/questions/{id}/publish` | `education:question:publish` | 发布当前租户草稿 |
| `PUT /admin-api/education/questions/{id}/archive` | `education:question:archive` | 归档当前租户已发布题目 |
创建草稿请求示例:
```json
{
"stem": "2 + 2 = ?",
"type": "choice",
"difficulty": "easy",
"options": [
{"label": "A", "content": "4", "order": 1.0},
{"label": "B", "content": "5", "order": 2.0}
],
"correctAnswer": "A",
"explanation": "基础加法",
"analysis": null
}
```
`correctAnswer``explanation``analysis` 是服务端受保护内容;学生端仍只返回下文列出的安全题目投影。发布和归档采用 tenant/scope/expected-state CAS并在同一事务追加生命周期审计。`SCALAR_READ` 或其他不支持的 provider mode 返回 `1_005_003_070`,不会查询或修改题目。
| 错误码 | 说明 |
|--------|------|
| `1_005_003_070` | 当前 provider mode 不支持本地题目创作 |
| `1_005_003_071` | 题目不存在或无权管理 |
| `1_005_003_072` | 状态已变化或生命周期转换非法 |
| `1_005_003_073` | 题目内容不完整或不安全,不能发布 |
### 用户 APP - 教育租户识别
```
@@ -180,6 +220,8 @@ yudao-module-education/src/main/resources/db/migration/education/
- `V4010``V4020` 是不可变迁移历史,不得修改。
- `V4030` 创建或接管 Practice 核心闭环表,并在存在旧答案/交卷幂等表时向统一 `education_idempotency` 回填数据。
- `V4070` 对 19 条目录引用边执行历史预校验并安装写入守卫,同时将 11 张目录表的 `tenant_id/scope` 设为插入后不可变,阻止 PUBLIC→租户、跨租户以及父节点归属变更造成的非法关系。
- `V4080` 增加第 20 条目录引用边 `education_question_version.question_id → education_question.id`,并为版本表增加第 12 个 ownership/scope 守卫;版本归属必须与题目完全一致。该迁移还建立 draft-first 生命周期、不可变题目版本、同事务追加式审计及历史已发布内容的 fail-closed 预检。
- V4080 在平台 `system_menu` 已存在时条件写入 Education 权限种子;固定 ID 已被不同 permission 占用时迁移失败,且迁移不会向角色写入授权关系。
-`education_answer_idempotency``education_submit_idempotency` 在首次接管时保留,后续清理必须使用更高版本的独立向前 migration。
- `sql/postgresql/education/` 是手工初始化/设计历史,`sql/mysql/education/` 是过时归档;两者都不是运行时交付入口。
- 禁止使用 `flyway clean``*-rollback.sql` 回退共享环境。应用回滚后如有 Schema 兼容问题,通过更高版本向前修复。
@@ -200,7 +242,7 @@ find yudao-module-education/target/classes/db/migration/education -type f -print
**当前工作区未检出完整的前端源码。** `yudao-ui/yudao-ui-admin-vue3/` 仅包含部分 MES 相关文件(`src/api/mes/``src/views/mes/`),缺少 `package.json``router/``store/``config/` 等核心框架文件。
因此:
- **管理后台教育菜单项**当前仅保留 `education:capability` 权限契约,正式 Flyway 尚未写入菜单种子;前端无路由/页面组件可渲染,因此不声明已有可见教育菜单
- **管理后台教育菜单项**V4080 已条件写入 Education 权限/菜单记录,但不分配角色;前端无路由页面组件,因此不声明已有可操作的可见教育页面
- **Student Web/H5 应用外壳**:前端源码不存在,无法建立。
### Student 端前端集成契约

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

View File

@@ -0,0 +1,245 @@
package cn.iocoder.yudao.module.education.controller.admin.question;
import cn.iocoder.yudao.framework.security.core.LoginUser;
import cn.iocoder.yudao.framework.security.core.service.SecurityFrameworkService;
import cn.iocoder.yudao.module.education.controller.admin.question.vo.QuestionDraftCreateReqVO;
import cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand;
import cn.iocoder.yudao.module.education.service.question.authoring.TenantQuestionLifecycleService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = QuestionAuthoringControllerContractTest.SecurityTestConfiguration.class)
class QuestionAuthoringControllerContractTest {
private static final Long ACTOR_ID = 7L;
private static final String AUTHOR_PERMISSION = "education:question:author";
private static final String PUBLISH_PERMISSION = "education:question:publish";
private static final String ARCHIVE_PERMISSION = "education:question:archive";
@Autowired
private QuestionAuthoringController controller;
@Autowired
private RecordingLifecycleService lifecycleService;
@Autowired
private MutableSecurityFrameworkService securityFrameworkService;
@BeforeEach
void setUp() {
lifecycleService.reset();
securityFrameworkService.reset();
LoginUser loginUser = new LoginUser();
loginUser.setId(ACTOR_ID);
loginUser.setTenantId(10L);
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(loginUser, null, List.of()));
}
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
}
@Test
void authorPermissionDoesNotAllowPublishing() {
grant(AUTHOR_PERMISSION);
assertThrows(AccessDeniedException.class, () -> controller.publish(101L));
assertEquals(0, lifecycleService.totalCalls());
}
@Test
void publishPermissionDoesNotAllowArchiving() {
grant(PUBLISH_PERMISSION);
assertThrows(AccessDeniedException.class, () -> controller.archive(101L));
assertEquals(0, lifecycleService.totalCalls());
}
@Test
void archivePermissionDoesNotAllowDraftCreation() {
grant(ARCHIVE_PERMISSION);
assertThrows(AccessDeniedException.class, () -> controller.createDraft(draftRequest()));
assertEquals(0, lifecycleService.totalCalls());
}
@Test
void authorPermissionAllowsDraftCreation() {
grant(AUTHOR_PERMISSION);
assertEquals(101L, controller.createDraft(draftRequest()).getData());
assertEquals("2 + 2 = ?", lifecycleService.createdCommand.stem());
assertEquals(1, lifecycleService.totalCalls());
}
@Test
void publishPermissionAllowsPublishing() {
grant(PUBLISH_PERMISSION);
controller.publish(101L);
assertEquals(101L, lifecycleService.publishedQuestionId);
assertEquals(ACTOR_ID, lifecycleService.publishedByActorId);
assertEquals(1, lifecycleService.totalCalls());
}
@Test
void archivePermissionAllowsArchiving() {
grant(ARCHIVE_PERMISSION);
controller.archive(101L);
assertEquals(101L, lifecycleService.archivedQuestionId);
assertEquals(ACTOR_ID, lifecycleService.archivedByActorId);
assertEquals(1, lifecycleService.totalCalls());
}
private void grant(String permission) {
securityFrameworkService.grant(permission);
}
private QuestionDraftCreateReqVO draftRequest() {
QuestionDraftCreateReqVO.Option optionA = new QuestionDraftCreateReqVO.Option();
optionA.setLabel("A");
optionA.setContent("4");
optionA.setOrder(1D);
QuestionDraftCreateReqVO.Option optionB = new QuestionDraftCreateReqVO.Option();
optionB.setLabel("B");
optionB.setContent("5");
optionB.setOrder(2D);
QuestionDraftCreateReqVO request = new QuestionDraftCreateReqVO();
request.setStem("2 + 2 = ?");
request.setType("choice");
request.setDifficulty("easy");
request.setOptions(List.of(optionA, optionB));
request.setCorrectAnswer("A");
return request;
}
@Configuration(proxyBeanMethods = false)
@EnableMethodSecurity
static class SecurityTestConfiguration {
@Bean("ss")
MutableSecurityFrameworkService securityFrameworkService() {
return new MutableSecurityFrameworkService();
}
@Bean
RecordingLifecycleService tenantQuestionLifecycleService() {
return new RecordingLifecycleService();
}
@Bean
QuestionAuthoringController questionAuthoringController(
TenantQuestionLifecycleService tenantQuestionLifecycleService) {
return new QuestionAuthoringController(tenantQuestionLifecycleService);
}
}
static class MutableSecurityFrameworkService implements SecurityFrameworkService {
private final Set<String> grantedPermissions = new HashSet<>();
void grant(String permission) {
grantedPermissions.add(permission);
}
void reset() {
grantedPermissions.clear();
}
@Override
public boolean hasPermission(String permission) {
return grantedPermissions.contains(permission);
}
@Override
public boolean hasAnyPermissions(String... permissions) {
return java.util.Arrays.stream(permissions).anyMatch(this::hasPermission);
}
@Override
public boolean hasRole(String role) {
return false;
}
@Override
public boolean hasAnyRoles(String... roles) {
return false;
}
@Override
public boolean hasScope(String scope) {
return false;
}
@Override
public boolean hasAnyScopes(String... scope) {
return false;
}
}
static class RecordingLifecycleService implements TenantQuestionLifecycleService {
private QuestionDraftCommand createdCommand;
private Long publishedQuestionId;
private Long publishedByActorId;
private Long archivedQuestionId;
private Long archivedByActorId;
void reset() {
createdCommand = null;
publishedQuestionId = null;
publishedByActorId = null;
archivedQuestionId = null;
archivedByActorId = null;
}
int totalCalls() {
int count = createdCommand != null ? 1 : 0;
count += publishedQuestionId != null ? 1 : 0;
count += archivedQuestionId != null ? 1 : 0;
return count;
}
@Override
public Long createDraft(QuestionDraftCommand command) {
createdCommand = command;
return 101L;
}
@Override
public void publish(Long questionId, Long actorId) {
publishedQuestionId = questionId;
publishedByActorId = actorId;
}
@Override
public void archive(Long questionId, Long actorId) {
archivedQuestionId = questionId;
archivedByActorId = actorId;
}
}
}

View File

@@ -0,0 +1,210 @@
package cn.iocoder.yudao.module.education.service.question.authoring;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;
import cn.iocoder.yudao.module.education.config.EducationProperties;
import cn.iocoder.yudao.module.education.controller.app.question.vo.SafeQuestionRespVO;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionDO;
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.service.catalog.provider.JavaCatalogProvider;
import cn.iocoder.yudao.module.education.service.question.QuestionCatalogService;
import cn.iocoder.yudao.module.education.service.question.QuestionCatalogServiceImpl;
import cn.iocoder.yudao.module.education.test.PostgreSqlDbIntegrationTest;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.TestPropertySource;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
import static org.junit.jupiter.api.Assertions.*;
@Import({TenantQuestionLifecycleServiceImpl.class, JavaCatalogProvider.class,
QuestionCatalogServiceImpl.class, EducationProperties.class})
@TestPropertySource(properties = {
"yudao.education.enabled=true",
"yudao.education.catalog-mode=JAVA_READ"
})
class TenantQuestionLifecyclePostgreSqlIntegrationTest extends PostgreSqlDbIntegrationTest {
@Resource private TenantQuestionLifecycleService lifecycleService;
@Resource private QuestionCatalogService questionCatalogService;
@Resource private QuestionLifecycleAuditMapper auditMapper;
@Resource private QuestionMapper questionMapper;
@Resource private QuestionVersionMapper versionMapper;
@Resource private PlatformTransactionManager transactionManager;
@BeforeEach
void setUpTenant() {
TenantContextHolder.setTenantId(10L);
}
@AfterEach
void clearTenant() {
TenantContextHolder.clear();
}
@Test
void shouldMoveDraftThroughPublishAndArchiveAcrossStudentReadSeam() {
Long id = lifecycleService.createDraft(new QuestionDraftCommand(
"2 + 2 = ?", "choice", "easy", List.of(
new QuestionDraftCommand.QuestionDraftOption("A", "4", 1D),
new QuestionDraftCommand.QuestionDraftOption("B", "5", 2D)),
"A", "secret explanation", "secret analysis"));
assertQuestionNotVisible(id);
lifecycleService.publish(id, 7L);
SafeQuestionRespVO published = questionCatalogService.getQuestion(String.valueOf(id));
assertEquals("2 + 2 = ?", published.getStem());
assertEquals(2, published.getOptions().size());
assertFalse(cn.iocoder.yudao.framework.common.util.json.JsonUtils.toJsonString(published)
.matches(".*(correctAnswer|explanation|analysis|isCorrect).*"));
lifecycleService.archive(id, 7L);
assertQuestionNotVisible(id);
assertEquals(2L, auditMapper.selectCount());
}
@Test
void shouldRollbackPublicationWhenDurableAuditFails() {
Long id = lifecycleService.createDraft(new QuestionDraftCommand(
"Capital of France?", "text", "easy", List.of(),
"Paris", null, null));
assertThrows(RuntimeException.class, () -> lifecycleService.publish(id, null));
assertEquals("DRAFT", questionMapper.selectTenantOwnedById(10L, id).getStatus());
assertEquals(0L, auditMapper.selectCount());
assertQuestionNotVisible(id);
}
@Test
void shouldAllowOnlyOneConcurrentPublishAndAppendOneAudit() throws InterruptedException {
Long id = lifecycleService.createDraft(questionCommand("Concurrent publish"));
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch go = new CountDownLatch(1);
AtomicInteger successCount = new AtomicInteger();
AtomicReference<Throwable> firstError = new AtomicReference<>();
AtomicReference<Throwable> secondError = new AtomicReference<>();
Thread first = publishThread(id, 7L, ready, go, successCount, firstError);
Thread second = publishThread(id, 8L, ready, go, successCount, secondError);
first.start();
second.start();
assertTrue(ready.await(5, TimeUnit.SECONDS));
go.countDown();
first.join(10000);
second.join(10000);
assertFalse(first.isAlive());
assertFalse(second.isAlive());
assertEquals(1, successCount.get());
Throwable loserError = firstError.get() != null ? firstError.get() : secondError.get();
ServiceException conflict = assertInstanceOf(ServiceException.class, loserError);
assertEquals(QUESTION_LIFECYCLE_CONFLICT.getCode(), conflict.getCode());
assertEquals("PUBLISHED", questionMapper.selectTenantOwnedById(10L, id).getStatus());
assertEquals(1L, auditMapper.selectCount());
}
@Test
void shouldRejectCrossTenantPublishAndArchive() {
Long id = lifecycleService.createDraft(questionCommand("Tenant isolated"));
TenantContextHolder.setTenantId(20L);
assertAuthoringNotFound(() -> lifecycleService.publish(id, 8L));
TenantContextHolder.setTenantId(10L);
lifecycleService.publish(id, 7L);
TenantContextHolder.setTenantId(20L);
assertAuthoringNotFound(() -> lifecycleService.archive(id, 8L));
TenantContextHolder.setTenantId(10L);
assertEquals("PUBLISHED", questionMapper.selectTenantOwnedById(10L, id).getStatus());
assertEquals(1L, auditMapper.selectCount());
}
@Test
void shouldRejectPublicQuestionFromTenantLifecycleService() {
Long publicQuestionId = createPublicDraft();
assertAuthoringNotFound(() -> lifecycleService.publish(publicQuestionId, 7L));
assertEquals(0L, auditMapper.selectCount());
}
private void assertQuestionNotVisible(Long id) {
ServiceException ex = assertThrows(ServiceException.class,
() -> questionCatalogService.getQuestion(String.valueOf(id)));
assertEquals(QUESTION_NOT_FOUND.getCode(), ex.getCode());
}
private void assertAuthoringNotFound(org.junit.jupiter.api.function.Executable executable) {
ServiceException ex = assertThrows(ServiceException.class, executable);
assertEquals(QUESTION_AUTHORING_NOT_FOUND.getCode(), ex.getCode());
}
private Thread publishThread(Long questionId, Long actorId, CountDownLatch ready, CountDownLatch go,
AtomicInteger successCount, AtomicReference<Throwable> error) {
return new Thread(() -> {
TenantContextHolder.setTenantId(10L);
ready.countDown();
try {
assertTrue(go.await(5, TimeUnit.SECONDS));
lifecycleService.publish(questionId, actorId);
successCount.incrementAndGet();
} catch (Throwable throwable) {
error.set(throwable);
} finally {
TenantContextHolder.clear();
}
});
}
private Long createPublicDraft() {
return TenantUtils.executeIgnore(() -> new TransactionTemplate(transactionManager).execute(status -> {
QuestionDO question = new QuestionDO();
question.setTenantId(0L);
question.setScope("PUBLIC");
question.setContentVersion(1);
question.setStem("Public draft");
question.setType("text");
question.setOptions("[]");
question.setCorrectAnswer("answer");
question.setStatus("DRAFT");
question.setIsPublished(false);
question.setSortOrder(0);
questionMapper.insert(question);
QuestionVersionDO version = new QuestionVersionDO();
version.setTenantId(0L);
version.setScope("PUBLIC");
version.setQuestionId(question.getId());
version.setVersionNumber(1);
version.setStem(question.getStem());
version.setType(question.getType());
version.setOptions(question.getOptions());
version.setCorrectAnswer(question.getCorrectAnswer());
versionMapper.insert(version);
return question.getId();
}));
}
private QuestionDraftCommand questionCommand(String stem) {
return new QuestionDraftCommand(stem, "text", "easy", List.of(), "answer", null, null);
}
}

View File

@@ -0,0 +1,213 @@
package cn.iocoder.yudao.module.education.service.question.authoring;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.yudao.module.education.config.EducationProperties;
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionDO;
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 org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.Mock;
import org.mockito.ArgumentCaptor;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.stream.Stream;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class TenantQuestionLifecycleServiceImplTest {
@Mock private QuestionMapper questionMapper;
@Mock private QuestionVersionMapper versionMapper;
@Mock private QuestionLifecycleAuditMapper auditMapper;
private EducationProperties properties;
private TenantQuestionLifecycleServiceImpl service;
@BeforeEach
void setUp() {
properties = new EducationProperties();
service = new TenantQuestionLifecycleServiceImpl(properties, questionMapper, versionMapper, auditMapper);
TenantContextHolder.setTenantId(10L);
}
@AfterEach
void tearDown() {
TenantContextHolder.clear();
}
@Test
void shouldFailBeforeDatabaseAccessInScalarMode() {
properties.setCatalogMode(CatalogProviderMode.SCALAR_READ);
ServiceException ex = assertThrows(ServiceException.class, () -> service.publish(1L, 7L));
assertEquals(QUESTION_AUTHORING_PROVIDER_UNSUPPORTED.getCode(), ex.getCode());
verifyNoInteractions(questionMapper, versionMapper, auditMapper);
}
@Test
void shouldCreateTenantOwnedDraftAndFirstImmutableVersion() {
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
doAnswer(invocation -> {
QuestionDO question = invocation.getArgument(0);
question.setId(101L);
return 1;
}).when(questionMapper).insert((QuestionDO) any(QuestionDO.class));
Long id = service.createDraft(command());
assertEquals(101L, id);
ArgumentCaptor<QuestionDO> questionCaptor = ArgumentCaptor.forClass(QuestionDO.class);
verify(questionMapper).insert((QuestionDO) questionCaptor.capture());
QuestionDO insertedQuestion = questionCaptor.getValue();
assertEquals(10L, insertedQuestion.getTenantId());
assertEquals("TENANT_OWNED", insertedQuestion.getScope());
assertEquals("DRAFT", insertedQuestion.getStatus());
assertFalse(insertedQuestion.getIsPublished());
assertEquals(1, insertedQuestion.getContentVersion());
ArgumentCaptor<cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionVersionDO> versionCaptor =
ArgumentCaptor.forClass(
cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionVersionDO.class);
verify(versionMapper).insert((cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionVersionDO)
versionCaptor.capture());
assertEquals(101L, versionCaptor.getValue().getQuestionId());
assertEquals(10L, versionCaptor.getValue().getTenantId());
assertEquals(1, versionCaptor.getValue().getVersionNumber());
verifyNoInteractions(auditMapper);
}
@Test
void shouldPublishWithCasAndAppendAudit() {
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
when(questionMapper.selectTenantOwnedById(10L, 101L)).thenReturn(draft());
when(questionMapper.updateLifecycle(10L, 101L, "DRAFT", "PUBLISHED", true)).thenReturn(1);
service.publish(101L, 7L);
ArgumentCaptor<cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionLifecycleAuditDO> auditCaptor =
ArgumentCaptor.forClass(
cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionLifecycleAuditDO.class);
verify(auditMapper).insert((cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionLifecycleAuditDO)
auditCaptor.capture());
assertEquals(10L, auditCaptor.getValue().getTenantId());
assertEquals(7L, auditCaptor.getValue().getActorId());
assertEquals("DRAFT", auditCaptor.getValue().getFromStatus());
assertEquals("PUBLISHED", auditCaptor.getValue().getToStatus());
}
@Test
void shouldPreserveRequestOrderWhenOptionOrderIsOmitted() {
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
doAnswer(invocation -> {
QuestionDO question = invocation.getArgument(0);
question.setId(102L);
return 1;
}).when(questionMapper).insert((QuestionDO) any(QuestionDO.class));
QuestionDraftCommand command = new QuestionDraftCommand("Pick", "choice", "easy", List.of(
new QuestionDraftCommand.QuestionDraftOption("A", "first", null),
new QuestionDraftCommand.QuestionDraftOption("B", "second", null)),
"A", null, null);
service.createDraft(command);
ArgumentCaptor<QuestionDO> captor = ArgumentCaptor.forClass(QuestionDO.class);
verify(questionMapper).insert((QuestionDO) captor.capture());
assertTrue(captor.getValue().getOptions().contains("\"order\":1.0"));
assertTrue(captor.getValue().getOptions().contains("\"order\":2.0"));
}
@Test
void shouldRejectOptionAnswerThatDoesNotExist() {
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
QuestionDO draft = draft();
draft.setCorrectAnswer("C");
when(questionMapper.selectTenantOwnedById(10L, 101L)).thenReturn(draft);
ServiceException ex = assertThrows(ServiceException.class, () -> service.publish(101L, 7L));
assertEquals(QUESTION_CONTENT_NOT_PUBLISHABLE.getCode(), ex.getCode());
verify(questionMapper, never()).updateLifecycle(any(), any(), any(), any(), anyBoolean());
}
@Test
void shouldRejectIncompleteDraftBeforeLifecycleUpdate() {
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
QuestionDO draft = draft();
draft.setCorrectAnswer(null);
when(questionMapper.selectTenantOwnedById(10L, 101L)).thenReturn(draft);
ServiceException ex = assertThrows(ServiceException.class, () -> service.publish(101L, 7L));
assertEquals(QUESTION_CONTENT_NOT_PUBLISHABLE.getCode(), ex.getCode());
verify(questionMapper, never()).updateLifecycle(any(), any(), any(), any(), anyBoolean());
verifyNoInteractions(auditMapper);
}
@ParameterizedTest(name = "{0} from {1}")
@MethodSource("illegalLifecycleTransitions")
void shouldRejectIllegalLifecycleTransition(String operation, String currentStatus) {
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
QuestionDO question = draft();
question.setStatus(currentStatus);
question.setIsPublished("PUBLISHED".equals(currentStatus));
when(questionMapper.selectTenantOwnedById(10L, 101L)).thenReturn(question);
ServiceException ex = assertThrows(ServiceException.class, () -> {
if ("publish".equals(operation)) {
service.publish(101L, 7L);
} else {
service.archive(101L, 7L);
}
});
assertEquals(QUESTION_LIFECYCLE_CONFLICT.getCode(), ex.getCode());
verify(questionMapper, never()).updateLifecycle(any(), any(), any(), any(), anyBoolean());
verifyNoInteractions(auditMapper);
}
private static Stream<Arguments> illegalLifecycleTransitions() {
return Stream.of(
Arguments.of("publish", "PUBLISHED"),
Arguments.of("publish", "ARCHIVED"),
Arguments.of("archive", "DRAFT"),
Arguments.of("archive", "ARCHIVED"));
}
private QuestionDraftCommand command() {
return new QuestionDraftCommand("2 + 2 = ?", "choice", "easy", List.of(
new QuestionDraftCommand.QuestionDraftOption("A", "4", 1D),
new QuestionDraftCommand.QuestionDraftOption("B", "5", 2D)),
"A", "basic", null);
}
private QuestionDO draft() {
QuestionDO question = new QuestionDO();
question.setId(101L);
question.setTenantId(10L);
question.setScope("TENANT_OWNED");
question.setContentVersion(1);
question.setStem("2 + 2 = ?");
question.setType("choice");
question.setOptions("[{\"label\":\"A\",\"content\":\"4\",\"order\":1}," +
"{\"label\":\"B\",\"content\":\"5\",\"order\":2}]");
question.setCorrectAnswer("A");
question.setStatus("DRAFT");
question.setIsPublished(false);
return question;
}
}

View File

@@ -119,7 +119,7 @@ class EducationFlywayMigrationIntegrationTest {
assertThat(queryStrings(schema,
"SELECT COALESCE(version, 'BASELINE') FROM flyway_schema_history ORDER BY installed_rank"))
.containsExactly("4009", "4010", "4020", "4030", "4040", "4050", "4060", "4070");
.containsExactly("4009", "4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080");
assertThat(queryLong(schema,
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = current_schema() " +
"AND table_name = 'education_idempotency'"))
@@ -181,7 +181,7 @@ class EducationFlywayMigrationIntegrationTest {
assertThat(queryStrings(schema,
"SELECT version FROM flyway_schema_history WHERE success = TRUE ORDER BY installed_rank"))
.containsExactly("4010", "4020", "4030", "4040", "4050", "4060", "4070");
.containsExactly("4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080");
assertThat(queryStrings(schema,
"SELECT table_name FROM information_schema.tables " +
"WHERE table_schema = current_schema() AND table_name IN (" +
@@ -261,6 +261,261 @@ class EducationFlywayMigrationIntegrationTest {
.hasMessageContaining("education_school.region_id");
}
@Test
void shouldCreateDraftFirstQuestionLifecycleSchema() throws SQLException {
String schema = createSchema("question_lifecycle");
configureFlyway(schema, false).load().migrate();
assertThat(queryStrings(schema, """
SELECT column_default
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'education_question'
AND column_name IN ('status', 'is_published')
ORDER BY column_name
"""))
.containsExactly("false", "'DRAFT'::character varying");
assertThat(queryStrings(schema, """
SELECT table_name
FROM information_schema.tables
WHERE table_schema = current_schema()
AND table_name IN (
'education_question_version',
'education_question_lifecycle_audit')
ORDER BY table_name
"""))
.containsExactly(
"education_question_lifecycle_audit",
"education_question_version");
execute(schema, """
INSERT INTO education_question
(id, tenant_id, scope, stem, type, options)
VALUES (100, 10, 'TENANT_OWNED', 'Draft question', 'fill', '[]');
INSERT INTO education_question_version
(tenant_id, scope, question_id, version_number, stem, type, options)
VALUES (10, 'TENANT_OWNED', 100, 1, 'Draft question', 'fill', '[]');
""");
assertThat(queryStrings(schema,
"SELECT status || ':' || is_published FROM education_question WHERE id = 100"))
.containsExactly("DRAFT:false");
assertThatThrownBy(() -> execute(schema, """
INSERT INTO education_question
(tenant_id, scope, stem, type, options, status, is_published)
VALUES (10, 'TENANT_OWNED', 'Bypass draft', 'fill', '[]', 'PUBLISHED', true);
"""))
.hasMessageContaining("new questions must start in DRAFT");
assertThatThrownBy(() -> execute(schema, """
INSERT INTO education_question_lifecycle_audit
(tenant_id, question_id, content_version, actor_id, from_status, to_status)
VALUES (10, 100, 1, 7, 'DRAFT', 'PUBLISHED');
"""))
.hasMessageContaining("question lifecycle audit must accompany its state transition");
assertThatThrownBy(() -> execute(schema, """
UPDATE education_question
SET status = 'ARCHIVED', is_published = false
WHERE id = 100;
"""))
.hasMessageContaining("invalid question lifecycle transition");
assertThatThrownBy(() -> execute(schema, """
UPDATE education_question_version SET stem = 'mutated' WHERE question_id = 100;
"""))
.hasMessageContaining("question versions are immutable");
assertThatThrownBy(() -> execute(schema, """
UPDATE education_question SET stem = 'mutated projection' WHERE id = 100;
"""))
.hasMessageContaining("question content must change through immutable versions");
assertThatThrownBy(() -> execute(schema, """
UPDATE education_question
SET status = 'PUBLISHED', is_published = true
WHERE id = 100;
"""))
.hasMessageContaining("question lifecycle transition requires transactional audit");
execute(schema, """
DO $lifecycle$
BEGIN
UPDATE education_question
SET status = 'PUBLISHED', is_published = true
WHERE id = 100;
INSERT INTO education_question_lifecycle_audit
(tenant_id, question_id, content_version, actor_id, from_status, to_status)
VALUES (10, 100, 1, 7, 'DRAFT', 'PUBLISHED');
END
$lifecycle$;
""");
assertThat(queryStrings(schema,
"SELECT status || ':' || is_published FROM education_question WHERE id = 100"))
.containsExactly("PUBLISHED:true");
assertThatThrownBy(() -> execute(schema, """
UPDATE education_question_lifecycle_audit SET actor_id = 8 WHERE question_id = 100;
"""))
.hasMessageContaining("question lifecycle audit is append-only");
assertThatThrownBy(() -> execute(schema, """
DELETE FROM education_question_lifecycle_audit WHERE question_id = 100;
"""))
.hasMessageContaining("question lifecycle audit is append-only");
execute(schema, """
INSERT INTO education_question
(id, tenant_id, scope, stem, type, options)
VALUES (200, 0, 'PUBLIC', 'Public draft', 'fill', '[]');
INSERT INTO education_question_version
(tenant_id, scope, question_id, version_number, stem, type, options)
VALUES (0, 'PUBLIC', 200, 1, 'Public draft', 'fill', '[]');
""");
assertThatThrownBy(() -> execute(schema, """
UPDATE education_question
SET status = 'PUBLISHED', is_published = true
WHERE id = 200;
"""))
.hasMessageContaining("PUBLIC question lifecycle is not managed by tenant authoring");
assertThatThrownBy(() -> execute(schema, """
INSERT INTO education_question_version
(tenant_id, scope, question_id, version_number, stem, type, options)
VALUES (10, 'TENANT_OWNED', 200, 2, 'Wrong owner', 'fill', '[]');
"""))
.hasMessageContaining("question version ownership must equal question ownership");
assertThatThrownBy(() -> execute(schema, """
INSERT INTO education_question_version
(tenant_id, scope, question_id, version_number, stem, type, options)
VALUES (0, 'PUBLIC', 100, 2, 'Wrong owner', 'fill', '[]');
"""))
.hasMessageContaining("question version ownership must equal question ownership");
}
@Test
void shouldNormalizeHistoricalQuestionVisibilityIntoLifecycleStates() throws SQLException {
String schema = createSchema("question_history");
configureFlyway(schema, false).target("4070").load().migrate();
execute(schema, """
INSERT INTO education_question
(id, tenant_id, scope, stem, type, options, correct_answer, status, is_published)
VALUES (101, 10, 'TENANT_OWNED', 'Published', 'fill', '[]', 'ok', 'PUBLISHED', true),
(102, 10, 'TENANT_OWNED', 'Hidden', 'fill', '[]', NULL, 'HIDDEN', true),
(103, 10, 'TENANT_OWNED', 'Inactive', 'fill', '[]', NULL, 'INACTIVE', false),
(104, 10, 'TENANT_OWNED', 'False publication', 'fill', '[]', NULL, 'PUBLISHED', false),
(105, 10, 'TENANT_OWNED', 'Draft', 'fill', '[]', NULL, 'DRAFT', true);
""");
configureFlyway(schema, false).load().migrate();
assertThat(queryStrings(schema, """
SELECT id || ':' || status || ':' || is_published
FROM education_question
ORDER BY id
"""))
.containsExactly(
"101:PUBLISHED:true",
"102:ARCHIVED:false",
"103:ARCHIVED:false",
"104:ARCHIVED:false",
"105:DRAFT:false");
assertThat(queryLong(schema, "SELECT COUNT(*) FROM education_question_version"))
.isEqualTo(5L);
assertThatThrownBy(() -> execute(schema, """
DO $late_audit$
BEGIN
UPDATE education_question SET updater = updater WHERE id = 101;
INSERT INTO education_question_lifecycle_audit
(tenant_id, question_id, content_version, actor_id, from_status, to_status)
VALUES (10, 101, 1, 7, 'DRAFT', 'PUBLISHED');
END
$late_audit$;
"""))
.hasMessageContaining("question lifecycle audit must accompany its state transition");
}
@Test
void shouldFailClosedForUnsafeHistoricalPublishedQuestion() throws SQLException {
String schema = createSchema("unsafe_question_history");
configureFlyway(schema, false).target("4070").load().migrate();
execute(schema, """
INSERT INTO education_question
(id, tenant_id, scope, stem, type, options, correct_answer, status, is_published)
VALUES (101, 10, 'TENANT_OWNED', 'Unsafe published', 'choice',
'[{"label":"A","content":"One","order":1},{"label":"B","content":"Two","order":2}]',
'Z', 'PUBLISHED', true);
""");
assertThatThrownBy(() -> configureFlyway(schema, false).load().migrate())
.hasMessageContaining("unsafe historical published question 101 blocks V4080");
assertThat(queryLong(schema,
"SELECT COUNT(*) FROM flyway_schema_history WHERE version = '4080' AND success = TRUE"))
.isZero();
}
@Test
void shouldFailClosedForHistoricalTrimmedDuplicateOptionLabel() throws SQLException {
String schema = createSchema("unsafe_trimmed_option_label");
configureFlyway(schema, false).target("4070").load().migrate();
execute(schema, """
INSERT INTO education_question
(id, tenant_id, scope, stem, type, options, correct_answer, status, is_published)
VALUES (101, 10, 'TENANT_OWNED', 'Unsafe labels', 'choice',
'[{"label":"A","content":"One","order":1},{"label":" A ","content":"Two","order":2}]',
'A', 'PUBLISHED', true);
""");
assertThatThrownBy(() -> configureFlyway(schema, false).load().migrate())
.hasMessageContaining("unsafe historical published question 101 blocks V4080");
}
@Test
void shouldFailClosedForHistoricalDuplicateOptionOrder() throws SQLException {
String schema = createSchema("unsafe_duplicate_option_order");
configureFlyway(schema, false).target("4070").load().migrate();
execute(schema, """
INSERT INTO education_question
(id, tenant_id, scope, stem, type, options, correct_answer, status, is_published)
VALUES (101, 10, 'TENANT_OWNED', 'Unsafe order', 'choice',
'[{"label":"A","content":"One","order":1},{"label":"B","content":"Two","order":1.0}]',
'A', 'PUBLISHED', true);
""");
assertThatThrownBy(() -> configureFlyway(schema, false).load().migrate())
.hasMessageContaining("unsafe historical published question 101 blocks V4080");
}
@Test
void shouldProvisionAssignableSystemRbacPermissionsWhenPlatformMenuExists() throws SQLException {
String schema = createSchema("question_permissions");
configureFlyway(schema, false).target("4070").load().migrate();
createSystemMenuFixture(schema);
configureFlyway(schema, false).load().migrate();
assertThat(queryStrings(schema, """
SELECT permission
FROM system_menu
WHERE id BETWEEN 6801 AND 6804
ORDER BY id
"""))
.containsExactly(
"education:capability",
"education:question:author",
"education:question:publish",
"education:question:archive");
}
@Test
void shouldFailClosedWhenEducationPermissionSeedIsSoftDeleted() throws SQLException {
String schema = createSchema("question_permission_conflict");
configureFlyway(schema, false).target("4070").load().migrate();
createSystemMenuFixture(schema);
execute(schema, """
INSERT INTO system_menu
(id, name, permission, type, sort, parent_id, status, deleted)
VALUES (6802, 'Deleted author permission', 'education:question:author',
3, 2, 6800, 0, 1);
""");
assertThatThrownBy(() -> configureFlyway(schema, false).load().migrate())
.hasMessageContaining("Education System RBAC seed IDs conflict");
}
@Test
void shouldAttachGraphGuardToEveryCatalogReference() throws SQLException {
String schema = createSchema("catalog_triggers");
@@ -305,6 +560,8 @@ class EducationFlywayMigrationIntegrationTest {
('education_question_collection_question', 'trg_education_qcq_collection_reference_scope',
'collection_id|education_question_collection|'),
('education_question_collection_question', 'trg_education_qcq_question_reference_scope',
'question_id|education_question|'),
('education_question_version', 'trg_education_question_version_reference_scope',
'question_id|education_question|')
)
SELECT COUNT(*)
@@ -324,7 +581,7 @@ class EducationFlywayMigrationIntegrationTest {
WHERE replace(encode(trigger.tgargs, 'escape'), $$\\000$$, '|') =
expected.trigger_arguments
"""))
.isEqualTo(19L);
.isEqualTo(20L);
assertThat(queryLong(schema, """
SELECT COUNT(*)
FROM pg_trigger trigger
@@ -334,14 +591,22 @@ class EducationFlywayMigrationIntegrationTest {
AND trigger.tgname LIKE 'trg_education_%_scope_immutable'
AND NOT trigger.tgisinternal
"""))
.isEqualTo(11L);
.isEqualTo(12L);
assertThat(queryLong(schema, """
SELECT COUNT(*)
FROM information_schema.routine_privileges
WHERE specific_schema = current_schema()
AND routine_name IN (
'education_check_reference_scope',
'education_prevent_catalog_scope_change')
'education_prevent_catalog_scope_change',
'education_prevent_question_version_mutation',
'education_enforce_question_lifecycle_transition',
'education_prevent_question_lifecycle_audit_mutation',
'education_prevent_question_content_projection_mutation',
'education_require_question_lifecycle_audit',
'education_check_question_version_ownership',
'education_validate_question_lifecycle_audit_insert',
'education_question_answer_key_is_valid')
AND grantee = 'PUBLIC'
AND privilege_type = 'EXECUTE'
"""))
@@ -405,6 +670,32 @@ class EducationFlywayMigrationIntegrationTest {
.isZero();
}
private void createSystemMenuFixture(String schema) throws SQLException {
execute(schema, """
CREATE TABLE system_menu (
id BIGINT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
permission VARCHAR(100) NOT NULL DEFAULT '',
type SMALLINT NOT NULL,
sort INTEGER NOT NULL DEFAULT 0,
parent_id BIGINT NOT NULL DEFAULT 0,
path VARCHAR(200) DEFAULT '',
icon VARCHAR(100) DEFAULT '#',
component VARCHAR(255),
component_name VARCHAR(255),
status SMALLINT NOT NULL DEFAULT 0,
visible BOOLEAN NOT NULL DEFAULT true,
keep_alive BOOLEAN NOT NULL DEFAULT true,
always_show BOOLEAN NOT NULL DEFAULT true,
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 SMALLINT NOT NULL DEFAULT 0
);
""");
}
private void createCompatibleManualPracticeFixture(String schema) throws SQLException {
execute(schema, """
CREATE TABLE education_practice_session (

View File

@@ -1,6 +1,10 @@
-- PostgreSQL persistence test cleanup.
-- Module-owned Flyway creates the disposable schema; this only isolates test data.
TRUNCATE TABLE
education_question_lifecycle_transition_token,
education_question_lifecycle_audit,
education_question_version,
education_question,
education_idempotency,
education_favorite,
education_wrong_question_idempotency,