package jnpf.dmsService.search.meili; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import jnpf.dmsService.search.DmsSearchProperties; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import java.io.IOException; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.UUID; @Service public class DmsMeilisearchClient { public static final String HIGHLIGHT_START = "[[[DMS_HIGHLIGHT_START]]]"; public static final String HIGHLIGHT_END = "[[[DMS_HIGHLIGHT_END]]]"; private final DmsSearchProperties properties; private final ObjectMapper objectMapper; private final HttpClient httpClient; public DmsMeilisearchClient(DmsSearchProperties properties, ObjectMapper objectMapper) { this.properties = properties; this.objectMapper = objectMapper; this.httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofMillis( Math.max(1, properties.getMeilisearch().getConnectTimeoutMs()))) .build(); } public void upsert(DmsSearchIndexDocument document) throws IOException { JsonNode response = send("POST", "/indexes/" + indexName(document.getTenantId()) + "/documents?primaryKey=id", Collections.singletonList(document), false); if (response == null) { throw new IOException("Meilisearch 写入没有返回任务信息"); } } /** Null means an absent index; a returned task ID must be polled before acknowledging cleanup. */ public Long deleteDocument(String tenantId, UUID fileVersionId) throws IOException { JsonNode response = send("DELETE", "/indexes/" + indexName(tenantId) + "/documents/" + fileVersionId, null, true); if (response == null) return null; if (!response.path("taskUid").canConvertToLong()) throw new IOException("Meilisearch 删除没有返回任务编号"); return response.path("taskUid").longValue(); } public String taskStatus(long taskId) throws IOException { JsonNode response = send("GET", "/tasks/" + taskId, null, true); return response == null ? "missing" : response.path("status").asText(); } public DmsMeilisearchSearchPage search(String tenantId, String keyword, int offset, int limit) throws IOException { Map body = new LinkedHashMap<>(); body.put("q", keyword); body.put("offset", offset); body.put("limit", limit); body.put("attributesToRetrieve", Arrays.asList( "id", "tenant_id", "document_id", "file_version_id", "folder_id", "folder_path", "document_no", "title", "document_type", "responsible_dept_id", "version_label", "file_name", "mime_type", "lifecycle_status", "created_at", "content_checksum")); body.put("attributesToHighlight", Arrays.asList( "document_no", "title", "file_name", "version_label", "content")); body.put("attributesToCrop", Collections.singletonList("content:45")); body.put("cropMarker", "..."); body.put("highlightPreTag", HIGHLIGHT_START); body.put("highlightPostTag", HIGHLIGHT_END); JsonNode response = send("POST", "/indexes/" + indexName(tenantId) + "/search", body, true); if (response == null) { return new DmsMeilisearchSearchPage(Collections.emptyList(), 0); } List hits = new ArrayList<>(); for (JsonNode rawHit : response.path("hits")) { ObjectNode documentNode = rawHit.deepCopy(); JsonNode formatted = documentNode.remove("_formatted"); documentNode.remove("_matchesPosition"); DmsSearchIndexDocument document = objectMapper.treeToValue( documentNode, DmsSearchIndexDocument.class); String title = formatted == null ? document.getTitle() : formatted.path("title").asText(document.getTitle()); String content = formatted == null ? "" : formatted.path("content").asText(""); hits.add(new DmsMeilisearchSearchHit(document, title, content)); } int total = response.path("estimatedTotalHits").asInt(offset + hits.size()); return new DmsMeilisearchSearchPage(hits, total); } private JsonNode send(String method, String path, Object body, boolean missingIndexAsEmpty) throws IOException { HttpRequest.Builder request = HttpRequest.newBuilder(endpoint(path)) .timeout(Duration.ofMillis( Math.max(1, properties.getMeilisearch().getReadTimeoutMs()))) .header("Accept", "application/json"); if (StringUtils.hasText(properties.getMeilisearch().getApiKey())) { request.header("Authorization", "Bearer " + properties.getMeilisearch().getApiKey().trim()); } if (body == null) { request.method(method, HttpRequest.BodyPublishers.noBody()); } else { request.header("Content-Type", "application/json"); request.method(method, HttpRequest.BodyPublishers.ofString( objectMapper.writeValueAsString(body))); } HttpResponse response; try { response = httpClient.send(request.build(), HttpResponse.BodyHandlers.ofString()); } catch (InterruptedException exception) { Thread.currentThread().interrupt(); throw new IOException("Meilisearch 请求被中断", exception); } if (missingIndexAsEmpty && response.statusCode() == 404) { return null; } if (response.statusCode() < 200 || response.statusCode() >= 300) { throw new IOException("Meilisearch 请求失败,HTTP " + response.statusCode()); } return StringUtils.hasText(response.body()) ? objectMapper.readTree(response.body()) : null; } private URI endpoint(String path) throws IOException { String baseUrl = properties.getMeilisearch().getBaseUrl(); if (!StringUtils.hasText(baseUrl)) { throw new IOException("Meilisearch 地址未配置"); } return URI.create(baseUrl.trim().replaceAll("/+$", "") + path); } private String indexName(String tenantId) throws IOException { String prefix = properties.getMeilisearch().getIndexName(); if (!StringUtils.hasText(prefix) || !prefix.matches("[A-Za-z0-9_-]+")) { throw new IOException("Meilisearch 索引名称无效"); } if (!StringUtils.hasText(tenantId)) { throw new IOException("全文检索任务缺少租户"); } String tenantKey = UUID.nameUUIDFromBytes( tenantId.trim().getBytes(StandardCharsets.UTF_8)).toString().replace("-", ""); return prefix + "_" + tenantKey; } }