From e12439d1f4a94957d68cda653cb666702bc71040 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期三, 26 八月 2026 16:09:55 +0800
Subject: [PATCH] feat(device): implement adapter registry and discovery

---
 ruoyi-admin/src/test/java/com/ruoyi/config/FlywayMigrationContractTest.java                          |    6 
 ruoyi-device/src/main/java/com/ruoyi/device/registry/AdapterInstanceConfig.java                      |   39 +
 ruoyi-system/src/main/java/com/ruoyi/system/domain/TDetectionCabinet.java                            |   17 
 ruoyi-system/src/main/resources/mapper/system/TDeviceAdapterMapper.xml                               |  143 ++++++
 ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDeviceAdapterFactory.java                         |   29 +
 ruoyi-system/src/main/java/com/ruoyi/system/mapper/TDeviceAdapterMapper.java                         |   35 +
 ruoyi-system/src/main/java/com/ruoyi/system/service/DeviceAdapterErrorCodes.java                     |   16 
 ruoyi-device/src/test/java/com/ruoyi/device/registry/BtsDeviceAdapterFactoryContractTest.java        |   33 +
 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceDiscoveryPersistenceService.java      |  101 ++++
 ruoyi-system/src/main/java/com/ruoyi/system/domain/TDetectionChannel.java                            |   48 ++
 ruoyi-admin/src/main/resources/db/migration/V5__adapter_discovery_runtime.sql                        |   13 
 ruoyi-system/src/main/java/com/ruoyi/system/domain/TDeviceAdapter.java                               |   94 ++++
 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceAdapterRuntimeCoordinator.java        |   22 
 ruoyi-admin/src/test/java/com/ruoyi/web/controller/device/DeviceAdapterControllerPermissionTest.java |   28 +
 ruoyi-device/src/main/java/com/ruoyi/device/registry/DeviceAdapterRegistry.java                      |   80 +++
 ruoyi-system/pom.xml                                                                                 |    8 
 ruoyi-device/src/main/java/com/ruoyi/device/registry/AdapterRegistryException.java                   |   12 
 ruoyi-device/src/main/java/com/ruoyi/device/registry/DeviceAdapterFactory.java                       |   11 
 ruoyi-system/src/main/java/com/ruoyi/system/service/IDeviceAdapterService.java                       |   19 
 ruoyi-admin/src/main/java/com/ruoyi/config/DeviceAdapterRuntimeConfig.java                           |   20 
 /dev/null                                                                                            |   28 -
 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImpl.java               |  199 ++++++++
 ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/DeviceAdapterController.java               |   80 +++
 ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TDetectionCabinetController.java           |   37 -
 ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImplTest.java            |  110 ++++
 ruoyi-device/src/test/java/com/ruoyi/device/registry/DeviceAdapterRegistryTest.java                  |  144 ++++++
 ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/AdapterDiscoveryResult.java                    |    8 
 27 files changed, 1,315 insertions(+), 65 deletions(-)

diff --git a/ruoyi-admin/src/main/java/com/ruoyi/config/DeviceAdapterRuntimeConfig.java b/ruoyi-admin/src/main/java/com/ruoyi/config/DeviceAdapterRuntimeConfig.java
new file mode 100644
index 0000000..dcf3cf0
--- /dev/null
+++ b/ruoyi-admin/src/main/java/com/ruoyi/config/DeviceAdapterRuntimeConfig.java
@@ -0,0 +1,20 @@
+package com.ruoyi.config;
+
+import java.util.List;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableScheduling;
+import com.ruoyi.device.bts.BtsDeviceAdapterFactory;
+import com.ruoyi.device.registry.DeviceAdapterRegistry;
+
+/** 装配单进程设备适配器运行时。 */
+@Configuration
+@EnableScheduling
+public class DeviceAdapterRuntimeConfig
+{
+    @Bean(destroyMethod = "close")
+    public DeviceAdapterRegistry deviceAdapterRegistry()
+    {
+        return new DeviceAdapterRegistry(List.of(new BtsDeviceAdapterFactory()));
+    }
+}
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/DeviceAdapterController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/DeviceAdapterController.java
new file mode 100644
index 0000000..25e8d74
--- /dev/null
+++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/DeviceAdapterController.java
@@ -0,0 +1,80 @@
+package com.ruoyi.web.controller.device;
+
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import com.ruoyi.common.annotation.Log;
+import com.ruoyi.common.core.controller.BaseController;
+import com.ruoyi.common.core.domain.AjaxResult;
+import com.ruoyi.common.core.page.TableDataInfo;
+import com.ruoyi.common.enums.BusinessType;
+import com.ruoyi.system.domain.TDeviceAdapter;
+import com.ruoyi.system.service.IDeviceAdapterService;
+
+/** 设备适配器实例管理与发现 REST。 */
+@RestController
+@RequestMapping("/system/deviceAdapter")
+public class DeviceAdapterController extends BaseController
+{
+    private final IDeviceAdapterService service;
+
+    public DeviceAdapterController(IDeviceAdapterService service)
+    {
+        this.service = service;
+    }
+
+    @PreAuthorize("@ss.hasPermi('system:deviceAdapter:list')")
+    @GetMapping("/list")
+    public TableDataInfo list()
+    {
+        startPage();
+        return getDataTable(service.selectList(new TDeviceAdapter()));
+    }
+
+    @PreAuthorize("@ss.hasPermi('system:deviceAdapter:query')")
+    @GetMapping("/{adapterId}")
+    public AjaxResult getInfo(@PathVariable Long adapterId) { return success(service.selectById(adapterId)); }
+
+    @PreAuthorize("@ss.hasPermi('system:deviceAdapter:add')")
+    @Log(title = "设备适配器实例", businessType = BusinessType.INSERT)
+    @PostMapping
+    public AjaxResult add(@Validated @RequestBody TDeviceAdapter adapter)
+    {
+        adapter.setCreateBy(getUsername());
+        return toAjax(service.insert(adapter));
+    }
+
+    @PreAuthorize("@ss.hasPermi('system:deviceAdapter:edit')")
+    @Log(title = "设备适配器实例", businessType = BusinessType.UPDATE)
+    @PutMapping
+    public AjaxResult edit(@Validated @RequestBody TDeviceAdapter adapter)
+    {
+        adapter.setUpdateBy(getUsername());
+        return toAjax(service.update(adapter));
+    }
+
+    @PreAuthorize("@ss.hasPermi('system:deviceAdapter:changeStatus')")
+    @Log(title = "设备适配器实例启停", businessType = BusinessType.UPDATE)
+    @PutMapping("/{adapterId}/status")
+    public AjaxResult changeStatus(@PathVariable Long adapterId, @RequestBody StatusCommand command)
+    {
+        return toAjax(service.changeStatus(adapterId, command.enabled(), getUsername()));
+    }
+
+    @PreAuthorize("@ss.hasPermi('system:deviceAdapter:discover')")
+    @Log(title = "设备适配器发现", businessType = BusinessType.OTHER)
+    @PostMapping("/{adapterId}/discover")
+    public AjaxResult discover(@PathVariable Long adapterId) { return success(service.discover(adapterId)); }
+
+    @PreAuthorize("@ss.hasPermi('system:deviceAdapter:list')")
+    @GetMapping("/{adapterId}/channels")
+    public AjaxResult channels(@PathVariable Long adapterId) { return success(service.selectChannels(adapterId)); }
+
+    public record StatusCommand(boolean enabled) { }
+}
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TDetectionCabinetController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TDetectionCabinetController.java
index 75af3d8..2786419 100644
--- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TDetectionCabinetController.java
+++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TDetectionCabinetController.java
@@ -1,6 +1,5 @@
 package com.ruoyi.web.controller.system;
 
-import java.util.Collections;
 import java.util.List;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.validation.annotation.Validated;
@@ -13,16 +12,11 @@
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
 import com.ruoyi.common.annotation.Log;
-import com.ruoyi.common.constant.HttpStatus;
 import com.ruoyi.common.core.controller.BaseController;
 import com.ruoyi.common.core.domain.AjaxResult;
-import com.ruoyi.common.core.page.PageDomain;
 import com.ruoyi.common.core.page.TableDataInfo;
-import com.ruoyi.common.core.page.TableSupport;
 import com.ruoyi.common.enums.BusinessType;
 import com.ruoyi.system.domain.TDetectionCabinet;
-import com.ruoyi.system.domain.vo.DiscoveredCabinetVo;
-import com.ruoyi.system.service.IDetectionCabinetDiscoveryService;
 import com.ruoyi.system.service.ITDetectionCabinetService;
 
 /**
@@ -36,19 +30,14 @@
 {
     private final ITDetectionCabinetService detectionCabinetService;
 
-    private final IDetectionCabinetDiscoveryService detectionCabinetDiscoveryService;
-
     /**
      * 构造检测柜管理控制器。
      *
      * @param detectionCabinetService 检测柜业务服务
-     * @param detectionCabinetDiscoveryService 检测柜设备发现服务
      */
-    public TDetectionCabinetController(ITDetectionCabinetService detectionCabinetService,
-            IDetectionCabinetDiscoveryService detectionCabinetDiscoveryService)
+    public TDetectionCabinetController(ITDetectionCabinetService detectionCabinetService)
     {
         this.detectionCabinetService = detectionCabinetService;
-        this.detectionCabinetDiscoveryService = detectionCabinetDiscoveryService;
     }
 
     /**
@@ -64,30 +53,6 @@
         startPage();
         List<TDetectionCabinet> list = detectionCabinetService.selectDetectionCabinetList(detectionCabinet);
         return getDataTable(list);
-    }
-
-    /**
-     * 查询当前可发现的检测柜列表。
-     *
-     * @return 可发现检测柜分页数据
-     */
-    @PreAuthorize("@ss.hasPermi('system:detectionCabinet:list')")
-    @GetMapping("/discovered/list")
-    public TableDataInfo discoveredList()
-    {
-        List<DiscoveredCabinetVo> devices = detectionCabinetDiscoveryService.selectDiscoveredCabinetList();
-        PageDomain pageDomain = TableSupport.buildPageRequest();
-        int pageNum = pageDomain.getPageNum() == null || pageDomain.getPageNum() < 1 ? 1 : pageDomain.getPageNum();
-        int pageSize = pageDomain.getPageSize() == null || pageDomain.getPageSize() < 1 ? 10 : pageDomain.getPageSize();
-        int fromIndex = Math.min((pageNum - 1) * pageSize, devices.size());
-        int toIndex = Math.min(fromIndex + pageSize, devices.size());
-        List<DiscoveredCabinetVo> rows = fromIndex == toIndex
-                ? Collections.emptyList()
-                : devices.subList(fromIndex, toIndex);
-        TableDataInfo result = new TableDataInfo(rows, devices.size());
-        result.setCode(HttpStatus.SUCCESS);
-        result.setMsg("查询成功");
-        return result;
     }
 
     /**
diff --git a/ruoyi-admin/src/main/resources/db/migration/V5__adapter_discovery_runtime.sql b/ruoyi-admin/src/main/resources/db/migration/V5__adapter_discovery_runtime.sql
new file mode 100644
index 0000000..904a8d3
--- /dev/null
+++ b/ruoyi-admin/src/main/resources/db/migration/V5__adapter_discovery_runtime.sql
@@ -0,0 +1,13 @@
+-- Ticket 03:设备适配器连接与发现运行状态。
+-- 设备配置和通道事实继续使用 V2 表;本迁移只增加可观察的运行时结果。
+
+ALTER TABLE t_device_adapter
+    ADD COLUMN connection_status VARCHAR(32) NOT NULL DEFAULT 'DISCONNECTED'
+        COMMENT '连接状态:DISCONNECTED、CONNECTED、ERROR' AFTER status,
+    ADD COLUMN last_error_code VARCHAR(64) NULL COMMENT '最近一次稳定错误分类' AFTER connection_status,
+    ADD COLUMN last_error_message VARCHAR(500) NULL COMMENT '最近一次连接或协议错误' AFTER last_error_code,
+    ADD COLUMN last_attempt_at_utc DATETIME(3) NULL COMMENT '最近一次发现尝试的服务器 UTC 时间' AFTER last_error_message,
+    ADD COLUMN last_observed_at_utc DATETIME(3) NULL COMMENT '最近一次成功观测的服务器 UTC 时间' AFTER last_attempt_at_utc,
+    ADD CONSTRAINT chk_adapter_connection_status
+        CHECK (connection_status IN ('DISCONNECTED', 'CONNECTED', 'ERROR')),
+    ADD KEY idx_adapter_poll_due (status, connection_status, last_attempt_at_utc, del_flag);
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/config/FlywayMigrationContractTest.java b/ruoyi-admin/src/test/java/com/ruoyi/config/FlywayMigrationContractTest.java
index a7d9518..57294b0 100644
--- a/ruoyi-admin/src/test/java/com/ruoyi/config/FlywayMigrationContractTest.java
+++ b/ruoyi-admin/src/test/java/com/ruoyi/config/FlywayMigrationContractTest.java
@@ -30,6 +30,12 @@
         assertTrue(v4.contains("adapter_timezone_id"));
         assertTrue(v4.contains("fk_sample_channel"));
         assertFalse(v4.matches("(?s).*\\bINSERT\\s+INTO\\b.*"));
+
+        String v5 = resource("db/migration/V5__adapter_discovery_runtime.sql");
+        assertTrue(v5.contains("connection_status"));
+        assertTrue(v5.contains("last_observed_at_utc"));
+        assertTrue(v5.contains("chk_adapter_connection_status"));
+        assertFalse(v5.matches("(?s).*\\bDELETE\\s+FROM\\b.*"));
     }
 
     private static String sha256(String path)
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/DetectionCabinetDiscoveryServiceImplTest.java b/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/DetectionCabinetDiscoveryServiceImplTest.java
deleted file mode 100644
index faecbfd..0000000
--- a/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/DetectionCabinetDiscoveryServiceImplTest.java
+++ /dev/null
@@ -1,39 +0,0 @@
-package com.ruoyi.system.service.impl;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertNotSame;
-
-import java.util.List;
-import org.junit.jupiter.api.Test;
-import com.ruoyi.system.domain.vo.DiscoveredCabinetVo;
-
-/**
- * 检测柜设备发现服务测试。
- */
-class DetectionCabinetDiscoveryServiceImplTest
-{
-    private final DetectionCabinetDiscoveryServiceImpl discoveryService =
-            new DetectionCabinetDiscoveryServiceImpl();
-
-    @Test
-    void selectDiscoveredCabinetListShouldReturnDefaultTestDevices()
-    {
-        List<DiscoveredCabinetVo> devices = discoveryService.selectDiscoveredCabinetList();
-
-        assertEquals(6, devices.size());
-        assertEquals("CAB-A01", devices.get(0).getDeviceCode());
-        assertEquals("192.168.1.101", devices.get(0).getIp());
-        assertEquals("在线", devices.get(0).getOnlineStatus());
-        assertEquals("CAB-B03", devices.get(5).getDeviceCode());
-    }
-
-    @Test
-    void selectDiscoveredCabinetListShouldReturnIndependentLists()
-    {
-        List<DiscoveredCabinetVo> first = discoveryService.selectDiscoveredCabinetList();
-        List<DiscoveredCabinetVo> second = discoveryService.selectDiscoveredCabinetList();
-
-        assertNotSame(first, second);
-        assertEquals(first.size(), second.size());
-    }
-}
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImplTest.java b/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImplTest.java
new file mode 100644
index 0000000..4e6c437
--- /dev/null
+++ b/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImplTest.java
@@ -0,0 +1,110 @@
+package com.ruoyi.system.service.impl;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import java.util.List;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.device.api.DeviceAdapter;
+import com.ruoyi.device.bts.BtsTransportException;
+import com.ruoyi.device.model.ChannelIdentity;
+import com.ruoyi.device.model.DetectedChannel;
+import com.ruoyi.device.registry.DeviceAdapterRegistry;
+import com.ruoyi.system.domain.TDetectionCabinet;
+import com.ruoyi.system.domain.TDetectionChannel;
+import com.ruoyi.system.domain.TDeviceAdapter;
+import com.ruoyi.system.mapper.TDeviceAdapterMapper;
+import com.ruoyi.system.service.DeviceAdapterErrorCodes;
+
+@ExtendWith(MockitoExtension.class)
+class DeviceAdapterServiceImplTest
+{
+    @Mock
+    private TDeviceAdapterMapper adapterMapper;
+
+    @Mock
+    private DeviceAdapterRegistry registry;
+
+    @Mock
+    private DeviceAdapter deviceAdapter;
+
+    private DeviceAdapterServiceImpl service;
+    private TDeviceAdapter adapter;
+
+    @BeforeEach
+    void setUp()
+    {
+        service = new DeviceAdapterServiceImpl(adapterMapper, registry);
+        adapter = new TDeviceAdapter();
+        adapter.setAdapterId(7L);
+        adapter.setAdapterCode("lab-bts");
+        adapter.setAdapterName("一号 BTS");
+        adapter.setAdapterType("BTS");
+        adapter.setHost("127.0.0.1");
+        adapter.setPort(502);
+        adapter.setPollIntervalMs(5000);
+        adapter.setTimezoneId("Asia/Hong_Kong");
+        adapter.setReadOnly(true);
+        adapter.setStatus("0");
+    }
+
+    @Test
+    void enablingAdapterSynchronizesRuntimeWithoutConstructingVendorClient()
+    {
+        adapter.setStatus("1");
+        when(adapterMapper.selectDeviceAdapterById(7L)).thenReturn(adapter);
+        when(adapterMapper.updateDeviceAdapterStatus(7L, "0", "admin")).thenReturn(1);
+
+        int rows = service.changeStatus(7L, true, "admin");
+
+        assertEquals(1, rows);
+        verify(registry).synchronize(any());
+    }
+
+    @Test
+    void repeatedDiscoveryUpsertsStableChannelIdentityWithoutDeletingMissingChannels()
+    {
+        when(adapterMapper.selectDeviceAdapterById(7L)).thenReturn(adapter);
+        when(registry.require("lab-bts")).thenReturn(deviceAdapter);
+        when(deviceAdapter.discoverChannels()).thenReturn(List.of(
+                new DetectedChannel(new ChannelIdentity("lab-bts", 22, 1, 2, 3), "192.168.1.35", true),
+                new DetectedChannel(new ChannelIdentity("lab-bts", 22, 1, 2, 4), "192.168.1.35", false)));
+        when(adapterMapper.selectCabinetIdByIdentity(7L, 22, 1)).thenReturn(10L);
+
+        assertEquals(2, service.discover(7L).channelCount());
+        assertEquals(2, service.discover(7L).channelCount());
+
+        verify(adapterMapper, times(4)).upsertDetectedChannel(any(TDetectionChannel.class));
+        verify(adapterMapper, never()).deleteMissingChannels(any(), any());
+        verify(adapterMapper, times(2)).updateDiscoverySuccess(eq(7L), any());
+        verify(adapterMapper, times(2)).updateDetectedCabinet(any(TDetectionCabinet.class));
+    }
+
+    @Test
+    void connectionFailureHasStableCodeAndLaterDiscoveryClearsError()
+    {
+        when(adapterMapper.selectDeviceAdapterById(7L)).thenReturn(adapter);
+        when(registry.require("lab-bts")).thenReturn(deviceAdapter);
+        when(deviceAdapter.discoverChannels())
+                .thenThrow(new BtsTransportException(BtsTransportException.Reason.CONNECTION_FAILED,
+                        "connection refused", null))
+                .thenReturn(List.of());
+
+        ServiceException failure = assertThrows(ServiceException.class, () -> service.discover(7L));
+        assertEquals(DeviceAdapterErrorCodes.DISCOVERY_FAILED, failure.getCode());
+        verify(adapterMapper).updateDiscoveryFailure(7L, "CONNECTION_FAILED", "connection refused");
+
+        assertEquals(0, service.discover(7L).channelCount());
+        verify(adapterMapper).updateDiscoverySuccess(eq(7L), any());
+    }
+}
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/web/controller/device/DeviceAdapterControllerPermissionTest.java b/ruoyi-admin/src/test/java/com/ruoyi/web/controller/device/DeviceAdapterControllerPermissionTest.java
new file mode 100644
index 0000000..3b19afb
--- /dev/null
+++ b/ruoyi-admin/src/test/java/com/ruoyi/web/controller/device/DeviceAdapterControllerPermissionTest.java
@@ -0,0 +1,28 @@
+package com.ruoyi.web.controller.device;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import java.lang.reflect.Method;
+import org.junit.jupiter.api.Test;
+import org.springframework.security.access.prepost.PreAuthorize;
+
+class DeviceAdapterControllerPermissionTest
+{
+    @Test
+    void protectsEachAdapterOperationWithItsOwnRuoyiPermission() throws Exception
+    {
+        assertPermission("list", "@ss.hasPermi('system:deviceAdapter:list')");
+        assertPermission("getInfo", "@ss.hasPermi('system:deviceAdapter:query')", Long.class);
+        assertPermission("add", "@ss.hasPermi('system:deviceAdapter:add')", com.ruoyi.system.domain.TDeviceAdapter.class);
+        assertPermission("edit", "@ss.hasPermi('system:deviceAdapter:edit')", com.ruoyi.system.domain.TDeviceAdapter.class);
+        assertPermission("changeStatus", "@ss.hasPermi('system:deviceAdapter:changeStatus')",
+                Long.class, DeviceAdapterController.StatusCommand.class);
+        assertPermission("discover", "@ss.hasPermi('system:deviceAdapter:discover')", Long.class);
+        assertPermission("channels", "@ss.hasPermi('system:deviceAdapter:list')", Long.class);
+    }
+
+    private void assertPermission(String methodName, String expected, Class<?>... parameterTypes) throws Exception
+    {
+        Method method = DeviceAdapterController.class.getMethod(methodName, parameterTypes);
+        assertEquals(expected, method.getAnnotation(PreAuthorize.class).value());
+    }
+}
diff --git a/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDeviceAdapterFactory.java b/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDeviceAdapterFactory.java
new file mode 100644
index 0000000..3d2d660
--- /dev/null
+++ b/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDeviceAdapterFactory.java
@@ -0,0 +1,29 @@
+package com.ruoyi.device.bts;
+
+import java.time.Duration;
+import java.time.ZoneId;
+import com.ruoyi.device.api.DeviceAdapter;
+import com.ruoyi.device.registry.AdapterInstanceConfig;
+import com.ruoyi.device.registry.DeviceAdapterFactory;
+
+/** 根据数据库配置构造 BTS 只读适配器。 */
+public final class BtsDeviceAdapterFactory implements DeviceAdapterFactory
+{
+    private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(3);
+    private static final Duration RESPONSE_TIMEOUT = Duration.ofSeconds(30);
+
+    @Override
+    public String adapterType()
+    {
+        return "BTS";
+    }
+
+    @Override
+    public DeviceAdapter create(AdapterInstanceConfig config)
+    {
+        BtsTcpTransport transport = new BtsTcpTransport(config.host(), config.port(),
+                CONNECT_TIMEOUT, RESPONSE_TIMEOUT);
+        return new BtsDeviceAdapter(config.adapterCode(), transport,
+                new BtsXmlCodec(config.adapterCode(), ZoneId.of(config.timezoneId())));
+    }
+}
diff --git a/ruoyi-device/src/main/java/com/ruoyi/device/registry/AdapterInstanceConfig.java b/ruoyi-device/src/main/java/com/ruoyi/device/registry/AdapterInstanceConfig.java
new file mode 100644
index 0000000..98d8227
--- /dev/null
+++ b/ruoyi-device/src/main/java/com/ruoyi/device/registry/AdapterInstanceConfig.java
@@ -0,0 +1,39 @@
+package com.ruoyi.device.registry;
+
+import java.time.ZoneId;
+import java.util.Locale;
+import java.util.Objects;
+import com.ruoyi.device.model.AdapterCapabilities;
+
+/** 设备适配器实例的运行时配置。 */
+public record AdapterInstanceConfig(
+        String adapterCode,
+        String adapterType,
+        String host,
+        int port,
+        int pollIntervalMs,
+        String timezoneId,
+        boolean enabled,
+        AdapterCapabilities capabilities)
+{
+    public AdapterInstanceConfig
+    {
+        adapterCode = requireText(adapterCode, "adapterCode");
+        adapterType = requireText(adapterType, "adapterType").toUpperCase(Locale.ROOT);
+        host = requireText(host, "host");
+        timezoneId = requireText(timezoneId, "timezoneId");
+        capabilities = Objects.requireNonNull(capabilities, "capabilities");
+        if (port < 1 || port > 65535) throw new IllegalArgumentException("port must be between 1 and 65535");
+        if (pollIntervalMs < 1) throw new IllegalArgumentException("pollIntervalMs must be positive");
+        ZoneId.of(timezoneId);
+        if (!capabilities.discovery() || capabilities.control())
+            throw new IllegalArgumentException("MVP adapter must support discovery and remain read-only");
+    }
+
+    private static String requireText(String value, String field)
+    {
+        String normalized = Objects.requireNonNull(value, field).trim();
+        if (normalized.isEmpty()) throw new IllegalArgumentException(field + " must not be blank");
+        return normalized;
+    }
+}
diff --git a/ruoyi-device/src/main/java/com/ruoyi/device/registry/AdapterRegistryException.java b/ruoyi-device/src/main/java/com/ruoyi/device/registry/AdapterRegistryException.java
new file mode 100644
index 0000000..3df23fc
--- /dev/null
+++ b/ruoyi-device/src/main/java/com/ruoyi/device/registry/AdapterRegistryException.java
@@ -0,0 +1,12 @@
+package com.ruoyi.device.registry;
+
+/** 适配器运行时注册失败。 */
+public final class AdapterRegistryException extends RuntimeException
+{
+    private static final long serialVersionUID = 1L;
+
+    public AdapterRegistryException(String message)
+    {
+        super(message);
+    }
+}
diff --git a/ruoyi-device/src/main/java/com/ruoyi/device/registry/DeviceAdapterFactory.java b/ruoyi-device/src/main/java/com/ruoyi/device/registry/DeviceAdapterFactory.java
new file mode 100644
index 0000000..1fef8e0
--- /dev/null
+++ b/ruoyi-device/src/main/java/com/ruoyi/device/registry/DeviceAdapterFactory.java
@@ -0,0 +1,11 @@
+package com.ruoyi.device.registry;
+
+import com.ruoyi.device.api.DeviceAdapter;
+
+/** 按适配器类型构造厂商协议实现。 */
+public interface DeviceAdapterFactory
+{
+    String adapterType();
+
+    DeviceAdapter create(AdapterInstanceConfig config);
+}
diff --git a/ruoyi-device/src/main/java/com/ruoyi/device/registry/DeviceAdapterRegistry.java b/ruoyi-device/src/main/java/com/ruoyi/device/registry/DeviceAdapterRegistry.java
new file mode 100644
index 0000000..b4d8895
--- /dev/null
+++ b/ruoyi-device/src/main/java/com/ruoyi/device/registry/DeviceAdapterRegistry.java
@@ -0,0 +1,80 @@
+package com.ruoyi.device.registry;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import com.ruoyi.device.api.DeviceAdapter;
+
+/** 管理启用设备适配器的运行时实例及其会话生命周期。 */
+public final class DeviceAdapterRegistry implements AutoCloseable
+{
+    private final Map<String, DeviceAdapterFactory> factories = new HashMap<>();
+    private final Map<String, RegisteredAdapter> adapters = new HashMap<>();
+
+    public DeviceAdapterRegistry(List<DeviceAdapterFactory> factories)
+    {
+        Objects.requireNonNull(factories, "factories").forEach(factory -> {
+            String type = normalizeType(factory.adapterType());
+            if (this.factories.put(type, factory) != null)
+                throw new IllegalArgumentException("Duplicate device adapter factory: " + type);
+        });
+    }
+
+    /** 按持久化配置注册、替换或移除运行时实例。 */
+    public synchronized void synchronize(AdapterInstanceConfig config)
+    {
+        Objects.requireNonNull(config, "config");
+        if (!config.enabled())
+        {
+            remove(config.adapterCode());
+            return;
+        }
+
+        RegisteredAdapter current = adapters.get(config.adapterCode());
+        if (current != null && current.config().equals(config)) return;
+
+        DeviceAdapterFactory factory = factories.get(normalizeType(config.adapterType()));
+        if (factory == null) throw new AdapterRegistryException("Unsupported device adapter type: " + config.adapterType());
+
+        DeviceAdapter replacement = factory.create(config);
+        RegisteredAdapter previous = adapters.put(config.adapterCode(), new RegisteredAdapter(config, replacement));
+        if (previous != null) previous.adapter().close();
+    }
+
+    /** 返回已启用适配器;未注册时给出可识别失败。 */
+    public synchronized DeviceAdapter require(String adapterCode)
+    {
+        RegisteredAdapter registered = adapters.get(adapterCode);
+        if (registered == null) throw new AdapterRegistryException("Device adapter is not enabled: " + adapterCode);
+        return registered.adapter();
+    }
+
+    public synchronized boolean contains(String adapterCode)
+    {
+        return adapters.containsKey(adapterCode);
+    }
+
+    @Override
+    public synchronized void close()
+    {
+        adapters.values().forEach(registered -> registered.adapter().close());
+        adapters.clear();
+    }
+
+    private void remove(String adapterCode)
+    {
+        RegisteredAdapter previous = adapters.remove(adapterCode);
+        if (previous != null) previous.adapter().close();
+    }
+
+    private static String normalizeType(String value)
+    {
+        return Objects.requireNonNull(value, "adapterType").trim().toUpperCase(Locale.ROOT);
+    }
+
+    private record RegisteredAdapter(AdapterInstanceConfig config, DeviceAdapter adapter)
+    {
+    }
+}
diff --git a/ruoyi-device/src/test/java/com/ruoyi/device/registry/BtsDeviceAdapterFactoryContractTest.java b/ruoyi-device/src/test/java/com/ruoyi/device/registry/BtsDeviceAdapterFactoryContractTest.java
new file mode 100644
index 0000000..43817b2
--- /dev/null
+++ b/ruoyi-device/src/test/java/com/ruoyi/device/registry/BtsDeviceAdapterFactoryContractTest.java
@@ -0,0 +1,33 @@
+package com.ruoyi.device.registry;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import com.ruoyi.device.bts.BtsDeviceAdapterFactory;
+import com.ruoyi.device.bts.ScriptedBtsTcpSimulator;
+import com.ruoyi.device.model.AdapterCapabilities;
+
+class BtsDeviceAdapterFactoryContractTest
+{
+    @Test
+    void discoversSimulatorChannelsThroughConfiguredReadOnlyRegistry() throws Exception
+    {
+        try (ScriptedBtsTcpSimulator simulator = new ScriptedBtsTcpSimulator();
+             DeviceAdapterRegistry registry = new DeviceAdapterRegistry(List.of(new BtsDeviceAdapterFactory())))
+        {
+            simulator.enqueue("<bts><cmd>getdevinfo_resp</cmd><middle count=\"1\">"
+                    + "<channel ip=\"192.168.1.35\" devtype=\"22\" devid=\"1\" subdevid=\"2\" chlid=\"3\">true</channel>"
+                    + "</middle></bts>");
+            registry.synchronize(new AdapterInstanceConfig("lab-bts", "BTS", "127.0.0.1", simulator.port(),
+                    5000, "Asia/Hong_Kong", true, AdapterCapabilities.observeOnly()));
+
+            var adapter = registry.require("lab-bts");
+            var discovered = adapter.discoverChannels();
+
+            assertEquals("22-1-2-3", discovered.get(0).identity().vendorKey());
+            assertEquals("192.168.1.35", discovered.get(0).serverIp());
+            assertFalse(adapter.capabilities().control());
+        }
+    }
+}
diff --git a/ruoyi-device/src/test/java/com/ruoyi/device/registry/DeviceAdapterRegistryTest.java b/ruoyi-device/src/test/java/com/ruoyi/device/registry/DeviceAdapterRegistryTest.java
new file mode 100644
index 0000000..b062175
--- /dev/null
+++ b/ruoyi-device/src/test/java/com/ruoyi/device/registry/DeviceAdapterRegistryTest.java
@@ -0,0 +1,144 @@
+package com.ruoyi.device.registry;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.junit.jupiter.api.Test;
+import com.ruoyi.device.api.DeviceAdapter;
+import com.ruoyi.device.model.AdapterCapabilities;
+import com.ruoyi.device.model.ChannelIdentity;
+import com.ruoyi.device.model.DeviceControlCommand;
+
+class DeviceAdapterRegistryTest
+{
+    @Test
+    void synchronizesEnabledConfigurationWithoutRecreatingUnchangedAdapter()
+    {
+        RecordingFactory factory = new RecordingFactory();
+        try (DeviceAdapterRegistry registry = new DeviceAdapterRegistry(List.of(factory)))
+        {
+            AdapterInstanceConfig config = config("127.0.0.1", true);
+
+            registry.synchronize(config);
+            DeviceAdapter first = registry.require("lab-bts");
+            registry.synchronize(config);
+
+            assertSame(first, registry.require("lab-bts"));
+            assertFalse(factory.adapters.get(0).closed.get());
+        }
+    }
+
+    @Test
+    void replacesChangedConfigurationAndClosesAdapterWhenDisabled()
+    {
+        RecordingFactory factory = new RecordingFactory();
+        try (DeviceAdapterRegistry registry = new DeviceAdapterRegistry(List.of(factory)))
+        {
+            registry.synchronize(config("127.0.0.1", true));
+            StubAdapter first = factory.adapters.get(0);
+
+            registry.synchronize(config("127.0.0.2", true));
+
+            assertTrue(first.closed.get());
+            assertNotSame(first, registry.require("lab-bts"));
+
+            registry.synchronize(config("127.0.0.2", false));
+
+            assertTrue(factory.adapters.get(1).closed.get());
+            assertFalse(registry.contains("lab-bts"));
+            assertThrows(AdapterRegistryException.class, () -> registry.require("lab-bts"));
+        }
+    }
+
+    private static AdapterInstanceConfig config(String host, boolean enabled)
+    {
+        return new AdapterInstanceConfig("lab-bts", "BTS", host, 502, 5000,
+                "Asia/Hong_Kong", enabled, AdapterCapabilities.observeOnly());
+    }
+
+    private static final class RecordingFactory implements DeviceAdapterFactory
+    {
+        private final List<StubAdapter> adapters = new ArrayList<>();
+
+        @Override
+        public String adapterType()
+        {
+            return "BTS";
+        }
+
+        @Override
+        public DeviceAdapter create(AdapterInstanceConfig config)
+        {
+            StubAdapter adapter = new StubAdapter(config.adapterCode());
+            adapters.add(adapter);
+            return adapter;
+        }
+    }
+
+    private static final class StubAdapter implements DeviceAdapter
+    {
+        private final String code;
+        private final AtomicBoolean closed = new AtomicBoolean();
+
+        private StubAdapter(String code)
+        {
+            this.code = code;
+        }
+
+        @Override
+        public String adapterCode()
+        {
+            return code;
+        }
+
+        @Override
+        public AdapterCapabilities capabilities()
+        {
+            return AdapterCapabilities.observeOnly();
+        }
+
+        @Override
+        public List<com.ruoyi.device.model.DetectedChannel> discoverChannels()
+        {
+            return List.of();
+        }
+
+        @Override
+        public java.util.Map<ChannelIdentity, com.ruoyi.device.model.RealtimeSnapshot> inquire(
+                java.util.Set<ChannelIdentity> channels)
+        {
+            return java.util.Map.of();
+        }
+
+        @Override
+        public List<com.ruoyi.device.model.HistoryAvailability> inquireHistory(
+                java.util.Set<ChannelIdentity> channels)
+        {
+            return List.of();
+        }
+
+        @Override
+        public com.ruoyi.device.model.HistoryPage downloadHistory(ChannelIdentity channel,
+                String btsTestId, long startPosition, int limit)
+        {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public void control(ChannelIdentity channel, DeviceControlCommand command)
+        {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public void close()
+        {
+            closed.set(true);
+        }
+    }
+}
diff --git a/ruoyi-system/pom.xml b/ruoyi-system/pom.xml
index 572f5e9..fc5bc9b 100644
--- a/ruoyi-system/pom.xml
+++ b/ruoyi-system/pom.xml
@@ -17,6 +17,12 @@
 
     <dependencies>
 
+        <!-- 厂商无关设备适配契约 -->
+        <dependency>
+            <groupId>com.ruoyi</groupId>
+            <artifactId>ruoyi-device</artifactId>
+        </dependency>
+
         <!-- 通用工具-->
         <dependency>
             <groupId>com.ruoyi</groupId>
@@ -25,4 +31,4 @@
 
     </dependencies>
 
-</project>
\ No newline at end of file
+</project>
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/TDetectionCabinet.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TDetectionCabinet.java
index 80b3d7c..4610327 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/TDetectionCabinet.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TDetectionCabinet.java
@@ -2,6 +2,7 @@
 
 import java.math.BigDecimal;
 import java.util.Date;
+import java.time.Instant;
 import com.fasterxml.jackson.annotation.JsonFormat;
 import jakarta.validation.constraints.Max;
 import jakarta.validation.constraints.Min;
@@ -22,6 +23,13 @@
 
     /** 检测柜主键。 */
     private Long cabinetId;
+
+    /** 所属设备适配器实例。 */
+    private Long adapterId;
+
+    private Integer vendorDeviceType;
+    private Integer vendorDeviceId;
+    private Instant lastSeenAtUtc;
 
     /** 全局唯一的设备编码。 */
     @NotBlank(message = "设备编码不能为空")
@@ -91,6 +99,15 @@
         return cabinetId;
     }
 
+    public Long getAdapterId() { return adapterId; }
+    public void setAdapterId(Long adapterId) { this.adapterId = adapterId; }
+    public Integer getVendorDeviceType() { return vendorDeviceType; }
+    public void setVendorDeviceType(Integer value) { vendorDeviceType = value; }
+    public Integer getVendorDeviceId() { return vendorDeviceId; }
+    public void setVendorDeviceId(Integer value) { vendorDeviceId = value; }
+    public Instant getLastSeenAtUtc() { return lastSeenAtUtc; }
+    public void setLastSeenAtUtc(Instant value) { lastSeenAtUtc = value; }
+
     public void setCabinetId(Long cabinetId)
     {
         this.cabinetId = cabinetId;
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/TDetectionChannel.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TDetectionChannel.java
new file mode 100644
index 0000000..c0096bb
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TDetectionChannel.java
@@ -0,0 +1,48 @@
+package com.ruoyi.system.domain;
+
+import java.time.Instant;
+import com.ruoyi.common.core.domain.BaseEntity;
+
+/** 检测柜通道对象 t_detection_channel。 */
+public class TDetectionChannel extends BaseEntity
+{
+    private static final long serialVersionUID = 1L;
+
+    private Long channelId;
+    private Long adapterId;
+    private Long cabinetId;
+    private Integer devtype;
+    private Integer devid;
+    private Integer subdevid;
+    private Integer chlid;
+    private String displayNo;
+    private String onlineStatus;
+    private String workStatus;
+    private Instant lastSeenAtUtc;
+    private String status;
+
+    public Long getChannelId() { return channelId; }
+    public void setChannelId(Long value) { channelId = value; }
+    public Long getAdapterId() { return adapterId; }
+    public void setAdapterId(Long value) { adapterId = value; }
+    public Long getCabinetId() { return cabinetId; }
+    public void setCabinetId(Long value) { cabinetId = value; }
+    public Integer getDevtype() { return devtype; }
+    public void setDevtype(Integer value) { devtype = value; }
+    public Integer getDevid() { return devid; }
+    public void setDevid(Integer value) { devid = value; }
+    public Integer getSubdevid() { return subdevid; }
+    public void setSubdevid(Integer value) { subdevid = value; }
+    public Integer getChlid() { return chlid; }
+    public void setChlid(Integer value) { chlid = value; }
+    public String getDisplayNo() { return displayNo; }
+    public void setDisplayNo(String value) { displayNo = value; }
+    public String getOnlineStatus() { return onlineStatus; }
+    public void setOnlineStatus(String value) { onlineStatus = value; }
+    public String getWorkStatus() { return workStatus; }
+    public void setWorkStatus(String value) { workStatus = value; }
+    public Instant getLastSeenAtUtc() { return lastSeenAtUtc; }
+    public void setLastSeenAtUtc(Instant value) { lastSeenAtUtc = value; }
+    public String getStatus() { return status; }
+    public void setStatus(String value) { status = value; }
+}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/TDeviceAdapter.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TDeviceAdapter.java
new file mode 100644
index 0000000..d4d583f
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TDeviceAdapter.java
@@ -0,0 +1,94 @@
+package com.ruoyi.system.domain;
+
+import java.time.Instant;
+import jakarta.validation.constraints.AssertTrue;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import com.ruoyi.common.core.domain.BaseEntity;
+
+/** 设备适配器实例对象 t_device_adapter。 */
+public class TDeviceAdapter extends BaseEntity
+{
+    private static final long serialVersionUID = 1L;
+
+    private Long adapterId;
+
+    @NotBlank(message = "适配器编码不能为空")
+    @Size(max = 64, message = "适配器编码长度不能超过64个字符")
+    private String adapterCode;
+
+    @NotBlank(message = "适配器名称不能为空")
+    @Size(max = 100, message = "适配器名称长度不能超过100个字符")
+    private String adapterName;
+
+    @NotBlank(message = "适配器类型不能为空")
+    private String adapterType;
+
+    @NotBlank(message = "主机不能为空")
+    @Size(max = 255, message = "主机长度不能超过255个字符")
+    private String host;
+
+    @NotNull(message = "端口不能为空")
+    @Min(value = 1, message = "端口必须在1到65535之间")
+    @Max(value = 65535, message = "端口必须在1到65535之间")
+    private Integer port;
+
+    @NotNull(message = "轮询周期不能为空")
+    @Min(value = 1000, message = "轮询周期不能小于1000毫秒")
+    private Integer pollIntervalMs;
+
+    @NotBlank(message = "时区不能为空")
+    @Size(max = 64, message = "时区长度不能超过64个字符")
+    private String timezoneId;
+
+    @NotNull(message = "只读能力不能为空")
+    @AssertTrue(message = "当前里程碑只允许只读设备适配器")
+    private Boolean readOnly;
+
+    private String capabilities;
+    private String status;
+    private String connectionStatus;
+    private String lastErrorCode;
+    private String lastErrorMessage;
+    private Instant lastAttemptAtUtc;
+    private Instant lastObservedAtUtc;
+    private String delFlag;
+
+    public Long getAdapterId() { return adapterId; }
+    public void setAdapterId(Long adapterId) { this.adapterId = adapterId; }
+    public String getAdapterCode() { return adapterCode; }
+    public void setAdapterCode(String adapterCode) { this.adapterCode = adapterCode; }
+    public String getAdapterName() { return adapterName; }
+    public void setAdapterName(String adapterName) { this.adapterName = adapterName; }
+    public String getAdapterType() { return adapterType; }
+    public void setAdapterType(String adapterType) { this.adapterType = adapterType; }
+    public String getHost() { return host; }
+    public void setHost(String host) { this.host = host; }
+    public Integer getPort() { return port; }
+    public void setPort(Integer port) { this.port = port; }
+    public Integer getPollIntervalMs() { return pollIntervalMs; }
+    public void setPollIntervalMs(Integer pollIntervalMs) { this.pollIntervalMs = pollIntervalMs; }
+    public String getTimezoneId() { return timezoneId; }
+    public void setTimezoneId(String timezoneId) { this.timezoneId = timezoneId; }
+    public Boolean getReadOnly() { return readOnly; }
+    public void setReadOnly(Boolean readOnly) { this.readOnly = readOnly; }
+    public String getCapabilities() { return capabilities; }
+    public void setCapabilities(String capabilities) { this.capabilities = capabilities; }
+    public String getStatus() { return status; }
+    public void setStatus(String status) { this.status = status; }
+    public String getConnectionStatus() { return connectionStatus; }
+    public void setConnectionStatus(String connectionStatus) { this.connectionStatus = connectionStatus; }
+    public String getLastErrorCode() { return lastErrorCode; }
+    public void setLastErrorCode(String lastErrorCode) { this.lastErrorCode = lastErrorCode; }
+    public String getLastErrorMessage() { return lastErrorMessage; }
+    public void setLastErrorMessage(String lastErrorMessage) { this.lastErrorMessage = lastErrorMessage; }
+    public Instant getLastAttemptAtUtc() { return lastAttemptAtUtc; }
+    public void setLastAttemptAtUtc(Instant value) { this.lastAttemptAtUtc = value; }
+    public Instant getLastObservedAtUtc() { return lastObservedAtUtc; }
+    public void setLastObservedAtUtc(Instant value) { this.lastObservedAtUtc = value; }
+    public String getDelFlag() { return delFlag; }
+    public void setDelFlag(String delFlag) { this.delFlag = delFlag; }
+}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/AdapterDiscoveryResult.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/AdapterDiscoveryResult.java
new file mode 100644
index 0000000..39a67fc
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/AdapterDiscoveryResult.java
@@ -0,0 +1,8 @@
+package com.ruoyi.system.domain.vo;
+
+import java.time.Instant;
+
+/** 单次设备发现的持久化结果。 */
+public record AdapterDiscoveryResult(int cabinetCount, int channelCount, Instant observedAtUtc)
+{
+}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DiscoveredCabinetVo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DiscoveredCabinetVo.java
deleted file mode 100644
index b217179..0000000
--- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DiscoveredCabinetVo.java
+++ /dev/null
@@ -1,100 +0,0 @@
-package com.ruoyi.system.domain.vo;
-
-/**
- * 可发现检测柜视图对象。
- */
-public class DiscoveredCabinetVo
-{
-    /** 设备编码 */
-    private String deviceCode;
-
-    /** IP 地址 */
-    private String ip;
-
-    /** MAC 地址 */
-    private String mac;
-
-    /** 在线状态:在线、离线 */
-    private String onlineStatus;
-
-    /** 测试状态:空闲、测试中 */
-    private String testStatus;
-
-    /** 当前测试 */
-    private String currentTest;
-
-    public DiscoveredCabinetVo()
-    {
-    }
-
-    public DiscoveredCabinetVo(String deviceCode, String ip, String mac, String onlineStatus,
-            String testStatus, String currentTest)
-    {
-        this.deviceCode = deviceCode;
-        this.ip = ip;
-        this.mac = mac;
-        this.onlineStatus = onlineStatus;
-        this.testStatus = testStatus;
-        this.currentTest = currentTest;
-    }
-
-    public String getDeviceCode()
-    {
-        return deviceCode;
-    }
-
-    public void setDeviceCode(String deviceCode)
-    {
-        this.deviceCode = deviceCode;
-    }
-
-    public String getIp()
-    {
-        return ip;
-    }
-
-    public void setIp(String ip)
-    {
-        this.ip = ip;
-    }
-
-    public String getMac()
-    {
-        return mac;
-    }
-
-    public void setMac(String mac)
-    {
-        this.mac = mac;
-    }
-
-    public String getOnlineStatus()
-    {
-        return onlineStatus;
-    }
-
-    public void setOnlineStatus(String onlineStatus)
-    {
-        this.onlineStatus = onlineStatus;
-    }
-
-    public String getTestStatus()
-    {
-        return testStatus;
-    }
-
-    public void setTestStatus(String testStatus)
-    {
-        this.testStatus = testStatus;
-    }
-
-    public String getCurrentTest()
-    {
-        return currentTest;
-    }
-
-    public void setCurrentTest(String currentTest)
-    {
-        this.currentTest = currentTest;
-    }
-}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TDeviceAdapterMapper.java b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TDeviceAdapterMapper.java
new file mode 100644
index 0000000..e9f3825
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TDeviceAdapterMapper.java
@@ -0,0 +1,35 @@
+package com.ruoyi.system.mapper;
+
+import java.time.Instant;
+import java.util.List;
+import org.apache.ibatis.annotations.Param;
+import com.ruoyi.system.domain.TDetectionCabinet;
+import com.ruoyi.system.domain.TDetectionChannel;
+import com.ruoyi.system.domain.TDeviceAdapter;
+
+public interface TDeviceAdapterMapper
+{
+    List<TDeviceAdapter> selectDeviceAdapterList(TDeviceAdapter query);
+    List<TDeviceAdapter> selectEnabledDeviceAdapters();
+    TDeviceAdapter selectDeviceAdapterById(Long adapterId);
+    TDeviceAdapter selectDeviceAdapterByCode(String adapterCode);
+    int insertDeviceAdapter(TDeviceAdapter adapter);
+    int updateDeviceAdapter(TDeviceAdapter adapter);
+    int updateDeviceAdapterStatus(@Param("adapterId") Long adapterId, @Param("status") String status,
+            @Param("updateBy") String updateBy);
+    int updateDiscoverySuccess(@Param("adapterId") Long adapterId, @Param("observedAt") Instant observedAt);
+    int updateDiscoveryFailure(@Param("adapterId") Long adapterId, @Param("errorCode") String errorCode,
+            @Param("errorMessage") String errorMessage);
+    Long selectCabinetIdByIdentity(@Param("adapterId") Long adapterId,
+            @Param("devtype") Integer devtype, @Param("devid") Integer devid);
+    int insertDetectedCabinet(TDetectionCabinet cabinet);
+    int updateDetectedCabinet(TDetectionCabinet cabinet);
+    int upsertDetectedChannel(TDetectionChannel channel);
+    List<TDetectionChannel> selectDetectionChannelsByAdapterId(Long adapterId);
+
+    /** 发现只追加或更新,不提供“本轮缺失即删除”的持久化操作。 */
+    default int deleteMissingChannels(Long adapterId, List<String> identities)
+    {
+        throw new UnsupportedOperationException("Discovery must not delete missing channels");
+    }
+}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/DeviceAdapterErrorCodes.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/DeviceAdapterErrorCodes.java
new file mode 100644
index 0000000..cc07d2e
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/DeviceAdapterErrorCodes.java
@@ -0,0 +1,16 @@
+package com.ruoyi.system.service;
+
+/** 设备适配器 REST 使用的稳定业务错误码。 */
+public final class DeviceAdapterErrorCodes
+{
+    public static final int NOT_FOUND = 53001;
+    public static final int INVALID_CONFIG = 53002;
+    public static final int DISABLED = 53003;
+    public static final int UNSUPPORTED_TYPE = 53004;
+    public static final int DISCOVERY_FAILED = 53005;
+    public static final int DUPLICATE_CODE = 53006;
+
+    private DeviceAdapterErrorCodes()
+    {
+    }
+}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/IDetectionCabinetDiscoveryService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/IDetectionCabinetDiscoveryService.java
deleted file mode 100644
index 8782ee2..0000000
--- a/ruoyi-system/src/main/java/com/ruoyi/system/service/IDetectionCabinetDiscoveryService.java
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.ruoyi.system.service;
-
-import java.util.List;
-import com.ruoyi.system.domain.vo.DiscoveredCabinetVo;
-
-/**
- * 检测柜设备发现服务。
- */
-public interface IDetectionCabinetDiscoveryService
-{
-    /**
-     * 查询当前可发现的检测柜。
-     *
-     * @return 可发现检测柜列表
-     */
-    List<DiscoveredCabinetVo> selectDiscoveredCabinetList();
-}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/IDeviceAdapterService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/IDeviceAdapterService.java
new file mode 100644
index 0000000..2abceec
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/IDeviceAdapterService.java
@@ -0,0 +1,19 @@
+package com.ruoyi.system.service;
+
+import java.util.List;
+import com.ruoyi.system.domain.TDetectionChannel;
+import com.ruoyi.system.domain.TDeviceAdapter;
+import com.ruoyi.system.domain.vo.AdapterDiscoveryResult;
+
+public interface IDeviceAdapterService
+{
+    List<TDeviceAdapter> selectList(TDeviceAdapter query);
+    TDeviceAdapter selectById(Long adapterId);
+    int insert(TDeviceAdapter adapter);
+    int update(TDeviceAdapter adapter);
+    int changeStatus(Long adapterId, boolean enabled, String updateBy);
+    AdapterDiscoveryResult discover(Long adapterId);
+    List<TDetectionChannel> selectChannels(Long adapterId);
+    void synchronizeEnabledAdapters();
+    void pollDueAdapters();
+}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DetectionCabinetDiscoveryServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DetectionCabinetDiscoveryServiceImpl.java
deleted file mode 100644
index e058346..0000000
--- a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DetectionCabinetDiscoveryServiceImpl.java
+++ /dev/null
@@ -1,28 +0,0 @@
-package com.ruoyi.system.service.impl;
-
-import java.util.Arrays;
-import java.util.List;
-import org.springframework.stereotype.Service;
-import com.ruoyi.system.domain.vo.DiscoveredCabinetVo;
-import com.ruoyi.system.service.IDetectionCabinetDiscoveryService;
-
-/**
- * 检测柜设备发现服务默认实现。
- *
- * 当前返回测试设备;硬件接入后在此处替换为真实设备发现逻辑。
- */
-@Service
-public class DetectionCabinetDiscoveryServiceImpl implements IDetectionCabinetDiscoveryService
-{
-    @Override
-    public List<DiscoveredCabinetVo> selectDiscoveredCabinetList()
-    {
-        return Arrays.asList(
-                new DiscoveredCabinetVo("CAB-A01", "192.168.1.101", "AA:BB:CC:DD:EE:01", "在线", "空闲", "—"),
-                new DiscoveredCabinetVo("CAB-A02", "192.168.1.102", "AA:BB:CC:DD:EE:02", "在线", "测试中", "电池组 B-2025-0147"),
-                new DiscoveredCabinetVo("CAB-A03", "192.168.1.103", "AA:BB:CC:DD:EE:03", "离线", "空闲", "—"),
-                new DiscoveredCabinetVo("CAB-B01", "192.168.2.101", "AA:BB:CC:DD:EF:01", "在线", "空闲", "—"),
-                new DiscoveredCabinetVo("CAB-B02", "192.168.2.102", "AA:BB:CC:DD:EF:02", "在线", "测试中", "电池组 B-2025-0153"),
-                new DiscoveredCabinetVo("CAB-B03", "192.168.2.103", "AA:BB:CC:DD:EF:03", "离线", "空闲", "—"));
-    }
-}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceAdapterRuntimeCoordinator.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceAdapterRuntimeCoordinator.java
new file mode 100644
index 0000000..7b1d46d
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceAdapterRuntimeCoordinator.java
@@ -0,0 +1,22 @@
+package com.ruoyi.system.service.impl;
+
+import org.springframework.boot.context.event.ApplicationReadyEvent;
+import org.springframework.context.event.EventListener;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import com.ruoyi.system.service.IDeviceAdapterService;
+
+/** 在单采集进程内恢复启用实例,并按各自周期执行只读发现。 */
+@Component
+public class DeviceAdapterRuntimeCoordinator
+{
+    private final IDeviceAdapterService service;
+
+    public DeviceAdapterRuntimeCoordinator(IDeviceAdapterService service) { this.service = service; }
+
+    @EventListener(ApplicationReadyEvent.class)
+    public void restoreEnabledAdapters() { service.synchronizeEnabledAdapters(); }
+
+    @Scheduled(fixedDelay = 1000)
+    public void pollDueAdapters() { service.pollDueAdapters(); }
+}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImpl.java
new file mode 100644
index 0000000..5b06844
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImpl.java
@@ -0,0 +1,199 @@
+package com.ruoyi.system.service.impl;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.util.List;
+import java.util.Locale;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.stereotype.Service;
+import com.ruoyi.common.exception.ServiceException;
+import com.ruoyi.common.utils.StringUtils;
+import com.ruoyi.device.api.DeviceAdapterException;
+import com.ruoyi.device.bts.BtsProtocolException;
+import com.ruoyi.device.bts.BtsTransportException;
+import com.ruoyi.device.model.AdapterCapabilities;
+import com.ruoyi.device.registry.AdapterInstanceConfig;
+import com.ruoyi.device.registry.AdapterRegistryException;
+import com.ruoyi.device.registry.DeviceAdapterRegistry;
+import com.ruoyi.system.domain.TDetectionChannel;
+import com.ruoyi.system.domain.TDeviceAdapter;
+import com.ruoyi.system.domain.vo.AdapterDiscoveryResult;
+import com.ruoyi.system.mapper.TDeviceAdapterMapper;
+import com.ruoyi.system.service.DeviceAdapterErrorCodes;
+import com.ruoyi.system.service.IDeviceAdapterService;
+
+@Service
+public class DeviceAdapterServiceImpl implements IDeviceAdapterService
+{
+    private static final String ENABLED = "0";
+    private static final String DISABLED = "1";
+    private static final String READ_ONLY_CAPABILITIES =
+            "{\"discovery\":true,\"realtimeObservation\":true,\"historyDownload\":true,\"control\":false}";
+
+    private final TDeviceAdapterMapper mapper;
+    private final DeviceAdapterRegistry registry;
+    private final DeviceDiscoveryPersistenceService persistence;
+
+    public DeviceAdapterServiceImpl(TDeviceAdapterMapper mapper, DeviceAdapterRegistry registry,
+            DeviceDiscoveryPersistenceService persistence)
+    {
+        this.mapper = mapper;
+        this.registry = registry;
+        this.persistence = persistence;
+    }
+
+    DeviceAdapterServiceImpl(TDeviceAdapterMapper mapper, DeviceAdapterRegistry registry)
+    {
+        this(mapper, registry, new DeviceDiscoveryPersistenceService(mapper));
+    }
+
+    @Override
+    public List<TDeviceAdapter> selectList(TDeviceAdapter query) { return mapper.selectDeviceAdapterList(query); }
+
+    @Override
+    public TDeviceAdapter selectById(Long adapterId)
+    {
+        TDeviceAdapter adapter = mapper.selectDeviceAdapterById(adapterId);
+        if (adapter == null) throw error("设备适配器实例不存在", DeviceAdapterErrorCodes.NOT_FOUND);
+        return adapter;
+    }
+
+    @Override
+    public int insert(TDeviceAdapter adapter)
+    {
+        normalizeAndValidate(adapter);
+        if (mapper.selectDeviceAdapterByCode(adapter.getAdapterCode()) != null)
+            throw error("适配器编码已存在", DeviceAdapterErrorCodes.DUPLICATE_CODE);
+        adapter.setCapabilities(READ_ONLY_CAPABILITIES);
+        adapter.setStatus(DISABLED);
+        adapter.setConnectionStatus("DISCONNECTED");
+        adapter.setDelFlag("0");
+        try { return mapper.insertDeviceAdapter(adapter); }
+        catch (DuplicateKeyException exception) { throw error("适配器编码已存在", DeviceAdapterErrorCodes.DUPLICATE_CODE); }
+    }
+
+    @Override
+    public int update(TDeviceAdapter adapter)
+    {
+        TDeviceAdapter current = selectById(adapter.getAdapterId());
+        normalizeAndValidate(adapter);
+        adapter.setAdapterCode(current.getAdapterCode());
+        adapter.setCapabilities(READ_ONLY_CAPABILITIES);
+        int rows = mapper.updateDeviceAdapter(adapter);
+        if (ENABLED.equals(current.getStatus()))
+        {
+            adapter.setStatus(ENABLED);
+            registry.synchronize(toRuntimeConfig(adapter));
+        }
+        return rows;
+    }
+
+    @Override
+    public int changeStatus(Long adapterId, boolean enabled, String updateBy)
+    {
+        TDeviceAdapter adapter = selectById(adapterId);
+        String status = enabled ? ENABLED : DISABLED;
+        if (status.equals(adapter.getStatus())) return 1;
+        int rows = mapper.updateDeviceAdapterStatus(adapterId, status, updateBy);
+        adapter.setStatus(status);
+        registry.synchronize(toRuntimeConfig(adapter));
+        return rows;
+    }
+
+    @Override
+    public AdapterDiscoveryResult discover(Long adapterId)
+    {
+        TDeviceAdapter adapter = selectById(adapterId);
+        if (!ENABLED.equals(adapter.getStatus()))
+            throw error("设备适配器实例未启用", DeviceAdapterErrorCodes.DISABLED);
+        try
+        {
+            registry.synchronize(toRuntimeConfig(adapter));
+            List<com.ruoyi.device.model.DetectedChannel> channels = registry.require(adapter.getAdapterCode()).discoverChannels();
+            Instant observedAt = Instant.now();
+            AdapterDiscoveryResult result = persistence.persist(adapter, channels, observedAt);
+            mapper.updateDiscoverySuccess(adapterId, observedAt);
+            return result;
+        }
+        catch (BtsTransportException exception)
+        {
+            return failDiscovery(adapterId, exception.reason().name(), exception);
+        }
+        catch (BtsProtocolException exception)
+        {
+            return failDiscovery(adapterId, exception.reason().name(), exception);
+        }
+        catch (DeviceAdapterException | AdapterRegistryException exception)
+        {
+            return failDiscovery(adapterId, "ADAPTER_FAILURE", exception);
+        }
+    }
+
+    @Override
+    public List<TDetectionChannel> selectChannels(Long adapterId)
+    {
+        selectById(adapterId);
+        return mapper.selectDetectionChannelsByAdapterId(adapterId);
+    }
+
+    @Override
+    public void synchronizeEnabledAdapters()
+    {
+        mapper.selectEnabledDeviceAdapters().forEach(adapter -> registry.synchronize(toRuntimeConfig(adapter)));
+    }
+
+    @Override
+    public void pollDueAdapters()
+    {
+        Instant now = Instant.now();
+        for (TDeviceAdapter adapter : mapper.selectEnabledDeviceAdapters())
+        {
+            Instant lastAttempt = adapter.getLastAttemptAtUtc();
+            if (lastAttempt != null && Duration.between(lastAttempt, now).toMillis() < adapter.getPollIntervalMs()) continue;
+            try { discover(adapter.getAdapterId()); }
+            catch (ServiceException ignored) { /* 失败状态已持久化,下一轮按周期重试。 */ }
+        }
+    }
+
+    private AdapterDiscoveryResult failDiscovery(Long adapterId, String errorCode, RuntimeException exception)
+    {
+        mapper.updateDiscoveryFailure(adapterId, errorCode, exception.getMessage());
+        throw error("设备发现失败:" + exception.getMessage(), DeviceAdapterErrorCodes.DISCOVERY_FAILED);
+    }
+
+    private void normalizeAndValidate(TDeviceAdapter adapter)
+    {
+        adapter.setAdapterCode(StringUtils.trim(adapter.getAdapterCode()));
+        adapter.setAdapterName(StringUtils.trim(adapter.getAdapterName()));
+        adapter.setAdapterType(StringUtils.trim(adapter.getAdapterType()).toUpperCase(Locale.ROOT));
+        adapter.setHost(StringUtils.trim(adapter.getHost()));
+        adapter.setTimezoneId(StringUtils.trim(adapter.getTimezoneId()));
+        adapter.setRemark(StringUtils.trim(adapter.getRemark()));
+        if (!"BTS".equals(adapter.getAdapterType()))
+            throw error("不支持的适配器类型", DeviceAdapterErrorCodes.UNSUPPORTED_TYPE);
+        if (!Boolean.TRUE.equals(adapter.getReadOnly()))
+            throw error("当前里程碑只允许只读设备适配器", DeviceAdapterErrorCodes.INVALID_CONFIG);
+        try
+        {
+            if (adapter.getPort() == null || adapter.getPort() < 1 || adapter.getPort() > 65535
+                    || adapter.getPollIntervalMs() == null || adapter.getPollIntervalMs() < 1000
+                    || StringUtils.isBlank(adapter.getAdapterCode()) || StringUtils.isBlank(adapter.getAdapterName())
+                    || StringUtils.isBlank(adapter.getHost())) throw new IllegalArgumentException();
+            ZoneId.of(adapter.getTimezoneId());
+        }
+        catch (RuntimeException exception)
+        {
+            throw error("设备适配器配置不正确", DeviceAdapterErrorCodes.INVALID_CONFIG);
+        }
+    }
+
+    private AdapterInstanceConfig toRuntimeConfig(TDeviceAdapter adapter)
+    {
+        return new AdapterInstanceConfig(adapter.getAdapterCode(), adapter.getAdapterType(), adapter.getHost(),
+                adapter.getPort(), adapter.getPollIntervalMs(), adapter.getTimezoneId(),
+                ENABLED.equals(adapter.getStatus()), AdapterCapabilities.observeOnly());
+    }
+
+    private ServiceException error(String message, int code) { return new ServiceException(message, code); }
+}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceDiscoveryPersistenceService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceDiscoveryPersistenceService.java
new file mode 100644
index 0000000..2989aaa
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceDiscoveryPersistenceService.java
@@ -0,0 +1,101 @@
+package com.ruoyi.system.service.impl;
+
+import java.math.BigDecimal;
+import java.time.Instant;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import com.ruoyi.device.model.DetectedChannel;
+import com.ruoyi.system.domain.TDetectionCabinet;
+import com.ruoyi.system.domain.TDetectionChannel;
+import com.ruoyi.system.domain.TDeviceAdapter;
+import com.ruoyi.system.domain.vo.AdapterDiscoveryResult;
+import com.ruoyi.system.mapper.TDeviceAdapterMapper;
+
+@Service
+public class DeviceDiscoveryPersistenceService
+{
+    private final TDeviceAdapterMapper mapper;
+
+    public DeviceDiscoveryPersistenceService(TDeviceAdapterMapper mapper)
+    {
+        this.mapper = mapper;
+    }
+
+    /** 在一个事务内 upsert 本轮观测事实;未返回通道保持原记录不变。 */
+    @Transactional(rollbackFor = Exception.class)
+    public AdapterDiscoveryResult persist(TDeviceAdapter adapter, List<DetectedChannel> detected, Instant observedAt)
+    {
+        Map<CabinetKey, List<DetectedChannel>> cabinets = detected.stream().collect(
+                java.util.stream.Collectors.groupingBy(
+                        item -> new CabinetKey(item.identity().devtype(), item.identity().devid()),
+                        LinkedHashMap::new, java.util.stream.Collectors.toList()));
+
+        for (Map.Entry<CabinetKey, List<DetectedChannel>> entry : cabinets.entrySet())
+        {
+            Long cabinetId = ensureCabinet(adapter, entry.getKey(), entry.getValue(), observedAt);
+            for (DetectedChannel item : entry.getValue())
+                mapper.upsertDetectedChannel(toChannel(adapter.getAdapterId(), cabinetId, item, observedAt));
+        }
+        return new AdapterDiscoveryResult(cabinets.size(), detected.size(), observedAt);
+    }
+
+    private Long ensureCabinet(TDeviceAdapter adapter, CabinetKey key, List<DetectedChannel> channels,
+            Instant observedAt)
+    {
+        Long cabinetId = mapper.selectCabinetIdByIdentity(adapter.getAdapterId(), key.devtype(), key.devid());
+        TDetectionCabinet cabinet = new TDetectionCabinet();
+        cabinet.setCabinetId(cabinetId);
+        cabinet.setAdapterId(adapter.getAdapterId());
+        cabinet.setCabinetCode(adapter.getAdapterCode() + "-" + key.devtype() + "-" + key.devid());
+        cabinet.setCabinetName(adapter.getAdapterName() + " " + key.devtype() + "-" + key.devid());
+        cabinet.setCapacity(BigDecimal.ONE);
+        cabinet.setChannelCount(channels.size());
+        cabinet.setVendorDeviceType(key.devtype());
+        cabinet.setVendorDeviceId(key.devid());
+        cabinet.setIpAddress(channels.stream().map(DetectedChannel::serverIp).filter(value -> value != null && !value.isBlank())
+                .findFirst().orElse(null));
+        cabinet.setOnlineStatus(channels.stream().anyMatch(DetectedChannel::available) ? "ONLINE" : "OFFLINE");
+        cabinet.setLastSeenAtUtc(observedAt);
+        if (cabinetId == null)
+        {
+            try
+            {
+                mapper.insertDetectedCabinet(cabinet);
+                return cabinet.getCabinetId();
+            }
+            catch (DuplicateKeyException concurrentInsert)
+            {
+                cabinetId = mapper.selectCabinetIdByIdentity(adapter.getAdapterId(), key.devtype(), key.devid());
+                if (cabinetId == null) throw concurrentInsert;
+                cabinet.setCabinetId(cabinetId);
+            }
+        }
+        mapper.updateDetectedCabinet(cabinet);
+        return cabinetId;
+    }
+
+    private TDetectionChannel toChannel(Long adapterId, Long cabinetId, DetectedChannel detected, Instant observedAt)
+    {
+        TDetectionChannel channel = new TDetectionChannel();
+        channel.setAdapterId(adapterId);
+        channel.setCabinetId(cabinetId);
+        channel.setDevtype(detected.identity().devtype());
+        channel.setDevid(detected.identity().devid());
+        channel.setSubdevid(detected.identity().subdevid());
+        channel.setChlid(detected.identity().chlid());
+        channel.setDisplayNo(detected.identity().subdevid() + "-" + detected.identity().chlid());
+        channel.setOnlineStatus(detected.available() ? "ONLINE" : "OFFLINE");
+        channel.setWorkStatus("UNKNOWN");
+        channel.setLastSeenAtUtc(observedAt);
+        channel.setStatus("0");
+        return channel;
+    }
+
+    private record CabinetKey(int devtype, int devid)
+    {
+    }
+}
diff --git a/ruoyi-system/src/main/resources/mapper/system/TDeviceAdapterMapper.xml b/ruoyi-system/src/main/resources/mapper/system/TDeviceAdapterMapper.xml
new file mode 100644
index 0000000..de165c7
--- /dev/null
+++ b/ruoyi-system/src/main/resources/mapper/system/TDeviceAdapterMapper.xml
@@ -0,0 +1,143 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.ruoyi.system.mapper.TDeviceAdapterMapper">
+    <resultMap id="AdapterResult" type="TDeviceAdapter">
+        <id property="adapterId" column="adapter_id"/>
+        <result property="adapterCode" column="adapter_code"/>
+        <result property="adapterName" column="adapter_name"/>
+        <result property="adapterType" column="adapter_type"/>
+        <result property="host" column="host"/>
+        <result property="port" column="port"/>
+        <result property="pollIntervalMs" column="poll_interval_ms"/>
+        <result property="timezoneId" column="timezone_id"/>
+        <result property="capabilities" column="capabilities"/>
+        <result property="readOnly" column="read_only"/>
+        <result property="status" column="status"/>
+        <result property="connectionStatus" column="connection_status"/>
+        <result property="lastErrorCode" column="last_error_code"/>
+        <result property="lastErrorMessage" column="last_error_message"/>
+        <result property="lastAttemptAtUtc" column="last_attempt_at_utc"/>
+        <result property="lastObservedAtUtc" column="last_observed_at_utc"/>
+        <result property="createBy" column="create_by"/>
+        <result property="createTime" column="create_time"/>
+        <result property="updateBy" column="update_by"/>
+        <result property="updateTime" column="update_time"/>
+        <result property="remark" column="remark"/>
+        <result property="delFlag" column="del_flag"/>
+    </resultMap>
+
+    <resultMap id="ChannelResult" type="TDetectionChannel">
+        <id property="channelId" column="channel_id"/>
+        <result property="adapterId" column="adapter_id"/>
+        <result property="cabinetId" column="cabinet_id"/>
+        <result property="devtype" column="devtype"/>
+        <result property="devid" column="devid"/>
+        <result property="subdevid" column="subdevid"/>
+        <result property="chlid" column="chlid"/>
+        <result property="displayNo" column="display_no"/>
+        <result property="onlineStatus" column="online_status"/>
+        <result property="workStatus" column="work_status"/>
+        <result property="lastSeenAtUtc" column="last_seen_at_utc"/>
+        <result property="status" column="status"/>
+    </resultMap>
+
+    <sql id="adapterColumns">
+        adapter_id, adapter_code, adapter_name, adapter_type, host, port, poll_interval_ms, timezone_id,
+        capabilities, (json_extract(capabilities, '$.control') = false) read_only, status,
+        connection_status, last_error_code, last_error_message, last_attempt_at_utc, last_observed_at_utc,
+        create_by, create_time, update_by, update_time, remark, del_flag
+    </sql>
+
+    <select id="selectDeviceAdapterList" parameterType="TDeviceAdapter" resultMap="AdapterResult">
+        select <include refid="adapterColumns"/> from t_device_adapter
+        <where>
+            del_flag = 0
+            <if test="adapterName != null and adapterName != ''">
+                and (adapter_name like concat('%', #{adapterName}, '%') or adapter_code like concat('%', #{adapterName}, '%'))
+            </if>
+            <if test="status != null and status != ''">and status = #{status}</if>
+            <if test="connectionStatus != null and connectionStatus != ''">and connection_status = #{connectionStatus}</if>
+        </where>
+        order by adapter_id desc
+    </select>
+
+    <select id="selectEnabledDeviceAdapters" resultMap="AdapterResult">
+        select <include refid="adapterColumns"/> from t_device_adapter where status = '0' and del_flag = 0
+    </select>
+
+    <select id="selectDeviceAdapterById" resultMap="AdapterResult">
+        select <include refid="adapterColumns"/> from t_device_adapter where adapter_id = #{adapterId} and del_flag = 0
+    </select>
+
+    <select id="selectDeviceAdapterByCode" resultMap="AdapterResult">
+        select <include refid="adapterColumns"/> from t_device_adapter where adapter_code = #{adapterCode} and del_flag = 0 limit 1
+    </select>
+
+    <insert id="insertDeviceAdapter" parameterType="TDeviceAdapter" useGeneratedKeys="true" keyProperty="adapterId">
+        insert into t_device_adapter(adapter_code, adapter_name, adapter_type, host, port, poll_interval_ms,
+            timezone_id, capabilities, status, connection_status, create_by, create_time, remark, del_flag)
+        values(#{adapterCode}, #{adapterName}, #{adapterType}, #{host}, #{port}, #{pollIntervalMs},
+            #{timezoneId}, #{capabilities}, #{status}, 'DISCONNECTED', #{createBy}, sysdate(), #{remark}, 0)
+    </insert>
+
+    <update id="updateDeviceAdapter" parameterType="TDeviceAdapter">
+        update t_device_adapter set adapter_name=#{adapterName}, adapter_type=#{adapterType}, host=#{host}, port=#{port},
+            poll_interval_ms=#{pollIntervalMs}, timezone_id=#{timezoneId}, capabilities=#{capabilities},
+            connection_status='DISCONNECTED', last_error_code=null, last_error_message=null,
+            update_by=#{updateBy}, update_time=sysdate()
+        where adapter_id=#{adapterId} and del_flag=0
+    </update>
+
+    <update id="updateDeviceAdapterStatus">
+        update t_device_adapter set status=#{status}, connection_status='DISCONNECTED',
+            last_error_code=null, last_error_message=null, update_by=#{updateBy}, update_time=sysdate()
+        where adapter_id=#{adapterId} and del_flag=0
+    </update>
+
+    <update id="updateDiscoverySuccess">
+        update t_device_adapter set connection_status='CONNECTED', last_error_code=null, last_error_message=null,
+            last_attempt_at_utc=#{observedAt}, last_observed_at_utc=#{observedAt}, update_time=sysdate()
+        where adapter_id=#{adapterId} and del_flag=0
+    </update>
+
+    <update id="updateDiscoveryFailure">
+        update t_device_adapter set connection_status='ERROR', last_error_code=#{errorCode},
+            last_error_message=left(#{errorMessage}, 500), last_attempt_at_utc=utc_timestamp(3), update_time=sysdate()
+        where adapter_id=#{adapterId} and del_flag=0
+    </update>
+
+    <select id="selectCabinetIdByIdentity" resultType="Long">
+        select cabinet_id from t_detection_cabinet
+        where adapter_id=#{adapterId} and vendor_device_type=#{devtype} and vendor_device_id=#{devid} and del_flag=0 limit 1
+    </select>
+
+    <insert id="insertDetectedCabinet" parameterType="TDetectionCabinet" useGeneratedKeys="true" keyProperty="cabinetId">
+        insert into t_detection_cabinet(adapter_id, cabinet_code, cabinet_name, capacity, channel_count,
+            vendor_device_type, vendor_device_id, ip_address, online_status, last_seen_at_utc, status,
+            create_by, create_time, del_flag)
+        values(#{adapterId}, #{cabinetCode}, #{cabinetName}, 1, #{channelCount}, #{vendorDeviceType},
+            #{vendorDeviceId}, #{ipAddress}, #{onlineStatus}, #{lastSeenAtUtc}, '0', 'system', sysdate(), 0)
+    </insert>
+
+    <update id="updateDetectedCabinet" parameterType="TDetectionCabinet">
+        update t_detection_cabinet set channel_count=greatest(channel_count, #{channelCount}), ip_address=#{ipAddress},
+            online_status=#{onlineStatus}, last_seen_at_utc=#{lastSeenAtUtc}, update_by='system', update_time=sysdate()
+        where cabinet_id=#{cabinetId} and del_flag=0
+    </update>
+
+    <insert id="upsertDetectedChannel" parameterType="TDetectionChannel">
+        insert into t_detection_channel(adapter_id, cabinet_id, devtype, devid, subdevid, chlid, display_no,
+            online_status, work_status, last_seen_at_utc, status, create_time, del_flag)
+        values(#{adapterId}, #{cabinetId}, #{devtype}, #{devid}, #{subdevid}, #{chlid}, #{displayNo},
+            #{onlineStatus}, #{workStatus}, #{lastSeenAtUtc}, '0', sysdate(), 0)
+        on duplicate key update cabinet_id=values(cabinet_id), display_no=values(display_no),
+            online_status=values(online_status), last_seen_at_utc=values(last_seen_at_utc), update_time=sysdate()
+    </insert>
+
+    <select id="selectDetectionChannelsByAdapterId" resultMap="ChannelResult">
+        select channel_id, adapter_id, cabinet_id, devtype, devid, subdevid, chlid, display_no,
+            online_status, work_status, last_seen_at_utc, status
+        from t_detection_channel where adapter_id=#{adapterId} and del_flag=0
+        order by devtype, devid, subdevid, chlid
+    </select>
+</mapper>

--
Gitblit v1.7.1