Explorar el Código

feat(setting): 设置侧边栏新增系统服务健康状态查看

- 后端新增 /sys/serviceHealth,并行探测后端、PostgreSQL、Redis、Elasticsearch,单项 3 秒超时
- PostgreSQL 走主库 SELECT 1;ES 用低层 RestClient 读取 /_cluster/health,兼容 8.7 服务端
- 设置抽屉新增“系统服务”入口,弹窗展示状态、探测耗时与详情,支持手动刷新
cc hace 4 días
padre
commit
4357ff8949

+ 16 - 0
ai-electron/frontend/src/core/layouts/default/setting/api.ts

@@ -4,6 +4,22 @@ const { adminPath, ctxPath } = useGlobSetting();
 
 
 export const cleanCache = () => defHttp.get<void>({ url: adminPath + '/sys/cleanCache' });
 export const cleanCache = () => defHttp.get<void>({ url: adminPath + '/sys/cleanCache' });
 
 
+/** 系统服务健康状态项(后端 / PostgreSQL / Redis / Elasticsearch) */
+export interface ServiceHealthItem {
+  name: 'backend' | 'postgresql' | 'redis' | 'elasticsearch';
+  label: string;
+  up: boolean;
+  latencyMs: number;
+  detail?: string;
+}
+
+/**
+ * 查询系统服务健康状态。
+ * 后端已对每项探测做了 3 秒超时控制,这里留 15 秒足够覆盖最慢的依赖。
+ */
+export const getServiceHealth = () =>
+  defHttp.get<ServiceHealthItem[]>({ url: adminPath + '/sys/serviceHealth', timeout: 15 * 1000 });
+
 /**
 /**
  * 上传升级包并执行升级(Web 化:前端上传 zip,不再传服务端路径)。
  * 上传升级包并执行升级(Web 化:前端上传 zip,不再传服务端路径)。
  * 升级耗时较长(解压 + 覆盖数据库 + 执行升级 SQL),超时给了 30 分钟。
  * 升级耗时较长(解压 + 覆盖数据库 + 执行升级 SQL),超时给了 30 分钟。

+ 251 - 0
ai-electron/frontend/src/core/layouts/default/setting/components/ServiceHealthModal.vue

@@ -0,0 +1,251 @@
+<template>
+  <BasicModal
+    v-bind="$attrs"
+    title="系统服务"
+    :width="560"
+    :centered="true"
+    :showOkBtn="false"
+    :showCancelBtn="false"
+    @register="registerModal"
+  >
+    <div :class="prefixCls">
+      <div class="service-health__summary" :class="summaryClass">
+        <span class="service-health__summary-dot"></span>
+        <span class="service-health__summary-text">{{ summaryText }}</span>
+        <span v-if="checkedAt" class="service-health__checked-at">{{ checkedAt }}</span>
+        <a-button type="text" size="small" class="service-health__refresh" :loading="loading" @click="load">
+          <ReloadOutlined />
+          刷新
+        </a-button>
+      </div>
+
+      <div v-if="loading && !items.length" class="service-health__placeholder">正在检测服务状态...</div>
+      <div v-else-if="!items.length" class="service-health__placeholder">暂未获取到服务状态,请点击刷新重试</div>
+      <div v-else class="service-health__list">
+        <div v-for="item in items" :key="item.name" class="service-health__item">
+          <span class="service-health__dot" :class="item.up ? 'is-up' : 'is-down'"></span>
+          <div class="service-health__main">
+            <div class="service-health__name">
+              {{ item.label }}
+              <span class="service-health__tag" :class="item.up ? 'is-up' : 'is-down'">
+                {{ item.up ? '正常' : '异常' }}
+              </span>
+            </div>
+            <div class="service-health__detail" :title="item.detail">{{ item.detail || '-' }}</div>
+          </div>
+          <div class="service-health__latency">{{ formatLatency(item.latencyMs) }}</div>
+        </div>
+      </div>
+    </div>
+  </BasicModal>
+</template>
+
+<script lang="ts" setup>
+  import { computed, ref, unref, watch } from 'vue';
+  import { ReloadOutlined } from '@ant-design/icons-vue';
+  import { BasicModal, useModalInner } from '@/core/components/Modal';
+  import { useDesign } from '@/core/hooks/web/useDesign';
+  import { useMessage } from '@/core/hooks/web/useMessage';
+  import { getServiceHealth, type ServiceHealthItem } from '../api';
+
+  const { prefixCls } = useDesign('service-health-modal');
+  const { createMessage } = useMessage();
+
+  const [registerModal, { getOpen }] = useModalInner();
+
+  const items = ref<ServiceHealthItem[]>([]);
+  const loading = ref(false);
+  const checkedAt = ref('');
+
+  const downCount = computed(() => items.value.filter((item) => !item.up).length);
+  const summaryClass = computed(() => {
+    if (!items.value.length) return '';
+    return downCount.value ? 'is-down' : 'is-up';
+  });
+  const summaryText = computed(() => {
+    if (!items.value.length) return loading.value ? '正在检测服务状态' : '暂未获取到服务状态';
+    return downCount.value ? `${downCount.value} 项服务异常` : '全部服务运行正常';
+  });
+
+  function formatLatency(ms: number | string) {
+    const value = Number(ms);
+    if (!Number.isFinite(value)) return '-';
+    return value < 1000 ? `${value} ms` : `${(value / 1000).toFixed(1)} s`;
+  }
+
+  async function load() {
+    if (loading.value) return;
+    loading.value = true;
+    try {
+      const res = await getServiceHealth();
+      items.value = Array.isArray(res) ? res : [];
+      checkedAt.value = `检测于 ${new Date().toLocaleTimeString('zh-CN', { hour12: false })}`;
+    } catch (error: any) {
+      items.value = [];
+      checkedAt.value = '';
+      createMessage.error(error?.message || '获取系统服务状态失败');
+    } finally {
+      loading.value = false;
+    }
+  }
+
+  watch(
+    () => unref(getOpen),
+    (open) => {
+      if (!open) return;
+      void load();
+    },
+    { immediate: false },
+  );
+</script>
+
+<style lang="less">
+  @prefix-cls: ~'jeesite-service-health-modal';
+
+  .@{prefix-cls} {
+    .service-health__summary {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      padding: 12px 14px;
+      background: #f8fafc;
+      border: 1px solid #e5e7eb;
+      border-radius: 10px;
+
+      &.is-up {
+        background: #f0fdf4;
+        border-color: #bbf7d0;
+
+        .service-health__summary-dot {
+          background: #22c55e;
+        }
+      }
+
+      &.is-down {
+        background: #fef2f2;
+        border-color: #fecaca;
+
+        .service-health__summary-dot {
+          background: #ef4444;
+        }
+      }
+    }
+
+    .service-health__summary-dot {
+      flex-shrink: 0;
+      width: 8px;
+      height: 8px;
+      background: #9ca3af;
+      border-radius: 50%;
+    }
+
+    .service-health__summary-text {
+      font-size: 14px;
+      font-weight: 600;
+      color: #111827;
+    }
+
+    .service-health__checked-at {
+      font-size: 12px;
+      color: #9ca3af;
+    }
+
+    .service-health__refresh {
+      margin-left: auto;
+      font-size: 13px;
+      color: #4b5563;
+    }
+
+    .service-health__placeholder {
+      padding: 48px 0;
+      font-size: 14px;
+      color: #6b7280;
+      text-align: center;
+    }
+
+    .service-health__list {
+      display: flex;
+      flex-direction: column;
+      gap: 10px;
+      margin-top: 14px;
+    }
+
+    .service-health__item {
+      display: flex;
+      align-items: flex-start;
+      gap: 12px;
+      padding: 14px 16px;
+      background: #f8fafc;
+      border: 1px solid #e5e7eb;
+      border-radius: 10px;
+    }
+
+    .service-health__dot {
+      flex-shrink: 0;
+      width: 8px;
+      height: 8px;
+      margin-top: 6px;
+      border-radius: 50%;
+
+      &.is-up {
+        background: #22c55e;
+        box-shadow: 0 0 0 3px rgb(34 197 94 / 15%);
+      }
+
+      &.is-down {
+        background: #ef4444;
+        box-shadow: 0 0 0 3px rgb(239 68 68 / 15%);
+      }
+    }
+
+    .service-health__main {
+      flex: 1;
+      min-width: 0;
+    }
+
+    .service-health__name {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      font-size: 15px;
+      font-weight: 600;
+      color: #111827;
+    }
+
+    .service-health__tag {
+      padding: 0 8px;
+      font-size: 12px;
+      font-weight: 500;
+      line-height: 20px;
+      border-radius: 10px;
+
+      &.is-up {
+        color: #15803d;
+        background: #dcfce7;
+      }
+
+      &.is-down {
+        color: #b91c1c;
+        background: #fee2e2;
+      }
+    }
+
+    .service-health__detail {
+      margin-top: 4px;
+      overflow: hidden;
+      font-size: 13px;
+      line-height: 1.6;
+      color: #6b7280;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+
+    .service-health__latency {
+      flex-shrink: 0;
+      padding-top: 2px;
+      font-size: 13px;
+      font-variant-numeric: tabular-nums;
+      color: #9ca3af;
+    }
+  }
+</style>

+ 13 - 0
ai-electron/frontend/src/core/layouts/default/setting/components/SettingFooter.vue

@@ -10,6 +10,11 @@
       服务地址
       服务地址
     </a-button>
     </a-button>
 
 
+    <a-button block @click="openServiceHealthModal(true)" class="mt-3">
+      <CloudServerOutlined class="mr-2" />
+      系统服务
+    </a-button>
+
     <a-button block @click="openAuthorizationInfoModal(true)" class="mt-3">
     <a-button block @click="openAuthorizationInfoModal(true)" class="mt-3">
       <SafetyCertificateOutlined class="mr-2" />
       <SafetyCertificateOutlined class="mr-2" />
       授权信息
       授权信息
@@ -39,12 +44,14 @@
     <ProductInfoModal @register="registerProductInfoModal" />
     <ProductInfoModal @register="registerProductInfoModal" />
     <AuthorizationInfoModal @register="registerAuthorizationInfoModal" />
     <AuthorizationInfoModal @register="registerAuthorizationInfoModal" />
     <ServerAddressModal @register="registerServerAddressModal" />
     <ServerAddressModal @register="registerServerAddressModal" />
+    <ServiceHealthModal @register="registerServiceHealthModal" />
   </div>
   </div>
 </template>
 </template>
 <script lang="ts">
 <script lang="ts">
   import { defineComponent, nextTick, unref } from 'vue';
   import { defineComponent, nextTick, unref } from 'vue';
 
 
   import {
   import {
+    CloudServerOutlined,
     CopyOutlined,
     CopyOutlined,
     RedoOutlined,
     RedoOutlined,
     EnvironmentOutlined,
     EnvironmentOutlined,
@@ -75,10 +82,12 @@
   import ProductInfoModal from './ProductInfoModal.vue';
   import ProductInfoModal from './ProductInfoModal.vue';
   import AuthorizationInfoModal from './AuthorizationInfoModal.vue';
   import AuthorizationInfoModal from './AuthorizationInfoModal.vue';
   import ServerAddressModal from './ServerAddressModal.vue';
   import ServerAddressModal from './ServerAddressModal.vue';
+  import ServiceHealthModal from './ServiceHealthModal.vue';
 
 
   export default defineComponent({
   export default defineComponent({
     name: 'SettingFooter',
     name: 'SettingFooter',
     components: {
     components: {
+      CloudServerOutlined,
       CopyOutlined,
       CopyOutlined,
       RedoOutlined,
       RedoOutlined,
       EnvironmentOutlined,
       EnvironmentOutlined,
@@ -89,6 +98,7 @@
       ProductInfoModal,
       ProductInfoModal,
       AuthorizationInfoModal,
       AuthorizationInfoModal,
       ServerAddressModal,
       ServerAddressModal,
+      ServiceHealthModal,
     },
     },
     setup() {
     setup() {
       // const permissionStore = usePermissionStore();
       // const permissionStore = usePermissionStore();
@@ -103,6 +113,7 @@
       const [registerAuthorizationInfoModal, { openModal: openAuthorizationInfoModal }] = useModal();
       const [registerAuthorizationInfoModal, { openModal: openAuthorizationInfoModal }] = useModal();
       const [registerMapLocalSettingModal, { openModal: openMapLocalSettingModal }] = useModal();
       const [registerMapLocalSettingModal, { openModal: openMapLocalSettingModal }] = useModal();
       const [registerServerAddressModal, { openModal: openServerAddressModal }] = useModal();
       const [registerServerAddressModal, { openModal: openServerAddressModal }] = useModal();
+      const [registerServiceHealthModal, { openModal: openServiceHealthModal }] = useModal();
       function handleCopy() {
       function handleCopy() {
         const { isSuccessRef } = useCopyToClipboard(JSON.stringify(unref(appStore.getProjectConfig), null, 2));
         const { isSuccessRef } = useCopyToClipboard(JSON.stringify(unref(appStore.getProjectConfig), null, 2));
         unref(isSuccessRef) &&
         unref(isSuccessRef) &&
@@ -154,6 +165,8 @@
         openMapLocalSettingModal,
         openMapLocalSettingModal,
         registerServerAddressModal,
         registerServerAddressModal,
         openServerAddressModal,
         openServerAddressModal,
+        registerServiceHealthModal,
+        openServiceHealthModal,
       };
       };
     },
     },
   });
   });

+ 46 - 0
ai-server/src/main/java/com/zsjz/ai/common/model/plat/dto/ServiceHealthDTO.java

@@ -0,0 +1,46 @@
+package com.zsjz.ai.common.model.plat.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.io.Serializable;
+
+/**
+ * 系统服务健康状态
+ *
+ * <p>设置 → 系统服务里展示,一个实例对应一个被探测的服务。
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class ServiceHealthDTO implements Serializable {
+
+    /**
+     * 服务标识:backend / redis / elasticsearch
+     */
+    private String name;
+
+    /**
+     * 展示名称
+     */
+    private String label;
+
+    /**
+     * 是否可用
+     */
+    private boolean up;
+
+    /**
+     * 探测耗时(毫秒)
+     *
+     * <p>刻意用 int:项目全局把 Long 序列化成字符串(防前端精度丢失),
+     * 耗时用小整数即可,避免前端拿到 {@code "3"} 需要再做类型转换。
+     */
+    private int latencyMs;
+
+    /**
+     * 探测详情:正常时为运行/版本信息,异常时为失败原因
+     */
+    private String detail;
+}

+ 18 - 0
ai-server/src/main/java/com/zsjz/ai/module/plat/controller/SystemController.java

@@ -2,7 +2,9 @@
 package com.zsjz.ai.module.plat.controller;
 package com.zsjz.ai.module.plat.controller;
 
 
 import com.zsjz.ai.common.config.Result;
 import com.zsjz.ai.common.config.Result;
+import com.zsjz.ai.common.model.plat.dto.ServiceHealthDTO;
 import com.zsjz.ai.common.model.plat.entity.SystemInfo;
 import com.zsjz.ai.common.model.plat.entity.SystemInfo;
+import com.zsjz.ai.module.plat.service.SystemHealthService;
 import com.zsjz.ai.module.plat.service.SystemService;
 import com.zsjz.ai.module.plat.service.SystemService;
 
 
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -19,6 +21,8 @@ import org.springframework.web.bind.annotation.RequestPart;
 import org.springframework.web.bind.annotation.RestController;
 import org.springframework.web.bind.annotation.RestController;
 import org.springframework.web.multipart.MultipartFile;
 import org.springframework.web.multipart.MultipartFile;
 
 
+import java.util.List;
+
 /**
 /**
  * 系统控制器
  * 系统控制器
  * 提供系统授权、版本管理、缓存管理等系统级接口
  * 提供系统授权、版本管理、缓存管理等系统级接口
@@ -40,6 +44,9 @@ public class SystemController {
     @Autowired
     @Autowired
     private SystemService systemService;
     private SystemService systemService;
 
 
+    @Autowired
+    private SystemHealthService systemHealthService;
+
     /**
     /**
      * 健康检查接口
      * 健康检查接口
      * 用于检测系统是否正常运行
      * 用于检测系统是否正常运行
@@ -49,6 +56,17 @@ public class SystemController {
         return Result.succeed();
         return Result.succeed();
     }
     }
 
 
+    /**
+     * 系统服务健康状态
+     * 供「设置 → 系统服务」展示后端、PostgreSQL、Redis、Elasticsearch 的可用性与探测耗时
+     *
+     * @return 各项服务的探测结果
+     */
+    @GetMapping("/serviceHealth")
+    public Result<List<ServiceHealthDTO>> serviceHealth() {
+        return Result.succeed(systemHealthService.check());
+    }
+
     /**
     /**
      * 获取系统授权信息
      * 获取系统授权信息
      * 返回当前系统的授权状态和剩余天数
      * 返回当前系统的授权状态和剩余天数

+ 209 - 0
ai-server/src/main/java/com/zsjz/ai/module/plat/service/SystemHealthService.java

@@ -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();
+    }
+}