diff --git a/docs/education/migration/07-decisions.md b/docs/education/migration/07-decisions.md index 51a4fecc..3be3146f 100644 --- a/docs/education/migration/07-decisions.md +++ b/docs/education/migration/07-decisions.md @@ -126,6 +126,10 @@ The durable artifact classification, adoption matrix, version allocation, backfi 17. Activation exposes the collection route only when the collection and its existing Content Node are available. Archiving closes only collection-route discovery; it does not alter direct Question visibility or historical Practice Question Snapshots. 18. `access_rules` is descriptive reserved metadata in this slice, not entitlement enforcement. PUBLIC Question curation, dynamic filters, paid/private access, Category, Practice Blueprint, and Content Entry creation are excluded. +19. V4120 closes the bounded EDU-010 Category and Practice Blueprint scope. Category is a standalone Subject-scoped catalog aggregate, not Question classification. Blueprint authoring is limited to exactly one current-tenant NODE or active Manual COLLECTION target; target ownership, route, and counts are server-derived. +20. Category and Practice Blueprint use the same `DRAFT → ACTIVE → ARCHIVED`, `authoring_version` CAS, transactional append-only audit, current-tenant ownership, and `JAVA_READ` authority rules as Content Node and Collection. Student reads require ACTIVE lifecycle plus route availability. +21. The six added permissions are `education:category:{author,publish,archive}` and `education:practice-blueprint:{author,publish,archive}`. V4120 conditionally seeds permission rows at IDs 6809-6814, fails on conflicting IDs, and assigns no roles. + **Permission and data-scope matrix:** | Actor capability | Permission | Allowed rows | Data scope | @@ -140,6 +144,12 @@ The durable artifact classification, adoption matrix, version allocation, backfi | Collection author | `education:collection:author` | Create/revise current-tenant TENANT_OWNED Manual Question Collection drafts and replace ordered membership | Current tenant; DRAFT-only; existing ACTIVE node and PUBLISHED tenant questions | | Collection publisher | `education:collection:publish` | Activate current-tenant TENANT_OWNED Manual Question Collection drafts | Tenant-wide shared catalog asset; authoring-version CAS | | Collection archiver | `education:collection:archive` | Archive current-tenant TENANT_OWNED active Manual Question Collections | Tenant-wide shared catalog asset; authoring-version CAS | +| Category author | `education:category:author` | Create/revise current-tenant TENANT_OWNED Category drafts | Current tenant; active PUBLIC or same-tenant Subject reference | +| Category publisher | `education:category:publish` | Activate current-tenant TENANT_OWNED Category drafts | Tenant-wide shared catalog asset; authoring-version CAS | +| Category archiver | `education:category:archive` | Archive current-tenant TENANT_OWNED active Categories | Tenant-wide shared catalog asset; authoring-version CAS | +| Practice Blueprint author | `education:practice-blueprint:author` | Create/revise bounded NODE or COLLECTION blueprint drafts | Current tenant; target ownership and counts derived server-side | +| Practice Blueprint publisher | `education:practice-blueprint:publish` | Activate current-tenant TENANT_OWNED blueprint drafts | Tenant-wide shared catalog asset; target revalidation and CAS | +| Practice Blueprint archiver | `education:practice-blueprint:archive` | Archive current-tenant TENANT_OWNED active blueprints | Tenant-wide shared catalog asset; authoring-version CAS | | Platform curator | none in this slice | No PUBLIC writes through these endpoints | Fail closed | V4080/V4090 conditionally seed the five Education permissions (`education:capability` plus author/classify/publish/archive). V4110 conditionally seeds the collection author/publish/archive permissions at IDs 6806-6808. Seeds run only when `system_menu` exists, fail when a fixed ID is occupied by a different permission, and deliberately assign no role. diff --git a/docs/education/migration/issues/EDU-010-content-publication.md b/docs/education/migration/issues/EDU-010-content-publication.md index beba59fe..a60e5f47 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, JAVA_READ tenant-question lifecycle, Question Placement, tenant Content Node lifecycle, and Manual Question Collection slices are delivered; category and blueprint authoring remain +- **Status:** done — bounded JAVA_READ tenant Question, Placement, Content Node, Manual Collection, Category, and Practice Blueprint authoring/publication lifecycles are delivered; broader excluded workflows remain separately scoped - **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) @@ -132,7 +132,23 @@ Accepted boundary: No new ADR is added: this bounded slice consistently applies the existing native-authority and lifecycle decisions and does not introduce a separate hard-to-reverse architectural trade-off. -## Verification evidence (2026-07-30) +## Delivery progress — JAVA_READ Category and Practice Blueprint slice (2026-07-31) + +Delivered: + +- Category draft create/revise, activate, and archive commands manage only current-tenant `TENANT_OWNED` rows. Subject ownership is server-validated against active PUBLIC or same-tenant Subjects; student category discovery requires `publication_status='ACTIVE'` and `is_active=true`. +- Practice Blueprint draft create/revise, activate, and archive commands support only bounded `NODE` and `COLLECTION` modes. Exactly one target is selected by the request, while `entry_id`, effective node, tenant/scope, eligible/total counts, and lifecycle are server-controlled. +- NODE blueprints require an existing ACTIVE visible current-tenant Content Node and count current-tenant Published Questions on that node. COLLECTION blueprints require an ACTIVE current-tenant Manual Question Collection and derive counts from its maintained membership count. +- Both aggregates use `DRAFT → ACTIVE → ARCHIVED`, one monotonic `authoring_version` CAS, immutable ACTIVE content, terminal ARCHIVED state, transactional actor/version/status audit, append-only audit tables, and fail-before-mapper `JAVA_READ` authority checks. +- Separate Category and Practice Blueprint author/publish/archive permissions are conditionally seeded by V4120 without assigning any role. PUBLIC/platform-curator writes remain unavailable. +- Student blueprint lookup uses one availability query across the blueprint, Content Node, Content Entry, and optional Collection so draft, archived, or route-unavailable blueprints fail closed. +- `V4120__add_category_and_practice_blueprint_authoring.sql` is the only new migration version and was executed by focused real-PostgreSQL tests. + +## Remaining exclusions after EDU-010 + +EDU-010 intentionally does not deliver PUBLIC/platform-curator authoring, Content Entry authoring, Question Bank authoring, dynamic/filter blueprints, mixed or descendant-node blueprint selection, type/difficulty-specific authoring semantics, paid/private entitlement enforcement, Retire/restore transitions, per-member score/required flags, legacy asset/import workflows, or administrative list/detail/delete endpoints. Category remains a separately discoverable catalog aggregate and is not a Question relationship; Question classification continues through Placement. + +## Verification evidence (2026-07-31) - `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. diff --git a/docs/education/migration/issues/README.md b/docs/education/migration/issues/README.md index dc17bc4e..04b792ea 100644 --- a/docs/education/migration/issues/README.md +++ b/docs/education/migration/issues/README.md @@ -36,7 +36,7 @@ EDU-000 Phase 0 artifacts done └── EDU-005 PostgreSQL/Flyway takeover decision done EDU-009 + provider/content decisions - └── EDU-010 Tenant content publication in progress (Manual Collection delivered; category/blueprint remain) + └── EDU-010 Tenant content publication done (bounded JAVA_READ publication scope delivered) └── EDU-011 Import/export/assets/scanning blocked; contract disposition exposed by capability manifest EDU-004 diff --git a/yudao-module-education/CONTEXT.md b/yudao-module-education/CONTEXT.md index fef312f3..508893aa 100644 --- a/yudao-module-education/CONTEXT.md +++ b/yudao-module-education/CONTEXT.md @@ -104,21 +104,17 @@ _Avoid_: Question publication, snapshot invalidation Descriptive collection metadata reserved for a future access contract. Its presence does not grant, deny, or prove paid, private, membership, or other entitlement in the current Education model. _Avoid_: Entitlement policy, authorization rule -**Vocabulary Review State**: -Education-owned, tenant-and-student scoped progress for one explicit vocabulary key. Review outcomes are bounded to Again, Hard, Good, or Easy and deterministically move mastery and the next review time; this is learning state, not catalog authority. -_Avoid_: Shared dictionary ownership, client-selected mastery +**Category Publication State**: +The lifecycle of a tenant-owned Subject-scoped Category: Draft is author-editable and student-invisible, Active is immutable and discoverable, and Archived is terminal and undiscoverable. Category does not classify Questions. +_Avoid_: Question Placement, question category relation -**Exam Reminder Plan**: -A student-owned schedule that Education durably claims and dispatches through System's `NotifyMessageSendApi`. The reminder row is the retry and compatibility authority; the System notify message is the delivery result, not the schedule itself. -_Avoid_: Direct System table write, best-effort timer +**Practice Blueprint**: +A bounded tenant-owned practice configuration attached to exactly one active current-tenant Content Node or Manual Question Collection. Its route and eligible counts are server-derived; it does not define dynamic filtering, entitlement, or descendant traversal. +_Avoid_: Dynamic query, paid practice policy -**Learning Award Orchestration**: -Education's idempotent record of one bounded learning event and its point/badge outcome. Point balance remains Member-owned and is changed only through `MemberPointApi`; clients select a supported event family, never a point amount or badge code. -_Avoid_: Education point ledger, arbitrary client reward - -**Tenant Learning Leaderboard**: -A bounded current-tenant projection of awarded Education learning points. It exposes deterministic anonymous aliases and aggregate scores only; member identifiers and detailed reports are excluded. -_Avoid_: Cross-tenant ranking, student profile export +**Practice Blueprint Publication State**: +The lifecycle of a Practice Blueprint: Draft is author-editable and student-invisible, Active is immutable and returned only while its target route remains available, and Archived is terminal and unavailable. +_Avoid_: Boolean availability as an independent state **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. diff --git a/yudao-module-education/README.md b/yudao-module-education/README.md index 62112442..22e5f761 100644 --- a/yudao-module-education/README.md +++ b/yudao-module-education/README.md @@ -17,7 +17,6 @@ - 答案保存端点 (见下方 Answer API) — 幂等保存,安全重试 - 题目安全过滤(答案/解析绝不暴露到前端) - 管理端题目创作、目录放置、发布和归档端点 — 仅在 `JAVA_READ` 下接受写入 -- 租户班级、现有 Member 学生/教师关系,以及具备过期与审计语义的幂等邀请创建/接受 - 独立的功能开关配置 + Scalar 数据源配置 - 错误码常量(通用 + 租户 + Catalog/Scalar + 题目/练习) - System RBAC 权限注解及 V4080/V4090 条件种子(`education:capability`、`education:question:author`、`education:question:classify`、`education:question:publish`、`education:question:archive`);迁移不自动向任何角色授权 @@ -126,20 +125,6 @@ GET /admin-api/education/capability | `1_005_003_075` | 归类版本或题目状态已变化 | | `1_005_003_076` | 题目尚未归类,不能发布 | -### 管理后台与用户 APP - 班级关系 - -班级只属于当前框架租户,关系引用当前租户已经存在的 Member 用户。Education 不创建账号、不保存密码,也不把 `STUDENT`/`TEACHER` 班级角色映射为 System RBAC 角色。 - -| 端点 | 权限 | 说明 | -|------|------|------| -| `POST /admin-api/education/classes` | `education:class:create` | 创建当前租户班级 | -| `GET /admin-api/education/classes` | `education:class:query` | 查询当前租户班级 | -| `GET /admin-api/education/classes/{classId}/members` | `education:class-member:query` | 查询指定班级关系 | -| `POST /admin-api/education/classes/{classId}/invitations` | `education:class-invitation:create` | 幂等邀请现有 Member 用户 | -| `POST /app-api/education/class-invitations/{invitationId}/accept` | Member 登录态 | 邀请本人幂等接受 | - -邀请创建的幂等范围是当前租户、创建人和 `idempotencyKey`;同键不同请求返回 `1_005_004_006`。接受时锁定邀请,校验邀请人、过期时间和班级状态,在同一事务最多创建一条关系和一条 `ACCEPTED` 审计。跨租户班级、关系与邀请在 Mapper 和 PostgreSQL trigger 两层失败关闭。V4140 是该能力唯一 migration。 - ### 用户 APP - 教育租户识别 ``` @@ -254,8 +239,7 @@ yudao-module-education/src/main/resources/db/migration/education/ - `V4080` 增加第 20 条目录引用边 `education_question_version.question_id → education_question.id`,并为版本表增加第 12 个 ownership/scope 守卫;版本归属必须与题目完全一致。该迁移还建立 draft-first 生命周期、不可变题目版本、同事务追加式审计及历史已发布内容的 fail-closed 预检。 - `V4090` 增加 Question Placement 乐观版本、PUBLIC 管理拒绝、发布后放置冻结、发布前可用节点约束,以及 `education:question:classify` 权限种子。 - `V4100` 增加租户 Content Node 的 `DRAFT → ACTIVE → ARCHIVED` 生命周期、统一 `authoring_version` CAS、同事务追加式审计和 entry/parent 结构约束;不写入权限或角色种子。 -- `V4110` 增加租户手工题集的生命周期、完整有序成员替换和学生发现路径约束。 -- `V4160` 仅用于 EDU-014 有界学习波次:新增词汇复习状态、考试提醒计划、学习奖励编排和学生反馈表,并在 System 通知模板表存在时条件安装 `education_exam_reminder` 模板。视频与 AI 不获得任何访问或权益能力。 +- `V4120` 增加租户 Category 与 bounded Practice Blueprint 的 `DRAFT → ACTIVE → ARCHIVED` 生命周期、统一 `authoring_version` CAS、事务内追加式审计、独立 RBAC 和学生端路由可见性约束;不授予任何角色。 - V4080/V4090 在平台 `system_menu` 已存在时条件写入 Education 权限种子;固定 ID 已被不同 permission 占用时迁移失败,且迁移不会向角色写入授权关系。 - 旧 `education_answer_idempotency`、`education_submit_idempotency` 在首次接管时保留,后续清理必须使用更高版本的独立向前 migration。 - `sql/postgresql/education/` 是手工初始化/设计历史,`sql/mysql/education/` 是过时归档;两者都不是运行时交付入口。 diff --git a/yudao-module-education/src/main/resources/db/migration/education/V4120__add_category_and_practice_blueprint_authoring.sql b/yudao-module-education/src/main/resources/db/migration/education/V4120__add_category_and_practice_blueprint_authoring.sql index c36d4242..b4bc6d0b 100644 --- a/yudao-module-education/src/main/resources/db/migration/education/V4120__add_category_and_practice_blueprint_authoring.sql +++ b/yudao-module-education/src/main/resources/db/migration/education/V4120__add_category_and_practice_blueprint_authoring.sql @@ -31,12 +31,12 @@ ALTER TABLE education_practice_blueprint question_limit IS NULL OR question_limit BETWEEN min_questions AND max_questions), ADD CONSTRAINT ck_education_practice_blueprint_duration CHECK (duration_minutes IS NULL OR duration_minutes > 0); -CREATE UNIQUE INDEX uk_education_practice_blueprint_tenant_active_node +CREATE UNIQUE INDEX uk_education_practice_blueprint_tenant_live_node ON education_practice_blueprint(tenant_id,node_id) - WHERE deleted=false AND scope='TENANT_OWNED' AND mode='NODE' AND publication_status='ACTIVE'; -CREATE UNIQUE INDEX uk_education_practice_blueprint_tenant_active_collection + WHERE deleted=false AND scope='TENANT_OWNED' AND mode='NODE' AND publication_status IN ('DRAFT','ACTIVE'); +CREATE UNIQUE INDEX uk_education_practice_blueprint_tenant_live_collection ON education_practice_blueprint(tenant_id,collection_id) - WHERE deleted=false AND scope='TENANT_OWNED' AND mode='COLLECTION' AND publication_status='ACTIVE'; + WHERE deleted=false AND scope='TENANT_OWNED' AND mode='COLLECTION' AND publication_status IN ('DRAFT','ACTIVE'); CREATE TABLE education_category_lifecycle_audit ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, @@ -85,7 +85,7 @@ BEGIN IF OLD.scope='TENANT_OWNED' AND NEW.deleted IS DISTINCT FROM OLD.deleted THEN RAISE EXCEPTION 'tenant category cannot be logically deleted' USING ERRCODE='23514'; END IF; IF OLD.publication_status='ARCHIVED' AND NEW IS DISTINCT FROM OLD THEN RAISE EXCEPTION 'archived category is immutable' USING ERRCODE='23514'; END IF; IF NEW.authoring_version<>OLD.authoring_version+1 THEN RAISE EXCEPTION 'category version must advance once' USING ERRCODE='23514'; END IF; - IF OLD.publication_status<>'DRAFT' AND (NEW.subject_id IS DISTINCT FROM OLD.subject_id OR NEW.name IS DISTINCT FROM OLD.name OR NEW.sort_order IS DISTINCT FROM OLD.sort_order) THEN RAISE EXCEPTION 'active category is immutable' USING ERRCODE='23514'; END IF; + IF OLD.publication_status<>'DRAFT' AND (NEW.subject_id IS DISTINCT FROM OLD.subject_id OR NEW.legacy_node_id IS DISTINCT FROM OLD.legacy_node_id OR NEW.name IS DISTINCT FROM OLD.name OR NEW.sort_order IS DISTINCT FROM OLD.sort_order) THEN RAISE EXCEPTION 'active category is immutable' USING ERRCODE='23514'; END IF; IF NEW.publication_status IS DISTINCT FROM OLD.publication_status THEN IF NOT ((OLD.publication_status='DRAFT' AND NEW.publication_status='ACTIVE') OR (OLD.publication_status='ACTIVE' AND NEW.publication_status='ARCHIVED')) THEN RAISE EXCEPTION 'invalid category lifecycle' USING ERRCODE='23514'; END IF; EXECUTE format('INSERT INTO %I.education_category_lifecycle_transition_token VALUES($1,$2,$3,$4,$5,$6)',TG_TABLE_SCHEMA) USING pg_current_xact_id()::text::BIGINT,NEW.tenant_id,NEW.id,NEW.authoring_version,OLD.publication_status,NEW.publication_status; diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/blueprint/authoring/PracticeBlueprintAuthoringPostgreSqlIntegrationTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/blueprint/authoring/PracticeBlueprintAuthoringPostgreSqlIntegrationTest.java index 57c2fc4f..48b43976 100644 --- a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/blueprint/authoring/PracticeBlueprintAuthoringPostgreSqlIntegrationTest.java +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/blueprint/authoring/PracticeBlueprintAuthoringPostgreSqlIntegrationTest.java @@ -68,9 +68,10 @@ class PracticeBlueprintAuthoringPostgreSqlIntegrationTest extends PostgreSqlDbIn @Test void activeBlueprintContentAndAuditAreDatabaseImmutable() throws Exception { Long id = service.createDraft(command(null)); service.activate(id, 0, 7L); - assertThrows(Exception.class, () -> blueprintMapper.updateDraftCas(10L, id, - blueprintMapper.selectTenantOwnedById(10L, id), 1)); - PracticeBlueprintLifecycleAuditDO audit = auditMapper.selectOne(); + PracticeBlueprintDO active = blueprintMapper.selectTenantOwnedById(10L, id); + active.setSuggestedCount(1); + assertEquals(0, blueprintMapper.updateDraftCas(10L, id, active, 1)); + PracticeBlueprintLifecycleAuditDO audit = auditMapper.selectOne(PracticeBlueprintLifecycleAuditDO::getBlueprintId, id); audit.setActorId(8L); assertThrows(Exception.class, () -> auditMapper.updateById(audit)); } diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/category/authoring/CategoryAuthoringPostgreSqlIntegrationTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/category/authoring/CategoryAuthoringPostgreSqlIntegrationTest.java new file mode 100644 index 00000000..2a442b48 --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/category/authoring/CategoryAuthoringPostgreSqlIntegrationTest.java @@ -0,0 +1,67 @@ +package cn.iocoder.yudao.module.education.service.category.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.*; +import cn.iocoder.yudao.module.education.dal.mysql.catalog.*; +import cn.iocoder.yudao.module.education.enums.CatalogProviderMode; +import cn.iocoder.yudao.module.education.test.PostgreSqlDbIntegrationTest; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.*; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.TestPropertySource; +import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; + +@Import({CategoryAuthoringServiceImpl.class, EducationProperties.class, + cn.iocoder.yudao.module.education.service.catalog.provider.JavaCatalogProvider.class}) +@TestPropertySource(properties = {"yudao.education.enabled=true", "yudao.education.catalog-mode=JAVA_READ"}) +class CategoryAuthoringPostgreSqlIntegrationTest extends PostgreSqlDbIntegrationTest { + @Resource EducationProperties properties; + @Resource CategoryAuthoringService service; + @Resource CategoryMapper categoryMapper; + @Resource CategoryLifecycleAuditMapper auditMapper; + @Resource SubjectMapper subjectMapper; + @Resource cn.iocoder.yudao.module.education.service.catalog.provider.JavaCatalogProvider catalogProvider; + + @BeforeEach void setUp() { + properties.setCatalogMode(CatalogProviderMode.JAVA_READ); TenantContextHolder.setTenantId(10L); + SubjectDO subject = new SubjectDO(); subject.setId(100L); subject.setTenantId(10L); subject.setScope("TENANT_OWNED"); + subject.setName("Subject"); subject.setIsActive(true); subjectMapper.insert(subject); + } + @AfterEach void clear() { TenantContextHolder.clear(); } + + @Test void draftActiveArchiveControlsStudentVisibilityAndAudit() { + Long id = service.createDraft(command(null)); + assertTrue(catalogProvider.listCategories("100", null).isEmpty()); + assertEquals(1, service.activate(id, 0, 7L)); + assertEquals(1, catalogProvider.listCategories("100", null).size()); + assertEquals(2, service.archive(id, 1, 7L)); + assertTrue(catalogProvider.listCategories("100", null).isEmpty()); + assertEquals(2L, auditMapper.selectCount()); + } + + @Test void unsupportedCrossTenantAndStaleFailClosed() { + properties.setCatalogMode(CatalogProviderMode.SCALAR_READ); + ServiceException unsupported = assertThrows(ServiceException.class, () -> service.createDraft(command(null))); + assertEquals(CATEGORY_AUTHORING_PROVIDER_UNSUPPORTED.getCode(), unsupported.getCode()); + properties.setCatalogMode(CatalogProviderMode.JAVA_READ); Long id = service.createDraft(command(null)); + TenantContextHolder.setTenantId(20L); + ServiceException notFound = assertThrows(ServiceException.class, () -> service.activate(id, 0, 8L)); + assertEquals(CATEGORY_AUTHORING_NOT_FOUND.getCode(), notFound.getCode()); + TenantContextHolder.setTenantId(10L); service.activate(id, 0, 7L); + ServiceException stale = assertThrows(ServiceException.class, () -> service.archive(id, 0, 7L)); + assertEquals(CATEGORY_AUTHORING_CONFLICT.getCode(), stale.getCode()); + } + + @Test void lifecycleAuditIsAppendOnly() { + Long id = service.createDraft(command(null)); service.activate(id, 0, 7L); + CategoryLifecycleAuditDO audit = auditMapper.selectOne(CategoryLifecycleAuditDO::getCategoryId, id); + audit.setActorId(8L); assertThrows(Exception.class, () -> auditMapper.updateById(audit)); + } + + private CategoryAuthoringCommand command(Integer version) { + return new CategoryAuthoringCommand(100L, null, "Category", 0, version); + } +} 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 0402265a..160a5890 100644 --- a/yudao-module-education/src/test/resources/sql/postgresql/clean.sql +++ b/yudao-module-education/src/test/resources/sql/postgresql/clean.sql @@ -8,6 +8,10 @@ TRUNCATE TABLE education_entitlement_event, education_entitlement, education_resource_product_binding, + education_practice_blueprint_lifecycle_audit, + education_category_lifecycle_audit, + education_practice_blueprint, + education_category, education_question_collection_lifecycle_audit, education_content_node_lifecycle_audit, education_question_lifecycle_transition_token, @@ -25,5 +29,6 @@ TRUNCATE TABLE education_practice_question, education_practice_session, education_content_node, - education_content_entry + education_content_entry, + education_subject RESTART IDENTITY CASCADE;