diff --git a/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/facade/DifyKnowledgeRuntimeClient.java b/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/facade/DifyKnowledgeRuntimeClient.java index df4b960a..fa53f9b5 100644 --- a/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/facade/DifyKnowledgeRuntimeClient.java +++ b/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/facade/DifyKnowledgeRuntimeClient.java @@ -30,7 +30,7 @@ import java.util.UUID; /** * Dify Datasets API 的 Knowledge 运行时 adapter。 * - *

HTTP 范式对齐 {@code RealDifyMuseAiRuntimeClient} / {@code HttpRagFlowKnowledgeRuntimeClient}: + *

HTTP 范式对齐 {@code RealDifyMuseAiRuntimeClient}: * JDK {@link HttpClient} + 直连 {@link ProxySelector}(内网 Dify 必须绕过系统代理,否则 live 调用被污染成假 502)+ * Bearer(Datasets API key)+ 脱敏审计 + fail-closed 错误映射。契约以 S1 实测的 {@code P1rDifyDatasetsContractLiveIT} 为准。

* diff --git a/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/facade/DifyKnowledgeRuntimeClientConfiguration.java b/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/facade/DifyKnowledgeRuntimeClientConfiguration.java index 33c8ccf6..71c7fe65 100644 --- a/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/facade/DifyKnowledgeRuntimeClientConfiguration.java +++ b/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/facade/DifyKnowledgeRuntimeClientConfiguration.java @@ -10,16 +10,17 @@ import org.springframework.util.StringUtils; import java.time.Duration; /** - * Dify Datasets runtime client 的 Spring 装配配置。 + * Dify Datasets runtime client 的 Spring 装配配置(S6d 起为 Knowledge 缺省运行时)。 * - *

与 {@link RagFlowKnowledgeRuntimeClientConfiguration} 共存(RAGFlow 装配 S6d 才摘除)。为确保单人只装一个 - * {@link KnowledgeRuntimeClient},两个装配类按 provider 选择器 {@code muse.knowledge.runtime-provider} 互斥: - * 本类仅在 {@code =dify} 时参与,RAGFlow 装配在 {@code =ragflow} 或缺省(matchIfMissing)时参与。这样 - * {@code @ConditionalOnMissingBean} 的求值顺序不再影响结果,避免组件扫描顺序带来的非确定性冲突。

+ *

S6d 摘除 RAGFlow 运行时后,本类是唯一的具体 {@link KnowledgeRuntimeClient} 装配来源,按 provider 选择器 + * {@code muse.knowledge.runtime-provider} 生效:取 {@code dify} 或缺省({@code matchIfMissing=true},即未显式配置时 + * 默认 Dify)时装 Dify 主 bean;显式配成其它值(如遗留的 {@code ragflow},其运行时已删)时主 bean 让位,由下方 + * {@code @ConditionalOnMissingBean} 兜底装通用的 {@link UnavailableKnowledgeRuntimeClient}。两个 @Bean 同处一类、 + * 兜底声明在主 bean 之后,{@code @ConditionalOnMissingBean} 按声明顺序求值,确保任何 provider 取值下都有且仅有 + * 一个 bean,绝不因无 bean 致启动失败。

* - *

装配范式对齐 RAGFlow(无 {@code .enable} 开关):读 {@code muse.knowledge.dify.*};缺 base-url 或 - * dataset-api-key 一律装 {@link UnavailableKnowledgeRuntimeClient}(fail-closed,即"选了 Dify 但配错=拒绝", - * 绝不静默回退 RAGFlow)。

+ *

读 {@code muse.knowledge.dify.*};缺 base-url 或 dataset-api-key 一律装 {@link UnavailableKnowledgeRuntimeClient} + * (fail-closed,即"选了 Dify 但配错=拒绝",绝不静默降级)。

*/ @Configuration(proxyBeanMethods = false) public class DifyKnowledgeRuntimeClientConfiguration { @@ -30,7 +31,7 @@ public class DifyKnowledgeRuntimeClientConfiguration { @Bean @ConditionalOnMissingBean(KnowledgeRuntimeClient.class) - @ConditionalOnProperty(name = "muse.knowledge.runtime-provider", havingValue = "dify") + @ConditionalOnProperty(name = "muse.knowledge.runtime-provider", havingValue = "dify", matchIfMissing = true) public KnowledgeRuntimeClient difyKnowledgeRuntimeClient(Environment environment) { String baseUrl = environment.getProperty(PROPERTY_PREFIX + "base-url"); String apiKey = environment.getProperty(PROPERTY_PREFIX + "dataset-api-key"); @@ -43,6 +44,17 @@ public class DifyKnowledgeRuntimeClientConfiguration { return new DifyKnowledgeRuntimeClient(baseUrl, apiKey, Duration.ofSeconds(timeoutSeconds), retryBudget); } + /** + * 通用不可用兜底 bean:仅当上面 Dify 主 bean 未装成(provider 显式配成非 dify 值,如遗留 {@code ragflow})时生效。 + * 声明在主 bean 之后,{@code @ConditionalOnMissingBean} 按同类内 @Bean 声明顺序求值,保证 provider 任意取值下 + * 始终恰好一个 {@link KnowledgeRuntimeClient},既不因无 bean 启动失败,也不静默降级(桩返回 CONFIG_MISSING)。 + */ + @Bean + @ConditionalOnMissingBean(KnowledgeRuntimeClient.class) + public KnowledgeRuntimeClient unavailableKnowledgeRuntimeClient() { + return new UnavailableKnowledgeRuntimeClient(); + } + private int positiveInt(Environment environment, String propertyName, int defaultValue) { Integer value = environment.getProperty(PROPERTY_PREFIX + propertyName, Integer.class, defaultValue); return value == null || value <= 0 ? defaultValue : value; diff --git a/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/facade/HttpRagFlowKnowledgeRuntimeClient.java b/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/facade/HttpRagFlowKnowledgeRuntimeClient.java deleted file mode 100644 index ae9d6248..00000000 --- a/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/facade/HttpRagFlowKnowledgeRuntimeClient.java +++ /dev/null @@ -1,582 +0,0 @@ -package cn.iocoder.muse.module.knowledge.application.muse.facade; - -import cn.iocoder.muse.framework.common.util.json.JsonUtils; -import com.fasterxml.jackson.databind.JsonNode; -import org.springframework.util.StringUtils; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.net.ProxySelector; -import java.net.URLEncoder; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpHeaders; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.time.Duration; -import java.time.Instant; -import java.util.HexFormat; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Set; -import java.util.UUID; - -/** - * RAGFlow HTTP runtime client。 - */ -public class HttpRagFlowKnowledgeRuntimeClient implements KnowledgeRuntimeClient { - - private final String baseUrl; - private final String apiKey; - private final Duration timeout; - private final int retryBudget; - private final boolean graphRagAttributionReady; - private final HttpClient httpClient; - private static final ProxySelector DIRECT_PROXY_SELECTOR = ProxySelector.of(null); - - public HttpRagFlowKnowledgeRuntimeClient(String baseUrl, String apiKey, Duration timeout, - int retryBudget, boolean graphRagAttributionReady) { - this.baseUrl = normalizeBaseUrl(baseUrl); - this.apiKey = apiKey; - this.timeout = timeout == null ? Duration.ofSeconds(5) : timeout; - this.retryBudget = Math.max(retryBudget, 0); - this.graphRagAttributionReady = graphRagAttributionReady; - this.httpClient = HttpClient.newBuilder() - .connectTimeout(this.timeout) - // RAGFlow 部署在 tailnet 内网,显式直连可避免系统代理污染 live IT 与运行时调用。 - .proxy(DIRECT_PROXY_SELECTOR) - .build(); - } - - @Override - public RuntimeResult createDataset(CreateDatasetCommand command) { - Map body = new LinkedHashMap<>(); - body.put("name", command.name()); - // RAGFlow 当前 create dataset 接口只接受 name;config 由 updateDatasetConfig 单独提交,避免创建阶段被外部 API 拒绝。 - return executeJson(Operation.CREATE_DATASET, command.correlationId(), command.requestHash(), command.attempt(), - "POST", "/api/v1/datasets", body, "id", null); - } - - @Override - public RuntimeResult uploadDocuments(UploadDocumentsCommand command) { - MultipartPayload multipartPayload = buildMultipartPayload(command.documents()); - if (multipartPayload == null) { - return failure(Operation.UPLOAD_DOCUMENTS, command.correlationId(), command.requestHash(), command.attempt(), - null, FailureClass.FILE_REJECTED, 0L, "document content is not materialized"); - } - return execute(Operation.UPLOAD_DOCUMENTS, command.correlationId(), command.requestHash(), command.attempt(), - "POST", "/api/v1/datasets/" + command.ragflowDatasetId() + "/documents", - multipartPayload.body(), multipartPayload.contentType(), "id", null); - } - - @Override - public RuntimeResult startParseDocuments(StartParseDocumentsCommand command) { - Map body = Map.of("document_ids", command.ragflowDocumentIds()); - RuntimeResult rawResult = executeJson(Operation.START_PARSE_DOCUMENTS, command.correlationId(), command.requestHash(), - command.attempt(), "POST", "/api/v1/datasets/" + command.ragflowDatasetId() + "/chunks", - body, null, command.processingTaskId()); - if (rawResult.status() == Status.FAILED) { - return rawResult; - } - // RAGFlow parse 没有稳定外部 task id;Muse 只向上返回内部 processingTaskId/correlationId。 - return new RuntimeResult(null, Operation.START_PARSE_DOCUMENTS, command.correlationId(), command.requestHash(), - command.attempt(), rawResult.durationMillis(), Status.ACCEPTED, null, command.processingTaskId(), - "{\"status\":\"accepted\",\"processingTaskId\":\"" + escape(command.processingTaskId()) + "\"}", - List.of(), Map.of("processingTaskId", command.processingTaskId())); - } - - @Override - public RuntimeResult pollDocumentStatuses(PollDocumentStatusesCommand command) { - String path = "/api/v1/datasets/" + command.ragflowDatasetId() + "/documents"; - if (command.ragflowDocumentIds() != null && !command.ragflowDocumentIds().isEmpty()) { - if (command.ragflowDocumentIds().size() > 1) { - return singleDocumentPollingValidationFailure(command); - } - // RAGFlow list documents 的真实查询参数是 id=,不是批量 document_ids。 - // 当前 Muse 上传链路按单文档 smoke 验收;多文档批量轮询后续应在调用方拆分,避免生成 RAGFlow 不支持的查询。 - path += "?id=" + encode(command.ragflowDocumentIds().getFirst()); - } - RuntimeResult rawResult = execute(Operation.POLL_DOCUMENT_STATUSES, command.correlationId(), - command.requestHash(), command.attempt(), "GET", path, new byte[0], null, null, command.processingTaskId()); - if (rawResult.status() == Status.FAILED) { - return rawResult; - } - List statuses = normalizeDocumentStatuses(rawResult.summary().get("responseBody"), - command.ragflowDocumentIds()); - return new RuntimeResult(null, Operation.POLL_DOCUMENT_STATUSES, command.correlationId(), command.requestHash(), - command.attempt(), rawResult.durationMillis(), Status.SUCCEEDED, null, command.processingTaskId(), - sanitizeSummary(Map.of("documents", statuses)), statuses, Map.of("documents", statuses)); - } - - private RuntimeResult singleDocumentPollingValidationFailure(PollDocumentStatusesCommand command) { - Map summary = new LinkedHashMap<>(); - summary.put("status", Status.FAILED.name().toLowerCase(Locale.ROOT)); - summary.put("failureClass", FailureClass.VALIDATION_ERROR.name()); - summary.put("operation", Operation.POLL_DOCUMENT_STATUSES.wireName()); - summary.put("reason", "only support single document status polling"); - return new RuntimeResult(null, Operation.POLL_DOCUMENT_STATUSES, command.correlationId(), command.requestHash(), - command.attempt(), 0L, Status.FAILED, FailureClass.VALIDATION_ERROR, command.processingTaskId(), - sanitizeSummary(summary), List.of(), summary); - } - - @Override - public RuntimeResult retrieveChunks(RetrieveChunksCommand command) { - Map body = new LinkedHashMap<>(); - body.put("dataset_ids", command.ragflowDatasetIds()); - // RAGFlow 要求 document_ids 为 list;为 null(不限定具体文档、按授权 dataset 全量检索)时不放入 body, - // 否则 RAGFlow 拒绝 code 102 "`documents` should be a list" → 检索 fail-closed,P-A 上下文检索永不命中。 - if (command.ragflowDocumentIds() != null) { - body.put("document_ids", command.ragflowDocumentIds()); - } - body.put("question", command.question()); - body.put("top_k", command.topK()); - body.put("similarity_threshold", command.threshold()); - // RAGFlow 官方 HTTP 契约字段名是 metadata_condition;旧字段 metadata_filter 会被静默忽略。 - if (command.metadataCondition() != null) { - body.put("metadata_condition", command.metadataCondition()); - } - return executeJson(Operation.RETRIEVE_CHUNKS, command.correlationId(), command.requestHash(), command.attempt(), - "POST", "/api/v1/retrieval", body, null, null); - } - - protected HttpResponsePayload send(HttpRequestPayload request) throws IOException, InterruptedException { - HttpRequest.Builder builder = HttpRequest.newBuilder() - .uri(URI.create(baseUrl + request.path())) - .timeout(timeout) - .header("Accept", "application/json"); - if (StringUtils.hasText(apiKey)) { - builder.header("Authorization", "Bearer " + apiKey); - } - if ("GET".equals(request.method())) { - builder.GET(); - } else { - builder.header("Content-Type", request.contentType() == null ? "application/json" : request.contentType()) - .method(request.method(), HttpRequest.BodyPublishers.ofByteArray(request.body())); - } - HttpResponse response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); - return new HttpResponsePayload(response.statusCode(), response.body(), response.headers()); - } - - private RuntimeResult executeJson(Operation operation, String correlationId, String requestHash, int attempt, - String method, String path, Object body, String externalIdField, - String processingTaskId) { - byte[] requestBody = "GET".equals(method) ? new byte[0] - : JsonUtils.toJsonString(body == null ? Map.of() : body).getBytes(StandardCharsets.UTF_8); - return execute(operation, correlationId, requestHash, attempt, method, path, requestBody, - "application/json", externalIdField, processingTaskId); - } - - private RuntimeResult execute(Operation operation, String correlationId, String requestHash, int attempt, - String method, String path, byte[] requestBody, String contentType, - String externalIdField, String processingTaskId) { - Instant startedAt = Instant.now(); - FailureClass configurationFailure = validateConfiguration(); - if (configurationFailure != null) { - return failure(operation, correlationId, requestHash, attempt, processingTaskId, configurationFailure, - elapsedMillis(startedAt), configurationFailure.name()); - } - int maxAttempts = Math.max(1, retryBudget + 1); - RuntimeResult lastFailure = null; - for (int currentAttempt = 1; currentAttempt <= maxAttempts; currentAttempt++) { - try { - HttpResponsePayload response = send(new HttpRequestPayload(method, path, - requestBody == null ? new byte[0] : requestBody, contentType)); - long duration = elapsedMillis(startedAt); - FailureClass httpFailure = classifyHttpFailure(response.statusCode()); - if (httpFailure != null) { - lastFailure = failure(operation, correlationId, requestHash, attempt, processingTaskId, - httpFailure, duration, response.body()); - if (!isRetryable(httpFailure) || currentAttempt == maxAttempts) { - return lastFailure; - } - continue; - } - return success(operation, correlationId, requestHash, attempt, processingTaskId, externalIdField, - response.body(), duration); - } catch (java.net.http.HttpTimeoutException ex) { - lastFailure = failure(operation, correlationId, requestHash, attempt, processingTaskId, - FailureClass.TIMEOUT, elapsedMillis(startedAt), ex.getMessage()); - } catch (IOException ex) { - lastFailure = failure(operation, correlationId, requestHash, attempt, processingTaskId, - FailureClass.RAGFLOW_UNAVAILABLE, elapsedMillis(startedAt), ex.getMessage()); - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - return failure(operation, correlationId, requestHash, attempt, processingTaskId, - FailureClass.RAGFLOW_UNAVAILABLE, elapsedMillis(startedAt), ex.getMessage()); - } catch (RuntimeException ex) { - return failure(operation, correlationId, requestHash, attempt, processingTaskId, - FailureClass.UNKNOWN_FAILURE, elapsedMillis(startedAt), ex.getMessage()); - } - if (currentAttempt == maxAttempts) { - return lastFailure; - } - } - return lastFailure == null - ? failure(operation, correlationId, requestHash, attempt, processingTaskId, - FailureClass.UNKNOWN_FAILURE, elapsedMillis(startedAt), "unknown failure") - : lastFailure; - } - - private RuntimeResult success(Operation operation, String correlationId, String requestHash, int attempt, - String processingTaskId, String externalIdField, String responseBody, - long durationMillis) { - JsonNode bodyTree = parseBody(responseBody); - if (bodyTree == null) { - return failure(operation, correlationId, requestHash, attempt, processingTaskId, - FailureClass.RESPONSE_SCHEMA_CHANGED, durationMillis, responseBody); - } - FailureClass envelopeFailure = classifyEnvelopeFailure(bodyTree); - if (envelopeFailure != null) { - return failure(operation, correlationId, requestHash, attempt, processingTaskId, - envelopeFailure, durationMillis, firstText(bodyTree, "message", "msg", "error")); - } - JsonNode data = bodyTree.has("data") ? bodyTree.path("data") : bodyTree; - String externalId = externalIdField == null ? null : findText(data, externalIdField); - if (externalIdField != null && !StringUtils.hasText(externalId)) { - return failure(operation, correlationId, requestHash, attempt, processingTaskId, - FailureClass.RESPONSE_SCHEMA_CHANGED, durationMillis, "required externalId is missing"); - } - Map summary = buildMetadataSummary(operation, bodyTree, externalId, null); - // 后续状态归一仍需要原始响应树,审计摘要只输出 buildMetadataSummary 的保守元数据。 - summary.put("responseBody", bodyTree); - if (externalId != null) { - summary.put("externalId", externalId); - } - return new RuntimeResult(externalId, operation, correlationId, requestHash, attempt, durationMillis, - Status.SUCCEEDED, null, processingTaskId, - sanitizeSummary(buildMetadataSummary(operation, bodyTree, externalId, null)), List.of(), summary); - } - - private RuntimeResult failure(Operation operation, String correlationId, String requestHash, int attempt, - String processingTaskId, FailureClass failureClass, long durationMillis, - String message) { - Map summary = new LinkedHashMap<>(); - summary.put("status", Status.FAILED.name().toLowerCase(Locale.ROOT)); - summary.put("failureClass", failureClass.name()); - summary.put("operation", operation.wireName()); - if (message != null && !message.isBlank()) { - summary.put("providerMessageSummary", safeSummaryNode(message)); - } - return new RuntimeResult(null, operation, correlationId, requestHash, attempt, durationMillis, - Status.FAILED, failureClass, processingTaskId, sanitizeSummary(summary), List.of(), summary); - } - - private JsonNode safeSummaryNode(String message) { - Map summary = new LinkedHashMap<>(); - summary.put("type", "provider_body"); - summary.put("length", message == null ? 0 : message.length()); - summary.put("sha256", sha256(message == null ? "" : message)); - summary.put("redacted", true); - summary.put("json", looksLikeJson(message)); - return JsonUtils.getObjectMapper().valueToTree(summary); - } - - private FailureClass validateConfiguration() { - if (!StringUtils.hasText(baseUrl)) { - return FailureClass.CONFIG_MISSING; - } - if (!StringUtils.hasText(apiKey)) { - return FailureClass.AUTH_MISSING; - } - return null; - } - - private FailureClass classifyHttpFailure(int statusCode) { - if (statusCode >= 200 && statusCode < 300) { - return null; - } - return switch (statusCode) { - case 400 -> FailureClass.VALIDATION_ERROR; - case 401, 403 -> FailureClass.AUTH_FAILED; - case 404 -> FailureClass.TASK_NOT_FOUND; - case 409 -> FailureClass.CONFLICT; - case 413, 415 -> FailureClass.FILE_REJECTED; - case 429 -> FailureClass.RATE_LIMITED; - default -> statusCode >= 500 ? FailureClass.RAGFLOW_UNAVAILABLE : FailureClass.UNKNOWN_FAILURE; - }; - } - - private boolean isRetryable(FailureClass failureClass) { - return failureClass == FailureClass.RAGFLOW_UNAVAILABLE - || failureClass == FailureClass.TIMEOUT - || failureClass == FailureClass.RATE_LIMITED; - } - - private FailureClass classifyEnvelopeFailure(JsonNode bodyTree) { - if (bodyTree == null || bodyTree.isMissingNode() || bodyTree.isNull()) { - return FailureClass.RESPONSE_SCHEMA_CHANGED; - } - if (bodyTree.has("code") && bodyTree.path("code").canConvertToInt() && bodyTree.path("code").asInt() != 0) { - return classifyRagflowCode(bodyTree.path("code").asInt(), firstText(bodyTree, "message", "msg", "error")); - } - if (bodyTree.has("error") || bodyTree.has("errors")) { - return classifyRagflowMessage(firstText(bodyTree, "error", "message", "msg")); - } - String status = firstText(bodyTree, "status"); - if (status != null && Set.of("failed", "error", "unauthorized", "forbidden").contains(status.toLowerCase(Locale.ROOT))) { - return classifyRagflowMessage(firstText(bodyTree, "message", "msg", "error", "status")); - } - return null; - } - - private FailureClass classifyRagflowCode(int code, String message) { - return switch (code) { - case 401, 403, 1001, 1002 -> FailureClass.AUTH_FAILED; - case 400, 422 -> FailureClass.VALIDATION_ERROR; - case 404 -> FailureClass.TASK_NOT_FOUND; - case 409 -> FailureClass.CONFLICT; - case 429 -> FailureClass.RATE_LIMITED; - default -> classifyRagflowMessage(message); - }; - } - - private FailureClass classifyRagflowMessage(String message) { - String normalized = message == null ? "" : message.toLowerCase(Locale.ROOT); - if (normalized.contains("auth") || normalized.contains("unauthor") || normalized.contains("forbidden")) { - return FailureClass.AUTH_FAILED; - } - if (normalized.contains("validation") || normalized.contains("invalid") || normalized.contains("param")) { - return FailureClass.VALIDATION_ERROR; - } - if (normalized.contains("index") && normalized.contains("ready")) { - return FailureClass.INDEX_NOT_READY; - } - if (normalized.contains("chunk") && (normalized.contains("empty") || normalized.contains("no "))) { - return FailureClass.NO_CHUNK; - } - if (normalized.contains("graph") && normalized.contains("ready")) { - return FailureClass.GRAPH_NOT_READY; - } - if (normalized.contains("already") && normalized.contains("running")) { - return FailureClass.TASK_ALREADY_RUNNING; - } - if (normalized.contains("not found")) { - return FailureClass.TASK_NOT_FOUND; - } - return FailureClass.RESPONSE_SCHEMA_CHANGED; - } - - private List normalizeDocumentStatuses(Object responseBody, List requestedDocumentIds) { - JsonNode root = responseBody instanceof JsonNode jsonNode ? jsonNode : JsonUtils.getObjectMapper().valueToTree(responseBody); - JsonNode documents = root.path("data"); - if (documents.isObject() && documents.has("docs")) { - documents = documents.path("docs"); - } - if (documents.isObject() && documents.has("documents")) { - documents = documents.path("documents"); - } - List statuses = new ArrayList<>(); - if (!documents.isArray()) { - return statuses; - } - Set requestedIds = requestedDocumentIds == null ? Set.of() : new LinkedHashSet<>(requestedDocumentIds); - for (JsonNode document : documents) { - String documentId = firstText(document, "id", "document_id"); - if (!requestedIds.isEmpty() && !requestedIds.contains(documentId)) { - continue; - } - String run = document.path("run").asText(""); - Integer progress = document.has("progress") && document.path("progress").canConvertToInt() - ? document.path("progress").asInt() : null; - statuses.add(new DocumentStatus( - documentId, - normalizeMuseStatus(run, progress), - run, - progress, - firstText(document, "progress_msg", "progressMsg") - )); - } - return statuses; - } - - private String normalizeMuseStatus(String run, Integer progress) { - String normalizedRun = run == null ? "" : run.toLowerCase(Locale.ROOT); - if (Set.of("done", "completed", "success").contains(normalizedRun) || Integer.valueOf(100).equals(progress)) { - return "searchable"; - } - if (Set.of("running", "parsing", "indexing", "queued").contains(normalizedRun)) { - return "processing"; - } - if (Set.of("failed", "error", "cancelled").contains(normalizedRun)) { - return "failed"; - } - return "pending_process"; - } - - private JsonNode parseBody(String responseBody) { - try { - return JsonUtils.parseTree(responseBody == null || responseBody.isBlank() ? "{}" : responseBody); - } catch (RuntimeException ex) { - return null; - } - } - - private String findText(JsonNode node, String fieldName) { - if (node == null || node.isMissingNode() || node.isNull()) { - return null; - } - if (node.has(fieldName) && node.path(fieldName).isTextual()) { - return node.path(fieldName).asText(); - } - if (node.isArray() && !node.isEmpty()) { - return findText(node.get(0), fieldName); - } - return null; - } - - private String firstText(JsonNode node, String... fieldNames) { - for (String fieldName : fieldNames) { - if (node.has(fieldName) && node.path(fieldName).isTextual()) { - return node.path(fieldName).asText(); - } - } - return null; - } - - private MultipartPayload buildMultipartPayload(List documents) { - if (documents == null || documents.isEmpty()) { - return null; - } - String boundary = "----MuseRagFlow" + UUID.randomUUID().toString().replace("-", ""); - try { - ByteArrayOutputStream body = new ByteArrayOutputStream(); - for (DocumentUpload document : documents) { - if (document == null || document.content() == null || document.content().length == 0 - || !StringUtils.hasText(document.fileName())) { - return null; - } - String contentType = StringUtils.hasText(document.contentType()) ? document.contentType() : "application/octet-stream"; - writeAscii(body, "--" + boundary + "\r\n"); - writeAscii(body, "Content-Disposition: form-data; name=\"file\"; filename=\"" - + escapeMultipart(document.fileName()) + "\"\r\n"); - writeAscii(body, "Content-Type: " + contentType + "\r\n\r\n"); - // Task 3 只承接小型已材料化资源;Task 5 再接对象存储/流式文件边界。 - body.write(document.content()); - writeAscii(body, "\r\n"); - } - writeAscii(body, "--" + boundary + "--\r\n"); - return new MultipartPayload(body.toByteArray(), "multipart/form-data; boundary=" + boundary); - } catch (IOException ex) { - return null; - } - } - - private Map buildMetadataSummary(Operation operation, JsonNode bodyTree, String externalId, - FailureClass failureClass) { - Map summary = new LinkedHashMap<>(); - summary.put("operation", operation.wireName()); - summary.put("status", failureClass == null ? Status.SUCCEEDED.name().toLowerCase(Locale.ROOT) - : Status.FAILED.name().toLowerCase(Locale.ROOT)); - if (bodyTree != null && bodyTree.has("code")) { - summary.put("code", bodyTree.path("code")); - } - if (StringUtils.hasText(externalId)) { - summary.put("externalId", externalId); - } - if (failureClass != null) { - summary.put("failureClass", failureClass.name()); - } - JsonNode data = bodyTree == null ? null : bodyTree.path("data"); - if (data != null && data.isArray()) { - summary.put("count", data.size()); - } else if (data != null && data.isObject()) { - putCountIfPresent(summary, data, "docs"); - putCountIfPresent(summary, data, "documents"); - putCountIfPresent(summary, data, "chunks"); - putCountIfPresent(summary, data, "nodes"); - putCountIfPresent(summary, data, "edges"); - JsonNode graph = data.path("graph"); - if (graph.isObject()) { - putCountIfPresent(summary, graph, "nodes"); - putCountIfPresent(summary, graph, "edges"); - } - if (data.has("mind_map")) { - summary.put("mindMapPresent", !data.path("mind_map").isNull()); - if (data.path("mind_map").isArray()) { - summary.put("mindMapCount", data.path("mind_map").size()); - } - } else if (data.has("mindMap")) { - summary.put("mindMapPresent", !data.path("mindMap").isNull()); - if (data.path("mindMap").isArray()) { - summary.put("mindMapCount", data.path("mindMap").size()); - } - } - if (data.has("progress")) { - summary.put("progress", data.path("progress")); - } - } - return summary; - } - - private void putCountIfPresent(Map summary, JsonNode data, String fieldName) { - if (data.has(fieldName) && data.path(fieldName).isArray()) { - summary.put(fieldName + "Count", data.path(fieldName).size()); - } - } - - private String sanitizeSummary(Object value) { - return RagFlowKnowledgeRedactor.sanitizeObject(value); - } - - private long elapsedMillis(Instant startedAt) { - return Math.max(0L, Duration.between(startedAt, Instant.now()).toMillis()); - } - - private String escape(String value) { - return value == null ? "" : value.replace("\\", "\\\\").replace("\"", "\\\""); - } - - private String escapeMultipart(String value) { - return escape(value).replace("\r", "").replace("\n", ""); - } - - private String encode(String value) { - return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8); - } - - private boolean looksLikeJson(String value) { - if (value == null) { - return false; - } - String trimmed = value.trim(); - return (trimmed.startsWith("{") && trimmed.endsWith("}")) - || (trimmed.startsWith("[") && trimmed.endsWith("]")); - } - - private String sha256(String payload) { - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - return HexFormat.of().formatHex(digest.digest(payload.getBytes(StandardCharsets.UTF_8))); - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException("JDK 缺少 SHA-256 摘要算法", e); - } - } - - private void writeAscii(ByteArrayOutputStream outputStream, String text) throws IOException { - outputStream.write(text.getBytes(StandardCharsets.US_ASCII)); - } - - private static String normalizeBaseUrl(String rawBaseUrl) { - if (rawBaseUrl == null || rawBaseUrl.isBlank()) { - return null; - } - return rawBaseUrl.endsWith("/") ? rawBaseUrl.substring(0, rawBaseUrl.length() - 1) : rawBaseUrl; - } - - protected record HttpRequestPayload(String method, String path, byte[] body, String contentType) { - } - - protected record HttpResponsePayload(int statusCode, String body, HttpHeaders headers) { - } - - private record MultipartPayload(byte[] body, String contentType) { - } - -} diff --git a/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/facade/RagFlowKnowledgeRuntimeClientConfiguration.java b/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/facade/RagFlowKnowledgeRuntimeClientConfiguration.java deleted file mode 100644 index 093ff405..00000000 --- a/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/facade/RagFlowKnowledgeRuntimeClientConfiguration.java +++ /dev/null @@ -1,54 +0,0 @@ -package cn.iocoder.muse.module.knowledge.application.muse.facade; - -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.core.env.Environment; -import org.springframework.util.StringUtils; - -import java.time.Duration; - -/** - * RAGFlow runtime client 的 Spring 装配配置。 - * - *

与 {@link DifyKnowledgeRuntimeClientConfiguration} 按 provider 选择器 {@code muse.knowledge.runtime-provider} - * 互斥:仅在 {@code =ragflow} 或缺省(matchIfMissing=true,即保持既有部署零变更)时参与,{@code =dify} 时让位给 Dify 装配。 - * S6d 摘除 RAGFlow 时连同本类一并删除。

- */ -@Configuration(proxyBeanMethods = false) -public class RagFlowKnowledgeRuntimeClientConfiguration { - - private static final String PROPERTY_PREFIX = "muse.knowledge.ragflow."; - private static final int DEFAULT_TIMEOUT_SECONDS = 5; - private static final int DEFAULT_RETRY_BUDGET = 0; - - @Bean - @ConditionalOnMissingBean(KnowledgeRuntimeClient.class) - @ConditionalOnProperty(name = "muse.knowledge.runtime-provider", havingValue = "ragflow", matchIfMissing = true) - public KnowledgeRuntimeClient ragFlowKnowledgeRuntimeClient(Environment environment) { - String baseUrl = environment.getProperty(PROPERTY_PREFIX + "base-url"); - String apiKey = environment.getProperty(PROPERTY_PREFIX + "api-key"); - if (!StringUtils.hasText(baseUrl) || !StringUtils.hasText(apiKey)) { - return new UnavailableRagFlowKnowledgeRuntimeClient(); - } - - int timeoutSeconds = positiveInt(environment, "timeout-seconds", DEFAULT_TIMEOUT_SECONDS); - int retryBudget = nonNegativeInt(environment, "retry-budget", DEFAULT_RETRY_BUDGET); - boolean graphRagAttributionReady = Boolean.TRUE.equals(environment.getProperty( - PROPERTY_PREFIX + "graphrag-attribution-ready", Boolean.class, false)); - return new HttpRagFlowKnowledgeRuntimeClient(baseUrl, apiKey, Duration.ofSeconds(timeoutSeconds), - retryBudget, graphRagAttributionReady); - } - - private int positiveInt(Environment environment, String propertyName, int defaultValue) { - Integer value = environment.getProperty(PROPERTY_PREFIX + propertyName, Integer.class, defaultValue); - return value == null || value <= 0 ? defaultValue : value; - } - - private int nonNegativeInt(Environment environment, String propertyName, int defaultValue) { - Integer value = environment.getProperty(PROPERTY_PREFIX + propertyName, Integer.class, defaultValue); - return value == null || value < 0 ? defaultValue : value; - } - -} diff --git a/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/facade/UnavailableRagFlowKnowledgeRuntimeClient.java b/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/facade/UnavailableRagFlowKnowledgeRuntimeClient.java deleted file mode 100644 index d483a3fe..00000000 --- a/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/main/java/cn/iocoder/muse/module/knowledge/application/muse/facade/UnavailableRagFlowKnowledgeRuntimeClient.java +++ /dev/null @@ -1,46 +0,0 @@ -package cn.iocoder.muse.module.knowledge.application.muse.facade; - -import java.util.List; -import java.util.Map; - -/** - * RAGFlow 未配置时的 fail closed client。 - */ -public class UnavailableRagFlowKnowledgeRuntimeClient implements KnowledgeRuntimeClient { - - @Override - public RuntimeResult createDataset(CreateDatasetCommand command) { - return unavailable(Operation.CREATE_DATASET, command.correlationId(), command.requestHash(), command.attempt(), null); - } - - @Override - public RuntimeResult uploadDocuments(UploadDocumentsCommand command) { - return unavailable(Operation.UPLOAD_DOCUMENTS, command.correlationId(), command.requestHash(), command.attempt(), null); - } - - @Override - public RuntimeResult startParseDocuments(StartParseDocumentsCommand command) { - return unavailable(Operation.START_PARSE_DOCUMENTS, command.correlationId(), command.requestHash(), - command.attempt(), command.processingTaskId()); - } - - @Override - public RuntimeResult pollDocumentStatuses(PollDocumentStatusesCommand command) { - return unavailable(Operation.POLL_DOCUMENT_STATUSES, command.correlationId(), command.requestHash(), - command.attempt(), command.processingTaskId()); - } - - @Override - public RuntimeResult retrieveChunks(RetrieveChunksCommand command) { - return unavailable(Operation.RETRIEVE_CHUNKS, command.correlationId(), command.requestHash(), command.attempt(), null); - } - - private RuntimeResult unavailable(Operation operation, String correlationId, String requestHash, - int attempt, String processingTaskId) { - return new RuntimeResult(null, operation, correlationId, requestHash, attempt, 0L, - Status.FAILED, FailureClass.CONFIG_MISSING, processingTaskId, - "{\"status\":\"failed\",\"failureClass\":\"CONFIG_MISSING\"}", - List.of(), Map.of("failureClass", FailureClass.CONFIG_MISSING.name())); - } - -} diff --git a/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/test/java/cn/iocoder/muse/module/knowledge/application/muse/facade/DifyKnowledgeRuntimeClientConfigurationTest.java b/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/test/java/cn/iocoder/muse/module/knowledge/application/muse/facade/DifyKnowledgeRuntimeClientConfigurationTest.java index 53f2fe84..a5c02ef1 100644 --- a/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/test/java/cn/iocoder/muse/module/knowledge/application/muse/facade/DifyKnowledgeRuntimeClientConfigurationTest.java +++ b/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/test/java/cn/iocoder/muse/module/knowledge/application/muse/facade/DifyKnowledgeRuntimeClientConfigurationTest.java @@ -8,17 +8,17 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; /** - * Dify Knowledge 运行时 client 装配测试。 + * Dify Knowledge 运行时 client 装配测试(S6d 起 Dify 为缺省运行时)。 * - *

两层验证:①装配方法本身(配 dify.* 全→Dify、缺→Unavailable);②与 RAGFlow 装配共存时按 - * {@code muse.knowledge.runtime-provider} 选择器确定性互斥——任何组合下只装一个 {@link KnowledgeRuntimeClient}, - * 不依赖组件扫描/Bean 求值顺序。

+ *

两层验证:①装配方法本身(配 dify.* 全→Dify、缺→Unavailable);②provider 选择器 + * {@code muse.knowledge.runtime-provider} 语义——取 {@code dify} 或缺省装 Dify,显式配成其它值(如遗留 + * {@code ragflow},运行时已删)由兜底装通用 {@link UnavailableKnowledgeRuntimeClient};任何取值下都有且仅有 + * 一个 {@link KnowledgeRuntimeClient}。

*/ class DifyKnowledgeRuntimeClientConfigurationTest { private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() - .withUserConfiguration(DifyKnowledgeRuntimeClientConfiguration.class, - RagFlowKnowledgeRuntimeClientConfiguration.class); + .withUserConfiguration(DifyKnowledgeRuntimeClientConfiguration.class); // ==================== 装配方法本身 ==================== @@ -58,7 +58,7 @@ class DifyKnowledgeRuntimeClientConfigurationTest { assertInstanceOf(UnavailableKnowledgeRuntimeClient.class, client); } - // ==================== 与 RAGFlow 装配共存:确定性互斥 ==================== + // ==================== provider 选择器:缺省 Dify + 非 dify 值兜底 Unavailable ==================== @Test void should_installDifyOnlyWhenProviderIsDify() { @@ -85,37 +85,36 @@ class DifyKnowledgeRuntimeClientConfigurationTest { } @Test - void should_installRagFlowWhenProviderIsRagflow() { - contextRunner.withPropertyValues( - "muse.knowledge.runtime-provider=ragflow", - "muse.knowledge.ragflow.base-url=https://ragflow.example", - "muse.knowledge.ragflow.api-key=ragflow-key") + void should_installUnavailableWhenProviderIsRagflow() { + // 遗留 provider=ragflow(运行时 S6d 已删):Dify 主 bean 让位,兜底装通用 UnavailableKnowledgeRuntimeClient, + // 既不因无 bean 启动失败,也不静默降级。 + contextRunner.withPropertyValues("muse.knowledge.runtime-provider=ragflow") .run(context -> { assertEquals(1, context.getBeansOfType(KnowledgeRuntimeClient.class).size()); - assertInstanceOf(HttpRagFlowKnowledgeRuntimeClient.class, + assertInstanceOf(UnavailableKnowledgeRuntimeClient.class, context.getBean(KnowledgeRuntimeClient.class)); }); } @Test - void should_defaultToRagFlowWhenProviderUnset() { - // 缺省 provider(既有部署零变更):matchIfMissing 让 RAGFlow 装配继续生效,Dify 让位。 + void should_defaultToDifyWhenProviderUnset() { + // 缺省 provider(未显式配置):matchIfMissing 让 Dify 成缺省运行时。 contextRunner.withPropertyValues( - "muse.knowledge.ragflow.base-url=https://ragflow.example", - "muse.knowledge.ragflow.api-key=ragflow-key") + "muse.knowledge.dify.base-url=http://dify.example/v1", + "muse.knowledge.dify.dataset-api-key=dataset-key") .run(context -> { assertEquals(1, context.getBeansOfType(KnowledgeRuntimeClient.class).size()); - assertInstanceOf(HttpRagFlowKnowledgeRuntimeClient.class, + assertInstanceOf(DifyKnowledgeRuntimeClient.class, context.getBean(KnowledgeRuntimeClient.class)); }); } @Test void should_installSingleUnavailableWhenProviderUnsetAndNothingConfigured() { + // 缺省 provider + 未配 dify.*:缺省进 Dify 装配但配缺 → fail-closed 通用 Unavailable 桩,仍确保只有一个 bean。 contextRunner.run(context -> { - // 仍确保只有一个 bean(RAGFlow 的 Unavailable 桩),不因两装配类共存而重复注册。 assertEquals(1, context.getBeansOfType(KnowledgeRuntimeClient.class).size()); - assertInstanceOf(UnavailableRagFlowKnowledgeRuntimeClient.class, + assertInstanceOf(UnavailableKnowledgeRuntimeClient.class, context.getBean(KnowledgeRuntimeClient.class)); }); } diff --git a/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/test/java/cn/iocoder/muse/module/knowledge/application/muse/facade/KnowledgeRuntimeClientTest.java b/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/test/java/cn/iocoder/muse/module/knowledge/application/muse/facade/KnowledgeRuntimeClientTest.java deleted file mode 100644 index 9cdb5a5d..00000000 --- a/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/test/java/cn/iocoder/muse/module/knowledge/application/muse/facade/KnowledgeRuntimeClientTest.java +++ /dev/null @@ -1,398 +0,0 @@ -package cn.iocoder.muse.module.knowledge.application.muse.facade; - -import org.junit.jupiter.api.Test; - -import java.io.IOException; -import java.net.http.HttpHeaders; -import java.time.Duration; -import java.util.Arrays; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -/** - * Knowledge 运行时 client 基础行为测试。 - */ -class KnowledgeRuntimeClientTest { - - @Test - void unavailableClientShouldFailClosedWhenConfigMissing() { - KnowledgeRuntimeClient client = new UnavailableRagFlowKnowledgeRuntimeClient(); - - KnowledgeRuntimeClient.RuntimeResult result = client.createDataset( - new KnowledgeRuntimeClient.CreateDatasetCommand(100L, 2001L, 4001L, - "KB", Map.of(), "corr-1", "hash-1", 1)); - - assertEquals(KnowledgeRuntimeClient.Status.FAILED, result.status()); - assertEquals(KnowledgeRuntimeClient.FailureClass.CONFIG_MISSING, result.failureClass()); - assertEquals("createDataset", result.operation().wireName()); - assertFalse(result.redactedSummary().isBlank()); - } - - @Test - void startParseShouldExposeOnlyMuseProcessingTaskAndCorrelation() { - HttpRagFlowKnowledgeRuntimeClient client = new StubHttpRagFlowKnowledgeRuntimeClient( - "https://ragflow.example", "configured-api-key", """ - {"code":0,"data":{"task_id":"external-task-should-not-leak","id":"external-task-should-not-leak"}} - """); - - KnowledgeRuntimeClient.RuntimeResult result = client.startParseDocuments( - new KnowledgeRuntimeClient.StartParseDocumentsCommand(100L, 2001L, 4001L, - "dataset-1", List.of("doc-1"), "muse-task-1", "corr-parse-1", "hash-parse-1", 1)); - - assertEquals(KnowledgeRuntimeClient.Status.ACCEPTED, result.status()); - assertNull(result.externalId()); - assertEquals("muse-task-1", result.processingTaskId()); - assertEquals("corr-parse-1", result.correlationId()); - assertFalse(result.redactedSummary().contains("external-task-should-not-leak")); - } - - @Test - void redactionShouldNotLeakCredentialsFromSummaries() { - HttpRagFlowKnowledgeRuntimeClient client = new StubHttpRagFlowKnowledgeRuntimeClient( - "https://ragflow.example", "configured-api-key", "{\"code\":0,\"data\":{\"id\":\"dataset-1\"}}"); - - KnowledgeRuntimeClient.RuntimeResult result = client.createDataset( - new KnowledgeRuntimeClient.CreateDatasetCommand(100L, 2001L, 4001L, - "KB", Map.of("apiKey", "secret-value", "authorization", "Bearer abcdefgh"), - "corr-1", "hash-1", 1)); - - assertFalse(result.redactedSummary().contains("secret-value")); - assertFalse(result.redactedSummary().toLowerCase().contains("bearer abcdefgh")); - } - - @Test - void createDatasetShouldSendOnlyNameWithoutConfigPayload() { - CapturingHttpRagFlowKnowledgeRuntimeClient client = new CapturingHttpRagFlowKnowledgeRuntimeClient( - "https://ragflow.example", "configured-api-key", "{\"code\":0,\"data\":{\"id\":\"dataset-1\"}}"); - - KnowledgeRuntimeClient.RuntimeResult result = client.createDataset( - new KnowledgeRuntimeClient.CreateDatasetCommand(100L, 2001L, 4001L, - "KB", Map.of("chunk_method", "naive", "apiKey", "secret-value"), - "corr-create-name-only", "hash-create-name-only", 1)); - - assertEquals(KnowledgeRuntimeClient.Status.SUCCEEDED, result.status()); - String requestBody = new String(client.requests.get(0).body(), StandardCharsets.UTF_8); - assertTrue(requestBody.contains("KB")); - assertFalse(requestBody.contains("config")); - assertFalse(requestBody.contains("chunk_method")); - assertFalse(requestBody.contains("secret-value")); - } - - @Test - void ragflowEnvelopeNonZeroCodeShouldFailClosedInsteadOfSucceeded() { - HttpRagFlowKnowledgeRuntimeClient client = new StubHttpRagFlowKnowledgeRuntimeClient( - "https://ragflow.example", "configured-api-key", "{\"code\":401,\"message\":\"auth failed\"}"); - - KnowledgeRuntimeClient.RuntimeResult result = client.createDataset( - new KnowledgeRuntimeClient.CreateDatasetCommand(100L, 2001L, 4001L, - "KB", Map.of(), "corr-envelope-1", "hash-envelope-1", 1)); - - assertEquals(KnowledgeRuntimeClient.Status.FAILED, result.status()); - assertEquals(KnowledgeRuntimeClient.FailureClass.AUTH_FAILED, result.failureClass()); - assertNull(result.externalId()); - } - - @Test - void createDatasetShouldFailClosedWhenRequiredExternalIdMissing() { - HttpRagFlowKnowledgeRuntimeClient client = new StubHttpRagFlowKnowledgeRuntimeClient( - "https://ragflow.example", "configured-api-key", "{\"code\":0,\"data\":{\"name\":\"KB\"}}"); - - KnowledgeRuntimeClient.RuntimeResult result = client.createDataset( - new KnowledgeRuntimeClient.CreateDatasetCommand(100L, 2001L, 4001L, - "KB", Map.of(), "corr-envelope-2", "hash-envelope-2", 1)); - - assertEquals(KnowledgeRuntimeClient.Status.FAILED, result.status()); - assertEquals(KnowledgeRuntimeClient.FailureClass.RESPONSE_SCHEMA_CHANGED, result.failureClass()); - } - - @Test - void failureSummaryShouldNotContainPlainProviderErrorBody() { - String providerError = "plain provider unavailable body"; - HttpRagFlowKnowledgeRuntimeClient client = new StubHttpRagFlowKnowledgeRuntimeClient( - "https://ragflow.example", "configured-api-key", 500, providerError); - - KnowledgeRuntimeClient.RuntimeResult result = client.createDataset( - new KnowledgeRuntimeClient.CreateDatasetCommand(100L, 2001L, 4001L, - "KB", Map.of(), "corr-fail-body-1", "hash-fail-body-1", 1)); - - assertEquals(KnowledgeRuntimeClient.Status.FAILED, result.status()); - assertEquals(KnowledgeRuntimeClient.FailureClass.RAGFLOW_UNAVAILABLE, result.failureClass()); - assertFalse(result.redactedSummary().contains(providerError)); - assertTrue(result.redactedSummary().contains("\"redacted\":true")); - assertTrue(result.redactedSummary().contains("\"sha256\"")); - } - - @Test - void failureSummaryShouldNotContainJsonProviderMessageOrErrorFields() { - String providerMessage = "plain provider raw error"; - String providerError = "raw error field"; - HttpRagFlowKnowledgeRuntimeClient client = new StubHttpRagFlowKnowledgeRuntimeClient( - "https://ragflow.example", "configured-api-key", 500, - "{\"message\":\"" + providerMessage + "\",\"error\":\"" + providerError + "\"}"); - - KnowledgeRuntimeClient.RuntimeResult result = client.createDataset( - new KnowledgeRuntimeClient.CreateDatasetCommand(100L, 2001L, 4001L, - "KB", Map.of(), "corr-fail-body-json-1", "hash-fail-body-json-1", 1)); - - assertEquals(KnowledgeRuntimeClient.Status.FAILED, result.status()); - assertEquals(KnowledgeRuntimeClient.FailureClass.RAGFLOW_UNAVAILABLE, result.failureClass()); - assertFalse(result.redactedSummary().contains(providerMessage)); - assertFalse(result.redactedSummary().contains(providerError)); - assertTrue(result.redactedSummary().contains("\"redacted\":true")); - assertTrue(result.redactedSummary().contains("\"sha256\"")); - } - - @Test - void failureSummaryShouldNotContainJsonProviderErrorsArray() { - String providerError = "array raw error"; - HttpRagFlowKnowledgeRuntimeClient client = new StubHttpRagFlowKnowledgeRuntimeClient( - "https://ragflow.example", "configured-api-key", 400, - "{\"errors\":[{\"message\":\"" + providerError + "\"}]}"); - - KnowledgeRuntimeClient.RuntimeResult result = client.createDataset( - new KnowledgeRuntimeClient.CreateDatasetCommand(100L, 2001L, 4001L, - "KB", Map.of(), "corr-fail-body-json-2", "hash-fail-body-json-2", 1)); - - assertEquals(KnowledgeRuntimeClient.Status.FAILED, result.status()); - assertEquals(KnowledgeRuntimeClient.FailureClass.VALIDATION_ERROR, result.failureClass()); - assertFalse(result.redactedSummary().contains(providerError)); - assertTrue(result.redactedSummary().contains("\"redacted\":true")); - assertTrue(result.redactedSummary().contains("\"sha256\"")); - } - - @Test - void nonZeroEnvelopeFailureSummaryShouldNotContainPlainMessage() { - String providerMessage = "business rule rejected this dataset"; - HttpRagFlowKnowledgeRuntimeClient client = new StubHttpRagFlowKnowledgeRuntimeClient( - "https://ragflow.example", "configured-api-key", - "{\"code\":422,\"message\":\"" + providerMessage + "\"}"); - - KnowledgeRuntimeClient.RuntimeResult result = client.createDataset( - new KnowledgeRuntimeClient.CreateDatasetCommand(100L, 2001L, 4001L, - "KB", Map.of(), "corr-fail-body-2", "hash-fail-body-2", 1)); - - assertEquals(KnowledgeRuntimeClient.Status.FAILED, result.status()); - assertEquals(KnowledgeRuntimeClient.FailureClass.VALIDATION_ERROR, result.failureClass()); - assertFalse(result.redactedSummary().contains(providerMessage)); - assertTrue(result.redactedSummary().contains("\"redacted\":true")); - assertTrue(result.redactedSummary().contains("\"sha256\"")); - } - - @Test - void uploadDocumentsShouldUseMultipartAndRejectMissingMaterializedBytes() { - CapturingHttpRagFlowKnowledgeRuntimeClient client = new CapturingHttpRagFlowKnowledgeRuntimeClient( - "https://ragflow.example", "configured-api-key", "{\"code\":0,\"data\":[{\"id\":\"doc-1\"}]}"); - - KnowledgeRuntimeClient.RuntimeResult rejected = client.uploadDocuments( - new KnowledgeRuntimeClient.UploadDocumentsCommand(100L, 2001L, 4001L, - "dataset-1", List.of(new KnowledgeRuntimeClient.DocumentUpload( - 5001L, 6001L, "file-ref-1", "novel.txt", "text/plain", 10L, null)), - "corr-upload-1", "hash-upload-1", 1)); - - assertEquals(KnowledgeRuntimeClient.Status.FAILED, rejected.status()); - assertEquals(KnowledgeRuntimeClient.FailureClass.FILE_REJECTED, rejected.failureClass()); - - KnowledgeRuntimeClient.RuntimeResult uploaded = client.uploadDocuments( - new KnowledgeRuntimeClient.UploadDocumentsCommand(100L, 2001L, 4001L, - "dataset-1", List.of(new KnowledgeRuntimeClient.DocumentUpload( - 5001L, 6001L, "file-ref-1", "novel.txt", "text/plain", 5L, - "hello".getBytes(StandardCharsets.UTF_8))), - "corr-upload-2", "hash-upload-2", 1)); - - assertEquals(KnowledgeRuntimeClient.Status.SUCCEEDED, uploaded.status()); - assertEquals("POST", client.requests.get(0).method()); - assertTrue(client.requests.get(0).contentType().startsWith("multipart/form-data; boundary=")); - assertTrue(new String(client.requests.get(0).body(), StandardCharsets.ISO_8859_1).contains("filename=\"novel.txt\"")); - assertFalse(new String(client.requests.get(0).body(), StandardCharsets.ISO_8859_1).contains("\"documents\"")); - } - - @Test - void uploadDocumentsShouldPreserveBinaryBytesInsideMultipartPayload() { - CapturingHttpRagFlowKnowledgeRuntimeClient client = new CapturingHttpRagFlowKnowledgeRuntimeClient( - "https://ragflow.example", "configured-api-key", "{\"code\":0,\"data\":[{\"id\":\"doc-1\"}]}"); - byte[] binaryContent = new byte[]{0x25, 0x50, 0x44, 0x46, 0x00, (byte) 0x80, (byte) 0xFF, 0x01}; - - KnowledgeRuntimeClient.RuntimeResult uploaded = client.uploadDocuments( - new KnowledgeRuntimeClient.UploadDocumentsCommand(100L, 2001L, 4001L, - "dataset-1", List.of(new KnowledgeRuntimeClient.DocumentUpload( - 5001L, 6001L, "file-ref-1", "binary.pdf", "application/pdf", - (long) binaryContent.length, binaryContent)), - "corr-upload-bin", "hash-upload-bin", 1)); - - assertEquals(KnowledgeRuntimeClient.Status.SUCCEEDED, uploaded.status()); - assertTrue(containsSubsequence(client.requests.get(0).body(), binaryContent)); - } - - @Test - void redactedSummaryShouldNotLeakChunkQuestionPromptOrResponseContent() { - String responseBody = """ - {"code":0,"data":{"chunks":[{"id":"chunk-1","content":"secret chunk content"}],"response":"raw answer text"}} - """; - HttpRagFlowKnowledgeRuntimeClient client = new StubHttpRagFlowKnowledgeRuntimeClient( - "https://ragflow.example", "configured-api-key", responseBody); - - KnowledgeRuntimeClient.RuntimeResult result = client.retrieveChunks( - new KnowledgeRuntimeClient.RetrieveChunksCommand(100L, 2001L, 4001L, - List.of("dataset-1"), List.of("doc-1"), "private retrieval question", - 3, 0.5, Map.of("prompt", "private prompt"), "corr-redact-1", "hash-redact-1", 1)); - - assertFalse(result.redactedSummary().contains("secret chunk content")); - assertFalse(result.redactedSummary().contains("raw answer text")); - assertFalse(result.redactedSummary().contains("private retrieval question")); - assertFalse(result.redactedSummary().contains("private prompt")); - } - - @Test - void retrieveChunksShouldSucceedWithoutTopLevelIdAndKeepOnlyMetadataSummary() { - HttpRagFlowKnowledgeRuntimeClient client = new StubHttpRagFlowKnowledgeRuntimeClient( - "https://ragflow.example", "configured-api-key", """ - {"code":0,"data":{"chunks":[{"content":"secret chunk content","score":0.91}],"response":"raw answer text"}} - """); - - KnowledgeRuntimeClient.RuntimeResult result = client.retrieveChunks( - new KnowledgeRuntimeClient.RetrieveChunksCommand(100L, 2001L, 4001L, - List.of("dataset-1"), List.of("doc-1"), "private retrieval question", - 3, 0.5, Map.of("prompt", "private prompt"), "corr-retrieve-1", "hash-retrieve-1", 1)); - - assertEquals(KnowledgeRuntimeClient.Status.SUCCEEDED, result.status()); - assertNull(result.externalId()); - assertTrue(result.redactedSummary().contains("\"chunksCount\":1")); - assertFalse(result.redactedSummary().contains("secret chunk content")); - assertFalse(result.redactedSummary().contains("raw answer text")); - assertFalse(result.redactedSummary().contains("private retrieval question")); - assertFalse(result.redactedSummary().contains("private prompt")); - } - - @Test - void retrieveChunksShouldOmitDocumentIdsFromBodyWhenNull() { - // 回归:document_ids 为 null(P-A 按授权 dataset 全量检索、不限定具体文档)时不得放入 body; - // RAGFlow 要求其为 list,传 null 会被拒 code 102 "`documents` should be a list" 致检索 fail-closed → P-A 永不命中。 - CapturingHttpRagFlowKnowledgeRuntimeClient client = new CapturingHttpRagFlowKnowledgeRuntimeClient( - "https://ragflow.example", "configured-api-key", - "{\"code\":0,\"data\":{\"chunks\":[{\"content\":\"c\",\"score\":0.9}]}}"); - - KnowledgeRuntimeClient.RuntimeResult result = client.retrieveChunks( - new KnowledgeRuntimeClient.RetrieveChunksCommand(100L, 2001L, 4001L, - List.of("dataset-1"), null, "q", 5, 0.2, null, "corr-null-doc", "hash-null-doc", 1)); - - assertEquals(KnowledgeRuntimeClient.Status.SUCCEEDED, result.status()); - String requestBody = new String(client.requests.get(0).body(), StandardCharsets.UTF_8); - assertFalse(requestBody.contains("document_ids"), "document_ids 为 null 时不应出现在请求 body"); - assertTrue(requestBody.contains("dataset_ids")); - } - - @Test - void retrieveChunksShouldSendMetadataConditionWithOfficialRagflowFieldName() { - // 回归:RAGFlow 检索元数据过滤的官方字段是 metadata_condition; - // metadata_filter 会被外部服务静默忽略,不能作为任何隔离或筛选证据。 - CapturingHttpRagFlowKnowledgeRuntimeClient client = new CapturingHttpRagFlowKnowledgeRuntimeClient( - "https://ragflow.example", "configured-api-key", - "{\"code\":0,\"data\":{\"chunks\":[{\"content\":\"c\",\"score\":0.9}]}}"); - - KnowledgeRuntimeClient.RuntimeResult result = client.retrieveChunks( - new KnowledgeRuntimeClient.RetrieveChunksCommand(100L, 2001L, 4001L, - List.of("dataset-1"), List.of("doc-1"), "q", 5, 0.2, - Map.of("logic", "and", "conditions", List.of(Map.of( - "name", "source", "comparison_operator", "=", "value", "public"))), - "corr-metadata-condition", "hash-metadata-condition", 1)); - - assertEquals(KnowledgeRuntimeClient.Status.SUCCEEDED, result.status()); - String requestBody = new String(client.requests.get(0).body(), StandardCharsets.UTF_8); - assertTrue(requestBody.contains("\"metadata_condition\"")); - assertFalse(requestBody.contains("\"metadata_filter\"")); - } - - @Test - void pollDocumentStatusesShouldUseRagflowIdQueryForSingleDocumentSmoke() { - CapturingHttpRagFlowKnowledgeRuntimeClient client = new CapturingHttpRagFlowKnowledgeRuntimeClient( - "https://ragflow.example", "configured-api-key", """ - {"code":0,"data":{"docs":[ - {"id":"doc-1","run":"DONE","progress":100,"progress_msg":"done"}, - {"id":"doc-2","run":"RUNNING","progress":50,"progress_msg":"running"}, - {"id":"doc-3","run":"DONE","progress":100,"progress_msg":"done"} - ]}} - """); - - KnowledgeRuntimeClient.RuntimeResult result = client.pollDocumentStatuses( - new KnowledgeRuntimeClient.PollDocumentStatusesCommand(100L, 2001L, 4001L, - "dataset-1", List.of("doc-1"), "task-1", "corr-poll-1", "hash-poll-1", 1)); - - assertEquals(KnowledgeRuntimeClient.Status.SUCCEEDED, result.status()); - assertEquals(List.of("doc-1"), result.documentStatuses().stream() - .map(KnowledgeRuntimeClient.DocumentStatus::ragflowDocumentId).toList()); - assertTrue(client.requests.get(0).path().contains("id=doc-1")); - assertFalse(client.requests.get(0).path().contains("document_ids=")); - } - - @Test - void pollDocumentStatusesShouldRejectMultipleDocumentIdsBeforeCallingRagflow() { - CapturingHttpRagFlowKnowledgeRuntimeClient client = new CapturingHttpRagFlowKnowledgeRuntimeClient( - "https://ragflow.example", "configured-api-key", "{\"code\":0,\"data\":{\"docs\":[]}}"); - - KnowledgeRuntimeClient.RuntimeResult result = client.pollDocumentStatuses( - new KnowledgeRuntimeClient.PollDocumentStatusesCommand(100L, 2001L, 4001L, - "dataset-1", List.of("doc-1", "doc-2"), "task-1", "corr-poll-many", "hash-poll-many", 1)); - - assertEquals(KnowledgeRuntimeClient.Status.FAILED, result.status()); - assertEquals(KnowledgeRuntimeClient.FailureClass.VALIDATION_ERROR, result.failureClass()); - assertTrue(result.redactedSummary().contains("single document status polling")); - assertEquals(0, client.requests.size()); - } - - private static boolean containsSubsequence(byte[] payload, byte[] expected) { - for (int start = 0; start <= payload.length - expected.length; start++) { - if (Arrays.equals(Arrays.copyOfRange(payload, start, start + expected.length), expected)) { - return true; - } - } - return false; - } - - private static final class StubHttpRagFlowKnowledgeRuntimeClient extends HttpRagFlowKnowledgeRuntimeClient { - - private final String body; - private final int statusCode; - - private StubHttpRagFlowKnowledgeRuntimeClient(String baseUrl, String apiKey, String body) { - this(baseUrl, apiKey, 200, body); - } - - private StubHttpRagFlowKnowledgeRuntimeClient(String baseUrl, String apiKey, int statusCode, String body) { - super(baseUrl, apiKey, Duration.ofSeconds(1), 0, true); - this.statusCode = statusCode; - this.body = body; - } - - @Override - protected HttpResponsePayload send(HttpRequestPayload request) throws IOException, InterruptedException { - return new HttpResponsePayload(statusCode, body, HttpHeaders.of(Map.of(), (name, value) -> true)); - } - - } - - private static final class CapturingHttpRagFlowKnowledgeRuntimeClient extends HttpRagFlowKnowledgeRuntimeClient { - - private final String body; - private final List requests = new ArrayList<>(); - - private CapturingHttpRagFlowKnowledgeRuntimeClient(String baseUrl, String apiKey, String body) { - super(baseUrl, apiKey, Duration.ofSeconds(1), 0, true); - this.body = body; - } - - @Override - protected HttpResponsePayload send(HttpRequestPayload request) throws IOException, InterruptedException { - requests.add(request); - return new HttpResponsePayload(200, body, HttpHeaders.of(Map.of(), (name, value) -> true)); - } - - } - -} diff --git a/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/test/java/cn/iocoder/muse/module/knowledge/application/muse/facade/P1rRagFlowLiveAcceptanceIT.java b/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/test/java/cn/iocoder/muse/module/knowledge/application/muse/facade/P1rRagFlowLiveAcceptanceIT.java deleted file mode 100644 index 94f5f6d0..00000000 --- a/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/test/java/cn/iocoder/muse/module/knowledge/application/muse/facade/P1rRagFlowLiveAcceptanceIT.java +++ /dev/null @@ -1,237 +0,0 @@ -package cn.iocoder.muse.module.knowledge.application.muse.facade; - -import cn.iocoder.muse.framework.common.util.json.JsonUtils; -import com.fasterxml.jackson.databind.JsonNode; -import org.junit.jupiter.api.Test; - -import java.net.URI; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.time.Duration; -import java.time.Instant; -import java.util.HexFormat; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assumptions.assumeTrue; - -/** - * P1R-5 RAGFlow 外部 live acceptance 入口。 - * - *

默认必须跳过,只有显式设置 MUSE_P1R_EXTERNAL_ACCEPTANCE=true 才会真实调用外部 RAGFlow。 - * 本测试会保留新建 dataset 作为验收证据,不做删除;输出证据只打印 endpoint 与 key 指纹。

- */ -class P1rRagFlowLiveAcceptanceIT { - - private static final String ACCEPTANCE_ENV = "MUSE_P1R_EXTERNAL_ACCEPTANCE"; - private static final String BASE_URL_ENV = "MUSE_KNOWLEDGE_RAGFLOW_BASE_URL"; - private static final String API_KEY_ENV = "MUSE_KNOWLEDGE_RAGFLOW_API_KEY"; - private static final String GRAPHRAG_READY_ENV = "MUSE_KNOWLEDGE_RAGFLOW_GRAPHRAG_ATTRIBUTION_READY"; - - @Test - void shouldCallRagFlowThroughMuseRuntimeClientAndPrintRedactedEvidence() throws Exception { - assumeTrue(externalAcceptanceEnabled(), "P1R 外部验收未启用,设置 MUSE_P1R_EXTERNAL_ACCEPTANCE=true 后才运行"); - - String baseUrl = requiredEnv(BASE_URL_ENV); - String apiKey = requiredEnv(API_KEY_ENV); - boolean graphRagReady = Boolean.parseBoolean(envOrDefault(GRAPHRAG_READY_ENV, "false")); - HttpRagFlowKnowledgeRuntimeClient client = client(baseUrl, apiKey, graphRagReady); - String suffix = String.valueOf(Instant.now().toEpochMilli()); - String datasetName = "p1r-live-" + suffix; - String correlationPrefix = "p1r5-ragflow-live-" + suffix; - - KnowledgeRuntimeClient.RuntimeResult createDataset = client.createDataset( - new KnowledgeRuntimeClient.CreateDatasetCommand(1L, 1L, 1L, datasetName, - Map.of(), correlationPrefix + "-dataset", hash(datasetName), 1)); - assertSucceeded(createDataset, "createDataset"); - assertNotNull(createDataset.externalId(), "RAGFlow createDataset 必须返回 dataset id"); - String datasetId = createDataset.externalId(); - - byte[] content = liveDocumentContent(suffix); - KnowledgeRuntimeClient.RuntimeResult upload = client.uploadDocuments( - new KnowledgeRuntimeClient.UploadDocumentsCommand(1L, 1L, 1L, datasetId, - List.of(new KnowledgeRuntimeClient.DocumentUpload(1L, 1L, - "p1r-live-" + suffix, datasetName + ".txt", "text/plain", - (long) content.length, content)), - correlationPrefix + "-upload", hash(datasetId + "-upload"), 1)); - assertSucceeded(upload, "uploadDocuments"); - assertNotNull(upload.externalId(), "RAGFlow uploadDocuments 必须返回 document id"); - String documentId = upload.externalId(); - - KnowledgeRuntimeClient.RuntimeResult parse = client.startParseDocuments( - new KnowledgeRuntimeClient.StartParseDocumentsCommand(1L, 1L, 1L, datasetId, - List.of(documentId), correlationPrefix + "-parse-task", - correlationPrefix + "-parse", hash(documentId + "-parse"), 1)); - assertEquals(KnowledgeRuntimeClient.Status.ACCEPTED, parse.status(), - () -> "startParseDocuments 未进入 accepted:" + parse.redactedSummary()); - - KnowledgeRuntimeClient.RuntimeResult poll = waitUntilSearchable(client, datasetId, documentId, - correlationPrefix); - KnowledgeRuntimeClient.RuntimeResult retrieval = client.retrieveChunks( - new KnowledgeRuntimeClient.RetrieveChunksCommand(1L, 1L, 1L, - List.of(datasetId), List.of(documentId), "P1R live acceptance marker 是什么?", - 3, 0.0, Map.of(), correlationPrefix + "-retrieval", - hash(documentId + "-retrieval"), 1)); - assertSucceeded(retrieval, "retrieveChunks"); - assertTrue(countFromRedactedSummary(retrieval.redactedSummary(), "chunksCount") > 0, - () -> "retrieveChunks 没有返回 chunk,不能作为 retrieval 正向证据:" + retrieval.redactedSummary()); - - Map evidence = redactedEvidence(baseUrl, apiKey, datasetName, datasetId, documentId, - createDataset, upload, parse, poll, retrieval, graphRagReady); - System.out.println(JsonUtils.toJsonString(evidence)); - } - - private HttpRagFlowKnowledgeRuntimeClient client(String baseUrl, String apiKey, boolean graphRagReady) { - return new HttpRagFlowKnowledgeRuntimeClient(baseUrl, apiKey, - Duration.ofSeconds(intEnv("MUSE_KNOWLEDGE_RAGFLOW_TIMEOUT_SECONDS", 30)), - intEnv("MUSE_KNOWLEDGE_RAGFLOW_RETRY_BUDGET", 0), graphRagReady); - } - - private KnowledgeRuntimeClient.RuntimeResult waitUntilSearchable( - HttpRagFlowKnowledgeRuntimeClient client, String datasetId, String documentId, String correlationPrefix) - throws InterruptedException { - long deadline = System.nanoTime() - + Duration.ofSeconds(intEnv("MUSE_P1R_RAGFLOW_PARSE_WAIT_SECONDS", 120)).toNanos(); - KnowledgeRuntimeClient.RuntimeResult last = null; - int attempt = 1; - while (System.nanoTime() < deadline) { - last = client.pollDocumentStatuses(new KnowledgeRuntimeClient.PollDocumentStatusesCommand( - 1L, 1L, 1L, datasetId, List.of(documentId), correlationPrefix + "-parse-task", - correlationPrefix + "-poll-" + attempt, hash(documentId + "-poll-" + attempt), attempt)); - assertSucceeded(last, "pollDocumentStatuses"); - if (last.documentStatuses().stream().anyMatch(status -> "searchable".equals(status.museStatus()))) { - return last; - } - Thread.sleep(2_000L); - attempt++; - } - throw new AssertionError("RAGFlow document parse 未在等待窗口内进入 searchable,last=" - + (last == null ? "none" : last.redactedSummary())); - } - - private void assertSucceeded(KnowledgeRuntimeClient.RuntimeResult result, String operation) { - assertNotNull(result, operation + " result 不能为空"); - assertEquals(KnowledgeRuntimeClient.Status.SUCCEEDED, result.status(), - () -> operation + " failed: " + result.redactedSummary()); - } - - private byte[] liveDocumentContent(String suffix) { - String text = """ - P1R live acceptance marker %s. - This document is intentionally small and kept in RAGFlow as external acceptance evidence. - Muse Knowledge RAGFlow smoke verifies dataset creation, document upload, parse polling, chunks listing and retrieval. - """.formatted(suffix); - return text.getBytes(StandardCharsets.UTF_8); - } - - private Map redactedEvidence(String baseUrl, String apiKey, String datasetName, - String datasetId, String documentId, - KnowledgeRuntimeClient.RuntimeResult createDataset, - KnowledgeRuntimeClient.RuntimeResult upload, - KnowledgeRuntimeClient.RuntimeResult parse, - KnowledgeRuntimeClient.RuntimeResult poll, - KnowledgeRuntimeClient.RuntimeResult retrieval, - boolean graphRagReady) { - Map evidence = new LinkedHashMap<>(); - evidence.put("acceptance", "p1r5-ragflow-live"); - evidence.put("endpoint", endpointSummary(baseUrl, "/api/v1")); - evidence.put("apiKey", secretFingerprint(apiKey)); - evidence.put("datasetName", datasetName); - evidence.put("datasetId", datasetId); - evidence.put("documentId", documentId); - evidence.put("createDataset", operationSummary(createDataset)); - evidence.put("uploadDocuments", operationSummary(upload)); - evidence.put("startParseDocuments", operationSummary(parse)); - evidence.put("pollDocumentStatuses", operationSummary(poll)); - evidence.put("documentStatuses", poll.documentStatuses()); - evidence.put("retrieveChunks", operationSummary(retrieval)); - evidence.put("retrievalChunksCount", countFromRedactedSummary(retrieval.redactedSummary(), "chunksCount")); - evidence.put("graphRagAttributionReady", graphRagReady); - evidence.put("datasetRetained", true); - return evidence; - } - - private Map operationSummary(KnowledgeRuntimeClient.RuntimeResult result) { - Map summary = new LinkedHashMap<>(); - summary.put("operation", result.operation().wireName()); - summary.put("status", result.status()); - summary.put("failureClass", result.failureClass()); - summary.put("durationMillis", result.durationMillis()); - summary.put("externalIdPresent", result.externalId() != null && !result.externalId().isBlank()); - return summary; - } - - private int countFromRedactedSummary(String redactedSummary, String fieldName) { - JsonNode node = JsonUtils.parseTree(redactedSummary == null || redactedSummary.isBlank() ? "{}" : redactedSummary); - return node.has(fieldName) && node.path(fieldName).canConvertToInt() ? node.path(fieldName).asInt() : 0; - } - - private Map endpointSummary(String baseUrl, String path) { - URI uri = URI.create(trimRight(baseUrl, "/") + path); - Map endpoint = new LinkedHashMap<>(); - endpoint.put("scheme", uri.getScheme()); - endpoint.put("host", uri.getHost()); - endpoint.put("port", uri.getPort()); - endpoint.put("path", uri.getPath()); - return endpoint; - } - - private Map secretFingerprint(String secret) { - Map fingerprint = new LinkedHashMap<>(); - fingerprint.put("length", secret == null ? 0 : secret.length()); - fingerprint.put("sha256Prefix", sha256Prefix(secret == null ? "" : secret, 12)); - return fingerprint; - } - - private boolean externalAcceptanceEnabled() { - return "true".equalsIgnoreCase(System.getenv(ACCEPTANCE_ENV)); - } - - private String requiredEnv(String name) { - String value = System.getenv(name); - assertTrue(value != null && !value.isBlank(), "缺少外部验收环境变量:" + name); - return value; - } - - private String envOrDefault(String name, String defaultValue) { - String value = System.getenv(name); - return value == null || value.isBlank() ? defaultValue : value; - } - - private int intEnv(String name, int defaultValue) { - String value = System.getenv(name); - if (value == null || value.isBlank()) { - return defaultValue; - } - return Integer.parseInt(value); - } - - private String hash(String value) { - return sha256Prefix(value == null ? "" : value, 64); - } - - private String sha256Prefix(String value, int length) { - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - String hex = HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8))); - return hex.substring(0, Math.min(length, hex.length())); - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException("JDK 缺少 SHA-256 摘要算法", e); - } - } - - private String trimRight(String value, String suffix) { - String result = value == null ? "" : value.trim(); - while (result.endsWith(suffix)) { - result = result.substring(0, result.length() - suffix.length()); - } - return result; - } - -} diff --git a/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/test/java/cn/iocoder/muse/module/knowledge/application/muse/facade/RagFlowKnowledgeRuntimeClientConfigurationTest.java b/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/test/java/cn/iocoder/muse/module/knowledge/application/muse/facade/RagFlowKnowledgeRuntimeClientConfigurationTest.java deleted file mode 100644 index c91ad205..00000000 --- a/muse-cloud/muse-module-knowledge/muse-module-knowledge-server/src/test/java/cn/iocoder/muse/module/knowledge/application/muse/facade/RagFlowKnowledgeRuntimeClientConfigurationTest.java +++ /dev/null @@ -1,37 +0,0 @@ -package cn.iocoder.muse.module.knowledge.application.muse.facade; - -import org.junit.jupiter.api.Test; -import org.springframework.mock.env.MockEnvironment; - -import static org.junit.jupiter.api.Assertions.assertInstanceOf; - -/** - * RAGFlow runtime client Spring 装配测试。 - */ -class RagFlowKnowledgeRuntimeClientConfigurationTest { - - @Test - void should_registerUnavailableClientWhenRequiredConfigMissing() { - RagFlowKnowledgeRuntimeClientConfiguration configuration = new RagFlowKnowledgeRuntimeClientConfiguration(); - - KnowledgeRuntimeClient client = configuration.ragFlowKnowledgeRuntimeClient(new MockEnvironment()); - - assertInstanceOf(UnavailableRagFlowKnowledgeRuntimeClient.class, client); - } - - @Test - void should_registerHttpClientWhenBaseUrlAndApiKeyAreConfigured() { - MockEnvironment environment = new MockEnvironment() - .withProperty("muse.knowledge.ragflow.base-url", "https://ragflow.example") - .withProperty("muse.knowledge.ragflow.api-key", "test-api-key") - .withProperty("muse.knowledge.ragflow.timeout-seconds", "7") - .withProperty("muse.knowledge.ragflow.retry-budget", "2") - .withProperty("muse.knowledge.ragflow.graphrag-attribution-ready", "true"); - RagFlowKnowledgeRuntimeClientConfiguration configuration = new RagFlowKnowledgeRuntimeClientConfiguration(); - - KnowledgeRuntimeClient client = configuration.ragFlowKnowledgeRuntimeClient(environment); - - assertInstanceOf(HttpRagFlowKnowledgeRuntimeClient.class, client); - } - -} diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rKnowledgeRuntimeEndToEndLiveAcceptanceIT.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rKnowledgeRuntimeEndToEndLiveAcceptanceIT.java deleted file mode 100644 index 082c0899..00000000 --- a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rKnowledgeRuntimeEndToEndLiveAcceptanceIT.java +++ /dev/null @@ -1,1061 +0,0 @@ -package cn.iocoder.muse.server.framework.api; - -import cn.hutool.extra.spring.SpringUtil; -import cn.iocoder.muse.framework.common.enums.UserTypeEnum; -import cn.iocoder.muse.framework.common.util.json.JsonUtils; -import cn.iocoder.muse.framework.datasource.config.MuseDataSourceAutoConfiguration; -import cn.iocoder.muse.framework.mybatis.config.MuseMybatisAutoConfiguration; -import cn.iocoder.muse.framework.security.core.LoginUser; -import cn.iocoder.muse.framework.security.core.util.SecurityFrameworkUtils; -import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder; -import cn.iocoder.muse.framework.tenant.core.util.TenantUtils; -import cn.iocoder.muse.module.infra.api.file.FileApi; -import cn.iocoder.muse.module.knowledge.application.muse.MuseKnowledgeAuditService; -import cn.iocoder.muse.module.knowledge.application.muse.MuseKnowledgeCommandService; -import cn.iocoder.muse.module.knowledge.application.muse.MuseKnowledgeDocumentService; -import cn.iocoder.muse.module.knowledge.application.muse.MuseKnowledgeMaterializationService; -import cn.iocoder.muse.module.knowledge.application.muse.MuseKnowledgeProcessingTaskService; -import cn.iocoder.muse.module.knowledge.application.muse.facade.HttpRagFlowKnowledgeRuntimeClient; -import cn.iocoder.muse.module.knowledge.application.muse.facade.KnowledgeFileFacade; -import cn.iocoder.muse.module.knowledge.application.muse.facade.KnowledgeRuntimeClient; -import cn.iocoder.muse.module.knowledge.controller.app.muse.vo.AppKnowledgeDocumentVO; -import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeBaseDO; -import cn.iocoder.muse.module.knowledge.dal.mysql.muse.MuseKnowledgeBaseMapper; -import com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration; -import com.github.yulichang.autoconfigure.MybatisPlusJoinAutoConfiguration; -import com.fasterxml.jackson.databind.JsonNode; -import org.flywaydb.core.Flyway; -import org.flywaydb.core.api.MigrationInfo; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.ObjectProvider; -import org.springframework.boot.WebApplicationType; -import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; -import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration; -import org.springframework.boot.builder.SpringApplicationBuilder; -import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.context.ConfigurableApplicationContext; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Import; -import org.springframework.context.annotation.Primary; -import org.springframework.core.env.MapPropertySource; -import org.springframework.mock.web.MockHttpServletRequest; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.util.StringUtils; - -import javax.sql.DataSource; -import java.net.URI; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.sql.Connection; -import java.sql.DriverManager; -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.sql.Statement; -import java.time.Duration; -import java.time.Instant; -import java.util.HexFormat; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; -import java.util.Set; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assumptions.assumeTrue; - -/** - * P1R-5 Knowledge 主业务入口到真实 RAGFlow / PostgreSQL 的 opt-in live acceptance。 - * - *

默认必须跳过,避免普通测试误清 PostgreSQL 或外呼真实 RAGFlow。只有显式设置 - * {@code MUSE_P1R_EXTERNAL_ACCEPTANCE=true} 时,本测试才会清理 PostgreSQL {@code _test} 库、 - * 迁移到 V14、seed 最小用户 KB,并通过 {@link MuseKnowledgeDocumentService#uploadAppDocument} - * 触发真实 {@link HttpRagFlowKnowledgeRuntimeClient}。

- */ -class P1rKnowledgeRuntimeEndToEndLiveAcceptanceIT { - - private static final String ACCEPTANCE_ENV = "MUSE_P1R_EXTERNAL_ACCEPTANCE"; - private static final String JDBC_URL_PROPERTY = "p1r.flyway.url"; - private static final String JDBC_USER_PROPERTY = "p1r.flyway.user"; - private static final String JDBC_URL_ENV = "P1R_FLYWAY_URL"; - private static final String JDBC_USER_ENV = "P1R_FLYWAY_USER"; - private static final String FLYWAY_LOCATION_PROPERTY = "p1r.flyway.locations"; - private static final String POSTGRESQL_JDBC_PREFIX = "jdbc:postgresql://"; - private static final String JDBC_URI_PREFIX = "jdbc:"; - private static final String TARGET_VERSION = "14"; - private static final Long TENANT_ID = 100L; - private static final Long OWNER_USER_ID = 2001L; - private static final String API_VERSION = "1"; - private static final String RAGFLOW_API_PATH = "/api/v1"; - private static final Set CREDENTIAL_QUERY_KEYS = Set.of( - "user", "username", "password", "pass", "pwd", "sslpassword", "ssl_password", - "token", "secret", "api_key", "apikey", "bearer", "access_token", "refresh_token"); - - @AfterEach - void clearRuntimeContexts() { - // live acceptance 在同一 JVM 内手工设置租户和登录态,测试结束必须清理,避免污染后续测试。 - SecurityContextHolder.clearContext(); - TenantContextHolder.clear(); - } - - @Test - void shouldMaskNonPostgresqlJdbcAuthorityInFailureOutput() { - String masked = maskedUrl("jdbc:mysql://user:password@host:3306/db_test?password=x"); - - assertFalse(masked.contains("user"), () -> "非 PostgreSQL JDBC URL 失败摘要不能泄露 userinfo: " + masked); - assertFalse(masked.contains("password"), () -> "非 PostgreSQL JDBC URL 失败摘要不能泄露密码: " + masked); - assertFalse(masked.contains("host"), () -> "非 PostgreSQL JDBC URL 失败摘要不能泄露真实 host: " + masked); - } - - @Test - void shouldMaskMalformedPostgresqlJdbcParseFailureOutput() { - String url = "jdbc:postgresql://user:password@bad host/muse_local_test?password=x"; - - AssertionError error = assertThrows(AssertionError.class, () -> assertSafeJdbcUrl(url)); - String message = error.getMessage(); - - assertTrue(message.contains("") || message.contains(""), - () -> "malformed PostgreSQL JDBC URL 解析失败消息必须包含脱敏摘要: " + message); - assertFalse(message.contains("user"), () -> "解析失败消息不能泄露 userinfo: " + message); - assertFalse(message.contains("password"), () -> "解析失败消息不能泄露密码或凭据参数: " + message); - assertFalse(message.contains("bad host"), () -> "解析失败消息不能泄露真实 host: " + message); - } - - @Test - void shouldUploadKnowledgeDocumentThroughMuseBusinessServiceAndPersistDbFactsWithRealPostgresqlAndRagFlow() - throws Exception { - assumeTrue(externalAcceptanceEnabled(), - "P1R 外部端到端验收未启用,设置 MUSE_P1R_EXTERNAL_ACCEPTANCE=true 后才运行"); - - LiveSettings settings = LiveSettings.fromEnvironment(); - DatabaseEngineFacts dbEngine = migrateIsolatedTestDatabase(settings); - - try (ConfigurableApplicationContext context = liveContext(settings)) { - setLiveLoginUser(); - LiveResult result = TenantUtils.execute(TENANT_ID, () -> executeMuseKnowledgeBusinessFlow(context)); - DbFacts dbFacts = readDbFacts(settings, result); - assertDbFacts(result, dbFacts); - ExternalVerification externalVerification = verifyExternalRagFlowSearchable(context, result, dbFacts); - - System.out.println(JsonUtils.toJsonString(redactedEvidence(settings, result, dbFacts, - externalVerification, dbEngine))); - } - } - - private void setLiveLoginUser() { - // 当前测试直接调用 application service,没有 HTTP 认证过滤器;这里补齐审计字段依赖的登录态。 - SecurityFrameworkUtils.setLoginUser(liveLoginUser(), new MockHttpServletRequest()); - } - - private LoginUser liveLoginUser() { - LoginUser loginUser = new LoginUser(); - loginUser.setId(OWNER_USER_ID); - loginUser.setUserType(UserTypeEnum.MEMBER.getValue()); - loginUser.setTenantId(TENANT_ID); - loginUser.setVisitTenantId(TENANT_ID); - return loginUser; - } - - private LiveResult executeMuseKnowledgeBusinessFlow(ConfigurableApplicationContext context) { - Long kbId = seedUserKnowledgeBase(context); - String suffix = String.valueOf(Instant.now().toEpochMilli()); - String commandId = "p1r5-knowledge-e2e-live-" + suffix; - - AppKnowledgeDocumentVO.UploadReqVO request = new AppKnowledgeDocumentVO.UploadReqVO(); - request.setCommandId(commandId); - request.setEntryContent(liveDocumentContent(suffix)); - request.setSourceDescription("P1R-5 Knowledge 主业务 live acceptance"); - - AppKnowledgeDocumentVO.UploadRespVO response = context.getBean(MuseKnowledgeDocumentService.class) - .uploadAppDocument(OWNER_USER_ID, API_VERSION, kbId, request); - assertNotNull(response.getDocumentId(), "uploadAppDocument 必须返回 documentId"); - assertTrue(StringUtils.hasText(response.getScanTaskId()), "uploadAppDocument 必须返回 processing task id"); - assertEquals("pending_scan", response.getProcessingStatus(), - "上传响应不是最终 RAGFlow 证据,最终判断必须读取 PostgreSQL facts"); - return new LiveResult(kbId, commandId, response.getDocumentId(), response.getScanTaskId(), suffix); - } - - private Long seedUserKnowledgeBase(ConfigurableApplicationContext context) { - restartKnowledgeBaseIdentity(context.getBean(DataSource.class)); - MuseKnowledgeBaseDO kb = new MuseKnowledgeBaseDO(); - kb.setName("P1R-5 live user KB"); - kb.setDescription("P1R-5 Knowledge / RAGFlow 主业务端到端验收 seed KB"); - kb.setKbType("user"); - kb.setOwnerUserId(OWNER_USER_ID); - kb.setStatus("active"); - kb.setActiveVersion(1); - kb.setVisibilityPolicy(JsonUtils.toJsonString(Map.of("scope", "p1r-live", "owner", "user"))); - kb.setCommandId("p1r5-live-kb-" + Instant.now().toEpochMilli()); - kb.setRevision(1); - kb.setTenantId(TENANT_ID); - context.getBean(MuseKnowledgeBaseMapper.class).insert(kb); - assertNotNull(kb.getId(), "seed KB 必须返回 id"); - return kb.getId(); - } - - private void restartKnowledgeBaseIdentity(DataSource dataSource) { - long restartWith = Instant.now().toEpochMilli(); - try (Connection connection = dataSource.getConnection(); - Statement statement = connection.createStatement()) { - // RAGFlow datasetName 由业务服务按 kbId + activeVersion 生成;重置到时间戳区间可避免 live 重跑撞上旧外部 dataset。 - statement.execute("ALTER TABLE muse_knowledge_base ALTER COLUMN id RESTART WITH " + restartWith); - } catch (SQLException ex) { - throw new IllegalStateException("设置 P1R live seed KB identity 起点失败", ex); - } - } - - private String liveDocumentContent(String suffix) { - return """ - P1R-5 Knowledge live acceptance marker %s. - Muse Knowledge business service uploads this entryContent through uploadAppDocument. - The live assertion proves Muse DB facts for KB, document, version, processing task, bindings, RAGFlow calls and command. - """.formatted(suffix); - } - - private void assertDbFacts(LiveResult result, DbFacts facts) { - assertEquals(1, facts.knowledgeBaseCount(), "必须存在 1 行 muse_knowledge_base seed KB"); - assertEquals(1, facts.documentCount(), "必须存在 1 行 muse_knowledge_document"); - assertEquals(1, facts.documentVersionCount(), "必须存在 1 行 muse_knowledge_document_version"); - assertEquals(1, facts.processingTaskCount(), "必须存在 1 行 muse_knowledge_processing_task"); - assertEquals(1, facts.commandCount(), "必须存在 1 行 muse_knowledge_command"); - assertEquals(2, facts.bindingCount(), "必须存在 dataset binding + document version binding"); - assertEquals(1, facts.datasetBindingCount(), "必须存在 1 行 dataset binding"); - assertEquals(1, facts.documentBindingCount(), "必须存在 1 行 document version binding"); - assertTrue(facts.ragflowCallCount() >= 3, "必须至少记录 createDataset/uploadDocuments/startParseDocuments 3 次 RAGFlow 调用"); - - assertEquals("user", facts.kbType(), "seed KB 必须是用户 KB"); - assertEquals("active", facts.kbStatus(), "seed KB 必须是 active"); - assertEquals(1, facts.kbActiveVersion(), "seed KB activeVersion 必须是 1"); - assertEquals("completed", facts.commandStatus(), "upload command 必须完成,证明可回放快照已落库"); - assertEquals("passed", facts.documentVersionScanStatus(), "test-only scanner 必须只把扫描结果标记为 passed"); - assertEquals("processing", facts.documentVersionProcessingStatus(), "RAGFlow parse accepted 后资料版本应处于 processing"); - assertEquals("parsing", facts.processingTaskStatus(), "processing_task 必须进入 parsing 语义"); - assertEquals("materialized", facts.materializationStatus(), "entryContent 必须已材料化为可上传 bytes"); - assertEquals("passed", facts.processingTaskScanStatus(), "processing_task 必须记录扫描已通过"); - assertEquals("processing", facts.processingTaskParseStatus(), "processing_task 必须记录 parse processing"); - assertEquals("pending", facts.processingTaskIndexStatus(), "parse accepted 后 index 仍应等待轮询推进"); - assertEquals(result.scanTaskId(), facts.processingTaskId(), "响应 scanTaskId 必须对应 DB processing_task.task_id"); - assertTrue(StringUtils.hasText(facts.ragflowDatasetId()), "processing_task 必须写入 ragflow_dataset_id"); - assertTrue(StringUtils.hasText(facts.ragflowDocumentId()), "processing_task 必须写入 ragflow_document_id"); - assertTrue(facts.processingTaskStarted(), "processing_task 必须有 started_at"); - assertFalse(facts.processingTaskFinished(), "parse accepted 后 processing_task 不应被伪造为 finished"); - assertTrue(StringUtils.hasText(facts.processingTaskResultSummary()) - && !"{}".equals(facts.processingTaskResultSummary().trim()), - "processing_task 必须有非空 result_summary"); - - assertTrue(facts.createDatasetSucceeded(), "ragflow_call 必须记录 createDataset succeeded"); - assertTrue(facts.uploadDocumentsSucceeded(), "ragflow_call 必须记录 uploadDocuments succeeded"); - assertTrue(facts.startParseAccepted(), "ragflow_call 必须记录 startParseDocuments accepted"); - assertEquals(facts.ragflowDatasetId(), facts.datasetBindingDatasetId(), - "dataset binding 必须和 processing_task datasetId 一致"); - assertEquals(facts.ragflowDatasetId(), facts.documentBindingDatasetId(), - "document binding 必须和 processing_task datasetId 一致"); - assertEquals(facts.ragflowDocumentId(), facts.documentBindingDocumentId(), - "document binding 必须和 processing_task documentId 一致"); - assertTrue(facts.allRagflowResponseSummariesNonEmpty(), - "每条 RAGFlow business call 都必须有非空 response_summary"); - } - - private ExternalVerification verifyExternalRagFlowSearchable(ConfigurableApplicationContext context, - LiveResult result, - DbFacts facts) throws InterruptedException { - KnowledgeRuntimeClient client = context.getBean(KnowledgeRuntimeClient.class); - String correlationPrefix = "p1r5-knowledge-e2e-external-" + result.suffix(); - KnowledgeRuntimeClient.RuntimeResult poll = waitUntilSearchable(client, facts.ragflowDatasetId(), - facts.ragflowDocumentId(), facts.processingTaskId(), correlationPrefix); - KnowledgeRuntimeClient.RuntimeResult retrieval = client.retrieveChunks( - new KnowledgeRuntimeClient.RetrieveChunksCommand(TENANT_ID, OWNER_USER_ID, result.kbId(), - List.of(facts.ragflowDatasetId()), List.of(facts.ragflowDocumentId()), - "P1R-5 Knowledge live acceptance marker 是什么?", 3, 0.0, Map.of(), - correlationPrefix + "-retrieval", hash(facts.ragflowDocumentId() + "-retrieval"), 1)); - assertSucceeded(retrieval, "retrieveChunks"); - int retrievalChunksCount = countFromRedactedSummary(retrieval.redactedSummary(), "chunksCount"); - assertTrue(retrievalChunksCount > 0, - () -> "retrieveChunks 没有返回 chunk,不能作为外部检索证据:" + retrieval.redactedSummary()); - return new ExternalVerification(poll, retrieval, retrievalChunksCount); - } - - private KnowledgeRuntimeClient.RuntimeResult waitUntilSearchable(KnowledgeRuntimeClient client, - String datasetId, - String documentId, - String processingTaskId, - String correlationPrefix) - throws InterruptedException { - long deadline = System.nanoTime() - + Duration.ofSeconds(intEnv("MUSE_P1R_RAGFLOW_PARSE_WAIT_SECONDS", 120)).toNanos(); - KnowledgeRuntimeClient.RuntimeResult last = null; - int attempt = 1; - while (System.nanoTime() < deadline) { - last = client.pollDocumentStatuses(new KnowledgeRuntimeClient.PollDocumentStatusesCommand( - TENANT_ID, OWNER_USER_ID, 0L, datasetId, List.of(documentId), processingTaskId, - correlationPrefix + "-poll-" + attempt, hash(documentId + "-poll-" + attempt), attempt)); - assertSucceeded(last, "pollDocumentStatuses"); - if (last.documentStatuses().stream().anyMatch(status -> "searchable".equals(status.museStatus()))) { - return last; - } - Thread.sleep(2_000L); - attempt++; - } - throw new AssertionError("RAGFlow document parse 未在等待窗口内进入 searchable,last=" - + (last == null ? "none" : last.redactedSummary())); - } - - private void assertSucceeded(KnowledgeRuntimeClient.RuntimeResult result, String operation) { - assertNotNull(result, operation + " result 不能为空"); - assertEquals(KnowledgeRuntimeClient.Status.SUCCEEDED, result.status(), - () -> operation + " failed: " + result.redactedSummary()); - } - - private DbFacts readDbFacts(LiveSettings settings, LiveResult result) { - try (Connection connection = DriverManager.getConnection(settings.jdbcUrl(), settings.jdbcUser(), - settings.jdbcPassword())) { - return new DbFacts( - count(connection, "muse_knowledge_base", "id = ?", result.kbId()), - count(connection, "muse_knowledge_document", "id = ?", result.documentId()), - count(connection, "muse_knowledge_document_version", "document_id = ?", result.documentId()), - count(connection, "muse_knowledge_processing_task", "task_id = ?", result.scanTaskId()), - count(connection, "muse_knowledge_command", "command_id = ?", result.commandId()), - count(connection, "muse_knowledge_ragflow_binding", "kb_id = ?", result.kbId()), - count(connection, "muse_knowledge_ragflow_binding", "kb_id = ? AND document_id IS NULL", result.kbId()), - count(connection, "muse_knowledge_ragflow_binding", - "kb_id = ? AND document_version_id = (SELECT id FROM muse_knowledge_document_version WHERE document_id = ?)", - result.kbId(), result.documentId()), - count(connection, "muse_knowledge_ragflow_call", "command_id = ?", result.commandId()), - stringValue(connection, "SELECT kb_type FROM muse_knowledge_base WHERE id = ?", result.kbId()), - stringValue(connection, "SELECT status FROM muse_knowledge_base WHERE id = ?", result.kbId()), - intValue(connection, "SELECT active_version FROM muse_knowledge_base WHERE id = ?", result.kbId()), - stringValue(connection, "SELECT status FROM muse_knowledge_command WHERE command_id = ?", - result.commandId()), - stringValue(connection, """ - SELECT scan_status FROM muse_knowledge_document_version - WHERE document_id = ? - ORDER BY version DESC LIMIT 1 - """, result.documentId()), - stringValue(connection, """ - SELECT processing_status FROM muse_knowledge_document_version - WHERE document_id = ? - ORDER BY version DESC LIMIT 1 - """, result.documentId()), - stringValue(connection, """ - SELECT task_id FROM muse_knowledge_processing_task - WHERE task_id = ? - """, result.scanTaskId()), - stringValue(connection, """ - SELECT status FROM muse_knowledge_processing_task - WHERE task_id = ? - """, result.scanTaskId()), - stringValue(connection, """ - SELECT materialization_status FROM muse_knowledge_processing_task - WHERE task_id = ? - """, result.scanTaskId()), - stringValue(connection, """ - SELECT scan_status FROM muse_knowledge_processing_task - WHERE task_id = ? - """, result.scanTaskId()), - stringValue(connection, """ - SELECT parse_status FROM muse_knowledge_processing_task - WHERE task_id = ? - """, result.scanTaskId()), - stringValue(connection, """ - SELECT index_status FROM muse_knowledge_processing_task - WHERE task_id = ? - """, result.scanTaskId()), - stringValue(connection, """ - SELECT ragflow_dataset_id FROM muse_knowledge_processing_task - WHERE task_id = ? - """, result.scanTaskId()), - stringValue(connection, """ - SELECT ragflow_document_id FROM muse_knowledge_processing_task - WHERE task_id = ? - """, result.scanTaskId()), - booleanValue(connection, """ - SELECT started_at IS NOT NULL FROM muse_knowledge_processing_task - WHERE task_id = ? - """, result.scanTaskId()), - booleanValue(connection, """ - SELECT finished_at IS NOT NULL FROM muse_knowledge_processing_task - WHERE task_id = ? - """, result.scanTaskId()), - stringValue(connection, """ - SELECT result_summary::text FROM muse_knowledge_processing_task - WHERE task_id = ? - """, result.scanTaskId()), - booleanValue(connection, """ - SELECT EXISTS ( - SELECT 1 FROM muse_knowledge_ragflow_call - WHERE tenant_id = ? AND deleted = FALSE AND command_id = ? - AND operation = 'createDataset' AND status = 'succeeded' - ) - """, TENANT_ID, result.commandId()), - booleanValue(connection, """ - SELECT EXISTS ( - SELECT 1 FROM muse_knowledge_ragflow_call - WHERE tenant_id = ? AND deleted = FALSE AND command_id = ? - AND operation = 'uploadDocuments' AND status = 'succeeded' - ) - """, TENANT_ID, result.commandId()), - booleanValue(connection, """ - SELECT EXISTS ( - SELECT 1 FROM muse_knowledge_ragflow_call - WHERE tenant_id = ? AND deleted = FALSE AND command_id = ? - AND operation = 'startParseDocuments' AND status = 'accepted' - ) - """, TENANT_ID, result.commandId()), - stringValue(connection, """ - SELECT ragflow_dataset_id FROM muse_knowledge_ragflow_binding - WHERE tenant_id = ? AND deleted = FALSE AND kb_id = ? AND document_id IS NULL - ORDER BY id DESC LIMIT 1 - """, TENANT_ID, result.kbId()), - stringValue(connection, """ - SELECT ragflow_dataset_id FROM muse_knowledge_ragflow_binding - WHERE tenant_id = ? AND deleted = FALSE AND document_id = ? - ORDER BY id DESC LIMIT 1 - """, TENANT_ID, result.documentId()), - stringValue(connection, """ - SELECT ragflow_document_id FROM muse_knowledge_ragflow_binding - WHERE tenant_id = ? AND deleted = FALSE AND document_id = ? - ORDER BY id DESC LIMIT 1 - """, TENANT_ID, result.documentId()), - booleanValue(connection, """ - SELECT NOT EXISTS ( - SELECT 1 FROM muse_knowledge_ragflow_call - WHERE tenant_id = ? AND deleted = FALSE AND command_id = ? - AND (response_summary IS NULL OR response_summary = '{}'::jsonb) - ) - """, TENANT_ID, result.commandId())); - } catch (SQLException ex) { - throw new IllegalStateException("读取 P1R Knowledge live acceptance DB 事实失败", ex); - } - } - - private long count(Connection connection, String table, String where, Object... args) throws SQLException { - String sql = "SELECT COUNT(*) FROM " + table + " WHERE tenant_id = ? AND deleted = FALSE AND " + where; - try (PreparedStatement statement = connection.prepareStatement(sql)) { - statement.setLong(1, TENANT_ID); - bind(statement, 2, args); - try (ResultSet resultSet = statement.executeQuery()) { - assertTrue(resultSet.next(), "必须能读取计数: " + table); - return resultSet.getLong(1); - } - } - } - - private String stringValue(Connection connection, String sql, Object... args) throws SQLException { - try (PreparedStatement statement = connection.prepareStatement(sql)) { - bind(statement, 1, args); - try (ResultSet resultSet = statement.executeQuery()) { - return resultSet.next() ? resultSet.getString(1) : null; - } - } - } - - private int intValue(Connection connection, String sql, Object... args) throws SQLException { - try (PreparedStatement statement = connection.prepareStatement(sql)) { - bind(statement, 1, args); - try (ResultSet resultSet = statement.executeQuery()) { - return resultSet.next() ? resultSet.getInt(1) : 0; - } - } - } - - private boolean booleanValue(Connection connection, String sql, Object... args) throws SQLException { - try (PreparedStatement statement = connection.prepareStatement(sql)) { - bind(statement, 1, args); - try (ResultSet resultSet = statement.executeQuery()) { - return resultSet.next() && resultSet.getBoolean(1); - } - } - } - - private void bind(PreparedStatement statement, int startIndex, Object... args) throws SQLException { - for (int i = 0; i < args.length; i++) { - Object value = args[i]; - if (value instanceof Long longValue) { - statement.setLong(startIndex + i, longValue); - } else if (value instanceof Integer intValue) { - statement.setInt(startIndex + i, intValue); - } else { - statement.setString(startIndex + i, String.valueOf(value)); - } - } - } - - private ConfigurableApplicationContext liveContext(LiveSettings settings) { - Map properties = new LinkedHashMap<>(); - properties.put("muse.info.base-package", "cn.iocoder.muse.module.knowledge"); - properties.put("spring.datasource.url", settings.jdbcUrl()); - properties.put("spring.datasource.username", settings.jdbcUser()); - properties.put("spring.datasource.password", settings.jdbcPassword()); - properties.put("spring.datasource.driver-class-name", "org.postgresql.Driver"); - properties.put("spring.main.banner-mode", "off"); - properties.put("spring.main.lazy-initialization", "true"); - properties.put("mybatis-plus.global-config.db-config.id-type", "AUTO"); - properties.put("muse.knowledge.ragflow.base-url", settings.ragflowBaseUrl()); - properties.put("muse.knowledge.ragflow.api-key", settings.ragflowApiKey()); - properties.put("muse.knowledge.ragflow.timeout-seconds", settings.ragflowTimeoutSeconds()); - properties.put("muse.knowledge.ragflow.retry-budget", settings.ragflowRetryBudget()); - properties.put("muse.knowledge.ragflow.graphrag-attribution-ready", "false"); - - return new SpringApplicationBuilder(LiveAcceptanceConfiguration.class) - .web(WebApplicationType.NONE) - .initializers(applicationContext -> applicationContext.getEnvironment().getPropertySources() - .addFirst(new MapPropertySource("p1r-knowledge-live-acceptance", properties))) - .properties(properties) - .run(); - } - - private DatabaseEngineFacts migrateIsolatedTestDatabase(LiveSettings settings) { - silenceFlywayInfoLogs(); - assertSafeJdbcUrl(settings.jdbcUrl()); - DatabaseEngineFacts dbEngine = assertPostgresqlTestDatabase(settings); - System.out.println(JsonUtils.toJsonString(Map.of( - "acceptance", "p1r5-knowledge-runtime-e2e-live", - "preCleanDbEngine", dbEngineEvidence(dbEngine)))); - Flyway flyway = Flyway.configure() - .dataSource(settings.jdbcUrl(), settings.jdbcUser(), settings.jdbcPassword()) - .locations(resolveMuseSqlLocation(settings.flywayLocations())) - .schemas("public") - .defaultSchema("public") - .target(TARGET_VERSION) - .cleanDisabled(false) - .load(); - flyway.clean(); - flyway.migrate(); - MigrationInfo current = flyway.info().current(); - assertEquals(TARGET_VERSION, Objects.requireNonNull(current, "必须存在当前 Flyway 版本") - .getVersion().getVersion(), "P1R-5 Knowledge live acceptance 必须迁移到 V14 schema"); - return dbEngine; - } - - private DatabaseEngineFacts assertPostgresqlTestDatabase(LiveSettings settings) { - try (Connection connection = DriverManager.getConnection(settings.jdbcUrl(), settings.jdbcUser(), - settings.jdbcPassword()); - PreparedStatement statement = connection.prepareStatement("SELECT current_database(), version()"); - ResultSet resultSet = statement.executeQuery()) { - assertTrue(resultSet.next(), "Flyway clean 前必须能读取 PostgreSQL engine 信息"); - String databaseName = resultSet.getString(1); - String version = resultSet.getString(2); - assertTrue(StringUtils.hasText(databaseName) && databaseName.endsWith("_test"), - "Flyway clean 前 current_database() 必须是 _test 隔离库: " + databaseName); - assertTrue(StringUtils.hasText(version) && version.contains("PostgreSQL"), - "Flyway clean 前 version() 必须来自 PostgreSQL: " + versionSummary(version)); - return new DatabaseEngineFacts(databaseName, version); - } catch (SQLException ex) { - throw new IllegalStateException("Flyway clean 前检查 PostgreSQL _test 数据库失败: " - + maskedUrl(settings.jdbcUrl()), ex); - } - } - - private Map redactedEvidence(LiveSettings settings, LiveResult result, DbFacts facts, - ExternalVerification externalVerification, - DatabaseEngineFacts dbEngine) { - Map evidence = new LinkedHashMap<>(); - evidence.put("acceptance", "p1r5-knowledge-runtime-e2e-live"); - evidence.put("endpoint", endpointSummary(settings.ragflowBaseUrl(), RAGFLOW_API_PATH)); - evidence.put("apiKey", secretFingerprint(settings.ragflowApiKey())); - evidence.put("jdbcUrl", maskedUrl(settings.jdbcUrl())); - evidence.put("flywayTargetVersion", TARGET_VERSION); - evidence.put("dbEngine", dbEngineEvidence(dbEngine)); - evidence.put("request", requestEvidence(result, facts)); - evidence.put("response", responseEvidence(result, facts)); - evidence.put("db", dbEvidence(facts)); - evidence.put("externalVerification", externalEvidence(externalVerification)); - return evidence; - } - - private Map requestEvidence(LiveResult result, DbFacts facts) { - Map request = new LinkedHashMap<>(); - request.put("tenantId", TENANT_ID); - request.put("ownerUserId", OWNER_USER_ID); - request.put("kbId", result.kbId()); - request.put("commandIdSha256Prefix", sha256Prefix(result.commandId(), 12)); - request.put("documentId", result.documentId()); - request.put("processingTaskId", result.scanTaskId()); - request.put("entryContentLength", length(liveDocumentContent(result.suffix()))); - request.put("entryContentSha256Prefix", sha256Prefix(liveDocumentContent(result.suffix()), 12)); - request.put("ragflowDatasetId", facts.ragflowDatasetId()); - request.put("ragflowDocumentId", facts.ragflowDocumentId()); - return request; - } - - private Map responseEvidence(LiveResult result, DbFacts facts) { - Map response = new LinkedHashMap<>(); - response.put("uploadResponseProcessingStatus", "pending_scan"); - response.put("dbTaskStatus", facts.processingTaskStatus()); - response.put("dbParseStatus", facts.processingTaskParseStatus()); - response.put("dbIndexStatus", facts.processingTaskIndexStatus()); - response.put("commandStatus", facts.commandStatus()); - response.put("processingTaskStarted", facts.processingTaskStarted()); - response.put("processingTaskFinished", facts.processingTaskFinished()); - response.put("processingTaskResultSummaryLength", length(facts.processingTaskResultSummary())); - response.put("processingTaskResultSummarySha256Prefix", - sha256Prefix(defaultValue(facts.processingTaskResultSummary(), ""), 12)); - response.put("documentId", result.documentId()); - return response; - } - - private Map dbEvidence(DbFacts facts) { - Map db = new LinkedHashMap<>(); - db.put("knowledgeBaseCount", facts.knowledgeBaseCount()); - db.put("documentCount", facts.documentCount()); - db.put("documentVersionCount", facts.documentVersionCount()); - db.put("processingTaskCount", facts.processingTaskCount()); - db.put("commandCount", facts.commandCount()); - db.put("bindingCount", facts.bindingCount()); - db.put("datasetBindingCount", facts.datasetBindingCount()); - db.put("documentBindingCount", facts.documentBindingCount()); - db.put("ragflowCallCount", facts.ragflowCallCount()); - db.put("createDatasetSucceeded", facts.createDatasetSucceeded()); - db.put("uploadDocumentsSucceeded", facts.uploadDocumentsSucceeded()); - db.put("startParseAccepted", facts.startParseAccepted()); - db.put("allRagflowResponseSummariesNonEmpty", facts.allRagflowResponseSummariesNonEmpty()); - return db; - } - - private Map externalEvidence(ExternalVerification verification) { - Map evidence = new LinkedHashMap<>(); - evidence.put("scope", "external_verification_not_business_persisted"); - evidence.put("pollDocumentStatuses", operationSummary(verification.poll())); - evidence.put("documentStatuses", verification.poll().documentStatuses()); - evidence.put("retrieveChunks", operationSummary(verification.retrieval())); - evidence.put("retrievalChunksCount", verification.retrievalChunksCount()); - return evidence; - } - - private Map operationSummary(KnowledgeRuntimeClient.RuntimeResult result) { - Map summary = new LinkedHashMap<>(); - summary.put("operation", result.operation().wireName()); - summary.put("status", result.status()); - summary.put("failureClass", result.failureClass()); - summary.put("durationMillis", result.durationMillis()); - summary.put("externalIdPresent", result.externalId() != null && !result.externalId().isBlank()); - return summary; - } - - private Map dbEngineEvidence(DatabaseEngineFacts facts) { - Map engine = new LinkedHashMap<>(); - engine.put("databaseNameSha256Prefix", sha256Prefix(facts.databaseName(), 12)); - engine.put("databaseNameSuffix", facts.databaseName().endsWith("_test") ? "_test" : ""); - engine.put("versionSummary", versionSummary(facts.version())); - return engine; - } - - private Map endpointSummary(String baseUrl, String path) { - URI uri = URI.create(trimRight(baseUrl, "/") + path); - Map endpoint = new LinkedHashMap<>(); - endpoint.put("scheme", uri.getScheme()); - endpoint.put("host", uri.getHost()); - endpoint.put("port", uri.getPort()); - endpoint.put("path", uri.getPath()); - return endpoint; - } - - private Map secretFingerprint(String secret) { - Map fingerprint = new LinkedHashMap<>(); - fingerprint.put("length", secret == null ? 0 : secret.length()); - fingerprint.put("sha256Prefix", sha256Prefix(secret == null ? "" : secret, 12)); - return fingerprint; - } - - private int countFromRedactedSummary(String redactedSummary, String fieldName) { - JsonNode node = JsonUtils.parseTree(redactedSummary == null || redactedSummary.isBlank() ? "{}" : redactedSummary); - return node.has(fieldName) && node.path(fieldName).canConvertToInt() ? node.path(fieldName).asInt() : 0; - } - - private static String requiredPropertyOrEnv(String propertyName, String envName) { - String value = System.getProperty(propertyName); - if (!StringUtils.hasText(value)) { - value = System.getenv(envName); - } - assertTrue(StringUtils.hasText(value), "缺少必需系统属性或环境变量: " + propertyName + " / " + envName); - return value.trim(); - } - - private static String requiredEnv(String name) { - String value = System.getenv(name); - assertTrue(StringUtils.hasText(value), "缺少必需环境变量: " + name); - return value.trim(); - } - - private static String requiredPasswordEnvironment() { - String password = firstNonBlankEnvironment("P1R_FLYWAY_PASSWORD", "MUSE_POSTGRES_PASSWORD"); - assertTrue(password != null, "缺少必需数据库密码环境变量: P1R_FLYWAY_PASSWORD 或 MUSE_POSTGRES_PASSWORD"); - return password; - } - - private static String firstNonBlankEnvironment(String... names) { - // 数据库密码不允许从 system property 读取,避免被 Surefire XML 或命令历史记录。 - for (String name : names) { - String value = System.getenv(name); - if (StringUtils.hasText(value)) { - return value; - } - } - return null; - } - - private static int intEnv(String name, int defaultValue) { - String value = System.getenv(name); - if (!StringUtils.hasText(value)) { - return defaultValue; - } - return Integer.parseInt(value.trim()); - } - - private static String resolveMuseSqlLocation(String requestedLocations) { - assertEquals("filesystem:sql/muse", requestedLocations, - "P1R live acceptance 要求显式使用 filesystem:sql/muse"); - Path current = Path.of(System.getProperty("user.dir")).toAbsolutePath(); - for (Path cursor = current; cursor != null; cursor = cursor.getParent()) { - Path candidate = cursor.resolve("sql/muse"); - if (Files.isDirectory(candidate)) { - return "filesystem:" + candidate; - } - } - throw new IllegalStateException("无法从当前目录向上找到 sql/muse: " + current); - } - - private static void assertSafeJdbcUrl(String url) { - assertTrue(url.startsWith(POSTGRESQL_JDBC_PREFIX), - "JDBC URL 必须显式使用 jdbc:postgresql://,避免 Flyway clean 误连其它数据库: " + maskedUrl(url)); - URI uri = parseJdbcUriForAssertion(url); - assertEquals("postgresql", uri.getScheme(), - "JDBC URL 必须解析为 PostgreSQL scheme: " + maskedUrl(url)); - assertTrue(StringUtils.hasText(uri.getHost()), - "JDBC URL 必须显式包含 host: " + maskedUrl(url)); - assertFalse(StringUtils.hasText(uri.getRawUserInfo()), - "JDBC URL 不能携带 authority/userinfo,用户名走属性/环境变量,密码只走环境变量: " + maskedUrl(url)); - assertNoCredentialQuery(url); - String databaseName = jdbcDatabaseName(uri); - assertTrue(databaseName.endsWith("_test"), - "JDBC URL 必须指向 _test 后缀隔离库,避免清理非测试库: " + maskedUrl(url)); - assertFalse("muse_local".equals(databaseName), - "P1R live acceptance 禁止默认连接 muse_local"); - } - - private static URI parseJdbcUriForAssertion(String url) { - try { - return URI.create(url.substring(JDBC_URI_PREFIX.length())); - } catch (IllegalArgumentException ex) { - // JDK URI 解析异常会回显原始 authority/query;断言失败只允许输出脱敏后的 JDBC 摘要。 - throw new AssertionError("JDBC URL 必须可解析为 PostgreSQL URI: " + maskedUrl(url)); - } - } - - private static String jdbcDatabaseName(URI uri) { - String path = uri.getRawPath(); - assertTrue(StringUtils.hasText(path) && path.length() > 1, - "JDBC URL 必须包含数据库名"); - String databaseName = path.substring(path.lastIndexOf('/') + 1); - assertTrue(StringUtils.hasText(databaseName), - "JDBC URL 必须包含数据库名"); - return databaseName; - } - - private static void assertNoCredentialQuery(String url) { - int queryStart = url.indexOf('?'); - if (queryStart < 0) { - return; - } - String query = url.substring(queryStart + 1); - for (String parameter : query.split("&")) { - String key = parameter; - int equalsStart = key.indexOf('='); - if (equalsStart >= 0) { - key = key.substring(0, equalsStart); - } - assertFalse(isCredentialQueryKey(key), - "JDBC URL 不能携带凭据 query 参数;用户名走属性/环境变量,密码只走环境变量"); - } - } - - private static boolean isCredentialQueryKey(String rawKey) { - String key = rawKey.trim().toLowerCase(Locale.ROOT).replace('-', '_'); - return CREDENTIAL_QUERY_KEYS.contains(key) - || key.endsWith("_token") - || key.endsWith("_secret") - || key.endsWith("_password"); - } - - private static String maskedUrl(String url) { - if (!StringUtils.hasText(url)) { - return ""; - } - // 失败消息只能暴露 JDBC 类型、占位 authority、测试库名和 query 是否存在;解析失败也不能回显原始串。 - String trimmed = url.trim(); - if (!trimmed.startsWith(JDBC_URI_PREFIX)) { - return ""; - } - - String jdbcBody = trimmed.substring(JDBC_URI_PREFIX.length()); - String scheme = jdbcScheme(jdbcBody); - if (!StringUtils.hasText(scheme)) { - return JDBC_URI_PREFIX + ""; - } - if (!jdbcBody.regionMatches(true, scheme.length(), "://", 0, 3)) { - return JDBC_URI_PREFIX + scheme + ":"; - } - if ("postgresql".equals(scheme)) { - return maskedPostgresqlUrl(jdbcBody); - } - return maskedHierarchicalJdbcUrl(scheme, jdbcBody); - } - - private static String jdbcScheme(String jdbcBody) { - int schemeEnd = jdbcBody.indexOf(':'); - if (schemeEnd <= 0) { - return ""; - } - String scheme = jdbcBody.substring(0, schemeEnd).trim().toLowerCase(Locale.ROOT); - return scheme.matches("[a-z][a-z0-9+.-]*") ? scheme : ""; - } - - private static String maskedPostgresqlUrl(String jdbcBody) { - try { - URI uri = URI.create(jdbcBody); - String database = safeJdbcDatabaseName(uri.getRawPath(), ""); - String port = uri.getPort() < 0 ? "" : ":" + uri.getPort(); - String suffix = StringUtils.hasText(uri.getRawQuery()) ? "?" : ""; - return POSTGRESQL_JDBC_PREFIX + "" + port + "/" + database + suffix; - } catch (RuntimeException ignored) { - return POSTGRESQL_JDBC_PREFIX + "/" + fallbackJdbcDatabaseName(jdbcBody, "") - + querySuffix(jdbcBody); - } - } - - private static String maskedHierarchicalJdbcUrl(String scheme, String jdbcBody) { - try { - URI uri = URI.create(jdbcBody); - String database = safeJdbcDatabaseName(uri.getRawPath(), ""); - String databaseSuffix = StringUtils.hasText(database) ? "/" + database : ""; - String querySuffix = StringUtils.hasText(uri.getRawQuery()) ? "?" : ""; - return JDBC_URI_PREFIX + scheme + "://" + databaseSuffix + querySuffix; - } catch (RuntimeException ignored) { - String database = fallbackJdbcDatabaseName(jdbcBody, ""); - String databaseSuffix = StringUtils.hasText(database) ? "/" + database : ""; - return JDBC_URI_PREFIX + scheme + "://" + databaseSuffix + querySuffix(jdbcBody); - } - } - - private static String safeJdbcDatabaseName(String rawPath, String fallback) { - if (!StringUtils.hasText(rawPath) || rawPath.length() <= 1) { - return fallback; - } - String database = rawPath.substring(rawPath.lastIndexOf('/') + 1); - if (!StringUtils.hasText(database)) { - return fallback; - } - return database.matches("[A-Za-z0-9._%-]+") ? database : fallback; - } - - private static String fallbackJdbcDatabaseName(String jdbcBody, String fallback) { - int authorityStart = jdbcBody.indexOf("://"); - if (authorityStart < 0) { - return fallback; - } - int pathStart = jdbcBody.indexOf('/', authorityStart + 3); - if (pathStart < 0) { - return fallback; - } - int pathEnd = firstIndexOf(jdbcBody, pathStart + 1, '?', '#'); - String path = jdbcBody.substring(pathStart, pathEnd); - return safeJdbcDatabaseName(path, fallback); - } - - private static int firstIndexOf(String value, int start, char... candidates) { - int result = value.length(); - for (char candidate : candidates) { - int index = value.indexOf(candidate, start); - if (index >= 0 && index < result) { - result = index; - } - } - return result; - } - - private static String querySuffix(String value) { - return value.indexOf('?') < 0 ? "" : "?"; - } - - private static String versionSummary(String version) { - if (!StringUtils.hasText(version)) { - return ""; - } - String normalized = version.replaceAll("\\s+", " ").trim(); - return normalized.length() > 96 ? normalized.substring(0, 96) : normalized; - } - - private static void silenceFlywayInfoLogs() { - try { - Object flywayLogger = LoggerFactory.getLogger("org.flywaydb"); - Class levelClass = Class.forName("ch.qos.logback.classic.Level"); - Object warnLevel = levelClass.getField("WARN").get(null); - flywayLogger.getClass().getMethod("setLevel", levelClass).invoke(flywayLogger, warnLevel); - } catch (ReflectiveOperationException | LinkageError ignored) { - // 日志实现不是 logback 时不影响验收;测试自身仍只输出 masked URL 和摘要。 - } - } - - private static boolean externalAcceptanceEnabled() { - return "true".equalsIgnoreCase(System.getenv(ACCEPTANCE_ENV)); - } - - private static String hash(String value) { - return sha256Prefix(value == null ? "" : value, 64); - } - - private static String sha256Prefix(String value, int length) { - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - String hex = HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8))); - return hex.substring(0, Math.min(length, hex.length())); - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException("JDK 缺少 SHA-256 摘要算法", e); - } - } - - private static String trimRight(String value, String suffix) { - String result = value == null ? "" : value.trim(); - while (result.endsWith(suffix)) { - result = result.substring(0, result.length() - suffix.length()); - } - return result; - } - - private static String defaultValue(String value, String fallback) { - return value == null ? fallback : value; - } - - private static int length(String value) { - return value == null ? 0 : value.length(); - } - - private record LiveSettings(String jdbcUrl, - String jdbcUser, - String jdbcPassword, - String flywayLocations, - String ragflowBaseUrl, - String ragflowApiKey, - int ragflowTimeoutSeconds, - int ragflowRetryBudget) { - - private static LiveSettings fromEnvironment() { - String jdbcUrl = requiredPropertyOrEnv(JDBC_URL_PROPERTY, JDBC_URL_ENV); - assertSafeJdbcUrl(jdbcUrl); - return new LiveSettings( - jdbcUrl, - requiredPropertyOrEnv(JDBC_USER_PROPERTY, JDBC_USER_ENV), - requiredPasswordEnvironment(), - System.getProperty(FLYWAY_LOCATION_PROPERTY, "filesystem:sql/muse"), - requiredEnv("MUSE_KNOWLEDGE_RAGFLOW_BASE_URL"), - requiredEnv("MUSE_KNOWLEDGE_RAGFLOW_API_KEY"), - intEnv("MUSE_KNOWLEDGE_RAGFLOW_TIMEOUT_SECONDS", 30), - intEnv("MUSE_KNOWLEDGE_RAGFLOW_RETRY_BUDGET", 0)); - } - } - - private record LiveResult(Long kbId, - String commandId, - Long documentId, - String scanTaskId, - String suffix) { - } - - private record DbFacts(long knowledgeBaseCount, - long documentCount, - long documentVersionCount, - long processingTaskCount, - long commandCount, - long bindingCount, - long datasetBindingCount, - long documentBindingCount, - long ragflowCallCount, - String kbType, - String kbStatus, - int kbActiveVersion, - String commandStatus, - String documentVersionScanStatus, - String documentVersionProcessingStatus, - String processingTaskId, - String processingTaskStatus, - String materializationStatus, - String processingTaskScanStatus, - String processingTaskParseStatus, - String processingTaskIndexStatus, - String ragflowDatasetId, - String ragflowDocumentId, - boolean processingTaskStarted, - boolean processingTaskFinished, - String processingTaskResultSummary, - boolean createDatasetSucceeded, - boolean uploadDocumentsSucceeded, - boolean startParseAccepted, - String datasetBindingDatasetId, - String documentBindingDatasetId, - String documentBindingDocumentId, - boolean allRagflowResponseSummariesNonEmpty) { - } - - private record ExternalVerification(KnowledgeRuntimeClient.RuntimeResult poll, - KnowledgeRuntimeClient.RuntimeResult retrieval, - int retrievalChunksCount) { - } - - private record DatabaseEngineFacts(String databaseName, - String version) { - } - - @TestConfiguration - @Import({ - MuseDataSourceAutoConfiguration.class, - DataSourceAutoConfiguration.class, - DataSourceTransactionManagerAutoConfiguration.class, - MuseMybatisAutoConfiguration.class, - MybatisPlusAutoConfiguration.class, - MybatisPlusJoinAutoConfiguration.class, - MuseKnowledgeDocumentService.class, - MuseKnowledgeCommandService.class, - MuseKnowledgeAuditService.class, - MuseKnowledgeMaterializationService.class, - MuseKnowledgeProcessingTaskService.class, - SpringUtil.class - }) - static class LiveAcceptanceConfiguration { - - @Bean - KnowledgeRuntimeClient ragFlowKnowledgeRuntimeClient() { - return new HttpRagFlowKnowledgeRuntimeClient(requiredEnv("MUSE_KNOWLEDGE_RAGFLOW_BASE_URL"), - requiredEnv("MUSE_KNOWLEDGE_RAGFLOW_API_KEY"), - Duration.ofSeconds(intEnv("MUSE_KNOWLEDGE_RAGFLOW_TIMEOUT_SECONDS", 30)), - intEnv("MUSE_KNOWLEDGE_RAGFLOW_RETRY_BUDGET", 0), false); - } - - @Bean - @Primary - KnowledgeFileFacade p1rLiveKnowledgeFileFacade(ObjectProvider fileApiProvider) { - // 补足扫描服务后的新构造签名;本 IT override materializeText 走 test-only passed,不经 scanService 判定。 - return new KnowledgeFileFacade(fileApiProvider, - new cn.iocoder.muse.module.knowledge.application.muse.facade.KnowledgeContentScanService()) { - - @Override - public MaterializedFile materializeText(Long kbId, String fileName, String entryContent) { - byte[] content = entryContent.getBytes(StandardCharsets.UTF_8); - // test-only passed scanner 只替代“扫描服务已通过”这一外部前置条件;RAGFlow adapter 和 Muse DB 写入仍走真实主业务链路。 - return MaterializedFile.materialized("p1r-live-file-ref-" + kbId + "-" + sha256Prefix(fileName, 8), - fileName, (long) content.length, "text/plain; charset=UTF-8", - sha256Prefix(entryContent, 64), "passed", content); - } - }; - } - } -}