mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(wiki): align HTTP status with R envelope and harden config + edit paths
This commit is contained in:
parent
1bba59fc7e
commit
adf5d93975
@ -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;
|
||||
}
|
||||
}
|
||||
@ -4,20 +4,23 @@ import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.validation.BindException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.context.request.async.AsyncRequestTimeoutException;
|
||||
import org.springframework.web.servlet.resource.NoResourceFoundException;
|
||||
import vip.mate.common.result.R;
|
||||
import vip.mate.i18n.I18nService;
|
||||
|
||||
/**
|
||||
* 全局异常处理器
|
||||
* Global exception handler.
|
||||
* <p>
|
||||
* MateClawException 的中文消息通过 I18nService 查表翻译:
|
||||
* 中文原文作为 key 前缀查 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<Void> handleAsyncTimeout(AsyncRequestTimeoutException e,
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response) {
|
||||
public ResponseEntity<R<Void>> handleAsyncTimeout(AsyncRequestTimeoutException e,
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response) {
|
||||
if (isSseRequest(request) || response.isCommitted()) {
|
||||
log.debug("SSE async timeout (normal lifecycle): {} {}", request.getMethod(), request.getRequestURI());
|
||||
// 不返回任何 body,避免 text/event-stream 无法序列化 R 的问题
|
||||
// 返回 null 让框架自然结束异步请求
|
||||
// Return no body for SSE so the framework can end the async request.
|
||||
return null;
|
||||
}
|
||||
log.warn("Async request timeout: {} {}", request.getMethod(), request.getRequestURI());
|
||||
return R.fail(503, "Request timeout, please try again");
|
||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.body(R.fail(503, "Request timeout, please try again"));
|
||||
}
|
||||
|
||||
@ExceptionHandler(MateClawException.class)
|
||||
public R<Void> handleMateClawException(MateClawException e) {
|
||||
public ResponseEntity<R<Void>> handleMateClawException(MateClawException e) {
|
||||
log.warn("Business exception: [{}] {}", e.getCode(), e.getMessage());
|
||||
String msg = translateExceptionMsg(e);
|
||||
return R.fail(e.getCode(), msg);
|
||||
return ResponseEntity.status(httpStatusForCode(e.getCode())).body(R.fail(e.getCode(), msg));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -66,31 +69,37 @@ public class GlobalExceptionHandler {
|
||||
}
|
||||
|
||||
@ExceptionHandler(BindException.class)
|
||||
public R<Void> handleBindException(BindException e) {
|
||||
public ResponseEntity<R<Void>> handleBindException(BindException e) {
|
||||
String msg = e.getBindingResult().getFieldErrors().stream()
|
||||
.map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
|
||||
.findFirst()
|
||||
.orElse("Validation failed");
|
||||
log.warn("Validation failed: {}", msg);
|
||||
return R.fail(400, msg);
|
||||
return ResponseEntity.badRequest().body(R.fail(400, msg));
|
||||
}
|
||||
|
||||
@ExceptionHandler(NoResourceFoundException.class)
|
||||
public ResponseEntity<R<Void>> handleNoResourceFound(NoResourceFoundException e,
|
||||
HttpServletRequest request) {
|
||||
log.warn("Resource not found: {} {}", request.getMethod(), request.getRequestURI());
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(R.fail(404, "Resource not found"));
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public R<Void> handleException(Exception e,
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response) {
|
||||
// response 已提交或 SSE 请求,不再尝试写 JSON body
|
||||
public ResponseEntity<R<Void>> handleException(Exception e,
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response) {
|
||||
if (response.isCommitted() || isSseRequest(request)) {
|
||||
log.warn("Exception after response committed or during SSE (suppressed): {} {} - {}",
|
||||
request.getMethod(), request.getRequestURI(), e.getMessage());
|
||||
return null;
|
||||
}
|
||||
log.error("Unexpected error: {} {}", request.getMethod(), request.getRequestURI(), e);
|
||||
return R.fail("Internal server error");
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(R.fail("Internal server error"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 SSE 请求:检查 Accept 头 或 已设置的 Content-Type
|
||||
* Identify SSE requests from the Accept header, Content-Type, or known stream path.
|
||||
*/
|
||||
private boolean isSseRequest(HttpServletRequest request) {
|
||||
String accept = request.getHeader("Accept");
|
||||
@ -101,8 +110,13 @@ public class GlobalExceptionHandler {
|
||||
if (contentType != null && contentType.contains(MediaType.TEXT_EVENT_STREAM_VALUE)) {
|
||||
return true;
|
||||
}
|
||||
// 备选:路径匹配
|
||||
// Fallback path match for stream endpoints that omit explicit headers.
|
||||
String uri = request.getRequestURI();
|
||||
return uri != null && uri.contains("/chat/stream");
|
||||
}
|
||||
|
||||
private HttpStatus httpStatusForCode(int code) {
|
||||
HttpStatus status = HttpStatus.resolve(code);
|
||||
return status != null ? status : HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -60,7 +60,7 @@ public class WikiTransformationController {
|
||||
public R<WikiTransformationEntity> get(@PathVariable Long id,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
WikiTransformationEntity t = transformationService.getById(id);
|
||||
if (t == null) return R.fail("Transformation not found");
|
||||
if (t == null) return R.fail(404, "Transformation not found");
|
||||
verifyTemplateWorkspace(t, workspaceId);
|
||||
return R.ok(t);
|
||||
}
|
||||
@ -84,7 +84,7 @@ public class WikiTransformationController {
|
||||
@RequestBody WikiTransformationEntity body,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
WikiTransformationEntity existing = transformationService.getById(id);
|
||||
if (existing == null) return R.fail("Transformation not found");
|
||||
if (existing == null) return R.fail(404, "Transformation not found");
|
||||
verifyTemplateWorkspace(existing, workspaceId);
|
||||
return R.ok(transformationService.update(id, body));
|
||||
}
|
||||
@ -114,16 +114,16 @@ public class WikiTransformationController {
|
||||
@RequestParam(defaultValue = "false") boolean sync,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
WikiTransformationEntity t = transformationService.getById(id);
|
||||
if (t == null) return R.fail("Transformation not found");
|
||||
if (t == null) return R.fail(404, "Transformation not found");
|
||||
verifyTemplateWorkspace(t, workspaceId);
|
||||
|
||||
Object rawIdRaw = body == null ? null : body.get("rawId");
|
||||
Object pageIdRaw = body == null ? null : body.get("pageId");
|
||||
if (rawIdRaw == null && pageIdRaw == null) {
|
||||
return R.fail("One of rawId / pageId is required");
|
||||
return R.fail(400, "One of rawId / pageId is required");
|
||||
}
|
||||
if (rawIdRaw != null && pageIdRaw != null) {
|
||||
return R.fail("Pass only one of rawId / pageId, not both");
|
||||
return R.fail(400, "Pass only one of rawId / pageId, not both");
|
||||
}
|
||||
|
||||
if (rawIdRaw != null) {
|
||||
@ -147,14 +147,14 @@ public class WikiTransformationController {
|
||||
@RequestParam Long kbId,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
WikiTransformationEntity t = transformationService.getById(id);
|
||||
if (t == null) return R.fail("Transformation not found");
|
||||
if (t == null) return R.fail(404, "Transformation not found");
|
||||
verifyTemplateWorkspace(t, workspaceId);
|
||||
verifyKBWorkspace(kbId, workspaceId != null ? workspaceId : 1L);
|
||||
|
||||
try {
|
||||
WikiTransformationAggregator.Result res = aggregator.aggregate(t, kbId, "manual");
|
||||
if (res.pageId() == null) {
|
||||
return R.fail(res.title()); // when sources are empty we put the reason in title field
|
||||
return R.fail(409, res.title()); // when sources are empty we put the reason in title field
|
||||
}
|
||||
return R.ok(Map.of(
|
||||
"pageId", res.pageId(),
|
||||
@ -164,7 +164,7 @@ public class WikiTransformationController {
|
||||
"charsFed", res.charsFed(),
|
||||
"created", res.created()));
|
||||
} catch (IllegalStateException | IllegalArgumentException e) {
|
||||
return R.fail(e.getMessage());
|
||||
return R.fail(400, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@ -175,7 +175,7 @@ public class WikiTransformationController {
|
||||
public R<WikiTransformationRunEntity> getRun(@PathVariable Long runId,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
WikiTransformationRunEntity run = transformationService.getRun(runId);
|
||||
if (run == null) return R.fail("Run not found");
|
||||
if (run == null) return R.fail(404, "Run not found");
|
||||
verifyKBWorkspace(run.getKbId(), workspaceId != null ? workspaceId : 1L);
|
||||
return R.ok(run);
|
||||
}
|
||||
@ -194,7 +194,7 @@ public class WikiTransformationController {
|
||||
}
|
||||
if (transformationId != null) {
|
||||
WikiTransformationEntity t = transformationService.getById(transformationId);
|
||||
if (t == null) return R.fail("Transformation not found");
|
||||
if (t == null) return R.fail(404, "Transformation not found");
|
||||
verifyTemplateWorkspace(t, wsId);
|
||||
return R.ok(transformationService.listRunsByTransformation(transformationId, limit));
|
||||
}
|
||||
@ -202,7 +202,7 @@ public class WikiTransformationController {
|
||||
verifyKBWorkspace(kbId, wsId);
|
||||
return R.ok(transformationService.listRunsByKb(kbId, limit));
|
||||
}
|
||||
return R.fail("One of rawId / kbId / transformationId is required");
|
||||
return R.fail(400, "One of rawId / kbId / transformationId is required");
|
||||
}
|
||||
|
||||
@RequireWorkspaceRole("member")
|
||||
@ -214,10 +214,10 @@ public class WikiTransformationController {
|
||||
public R<Void> cancelRun(@PathVariable Long runId,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
WikiTransformationRunEntity run = transformationService.getRun(runId);
|
||||
if (run == null) return R.fail("Run not found");
|
||||
if (run == null) return R.fail(404, "Run not found");
|
||||
verifyKBWorkspace(run.getKbId(), workspaceId != null ? workspaceId : 1L);
|
||||
boolean cancelled = executor.cancelRun(runId);
|
||||
if (!cancelled) return R.fail("Run is not running");
|
||||
if (!cancelled) return R.fail(409, "Run is not running");
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
@ -228,17 +228,17 @@ public class WikiTransformationController {
|
||||
public R<Map<String, Object>> saveRunAsPage(@PathVariable Long runId,
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
|
||||
WikiTransformationRunEntity run = transformationService.getRun(runId);
|
||||
if (run == null) return R.fail("Run not found");
|
||||
if (run == null) return R.fail(404, "Run not found");
|
||||
verifyKBWorkspace(run.getKbId(), workspaceId != null ? workspaceId : 1L);
|
||||
try {
|
||||
var page = executor.manualSaveRunAsPage(runId);
|
||||
if (page == null) return R.fail("Page service unavailable");
|
||||
if (page == null) return R.fail(503, "Page service unavailable");
|
||||
return R.ok(Map.of(
|
||||
"pageId", page.getId(),
|
||||
"slug", page.getSlug(),
|
||||
"title", page.getTitle()));
|
||||
} catch (IllegalStateException | IllegalArgumentException e) {
|
||||
return R.fail(e.getMessage());
|
||||
return R.fail(400, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,7 @@ import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.wiki.job.WikiJobStep;
|
||||
import vip.mate.wiki.job.WikiKbConfig;
|
||||
import vip.mate.wiki.job.WikiKbConfigParser;
|
||||
import vip.mate.wiki.job.model.WikiProcessingJobEntity;
|
||||
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
|
||||
|
||||
@ -30,15 +31,11 @@ public class KbConfigStepModelStrategy implements WikiStepModelStrategy {
|
||||
@Override
|
||||
public Long selectModelId(WikiProcessingJobEntity job, WikiKnowledgeBaseEntity kb, WikiJobStep step) {
|
||||
if (kb == null || kb.getConfigContent() == null) return null;
|
||||
try {
|
||||
WikiKbConfig config = objectMapper.readValue(kb.getConfigContent(), WikiKbConfig.class);
|
||||
Map<String, Long> stepModels = config.getStepModels();
|
||||
if (stepModels == null) return null;
|
||||
String key = job.getJobType() + "." + step.name().toLowerCase();
|
||||
return stepModels.get(key);
|
||||
} catch (Exception e) {
|
||||
log.debug("[KbConfigStrategy] Failed to parse KB config: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
WikiKbConfig config = WikiKbConfigParser.parse(objectMapper, kb.getConfigContent());
|
||||
if (config == null) return null;
|
||||
Map<String, Long> stepModels = config.getStepModels();
|
||||
if (stepModels == null) return null;
|
||||
String key = job.getJobType() + "." + step.name().toLowerCase();
|
||||
return stepModels.get(key);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -18,6 +18,7 @@ import vip.mate.llm.service.ModelConfigService;
|
||||
import vip.mate.wiki.WikiProperties;
|
||||
import vip.mate.wiki.dto.WikiChunkDraft;
|
||||
import vip.mate.wiki.job.WikiKbConfig;
|
||||
import vip.mate.wiki.job.WikiKbConfigParser;
|
||||
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
|
||||
import vip.mate.wiki.model.WikiPageEntity;
|
||||
import vip.mate.wiki.model.WikiRawMaterialEntity;
|
||||
@ -162,26 +163,25 @@ public class WikiProcessingService {
|
||||
private final ConcurrentHashMap<Long, ProgressCounter> progressCounters = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 处理单个原始材料
|
||||
* Process one raw material.
|
||||
*/
|
||||
public void processRawMaterial(Long rawId) {
|
||||
processRawMaterial(rawId, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理单个原始材料(支持强制重跑)
|
||||
* Process one raw material, optionally bypassing the content-hash shortcut.
|
||||
*
|
||||
* @param rawId 材料 ID
|
||||
* @param force 为 true 时忽略 content_hash 短路(RFC-012 Change 5),用于模型/提示词变更后的强制重跑
|
||||
* @param rawId raw material ID
|
||||
* @param force when true, ignore content_hash so model or prompt changes can re-run the full pipeline
|
||||
*/
|
||||
public void processRawMaterial(Long rawId, boolean force) {
|
||||
// RFC-012 follow-up #3:消费续传标志(reprocess() 把 partial 改回 pending 之前打的标)。
|
||||
// 必须在 claimForProcessing 之前读,因为 claim 会把状态再改一次。
|
||||
// flag 只在内存中,server 重启会丢 → 重启后仍按 pending 走正常流程(退化为全量重跑,
|
||||
// 功能不丢失只是性能回退)。
|
||||
// Consume the partial-resume marker before claimForProcessing changes
|
||||
// the row status again. The marker is in-memory only; after a restart
|
||||
// the pending row falls back to a full re-run.
|
||||
boolean isPartialResume = rawService.consumePartialResumeFlag(rawId);
|
||||
|
||||
// CAS 式抢占:防止并发重复处理
|
||||
// Claim once so concurrent workers do not process the same raw twice.
|
||||
if (!rawService.claimForProcessing(rawId)) {
|
||||
log.debug("[Wiki] Raw material {} already claimed or not pending, skipping", rawId);
|
||||
return;
|
||||
@ -193,7 +193,7 @@ public class WikiProcessingService {
|
||||
return;
|
||||
}
|
||||
|
||||
// RFC-012 Change 5:若 content_hash 与上次成功处理时一致,直接短路
|
||||
// Skip unchanged content unless the caller requested a forced re-run.
|
||||
if (!force
|
||||
&& raw.getContentHash() != null
|
||||
&& raw.getContentHash().equals(raw.getLastProcessedHash())) {
|
||||
@ -210,15 +210,14 @@ public class WikiProcessingService {
|
||||
|
||||
kbService.updateStatus(kb.getId(), "processing");
|
||||
|
||||
// RFC-051 PR-2: every ingest path opens with a scaffold check so older
|
||||
// KBs get their overview / log pages on first use without a manual step.
|
||||
// Every ingest path opens with a scaffold check so older KBs get their
|
||||
// overview / log pages on first use without a manual step.
|
||||
if (scaffoldService != null) {
|
||||
scaffoldService.ensureScaffold(kb.getId());
|
||||
}
|
||||
|
||||
// RFC-051 PR-1b: lazy ingest short-circuit. Per KB config, skip the heavy
|
||||
// pipeline entirely: extract → chunk → embed → completed. 0 pages is the
|
||||
// expected outcome, not a failure. ingestMode==null keeps existing behavior.
|
||||
// Lazy ingest skips the heavy generation pipeline: extract, chunk,
|
||||
// embed, completed. Zero pages is expected, not a failure.
|
||||
if ("lazy".equals(resolveIngestMode(kb))) {
|
||||
processLazyIngest(kb, raw);
|
||||
return;
|
||||
@ -362,7 +361,7 @@ public class WikiProcessingService {
|
||||
} else {
|
||||
rawService.updateProcessingStatus(rawId, "completed", null);
|
||||
finalStatus = "completed";
|
||||
// RFC-012 Change 5:记录本次成功处理时的 hash,供下次短路判断
|
||||
// Record the successful hash for the next unchanged-content shortcut.
|
||||
if (raw.getContentHash() != null) {
|
||||
rawService.setLastProcessedHash(rawId, raw.getContentHash());
|
||||
}
|
||||
@ -596,9 +595,9 @@ public class WikiProcessingService {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// RFC-051 PR-9: skip remaining chunks if the user deleted the raw
|
||||
// while earlier chunks were still in flight. Counts as a "failed chunk"
|
||||
// for terminal-status accounting (not actually failed, just abandoned).
|
||||
// Skip remaining chunks if the user deleted the raw while
|
||||
// earlier chunks were still in flight. Counts as a failed
|
||||
// chunk for terminal-status accounting.
|
||||
if (isAborted(raw.getId(), "chunk " + (chunkIndex + 1) + "/" + totalChunks)) {
|
||||
failedChunks.incrementAndGet();
|
||||
return;
|
||||
@ -1326,9 +1325,9 @@ public class WikiProcessingService {
|
||||
Long kbId = kb.getId();
|
||||
Long rawId = raw.getId();
|
||||
|
||||
// RFC-051 PR-9: refuse to materialize a page (or merge into an existing one) tied
|
||||
// to a raw the user just deleted. Prevents zombie pages whose source_raw_ids point
|
||||
// at a tombstoned row.
|
||||
// Refuse to materialize a page, or merge into an existing one, for a
|
||||
// raw the user just deleted. This prevents pages whose source_raw_ids
|
||||
// point at a tombstoned row.
|
||||
if (isAborted(rawId, "savePageContent slug=" + slug)) return false;
|
||||
|
||||
// Fallback 0: cross-spelling canonical match (DB has same concept under different slug)
|
||||
@ -2181,47 +2180,38 @@ public class WikiProcessingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-051 PR-6b follow-up: KB-level override for structured route output,
|
||||
* falling back to the global property when the KB hasn't set a preference.
|
||||
* Parse failures fall back to global too — never block ingest on bad config.
|
||||
* KB-level override for structured route output, falling back to the global
|
||||
* property when the KB has not set a preference. Parse failures fall back to
|
||||
* global too; ingestion must not be blocked by bad optional config.
|
||||
*/
|
||||
private boolean resolveStructuredRouteFlag(WikiKnowledgeBaseEntity kb) {
|
||||
boolean fallback = properties.isUseStructuredRoute();
|
||||
if (kb == null || kb.getConfigContent() == null) return fallback;
|
||||
try {
|
||||
WikiKbConfig config = objectMapper.readValue(kb.getConfigContent(), WikiKbConfig.class);
|
||||
return config.getUseStructuredRoute() != null
|
||||
? config.getUseStructuredRoute()
|
||||
: fallback;
|
||||
} catch (Exception e) {
|
||||
return fallback;
|
||||
}
|
||||
WikiKbConfig config = WikiKbConfigParser.parse(objectMapper, kb.getConfigContent());
|
||||
return config != null && config.getUseStructuredRoute() != null
|
||||
? config.getUseStructuredRoute()
|
||||
: fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-051 PR-1b: read {@code ingestMode} from KB config JSON. Returns null
|
||||
* on any parse error or missing field so the caller falls through to eager.
|
||||
* Read {@code ingestMode} from KB config JSON or markdown frontmatter.
|
||||
* Returns null on any parse error or missing field so the caller falls
|
||||
* through to eager mode.
|
||||
*/
|
||||
private String resolveIngestMode(WikiKnowledgeBaseEntity kb) {
|
||||
if (kb == null || kb.getConfigContent() == null) return null;
|
||||
try {
|
||||
WikiKbConfig config = objectMapper.readValue(kb.getConfigContent(), WikiKbConfig.class);
|
||||
return config.getIngestMode();
|
||||
} catch (Exception e) {
|
||||
log.warn("[Wiki] Failed to parse KB config for ingest mode, falling back to eager: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
WikiKbConfig config = WikiKbConfigParser.parse(objectMapper, kb.getConfigContent());
|
||||
return config != null ? config.getIngestMode() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-051 PR-9: returns {@code true} when the caller should bail out of an
|
||||
* in-flight processing path because the raw material has been deleted.
|
||||
* Returns {@code true} when the caller should bail out of an in-flight
|
||||
* processing path because the raw material has been deleted.
|
||||
* <p>
|
||||
* {@link WikiRawMaterialService#delete(Long)} is a logical delete (the
|
||||
* {@code @TableLogic} column flips to 1), so {@code selectById} returns
|
||||
* {@code null} as soon as the deletion commits. Sprinkling this check
|
||||
* right before each LLM call keeps token spend bounded by a single
|
||||
* in-flight call after the user clicks delete.
|
||||
* {@link WikiRawMaterialService#delete(Long)} is a logical delete. The row
|
||||
* disappears from normal lookups as soon as deletion commits. Checking
|
||||
* before each LLM call keeps token spend bounded by a single in-flight call
|
||||
* after the user clicks delete.
|
||||
*
|
||||
* @param rawId the raw material id this processing path is about
|
||||
* @param ctx short string used in the log line
|
||||
@ -2269,12 +2259,11 @@ public class WikiProcessingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-051 PR-1b: lazy ingest — chunk + embed, no page generation.
|
||||
* Lazy ingest: chunk and embed, without page generation.
|
||||
* <p>
|
||||
* Intentionally minimal: reuses the legacy {@code persistChunks(List<String>, offsets)}
|
||||
* overload (no structural metadata; that lands in PR-1c with the preprocessor)
|
||||
* and the existing {@code embedMissingChunks} entry point. Zero pages is the
|
||||
* expected outcome, not a failure.
|
||||
* overload and the existing {@code embedMissingChunks} entry point. Zero
|
||||
* pages is the expected outcome, not a failure.
|
||||
*/
|
||||
private void processLazyIngest(WikiKnowledgeBaseEntity kb, WikiRawMaterialEntity raw) {
|
||||
Long rawId = raw.getId();
|
||||
@ -2295,9 +2284,9 @@ public class WikiProcessingService {
|
||||
return;
|
||||
}
|
||||
|
||||
// RFC-051 PR-9: text extraction can take many seconds on large binaries.
|
||||
// If the user deleted the raw during that window, persisting chunks for a
|
||||
// tombstoned row is wasted work that the cascade-cleanup already covered.
|
||||
// Text extraction can take many seconds on large binaries. If the
|
||||
// user deleted the raw during that window, persisting chunks for a
|
||||
// tombstoned row is wasted work already covered by cascade cleanup.
|
||||
if (isAborted(rawId, "lazy ingest after extract")) {
|
||||
kbService.updateStatus(kbId, "active");
|
||||
return;
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -86,6 +86,20 @@ class WikiProcessingServiceLazyTest {
|
||||
return k;
|
||||
}
|
||||
|
||||
private WikiKnowledgeBaseEntity markdownKbWithFrontmatter(String ingestMode) {
|
||||
WikiKnowledgeBaseEntity k = new WikiKnowledgeBaseEntity();
|
||||
k.setId(KB_ID);
|
||||
k.setConfigContent("""
|
||||
---
|
||||
ingestMode: %s
|
||||
---
|
||||
# Wiki Processing Rules
|
||||
|
||||
Keep pages concise.
|
||||
""".formatted(ingestMode));
|
||||
return k;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("lazy mode: skips LLM pipeline, persists chunks, marks completed with 0 pages")
|
||||
void lazyMode_noLlmCalls() throws InterruptedException {
|
||||
@ -136,6 +150,34 @@ class WikiProcessingServiceLazyTest {
|
||||
verify(kbService).updateStatus(KB_ID, "active");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("lazy mode from markdown frontmatter: skips LLM pipeline")
|
||||
void markdownFrontmatterLazyMode_noLlmCalls() throws InterruptedException {
|
||||
WikiRawMaterialEntity rawEntity = raw();
|
||||
WikiKnowledgeBaseEntity kbEntity = markdownKbWithFrontmatter("lazy");
|
||||
|
||||
when(rawService.claimForProcessing(RAW_ID)).thenReturn(true);
|
||||
when(rawService.getById(RAW_ID)).thenReturn(rawEntity);
|
||||
when(rawService.getTextContent(rawEntity)).thenReturn("Some document text for lazy ingest. ".repeat(20));
|
||||
when(kbService.getById(KB_ID)).thenReturn(kbEntity);
|
||||
when(pageService.countByKbId(KB_ID)).thenReturn(0);
|
||||
|
||||
CountDownLatch embedCalled = new CountDownLatch(1);
|
||||
when(embeddingService.embedMissingChunks(KB_ID)).thenAnswer(inv -> {
|
||||
embedCalled.countDown();
|
||||
return 0;
|
||||
});
|
||||
|
||||
service.processRawMaterial(RAW_ID);
|
||||
|
||||
verify(chunkService, times(1)).persistChunks(eq(KB_ID), eq(RAW_ID), anyList(), anyList());
|
||||
verify(pageService, never()).deleteExclusiveBySourceRawId(anyLong(), anyLong());
|
||||
assertTrue(embedCalled.await(5, TimeUnit.SECONDS), "embedMissingChunks should have been invoked");
|
||||
verify(progressBus).broadcast(eq(KB_ID),
|
||||
eq(WikiProgressBus.EVENT_RAW_STARTED),
|
||||
argThat((Map<String, Object> m) -> "lazy".equals(m.get("phase"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("lazy mode with blank text: marks failed, no chunks persisted")
|
||||
void lazyMode_blankText_failsCleanly() {
|
||||
|
||||
@ -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) =>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -297,9 +297,9 @@ const { t } = useI18n()
|
||||
const store = useWikiStore()
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// RFC-012 M3:当列表中存在 processing 的材料时,优先订阅后端 SSE 实时进度流,
|
||||
// 60s 兜底拉取 processingStatus / fetchRawMaterials 作为 SSE 断线降级(DB 是真源)。
|
||||
// 处理完毕(无 processing 项)自动断开 SSE + 停止兜底轮询;组件卸载时也会清理。
|
||||
// While raw materials are active, subscribe to the backend SSE progress stream.
|
||||
// A slower polling fallback keeps the UI in sync if SSE reconnects or misses a
|
||||
// terminal event. The database remains the source of truth.
|
||||
let sse: EventSource | null = null
|
||||
let fallbackTimer: number | null = null
|
||||
let activeKbId: number | null = null
|
||||
@ -319,7 +319,7 @@ function applyProgressEvent(payload: any) {
|
||||
function openSse(kbId: number) {
|
||||
closeSse()
|
||||
activeKbId = kbId
|
||||
// Vite 代理 /api → :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(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user