diff --git a/mateclaw-server/src/main/java/vip/mate/common/result/RHttpStatusAdvice.java b/mateclaw-server/src/main/java/vip/mate/common/result/RHttpStatusAdvice.java new file mode 100644 index 00000000..3901caa2 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/common/result/RHttpStatusAdvice.java @@ -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 { + + @Override + public boolean supports(MethodParameter returnType, + Class> converterType) { + return true; + } + + @Override + public Object beforeBodyWrite(Object body, + MethodParameter returnType, + MediaType selectedContentType, + Class> 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; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/exception/GlobalExceptionHandler.java b/mateclaw-server/src/main/java/vip/mate/exception/GlobalExceptionHandler.java index a1eced58..ccfe8da2 100644 --- a/mateclaw-server/src/main/java/vip/mate/exception/GlobalExceptionHandler.java +++ b/mateclaw-server/src/main/java/vip/mate/exception/GlobalExceptionHandler.java @@ -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. *

- * MateClawException 的中文消息通过 I18nService 查表翻译: - * 中文原文作为 key 前缀查 properties(err.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 handleAsyncTimeout(AsyncRequestTimeoutException e, - HttpServletRequest request, - HttpServletResponse response) { + public ResponseEntity> 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 handleMateClawException(MateClawException e) { + public ResponseEntity> 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 handleBindException(BindException e) { + public ResponseEntity> 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> 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 handleException(Exception e, - HttpServletRequest request, - HttpServletResponse response) { - // response 已提交或 SSE 请求,不再尝试写 JSON body + public ResponseEntity> 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; + } } 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 0de20d85..4b25e82b 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 @@ -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 5:force=true 时清空 last_processed_hash,让下一次处理必然执行完整管线 + // Force reprocessing by clearing the hash used to skip unchanged inputs. if (force) { rawService.setLastProcessedHash(rawId, null); } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiTransformationController.java b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiTransformationController.java index be3cb422..93acf41b 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiTransformationController.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiTransformationController.java @@ -60,7 +60,7 @@ public class WikiTransformationController { public R 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 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 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> 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()); } } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiKbConfigParser.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiKbConfigParser.java new file mode 100644 index 00000000..62792a78 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/WikiKbConfigParser.java @@ -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 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; + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/KbConfigStepModelStrategy.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/KbConfigStepModelStrategy.java index ea44de14..249d425c 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/KbConfigStepModelStrategy.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/KbConfigStepModelStrategy.java @@ -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 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 stepModels = config.getStepModels(); + if (stepModels == null) return null; + String key = job.getJobType() + "." + step.name().toLowerCase(); + return stepModels.get(key); } } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/KbDefaultModelStrategy.java b/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/KbDefaultModelStrategy.java index 24bbebfb..ae03657c 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/KbDefaultModelStrategy.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/job/strategy/KbDefaultModelStrategy.java @@ -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; } } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java index 1fa2e91b..c3e3e828 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java @@ -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); diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java index 996e7e4c..da447ee2 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java @@ -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 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. *

- * {@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. *

* Intentionally minimal: reuses the legacy {@code persistChunks(List, 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; diff --git a/mateclaw-server/src/test/java/vip/mate/common/result/RHttpStatusAdviceTest.java b/mateclaw-server/src/test/java/vip/mate/common/result/RHttpStatusAdviceTest.java new file mode 100644 index 00000000..6fc9ed6a --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/common/result/RHttpStatusAdviceTest.java @@ -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()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/exception/GlobalExceptionHandlerTest.java b/mateclaw-server/src/test/java/vip/mate/exception/GlobalExceptionHandlerTest.java new file mode 100644 index 00000000..b33099c0 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/exception/GlobalExceptionHandlerTest.java @@ -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> 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> response = handler.handleException( + new RuntimeException("boom"), request, servletResponse); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + assertEquals(500, response.getBody().getCode()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/controller/WikiTransformationControllerTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/controller/WikiTransformationControllerTest.java new file mode 100644 index 00000000..fe9c7f87 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/controller/WikiTransformationControllerTest.java @@ -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 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 response = controller.apply( + 99L, Map.of("rawId", 1L, "pageId", 2L), false, 1L); + + assertEquals(400, response.getCode()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/job/strategy/KbModelStrategyConfigTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/job/strategy/KbModelStrategyConfigTest.java new file mode 100644 index 00000000..c5f73e83 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/job/strategy/KbModelStrategyConfigTest.java @@ -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; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiPageServiceTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiPageServiceTest.java new file mode 100644 index 00000000..1ea7a81f --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiPageServiceTest.java @@ -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); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiProcessingServiceLazyTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiProcessingServiceLazyTest.java index a4c50400..f17a8b69 100644 --- a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiProcessingServiceLazyTest.java +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiProcessingServiceLazyTest.java @@ -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 m) -> "lazy".equals(m.get("phase")))); + } + @Test @DisplayName("lazy mode with blank text: marks failed, no chunks persisted") void lazyMode_blankText_failsCleanly() { diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 53db0867..a561e353 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -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) => diff --git a/mateclaw-ui/src/stores/useWikiStore.ts b/mateclaw-ui/src/stores/useWikiStore.ts index 774db087..50d9aa35 100644 --- a/mateclaw-ui/src/stores/useWikiStore.ts +++ b/mateclaw-ui/src/stores/useWikiStore.ts @@ -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, diff --git a/mateclaw-ui/src/views/Wiki/components/RawMaterialPanel.vue b/mateclaw-ui/src/views/Wiki/components/RawMaterialPanel.vue index bff2bb28..6b84df0c 100644 --- a/mateclaw-ui/src/views/Wiki/components/RawMaterialPanel.vue +++ b/mateclaw-ui/src/views/Wiki/components/RawMaterialPanel.vue @@ -297,9 +297,9 @@ const { t } = useI18n() const store = useWikiStore() const fileInput = ref(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 → :18088;EventSource 走相对路径即可 + // 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(