yeg
2026-08-30 20b346ef9d4fcbbbe05abfe08e4f4f0303b0812e
实现 Ticket12 人工结束与顺序接入

新增接收区间边界事实、人工结束原子关闭与通道租约即时释放,补充独立接口、迁移和后端测试。
2个文件已添加
8个文件已修改
182 ■■■■■ 已修改文件
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TTestController.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/resources/db/migration/V20__manual_end_reception_intervals.sql 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/ruoyi/config/FlywayMigrationContractTest.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TTestServiceImplTest.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/TTestControllerContractTest.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/domain/TTest.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestReceptionInterval.java 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/mapper/TTestMapper.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TTestServiceImpl.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TTestController.java
@@ -125,6 +125,16 @@
        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}")
ruoyi-admin/src/main/resources/db/migration/V20__manual_end_reception_intervals.sql
New file
@@ -0,0 +1,38 @@
-- 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;
ruoyi-admin/src/test/java/com/ruoyi/config/FlywayMigrationContractTest.java
@@ -143,6 +143,12 @@
        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"));
ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TTestServiceImplTest.java
@@ -389,7 +389,9 @@
        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());
    }
@@ -542,12 +544,30 @@
        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();
ruoyi-admin/src/test/java/com/ruoyi/web/controller/system/TTestControllerContractTest.java
@@ -27,6 +27,9 @@
        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
ruoyi-system/src/main/java/com/ruoyi/system/domain/TTest.java
@@ -55,6 +55,7 @@
    @NotEmpty(message = "阈值配置不能为空")
    private List<TTestWarningThreshold> thresholds;
    private List<TTestFieldSnapshot> fieldSnapshots;
    private List<TTestReceptionInterval> receptionIntervals;
    private List<TTestResult> resultSummary;
    public Long getTestId() { return testId; }
@@ -107,6 +108,8 @@
    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; }
}
ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestReceptionInterval.java
New file
@@ -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; }
}
ruoyi-system/src/main/java/com/ruoyi/system/mapper/TTestMapper.java
@@ -13,6 +13,7 @@
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;
@@ -49,6 +50,8 @@
    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);
@@ -75,6 +78,14 @@
    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);
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TTestServiceImpl.java
@@ -180,8 +180,8 @@
            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)
@@ -200,9 +200,15 @@
            {
                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("测试状态已变更,请刷新后重试");
@@ -266,10 +272,12 @@
        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;
    }
@@ -360,6 +368,8 @@
        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);
    }
ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
@@ -125,6 +125,19 @@
        <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,
@@ -303,6 +316,13 @@
        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
@@ -426,6 +446,19 @@
        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>