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