ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TTestDataRevisionController.java
New file @@ -0,0 +1,40 @@ package com.ruoyi.web.controller.system; import java.util.List; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import com.ruoyi.common.core.controller.BaseController; import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.core.page.TableDataInfo; import com.ruoyi.system.domain.TTestDataRevisionAction; import com.ruoyi.system.service.ITestConsoleDataService; /** 数据修订行为查询接口;只读返回一次用户行为及其摘要。 */ @RestController @RequestMapping("/system/data-revision") public class TTestDataRevisionController extends BaseController { @Autowired private ITestConsoleDataService service; @PreAuthorize("@ss.hasPermi('system:dataRevision:list')") @GetMapping("/list") public TableDataInfo list(TTestDataRevisionAction query) { startPage(); List<TTestDataRevisionAction> rows = service.selectActions(query); return getDataTable(rows); } @PreAuthorize("@ss.hasPermi('system:dataRevision:query')") @GetMapping("/{actionId}") public AjaxResult detail(@PathVariable Long actionId) { TTestDataRevisionAction action = service.selectActionById(actionId); return action == null ? error("未找到数据修订行为") : success(action); } } ruoyi-admin/src/main/resources/db/migration/V26__data_revision_action_query_contract.sql
New file @@ -0,0 +1,3 @@ -- Ticket26:数据修订行为查询使用现有一次行为表,不复制按单元格记录。 CREATE INDEX idx_data_revision_query_time ON t_test_data_revision_action (data_type, operation_type, created_at_utc, action_id); ruoyi-admin/src/test/java/com/ruoyi/config/Ticket26MigrationContractTest.java
New file @@ -0,0 +1,25 @@ package com.ruoyi.config; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.InputStream; import java.nio.charset.StandardCharsets; import org.junit.jupiter.api.Test; /** Ticket26 迁移只增加行为查询索引,不建立按行或按格修订表。 */ class Ticket26MigrationContractTest { @Test void addsActionQueryIndexWithoutReplacingAuditFacts() throws Exception { try (InputStream stream = getClass().getClassLoader().getResourceAsStream( "db/migration/V26__data_revision_action_query_contract.sql")) { if (stream == null) throw new AssertionError("缺少 Ticket26 迁移"); String sql = new String(stream.readAllBytes(), StandardCharsets.UTF_8); assertTrue(sql.contains("idx_data_revision_query_time")); assertTrue(sql.contains("t_test_data_revision_action")); assertFalse(sql.matches("(?s).*\\b(DROP|DELETE|UPDATE)\\b.*")); } } } ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TestConsoleDataRevisionQueryServiceTest.java
New file @@ -0,0 +1,116 @@ package com.ruoyi.system.service.impl; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.List; import org.junit.jupiter.api.BeforeEach; 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 com.ruoyi.common.exception.ServiceException; import com.ruoyi.system.domain.TTestDataRevisionAction; import com.ruoyi.system.domain.TTestDataOverride; import com.ruoyi.system.mapper.TTestMapper; /** Ticket26:修订行为按一次提交查询,且三类数据与三类操作均受服务端约束。 */ @ExtendWith(MockitoExtension.class) class TestConsoleDataRevisionQueryServiceTest { @Mock private TTestMapper mapper; @InjectMocks private TestConsoleDataServiceImpl service; private TTestDataRevisionAction query; @BeforeEach void setUp() { query = new TTestDataRevisionAction(); query.setTestId(26L); query.setDataType("STEP"); query.setOperationType("EDIT"); } @Test void delegatesOneActionQueryWithFilters() { TTestDataRevisionAction action = new TTestDataRevisionAction(); action.setActionId(101L); when(mapper.selectDataRevisionActions(query)).thenReturn(List.of(action)); assertEquals(101L, service.selectActions(query).get(0).getActionId()); verify(mapper).selectDataRevisionActions(query); } @Test void rejectsUnknownDataTypeAndOperation() { query.setDataType("CELL"); assertThrows(ServiceException.class, () -> service.selectActions(query)); query.setDataType("CYCLE"); query.setOperationType("RESTORE"); assertThrows(ServiceException.class, () -> service.selectActions(query)); } @Test void returnsReadOnlyActionDetail() { TTestDataRevisionAction action = new TTestDataRevisionAction(); action.setActionId(99L); action.setOperationType("DELETE"); when(mapper.selectDataRevisionActionById(99L)).thenReturn(action); assertEquals("DELETE", service.selectActionById(99L).getOperationType()); assertThrows(ServiceException.class, () -> service.selectActionById(0L)); } @Test void rejectsIdentitySummaryThatDoesNotMatchOverrides() { TTestDataRevisionAction action = new TTestDataRevisionAction(); action.setTestId(26L); action.setDataType("REALTIME"); action.setOperationType("DELETE"); action.setScopeSummary("删除一条实时数据"); action.setIdentitySummary("[\"record-1\"]"); action.setReason("异常点"); action.setOperatorName("tester"); TTestDataOverride override = new TTestDataOverride(); override.setStableIdentity("record-2"); override.setDeleted(true); assertThrows(ServiceException.class, () -> service.submitAction(action, List.of(override), List.of(), List.of())); } @Test void rejectsIdentitySummaryWithExtraIdentity() { TTestDataRevisionAction action = new TTestDataRevisionAction(); action.setTestId(26L); action.setDataType("STEP"); action.setOperationType("DELETE"); action.setScopeSummary("删除一条工步数据"); action.setIdentitySummary("[\"step:1:2:3:4\",\"step:1:2:3:5\"]"); action.setReason("异常工步"); action.setOperatorName("tester"); TTestDataOverride override = new TTestDataOverride(); override.setStableIdentity("step:1:2:3:4"); override.setDeleted(true); assertThrows(ServiceException.class, () -> service.submitAction(action, List.of(override), List.of(), List.of())); } @Test void physicallyDeletesSamplesBehindStepAggregate() { TTestDataRevisionAction action = new TTestDataRevisionAction(); action.setTestId(26L); action.setDataType("STEP"); action.setOperationType("DELETE"); action.setScopeSummary("删除一条工步数据"); action.setIdentitySummary("[\"step:1:2:3:4\"]"); action.setReason("异常工步"); action.setOperatorName("tester"); TTestDataOverride override = new TTestDataOverride(); override.setStableIdentity("step:1:2:3:4"); override.setDeleted(true); when(mapper.selectBtsSampleIdsByDeviceChannelStep(26L, 1L, 2L, 3, 4)) .thenReturn(List.of(101L, 102L)); service.submitAction(action, List.of(override), List.of(), List.of()); verify(mapper).deleteBtsMeasurementsBySampleIds(List.of(101L, 102L)); verify(mapper).deleteBtsSamplesByIds(26L, List.of(101L, 102L)); } } ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestDataRevisionAction.java
@@ -2,6 +2,9 @@ import java.io.Serializable; import java.util.Date; import org.springframework.format.annotation.DateTimeFormat; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonFormat; /** 一次控制台数据编辑、导入或删除行为。 */ public class TTestDataRevisionAction implements Serializable @@ -17,7 +20,18 @@ private String reason; private Long operatorId; private String operatorName; @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX", timezone = "UTC") private Date createdAtUtc; /** 查询展示字段:来源测试名称。 */ private String testName; /** 返回测试控制台时使用的后端接收状态。 */ private String testReceptionStatus; @JsonIgnore @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) private Date beginCreatedAt; @JsonIgnore @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) private Date endCreatedAt; public Long getActionId() { return actionId; } public void setActionId(Long value) { actionId = value; } @@ -41,4 +55,12 @@ public void setOperatorName(String value) { operatorName = value; } public Date getCreatedAtUtc() { return createdAtUtc; } public void setCreatedAtUtc(Date value) { createdAtUtc = value; } public String getTestName() { return testName; } public void setTestName(String value) { testName = value; } public String getTestReceptionStatus() { return testReceptionStatus; } public void setTestReceptionStatus(String value) { testReceptionStatus = value; } public Date getBeginCreatedAt() { return beginCreatedAt; } public void setBeginCreatedAt(Date value) { beginCreatedAt = value; } public Date getEndCreatedAt() { return endCreatedAt; } public void setEndCreatedAt(Date value) { endCreatedAt = value; } } ruoyi-system/src/main/java/com/ruoyi/system/mapper/TTestMapper.java
@@ -192,14 +192,23 @@ int countOpenExceptions(Long testId); int insertDataRevisionAction(TTestDataRevisionAction action); List<TTestDataRevisionAction> selectDataRevisionActions(TTestDataRevisionAction query); TTestDataRevisionAction selectDataRevisionActionById(Long actionId); int upsertDataOverride(TTestDataOverride override); List<TTestDataOverride> selectDataOverrides(@Param("testId") Long testId, @Param("dataType") String dataType); int deleteBtsSamplesByIds(@Param("testId") Long testId, @Param("sampleIds") List<Long> sampleIds); int deleteBtsMeasurementsBySampleIds(@Param("sampleIds") List<Long> sampleIds); int deleteCollectorSamplesByIds(@Param("testId") Long testId, @Param("sampleIds") List<Long> sampleIds); Long selectBtsSampleIdByChannelSequence(@Param("testId") Long testId, @Param("channelId") Long channelId, @Param("sequenceId") Long sequenceId); Long selectBtsSampleIdByDeviceChannelSequence(@Param("testId") Long testId, @Param("deviceId") Long deviceId, @Param("channelNo") Long channelNo, @Param("sequenceId") Long sequenceId); List<Long> selectBtsSampleIdsByDeviceChannelStep(@Param("testId") Long testId, @Param("deviceId") Long deviceId, @Param("channelNo") Long channelNo, @Param("cycleNo") Integer cycleNo, @Param("stepNo") Integer stepNo); List<Long> selectBtsSampleIdsByDeviceChannelCycle(@Param("testId") Long testId, @Param("deviceId") Long deviceId, @Param("channelNo") Long channelNo, @Param("cycleNo") Integer cycleNo); int insertConsoleImportRecord(TTestConsoleImportRecord record); int countTestBatteryNo(@Param("testId") Long testId, @Param("batteryNo") String batteryNo); } ruoyi-system/src/main/java/com/ruoyi/system/service/ITestConsoleDataService.java
@@ -7,6 +7,12 @@ /** 控制台三类数据的修订、导入和物理删除服务。 */ public interface ITestConsoleDataService { /** 按测试、数据类型、操作类型和时间查询一次用户行为。 */ List<TTestDataRevisionAction> selectActions(TTestDataRevisionAction query); /** 返回一条只读修订行为,不恢复被删除的数据。 */ TTestDataRevisionAction selectActionById(Long actionId); List<TTestDataOverride> selectOverrides(Long testId, String dataType); /** 在一个事务中写入一次行为、覆盖状态并执行请求的物理删除。 */ ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestConsoleDataServiceImpl.java
@@ -28,6 +28,24 @@ private TTestMapper mapper; @Override public List<TTestDataRevisionAction> selectActions(TTestDataRevisionAction query) { if (query == null) query = new TTestDataRevisionAction(); if (query.getDataType() != null && !query.getDataType().isBlank() && !DATA_TYPES.contains(query.getDataType())) throw new ServiceException("控制台数据类型无效"); if (query.getOperationType() != null && !query.getOperationType().isBlank() && !OPERATIONS.contains(query.getOperationType())) throw new ServiceException("数据操作类型无效"); return mapper.selectDataRevisionActions(query); } @Override public TTestDataRevisionAction selectActionById(Long actionId) { if (actionId == null || actionId < 1) throw new ServiceException("修订行为ID无效"); return mapper.selectDataRevisionActionById(actionId); } @Override public List<TTestDataOverride> selectOverrides(Long testId, String dataType) { validateType(testId, dataType); @@ -50,6 +68,7 @@ } if ("EDIT".equals(action.getOperationType())) validateEditableFields(action); if (action.getReason() == null || action.getReason().trim().isEmpty()) throw new ServiceException("数据操作原因不能为空"); validateActionSummaries(action, overrides); if (action.getOperatorName() == null || action.getOperatorName().trim().isEmpty()) action.setOperatorName("未知用户"); action.setReason(action.getReason().trim()); action.setCreatedAtUtc(new Date()); @@ -95,18 +114,31 @@ mapper.upsertDataOverride(item); } } if ("DELETE".equals(action.getOperationType()) && "REALTIME".equals(action.getDataType()) && overrides != null) if ("DELETE".equals(action.getOperationType()) && overrides != null) { List<Long> resolvedIds = new ArrayList<>(); for (TTestDataOverride item : overrides) { Long[] identity = parseRealtimeIdentity(item.getStableIdentity()); if (identity == null) continue; Long sampleId = mapper.selectBtsSampleIdByChannelSequence(action.getTestId(), identity[0], identity[1]); Long[] identity = parseDeleteIdentity(item.getStableIdentity(), action.getDataType()); if (identity == null) throw new ServiceException("删除数据稳定身份无效"); switch (action.getDataType()) { case "REALTIME" -> { Long sampleId = mapper.selectBtsSampleIdByDeviceChannelSequence(action.getTestId(), identity[0], identity[1], identity[2]); if (sampleId != null) resolvedIds.add(sampleId); } case "STEP" -> resolvedIds.addAll(mapper.selectBtsSampleIdsByDeviceChannelStep(action.getTestId(), identity[0], identity[1], identity[2].intValue(), identity[3].intValue())); case "CYCLE" -> resolvedIds.addAll(mapper.selectBtsSampleIdsByDeviceChannelCycle(action.getTestId(), identity[0], identity[1], identity[2].intValue())); default -> throw new ServiceException("控制台数据类型无效"); } } if (!resolvedIds.isEmpty()) { resolvedIds = new ArrayList<>(new HashSet<>(resolvedIds)); // 工步和循环是采样聚合视图,物理删除需在同一事务内清理对应底层采样。 mapper.deleteBtsMeasurementsBySampleIds(resolvedIds); mapper.deleteBtsSamplesByIds(action.getTestId(), resolvedIds); } @@ -175,6 +207,43 @@ catch (Exception exception) { throw new ServiceException("修改字段格式无效"); } } /** 防止客户端提交与实际覆盖身份不一致的伪行为摘要。 */ private void validateActionSummaries(TTestDataRevisionAction action, List<TTestDataOverride> overrides) { if (action.getScopeSummary() == null || action.getScopeSummary().trim().isEmpty()) throw new ServiceException("数据操作范围不能为空"); Set<String> summaryIdentities = new HashSet<>(); try { Object parsed = JSON.parse(action.getIdentitySummary()); if (!(parsed instanceof List<?> values)) throw new ServiceException("身份摘要格式无效"); for (Object value : values) if (value == null || !summaryIdentities.add(String.valueOf(value))) throw new ServiceException("身份摘要存在重复或空值"); } catch (ServiceException exception) { throw exception; } catch (Exception exception) { throw new ServiceException("身份摘要格式无效"); } if (overrides == null) return; Set<String> overrideIdentities = new HashSet<>(); for (TTestDataOverride item : overrides) if (item == null || !overrideIdentities.add(item.getStableIdentity()) || !summaryIdentities.contains(item.getStableIdentity())) throw new ServiceException("身份摘要与数据覆盖明细不一致"); if (!summaryIdentities.equals(overrideIdentities)) throw new ServiceException("身份摘要与数据覆盖明细不一致"); if ("EDIT".equals(action.getOperationType())) { try { Object parsed = JSON.parse(action.getChangesSummary()); if (!(parsed instanceof List<?> changes) || changes.size() < overrides.size()) throw new ServiceException("字段变化摘要与数据覆盖明细不一致"); } catch (ServiceException exception) { throw exception; } catch (Exception exception) { throw new ServiceException("字段变化摘要格式无效"); } } } private Set<String> editableFields(String dataType) { return switch (dataType) @@ -188,11 +257,20 @@ }; } private Long[] parseRealtimeIdentity(String value) private Long[] parseDeleteIdentity(String value, String dataType) { String[] parts = value.split(":", -1); if (parts.length != 3) return null; try { return new Long[] { Long.valueOf(parts[0]), Long.valueOf(parts[2]) }; } int expectedLength = "STEP".equals(dataType) ? 5 : "CYCLE".equals(dataType) ? 4 : 3; if (parts.length != expectedLength) return null; int offset = "REALTIME".equals(dataType) ? 0 : 1; try { if ("STEP".equals(dataType)) return new Long[] { Long.valueOf(parts[offset]), Long.valueOf(parts[offset + 1]), Long.valueOf(parts[offset + 2]), Long.valueOf(parts[offset + 3]) }; return new Long[] { Long.valueOf(parts[offset]), Long.valueOf(parts[offset + 1]), Long.valueOf(parts[offset + 2]) }; } catch (NumberFormatException exception) { return null; } } ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
@@ -227,6 +227,42 @@ (#{testName}, #{creatorId}, #{principalId}, #{testTypeId}, #{testPurpose}, #{testStatus}, #{receptionStatus}, #{monitorTimeoutMinutes}, 0, #{createBy}, #{createTime}, #{remark}) </insert> <resultMap id="TTestDataRevisionActionResultMap" type="com.ruoyi.system.domain.TTestDataRevisionAction"> <id property="actionId" column="action_id"/> <result property="testId" column="test_id"/><result property="testName" column="test_name"/> <result property="testReceptionStatus" column="reception_status"/> <result property="dataType" column="data_type"/><result property="operationType" column="operation_type"/> <result property="scopeSummary" column="scope_summary"/><result property="identitySummary" column="identity_summary"/> <result property="changesSummary" column="changes_summary"/><result property="reason" column="reason"/> <result property="operatorId" column="operator_id"/><result property="operatorName" column="operator_name"/> <result property="createdAtUtc" column="created_at_utc"/> </resultMap> <select id="selectDataRevisionActions" parameterType="com.ruoyi.system.domain.TTestDataRevisionAction" resultMap="TTestDataRevisionActionResultMap"> select a.action_id, a.test_id, t.test_name, t.reception_status, a.data_type, a.operation_type, a.scope_summary, cast(a.identity_summary as char) identity_summary, cast(a.changes_summary as char) changes_summary, a.reason, a.operator_id, a.operator_name, a.created_at_utc from t_test_data_revision_action a join t_test t on t.test_id = a.test_id <where> <if test="testId != null">and a.test_id = #{testId}</if> <if test="testName != null and testName != ''">and t.test_name like concat('%', #{testName}, '%')</if> <if test="dataType != null and dataType != ''">and a.data_type = #{dataType}</if> <if test="operationType != null and operationType != ''">and a.operation_type = #{operationType}</if> <if test="beginCreatedAt != null">and a.created_at_utc >= #{beginCreatedAt}</if> <if test="endCreatedAt != null">and a.created_at_utc < #{endCreatedAt}</if> </where> order by a.created_at_utc desc, a.action_id desc </select> <select id="selectDataRevisionActionById" parameterType="long" resultMap="TTestDataRevisionActionResultMap"> select a.action_id, a.test_id, t.test_name, t.reception_status, a.data_type, a.operation_type, a.scope_summary, cast(a.identity_summary as char) identity_summary, cast(a.changes_summary as char) changes_summary, a.reason, a.operator_id, a.operator_name, a.created_at_utc from t_test_data_revision_action a join t_test t on t.test_id = a.test_id where a.action_id = #{actionId} </select> <update id="updateTest" parameterType="TTest"> update t_test @@ -822,11 +858,29 @@ delete from t_test_collector_sample where test_id = #{testId} and sample_id in <foreach collection="sampleIds" item="id" open="(" separator="," close=")">#{id}</foreach> </delete> <select id="selectBtsSampleIdByChannelSequence" resultType="long"> select sample_id from t_test_sample where test_id = #{testId} and channel_id = #{channelId} and seq_id = #{sequenceId} <select id="selectBtsSampleIdByDeviceChannelSequence" resultType="long"> select sample.sample_id from t_test_sample sample join t_detection_channel channel_fact on channel_fact.channel_id = sample.channel_id where sample.test_id = #{testId} and channel_fact.devid = #{deviceId} and channel_fact.chlid = #{channelNo} and sample.seq_id = #{sequenceId} limit 1 </select> <select id="selectBtsSampleIdsByDeviceChannelStep" resultType="long"> select sample.sample_id from t_test_sample sample join t_detection_channel channel_fact on channel_fact.channel_id = sample.channel_id where sample.test_id = #{testId} and channel_fact.devid = #{deviceId} and channel_fact.chlid = #{channelNo} and sample.cycle_no = #{cycleNo} and sample.step_no = #{stepNo} </select> <select id="selectBtsSampleIdsByDeviceChannelCycle" resultType="long"> select sample.sample_id from t_test_sample sample join t_detection_channel channel_fact on channel_fact.channel_id = sample.channel_id where sample.test_id = #{testId} and channel_fact.devid = #{deviceId} and channel_fact.chlid = #{channelNo} and sample.cycle_no = #{cycleNo} </select> <insert id="insertConsoleImportRecord" parameterType="com.ruoyi.system.domain.TTestConsoleImportRecord" useGeneratedKeys="true" keyProperty="importRecordId"> insert into t_test_console_import_record