From a4f06a9095a2eab0bb48b406c4609be6e70b8854 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期四, 27 八月 2026 09:50:26 +0800
Subject: [PATCH] 设备管理-检测柜初版

---
 ruoyi-admin/src/test/java/com/ruoyi/config/FlywayMigrationContractTest.java                            |   16 
 .gitignore                                                                                             |    5 
 ruoyi-system/src/main/java/com/ruoyi/system/domain/TDetectionCabinet.java                              |   11 
 ruoyi-admin/src/test/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorControllerTest.java   |   94 +++
 ruoyi-system/src/main/resources/mapper/system/TDeviceAdapterMapper.xml                                 |   27 
 ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDeviceAdapterFactory.java                           |    2 
 ruoyi-system/src/main/java/com/ruoyi/system/mapper/TDeviceAdapterMapper.java                           |    1 
 ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml                                          |    2 
 ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsXmlCodec.java                                       |    6 
 ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/TDetectionCabinetControllerContractTest.java |   21 
 ruoyi-device/src/test/java/com/ruoyi/device/registry/BtsDeviceAdapterFactoryContractTest.java          |    8 
 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceDiscoveryPersistenceService.java        |    9 
 ruoyi-device/src/main/java/com/ruoyi/device/model/DetectedChannel.java                                 |    7 
 ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorController.java       |  117 ++++
 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TDetectionCabinetServiceImpl.java             |   62 +
 ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDevelopmentSimulator.java                           |  314 ++++++++++
 ruoyi-device/src/main/java/com/ruoyi/device/bts/ScriptedBtsTcpSimulator.java                           |   33 
 ruoyi-system/src/main/java/com/ruoyi/system/service/ITDetectionCabinetService.java                     |   10 
 ruoyi-admin/src/test/java/com/ruoyi/config/DeviceAdapterRuntimeConfigTest.java                         |   29 +
 ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DiscoveredCabinetVo.java                         |   26 
 ruoyi-system/src/main/java/com/ruoyi/system/service/IDeviceAdapterService.java                         |    3 
 ruoyi-admin/src/main/resources/application.yml                                                         |    3 
 ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDeviceAdapter.java                                  |   12 
 ruoyi-system/src/main/java/com/ruoyi/system/mapper/TDetectionCabinetMapper.java                        |   13 
 ruoyi-admin/src/main/java/com/ruoyi/config/DeviceAdapterRuntimeConfig.java                             |   35 +
 ruoyi-admin/src/main/resources/static/dev/bts-simulator.html                                           |  278 +++++++++
 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImpl.java                 |   55 +
 ruoyi-admin/src/main/java/com/ruoyi/web/config/BtsDevelopmentSimulatorWebConfig.java                   |   45 +
 ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TDetectionCabinetController.java             |   30 +
 ruoyi-admin/src/main/resources/application-druid.yml                                                   |    2 
 ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImplTest.java              |  107 +++
 ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TDetectionCabinetServiceImplTest.java          |  144 ++++
 ruoyi-device/src/main/java/com/ruoyi/device/model/DetectedDeviceMetadata.java                          |   10 
 ruoyi-system/src/main/resources/mapper/system/TDetectionCabinetMapper.xml                              |   87 ++
 ruoyi-device/src/test/java/com/ruoyi/device/contract/SimulatorPublicContractTest.java                  |  101 +++
 35 files changed, 1,642 insertions(+), 83 deletions(-)

diff --git a/.gitignore b/.gitignore
index a976e71..5bf40c2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,4 +8,9 @@
 /target/
 /ruoyi-device/target/
 /logs/
+/ruoyi-admin/src/main/resources/db/migration/*
+!/ruoyi-admin/src/main/resources/db/migration/V6__separate_discovery_from_registration.sql
+!/ruoyi-admin/src/main/resources/db/migration/V7__complete_device_registration_boundary.sql
+/.mvn-review-repository/
 /ruoyi-admin/src/main/resources/db/migration/
+/ruoyi-admin/logs/
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/config/DeviceAdapterRuntimeConfig.java b/ruoyi-admin/src/main/java/com/ruoyi/config/DeviceAdapterRuntimeConfig.java
index dcf3cf0..a86366d 100644
--- a/ruoyi-admin/src/main/java/com/ruoyi/config/DeviceAdapterRuntimeConfig.java
+++ b/ruoyi-admin/src/main/java/com/ruoyi/config/DeviceAdapterRuntimeConfig.java
@@ -1,11 +1,16 @@
 package com.ruoyi.config;
 
+import java.io.IOException;
 import java.util.List;
+import org.springframework.boot.ApplicationRunner;
 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.bts.BtsDevelopmentSimulator;
 import com.ruoyi.device.registry.DeviceAdapterRegistry;
+import com.ruoyi.system.domain.TDeviceAdapter;
+import com.ruoyi.system.service.IDeviceAdapterService;
 
 /** 装配单进程设备适配器运行时。 */
 @Configuration
@@ -17,4 +22,34 @@
     {
         return new DeviceAdapterRegistry(List.of(new BtsDeviceAdapterFactory()));
     }
+
+    @Bean(destroyMethod = "close")
+    public BtsDevelopmentSimulator btsDevelopmentSimulator() throws IOException
+    {
+        return new BtsDevelopmentSimulator(BtsDevelopmentSimulator.DEFAULT_PORT);
+    }
+
+    @Bean
+    public ApplicationRunner registerDevelopmentSimulatorAdapter(IDeviceAdapterService service)
+    {
+        // ApplicationRunner 先于 ApplicationReadyEvent,确保恢复启用适配器时 dev-sim 已存在。
+        return arguments -> service.ensureManagedAdapter(developmentSimulatorAdapter());
+    }
+
+    private TDeviceAdapter developmentSimulatorAdapter()
+    {
+        TDeviceAdapter adapter = new TDeviceAdapter();
+        adapter.setAdapterCode("dev-sim");
+        adapter.setAdapterName("dev-sim BTS 模拟器");
+        adapter.setAdapterType("BTS");
+        adapter.setHost(BtsDevelopmentSimulator.HOST);
+        adapter.setPort(BtsDevelopmentSimulator.DEFAULT_PORT);
+        adapter.setPollIntervalMs(5000);
+        adapter.setTimezoneId("Asia/Hong_Kong");
+        adapter.setReadOnly(true);
+        adapter.setCapabilities("{\"discovery\":true,\"realtimeObservation\":true,"
+                + "\"historyDownload\":false,\"control\":false}");
+        adapter.setRemark("临时人工验收设备");
+        return adapter;
+    }
 }
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/config/BtsDevelopmentSimulatorWebConfig.java b/ruoyi-admin/src/main/java/com/ruoyi/web/config/BtsDevelopmentSimulatorWebConfig.java
new file mode 100644
index 0000000..5b37f9c
--- /dev/null
+++ b/ruoyi-admin/src/main/java/com/ruoyi/web/config/BtsDevelopmentSimulatorWebConfig.java
@@ -0,0 +1,45 @@
+package com.ruoyi.web.config;
+
+import java.net.InetAddress;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.HandlerInterceptor;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+/** 将临时 BTS 模拟器页面和控制 API 限制在本机。 */
+@Configuration
+public class BtsDevelopmentSimulatorWebConfig implements WebMvcConfigurer
+{
+    @Override
+    public void addInterceptors(InterceptorRegistry registry)
+    {
+        registry.addInterceptor(new LocalOnlyInterceptor())
+                .addPathPatterns("/dev/bts-simulator.html", "/dev/bts-simulator/**");
+    }
+
+    public static final class LocalOnlyInterceptor implements HandlerInterceptor
+    {
+        @Override
+        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
+                throws Exception
+        {
+            if (isLoopback(request.getRemoteAddr())) return true;
+            response.sendError(HttpServletResponse.SC_FORBIDDEN, "BTS simulator controls are local-only");
+            return false;
+        }
+
+        private boolean isLoopback(String remoteAddress)
+        {
+            try
+            {
+                return remoteAddress != null && InetAddress.getByName(remoteAddress).isLoopbackAddress();
+            }
+            catch (Exception ignored)
+            {
+                return false;
+            }
+        }
+    }
+}
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorController.java
new file mode 100644
index 0000000..0842a00
--- /dev/null
+++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorController.java
@@ -0,0 +1,117 @@
+package com.ruoyi.web.controller.device;
+
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.core.io.Resource;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.DeleteMapping;
+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.RestController;
+import org.springframework.web.server.ResponseStatusException;
+import com.ruoyi.common.annotation.Anonymous;
+import com.ruoyi.device.bts.BtsDevelopmentSimulator;
+import com.ruoyi.device.bts.BtsDevelopmentSimulator.StateSnapshot;
+import com.ruoyi.device.model.DeviceWorkStatus;
+
+/** 本机临时 BTS 模拟器控制 API。 */
+@Anonymous
+@RestController
+public class BtsDevelopmentSimulatorController
+{
+    private final BtsDevelopmentSimulator simulator;
+
+    public BtsDevelopmentSimulatorController(BtsDevelopmentSimulator simulator)
+    {
+        this.simulator = simulator;
+    }
+
+    /** 返回临时控制页;匿名放行后仍由本机拦截器限制来源。 */
+    @GetMapping(value = "/dev/bts-simulator.html", produces = MediaType.TEXT_HTML_VALUE)
+    public Resource page()
+    {
+        return new ClassPathResource("static/dev/bts-simulator.html");
+    }
+
+    /** 返回模拟器、虚拟设备、通道和最近请求的当前快照。 */
+    @GetMapping("/dev/bts-simulator/state")
+    public StateSnapshot state()
+    {
+        return simulator.snapshot();
+    }
+
+    /** 添加一台虚拟设备。 */
+    @PostMapping("/dev/bts-simulator/devices")
+    public StateSnapshot createDevice(@RequestBody CreateDeviceCommand command)
+    {
+        try
+        {
+            simulator.addDevice(command.ip(), command.channelCount());
+            return simulator.snapshot();
+        }
+        catch (IllegalArgumentException exception)
+        {
+            throw badRequest(exception);
+        }
+    }
+
+    /** 修改指定虚拟设备的地址、通道数和在线状态。 */
+    @PutMapping("/dev/bts-simulator/devices/{deviceId}")
+    public StateSnapshot updateDevice(@PathVariable int deviceId, @RequestBody DeviceStateCommand command)
+    {
+        try
+        {
+            simulator.updateDevice(deviceId, command.ip(), command.channelCount(), command.online());
+            return simulator.snapshot();
+        }
+        catch (IllegalArgumentException exception)
+        {
+            throw badRequest(exception);
+        }
+    }
+
+    /** 删除指定虚拟设备。 */
+    @DeleteMapping("/dev/bts-simulator/devices/{deviceId}")
+    public StateSnapshot deleteDevice(@PathVariable int deviceId)
+    {
+        try
+        {
+            simulator.deleteDevice(deviceId);
+            return simulator.snapshot();
+        }
+        catch (IllegalArgumentException exception)
+        {
+            throw badRequest(exception);
+        }
+    }
+
+    /** 修改指定虚拟设备通道的在线和工作状态。 */
+    @PutMapping("/dev/bts-simulator/devices/{deviceId}/channels/{channelId}")
+    public StateSnapshot updateChannel(@PathVariable int deviceId, @PathVariable int channelId,
+            @RequestBody ChannelStateCommand command)
+    {
+        try
+        {
+            simulator.updateChannel(deviceId, channelId, command.online(), command.workStatus());
+            return simulator.snapshot();
+        }
+        catch (IllegalArgumentException exception)
+        {
+            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, exception.getMessage(), exception);
+        }
+    }
+
+    private ResponseStatusException badRequest(IllegalArgumentException exception)
+    {
+        return new ResponseStatusException(HttpStatus.BAD_REQUEST, exception.getMessage(), exception);
+    }
+
+    public record CreateDeviceCommand(String ip, int channelCount) { }
+
+    public record DeviceStateCommand(String ip, int channelCount, boolean online) { }
+
+    public record ChannelStateCommand(boolean online, DeviceWorkStatus workStatus) { }
+}
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TDetectionCabinetController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TDetectionCabinetController.java
index 2786419..019182d 100644
--- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TDetectionCabinetController.java
+++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TDetectionCabinetController.java
@@ -1,5 +1,6 @@
 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;
@@ -12,11 +13,15 @@
 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.TableDataInfo;
+import com.ruoyi.common.core.page.PageDomain;
+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.ITDetectionCabinetService;
 
 /**
@@ -56,6 +61,31 @@
     }
 
     /**
+     * 保留原设备选择弹窗使用的分页接口,数据来自已持久化检测柜。
+     *
+     * @return 可供选择的检测柜分页数据
+     */
+    @PreAuthorize("@ss.hasPermi('system:detectionCabinet:list')")
+    @GetMapping("/discovered/list")
+    public TableDataInfo discoveredList()
+    {
+        List<DiscoveredCabinetVo> devices = detectionCabinetService.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();
+        long offset = (long) (pageNum - 1) * pageSize;
+        int fromIndex = (int) Math.min(offset, devices.size());
+        int toIndex = (int) Math.min((long) 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;
+    }
+
+    /**
      * 查询检测柜详情。
      *
      * @param cabinetId 检测柜主键
diff --git a/ruoyi-admin/src/main/resources/application-druid.yml b/ruoyi-admin/src/main/resources/application-druid.yml
index e5038ab..a5a557a 100644
--- a/ruoyi-admin/src/main/resources/application-druid.yml
+++ b/ruoyi-admin/src/main/resources/application-druid.yml
@@ -6,7 +6,7 @@
         druid:
             # 主库数据源
             master:
-                url: jdbc:mysql://127.0.0.1:3307/battery_detection?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
+                url: jdbc:mysql://127.0.0.1:6134/battery_detection?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
                 username: root
                 password: 123456
             # 从库数据源
diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml
index bef0232..181ad7f 100644
--- a/ruoyi-admin/src/main/resources/application.yml
+++ b/ruoyi-admin/src/main/resources/application.yml
@@ -34,7 +34,8 @@
 # 日志配置
 logging:
   level:
-    com.ruoyi: debug
+    # 调度器每秒检查到期适配器;控制台只保留业务 INFO 及以上,避免 MyBatis 调试 SQL 刷屏。
+    com.ruoyi: info
     org.springframework: warn
 
 # 用户配置
diff --git a/ruoyi-admin/src/main/resources/static/dev/bts-simulator.html b/ruoyi-admin/src/main/resources/static/dev/bts-simulator.html
new file mode 100644
index 0000000..0ff4148
--- /dev/null
+++ b/ruoyi-admin/src/main/resources/static/dev/bts-simulator.html
@@ -0,0 +1,278 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>BTS 开发模拟器</title>
+  <style>
+    :root { color-scheme: light; font-family: "Microsoft YaHei", Arial, sans-serif; color: #26323d; background: #f3f6f8; }
+    * { box-sizing: border-box; }
+    body { margin: 0; min-width: 320px; }
+    header { display: flex; align-items: center; justify-content: space-between; gap: 20px; min-height: 62px; padding: 0 28px; color: #fff; background: #213547; border-bottom: 3px solid #26a269; }
+    h1 { margin: 0; font-size: 18px; font-weight: 600; letter-spacing: 0; }
+    header span { font: 12px Consolas, monospace; color: #c9d5de; }
+    main { width: min(1120px, calc(100% - 32px)); margin: 22px auto 40px; }
+    section { margin-bottom: 20px; background: #fff; border: 1px solid #d5dde3; }
+    .section-title, .device-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 44px; padding: 0 16px; background: #edf2f5; border-bottom: 1px solid #d5dde3; }
+    h2, h3 { margin: 0; font-size: 14px; font-weight: 600; letter-spacing: 0; }
+    .summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); }
+    .summary > div { min-height: 76px; padding: 15px 16px; border-right: 1px solid #e2e8ec; }
+    .summary > div:last-child { border-right: 0; }
+    .label { display: block; margin-bottom: 7px; color: #6b7780; font-size: 12px; }
+    .value { font-size: 14px; font-weight: 600; }
+    .status { display: inline-flex; align-items: center; gap: 7px; }
+    .status::before { width: 8px; height: 8px; content: ""; border-radius: 50%; background: #c43d4b; }
+    .status.online::before { background: #218a5b; }
+    .add-form, .device-settings { display: grid; grid-template-columns: minmax(180px, 1fr) 150px auto; gap: 12px; align-items: end; padding: 16px; }
+    .device-settings { grid-template-columns: minmax(180px, 1fr) 130px 140px auto auto; }
+    label { color: #5e6a73; font-size: 12px; }
+    label > span { display: block; margin-bottom: 6px; }
+    input[type="text"], input[type="number"], select { width: 100%; height: 34px; padding: 0 9px; color: #26323d; background: #fff; border: 1px solid #bdc8d0; border-radius: 3px; }
+    input[type="checkbox"] { width: 17px; height: 17px; accent-color: #218a5b; cursor: pointer; }
+    .switch-label { display: inline-flex; align-items: center; gap: 8px; height: 34px; }
+    .switch-label > span { margin: 0; }
+    button { height: 34px; padding: 0 13px; color: #fff; background: #2877a9; border: 1px solid #2877a9; border-radius: 3px; cursor: pointer; }
+    button:hover { background: #1f638e; }
+    button.danger { color: #a03946; background: #fff; border-color: #d8a6ad; }
+    button.danger:hover { background: #fff3f4; }
+    .device { margin: 16px; border: 1px solid #d5dde3; }
+    .device-title { background: #f7f9fa; }
+    table { width: 100%; border-collapse: collapse; table-layout: fixed; }
+    th, td { height: 48px; padding: 8px 14px; text-align: left; border-bottom: 1px solid #e3e9ed; font-size: 13px; }
+    th { height: 40px; color: #5e6a73; background: #f8fafb; font-size: 12px; font-weight: 600; }
+    tbody tr:last-child td { border-bottom: 0; }
+    td select { width: 150px; }
+    .request-time { width: 185px; font: 12px Consolas, monospace; color: #53616b; }
+    .request-command { width: 130px; font: 12px Consolas, monospace; font-weight: 600; }
+    .request-xml { overflow: hidden; color: #5c6871; font: 11px Consolas, monospace; text-overflow: ellipsis; white-space: nowrap; }
+    .empty { height: 96px; color: #7d8992; text-align: center; }
+    .error { display: none; margin-bottom: 16px; padding: 11px 14px; color: #8b2635; background: #fff0f2; border: 1px solid #e4a8b0; font-size: 13px; }
+    .error.visible { display: block; }
+    @media (max-width: 760px) {
+      header { align-items: flex-start; flex-direction: column; padding: 14px 16px; }
+      main { width: calc(100% - 20px); margin-top: 12px; }
+      .summary { grid-template-columns: 1fr; }
+      .summary > div { border-right: 0; border-bottom: 1px solid #e2e8ec; }
+      .add-form, .device-settings { grid-template-columns: 1fr 1fr; }
+      .device-settings button { width: 100%; }
+      .device { margin: 10px; overflow-x: auto; }
+      .device table { min-width: 520px; }
+      th, td { padding: 8px; }
+      .request-time { width: 130px; }
+      .request-command { width: 90px; }
+    }
+  </style>
+</head>
+<body>
+  <header><h1>BTS 开发模拟器</h1><span>dev-sim / 仅限本机</span></header>
+  <main>
+    <div id="error" class="error"></div>
+    <section>
+      <div class="section-title"><h2>运行状态</h2></div>
+      <div class="summary">
+        <div><span class="label">模拟器</span><span id="runtime" class="value status">未知</span></div>
+        <div><span class="label">监听地址</span><span id="address" class="value">-</span></div>
+        <div><span class="label">虚拟设备</span><span id="device-count" class="value">0 台</span></div>
+      </div>
+    </section>
+    <section>
+      <div class="section-title"><h2>添加虚拟设备</h2><span class="label">虚拟 IP 必须唯一</span></div>
+      <form id="add-device" class="add-form">
+        <label><span>虚拟 IP</span><input id="new-ip" type="text" value="127.0.0.3" required></label>
+        <label><span>通道数</span><input id="new-channel-count" type="number" min="1" max="99" value="4" required></label>
+        <button type="submit">添加设备</button>
+      </form>
+    </section>
+    <section>
+      <div class="section-title"><h2>虚拟设备与通道</h2><span class="label">约在下一轮设备发现后同步至业务页面</span></div>
+      <div id="devices"></div>
+    </section>
+    <section>
+      <div class="section-title"><h2>最近请求</h2><span id="request-count" class="label">0 条</span></div>
+      <table>
+        <thead><tr><th class="request-time">接收时间</th><th class="request-command">命令</th><th>请求 XML</th></tr></thead>
+        <tbody id="requests"></tbody>
+      </table>
+    </section>
+  </main>
+  <script>
+    const api = '/dev/bts-simulator';
+    const labels = { STOPPED: '空闲', WORKING: '工作中', PAUSED: '暂停', FINISHED: '完成' };
+    const errorBox = document.querySelector('#error');
+
+    async function request(path, options) {
+      const response = await fetch(api + path, { ...options, headers: { 'Content-Type': 'application/json' } });
+      if (!response.ok) {
+        let message = `请求失败 (${response.status})`;
+        try { message = (await response.json()).message || message; } catch (_) { }
+        throw new Error(message);
+      }
+      return response.json();
+    }
+
+    function showError(error) {
+      errorBox.textContent = error instanceof Error ? error.message : '控制页请求失败';
+      errorBox.classList.add('visible');
+    }
+
+    function render(state) {
+      errorBox.classList.remove('visible');
+      const runtime = document.querySelector('#runtime');
+      runtime.textContent = state.running ? '运行中' : '已停止';
+      runtime.classList.toggle('online', state.running);
+      document.querySelector('#address').textContent = state.listenAddress;
+      document.querySelector('#device-count').textContent = `${state.devices.length} 台`;
+      renderDevices(state.devices);
+      renderRequests(state.recentRequests);
+    }
+
+    function renderDevices(devices) {
+      const container = document.querySelector('#devices');
+      if (!devices.length) {
+        const empty = document.createElement('div');
+        empty.className = 'empty';
+        empty.textContent = '暂无虚拟设备';
+        container.replaceChildren(empty);
+        return;
+      }
+      container.replaceChildren(...devices.map(device => {
+        const panel = document.createElement('article');
+        panel.className = 'device';
+        const title = document.createElement('div');
+        title.className = 'device-title';
+        const heading = document.createElement('h3');
+        heading.textContent = `设备 ${device.deviceId}`;
+        const identity = document.createElement('span');
+        identity.className = 'label';
+        identity.textContent = `devtype 22 / devid ${device.deviceId}`;
+        title.append(heading, identity);
+
+        const settings = document.createElement('div');
+        settings.className = 'device-settings';
+        const ip = field('虚拟 IP', 'text', device.ip);
+        const channelCount = field('通道数', 'number', device.channels.length);
+        channelCount.input.min = '1';
+        channelCount.input.max = '99';
+        const onlineLabel = document.createElement('label');
+        onlineLabel.className = 'switch-label';
+        const online = document.createElement('input');
+        online.type = 'checkbox';
+        online.checked = device.online;
+        const onlineText = document.createElement('span');
+        onlineText.textContent = device.online ? '整机在线' : '整机离线';
+        online.addEventListener('change', () => { onlineText.textContent = online.checked ? '整机在线' : '整机离线'; });
+        onlineLabel.append(online, onlineText);
+        const save = document.createElement('button');
+        save.type = 'button';
+        save.textContent = '保存配置';
+        save.addEventListener('click', async () => {
+          try { render(await request(`/devices/${device.deviceId}`, { method: 'PUT', body: JSON.stringify({ ip: ip.input.value, channelCount: Number(channelCount.input.value), online: online.checked }) })); }
+          catch (error) { showError(error); }
+        });
+        const remove = document.createElement('button');
+        remove.type = 'button';
+        remove.className = 'danger';
+        remove.textContent = '删除设备';
+        remove.addEventListener('click', async () => {
+          try { render(await request(`/devices/${device.deviceId}`, { method: 'DELETE' })); }
+          catch (error) { showError(error); }
+        });
+        settings.append(ip.label, channelCount.label, onlineLabel, save, remove);
+
+        const table = document.createElement('table');
+        table.innerHTML = '<thead><tr><th style="width:25%">通道</th><th style="width:30%">在线状态</th><th>工作状态</th></tr></thead>';
+        const body = document.createElement('tbody');
+        body.append(...device.channels.map(channel => channelRow(device.deviceId, channel)));
+        table.append(body);
+        panel.append(title, settings, table);
+        return panel;
+      }));
+    }
+
+    function field(labelText, type, value) {
+      const label = document.createElement('label');
+      const text = document.createElement('span');
+      text.textContent = labelText;
+      const input = document.createElement('input');
+      input.type = type;
+      input.value = value;
+      label.append(text, input);
+      return { label, input };
+    }
+
+    function channelRow(deviceId, channel) {
+      const row = document.createElement('tr');
+      const identity = document.createElement('td');
+      identity.textContent = `通道 ${channel.channelId}`;
+      const onlineCell = document.createElement('td');
+      const onlineLabel = document.createElement('label');
+      onlineLabel.className = 'switch-label';
+      const online = document.createElement('input');
+      online.type = 'checkbox';
+      online.checked = channel.online;
+      const onlineText = document.createElement('span');
+      onlineText.textContent = channel.online ? '在线' : '离线';
+      onlineLabel.append(online, onlineText);
+      onlineCell.append(onlineLabel);
+      const statusCell = document.createElement('td');
+      const status = document.createElement('select');
+      Object.entries(labels).forEach(([value, label]) => status.add(new Option(label, value)));
+      status.value = channel.workStatus;
+      const update = async () => {
+        try { render(await request(`/devices/${deviceId}/channels/${channel.channelId}`, { method: 'PUT', body: JSON.stringify({ online: online.checked, workStatus: status.value }) })); }
+        catch (error) { showError(error); await refresh(); }
+      };
+      online.addEventListener('change', update);
+      status.addEventListener('change', update);
+      statusCell.append(status);
+      row.append(identity, onlineCell, statusCell);
+      return row;
+    }
+
+    function renderRequests(items) {
+      const requests = document.querySelector('#requests');
+      document.querySelector('#request-count').textContent = `${items.length} 条`;
+      if (!items.length) {
+        const row = document.createElement('tr');
+        const cell = document.createElement('td');
+        cell.colSpan = 3;
+        cell.className = 'empty';
+        cell.textContent = '暂无请求';
+        row.append(cell);
+        requests.replaceChildren(row);
+        return;
+      }
+      requests.replaceChildren(...items.map(item => {
+        const row = document.createElement('tr');
+        const time = document.createElement('td');
+        time.className = 'request-time';
+        time.textContent = new Date(item.receivedAt).toLocaleString('zh-CN', { hour12: false });
+        const command = document.createElement('td');
+        command.className = 'request-command';
+        command.textContent = item.command;
+        const xml = document.createElement('td');
+        xml.className = 'request-xml';
+        xml.textContent = item.request;
+        xml.title = item.request;
+        row.append(time, command, xml);
+        return row;
+      }));
+    }
+
+    async function refresh() {
+      if (document.activeElement && ['INPUT', 'SELECT'].includes(document.activeElement.tagName)) return;
+      try { render(await request('/state')); } catch (error) { showError(error); }
+    }
+
+    document.querySelector('#add-device').addEventListener('submit', async event => {
+      event.preventDefault();
+      try {
+        render(await request('/devices', { method: 'POST', body: JSON.stringify({ ip: document.querySelector('#new-ip').value, channelCount: Number(document.querySelector('#new-channel-count').value) }) }));
+      } catch (error) { showError(error); }
+    });
+
+    refresh();
+    setInterval(refresh, 2000);
+  </script>
+</body>
+</html>
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/config/DeviceAdapterRuntimeConfigTest.java b/ruoyi-admin/src/test/java/com/ruoyi/config/DeviceAdapterRuntimeConfigTest.java
new file mode 100644
index 0000000..aa72eaa
--- /dev/null
+++ b/ruoyi-admin/src/test/java/com/ruoyi/config/DeviceAdapterRuntimeConfigTest.java
@@ -0,0 +1,29 @@
+package com.ruoyi.config;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.DefaultApplicationArguments;
+import org.mockito.ArgumentCaptor;
+import com.ruoyi.system.domain.TDeviceAdapter;
+import com.ruoyi.system.service.IDeviceAdapterService;
+
+class DeviceAdapterRuntimeConfigTest
+{
+    @Test
+    void registersDevelopmentSimulatorBeforeApplicationReady() throws Exception
+    {
+        IDeviceAdapterService service = mock(IDeviceAdapterService.class);
+        var runner = new DeviceAdapterRuntimeConfig().registerDevelopmentSimulatorAdapter(service);
+
+        runner.run(new DefaultApplicationArguments(new String[0]));
+
+        ArgumentCaptor<TDeviceAdapter> adapter = ArgumentCaptor.forClass(TDeviceAdapter.class);
+        verify(service).ensureManagedAdapter(adapter.capture());
+        assertEquals("dev-sim", adapter.getValue().getAdapterCode());
+        assertEquals("127.0.0.1", adapter.getValue().getHost());
+        assertEquals(15020, adapter.getValue().getPort());
+        assertEquals(5000, adapter.getValue().getPollIntervalMs());
+    }
+}
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/config/FlywayMigrationContractTest.java b/ruoyi-admin/src/test/java/com/ruoyi/config/FlywayMigrationContractTest.java
index 4550a66..8d11c51 100644
--- a/ruoyi-admin/src/test/java/com/ruoyi/config/FlywayMigrationContractTest.java
+++ b/ruoyi-admin/src/test/java/com/ruoyi/config/FlywayMigrationContractTest.java
@@ -16,6 +16,7 @@
     private static final String V1_SHA256 = "37de76546e5aa0aa0ce7e976b73383a2d80ef97fd2db923d9e23dbc2b34534c9";
     private static final String V2_SHA256 = "698023958beee6245b099cf4505d35874cd06823e4dbbc3ddcb11b8f76609b89";
     private static final String V3_SHA256 = "763de1dfaa96e6f07f7ed3b3975e6f281dbc0c2e7f139da3bed6a2f1bfc39727";
+    private static final String V6_SHA256 = "2f20752fcb9a318435d27ba4a0c3e0ba9443ba2f61a884f82bace457270d7db8";
 
     @Test
     void keepsImmutableBaselineAndAddsObservationIncrementOnly()
@@ -43,6 +44,21 @@
         assertTrue(v5.contains("system:deviceAdapter:discover"));
         assertTrue(v5.contains("system:detectionCabinet:list"));
         assertFalse(v5.matches("(?s).*\\bDELETE\\s+FROM\\b.*"));
+
+        String v6 = resource("db/migration/V6__separate_discovery_from_registration.sql");
+        assertEquals(V6_SHA256, sha256("db/migration/V6__separate_discovery_from_registration.sql"));
+        assertTrue(v6.contains("registration_status"));
+        assertTrue(v6.contains("DISCOVERED"));
+        assertTrue(v6.contains("REGISTERED"));
+        assertTrue(v6.contains("chk_cabinet_registration_status"));
+        assertTrue(v6.contains("DEFAULT 'DISCOVERED'"));
+        assertFalse(v6.matches("(?s).*\\bDELETE\\s+FROM\\b.*"));
+
+        String v7 = resource("db/migration/V7__complete_device_registration_boundary.sql");
+        assertTrue(v7.contains("WHEN create_by = 'system' THEN 'DISCOVERED'"));
+        assertTrue(v7.contains("ELSE 'REGISTERED'"));
+        assertTrue(v7.contains("uk_cabinet_mac_del"));
+        assertFalse(v7.matches("(?s).*\\bDELETE\\s+FROM\\b.*"));
     }
 
     private static String sha256(String path)
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImplTest.java b/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImplTest.java
index 2be39cd..41ee209 100644
--- a/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImplTest.java
+++ b/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImplTest.java
@@ -1,13 +1,18 @@
 package com.ruoyi.system.service.impl;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
+import java.io.InputStream;
 import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
 import java.time.Instant;
 import java.util.List;
 import java.util.Map;
@@ -18,11 +23,13 @@
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.context.annotation.AnnotationConfigApplicationContext;
 import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.device.api.DeviceAdapter;
 import com.ruoyi.device.api.DeviceAdapterException;
 import com.ruoyi.device.model.ChannelIdentity;
 import com.ruoyi.device.model.DetectedChannel;
+import com.ruoyi.device.model.DetectedDeviceMetadata;
 import com.ruoyi.device.model.DeviceWorkStatus;
 import com.ruoyi.device.model.RealtimeSnapshot;
 import com.ruoyi.device.registry.DeviceAdapterRegistry;
@@ -50,7 +57,8 @@
     @BeforeEach
     void setUp()
     {
-        service = new DeviceAdapterServiceImpl(adapterMapper, registry);
+        service = new DeviceAdapterServiceImpl(adapterMapper, registry,
+                new DeviceDiscoveryPersistenceService(adapterMapper));
         adapter = new TDeviceAdapter();
         adapter.setAdapterId(7L);
         adapter.setAdapterCode("lab-bts");
@@ -61,6 +69,7 @@
         adapter.setPollIntervalMs(5000);
         adapter.setTimezoneId("Asia/Hong_Kong");
         adapter.setReadOnly(true);
+        adapter.setCapabilities("{\"discovery\":true,\"realtimeObservation\":true,\"historyDownload\":true,\"control\":false}");
         adapter.setStatus("0");
     }
 
@@ -75,6 +84,23 @@
 
         assertEquals(1, rows);
         verify(registry).synchronize(any());
+    }
+
+    @Test
+    void springSelectsProductionConstructor()
+    {
+        try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext())
+        {
+            context.registerBean(TDeviceAdapterMapper.class, () -> org.mockito.Mockito.mock(TDeviceAdapterMapper.class));
+            context.registerBean(DeviceAdapterRegistry.class, () -> org.mockito.Mockito.mock(DeviceAdapterRegistry.class));
+            context.registerBean(DeviceDiscoveryPersistenceService.class,
+                    () -> org.mockito.Mockito.mock(DeviceDiscoveryPersistenceService.class));
+            context.register(DeviceAdapterServiceImpl.class);
+
+            context.refresh();
+
+            assertNotNull(context.getBean(DeviceAdapterServiceImpl.class));
+        }
     }
 
     @Test
@@ -93,6 +119,31 @@
         verify(adapterMapper, times(4)).upsertDetectedChannel(any(TDetectionChannel.class));
         verify(adapterMapper, times(2)).updateDiscoverySuccess(eq(7L), any());
         verify(adapterMapper, times(2)).updateDetectedCabinet(any(TDetectionCabinet.class));
+    }
+
+    @Test
+    void discoveryPersistsOptionalDeviceMetadataFromAdapterBoundary()
+    {
+        ChannelIdentity identity = new ChannelIdentity("lab-bts", 22, 8, 1, 1);
+        when(adapterMapper.selectDeviceAdapterById(7L)).thenReturn(adapter);
+        when(deviceAdapter.discoverChannels()).thenReturn(List.of(new DetectedChannel(identity, "127.0.0.8", true,
+                new DetectedDeviceMetadata("开发设备 8", "开发模拟器", "BTS Virtual Cabinet",
+                        "02:44:00:16:00:08"))));
+        when(adapterMapper.selectCabinetIdByIdentity(7L, 22, 8)).thenReturn(null);
+        when(adapterMapper.insertDetectedCabinet(any(TDetectionCabinet.class))).thenAnswer(invocation -> {
+            invocation.getArgument(0, TDetectionCabinet.class).setCabinetId(18L);
+            return 1;
+        });
+        stubRegistryExecution();
+
+        service.discover(7L);
+
+        var cabinet = org.mockito.ArgumentCaptor.forClass(TDetectionCabinet.class);
+        verify(adapterMapper).insertDetectedCabinet(cabinet.capture());
+        assertEquals("开发设备 8", cabinet.getValue().getCabinetName());
+        assertEquals("开发模拟器", cabinet.getValue().getManufacturer());
+        assertEquals("BTS Virtual Cabinet", cabinet.getValue().getModel());
+        assertEquals("02:44:00:16:00:08", cabinet.getValue().getMacAddress());
     }
 
     @Test
@@ -117,6 +168,28 @@
     }
 
     @Test
+    void unavailableDevicePersistsExplicitOfflineFactsWithoutRealtimeInquiry()
+    {
+        when(adapterMapper.selectDeviceAdapterById(7L)).thenReturn(adapter);
+        when(deviceAdapter.discoverChannels()).thenReturn(List.of(
+                new DetectedChannel(new ChannelIdentity("lab-bts", 22, 1, 1, 1), "127.0.0.1", false),
+                new DetectedChannel(new ChannelIdentity("lab-bts", 22, 1, 1, 2), "127.0.0.1", false)));
+        when(adapterMapper.selectCabinetIdByIdentity(7L, 22, 1)).thenReturn(10L);
+        stubRegistryExecution();
+
+        service.discover(7L);
+
+        var cabinet = org.mockito.ArgumentCaptor.forClass(TDetectionCabinet.class);
+        var channels = org.mockito.ArgumentCaptor.forClass(TDetectionChannel.class);
+        verify(adapterMapper).updateDetectedCabinet(cabinet.capture());
+        verify(adapterMapper, times(2)).upsertDetectedChannel(channels.capture());
+        verify(deviceAdapter, never()).inquire(any());
+        assertEquals("OFFLINE", cabinet.getValue().getOnlineStatus());
+        assertTrue(channels.getAllValues().stream()
+                .allMatch(channel -> "OFFLINE".equals(channel.getOnlineStatus())));
+    }
+
+    @Test
     void connectionFailureHasStableCodeAndLaterDiscoveryClearsError()
     {
         when(adapterMapper.selectDeviceAdapterById(7L)).thenReturn(adapter);
@@ -133,6 +206,38 @@
         verify(adapterMapper).updateDiscoverySuccess(eq(7L), any());
     }
 
+    @Test
+    void ensuresManagedAdapterFromApplicationProvidedConfiguration()
+    {
+        when(adapterMapper.selectDeviceAdapterByCode("lab-bts")).thenReturn(adapter);
+
+        TDeviceAdapter registered = service.ensureManagedAdapter(adapter);
+
+        verify(adapterMapper).upsertManagedDeviceAdapter(adapter);
+        assertEquals(adapter, registered);
+    }
+
+    @Test
+    void developmentSimulatorProvisioningIsIdempotentAndReadOnly()
+    {
+        String mapperXml;
+        try (InputStream input = getClass().getResourceAsStream("/mapper/system/TDeviceAdapterMapper.xml"))
+        {
+            if (input == null) throw new AssertionError("缺少设备适配器 Mapper");
+            mapperXml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
+        }
+        catch (Exception exception)
+        {
+            throw new AssertionError("无法读取设备适配器 Mapper", exception);
+        }
+
+        assertTrue(mapperXml.contains("upsertManagedDeviceAdapter"));
+        assertTrue(mapperXml.contains("#{adapterCode}"));
+        assertTrue(mapperXml.contains("#{capabilities}"));
+        assertTrue(mapperXml.contains("on duplicate key update"));
+        assertTrue(mapperXml.contains("del_flag=0"));
+    }
+
     @SuppressWarnings("unchecked")
     private void stubRegistryExecution()
     {
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TDetectionCabinetServiceImplTest.java b/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TDetectionCabinetServiceImplTest.java
index bbc051e..2305e44 100644
--- a/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TDetectionCabinetServiceImplTest.java
+++ b/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TDetectionCabinetServiceImplTest.java
@@ -1,8 +1,14 @@
 package com.ruoyi.system.service.impl;
 
+import java.io.IOException;
+import java.io.InputStream;
 import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -20,6 +26,7 @@
 import org.mockito.junit.jupiter.MockitoExtension;
 import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.system.domain.TDetectionCabinet;
+import com.ruoyi.system.domain.vo.DiscoveredCabinetVo;
 import com.ruoyi.system.mapper.TDetectionCabinetMapper;
 
 /**
@@ -58,28 +65,132 @@
      * 验证新增时会规范化基础字段并初始化运行状态。
      */
     @Test
-    void insertDetectionCabinetShouldNormalizeFieldsAndInitializeStatus()
+    void insertDetectionCabinetShouldPromoteDiscoveredCandidate()
     {
-        when(detectionCabinetMapper.selectByCabinetCode("CABINET-001")).thenReturn(null);
-        when(detectionCabinetMapper.selectByMacAddress("AA:BB:CC:DD:EE:FF")).thenReturn(null);
-        when(detectionCabinetMapper.insertDetectionCabinet(any(TDetectionCabinet.class))).thenReturn(1);
+        TDetectionCabinet candidate = new TDetectionCabinet();
+        candidate.setCabinetId(7L);
+        candidate.setCabinetCode("CABINET-001");
+        candidate.setMacAddress("AA:BB:CC:DD:EE:FF");
+        candidate.setRegistrationStatus("DISCOVERED");
+        when(detectionCabinetMapper.selectByCabinetCode("CABINET-001")).thenReturn(candidate);
+        when(detectionCabinetMapper.selectByMacAddress("AA:BB:CC:DD:EE:FF")).thenReturn(candidate);
+        when(detectionCabinetMapper.registerDiscoveredCabinet(any(TDetectionCabinet.class))).thenReturn(1);
 
         int rows = detectionCabinetService.insertDetectionCabinet(detectionCabinet);
 
         ArgumentCaptor<TDetectionCabinet> captor = ArgumentCaptor.forClass(TDetectionCabinet.class);
-        verify(detectionCabinetMapper).insertDetectionCabinet(captor.capture());
+        verify(detectionCabinetMapper).registerDiscoveredCabinet(captor.capture());
         TDetectionCabinet savedCabinet = captor.getValue();
         assertEquals(1, rows);
+        assertEquals(7L, savedCabinet.getCabinetId());
         assertEquals("CABINET-001", savedCabinet.getCabinetCode());
         assertEquals("一号检测柜", savedCabinet.getCabinetName());
         assertEquals("192.168.1.10", savedCabinet.getIpAddress());
         assertEquals("AA:BB:CC:DD:EE:FF", savedCabinet.getMacAddress());
         assertEquals(new BigDecimal("120.50"), savedCabinet.getCapacity());
-        assertEquals("0", savedCabinet.getOnlineStatus());
-        assertEquals("0", savedCabinet.getTestStatus());
+        assertNull(savedCabinet.getOnlineStatus());
         assertEquals("0", savedCabinet.getStatus());
-        assertEquals("0", savedCabinet.getDelFlag());
         assertNull(savedCabinet.getCurrentTest());
+    }
+
+    /**
+     * 验证设备选择查询不读取旧检测柜测试字段,并改由持久化通道状态聚合。
+     */
+    @Test
+    void discoveredCabinetSqlShouldNotReadLegacyTestColumns() throws IOException
+    {
+        try (InputStream input = getClass().getResourceAsStream(
+                "/mapper/system/TDetectionCabinetMapper.xml"))
+        {
+            assertNotNull(input);
+            String mapperXml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
+            int selectStart = mapperXml.indexOf("<select id=\"selectDiscoveredCabinetList\"");
+            int selectEnd = mapperXml.indexOf("</select>", selectStart);
+            String selectSql = mapperXml.substring(selectStart, selectEnd);
+            String activeChannelSql = sqlFragment(mapperXml, "activeCabinetChannelExists");
+
+            assertFalse(selectSql.contains("cabinet.test_status"));
+            assertFalse(selectSql.contains("cabinet.current_test"));
+            assertTrue(selectSql.contains("activeCabinetChannelExists"));
+            assertTrue(activeChannelSql.contains("t_detection_channel"));
+            assertTrue(activeChannelSql.contains("work_status"));
+            assertTrue(selectSql.contains("registration_status = 'DISCOVERED'"));
+        }
+    }
+
+    /**
+     * 验证检测柜主列表从通道事实派生旧页面展示字段,不读取数据库不存在的列。
+     */
+    @Test
+    void detectionCabinetListSqlShouldDeriveLegacyPresentationFields() throws IOException
+    {
+        try (InputStream input = getClass().getResourceAsStream(
+                "/mapper/system/TDetectionCabinetMapper.xml"))
+        {
+            assertNotNull(input);
+            String mapperXml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
+            int selectStart = mapperXml.indexOf("<sql id=\"selectDetectionCabinetVo\"");
+            int selectEnd = mapperXml.indexOf("</sql>", selectStart);
+            String selectSql = mapperXml.substring(selectStart, selectEnd);
+            String activeChannelSql = sqlFragment(mapperXml, "activeCabinetChannelExists");
+
+            assertFalse(selectSql.contains("online_status, test_status"));
+            assertFalse(selectSql.contains("current_test, status"));
+            assertTrue(selectSql.contains("activeCabinetChannelExists"));
+            assertTrue(activeChannelSql.contains("t_detection_channel"));
+            assertTrue(activeChannelSql.contains("work_status"));
+            assertTrue(selectSql.contains("as test_status"));
+            assertTrue(selectSql.contains("as current_test"));
+            int listStart = mapperXml.indexOf("<select id=\"selectDetectionCabinetList\"");
+            int listEnd = mapperXml.indexOf("</select>", listStart);
+            assertTrue(mapperXml.substring(listStart, listEnd).contains("registration_status = 'REGISTERED'"));
+            int registerStart = mapperXml.indexOf("<update id=\"registerDiscoveredCabinet\"");
+            int registerEnd = mapperXml.indexOf("</update>", registerStart);
+            String registerSql = mapperXml.substring(registerStart, registerEnd);
+            assertTrue(registerSql.contains("registration_status = 'REGISTERED'"));
+            assertTrue(registerSql.contains("create_by = #{createBy}"));
+        }
+    }
+
+    private String sqlFragment(String mapperXml, String id)
+    {
+        int start = mapperXml.indexOf("<sql id=\"" + id + "\"");
+        int end = mapperXml.indexOf("</sql>", start);
+        return mapperXml.substring(start, end);
+    }
+
+    /**
+     * 验证设备选择列表复用已持久化检测柜,并保持原前端字段和状态文本。
+     */
+    @Test
+    void selectDiscoveredCabinetListShouldMapPersistedCabinetFacts()
+    {
+        TDetectionCabinet persisted = new TDetectionCabinet();
+        persisted.setCabinetCode("CABINET-001");
+        persisted.setIpAddress("192.168.1.10");
+        persisted.setMacAddress("AA:BB:CC:DD:EE:FF");
+        persisted.setCabinetName("一号模拟柜");
+        persisted.setManufacturer("开发模拟器");
+        persisted.setModel("BTS Virtual Cabinet");
+        persisted.setChannelCount(6);
+        persisted.setOnlineStatus("ONLINE");
+        persisted.setObservedWorkStatus("ACTIVE");
+        when(detectionCabinetMapper.selectDiscoveredCabinetList())
+                .thenReturn(List.of(persisted));
+
+        List<DiscoveredCabinetVo> devices = detectionCabinetService.selectDiscoveredCabinetList();
+
+        assertEquals(1, devices.size());
+        assertEquals("CABINET-001", devices.get(0).getDeviceCode());
+        assertEquals("192.168.1.10", devices.get(0).getIp());
+        assertEquals("AA:BB:CC:DD:EE:FF", devices.get(0).getMac());
+        assertEquals("在线", devices.get(0).getOnlineStatus());
+        assertEquals("测试中", devices.get(0).getTestStatus());
+        assertEquals("待确认", devices.get(0).getCurrentTest());
+        assertEquals("一号模拟柜", devices.get(0).getName());
+        assertEquals("开发模拟器", devices.get(0).getManufacturer());
+        assertEquals("BTS Virtual Cabinet", devices.get(0).getModel());
+        assertEquals(6, devices.get(0).getChannelCount());
     }
 
     /**
@@ -90,13 +201,26 @@
     {
         TDetectionCabinet existing = new TDetectionCabinet();
         existing.setCabinetId(2L);
+        existing.setRegistrationStatus("REGISTERED");
         when(detectionCabinetMapper.selectByCabinetCode("CABINET-001")).thenReturn(existing);
 
         ServiceException exception = assertThrows(ServiceException.class,
                 () -> detectionCabinetService.insertDetectionCabinet(detectionCabinet));
 
         assertEquals("设备编码已存在", exception.getMessage());
-        verify(detectionCabinetMapper, never()).insertDetectionCabinet(any(TDetectionCabinet.class));
+        verify(detectionCabinetMapper, never()).registerDiscoveredCabinet(any(TDetectionCabinet.class));
+    }
+
+    @Test
+    void insertDetectionCabinetShouldRequireDiscoveredCandidate()
+    {
+        when(detectionCabinetMapper.selectByCabinetCode("CABINET-001")).thenReturn(null);
+
+        ServiceException exception = assertThrows(ServiceException.class,
+                () -> detectionCabinetService.insertDetectionCabinet(detectionCabinet));
+
+        assertEquals("请先选择发现候选设备", exception.getMessage());
+        verify(detectionCabinetMapper, never()).registerDiscoveredCabinet(any());
     }
 
     /**
@@ -111,7 +235,7 @@
                 () -> detectionCabinetService.insertDetectionCabinet(detectionCabinet));
 
         assertEquals("IP地址格式不正确", exception.getMessage());
-        verify(detectionCabinetMapper, never()).insertDetectionCabinet(any(TDetectionCabinet.class));
+        verify(detectionCabinetMapper, never()).registerDiscoveredCabinet(any(TDetectionCabinet.class));
     }
 
     /**
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorControllerTest.java b/ruoyi-admin/src/test/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorControllerTest.java
new file mode 100644
index 0000000..8d60d0b
--- /dev/null
+++ b/ruoyi-admin/src/test/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorControllerTest.java
@@ -0,0 +1,94 @@
+package com.ruoyi.web.controller.device;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import java.lang.reflect.Method;
+import java.net.InetAddress;
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.web.bind.annotation.GetMapping;
+import com.ruoyi.common.annotation.Anonymous;
+import com.ruoyi.device.bts.BtsDevelopmentSimulator;
+import com.ruoyi.device.model.DeviceWorkStatus;
+import com.ruoyi.web.config.BtsDevelopmentSimulatorWebConfig;
+
+class BtsDevelopmentSimulatorControllerTest
+{
+    @Test
+    void controllerUpdatesDeviceAndChannelState() throws Exception
+    {
+        try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
+        {
+            BtsDevelopmentSimulatorController controller = new BtsDevelopmentSimulatorController(simulator);
+
+            controller.updateDevice(1,
+                    new BtsDevelopmentSimulatorController.DeviceStateCommand("127.0.0.2", 4, false));
+            controller.updateChannel(1, 3,
+                    new BtsDevelopmentSimulatorController.ChannelStateCommand(true, DeviceWorkStatus.PAUSED));
+
+            assertFalse(controller.state().devices().get(0).online());
+            assertEquals(DeviceWorkStatus.PAUSED,
+                    controller.state().devices().get(0).channels().get(2).workStatus());
+        }
+    }
+
+    @Test
+    void controllerAddsUpdatesAndDeletesVirtualDevices() throws Exception
+    {
+        try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
+        {
+            BtsDevelopmentSimulatorController controller = new BtsDevelopmentSimulatorController(simulator);
+
+            var created = controller.createDevice(
+                    new BtsDevelopmentSimulatorController.CreateDeviceCommand("127.0.0.8", 6));
+            int deviceId = created.devices().stream()
+                    .filter(device -> device.ip().equals("127.0.0.8"))
+                    .findFirst().orElseThrow().deviceId();
+            controller.updateDevice(deviceId,
+                    new BtsDevelopmentSimulatorController.DeviceStateCommand("127.0.0.9", 3, false));
+
+            assertEquals(2, controller.state().devices().size());
+            assertEquals(3, controller.state().devices().stream()
+                    .filter(device -> device.deviceId() == deviceId)
+                    .findFirst().orElseThrow().channels().size());
+
+            controller.deleteDevice(deviceId);
+            assertEquals(1, controller.state().devices().size());
+        }
+    }
+
+    @Test
+    void developmentControlBoundaryOnlyAllowsLoopbackClients() throws Exception
+    {
+        var interceptor = new BtsDevelopmentSimulatorWebConfig.LocalOnlyInterceptor();
+        MockHttpServletResponse localResponse = new MockHttpServletResponse();
+        MockHttpServletRequest localRequest = requestFrom(InetAddress.getLoopbackAddress().getHostAddress());
+        MockHttpServletResponse remoteResponse = new MockHttpServletResponse();
+        MockHttpServletRequest remoteRequest = requestFrom("192.168.1.20");
+
+        assertTrue(interceptor.preHandle(localRequest, localResponse, new Object()));
+        assertFalse(interceptor.preHandle(remoteRequest, remoteResponse, new Object()));
+        assertEquals(403, remoteResponse.getStatus());
+        assertTrue(BtsDevelopmentSimulatorController.class.isAnnotationPresent(Anonymous.class));
+    }
+
+    @Test
+    void exposesStaticControlPageThroughAnonymousHandler() throws Exception
+    {
+        Method page = BtsDevelopmentSimulatorController.class.getMethod("page");
+
+        assertArrayEquals(new String[] { "/dev/bts-simulator.html" },
+                page.getAnnotation(GetMapping.class).value());
+        assertTrue(BtsDevelopmentSimulatorController.class.isAnnotationPresent(Anonymous.class));
+    }
+
+    private MockHttpServletRequest requestFrom(String remoteAddress)
+    {
+        MockHttpServletRequest request = new MockHttpServletRequest("GET", "/dev/bts-simulator/state");
+        request.setRemoteAddr(remoteAddress);
+        return request;
+    }
+}
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/TDetectionCabinetControllerContractTest.java b/ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/TDetectionCabinetControllerContractTest.java
new file mode 100644
index 0000000..a29e179
--- /dev/null
+++ b/ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/TDetectionCabinetControllerContractTest.java
@@ -0,0 +1,21 @@
+package com.ruoyi.web.controller.system;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+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;
+import org.springframework.web.bind.annotation.GetMapping;
+
+class TDetectionCabinetControllerContractTest
+{
+    @Test
+    void keepsOriginalDiscoveredDeviceSelectionEndpoint() throws Exception
+    {
+        Method method = TDetectionCabinetController.class.getMethod("discoveredList");
+
+        assertArrayEquals(new String[] { "/discovered/list" }, method.getAnnotation(GetMapping.class).value());
+        assertEquals("@ss.hasPermi('system:detectionCabinet:list')",
+                method.getAnnotation(PreAuthorize.class).value());
+    }
+}
diff --git a/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDevelopmentSimulator.java b/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDevelopmentSimulator.java
new file mode 100644
index 0000000..4d78627
--- /dev/null
+++ b/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDevelopmentSimulator.java
@@ -0,0 +1,314 @@
+package com.ruoyi.device.bts;
+
+import java.io.IOException;
+import java.io.StringReader;
+import java.time.Instant;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import javax.xml.stream.XMLInputFactory;
+import javax.xml.stream.XMLStreamConstants;
+import javax.xml.stream.XMLStreamReader;
+import com.ruoyi.device.model.DeviceWorkStatus;
+
+/** 提供由开发后端托管的临时只读 BTS 模拟端点。 */
+public final class BtsDevelopmentSimulator implements AutoCloseable
+{
+    public static final String HOST = "127.0.0.1";
+    public static final int DEFAULT_PORT = 15020;
+
+    private static final int DEVICE_TYPE = 22;
+    private static final int SUB_DEVICE_ID = 1;
+    private static final int MAX_CHANNEL_COUNT = 99;
+    private static final int MAX_RECENT_REQUESTS = 20;
+    private static final int TOMBSTONE_DISCOVERY_COUNT = 3;
+
+    private final ScriptedBtsTcpSimulator simulator;
+    private final Map<Integer, DeviceState> devices = new LinkedHashMap<>();
+    private final Map<String, ChannelTombstone> tombstones = new LinkedHashMap<>();
+    private final Deque<RecentRequest> recentRequests = new ArrayDeque<>();
+    private int nextDeviceId = 1;
+    private boolean running = true;
+
+    /** 启动仅监听 IPv4 本机回环地址的开发模拟器;传入 0 时由系统分配测试端口。 */
+    public BtsDevelopmentSimulator(int port) throws IOException
+    {
+        addDeviceInternal("127.0.0.2", 4);
+        simulator = ScriptedBtsTcpSimulator.responding(port, this::respond);
+    }
+
+    public int port()
+    {
+        return simulator.port();
+    }
+
+    /** 新增一台具有独立虚拟 IP 和通道数的开发设备。 */
+    public synchronized int addDevice(String ip, int channelCount)
+    {
+        validateDeviceConfiguration(ip, channelCount, null);
+        return addDeviceInternal(ip, channelCount);
+    }
+
+    /** 修改开发设备的虚拟地址、通道数和在线状态。 */
+    public synchronized void updateDevice(int deviceId, String ip, int channelCount, boolean online)
+    {
+        DeviceState current = requireDevice(deviceId);
+        validateDeviceConfiguration(ip, channelCount, deviceId);
+        List<ChannelState> channels = new ArrayList<>(channelCount);
+        for (int channelId = 1; channelId <= channelCount; channelId++)
+        {
+            channels.add(channelId <= current.channels().size()
+                    ? current.channels().get(channelId - 1)
+                    : new ChannelState(channelId, true, DeviceWorkStatus.STOPPED));
+        }
+        for (int channelId = channelCount + 1; channelId <= current.channels().size(); channelId++)
+            addTombstone(current.deviceId(), current.ip(), channelId);
+        for (int channelId = 1; channelId <= channelCount; channelId++)
+            tombstones.remove(tombstoneKey(deviceId, channelId));
+        devices.put(deviceId, new DeviceState(deviceId, ip.trim(), online, List.copyOf(channels)));
+    }
+
+    /** 删除指定开发设备;后续发现响应不再返回它。 */
+    public synchronized void deleteDevice(int deviceId)
+    {
+        DeviceState removed = devices.remove(deviceId);
+        if (removed == null) throw new IllegalArgumentException("Unknown simulator device: " + deviceId);
+        for (ChannelState channel : removed.channels())
+            addTombstone(removed.deviceId(), removed.ip(), channel.channelId());
+    }
+
+    /** 修改指定开发设备的单个通道状态。 */
+    public synchronized void updateChannel(int deviceId, int channelId, boolean online,
+            DeviceWorkStatus workStatus)
+    {
+        requireControllableStatus(workStatus);
+        DeviceState device = requireDevice(deviceId);
+        if (channelId < 1 || channelId > device.channels().size())
+            throw new IllegalArgumentException("channelId must be between 1 and " + device.channels().size());
+        List<ChannelState> channels = new ArrayList<>(device.channels());
+        channels.set(channelId - 1, new ChannelState(channelId, online, workStatus));
+        devices.put(deviceId, new DeviceState(deviceId, device.ip(), device.online(), List.copyOf(channels)));
+    }
+
+    /** 返回控制页需要的运行状态快照。 */
+    public synchronized StateSnapshot snapshot()
+    {
+        List<DeviceSnapshot> deviceSnapshots = devices.values().stream()
+                .map(device -> new DeviceSnapshot(device.deviceId(), device.ip(), device.online(),
+                        device.channels().stream()
+                                .map(channel -> new ChannelSnapshot(channel.channelId(), channel.online(),
+                                        channel.workStatus()))
+                                .toList()))
+                .toList();
+        return new StateSnapshot(running, HOST + ":" + port(), deviceSnapshots, List.copyOf(recentRequests));
+    }
+
+    private int addDeviceInternal(String ip, int channelCount)
+    {
+        int deviceId = nextDeviceId++;
+        List<ChannelState> channels = new ArrayList<>(channelCount);
+        for (int channelId = 1; channelId <= channelCount; channelId++)
+            channels.add(new ChannelState(channelId, true, DeviceWorkStatus.STOPPED));
+        devices.put(deviceId, new DeviceState(deviceId, ip.trim(), true, List.copyOf(channels)));
+        return deviceId;
+    }
+
+    private String respond(String request)
+    {
+        String command = readCommand(request);
+        recordRequest(command, request);
+        return switch (command)
+        {
+            case "getdevinfo" -> deviceInfoResponse();
+            case "inquire" -> observationResponse();
+            default -> "<bts version=\"1.0\"><cmd>error</cmd></bts>";
+        };
+    }
+
+    private synchronized void recordRequest(String command, String request)
+    {
+        recentRequests.addFirst(new RecentRequest(Instant.now(), command, request));
+        while (recentRequests.size() > MAX_RECENT_REQUESTS) recentRequests.removeLast();
+    }
+
+    private String readCommand(String request)
+    {
+        try
+        {
+            XMLInputFactory factory = XMLInputFactory.newFactory();
+            factory.setProperty(XMLInputFactory.SUPPORT_DTD, false);
+            factory.setProperty("javax.xml.stream.isSupportingExternalEntities", false);
+            XMLStreamReader reader = factory.createXMLStreamReader(new StringReader(request));
+            while (reader.hasNext())
+            {
+                if (reader.next() == XMLStreamConstants.START_ELEMENT && "cmd".equals(reader.getLocalName()))
+                    return reader.getElementText().trim();
+            }
+        }
+        catch (Exception exception)
+        {
+            throw new IllegalArgumentException("Invalid BTS request XML", exception);
+        }
+        throw new IllegalArgumentException("BTS request does not contain cmd");
+    }
+
+    private synchronized String deviceInfoResponse()
+    {
+        int channelCount = devices.values().stream().mapToInt(device -> device.channels().size()).sum()
+                + tombstones.size();
+        StringBuilder response = new StringBuilder("<bts version=\"1.0\"><cmd>getdevinfo_resp</cmd>")
+                .append("<serverip count=\"0\"/><middle count=\"").append(channelCount).append("\">");
+        for (DeviceState device : devices.values())
+        {
+            for (ChannelState channel : device.channels())
+            {
+                boolean available = device.online() && channel.online();
+                appendDiscoveredChannel(response, device.deviceId(), device.ip(), channel.channelId(), available);
+            }
+        }
+        for (Map.Entry<String, ChannelTombstone> entry : new ArrayList<>(tombstones.entrySet()))
+        {
+            ChannelTombstone tombstone = entry.getValue();
+            appendDiscoveredChannel(response, tombstone.deviceId(), tombstone.ip(), tombstone.channelId(), false);
+            if (tombstone.remainingDiscoveries() <= 1) tombstones.remove(entry.getKey());
+            else tombstones.put(entry.getKey(), new ChannelTombstone(tombstone.deviceId(), tombstone.ip(),
+                    tombstone.channelId(), tombstone.remainingDiscoveries() - 1));
+        }
+        return response.append("</middle></bts>").toString();
+    }
+
+    private void appendDiscoveredChannel(StringBuilder response, int deviceId, String ip, int channelId,
+            boolean available)
+    {
+        // 展示元数据是开发模拟器扩展,不代表真实 BTS 协议必然提供这些属性。
+        response.append("<channel ip=\"").append(ip).append("\" devtype=\"").append(DEVICE_TYPE)
+                .append("\" devid=\"").append(deviceId).append("\" subdevid=\"").append(SUB_DEVICE_ID)
+                .append("\" Channelid=\"").append(channelId).append("\" name=\"dev-sim 设备 ")
+                .append(deviceId).append("\" manufacturer=\"开发模拟器\" model=\"BTS Virtual Cabinet\" mac=\"")
+                .append(simulatorMac(DEVICE_TYPE, deviceId)).append("\">")
+                .append(available).append("</channel>");
+    }
+
+    private synchronized String observationResponse()
+    {
+        List<ObservedChannel> available = new ArrayList<>();
+        for (DeviceState device : devices.values())
+            for (ChannelState channel : device.channels())
+                if (device.online() && channel.online()) available.add(new ObservedChannel(device.deviceId(), channel));
+
+        StringBuilder response = new StringBuilder("<bts version=\"1.0\"><cmd>inquire_resp</cmd><list count=\"")
+                .append(available.size()).append("\">");
+        for (ObservedChannel observed : available)
+        {
+            ChannelState channel = observed.channel();
+            response.append("<inquire dev=\"").append(DEVICE_TYPE).append('-').append(observed.deviceId())
+                    .append('-').append(SUB_DEVICE_ID).append('-').append(channel.channelId())
+                    .append("-0\" cycle_id=\"1\" step_id=\"1\" step_type=\"cc\" workstatus=\"")
+                    .append(toBtsWorkStatus(channel.workStatus()))
+                    .append("\" current=\"0\" voltage=\"3.700\" capacity=\"0\" energy=\"0\" ")
+                    .append("totaltime=\"0\" relativetime=\"0\" open_or_close=\"0\" log_code=\"0\"/>");
+        }
+        return response.append("</list></bts>").toString();
+    }
+
+    private String toBtsWorkStatus(DeviceWorkStatus status)
+    {
+        return switch (status)
+        {
+            case STOPPED -> "stop";
+            case WORKING -> "working";
+            case PAUSED -> "pause";
+            case FINISHED -> "finish";
+            default -> throw new IllegalArgumentException("Unsupported simulator work status: " + status);
+        };
+    }
+
+    private void validateDeviceConfiguration(String ip, int channelCount, Integer currentDeviceId)
+    {
+        if (!isIpv4(ip)) throw new IllegalArgumentException("Simulator device IP must be a valid IPv4 address");
+        if (channelCount < 1 || channelCount > MAX_CHANNEL_COUNT)
+            throw new IllegalArgumentException("channelCount must be between 1 and " + MAX_CHANNEL_COUNT);
+        boolean duplicate = devices.values().stream()
+                .anyMatch(device -> device.ip().equals(ip.trim())
+                        && device.deviceId() != (currentDeviceId == null ? -1 : currentDeviceId));
+        if (duplicate) throw new IllegalArgumentException("Simulator device IP must be unique");
+    }
+
+    private boolean isIpv4(String ip)
+    {
+        if (ip == null) return false;
+        String[] parts = ip.trim().split("\\.", -1);
+        if (parts.length != 4) return false;
+        for (String part : parts)
+        {
+            try
+            {
+                int value = Integer.parseInt(part);
+                if (part.isEmpty() || value < 0 || value > 255) return false;
+            }
+            catch (NumberFormatException exception)
+            {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    private DeviceState requireDevice(int deviceId)
+    {
+        DeviceState device = devices.get(deviceId);
+        if (device == null) throw new IllegalArgumentException("Unknown simulator device: " + deviceId);
+        return device;
+    }
+
+    private void addTombstone(int deviceId, String ip, int channelId)
+    {
+        tombstones.put(tombstoneKey(deviceId, channelId),
+                new ChannelTombstone(deviceId, ip, channelId, TOMBSTONE_DISCOVERY_COUNT));
+    }
+
+    private String tombstoneKey(int deviceId, int channelId)
+    {
+        return deviceId + ":" + channelId;
+    }
+
+    private String simulatorMac(int devtype, int devid)
+    {
+        return String.format("02:44:%02X:%02X:%02X:%02X",
+                (devtype >>> 8) & 0xFF, devtype & 0xFF, (devid >>> 8) & 0xFF, devid & 0xFF);
+    }
+
+    private void requireControllableStatus(DeviceWorkStatus status)
+    {
+        if (status == null || (status != DeviceWorkStatus.STOPPED && status != DeviceWorkStatus.WORKING
+                && status != DeviceWorkStatus.PAUSED && status != DeviceWorkStatus.FINISHED))
+            throw new IllegalArgumentException("Simulator work status must be STOPPED, WORKING, PAUSED or FINISHED");
+    }
+
+    @Override
+    public synchronized void close() throws Exception
+    {
+        running = false;
+        simulator.close();
+    }
+
+    private record DeviceState(int deviceId, String ip, boolean online, List<ChannelState> channels) { }
+
+    private record ChannelState(int channelId, boolean online, DeviceWorkStatus workStatus) { }
+
+    private record ObservedChannel(int deviceId, ChannelState channel) { }
+
+    private record ChannelTombstone(int deviceId, String ip, int channelId, int remainingDiscoveries) { }
+
+    public record StateSnapshot(boolean running, String listenAddress, List<DeviceSnapshot> devices,
+            List<RecentRequest> recentRequests) { }
+
+    public record DeviceSnapshot(int deviceId, String ip, boolean online, List<ChannelSnapshot> channels) { }
+
+    public record ChannelSnapshot(int channelId, boolean online, DeviceWorkStatus workStatus) { }
+
+    public record RecentRequest(Instant receivedAt, String command, String request) { }
+}
diff --git a/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDeviceAdapter.java b/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDeviceAdapter.java
index 92b3554..6ff34ed 100644
--- a/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDeviceAdapter.java
+++ b/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDeviceAdapter.java
@@ -17,12 +17,20 @@
     private final String adapterCode;
     private final BtsTransport transport;
     private final BtsXmlCodec codec;
+    private final AdapterCapabilities capabilities;
 
     public BtsDeviceAdapter(String adapterCode, BtsTransport transport, BtsXmlCodec codec)
+    {
+        this(adapterCode, transport, codec, AdapterCapabilities.observeOnly());
+    }
+
+    public BtsDeviceAdapter(String adapterCode, BtsTransport transport, BtsXmlCodec codec,
+            AdapterCapabilities capabilities)
     {
         this.adapterCode = adapterCode;
         this.transport = transport;
         this.codec = codec;
+        this.capabilities = capabilities;
     }
 
     @Override
@@ -34,7 +42,7 @@
     @Override
     public AdapterCapabilities capabilities()
     {
-        return AdapterCapabilities.observeOnly();
+        return capabilities;
     }
 
     @Override
@@ -53,6 +61,7 @@
     @Override
     public List<HistoryAvailability> inquireHistory(Set<ChannelIdentity> channels)
     {
+        if (!capabilities.historyDownload()) throw new UnsupportedOperationException("History download is not supported");
         if (channels == null || channels.isEmpty()) return List.of();
         return codec.parseHistoryAvailability(transport.exchange(codec.inquireHistoryRequest(channels)));
     }
@@ -60,6 +69,7 @@
     @Override
     public HistoryPage downloadHistory(ChannelIdentity channel, String btsTestId, long startPosition, int limit)
     {
+        if (!capabilities.historyDownload()) throw new UnsupportedOperationException("History download is not supported");
         return codec.parseHistory(transport.exchange(codec.downloadRequest(channel, btsTestId, startPosition, limit)),
                 channel, btsTestId, startPosition);
     }
diff --git a/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDeviceAdapterFactory.java b/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDeviceAdapterFactory.java
index 3d2d660..a495b11 100644
--- a/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDeviceAdapterFactory.java
+++ b/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDeviceAdapterFactory.java
@@ -24,6 +24,6 @@
         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 BtsXmlCodec(config.adapterCode(), ZoneId.of(config.timezoneId())), config.capabilities());
     }
 }
diff --git a/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsXmlCodec.java b/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsXmlCodec.java
index 579be51..11a74d7 100644
--- a/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsXmlCodec.java
+++ b/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsXmlCodec.java
@@ -25,6 +25,7 @@
 import org.xml.sax.helpers.DefaultHandler;
 import com.ruoyi.device.model.ChannelIdentity;
 import com.ruoyi.device.model.DetectedChannel;
+import com.ruoyi.device.model.DetectedDeviceMetadata;
 import com.ruoyi.device.model.DeviceWorkStatus;
 import com.ruoyi.device.model.HistoryAvailability;
 import com.ruoyi.device.model.HistoryPage;
@@ -113,7 +114,10 @@
             ChannelIdentity identity = new ChannelIdentity(adapterCode, integerAttribute(element, "devtype"),
                     integerAttribute(element, "devid"), integerAttribute(element, "subdevid"), chlid);
             channels.add(new DetectedChannel(identity, requiredAttribute(element, "ip"),
-                    booleanText(element.getTextContent(), "available")));
+                    booleanText(element.getTextContent(), "available"),
+                    new DetectedDeviceMetadata(optionalAttribute(element, "name"),
+                            optionalAttribute(element, "manufacturer"), optionalAttribute(element, "model"),
+                            optionalAttribute(element, "mac"))));
         }
         return List.copyOf(channels);
     }
diff --git a/ruoyi-device/src/main/java/com/ruoyi/device/bts/ScriptedBtsTcpSimulator.java b/ruoyi-device/src/main/java/com/ruoyi/device/bts/ScriptedBtsTcpSimulator.java
index 1ef262c..eb80068 100644
--- a/ruoyi-device/src/main/java/com/ruoyi/device/bts/ScriptedBtsTcpSimulator.java
+++ b/ruoyi-device/src/main/java/com/ruoyi/device/bts/ScriptedBtsTcpSimulator.java
@@ -2,6 +2,8 @@
 
 import java.io.IOException;
 import java.io.InputStream;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
 import java.net.ServerSocket;
 import java.net.Socket;
 import java.net.SocketException;
@@ -14,6 +16,7 @@
 import java.util.concurrent.Executors;
 import java.util.concurrent.LinkedBlockingQueue;
 import java.util.concurrent.TimeUnit;
+import java.util.function.Function;
 
 /**
  * 提供可复用的 BTS TCP 脚本设备边界,用于闭环与恢复测试。
@@ -27,16 +30,21 @@
     private final ExecutorService executor = Executors.newSingleThreadExecutor();
     private final BlockingQueue<ScriptedAction> actions = new LinkedBlockingQueue<>();
     private final List<String> requests = new CopyOnWriteArrayList<>();
+    private final Function<String, String> responseProvider;
 
     public ScriptedBtsTcpSimulator() throws IOException
     {
-        this(true);
+        this(0, true, null);
     }
 
-    private ScriptedBtsTcpSimulator(boolean acceptConnections) throws IOException
+    private ScriptedBtsTcpSimulator(int port, boolean acceptConnections,
+            Function<String, String> responseProvider) throws IOException
     {
-        serverSocket = new ServerSocket(0);
-        port = serverSocket.getLocalPort();
+        if (port < 0 || port > 65535) throw new IllegalArgumentException("port must be between 0 and 65535");
+        this.responseProvider = responseProvider;
+        serverSocket = new ServerSocket();
+        serverSocket.bind(new InetSocketAddress(InetAddress.getByName("127.0.0.1"), port));
+        this.port = serverSocket.getLocalPort();
         if (acceptConnections)
             executor.submit(this::serve);
         else
@@ -45,7 +53,15 @@
 
     public static ScriptedBtsTcpSimulator refusingConnections() throws IOException
     {
-        return new ScriptedBtsTcpSimulator(false);
+        return new ScriptedBtsTcpSimulator(0, false, null);
+    }
+
+    /** 创建仅监听本机、可按请求持续生成响应的模拟器。 */
+    public static ScriptedBtsTcpSimulator responding(int port, Function<String, String> responseProvider)
+            throws IOException
+    {
+        if (responseProvider == null) throw new IllegalArgumentException("responseProvider must not be null");
+        return new ScriptedBtsTcpSimulator(port, true, responseProvider);
     }
 
     public int port()
@@ -102,8 +118,11 @@
         while (!socket.isClosed())
         {
             String request = readFrame(socket.getInputStream());
-            requests.add(request);
-            ScriptedAction action = actions.poll(5, TimeUnit.SECONDS);
+            if (responseProvider == null) requests.add(request);
+            ScriptedAction action = responseProvider == null
+                    ? actions.poll(5, TimeUnit.SECONDS)
+                    : new ScriptedAction(Duration.ZERO,
+                            responseProvider.apply(request).getBytes(StandardCharsets.UTF_8), false);
             if (action == null) throw new IOException("No scripted BTS action available");
             TimeUnit.MILLISECONDS.sleep(action.delay().toMillis());
             if (action.disconnect()) return;
diff --git a/ruoyi-device/src/main/java/com/ruoyi/device/model/DetectedChannel.java b/ruoyi-device/src/main/java/com/ruoyi/device/model/DetectedChannel.java
index ee7d857..361ed58 100644
--- a/ruoyi-device/src/main/java/com/ruoyi/device/model/DetectedChannel.java
+++ b/ruoyi-device/src/main/java/com/ruoyi/device/model/DetectedChannel.java
@@ -1,5 +1,10 @@
 package com.ruoyi.device.model;
 
-public record DetectedChannel(ChannelIdentity identity, String serverIp, boolean available)
+public record DetectedChannel(ChannelIdentity identity, String serverIp, boolean available,
+        DetectedDeviceMetadata deviceMetadata)
 {
+    public DetectedChannel(ChannelIdentity identity, String serverIp, boolean available)
+    {
+        this(identity, serverIp, available, DetectedDeviceMetadata.empty());
+    }
 }
diff --git a/ruoyi-device/src/main/java/com/ruoyi/device/model/DetectedDeviceMetadata.java b/ruoyi-device/src/main/java/com/ruoyi/device/model/DetectedDeviceMetadata.java
new file mode 100644
index 0000000..5e5fc6f
--- /dev/null
+++ b/ruoyi-device/src/main/java/com/ruoyi/device/model/DetectedDeviceMetadata.java
@@ -0,0 +1,10 @@
+package com.ruoyi.device.model;
+
+/** 设备发现响应可选携带的展示元数据;空值表示协议未提供。 */
+public record DetectedDeviceMetadata(String name, String manufacturer, String model, String macAddress)
+{
+    public static DetectedDeviceMetadata empty()
+    {
+        return new DetectedDeviceMetadata(null, null, null, null);
+    }
+}
diff --git a/ruoyi-device/src/test/java/com/ruoyi/device/contract/SimulatorPublicContractTest.java b/ruoyi-device/src/test/java/com/ruoyi/device/contract/SimulatorPublicContractTest.java
index 7fca87f..85887f6 100644
--- a/ruoyi-device/src/test/java/com/ruoyi/device/contract/SimulatorPublicContractTest.java
+++ b/ruoyi-device/src/test/java/com/ruoyi/device/contract/SimulatorPublicContractTest.java
@@ -2,9 +2,17 @@
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import java.time.Duration;
+import java.time.ZoneId;
+import java.util.Set;
 import org.junit.jupiter.api.Test;
+import com.ruoyi.device.api.DeviceAdapter;
+import com.ruoyi.device.bts.BtsDevelopmentSimulator;
+import com.ruoyi.device.bts.BtsDeviceAdapter;
+import com.ruoyi.device.bts.BtsXmlCodec;
 import com.ruoyi.device.bts.BtsTcpTransport;
 import com.ruoyi.device.bts.ScriptedBtsTcpSimulator;
+import com.ruoyi.device.model.DetectedChannel;
+import com.ruoyi.device.model.DeviceWorkStatus;
 
 class SimulatorPublicContractTest
 {
@@ -36,4 +44,97 @@
             assertEquals("payload#", simulator.requests().get(0));
         }
     }
+
+    @Test
+    void developmentSimulatorSupportsRepeatedDiscoveryAndObservation() throws Exception
+    {
+        try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0);
+             BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", simulator.port(),
+                     Duration.ofSeconds(1), Duration.ofSeconds(1));
+             DeviceAdapter adapter = new BtsDeviceAdapter("development-bts", transport,
+                     new BtsXmlCodec("development-bts", ZoneId.of("Asia/Hong_Kong"))))
+        {
+            var firstDiscovery = adapter.discoverChannels();
+            var secondDiscovery = adapter.discoverChannels();
+
+            assertEquals(4, firstDiscovery.size());
+            assertEquals(firstDiscovery, secondDiscovery);
+            assertEquals("开发模拟器", firstDiscovery.get(0).deviceMetadata().manufacturer());
+            assertEquals("BTS Virtual Cabinet", firstDiscovery.get(0).deviceMetadata().model());
+            assertEquals(1, firstDiscovery.stream().map(channel -> channel.identity().devid()).distinct().count());
+            assertEquals(4, firstDiscovery.stream().filter(channel -> channel.available()).count());
+
+            Set<com.ruoyi.device.model.ChannelIdentity> channels = firstDiscovery.stream()
+                    .map(channel -> channel.identity())
+                    .collect(java.util.stream.Collectors.toSet());
+            var firstObservation = adapter.inquire(channels);
+            var secondObservation = adapter.inquire(channels);
+
+            assertEquals(4, firstObservation.size());
+            assertEquals(DeviceWorkStatus.STOPPED, firstObservation.values().iterator().next().workStatus());
+            assertEquals(firstObservation.keySet(), secondObservation.keySet());
+        }
+    }
+
+    @Test
+    void developmentSimulatorPublishesOperatorControlledChannelFacts() throws Exception
+    {
+        try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0);
+             BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", simulator.port(),
+                     Duration.ofSeconds(1), Duration.ofSeconds(1));
+             DeviceAdapter adapter = new BtsDeviceAdapter("dev-sim", transport,
+                     new BtsXmlCodec("dev-sim", ZoneId.of("Asia/Hong_Kong"))))
+        {
+            simulator.updateChannel(1, 1, true, DeviceWorkStatus.WORKING);
+            simulator.updateChannel(1, 2, false, DeviceWorkStatus.PAUSED);
+
+            var discovered = adapter.discoverChannels();
+            Set<com.ruoyi.device.model.ChannelIdentity> available = discovered.stream()
+                    .filter(channel -> channel.available())
+                    .map(channel -> channel.identity())
+                    .collect(java.util.stream.Collectors.toSet());
+            var observed = adapter.inquire(available);
+
+            assertEquals(4, discovered.size());
+            assertEquals(3, available.size());
+            assertEquals(DeviceWorkStatus.WORKING, observed.entrySet().stream()
+                    .filter(entry -> entry.getKey().chlid() == 1)
+                    .findFirst().orElseThrow().getValue().workStatus());
+
+            simulator.updateDevice(1, "127.0.0.2", 4, false);
+            assertEquals(0, adapter.discoverChannels().stream().filter(channel -> channel.available()).count());
+            assertEquals(4, simulator.snapshot().devices().get(0).channels().size());
+            assertEquals(3, simulator.snapshot().recentRequests().size());
+        }
+    }
+
+    @Test
+    void developmentSimulatorSupportsMultipleConfigurableDevices() throws Exception
+    {
+        try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0);
+             BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", simulator.port(),
+                     Duration.ofSeconds(1), Duration.ofSeconds(1));
+             DeviceAdapter adapter = new BtsDeviceAdapter("dev-sim", transport,
+                     new BtsXmlCodec("dev-sim", ZoneId.of("Asia/Hong_Kong"))))
+        {
+            int secondDeviceId = simulator.addDevice("127.0.0.3", 2);
+            simulator.updateDevice(1, "127.0.0.2", 4, true);
+
+            var discovered = adapter.discoverChannels();
+
+            assertEquals(6, discovered.size());
+            assertEquals(2, discovered.stream().map(channel -> channel.identity().devid()).distinct().count());
+            assertEquals(4, discovered.stream().filter(channel -> channel.serverIp().equals("127.0.0.2")).count());
+            assertEquals(2, discovered.stream().filter(channel -> channel.serverIp().equals("127.0.0.3")).count());
+
+            simulator.deleteDevice(secondDeviceId);
+            for (int index = 0; index < 3; index++)
+            {
+                var withTombstone = adapter.discoverChannels();
+                assertEquals(6, withTombstone.size());
+                assertEquals(4, withTombstone.stream().filter(DetectedChannel::available).count());
+            }
+            assertEquals(4, adapter.discoverChannels().size());
+        }
+    }
 }
diff --git a/ruoyi-device/src/test/java/com/ruoyi/device/registry/BtsDeviceAdapterFactoryContractTest.java b/ruoyi-device/src/test/java/com/ruoyi/device/registry/BtsDeviceAdapterFactoryContractTest.java
index 322d22f..ff96455 100644
--- a/ruoyi-device/src/test/java/com/ruoyi/device/registry/BtsDeviceAdapterFactoryContractTest.java
+++ b/ruoyi-device/src/test/java/com/ruoyi/device/registry/BtsDeviceAdapterFactoryContractTest.java
@@ -2,6 +2,7 @@
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import java.util.List;
 import org.junit.jupiter.api.Test;
 import com.ruoyi.device.bts.BtsDeviceAdapterFactory;
@@ -19,8 +20,9 @@
             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>");
+            AdapterCapabilities capabilities = new AdapterCapabilities(true, true, false, false);
             registry.synchronize(new AdapterInstanceConfig("lab-bts", "BTS", "127.0.0.1", simulator.port(),
-                    5000, "Asia/Hong_Kong", true, AdapterCapabilities.observeOnly()));
+                    5000, "Asia/Hong_Kong", true, capabilities));
 
             var adapter = registry.execute("lab-bts", value -> value);
             var discovered = adapter.discoverChannels();
@@ -28,6 +30,10 @@
             assertEquals("22-1-2-3", discovered.get(0).identity().vendorKey());
             assertEquals("192.168.1.35", discovered.get(0).serverIp());
             assertFalse(adapter.capabilities().control());
+            assertFalse(adapter.capabilities().historyDownload());
+            assertThrows(UnsupportedOperationException.class,
+                    () -> adapter.inquireHistory(java.util.Set.of(discovered.get(0).identity())));
+            assertEquals(1, simulator.requests().size());
         }
     }
 }
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/TDetectionCabinet.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TDetectionCabinet.java
index 4610327..a7c6d26 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/TDetectionCabinet.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TDetectionCabinet.java
@@ -1,8 +1,8 @@
 package com.ruoyi.system.domain;
 
 import java.math.BigDecimal;
-import java.util.Date;
 import java.time.Instant;
+import java.util.Date;
 import com.fasterxml.jackson.annotation.JsonFormat;
 import jakarta.validation.constraints.Max;
 import jakarta.validation.constraints.Min;
@@ -30,6 +30,10 @@
     private Integer vendorDeviceType;
     private Integer vendorDeviceId;
     private Instant lastSeenAtUtc;
+    private String observedWorkStatus;
+
+    /** 登记状态:DISCOVERED 发现候选,REGISTERED 已登记。 */
+    private String registrationStatus;
 
     /** 全局唯一的设备编码。 */
     @NotBlank(message = "设备编码不能为空")
@@ -47,7 +51,6 @@
     private String ipAddress;
 
     /** 检测柜 MAC 地址,保存前统一为大写冒号分隔格式。 */
-    @NotBlank(message = "MAC地址不能为空")
     @Pattern(regexp = "^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$", message = "MAC地址格式不正确")
     private String macAddress;
 
@@ -107,6 +110,10 @@
     public void setVendorDeviceId(Integer value) { vendorDeviceId = value; }
     public Instant getLastSeenAtUtc() { return lastSeenAtUtc; }
     public void setLastSeenAtUtc(Instant value) { lastSeenAtUtc = value; }
+    public String getObservedWorkStatus() { return observedWorkStatus; }
+    public void setObservedWorkStatus(String value) { observedWorkStatus = value; }
+    public String getRegistrationStatus() { return registrationStatus; }
+    public void setRegistrationStatus(String value) { registrationStatus = value; }
 
     public void setCabinetId(Long cabinetId)
     {
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DiscoveredCabinetVo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DiscoveredCabinetVo.java
new file mode 100644
index 0000000..7e13073
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DiscoveredCabinetVo.java
@@ -0,0 +1,26 @@
+package com.ruoyi.system.domain.vo;
+
+/** 原检测柜选择弹窗使用的设备摘要。 */
+public record DiscoveredCabinetVo(
+        String deviceCode,
+        String ip,
+        String mac,
+        String name,
+        String manufacturer,
+        String model,
+        Integer channelCount,
+        String onlineStatus,
+        String testStatus,
+        String currentTest)
+{
+    public String getDeviceCode() { return deviceCode; }
+    public String getIp() { return ip; }
+    public String getMac() { return mac; }
+    public String getName() { return name; }
+    public String getManufacturer() { return manufacturer; }
+    public String getModel() { return model; }
+    public Integer getChannelCount() { return channelCount; }
+    public String getOnlineStatus() { return onlineStatus; }
+    public String getTestStatus() { return testStatus; }
+    public String getCurrentTest() { return currentTest; }
+}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TDetectionCabinetMapper.java b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TDetectionCabinetMapper.java
index bd249be..7f5409d 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TDetectionCabinetMapper.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TDetectionCabinetMapper.java
@@ -28,6 +28,13 @@
     List<TDetectionCabinet> selectDetectionCabinetList(TDetectionCabinet detectionCabinet);
 
     /**
+     * 查询设备选择弹窗需要的已发现检测柜事实。
+     *
+     * @return 已发现且未删除的检测柜
+     */
+    List<TDetectionCabinet> selectDiscoveredCabinetList();
+
+    /**
      * 根据设备编码查询未删除检测柜。
      *
      * @param cabinetCode 设备编码
@@ -44,12 +51,12 @@
     TDetectionCabinet selectByMacAddress(String macAddress);
 
     /**
-     * 新增检测柜。
+     * 将发现候选设备登记为正式检测柜。
      *
-     * @param detectionCabinet 检测柜信息
+     * @param detectionCabinet 候选主键及操作员填写的信息
      * @return 影响行数
      */
-    int insertDetectionCabinet(TDetectionCabinet detectionCabinet);
+    int registerDiscoveredCabinet(TDetectionCabinet detectionCabinet);
 
     /**
      * 修改检测柜基础信息。
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TDeviceAdapterMapper.java b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TDeviceAdapterMapper.java
index cdc4da5..e553cdc 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TDeviceAdapterMapper.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TDeviceAdapterMapper.java
@@ -13,6 +13,7 @@
     List<TDeviceAdapter> selectEnabledDeviceAdapters();
     TDeviceAdapter selectDeviceAdapterById(Long adapterId);
     TDeviceAdapter selectDeviceAdapterByCode(String adapterCode);
+    int upsertManagedDeviceAdapter(TDeviceAdapter adapter);
     int insertDeviceAdapter(TDeviceAdapter adapter);
     int updateDeviceAdapter(TDeviceAdapter adapter);
     int updateDeviceAdapterStatus(@Param("adapterId") Long adapterId, @Param("status") String status,
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/IDeviceAdapterService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/IDeviceAdapterService.java
index abca2da..6be5a52 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/service/IDeviceAdapterService.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/IDeviceAdapterService.java
@@ -13,6 +13,9 @@
     /** 查询设备适配器详情;实例不存在时返回稳定业务错误。 */
     TDeviceAdapter selectById(Long adapterId);
 
+    /** 幂等注册并启用由应用装配层托管的适配器配置。 */
+    TDeviceAdapter ensureManagedAdapter(TDeviceAdapter adapter);
+
     /** 新增默认禁用的只读设备适配器配置。 */
     int insert(TDeviceAdapter adapter);
 
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/ITDetectionCabinetService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/ITDetectionCabinetService.java
index 23d2746..a58babd 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/service/ITDetectionCabinetService.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/ITDetectionCabinetService.java
@@ -2,6 +2,7 @@
 
 import java.util.List;
 import com.ruoyi.system.domain.TDetectionCabinet;
+import com.ruoyi.system.domain.vo.DiscoveredCabinetVo;
 
 /**
  * 检测柜 Service 接口
@@ -27,7 +28,14 @@
     List<TDetectionCabinet> selectDetectionCabinetList(TDetectionCabinet detectionCabinet);
 
     /**
-     * 新增检测柜,并初始化在线、测试、启用及逻辑删除状态。
+     * 将已持久化的检测柜转换为原设备选择弹窗的数据合同。
+     *
+     * @return 可供选择的检测柜设备摘要
+     */
+    List<DiscoveredCabinetVo> selectDiscoveredCabinetList();
+
+    /**
+     * 将操作员选择的发现候选设备登记为正式检测柜。
      *
      * @param detectionCabinet 检测柜信息
      * @return 影响行数
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImpl.java
index 595b8e6..cc74b8f 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImpl.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImpl.java
@@ -11,6 +11,8 @@
 import java.util.stream.Collectors;
 import org.springframework.dao.DuplicateKeyException;
 import org.springframework.stereotype.Service;
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
 import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.common.utils.StringUtils;
 import com.ruoyi.device.api.DeviceAdapterException;
@@ -48,17 +50,12 @@
         this.persistence = persistence;
     }
 
-    DeviceAdapterServiceImpl(TDeviceAdapterMapper mapper, DeviceAdapterRegistry registry)
-    {
-        this(mapper, registry, new DeviceDiscoveryPersistenceService(mapper));
-    }
-
-    @Override
     /** 按管理端筛选条件查询适配器及其运行状态。 */
+    @Override
     public List<TDeviceAdapter> selectList(TDeviceAdapter query) { return mapper.selectDeviceAdapterList(query); }
 
-    @Override
     /** 查询适配器详情;实例不存在时抛出稳定业务错误。 */
+    @Override
     public TDeviceAdapter selectById(Long adapterId)
     {
         TDeviceAdapter adapter = mapper.selectDeviceAdapterById(adapterId);
@@ -66,8 +63,25 @@
         return adapter;
     }
 
+    /** 幂等注册应用托管的只读适配器;不预写检测柜或通道事实。 */
     @Override
+    public TDeviceAdapter ensureManagedAdapter(TDeviceAdapter adapter)
+    {
+        normalizeAndValidate(adapter);
+        AdapterCapabilities capabilities = parseCapabilities(adapter.getCapabilities());
+        if (capabilities.control())
+            throw error("当前里程碑只允许只读设备适配器", DeviceAdapterErrorCodes.INVALID_CONFIG);
+        adapter.setStatus(ENABLED);
+        adapter.setConnectionStatus("DISCONNECTED");
+        adapter.setDelFlag("0");
+        mapper.upsertManagedDeviceAdapter(adapter);
+        TDeviceAdapter registered = mapper.selectDeviceAdapterByCode(adapter.getAdapterCode());
+        if (registered == null) throw error("托管设备适配器注册失败", DeviceAdapterErrorCodes.NOT_FOUND);
+        return registered;
+    }
+
     /** 新增默认禁用的只读适配器配置。 */
+    @Override
     public int insert(TDeviceAdapter adapter)
     {
         normalizeAndValidate(adapter);
@@ -81,14 +95,14 @@
         catch (DuplicateKeyException exception) { throw error("适配器编码已存在", DeviceAdapterErrorCodes.DUPLICATE_CODE); }
     }
 
-    @Override
     /** 更新配置;已启用实例会同步运行时注册表。 */
+    @Override
     public int update(TDeviceAdapter adapter)
     {
         TDeviceAdapter current = selectById(adapter.getAdapterId());
         normalizeAndValidate(adapter);
         adapter.setAdapterCode(current.getAdapterCode());
-        adapter.setCapabilities(READ_ONLY_CAPABILITIES);
+        adapter.setCapabilities(current.getCapabilities());
         int rows = mapper.updateDeviceAdapter(adapter);
         if (ENABLED.equals(current.getStatus()))
         {
@@ -98,8 +112,8 @@
         return rows;
     }
 
-    @Override
     /** 启停适配器并同步其运行时实例。 */
+    @Override
     public int changeStatus(Long adapterId, boolean enabled, String updateBy)
     {
         TDeviceAdapter adapter = selectById(adapterId);
@@ -111,8 +125,8 @@
         return rows;
     }
 
-    @Override
     /** 执行一次发现和实时观测,并事务性写入通道事实。 */
+    @Override
     public AdapterDiscoveryResult discover(Long adapterId)
     {
         TDeviceAdapter adapter = selectById(adapterId);
@@ -127,7 +141,7 @@
                         .filter(DetectedChannel::available)
                         .map(DetectedChannel::identity)
                         .collect(Collectors.toCollection(LinkedHashSet::new));
-                return new DiscoveryObservation(channels, runtime.inquire(available));
+                return new DiscoveryObservation(channels, available.isEmpty() ? Map.of() : runtime.inquire(available));
             });
             Instant observedAt = Instant.now();
             AdapterDiscoveryResult result = persistence.persist(adapter, observation.channels(),
@@ -145,23 +159,23 @@
         }
     }
 
-    @Override
     /** 查询指定适配器已发现且未删除的通道。 */
+    @Override
     public List<TDetectionChannel> selectChannels(Long adapterId)
     {
         selectById(adapterId);
         return mapper.selectDetectionChannelsByAdapterId(adapterId);
     }
 
-    @Override
     /** 启动时恢复所有已启用适配器的运行时注册。 */
+    @Override
     public void synchronizeEnabledAdapters()
     {
         mapper.selectEnabledDeviceAdapters().forEach(adapter -> registry.synchronize(toRuntimeConfig(adapter)));
     }
 
-    @Override
     /** 轮询达到周期的已启用适配器,并持久化失败状态后继续调度。 */
+    @Override
     public void pollDueAdapters()
     {
         Instant now = Instant.now();
@@ -210,7 +224,16 @@
     {
         return new AdapterInstanceConfig(adapter.getAdapterCode(), adapter.getAdapterType(), adapter.getHost(),
                 adapter.getPort(), adapter.getPollIntervalMs(), adapter.getTimezoneId(),
-                ENABLED.equals(adapter.getStatus()), AdapterCapabilities.observeOnly());
+                ENABLED.equals(adapter.getStatus()), parseCapabilities(adapter.getCapabilities()));
+    }
+
+    private AdapterCapabilities parseCapabilities(String capabilities)
+    {
+        JSONObject value = JSON.parseObject(capabilities);
+        if (value == null) throw error("设备适配器能力配置不正确", DeviceAdapterErrorCodes.INVALID_CONFIG);
+        return new AdapterCapabilities(value.getBooleanValue("discovery"),
+                value.getBooleanValue("realtimeObservation"), value.getBooleanValue("historyDownload"),
+                value.getBooleanValue("control"));
     }
 
     private ServiceException error(String message, int code) { return new ServiceException(message, code); }
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceDiscoveryPersistenceService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceDiscoveryPersistenceService.java
index 91d84ff..b9e9cdf 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceDiscoveryPersistenceService.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceDiscoveryPersistenceService.java
@@ -10,6 +10,7 @@
 import org.springframework.transaction.annotation.Transactional;
 import com.ruoyi.device.model.DetectedChannel;
 import com.ruoyi.device.model.ChannelIdentity;
+import com.ruoyi.device.model.DetectedDeviceMetadata;
 import com.ruoyi.device.model.RealtimeSnapshot;
 import com.ruoyi.system.domain.TDetectionCabinet;
 import com.ruoyi.system.domain.TDetectionChannel;
@@ -55,7 +56,13 @@
         cabinet.setCabinetId(cabinetId);
         cabinet.setAdapterId(adapter.getAdapterId());
         cabinet.setCabinetCode(adapter.getAdapterCode() + "-" + key.devtype() + "-" + key.devid());
-        cabinet.setCabinetName(adapter.getAdapterName() + " " + key.devtype() + "-" + key.devid());
+        DetectedDeviceMetadata metadata = channels.get(0).deviceMetadata();
+        cabinet.setCabinetName(metadata.name() == null
+                ? adapter.getAdapterName() + " " + key.devtype() + "-" + key.devid()
+                : metadata.name());
+        cabinet.setManufacturer(metadata.manufacturer());
+        cabinet.setModel(metadata.model());
+        cabinet.setMacAddress(metadata.macAddress());
         cabinet.setCapacity(BigDecimal.ONE);
         cabinet.setChannelCount(channels.size());
         cabinet.setVendorDeviceType(key.devtype());
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TDetectionCabinetServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TDetectionCabinetServiceImpl.java
index 989c5ce..16e3491 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TDetectionCabinetServiceImpl.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TDetectionCabinetServiceImpl.java
@@ -12,6 +12,7 @@
 import com.ruoyi.common.utils.StringUtils;
 import com.ruoyi.common.utils.ip.IpUtils;
 import com.ruoyi.system.domain.TDetectionCabinet;
+import com.ruoyi.system.domain.vo.DiscoveredCabinetVo;
 import com.ruoyi.system.mapper.TDetectionCabinetMapper;
 import com.ruoyi.system.service.ITDetectionCabinetService;
 
@@ -26,6 +27,8 @@
     private static final String NORMAL = "0";
     private static final String DISABLED = "1";
     private static final String TESTING = "1";
+    private static final String ACTIVE = "ACTIVE";
+    private static final String DISCOVERED = "DISCOVERED";
 
     private final TDetectionCabinetMapper detectionCabinetMapper;
 
@@ -73,7 +76,39 @@
     }
 
     /**
-     * 新增检测柜,统一基础字段格式并校验 IP、设备编码和 MAC 地址后初始化运行状态。
+     * 复用已持久化检测柜事实供原选择设备弹窗展示,不生成模拟设备。
+     * 测试占用状态由已持久化通道工作状态聚合;测试归属留待后续绑定闭环确认。
+     *
+     * @return 可供选择的检测柜设备摘要
+     */
+    @Override
+    public List<DiscoveredCabinetVo> selectDiscoveredCabinetList()
+    {
+        return detectionCabinetMapper.selectDiscoveredCabinetList().stream()
+                .map(cabinet -> {
+                    boolean active = ACTIVE.equals(cabinet.getObservedWorkStatus());
+                    return new DiscoveredCabinetVo(
+                            cabinet.getCabinetCode(),
+                            cabinet.getIpAddress(),
+                            cabinet.getMacAddress(),
+                            cabinet.getCabinetName(),
+                            cabinet.getManufacturer(),
+                            cabinet.getModel(),
+                            cabinet.getChannelCount(),
+                            isOnline(cabinet.getOnlineStatus()) ? "在线" : "离线",
+                            active ? "测试中" : "空闲",
+                            active ? "待确认" : "-");
+                })
+                .toList();
+    }
+
+    private boolean isOnline(String onlineStatus)
+    {
+        return NORMAL.equals(onlineStatus) || "ONLINE".equalsIgnoreCase(onlineStatus);
+    }
+
+    /**
+     * 将操作员选择的发现候选设备登记为检测柜,并保留其适配器和通道身份。
      *
      * @param detectionCabinet 检测柜信息
      * @return 影响行数
@@ -83,15 +118,23 @@
     {
         normalizeBasicFields(detectionCabinet);
         validateIpAddress(detectionCabinet.getIpAddress());
+        TDetectionCabinet candidate = detectionCabinetMapper.selectByCabinetCode(detectionCabinet.getCabinetCode());
+        if (candidate == null)
+        {
+            throw new ServiceException("请先选择发现候选设备");
+        }
+        if (!DISCOVERED.equals(candidate.getRegistrationStatus()))
+        {
+            throw new ServiceException("设备编码已存在");
+        }
+        detectionCabinet.setCabinetId(candidate.getCabinetId());
         validateUniqueFields(detectionCabinet);
-        detectionCabinet.setOnlineStatus(NORMAL);
-        detectionCabinet.setTestStatus(NORMAL);
-        detectionCabinet.setCurrentTest(null);
         detectionCabinet.setStatus(NORMAL);
-        detectionCabinet.setDelFlag(NORMAL);
         try
         {
-            return detectionCabinetMapper.insertDetectionCabinet(detectionCabinet);
+            int rows = detectionCabinetMapper.registerDiscoveredCabinet(detectionCabinet);
+            if (rows == 0) throw new ServiceException("发现候选设备状态已变化,请重新选择");
+            return rows;
         }
         catch (DuplicateKeyException exception)
         {
@@ -204,6 +247,10 @@
     @Override
     public boolean checkMacAddressUnique(TDetectionCabinet detectionCabinet)
     {
+        if (StringUtils.isBlank(detectionCabinet.getMacAddress()))
+        {
+            return UserConstants.UNIQUE;
+        }
         Long cabinetId = StringUtils.isNull(detectionCabinet.getCabinetId()) ? -1L : detectionCabinet.getCabinetId();
         TDetectionCabinet info = detectionCabinetMapper.selectByMacAddress(detectionCabinet.getMacAddress());
         if (StringUtils.isNotNull(info) && info.getCabinetId().longValue() != cabinetId.longValue())
@@ -238,7 +285,8 @@
 
     private String normalizeMacAddress(String macAddress)
     {
-        return StringUtils.trim(macAddress).replace('-', ':').toUpperCase(Locale.ROOT);
+        String normalized = StringUtils.trim(macAddress);
+        return StringUtils.isBlank(normalized) ? null : normalized.replace('-', ':').toUpperCase(Locale.ROOT);
     }
 
     private void validateIpAddress(String ipAddress)
diff --git a/ruoyi-system/src/main/resources/mapper/system/TDetectionCabinetMapper.xml b/ruoyi-system/src/main/resources/mapper/system/TDetectionCabinetMapper.xml
index 22ad494..07efe78 100644
--- a/ruoyi-system/src/main/resources/mapper/system/TDetectionCabinetMapper.xml
+++ b/ruoyi-system/src/main/resources/mapper/system/TDetectionCabinetMapper.xml
@@ -24,20 +24,49 @@
         <result property="updateTime" column="update_time" />
         <result property="remark" column="remark" />
         <result property="delFlag" column="del_flag" />
+        <result property="registrationStatus" column="registration_status" />
     </resultMap>
+
+    <resultMap type="TDetectionCabinet" id="DiscoveredCabinetResult">
+        <id property="cabinetId" column="cabinet_id" />
+        <result property="cabinetCode" column="cabinet_code" />
+        <result property="ipAddress" column="ip_address" />
+        <result property="macAddress" column="mac_address" />
+        <result property="cabinetName" column="cabinet_name" />
+        <result property="manufacturer" column="manufacturer" />
+        <result property="model" column="model" />
+        <result property="channelCount" column="channel_count" />
+        <result property="onlineStatus" column="online_status" />
+        <result property="observedWorkStatus" column="observed_work_status" />
+    </resultMap>
+
+    <sql id="activeCabinetChannelExists">
+        exists (
+            select 1 from t_detection_channel channel_fact
+            where channel_fact.cabinet_id = cabinet.cabinet_id
+              and channel_fact.del_flag = 0
+              and channel_fact.work_status in (
+                  'WORKING', 'PAUSED', 'PROTECTED', 'SYNCHRONIZING', 'LIT',
+                  'WAITING_TIMEOUT', 'WAITING_START', 'LOCKED'
+              )
+        )
+    </sql>
 
     <sql id="selectDetectionCabinetVo">
         select cabinet_id, cabinet_code, cabinet_name, ip_address, mac_address,
-               manufacturer, model, capacity, channel_count, online_status, test_status,
-               current_test, status, create_by, create_time, update_by, update_time,
-               remark, del_flag
-        from t_detection_cabinet
+               manufacturer, model, capacity, channel_count,
+               case when upper(online_status) = 'ONLINE' or online_status = '0' then '0' else '1' end as online_status,
+               case when <include refid="activeCabinetChannelExists"/> then '1' else '0' end as test_status,
+               case when <include refid="activeCabinetChannelExists"/> then '待确认' else null end as current_test,
+               status, create_by, create_time, update_by, update_time, remark, del_flag, registration_status
+        from t_detection_cabinet cabinet
     </sql>
 
     <select id="selectDetectionCabinetList" parameterType="TDetectionCabinet" resultMap="TDetectionCabinetResult">
         <include refid="selectDetectionCabinetVo"/>
         <where>
             del_flag = '0'
+            and registration_status = 'REGISTERED'
             <if test="device != null and device != ''">
                 and (
                     ip_address like concat('%', #{device}, '%')
@@ -65,10 +94,17 @@
                 and mac_address = #{macAddress}
             </if>
             <if test="onlineStatus != null and onlineStatus != ''">
-                and online_status = #{onlineStatus}
+                and (case when upper(online_status) = 'ONLINE' or online_status = '0' then '0' else '1' end) = #{onlineStatus}
             </if>
             <if test="testStatus != null and testStatus != ''">
-                and test_status = #{testStatus}
+                <choose>
+                    <when test='testStatus.equals("1")'>
+                        and <include refid="activeCabinetChannelExists"/>
+                    </when>
+                    <otherwise>
+                        and not <include refid="activeCabinetChannelExists"/>
+                    </otherwise>
+                </choose>
             </if>
             <if test="status != null and status != ''">
                 and status = #{status}
@@ -77,9 +113,20 @@
         order by create_time desc, cabinet_id desc
     </select>
 
+    <select id="selectDiscoveredCabinetList" resultMap="DiscoveredCabinetResult">
+        select cabinet.cabinet_id, cabinet.cabinet_code, cabinet.cabinet_name, cabinet.ip_address,
+               cabinet.mac_address, cabinet.manufacturer, cabinet.model, cabinet.channel_count,
+               cabinet.online_status,
+               case when <include refid="activeCabinetChannelExists"/>
+                    then 'ACTIVE' else 'IDLE' end as observed_work_status
+        from t_detection_cabinet cabinet
+        where cabinet.del_flag = 0 and cabinet.registration_status = 'DISCOVERED'
+        order by cabinet.last_seen_at_utc desc, cabinet.cabinet_id desc
+    </select>
+
     <select id="selectDetectionCabinetById" parameterType="Long" resultMap="TDetectionCabinetResult">
         <include refid="selectDetectionCabinetVo"/>
-        where cabinet_id = #{cabinetId} and del_flag = '0'
+        where cabinet_id = #{cabinetId} and del_flag = '0' and registration_status = 'REGISTERED'
     </select>
 
     <select id="selectByCabinetCode" parameterType="String" resultMap="TDetectionCabinetResult">
@@ -92,17 +139,15 @@
         where mac_address = #{macAddress} and del_flag = '0' limit 1
     </select>
 
-    <insert id="insertDetectionCabinet" parameterType="TDetectionCabinet" useGeneratedKeys="true" keyProperty="cabinetId">
-        insert into t_detection_cabinet(
-            cabinet_code, cabinet_name, ip_address, mac_address, manufacturer, model, capacity,
-            channel_count, online_status, test_status, current_test, status,
-            create_by, create_time, remark, del_flag
-        ) values (
-            #{cabinetCode}, #{cabinetName}, #{ipAddress}, #{macAddress}, #{manufacturer}, #{model}, #{capacity},
-            #{channelCount}, #{onlineStatus}, #{testStatus}, #{currentTest}, #{status},
-            #{createBy}, sysdate(), #{remark}, #{delFlag}
-        )
-    </insert>
+    <update id="registerDiscoveredCabinet" parameterType="TDetectionCabinet">
+        update t_detection_cabinet
+        set cabinet_name = #{cabinetName}, ip_address = #{ipAddress}, mac_address = #{macAddress},
+            manufacturer = #{manufacturer}, model = #{model}, capacity = #{capacity},
+            channel_count = #{channelCount}, status = #{status}, registration_status = 'REGISTERED',
+            create_by = #{createBy}, create_time = sysdate(), update_by = #{createBy},
+            update_time = sysdate(), remark = #{remark}
+        where cabinet_id = #{cabinetId} and del_flag = '0' and registration_status = 'DISCOVERED'
+    </update>
 
     <update id="updateDetectionCabinet" parameterType="TDetectionCabinet">
         update t_detection_cabinet
@@ -119,19 +164,19 @@
             update_by = #{updateBy},
             update_time = sysdate()
         </set>
-        where cabinet_id = #{cabinetId} and del_flag = '0'
+        where cabinet_id = #{cabinetId} and del_flag = '0' and registration_status = 'REGISTERED'
     </update>
 
     <update id="updateDetectionCabinetStatus" parameterType="TDetectionCabinet">
         update t_detection_cabinet
         set status = #{status}, update_by = #{updateBy}, update_time = sysdate()
-        where cabinet_id = #{cabinetId} and del_flag = '0'
+        where cabinet_id = #{cabinetId} and del_flag = '0' and registration_status = 'REGISTERED'
     </update>
 
     <update id="deleteDetectionCabinetById">
         update t_detection_cabinet
         set del_flag = cast(cabinet_id as char), update_by = #{updateBy}, update_time = sysdate()
-        where cabinet_id = #{cabinetId} and del_flag = '0'
+        where cabinet_id = #{cabinetId} and del_flag = '0' and registration_status = 'REGISTERED'
     </update>
 
 </mapper>
diff --git a/ruoyi-system/src/main/resources/mapper/system/TDeviceAdapterMapper.xml b/ruoyi-system/src/main/resources/mapper/system/TDeviceAdapterMapper.xml
index eea0964..d4460b5 100644
--- a/ruoyi-system/src/main/resources/mapper/system/TDeviceAdapterMapper.xml
+++ b/ruoyi-system/src/main/resources/mapper/system/TDeviceAdapterMapper.xml
@@ -73,6 +73,20 @@
         select <include refid="adapterColumns"/> from t_device_adapter where adapter_code = #{adapterCode} and del_flag = 0 limit 1
     </select>
 
+    <!-- 应用装配层提供托管配置;持久化层仅负责按稳定编码幂等恢复。 -->
+    <insert id="upsertManagedDeviceAdapter" parameterType="TDeviceAdapter">
+        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, update_by, update_time,
+            remark, del_flag)
+        values(#{adapterCode}, #{adapterName}, #{adapterType}, #{host}, #{port}, #{pollIntervalMs}, #{timezoneId},
+            #{capabilities}, '0', 'DISCONNECTED', 'system', sysdate(), 'system', sysdate(), #{remark}, 0)
+        on duplicate key update adapter_name=values(adapter_name), adapter_type=values(adapter_type),
+            host=values(host), port=values(port), poll_interval_ms=values(poll_interval_ms),
+            timezone_id=values(timezone_id), capabilities=values(capabilities), status='0',
+            connection_status='DISCONNECTED', last_error_code=null, last_error_message=null,
+            update_by='system', update_time=sysdate(), remark=values(remark), del_flag=0
+    </insert>
+
     <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)
@@ -112,15 +126,16 @@
     </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 into t_detection_cabinet(adapter_id, cabinet_code, cabinet_name, manufacturer, model, capacity,
+            channel_count, vendor_device_type, vendor_device_id, ip_address, mac_address, online_status,
+            last_seen_at_utc, registration_status, status, create_by, create_time, del_flag)
+        values(#{adapterId}, #{cabinetCode}, #{cabinetName}, #{manufacturer}, #{model}, 1,
+            #{channelCount}, #{vendorDeviceType}, #{vendorDeviceId}, #{ipAddress}, #{macAddress},
+            #{onlineStatus}, #{lastSeenAtUtc}, 'DISCOVERED', '0', 'system', sysdate(), 0)
     </insert>
 
     <update id="updateDetectedCabinet" parameterType="TDetectionCabinet">
-        update t_detection_cabinet set channel_count=greatest(channel_count, #{channelCount}), ip_address=#{ipAddress},
+        update t_detection_cabinet set 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>
diff --git a/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml b/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
index c976847..958534d 100644
--- a/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
+++ b/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
@@ -110,6 +110,6 @@
     <select id="countTestTypeEnabled" resultType="int">select count(1) from t_test_type where test_type_id=#{testTypeId} and status='0' and del_flag='0'</select>
     <select id="countUserEnabled" resultType="int">select count(1) from sys_user where user_id=#{userId} and status='0' and del_flag='0'</select>
     <select id="countBatteryInEnabledGroup" resultType="int">select count(1) from t_battery_group_battery b inner join t_battery_group g on g.group_id=b.group_id and g.status='0' and g.del_flag='0' where b.group_id=#{groupId} and b.battery_no=#{batteryNo} and b.del_flag='0'</select>
-    <select id="countCabinetEnabled" resultType="int">select count(1) from t_detection_cabinet where cabinet_id=#{cabinetId} and status='0' and del_flag='0'</select>
+    <select id="countCabinetEnabled" resultType="int">select count(1) from t_detection_cabinet where cabinet_id=#{cabinetId} and registration_status='REGISTERED' and status='0' and del_flag='0'</select>
     <select id="countCollectorEnabled" resultType="int">select count(1) from t_collector where collector_id=#{collectorId} and cabinet_id=#{cabinetId} and status='0' and del_flag='0'</select>
 </mapper>

--
Gitblit v1.7.1