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