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