From a2a8908f2d9d4e19201423ff58ee23c65a8df670 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期六, 29 八月 2026 20:51:10 +0800
Subject: [PATCH] 实现测试原子开始接收
---
ruoyi-admin/src/test/java/com/ruoyi/config/FlywayMigrationContractTest.java | 7
ruoyi-system/src/main/java/com/ruoyi/system/domain/TTest.java | 4
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TTestServiceImpl.java | 147 ++++++++++++++++-
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TTestController.java | 13 +
ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TTestServiceImplTest.java | 138 +++++++++++++++++
ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/TTestControllerContractTest.java | 4
ruoyi-admin/src/main/resources/db/migration/V14__test_reception_intervals.sql | 50 ++++++
ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml | 52 ++++++
ruoyi-system/src/main/java/com/ruoyi/system/service/ITTestService.java | 5
ruoyi-system/src/main/java/com/ruoyi/system/mapper/TTestMapper.java | 18 ++
ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestReceptionInterval.java | 42 +++++
11 files changed, 463 insertions(+), 17 deletions(-)
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TTestController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TTestController.java
index 4ebf19a..c1cd2e5 100644
--- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TTestController.java
+++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TTestController.java
@@ -81,7 +81,18 @@
@PutMapping("/start")
public AjaxResult start(@RequestParam Long testId)
{
- return toAjax(testService.startTest(testId, getUsername()));
+ testService.startReception(testId, getUserId(), getUsername());
+ return success(testService.selectTestById(testId));
+ }
+
+ /** 独立的开始接收动作;只改变系统数据归属,不向设备发送控制命令。 */
+ @PreAuthorize("@ss.hasPermi('system:test:start')")
+ @Log(title = "测试数据接收", businessType = BusinessType.UPDATE)
+ @PostMapping("/{testId}/reception/start")
+ public AjaxResult startReception(@PathVariable Long testId)
+ {
+ testService.startReception(testId, getUserId(), getUsername());
+ return success(testService.selectTestById(testId));
}
@PreAuthorize("@ss.hasPermi('system:test:remove')")
diff --git a/ruoyi-admin/src/main/resources/db/migration/V14__test_reception_intervals.sql b/ruoyi-admin/src/main/resources/db/migration/V14__test_reception_intervals.sql
new file mode 100644
index 0000000..d3e971a
--- /dev/null
+++ b/ruoyi-admin/src/main/resources/db/migration/V14__test_reception_intervals.sql
@@ -0,0 +1,50 @@
+-- Ticket08:记录每个数据来源的实际接收边界,并以独占租约防止并发重叠。
+CREATE TABLE t_test_reception_interval (
+ interval_id BIGINT NOT NULL AUTO_INCREMENT,
+ test_id BIGINT NOT NULL,
+ binding_id BIGINT NULL,
+ source_type VARCHAR(32) NOT NULL,
+ source_id BIGINT NOT NULL,
+ source_channel_code VARCHAR(64) NULL,
+ action_at_utc DATETIME(3) NOT NULL,
+ start_boundary_at_utc DATETIME(3) NOT NULL,
+ end_boundary_at_utc DATETIME(3) NULL,
+ boundary_note VARCHAR(255) NULL,
+ interval_status VARCHAR(32) NOT NULL DEFAULT 'OPEN',
+ PRIMARY KEY (interval_id),
+ KEY idx_reception_interval_test (test_id, source_type, source_id, action_at_utc),
+ KEY idx_reception_interval_source (source_type, source_id, source_channel_code, start_boundary_at_utc),
+ CONSTRAINT fk_reception_interval_test FOREIGN KEY (test_id) REFERENCES t_test (test_id),
+ CONSTRAINT fk_reception_interval_binding FOREIGN KEY (binding_id) REFERENCES t_test_channel_binding (binding_id),
+ CONSTRAINT chk_reception_interval_source CHECK (source_type IN ('CABINET_CHANNEL', 'COLLECTOR_CHANNEL')),
+ CONSTRAINT chk_reception_interval_status CHECK (interval_status IN ('OPEN', 'CLOSED'))
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='测试数据接收区间';
+
+CREATE TABLE t_collector_channel_lease (
+ collector_id BIGINT NOT NULL,
+ channel_code VARCHAR(64) NOT NULL,
+ test_id BIGINT NOT NULL,
+ binding_id BIGINT NOT NULL,
+ acquired_by BIGINT NULL,
+ acquired_at_utc DATETIME(3) NOT NULL,
+ PRIMARY KEY (collector_id, channel_code),
+ KEY idx_collector_lease_test (test_id),
+ CONSTRAINT fk_collector_lease_collector FOREIGN KEY (collector_id) REFERENCES t_collector (collector_id),
+ CONSTRAINT fk_collector_lease_test FOREIGN KEY (test_id) REFERENCES t_test (test_id),
+ CONSTRAINT fk_collector_lease_binding FOREIGN KEY (binding_id) REFERENCES t_test_channel_binding (binding_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='活动采集器通道独占锁';
+
+ALTER TABLE t_test
+ ADD KEY idx_test_reception_status (reception_status, test_status);
+
+-- 为独立动作补充按钮权限;仅写系统菜单元数据,不导入演示业务数据。
+SET @reception_menu_id := (SELECT COALESCE(MAX(menu_id), 0) + 1 FROM sys_menu);
+INSERT INTO sys_menu
+ (menu_id, menu_name, parent_id, order_num, path, component, query, route_name,
+ is_frame, is_cache, menu_type, visible, status, perms, icon,
+ create_by, create_time, update_by, update_time, remark)
+SELECT @reception_menu_id, '开始接收', menu_id, 6, '', NULL, NULL, '', 1, 0, 'F', '0', '0',
+ 'system:test:start', '#', 'admin', NOW(), '', NULL, '测试开始接收权限'
+FROM sys_menu
+WHERE perms = 'system:test:list'
+LIMIT 1;
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 6031b61..a248f85 100644
--- a/ruoyi-admin/src/test/java/com/ruoyi/config/FlywayMigrationContractTest.java
+++ b/ruoyi-admin/src/test/java/com/ruoyi/config/FlywayMigrationContractTest.java
@@ -108,6 +108,13 @@
assertTrue(v13.contains("CREATE TABLE t_test_field_snapshot"));
assertFalse(v13.matches("(?s).*\\bINSERT\\s+INTO\\b.*"));
assertFalse(v13.matches("(?s).*\\bUPDATE\\s+t_test\\b.*"));
+
+ String v14 = resource("db/migration/V14__test_reception_intervals.sql");
+ assertTrue(v14.contains("CREATE TABLE t_test_reception_interval"));
+ assertTrue(v14.contains("start_boundary_at_utc"));
+ assertTrue(v14.contains("CREATE TABLE t_collector_channel_lease"));
+ assertTrue(v14.contains("idx_test_reception_status"));
+ assertTrue(v14.contains("system:test:start"));
}
private static String sha256(String path)
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TTestServiceImplTest.java b/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TTestServiceImplTest.java
index 291e569..04ec96a 100644
--- a/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TTestServiceImplTest.java
+++ b/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TTestServiceImplTest.java
@@ -15,11 +15,13 @@
import java.math.BigDecimal;
import java.util.Collections;
import java.util.LinkedHashMap;
+import java.time.Instant;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.dao.DuplicateKeyException;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.system.domain.TTest;
import com.ruoyi.system.domain.TTestBattery;
@@ -28,6 +30,7 @@
import com.ruoyi.system.domain.TTestFieldSnapshot;
import com.ruoyi.system.domain.TTestPlanStep;
import com.ruoyi.system.domain.TTestWarningThreshold;
+import com.ruoyi.system.domain.TDetectionChannel;
import com.ruoyi.system.mapper.TTestMapper;
import com.ruoyi.system.service.ISharedFieldSnapshotService;
@@ -350,6 +353,141 @@
verify(testMapper).insertTestAudit(eq(10L), eq("TEST_DELETED"), eq(7L), eq("operator"), any(), isNull());
}
+ @Test
+ void startReceptionShouldOpenAllSourceIntervalsAtomically()
+ {
+ TTest current = validTest();
+ current.setTestId(10L);
+ current.setReceptionStatus("NOT_STARTED");
+ current.getBatteries().get(0).getBinding().setBindingId(30L);
+ when(testMapper.selectTestByIdForUpdate(10L)).thenReturn(current);
+ TDetectionChannel channel = new TDetectionChannel();
+ channel.setChannelId(100L);
+ channel.setOnlineStatus("ONLINE");
+ channel.setLastSeenAtUtc(Instant.parse("2026-08-29T12:00:00Z"));
+ when(testMapper.selectReceptionChannelForUpdate(100L)).thenReturn(channel);
+ when(testMapper.countCollectorEnabled(8L)).thenReturn(1);
+ when(testMapper.selectCollectorChannelCount(8L)).thenReturn(4);
+ when(testMapper.collectorOnline(8L)).thenReturn(true);
+ when(testMapper.updateReceptionStarted(eq(10L), any(), eq("operator"))).thenReturn(1);
+
+ assertEquals(1, testService.startReception(10L, 7L, "operator"));
+ verify(testMapper).insertChannelLease(eq(100L), eq(10L), eq(30L), eq(7L), any());
+ verify(testMapper, times(3)).insertCollectorChannelLease(eq(8L), any(), eq(10L), eq(30L), eq(7L), any());
+ verify(testMapper, times(4)).insertReceptionInterval(any());
+ verify(testMapper).insertTestAudit(eq(10L), eq("RECEPTION_STARTED"), eq(7L), eq("operator"), isNull(), any());
+ }
+
+ @Test
+ void startReceptionShouldMarkRunningCabinetAttachmentWithoutControlCommand()
+ {
+ TTest current = validTest();
+ current.setTestId(10L);
+ current.setReceptionStatus("NOT_STARTED");
+ current.getBatteries().get(0).getBinding().setBindingId(30L);
+ when(testMapper.selectTestByIdForUpdate(10L)).thenReturn(current);
+ TDetectionChannel channel = new TDetectionChannel();
+ channel.setChannelId(100L);
+ channel.setOnlineStatus("ONLINE");
+ channel.setWorkStatus("WORKING");
+ channel.setLastSeenAtUtc(Instant.parse("2026-08-29T12:00:00Z"));
+ when(testMapper.selectReceptionChannelForUpdate(100L)).thenReturn(channel);
+ when(testMapper.countCollectorEnabled(8L)).thenReturn(1);
+ when(testMapper.selectCollectorChannelCount(8L)).thenReturn(4);
+ when(testMapper.collectorOnline(8L)).thenReturn(true);
+ when(testMapper.updateReceptionStarted(eq(10L), any(), eq("operator"))).thenReturn(1);
+
+ assertEquals(1, testService.startReception(10L, 7L, "operator"));
+ verify(testMapper).insertTestAudit(eq(10L), eq("RECEPTION_STARTED"), eq(7L), eq("operator"), isNull(),
+ org.mockito.ArgumentMatchers.argThat(value -> value != null && value.contains("runtimeAttach")
+ && value.contains("运行中接入")));
+ }
+
+ @Test
+ void startReceptionShouldRollbackWhenDatabaseLeaseIsTaken()
+ {
+ TTest current = validTest();
+ current.setTestId(10L);
+ current.setReceptionStatus("NOT_STARTED");
+ current.getBatteries().get(0).getBinding().setBindingId(30L);
+ when(testMapper.selectTestByIdForUpdate(10L)).thenReturn(current);
+ TDetectionChannel channel = new TDetectionChannel();
+ channel.setChannelId(100L);
+ channel.setOnlineStatus("ONLINE");
+ channel.setLastSeenAtUtc(Instant.parse("2026-08-29T12:00:00Z"));
+ when(testMapper.selectReceptionChannelForUpdate(100L)).thenReturn(channel);
+ when(testMapper.countCollectorEnabled(8L)).thenReturn(1);
+ when(testMapper.selectCollectorChannelCount(8L)).thenReturn(4);
+ when(testMapper.collectorOnline(8L)).thenReturn(true);
+ when(testMapper.insertChannelLease(any(), any(), any(), any(), any()))
+ .thenThrow(new DuplicateKeyException("lease already exists"));
+
+ ServiceException exception = assertThrows(ServiceException.class,
+ () -> testService.startReception(10L, 7L, "operator"));
+
+ assertEquals("数据来源已被其他测试占用,请刷新后重试", exception.getMessage());
+ verify(testMapper, never()).insertReceptionInterval(any());
+ verify(testMapper, never()).updateReceptionStarted(any(), any(), any());
+ verify(testMapper, never()).insertTestAudit(anyLong(), any(), anyLong(), any(), any(), any());
+ }
+
+ @Test
+ void startReceptionShouldRejectOfflineSourceWithoutWritingAnything()
+ {
+ TTest current = validTest();
+ current.setTestId(10L);
+ current.setReceptionStatus("NOT_STARTED");
+ when(testMapper.selectTestByIdForUpdate(10L)).thenReturn(current);
+ TDetectionChannel channel = new TDetectionChannel();
+ channel.setChannelId(100L);
+ channel.setOnlineStatus("OFFLINE");
+ when(testMapper.selectReceptionChannelForUpdate(100L)).thenReturn(channel);
+
+ ServiceException exception = assertThrows(ServiceException.class,
+ () -> testService.startReception(10L, 7L, "operator"));
+
+ assertEquals("检测柜通道 100 当前离线或不可观测", exception.getMessage());
+ verify(testMapper, never()).insertChannelLease(any(), any(), any(), any(), any());
+ verify(testMapper, never()).insertReceptionInterval(any());
+ verify(testMapper, never()).updateReceptionStarted(any(), any(), any());
+ }
+
+ @Test
+ void startReceptionShouldRejectIncompleteCollectorMappingsBeforeWriting()
+ {
+ TTest current = validTest();
+ current.setTestId(10L);
+ current.setReceptionStatus("NOT_STARTED");
+ current.getBatteries().get(0).getBinding().setBindingId(30L);
+ current.getBatteries().get(0).setCollectorChannels(Collections.emptyList());
+ when(testMapper.selectTestByIdForUpdate(10L)).thenReturn(current);
+ TDetectionChannel channel = new TDetectionChannel();
+ channel.setChannelId(100L);
+ channel.setOnlineStatus("ONLINE");
+ channel.setLastSeenAtUtc(Instant.parse("2026-08-29T12:00:00Z"));
+ when(testMapper.selectReceptionChannelForUpdate(100L)).thenReturn(channel);
+ when(testMapper.countCollectorEnabled(8L)).thenReturn(1);
+ when(testMapper.collectorOnline(8L)).thenReturn(true);
+ when(testMapper.selectCollectorChannelCount(8L)).thenReturn(4);
+
+ ServiceException exception = assertThrows(ServiceException.class,
+ () -> testService.startReception(10L, 7L, "operator"));
+
+ assertEquals("每个电池号必须绑定主温度、正电位主通道和负电位主通道", exception.getMessage());
+ verify(testMapper, never()).insertChannelLease(any(), any(), any(), any(), any());
+ verify(testMapper, never()).updateReceptionStarted(any(), any(), any());
+ }
+
+ @Test
+ void startReceptionShouldRejectMissingOperatorExplicitly()
+ {
+ ServiceException exception = assertThrows(ServiceException.class,
+ () -> testService.startReception(10L, null, "operator"));
+
+ assertEquals("操作人不能为空", exception.getMessage());
+ verify(testMapper, never()).selectTestByIdForUpdate(anyLong());
+ }
+
private void mockEnabledOptions()
{
mockEnabledPeopleAndType();
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/TTestControllerContractTest.java b/ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/TTestControllerContractTest.java
index 29c10c0..8db451e 100644
--- a/ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/TTestControllerContractTest.java
+++ b/ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/TTestControllerContractTest.java
@@ -3,6 +3,7 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.lang.reflect.Method;
import org.junit.jupiter.api.Test;
+import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.security.access.prepost.PreAuthorize;
class TTestControllerContractTest
@@ -14,6 +15,9 @@
assertPermission("getInfo", "@ss.hasPermi('system:test:query')", Long.class);
assertPermission("add", "@ss.hasPermi('system:test:add')", com.ruoyi.system.domain.TTest.class);
assertPermission("edit", "@ss.hasPermi('system:test:edit')", com.ruoyi.system.domain.TTest.class);
+ assertPermission("startReception", "@ss.hasPermi('system:test:start')", Long.class);
+ Method startReception = TTestController.class.getMethod("startReception", Long.class);
+ assertEquals("/{testId}/reception/start", startReception.getAnnotation(PostMapping.class).value()[0]);
}
private void assertPermission(String methodName, String permission, Class<?>... parameterTypes) throws Exception
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTest.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTest.java
index ffd743b..6af3e29 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTest.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTest.java
@@ -37,6 +37,8 @@
private Integer monitorTimeoutMinutes;
private Date startedAtUtc;
private Date endedAtUtc;
+ /** 是否从已运行设备的后续观测边界接入。 */
+ private Boolean runtimeAttach;
private String delFlag;
private String batteryNo;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@@ -85,6 +87,8 @@
public void setStartedAtUtc(Date value) { startedAtUtc = value; }
public Date getEndedAtUtc() { return endedAtUtc; }
public void setEndedAtUtc(Date value) { endedAtUtc = value; }
+ public Boolean getRuntimeAttach() { return runtimeAttach; }
+ public void setRuntimeAttach(Boolean value) { runtimeAttach = value; }
public String getDelFlag() { return delFlag; }
public void setDelFlag(String value) { delFlag = value; }
public String getBatteryNo() { return batteryNo; }
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestReceptionInterval.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestReceptionInterval.java
new file mode 100644
index 0000000..338160e
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestReceptionInterval.java
@@ -0,0 +1,42 @@
+package com.ruoyi.system.domain;
+
+import java.util.Date;
+
+/** 测试数据来源的一段接收区间及其实际采样边界。 */
+public class TTestReceptionInterval
+{
+ private Long intervalId;
+ private Long testId;
+ private Long bindingId;
+ private String sourceType;
+ private Long sourceId;
+ private String sourceChannelCode;
+ private Date actionAtUtc;
+ private Date startBoundaryAtUtc;
+ private Date endBoundaryAtUtc;
+ private String boundaryNote;
+ private String intervalStatus;
+
+ public Long getIntervalId() { return intervalId; }
+ public void setIntervalId(Long value) { intervalId = value; }
+ public Long getTestId() { return testId; }
+ public void setTestId(Long value) { testId = value; }
+ public Long getBindingId() { return bindingId; }
+ public void setBindingId(Long value) { bindingId = value; }
+ public String getSourceType() { return sourceType; }
+ public void setSourceType(String value) { sourceType = value; }
+ public Long getSourceId() { return sourceId; }
+ public void setSourceId(Long value) { sourceId = value; }
+ public String getSourceChannelCode() { return sourceChannelCode; }
+ public void setSourceChannelCode(String value) { sourceChannelCode = value; }
+ public Date getActionAtUtc() { return actionAtUtc; }
+ public void setActionAtUtc(Date value) { actionAtUtc = value; }
+ public Date getStartBoundaryAtUtc() { return startBoundaryAtUtc; }
+ public void setStartBoundaryAtUtc(Date value) { startBoundaryAtUtc = value; }
+ public Date getEndBoundaryAtUtc() { return endBoundaryAtUtc; }
+ public void setEndBoundaryAtUtc(Date value) { endBoundaryAtUtc = value; }
+ public String getBoundaryNote() { return boundaryNote; }
+ public void setBoundaryNote(String value) { boundaryNote = value; }
+ public String getIntervalStatus() { return intervalStatus; }
+ public void setIntervalStatus(String value) { intervalStatus = value; }
+}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TTestMapper.java b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TTestMapper.java
index 4ed76f8..47cd067 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TTestMapper.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TTestMapper.java
@@ -1,6 +1,7 @@
package com.ruoyi.system.mapper;
import java.util.List;
+import java.util.Date;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.system.domain.TTest;
import com.ruoyi.system.domain.TTestBattery;
@@ -10,6 +11,8 @@
import com.ruoyi.system.domain.TTestPlanStep;
import com.ruoyi.system.domain.TTestResult;
import com.ruoyi.system.domain.TTestWarningThreshold;
+import com.ruoyi.system.domain.TDetectionChannel;
+import com.ruoyi.system.domain.TTestReceptionInterval;
import com.ruoyi.system.domain.vo.TestBatteryOptionVo;
import com.ruoyi.system.domain.vo.TestChannelOptionVo;
import com.ruoyi.system.domain.vo.TestCollectorOptionVo;
@@ -60,4 +63,19 @@
int insertTestAudit(@Param("testId") Long testId, @Param("eventType") String eventType,
@Param("actorId") Long actorId, @Param("actorName") String actorName,
@Param("previousValue") String previousValue, @Param("newValue") String newValue);
+
+ /** 锁定检测柜通道的最新可观测事实,供接收边界确认使用。 */
+ TDetectionChannel selectReceptionChannelForUpdate(Long channelId);
+ /** 锁定当前检测柜通道租约;返回空表示可接入。 */
+ TTestChannelBinding selectChannelLeaseForUpdate(Long channelId);
+ int insertChannelLease(@Param("channelId") Long channelId, @Param("testId") Long testId,
+ @Param("bindingId") Long bindingId, @Param("acquiredBy") Long acquiredBy,
+ @Param("acquiredAtUtc") Date acquiredAtUtc);
+ int insertCollectorChannelLease(@Param("collectorId") Long collectorId, @Param("channelCode") String channelCode,
+ @Param("testId") Long testId, @Param("bindingId") Long bindingId,
+ @Param("acquiredBy") Long acquiredBy, @Param("acquiredAtUtc") Date acquiredAtUtc);
+ boolean collectorOnline(Long collectorId);
+ int updateReceptionStarted(@Param("testId") Long testId, @Param("startedAtUtc") Date startedAtUtc,
+ @Param("updateBy") String updateBy);
+ int insertReceptionInterval(TTestReceptionInterval interval);
}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/ITTestService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/ITTestService.java
index b20e0cd..e09428a 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/service/ITTestService.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/ITTestService.java
@@ -23,9 +23,12 @@
/** 仅在尚未开始接收时替换测试配置。 */
int updateTest(TTest test);
- /** 拒绝旧通用启动入口,开始接收由后续原子动作实现。 */
+ /** 旧服务入口已停用;调用方须使用带操作人的原子开始接收方法。 */
int startTest(Long testId, String updateBy);
+ /** 原子开始接收:校验全部来源并从实际观测边界打开区间。 */
+ int startReception(Long testId, Long operatorId, String updateBy);
+
/** 仅删除未开始接收的测试并追加审计记录。 */
int deleteTestByIds(Long[] testIds, Long operatorId, String updateBy);
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TTestServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TTestServiceImpl.java
index d077b3d..e2fedad 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TTestServiceImpl.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TTestServiceImpl.java
@@ -10,6 +10,7 @@
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONException;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.common.exception.ServiceException;
@@ -21,7 +22,10 @@
import com.ruoyi.system.domain.TTestCollectorChannel;
import com.ruoyi.system.domain.TTestPlanStep;
import com.ruoyi.system.domain.TTestResult;
+import com.ruoyi.system.domain.TTestFieldSnapshot;
import com.ruoyi.system.domain.TTestWarningThreshold;
+import com.ruoyi.system.domain.TDetectionChannel;
+import com.ruoyi.system.domain.TTestReceptionInterval;
import com.ruoyi.system.domain.vo.TestConfigurationOptionsVo;
import com.ruoyi.system.mapper.TTestMapper;
import com.ruoyi.system.service.ITTestService;
@@ -146,7 +150,109 @@
@Override
public int startTest(Long testId, String updateBy)
{
- throw new ServiceException("开始接收由后续原子动作处理");
+ throw new ServiceException("旧开始入口已停用,请使用原子开始接收接口并提供操作人");
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public int startReception(Long testId, Long operatorId, String updateBy)
+ {
+ if (testId == null) throw new ServiceException("测试ID不能为空");
+ if (operatorId == null) throw new ServiceException("操作人不能为空");
+ TTest current = testMapper.selectTestByIdForUpdate(testId);
+ if (current == null) throw new ServiceException("测试不存在");
+ if (!NOT_STARTED.equals(current.getReceptionStatus()))
+ throw new ServiceException("当前测试不是未开始接收状态");
+ loadConfiguration(current);
+ if (current.getBatteries().isEmpty()) throw new ServiceException("测试未配置电池号");
+
+ Date actionAt = new Date();
+ List<TTestReceptionInterval> intervals = new ArrayList<>();
+ Set<Long> channelIds = new HashSet<>();
+ Set<String> collectorChannels = new HashSet<>();
+ boolean runtimeAttach = false;
+ for (TTestBattery battery : current.getBatteries())
+ {
+ TTestChannelBinding binding = battery.getBinding();
+ if (binding == null || binding.getChannelId() == null)
+ throw new ServiceException("电池号 " + battery.getBatteryNo() + " 未绑定检测柜通道");
+ if (!channelIds.add(binding.getChannelId()))
+ throw new ServiceException("检测柜通道重复绑定,无法开始接收");
+ TDetectionChannel channel = testMapper.selectReceptionChannelForUpdate(binding.getChannelId());
+ if (channel == null || !isEnabled(channel.getStatus()) || !isOnline(channel.getOnlineStatus()))
+ throw new ServiceException("检测柜通道 " + binding.getChannelId() + " 当前离线或不可观测");
+ if (channel.getLastSeenAtUtc() == null)
+ throw new ServiceException("检测柜通道 " + binding.getChannelId() + " 尚未取得成功观测边界");
+ runtimeAttach |= "WORKING".equalsIgnoreCase(channel.getWorkStatus())
+ || "PAUSED".equalsIgnoreCase(channel.getWorkStatus());
+ if (testMapper.selectChannelLeaseForUpdate(binding.getChannelId()) != null)
+ throw new ServiceException("检测柜通道 " + binding.getChannelId() + " 已被其他测试占用");
+ if (battery.getCollectorId() == null || testMapper.countCollectorEnabled(battery.getCollectorId()) == 0)
+ throw new ServiceException("电池号 " + battery.getBatteryNo() + " 的采集器不存在或未启用");
+ if (!testMapper.collectorOnline(battery.getCollectorId()))
+ throw new ServiceException("采集器 " + battery.getCollectorId() + " 当前离线或不可观测");
+ Integer collectorChannelCount = testMapper.selectCollectorChannelCount(battery.getCollectorId());
+ validateCollectorChannels(battery.getCollectorId(), collectorChannelCount == null ? 0 : collectorChannelCount,
+ battery.getCollectorChannels(), collectorChannels);
+
+ intervals.add(interval(current, binding.getBindingId(), "CABINET_CHANNEL", binding.getChannelId(), null,
+ actionAt, Date.from(channel.getLastSeenAtUtc()), runtimeAttach
+ ? "运行中接入:采用检测柜最近成功观测边界" : "采用检测柜最近成功观测边界"));
+ for (TTestCollectorChannel mapping : mutable(battery.getCollectorChannels()))
+ {
+ intervals.add(interval(current, binding.getBindingId(), "COLLECTOR_CHANNEL", battery.getCollectorId(),
+ mapping.getChannelCode(), actionAt, actionAt, "采集器开始接收确认边界"));
+ }
+ }
+
+ try
+ {
+ for (TTestBattery battery : current.getBatteries())
+ {
+ TTestChannelBinding binding = battery.getBinding();
+ testMapper.insertChannelLease(binding.getChannelId(), testId, binding.getBindingId(), operatorId, actionAt);
+ for (TTestCollectorChannel mapping : mutable(battery.getCollectorChannels()))
+ testMapper.insertCollectorChannelLease(battery.getCollectorId(), mapping.getChannelCode(), testId,
+ binding.getBindingId(), operatorId, actionAt);
+ }
+ for (TTestReceptionInterval interval : intervals) testMapper.insertReceptionInterval(interval);
+ if (testMapper.updateReceptionStarted(testId, actionAt, updateBy) != 1)
+ throw new ServiceException("测试状态已变更,请刷新后重试");
+ testMapper.insertTestAudit(testId, "RECEPTION_STARTED", operatorId, updateBy, null,
+ JSON.toJSONString(Map.of("receptionStatus", "RECEIVING", "intervalCount", intervals.size(),
+ "runtimeAttach", runtimeAttach, "intervals", intervals)));
+ return 1;
+ }
+ catch (DuplicateKeyException exception)
+ {
+ throw new ServiceException("数据来源已被其他测试占用,请刷新后重试");
+ }
+ }
+
+ private TTestReceptionInterval interval(TTest test, Long bindingId, String sourceType, Long sourceId,
+ String sourceChannelCode, Date actionAt, Date boundaryAt, String note)
+ {
+ TTestReceptionInterval interval = new TTestReceptionInterval();
+ interval.setTestId(test.getTestId());
+ interval.setBindingId(bindingId);
+ interval.setSourceType(sourceType);
+ interval.setSourceId(sourceId);
+ interval.setSourceChannelCode(sourceChannelCode);
+ interval.setActionAtUtc(actionAt);
+ interval.setStartBoundaryAtUtc(boundaryAt);
+ interval.setBoundaryNote(note);
+ interval.setIntervalStatus("OPEN");
+ return interval;
+ }
+
+ private boolean isOnline(String status)
+ {
+ return status != null && ("ONLINE".equalsIgnoreCase(status) || "0".equals(status));
+ }
+
+ private boolean isEnabled(String status)
+ {
+ return status == null || "0".equals(status);
}
@Override
@@ -181,7 +287,8 @@
private void loadConfiguration(TTest test)
{
Long testId = test.getTestId();
- List<TTestPlanStep> planSteps = mutable(testMapper.selectTestPlanSteps(testId));
+ List<TTestPlanStep> loadedPlanSteps = testMapper.selectTestPlanSteps(testId);
+ List<TTestPlanStep> planSteps = loadedPlanSteps == null ? mutable(test.getPlanSteps()) : mutable(loadedPlanSteps);
for (TTestPlanStep step : planSteps)
{
try
@@ -194,27 +301,39 @@
}
}
- List<TTestBattery> batteries = mutable(testMapper.selectTestBatteries(testId));
- List<TTestBatteryField> fields = mutable(testMapper.selectTestBatteryFields(testId));
- List<TTestCollectorChannel> collectorChannels = mutable(testMapper.selectTestCollectorChannels(testId));
+ List<TTestBattery> loadedBatteries = testMapper.selectTestBatteries(testId);
+ List<TTestBattery> batteries = loadedBatteries == null || (loadedBatteries.isEmpty() && test.getBatteries() != null
+ && !test.getBatteries().isEmpty()) ? mutable(test.getBatteries()) : mutable(loadedBatteries);
+ List<TTestBatteryField> loadedFields = testMapper.selectTestBatteryFields(testId);
+ List<TTestBatteryField> fields = loadedFields == null ? List.of() : mutable(loadedFields);
+ List<TTestCollectorChannel> loadedCollectorChannels = testMapper.selectTestCollectorChannels(testId);
+ List<TTestCollectorChannel> collectorChannels = loadedCollectorChannels == null ? List.of() : mutable(loadedCollectorChannels);
Map<Long, TTestChannelBinding> bindings = new HashMap<>();
- for (TTestChannelBinding binding : mutable(testMapper.selectTestChannelBindings(testId)))
+ List<TTestChannelBinding> loadedBindings = testMapper.selectTestChannelBindings(testId);
+ if (loadedBindings == null || (loadedBindings.isEmpty() && batteries.stream().anyMatch(item -> item.getBinding() != null)))
+ for (TTestBattery battery : batteries)
+ if (battery.getBinding() != null) bindings.put(battery.getRelationId(), battery.getBinding());
+ for (TTestChannelBinding binding : mutable(loadedBindings))
bindings.put(binding.getBatteryRelationId(), binding);
for (TTestBattery battery : batteries)
{
battery.setBinding(bindings.get(battery.getRelationId()));
- battery.setFields(fields.stream()
- .filter(field -> battery.getRelationId().equals(field.getRelationId()))
- .toList());
- battery.setCollectorChannels(collectorChannels.stream()
- .filter(mapping -> battery.getRelationId().equals(mapping.getBatteryRelationId()))
- .toList());
+ if (loadedFields != null && (!loadedFields.isEmpty() || battery.getFields() == null))
+ battery.setFields(fields.stream()
+ .filter(field -> battery.getRelationId().equals(field.getRelationId()))
+ .toList());
+ if (loadedCollectorChannels != null && (!loadedCollectorChannels.isEmpty() || battery.getCollectorChannels() == null))
+ battery.setCollectorChannels(collectorChannels.stream()
+ .filter(mapping -> battery.getRelationId().equals(mapping.getBatteryRelationId()))
+ .toList());
}
test.setPlanSteps(planSteps);
test.setBatteries(batteries);
test.setBatteryNo(joinBatteryNos(batteries));
- test.setThresholds(mutable(testMapper.selectTestThresholds(testId)));
- test.setFieldSnapshots(sharedFieldSnapshotService.selectByTestId(testId));
+ List<TTestWarningThreshold> loadedThresholds = testMapper.selectTestThresholds(testId);
+ test.setThresholds(loadedThresholds == null ? mutable(test.getThresholds()) : mutable(loadedThresholds));
+ List<TTestFieldSnapshot> loadedSnapshots = sharedFieldSnapshotService.selectByTestId(testId);
+ test.setFieldSnapshots(loadedSnapshots == null ? mutable(test.getFieldSnapshots()) : loadedSnapshots);
}
private void validateBase(TTest test, boolean update)
diff --git a/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml b/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
index a313720..d19302b 100644
--- a/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
+++ b/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
@@ -18,6 +18,7 @@
<result property="monitorTimeoutMinutes" column="monitor_timeout_minutes" />
<result property="startedAtUtc" column="started_at_utc" />
<result property="endedAtUtc" column="ended_at_utc" />
+ <result property="runtimeAttach" column="runtime_attach" />
<result property="delFlag" column="del_flag" />
<result property="createBy" column="create_by" />
<result property="createTime" column="create_time" />
@@ -115,7 +116,9 @@
t.test_status, t.reception_status, t.monitor_timeout_minutes,
t.started_at_utc, t.ended_at_utc, t.del_flag,
t.create_by, t.create_time, t.update_by, t.update_time, t.remark,
- (select count(1) from t_test_battery b where b.test_id = t.test_id and b.del_flag = 0) battery_count
+ (select count(1) from t_test_battery b where b.test_id = t.test_id and b.del_flag = 0) battery_count,
+ exists (select 1 from t_test_reception_interval ri where ri.test_id = t.test_id
+ and ri.boundary_note like '运行中接入%') runtime_attach
from t_test t
left join sys_user creator on creator.user_id = t.creator_id and creator.del_flag = '0'
left join sys_user principal on principal.user_id = t.principal_id and principal.del_flag = '0'
@@ -343,4 +346,51 @@
<choose><when test="newValue != null">cast(#{newValue} as json)</when><otherwise>null</otherwise></choose>,
uuid())
</insert>
+
+ <select id="selectReceptionChannelForUpdate" parameterType="Long" resultType="com.ruoyi.system.domain.TDetectionChannel">
+ select channel_id, adapter_id, cabinet_id, devtype, devid, subdevid, chlid, display_no,
+ online_status, work_status, last_seen_at_utc, status
+ from t_detection_channel channel_fact
+ join t_detection_cabinet cabinet on cabinet.cabinet_id = channel_fact.cabinet_id
+ where channel_fact.channel_id = #{channelId}
+ and channel_fact.del_flag = 0 and channel_fact.status = '0'
+ and cabinet.del_flag = 0 and cabinet.status = '0'
+ and cabinet.registration_status = 'REGISTERED'
+ for update
+ </select>
+ <select id="selectChannelLeaseForUpdate" parameterType="Long" resultMap="TTestBindingResultMap">
+ select binding.binding_id, binding.test_id, binding.battery_relation_id, binding.channel_id,
+ binding.claim_status
+ from t_channel_lease lease
+ join t_test_channel_binding binding on binding.binding_id = lease.binding_id
+ where lease.channel_id = #{channelId}
+ for update
+ </select>
+ <insert id="insertChannelLease">
+ insert into t_channel_lease(channel_id, test_id, binding_id, acquired_by, acquired_at_utc)
+ values(#{channelId}, #{testId}, #{bindingId}, #{acquiredBy}, #{acquiredAtUtc})
+ </insert>
+ <insert id="insertCollectorChannelLease">
+ insert into t_collector_channel_lease(collector_id, channel_code, test_id, binding_id, acquired_by, acquired_at_utc)
+ values(#{collectorId}, #{channelCode}, #{testId}, #{bindingId}, #{acquiredBy}, #{acquiredAtUtc})
+ </insert>
+ <select id="collectorOnline" resultType="boolean">
+ select count(1) > 0 from t_collector
+ where collector_id = #{collectorId} and status = '0' and del_flag = 0
+ and upper(online_status) in ('ONLINE', '1')
+ </select>
+ <update id="updateReceptionStarted">
+ update t_test
+ set test_status = 'RUNNING', reception_status = 'RECEIVING',
+ started_at_utc = coalesce(started_at_utc, #{startedAtUtc}),
+ update_by = #{updateBy}, update_time = #{startedAtUtc}
+ where test_id = #{testId} and reception_status = 'NOT_STARTED' and del_flag = 0
+ </update>
+ <insert id="insertReceptionInterval" parameterType="com.ruoyi.system.domain.TTestReceptionInterval" useGeneratedKeys="true" keyProperty="intervalId">
+ insert into t_test_reception_interval
+ (test_id, binding_id, source_type, source_id, source_channel_code, action_at_utc,
+ start_boundary_at_utc, boundary_note, interval_status)
+ values(#{testId}, #{bindingId}, #{sourceType}, #{sourceId}, #{sourceChannelCode}, #{actionAtUtc},
+ #{startBoundaryAtUtc}, #{boundaryNote}, #{intervalStatus})
+ </insert>
</mapper>
--
Gitblit v1.7.1