feat(education): add native question publication lifecycle

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

View File

@@ -0,0 +1,5 @@
# Native question authoring follows the active catalog authority
Education exposes local question authoring only when `catalog-mode=JAVA_READ`; `SCALAR_READ` and other modes fail with an explicit business error before any database access. Tenant questions start as `DRAFT`, retain immutable content versions, move only through `DRAFT → PUBLISHED → ARCHIVED`, and append an Education-owned lifecycle audit in the same transaction, because a local write under Scalar authority would be invisible to students and the platform's asynchronous operation log cannot prove atomic publication.
`ARCHIVED` is terminal for the current command surface, and `RETIRED` is not introduced until its distinct business and restoration semantics are decided. Direct question visibility follows the question's own publication state; entry, node, and collection state gates discovery through those routes rather than rewriting the question lifecycle.

View File

@@ -29,7 +29,7 @@ The durable artifact classification, adoption matrix, version allocation, backfi
3. V4030 owns the final Practice core-loop schema, including sessions/questions, reports/details, wrong questions, favorites, and unified `education_idempotency`. Fresh schema does not create legacy answer/submit idempotency tables.
4. Existing manually bootstrapped databases require explicit schema comparison and adoption. A verified V4020-equivalent catalog may use an environment-specific 4020 baseline; incompatible environments require a higher-version correction, never falsified history.
5. Legacy idempotency data is backfilled into the unified table before any later forward cleanup. Legacy tables are preserved during initial adoption.
6. The Education capability menu seed is a separate conditional V4040 owner only if the administrator endpoint remains approved; role assignment is not seeded.
6. The earlier V4040 capability-seed plan was superseded after V4040 was allocated to the submit-claim lease. V4080 conditionally seeds the approved capability plus question author/publish/archive permissions when `system_menu` exists; role assignment is not seeded.
7. Docker/manual SQL initialization and MySQL rollback runbooks must be removed from active operations when EDU-006 lands. EDU-016's temporary test bridge becomes Flyway-driven after equivalence is proven.
8. The inspected local disposable `postgresdb` had no Flyway history and no Education tables. EDU-005 ran no migration and makes no migration-success claim.
@@ -102,6 +102,34 @@ The durable artifact classification, adoption matrix, version allocation, backfi
**Evidence:** Schema graph analysis of V4020 (11 catalog tables, all single-column FKs, no composite tenant enforcement), legacy RLS/trigger audit (Supabase RLS + staged composite FK migration), current CHECK constraints provide row-level but not cross-row enforcement.
### Native question authoring authority and lifecycle (resolved 2026-07-30)
**Decision:** Local tenant question authoring is available only under explicit `JAVA_READ` authority and fails before persistence access in `SCALAR_READ`. New questions are `DRAFT`; the current command surface permits only `DRAFT → PUBLISHED → ARCHIVED`, with no restore or retire command.
**Rules:**
1. Create, publish, and archive are explicit commands with separate System RBAC permissions; clients cannot submit tenant, scope, lifecycle, or actor fields.
2. Tenant authoring always creates `TENANT_OWNED` content for the current framework tenant. PUBLIC/platform-curator authoring is not part of this slice.
3. Question Content Versions are immutable. Publication changes lifecycle state for the current version and never changes `tenant_id`, `scope`, or content version.
4. Education appends a lifecycle audit in the same transaction as each state transition. The asynchronous platform operation log remains supplementary, not proof of atomic publication.
5. Direct question visibility follows the question's Publication State. Entry, node, and collection availability gates their own discovery routes and does not mutate the question lifecycle or historical practice snapshots.
6. Historical `HIDDEN`, `INACTIVE`, and `PUBLISHED/is_published=false` rows map to `ARCHIVED`; historical `DRAFT` rows become non-published; only consistent published rows remain `PUBLISHED`.
7. V4080 adds Question Version → Question as the twentieth guarded catalog edge. A version must have exactly the same tenant and scope as its question, and the version table becomes the twelfth catalog table whose ownership cannot change after insert.
**Permission and data-scope matrix:**
| Actor capability | Permission | Allowed rows | Data scope |
|---|---|---|---|
| Author | `education:question:author` | Create current-tenant `TENANT_OWNED` drafts | Current tenant; no request-supplied owner or tenant |
| Publisher | `education:question:publish` | Current-tenant `TENANT_OWNED` drafts | Tenant-wide shared catalog asset; department/self DataPermission does not expand access |
| Archiver | `education:question:archive` | Current-tenant `TENANT_OWNED` published questions | Tenant-wide shared catalog asset; department/self DataPermission does not expand access |
| Platform curator | none in this slice | No PUBLIC or tenant-owned writes through these endpoints | Fail closed |
V4080 conditionally seeds the four Education permissions (`education:capability` plus author/publish/archive) when the adopted platform schema contains `system_menu`. It fails when a fixed ID is already occupied by a different permission and deliberately does not assign the permissions to any role.
**Rationale:** A successful PostgreSQL write while Scalar remains authoritative would be student-invisible. Department/self DataPermission has no truthful meaning for tenant-wide shared catalog rows without a separate ownership model, so action permissions plus framework tenant isolation are explicit rather than applying a misleading annotation.
**ADR:** `docs/adr/0001-native-question-authoring-authority.md`.
## Unresolved decisions
1. The valid option schema for each question type, including whether absent options are legal; whether malformed published content is omitted or produces a controlled source failure.

View File

@@ -1,6 +1,6 @@
# EDU-010 — Tenant content publication and graph integrity
- **Status:** in progress — catalog graph-integrity slice delivered; authoring/publication lifecycle remains
- **Status:** in progress — catalog graph-integrity and the verified JAVA_READ tenant-question lifecycle slice are delivered; broader classification/collection authoring remains
- **Type:** implementation program
- **Phase:** 3
- **Blockers:** EDU-004 ✓ (done), EDU-009 ✓ (done), provider-authority decision ✓ (resolved 2026-07-30, see decisions.md), PUBLIC graph-semantics decision ✓ (resolved 2026-07-30, see decisions.md)
@@ -20,11 +20,12 @@ Authorized tenant administrators can author, classify, publish, archive, and ret
## Acceptance criteria
- [ ] Admin permission and data-scope matrix is explicit.
- [x] Admin permission and data-scope matrix is explicit.
- [x] Cross-tenant graph relationships cannot be persisted in the current V4020 catalog graph.
- [x] PUBLIC and tenant-owned reference rules are enforced across the current 19 reference edges, with exact trigger-mapping and focused behavior tests.
- [ ] Unpublished/archived content is never student-visible.
- [ ] Publication is transactional and auditable.
- [x] V4080 extends the graph guard to the new Question Version → Question edge and makes the version table the twelfth ownership-protected catalog table.
- [x] Unpublished/archived content is never student-visible.
- [x] Publication is transactional and auditable.
- [x] Database graph-integrity changes use `flyway-postgresql` and forward migration V4070.
## Delivery progress — graph-integrity slice (2026-07-30)
@@ -41,16 +42,45 @@ Delivered:
No non-Education module changed. Application rollback can disable native authoring when it is introduced; database recovery remains a higher forward migration and must preserve existing content.
## Remaining publication slice
## Publication-slice constraints (resolved)
Do not add a local-only admin write path while `SCALAR_READ` remains the default authoritative provider: a successful PostgreSQL write would not be visible to students. The next bounded slice is a `JAVA_READ`-only tenant question `draft → publish → archive → student read` path that fails closed in unsupported provider modes.
Do not add a local-only admin write path while `SCALAR_READ` remains the default authoritative provider: a successful PostgreSQL write would not be visible to students. The implemented bounded slice is a `JAVA_READ`-only tenant question `draft → publish → archive → student read` path that fails closed in unsupported provider modes.
Before its Flyway/application implementation, resolve and document:
The slice resolves the prerequisite decisions as follows:
1. the exact `DRAFT/PUBLISHED/ARCHIVED/RETIRED` state machine and mapping of V4020 `HIDDEN/INACTIVE` rows;
2. immutable question-version and transactional publication-audit ownership;
3. whether ancestor entry/node/collection/archive state makes a question unavailable to fresh student reads;
4. the explicit tenant-admin versus platform-curator permission/data-scope matrix.
1. the command surface is `DRAFTPUBLISHEDARCHIVED`; `RETIRED` remains undefined, and V4020 `HIDDEN/INACTIVE` rows map to `ARCHIVED`;
2. Education owns immutable question versions and transactional lifecycle audit;
3. a question's state controls direct visibility while entry/node/collection availability gates only route-specific discovery;
4. tenant admin action permissions are separate, tenant-wide, and tenant-bound; platform-curator/PUBLIC writes fail closed in this slice.
## Delivery progress — JAVA_READ tenant-question lifecycle slice (2026-07-30)
Delivered:
- Explicit Admin APIs and independent RBAC permissions for tenant question author, publish, and archive operations. Requests cannot choose tenant, scope, lifecycle, or actor identity:
- `POST /admin-api/education/questions/drafts``education:question:author`;
- `PUT /admin-api/education/questions/{id}/publish``education:question:publish`;
- `PUT /admin-api/education/questions/{id}/archive``education:question:archive`.
- Provider-authority guard: authoring is accepted only for `JAVA_READ`; `SCALAR_READ` fails before any Mapper access.
- V4080 changes native defaults to `DRAFT/false`, normalizes legacy visibility states, fails closed on unsafe historical published content, constrains the single direction `DRAFT → PUBLISHED → ARCHIVED`, creates immutable Question Content Versions, and creates append-only lifecycle audit facts.
- V4080 adds the Question Version → Question reference as the twentieth guarded catalog edge. In addition to the generic scope rule, a version's tenant and scope must exactly equal its question's ownership; the version table is the twelfth catalog table protected against ownership mutation.
- When the adopted platform schema contains `system_menu`, V4080 conditionally seeds the Education capability plus author/publish/archive permissions. A fixed ID already occupied by a different permission aborts migration, and no role assignment is seeded.
- Publication and archive use tenant/scope/expected-state CAS updates and append audit facts in the same transaction. Audit failure rolls back the state change.
- PostgreSQL integration-test coverage exercises the student read-after-write seam: draft is invisible, published content is returned only through the existing Safe Question projection, and archived content is invisible while stored snapshots remain independent.
- Direct question visibility is controlled by the question lifecycle. Container availability is a route-level discovery gate; classification, collection, and platform-curator write paths remain outside this bounded slice.
Permission and data-scope matrix is recorded in `07-decisions.md`. The three lifecycle endpoints manage tenant-wide shared catalog assets within the current framework tenant; department/self DataPermission does not grant additional row access, PUBLIC authoring fails closed, and the V4080 seed intentionally grants no role.
The listed acceptance criteria are satisfied for the native catalog graph and tenant-question lifecycle slice. EDU-010 remains in progress because its tenant-admin outcome also includes classification, collection, blueprint, retire, and platform-curator workflows that are not part of this bounded slice.
## Verification evidence (2026-07-30)
- `mvn -pl yudao-module-education test` with the five `EDU_TEST_POSTGRES_*` variables pointed at the local Docker PostgreSQL: **485 tests passed**, including 18 Flyway migration tests and five lifecycle PostgreSQL integration tests. V4080 was actually executed in disposable PostgreSQL schemas.
- The lifecycle integration tests prove concurrent double-publish has one success and one lifecycle conflict with exactly one publish audit, cross-tenant and PUBLIC management fail closed, and `draft → publish → archive` matches student visibility.
- The real Spring Method Security contract tests prove each of author/publish/archive requires its own permission and rejected calls do not reach the lifecycle service.
- `mvn -pl yudao-server -am -DskipTests clean compile`: **22 reactor modules passed**.
- `git diff --check`: passed.
- `target/classes/db/migration/education/V4080__add_question_publication_lifecycle.sql`: present after the module build.
## Risk and rollback

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,