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 jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindException; import org.springframework.validation.BindException;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.async.AsyncRequestTimeoutException; import org.springframework.web.context.request.async.AsyncRequestTimeoutException;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import vip.mate.common.result.R; import vip.mate.common.result.R;
import vip.mate.i18n.I18nService; import vip.mate.i18n.I18nService;
/** /**
* 全局异常处理器 * Global exception handler.
* <p> * <p>
* MateClawException 的中文消息通过 I18nService 查表翻译 * MateClawException messages are translated through I18nService when the
* 中文原文作为 key 前缀查 propertieserr.msg.{hash} * 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 * @author MateClaw Team
*/ */
@ -29,24 +32,24 @@ public class GlobalExceptionHandler {
private final I18nService i18nService; private final I18nService i18nService;
@ExceptionHandler(AsyncRequestTimeoutException.class) @ExceptionHandler(AsyncRequestTimeoutException.class)
public R<Void> handleAsyncTimeout(AsyncRequestTimeoutException e, public ResponseEntity<R<Void>> handleAsyncTimeout(AsyncRequestTimeoutException e,
HttpServletRequest request, HttpServletRequest request,
HttpServletResponse response) { HttpServletResponse response) {
if (isSseRequest(request) || response.isCommitted()) { if (isSseRequest(request) || response.isCommitted()) {
log.debug("SSE async timeout (normal lifecycle): {} {}", request.getMethod(), request.getRequestURI()); log.debug("SSE async timeout (normal lifecycle): {} {}", request.getMethod(), request.getRequestURI());
// 不返回任何 body避免 text/event-stream 无法序列化 R 的问题 // Return no body for SSE so the framework can end the async request.
// 返回 null 让框架自然结束异步请求
return null; return null;
} }
log.warn("Async request timeout: {} {}", request.getMethod(), request.getRequestURI()); 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) @ExceptionHandler(MateClawException.class)
public R<Void> handleMateClawException(MateClawException e) { public ResponseEntity<R<Void>> handleMateClawException(MateClawException e) {
log.warn("Business exception: [{}] {}", e.getCode(), e.getMessage()); log.warn("Business exception: [{}] {}", e.getCode(), e.getMessage());
String msg = translateExceptionMsg(e); 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) @ExceptionHandler(BindException.class)
public R<Void> handleBindException(BindException e) { public ResponseEntity<R<Void>> handleBindException(BindException e) {
String msg = e.getBindingResult().getFieldErrors().stream() String msg = e.getBindingResult().getFieldErrors().stream()
.map(fe -> fe.getField() + ": " + fe.getDefaultMessage()) .map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
.findFirst() .findFirst()
.orElse("Validation failed"); .orElse("Validation failed");
log.warn("Validation failed: {}", msg); 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) @ExceptionHandler(Exception.class)
public R<Void> handleException(Exception e, public ResponseEntity<R<Void>> handleException(Exception e,
HttpServletRequest request, HttpServletRequest request,
HttpServletResponse response) { HttpServletResponse response) {
// response 已提交或 SSE 请求不再尝试写 JSON body
if (response.isCommitted() || isSseRequest(request)) { if (response.isCommitted() || isSseRequest(request)) {
log.warn("Exception after response committed or during SSE (suppressed): {} {} - {}", log.warn("Exception after response committed or during SSE (suppressed): {} {} - {}",
request.getMethod(), request.getRequestURI(), e.getMessage()); request.getMethod(), request.getRequestURI(), e.getMessage());
return null; return null;
} }
log.error("Unexpected error: {} {}", request.getMethod(), request.getRequestURI(), e); 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) { private boolean isSseRequest(HttpServletRequest request) {
String accept = request.getHeader("Accept"); String accept = request.getHeader("Accept");
@ -101,8 +110,13 @@ public class GlobalExceptionHandler {
if (contentType != null && contentType.contains(MediaType.TEXT_EVENT_STREAM_VALUE)) { if (contentType != null && contentType.contains(MediaType.TEXT_EVENT_STREAM_VALUE)) {
return true; return true;
} }
// 备选路径匹配 // Fallback path match for stream endpoints that omit explicit headers.
String uri = request.getRequestURI(); String uri = request.getRequestURI();
return uri != null && uri.contains("/chat/stream"); 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)) { if ("text".equals(sourceType)) {
// 文本文件直接读取内容 // Text files can be stored directly without staging to disk.
String content = new String(file.getBytes(), StandardCharsets.UTF_8); String content = new String(file.getBytes(), StandardCharsets.UTF_8);
return R.ok(rawService.addText(kbId, originalName, content)); return R.ok(rawService.addText(kbId, originalName, content));
} else { } 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(); Path uploadDir = Paths.get(properties.getUploadDir()).toAbsolutePath().normalize();
Files.createDirectories(uploadDir); Files.createDirectories(uploadDir);
Path targetPath = uploadDir.resolve(System.currentTimeMillis() + "_" + originalName); Path targetPath = uploadDir.resolve(System.currentTimeMillis() + "_" + originalName);
@ -292,7 +293,7 @@ public class WikiController {
if (raw == null || !kbId.equals(raw.getKbId())) { if (raw == null || !kbId.equals(raw.getKbId())) {
return R.fail("Raw material not found in this knowledge base"); 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) { if (force) {
rawService.setLastProcessedHash(rawId, null); rawService.setLastProcessedHash(rawId, null);
} }

View File

@ -60,7 +60,7 @@ public class WikiTransformationController {
public R<WikiTransformationEntity> get(@PathVariable Long id, public R<WikiTransformationEntity> get(@PathVariable Long id,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
WikiTransformationEntity t = transformationService.getById(id); 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); verifyTemplateWorkspace(t, workspaceId);
return R.ok(t); return R.ok(t);
} }
@ -84,7 +84,7 @@ public class WikiTransformationController {
@RequestBody WikiTransformationEntity body, @RequestBody WikiTransformationEntity body,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
WikiTransformationEntity existing = transformationService.getById(id); 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); verifyTemplateWorkspace(existing, workspaceId);
return R.ok(transformationService.update(id, body)); return R.ok(transformationService.update(id, body));
} }
@ -114,16 +114,16 @@ public class WikiTransformationController {
@RequestParam(defaultValue = "false") boolean sync, @RequestParam(defaultValue = "false") boolean sync,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
WikiTransformationEntity t = transformationService.getById(id); 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); verifyTemplateWorkspace(t, workspaceId);
Object rawIdRaw = body == null ? null : body.get("rawId"); Object rawIdRaw = body == null ? null : body.get("rawId");
Object pageIdRaw = body == null ? null : body.get("pageId"); Object pageIdRaw = body == null ? null : body.get("pageId");
if (rawIdRaw == null && pageIdRaw == null) { 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) { 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) { if (rawIdRaw != null) {
@ -147,14 +147,14 @@ public class WikiTransformationController {
@RequestParam Long kbId, @RequestParam Long kbId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
WikiTransformationEntity t = transformationService.getById(id); 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); verifyTemplateWorkspace(t, workspaceId);
verifyKBWorkspace(kbId, workspaceId != null ? workspaceId : 1L); verifyKBWorkspace(kbId, workspaceId != null ? workspaceId : 1L);
try { try {
WikiTransformationAggregator.Result res = aggregator.aggregate(t, kbId, "manual"); WikiTransformationAggregator.Result res = aggregator.aggregate(t, kbId, "manual");
if (res.pageId() == null) { 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( return R.ok(Map.of(
"pageId", res.pageId(), "pageId", res.pageId(),
@ -164,7 +164,7 @@ public class WikiTransformationController {
"charsFed", res.charsFed(), "charsFed", res.charsFed(),
"created", res.created())); "created", res.created()));
} catch (IllegalStateException | IllegalArgumentException e) { } 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, public R<WikiTransformationRunEntity> getRun(@PathVariable Long runId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
WikiTransformationRunEntity run = transformationService.getRun(runId); 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); verifyKBWorkspace(run.getKbId(), workspaceId != null ? workspaceId : 1L);
return R.ok(run); return R.ok(run);
} }
@ -194,7 +194,7 @@ public class WikiTransformationController {
} }
if (transformationId != null) { if (transformationId != null) {
WikiTransformationEntity t = transformationService.getById(transformationId); 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); verifyTemplateWorkspace(t, wsId);
return R.ok(transformationService.listRunsByTransformation(transformationId, limit)); return R.ok(transformationService.listRunsByTransformation(transformationId, limit));
} }
@ -202,7 +202,7 @@ public class WikiTransformationController {
verifyKBWorkspace(kbId, wsId); verifyKBWorkspace(kbId, wsId);
return R.ok(transformationService.listRunsByKb(kbId, limit)); 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") @RequireWorkspaceRole("member")
@ -214,10 +214,10 @@ public class WikiTransformationController {
public R<Void> cancelRun(@PathVariable Long runId, public R<Void> cancelRun(@PathVariable Long runId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
WikiTransformationRunEntity run = transformationService.getRun(runId); 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); verifyKBWorkspace(run.getKbId(), workspaceId != null ? workspaceId : 1L);
boolean cancelled = executor.cancelRun(runId); 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(); return R.ok();
} }
@ -228,17 +228,17 @@ public class WikiTransformationController {
public R<Map<String, Object>> saveRunAsPage(@PathVariable Long runId, public R<Map<String, Object>> saveRunAsPage(@PathVariable Long runId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
WikiTransformationRunEntity run = transformationService.getRun(runId); 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); verifyKBWorkspace(run.getKbId(), workspaceId != null ? workspaceId : 1L);
try { try {
var page = executor.manualSaveRunAsPage(runId); 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( return R.ok(Map.of(
"pageId", page.getId(), "pageId", page.getId(),
"slug", page.getSlug(), "slug", page.getSlug(),
"title", page.getTitle())); "title", page.getTitle()));
} catch (IllegalStateException | IllegalArgumentException e) { } 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 org.springframework.stereotype.Component;
import vip.mate.wiki.job.WikiJobStep; import vip.mate.wiki.job.WikiJobStep;
import vip.mate.wiki.job.WikiKbConfig; import vip.mate.wiki.job.WikiKbConfig;
import vip.mate.wiki.job.WikiKbConfigParser;
import vip.mate.wiki.job.model.WikiProcessingJobEntity; import vip.mate.wiki.job.model.WikiProcessingJobEntity;
import vip.mate.wiki.model.WikiKnowledgeBaseEntity; import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
@ -30,15 +31,11 @@ public class KbConfigStepModelStrategy implements WikiStepModelStrategy {
@Override @Override
public Long selectModelId(WikiProcessingJobEntity job, WikiKnowledgeBaseEntity kb, WikiJobStep step) { public Long selectModelId(WikiProcessingJobEntity job, WikiKnowledgeBaseEntity kb, WikiJobStep step) {
if (kb == null || kb.getConfigContent() == null) return null; if (kb == null || kb.getConfigContent() == null) return null;
try { WikiKbConfig config = WikiKbConfigParser.parse(objectMapper, kb.getConfigContent());
WikiKbConfig config = objectMapper.readValue(kb.getConfigContent(), WikiKbConfig.class); if (config == null) return null;
Map<String, Long> stepModels = config.getStepModels(); Map<String, Long> stepModels = config.getStepModels();
if (stepModels == null) return null; if (stepModels == null) return null;
String key = job.getJobType() + "." + step.name().toLowerCase(); String key = job.getJobType() + "." + step.name().toLowerCase();
return stepModels.get(key); return stepModels.get(key);
} catch (Exception e) {
log.debug("[KbConfigStrategy] Failed to parse KB config: {}", e.getMessage());
return null;
}
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -26,7 +26,8 @@ export interface WikiRawMaterial {
lastProcessedAt: string | null lastProcessedAt: string | null
errorMessage: string | null errorMessage: string | null
createTime: string 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 progressPhase: string | null
progressTotal: number progressTotal: number
progressDone: number progressDone: number
@ -46,16 +47,16 @@ export interface WikiPage {
version: number version: number
lastUpdatedBy: string lastUpdatedBy: string
pageType?: string | null pageType?: string | null
// RFC-051 PR-2: locked=1 blocks AI/tool/UI deletion (combined with pageType=system // locked=1 blocks AI/tool/UI deletion. System pages are locked by default,
// for the built-in overview/log pages, but users can lock any page). // but users can lock any page.
locked?: number | null 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 archived?: number | null
createTime: string createTime: string
updateTime: 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 { export function isProtectedPage(page: WikiPage | null | undefined): boolean {
if (!page) return false if (!page) return false
if (page.pageType === 'system') return true if (page.pageType === 'system') return true
@ -128,6 +129,23 @@ export const useWikiStore = defineStore('wiki', () => {
if (!rawId) totalPageCount.value = pages.value.length 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) { async function filterPagesByRaw(kbId: number, rawId: number) {
selectedRawId.value = rawId selectedRawId.value = rawId
await fetchPages(kbId, rawId) await fetchPages(kbId, rawId)
@ -173,7 +191,7 @@ export const useWikiStore = defineStore('wiki', () => {
async function scanDirectory(kbId: number) { async function scanDirectory(kbId: number) {
const res: any = await wikiApi.scanDirectory(kbId) const res: any = await wikiApi.scanDirectory(kbId)
const result = res.data || res const result = res.data || res
// 扫描后刷新材料列表 // Refresh materials after the scan imports new rows.
await fetchRawMaterials(kbId) await fetchRawMaterials(kbId)
return result return result
} }
@ -194,6 +212,7 @@ export const useWikiStore = defineStore('wiki', () => {
backToLibrary, backToLibrary,
fetchRawMaterials, fetchRawMaterials,
fetchPages, fetchPages,
refreshCurrentKB,
filterPagesByRaw, filterPagesByRaw,
clearRawFilter, clearRawFilter,
loadPage, loadPage,

View File

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