yeg
2026-08-26 e12439d1f4a94957d68cda653cb666702bc71040
feat(device): implement adapter registry and discovery
4个文件已删除
22个文件已添加
4个文件已修改
1536 ■■■■■ 已修改文件
ruoyi-admin/src/main/java/com/ruoyi/config/DeviceAdapterRuntimeConfig.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/DeviceAdapterController.java 80 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TDetectionCabinetController.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/resources/db/migration/V5__adapter_discovery_runtime.sql 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/ruoyi/config/FlywayMigrationContractTest.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/DetectionCabinetDiscoveryServiceImplTest.java 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImplTest.java 110 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/ruoyi/web/controller/device/DeviceAdapterControllerPermissionTest.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDeviceAdapterFactory.java 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/main/java/com/ruoyi/device/registry/AdapterInstanceConfig.java 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/main/java/com/ruoyi/device/registry/AdapterRegistryException.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/main/java/com/ruoyi/device/registry/DeviceAdapterFactory.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/main/java/com/ruoyi/device/registry/DeviceAdapterRegistry.java 80 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/test/java/com/ruoyi/device/registry/BtsDeviceAdapterFactoryContractTest.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/test/java/com/ruoyi/device/registry/DeviceAdapterRegistryTest.java 144 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/pom.xml 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/domain/TDetectionCabinet.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/domain/TDetectionChannel.java 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/domain/TDeviceAdapter.java 94 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/AdapterDiscoveryResult.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DiscoveredCabinetVo.java 100 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/mapper/TDeviceAdapterMapper.java 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/DeviceAdapterErrorCodes.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/IDetectionCabinetDiscoveryService.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/IDeviceAdapterService.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DetectionCabinetDiscoveryServiceImpl.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceAdapterRuntimeCoordinator.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImpl.java 199 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceDiscoveryPersistenceService.java 101 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/resources/mapper/system/TDeviceAdapterMapper.xml 143 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/config/DeviceAdapterRuntimeConfig.java
New file
@@ -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()));
    }
}
ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/DeviceAdapterController.java
New file
@@ -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) { }
}
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;
    }
    /**
ruoyi-admin/src/main/resources/db/migration/V5__adapter_discovery_runtime.sql
New file
@@ -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);
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)
ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/DetectionCabinetDiscoveryServiceImplTest.java
File was deleted
ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImplTest.java
New file
@@ -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());
    }
}
ruoyi-admin/src/test/java/com/ruoyi/web/controller/device/DeviceAdapterControllerPermissionTest.java
New file
@@ -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());
    }
}
ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDeviceAdapterFactory.java
New file
@@ -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())));
    }
}
ruoyi-device/src/main/java/com/ruoyi/device/registry/AdapterInstanceConfig.java
New file
@@ -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;
    }
}
ruoyi-device/src/main/java/com/ruoyi/device/registry/AdapterRegistryException.java
New file
@@ -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);
    }
}
ruoyi-device/src/main/java/com/ruoyi/device/registry/DeviceAdapterFactory.java
New file
@@ -0,0 +1,11 @@
package com.ruoyi.device.registry;
import com.ruoyi.device.api.DeviceAdapter;
/** 按适配器类型构造厂商协议实现。 */
public interface DeviceAdapterFactory
{
    String adapterType();
    DeviceAdapter create(AdapterInstanceConfig config);
}
ruoyi-device/src/main/java/com/ruoyi/device/registry/DeviceAdapterRegistry.java
New file
@@ -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)
    {
    }
}
ruoyi-device/src/test/java/com/ruoyi/device/registry/BtsDeviceAdapterFactoryContractTest.java
New file
@@ -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());
        }
    }
}
ruoyi-device/src/test/java/com/ruoyi/device/registry/DeviceAdapterRegistryTest.java
New file
@@ -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);
        }
    }
}
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>
</project>
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;
ruoyi-system/src/main/java/com/ruoyi/system/domain/TDetectionChannel.java
New file
@@ -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; }
}
ruoyi-system/src/main/java/com/ruoyi/system/domain/TDeviceAdapter.java
New file
@@ -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; }
}
ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/AdapterDiscoveryResult.java
New file
@@ -0,0 +1,8 @@
package com.ruoyi.system.domain.vo;
import java.time.Instant;
/** 单次设备发现的持久化结果。 */
public record AdapterDiscoveryResult(int cabinetCount, int channelCount, Instant observedAtUtc)
{
}
ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DiscoveredCabinetVo.java
File was deleted
ruoyi-system/src/main/java/com/ruoyi/system/mapper/TDeviceAdapterMapper.java
New file
@@ -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");
    }
}
ruoyi-system/src/main/java/com/ruoyi/system/service/DeviceAdapterErrorCodes.java
New file
@@ -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()
    {
    }
}
ruoyi-system/src/main/java/com/ruoyi/system/service/IDetectionCabinetDiscoveryService.java
File was deleted
ruoyi-system/src/main/java/com/ruoyi/system/service/IDeviceAdapterService.java
New file
@@ -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();
}
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DetectionCabinetDiscoveryServiceImpl.java
File was deleted
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceAdapterRuntimeCoordinator.java
New file
@@ -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(); }
}
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImpl.java
New file
@@ -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); }
}
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceDiscoveryPersistenceService.java
New file
@@ -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)
    {
    }
}
ruoyi-system/src/main/resources/mapper/system/TDeviceAdapterMapper.xml
New file
@@ -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>