diff --git a/docs/adr/0001-native-question-authoring-authority.md b/docs/adr/0001-native-question-authoring-authority.md new file mode 100644 index 00000000..323300ae --- /dev/null +++ b/docs/adr/0001-native-question-authoring-authority.md @@ -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. diff --git a/docs/education/migration/07-decisions.md b/docs/education/migration/07-decisions.md index d0b7bd71..3e610e29 100644 --- a/docs/education/migration/07-decisions.md +++ b/docs/education/migration/07-decisions.md @@ -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. diff --git a/docs/education/migration/issues/EDU-010-content-publication.md b/docs/education/migration/issues/EDU-010-content-publication.md index b83d839e..875c6d4c 100644 --- a/docs/education/migration/issues/EDU-010-content-publication.md +++ b/docs/education/migration/issues/EDU-010-content-publication.md @@ -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 diff --git a/yudao-module-education/CONTEXT.md b/yudao-module-education/CONTEXT.md index 59b452a2..ffa88b7c 100644 --- a/yudao-module-education/CONTEXT.md +++ b/yudao-module-education/CONTEXT.md @@ -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 diff --git a/yudao-module-education/README.md b/yudao-module-education/README.md index 6bb7e852..ee3bbe70 100644 --- a/yudao-module-education/README.md +++ b/yudao-module-education/README.md @@ -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 端前端集成契约 diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/question/QuestionAuthoringController.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/question/QuestionAuthoringController.java new file mode 100644 index 00000000..2b475997 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/question/QuestionAuthoringController.java @@ -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 createDraft(@Valid @RequestBody QuestionDraftCreateReqVO reqVO) { + List 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 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 archive(@PathVariable("id") Long id) { + lifecycleService.archive(id, getLoginUserId()); + return success(true); + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/question/vo/QuestionDraftCreateReqVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/question/vo/QuestionDraftCreateReqVO.java new file mode 100644 index 00000000..b55e8003 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/question/vo/QuestionDraftCreateReqVO.java @@ -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; + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/catalog/QuestionLifecycleAuditDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/catalog/QuestionLifecycleAuditDO.java new file mode 100644 index 00000000..c8bdaaba --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/catalog/QuestionLifecycleAuditDO.java @@ -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; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/catalog/QuestionVersionDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/catalog/QuestionVersionDO.java new file mode 100644 index 00000000..242ce547 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/catalog/QuestionVersionDO.java @@ -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; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/QuestionLifecycleAuditMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/QuestionLifecycleAuditMapper.java new file mode 100644 index 00000000..3fa819f2 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/QuestionLifecycleAuditMapper.java @@ -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 { +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/QuestionMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/QuestionMapper.java index 892dbb5c..5c0c5708 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/QuestionMapper.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/QuestionMapper.java @@ -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 { 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() + .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() + .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 visible(Long tenantId) { LambdaQueryWrapperX w = new LambdaQueryWrapperX<>(); CatalogScopeQuery.apply(w, QuestionDO::getTenantId, QuestionDO::getScope, tenantId); diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/QuestionVersionMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/QuestionVersionMapper.java new file mode 100644 index 00000000..70e1ab4e --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/QuestionVersionMapper.java @@ -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 { +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java index 4f9d5c2c..1a200277 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java @@ -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, "题目内容不完整或不安全,无法发布"); } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/authoring/QuestionDraftCommand.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/authoring/QuestionDraftCommand.java new file mode 100644 index 00000000..b7e47041 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/authoring/QuestionDraftCommand.java @@ -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 options, + String correctAnswer, + String explanation, + String analysis) { + + public record QuestionDraftOption(String label, String content, Double order) { + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/authoring/TenantQuestionLifecycleService.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/authoring/TenantQuestionLifecycleService.java new file mode 100644 index 00000000..fb269f48 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/authoring/TenantQuestionLifecycleService.java @@ -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); +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/authoring/TenantQuestionLifecycleServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/authoring/TenantQuestionLifecycleServiceImpl.java new file mode 100644 index 00000000..d94fd4f5 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/authoring/TenantQuestionLifecycleServiceImpl.java @@ -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 本地题目的创作与发布深模块。 + * + *

当前只开放单向 DRAFT → PUBLISHED → ARCHIVED,不提供通用状态更新。

+ */ +@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 suppliedOptions = command.options() != null + ? command.options() : List.of(); + List 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 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 options) { + if (!QuestionContentSafety.isOptionBackedType(rawType)) { + return; + } + Set 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 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 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()); + } + } +} diff --git a/yudao-module-education/src/main/resources/db/migration/education/V4080__add_question_publication_lifecycle.sql b/yudao-module-education/src/main/resources/db/migration/education/V4080__add_question_publication_lifecycle.sql new file mode 100644 index 00000000..795e3801 --- /dev/null +++ b/yudao-module-education/src/main/resources/db/migration/education/V4080__add_question_publication_lifecycle.sql @@ -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; +$$; diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/question/QuestionAuthoringControllerContractTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/question/QuestionAuthoringControllerContractTest.java new file mode 100644 index 00000000..ebf2d9fe --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/question/QuestionAuthoringControllerContractTest.java @@ -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 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; + } + } +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/question/authoring/TenantQuestionLifecyclePostgreSqlIntegrationTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/question/authoring/TenantQuestionLifecyclePostgreSqlIntegrationTest.java new file mode 100644 index 00000000..537f93fd --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/question/authoring/TenantQuestionLifecyclePostgreSqlIntegrationTest.java @@ -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 firstError = new AtomicReference<>(); + AtomicReference 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 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); + } +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/question/authoring/TenantQuestionLifecycleServiceImplTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/question/authoring/TenantQuestionLifecycleServiceImplTest.java new file mode 100644 index 00000000..173efa51 --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/question/authoring/TenantQuestionLifecycleServiceImplTest.java @@ -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 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 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 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 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 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; + } +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/EducationFlywayMigrationIntegrationTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/EducationFlywayMigrationIntegrationTest.java index fbd1c88c..b3554276 100644 --- a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/EducationFlywayMigrationIntegrationTest.java +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/test/EducationFlywayMigrationIntegrationTest.java @@ -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 ( diff --git a/yudao-module-education/src/test/resources/sql/postgresql/clean.sql b/yudao-module-education/src/test/resources/sql/postgresql/clean.sql index b0af84fe..00f5ccaf 100644 --- a/yudao-module-education/src/test/resources/sql/postgresql/clean.sql +++ b/yudao-module-education/src/test/resources/sql/postgresql/clean.sql @@ -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,