test(education): verify category blueprint publication

This commit is contained in:
2026-07-31 12:56:15 +08:00
parent b8b06e0292
commit be2c22e440
9 changed files with 121 additions and 42 deletions

View File

@@ -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. 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. 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:** **Permission and data-scope matrix:**
| Actor capability | Permission | Allowed rows | Data scope | | 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 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 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 | | 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 | | 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. 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.

View File

@@ -1,6 +1,6 @@
# EDU-010 — Tenant content publication and graph integrity # 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 - **Type:** implementation program
- **Phase:** 3 - **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) - **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. 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. - `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 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.

View File

@@ -36,7 +36,7 @@ EDU-000 Phase 0 artifacts done
└── EDU-005 PostgreSQL/Flyway takeover decision done └── EDU-005 PostgreSQL/Flyway takeover decision done
EDU-009 + provider/content decisions 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-011 Import/export/assets/scanning blocked; contract disposition exposed by capability manifest
EDU-004 EDU-004

View File

@@ -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. 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 _Avoid_: Entitlement policy, authorization rule
**Vocabulary Review State**: **Category Publication 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. 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_: Shared dictionary ownership, client-selected mastery _Avoid_: Question Placement, question category relation
**Exam Reminder Plan**: **Practice Blueprint**:
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. 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_: Direct System table write, best-effort timer _Avoid_: Dynamic query, paid practice policy
**Learning Award Orchestration**: **Practice Blueprint Publication State**:
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. 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_: Education point ledger, arbitrary client reward _Avoid_: Boolean availability as an independent state
**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
**Protected Answer Key**: **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. 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.

View File

@@ -17,7 +17,6 @@
- 答案保存端点 (见下方 Answer API) — 幂等保存,安全重试 - 答案保存端点 (见下方 Answer API) — 幂等保存,安全重试
- 题目安全过滤(答案/解析绝不暴露到前端) - 题目安全过滤(答案/解析绝不暴露到前端)
- 管理端题目创作、目录放置、发布和归档端点 — 仅在 `JAVA_READ` 下接受写入 - 管理端题目创作、目录放置、发布和归档端点 — 仅在 `JAVA_READ` 下接受写入
- 租户班级、现有 Member 学生/教师关系,以及具备过期与审计语义的幂等邀请创建/接受
- 独立的功能开关配置 + Scalar 数据源配置 - 独立的功能开关配置 + Scalar 数据源配置
- 错误码常量(通用 + 租户 + Catalog/Scalar + 题目/练习) - 错误码常量(通用 + 租户 + Catalog/Scalar + 题目/练习)
- System RBAC 权限注解及 V4080/V4090 条件种子(`education:capability``education:question:author``education:question:classify``education:question:publish``education:question:archive`);迁移不自动向任何角色授权 - 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_075` | 归类版本或题目状态已变化 |
| `1_005_003_076` | 题目尚未归类,不能发布 | | `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 - 教育租户识别 ### 用户 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 预检。 - `V4080` 增加第 20 条目录引用边 `education_question_version.question_id → education_question.id`,并为版本表增加第 12 个 ownership/scope 守卫;版本归属必须与题目完全一致。该迁移还建立 draft-first 生命周期、不可变题目版本、同事务追加式审计及历史已发布内容的 fail-closed 预检。
- `V4090` 增加 Question Placement 乐观版本、PUBLIC 管理拒绝、发布后放置冻结、发布前可用节点约束,以及 `education:question:classify` 权限种子。 - `V4090` 增加 Question Placement 乐观版本、PUBLIC 管理拒绝、发布后放置冻结、发布前可用节点约束,以及 `education:question:classify` 权限种子。
- `V4100` 增加租户 Content Node 的 `DRAFT → ACTIVE → ARCHIVED` 生命周期、统一 `authoring_version` CAS、同事务追加式审计和 entry/parent 结构约束;不写入权限或角色种子。 - `V4100` 增加租户 Content Node 的 `DRAFT → ACTIVE → ARCHIVED` 生命周期、统一 `authoring_version` CAS、同事务追加式审计和 entry/parent 结构约束;不写入权限或角色种子。
- `V4110` 增加租户手工题集的生命周期、完整有序成员替换和学生发现路径约束 - `V4120` 增加租户 Category 与 bounded Practice Blueprint 的 `DRAFT → ACTIVE → ARCHIVED` 生命周期、统一 `authoring_version` CAS、事务内追加式审计、独立 RBAC 和学生端路由可见性约束;不授予任何角色
- `V4160` 仅用于 EDU-014 有界学习波次:新增词汇复习状态、考试提醒计划、学习奖励编排和学生反馈表,并在 System 通知模板表存在时条件安装 `education_exam_reminder` 模板。视频与 AI 不获得任何访问或权益能力。
- V4080/V4090 在平台 `system_menu` 已存在时条件写入 Education 权限种子;固定 ID 已被不同 permission 占用时迁移失败,且迁移不会向角色写入授权关系。 - V4080/V4090 在平台 `system_menu` 已存在时条件写入 Education 权限种子;固定 ID 已被不同 permission 占用时迁移失败,且迁移不会向角色写入授权关系。
-`education_answer_idempotency``education_submit_idempotency` 在首次接管时保留,后续清理必须使用更高版本的独立向前 migration。 -`education_answer_idempotency``education_submit_idempotency` 在首次接管时保留,后续清理必须使用更高版本的独立向前 migration。
- `sql/postgresql/education/` 是手工初始化/设计历史,`sql/mysql/education/` 是过时归档;两者都不是运行时交付入口。 - `sql/postgresql/education/` 是手工初始化/设计历史,`sql/mysql/education/` 是过时归档;两者都不是运行时交付入口。

View File

@@ -31,12 +31,12 @@ ALTER TABLE education_practice_blueprint
question_limit IS NULL OR question_limit BETWEEN min_questions AND max_questions), 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); 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) ON education_practice_blueprint(tenant_id,node_id)
WHERE deleted=false AND scope='TENANT_OWNED' AND mode='NODE' AND publication_status='ACTIVE'; WHERE deleted=false AND scope='TENANT_OWNED' AND mode='NODE' AND publication_status IN ('DRAFT','ACTIVE');
CREATE UNIQUE INDEX uk_education_practice_blueprint_tenant_active_collection CREATE UNIQUE INDEX uk_education_practice_blueprint_tenant_live_collection
ON education_practice_blueprint(tenant_id,collection_id) 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 ( CREATE TABLE education_category_lifecycle_audit (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, 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.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 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 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 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; 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; 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;

View File

@@ -68,9 +68,10 @@ class PracticeBlueprintAuthoringPostgreSqlIntegrationTest extends PostgreSqlDbIn
@Test void activeBlueprintContentAndAuditAreDatabaseImmutable() throws Exception { @Test void activeBlueprintContentAndAuditAreDatabaseImmutable() throws Exception {
Long id = service.createDraft(command(null)); service.activate(id, 0, 7L); Long id = service.createDraft(command(null)); service.activate(id, 0, 7L);
assertThrows(Exception.class, () -> blueprintMapper.updateDraftCas(10L, id, PracticeBlueprintDO active = blueprintMapper.selectTenantOwnedById(10L, id);
blueprintMapper.selectTenantOwnedById(10L, id), 1)); active.setSuggestedCount(1);
PracticeBlueprintLifecycleAuditDO audit = auditMapper.selectOne(); assertEquals(0, blueprintMapper.updateDraftCas(10L, id, active, 1));
PracticeBlueprintLifecycleAuditDO audit = auditMapper.selectOne(PracticeBlueprintLifecycleAuditDO::getBlueprintId, id);
audit.setActorId(8L); assertThrows(Exception.class, () -> auditMapper.updateById(audit)); audit.setActorId(8L); assertThrows(Exception.class, () -> auditMapper.updateById(audit));
} }

View File

@@ -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);
}
}

View File

@@ -8,6 +8,10 @@ TRUNCATE TABLE
education_entitlement_event, education_entitlement_event,
education_entitlement, education_entitlement,
education_resource_product_binding, education_resource_product_binding,
education_practice_blueprint_lifecycle_audit,
education_category_lifecycle_audit,
education_practice_blueprint,
education_category,
education_question_collection_lifecycle_audit, education_question_collection_lifecycle_audit,
education_content_node_lifecycle_audit, education_content_node_lifecycle_audit,
education_question_lifecycle_transition_token, education_question_lifecycle_transition_token,
@@ -25,5 +29,6 @@ TRUNCATE TABLE
education_practice_question, education_practice_question,
education_practice_session, education_practice_session,
education_content_node, education_content_node,
education_content_entry education_content_entry,
education_subject
RESTART IDENTITY CASCADE; RESTART IDENTITY CASCADE;