feat(education): expose migration theme status
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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")),
|
||||
"主题清单不得暴露连接地址或凭据");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user