实现 Ticket12 人工结束与顺序接入
新增接收区间边界事实、人工结束原子关闭与通道租约即时释放,补充独立接口、迁移和后端测试。
| | |
| | | return success(testService.selectTestById(testId)); |
| | | } |
| | | |
| | | /** 人工结束接收动作别名;与停止测试共用原子关闭区间和释放租约事务。 */ |
| | | @PreAuthorize("@ss.hasPermi('system:test:stop')") |
| | | @Log(title = "人工结束接收", businessType = BusinessType.UPDATE) |
| | | @PostMapping("/{testId}/reception/end") |
| | | public AjaxResult endReception(@PathVariable Long testId) |
| | | { |
| | | testService.stopTest(testId, getUserId(), getUsername()); |
| | | return success(testService.selectTestById(testId)); |
| | | } |
| | | |
| | | @PreAuthorize("@ss.hasPermi('system:test:remove')") |
| | | @Log(title = "测试管理", businessType = BusinessType.DELETE) |
| | | @DeleteMapping("/{testIds}") |
| New file |
| | |
| | | -- Ticket12:人工结束关闭全部开放接收区间,并为顺序接入保留稳定的来源时间索引。 |
| | | -- DDL 使用 information_schema 守卫,便于已迁移开发库重复启动时安全执行。 |
| | | SET @boundary_constraint_sql := ( |
| | | SELECT IF(COUNT(*) = 0, |
| | | 'ALTER TABLE t_test_reception_interval ADD CONSTRAINT chk_reception_interval_boundary_order CHECK (end_boundary_at_utc IS NULL OR end_boundary_at_utc >= start_boundary_at_utc)', |
| | | 'SELECT 1') |
| | | FROM information_schema.table_constraints |
| | | WHERE constraint_schema = DATABASE() |
| | | AND table_name = 't_test_reception_interval' |
| | | AND constraint_name = 'chk_reception_interval_boundary_order'); |
| | | PREPARE boundary_constraint_stmt FROM @boundary_constraint_sql; |
| | | EXECUTE boundary_constraint_stmt; |
| | | DEALLOCATE PREPARE boundary_constraint_stmt; |
| | | |
| | | SET @source_order_index_sql := ( |
| | | SELECT IF(COUNT(*) = 0, |
| | | 'ALTER TABLE t_test_reception_interval ADD KEY idx_reception_interval_source_order (source_type, source_id, source_channel_code, start_boundary_at_utc, interval_id)', |
| | | 'SELECT 1') |
| | | FROM information_schema.statistics |
| | | WHERE table_schema = DATABASE() |
| | | AND table_name = 't_test_reception_interval' |
| | | AND index_name = 'idx_reception_interval_source_order'); |
| | | PREPARE source_order_index_stmt FROM @source_order_index_sql; |
| | | EXECUTE source_order_index_stmt; |
| | | DEALLOCATE PREPARE source_order_index_stmt; |
| | | |
| | | -- 停止测试权限由 V16 提供;此处保留权限插入的幂等保护。 |
| | | SET @stop_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 @stop_menu_id, '停止测试', menu_id, 9, '', NULL, NULL, '', 1, 0, 'F', '0', '0', |
| | | 'system:test:stop', '#', 'admin', NOW(), '', NULL, '测试人工结束权限' |
| | | FROM sys_menu |
| | | WHERE perms = 'system:test:list' |
| | | AND NOT EXISTS (SELECT 1 FROM sys_menu existing WHERE existing.perms = 'system:test:stop') |
| | | LIMIT 1; |
| | |
| | | assertTrue(v19.contains("ADD UNIQUE KEY uk_collector_exception_open (open_dedupe_key)")); |
| | | assertFalse(v19.matches("(?s).*\\bDROP\\s+TABLE\\b.*")); |
| | | |
| | | String v20 = resource("db/migration/V20__manual_end_reception_intervals.sql"); |
| | | assertTrue(v20.contains("chk_reception_interval_boundary_order")); |
| | | assertTrue(v20.contains("idx_reception_interval_source_order")); |
| | | assertTrue(v20.contains("system:test:stop")); |
| | | assertFalse(v20.matches("(?s).*\\bDROP\\s+TABLE\\b.*")); |
| | | |
| | | String testMapper = resource("mapper/system/TTestMapper.xml"); |
| | | assertTrue(testMapper.contains("selectReceivingChannelsForAdapter")); |
| | | assertTrue(testMapper.contains("RECEPTION_RESUMED")); |
| | |
| | | |
| | | assertEquals(1, testService.startReception(10L, 7L, "operator")); |
| | | verify(testMapper).insertChannelLease(eq(100L), eq(10L), eq(30L), eq(7L), any()); |
| | | verify(testMapper).insertReceptionInterval(eq(10L), eq(30L), eq("CABINET_CHANNEL"), eq(100L), isNull(), any(), eq("RECEPTION_STARTED")); |
| | | verify(testMapper, times(3)).insertCollectorChannelLease(eq(8L), any(), eq(10L), eq(30L), eq(7L), any()); |
| | | verify(testMapper, times(3)).insertReceptionInterval(eq(10L), eq(30L), eq("COLLECTOR_CHANNEL"), eq(8L), any(), any(), eq("RECEPTION_STARTED")); |
| | | verify(testMapper).insertTestAudit(eq(10L), eq("RECEPTION_STARTED"), eq(7L), eq("operator"), isNull(), any()); |
| | | } |
| | | |
| | |
| | | |
| | | assertEquals(1, testService.stopTest(10L, 7L, "operator")); |
| | | verify(testMapper).updateReceptionStopped(eq(10L), eq("operator"), any()); |
| | | verify(testMapper).closeOpenReceptionIntervals(eq(10L), any(), eq("MANUAL_END")); |
| | | verify(testMapper).deleteChannelLeasesByTestId(10L); |
| | | verify(testMapper).deleteCollectorChannelLeasesByTestId(10L); |
| | | verify(testMapper).insertTestAudit(eq(10L), eq("TEST_STOPPED"), eq(7L), eq("operator"), isNull(), |
| | | org.mockito.ArgumentMatchers.argThat(value -> value != null && value.contains("COMPLETED"))); |
| | | } |
| | | |
| | | @Test |
| | | void stopTestShouldAuditImmediateReleaseAndClosedIntervals() |
| | | { |
| | | TTest current = validTest(); |
| | | current.setTestId(10L); |
| | | current.setReceptionStatus("RECEIVING"); |
| | | when(testMapper.selectTestByIdForUpdate(10L)).thenReturn(current); |
| | | when(testMapper.updateReceptionStopped(eq(10L), eq("operator"), any())).thenReturn(1); |
| | | when(testMapper.closeOpenReceptionIntervals(eq(10L), any(), eq("MANUAL_END"))).thenReturn(4); |
| | | |
| | | testService.stopTest(10L, 7L, "operator"); |
| | | |
| | | verify(testMapper).insertTestAudit(eq(10L), eq("TEST_STOPPED"), eq(7L), eq("operator"), isNull(), |
| | | org.mockito.ArgumentMatchers.argThat(value -> value != null |
| | | && value.contains("closedIntervals") && value.contains("IMMEDIATE"))); |
| | | } |
| | | |
| | | private void mockEnabledOptions() |
| | | { |
| | | mockEnabledPeopleAndType(); |
| | |
| | | assertPermission("stopTest", "@ss.hasPermi('system:test:stop')", Long.class); |
| | | assertEquals("/{testId}/stop", TTestController.class.getMethod("stopTest", Long.class) |
| | | .getAnnotation(PostMapping.class).value()[0]); |
| | | assertPermission("endReception", "@ss.hasPermi('system:test:stop')", Long.class); |
| | | assertEquals("/{testId}/reception/end", TTestController.class.getMethod("endReception", Long.class) |
| | | .getAnnotation(PostMapping.class).value()[0]); |
| | | } |
| | | |
| | | private void assertPermission(String methodName, String permission, Class<?>... parameterTypes) throws Exception |
| | |
| | | @NotEmpty(message = "阈值配置不能为空") |
| | | private List<TTestWarningThreshold> thresholds; |
| | | private List<TTestFieldSnapshot> fieldSnapshots; |
| | | private List<TTestReceptionInterval> receptionIntervals; |
| | | private List<TTestResult> resultSummary; |
| | | |
| | | public Long getTestId() { return testId; } |
| | |
| | | public void setThresholds(List<TTestWarningThreshold> value) { thresholds = value; } |
| | | public List<TTestFieldSnapshot> getFieldSnapshots() { return fieldSnapshots; } |
| | | public void setFieldSnapshots(List<TTestFieldSnapshot> value) { fieldSnapshots = value; } |
| | | public List<TTestReceptionInterval> getReceptionIntervals() { return receptionIntervals; } |
| | | public void setReceptionIntervals(List<TTestReceptionInterval> value) { receptionIntervals = value; } |
| | | public List<TTestResult> getResultSummary() { return resultSummary; } |
| | | public void setResultSummary(List<TTestResult> value) { resultSummary = value; } |
| | | } |
| New file |
| | |
| | | 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; } |
| | | } |
| | |
| | | import com.ruoyi.system.domain.TTestWarningThreshold; |
| | | import com.ruoyi.system.domain.TTestCollectorSource; |
| | | import com.ruoyi.system.domain.TTestCollectorExceptionInterval; |
| | | import com.ruoyi.system.domain.TTestReceptionInterval; |
| | | import com.ruoyi.system.domain.TDetectionChannel; |
| | | import com.ruoyi.system.domain.vo.TestBatteryOptionVo; |
| | | import com.ruoyi.system.domain.vo.TestChannelOptionVo; |
| | |
| | | List<TTestChannelBinding> selectTestChannelBindings(Long testId); |
| | | List<TTestCollectorChannel> selectTestCollectorChannels(Long testId); |
| | | List<TTestWarningThreshold> selectTestThresholds(Long testId); |
| | | /** 按来源和起始边界顺序返回测试接收区间。 */ |
| | | List<TTestReceptionInterval> selectTestReceptionIntervals(Long testId); |
| | | List<TTestResult> selectTestResults(TTestResult result); |
| | | int countTestTypeEnabled(Long testTypeId); |
| | | int countUserEnabled(Long userId); |
| | |
| | | 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); |
| | | /** 为每个绑定来源追加一个开放接收区间;区间关闭后仅保留历史事实。 */ |
| | | int insertReceptionInterval(@Param("testId") Long testId, @Param("bindingId") Long bindingId, |
| | | @Param("sourceType") String sourceType, @Param("sourceId") Long sourceId, |
| | | @Param("sourceChannelCode") String sourceChannelCode, @Param("actionAtUtc") Date actionAtUtc, |
| | | @Param("boundaryNote") String boundaryNote); |
| | | /** 在停止动作事务内关闭该测试全部开放区间,返回实际关闭数量。 */ |
| | | int closeOpenReceptionIntervals(@Param("testId") Long testId, @Param("endBoundaryAtUtc") Date endBoundaryAtUtc, |
| | | @Param("boundaryNote") String boundaryNote); |
| | | int deleteChannelLeasesByTestId(Long testId); |
| | | int deleteCollectorChannelLeasesByTestId(Long testId); |
| | | int hasReceivingTestForAdapter(@Param("adapterCode") String adapterCode); |
| | |
| | | TDetectionChannel channel = testMapper.selectReceptionChannelForUpdate(binding.getChannelId()); |
| | | if (channel == null || !isEnabled(channel.getStatus()) || !isOnline(channel.getOnlineStatus())) |
| | | throw new ServiceException("检测柜通道 " + binding.getChannelId() + " 当前离线或不可观测"); |
| | | runtimeAttach |= "WORKING".equalsIgnoreCase(channel.getWorkStatus()) |
| | | || "PAUSED".equalsIgnoreCase(channel.getWorkStatus()); |
| | | // 只有设备事实为运行中时才标记“运行中接入”;设备暂停属于独立设备状态。 |
| | | runtimeAttach |= "WORKING".equalsIgnoreCase(channel.getWorkStatus()); |
| | | if (testMapper.selectChannelLeaseForUpdate(binding.getChannelId()) != null) |
| | | throw new ServiceException("检测柜通道 " + binding.getChannelId() + " 已被其他测试占用"); |
| | | if (battery.getCollectorId() == null || testMapper.countCollectorEnabled(battery.getCollectorId()) == 0) |
| | |
| | | { |
| | | TTestChannelBinding binding = battery.getBinding(); |
| | | testMapper.insertChannelLease(binding.getChannelId(), testId, binding.getBindingId(), operatorId, actionAt); |
| | | testMapper.insertReceptionInterval(testId, binding.getBindingId(), "CABINET_CHANNEL", |
| | | binding.getChannelId(), null, actionAt, "RECEPTION_STARTED"); |
| | | for (TTestCollectorChannel mapping : mutable(battery.getCollectorChannels())) |
| | | { |
| | | testMapper.insertCollectorChannelLease(battery.getCollectorId(), mapping.getChannelCode(), testId, |
| | | binding.getBindingId(), operatorId, actionAt); |
| | | testMapper.insertReceptionInterval(testId, binding.getBindingId(), "COLLECTOR_CHANNEL", |
| | | battery.getCollectorId(), mapping.getChannelCode(), actionAt, "RECEPTION_STARTED"); |
| | | } |
| | | } |
| | | if (testMapper.updateReceptionStarted(testId, actionAt, runtimeAttach, updateBy) != 1) |
| | | throw new ServiceException("测试状态已变更,请刷新后重试"); |
| | |
| | | Date actionAt = new Date(); |
| | | if (testMapper.updateReceptionStopped(testId, updateBy, actionAt) != 1) |
| | | throw new ServiceException("测试状态已变更,请刷新后重试"); |
| | | int closedIntervals = testMapper.closeOpenReceptionIntervals(testId, actionAt, "MANUAL_END"); |
| | | testMapper.deleteChannelLeasesByTestId(testId); |
| | | testMapper.deleteCollectorChannelLeasesByTestId(testId); |
| | | testMapper.insertTestAudit(testId, "TEST_STOPPED", operatorId, updateBy, null, |
| | | JSON.toJSONString(Map.of("testStatus", "COMPLETED", "receptionStatus", "ENDED", "polling", "STOPPED"))); |
| | | JSON.toJSONString(Map.of("testStatus", "COMPLETED", "receptionStatus", "ENDED", |
| | | "polling", "STOPPED", "closedIntervals", closedIntervals, "release", "IMMEDIATE"))); |
| | | return 1; |
| | | } |
| | | |
| | |
| | | test.setBatteryNo(joinBatteryNos(batteries)); |
| | | List<TTestWarningThreshold> loadedThresholds = testMapper.selectTestThresholds(testId); |
| | | test.setThresholds(loadedThresholds == null ? mutable(test.getThresholds()) : mutable(loadedThresholds)); |
| | | List<com.ruoyi.system.domain.TTestReceptionInterval> intervals = testMapper.selectTestReceptionIntervals(testId); |
| | | test.setReceptionIntervals(intervals == null ? List.of() : intervals); |
| | | List<TTestFieldSnapshot> loadedSnapshots = sharedFieldSnapshotService.selectByTestId(testId); |
| | | test.setFieldSnapshots(loadedSnapshots == null ? mutable(test.getFieldSnapshots()) : loadedSnapshots); |
| | | } |
| | |
| | | <result property="intervalStatus" column="interval_status" /> |
| | | <result property="detail" column="detail" /> |
| | | </resultMap> |
| | | <resultMap id="TTestReceptionIntervalResultMap" type="TTestReceptionInterval"> |
| | | <id property="intervalId" column="interval_id" /> |
| | | <result property="testId" column="test_id" /> |
| | | <result property="bindingId" column="binding_id" /> |
| | | <result property="sourceType" column="source_type" /> |
| | | <result property="sourceId" column="source_id" /> |
| | | <result property="sourceChannelCode" column="source_channel_code" /> |
| | | <result property="actionAtUtc" column="action_at_utc" /> |
| | | <result property="startBoundaryAtUtc" column="start_boundary_at_utc" /> |
| | | <result property="endBoundaryAtUtc" column="end_boundary_at_utc" /> |
| | | <result property="boundaryNote" column="boundary_note" /> |
| | | <result property="intervalStatus" column="interval_status" /> |
| | | </resultMap> |
| | | |
| | | <sql id="selectTestBase"> |
| | | select t.test_id, t.test_name, t.creator_id, creator.nick_name creator_name, |
| | |
| | | select threshold_id, test_id, metric_code, min_value, max_value, unit, severity, del_flag |
| | | from t_test_warning_threshold where test_id = #{testId} and del_flag = 0 order by threshold_id |
| | | </select> |
| | | <select id="selectTestReceptionIntervals" parameterType="Long" resultMap="TTestReceptionIntervalResultMap"> |
| | | select interval_id, test_id, binding_id, source_type, source_id, source_channel_code, |
| | | action_at_utc, start_boundary_at_utc, end_boundary_at_utc, boundary_note, interval_status |
| | | from t_test_reception_interval |
| | | where test_id = #{testId} |
| | | order by source_type, source_id, source_channel_code, start_boundary_at_utc, interval_id |
| | | </select> |
| | | <select id="selectTestResults" parameterType="TTestResult" resultMap="TTestResultResultMap"> |
| | | select result.*, battery.test_id from t_test_result result |
| | | join t_test_battery battery on battery.relation_id = result.relation_id |
| | |
| | | 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> |
| | | <insert id="insertReceptionInterval"> |
| | | 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}, #{actionAtUtc}, #{boundaryNote}, 'OPEN') |
| | | </insert> |
| | | <update id="closeOpenReceptionIntervals"> |
| | | update t_test_reception_interval |
| | | set end_boundary_at_utc = #{endBoundaryAtUtc}, |
| | | boundary_note = #{boundaryNote}, interval_status = 'CLOSED' |
| | | where test_id = #{testId} and interval_status = 'OPEN' |
| | | </update> |
| | | <delete id="deleteChannelLeasesByTestId"> |
| | | delete from t_channel_lease where test_id = #{testId} |
| | | </delete> |