fix(wiki): align HTTP status with R envelope and harden config + edit paths

This commit is contained in:
matevip 2026-05-14 23:01:00 +08:00
parent 1bba59fc7e
commit adf5d93975
18 changed files with 550 additions and 129 deletions

View File

@ -0,0 +1,44 @@
package vip.mate.common.result;
import org.springframework.core.MethodParameter;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpResponse;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
/**
* Align HTTP status codes with the project-wide {@link R} response envelope.
*/
@ControllerAdvice
public class RHttpStatusAdvice implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter returnType,
Class<? extends HttpMessageConverter<?>> converterType) {
return true;
}
@Override
public Object beforeBodyWrite(Object body,
MethodParameter returnType,
MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request,
ServerHttpResponse response) {
if (body instanceof R<?> envelope && envelope.getCode() != ResultCode.SUCCESS.getCode()) {
HttpStatus status = HttpStatus.resolve(envelope.getCode());
if (status == null) status = HttpStatus.INTERNAL_SERVER_ERROR;
if (response instanceof ServletServerHttpResponse servletResponse
&& !servletResponse.getServletResponse().isCommitted()) {
servletResponse.getServletResponse().setStatus(status.value());
} else {
response.setStatusCode(status);
}
}
return body;
}
}

View File

@ -4,20 +4,23 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.async.AsyncRequestTimeoutException;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import vip.mate.common.result.R;
import vip.mate.i18n.I18nService;
/**
* 全局异常处理器
* Global exception handler.
* <p>
* MateClawException 的中文消息通过 I18nService 查表翻译
* 中文原文作为 key 前缀查 propertieserr.msg.{hash}
* 找到翻译返回翻译找不到返回原文
* MateClawException messages are translated through I18nService when the
* exception provides a message key. The JSON body keeps the project-wide
* R envelope while the HTTP status mirrors the failure class.
*
* @author MateClaw Team
*/
@ -29,24 +32,24 @@ public class GlobalExceptionHandler {
private final I18nService i18nService;
@ExceptionHandler(AsyncRequestTimeoutException.class)
public R<Void> handleAsyncTimeout(AsyncRequestTimeoutException e,
HttpServletRequest request,
HttpServletResponse response) {
public ResponseEntity<R<Void>> handleAsyncTimeout(AsyncRequestTimeoutException e,
HttpServletRequest request,
HttpServletResponse response) {
if (isSseRequest(request) || response.isCommitted()) {
log.debug("SSE async timeout (normal lifecycle): {} {}", request.getMethod(), request.getRequestURI());
// 不返回任何 body避免 text/event-stream 无法序列化 R 的问题
// 返回 null 让框架自然结束异步请求
// Return no body for SSE so the framework can end the async request.
return null;
}
log.warn("Async request timeout: {} {}", request.getMethod(), request.getRequestURI());
return R.fail(503, "Request timeout, please try again");
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(R.fail(503, "Request timeout, please try again"));
}
@ExceptionHandler(MateClawException.class)
public R<Void> handleMateClawException(MateClawException e) {
public ResponseEntity<R<Void>> handleMateClawException(MateClawException e) {
log.warn("Business exception: [{}] {}", e.getCode(), e.getMessage());
String msg = translateExceptionMsg(e);
return R.fail(e.getCode(), msg);
return ResponseEntity.status(httpStatusForCode(e.getCode())).body(R.fail(e.getCode(), msg));
}
/**
@ -66,31 +69,37 @@ public class GlobalExceptionHandler {
}
@ExceptionHandler(BindException.class)
public R<Void> handleBindException(BindException e) {
public ResponseEntity<R<Void>> handleBindException(BindException e) {
String msg = e.getBindingResult().getFieldErrors().stream()
.map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
.findFirst()
.orElse("Validation failed");
log.warn("Validation failed: {}", msg);
return R.fail(400, msg);
return ResponseEntity.badRequest().body(R.fail(400, msg));
}
@ExceptionHandler(NoResourceFoundException.class)
public ResponseEntity<R<Void>> handleNoResourceFound(NoResourceFoundException e,
HttpServletRequest request) {
log.warn("Resource not found: {} {}", request.getMethod(), request.getRequestURI());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(R.fail(404, "Resource not found"));
}
@ExceptionHandler(Exception.class)
public R<Void> handleException(Exception e,
HttpServletRequest request,
HttpServletResponse response) {
// response 已提交或 SSE 请求不再尝试写 JSON body
public ResponseEntity<R<Void>> handleException(Exception e,
HttpServletRequest request,
HttpServletResponse response) {
if (response.isCommitted() || isSseRequest(request)) {
log.warn("Exception after response committed or during SSE (suppressed): {} {} - {}",
request.getMethod(), request.getRequestURI(), e.getMessage());
return null;
}
log.error("Unexpected error: {} {}", request.getMethod(), request.getRequestURI(), e);
return R.fail("Internal server error");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(R.fail("Internal server error"));
}
/**
* 判断是否为 SSE 请求检查 Accept 已设置的 Content-Type
* Identify SSE requests from the Accept header, Content-Type, or known stream path.
*/
private boolean isSseRequest(HttpServletRequest request) {
String accept = request.getHeader("Accept");
@ -101,8 +110,13 @@ public class GlobalExceptionHandler {
if (contentType != null && contentType.contains(MediaType.TEXT_EVENT_STREAM_VALUE)) {
return true;
}
// 备选路径匹配
// Fallback path match for stream endpoints that omit explicit headers.
String uri = request.getRequestURI();
return uri != null && uri.contains("/chat/stream");
}
private HttpStatus httpStatusForCode(int code) {
HttpStatus status = HttpStatus.resolve(code);
return status != null ? status : HttpStatus.INTERNAL_SERVER_ERROR;
}
}

View File

@ -251,11 +251,12 @@ public class WikiController {
};
if ("text".equals(sourceType)) {
// 文本文件直接读取内容
// Text files can be stored directly without staging to disk.
String content = new String(file.getBytes(), StandardCharsets.UTF_8);
return R.ok(rawService.addText(kbId, originalName, content));
} else {
// 二进制文件保存到磁盘转绝对路径避免 Tomcat 临时目录解析问题
// Binary files are staged under an absolute path so Tomcat temp
// directory resolution does not affect later processing.
Path uploadDir = Paths.get(properties.getUploadDir()).toAbsolutePath().normalize();
Files.createDirectories(uploadDir);
Path targetPath = uploadDir.resolve(System.currentTimeMillis() + "_" + originalName);
@ -292,7 +293,7 @@ public class WikiController {
if (raw == null || !kbId.equals(raw.getKbId())) {
return R.fail("Raw material not found in this knowledge base");
}
// RFC-012 Change 5force=true 时清空 last_processed_hash让下一次处理必然执行完整管线
// Force reprocessing by clearing the hash used to skip unchanged inputs.
if (force) {
rawService.setLastProcessedHash(rawId, null);
}

View File

@ -60,7 +60,7 @@ public class WikiTransformationController {
public R<WikiTransformationEntity> get(@PathVariable Long id,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
WikiTransformationEntity t = transformationService.getById(id);
if (t == null) return R.fail("Transformation not found");
if (t == null) return R.fail(404, "Transformation not found");
verifyTemplateWorkspace(t, workspaceId);
return R.ok(t);
}
@ -84,7 +84,7 @@ public class WikiTransformationController {
@RequestBody WikiTransformationEntity body,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
WikiTransformationEntity existing = transformationService.getById(id);
if (existing == null) return R.fail("Transformation not found");
if (existing == null) return R.fail(404, "Transformation not found");
verifyTemplateWorkspace(existing, workspaceId);
return R.ok(transformationService.update(id, body));
}
@ -114,16 +114,16 @@ public class WikiTransformationController {
@RequestParam(defaultValue = "false") boolean sync,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
WikiTransformationEntity t = transformationService.getById(id);
if (t == null) return R.fail("Transformation not found");
if (t == null) return R.fail(404, "Transformation not found");
verifyTemplateWorkspace(t, workspaceId);
Object rawIdRaw = body == null ? null : body.get("rawId");
Object pageIdRaw = body == null ? null : body.get("pageId");
if (rawIdRaw == null && pageIdRaw == null) {
return R.fail("One of rawId / pageId is required");
return R.fail(400, "One of rawId / pageId is required");
}
if (rawIdRaw != null && pageIdRaw != null) {
return R.fail("Pass only one of rawId / pageId, not both");
return R.fail(400, "Pass only one of rawId / pageId, not both");
}
if (rawIdRaw != null) {
@ -147,14 +147,14 @@ public class WikiTransformationController {
@RequestParam Long kbId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
WikiTransformationEntity t = transformationService.getById(id);
if (t == null) return R.fail("Transformation not found");
if (t == null) return R.fail(404, "Transformation not found");
verifyTemplateWorkspace(t, workspaceId);
verifyKBWorkspace(kbId, workspaceId != null ? workspaceId : 1L);
try {
WikiTransformationAggregator.Result res = aggregator.aggregate(t, kbId, "manual");
if (res.pageId() == null) {
return R.fail(res.title()); // when sources are empty we put the reason in title field
return R.fail(409, res.title()); // when sources are empty we put the reason in title field
}
return R.ok(Map.of(
"pageId", res.pageId(),
@ -164,7 +164,7 @@ public class WikiTransformationController {
"charsFed", res.charsFed(),
"created", res.created()));
} catch (IllegalStateException | IllegalArgumentException e) {
return R.fail(e.getMessage());
return R.fail(400, e.getMessage());
}
}
@ -175,7 +175,7 @@ public class WikiTransformationController {
public R<WikiTransformationRunEntity> getRun(@PathVariable Long runId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
WikiTransformationRunEntity run = transformationService.getRun(runId);
if (run == null) return R.fail("Run not found");
if (run == null) return R.fail(404, "Run not found");
verifyKBWorkspace(run.getKbId(), workspaceId != null ? workspaceId : 1L);
return R.ok(run);
}
@ -194,7 +194,7 @@ public class WikiTransformationController {
}
if (transformationId != null) {
WikiTransformationEntity t = transformationService.getById(transformationId);
if (t == null) return R.fail("Transformation not found");
if (t == null) return R.fail(404, "Transformation not found");
verifyTemplateWorkspace(t, wsId);
return R.ok(transformationService.listRunsByTransformation(transformationId, limit));
}
@ -202,7 +202,7 @@ public class WikiTransformationController {
verifyKBWorkspace(kbId, wsId);
return R.ok(transformationService.listRunsByKb(kbId, limit));
}
return R.fail("One of rawId / kbId / transformationId is required");
return R.fail(400, "One of rawId / kbId / transformationId is required");
}
@RequireWorkspaceRole("member")
@ -214,10 +214,10 @@ public class WikiTransformationController {
public R<Void> cancelRun(@PathVariable Long runId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
WikiTransformationRunEntity run = transformationService.getRun(runId);
if (run == null) return R.fail("Run not found");
if (run == null) return R.fail(404, "Run not found");
verifyKBWorkspace(run.getKbId(), workspaceId != null ? workspaceId : 1L);
boolean cancelled = executor.cancelRun(runId);
if (!cancelled) return R.fail("Run is not running");
if (!cancelled) return R.fail(409, "Run is not running");
return R.ok();
}
@ -228,17 +228,17 @@ public class WikiTransformationController {
public R<Map<String, Object>> saveRunAsPage(@PathVariable Long runId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
WikiTransformationRunEntity run = transformationService.getRun(runId);
if (run == null) return R.fail("Run not found");
if (run == null) return R.fail(404, "Run not found");
verifyKBWorkspace(run.getKbId(), workspaceId != null ? workspaceId : 1L);
try {
var page = executor.manualSaveRunAsPage(runId);
if (page == null) return R.fail("Page service unavailable");
if (page == null) return R.fail(503, "Page service unavailable");
return R.ok(Map.of(
"pageId", page.getId(),
"slug", page.getSlug(),
"title", page.getTitle()));
} catch (IllegalStateException | IllegalArgumentException e) {
return R.fail(e.getMessage());
return R.fail(400, e.getMessage());
}
}

View File

@ -0,0 +1,81 @@
package vip.mate.wiki.job;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Parses optional machine-readable KB config from JSON or markdown frontmatter.
*/
public final class WikiKbConfigParser {
private WikiKbConfigParser() {
}
public static WikiKbConfig parse(ObjectMapper objectMapper, String content) {
String trimmed = content == null ? "" : content.trim();
if (trimmed.isEmpty()) return null;
if (trimmed.startsWith("{")) {
try {
return objectMapper.readValue(trimmed, WikiKbConfig.class);
} catch (Exception e) {
return null;
}
}
if (trimmed.startsWith("---")) {
return parseFrontmatter(trimmed);
}
return null;
}
private static WikiKbConfig parseFrontmatter(String content) {
int end = content.indexOf("\n---", 3);
if (end < 0) return null;
WikiKbConfig config = new WikiKbConfig();
Map<String, Long> stepModels = new LinkedHashMap<>();
String frontmatter = content.substring(3, end);
for (String line : frontmatter.split("\\R")) {
int colon = line.indexOf(':');
if (colon <= 0) continue;
String key = line.substring(0, colon).trim();
String value = unquote(line.substring(colon + 1).trim());
if (value.isBlank()) continue;
if ("ingestMode".equals(key)) {
config.setIngestMode(value);
} else if ("useStructuredRoute".equals(key)) {
config.setUseStructuredRoute(Boolean.valueOf(value));
} else if ("wikiDefaultModelId".equals(key)) {
Long parsed = parseLong(value);
if (parsed != null) config.setWikiDefaultModelId(parsed);
} else if (key.startsWith("stepModels.")) {
Long parsed = parseLong(value);
if (parsed != null) {
stepModels.put(key.substring("stepModels.".length()), parsed);
}
}
}
if (!stepModels.isEmpty()) {
config.setStepModels(stepModels);
}
return config;
}
private static String unquote(String value) {
if ((value.startsWith("\"") && value.endsWith("\""))
|| (value.startsWith("'") && value.endsWith("'"))) {
return value.substring(1, value.length() - 1);
}
return value;
}
private static Long parseLong(String value) {
try {
return Long.parseLong(value);
} catch (NumberFormatException e) {
return null;
}
}
}

View File

@ -7,6 +7,7 @@ import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import vip.mate.wiki.job.WikiJobStep;
import vip.mate.wiki.job.WikiKbConfig;
import vip.mate.wiki.job.WikiKbConfigParser;
import vip.mate.wiki.job.model.WikiProcessingJobEntity;
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
@ -30,15 +31,11 @@ public class KbConfigStepModelStrategy implements WikiStepModelStrategy {
@Override
public Long selectModelId(WikiProcessingJobEntity job, WikiKnowledgeBaseEntity kb, WikiJobStep step) {
if (kb == null || kb.getConfigContent() == null) return null;
try {
WikiKbConfig config = objectMapper.readValue(kb.getConfigContent(), WikiKbConfig.class);
Map<String, Long> stepModels = config.getStepModels();
if (stepModels == null) return null;
String key = job.getJobType() + "." + step.name().toLowerCase();
return stepModels.get(key);
} catch (Exception e) {
log.debug("[KbConfigStrategy] Failed to parse KB config: {}", e.getMessage());
return null;
}
WikiKbConfig config = WikiKbConfigParser.parse(objectMapper, kb.getConfigContent());
if (config == null) return null;
Map<String, Long> stepModels = config.getStepModels();
if (stepModels == null) return null;
String key = job.getJobType() + "." + step.name().toLowerCase();
return stepModels.get(key);
}
}

View File

@ -7,6 +7,7 @@ import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import vip.mate.wiki.job.WikiJobStep;
import vip.mate.wiki.job.WikiKbConfig;
import vip.mate.wiki.job.WikiKbConfigParser;
import vip.mate.wiki.job.model.WikiProcessingJobEntity;
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
@ -38,12 +39,7 @@ public class KbDefaultModelStrategy implements WikiStepModelStrategy {
@Override
public Long selectModelId(WikiProcessingJobEntity job, WikiKnowledgeBaseEntity kb, WikiJobStep step) {
if (kb == null || kb.getConfigContent() == null) return null;
try {
WikiKbConfig config = objectMapper.readValue(kb.getConfigContent(), WikiKbConfig.class);
return config.getWikiDefaultModelId();
} catch (Exception e) {
log.debug("[KbDefaultModelStrategy] Failed to parse KB config: {}", e.getMessage());
return null;
}
WikiKbConfig config = WikiKbConfigParser.parse(objectMapper, kb.getConfigContent());
return config != null ? config.getWikiDefaultModelId() : null;
}
}

View File

@ -317,6 +317,7 @@ public class WikiPageService {
if (!rawIds.contains(newRawId)) {
rawIds.add(newRawId);
existing.setSourceRawIds(toJson(rawIds));
existing.setUpdateTime(LocalDateTime.now());
pageMapper.updateById(existing);
evictSummaryCache(kbId);
return getBySlug(kbId, slug); // DB 重新加载确保一致性
@ -330,6 +331,7 @@ public class WikiPageService {
existing.setOutgoingLinks(extractLinksAsJson(content));
existing.setVersion(existing.getVersion() + 1);
existing.setLastUpdatedBy("ai");
existing.setUpdateTime(LocalDateTime.now());
// 追加新的 source raw id
if (newRawId != null) {
@ -377,6 +379,7 @@ public class WikiPageService {
}
if (!entryExists || !idExists) {
page.setUpdateTime(LocalDateTime.now());
pageMapper.updateById(page);
evictSummaryCache(page.getKbId());
}
@ -395,6 +398,7 @@ public class WikiPageService {
existing.setOutgoingLinks(extractLinksAsJson(content));
existing.setVersion(existing.getVersion() + 1);
existing.setLastUpdatedBy("manual");
existing.setUpdateTime(LocalDateTime.now());
// 同步更新摘要防止与 content 漂移
if (summary != null) {
existing.setSummary(summary);

View File

@ -18,6 +18,7 @@ import vip.mate.llm.service.ModelConfigService;
import vip.mate.wiki.WikiProperties;
import vip.mate.wiki.dto.WikiChunkDraft;
import vip.mate.wiki.job.WikiKbConfig;
import vip.mate.wiki.job.WikiKbConfigParser;
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
import vip.mate.wiki.model.WikiPageEntity;
import vip.mate.wiki.model.WikiRawMaterialEntity;
@ -162,26 +163,25 @@ public class WikiProcessingService {
private final ConcurrentHashMap<Long, ProgressCounter> progressCounters = new ConcurrentHashMap<>();
/**
* 处理单个原始材料
* Process one raw material.
*/
public void processRawMaterial(Long rawId) {
processRawMaterial(rawId, false);
}
/**
* 处理单个原始材料支持强制重跑
* Process one raw material, optionally bypassing the content-hash shortcut.
*
* @param rawId 材料 ID
* @param force true 时忽略 content_hash 短路RFC-012 Change 5用于模型/提示词变更后的强制重跑
* @param rawId raw material ID
* @param force when true, ignore content_hash so model or prompt changes can re-run the full pipeline
*/
public void processRawMaterial(Long rawId, boolean force) {
// RFC-012 follow-up #3消费续传标志reprocess() partial 改回 pending 之前打的标
// 必须在 claimForProcessing 之前读因为 claim 会把状态再改一次
// flag 只在内存中server 重启会丢 重启后仍按 pending 走正常流程退化为全量重跑
// 功能不丢失只是性能回退
// Consume the partial-resume marker before claimForProcessing changes
// the row status again. The marker is in-memory only; after a restart
// the pending row falls back to a full re-run.
boolean isPartialResume = rawService.consumePartialResumeFlag(rawId);
// CAS 式抢占防止并发重复处理
// Claim once so concurrent workers do not process the same raw twice.
if (!rawService.claimForProcessing(rawId)) {
log.debug("[Wiki] Raw material {} already claimed or not pending, skipping", rawId);
return;
@ -193,7 +193,7 @@ public class WikiProcessingService {
return;
}
// RFC-012 Change 5 content_hash 与上次成功处理时一致直接短路
// Skip unchanged content unless the caller requested a forced re-run.
if (!force
&& raw.getContentHash() != null
&& raw.getContentHash().equals(raw.getLastProcessedHash())) {
@ -210,15 +210,14 @@ public class WikiProcessingService {
kbService.updateStatus(kb.getId(), "processing");
// RFC-051 PR-2: every ingest path opens with a scaffold check so older
// KBs get their overview / log pages on first use without a manual step.
// Every ingest path opens with a scaffold check so older KBs get their
// overview / log pages on first use without a manual step.
if (scaffoldService != null) {
scaffoldService.ensureScaffold(kb.getId());
}
// RFC-051 PR-1b: lazy ingest short-circuit. Per KB config, skip the heavy
// pipeline entirely: extract chunk embed completed. 0 pages is the
// expected outcome, not a failure. ingestMode==null keeps existing behavior.
// Lazy ingest skips the heavy generation pipeline: extract, chunk,
// embed, completed. Zero pages is expected, not a failure.
if ("lazy".equals(resolveIngestMode(kb))) {
processLazyIngest(kb, raw);
return;
@ -362,7 +361,7 @@ public class WikiProcessingService {
} else {
rawService.updateProcessingStatus(rawId, "completed", null);
finalStatus = "completed";
// RFC-012 Change 5记录本次成功处理时的 hash供下次短路判断
// Record the successful hash for the next unchanged-content shortcut.
if (raw.getContentHash() != null) {
rawService.setLastProcessedHash(rawId, raw.getContentHash());
}
@ -596,9 +595,9 @@ public class WikiProcessingService {
return;
}
try {
// RFC-051 PR-9: skip remaining chunks if the user deleted the raw
// while earlier chunks were still in flight. Counts as a "failed chunk"
// for terminal-status accounting (not actually failed, just abandoned).
// Skip remaining chunks if the user deleted the raw while
// earlier chunks were still in flight. Counts as a failed
// chunk for terminal-status accounting.
if (isAborted(raw.getId(), "chunk " + (chunkIndex + 1) + "/" + totalChunks)) {
failedChunks.incrementAndGet();
return;
@ -1326,9 +1325,9 @@ public class WikiProcessingService {
Long kbId = kb.getId();
Long rawId = raw.getId();
// RFC-051 PR-9: refuse to materialize a page (or merge into an existing one) tied
// to a raw the user just deleted. Prevents zombie pages whose source_raw_ids point
// at a tombstoned row.
// Refuse to materialize a page, or merge into an existing one, for a
// raw the user just deleted. This prevents pages whose source_raw_ids
// point at a tombstoned row.
if (isAborted(rawId, "savePageContent slug=" + slug)) return false;
// Fallback 0: cross-spelling canonical match (DB has same concept under different slug)
@ -2181,47 +2180,38 @@ public class WikiProcessingService {
}
/**
* RFC-051 PR-6b follow-up: KB-level override for structured route output,
* falling back to the global property when the KB hasn't set a preference.
* Parse failures fall back to global too never block ingest on bad config.
* KB-level override for structured route output, falling back to the global
* property when the KB has not set a preference. Parse failures fall back to
* global too; ingestion must not be blocked by bad optional config.
*/
private boolean resolveStructuredRouteFlag(WikiKnowledgeBaseEntity kb) {
boolean fallback = properties.isUseStructuredRoute();
if (kb == null || kb.getConfigContent() == null) return fallback;
try {
WikiKbConfig config = objectMapper.readValue(kb.getConfigContent(), WikiKbConfig.class);
return config.getUseStructuredRoute() != null
? config.getUseStructuredRoute()
: fallback;
} catch (Exception e) {
return fallback;
}
WikiKbConfig config = WikiKbConfigParser.parse(objectMapper, kb.getConfigContent());
return config != null && config.getUseStructuredRoute() != null
? config.getUseStructuredRoute()
: fallback;
}
/**
* RFC-051 PR-1b: read {@code ingestMode} from KB config JSON. Returns null
* on any parse error or missing field so the caller falls through to eager.
* Read {@code ingestMode} from KB config JSON or markdown frontmatter.
* Returns null on any parse error or missing field so the caller falls
* through to eager mode.
*/
private String resolveIngestMode(WikiKnowledgeBaseEntity kb) {
if (kb == null || kb.getConfigContent() == null) return null;
try {
WikiKbConfig config = objectMapper.readValue(kb.getConfigContent(), WikiKbConfig.class);
return config.getIngestMode();
} catch (Exception e) {
log.warn("[Wiki] Failed to parse KB config for ingest mode, falling back to eager: {}", e.getMessage());
return null;
}
WikiKbConfig config = WikiKbConfigParser.parse(objectMapper, kb.getConfigContent());
return config != null ? config.getIngestMode() : null;
}
/**
* RFC-051 PR-9: returns {@code true} when the caller should bail out of an
* in-flight processing path because the raw material has been deleted.
* Returns {@code true} when the caller should bail out of an in-flight
* processing path because the raw material has been deleted.
* <p>
* {@link WikiRawMaterialService#delete(Long)} is a logical delete (the
* {@code @TableLogic} column flips to 1), so {@code selectById} returns
* {@code null} as soon as the deletion commits. Sprinkling this check
* right before each LLM call keeps token spend bounded by a single
* in-flight call after the user clicks delete.
* {@link WikiRawMaterialService#delete(Long)} is a logical delete. The row
* disappears from normal lookups as soon as deletion commits. Checking
* before each LLM call keeps token spend bounded by a single in-flight call
* after the user clicks delete.
*
* @param rawId the raw material id this processing path is about
* @param ctx short string used in the log line
@ -2269,12 +2259,11 @@ public class WikiProcessingService {
}
/**
* RFC-051 PR-1b: lazy ingest chunk + embed, no page generation.
* Lazy ingest: chunk and embed, without page generation.
* <p>
* Intentionally minimal: reuses the legacy {@code persistChunks(List<String>, offsets)}
* overload (no structural metadata; that lands in PR-1c with the preprocessor)
* and the existing {@code embedMissingChunks} entry point. Zero pages is the
* expected outcome, not a failure.
* overload and the existing {@code embedMissingChunks} entry point. Zero
* pages is the expected outcome, not a failure.
*/
private void processLazyIngest(WikiKnowledgeBaseEntity kb, WikiRawMaterialEntity raw) {
Long rawId = raw.getId();
@ -2295,9 +2284,9 @@ public class WikiProcessingService {
return;
}
// RFC-051 PR-9: text extraction can take many seconds on large binaries.
// If the user deleted the raw during that window, persisting chunks for a
// tombstoned row is wasted work that the cascade-cleanup already covered.
// Text extraction can take many seconds on large binaries. If the
// user deleted the raw during that window, persisting chunks for a
// tombstoned row is wasted work already covered by cascade cleanup.
if (isAborted(rawId, "lazy ingest after extract")) {
kbService.updateStatus(kbId, "active");
return;

View File

@ -0,0 +1,33 @@
package vip.mate.common.result;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.http.server.ServletServerHttpResponse;
import org.springframework.mock.web.MockHttpServletResponse;
import static org.junit.jupiter.api.Assertions.assertEquals;
class RHttpStatusAdviceTest {
@Test
void failEnvelopeSetsHttpStatusFromBodyCode() {
RHttpStatusAdvice advice = new RHttpStatusAdvice();
MockHttpServletResponse servlet = new MockHttpServletResponse();
advice.beforeBodyWrite(R.fail(400, "bad input"), null, MediaType.APPLICATION_JSON,
null, null, new ServletServerHttpResponse(servlet));
assertEquals(400, servlet.getStatus());
}
@Test
void defaultFailEnvelopeSetsInternalServerErrorStatus() {
RHttpStatusAdvice advice = new RHttpStatusAdvice();
MockHttpServletResponse servlet = new MockHttpServletResponse();
advice.beforeBodyWrite(R.fail("boom"), null, MediaType.APPLICATION_JSON,
null, null, new ServletServerHttpResponse(servlet));
assertEquals(500, servlet.getStatus());
}
}

View File

@ -0,0 +1,39 @@
package vip.mate.exception;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import vip.mate.common.result.R;
import vip.mate.i18n.I18nService;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class GlobalExceptionHandlerTest {
private final GlobalExceptionHandler handler = new GlobalExceptionHandler(mock(I18nService.class));
@Test
void mateClawExceptionUsesMatchingHttpStatus() {
ResponseEntity<R<Void>> response = handler.handleMateClawException(
new MateClawException(404, "Not found"));
assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
assertEquals(404, response.getBody().getCode());
}
@Test
void genericExceptionUsesInternalServerErrorStatus() {
jakarta.servlet.http.HttpServletRequest request = mock(jakarta.servlet.http.HttpServletRequest.class);
jakarta.servlet.http.HttpServletResponse servletResponse = mock(jakarta.servlet.http.HttpServletResponse.class);
when(request.getMethod()).thenReturn("GET");
when(request.getRequestURI()).thenReturn("/missing");
ResponseEntity<R<Void>> response = handler.handleException(
new RuntimeException("boom"), request, servletResponse);
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());
assertEquals(500, response.getBody().getCode());
}
}

View File

@ -0,0 +1,56 @@
package vip.mate.wiki.controller;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import vip.mate.common.result.R;
import vip.mate.wiki.model.WikiTransformationEntity;
import vip.mate.wiki.model.WikiTransformationRunEntity;
import vip.mate.wiki.service.WikiKnowledgeBaseService;
import vip.mate.wiki.service.WikiTransformationAggregator;
import vip.mate.wiki.service.WikiTransformationExecutor;
import vip.mate.wiki.service.WikiTransformationService;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class WikiTransformationControllerTest {
private WikiTransformationService transformationService;
private WikiTransformationController controller;
@BeforeEach
void setUp() {
transformationService = mock(WikiTransformationService.class);
controller = new WikiTransformationController(
transformationService,
mock(WikiTransformationExecutor.class),
mock(WikiTransformationAggregator.class),
mock(WikiKnowledgeBaseService.class));
}
@Test
void applyMissingTemplateReturns404Envelope() {
when(transformationService.getById(99L)).thenReturn(null);
R<WikiTransformationRunEntity> response = controller.apply(
99L, Map.of("rawId", 1L), false, 1L);
assertEquals(404, response.getCode());
}
@Test
void applyWithRawIdAndPageIdReturns400Envelope() {
WikiTransformationEntity transformation = new WikiTransformationEntity();
transformation.setId(99L);
transformation.setWorkspaceId(1L);
when(transformationService.getById(99L)).thenReturn(transformation);
R<WikiTransformationRunEntity> response = controller.apply(
99L, Map.of("rawId", 1L, "pageId", 2L), false, 1L);
assertEquals(400, response.getCode());
}
}

View File

@ -0,0 +1,66 @@
package vip.mate.wiki.job.strategy;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import vip.mate.wiki.job.WikiJobStep;
import vip.mate.wiki.job.model.WikiProcessingJobEntity;
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
class KbModelStrategyConfigTest {
@Test
void defaultModelStrategyReadsMarkdownFrontmatter() {
WikiKnowledgeBaseEntity kb = kb("""
---
wikiDefaultModelId: 12345
---
# Wiki Processing Rules
""");
Long modelId = new KbDefaultModelStrategy(new ObjectMapper())
.selectModelId(job(), kb, WikiJobStep.ROUTE);
assertEquals(12345L, modelId);
}
@Test
void stepModelStrategyReadsDottedFrontmatterKey() {
WikiKnowledgeBaseEntity kb = kb("""
---
stepModels.heavy_ingest.create_page: 67890
---
# Wiki Processing Rules
""");
Long modelId = new KbConfigStepModelStrategy(new ObjectMapper())
.selectModelId(job(), kb, WikiJobStep.CREATE_PAGE);
assertEquals(67890L, modelId);
}
@Test
void plainMarkdownConfigIsTreatedAsEmptyConfig() {
WikiKnowledgeBaseEntity kb = kb("# Wiki Processing Rules\n\nNo machine config here.");
Long modelId = new KbDefaultModelStrategy(new ObjectMapper())
.selectModelId(job(), kb, WikiJobStep.ROUTE);
assertNull(modelId);
}
private static WikiKnowledgeBaseEntity kb(String configContent) {
WikiKnowledgeBaseEntity kb = new WikiKnowledgeBaseEntity();
kb.setId(7L);
kb.setConfigContent(configContent);
return kb;
}
private static WikiProcessingJobEntity job() {
WikiProcessingJobEntity job = new WikiProcessingJobEntity();
job.setJobType("heavy_ingest");
return job;
}
}

View File

@ -0,0 +1,40 @@
package vip.mate.wiki.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import vip.mate.wiki.model.WikiPageEntity;
import vip.mate.wiki.repository.WikiPageMapper;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class WikiPageServiceTest {
@Test
void manualUpdateRefreshesUpdateTimeBeforePersisting() {
WikiPageMapper mapper = mock(WikiPageMapper.class);
WikiPageEntity page = new WikiPageEntity();
page.setId(99L);
page.setKbId(7L);
page.setSlug("page");
page.setContent("old");
page.setSummary("old summary");
page.setVersion(1);
page.setLastUpdatedBy("ai");
LocalDateTime oldUpdateTime = LocalDateTime.now().minusDays(1);
page.setUpdateTime(oldUpdateTime);
when(mapper.selectOne(any())).thenReturn(page);
when(mapper.updateById(any(WikiPageEntity.class))).thenReturn(1);
new WikiPageService(mapper, new ObjectMapper())
.updatePageManually(7L, "page", "new body", null);
assertTrue(page.getUpdateTime().isAfter(oldUpdateTime));
verify(mapper).updateById(page);
}
}

View File

@ -86,6 +86,20 @@ class WikiProcessingServiceLazyTest {
return k;
}
private WikiKnowledgeBaseEntity markdownKbWithFrontmatter(String ingestMode) {
WikiKnowledgeBaseEntity k = new WikiKnowledgeBaseEntity();
k.setId(KB_ID);
k.setConfigContent("""
---
ingestMode: %s
---
# Wiki Processing Rules
Keep pages concise.
""".formatted(ingestMode));
return k;
}
@Test
@DisplayName("lazy mode: skips LLM pipeline, persists chunks, marks completed with 0 pages")
void lazyMode_noLlmCalls() throws InterruptedException {
@ -136,6 +150,34 @@ class WikiProcessingServiceLazyTest {
verify(kbService).updateStatus(KB_ID, "active");
}
@Test
@DisplayName("lazy mode from markdown frontmatter: skips LLM pipeline")
void markdownFrontmatterLazyMode_noLlmCalls() throws InterruptedException {
WikiRawMaterialEntity rawEntity = raw();
WikiKnowledgeBaseEntity kbEntity = markdownKbWithFrontmatter("lazy");
when(rawService.claimForProcessing(RAW_ID)).thenReturn(true);
when(rawService.getById(RAW_ID)).thenReturn(rawEntity);
when(rawService.getTextContent(rawEntity)).thenReturn("Some document text for lazy ingest. ".repeat(20));
when(kbService.getById(KB_ID)).thenReturn(kbEntity);
when(pageService.countByKbId(KB_ID)).thenReturn(0);
CountDownLatch embedCalled = new CountDownLatch(1);
when(embeddingService.embedMissingChunks(KB_ID)).thenAnswer(inv -> {
embedCalled.countDown();
return 0;
});
service.processRawMaterial(RAW_ID);
verify(chunkService, times(1)).persistChunks(eq(KB_ID), eq(RAW_ID), anyList(), anyList());
verify(pageService, never()).deleteExclusiveBySourceRawId(anyLong(), anyLong());
assertTrue(embedCalled.await(5, TimeUnit.SECONDS), "embedMissingChunks should have been invoked");
verify(progressBus).broadcast(eq(KB_ID),
eq(WikiProgressBus.EVENT_RAW_STARTED),
argThat((Map<String, Object> m) -> "lazy".equals(m.get("phase"))));
}
@Test
@DisplayName("lazy mode with blank text: marks failed, no chunks persisted")
void lazyMode_blankText_failsCleanly() {

View File

@ -663,7 +663,7 @@ export const wikiApi = {
getBacklinks: (kbId: number, slug: string) =>
http.get(`/wiki/knowledge-bases/${kbId}/pages/${encodeURIComponent(slug)}/backlinks`),
// RFC-051 PR-7: archived pages
// Archived pages
listArchivedPages: (kbId: number) =>
http.get(`/wiki/knowledge-bases/${kbId}/pages/archived`),
archivePage: (kbId: number, slug: string) =>

View File

@ -26,7 +26,8 @@ export interface WikiRawMaterial {
lastProcessedAt: string | null
errorMessage: string | null
createTime: string
// RFC-012 M2 v2 UI两阶段消化进度字段后端在 route 后写 total每页完成后 +1 done
// Two-stage ingestion progress: backend writes total after routing and
// increments done as each generated page finishes.
progressPhase: string | null
progressTotal: number
progressDone: number
@ -46,16 +47,16 @@ export interface WikiPage {
version: number
lastUpdatedBy: string
pageType?: string | null
// RFC-051 PR-2: locked=1 blocks AI/tool/UI deletion (combined with pageType=system
// for the built-in overview/log pages, but users can lock any page).
// locked=1 blocks AI/tool/UI deletion. System pages are locked by default,
// but users can lock any page.
locked?: number | null
// RFC-051 PR-7: archived=1 hides the page from default list/search/related.
// archived=1 hides the page from default list/search/related.
archived?: number | null
createTime: string
updateTime: string
}
/** RFC-051 PR-8: shared protection check used by viewer + list to gate delete UI. */
/** Shared protection check used by viewer and list to gate delete UI. */
export function isProtectedPage(page: WikiPage | null | undefined): boolean {
if (!page) return false
if (page.pageType === 'system') return true
@ -128,6 +129,23 @@ export const useWikiStore = defineStore('wiki', () => {
if (!rawId) totalPageCount.value = pages.value.length
}
async function refreshCurrentKB(options: { preserveRawFilter?: boolean } = {}) {
if (!currentKB.value) return
const kbId = currentKB.value.id
const rawId = options.preserveRawFilter ? selectedRawId.value : null
const [kbRes] = await Promise.all([
wikiApi.getKB(kbId),
fetchRawMaterials(kbId),
fetchPages(kbId, rawId ?? undefined),
])
const nextKB = (kbRes as any).data || kbRes
currentKB.value = nextKB
const idx = knowledgeBases.value.findIndex(kb => kb.id === kbId)
if (idx >= 0) {
knowledgeBases.value[idx] = nextKB
}
}
async function filterPagesByRaw(kbId: number, rawId: number) {
selectedRawId.value = rawId
await fetchPages(kbId, rawId)
@ -173,7 +191,7 @@ export const useWikiStore = defineStore('wiki', () => {
async function scanDirectory(kbId: number) {
const res: any = await wikiApi.scanDirectory(kbId)
const result = res.data || res
// 扫描后刷新材料列表
// Refresh materials after the scan imports new rows.
await fetchRawMaterials(kbId)
return result
}
@ -194,6 +212,7 @@ export const useWikiStore = defineStore('wiki', () => {
backToLibrary,
fetchRawMaterials,
fetchPages,
refreshCurrentKB,
filterPagesByRaw,
clearRawFilter,
loadPage,

View File

@ -297,9 +297,9 @@ const { t } = useI18n()
const store = useWikiStore()
const fileInput = ref<HTMLInputElement | null>(null)
// RFC-012 M3 processing SSE
// 60s processingStatus / fetchRawMaterials SSE 线DB
// processing SSE +
// While raw materials are active, subscribe to the backend SSE progress stream.
// A slower polling fallback keeps the UI in sync if SSE reconnects or misses a
// terminal event. The database remains the source of truth.
let sse: EventSource | null = null
let fallbackTimer: number | null = null
let activeKbId: number | null = null
@ -319,7 +319,7 @@ function applyProgressEvent(payload: any) {
function openSse(kbId: number) {
closeSse()
activeKbId = kbId
// Vite /api :18088EventSource
// Vite proxies /api to the backend, so EventSource can use a relative URL.
const es = new EventSource(`/api/v1/wiki/knowledge-bases/${kbId}/progress`)
sse = es
@ -353,7 +353,7 @@ function openSse(kbId: number) {
}
// Clear stale job entry so JobStageBar hides
delete rawJobs[data.rawId]
if (store.currentKB) store.fetchRawMaterials(store.currentKB.id)
if (store.currentKB) void store.refreshCurrentKB()
} catch { /* ignore */ }
})
es.addEventListener('raw.failed', (ev: MessageEvent) => {
@ -363,7 +363,7 @@ function openSse(kbId: number) {
if (raw) raw.processingStatus = 'failed'
// Clear stale job entry
delete rawJobs[data.rawId]
if (store.currentKB) store.fetchRawMaterials(store.currentKB.id)
if (store.currentKB) void store.refreshCurrentKB()
} catch { /* ignore */ }
})
es.onerror = () => {
@ -389,7 +389,7 @@ watch(
// 60s fallback polling
if (fallbackTimer == null) {
fallbackTimer = window.setInterval(() => {
if (store.currentKB) store.fetchRawMaterials(store.currentKB.id)
if (store.currentKB) void store.refreshCurrentKB()
}, 60000)
}
} else {
@ -445,9 +445,9 @@ async function pollJobs() {
}
} catch { /* ignore */ }
}
// When any job reaches terminal, refresh raw materials to sync status badges
// When any job reaches terminal, refresh wiki metadata, pages, and raw badges.
if (anyTerminal) {
await store.fetchRawMaterials(kbId)
await store.refreshCurrentKB()
}
// Continue polling while there are still processing/pending raws
const stillActive = store.rawMaterials.some(