|
@@ -0,0 +1,209 @@
|
|
|
|
|
+package com.zsjz.ai.module.plat.service;
|
|
|
|
|
+
|
|
|
|
|
+import com.fasterxml.jackson.databind.JsonNode;
|
|
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
|
|
+import com.zsjz.ai.common.model.plat.dto.ServiceHealthDTO;
|
|
|
|
|
+import lombok.RequiredArgsConstructor;
|
|
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
|
|
+import org.apache.http.util.EntityUtils;
|
|
|
|
|
+import org.elasticsearch.client.Request;
|
|
|
|
|
+import org.elasticsearch.client.Response;
|
|
|
|
|
+import org.elasticsearch.client.RestClient;
|
|
|
|
|
+import org.springframework.data.redis.connection.RedisConnection;
|
|
|
|
|
+import org.springframework.data.redis.connection.RedisConnectionFactory;
|
|
|
|
|
+import org.springframework.data.redis.core.RedisTemplate;
|
|
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
|
|
+
|
|
|
|
|
+import javax.sql.DataSource;
|
|
|
|
|
+import java.lang.management.ManagementFactory;
|
|
|
|
|
+import java.lang.management.RuntimeMXBean;
|
|
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
|
|
+import java.sql.Connection;
|
|
|
|
|
+import java.sql.ResultSet;
|
|
|
|
|
+import java.sql.Statement;
|
|
|
|
|
+import java.util.ArrayList;
|
|
|
|
|
+import java.util.List;
|
|
|
|
|
+import java.util.concurrent.ExecutorService;
|
|
|
|
|
+import java.util.concurrent.Executors;
|
|
|
|
|
+import java.util.concurrent.Future;
|
|
|
|
|
+import java.util.concurrent.TimeUnit;
|
|
|
|
|
+import java.util.concurrent.TimeoutException;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 系统服务健康检查
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>供设置 → 系统服务展示:后端自身、PostgreSQL、Redis、Elasticsearch 的可用性与探测耗时。
|
|
|
|
|
+ * 探测并发执行,单项超时 {@value #PROBE_TIMEOUT_SECONDS} 秒即判为异常,
|
|
|
|
|
+ * 避免任一依赖不可达时把整个接口拖到客户端超时。
|
|
|
|
|
+ */
|
|
|
|
|
+@Slf4j
|
|
|
|
|
+@Service
|
|
|
|
|
+@RequiredArgsConstructor
|
|
|
|
|
+public class SystemHealthService {
|
|
|
|
|
+
|
|
|
|
|
+ private static final long PROBE_TIMEOUT_SECONDS = 3;
|
|
|
|
|
+
|
|
|
|
|
+ private final DataSource dataSource;
|
|
|
|
|
+ private final RedisTemplate<String, Object> redisTemplate;
|
|
|
|
|
+ private final RestClient restClient;
|
|
|
|
|
+ private final ObjectMapper objectMapper;
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 探测全部系统服务
|
|
|
|
|
+ *
|
|
|
|
|
+ * @return 按「后端服务、PostgreSQL、Redis、Elasticsearch」顺序排列的探测结果
|
|
|
|
|
+ */
|
|
|
|
|
+ public List<ServiceHealthDTO> check() {
|
|
|
|
|
+ ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
|
|
|
|
+ try {
|
|
|
|
|
+ Future<ServiceHealthDTO> dbFuture = executor.submit(this::checkPostgresql);
|
|
|
|
|
+ Future<ServiceHealthDTO> redisFuture = executor.submit(this::checkRedis);
|
|
|
|
|
+ Future<ServiceHealthDTO> esFuture = executor.submit(this::checkElasticsearch);
|
|
|
|
|
+
|
|
|
|
|
+ List<ServiceHealthDTO> result = new ArrayList<>(4);
|
|
|
|
|
+ result.add(checkBackend());
|
|
|
|
|
+ result.add(await(dbFuture, "postgresql", "PostgreSQL"));
|
|
|
|
|
+ result.add(await(redisFuture, "redis", "Redis"));
|
|
|
|
|
+ result.add(await(esFuture, "elasticsearch", "Elasticsearch"));
|
|
|
|
|
+ return result;
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ executor.shutdownNow();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 后端自身:能进入本方法即说明服务可用,额外带出运行时长便于排查。
|
|
|
|
|
+ */
|
|
|
|
|
+ private ServiceHealthDTO checkBackend() {
|
|
|
|
|
+ long start = System.nanoTime();
|
|
|
|
|
+ RuntimeMXBean runtime = ManagementFactory.getRuntimeMXBean();
|
|
|
|
|
+ String detail = "运行时长 " + formatUptime(runtime.getUptime())
|
|
|
|
|
+ + " · Java " + System.getProperty("java.version");
|
|
|
|
|
+ return new ServiceHealthDTO("backend", "后端服务", true, elapsedMs(start), detail);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * PostgreSQL:执行 SELECT 1 验证主库可读写(连接本身可用)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>跑在独立虚拟线程上,没有案件上下文 / {@code @DS("slave")},
|
|
|
|
|
+ * 因此默认路由到 primary(PostgreSQL),不会误探 DuckDB 案件库。
|
|
|
|
|
+ */
|
|
|
|
|
+ private ServiceHealthDTO checkPostgresql() {
|
|
|
|
|
+ long start = System.nanoTime();
|
|
|
|
|
+ try (Connection connection = dataSource.getConnection();
|
|
|
|
|
+ Statement statement = connection.createStatement();
|
|
|
|
|
+ ResultSet resultSet = statement.executeQuery("SELECT 1")) {
|
|
|
|
|
+ if (!resultSet.next() || resultSet.getInt(1) != 1) {
|
|
|
|
|
+ throw new IllegalStateException("SELECT 1 返回异常");
|
|
|
|
|
+ }
|
|
|
|
|
+ String version = connection.getMetaData().getDatabaseProductVersion();
|
|
|
|
|
+ return new ServiceHealthDTO("postgresql", "PostgreSQL", true, elapsedMs(start),
|
|
|
|
|
+ "PostgreSQL " + version + " · SELECT 1 正常");
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("PostgreSQL 健康检查失败: {}", e.getMessage());
|
|
|
|
|
+ return new ServiceHealthDTO("postgresql", "PostgreSQL", false, elapsedMs(start), rootMessage(e));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Redis:发一次 PING,返回 PONG 才算正常。
|
|
|
|
|
+ */
|
|
|
|
|
+ private ServiceHealthDTO checkRedis() {
|
|
|
|
|
+ long start = System.nanoTime();
|
|
|
|
|
+ try {
|
|
|
|
|
+ RedisConnectionFactory factory = redisTemplate.getConnectionFactory();
|
|
|
|
|
+ if (factory == null) {
|
|
|
|
|
+ throw new IllegalStateException("Redis 连接工厂未初始化");
|
|
|
|
|
+ }
|
|
|
|
|
+ try (RedisConnection connection = factory.getConnection()) {
|
|
|
|
|
+ String pong = connection.ping();
|
|
|
|
|
+ if (!"PONG".equalsIgnoreCase(pong)) {
|
|
|
|
|
+ throw new IllegalStateException("PING 返回 " + pong);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return new ServiceHealthDTO("redis", "Redis", true, elapsedMs(start), "PING 正常");
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("Redis 健康检查失败: {}", e.getMessage());
|
|
|
|
|
+ return new ServiceHealthDTO("redis", "Redis", false, elapsedMs(start), rootMessage(e));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Elasticsearch:读取集群健康状态。green/yellow 可用,red 视为异常。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>刻意使用低层 RestClient 而不是强类型 Java Client:项目依赖的客户端是 8.18,
|
|
|
|
|
+ * 现场 ES 服务端可能是 8.7 等旧版本,{@code HealthResponse} 新增字段缺失会直接反序列化失败。
|
|
|
|
|
+ */
|
|
|
|
|
+ private ServiceHealthDTO checkElasticsearch() {
|
|
|
|
|
+ long start = System.nanoTime();
|
|
|
|
|
+ try {
|
|
|
|
|
+ Response response = restClient.performRequest(new Request("GET", "/_cluster/health"));
|
|
|
|
|
+ if (response.getEntity() == null) {
|
|
|
|
|
+ throw new IllegalStateException("Elasticsearch 返回空响应");
|
|
|
|
|
+ }
|
|
|
|
|
+ JsonNode health = objectMapper.readTree(EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8));
|
|
|
|
|
+ String status = health.path("status").asText();
|
|
|
|
|
+ boolean up = !"red".equalsIgnoreCase(status);
|
|
|
|
|
+ String detail = "集群 " + health.path("cluster_name").asText()
|
|
|
|
|
+ + " · 节点 " + health.path("number_of_nodes").asInt()
|
|
|
|
|
+ + " · 状态 " + status;
|
|
|
|
|
+ return new ServiceHealthDTO("elasticsearch", "Elasticsearch", up, elapsedMs(start), detail);
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("Elasticsearch 健康检查失败: {}", e.getMessage());
|
|
|
|
|
+ return new ServiceHealthDTO("elasticsearch", "Elasticsearch", false, elapsedMs(start), rootMessage(e));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 等待异步探测结果,超时即判异常,不再拖住整个请求。
|
|
|
|
|
+ */
|
|
|
|
|
+ private ServiceHealthDTO await(Future<ServiceHealthDTO> future, String name, String label) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ return future.get(PROBE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
|
|
|
|
+ } catch (TimeoutException e) {
|
|
|
|
|
+ future.cancel(true);
|
|
|
|
|
+ return new ServiceHealthDTO(name, label, false, (int) (PROBE_TIMEOUT_SECONDS * 1000),
|
|
|
|
|
+ "探测超时(" + PROBE_TIMEOUT_SECONDS + " 秒)");
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ future.cancel(true);
|
|
|
|
|
+ return new ServiceHealthDTO(name, label, false, 0, rootMessage(e));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private int elapsedMs(long startNano) {
|
|
|
|
|
+ return (int) TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNano);
|
|
|
|
|
+ } /**
|
|
|
|
|
+ * 取最内层异常信息,去掉过长的类名前缀,便于前端直接展示。
|
|
|
|
|
+ */
|
|
|
|
|
+ private String rootMessage(Throwable throwable) {
|
|
|
|
|
+ Throwable cause = throwable;
|
|
|
|
|
+ while (cause.getCause() != null && cause.getCause() != cause) {
|
|
|
|
|
+ cause = cause.getCause();
|
|
|
|
|
+ }
|
|
|
|
|
+ String message = cause.getMessage();
|
|
|
|
|
+ if (message == null || message.isBlank()) {
|
|
|
|
|
+ message = cause.getClass().getSimpleName();
|
|
|
|
|
+ }
|
|
|
|
|
+ return message.length() > 300 ? message.substring(0, 300) + "..." : message;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String formatUptime(long uptimeMs) {
|
|
|
|
|
+ long totalSeconds = uptimeMs / 1000;
|
|
|
|
|
+ long days = totalSeconds / 86400;
|
|
|
|
|
+ long hours = totalSeconds % 86400 / 3600;
|
|
|
|
|
+ long minutes = totalSeconds % 3600 / 60;
|
|
|
|
|
+ long seconds = totalSeconds % 60;
|
|
|
|
|
+ StringBuilder builder = new StringBuilder();
|
|
|
|
|
+ if (days > 0) {
|
|
|
|
|
+ builder.append(days).append(" 天 ");
|
|
|
|
|
+ }
|
|
|
|
|
+ if (days > 0 || hours > 0) {
|
|
|
|
|
+ builder.append(hours).append(" 小时 ");
|
|
|
|
|
+ }
|
|
|
|
|
+ if (days > 0 || hours > 0 || minutes > 0) {
|
|
|
|
|
+ builder.append(minutes).append(" 分 ");
|
|
|
|
|
+ }
|
|
|
|
|
+ builder.append(seconds).append(" 秒");
|
|
|
|
|
+ return builder.toString();
|
|
|
|
|
+ }
|
|
|
|
|
+}
|