yeg
2026-08-26 de98fe81cca90dd855e0519f065e77fce56333ef
fix(device): address adapter discovery review
14个文件已修改
372 ■■■■■ 已修改文件
ruoyi-admin/src/main/resources/db/migration/V5__adapter_discovery_runtime.sql 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/ruoyi/config/FlywayMigrationContractTest.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImplTest.java 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/main/java/com/ruoyi/device/api/DeviceAdapterException.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsProtocolException.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsTransportException.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/main/java/com/ruoyi/device/registry/DeviceAdapterRegistry.java 91 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/test/java/com/ruoyi/device/registry/BtsDeviceAdapterFactoryContractTest.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/test/java/com/ruoyi/device/registry/DeviceAdapterRegistryTest.java 106 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/mapper/TDeviceAdapterMapper.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/IDeviceAdapterService.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImpl.java 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceDiscoveryPersistenceService.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/resources/mapper/system/TDeviceAdapterMapper.xml 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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, '');
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.*"));
    }
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));
    }
}
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;
    }
}
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;
    }
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;
    }
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;
        }
    }
}
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());
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);
        }
    }
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");
    }
}
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();
}
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)
    {
    }
}
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;
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">