feat(wiki): pageType profile CRUD API and service operations

This commit is contained in:
matevip 2026-05-31 07:54:27 +08:00
parent 7cd9971596
commit bea48bcf89
3 changed files with 272 additions and 0 deletions

View File

@ -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<Map<String, Object>> getPageTypeProfile(@PathVariable Long id,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(id, workspaceId);
WikiPageTypeProfileEntity row = pageTypeProfileService.findEnabledRow(id);
Map<String, Object> 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<Void> savePageTypeProfile(@PathVariable Long id, @RequestBody Map<String, String> 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<Map<String, Object>> validatePageTypeProfile(@PathVariable Long id, @RequestBody Map<String, String> body,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(id, workspaceId);
List<String> issues = pageTypeProfileService.validateProfileJson(body.getOrDefault("config", ""));
Map<String, Object> 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<Void> 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")

View File

@ -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<WikiPageTypeProfileEntity>()
.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<WikiPageTypeProfileEntity>()
.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<String> validateProfileJson(String configJson) {
java.util.List<String> 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<String> 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

View File

@ -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<String> noTypes = service.validateProfileJson("{\"pageTypes\":{}}");
assertFalse(noTypes.isEmpty());
List<String> badEnum = service.validateProfileJson(
"{\"pageTypes\":{\"x\":{\"schema\":{\"f\":{\"type\":\"enum\"}}}}}");
assertTrue(badEnum.stream().anyMatch(s -> s.contains("enum")));
List<String> badType = service.validateProfileJson(
"{\"pageTypes\":{\"x\":{\"schema\":{\"f\":{\"type\":\"banana\"}}}}}");
assertTrue(badType.stream().anyMatch(s -> s.contains("unknown field type")));
}
}