From de98fe81cca90dd855e0519f065e77fce56333ef Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期三, 26 八月 2026 16:28:30 +0800
Subject: [PATCH] fix(device): address adapter discovery review

---
 ruoyi-device/src/main/java/com/ruoyi/device/registry/DeviceAdapterRegistry.java                 |   91 ++++++++++--
 ruoyi-admin/src/test/java/com/ruoyi/config/FlywayMigrationContractTest.java                     |    7 +
 ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsTransportException.java                      |    2 
 ruoyi-system/src/main/resources/mapper/system/TDeviceAdapterMapper.xml                          |    3 
 ruoyi-system/src/main/java/com/ruoyi/system/mapper/TDeviceAdapterMapper.java                    |    6 
 ruoyi-system/src/main/java/com/ruoyi/system/service/IDeviceAdapterService.java                  |   17 ++
 ruoyi-device/src/main/java/com/ruoyi/device/api/DeviceAdapterException.java                     |   17 ++
 ruoyi-device/src/test/java/com/ruoyi/device/registry/BtsDeviceAdapterFactoryContractTest.java   |    2 
 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceDiscoveryPersistenceService.java |   14 +
 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImpl.java          |   36 +++-
 ruoyi-admin/src/main/resources/db/migration/V5__adapter_discovery_runtime.sql                   |   20 ++
 ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImplTest.java       |   46 +++++-
 ruoyi-device/src/test/java/com/ruoyi/device/registry/DeviceAdapterRegistryTest.java             |  106 ++++++++++++++
 ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsProtocolException.java                       |    5 
 14 files changed, 314 insertions(+), 58 deletions(-)

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
index 904a8d3..8168259 100644
--- 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
@@ -11,3 +11,23 @@
     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);
+
+-- 适配器页面复用“检测柜”菜单;按钮权限必须进入 sys_menu,普通角色才可被授予 REST 权限。
+SET @adapter_permission_parent := (
+    SELECT menu_id FROM sys_menu
+    WHERE perms = 'system:detectionCabinet:list'
+    ORDER BY menu_id DESC LIMIT 1
+);
+SET @adapter_permission_base := (SELECT COALESCE(MAX(menu_id), 0) FROM sys_menu);
+
+INSERT INTO sys_menu
+    (menu_id, menu_name, parent_id, order_num, path, component, query, route_name,
+     is_frame, is_cache, menu_type, visible, status, perms, icon,
+     create_by, create_time, update_by, update_time, remark)
+VALUES
+    (@adapter_permission_base + 1, '适配器列表', @adapter_permission_parent, 11, '', NULL, NULL, '', 1, 0, 'F', '0', '0', 'system:deviceAdapter:list', '#', 'admin', NOW(), '', NULL, ''),
+    (@adapter_permission_base + 2, '适配器查看', @adapter_permission_parent, 12, '', NULL, NULL, '', 1, 0, 'F', '0', '0', 'system:deviceAdapter:query', '#', 'admin', NOW(), '', NULL, ''),
+    (@adapter_permission_base + 3, '适配器新增', @adapter_permission_parent, 13, '', NULL, NULL, '', 1, 0, 'F', '0', '0', 'system:deviceAdapter:add', '#', 'admin', NOW(), '', NULL, ''),
+    (@adapter_permission_base + 4, '适配器编辑', @adapter_permission_parent, 14, '', NULL, NULL, '', 1, 0, 'F', '0', '0', 'system:deviceAdapter:edit', '#', 'admin', NOW(), '', NULL, ''),
+    (@adapter_permission_base + 5, '适配器启停', @adapter_permission_parent, 15, '', NULL, NULL, '', 1, 0, 'F', '0', '0', 'system:deviceAdapter:changeStatus', '#', 'admin', NOW(), '', NULL, ''),
+    (@adapter_permission_base + 6, '适配器发现', @adapter_permission_parent, 16, '', NULL, NULL, '', 1, 0, 'F', '0', '0', 'system:deviceAdapter:discover', '#', 'admin', NOW(), '', NULL, '');
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 57294b0..4550a66 100644
--- a/ruoyi-admin/src/test/java/com/ruoyi/config/FlywayMigrationContractTest.java
+++ b/ruoyi-admin/src/test/java/com/ruoyi/config/FlywayMigrationContractTest.java
@@ -35,6 +35,13 @@
         assertTrue(v5.contains("connection_status"));
         assertTrue(v5.contains("last_observed_at_utc"));
         assertTrue(v5.contains("chk_adapter_connection_status"));
+        assertTrue(v5.contains("system:deviceAdapter:list"));
+        assertTrue(v5.contains("system:deviceAdapter:query"));
+        assertTrue(v5.contains("system:deviceAdapter:add"));
+        assertTrue(v5.contains("system:deviceAdapter:edit"));
+        assertTrue(v5.contains("system:deviceAdapter:changeStatus"));
+        assertTrue(v5.contains("system:deviceAdapter:discover"));
+        assertTrue(v5.contains("system:detectionCabinet:list"));
         assertFalse(v5.matches("(?s).*\\bDELETE\\s+FROM\\b.*"));
     }
 
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
index 4e6c437..2be39cd 100644
--- 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
@@ -4,11 +4,15 @@
 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.math.BigDecimal;
+import java.time.Instant;
 import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
@@ -16,9 +20,11 @@
 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.api.DeviceAdapterException;
 import com.ruoyi.device.model.ChannelIdentity;
 import com.ruoyi.device.model.DetectedChannel;
+import com.ruoyi.device.model.DeviceWorkStatus;
+import com.ruoyi.device.model.RealtimeSnapshot;
 import com.ruoyi.device.registry.DeviceAdapterRegistry;
 import com.ruoyi.system.domain.TDetectionCabinet;
 import com.ruoyi.system.domain.TDetectionChannel;
@@ -75,30 +81,49 @@
     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)));
+        stubRegistryExecution();
         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 discoveryPersistsRealtimeWorkStatusForAvailableChannels()
+    {
+        ChannelIdentity identity = new ChannelIdentity("lab-bts", 22, 1, 2, 3);
+        when(adapterMapper.selectDeviceAdapterById(7L)).thenReturn(adapter);
+        when(deviceAdapter.discoverChannels()).thenReturn(List.of(
+                new DetectedChannel(identity, "192.168.1.35", true)));
+        when(deviceAdapter.inquire(Set.of(identity))).thenReturn(Map.of(identity,
+                new RealtimeSnapshot(identity, DeviceWorkStatus.WORKING, 1, 2, "cc", null,
+                        BigDecimal.ONE, BigDecimal.TEN, null, null, 3L, 2L, null,
+                        Instant.parse("2026-08-26T01:02:03Z"), Map.of())));
+        when(adapterMapper.selectCabinetIdByIdentity(7L, 22, 1)).thenReturn(10L);
+        stubRegistryExecution();
+
+        service.discover(7L);
+
+        var channel = org.mockito.ArgumentCaptor.forClass(TDetectionChannel.class);
+        verify(adapterMapper).upsertDetectedChannel(channel.capture());
+        assertEquals("WORKING", channel.getValue().getWorkStatus());
     }
 
     @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))
+                .thenThrow(new DeviceAdapterException("CONNECTION_FAILED", "connection refused", null))
                 .thenReturn(List.of());
+        stubRegistryExecution();
 
         ServiceException failure = assertThrows(ServiceException.class, () -> service.discover(7L));
         assertEquals(DeviceAdapterErrorCodes.DISCOVERY_FAILED, failure.getCode());
@@ -107,4 +132,11 @@
         assertEquals(0, service.discover(7L).channelCount());
         verify(adapterMapper).updateDiscoverySuccess(eq(7L), any());
     }
+
+    @SuppressWarnings("unchecked")
+    private void stubRegistryExecution()
+    {
+        when(registry.execute(eq("lab-bts"), any())).thenAnswer(invocation ->
+                ((Function<DeviceAdapter, Object>) invocation.getArgument(1)).apply(deviceAdapter));
+    }
 }
diff --git a/ruoyi-device/src/main/java/com/ruoyi/device/api/DeviceAdapterException.java b/ruoyi-device/src/main/java/com/ruoyi/device/api/DeviceAdapterException.java
index 30060a4..9c5b090 100644
--- a/ruoyi-device/src/main/java/com/ruoyi/device/api/DeviceAdapterException.java
+++ b/ruoyi-device/src/main/java/com/ruoyi/device/api/DeviceAdapterException.java
@@ -3,14 +3,29 @@
 public class DeviceAdapterException extends RuntimeException
 {
     private static final long serialVersionUID = 1L;
+    private static final String DEFAULT_FAILURE_CODE = "ADAPTER_FAILURE";
+
+    private final String failureCode;
 
     public DeviceAdapterException(String message)
     {
-        super(message);
+        this(DEFAULT_FAILURE_CODE, message, null);
     }
 
     public DeviceAdapterException(String message, Throwable cause)
     {
+        this(DEFAULT_FAILURE_CODE, message, cause);
+    }
+
+    public DeviceAdapterException(String failureCode, String message, Throwable cause)
+    {
         super(message, cause);
+        this.failureCode = failureCode;
+    }
+
+    /** 返回供业务层持久化和恢复判断使用的稳定失败分类。 */
+    public String failureCode()
+    {
+        return failureCode;
     }
 }
diff --git a/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsProtocolException.java b/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsProtocolException.java
index e619424..171ebbd 100644
--- a/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsProtocolException.java
+++ b/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsProtocolException.java
@@ -30,13 +30,12 @@
 
     public BtsProtocolException(Reason reason, String message)
     {
-        super(message);
-        this.reason = reason;
+        this(reason, message, null);
     }
 
     public BtsProtocolException(Reason reason, String message, Throwable cause)
     {
-        super(message, cause);
+        super(reason.name(), message, cause);
         this.reason = reason;
     }
 
diff --git a/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsTransportException.java b/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsTransportException.java
index f6fe0f8..0f63a59 100644
--- a/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsTransportException.java
+++ b/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsTransportException.java
@@ -23,7 +23,7 @@
 
     public BtsTransportException(Reason reason, String message, Throwable cause)
     {
-        super(message, cause);
+        super(reason.name(), message, cause);
         this.reason = reason;
     }
 
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
index b4d8895..bda7ccf 100644
--- a/ruoyi-device/src/main/java/com/ruoyi/device/registry/DeviceAdapterRegistry.java
+++ b/ruoyi-device/src/main/java/com/ruoyi/device/registry/DeviceAdapterRegistry.java
@@ -5,6 +5,8 @@
 import java.util.Locale;
 import java.util.Map;
 import java.util.Objects;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.function.Function;
 import com.ruoyi.device.api.DeviceAdapter;
 
 /** 管理启用设备适配器的运行时实例及其会话生命周期。 */
@@ -23,7 +25,7 @@
     }
 
     /** 按持久化配置注册、替换或移除运行时实例。 */
-    public synchronized void synchronize(AdapterInstanceConfig config)
+    public void synchronize(AdapterInstanceConfig config)
     {
         Objects.requireNonNull(config, "config");
         if (!config.enabled())
@@ -32,23 +34,42 @@
             return;
         }
 
-        RegisteredAdapter current = adapters.get(config.adapterCode());
-        if (current != null && current.config().equals(config)) return;
+        RegisteredAdapter previous;
+        synchronized (this)
+        {
+            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());
+            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();
+            DeviceAdapter replacement = factory.create(config);
+            previous = adapters.put(config.adapterCode(), new RegisteredAdapter(config, replacement));
+        }
+        closeWhenIdle(previous);
     }
 
-    /** 返回已启用适配器;未注册时给出可识别失败。 */
-    public synchronized DeviceAdapter require(String adapterCode)
+    /** 在实例级串行边界内执行一次适配器操作;未注册时给出可识别失败。 */
+    public <T> T execute(String adapterCode, Function<DeviceAdapter, T> operation)
     {
-        RegisteredAdapter registered = adapters.get(adapterCode);
-        if (registered == null) throw new AdapterRegistryException("Device adapter is not enabled: " + adapterCode);
-        return registered.adapter();
+        Objects.requireNonNull(operation, "operation");
+        RegisteredAdapter registered;
+        synchronized (this)
+        {
+            registered = adapters.get(adapterCode);
+            if (registered == null)
+                throw new AdapterRegistryException("Device adapter is not enabled: " + adapterCode);
+            registered.operationLock.lock();
+        }
+        try
+        {
+            return operation.apply(registered.adapter);
+        }
+        finally
+        {
+            registered.operationLock.unlock();
+        }
     }
 
     public synchronized boolean contains(String adapterCode)
@@ -57,16 +78,39 @@
     }
 
     @Override
-    public synchronized void close()
+    public void close()
     {
-        adapters.values().forEach(registered -> registered.adapter().close());
-        adapters.clear();
+        List<RegisteredAdapter> registered;
+        synchronized (this)
+        {
+            registered = List.copyOf(adapters.values());
+            adapters.clear();
+        }
+        registered.forEach(this::closeWhenIdle);
     }
 
     private void remove(String adapterCode)
     {
-        RegisteredAdapter previous = adapters.remove(adapterCode);
-        if (previous != null) previous.adapter().close();
+        RegisteredAdapter previous;
+        synchronized (this)
+        {
+            previous = adapters.remove(adapterCode);
+        }
+        closeWhenIdle(previous);
+    }
+
+    private void closeWhenIdle(RegisteredAdapter registered)
+    {
+        if (registered == null) return;
+        registered.operationLock.lock();
+        try
+        {
+            registered.adapter.close();
+        }
+        finally
+        {
+            registered.operationLock.unlock();
+        }
     }
 
     private static String normalizeType(String value)
@@ -74,7 +118,16 @@
         return Objects.requireNonNull(value, "adapterType").trim().toUpperCase(Locale.ROOT);
     }
 
-    private record RegisteredAdapter(AdapterInstanceConfig config, DeviceAdapter adapter)
+    private static final class RegisteredAdapter
     {
+        private final AdapterInstanceConfig config;
+        private final DeviceAdapter adapter;
+        private final ReentrantLock operationLock = new ReentrantLock(true);
+
+        private RegisteredAdapter(AdapterInstanceConfig config, DeviceAdapter adapter)
+        {
+            this.config = config;
+            this.adapter = 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
index 43817b2..322d22f 100644
--- a/ruoyi-device/src/test/java/com/ruoyi/device/registry/BtsDeviceAdapterFactoryContractTest.java
+++ b/ruoyi-device/src/test/java/com/ruoyi/device/registry/BtsDeviceAdapterFactoryContractTest.java
@@ -22,7 +22,7 @@
             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 adapter = registry.execute("lab-bts", value -> value);
             var discovered = adapter.discoverChannels();
 
             assertEquals("22-1-2-3", discovered.get(0).identity().vendorKey());
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
index b062175..83c002c 100644
--- a/ruoyi-device/src/test/java/com/ruoyi/device/registry/DeviceAdapterRegistryTest.java
+++ b/ruoyi-device/src/test/java/com/ruoyi/device/registry/DeviceAdapterRegistryTest.java
@@ -7,6 +7,11 @@
 import static org.junit.jupiter.api.Assertions.assertNotSame;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
 import org.junit.jupiter.api.Test;
 import com.ruoyi.device.api.DeviceAdapter;
@@ -25,10 +30,10 @@
             AdapterInstanceConfig config = config("127.0.0.1", true);
 
             registry.synchronize(config);
-            DeviceAdapter first = registry.require("lab-bts");
+            DeviceAdapter first = registry.execute("lab-bts", adapter -> adapter);
             registry.synchronize(config);
 
-            assertSame(first, registry.require("lab-bts"));
+            assertSame(first, registry.execute("lab-bts", adapter -> adapter));
             assertFalse(factory.adapters.get(0).closed.get());
         }
     }
@@ -45,13 +50,106 @@
             registry.synchronize(config("127.0.0.2", true));
 
             assertTrue(first.closed.get());
-            assertNotSame(first, registry.require("lab-bts"));
+            assertNotSame(first, registry.execute("lab-bts", adapter -> adapter));
 
             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"));
+            assertThrows(AdapterRegistryException.class,
+                    () -> registry.execute("lab-bts", adapter -> adapter));
+        }
+    }
+
+    @Test
+    void serializesOperationsForTheSameAdapterInstance() throws Exception
+    {
+        RecordingFactory factory = new RecordingFactory();
+        try (DeviceAdapterRegistry registry = new DeviceAdapterRegistry(List.of(factory)))
+        {
+            registry.synchronize(config("127.0.0.1", true));
+            CountDownLatch firstEntered = new CountDownLatch(1);
+            CountDownLatch releaseFirst = new CountDownLatch(1);
+            CountDownLatch secondEntered = new CountDownLatch(1);
+            ExecutorService executor = Executors.newFixedThreadPool(2);
+            try
+            {
+                Future<?> first = executor.submit(() -> registry.execute("lab-bts", adapter -> {
+                    firstEntered.countDown();
+                    await(releaseFirst);
+                    return null;
+                }));
+                assertTrue(firstEntered.await(1, TimeUnit.SECONDS));
+
+                Future<?> second = executor.submit(() -> registry.execute("lab-bts", adapter -> {
+                    secondEntered.countDown();
+                    return null;
+                }));
+
+                assertFalse(secondEntered.await(100, TimeUnit.MILLISECONDS));
+                releaseFirst.countDown();
+                first.get(1, TimeUnit.SECONDS);
+                second.get(1, TimeUnit.SECONDS);
+                assertTrue(secondEntered.await(1, TimeUnit.SECONDS));
+            }
+            finally
+            {
+                executor.shutdownNow();
+            }
+        }
+    }
+
+    @Test
+    void configurationReplacementWaitsForRunningOperationBeforeClosingAdapter() throws Exception
+    {
+        RecordingFactory factory = new RecordingFactory();
+        try (DeviceAdapterRegistry registry = new DeviceAdapterRegistry(List.of(factory)))
+        {
+            registry.synchronize(config("127.0.0.1", true));
+            StubAdapter firstAdapter = factory.adapters.get(0);
+            CountDownLatch operationEntered = new CountDownLatch(1);
+            CountDownLatch releaseOperation = new CountDownLatch(1);
+            CountDownLatch replacementStarted = new CountDownLatch(1);
+            ExecutorService executor = Executors.newFixedThreadPool(2);
+            try
+            {
+                Future<?> operation = executor.submit(() -> registry.execute("lab-bts", adapter -> {
+                    operationEntered.countDown();
+                    await(releaseOperation);
+                    return null;
+                }));
+                assertTrue(operationEntered.await(1, TimeUnit.SECONDS));
+
+                Future<?> replacement = executor.submit(() -> {
+                    replacementStarted.countDown();
+                    registry.synchronize(config("127.0.0.2", true));
+                });
+                assertTrue(replacementStarted.await(1, TimeUnit.SECONDS));
+                assertFalse(replacement.isDone());
+                assertFalse(firstAdapter.closed.get());
+
+                releaseOperation.countDown();
+                operation.get(1, TimeUnit.SECONDS);
+                replacement.get(1, TimeUnit.SECONDS);
+                assertTrue(firstAdapter.closed.get());
+            }
+            finally
+            {
+                executor.shutdownNow();
+            }
+        }
+    }
+
+    private static void await(CountDownLatch latch)
+    {
+        try
+        {
+            if (!latch.await(1, TimeUnit.SECONDS)) throw new AssertionError("等待适配器测试信号超时");
+        }
+        catch (InterruptedException exception)
+        {
+            Thread.currentThread().interrupt();
+            throw new AssertionError("等待适配器测试信号被中断", exception);
         }
     }
 
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
index e9f3825..cdc4da5 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TDeviceAdapterMapper.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TDeviceAdapterMapper.java
@@ -26,10 +26,4 @@
     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/IDeviceAdapterService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/IDeviceAdapterService.java
index 2abceec..abca2da 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/service/IDeviceAdapterService.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/IDeviceAdapterService.java
@@ -7,13 +7,30 @@
 
 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/DeviceAdapterServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImpl.java
index 5b06844..6d26688 100644
--- 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
@@ -3,16 +3,21 @@
 import java.time.Duration;
 import java.time.Instant;
 import java.time.ZoneId;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
 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.model.ChannelIdentity;
+import com.ruoyi.device.model.DetectedChannel;
+import com.ruoyi.device.model.RealtimeSnapshot;
 import com.ruoyi.device.registry.AdapterInstanceConfig;
 import com.ruoyi.device.registry.AdapterRegistryException;
 import com.ruoyi.device.registry.DeviceAdapterRegistry;
@@ -110,21 +115,25 @@
         try
         {
             registry.synchronize(toRuntimeConfig(adapter));
-            List<com.ruoyi.device.model.DetectedChannel> channels = registry.require(adapter.getAdapterCode()).discoverChannels();
+            DiscoveryObservation observation = registry.execute(adapter.getAdapterCode(), runtime -> {
+                List<DetectedChannel> channels = runtime.discoverChannels();
+                Set<ChannelIdentity> available = channels.stream()
+                        .filter(DetectedChannel::available)
+                        .map(DetectedChannel::identity)
+                        .collect(Collectors.toCollection(LinkedHashSet::new));
+                return new DiscoveryObservation(channels, runtime.inquire(available));
+            });
             Instant observedAt = Instant.now();
-            AdapterDiscoveryResult result = persistence.persist(adapter, channels, observedAt);
+            AdapterDiscoveryResult result = persistence.persist(adapter, observation.channels(),
+                    observation.snapshots(), observedAt);
             mapper.updateDiscoverySuccess(adapterId, observedAt);
             return result;
         }
-        catch (BtsTransportException exception)
+        catch (DeviceAdapterException exception)
         {
-            return failDiscovery(adapterId, exception.reason().name(), exception);
+            return failDiscovery(adapterId, exception.failureCode(), exception);
         }
-        catch (BtsProtocolException exception)
-        {
-            return failDiscovery(adapterId, exception.reason().name(), exception);
-        }
-        catch (DeviceAdapterException | AdapterRegistryException exception)
+        catch (AdapterRegistryException exception)
         {
             return failDiscovery(adapterId, "ADAPTER_FAILURE", exception);
         }
@@ -196,4 +205,9 @@
     }
 
     private ServiceException error(String message, int code) { return new ServiceException(message, code); }
+
+    private record DiscoveryObservation(List<DetectedChannel> channels,
+            Map<ChannelIdentity, RealtimeSnapshot> snapshots)
+    {
+    }
 }
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
index 2989aaa..55f051b 100644
--- 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
@@ -9,6 +9,8 @@
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import com.ruoyi.device.model.DetectedChannel;
+import com.ruoyi.device.model.ChannelIdentity;
+import com.ruoyi.device.model.RealtimeSnapshot;
 import com.ruoyi.system.domain.TDetectionCabinet;
 import com.ruoyi.system.domain.TDetectionChannel;
 import com.ruoyi.system.domain.TDeviceAdapter;
@@ -27,7 +29,8 @@
 
     /** 在一个事务内 upsert 本轮观测事实;未返回通道保持原记录不变。 */
     @Transactional(rollbackFor = Exception.class)
-    public AdapterDiscoveryResult persist(TDeviceAdapter adapter, List<DetectedChannel> detected, Instant observedAt)
+    public AdapterDiscoveryResult persist(TDeviceAdapter adapter, List<DetectedChannel> detected,
+            Map<ChannelIdentity, RealtimeSnapshot> snapshots, Instant observedAt)
     {
         Map<CabinetKey, List<DetectedChannel>> cabinets = detected.stream().collect(
                 java.util.stream.Collectors.groupingBy(
@@ -38,7 +41,8 @@
         {
             Long cabinetId = ensureCabinet(adapter, entry.getKey(), entry.getValue(), observedAt);
             for (DetectedChannel item : entry.getValue())
-                mapper.upsertDetectedChannel(toChannel(adapter.getAdapterId(), cabinetId, item, observedAt));
+                mapper.upsertDetectedChannel(toChannel(adapter.getAdapterId(), cabinetId, item,
+                        snapshots.get(item.identity()), observedAt));
         }
         return new AdapterDiscoveryResult(cabinets.size(), detected.size(), observedAt);
     }
@@ -78,7 +82,8 @@
         return cabinetId;
     }
 
-    private TDetectionChannel toChannel(Long adapterId, Long cabinetId, DetectedChannel detected, Instant observedAt)
+    private TDetectionChannel toChannel(Long adapterId, Long cabinetId, DetectedChannel detected,
+            RealtimeSnapshot snapshot, Instant observedAt)
     {
         TDetectionChannel channel = new TDetectionChannel();
         channel.setAdapterId(adapterId);
@@ -89,7 +94,8 @@
         channel.setChlid(detected.identity().chlid());
         channel.setDisplayNo(detected.identity().subdevid() + "-" + detected.identity().chlid());
         channel.setOnlineStatus(detected.available() ? "ONLINE" : "OFFLINE");
-        channel.setWorkStatus("UNKNOWN");
+        channel.setWorkStatus(snapshot == null || snapshot.workStatus() == null
+                ? "UNKNOWN" : snapshot.workStatus().name());
         channel.setLastSeenAtUtc(observedAt);
         channel.setStatus("0");
         return channel;
diff --git a/ruoyi-system/src/main/resources/mapper/system/TDeviceAdapterMapper.xml b/ruoyi-system/src/main/resources/mapper/system/TDeviceAdapterMapper.xml
index de165c7..eea0964 100644
--- a/ruoyi-system/src/main/resources/mapper/system/TDeviceAdapterMapper.xml
+++ b/ruoyi-system/src/main/resources/mapper/system/TDeviceAdapterMapper.xml
@@ -131,7 +131,8 @@
         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()
+            online_status=values(online_status), work_status=values(work_status),
+            last_seen_at_utc=values(last_seen_at_utc), update_time=sysdate()
     </insert>
 
     <select id="selectDetectionChannelsByAdapterId" resultMap="ChannelResult">

--
Gitblit v1.7.1