diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java index 00e0ca04..7e0cf1ed 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java @@ -1,5 +1,6 @@ package vip.mate.wiki.controller; +import com.fasterxml.jackson.databind.ObjectMapper; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @@ -16,7 +17,9 @@ import vip.mate.workspace.core.annotation.RequireWorkspaceRole; import vip.mate.wiki.WikiProperties; import vip.mate.wiki.model.WikiKnowledgeBaseEntity; import vip.mate.wiki.model.WikiPageEntity; +import vip.mate.wiki.model.WikiPageTypeProfileEntity; import vip.mate.wiki.model.WikiRawMaterialEntity; +import vip.mate.wiki.profile.WikiPageTypeProfileService; import vip.mate.wiki.service.WikiDirectoryScanService; import vip.mate.wiki.service.WikiKnowledgeBaseService; import vip.mate.wiki.service.WikiLintJobService; @@ -55,6 +58,8 @@ public class WikiController { private final WikiProperties properties; private final WikiProgressBus progressBus; private final AuditEventService auditEventService; + private final WikiPageTypeProfileService pageTypeProfileService; + private final ObjectMapper objectMapper; // ==================== Knowledge Base ==================== @@ -189,6 +194,77 @@ public class WikiController { return R.ok(); } + // ==================== PageType Profile ==================== + + @RequireWorkspaceRole("viewer") + @Operation(summary = "获取知识库 pageType profile(未配置则返回内置默认)") + @GetMapping("/knowledge-bases/{id}/page-type-profile") + public R> getPageTypeProfile(@PathVariable Long id, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyKBWorkspace(id, workspaceId); + WikiPageTypeProfileEntity row = pageTypeProfileService.findEnabledRow(id); + Map out = new LinkedHashMap<>(); + if (row != null) { + out.put("name", row.getName()); + out.put("version", row.getVersion()); + out.put("config", row.getConfigJson()); + out.put("builtinDefault", false); + } else { + String json; + try { + json = objectMapper.writeValueAsString(pageTypeProfileService.getDefaultProfile()); + } catch (Exception e) { + json = "{}"; + } + out.put("name", "default"); + out.put("version", 0); + out.put("config", json); + out.put("builtinDefault", true); + } + return R.ok(out); + } + + @RequireWorkspaceRole("admin") + @Operation(summary = "保存知识库 pageType profile") + @PutMapping("/knowledge-bases/{id}/page-type-profile") + public R savePageTypeProfile(@PathVariable Long id, @RequestBody Map body, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyKBWorkspace(id, workspaceId); + String config = body.get("config"); + if (config == null || config.isBlank()) { + return R.fail(400, "config is required"); + } + try { + pageTypeProfileService.saveProfile(id, body.get("name"), config); + } catch (IllegalArgumentException e) { + return R.fail(400, e.getMessage()); + } + return R.ok(); + } + + @RequireWorkspaceRole("member") + @Operation(summary = "校验 pageType profile JSON(不保存)") + @PostMapping("/knowledge-bases/{id}/page-type-profile/validate") + public R> validatePageTypeProfile(@PathVariable Long id, @RequestBody Map body, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyKBWorkspace(id, workspaceId); + List issues = pageTypeProfileService.validateProfileJson(body.getOrDefault("config", "")); + Map out = new LinkedHashMap<>(); + out.put("valid", issues.isEmpty()); + out.put("issues", issues); + return R.ok(out); + } + + @RequireWorkspaceRole("admin") + @Operation(summary = "重置 pageType profile 为内置默认") + @PostMapping("/knowledge-bases/{id}/page-type-profile/reset-default") + public R resetPageTypeProfile(@PathVariable Long id, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyKBWorkspace(id, workspaceId); + pageTypeProfileService.resetToDefault(id); + return R.ok(); + } + // ==================== Directory Scan ==================== @RequireWorkspaceRole("member") diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/profile/WikiPageTypeProfileService.java b/mateclaw-server/src/main/java/vip/mate/wiki/profile/WikiPageTypeProfileService.java index 8ce2e6cc..eac958e4 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/profile/WikiPageTypeProfileService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/profile/WikiPageTypeProfileService.java @@ -89,6 +89,98 @@ public class WikiPageTypeProfileService { return resolveProfile(kbId).getPageTypes().keySet(); } + /** The enabled profile row for a KB, or {@code null} when none configured. */ + public WikiPageTypeProfileEntity findEnabledRow(Long kbId) { + if (kbId == null) { + return null; + } + return profileMapper.selectOne( + new LambdaQueryWrapper() + .eq(WikiPageTypeProfileEntity::getKbId, kbId) + .eq(WikiPageTypeProfileEntity::getEnabled, 1) + .last("LIMIT 1")); + } + + /** + * Persist a KB profile. Parses {@code configJson} first (rejecting invalid + * JSON), then upserts the KB's single enabled row — updating in place and + * bumping its version when one exists, else inserting a new enabled row. + * + * @throws IllegalArgumentException when {@code configJson} does not parse + */ + public void saveProfile(Long kbId, String name, String configJson) { + try { + objectMapper.readValue(configJson, WikiPageTypeProfile.class); + } catch (Exception e) { + throw new IllegalArgumentException("Invalid profile config JSON: " + e.getMessage()); + } + WikiPageTypeProfileEntity existing = findEnabledRow(kbId); + if (existing != null) { + existing.setConfigJson(configJson); + if (name != null && !name.isBlank()) { + existing.setName(name); + } + existing.setVersion((existing.getVersion() == null ? 1 : existing.getVersion()) + 1); + profileMapper.updateById(existing); + } else { + WikiPageTypeProfileEntity row = new WikiPageTypeProfileEntity(); + row.setKbId(kbId); + row.setName(name == null || name.isBlank() ? "default" : name); + row.setVersion(1); + row.setConfigJson(configJson); + row.setEnabled(1); + profileMapper.insert(row); + } + } + + /** + * Reset a KB to the built-in default by removing its profile rows, so + * {@link #resolveProfile} falls back to the default. Logical delete. + */ + public void resetToDefault(Long kbId) { + if (kbId == null) { + return; + } + profileMapper.delete(new LambdaQueryWrapper() + .eq(WikiPageTypeProfileEntity::getKbId, kbId)); + } + + /** + * Structurally validate a profile JSON without persisting it. Returns a + * list of human-readable issues; empty means valid. + */ + public java.util.List validateProfileJson(String configJson) { + java.util.List issues = new java.util.ArrayList<>(); + WikiPageTypeProfile profile; + try { + profile = objectMapper.readValue(configJson, WikiPageTypeProfile.class); + } catch (Exception e) { + issues.add("Invalid JSON: " + e.getMessage()); + return issues; + } + if (profile.getPageTypes() == null || profile.getPageTypes().isEmpty()) { + issues.add("Profile declares no pageTypes"); + return issues; + } + java.util.Set validTypes = java.util.Set.of( + "string", "number", "boolean", "date", "enum", "string_array"); + profile.getPageTypes().forEach((typeName, def) -> { + if (def.getSchema() == null) { + return; + } + def.getSchema().forEach((fieldName, fieldSchema) -> { + String t = fieldSchema.getType(); + if (t == null || !validTypes.contains(t.trim().toLowerCase())) { + issues.add(typeName + "." + fieldName + ": unknown field type '" + t + "'"); + } else if ("enum".equalsIgnoreCase(t.trim()) + && (fieldSchema.getValues() == null || fieldSchema.getValues().isEmpty())) { + issues.add(typeName + "." + fieldName + ": enum field declares no values"); + } + }); + }); + return issues; + } + /** * Normalise a routed/created pageType against the KB profile: a declared * type is returned as-is (lowercase); an unknown type is downgraded to the diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/profile/WikiPageTypeProfileServiceE2ETest.java b/mateclaw-server/src/test/java/vip/mate/wiki/profile/WikiPageTypeProfileServiceE2ETest.java new file mode 100644 index 00000000..190b5a02 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/profile/WikiPageTypeProfileServiceE2ETest.java @@ -0,0 +1,104 @@ +package vip.mate.wiki.profile; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; +import vip.mate.wiki.model.WikiPageTypeProfileEntity; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Integration tests for {@link WikiPageTypeProfileService} CRUD against H2: + * upsert keeps a single enabled row (no generated-column violation), reset + * falls back to the default, and JSON validation reports structural issues. + */ +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.NONE, + properties = { + "spring.flyway.enabled=true", + "spring.flyway.locations=classpath:db/migration/h2", + "mateclaw.feature-flag.refresh-ms=999999" + } +) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class WikiPageTypeProfileServiceE2ETest { + + @Autowired + private WikiPageTypeProfileService service; + + private static final String EPISODE_JSON = + "{\"version\":1,\"pageTypes\":{\"episode\":{\"label\":\"Episode\"}}}"; + + @Test + void saveThenResolve_roundTrips() { + long kb = 5001L; + service.saveProfile(kb, "liquidity", EPISODE_JSON); + + WikiPageTypeProfileEntity row = service.findEnabledRow(kb); + assertNotNull(row); + assertEquals("liquidity", row.getName()); + assertEquals(1, row.getVersion()); + assertTrue(service.resolveProfile(kb).hasPageType("episode")); + } + + @Test + void saveTwice_upsertsInPlaceAndBumpsVersion() { + long kb = 5002L; + service.saveProfile(kb, "v1", EPISODE_JSON); + // Saving again must update the single enabled row, not insert a second + // (which would violate the one-enabled-per-KB generated-column UNIQUE). + service.saveProfile(kb, "v1", + "{\"version\":1,\"pageTypes\":{\"episode\":{\"label\":\"E\"},\"pattern\":{\"label\":\"P\"}}}"); + + WikiPageTypeProfileEntity row = service.findEnabledRow(kb); + assertEquals(2, row.getVersion()); + assertTrue(service.resolveProfile(kb).hasPageType("pattern")); + } + + @Test + void resetToDefault_removesRowAndFallsBack() { + long kb = 5003L; + service.saveProfile(kb, "custom", EPISODE_JSON); + assertNotNull(service.findEnabledRow(kb)); + + service.resetToDefault(kb); + + assertNull(service.findEnabledRow(kb)); + // Resolution now returns the built-in default. + assertTrue(service.resolveProfile(kb).hasPageType("concept")); + assertFalse(service.resolveProfile(kb).hasPageType("episode")); + } + + @Test + void invalidConfig_isRejectedOnSave() { + try { + service.saveProfile(5004L, "bad", "{ not json"); + org.junit.jupiter.api.Assertions.fail("expected IllegalArgumentException"); + } catch (IllegalArgumentException expected) { + // ok + } + } + + @Test + void validateProfileJson_reportsIssues() { + assertTrue(service.validateProfileJson(EPISODE_JSON).isEmpty()); + + List noTypes = service.validateProfileJson("{\"pageTypes\":{}}"); + assertFalse(noTypes.isEmpty()); + + List badEnum = service.validateProfileJson( + "{\"pageTypes\":{\"x\":{\"schema\":{\"f\":{\"type\":\"enum\"}}}}}"); + assertTrue(badEnum.stream().anyMatch(s -> s.contains("enum"))); + + List badType = service.validateProfileJson( + "{\"pageTypes\":{\"x\":{\"schema\":{\"f\":{\"type\":\"banana\"}}}}}"); + assertTrue(badType.stream().anyMatch(s -> s.contains("unknown field type"))); + } +}