forked from wangziqi/ruoyi-vue-pro
feat(education): add tenant question placement
This commit is contained in:
@@ -60,6 +60,14 @@ _Avoid_: Read provider switch as implicit write permission
|
||||
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
|
||||
|
||||
**Question Placement**:
|
||||
The optimistic assignment of a tenant Draft Question Content item to one active, visible, selectable Content Node that the current tenant may reference. Placement is mutable only while the question is DRAFT and is frozen by publication. It is the current question-classification seam; it is not Category CRUD.
|
||||
_Avoid_: Category assignment, editable published classification
|
||||
|
||||
**Placement Version**:
|
||||
A non-negative optimistic concurrency version for Question Placement. It advances exactly once when the node changes, is independent from the immutable Question Content Version, and participates in publication CAS.
|
||||
_Avoid_: Content version, lifecycle version
|
||||
|
||||
**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
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## 当前状态
|
||||
|
||||
此模块提供教育业务功能骨架、题库目录浏览、题目预览与练习闭环,以及 `JAVA_READ` 下的租户题目草稿、发布和归档 tracer bullet。
|
||||
此模块提供教育业务功能骨架、题库目录浏览、题目预览与练习闭环,以及 `JAVA_READ` 下的租户题目草稿、目录放置、发布和归档 tracer bullet。
|
||||
|
||||
**已实现**:
|
||||
- 模块骨架与包结构
|
||||
@@ -16,10 +16,10 @@
|
||||
- 练习配置预览端点 (见下方 Practice API) — 学生端已认证
|
||||
- 答案保存端点 (见下方 Answer API) — 幂等保存,安全重试
|
||||
- 题目安全过滤(答案/解析绝不暴露到前端)
|
||||
- 管理端题目创作、发布和归档端点 — 仅在 `JAVA_READ` 下接受写入
|
||||
- 管理端题目创作、目录放置、发布和归档端点 — 仅在 `JAVA_READ` 下接受写入
|
||||
- 独立的功能开关配置 + Scalar 数据源配置
|
||||
- 错误码常量(通用 + 租户 + Catalog/Scalar + 题目/练习)
|
||||
- System RBAC 权限注解及 V4080 条件种子(`education:capability`、`education:question:author`、`education:question:publish`、`education:question:archive`);迁移不自动向任何角色授权
|
||||
- System RBAC 权限注解及 V4080/V4090 条件种子(`education:capability`、`education:question:author`、`education:question:classify`、`education:question:publish`、`education:question:archive`);迁移不自动向任何角色授权
|
||||
|
||||
## 功能配置
|
||||
|
||||
@@ -48,6 +48,7 @@ yudao:
|
||||
- `practice-write-enabled=false`:拒绝新建练习、保存答案和交卷;会话恢复、报告与历史查询保持可用。
|
||||
- `pilot-tenant-ids`:非空时仅允许列表内租户使用题库和练习写入能力。
|
||||
- 应用回滚只回滚应用版本或开关;不得执行 `*-rollback.sql`。SQL 回滚脚本仅用于明确的数据销毁场景。
|
||||
- 回滚到 V4090 之前的应用版本前,必须先切离 `JAVA_READ` 以停止原生题目写入;没有可用替代读取权威时应关闭 Education。V4090 Schema 保留并通过后续更高版本向前修正。
|
||||
|
||||
## API
|
||||
|
||||
@@ -75,11 +76,12 @@ GET /admin-api/education/capability
|
||||
|
||||
### 管理后台 - 题目创作与发布
|
||||
|
||||
这些端点只管理当前框架租户的 `TENANT_OWNED` 题目,不提供 PUBLIC/platform-curator 写入口。客户端不能提交 `tenantId`、`scope`、生命周期状态或 actor;新题固定以 `DRAFT/false` 创建,并且只允许 `DRAFT → PUBLISHED → ARCHIVED`。
|
||||
这些端点只管理当前框架租户的 `TENANT_OWNED` 题目,不提供 PUBLIC/platform-curator 写入口。客户端不能提交 `tenantId`、`scope`、生命周期状态或 actor;新题固定以 `DRAFT/false` 创建,放置到可用 Content Node 后才可发布,并且只允许 `DRAFT → PUBLISHED → ARCHIVED`。
|
||||
|
||||
| 端点 | 权限 | 说明 |
|
||||
|------|------|------|
|
||||
| `POST /admin-api/education/questions/drafts` | `education:question:author` | 创建当前租户草稿,返回题目 ID |
|
||||
| `PUT /admin-api/education/questions/{id}/placement` | `education:question:classify` | 将草稿放置或重新放置到允许的 Content Node,返回放置版本 |
|
||||
| `PUT /admin-api/education/questions/{id}/publish` | `education:question:publish` | 发布当前租户草稿 |
|
||||
| `PUT /admin-api/education/questions/{id}/archive` | `education:question:archive` | 归档当前租户已发布题目 |
|
||||
|
||||
@@ -100,6 +102,17 @@ GET /admin-api/education/capability
|
||||
}
|
||||
```
|
||||
|
||||
目录放置请求示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"nodeId": 200,
|
||||
"expectedPlacementVersion": 0
|
||||
}
|
||||
```
|
||||
|
||||
放置目标必须是当前租户可读的 PUBLIC 或同租户 Content Node,并且处于 active、非 hidden、selectable 状态。放置使用独立乐观版本;发布后不可重新放置。学生按 node 查询时会在同一条 PostgreSQL 查询中再次检查节点可用性。
|
||||
|
||||
`correctAnswer`、`explanation` 和 `analysis` 是服务端受保护内容;学生端仍只返回下文列出的安全题目投影。发布和归档采用 tenant/scope/expected-state CAS,并在同一事务追加生命周期审计。`SCALAR_READ` 或其他不支持的 provider mode 返回 `1_005_003_070`,不会查询或修改题目。
|
||||
|
||||
| 错误码 | 说明 |
|
||||
@@ -108,6 +121,9 @@ GET /admin-api/education/capability
|
||||
| `1_005_003_071` | 题目不存在或无权管理 |
|
||||
| `1_005_003_072` | 状态已变化或生命周期转换非法 |
|
||||
| `1_005_003_073` | 题目内容不完整或不安全,不能发布 |
|
||||
| `1_005_003_074` | 归类目标不存在或不可用 |
|
||||
| `1_005_003_075` | 归类版本或题目状态已变化 |
|
||||
| `1_005_003_076` | 题目尚未归类,不能发布 |
|
||||
|
||||
### 用户 APP - 教育租户识别
|
||||
|
||||
@@ -221,7 +237,8 @@ yudao-module-education/src/main/resources/db/migration/education/
|
||||
- `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 占用时迁移失败,且迁移不会向角色写入授权关系。
|
||||
- `V4090` 增加 Question Placement 乐观版本、PUBLIC 管理拒绝、发布后放置冻结、发布前可用节点约束,以及 `education:question:classify` 权限种子。
|
||||
- V4080/V4090 在平台 `system_menu` 已存在时条件写入 Education 权限种子;固定 ID 已被不同 permission 占用时迁移失败,且迁移不会向角色写入授权关系。
|
||||
- 旧 `education_answer_idempotency`、`education_submit_idempotency` 在首次接管时保留,后续清理必须使用更高版本的独立向前 migration。
|
||||
- `sql/postgresql/education/` 是手工初始化/设计历史,`sql/mysql/education/` 是过时归档;两者都不是运行时交付入口。
|
||||
- 禁止使用 `flyway clean` 或 `*-rollback.sql` 回退共享环境。应用回滚后如有 Schema 兼容问题,通过更高版本向前修复。
|
||||
@@ -242,7 +259,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/` 等核心框架文件。
|
||||
|
||||
因此:
|
||||
- **管理后台教育菜单项**:V4080 已条件写入 Education 权限/菜单记录,但不分配角色;前端仍无路由或页面组件,因此不声明已有可操作的可见教育页面。
|
||||
- **管理后台教育菜单项**:V4080/V4090 已条件写入 Education 权限/菜单记录,但不分配角色;前端仍无路由或页面组件,因此不声明已有可操作的可见教育页面。
|
||||
- **Student Web/H5 应用外壳**:前端源码不存在,无法建立。
|
||||
|
||||
### Student 端前端集成契约
|
||||
|
||||
@@ -2,7 +2,9 @@ package cn.iocoder.yudao.module.education.controller.admin.question;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.module.education.controller.admin.question.vo.QuestionDraftCreateReqVO;
|
||||
import cn.iocoder.yudao.module.education.controller.admin.question.vo.QuestionPlacementReqVO;
|
||||
import cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand;
|
||||
import cn.iocoder.yudao.module.education.service.question.authoring.QuestionPlacementCommand;
|
||||
import cn.iocoder.yudao.module.education.service.question.authoring.TenantQuestionLifecycleService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
@@ -44,6 +46,15 @@ public class QuestionAuthoringController {
|
||||
return success(lifecycleService.createDraft(command));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/placement")
|
||||
@Operation(summary = "将租户题目草稿放置到内容节点")
|
||||
@PreAuthorize("@ss.hasPermission('education:question:classify')")
|
||||
public CommonResult<Integer> place(@PathVariable("id") Long id,
|
||||
@Valid @RequestBody QuestionPlacementReqVO reqVO) {
|
||||
return success(lifecycleService.place(id, new QuestionPlacementCommand(
|
||||
reqVO.getNodeId(), reqVO.getExpectedPlacementVersion())));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/publish")
|
||||
@Operation(summary = "发布租户题目")
|
||||
@PreAuthorize("@ss.hasPermission('education:question:publish')")
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package cn.iocoder.yudao.module.education.controller.admin.question.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import lombok.Data;
|
||||
|
||||
@Schema(description = "管理后台 - 题目目录放置 Request VO")
|
||||
@Data
|
||||
public class QuestionPlacementReqVO {
|
||||
|
||||
@Schema(description = "目标内容节点 ID", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotNull
|
||||
@Positive
|
||||
private Long nodeId;
|
||||
|
||||
@Schema(description = "期望放置版本", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
|
||||
@NotNull
|
||||
@Min(0)
|
||||
@Max(Integer.MAX_VALUE - 1L)
|
||||
private Integer expectedPlacementVersion;
|
||||
}
|
||||
@@ -67,6 +67,9 @@ public class QuestionDO extends CatalogScopeDO {
|
||||
/** 所属内容节点 ID */
|
||||
private Long nodeId;
|
||||
|
||||
/** 目录放置的乐观锁版本;不属于题目内容版本 */
|
||||
private Integer placementVersion;
|
||||
|
||||
/** 标签 JSON 数组 */
|
||||
private String tags;
|
||||
|
||||
|
||||
@@ -4,10 +4,28 @@ import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.ContentNodeDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface ContentNodeMapper extends BaseMapperX<ContentNodeDO> {
|
||||
@Select("""
|
||||
SELECT *
|
||||
FROM education_content_node
|
||||
WHERE id = #{nodeId}
|
||||
AND deleted = false
|
||||
AND is_active = true
|
||||
AND is_hidden = false
|
||||
AND is_selectable = true
|
||||
AND ((tenant_id = #{tenantId} AND scope = 'TENANT_OWNED')
|
||||
OR (tenant_id = 0 AND scope = 'PUBLIC'))
|
||||
FOR SHARE
|
||||
""")
|
||||
ContentNodeDO selectAvailablePlacementTarget(@Param("tenantId") Long tenantId,
|
||||
@Param("nodeId") Long nodeId);
|
||||
|
||||
default List<ContentNodeDO> selectChildren(Long tenantId, Long entryId, Long parentId, boolean includeInactive,
|
||||
String markerType) {
|
||||
LambdaQueryWrapperX<ContentNodeDO> w = base(tenantId, entryId, includeInactive, markerType);
|
||||
|
||||
@@ -13,15 +13,40 @@ import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface QuestionMapper extends BaseMapperX<QuestionDO> {
|
||||
default IPage<QuestionDO> selectPublishedPage(IPage<QuestionDO> page, Long tenantId, Long nodeId,
|
||||
default IPage<QuestionDO> selectPublishedPage(IPage<QuestionDO> page, Long tenantId,
|
||||
String type, String difficulty) {
|
||||
LambdaQueryWrapperX<QuestionDO> w = visible(tenantId);
|
||||
if (nodeId != null) w.eq(QuestionDO::getNodeId, nodeId);
|
||||
if (type != null && !type.isBlank()) w.eq(QuestionDO::getType, type);
|
||||
if (difficulty != null && !difficulty.isBlank()) w.eq(QuestionDO::getDifficulty, difficulty);
|
||||
return selectPage(page, w.orderByAsc(QuestionDO::getSortOrder).orderByAsc(QuestionDO::getId));
|
||||
}
|
||||
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT q.*
|
||||
FROM education_question q
|
||||
JOIN education_content_node node ON node.id = q.node_id
|
||||
WHERE q.deleted = false
|
||||
AND q.is_published = true
|
||||
AND q.status = 'PUBLISHED'
|
||||
AND q.node_id = #{nodeId}
|
||||
AND ((q.tenant_id = #{tenantId} AND q.scope = 'TENANT_OWNED')
|
||||
OR (q.tenant_id = 0 AND q.scope = 'PUBLIC'))
|
||||
AND node.deleted = false
|
||||
AND node.is_active = true
|
||||
AND node.is_hidden = false
|
||||
AND node.is_selectable = true
|
||||
AND ((node.tenant_id = #{tenantId} AND node.scope = 'TENANT_OWNED')
|
||||
OR (node.tenant_id = 0 AND node.scope = 'PUBLIC'))
|
||||
<if test="type != null and type != ''">AND q.type = #{type}</if>
|
||||
<if test="difficulty != null and difficulty != ''">AND q.difficulty = #{difficulty}</if>
|
||||
ORDER BY q.sort_order, q.id
|
||||
</script>
|
||||
""")
|
||||
IPage<QuestionDO> selectPublishedPageByAvailableNode(IPage<QuestionDO> page,
|
||||
@Param("tenantId") Long tenantId, @Param("nodeId") Long nodeId,
|
||||
@Param("type") String type, @Param("difficulty") String difficulty);
|
||||
|
||||
default IPage<QuestionDO> selectPublishedPageByIds(IPage<QuestionDO> page, Long tenantId, List<Long> ids,
|
||||
String type, String difficulty) {
|
||||
if (ids == null || ids.isEmpty()) {
|
||||
@@ -62,13 +87,30 @@ public interface QuestionMapper extends BaseMapperX<QuestionDO> {
|
||||
return selectCount(w);
|
||||
}
|
||||
|
||||
default long countPublished(Long tenantId, Long nodeId, String type, String difficulty) {
|
||||
LambdaQueryWrapperX<QuestionDO> w = visible(tenantId);
|
||||
if (nodeId != null) w.eq(QuestionDO::getNodeId, nodeId);
|
||||
if (type != null && !type.isBlank()) w.eq(QuestionDO::getType, type);
|
||||
if (difficulty != null && !difficulty.isBlank()) w.eq(QuestionDO::getDifficulty, difficulty);
|
||||
return selectCount(w);
|
||||
}
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT count(*)
|
||||
FROM education_question q
|
||||
JOIN education_content_node node ON node.id = q.node_id
|
||||
WHERE q.deleted = false
|
||||
AND q.is_published = true
|
||||
AND q.status = 'PUBLISHED'
|
||||
AND q.node_id = #{nodeId}
|
||||
AND ((q.tenant_id = #{tenantId} AND q.scope = 'TENANT_OWNED')
|
||||
OR (q.tenant_id = 0 AND q.scope = 'PUBLIC'))
|
||||
AND node.deleted = false
|
||||
AND node.is_active = true
|
||||
AND node.is_hidden = false
|
||||
AND node.is_selectable = true
|
||||
AND ((node.tenant_id = #{tenantId} AND node.scope = 'TENANT_OWNED')
|
||||
OR (node.tenant_id = 0 AND node.scope = 'PUBLIC'))
|
||||
<if test="type != null and type != ''">AND q.type = #{type}</if>
|
||||
<if test="difficulty != null and difficulty != ''">AND q.difficulty = #{difficulty}</if>
|
||||
</script>
|
||||
""")
|
||||
long countPublishedByAvailableNode(@Param("tenantId") Long tenantId,
|
||||
@Param("nodeId") Long nodeId, @Param("type") String type,
|
||||
@Param("difficulty") String difficulty);
|
||||
|
||||
default QuestionDO selectPublishedById(Long tenantId, Long id) {
|
||||
return selectOne(visible(tenantId).eq(QuestionDO::getId, id));
|
||||
@@ -82,16 +124,28 @@ public interface QuestionMapper extends BaseMapperX<QuestionDO> {
|
||||
}
|
||||
|
||||
default int updateLifecycle(Long tenantId, Long id, String expectedStatus,
|
||||
String targetStatus, boolean published) {
|
||||
String targetStatus, boolean published, Integer expectedPlacementVersion) {
|
||||
return update(null, new LambdaUpdateWrapper<QuestionDO>()
|
||||
.eq(QuestionDO::getId, id)
|
||||
.eq(QuestionDO::getTenantId, tenantId)
|
||||
.eq(QuestionDO::getScope, "TENANT_OWNED")
|
||||
.eq(QuestionDO::getStatus, expectedStatus)
|
||||
.eq(QuestionDO::getPlacementVersion, expectedPlacementVersion)
|
||||
.set(QuestionDO::getStatus, targetStatus)
|
||||
.set(QuestionDO::getIsPublished, published));
|
||||
}
|
||||
|
||||
default int updatePlacement(Long tenantId, Long id, Long nodeId, int expectedPlacementVersion) {
|
||||
return update(null, new LambdaUpdateWrapper<QuestionDO>()
|
||||
.eq(QuestionDO::getId, id)
|
||||
.eq(QuestionDO::getTenantId, tenantId)
|
||||
.eq(QuestionDO::getScope, "TENANT_OWNED")
|
||||
.eq(QuestionDO::getStatus, "DRAFT")
|
||||
.eq(QuestionDO::getPlacementVersion, expectedPlacementVersion)
|
||||
.set(QuestionDO::getNodeId, nodeId)
|
||||
.set(QuestionDO::getPlacementVersion, expectedPlacementVersion + 1));
|
||||
}
|
||||
|
||||
private LambdaQueryWrapperX<QuestionDO> visible(Long tenantId) {
|
||||
LambdaQueryWrapperX<QuestionDO> w = new LambdaQueryWrapperX<>();
|
||||
CatalogScopeQuery.apply(w, QuestionDO::getTenantId, QuestionDO::getScope, tenantId);
|
||||
|
||||
@@ -104,4 +104,7 @@ public interface ErrorCodeConstants {
|
||||
ErrorCode QUESTION_AUTHORING_NOT_FOUND = new ErrorCode(1_005_003_071, "题目不存在或无权管理");
|
||||
ErrorCode QUESTION_LIFECYCLE_CONFLICT = new ErrorCode(1_005_003_072, "题目状态已变化,无法执行 {} 操作");
|
||||
ErrorCode QUESTION_CONTENT_NOT_PUBLISHABLE = new ErrorCode(1_005_003_073, "题目内容不完整或不安全,无法发布");
|
||||
ErrorCode QUESTION_PLACEMENT_TARGET_UNAVAILABLE = new ErrorCode(1_005_003_074, "题目归类目标不存在或不可用");
|
||||
ErrorCode QUESTION_PLACEMENT_CONFLICT = new ErrorCode(1_005_003_075, "题目归类已变化,请刷新后重试");
|
||||
ErrorCode QUESTION_PLACEMENT_REQUIRED = new ErrorCode(1_005_003_076, "题目尚未归类,无法发布");
|
||||
}
|
||||
|
||||
@@ -161,7 +161,11 @@ public class JavaCatalogProvider implements CatalogProvider, QuestionCatalogProv
|
||||
.map(QuestionCollectionQuestionDO::getQuestionId).toList();
|
||||
IPage<QuestionDO> result = ids != null
|
||||
? readWithExplicitCatalogScope(() -> questionMapper.selectPublishedPageByIds(new Page<>(page, bounded), tenantId(), ids, type, difficulty))
|
||||
: readWithExplicitCatalogScope(() -> questionMapper.selectPublishedPage(new Page<>(page, bounded), tenantId(), nid, type, difficulty));
|
||||
: nid != null
|
||||
? readWithExplicitCatalogScope(() -> questionMapper.selectPublishedPageByAvailableNode(
|
||||
new Page<>(page, bounded), tenantId(), nid, type, difficulty))
|
||||
: readWithExplicitCatalogScope(() -> questionMapper.selectPublishedPage(
|
||||
new Page<>(page, bounded), tenantId(), type, difficulty));
|
||||
return buildPageResult(result);
|
||||
}
|
||||
|
||||
@@ -234,7 +238,8 @@ public class JavaCatalogProvider implements CatalogProvider, QuestionCatalogProv
|
||||
}
|
||||
|
||||
private long countVisibleNodeQuestions(Long nodeId, String type, String difficulty) {
|
||||
return readWithExplicitCatalogScope(() -> questionMapper.countPublished(tenantId(), nodeId, type, difficulty));
|
||||
return readWithExplicitCatalogScope(() -> questionMapper.countPublishedByAvailableNode(
|
||||
tenantId(), nodeId, type, difficulty));
|
||||
}
|
||||
|
||||
private CatalogPracticeBlueprintDTO toPracticeBlueprintDTO(PracticeBlueprintDO bp, long eligible) {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package cn.iocoder.yudao.module.education.service.question.authoring;
|
||||
|
||||
/** 将不可见的租户题目草稿放置到学生目录节点;版本用于乐观并发控制。 */
|
||||
public record QuestionPlacementCommand(Long nodeId, Integer expectedPlacementVersion) {
|
||||
}
|
||||
@@ -5,6 +5,8 @@ public interface TenantQuestionLifecycleService {
|
||||
|
||||
Long createDraft(QuestionDraftCommand command);
|
||||
|
||||
int place(Long questionId, QuestionPlacementCommand command);
|
||||
|
||||
void publish(Long questionId, Long actorId);
|
||||
|
||||
void archive(Long questionId, Long actorId);
|
||||
|
||||
@@ -3,10 +3,12 @@ package cn.iocoder.yudao.module.education.service.question.authoring;
|
||||
import cn.iocoder.yudao.framework.common.exception.ServiceException;
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;
|
||||
import cn.iocoder.yudao.module.education.config.EducationProperties;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionDO;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionLifecycleAuditDO;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionVersionDO;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.catalog.ContentNodeMapper;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionLifecycleAuditMapper;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionMapper;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionVersionMapper;
|
||||
@@ -41,19 +43,46 @@ public class TenantQuestionLifecycleServiceImpl implements TenantQuestionLifecyc
|
||||
|
||||
private final EducationProperties properties;
|
||||
private final QuestionMapper questionMapper;
|
||||
private final ContentNodeMapper contentNodeMapper;
|
||||
private final QuestionVersionMapper versionMapper;
|
||||
private final QuestionLifecycleAuditMapper auditMapper;
|
||||
|
||||
public TenantQuestionLifecycleServiceImpl(EducationProperties properties,
|
||||
QuestionMapper questionMapper,
|
||||
ContentNodeMapper contentNodeMapper,
|
||||
QuestionVersionMapper versionMapper,
|
||||
QuestionLifecycleAuditMapper auditMapper) {
|
||||
this.properties = properties;
|
||||
this.questionMapper = questionMapper;
|
||||
this.contentNodeMapper = contentNodeMapper;
|
||||
this.versionMapper = versionMapper;
|
||||
this.auditMapper = auditMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public int place(Long questionId, QuestionPlacementCommand command) {
|
||||
assertAuthoringMode();
|
||||
Long tenantId = TenantContextHolder.getRequiredTenantId();
|
||||
QuestionDO question = questionMapper.selectTenantOwnedById(tenantId, questionId);
|
||||
if (question == null) {
|
||||
throw exception(QUESTION_AUTHORING_NOT_FOUND);
|
||||
}
|
||||
if (!DRAFT.equals(question.getStatus())
|
||||
|| !command.expectedPlacementVersion().equals(question.getPlacementVersion())) {
|
||||
throw exception(QUESTION_PLACEMENT_CONFLICT);
|
||||
}
|
||||
requireAvailablePlacementTarget(tenantId, command.nodeId());
|
||||
if (command.nodeId().equals(question.getNodeId())) {
|
||||
throw exception(QUESTION_PLACEMENT_CONFLICT);
|
||||
}
|
||||
if (questionMapper.updatePlacement(tenantId, questionId, command.nodeId(),
|
||||
command.expectedPlacementVersion()) != 1) {
|
||||
throw exception(QUESTION_PLACEMENT_CONFLICT);
|
||||
}
|
||||
return command.expectedPlacementVersion() + 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long createDraft(QuestionDraftCommand command) {
|
||||
@@ -128,7 +157,8 @@ public class TenantQuestionLifecycleServiceImpl implements TenantQuestionLifecyc
|
||||
if (PUBLISHED.equals(toStatus)) {
|
||||
validatePublishable(question);
|
||||
}
|
||||
if (questionMapper.updateLifecycle(tenantId, questionId, fromStatus, toStatus, published) != 1) {
|
||||
if (questionMapper.updateLifecycle(tenantId, questionId, fromStatus, toStatus, published,
|
||||
question.getPlacementVersion()) != 1) {
|
||||
throw exception(QUESTION_LIFECYCLE_CONFLICT, operation);
|
||||
}
|
||||
|
||||
@@ -155,6 +185,18 @@ public class TenantQuestionLifecycleServiceImpl implements TenantQuestionLifecyc
|
||||
} catch (ServiceException ex) {
|
||||
throw exception(QUESTION_CONTENT_NOT_PUBLISHABLE);
|
||||
}
|
||||
if (question.getNodeId() == null) {
|
||||
throw exception(QUESTION_PLACEMENT_REQUIRED);
|
||||
}
|
||||
Long tenantId = TenantContextHolder.getRequiredTenantId();
|
||||
requireAvailablePlacementTarget(tenantId, question.getNodeId());
|
||||
}
|
||||
|
||||
private void requireAvailablePlacementTarget(Long tenantId, Long nodeId) {
|
||||
if (TenantUtils.executeIgnore(() -> contentNodeMapper.selectAvailablePlacementTarget(
|
||||
tenantId, nodeId)) == null) {
|
||||
throw exception(QUESTION_PLACEMENT_TARGET_UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateAnswerKey(String rawType, String rawAnswer,
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
-- EDU-010: make tenant Question Placement an explicit optimistic command and
|
||||
-- require a stable, available Content Node before native publication.
|
||||
|
||||
ALTER TABLE education_question
|
||||
ADD COLUMN placement_version INTEGER NOT NULL DEFAULT 0,
|
||||
ADD CONSTRAINT ck_education_question_placement_version
|
||||
CHECK (placement_version >= 0);
|
||||
|
||||
COMMENT ON COLUMN education_question.placement_version IS
|
||||
'题目目录放置的乐观锁版本;不属于不可变题目内容版本';
|
||||
|
||||
CREATE FUNCTION education_enforce_question_placement_mutation()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = pg_catalog, pg_temp
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.node_id IS NOT DISTINCT FROM OLD.node_id
|
||||
AND NEW.placement_version IS NOT DISTINCT FROM OLD.placement_version THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
IF NEW.scope <> 'TENANT_OWNED' OR NEW.tenant_id <= 0 THEN
|
||||
RAISE EXCEPTION 'PUBLIC question placement is not managed by tenant authoring'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF OLD.status <> 'DRAFT' OR NEW.status <> 'DRAFT' THEN
|
||||
RAISE EXCEPTION 'published or archived question placement is immutable'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF NEW.node_id IS NOT DISTINCT FROM OLD.node_id THEN
|
||||
RAISE EXCEPTION 'question placement version cannot change without placement'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF NEW.placement_version IS NULL
|
||||
OR NEW.placement_version <> OLD.placement_version + 1 THEN
|
||||
RAISE EXCEPTION 'question placement version must advance exactly once'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION education_enforce_question_lifecycle_transition()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = pg_catalog, pg_temp
|
||||
AS $$
|
||||
DECLARE
|
||||
placement_node_id BIGINT;
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
IF NEW.status <> 'DRAFT' OR NEW.is_published THEN
|
||||
RAISE EXCEPTION 'new questions must start in DRAFT'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
IF NEW.status IS DISTINCT FROM OLD.status
|
||||
AND (NEW.scope <> 'TENANT_OWNED' OR NEW.tenant_id <= 0) THEN
|
||||
RAISE EXCEPTION 'PUBLIC question lifecycle is not managed by tenant authoring'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF NEW.status IS DISTINCT FROM OLD.status AND NOT (
|
||||
(OLD.status = 'DRAFT' AND NEW.status = 'PUBLISHED') OR
|
||||
(OLD.status = 'PUBLISHED' AND NEW.status = 'ARCHIVED')
|
||||
) THEN
|
||||
RAISE EXCEPTION 'invalid question lifecycle transition from % to %', OLD.status, NEW.status
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF OLD.status = 'DRAFT' AND NEW.status = 'PUBLISHED' THEN
|
||||
IF NEW.node_id IS NULL THEN
|
||||
RAISE EXCEPTION 'question must be placed before publication'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
EXECUTE format(
|
||||
'SELECT id FROM %I.education_content_node' ||
|
||||
' WHERE id = $1 AND deleted = false AND is_active = true' ||
|
||||
' AND is_hidden = false AND is_selectable = true FOR SHARE',
|
||||
TG_TABLE_SCHEMA)
|
||||
INTO placement_node_id
|
||||
USING NEW.node_id;
|
||||
IF placement_node_id IS NULL THEN
|
||||
RAISE EXCEPTION 'question placement target is unavailable'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
END IF;
|
||||
IF NEW.status IS DISTINCT FROM OLD.status THEN
|
||||
EXECUTE format(
|
||||
'INSERT INTO %I.education_question_lifecycle_transition_token' ||
|
||||
' (transaction_id, tenant_id, question_id, content_version, from_status, to_status)' ||
|
||||
' VALUES ($1, $2, $3, $4, $5, $6)',
|
||||
TG_TABLE_SCHEMA)
|
||||
USING pg_current_xact_id()::text::BIGINT, NEW.tenant_id, NEW.id,
|
||||
NEW.content_version, OLD.status, NEW.status;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION education_enforce_question_placement_mutation() FROM PUBLIC;
|
||||
REVOKE ALL ON FUNCTION education_enforce_question_lifecycle_transition() FROM PUBLIC;
|
||||
|
||||
CREATE TRIGGER trg_education_question_placement_mutation
|
||||
BEFORE UPDATE OF node_id, placement_version ON education_question
|
||||
FOR EACH ROW EXECUTE FUNCTION education_enforce_question_placement_mutation();
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
installed_permission_count INTEGER;
|
||||
BEGIN
|
||||
IF to_regclass('system_menu') IS NULL THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
INSERT INTO system_menu (
|
||||
id, name, permission, type, sort, parent_id, path, icon, component,
|
||||
component_name, status, visible, keep_alive, always_show, creator, updater
|
||||
) VALUES
|
||||
(6805, '题目归类', 'education:question:classify', 3, 5, 6800, '', '', NULL,
|
||||
NULL, 0, false, true, true, 'education-flyway', 'education-flyway')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
SELECT count(*)
|
||||
INTO installed_permission_count
|
||||
FROM system_menu
|
||||
WHERE id = 6805
|
||||
AND permission = 'education:question:classify'
|
||||
AND type = 3
|
||||
AND parent_id = 6800
|
||||
AND status = 0
|
||||
AND deleted = 0;
|
||||
IF installed_permission_count <> 1 THEN
|
||||
RAISE EXCEPTION 'Education Question Placement RBAC seed ID conflicts with existing system_menu row'
|
||||
USING ERRCODE = '23505';
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
@@ -3,7 +3,9 @@ package cn.iocoder.yudao.module.education.controller.admin.question;
|
||||
import cn.iocoder.yudao.framework.security.core.LoginUser;
|
||||
import cn.iocoder.yudao.framework.security.core.service.SecurityFrameworkService;
|
||||
import cn.iocoder.yudao.module.education.controller.admin.question.vo.QuestionDraftCreateReqVO;
|
||||
import cn.iocoder.yudao.module.education.controller.admin.question.vo.QuestionPlacementReqVO;
|
||||
import cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand;
|
||||
import cn.iocoder.yudao.module.education.service.question.authoring.QuestionPlacementCommand;
|
||||
import cn.iocoder.yudao.module.education.service.question.authoring.TenantQuestionLifecycleService;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -32,6 +34,7 @@ class QuestionAuthoringControllerContractTest {
|
||||
|
||||
private static final Long ACTOR_ID = 7L;
|
||||
private static final String AUTHOR_PERMISSION = "education:question:author";
|
||||
private static final String CLASSIFY_PERMISSION = "education:question:classify";
|
||||
private static final String PUBLISH_PERMISSION = "education:question:publish";
|
||||
private static final String ARCHIVE_PERMISSION = "education:question:archive";
|
||||
|
||||
@@ -67,6 +70,14 @@ class QuestionAuthoringControllerContractTest {
|
||||
assertEquals(0, lifecycleService.totalCalls());
|
||||
}
|
||||
|
||||
@Test
|
||||
void authorPermissionDoesNotAllowPlacement() {
|
||||
grant(AUTHOR_PERMISSION);
|
||||
|
||||
assertThrows(AccessDeniedException.class, () -> controller.place(101L, null));
|
||||
assertEquals(0, lifecycleService.totalCalls());
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishPermissionDoesNotAllowArchiving() {
|
||||
grant(PUBLISH_PERMISSION);
|
||||
@@ -75,6 +86,14 @@ class QuestionAuthoringControllerContractTest {
|
||||
assertEquals(0, lifecycleService.totalCalls());
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyPermissionDoesNotAllowPublishing() {
|
||||
grant(CLASSIFY_PERMISSION);
|
||||
|
||||
assertThrows(AccessDeniedException.class, () -> controller.publish(101L));
|
||||
assertEquals(0, lifecycleService.totalCalls());
|
||||
}
|
||||
|
||||
@Test
|
||||
void archivePermissionDoesNotAllowDraftCreation() {
|
||||
grant(ARCHIVE_PERMISSION);
|
||||
@@ -92,6 +111,19 @@ class QuestionAuthoringControllerContractTest {
|
||||
assertEquals(1, lifecycleService.totalCalls());
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyPermissionAllowsPlacement() {
|
||||
grant(CLASSIFY_PERMISSION);
|
||||
QuestionPlacementReqVO request = new QuestionPlacementReqVO();
|
||||
request.setNodeId(201L);
|
||||
request.setExpectedPlacementVersion(0);
|
||||
|
||||
assertEquals(1, controller.place(101L, request).getData());
|
||||
assertEquals(101L, lifecycleService.placedQuestionId);
|
||||
assertEquals(new QuestionPlacementCommand(201L, 0), lifecycleService.placementCommand);
|
||||
assertEquals(1, lifecycleService.totalCalls());
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishPermissionAllowsPublishing() {
|
||||
grant(PUBLISH_PERMISSION);
|
||||
@@ -208,6 +240,8 @@ class QuestionAuthoringControllerContractTest {
|
||||
private Long publishedByActorId;
|
||||
private Long archivedQuestionId;
|
||||
private Long archivedByActorId;
|
||||
private Long placedQuestionId;
|
||||
private QuestionPlacementCommand placementCommand;
|
||||
|
||||
void reset() {
|
||||
createdCommand = null;
|
||||
@@ -215,12 +249,15 @@ class QuestionAuthoringControllerContractTest {
|
||||
publishedByActorId = null;
|
||||
archivedQuestionId = null;
|
||||
archivedByActorId = null;
|
||||
placedQuestionId = null;
|
||||
placementCommand = null;
|
||||
}
|
||||
|
||||
int totalCalls() {
|
||||
int count = createdCommand != null ? 1 : 0;
|
||||
count += publishedQuestionId != null ? 1 : 0;
|
||||
count += archivedQuestionId != null ? 1 : 0;
|
||||
count += placedQuestionId != null ? 1 : 0;
|
||||
return count;
|
||||
}
|
||||
|
||||
@@ -230,6 +267,13 @@ class QuestionAuthoringControllerContractTest {
|
||||
return 101L;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int place(Long questionId, QuestionPlacementCommand command) {
|
||||
placedQuestionId = questionId;
|
||||
placementCommand = command;
|
||||
return command.expectedPlacementVersion() + 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void publish(Long questionId, Long actorId) {
|
||||
publishedQuestionId = questionId;
|
||||
|
||||
@@ -5,8 +5,13 @@ import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;
|
||||
import cn.iocoder.yudao.module.education.config.EducationProperties;
|
||||
import cn.iocoder.yudao.module.education.controller.app.question.vo.SafeQuestionRespVO;
|
||||
import cn.iocoder.yudao.module.education.controller.app.question.vo.QuestionPageReqVO;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionDO;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionVersionDO;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.ContentEntryDO;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.ContentNodeDO;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.catalog.ContentEntryMapper;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.catalog.ContentNodeMapper;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionLifecycleAuditMapper;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionMapper;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionVersionMapper;
|
||||
@@ -46,10 +51,19 @@ class TenantQuestionLifecyclePostgreSqlIntegrationTest extends PostgreSqlDbInteg
|
||||
@Resource private QuestionMapper questionMapper;
|
||||
@Resource private QuestionVersionMapper versionMapper;
|
||||
@Resource private PlatformTransactionManager transactionManager;
|
||||
@Resource private ContentEntryMapper contentEntryMapper;
|
||||
@Resource private ContentNodeMapper contentNodeMapper;
|
||||
|
||||
@BeforeEach
|
||||
void setUpTenant() {
|
||||
TenantContextHolder.setTenantId(10L);
|
||||
TenantUtils.executeIgnore(() -> {
|
||||
insertEntry(100L, 10L, "TENANT_OWNED", "questions", "Questions");
|
||||
insertEntry(110L, 0L, "PUBLIC", "public-questions", "Public Questions");
|
||||
insertNode(200L, 10L, "TENANT_OWNED", 100L, "Algebra");
|
||||
insertNode(201L, 10L, "TENANT_OWNED", 100L, "Geometry");
|
||||
insertNode(210L, 0L, "PUBLIC", 110L, "Public Algebra");
|
||||
});
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
@@ -66,6 +80,8 @@ class TenantQuestionLifecyclePostgreSqlIntegrationTest extends PostgreSqlDbInteg
|
||||
"A", "secret explanation", "secret analysis"));
|
||||
|
||||
assertQuestionNotVisible(id);
|
||||
assertEquals(1, lifecycleService.place(id, new QuestionPlacementCommand(200L, 0)));
|
||||
assertNodeQuestionCount(200L, 0L);
|
||||
lifecycleService.publish(id, 7L);
|
||||
|
||||
SafeQuestionRespVO published = questionCatalogService.getQuestion(String.valueOf(id));
|
||||
@@ -73,6 +89,7 @@ class TenantQuestionLifecyclePostgreSqlIntegrationTest extends PostgreSqlDbInteg
|
||||
assertEquals(2, published.getOptions().size());
|
||||
assertFalse(cn.iocoder.yudao.framework.common.util.json.JsonUtils.toJsonString(published)
|
||||
.matches(".*(correctAnswer|explanation|analysis|isCorrect).*"));
|
||||
assertNodeQuestionCount(200L, 1L);
|
||||
|
||||
lifecycleService.archive(id, 7L);
|
||||
assertQuestionNotVisible(id);
|
||||
@@ -84,6 +101,7 @@ class TenantQuestionLifecyclePostgreSqlIntegrationTest extends PostgreSqlDbInteg
|
||||
Long id = lifecycleService.createDraft(new QuestionDraftCommand(
|
||||
"Capital of France?", "text", "easy", List.of(),
|
||||
"Paris", null, null));
|
||||
lifecycleService.place(id, new QuestionPlacementCommand(200L, 0));
|
||||
|
||||
assertThrows(RuntimeException.class, () -> lifecycleService.publish(id, null));
|
||||
|
||||
@@ -95,6 +113,7 @@ class TenantQuestionLifecyclePostgreSqlIntegrationTest extends PostgreSqlDbInteg
|
||||
@Test
|
||||
void shouldAllowOnlyOneConcurrentPublishAndAppendOneAudit() throws InterruptedException {
|
||||
Long id = lifecycleService.createDraft(questionCommand("Concurrent publish"));
|
||||
lifecycleService.place(id, new QuestionPlacementCommand(200L, 0));
|
||||
CountDownLatch ready = new CountDownLatch(2);
|
||||
CountDownLatch go = new CountDownLatch(1);
|
||||
AtomicInteger successCount = new AtomicInteger();
|
||||
@@ -120,9 +139,41 @@ class TenantQuestionLifecyclePostgreSqlIntegrationTest extends PostgreSqlDbInteg
|
||||
assertEquals(1L, auditMapper.selectCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAllowOnlyOneConcurrentPlacementForExpectedVersion() throws InterruptedException {
|
||||
Long id = lifecycleService.createDraft(questionCommand("Concurrent placement"));
|
||||
CountDownLatch ready = new CountDownLatch(2);
|
||||
CountDownLatch go = new CountDownLatch(1);
|
||||
AtomicInteger successCount = new AtomicInteger();
|
||||
AtomicReference<Throwable> firstError = new AtomicReference<>();
|
||||
AtomicReference<Throwable> secondError = new AtomicReference<>();
|
||||
|
||||
Thread first = placementThread(id, 200L, ready, go, successCount, firstError);
|
||||
Thread second = placementThread(id, 201L, ready, go, successCount, secondError);
|
||||
first.start();
|
||||
second.start();
|
||||
assertTrue(ready.await(5, TimeUnit.SECONDS));
|
||||
go.countDown();
|
||||
first.join(10000);
|
||||
second.join(10000);
|
||||
|
||||
assertFalse(first.isAlive());
|
||||
assertFalse(second.isAlive());
|
||||
assertEquals(1, successCount.get());
|
||||
Throwable loserError = firstError.get() != null ? firstError.get() : secondError.get();
|
||||
ServiceException conflict = assertInstanceOf(ServiceException.class, loserError);
|
||||
assertEquals(QUESTION_PLACEMENT_CONFLICT.getCode(), conflict.getCode());
|
||||
QuestionDO stored = questionMapper.selectTenantOwnedById(10L, id);
|
||||
assertTrue(stored.getNodeId().equals(200L) || stored.getNodeId().equals(201L));
|
||||
assertEquals(1, stored.getPlacementVersion());
|
||||
assertEquals("DRAFT", stored.getStatus());
|
||||
assertEquals(0L, auditMapper.selectCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectCrossTenantPublishAndArchive() {
|
||||
Long id = lifecycleService.createDraft(questionCommand("Tenant isolated"));
|
||||
lifecycleService.place(id, new QuestionPlacementCommand(200L, 0));
|
||||
|
||||
TenantContextHolder.setTenantId(20L);
|
||||
assertAuthoringNotFound(() -> lifecycleService.publish(id, 8L));
|
||||
@@ -147,12 +198,67 @@ class TenantQuestionLifecyclePostgreSqlIntegrationTest extends PostgreSqlDbInteg
|
||||
assertEquals(0L, auditMapper.selectCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAllowTenantDraftPlacementOnPublicNode() {
|
||||
Long id = lifecycleService.createDraft(questionCommand("Public classification"));
|
||||
|
||||
assertEquals(1, lifecycleService.place(id, new QuestionPlacementCommand(210L, 0)));
|
||||
lifecycleService.publish(id, 7L);
|
||||
|
||||
assertNodeQuestionCount(210L, 1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHidePublishedQuestionsWhenPlacementNodeBecomesUnavailable() {
|
||||
Long id = lifecycleService.createDraft(questionCommand("Hidden route"));
|
||||
lifecycleService.place(id, new QuestionPlacementCommand(200L, 0));
|
||||
lifecycleService.publish(id, 7L);
|
||||
assertNodeQuestionCount(200L, 1L);
|
||||
|
||||
ContentNodeDO update = new ContentNodeDO();
|
||||
update.setId(200L);
|
||||
update.setIsActive(false);
|
||||
contentNodeMapper.updateById(update);
|
||||
|
||||
assertNodeQuestionCount(200L, 0L);
|
||||
assertNotNull(questionCatalogService.getQuestion(String.valueOf(id)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectCrossTenantPlacementWithoutPartialWrite() {
|
||||
Long id = lifecycleService.createDraft(questionCommand("Tenant placement"));
|
||||
TenantUtils.executeIgnore(() -> {
|
||||
insertEntry(120L, 20L, "TENANT_OWNED", "other-questions", "Other Questions");
|
||||
insertNode(220L, 20L, "TENANT_OWNED", 120L, "Other Algebra");
|
||||
});
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> lifecycleService.place(id, new QuestionPlacementCommand(220L, 0)));
|
||||
|
||||
assertEquals(QUESTION_PLACEMENT_TARGET_UNAVAILABLE.getCode(), ex.getCode());
|
||||
QuestionDO stored = questionMapper.selectTenantOwnedById(10L, id);
|
||||
assertNull(stored.getNodeId());
|
||||
assertEquals(0, stored.getPlacementVersion());
|
||||
assertEquals(1L, versionMapper.selectCount());
|
||||
assertEquals(0L, auditMapper.selectCount());
|
||||
}
|
||||
|
||||
private void assertQuestionNotVisible(Long id) {
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> questionCatalogService.getQuestion(String.valueOf(id)));
|
||||
assertEquals(QUESTION_NOT_FOUND.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
private void assertNodeQuestionCount(Long nodeId, long expected) {
|
||||
QuestionPageReqVO request = new QuestionPageReqVO();
|
||||
request.setNodeId(String.valueOf(nodeId));
|
||||
var page = questionCatalogService.pageQuestions(request);
|
||||
assertEquals(expected, page.getTotal());
|
||||
assertEquals(expected, page.getList().size());
|
||||
assertFalse(cn.iocoder.yudao.framework.common.util.json.JsonUtils.toJsonString(page.getList())
|
||||
.matches(".*(correctAnswer|explanation|analysis|isCorrect).*"));
|
||||
}
|
||||
|
||||
private void assertAuthoringNotFound(org.junit.jupiter.api.function.Executable executable) {
|
||||
ServiceException ex = assertThrows(ServiceException.class, executable);
|
||||
assertEquals(QUESTION_AUTHORING_NOT_FOUND.getCode(), ex.getCode());
|
||||
@@ -175,6 +281,23 @@ class TenantQuestionLifecyclePostgreSqlIntegrationTest extends PostgreSqlDbInteg
|
||||
});
|
||||
}
|
||||
|
||||
private Thread placementThread(Long questionId, Long nodeId, CountDownLatch ready, CountDownLatch go,
|
||||
AtomicInteger successCount, AtomicReference<Throwable> error) {
|
||||
return new Thread(() -> {
|
||||
TenantContextHolder.setTenantId(10L);
|
||||
ready.countDown();
|
||||
try {
|
||||
assertTrue(go.await(5, TimeUnit.SECONDS));
|
||||
lifecycleService.place(questionId, new QuestionPlacementCommand(nodeId, 0));
|
||||
successCount.incrementAndGet();
|
||||
} catch (Throwable throwable) {
|
||||
error.set(throwable);
|
||||
} finally {
|
||||
TenantContextHolder.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private Long createPublicDraft() {
|
||||
return TenantUtils.executeIgnore(() -> new TransactionTemplate(transactionManager).execute(status -> {
|
||||
QuestionDO question = new QuestionDO();
|
||||
@@ -207,4 +330,26 @@ class TenantQuestionLifecyclePostgreSqlIntegrationTest extends PostgreSqlDbInteg
|
||||
private QuestionDraftCommand questionCommand(String stem) {
|
||||
return new QuestionDraftCommand(stem, "text", "easy", List.of(), "answer", null, null);
|
||||
}
|
||||
|
||||
private void insertEntry(Long id, Long tenantId, String scope, String key, String name) {
|
||||
ContentEntryDO entry = new ContentEntryDO();
|
||||
entry.setId(id);
|
||||
entry.setTenantId(tenantId);
|
||||
entry.setScope(scope);
|
||||
entry.setEntryKey(key);
|
||||
entry.setName(name);
|
||||
entry.setEntryType("question");
|
||||
contentEntryMapper.insert(entry);
|
||||
}
|
||||
|
||||
private void insertNode(Long id, Long tenantId, String scope, Long entryId, String name) {
|
||||
ContentNodeDO node = new ContentNodeDO();
|
||||
node.setId(id);
|
||||
node.setTenantId(tenantId);
|
||||
node.setScope(scope);
|
||||
node.setEntryId(entryId);
|
||||
node.setName(name);
|
||||
node.setNodeType("category");
|
||||
contentNodeMapper.insert(node);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ package cn.iocoder.yudao.module.education.service.question.authoring;
|
||||
import cn.iocoder.yudao.framework.common.exception.ServiceException;
|
||||
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.yudao.module.education.config.EducationProperties;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.ContentNodeDO;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionDO;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.catalog.ContentNodeMapper;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionLifecycleAuditMapper;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionMapper;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionVersionMapper;
|
||||
@@ -31,6 +33,7 @@ import static org.mockito.Mockito.*;
|
||||
class TenantQuestionLifecycleServiceImplTest {
|
||||
|
||||
@Mock private QuestionMapper questionMapper;
|
||||
@Mock private ContentNodeMapper contentNodeMapper;
|
||||
@Mock private QuestionVersionMapper versionMapper;
|
||||
@Mock private QuestionLifecycleAuditMapper auditMapper;
|
||||
|
||||
@@ -40,7 +43,8 @@ class TenantQuestionLifecycleServiceImplTest {
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
properties = new EducationProperties();
|
||||
service = new TenantQuestionLifecycleServiceImpl(properties, questionMapper, versionMapper, auditMapper);
|
||||
service = new TenantQuestionLifecycleServiceImpl(properties, questionMapper, contentNodeMapper,
|
||||
versionMapper, auditMapper);
|
||||
TenantContextHolder.setTenantId(10L);
|
||||
}
|
||||
|
||||
@@ -56,7 +60,18 @@ class TenantQuestionLifecycleServiceImplTest {
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> service.publish(1L, 7L));
|
||||
|
||||
assertEquals(QUESTION_AUTHORING_PROVIDER_UNSUPPORTED.getCode(), ex.getCode());
|
||||
verifyNoInteractions(questionMapper, versionMapper, auditMapper);
|
||||
verifyNoInteractions(questionMapper, contentNodeMapper, versionMapper, auditMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailPlacementBeforeDatabaseAccessInScalarMode() {
|
||||
properties.setCatalogMode(CatalogProviderMode.SCALAR_READ);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> service.place(101L, new QuestionPlacementCommand(201L, 0)));
|
||||
|
||||
assertEquals(QUESTION_AUTHORING_PROVIDER_UNSUPPORTED.getCode(), ex.getCode());
|
||||
verifyNoInteractions(questionMapper, contentNodeMapper, versionMapper, auditMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -95,7 +110,8 @@ class TenantQuestionLifecycleServiceImplTest {
|
||||
void shouldPublishWithCasAndAppendAudit() {
|
||||
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
|
||||
when(questionMapper.selectTenantOwnedById(10L, 101L)).thenReturn(draft());
|
||||
when(questionMapper.updateLifecycle(10L, 101L, "DRAFT", "PUBLISHED", true)).thenReturn(1);
|
||||
when(contentNodeMapper.selectAvailablePlacementTarget(10L, 201L)).thenReturn(availableNode());
|
||||
when(questionMapper.updateLifecycle(10L, 101L, "DRAFT", "PUBLISHED", true, 1)).thenReturn(1);
|
||||
|
||||
service.publish(101L, 7L);
|
||||
|
||||
@@ -110,6 +126,82 @@ class TenantQuestionLifecycleServiceImplTest {
|
||||
assertEquals("PUBLISHED", auditCaptor.getValue().getToStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPlaceDraftOnAvailableNodeWithCas() {
|
||||
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
|
||||
ContentNodeDO node = new ContentNodeDO();
|
||||
node.setId(201L);
|
||||
node.setTenantId(10L);
|
||||
node.setScope("TENANT_OWNED");
|
||||
node.setIsActive(true);
|
||||
node.setIsHidden(false);
|
||||
node.setIsSelectable(true);
|
||||
QuestionDO draft = draft();
|
||||
draft.setNodeId(null);
|
||||
draft.setPlacementVersion(0);
|
||||
when(questionMapper.selectTenantOwnedById(10L, 101L)).thenReturn(draft);
|
||||
when(contentNodeMapper.selectAvailablePlacementTarget(10L, 201L)).thenReturn(node);
|
||||
when(questionMapper.updatePlacement(10L, 101L, 201L, 0)).thenReturn(1);
|
||||
|
||||
int placementVersion = service.place(101L, new QuestionPlacementCommand(201L, 0));
|
||||
|
||||
assertEquals(1, placementVersion);
|
||||
verify(questionMapper).updatePlacement(10L, 101L, 201L, 0);
|
||||
verifyNoInteractions(versionMapper, auditMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectUnavailablePlacementTarget() {
|
||||
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
|
||||
QuestionDO draft = draft();
|
||||
when(questionMapper.selectTenantOwnedById(10L, 101L)).thenReturn(draft);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> service.place(101L, new QuestionPlacementCommand(999L, 1)));
|
||||
|
||||
assertEquals(QUESTION_PLACEMENT_TARGET_UNAVAILABLE.getCode(), ex.getCode());
|
||||
verify(questionMapper, never()).updatePlacement(any(), any(), any(), anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectStalePlacementVersionBeforeTargetLookup() {
|
||||
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
|
||||
when(questionMapper.selectTenantOwnedById(10L, 101L)).thenReturn(draft());
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> service.place(101L, new QuestionPlacementCommand(202L, 0)));
|
||||
|
||||
assertEquals(QUESTION_PLACEMENT_CONFLICT.getCode(), ex.getCode());
|
||||
verifyNoInteractions(contentNodeMapper);
|
||||
verify(questionMapper, never()).updatePlacement(any(), any(), any(), anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectPlacementWhenCasLoses() {
|
||||
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
|
||||
when(questionMapper.selectTenantOwnedById(10L, 101L)).thenReturn(draft());
|
||||
when(contentNodeMapper.selectAvailablePlacementTarget(10L, 202L)).thenReturn(availableNode());
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> service.place(101L, new QuestionPlacementCommand(202L, 1)));
|
||||
|
||||
assertEquals(QUESTION_PLACEMENT_CONFLICT.getCode(), ex.getCode());
|
||||
verify(questionMapper).updatePlacement(10L, 101L, 202L, 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectPlacementOnCurrentNodeWithoutVersionChange() {
|
||||
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
|
||||
when(questionMapper.selectTenantOwnedById(10L, 101L)).thenReturn(draft());
|
||||
when(contentNodeMapper.selectAvailablePlacementTarget(10L, 201L)).thenReturn(availableNode());
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> service.place(101L, new QuestionPlacementCommand(201L, 1)));
|
||||
|
||||
assertEquals(QUESTION_PLACEMENT_CONFLICT.getCode(), ex.getCode());
|
||||
verify(questionMapper, never()).updatePlacement(any(), any(), any(), anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPreserveRequestOrderWhenOptionOrderIsOmitted() {
|
||||
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
|
||||
@@ -141,7 +233,7 @@ class TenantQuestionLifecycleServiceImplTest {
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> service.publish(101L, 7L));
|
||||
|
||||
assertEquals(QUESTION_CONTENT_NOT_PUBLISHABLE.getCode(), ex.getCode());
|
||||
verify(questionMapper, never()).updateLifecycle(any(), any(), any(), any(), anyBoolean());
|
||||
verify(questionMapper, never()).updateLifecycle(any(), any(), any(), any(), anyBoolean(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -154,7 +246,34 @@ class TenantQuestionLifecycleServiceImplTest {
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> service.publish(101L, 7L));
|
||||
|
||||
assertEquals(QUESTION_CONTENT_NOT_PUBLISHABLE.getCode(), ex.getCode());
|
||||
verify(questionMapper, never()).updateLifecycle(any(), any(), any(), any(), anyBoolean());
|
||||
verify(questionMapper, never()).updateLifecycle(any(), any(), any(), any(), anyBoolean(), any());
|
||||
verifyNoInteractions(auditMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectPublishingUnplacedDraft() {
|
||||
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
|
||||
QuestionDO draft = draft();
|
||||
draft.setPlacementVersion(0);
|
||||
draft.setNodeId(null);
|
||||
when(questionMapper.selectTenantOwnedById(10L, 101L)).thenReturn(draft);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> service.publish(101L, 7L));
|
||||
|
||||
assertEquals(QUESTION_PLACEMENT_REQUIRED.getCode(), ex.getCode());
|
||||
verify(questionMapper, never()).updateLifecycle(any(), any(), any(), any(), anyBoolean(), any());
|
||||
verifyNoInteractions(contentNodeMapper, auditMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectPublishingWhenPlacementTargetBecameUnavailable() {
|
||||
properties.setCatalogMode(CatalogProviderMode.JAVA_READ);
|
||||
when(questionMapper.selectTenantOwnedById(10L, 101L)).thenReturn(draft());
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> service.publish(101L, 7L));
|
||||
|
||||
assertEquals(QUESTION_PLACEMENT_TARGET_UNAVAILABLE.getCode(), ex.getCode());
|
||||
verify(questionMapper, never()).updateLifecycle(any(), any(), any(), any(), anyBoolean(), any());
|
||||
verifyNoInteractions(auditMapper);
|
||||
}
|
||||
|
||||
@@ -176,7 +295,7 @@ class TenantQuestionLifecycleServiceImplTest {
|
||||
});
|
||||
|
||||
assertEquals(QUESTION_LIFECYCLE_CONFLICT.getCode(), ex.getCode());
|
||||
verify(questionMapper, never()).updateLifecycle(any(), any(), any(), any(), anyBoolean());
|
||||
verify(questionMapper, never()).updateLifecycle(any(), any(), any(), any(), anyBoolean(), any());
|
||||
verifyNoInteractions(auditMapper);
|
||||
}
|
||||
|
||||
@@ -206,8 +325,21 @@ class TenantQuestionLifecycleServiceImplTest {
|
||||
question.setOptions("[{\"label\":\"A\",\"content\":\"4\",\"order\":1}," +
|
||||
"{\"label\":\"B\",\"content\":\"5\",\"order\":2}]");
|
||||
question.setCorrectAnswer("A");
|
||||
question.setNodeId(201L);
|
||||
question.setPlacementVersion(1);
|
||||
question.setStatus("DRAFT");
|
||||
question.setIsPublished(false);
|
||||
return question;
|
||||
}
|
||||
|
||||
private ContentNodeDO availableNode() {
|
||||
ContentNodeDO node = new ContentNodeDO();
|
||||
node.setId(201L);
|
||||
node.setTenantId(10L);
|
||||
node.setScope("TENANT_OWNED");
|
||||
node.setIsActive(true);
|
||||
node.setIsHidden(false);
|
||||
node.setIsSelectable(true);
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ class EducationFlywayMigrationIntegrationTest {
|
||||
|
||||
assertThat(queryStrings(schema,
|
||||
"SELECT COALESCE(version, 'BASELINE') FROM flyway_schema_history ORDER BY installed_rank"))
|
||||
.containsExactly("4009", "4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080");
|
||||
.containsExactly("4009", "4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080", "4090");
|
||||
assertThat(queryLong(schema,
|
||||
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = current_schema() " +
|
||||
"AND table_name = 'education_idempotency'"))
|
||||
@@ -181,7 +181,7 @@ class EducationFlywayMigrationIntegrationTest {
|
||||
|
||||
assertThat(queryStrings(schema,
|
||||
"SELECT version FROM flyway_schema_history WHERE success = TRUE ORDER BY installed_rank"))
|
||||
.containsExactly("4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080");
|
||||
.containsExactly("4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080", "4090");
|
||||
assertThat(queryStrings(schema,
|
||||
"SELECT table_name FROM information_schema.tables " +
|
||||
"WHERE table_schema = current_schema() AND table_name IN (" +
|
||||
@@ -264,7 +264,7 @@ class EducationFlywayMigrationIntegrationTest {
|
||||
@Test
|
||||
void shouldCreateDraftFirstQuestionLifecycleSchema() throws SQLException {
|
||||
String schema = createSchema("question_lifecycle");
|
||||
configureFlyway(schema, false).load().migrate();
|
||||
configureFlyway(schema, false).target("4080").load().migrate();
|
||||
|
||||
assertThat(queryStrings(schema, """
|
||||
SELECT column_default
|
||||
@@ -386,6 +386,85 @@ class EducationFlywayMigrationIntegrationTest {
|
||||
.hasMessageContaining("question version ownership must equal question ownership");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCreateQuestionPlacementSchemaAndFreezePublishedPlacement() throws SQLException {
|
||||
String schema = createSchema("question_placement");
|
||||
configureFlyway(schema, false).load().migrate();
|
||||
|
||||
assertThat(queryStrings(schema, """
|
||||
SELECT column_default || ':' || is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = current_schema()
|
||||
AND table_name = 'education_question'
|
||||
AND column_name = 'placement_version'
|
||||
"""))
|
||||
.containsExactly("0:NO");
|
||||
|
||||
execute(schema, """
|
||||
INSERT INTO education_content_entry
|
||||
(id, tenant_id, scope, entry_key, name, entry_type)
|
||||
VALUES (100, 10, 'TENANT_OWNED', 'questions', 'Questions', 'question');
|
||||
INSERT INTO education_content_node
|
||||
(id, tenant_id, scope, entry_id, name, node_type)
|
||||
VALUES (200, 10, 'TENANT_OWNED', 100, 'Algebra', 'category'),
|
||||
(201, 10, 'TENANT_OWNED', 100, 'Geometry', 'category');
|
||||
INSERT INTO education_question
|
||||
(id, tenant_id, scope, stem, type, options, correct_answer)
|
||||
VALUES (300, 10, 'TENANT_OWNED', 'Placed draft', 'text', '[]', 'answer');
|
||||
INSERT INTO education_question_version
|
||||
(tenant_id, scope, question_id, version_number, stem, type, options, correct_answer)
|
||||
VALUES (10, 'TENANT_OWNED', 300, 1, 'Placed draft', 'text', '[]', 'answer');
|
||||
INSERT INTO education_content_entry
|
||||
(id, tenant_id, scope, entry_key, name, entry_type)
|
||||
VALUES (110, 0, 'PUBLIC', 'public-questions', 'Public Questions', 'question');
|
||||
INSERT INTO education_content_node
|
||||
(id, tenant_id, scope, entry_id, name, node_type)
|
||||
VALUES (210, 0, 'PUBLIC', 110, 'Public Algebra', 'category'),
|
||||
(211, 0, 'PUBLIC', 110, 'Public Geometry', 'category');
|
||||
INSERT INTO education_question
|
||||
(id, tenant_id, scope, stem, type, options, correct_answer, node_id)
|
||||
VALUES (310, 0, 'PUBLIC', 'Public draft', 'text', '[]', 'answer', 210);
|
||||
INSERT INTO education_question_version
|
||||
(tenant_id, scope, question_id, version_number, stem, type, options, correct_answer)
|
||||
VALUES (0, 'PUBLIC', 310, 1, 'Public draft', 'text', '[]', 'answer');
|
||||
""");
|
||||
|
||||
assertThatThrownBy(() -> execute(schema, """
|
||||
UPDATE education_question
|
||||
SET node_id = 211, placement_version = 1
|
||||
WHERE id = 310;
|
||||
"""))
|
||||
.hasMessageContaining("PUBLIC question placement is not managed by tenant authoring");
|
||||
|
||||
assertThatThrownBy(() -> execute(schema, """
|
||||
UPDATE education_question SET node_id = 200 WHERE id = 300;
|
||||
"""))
|
||||
.hasMessageContaining("question placement version must advance exactly once");
|
||||
|
||||
execute(schema, """
|
||||
UPDATE education_question
|
||||
SET node_id = 200, placement_version = 1
|
||||
WHERE id = 300;
|
||||
DO $publish$
|
||||
BEGIN
|
||||
UPDATE education_question
|
||||
SET status = 'PUBLISHED', is_published = true
|
||||
WHERE id = 300;
|
||||
INSERT INTO education_question_lifecycle_audit
|
||||
(tenant_id, question_id, content_version, actor_id, from_status, to_status)
|
||||
VALUES (10, 300, 1, 7, 'DRAFT', 'PUBLISHED');
|
||||
END
|
||||
$publish$;
|
||||
""");
|
||||
|
||||
assertThatThrownBy(() -> execute(schema, """
|
||||
UPDATE education_question
|
||||
SET node_id = 201, placement_version = 2
|
||||
WHERE id = 300;
|
||||
"""))
|
||||
.hasMessageContaining("published or archived question placement is immutable");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNormalizeHistoricalQuestionVisibilityIntoLifecycleStates() throws SQLException {
|
||||
String schema = createSchema("question_history");
|
||||
@@ -490,14 +569,15 @@ class EducationFlywayMigrationIntegrationTest {
|
||||
assertThat(queryStrings(schema, """
|
||||
SELECT permission
|
||||
FROM system_menu
|
||||
WHERE id BETWEEN 6801 AND 6804
|
||||
WHERE id BETWEEN 6801 AND 6805
|
||||
ORDER BY id
|
||||
"""))
|
||||
.containsExactly(
|
||||
"education:capability",
|
||||
"education:question:author",
|
||||
"education:question:publish",
|
||||
"education:question:archive");
|
||||
"education:question:archive",
|
||||
"education:question:classify");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -516,6 +596,23 @@ class EducationFlywayMigrationIntegrationTest {
|
||||
.hasMessageContaining("Education System RBAC seed IDs conflict");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFailClosedWhenQuestionPlacementPermissionSeedConflicts() throws SQLException {
|
||||
String schema = createSchema("question_placement_permission_conflict");
|
||||
configureFlyway(schema, false).target("4070").load().migrate();
|
||||
createSystemMenuFixture(schema);
|
||||
configureFlyway(schema, false).target("4080").load().migrate();
|
||||
execute(schema, """
|
||||
INSERT INTO system_menu
|
||||
(id, name, permission, type, sort, parent_id, status, deleted)
|
||||
VALUES (6805, 'Conflicting placement permission', 'education:question:other',
|
||||
3, 5, 6800, 0, 0);
|
||||
""");
|
||||
|
||||
assertThatThrownBy(() -> configureFlyway(schema, false).load().migrate())
|
||||
.hasMessageContaining("Education Question Placement RBAC seed ID conflicts");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAttachGraphGuardToEveryCatalogReference() throws SQLException {
|
||||
String schema = createSchema("catalog_triggers");
|
||||
@@ -606,6 +703,7 @@ class EducationFlywayMigrationIntegrationTest {
|
||||
'education_require_question_lifecycle_audit',
|
||||
'education_check_question_version_ownership',
|
||||
'education_validate_question_lifecycle_audit_insert',
|
||||
'education_enforce_question_placement_mutation',
|
||||
'education_question_answer_key_is_valid')
|
||||
AND grantee = 'PUBLIC'
|
||||
AND privilege_type = 'EXECUTE'
|
||||
|
||||
@@ -12,5 +12,7 @@ TRUNCATE TABLE
|
||||
education_practice_report_detail,
|
||||
education_practice_report,
|
||||
education_practice_question,
|
||||
education_practice_session
|
||||
education_practice_session,
|
||||
education_content_node,
|
||||
education_content_entry
|
||||
RESTART IDENTITY CASCADE;
|
||||
|
||||
Reference in New Issue
Block a user