diff --git a/docs/education/migration/issues/EDU-010-content-publication.md b/docs/education/migration/issues/EDU-010-content-publication.md index 694abc18..beba59fe 100644 --- a/docs/education/migration/issues/EDU-010-content-publication.md +++ b/docs/education/migration/issues/EDU-010-content-publication.md @@ -30,9 +30,9 @@ Authorized tenant administrators can author, classify, publish, archive, and ret - [x] Database graph-integrity changes use `flyway-postgresql` and forward migrations V4070/V4090. - [x] The next Manual Question Collection bounded contract, exclusions, pre-provisioning constraint, and readiness gates are explicit before implementation. - [x] A current-tenant `TENANT_OWNED` Manual Question Collection can be authored only in `JAVA_READ` on an existing ACTIVE, visible Content Node. -- [ ] Collection lifecycle is `DRAFT → ACTIVE → ARCHIVED` with one optimistic authoring version; ACTIVE content/membership is immutable and archive is terminal. -- [ ] DRAFT membership is ordered replace-all, accepts only current-tenant `TENANT_OWNED` PUBLISHED Questions, and rejects duplicates. -- [ ] Collection archive gates only collection-route discovery while direct Question visibility and historical Practice snapshots remain unchanged. +- [x] Collection lifecycle is `DRAFT → ACTIVE → ARCHIVED` with one optimistic authoring version; ACTIVE content/membership is immutable and archive is terminal. +- [x] DRAFT membership is ordered replace-all, accepts only current-tenant `TENANT_OWNED` PUBLISHED Questions, and rejects duplicates. +- [x] Collection archive gates only collection-route discovery while direct Question visibility and historical Practice snapshots remain unchanged. ## Delivery progress — graph-integrity slice (2026-07-30) diff --git a/docs/education/migration/issues/README.md b/docs/education/migration/issues/README.md index 509d4ba6..dc17bc4e 100644 --- a/docs/education/migration/issues/README.md +++ b/docs/education/migration/issues/README.md @@ -36,18 +36,18 @@ 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 (graph integrity and Content Node lifecycle delivered) - └── EDU-011 Import/export/assets/scanning blocked + └── EDU-010 Tenant content publication in progress (Manual Collection delivered; category/blueprint remain) + └── EDU-011 Import/export/assets/scanning blocked; contract disposition exposed by capability manifest EDU-004 - └── EDU-012 Classes and education relationships blocked + └── EDU-012 Classes and education relationships blocked; contract disposition exposed by capability manifest Commerce ownership decisions - └── EDU-013 Education commercialization blocked + └── EDU-013 Education commercialization product decision required; fail-closed disposition exposed All owner/contract decisions - └── EDU-014 Extended learning waves blocked - └── EDU-015 Operational independence blocked + └── EDU-014 Extended learning waves product decision required; family decomposition remains + └── EDU-015 Operational independence legacy-dependent under SCALAR_READ; target-only proof remains ``` ## Recommended execution order diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/EducationCapabilityController.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/EducationCapabilityController.java index e5f9add4..57be6bbf 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/EducationCapabilityController.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/EducationCapabilityController.java @@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.education.controller.admin; import cn.iocoder.yudao.framework.common.pojo.CommonResult; import cn.iocoder.yudao.module.education.config.EducationProperties; import cn.iocoder.yudao.module.education.controller.admin.vo.EducationCapabilityRespVO; +import cn.iocoder.yudao.module.education.service.capability.EducationThemeCatalog; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; @@ -27,6 +28,9 @@ public class EducationCapabilityController { @Resource private EducationProperties educationProperties; + @Resource + private EducationThemeCatalog educationThemeCatalog; + @GetMapping("/capability") @Operation(summary = "获得教育模块能力信息") @PreAuthorize("@ss.hasPermission('education:capability')") @@ -37,6 +41,7 @@ public class EducationCapabilityController { .version(educationProperties.getVersion()) .capabilities(List.of("shell", "catalog", "questions", "practice-preview", "answer-save", "session-submit", "practice-report")) + .themes(educationThemeCatalog.describeThemes()) .catalogReadEnabled(educationProperties.isCatalogReadEnabled()) .practiceWriteEnabled(educationProperties.isPracticeWriteEnabled()) .pilotTenantCount(educationProperties.getPilotTenantIds().size()) diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/vo/EducationCapabilityRespVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/vo/EducationCapabilityRespVO.java index 2ba11d2c..5e7012d0 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/vo/EducationCapabilityRespVO.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/vo/EducationCapabilityRespVO.java @@ -27,6 +27,9 @@ public class EducationCapabilityRespVO { @Schema(description = "支持的能力列表") private List capabilities; + @Schema(description = "后续迁移主题及其真实交付状态") + private List themes; + @Schema(description = "题库读取是否开放", example = "true") private boolean catalogReadEnabled; diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/vo/EducationThemeRespVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/vo/EducationThemeRespVO.java new file mode 100644 index 00000000..6d4163ee --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/admin/vo/EducationThemeRespVO.java @@ -0,0 +1,45 @@ +package cn.iocoder.yudao.module.education.controller.admin.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Schema(description = "管理后台 - 教育迁移主题信息 Response VO") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class EducationThemeRespVO { + + @Schema(description = "迁移 Ticket", example = "EDU-011") + private String key; + + @Schema(description = "主题名称", example = "content-import-export-assets") + private String name; + + @Schema(description = "当前状态", example = "BLOCKED") + private String status; + + @Schema(description = "目标归属模块") + private List owningModules; + + @Schema(description = "当前已验证可执行接口") + private List executableInterfaces; + + @Schema(description = "阻塞项") + private List blockers; + + @Schema(description = "仍在使用的遗留依赖") + private List legacyDependencies; + + @Schema(description = "回滚类别", example = "FEATURE_FLAG") + private String rollbackCategory; + + @Schema(description = "最高证据等级", example = "CONTRACT_ONLY") + private String evidenceLevel; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/capability/EducationThemeCatalog.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/capability/EducationThemeCatalog.java new file mode 100644 index 00000000..f8490c99 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/capability/EducationThemeCatalog.java @@ -0,0 +1,56 @@ +package cn.iocoder.yudao.module.education.service.capability; + +import cn.iocoder.yudao.module.education.config.EducationProperties; +import cn.iocoder.yudao.module.education.controller.admin.vo.EducationThemeRespVO; +import cn.iocoder.yudao.module.education.enums.CatalogProviderMode; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class EducationThemeCatalog { + + private final EducationProperties properties; + + public EducationThemeCatalog(EducationProperties properties) { + this.properties = properties; + } + + public List describeThemes() { + List catalogDependencies = properties.getCatalogMode() == CatalogProviderMode.SCALAR_READ + ? List.of("scalar-catalog") : List.of(); + return List.of( + theme("EDU-011", "content-import-export-assets", "BLOCKED", + List.of("education", "infra"), List.of(), + List.of("asset-admission-contract", "scanner-ownership", "durable-job-lease"), + List.of(), "FEATURE_FLAG", "CONTRACT_ONLY"), + theme("EDU-012", "classes-relationships", "BLOCKED", + List.of("education", "member", "system"), List.of(), + List.of("relationship-role-model", "class-data-scope", "invitation-contract"), + List.of(), "FEATURE_FLAG", "CONTRACT_ONLY"), + theme("EDU-013", "commercialization", "DECISION_REQUIRED", + List.of("education", "mall", "pay", "member", "crm"), List.of(), + List.of("entitlement-owner", "grant-revoke-refund-contract"), + List.of(), "ACCESS_FAIL_CLOSED", "CONTRACT_ONLY"), + theme("EDU-014", "extended-learning", "DECISION_REQUIRED", + List.of("education", "member", "system", "infra", "ai"), List.of(), + List.of("capability-family-disposition", "entitlement-and-export-policy"), + List.of(), "FEATURE_FLAG", "CONTRACT_ONLY"), + theme("EDU-015", "operational-independence", + catalogDependencies.isEmpty() ? "PARTIAL" : "LEGACY_DEPENDENT", + List.of("education", "infra"), List.of(), + List.of("target-only-runtime-evidence", "worker-scanner-exit-contract"), + catalogDependencies, "PROVIDER_SWITCH", "INTEGRATION_TESTED")); + } + + private EducationThemeRespVO theme(String key, String name, String status, List owningModules, + List executableInterfaces, List blockers, List legacyDependencies, + String rollbackCategory, String evidenceLevel) { + return EducationThemeRespVO.builder() + .key(key).name(name).status(status).owningModules(owningModules) + .executableInterfaces(executableInterfaces).blockers(blockers) + .legacyDependencies(legacyDependencies).rollbackCategory(rollbackCategory) + .evidenceLevel(evidenceLevel).build(); + } + +} 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 4f06195c..d8e439db 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 @@ -154,6 +154,7 @@ public class JavaCatalogProvider implements CatalogProvider, QuestionCatalogProv int pageNo, int pageSize) { Long cid = parseOptionalLong(collectionId); Long nid = parseOptionalLong(nodeId); int bounded = clampPageSize(pageSize); int page = pageNo > 0 ? pageNo : 1; + if (cid != null && !isCollectionAvailable(cid)) throw exception(QUESTION_NOT_FOUND); IPage result = cid != null ? readWithExplicitCatalogScope(() -> questionMapper.selectPublishedPageByAvailableCollection( new Page<>(page, bounded), tenantId(), cid, type, difficulty)) @@ -168,6 +169,7 @@ public class JavaCatalogProvider implements CatalogProvider, QuestionCatalogProv @Override public CatalogQuestionPageResult listCollectionQuestions(String collectionId, String type, String difficulty, int pageNo, int pageSize) { Long cid = parseRequiredLong(collectionId); int page = pageNo > 0 ? pageNo : 1; + if (!isCollectionAvailable(cid)) throw exception(QUESTION_NOT_FOUND); IPage result = readWithExplicitCatalogScope(() -> questionMapper.selectPublishedPageByAvailableCollection( new Page<>(page, clampPageSize(pageSize)), tenantId(), cid, type, difficulty)); return buildPageResult(result); @@ -183,7 +185,7 @@ public class JavaCatalogProvider implements CatalogProvider, QuestionCatalogProv public CatalogPracticeBlueprintDTO getPracticeBlueprint(String collectionId, String nodeId, String type, String difficulty) { Long cid = parseOptionalLong(collectionId); Long nid = parseOptionalLong(nodeId); if (cid == null && nid == null) throw exception(CATALOG_INVALID_IDENTIFIER, "parent"); - if (cid != null && readWithExplicitCatalogScope(() -> questionCollectionMapper.selectAvailableForStudent(tenantId(), cid)) == null) { + if (cid != null && !isCollectionAvailable(cid)) { return null; } PracticeBlueprintDO bp = readWithExplicitCatalogScope(() -> practiceBlueprintMapper.selectByCollectionOrNode( @@ -228,6 +230,11 @@ public class JavaCatalogProvider implements CatalogProvider, QuestionCatalogProv .build(); } + private boolean isCollectionAvailable(Long collectionId) { + return readWithExplicitCatalogScope(() -> questionCollectionMapper.selectAvailableForStudent( + tenantId(), collectionId)) != null; + } + private long countVisibleCollectionQuestions(Long collectionId, String type, String difficulty) { IPage page = readWithExplicitCatalogScope(() -> questionMapper.selectPublishedPageByAvailableCollection( new Page<>(1, 1), tenantId(), collectionId, type, difficulty)); diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/EducationCapabilityControllerTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/EducationCapabilityControllerTest.java index 78f07306..3ec6a5ca 100644 --- a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/EducationCapabilityControllerTest.java +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/admin/EducationCapabilityControllerTest.java @@ -3,6 +3,8 @@ package cn.iocoder.yudao.module.education.controller.admin; import cn.iocoder.yudao.framework.common.pojo.CommonResult; import cn.iocoder.yudao.module.education.config.EducationProperties; import cn.iocoder.yudao.module.education.controller.admin.vo.EducationCapabilityRespVO; +import cn.iocoder.yudao.module.education.controller.admin.vo.EducationThemeRespVO; +import cn.iocoder.yudao.module.education.service.capability.EducationThemeCatalog; import jakarta.annotation.Resource; import org.junit.jupiter.api.Test; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -11,6 +13,8 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; +import java.util.List; + import static org.junit.jupiter.api.Assertions.*; /** @@ -29,7 +33,7 @@ public class EducationCapabilityControllerTest { @Configuration @EnableConfigurationProperties(EducationProperties.class) - @Import(EducationCapabilityController.class) + @Import({EducationCapabilityController.class, EducationThemeCatalog.class}) static class Config { } @@ -49,6 +53,21 @@ public class EducationCapabilityControllerTest { assertEquals("1.0.0-test", data.getVersion(), "版本应匹配"); assertNotNull(data.getCapabilities(), "能力列表不应为空"); assertTrue(data.getCapabilities().contains("shell"), "应包含 shell 能力"); + assertNotNull(data.getThemes(), "迁移主题不应为空"); + assertEquals(5, data.getThemes().size(), "应完整列出 EDU-011 至 EDU-015"); + assertEquals(5, data.getThemes().stream().map(EducationThemeRespVO::getKey).distinct().count(), + "迁移主题不应重复"); + assertTrue(data.getThemes().stream().noneMatch(theme -> "AVAILABLE".equals(theme.getStatus())), + "未交付主题不得声明为可用"); + EducationThemeRespVO operations = data.getThemes().stream() + .filter(theme -> "EDU-015".equals(theme.getKey())).findFirst().orElseThrow(); + assertEquals("LEGACY_DEPENDENT", operations.getStatus(), "默认 Scalar 模式必须披露遗留依赖"); + assertEquals(List.of("scalar-catalog"), operations.getLegacyDependencies()); + assertTrue(data.getThemes().stream().filter(theme -> "BLOCKED".equals(theme.getStatus())) + .allMatch(theme -> !theme.getBlockers().isEmpty()), "阻塞主题必须披露阻塞键"); + assertTrue(data.getThemes().stream().flatMap(theme -> theme.getLegacyDependencies().stream()) + .noneMatch(value -> value.contains("://") || value.contains("token") || value.contains("password")), + "主题清单不得暴露连接地址或凭据"); } }