From 2a40cbd69e4498ea3256ce15542c3fa988fb6a13 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Thu, 30 Jul 2026 22:28:08 +0800 Subject: [PATCH] feat(education): add tenant question placement --- ...001-native-question-authoring-authority.md | 4 +- docs/education/migration/07-decisions.md | 10 +- .../issues/EDU-010-content-publication.md | 30 +++- yudao-module-education/CONTEXT.md | 8 + yudao-module-education/README.md | 29 +++- .../question/QuestionAuthoringController.java | 11 ++ .../question/vo/QuestionPlacementReqVO.java | 24 +++ .../dal/dataobject/catalog/QuestionDO.java | 3 + .../dal/mysql/catalog/ContentNodeMapper.java | 18 +++ .../dal/mysql/catalog/QuestionMapper.java | 74 +++++++-- .../education/enums/ErrorCodeConstants.java | 3 + .../catalog/provider/JavaCatalogProvider.java | 9 +- .../authoring/QuestionPlacementCommand.java | 5 + .../TenantQuestionLifecycleService.java | 2 + .../TenantQuestionLifecycleServiceImpl.java | 44 +++++- .../V4090__add_question_placement.sql | 138 +++++++++++++++++ ...estionAuthoringControllerContractTest.java | 44 ++++++ ...ionLifecyclePostgreSqlIntegrationTest.java | 145 ++++++++++++++++++ ...enantQuestionLifecycleServiceImplTest.java | 144 ++++++++++++++++- ...ucationFlywayMigrationIntegrationTest.java | 108 ++++++++++++- .../test/resources/sql/postgresql/clean.sql | 4 +- 21 files changed, 813 insertions(+), 44 deletions(-) create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/question/vo/QuestionPlacementReqVO.java create mode 100644 yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/authoring/QuestionPlacementCommand.java create mode 100644 yudao-module-education/src/main/resources/db/migration/education/V4090__add_question_placement.sql diff --git a/docs/adr/0001-native-question-authoring-authority.md b/docs/adr/0001-native-question-authoring-authority.md index 323300ae..eb287b78 100644 --- a/docs/adr/0001-native-question-authoring-authority.md +++ b/docs/adr/0001-native-question-authoring-authority.md @@ -1,5 +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. +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, are placed optimistically on an allowed Content Node, 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. +Question Placement has its own optimistic version, is available only to current-tenant `TENANT_OWNED` drafts, and becomes immutable at publication. Publication requires a stable active, visible, selectable PUBLIC or same-tenant Content Node. `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 3e610e29..9fd6cc2b 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 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. +6. The earlier V4040 capability-seed plan was superseded after V4040 was allocated to the submit-claim lease. V4080/V4090 conditionally seed the approved capability plus question author/classify/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. @@ -107,24 +107,28 @@ The durable artifact classification, adoption matrix, version allocation, backfi **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. +1. Create, place, 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. +8. Question Placement means assigning a DRAFT question's `node_id`; it does not mean Category CRUD. The `education_category` table has no Question relationship in the current target model. +9. Placement targets must be visible, active, selectable PUBLIC or same-tenant Content Nodes. Placement has an independent optimistic version, becomes immutable after publication, and publication CAS includes that version. +10. Node-route student visibility is evaluated atomically in the Question/Content Node query. Node availability gates that route but does not change direct question visibility or historical snapshots. **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 | +| Classifier | `education:question:classify` | Place or re-place current-tenant `TENANT_OWNED` drafts on allowed Content Nodes | Tenant-wide shared catalog asset; PUBLIC/same-tenant targets only | | 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. +V4080/V4090 conditionally seed the five Education permissions (`education:capability` plus author/classify/publish/archive) when the adopted platform schema contains `system_menu`. They fail when a fixed ID is already occupied by a different permission and deliberately do 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. diff --git a/docs/education/migration/issues/EDU-010-content-publication.md b/docs/education/migration/issues/EDU-010-content-publication.md index 875c6d4c..6e3e0266 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 and the verified JAVA_READ tenant-question lifecycle slice are delivered; broader classification/collection authoring remains +- **Status:** in progress — catalog graph-integrity, the verified JAVA_READ tenant-question lifecycle, and Question Placement slices are delivered; category, collection, and blueprint authoring remain - **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) @@ -26,7 +26,8 @@ Authorized tenant administrators can author, classify, publish, archive, and ret - [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. +- [x] Tenant Question Placement is permission-separated, tenant-safe, optimistic, and frozen after publication. +- [x] Database graph-integrity changes use `flyway-postgresql` and forward migrations V4070/V4090. ## Delivery progress — graph-integrity slice (2026-07-30) @@ -67,22 +68,35 @@ Delivered: - 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. +- Direct question visibility is controlled by the question lifecycle. Container availability is a route-level discovery gate; Question Placement is delivered separately below, while Category, Collection, and platform-curator write paths remain outside the lifecycle 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. +The listed acceptance criteria are satisfied for the native catalog graph, tenant-question lifecycle, and Question Placement slices. EDU-010 remains in progress because its tenant-admin outcome also includes Category, Collection, Blueprint, Retire, and platform-curator workflows that are not part of these bounded slices. + +## Delivery progress — JAVA_READ Question Placement slice (2026-07-30) + +Delivered: + +- `PUT /admin-api/education/questions/{id}/placement` uses the independent `education:question:classify` permission. The request contains only `nodeId` and `expectedPlacementVersion`; tenant, scope, actor, lifecycle, and ownership remain server-controlled. +- Only current-tenant `TENANT_OWNED` drafts can be placed. A target must be a visible, active, selectable PUBLIC or same-tenant Content Node. `SCALAR_READ` fails before Mapper access, and tenant endpoints cannot manage PUBLIC questions. +- Placement uses a monotonic optimistic version and tenant/scope/status/version CAS. Repeating the current placement is rejected as a conflict; two writers using the same expected version have one winner. +- Publication requires a stable available placement and includes the validated placement version in its lifecycle CAS. V4090 prevents direct PUBLIC placement, requires exact placement-version advancement, freezes placement after publication, and rejects publication without an available node. +- Student node-route reads join Question and Content Node in one PostgreSQL statement. A hidden, inactive, non-selectable, cross-scope, or deleted node cannot expose questions through that route; direct question visibility still follows the question Publication State. +- V4090 conditionally seeds `education:question:classify` at fixed ID 6805, fails closed on conflicting rows, and grants no role. + +Category CRUD, Content Node authoring, Collection/Blueprint authoring, PUBLIC curator workflows, and a distinct Retire state remain outside this slice. In the target domain, `education_category` is not connected to Question; the delivered classification seam is explicitly Question Placement through `question.node_id`. ## 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. +- `mvn -pl yudao-module-education clean test` with the five `EDU_TEST_POSTGRES_*` variables pointed at the local Docker PostgreSQL: **502 tests passed**, including 20 Flyway migration tests and nine lifecycle/placement PostgreSQL integration tests. V4090 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. +- The real Spring Method Security contract tests prove each of author/classify/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. +- `target/classes/db/migration/education/V4080__add_question_publication_lifecycle.sql` and `V4090__add_question_placement.sql`: present after the module build. ## Risk and rollback - **Risk:** High content-integrity and authorization risk. -- **Rollback:** Disable authoring/publishing and roll back application; preserve data and correct forward. +- **Rollback:** Before rolling the application back behind V4090, disable native authoring by switching away from `JAVA_READ` (or disable Education when no alternate read authority is configured). Preserve V4090 data and schema, then correct forward. A V4080 application left writable in `JAVA_READ` is intentionally rejected when it attempts to publish an unplaced draft. diff --git a/yudao-module-education/CONTEXT.md b/yudao-module-education/CONTEXT.md index ffa88b7c..4f2d0f74 100644 --- a/yudao-module-education/CONTEXT.md +++ b/yudao-module-education/CONTEXT.md @@ -60,6 +60,14 @@ _Avoid_: Read provider switch as implicit write permission An append-only Education domain fact recording actor, content version, and a valid Publication State transition in the same transaction as that transition. _Avoid_: Asynchronous operation log as publication proof +**Question Placement**: +The optimistic assignment of a tenant Draft Question Content item to one active, visible, selectable Content Node that the current tenant may reference. Placement is mutable only while the question is DRAFT and is frozen by publication. It is the current question-classification seam; it is not Category CRUD. +_Avoid_: Category assignment, editable published classification + +**Placement Version**: +A non-negative optimistic concurrency version for Question Placement. It advances exactly once when the node changes, is independent from the immutable Question Content Version, and participates in publication CAS. +_Avoid_: Content version, lifecycle version + **Protected Answer Key**: Server-only correctness and explanation data captured for stable scoring of a practice session. It is never included in a Safe Question, Question Snapshot JSON, or pre-submit response. _Avoid_: Question Snapshot, frontend answer diff --git a/yudao-module-education/README.md b/yudao-module-education/README.md index ee3bbe70..47797f3c 100644 --- a/yudao-module-education/README.md +++ b/yudao-module-education/README.md @@ -4,7 +4,7 @@ ## 当前状态 -此模块提供教育业务功能骨架、题库目录浏览、题目预览与练习闭环,以及 `JAVA_READ` 下的租户题目草稿、发布和归档 tracer bullet。 +此模块提供教育业务功能骨架、题库目录浏览、题目预览与练习闭环,以及 `JAVA_READ` 下的租户题目草稿、目录放置、发布和归档 tracer bullet。 **已实现**: - 模块骨架与包结构 @@ -16,10 +16,10 @@ - 练习配置预览端点 (见下方 Practice API) — 学生端已认证 - 答案保存端点 (见下方 Answer API) — 幂等保存,安全重试 - 题目安全过滤(答案/解析绝不暴露到前端) -- 管理端题目创作、发布和归档端点 — 仅在 `JAVA_READ` 下接受写入 +- 管理端题目创作、目录放置、发布和归档端点 — 仅在 `JAVA_READ` 下接受写入 - 独立的功能开关配置 + Scalar 数据源配置 - 错误码常量(通用 + 租户 + Catalog/Scalar + 题目/练习) -- System RBAC 权限注解及 V4080 条件种子(`education:capability`、`education:question:author`、`education:question:publish`、`education:question:archive`);迁移不自动向任何角色授权 +- System RBAC 权限注解及 V4080/V4090 条件种子(`education:capability`、`education:question:author`、`education:question:classify`、`education:question:publish`、`education:question:archive`);迁移不自动向任何角色授权 ## 功能配置 @@ -48,6 +48,7 @@ yudao: - `practice-write-enabled=false`:拒绝新建练习、保存答案和交卷;会话恢复、报告与历史查询保持可用。 - `pilot-tenant-ids`:非空时仅允许列表内租户使用题库和练习写入能力。 - 应用回滚只回滚应用版本或开关;不得执行 `*-rollback.sql`。SQL 回滚脚本仅用于明确的数据销毁场景。 +- 回滚到 V4090 之前的应用版本前,必须先切离 `JAVA_READ` 以停止原生题目写入;没有可用替代读取权威时应关闭 Education。V4090 Schema 保留并通过后续更高版本向前修正。 ## API @@ -75,11 +76,12 @@ GET /admin-api/education/capability ### 管理后台 - 题目创作与发布 -这些端点只管理当前框架租户的 `TENANT_OWNED` 题目,不提供 PUBLIC/platform-curator 写入口。客户端不能提交 `tenantId`、`scope`、生命周期状态或 actor;新题固定以 `DRAFT/false` 创建,并且只允许 `DRAFT → PUBLISHED → ARCHIVED`。 +这些端点只管理当前框架租户的 `TENANT_OWNED` 题目,不提供 PUBLIC/platform-curator 写入口。客户端不能提交 `tenantId`、`scope`、生命周期状态或 actor;新题固定以 `DRAFT/false` 创建,放置到可用 Content Node 后才可发布,并且只允许 `DRAFT → PUBLISHED → ARCHIVED`。 | 端点 | 权限 | 说明 | |------|------|------| | `POST /admin-api/education/questions/drafts` | `education:question:author` | 创建当前租户草稿,返回题目 ID | +| `PUT /admin-api/education/questions/{id}/placement` | `education:question:classify` | 将草稿放置或重新放置到允许的 Content Node,返回放置版本 | | `PUT /admin-api/education/questions/{id}/publish` | `education:question:publish` | 发布当前租户草稿 | | `PUT /admin-api/education/questions/{id}/archive` | `education:question:archive` | 归档当前租户已发布题目 | @@ -100,6 +102,17 @@ GET /admin-api/education/capability } ``` +目录放置请求示例: + +```json +{ + "nodeId": 200, + "expectedPlacementVersion": 0 +} +``` + +放置目标必须是当前租户可读的 PUBLIC 或同租户 Content Node,并且处于 active、非 hidden、selectable 状态。放置使用独立乐观版本;发布后不可重新放置。学生按 node 查询时会在同一条 PostgreSQL 查询中再次检查节点可用性。 + `correctAnswer`、`explanation` 和 `analysis` 是服务端受保护内容;学生端仍只返回下文列出的安全题目投影。发布和归档采用 tenant/scope/expected-state CAS,并在同一事务追加生命周期审计。`SCALAR_READ` 或其他不支持的 provider mode 返回 `1_005_003_070`,不会查询或修改题目。 | 错误码 | 说明 | @@ -108,6 +121,9 @@ GET /admin-api/education/capability | `1_005_003_071` | 题目不存在或无权管理 | | `1_005_003_072` | 状态已变化或生命周期转换非法 | | `1_005_003_073` | 题目内容不完整或不安全,不能发布 | +| `1_005_003_074` | 归类目标不存在或不可用 | +| `1_005_003_075` | 归类版本或题目状态已变化 | +| `1_005_003_076` | 题目尚未归类,不能发布 | ### 用户 APP - 教育租户识别 @@ -221,7 +237,8 @@ yudao-module-education/src/main/resources/db/migration/education/ - `V4030` 创建或接管 Practice 核心闭环表,并在存在旧答案/交卷幂等表时向统一 `education_idempotency` 回填数据。 - `V4070` 对 19 条目录引用边执行历史预校验并安装写入守卫,同时将 11 张目录表的 `tenant_id/scope` 设为插入后不可变,阻止 PUBLIC→租户、跨租户以及父节点归属变更造成的非法关系。 - `V4080` 增加第 20 条目录引用边 `education_question_version.question_id → education_question.id`,并为版本表增加第 12 个 ownership/scope 守卫;版本归属必须与题目完全一致。该迁移还建立 draft-first 生命周期、不可变题目版本、同事务追加式审计及历史已发布内容的 fail-closed 预检。 -- V4080 在平台 `system_menu` 已存在时条件写入 Education 权限种子;固定 ID 已被不同 permission 占用时迁移失败,且迁移不会向角色写入授权关系。 +- `V4090` 增加 Question Placement 乐观版本、PUBLIC 管理拒绝、发布后放置冻结、发布前可用节点约束,以及 `education:question:classify` 权限种子。 +- V4080/V4090 在平台 `system_menu` 已存在时条件写入 Education 权限种子;固定 ID 已被不同 permission 占用时迁移失败,且迁移不会向角色写入授权关系。 - 旧 `education_answer_idempotency`、`education_submit_idempotency` 在首次接管时保留,后续清理必须使用更高版本的独立向前 migration。 - `sql/postgresql/education/` 是手工初始化/设计历史,`sql/mysql/education/` 是过时归档;两者都不是运行时交付入口。 - 禁止使用 `flyway clean` 或 `*-rollback.sql` 回退共享环境。应用回滚后如有 Schema 兼容问题,通过更高版本向前修复。 @@ -242,7 +259,7 @@ find yudao-module-education/target/classes/db/migration/education -type f -print **当前工作区未检出完整的前端源码。** `yudao-ui/yudao-ui-admin-vue3/` 仅包含部分 MES 相关文件(`src/api/mes/`、`src/views/mes/`),缺少 `package.json`、`router/`、`store/`、`config/` 等核心框架文件。 因此: -- **管理后台教育菜单项**:V4080 已条件写入 Education 权限/菜单记录,但不分配角色;前端仍无路由或页面组件,因此不声明已有可操作的可见教育页面。 +- **管理后台教育菜单项**:V4080/V4090 已条件写入 Education 权限/菜单记录,但不分配角色;前端仍无路由或页面组件,因此不声明已有可操作的可见教育页面。 - **Student Web/H5 应用外壳**:前端源码不存在,无法建立。 ### Student 端前端集成契约 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 index 2b475997..17e308bc 100644 --- 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 @@ -2,7 +2,9 @@ package cn.iocoder.yudao.module.education.controller.admin.question; import cn.iocoder.yudao.framework.common.pojo.CommonResult; import cn.iocoder.yudao.module.education.controller.admin.question.vo.QuestionDraftCreateReqVO; +import cn.iocoder.yudao.module.education.controller.admin.question.vo.QuestionPlacementReqVO; import cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand; +import cn.iocoder.yudao.module.education.service.question.authoring.QuestionPlacementCommand; import cn.iocoder.yudao.module.education.service.question.authoring.TenantQuestionLifecycleService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -44,6 +46,15 @@ public class QuestionAuthoringController { return success(lifecycleService.createDraft(command)); } + @PutMapping("/{id}/placement") + @Operation(summary = "将租户题目草稿放置到内容节点") + @PreAuthorize("@ss.hasPermission('education:question:classify')") + public CommonResult place(@PathVariable("id") Long id, + @Valid @RequestBody QuestionPlacementReqVO reqVO) { + return success(lifecycleService.place(id, new QuestionPlacementCommand( + reqVO.getNodeId(), reqVO.getExpectedPlacementVersion()))); + } + @PutMapping("/{id}/publish") @Operation(summary = "发布租户题目") @PreAuthorize("@ss.hasPermission('education:question:publish')") diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/question/vo/QuestionPlacementReqVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/question/vo/QuestionPlacementReqVO.java new file mode 100644 index 00000000..d9b5fc55 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/question/vo/QuestionPlacementReqVO.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.education.controller.admin.question.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import lombok.Data; + +@Schema(description = "管理后台 - 题目目录放置 Request VO") +@Data +public class QuestionPlacementReqVO { + + @Schema(description = "目标内容节点 ID", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull + @Positive + private Long nodeId; + + @Schema(description = "期望放置版本", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull + @Min(0) + @Max(Integer.MAX_VALUE - 1L) + private Integer expectedPlacementVersion; +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/catalog/QuestionDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/catalog/QuestionDO.java index b3974536..21d8b859 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/catalog/QuestionDO.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/catalog/QuestionDO.java @@ -67,6 +67,9 @@ public class QuestionDO extends CatalogScopeDO { /** 所属内容节点 ID */ private Long nodeId; + /** 目录放置的乐观锁版本;不属于题目内容版本 */ + private Integer placementVersion; + /** 标签 JSON 数组 */ private String tags; diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/ContentNodeMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/ContentNodeMapper.java index 3e6d545d..f3beedde 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/ContentNodeMapper.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/ContentNodeMapper.java @@ -4,10 +4,28 @@ import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; import cn.iocoder.yudao.module.education.dal.dataobject.catalog.ContentNodeDO; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + import java.util.List; @Mapper public interface ContentNodeMapper extends BaseMapperX { + @Select(""" + SELECT * + FROM education_content_node + WHERE id = #{nodeId} + AND deleted = false + AND is_active = true + AND is_hidden = false + AND is_selectable = true + AND ((tenant_id = #{tenantId} AND scope = 'TENANT_OWNED') + OR (tenant_id = 0 AND scope = 'PUBLIC')) + FOR SHARE + """) + ContentNodeDO selectAvailablePlacementTarget(@Param("tenantId") Long tenantId, + @Param("nodeId") Long nodeId); + default List selectChildren(Long tenantId, Long entryId, Long parentId, boolean includeInactive, String markerType) { LambdaQueryWrapperX w = base(tenantId, entryId, includeInactive, markerType); 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 5c0c5708..8b83b1cb 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 @@ -13,15 +13,40 @@ import java.util.List; @Mapper public interface QuestionMapper extends BaseMapperX { - default IPage selectPublishedPage(IPage page, Long tenantId, Long nodeId, + default IPage selectPublishedPage(IPage page, Long tenantId, String type, String difficulty) { LambdaQueryWrapperX w = visible(tenantId); - if (nodeId != null) w.eq(QuestionDO::getNodeId, nodeId); if (type != null && !type.isBlank()) w.eq(QuestionDO::getType, type); if (difficulty != null && !difficulty.isBlank()) w.eq(QuestionDO::getDifficulty, difficulty); return selectPage(page, w.orderByAsc(QuestionDO::getSortOrder).orderByAsc(QuestionDO::getId)); } + @Select(""" + + """) + IPage selectPublishedPageByAvailableNode(IPage page, + @Param("tenantId") Long tenantId, @Param("nodeId") Long nodeId, + @Param("type") String type, @Param("difficulty") String difficulty); + default IPage selectPublishedPageByIds(IPage page, Long tenantId, List ids, String type, String difficulty) { if (ids == null || ids.isEmpty()) { @@ -62,13 +87,30 @@ public interface QuestionMapper extends BaseMapperX { return selectCount(w); } - default long countPublished(Long tenantId, Long nodeId, String type, String difficulty) { - LambdaQueryWrapperX w = visible(tenantId); - if (nodeId != null) w.eq(QuestionDO::getNodeId, nodeId); - if (type != null && !type.isBlank()) w.eq(QuestionDO::getType, type); - if (difficulty != null && !difficulty.isBlank()) w.eq(QuestionDO::getDifficulty, difficulty); - return selectCount(w); - } + @Select(""" + + """) + long countPublishedByAvailableNode(@Param("tenantId") Long tenantId, + @Param("nodeId") Long nodeId, @Param("type") String type, + @Param("difficulty") String difficulty); default QuestionDO selectPublishedById(Long tenantId, Long id) { return selectOne(visible(tenantId).eq(QuestionDO::getId, id)); @@ -82,16 +124,28 @@ public interface QuestionMapper extends BaseMapperX { } default int updateLifecycle(Long tenantId, Long id, String expectedStatus, - String targetStatus, boolean published) { + String targetStatus, boolean published, Integer expectedPlacementVersion) { return update(null, new LambdaUpdateWrapper() .eq(QuestionDO::getId, id) .eq(QuestionDO::getTenantId, tenantId) .eq(QuestionDO::getScope, "TENANT_OWNED") .eq(QuestionDO::getStatus, expectedStatus) + .eq(QuestionDO::getPlacementVersion, expectedPlacementVersion) .set(QuestionDO::getStatus, targetStatus) .set(QuestionDO::getIsPublished, published)); } + default int updatePlacement(Long tenantId, Long id, Long nodeId, int expectedPlacementVersion) { + return update(null, new LambdaUpdateWrapper() + .eq(QuestionDO::getId, id) + .eq(QuestionDO::getTenantId, tenantId) + .eq(QuestionDO::getScope, "TENANT_OWNED") + .eq(QuestionDO::getStatus, "DRAFT") + .eq(QuestionDO::getPlacementVersion, expectedPlacementVersion) + .set(QuestionDO::getNodeId, nodeId) + .set(QuestionDO::getPlacementVersion, expectedPlacementVersion + 1)); + } + private LambdaQueryWrapperX 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/enums/ErrorCodeConstants.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java index 1a200277..f114a247 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 @@ -104,4 +104,7 @@ public interface ErrorCodeConstants { ErrorCode QUESTION_AUTHORING_NOT_FOUND = new ErrorCode(1_005_003_071, "题目不存在或无权管理"); ErrorCode QUESTION_LIFECYCLE_CONFLICT = new ErrorCode(1_005_003_072, "题目状态已变化,无法执行 {} 操作"); ErrorCode QUESTION_CONTENT_NOT_PUBLISHABLE = new ErrorCode(1_005_003_073, "题目内容不完整或不安全,无法发布"); + ErrorCode QUESTION_PLACEMENT_TARGET_UNAVAILABLE = new ErrorCode(1_005_003_074, "题目归类目标不存在或不可用"); + ErrorCode QUESTION_PLACEMENT_CONFLICT = new ErrorCode(1_005_003_075, "题目归类已变化,请刷新后重试"); + ErrorCode QUESTION_PLACEMENT_REQUIRED = new ErrorCode(1_005_003_076, "题目尚未归类,无法发布"); } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/provider/JavaCatalogProvider.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/provider/JavaCatalogProvider.java index 9e239bdc..0f44b1a7 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/provider/JavaCatalogProvider.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/provider/JavaCatalogProvider.java @@ -161,7 +161,11 @@ public class JavaCatalogProvider implements CatalogProvider, QuestionCatalogProv .map(QuestionCollectionQuestionDO::getQuestionId).toList(); IPage result = ids != null ? readWithExplicitCatalogScope(() -> questionMapper.selectPublishedPageByIds(new Page<>(page, bounded), tenantId(), ids, type, difficulty)) - : readWithExplicitCatalogScope(() -> questionMapper.selectPublishedPage(new Page<>(page, bounded), tenantId(), nid, type, difficulty)); + : nid != null + ? readWithExplicitCatalogScope(() -> questionMapper.selectPublishedPageByAvailableNode( + new Page<>(page, bounded), tenantId(), nid, type, difficulty)) + : readWithExplicitCatalogScope(() -> questionMapper.selectPublishedPage( + new Page<>(page, bounded), tenantId(), type, difficulty)); return buildPageResult(result); } @@ -234,7 +238,8 @@ public class JavaCatalogProvider implements CatalogProvider, QuestionCatalogProv } private long countVisibleNodeQuestions(Long nodeId, String type, String difficulty) { - return readWithExplicitCatalogScope(() -> questionMapper.countPublished(tenantId(), nodeId, type, difficulty)); + return readWithExplicitCatalogScope(() -> questionMapper.countPublishedByAvailableNode( + tenantId(), nodeId, type, difficulty)); } private CatalogPracticeBlueprintDTO toPracticeBlueprintDTO(PracticeBlueprintDO bp, long eligible) { diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/authoring/QuestionPlacementCommand.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/authoring/QuestionPlacementCommand.java new file mode 100644 index 00000000..58a3af69 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/authoring/QuestionPlacementCommand.java @@ -0,0 +1,5 @@ +package cn.iocoder.yudao.module.education.service.question.authoring; + +/** 将不可见的租户题目草稿放置到学生目录节点;版本用于乐观并发控制。 */ +public record QuestionPlacementCommand(Long nodeId, Integer expectedPlacementVersion) { +} 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 index fb269f48..cbbb433f 100644 --- 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 @@ -5,6 +5,8 @@ public interface TenantQuestionLifecycleService { Long createDraft(QuestionDraftCommand command); + int place(Long questionId, QuestionPlacementCommand command); + void publish(Long questionId, Long actorId); void archive(Long questionId, Long actorId); 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 index d94fd4f5..2a7d22e3 100644 --- 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 @@ -3,10 +3,12 @@ package cn.iocoder.yudao.module.education.service.question.authoring; import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.framework.common.util.json.JsonUtils; import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; +import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils; import cn.iocoder.yudao.module.education.config.EducationProperties; import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionDO; import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionLifecycleAuditDO; import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionVersionDO; +import cn.iocoder.yudao.module.education.dal.mysql.catalog.ContentNodeMapper; import cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionLifecycleAuditMapper; import cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionMapper; import cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionVersionMapper; @@ -41,19 +43,46 @@ public class TenantQuestionLifecycleServiceImpl implements TenantQuestionLifecyc private final EducationProperties properties; private final QuestionMapper questionMapper; + private final ContentNodeMapper contentNodeMapper; private final QuestionVersionMapper versionMapper; private final QuestionLifecycleAuditMapper auditMapper; public TenantQuestionLifecycleServiceImpl(EducationProperties properties, QuestionMapper questionMapper, + ContentNodeMapper contentNodeMapper, QuestionVersionMapper versionMapper, QuestionLifecycleAuditMapper auditMapper) { this.properties = properties; this.questionMapper = questionMapper; + this.contentNodeMapper = contentNodeMapper; this.versionMapper = versionMapper; this.auditMapper = auditMapper; } + @Override + @Transactional(rollbackFor = Exception.class) + public int place(Long questionId, QuestionPlacementCommand command) { + assertAuthoringMode(); + Long tenantId = TenantContextHolder.getRequiredTenantId(); + QuestionDO question = questionMapper.selectTenantOwnedById(tenantId, questionId); + if (question == null) { + throw exception(QUESTION_AUTHORING_NOT_FOUND); + } + if (!DRAFT.equals(question.getStatus()) + || !command.expectedPlacementVersion().equals(question.getPlacementVersion())) { + throw exception(QUESTION_PLACEMENT_CONFLICT); + } + requireAvailablePlacementTarget(tenantId, command.nodeId()); + if (command.nodeId().equals(question.getNodeId())) { + throw exception(QUESTION_PLACEMENT_CONFLICT); + } + if (questionMapper.updatePlacement(tenantId, questionId, command.nodeId(), + command.expectedPlacementVersion()) != 1) { + throw exception(QUESTION_PLACEMENT_CONFLICT); + } + return command.expectedPlacementVersion() + 1; + } + @Override @Transactional(rollbackFor = Exception.class) public Long createDraft(QuestionDraftCommand command) { @@ -128,7 +157,8 @@ public class TenantQuestionLifecycleServiceImpl implements TenantQuestionLifecyc if (PUBLISHED.equals(toStatus)) { validatePublishable(question); } - if (questionMapper.updateLifecycle(tenantId, questionId, fromStatus, toStatus, published) != 1) { + if (questionMapper.updateLifecycle(tenantId, questionId, fromStatus, toStatus, published, + question.getPlacementVersion()) != 1) { throw exception(QUESTION_LIFECYCLE_CONFLICT, operation); } @@ -155,6 +185,18 @@ public class TenantQuestionLifecycleServiceImpl implements TenantQuestionLifecyc } catch (ServiceException ex) { throw exception(QUESTION_CONTENT_NOT_PUBLISHABLE); } + if (question.getNodeId() == null) { + throw exception(QUESTION_PLACEMENT_REQUIRED); + } + Long tenantId = TenantContextHolder.getRequiredTenantId(); + requireAvailablePlacementTarget(tenantId, question.getNodeId()); + } + + private void requireAvailablePlacementTarget(Long tenantId, Long nodeId) { + if (TenantUtils.executeIgnore(() -> contentNodeMapper.selectAvailablePlacementTarget( + tenantId, nodeId)) == null) { + throw exception(QUESTION_PLACEMENT_TARGET_UNAVAILABLE); + } } private void validateAnswerKey(String rawType, String rawAnswer, diff --git a/yudao-module-education/src/main/resources/db/migration/education/V4090__add_question_placement.sql b/yudao-module-education/src/main/resources/db/migration/education/V4090__add_question_placement.sql new file mode 100644 index 00000000..1f35e36f --- /dev/null +++ b/yudao-module-education/src/main/resources/db/migration/education/V4090__add_question_placement.sql @@ -0,0 +1,138 @@ +-- EDU-010: make tenant Question Placement an explicit optimistic command and +-- require a stable, available Content Node before native publication. + +ALTER TABLE education_question + ADD COLUMN placement_version INTEGER NOT NULL DEFAULT 0, + ADD CONSTRAINT ck_education_question_placement_version + CHECK (placement_version >= 0); + +COMMENT ON COLUMN education_question.placement_version IS + '题目目录放置的乐观锁版本;不属于不可变题目内容版本'; + +CREATE FUNCTION education_enforce_question_placement_mutation() +RETURNS TRIGGER +LANGUAGE plpgsql +SET search_path = pg_catalog, pg_temp +AS $$ +BEGIN + IF NEW.node_id IS NOT DISTINCT FROM OLD.node_id + AND NEW.placement_version IS NOT DISTINCT FROM OLD.placement_version THEN + RETURN NEW; + END IF; + IF NEW.scope <> 'TENANT_OWNED' OR NEW.tenant_id <= 0 THEN + RAISE EXCEPTION 'PUBLIC question placement is not managed by tenant authoring' + USING ERRCODE = '23514'; + END IF; + IF OLD.status <> 'DRAFT' OR NEW.status <> 'DRAFT' THEN + RAISE EXCEPTION 'published or archived question placement is immutable' + USING ERRCODE = '23514'; + END IF; + IF NEW.node_id IS NOT DISTINCT FROM OLD.node_id THEN + RAISE EXCEPTION 'question placement version cannot change without placement' + USING ERRCODE = '23514'; + END IF; + IF NEW.placement_version IS NULL + OR NEW.placement_version <> OLD.placement_version + 1 THEN + RAISE EXCEPTION 'question placement version must advance exactly once' + USING ERRCODE = '23514'; + END IF; + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION education_enforce_question_lifecycle_transition() +RETURNS TRIGGER +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, pg_temp +AS $$ +DECLARE + placement_node_id BIGINT; +BEGIN + IF TG_OP = 'INSERT' THEN + IF NEW.status <> 'DRAFT' OR NEW.is_published THEN + RAISE EXCEPTION 'new questions must start in DRAFT' + USING ERRCODE = '23514'; + END IF; + RETURN NEW; + END IF; + IF NEW.status IS DISTINCT FROM OLD.status + AND (NEW.scope <> 'TENANT_OWNED' OR NEW.tenant_id <= 0) THEN + RAISE EXCEPTION 'PUBLIC question lifecycle is not managed by tenant authoring' + USING ERRCODE = '23514'; + END IF; + IF NEW.status IS DISTINCT FROM OLD.status AND NOT ( + (OLD.status = 'DRAFT' AND NEW.status = 'PUBLISHED') OR + (OLD.status = 'PUBLISHED' AND NEW.status = 'ARCHIVED') + ) THEN + RAISE EXCEPTION 'invalid question lifecycle transition from % to %', OLD.status, NEW.status + USING ERRCODE = '23514'; + END IF; + IF OLD.status = 'DRAFT' AND NEW.status = 'PUBLISHED' THEN + IF NEW.node_id IS NULL THEN + RAISE EXCEPTION 'question must be placed before publication' + USING ERRCODE = '23514'; + END IF; + EXECUTE format( + 'SELECT id FROM %I.education_content_node' || + ' WHERE id = $1 AND deleted = false AND is_active = true' || + ' AND is_hidden = false AND is_selectable = true FOR SHARE', + TG_TABLE_SCHEMA) + INTO placement_node_id + USING NEW.node_id; + IF placement_node_id IS NULL THEN + RAISE EXCEPTION 'question placement target is unavailable' + USING ERRCODE = '23514'; + END IF; + END IF; + IF NEW.status IS DISTINCT FROM OLD.status THEN + EXECUTE format( + 'INSERT INTO %I.education_question_lifecycle_transition_token' || + ' (transaction_id, tenant_id, question_id, content_version, from_status, to_status)' || + ' VALUES ($1, $2, $3, $4, $5, $6)', + TG_TABLE_SCHEMA) + USING pg_current_xact_id()::text::BIGINT, NEW.tenant_id, NEW.id, + NEW.content_version, OLD.status, NEW.status; + END IF; + RETURN NEW; +END; +$$; + +REVOKE ALL ON FUNCTION education_enforce_question_placement_mutation() FROM PUBLIC; +REVOKE ALL ON FUNCTION education_enforce_question_lifecycle_transition() FROM PUBLIC; + +CREATE TRIGGER trg_education_question_placement_mutation + BEFORE UPDATE OF node_id, placement_version ON education_question + FOR EACH ROW EXECUTE FUNCTION education_enforce_question_placement_mutation(); + +DO $$ +DECLARE + installed_permission_count INTEGER; +BEGIN + IF to_regclass('system_menu') IS NULL THEN + RETURN; + END IF; + + INSERT INTO system_menu ( + id, name, permission, type, sort, parent_id, path, icon, component, + component_name, status, visible, keep_alive, always_show, creator, updater + ) VALUES + (6805, '题目归类', 'education:question:classify', 3, 5, 6800, '', '', NULL, + NULL, 0, false, true, true, 'education-flyway', 'education-flyway') + ON CONFLICT (id) DO NOTHING; + + SELECT count(*) + INTO installed_permission_count + FROM system_menu + WHERE id = 6805 + AND permission = 'education:question:classify' + AND type = 3 + AND parent_id = 6800 + AND status = 0 + AND deleted = 0; + IF installed_permission_count <> 1 THEN + RAISE EXCEPTION 'Education Question Placement RBAC seed ID conflicts with existing system_menu row' + USING ERRCODE = '23505'; + END IF; +END; +$$; 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 index ebf2d9fe..b2b419cf 100644 --- 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 @@ -3,7 +3,9 @@ package cn.iocoder.yudao.module.education.controller.admin.question; import cn.iocoder.yudao.framework.security.core.LoginUser; import cn.iocoder.yudao.framework.security.core.service.SecurityFrameworkService; import cn.iocoder.yudao.module.education.controller.admin.question.vo.QuestionDraftCreateReqVO; +import cn.iocoder.yudao.module.education.controller.admin.question.vo.QuestionPlacementReqVO; import cn.iocoder.yudao.module.education.service.question.authoring.QuestionDraftCommand; +import cn.iocoder.yudao.module.education.service.question.authoring.QuestionPlacementCommand; import cn.iocoder.yudao.module.education.service.question.authoring.TenantQuestionLifecycleService; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -32,6 +34,7 @@ class QuestionAuthoringControllerContractTest { private static final Long ACTOR_ID = 7L; private static final String AUTHOR_PERMISSION = "education:question:author"; + private static final String CLASSIFY_PERMISSION = "education:question:classify"; private static final String PUBLISH_PERMISSION = "education:question:publish"; private static final String ARCHIVE_PERMISSION = "education:question:archive"; @@ -67,6 +70,14 @@ class QuestionAuthoringControllerContractTest { assertEquals(0, lifecycleService.totalCalls()); } + @Test + void authorPermissionDoesNotAllowPlacement() { + grant(AUTHOR_PERMISSION); + + assertThrows(AccessDeniedException.class, () -> controller.place(101L, null)); + assertEquals(0, lifecycleService.totalCalls()); + } + @Test void publishPermissionDoesNotAllowArchiving() { grant(PUBLISH_PERMISSION); @@ -75,6 +86,14 @@ class QuestionAuthoringControllerContractTest { assertEquals(0, lifecycleService.totalCalls()); } + @Test + void classifyPermissionDoesNotAllowPublishing() { + grant(CLASSIFY_PERMISSION); + + assertThrows(AccessDeniedException.class, () -> controller.publish(101L)); + assertEquals(0, lifecycleService.totalCalls()); + } + @Test void archivePermissionDoesNotAllowDraftCreation() { grant(ARCHIVE_PERMISSION); @@ -92,6 +111,19 @@ class QuestionAuthoringControllerContractTest { assertEquals(1, lifecycleService.totalCalls()); } + @Test + void classifyPermissionAllowsPlacement() { + grant(CLASSIFY_PERMISSION); + QuestionPlacementReqVO request = new QuestionPlacementReqVO(); + request.setNodeId(201L); + request.setExpectedPlacementVersion(0); + + assertEquals(1, controller.place(101L, request).getData()); + assertEquals(101L, lifecycleService.placedQuestionId); + assertEquals(new QuestionPlacementCommand(201L, 0), lifecycleService.placementCommand); + assertEquals(1, lifecycleService.totalCalls()); + } + @Test void publishPermissionAllowsPublishing() { grant(PUBLISH_PERMISSION); @@ -208,6 +240,8 @@ class QuestionAuthoringControllerContractTest { private Long publishedByActorId; private Long archivedQuestionId; private Long archivedByActorId; + private Long placedQuestionId; + private QuestionPlacementCommand placementCommand; void reset() { createdCommand = null; @@ -215,12 +249,15 @@ class QuestionAuthoringControllerContractTest { publishedByActorId = null; archivedQuestionId = null; archivedByActorId = null; + placedQuestionId = null; + placementCommand = null; } int totalCalls() { int count = createdCommand != null ? 1 : 0; count += publishedQuestionId != null ? 1 : 0; count += archivedQuestionId != null ? 1 : 0; + count += placedQuestionId != null ? 1 : 0; return count; } @@ -230,6 +267,13 @@ class QuestionAuthoringControllerContractTest { return 101L; } + @Override + public int place(Long questionId, QuestionPlacementCommand command) { + placedQuestionId = questionId; + placementCommand = command; + return command.expectedPlacementVersion() + 1; + } + @Override public void publish(Long questionId, Long actorId) { publishedQuestionId = questionId; 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 index 537f93fd..c8ca9bfe 100644 --- 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 @@ -5,8 +5,13 @@ import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils; import cn.iocoder.yudao.module.education.config.EducationProperties; import cn.iocoder.yudao.module.education.controller.app.question.vo.SafeQuestionRespVO; +import cn.iocoder.yudao.module.education.controller.app.question.vo.QuestionPageReqVO; import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionDO; import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionVersionDO; +import cn.iocoder.yudao.module.education.dal.dataobject.catalog.ContentEntryDO; +import cn.iocoder.yudao.module.education.dal.dataobject.catalog.ContentNodeDO; +import cn.iocoder.yudao.module.education.dal.mysql.catalog.ContentEntryMapper; +import cn.iocoder.yudao.module.education.dal.mysql.catalog.ContentNodeMapper; import cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionLifecycleAuditMapper; import cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionMapper; import cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionVersionMapper; @@ -46,10 +51,19 @@ class TenantQuestionLifecyclePostgreSqlIntegrationTest extends PostgreSqlDbInteg @Resource private QuestionMapper questionMapper; @Resource private QuestionVersionMapper versionMapper; @Resource private PlatformTransactionManager transactionManager; + @Resource private ContentEntryMapper contentEntryMapper; + @Resource private ContentNodeMapper contentNodeMapper; @BeforeEach void setUpTenant() { TenantContextHolder.setTenantId(10L); + TenantUtils.executeIgnore(() -> { + insertEntry(100L, 10L, "TENANT_OWNED", "questions", "Questions"); + insertEntry(110L, 0L, "PUBLIC", "public-questions", "Public Questions"); + insertNode(200L, 10L, "TENANT_OWNED", 100L, "Algebra"); + insertNode(201L, 10L, "TENANT_OWNED", 100L, "Geometry"); + insertNode(210L, 0L, "PUBLIC", 110L, "Public Algebra"); + }); } @AfterEach @@ -66,6 +80,8 @@ class TenantQuestionLifecyclePostgreSqlIntegrationTest extends PostgreSqlDbInteg "A", "secret explanation", "secret analysis")); assertQuestionNotVisible(id); + assertEquals(1, lifecycleService.place(id, new QuestionPlacementCommand(200L, 0))); + assertNodeQuestionCount(200L, 0L); lifecycleService.publish(id, 7L); SafeQuestionRespVO published = questionCatalogService.getQuestion(String.valueOf(id)); @@ -73,6 +89,7 @@ class TenantQuestionLifecyclePostgreSqlIntegrationTest extends PostgreSqlDbInteg assertEquals(2, published.getOptions().size()); assertFalse(cn.iocoder.yudao.framework.common.util.json.JsonUtils.toJsonString(published) .matches(".*(correctAnswer|explanation|analysis|isCorrect).*")); + assertNodeQuestionCount(200L, 1L); lifecycleService.archive(id, 7L); assertQuestionNotVisible(id); @@ -84,6 +101,7 @@ class TenantQuestionLifecyclePostgreSqlIntegrationTest extends PostgreSqlDbInteg Long id = lifecycleService.createDraft(new QuestionDraftCommand( "Capital of France?", "text", "easy", List.of(), "Paris", null, null)); + lifecycleService.place(id, new QuestionPlacementCommand(200L, 0)); assertThrows(RuntimeException.class, () -> lifecycleService.publish(id, null)); @@ -95,6 +113,7 @@ class TenantQuestionLifecyclePostgreSqlIntegrationTest extends PostgreSqlDbInteg @Test void shouldAllowOnlyOneConcurrentPublishAndAppendOneAudit() throws InterruptedException { Long id = lifecycleService.createDraft(questionCommand("Concurrent publish")); + lifecycleService.place(id, new QuestionPlacementCommand(200L, 0)); CountDownLatch ready = new CountDownLatch(2); CountDownLatch go = new CountDownLatch(1); AtomicInteger successCount = new AtomicInteger(); @@ -120,9 +139,41 @@ class TenantQuestionLifecyclePostgreSqlIntegrationTest extends PostgreSqlDbInteg assertEquals(1L, auditMapper.selectCount()); } + @Test + void shouldAllowOnlyOneConcurrentPlacementForExpectedVersion() throws InterruptedException { + Long id = lifecycleService.createDraft(questionCommand("Concurrent placement")); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch go = new CountDownLatch(1); + AtomicInteger successCount = new AtomicInteger(); + AtomicReference firstError = new AtomicReference<>(); + AtomicReference secondError = new AtomicReference<>(); + + Thread first = placementThread(id, 200L, ready, go, successCount, firstError); + Thread second = placementThread(id, 201L, ready, go, successCount, secondError); + first.start(); + second.start(); + assertTrue(ready.await(5, TimeUnit.SECONDS)); + go.countDown(); + first.join(10000); + second.join(10000); + + assertFalse(first.isAlive()); + assertFalse(second.isAlive()); + assertEquals(1, successCount.get()); + Throwable loserError = firstError.get() != null ? firstError.get() : secondError.get(); + ServiceException conflict = assertInstanceOf(ServiceException.class, loserError); + assertEquals(QUESTION_PLACEMENT_CONFLICT.getCode(), conflict.getCode()); + QuestionDO stored = questionMapper.selectTenantOwnedById(10L, id); + assertTrue(stored.getNodeId().equals(200L) || stored.getNodeId().equals(201L)); + assertEquals(1, stored.getPlacementVersion()); + assertEquals("DRAFT", stored.getStatus()); + assertEquals(0L, auditMapper.selectCount()); + } + @Test void shouldRejectCrossTenantPublishAndArchive() { Long id = lifecycleService.createDraft(questionCommand("Tenant isolated")); + lifecycleService.place(id, new QuestionPlacementCommand(200L, 0)); TenantContextHolder.setTenantId(20L); assertAuthoringNotFound(() -> lifecycleService.publish(id, 8L)); @@ -147,12 +198,67 @@ class TenantQuestionLifecyclePostgreSqlIntegrationTest extends PostgreSqlDbInteg assertEquals(0L, auditMapper.selectCount()); } + @Test + void shouldAllowTenantDraftPlacementOnPublicNode() { + Long id = lifecycleService.createDraft(questionCommand("Public classification")); + + assertEquals(1, lifecycleService.place(id, new QuestionPlacementCommand(210L, 0))); + lifecycleService.publish(id, 7L); + + assertNodeQuestionCount(210L, 1L); + } + + @Test + void shouldHidePublishedQuestionsWhenPlacementNodeBecomesUnavailable() { + Long id = lifecycleService.createDraft(questionCommand("Hidden route")); + lifecycleService.place(id, new QuestionPlacementCommand(200L, 0)); + lifecycleService.publish(id, 7L); + assertNodeQuestionCount(200L, 1L); + + ContentNodeDO update = new ContentNodeDO(); + update.setId(200L); + update.setIsActive(false); + contentNodeMapper.updateById(update); + + assertNodeQuestionCount(200L, 0L); + assertNotNull(questionCatalogService.getQuestion(String.valueOf(id))); + } + + @Test + void shouldRejectCrossTenantPlacementWithoutPartialWrite() { + Long id = lifecycleService.createDraft(questionCommand("Tenant placement")); + TenantUtils.executeIgnore(() -> { + insertEntry(120L, 20L, "TENANT_OWNED", "other-questions", "Other Questions"); + insertNode(220L, 20L, "TENANT_OWNED", 120L, "Other Algebra"); + }); + + ServiceException ex = assertThrows(ServiceException.class, + () -> lifecycleService.place(id, new QuestionPlacementCommand(220L, 0))); + + assertEquals(QUESTION_PLACEMENT_TARGET_UNAVAILABLE.getCode(), ex.getCode()); + QuestionDO stored = questionMapper.selectTenantOwnedById(10L, id); + assertNull(stored.getNodeId()); + assertEquals(0, stored.getPlacementVersion()); + assertEquals(1L, versionMapper.selectCount()); + assertEquals(0L, auditMapper.selectCount()); + } + private void assertQuestionNotVisible(Long id) { ServiceException ex = assertThrows(ServiceException.class, () -> questionCatalogService.getQuestion(String.valueOf(id))); assertEquals(QUESTION_NOT_FOUND.getCode(), ex.getCode()); } + private void assertNodeQuestionCount(Long nodeId, long expected) { + QuestionPageReqVO request = new QuestionPageReqVO(); + request.setNodeId(String.valueOf(nodeId)); + var page = questionCatalogService.pageQuestions(request); + assertEquals(expected, page.getTotal()); + assertEquals(expected, page.getList().size()); + assertFalse(cn.iocoder.yudao.framework.common.util.json.JsonUtils.toJsonString(page.getList()) + .matches(".*(correctAnswer|explanation|analysis|isCorrect).*")); + } + private void assertAuthoringNotFound(org.junit.jupiter.api.function.Executable executable) { ServiceException ex = assertThrows(ServiceException.class, executable); assertEquals(QUESTION_AUTHORING_NOT_FOUND.getCode(), ex.getCode()); @@ -175,6 +281,23 @@ class TenantQuestionLifecyclePostgreSqlIntegrationTest extends PostgreSqlDbInteg }); } + private Thread placementThread(Long questionId, Long nodeId, CountDownLatch ready, CountDownLatch go, + AtomicInteger successCount, AtomicReference error) { + return new Thread(() -> { + TenantContextHolder.setTenantId(10L); + ready.countDown(); + try { + assertTrue(go.await(5, TimeUnit.SECONDS)); + lifecycleService.place(questionId, new QuestionPlacementCommand(nodeId, 0)); + successCount.incrementAndGet(); + } catch (Throwable throwable) { + error.set(throwable); + } finally { + TenantContextHolder.clear(); + } + }); + } + private Long createPublicDraft() { return TenantUtils.executeIgnore(() -> new TransactionTemplate(transactionManager).execute(status -> { QuestionDO question = new QuestionDO(); @@ -207,4 +330,26 @@ class TenantQuestionLifecyclePostgreSqlIntegrationTest extends PostgreSqlDbInteg private QuestionDraftCommand questionCommand(String stem) { return new QuestionDraftCommand(stem, "text", "easy", List.of(), "answer", null, null); } + + private void insertEntry(Long id, Long tenantId, String scope, String key, String name) { + ContentEntryDO entry = new ContentEntryDO(); + entry.setId(id); + entry.setTenantId(tenantId); + entry.setScope(scope); + entry.setEntryKey(key); + entry.setName(name); + entry.setEntryType("question"); + contentEntryMapper.insert(entry); + } + + private void insertNode(Long id, Long tenantId, String scope, Long entryId, String name) { + ContentNodeDO node = new ContentNodeDO(); + node.setId(id); + node.setTenantId(tenantId); + node.setScope(scope); + node.setEntryId(entryId); + node.setName(name); + node.setNodeType("category"); + contentNodeMapper.insert(node); + } } 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 index 173efa51..04206e15 100644 --- 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 @@ -3,7 +3,9 @@ package cn.iocoder.yudao.module.education.service.question.authoring; import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; import cn.iocoder.yudao.module.education.config.EducationProperties; +import cn.iocoder.yudao.module.education.dal.dataobject.catalog.ContentNodeDO; import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionDO; +import cn.iocoder.yudao.module.education.dal.mysql.catalog.ContentNodeMapper; import cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionLifecycleAuditMapper; import cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionMapper; import cn.iocoder.yudao.module.education.dal.mysql.catalog.QuestionVersionMapper; @@ -31,6 +33,7 @@ import static org.mockito.Mockito.*; class TenantQuestionLifecycleServiceImplTest { @Mock private QuestionMapper questionMapper; + @Mock private ContentNodeMapper contentNodeMapper; @Mock private QuestionVersionMapper versionMapper; @Mock private QuestionLifecycleAuditMapper auditMapper; @@ -40,7 +43,8 @@ class TenantQuestionLifecycleServiceImplTest { @BeforeEach void setUp() { properties = new EducationProperties(); - service = new TenantQuestionLifecycleServiceImpl(properties, questionMapper, versionMapper, auditMapper); + service = new TenantQuestionLifecycleServiceImpl(properties, questionMapper, contentNodeMapper, + versionMapper, auditMapper); TenantContextHolder.setTenantId(10L); } @@ -56,7 +60,18 @@ class TenantQuestionLifecycleServiceImplTest { ServiceException ex = assertThrows(ServiceException.class, () -> service.publish(1L, 7L)); assertEquals(QUESTION_AUTHORING_PROVIDER_UNSUPPORTED.getCode(), ex.getCode()); - verifyNoInteractions(questionMapper, versionMapper, auditMapper); + verifyNoInteractions(questionMapper, contentNodeMapper, versionMapper, auditMapper); + } + + @Test + void shouldFailPlacementBeforeDatabaseAccessInScalarMode() { + properties.setCatalogMode(CatalogProviderMode.SCALAR_READ); + + ServiceException ex = assertThrows(ServiceException.class, + () -> service.place(101L, new QuestionPlacementCommand(201L, 0))); + + assertEquals(QUESTION_AUTHORING_PROVIDER_UNSUPPORTED.getCode(), ex.getCode()); + verifyNoInteractions(questionMapper, contentNodeMapper, versionMapper, auditMapper); } @Test @@ -95,7 +110,8 @@ class TenantQuestionLifecycleServiceImplTest { void shouldPublishWithCasAndAppendAudit() { properties.setCatalogMode(CatalogProviderMode.JAVA_READ); when(questionMapper.selectTenantOwnedById(10L, 101L)).thenReturn(draft()); - when(questionMapper.updateLifecycle(10L, 101L, "DRAFT", "PUBLISHED", true)).thenReturn(1); + when(contentNodeMapper.selectAvailablePlacementTarget(10L, 201L)).thenReturn(availableNode()); + when(questionMapper.updateLifecycle(10L, 101L, "DRAFT", "PUBLISHED", true, 1)).thenReturn(1); service.publish(101L, 7L); @@ -110,6 +126,82 @@ class TenantQuestionLifecycleServiceImplTest { assertEquals("PUBLISHED", auditCaptor.getValue().getToStatus()); } + @Test + void shouldPlaceDraftOnAvailableNodeWithCas() { + properties.setCatalogMode(CatalogProviderMode.JAVA_READ); + ContentNodeDO node = new ContentNodeDO(); + node.setId(201L); + node.setTenantId(10L); + node.setScope("TENANT_OWNED"); + node.setIsActive(true); + node.setIsHidden(false); + node.setIsSelectable(true); + QuestionDO draft = draft(); + draft.setNodeId(null); + draft.setPlacementVersion(0); + when(questionMapper.selectTenantOwnedById(10L, 101L)).thenReturn(draft); + when(contentNodeMapper.selectAvailablePlacementTarget(10L, 201L)).thenReturn(node); + when(questionMapper.updatePlacement(10L, 101L, 201L, 0)).thenReturn(1); + + int placementVersion = service.place(101L, new QuestionPlacementCommand(201L, 0)); + + assertEquals(1, placementVersion); + verify(questionMapper).updatePlacement(10L, 101L, 201L, 0); + verifyNoInteractions(versionMapper, auditMapper); + } + + @Test + void shouldRejectUnavailablePlacementTarget() { + properties.setCatalogMode(CatalogProviderMode.JAVA_READ); + QuestionDO draft = draft(); + when(questionMapper.selectTenantOwnedById(10L, 101L)).thenReturn(draft); + + ServiceException ex = assertThrows(ServiceException.class, + () -> service.place(101L, new QuestionPlacementCommand(999L, 1))); + + assertEquals(QUESTION_PLACEMENT_TARGET_UNAVAILABLE.getCode(), ex.getCode()); + verify(questionMapper, never()).updatePlacement(any(), any(), any(), anyInt()); + } + + @Test + void shouldRejectStalePlacementVersionBeforeTargetLookup() { + properties.setCatalogMode(CatalogProviderMode.JAVA_READ); + when(questionMapper.selectTenantOwnedById(10L, 101L)).thenReturn(draft()); + + ServiceException ex = assertThrows(ServiceException.class, + () -> service.place(101L, new QuestionPlacementCommand(202L, 0))); + + assertEquals(QUESTION_PLACEMENT_CONFLICT.getCode(), ex.getCode()); + verifyNoInteractions(contentNodeMapper); + verify(questionMapper, never()).updatePlacement(any(), any(), any(), anyInt()); + } + + @Test + void shouldRejectPlacementWhenCasLoses() { + properties.setCatalogMode(CatalogProviderMode.JAVA_READ); + when(questionMapper.selectTenantOwnedById(10L, 101L)).thenReturn(draft()); + when(contentNodeMapper.selectAvailablePlacementTarget(10L, 202L)).thenReturn(availableNode()); + + ServiceException ex = assertThrows(ServiceException.class, + () -> service.place(101L, new QuestionPlacementCommand(202L, 1))); + + assertEquals(QUESTION_PLACEMENT_CONFLICT.getCode(), ex.getCode()); + verify(questionMapper).updatePlacement(10L, 101L, 202L, 1); + } + + @Test + void shouldRejectPlacementOnCurrentNodeWithoutVersionChange() { + properties.setCatalogMode(CatalogProviderMode.JAVA_READ); + when(questionMapper.selectTenantOwnedById(10L, 101L)).thenReturn(draft()); + when(contentNodeMapper.selectAvailablePlacementTarget(10L, 201L)).thenReturn(availableNode()); + + ServiceException ex = assertThrows(ServiceException.class, + () -> service.place(101L, new QuestionPlacementCommand(201L, 1))); + + assertEquals(QUESTION_PLACEMENT_CONFLICT.getCode(), ex.getCode()); + verify(questionMapper, never()).updatePlacement(any(), any(), any(), anyInt()); + } + @Test void shouldPreserveRequestOrderWhenOptionOrderIsOmitted() { properties.setCatalogMode(CatalogProviderMode.JAVA_READ); @@ -141,7 +233,7 @@ class TenantQuestionLifecycleServiceImplTest { ServiceException ex = assertThrows(ServiceException.class, () -> service.publish(101L, 7L)); assertEquals(QUESTION_CONTENT_NOT_PUBLISHABLE.getCode(), ex.getCode()); - verify(questionMapper, never()).updateLifecycle(any(), any(), any(), any(), anyBoolean()); + verify(questionMapper, never()).updateLifecycle(any(), any(), any(), any(), anyBoolean(), any()); } @Test @@ -154,7 +246,34 @@ class TenantQuestionLifecycleServiceImplTest { ServiceException ex = assertThrows(ServiceException.class, () -> service.publish(101L, 7L)); assertEquals(QUESTION_CONTENT_NOT_PUBLISHABLE.getCode(), ex.getCode()); - verify(questionMapper, never()).updateLifecycle(any(), any(), any(), any(), anyBoolean()); + verify(questionMapper, never()).updateLifecycle(any(), any(), any(), any(), anyBoolean(), any()); + verifyNoInteractions(auditMapper); + } + + @Test + void shouldRejectPublishingUnplacedDraft() { + properties.setCatalogMode(CatalogProviderMode.JAVA_READ); + QuestionDO draft = draft(); + draft.setPlacementVersion(0); + draft.setNodeId(null); + when(questionMapper.selectTenantOwnedById(10L, 101L)).thenReturn(draft); + + ServiceException ex = assertThrows(ServiceException.class, () -> service.publish(101L, 7L)); + + assertEquals(QUESTION_PLACEMENT_REQUIRED.getCode(), ex.getCode()); + verify(questionMapper, never()).updateLifecycle(any(), any(), any(), any(), anyBoolean(), any()); + verifyNoInteractions(contentNodeMapper, auditMapper); + } + + @Test + void shouldRejectPublishingWhenPlacementTargetBecameUnavailable() { + properties.setCatalogMode(CatalogProviderMode.JAVA_READ); + when(questionMapper.selectTenantOwnedById(10L, 101L)).thenReturn(draft()); + + ServiceException ex = assertThrows(ServiceException.class, () -> service.publish(101L, 7L)); + + assertEquals(QUESTION_PLACEMENT_TARGET_UNAVAILABLE.getCode(), ex.getCode()); + verify(questionMapper, never()).updateLifecycle(any(), any(), any(), any(), anyBoolean(), any()); verifyNoInteractions(auditMapper); } @@ -176,7 +295,7 @@ class TenantQuestionLifecycleServiceImplTest { }); assertEquals(QUESTION_LIFECYCLE_CONFLICT.getCode(), ex.getCode()); - verify(questionMapper, never()).updateLifecycle(any(), any(), any(), any(), anyBoolean()); + verify(questionMapper, never()).updateLifecycle(any(), any(), any(), any(), anyBoolean(), any()); verifyNoInteractions(auditMapper); } @@ -206,8 +325,21 @@ class TenantQuestionLifecycleServiceImplTest { question.setOptions("[{\"label\":\"A\",\"content\":\"4\",\"order\":1}," + "{\"label\":\"B\",\"content\":\"5\",\"order\":2}]"); question.setCorrectAnswer("A"); + question.setNodeId(201L); + question.setPlacementVersion(1); question.setStatus("DRAFT"); question.setIsPublished(false); return question; } + + private ContentNodeDO availableNode() { + ContentNodeDO node = new ContentNodeDO(); + node.setId(201L); + node.setTenantId(10L); + node.setScope("TENANT_OWNED"); + node.setIsActive(true); + node.setIsHidden(false); + node.setIsSelectable(true); + return node; + } } 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 b3554276..a89a9595 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", "4080"); + .containsExactly("4009", "4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080", "4090"); assertThat(queryLong(schema, "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = current_schema() " + "AND table_name = 'education_idempotency'")) @@ -181,7 +181,7 @@ class EducationFlywayMigrationIntegrationTest { assertThat(queryStrings(schema, "SELECT version FROM flyway_schema_history WHERE success = TRUE ORDER BY installed_rank")) - .containsExactly("4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080"); + .containsExactly("4010", "4020", "4030", "4040", "4050", "4060", "4070", "4080", "4090"); assertThat(queryStrings(schema, "SELECT table_name FROM information_schema.tables " + "WHERE table_schema = current_schema() AND table_name IN (" + @@ -264,7 +264,7 @@ class EducationFlywayMigrationIntegrationTest { @Test void shouldCreateDraftFirstQuestionLifecycleSchema() throws SQLException { String schema = createSchema("question_lifecycle"); - configureFlyway(schema, false).load().migrate(); + configureFlyway(schema, false).target("4080").load().migrate(); assertThat(queryStrings(schema, """ SELECT column_default @@ -386,6 +386,85 @@ class EducationFlywayMigrationIntegrationTest { .hasMessageContaining("question version ownership must equal question ownership"); } + @Test + void shouldCreateQuestionPlacementSchemaAndFreezePublishedPlacement() throws SQLException { + String schema = createSchema("question_placement"); + configureFlyway(schema, false).load().migrate(); + + assertThat(queryStrings(schema, """ + SELECT column_default || ':' || is_nullable + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'education_question' + AND column_name = 'placement_version' + """)) + .containsExactly("0:NO"); + + execute(schema, """ + INSERT INTO education_content_entry + (id, tenant_id, scope, entry_key, name, entry_type) + VALUES (100, 10, 'TENANT_OWNED', 'questions', 'Questions', 'question'); + INSERT INTO education_content_node + (id, tenant_id, scope, entry_id, name, node_type) + VALUES (200, 10, 'TENANT_OWNED', 100, 'Algebra', 'category'), + (201, 10, 'TENANT_OWNED', 100, 'Geometry', 'category'); + INSERT INTO education_question + (id, tenant_id, scope, stem, type, options, correct_answer) + VALUES (300, 10, 'TENANT_OWNED', 'Placed draft', 'text', '[]', 'answer'); + INSERT INTO education_question_version + (tenant_id, scope, question_id, version_number, stem, type, options, correct_answer) + VALUES (10, 'TENANT_OWNED', 300, 1, 'Placed draft', 'text', '[]', 'answer'); + INSERT INTO education_content_entry + (id, tenant_id, scope, entry_key, name, entry_type) + VALUES (110, 0, 'PUBLIC', 'public-questions', 'Public Questions', 'question'); + INSERT INTO education_content_node + (id, tenant_id, scope, entry_id, name, node_type) + VALUES (210, 0, 'PUBLIC', 110, 'Public Algebra', 'category'), + (211, 0, 'PUBLIC', 110, 'Public Geometry', 'category'); + INSERT INTO education_question + (id, tenant_id, scope, stem, type, options, correct_answer, node_id) + VALUES (310, 0, 'PUBLIC', 'Public draft', 'text', '[]', 'answer', 210); + INSERT INTO education_question_version + (tenant_id, scope, question_id, version_number, stem, type, options, correct_answer) + VALUES (0, 'PUBLIC', 310, 1, 'Public draft', 'text', '[]', 'answer'); + """); + + assertThatThrownBy(() -> execute(schema, """ + UPDATE education_question + SET node_id = 211, placement_version = 1 + WHERE id = 310; + """)) + .hasMessageContaining("PUBLIC question placement is not managed by tenant authoring"); + + assertThatThrownBy(() -> execute(schema, """ + UPDATE education_question SET node_id = 200 WHERE id = 300; + """)) + .hasMessageContaining("question placement version must advance exactly once"); + + execute(schema, """ + UPDATE education_question + SET node_id = 200, placement_version = 1 + WHERE id = 300; + DO $publish$ + BEGIN + UPDATE education_question + SET status = 'PUBLISHED', is_published = true + WHERE id = 300; + INSERT INTO education_question_lifecycle_audit + (tenant_id, question_id, content_version, actor_id, from_status, to_status) + VALUES (10, 300, 1, 7, 'DRAFT', 'PUBLISHED'); + END + $publish$; + """); + + assertThatThrownBy(() -> execute(schema, """ + UPDATE education_question + SET node_id = 201, placement_version = 2 + WHERE id = 300; + """)) + .hasMessageContaining("published or archived question placement is immutable"); + } + @Test void shouldNormalizeHistoricalQuestionVisibilityIntoLifecycleStates() throws SQLException { String schema = createSchema("question_history"); @@ -490,14 +569,15 @@ class EducationFlywayMigrationIntegrationTest { assertThat(queryStrings(schema, """ SELECT permission FROM system_menu - WHERE id BETWEEN 6801 AND 6804 + WHERE id BETWEEN 6801 AND 6805 ORDER BY id """)) .containsExactly( "education:capability", "education:question:author", "education:question:publish", - "education:question:archive"); + "education:question:archive", + "education:question:classify"); } @Test @@ -516,6 +596,23 @@ class EducationFlywayMigrationIntegrationTest { .hasMessageContaining("Education System RBAC seed IDs conflict"); } + @Test + void shouldFailClosedWhenQuestionPlacementPermissionSeedConflicts() throws SQLException { + String schema = createSchema("question_placement_permission_conflict"); + configureFlyway(schema, false).target("4070").load().migrate(); + createSystemMenuFixture(schema); + configureFlyway(schema, false).target("4080").load().migrate(); + execute(schema, """ + INSERT INTO system_menu + (id, name, permission, type, sort, parent_id, status, deleted) + VALUES (6805, 'Conflicting placement permission', 'education:question:other', + 3, 5, 6800, 0, 0); + """); + + assertThatThrownBy(() -> configureFlyway(schema, false).load().migrate()) + .hasMessageContaining("Education Question Placement RBAC seed ID conflicts"); + } + @Test void shouldAttachGraphGuardToEveryCatalogReference() throws SQLException { String schema = createSchema("catalog_triggers"); @@ -606,6 +703,7 @@ class EducationFlywayMigrationIntegrationTest { 'education_require_question_lifecycle_audit', 'education_check_question_version_ownership', 'education_validate_question_lifecycle_audit_insert', + 'education_enforce_question_placement_mutation', 'education_question_answer_key_is_valid') AND grantee = 'PUBLIC' AND privilege_type = 'EXECUTE' 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 00f5ccaf..fae019e1 100644 --- a/yudao-module-education/src/test/resources/sql/postgresql/clean.sql +++ b/yudao-module-education/src/test/resources/sql/postgresql/clean.sql @@ -12,5 +12,7 @@ TRUNCATE TABLE education_practice_report_detail, education_practice_report, education_practice_question, - education_practice_session + education_practice_session, + education_content_node, + education_content_entry RESTART IDENTITY CASCADE;