feat(device): implement adapter registry and discovery
| New file |
| | |
| | | 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())); |
| | | } |
| | | } |
| New file |
| | |
| | | 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) { } |
| | | } |
| | |
| | | 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; |
| | |
| | | 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; |
| | | |
| | | /** |
| | |
| | | { |
| | | 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; |
| | | } |
| | | |
| | | /** |
| | |
| | | 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; |
| | | } |
| | | |
| | | /** |
| New file |
| | |
| | | -- 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); |
| | |
| | | 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) |
| New file |
| | |
| | | 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()); |
| | | } |
| | | } |
| New file |
| | |
| | | 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()); |
| | | } |
| | | } |
| New file |
| | |
| | | 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()))); |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| New file |
| | |
| | | package com.ruoyi.device.registry; |
| | | |
| | | /** 适配器运行时注册失败。 */ |
| | | public final class AdapterRegistryException extends RuntimeException |
| | | { |
| | | private static final long serialVersionUID = 1L; |
| | | |
| | | public AdapterRegistryException(String message) |
| | | { |
| | | super(message); |
| | | } |
| | | } |
| New file |
| | |
| | | package com.ruoyi.device.registry; |
| | | |
| | | import com.ruoyi.device.api.DeviceAdapter; |
| | | |
| | | /** 按适配器类型构造厂商协议实现。 */ |
| | | public interface DeviceAdapterFactory |
| | | { |
| | | String adapterType(); |
| | | |
| | | DeviceAdapter create(AdapterInstanceConfig config); |
| | | } |
| New file |
| | |
| | | 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) |
| | | { |
| | | } |
| | | } |
| New file |
| | |
| | | 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()); |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| | | } |
| | |
| | | |
| | | <dependencies> |
| | | |
| | | <!-- 厂商无关设备适配契约 --> |
| | | <dependency> |
| | | <groupId>com.ruoyi</groupId> |
| | | <artifactId>ruoyi-device</artifactId> |
| | | </dependency> |
| | | |
| | | <!-- 通用工具--> |
| | | <dependency> |
| | | <groupId>com.ruoyi</groupId> |
| | |
| | | |
| | | </dependencies> |
| | | |
| | | </project> |
| | | </project> |
| | |
| | | |
| | | 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; |
| | |
| | | |
| | | /** 检测柜主键。 */ |
| | | private Long cabinetId; |
| | | |
| | | /** 所属设备适配器实例。 */ |
| | | private Long adapterId; |
| | | |
| | | private Integer vendorDeviceType; |
| | | private Integer vendorDeviceId; |
| | | private Instant lastSeenAtUtc; |
| | | |
| | | /** 全局唯一的设备编码。 */ |
| | | @NotBlank(message = "设备编码不能为空") |
| | |
| | | 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; |
| New file |
| | |
| | | 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; } |
| | | } |
| New file |
| | |
| | | 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; } |
| | | } |
| New file |
| | |
| | | package com.ruoyi.system.domain.vo; |
| | | |
| | | import java.time.Instant; |
| | | |
| | | /** 单次设备发现的持久化结果。 */ |
| | | public record AdapterDiscoveryResult(int cabinetCount, int channelCount, Instant observedAtUtc) |
| | | { |
| | | } |
| New file |
| | |
| | | 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"); |
| | | } |
| | | } |
| New file |
| | |
| | | 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() |
| | | { |
| | | } |
| | | } |
| New file |
| | |
| | | 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(); |
| | | } |
| New file |
| | |
| | | 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(); } |
| | | } |
| New file |
| | |
| | | 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); } |
| | | } |
| New file |
| | |
| | | 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) |
| | | { |
| | | } |
| | | } |
| New file |
| | |
| | | <?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> |