feat(education): expose migration theme status

This commit is contained in:
2026-07-31 12:22:56 +08:00
parent bc2d555eb3
commit f65a39b902
8 changed files with 146 additions and 11 deletions

View File

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

View File

@@ -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

View File

@@ -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())

View File

@@ -27,6 +27,9 @@ public class EducationCapabilityRespVO {
@Schema(description = "支持的能力列表")
private List<String> capabilities;
@Schema(description = "后续迁移主题及其真实交付状态")
private List<EducationThemeRespVO> themes;
@Schema(description = "题库读取是否开放", example = "true")
private boolean catalogReadEnabled;

View File

@@ -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<String> owningModules;
@Schema(description = "当前已验证可执行接口")
private List<String> executableInterfaces;
@Schema(description = "阻塞项")
private List<String> blockers;
@Schema(description = "仍在使用的遗留依赖")
private List<String> legacyDependencies;
@Schema(description = "回滚类别", example = "FEATURE_FLAG")
private String rollbackCategory;
@Schema(description = "最高证据等级", example = "CONTRACT_ONLY")
private String evidenceLevel;
}

View File

@@ -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<EducationThemeRespVO> describeThemes() {
List<String> 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<String> owningModules,
List<String> executableInterfaces, List<String> blockers, List<String> 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();
}
}

View File

@@ -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<QuestionDO> 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<QuestionDO> 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<QuestionDO> page = readWithExplicitCatalogScope(() -> questionMapper.selectPublishedPageByAvailableCollection(
new Page<>(1, 1), tenantId(), collectionId, type, difficulty));

View File

@@ -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")),
"主题清单不得暴露连接地址或凭据");
}
}